Audio Worklet Stream Library
This library provides a way to work with audio worklets and streams using modern web technologies. It allows for the manual writing of audio frames to a buffer and supports various buffer writing strategies.
This library was created for use in my project fbdplay_wasm. In this project, we utilize only a very limited set of WebAudio functionalities. It might lack features for general use.
Features
- Manual Buffer Writing: Provides the ability to manually write audio frames to a buffer.
- Multiple Buffer Writing Strategies: Includes support for manual, timer-based, and worker-based buffer writing.
- Worker-Based Stability: Utilizes Workers to ensure stable and consistent audio playback, reducing the impact of UI thread throttling.
- Vite Integration: Leverages Vite for easy worker loading and configuration without complex setup.
- Audio Worklet Integration: Seamlessly integrates with the Web Audio API's Audio Worklet for real-time audio processing.
- Optimized Performance: Designed for efficient real-time audio processing with batch frame handling.
Browser Compatibility
Prerequisites
- Node.js and npm: Make sure you have Node.js (version 20 or higher) and npm installed. This library hasn't been tested on versions below 20.
- Vite: This library uses Vite as the bundler for its simplicity in loading and configuring workers.
Installation
To install the library, run:
npm install @ain1084/audio-worklet-stream
@ain1084/audio-worklet-stream
to the optimizeDeps.exclude section in vite.config.ts
. Furthermore, include the necessary COOP (Cross-Origin Opener Policy) and COEP (Cross-Origin Embedder Policy) settings to enable the use of SharedArrayBuffer
.
### vite.config.ts
typescript
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: {
exclude: ['@ain1084/audio-worklet-stream']
},
plugins: [
{
name: 'configure-response-headers',
configureServer: (server) => {
server.middlewares.use((_req, res, next) => {
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
next()
})
},
},
],
})
If you are using Nuxt3, add it under vite in nuxt.config.ts
.
### nuxt.config.ts
typescript
export default defineNuxtConfig({
vite: {
optimizeDeps: {
exclude: ['@ain1084/audio-worklet-stream']
},
plugins: [
{
name: 'configure-response-headers',
configureServer: (server) => {
server.middlewares.use((_req, res, next) => {
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
next()
})
},
},
],
},
nitro: {
rollupConfig: {
external: '@ain1084/audio-worklet-stream',
},
routeRules: {
'/**': {
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
},
},
},
},
})
Usage
Overview
This library continuously plays audio sample frames using AudioWorkletNode. The audio sample frames need to be supplied externally via a ring buffer. The library provides functionality to retrieve the number of written and read (played) frames and allows stopping playback at a specified frame.
The output-only AudioNode is implemented by the OutputStreamNode class, which inherits from AudioWorkletNode. This class adds functionalities such as stream playback, stopping, and retrieving playback position to the AudioWorkletNode.
Instances of OutputStreamNode cannot be constructed directly. First, an instance of StreamNodeFactory needs to be created. The StreamNodeFactory is instantiated by calling its static create method with a BaseAudioContext as an argument. This method internally loads the necessary modules. Then, through the returned instance, the construction of OutputStreamNode becomes possible.
The library does not handle the construction or destruction of AudioContext. When constructing AudioContext, be sure to do so in response to a user interaction, such as a UI event (e.g., button press).
Example:
import { StreamNodeFactory, type OutputStreamNode } from '@ain1084/audio-worklet-stream'
let audioContext: AudioContext | null = null
let factory: StreamNodeFactory | null = null
const clicked = async () => {
if (!audioContext) {
audioContext = new AudioContext()
factory = await StreamNodeFactory.create(audioContext)
}
// Create manual buffer stream
const channelCount = 1
const [node, writer] = await factory.createManualBufferNode({
channelCount,
frameCount: 4096,
})
// Write frames
writer.write((segment) => {
for (let frame = 0; frame < segment.frameCount; frame++) {
for (let channel = 0; channel < segment.channels; ++channel) {
segment.set(frame, channel, /* TODO: Write sample value */)
}
}
// Return the count of written frames
return segment.frameCount
})
// Start playback
node.start()
audioContext.connect(audioContext.destination)
}
Buffer Writing Methods
As outlined in the overview, OutputStreamNode requires external audio samples. These samples must be written to a ring buffer, and there are several methods to achieve this.
Note: The diagrams are simplified for ease of understanding and may differ from the actual implementation.
Manual
- This method involves manually writing to the ring buffer. Use the
OutputStreamFactory.createManualBufferNode
method, specifying the number of channels and frames to create anOutputStreamNode
. TheFrameBufferWriter
, used for writing to the ring buffer, is also returned by this method along with theOutputStreamNode
. - When the
OutputStreamNode
is first constructed, the ring buffer is empty. You must write to the buffer before starting playback to avoid audio gaps. While the node is playing, you must continue writing to the ring buffer to prevent audio frame depletion (which would cause silence). - If the audio frames run out, the stream playback continues with the node outputting silence.
- To stop the stream playback, call the
stop()
method ofOutputStreamNode
. You can specify the frame at which to stop playback. For example, calling stop() with a frame count stops playback at that exact frame. If you want to play all the written frames, you can specify the total number of written frames, which can be obtained via theFrameBufferWriter
.
Timed
- This method writes to the ring buffer using a timer initiated on the UI thread. Create it using the
OutputStreamFactory.createTimedBufferNode()
method, specifying the number of channels, the timer interval, and theFrameBufferFiller
that supplies samples to the buffer. - Writing to the ring buffer is handled by the FrameBufferFiller. The timer periodically calls the fill method of the
FrameBufferFiller
, which supplies audio frames via theFrameBufferWriter
. - If the audio frames run out, the stream playback continues with the node outputting silence.
- If the fill method of the
FrameBufferFiller
returns false, it indicates that no more audio frames are available. OnceOutputStreamNode
outputs all the written frames, the stream automatically stops and disconnects. - Like the Manual method, you can also stop playback at any time using the
stop()
method.
Worker
- Similar to the Timed method, this method uses a timer to write to the ring buffer, but the timer runs within a Worker. This approach reduces the impact of UI thread throttling, providing more stable playback.
- Create it using the
OutputStreamFactory.createWorkerBufferNode()
method. - Writing to the ring buffer occurs within the Worker.
- While the ring buffer writing is still managed by the
FrameBufferFiller
, the instance must be created and used within the Worker. - The
FrameBufferFiller
implementation is instantiated within the Worker. - You need to create a custom Worker. However, helper implementations are available to simplify this process. Essentially, you only need to specify the
FrameBufferFiller
implementation class within the Worker. - Depending on how you implement the
FrameBufferFiller
class, you can use the same implementation as the Timed method.
Note: Any data passed from the UI thread to the Worker (such as fillerParams in the WorkerBufferNodeParams<T>) must be serializable (e.g., primitives, arrays, objects). Non-serializable values like functions or DOM elements cannot be passed.
Buffer Underrun Handling
- When the buffer becomes empty, silent audio is output instead of throwing an error.
- The AudioNode continues to operate and consume CPU resources even during silent output.
- Normal audio output resumes automatically when new audio data is supplied.
- An UnderrunEvent is emitted upon recovery from an underrun, reporting the duration of silence (note: this is a post-event notification).
Details of Configuration Parameters
API Documentation
You can find the full API documentation here.
Example
Performance Optimization Guide
Advanced Usage Guide
BufferFillWorker
.
2. Implement advanced audio processing algorithms within the Worker.
3. Use SharedArrayBuffer
for efficient data sharing between the main thread and the Worker.
### Handling Multiple Audio Streams
When working with multiple audio streams:
1. Create separate OutputStreamNode
instances for each stream.
2. Manage their lifecycle and synchronization carefully.
3. Consider implementing a mixer if you need to combine multiple streams.
### Integrating with Other Web Audio API Features
You can combine this library with other Web Audio API features:
1. Connect the OutputStreamNode
to other AudioNodes for additional processing.
2. Use AnalyserNode for visualizations.
3. Implement spatial audio using PannerNode.
### Error Handling and Debugging
For robust applications:
1. Implement comprehensive error handling, especially for Worker-based strategies.
2. Use the UnderrunEvent to detect and handle buffer underruns.
3. Implement logging or metrics collection for performance monitoring.
These advanced techniques will help you leverage the full power of the Audio Worklet Stream Library in complex audio applications.
Troubleshooting Guide
@ain1084/audio-worklet-stream
is properly installed.
2. Check your bundler configuration, especially the optimizeDeps.exclude
setting in Vite.
#### Browser Errors
1. Verify that you've set the correct COOP (Cross-Origin Opener Policy) and COEP (Cross-Origin Embedder Policy) headers as described in the installation instructions.
2. If using a development server, ensure it's configured to send the required headers.
### Performance Issues
If you're experiencing poor performance:
1. Profile your application using browser developer tools.
2. Consider using the Worker strategy for computationally intensive tasks.
3. Optimize your audio processing algorithms.
If you're still facing issues after trying these solutions, please open an issue on our GitHub repository with a detailed description of the problem and steps to reproduce it.
Known Issues and Workarounds
@ain1084/audio-worklet-stream
in a Nuxt 3 project, you may encounter issues during SSR (Server-Side Rendering) or when importing the package as an ESM module. This can result in errors like:
bash
[nuxt] [request error] [unhandled] [500] Cannot find module '/path/to/node_modules/@ain1084/audio-worklet-stream/dist/esm/events' imported from '/path/to/node_modules/@ain1084/audio-worklet-stream/dist/esm/index.js'
### Workarounds
1. Disable SSR for the Component
You can disable SSR for the component that uses the package. This can be done by using <client-only>
:
vue
<client-only>
<MyComponent />
</client-only>
2. Use ssr: false in nuxt.config.ts
You can disable SSR for the entire project in nuxt.config.ts
:
typescript
export default defineNuxtConfig({
ssr: false,
// other configurations
})
3. Use import.meta.server and import.meta.client
For a more granular control, you can use import.meta.server
and import.meta.client
to conditionally import the module only on the client-side. Note that this method is more complex compared to 1 and 2:
typescript
if (import.meta.client) {
const { StreamNodeFactory } = await import('@ain1084/audio-worklet-stream');
// Use StreamNodeFactory
}
### Example Configuration
To ensure proper operation, it is essential to use ssr: false
or <client-only>
for components and to exclude @ain1084/audio-worklet-stream
from Vite's optimization in your nuxt.config.ts
:
typescript
export default defineNuxtConfig({
ssr: false, // or use <client-only> for specific components
vite: {
optimizeDeps: {
exclude: ['@ain1084/audio-worklet-stream']
},
plugins: [
{
name: 'configure-response-headers',
configureServer: (server) => {
server.middlewares.use((_req, res, next) => {
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
next()
})
},
},
],
},
nitro: {
rollupConfig: {
external: '@ain1084/audio-worklet-stream',
},
// Ensure COEP and COOP settings for SharedArrayBuffer
routeRules: {
'/**': {
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
},
},
},
},
})
Future Plans
We are considering potential enhancements for future releases, including:
- Buffer management optimization: We are considering ways to improve memory efficiency and initialization time, especially when dealing with multiple audio streams.
Please note that these are just considerations and may or may not be implemented in future versions. We always aim to balance new features with maintaining the library's stability and simplicity.
Notes
Vite as a Bundler: This library utilizes Vite to enable the loading and placement of workers without complex configurations. It may not work out-of-the-box with WebPack due to differences in how bundlers handle workers. While similar methods may exist for WebPack, this library currently only supports Vite. Initially, a bundler-independent approach was considered, but a suitable method could not be found.
Security Requirements: Since this library uses
SharedArrayBuffer
, ensuring browser compatibility requires meeting specific security requirements. For more details, refer to the MDN Web Docs on SharedArrayBuffer Security Requirements.
Contribution
Contributions are welcome! Please open an issue or submit a pull request on GitHub.
License
This project is licensed under multiple licenses:
You can choose either license depending on your project needs.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.