@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
package/src/audio/keys/piano.ts
CHANGED
|
@@ -156,6 +156,12 @@ export type PianoNoteOn = Readonly<{
|
|
|
156
156
|
trackSeed: number;
|
|
157
157
|
/** Track seed plus note id (this strike's own randomness). */
|
|
158
158
|
noteSeed: string;
|
|
159
|
+
/**
|
|
160
|
+
* Una corda (0.6.1) at the onset, 0..1 (half pedal 0.5); absent is 0.
|
|
161
|
+
* The hammer shifts: fewer strings struck, a softer felt spot, more of
|
|
162
|
+
* the unstruck string's aftersound.
|
|
163
|
+
*/
|
|
164
|
+
soft?: number;
|
|
159
165
|
}>;
|
|
160
166
|
|
|
161
167
|
type Rattle = {
|
|
@@ -207,18 +213,47 @@ export class PianoVoice {
|
|
|
207
213
|
p.decay *
|
|
208
214
|
(1 - 0.15 * p.felt) *
|
|
209
215
|
(prepKind === 1 ? 0.12 : prepKind >= 0 ? 0.5 : 1);
|
|
210
|
-
const
|
|
216
|
+
const soft = clamp(note.soft ?? 0, 0, 1);
|
|
217
|
+
let after = clamp(p.after, 0, 0.95);
|
|
211
218
|
const thMs =
|
|
212
219
|
lerp(3.5, 0.8, keyPos) *
|
|
213
220
|
lerp(1.6, 0.7, v) *
|
|
214
221
|
(1 + 1.5 * p.felt) *
|
|
215
222
|
(1.3 - 0.6 * p.hardness);
|
|
223
|
+
// Una corda (keys.md 2.1): the shifted hammer meets the strings with
|
|
224
|
+
// its softer felt. Modelled spectrally, so the pulse length stays and
|
|
225
|
+
// its nulls never land on a low partial: mode input gains get a smooth
|
|
226
|
+
// two-pole low-pass relative to partial 1 (cutoff at a tenth of the
|
|
227
|
+
// spec's hammer cutoff, lowered 25% at full pedal), floored at -12 dB
|
|
228
|
+
// so no low harmonic is notched out; more of the slow aftersound is
|
|
229
|
+
// excited. Measured at keys 48-84, velocity 0.5-0.9: centroid -12 to
|
|
230
|
+
// -20% through key 72 (-5% at 84), peak -2.3 to -4.8 dB, no partial of
|
|
231
|
+
// the first eight more than 9 dB down (src/audio/keys/pedals.test.ts).
|
|
232
|
+
const softFc =
|
|
233
|
+
35 *
|
|
234
|
+
2 ** (5 * v * (0.4 + 0.6 * p.hardness) + 1.5 * keyPos) *
|
|
235
|
+
0.75 ** soft;
|
|
236
|
+
const softFloor = 1 - 0.75 * soft;
|
|
237
|
+
const softW = (hz: number) =>
|
|
238
|
+
soft > 0
|
|
239
|
+
? Math.max(
|
|
240
|
+
softFloor,
|
|
241
|
+
((1 + (f1 / softFc) ** 2) / (1 + (hz / softFc) ** 2)) ** soft,
|
|
242
|
+
)
|
|
243
|
+
: 1;
|
|
244
|
+
if (soft > 0) after = clamp(after + (1 - after) * 0.4 * soft, 0, 0.95);
|
|
216
245
|
this.pulse = hammerPulse(
|
|
217
246
|
Math.max(2, Math.round(thMs * 1e-3 * sr)),
|
|
218
247
|
1 + 2 * v * p.hardness,
|
|
219
248
|
);
|
|
220
249
|
const strings = stringsFor(key);
|
|
221
|
-
|
|
250
|
+
// Una corda strikes one string fewer: the unison spread narrows.
|
|
251
|
+
const spread =
|
|
252
|
+
strings === 1
|
|
253
|
+
? 0.08
|
|
254
|
+
: soft > 0
|
|
255
|
+
? p.unison * (1 - (soft * 1) / strings)
|
|
256
|
+
: p.unison;
|
|
222
257
|
const maxHz = MODE_CEILING * sr;
|
|
223
258
|
let partials = 0;
|
|
224
259
|
while (partials < 160) {
|
|
@@ -234,6 +269,8 @@ export class PianoVoice {
|
|
|
234
269
|
const shift = prepKind === 0 || prepKind === 2 ? 0.04 + 0.1 * prep() : 0;
|
|
235
270
|
const candidates: { hz: number; t60: number; u: number }[] = [];
|
|
236
271
|
let energy = 0;
|
|
272
|
+
let sum0 = 0;
|
|
273
|
+
let sumW = 0;
|
|
237
274
|
for (let n = 1; n <= partials; n += 1) {
|
|
238
275
|
let fn = n * f0 * Math.sqrt(1 + B * n * n);
|
|
239
276
|
if (shift && n > 1) fn *= 1 - shift * Math.sin(n * Math.PI * xb) ** 2;
|
|
@@ -241,8 +278,11 @@ export class PianoVoice {
|
|
|
241
278
|
if (Math.abs(shape) < 1e-3) continue;
|
|
242
279
|
const tn = 1 / (1 / T1 + (fn / 3000) ** 2 / (1.2 * p.decay));
|
|
243
280
|
const damped = prepKind === 1 && n > 3 ? 0.3 : 1;
|
|
244
|
-
const
|
|
245
|
-
energy +=
|
|
281
|
+
const u0 = shape * damped;
|
|
282
|
+
energy += u0 * u0;
|
|
283
|
+
const u = u0 * softW(fn);
|
|
284
|
+
sum0 += Math.abs(u0);
|
|
285
|
+
sumW += Math.abs(u);
|
|
246
286
|
if (n <= 12) {
|
|
247
287
|
// Prompt sound at the centre, aftersound split into a symmetric pair.
|
|
248
288
|
const d = (spread / 2) * (1 + 0.3 * (random() - 0.5));
|
|
@@ -266,7 +306,11 @@ export class PianoVoice {
|
|
|
266
306
|
t60: 0.6 + 1.2 * prep(),
|
|
267
307
|
u: 0.35,
|
|
268
308
|
});
|
|
269
|
-
|
|
309
|
+
let loud = v ** 1.4 * (1 - 0.4 * p.felt);
|
|
310
|
+
// Una corda's level is 30% down: half the low-pass loss (in summed mode
|
|
311
|
+
// amplitude) is made up so the extra loss stays about 1 dB.
|
|
312
|
+
if (soft > 0)
|
|
313
|
+
loud *= (1 - 0.3 * soft) * Math.sqrt(sum0 / Math.max(sumW, 1e-9));
|
|
270
314
|
const G = (0.35 * loud) / Math.sqrt(Math.max(energy, 1e-9));
|
|
271
315
|
const mags = candidates.map(
|
|
272
316
|
(c) => pulseMag(this.pulse, (TAU * c.hz) / sr) * Math.abs(c.u),
|
|
@@ -295,7 +339,7 @@ export class PianoVoice {
|
|
|
295
339
|
2800,
|
|
296
340
|
1.5,
|
|
297
341
|
0.002,
|
|
298
|
-
0.02 * v * v * p.hardness * (1 - p.felt),
|
|
342
|
+
0.02 * v * v * p.hardness * (1 - p.felt) * (1 - 0.3 * soft),
|
|
299
343
|
sr,
|
|
300
344
|
),
|
|
301
345
|
);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sympathetic resonance for the modelled pianos (0.6.1, keys-electric
|
|
3
|
+
* lane): a per-track bank of 36 tuned feedback combs (C2..B4), fed by the
|
|
4
|
+
* dry sum, ringing with T60 4 s while the sustain pedal is down (all
|
|
5
|
+
* dampers up) and 0.06 s when it is up. Ported from the design prototype
|
|
6
|
+
* (proto/keys/keys.ts `Sympathetic`).
|
|
7
|
+
*
|
|
8
|
+
* Gating (zero cost otherwise): it runs only on a piano track with `sym`
|
|
9
|
+
* above 0 and at least one sustain pedal `down` or `half` event; between
|
|
10
|
+
* pedal-down spans it stops processing once its energy is below -90 dB.
|
|
11
|
+
* At 44.1 kHz and above it runs at half rate (pairs averaged in, output
|
|
12
|
+
* linearly interpolated), which keeps 48 kHz near the 22.05 kHz cost; the
|
|
13
|
+
* combs top out at B4 (494 Hz), far below the halved Nyquist.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { hermite4 } from "../dsp/interp.ts";
|
|
17
|
+
|
|
18
|
+
const TAU = 2 * Math.PI;
|
|
19
|
+
const LOW = 36;
|
|
20
|
+
const HIGH = 71;
|
|
21
|
+
/** Energy below which the bank goes quiet while the pedal is up. */
|
|
22
|
+
const QUIET = 1e-9;
|
|
23
|
+
|
|
24
|
+
export class Sympathetic {
|
|
25
|
+
private readonly lines: Float64Array[] = [];
|
|
26
|
+
private readonly delay: number[] = [];
|
|
27
|
+
private readonly lp: number[] = [];
|
|
28
|
+
private readonly ws: number[] = [];
|
|
29
|
+
private readonly gUp: number[] = [];
|
|
30
|
+
private readonly gDown: number[] = [];
|
|
31
|
+
private readonly a: number;
|
|
32
|
+
/** Internal decimation: 1 at 22.05 kHz, 2 at 44.1/48 kHz. */
|
|
33
|
+
readonly factor: number;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
hzOf: (pitch: number) => number,
|
|
37
|
+
readonly amount: number,
|
|
38
|
+
sr: number,
|
|
39
|
+
) {
|
|
40
|
+
this.factor = sr >= 40_000 ? 2 : 1;
|
|
41
|
+
const rate = sr / this.factor;
|
|
42
|
+
this.a = Math.exp((-TAU * 4000) / rate);
|
|
43
|
+
const lpDelay = this.a / (1 - this.a);
|
|
44
|
+
for (let q = LOW; q <= HIGH; q += 1) {
|
|
45
|
+
const hz = hzOf(q);
|
|
46
|
+
// An unmapped tuning degree has no string to ring.
|
|
47
|
+
if (!(hz > 0) || !Number.isFinite(hz)) continue;
|
|
48
|
+
const d = Math.max(2, rate / hz - lpDelay);
|
|
49
|
+
this.lines.push(new Float64Array(Math.ceil(d) + 8));
|
|
50
|
+
this.delay.push(d);
|
|
51
|
+
this.lp.push(0);
|
|
52
|
+
this.ws.push(0);
|
|
53
|
+
this.gDown.push(10 ** ((-3 * d) / (rate * 4)));
|
|
54
|
+
this.gUp.push(10 ** ((-3 * d) / (rate * 0.06)));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One internal-rate step: input `x`, returns [left, right] resonance. */
|
|
59
|
+
private step(x: number, pedal: boolean, out: [number, number]): void {
|
|
60
|
+
let yl = 0;
|
|
61
|
+
let yr = 0;
|
|
62
|
+
const a = this.a;
|
|
63
|
+
for (let k = 0; k < this.lines.length; k += 1) {
|
|
64
|
+
const line = this.lines[k]!;
|
|
65
|
+
const size = line.length;
|
|
66
|
+
const w = this.ws[k]!;
|
|
67
|
+
const pos = w - this.delay[k]!;
|
|
68
|
+
const i = Math.floor(pos);
|
|
69
|
+
const at = (j: number) => line[((j % size) + size) % size]!;
|
|
70
|
+
const y = hermite4(at(i - 1), at(i), at(i + 1), at(i + 2), pos - i);
|
|
71
|
+
const f = (this.lp[k] = y + a * (this.lp[k]! - y));
|
|
72
|
+
line[w % size] = x + (pedal ? this.gDown[k]! : this.gUp[k]!) * f;
|
|
73
|
+
this.ws[k] = (w + 1) % size;
|
|
74
|
+
if (k & 1) yr += f;
|
|
75
|
+
else yl += f;
|
|
76
|
+
}
|
|
77
|
+
out[0] = yl * 0.5;
|
|
78
|
+
out[1] = yr * 0.5;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* In place: adds resonance to `left`/`right`. `down(i)` says whether the
|
|
83
|
+
* sustain pedal is down at sample `i` (read per sample pair at most).
|
|
84
|
+
*/
|
|
85
|
+
process(
|
|
86
|
+
left: Float64Array,
|
|
87
|
+
right: Float64Array,
|
|
88
|
+
down: (sample: number) => boolean,
|
|
89
|
+
): void {
|
|
90
|
+
const c = 0.02 * this.amount;
|
|
91
|
+
const out: [number, number] = [0, 0];
|
|
92
|
+
const f = this.factor;
|
|
93
|
+
let prevL = 0;
|
|
94
|
+
let prevR = 0;
|
|
95
|
+
let energy = 0;
|
|
96
|
+
for (let i = 0; i < left.length; i += f) {
|
|
97
|
+
const pedal = down(i);
|
|
98
|
+
let x = 0;
|
|
99
|
+
for (let j = 0; j < f && i + j < left.length; j += 1)
|
|
100
|
+
x += left[i + j]! + right[i + j]!;
|
|
101
|
+
x = (x / (2 * f)) * c;
|
|
102
|
+
// Dampers down and the combs quiet: skip them entirely.
|
|
103
|
+
if (!pedal && energy < QUIET) {
|
|
104
|
+
prevL = 0;
|
|
105
|
+
prevR = 0;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
this.step(x, pedal, out);
|
|
109
|
+
energy = 0.999 * energy + 0.001 * (out[0] * out[0] + out[1] * out[1]);
|
|
110
|
+
if (f === 1) {
|
|
111
|
+
left[i]! += out[0];
|
|
112
|
+
right[i]! += out[1];
|
|
113
|
+
} else {
|
|
114
|
+
// Linear interpolation back to the full rate (one sample latency
|
|
115
|
+
// at half rate, inaudible on a 4 s resonance).
|
|
116
|
+
left[i]! += 0.5 * (prevL + out[0]);
|
|
117
|
+
right[i]! += 0.5 * (prevR + out[1]);
|
|
118
|
+
if (i + 1 < left.length) {
|
|
119
|
+
left[i + 1]! += out[0];
|
|
120
|
+
right[i + 1]! += out[1];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
prevL = out[0];
|
|
124
|
+
prevR = out[1];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/audio/live-worker.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type LiveFullRequest = Readonly<{
|
|
|
18
18
|
velocity: number;
|
|
19
19
|
seconds: number;
|
|
20
20
|
tick?: number;
|
|
21
|
+
clock?: number;
|
|
21
22
|
samples?: SampleBank;
|
|
22
23
|
}>;
|
|
23
24
|
|
|
@@ -32,7 +33,7 @@ declare const self: Worker;
|
|
|
32
33
|
let synth: LiveSynth | undefined;
|
|
33
34
|
|
|
34
35
|
self.onmessage = (event: MessageEvent<LiveFullRequest>) => {
|
|
35
|
-
const { id, sampleRate, score, samples, tick, ...note } = event.data;
|
|
36
|
+
const { id, sampleRate, score, samples, tick, clock, ...note } = event.data;
|
|
36
37
|
try {
|
|
37
38
|
if (synth?.rate !== sampleRate) synth = new LiveSynth(sampleRate);
|
|
38
39
|
const pcm = synth.render({
|
|
@@ -41,6 +42,7 @@ self.onmessage = (event: MessageEvent<LiveFullRequest>) => {
|
|
|
41
42
|
full: true,
|
|
42
43
|
...(samples ? { samples } : {}),
|
|
43
44
|
...(tick === undefined ? {} : { tick }),
|
|
45
|
+
...(clock === undefined ? {} : { clock }),
|
|
44
46
|
});
|
|
45
47
|
const reply: LiveFullReply = pcm ? { id, pcm } : { id };
|
|
46
48
|
self.postMessage(reply);
|
package/src/audio/live.ts
CHANGED
|
@@ -19,10 +19,15 @@ import {
|
|
|
19
19
|
import { bpmAtTick } from "../../core/tempo.ts";
|
|
20
20
|
import { EMPTY_SAMPLE_BANK, sampleKey, type SampleBank } from "./samples.ts";
|
|
21
21
|
import { RENDER_CHANNELS, renderScorePcm } from "./wav.ts";
|
|
22
|
-
import { engineFor } from "./instruments.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";
|
|
23
25
|
import { noteHz, resolveTuning } from "../../core/tuning.ts";
|
|
24
26
|
import { liveFitPending, withLiveFit } from "./fit.ts";
|
|
27
|
+
import { isOrganFamily } from "../../core/keys.ts";
|
|
25
28
|
import type { LiveFullReply, LiveFullRequest } from "./live-worker.ts";
|
|
29
|
+
import { isBowed, liveStringTrack } from "./strings/engine.ts";
|
|
30
|
+
import { resolveString } from "../../core/strings.ts";
|
|
26
31
|
|
|
27
32
|
/** A rendered live note: interleaved stereo 16-bit PCM. */
|
|
28
33
|
export type LiveNotePcm = Readonly<{
|
|
@@ -37,11 +42,22 @@ export type LiveNotePcm = Readonly<{
|
|
|
37
42
|
*/
|
|
38
43
|
fitting?: true;
|
|
39
44
|
/**
|
|
40
|
-
* True when this is only the first window of a guitar-rig note
|
|
45
|
+
* True when this is only the first window of a guitar-rig or bowed note
|
|
41
46
|
* (`LIVE_RIG_WINDOW_SECONDS`): call `LiveSynth.render` again with
|
|
42
47
|
* `full: true` off the key path and swap the result in by voice id.
|
|
43
48
|
*/
|
|
44
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;
|
|
45
61
|
}>;
|
|
46
62
|
|
|
47
63
|
export type LiveNoteRequest = Readonly<{
|
|
@@ -58,6 +74,8 @@ export type LiveNoteRequest = Readonly<{
|
|
|
58
74
|
tick?: number;
|
|
59
75
|
/** Render the whole note even through a guitar rig (the background pass). */
|
|
60
76
|
full?: boolean;
|
|
77
|
+
/** An organ note's clock tick from its first window (`LiveNotePcm.clock`). */
|
|
78
|
+
clock?: number;
|
|
61
79
|
}>;
|
|
62
80
|
|
|
63
81
|
/**
|
|
@@ -78,6 +96,8 @@ const CACHE_ENTRIES = 96;
|
|
|
78
96
|
*/
|
|
79
97
|
export class LiveSynth {
|
|
80
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();
|
|
81
101
|
|
|
82
102
|
public constructor(private readonly sampleRate: number) {}
|
|
83
103
|
|
|
@@ -122,6 +142,38 @@ export class LiveSynth {
|
|
|
122
142
|
);
|
|
123
143
|
const velocity = Math.max(0, Math.min(1, request.velocity));
|
|
124
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;
|
|
125
177
|
const key = JSON.stringify([
|
|
126
178
|
liveTrack(track),
|
|
127
179
|
pitch,
|
|
@@ -137,6 +189,7 @@ export class LiveSynth {
|
|
|
137
189
|
...(score.tuning || track.tuning
|
|
138
190
|
? [score.tuning ?? null, score.key]
|
|
139
191
|
: []),
|
|
192
|
+
...(organTick !== undefined ? [`organ:${organTick}`] : []),
|
|
140
193
|
// A 0.6 engine's assets, and the key it may tune to.
|
|
141
194
|
...(liveEngine
|
|
142
195
|
? [
|
|
@@ -147,21 +200,44 @@ export class LiveSynth {
|
|
|
147
200
|
score.key ?? null,
|
|
148
201
|
]
|
|
149
202
|
: []),
|
|
203
|
+
...(chord ? [chord] : []),
|
|
150
204
|
]);
|
|
151
|
-
return {
|
|
205
|
+
return {
|
|
206
|
+
track,
|
|
207
|
+
score,
|
|
208
|
+
bpm,
|
|
209
|
+
seconds,
|
|
210
|
+
durationTicks,
|
|
211
|
+
velocity,
|
|
212
|
+
pitch,
|
|
213
|
+
key,
|
|
214
|
+
organTick,
|
|
215
|
+
quantChord,
|
|
216
|
+
};
|
|
152
217
|
}
|
|
153
218
|
|
|
154
219
|
public render(request: LiveNoteRequest): LiveNotePcm | undefined {
|
|
155
220
|
const plan = this.plan(request);
|
|
156
221
|
if (!plan) return undefined;
|
|
157
|
-
const {
|
|
158
|
-
|
|
222
|
+
const {
|
|
223
|
+
track,
|
|
224
|
+
score,
|
|
225
|
+
bpm,
|
|
226
|
+
seconds,
|
|
227
|
+
durationTicks,
|
|
228
|
+
velocity,
|
|
229
|
+
pitch,
|
|
230
|
+
key,
|
|
231
|
+
organTick,
|
|
232
|
+
quantChord,
|
|
233
|
+
} = plan;
|
|
159
234
|
const liveEngine = engineFor(track);
|
|
160
235
|
// The first window of a rig note: its own cache entry, swapped for the
|
|
161
236
|
// whole note once the background pass has rendered it.
|
|
237
|
+
// Organs too: wheels, rotors and drive cost 4-8 ms per note-second.
|
|
162
238
|
const windowed =
|
|
163
239
|
request.full !== true &&
|
|
164
|
-
hasRig(track) &&
|
|
240
|
+
(hasRig(track) || organTick !== undefined || bowedTrack(track)) &&
|
|
165
241
|
seconds > LIVE_RIG_WINDOW_SECONDS &&
|
|
166
242
|
!this.cache.has(key);
|
|
167
243
|
if (windowed) {
|
|
@@ -214,6 +290,8 @@ export class LiveSynth {
|
|
|
214
290
|
? 1
|
|
215
291
|
: Math.max(MAX_LIVE_NOTE_SECONDS + 4, seconds + tail),
|
|
216
292
|
...(request.samples ? { samples: request.samples } : {}),
|
|
293
|
+
...(organTick ? { seedTick: organTick } : {}),
|
|
294
|
+
...(quantChord ? { quantChord } : {}),
|
|
217
295
|
}),
|
|
218
296
|
);
|
|
219
297
|
const frames = windowed
|
|
@@ -233,9 +311,13 @@ export class LiveSynth {
|
|
|
233
311
|
),
|
|
234
312
|
)
|
|
235
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);
|
|
236
317
|
const rendered: LiveNotePcm = {
|
|
237
|
-
pcm
|
|
318
|
+
pcm,
|
|
238
319
|
frames,
|
|
320
|
+
...(ringOut ? { ringOut: true as const } : {}),
|
|
239
321
|
...(liveEngine
|
|
240
322
|
? {
|
|
241
323
|
releaseSeconds: Math.min(
|
|
@@ -245,8 +327,11 @@ export class LiveSynth {
|
|
|
245
327
|
}
|
|
246
328
|
: {}),
|
|
247
329
|
...(windowed ? { partial: true } : {}),
|
|
330
|
+
...(windowed && organTick !== undefined ? { clock: organTick } : {}),
|
|
248
331
|
};
|
|
249
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;
|
|
250
335
|
this.cache.set(windowed ? `${key}#window` : key, rendered);
|
|
251
336
|
this.trim();
|
|
252
337
|
return rendered;
|
|
@@ -266,17 +351,32 @@ export class LiveSynth {
|
|
|
266
351
|
* without track `time`, so a key sounds once, as played, not re-placed.
|
|
267
352
|
*/
|
|
268
353
|
function liveTrack(track: Track): Track {
|
|
269
|
-
// A held key is the performance: the recorded
|
|
354
|
+
// A held key is the performance: the recorded pedals and the render-time
|
|
270
355
|
// humanize stay out of it. The velocity curve stays, so a key sounds like
|
|
271
356
|
// the note it records as.
|
|
272
357
|
const {
|
|
273
358
|
solo: _solo,
|
|
274
359
|
time: _time,
|
|
275
360
|
pedal: _pedal,
|
|
361
|
+
softPedal: _softPedal,
|
|
362
|
+
sostenuto: _sostenuto,
|
|
276
363
|
humanize: _humanize,
|
|
277
364
|
...rest
|
|
278
365
|
} = track;
|
|
279
|
-
|
|
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
|
+
);
|
|
280
380
|
}
|
|
281
381
|
|
|
282
382
|
/** Whether the track plays through a guitar rig stage. */
|
|
@@ -299,6 +399,32 @@ function samplerDigest(track: Track, bank: SampleBank | undefined): string[] {
|
|
|
299
399
|
}
|
|
300
400
|
|
|
301
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
|
+
|
|
302
428
|
function audibleFrames(pcm: Int16Array): number {
|
|
303
429
|
let last = pcm.length - 1;
|
|
304
430
|
while (last >= 0 && pcm[last] === 0) last -= 1;
|
|
@@ -344,6 +470,7 @@ export class LiveFullRenderer {
|
|
|
344
470
|
velocity: request.velocity,
|
|
345
471
|
seconds: request.seconds,
|
|
346
472
|
...(request.tick === undefined ? {} : { tick: request.tick }),
|
|
473
|
+
...(request.clock === undefined ? {} : { clock: request.clock }),
|
|
347
474
|
...(request.samples ? { samples: request.samples } : {}),
|
|
348
475
|
};
|
|
349
476
|
try {
|
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";
|
|
@@ -131,6 +137,14 @@ export function phraseRole(track: Track): PhraseRole {
|
|
|
131
137
|
if (isWavetableInstrument(track.instrument)) return "drone";
|
|
132
138
|
// Grain clouds are textures: held notes let them evolve.
|
|
133
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
|
+
}
|
|
134
148
|
const words = `${track.instrument} ${track.name} ${track.id}`.toLowerCase();
|
|
135
149
|
if (/\b(bass|sub|808)/.test(words)) return "riff";
|
|
136
150
|
if (/\b(lead|arp|pluck|saw|square|pulse|z_|mono|melody|hook)/.test(words))
|
|
@@ -287,7 +301,7 @@ function sliceTrack(track: Track, start: number, end: number): Track {
|
|
|
287
301
|
lanes[name] = sliceLane(points, start, end);
|
|
288
302
|
sliced.fxAutomation = lanes;
|
|
289
303
|
}
|
|
290
|
-
return sliced as Track;
|
|
304
|
+
return slicePedals(sliced as Track, [{ from: start, to: end, offset: 0 }]);
|
|
291
305
|
}
|
|
292
306
|
|
|
293
307
|
/**
|