@libraz/libsonare 1.7.2 → 1.8.1

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 +7 -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 +874 -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 +502 -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 +134 -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 +4173 -1621
  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 +483 -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 +654 -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 +528 -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 +165 -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 +893 -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 +3945 -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 +340 -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 +164 -0
  179. package/dist/worklet/engine-mixer-facade.d.ts.map +1 -0
  180. package/dist/worklet/engine-node.d.ts +83 -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 +72 -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 +75 -0
  193. package/dist/worklet/engine-strips.d.ts.map +1 -0
  194. package/dist/worklet/engine-sync.d.ts +39 -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 +418 -0
  199. package/dist/worklet/engine.d.ts.map +1 -0
  200. package/dist/worklet/guards.d.ts +53 -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 +331 -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 +3200 -541
  215. package/dist/worklet.js.map +1 -1
  216. package/package.json +23 -12
  217. package/src/_effects_common.ts +47 -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 +101 -22
  226. package/src/effects_note_ops.ts +683 -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 +388 -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 +288 -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 +441 -32
  246. package/src/mastering_dynamics.ts +22 -11
  247. package/src/metering.ts +67 -24
  248. package/src/mixer.ts +251 -22
  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 +271 -271
  259. package/src/public_types.ts +122 -3
  260. package/src/public_types_acoustic.ts +112 -3
  261. package/src/public_types_mastering.ts +275 -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 +196 -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 +491 -5
  268. package/src/quick_analysis.ts +203 -26
  269. package/src/realtime_engine.ts +773 -34
  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 +1158 -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 +202 -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 +800 -32
  287. package/src/worklet/engine-node.ts +99 -29
  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 +332 -93
  291. package/src/worklet/engine-register.ts +32 -18
  292. package/src/worklet/engine-strips.ts +275 -9
  293. package/src/worklet/engine-sync.ts +20 -7
  294. package/src/worklet/engine.ts +394 -48
  295. package/src/worklet/guards.ts +195 -44
  296. package/src/worklet/messages.ts +229 -2
  297. package/src/worklet/mixer-processor.ts +117 -48
  298. package/src/worklet/playback-processor.ts +300 -0
  299. package/src/worklet/protocol.ts +82 -11
  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,
@@ -15,11 +15,11 @@ import {
15
15
  meterRingFromSharedBuffer,
16
16
  type SharedMeterRingWriter,
17
17
  type SharedSpectrumRingWriter,
18
+ SONARE_FLOOR_DB,
18
19
  SONARE_METER_RING_RECORD_FLOATS,
19
20
  type SonareWorkletMeterSnapshot,
20
21
  type SonareWorkletSpectrumSnapshot,
21
22
  spectrumRingFromSharedBuffer,
22
- toDb,
23
23
  } from './protocol';
24
24
 
25
25
  /**
@@ -43,6 +43,32 @@ export class SonareWorkletProcessor {
43
43
  private lastSpectrumFrame = 0;
44
44
  private transport?: WorkletTransport;
45
45
  private meterRing?: SharedMeterRingWriter;
46
+ /**
47
+ * Reused meter record, so a publish writes fields instead of allocating one.
48
+ *
49
+ * `targetId` is always the master and the four LUFS / gain-reduction fields
50
+ * are always unavailable here — the mixer worklet does not run the
51
+ * K-weighting filters or the engine's pre-trim input tap — so finite floor
52
+ * values are set once rather than per interval.
53
+ */
54
+ private readonly meterScratch: SonareWorkletMeterSnapshot = {
55
+ type: 'meter',
56
+ targetId: 0,
57
+ frame: 0,
58
+ peakDbL: 0,
59
+ peakDbR: 0,
60
+ rmsDbL: 0,
61
+ rmsDbR: 0,
62
+ correlation: 0,
63
+ truePeakDbL: 0,
64
+ truePeakDbR: 0,
65
+ momentaryLufs: Number.NaN,
66
+ shortTermLufs: Number.NaN,
67
+ integratedLufs: Number.NaN,
68
+ gainReductionDb: Number.NaN,
69
+ inputPeakDbL: SONARE_FLOOR_DB,
70
+ inputPeakDbR: SONARE_FLOOR_DB,
71
+ };
46
72
  private spectrumRing?: SharedSpectrumRingWriter;
47
73
  private spectrumBands: Float32Array;
48
74
 
@@ -52,10 +78,20 @@ export class SonareWorkletProcessor {
52
78
  }
53
79
  this.sampleRate = options.sampleRate ?? 48000;
54
80
  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));
81
+ // Zero is the off switch for both publications, so the floor is 0.
82
+ this.meterIntervalFrames = requireIntegerOption(
83
+ options.meterIntervalFrames,
84
+ 2048,
85
+ 'meterIntervalFrames',
86
+ 0,
87
+ );
88
+ this.spectrumIntervalFrames = requireIntegerOption(
89
+ options.spectrumIntervalFrames,
90
+ 0,
91
+ 'spectrumIntervalFrames',
92
+ 0,
93
+ );
57
94
  this.transport = transport;
58
- this.meterIntervalFrames = Math.max(0, Math.floor(options.meterIntervalFrames ?? 2048));
59
95
  this.meterRing = options.meterSharedBuffer
60
96
  ? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity)
61
97
  : undefined;
@@ -76,6 +112,10 @@ export class SonareWorkletProcessor {
76
112
  throw new Error('stripCount must match the scene strip count.');
77
113
  }
78
114
  this.realtime = this.mixer.createRealtimeBuffer();
115
+ // The mixer meters the master it just produced, so the true-peak filter sees
116
+ // every block without the worklet copying the output back in. 4x is the
117
+ // BS.1770-4 Annex 2 minimum.
118
+ this.mixer.configureMeter(this.meterIntervalFrames > 0, 4);
79
119
  }
80
120
 
81
121
  process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
@@ -132,10 +172,7 @@ export class SonareWorkletProcessor {
132
172
  }
133
173
  }
134
174
  this.processedFrames += usable;
135
- this.publishMeter(
136
- this.realtime.outLeft.subarray(0, usable),
137
- this.realtime.outRight.subarray(0, usable),
138
- );
175
+ this.publishMeter();
139
176
  this.publishSpectrum(
140
177
  this.realtime.outLeft.subarray(0, usable),
141
178
  this.realtime.outRight.subarray(0, usable),
@@ -152,7 +189,15 @@ export class SonareWorkletProcessor {
152
189
  return;
153
190
  }
154
191
  if (message.type === 'setMeterInterval') {
155
- this.meterIntervalFrames = Math.max(0, Math.floor(message.frames));
192
+ const frames = Math.max(0, Math.floor(message.frames));
193
+ // Toggling metering also toggles the mixer's meter, so a disabled meter
194
+ // costs nothing per block. Re-enabling restarts it: the filter history and
195
+ // the published snapshot are from before the gap, and carrying them
196
+ // forward would report a peak the caller never asked to be measured.
197
+ if (frames > 0 !== this.meterIntervalFrames > 0) {
198
+ this.mixer.configureMeter(frames > 0, 4);
199
+ }
200
+ this.meterIntervalFrames = frames;
156
201
  return;
157
202
  }
158
203
  if (message.type === 'scheduleInsertAutomation') {
@@ -174,8 +219,10 @@ export class SonareWorkletProcessor {
174
219
  }
175
220
  }
176
221
 
177
- private publishMeter(left: Float32Array, right: Float32Array): void {
178
- if (!this.transport || this.meterIntervalFrames <= 0) {
222
+ private publishMeter(): void {
223
+ // Symmetric with the engine processor: a ring-only configuration still has
224
+ // meters to publish, so the absence of a transport alone must not skip it.
225
+ if ((!this.transport && !this.meterRing) || this.meterIntervalFrames <= 0) {
179
226
  return;
180
227
  }
181
228
  if (this.processedFrames - this.lastMeterFrame < this.meterIntervalFrames) {
@@ -183,50 +230,66 @@ export class SonareWorkletProcessor {
183
230
  }
184
231
  this.lastMeterFrame = this.processedFrames;
185
232
 
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;
233
+ // Latch the reading into the mixer's own scratch and read the seven fields
234
+ // back as numbers. `meterSnapshot()` returns a fresh embind object, so
235
+ // calling it here would allocate on the render thread once per interval —
236
+ // about 23 per second at the default — no matter what this file then does
237
+ // with the result. Scratch field order, shared with the engine's
238
+ // meterScratchValue: 0 peakDbL, 1 peakDbR, 2 rmsDbL, 3 rmsDbR,
239
+ // 4 correlation, 5 truePeakDbL, 6 truePeakDbR.
240
+ if (!this.mixer.latchMeterSnapshot()) {
241
+ // The meter has never been enabled, so there is no reading to publish.
242
+ return;
205
243
  }
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);
244
+ if (this.meterRing) {
245
+ // Fill the reusable record and serialise it straight into the ring, which
246
+ // exists precisely so this thread allocates and posts nothing. The record
247
+ // never escapes, so reuse is safe here in a way it is not on the
248
+ // postMessage path below.
249
+ const meter = this.meterScratch;
250
+ meter.frame = this.processedFrames;
251
+ meter.peakDbL = this.mixer.meterScratchValue(0);
252
+ meter.peakDbR = this.mixer.meterScratchValue(1);
253
+ meter.rmsDbL = this.mixer.meterScratchValue(2);
254
+ meter.rmsDbR = this.mixer.meterScratchValue(3);
255
+ meter.correlation = this.mixer.meterScratchValue(4);
256
+ meter.truePeakDbL = this.mixer.meterScratchValue(5);
257
+ meter.truePeakDbR = this.mixer.meterScratchValue(6);
258
+ this.writeMeterRing(meter);
259
+ return;
260
+ }
261
+ // No ring: this is the structured-clone fallback, already off the
262
+ // zero-allocation contract, and a listener may retain what it is handed. It
263
+ // still reads the latched scalars rather than `meterSnapshot()`, so the one
264
+ // object it does build is the one it posts.
209
265
  const meter: SonareWorkletMeterSnapshot = {
210
266
  type: 'meter',
211
267
  targetId: 0,
212
268
  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),
269
+ peakDbL: this.mixer.meterScratchValue(0),
270
+ peakDbR: this.mixer.meterScratchValue(1),
271
+ rmsDbL: this.mixer.meterScratchValue(2),
272
+ rmsDbR: this.mixer.meterScratchValue(3),
273
+ correlation: this.mixer.meterScratchValue(4),
274
+ truePeakDbL: this.mixer.meterScratchValue(5),
275
+ truePeakDbR: this.mixer.meterScratchValue(6),
276
+ // Declared unavailable rather than floored: the mixer worklet does not run
277
+ // the K-weighting filters, and a floor value would read as silence.
220
278
  momentaryLufs: Number.NaN,
221
279
  shortTermLufs: Number.NaN,
222
280
  integratedLufs: Number.NaN,
223
281
  gainReductionDb: Number.NaN,
282
+ inputPeakDbL: SONARE_FLOOR_DB,
283
+ inputPeakDbR: SONARE_FLOOR_DB,
224
284
  };
225
- this.transport.onMeter?.(meter);
226
- if (this.meterRing) {
227
- this.writeMeterRing(meter);
285
+ // Alternative channels for one record, not a broadcast pair: a transport
286
+ // that supplies both (the engine registration resolves both to
287
+ // `port.postMessage`) would otherwise deliver every record twice and double
288
+ // the host's peak-hold decay rate.
289
+ if (this.transport?.onMeter) {
290
+ this.transport.onMeter(meter);
228
291
  } else {
229
- this.transport.postMessage?.(meter);
292
+ this.transport?.postMessage?.(meter);
230
293
  }
231
294
  }
232
295
 
@@ -251,6 +314,8 @@ export class SonareWorkletProcessor {
251
314
  ring.records[offset + 11] = meter.shortTermLufs;
252
315
  ring.records[offset + 12] = meter.integratedLufs;
253
316
  ring.records[offset + 13] = meter.gainReductionDb;
317
+ ring.records[offset + 14] = meter.inputPeakDbL;
318
+ ring.records[offset + 15] = meter.inputPeakDbR;
254
319
  Atomics.store(ring.header, 0, writeIndex + 1);
255
320
  // writeIndex is a free-running monotonic counter, so an overflow guard here
256
321
  // would fire on essentially every write past the first `capacity` records
@@ -277,8 +342,12 @@ export class SonareWorkletProcessor {
277
342
  frame: this.processedFrames,
278
343
  bands: new Float32Array(this.spectrumBands),
279
344
  };
280
- this.transport?.onSpectrum?.(spectrum);
281
- this.transport?.postMessage?.(spectrum);
345
+ // One record, one delivery -- see publishMeter above.
346
+ if (this.transport?.onSpectrum) {
347
+ this.transport.onSpectrum(spectrum);
348
+ } else {
349
+ this.transport?.postMessage?.(spectrum);
350
+ }
282
351
  }
283
352
 
284
353
  private computeSpectrum(left: Float32Array, right: Float32Array): void {
@@ -315,7 +384,7 @@ export class SonareWorkletProcessor {
315
384
  return;
316
385
  }
317
386
  const writeIndex = Atomics.load(ring.header, 0);
318
- const offset = (writeIndex % ring.capacity) * ring.recordFloats;
387
+ const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
319
388
  ring.records[offset] = encodeFrameLo(frame);
320
389
  ring.records[offset + 1] = encodeFrameHi(frame);
321
390
  ring.records[offset + 2] = bands.length;
@@ -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
+ }