@libraz/libsonare 1.7.2 → 1.8.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 (303) hide show
  1. package/NOTICE +178 -0
  2. package/README.md +26 -203
  3. package/dist/_chain_config.d.ts +14 -0
  4. package/dist/_chain_config.d.ts.map +1 -0
  5. package/dist/_effects_common.d.ts +6 -0
  6. package/dist/_effects_common.d.ts.map +1 -0
  7. package/dist/_feature_validation.d.ts +8 -0
  8. package/dist/_feature_validation.d.ts.map +1 -0
  9. package/dist/_fft_options.d.ts +24 -0
  10. package/dist/_fft_options.d.ts.map +1 -0
  11. package/dist/align_take.d.ts +50 -0
  12. package/dist/align_take.d.ts.map +1 -0
  13. package/dist/analysis.d.ts +29 -5840
  14. package/dist/analysis.d.ts.map +1 -0
  15. package/dist/analysis.js +873 -722
  16. package/dist/analysis.js.map +1 -1
  17. package/dist/analysis_helpers.d.ts +9 -0
  18. package/dist/analysis_helpers.d.ts.map +1 -0
  19. package/dist/audio.d.ts +163 -0
  20. package/dist/audio.d.ts.map +1 -0
  21. package/dist/clip_page_streamer.d.ts +133 -0
  22. package/dist/clip_page_streamer.d.ts.map +1 -0
  23. package/dist/codes.d.ts +44 -0
  24. package/dist/codes.d.ts.map +1 -0
  25. package/dist/effects_mastering.d.ts +23 -0
  26. package/dist/effects_mastering.d.ts.map +1 -0
  27. package/dist/effects_note_ops.d.ts +477 -0
  28. package/dist/effects_note_ops.d.ts.map +1 -0
  29. package/dist/effects_percussive.d.ts +185 -0
  30. package/dist/effects_percussive.d.ts.map +1 -0
  31. package/dist/effects_separation.d.ts +65 -0
  32. package/dist/effects_separation.d.ts.map +1 -0
  33. package/dist/effects_spectral.d.ts +28 -0
  34. package/dist/effects_spectral.d.ts.map +1 -0
  35. package/dist/effects_timepitch.d.ts +129 -0
  36. package/dist/effects_timepitch.d.ts.map +1 -0
  37. package/dist/effects_voice_change.d.ts +53 -0
  38. package/dist/effects_voice_change.d.ts.map +1 -0
  39. package/dist/errors.d.ts +51 -0
  40. package/dist/errors.d.ts.map +1 -0
  41. package/dist/feature_core.d.ts +341 -0
  42. package/dist/feature_core.d.ts.map +1 -0
  43. package/dist/feature_decompose.d.ts +278 -0
  44. package/dist/feature_decompose.d.ts.map +1 -0
  45. package/dist/feature_inverse.d.ts +128 -0
  46. package/dist/feature_inverse.d.ts.map +1 -0
  47. package/dist/feature_loudness.d.ts +66 -0
  48. package/dist/feature_loudness.d.ts.map +1 -0
  49. package/dist/feature_music.d.ts +307 -0
  50. package/dist/feature_music.d.ts.map +1 -0
  51. package/dist/feature_pitch.d.ts +108 -0
  52. package/dist/feature_pitch.d.ts.map +1 -0
  53. package/dist/feature_resample.d.ts +16 -0
  54. package/dist/feature_resample.d.ts.map +1 -0
  55. package/dist/feature_spectral.d.ts +137 -0
  56. package/dist/feature_spectral.d.ts.map +1 -0
  57. package/dist/feature_spectrogram.d.ts +198 -0
  58. package/dist/feature_spectrogram.d.ts.map +1 -0
  59. package/dist/features.d.ts +10 -0
  60. package/dist/features.d.ts.map +1 -0
  61. package/dist/hrtf/default.shrf +0 -0
  62. package/dist/index.d.ts +74 -7431
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +3758 -1362
  65. package/dist/index.js.map +1 -1
  66. package/dist/instrument_types.d.ts +517 -0
  67. package/dist/instrument_types.d.ts.map +1 -0
  68. package/dist/live_audio.d.ts +35 -0
  69. package/dist/live_audio.d.ts.map +1 -0
  70. package/dist/mastering_chain.d.ts +213 -0
  71. package/dist/mastering_chain.d.ts.map +1 -0
  72. package/dist/mastering_core.d.ts +457 -0
  73. package/dist/mastering_core.d.ts.map +1 -0
  74. package/dist/mastering_dynamics.d.ts +80 -0
  75. package/dist/mastering_dynamics.d.ts.map +1 -0
  76. package/dist/metering.d.ts +287 -0
  77. package/dist/metering.d.ts.map +1 -0
  78. package/dist/mixer.d.ts +464 -0
  79. package/dist/mixer.d.ts.map +1 -0
  80. package/dist/mixing_assistant.d.ts +62 -0
  81. package/dist/mixing_assistant.d.ts.map +1 -0
  82. package/dist/mixing_oneshot.d.ts +40 -0
  83. package/dist/mixing_oneshot.d.ts.map +1 -0
  84. package/dist/module_state.d.ts +15 -0
  85. package/dist/module_state.d.ts.map +1 -0
  86. package/dist/opfs_clip_pages.d.ts +28 -0
  87. package/dist/opfs_clip_pages.d.ts.map +1 -0
  88. package/dist/playback_renderer.d.ts +128 -0
  89. package/dist/playback_renderer.d.ts.map +1 -0
  90. package/dist/polyphony.d.ts +202 -0
  91. package/dist/polyphony.d.ts.map +1 -0
  92. package/dist/project.d.ts +8 -0
  93. package/dist/project.d.ts.map +1 -0
  94. package/dist/project_class.d.ts +562 -0
  95. package/dist/project_class.d.ts.map +1 -0
  96. package/dist/project_internal.d.ts +194 -0
  97. package/dist/project_internal.d.ts.map +1 -0
  98. package/dist/project_synth.d.ts +74 -0
  99. package/dist/project_synth.d.ts.map +1 -0
  100. package/dist/project_types.d.ts +652 -0
  101. package/dist/project_types.d.ts.map +1 -0
  102. package/dist/public_types.d.ts +185 -0
  103. package/dist/public_types.d.ts.map +1 -0
  104. package/dist/public_types_acoustic.d.ts +215 -0
  105. package/dist/public_types_acoustic.d.ts.map +1 -0
  106. package/dist/public_types_mastering.d.ts +510 -0
  107. package/dist/public_types_mastering.d.ts.map +1 -0
  108. package/dist/public_types_mixing.d.ts +436 -0
  109. package/dist/public_types_mixing.d.ts.map +1 -0
  110. package/dist/public_types_music.d.ts +619 -0
  111. package/dist/public_types_music.d.ts.map +1 -0
  112. package/dist/public_types_playback.d.ts +164 -0
  113. package/dist/public_types_playback.d.ts.map +1 -0
  114. package/dist/public_types_realtime.d.ts +174 -0
  115. package/dist/public_types_realtime.d.ts.map +1 -0
  116. package/dist/public_types_repair.d.ts +424 -0
  117. package/dist/public_types_repair.d.ts.map +1 -0
  118. package/dist/public_types_spectral.d.ts +697 -0
  119. package/dist/public_types_spectral.d.ts.map +1 -0
  120. package/dist/quick_analysis.d.ts +445 -0
  121. package/dist/quick_analysis.d.ts.map +1 -0
  122. package/dist/realtime_engine.d.ts +850 -0
  123. package/dist/realtime_engine.d.ts.map +1 -0
  124. package/dist/realtime_voice_changer.d.ts +158 -0
  125. package/dist/realtime_voice_changer.d.ts.map +1 -0
  126. package/dist/repair_dereverb.d.ts +187 -0
  127. package/dist/repair_dereverb.d.ts.map +1 -0
  128. package/dist/repair_impulsive.d.ts +186 -0
  129. package/dist/repair_impulsive.d.ts.map +1 -0
  130. package/dist/repair_noise.d.ts +239 -0
  131. package/dist/repair_noise.d.ts.map +1 -0
  132. package/dist/repair_trim.d.ts +123 -0
  133. package/dist/repair_trim.d.ts.map +1 -0
  134. package/dist/sample_bank.d.ts +84 -0
  135. package/dist/sample_bank.d.ts.map +1 -0
  136. package/dist/scale.d.ts +10 -0
  137. package/dist/scale.d.ts.map +1 -0
  138. package/dist/schemas/mixer-scene.schema.json +393 -0
  139. package/dist/schemas/playback-renderer-config.schema.json +392 -0
  140. package/dist/sonare-analysis.d.ts +8 -0
  141. package/dist/sonare-analysis.js +2 -2
  142. package/dist/sonare-analysis.wasm +0 -0
  143. package/dist/sonare.d.ts +3909 -0
  144. package/dist/sonare.js +2 -2
  145. package/dist/sonare.wasm +0 -0
  146. package/dist/stream_analyzer.d.ts +163 -0
  147. package/dist/stream_analyzer.d.ts.map +1 -0
  148. package/dist/stream_types.d.ts +214 -0
  149. package/dist/stream_types.d.ts.map +1 -0
  150. package/dist/streaming_mixing.d.ts +6 -0
  151. package/dist/streaming_mixing.d.ts.map +1 -0
  152. package/dist/streaming_processors.d.ts +335 -0
  153. package/dist/streaming_processors.d.ts.map +1 -0
  154. package/dist/transcribe.d.ts +77 -0
  155. package/dist/transcribe.d.ts.map +1 -0
  156. package/dist/validation.d.ts +140 -0
  157. package/dist/validation.d.ts.map +1 -0
  158. package/dist/web_midi.d.ts +77 -0
  159. package/dist/web_midi.d.ts.map +1 -0
  160. package/dist/worker.d.ts +5 -48
  161. package/dist/worker.d.ts.map +1 -0
  162. package/dist/worker.js +94 -41
  163. package/dist/worker.js.map +1 -1
  164. package/dist/worker_client.d.ts +96 -0
  165. package/dist/worker_client.d.ts.map +1 -0
  166. package/dist/worker_protocol.d.ts +43 -0
  167. package/dist/worker_protocol.d.ts.map +1 -0
  168. package/dist/worklet/audio_types.d.ts +21 -0
  169. package/dist/worklet/audio_types.d.ts.map +1 -0
  170. package/dist/worklet/engine-automation.d.ts +29 -0
  171. package/dist/worklet/engine-automation.d.ts.map +1 -0
  172. package/dist/worklet/engine-capture-facade.d.ts +35 -0
  173. package/dist/worklet/engine-capture-facade.d.ts.map +1 -0
  174. package/dist/worklet/engine-clips.d.ts +23 -0
  175. package/dist/worklet/engine-clips.d.ts.map +1 -0
  176. package/dist/worklet/engine-markers.d.ts +40 -0
  177. package/dist/worklet/engine-markers.d.ts.map +1 -0
  178. package/dist/worklet/engine-mixer-facade.d.ts +128 -0
  179. package/dist/worklet/engine-mixer-facade.d.ts.map +1 -0
  180. package/dist/worklet/engine-node.d.ts +81 -0
  181. package/dist/worklet/engine-node.d.ts.map +1 -0
  182. package/dist/worklet/engine-offline.d.ts +81 -0
  183. package/dist/worklet/engine-offline.d.ts.map +1 -0
  184. package/dist/worklet/engine-options.d.ts +12 -0
  185. package/dist/worklet/engine-options.d.ts.map +1 -0
  186. package/dist/worklet/engine-parameter-facade.d.ts +106 -0
  187. package/dist/worklet/engine-parameter-facade.d.ts.map +1 -0
  188. package/dist/worklet/engine-processor.d.ts +68 -0
  189. package/dist/worklet/engine-processor.d.ts.map +1 -0
  190. package/dist/worklet/engine-register.d.ts +2 -0
  191. package/dist/worklet/engine-register.d.ts.map +1 -0
  192. package/dist/worklet/engine-strips.d.ts +73 -0
  193. package/dist/worklet/engine-strips.d.ts.map +1 -0
  194. package/dist/worklet/engine-sync.d.ts +37 -0
  195. package/dist/worklet/engine-sync.d.ts.map +1 -0
  196. package/dist/worklet/engine-tempo-facade.d.ts +48 -0
  197. package/dist/worklet/engine-tempo-facade.d.ts.map +1 -0
  198. package/dist/worklet/engine.d.ts +397 -0
  199. package/dist/worklet/engine.d.ts.map +1 -0
  200. package/dist/worklet/guards.d.ts +47 -0
  201. package/dist/worklet/guards.d.ts.map +1 -0
  202. package/dist/worklet/messages.d.ts +710 -0
  203. package/dist/worklet/messages.d.ts.map +1 -0
  204. package/dist/worklet/mixer-processor.d.ts +46 -0
  205. package/dist/worklet/mixer-processor.d.ts.map +1 -0
  206. package/dist/worklet/playback-processor.d.ts +62 -0
  207. package/dist/worklet/playback-processor.d.ts.map +1 -0
  208. package/dist/worklet/protocol.d.ts +323 -0
  209. package/dist/worklet/protocol.d.ts.map +1 -0
  210. package/dist/worklet/voice-changer-processor.d.ts +41 -0
  211. package/dist/worklet/voice-changer-processor.d.ts.map +1 -0
  212. package/dist/worklet.d.ts +16 -2515
  213. package/dist/worklet.d.ts.map +1 -0
  214. package/dist/worklet.js +2763 -501
  215. package/dist/worklet.js.map +1 -1
  216. package/package.json +23 -12
  217. package/src/_effects_common.ts +17 -0
  218. package/src/_feature_validation.ts +34 -0
  219. package/src/_fft_options.ts +39 -0
  220. package/src/align_take.ts +64 -0
  221. package/src/analysis.ts +56 -3
  222. package/src/analysis_helpers.ts +7 -0
  223. package/src/audio.ts +106 -3
  224. package/src/codes.ts +39 -2
  225. package/src/effects_mastering.ts +97 -22
  226. package/src/effects_note_ops.ts +635 -0
  227. package/src/effects_percussive.ts +217 -0
  228. package/src/effects_separation.ts +150 -0
  229. package/src/effects_spectral.ts +60 -0
  230. package/src/effects_timepitch.ts +377 -0
  231. package/src/errors.ts +23 -1
  232. package/src/feature_core.ts +127 -2
  233. package/src/feature_decompose.ts +633 -0
  234. package/src/feature_inverse.ts +454 -0
  235. package/src/feature_loudness.ts +125 -0
  236. package/src/feature_music.ts +107 -14
  237. package/src/feature_pitch.ts +96 -1
  238. package/src/feature_spectral.ts +16 -611
  239. package/src/feature_spectrogram.ts +63 -450
  240. package/src/features.ts +36 -22
  241. package/src/index.ts +282 -30
  242. package/src/instrument_types.ts +645 -0
  243. package/src/live_audio.ts +27 -1
  244. package/src/mastering_chain.ts +184 -0
  245. package/src/mastering_core.ts +346 -32
  246. package/src/mastering_dynamics.ts +22 -11
  247. package/src/metering.ts +67 -24
  248. package/src/mixer.ts +212 -3
  249. package/src/mixing_assistant.ts +138 -0
  250. package/src/mixing_oneshot.ts +10 -5
  251. package/src/module_state.ts +24 -2
  252. package/src/playback_renderer.ts +252 -0
  253. package/src/polyphony.ts +279 -0
  254. package/src/project.ts +61 -24
  255. package/src/project_class.ts +450 -27
  256. package/src/project_internal.ts +149 -42
  257. package/src/project_synth.ts +67 -1
  258. package/src/project_types.ts +268 -270
  259. package/src/public_types.ts +122 -3
  260. package/src/public_types_acoustic.ts +112 -3
  261. package/src/public_types_mastering.ts +254 -73
  262. package/src/public_types_mixing.ts +363 -1
  263. package/src/public_types_music.ts +312 -2
  264. package/src/public_types_playback.ts +195 -0
  265. package/src/public_types_realtime.ts +39 -7
  266. package/src/public_types_repair.ts +446 -0
  267. package/src/public_types_spectral.ts +487 -1
  268. package/src/quick_analysis.ts +203 -26
  269. package/src/realtime_engine.ts +711 -28
  270. package/src/realtime_voice_changer.ts +55 -1
  271. package/src/repair_dereverb.ts +299 -0
  272. package/src/repair_impulsive.ts +395 -0
  273. package/src/repair_noise.ts +425 -0
  274. package/src/repair_trim.ts +226 -0
  275. package/src/sample_bank.ts +113 -0
  276. package/src/sonare.js.d.ts +1122 -30
  277. package/src/stream_analyzer.ts +36 -4
  278. package/src/stream_types.ts +37 -0
  279. package/src/streaming_mixing.ts +1 -1
  280. package/src/streaming_processors.ts +194 -10
  281. package/src/transcribe.ts +89 -0
  282. package/src/validation.ts +285 -11
  283. package/src/web_midi.ts +1 -6
  284. package/src/worker.ts +18 -2
  285. package/src/worklet/audio_types.ts +37 -0
  286. package/src/worklet/engine-mixer-facade.ts +444 -10
  287. package/src/worklet/engine-node.ts +78 -26
  288. package/src/worklet/engine-offline.ts +14 -8
  289. package/src/worklet/engine-parameter-facade.ts +21 -0
  290. package/src/worklet/engine-processor.ts +272 -83
  291. package/src/worklet/engine-register.ts +32 -18
  292. package/src/worklet/engine-strips.ts +280 -9
  293. package/src/worklet/engine-sync.ts +14 -6
  294. package/src/worklet/engine.ts +365 -31
  295. package/src/worklet/guards.ts +140 -44
  296. package/src/worklet/messages.ts +227 -2
  297. package/src/worklet/mixer-processor.ts +109 -47
  298. package/src/worklet/playback-processor.ts +300 -0
  299. package/src/worklet/protocol.ts +53 -3
  300. package/src/worklet/voice-changer-processor.ts +17 -11
  301. package/src/worklet.ts +17 -0
  302. package/src/effects_transform.ts +0 -718
  303. package/src/mastering_repair.ts +0 -273
@@ -1,7 +1,7 @@
1
1
  import type { MixerRealtimeBuffer } from '../index';
2
2
  import { Mixer } from '../index';
3
3
  import type { WorkletInput, WorkletOutput } from './audio_types';
4
- import { isWorkletMessage } from './guards';
4
+ import { isWorkletMessage, requireIntegerOption } from './guards';
5
5
  import type {
6
6
  SonareWorkletMessage,
7
7
  SonareWorkletProcessorOptions,
@@ -19,7 +19,6 @@ import {
19
19
  type SonareWorkletMeterSnapshot,
20
20
  type SonareWorkletSpectrumSnapshot,
21
21
  spectrumRingFromSharedBuffer,
22
- toDb,
23
22
  } from './protocol';
24
23
 
25
24
  /**
@@ -43,6 +42,30 @@ export class SonareWorkletProcessor {
43
42
  private lastSpectrumFrame = 0;
44
43
  private transport?: WorkletTransport;
45
44
  private meterRing?: SharedMeterRingWriter;
45
+ /**
46
+ * Reused meter record, so a publish writes fields instead of allocating one.
47
+ *
48
+ * `targetId` is always the master and the four LUFS / gain-reduction fields
49
+ * are always unavailable here — the mixer worklet does not run the
50
+ * K-weighting filters, and a floor value would read as silence — so both are
51
+ * set once rather than per interval.
52
+ */
53
+ private readonly meterScratch: SonareWorkletMeterSnapshot = {
54
+ type: 'meter',
55
+ targetId: 0,
56
+ frame: 0,
57
+ peakDbL: 0,
58
+ peakDbR: 0,
59
+ rmsDbL: 0,
60
+ rmsDbR: 0,
61
+ correlation: 0,
62
+ truePeakDbL: 0,
63
+ truePeakDbR: 0,
64
+ momentaryLufs: Number.NaN,
65
+ shortTermLufs: Number.NaN,
66
+ integratedLufs: Number.NaN,
67
+ gainReductionDb: Number.NaN,
68
+ };
46
69
  private spectrumRing?: SharedSpectrumRingWriter;
47
70
  private spectrumBands: Float32Array;
48
71
 
@@ -52,10 +75,20 @@ export class SonareWorkletProcessor {
52
75
  }
53
76
  this.sampleRate = options.sampleRate ?? 48000;
54
77
  this.blockSize = options.blockSize ?? 128;
55
- this.meterIntervalFrames = Math.max(0, Math.floor(options.meterIntervalFrames ?? 2048));
56
- this.spectrumIntervalFrames = Math.max(0, Math.floor(options.spectrumIntervalFrames ?? 0));
78
+ // Zero is the off switch for both publications, so the floor is 0.
79
+ this.meterIntervalFrames = requireIntegerOption(
80
+ options.meterIntervalFrames,
81
+ 2048,
82
+ 'meterIntervalFrames',
83
+ 0,
84
+ );
85
+ this.spectrumIntervalFrames = requireIntegerOption(
86
+ options.spectrumIntervalFrames,
87
+ 0,
88
+ 'spectrumIntervalFrames',
89
+ 0,
90
+ );
57
91
  this.transport = transport;
58
- this.meterIntervalFrames = Math.max(0, Math.floor(options.meterIntervalFrames ?? 2048));
59
92
  this.meterRing = options.meterSharedBuffer
60
93
  ? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity)
61
94
  : undefined;
@@ -76,6 +109,10 @@ export class SonareWorkletProcessor {
76
109
  throw new Error('stripCount must match the scene strip count.');
77
110
  }
78
111
  this.realtime = this.mixer.createRealtimeBuffer();
112
+ // The mixer meters the master it just produced, so the true-peak filter sees
113
+ // every block without the worklet copying the output back in. 4x is the
114
+ // BS.1770-4 Annex 2 minimum.
115
+ this.mixer.configureMeter(this.meterIntervalFrames > 0, 4);
79
116
  }
80
117
 
81
118
  process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
@@ -132,10 +169,7 @@ export class SonareWorkletProcessor {
132
169
  }
133
170
  }
134
171
  this.processedFrames += usable;
135
- this.publishMeter(
136
- this.realtime.outLeft.subarray(0, usable),
137
- this.realtime.outRight.subarray(0, usable),
138
- );
172
+ this.publishMeter();
139
173
  this.publishSpectrum(
140
174
  this.realtime.outLeft.subarray(0, usable),
141
175
  this.realtime.outRight.subarray(0, usable),
@@ -152,7 +186,15 @@ export class SonareWorkletProcessor {
152
186
  return;
153
187
  }
154
188
  if (message.type === 'setMeterInterval') {
155
- this.meterIntervalFrames = Math.max(0, Math.floor(message.frames));
189
+ const frames = Math.max(0, Math.floor(message.frames));
190
+ // Toggling metering also toggles the mixer's meter, so a disabled meter
191
+ // costs nothing per block. Re-enabling restarts it: the filter history and
192
+ // the published snapshot are from before the gap, and carrying them
193
+ // forward would report a peak the caller never asked to be measured.
194
+ if (frames > 0 !== this.meterIntervalFrames > 0) {
195
+ this.mixer.configureMeter(frames > 0, 4);
196
+ }
197
+ this.meterIntervalFrames = frames;
156
198
  return;
157
199
  }
158
200
  if (message.type === 'scheduleInsertAutomation') {
@@ -174,8 +216,10 @@ export class SonareWorkletProcessor {
174
216
  }
175
217
  }
176
218
 
177
- private publishMeter(left: Float32Array, right: Float32Array): void {
178
- if (!this.transport || this.meterIntervalFrames <= 0) {
219
+ private publishMeter(): void {
220
+ // Symmetric with the engine processor: a ring-only configuration still has
221
+ // meters to publish, so the absence of a transport alone must not skip it.
222
+ if ((!this.transport && !this.meterRing) || this.meterIntervalFrames <= 0) {
179
223
  return;
180
224
  }
181
225
  if (this.processedFrames - this.lastMeterFrame < this.meterIntervalFrames) {
@@ -183,50 +227,64 @@ export class SonareWorkletProcessor {
183
227
  }
184
228
  this.lastMeterFrame = this.processedFrames;
185
229
 
186
- let peakL = 0;
187
- let peakR = 0;
188
- let sumL = 0;
189
- let sumR = 0;
190
- let sumLR = 0;
191
- for (let i = 0; i < left.length; i++) {
192
- const l = left[i] ?? 0;
193
- const r = right[i] ?? 0;
194
- const absL = Math.abs(l);
195
- const absR = Math.abs(r);
196
- if (absL > peakL) {
197
- peakL = absL;
198
- }
199
- if (absR > peakR) {
200
- peakR = absR;
201
- }
202
- sumL += l * l;
203
- sumR += r * r;
204
- sumLR += l * r;
230
+ // Latch the reading into the mixer's own scratch and read the seven fields
231
+ // back as numbers. `meterSnapshot()` returns a fresh embind object, so
232
+ // calling it here would allocate on the render thread once per interval —
233
+ // about 23 per second at the default — no matter what this file then does
234
+ // with the result. Scratch field order, shared with the engine's
235
+ // meterScratchValue: 0 peakDbL, 1 peakDbR, 2 rmsDbL, 3 rmsDbR,
236
+ // 4 correlation, 5 truePeakDbL, 6 truePeakDbR.
237
+ if (!this.mixer.latchMeterSnapshot()) {
238
+ // The meter has never been enabled, so there is no reading to publish.
239
+ return;
240
+ }
241
+ if (this.meterRing) {
242
+ // Fill the reusable record and serialise it straight into the ring, which
243
+ // exists precisely so this thread allocates and posts nothing. The record
244
+ // never escapes, so reuse is safe here in a way it is not on the
245
+ // postMessage path below.
246
+ const meter = this.meterScratch;
247
+ meter.frame = this.processedFrames;
248
+ meter.peakDbL = this.mixer.meterScratchValue(0);
249
+ meter.peakDbR = this.mixer.meterScratchValue(1);
250
+ meter.rmsDbL = this.mixer.meterScratchValue(2);
251
+ meter.rmsDbR = this.mixer.meterScratchValue(3);
252
+ meter.correlation = this.mixer.meterScratchValue(4);
253
+ meter.truePeakDbL = this.mixer.meterScratchValue(5);
254
+ meter.truePeakDbR = this.mixer.meterScratchValue(6);
255
+ this.writeMeterRing(meter);
256
+ return;
205
257
  }
206
- const rmsL = Math.sqrt(sumL / Math.max(1, left.length));
207
- const rmsR = Math.sqrt(sumR / Math.max(1, right.length));
208
- const denominator = Math.sqrt(sumL * sumR);
258
+ // No ring: this is the structured-clone fallback, already off the
259
+ // zero-allocation contract, and a listener may retain what it is handed. It
260
+ // still reads the latched scalars rather than `meterSnapshot()`, so the one
261
+ // object it does build is the one it posts.
209
262
  const meter: SonareWorkletMeterSnapshot = {
210
263
  type: 'meter',
211
264
  targetId: 0,
212
265
  frame: this.processedFrames,
213
- peakDbL: toDb(peakL),
214
- peakDbR: toDb(peakR),
215
- rmsDbL: toDb(rmsL),
216
- rmsDbR: toDb(rmsR),
217
- correlation: denominator > 0 ? sumLR / denominator : 0,
218
- truePeakDbL: toDb(peakL),
219
- truePeakDbR: toDb(peakR),
266
+ peakDbL: this.mixer.meterScratchValue(0),
267
+ peakDbR: this.mixer.meterScratchValue(1),
268
+ rmsDbL: this.mixer.meterScratchValue(2),
269
+ rmsDbR: this.mixer.meterScratchValue(3),
270
+ correlation: this.mixer.meterScratchValue(4),
271
+ truePeakDbL: this.mixer.meterScratchValue(5),
272
+ truePeakDbR: this.mixer.meterScratchValue(6),
273
+ // Declared unavailable rather than floored: the mixer worklet does not run
274
+ // the K-weighting filters, and a floor value would read as silence.
220
275
  momentaryLufs: Number.NaN,
221
276
  shortTermLufs: Number.NaN,
222
277
  integratedLufs: Number.NaN,
223
278
  gainReductionDb: Number.NaN,
224
279
  };
225
- this.transport.onMeter?.(meter);
226
- if (this.meterRing) {
227
- this.writeMeterRing(meter);
280
+ // Alternative channels for one record, not a broadcast pair: a transport
281
+ // that supplies both (the engine registration resolves both to
282
+ // `port.postMessage`) would otherwise deliver every record twice and double
283
+ // the host's peak-hold decay rate.
284
+ if (this.transport?.onMeter) {
285
+ this.transport.onMeter(meter);
228
286
  } else {
229
- this.transport.postMessage?.(meter);
287
+ this.transport?.postMessage?.(meter);
230
288
  }
231
289
  }
232
290
 
@@ -277,8 +335,12 @@ export class SonareWorkletProcessor {
277
335
  frame: this.processedFrames,
278
336
  bands: new Float32Array(this.spectrumBands),
279
337
  };
280
- this.transport?.onSpectrum?.(spectrum);
281
- this.transport?.postMessage?.(spectrum);
338
+ // One record, one delivery -- see publishMeter above.
339
+ if (this.transport?.onSpectrum) {
340
+ this.transport.onSpectrum(spectrum);
341
+ } else {
342
+ this.transport?.postMessage?.(spectrum);
343
+ }
282
344
  }
283
345
 
284
346
  private computeSpectrum(left: Float32Array, right: Float32Array): void {
@@ -0,0 +1,300 @@
1
+ import { HrtfSet, PlaybackRenderer } from '../index';
2
+ import type { PlaybackRendererConfig } from '../public_types_playback';
3
+ import type { WasmPlaybackRenderer } from '../sonare.js';
4
+ import { copyPlanesToOutput, type WorkletInput, type WorkletOutput } from './audio_types';
5
+ import { isPlaybackMessage, requireIntegerOption } from './guards';
6
+ import type {
7
+ SonarePlaybackDiagnosticsReplyMessage,
8
+ SonarePlaybackErrorMessage,
9
+ SonarePlaybackMessage,
10
+ SonarePlaybackNodeOptions,
11
+ SonarePlaybackWorkletProcessorOptions,
12
+ WorkletPort,
13
+ } from './messages';
14
+
15
+ /** Largest input channel count any layout accepts (7.1). */
16
+ const MAX_INPUT_CHANNELS = 8;
17
+ const SPEAKER_LAYOUT_CHANNELS: Readonly<Record<string, number>> = { stereo: 2, '5.1': 6, '7.1': 8 };
18
+
19
+ function configObject(config: PlaybackRendererConfig | string | undefined): PlaybackRendererConfig {
20
+ if (config === undefined) {
21
+ return {};
22
+ }
23
+ return typeof config === 'string' ? (JSON.parse(config) as PlaybackRendererConfig) : config;
24
+ }
25
+
26
+ /** The target's channel count, known before any renderer exists (headphones: 2). */
27
+ function outputChannelCount(config: PlaybackRendererConfig): number {
28
+ if (config.target?.kind !== 'speakers') {
29
+ return 2;
30
+ }
31
+ const count = SPEAKER_LAYOUT_CHANNELS[config.target.layout ?? ''];
32
+ if (count === undefined) {
33
+ throw new RangeError('target.layout must be "stereo", "5.1" or "7.1" for a speakers target');
34
+ }
35
+ return count;
36
+ }
37
+
38
+ /**
39
+ * The playback renderer inside an AudioWorklet.
40
+ *
41
+ * `inputs[0].length` is the block's input channel count, so a
42
+ * `MediaElementAudioSourceNode` whose channel count follows the media drives
43
+ * `input.layout: "auto"` directly. A block with no input channels (no active
44
+ * connection: paused, ended, loading) renders silence on the active layout. A
45
+ * block whose channel count the renderer refuses renders silence the same way
46
+ * and counts toward `unsupported_input_blocks`; either way the renderer
47
+ * advances by the block, so the timeline never shifts. Nothing throws on the
48
+ * audio thread.
49
+ *
50
+ * Every buffer is allocated in the constructor: `process()` copies into WASM
51
+ * heap planes and allocates nothing.
52
+ */
53
+ export class SonarePlaybackWorkletProcessor {
54
+ private static warnedBlockOverflow = false;
55
+ private readonly renderer: PlaybackRenderer;
56
+ private readonly native: WasmPlaybackRenderer;
57
+ private readonly port?: WorkletPort;
58
+ private readonly maxBlockSize: number;
59
+ private readonly outputChannels: number;
60
+ private inputPlanes: Float32Array[] = [];
61
+ private outputPlanes: Float32Array[] = [];
62
+ private unsupportedInputBlocks = 0;
63
+ private destroyed = false;
64
+
65
+ constructor(options: SonarePlaybackWorkletProcessorOptions = {}, port?: WorkletPort) {
66
+ this.port = port;
67
+ this.maxBlockSize = requireIntegerOption(options.maxBlockSize, 128, 'maxBlockSize', 1);
68
+ const scopeRate = (globalThis as { sampleRate?: unknown }).sampleRate;
69
+ const sampleRate = options.sampleRate ?? (typeof scopeRate === 'number' ? scopeRate : 48000);
70
+ const hrtf = options.hrtf ? HrtfSet.fromBytes(new Uint8Array(options.hrtf)) : undefined;
71
+ try {
72
+ this.renderer = new PlaybackRenderer({
73
+ config: options.config ?? {},
74
+ hrtf,
75
+ sampleRate,
76
+ maxBlockSize: this.maxBlockSize,
77
+ });
78
+ } finally {
79
+ // The renderer keeps its own copy of the set.
80
+ hrtf?.delete();
81
+ }
82
+ // One reviewed read of the facade's private handle, for the heap-plane path.
83
+ this.native = (this.renderer as unknown as { native: WasmPlaybackRenderer }).native;
84
+ this.outputChannels = this.native.outputChannels();
85
+ this.acquirePlanes();
86
+ }
87
+
88
+ /**
89
+ * Handles a control-plane message. AudioWorklet port handlers run on the
90
+ * rendering thread between `process()` calls, which is what makes `reset`
91
+ * safe here. A refused message is answered with an `error` message.
92
+ */
93
+ receiveMessage(message: SonarePlaybackMessage): void {
94
+ if (this.destroyed) {
95
+ return;
96
+ }
97
+ try {
98
+ switch (message.type) {
99
+ case 'config':
100
+ this.renderer.setConfig(message.config);
101
+ break;
102
+ case 'orientation':
103
+ this.renderer.setHeadOrientation(message.yaw, message.pitch ?? 0, message.roll ?? 0);
104
+ break;
105
+ case 'reset':
106
+ this.renderer.reset();
107
+ break;
108
+ case 'diagnostics':
109
+ this.port?.postMessage?.({
110
+ type: 'diagnostics',
111
+ diagnostics: this.diagnostics(),
112
+ } satisfies SonarePlaybackDiagnosticsReplyMessage);
113
+ break;
114
+ case 'destroy':
115
+ this.destroy();
116
+ break;
117
+ }
118
+ } catch (error) {
119
+ this.port?.postMessage?.({
120
+ type: 'error',
121
+ request: message.type,
122
+ message: error instanceof Error ? error.message : String(error),
123
+ } satisfies SonarePlaybackErrorMessage);
124
+ }
125
+ }
126
+
127
+ /** The renderer's diagnostics plus `unsupported_input_blocks`, as a plain object. */
128
+ diagnostics(): SonarePlaybackDiagnosticsReplyMessage['diagnostics'] {
129
+ return {
130
+ ...this.renderer.diagnostics(),
131
+ unsupported_input_blocks: this.unsupportedInputBlocks,
132
+ };
133
+ }
134
+
135
+ process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
136
+ if (this.destroyed) {
137
+ return false;
138
+ }
139
+ const output = outputs[0];
140
+ const requested = output?.[0]?.length ?? 0;
141
+ if (!output || requested === 0) {
142
+ return true;
143
+ }
144
+ const frames = this.clampFrames(requested);
145
+ // Heap views detach when WASM linear memory grows; the storage behind them
146
+ // never moves, so re-acquiring is allocation-free on the native side.
147
+ if (this.inputPlanes[0]?.byteLength === 0 || this.outputPlanes[0]?.byteLength === 0) {
148
+ this.acquirePlanes();
149
+ }
150
+
151
+ const input = inputs[0];
152
+ const inChannels = input?.length ?? 0;
153
+ let code: number;
154
+ if (inChannels === 0) {
155
+ code = this.native.processPreparedSilence(frames);
156
+ } else if (inChannels > MAX_INPUT_CHANNELS) {
157
+ this.unsupportedInputBlocks++;
158
+ code = this.native.processPreparedSilence(frames);
159
+ } else {
160
+ for (let ch = 0; ch < inChannels; ch++) {
161
+ const source = input[ch];
162
+ const plane = this.inputPlanes[ch];
163
+ const copied = Math.min(frames, source.length);
164
+ plane.set(copied === source.length ? source : source.subarray(0, copied));
165
+ if (copied < frames) {
166
+ plane.fill(0, copied, frames);
167
+ }
168
+ }
169
+ code = this.native.processPrepared(inChannels, frames);
170
+ if (code !== 0) {
171
+ // A refused block leaves the renderer untouched; render silence instead.
172
+ this.unsupportedInputBlocks++;
173
+ code = this.native.processPreparedSilence(frames);
174
+ }
175
+ }
176
+ if (code === 0) {
177
+ copyPlanesToOutput(output, this.outputPlanes, frames);
178
+ } else {
179
+ for (const channel of output) {
180
+ channel.fill(0);
181
+ }
182
+ }
183
+ return true;
184
+ }
185
+
186
+ destroy(): void {
187
+ if (this.destroyed) {
188
+ return;
189
+ }
190
+ this.destroyed = true;
191
+ this.renderer.delete();
192
+ }
193
+
194
+ private acquirePlanes(): void {
195
+ this.inputPlanes = [];
196
+ for (let ch = 0; ch < MAX_INPUT_CHANNELS; ch++) {
197
+ this.inputPlanes.push(this.native.inputPlane(ch));
198
+ }
199
+ this.outputPlanes = [];
200
+ for (let ch = 0; ch < this.outputChannels; ch++) {
201
+ this.outputPlanes.push(this.native.outputPlane(ch));
202
+ }
203
+ }
204
+
205
+ // Frames past the construction-time capacity are left silent rather than
206
+ // reallocating on the audio thread.
207
+ private clampFrames(frames: number): number {
208
+ if (frames <= this.maxBlockSize) {
209
+ return frames;
210
+ }
211
+ if (!SonarePlaybackWorkletProcessor.warnedBlockOverflow) {
212
+ SonarePlaybackWorkletProcessor.warnedBlockOverflow = true;
213
+ // biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
214
+ console.warn(
215
+ `SonarePlaybackWorkletProcessor: requested ${frames} frames exceeds maxBlockSize ` +
216
+ `${this.maxBlockSize}; clamping.`,
217
+ );
218
+ }
219
+ return this.maxBlockSize;
220
+ }
221
+ }
222
+
223
+ export function registerSonarePlaybackWorkletProcessor(name = 'sonare-playback-processor'): void {
224
+ const scope = globalThis as unknown as {
225
+ AudioWorkletProcessor?: new () => object;
226
+ registerProcessor?: (processorName: string, processorCtor: unknown) => void;
227
+ };
228
+ if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
229
+ throw new Error('AudioWorkletProcessor is not available in this context.');
230
+ }
231
+ const Base = scope.AudioWorkletProcessor;
232
+ class RegisteredSonarePlaybackWorkletProcessor extends Base {
233
+ private bridge: SonarePlaybackWorkletProcessor;
234
+ readonly port?: WorkletPort;
235
+
236
+ constructor(options?: { processorOptions?: SonarePlaybackWorkletProcessorOptions }) {
237
+ super();
238
+ const port = this.port;
239
+ this.bridge = new SonarePlaybackWorkletProcessor(options?.processorOptions ?? {}, port);
240
+ const onMessage = (event: { data: unknown }) => {
241
+ if (isPlaybackMessage(event.data)) {
242
+ this.bridge.receiveMessage(event.data);
243
+ }
244
+ };
245
+ if (port?.addEventListener) {
246
+ port.addEventListener('message', onMessage);
247
+ port.start?.();
248
+ } else if (port) {
249
+ port.onmessage = onMessage;
250
+ }
251
+ }
252
+
253
+ process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
254
+ return this.bridge.process(inputs, outputs);
255
+ }
256
+ }
257
+ scope.registerProcessor(name, RegisteredSonarePlaybackWorkletProcessor);
258
+ }
259
+
260
+ /**
261
+ * Creates the AudioWorkletNode for a processor registered with
262
+ * {@link registerSonarePlaybackWorkletProcessor}. The node's input follows its
263
+ * source's channel count without any browser up/down mix
264
+ * (`channelCountMode: "max"`, `channelInterpretation: "discrete"`), and its
265
+ * output carries the target's channel count. The configuration is serialized
266
+ * here, on the main thread.
267
+ *
268
+ * @example
269
+ * ```ts
270
+ * await context.audioWorklet.addModule(playbackWorkletUrl);
271
+ * const hrtf = await (await fetch(hrtfUrl)).arrayBuffer();
272
+ * const node = createSonarePlaybackNode(context, { config: {}, hrtf });
273
+ * context.createMediaElementSource(video).connect(node).connect(context.destination);
274
+ * video.addEventListener('seeking', () => node.port.postMessage({ type: 'reset' }));
275
+ * ```
276
+ */
277
+ export function createSonarePlaybackNode(
278
+ context: BaseAudioContext,
279
+ options: SonarePlaybackNodeOptions = {},
280
+ ): AudioWorkletNode {
281
+ const config = configObject(options.config);
282
+ const factory =
283
+ options.nodeFactory ??
284
+ ((ctx: BaseAudioContext, name: string, nodeOptions: AudioWorkletNodeOptions) =>
285
+ new AudioWorkletNode(ctx, name, nodeOptions));
286
+ const processorOptions: SonarePlaybackWorkletProcessorOptions = {
287
+ config: JSON.stringify(config),
288
+ hrtf: options.hrtf,
289
+ maxBlockSize: options.maxBlockSize,
290
+ sampleRate: options.sampleRate ?? context.sampleRate,
291
+ };
292
+ return factory(context, options.processorName ?? 'sonare-playback-processor', {
293
+ numberOfInputs: 1,
294
+ numberOfOutputs: 1,
295
+ outputChannelCount: [outputChannelCount(config)],
296
+ channelCountMode: 'max',
297
+ channelInterpretation: 'discrete',
298
+ processorOptions,
299
+ });
300
+ }
@@ -25,7 +25,17 @@ export interface SonareWorkletMeterSnapshot {
25
25
  rmsDbL: number;
26
26
  rmsDbR: number;
27
27
  correlation: number;
28
+ /**
29
+ * Left-channel inter-sample (true) peak in dB, from the ITU-R BS.1770-4
30
+ * polyphase reconstruction at 4x. A streaming measurement, and the render
31
+ * quantum makes that visible here: the centered reconstruction stencil needs a
32
+ * few future samples a realtime path does not have, so each block's last
33
+ * samples read marginally low (about 0.1 dB across 64..8192-sample blocks on a
34
+ * near-Nyquist tone, always under-reading). Use `meteringTruePeakDb` over the
35
+ * whole signal for an exact dBTP number.
36
+ */
28
37
  truePeakDbL: number;
38
+ /** Right-channel inter-sample (true) peak in dB. See {@link truePeakDbL}. */
29
39
  truePeakDbR: number;
30
40
  momentaryLufs: number;
31
41
  shortTermLufs: number;
@@ -140,6 +150,7 @@ export enum SonareEngineTelemetryError {
140
150
  MetronomeOverflow = 18,
141
151
  InvalidCommand = 19,
142
152
  MaxChannelsExceeded = 20,
153
+ ParameterBaseOverflow = 21,
143
154
  }
144
155
 
145
156
  export interface SonareMeterRingBuffer {
@@ -307,8 +318,24 @@ export interface SharedSpectrumRingWriter {
307
318
  recordFloats: number;
308
319
  }
309
320
 
321
+ /**
322
+ * Shared finite dB floor (`sonare::constants::kFloorDb`). Every level-in-dB
323
+ * field on a worklet snapshot bottoms out here rather than at `-Infinity`.
324
+ */
325
+ export const SONARE_FLOOR_DB = -120;
326
+
327
+ /**
328
+ * Magnitude to dB with the shared finite floor.
329
+ *
330
+ * `-Infinity` is not a usable value for a meter reading: it propagates NaN
331
+ * through the usual `(db + 60) / 60 * height` bar geometry, serializes to
332
+ * `null` through `JSON.stringify`, and poisons `Math.min`/`Math.max`
333
+ * aggregation. The native meters have always floored at `kFloorDb`, so a
334
+ * producer emitting `-Infinity` also made the two worklets disagree about
335
+ * silence.
336
+ */
310
337
  export function toDb(value: number): number {
311
- return value > 0 ? 20 * Math.log10(value) : Number.NEGATIVE_INFINITY;
338
+ return value > 0 ? Math.max(20 * Math.log10(value), SONARE_FLOOR_DB) : SONARE_FLOOR_DB;
312
339
  }
313
340
 
314
341
  export function isRecord(value: unknown): value is Record<string, unknown> {
@@ -948,6 +975,27 @@ function recordOffset(index: number, capacity: number, recordBytes: number): num
948
975
  return (index % capacity) * recordBytes;
949
976
  }
950
977
 
978
+ /**
979
+ * The domain of a uint32 record slot: no fraction, no sign, no overflow.
980
+ *
981
+ * `setUint32` folds anything outside it into a legal value, so a negative id
982
+ * becomes a large one and 2**32 becomes 0. An absent value is out of domain;
983
+ * a slot with a default resolves it before asking.
984
+ */
985
+ export function isUint32Slot(value: number | undefined): boolean {
986
+ return (
987
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 0xffff_ffff
988
+ );
989
+ }
990
+
991
+ function toUint32Slot(value: number | undefined, fallback: number, name: string): number {
992
+ const resolved = value ?? fallback;
993
+ if (!isUint32Slot(resolved)) {
994
+ throw new RangeError(`${name} must be an integer within [0, 4294967295]`);
995
+ }
996
+ return resolved;
997
+ }
998
+
951
999
  function toSafeInteger(value: number | bigint | undefined, fallback: number): number {
952
1000
  const resolved = typeof value === 'bigint' ? Number(value) : value;
953
1001
  if (resolved === undefined) {
@@ -976,8 +1024,10 @@ function writeEngineCommandRecord(
976
1024
  offset: number,
977
1025
  command: SonareEngineCommandRecord,
978
1026
  ): void {
979
- view.setUint32(offset, command.type, true);
980
- view.setUint32(offset + 4, command.targetId ?? 0, true);
1027
+ // Both slots are uint32 and the readers are unsigned, so the writer holds
1028
+ // them to that domain; the command vocabulary itself is the node's check.
1029
+ view.setUint32(offset, toUint32Slot(command.type, 0, 'type'), true);
1030
+ view.setUint32(offset + 4, toUint32Slot(command.targetId, 0, 'targetId'), true);
981
1031
  writeInt64Words(view, offset + 8, toSafeInteger(command.sampleTime, -1));
982
1032
  // argFloat occupies a full 8-byte Float64 slot (replacing the old Float32 +
983
1033
  // 4-byte pad) so PPQ scalars carried here keep full double precision over the