Nghiên cứu điển hình – Câu chuyện về một trò chơi HTML5 có âm thanh trên web

Vận động viên điền kinh

Ảnh chụp màn hình Therunners
Ảnh chụp màn hình củaFieldrunners

fieldrunners là một trò chơi phong cách thủ thành từng đạt giải thưởng, được phát hành lần đầu cho iPhone vào năm 2008. Kể từ đó, miền đã được chuyển sang nhiều nền tảng khác. Một trong những nền tảng gần đây nhất là trình duyệt Chrome vào tháng 10 năm 2011. Một trong những thách thức của việc chuyểnfieldrunners sang nền tảng HTML5 là cách phát âm thanh.

Trường đua không sử dụng hiệu ứng âm thanh phức tạp, nhưng có một số kỳ vọng về cách ứng dụng có thể tương tác với hiệu ứng âm thanh. Trò chơi có 88 hiệu ứng âm thanh, trong đó có thể cho phép nhiều người chơi cùng một lúc. Hầu hết các âm thanh này rất ngắn và cần được phát kịp thời nhất có thể để tránh tạo ra bất kỳ sự ngắt kết nối nào với bản trình bày đồ hoạ.

Đã xuất hiện một số thử thách

Trong khi chuyển Trường chạy sang HTML5, chúng tôi đã gặp sự cố với việc phát âm thanh bằng thẻ Âm thanh và ngay từ đầu đã quyết định tập trung vào API Âm thanh web. Sử dụng WebAudio đã giúp chúng tôi giải quyết các vấn đề như cung cấp cho chúng tôi số lượng lớn các hiệu ứng đồng thời phát lại màfieldrunners yêu cầu. Tuy nhiên, trong khi phát triển một hệ thống âm thanh cho Productrunners HTML5, chúng tôi đã gặp phải một vài vấn đề chi tiết mà các nhà phát triển khác có thể muốn biết.

Bản chất của AudioBufferSourceNodes

AudioBufferSourceNodes là phương thức chính để phát âm thanh bằng WebAudio. Bạn cần hiểu rằng đây là đối tượng dùng một lần. Bạn tạo một AudioBufferSourceNode, chỉ định cho nó một vùng đệm, kết nối nó với biểu đồ và phát bằng NoteOn hoặc NoteGrainOn. Sau đó, bạn có thể gọi NoteOff để dừng phát, nhưng không thể phát lại nguồn bằng cách gọi NoteOn hoặc NoteGrainOn - bạn phải tạo một AudioBufferSourceNode khác. Tuy nhiên, bạn có thể sử dụng lại cùng một đối tượng AudioBuffer cơ bản (trên thực tế, thậm chí bạn có thể có nhiều AudioBufferSourceNodes đang hoạt động trỏ đến cùng một phiên bản AudioBuffer!). Bạn có thể tìm thấy một đoạn trích của Trường runners trong phần Cho tôi nghe giai điệu.

Nội dung không được lưu vào bộ nhớ đệm

Khi phát hành, máy chủ HTML5 củafieldrunners cho thấy một số lượng lớn yêu cầu đối với các tệp nhạc. Kết quả này phát sinh từ việc Chrome 15 tiếp tục tải tệp xuống theo từng phần, sau đó không lưu tệp vào bộ nhớ đệm. Tại thời điểm đó, chúng tôi đã quyết định tải các tệp nhạc giống như các tệp âm thanh còn lại. Làm như vậy là chưa tối ưu, nhưng một số phiên bản của các trình duyệt khác vẫn làm như vậy.

Tắt tiếng khi không rõ nét

Trước đây, rất khó phát hiện thời điểm thẻ của trò chơi nằm ngoài tiêu điểm. fieldrunners bắt đầu chuyển trước Chrome 13, trong đó API hiển thị trang thay thế nhu cầu sử dụng mã tích hợp của chúng tôi để phát hiện việc làm mờ thẻ. Mỗi trò chơi nên sử dụng API Visibility để viết một đoạn trích nhỏ nhằm tắt tiếng hoặc tạm dừng âm thanh nếu không tạm dừng toàn bộ trò chơi. Vì Trườngrunners sử dụng API requestAnimationFrame, nên việc tạm dừng trò chơi đã được xử lý ngầm, chứ không phải việc tạm dừng âm thanh.

Âm thanh tạm dừng

Thật kỳ lạ, khi nhận được phản hồi về bài viết này, chúng tôi được thông báo rằng kỹ thuật chúng tôi dùng để tạm dừng âm thanh không phù hợp. Chúng tôi đã sử dụng một lỗi trong cách triển khai hiện tại của Âm thanh web để tạm dừng việc phát âm thanh. Vì vấn đề này sẽ được khắc phục trong tương lai, nên bạn không thể chỉ tạm dừng âm thanh bằng cách ngắt kết nối một nút hoặc đồ thị con để tạm dừng phát.

Cấu trúc nút âm thanh web đơn giản

fieldrunners có mô hình âm thanh rất đơn giản. Mô hình đó có thể hỗ trợ bộ tính năng sau đây:

  • Kiểm soát âm lượng của hiệu ứng âm thanh.
  • Điều chỉnh âm lượng của bản nhạc nền.
  • Tắt tất cả âm thanh.
  • Tắt âm thanh phát khi trò chơi tạm dừng.
  • Bật lại chính những âm thanh đó khi trò chơi tiếp tục.
  • Tắt tất cả âm thanh khi thẻ của trò chơi mất tiêu điểm.
  • Khởi động lại quá trình phát sau khi phát âm thanh nếu cần.

Để có các tính năng trên bằng Web Audio, Web đã sử dụng 3 trong số các nút được cung cấp có thể là: DestinationNode, GainNode, AudioBufferSourceNode. AudioBufferSourceNodes phát âm thanh. GainNodes kết nối AudioBufferSourceNodes với nhau. Đích đến do ngữ cảnh Âm thanh trên web tạo ra (được gọi là đích đến) sẽ phát âm thanh cho trình phát. Âm thanh web có nhiều loại nút hơn nhưng chỉ với những nút này, chúng ta có thể tạo một biểu đồ rất đơn giản cho âm thanh trong trò chơi.

Biểu đồ biểu đồ nút

Biểu đồ nút Web Audio dẫn từ các nút lá đến nút đích. Trường chạy trường đã sử dụng 6 nút tăng lợi ích vĩnh viễn, nhưng 3 nút là đủ để cho phép dễ dàng kiểm soát âm lượng và kết nối một số lượng lớn hơn các nút tạm thời sẽ phát lại vùng đệm. Trước tiên, một nút thu được chính gắn mọi nút con vào đích đến. Hai nút độ lợi ích nằm ngay cạnh nút chính, một nút dành cho kênh âm nhạc và nút còn lại để liên kết tất cả hiệu ứng âm thanh.

Therunners có thêm 3 nút tăng do sử dụng lỗi không chính xác làm tính năng. Chúng tôi đã sử dụng các nút đó để cắt bớt các nhóm âm thanh đang phát khỏi biểu đồ làm dừng tiến trình của các âm thanh đó. Chúng tôi thực hiện việc này để tạm dừng âm thanh. Do điều này không chính xác nên giờ đây chúng ta chỉ sử dụng 3 nút tăng tổng cộng như mô tả ở trên. Nhiều đoạn mã sau đây có chứa các nút không chính xác, cho biết những việc chúng tôi đã làm và cách chúng tôi khắc phục vấn đề đó trong thời gian ngắn. Nhưng về lâu dài, bạn sẽ không muốn sử dụng các nút sau nút coreEffectsGain.

function AudioManager() {
  // map for loaded sounds
  this.sounds = {};

  // create our permanent nodes
  this.nodes = {
    destination: this.audioContext.destination,
    masterGain: this.audioContext.createGain(),

    backgroundMusicGain: this.audioContext.createGain(),

    coreEffectsGain: this.audioContext.createGain(),
    effectsGain: this.audioContext.createGain(),
    pausedEffectsGain: this.audioContext.createGain()
  };

  // and setup the graph
  this.nodes.masterGain.connect( this.nodes.destination );

  this.nodes.backgroundMusicGain.connect( this.nodes.masterGain );

  this.nodes.coreEffectsGain.connect( this.nodes.masterGain );
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
  this.nodes.pausedEffectsGain.connect( this.nodes.coreEffectsGain );
}

Hầu hết các trò chơi đều cho phép kiểm soát riêng hiệu ứng âm thanh và âm nhạc. Điều này có thể dễ dàng hoàn thành với biểu đồ ở trên của chúng ta. Mỗi nút tăng giá trị có một thuộc tính "tăng" có thể được thiết lập thành bất kỳ giá trị thập phân nào từ 0 đến 1. Giá trị này có thể dùng để kiểm soát âm lượng về cơ bản. Vì muốn kiểm soát riêng âm lượng của kênh âm nhạc và hiệu ứng âm thanh, chúng ta có một nút tăng âm lượng cho mỗi kênh mà chúng ta có thể điều khiển âm lượng.

function setArbitraryVolume() {
  var musicGainNode = this.nodes.backgroundMusicGain;

  // set music volume to 50%
  musicGainNode.gain.value = 0.5;
}

Chúng ta có thể sử dụng cùng một khả năng này để kiểm soát âm lượng của mọi nội dung, của hiệu ứng âm thanh và âm nhạc. Việc đặt mức tăng âm của nút chính sẽ ảnh hưởng đến tất cả âm thanh của trò chơi. Nếu đặt giá trị khuếch đại thành 0, bạn sẽ tắt âm thanh và nhạc. AudioBufferSourceNodes cũng có tham số mức tăng. Bạn có thể theo dõi danh sách tất cả âm thanh đang phát và điều chỉnh riêng giá trị tăng âm của chúng cho âm lượng tổng thể. Nếu bạn tạo hiệu ứng âm thanh bằng thẻ Âm thanh, đây là việc bạn phải làm. Thay vào đó, biểu đồ nút của Âm thanh web giúp bạn sửa đổi âm lượng của vô số âm thanh một cách dễ dàng hơn nhiều. Việc điều chỉnh âm lượng theo cách này cũng cung cấp cho bạn thêm năng lượng mà không phức tạp. Chúng ta có thể chỉ cần đính kèm trực tiếp AudioBufferSourceNode vào nút chính để phát nhạc và điều khiển mức tăng âm của riêng nó. Nhưng bạn sẽ phải đặt giá trị này mỗi khi tạo AudioBufferSourceNode cho mục đích phát nhạc. Thay vào đó, bạn chỉ thay đổi một nút khi trình phát thay đổi âm lượng nhạc và khi khởi chạy. Bây giờ, chúng ta đã có giá trị nhận được trên các nguồn bộ đệm để làm việc khác. Đối với âm nhạc, một cách sử dụng phổ biến có thể là để chuyển dần từ bản âm thanh này sang bản âm thanh khác khi bản âm thanh này xuất hiện và xuất hiện. Âm thanh web cung cấp một phương pháp hay để dễ dàng thực hiện việc này.

function arbitraryCrossfade( track1, track2 ) {
  track1.gain.linearRampToValueAtTime( 0, 1 );
  track2.gain.linearRampToValueAtTime( 1, 1 );
}

Therunners không sử dụng cụ thể tính năng chuyển đổi. Nếu chúng tôi biết chức năng cài đặt giá trị của WebAudio trong lần chuyển hệ thống âm thanh ban đầu thì có lẽ chúng tôi sẽ có.

Âm thanh tạm dừng

Khi tạm dừng một trò chơi, người chơi có thể nghe thấy một số âm thanh vẫn phát. Âm thanh là một phần quan trọng trong phản hồi cho thao tác nhấn phổ biến các thành phần trên giao diện người dùng trong trình đơn trò chơi. Vìfieldrunners có một số giao diện để người dùng tương tác trong khi trò chơi bị tạm dừng, nên chúng ta vẫn muốn những người chơi đó đang chơi. Tuy nhiên, chúng tôi không muốn tiếp tục phát âm thanh dài hoặc lặp lại. Việc dừng những âm thanh đó khá dễ dàng hoặc ít nhất là theo chúng tôi.

AudioManager.prototype.pauseEffects = function() {
  this.nodes.effectsGain.disconnect();
}

Nút hiệu ứng bị tạm dừng vẫn đang kết nối. Mọi âm thanh được phép bỏ qua trạng thái tạm dừng của trò chơi sẽ tiếp tục phát qua đó. Khi trò chơi tiếp tục chạy, chúng ta có thể kết nối lại các nút đó và ngay lập tức phát âm thanh trở lại.

AudioManager.prototype.resumeEffects = function() {
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
}

Sau khi chuyển Trường chạy, chúng tôi phát hiện ra rằng việc chỉ ngắt kết nối một nút hoặc đồ thị con sẽ không tạm dừng việc phát AudioBufferSourceNodes. Chúng tôi đã tận dụng một lỗi trong WebAudio hiện đang dừng phát các nút không được kết nối với nút Đích đến trong biểu đồ. Vì vậy, để đảm bảo sẵn sàng cho bản sửa lỗi trong tương lai đó, chúng ta cần một số mã như sau:

AudioManager.prototype.pauseEffects = function() {
  this.nodes.effectsGain.disconnect();

  var now = Date.now();
  for ( var name in this.sounds ) {
    var sound = this.sounds[ name ];

    if ( !sound.ignorePause && ( now - sound.source.noteOnAt < sound.buffer.duration * 1000 ) ) {
      sound.pausedAt = now - sound.source.noteOnAt;
      sound.source.noteOff();
    }
  }
}

AudioManager.prototype.resumeEffects = function() {
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );

  var now = Date.now();
  for ( var name in this.sounds ) {
    if ( sound.pausedAt ) {
      this.play( sound.name );
      delete sound.pausedAt;
    }
  }
};

Nếu chúng ta biết điều này trước đó rằng chúng ta đang lạm dụng lỗi, cấu trúc của mã âm thanh sẽ rất khác. Do đó, điều này đã ảnh hưởng đến một số mục của bài viết này. Nó có tác động trực tiếp ở đây mà còn trong các đoạn mã của chúng ta trong phim Mất tập trung và Cho tôi giai điệu. Để biết cách thức hoạt động của chức năng này, bạn cần phải thay đổi cả biểu đồ nút (vì chúng tôi đã tạo các nút để rút ngắn thời gian phát) và mã bổ sung sẽ ghi lại và cung cấp trạng thái tạm dừng mà Âm thanh web không tự thực hiện.

Mất tập trung

Nút chính của chúng tôi được sử dụng cho tính năng này. Khi người dùng trình duyệt chuyển sang một thẻ khác, trò chơi đó sẽ không xuất hiện nữa. Tầm nhìn, mất trí và âm thanh cũng nên biến mất. Có nhiều thủ thuật bạn có thể thực hiện để xác định trạng thái hiển thị cụ thể cho một trang trò chơi, nhưng với API Visibility, việc này trở nên dễ dàng hơn rất nhiều.

Trường chạy sẽ chỉ phát dưới dạng thẻ đang hoạt động nhờ sử dụng requestAnimationFrame để gọi vòng lặp cập nhật. Tuy nhiên, ngữ cảnh Âm thanh trên web sẽ tiếp tục phát hiệu ứng lặp lại và bản nhạc trong nền khi người dùng đang ở một thẻ khác. Tuy nhiên, chúng ta có thể dừng điều đó bằng một đoạn mã nhận biết API Visibility rất nhỏ.

function AudioManager() {
  // map and node setup
  // ...

  // disable all sound when on other tabs
  var self = this;
  window.addEventListener( 'webkitvisibilitychange', function( e ) {
    if ( document.webkitHidden ) {
      self.nodes.masterGain.disconnect();

      // As noted in Pausing Sounds disconnecting isn't enough.
      // For Fieldrunners calling our new pauseEffects method would be
      // enough to accomplish that, though we may still need some logic
      // to not resume if already paused.
      self.pauseEffects();
    } else {
      self.nodes.masterGain.connect( this.nodes.destination );
      self.resumeEffects();
    }
  });
}

Trước khi viết bài viết này, chúng tôi cho rằng việc ngắt kết nối bản chính là đủ để tạm dừng tất cả âm thanh thay vì tắt tiếng. Bằng cách ngắt kết nối nút tại thời điểm đó, chúng tôi đã ngăn nút và các phần tử con của nút xử lý và phát. Khi thiết bị được kết nối lại, tất cả âm thanh và bản nhạc sẽ bắt đầu phát từ nơi ban đầu, đồng thời việc chơi trò chơi sẽ tiếp tục từ nơi ban đầu. Nhưng đây là hành vi ngoài dự kiến. Chỉ cần ngắt kết nối để tạm dừng phát là chưa đủ.

API Khả năng hiển thị trang giúp bạn dễ dàng biết được khi nào thẻ của mình không còn được đặt tiêu điểm. Nếu bạn đã có một mã hiệu quả để tạm dừng âm thanh, thì khi thẻ trò chơi bị ẩn, hệ thống chỉ cần vài dòng để viết ở trạng thái tạm dừng âm thanh.

Hãy để tôi nghe một chút

Chúng tôi hiện đã thiết lập một vài mục. Chúng ta có biểu đồ các nút. Chúng ta có thể tạm dừng âm thanh khi người chơi tạm dừng trò chơi và phát âm thanh mới cho các phần tử như trình đơn trò chơi. Chúng ta có thể tạm dừng tất cả âm thanh và nhạc khi người dùng chuyển sang một thẻ mới. Bây giờ, chúng ta cần phát âm thanh.

Thay vì phát nhiều bản sao âm thanh cho nhiều bản sao của một thực thể trò chơi, chẳng hạn như khi một nhân vật sắp chết, Therunners chỉ phát một âm thanh một lần trong suốt thời gian phát. Nếu cần phát âm thanh sau khi phát xong, thì thiết bị có thể khởi động lại nhưng không cần khởi động lại trong khi đang phát. Đây là quyết định đối với thiết kế âm thanh củafieldrunners vì âm thanh được yêu cầu phát nhanh chóng, nếu không sẽ bị gián đoạn nếu được phép khởi động lại hoặc tạo ra âm thanh khó chịu nếu được phép phát nhiều bản âm thanh. AudioBufferSourceNodes dự kiến sẽ được dùng một lần. Tạo một nút, đính kèm vùng đệm, đặt giá trị boolean của vòng lặp nếu cần, kết nối với một nút trên biểu đồ sẽ dẫn đến đích, gọi NoteOn hoặc NoteGrainOn và tuỳ ý gọi NoteOff.

Đối với fieldrunner, giao diện này sẽ có dạng như sau:

AudioManager.prototype.play = function( options ) {
  var now = Date.now(),
    // pull from a map of loaded audio buffers
    sound = this.sounds[ options.name ],
    channel,
    source,
    resumeSource;

  if ( !sound ) {
    return;
  }

  if ( sound.source ) {
    var source = sound.source;
    if ( !options.loop && now - source.noteOnAt > sound.buffer.duration * 1000 ) {
      // discard the previous source node
      source.stop( 0 );
      source.disconnect();
    } else {
      return;
    }
  }

  source = this.audioContext.createBufferSource();
  sound.source = source;
  // track when the source is started to know if it should still be playing
  source.noteOnAt = now;

  // help with pausing
  sound.ignorePause = !!options.ignorePause;

  if ( options.ignorePause ) {
    channel = this.nodes.pausedEffectsGain;
  } else {
    channel = this.nodes.effectsGain;
  }

  source.buffer = sound.buffer;
  source.connect( channel );
  source.loop = options.loop || false;

  // Fieldrunners' current code doesn't consider sound.pausedAt.
  // This is an added section to assist the new pausing code.
  if ( sound.pausedAt ) {
    source.start( ( sound.buffer.duration * 1000 - sound.pausedAt ) / 1000 );
    source.noteOnAt = now + sound.buffer.duration * 1000 - sound.pausedAt;

    // if you needed to precisely stop sounds, you'd want to store this
    resumeSource = this.audioContext.createBufferSource();
    resumeSource.buffer = sound.buffer;
    resumeSource.connect( channel );
    resumeSource.start(
      0,
      sound.pausedAt,
      sound.buffer.duration - sound.pausedAt / 1000
    );
  } else {
    // start play immediately with a value of 0 or less
    source.start( 0 );
  }
}

Phát trực tuyến quá nhiều

Trường chạy ban đầu được ra mắt với nhạc nền được phát bằng thẻ Âm thanh. Khi phát hành, chúng tôi nhận thấy các tệp âm nhạc được yêu cầu với số lần không cân đối với số lần yêu cầu cho phần nội dung còn lại của trò chơi. Sau khi nghiên cứu, chúng tôi phát hiện ra rằng tại thời điểm đó, trình duyệt Chrome không lưu các đoạn được phát trực tuyến của tệp nhạc vào bộ nhớ đệm. Do đó, trình duyệt sẽ yêu cầu bản nhạc phát vài phút một lần sau khi hoàn tất. Trong lần thử nghiệm gần đây hơn, Chrome đã lưu các bản nhạc phát trực tuyến vào bộ nhớ đệm, tuy nhiên, các trình duyệt khác có thể chưa làm được việc này. Truyền trực tuyến các tệp âm thanh lớn có thẻ Âm thanh cho chức năng như phát nhạc là cách tối ưu, nhưng đối với một số phiên bản trình duyệt, bạn có thể tải nhạc giống như cách tải hiệu ứng âm thanh.

Vì tất cả các hiệu ứng âm thanh đều được phát qua Âm thanh trên web, chúng tôi cũng chuyển việc phát nhạc nền sang Âm thanh trên web. Điều này có nghĩa là chúng ta sẽ tải các bản nhạc giống như cách tải tất cả hiệu ứng bằng XMLHttpRequests và loại phản hồi vùng đệm mảng.

AudioManager.prototype.load = function( options ) {
  var xhr,
      // pull from a map of name, object pairs
      sound = this.sounds[ options.name ];

  if ( sound ) {
    // this is a great spot to add success methods to a list or use promises
    // for handling the load event or call success if already loaded
    if ( sound.buffer && options.success ) {
      options.success( options.name );
    } else if ( options.success ) {
      sound.success.push( options.success );
    }

    // one buffer is enough so shortcut here
    return;
  }

  sound = {
    name: options.name,
    buffer: null,
    source: null,
    success: ( options.success ? [ options.success ] : [] )
  };
  this.sounds[ options.name ] = sound;

  xhr = new XMLHttpRequest();
  xhr.open( 'GET', options.path, true );
  xhr.responseType = 'arraybuffer';
  xhr.onload = function( e ) {
    sound.buffer = self._context.createBuffer( xhr.response, false );

    // call all waiting handlers
    sound.success.forEach( function( success ) {
      success( sound.name );
    });
    delete sound.success;
  };
  xhr.onerror = function( e ) {

    // failures are uncommon but you want to do deal with them

  };
  xhr.send();
}

Tóm tắt

fieldrunners là một bước đột phá khi mang đến cho Chrome và HTML5. Bên ngoài ngọn núi làm việc của riêng mình bằng cách đưa hàng nghìn dòng C++ vào javascript, một số tình huống nan giải và quyết định dành riêng cho việc khơi dậy HTML5. Để nhắc lại một đối tượng nếu không có đối tượng nào khác, AudioBufferSourceNodes là đối tượng sử dụng một lần. Tạo chúng, đính kèm Vùng đệm âm thanh, kết nối Vùng đệm âm thanh với biểu đồ Âm thanh web và phát bằng NoteOn hoặc NoteGrainOn. Bạn cần phát lại âm thanh đó? Sau đó, hãy tạo một AudioBufferSourceNode khác.