@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.
- package/CLAUDE.md +335 -0
- package/dist/core.d.ts +33 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +33 -0
- package/dist/core.js.map +1 -0
- package/dist/engine.d.ts +103 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +2 -0
- package/dist/engine.js.map +1 -0
- package/dist/mocks/index.d.ts +46 -0
- package/dist/mocks/index.d.ts.map +1 -0
- package/dist/mocks/index.js +93 -0
- package/dist/mocks/index.js.map +1 -0
- package/dist/player.d.ts +81 -0
- package/dist/player.d.ts.map +1 -0
- package/dist/player.js +209 -0
- package/dist/player.js.map +1 -0
- package/dist/rn/audio/offline-render.d.ts +29 -0
- package/dist/rn/audio/offline-render.d.ts.map +1 -0
- package/dist/rn/audio/offline-render.js +134 -0
- package/dist/rn/audio/offline-render.js.map +1 -0
- package/dist/rn/index.d.ts +50 -0
- package/dist/rn/index.d.ts.map +1 -0
- package/dist/rn/index.js +39 -0
- package/dist/rn/index.js.map +1 -0
- package/dist/rn/playback/audio-api.d.ts +93 -0
- package/dist/rn/playback/audio-api.d.ts.map +1 -0
- package/dist/rn/playback/audio-api.js +24 -0
- package/dist/rn/playback/audio-api.js.map +1 -0
- package/dist/rn/playback/expression-table.d.ts +22 -0
- package/dist/rn/playback/expression-table.d.ts.map +1 -0
- package/dist/rn/playback/expression-table.js +926 -0
- package/dist/rn/playback/expression-table.js.map +1 -0
- package/dist/rn/playback/expression.d.ts +48 -0
- package/dist/rn/playback/expression.d.ts.map +1 -0
- package/dist/rn/playback/expression.js +120 -0
- package/dist/rn/playback/expression.js.map +1 -0
- package/dist/rn/playback/gm-pack-name.d.ts +40 -0
- package/dist/rn/playback/gm-pack-name.d.ts.map +1 -0
- package/dist/rn/playback/gm-pack-name.js +61 -0
- package/dist/rn/playback/gm-pack-name.js.map +1 -0
- package/dist/rn/playback/pack-library.d.ts +44 -0
- package/dist/rn/playback/pack-library.d.ts.map +1 -0
- package/dist/rn/playback/pack-library.js +85 -0
- package/dist/rn/playback/pack-library.js.map +1 -0
- package/dist/rn/playback/pack-names.json +130 -0
- package/dist/rn/playback/sample-engine.d.ts +216 -0
- package/dist/rn/playback/sample-engine.d.ts.map +1 -0
- package/dist/rn/playback/sample-engine.js +669 -0
- package/dist/rn/playback/sample-engine.js.map +1 -0
- package/dist/rn/playback/sample-pack.d.ts +71 -0
- package/dist/rn/playback/sample-pack.d.ts.map +1 -0
- package/dist/rn/playback/sample-pack.js +112 -0
- package/dist/rn/playback/sample-pack.js.map +1 -0
- package/dist/rn/playback/sustain-loop.d.ts +24 -0
- package/dist/rn/playback/sustain-loop.d.ts.map +1 -0
- package/dist/rn/playback/sustain-loop.js +111 -0
- package/dist/rn/playback/sustain-loop.js.map +1 -0
- package/dist/rn/playback/voice-plan.d.ts +60 -0
- package/dist/rn/playback/voice-plan.d.ts.map +1 -0
- package/dist/rn/playback/voice-plan.js +38 -0
- package/dist/rn/playback/voice-plan.js.map +1 -0
- package/dist/shared/bus.d.ts +73 -0
- package/dist/shared/bus.d.ts.map +1 -0
- package/dist/shared/bus.js +83 -0
- package/dist/shared/bus.js.map +1 -0
- package/dist/shared/midi.d.ts +18 -0
- package/dist/shared/midi.d.ts.map +1 -0
- package/dist/shared/midi.js +25 -0
- package/dist/shared/midi.js.map +1 -0
- package/dist/shared/mix.d.ts +75 -0
- package/dist/shared/mix.d.ts.map +1 -0
- package/dist/shared/mix.js +115 -0
- package/dist/shared/mix.js.map +1 -0
- package/dist/shared/note-queue.d.ts +43 -0
- package/dist/shared/note-queue.d.ts.map +1 -0
- package/dist/shared/note-queue.js +54 -0
- package/dist/shared/note-queue.js.map +1 -0
- package/dist/shared/plan.d.ts +34 -0
- package/dist/shared/plan.d.ts.map +1 -0
- package/dist/shared/plan.js +134 -0
- package/dist/shared/plan.js.map +1 -0
- package/dist/shared/pump-window.d.ts +38 -0
- package/dist/shared/pump-window.d.ts.map +1 -0
- package/dist/shared/pump-window.js +13 -0
- package/dist/shared/pump-window.js.map +1 -0
- package/dist/shared/render-events.d.ts +25 -0
- package/dist/shared/render-events.d.ts.map +1 -0
- package/dist/shared/render-events.js +79 -0
- package/dist/shared/render-events.js.map +1 -0
- package/dist/shared/sounding-set.d.ts +45 -0
- package/dist/shared/sounding-set.d.ts.map +1 -0
- package/dist/shared/sounding-set.js +81 -0
- package/dist/shared/sounding-set.js.map +1 -0
- package/dist/shared/test-plan.d.ts +36 -0
- package/dist/shared/test-plan.d.ts.map +1 -0
- package/dist/shared/test-plan.js +83 -0
- package/dist/shared/test-plan.js.map +1 -0
- package/dist/singleton.d.ts +23 -0
- package/dist/singleton.d.ts.map +1 -0
- package/dist/singleton.js +26 -0
- package/dist/singleton.js.map +1 -0
- package/dist/types.d.ts +84 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/web/audio/offline-synth.d.ts +24 -0
- package/dist/web/audio/offline-synth.d.ts.map +1 -0
- package/dist/web/audio/offline-synth.js +92 -0
- package/dist/web/audio/offline-synth.js.map +1 -0
- package/dist/web/audio/soundfont-render.d.ts +31 -0
- package/dist/web/audio/soundfont-render.d.ts.map +1 -0
- package/dist/web/audio/soundfont-render.js +130 -0
- package/dist/web/audio/soundfont-render.js.map +1 -0
- package/dist/web/audio/synth-types.d.ts +24 -0
- package/dist/web/audio/synth-types.d.ts.map +1 -0
- package/dist/web/audio/synth-types.js +2 -0
- package/dist/web/audio/synth-types.js.map +1 -0
- package/dist/web/index.d.ts +40 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +59 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/playback/channel-allocator.d.ts +54 -0
- package/dist/web/playback/channel-allocator.d.ts.map +1 -0
- package/dist/web/playback/channel-allocator.js +72 -0
- package/dist/web/playback/channel-allocator.js.map +1 -0
- package/dist/web/playback/click.d.ts +9 -0
- package/dist/web/playback/click.d.ts.map +1 -0
- package/dist/web/playback/click.js +61 -0
- package/dist/web/playback/click.js.map +1 -0
- package/dist/web/playback/clock.d.ts +36 -0
- package/dist/web/playback/clock.d.ts.map +1 -0
- package/dist/web/playback/clock.js +64 -0
- package/dist/web/playback/clock.js.map +1 -0
- package/dist/web/playback/governor.d.ts +45 -0
- package/dist/web/playback/governor.d.ts.map +1 -0
- package/dist/web/playback/governor.js +33 -0
- package/dist/web/playback/governor.js.map +1 -0
- package/dist/web/playback/quiet-stub-notices.d.ts +55 -0
- package/dist/web/playback/quiet-stub-notices.d.ts.map +1 -0
- package/dist/web/playback/quiet-stub-notices.js +93 -0
- package/dist/web/playback/quiet-stub-notices.js.map +1 -0
- package/dist/web/playback/soundfont-engine.d.ts +281 -0
- package/dist/web/playback/soundfont-engine.d.ts.map +1 -0
- package/dist/web/playback/soundfont-engine.js +735 -0
- package/dist/web/playback/soundfont-engine.js.map +1 -0
- package/dist/web/playback/soundfont-loader.d.ts +30 -0
- package/dist/web/playback/soundfont-loader.d.ts.map +1 -0
- package/dist/web/playback/soundfont-loader.js +68 -0
- package/dist/web/playback/soundfont-loader.js.map +1 -0
- package/dist/web/playback/synth-host.d.ts +142 -0
- package/dist/web/playback/synth-host.d.ts.map +1 -0
- package/dist/web/playback/synth-host.js +293 -0
- package/dist/web/playback/synth-host.js.map +1 -0
- package/package.json +18 -3
- package/dist/index.d.ts +0 -14
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { getMusicPositionSource } from '@sudobility/music_types';
|
|
2
|
+
export class PlaybackBus {
|
|
3
|
+
positionListeners = new Set();
|
|
4
|
+
soundingListeners = new Set();
|
|
5
|
+
transportListeners = new Set();
|
|
6
|
+
/** The last values published, so a subscriber joining mid-playback is not blind until the next event. */
|
|
7
|
+
lastSounding = [];
|
|
8
|
+
lastTransport = 'stopped';
|
|
9
|
+
onPosition(listener) {
|
|
10
|
+
this.positionListeners.add(listener);
|
|
11
|
+
return () => this.positionListeners.delete(listener);
|
|
12
|
+
}
|
|
13
|
+
onSounding(listener) {
|
|
14
|
+
this.soundingListeners.add(listener);
|
|
15
|
+
return () => this.soundingListeners.delete(listener);
|
|
16
|
+
}
|
|
17
|
+
onTransport(listener) {
|
|
18
|
+
this.transportListeners.add(listener);
|
|
19
|
+
return () => this.transportListeners.delete(listener);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Every caret move in the app arrives here — the engine's position reports,
|
|
23
|
+
* a seek, a click on the staff, an arrow key — which is what makes this the
|
|
24
|
+
* one place that has to tell `IMusicPosition` about it. Reporting from the
|
|
25
|
+
* controller's report handler instead would have covered playback only, and
|
|
26
|
+
* left the shared position stale the moment somebody moved the caret by
|
|
27
|
+
* hand.
|
|
28
|
+
*
|
|
29
|
+
* The anchor is stamped **here**, inside the engine's own callback, rather
|
|
30
|
+
* than wherever a React subscriber eventually runs: that is the difference
|
|
31
|
+
* between measuring from the audio clock and measuring from event-loop
|
|
32
|
+
* latency, and it is why the caret used to drift away from the highlights
|
|
33
|
+
* and the keyboard under load.
|
|
34
|
+
*/
|
|
35
|
+
publishPosition(tick) {
|
|
36
|
+
getMusicPositionSource().report(tick);
|
|
37
|
+
for (const listener of this.positionListeners)
|
|
38
|
+
listener(tick);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The engine already emits only on change (`SoundingSet`), so this does not
|
|
42
|
+
* filter again — doing so would hide a deliberate re-publish after a seek.
|
|
43
|
+
*/
|
|
44
|
+
publishSounding(notes) {
|
|
45
|
+
this.lastSounding = notes;
|
|
46
|
+
for (const listener of this.soundingListeners)
|
|
47
|
+
listener(notes);
|
|
48
|
+
}
|
|
49
|
+
publishTransport(state) {
|
|
50
|
+
this.lastTransport = state;
|
|
51
|
+
for (const listener of this.transportListeners)
|
|
52
|
+
listener(state);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Where playback last reported it was. Read once on subscribe; do not poll
|
|
56
|
+
* this.
|
|
57
|
+
*
|
|
58
|
+
* Delegated to the playhead rather than kept here. The bus used to store its
|
|
59
|
+
* own copy of the reported tick beside the one `IMusicPositionSource` holds,
|
|
60
|
+
* which made two objects each own the same fact — and they duly came apart:
|
|
61
|
+
* when the engine's throttle stopped reporting, the playhead went on
|
|
62
|
+
* projecting while this copy stayed where it was, so the caret glided the
|
|
63
|
+
* length of the score with the scroll and the readouts still at bar one.
|
|
64
|
+
* Reading through means the smoothed position and the reported one are two
|
|
65
|
+
* views of one state, which is what the interface promises.
|
|
66
|
+
*/
|
|
67
|
+
get positionTick() {
|
|
68
|
+
return getMusicPositionSource().reportedTick;
|
|
69
|
+
}
|
|
70
|
+
get sounding() {
|
|
71
|
+
return this.lastSounding;
|
|
72
|
+
}
|
|
73
|
+
get transport() {
|
|
74
|
+
return this.lastTransport;
|
|
75
|
+
}
|
|
76
|
+
/** Drops every listener. For teardown; a live app never calls this. */
|
|
77
|
+
clear() {
|
|
78
|
+
this.positionListeners.clear();
|
|
79
|
+
this.soundingListeners.clear();
|
|
80
|
+
this.transportListeners.clear();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=bus.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bus.js","sourceRoot":"","sources":["../../src/shared/bus.ts"],"names":[],"mappings":"AA2BA,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAEjE,MAAM,OAAO,WAAW;IACL,iBAAiB,GAAG,IAAI,GAAG,EAA0B,CAAC;IACtD,iBAAiB,GAAG,IAAI,GAAG,EAEzC,CAAC;IACa,kBAAkB,GAAG,IAAI,GAAG,EAE1C,CAAC;IAEJ,yGAAyG;IACjG,YAAY,GAA4B,EAAE,CAAC;IAC3C,aAAa,GAA2B,SAAS,CAAC;IAE1D,UAAU,CAAC,QAAgC;QACzC,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACrC,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,iBAAiB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IACvD,CAAC;IAED,UAAU,CAAC,QAAkD;QAC3D,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACrC,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,iBAAiB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IACvD,CAAC;IAED,WAAW,CAAC,QAAiD;QAC3D,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACtC,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,kBAAkB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IACxD,CAAC;IAED;;;;;;;;;;;;;OAaG;IACH,eAAe,CAAC,IAAY;QAC1B,sBAAsB,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QACtC,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,iBAAiB;YAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;IAChE,CAAC;IAED;;;OAGG;IACH,eAAe,CAAC,KAA8B;QAC5C,IAAI,CAAC,YAAY,GAAG,KAAK,CAAC;QAC1B,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,iBAAiB;YAAE,QAAQ,CAAC,KAAK,CAAC,CAAC;IACjE,CAAC;IAED,gBAAgB,CAAC,KAA6B;QAC5C,IAAI,CAAC,aAAa,GAAG,KAAK,CAAC;QAC3B,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,kBAAkB;YAAE,QAAQ,CAAC,KAAK,CAAC,CAAC;IAClE,CAAC;IAED;;;;;;;;;;;;OAYG;IACH,IAAI,YAAY;QACd,OAAO,sBAAsB,EAAE,CAAC,YAAY,CAAC;IAC/C,CAAC;IAED,IAAI,QAAQ;QACV,OAAO,IAAI,CAAC,YAAY,CAAC;IAC3B,CAAC;IAED,IAAI,SAAS;QACX,OAAO,IAAI,CAAC,aAAa,CAAC;IAC5B,CAAC;IAED,uEAAuE;IACvE,KAAK;QACH,IAAI,CAAC,iBAAiB,CAAC,KAAK,EAAE,CAAC;QAC/B,IAAI,CAAC,iBAAiB,CAAC,KAAK,EAAE,CAAC;QAC/B,IAAI,CAAC,kBAAkB,CAAC,KAAK,EAAE,CAAC;IAClC,CAAC;CACF"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure MIDI-number conversions used by the Tone.js adapter (Task 13). Kept
|
|
3
|
+
* dependency-free (no Tone.js import) so they're trivially unit-testable
|
|
4
|
+
* without mocking `tone`, per the Task 13 brief's guidance to keep every
|
|
5
|
+
* piece of logic that doesn't actually need Tone in small, separately
|
|
6
|
+
* testable functions.
|
|
7
|
+
*/
|
|
8
|
+
/** Converts a MIDI note number (60 = C4) to its frequency in Hertz. */
|
|
9
|
+
export declare function midiToHertz(midi: number): number;
|
|
10
|
+
/**
|
|
11
|
+
* Converts a domain `NoteEvent.velocity` (spec §4: integer 0-127) to Tone's
|
|
12
|
+
* normalized 0-1 velocity range, clamped defensively in case of an
|
|
13
|
+
* out-of-range value slipping through (validation should catch this
|
|
14
|
+
* earlier, but the audio layer shouldn't crash or produce out-of-range
|
|
15
|
+
* gain if it doesn't).
|
|
16
|
+
*/
|
|
17
|
+
export declare function normalizeVelocity(velocity: number): number;
|
|
18
|
+
//# sourceMappingURL=midi.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"midi.d.ts","sourceRoot":"","sources":["../../src/shared/midi.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAMH,uEAAuE;AACvE,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE1D"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure MIDI-number conversions used by the Tone.js adapter (Task 13). Kept
|
|
3
|
+
* dependency-free (no Tone.js import) so they're trivially unit-testable
|
|
4
|
+
* without mocking `tone`, per the Task 13 brief's guidance to keep every
|
|
5
|
+
* piece of logic that doesn't actually need Tone in small, separately
|
|
6
|
+
* testable functions.
|
|
7
|
+
*/
|
|
8
|
+
/** A4 = 69 = 440Hz, the standard MIDI tuning reference. */
|
|
9
|
+
const A4_MIDI = 69;
|
|
10
|
+
const A4_HERTZ = 440;
|
|
11
|
+
/** Converts a MIDI note number (60 = C4) to its frequency in Hertz. */
|
|
12
|
+
export function midiToHertz(midi) {
|
|
13
|
+
return A4_HERTZ * 2 ** ((midi - A4_MIDI) / 12);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Converts a domain `NoteEvent.velocity` (spec §4: integer 0-127) to Tone's
|
|
17
|
+
* normalized 0-1 velocity range, clamped defensively in case of an
|
|
18
|
+
* out-of-range value slipping through (validation should catch this
|
|
19
|
+
* earlier, but the audio layer shouldn't crash or produce out-of-range
|
|
20
|
+
* gain if it doesn't).
|
|
21
|
+
*/
|
|
22
|
+
export function normalizeVelocity(velocity) {
|
|
23
|
+
return Math.min(1, Math.max(0, velocity / 127));
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=midi.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"midi.js","sourceRoot":"","sources":["../../src/shared/midi.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,2DAA2D;AAC3D,MAAM,OAAO,GAAG,EAAE,CAAC;AACnB,MAAM,QAAQ,GAAG,GAAG,CAAC;AAErB,uEAAuE;AACvE,MAAM,UAAU,WAAW,CAAC,IAAY;IACtC,OAAO,QAAQ,GAAG,CAAC,IAAI,CAAC,CAAC,IAAI,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;AACjD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAAC,QAAgB;IAChD,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,GAAG,GAAG,CAAC,CAAC,CAAC;AAClD,CAAC"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The master-bus rules, shared by live playback and offline export on both
|
|
3
|
+
* platforms — `web/playback/synth-host.ts`, `web/audio/soundfont-render.ts`,
|
|
4
|
+
* `rn/playback/sample-engine.ts` and `rn/audio/offline-render.ts`.
|
|
5
|
+
*
|
|
6
|
+
* They live here rather than in each caller because an export that mixed
|
|
7
|
+
* differently from playback is exactly the bug this module exists to prevent:
|
|
8
|
+
* the file is supposed to be a recording of what was heard, and copies of these
|
|
9
|
+
* numbers would drift apart the first time one was tuned.
|
|
10
|
+
*
|
|
11
|
+
* Living here is not the same as being used, which is how the web export came
|
|
12
|
+
* to sum its channels raw while playback trimmed them — `sqrt(n)` louder than
|
|
13
|
+
* what was heard, into encoders that hard-clamp. All four callers are named
|
|
14
|
+
* above so the next one to be added is noticed by its absence.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Ceiling for the safety limiter on the master bus, in dB below full scale.
|
|
18
|
+
*
|
|
19
|
+
* The headroom trim sizes the mix for how loud it is *on average*; this catches
|
|
20
|
+
* the peaks it cannot predict — the moments when parts happen to line up in
|
|
21
|
+
* phase, which sum far above the incoherent average. Just under full scale
|
|
22
|
+
* rather than at it, so the limiter, and not the sound card, decides the
|
|
23
|
+
* ceiling.
|
|
24
|
+
*/
|
|
25
|
+
export declare const LIMITER_CEILING_DB = -1;
|
|
26
|
+
/**
|
|
27
|
+
* How far to pull the master down for `trackCount` channels summing into it.
|
|
28
|
+
*
|
|
29
|
+
* Every channel is scheduled at its own MIDI volume with nothing reconciling
|
|
30
|
+
* them, so the mix got louder with every track added. A nine-track arrangement
|
|
31
|
+
* summed to roughly two and a half times full scale and clipped through most of
|
|
32
|
+
* the piece; what that distortion buried first were the quiet, sustained
|
|
33
|
+
* voices, which is why tracks that were neither muted nor silent could not be
|
|
34
|
+
* heard at all.
|
|
35
|
+
*
|
|
36
|
+
* `1/sqrt(n)`, not `1/n`: separate instrument parts are not phase-correlated,
|
|
37
|
+
* so their powers add rather than their amplitudes. `1/n` is the law for copies
|
|
38
|
+
* of one signal and would leave a large arrangement far too quiet. A single
|
|
39
|
+
* track keeps unity — it has nothing to sum with.
|
|
40
|
+
*/
|
|
41
|
+
export declare function headroomTrimFor(trackCount: number): number;
|
|
42
|
+
/**
|
|
43
|
+
* Holds a rendered buffer under `LIMITER_CEILING_DB`, in place.
|
|
44
|
+
*
|
|
45
|
+
* The offline counterpart of the `DynamicsCompressorNode` on the live master
|
|
46
|
+
* bus, and it exists for the same reason: the headroom trim sizes the mix for
|
|
47
|
+
* how loud it is *on average*, and the moments where parts happen to line up in
|
|
48
|
+
* phase sum far above that. Live those peaks meet a limiter; in an export they
|
|
49
|
+
* meet `encodeWav`/`encodeMp3`, which clamp — so without this the loudest
|
|
50
|
+
* instants of a file are hard-clipped rather than ducked.
|
|
51
|
+
*
|
|
52
|
+
* It is **not** a reimplementation of that node. `DynamicsCompressorNode` has a
|
|
53
|
+
* knee, a ratio and program-dependent behaviour the specification does not pin
|
|
54
|
+
* down closely enough to reproduce, and chasing it would be pretending to a
|
|
55
|
+
* precision that is not there. What is reproduced is the job: nothing leaves
|
|
56
|
+
* here above the ceiling, and it gets there by ducking rather than clipping.
|
|
57
|
+
*
|
|
58
|
+
* The gain is worked out **backwards**, which is the one thing an offline
|
|
59
|
+
* limiter can do that a live one cannot. Each sample's gain is the lower of
|
|
60
|
+
* what that sample needs and what the next sample allows plus one attack step,
|
|
61
|
+
* so the ramp down is *finished* when the peak lands instead of chasing it.
|
|
62
|
+
* That ordering is also what makes the ceiling exact rather than approximate: a
|
|
63
|
+
* forward one-pole never quite arrives, and clamping the residue turns the last
|
|
64
|
+
* fraction of a decibel back into the clipping this replaces.
|
|
65
|
+
*
|
|
66
|
+
* The forward pass is the release, and the reason a sustained loud passage does
|
|
67
|
+
* not pump: within one cycle the requirement relaxes at every zero crossing,
|
|
68
|
+
* and the bounded climb simply does not follow it.
|
|
69
|
+
*
|
|
70
|
+
* Costs one `Float32Array` the length of the render — 53MB for five minutes at
|
|
71
|
+
* 44.1kHz, freed on return, against a `samples` buffer of the same size that
|
|
72
|
+
* the caller is already holding.
|
|
73
|
+
*/
|
|
74
|
+
export declare function limitPeaks(samples: Float32Array, sampleRate: number): void;
|
|
75
|
+
//# sourceMappingURL=mix.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mix.d.ts","sourceRoot":"","sources":["../../src/shared/mix.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE1D;AAsBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAyB1E"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The master-bus rules, shared by live playback and offline export on both
|
|
3
|
+
* platforms — `web/playback/synth-host.ts`, `web/audio/soundfont-render.ts`,
|
|
4
|
+
* `rn/playback/sample-engine.ts` and `rn/audio/offline-render.ts`.
|
|
5
|
+
*
|
|
6
|
+
* They live here rather than in each caller because an export that mixed
|
|
7
|
+
* differently from playback is exactly the bug this module exists to prevent:
|
|
8
|
+
* the file is supposed to be a recording of what was heard, and copies of these
|
|
9
|
+
* numbers would drift apart the first time one was tuned.
|
|
10
|
+
*
|
|
11
|
+
* Living here is not the same as being used, which is how the web export came
|
|
12
|
+
* to sum its channels raw while playback trimmed them — `sqrt(n)` louder than
|
|
13
|
+
* what was heard, into encoders that hard-clamp. All four callers are named
|
|
14
|
+
* above so the next one to be added is noticed by its absence.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Ceiling for the safety limiter on the master bus, in dB below full scale.
|
|
18
|
+
*
|
|
19
|
+
* The headroom trim sizes the mix for how loud it is *on average*; this catches
|
|
20
|
+
* the peaks it cannot predict — the moments when parts happen to line up in
|
|
21
|
+
* phase, which sum far above the incoherent average. Just under full scale
|
|
22
|
+
* rather than at it, so the limiter, and not the sound card, decides the
|
|
23
|
+
* ceiling.
|
|
24
|
+
*/
|
|
25
|
+
export const LIMITER_CEILING_DB = -1;
|
|
26
|
+
/**
|
|
27
|
+
* How far to pull the master down for `trackCount` channels summing into it.
|
|
28
|
+
*
|
|
29
|
+
* Every channel is scheduled at its own MIDI volume with nothing reconciling
|
|
30
|
+
* them, so the mix got louder with every track added. A nine-track arrangement
|
|
31
|
+
* summed to roughly two and a half times full scale and clipped through most of
|
|
32
|
+
* the piece; what that distortion buried first were the quiet, sustained
|
|
33
|
+
* voices, which is why tracks that were neither muted nor silent could not be
|
|
34
|
+
* heard at all.
|
|
35
|
+
*
|
|
36
|
+
* `1/sqrt(n)`, not `1/n`: separate instrument parts are not phase-correlated,
|
|
37
|
+
* so their powers add rather than their amplitudes. `1/n` is the law for copies
|
|
38
|
+
* of one signal and would leave a large arrangement far too quiet. A single
|
|
39
|
+
* track keeps unity — it has nothing to sum with.
|
|
40
|
+
*/
|
|
41
|
+
export function headroomTrimFor(trackCount) {
|
|
42
|
+
return trackCount > 1 ? 1 / Math.sqrt(trackCount) : 1;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Attack and release for the offline limiter, in seconds, quoted against
|
|
46
|
+
* `LIMITER_REFERENCE_DB` of reduction.
|
|
47
|
+
*
|
|
48
|
+
* The same numbers `SynthHost` gives its `DynamicsCompressorNode`, so a peak
|
|
49
|
+
* ducks for about as long either way. Expressed as a slew rate rather than a
|
|
50
|
+
* filter coefficient, which is what makes the ceiling exact — see `limitPeaks`.
|
|
51
|
+
*/
|
|
52
|
+
const LIMITER_ATTACK_SECONDS = 0.003;
|
|
53
|
+
const LIMITER_RELEASE_SECONDS = 0.25;
|
|
54
|
+
/**
|
|
55
|
+
* The depth the attack and release times describe.
|
|
56
|
+
*
|
|
57
|
+
* A slew rate needs a distance to be a time. Twelve decibels is deep enough to
|
|
58
|
+
* cover the peaks a phase-aligned tutti produces over the headroom trim, and a
|
|
59
|
+
* shallower reduction simply completes proportionally sooner.
|
|
60
|
+
*/
|
|
61
|
+
const LIMITER_REFERENCE_DB = 12;
|
|
62
|
+
/**
|
|
63
|
+
* Holds a rendered buffer under `LIMITER_CEILING_DB`, in place.
|
|
64
|
+
*
|
|
65
|
+
* The offline counterpart of the `DynamicsCompressorNode` on the live master
|
|
66
|
+
* bus, and it exists for the same reason: the headroom trim sizes the mix for
|
|
67
|
+
* how loud it is *on average*, and the moments where parts happen to line up in
|
|
68
|
+
* phase sum far above that. Live those peaks meet a limiter; in an export they
|
|
69
|
+
* meet `encodeWav`/`encodeMp3`, which clamp — so without this the loudest
|
|
70
|
+
* instants of a file are hard-clipped rather than ducked.
|
|
71
|
+
*
|
|
72
|
+
* It is **not** a reimplementation of that node. `DynamicsCompressorNode` has a
|
|
73
|
+
* knee, a ratio and program-dependent behaviour the specification does not pin
|
|
74
|
+
* down closely enough to reproduce, and chasing it would be pretending to a
|
|
75
|
+
* precision that is not there. What is reproduced is the job: nothing leaves
|
|
76
|
+
* here above the ceiling, and it gets there by ducking rather than clipping.
|
|
77
|
+
*
|
|
78
|
+
* The gain is worked out **backwards**, which is the one thing an offline
|
|
79
|
+
* limiter can do that a live one cannot. Each sample's gain is the lower of
|
|
80
|
+
* what that sample needs and what the next sample allows plus one attack step,
|
|
81
|
+
* so the ramp down is *finished* when the peak lands instead of chasing it.
|
|
82
|
+
* That ordering is also what makes the ceiling exact rather than approximate: a
|
|
83
|
+
* forward one-pole never quite arrives, and clamping the residue turns the last
|
|
84
|
+
* fraction of a decibel back into the clipping this replaces.
|
|
85
|
+
*
|
|
86
|
+
* The forward pass is the release, and the reason a sustained loud passage does
|
|
87
|
+
* not pump: within one cycle the requirement relaxes at every zero crossing,
|
|
88
|
+
* and the bounded climb simply does not follow it.
|
|
89
|
+
*
|
|
90
|
+
* Costs one `Float32Array` the length of the render — 53MB for five minutes at
|
|
91
|
+
* 44.1kHz, freed on return, against a `samples` buffer of the same size that
|
|
92
|
+
* the caller is already holding.
|
|
93
|
+
*/
|
|
94
|
+
export function limitPeaks(samples, sampleRate) {
|
|
95
|
+
const attackSlope = LIMITER_REFERENCE_DB / Math.max(1, LIMITER_ATTACK_SECONDS * sampleRate);
|
|
96
|
+
const releaseSlope = LIMITER_REFERENCE_DB / Math.max(1, LIMITER_RELEASE_SECONDS * sampleRate);
|
|
97
|
+
// Gain in dB, never above zero: a limiter only ever turns things down.
|
|
98
|
+
const envelope = new Float32Array(samples.length);
|
|
99
|
+
let carried = 0;
|
|
100
|
+
for (let i = samples.length - 1; i >= 0; i -= 1) {
|
|
101
|
+
const magnitude = Math.abs(samples[i]);
|
|
102
|
+
const required = magnitude > 0
|
|
103
|
+
? Math.min(0, LIMITER_CEILING_DB - 20 * Math.log10(magnitude))
|
|
104
|
+
: 0;
|
|
105
|
+
carried = Math.min(required, carried + attackSlope);
|
|
106
|
+
envelope[i] = carried;
|
|
107
|
+
}
|
|
108
|
+
let level = envelope[0] ?? 0;
|
|
109
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
110
|
+
level = Math.min(envelope[i], level + releaseSlope);
|
|
111
|
+
// Exactly 1 where nothing needed doing, so an untouched mix stays bit-identical.
|
|
112
|
+
samples[i] = level === 0 ? samples[i] : samples[i] * 10 ** (level / 20);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=mix.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mix.js","sourceRoot":"","sources":["../../src/shared/mix.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,CAAC;AAErC;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,eAAe,CAAC,UAAkB;IAChD,OAAO,UAAU,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACxD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,sBAAsB,GAAG,KAAK,CAAC;AACrC,MAAM,uBAAuB,GAAG,IAAI,CAAC;AAErC;;;;;;GAMG;AACH,MAAM,oBAAoB,GAAG,EAAE,CAAC;AAEhC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,UAAU,UAAU,CAAC,OAAqB,EAAE,UAAkB;IAClE,MAAM,WAAW,GACf,oBAAoB,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,sBAAsB,GAAG,UAAU,CAAC,CAAC;IAC1E,MAAM,YAAY,GAChB,oBAAoB,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,uBAAuB,GAAG,UAAU,CAAC,CAAC;IAE3E,uEAAuE;IACvE,MAAM,QAAQ,GAAG,IAAI,YAAY,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAClD,IAAI,OAAO,GAAG,CAAC,CAAC;IAChB,KAAK,IAAI,CAAC,GAAG,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAChD,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAE,CAAC,CAAC;QACxC,MAAM,QAAQ,GACZ,SAAS,GAAG,CAAC;YACX,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,kBAAkB,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;YAC9D,CAAC,CAAC,CAAC,CAAC;QACR,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,OAAO,GAAG,WAAW,CAAC,CAAC;QACpD,QAAQ,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC;IACxB,CAAC;IAED,IAAI,KAAK,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAC7B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3C,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAE,EAAE,KAAK,GAAG,YAAY,CAAC,CAAC;QACrD,iFAAiF;QACjF,OAAO,CAAC,CAAC,CAAC,GAAG,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAE,GAAG,EAAE,IAAI,CAAC,KAAK,GAAG,EAAE,CAAC,CAAC;IAC5E,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notes of a score, in time order, with a cursor.
|
|
3
|
+
*
|
|
4
|
+
* Replaces scheduling every note up front. The old engine handed the whole
|
|
5
|
+
* piece to Tone's Transport as individual events — some 11,500 of them for a
|
|
6
|
+
* three-minute arrangement — and every seek, edit or tempo change cancelled
|
|
7
|
+
* and rebuilt all of it. Here a seek is a binary search and a cursor move,
|
|
8
|
+
* whatever the length of the piece.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately knows nothing about time in seconds: ticks are the score's own
|
|
11
|
+
* unit, and converting them depends on the tempo map and the playback-speed
|
|
12
|
+
* multiplier, both of which belong to the engine.
|
|
13
|
+
*/
|
|
14
|
+
import type { PlaybackNote as ScheduledNote } from '@sudobility/music_types';
|
|
15
|
+
export declare class NoteQueue {
|
|
16
|
+
private notes;
|
|
17
|
+
private cursor;
|
|
18
|
+
load(notes: readonly ScheduledNote[]): void;
|
|
19
|
+
/**
|
|
20
|
+
* Moves the cursor to the first note at or after `tick`.
|
|
21
|
+
*
|
|
22
|
+
* Binary search rather than a scan: this runs on every seek, and a scan
|
|
23
|
+
* would make seeking cost more the further into the piece you go.
|
|
24
|
+
*/
|
|
25
|
+
seekToTick(tick: number): void;
|
|
26
|
+
/**
|
|
27
|
+
* Every not-yet-returned note starting at or before `tick`, up to `maxCount`
|
|
28
|
+
* of them, advancing the cursor past what it returns.
|
|
29
|
+
*
|
|
30
|
+
* The cap bounds one pump tick's cost. On the web engine each note becomes a
|
|
31
|
+
* `postMessage` to the worklet sequencer, and a four-second horizon over a
|
|
32
|
+
* two-hundred-track score is thousands of them; whatever the cap leaves
|
|
33
|
+
* behind is picked up 50ms later, long before it is due.
|
|
34
|
+
*
|
|
35
|
+
* Unbounded by default, because the React Native sample engine drains its
|
|
36
|
+
* whole horizon in one go — it posts nothing across a thread boundary, so it
|
|
37
|
+
* has nothing to bound.
|
|
38
|
+
*/
|
|
39
|
+
drainUntil(tick: number, maxCount?: number): ScheduledNote[];
|
|
40
|
+
/** True once every note has been drained — how the engine knows the piece is over. */
|
|
41
|
+
get exhausted(): boolean;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=note-queue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"note-queue.d.ts","sourceRoot":"","sources":["../../src/shared/note-queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,YAAY,IAAI,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAE7E,qBAAa,SAAS;IACpB,OAAO,CAAC,KAAK,CAAuB;IACpC,OAAO,CAAC,MAAM,CAAK;IAEnB,IAAI,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK3C;;;;;OAKG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAW9B;;;;;;;;;;;;OAYG;IACH,UAAU,CACR,IAAI,EAAE,MAAM,EACZ,QAAQ,SAA0B,GACjC,aAAa,EAAE;IAalB,sFAAsF;IACtF,IAAI,SAAS,IAAI,OAAO,CAEvB;CACF"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export class NoteQueue {
|
|
2
|
+
notes = [];
|
|
3
|
+
cursor = 0;
|
|
4
|
+
load(notes) {
|
|
5
|
+
this.notes = [...notes].sort((a, b) => a.tick - b.tick);
|
|
6
|
+
this.cursor = 0;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Moves the cursor to the first note at or after `tick`.
|
|
10
|
+
*
|
|
11
|
+
* Binary search rather than a scan: this runs on every seek, and a scan
|
|
12
|
+
* would make seeking cost more the further into the piece you go.
|
|
13
|
+
*/
|
|
14
|
+
seekToTick(tick) {
|
|
15
|
+
let lo = 0;
|
|
16
|
+
let hi = this.notes.length;
|
|
17
|
+
while (lo < hi) {
|
|
18
|
+
const mid = (lo + hi) >> 1;
|
|
19
|
+
if (this.notes[mid].tick < tick)
|
|
20
|
+
lo = mid + 1;
|
|
21
|
+
else
|
|
22
|
+
hi = mid;
|
|
23
|
+
}
|
|
24
|
+
this.cursor = lo;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Every not-yet-returned note starting at or before `tick`, up to `maxCount`
|
|
28
|
+
* of them, advancing the cursor past what it returns.
|
|
29
|
+
*
|
|
30
|
+
* The cap bounds one pump tick's cost. On the web engine each note becomes a
|
|
31
|
+
* `postMessage` to the worklet sequencer, and a four-second horizon over a
|
|
32
|
+
* two-hundred-track score is thousands of them; whatever the cap leaves
|
|
33
|
+
* behind is picked up 50ms later, long before it is due.
|
|
34
|
+
*
|
|
35
|
+
* Unbounded by default, because the React Native sample engine drains its
|
|
36
|
+
* whole horizon in one go — it posts nothing across a thread boundary, so it
|
|
37
|
+
* has nothing to bound.
|
|
38
|
+
*/
|
|
39
|
+
drainUntil(tick, maxCount = Number.MAX_SAFE_INTEGER) {
|
|
40
|
+
const out = [];
|
|
41
|
+
while (out.length < maxCount &&
|
|
42
|
+
this.cursor < this.notes.length &&
|
|
43
|
+
this.notes[this.cursor].tick <= tick) {
|
|
44
|
+
out.push(this.notes[this.cursor]);
|
|
45
|
+
this.cursor += 1;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/** True once every note has been drained — how the engine knows the piece is over. */
|
|
50
|
+
get exhausted() {
|
|
51
|
+
return this.cursor >= this.notes.length;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=note-queue.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"note-queue.js","sourceRoot":"","sources":["../../src/shared/note-queue.ts"],"names":[],"mappings":"AAeA,MAAM,OAAO,SAAS;IACZ,KAAK,GAAoB,EAAE,CAAC;IAC5B,MAAM,GAAG,CAAC,CAAC;IAEnB,IAAI,CAAC,KAA+B;QAClC,IAAI,CAAC,KAAK,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC;QACxD,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;IAClB,CAAC;IAED;;;;;OAKG;IACH,UAAU,CAAC,IAAY;QACrB,IAAI,EAAE,GAAG,CAAC,CAAC;QACX,IAAI,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC;QAC3B,OAAO,EAAE,GAAG,EAAE,EAAE,CAAC;YACf,MAAM,GAAG,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;YAC3B,IAAI,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,GAAG,IAAI;gBAAE,EAAE,GAAG,GAAG,GAAG,CAAC,CAAC;;gBACzC,EAAE,GAAG,GAAG,CAAC;QAChB,CAAC;QACD,IAAI,CAAC,MAAM,GAAG,EAAE,CAAC;IACnB,CAAC;IAED;;;;;;;;;;;;OAYG;IACH,UAAU,CACR,IAAY,EACZ,QAAQ,GAAG,MAAM,CAAC,gBAAgB;QAElC,MAAM,GAAG,GAAoB,EAAE,CAAC;QAChC,OACE,GAAG,CAAC,MAAM,GAAG,QAAQ;YACrB,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM;YAC/B,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,IAAI,IAAI,EACpC,CAAC;YACD,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;YAClC,IAAI,CAAC,MAAM,IAAI,CAAC,CAAC;QACnB,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IAED,sFAAsF;IACtF,IAAI,SAAS;QACX,OAAO,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC;IAC1C,CAAC;CACF"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything live playback needs, decided here rather than in the engine.
|
|
3
|
+
*
|
|
4
|
+
* The live counterpart of `renderEvents`, and the reason music_io needs no
|
|
5
|
+
* musical code at all: ties are joined, pitches resolved, the measure grid's
|
|
6
|
+
* beats read off, the GM voice resolved and the tempo handed over as a
|
|
7
|
+
* conversion. The engine schedules and sounds; it decides nothing.
|
|
8
|
+
*
|
|
9
|
+
* This is `schedule.ts` from music_io, moved to where its imports already
|
|
10
|
+
* lived. The note traversal itself is `flattenScoreNotes`, shared with
|
|
11
|
+
* `renderEvents` so live and offline cannot drift over ties again.
|
|
12
|
+
*/
|
|
13
|
+
import type { PlaybackPlan, PlaybackTrack, Score } from '@sudobility/music_types';
|
|
14
|
+
import type { AuditionVoice } from '../engine.js';
|
|
15
|
+
/**
|
|
16
|
+
* The GM voice a program addresses.
|
|
17
|
+
*
|
|
18
|
+
* The one place the kit-versus-instrument distinction is resolved, shared by
|
|
19
|
+
* the plan and by auditioning. A percussion track's `midiProgram` is a *kit*
|
|
20
|
+
* address — `gmKitAt` maps any address to the kit whose region contains it, so
|
|
21
|
+
* a score that arrives at a program GM defines no kit at still plays — and the
|
|
22
|
+
* name must be the kit's, because `gmInstrument(40)` is Violin where kit 40 is
|
|
23
|
+
* Brush.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveVoice(program: number, isPercussion: boolean, fallbackName?: string): AuditionVoice;
|
|
26
|
+
/**
|
|
27
|
+
* The tracks alone.
|
|
28
|
+
*
|
|
29
|
+
* Separate from `playbackPlan` because a mix change while playing must not
|
|
30
|
+
* rebuild every note — `PlaybackEngine.applyMix` takes only this.
|
|
31
|
+
*/
|
|
32
|
+
export declare function playbackTracks(score: Score): PlaybackTrack[];
|
|
33
|
+
export declare function playbackPlan(score: Score): PlaybackPlan;
|
|
34
|
+
//# sourceMappingURL=plan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan.d.ts","sourceRoot":"","sources":["../../src/shared/plan.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAEV,YAAY,EACZ,aAAa,EACb,KAAK,EACN,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAalD;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,OAAO,EACrB,YAAY,SAAK,GAChB,aAAa,CAUf;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,KAAK,GAAG,aAAa,EAAE,CAqB5D;AAqDD,wBAAgB,YAAY,CAAC,KAAK,EAAE,KAAK,GAAG,YAAY,CAgDvD"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { TempoMap } from '@sudobility/music_types';
|
|
2
|
+
import { performanceTimeline, } from '@sudobility/music_types';
|
|
3
|
+
import { fermataTempoMap } from '@sudobility/music_types';
|
|
4
|
+
import { flattenScoreNotes } from '@sudobility/music_types';
|
|
5
|
+
import { beatBoundaries } from '@sudobility/music_types';
|
|
6
|
+
import { gmInstrument } from '@sudobility/music_types';
|
|
7
|
+
import { gmKitAt } from '@sudobility/music_types';
|
|
8
|
+
import { isPercussionTrack } from '@sudobility/music_types';
|
|
9
|
+
/**
|
|
10
|
+
* The GM voice a program addresses.
|
|
11
|
+
*
|
|
12
|
+
* The one place the kit-versus-instrument distinction is resolved, shared by
|
|
13
|
+
* the plan and by auditioning. A percussion track's `midiProgram` is a *kit*
|
|
14
|
+
* address — `gmKitAt` maps any address to the kit whose region contains it, so
|
|
15
|
+
* a score that arrives at a program GM defines no kit at still plays — and the
|
|
16
|
+
* name must be the kit's, because `gmInstrument(40)` is Violin where kit 40 is
|
|
17
|
+
* Brush.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveVoice(program, isPercussion, fallbackName = '') {
|
|
20
|
+
if (isPercussion) {
|
|
21
|
+
const kit = gmKitAt(program);
|
|
22
|
+
return { program: kit.program, name: kit.name, isPercussion: true };
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
program,
|
|
26
|
+
name: gmInstrument(program)?.name ?? fallbackName,
|
|
27
|
+
isPercussion: false,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The tracks alone.
|
|
32
|
+
*
|
|
33
|
+
* Separate from `playbackPlan` because a mix change while playing must not
|
|
34
|
+
* rebuild every note — `PlaybackEngine.applyMix` takes only this.
|
|
35
|
+
*/
|
|
36
|
+
export function playbackTracks(score) {
|
|
37
|
+
return score.tracks.map(track => {
|
|
38
|
+
const percussion = isPercussionTrack(track);
|
|
39
|
+
const voice = resolveVoice(track.midiProgram, percussion, track.instrumentName);
|
|
40
|
+
return {
|
|
41
|
+
id: track.id,
|
|
42
|
+
midiProgram: track.midiProgram,
|
|
43
|
+
instrumentName: track.instrumentName,
|
|
44
|
+
isPercussion: percussion,
|
|
45
|
+
volume: track.volume,
|
|
46
|
+
pan: track.pan,
|
|
47
|
+
muted: track.muted,
|
|
48
|
+
solo: track.solo,
|
|
49
|
+
voiceProgram: voice.program,
|
|
50
|
+
voiceName: voice.name,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Every beat position across the measure grid, read off the first track —
|
|
56
|
+
* every track shares one grid once `rebuildMeasureTicks` has run.
|
|
57
|
+
*/
|
|
58
|
+
function metronomeClicks(score) {
|
|
59
|
+
const track = score.tracks[0];
|
|
60
|
+
if (!track)
|
|
61
|
+
return [];
|
|
62
|
+
const clicks = [];
|
|
63
|
+
for (const measure of track.measures) {
|
|
64
|
+
beatBoundaries(measure.timeSignature, score.ppq).forEach((offset, i) => {
|
|
65
|
+
clicks.push({ tick: measure.startTick + offset, accent: i === 0 });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return clicks;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Lays `events` out along the timeline, repeating what the repeats repeat.
|
|
72
|
+
*
|
|
73
|
+
* Each segment copies the written events inside its source range to its own
|
|
74
|
+
* place in performance time. A copy gets a distinct id — the caret lights a
|
|
75
|
+
* note by id, and the same written note sounding twice would otherwise light
|
|
76
|
+
* both times at once.
|
|
77
|
+
*
|
|
78
|
+
* The identity timeline copies everything exactly once at its own tick, so a
|
|
79
|
+
* score without repeats produces precisely what it did before.
|
|
80
|
+
*/
|
|
81
|
+
function expandAlongTimeline(events, timeline, withTick) {
|
|
82
|
+
if (timeline.segments.length === 0)
|
|
83
|
+
return events;
|
|
84
|
+
const expanded = [];
|
|
85
|
+
const passesBySource = new Map();
|
|
86
|
+
for (const segment of timeline.segments) {
|
|
87
|
+
const pass = (passesBySource.get(segment.sourceTick) ?? 0) + 1;
|
|
88
|
+
passesBySource.set(segment.sourceTick, pass);
|
|
89
|
+
const end = segment.sourceTick + segment.durationTicks;
|
|
90
|
+
for (const event of events) {
|
|
91
|
+
if (event.tick < segment.sourceTick || event.tick >= end)
|
|
92
|
+
continue;
|
|
93
|
+
const offset = event.tick - segment.sourceTick;
|
|
94
|
+
expanded.push(withTick(event, segment.performanceTick + offset, pass));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return expanded.sort((a, b) => a.tick - b.tick);
|
|
98
|
+
}
|
|
99
|
+
export function playbackPlan(score) {
|
|
100
|
+
const timeline = performanceTimeline(score);
|
|
101
|
+
/*
|
|
102
|
+
Notes are laid out in *performance* time, so a repeated bar genuinely
|
|
103
|
+
sounds twice. Everything that draws translates back through `timeline`
|
|
104
|
+
— see `sourceTickFor` — which is what keeps the score the canonical,
|
|
105
|
+
written thing rather than teaching the caret about repeats.
|
|
106
|
+
*/
|
|
107
|
+
const notes = expandAlongTimeline(flattenScoreNotes(score), timeline, (note, tick, pass) => ({
|
|
108
|
+
...note,
|
|
109
|
+
tick,
|
|
110
|
+
// Distinct per pass: the caret lights a note by id, and one written
|
|
111
|
+
// note sounding twice must not light both places at once.
|
|
112
|
+
noteId: pass === 1 ? note.noteId : `${note.noteId}#${pass}`,
|
|
113
|
+
}));
|
|
114
|
+
const clicks = expandAlongTimeline(metronomeClicks(score), timeline, (click, tick) => ({ ...click, tick }));
|
|
115
|
+
return {
|
|
116
|
+
tracks: playbackTracks(score),
|
|
117
|
+
notes,
|
|
118
|
+
clicks,
|
|
119
|
+
timeline,
|
|
120
|
+
/*
|
|
121
|
+
The tempo map with fermatas written into it as local slowings — see
|
|
122
|
+
`fermataTempoMap`. A pause is expressed as tempo rather than as longer
|
|
123
|
+
notes because the score tick has to stay the playback tick; the caret
|
|
124
|
+
builds its own map from the same function, so the two cannot disagree
|
|
125
|
+
about how long a hold lasts.
|
|
126
|
+
|
|
127
|
+
`TempoMap` satisfies `TempoConversion` structurally, so nothing converts
|
|
128
|
+
twice.
|
|
129
|
+
*/
|
|
130
|
+
tempo: new TempoMap(fermataTempoMap(score), score.ppq),
|
|
131
|
+
durationTicks: Math.max(timeline.durationTicks, notes.reduce((n, note) => Math.max(n, note.tick + note.durTicks), 0)),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=plan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan.js","sourceRoot":"","sources":["../../src/shared/plan.ts"],"names":[],"mappings":"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"}
|