@sudobility/music_player 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +62 -0
- package/dist/shared/bus.d.ts.map +1 -0
- package/dist/shared/bus.js +73 -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 +716 -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
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# music_player
|
|
2
|
+
|
|
3
|
+
> **Git policy — never auto-commit or auto-push.** Leave your work in the working tree.
|
|
4
|
+
> Run `git commit`, `git push` or `npm publish` **only when the user explicitly asks in
|
|
5
|
+
> that turn**. Approval for an earlier change does not carry forward, and finishing a
|
|
6
|
+
> task is not permission to commit it.
|
|
7
|
+
|
|
8
|
+
Everything that makes sound: the transport, the two synth engines, plan
|
|
9
|
+
building, and offline rendering. One of seven repos in the Moosiac family — see
|
|
10
|
+
`music_app/docs/architecture.md`.
|
|
11
|
+
|
|
12
|
+
**The rule: this package makes sound. `music_io` moves bytes. `music_codecs`
|
|
13
|
+
reads and writes score formats. `music_lib` edits.** It takes a `Score` and
|
|
14
|
+
produces audio; it knows nothing about editing, files or the store.
|
|
15
|
+
|
|
16
|
+
Its own package because playback was previously spread across three: the engines
|
|
17
|
+
in `music_io`, the transport brain in `music_lib` where it read the Zustand
|
|
18
|
+
store, and the plan builders in `music_lib` too. Nothing owned playback; three
|
|
19
|
+
packages owned a third of it each.
|
|
20
|
+
|
|
21
|
+
## Structure
|
|
22
|
+
|
|
23
|
+
- `src/types.ts` — `IMusicPlayer`, the transport interface
|
|
24
|
+
- `src/engine.ts` — the engine contract (`PlaybackEngine`, `PlaybackObserver`, `AuditionVoice`)
|
|
25
|
+
- `src/player.ts` — `MusicPlayer`, the transport; was `PlaybackController`, minus its store
|
|
26
|
+
- `src/singleton.ts` — `initializeMusicPlayer` / `getMusicPlayer` / `resetMusicPlayer`
|
|
27
|
+
- `src/core.ts` — the **platform-free** entry: interface, singleton, engine contract, plan builders
|
|
28
|
+
- `src/shared/` — `plan.ts`, `render-events.ts`, `bus.ts`, and the scheduling primitives both engines use
|
|
29
|
+
- `src/web/` — libfluidsynth (WebAssembly) in an `AudioWorklet`
|
|
30
|
+
- `src/rn/` — a sample-based FluidR3 engine
|
|
31
|
+
- `src/mocks/` — `MockMusicPlayer`, for downstream tests
|
|
32
|
+
|
|
33
|
+
## Package boundaries
|
|
34
|
+
|
|
35
|
+
`src/contract/no-domain-imports.test.ts` enforces: no `music_lib`, no
|
|
36
|
+
`music_io`, no `vexflow`, no runtime dependency. `music_types` is the only
|
|
37
|
+
required peer; the two engine libraries are **optional** peers, so a web app
|
|
38
|
+
installs `js-synthesizer` and a React Native app installs
|
|
39
|
+
`react-native-audio-api` without either carrying the other's.
|
|
40
|
+
|
|
41
|
+
## Gotchas
|
|
42
|
+
|
|
43
|
+
- **`IMusicPlayer.load()` takes a `Score`; the engine still takes a plan.** This
|
|
44
|
+
package owns `playbackPlan`, so making the caller build one first would be a
|
|
45
|
+
two-step dance whose second step is here. Underneath, `PlaybackEngine.load()`
|
|
46
|
+
still takes a `PlaybackPlan` and does no score maths — which is where
|
|
47
|
+
`music_io`'s "handed a plan, never a score" rule was always actually about.
|
|
48
|
+
- **The player has no caret, and that is deliberate.** `togglePlay`,
|
|
49
|
+
`seekToMeasure`, `goToStart`, `previousMeasure`, `nextMeasure` and
|
|
50
|
+
loop-from-selection are **not** here: all of them read or write the caret or
|
|
51
|
+
the selection, which is editing state. A copy of either here would be a second
|
|
52
|
+
thing that can disagree with the first — the exact bug `MusicPosition` exists
|
|
53
|
+
to prevent. They live in music_lib's playback adapter and compose the
|
|
54
|
+
primitives this exposes.
|
|
55
|
+
- **`music_lib` imports this package only through `/core`.** The root export
|
|
56
|
+
resolves to the web entry (or the RN one under Metro), both of which construct
|
|
57
|
+
a synth. `/core` is the platform-free half. A bare root import from music_lib
|
|
58
|
+
would put an `AudioWorklet` in a package whose whole point is that it has none
|
|
59
|
+
— its own guard test asserts this, and it was caught that way.
|
|
60
|
+
- **The plan model stays in `music_types`; the engine contract lives here.**
|
|
61
|
+
`PlaybackPlan` composes `PerformanceTimeline`, which `performanceTimeline()`
|
|
62
|
+
in music_types produces, so the whole plan cluster is anchored there — moving
|
|
63
|
+
it would make music_types import from this package. The engine contract moved
|
|
64
|
+
because this package is the only thing that implements *or* calls it.
|
|
65
|
+
- **`load()` while playing is a mix change and nothing else.** The note queue is
|
|
66
|
+
kept. The host's edit lock is what makes that safe: while the transport runs,
|
|
67
|
+
the only score change that can reach here is a mix change. A score arriving
|
|
68
|
+
from outside that path — a generation result, an opened snapshot — **must be
|
|
69
|
+
preceded by `stop()`**, or it will be heard as the old score.
|
|
70
|
+
- **`load()` and `play()` reject; they do not swallow.** The host translates: the
|
|
71
|
+
message a user sees is localized and this package carries no copy. An earlier
|
|
72
|
+
draft took an `onError` callback and stranded music_lib's translated string.
|
|
73
|
+
- **The bus is exposed, not wrapped.** `IMusicPlayer.bus` is the real
|
|
74
|
+
`PlaybackBus`, so a host binds React to the same object the engine publishes
|
|
75
|
+
into. A delegating view was tried and is wrong: two objects answering "what is
|
|
76
|
+
sounding" is the disagreement the single source of truth exists to remove.
|
|
77
|
+
- **`bus.onPosition` and `IMusicPosition.tick` are not duplicates.** The channel
|
|
78
|
+
is a *change signal*; the authoritative value is dead-reckoned forward between
|
|
79
|
+
reports so the caret moves smoothly. A subscriber taking the tick from the
|
|
80
|
+
event gets the unsmoothed number.
|
|
81
|
+
- **Synth load progress is not a bus channel.** It reports per percent and
|
|
82
|
+
behaves like ordinary React state, so it goes to the host's store via
|
|
83
|
+
`onLoadState`.
|
|
84
|
+
- **Consumers must exclude this package from Vite's dep pre-bundling.** The web
|
|
85
|
+
entry lazily `import('js-synthesizer')`, and esbuild's pre-bundler resolves it
|
|
86
|
+
eagerly into a chunk whose worklet asset URLs no longer point where the app
|
|
87
|
+
serves them. The symptom is a blank page with no console error and a
|
|
88
|
+
*production build that works fine*; music_app's `vite.config.ts` lists it
|
|
89
|
+
beside music_lib and music_io.
|
|
90
|
+
|
|
91
|
+
- **Platform *engines* are optional peers; `@breezystack/lamejs` is the one
|
|
92
|
+
exception.** A React Native app must never pull a browser audio library into
|
|
93
|
+
its graph, which is why `js-synthesizer` — libfluidsynth compiled to WASM, for
|
|
94
|
+
an AudioWorklet — is an optional peer that music_app declares itself. lamejs
|
|
95
|
+
stays a runtime `dependency` for two concrete reasons: `web-audio-codec.ts`
|
|
96
|
+
imports it *statically*, so an app that skipped installing it would fail to
|
|
97
|
+
load the entire web entry rather than just mp3 export; and `AudioCodec.encodeMp3`
|
|
98
|
+
is synchronous in `music_types`, so it cannot be made lazy the way
|
|
99
|
+
`js-synthesizer` is without changing that interface across four repos. It is
|
|
100
|
+
pure JS and runs under Metro — and now that `encodeMp3` is shared, the RN
|
|
101
|
+
entry imports it too, which settles the question: it is not a platform
|
|
102
|
+
library at all.
|
|
103
|
+
- **There is no RN audio, and that is a decision, not a gap in the port.** The
|
|
104
|
+
web engine is libfluidsynth compiled to WASM running in an `AudioWorklet`;
|
|
105
|
+
React Native has no AudioWorklet, so there is nothing to port — an RN engine
|
|
106
|
+
means a different implementation of the same `PlaybackEngine` interface (a
|
|
107
|
+
native synth module, or `react-native-audio-api` plus a soundfont player).
|
|
108
|
+
What was there before subclassed the Tone engine over
|
|
109
|
+
`react-native-audio-api` and synthesised its own approximations of the
|
|
110
|
+
instruments, so it could never match what the web plays; a wrong sound is
|
|
111
|
+
worse than a clear error. `unavailablePlayback()` now throws from anything
|
|
112
|
+
that would make a sound and stays a silent no-op for the lifecycle methods, so
|
|
113
|
+
a shared component calling `pause()` on unmount does not crash the app. The
|
|
114
|
+
historical spikes are in `spikes/tone-on-rn-audio-api.md`.
|
|
115
|
+
- **The two platforms play the same font by different means, and that is the
|
|
116
|
+
design.** Web runs libfluidsynth (WebAssembly) in an `AudioWorklet`; React
|
|
117
|
+
Native has neither — `react-native-audio-api` exposes no `addModule` and
|
|
118
|
+
Hermes has no WebAssembly — so it plays **FluidR3 pre-rendered per note**
|
|
119
|
+
instead, from Benjamin Gleitzman's packs, generated from the same
|
|
120
|
+
`FluidR3_GM.sf2` the web plays as `FluidR3Mono_GM.sf3`. Every key of all 128
|
|
121
|
+
melodic programs is a real recording, so nothing is pitch-shifted in range.
|
|
122
|
+
What is lost is SF2's live per-zone filters, LFOs and modulator envelopes —
|
|
123
|
+
expressive variation, not the instrument's identity. The engine that was
|
|
124
|
+
deleted synthesised oscillator approximations, which is a different and much
|
|
125
|
+
worse thing. See `spikes/rn-sample-playback.md`.
|
|
126
|
+
- **`applyMix(score)` exists because mute and solo had setters and volume and pan did not.** A track's volume was read once at `loadScore` and pan only in `applyScoreToHost`, so once `music_lib`'s edit lock made a mix change stop reloading the score, moving a fader mid-playback moved the fader and not the sound. The web engine pushes CC7 and CC10 from the score and schedules nothing. **The RN engine reaches the same place with a node instead of a control change:** one gain-and-panner strip per track, built on first use, with voices connecting to their track's strip rather than to the master. Per-track rather than per-voice — the offline renderer builds a panner per voice because it schedules once and never changes its mind, where a live fader has to move under notes that are already sounding. That also settles where the headroom trim lives on RN: on the master, as it is on web, not multiplied into every voice's gain. **The metronome click bypasses the master on both platforms** so a score with more parts does not get a quieter click.
|
|
127
|
+
- **The transport is shared; only voicing is per platform.** `shared/playback/`
|
|
128
|
+
holds score flattening, the note cursor, the sounding set, the dispatch window
|
|
129
|
+
and the master trim. `governor.ts` and `channel-allocator.ts`
|
|
130
|
+
stayed in `web/` despite importing nothing — they are fluidsynth-specific in
|
|
131
|
+
meaning (interpolation orders; channels per synth instance) — and
|
|
132
|
+
`soundfont-loader.ts` stayed because it uses the browser `caches` global.
|
|
133
|
+
Import-free is not the same as platform-free.
|
|
134
|
+
- **`pump-window.ts` has one consumer left, and it is the RN engine.** The web
|
|
135
|
+
engine hands a four-second horizon of self-releasing notes to the fluidsynth
|
|
136
|
+
sequencer, so nothing there is ever dispatched late enough to need skipping.
|
|
137
|
+
RN has no sequencer to hand a horizon to and keeps the lookahead-and-grace
|
|
138
|
+
model, which is also why `NoteQueue.drainUntil`'s cap is optional: the web
|
|
139
|
+
engine passes one because every note it schedules is a `postMessage`, and RN
|
|
140
|
+
posts nothing across a thread boundary so it has nothing to bound.
|
|
141
|
+
- **One synth instance addresses 256 channels, not 16.** `midiChannelCount`
|
|
142
|
+
takes 16-256 in multiples of 16, so every score up to 240 pitched tracks runs
|
|
143
|
+
on one instance with one copy of the 23MB font. A second instance — and a
|
|
144
|
+
second font copy — now opens past 240 tracks rather than past 15. Channels
|
|
145
|
+
where `c % 16 === 9` are reserved for percussion: only literal channel 9 is
|
|
146
|
+
documented as drum-typed by default, and whether fluidsynth extends that past
|
|
147
|
+
its first block cannot be determined without a real synth, so pitched tracks
|
|
148
|
+
stay off all of them. **`needsDrumTypeSwitch` must be false for a pitched
|
|
149
|
+
track** — `soundfont-render.ts` branches on
|
|
150
|
+
`isPercussion || needsDrumTypeSwitch`, so a pitched track carrying it exports
|
|
151
|
+
as percussion. That shipped for one commit.
|
|
152
|
+
- **Polyphony is init-time only.** `ISynthesizer` has `setInterpolation` and
|
|
153
|
+
`setGain` and no polyphony setter, so the ceiling (2048) is chosen once in
|
|
154
|
+
`synth-host.ts` and fluidsynth's overflow priority steals above it. Anything
|
|
155
|
+
proposing to adapt polyphony at runtime is proposing to re-init the synth,
|
|
156
|
+
which means reloading the font.
|
|
157
|
+
- **The offline renderer shares `allocateChannels` with playback.** Change the
|
|
158
|
+
channel count and `audio/offline-synth.ts` must move in the same commit, or an
|
|
159
|
+
export addresses channels its synth does not have and silently loses every
|
|
160
|
+
track past the sixteenth.
|
|
161
|
+
- **Timing lives in the worklet.** The pump tops up a rolling horizon
|
|
162
|
+
(`HORIZON_SECONDS`, 512 events per tick) and each note is one sequencer event
|
|
163
|
+
carrying its own duration, so fluidsynth releases it on the audio thread.
|
|
164
|
+
There is no grace window and no skip-late rule on the web engine; a note whose
|
|
165
|
+
moment passed during a stall sounds at once rather than being dropped. The old
|
|
166
|
+
200ms window plus 200ms grace meant a stall over ~400ms silently lost every
|
|
167
|
+
note inside it, and a 200-track notation redraw measures 119ms. Do not
|
|
168
|
+
reintroduce a skip rule without re-reading that.
|
|
169
|
+
- **A four-second horizon means anything that changes the future has to take
|
|
170
|
+
the future back.** The three that do are `seek`/`stop` (already), a playback
|
|
171
|
+
**speed** change and the **loop** wrap. A speed change re-anchors the caret
|
|
172
|
+
instantly while every queued note still carries a delay computed at the old
|
|
173
|
+
speed, so without a re-seek the music kept the previous tempo for a whole
|
|
174
|
+
horizon and then jumped; `setTempoMultiplier` therefore re-seeks where it
|
|
175
|
+
stands when the pump is running, costing one 50ms gap. A loop wrap only
|
|
176
|
+
happens on the pump tick *after* the end is reached, so `horizonTick` clamps
|
|
177
|
+
the drain to `endTick - 1` — otherwise notes just past the end were already in
|
|
178
|
+
the sequencer and sounded before the loop came round, every pass. For the same
|
|
179
|
+
reason a loop never lets the transport stop: its range may run past the last
|
|
180
|
+
note. **Metronome clicks are the case that bites**, because they are real
|
|
181
|
+
oscillators on the graph and `allSoundOff` speaks only to the synth — a
|
|
182
|
+
scheduled click is unreachable from there. `scheduleClick` hands back a
|
|
183
|
+
`cancel`, the engine holds what is pending, and stop, seek, dispose and
|
|
184
|
+
switching the metronome off all take it back. Before that, pausing left the
|
|
185
|
+
room ticking for four seconds.
|
|
186
|
+
- **`headroomTrimFor` is in `shared/playback/mix.ts` so playback and export
|
|
187
|
+
cannot disagree, and for a while the web export simply did not call it.**
|
|
188
|
+
Every channel is scheduled at its own MIDI volume with nothing reconciling
|
|
189
|
+
them, so a rendered file came out `sqrt(n)` louder than what was heard and
|
|
190
|
+
the wav/mp3 encoders — which hard-clamp — turned that into clipping across
|
|
191
|
+
most of a large score. The RN renderer always applied it. `mix.ts`'s doc now
|
|
192
|
+
names all four call sites, so a fifth is noticed by its absence.
|
|
193
|
+
- **The export limiter is not the live one, and works backwards.** `limitPeaks`
|
|
194
|
+
is the offline half of the master bus, after the trim exactly as
|
|
195
|
+
`DynamicsCompressorNode` sits after the master gain live — the trim sizes the
|
|
196
|
+
mix for its *average* level and the phase-aligned moments still overshoot,
|
|
197
|
+
which in an export means the encoders' clamp rather than a sound card. It does
|
|
198
|
+
**not** reimplement that node: its knee, ratio and program-dependent behaviour
|
|
199
|
+
are not specified closely enough to reproduce, and the job — nothing leaves
|
|
200
|
+
above the ceiling, by ducking rather than clipping — is what is reproduced.
|
|
201
|
+
The gain envelope is computed **backwards** (each sample's gain is the lower
|
|
202
|
+
of what it needs and what the next allows plus one attack step) so the ramp is
|
|
203
|
+
finished when the peak lands, and so the ceiling is *exact*: a forward
|
|
204
|
+
one-pole never quite arrives, and clamping the residue is the clipping this
|
|
205
|
+
replaces. The forward pass is the release, and it is what stops a loud
|
|
206
|
+
sustained passage pumping once per cycle. RN's renderers have no limiter at
|
|
207
|
+
all, matching RN playback, which also has none.
|
|
208
|
+
- **A note-on at velocity 0 is a note-off.** The offline renderer always clamped
|
|
209
|
+
to 1..127; the live engine passed `PlaybackNote.velocity` through untouched,
|
|
210
|
+
so a plan carrying a fractional or zero velocity produced a note that silently
|
|
211
|
+
did not sound in playback and did sound in the export. `clampVelocity` in
|
|
212
|
+
`soundfont-engine.ts` closes that. Note the unit trap either side of it:
|
|
213
|
+
`PlaybackNote.velocity` is MIDI 0-127 and `RenderEvent.velocity` is 0..1.
|
|
214
|
+
- **`onActiveNotes` fires only on change** (`shared/playback/sounding-set.ts`).
|
|
215
|
+
It used to be a fresh array every pump tick whether or not a note had begun or
|
|
216
|
+
ended, waking notation colour and the keyboard's key lights twenty times a
|
|
217
|
+
second through a held chord. Anything that starts re-sending it per tick has
|
|
218
|
+
undone the reason that module exists.
|
|
219
|
+
- **The lit set comes off the clock, never off the voices — on both engines
|
|
220
|
+
now.** RN used to derive it from its scheduled voices, which are built a
|
|
221
|
+
lookahead ahead of the clock and torn down only after the instrument's
|
|
222
|
+
release: notation lit every note 200ms early and stayed lit through a half
|
|
223
|
+
second of decay after the written note was over. `SoundingSet` answers "what
|
|
224
|
+
is sounding *now*", which is the question notation is asking; a voice list
|
|
225
|
+
answers "what has been arranged for", which is a different one.
|
|
226
|
+
- **The RN engine deliberately does not reuse the web engine's pump.** Merging
|
|
227
|
+
the two transports behind a synth port is the obvious refactor and the wrong
|
|
228
|
+
order: the web engine is verified on real devices and the RN one cannot be
|
|
229
|
+
until it runs on a phone. Do that merge after RN audio is device-tested, not
|
|
230
|
+
before.
|
|
231
|
+
- **Pack names are not GM slugs.** Punctuation is *removed*, not turned into a
|
|
232
|
+
separator: `Honky-tonk Piano` is `honkytonk_piano` and `Lead 8 (bass + lead)`
|
|
233
|
+
keeps the double underscore where the `+` was. That covers 127 of 128; program
|
|
234
|
+
54 is a real naming disagreement (GM "Voice Oohs" vs. the catalogue's "Synth
|
|
235
|
+
Voice"). `gm-pack-name.test.ts` checks all 128 against a vendored copy of the
|
|
236
|
+
CDN manifest, because a wrong name is a 404, which parses to zero samples,
|
|
237
|
+
which is an instrument that plays nothing with no error anywhere.
|
|
238
|
+
- **Drum kits are rendered here, not fetched.** The pre-rendered FluidR3 packs
|
|
239
|
+
are the 128 *melodic* programs only, and no CDN publishes GM percussion under
|
|
240
|
+
a licence this project can use — WebAudioFont, the one that has it, is
|
|
241
|
+
GPL-3.0. So `scripts/build-percussion-packs.mjs` renders the eight GM kits out
|
|
242
|
+
of the same `FluidR3Mono_GM.sf3` the web engine plays, with `fluidsynth` and
|
|
243
|
+
`lame`, into the melodic packs' own format so one parser reads both. FluidR3
|
|
244
|
+
is CC-BY 3.0, so redistributing the renderings is allowed with attribution
|
|
245
|
+
(the script writes an `ATTRIBUTION.md` beside them). Output is ~1.1-1.7MB per
|
|
246
|
+
kit; it is a build-time tool, run when the font changes, not part of
|
|
247
|
+
`bun run build`.
|
|
248
|
+
- **The expression a recording cannot carry is measured back in**
|
|
249
|
+
(`playback/expression.ts`, built by `scripts/measure-expression.mjs`). The
|
|
250
|
+
packs are recordings taken at full velocity, so playing one softer is just the
|
|
251
|
+
loud recording turned down — where fluidsynth also darkens it, shapes its
|
|
252
|
+
release per instrument, and sustains it while held. Three fixes, all measured
|
|
253
|
+
from the same `.sf3` the web plays:
|
|
254
|
+
1. **Velocity is (v/127)², not v/127.** SF2's default velocity-to-attenuation
|
|
255
|
+
modulator is 960cB concave; fluidsynth's concave table
|
|
256
|
+
(`1 + (40/96)·log10(v/127)`) reduces exactly to a square. The linear curve
|
|
257
|
+
the engine had played velocity 32 at 0.252 against a true 0.063 — **12dB
|
|
258
|
+
too loud on every soft passage**, which flattens all dynamics.
|
|
259
|
+
2. **A per-instrument low-pass** whose corner falls with velocity: piano goes
|
|
260
|
+
~4989Hz at v96 to ~556Hz at v16, strings barely move, violin and flute not
|
|
261
|
+
at all. Only 57 of 128 instruments darken, and a node is built only when
|
|
262
|
+
the corner is below 16kHz.
|
|
263
|
+
3. **A per-instrument release**, median 0.56s measured, against the flat 80ms
|
|
264
|
+
the engine applied to everything — seven times too short, chopping every
|
|
265
|
+
released chord.
|
|
266
|
+
- **The expression table is measured, not extracted, and that is the
|
|
267
|
+
interesting part.** The first attempt parsed `initialFilterFc` straight out of
|
|
268
|
+
the `pdta` chunk (readable even in SF3 — only `sdta` is Vorbis-compressed). It
|
|
269
|
+
gave Grand Piano a **300Hz** low-pass, which is plainly wrong: the rendered
|
|
270
|
+
piano has energy out to 4.6kHz. The cause is layering — FluidR3's piano preset
|
|
271
|
+
points at two instruments at once and the recording is their sum, so no single
|
|
272
|
+
layer's generators describe it. Generators cannot be read off a layered
|
|
273
|
+
preset; the rendered sound can. The script now renders all 128 programs at five
|
|
274
|
+
velocities and finds where the quiet spectrum falls 3dB below the loud one,
|
|
275
|
+
**after normalising both to the fundamental** — skipping that normalisation
|
|
276
|
+
produced a table reading 281Hz for all 128, because a quiet note is quieter at
|
|
277
|
+
every frequency and the first band examined already looks 3dB down.
|
|
278
|
+
- **Generated assets are pinned to the soundfont by hash.** Both
|
|
279
|
+
`expression-table.ts` and the percussion packs are derived from
|
|
280
|
+
`FluidR3Mono_GM.sf3` and regenerated by nobody. Each records the font's
|
|
281
|
+
SHA-256, and music_app — which owns the font — asserts the match in
|
|
282
|
+
`src/config/generated-assets.test.ts`. Without it a swapped font leaves RN
|
|
283
|
+
playing kits and velocity curves from the old one, silently, presenting as
|
|
284
|
+
"RN sounds subtly unlike web". The hash is read through
|
|
285
|
+
`@sudobility/music_io/rn/expression-table`, a dependency-free subpath, because
|
|
286
|
+
reaching it via the RN entry drags in optional peers a web app does not install.
|
|
287
|
+
- **Notes longer than 3.13 seconds used to go silent.** Every pack entry is
|
|
288
|
+
exactly that long, whatever the instrument, so a whole note at 60bpm or a tied
|
|
289
|
+
pad simply stopped sounding partway through with the gain envelope still
|
|
290
|
+
holding a level over nothing. `sustain-loop.ts` loops a window inside the
|
|
291
|
+
steady portion **spanning a whole number of periods of the sampled note** —
|
|
292
|
+
which is known, since it is the pack entry being played, so no pitch detection
|
|
293
|
+
is needed. Zero-crossing alignment alone was not enough: it removes the step
|
|
294
|
+
discontinuity but not the *phase* one, so an arbitrary-length window ends
|
|
295
|
+
part-way through a cycle and every harmonic restarts out of phase, audible as
|
|
296
|
+
a buzz once a second on a held note. Only for the 79
|
|
297
|
+
instruments measured as holding their level: looping a piano or a marimba
|
|
298
|
+
would sustain a note that is supposed to die away, which is a worse and more
|
|
299
|
+
obvious error than the truncation it fixes.
|
|
300
|
+
- **A drum note is never bent** (`exactSample`, not `nearestSample`). In a kit
|
|
301
|
+
a note number names a *different instrument* — 38 is a snare, 42 a closed
|
|
302
|
+
hi-hat — so reaching for the closest available slot answers a missing cowbell
|
|
303
|
+
with a detuned tom, confidently. A slot the kit does not define plays nothing,
|
|
304
|
+
which is what real hardware does; TR-808 genuinely leaves three of GM's slots
|
|
305
|
+
empty. `percussionPackName` addresses a kit by the program that selects it,
|
|
306
|
+
and goes through `gmKitAt`, because a percussion track's `midiProgram` is a
|
|
307
|
+
kit and never agrees with the instrument table (Brush is 40; program 40 is
|
|
308
|
+
Violin).
|
|
309
|
+
- **`percussionBase` has no default, deliberately.** The melodic packs have a
|
|
310
|
+
CDN to fall back on; the kits are app-hosted, so there is nowhere to point.
|
|
311
|
+
An app that ships scores with drums and never sets it gets a sentence naming
|
|
312
|
+
the build script rather than silent percussion.
|
|
313
|
+
- **An export is rendered offline, not played into a file.** The engine
|
|
314
|
+
schedules against a *live* clock and pumps 200ms at a time, so reusing it for
|
|
315
|
+
export would make a render take as long as the piece. `RenderPlan` arrives
|
|
316
|
+
fully resolved (seconds, not ticks; mute and solo already applied by
|
|
317
|
+
`renderEvents` in music_lib), so `rn/audio/offline-render.ts` schedules the
|
|
318
|
+
whole thing up front against an `OfflineAudioContext`. What it *does* share
|
|
319
|
+
with the engine is `PackLibrary` and `planVoice` — the sample choice and the
|
|
320
|
+
gain — which is what keeps the file a recording of what was heard rather than
|
|
321
|
+
a second voicing that drifts. Note the velocity units differ: `RenderPlan`
|
|
322
|
+
carries 0..1 and `planVoice` normalizes 0..127, so the renderer scales back up.
|
|
323
|
+
Getting that backwards renders a file ~127x too quiet, which is why it has its
|
|
324
|
+
own test.
|
|
325
|
+
## Related Projects
|
|
326
|
+
|
|
327
|
+
- `music_types` — the model, the score domain, the playhead (`@sudobility/music_types`)
|
|
328
|
+
- `music_codecs` — score file formats
|
|
329
|
+
- `music_io` — files, audio encoding, XML, MIDI input
|
|
330
|
+
- `music_lib` — editing; binds the store to this through its playback adapter
|
|
331
|
+
- `music_app` — the web UI
|
|
332
|
+
|
|
333
|
+
## Git Workflow
|
|
334
|
+
|
|
335
|
+
- Do not use feature branches for code changes. Always stay on the current branch.
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform-free half: the interface and the singleton, and nothing that
|
|
3
|
+
* makes a sound.
|
|
4
|
+
*
|
|
5
|
+
* Its own entry point so a package that only *drives* playback does not pull an
|
|
6
|
+
* audio engine in with it. `music_lib` is exactly that case — it holds an
|
|
7
|
+
* `IMusicPlayer` and calls it, and its own guard test asserts it declares no
|
|
8
|
+
* platform dependency. Importing this package's root would resolve to the web
|
|
9
|
+
* entry and drag in `AudioWorklet` and `js-synthesizer`, which would make that
|
|
10
|
+
* assertion false.
|
|
11
|
+
*
|
|
12
|
+
* Same reasoning as `@sudobility/music_player/rn/expression-table`: a subpath
|
|
13
|
+
* exists so a consumer can reach one dependency-free thing without the peers
|
|
14
|
+
* that surround it.
|
|
15
|
+
*/
|
|
16
|
+
export * from './types.js';
|
|
17
|
+
/** The engine contract, for a platform implementing one. */
|
|
18
|
+
export * from './engine.js';
|
|
19
|
+
export * from './singleton.js';
|
|
20
|
+
/**
|
|
21
|
+
* Plan building is here too: it is pure score maths over `@sudobility/music_types`
|
|
22
|
+
* and touches no engine, so a consumer that wants to know what *would* sound —
|
|
23
|
+
* music_lib's articulation tests, music_api if it ever needs to — can have it
|
|
24
|
+
* without an audio context.
|
|
25
|
+
*/
|
|
26
|
+
export { playbackPlan, playbackTracks, resolveVoice } from './shared/plan.js';
|
|
27
|
+
export { renderEvents } from './shared/render-events.js';
|
|
28
|
+
/**
|
|
29
|
+
* The bus itself, for hosts that bind React to it and for their tests, which
|
|
30
|
+
* construct one and publish into it. Platform-free: three listener sets.
|
|
31
|
+
*/
|
|
32
|
+
export { PlaybackBus } from './shared/bus.js';
|
|
33
|
+
//# sourceMappingURL=core.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,cAAc,YAAY,CAAC;AAC3B,4DAA4D;AAC5D,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAE/B;;;;;GAKG;AACH,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAC9E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAEzD;;;GAGG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC"}
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform-free half: the interface and the singleton, and nothing that
|
|
3
|
+
* makes a sound.
|
|
4
|
+
*
|
|
5
|
+
* Its own entry point so a package that only *drives* playback does not pull an
|
|
6
|
+
* audio engine in with it. `music_lib` is exactly that case — it holds an
|
|
7
|
+
* `IMusicPlayer` and calls it, and its own guard test asserts it declares no
|
|
8
|
+
* platform dependency. Importing this package's root would resolve to the web
|
|
9
|
+
* entry and drag in `AudioWorklet` and `js-synthesizer`, which would make that
|
|
10
|
+
* assertion false.
|
|
11
|
+
*
|
|
12
|
+
* Same reasoning as `@sudobility/music_player/rn/expression-table`: a subpath
|
|
13
|
+
* exists so a consumer can reach one dependency-free thing without the peers
|
|
14
|
+
* that surround it.
|
|
15
|
+
*/
|
|
16
|
+
export * from './types.js';
|
|
17
|
+
/** The engine contract, for a platform implementing one. */
|
|
18
|
+
export * from './engine.js';
|
|
19
|
+
export * from './singleton.js';
|
|
20
|
+
/**
|
|
21
|
+
* Plan building is here too: it is pure score maths over `@sudobility/music_types`
|
|
22
|
+
* and touches no engine, so a consumer that wants to know what *would* sound —
|
|
23
|
+
* music_lib's articulation tests, music_api if it ever needs to — can have it
|
|
24
|
+
* without an audio context.
|
|
25
|
+
*/
|
|
26
|
+
export { playbackPlan, playbackTracks, resolveVoice } from './shared/plan.js';
|
|
27
|
+
export { renderEvents } from './shared/render-events.js';
|
|
28
|
+
/**
|
|
29
|
+
* The bus itself, for hosts that bind React to it and for their tests, which
|
|
30
|
+
* construct one and publish into it. Platform-free: three listener sets.
|
|
31
|
+
*/
|
|
32
|
+
export { PlaybackBus } from './shared/bus.js';
|
|
33
|
+
//# sourceMappingURL=core.js.map
|
package/dist/core.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"core.js","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,cAAc,YAAY,CAAC;AAC3B,4DAA4D;AAC5D,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAE/B;;;;;GAKG;AACH,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAC9E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAEzD;;;GAGG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC"}
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine contract: what a platform must provide for sound to come out.
|
|
3
|
+
*
|
|
4
|
+
* Here rather than in `@sudobility/music_types` because this package is the
|
|
5
|
+
* only thing that implements it — the web's fluidsynth engine and React
|
|
6
|
+
* Native's sample engine — and the only thing that calls it. The *plan* those
|
|
7
|
+
* engines consume stays in music_types, and must: `PlaybackPlan` composes
|
|
8
|
+
* `PerformanceTimeline`, which `performanceTimeline()` produces there.
|
|
9
|
+
*
|
|
10
|
+
* So the line is: the plan model is score domain, the engine contract is
|
|
11
|
+
* playback.
|
|
12
|
+
*/
|
|
13
|
+
import type { PlaybackLoadState, PlaybackPlan, PlaybackTrack, ScoreRange, SoundingNote, TransportPlaybackState } from '@sudobility/music_types';
|
|
14
|
+
/**
|
|
15
|
+
* A voice to audition, resolved from the GM tables **by the caller**.
|
|
16
|
+
*
|
|
17
|
+
* The engine cannot resolve it: a percussion voice's program addresses a drum
|
|
18
|
+
* kit, and only the GM tables know which kit an arbitrary address falls in.
|
|
19
|
+
* Resolving here is what lets the platform layer keep no catalogue of its own.
|
|
20
|
+
*/
|
|
21
|
+
export type AuditionVoice = {
|
|
22
|
+
/** The kit's program on a percussion voice, the instrument's own otherwise. */
|
|
23
|
+
program: number;
|
|
24
|
+
/** The GM catalogue name for `program`. */
|
|
25
|
+
name: string;
|
|
26
|
+
isPercussion: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type PlaybackObserver = {
|
|
29
|
+
onPositionTick(tick: number): void;
|
|
30
|
+
/**
|
|
31
|
+
* The notes currently sounding, whenever that set changes.
|
|
32
|
+
*
|
|
33
|
+
* Resolved rather than bare ids. The scheduler already knows each note's
|
|
34
|
+
* track and pitch — it scheduled them — and emitting only the id forced every
|
|
35
|
+
* consumer to search the whole score to get it back: `playingPitchesForTrack`
|
|
36
|
+
* was an O(score) scan per sounding note, twenty times a second.
|
|
37
|
+
*/
|
|
38
|
+
onActiveNotes(notes: SoundingNote[]): void;
|
|
39
|
+
onStateChange(state: TransportPlaybackState): void;
|
|
40
|
+
/**
|
|
41
|
+
* How far along the engine is in becoming able to make a sound.
|
|
42
|
+
*
|
|
43
|
+
* Optional because not every engine has anything to load. The soundfont
|
|
44
|
+
* engine does: tens of megabytes to fetch and several seconds for the synth
|
|
45
|
+
* to digest, all on the first press of Play. Without this the transport
|
|
46
|
+
* simply looks broken for that whole time — which is exactly how it read
|
|
47
|
+
* before there was anywhere to report it.
|
|
48
|
+
*/
|
|
49
|
+
onLoadStateChange?(state: PlaybackLoadState): void;
|
|
50
|
+
};
|
|
51
|
+
export interface PlaybackEngine {
|
|
52
|
+
initialize(): Promise<void>;
|
|
53
|
+
/** Adopts a plan. The engine is handed music, never a score. */
|
|
54
|
+
load(plan: PlaybackPlan): Promise<void>;
|
|
55
|
+
play(fromTick?: number): Promise<void>;
|
|
56
|
+
pause(): void;
|
|
57
|
+
stop(): void;
|
|
58
|
+
seek(tick: number): void;
|
|
59
|
+
setTempoMultiplier(multiplier: number): void;
|
|
60
|
+
setLoop(range: ScoreRange | null): void;
|
|
61
|
+
setTrackMute(trackId: string, muted: boolean): void;
|
|
62
|
+
setTrackSolo(trackId: string, solo: boolean): void;
|
|
63
|
+
/**
|
|
64
|
+
* Re-reads every track's volume, pan, mute and solo and pushes them,
|
|
65
|
+
* touching nothing that is scheduled.
|
|
66
|
+
*
|
|
67
|
+
* The mixing counterpart to `load`. Mute and solo had setters; volume and pan
|
|
68
|
+
* did not, and a track's volume was only ever read at load time — so once a
|
|
69
|
+
* mix change stopped reloading the score (the playback edit lock, see
|
|
70
|
+
* `music_lib`'s `score-slice`), moving a fader during playback moved the
|
|
71
|
+
* fader and not the sound.
|
|
72
|
+
*
|
|
73
|
+
* Takes only the tracks, so changing a gain does not rebuild every note —
|
|
74
|
+
* which is the whole reason this is separate from `load`. Idempotent, and
|
|
75
|
+
* takes them all rather than one property: the caller says "the mix changed,
|
|
76
|
+
* here it is" and does not work out which property it was.
|
|
77
|
+
*/
|
|
78
|
+
applyMix(tracks: readonly PlaybackTrack[]): void;
|
|
79
|
+
/** Toggles the metronome click. */
|
|
80
|
+
setMetronome(enabled: boolean): void;
|
|
81
|
+
/** Sets overall output level, 0-1 linear gain. */
|
|
82
|
+
setMasterVolume(volume: number): void;
|
|
83
|
+
/**
|
|
84
|
+
* Sounds `midi` immediately on `program`'s voice and holds it, independent of
|
|
85
|
+
* the transport — for auditioning a key while editing.
|
|
86
|
+
*
|
|
87
|
+
* `voice.isPercussion` mirrors what playback does with a percussion-clef
|
|
88
|
+
* track: without it, tapping a note on a drum track auditioned a pitched
|
|
89
|
+
* instrument while the same note played back as a drum.
|
|
90
|
+
*
|
|
91
|
+
* Separate from `play` because the two answer different questions: `play`
|
|
92
|
+
* renders the written score along a timeline, this makes a sound *now*, for
|
|
93
|
+
* as long as the caller holds it, whether or not a score is even loaded.
|
|
94
|
+
* Implementations must not disturb transport state.
|
|
95
|
+
*/
|
|
96
|
+
noteOn(midi: number, voice: AuditionVoice): void;
|
|
97
|
+
/** Releases a pitch started by `noteOn`. Silent no-op if it is not sounding. */
|
|
98
|
+
noteOff(midi: number): void;
|
|
99
|
+
/** Registers (or, with `null`, clears) the single observer receiving position/active-note/state updates. */
|
|
100
|
+
setObserver(observer: PlaybackObserver | null): void;
|
|
101
|
+
dispose(): void;
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EACV,iBAAiB,EACjB,YAAY,EACZ,aAAa,EACb,UAAU,EACV,YAAY,EACZ,sBAAsB,EACvB,MAAM,yBAAyB,CAAC;AAEjC;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,+EAA+E;IAC/E,OAAO,EAAE,MAAM,CAAC;IAChB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC;;;;;;;OAOG;IACH,aAAa,CAAC,KAAK,EAAE,YAAY,EAAE,GAAG,IAAI,CAAC;IAC3C,aAAa,CAAC,KAAK,EAAE,sBAAsB,GAAG,IAAI,CAAC;IACnD;;;;;;;;OAQG;IACH,iBAAiB,CAAC,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAAC;CACpD,CAAC;AAEF,MAAM,WAAW,cAAc;IAC7B,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,gEAAgE;IAChE,IAAI,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,IAAI,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,KAAK,IAAI,IAAI,CAAC;IACd,IAAI,IAAI,IAAI,CAAC;IACb,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7C,OAAO,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,GAAG,IAAI,CAAC;IACxC,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IACpD,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI,CAAC;IACnD;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,MAAM,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI,CAAC;IACjD,mCAAmC;IACnC,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IACrC,kDAAkD;IAClD,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;IACjD,gFAAgF;IAChF,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4GAA4G;IAC5G,WAAW,CAAC,QAAQ,EAAE,gBAAgB,GAAG,IAAI,GAAG,IAAI,CAAC;IACrD,OAAO,IAAI,IAAI,CAAC;CACjB"}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.js","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A player that records what it was asked to do and makes no sound.
|
|
3
|
+
*
|
|
4
|
+
* Shipped from this package rather than rewritten in each consumer, so
|
|
5
|
+
* music_lib's and music_app's tests exercise the same double this package's own
|
|
6
|
+
* suite does.
|
|
7
|
+
*/
|
|
8
|
+
import type { PlaybackLoadState, PlaybackTrack, Score, ScoreRange, SoundingNote, TransportPlaybackState } from '@sudobility/music_types';
|
|
9
|
+
import { PlaybackBus } from '../shared/bus.js';
|
|
10
|
+
import type { IMusicPlayer, Unsubscribe } from '../types.js';
|
|
11
|
+
export declare class MockMusicPlayer implements IMusicPlayer {
|
|
12
|
+
readonly calls: string[];
|
|
13
|
+
/** The score it was last handed, for tests to assert on. */
|
|
14
|
+
loadedScore: Score | null;
|
|
15
|
+
private readonly position;
|
|
16
|
+
private readonly soundingListeners;
|
|
17
|
+
private readonly transport;
|
|
18
|
+
private readonly loadState;
|
|
19
|
+
/** A real bus, so a consumer's tests can publish into it exactly as the engine would. */
|
|
20
|
+
readonly bus: PlaybackBus;
|
|
21
|
+
load(score: Score, opts?: {
|
|
22
|
+
visibleTrackIds?: readonly string[];
|
|
23
|
+
}): Promise<void>;
|
|
24
|
+
play(): Promise<void>;
|
|
25
|
+
pause(): void;
|
|
26
|
+
stop(): void;
|
|
27
|
+
seek(tick: number): void;
|
|
28
|
+
setLoop(range: ScoreRange | null): void;
|
|
29
|
+
setTempoMultiplier(m: number): void;
|
|
30
|
+
setMetronome(on: boolean): void;
|
|
31
|
+
setMasterVolume(v: number): void;
|
|
32
|
+
applyMix(tracks: readonly PlaybackTrack[]): void;
|
|
33
|
+
setVisibleTracks(ids: readonly string[]): void;
|
|
34
|
+
noteOn(midi: number, program: number, isPercussion?: boolean): void;
|
|
35
|
+
noteOff(midi: number): void;
|
|
36
|
+
onPosition(fn: (t: number) => void): Unsubscribe;
|
|
37
|
+
onSounding(fn: (n: readonly SoundingNote[]) => void): Unsubscribe;
|
|
38
|
+
onTransport(fn: (s: TransportPlaybackState) => void): Unsubscribe;
|
|
39
|
+
onLoadState(fn: (s: PlaybackLoadState) => void): Unsubscribe;
|
|
40
|
+
dispose(): void;
|
|
41
|
+
emitPosition(tick: number): void;
|
|
42
|
+
emitSounding(notes: readonly SoundingNote[]): void;
|
|
43
|
+
emitTransport(state: TransportPlaybackState): void;
|
|
44
|
+
emitLoadState(state: PlaybackLoadState): void;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/mocks/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EACV,iBAAiB,EACjB,aAAa,EACb,KAAK,EACL,UAAU,EACV,YAAY,EACZ,sBAAsB,EACvB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE7D,qBAAa,eAAgB,YAAW,YAAY;IAClD,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAM;IAC9B,4DAA4D;IAC5D,WAAW,EAAE,KAAK,GAAG,IAAI,CAAQ;IAEjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkC;IAC3D,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAE9B;IACJ,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAkD;IAC5E,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA6C;IAEvE,yFAAyF;IACzF,QAAQ,CAAC,GAAG,cAAqB;IAE3B,IAAI,CACR,KAAK,EAAE,KAAK,EACZ,IAAI,GAAE;QAAE,eAAe,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;KAAO,GACjD,OAAO,CAAC,IAAI,CAAC;IASV,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAG3B,KAAK,IAAI,IAAI;IAGb,IAAI,IAAI,IAAI;IAGZ,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAGxB,OAAO,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,GAAG,IAAI;IAKvC,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI;IAGnC,YAAY,CAAC,EAAE,EAAE,OAAO,GAAG,IAAI;IAG/B,eAAe,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI;IAGhC,QAAQ,CAAC,MAAM,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAGhD,gBAAgB,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI;IAG9C,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,UAAQ,GAAG,IAAI;IAGjE,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAI3B,UAAU,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,GAAG,WAAW;IAIhD,UAAU,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,SAAS,YAAY,EAAE,KAAK,IAAI,GAAG,WAAW;IAIjE,WAAW,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,sBAAsB,KAAK,IAAI,GAAG,WAAW;IAIjE,WAAW,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,iBAAiB,KAAK,IAAI,GAAG,WAAW;IAK5D,OAAO,IAAI,IAAI;IAKf,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAIhC,YAAY,CAAC,KAAK,EAAE,SAAS,YAAY,EAAE,GAAG,IAAI;IAIlD,aAAa,CAAC,KAAK,EAAE,sBAAsB,GAAG,IAAI;IAGlD,aAAa,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI;CAG9C"}
|