@hraness/dawg 0.2.0 → 0.4.0
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/CHANGELOG.md +201 -0
- package/DAWG.md +762 -11
- package/README.md +79 -30
- package/core/chords.ts +1724 -0
- package/core/diff.ts +261 -0
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/key.ts +43 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/pitch.ts +60 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +1054 -14
- package/core/sdk/eval-child.ts +125 -0
- package/core/sdk/eval.ts +260 -0
- package/core/sdk/print.ts +675 -0
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +4159 -0
- package/core/slug.ts +19 -0
- package/core/synth.ts +1001 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +317 -16
- package/src/agent/brief.ts +50 -5
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +55 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +705 -14
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +35 -14
- package/src/agent/xcb.ts +233 -21
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +430 -33
- package/src/audio/kits.ts +200 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +420 -0
- package/src/audio/samples.ts +1025 -0
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +550 -334
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +192 -0
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +535 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +34 -15
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +462 -0
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +2136 -170
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +186 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +397 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/media/wavetable.ts +202 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +49 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +6 -38
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +2125 -0
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +984 -0
- package/src/tui/sketch.ts +108 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +684 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +398 -33
- package/tui/drums.ts +44 -0
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +24 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +188 -0
package/src/audio/wav.ts
CHANGED
|
@@ -1,12 +1,60 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SCORE_LIMITS,
|
|
3
|
+
isSamplerInstrument,
|
|
3
4
|
isTrackAudible,
|
|
5
|
+
isWavetableInstrument,
|
|
6
|
+
wavetableOf,
|
|
4
7
|
type AutomationPoint,
|
|
5
8
|
type Note,
|
|
6
9
|
type Track,
|
|
7
10
|
type TrackScore,
|
|
8
11
|
} from "../../core/score.ts";
|
|
9
12
|
import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
|
|
13
|
+
import {
|
|
14
|
+
planSamplerVoices,
|
|
15
|
+
renderSamplerVoices,
|
|
16
|
+
samplerTailSeconds,
|
|
17
|
+
} from "./sampler.ts";
|
|
18
|
+
import { EFFECT_NAMES, FX_PRESETS, effectSpec } from "../../core/fx.ts";
|
|
19
|
+
import { seededRandom } from "./random.ts";
|
|
20
|
+
import {
|
|
21
|
+
isStereoVoice,
|
|
22
|
+
renderSynthNote,
|
|
23
|
+
synthTailSeconds,
|
|
24
|
+
usesSynthVoice,
|
|
25
|
+
type VoiceContext,
|
|
26
|
+
} from "./synth/voice.ts";
|
|
27
|
+
import { legacyWave } from "./synth/oscillators.ts";
|
|
28
|
+
import {
|
|
29
|
+
EMPTY_SAMPLE_BANK,
|
|
30
|
+
sampleKey,
|
|
31
|
+
type DecodedSample,
|
|
32
|
+
type SampleBank,
|
|
33
|
+
} from "./samples.ts";
|
|
34
|
+
import { synthKit } from "../../core/kits.ts";
|
|
35
|
+
import { kitDrumSample, kitTailSeconds, newKitVoiceState } from "./kits.ts";
|
|
36
|
+
import {
|
|
37
|
+
applyMonoChain,
|
|
38
|
+
applyStereoChain,
|
|
39
|
+
delayTailFor,
|
|
40
|
+
interpolateAutomation,
|
|
41
|
+
reverbImpulse,
|
|
42
|
+
reverbTailFor,
|
|
43
|
+
} from "./effects/chain.ts";
|
|
44
|
+
import {
|
|
45
|
+
createOrbitBus,
|
|
46
|
+
renderOrbitBus,
|
|
47
|
+
sendToBus,
|
|
48
|
+
sharedOrbitOf,
|
|
49
|
+
type OrbitBus,
|
|
50
|
+
} from "./effects/bus.ts";
|
|
51
|
+
import { tableFor, wavetableOscillator } from "./wavetable.ts";
|
|
52
|
+
import {
|
|
53
|
+
duckGains,
|
|
54
|
+
duckSettings,
|
|
55
|
+
orbitOf,
|
|
56
|
+
type Ducker,
|
|
57
|
+
} from "./effects/duck.ts";
|
|
10
58
|
|
|
11
59
|
export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
|
|
12
60
|
|
|
@@ -18,6 +66,11 @@ export type RenderOptions = WavOptions &
|
|
|
18
66
|
* what the streaming engine plays; exports keep the default one-shot.
|
|
19
67
|
*/
|
|
20
68
|
loop?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Decoded sampler voices (`SampleLibrary.load`). Without it sampler
|
|
71
|
+
* tracks render silent; scores without samplers are unaffected.
|
|
72
|
+
*/
|
|
73
|
+
samples?: SampleBank;
|
|
21
74
|
}>;
|
|
22
75
|
|
|
23
76
|
/** Interleaved stereo 16-bit PCM plus its frame count. */
|
|
@@ -41,16 +94,46 @@ export const AVAILABLE_INSTRUMENTS = Object.freeze([
|
|
|
41
94
|
"saw",
|
|
42
95
|
"square",
|
|
43
96
|
"triangle",
|
|
97
|
+
"wavetable",
|
|
44
98
|
"kit",
|
|
99
|
+
// Synth-voice sounds (core/synth.ts), Strudel names; `synth` shapes them.
|
|
100
|
+
"sawtooth",
|
|
101
|
+
"supersaw",
|
|
102
|
+
"pulse",
|
|
103
|
+
"user",
|
|
104
|
+
"white",
|
|
105
|
+
"pink",
|
|
106
|
+
"brown",
|
|
107
|
+
"crackle",
|
|
108
|
+
// ZzFX sounds (src/audio/synth/zzfx.ts).
|
|
109
|
+
"z_sine",
|
|
110
|
+
"z_triangle",
|
|
111
|
+
"z_sawtooth",
|
|
112
|
+
"z_square",
|
|
113
|
+
"z_tan",
|
|
114
|
+
"z_noise",
|
|
45
115
|
] as const);
|
|
46
116
|
|
|
47
|
-
/** Per-track effects understood by the renderer and the agent
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
117
|
+
/** Per-track effects understood by the renderer and the agent, in chain
|
|
118
|
+
* order, with each effect's simple parameters (`fx` takes the rest). */
|
|
119
|
+
export const AVAILABLE_EFFECTS: readonly string[] = Object.freeze([
|
|
120
|
+
"pan(-1..1 equal-power)",
|
|
121
|
+
...EFFECT_NAMES.map((name) => {
|
|
122
|
+
const spec = effectSpec(name);
|
|
123
|
+
return `${name}(${spec.simple.join(" ")})`;
|
|
124
|
+
}),
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/** `fx <effect> preset <name>` names, for the agent brief. */
|
|
128
|
+
export const AVAILABLE_FX_PRESETS: Readonly<Record<string, string>> =
|
|
129
|
+
Object.freeze(
|
|
130
|
+
Object.fromEntries(
|
|
131
|
+
EFFECT_NAMES.filter((name) => FX_PRESETS[name]).map((name) => [
|
|
132
|
+
name,
|
|
133
|
+
Object.keys(FX_PRESETS[name]!).join(" "),
|
|
134
|
+
]),
|
|
135
|
+
),
|
|
136
|
+
);
|
|
54
137
|
|
|
55
138
|
/** Longest drum one-shot, in seconds; hits ring past their note length. */
|
|
56
139
|
const MAX_DRUM_SECONDS = 0.6;
|
|
@@ -66,6 +149,8 @@ type RenderContext = Readonly<{
|
|
|
66
149
|
sampleRate: number;
|
|
67
150
|
samples: number;
|
|
68
151
|
samplesPerTick: number;
|
|
152
|
+
tempoBpm: number;
|
|
153
|
+
irs?: ReadonlyMap<string, DecodedSample>;
|
|
69
154
|
}>;
|
|
70
155
|
|
|
71
156
|
/** Exact (fractional) loop length in frames at a sample rate. */
|
|
@@ -83,7 +168,7 @@ export function clampSampleRate(sampleRate?: number): number {
|
|
|
83
168
|
/** Render the bounded score to a stereo 16-bit PCM WAV. */
|
|
84
169
|
export function renderScoreWav(
|
|
85
170
|
score: TrackScore,
|
|
86
|
-
options: WavOptions = {},
|
|
171
|
+
options: WavOptions & Pick<RenderOptions, "samples"> = {},
|
|
87
172
|
): Uint8Array {
|
|
88
173
|
const audio = renderScorePcm(score, options);
|
|
89
174
|
return encodeWav(audio.pcm, audio.sampleRate, RENDER_CHANNELS);
|
|
@@ -98,113 +183,424 @@ export function renderScorePcm(
|
|
|
98
183
|
score: TrackScore,
|
|
99
184
|
options: RenderOptions = {},
|
|
100
185
|
): RenderedAudio {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
186
|
+
return new StemRenderer({ maxCacheBytes: 0 }).render(score, options);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Default byte budget for cached per-track stems. */
|
|
190
|
+
export const DEFAULT_STEM_CACHE_BYTES = 256 * 1024 * 1024;
|
|
191
|
+
|
|
192
|
+
type Stem = {
|
|
193
|
+
key: string;
|
|
194
|
+
left: Float64Array;
|
|
195
|
+
right: Float64Array;
|
|
196
|
+
bytes: number;
|
|
197
|
+
used: number;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The renderer behind `renderScorePcm`, with a per-track stem cache. Each
|
|
202
|
+
* track's stereo stem (after its own effects) is keyed by everything that
|
|
203
|
+
* shapes it: the track settings, its notes, tempo, tick resolution, sample
|
|
204
|
+
* rate and buffer length. Edits re-render only the tracks whose key changed
|
|
205
|
+
* and sum the cached rest in the original order, so the result is
|
|
206
|
+
* byte-identical to a cold render; mute and solo only pick which stems are
|
|
207
|
+
* summed. Scratch buffers are reused across renders.
|
|
208
|
+
*/
|
|
209
|
+
export class StemRenderer {
|
|
210
|
+
private readonly maxCacheBytes: number;
|
|
211
|
+
private readonly stems = new Map<string, Stem>();
|
|
212
|
+
/** Shared orbit bus outputs by orbit, keyed by their members' stems. */
|
|
213
|
+
private readonly buses = new Map<
|
|
214
|
+
number,
|
|
215
|
+
{ key: string; left: Float64Array; right: Float64Array }
|
|
216
|
+
>();
|
|
217
|
+
private cacheBytes = 0;
|
|
218
|
+
private renders = 0;
|
|
219
|
+
private scratch: {
|
|
220
|
+
dry: Float64Array;
|
|
221
|
+
dryR: Float64Array;
|
|
222
|
+
left: Float64Array;
|
|
223
|
+
right: Float64Array;
|
|
224
|
+
mixL: Float64Array;
|
|
225
|
+
mixR: Float64Array;
|
|
226
|
+
} = {
|
|
227
|
+
dry: new Float64Array(0),
|
|
228
|
+
dryR: new Float64Array(0),
|
|
229
|
+
left: new Float64Array(0),
|
|
230
|
+
right: new Float64Array(0),
|
|
231
|
+
mixL: new Float64Array(0),
|
|
232
|
+
mixR: new Float64Array(0),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
public constructor(options: Readonly<{ maxCacheBytes?: number }> = {}) {
|
|
236
|
+
this.maxCacheBytes = Math.max(
|
|
237
|
+
0,
|
|
238
|
+
options.maxCacheBytes ?? DEFAULT_STEM_CACHE_BYTES,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Cached stems and their total size, for tests and diagnostics. */
|
|
243
|
+
public get cache(): Readonly<{ stems: number; bytes: number }> {
|
|
244
|
+
return { stems: this.stems.size, bytes: this.cacheBytes };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
public render(score: TrackScore, options: RenderOptions = {}): RenderedAudio {
|
|
248
|
+
const sampleRate = clampSampleRate(options.sampleRate);
|
|
249
|
+
const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
|
|
250
|
+
const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
|
|
251
|
+
const bank = options.samples ?? EMPTY_SAMPLE_BANK;
|
|
252
|
+
const reverbTail = Math.max(
|
|
253
|
+
0,
|
|
254
|
+
...score.tracks.map((track) =>
|
|
255
|
+
reverbTailFor(track, sampleRate, bank.irs),
|
|
118
256
|
),
|
|
119
257
|
);
|
|
120
|
-
|
|
258
|
+
// Zero without sampler voices, so synth-only renders are unchanged.
|
|
259
|
+
// Zero without synth-voice tracks, so legacy renders are unchanged.
|
|
260
|
+
const samplerTail = Math.min(
|
|
121
261
|
MAX_LOOP_TAIL_SECONDS,
|
|
122
|
-
|
|
262
|
+
Math.max(
|
|
263
|
+
samplerTailSeconds(score, bank, sampleRate),
|
|
264
|
+
...score.tracks.map(synthTailSeconds),
|
|
265
|
+
),
|
|
123
266
|
);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
267
|
+
let frames: number;
|
|
268
|
+
let samples: number;
|
|
269
|
+
if (options.loop) {
|
|
270
|
+
frames = Math.max(
|
|
271
|
+
1,
|
|
272
|
+
Math.min(
|
|
273
|
+
Math.round(loopFrames(score, sampleRate)),
|
|
274
|
+
Math.ceil(maxSeconds * sampleRate),
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
const tailSeconds = Math.min(
|
|
278
|
+
MAX_LOOP_TAIL_SECONDS,
|
|
279
|
+
Math.max(MAX_DRUM_SECONDS, samplerTail, kitTailSeconds(score)) +
|
|
280
|
+
0.1 +
|
|
281
|
+
reverbTail +
|
|
282
|
+
Math.max(
|
|
283
|
+
0,
|
|
284
|
+
...score.tracks.map((track) => delayTailFor(track, score.tempoBpm)),
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
samples = frames + Math.ceil(tailSeconds * sampleRate);
|
|
288
|
+
} else {
|
|
289
|
+
const seconds = Math.min(
|
|
290
|
+
maxSeconds,
|
|
291
|
+
loopSeconds + Math.max(ONE_SHOT_TAIL_SECONDS, samplerTail) + reverbTail,
|
|
292
|
+
);
|
|
293
|
+
frames = Math.max(1, Math.ceil(seconds * sampleRate));
|
|
294
|
+
samples = frames;
|
|
295
|
+
}
|
|
296
|
+
const context: RenderContext = {
|
|
297
|
+
score,
|
|
298
|
+
sampleRate,
|
|
299
|
+
samples,
|
|
300
|
+
samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
|
|
301
|
+
tempoBpm: score.tempoBpm,
|
|
302
|
+
...(bank.irs ? { irs: bank.irs } : {}),
|
|
303
|
+
};
|
|
304
|
+
this.renders += 1;
|
|
305
|
+
const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
|
|
306
|
+
mixL.fill(0);
|
|
307
|
+
mixR.fill(0);
|
|
308
|
+
const tracks = new Map(score.tracks.map((track) => [track.id, track]));
|
|
309
|
+
const groups = new Map<string, Note[]>();
|
|
310
|
+
for (const note of score.notes) {
|
|
311
|
+
const group = groups.get(note.trackId);
|
|
312
|
+
if (group) group.push(note);
|
|
313
|
+
else groups.set(note.trackId, [note]);
|
|
314
|
+
}
|
|
315
|
+
// Orbit ducking is a gain on finished stems (src/audio/effects/duck.ts).
|
|
316
|
+
const duckers: Ducker[] = [];
|
|
317
|
+
for (const [trackId, notes] of groups) {
|
|
318
|
+
const settings = duckSettings(tracks.get(trackId));
|
|
319
|
+
if (settings && isTrackAudible(score, trackId))
|
|
320
|
+
duckers.push({
|
|
321
|
+
trackId,
|
|
322
|
+
...settings,
|
|
323
|
+
onsets: notes.map((note) => noteSpan(note, context).start),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const ducked = duckGains(
|
|
327
|
+
[...groups.keys()].map((id) => ({ id, orbit: orbitOf(tracks.get(id)) })),
|
|
328
|
+
duckers,
|
|
329
|
+
samples,
|
|
330
|
+
sampleRate,
|
|
331
|
+
options.loop ? frames : undefined,
|
|
129
332
|
);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
333
|
+
// Shared orbit buses (src/audio/effects/bus.ts), created on first send.
|
|
334
|
+
const buses = new Map<number, OrbitBus>();
|
|
335
|
+
// Tracks render one at a time; the sum order (first note per track) is
|
|
336
|
+
// part of the output, so cached and cold renders keep it.
|
|
337
|
+
for (const [trackId, notes] of groups) {
|
|
338
|
+
if (!isTrackAudible(score, trackId)) continue;
|
|
339
|
+
const track = tracks.get(trackId);
|
|
340
|
+
const busOrbit = sharedOrbitOf(track);
|
|
341
|
+
const sampler = isSamplerInstrument(track?.instrument);
|
|
342
|
+
// Wavetable hook: the oscillator factory for a wavetable track (its
|
|
343
|
+
// table id joins the stem key), undefined for every other instrument.
|
|
344
|
+
const wavetable = track ? wavetableHook(track, bank, context) : undefined;
|
|
345
|
+
const key = stemKey(
|
|
346
|
+
track,
|
|
347
|
+
notes,
|
|
348
|
+
context,
|
|
349
|
+
sampler ? bank : undefined,
|
|
350
|
+
wavetable?.id,
|
|
351
|
+
track ? reverbImpulse(track, sampleRate, bank.irs)?.id : undefined,
|
|
352
|
+
);
|
|
353
|
+
let stem = this.stems.get(trackId);
|
|
354
|
+
if (stem?.key === key) stem.used = this.renders;
|
|
355
|
+
else {
|
|
356
|
+
const caching = this.maxCacheBytes > 0;
|
|
357
|
+
const target = caching
|
|
358
|
+
? {
|
|
359
|
+
left: new Float64Array(samples),
|
|
360
|
+
right: new Float64Array(samples),
|
|
361
|
+
}
|
|
362
|
+
: { left, right };
|
|
363
|
+
dry.fill(0);
|
|
364
|
+
const synthVoice = !sampler && usesSynthVoice(track);
|
|
365
|
+
const stereo = synthVoice && isStereoVoice(track);
|
|
366
|
+
if (stereo) dryR.fill(0);
|
|
367
|
+
if (sampler) {
|
|
368
|
+
if (track) renderSamplerNotes(dry, notes, track, context, bank);
|
|
369
|
+
} else if (synthVoice && track) {
|
|
370
|
+
const gainAt = (tick: number) => trackGainAt(track, tick);
|
|
371
|
+
const voice = {
|
|
372
|
+
...context,
|
|
373
|
+
ticksPerBeat: score.ticksPerBeat,
|
|
374
|
+
...(wavetable ? { oscillatorFor: wavetable.oscillatorFor } : {}),
|
|
375
|
+
};
|
|
376
|
+
for (const note of notes) {
|
|
377
|
+
const { start, length } = noteSpan(note, context);
|
|
378
|
+
renderSynthNote(
|
|
379
|
+
dry,
|
|
380
|
+
stereo ? dryR : undefined,
|
|
381
|
+
note,
|
|
382
|
+
track,
|
|
383
|
+
start,
|
|
384
|
+
length,
|
|
385
|
+
voice,
|
|
386
|
+
gainAt,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
const drums = isDrumInstrument(track?.instrument);
|
|
391
|
+
for (const note of notes) {
|
|
392
|
+
if (drums) renderDrumNote(dry, note, track, context);
|
|
393
|
+
else renderToneNote(dry, note, track, context);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (track) applyMonoChain(dry, track, context);
|
|
397
|
+
if (stereo && track) applyMonoChain(dryR, track, context);
|
|
398
|
+
applyPan(
|
|
399
|
+
dry,
|
|
400
|
+
target.left,
|
|
401
|
+
target.right,
|
|
402
|
+
track,
|
|
403
|
+
context,
|
|
404
|
+
stereo ? dryR : undefined,
|
|
405
|
+
);
|
|
406
|
+
if (track)
|
|
407
|
+
applyStereoChain(
|
|
408
|
+
target.left,
|
|
409
|
+
target.right,
|
|
410
|
+
track,
|
|
411
|
+
context,
|
|
412
|
+
busOrbit === undefined,
|
|
413
|
+
);
|
|
414
|
+
stem = {
|
|
415
|
+
key,
|
|
416
|
+
left: target.left,
|
|
417
|
+
right: target.right,
|
|
418
|
+
bytes: target.left.byteLength + target.right.byteLength,
|
|
419
|
+
used: this.renders,
|
|
420
|
+
};
|
|
421
|
+
if (caching) this.store(trackId, stem);
|
|
422
|
+
}
|
|
423
|
+
if (track && busOrbit !== undefined) {
|
|
424
|
+
let bus = buses.get(busOrbit);
|
|
425
|
+
if (!bus)
|
|
426
|
+
buses.set(busOrbit, (bus = createOrbitBus(busOrbit, samples)));
|
|
427
|
+
sendToBus(bus, track, stem.left, stem.right, context, key);
|
|
428
|
+
}
|
|
429
|
+
const gain = ducked.get(trackId);
|
|
430
|
+
if (gain)
|
|
431
|
+
for (let index = 0; index < samples; index += 1) {
|
|
432
|
+
mixL[index]! += stem.left[index]! * gain[index]!;
|
|
433
|
+
mixR[index]! += stem.right[index]! * gain[index]!;
|
|
434
|
+
}
|
|
435
|
+
else
|
|
436
|
+
for (let index = 0; index < samples; index += 1) {
|
|
437
|
+
mixL[index]! += stem.left[index]!;
|
|
438
|
+
mixR[index]! += stem.right[index]!;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Bus returns join after every stem, in orbit order; the orbit's ducking
|
|
442
|
+
// applies to them as to its tracks.
|
|
443
|
+
const busGains = duckGains(
|
|
444
|
+
[...buses.keys()].map((orbit) => ({ id: `bus:${orbit}`, orbit })),
|
|
445
|
+
duckers,
|
|
446
|
+
samples,
|
|
447
|
+
sampleRate,
|
|
448
|
+
options.loop ? frames : undefined,
|
|
449
|
+
);
|
|
450
|
+
for (const orbit of [...buses.keys()].sort((a, b) => a - b)) {
|
|
451
|
+
const bus = buses.get(orbit)!;
|
|
452
|
+
const key = JSON.stringify(bus.keys);
|
|
453
|
+
let wet = this.buses.get(orbit);
|
|
454
|
+
if (wet?.key !== key) {
|
|
455
|
+
wet = { key, ...renderOrbitBus(bus, context) };
|
|
456
|
+
if (this.maxCacheBytes > 0) this.buses.set(orbit, wet);
|
|
457
|
+
}
|
|
458
|
+
const gain = busGains.get(`bus:${orbit}`);
|
|
459
|
+
for (let index = 0; index < samples; index += 1) {
|
|
460
|
+
const g = gain ? gain[index]! : 1;
|
|
461
|
+
mixL[index]! += wet.left[index]! * g;
|
|
462
|
+
mixR[index]! += wet.right[index]! * g;
|
|
463
|
+
}
|
|
161
464
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
465
|
+
for (const orbit of [...this.buses.keys()])
|
|
466
|
+
if (!buses.has(orbit)) this.buses.delete(orbit);
|
|
467
|
+
// Stems of tracks that left the score are not worth keeping; muted and
|
|
468
|
+
// unsoloed tracks keep theirs so toggling them back is free.
|
|
469
|
+
for (const trackId of [...this.stems.keys()])
|
|
470
|
+
if (!groups.has(trackId)) this.evict(trackId);
|
|
471
|
+
if (samples > frames) {
|
|
472
|
+
// Linear effects superpose, so folding the tail onto the start yields
|
|
473
|
+
// the steady state of the loop playing forever.
|
|
474
|
+
for (let index = frames; index < samples; index += 1) {
|
|
475
|
+
mixL[index % frames]! += mixL[index]!;
|
|
476
|
+
mixR[index % frames]! += mixR[index]!;
|
|
477
|
+
}
|
|
167
478
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
479
|
+
const pcm = new Int16Array(frames * RENDER_CHANNELS);
|
|
480
|
+
for (let index = 0; index < frames; index += 1) {
|
|
481
|
+
pcm[index * 2] = clamp16(mixL[index]! * 32767);
|
|
482
|
+
pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
|
|
171
483
|
}
|
|
484
|
+
return Object.freeze({
|
|
485
|
+
sampleRate,
|
|
486
|
+
channels: RENDER_CHANNELS,
|
|
487
|
+
frames,
|
|
488
|
+
pcm,
|
|
489
|
+
});
|
|
172
490
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
491
|
+
|
|
492
|
+
private scratchFor(samples: number) {
|
|
493
|
+
if (this.scratch.dry.length < samples) {
|
|
494
|
+
this.scratch = {
|
|
495
|
+
dry: new Float64Array(samples),
|
|
496
|
+
dryR: new Float64Array(samples),
|
|
497
|
+
left: new Float64Array(samples),
|
|
498
|
+
right: new Float64Array(samples),
|
|
499
|
+
mixL: new Float64Array(samples),
|
|
500
|
+
mixR: new Float64Array(samples),
|
|
501
|
+
};
|
|
179
502
|
}
|
|
503
|
+
const view = (buffer: Float64Array) => buffer.subarray(0, samples);
|
|
504
|
+
return {
|
|
505
|
+
dry: view(this.scratch.dry),
|
|
506
|
+
dryR: view(this.scratch.dryR),
|
|
507
|
+
left: view(this.scratch.left),
|
|
508
|
+
right: view(this.scratch.right),
|
|
509
|
+
mixL: view(this.scratch.mixL),
|
|
510
|
+
mixR: view(this.scratch.mixR),
|
|
511
|
+
};
|
|
180
512
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
513
|
+
|
|
514
|
+
private store(trackId: string, stem: Stem): void {
|
|
515
|
+
this.evict(trackId);
|
|
516
|
+
// Make room by dropping the least recently used stems from other
|
|
517
|
+
// renders; a score whose stems exceed the budget is simply not cached.
|
|
518
|
+
while (
|
|
519
|
+
this.cacheBytes + stem.bytes > this.maxCacheBytes &&
|
|
520
|
+
this.stems.size > 0
|
|
521
|
+
) {
|
|
522
|
+
let oldest: string | undefined;
|
|
523
|
+
for (const [id, candidate] of this.stems)
|
|
524
|
+
if (
|
|
525
|
+
candidate.used !== this.renders &&
|
|
526
|
+
(oldest === undefined ||
|
|
527
|
+
candidate.used < this.stems.get(oldest)!.used)
|
|
528
|
+
)
|
|
529
|
+
oldest = id;
|
|
530
|
+
if (oldest === undefined) break;
|
|
531
|
+
this.evict(oldest);
|
|
532
|
+
}
|
|
533
|
+
if (this.cacheBytes + stem.bytes > this.maxCacheBytes) return;
|
|
534
|
+
this.stems.set(trackId, stem);
|
|
535
|
+
this.cacheBytes += stem.bytes;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private evict(trackId: string): void {
|
|
539
|
+
const stem = this.stems.get(trackId);
|
|
540
|
+
if (!stem) return;
|
|
541
|
+
this.stems.delete(trackId);
|
|
542
|
+
this.cacheBytes -= stem.bytes;
|
|
185
543
|
}
|
|
186
|
-
return Object.freeze({ sampleRate, channels: RENDER_CHANNELS, frames, pcm });
|
|
187
544
|
}
|
|
188
545
|
|
|
189
|
-
/**
|
|
190
|
-
function
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
longest = Math.max(
|
|
203
|
-
longest,
|
|
204
|
-
((track.delay.beats * 60) / score.tempoBpm) * (repeats + 1),
|
|
205
|
-
);
|
|
546
|
+
/** Everything a track's stem depends on, except mute and solo. */
|
|
547
|
+
function stemKey(
|
|
548
|
+
track: Track | undefined,
|
|
549
|
+
notes: readonly Note[],
|
|
550
|
+
context: RenderContext,
|
|
551
|
+
bank?: SampleBank,
|
|
552
|
+
wavetableId?: string,
|
|
553
|
+
impulseId?: string,
|
|
554
|
+
): string {
|
|
555
|
+
let settings: Record<string, unknown> | null = null;
|
|
556
|
+
if (track) {
|
|
557
|
+
const { muted: _muted, solo: _solo, ...rest } = track;
|
|
558
|
+
settings = rest;
|
|
206
559
|
}
|
|
207
|
-
return
|
|
560
|
+
return JSON.stringify([
|
|
561
|
+
settings,
|
|
562
|
+
notes,
|
|
563
|
+
context.score.tempoBpm,
|
|
564
|
+
context.score.ticksPerBeat,
|
|
565
|
+
// Bar length: sampler `unit: "c"` stretches a window to whole bars.
|
|
566
|
+
context.score.beatsPerBar,
|
|
567
|
+
context.sampleRate,
|
|
568
|
+
context.samples,
|
|
569
|
+
// Sampler stems also depend on the decoded files: a replaced or missing
|
|
570
|
+
// sample changes the key even when the score did not change.
|
|
571
|
+
...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
|
|
572
|
+
...(wavetableId ? [wavetableId] : []),
|
|
573
|
+
// A convolution reverb's impulse (its sha256 for a sample).
|
|
574
|
+
...(impulseId ? [impulseId] : []),
|
|
575
|
+
]);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function samplerVoiceDigest(track: Track, bank: SampleBank): string[] {
|
|
579
|
+
return Object.keys(track.sampler?.voices ?? {})
|
|
580
|
+
.sort()
|
|
581
|
+
.map((voice) => {
|
|
582
|
+
const sample = bank.voices.get(sampleKey(track.id, voice));
|
|
583
|
+
return sample
|
|
584
|
+
? `${voice}:${sample.sha256}:${sample.sampleRate}:${sample.frames}`
|
|
585
|
+
: `${voice}:-`;
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Sample voices of a sampler track into its mono dry buffer. */
|
|
590
|
+
function renderSamplerNotes(
|
|
591
|
+
target: Float64Array,
|
|
592
|
+
notes: readonly Note[],
|
|
593
|
+
track: Track,
|
|
594
|
+
context: RenderContext,
|
|
595
|
+
bank: SampleBank,
|
|
596
|
+
): void {
|
|
597
|
+
const timing = { score: context.score, sampleRate: context.sampleRate };
|
|
598
|
+
renderSamplerVoices(
|
|
599
|
+
target,
|
|
600
|
+
planSamplerVoices(track, notes, bank, timing),
|
|
601
|
+
timing,
|
|
602
|
+
(tick) => trackGainAt(track, tick),
|
|
603
|
+
);
|
|
208
604
|
}
|
|
209
605
|
|
|
210
606
|
/** Track volume and volume automation at a score tick (pan is applied in stereo). */
|
|
@@ -263,8 +659,7 @@ function renderToneNote(
|
|
|
263
659
|
0.28 *
|
|
264
660
|
trackGainAt(track, note.startTick + elapsed / samplesPerTick);
|
|
265
661
|
const phase = (frequency * elapsed) / sampleRate;
|
|
266
|
-
target[index]! +=
|
|
267
|
-
synthSample(instrument, phase, frequency, sampleRate) * envelope;
|
|
662
|
+
target[index]! += legacyWave(instrument, phase) * envelope;
|
|
268
663
|
}
|
|
269
664
|
}
|
|
270
665
|
|
|
@@ -281,9 +676,13 @@ function renderDrumNote(
|
|
|
281
676
|
): void {
|
|
282
677
|
const { sampleRate, samples, samplesPerTick } = context;
|
|
283
678
|
const { start } = noteSpan(note, context);
|
|
679
|
+
// A track `kit` swaps in a synthesized kit (src/audio/kits.ts); without
|
|
680
|
+
// one the voices below render exactly as they always have.
|
|
681
|
+
const kit = synthKit(track?.kit);
|
|
682
|
+
const kitState = kit ? newKitVoiceState() : undefined;
|
|
284
683
|
const end = Math.min(
|
|
285
684
|
samples,
|
|
286
|
-
start + Math.ceil(MAX_DRUM_SECONDS * sampleRate),
|
|
685
|
+
start + Math.ceil((kit?.seconds ?? MAX_DRUM_SECONDS) * sampleRate),
|
|
287
686
|
);
|
|
288
687
|
const voice = drumVoiceForPitch(note.pitch);
|
|
289
688
|
const random = seededRandom(`${note.id}:${note.startTick}`);
|
|
@@ -298,7 +697,17 @@ function renderDrumNote(
|
|
|
298
697
|
const bright = noise - previousNoise;
|
|
299
698
|
previousNoise = noise;
|
|
300
699
|
let sample = 0;
|
|
301
|
-
if (
|
|
700
|
+
if (kit && kitState)
|
|
701
|
+
sample = kitDrumSample(
|
|
702
|
+
kit,
|
|
703
|
+
voice,
|
|
704
|
+
t,
|
|
705
|
+
noise,
|
|
706
|
+
bright,
|
|
707
|
+
kitState,
|
|
708
|
+
sampleRate,
|
|
709
|
+
);
|
|
710
|
+
else if (voice === "kick") {
|
|
302
711
|
const frequency = 45 + 105 * Math.exp(-t * 28);
|
|
303
712
|
phase += frequency / sampleRate;
|
|
304
713
|
sample =
|
|
@@ -346,64 +755,6 @@ function renderDrumNote(
|
|
|
346
755
|
}
|
|
347
756
|
}
|
|
348
757
|
|
|
349
|
-
/**
|
|
350
|
-
* RBJ biquad low-pass with static or automated cutoff and resonance.
|
|
351
|
-
* Resonance 0..1 maps to Q 0.707..8 and the cutoff is kept under 45% of the
|
|
352
|
-
* sample rate, so the filter is stable for every value the score accepts.
|
|
353
|
-
*/
|
|
354
|
-
function applyLowPass(
|
|
355
|
-
buffer: Float64Array,
|
|
356
|
-
track: Track,
|
|
357
|
-
context: RenderContext,
|
|
358
|
-
): void {
|
|
359
|
-
const automation = track.filterAutomation ?? [];
|
|
360
|
-
const resonanceLane = track.resonanceAutomation ?? [];
|
|
361
|
-
if (!track.filter && automation.length === 0 && resonanceLane.length === 0)
|
|
362
|
-
return;
|
|
363
|
-
const { sampleRate, samplesPerTick } = context;
|
|
364
|
-
const staticCutoff = track.filter?.cutoff ?? SCORE_LIMITS.maxFilterCutoff;
|
|
365
|
-
const staticResonance = track.filter?.resonance ?? 0;
|
|
366
|
-
const automated = automation.length > 0 || resonanceLane.length > 0;
|
|
367
|
-
let b0 = 0;
|
|
368
|
-
let b1 = 0;
|
|
369
|
-
let b2 = 0;
|
|
370
|
-
let a1 = 0;
|
|
371
|
-
let a2 = 0;
|
|
372
|
-
let z1 = 0;
|
|
373
|
-
let z2 = 0;
|
|
374
|
-
const update = (cutoff: number, resonance: number) => {
|
|
375
|
-
const q = 0.707 + Math.max(0, Math.min(1, resonance)) * 7.293;
|
|
376
|
-
const frequency = Math.max(
|
|
377
|
-
SCORE_LIMITS.minFilterCutoff,
|
|
378
|
-
Math.min(cutoff, sampleRate * 0.45),
|
|
379
|
-
);
|
|
380
|
-
const omega = (2 * Math.PI * frequency) / sampleRate;
|
|
381
|
-
const alpha = Math.sin(omega) / (2 * q);
|
|
382
|
-
const cos = Math.cos(omega);
|
|
383
|
-
const a0 = 1 + alpha;
|
|
384
|
-
b0 = (1 - cos) / 2 / a0;
|
|
385
|
-
b1 = (1 - cos) / a0;
|
|
386
|
-
b2 = b0;
|
|
387
|
-
a1 = (-2 * cos) / a0;
|
|
388
|
-
a2 = (1 - alpha) / a0;
|
|
389
|
-
};
|
|
390
|
-
update(staticCutoff, staticResonance);
|
|
391
|
-
for (let index = 0; index < buffer.length; index += 1) {
|
|
392
|
-
if (automated && index % CONTROL_SAMPLES === 0) {
|
|
393
|
-
const tick = index / samplesPerTick;
|
|
394
|
-
update(
|
|
395
|
-
interpolateAutomation(automation, tick, staticCutoff),
|
|
396
|
-
interpolateAutomation(resonanceLane, tick, staticResonance),
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
const input = buffer[index]!;
|
|
400
|
-
const output = b0 * input + z1;
|
|
401
|
-
z1 = b1 * input - a1 * output + z2;
|
|
402
|
-
z2 = b2 * input - a2 * output;
|
|
403
|
-
buffer[index] = output;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
758
|
/**
|
|
408
759
|
* Equal-power pan of the mono track into the stereo pair: left = cos(θ),
|
|
409
760
|
* right = sin(θ) with θ = (pan + 1)·π/4, so the summed power is constant
|
|
@@ -415,6 +766,7 @@ function applyPan(
|
|
|
415
766
|
right: Float64Array,
|
|
416
767
|
track: Track | undefined,
|
|
417
768
|
context: RenderContext,
|
|
769
|
+
dryRight?: Float64Array,
|
|
418
770
|
): void {
|
|
419
771
|
const lane = track?.panAutomation ?? [];
|
|
420
772
|
const staticPan = track?.pan ?? 0;
|
|
@@ -430,187 +782,51 @@ function applyPan(
|
|
|
430
782
|
);
|
|
431
783
|
const sample = dry[index]!;
|
|
432
784
|
left[index] = sample * gainL;
|
|
433
|
-
right
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Tempo-synced stereo feedback delay. Each side echoes its own input and
|
|
439
|
-
* feeds back into the opposite side, so repeats ping-pong across the field.
|
|
440
|
-
* Feedback and mix follow their automation lanes when present.
|
|
441
|
-
*/
|
|
442
|
-
function applyDelay(
|
|
443
|
-
left: Float64Array,
|
|
444
|
-
right: Float64Array,
|
|
445
|
-
track: Track,
|
|
446
|
-
context: RenderContext,
|
|
447
|
-
): void {
|
|
448
|
-
const delay = track.delay;
|
|
449
|
-
if (!delay) return;
|
|
450
|
-
const feedbackLane = track.delayFeedbackAutomation ?? [];
|
|
451
|
-
const mixLane = track.delayMixAutomation ?? [];
|
|
452
|
-
if (delay.mix <= 0 && mixLane.length === 0) return;
|
|
453
|
-
const { score, sampleRate, samplesPerTick } = context;
|
|
454
|
-
const length = Math.max(
|
|
455
|
-
1,
|
|
456
|
-
Math.round(((delay.beats * 60) / score.tempoBpm) * sampleRate),
|
|
457
|
-
);
|
|
458
|
-
const lineL = new Float64Array(length);
|
|
459
|
-
const lineR = new Float64Array(length);
|
|
460
|
-
let feedback = delay.feedback;
|
|
461
|
-
let mix = delay.mix;
|
|
462
|
-
const automated = feedbackLane.length > 0 || mixLane.length > 0;
|
|
463
|
-
for (let index = 0; index < left.length; index += 1) {
|
|
464
|
-
if (automated && index % CONTROL_SAMPLES === 0) {
|
|
465
|
-
const tick = index / samplesPerTick;
|
|
466
|
-
feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
|
|
467
|
-
mix = interpolateAutomation(mixLane, tick, delay.mix);
|
|
468
|
-
}
|
|
469
|
-
const slot = index % length;
|
|
470
|
-
const wetL = lineL[slot]!;
|
|
471
|
-
const wetR = lineR[slot]!;
|
|
472
|
-
const dryL = left[index]!;
|
|
473
|
-
const dryR = right[index]!;
|
|
474
|
-
lineL[slot] = dryL + wetR * feedback;
|
|
475
|
-
lineR[slot] = dryR + wetL * feedback;
|
|
476
|
-
left[index] = dryL + wetL * mix;
|
|
477
|
-
right[index] = dryR + wetR * mix;
|
|
785
|
+
// A stereo voice pans its own right channel the same way.
|
|
786
|
+
right[index] = (dryRight ? dryRight[index]! : sample) * gainR;
|
|
478
787
|
}
|
|
479
788
|
}
|
|
480
789
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const REVERB_WET_SCALE = 3;
|
|
790
|
+
type WavetableHook = Readonly<{
|
|
791
|
+
/** Joins the stem key: a different or missing table re-renders. */
|
|
792
|
+
id: string;
|
|
793
|
+
oscillatorFor: NonNullable<VoiceContext["oscillatorFor"]>;
|
|
794
|
+
}>;
|
|
487
795
|
|
|
488
796
|
/**
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
* feedback (0.7..0.98) and darkens the tail; the wet signal is added to the
|
|
492
|
-
* dry stereo pair at `mix`.
|
|
797
|
+
* Wavetable hook: for a wavetable track, each note's oscillator reads the
|
|
798
|
+
* track's table (`src/audio/wavetable.ts`); the synth voice does the rest.
|
|
493
799
|
*/
|
|
494
|
-
function
|
|
495
|
-
left: Float64Array,
|
|
496
|
-
right: Float64Array,
|
|
800
|
+
function wavetableHook(
|
|
497
801
|
track: Track,
|
|
802
|
+
bank: SampleBank,
|
|
498
803
|
context: RenderContext,
|
|
499
|
-
):
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
for (const comb of side.combs) {
|
|
525
|
-
const delayed = comb.buffer[comb.index]!;
|
|
526
|
-
comb.store = delayed * (1 - damp) + comb.store * damp;
|
|
527
|
-
comb.buffer[comb.index] = input + comb.store * feedback;
|
|
528
|
-
comb.index = comb.index + 1 === comb.buffer.length ? 0 : comb.index + 1;
|
|
529
|
-
output += delayed;
|
|
530
|
-
}
|
|
531
|
-
for (const allpass of side.allpasses) {
|
|
532
|
-
const delayed = allpass.buffer[allpass.index]!;
|
|
533
|
-
allpass.buffer[allpass.index] = output + delayed * 0.5;
|
|
534
|
-
output = delayed - output;
|
|
535
|
-
allpass.index =
|
|
536
|
-
allpass.index + 1 === allpass.buffer.length ? 0 : allpass.index + 1;
|
|
537
|
-
}
|
|
538
|
-
return output;
|
|
804
|
+
): WavetableHook | undefined {
|
|
805
|
+
if (!isWavetableInstrument(track.instrument)) return undefined;
|
|
806
|
+
const settings = wavetableOf(track);
|
|
807
|
+
const table = tableFor(track, settings, bank.wavetables);
|
|
808
|
+
// A pack table that failed to load renders silent (the problem is reported).
|
|
809
|
+
if (!table)
|
|
810
|
+
return {
|
|
811
|
+
id: `missing:${settings.table.src}`,
|
|
812
|
+
oscillatorFor: () => () => () => 0,
|
|
813
|
+
};
|
|
814
|
+
const lane = track.wtAutomation ?? [];
|
|
815
|
+
const positionAt =
|
|
816
|
+
lane.length > 0
|
|
817
|
+
? (tick: number) => interpolateAutomation(lane, tick, settings.wt ?? 0)
|
|
818
|
+
: undefined;
|
|
819
|
+
return {
|
|
820
|
+
id: table.id,
|
|
821
|
+
oscillatorFor: (note) =>
|
|
822
|
+
wavetableOscillator(table, settings, note, {
|
|
823
|
+
sampleRate: context.sampleRate,
|
|
824
|
+
samplesPerTick: context.samplesPerTick,
|
|
825
|
+
secondsPerTick: context.samplesPerTick / context.sampleRate,
|
|
826
|
+
gate: noteSpan(note, context).length / context.sampleRate,
|
|
827
|
+
positionAt,
|
|
828
|
+
}),
|
|
539
829
|
};
|
|
540
|
-
for (let index = 0; index < left.length; index += 1) {
|
|
541
|
-
const input = (left[index]! + right[index]!) * REVERB_INPUT_GAIN;
|
|
542
|
-
left[index]! += process(sides[0], input) * wet;
|
|
543
|
-
right[index]! += process(sides[1], input) * wet;
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/** FNV-1a seeded mulberry32: integer-only, so identical on every platform. */
|
|
548
|
-
function seededRandom(seed: string): () => number {
|
|
549
|
-
let hash = 0x811c9dc5;
|
|
550
|
-
for (let index = 0; index < seed.length; index += 1) {
|
|
551
|
-
hash ^= seed.charCodeAt(index);
|
|
552
|
-
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
553
|
-
}
|
|
554
|
-
let state = hash;
|
|
555
|
-
return () => {
|
|
556
|
-
state = (state + 0x6d2b79f5) >>> 0;
|
|
557
|
-
let value = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
558
|
-
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
|
|
559
|
-
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
|
|
560
|
-
};
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/** Resolve a piecewise-linear automation lane, holding the static value before its first point. */
|
|
564
|
-
function interpolateAutomation(
|
|
565
|
-
points: readonly AutomationPoint[],
|
|
566
|
-
tick: number,
|
|
567
|
-
fallback: number,
|
|
568
|
-
): number {
|
|
569
|
-
if (points.length === 0 || !Number.isFinite(tick)) return fallback;
|
|
570
|
-
const first = points[0]!;
|
|
571
|
-
if (tick < first.tick) return fallback;
|
|
572
|
-
const last = points[points.length - 1]!;
|
|
573
|
-
if (tick >= last.tick) return last.value;
|
|
574
|
-
for (let index = 1; index < points.length; index += 1) {
|
|
575
|
-
const right = points[index]!;
|
|
576
|
-
if (tick > right.tick) continue;
|
|
577
|
-
const left = points[index - 1]!;
|
|
578
|
-
const span = right.tick - left.tick;
|
|
579
|
-
const ratio = span <= 0 ? 1 : (tick - left.tick) / span;
|
|
580
|
-
return left.value + (right.value - left.value) * ratio;
|
|
581
|
-
}
|
|
582
|
-
return last.value;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/** A tiny deterministic instrument bank. Names come from the score's track metadata. */
|
|
586
|
-
function synthSample(
|
|
587
|
-
instrument: string,
|
|
588
|
-
phase: number,
|
|
589
|
-
frequency: number,
|
|
590
|
-
sampleRate: number,
|
|
591
|
-
): number {
|
|
592
|
-
const name = instrument.trim().toLowerCase();
|
|
593
|
-
const cycle = phase - Math.floor(phase);
|
|
594
|
-
const sine = Math.sin(2 * Math.PI * phase);
|
|
595
|
-
if (name.includes("square")) return cycle < 0.5 ? 1 : -1;
|
|
596
|
-
if (name.includes("saw")) return 2 * cycle - 1;
|
|
597
|
-
if (name.includes("triangle")) return 1 - 4 * Math.abs(cycle - 0.5);
|
|
598
|
-
if (name.includes("bass")) {
|
|
599
|
-
// A rounded fundamental plus a quiet octave gives bass tracks useful weight.
|
|
600
|
-
return Math.tanh(
|
|
601
|
-
0.9 * Math.sin(2 * Math.PI * phase) +
|
|
602
|
-
0.25 * Math.sin(4 * Math.PI * phase),
|
|
603
|
-
);
|
|
604
|
-
}
|
|
605
|
-
if (name.includes("piano") || name.includes("pluck")) {
|
|
606
|
-
// Add stable harmonics; the envelope above supplies the note decay.
|
|
607
|
-
const harmonic =
|
|
608
|
-
Math.sin(4 * Math.PI * phase) * 0.28 +
|
|
609
|
-
Math.sin(6 * Math.PI * phase) * 0.12;
|
|
610
|
-
return Math.tanh(sine + harmonic);
|
|
611
|
-
}
|
|
612
|
-
// Unknown instruments deliberately fall back to the original sine voice.
|
|
613
|
-
return sine;
|
|
614
830
|
}
|
|
615
831
|
|
|
616
832
|
export function encodeWav(
|