@hraness/dawg 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/DAWG.md +592 -49
- package/README.md +4 -4
- package/core/chords.ts +492 -4
- package/core/diff.ts +9 -0
- package/core/expression.ts +143 -6
- package/core/fx.ts +689 -2
- package/core/granular.ts +619 -0
- package/core/instruments.ts +442 -0
- package/core/keys.ts +955 -0
- package/core/resonators.ts +862 -0
- package/core/score.ts +529 -8
- package/core/sdk/eval-child.ts +2 -0
- package/core/sdk/print.ts +247 -16
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2417 -40
- package/core/sections.ts +44 -10
- package/core/strings.ts +1080 -0
- package/core/winds.ts +652 -0
- package/guides/automation.md +1 -0
- package/guides/chords.md +3 -1
- package/guides/effects.md +5 -2
- package/guides/media.md +1 -1
- package/guides/performance.md +3 -0
- package/guides/resample.md +26 -0
- package/guides/sounds.md +7 -2
- package/guides/tempo.md +1 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +13 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +42 -2
- package/src/agent/preview-tool.ts +7 -1
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/tools.ts +726 -17
- package/src/agent/xcb-agent.ts +13 -0
- package/src/audio/arrange.ts +42 -3
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/envelope.ts +161 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/chain.ts +23 -3
- package/src/audio/effects/common.ts +14 -0
- package/src/audio/effects/convolution.ts +106 -1
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/engine.ts +56 -6
- package/src/audio/fit.ts +447 -0
- package/src/audio/granular.ts +995 -0
- package/src/audio/instrument-check.ts +137 -0
- package/src/audio/instruments.ts +140 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/electric.ts +420 -0
- package/src/audio/keys/engine.ts +485 -0
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +476 -0
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/live-worker.ts +56 -0
- package/src/audio/live.ts +361 -15
- package/src/audio/loudness.ts +65 -20
- package/src/audio/preview.ts +17 -1
- package/src/audio/resample.ts +285 -0
- package/src/audio/resonators.ts +295 -0
- package/src/audio/sampler.ts +328 -35
- package/src/audio/samples.ts +56 -8
- package/src/audio/strings/body.ts +263 -0
- package/src/audio/strings/bow.ts +656 -0
- package/src/audio/strings/engine.ts +615 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +200 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/warp.ts +61 -0
- package/src/audio/wav.ts +191 -19
- package/src/audio/winds/engine.ts +302 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +80 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/voice.ts +479 -0
- package/src/commands/expression.ts +111 -23
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +35 -1
- package/src/commands/granular.ts +432 -0
- package/src/commands/help.ts +159 -5
- package/src/commands/keys.ts +629 -0
- package/src/commands/modal.ts +310 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rig.ts +264 -0
- package/src/commands/sample.ts +52 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/string.ts +201 -0
- package/src/commands/strum.ts +473 -0
- package/src/commands/time.ts +4 -1
- package/src/commands/wind.ts +244 -0
- package/src/main.ts +339 -15
- package/src/project/check.ts +17 -0
- package/src/render.ts +49 -4
- package/src/session/presence.ts +16 -3
- package/src/tui/audition.ts +2 -2
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu.ts +934 -9
- package/src/tui/modal-menu.ts +145 -0
- package/src/tui/performance-menu.ts +42 -0
- package/src/tui/play-chords.ts +104 -3
- package/src/tui/play-mode.ts +1 -0
- package/src/tui/play-session.ts +70 -2
- package/src/tui/wind-menu.ts +144 -0
package/src/audio/sampler.ts
CHANGED
|
@@ -32,13 +32,22 @@ import {
|
|
|
32
32
|
type TrackScore,
|
|
33
33
|
} from "../../core/score.ts";
|
|
34
34
|
import { sampleKey, type DecodedSample, type SampleBank } from "./samples.ts";
|
|
35
|
+
import { seedHash } from "./dsp/rng.ts";
|
|
35
36
|
import {
|
|
36
37
|
barAt,
|
|
37
38
|
loopSecondsOf,
|
|
38
39
|
performedNotes,
|
|
39
40
|
secondsAtTick,
|
|
40
41
|
} from "../../core/tempo.ts";
|
|
41
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
beatFitMap,
|
|
44
|
+
sampleWarpFor,
|
|
45
|
+
spanFitMap,
|
|
46
|
+
warpedSpan,
|
|
47
|
+
type FitMap,
|
|
48
|
+
type SampleWarp,
|
|
49
|
+
} from "./warp.ts";
|
|
50
|
+
import { fittedBuffer, shiftedBuffer, windowAt } from "./fit.ts";
|
|
42
51
|
import {
|
|
43
52
|
performanceTimingFor,
|
|
44
53
|
tunedTiming,
|
|
@@ -55,6 +64,8 @@ const ATTACK_SECONDS = 0.001;
|
|
|
55
64
|
/** Fade at a natural end, a held release, a choke and a voice steal. */
|
|
56
65
|
const END_FADE_SECONDS = 0.003;
|
|
57
66
|
const RELEASE_SECONDS = 0.01;
|
|
67
|
+
/** Extra fitted frames past a note's reach (read interpolation). */
|
|
68
|
+
const REACH_MARGIN_FRAMES = 64;
|
|
58
69
|
const CHOKE_SECONDS = 0.005;
|
|
59
70
|
/** Loop seam crossfade (source time). */
|
|
60
71
|
const LOOP_CROSSFADE_SECONDS = 0.005;
|
|
@@ -96,6 +107,10 @@ export type SamplerVoice = {
|
|
|
96
107
|
readonly cents?: NotePerformance["cents"];
|
|
97
108
|
/** Half pedal: the level fades from `from` seconds. */
|
|
98
109
|
readonly damp?: NotePerformance["damp"];
|
|
110
|
+
/** `fadeInTime` in output frames (0.6.1); absent is the 1 ms anti-click. */
|
|
111
|
+
readonly attack?: number;
|
|
112
|
+
/** A resampled stereo file (0.6.1): plays its own left and right. */
|
|
113
|
+
readonly stereo?: boolean;
|
|
99
114
|
};
|
|
100
115
|
|
|
101
116
|
/**
|
|
@@ -150,38 +165,111 @@ function noteLengthFrames(note: Note, timing: SamplerTiming): number {
|
|
|
150
165
|
function voiceResolver(
|
|
151
166
|
track: Track,
|
|
152
167
|
tuning?: TuningTable,
|
|
153
|
-
): (
|
|
168
|
+
): (
|
|
169
|
+
pitch: number,
|
|
170
|
+
velocity: number,
|
|
171
|
+
) => { voice: string; ratio: number } | undefined {
|
|
154
172
|
const sampler = track.sampler!;
|
|
173
|
+
const pick = layerPicker(
|
|
174
|
+
track.id,
|
|
175
|
+
sampler.voices,
|
|
176
|
+
sampler.mode === "oneshot",
|
|
177
|
+
);
|
|
155
178
|
if (sampler.mode === "oneshot") {
|
|
156
179
|
const byPitch = new Map<number, string>();
|
|
157
180
|
for (const [voice, slot] of samplerVoiceSlots(sampler))
|
|
158
181
|
byPitch.set(slot, voice);
|
|
159
|
-
return (pitch) => {
|
|
160
|
-
const
|
|
182
|
+
return (pitch, velocity) => {
|
|
183
|
+
const slotted = byPitch.get(pitch);
|
|
184
|
+
if (slotted === undefined) return undefined;
|
|
185
|
+
const voice = pick(slotted, velocity);
|
|
161
186
|
return voice === undefined ? undefined : { voice, ratio: 1 };
|
|
162
187
|
};
|
|
163
188
|
}
|
|
164
189
|
// Keyed: the voice whose root is the highest at or below the pitch (a
|
|
165
|
-
// multi-sampled instrument), else the lowest root.
|
|
190
|
+
// multi-sampled instrument), else the lowest root. With velocity layers
|
|
191
|
+
// only the voices whose `vel` holds the note's velocity take part.
|
|
166
192
|
const keyed = Object.entries(sampler.voices)
|
|
167
|
-
.map(([voice, ref]) => ({ voice, root: ref.root ?? 60 }))
|
|
193
|
+
.map(([voice, ref]) => ({ voice, root: ref.root ?? 60, vel: ref.vel }))
|
|
168
194
|
.sort((a, b) => a.root - b.root || (a.voice < b.voice ? -1 : 1));
|
|
169
|
-
return (pitch) => {
|
|
170
|
-
let chosen
|
|
171
|
-
for (const candidate of keyed)
|
|
172
|
-
if (candidate.
|
|
195
|
+
return (pitch, velocity) => {
|
|
196
|
+
let chosen: (typeof keyed)[number] | undefined;
|
|
197
|
+
for (const candidate of keyed) {
|
|
198
|
+
if (!inLayer(candidate.vel, velocity)) continue;
|
|
199
|
+
if (chosen === undefined || candidate.root <= pitch) chosen = candidate;
|
|
200
|
+
}
|
|
173
201
|
if (!chosen) return undefined;
|
|
174
|
-
|
|
175
|
-
|
|
202
|
+
const voice = pick(chosen.voice, velocity) ?? chosen.voice;
|
|
203
|
+
if (!tuning) return { voice, ratio: 2 ** ((pitch - chosen.root) / 12) };
|
|
176
204
|
const hz = tuning.hz[pitch]!;
|
|
177
205
|
if (!(hz > 0)) return undefined;
|
|
178
206
|
return {
|
|
179
|
-
voice
|
|
207
|
+
voice,
|
|
180
208
|
ratio: hz / (440 * 2 ** ((chosen.root - 69) / 12)),
|
|
181
209
|
};
|
|
182
210
|
};
|
|
183
211
|
}
|
|
184
212
|
|
|
213
|
+
/** MIDI velocity (0..127) of a note velocity 0..1. */
|
|
214
|
+
export function midiVelocity(velocity: number): number {
|
|
215
|
+
return Math.max(0, Math.min(127, Math.round(velocity * 127)));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function inLayer(
|
|
219
|
+
vel: readonly [number, number] | undefined,
|
|
220
|
+
velocity: number,
|
|
221
|
+
): boolean {
|
|
222
|
+
if (!vel) return true;
|
|
223
|
+
const v = midiVelocity(velocity);
|
|
224
|
+
return v >= vel[0] && v <= vel[1];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Velocity layers and round robin (0.6.1, SFZ lovel/hivel and seq_length).
|
|
229
|
+
* A voice with `rr` stands for its whole group: of the group's voices that
|
|
230
|
+
* hold the note's velocity (and, keyed, share its root) the next one in
|
|
231
|
+
* name order plays. When no layer of the group holds the velocity, the
|
|
232
|
+
* nearest layer plays rather than nothing. Each group keeps its own
|
|
233
|
+
* counter in note order, starting at a turn seeded by the track id and
|
|
234
|
+
* the group, so a render is deterministic (A B A B) while two tracks
|
|
235
|
+
* with the same samples need not alternate in lockstep. A keyed voice
|
|
236
|
+
* without `rr` plays itself when its `vel` holds the velocity, else
|
|
237
|
+
* nothing; a one-shot voice without `rr` (its own pad) always plays.
|
|
238
|
+
*/
|
|
239
|
+
function layerPicker(
|
|
240
|
+
trackId: string,
|
|
241
|
+
voices: Readonly<Record<string, SampleRef>>,
|
|
242
|
+
oneshot: boolean,
|
|
243
|
+
): (voice: string, velocity: number) => string | undefined {
|
|
244
|
+
const counters = new Map<string, number>();
|
|
245
|
+
const names = Object.keys(voices).sort();
|
|
246
|
+
return (voice, velocity) => {
|
|
247
|
+
const ref = voices[voice]!;
|
|
248
|
+
if (ref.rr === undefined)
|
|
249
|
+
return oneshot || inLayer(ref.vel, velocity) ? voice : undefined;
|
|
250
|
+
const members = names.filter((name) => {
|
|
251
|
+
const other = voices[name]!;
|
|
252
|
+
return other.rr === ref.rr && (other.root ?? 60) === (ref.root ?? 60);
|
|
253
|
+
});
|
|
254
|
+
let group = members.filter((name) => inLayer(voices[name]!.vel, velocity));
|
|
255
|
+
if (group.length === 0) {
|
|
256
|
+
const v = midiVelocity(velocity);
|
|
257
|
+
const gap = (name: string) => {
|
|
258
|
+
const vel = voices[name]!.vel!;
|
|
259
|
+
return v < vel[0] ? vel[0] - v : v - vel[1];
|
|
260
|
+
};
|
|
261
|
+
const nearest = Math.min(...members.map(gap));
|
|
262
|
+
group = members.filter((name) => gap(name) === nearest);
|
|
263
|
+
}
|
|
264
|
+
if (group.length === 0) return undefined;
|
|
265
|
+
const key = `${ref.rr}:${ref.root ?? 60}:${group.join(",")}`;
|
|
266
|
+
const turn =
|
|
267
|
+
counters.get(key) ?? seedHash(`${trackId}:${key}`) % group.length;
|
|
268
|
+
counters.set(key, turn + 1);
|
|
269
|
+
return group[turn % group.length];
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
185
273
|
/** Plans every voice a sampler track plays, with choke and polyphony applied. */
|
|
186
274
|
export function planSamplerVoices(
|
|
187
275
|
track: Track,
|
|
@@ -214,35 +302,78 @@ export function planSamplerVoices(
|
|
|
214
302
|
voice.fade = Math.min(fade, end - voice.start);
|
|
215
303
|
};
|
|
216
304
|
for (const note of ordered) {
|
|
217
|
-
const resolved = resolve(note.pitch);
|
|
305
|
+
const resolved = resolve(note.pitch, note.velocity);
|
|
218
306
|
if (!resolved) continue;
|
|
219
307
|
// A note's cents repitch every voice, keyed or one-shot.
|
|
220
308
|
const target = note.cents
|
|
221
309
|
? { ...resolved, ratio: resolved.ratio * 2 ** (note.cents / 1200) }
|
|
222
310
|
: resolved;
|
|
223
311
|
const ref: SampleRef | undefined = sampler.voices[target.voice];
|
|
224
|
-
const
|
|
225
|
-
if (!ref || !
|
|
226
|
-
const speed = ref.speed ?? 1;
|
|
227
|
-
const regionStart = (ref.begin ?? 0) * sample.frames;
|
|
228
|
-
const regionEnd = (ref.end ?? 1) * sample.frames;
|
|
312
|
+
const decoded = bank.voices.get(sampleKey(track.id, target.voice));
|
|
313
|
+
if (!ref || !decoded) continue;
|
|
229
314
|
const held = noteLengthFrames(note, timing);
|
|
315
|
+
// 0.6 fit (`bpm`, `len`, or `fit` with fitmode beats|tones): the window
|
|
316
|
+
// is fitted once into a render-rate buffer that plays forward at step 1
|
|
317
|
+
// (times the keyed ratio). Absent fields keep the 0.5 path untouched.
|
|
318
|
+
// Only the frames this note can reach are fitted: a keyed or clipped
|
|
319
|
+
// note (not looping, no accelerate) ends at its held length plus the
|
|
320
|
+
// release, read at the keyed ratio; anything else may play it all.
|
|
321
|
+
const reach =
|
|
322
|
+
ref.loop !== true &&
|
|
323
|
+
(ref.accelerate ?? 0) === 0 &&
|
|
324
|
+
(sampler.mode === "keyed" || ref.clip !== undefined)
|
|
325
|
+
? Math.ceil(
|
|
326
|
+
((ref.clip === undefined
|
|
327
|
+
? held
|
|
328
|
+
: Math.max(1, Math.floor(held * ref.clip))) +
|
|
329
|
+
Math.round(RELEASE_SECONDS * sampleRate)) *
|
|
330
|
+
target.ratio,
|
|
331
|
+
) + REACH_MARGIN_FRAMES
|
|
332
|
+
: Infinity;
|
|
333
|
+
const fitted = isFitted(ref)
|
|
334
|
+
? fitVoice(ref, decoded, note.startTick, held, timing, reach)
|
|
335
|
+
: undefined;
|
|
336
|
+
if (fitted === null) continue;
|
|
337
|
+
// 0.6.1 `shift`/`formant`: a cached constant-length pitch shift of the
|
|
338
|
+
// played buffer (after any fit, so it follows fitmode).
|
|
339
|
+
const shifted = isShifted(ref)
|
|
340
|
+
? shiftVoice(ref, fitted?.sample ?? decoded, fitted ? "fit" : "raw")
|
|
341
|
+
: undefined;
|
|
342
|
+
const sample = shifted ?? fitted?.sample ?? decoded;
|
|
343
|
+
// Live, until the cached shift is ready: a plain repitch at the shifted
|
|
344
|
+
// pitch (the length follows, like tape).
|
|
345
|
+
const repitch =
|
|
346
|
+
shifted === undefined && isShifted(ref)
|
|
347
|
+
? 2 ** ((ref.shift ?? 0) / 12)
|
|
348
|
+
: 1;
|
|
349
|
+
const speed = fitted ? repitch : (ref.speed ?? 1) * repitch;
|
|
350
|
+
const regionStart = fitted ? 0 : (ref.begin ?? 0) * sample.frames;
|
|
351
|
+
const regionEnd = fitted ? sample.frames : (ref.end ?? 1) * sample.frames;
|
|
230
352
|
// `fit`, `unit: "c"` and `unit: "s"` give the window a duration; the
|
|
231
353
|
// keyed ratio still repitches on top.
|
|
232
354
|
const unit = ref.unit ?? "r";
|
|
233
|
-
const seconds =
|
|
234
|
-
?
|
|
235
|
-
:
|
|
236
|
-
?
|
|
237
|
-
: unit === "
|
|
238
|
-
? Math.abs(speed)
|
|
239
|
-
:
|
|
355
|
+
const seconds = fitted
|
|
356
|
+
? undefined
|
|
357
|
+
: ref.fit
|
|
358
|
+
? held / sampleRate
|
|
359
|
+
: unit === "c"
|
|
360
|
+
? cycleSeconds(timing.score, note.startTick) / Math.abs(speed)
|
|
361
|
+
: unit === "s"
|
|
362
|
+
? Math.abs(speed)
|
|
363
|
+
: undefined;
|
|
240
364
|
const step =
|
|
241
365
|
seconds === undefined
|
|
242
366
|
? ((Math.abs(speed) * sample.sampleRate) / sampleRate) * target.ratio
|
|
243
367
|
: ((regionEnd - regionStart) / (seconds * sampleRate)) * target.ratio;
|
|
244
368
|
const natural = Math.max(1, Math.floor((regionEnd - regionStart) / step));
|
|
245
|
-
const release = Math.max(
|
|
369
|
+
const release = Math.max(
|
|
370
|
+
1,
|
|
371
|
+
Math.round((ref.fadeTime ?? RELEASE_SECONDS) * sampleRate),
|
|
372
|
+
);
|
|
373
|
+
const endFade = Math.max(
|
|
374
|
+
1,
|
|
375
|
+
Math.round((ref.fadeTime ?? END_FADE_SECONDS) * sampleRate),
|
|
376
|
+
);
|
|
246
377
|
const loop = ref.loop === true;
|
|
247
378
|
const clipped =
|
|
248
379
|
ref.clip === undefined ? held : Math.max(1, Math.floor(held * ref.clip));
|
|
@@ -259,7 +390,7 @@ export function planSamplerVoices(
|
|
|
259
390
|
fade = release;
|
|
260
391
|
} else {
|
|
261
392
|
length = natural;
|
|
262
|
-
fade =
|
|
393
|
+
fade = endFade;
|
|
263
394
|
}
|
|
264
395
|
// accelerate: the rate ramps by `accelerate`× over the planned length;
|
|
265
396
|
// a non-looping voice ends where the ramped read runs out (or stops).
|
|
@@ -273,13 +404,19 @@ export function planSamplerVoices(
|
|
|
273
404
|
const until = Math.max(1, Math.floor(reach));
|
|
274
405
|
if (until < length) {
|
|
275
406
|
length = until;
|
|
276
|
-
fade =
|
|
407
|
+
fade = endFade;
|
|
277
408
|
}
|
|
278
409
|
}
|
|
279
|
-
const loopFrom =
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
410
|
+
const loopFrom = fitted
|
|
411
|
+
? fitted.loopFrom
|
|
412
|
+
: ref.loopBegin === undefined
|
|
413
|
+
? regionStart
|
|
414
|
+
: ref.loopBegin * sample.frames;
|
|
415
|
+
const loopTo = fitted
|
|
416
|
+
? fitted.loopTo
|
|
417
|
+
: ref.loopEnd === undefined
|
|
418
|
+
? regionEnd
|
|
419
|
+
: ref.loopEnd * sample.frames;
|
|
283
420
|
const start = noteStartFrame(note, timing);
|
|
284
421
|
const performance = (note as PerformedNote).performance;
|
|
285
422
|
const voice: SamplerVoice = {
|
|
@@ -300,6 +437,12 @@ export function planSamplerVoices(
|
|
|
300
437
|
loopSpan: loopTo - loopFrom,
|
|
301
438
|
ramp,
|
|
302
439
|
squiz: ref.squiz ?? 1,
|
|
440
|
+
...(ref.fadeInTime !== undefined
|
|
441
|
+
? { attack: Math.max(1, ref.fadeInTime * sampleRate) }
|
|
442
|
+
: {}),
|
|
443
|
+
...(ref.from !== undefined && sample.left && sample.right
|
|
444
|
+
? { stereo: true }
|
|
445
|
+
: {}),
|
|
303
446
|
...(performance?.cents ? { cents: performance.cents } : {}),
|
|
304
447
|
...(performance?.damp ? { damp: performance.damp } : {}),
|
|
305
448
|
};
|
|
@@ -318,6 +461,153 @@ export function planSamplerVoices(
|
|
|
318
461
|
return voices;
|
|
319
462
|
}
|
|
320
463
|
|
|
464
|
+
/** A SampleRef that fits its window to the song (0.6 `bpm`, `len`, fitmode). */
|
|
465
|
+
export function isFitted(ref: SampleRef): boolean {
|
|
466
|
+
return (
|
|
467
|
+
ref.bpm !== undefined ||
|
|
468
|
+
ref.len !== undefined ||
|
|
469
|
+
(ref.fit === true && (ref.fitmode === "beats" || ref.fitmode === "tones"))
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
type FittedVoice = Readonly<{
|
|
474
|
+
sample: DecodedSample;
|
|
475
|
+
loopFrom: number;
|
|
476
|
+
loopTo: number;
|
|
477
|
+
}>;
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The fitted window of one note. Precedence `fit` > `bpm` > `len`:
|
|
481
|
+
* - `fit`: the window fills the note (Strudel `fit`, with an algorithm);
|
|
482
|
+
* - `bpm`: the window advances `|speed|` source beats per song beat,
|
|
483
|
+
* following the tempo map (ramps, steps, fermatas);
|
|
484
|
+
* - `len`: the window spans `len` song beats through the tempo map.
|
|
485
|
+
* A negative `speed` reverses the window; `unit` is ignored once fitted.
|
|
486
|
+
* Returns null while a live fit is still computing (the voice stays
|
|
487
|
+
* silent rather than playing at the wrong pitch).
|
|
488
|
+
*/
|
|
489
|
+
function fitVoice(
|
|
490
|
+
ref: SampleRef,
|
|
491
|
+
decoded: DecodedSample,
|
|
492
|
+
startTick: number,
|
|
493
|
+
held: number,
|
|
494
|
+
timing: SamplerTiming,
|
|
495
|
+
reach: number,
|
|
496
|
+
): FittedVoice | null {
|
|
497
|
+
const { sampleRate, score, warp } = timing;
|
|
498
|
+
const speed = ref.speed ?? 1;
|
|
499
|
+
const reverse = speed < 0;
|
|
500
|
+
const from = (ref.begin ?? 0) * decoded.frames;
|
|
501
|
+
const to = (ref.end ?? 1) * decoded.frames;
|
|
502
|
+
const rate = decoded.sampleRate / sampleRate;
|
|
503
|
+
const window = windowAt(decoded.mono, from, to, rate, reverse);
|
|
504
|
+
const frames = window.length;
|
|
505
|
+
let map: FitMap;
|
|
506
|
+
if (ref.fit === true) map = spanFitMap(frames, held);
|
|
507
|
+
else {
|
|
508
|
+
const perBeat =
|
|
509
|
+
ref.bpm !== undefined
|
|
510
|
+
? ((sampleRate * 60) / ref.bpm) * Math.abs(speed)
|
|
511
|
+
: frames / ref.len!;
|
|
512
|
+
map = beatFitMap(
|
|
513
|
+
warp,
|
|
514
|
+
{
|
|
515
|
+
tempoBpm: score.tempoBpm,
|
|
516
|
+
ticksPerBeat: score.ticksPerBeat,
|
|
517
|
+
sampleRate,
|
|
518
|
+
},
|
|
519
|
+
startTick,
|
|
520
|
+
frames,
|
|
521
|
+
perBeat,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
const algorithm = ref.fitmode ?? "repitch";
|
|
525
|
+
const windowKey = `${decoded.sha256}:${decoded.sampleRate}:${from}:${to}:${sampleRate}:${reverse ? 1 : 0}`;
|
|
526
|
+
const length = Math.min(map.length, reach);
|
|
527
|
+
const key = `${windowKey}:${algorithm}:${mapPrint(map, frames)}:${Math.round(length)}`;
|
|
528
|
+
const buffer = fittedBuffer(
|
|
529
|
+
key,
|
|
530
|
+
window,
|
|
531
|
+
sampleRate,
|
|
532
|
+
map,
|
|
533
|
+
algorithm,
|
|
534
|
+
windowKey,
|
|
535
|
+
length,
|
|
536
|
+
);
|
|
537
|
+
if (!buffer) return null;
|
|
538
|
+
const sample: DecodedSample = Object.freeze({
|
|
539
|
+
sha256: decoded.sha256,
|
|
540
|
+
sampleRate,
|
|
541
|
+
channels: 1,
|
|
542
|
+
frames: buffer.length,
|
|
543
|
+
mono: buffer,
|
|
544
|
+
});
|
|
545
|
+
// Loop points move with the map (in window frames, then out frames).
|
|
546
|
+
const loopAt = (fraction: number | undefined, fallback: number) => {
|
|
547
|
+
if (fraction === undefined) return fallback;
|
|
548
|
+
const source = fraction * decoded.frames;
|
|
549
|
+
const inWindow = (reverse ? to - source : source - from) / rate;
|
|
550
|
+
return Math.max(0, Math.min(buffer.length, map.outAt(inWindow)));
|
|
551
|
+
};
|
|
552
|
+
const a = loopAt(ref.loopBegin, 0);
|
|
553
|
+
const b = loopAt(ref.loopEnd, buffer.length);
|
|
554
|
+
return Object.freeze({
|
|
555
|
+
sample,
|
|
556
|
+
loopFrom: Math.min(a, b),
|
|
557
|
+
loopTo: Math.max(a, b),
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** A SampleRef with a pitch or formant shift (0.6.1). */
|
|
562
|
+
export function isShifted(ref: SampleRef): boolean {
|
|
563
|
+
return (
|
|
564
|
+
(ref.shift !== undefined && ref.shift !== 0) || ref.formant !== undefined
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Buffers already shifted, by the identity of the buffer they shift. */
|
|
569
|
+
const shiftKeys = new WeakMap<Float32Array, string>();
|
|
570
|
+
let shiftSerial = 0;
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* The shifted copy of `sample` (whole buffer, same length). Returns null
|
|
574
|
+
* while a live shift is still computing (the voice stays silent).
|
|
575
|
+
*/
|
|
576
|
+
function shiftVoice(
|
|
577
|
+
ref: SampleRef,
|
|
578
|
+
sample: DecodedSample,
|
|
579
|
+
kind: "raw" | "fit",
|
|
580
|
+
): DecodedSample | undefined {
|
|
581
|
+
// Decoded files key by content; fitted buffers (already cached by their
|
|
582
|
+
// own key) by identity.
|
|
583
|
+
let base = shiftKeys.get(sample.mono);
|
|
584
|
+
if (base === undefined) {
|
|
585
|
+
base =
|
|
586
|
+
kind === "raw"
|
|
587
|
+
? `${sample.sha256}:${sample.sampleRate}:${sample.frames}`
|
|
588
|
+
: `fit#${(shiftSerial += 1)}`;
|
|
589
|
+
shiftKeys.set(sample.mono, base);
|
|
590
|
+
}
|
|
591
|
+
const key = `shift:${base}:${ref.shift ?? 0}:${ref.formant ?? "follow"}`;
|
|
592
|
+
const buffer = shiftedBuffer(
|
|
593
|
+
key,
|
|
594
|
+
sample.mono,
|
|
595
|
+
sample.sampleRate,
|
|
596
|
+
ref.shift ?? 0,
|
|
597
|
+
ref.formant,
|
|
598
|
+
);
|
|
599
|
+
if (!buffer) return undefined;
|
|
600
|
+
return Object.freeze({ ...sample, frames: buffer.length, mono: buffer });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Fingerprint of a position map: its length and 64 exact samples. */
|
|
604
|
+
function mapPrint(map: FitMap, frames: number): string {
|
|
605
|
+
const parts = [map.length.toString()];
|
|
606
|
+
for (let i = 1; i <= 64; i += 1)
|
|
607
|
+
parts.push(map.outAt((frames * i) / 64).toString());
|
|
608
|
+
return parts.join(",");
|
|
609
|
+
}
|
|
610
|
+
|
|
321
611
|
/** Linear-interpolated read, clamped to the sample. */
|
|
322
612
|
function readAt(mono: Float32Array, position: number): number {
|
|
323
613
|
const last = mono.length - 1;
|
|
@@ -337,6 +627,8 @@ export function renderSamplerVoices(
|
|
|
337
627
|
voices: readonly SamplerVoice[],
|
|
338
628
|
timing: SamplerTiming,
|
|
339
629
|
gainAt: (tick: number) => number,
|
|
630
|
+
/** The channel a stereo voice reads (absent: every voice reads mono). */
|
|
631
|
+
channel?: "left" | "right",
|
|
340
632
|
): void {
|
|
341
633
|
const { sampleRate, score, warp } = timing;
|
|
342
634
|
const samplesPerTick =
|
|
@@ -345,7 +637,8 @@ export function renderSamplerVoices(
|
|
|
345
637
|
for (const voice of voices) {
|
|
346
638
|
const { sample, step, regionStart, regionEnd, reverse, loop } = voice;
|
|
347
639
|
const { loopStart, loopSpan, ramp } = voice;
|
|
348
|
-
const mono =
|
|
640
|
+
const mono =
|
|
641
|
+
channel && voice.stereo ? (sample[channel] ?? sample.mono) : sample.mono;
|
|
349
642
|
const span = regionEnd - regionStart;
|
|
350
643
|
const crossfade = loop
|
|
351
644
|
? Math.min(LOOP_CROSSFADE_SECONDS * sample.sampleRate, loopSpan / 4)
|
|
@@ -403,7 +696,7 @@ export function renderSamplerVoices(
|
|
|
403
696
|
const elapsed = index - voice.start;
|
|
404
697
|
const value = squizzed ? squizzed[elapsed]! : read(elapsed);
|
|
405
698
|
if (value === undefined) break;
|
|
406
|
-
let envelope = Math.min(1, elapsed / attack);
|
|
699
|
+
let envelope = Math.min(1, elapsed / (voice.attack ?? attack));
|
|
407
700
|
if (index >= fadeFrom) envelope *= (voice.end - index) / voice.fade;
|
|
408
701
|
const damp = voice.damp;
|
|
409
702
|
if (damp && elapsed / sampleRate > damp.from)
|
package/src/audio/samples.ts
CHANGED
|
@@ -32,8 +32,10 @@ import {
|
|
|
32
32
|
isLocalTableSrc,
|
|
33
33
|
isWavetableInstrument,
|
|
34
34
|
type SampleRef,
|
|
35
|
+
type Track,
|
|
35
36
|
type TrackScore,
|
|
36
37
|
} from "../../core/score.ts";
|
|
38
|
+
import { GRANULAR_SOURCE_VOICE } from "../../core/granular.ts";
|
|
37
39
|
import { PackError, PackStore } from "./packs.ts";
|
|
38
40
|
import {
|
|
39
41
|
DEFAULT_MEMORY_CACHE_BYTES,
|
|
@@ -56,6 +58,12 @@ export type DecodedSample = Readonly<{
|
|
|
56
58
|
channels: number;
|
|
57
59
|
frames: number;
|
|
58
60
|
mono: Float32Array;
|
|
61
|
+
/**
|
|
62
|
+
* The two channels of a stereo file (0.6.1): a resampled ref (one with
|
|
63
|
+
* `from`) plays them as stereo. Absent for mono and multichannel files.
|
|
64
|
+
*/
|
|
65
|
+
left?: Float32Array;
|
|
66
|
+
right?: Float32Array;
|
|
59
67
|
}>;
|
|
60
68
|
|
|
61
69
|
export type SampleProblem = Readonly<{
|
|
@@ -94,10 +102,35 @@ export function hasSamplerTracks(score: TrackScore): boolean {
|
|
|
94
102
|
Object.keys(track.sampler.voices).length > 0) ||
|
|
95
103
|
packWavetable(track) !== undefined ||
|
|
96
104
|
localWavetable(track) !== undefined ||
|
|
97
|
-
reverbIrSample(track) !== undefined
|
|
105
|
+
reverbIrSample(track) !== undefined ||
|
|
106
|
+
granularSample(track) !== undefined,
|
|
98
107
|
);
|
|
99
108
|
}
|
|
100
109
|
|
|
110
|
+
/**
|
|
111
|
+
* A granular track's sample source (0.6): its `granular.src` when that is a
|
|
112
|
+
* SampleRef, loaded and sha256-pinned like a sampler voice under the voice
|
|
113
|
+
* name `granular:src`. A `synth:` source or no source needs no loading.
|
|
114
|
+
*/
|
|
115
|
+
export function granularSample(track: Track): SampleRef | undefined {
|
|
116
|
+
if (track.instrument !== "granular") return undefined;
|
|
117
|
+
const src = track.granular?.src;
|
|
118
|
+
return src !== undefined && typeof src !== "string" ? src : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The sample voices a track loads: sampler voices, then a granular source. */
|
|
122
|
+
function trackSampleRefs(track: Track): [string, SampleRef][] {
|
|
123
|
+
const refs: [string, SampleRef][] = [];
|
|
124
|
+
if (isSamplerInstrument(track.instrument) && track.sampler) {
|
|
125
|
+
const names = Object.keys(track.sampler.voices).sort();
|
|
126
|
+
for (const voice of names.slice(0, SCORE_LIMITS.maxSamplerVoices))
|
|
127
|
+
refs.push([voice, track.sampler.voices[voice]!]);
|
|
128
|
+
}
|
|
129
|
+
const granular = granularSample(track);
|
|
130
|
+
if (granular) refs.push([GRANULAR_SOURCE_VOICE, granular]);
|
|
131
|
+
return refs;
|
|
132
|
+
}
|
|
133
|
+
|
|
101
134
|
/** The project WAV a wavetable track plays, if any. */
|
|
102
135
|
export function localWavetable(
|
|
103
136
|
track: TrackScore["tracks"][number],
|
|
@@ -297,6 +330,20 @@ function checkShape(sampleRate: number, channels: number, frames: number) {
|
|
|
297
330
|
}
|
|
298
331
|
|
|
299
332
|
/** Average the channels; the renderer plays sampler voices in mono before pan. */
|
|
333
|
+
/** A stereo file's left and right channels (nothing for other shapes). */
|
|
334
|
+
export function stereoChannels(
|
|
335
|
+
pcm: PcmData,
|
|
336
|
+
): { left: Float32Array; right: Float32Array } | Record<string, never> {
|
|
337
|
+
if (pcm.channels !== 2) return {};
|
|
338
|
+
const left = new Float32Array(pcm.frames);
|
|
339
|
+
const right = new Float32Array(pcm.frames);
|
|
340
|
+
for (let frame = 0; frame < pcm.frames; frame += 1) {
|
|
341
|
+
left[frame] = pcm.data[frame * 2]!;
|
|
342
|
+
right[frame] = pcm.data[frame * 2 + 1]!;
|
|
343
|
+
}
|
|
344
|
+
return { left, right };
|
|
345
|
+
}
|
|
346
|
+
|
|
300
347
|
export function monoMix(pcm: PcmData): Float32Array {
|
|
301
348
|
if (pcm.channels === 1) return pcm.data;
|
|
302
349
|
const mono = new Float32Array(pcm.frames);
|
|
@@ -585,8 +632,7 @@ export class SampleLibrary implements SampleSource {
|
|
|
585
632
|
this.inUse = new Set();
|
|
586
633
|
const urls: string[] = [];
|
|
587
634
|
for (const track of score.tracks) {
|
|
588
|
-
|
|
589
|
-
for (const ref of Object.values(track.sampler.voices)) {
|
|
635
|
+
for (const [, ref] of trackSampleRefs(track)) {
|
|
590
636
|
if (ref.sha256) this.inUse.add(ref.sha256);
|
|
591
637
|
if (ref.url) urls.push(ref.url);
|
|
592
638
|
}
|
|
@@ -656,10 +702,7 @@ export class SampleLibrary implements SampleSource {
|
|
|
656
702
|
}
|
|
657
703
|
}
|
|
658
704
|
for (const track of score.tracks) {
|
|
659
|
-
|
|
660
|
-
const names = Object.keys(track.sampler.voices).sort();
|
|
661
|
-
for (const voice of names.slice(0, SCORE_LIMITS.maxSamplerVoices)) {
|
|
662
|
-
const ref = track.sampler.voices[voice]!;
|
|
705
|
+
for (const [voice, ref] of trackSampleRefs(track)) {
|
|
663
706
|
let src = ref.src;
|
|
664
707
|
if (src.startsWith(PACK_PREFIX)) {
|
|
665
708
|
try {
|
|
@@ -873,6 +916,7 @@ export class SampleLibrary implements SampleSource {
|
|
|
873
916
|
channels: pcm.channels,
|
|
874
917
|
frames: pcm.frames,
|
|
875
918
|
mono: monoMix(pcm),
|
|
919
|
+
...stereoChannels(pcm),
|
|
876
920
|
});
|
|
877
921
|
this.remember(sample);
|
|
878
922
|
return sample;
|
|
@@ -920,6 +964,7 @@ export class SampleLibrary implements SampleSource {
|
|
|
920
964
|
channels: pcm.channels,
|
|
921
965
|
frames: pcm.frames,
|
|
922
966
|
mono: monoMix(pcm),
|
|
967
|
+
...stereoChannels(pcm),
|
|
923
968
|
});
|
|
924
969
|
this.remember(sample);
|
|
925
970
|
return sample;
|
|
@@ -943,7 +988,10 @@ export class SampleLibrary implements SampleSource {
|
|
|
943
988
|
}
|
|
944
989
|
|
|
945
990
|
private remember(sample: DecodedSample): void {
|
|
946
|
-
const bytes =
|
|
991
|
+
const bytes =
|
|
992
|
+
sample.mono.byteLength +
|
|
993
|
+
(sample.left?.byteLength ?? 0) +
|
|
994
|
+
(sample.right?.byteLength ?? 0);
|
|
947
995
|
this.memory.set(sample.sha256, { sample, bytes });
|
|
948
996
|
this.memoryBytes += bytes;
|
|
949
997
|
for (const [sha, entry] of this.memory) {
|