@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
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electric keys (0.6.1 keys-electric lane): the Rhodes-style tine piano
|
|
3
|
+
* (`epiano`), the Wurlitzer-style reed piano (`wurli`) and the clavinet
|
|
4
|
+
* (`clav`), ported from the reviewed prototype
|
|
5
|
+
* (`.plans/dawg-06/proto/keys/keys.ts`, `TineVoice` and `ClavVoice`) with
|
|
6
|
+
* the review's round-2 fixes: the pickups run at twice the sample rate
|
|
7
|
+
* (band-limited both ways, so a hard-driven tine does not alias), the
|
|
8
|
+
* epiano carries its own suitcase stereo vibrato (`vibe`), the wurli its
|
|
9
|
+
* tremolo (`trem`), and clav pickup positions vary per track (seeded).
|
|
10
|
+
*
|
|
11
|
+
* Tine: a clamped-free cantilever, three modes at 1, 6.267 and 17.547 times
|
|
12
|
+
* the fundamental plus the tone bar, read through an electromagnetic pickup
|
|
13
|
+
* whose flux falls off with the tine's distance from the pole piece
|
|
14
|
+
* (1 / (1 + d^2)), differentiated (Faraday). After Shear and Wright, "The
|
|
15
|
+
* Electromagnetically Sustained Rhodes Piano" (NIME 2011) and Gabrielli et
|
|
16
|
+
* al., "Physical modeling of the Rhodes piano" (DAFx-10). Reed: the
|
|
17
|
+
* electrostatic pickup of the Wurlitzer 200 reads capacitance, 1 / (1 - k x),
|
|
18
|
+
* not a derivative; a peak at 1.1 kHz gives the nasal case. Clav: modal
|
|
19
|
+
* string struck near its end by a tangent, two pickup combs sin(n pi p) at
|
|
20
|
+
* neck and bridge, the mute slider damping upper partials and a small
|
|
21
|
+
* negative re-pluck when the string leaves the anvil (Gabrielli, Välimäki,
|
|
22
|
+
* Penttinen, Squartini and Bilbao, EURASIP JASP 2013:103).
|
|
23
|
+
*/
|
|
24
|
+
import { DcBlock, OnePole } from "../dsp/filters.ts";
|
|
25
|
+
import { Oversample2x } from "../dsp/oversample.ts";
|
|
26
|
+
import { unit } from "../dsp/rng.ts";
|
|
27
|
+
import {
|
|
28
|
+
Biquad2,
|
|
29
|
+
Burst,
|
|
30
|
+
clamp,
|
|
31
|
+
hammerPulse,
|
|
32
|
+
lerp,
|
|
33
|
+
LN1000,
|
|
34
|
+
ModeBank,
|
|
35
|
+
panGains,
|
|
36
|
+
pulseMag,
|
|
37
|
+
TAU,
|
|
38
|
+
} from "./dsp.ts";
|
|
39
|
+
|
|
40
|
+
/** Resolved electric voice parameters (core/keys.ts `KEYS_PARAMS`). */
|
|
41
|
+
export type ElectricParams = Readonly<{
|
|
42
|
+
kind: "epiano" | "wurli" | "clav";
|
|
43
|
+
hardness: number;
|
|
44
|
+
touch: number;
|
|
45
|
+
decay: number;
|
|
46
|
+
release: number;
|
|
47
|
+
width: number;
|
|
48
|
+
bark: number;
|
|
49
|
+
bell: number;
|
|
50
|
+
/** Output low-pass in Hz; 0 is off. */
|
|
51
|
+
tone: number;
|
|
52
|
+
pickup: "neck" | "bridge" | "both" | "out";
|
|
53
|
+
mute: number;
|
|
54
|
+
}>;
|
|
55
|
+
|
|
56
|
+
export type ElectricNoteOn = Readonly<{
|
|
57
|
+
pitch: number;
|
|
58
|
+
hz: number;
|
|
59
|
+
velocity: number;
|
|
60
|
+
/** Track seed hash (clav pickup positions are per track). */
|
|
61
|
+
trackSeed: number;
|
|
62
|
+
noteSeed: string;
|
|
63
|
+
}>;
|
|
64
|
+
|
|
65
|
+
/** Clamped-free cantilever mode ratios. */
|
|
66
|
+
const BEAM = [1, 6.267, 17.547] as const;
|
|
67
|
+
/** Rest position of the tine relative to the pickup pole. */
|
|
68
|
+
const X0 = 0.4;
|
|
69
|
+
|
|
70
|
+
/** The voice interface the keys engine drives (shared with `PianoVoice`). */
|
|
71
|
+
export type KeysVoice = {
|
|
72
|
+
readonly gl: number;
|
|
73
|
+
readonly gr: number;
|
|
74
|
+
readonly released: boolean;
|
|
75
|
+
noteOff(): void;
|
|
76
|
+
setCents(cents: number): void;
|
|
77
|
+
damp(tau: number): void;
|
|
78
|
+
process(out: Float64Array, offset: number, count: number): boolean;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Seconds a released tine or reed takes to fall 60 dB. */
|
|
82
|
+
export function tineReleaseT60(pitch: number, release: number): number {
|
|
83
|
+
return release * (0.09 + 0.25 * clamp((60 - pitch) / 32, 0, 1));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Seconds a released clav string takes to fall 60 dB (the yarn damper). */
|
|
87
|
+
export function clavReleaseT60(release: number): number {
|
|
88
|
+
return release * 0.06;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Tine (epiano) and reed (wurli) voice. */
|
|
92
|
+
export class TineVoice implements KeysVoice {
|
|
93
|
+
private readonly bank: ModeBank;
|
|
94
|
+
private readonly pulse: Float64Array;
|
|
95
|
+
private pos = 0;
|
|
96
|
+
private readonly over = new Oversample2x();
|
|
97
|
+
private readonly dc: DcBlock;
|
|
98
|
+
private readonly lp: OnePole | undefined;
|
|
99
|
+
private readonly peak: Biquad2 | undefined;
|
|
100
|
+
private readonly norm: number;
|
|
101
|
+
readonly gl: number;
|
|
102
|
+
readonly gr: number;
|
|
103
|
+
private tmp = new Float64Array(0);
|
|
104
|
+
private readonly bursts: Burst[] = [];
|
|
105
|
+
private readonly rhodes: boolean;
|
|
106
|
+
/** Pickup state at the doubled rate. */
|
|
107
|
+
private prevX = 0;
|
|
108
|
+
private prevPhi = 0;
|
|
109
|
+
private releasedFlag = false;
|
|
110
|
+
private age = 0;
|
|
111
|
+
private readonly shape: (x: number) => number;
|
|
112
|
+
|
|
113
|
+
constructor(
|
|
114
|
+
private readonly note: ElectricNoteOn,
|
|
115
|
+
private readonly p: ElectricParams,
|
|
116
|
+
private readonly sr: number,
|
|
117
|
+
) {
|
|
118
|
+
const v = clamp(lerp(0.8, note.velocity, p.touch), 0.01, 1);
|
|
119
|
+
const key = note.pitch;
|
|
120
|
+
const f = note.hz;
|
|
121
|
+
const rhodes = p.kind === "epiano";
|
|
122
|
+
this.rhodes = rhodes;
|
|
123
|
+
const T = rhodes
|
|
124
|
+
? p.decay * 9 * 2 ** (-(key - 36) / 26)
|
|
125
|
+
: p.decay * 4.5 * 2 ** (-(key - 40) / 26);
|
|
126
|
+
const bell =
|
|
127
|
+
p.bell *
|
|
128
|
+
(rhodes ? 0.04 + 0.22 * v * v : 0.02 + 0.08 * v * v) *
|
|
129
|
+
2 ** (-(key - 60) / 30);
|
|
130
|
+
const drive = lerp(0.35, rhodes ? 1.6 : 1.5, p.bark);
|
|
131
|
+
const A = (0.15 + 0.85 * v) * drive;
|
|
132
|
+
// Hardness scales the hammer contact (0.5 is the prototype's).
|
|
133
|
+
const contact = lerp(2.2, 0.9, v) * (1.4 - 0.8 * p.hardness);
|
|
134
|
+
this.pulse = hammerPulse(Math.max(2, Math.round(contact * 1e-3 * sr)), 1.5);
|
|
135
|
+
this.bank = new ModeBank(4);
|
|
136
|
+
this.bank.add(f, T, A * 0.85, 1, sr);
|
|
137
|
+
// Tone bar: a slightly sharp, longer partner (the Rhodes tuning fork).
|
|
138
|
+
if (rhodes) this.bank.add(f * 2 ** (0.4 / 1200), T * 1.3, A * 0.15, 1, sr);
|
|
139
|
+
this.bank.add(f * BEAM[1], 0.35 * 2 ** (-(key - 60) / 24), A * bell, 1, sr);
|
|
140
|
+
if (rhodes) this.bank.add(f * BEAM[2], 0.06, A * bell * 0.3 * v, 1, sr);
|
|
141
|
+
// Normalise by the pickup's small-signal slope at the doubled rate. The
|
|
142
|
+
// tine's 0.36 puts a velocity-0.8 C-major triad near -6.5 dBFS, the
|
|
143
|
+
// house level (the reed's 0.32 already sits there).
|
|
144
|
+
const w = (TAU * f) / (2 * sr);
|
|
145
|
+
this.norm =
|
|
146
|
+
(rhodes ? 0.36 : 0.32) /
|
|
147
|
+
((rhodes ? (2 * X0) / (1 + X0 * X0) ** 2 : 0.5) * w);
|
|
148
|
+
this.prevPhi = this.pickup(0);
|
|
149
|
+
this.dc = new DcBlock(sr, 8);
|
|
150
|
+
this.lp = p.tone > 0 ? new OnePole(p.tone, sr) : undefined;
|
|
151
|
+
this.peak = rhodes
|
|
152
|
+
? undefined
|
|
153
|
+
: new Biquad2().set("peak", 1100, 0.9, 4, sr);
|
|
154
|
+
[this.gl, this.gr] = panGains(p.width * clamp((key - 60) / 30, -1, 1));
|
|
155
|
+
this.bursts.push(
|
|
156
|
+
new Burst(
|
|
157
|
+
`${note.noteSeed}:tine`,
|
|
158
|
+
rhodes ? 1800 : 1200,
|
|
159
|
+
1.2,
|
|
160
|
+
0.002,
|
|
161
|
+
0.01 * v * v,
|
|
162
|
+
sr,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
// The pickup at 2x: linear interpolation of the modal output between
|
|
166
|
+
// base-rate samples is done by the half-band interpolator; this runs on
|
|
167
|
+
// each doubled-rate sample in order, so it may keep state.
|
|
168
|
+
this.shape = (x: number) => {
|
|
169
|
+
this.prevX = x;
|
|
170
|
+
const phi = this.pickup(x);
|
|
171
|
+
const dy = rhodes ? -(phi - this.prevPhi) : phi - this.prevPhi;
|
|
172
|
+
this.prevPhi = phi;
|
|
173
|
+
const y = dy * this.norm;
|
|
174
|
+
return rhodes ? y : Math.tanh(1.3 * y) / 1.3;
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private pickup(x: number): number {
|
|
179
|
+
if (this.rhodes) {
|
|
180
|
+
const d = x - X0;
|
|
181
|
+
return 1 / (1 + d * d);
|
|
182
|
+
}
|
|
183
|
+
return 1 / (1 - 0.5 * Math.min(x, 1.6));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
get released(): boolean {
|
|
187
|
+
return this.releasedFlag;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
noteOff(): void {
|
|
191
|
+
if (this.releasedFlag) return;
|
|
192
|
+
this.releasedFlag = true;
|
|
193
|
+
const t = tineReleaseT60(this.note.pitch, this.p.release);
|
|
194
|
+
this.bank.damp(() => t, this.sr);
|
|
195
|
+
this.bursts.push(
|
|
196
|
+
new Burst(
|
|
197
|
+
`${this.note.noteSeed}:damper`,
|
|
198
|
+
180,
|
|
199
|
+
1,
|
|
200
|
+
0.02,
|
|
201
|
+
0.006 * this.note.velocity,
|
|
202
|
+
this.sr,
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setCents(cents: number): void {
|
|
208
|
+
this.bank.retune(2 ** (cents / 1200));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
damp(tau: number): void {
|
|
212
|
+
this.bank.damp(() => LN1000 * tau, this.sr);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
process(out: Float64Array, offset: number, count: number): boolean {
|
|
216
|
+
if (this.tmp.length < count) this.tmp = new Float64Array(count);
|
|
217
|
+
const tmp = this.tmp;
|
|
218
|
+
tmp.fill(0, 0, count);
|
|
219
|
+
let done = 0;
|
|
220
|
+
if (this.pos < this.pulse.length) {
|
|
221
|
+
done = Math.min(count, this.pulse.length - this.pos);
|
|
222
|
+
this.bank.run(tmp, 0, done, this.pulse, this.pos);
|
|
223
|
+
this.pos += done;
|
|
224
|
+
}
|
|
225
|
+
if (done < count) this.bank.run(tmp, done, count - done, null, 0);
|
|
226
|
+
for (let i = 0; i < count; i += 1) {
|
|
227
|
+
let y = this.over.process(tmp[i]!, this.shape);
|
|
228
|
+
y = this.dc.process(y);
|
|
229
|
+
if (this.peak) y = this.peak.process(y);
|
|
230
|
+
if (this.lp) y = this.lp.process(y);
|
|
231
|
+
for (const b of this.bursts) if (!b.done) y += b.next();
|
|
232
|
+
out[offset + i]! += y;
|
|
233
|
+
}
|
|
234
|
+
this.age += count;
|
|
235
|
+
if (this.age % 256 < count) this.bank.cull(1e-6);
|
|
236
|
+
// The oversampler and DC blocker hold a short tail after the modes go.
|
|
237
|
+
return (
|
|
238
|
+
this.bank.n > 0 ||
|
|
239
|
+
this.bursts.some((b) => !b.done) ||
|
|
240
|
+
Math.abs(this.prevX) > 1e-9
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Clav pickup positions (fractions of the string from the bridge end), with
|
|
247
|
+
* a seeded per-track spread of +/-6%: two clavinets never sound identical.
|
|
248
|
+
*/
|
|
249
|
+
export function clavPositions(trackSeed: number): {
|
|
250
|
+
neck: number;
|
|
251
|
+
bridge: number;
|
|
252
|
+
tangent: number;
|
|
253
|
+
} {
|
|
254
|
+
const spread = (slot: number) => 1 + 0.12 * (unit(trackSeed, 0, slot) - 0.5);
|
|
255
|
+
return {
|
|
256
|
+
neck: 0.19 * spread(0),
|
|
257
|
+
bridge: 0.065 * spread(1),
|
|
258
|
+
tangent: 0.04 * spread(2),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export class ClavVoice implements KeysVoice {
|
|
263
|
+
private readonly bank: ModeBank;
|
|
264
|
+
private pulse: Float64Array;
|
|
265
|
+
private pos = 0;
|
|
266
|
+
private readonly lp: OnePole | undefined;
|
|
267
|
+
private readonly hp: Biquad2;
|
|
268
|
+
readonly gl: number;
|
|
269
|
+
readonly gr: number;
|
|
270
|
+
private tmp = new Float64Array(0);
|
|
271
|
+
private releasedFlag = false;
|
|
272
|
+
private age = 0;
|
|
273
|
+
|
|
274
|
+
constructor(
|
|
275
|
+
note: ElectricNoteOn,
|
|
276
|
+
private readonly p: ElectricParams,
|
|
277
|
+
private readonly sr: number,
|
|
278
|
+
) {
|
|
279
|
+
const v = clamp(lerp(0.8, note.velocity, p.touch), 0.01, 1);
|
|
280
|
+
const key = note.pitch;
|
|
281
|
+
const B = 1.5e-4 * 2 ** ((key - 48) / 16);
|
|
282
|
+
// Partial 1 sits exactly at the tuned pitch.
|
|
283
|
+
const f0 = note.hz / Math.sqrt(1 + B);
|
|
284
|
+
const T = p.decay * 5 * 2 ** (-(key - 40) / 24);
|
|
285
|
+
const { neck, bridge, tangent } = clavPositions(note.trackSeed);
|
|
286
|
+
const contact = lerp(0.6, 0.25, v) * (1.4 - 0.8 * p.hardness);
|
|
287
|
+
this.pulse = hammerPulse(Math.max(2, Math.round(contact * 1e-3 * sr)), 1.2);
|
|
288
|
+
const bank = new ModeBank(80);
|
|
289
|
+
const modes: [number, number, number][] = [];
|
|
290
|
+
let energy = 0;
|
|
291
|
+
for (let n = 1; n <= 80; n += 1) {
|
|
292
|
+
const fn = n * f0 * Math.sqrt(1 + B * n * n);
|
|
293
|
+
if (fn >= 0.45 * sr) break;
|
|
294
|
+
const a = Math.sin(n * Math.PI * neck);
|
|
295
|
+
const b = Math.sin(n * Math.PI * bridge);
|
|
296
|
+
const w =
|
|
297
|
+
p.pickup === "neck"
|
|
298
|
+
? a
|
|
299
|
+
: p.pickup === "bridge"
|
|
300
|
+
? b * 2
|
|
301
|
+
: p.pickup === "out"
|
|
302
|
+
? a - b
|
|
303
|
+
: (a + b) * 0.7;
|
|
304
|
+
const u = Math.sin(n * Math.PI * tangent) * w;
|
|
305
|
+
const t60 =
|
|
306
|
+
(T / (1 + (fn / 3000) ** 2)) *
|
|
307
|
+
(1 - 0.85 * p.mute * Math.min(1, (n - 1) / 4));
|
|
308
|
+
modes.push([fn, t60, u]);
|
|
309
|
+
// Level is set on what the pickup hears through the tangent's
|
|
310
|
+
// contact spectrum, so every pickup switch sits within about 1.5 dB
|
|
311
|
+
// of the others (a C-major triad at velocity 0.8 near -6 dBFS).
|
|
312
|
+
energy += (u * pulseMag(this.pulse, (2 * Math.PI * fn) / sr)) ** 2;
|
|
313
|
+
}
|
|
314
|
+
// Out of phase leaves a peakier, thinner sum: +1.6 dB brings its peak
|
|
315
|
+
// level to the other pickups' (measured at C3, velocity 0.8).
|
|
316
|
+
const trim = p.pickup === "out" ? 1.2 : 1;
|
|
317
|
+
const G = (0.146 * trim * v ** 1.2) / Math.sqrt(Math.max(1e-12, energy));
|
|
318
|
+
for (const [fn, t60, u] of modes)
|
|
319
|
+
bank.add(fn, Math.max(0.02, t60), u * G, 1, sr);
|
|
320
|
+
this.bank = bank;
|
|
321
|
+
this.lp = p.tone > 0 ? new OnePole(p.tone, sr) : undefined;
|
|
322
|
+
this.hp = new Biquad2().set("hpf", 70, 0.7, 0, sr);
|
|
323
|
+
[this.gl, this.gr] = panGains(p.width * clamp((key - 60) / 30, -1, 1));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
get released(): boolean {
|
|
327
|
+
return this.releasedFlag;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
noteOff(): void {
|
|
331
|
+
if (this.releasedFlag) return;
|
|
332
|
+
this.releasedFlag = true;
|
|
333
|
+
// The string leaves the anvil: a small negative pluck, then the yarn damps it.
|
|
334
|
+
const plunk = hammerPulse(Math.max(2, Math.round(0.3e-3 * this.sr)), 1);
|
|
335
|
+
for (let i = 0; i < plunk.length; i += 1) plunk[i]! *= -0.35;
|
|
336
|
+
this.pulse = plunk;
|
|
337
|
+
this.pos = 0;
|
|
338
|
+
const t = clavReleaseT60(this.p.release);
|
|
339
|
+
this.bank.damp(() => t, this.sr);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
setCents(cents: number): void {
|
|
343
|
+
this.bank.retune(2 ** (cents / 1200));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
damp(tau: number): void {
|
|
347
|
+
this.bank.damp(() => LN1000 * tau, this.sr);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
process(out: Float64Array, offset: number, count: number): boolean {
|
|
351
|
+
if (this.tmp.length < count) this.tmp = new Float64Array(count);
|
|
352
|
+
const tmp = this.tmp;
|
|
353
|
+
tmp.fill(0, 0, count);
|
|
354
|
+
let done = 0;
|
|
355
|
+
if (this.pos < this.pulse.length) {
|
|
356
|
+
done = Math.min(count, this.pulse.length - this.pos);
|
|
357
|
+
this.bank.run(tmp, 0, done, this.pulse, this.pos);
|
|
358
|
+
this.pos += done;
|
|
359
|
+
}
|
|
360
|
+
if (done < count) this.bank.run(tmp, done, count - done, null, 0);
|
|
361
|
+
for (let i = 0; i < count; i += 1) {
|
|
362
|
+
let y = this.hp.process(tmp[i]!);
|
|
363
|
+
if (this.lp) y = this.lp.process(y);
|
|
364
|
+
out[offset + i]! += y;
|
|
365
|
+
}
|
|
366
|
+
this.age += count;
|
|
367
|
+
if (this.age % 256 < count) this.bank.cull(1e-6);
|
|
368
|
+
return this.bank.n > 0;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** A voice for an electric family. */
|
|
373
|
+
export function electricVoice(
|
|
374
|
+
note: ElectricNoteOn,
|
|
375
|
+
p: ElectricParams,
|
|
376
|
+
sr: number,
|
|
377
|
+
): KeysVoice {
|
|
378
|
+
return p.kind === "clav"
|
|
379
|
+
? new ClavVoice(note, p, sr)
|
|
380
|
+
: new TineVoice(note, p, sr);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The electric post stage, run on the summed track: the epiano's suitcase
|
|
385
|
+
* stereo vibrato (antiphase left/right gain, `vibe` depth at `vibehz`) and
|
|
386
|
+
* the wurli's tremolo (`trem` depth at 5.6 Hz, both channels). The phase is
|
|
387
|
+
* song time (`startSample` of the buffer at the song's sample 0), so a loop
|
|
388
|
+
* and a full render agree. `depthAt(sample)` reads the lane.
|
|
389
|
+
*/
|
|
390
|
+
export function electricPost(
|
|
391
|
+
left: Float64Array,
|
|
392
|
+
right: Float64Array,
|
|
393
|
+
kind: ElectricParams["kind"],
|
|
394
|
+
rateHz: number,
|
|
395
|
+
depthAt: (sample: number) => number,
|
|
396
|
+
sr: number,
|
|
397
|
+
startSample = 0,
|
|
398
|
+
): void {
|
|
399
|
+
if (kind === "clav") return;
|
|
400
|
+
const block = 32;
|
|
401
|
+
for (let i = 0; i < left.length; i += block) {
|
|
402
|
+
const depth = clamp(depthAt(i), 0, 1);
|
|
403
|
+
const end = Math.min(left.length, i + block);
|
|
404
|
+
if (depth <= 0) continue;
|
|
405
|
+
for (let s = i; s < end; s += 1) {
|
|
406
|
+
const lfo = Math.sin((TAU * rateHz * (s + startSample)) / sr);
|
|
407
|
+
if (kind === "epiano") {
|
|
408
|
+
// Constant-power autopan between the two speakers of the suitcase.
|
|
409
|
+
const pan = depth * lfo;
|
|
410
|
+
const a = ((pan + 1) * Math.PI) / 4;
|
|
411
|
+
left[s]! *= Math.SQRT2 * Math.cos(a);
|
|
412
|
+
right[s]! *= Math.SQRT2 * Math.sin(a);
|
|
413
|
+
} else {
|
|
414
|
+
const gain = 1 - 0.5 * depth * (1 - lfo);
|
|
415
|
+
left[s]! *= gain;
|
|
416
|
+
right[s]! *= gain;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
package/src/audio/keys/engine.ts
CHANGED
|
@@ -16,14 +16,20 @@
|
|
|
16
16
|
* - Post: the family's body EQ per channel (the minimal per-track post
|
|
17
17
|
* hook), then the track volume, before the existing effects chain.
|
|
18
18
|
*/
|
|
19
|
-
import type
|
|
20
|
-
import {
|
|
19
|
+
import { pedalStateAt, type PerformedNote } from "../../../core/expression.ts";
|
|
20
|
+
import {
|
|
21
|
+
isElectricFamily,
|
|
22
|
+
isPianoFamily,
|
|
23
|
+
resolvedKeys,
|
|
24
|
+
type ElectricFamily,
|
|
25
|
+
} from "../../../core/keys.ts";
|
|
21
26
|
import type { Track } from "../../../core/score.ts";
|
|
22
27
|
import { noteHz } from "../../../core/tuning.ts";
|
|
23
28
|
import type { FxLane } from "../../../core/fx.ts";
|
|
24
29
|
import { interpolateAutomation } from "../effects/common.ts";
|
|
25
30
|
import type { EngineContext, InstrumentEngine } from "../instruments.ts";
|
|
26
31
|
import { Biquad2, clamp, CONTROL, LN1000 } from "./dsp.ts";
|
|
32
|
+
import { ORGAN_ENGINES } from "./organ.ts";
|
|
27
33
|
import {
|
|
28
34
|
damperT60,
|
|
29
35
|
physicalKey,
|
|
@@ -34,6 +40,15 @@ import {
|
|
|
34
40
|
trackSeedOf,
|
|
35
41
|
type PianoParams,
|
|
36
42
|
} from "./piano.ts";
|
|
43
|
+
import {
|
|
44
|
+
clavReleaseT60,
|
|
45
|
+
electricPost,
|
|
46
|
+
electricVoice,
|
|
47
|
+
tineReleaseT60,
|
|
48
|
+
type ElectricParams,
|
|
49
|
+
type KeysVoice,
|
|
50
|
+
} from "./electric.ts";
|
|
51
|
+
import { Sympathetic } from "./sympathetic.ts";
|
|
37
52
|
|
|
38
53
|
export const KEYS_LIMITS = Object.freeze({
|
|
39
54
|
/** Per-track polyphony cap (voice stealing beyond it). */
|
|
@@ -98,6 +113,39 @@ export function pianoParamsAt(
|
|
|
98
113
|
};
|
|
99
114
|
}
|
|
100
115
|
|
|
116
|
+
/** Electric voice parameters for a note: the resolved keys plus lanes. */
|
|
117
|
+
export function electricParamsAt(
|
|
118
|
+
track: Track,
|
|
119
|
+
values: Values,
|
|
120
|
+
tick: number,
|
|
121
|
+
kind: ElectricFamily,
|
|
122
|
+
): ElectricParams {
|
|
123
|
+
const lane = (name: string) => {
|
|
124
|
+
const points = track.fxAutomation?.[`keys-${name}` as FxLane];
|
|
125
|
+
const base = num(values, name);
|
|
126
|
+
return points && points.length > 0
|
|
127
|
+
? interpolateAutomation(points, tick, base)
|
|
128
|
+
: base;
|
|
129
|
+
};
|
|
130
|
+
const pickup = values.pickup;
|
|
131
|
+
return {
|
|
132
|
+
kind,
|
|
133
|
+
hardness: clamp(lane("hardness"), 0, 1),
|
|
134
|
+
touch: clamp(lane("touch"), 0, 1),
|
|
135
|
+
decay: clamp(lane("decay"), 0.1, 4),
|
|
136
|
+
release: clamp(lane("release"), 0.1, 4),
|
|
137
|
+
width: num(values, "width"),
|
|
138
|
+
bark: num(values, "bark"),
|
|
139
|
+
bell: num(values, "bell"),
|
|
140
|
+
tone: clamp(lane("tone"), 0, 12000),
|
|
141
|
+
pickup:
|
|
142
|
+
pickup === "neck" || pickup === "bridge" || pickup === "out"
|
|
143
|
+
? pickup
|
|
144
|
+
: "both",
|
|
145
|
+
mute: num(values, "mute"),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
101
149
|
/** Body voicing name for a track (the stored `body`, else the family's). */
|
|
102
150
|
export function bodyOf(values: Values): string {
|
|
103
151
|
const body = values.body;
|
|
@@ -105,7 +153,7 @@ export function bodyOf(values: Values): string {
|
|
|
105
153
|
}
|
|
106
154
|
|
|
107
155
|
type Live = {
|
|
108
|
-
voice:
|
|
156
|
+
voice: KeysVoice;
|
|
109
157
|
/** Sample index the voice started at. */
|
|
110
158
|
start: number;
|
|
111
159
|
/** Sample index of key-up. */
|
|
@@ -161,6 +209,9 @@ export function renderKeysTrack(
|
|
|
161
209
|
const sr = context.sampleRate;
|
|
162
210
|
const total = left.length;
|
|
163
211
|
const values = resolvedKeys(track.instrument ?? "grand", track.keys);
|
|
212
|
+
const electric = isElectricFamily(track.instrument)
|
|
213
|
+
? track.instrument
|
|
214
|
+
: undefined;
|
|
164
215
|
const trackSeed = trackSeedOf(track.id);
|
|
165
216
|
const seedTick = context.seedTick ?? 0;
|
|
166
217
|
const window = Math.round(KEYS_LIMITS.tailSeconds * sr);
|
|
@@ -204,18 +255,30 @@ export function renderKeysTrack(
|
|
|
204
255
|
victim.stolen = true;
|
|
205
256
|
}
|
|
206
257
|
const note = onset.note;
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
258
|
+
// Una corda (0.6.1) at the onset: down is the full shift, half half.
|
|
259
|
+
const softState = track.softPedal
|
|
260
|
+
? pedalStateAt(track.softPedal, note.startTick)
|
|
261
|
+
: "up";
|
|
262
|
+
const soft = softState === "down" ? 1 : softState === "half" ? 0.5 : 0;
|
|
263
|
+
const noteOn = {
|
|
264
|
+
pitch: note.pitch,
|
|
265
|
+
hz: onset.hz,
|
|
266
|
+
velocity: clamp(note.velocity, 0, 1),
|
|
267
|
+
trackSeed,
|
|
268
|
+
noteSeed: `${track.id}:${note.id}:${note.startTick + seedTick}`,
|
|
269
|
+
...(soft > 0 ? { soft } : {}),
|
|
270
|
+
};
|
|
271
|
+
const voice: KeysVoice = electric
|
|
272
|
+
? electricVoice(
|
|
273
|
+
noteOn,
|
|
274
|
+
electricParamsAt(track, values, note.startTick, electric),
|
|
275
|
+
sr,
|
|
276
|
+
)
|
|
277
|
+
: new PianoVoice(
|
|
278
|
+
noteOn,
|
|
279
|
+
pianoParamsAt(track, values, note.startTick),
|
|
280
|
+
sr,
|
|
281
|
+
);
|
|
219
282
|
const performance = note.performance;
|
|
220
283
|
// The `vib`/`vibmod` wow (lofi) unless the note brings its own vibrato.
|
|
221
284
|
const vib = num(values, "vib");
|
|
@@ -277,6 +340,50 @@ export function renderKeysTrack(
|
|
|
277
340
|
if (!alive) live.splice(k, 1);
|
|
278
341
|
}
|
|
279
342
|
}
|
|
343
|
+
if (electric) {
|
|
344
|
+
// The electric post: suitcase vibrato or reed tremolo, then volume.
|
|
345
|
+
const param = electric === "wurli" ? "trem" : "vibe";
|
|
346
|
+
const points = track.fxAutomation?.[`keys-${param}` as FxLane];
|
|
347
|
+
const base = num(values, param);
|
|
348
|
+
const tickOf = (sample: number) =>
|
|
349
|
+
context.warp
|
|
350
|
+
? context.warp.tick(sample)
|
|
351
|
+
: sample / context.samplesPerTick;
|
|
352
|
+
const depthAt =
|
|
353
|
+
points && points.length > 0
|
|
354
|
+
? (sample: number) =>
|
|
355
|
+
interpolateAutomation(points, tickOf(sample), base)
|
|
356
|
+
: () => base;
|
|
357
|
+
const rate = electric === "wurli" ? 5.6 : num(values, "vibehz") || 4;
|
|
358
|
+
const startSample = Math.round(seedTick * context.samplesPerTick);
|
|
359
|
+
electricPost(left, right, electric, rate, depthAt, sr, startSample);
|
|
360
|
+
applyVolume(left, right, track, context);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
// Sympathetic resonance (0.6.1): only with `sym` and sustain pedal events.
|
|
364
|
+
const sym = num(values, "sym");
|
|
365
|
+
const pedal = track.pedal;
|
|
366
|
+
if (sym > 0 && pedal?.some((event) => event.state !== "up")) {
|
|
367
|
+
const bank = new Sympathetic(
|
|
368
|
+
(pitch) => noteHz(pitch, undefined, context.tuning),
|
|
369
|
+
sym,
|
|
370
|
+
sr,
|
|
371
|
+
);
|
|
372
|
+
// Pedal changes as samples; the bank reads them in order.
|
|
373
|
+
const changes = pedal.map((event) => ({
|
|
374
|
+
at: context.warp
|
|
375
|
+
? context.warp.sample(event.tick)
|
|
376
|
+
: event.tick * context.samplesPerTick,
|
|
377
|
+
down: event.state !== "up",
|
|
378
|
+
}));
|
|
379
|
+
let cursor = 0;
|
|
380
|
+
let down = false;
|
|
381
|
+
bank.process(left, right, (sample) => {
|
|
382
|
+
while (cursor < changes.length && changes[cursor]!.at <= sample)
|
|
383
|
+
down = changes[cursor++]!.down;
|
|
384
|
+
return down;
|
|
385
|
+
});
|
|
386
|
+
}
|
|
280
387
|
// The per-track post hook: body EQ, then the track volume.
|
|
281
388
|
const body = bodyOf(values);
|
|
282
389
|
const eqL = pianoBody(body, sr);
|
|
@@ -329,6 +436,14 @@ function applyVolume(
|
|
|
329
436
|
export function keysReleaseSeconds(track: Track, hz: number): number {
|
|
330
437
|
if (!(hz > 0) || !Number.isFinite(hz)) return 0.01;
|
|
331
438
|
const values = resolvedKeys(track.instrument ?? "grand", track.keys);
|
|
439
|
+
if (isElectricFamily(track.instrument)) {
|
|
440
|
+
const release = clamp(num(values, "release"), 0.1, 4);
|
|
441
|
+
const t60 =
|
|
442
|
+
track.instrument === "clav"
|
|
443
|
+
? clavReleaseT60(release)
|
|
444
|
+
: tineReleaseT60(physicalKey(hz), release);
|
|
445
|
+
return Math.max(0.01, (3 / (2 * LN1000)) * t60);
|
|
446
|
+
}
|
|
332
447
|
const p = pianoParamsAt(track, values, 0);
|
|
333
448
|
const key = physicalKey(hz);
|
|
334
449
|
if (undamped(key)) return KEYS_LIMITS.tailSeconds;
|
|
@@ -353,9 +468,18 @@ function engineFor(id: string): InstrumentEngine {
|
|
|
353
468
|
};
|
|
354
469
|
}
|
|
355
470
|
|
|
356
|
-
/**
|
|
471
|
+
/**
|
|
472
|
+
* One engine per keys family (the registry dispatches by instrument id):
|
|
473
|
+
* the pianos, then the electric keys and the organs (0.6.1).
|
|
474
|
+
*/
|
|
357
475
|
export const KEYS_ENGINES: readonly InstrumentEngine[] = Object.freeze(
|
|
358
|
-
[
|
|
359
|
-
.filter((id) =>
|
|
360
|
-
|
|
476
|
+
[
|
|
477
|
+
...["grand", "upright", "felt", "honkytonk", "prepared"].filter((id) =>
|
|
478
|
+
isPianoFamily(id),
|
|
479
|
+
),
|
|
480
|
+
...["epiano", "wurli", "clav"].filter((id) => isElectricFamily(id)),
|
|
481
|
+
]
|
|
482
|
+
.map(engineFor)
|
|
483
|
+
// f061-organ: tonewheel, combo and pipe (src/audio/keys/organ.ts).
|
|
484
|
+
.concat(ORGAN_ENGINES),
|
|
361
485
|
);
|