@hraness/dawg 0.5.0 → 0.6.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 +29 -0
- package/DAWG.md +269 -13
- package/README.md +4 -4
- package/core/diff.ts +4 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/resonators.ts +574 -0
- package/core/score.ts +181 -6
- package/core/sdk/eval-child.ts +2 -0
- package/core/sdk/print.ts +169 -13
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +1114 -21
- package/core/sections.ts +34 -8
- package/core/strings.ts +845 -0
- package/guides/effects.md +3 -1
- package/guides/sounds.md +4 -0
- package/guides/tempo.md +1 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +7 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +39 -2
- package/src/agent/preview-tool.ts +6 -1
- package/src/agent/tools.ts +538 -14
- package/src/agent/xcb-agent.ts +7 -0
- package/src/audio/arrange.ts +21 -3
- package/src/audio/dsp/bank.ts +233 -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/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/chain.ts +6 -1
- 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 +10 -1
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +231 -12
- package/src/audio/loudness.ts +65 -20
- package/src/audio/preview.ts +2 -0
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +163 -17
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/warp.ts +61 -0
- package/src/audio/wav.ts +78 -6
- package/src/commands/expression.ts +9 -2
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +74 -3
- package/src/commands/keys.ts +353 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/time.ts +4 -1
- package/src/main.ts +210 -8
- package/src/project/check.ts +13 -0
- package/src/render.ts +33 -4
- package/src/tui/audition.ts +2 -2
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu.ts +332 -9
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/play-session.ts +45 -2
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The modal percussion engine (0.6, lane f06-modal): renders a track whose
|
|
3
|
+
* instrument is `"modal"` and that carries a `modal` field. Registered in
|
|
4
|
+
* `src/audio/instruments.ts`; without the field the legacy tone voice plays.
|
|
5
|
+
*
|
|
6
|
+
* Per track: notes in onset order, at most `MAX_RESONATOR_VOICES` sounding
|
|
7
|
+
* (the oldest onset is stolen with a 5 ms fade), each a `ModalBank` struck
|
|
8
|
+
* once and rung for up to `MAX_RESONATOR_TAIL` seconds after note-off with
|
|
9
|
+
* early stop on silence. Stealing depends only on earlier onsets, so a
|
|
10
|
+
* render is prefix-stable: adding a later note never changes earlier audio
|
|
11
|
+
* before that note starts.
|
|
12
|
+
*
|
|
13
|
+
* 0.5 hooks: tuning and note cents (`noteHz`), bends and glides
|
|
14
|
+
* (`performance.cents`, faded per tick above 0.45 sr), articulation as
|
|
15
|
+
* timbre only (accent and marcato harden the mallet; staccato damps sooner
|
|
16
|
+
* through the shorter length), sustain pedal (longer lengths) and half
|
|
17
|
+
* pedal (`performance.damp`), velocity curve and humanize (already in the
|
|
18
|
+
* performed notes), and the tempo map (`context.warp`).
|
|
19
|
+
*/
|
|
20
|
+
import type { PerformedNote } from "../../core/expression.ts";
|
|
21
|
+
import {
|
|
22
|
+
MAX_RESONATOR_TAIL,
|
|
23
|
+
MAX_RESONATOR_VOICES,
|
|
24
|
+
MODAL_LANE_PARAMS,
|
|
25
|
+
modalSettings,
|
|
26
|
+
modalTailSeconds,
|
|
27
|
+
RESONATOR_TABLE_VERSION,
|
|
28
|
+
type ModalSettings,
|
|
29
|
+
} from "../../core/resonators.ts";
|
|
30
|
+
import type { AutomationPoint, Track } from "../../core/score.ts";
|
|
31
|
+
import { noteHz } from "../../core/tuning.ts";
|
|
32
|
+
import { ModalBank, MODAL_BLOCK } from "./dsp/modal.ts";
|
|
33
|
+
import { logCosh } from "./dsp/shape.ts";
|
|
34
|
+
import { interpolateAutomation } from "./effects/common.ts";
|
|
35
|
+
import type { EngineContext, InstrumentEngine } from "./instruments.ts";
|
|
36
|
+
import { warpedSpan } from "./warp.ts";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Raised-cosine fade applied to a stolen voice, seconds. At 80 ms the
|
|
40
|
+
* largest per-sample gain step is pi/(2*fade samples): -61 dB at 22.05 kHz,
|
|
41
|
+
* -68 dB at 48 kHz, so stealing a long gong or kempul does not click.
|
|
42
|
+
*/
|
|
43
|
+
const STEAL_FADE = 0.08;
|
|
44
|
+
/** Extra mallet hardness for accent and marcato. */
|
|
45
|
+
const ACCENT_HARDNESS = 0.2;
|
|
46
|
+
/**
|
|
47
|
+
* Track-level safety knee: exactly linear up to KNEE, then a tanh shoulder
|
|
48
|
+
* that never passes CEILING. Single notes (about 0.8 at velocity 0.8) and
|
|
49
|
+
* dyads pass bit-exact; only dense velocity-1 chords reach the shoulder. A
|
|
50
|
+
* six-note chord at velocity 1 sums to about 4-5 and lands under the
|
|
51
|
+
* ceiling, which the centre pan and track gain keep below 0 dBFS (spec
|
|
52
|
+
* test 13).
|
|
53
|
+
*/
|
|
54
|
+
export const MODAL_KNEE = 1;
|
|
55
|
+
const KNEE = MODAL_KNEE;
|
|
56
|
+
const CEILING = 1.38;
|
|
57
|
+
const SPAN = CEILING - KNEE;
|
|
58
|
+
|
|
59
|
+
function knee(x: number): number {
|
|
60
|
+
const a = Math.abs(x);
|
|
61
|
+
if (a <= KNEE) return x;
|
|
62
|
+
return Math.sign(x) * (KNEE + SPAN * Math.tanh((a - KNEE) / SPAN));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Antiderivative of `knee` (even), for first-order ADAA. */
|
|
66
|
+
function kneeF(x: number): number {
|
|
67
|
+
const a = Math.abs(x);
|
|
68
|
+
if (a <= KNEE) return 0.5 * x * x;
|
|
69
|
+
return (
|
|
70
|
+
0.5 * KNEE * KNEE +
|
|
71
|
+
KNEE * (a - KNEE) +
|
|
72
|
+
SPAN * SPAN * logCosh((a - KNEE) / SPAN)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The knee with first-order ADAA only inside the shoulder: while this and
|
|
78
|
+
* the previous sample are both under KNEE the output is the input itself, so
|
|
79
|
+
* the limiter adds no two-tap low-pass or half-sample delay to normal play.
|
|
80
|
+
*/
|
|
81
|
+
export function applyModalKnee(dry: Float64Array, samples: number): void {
|
|
82
|
+
let x1 = 0;
|
|
83
|
+
let F1 = 0;
|
|
84
|
+
for (let index = 0; index < samples; index += 1) {
|
|
85
|
+
const x = dry[index]!;
|
|
86
|
+
const Fx = kneeF(x);
|
|
87
|
+
if (Math.abs(x) > KNEE || Math.abs(x1) > KNEE) {
|
|
88
|
+
const dx = x - x1;
|
|
89
|
+
dry[index] = Math.abs(dx) < 1e-5 ? knee(0.5 * (x + x1)) : (Fx - F1) / dx;
|
|
90
|
+
}
|
|
91
|
+
x1 = x;
|
|
92
|
+
F1 = Fx;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type Planned = Readonly<{
|
|
97
|
+
note: PerformedNote;
|
|
98
|
+
start: number;
|
|
99
|
+
length: number;
|
|
100
|
+
}>;
|
|
101
|
+
|
|
102
|
+
function span(
|
|
103
|
+
note: PerformedNote,
|
|
104
|
+
context: EngineContext,
|
|
105
|
+
): { start: number; length: number } {
|
|
106
|
+
if (context.warp)
|
|
107
|
+
return warpedSpan(context.warp, note.startTick, note.durationTicks);
|
|
108
|
+
const perTick =
|
|
109
|
+
(60 * context.sampleRate) / (context.tempoBpm * context.ticksPerBeat);
|
|
110
|
+
return {
|
|
111
|
+
start: Math.max(0, Math.floor(note.startTick * perTick)),
|
|
112
|
+
length: Math.max(1, Math.floor(note.durationTicks * perTick)),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function laneOf(
|
|
117
|
+
track: Track,
|
|
118
|
+
param: string,
|
|
119
|
+
): readonly AutomationPoint[] | undefined {
|
|
120
|
+
const lanes = track.fxAutomation as
|
|
121
|
+
Readonly<Record<string, readonly AutomationPoint[]>> | undefined;
|
|
122
|
+
const points = lanes?.[`modal-${param}`];
|
|
123
|
+
return points && points.length > 0 ? points : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Renders a modal track's performed notes into `dry` (mono). */
|
|
127
|
+
export function renderResonatorTrack(
|
|
128
|
+
dry: Float64Array,
|
|
129
|
+
notes: readonly PerformedNote[],
|
|
130
|
+
track: Track,
|
|
131
|
+
context: EngineContext,
|
|
132
|
+
): void {
|
|
133
|
+
const { sampleRate, samples } = context;
|
|
134
|
+
const samplesPerTick = context.samplesPerTick;
|
|
135
|
+
const tickAt = (index: number): number =>
|
|
136
|
+
context.warp ? context.warp.tick(index) : index / samplesPerTick;
|
|
137
|
+
const planned: Planned[] = notes
|
|
138
|
+
.map((note) => ({ note, ...span(note, context) }))
|
|
139
|
+
.filter((entry) => entry.start < samples)
|
|
140
|
+
.sort(
|
|
141
|
+
(a, b) =>
|
|
142
|
+
a.start - b.start ||
|
|
143
|
+
a.note.pitch - b.note.pitch ||
|
|
144
|
+
(a.note.id < b.note.id ? -1 : a.note.id > b.note.id ? 1 : 0),
|
|
145
|
+
);
|
|
146
|
+
const lanes = new Map<string, readonly AutomationPoint[]>();
|
|
147
|
+
for (const { param } of MODAL_LANE_PARAMS) {
|
|
148
|
+
const points = laneOf(track, param);
|
|
149
|
+
if (points) lanes.set(param, points);
|
|
150
|
+
}
|
|
151
|
+
const motorLane = lanes.get("motordepth");
|
|
152
|
+
const volumeLane = track.volumeAutomation ?? [];
|
|
153
|
+
const volume = Math.max(0, Math.min(1, track.volume ?? 1));
|
|
154
|
+
const gainAt = (index: number): number =>
|
|
155
|
+
volume *
|
|
156
|
+
(volumeLane.length > 0
|
|
157
|
+
? interpolateAutomation(volumeLane, tickAt(index), 1)
|
|
158
|
+
: 1);
|
|
159
|
+
const seedTick = context.seedTick ?? 0;
|
|
160
|
+
const base = modalSettings(track.modal);
|
|
161
|
+
const banks: ModalBank[] = [];
|
|
162
|
+
// Conservative end of each planned voice (its ring cap, or a steal).
|
|
163
|
+
const ends: number[] = [];
|
|
164
|
+
const steals: number[] = [];
|
|
165
|
+
const sounding: number[] = [];
|
|
166
|
+
for (let i = 0; i < planned.length; i += 1) {
|
|
167
|
+
const { note, start, length } = planned[i]!;
|
|
168
|
+
const onsetTick = note.startTick;
|
|
169
|
+
const settings =
|
|
170
|
+
lanes.size === 0
|
|
171
|
+
? base
|
|
172
|
+
: clampLanes(
|
|
173
|
+
modalSettings(track.modal, (param) => {
|
|
174
|
+
const points = lanes.get(param);
|
|
175
|
+
return points
|
|
176
|
+
? interpolateAutomation(
|
|
177
|
+
points,
|
|
178
|
+
onsetTick,
|
|
179
|
+
base[param as keyof typeof base] as number,
|
|
180
|
+
)
|
|
181
|
+
: undefined;
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
const performance = note.performance;
|
|
185
|
+
const accent =
|
|
186
|
+
performance?.accent || note.articulation === "marcato"
|
|
187
|
+
? ACCENT_HARDNESS
|
|
188
|
+
: 0;
|
|
189
|
+
const bank = new ModalBank(
|
|
190
|
+
settings,
|
|
191
|
+
{
|
|
192
|
+
hz: noteHz(note.pitch, note.cents, context.tuning),
|
|
193
|
+
velocity: note.velocity,
|
|
194
|
+
start: start / sampleRate,
|
|
195
|
+
duration: length / sampleRate,
|
|
196
|
+
seed: `${note.id}:${note.startTick + seedTick}:modal`,
|
|
197
|
+
...(accent ? { accent } : {}),
|
|
198
|
+
...(performance?.cents ? { cents: performance.cents } : {}),
|
|
199
|
+
...(motorLane
|
|
200
|
+
? {
|
|
201
|
+
motordepth: (t: number) =>
|
|
202
|
+
interpolateAutomation(
|
|
203
|
+
motorLane,
|
|
204
|
+
tickAt(start + t * sampleRate),
|
|
205
|
+
settings.motordepth,
|
|
206
|
+
),
|
|
207
|
+
}
|
|
208
|
+
: {}),
|
|
209
|
+
},
|
|
210
|
+
sampleRate,
|
|
211
|
+
MAX_RESONATOR_TAIL,
|
|
212
|
+
);
|
|
213
|
+
banks.push(bank);
|
|
214
|
+
ends.push(start + bank.endAt);
|
|
215
|
+
steals.push(Infinity);
|
|
216
|
+
// Voice stealing in onset order: drop finished voices, then steal the
|
|
217
|
+
// oldest onset while the pool is full.
|
|
218
|
+
for (let k = sounding.length - 1; k >= 0; k -= 1)
|
|
219
|
+
if (ends[sounding[k]!]! <= start) sounding.splice(k, 1);
|
|
220
|
+
while (sounding.length >= MAX_RESONATOR_VOICES) {
|
|
221
|
+
const oldest = sounding.shift()!;
|
|
222
|
+
steals[oldest] = start;
|
|
223
|
+
ends[oldest] = Math.min(
|
|
224
|
+
ends[oldest]!,
|
|
225
|
+
start + Math.round(STEAL_FADE * sampleRate),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
sounding.push(i);
|
|
229
|
+
}
|
|
230
|
+
const scratch = new Float64Array(MODAL_BLOCK);
|
|
231
|
+
const fade = Math.max(1, Math.round(STEAL_FADE * sampleRate));
|
|
232
|
+
for (let i = 0; i < planned.length; i += 1) {
|
|
233
|
+
const { note, start } = planned[i]!;
|
|
234
|
+
const bank = banks[i]!;
|
|
235
|
+
const stop = Math.min(samples, ends[i]!);
|
|
236
|
+
const steal = steals[i]!;
|
|
237
|
+
const damp = note.performance?.damp;
|
|
238
|
+
const dampFrom = damp ? start + Math.round(damp.from * sampleRate) : 0;
|
|
239
|
+
let at = start;
|
|
240
|
+
while (at < stop) {
|
|
241
|
+
const count = Math.min(MODAL_BLOCK, stop - at);
|
|
242
|
+
scratch.fill(0, 0, count);
|
|
243
|
+
const alive = bank.process(scratch, 0, count);
|
|
244
|
+
const gain = gainAt(at);
|
|
245
|
+
for (let j = 0; j < count; j += 1) {
|
|
246
|
+
const index = at + j;
|
|
247
|
+
let level = gain;
|
|
248
|
+
if (index >= steal)
|
|
249
|
+
level *=
|
|
250
|
+
index - steal >= fade
|
|
251
|
+
? 0
|
|
252
|
+
: 0.5 + 0.5 * Math.cos((Math.PI * (index - steal)) / fade);
|
|
253
|
+
if (damp && index > dampFrom)
|
|
254
|
+
level *= Math.exp(-(index - dampFrom) / (damp.tau * sampleRate));
|
|
255
|
+
dry[index]! += scratch[j]! * level;
|
|
256
|
+
}
|
|
257
|
+
if (!alive) break;
|
|
258
|
+
at += count;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Causal and stateless across renders, so the output stays prefix-stable.
|
|
262
|
+
applyModalKnee(dry, samples);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Lane values land inside each parameter's range. */
|
|
266
|
+
function clampLanes(settings: ModalSettings): ModalSettings {
|
|
267
|
+
const out: Record<string, number | string> = { ...settings };
|
|
268
|
+
for (const { param, spec } of MODAL_LANE_PARAMS) {
|
|
269
|
+
const value = out[param];
|
|
270
|
+
if (typeof value === "number")
|
|
271
|
+
out[param] = Math.max(spec.min, Math.min(spec.max, value));
|
|
272
|
+
}
|
|
273
|
+
return Object.freeze(out) as ModalSettings;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The modal engine as registered in `src/audio/instruments.ts`. */
|
|
277
|
+
export const MODAL_ENGINE: InstrumentEngine = Object.freeze({
|
|
278
|
+
id: "modal",
|
|
279
|
+
field: "modal",
|
|
280
|
+
render(dry, _dryR, notes, track, context) {
|
|
281
|
+
renderResonatorTrack(dry, notes, track, context);
|
|
282
|
+
},
|
|
283
|
+
tailSeconds: (track: Track, lowestPitch?: number) =>
|
|
284
|
+
modalTailSeconds(track.modal, lowestPitch),
|
|
285
|
+
stereo: () => false,
|
|
286
|
+
assetDigests: () => [`resonators:${RESONATOR_TABLE_VERSION}`],
|
|
287
|
+
});
|
package/src/audio/sampler.ts
CHANGED
|
@@ -38,7 +38,15 @@ import {
|
|
|
38
38
|
performedNotes,
|
|
39
39
|
secondsAtTick,
|
|
40
40
|
} from "../../core/tempo.ts";
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
beatFitMap,
|
|
43
|
+
sampleWarpFor,
|
|
44
|
+
spanFitMap,
|
|
45
|
+
warpedSpan,
|
|
46
|
+
type FitMap,
|
|
47
|
+
type SampleWarp,
|
|
48
|
+
} from "./warp.ts";
|
|
49
|
+
import { fittedBuffer, windowAt } from "./fit.ts";
|
|
42
50
|
import {
|
|
43
51
|
performanceTimingFor,
|
|
44
52
|
tunedTiming,
|
|
@@ -55,6 +63,8 @@ const ATTACK_SECONDS = 0.001;
|
|
|
55
63
|
/** Fade at a natural end, a held release, a choke and a voice steal. */
|
|
56
64
|
const END_FADE_SECONDS = 0.003;
|
|
57
65
|
const RELEASE_SECONDS = 0.01;
|
|
66
|
+
/** Extra fitted frames past a note's reach (read interpolation). */
|
|
67
|
+
const REACH_MARGIN_FRAMES = 64;
|
|
58
68
|
const CHOKE_SECONDS = 0.005;
|
|
59
69
|
/** Loop seam crossfade (source time). */
|
|
60
70
|
const LOOP_CROSSFADE_SECONDS = 0.005;
|
|
@@ -221,22 +231,47 @@ export function planSamplerVoices(
|
|
|
221
231
|
? { ...resolved, ratio: resolved.ratio * 2 ** (note.cents / 1200) }
|
|
222
232
|
: resolved;
|
|
223
233
|
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;
|
|
234
|
+
const decoded = bank.voices.get(sampleKey(track.id, target.voice));
|
|
235
|
+
if (!ref || !decoded) continue;
|
|
229
236
|
const held = noteLengthFrames(note, timing);
|
|
237
|
+
// 0.6 fit (`bpm`, `len`, or `fit` with fitmode beats|tones): the window
|
|
238
|
+
// is fitted once into a render-rate buffer that plays forward at step 1
|
|
239
|
+
// (times the keyed ratio). Absent fields keep the 0.5 path untouched.
|
|
240
|
+
// Only the frames this note can reach are fitted: a keyed or clipped
|
|
241
|
+
// note (not looping, no accelerate) ends at its held length plus the
|
|
242
|
+
// release, read at the keyed ratio; anything else may play it all.
|
|
243
|
+
const reach =
|
|
244
|
+
ref.loop !== true &&
|
|
245
|
+
(ref.accelerate ?? 0) === 0 &&
|
|
246
|
+
(sampler.mode === "keyed" || ref.clip !== undefined)
|
|
247
|
+
? Math.ceil(
|
|
248
|
+
((ref.clip === undefined
|
|
249
|
+
? held
|
|
250
|
+
: Math.max(1, Math.floor(held * ref.clip))) +
|
|
251
|
+
Math.round(RELEASE_SECONDS * sampleRate)) *
|
|
252
|
+
target.ratio,
|
|
253
|
+
) + REACH_MARGIN_FRAMES
|
|
254
|
+
: Infinity;
|
|
255
|
+
const fitted = isFitted(ref)
|
|
256
|
+
? fitVoice(ref, decoded, note.startTick, held, timing, reach)
|
|
257
|
+
: undefined;
|
|
258
|
+
if (fitted === null) continue;
|
|
259
|
+
const sample = fitted?.sample ?? decoded;
|
|
260
|
+
const speed = fitted ? 1 : (ref.speed ?? 1);
|
|
261
|
+
const regionStart = fitted ? 0 : (ref.begin ?? 0) * sample.frames;
|
|
262
|
+
const regionEnd = fitted ? sample.frames : (ref.end ?? 1) * sample.frames;
|
|
230
263
|
// `fit`, `unit: "c"` and `unit: "s"` give the window a duration; the
|
|
231
264
|
// keyed ratio still repitches on top.
|
|
232
265
|
const unit = ref.unit ?? "r";
|
|
233
|
-
const seconds =
|
|
234
|
-
?
|
|
235
|
-
:
|
|
236
|
-
?
|
|
237
|
-
: unit === "
|
|
238
|
-
? Math.abs(speed)
|
|
239
|
-
:
|
|
266
|
+
const seconds = fitted
|
|
267
|
+
? undefined
|
|
268
|
+
: ref.fit
|
|
269
|
+
? held / sampleRate
|
|
270
|
+
: unit === "c"
|
|
271
|
+
? cycleSeconds(timing.score, note.startTick) / Math.abs(speed)
|
|
272
|
+
: unit === "s"
|
|
273
|
+
? Math.abs(speed)
|
|
274
|
+
: undefined;
|
|
240
275
|
const step =
|
|
241
276
|
seconds === undefined
|
|
242
277
|
? ((Math.abs(speed) * sample.sampleRate) / sampleRate) * target.ratio
|
|
@@ -276,10 +311,16 @@ export function planSamplerVoices(
|
|
|
276
311
|
fade = Math.max(1, Math.round(END_FADE_SECONDS * sampleRate));
|
|
277
312
|
}
|
|
278
313
|
}
|
|
279
|
-
const loopFrom =
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
314
|
+
const loopFrom = fitted
|
|
315
|
+
? fitted.loopFrom
|
|
316
|
+
: ref.loopBegin === undefined
|
|
317
|
+
? regionStart
|
|
318
|
+
: ref.loopBegin * sample.frames;
|
|
319
|
+
const loopTo = fitted
|
|
320
|
+
? fitted.loopTo
|
|
321
|
+
: ref.loopEnd === undefined
|
|
322
|
+
? regionEnd
|
|
323
|
+
: ref.loopEnd * sample.frames;
|
|
283
324
|
const start = noteStartFrame(note, timing);
|
|
284
325
|
const performance = (note as PerformedNote).performance;
|
|
285
326
|
const voice: SamplerVoice = {
|
|
@@ -318,6 +359,111 @@ export function planSamplerVoices(
|
|
|
318
359
|
return voices;
|
|
319
360
|
}
|
|
320
361
|
|
|
362
|
+
/** A SampleRef that fits its window to the song (0.6 `bpm`, `len`, fitmode). */
|
|
363
|
+
export function isFitted(ref: SampleRef): boolean {
|
|
364
|
+
return (
|
|
365
|
+
ref.bpm !== undefined ||
|
|
366
|
+
ref.len !== undefined ||
|
|
367
|
+
(ref.fit === true && (ref.fitmode === "beats" || ref.fitmode === "tones"))
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
type FittedVoice = Readonly<{
|
|
372
|
+
sample: DecodedSample;
|
|
373
|
+
loopFrom: number;
|
|
374
|
+
loopTo: number;
|
|
375
|
+
}>;
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The fitted window of one note. Precedence `fit` > `bpm` > `len`:
|
|
379
|
+
* - `fit`: the window fills the note (Strudel `fit`, with an algorithm);
|
|
380
|
+
* - `bpm`: the window advances `|speed|` source beats per song beat,
|
|
381
|
+
* following the tempo map (ramps, steps, fermatas);
|
|
382
|
+
* - `len`: the window spans `len` song beats through the tempo map.
|
|
383
|
+
* A negative `speed` reverses the window; `unit` is ignored once fitted.
|
|
384
|
+
* Returns null while a live fit is still computing (the voice stays
|
|
385
|
+
* silent rather than playing at the wrong pitch).
|
|
386
|
+
*/
|
|
387
|
+
function fitVoice(
|
|
388
|
+
ref: SampleRef,
|
|
389
|
+
decoded: DecodedSample,
|
|
390
|
+
startTick: number,
|
|
391
|
+
held: number,
|
|
392
|
+
timing: SamplerTiming,
|
|
393
|
+
reach: number,
|
|
394
|
+
): FittedVoice | null {
|
|
395
|
+
const { sampleRate, score, warp } = timing;
|
|
396
|
+
const speed = ref.speed ?? 1;
|
|
397
|
+
const reverse = speed < 0;
|
|
398
|
+
const from = (ref.begin ?? 0) * decoded.frames;
|
|
399
|
+
const to = (ref.end ?? 1) * decoded.frames;
|
|
400
|
+
const rate = decoded.sampleRate / sampleRate;
|
|
401
|
+
const window = windowAt(decoded.mono, from, to, rate, reverse);
|
|
402
|
+
const frames = window.length;
|
|
403
|
+
let map: FitMap;
|
|
404
|
+
if (ref.fit === true) map = spanFitMap(frames, held);
|
|
405
|
+
else {
|
|
406
|
+
const perBeat =
|
|
407
|
+
ref.bpm !== undefined
|
|
408
|
+
? ((sampleRate * 60) / ref.bpm) * Math.abs(speed)
|
|
409
|
+
: frames / ref.len!;
|
|
410
|
+
map = beatFitMap(
|
|
411
|
+
warp,
|
|
412
|
+
{
|
|
413
|
+
tempoBpm: score.tempoBpm,
|
|
414
|
+
ticksPerBeat: score.ticksPerBeat,
|
|
415
|
+
sampleRate,
|
|
416
|
+
},
|
|
417
|
+
startTick,
|
|
418
|
+
frames,
|
|
419
|
+
perBeat,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
const algorithm = ref.fitmode ?? "repitch";
|
|
423
|
+
const windowKey = `${decoded.sha256}:${decoded.sampleRate}:${from}:${to}:${sampleRate}:${reverse ? 1 : 0}`;
|
|
424
|
+
const length = Math.min(map.length, reach);
|
|
425
|
+
const key = `${windowKey}:${algorithm}:${mapPrint(map, frames)}:${Math.round(length)}`;
|
|
426
|
+
const buffer = fittedBuffer(
|
|
427
|
+
key,
|
|
428
|
+
window,
|
|
429
|
+
sampleRate,
|
|
430
|
+
map,
|
|
431
|
+
algorithm,
|
|
432
|
+
windowKey,
|
|
433
|
+
length,
|
|
434
|
+
);
|
|
435
|
+
if (!buffer) return null;
|
|
436
|
+
const sample: DecodedSample = Object.freeze({
|
|
437
|
+
sha256: decoded.sha256,
|
|
438
|
+
sampleRate,
|
|
439
|
+
channels: 1,
|
|
440
|
+
frames: buffer.length,
|
|
441
|
+
mono: buffer,
|
|
442
|
+
});
|
|
443
|
+
// Loop points move with the map (in window frames, then out frames).
|
|
444
|
+
const loopAt = (fraction: number | undefined, fallback: number) => {
|
|
445
|
+
if (fraction === undefined) return fallback;
|
|
446
|
+
const source = fraction * decoded.frames;
|
|
447
|
+
const inWindow = (reverse ? to - source : source - from) / rate;
|
|
448
|
+
return Math.max(0, Math.min(buffer.length, map.outAt(inWindow)));
|
|
449
|
+
};
|
|
450
|
+
const a = loopAt(ref.loopBegin, 0);
|
|
451
|
+
const b = loopAt(ref.loopEnd, buffer.length);
|
|
452
|
+
return Object.freeze({
|
|
453
|
+
sample,
|
|
454
|
+
loopFrom: Math.min(a, b),
|
|
455
|
+
loopTo: Math.max(a, b),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Fingerprint of a position map: its length and 64 exact samples. */
|
|
460
|
+
function mapPrint(map: FitMap, frames: number): string {
|
|
461
|
+
const parts = [map.length.toString()];
|
|
462
|
+
for (let i = 1; i <= 64; i += 1)
|
|
463
|
+
parts.push(map.outAt((frames * i) / 64).toString());
|
|
464
|
+
return parts.join(",");
|
|
465
|
+
}
|
|
466
|
+
|
|
321
467
|
/** Linear-interpolated read, clamped to the sample. */
|
|
322
468
|
function readAt(mono: Float32Array, position: number): number {
|
|
323
469
|
const last = mono.length - 1;
|
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,
|
|
@@ -94,10 +96,35 @@ export function hasSamplerTracks(score: TrackScore): boolean {
|
|
|
94
96
|
Object.keys(track.sampler.voices).length > 0) ||
|
|
95
97
|
packWavetable(track) !== undefined ||
|
|
96
98
|
localWavetable(track) !== undefined ||
|
|
97
|
-
reverbIrSample(track) !== undefined
|
|
99
|
+
reverbIrSample(track) !== undefined ||
|
|
100
|
+
granularSample(track) !== undefined,
|
|
98
101
|
);
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A granular track's sample source (0.6): its `granular.src` when that is a
|
|
106
|
+
* SampleRef, loaded and sha256-pinned like a sampler voice under the voice
|
|
107
|
+
* name `granular:src`. A `synth:` source or no source needs no loading.
|
|
108
|
+
*/
|
|
109
|
+
export function granularSample(track: Track): SampleRef | undefined {
|
|
110
|
+
if (track.instrument !== "granular") return undefined;
|
|
111
|
+
const src = track.granular?.src;
|
|
112
|
+
return src !== undefined && typeof src !== "string" ? src : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The sample voices a track loads: sampler voices, then a granular source. */
|
|
116
|
+
function trackSampleRefs(track: Track): [string, SampleRef][] {
|
|
117
|
+
const refs: [string, SampleRef][] = [];
|
|
118
|
+
if (isSamplerInstrument(track.instrument) && track.sampler) {
|
|
119
|
+
const names = Object.keys(track.sampler.voices).sort();
|
|
120
|
+
for (const voice of names.slice(0, SCORE_LIMITS.maxSamplerVoices))
|
|
121
|
+
refs.push([voice, track.sampler.voices[voice]!]);
|
|
122
|
+
}
|
|
123
|
+
const granular = granularSample(track);
|
|
124
|
+
if (granular) refs.push([GRANULAR_SOURCE_VOICE, granular]);
|
|
125
|
+
return refs;
|
|
126
|
+
}
|
|
127
|
+
|
|
101
128
|
/** The project WAV a wavetable track plays, if any. */
|
|
102
129
|
export function localWavetable(
|
|
103
130
|
track: TrackScore["tracks"][number],
|
|
@@ -585,8 +612,7 @@ export class SampleLibrary implements SampleSource {
|
|
|
585
612
|
this.inUse = new Set();
|
|
586
613
|
const urls: string[] = [];
|
|
587
614
|
for (const track of score.tracks) {
|
|
588
|
-
|
|
589
|
-
for (const ref of Object.values(track.sampler.voices)) {
|
|
615
|
+
for (const [, ref] of trackSampleRefs(track)) {
|
|
590
616
|
if (ref.sha256) this.inUse.add(ref.sha256);
|
|
591
617
|
if (ref.url) urls.push(ref.url);
|
|
592
618
|
}
|
|
@@ -656,10 +682,7 @@ export class SampleLibrary implements SampleSource {
|
|
|
656
682
|
}
|
|
657
683
|
}
|
|
658
684
|
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]!;
|
|
685
|
+
for (const [voice, ref] of trackSampleRefs(track)) {
|
|
663
686
|
let src = ref.src;
|
|
664
687
|
if (src.startsWith(PACK_PREFIX)) {
|
|
665
688
|
try {
|