@hraness/dawg 0.0.0-stage → 0.3.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 +126 -0
- package/DAWG.md +327 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/core/diff.ts +249 -0
- package/core/drums.ts +102 -0
- package/core/key.ts +43 -0
- package/core/loop.ts +78 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +1388 -0
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +45 -4
- package/src/agent/agent.ts +853 -0
- package/src/agent/brief.ts +160 -0
- package/src/agent/gateway.ts +441 -0
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +157 -0
- package/src/agent/planner.ts +259 -0
- package/src/agent/provider.ts +454 -0
- package/src/agent/sse.ts +114 -0
- package/src/agent/tools.ts +1373 -0
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +262 -0
- package/src/agent/xcb.ts +579 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +841 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +134 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +861 -0
- package/src/auth/cli.ts +231 -0
- package/src/auth/credentials.ts +411 -0
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +1191 -0
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +207 -0
- package/src/auth/tui.ts +107 -0
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +69 -0
- package/src/commands/music.ts +461 -0
- package/src/commands/sample.ts +302 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +2209 -0
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -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 +292 -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/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 +121 -0
- package/src/session/attach.ts +181 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +740 -0
- package/src/session/delta.ts +249 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +430 -0
- package/src/session/port.ts +481 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +168 -0
- package/src/session/store.ts +581 -0
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +364 -0
- package/tui/app.ts +1372 -0
- package/tui/drums.ts +65 -0
- package/tui/highway.ts +921 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +80 -0
- package/tui/play-strip.ts +143 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
package/src/audio/wav.ts
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SCORE_LIMITS,
|
|
3
|
+
isSamplerInstrument,
|
|
4
|
+
isTrackAudible,
|
|
5
|
+
type AutomationPoint,
|
|
6
|
+
type Note,
|
|
7
|
+
type Track,
|
|
8
|
+
type TrackScore,
|
|
9
|
+
} from "../../core/score.ts";
|
|
10
|
+
import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
|
|
11
|
+
import {
|
|
12
|
+
planSamplerVoices,
|
|
13
|
+
renderSamplerVoices,
|
|
14
|
+
samplerTailSeconds,
|
|
15
|
+
} from "./sampler.ts";
|
|
16
|
+
import { EMPTY_SAMPLE_BANK, sampleKey, type SampleBank } from "./samples.ts";
|
|
17
|
+
|
|
18
|
+
export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
|
|
19
|
+
|
|
20
|
+
export type RenderOptions = WavOptions &
|
|
21
|
+
Readonly<{
|
|
22
|
+
/**
|
|
23
|
+
* Render exactly one loop and fold every tail (release, delay, reverb)
|
|
24
|
+
* back onto the loop start, so the buffer repeats seamlessly. This is
|
|
25
|
+
* what the streaming engine plays; exports keep the default one-shot.
|
|
26
|
+
*/
|
|
27
|
+
loop?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Decoded sampler voices (`SampleLibrary.load`). Without it sampler
|
|
30
|
+
* tracks render silent; scores without samplers are unaffected.
|
|
31
|
+
*/
|
|
32
|
+
samples?: SampleBank;
|
|
33
|
+
}>;
|
|
34
|
+
|
|
35
|
+
/** Interleaved stereo 16-bit PCM plus its frame count. */
|
|
36
|
+
export type RenderedAudio = Readonly<{
|
|
37
|
+
sampleRate: number;
|
|
38
|
+
channels: 2;
|
|
39
|
+
frames: number;
|
|
40
|
+
pcm: Int16Array;
|
|
41
|
+
}>;
|
|
42
|
+
|
|
43
|
+
/** Output channel count for every render and export. */
|
|
44
|
+
export const RENDER_CHANNELS = 2 as const;
|
|
45
|
+
export const DEFAULT_SAMPLE_RATE = 22_050;
|
|
46
|
+
|
|
47
|
+
/** Names understood by the deterministic local voice bank and the agent. */
|
|
48
|
+
export const AVAILABLE_INSTRUMENTS = Object.freeze([
|
|
49
|
+
"sine",
|
|
50
|
+
"piano",
|
|
51
|
+
"pluck",
|
|
52
|
+
"bass",
|
|
53
|
+
"saw",
|
|
54
|
+
"square",
|
|
55
|
+
"triangle",
|
|
56
|
+
"kit",
|
|
57
|
+
] as const);
|
|
58
|
+
|
|
59
|
+
/** Per-track effects understood by the renderer and the agent. */
|
|
60
|
+
export const AVAILABLE_EFFECTS = Object.freeze([
|
|
61
|
+
"pan (equal-power stereo: -1 left .. 1 right, automatable)",
|
|
62
|
+
"filter (low-pass: cutoff 20..20000 Hz and resonance 0..1, both automatable)",
|
|
63
|
+
"delay (stereo send: beats 0.0625..4, feedback 0..0.9 and mix 0..1 automatable)",
|
|
64
|
+
"reverb (stereo send: mix 0..1, size 0..1)",
|
|
65
|
+
] as const);
|
|
66
|
+
|
|
67
|
+
/** Longest drum one-shot, in seconds; hits ring past their note length. */
|
|
68
|
+
const MAX_DRUM_SECONDS = 0.6;
|
|
69
|
+
/** Effect parameters are refreshed at this sample interval when automated. */
|
|
70
|
+
const CONTROL_SAMPLES = 32;
|
|
71
|
+
/** One-shot renders keep this much ring-out after the last bar. */
|
|
72
|
+
const ONE_SHOT_TAIL_SECONDS = 0.35;
|
|
73
|
+
/** Loop renders fold at most this much tail back onto the loop start. */
|
|
74
|
+
const MAX_LOOP_TAIL_SECONDS = 8;
|
|
75
|
+
|
|
76
|
+
type RenderContext = Readonly<{
|
|
77
|
+
score: TrackScore;
|
|
78
|
+
sampleRate: number;
|
|
79
|
+
samples: number;
|
|
80
|
+
samplesPerTick: number;
|
|
81
|
+
}>;
|
|
82
|
+
|
|
83
|
+
/** Exact (fractional) loop length in frames at a sample rate. */
|
|
84
|
+
export function loopFrames(score: TrackScore, sampleRate: number): number {
|
|
85
|
+
return (score.bars * score.beatsPerBar * 60 * sampleRate) / score.tempoBpm;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function clampSampleRate(sampleRate?: number): number {
|
|
89
|
+
return Math.max(
|
|
90
|
+
8_000,
|
|
91
|
+
Math.min(48_000, Math.floor(sampleRate ?? DEFAULT_SAMPLE_RATE)),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Render the bounded score to a stereo 16-bit PCM WAV. */
|
|
96
|
+
export function renderScoreWav(
|
|
97
|
+
score: TrackScore,
|
|
98
|
+
options: WavOptions & Pick<RenderOptions, "samples"> = {},
|
|
99
|
+
): Uint8Array {
|
|
100
|
+
const audio = renderScorePcm(score, options);
|
|
101
|
+
return encodeWav(audio.pcm, audio.sampleRate, RENDER_CHANNELS);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Render the score to interleaved stereo PCM. Every step is plain float64
|
|
106
|
+
* arithmetic in a fixed order with seeded noise, so identical scores render
|
|
107
|
+
* byte-identical buffers on every run.
|
|
108
|
+
*/
|
|
109
|
+
export function renderScorePcm(
|
|
110
|
+
score: TrackScore,
|
|
111
|
+
options: RenderOptions = {},
|
|
112
|
+
): RenderedAudio {
|
|
113
|
+
return new StemRenderer({ maxCacheBytes: 0 }).render(score, options);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Default byte budget for cached per-track stems. */
|
|
117
|
+
export const DEFAULT_STEM_CACHE_BYTES = 256 * 1024 * 1024;
|
|
118
|
+
|
|
119
|
+
type Stem = {
|
|
120
|
+
key: string;
|
|
121
|
+
left: Float64Array;
|
|
122
|
+
right: Float64Array;
|
|
123
|
+
bytes: number;
|
|
124
|
+
used: number;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The renderer behind `renderScorePcm`, with a per-track stem cache. Each
|
|
129
|
+
* track's stereo stem (after its own effects) is keyed by everything that
|
|
130
|
+
* shapes it: the track settings, its notes, tempo, tick resolution, sample
|
|
131
|
+
* rate and buffer length. Edits re-render only the tracks whose key changed
|
|
132
|
+
* and sum the cached rest in the original order, so the result is
|
|
133
|
+
* byte-identical to a cold render; mute and solo only pick which stems are
|
|
134
|
+
* summed. Scratch buffers are reused across renders.
|
|
135
|
+
*/
|
|
136
|
+
export class StemRenderer {
|
|
137
|
+
private readonly maxCacheBytes: number;
|
|
138
|
+
private readonly stems = new Map<string, Stem>();
|
|
139
|
+
private cacheBytes = 0;
|
|
140
|
+
private renders = 0;
|
|
141
|
+
private scratch: {
|
|
142
|
+
dry: Float64Array;
|
|
143
|
+
left: Float64Array;
|
|
144
|
+
right: Float64Array;
|
|
145
|
+
mixL: Float64Array;
|
|
146
|
+
mixR: Float64Array;
|
|
147
|
+
} = {
|
|
148
|
+
dry: new Float64Array(0),
|
|
149
|
+
left: new Float64Array(0),
|
|
150
|
+
right: new Float64Array(0),
|
|
151
|
+
mixL: new Float64Array(0),
|
|
152
|
+
mixR: new Float64Array(0),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
public constructor(options: Readonly<{ maxCacheBytes?: number }> = {}) {
|
|
156
|
+
this.maxCacheBytes = Math.max(
|
|
157
|
+
0,
|
|
158
|
+
options.maxCacheBytes ?? DEFAULT_STEM_CACHE_BYTES,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Cached stems and their total size, for tests and diagnostics. */
|
|
163
|
+
public get cache(): Readonly<{ stems: number; bytes: number }> {
|
|
164
|
+
return { stems: this.stems.size, bytes: this.cacheBytes };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
public render(score: TrackScore, options: RenderOptions = {}): RenderedAudio {
|
|
168
|
+
const sampleRate = clampSampleRate(options.sampleRate);
|
|
169
|
+
const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
|
|
170
|
+
const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
|
|
171
|
+
const reverbTail = Math.max(
|
|
172
|
+
0,
|
|
173
|
+
...score.tracks.map((track) =>
|
|
174
|
+
track.reverb && track.reverb.mix > 0 ? 1 + 3 * track.reverb.size : 0,
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
const bank = options.samples ?? EMPTY_SAMPLE_BANK;
|
|
178
|
+
// Zero without sampler voices, so synth-only renders are unchanged.
|
|
179
|
+
const samplerTail = Math.min(
|
|
180
|
+
MAX_LOOP_TAIL_SECONDS,
|
|
181
|
+
samplerTailSeconds(score, bank, sampleRate),
|
|
182
|
+
);
|
|
183
|
+
let frames: number;
|
|
184
|
+
let samples: number;
|
|
185
|
+
if (options.loop) {
|
|
186
|
+
frames = Math.max(
|
|
187
|
+
1,
|
|
188
|
+
Math.min(
|
|
189
|
+
Math.round(loopFrames(score, sampleRate)),
|
|
190
|
+
Math.ceil(maxSeconds * sampleRate),
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
const tailSeconds = Math.min(
|
|
194
|
+
MAX_LOOP_TAIL_SECONDS,
|
|
195
|
+
Math.max(MAX_DRUM_SECONDS, samplerTail) +
|
|
196
|
+
0.1 +
|
|
197
|
+
reverbTail +
|
|
198
|
+
delayTailSeconds(score),
|
|
199
|
+
);
|
|
200
|
+
samples = frames + Math.ceil(tailSeconds * sampleRate);
|
|
201
|
+
} else {
|
|
202
|
+
const seconds = Math.min(
|
|
203
|
+
maxSeconds,
|
|
204
|
+
loopSeconds + Math.max(ONE_SHOT_TAIL_SECONDS, samplerTail) + reverbTail,
|
|
205
|
+
);
|
|
206
|
+
frames = Math.max(1, Math.ceil(seconds * sampleRate));
|
|
207
|
+
samples = frames;
|
|
208
|
+
}
|
|
209
|
+
const context: RenderContext = {
|
|
210
|
+
score,
|
|
211
|
+
sampleRate,
|
|
212
|
+
samples,
|
|
213
|
+
samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
|
|
214
|
+
};
|
|
215
|
+
this.renders += 1;
|
|
216
|
+
const { dry, left, right, mixL, mixR } = this.scratchFor(samples);
|
|
217
|
+
mixL.fill(0);
|
|
218
|
+
mixR.fill(0);
|
|
219
|
+
const tracks = new Map(score.tracks.map((track) => [track.id, track]));
|
|
220
|
+
const groups = new Map<string, Note[]>();
|
|
221
|
+
for (const note of score.notes) {
|
|
222
|
+
const group = groups.get(note.trackId);
|
|
223
|
+
if (group) group.push(note);
|
|
224
|
+
else groups.set(note.trackId, [note]);
|
|
225
|
+
}
|
|
226
|
+
// Tracks render one at a time; the sum order (first note per track) is
|
|
227
|
+
// part of the output, so cached and cold renders keep it.
|
|
228
|
+
for (const [trackId, notes] of groups) {
|
|
229
|
+
if (!isTrackAudible(score, trackId)) continue;
|
|
230
|
+
const track = tracks.get(trackId);
|
|
231
|
+
const sampler = isSamplerInstrument(track?.instrument);
|
|
232
|
+
const key = stemKey(track, notes, context, sampler ? bank : undefined);
|
|
233
|
+
let stem = this.stems.get(trackId);
|
|
234
|
+
if (stem?.key === key) stem.used = this.renders;
|
|
235
|
+
else {
|
|
236
|
+
const caching = this.maxCacheBytes > 0;
|
|
237
|
+
const target = caching
|
|
238
|
+
? {
|
|
239
|
+
left: new Float64Array(samples),
|
|
240
|
+
right: new Float64Array(samples),
|
|
241
|
+
}
|
|
242
|
+
: { left, right };
|
|
243
|
+
dry.fill(0);
|
|
244
|
+
if (sampler) {
|
|
245
|
+
if (track) renderSamplerNotes(dry, notes, track, context, bank);
|
|
246
|
+
} else {
|
|
247
|
+
const drums = isDrumInstrument(track?.instrument);
|
|
248
|
+
for (const note of notes) {
|
|
249
|
+
if (drums) renderDrumNote(dry, note, track, context);
|
|
250
|
+
else renderToneNote(dry, note, track, context);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (track) applyLowPass(dry, track, context);
|
|
254
|
+
applyPan(dry, target.left, target.right, track, context);
|
|
255
|
+
if (track) {
|
|
256
|
+
applyDelay(target.left, target.right, track, context);
|
|
257
|
+
applyReverb(target.left, target.right, track, context);
|
|
258
|
+
}
|
|
259
|
+
stem = {
|
|
260
|
+
key,
|
|
261
|
+
left: target.left,
|
|
262
|
+
right: target.right,
|
|
263
|
+
bytes: target.left.byteLength + target.right.byteLength,
|
|
264
|
+
used: this.renders,
|
|
265
|
+
};
|
|
266
|
+
if (caching) this.store(trackId, stem);
|
|
267
|
+
}
|
|
268
|
+
for (let index = 0; index < samples; index += 1) {
|
|
269
|
+
mixL[index]! += stem.left[index]!;
|
|
270
|
+
mixR[index]! += stem.right[index]!;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Stems of tracks that left the score are not worth keeping; muted and
|
|
274
|
+
// unsoloed tracks keep theirs so toggling them back is free.
|
|
275
|
+
for (const trackId of [...this.stems.keys()])
|
|
276
|
+
if (!groups.has(trackId)) this.evict(trackId);
|
|
277
|
+
if (samples > frames) {
|
|
278
|
+
// Linear effects superpose, so folding the tail onto the start yields
|
|
279
|
+
// the steady state of the loop playing forever.
|
|
280
|
+
for (let index = frames; index < samples; index += 1) {
|
|
281
|
+
mixL[index % frames]! += mixL[index]!;
|
|
282
|
+
mixR[index % frames]! += mixR[index]!;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const pcm = new Int16Array(frames * RENDER_CHANNELS);
|
|
286
|
+
for (let index = 0; index < frames; index += 1) {
|
|
287
|
+
pcm[index * 2] = clamp16(mixL[index]! * 32767);
|
|
288
|
+
pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
|
|
289
|
+
}
|
|
290
|
+
return Object.freeze({
|
|
291
|
+
sampleRate,
|
|
292
|
+
channels: RENDER_CHANNELS,
|
|
293
|
+
frames,
|
|
294
|
+
pcm,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private scratchFor(samples: number) {
|
|
299
|
+
if (this.scratch.dry.length < samples) {
|
|
300
|
+
this.scratch = {
|
|
301
|
+
dry: new Float64Array(samples),
|
|
302
|
+
left: new Float64Array(samples),
|
|
303
|
+
right: new Float64Array(samples),
|
|
304
|
+
mixL: new Float64Array(samples),
|
|
305
|
+
mixR: new Float64Array(samples),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const view = (buffer: Float64Array) => buffer.subarray(0, samples);
|
|
309
|
+
return {
|
|
310
|
+
dry: view(this.scratch.dry),
|
|
311
|
+
left: view(this.scratch.left),
|
|
312
|
+
right: view(this.scratch.right),
|
|
313
|
+
mixL: view(this.scratch.mixL),
|
|
314
|
+
mixR: view(this.scratch.mixR),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private store(trackId: string, stem: Stem): void {
|
|
319
|
+
this.evict(trackId);
|
|
320
|
+
// Make room by dropping the least recently used stems from other
|
|
321
|
+
// renders; a score whose stems exceed the budget is simply not cached.
|
|
322
|
+
while (
|
|
323
|
+
this.cacheBytes + stem.bytes > this.maxCacheBytes &&
|
|
324
|
+
this.stems.size > 0
|
|
325
|
+
) {
|
|
326
|
+
let oldest: string | undefined;
|
|
327
|
+
for (const [id, candidate] of this.stems)
|
|
328
|
+
if (
|
|
329
|
+
candidate.used !== this.renders &&
|
|
330
|
+
(oldest === undefined ||
|
|
331
|
+
candidate.used < this.stems.get(oldest)!.used)
|
|
332
|
+
)
|
|
333
|
+
oldest = id;
|
|
334
|
+
if (oldest === undefined) break;
|
|
335
|
+
this.evict(oldest);
|
|
336
|
+
}
|
|
337
|
+
if (this.cacheBytes + stem.bytes > this.maxCacheBytes) return;
|
|
338
|
+
this.stems.set(trackId, stem);
|
|
339
|
+
this.cacheBytes += stem.bytes;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private evict(trackId: string): void {
|
|
343
|
+
const stem = this.stems.get(trackId);
|
|
344
|
+
if (!stem) return;
|
|
345
|
+
this.stems.delete(trackId);
|
|
346
|
+
this.cacheBytes -= stem.bytes;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Everything a track's stem depends on, except mute and solo. */
|
|
351
|
+
function stemKey(
|
|
352
|
+
track: Track | undefined,
|
|
353
|
+
notes: readonly Note[],
|
|
354
|
+
context: RenderContext,
|
|
355
|
+
bank?: SampleBank,
|
|
356
|
+
): string {
|
|
357
|
+
let settings: Record<string, unknown> | null = null;
|
|
358
|
+
if (track) {
|
|
359
|
+
const { muted: _muted, solo: _solo, ...rest } = track;
|
|
360
|
+
settings = rest;
|
|
361
|
+
}
|
|
362
|
+
return JSON.stringify([
|
|
363
|
+
settings,
|
|
364
|
+
notes,
|
|
365
|
+
context.score.tempoBpm,
|
|
366
|
+
context.score.ticksPerBeat,
|
|
367
|
+
context.sampleRate,
|
|
368
|
+
context.samples,
|
|
369
|
+
// Sampler stems also depend on the decoded files: a replaced or missing
|
|
370
|
+
// sample changes the key even when the score did not change.
|
|
371
|
+
...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
|
|
372
|
+
]);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function samplerVoiceDigest(track: Track, bank: SampleBank): string[] {
|
|
376
|
+
return Object.keys(track.sampler?.voices ?? {})
|
|
377
|
+
.sort()
|
|
378
|
+
.map((voice) => {
|
|
379
|
+
const sample = bank.voices.get(sampleKey(track.id, voice));
|
|
380
|
+
return sample
|
|
381
|
+
? `${voice}:${sample.sha256}:${sample.sampleRate}:${sample.frames}`
|
|
382
|
+
: `${voice}:-`;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Sample voices of a sampler track into its mono dry buffer. */
|
|
387
|
+
function renderSamplerNotes(
|
|
388
|
+
target: Float64Array,
|
|
389
|
+
notes: readonly Note[],
|
|
390
|
+
track: Track,
|
|
391
|
+
context: RenderContext,
|
|
392
|
+
bank: SampleBank,
|
|
393
|
+
): void {
|
|
394
|
+
const timing = { score: context.score, sampleRate: context.sampleRate };
|
|
395
|
+
renderSamplerVoices(
|
|
396
|
+
target,
|
|
397
|
+
planSamplerVoices(track, notes, bank, timing),
|
|
398
|
+
timing,
|
|
399
|
+
(tick) => trackGainAt(track, tick),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Seconds until the slowest delay decays below -60 dB, bounded. */
|
|
404
|
+
function delayTailSeconds(score: TrackScore): number {
|
|
405
|
+
let longest = 0;
|
|
406
|
+
for (const track of score.tracks) {
|
|
407
|
+
if (!track.delay) continue;
|
|
408
|
+
const feedback = Math.max(
|
|
409
|
+
track.delay.feedback,
|
|
410
|
+
...(track.delayFeedbackAutomation ?? []).map((point) => point.value),
|
|
411
|
+
);
|
|
412
|
+
const repeats =
|
|
413
|
+
feedback <= 0.001
|
|
414
|
+
? 1
|
|
415
|
+
: Math.min(64, Math.log(0.001) / Math.log(feedback));
|
|
416
|
+
longest = Math.max(
|
|
417
|
+
longest,
|
|
418
|
+
((track.delay.beats * 60) / score.tempoBpm) * (repeats + 1),
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
return longest;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Track volume and volume automation at a score tick (pan is applied in stereo). */
|
|
425
|
+
function trackGainAt(track: Track | undefined, tick: number): number {
|
|
426
|
+
const trackGain = Math.max(0, Math.min(1, track?.volume ?? 1));
|
|
427
|
+
const automatedGain = interpolateAutomation(
|
|
428
|
+
track?.volumeAutomation ?? [],
|
|
429
|
+
tick,
|
|
430
|
+
1,
|
|
431
|
+
);
|
|
432
|
+
return trackGain * automatedGain;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function noteSpan(
|
|
436
|
+
note: Note,
|
|
437
|
+
context: RenderContext,
|
|
438
|
+
): { start: number; length: number } {
|
|
439
|
+
const { score, sampleRate } = context;
|
|
440
|
+
const start = Math.max(
|
|
441
|
+
0,
|
|
442
|
+
Math.floor(
|
|
443
|
+
(((note.startTick / score.ticksPerBeat) * 60) / score.tempoBpm) *
|
|
444
|
+
sampleRate,
|
|
445
|
+
),
|
|
446
|
+
);
|
|
447
|
+
const length = Math.max(
|
|
448
|
+
1,
|
|
449
|
+
Math.floor(
|
|
450
|
+
(((note.durationTicks / score.ticksPerBeat) * 60) / score.tempoBpm) *
|
|
451
|
+
sampleRate,
|
|
452
|
+
),
|
|
453
|
+
);
|
|
454
|
+
return { start, length };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function renderToneNote(
|
|
458
|
+
target: Float64Array,
|
|
459
|
+
note: Note,
|
|
460
|
+
track: Track | undefined,
|
|
461
|
+
context: RenderContext,
|
|
462
|
+
): void {
|
|
463
|
+
const { sampleRate, samples, samplesPerTick } = context;
|
|
464
|
+
const instrument = track?.instrument ?? "sine";
|
|
465
|
+
const { start, length } = noteSpan(note, context);
|
|
466
|
+
const end = Math.min(samples, start + length);
|
|
467
|
+
const frequency = 440 * 2 ** ((note.pitch - 69) / 12);
|
|
468
|
+
const velocity = Math.max(0, Math.min(1, note.velocity));
|
|
469
|
+
for (let index = start; index < end; index += 1) {
|
|
470
|
+
const elapsed = index - start;
|
|
471
|
+
const remaining = end - index;
|
|
472
|
+
const attack = Math.min(1, elapsed / Math.max(1, sampleRate * 0.012));
|
|
473
|
+
const release = Math.min(1, remaining / Math.max(1, sampleRate * 0.09));
|
|
474
|
+
const envelope =
|
|
475
|
+
Math.min(attack, release) *
|
|
476
|
+
velocity *
|
|
477
|
+
0.28 *
|
|
478
|
+
trackGainAt(track, note.startTick + elapsed / samplesPerTick);
|
|
479
|
+
const phase = (frequency * elapsed) / sampleRate;
|
|
480
|
+
target[index]! +=
|
|
481
|
+
synthSample(instrument, phase, frequency, sampleRate) * envelope;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Synthesize a drum one-shot. Noise comes from a PRNG seeded by the note's
|
|
487
|
+
* id and start tick, so a hit sounds identical on every render and in every
|
|
488
|
+
* window regardless of note order.
|
|
489
|
+
*/
|
|
490
|
+
function renderDrumNote(
|
|
491
|
+
target: Float64Array,
|
|
492
|
+
note: Note,
|
|
493
|
+
track: Track | undefined,
|
|
494
|
+
context: RenderContext,
|
|
495
|
+
): void {
|
|
496
|
+
const { sampleRate, samples, samplesPerTick } = context;
|
|
497
|
+
const { start } = noteSpan(note, context);
|
|
498
|
+
const end = Math.min(
|
|
499
|
+
samples,
|
|
500
|
+
start + Math.ceil(MAX_DRUM_SECONDS * sampleRate),
|
|
501
|
+
);
|
|
502
|
+
const voice = drumVoiceForPitch(note.pitch);
|
|
503
|
+
const random = seededRandom(`${note.id}:${note.startTick}`);
|
|
504
|
+
const velocity = Math.max(0, Math.min(1, note.velocity));
|
|
505
|
+
let phase = 0;
|
|
506
|
+
let previousNoise = 0;
|
|
507
|
+
let band = 0;
|
|
508
|
+
for (let index = start; index < end; index += 1) {
|
|
509
|
+
const t = (index - start) / sampleRate;
|
|
510
|
+
const noise = random() * 2 - 1;
|
|
511
|
+
// A first difference is a cheap, stable high-pass for metallic hats.
|
|
512
|
+
const bright = noise - previousNoise;
|
|
513
|
+
previousNoise = noise;
|
|
514
|
+
let sample = 0;
|
|
515
|
+
if (voice === "kick") {
|
|
516
|
+
const frequency = 45 + 105 * Math.exp(-t * 28);
|
|
517
|
+
phase += frequency / sampleRate;
|
|
518
|
+
sample =
|
|
519
|
+
Math.sin(2 * Math.PI * phase) * Math.exp(-t * 7.5) +
|
|
520
|
+
noise * 0.12 * Math.exp(-t * 300);
|
|
521
|
+
} else if (voice === "tom") {
|
|
522
|
+
const frequency = 105 + 95 * Math.exp(-t * 18);
|
|
523
|
+
phase += frequency / sampleRate;
|
|
524
|
+
sample = Math.sin(2 * Math.PI * phase) * Math.exp(-t * 9);
|
|
525
|
+
} else if (voice === "snare") {
|
|
526
|
+
phase += 185 / sampleRate;
|
|
527
|
+
sample =
|
|
528
|
+
Math.sin(2 * Math.PI * phase) * 0.45 * Math.exp(-t * 22) +
|
|
529
|
+
noise * 0.7 * Math.exp(-t * 16);
|
|
530
|
+
} else if (voice === "clap") {
|
|
531
|
+
// Three quick bursts followed by a short diffuse tail.
|
|
532
|
+
band += 0.35 * (bright - band);
|
|
533
|
+
const burst = t < 0.03 ? (Math.floor(t / 0.01) % 2 === 0 ? 1 : 0.35) : 0;
|
|
534
|
+
sample = band * (burst + 0.8 * Math.exp(-(t - 0.03) * 18) * +(t >= 0.03));
|
|
535
|
+
} else if (voice === "hat") {
|
|
536
|
+
sample = bright * 0.42 * Math.exp(-t * 60);
|
|
537
|
+
} else if (voice === "openhat") {
|
|
538
|
+
sample = bright * 0.36 * Math.exp(-t * 9);
|
|
539
|
+
} else {
|
|
540
|
+
sample =
|
|
541
|
+
(Math.sin(2 * Math.PI * 1_700 * t) * 0.6 +
|
|
542
|
+
Math.sin(2 * Math.PI * 820 * t) * 0.4) *
|
|
543
|
+
Math.exp(-t * 90);
|
|
544
|
+
}
|
|
545
|
+
const attack = Math.min(
|
|
546
|
+
1,
|
|
547
|
+
(index - start) / Math.max(1, sampleRate * 0.001),
|
|
548
|
+
);
|
|
549
|
+
const release = Math.min(
|
|
550
|
+
1,
|
|
551
|
+
(end - index) / Math.max(1, sampleRate * 0.005),
|
|
552
|
+
);
|
|
553
|
+
target[index]! +=
|
|
554
|
+
sample *
|
|
555
|
+
attack *
|
|
556
|
+
release *
|
|
557
|
+
velocity *
|
|
558
|
+
0.5 *
|
|
559
|
+
trackGainAt(track, note.startTick + (index - start) / samplesPerTick);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* RBJ biquad low-pass with static or automated cutoff and resonance.
|
|
565
|
+
* Resonance 0..1 maps to Q 0.707..8 and the cutoff is kept under 45% of the
|
|
566
|
+
* sample rate, so the filter is stable for every value the score accepts.
|
|
567
|
+
*/
|
|
568
|
+
function applyLowPass(
|
|
569
|
+
buffer: Float64Array,
|
|
570
|
+
track: Track,
|
|
571
|
+
context: RenderContext,
|
|
572
|
+
): void {
|
|
573
|
+
const automation = track.filterAutomation ?? [];
|
|
574
|
+
const resonanceLane = track.resonanceAutomation ?? [];
|
|
575
|
+
if (!track.filter && automation.length === 0 && resonanceLane.length === 0)
|
|
576
|
+
return;
|
|
577
|
+
const { sampleRate, samplesPerTick } = context;
|
|
578
|
+
const staticCutoff = track.filter?.cutoff ?? SCORE_LIMITS.maxFilterCutoff;
|
|
579
|
+
const staticResonance = track.filter?.resonance ?? 0;
|
|
580
|
+
const automated = automation.length > 0 || resonanceLane.length > 0;
|
|
581
|
+
let b0 = 0;
|
|
582
|
+
let b1 = 0;
|
|
583
|
+
let b2 = 0;
|
|
584
|
+
let a1 = 0;
|
|
585
|
+
let a2 = 0;
|
|
586
|
+
let z1 = 0;
|
|
587
|
+
let z2 = 0;
|
|
588
|
+
const update = (cutoff: number, resonance: number) => {
|
|
589
|
+
const q = 0.707 + Math.max(0, Math.min(1, resonance)) * 7.293;
|
|
590
|
+
const frequency = Math.max(
|
|
591
|
+
SCORE_LIMITS.minFilterCutoff,
|
|
592
|
+
Math.min(cutoff, sampleRate * 0.45),
|
|
593
|
+
);
|
|
594
|
+
const omega = (2 * Math.PI * frequency) / sampleRate;
|
|
595
|
+
const alpha = Math.sin(omega) / (2 * q);
|
|
596
|
+
const cos = Math.cos(omega);
|
|
597
|
+
const a0 = 1 + alpha;
|
|
598
|
+
b0 = (1 - cos) / 2 / a0;
|
|
599
|
+
b1 = (1 - cos) / a0;
|
|
600
|
+
b2 = b0;
|
|
601
|
+
a1 = (-2 * cos) / a0;
|
|
602
|
+
a2 = (1 - alpha) / a0;
|
|
603
|
+
};
|
|
604
|
+
update(staticCutoff, staticResonance);
|
|
605
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
606
|
+
if (automated && index % CONTROL_SAMPLES === 0) {
|
|
607
|
+
const tick = index / samplesPerTick;
|
|
608
|
+
update(
|
|
609
|
+
interpolateAutomation(automation, tick, staticCutoff),
|
|
610
|
+
interpolateAutomation(resonanceLane, tick, staticResonance),
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
const input = buffer[index]!;
|
|
614
|
+
const output = b0 * input + z1;
|
|
615
|
+
z1 = b1 * input - a1 * output + z2;
|
|
616
|
+
z2 = b2 * input - a2 * output;
|
|
617
|
+
buffer[index] = output;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Equal-power pan of the mono track into the stereo pair: left = cos(θ),
|
|
623
|
+
* right = sin(θ) with θ = (pan + 1)·π/4, so the summed power is constant
|
|
624
|
+
* and a centred track sits 3 dB down in each channel.
|
|
625
|
+
*/
|
|
626
|
+
function applyPan(
|
|
627
|
+
dry: Float64Array,
|
|
628
|
+
left: Float64Array,
|
|
629
|
+
right: Float64Array,
|
|
630
|
+
track: Track | undefined,
|
|
631
|
+
context: RenderContext,
|
|
632
|
+
): void {
|
|
633
|
+
const lane = track?.panAutomation ?? [];
|
|
634
|
+
const staticPan = track?.pan ?? 0;
|
|
635
|
+
const gains = (pan: number): [number, number] => {
|
|
636
|
+
const theta = ((Math.max(-1, Math.min(1, pan)) + 1) * Math.PI) / 4;
|
|
637
|
+
return [Math.cos(theta), Math.sin(theta)];
|
|
638
|
+
};
|
|
639
|
+
let [gainL, gainR] = gains(staticPan);
|
|
640
|
+
for (let index = 0; index < dry.length; index += 1) {
|
|
641
|
+
if (lane.length > 0 && index % CONTROL_SAMPLES === 0)
|
|
642
|
+
[gainL, gainR] = gains(
|
|
643
|
+
interpolateAutomation(lane, index / context.samplesPerTick, staticPan),
|
|
644
|
+
);
|
|
645
|
+
const sample = dry[index]!;
|
|
646
|
+
left[index] = sample * gainL;
|
|
647
|
+
right[index] = sample * gainR;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Tempo-synced stereo feedback delay. Each side echoes its own input and
|
|
653
|
+
* feeds back into the opposite side, so repeats ping-pong across the field.
|
|
654
|
+
* Feedback and mix follow their automation lanes when present.
|
|
655
|
+
*/
|
|
656
|
+
function applyDelay(
|
|
657
|
+
left: Float64Array,
|
|
658
|
+
right: Float64Array,
|
|
659
|
+
track: Track,
|
|
660
|
+
context: RenderContext,
|
|
661
|
+
): void {
|
|
662
|
+
const delay = track.delay;
|
|
663
|
+
if (!delay) return;
|
|
664
|
+
const feedbackLane = track.delayFeedbackAutomation ?? [];
|
|
665
|
+
const mixLane = track.delayMixAutomation ?? [];
|
|
666
|
+
if (delay.mix <= 0 && mixLane.length === 0) return;
|
|
667
|
+
const { score, sampleRate, samplesPerTick } = context;
|
|
668
|
+
const length = Math.max(
|
|
669
|
+
1,
|
|
670
|
+
Math.round(((delay.beats * 60) / score.tempoBpm) * sampleRate),
|
|
671
|
+
);
|
|
672
|
+
const lineL = new Float64Array(length);
|
|
673
|
+
const lineR = new Float64Array(length);
|
|
674
|
+
let feedback = delay.feedback;
|
|
675
|
+
let mix = delay.mix;
|
|
676
|
+
const automated = feedbackLane.length > 0 || mixLane.length > 0;
|
|
677
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
678
|
+
if (automated && index % CONTROL_SAMPLES === 0) {
|
|
679
|
+
const tick = index / samplesPerTick;
|
|
680
|
+
feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
|
|
681
|
+
mix = interpolateAutomation(mixLane, tick, delay.mix);
|
|
682
|
+
}
|
|
683
|
+
const slot = index % length;
|
|
684
|
+
const wetL = lineL[slot]!;
|
|
685
|
+
const wetR = lineR[slot]!;
|
|
686
|
+
const dryL = left[index]!;
|
|
687
|
+
const dryR = right[index]!;
|
|
688
|
+
lineL[slot] = dryL + wetR * feedback;
|
|
689
|
+
lineR[slot] = dryR + wetL * feedback;
|
|
690
|
+
left[index] = dryL + wetL * mix;
|
|
691
|
+
right[index] = dryR + wetR * mix;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Freeverb comb and allpass tunings at 44.1 kHz; the right side is spread. */
|
|
696
|
+
const COMB_TUNING = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617] as const;
|
|
697
|
+
const ALLPASS_TUNING = [556, 441, 341, 225] as const;
|
|
698
|
+
const STEREO_SPREAD = 23;
|
|
699
|
+
const REVERB_INPUT_GAIN = 0.015;
|
|
700
|
+
const REVERB_WET_SCALE = 3;
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Schroeder/Freeverb-style stereo reverb send: eight damped feedback combs
|
|
704
|
+
* in parallel, then four series allpasses, per channel. Size sets the comb
|
|
705
|
+
* feedback (0.7..0.98) and darkens the tail; the wet signal is added to the
|
|
706
|
+
* dry stereo pair at `mix`.
|
|
707
|
+
*/
|
|
708
|
+
function applyReverb(
|
|
709
|
+
left: Float64Array,
|
|
710
|
+
right: Float64Array,
|
|
711
|
+
track: Track,
|
|
712
|
+
context: RenderContext,
|
|
713
|
+
): void {
|
|
714
|
+
const reverb = track.reverb;
|
|
715
|
+
if (!reverb || reverb.mix <= 0) return;
|
|
716
|
+
const scale = context.sampleRate / 44_100;
|
|
717
|
+
const feedback = 0.7 + 0.28 * reverb.size;
|
|
718
|
+
const damp = 0.2 + 0.3 * reverb.size;
|
|
719
|
+
const wet = reverb.mix * REVERB_WET_SCALE;
|
|
720
|
+
const channel = (spread: number) => ({
|
|
721
|
+
combs: COMB_TUNING.map((tuning) => ({
|
|
722
|
+
buffer: new Float64Array(
|
|
723
|
+
Math.max(1, Math.round((tuning + spread) * scale)),
|
|
724
|
+
),
|
|
725
|
+
index: 0,
|
|
726
|
+
store: 0,
|
|
727
|
+
})),
|
|
728
|
+
allpasses: ALLPASS_TUNING.map((tuning) => ({
|
|
729
|
+
buffer: new Float64Array(
|
|
730
|
+
Math.max(1, Math.round((tuning + spread) * scale)),
|
|
731
|
+
),
|
|
732
|
+
index: 0,
|
|
733
|
+
})),
|
|
734
|
+
});
|
|
735
|
+
const sides = [channel(0), channel(STEREO_SPREAD)] as const;
|
|
736
|
+
const process = (side: (typeof sides)[number], input: number): number => {
|
|
737
|
+
let output = 0;
|
|
738
|
+
for (const comb of side.combs) {
|
|
739
|
+
const delayed = comb.buffer[comb.index]!;
|
|
740
|
+
comb.store = delayed * (1 - damp) + comb.store * damp;
|
|
741
|
+
comb.buffer[comb.index] = input + comb.store * feedback;
|
|
742
|
+
comb.index = comb.index + 1 === comb.buffer.length ? 0 : comb.index + 1;
|
|
743
|
+
output += delayed;
|
|
744
|
+
}
|
|
745
|
+
for (const allpass of side.allpasses) {
|
|
746
|
+
const delayed = allpass.buffer[allpass.index]!;
|
|
747
|
+
allpass.buffer[allpass.index] = output + delayed * 0.5;
|
|
748
|
+
output = delayed - output;
|
|
749
|
+
allpass.index =
|
|
750
|
+
allpass.index + 1 === allpass.buffer.length ? 0 : allpass.index + 1;
|
|
751
|
+
}
|
|
752
|
+
return output;
|
|
753
|
+
};
|
|
754
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
755
|
+
const input = (left[index]! + right[index]!) * REVERB_INPUT_GAIN;
|
|
756
|
+
left[index]! += process(sides[0], input) * wet;
|
|
757
|
+
right[index]! += process(sides[1], input) * wet;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** FNV-1a seeded mulberry32: integer-only, so identical on every platform. */
|
|
762
|
+
function seededRandom(seed: string): () => number {
|
|
763
|
+
let hash = 0x811c9dc5;
|
|
764
|
+
for (let index = 0; index < seed.length; index += 1) {
|
|
765
|
+
hash ^= seed.charCodeAt(index);
|
|
766
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
767
|
+
}
|
|
768
|
+
let state = hash;
|
|
769
|
+
return () => {
|
|
770
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
771
|
+
let value = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
772
|
+
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
|
|
773
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Resolve a piecewise-linear automation lane, holding the static value before its first point. */
|
|
778
|
+
function interpolateAutomation(
|
|
779
|
+
points: readonly AutomationPoint[],
|
|
780
|
+
tick: number,
|
|
781
|
+
fallback: number,
|
|
782
|
+
): number {
|
|
783
|
+
if (points.length === 0 || !Number.isFinite(tick)) return fallback;
|
|
784
|
+
const first = points[0]!;
|
|
785
|
+
if (tick < first.tick) return fallback;
|
|
786
|
+
const last = points[points.length - 1]!;
|
|
787
|
+
if (tick >= last.tick) return last.value;
|
|
788
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
789
|
+
const right = points[index]!;
|
|
790
|
+
if (tick > right.tick) continue;
|
|
791
|
+
const left = points[index - 1]!;
|
|
792
|
+
const span = right.tick - left.tick;
|
|
793
|
+
const ratio = span <= 0 ? 1 : (tick - left.tick) / span;
|
|
794
|
+
return left.value + (right.value - left.value) * ratio;
|
|
795
|
+
}
|
|
796
|
+
return last.value;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** A tiny deterministic instrument bank. Names come from the score's track metadata. */
|
|
800
|
+
function synthSample(
|
|
801
|
+
instrument: string,
|
|
802
|
+
phase: number,
|
|
803
|
+
frequency: number,
|
|
804
|
+
sampleRate: number,
|
|
805
|
+
): number {
|
|
806
|
+
const name = instrument.trim().toLowerCase();
|
|
807
|
+
const cycle = phase - Math.floor(phase);
|
|
808
|
+
const sine = Math.sin(2 * Math.PI * phase);
|
|
809
|
+
if (name.includes("square")) return cycle < 0.5 ? 1 : -1;
|
|
810
|
+
if (name.includes("saw")) return 2 * cycle - 1;
|
|
811
|
+
if (name.includes("triangle")) return 1 - 4 * Math.abs(cycle - 0.5);
|
|
812
|
+
if (name.includes("bass")) {
|
|
813
|
+
// A rounded fundamental plus a quiet octave gives bass tracks useful weight.
|
|
814
|
+
return Math.tanh(
|
|
815
|
+
0.9 * Math.sin(2 * Math.PI * phase) +
|
|
816
|
+
0.25 * Math.sin(4 * Math.PI * phase),
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
if (name.includes("piano") || name.includes("pluck")) {
|
|
820
|
+
// Add stable harmonics; the envelope above supplies the note decay.
|
|
821
|
+
const harmonic =
|
|
822
|
+
Math.sin(4 * Math.PI * phase) * 0.28 +
|
|
823
|
+
Math.sin(6 * Math.PI * phase) * 0.12;
|
|
824
|
+
return Math.tanh(sine + harmonic);
|
|
825
|
+
}
|
|
826
|
+
// Unknown instruments deliberately fall back to the original sine voice.
|
|
827
|
+
return sine;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export function encodeWav(
|
|
831
|
+
pcm: Int16Array,
|
|
832
|
+
sampleRate: number,
|
|
833
|
+
channels = 1,
|
|
834
|
+
): Uint8Array {
|
|
835
|
+
const dataBytes = pcm.byteLength;
|
|
836
|
+
const bytes = new Uint8Array(44 + dataBytes);
|
|
837
|
+
const view = new DataView(bytes.buffer);
|
|
838
|
+
writeAscii(bytes, 0, "RIFF");
|
|
839
|
+
view.setUint32(4, 36 + dataBytes, true);
|
|
840
|
+
writeAscii(bytes, 8, "WAVEfmt ");
|
|
841
|
+
view.setUint32(16, 16, true);
|
|
842
|
+
view.setUint16(20, 1, true);
|
|
843
|
+
view.setUint16(22, channels, true);
|
|
844
|
+
view.setUint32(24, sampleRate, true);
|
|
845
|
+
view.setUint32(28, sampleRate * 2 * channels, true);
|
|
846
|
+
view.setUint16(32, 2 * channels, true);
|
|
847
|
+
view.setUint16(34, 16, true);
|
|
848
|
+
writeAscii(bytes, 36, "data");
|
|
849
|
+
view.setUint32(40, dataBytes, true);
|
|
850
|
+
bytes.set(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength), 44);
|
|
851
|
+
return bytes;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function writeAscii(target: Uint8Array, offset: number, value: string): void {
|
|
855
|
+
for (let index = 0; index < value.length; index += 1)
|
|
856
|
+
target[offset + index] = value.charCodeAt(index);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function clamp16(value: number): number {
|
|
860
|
+
return Math.max(-32_768, Math.min(32_767, Math.round(value)));
|
|
861
|
+
}
|