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