@sudobility/music_player 0.1.0 → 0.1.2

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 (159) hide show
  1. package/CLAUDE.md +335 -0
  2. package/dist/core.d.ts +33 -0
  3. package/dist/core.d.ts.map +1 -0
  4. package/dist/core.js +33 -0
  5. package/dist/core.js.map +1 -0
  6. package/dist/engine.d.ts +103 -0
  7. package/dist/engine.d.ts.map +1 -0
  8. package/dist/engine.js +2 -0
  9. package/dist/engine.js.map +1 -0
  10. package/dist/mocks/index.d.ts +46 -0
  11. package/dist/mocks/index.d.ts.map +1 -0
  12. package/dist/mocks/index.js +93 -0
  13. package/dist/mocks/index.js.map +1 -0
  14. package/dist/player.d.ts +81 -0
  15. package/dist/player.d.ts.map +1 -0
  16. package/dist/player.js +209 -0
  17. package/dist/player.js.map +1 -0
  18. package/dist/rn/audio/offline-render.d.ts +29 -0
  19. package/dist/rn/audio/offline-render.d.ts.map +1 -0
  20. package/dist/rn/audio/offline-render.js +134 -0
  21. package/dist/rn/audio/offline-render.js.map +1 -0
  22. package/dist/rn/index.d.ts +50 -0
  23. package/dist/rn/index.d.ts.map +1 -0
  24. package/dist/rn/index.js +39 -0
  25. package/dist/rn/index.js.map +1 -0
  26. package/dist/rn/playback/audio-api.d.ts +93 -0
  27. package/dist/rn/playback/audio-api.d.ts.map +1 -0
  28. package/dist/rn/playback/audio-api.js +24 -0
  29. package/dist/rn/playback/audio-api.js.map +1 -0
  30. package/dist/rn/playback/expression-table.d.ts +22 -0
  31. package/dist/rn/playback/expression-table.d.ts.map +1 -0
  32. package/dist/rn/playback/expression-table.js +926 -0
  33. package/dist/rn/playback/expression-table.js.map +1 -0
  34. package/dist/rn/playback/expression.d.ts +48 -0
  35. package/dist/rn/playback/expression.d.ts.map +1 -0
  36. package/dist/rn/playback/expression.js +120 -0
  37. package/dist/rn/playback/expression.js.map +1 -0
  38. package/dist/rn/playback/gm-pack-name.d.ts +40 -0
  39. package/dist/rn/playback/gm-pack-name.d.ts.map +1 -0
  40. package/dist/rn/playback/gm-pack-name.js +61 -0
  41. package/dist/rn/playback/gm-pack-name.js.map +1 -0
  42. package/dist/rn/playback/pack-library.d.ts +44 -0
  43. package/dist/rn/playback/pack-library.d.ts.map +1 -0
  44. package/dist/rn/playback/pack-library.js +85 -0
  45. package/dist/rn/playback/pack-library.js.map +1 -0
  46. package/dist/rn/playback/pack-names.json +130 -0
  47. package/dist/rn/playback/sample-engine.d.ts +216 -0
  48. package/dist/rn/playback/sample-engine.d.ts.map +1 -0
  49. package/dist/rn/playback/sample-engine.js +669 -0
  50. package/dist/rn/playback/sample-engine.js.map +1 -0
  51. package/dist/rn/playback/sample-pack.d.ts +71 -0
  52. package/dist/rn/playback/sample-pack.d.ts.map +1 -0
  53. package/dist/rn/playback/sample-pack.js +112 -0
  54. package/dist/rn/playback/sample-pack.js.map +1 -0
  55. package/dist/rn/playback/sustain-loop.d.ts +24 -0
  56. package/dist/rn/playback/sustain-loop.d.ts.map +1 -0
  57. package/dist/rn/playback/sustain-loop.js +111 -0
  58. package/dist/rn/playback/sustain-loop.js.map +1 -0
  59. package/dist/rn/playback/voice-plan.d.ts +60 -0
  60. package/dist/rn/playback/voice-plan.d.ts.map +1 -0
  61. package/dist/rn/playback/voice-plan.js +38 -0
  62. package/dist/rn/playback/voice-plan.js.map +1 -0
  63. package/dist/shared/bus.d.ts +73 -0
  64. package/dist/shared/bus.d.ts.map +1 -0
  65. package/dist/shared/bus.js +83 -0
  66. package/dist/shared/bus.js.map +1 -0
  67. package/dist/shared/midi.d.ts +18 -0
  68. package/dist/shared/midi.d.ts.map +1 -0
  69. package/dist/shared/midi.js +25 -0
  70. package/dist/shared/midi.js.map +1 -0
  71. package/dist/shared/mix.d.ts +75 -0
  72. package/dist/shared/mix.d.ts.map +1 -0
  73. package/dist/shared/mix.js +115 -0
  74. package/dist/shared/mix.js.map +1 -0
  75. package/dist/shared/note-queue.d.ts +43 -0
  76. package/dist/shared/note-queue.d.ts.map +1 -0
  77. package/dist/shared/note-queue.js +54 -0
  78. package/dist/shared/note-queue.js.map +1 -0
  79. package/dist/shared/plan.d.ts +34 -0
  80. package/dist/shared/plan.d.ts.map +1 -0
  81. package/dist/shared/plan.js +134 -0
  82. package/dist/shared/plan.js.map +1 -0
  83. package/dist/shared/pump-window.d.ts +38 -0
  84. package/dist/shared/pump-window.d.ts.map +1 -0
  85. package/dist/shared/pump-window.js +13 -0
  86. package/dist/shared/pump-window.js.map +1 -0
  87. package/dist/shared/render-events.d.ts +25 -0
  88. package/dist/shared/render-events.d.ts.map +1 -0
  89. package/dist/shared/render-events.js +79 -0
  90. package/dist/shared/render-events.js.map +1 -0
  91. package/dist/shared/sounding-set.d.ts +45 -0
  92. package/dist/shared/sounding-set.d.ts.map +1 -0
  93. package/dist/shared/sounding-set.js +81 -0
  94. package/dist/shared/sounding-set.js.map +1 -0
  95. package/dist/shared/test-plan.d.ts +36 -0
  96. package/dist/shared/test-plan.d.ts.map +1 -0
  97. package/dist/shared/test-plan.js +83 -0
  98. package/dist/shared/test-plan.js.map +1 -0
  99. package/dist/singleton.d.ts +23 -0
  100. package/dist/singleton.d.ts.map +1 -0
  101. package/dist/singleton.js +26 -0
  102. package/dist/singleton.js.map +1 -0
  103. package/dist/types.d.ts +84 -0
  104. package/dist/types.d.ts.map +1 -0
  105. package/dist/types.js +2 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/web/audio/offline-synth.d.ts +24 -0
  108. package/dist/web/audio/offline-synth.d.ts.map +1 -0
  109. package/dist/web/audio/offline-synth.js +92 -0
  110. package/dist/web/audio/offline-synth.js.map +1 -0
  111. package/dist/web/audio/soundfont-render.d.ts +31 -0
  112. package/dist/web/audio/soundfont-render.d.ts.map +1 -0
  113. package/dist/web/audio/soundfont-render.js +130 -0
  114. package/dist/web/audio/soundfont-render.js.map +1 -0
  115. package/dist/web/audio/synth-types.d.ts +24 -0
  116. package/dist/web/audio/synth-types.d.ts.map +1 -0
  117. package/dist/web/audio/synth-types.js +2 -0
  118. package/dist/web/audio/synth-types.js.map +1 -0
  119. package/dist/web/index.d.ts +40 -0
  120. package/dist/web/index.d.ts.map +1 -0
  121. package/dist/web/index.js +59 -0
  122. package/dist/web/index.js.map +1 -0
  123. package/dist/web/playback/channel-allocator.d.ts +54 -0
  124. package/dist/web/playback/channel-allocator.d.ts.map +1 -0
  125. package/dist/web/playback/channel-allocator.js +72 -0
  126. package/dist/web/playback/channel-allocator.js.map +1 -0
  127. package/dist/web/playback/click.d.ts +9 -0
  128. package/dist/web/playback/click.d.ts.map +1 -0
  129. package/dist/web/playback/click.js +61 -0
  130. package/dist/web/playback/click.js.map +1 -0
  131. package/dist/web/playback/clock.d.ts +36 -0
  132. package/dist/web/playback/clock.d.ts.map +1 -0
  133. package/dist/web/playback/clock.js +64 -0
  134. package/dist/web/playback/clock.js.map +1 -0
  135. package/dist/web/playback/governor.d.ts +45 -0
  136. package/dist/web/playback/governor.d.ts.map +1 -0
  137. package/dist/web/playback/governor.js +33 -0
  138. package/dist/web/playback/governor.js.map +1 -0
  139. package/dist/web/playback/quiet-stub-notices.d.ts +55 -0
  140. package/dist/web/playback/quiet-stub-notices.d.ts.map +1 -0
  141. package/dist/web/playback/quiet-stub-notices.js +93 -0
  142. package/dist/web/playback/quiet-stub-notices.js.map +1 -0
  143. package/dist/web/playback/soundfont-engine.d.ts +281 -0
  144. package/dist/web/playback/soundfont-engine.d.ts.map +1 -0
  145. package/dist/web/playback/soundfont-engine.js +735 -0
  146. package/dist/web/playback/soundfont-engine.js.map +1 -0
  147. package/dist/web/playback/soundfont-loader.d.ts +30 -0
  148. package/dist/web/playback/soundfont-loader.d.ts.map +1 -0
  149. package/dist/web/playback/soundfont-loader.js +68 -0
  150. package/dist/web/playback/soundfont-loader.js.map +1 -0
  151. package/dist/web/playback/synth-host.d.ts +142 -0
  152. package/dist/web/playback/synth-host.d.ts.map +1 -0
  153. package/dist/web/playback/synth-host.js +293 -0
  154. package/dist/web/playback/synth-host.js.map +1 -0
  155. package/package.json +18 -3
  156. package/dist/index.d.ts +0 -14
  157. package/dist/index.d.ts.map +0 -1
  158. package/dist/index.js +0 -2
  159. package/dist/index.js.map +0 -1
@@ -0,0 +1,92 @@
1
+ /**
2
+ * A long-lived synth for offline rendering, loaded once per page.
3
+ *
4
+ * Export cannot share playback's synth: Web Audio nodes belong to the context
5
+ * that created them, playback runs on a realtime `AudioContext`, and rendering
6
+ * faster than realtime needs an `OfflineAudioContext`. Nothing bridges the two.
7
+ * Worse, an `OfflineAudioContext` renders exactly once, so the previous design
8
+ * built a fresh worklet and reloaded the 23MB soundfont on *every export* —
9
+ * about five seconds each time, with libfluidsynth's build-stub notices along
10
+ * for the ride.
11
+ *
12
+ * `Synthesizer.render()` avoids all of it by needing no audio context at all:
13
+ * it fills buffers straight from the WASM synth. So one synth is built, the
14
+ * font is loaded into it once, and every export after the first is immediate.
15
+ *
16
+ * This one runs on the main thread rather than in a worklet, which is fine
17
+ * precisely because it is offline — there is no audio callback to starve.
18
+ */
19
+ import { CHANNELS_PER_INSTANCE } from '../playback/channel-allocator.js';
20
+ import { seedMainThreadQuietModule } from '../playback/quiet-stub-notices.js';
21
+ /** Matches playback's ceiling, so an export sounds like what was heard. */
22
+ const OFFLINE_POLYPHONY = 2048;
23
+ /** Resolves once `libfluidsynth-*.js` has been loaded and its WASM is ready. */
24
+ let fluidsynthReady = null;
25
+ /** The loaded synth, keyed so a changed font or rate rebuilds rather than lying. */
26
+ let cached = null;
27
+ /**
28
+ * Loads libfluidsynth into the *main thread*.
29
+ *
30
+ * A script tag rather than `addModule`: the non-worklet synth reads the module
31
+ * from the global scope, where `addModule` would put it inside the worklet
32
+ * instead. Memoised, since loading it twice would replace the global mid-flight.
33
+ */
34
+ async function ensureFluidsynth(moduleUrl) {
35
+ fluidsynthReady ??= (async () => {
36
+ if (typeof document === 'undefined') {
37
+ throw new Error('Offline rendering needs a document to load libfluidsynth');
38
+ }
39
+ // Before the script runs: emscripten reads the global Module on load.
40
+ seedMainThreadQuietModule();
41
+ await new Promise((resolve, reject) => {
42
+ const script = document.createElement('script');
43
+ script.src = moduleUrl;
44
+ script.onload = () => resolve();
45
+ script.onerror = () => reject(new Error(`Failed to load ${moduleUrl}`));
46
+ document.head.appendChild(script);
47
+ });
48
+ const jsSynth = await import('js-synthesizer');
49
+ await jsSynth.waitForReady();
50
+ })();
51
+ return fluidsynthReady;
52
+ }
53
+ /** The shared offline synth, building it on first use. */
54
+ export async function getOfflineSynth(request) {
55
+ const key = `${request.fluidsynthModuleUrl}|${request.fontUrl}|${request.sampleRate}`;
56
+ if (cached?.key === key)
57
+ return await cached.synth;
58
+ const building = (async () => {
59
+ let synth;
60
+ if (request.createSynth) {
61
+ synth = request.createSynth();
62
+ }
63
+ else {
64
+ await ensureFluidsynth(request.fluidsynthModuleUrl);
65
+ const jsSynth = await import('js-synthesizer');
66
+ synth = new jsSynth.Synthesizer();
67
+ }
68
+ // The same channel count playback uses, because both share
69
+ // `allocateChannels`: on a 16-channel synth an export would address
70
+ // channels that do not exist and silently lose every track past the
71
+ // sixteenth. Polyphony matches too, so an export sounds like what was heard.
72
+ synth.init(request.sampleRate, {
73
+ midiChannelCount: CHANNELS_PER_INSTANCE,
74
+ polyphony: OFFLINE_POLYPHONY,
75
+ });
76
+ const sfontId = await synth.loadSFont(await request.loadFont(request.fontUrl));
77
+ return { synth, sfontId, sampleRate: request.sampleRate };
78
+ })();
79
+ cached = { key, synth: building };
80
+ try {
81
+ return await building;
82
+ }
83
+ catch (error) {
84
+ cached = null; // a failed build must not be handed to the next caller
85
+ throw error;
86
+ }
87
+ }
88
+ /** Drops the shared synth. Exposed for tests and teardown, not used in normal flow. */
89
+ export function releaseOfflineSynth() {
90
+ cached = null;
91
+ }
92
+ //# sourceMappingURL=offline-synth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"offline-synth.js","sourceRoot":"","sources":["../../../src/web/audio/offline-synth.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Offline audio rendering through the shared soundfont synth.
3
+ *
4
+ * Export and playback use the same soundfont, the same channel allocation and
5
+ * the same percussion rules, so a rendered file matches what was heard. They
6
+ * cannot share the *synth itself* — playback's lives in an `AudioWorklet` bound
7
+ * to a realtime context, and Web Audio nodes do not cross contexts — so this
8
+ * holds its own, built once per page by `offline-synth.ts`.
9
+ *
10
+ * Timing comes from the render loop rather than from an audio context: the
11
+ * synth is asked for one small block at a time, and events falling inside that
12
+ * block are dispatched just before it. Block size sets the timing resolution;
13
+ * at 128 frames and 44.1kHz that is under three milliseconds, well below what
14
+ * anyone hears as misplacement.
15
+ *
16
+ * The earlier version used `OfflineAudioContext.suspend()`, which worked but
17
+ * needed a fresh context — and therefore a fresh worklet and a fresh 23MB font
18
+ * load — for every single export.
19
+ */
20
+ import type { DecodedAudio, RenderPlan } from '@sudobility/music_types';
21
+ import type { LoadedOfflineSynth } from './offline-synth.js';
22
+ export type { RenderEvent, RenderPlan, RenderTrack } from '@sudobility/music_types';
23
+ export type SoundfontRendererAssets = {
24
+ fluidsynthModuleUrl: string;
25
+ fontUrl: string;
26
+ loadFont: (url: string) => Promise<ArrayBuffer>;
27
+ /** Overridable so tests can supply a stub synth instead of loading WASM. */
28
+ acquireSynth?: (sampleRate: number) => Promise<LoadedOfflineSynth>;
29
+ };
30
+ export declare function createSoundfontRenderer(assets: SoundfontRendererAssets): (plan: RenderPlan) => Promise<DecodedAudio>;
31
+ //# sourceMappingURL=soundfont-render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"soundfont-render.d.ts","sourceRoot":"","sources":["../../../src/web/audio/soundfont-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAKxE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAE7D,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAEpF,MAAM,MAAM,uBAAuB,GAAG;IACpC,mBAAmB,EAAE,MAAM,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAChD,4EAA4E;IAC5E,YAAY,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;CACpE,CAAC;AAiBF,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,uBAAuB,GAC9B,CAAC,IAAI,EAAE,UAAU,KAAK,OAAO,CAAC,YAAY,CAAC,CAyH7C"}
@@ -0,0 +1,130 @@
1
+ import { allocateChannels } from '../playback/channel-allocator.js';
2
+ import { headroomTrimFor, limitPeaks } from '../../shared/mix.js';
3
+ import { getOfflineSynth } from './offline-synth.js';
4
+ const SAMPLE_RATE = 44100;
5
+ /** Release tail, so the last note is not cut off mid-decay. */
6
+ const TAIL_SECONDS = 1;
7
+ /** Timing resolution of the render: 128 frames is ~2.9ms at 44.1kHz. */
8
+ const BLOCK_FRAMES = 128;
9
+ const CC_VOLUME = 7;
10
+ const CC_PAN = 10;
11
+ const MAX_CC = 127;
12
+ /** Where General MIDI keeps its drum kits; channel 9 is already there. */
13
+ const DRUM_BANK = 128;
14
+ /** The kit channel 9 defaults to, so a switched channel matches it. */
15
+ const STANDARD_KIT = 0;
16
+ export function createSoundfontRenderer(assets) {
17
+ const acquire = assets.acquireSynth ??
18
+ ((sampleRate) => getOfflineSynth({
19
+ fluidsynthModuleUrl: assets.fluidsynthModuleUrl,
20
+ fontUrl: assets.fontUrl,
21
+ loadFont: assets.loadFont,
22
+ sampleRate,
23
+ }));
24
+ return async function render(plan) {
25
+ const { synth, sfontId, sampleRate } = await acquire(SAMPLE_RATE);
26
+ const totalFrames = Math.ceil(Math.max(0.1, plan.durationSec + TAIL_SECONDS) * sampleRate);
27
+ const { assignments, instanceCount } = allocateChannels(plan.tracks.map((t) => ({ id: t.id, isPercussion: t.isPercussion })));
28
+ // The same trim `SynthHost` puts on the live master bus, for the same
29
+ // reason: every channel is scheduled at its own MIDI volume with nothing
30
+ // reconciling them, so the sum grows with the part count. Without it the
31
+ // export was `sqrt(n)` louder than what was heard and the encoders — which
32
+ // hard-clamp — turned that into clipping across most of a large score.
33
+ // Sized by how many tracks *exist*, matching `setTrackCount(tracks.length)`.
34
+ const headroom = headroomTrimFor(plan.tracks.length);
35
+ const samples = new Float32Array(totalFrames);
36
+ // One synth, so an allocation that spans several of them is rendered as
37
+ // several passes summed into the same buffer. Ignoring the instance and
38
+ // keying on the channel alone put two tracks on one channel: the second
39
+ // overwrote the first's program, so past sixteen parts the export came out
40
+ // with the wrong instruments — while playback, which does open the extra
41
+ // synths, did not. Passes cost render time, and only a score too big for
42
+ // one synth pays it.
43
+ for (let instance = 0; instance < instanceCount; instance += 1) {
44
+ renderPass(plan.tracks.filter((t) => assignments.get(t.id)?.instance === instance));
45
+ }
46
+ synth.midiAllSoundsOff();
47
+ // Last in the chain, exactly as the limiter sits after the master gain on
48
+ // the live bus. The trim sizes the mix for its average level; this catches
49
+ // the phase-aligned moments it cannot predict, which would otherwise reach
50
+ // the encoders and be clamped.
51
+ limitPeaks(samples, sampleRate);
52
+ return { samples, sampleRate };
53
+ /** Configures this instance's channels, then sums its audio into `samples`. */
54
+ function renderPass(tracks) {
55
+ const channelFor = new Map();
56
+ // The synth is shared across exports, so it starts dirty; and whatever a
57
+ // previous pass left ringing is not part of this one either.
58
+ synth.midiAllSoundsOff();
59
+ for (const track of tracks) {
60
+ const assignment = assignments.get(track.id);
61
+ if (!assignment)
62
+ continue;
63
+ channelFor.set(track.id, assignment);
64
+ const { channel } = assignment;
65
+ if (track.isPercussion || assignment.needsDrumTypeSwitch) {
66
+ // Channel 9 is percussion already; anything else must be told — and
67
+ // told twice. Switching the type only routes *later* program changes
68
+ // to the drum bank; the channel keeps the preset it had, which is a
69
+ // piano, so a second drum track rendered its congas as piano notes.
70
+ // Never select a melodic program on either: on channel 9 that
71
+ // silences it.
72
+ if (channel !== 9)
73
+ synth.midiSetChannelType(channel, true);
74
+ // Standard first so a kit the font lacks falls back to drums rather
75
+ // than to the piano a switched channel starts on. General MIDI
76
+ // selects the kit with a program change on the drum channel, so the
77
+ // track's program is its kit.
78
+ synth.midiProgramSelect(channel, sfontId, DRUM_BANK, STANDARD_KIT);
79
+ if (track.midiProgram !== STANDARD_KIT) {
80
+ synth.midiProgramSelect(channel, sfontId, DRUM_BANK, track.midiProgram);
81
+ }
82
+ }
83
+ else {
84
+ synth.midiSetChannelType(channel, false);
85
+ synth.midiProgramSelect(channel, sfontId, 0, track.midiProgram);
86
+ }
87
+ synth.midiControl(channel, CC_VOLUME, Math.round(track.volume * MAX_CC));
88
+ synth.midiControl(channel, CC_PAN, panToCc(track.pan));
89
+ }
90
+ // Every note-on and note-off placed on the frame it falls due.
91
+ const timeline = [];
92
+ for (const event of plan.events) {
93
+ const assignment = channelFor.get(event.trackId);
94
+ if (!assignment)
95
+ continue; // a track this pass does not own, or one the plan never listed
96
+ const { channel } = assignment;
97
+ const velocity = Math.max(1, Math.min(MAX_CC, Math.round(event.velocity * MAX_CC)));
98
+ timeline.push({
99
+ atFrame: Math.round(event.startSec * sampleRate),
100
+ run: () => synth.midiNoteOn(channel, event.midi, velocity),
101
+ });
102
+ timeline.push({
103
+ atFrame: Math.round((event.startSec + event.durationSec) * sampleRate),
104
+ run: () => synth.midiNoteOff(channel, event.midi),
105
+ });
106
+ }
107
+ timeline.sort((a, b) => a.atFrame - b.atFrame);
108
+ const left = new Float32Array(BLOCK_FRAMES);
109
+ const right = new Float32Array(BLOCK_FRAMES);
110
+ let next = 0;
111
+ for (let frame = 0; frame < totalFrames; frame += BLOCK_FRAMES) {
112
+ const blockEnd = frame + BLOCK_FRAMES;
113
+ while (next < timeline.length && timeline[next].atFrame < blockEnd)
114
+ timeline[next++].run();
115
+ synth.render([left, right]);
116
+ // Mono mixdown, not just the left channel: a hard-panned track would
117
+ // otherwise vanish from the export while being audible in playback.
118
+ // Summed rather than assigned, so a second pass adds to the first.
119
+ const count = Math.min(BLOCK_FRAMES, totalFrames - frame);
120
+ for (let i = 0; i < count; i += 1) {
121
+ samples[frame + i] += (left[i] + right[i]) * 0.5 * headroom;
122
+ }
123
+ }
124
+ }
125
+ };
126
+ }
127
+ function panToCc(pan) {
128
+ return Math.round(((Math.min(1, Math.max(-1, pan)) + 1) / 2) * MAX_CC);
129
+ }
130
+ //# sourceMappingURL=soundfont-render.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"soundfont-render.js","sourceRoot":"","sources":["../../../src/web/audio/soundfont-render.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACpE,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAElE,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAarD,MAAM,WAAW,GAAG,KAAK,CAAC;AAC1B,+DAA+D;AAC/D,MAAM,YAAY,GAAG,CAAC,CAAC;AACvB,wEAAwE;AACxE,MAAM,YAAY,GAAG,GAAG,CAAC;AACzB,MAAM,SAAS,GAAG,CAAC,CAAC;AACpB,MAAM,MAAM,GAAG,EAAE,CAAC;AAClB,MAAM,MAAM,GAAG,GAAG,CAAC;AACnB,0EAA0E;AAC1E,MAAM,SAAS,GAAG,GAAG,CAAC;AACtB,uEAAuE;AACvE,MAAM,YAAY,GAAG,CAAC,CAAC;AAIvB,MAAM,UAAU,uBAAuB,CACrC,MAA+B;IAE/B,MAAM,OAAO,GACX,MAAM,CAAC,YAAY;QACnB,CAAC,CAAC,UAAkB,EAAE,EAAE,CACtB,eAAe,CAAC;YACd,mBAAmB,EAAE,MAAM,CAAC,mBAAmB;YAC/C,OAAO,EAAE,MAAM,CAAC,OAAO;YACvB,QAAQ,EAAE,MAAM,CAAC,QAAQ;YACzB,UAAU;SACX,CAAC,CAAC,CAAC;IAER,OAAO,KAAK,UAAU,MAAM,CAAC,IAAgB;QAC3C,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,GAAG,MAAM,OAAO,CAAC,WAAW,CAAC,CAAC;QAClE,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,WAAW,GAAG,YAAY,CAAC,GAAG,UAAU,CAAC,CAAC;QAE3F,MAAM,EAAE,WAAW,EAAE,aAAa,EAAE,GAAG,gBAAgB,CACrD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,YAAY,EAAE,CAAC,CAAC,YAAY,EAAE,CAAC,CAAC,CACrE,CAAC;QAEF,sEAAsE;QACtE,yEAAyE;QACzE,yEAAyE;QACzE,2EAA2E;QAC3E,uEAAuE;QACvE,6EAA6E;QAC7E,MAAM,QAAQ,GAAG,eAAe,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAErD,MAAM,OAAO,GAAG,IAAI,YAAY,CAAC,WAAW,CAAC,CAAC;QAC9C,wEAAwE;QACxE,wEAAwE;QACxE,wEAAwE;QACxE,2EAA2E;QAC3E,yEAAyE;QACzE,yEAAyE;QACzE,qBAAqB;QACrB,KAAK,IAAI,QAAQ,GAAG,CAAC,EAAE,QAAQ,GAAG,aAAa,EAAE,QAAQ,IAAI,CAAC,EAAE,CAAC;YAC/D,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC;QACtF,CAAC;QAED,KAAK,CAAC,gBAAgB,EAAE,CAAC;QACzB,0EAA0E;QAC1E,2EAA2E;QAC3E,2EAA2E;QAC3E,+BAA+B;QAC/B,UAAU,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC;QAChC,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,CAAC;QAE/B,+EAA+E;QAC/E,SAAS,UAAU,CAAC,MAA4B;YAC9C,MAAM,UAAU,GAAG,IAAI,GAAG,EAA6B,CAAC;YACxD,yEAAyE;YACzE,6DAA6D;YAC7D,KAAK,CAAC,gBAAgB,EAAE,CAAC;YACzB,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;gBAC3B,MAAM,UAAU,GAAG,WAAW,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;gBAC7C,IAAI,CAAC,UAAU;oBAAE,SAAS;gBAC1B,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,EAAE,UAAU,CAAC,CAAC;gBACrC,MAAM,EAAE,OAAO,EAAE,GAAG,UAAU,CAAC;gBAE/B,IAAI,KAAK,CAAC,YAAY,IAAI,UAAU,CAAC,mBAAmB,EAAE,CAAC;oBACzD,oEAAoE;oBACpE,qEAAqE;oBACrE,oEAAoE;oBACpE,oEAAoE;oBACpE,8DAA8D;oBAC9D,eAAe;oBACf,IAAI,OAAO,KAAK,CAAC;wBAAE,KAAK,CAAC,kBAAkB,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;oBAC3D,oEAAoE;oBACpE,+DAA+D;oBAC/D,oEAAoE;oBACpE,8BAA8B;oBAC9B,KAAK,CAAC,iBAAiB,CAAC,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,YAAY,CAAC,CAAC;oBACnE,IAAI,KAAK,CAAC,WAAW,KAAK,YAAY,EAAE,CAAC;wBACvC,KAAK,CAAC,iBAAiB,CAAC,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,KAAK,CAAC,WAAW,CAAC,CAAC;oBAC1E,CAAC;gBACH,CAAC;qBAAM,CAAC;oBACN,KAAK,CAAC,kBAAkB,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;oBACzC,KAAK,CAAC,iBAAiB,CAAC,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,CAAC,WAAW,CAAC,CAAC;gBAClE,CAAC;gBACD,KAAK,CAAC,WAAW,CAAC,OAAO,EAAE,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC;gBACzE,KAAK,CAAC,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;YACzD,CAAC;YAED,+DAA+D;YAC/D,MAAM,QAAQ,GAAiB,EAAE,CAAC;YAClC,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;gBAChC,MAAM,UAAU,GAAG,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;gBACjD,IAAI,CAAC,UAAU;oBAAE,SAAS,CAAC,+DAA+D;gBAC1F,MAAM,EAAE,OAAO,EAAE,GAAG,UAAU,CAAC;gBAC/B,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;gBACpF,QAAQ,CAAC,IAAI,CAAC;oBACZ,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,GAAG,UAAU,CAAC;oBAChD,GAAG,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC,OAAO,EAAE,KAAK,CAAC,IAAI,EAAE,QAAQ,CAAC;iBAC3D,CAAC,CAAC;gBACH,QAAQ,CAAC,IAAI,CAAC;oBACZ,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,QAAQ,GAAG,KAAK,CAAC,WAAW,CAAC,GAAG,UAAU,CAAC;oBACtE,GAAG,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,WAAW,CAAC,OAAO,EAAE,KAAK,CAAC,IAAI,CAAC;iBAClD,CAAC,CAAC;YACL,CAAC;YACD,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC;YAE/C,MAAM,IAAI,GAAG,IAAI,YAAY,CAAC,YAAY,CAAC,CAAC;YAC5C,MAAM,KAAK,GAAG,IAAI,YAAY,CAAC,YAAY,CAAC,CAAC;YAC7C,IAAI,IAAI,GAAG,CAAC,CAAC;YAEb,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,WAAW,EAAE,KAAK,IAAI,YAAY,EAAE,CAAC;gBAC/D,MAAM,QAAQ,GAAG,KAAK,GAAG,YAAY,CAAC;gBACtC,OAAO,IAAI,GAAG,QAAQ,CAAC,MAAM,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,GAAG,QAAQ;oBAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC;gBAE3F,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC;gBAE5B,qEAAqE;gBACrE,oEAAoE;gBACpE,mEAAmE;gBACnE,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,YAAY,EAAE,WAAW,GAAG,KAAK,CAAC,CAAC;gBAC1D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;oBAClC,OAAO,CAAC,KAAK,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,GAAG,QAAQ,CAAC;gBAC9D,CAAC;YACH,CAAC;QACH,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED,SAAS,OAAO,CAAC,GAAW;IAC1B,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC;AACzE,CAAC"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The slice of `js-synthesizer`'s non-worklet `Synthesizer` offline rendering
3
+ * uses, declared here so the renderer can be tested against a stub.
4
+ *
5
+ * Signatures follow the library's own `ISynthesizer`. Note `midiSetChannelType`
6
+ * takes a **boolean**, not a numeric channel-type constant — a mismatch that
7
+ * happens to work by truthiness and is therefore worth stating plainly.
8
+ */
9
+ export type SynthesizerLike = {
10
+ init(sampleRate: number, settings?: {
11
+ midiChannelCount?: number;
12
+ polyphony?: number;
13
+ }): void;
14
+ loadSFont(bin: ArrayBuffer): Promise<number>;
15
+ render(outBuffer: Float32Array[]): void;
16
+ midiNoteOn(chan: number, key: number, vel: number): void;
17
+ midiNoteOff(chan: number, key: number): void;
18
+ midiControl(chan: number, ctrl: number, val: number): void;
19
+ midiProgramSelect(chan: number, sfontId: number, bank: number, presetNum: number): void;
20
+ midiSetChannelType(chan: number, isDrum: boolean): void;
21
+ midiAllSoundsOff(chan?: number): void;
22
+ close(): void;
23
+ };
24
+ //# sourceMappingURL=synth-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"synth-types.d.ts","sourceRoot":"","sources":["../../../src/web/audio/synth-types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE;QAAE,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC7F,SAAS,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7C,MAAM,CAAC,SAAS,EAAE,YAAY,EAAE,GAAG,IAAI,CAAC;IACxC,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACzD,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7C,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3D,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACxF,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC;IACxD,gBAAgB,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,KAAK,IAAI,IAAI,CAAC;CACf,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=synth-types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"synth-types.js","sourceRoot":"","sources":["../../../src/web/audio/synth-types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,40 @@
1
+ import { MusicPlayer } from '../player.js';
2
+ export * from '../types.js';
3
+ export * from '../singleton.js';
4
+ export { MusicPlayer };
5
+ export { renderEvents } from '../shared/render-events.js';
6
+ export { playbackPlan, playbackTracks, resolveVoice } from '../shared/plan.js';
7
+ /**
8
+ * Where the soundfont engine's assets are served from.
9
+ *
10
+ * Passed in rather than resolved here: this is a library and cannot assume how
11
+ * a consuming app serves files out of `node_modules`. The worklet modules in
12
+ * particular must be reachable as URLs, because `addModule` takes a URL.
13
+ *
14
+ * Required, not optional: the soundfont synth is the only engine. There is no
15
+ * silent fallback to be had, and an app that has not hosted the assets should
16
+ * fail loudly at wiring time rather than mysteriously play nothing.
17
+ */
18
+ export type SoundfontAssets = {
19
+ /** The `-with-libsndfile` build. The plain one cannot read SF3. */
20
+ fluidsynthModuleUrl: string;
21
+ workletModuleUrl: string;
22
+ fontUrl: string;
23
+ };
24
+ /** The web player, built fresh. Hand it to `initializeMusicPlayer`. */
25
+ export declare function createMusicPlayer({ soundfont, }: {
26
+ soundfont: SoundfontAssets;
27
+ }): MusicPlayer;
28
+ /**
29
+ * Renders a plan to PCM, faster than realtime.
30
+ *
31
+ * Through the same soundfont as playback, so a file is a recording of what was
32
+ * heard — two voicing paths drift apart the first time one is tuned. The synth
33
+ * *plumbing* differs because it must: playback runs on a realtime
34
+ * `AudioContext` and rendering needs no context at all, and nothing bridges the
35
+ * two. What is shared is the voicing, which is the part that matters.
36
+ *
37
+ * The caller encodes and saves; this package does not touch files.
38
+ */
39
+ export declare function renderSamples(soundfont: SoundfontAssets): (plan: import("@sudobility/music_types").RenderPlan) => Promise<import("@sudobility/music_types").DecodedAudio>;
40
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/web/index.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAE3C,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,OAAO,EAAE,WAAW,EAAE,CAAC;AACvB,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAC1D,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAgB/E;;;;;;;;;;GAUG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,mEAAmE;IACnE,mBAAmB,EAAE,MAAM,CAAC;IAC5B,gBAAgB,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,uEAAuE;AACvE,wBAAgB,iBAAiB,CAAC,EAChC,SAAS,GACV,EAAE;IACD,SAAS,EAAE,eAAe,CAAC;CAC5B,GAAG,WAAW,CAWd;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,eAAe,mHAOvD"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The web player: libfluidsynth (WebAssembly) inside an `AudioWorklet`.
3
+ *
4
+ * Resolved by any bundler that is not Metro, through the `default` branch of
5
+ * this package's export condition.
6
+ */
7
+ import { SoundfontPlaybackEngine } from './playback/soundfont-engine.js';
8
+ import { SynthHost } from './playback/synth-host.js';
9
+ import { loadSoundfont, openSoundfontCache, } from './playback/soundfont-loader.js';
10
+ import { createSoundfontRenderer } from './audio/soundfont-render.js';
11
+ import { MusicPlayer } from '../player.js';
12
+ export * from '../types.js';
13
+ export * from '../singleton.js';
14
+ export { MusicPlayer };
15
+ export { renderEvents } from '../shared/render-events.js';
16
+ export { playbackPlan, playbackTracks, resolveVoice } from '../shared/plan.js';
17
+ /**
18
+ * Builds one real `AudioWorkletNodeSynthesizer`.
19
+ *
20
+ * The only place `js-synthesizer` is constructed, and the import is lazy so an
21
+ * app that never plays anything does not pull it in. Async by design: a
22
+ * synchronous factory would need the module loaded first, and a caller
23
+ * forgetting that step would fail at init with no sound and no obvious cause —
24
+ * the failure shape this engine exists to remove.
25
+ */
26
+ async function createWorkletSynth() {
27
+ const { AudioWorkletNodeSynthesizer } = await import('js-synthesizer');
28
+ return new AudioWorkletNodeSynthesizer();
29
+ }
30
+ /** The web player, built fresh. Hand it to `initializeMusicPlayer`. */
31
+ export function createMusicPlayer({ soundfont, }) {
32
+ return new MusicPlayer(new SoundfontPlaybackEngine({
33
+ host: new SynthHost({ createSynth: createWorkletSynth }),
34
+ moduleUrls: {
35
+ fluidsynth: soundfont.fluidsynthModuleUrl,
36
+ worklet: soundfont.workletModuleUrl,
37
+ },
38
+ fontUrl: soundfont.fontUrl,
39
+ }));
40
+ }
41
+ /**
42
+ * Renders a plan to PCM, faster than realtime.
43
+ *
44
+ * Through the same soundfont as playback, so a file is a recording of what was
45
+ * heard — two voicing paths drift apart the first time one is tuned. The synth
46
+ * *plumbing* differs because it must: playback runs on a realtime
47
+ * `AudioContext` and rendering needs no context at all, and nothing bridges the
48
+ * two. What is shared is the voicing, which is the part that matters.
49
+ *
50
+ * The caller encodes and saves; this package does not touch files.
51
+ */
52
+ export function renderSamples(soundfont) {
53
+ return createSoundfontRenderer({
54
+ fluidsynthModuleUrl: soundfont.fluidsynthModuleUrl,
55
+ fontUrl: soundfont.fontUrl,
56
+ loadFont: async (url) => loadSoundfont(url, { cache: await openSoundfontCache() }),
57
+ });
58
+ }
59
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/web/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AACzE,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAErD,OAAO,EACL,aAAa,EACb,kBAAkB,GACnB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAC;AACtE,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAE3C,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,OAAO,EAAE,WAAW,EAAE,CAAC;AACvB,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAC1D,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAE/E;;;;;;;;GAQG;AACH,KAAK,UAAU,kBAAkB;IAC/B,MAAM,EAAE,2BAA2B,EAAE,GAAG,MAAM,MAAM,CAAC,gBAAgB,CAAC,CAAC;IACvE,OAAO,IAAI,2BAA2B,EAA8B,CAAC;AACvE,CAAC;AAoBD,uEAAuE;AACvE,MAAM,UAAU,iBAAiB,CAAC,EAChC,SAAS,GAGV;IACC,OAAO,IAAI,WAAW,CACpB,IAAI,uBAAuB,CAAC;QAC1B,IAAI,EAAE,IAAI,SAAS,CAAC,EAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QACxD,UAAU,EAAE;YACV,UAAU,EAAE,SAAS,CAAC,mBAAmB;YACzC,OAAO,EAAE,SAAS,CAAC,gBAAgB;SACpC;QACD,OAAO,EAAE,SAAS,CAAC,OAAO;KAC3B,CAAC,CACH,CAAC;AACJ,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,aAAa,CAAC,SAA0B;IACtD,OAAO,uBAAuB,CAAC;QAC7B,mBAAmB,EAAE,SAAS,CAAC,mBAAmB;QAClD,OAAO,EAAE,SAAS,CAAC,OAAO;QAC1B,QAAQ,EAAE,KAAK,EAAC,GAAG,EAAC,EAAE,CACpB,aAAa,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,MAAM,kBAAkB,EAAE,EAAE,CAAC;KAC5D,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Which synth instance and MIDI channel each track plays on.
3
+ *
4
+ * General MIDI addresses 16 channels, but fluidsynth does not: its
5
+ * `midi-channels` setting takes 16-256 in multiples of 16, and one instance at
6
+ * 256 covers every realistic score with a single copy of the soundfont in WASM
7
+ * memory. This used to assume 16, which opened another instance — and another
8
+ * 23MB font copy — every sixteen tracks.
9
+ *
10
+ * Three rules shape the allocation, all of them facts about fluidsynth rather
11
+ * than preferences:
12
+ *
13
+ * - **Channel 9 is percussion and cannot be anything else.** The spike found
14
+ * that a drum track sounds there only if left completely alone: selecting a
15
+ * program on it silences the channel. So percussion prefers channel 9, and a
16
+ * pitched track may never take it.
17
+ * - **Whether channels 25, 41, ... are also percussion is undocumented.**
18
+ * General MIDI's drum channel is the tenth of each block of sixteen, and
19
+ * whether fluidsynth extends that convention past its first block cannot be
20
+ * determined without a real synth. Every `c % 16 === 9` is therefore kept
21
+ * away from pitched tracks: correct under either behaviour, and it costs
22
+ * sixteen channels out of 256.
23
+ * - **Any channel can be made percussion** with `midiSetChannelType`. That is
24
+ * what lets a seventeenth drum track share an instance instead of costing a
25
+ * whole new synth just to reach another drum-capable channel.
26
+ *
27
+ * Instances are therefore opened only when channels genuinely run out, which
28
+ * now means past 240 pitched tracks rather than past 15.
29
+ */
30
+ export type ChannelAssignment = {
31
+ instance: number;
32
+ channel: number;
33
+ /**
34
+ * True when the channel is percussion but is not channel 9, so the engine
35
+ * must call `midiSetChannelType` on it. Channel 9 needs nothing.
36
+ */
37
+ needsDrumTypeSwitch: boolean;
38
+ };
39
+ export type AllocatorTrack = {
40
+ id: string;
41
+ isPercussion: boolean;
42
+ };
43
+ /**
44
+ * Channels one fluidsynth instance addresses.
45
+ *
46
+ * `midiChannelCount` accepts 16-256 in multiples of 16, so a single synth
47
+ * covers every realistic score with one copy of the soundfont in WASM memory.
48
+ */
49
+ export declare const CHANNELS_PER_INSTANCE = 256;
50
+ export declare function allocateChannels(tracks: readonly AllocatorTrack[]): {
51
+ assignments: Map<string, ChannelAssignment>;
52
+ instanceCount: number;
53
+ };
54
+ //# sourceMappingURL=channel-allocator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-allocator.d.ts","sourceRoot":"","sources":["../../../src/web/playback/channel-allocator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,mBAAmB,EAAE,OAAO,CAAC;CAC9B,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,OAAO,CAAA;CAAE,CAAC;AAEnE;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,MAAM,CAAC;AAUzC,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,cAAc,EAAE,GAAG;IACnE,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;IAC5C,aAAa,EAAE,MAAM,CAAC;CACvB,CA4DA"}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Channels one fluidsynth instance addresses.
3
+ *
4
+ * `midiChannelCount` accepts 16-256 in multiples of 16, so a single synth
5
+ * covers every realistic score with one copy of the soundfont in WASM memory.
6
+ */
7
+ export const CHANNELS_PER_INSTANCE = 256;
8
+ /** The one channel fluidsynth already types as drums, so it needs no switch. */
9
+ const DEFAULT_PERCUSSION_CHANNEL = 9;
10
+ /** Channels reserved for percussion; see this module's doc for why it is not just 9. */
11
+ function isDrumCapable(channel) {
12
+ return channel % 16 === 9;
13
+ }
14
+ export function allocateChannels(tracks) {
15
+ const assignments = new Map();
16
+ /** Taken channels per instance; the array length is the instance count. */
17
+ const taken = [new Set()];
18
+ const freeChannel = (instance, wantDrum) => {
19
+ for (let channel = 0; channel < CHANNELS_PER_INSTANCE; channel += 1) {
20
+ if (isDrumCapable(channel) !== wantDrum)
21
+ continue;
22
+ if (!taken[instance].has(channel))
23
+ return channel;
24
+ }
25
+ return null;
26
+ };
27
+ const claim = (id, instance, channel, isPercussion) => {
28
+ taken[instance].add(channel);
29
+ assignments.set(id, {
30
+ instance,
31
+ channel,
32
+ // False for every pitched track. `soundfont-render.ts` branches on
33
+ // `track.isPercussion || assignment.needsDrumTypeSwitch`, so a pitched
34
+ // track carrying this flag would be sent a drum-bank program select and
35
+ // export as percussion.
36
+ //
37
+ // Among percussion, only instance 0's channel 9 is fluidsynth's own drum
38
+ // channel; every other drum slot has to be told what it is.
39
+ needsDrumTypeSwitch: isPercussion && !(instance === 0 && channel === DEFAULT_PERCUSSION_CHANNEL),
40
+ });
41
+ };
42
+ /** Places `id` on the first instance with a free channel of the wanted kind, opening one if none has. */
43
+ const place = (id, wantDrum, isPercussion) => {
44
+ for (let instance = 0; instance < taken.length; instance += 1) {
45
+ const channel = freeChannel(instance, wantDrum);
46
+ if (channel !== null) {
47
+ claim(id, instance, channel, isPercussion);
48
+ return;
49
+ }
50
+ }
51
+ taken.push(new Set());
52
+ // A fresh instance always has a free channel of either kind.
53
+ claim(id, taken.length - 1, freeChannel(taken.length - 1, wantDrum), isPercussion);
54
+ };
55
+ // Percussion first, so the drum-capable channels reach the tracks that need
56
+ // them before a pitched track can push one onto a second instance.
57
+ for (const track of tracks) {
58
+ if (!track.isPercussion)
59
+ continue;
60
+ // Prefer a drum-capable channel; fall back to an ordinary one, switched
61
+ // explicitly, rather than opening an instance for the sake of one channel.
62
+ const hasDrumSlot = taken.some((_, instance) => freeChannel(instance, true) !== null);
63
+ place(track.id, hasDrumSlot, true);
64
+ }
65
+ for (const track of tracks) {
66
+ if (track.isPercussion)
67
+ continue;
68
+ place(track.id, false, false);
69
+ }
70
+ return { assignments, instanceCount: taken.length };
71
+ }
72
+ //# sourceMappingURL=channel-allocator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel-allocator.js","sourceRoot":"","sources":["../../../src/web/playback/channel-allocator.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ /** A click already on the audio graph, and the two things a caller can do with it. */
2
+ export type ScheduledClick = {
3
+ /** When it has finished sounding, so a caller knows when to forget it. */
4
+ readonly endsAt: number;
5
+ /** Silences it from `atSeconds`, whether or not it has begun. */
6
+ cancel(atSeconds: number): void;
7
+ };
8
+ export declare function scheduleClick(context: BaseAudioContext, destination: AudioNode, atSeconds: number, accent: boolean): ScheduledClick;
9
+ //# sourceMappingURL=click.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"click.d.ts","sourceRoot":"","sources":["../../../src/web/playback/click.ts"],"names":[],"mappings":"AAyBA,sFAAsF;AACtF,MAAM,MAAM,cAAc,GAAG;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,iEAAiE;IACjE,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC,CAAC;AAEF,wBAAgB,aAAa,CAC3B,OAAO,EAAE,gBAAgB,EACzB,WAAW,EAAE,SAAS,EACtB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,OAAO,GACd,cAAc,CAqChB"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The metronome click, in plain Web Audio.
3
+ *
4
+ * Not routed through the synth, and not through the mixer. A click is a
5
+ * monitoring aid rather than part of the music, so the master headroom trim and
6
+ * the per-track balance have no business changing how loud it is — and it
7
+ * should not consume one of the sixteen MIDI channels a score might need.
8
+ *
9
+ * Unlike notes, clicks are scheduled *ahead* at an exact time: an oscillator
10
+ * started with `start(at)` sounds at that sample whatever the main thread is
11
+ * doing. So the click stays steady even when the pump runs late, which makes it
12
+ * a usable reference rather than a second thing to distrust.
13
+ *
14
+ * Which is also why scheduling one hands back a way to take it back. The
15
+ * engine keeps a whole horizon of them queued, and `allSoundOff` does not
16
+ * reach any of it — that speaks to the synth, and a click deliberately does
17
+ * not go through the synth. So pausing left the room ticking for as long as
18
+ * the horizon was deep.
19
+ */
20
+ const ACCENT_HZ = 1500;
21
+ const BEAT_HZ = 1000;
22
+ const CLICK_SECONDS = 0.03;
23
+ /** Quiet enough to sit under the music, loud enough to hear over it. */
24
+ const CLICK_PEAK = 0.25;
25
+ export function scheduleClick(context, destination, atSeconds, accent) {
26
+ const oscillator = context.createOscillator();
27
+ const gain = context.createGain();
28
+ oscillator.frequency.value = accent ? ACCENT_HZ : BEAT_HZ;
29
+ oscillator.connect(gain);
30
+ gain.connect(destination);
31
+ // A short decay rather than a hard stop: a square-edged gate on a sine is an
32
+ // audible tick of its own.
33
+ const end = atSeconds + CLICK_SECONDS;
34
+ gain.gain.setValueAtTime(CLICK_PEAK, atSeconds);
35
+ gain.gain.exponentialRampToValueAtTime(0.0001, end);
36
+ oscillator.start(atSeconds);
37
+ oscillator.stop(end);
38
+ let released = false;
39
+ oscillator.onended = () => {
40
+ released = true;
41
+ oscillator.disconnect();
42
+ gain.disconnect();
43
+ };
44
+ return {
45
+ endsAt: end,
46
+ cancel(from) {
47
+ // Nothing to take back once it has run its course. `onended` is the
48
+ // reliable signal but it arrives on a later task, so the time is checked
49
+ // too — a click that finished microseconds ago is still finished.
50
+ if (released || from >= end)
51
+ return;
52
+ // Drops the peak scheduled above when `from` precedes it, so a click
53
+ // cancelled before it begins never gets a level at all.
54
+ gain.gain.cancelScheduledValues(from);
55
+ gain.gain.setValueAtTime(0, from);
56
+ // A stop at or before the start means it simply never plays.
57
+ oscillator.stop(from);
58
+ },
59
+ };
60
+ }
61
+ //# sourceMappingURL=click.js.map