Skip to content

Commit

Permalink
Add Audio plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
wouterlucas committed Oct 17, 2024
1 parent c8e02da commit fcf2a0f
Show file tree
Hide file tree
Showing 5 changed files with 554 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@
- [Language](/plugins/language.md)
- [Theme](/plugins/theme.md)
- [Global App State](/plugins/global_app_state.md)
- [Audio](/plugins/audio.md)
162 changes: 162 additions & 0 deletions docs/plugins/audio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@

# Audio Plugin

The Blits Audio Plugin allows developers to integrate audio playback into their Blits application. This plugin provides a simple API for preloading, playing, controlling, and managing audio tracks, including managing volume, playback rate (pitch), and other settings.

## Registering the Plugin

The Audio Plugin is not included by default and needs to be explicitly registered before usage. This makes the plugin _tree-shakable_, meaning if audio is not required, it won't be part of the final app bundle.

To register the plugin, you should import and register it before calling the `Blits.Launch()` method, as shown in the example below:

```js
// index.js

import Blits from '@lightningjs/blits'
// import the audio plugin
import { audio } from '@lightningjs/blits/plugins'

import App from './App.js'

// Register the audio plugin with optional preload settings
Blits.Plugin(audio, {
preload: {
background: '/assets/audio/background.mp3',
jump: '/assets/audio/jump.mp3',
},
})

Blits.Launch(App, 'app', {
// launch settings
})
```

The Audio Plugin can accept an optional `preload` configuration, which allows you to preload audio files during initialization. These files are stored in an internal library for easy access during gameplay.

## Playing Audio Tracks

Once the plugin is registered, you can play audio tracks either from the preloaded library or from a URL. Here’s an example of how to use it inside a Blits Component:

```js
Blits.Component('MyComponent', {
hooks: {
ready() {
// Play a preloaded track and get a track controller
const bgMusic = this.$audio.playTrack('background', { volume: 0.5 })

// Play a track from URL and get its controller
const effect = this.$audio.playUrl('/assets/audio/victory.mp3', { volume: 0.8 }, 'victory')
},
},
})
```

The `playTrack()` method allows you to play an audio track from the preloaded library, while `playUrl()` allows you to play a track from a specified URL. Both methods return a track controller object.

### Track Controller Methods:
- `stop()`: Stops the track and removes it from the active list.
- `setVolume(volume)`: Adjusts the playback volume for the track.

### Example Usage of Track Controller:
```js
Blits.Component('MyComponent', {
hooks: {
ready() {
const bgMusic = this.$audio.playTrack('background', { volume: 0.5 }, 'bg-music')

// set volume on the track
bgMusic.setVolume(0.8)
// stop the track
bgMusic.stop()
},
},
})
```

## Removing Preloaded Audio Tracks

In some cases, you might want to remove a preloaded audio track from the library, freeing up memory or resources. You can do this using the `removeTrack()` method:

```js
Blits.Component('MyComponent', {
input: {
removeJumpTrack() {
// Remove the 'jump' track from the preloaded library
this.$audio.removeTrack('jump')
},
},
})
```

The `removeTrack(key)` method deletes the specified track from the internal `tracks` object, preventing further access to it.

## Preloading Audio Files

The most efficient way to manage audio in your app is to preload audio files. The Audio Plugin supports preloading via the `preloadTracks()` method. You can pass in an object where each key is the track name, and each value is the URL of the audio file.

```js
Blits.Component('MyComponent', {
hooks: {
init() {
this.$audio.preload({
jump: '/assets/audio/jump.mp3',
hit: '/assets/audio/hit.mp3',
})
},
},
})
```

Preloaded audio files are stored in an internal library, which you can reference when calling `playTrack()`.

## Error Handling

In cases where the `AudioContext` cannot be instantiated (e.g., due to browser limitations or disabled audio features), the Audio Plugin will automatically disable itself, preventing errors. If the `AudioContext` fails to initialize, an error message will be logged, and audio-related methods will return early without throwing additional errors.

You can check whether audio is available via the `audioEnabled` property:

```js
Blits.Component('MyComponent', {
hooks: {
ready() {
if (!this.$audio.audioEnabled) {
console.warn('Audio is disabled on this platform.')
}
},
},
})
```

This ensures that your app continues to function even if audio features are not supported or available.

## Public API

The Audio Plugin provides the following methods and properties:

- `playTrack(key, { volume, pitch }, trackId)`: Plays a preloaded audio track and returns a track controller.
- `playUrl(url, { volume, pitch }, trackId)`: Plays an audio track from a URL and returns a track controller.
- `pause()`: Pauses the current audio context.
- `resume()`: Resumes the current audio context.
- `stop(trackId)`: Stops a specific audio track by its ID.
- `stopAll()`: Stops all currently playing audio tracks.
- `setVolume(trackId, volume)`: Sets the volume for a specific track by its ID.
- `preload(tracks)`: Preloads a set of audio tracks into the internal library.
- `removeTrack(key)`: Removes a preloaded track from the library.
- `destroy()`: Destroys the audio context and stops all tracks.
- `get activeTracks` : Return an Object of Active Track Controllers currently being played
- `get audioEnabled`: Returns `true` if the `AudioContext` is available and audio is enabled.
- `get tracks` : Return an Object of preloaded Tracks

## Destroying the Plugin

When you're done with the audio functionality, you can clean up the plugin and close the `AudioContext` by calling the `destroy()` method. This is especially useful when you no longer need audio in your application:

```js
Blits.Component('MyComponent', {
hooks: {
exit() {
this.$audio.destroy()
},
},
})
```
201 changes: 201 additions & 0 deletions src/plugins/audio.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/*
* Copyright 2024 Comcast Cable Communications Management, LLC
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/

import { Log } from '../lib/log.js'

export default {
name: 'audio',
plugin(options = {}) {
let audioContext = undefined
let audioEnabled = true
let activeTracks = {} // Store all active track controllers

try {
audioContext = new AudioContext()
} catch (e) {
Log.error('AudioContext is not supported or failed to initialize. Audio will be disabled.')
audioEnabled = false
}

let tracks = {}

const loadAudioData = async (url) => {
if (audioEnabled === false) return
try {
const response = await fetch(url)

if (!response.ok) {
throw Error(`${response.status} - ${response.statusText}`)
}

const arrayBuffer = await response.arrayBuffer()
return audioContext.decodeAudioData(arrayBuffer)
} catch (e) {
Log.error(`Failed to load audio from ${url}: ${e}`)
}
}

const preloadTracks = async (trackList) => {
if (audioEnabled === false) return
for (const [key, url] of Object.entries(trackList)) {
const audioData = await loadAudioData(url)
if (audioData) {
tracks[key] = audioData
}
}
}

const createTrackController = (source, gainNode, trackId) => {
return {
stop() {
try {
source.stop()
} catch (e) {
Log.warn('Error stopping audio track', trackId)
}

delete activeTracks[trackId]
},
setVolume(volume) {
gainNode.gain.value = volume
},
get source() {
return source
},
get gainNode() {
return gainNode
},
}
}

const playAudioBuffer = (buffer, trackId, { volume = 1, pitch = 1 } = {}) => {
if (audioEnabled === false || audioContext === undefined) {
Log.warn('AudioContext not available. Cannot play audio.')
return
}

const source = audioContext.createBufferSource()
source.buffer = buffer
source.playbackRate.value = pitch

const gainNode = audioContext.createGain()
gainNode.gain.value = volume

source.connect(gainNode)
gainNode.connect(audioContext.destination)

source.onended = () => {
delete activeTracks[trackId]
Log.info(`Track ${trackId} finished playing.`)
}

// Create and store the track controller
const trackController = createTrackController(source, gainNode, trackId)
activeTracks[trackId] = trackController

source.start()

return trackController
}

const playTrack = (key, options = {}, trackId = key) => {
if (audioEnabled === false) {
Log.warn('AudioContext not available. Cannot play track.')
return
}
if (tracks[key] !== undefined) {
return playAudioBuffer(tracks[key], trackId, options)
} else {
Log.warn(`Track ${key} not found in the library.`)
}
}

const playUrl = async (url, options = {}, trackId = url) => {
if (audioEnabled === false) return
const audioData = await loadAudioData(url)
if (audioData !== undefined) {
return playAudioBuffer(audioData, trackId, options)
}
}

const stop = (trackId) => {
if (audioEnabled === false || activeTracks[trackId] === undefined) return
activeTracks[trackId].stop()
}

const stopAll = () => {
if (audioEnabled === false) return
while (Object.keys(activeTracks).length > 0) {
const trackId = Object.keys(activeTracks)[0]
stop(trackId)
}
}

const removeTrack = (key) => {
if (tracks[key] !== undefined) {
// stop if the track happens to be active as well
if (activeTracks[key] !== undefined) {
activeTracks[key].stop()
}

delete tracks[key]
Log.info(`Track ${key} removed from the preloaded library.`)
} else {
Log.warn(`Track ${key} not found in the library.`)
}
}

const destroy = () => {
if (audioEnabled === false) return
stopAll() // Stop all active tracks before destroying
audioContext.close()
}

if (options.preload === true && audioEnabled === true) {
preloadTracks(options.preload)
}

// Public API for the Audio Plugin
return {
get audioEnabled() {
return audioEnabled
},
get activeTracks() {
return activeTracks
},
get tracks() {
return tracks
},
get state() {
return audioContext.state
},
destroy, // Destroy the audio context and stop all tracks
pause() {
return audioContext.suspend()
},
playTrack, // Play a preloaded track by its key and return the track controller
playUrl, // Play a track directly from a URL and return the track controller
preload: preloadTracks, // Preload a set of audio tracks
resume() {
return audioContext.resume()
},
removeTrack, // Remove a track from the preloaded library
stop, // Stop a specific track by its ID
stopAll, // Stop all active tracks
}
},
}
Loading

0 comments on commit fcf2a0f

Please sign in to comment.