@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/live.ts
CHANGED
|
@@ -17,11 +17,48 @@ import {
|
|
|
17
17
|
type Track,
|
|
18
18
|
} from "../../core/score.ts";
|
|
19
19
|
import { bpmAtTick } from "../../core/tempo.ts";
|
|
20
|
-
import { sampleKey, type SampleBank } from "./samples.ts";
|
|
20
|
+
import { EMPTY_SAMPLE_BANK, sampleKey, type SampleBank } from "./samples.ts";
|
|
21
21
|
import { RENDER_CHANNELS, renderScorePcm } from "./wav.ts";
|
|
22
|
+
import { engineFor, RING_OUT_FADE_SECONDS } from "./instruments.ts";
|
|
23
|
+
import { chordDigest, chordTimeline } from "./granular.ts";
|
|
24
|
+
import { resolveGranular } from "../../core/granular.ts";
|
|
25
|
+
import { noteHz, resolveTuning } from "../../core/tuning.ts";
|
|
26
|
+
import { liveFitPending, withLiveFit } from "./fit.ts";
|
|
27
|
+
import { isOrganFamily } from "../../core/keys.ts";
|
|
28
|
+
import type { LiveFullReply, LiveFullRequest } from "./live-worker.ts";
|
|
29
|
+
import { isBowed, liveStringTrack } from "./strings/engine.ts";
|
|
30
|
+
import { resolveString } from "../../core/strings.ts";
|
|
22
31
|
|
|
23
32
|
/** A rendered live note: interleaved stereo 16-bit PCM. */
|
|
24
|
-
export type LiveNotePcm = Readonly<{
|
|
33
|
+
export type LiveNotePcm = Readonly<{
|
|
34
|
+
pcm: Int16Array;
|
|
35
|
+
frames: number;
|
|
36
|
+
/** Release fade on note-off, in seconds; absent is the 10 ms default. */
|
|
37
|
+
releaseSeconds?: number;
|
|
38
|
+
/**
|
|
39
|
+
* A fitted sample window (0.6 `bpm`/`len`/`fitmode`) longer than 8 s is
|
|
40
|
+
* still being computed: the note is silent and not cached; play it again
|
|
41
|
+
* once its background fit (fit.ts `onFitReady`) lands.
|
|
42
|
+
*/
|
|
43
|
+
fitting?: true;
|
|
44
|
+
/**
|
|
45
|
+
* True when this is only the first window of a guitar-rig or bowed note
|
|
46
|
+
* (`LIVE_RIG_WINDOW_SECONDS`): call `LiveSynth.render` again with
|
|
47
|
+
* `full: true` off the key path and swap the result in by voice id.
|
|
48
|
+
*/
|
|
49
|
+
partial?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* The organ clock tick a first window rendered at: pass it back as the
|
|
52
|
+
* request's `clock` for the full pass so the wheels and rotors continue.
|
|
53
|
+
*/
|
|
54
|
+
clock?: number;
|
|
55
|
+
/**
|
|
56
|
+
* The key's release does not fade the voice: it rings to the end of its
|
|
57
|
+
* rendered tail (0.6.1 modal `damp 0`), which ends in a `RING_OUT_FADE_SECONDS`
|
|
58
|
+
* fade when the length cap cut it.
|
|
59
|
+
*/
|
|
60
|
+
ringOut?: true;
|
|
61
|
+
}>;
|
|
25
62
|
|
|
26
63
|
export type LiveNoteRequest = Readonly<{
|
|
27
64
|
score: TrackScore;
|
|
@@ -35,8 +72,20 @@ export type LiveNoteRequest = Readonly<{
|
|
|
35
72
|
samples?: SampleBank;
|
|
36
73
|
/** Song tick being played: the tempo there sets length and synced effects. */
|
|
37
74
|
tick?: number;
|
|
75
|
+
/** Render the whole note even through a guitar rig (the background pass). */
|
|
76
|
+
full?: boolean;
|
|
77
|
+
/** An organ note's clock tick from its first window (`LiveNotePcm.clock`). */
|
|
78
|
+
clock?: number;
|
|
38
79
|
}>;
|
|
39
80
|
|
|
81
|
+
/**
|
|
82
|
+
* A guitar rig (stomp, head, cab) is oversampled and costs 15-25 ms per
|
|
83
|
+
* track-second, so a held note through one first renders only this window,
|
|
84
|
+
* at render quality, and the rest follows in the background. Every rig stage
|
|
85
|
+
* is causal, so the window is the exact prefix of the full note.
|
|
86
|
+
*/
|
|
87
|
+
export const LIVE_RIG_WINDOW_SECONDS = 0.75;
|
|
88
|
+
|
|
40
89
|
/** Longest a single live note is rendered for (a held sustain pedal). */
|
|
41
90
|
export const MAX_LIVE_NOTE_SECONDS = 8;
|
|
42
91
|
const CACHE_ENTRIES = 96;
|
|
@@ -47,6 +96,8 @@ const CACHE_ENTRIES = 96;
|
|
|
47
96
|
*/
|
|
48
97
|
export class LiveSynth {
|
|
49
98
|
private readonly cache = new Map<string, LiveNotePcm>();
|
|
99
|
+
/** f061-organ: the live organ clock's origin (performance.now ms). */
|
|
100
|
+
private readonly organEpoch = performance.now();
|
|
50
101
|
|
|
51
102
|
public constructor(private readonly sampleRate: number) {}
|
|
52
103
|
|
|
@@ -58,12 +109,24 @@ export class LiveSynth {
|
|
|
58
109
|
return this.cache.size;
|
|
59
110
|
}
|
|
60
111
|
|
|
61
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Stores a whole rig note rendered elsewhere (the background worker) so the
|
|
114
|
+
* next press of that key hits the cache and skips the window.
|
|
115
|
+
*/
|
|
116
|
+
public adopt(request: LiveNoteRequest, pcm: LiveNotePcm): void {
|
|
117
|
+
const plan = this.plan(request);
|
|
118
|
+
if (!plan || pcm.partial) return;
|
|
119
|
+
this.cache.set(plan.key, pcm);
|
|
120
|
+
this.trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private plan(request: LiveNoteRequest) {
|
|
62
124
|
const track = request.score.tracks.find(
|
|
63
125
|
(candidate) => candidate.id === request.trackId,
|
|
64
126
|
);
|
|
65
127
|
if (!track) return undefined;
|
|
66
128
|
const { score } = request;
|
|
129
|
+
const liveEngine = engineFor(track);
|
|
67
130
|
const bpm =
|
|
68
131
|
request.tick === undefined
|
|
69
132
|
? score.tempoBpm
|
|
@@ -79,6 +142,38 @@ export class LiveSynth {
|
|
|
79
142
|
);
|
|
80
143
|
const velocity = Math.max(0, Math.min(1, request.velocity));
|
|
81
144
|
const pitch = Math.max(0, Math.min(127, Math.round(request.pitch)));
|
|
145
|
+
// f061-organ: one rotating speaker and one set of free-running wheels
|
|
146
|
+
// per organ track. Each key renders from the song tick it sounds at
|
|
147
|
+
// (or the synth's own clock when stopped), so successive notes share
|
|
148
|
+
// the track's rotor and wheel phases instead of restarting them.
|
|
149
|
+
const organTick =
|
|
150
|
+
track.keys && isOrganFamily(track.instrument)
|
|
151
|
+
? Math.max(
|
|
152
|
+
0,
|
|
153
|
+
Math.round(
|
|
154
|
+
request.clock ??
|
|
155
|
+
request.tick ??
|
|
156
|
+
((performance.now() - this.organEpoch) / 1000) * ticksPerSecond,
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
: undefined;
|
|
160
|
+
// Granular `quant chord` (0.6.1): a live note snaps to the song's
|
|
161
|
+
// chords at the played tick, as the offline render of the same note.
|
|
162
|
+
const quantChord =
|
|
163
|
+
track.granular !== undefined &&
|
|
164
|
+
liveEngine?.id === "granular" &&
|
|
165
|
+
resolveGranular(track.granular).quant === "chord"
|
|
166
|
+
? { score, tick: request.tick ?? 0 }
|
|
167
|
+
: undefined;
|
|
168
|
+
const chord = quantChord
|
|
169
|
+
? chordDigest(
|
|
170
|
+
chordTimeline(score),
|
|
171
|
+
quantChord.tick,
|
|
172
|
+
quantChord.tick +
|
|
173
|
+
durationTicks +
|
|
174
|
+
Math.ceil(liveEngine!.tailSeconds(track, pitch) * ticksPerSecond),
|
|
175
|
+
)
|
|
176
|
+
: undefined;
|
|
82
177
|
const key = JSON.stringify([
|
|
83
178
|
liveTrack(track),
|
|
84
179
|
pitch,
|
|
@@ -88,11 +183,67 @@ export class LiveSynth {
|
|
|
88
183
|
score.ticksPerBeat,
|
|
89
184
|
this.sampleRate,
|
|
90
185
|
samplerDigest(track, request.samples),
|
|
186
|
+
// Assets a 0.6 engine reads (a granular sample source).
|
|
187
|
+
...engineDigest(track, request.samples),
|
|
91
188
|
// The song tuning and key (the default tuning root) retune live notes.
|
|
92
189
|
...(score.tuning || track.tuning
|
|
93
190
|
? [score.tuning ?? null, score.key]
|
|
94
191
|
: []),
|
|
192
|
+
...(organTick !== undefined ? [`organ:${organTick}`] : []),
|
|
193
|
+
// A 0.6 engine's assets, and the key it may tune to.
|
|
194
|
+
...(liveEngine
|
|
195
|
+
? [
|
|
196
|
+
liveEngine.assetDigests?.(
|
|
197
|
+
track,
|
|
198
|
+
request.samples ?? EMPTY_SAMPLE_BANK,
|
|
199
|
+
) ?? [],
|
|
200
|
+
score.key ?? null,
|
|
201
|
+
]
|
|
202
|
+
: []),
|
|
203
|
+
...(chord ? [chord] : []),
|
|
95
204
|
]);
|
|
205
|
+
return {
|
|
206
|
+
track,
|
|
207
|
+
score,
|
|
208
|
+
bpm,
|
|
209
|
+
seconds,
|
|
210
|
+
durationTicks,
|
|
211
|
+
velocity,
|
|
212
|
+
pitch,
|
|
213
|
+
key,
|
|
214
|
+
organTick,
|
|
215
|
+
quantChord,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
public render(request: LiveNoteRequest): LiveNotePcm | undefined {
|
|
220
|
+
const plan = this.plan(request);
|
|
221
|
+
if (!plan) return undefined;
|
|
222
|
+
const {
|
|
223
|
+
track,
|
|
224
|
+
score,
|
|
225
|
+
bpm,
|
|
226
|
+
seconds,
|
|
227
|
+
durationTicks,
|
|
228
|
+
velocity,
|
|
229
|
+
pitch,
|
|
230
|
+
key,
|
|
231
|
+
organTick,
|
|
232
|
+
quantChord,
|
|
233
|
+
} = plan;
|
|
234
|
+
const liveEngine = engineFor(track);
|
|
235
|
+
// The first window of a rig note: its own cache entry, swapped for the
|
|
236
|
+
// whole note once the background pass has rendered it.
|
|
237
|
+
// Organs too: wheels, rotors and drive cost 4-8 ms per note-second.
|
|
238
|
+
const windowed =
|
|
239
|
+
request.full !== true &&
|
|
240
|
+
(hasRig(track) || organTick !== undefined || bowedTrack(track)) &&
|
|
241
|
+
seconds > LIVE_RIG_WINDOW_SECONDS &&
|
|
242
|
+
!this.cache.has(key);
|
|
243
|
+
if (windowed) {
|
|
244
|
+
const first = this.cache.get(`${key}#window`);
|
|
245
|
+
if (first) return first;
|
|
246
|
+
}
|
|
96
247
|
const hit = this.cache.get(key);
|
|
97
248
|
if (hit) {
|
|
98
249
|
// Refresh LRU order.
|
|
@@ -112,7 +263,7 @@ export class LiveSynth {
|
|
|
112
263
|
ticksPerBeat: score.ticksPerBeat,
|
|
113
264
|
// The song tuning, and the key whose tonic is the default root.
|
|
114
265
|
...(score.tuning ? { tuning: score.tuning } : {}),
|
|
115
|
-
...(score.tuning || track.tuning ? { key: score.key } : {}),
|
|
266
|
+
...(score.tuning || track.tuning || liveEngine ? { key: score.key } : {}),
|
|
116
267
|
tracks: [liveTrack(track)],
|
|
117
268
|
notes: [
|
|
118
269
|
{
|
|
@@ -125,23 +276,73 @@ export class LiveSynth {
|
|
|
125
276
|
},
|
|
126
277
|
],
|
|
127
278
|
});
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
279
|
+
// A 0.6 engine's ring-out sets the one-note length and the key release.
|
|
280
|
+
const tail = liveEngine
|
|
281
|
+
? Math.max(0, liveEngine.tailSeconds(track, pitch))
|
|
282
|
+
: 0;
|
|
283
|
+
// Fitted sample windows over 8 s fit in the background (silent until
|
|
284
|
+
// ready, never at the wrong pitch); shorter ones fit synchronously.
|
|
285
|
+
const audio = withLiveFit(() =>
|
|
286
|
+
renderScorePcm(single, {
|
|
287
|
+
sampleRate: this.sampleRate,
|
|
288
|
+
// The renderer's shortest one-shot is 1 s; the window is cut below.
|
|
289
|
+
maxSeconds: windowed
|
|
290
|
+
? 1
|
|
291
|
+
: Math.max(MAX_LIVE_NOTE_SECONDS + 4, seconds + tail),
|
|
292
|
+
...(request.samples ? { samples: request.samples } : {}),
|
|
293
|
+
...(organTick ? { seedTick: organTick } : {}),
|
|
294
|
+
...(quantChord ? { quantChord } : {}),
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
const frames = windowed
|
|
298
|
+
? Math.min(
|
|
299
|
+
audibleFrames(audio.pcm),
|
|
300
|
+
Math.round(LIVE_RIG_WINDOW_SECONDS * this.sampleRate),
|
|
301
|
+
)
|
|
302
|
+
: audibleFrames(audio.pcm);
|
|
303
|
+
const release = liveEngine?.releaseSeconds
|
|
304
|
+
? liveEngine.releaseSeconds(
|
|
305
|
+
track,
|
|
306
|
+
pitch,
|
|
307
|
+
noteHz(
|
|
308
|
+
pitch,
|
|
309
|
+
undefined,
|
|
310
|
+
resolveTuning(score.tuning, track.tuning, score.key),
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
: tail;
|
|
314
|
+
const ringOut = !windowed && liveEngine?.ringOut?.(track) === true;
|
|
315
|
+
const pcm = audio.pcm.subarray(0, frames * RENDER_CHANNELS);
|
|
316
|
+
if (ringOut) fadeCutTail(pcm, frames, this.sampleRate);
|
|
134
317
|
const rendered: LiveNotePcm = {
|
|
135
|
-
pcm
|
|
318
|
+
pcm,
|
|
136
319
|
frames,
|
|
320
|
+
...(ringOut ? { ringOut: true as const } : {}),
|
|
321
|
+
...(liveEngine
|
|
322
|
+
? {
|
|
323
|
+
releaseSeconds: Math.min(
|
|
324
|
+
Math.max(0, release),
|
|
325
|
+
MAX_LIVE_NOTE_SECONDS,
|
|
326
|
+
),
|
|
327
|
+
}
|
|
328
|
+
: {}),
|
|
329
|
+
...(windowed ? { partial: true } : {}),
|
|
330
|
+
...(windowed && organTick !== undefined ? { clock: organTick } : {}),
|
|
137
331
|
};
|
|
138
|
-
|
|
332
|
+
if (liveFitPending()) return { ...rendered, fitting: true };
|
|
333
|
+
// Organ keys render at their song tick: caching them would only evict.
|
|
334
|
+
if (organTick !== undefined) return rendered;
|
|
335
|
+
this.cache.set(windowed ? `${key}#window` : key, rendered);
|
|
336
|
+
this.trim();
|
|
337
|
+
return rendered;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private trim(): void {
|
|
139
341
|
while (this.cache.size > CACHE_ENTRIES) {
|
|
140
342
|
const oldest = this.cache.keys().next().value;
|
|
141
343
|
if (oldest === undefined) break;
|
|
142
344
|
this.cache.delete(oldest);
|
|
143
345
|
}
|
|
144
|
-
return rendered;
|
|
145
346
|
}
|
|
146
347
|
}
|
|
147
348
|
|
|
@@ -150,17 +351,43 @@ export class LiveSynth {
|
|
|
150
351
|
* without track `time`, so a key sounds once, as played, not re-placed.
|
|
151
352
|
*/
|
|
152
353
|
function liveTrack(track: Track): Track {
|
|
153
|
-
// A held key is the performance: the recorded
|
|
354
|
+
// A held key is the performance: the recorded pedals and the render-time
|
|
154
355
|
// humanize stay out of it. The velocity curve stays, so a key sounds like
|
|
155
356
|
// the note it records as.
|
|
156
357
|
const {
|
|
157
358
|
solo: _solo,
|
|
158
359
|
time: _time,
|
|
159
360
|
pedal: _pedal,
|
|
361
|
+
softPedal: _softPedal,
|
|
362
|
+
sostenuto: _sostenuto,
|
|
160
363
|
humanize: _humanize,
|
|
161
364
|
...rest
|
|
162
365
|
} = track;
|
|
163
|
-
|
|
366
|
+
// A bowed section plays two players live (f061-bowed render budget).
|
|
367
|
+
return liveStringTrack({ ...rest, muted: false });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Whether the track is a bowed string: a held bow costs about 5 ms per
|
|
372
|
+
* second, so it windows like a rig (the bow loop is causal too).
|
|
373
|
+
*/
|
|
374
|
+
function bowedTrack(track: Track): boolean {
|
|
375
|
+
return (
|
|
376
|
+
track.instrument === "string" &&
|
|
377
|
+
track.string !== undefined &&
|
|
378
|
+
isBowed(resolveString(track.string))
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Whether the track plays through a guitar rig stage. */
|
|
383
|
+
function hasRig(track: Track): boolean {
|
|
384
|
+
return Boolean(track.fx?.stomp || track.fx?.head || track.fx?.cab);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function engineDigest(track: Track, bank: SampleBank | undefined): string[] {
|
|
388
|
+
const engine = engineFor(track);
|
|
389
|
+
if (!engine?.assetDigests || !bank) return [];
|
|
390
|
+
return [...engine.assetDigests(track, bank)];
|
|
164
391
|
}
|
|
165
392
|
|
|
166
393
|
function samplerDigest(track: Track, bank: SampleBank | undefined): string[] {
|
|
@@ -172,8 +399,127 @@ function samplerDigest(track: Track, bank: SampleBank | undefined): string[] {
|
|
|
172
399
|
}
|
|
173
400
|
|
|
174
401
|
/** Frames up to the last non-silent sample, so mixing skips the dead tail. */
|
|
402
|
+
/**
|
|
403
|
+
* A ring-out note cut by the render length still sounds at its last frame:
|
|
404
|
+
* fade its final `RING_OUT_FADE_SECONDS` linearly to zero so the cap never
|
|
405
|
+
* clicks (each step stays under -60 dB of full scale). A tail that already
|
|
406
|
+
* decayed to silence is left alone.
|
|
407
|
+
*/
|
|
408
|
+
export function fadeCutTail(
|
|
409
|
+
pcm: Int16Array,
|
|
410
|
+
frames: number,
|
|
411
|
+
sampleRate: number,
|
|
412
|
+
): void {
|
|
413
|
+
if (frames === 0) return;
|
|
414
|
+
const last = (frames - 1) * RENDER_CHANNELS;
|
|
415
|
+
// Below -60 dBFS the cut is inaudible.
|
|
416
|
+
if (Math.abs(pcm[last]!) < 33 && Math.abs(pcm[last + 1]!) < 33) return;
|
|
417
|
+
const fade = Math.min(frames, Math.round(RING_OUT_FADE_SECONDS * sampleRate));
|
|
418
|
+
for (let i = 0; i < fade; i += 1) {
|
|
419
|
+
const frame = frames - fade + i;
|
|
420
|
+
const gain = (fade - 1 - i) / fade;
|
|
421
|
+
for (let c = 0; c < RENDER_CHANNELS; c += 1) {
|
|
422
|
+
const at = frame * RENDER_CHANNELS + c;
|
|
423
|
+
pcm[at] = Math.round(pcm[at]! * gain);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
175
428
|
function audibleFrames(pcm: Int16Array): number {
|
|
176
429
|
let last = pcm.length - 1;
|
|
177
430
|
while (last >= 0 && pcm[last] === 0) last -= 1;
|
|
178
431
|
return Math.ceil((last + 1) / RENDER_CHANNELS);
|
|
179
432
|
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Renders whole guitar-rig notes on a worker (`live-worker.ts`), in request
|
|
436
|
+
* order. When a worker cannot start, `full` falls back to rendering on the
|
|
437
|
+
* calling thread in a later tick, as before.
|
|
438
|
+
*/
|
|
439
|
+
export class LiveFullRenderer {
|
|
440
|
+
private worker: Bun.Worker | undefined;
|
|
441
|
+
private broken = false;
|
|
442
|
+
private nextId = 1;
|
|
443
|
+
private readonly pending = new Map<
|
|
444
|
+
number,
|
|
445
|
+
(pcm: LiveNotePcm | undefined) => void
|
|
446
|
+
>();
|
|
447
|
+
|
|
448
|
+
public full(
|
|
449
|
+
synth: LiveSynth,
|
|
450
|
+
request: LiveNoteRequest,
|
|
451
|
+
): Promise<LiveNotePcm | undefined> {
|
|
452
|
+
const worker = this.ensure();
|
|
453
|
+
if (!worker)
|
|
454
|
+
return new Promise((resolve) =>
|
|
455
|
+
setTimeout(() => resolve(synth.render({ ...request, full: true })), 0),
|
|
456
|
+
);
|
|
457
|
+
const id = this.nextId;
|
|
458
|
+
this.nextId += 1;
|
|
459
|
+
return new Promise((resolve) => {
|
|
460
|
+
this.pending.set(id, (pcm) => {
|
|
461
|
+
if (pcm) synth.adopt(request, pcm);
|
|
462
|
+
resolve(pcm);
|
|
463
|
+
});
|
|
464
|
+
const message: LiveFullRequest = {
|
|
465
|
+
id,
|
|
466
|
+
sampleRate: synth.rate,
|
|
467
|
+
score: request.score.toJSON(),
|
|
468
|
+
trackId: request.trackId,
|
|
469
|
+
pitch: request.pitch,
|
|
470
|
+
velocity: request.velocity,
|
|
471
|
+
seconds: request.seconds,
|
|
472
|
+
...(request.tick === undefined ? {} : { tick: request.tick }),
|
|
473
|
+
...(request.clock === undefined ? {} : { clock: request.clock }),
|
|
474
|
+
...(request.samples ? { samples: request.samples } : {}),
|
|
475
|
+
};
|
|
476
|
+
try {
|
|
477
|
+
worker.postMessage(message);
|
|
478
|
+
} catch {
|
|
479
|
+
this.pending.delete(id);
|
|
480
|
+
this.fail();
|
|
481
|
+
setTimeout(() => resolve(synth.render({ ...request, full: true })), 0);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Stops the worker; pending notes resolve without a full pass. */
|
|
487
|
+
public close(): void {
|
|
488
|
+
this.fail();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private ensure(): Bun.Worker | undefined {
|
|
492
|
+
if (this.worker) return this.worker;
|
|
493
|
+
if (this.broken) return undefined;
|
|
494
|
+
try {
|
|
495
|
+
// The DOM lib's Worker type lacks Bun's `unref`; the runtime is Bun's.
|
|
496
|
+
const worker = new Worker(
|
|
497
|
+
new URL("./live-worker.ts", import.meta.url).href,
|
|
498
|
+
) as unknown as Bun.Worker;
|
|
499
|
+
worker.addEventListener("message", (event: Event) => {
|
|
500
|
+
const reply = (event as MessageEvent<LiveFullReply>).data;
|
|
501
|
+
const done = this.pending.get(reply.id);
|
|
502
|
+
if (!done) return;
|
|
503
|
+
this.pending.delete(reply.id);
|
|
504
|
+
done(reply.pcm);
|
|
505
|
+
});
|
|
506
|
+
worker.addEventListener("error", () => this.fail());
|
|
507
|
+
worker.unref();
|
|
508
|
+
this.worker = worker;
|
|
509
|
+
return worker;
|
|
510
|
+
} catch {
|
|
511
|
+
this.broken = true;
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private fail(): void {
|
|
517
|
+
this.broken = true;
|
|
518
|
+
const worker = this.worker;
|
|
519
|
+
this.worker = undefined;
|
|
520
|
+
worker?.terminate();
|
|
521
|
+
const waiters = [...this.pending.values()];
|
|
522
|
+
this.pending.clear();
|
|
523
|
+
for (const done of waiters) done(undefined);
|
|
524
|
+
}
|
|
525
|
+
}
|
package/src/audio/loudness.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* K-weighted mean square, momentary (400 ms) and short-term (3 s) loudness
|
|
4
4
|
* every 100 ms, gated integrated loudness (absolute -70 LUFS, relative
|
|
5
5
|
* -10 LU), loudness range per EBU Tech 3342 (short-term values gated at
|
|
6
|
-
* -70 LUFS and -20 LU, 10th to 95th percentile) and true peak from
|
|
6
|
+
* -70 LUFS and -20 LU, 10th to 95th percentile) and true peak from an 8x
|
|
7
7
|
* oversampled polyphase interpolator (BS.1770-4 Annex 2, with a longer
|
|
8
8
|
* windowed-sinc filter than its example). Validated against
|
|
9
9
|
* the synthetic test signals of EBU Tech 3341 and 3342 in loudness.test.ts.
|
|
@@ -31,7 +31,7 @@ export type Loudness = Readonly<{
|
|
|
31
31
|
shortTermMax: number;
|
|
32
32
|
/** Loudness range (LRA), LU. */
|
|
33
33
|
range: number;
|
|
34
|
-
/** dBTP: the
|
|
34
|
+
/** dBTP: the 8x oversampled peak. */
|
|
35
35
|
truePeak: number;
|
|
36
36
|
/** dBFS: the highest sample. */
|
|
37
37
|
samplePeak: number;
|
|
@@ -239,15 +239,18 @@ function besselI0(x: number): number {
|
|
|
239
239
|
let phases: Float64Array[] | undefined;
|
|
240
240
|
|
|
241
241
|
/**
|
|
242
|
-
* The true-peak interpolator:
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
242
|
+
* The true-peak interpolator: Kaiser-windowed sinc phases of 32 taps, each
|
|
243
|
+
* in time order over x[n-15] .. x[n+16], estimating the signal at n + 1/8,
|
|
244
|
+
* 3/8, 5/8 and 7/8, then (0.6) n + 2/8, 4/8 and 6/8. With the samples
|
|
245
|
+
* themselves they give an evenly spaced 8x oversampled true peak; the odd
|
|
246
|
+
* eighths alone left 1/4-sample gaps that read dense material up to 0.2 dB
|
|
247
|
+
* low at a peak near n + 3/4. The odd phases come first and are unchanged,
|
|
248
|
+
* so a peak they already caught reads the same. BS.1770-4 Annex 2's 12-tap example filter rolls
|
|
246
249
|
* off early and reads dense, bright material (supersaws, distortion) up to
|
|
247
250
|
* half a dB low; this one stays within a few hundredths of a 64x reference.
|
|
248
251
|
*/
|
|
249
252
|
export function truePeakPhases(): readonly Float64Array[] {
|
|
250
|
-
phases ??= [1, 3, 5, 7].map((eighth) => {
|
|
253
|
+
phases ??= [1, 3, 5, 7, 2, 4, 6].map((eighth) => {
|
|
251
254
|
const frac = eighth / 8;
|
|
252
255
|
const taps = new Float64Array(TAPS_PER_PHASE);
|
|
253
256
|
const before = TAPS_PER_PHASE / 2 - 1;
|
|
@@ -266,9 +269,23 @@ export function truePeakPhases(): readonly Float64Array[] {
|
|
|
266
269
|
return phases;
|
|
267
270
|
}
|
|
268
271
|
|
|
272
|
+
let interleaved: Float64Array | undefined;
|
|
273
|
+
|
|
274
|
+
/** The seven phases interleaved by tap (`tap * 7 + phase`). */
|
|
275
|
+
function interleavedPhases(): Float64Array {
|
|
276
|
+
if (interleaved) return interleaved;
|
|
277
|
+
const branches = truePeakPhases();
|
|
278
|
+
const flat = new Float64Array(TAPS_PER_PHASE * branches.length);
|
|
279
|
+
branches.forEach((taps, phase) => {
|
|
280
|
+
for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1)
|
|
281
|
+
flat[tap * branches.length + phase] = taps[tap]!;
|
|
282
|
+
});
|
|
283
|
+
return (interleaved = flat);
|
|
284
|
+
}
|
|
285
|
+
|
|
269
286
|
/**
|
|
270
287
|
* Largest absolute value between each sample and the next (both samples
|
|
271
|
-
* and the
|
|
288
|
+
* and the seven interpolated points), as `out[n]` for n .. n+1.
|
|
272
289
|
*/
|
|
273
290
|
export function interSamplePeaks(
|
|
274
291
|
channel: Float64Array,
|
|
@@ -277,6 +294,7 @@ export function interSamplePeaks(
|
|
|
277
294
|
): void {
|
|
278
295
|
const n = channel.length;
|
|
279
296
|
const branches = truePeakPhases();
|
|
297
|
+
const flat = interleavedPhases();
|
|
280
298
|
const before = TAPS_PER_PHASE / 2 - 1;
|
|
281
299
|
const at = (index: number): number => {
|
|
282
300
|
if (index >= 0 && index < n) return channel[index]!;
|
|
@@ -287,23 +305,50 @@ export function interSamplePeaks(
|
|
|
287
305
|
let peak = Math.abs(channel[index]!);
|
|
288
306
|
const next = Math.abs(at(index + 1));
|
|
289
307
|
if (next > peak) peak = next;
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
308
|
+
const base = index - before;
|
|
309
|
+
if (base >= 0 && base + TAPS_PER_PHASE <= n) {
|
|
310
|
+
// Every phase in one pass over the window (the interior fast path).
|
|
311
|
+
let s0 = 0;
|
|
312
|
+
let s1 = 0;
|
|
313
|
+
let s2 = 0;
|
|
314
|
+
let s3 = 0;
|
|
315
|
+
let s4 = 0;
|
|
316
|
+
let s5 = 0;
|
|
317
|
+
let s6 = 0;
|
|
318
|
+
for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1) {
|
|
319
|
+
const x = channel[base + tap]!;
|
|
320
|
+
const k = tap * 7;
|
|
321
|
+
s0 += x * flat[k]!;
|
|
322
|
+
s1 += x * flat[k + 1]!;
|
|
323
|
+
s2 += x * flat[k + 2]!;
|
|
324
|
+
s3 += x * flat[k + 3]!;
|
|
325
|
+
s4 += x * flat[k + 4]!;
|
|
326
|
+
s5 += x * flat[k + 5]!;
|
|
327
|
+
s6 += x * flat[k + 6]!;
|
|
328
|
+
}
|
|
329
|
+
peak = Math.max(
|
|
330
|
+
peak,
|
|
331
|
+
Math.abs(s0),
|
|
332
|
+
Math.abs(s1),
|
|
333
|
+
Math.abs(s2),
|
|
334
|
+
Math.abs(s3),
|
|
335
|
+
Math.abs(s4),
|
|
336
|
+
Math.abs(s5),
|
|
337
|
+
Math.abs(s6),
|
|
338
|
+
);
|
|
339
|
+
} else
|
|
340
|
+
for (const taps of branches) {
|
|
341
|
+
let sum = 0;
|
|
297
342
|
for (let tap = 0; tap < TAPS_PER_PHASE; tap += 1)
|
|
298
|
-
sum += at(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
343
|
+
sum += at(base + tap) * taps[tap]!;
|
|
344
|
+
const magnitude = Math.abs(sum);
|
|
345
|
+
if (magnitude > peak) peak = magnitude;
|
|
346
|
+
}
|
|
302
347
|
if (peak > out[index]!) out[index] = peak;
|
|
303
348
|
}
|
|
304
349
|
}
|
|
305
350
|
|
|
306
|
-
/** The
|
|
351
|
+
/** The 8x oversampled true peak of a stereo buffer, linear. */
|
|
307
352
|
export function truePeakGain(
|
|
308
353
|
left: Float64Array,
|
|
309
354
|
right: Float64Array,
|
package/src/audio/preview.ts
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
* wavetables so position changes are audible.
|
|
17
17
|
*/
|
|
18
18
|
import { isDrumInstrument } from "../../core/drums.ts";
|
|
19
|
+
import { slicePedals } from "../../core/expression.ts";
|
|
20
|
+
import {
|
|
21
|
+
isStringTrack,
|
|
22
|
+
resolveString,
|
|
23
|
+
stringPresetOf,
|
|
24
|
+
} from "../../core/strings.ts";
|
|
19
25
|
import type { SongMaster } from "../../core/master.ts";
|
|
20
26
|
import { MODES, parseKey } from "../../core/chords.ts";
|
|
21
27
|
import { rhythmVoicePitch } from "../../core/rhythm.ts";
|
|
@@ -129,6 +135,16 @@ export function phraseRole(track: Track): PhraseRole {
|
|
|
129
135
|
if (isSamplerInstrument(track.instrument))
|
|
130
136
|
return track.sampler?.mode === "keyed" ? "chord" : "groove";
|
|
131
137
|
if (isWavetableInstrument(track.instrument)) return "drone";
|
|
138
|
+
// Grain clouds are textures: held notes let them evolve.
|
|
139
|
+
if (track.instrument === "granular") return "drone";
|
|
140
|
+
if (isStringTrack(track) && resolveString(track.string).exciter === "bow") {
|
|
141
|
+
// Bowed: low strings walk a line, solo strings sing a legato line,
|
|
142
|
+
// sections and pizz/trem hold chords.
|
|
143
|
+
const preset = stringPresetOf(track.string);
|
|
144
|
+
if (/^(contrabass|contrabasses|cello|cellos)$/.test(preset)) return "riff";
|
|
145
|
+
if (/^(violin|viola|fiddle|erhu|kamancheh)$/.test(preset)) return "lead";
|
|
146
|
+
return "chord";
|
|
147
|
+
}
|
|
132
148
|
const words = `${track.instrument} ${track.name} ${track.id}`.toLowerCase();
|
|
133
149
|
if (/\b(bass|sub|808)/.test(words)) return "riff";
|
|
134
150
|
if (/\b(lead|arp|pluck|saw|square|pulse|z_|mono|melody|hook)/.test(words))
|
|
@@ -285,7 +301,7 @@ function sliceTrack(track: Track, start: number, end: number): Track {
|
|
|
285
301
|
lanes[name] = sliceLane(points, start, end);
|
|
286
302
|
sliced.fxAutomation = lanes;
|
|
287
303
|
}
|
|
288
|
-
return sliced as Track;
|
|
304
|
+
return slicePedals(sliced as Track, [{ from: start, to: end, offset: 0 }]);
|
|
289
305
|
}
|
|
290
306
|
|
|
291
307
|
/**
|