cacophony 0.32.1 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/CHANGELOG.md +1600 -0
  2. package/README.md +680 -484
  3. package/dist/automation.cjs +2 -0
  4. package/dist/automation.cjs.map +1 -0
  5. package/dist/automation.d.ts +8 -0
  6. package/dist/automation.mjs +15 -0
  7. package/dist/automation.mjs.map +1 -0
  8. package/dist/autoplayUnlock.cjs +1 -1
  9. package/dist/autoplayUnlock.cjs.map +1 -1
  10. package/dist/autoplayUnlock.d.ts +2 -0
  11. package/dist/autoplayUnlock.mjs +32 -26
  12. package/dist/autoplayUnlock.mjs.map +1 -1
  13. package/dist/basePlayback.cjs +1 -1
  14. package/dist/basePlayback.cjs.map +1 -1
  15. package/dist/basePlayback.d.ts +6 -18
  16. package/dist/basePlayback.mjs +5 -2
  17. package/dist/basePlayback.mjs.map +1 -1
  18. package/dist/bundles/dattorro-reverb-bundle.js_url.cjs +1 -1
  19. package/dist/bundles/dattorro-reverb-bundle.js_url.cjs.map +1 -1
  20. package/dist/bundles/dattorro-reverb-bundle.js_url.mjs +1 -1
  21. package/dist/bundles/dattorro-reverb-bundle.js_url.mjs.map +1 -1
  22. package/dist/bundles/fdn-reverb-bundle.js_url.cjs +1 -1
  23. package/dist/bundles/fdn-reverb-bundle.js_url.cjs.map +1 -1
  24. package/dist/bundles/fdn-reverb-bundle.js_url.mjs +1 -1
  25. package/dist/bundles/fdn-reverb-bundle.js_url.mjs.map +1 -1
  26. package/dist/bundles/modulated-delay-bundle.js_url.cjs +1 -1
  27. package/dist/bundles/modulated-delay-bundle.js_url.cjs.map +1 -1
  28. package/dist/bundles/modulated-delay-bundle.js_url.mjs +1 -1
  29. package/dist/bundles/modulated-delay-bundle.js_url.mjs.map +1 -1
  30. package/dist/cacophony.cjs +1 -1
  31. package/dist/cacophony.cjs.map +1 -1
  32. package/dist/cacophony.d.ts +60 -9
  33. package/dist/cacophony.mjs +157 -80
  34. package/dist/cacophony.mjs.map +1 -1
  35. package/dist/container.cjs +1 -1
  36. package/dist/container.cjs.map +1 -1
  37. package/dist/container.d.ts +7 -1
  38. package/dist/container.mjs +28 -21
  39. package/dist/container.mjs.map +1 -1
  40. package/dist/context.d.ts +6 -0
  41. package/dist/effectChain.cjs +1 -1
  42. package/dist/effectChain.cjs.map +1 -1
  43. package/dist/effectChain.mjs +22 -27
  44. package/dist/effectChain.mjs.map +1 -1
  45. package/dist/effects.cjs +1 -1
  46. package/dist/effects.cjs.map +1 -1
  47. package/dist/effects.d.ts +2 -1
  48. package/dist/effects.mjs +56 -47
  49. package/dist/effects.mjs.map +1 -1
  50. package/dist/events.d.ts +13 -3
  51. package/dist/group.cjs +1 -1
  52. package/dist/group.cjs.map +1 -1
  53. package/dist/group.d.ts +5 -2
  54. package/dist/group.mjs +13 -7
  55. package/dist/group.mjs.map +1 -1
  56. package/dist/index.d.ts +3 -2
  57. package/dist/mediaStream.cjs +1 -1
  58. package/dist/mediaStream.cjs.map +1 -1
  59. package/dist/mediaStream.d.ts +1 -1
  60. package/dist/mediaStream.mjs +7 -7
  61. package/dist/mediaStream.mjs.map +1 -1
  62. package/dist/node.cjs +2 -2
  63. package/dist/node.cjs.map +1 -1
  64. package/dist/node.d.ts +8 -7
  65. package/dist/node.mjs +49 -44
  66. package/dist/node.mjs.map +1 -1
  67. package/dist/occlusion.cjs +2 -0
  68. package/dist/occlusion.cjs.map +1 -0
  69. package/dist/occlusion.d.ts +19 -0
  70. package/dist/occlusion.mjs +41 -0
  71. package/dist/occlusion.mjs.map +1 -0
  72. package/dist/pannerMixin.cjs +1 -1
  73. package/dist/pannerMixin.cjs.map +1 -1
  74. package/dist/pannerMixin.d.ts +23 -50
  75. package/dist/pannerMixin.mjs +57 -29
  76. package/dist/pannerMixin.mjs.map +1 -1
  77. package/dist/pcmStream.cjs +1 -1
  78. package/dist/pcmStream.cjs.map +1 -1
  79. package/dist/pcmStream.d.ts +1 -1
  80. package/dist/pcmStream.mjs +8 -8
  81. package/dist/pcmStream.mjs.map +1 -1
  82. package/dist/playOptions.cjs +1 -1
  83. package/dist/playOptions.cjs.map +1 -1
  84. package/dist/playOptions.d.ts +5 -1
  85. package/dist/playOptions.mjs +27 -20
  86. package/dist/playOptions.mjs.map +1 -1
  87. package/dist/playback.cjs +1 -1
  88. package/dist/playback.cjs.map +1 -1
  89. package/dist/playback.d.ts +19 -1
  90. package/dist/playback.mjs +46 -24
  91. package/dist/playback.mjs.map +1 -1
  92. package/dist/routableSource.cjs +1 -1
  93. package/dist/routableSource.cjs.map +1 -1
  94. package/dist/routableSource.d.ts +26 -3
  95. package/dist/routableSource.mjs +69 -34
  96. package/dist/routableSource.mjs.map +1 -1
  97. package/dist/sessionLifecycle.cjs +2 -0
  98. package/dist/sessionLifecycle.cjs.map +1 -0
  99. package/dist/sessionLifecycle.d.ts +13 -0
  100. package/dist/sessionLifecycle.mjs +55 -0
  101. package/dist/sessionLifecycle.mjs.map +1 -0
  102. package/dist/sound.cjs +1 -1
  103. package/dist/sound.cjs.map +1 -1
  104. package/dist/sound.mjs +22 -18
  105. package/dist/sound.mjs.map +1 -1
  106. package/dist/spatialAutomation.cjs +2 -0
  107. package/dist/spatialAutomation.cjs.map +1 -0
  108. package/dist/spatialAutomation.d.ts +11 -0
  109. package/dist/spatialAutomation.mjs +53 -0
  110. package/dist/spatialAutomation.mjs.map +1 -0
  111. package/dist/synth.cjs +1 -1
  112. package/dist/synth.cjs.map +1 -1
  113. package/dist/synth.mjs +13 -9
  114. package/dist/synth.mjs.map +1 -1
  115. package/dist/synthGroup.cjs +1 -1
  116. package/dist/synthGroup.cjs.map +1 -1
  117. package/dist/synthGroup.mjs +2 -0
  118. package/dist/synthGroup.mjs.map +1 -1
  119. package/dist/synthPlayback.cjs +1 -1
  120. package/dist/synthPlayback.cjs.map +1 -1
  121. package/dist/synthPlayback.mjs +1 -6
  122. package/dist/synthPlayback.mjs.map +1 -1
  123. package/docs/assets/hierarchy.js +1 -1
  124. package/docs/assets/highlight.css +4 -4
  125. package/docs/assets/navigation.js +1 -1
  126. package/docs/assets/search.js +1 -1
  127. package/docs/classes/AudioCache.html +4 -4
  128. package/docs/classes/AudioSprite.html +7 -7
  129. package/docs/classes/BarberpoleEffect.html +7 -7
  130. package/docs/classes/BiquadEffect.html +3 -3
  131. package/docs/classes/Bus.html +21 -21
  132. package/docs/classes/Cacophony.html +186 -98
  133. package/docs/classes/DynamicsEffect.html +7 -7
  134. package/docs/classes/FdnReverbEffect.html +7 -7
  135. package/docs/classes/FoaDecoder.html +5 -5
  136. package/docs/classes/FoaDecoderEffect.html +3 -3
  137. package/docs/classes/FrequencyShifterEffect.html +7 -7
  138. package/docs/classes/Group.html +53 -36
  139. package/docs/classes/HarmonizerEffect.html +7 -7
  140. package/docs/classes/ImpulseResponseEffect.html +3 -3
  141. package/docs/classes/KWeightingFilter.html +4 -4
  142. package/docs/classes/LoudnessMeter.html +11 -11
  143. package/docs/classes/MediaStreamPlayback.html +146 -147
  144. package/docs/classes/MediaStreamSound.html +118 -54
  145. package/docs/classes/MicrophoneStream.html +119 -55
  146. package/docs/classes/ModulatedDelayEffect.html +7 -7
  147. package/docs/classes/PcmStreamPlayback.html +146 -147
  148. package/docs/classes/PcmStreamSound.html +128 -64
  149. package/docs/classes/PhaserEffect.html +7 -7
  150. package/docs/classes/Playback.html +218 -175
  151. package/docs/classes/ReverbEffect.html +7 -7
  152. package/docs/classes/Scheduler.html +3 -3
  153. package/docs/classes/ShareEffect.html +3 -3
  154. package/docs/classes/Sound.html +136 -78
  155. package/docs/classes/SpectralFreezeEffect.html +7 -7
  156. package/docs/classes/StereoWidenerEffect.html +3 -3
  157. package/docs/classes/Synth.html +126 -68
  158. package/docs/classes/SynthGroup.html +28 -28
  159. package/docs/classes/TremoloEffect.html +7 -7
  160. package/docs/classes/TruePeakDetector.html +8 -8
  161. package/docs/classes/WaveshaperEffect.html +7 -7
  162. package/docs/functions/encodeMonoToFoaSN3D.html +1 -1
  163. package/docs/functions/integratedLoudness.html +1 -1
  164. package/docs/functions/integratedUngatedLoudness.html +1 -1
  165. package/docs/functions/loudnessRange.html +1 -1
  166. package/docs/functions/timeStretch.html +1 -1
  167. package/docs/functions/timeStretchChannels.html +1 -1
  168. package/docs/functions/truePeakDb.html +1 -1
  169. package/docs/hierarchy.html +28 -28
  170. package/docs/index.html +165 -2
  171. package/docs/interfaces/AudioBuffer.html +8 -8
  172. package/docs/interfaces/AudioBufferSourceNode.html +22 -22
  173. package/docs/interfaces/AudioEventCallbacks.html +2 -2
  174. package/docs/interfaces/AudioListener.html +12 -12
  175. package/docs/interfaces/AudioNode.html +14 -14
  176. package/docs/interfaces/AudioParam.html +26 -8
  177. package/docs/interfaces/AudioWorklet.html +2 -2
  178. package/docs/interfaces/AudioWorkletNode.html +16 -16
  179. package/docs/interfaces/BarberpoleOptions.html +5 -5
  180. package/docs/interfaces/BaseContext.html +25 -25
  181. package/docs/interfaces/BaseSound.html +16 -16
  182. package/docs/interfaces/BiquadCoefficients.html +6 -6
  183. package/docs/interfaces/BiquadFilterNode.html +20 -20
  184. package/docs/interfaces/BuiltEffectGraph.html +6 -6
  185. package/docs/interfaces/CacheErrorEvent.html +5 -5
  186. package/docs/interfaces/CacheHitEvent.html +4 -4
  187. package/docs/interfaces/CacheMissEvent.html +4 -4
  188. package/docs/interfaces/CacophonyEffect.html +2 -2
  189. package/docs/interfaces/CacophonyLogger.html +4 -4
  190. package/docs/interfaces/ChannelMergerNode.html +14 -14
  191. package/docs/interfaces/ChannelSplitterNode.html +14 -14
  192. package/docs/interfaces/ConvolverNode.html +16 -16
  193. package/docs/interfaces/CreateSpriteOptions.html +3 -3
  194. package/docs/interfaces/DeviceChangeEvent.html +64 -0
  195. package/docs/interfaces/DynamicsOptions.html +7 -7
  196. package/docs/interfaces/FadeStartEvent.html +4 -4
  197. package/docs/interfaces/FdnReverbOptions.html +6 -6
  198. package/docs/interfaces/FoaDecoderOptions.html +2 -2
  199. package/docs/interfaces/FrequencyShifterOptions.html +3 -3
  200. package/docs/interfaces/GainNode.html +15 -15
  201. package/docs/interfaces/GlobalPlaybackEvent.html +3 -3
  202. package/docs/interfaces/HarmonizerOptions.html +6 -6
  203. package/docs/interfaces/ImpulseResponseOptions.html +5 -5
  204. package/docs/interfaces/LoadingCompleteEvent.html +5 -5
  205. package/docs/interfaces/LoadingErrorEvent.html +5 -5
  206. package/docs/interfaces/LoadingProgressEvent.html +6 -6
  207. package/docs/interfaces/LoadingStartEvent.html +3 -3
  208. package/docs/interfaces/LoudnessChannelInput.html +3 -3
  209. package/docs/interfaces/LoudnessReading.html +5 -5
  210. package/docs/interfaces/MediaElementSourceNode.html +15 -15
  211. package/docs/interfaces/MediaStreamAudioSourceNode.html +15 -15
  212. package/docs/interfaces/MediaStreamSoundOptions.html +5 -5
  213. package/docs/interfaces/MicrophoneStreamOptions.html +8 -8
  214. package/docs/interfaces/ModulatedDelayOptions.html +8 -8
  215. package/docs/interfaces/OfflineOptions.html +5 -5
  216. package/docs/interfaces/OscillatorNode.html +20 -20
  217. package/docs/interfaces/PannerNode.html +28 -28
  218. package/docs/interfaces/PcmStreamBufferEvent.html +2 -2
  219. package/docs/interfaces/PcmStreamSoundOptions.html +6 -6
  220. package/docs/interfaces/PhaserOptions.html +7 -7
  221. package/docs/interfaces/PlayOptions.html +13 -13
  222. package/docs/interfaces/PlaybackErrorEvent.html +5 -5
  223. package/docs/interfaces/ReverbOptions.html +13 -13
  224. package/docs/interfaces/RuntimeOptions.html +41 -15
  225. package/docs/interfaces/ScheduledCallbackHandle.html +2 -2
  226. package/docs/interfaces/SendGainOptions.html +68 -0
  227. package/docs/interfaces/SoundCleanupHoldings.html +4 -4
  228. package/docs/interfaces/SoundErrorEvent.html +6 -6
  229. package/docs/interfaces/SpectralFreezeOptions.html +4 -4
  230. package/docs/interfaces/SpriteRegion.html +4 -4
  231. package/docs/interfaces/StereoPannerNode.html +15 -15
  232. package/docs/interfaces/StereoToBFormatOptions.html +2 -2
  233. package/docs/interfaces/StereoWidenerOptions.html +4 -4
  234. package/docs/interfaces/StreamCapabilities.html +5 -5
  235. package/docs/interfaces/TimeStretchOptions.html +5 -5
  236. package/docs/interfaces/TremoloOptions.html +5 -5
  237. package/docs/interfaces/WaveshaperOptions.html +5 -5
  238. package/docs/modules.html +4 -4
  239. package/docs/types/AudioSinkId.html +35 -0
  240. package/docs/types/BaseAudioEvents.html +11 -11
  241. package/docs/types/BuiltEffect.html +1 -1
  242. package/docs/types/BusConnectionTarget.html +1 -1
  243. package/docs/types/CacheEventCallback.html +1 -1
  244. package/docs/types/CacophonyEvents.html +40 -20
  245. package/docs/types/ErrorEventCallback.html +1 -1
  246. package/docs/types/FadeType.html +1 -1
  247. package/docs/types/HrtfPannerOptions.html +1 -1
  248. package/docs/types/ImpulseResponseSource.html +1 -1
  249. package/docs/types/LoadingEventCallback.html +1 -1
  250. package/docs/types/LoopCount.html +1 -1
  251. package/docs/types/LoudnessChannel.html +1 -1
  252. package/docs/types/Orientation.html +3 -3
  253. package/docs/types/PanCloneOverrides.html +13 -7
  254. package/docs/types/PanType.html +1 -1
  255. package/docs/types/PcmStreamEvents.html +6 -6
  256. package/docs/types/PcmStreamState.html +1 -1
  257. package/docs/types/PlaybackEvents.html +1 -1
  258. package/docs/types/Position.html +1 -1
  259. package/docs/types/SoundEvents.html +1 -1
  260. package/docs/types/SoundType.html +1 -1
  261. package/docs/types/SourceNode.html +1 -1
  262. package/docs/types/SpriteMap.html +1 -1
  263. package/docs/types/SynthEvents.html +1 -1
  264. package/docs/types/ThreeDOptions.html +5 -15
  265. package/docs/types/WebCodecsStreamSound.html +1 -1
  266. package/docs/variables/CHANNEL_WEIGHTS.html +1 -1
  267. package/docs/variables/K_WEIGHTING_STAGE1_48K.html +1 -1
  268. package/docs/variables/K_WEIGHTING_STAGE2_48K.html +1 -1
  269. package/docs/variables/consoleLogger.html +1 -1
  270. package/docs/variables/noopLogger.html +1 -1
  271. package/package.json +6 -2
package/README.md CHANGED
@@ -19,36 +19,36 @@ Cacophony is a powerful and intuitive audio library designed for modern web appl
19
19
  ## Installation
20
20
 
21
21
  ```bash
22
- npm install cacophony
23
- ```
24
-
25
- ## Bundling and lazy loading
26
-
27
- Use named ESM imports so your bundler can discard unused modules:
28
-
29
- ```typescript
30
- import { AudioCache } from 'cacophony';
31
- ```
32
-
33
- The package preserves its module boundaries and declares import side effects.
34
- Importing `AudioCache` or an individual effect does not pull in the full
35
- `Cacophony` class or the offline FFT implementation. The `Cacophony` class keeps
36
- its complete factory API, so importing that class still includes the code its
37
- methods reference.
38
-
39
- `createStream()` loads MediaBunny on demand when using the WebCodecs transport.
40
- HLS streams and browsers without `AudioDecoder` take their existing fallback
41
- paths without loading it. Each AudioWorklet payload loads when its processor is
42
- first needed. Call `await audio.loadWorklets()` to preload all processors.
43
-
44
- Consumer bundlers must support code splitting to keep these imports in separate
45
- downloadable chunks. Deploy all generated chunks with the application. MediaBunny
46
- remains an installed dependency; lazy loading defers its runtime load.
47
-
48
- Run `npm run build` followed by `npm run check:bundles -- --report` to inspect
49
- initial bundle sizes for cache-only, full-factory, and single-effect consumers.
50
-
51
- ## Quick Start
22
+ npm install cacophony
23
+ ```
24
+
25
+ ## Bundling and lazy loading
26
+
27
+ Use named ESM imports so your bundler can discard unused modules:
28
+
29
+ ```typescript
30
+ import { AudioCache } from 'cacophony';
31
+ ```
32
+
33
+ The package preserves its module boundaries and declares import side effects.
34
+ Importing `AudioCache` or an individual effect does not pull in the full
35
+ `Cacophony` class or the offline FFT implementation. The `Cacophony` class keeps
36
+ its complete factory API, so importing that class still includes the code its
37
+ methods reference.
38
+
39
+ `createStream()` loads MediaBunny on demand when using the WebCodecs transport.
40
+ HLS streams and browsers without `AudioDecoder` take their existing fallback
41
+ paths without loading it. Each AudioWorklet payload loads when its processor is
42
+ first needed. Call `await audio.loadWorklets()` to preload all processors.
43
+
44
+ Consumer bundlers must support code splitting to keep these imports in separate
45
+ downloadable chunks. Deploy all generated chunks with the application. MediaBunny
46
+ remains an installed dependency; lazy loading defers its runtime load.
47
+
48
+ Run `npm run build` followed by `npm run check:bundles -- --report` to inspect
49
+ initial bundle sizes for cache-only, full-factory, and single-effect consumers.
50
+
51
+ ## Quick Start
52
52
 
53
53
  ```typescript
54
54
  import { Cacophony } from 'cacophony';
@@ -81,6 +81,37 @@ async function audioDemo() {
81
81
  audioDemo();
82
82
  ```
83
83
 
84
+ ## Per-playback occlusion
85
+
86
+ Apply obstruction to an individual `Playback` using a normalized amount:
87
+
88
+ ```typescript
89
+ const [voice] = sound.play({ panType: 'HRTF', position: [5, 0, 0] });
90
+ voice.setOcclusion(0.7); // Smooth transition over 50 ms by default.
91
+ voice.setOcclusion(0, 100); // Restore the clear path over 100 ms.
92
+ console.log(voice.occlusion); // Last requested amount, independent of transition progress.
93
+ ```
94
+
95
+ The caller computes the amount and owns geometry, materials, and policy.
96
+ Cacophony renders it with a dedicated low-pass and attenuation stage after user
97
+ effects and before the panner. Finite amounts clamp to `[0, 1]`; non-finite
98
+ amounts throw `RangeError`. The optional transition duration is in milliseconds:
99
+ `0` applies immediately; negative or non-finite durations throw `RangeError`.
100
+
101
+ At `0`, the low-pass opens to the context's Nyquist frequency and attenuation
102
+ returns to unity, restoring the unobstructed response. At `1`, the low-pass
103
+ cutoff is 800 Hz (limited to Nyquist on lower-rate contexts) and attenuation is
104
+ -18 dB. Intermediate amounts map logarithmically to cutoff and linearly in
105
+ decibels to attenuation. The filter has a Butterworth response without a resonant
106
+ peak. Changes use linear AudioParam ramps, cancelling and re-anchoring any prior
107
+ occlusion transition at its current value.
108
+
109
+ Occlusion leaves ordinary volume and fades independent. Each voice starts clear;
110
+ updates affect only that voice. Pause/resume and panner changes retain its
111
+ occlusion, and `Playback.clone()` copies the requested amount into independent
112
+ nodes. One lazily allocated filter/gain pair is reused even after resetting to
113
+ `0`, then disconnected on cleanup. Updating a cleaned-up playback throws.
114
+
84
115
  ## Mobile Autoplay Handling
85
116
 
86
117
  Modern browsers — especially iOS Safari and Chrome on Android — refuse to
@@ -118,6 +149,92 @@ const cacophony = new Cacophony(undefined, undefined, { autoUnlock: false });
118
149
  The auto-unlock has no effect on offline contexts or in non-browser
119
150
  environments (`typeof document === 'undefined'`).
120
151
 
152
+ ## Mobile Robustness and Audio Sessions
153
+
154
+ By default, browser instances observe context `statechange` and emit `suspend`,
155
+ `resume`, or `interrupted` once per transition, including changes initiated by
156
+ the platform or by code using the context directly. Construction does not emit
157
+ an initial state event. A gesture unlock therefore emits `resume` along with
158
+ `unlock`. Once the context has run, `autoRecover` attempts silent resume after
159
+ suspension or interruption only while the document is visible, and retries on
160
+ `visibilitychange`, `pageshow`, and window `focus`. A context that has never run
161
+ (blocked by autoplay policy) is left to the gesture unlock, so `unlock` still fires
162
+ on the first interaction. It does not suspend audio merely because the page
163
+ is hidden. Failed recovery is logged through the instance logger; the existing
164
+ gesture unlock remains available when the browser requires interaction.
165
+
166
+ `await cacophony.pause()` records user intent before suspending. Neither automatic
167
+ recovery nor subsequent gestures undo that pause; call `await cacophony.resume()`
168
+ to continue. This also protects pauses made while a recovery attempt is pending.
169
+ Set `{ autoRecover: false }` to disable browser session and device-list listeners.
170
+ `autoUnlock` controls gesture handling separately; disable both to manage all
171
+ recovery yourself. Offline and non-browser contexts install no session listeners.
172
+
173
+ ```typescript
174
+ const cacophony = new Cacophony();
175
+ cacophony.on('interrupted', () => showAudioStatus('Audio interrupted'));
176
+ cacophony.on('resume', () => showAudioStatus('Audio running'));
177
+ cacophony.on('devicechange', ({ devices }) => updateOutputPicker(devices));
178
+ cacophony.on('sinkChange', ({ sinkId }) => showSelectedOutput(sinkId));
179
+
180
+ // When the instance is no longer needed:
181
+ cacophony.dispose(); // Removes platform and gesture listeners and event subscriptions.
182
+ await cacophony.context.close?.(); // The caller owns context and sound cleanup.
183
+ ```
184
+
185
+ `devicechange` carries `{ devices: MediaDeviceInfo[], timestamp: number }`, with
186
+ only `audiooutput` devices. Enumeration does not request permission; labels and
187
+ the device list may be restricted. Failed enumeration is logged and emits no
188
+ partial result. Closing the context also removes browser session, sink, and gesture
189
+ listeners when `autoRecover` is enabled; your event subscriptions stay attached
190
+ until `dispose()`. `sinkChange` reports the active sink independently of
191
+ the available device list; neither event selects a replacement automatically.
192
+
193
+ Automated tests cover native context transitions and synthetic device-list
194
+ notifications. Real hardware and OS interruptions require this manual matrix:
195
+
196
+ | Platform | Action | Expected result |
197
+ | --- | --- | --- |
198
+ | iOS Safari | Interrupt playback with a call or Siri, then return | `interrupted`/`suspend` when reported by the OS; visible-page recovery or gesture unlock restores audio |
199
+ | Android Chrome | Unplug headphones during playback | Device-list notification when delivered; context transitions are observable and recovery is attempted |
200
+ | Both | Background and foreground the page | No forced background suspension; retry on foreground if the context needs recovery |
201
+ | Both | Pause explicitly, then repeat these actions and tap | Audio remains paused until the app calls `resume()` |
202
+
203
+ ## Output Device Selection
204
+
205
+ `setOutputDevice(sinkId)` delegates to the supplied context's runtime `setSinkId`
206
+ capability. `outputDevice` reads its current sink: `''` means the system default,
207
+ a device id selects that output, and `{ type: 'none' }` selects silent processing.
208
+ The same API works with the Node backend; its constructor `sinkId` option remains
209
+ available. Switching does not recreate the context or audio graph.
210
+
211
+ ```typescript
212
+ // Run from a user action. selectAudioOutput is optional and browser-dependent.
213
+ const mediaDevices = navigator.mediaDevices as MediaDevices & {
214
+ selectAudioOutput?: () => Promise<MediaDeviceInfo>;
215
+ };
216
+ if (mediaDevices.selectAudioOutput) {
217
+ const output = await mediaDevices.selectAudioOutput();
218
+ await cacophony.setOutputDevice(output.deviceId);
219
+ }
220
+ await cacophony.setOutputDevice(''); // Return to the system default.
221
+ ```
222
+
223
+ Concrete device selection requires a secure context and any permissions required
224
+ by the platform; obtaining authorized ids (through `selectAudioOutput` or permitted
225
+ `enumerateDevices`/`getUserMedia`) belongs to the app. Platform errors such as
226
+ `NotAllowedError` and `NotFoundError` propagate unchanged. Unsupported real-time
227
+ contexts reject with `Error("Output device selection is not supported by this context")`
228
+ until the shared typed-error API in #169 lands. Offline selection is a no-op and
229
+ `outputDevice` is `undefined`.
230
+
231
+ Support is detected on the actual context, never inferred from a browser name.
232
+ Native Chromium supports selection; Safari and Firefox versions lacking
233
+ `AudioContext.setSinkId` reject it. Standardized-audio-context wrappers that do
234
+ not expose `setSinkId` are unsupported even when the native browser supports it.
235
+ See [Chrome's output-selection guide](https://developer.chrome.com/blog/audiocontext-setsinkid/)
236
+ for platform permissions and silent-sink behavior.
237
+
121
238
  ## Core Concepts
122
239
 
123
240
  ### Sound vs Playback Architecture
@@ -127,30 +244,30 @@ Cacophony uses a two-tier architecture that separates audio assets from their pl
127
244
  - **Sound**: Represents an audio asset (file or buffer). Acts as a container managing multiple playback instances.
128
245
  - **Playback**: Represents a single playback instance of a Sound. Each call to `play()` creates a new Playback.
129
246
 
130
- This design allows the same sound to be played multiple times simultaneously with different settings (volume, position, playback rate, etc.).
131
-
132
- Pass invocation settings to `play(options)` to configure each voice before audio
133
- starts. Omitted values inherit source defaults; `0` is valid for volume, stereo
134
- pan, and loop count. Sound defaults and overlapping voices stay independent.
135
-
136
- ```typescript
137
- const [voice] = sound.play({ volume: 0.5, playbackRate: 1.2, loopCount: 0,
138
- panType: 'stereo', stereoPan: 0 });
139
- const step = group.playOrdered(true, { volume: 0.7, panType: 'HRTF',
140
- position: [10, 0, 5], threeDOptions: { distanceModel: 'linear', rolloffFactor: 0.1 } });
141
- ```
142
-
143
- `stereoPan` requires the effective stereo mode; `position` and `threeDOptions`
144
- require HRTF. Partial HRTF options preserve unspecified settings. Group all,
145
- random, and ordered playback and sprite children accept these same options.
146
- Invalid options throw before voice preparation, and group playback preflights
147
- every member. Direct `Playback.play(options)` also supports these settings,
148
- scheduling, and fades. Calling play on an already playing voice remains a no-op
149
- after validation.
150
-
151
- Synths and live streams support volume, spatial settings, and fade-in. They
152
- reject playback rate, loop count, scheduled starts, end fades, and per-loop fade
153
- settings because these sources do not support those controls.
247
+ This design allows the same sound to be played multiple times simultaneously with different settings (volume, position, playback rate, etc.).
248
+
249
+ Pass invocation settings to `play(options)` to configure each voice before audio
250
+ starts. Omitted values inherit source defaults; `0` is valid for volume, stereo
251
+ pan, and loop count. Sound defaults and overlapping voices stay independent.
252
+
253
+ ```typescript
254
+ const [voice] = sound.play({ volume: 0.5, playbackRate: 1.2, loopCount: 0,
255
+ panType: 'stereo', stereoPan: 0 });
256
+ const step = group.playOrdered(true, { volume: 0.7, panType: 'HRTF',
257
+ position: [10, 0, 5], threeDOptions: { distanceModel: 'linear', rolloffFactor: 0.1 } });
258
+ ```
259
+
260
+ `stereoPan` requires the effective stereo mode; `position` and `threeDOptions`
261
+ require HRTF. Partial HRTF options preserve unspecified settings. Group all,
262
+ random, and ordered playback and sprite children accept these same options.
263
+ Invalid options throw before voice preparation, and group playback preflights
264
+ every member. Direct `Playback.play(options)` also supports these settings,
265
+ scheduling, and fades. Calling play on an already playing voice remains a no-op
266
+ after validation.
267
+
268
+ Synths and live streams support volume, spatial settings, and fade-in. They
269
+ reject playback rate, loop count, scheduled starts, end fades, and per-loop fade
270
+ settings because these sources do not support those controls.
154
271
 
155
272
  ```typescript
156
273
  const sound = await cacophony.createSound('laser.mp3');
@@ -173,111 +290,111 @@ setTimeout(() => playback1.pause(), 1000);
173
290
  setTimeout(() => playback2.stop(), 2000);
174
291
 
175
292
  // Or control all playbacks through the Sound
176
- sound.stop(); // Stops all three playbacks
177
- ```
178
-
179
- Each playback exposes a read-only `state`: `"unplayed"`, `"playing"`, `"paused"`,
180
- or `"stopped"`. When `sound.preplay()` or `sound.play()` prepares a new voice,
181
- it removes stopped entries from `sound.playbacks`. Repeated play/stop calls
182
- therefore retain at most the most recently stopped voice in that collection.
183
- Unplayed, paused, and playing voices remain registered.
184
-
185
- An individual `playback.stop()` preserves the ability to call `playback.play()`
186
- again. A successfully restarted playback rejoins `sound.playbacks` and receives
187
- Sound controls again, even if it was previously reaped. Reaping disconnects its
188
- output and sends; replay restores the Sound's current routing and event forwarding.
189
- The playback retains its individual volume, rate, and other settings while reaped.
190
- Reaping does not call `cleanup()`; call `playback.cleanup()` when a retained playback
191
- is no longer needed.
192
-
193
- ### Audio Sprites for Games
194
-
195
- Pack many short effects into one decoded WAV atlas while keeping each named
196
- region as an ordinary, independently configurable `Sound`:
197
-
198
- ```bash
199
- cacophony sprite sfx/laser.wav sfx/engine.wav \
200
- --out public/sfx-atlas.wav --map public/sfx-atlas.json --gap 10
201
- ```
202
-
203
- ```typescript
204
- import { Cacophony, type SpriteMap } from 'cacophony';
205
-
206
- const map = {
207
- laser: { start: 0, duration: 0.35 },
208
- engine: { start: 0.36, duration: 1.2, loopCount: 'infinite' },
209
- } as const satisfies SpriteMap;
210
-
211
- const cacophony = new Cacophony();
212
- const sprite = await cacophony.createSprite('/sfx-atlas.wav', map);
213
-
214
- // Literal keys stay typed. Each child uses the normal Sound API.
215
- sprite.sounds.laser.volume = 0.7;
216
- sprite.sounds.laser.play();
217
-
218
- // Dynamic names use the checked lookup.
219
- const selected: string = chooseEffectName();
220
- if (sprite.has(selected)) sprite.get(selected).play();
221
-
222
- // Child configuration is independent; the atlas has no shared play controls.
223
- sprite.sounds.engine.loop('infinite');
224
- sprite.sounds.engine.play();
225
-
226
- sprite.cleanup();
227
- ```
228
-
229
- All child Sounds share the atlas `AudioBuffer`. `cleanup()` owns those children
230
- but not clones created with `sound.clone()`, which remain independently usable.
231
-
232
- ## Sound Types
293
+ sound.stop(); // Stops all three playbacks
294
+ ```
295
+
296
+ Each playback exposes a read-only `state`: `"unplayed"`, `"playing"`, `"paused"`,
297
+ or `"stopped"`. When `sound.preplay()` or `sound.play()` prepares a new voice,
298
+ it removes stopped entries from `sound.playbacks`. Repeated play/stop calls
299
+ therefore retain at most the most recently stopped voice in that collection.
300
+ Unplayed, paused, and playing voices remain registered.
301
+
302
+ An individual `playback.stop()` preserves the ability to call `playback.play()`
303
+ again. A successfully restarted playback rejoins `sound.playbacks` and receives
304
+ Sound controls again, even if it was previously reaped. Reaping disconnects its
305
+ output and sends; replay restores the Sound's current routing and event forwarding.
306
+ The playback retains its individual volume, rate, and other settings while reaped.
307
+ Reaping does not call `cleanup()`; call `playback.cleanup()` when a retained playback
308
+ is no longer needed.
309
+
310
+ ### Audio Sprites for Games
311
+
312
+ Pack many short effects into one decoded WAV atlas while keeping each named
313
+ region as an ordinary, independently configurable `Sound`:
314
+
315
+ ```bash
316
+ cacophony sprite sfx/laser.wav sfx/engine.wav \
317
+ --out public/sfx-atlas.wav --map public/sfx-atlas.json --gap 10
318
+ ```
319
+
320
+ ```typescript
321
+ import { Cacophony, type SpriteMap } from 'cacophony';
322
+
323
+ const map = {
324
+ laser: { start: 0, duration: 0.35 },
325
+ engine: { start: 0.36, duration: 1.2, loopCount: 'infinite' },
326
+ } as const satisfies SpriteMap;
327
+
328
+ const cacophony = new Cacophony();
329
+ const sprite = await cacophony.createSprite('/sfx-atlas.wav', map);
330
+
331
+ // Literal keys stay typed. Each child uses the normal Sound API.
332
+ sprite.sounds.laser.volume = 0.7;
333
+ sprite.sounds.laser.play();
233
334
 
234
- Cacophony supports three URL-backed sound labels:
335
+ // Dynamic names use the checked lookup.
336
+ const selected: string = chooseEffectName();
337
+ if (sprite.has(selected)) sprite.get(selected).play();
338
+
339
+ // Child configuration is independent; the atlas has no shared play controls.
340
+ sprite.sounds.engine.loop('infinite');
341
+ sprite.sounds.engine.play();
342
+
343
+ sprite.cleanup();
344
+ ```
345
+
346
+ All child Sounds share the atlas `AudioBuffer`. `cleanup()` owns those children
347
+ but not clones created with `sound.clone()`, which remain independently usable.
348
+
349
+ ## Sound Types
350
+
351
+ Cacophony supports three URL-backed sound labels:
235
352
 
236
353
  | Type | Memory | Latency | Seeking | Multiple Instances | Best For |
237
354
  |------|--------|---------|---------|-------------------|----------|
238
355
  | **Buffer** (default) | High | None | Full | Yes | Sound effects, UI sounds, short music clips |
239
- | **HTML** | Medium | Low | Full | Yes | Media-element-backed background music, large audio files, and podcasts |
240
- | **Streaming** | Bounded | Low | Range-backed | One stream per source | Sample-accurate URL audio with effects, panning, and metering |
356
+ | **HTML** | Medium | Low | Full | Yes | Media-element-backed background music, large audio files, and podcasts |
357
+ | **Streaming** | Bounded | Low | Range-backed | One stream per source | Sample-accurate URL audio with effects, panning, and metering |
241
358
 
242
359
  ```typescript
243
360
  // Buffer - entire file loaded into memory
244
361
  const sfx = await cacophony.createSound('explosion.mp3', 'buffer');
245
362
 
246
- // HTML - media-element-backed playback, good for large files
247
- const music = await cacophony.createSound('bgm.mp3', 'html');
248
-
249
- // Streaming - incremental demux + WebCodecs decode into a PCM worklet
250
- const radio = await cacophony.createStream('https://example.com/live-radio.mp3');
251
- console.log(radio.streamCapabilities);
252
- // { transport: 'webcodecs', seekable: false, live: true, duration: Infinity }
253
- ```
254
-
255
- `createStream(url)` uses a fetch → demux → WebCodecs `AudioDecoder` → PCM
256
- worklet path when WebCodecs is present and can decode the primary audio track.
257
- The returned source has `soundType === 'streaming'` and uses the same volume,
258
- pan/HRTF, filter, bus, send, and loudness-meter APIs as every other routable
259
- source. MP3, ADTS/MP4 AAC, Ogg, FLAC, and WAVE containers are supported.
260
-
261
- If WebCodecs is absent or the primary track has no WebCodecs decoder,
262
- `createStream()` falls back to a media-element `Sound`. Check
263
- `streamCapabilities.transport` for `'webcodecs'` or `'media-element'` rather
264
- than assuming a tier.
265
-
266
- Native HLS (`.m3u8`) playback, notably in Safari, uses the media element
267
- directly. In browsers without native HLS, `createStream()` lazily uses
268
- `hls.js`, declared as an optional peer dependency:
269
-
270
- ```bash
271
- npm install hls.js
272
- ```
273
-
274
- If neither native HLS nor an installed, MSE-capable `hls.js` is available,
275
- `createStream()` rejects with install guidance instead of leaving a media
276
- element silently waiting. hls.js failures after loading are emitted through
277
- the Sound's `soundError` event, and `sound.cleanup()` detaches and destroys the
278
- hls.js instance.
279
-
280
- ### Format Fallback (Howler-style)
363
+ // HTML - media-element-backed playback, good for large files
364
+ const music = await cacophony.createSound('bgm.mp3', 'html');
365
+
366
+ // Streaming - incremental demux + WebCodecs decode into a PCM worklet
367
+ const radio = await cacophony.createStream('https://example.com/live-radio.mp3');
368
+ console.log(radio.streamCapabilities);
369
+ // { transport: 'webcodecs', seekable: false, live: true, duration: Infinity }
370
+ ```
371
+
372
+ `createStream(url)` uses a fetch → demux → WebCodecs `AudioDecoder` → PCM
373
+ worklet path when WebCodecs is present and can decode the primary audio track.
374
+ The returned source has `soundType === 'streaming'` and uses the same volume,
375
+ pan/HRTF, filter, bus, send, and loudness-meter APIs as every other routable
376
+ source. MP3, ADTS/MP4 AAC, Ogg, FLAC, and WAVE containers are supported.
377
+
378
+ If WebCodecs is absent or the primary track has no WebCodecs decoder,
379
+ `createStream()` falls back to a media-element `Sound`. Check
380
+ `streamCapabilities.transport` for `'webcodecs'` or `'media-element'` rather
381
+ than assuming a tier.
382
+
383
+ Native HLS (`.m3u8`) playback, notably in Safari, uses the media element
384
+ directly. In browsers without native HLS, `createStream()` lazily uses
385
+ `hls.js`, declared as an optional peer dependency:
386
+
387
+ ```bash
388
+ npm install hls.js
389
+ ```
390
+
391
+ If neither native HLS nor an installed, MSE-capable `hls.js` is available,
392
+ `createStream()` rejects with install guidance instead of leaving a media
393
+ element silently waiting. hls.js failures after loading are emitted through
394
+ the Sound's `soundError` event, and `sound.cleanup()` detaches and destroys the
395
+ hls.js instance.
396
+
397
+ ### Format Fallback (Howler-style)
281
398
 
282
399
  Pass an array of URLs and Cacophony picks the first one the browser can play.
283
400
  It queries `HTMLAudioElement.canPlayType` per extension and fetches only the
@@ -419,86 +536,86 @@ const playback = sound.play()[0];
419
536
  playback.filters[0].frequency.value = 500; // Only affects this playback
420
537
  ```
421
538
 
422
- See [TypeDoc](https://cacophony.js.org) for complete filter parameters and options.
423
-
424
- ### Per-source effects
425
-
426
- Use `addEffect` for source character such as distortion, EQ, tremolo, or pitch
427
- processing. The source stores the `CacophonyEffect` as a recipe and builds a
428
- fresh effect instance for every playback, before that playback's panner. The
429
- returned playback handle can be automated without changing another playback.
430
-
431
- ```typescript
432
- const guitar = await cacophony.createSound('guitar.mp3');
433
- const distortion = cacophony.createDistortion({ drive: 4 });
434
- guitar.addEffect(distortion);
435
-
436
- const [take] = guitar.play();
437
- const liveTremolo = await take.addEffect(cacophony.createTremolo({ rate: 5 }));
438
- take.rampEffectParam(liveTremolo, 'depth', 0.8, { duration: 250 });
439
-
440
- guitar.removeEffect(distortion); // future playbacks no longer build it
441
- ```
442
-
443
- ### Advanced paper-backed effects
444
-
445
- The advanced suite adds five non-neural effects derived from published DSP
446
- algorithms. They work as per-source recipes or shared bus effects and expose
447
- their live controls through `rampEffectParam`.
448
-
449
- ```typescript
450
- // Equal-Hz SSB translation: harmonic material becomes bell-like/inharmonic.
451
- guitar.addEffect(cacophony.createFrequencyShifter({ frequency: 180 }));
452
-
453
- // Endlessly descending notches (use a positive rate to reverse direction).
454
- drums.addEffect(cacophony.createBarberpole({ rate: -0.1, stages: 32 }));
455
-
456
- // Dry voice plus a fifth and octave, all produced in one STFT analysis pass.
457
- vocals.addEffect(cacophony.createHarmonizer({
458
- semitonesA: 7,
459
- semitonesB: 12,
460
- gainA: 0.6,
461
- gainB: 0.45,
462
- }));
463
-
464
- // Capture/release a phase-continuing spectral texture at runtime.
465
- const freeze = await playback.addEffect(cacophony.createSpectralFreeze());
466
- playback.rampEffectParam(freeze, 'freeze', 1); // capture
467
- playback.rampEffectParam(freeze, 'freeze', 0); // return to live input
468
-
469
- // Convert mono or stereo material to transient-preserving decorrelated stereo.
470
- ambience.addEffect(cacophony.createStereoWidener({
471
- width: 0.75,
472
- decorrelation: 1,
473
- transientProtection: 0.8,
474
- }));
475
- ```
476
-
477
- `createFrequencyShifter` implements Wardle's Hilbert-transform single-sideband
478
- modulator (DAFx-98). `createBarberpole` implements the warped spectral-delay SSB
479
- structure of Esqueda, Valimaki and Parker (DAFx-15). `createHarmonizer` extends
480
- the Laroche-Dolson identity-phase-locked peak shifter to two simultaneous
481
- voices. `createSpectralFreeze` preserves measured inter-frame phase advance
482
- instead of looping one static FFT frame. `createStereoWidener` uses sparse
483
- velvet-noise decorrelation and temporarily retreats to the direct path for
484
- transients.
485
-
486
- Keep shared space effects such as reverb on a bus and feed them with a wet
487
- send. This leaves the playback's dry character chain intact while every source
488
- sent to the bus shares one live reverb instance.
489
-
490
- ```typescript
491
- const vocals = await cacophony.createSound('vocals.mp3');
492
- vocals.addEffect(cacophony.createDistortion({ drive: 1.5 })); // dry, per playback
493
-
494
- const chamber = cacophony.createBus('chamber');
495
- await chamber.addFilter(cacophony.createReverb({ wet: 1, dry: 0 }));
496
- vocals.routeTo(chamber, 0.25); // 25% wet send; primary output stays on master
497
-
498
- vocals.play();
499
- ```
500
-
501
- ## Custom Audio Routing
539
+ See [TypeDoc](https://cacophony.js.org) for complete filter parameters and options.
540
+
541
+ ### Per-source effects
542
+
543
+ Use `addEffect` for source character such as distortion, EQ, tremolo, or pitch
544
+ processing. The source stores the `CacophonyEffect` as a recipe and builds a
545
+ fresh effect instance for every playback, before that playback's panner. The
546
+ returned playback handle can be automated without changing another playback.
547
+
548
+ ```typescript
549
+ const guitar = await cacophony.createSound('guitar.mp3');
550
+ const distortion = cacophony.createDistortion({ drive: 4 });
551
+ guitar.addEffect(distortion);
552
+
553
+ const [take] = guitar.play();
554
+ const liveTremolo = await take.addEffect(cacophony.createTremolo({ rate: 5 }));
555
+ take.rampEffectParam(liveTremolo, 'depth', 0.8, { duration: 250 });
556
+
557
+ guitar.removeEffect(distortion); // future playbacks no longer build it
558
+ ```
559
+
560
+ ### Advanced paper-backed effects
561
+
562
+ The advanced suite adds five non-neural effects derived from published DSP
563
+ algorithms. They work as per-source recipes or shared bus effects and expose
564
+ their live controls through `rampEffectParam`.
565
+
566
+ ```typescript
567
+ // Equal-Hz SSB translation: harmonic material becomes bell-like/inharmonic.
568
+ guitar.addEffect(cacophony.createFrequencyShifter({ frequency: 180 }));
569
+
570
+ // Endlessly descending notches (use a positive rate to reverse direction).
571
+ drums.addEffect(cacophony.createBarberpole({ rate: -0.1, stages: 32 }));
572
+
573
+ // Dry voice plus a fifth and octave, all produced in one STFT analysis pass.
574
+ vocals.addEffect(cacophony.createHarmonizer({
575
+ semitonesA: 7,
576
+ semitonesB: 12,
577
+ gainA: 0.6,
578
+ gainB: 0.45,
579
+ }));
580
+
581
+ // Capture/release a phase-continuing spectral texture at runtime.
582
+ const freeze = await playback.addEffect(cacophony.createSpectralFreeze());
583
+ playback.rampEffectParam(freeze, 'freeze', 1); // capture
584
+ playback.rampEffectParam(freeze, 'freeze', 0); // return to live input
585
+
586
+ // Convert mono or stereo material to transient-preserving decorrelated stereo.
587
+ ambience.addEffect(cacophony.createStereoWidener({
588
+ width: 0.75,
589
+ decorrelation: 1,
590
+ transientProtection: 0.8,
591
+ }));
592
+ ```
593
+
594
+ `createFrequencyShifter` implements Wardle's Hilbert-transform single-sideband
595
+ modulator (DAFx-98). `createBarberpole` implements the warped spectral-delay SSB
596
+ structure of Esqueda, Valimaki and Parker (DAFx-15). `createHarmonizer` extends
597
+ the Laroche-Dolson identity-phase-locked peak shifter to two simultaneous
598
+ voices. `createSpectralFreeze` preserves measured inter-frame phase advance
599
+ instead of looping one static FFT frame. `createStereoWidener` uses sparse
600
+ velvet-noise decorrelation and temporarily retreats to the direct path for
601
+ transients.
602
+
603
+ Keep shared space effects such as reverb on a bus and feed them with a wet
604
+ send. This leaves the playback's dry character chain intact while every source
605
+ sent to the bus shares one live reverb instance.
606
+
607
+ ```typescript
608
+ const vocals = await cacophony.createSound('vocals.mp3');
609
+ vocals.addEffect(cacophony.createDistortion({ drive: 1.5 })); // dry, per playback
610
+
611
+ const chamber = cacophony.createBus('chamber');
612
+ await chamber.addFilter(cacophony.createReverb({ wet: 1, dry: 0 }));
613
+ vocals.routeTo(chamber, 0.25); // 25% wet send; primary output stays on master
614
+
615
+ vocals.play();
616
+ ```
617
+
618
+ ## Custom Audio Routing
502
619
 
503
620
  Cacophony exposes the underlying Web Audio graph through Playback instances, enabling manual routing through custom effects chains. This is the low-level foundation for building complex audio processing pipelines.
504
621
 
@@ -556,9 +673,9 @@ API documented below. The Bus class supersedes the older user-built
556
673
  A `Bus` is a named summing node with its own filter chain and per-edge
557
674
  gain on outgoing connections. Sounds and synths route to buses via
558
675
  `routeTo`; buses can carry rich effects (not just BiquadFilter) by
559
- adding a `CacophonyEffect` to their filter chain. The built-in
560
- `cacophony.createReverb()` and `cacophony.createImpulseResponse()` effects
561
- are ready to drop into a bus.
676
+ adding a `CacophonyEffect` to their filter chain. The built-in
677
+ `cacophony.createReverb()` and `cacophony.createImpulseResponse()` effects
678
+ are ready to drop into a bus.
562
679
 
563
680
  ```typescript
564
681
  // 1. Create a named bus
@@ -582,6 +699,28 @@ drums.routeTo(reverbBus, 0.3);
582
699
  drums.play();
583
700
  ```
584
701
 
702
+ Source sends can be changed and removed while voices are playing:
703
+
704
+ ```typescript
705
+ drums.routeTo(reverbBus, 0.1, { duration: 250 }); // ramp the existing send over 250 ms
706
+ drums.routeTo(reverbBus, 0, { duration: 500, type: 'exponential' });
707
+ drums.removeSend(reverbBus); // disconnect the send on every voice and forget it for future voices
708
+ ```
709
+
710
+ Gain changes are immediate by default. Ramps reuse each voice's send GainNode,
711
+ cancel earlier automation, and start from the current envelope value. New sends
712
+ and future voices start at the requested gain. Exponential fades to zero reach
713
+ exact silence at the endpoint; transitions from zero or involving negative gains
714
+ use a linear ramp. Durations are finite, non-negative milliseconds and gains must
715
+ be finite.
716
+
717
+ `removeSend` leaves primary routing and other sends alone, even when the removed
718
+ send's bus is also the primary target. It is a no-op if the source has no send to
719
+ that bus. Both methods accept a registered bus name and work on Sound, Synth,
720
+ stream sources, and Group members. To remove a send after its named bus has been
721
+ destroyed, pass the retained bus reference; its name is no longer registered.
722
+ Removal disconnects immediately.
723
+
585
724
  ### Looking up buses by name
586
725
 
587
726
  ```typescript
@@ -599,81 +738,81 @@ the same node as `cacophony.globalGainNode`, so the existing
599
738
  transparently. Routing a sound to `cacophony.master` (or never calling
600
739
  `routeTo`) sends it through the master path.
601
740
 
602
- ### Bus-to-bus routing with per-edge gain
741
+ ### Bus-to-bus routing with per-edge gain
603
742
 
604
743
  ```typescript
605
744
  const groupBus = cacophony.createBus('group');
606
745
  const sendBus = cacophony.createBus('aux');
607
746
  groupBus.connect(sendBus, 0.2); // 20% send: groupBus.output → sendGain(0.2) → sendBus.input
608
- groupBus.disconnect(sendBus); // tears down the sendGain too
609
- ```
610
-
611
- ### Native impulse responses
612
-
613
- `createImpulseResponse()` builds a native `ConvolverNode` effect from an
614
- `AudioBuffer` or URL. URL-backed IRs are fetched and decoded once per audio
615
- context and URL; failed loads are evicted so a later call can retry. Convolver
616
- normalization defaults to `false`, preserving measured IR gain.
617
-
618
- ```typescript
619
- // Wet-only send bus: returns a single ConvolverNode internally.
620
- const chamber = cacophony.createBus('chamber');
621
- await chamber.addFilter(cacophony.createImpulseResponse('/irs/chamber.wav'));
622
- vocals.routeTo(chamber, 0.25);
623
-
624
- // Inline dry/wet graph: exposes dry/wet AudioParams for bus automation.
625
- const room = cacophony.createImpulseResponse('/irs/room.wav', {
626
- dry: 0.7,
627
- wet: 0.3,
628
- normalize: false,
629
- });
630
- const handle = await bus.addFilter(room);
631
- bus.rampFilterParam(handle, 'wet', 0.6, { duration: 500 });
632
- ```
633
-
634
- ### Adding custom effects to a bus
635
-
636
- Bus filter chains accept Cacophony-built BiquadFilters and any
637
- `CacophonyEffect`. Raw third-party AudioNodes are rejected unless you
638
- wrap them explicitly with `cacophony.shareEffect(node)` — this surfaces
639
- the shared-state intent (the same node will run on every bus that adds it).
640
- An effect can build either a single `AudioNode` or a `BuiltEffectGraph` with
641
- separate `input`/`output` endpoints for multi-node processors.
747
+ groupBus.disconnect(sendBus); // tears down the sendGain too
748
+ ```
749
+
750
+ ### Native impulse responses
751
+
752
+ `createImpulseResponse()` builds a native `ConvolverNode` effect from an
753
+ `AudioBuffer` or URL. URL-backed IRs are fetched and decoded once per audio
754
+ context and URL; failed loads are evicted so a later call can retry. Convolver
755
+ normalization defaults to `false`, preserving measured IR gain.
756
+
757
+ ```typescript
758
+ // Wet-only send bus: returns a single ConvolverNode internally.
759
+ const chamber = cacophony.createBus('chamber');
760
+ await chamber.addFilter(cacophony.createImpulseResponse('/irs/chamber.wav'));
761
+ vocals.routeTo(chamber, 0.25);
762
+
763
+ // Inline dry/wet graph: exposes dry/wet AudioParams for bus automation.
764
+ const room = cacophony.createImpulseResponse('/irs/room.wav', {
765
+ dry: 0.7,
766
+ wet: 0.3,
767
+ normalize: false,
768
+ });
769
+ const handle = await bus.addFilter(room);
770
+ bus.rampFilterParam(handle, 'wet', 0.6, { duration: 500 });
771
+ ```
772
+
773
+ ### Adding custom effects to a bus
774
+
775
+ Bus filter chains accept Cacophony-built BiquadFilters and any
776
+ `CacophonyEffect`. Raw third-party AudioNodes are rejected unless you
777
+ wrap them explicitly with `cacophony.shareEffect(node)` — this surfaces
778
+ the shared-state intent (the same node will run on every bus that adds it).
779
+ An effect can build either a single `AudioNode` or a `BuiltEffectGraph` with
780
+ separate `input`/`output` endpoints for multi-node processors.
642
781
 
643
782
  ```typescript
644
783
  const eq = cacophony.createBiquadFilter({ type: 'highshelf', frequency: 4000, gain: 3 });
645
784
  await bus.addFilter(eq);
646
785
 
647
- const sharedWorklet = new AudioWorkletNode(cacophony.context, 'my-fx');
648
- await bus.addFilter(cacophony.shareEffect(sharedWorklet));
649
- ```
650
-
651
- FOA binaural decoding is available in both forms. Use
652
- `createFoaDecoder()` when you want explicit custom wiring through
653
- `decoder.input` and `decoder.output`; use `createFoaDecoderEffect()` only on a
654
- dedicated 4-channel ACN/SN3D FOA bus, typically as the first and only filter
655
- that converts that bus to stereo binaural output.
656
-
657
- For physically correct mono positioning, use `encodeMonoToFoaSN3D()` and feed
658
- its ACN/SN3D `[W, Y, Z, X]` output to the decoder. Stereo recordings can instead
659
- be perceptually upmixed with `createStereoToBFormatNode()`. The default
660
- `algorithm: 'perceptual'` path preserves the existing three-band front cue;
661
- `algorithm: 'bcc'` uses frequency-domain coherence and pan analysis:
662
-
663
- ```typescript
664
- const upmixer = await cacophony.createStereoToBFormatNode({ algorithm: 'bcc' });
665
- const decoder = await cacophony.createFoaDecoder();
666
- stereoSource.connect(upmixer);
667
- upmixer.connect(decoder.input);
668
- decoder.output.connect(cacophony.context.destination);
669
-
670
- // BCC analysis is stateful. Reset it after a seek or other discontinuity.
671
- upmixer.port.postMessage({ type: 'reset' });
672
- ```
673
-
674
- Both stereo upmix algorithms emit ACN `[W, Y, Z, X]` but are perceptual
675
- approximations, neither N3D nor SN3D. The BCC path produces W/Y only and keeps
676
- Z/X at zero, trading the default path's front cue for per-band spatial analysis.
786
+ const sharedWorklet = new AudioWorkletNode(cacophony.context, 'my-fx');
787
+ await bus.addFilter(cacophony.shareEffect(sharedWorklet));
788
+ ```
789
+
790
+ FOA binaural decoding is available in both forms. Use
791
+ `createFoaDecoder()` when you want explicit custom wiring through
792
+ `decoder.input` and `decoder.output`; use `createFoaDecoderEffect()` only on a
793
+ dedicated 4-channel ACN/SN3D FOA bus, typically as the first and only filter
794
+ that converts that bus to stereo binaural output.
795
+
796
+ For physically correct mono positioning, use `encodeMonoToFoaSN3D()` and feed
797
+ its ACN/SN3D `[W, Y, Z, X]` output to the decoder. Stereo recordings can instead
798
+ be perceptually upmixed with `createStereoToBFormatNode()`. The default
799
+ `algorithm: 'perceptual'` path preserves the existing three-band front cue;
800
+ `algorithm: 'bcc'` uses frequency-domain coherence and pan analysis:
801
+
802
+ ```typescript
803
+ const upmixer = await cacophony.createStereoToBFormatNode({ algorithm: 'bcc' });
804
+ const decoder = await cacophony.createFoaDecoder();
805
+ stereoSource.connect(upmixer);
806
+ upmixer.connect(decoder.input);
807
+ decoder.output.connect(cacophony.context.destination);
808
+
809
+ // BCC analysis is stateful. Reset it after a seek or other discontinuity.
810
+ upmixer.port.postMessage({ type: 'reset' });
811
+ ```
812
+
813
+ Both stereo upmix algorithms emit ACN `[W, Y, Z, X]` but are perceptual
814
+ approximations, neither N3D nor SN3D. The BCC path produces W/Y only and keeps
815
+ Z/X at zero, trading the default path's front cue for per-band spatial analysis.
677
816
 
678
817
  ### Cleaning up
679
818
 
@@ -956,7 +1095,64 @@ stereoSound.stereoPan = 0.5; // -1 (left) to 1 (right)
956
1095
  stereoSound.play();
957
1096
  ```
958
1097
 
959
- See [TypeDoc](https://cacophony.js.org) for distance models, cone effects, and advanced 3D audio options.
1098
+ See [TypeDoc](https://cacophony.js.org) for distance models, cone effects, and advanced 3D audio options.
1099
+
1100
+ `stereoPan` requires stereo mode; `position` and `threeDOptions` require HRTF mode.
1101
+ These setters validate the source and every voice before changing defaults or live
1102
+ settings. An incompatible mode, invalid value, or cleaned-up voice rejects the
1103
+ whole update. Group spatial setters also validate every member first.
1104
+
1105
+ `threeDOptions` configures an HRTF panner without switching its mode. Select the
1106
+ mode when creating the source or with `play({ panType, ... })`. If per-playback
1107
+ overrides produce mixed stereo/HRTF voices, use each voice's spatial controls.
1108
+
1109
+ `Sound.clone`, `Synth.clone`, and `Playback.clone` validate spatial overrides against
1110
+ the clone's target mode before allocation. Supply `stereoPan` for stereo clones,
1111
+ or `position` and `threeDOptions` for HRTF clones; incompatible overrides throw.
1112
+
1113
+ ### Motion smoothing
1114
+
1115
+ Source position, source orientation, stereo pan, and modern listener pose updates can
1116
+ approach their targets exponentially instead of changing in steps. Smoothing is opt-in:
1117
+ the default time constant is `0`, which keeps immediate writes.
1118
+
1119
+ ```typescript
1120
+ const audio = new Cacophony(undefined, undefined, { spatialSmoothingTau: 0.03 });
1121
+ const moving = await audio.createSound('bird.mp3', 'buffer', 'HRTF');
1122
+ moving.position = [10, 5, 0];
1123
+ const [voice] = moving.play(); // Starts at this pose immediately
1124
+
1125
+ moving.position = [12, 5, 0]; // Current voices approach the new position
1126
+ moving.spatialSmoothingTau = 0.05; // Applies to current and future voices
1127
+ voice.spatialSmoothingTau = 0.02; // Independent until the next source assignment
1128
+ audio.listenerSmoothingTau = 0.04; // Independent of source defaults
1129
+ audio.listenerPosition = [1, 0, 0];
1130
+
1131
+ moving.spatialSmoothingTau = 0; // Snap pending motion to its target
1132
+ moving.position = [30, 5, 0]; // An immediate teleport
1133
+ ```
1134
+
1135
+ Time constants are **seconds**, unlike fade/ramp durations in milliseconds. After
1136
+ one time constant, roughly 63% of the change is complete; after three, roughly 95%.
1137
+ Negative or non-finite constants throw `RangeError`. The same runtime option is
1138
+ available on `createNodeCacophony()` and `createOfflineNodeCacophony()`.
1139
+
1140
+ Sound, Synth, and stream sources inherit the instance default. A source assignment
1141
+ updates its current voices and the default for future voices. Sound, Synth, and
1142
+ Playback clones preserve the setting and requested pose. Initial source and
1143
+ `play(options)` poses are immediate; subsequent changes, including while paused,
1144
+ are smoothed. Stopping a voice snaps pending motion to its target, and a stopped
1145
+ voice takes new poses immediately, so a replayed voice starts where it was asked to.
1146
+ Partial `threeDOptions` updates leave omitted axes alone.
1147
+
1148
+ Spatial getters report the last requested targets, including during transitions.
1149
+ Use these setters consistently: direct automation or mutation of the underlying
1150
+ panner parameters is outside this target-state contract. Orientation components
1151
+ are smoothed independently; this does not provide constant-speed angular rotation.
1152
+ HRTF processing uses control-rate parameters, so smoothing reduces motion artifacts
1153
+ without promising sample-perfect continuity. Legacy listeners using
1154
+ `setPosition`/`setOrientation`, and custom parameters lacking `setTargetAtTime`,
1155
+ continue to update immediately.
960
1156
 
961
1157
  ## Microphone Input
962
1158
 
@@ -965,22 +1161,22 @@ Capture, process, and manipulate live audio input:
965
1161
  ```typescript
966
1162
  const cacophony = new Cacophony();
967
1163
 
968
- try {
969
- const micStream = await cacophony.getMicrophoneStream({
970
- panType: 'stereo',
971
- stereoPan: 0,
972
- constraints: {
973
- audio: { echoCancellation: true }
974
- }
975
- });
976
-
977
- // Apply filters to microphone input
978
- const lowPassFilter = cacophony.createBiquadFilter({ type: 'lowpass', frequency: 1000 });
979
- micStream.addFilter(lowPassFilter);
980
-
981
- // Control microphone volume
982
- micStream.volume = 0.8;
983
- micStream.play();
1164
+ try {
1165
+ const micStream = await cacophony.getMicrophoneStream({
1166
+ panType: 'stereo',
1167
+ stereoPan: 0,
1168
+ constraints: {
1169
+ audio: { echoCancellation: true }
1170
+ }
1171
+ });
1172
+
1173
+ // Apply filters to microphone input
1174
+ const lowPassFilter = cacophony.createBiquadFilter({ type: 'lowpass', frequency: 1000 });
1175
+ micStream.addFilter(lowPassFilter);
1176
+
1177
+ // Control microphone volume
1178
+ micStream.volume = 0.8;
1179
+ micStream.play();
984
1180
 
985
1181
  // Pause and resume
986
1182
  setTimeout(() => {
@@ -988,142 +1184,142 @@ try {
988
1184
  setTimeout(() => micStream.resume(), 2000);
989
1185
  }, 5000);
990
1186
 
991
- } catch (error) {
992
- // Permission and device failures reject getMicrophoneStream().
993
- console.error("Error accessing microphone:", error);
994
- }
995
- ```
996
-
997
- Microphone monitoring is routed through the master bus, so global volume,
998
- mute, master effects, and metering apply. Each playback exposes the standard
999
- play, pause, stop, and error event surface and is fully disconnected when the
1000
- microphone is stopped.
1187
+ } catch (error) {
1188
+ // Permission and device failures reject getMicrophoneStream().
1189
+ console.error("Error accessing microphone:", error);
1190
+ }
1191
+ ```
1001
1192
 
1002
- ## Audio Streaming
1003
-
1004
- `createStream()` incrementally fetches and demuxes MP3, ADTS/MP4 AAC, Ogg,
1005
- FLAC, and WAVE audio. A WebCodecs `AudioDecoder` produces PCM chunks, which are
1006
- resampled to the active audio context and written into the same AudioWorklet
1007
- ring buffer used by `createPcmStreamSound()`. The returned stream is therefore
1008
- sample-accurately scheduled, pannable, filterable, bus-routable, and meterable.
1009
-
1010
- The current streaming resampler uses linear interpolation without an
1011
- anti-aliasing filter. When downsampling, source frequencies above the target
1012
- Nyquist frequency can alias into the audible band; this is an accepted v1
1013
- quality trade-off pending a future band-limited resampler.
1014
-
1015
- ```typescript
1016
- const controller = new AbortController();
1017
- const stream = await cacophony.createStream('/music.ogg', controller.signal, 'stereo');
1018
-
1019
- console.log(stream.streamCapabilities);
1020
- // {
1021
- // transport: 'webcodecs',
1022
- // seekable: true,
1023
- // live: false,
1024
- // duration: 183.42,
1025
- // }
1026
-
1027
- stream.routeTo(musicBus);
1028
- stream.addFilter(cacophony.createBiquadFilter({ type: 'lowpass', frequency: 1800 }));
1029
- await cacophony.createLoudnessMeter(musicBus);
1030
- stream.play();
1031
- stream.seek(30); // reopens the decoder at 30s when the server supports byte ranges
1032
-
1033
- controller.abort(); // cancels fetch/decoder work and tears down the PCM worklet
1034
- ```
1035
-
1036
- `seekable` becomes `true` only after the server answers a byte-range request
1037
- with a range response. Live sources report `live: true`, `seekable: false`, and
1038
- `duration: Infinity`; calling `seek()` on one throws a clear error. A finite
1039
- file whose duration metadata is unavailable also reports `Infinity` rather
1040
- than inventing a duration.
1041
-
1042
- When `AudioDecoder` is unavailable, or the primary audio track cannot be
1043
- decoded by WebCodecs, `createStream()` returns the compatibility
1044
- media-element tier. Its `streamCapabilities.transport` is `'media-element'`;
1045
- the browser owns buffering, seeking, and live-stream behavior in that tier.
1046
-
1047
- ### Media-element streaming contract
1048
-
1049
- `createSound(url, 'streaming')` selects the media-element tier directly.
1050
- `createStream(url)` returns the same tier when WebCodecs is unavailable or
1051
- cannot decode the primary track, and always uses it for HLS. After metadata
1052
- loads, `streamCapabilities` reports the browser's duration and whether it
1053
- currently exposes a seekable range. A live source has `live: true` and
1054
- `duration: Infinity`; an ordinary finite resource reports its real duration.
1055
- The `Sound` itself has `duration === NaN` while unplayed because it has no
1056
- playback yet. After `preplay()` or `play()`, `sound.duration` and
1057
- `playback.duration` read the loaded media element and return the finite value
1058
- or `Infinity` for a live source.
1059
-
1060
- Media playback moves from unplayed to playing only after the element's
1061
- `play()` promise resolves. `pause()` moves it to paused, `resume()` calls
1062
- `play()` again and returns it to playing, and `stop()` moves it to stopped and
1063
- resets it. Playback events follow those accepted transitions: `play`, `pause`,
1064
- then both `play` and `resume` on a resume, followed by `stop`. The owning
1065
- `Sound` emits `play` after the initial accepted play, plus its collective
1066
- `pause`, `resume`, and `stop` events.
1067
-
1068
- `seek(time)` writes the media element's `currentTime` only when the browser
1069
- exposes a seekable range. A live or finite media source without such a range
1070
- throws a descriptive error and remains at its current position. Infinite
1071
- looping uses the media element's native `loop`; finite loop counts are managed
1072
- by Cacophony and emit `loopEnd` between iterations before the final `ended`.
1073
-
1074
- Every `preplay()` creates an independent media element, so a media-backed
1075
- `Sound` supports concurrent playback instances. `cleanup()` pauses each
1076
- element, clears its `src`, calls `load()` to release the resource, disconnects
1077
- its Web Audio nodes, and removes the playback. Offline `timeStretch()` requires
1078
- a decoded `AudioBuffer`, so calling `timeStretch()` on this tier throws a
1079
- predictable buffer-required error.
1080
-
1081
- For audio that arrives as decoded samples instead of a URL, use the
1082
- AudioWorklet-backed push source:
1083
-
1084
- ```typescript
1085
- const pcm = await cacophony.createPcmStreamSound({
1086
- channelCount: 1,
1087
- bufferDuration: 1,
1088
- latency: 0.05,
1089
- signal: abortController.signal,
1090
- });
1091
-
1092
- pcm.on('underrun', () => console.warn('PCM producer fell behind'));
1093
- pcm.on('drain', () => writeNextChunk());
1094
- pcm.on('ended', () => console.log('PCM playback finished'));
1095
-
1096
- pcm.play();
1097
- const hasCapacity = pcm.write(samples); // interleaved Float32Array at context.sampleRate
1098
- if (!hasCapacity) {
1099
- // The accepted write filled the ring buffer, or the chunk was rejected
1100
- // because it would exceed capacity. Wait for drain before writing again.
1101
- }
1102
- console.log(pcm.bufferedDuration);
1103
- pcm.end(); // no more writes; "ended" fires after buffered PCM is consumed
1104
- ```
1105
-
1106
- `bufferDuration` fixes the ring-buffer capacity; `latency` sets how much PCM is
1107
- collected before initial consumption. `write()` returns `false` when the buffer
1108
- needs producer backpressure. An accepted write that fills the buffer also
1109
- returns `false`; a chunk that would exceed remaining capacity is rejected and
1110
- must be retried after `drain`.
1111
-
1112
- PCM underrun emits silence plus one `underrun` event per underrun episode;
1113
- writing more samples recovers playback without repeating stale audio.
1114
- `pause()` stops consuming and `resume()` continues from the buffered frame.
1115
- Volume, stereo/HRTF pan, filters, buses, and sends use the same public APIs as
1116
- other sources. Seek is not supported for a push PCM source. Loop is not
1117
- supported because the source does not retain consumed samples.
1118
-
1119
- Adaptive HLS/DASH and DRM are outside the WebCodecs pull transport. `.m3u8`
1120
- URLs use the media tier: native HLS where `HTMLAudioElement.canPlayType()`
1121
- reports support (notably Safari), then the optional `hls.js` peer in
1122
- MSE-capable browsers. Install that peer with `npm install hls.js`. DASH and DRM
1123
- remain outside this integration.
1124
-
1125
- ```typescript
1126
- const cacophony = new Cacophony();
1193
+ Microphone monitoring is routed through the master bus, so global volume,
1194
+ mute, master effects, and metering apply. Each playback exposes the standard
1195
+ play, pause, stop, and error event surface and is fully disconnected when the
1196
+ microphone is stopped.
1197
+
1198
+ ## Audio Streaming
1199
+
1200
+ `createStream()` incrementally fetches and demuxes MP3, ADTS/MP4 AAC, Ogg,
1201
+ FLAC, and WAVE audio. A WebCodecs `AudioDecoder` produces PCM chunks, which are
1202
+ resampled to the active audio context and written into the same AudioWorklet
1203
+ ring buffer used by `createPcmStreamSound()`. The returned stream is therefore
1204
+ sample-accurately scheduled, pannable, filterable, bus-routable, and meterable.
1205
+
1206
+ The current streaming resampler uses linear interpolation without an
1207
+ anti-aliasing filter. When downsampling, source frequencies above the target
1208
+ Nyquist frequency can alias into the audible band; this is an accepted v1
1209
+ quality trade-off pending a future band-limited resampler.
1210
+
1211
+ ```typescript
1212
+ const controller = new AbortController();
1213
+ const stream = await cacophony.createStream('/music.ogg', controller.signal, 'stereo');
1214
+
1215
+ console.log(stream.streamCapabilities);
1216
+ // {
1217
+ // transport: 'webcodecs',
1218
+ // seekable: true,
1219
+ // live: false,
1220
+ // duration: 183.42,
1221
+ // }
1222
+
1223
+ stream.routeTo(musicBus);
1224
+ stream.addFilter(cacophony.createBiquadFilter({ type: 'lowpass', frequency: 1800 }));
1225
+ await cacophony.createLoudnessMeter(musicBus);
1226
+ stream.play();
1227
+ stream.seek(30); // reopens the decoder at 30s when the server supports byte ranges
1228
+
1229
+ controller.abort(); // cancels fetch/decoder work and tears down the PCM worklet
1230
+ ```
1231
+
1232
+ `seekable` becomes `true` only after the server answers a byte-range request
1233
+ with a range response. Live sources report `live: true`, `seekable: false`, and
1234
+ `duration: Infinity`; calling `seek()` on one throws a clear error. A finite
1235
+ file whose duration metadata is unavailable also reports `Infinity` rather
1236
+ than inventing a duration.
1237
+
1238
+ When `AudioDecoder` is unavailable, or the primary audio track cannot be
1239
+ decoded by WebCodecs, `createStream()` returns the compatibility
1240
+ media-element tier. Its `streamCapabilities.transport` is `'media-element'`;
1241
+ the browser owns buffering, seeking, and live-stream behavior in that tier.
1242
+
1243
+ ### Media-element streaming contract
1244
+
1245
+ `createSound(url, 'streaming')` selects the media-element tier directly.
1246
+ `createStream(url)` returns the same tier when WebCodecs is unavailable or
1247
+ cannot decode the primary track, and always uses it for HLS. After metadata
1248
+ loads, `streamCapabilities` reports the browser's duration and whether it
1249
+ currently exposes a seekable range. A live source has `live: true` and
1250
+ `duration: Infinity`; an ordinary finite resource reports its real duration.
1251
+ The `Sound` itself has `duration === NaN` while unplayed because it has no
1252
+ playback yet. After `preplay()` or `play()`, `sound.duration` and
1253
+ `playback.duration` read the loaded media element and return the finite value
1254
+ or `Infinity` for a live source.
1255
+
1256
+ Media playback moves from unplayed to playing only after the element's
1257
+ `play()` promise resolves. `pause()` moves it to paused, `resume()` calls
1258
+ `play()` again and returns it to playing, and `stop()` moves it to stopped and
1259
+ resets it. Playback events follow those accepted transitions: `play`, `pause`,
1260
+ then both `play` and `resume` on a resume, followed by `stop`. The owning
1261
+ `Sound` emits `play` after the initial accepted play, plus its collective
1262
+ `pause`, `resume`, and `stop` events.
1263
+
1264
+ `seek(time)` writes the media element's `currentTime` only when the browser
1265
+ exposes a seekable range. A live or finite media source without such a range
1266
+ throws a descriptive error and remains at its current position. Infinite
1267
+ looping uses the media element's native `loop`; finite loop counts are managed
1268
+ by Cacophony and emit `loopEnd` between iterations before the final `ended`.
1269
+
1270
+ Every `preplay()` creates an independent media element, so a media-backed
1271
+ `Sound` supports concurrent playback instances. `cleanup()` pauses each
1272
+ element, clears its `src`, calls `load()` to release the resource, disconnects
1273
+ its Web Audio nodes, and removes the playback. Offline `timeStretch()` requires
1274
+ a decoded `AudioBuffer`, so calling `timeStretch()` on this tier throws a
1275
+ predictable buffer-required error.
1276
+
1277
+ For audio that arrives as decoded samples instead of a URL, use the
1278
+ AudioWorklet-backed push source:
1279
+
1280
+ ```typescript
1281
+ const pcm = await cacophony.createPcmStreamSound({
1282
+ channelCount: 1,
1283
+ bufferDuration: 1,
1284
+ latency: 0.05,
1285
+ signal: abortController.signal,
1286
+ });
1287
+
1288
+ pcm.on('underrun', () => console.warn('PCM producer fell behind'));
1289
+ pcm.on('drain', () => writeNextChunk());
1290
+ pcm.on('ended', () => console.log('PCM playback finished'));
1291
+
1292
+ pcm.play();
1293
+ const hasCapacity = pcm.write(samples); // interleaved Float32Array at context.sampleRate
1294
+ if (!hasCapacity) {
1295
+ // The accepted write filled the ring buffer, or the chunk was rejected
1296
+ // because it would exceed capacity. Wait for drain before writing again.
1297
+ }
1298
+ console.log(pcm.bufferedDuration);
1299
+ pcm.end(); // no more writes; "ended" fires after buffered PCM is consumed
1300
+ ```
1301
+
1302
+ `bufferDuration` fixes the ring-buffer capacity; `latency` sets how much PCM is
1303
+ collected before initial consumption. `write()` returns `false` when the buffer
1304
+ needs producer backpressure. An accepted write that fills the buffer also
1305
+ returns `false`; a chunk that would exceed remaining capacity is rejected and
1306
+ must be retried after `drain`.
1307
+
1308
+ PCM underrun emits silence plus one `underrun` event per underrun episode;
1309
+ writing more samples recovers playback without repeating stale audio.
1310
+ `pause()` stops consuming and `resume()` continues from the buffered frame.
1311
+ Volume, stereo/HRTF pan, filters, buses, and sends use the same public APIs as
1312
+ other sources. Seek is not supported for a push PCM source. Loop is not
1313
+ supported because the source does not retain consumed samples.
1314
+
1315
+ Adaptive HLS/DASH and DRM are outside the WebCodecs pull transport. `.m3u8`
1316
+ URLs use the media tier: native HLS where `HTMLAudioElement.canPlayType()`
1317
+ reports support (notably Safari), then the optional `hls.js` peer in
1318
+ MSE-capable browsers. Install that peer with `npm install hls.js`. DASH and DRM
1319
+ remain outside this integration.
1320
+
1321
+ ```typescript
1322
+ const cacophony = new Cacophony();
1127
1323
 
1128
1324
  try {
1129
1325
  const streamedSound = await cacophony.createStream('https://example.com/live_radio_stream');
@@ -1304,18 +1500,18 @@ synth.type = 'square'; // Triggers typeChange event
1304
1500
  | `pause` | `void` | Fired when playback pauses. |
1305
1501
  | `resume` | `void` | Fired when playback resumes after being paused. |
1306
1502
  | `ended` | `void` | Fired when playback ends naturally. |
1307
- | `seek` | `number` | Fired once after a successful `Playback.seek(time)`, with the requested time in seconds. Rejected seeks do not emit. |
1503
+ | `seek` | `number` | Fired once after a successful `Playback.seek(time)`, with the requested time in seconds. Rejected seeks do not emit. |
1308
1504
  | `volumeChange` | `number` | Fired when playback volume changes. Receives new volume value. |
1309
1505
  | `error` | `PlaybackErrorEvent` | Fired on playback errors. Contains `error`, `errorType`, `timestamp`, `recoverable`. |
1310
1506
 
1311
- `seek` is specific to `Playback` (buffer and media-element sources). Subscribe on
1312
- each playback to observe seeks, including those requested through `Sound.seek()`.
1313
- `Sound` does not relay a collective `seek`: its concurrent voices can have
1314
- different positions, and a collective seek can fail after some voices have moved.
1315
- For media elements, the event reports acceptance of the `currentTime` write;
1316
- it does not wait for the browser's asynchronous `seeked` event or playback resumption.
1317
-
1318
- #### Synth Events
1507
+ `seek` is specific to `Playback` (buffer and media-element sources). Subscribe on
1508
+ each playback to observe seeks, including those requested through `Sound.seek()`.
1509
+ `Sound` does not relay a collective `seek`: its concurrent voices can have
1510
+ different positions, and a collective seek can fail after some voices have moved.
1511
+ For media elements, the event reports acceptance of the `currentTime` write;
1512
+ it does not wait for the browser's asynchronous `seeked` event or playback resumption.
1513
+
1514
+ #### Synth Events
1319
1515
 
1320
1516
  | Event | Payload | Description |
1321
1517
  |-------|---------|-------------|
@@ -1336,29 +1532,29 @@ Cacophony implements intelligent three-layer caching for optimal performance:
1336
1532
 
1337
1533
  **Memory Cache (LRU)** → **Browser Cache API** → **Network**
1338
1534
 
1339
- HTTP reuse decisions are made by `http-cache-semantics` for both decoded memory entries and persistent responses, using the response headers available to the runtime. Fresh responses can be reused; stale responses are conditionally validated when ETag or Last-Modified is available. `no-cache` requires validation, and `no-store` prevents retention in either layer. A successful matching 304 can reuse the decoded buffer while updating its policy.
1340
-
1341
- If a 304 cannot be matched to the stored representation, Cacophony retries with an unconditional Fetch reload. This includes a 304 that omits the ETag of an ETag-bearing stored response. Compared with the previous cache implementation, such servers can incur an additional request; return matching validators on 304 responses to allow reuse.
1342
-
1343
- A matching 304 emits `onCacheHit` with `cacheType: "conditional"`. When it reuses an already decoded buffer, it does not emit `onLoadingComplete`; when it decodes retained bytes, it emits both events. This differs from the previous implementation's completion event on every 304. Await `getAudioBuffer()` to observe completion regardless of the cache path.
1344
-
1345
- The 24-hour default TTL applies only when the response supplies no Cache-Control, Expires, Pragma, or validators. Explicit expiry never falls back to that TTL. Custom TTL values use milliseconds and are rounded down to whole seconds. Persistence uses a versioned response containing both bytes and policy; older cache entries are ignored and fetched again. On first persistent-cache access, Cacophony attempts to delete its legacy `audio-cache` namespace. Cleanup failure is reported through `onCacheError` and does not prevent loading or use of the current cache.
1346
-
1347
- `AudioCache.setCacheExpirationTime()` affects newly created response policies. Existing decoded and persisted entries keep the lifetime assigned when their policy was created, including across cache instances. Clearing decoded memory does not rewrite persistent policies. Unlike the previous implementation, negative or non-finite TTL values throw `RangeError` and leave the configured TTL unchanged; zero is valid and disables fallback freshness.
1348
-
1349
- Compatibility change: a response with only `Cache-Control: public` or `private` and no expiry or validators no longer receives the fallback TTL. These directives permit storage but do not specify how long the response is fresh. Such responses can be stored, but subsequent loads go through Fetch again. Cacophony deliberately does not assign heuristic freshness to responses with explicit cache directives. To enable fresh reuse, serve an appropriate `max-age` (for example, `private, max-age=60`) or `Expires`; increasing the fallback TTL does not override those responses.
1350
-
1351
- Cross-origin audio uses both decoded memory and persistent caching. CORS exposes Cache-Control, Expires, and Last-Modified without extra server configuration. For cross-origin entries requiring validation, Cacophony asks Fetch to revalidate through the browser HTTP cache, avoiding application-added conditional headers and their CORS preflight requirements. Browser HTTP caching remains enabled. A browser-validated response is decoded again because Fetch delivers the resulting representation rather than its internal 304 exchange.
1352
-
1353
- In browsers, the guarantee is HTTP policy based on browser-visible headers. Cross-origin caching remains enabled when CORS hides headers. A hidden Age can overestimate remaining freshness, and a hidden Vary cannot participate in application-cache matching. Browser validation applies when Fetch runs; a fresh application-cache hit does not consult the browser HTTP cache. This URL-only API explicitly controls Accept; a visible Vary on other, host-managed headers prevents application-cache retention. Data-URL decoding and caching are unchanged.
1354
-
1355
- For static audio, use versioned or content-hashed URLs such as `/audio/chime.a1b2c3.ogg` with a long `Cache-Control: max-age`. Publish changed audio at a new URL. For mutable audio requiring validation on every load, use `Cache-Control: no-cache`. Expose relevant response headers when freshness or variants depend on them:
1356
-
1357
- ```http
1358
- Access-Control-Expose-Headers: Age, Date, ETag, Vary
1359
- ```
1360
-
1361
- This header supplements the server's normal CORS access configuration. Exposing ETag does not cause Cacophony to add conditional request headers to cross-origin requests; Fetch still handles validation.
1535
+ HTTP reuse decisions are made by `http-cache-semantics` for both decoded memory entries and persistent responses, using the response headers available to the runtime. Fresh responses can be reused; stale responses are conditionally validated when ETag or Last-Modified is available. `no-cache` requires validation, and `no-store` prevents retention in either layer. A successful matching 304 can reuse the decoded buffer while updating its policy.
1536
+
1537
+ If a 304 cannot be matched to the stored representation, Cacophony retries with an unconditional Fetch reload. This includes a 304 that omits the ETag of an ETag-bearing stored response. Compared with the previous cache implementation, such servers can incur an additional request; return matching validators on 304 responses to allow reuse.
1538
+
1539
+ A matching 304 emits `onCacheHit` with `cacheType: "conditional"`. When it reuses an already decoded buffer, it does not emit `onLoadingComplete`; when it decodes retained bytes, it emits both events. This differs from the previous implementation's completion event on every 304. Await `getAudioBuffer()` to observe completion regardless of the cache path.
1540
+
1541
+ The 24-hour default TTL applies only when the response supplies no Cache-Control, Expires, Pragma, or validators. Explicit expiry never falls back to that TTL. Custom TTL values use milliseconds and are rounded down to whole seconds. Persistence uses a versioned response containing both bytes and policy; older cache entries are ignored and fetched again. On first persistent-cache access, Cacophony attempts to delete its legacy `audio-cache` namespace. Cleanup failure is reported through `onCacheError` and does not prevent loading or use of the current cache.
1542
+
1543
+ `AudioCache.setCacheExpirationTime()` affects newly created response policies. Existing decoded and persisted entries keep the lifetime assigned when their policy was created, including across cache instances. Clearing decoded memory does not rewrite persistent policies. Unlike the previous implementation, negative or non-finite TTL values throw `RangeError` and leave the configured TTL unchanged; zero is valid and disables fallback freshness.
1544
+
1545
+ Compatibility change: a response with only `Cache-Control: public` or `private` and no expiry or validators no longer receives the fallback TTL. These directives permit storage but do not specify how long the response is fresh. Such responses can be stored, but subsequent loads go through Fetch again. Cacophony deliberately does not assign heuristic freshness to responses with explicit cache directives. To enable fresh reuse, serve an appropriate `max-age` (for example, `private, max-age=60`) or `Expires`; increasing the fallback TTL does not override those responses.
1546
+
1547
+ Cross-origin audio uses both decoded memory and persistent caching. CORS exposes Cache-Control, Expires, and Last-Modified without extra server configuration. For cross-origin entries requiring validation, Cacophony asks Fetch to revalidate through the browser HTTP cache, avoiding application-added conditional headers and their CORS preflight requirements. Browser HTTP caching remains enabled. A browser-validated response is decoded again because Fetch delivers the resulting representation rather than its internal 304 exchange.
1548
+
1549
+ In browsers, the guarantee is HTTP policy based on browser-visible headers. Cross-origin caching remains enabled when CORS hides headers. A hidden Age can overestimate remaining freshness, and a hidden Vary cannot participate in application-cache matching. Browser validation applies when Fetch runs; a fresh application-cache hit does not consult the browser HTTP cache. This URL-only API explicitly controls Accept; a visible Vary on other, host-managed headers prevents application-cache retention. Data-URL decoding and caching are unchanged.
1550
+
1551
+ For static audio, use versioned or content-hashed URLs such as `/audio/chime.a1b2c3.ogg` with a long `Cache-Control: max-age`. Publish changed audio at a new URL. For mutable audio requiring validation on every load, use `Cache-Control: no-cache`. Expose relevant response headers when freshness or variants depend on them:
1552
+
1553
+ ```http
1554
+ Access-Control-Expose-Headers: Age, Date, ETag, Vary
1555
+ ```
1556
+
1557
+ This header supplements the server's normal CORS access configuration. Exposing ETag does not cause Cacophony to add conditional request headers to cross-origin requests; Fetch still handles validation.
1362
1558
 
1363
1559
  ```typescript
1364
1560
  const cacophony = new Cacophony();
@@ -1366,7 +1562,7 @@ const cacophony = new Cacophony();
1366
1562
  // First load - fetches from network, stores in cache
1367
1563
  const sound1 = await cacophony.createSound('audio.mp3');
1368
1564
 
1369
- // Second load - from memory while the HTTP policy permits reuse
1565
+ // Second load - from memory while the HTTP policy permits reuse
1370
1566
  const sound2 = await cacophony.createSound('audio.mp3');
1371
1567
 
1372
1568
  // Monitor cache performance via events
@@ -1383,12 +1579,12 @@ cacophony.on('cacheMiss', (event) => {
1383
1579
  // Clear memory cache (browser cache persists)
1384
1580
  cacophony.clearMemoryCache();
1385
1581
 
1386
- // Optional: configure TTL for responses without cache directives, expiry, or validators
1582
+ // Optional: configure TTL for responses without cache directives, expiry, or validators
1387
1583
  import { AudioCache } from 'cacophony';
1388
1584
  AudioCache.setCacheExpirationTime(60 * 60 * 1000); // 1 hour
1389
1585
  ```
1390
1586
 
1391
- Decoded buffers remain bounded to 64 MiB per audio context. Concurrent callers share fetching and decoding; cancelling one caller does not cancel the others. Storage errors are reported through cacheError while a successfully downloaded response can still be delivered.
1587
+ Decoded buffers remain bounded to 64 MiB per audio context. Concurrent callers share fetching and decoding; cancelling one caller does not cancel the others. Storage errors are reported through cacheError while a successfully downloaded response can still be delivered.
1392
1588
 
1393
1589
  ## Cancellation with AbortSignal
1394
1590