@hraness/dawg 0.6.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 +34 -0
- package/DAWG.md +338 -51
- package/README.md +4 -4
- package/core/chords.ts +492 -4
- package/core/diff.ts +5 -0
- package/core/expression.ts +143 -6
- package/core/fx.ts +258 -6
- package/core/granular.ts +91 -0
- package/core/instruments.ts +161 -0
- package/core/keys.ts +573 -4
- package/core/resonators.ts +291 -3
- package/core/score.ts +349 -3
- package/core/sdk/print.ts +78 -3
- package/core/sdk/v1.ts +1320 -36
- package/core/sections.ts +12 -4
- package/core/strings.ts +237 -2
- package/core/winds.ts +652 -0
- package/guides/automation.md +1 -0
- package/guides/chords.md +3 -1
- package/guides/effects.md +3 -2
- package/guides/media.md +1 -1
- package/guides/performance.md +3 -0
- package/guides/resample.md +26 -0
- package/guides/sounds.md +7 -6
- package/package.json +1 -1
- package/src/agent/agent.ts +8 -2
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/ops.ts +4 -1
- package/src/agent/preview-tool.ts +2 -1
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/tools.ts +200 -15
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/arrange.ts +21 -0
- package/src/audio/dsp/envelope.ts +161 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/effects/chain.ts +18 -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/engine.ts +46 -5
- package/src/audio/fit.ts +45 -0
- package/src/audio/granular.ts +352 -21
- package/src/audio/instrument-check.ts +9 -5
- package/src/audio/instruments.ts +32 -3
- package/src/audio/keys/electric.ts +420 -0
- package/src/audio/keys/engine.ts +143 -19
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +50 -6
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/live-worker.ts +3 -1
- package/src/audio/live.ts +136 -9
- package/src/audio/preview.ts +15 -1
- package/src/audio/resample.ts +285 -0
- package/src/audio/resonators.ts +10 -2
- package/src/audio/sampler.ts +168 -21
- package/src/audio/samples.ts +26 -1
- package/src/audio/strings/body.ts +29 -16
- package/src/audio/strings/bow.ts +656 -0
- package/src/audio/strings/engine.ts +266 -20
- package/src/audio/strings/measure.test-helpers.ts +2 -0
- package/src/audio/wav.ts +119 -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 +103 -22
- package/src/commands/fx.ts +4 -1
- package/src/commands/granular.ts +7 -2
- package/src/commands/help.ts +91 -8
- package/src/commands/keys.ts +287 -11
- package/src/commands/modal.ts +67 -4
- package/src/commands/resample.ts +281 -0
- package/src/commands/rig.ts +5 -1
- package/src/commands/sample.ts +49 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/string.ts +28 -2
- package/src/commands/strum.ts +473 -0
- package/src/commands/wind.ts +244 -0
- package/src/main.ts +129 -7
- package/src/project/check.ts +5 -1
- package/src/render.ts +16 -0
- package/src/session/presence.ts +16 -3
- package/src/tui/audition.ts +1 -1
- package/src/tui/menu.ts +637 -35
- package/src/tui/modal-menu.ts +34 -7
- 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 +34 -9
- package/src/tui/wind-menu.ts +144 -0
|
@@ -22,6 +22,12 @@ import type { EngineContext, InstrumentEngine } from "../instruments.ts";
|
|
|
22
22
|
import { applyBody, bodyFor, symKeys, sympathetic } from "./body.ts";
|
|
23
23
|
import { clamp } from "./loop.ts";
|
|
24
24
|
import { PluckString, type Exciter, type PluckSpec } from "./pluck.ts";
|
|
25
|
+
import {
|
|
26
|
+
BowedString,
|
|
27
|
+
type BowControl,
|
|
28
|
+
type BowSpec,
|
|
29
|
+
type BowNote,
|
|
30
|
+
} from "./bow.ts";
|
|
25
31
|
|
|
26
32
|
const BLOCK = 128;
|
|
27
33
|
/** Longest a single string may ring (seconds). */
|
|
@@ -84,8 +90,155 @@ export function noteSpec(
|
|
|
84
90
|
};
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
/** Whether resolved values play the bowed voice (`exciter bow`). */
|
|
94
|
+
export function isBowed(values: StringValues): boolean {
|
|
95
|
+
return values.exciter === "bow";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Play mode keeps a bowed section to two players (live budget). */
|
|
99
|
+
export const LIVE_BOW_UNISON = 2;
|
|
100
|
+
|
|
101
|
+
/** The track as play mode hears it: a bowed section capped at 2 players. */
|
|
102
|
+
export function liveStringTrack(track: Track): Track {
|
|
103
|
+
if (track.instrument !== "string" || !track.string) return track;
|
|
104
|
+
const values = resolveString(track.string);
|
|
105
|
+
if (!isBowed(values) || (values.unison as number) <= LIVE_BOW_UNISON)
|
|
106
|
+
return track;
|
|
107
|
+
return { ...track, string: { ...track.string, unison: LIVE_BOW_UNISON } };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Bow lanes read every 32 samples (`string-<name>`, absent: the value). */
|
|
111
|
+
const BOW_LANES = ["pressure", "speed", "sord", "dyn"] as const;
|
|
112
|
+
|
|
113
|
+
/** The bowed spec and note fields for one note. */
|
|
114
|
+
export function bowSpec(
|
|
115
|
+
values: StringValues,
|
|
116
|
+
note: PerformedNote,
|
|
117
|
+
track: Track,
|
|
118
|
+
context: EngineContext,
|
|
119
|
+
): { spec: BowSpec; velocity: number; control?: (t: number) => BowControl } {
|
|
120
|
+
const at = (name: string) => valueAt(track, values, name, note.startTick);
|
|
121
|
+
let velocity = clamp(note.velocity, 0, 1);
|
|
122
|
+
const articulation = note.articulation;
|
|
123
|
+
const sens = clamp(values.vel as number, 0, 1);
|
|
124
|
+
velocity = 1 - sens * (1 - velocity);
|
|
125
|
+
let attack = values.attack as number;
|
|
126
|
+
let release = values.release as number;
|
|
127
|
+
// Detache (spec 4): staccato and ghost are short strokes, quick on and off.
|
|
128
|
+
if (articulation === "staccato" || articulation === "ghost") {
|
|
129
|
+
attack = Math.min(attack, 0.01);
|
|
130
|
+
release = Math.min(release, 0.03);
|
|
131
|
+
}
|
|
132
|
+
// A harder stroke presses harder (spec 4: pressure += 0.3 (vel - 0.5)).
|
|
133
|
+
const press = (p: number) => clamp(p + 0.3 * (velocity - 0.5), 0, 1);
|
|
134
|
+
// Accent and marcato bite: pressure +0.2 for the first 80 ms.
|
|
135
|
+
const bite = articulation === "accent" || articulation === "marcato";
|
|
136
|
+
const spec: BowSpec = {
|
|
137
|
+
decay: Math.max(0.02, at("ring")),
|
|
138
|
+
track: values.track as number,
|
|
139
|
+
damp: clamp(values.damp as number, 0, 1),
|
|
140
|
+
pos: clamp(at("pos"), 0.01, 0.5),
|
|
141
|
+
pressure: press(at("pressure")),
|
|
142
|
+
speed: clamp(at("speed"), 0, 1),
|
|
143
|
+
attack,
|
|
144
|
+
release,
|
|
145
|
+
tremhz: values.tremhz as number,
|
|
146
|
+
sord: clamp(at("sord"), 0, 1),
|
|
147
|
+
};
|
|
148
|
+
const lanes = BOW_LANES.filter(
|
|
149
|
+
(name) =>
|
|
150
|
+
(track.fxAutomation?.[`string-${name}` as FxLane]?.length ?? 0) > 0,
|
|
151
|
+
);
|
|
152
|
+
const dyn = clamp(values.dyn as number, 0, 1);
|
|
153
|
+
if (lanes.length === 0 && dyn === 1 && !bite) return { spec, velocity };
|
|
154
|
+
const fixed: BowControl = {
|
|
155
|
+
pressure: spec.pressure,
|
|
156
|
+
speed: spec.speed,
|
|
157
|
+
sord: spec.sord,
|
|
158
|
+
dyn,
|
|
159
|
+
};
|
|
160
|
+
const bitten: BowControl = {
|
|
161
|
+
...fixed,
|
|
162
|
+
pressure: clamp(spec.pressure + 0.2, 0, 1),
|
|
163
|
+
};
|
|
164
|
+
if (lanes.length === 0)
|
|
165
|
+
return {
|
|
166
|
+
spec,
|
|
167
|
+
velocity,
|
|
168
|
+
control: bite ? (t) => (t < 0.08 ? bitten : fixed) : () => fixed,
|
|
169
|
+
};
|
|
170
|
+
// Lanes are read in ticks; warped songs read them at the note's tempo.
|
|
171
|
+
const ticksPerSecond = context.sampleRate / context.samplesPerTick;
|
|
172
|
+
const control = (t: number): BowControl => {
|
|
173
|
+
const tick = note.startTick + t * ticksPerSecond;
|
|
174
|
+
const lane = (name: string) =>
|
|
175
|
+
clamp(valueAt(track, values, name, tick), 0, 1);
|
|
176
|
+
return {
|
|
177
|
+
pressure: clamp(
|
|
178
|
+
press(lane("pressure")) + (bite && t < 0.08 ? 0.2 : 0),
|
|
179
|
+
0,
|
|
180
|
+
1,
|
|
181
|
+
),
|
|
182
|
+
speed: lane("speed"),
|
|
183
|
+
sord: lane("sord"),
|
|
184
|
+
dyn: lane("dyn"),
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
return { spec, velocity, control };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The preset vibrato alone (a slur keeps it running). */
|
|
191
|
+
function vibratoCurve(
|
|
192
|
+
note: PerformedNote,
|
|
193
|
+
values: StringValues,
|
|
194
|
+
track: Track,
|
|
195
|
+
player: Readonly<{ rate: number; depth: number; phase: number }> = SOLO,
|
|
196
|
+
): ((t: number) => number) | undefined {
|
|
197
|
+
if (note.performance?.replaceVibrato) return undefined;
|
|
198
|
+
const rate = valueAt(track, values, "vib", note.startTick) * player.rate;
|
|
199
|
+
const depth =
|
|
200
|
+
valueAt(track, values, "vibmod", note.startTick) * 100 * player.depth;
|
|
201
|
+
const delay = values.vibdelay as number;
|
|
202
|
+
if (!(rate > 0 && depth > 0)) return undefined;
|
|
203
|
+
const phase = player.phase;
|
|
204
|
+
return (t: number) => {
|
|
205
|
+
if (t <= delay) return 0;
|
|
206
|
+
const fade = Math.min(1, (t - delay) / 0.15);
|
|
207
|
+
return depth * fade * Math.sin(2 * Math.PI * (rate * (t - delay) + phase));
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const SOLO = { rate: 1, depth: 1, phase: 0 } as const;
|
|
212
|
+
|
|
213
|
+
/** Longest seeded onset spread of a bowed section's players (spec 2). */
|
|
214
|
+
export const SECTION_SPREAD = 0.025;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* One section player (spec 2, Ensemble): seeded vibrato rate x0.92-1.08,
|
|
218
|
+
* depth x0.8-1.2 and phase, and an onset up to 25 ms late (scaled down
|
|
219
|
+
* for notes under 0.25 s). The first player stays on the grid.
|
|
220
|
+
*/
|
|
221
|
+
export function sectionPlayer(
|
|
222
|
+
seed: number,
|
|
223
|
+
index: number,
|
|
224
|
+
hold: number,
|
|
225
|
+
): { rate: number; depth: number; phase: number; onset: number } {
|
|
226
|
+
const h = seedHash(`${seed}:${index}:player`);
|
|
227
|
+
return {
|
|
228
|
+
rate: 0.92 + 0.16 * unit(h, 0, 1),
|
|
229
|
+
depth: 0.8 + 0.4 * unit(h, 1, 1),
|
|
230
|
+
phase: unit(h, 2, 1),
|
|
231
|
+
onset:
|
|
232
|
+
index === 0
|
|
233
|
+
? 0
|
|
234
|
+
: SECTION_SPREAD * unit(h, 3, 1) * Math.min(1, hold / 0.25),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
87
238
|
type Voice = {
|
|
88
|
-
string: PluckString;
|
|
239
|
+
string: PluckString | BowedString;
|
|
240
|
+
/** The string's pitch over the note's base pitch (a slur keeps it). */
|
|
241
|
+
ratio: number;
|
|
89
242
|
start: number;
|
|
90
243
|
pitch: number;
|
|
91
244
|
group: number;
|
|
@@ -94,6 +247,26 @@ type Voice = {
|
|
|
94
247
|
damp?: Readonly<{ from: number; tau: number }>;
|
|
95
248
|
};
|
|
96
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Whether pending note `index` (starting at `start`) is a lone note: no
|
|
252
|
+
* other note starts with it, so a held single note may slur into it.
|
|
253
|
+
*/
|
|
254
|
+
function slurs(
|
|
255
|
+
pending: readonly { start: number }[],
|
|
256
|
+
index: number,
|
|
257
|
+
start: number,
|
|
258
|
+
): boolean {
|
|
259
|
+
return (
|
|
260
|
+
pending[index - 1]?.start !== start && pending[index + 1]?.start !== start
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A 64th note in samples at the song's start tempo (the slur window). */
|
|
265
|
+
function slurWindow(context: EngineContext): number {
|
|
266
|
+
const ticks = (context.score.ticksPerBeat ?? 480) / 16;
|
|
267
|
+
return ticks * context.samplesPerTick;
|
|
268
|
+
}
|
|
269
|
+
|
|
97
270
|
/** Ring-out after the last note-off, in seconds. */
|
|
98
271
|
export function stringTailSeconds(track: Track): number {
|
|
99
272
|
const values = resolveString(track.string);
|
|
@@ -163,6 +336,7 @@ export function renderStrings(
|
|
|
163
336
|
const octbelow = values.octbelow as number;
|
|
164
337
|
const cap = Math.max(1, Math.round(values.voices as number));
|
|
165
338
|
const tuning = context.tuning;
|
|
339
|
+
const bowed = isBowed(values);
|
|
166
340
|
const L = new Float64Array(total);
|
|
167
341
|
const R = dryR ? new Float64Array(total) : undefined;
|
|
168
342
|
const voices: Voice[] = [];
|
|
@@ -183,6 +357,47 @@ export function renderStrings(
|
|
|
183
357
|
next += 1;
|
|
184
358
|
const baseHz = noteHz(note.pitch, note.cents, tuning);
|
|
185
359
|
if (!(baseHz > 0) || baseHz >= 0.45 * sr) continue;
|
|
360
|
+
// Slur (bowed): a single note that starts while one earlier single
|
|
361
|
+
// note is still bowed moves that bow to the new pitch in 7 ms, with
|
|
362
|
+
// no new attack (legato articulation overlaps its notes, so it
|
|
363
|
+
// slurs too). Chords and double stops start new strokes.
|
|
364
|
+
if (bowed && slurs(pending, next - 1, start)) {
|
|
365
|
+
const held = voices.filter(
|
|
366
|
+
(v) => !v.string.done && v.string.offAt > start - v.start,
|
|
367
|
+
);
|
|
368
|
+
const groups = new Set(held.map((v) => v.group));
|
|
369
|
+
const pitches = new Set(held.map((v) => v.pitch));
|
|
370
|
+
// A true legato only: the held note lets go within a short window
|
|
371
|
+
// of the new one (a 64th note or 150 ms, legato's own overlap), or
|
|
372
|
+
// the new note is marked legato. A pedal or a note held under a
|
|
373
|
+
// moving line keeps sounding and the new note takes a new stroke.
|
|
374
|
+
const window = Math.max(0.15 * sr, slurWindow(context));
|
|
375
|
+
const letsGo =
|
|
376
|
+
note.articulation === "legato" ||
|
|
377
|
+
held.every((v) => v.start + v.string.offAt <= start + window);
|
|
378
|
+
const range = held.every((v) =>
|
|
379
|
+
(v.string as BowedString).canSlurTo(baseHz * v.ratio),
|
|
380
|
+
);
|
|
381
|
+
if (
|
|
382
|
+
letsGo &&
|
|
383
|
+
range &&
|
|
384
|
+
groups.size === 1 &&
|
|
385
|
+
pitches.size === 1 &&
|
|
386
|
+
held[0]!.start < start
|
|
387
|
+
) {
|
|
388
|
+
const hold = holdSamples(note, start, context) / sr;
|
|
389
|
+
for (const v of held) {
|
|
390
|
+
(v.string as BowedString).slurTo(
|
|
391
|
+
baseHz * v.ratio,
|
|
392
|
+
start - v.start,
|
|
393
|
+
Math.max(0.01, hold),
|
|
394
|
+
note.performance?.cents,
|
|
395
|
+
);
|
|
396
|
+
v.pitch = note.pitch;
|
|
397
|
+
}
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
186
401
|
// Restrike: the same key damps its previous string.
|
|
187
402
|
for (const v of voices)
|
|
188
403
|
if (v.pitch === note.pitch) v.string.releaseAt(start - v.start);
|
|
@@ -197,11 +412,10 @@ export function renderStrings(
|
|
|
197
412
|
for (const v of held)
|
|
198
413
|
if (v.group === oldest) v.string.releaseAt(start - v.start);
|
|
199
414
|
}
|
|
200
|
-
const { spec, velocity } = noteSpec(values, note, track);
|
|
201
415
|
const seed = seedHash(
|
|
202
416
|
`${note.id}:${note.startTick + (context.seedTick ?? 0)}:string`,
|
|
203
417
|
);
|
|
204
|
-
const strings: { hz: number; gain: number }[] = [];
|
|
418
|
+
const strings: { hz: number; gain: number; ratio: number }[] = [];
|
|
205
419
|
// The course's tuning error belongs to the key, not to the strike: it
|
|
206
420
|
// is seeded by track and key (so every velocity of a key beats the
|
|
207
421
|
// same way) and centred, so the course's mean pitch is the table's.
|
|
@@ -213,42 +427,74 @@ export function renderStrings(
|
|
|
213
427
|
for (let i = 0; i < unison; i += 1) {
|
|
214
428
|
const d = unison > 1 ? (i / (unison - 1) - 0.5) * detune : 0;
|
|
215
429
|
const jitter = jitters[i]! - meanJitter;
|
|
430
|
+
const ratio = 2 ** ((d * 100 + jitter) / 1200);
|
|
216
431
|
strings.push({
|
|
217
|
-
hz: baseHz *
|
|
432
|
+
hz: baseHz * ratio,
|
|
218
433
|
gain: 1 / Math.sqrt(unison),
|
|
434
|
+
ratio,
|
|
219
435
|
});
|
|
220
436
|
}
|
|
221
437
|
if (oct > 0 && note.pitch < octbelow) {
|
|
222
438
|
const up = noteHz(Math.min(127, note.pitch + 12), note.cents, tuning);
|
|
223
439
|
const hz = up > 0 ? up : baseHz * 2;
|
|
224
|
-
if (hz < 0.45 * sr)
|
|
440
|
+
if (hz < 0.45 * sr) {
|
|
441
|
+
const tuned =
|
|
442
|
+
hz * 2 ** (((unit(course, unison, 1) - 0.5) * 4) / 1200);
|
|
225
443
|
strings.push({
|
|
226
|
-
hz:
|
|
444
|
+
hz: tuned,
|
|
227
445
|
gain: oct / Math.sqrt(unison),
|
|
446
|
+
ratio: tuned / baseHz,
|
|
228
447
|
});
|
|
448
|
+
}
|
|
229
449
|
}
|
|
230
450
|
const hold = holdSamples(note, start, context) / sr;
|
|
231
|
-
const
|
|
451
|
+
const pluck = bowed ? undefined : noteSpec(values, note, track);
|
|
452
|
+
const bow = bowed ? bowSpec(values, note, track, context) : undefined;
|
|
453
|
+
const cents = bowed
|
|
454
|
+
? note.performance?.cents
|
|
455
|
+
: centsCurve(note, values, track);
|
|
456
|
+
const section = bowed && unison > 1;
|
|
232
457
|
strings.forEach((s, i) => {
|
|
458
|
+
// Section players (not the octave string) are seeded as players.
|
|
459
|
+
const player =
|
|
460
|
+
section && i < unison ? sectionPlayer(seed, i, hold) : undefined;
|
|
461
|
+
const onset = player ? Math.round(player.onset * sr) : 0;
|
|
462
|
+
const vibrato = bowed
|
|
463
|
+
? vibratoCurve(note, values, track, player)
|
|
464
|
+
: undefined;
|
|
233
465
|
const pan =
|
|
234
466
|
strings.length > 1 ? (i / (strings.length - 1) - 0.5) * spread : 0;
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
467
|
+
const common = {
|
|
468
|
+
hz: s.hz,
|
|
469
|
+
hold: Math.max(0.01, hold - onset / sr),
|
|
470
|
+
seed: seedHash(`${seed}:${i}`),
|
|
471
|
+
...(cents ? { cents } : {}),
|
|
472
|
+
};
|
|
473
|
+
const string = bow
|
|
474
|
+
? new BowedString(
|
|
475
|
+
bow.spec,
|
|
476
|
+
{
|
|
477
|
+
...common,
|
|
478
|
+
velocity: bow.velocity,
|
|
479
|
+
...(vibrato ? { vibrato } : {}),
|
|
480
|
+
...(bow.control ? { control: bow.control } : {}),
|
|
481
|
+
} satisfies BowNote,
|
|
482
|
+
sr,
|
|
483
|
+
// A bow sustains for as long as the note is held.
|
|
484
|
+
Math.max(MAX_RING, hold + bow.spec.release + 1),
|
|
485
|
+
)
|
|
486
|
+
: new PluckString(
|
|
487
|
+
pluck!.spec,
|
|
488
|
+
{ ...common, velocity: pluck!.velocity },
|
|
489
|
+
sr,
|
|
490
|
+
MAX_RING,
|
|
491
|
+
);
|
|
247
492
|
const gl = R ? s.gain * Math.sqrt(0.5 - pan / 2) * Math.SQRT2 : s.gain;
|
|
248
493
|
const gr = s.gain * Math.sqrt(0.5 + pan / 2) * Math.SQRT2;
|
|
249
494
|
voices.push({
|
|
250
495
|
string,
|
|
251
|
-
|
|
496
|
+
ratio: s.ratio,
|
|
497
|
+
start: start + onset,
|
|
252
498
|
pitch: note.pitch,
|
|
253
499
|
group: start,
|
|
254
500
|
gainL: gl,
|
|
@@ -24,6 +24,7 @@ export type TestNote = {
|
|
|
24
24
|
seconds?: number;
|
|
25
25
|
velocity?: number;
|
|
26
26
|
performance?: PerformedNote["performance"];
|
|
27
|
+
articulation?: PerformedNote["articulation"];
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
/** Renders notes on a string track (mono, before effects). */
|
|
@@ -58,6 +59,7 @@ export function render(
|
|
|
58
59
|
startTick: Math.round(((n.start ?? 0) * SR) / SPT),
|
|
59
60
|
durationTicks: Math.round(((n.seconds ?? seconds) * SR) / SPT),
|
|
60
61
|
...(n.performance ? { performance: n.performance } : {}),
|
|
62
|
+
...(n.articulation ? { articulation: n.articulation } : {}),
|
|
61
63
|
})) as unknown as PerformedNote[];
|
|
62
64
|
const dry = new Float64Array(samples);
|
|
63
65
|
renderStrings(dry, undefined, performed, track, context);
|
package/src/audio/wav.ts
CHANGED
|
@@ -23,7 +23,11 @@ import {
|
|
|
23
23
|
} from "./sampler.ts";
|
|
24
24
|
import { EFFECT_NAMES, FX_PRESETS, effectSpec } from "../../core/fx.ts";
|
|
25
25
|
import { seededRandom } from "./random.ts";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
engineFor,
|
|
28
|
+
engineTailSeconds,
|
|
29
|
+
RING_OUT_FADE_SECONDS,
|
|
30
|
+
} from "./instruments.ts";
|
|
27
31
|
import {
|
|
28
32
|
isStereoVoice,
|
|
29
33
|
renderSynthNote,
|
|
@@ -46,6 +50,8 @@ import {
|
|
|
46
50
|
applyStereoChain,
|
|
47
51
|
delayTailFor,
|
|
48
52
|
interpolateAutomation,
|
|
53
|
+
needsEffectNotes,
|
|
54
|
+
type EffectNote,
|
|
49
55
|
reverbActive,
|
|
50
56
|
reverbImpulse,
|
|
51
57
|
reverbTailFor,
|
|
@@ -97,6 +103,19 @@ export type RenderOptions = WavOptions &
|
|
|
97
103
|
* a single pass of the whole song. Zero (the default) changes nothing.
|
|
98
104
|
*/
|
|
99
105
|
seedTick?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Song seconds at an arranged window's first frame (with a tempo map,
|
|
108
|
+
* more than `seedTick` at the base tempo); engines whose state runs on
|
|
109
|
+
* the song clock (organ wheels and rotors) start there.
|
|
110
|
+
*/
|
|
111
|
+
seedSeconds?: number;
|
|
112
|
+
/** Per-track engine state at the window origin (`windowSeed`). */
|
|
113
|
+
seedState?: Readonly<Record<string, string>>;
|
|
114
|
+
/**
|
|
115
|
+
* The song whose chords a granular `quant chord` reads, at `tick` for
|
|
116
|
+
* this render's tick 0 (a live note renders alone). Absent: the score.
|
|
117
|
+
*/
|
|
118
|
+
quantChord?: Readonly<{ score: TrackScore; tick: number }>;
|
|
100
119
|
}>;
|
|
101
120
|
|
|
102
121
|
/** Interleaved stereo 16-bit PCM plus its frame count. */
|
|
@@ -191,12 +210,13 @@ const MAX_ENGINE_TAIL_SECONDS = 30;
|
|
|
191
210
|
* The longest 0.6 engine ring-out in `score`, capped at 30 s. One-shot
|
|
192
211
|
* renders (exports, previews) let engine tails reach their own cap; the
|
|
193
212
|
* 8 s cap applies only to loop folding. Zero without an engine, so legacy
|
|
194
|
-
* renders are unchanged.
|
|
213
|
+
* renders are unchanged. `ringing` counts only ring-out tracks (0.6.1).
|
|
195
214
|
*/
|
|
196
|
-
function oneShotEngineTail(score: TrackScore): number {
|
|
215
|
+
function oneShotEngineTail(score: TrackScore, ringing = false): number {
|
|
197
216
|
let tail = 0;
|
|
198
217
|
for (const track of score.tracks) {
|
|
199
|
-
|
|
218
|
+
const engine = engineFor(track);
|
|
219
|
+
if (!engine || (ringing && engine.ringOut?.(track) !== true)) continue;
|
|
200
220
|
let lowest: number | undefined;
|
|
201
221
|
for (const note of score.notes)
|
|
202
222
|
if (
|
|
@@ -219,6 +239,9 @@ export type RenderContext = Readonly<{
|
|
|
219
239
|
/** Tempo map in samples (core/tempo.ts); absent: constant tempo. */
|
|
220
240
|
warp?: SampleWarp;
|
|
221
241
|
seedTick?: number;
|
|
242
|
+
seedSeconds?: number;
|
|
243
|
+
seedState?: Readonly<Record<string, string>>;
|
|
244
|
+
quantChord?: Readonly<{ score: TrackScore; tick: number }>;
|
|
222
245
|
}>;
|
|
223
246
|
|
|
224
247
|
/** Exact (fractional) loop length in frames at a sample rate. */
|
|
@@ -421,6 +444,9 @@ export class StemRenderer {
|
|
|
421
444
|
...(bank.irs ? { irs: bank.irs } : {}),
|
|
422
445
|
...(warp ? { warp } : {}),
|
|
423
446
|
...(options.seedTick ? { seedTick: options.seedTick } : {}),
|
|
447
|
+
...(options.seedSeconds ? { seedSeconds: options.seedSeconds } : {}),
|
|
448
|
+
...(options.seedState ? { seedState: options.seedState } : {}),
|
|
449
|
+
...(options.quantChord ? { quantChord: options.quantChord } : {}),
|
|
424
450
|
};
|
|
425
451
|
this.renders += 1;
|
|
426
452
|
const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
|
|
@@ -485,7 +511,7 @@ export class StemRenderer {
|
|
|
485
511
|
const engineDigests =
|
|
486
512
|
engine && track
|
|
487
513
|
? [
|
|
488
|
-
...(engine.assetDigests?.(track, bank) ?? []),
|
|
514
|
+
...(engine.assetDigests?.(track, bank, score) ?? []),
|
|
489
515
|
`key:${score.key ?? ""}`,
|
|
490
516
|
]
|
|
491
517
|
: undefined;
|
|
@@ -545,7 +571,7 @@ export class StemRenderer {
|
|
|
545
571
|
: { left, right };
|
|
546
572
|
dry.fill(0);
|
|
547
573
|
const synthVoice = !engine && !sampler && usesSynthVoice(track);
|
|
548
|
-
|
|
574
|
+
let stereo = engine
|
|
549
575
|
? engine.stereo(track!)
|
|
550
576
|
: synthVoice && isStereoVoice(track);
|
|
551
577
|
if (stereo) dryR.fill(0);
|
|
@@ -563,7 +589,16 @@ export class StemRenderer {
|
|
|
563
589
|
bank,
|
|
564
590
|
);
|
|
565
591
|
} else if (sampler) {
|
|
566
|
-
|
|
592
|
+
// A resampled stereo file plays as stereo (0.6.1).
|
|
593
|
+
if (track)
|
|
594
|
+
stereo = renderSamplerNotes(
|
|
595
|
+
dry,
|
|
596
|
+
played,
|
|
597
|
+
track,
|
|
598
|
+
context,
|
|
599
|
+
bank,
|
|
600
|
+
dryR,
|
|
601
|
+
);
|
|
567
602
|
} else if (synthVoice && track) {
|
|
568
603
|
const gainAt = (tick: number) => trackGainAt(track, tick);
|
|
569
604
|
const voice = {
|
|
@@ -603,8 +638,13 @@ export class StemRenderer {
|
|
|
603
638
|
else renderToneNote(dry, note, track, context, tuning);
|
|
604
639
|
}
|
|
605
640
|
}
|
|
606
|
-
|
|
607
|
-
|
|
641
|
+
// Note-aware stages (bloom, swell) see the notes; others never do.
|
|
642
|
+
const chain =
|
|
643
|
+
track && needsEffectNotes(track)
|
|
644
|
+
? { ...context, notes: effectNotes(played, context, tuning) }
|
|
645
|
+
: context;
|
|
646
|
+
if (track) applyMonoChain(dry, track, chain);
|
|
647
|
+
if (stereo && track) applyMonoChain(dryR, track, chain);
|
|
608
648
|
applyPan(
|
|
609
649
|
dry,
|
|
610
650
|
target.left,
|
|
@@ -620,7 +660,7 @@ export class StemRenderer {
|
|
|
620
660
|
target.left,
|
|
621
661
|
target.right,
|
|
622
662
|
track,
|
|
623
|
-
|
|
663
|
+
chain,
|
|
624
664
|
true,
|
|
625
665
|
false,
|
|
626
666
|
);
|
|
@@ -638,7 +678,7 @@ export class StemRenderer {
|
|
|
638
678
|
target.left,
|
|
639
679
|
target.right,
|
|
640
680
|
track,
|
|
641
|
-
|
|
681
|
+
chain,
|
|
642
682
|
busOrbit === undefined,
|
|
643
683
|
);
|
|
644
684
|
stem = {
|
|
@@ -694,6 +734,20 @@ export class StemRenderer {
|
|
|
694
734
|
if (!groups.has(trackId)) this.evict(trackId);
|
|
695
735
|
// Linear effects superpose, so folding the tail onto the start yields
|
|
696
736
|
// the steady state of the loop playing forever.
|
|
737
|
+
// A ring-out longer than the fold (a gong past 8 s) would be cut there:
|
|
738
|
+
// fade its last RING_OUT_FADE_SECONDS so the fold never clicks. Only
|
|
739
|
+
// ringing 0.6 engine tracks qualify, so legacy loops are untouched.
|
|
740
|
+
if (
|
|
741
|
+
options.loop &&
|
|
742
|
+
samples > frames &&
|
|
743
|
+
oneShotEngineTail(score, true) > (samples - frames) / sampleRate
|
|
744
|
+
)
|
|
745
|
+
fadeEnd(
|
|
746
|
+
mixL,
|
|
747
|
+
mixR,
|
|
748
|
+
samples,
|
|
749
|
+
Math.round(RING_OUT_FADE_SECONDS * sampleRate),
|
|
750
|
+
);
|
|
697
751
|
if (samples > frames) foldTail(mixL, mixR, frames, samples);
|
|
698
752
|
// The song master (src/audio/master.ts); none leaves the mix untouched.
|
|
699
753
|
const mastered = applyMaster(
|
|
@@ -800,6 +854,21 @@ function addStem(
|
|
|
800
854
|
}
|
|
801
855
|
|
|
802
856
|
/** Fold everything past `frames` back onto the loop start. */
|
|
857
|
+
/** Fade the last `fade` of `samples` mix frames linearly to zero. */
|
|
858
|
+
function fadeEnd(
|
|
859
|
+
mixL: Float64Array,
|
|
860
|
+
mixR: Float64Array,
|
|
861
|
+
samples: number,
|
|
862
|
+
fade: number,
|
|
863
|
+
): void {
|
|
864
|
+
const n = Math.min(fade, samples);
|
|
865
|
+
for (let i = 0; i < n; i += 1) {
|
|
866
|
+
const gain = (n - 1 - i) / n;
|
|
867
|
+
mixL[samples - n + i]! *= gain;
|
|
868
|
+
mixR[samples - n + i]! *= gain;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
803
872
|
function foldTail(
|
|
804
873
|
mixL: Float64Array,
|
|
805
874
|
mixR: Float64Array,
|
|
@@ -927,6 +996,10 @@ function stemKey(
|
|
|
927
996
|
: []),
|
|
928
997
|
// Seeded noise follows the window origin of an arranged render.
|
|
929
998
|
...(context.seedTick ? [`seed:${context.seedTick}`] : []),
|
|
999
|
+
...(context.seedSeconds ? [`at:${context.seedSeconds}`] : []),
|
|
1000
|
+
...(track && context.seedState?.[track.id]
|
|
1001
|
+
? [`state:${context.seedState[track.id]}`]
|
|
1002
|
+
: []),
|
|
930
1003
|
]);
|
|
931
1004
|
}
|
|
932
1005
|
|
|
@@ -941,25 +1014,34 @@ function samplerVoiceDigest(track: Track, bank: SampleBank): string[] {
|
|
|
941
1014
|
});
|
|
942
1015
|
}
|
|
943
1016
|
|
|
944
|
-
/**
|
|
1017
|
+
/**
|
|
1018
|
+
* Sample voices of a sampler track into its mono dry buffer. When a voice
|
|
1019
|
+
* plays a resampled stereo file (0.6.1), `target` takes the left channel,
|
|
1020
|
+
* `targetRight` the right, and the result is true (the track is stereo).
|
|
1021
|
+
*/
|
|
945
1022
|
function renderSamplerNotes(
|
|
946
1023
|
target: Float64Array,
|
|
947
1024
|
notes: readonly Note[],
|
|
948
1025
|
track: Track,
|
|
949
1026
|
context: RenderContext,
|
|
950
1027
|
bank: SampleBank,
|
|
951
|
-
|
|
1028
|
+
targetRight: Float64Array,
|
|
1029
|
+
): boolean {
|
|
952
1030
|
const timing = {
|
|
953
1031
|
score: context.score,
|
|
954
1032
|
sampleRate: context.sampleRate,
|
|
955
1033
|
...(context.warp ? { warp: context.warp } : {}),
|
|
956
1034
|
};
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
timing,
|
|
961
|
-
|
|
962
|
-
|
|
1035
|
+
const voices = planSamplerVoices(track, notes, bank, timing);
|
|
1036
|
+
const gainAt = (tick: number) => trackGainAt(track, tick);
|
|
1037
|
+
if (!voices.some((voice) => voice.stereo)) {
|
|
1038
|
+
renderSamplerVoices(target, voices, timing, gainAt);
|
|
1039
|
+
return false;
|
|
1040
|
+
}
|
|
1041
|
+
targetRight.fill(0);
|
|
1042
|
+
renderSamplerVoices(target, voices, timing, gainAt, "left");
|
|
1043
|
+
renderSamplerVoices(targetRight, voices, timing, gainAt, "right");
|
|
1044
|
+
return true;
|
|
963
1045
|
}
|
|
964
1046
|
|
|
965
1047
|
/** Track volume and volume automation at a score tick (pan is applied in stereo). */
|
|
@@ -973,6 +1055,24 @@ function trackGainAt(track: Track | undefined, tick: number): number {
|
|
|
973
1055
|
return trackGain * automatedGain;
|
|
974
1056
|
}
|
|
975
1057
|
|
|
1058
|
+
/** The notes as note-aware effects see them (EffectContext.notes). */
|
|
1059
|
+
function effectNotes(
|
|
1060
|
+
notes: readonly Note[],
|
|
1061
|
+
context: RenderContext,
|
|
1062
|
+
tuning: TuningTable | undefined,
|
|
1063
|
+
): EffectNote[] {
|
|
1064
|
+
return notes.map((note) => {
|
|
1065
|
+
const { start, length } = noteSpan(note, context);
|
|
1066
|
+
return {
|
|
1067
|
+
id: note.id,
|
|
1068
|
+
start,
|
|
1069
|
+
length,
|
|
1070
|
+
hz: noteHz(note.pitch, note.cents, tuning),
|
|
1071
|
+
velocity: note.velocity,
|
|
1072
|
+
};
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
|
|
976
1076
|
function noteSpan(
|
|
977
1077
|
note: Note,
|
|
978
1078
|
context: RenderContext,
|