@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,1335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Organs on the keys engine (0.6.1 f061-organ), ported from the design
|
|
3
|
+
* prototype (proto/keys/keys.ts TonewheelVoice, OrganPost, ComboVoice,
|
|
4
|
+
* PipeVoice):
|
|
5
|
+
*
|
|
6
|
+
* - `tonewheel`: nine drawbars on 91 free-running tonewheels whose phase
|
|
7
|
+
* is a function of song time (never reset per key), foldback at both
|
|
8
|
+
* ends, single-trigger percussion that mutes the 1' bar, key click.
|
|
9
|
+
* - `combo`: divider-style registers (16' 8' 4' 2⅔' 2') of band-limited
|
|
10
|
+
* pulse or saw waves, with the `vib`/`vibmod` vibrato.
|
|
11
|
+
* - `pipe`: band-limited additive wavetable ranks, 19 stops with
|
|
12
|
+
* registrations, per-pipe seeded detune, wind, chiff and tremulant.
|
|
13
|
+
*
|
|
14
|
+
* Every organ track runs one `OrganPost` on its sum: scanner vibrato or
|
|
15
|
+
* chorus, preamp drive (2x oversampled) and a two-rotor rotary speaker
|
|
16
|
+
* whose rotors glide between speeds with inertia (lane `keys-rotary`).
|
|
17
|
+
* Randomness is seeded only; nothing reads the clock.
|
|
18
|
+
*/
|
|
19
|
+
import type { PerformedNote } from "../../../core/expression.ts";
|
|
20
|
+
import type { FxLane } from "../../../core/fx.ts";
|
|
21
|
+
import {
|
|
22
|
+
isOrganFamily,
|
|
23
|
+
pipeStops,
|
|
24
|
+
resolvedKeys,
|
|
25
|
+
rotarySpeed,
|
|
26
|
+
type OrganFamily,
|
|
27
|
+
} from "../../../core/keys.ts";
|
|
28
|
+
import type { Track } from "../../../core/score.ts";
|
|
29
|
+
import { noteHz, type TuningTable } from "../../../core/tuning.ts";
|
|
30
|
+
import { seedHash, seededRandom } from "../dsp/rng.ts";
|
|
31
|
+
import { OnePole } from "../dsp/filters.ts";
|
|
32
|
+
import { interpolateAutomation } from "../effects/common.ts";
|
|
33
|
+
import { sampleWarpFor } from "../warp.ts";
|
|
34
|
+
import type { TimeScore } from "../../../core/tempo.ts";
|
|
35
|
+
import type { EngineContext, InstrumentEngine } from "../instruments.ts";
|
|
36
|
+
import { polyBlep } from "../synth/oscillators.ts";
|
|
37
|
+
import { Biquad2, Burst, clamp, CONTROL, TAU } from "./dsp.ts";
|
|
38
|
+
|
|
39
|
+
/** Polyphony per organ track; the oldest voice is stolen past it. */
|
|
40
|
+
export const ORGAN_VOICES = 64;
|
|
41
|
+
const STEAL_SECONDS = 0.005;
|
|
42
|
+
|
|
43
|
+
/** Drawbar offsets in semitones: 16' 5⅓' 8' 4' 2⅔' 2' 1⅗' 1⅓' 1'. */
|
|
44
|
+
export const DRAWBAR_SEMITONES = Object.freeze([
|
|
45
|
+
-12, 7, 0, 12, 19, 24, 28, 31, 36,
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** The 91 wheels span keys 24..114 (C1 to F#8); keys outside fold back. */
|
|
49
|
+
export const WHEEL_LOW = 24;
|
|
50
|
+
export const WHEEL_HIGH = 114;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The tonewheel a drawbar footage of `pitch` sounds: the 16' folds back up
|
|
54
|
+
* an octave below C1 and the top footages fold down above the 91st wheel.
|
|
55
|
+
*/
|
|
56
|
+
export function wheelPitch(pitch: number): number {
|
|
57
|
+
let wheel = pitch;
|
|
58
|
+
while (wheel < WHEEL_LOW) wheel += 12;
|
|
59
|
+
while (wheel > WHEEL_HIGH) wheel -= 12;
|
|
60
|
+
return wheel;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One wheel at full drawbar (the level trim for every tonewheel preset). */
|
|
64
|
+
const WHEEL_GAIN = 0.11;
|
|
65
|
+
/** One full pipe rank (scaled by the registration's power). */
|
|
66
|
+
const PIPE_GAIN = 0.1;
|
|
67
|
+
|
|
68
|
+
/** Drawbar level: each step below 8 is 3 dB down; 0 is silent. */
|
|
69
|
+
function drawbarGain(digit: number): number {
|
|
70
|
+
return digit <= 0 ? 0 : 10 ** ((-(8 - digit) * 3) / 20);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function digitsOf(text: unknown, count: number, fallback: string): number[] {
|
|
74
|
+
const value = typeof text === "string" ? text : fallback;
|
|
75
|
+
return value
|
|
76
|
+
.padEnd(count, "0")
|
|
77
|
+
.slice(0, count)
|
|
78
|
+
.split("")
|
|
79
|
+
.map((digit) => clamp(Number(digit) || 0, 0, 8));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One wheel's phase at song time `seconds`, free-running per track. */
|
|
83
|
+
function wheelPhase(
|
|
84
|
+
trackSeed: string,
|
|
85
|
+
wheel: number,
|
|
86
|
+
hz: number,
|
|
87
|
+
seconds: number,
|
|
88
|
+
) {
|
|
89
|
+
const phase0 = seedHash(`${trackSeed}:wheel:${wheel}`) / 4294967296;
|
|
90
|
+
return TAU * ((phase0 + hz * seconds) % 1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What a voice needs about its note. */
|
|
94
|
+
export type OrganNote = Readonly<{
|
|
95
|
+
pitch: number;
|
|
96
|
+
hz: number;
|
|
97
|
+
/** Hz of any key in the track's tuning (unmapped keys give 0). */
|
|
98
|
+
hzOf: (pitch: number) => number;
|
|
99
|
+
/** Song seconds at the note's first sample (wheels are phase-locked). */
|
|
100
|
+
startSec: number;
|
|
101
|
+
trackSeed: string;
|
|
102
|
+
noteSeed: string;
|
|
103
|
+
/**
|
|
104
|
+
* The track's shared percussion envelope at this key's onset, 0..1: 1 when
|
|
105
|
+
* the key (or a chord struck with it) retriggers it, the decayed level for
|
|
106
|
+
* a key added while others are held, 0 for none (single-trigger).
|
|
107
|
+
*/
|
|
108
|
+
perc: number;
|
|
109
|
+
/**
|
|
110
|
+
* The key that sounds `semis` 12-TET semitones above `pitch` in the
|
|
111
|
+
* track's tuning (the nearest degree in a table that is not 12 steps per
|
|
112
|
+
* octave); absent adds the semitones as keys.
|
|
113
|
+
*/
|
|
114
|
+
keyAt?: (pitch: number, semis: number) => number;
|
|
115
|
+
}>;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Footage intervals for a tuning: 12-step tables (and keymapped ones) add
|
|
119
|
+
* semitones as keys so temperaments still bend the organ; any other table
|
|
120
|
+
* takes the degree nearest the 12-TET interval, so octave footages land on
|
|
121
|
+
* the table's octave and mutations on its nearest fifth or third.
|
|
122
|
+
*/
|
|
123
|
+
export function footageKeys(
|
|
124
|
+
table: TuningTable | undefined,
|
|
125
|
+
): ((pitch: number, semis: number) => number) | undefined {
|
|
126
|
+
if (!table || !table.linear || table.size === 12) return undefined;
|
|
127
|
+
const step = table.period / table.size;
|
|
128
|
+
return (pitch, semis) => {
|
|
129
|
+
const base = table.hz[pitch] ?? 0;
|
|
130
|
+
if (!(base > 0)) return pitch + semis;
|
|
131
|
+
const guess = pitch + Math.round((semis * 100) / step);
|
|
132
|
+
let best = guess;
|
|
133
|
+
let error = Infinity;
|
|
134
|
+
for (let key = guess - 3; key <= guess + 3; key += 1) {
|
|
135
|
+
const hz = table.hz[key];
|
|
136
|
+
if (!hz || !(hz > 0)) continue;
|
|
137
|
+
const miss = Math.abs(1200 * Math.log2(hz / base) - semis * 100);
|
|
138
|
+
if (miss < error) {
|
|
139
|
+
error = miss;
|
|
140
|
+
best = key;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return best;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Lowest and highest wheel in Hz (C1, F#8) for folding non-12 tables. */
|
|
148
|
+
const WHEEL_LOW_HZ = 440 * 2 ** ((24 - 69) / 12);
|
|
149
|
+
const WHEEL_HIGH_HZ = 440 * 2 ** ((114 - 69) / 12);
|
|
150
|
+
|
|
151
|
+
/** The wheel a footage key sounds in the note's tuning (folded). */
|
|
152
|
+
function foldedWheel(note: OrganNote, key: number): number {
|
|
153
|
+
if (!note.keyAt) return wheelPitch(key);
|
|
154
|
+
let wheel = key;
|
|
155
|
+
for (let guard = 0; guard < 12; guard += 1) {
|
|
156
|
+
const hz = note.hzOf(wheel);
|
|
157
|
+
if (!(hz > 0)) break;
|
|
158
|
+
if (hz < WHEEL_LOW_HZ * 0.999) wheel = note.keyAt(wheel, 12);
|
|
159
|
+
else if (hz > WHEEL_HIGH_HZ * 1.001) wheel = note.keyAt(wheel, -12);
|
|
160
|
+
else break;
|
|
161
|
+
}
|
|
162
|
+
return wheel;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function footage(note: OrganNote, semis: number): number {
|
|
166
|
+
return note.keyAt ? note.keyAt(note.pitch, semis) : note.pitch + semis;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type Values = Readonly<Record<string, number | string>>;
|
|
170
|
+
|
|
171
|
+
const num = (values: Values, name: string, fallback = 0): number => {
|
|
172
|
+
const value = values[name];
|
|
173
|
+
return typeof value === "number" ? value : fallback;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const str = (values: Values, name: string, fallback: string): string => {
|
|
177
|
+
const value = values[name];
|
|
178
|
+
return typeof value === "string" ? value : fallback;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** An organ voice: mono samples added into `out`. */
|
|
182
|
+
export interface OrganVoice {
|
|
183
|
+
readonly released: boolean;
|
|
184
|
+
noteOff(): void;
|
|
185
|
+
setCents(cents: number): void;
|
|
186
|
+
/** Adds `count` samples at `offset`; false once silent for good. */
|
|
187
|
+
process(out: Float64Array, offset: number, count: number): boolean;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Tonewheel
|
|
192
|
+
|
|
193
|
+
export class TonewheelVoice implements OrganVoice {
|
|
194
|
+
private readonly c = new Float64Array(9);
|
|
195
|
+
private readonly s = new Float64Array(9);
|
|
196
|
+
private readonly rc = new Float64Array(9);
|
|
197
|
+
private readonly rs = new Float64Array(9);
|
|
198
|
+
private readonly baseW = new Float64Array(9);
|
|
199
|
+
private readonly amp = new Float64Array(9);
|
|
200
|
+
private readonly on = new Int32Array(9);
|
|
201
|
+
private off: Int32Array | undefined;
|
|
202
|
+
private n = 0;
|
|
203
|
+
private readonly bursts: Burst[] = [];
|
|
204
|
+
private perc = 0;
|
|
205
|
+
private readonly percK: number;
|
|
206
|
+
private percC = 0;
|
|
207
|
+
private percS = 0;
|
|
208
|
+
private percRc = 1;
|
|
209
|
+
private percRs = 0;
|
|
210
|
+
private percW = 0;
|
|
211
|
+
private fade = 1;
|
|
212
|
+
private fadeStep = 0;
|
|
213
|
+
private gateOpen = true;
|
|
214
|
+
private readonly click: number;
|
|
215
|
+
/** The wheel pitches of the nine drawbars (for tests and the docs). */
|
|
216
|
+
readonly wheels: readonly number[];
|
|
217
|
+
/** The wheel frequencies of the nine drawbars, in Hz. */
|
|
218
|
+
readonly wheelHz: readonly number[];
|
|
219
|
+
|
|
220
|
+
constructor(
|
|
221
|
+
private readonly note: OrganNote,
|
|
222
|
+
values: Values,
|
|
223
|
+
private readonly sr: number,
|
|
224
|
+
) {
|
|
225
|
+
const ratio = note.hz / (note.hzOf(note.pitch) || note.hz);
|
|
226
|
+
const levels = digitsOf(values.drawbars, 9, "888000000");
|
|
227
|
+
const perc = str(values, "perc", "off");
|
|
228
|
+
const percOn = perc !== "off";
|
|
229
|
+
const percSoft = str(values, "percvol", "normal") === "soft";
|
|
230
|
+
// A B3 mutes the 1' with percussion on and, at normal percussion
|
|
231
|
+
// volume, drops the drawbars 3 dB; soft keeps them at full level.
|
|
232
|
+
const percDrop = percOn && !percSoft ? 0.7 : 1;
|
|
233
|
+
if (percOn) levels[8] = 0;
|
|
234
|
+
this.click = clamp(num(values, "click", 0.5), 0, 1);
|
|
235
|
+
const rng = seededRandom(`${note.noteSeed}:contacts`);
|
|
236
|
+
const wheels: number[] = [];
|
|
237
|
+
const wheelHz: number[] = [];
|
|
238
|
+
for (let d = 0; d < 9; d += 1) {
|
|
239
|
+
const wheel = foldedWheel(note, footage(note, DRAWBAR_SEMITONES[d]!));
|
|
240
|
+
const hz = note.hzOf(wheel) * ratio;
|
|
241
|
+
wheels.push(wheel);
|
|
242
|
+
wheelHz.push(hz);
|
|
243
|
+
const w = (TAU * hz) / sr;
|
|
244
|
+
const ph = wheelPhase(note.trackSeed, wheel, hz, note.startSec);
|
|
245
|
+
this.c[d] = Math.cos(ph);
|
|
246
|
+
this.s[d] = Math.sin(ph);
|
|
247
|
+
this.baseW[d] = w;
|
|
248
|
+
this.rc[d] = Math.cos(w);
|
|
249
|
+
this.rs[d] = Math.sin(w);
|
|
250
|
+
this.amp[d] =
|
|
251
|
+
hz > 0 && hz < 0.45 * sr
|
|
252
|
+
? WHEEL_GAIN * percDrop * drawbarGain(levels[d]!)
|
|
253
|
+
: 0;
|
|
254
|
+
// Nine bus contacts close a few ms apart (the click's character).
|
|
255
|
+
const draw = rng();
|
|
256
|
+
this.on[d] = this.click > 0 ? Math.floor(draw * 0.003 * sr) : 0;
|
|
257
|
+
}
|
|
258
|
+
this.wheels = Object.freeze(wheels);
|
|
259
|
+
this.wheelHz = Object.freeze(wheelHz);
|
|
260
|
+
if (this.click === 0) {
|
|
261
|
+
this.fade = 0;
|
|
262
|
+
this.fadeStep = 1 / (0.004 * sr);
|
|
263
|
+
} else
|
|
264
|
+
this.bursts.push(
|
|
265
|
+
new Burst(
|
|
266
|
+
`${note.noteSeed}:click`,
|
|
267
|
+
3200,
|
|
268
|
+
0.7,
|
|
269
|
+
0.0012,
|
|
270
|
+
this.click * 0.06,
|
|
271
|
+
sr,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
const percFast = resolvedPercFast(values);
|
|
275
|
+
this.percK = Math.exp(-6.907755 / ((percFast ? 0.6 : 1.8) * sr));
|
|
276
|
+
if (percOn && note.perc > 1e-4) {
|
|
277
|
+
const wheel = foldedWheel(note, footage(note, perc === "2nd" ? 12 : 19));
|
|
278
|
+
const hz = note.hzOf(wheel) * ratio;
|
|
279
|
+
if (hz > 0 && hz < 0.45 * sr) {
|
|
280
|
+
const w = (TAU * hz) / sr;
|
|
281
|
+
const ph = wheelPhase(note.trackSeed, wheel, hz, note.startSec);
|
|
282
|
+
this.percC = Math.cos(ph);
|
|
283
|
+
this.percS = Math.sin(ph);
|
|
284
|
+
this.percW = w;
|
|
285
|
+
this.percRc = Math.cos(w);
|
|
286
|
+
this.percRs = Math.sin(w);
|
|
287
|
+
this.perc = WHEEL_GAIN * (percSoft ? 0.5 : 1) * note.perc;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
get released(): boolean {
|
|
293
|
+
return !this.gateOpen;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
noteOff(): void {
|
|
297
|
+
if (!this.gateOpen) return;
|
|
298
|
+
this.gateOpen = false;
|
|
299
|
+
if (this.click === 0) {
|
|
300
|
+
// No contacts to bounce: the wheels stay on and the sum fades out.
|
|
301
|
+
this.fadeStep = -1 / (0.006 * this.sr);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const rng = seededRandom(`${this.note.noteSeed}:open`);
|
|
305
|
+
this.off = new Int32Array(9);
|
|
306
|
+
for (let d = 0; d < 9; d += 1) {
|
|
307
|
+
const draw = rng();
|
|
308
|
+
this.off[d] = this.n + Math.floor(draw * 0.0015 * this.sr);
|
|
309
|
+
}
|
|
310
|
+
this.bursts.push(
|
|
311
|
+
new Burst(
|
|
312
|
+
`${this.note.noteSeed}:clickoff`,
|
|
313
|
+
3200,
|
|
314
|
+
0.7,
|
|
315
|
+
0.001,
|
|
316
|
+
this.click * 0.03,
|
|
317
|
+
this.sr,
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
setCents(cents: number): void {
|
|
323
|
+
const r = 2 ** (cents / 1200);
|
|
324
|
+
for (let d = 0; d < 9; d += 1) {
|
|
325
|
+
this.rc[d] = Math.cos(this.baseW[d]! * r);
|
|
326
|
+
this.rs[d] = Math.sin(this.baseW[d]! * r);
|
|
327
|
+
}
|
|
328
|
+
if (this.percW > 0) {
|
|
329
|
+
this.percRc = Math.cos(this.percW * r);
|
|
330
|
+
this.percRs = Math.sin(this.percW * r);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
process(out: Float64Array, offset: number, count: number): boolean {
|
|
335
|
+
const { c, s, rc, rs, amp, on } = this;
|
|
336
|
+
const off = this.off;
|
|
337
|
+
for (let i = 0; i < count; i += 1) {
|
|
338
|
+
const t = this.n++;
|
|
339
|
+
let y = 0;
|
|
340
|
+
for (let d = 0; d < 9; d += 1) {
|
|
341
|
+
if (amp[d] === 0) continue;
|
|
342
|
+
const cc = c[d]!;
|
|
343
|
+
const ss = s[d]!;
|
|
344
|
+
c[d] = cc * rc[d]! - ss * rs[d]!;
|
|
345
|
+
s[d] = ss * rc[d]! + cc * rs[d]!;
|
|
346
|
+
if (t < on[d]! || (off && t >= off[d]!)) continue;
|
|
347
|
+
y += amp[d]! * ss;
|
|
348
|
+
}
|
|
349
|
+
if (this.perc > 1e-6) {
|
|
350
|
+
const cc = this.percC;
|
|
351
|
+
const ss = this.percS;
|
|
352
|
+
this.percC = cc * this.percRc - ss * this.percRs;
|
|
353
|
+
this.percS = ss * this.percRc + cc * this.percRs;
|
|
354
|
+
y += this.perc * ss;
|
|
355
|
+
this.perc *= this.percK;
|
|
356
|
+
}
|
|
357
|
+
if (this.fadeStep !== 0) {
|
|
358
|
+
this.fade = clamp(this.fade + this.fadeStep, 0, 1);
|
|
359
|
+
y *= this.fade;
|
|
360
|
+
}
|
|
361
|
+
for (const burst of this.bursts) if (!burst.done) y += burst.next();
|
|
362
|
+
out[offset + i]! += y;
|
|
363
|
+
}
|
|
364
|
+
// Renormalise the rotations so the wheels never drift in level.
|
|
365
|
+
if ((this.n & 511) < count)
|
|
366
|
+
for (let d = 0; d < 9; d += 1) {
|
|
367
|
+
const m = Math.hypot(c[d]!, s[d]!) || 1;
|
|
368
|
+
c[d]! /= m;
|
|
369
|
+
s[d]! /= m;
|
|
370
|
+
}
|
|
371
|
+
if (this.gateOpen) return true;
|
|
372
|
+
if (this.bursts.some((burst) => !burst.done)) return true;
|
|
373
|
+
if (this.fadeStep < 0) return this.fade > 0;
|
|
374
|
+
let last = 0;
|
|
375
|
+
for (let d = 0; d < 9; d += 1) last = Math.max(last, off![d]!);
|
|
376
|
+
return this.n <= last;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Combo (divider organ: Farfisa, Vox)
|
|
382
|
+
|
|
383
|
+
const COMBO_RATIOS = Object.freeze([0.5, 1, 2, 3, 4]);
|
|
384
|
+
/** One full combo register (the level trim shared with the other organs). */
|
|
385
|
+
const COMBO_GAIN = 0.046;
|
|
386
|
+
|
|
387
|
+
export class ComboVoice implements OrganVoice {
|
|
388
|
+
private readonly ph = new Float64Array(5);
|
|
389
|
+
private readonly dts = new Float64Array(5);
|
|
390
|
+
private readonly lv: number[];
|
|
391
|
+
private readonly f = new Biquad2();
|
|
392
|
+
private readonly f2 = new Biquad2();
|
|
393
|
+
private readonly tone: string;
|
|
394
|
+
private readonly vib: number;
|
|
395
|
+
private readonly vibmod: number;
|
|
396
|
+
private env = 0;
|
|
397
|
+
private gate = true;
|
|
398
|
+
private n = 0;
|
|
399
|
+
private cents = 0;
|
|
400
|
+
private ratio = 1;
|
|
401
|
+
|
|
402
|
+
constructor(
|
|
403
|
+
private readonly note: OrganNote,
|
|
404
|
+
values: Values,
|
|
405
|
+
private readonly sr: number,
|
|
406
|
+
) {
|
|
407
|
+
this.lv = digitsOf(values.registers, 5, "08800").map((digit) =>
|
|
408
|
+
digit === 0 ? 0 : COMBO_GAIN * drawbarGain(digit),
|
|
409
|
+
);
|
|
410
|
+
this.tone = str(values, "voice", "reed");
|
|
411
|
+
this.vib = num(values, "vib", 0);
|
|
412
|
+
this.vibmod = num(values, "vibmod", 0);
|
|
413
|
+
// The dividers run all the time: the phase is a function of song time.
|
|
414
|
+
for (let r = 0; r < 5; r += 1)
|
|
415
|
+
this.ph[r] = (note.hz * COMBO_RATIOS[r]! * note.startSec) % 1;
|
|
416
|
+
const f = note.hz;
|
|
417
|
+
if (this.tone === "flute")
|
|
418
|
+
this.f.set("lpf", clamp(3 * f, 300, 7000), 0.7, 0, sr);
|
|
419
|
+
else if (this.tone === "reed") this.f.set("peak", 1400, 1.1, 6, sr);
|
|
420
|
+
else this.f.set("hpf", 300, 0.7, 0, sr);
|
|
421
|
+
this.f2.set("lpf", this.tone === "bright" ? 9000 : 5000, 0.7, 0, sr);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
get released(): boolean {
|
|
425
|
+
return !this.gate;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
noteOff(): void {
|
|
429
|
+
this.gate = false;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
setCents(cents: number): void {
|
|
433
|
+
this.cents = cents;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
process(out: Float64Array, offset: number, count: number): boolean {
|
|
437
|
+
const sr = this.sr;
|
|
438
|
+
const attack = 0.02 * (48_000 / sr);
|
|
439
|
+
const release = 0.004 * (48_000 / sr);
|
|
440
|
+
// Hoisted out of the sample loop (same arithmetic, so the same output).
|
|
441
|
+
const hz = this.note.hz;
|
|
442
|
+
const bright = this.tone === "bright";
|
|
443
|
+
const width = this.tone === "reed" ? 0.3 : 0.5;
|
|
444
|
+
const { ph, lv, dts } = this;
|
|
445
|
+
const setDts = () => {
|
|
446
|
+
for (let r = 0; r < 5; r += 1)
|
|
447
|
+
dts[r] = (hz * COMBO_RATIOS[r]! * this.ratio) / sr;
|
|
448
|
+
};
|
|
449
|
+
setDts();
|
|
450
|
+
for (let i = 0; i < count; i += 1) {
|
|
451
|
+
if ((this.n & (CONTROL - 1)) === 0) {
|
|
452
|
+
const t = this.note.startSec + this.n / sr;
|
|
453
|
+
const vib =
|
|
454
|
+
this.vib > 0 ? this.vibmod * 100 * Math.sin(TAU * this.vib * t) : 0;
|
|
455
|
+
this.ratio = 2 ** ((this.cents + vib) / 1200);
|
|
456
|
+
setDts();
|
|
457
|
+
}
|
|
458
|
+
this.n += 1;
|
|
459
|
+
let y = 0;
|
|
460
|
+
for (let r = 0; r < 5; r += 1) {
|
|
461
|
+
const dt = dts[r]!;
|
|
462
|
+
if (!(dt > 0) || dt >= 0.45) continue;
|
|
463
|
+
let t = ph[r]! + dt;
|
|
464
|
+
if (t >= 1) t -= 1;
|
|
465
|
+
ph[r] = t;
|
|
466
|
+
const level = lv[r]!;
|
|
467
|
+
if (level === 0) continue;
|
|
468
|
+
y +=
|
|
469
|
+
level *
|
|
470
|
+
(bright ? 2 * t - 1 - polyBlep(t, dt) : blepPulse(t, dt, width));
|
|
471
|
+
}
|
|
472
|
+
this.env +=
|
|
473
|
+
((this.gate ? 1 : 0) - this.env) * (this.gate ? attack : release);
|
|
474
|
+
y = this.f2.process(this.f.process(y)) * this.env;
|
|
475
|
+
out[offset + i]! += y;
|
|
476
|
+
}
|
|
477
|
+
return this.gate || this.env > 1e-5;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** A band-limited pulse of `width` (0..1) at phase `t` (cycles). */
|
|
482
|
+
function blepPulse(t: number, dt: number, width: number): number {
|
|
483
|
+
let y = t < width ? 1 : -1;
|
|
484
|
+
y += polyBlep(t, dt);
|
|
485
|
+
let t2 = t - width;
|
|
486
|
+
if (t2 < 0) t2 += 1;
|
|
487
|
+
y -= polyBlep(t2, dt);
|
|
488
|
+
return y - (2 * width - 1);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Pipe
|
|
493
|
+
|
|
494
|
+
type Spectrum =
|
|
495
|
+
| "principal"
|
|
496
|
+
| "flute"
|
|
497
|
+
| "stopped"
|
|
498
|
+
| "string"
|
|
499
|
+
| "trumpet"
|
|
500
|
+
| "oboe"
|
|
501
|
+
| "krummhorn";
|
|
502
|
+
|
|
503
|
+
const SPECTRA: Readonly<Record<Spectrum, (n: number) => number>> = {
|
|
504
|
+
principal: (n) => (n === 2 ? 0.55 : 1 / n ** 1.15),
|
|
505
|
+
flute: (n) => [1, 0.3, 0.1, 0.04][n - 1] ?? 0.01 / n,
|
|
506
|
+
stopped: (n) => (n % 2 ? 1 / n ** 1.6 : 0.03 / n),
|
|
507
|
+
string: (n) => 0.8 / n ** 0.7,
|
|
508
|
+
trumpet: (n) => (n <= 5 ? 0.5 + 0.12 * n : 1.1 / (n / 5) ** 1.1),
|
|
509
|
+
oboe: (n) => Math.exp(-(((n - 4) / 3) ** 2)) + 0.25 / n,
|
|
510
|
+
krummhorn: (n) => (n % 2 ? 0.9 / n ** 0.6 : 0.08 / n),
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const SPECTRUM_MAX: Readonly<Record<Spectrum, number>> = {
|
|
514
|
+
principal: 24,
|
|
515
|
+
flute: 8,
|
|
516
|
+
stopped: 12,
|
|
517
|
+
string: 40,
|
|
518
|
+
trumpet: 40,
|
|
519
|
+
oboe: 24,
|
|
520
|
+
krummhorn: 30,
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
type Stop = Readonly<{
|
|
524
|
+
spectrum: Spectrum;
|
|
525
|
+
semis: number;
|
|
526
|
+
gain: number;
|
|
527
|
+
/** Cents sharp (the celeste rank's beat). */
|
|
528
|
+
celeste?: number;
|
|
529
|
+
/** A flue pipe (chiff); reeds speak without it. */
|
|
530
|
+
flue: boolean;
|
|
531
|
+
}>;
|
|
532
|
+
|
|
533
|
+
const flue = (spectrum: Spectrum, semis: number, gain: number): Stop => ({
|
|
534
|
+
spectrum,
|
|
535
|
+
semis,
|
|
536
|
+
gain,
|
|
537
|
+
flue: true,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
/** The ranks each stop sounds (core/keys.ts PIPE_STOPS names them). */
|
|
541
|
+
export const STOPS: Readonly<Record<string, readonly Stop[]>> = Object.freeze({
|
|
542
|
+
subbass16: [flue("stopped", -12, 1)],
|
|
543
|
+
bourdon16: [flue("stopped", -12, 0.8)],
|
|
544
|
+
principal8: [flue("principal", 0, 1)],
|
|
545
|
+
flute8: [flue("flute", 0, 0.9)],
|
|
546
|
+
gedackt8: [flue("stopped", 0, 0.9)],
|
|
547
|
+
gamba8: [flue("string", 0, 0.5)],
|
|
548
|
+
celeste8: [{ ...flue("string", 0, 0.5), celeste: 7 }],
|
|
549
|
+
octave4: [flue("principal", 12, 0.8)],
|
|
550
|
+
flute4: [flue("flute", 12, 0.7)],
|
|
551
|
+
nazard: [flue("flute", 19, 0.45)],
|
|
552
|
+
fifteenth2: [flue("principal", 24, 0.6)],
|
|
553
|
+
piccolo2: [flue("flute", 24, 0.5)],
|
|
554
|
+
tierce: [flue("flute", 28, 0.4)],
|
|
555
|
+
larigot: [flue("flute", 31, 0.35)],
|
|
556
|
+
mixture: [19, 24, 31, 36].map((semis) => flue("principal", semis, 0.35)),
|
|
557
|
+
trumpet8: [{ spectrum: "trumpet", semis: 0, gain: 0.7, flue: false }],
|
|
558
|
+
oboe8: [{ spectrum: "oboe", semis: 0, gain: 0.6, flue: false }],
|
|
559
|
+
krummhorn8: [{ spectrum: "krummhorn", semis: 0, gain: 0.6, flue: false }],
|
|
560
|
+
trombone16: [{ spectrum: "trumpet", semis: -12, gain: 0.8, flue: false }],
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
/** Highest key a rank sounds before mixtures and mutations break back. */
|
|
564
|
+
const PIPE_TOP = 103;
|
|
565
|
+
const PIPE_TOP_HZ = 440 * 2 ** ((PIPE_TOP - 69) / 12);
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* A rank's pitch as a pure harmonic of the key: octaves 2^k, quints 3·2^k,
|
|
569
|
+
* tierces 5·2^k, so mutation and mixture ranks fuse with the foundation
|
|
570
|
+
* instead of beating against it (in any tuning).
|
|
571
|
+
*/
|
|
572
|
+
export function rankRatio(semis: number): number {
|
|
573
|
+
const octave = Math.floor(semis / 12);
|
|
574
|
+
const within = semis - octave * 12;
|
|
575
|
+
const pure = within === 7 ? 1.5 : within === 4 ? 1.25 : 2 ** (within / 12);
|
|
576
|
+
return 2 ** octave * pure;
|
|
577
|
+
}
|
|
578
|
+
const TABLE = 2048;
|
|
579
|
+
const tables = new Map<string, Float64Array>();
|
|
580
|
+
|
|
581
|
+
/** A band-limited single-cycle table of `harmonics` partials (cached). */
|
|
582
|
+
function table(spectrum: Spectrum, harmonics: number): Float64Array {
|
|
583
|
+
const key = `${spectrum}:${harmonics}`;
|
|
584
|
+
const cached = tables.get(key);
|
|
585
|
+
if (cached) return cached;
|
|
586
|
+
const out = new Float64Array(TABLE + 1);
|
|
587
|
+
let peak = 0;
|
|
588
|
+
for (let i = 0; i < TABLE; i += 1) {
|
|
589
|
+
let y = 0;
|
|
590
|
+
for (let n = 1; n <= harmonics; n += 1)
|
|
591
|
+
y += SPECTRA[spectrum](n) * Math.sin((TAU * n * i) / TABLE);
|
|
592
|
+
out[i] = y;
|
|
593
|
+
peak = Math.max(peak, Math.abs(y));
|
|
594
|
+
}
|
|
595
|
+
let power = 0;
|
|
596
|
+
for (let i = 0; i < TABLE; i += 1) {
|
|
597
|
+
out[i]! /= peak || 1;
|
|
598
|
+
power += out[i]! ** 2;
|
|
599
|
+
}
|
|
600
|
+
// Even loudness across spectra: a thin string table (high crest) gets
|
|
601
|
+
// up to 2x so a gamba registration is not buried under the flutes.
|
|
602
|
+
const loud = Math.min(2, Math.max(1, 0.6 / Math.sqrt(power / TABLE)));
|
|
603
|
+
for (let i = 0; i < TABLE; i += 1) out[i]! *= loud;
|
|
604
|
+
out[TABLE] = out[0]!;
|
|
605
|
+
tables.set(key, out);
|
|
606
|
+
return out;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
type Rank = {
|
|
610
|
+
t: Float64Array;
|
|
611
|
+
ph: number;
|
|
612
|
+
hz: number;
|
|
613
|
+
gain: number;
|
|
614
|
+
ta: number;
|
|
615
|
+
wob: number;
|
|
616
|
+
wobHz: number;
|
|
617
|
+
inc: number;
|
|
618
|
+
/** The wind's level wobble (control rate). */
|
|
619
|
+
level: number;
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
export class PipeVoice implements OrganVoice {
|
|
623
|
+
/** The ranks this key sounds (for tests). */
|
|
624
|
+
readonly ranks: Rank[] = [];
|
|
625
|
+
private n = 0;
|
|
626
|
+
private gate = true;
|
|
627
|
+
private relEnv = 1;
|
|
628
|
+
private readonly relK: number;
|
|
629
|
+
private readonly bursts: Burst[] = [];
|
|
630
|
+
private cents = 0;
|
|
631
|
+
private amp = 1;
|
|
632
|
+
private readonly trem: number;
|
|
633
|
+
private readonly wind: number;
|
|
634
|
+
|
|
635
|
+
constructor(
|
|
636
|
+
private readonly note: OrganNote,
|
|
637
|
+
values: Values,
|
|
638
|
+
private readonly sr: number,
|
|
639
|
+
) {
|
|
640
|
+
const ratio = note.hz / (note.hzOf(note.pitch) || note.hz);
|
|
641
|
+
const keyPos = clamp((note.pitch - 24) / 84, 0, 1);
|
|
642
|
+
this.trem = clamp(num(values, "trem"), 0, 1);
|
|
643
|
+
this.wind = clamp(num(values, "wind", 0.3), 0, 1);
|
|
644
|
+
const chiff = clamp(num(values, "chiff", 0.4), 0, 1);
|
|
645
|
+
let flueGain = 0;
|
|
646
|
+
let power = 0;
|
|
647
|
+
// One voicing error per key (every rank of a key shares it, so their
|
|
648
|
+
// partials coincide); only the celeste rank is tuned apart.
|
|
649
|
+
const key = seededRandom(`${note.trackSeed}:pipe:${note.pitch}`);
|
|
650
|
+
const shared = (key() - 0.5) * 2;
|
|
651
|
+
const f1 = note.hzOf(note.pitch);
|
|
652
|
+
for (const id of pipeStops(str(values, "stops", "principal8"))) {
|
|
653
|
+
for (const stop of STOPS[id] ?? []) {
|
|
654
|
+
let semis = stop.semis;
|
|
655
|
+
while (f1 * rankRatio(semis) > PIPE_TOP_HZ && semis > 0) semis -= 12;
|
|
656
|
+
const pipe = seededRandom(
|
|
657
|
+
`${note.trackSeed}:pipe:${id}:${semis}:${note.pitch}`,
|
|
658
|
+
);
|
|
659
|
+
const detune = shared + (stop.celeste ?? 0);
|
|
660
|
+
const hz = f1 * rankRatio(semis) * ratio * 2 ** (detune / 1200);
|
|
661
|
+
// Phase on the song clock, like the wheels: an arranged window that
|
|
662
|
+
// re-strikes a held key there lands on the same waveform.
|
|
663
|
+
const ph = (pipe() + ((hz * note.startSec) % 1)) % 1;
|
|
664
|
+
const wob = pipe() * TAU;
|
|
665
|
+
const wobHz = 0.2 + 0.3 * pipe();
|
|
666
|
+
if (!(hz > 0) || hz >= 0.45 * sr) continue;
|
|
667
|
+
const h = Math.max(
|
|
668
|
+
1,
|
|
669
|
+
Math.min(SPECTRUM_MAX[stop.spectrum], Math.floor((0.45 * sr) / hz)),
|
|
670
|
+
);
|
|
671
|
+
const ta =
|
|
672
|
+
(stop.flue ? 0.02 + 0.06 * (1 - keyPos) : 0.012) *
|
|
673
|
+
(semis < 0 ? 1.5 : 1);
|
|
674
|
+
this.ranks.push({
|
|
675
|
+
t: table(stop.spectrum, h),
|
|
676
|
+
ph,
|
|
677
|
+
hz,
|
|
678
|
+
gain: stop.gain,
|
|
679
|
+
ta,
|
|
680
|
+
wob,
|
|
681
|
+
wobHz,
|
|
682
|
+
inc: hz / sr,
|
|
683
|
+
level: 1,
|
|
684
|
+
});
|
|
685
|
+
if (stop.flue) flueGain += stop.gain;
|
|
686
|
+
power += stop.gain * stop.gain;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// Registrations sum to a similar loudness: scale by the ranks' summed
|
|
690
|
+
// power (adding stops still adds level, by about half its dB).
|
|
691
|
+
const norm = PIPE_GAIN / Math.max(0.25, power) ** 0.35;
|
|
692
|
+
for (const rank of this.ranks) rank.gain *= norm;
|
|
693
|
+
if (chiff > 0 && flueGain > 0)
|
|
694
|
+
this.bursts.push(
|
|
695
|
+
new Burst(
|
|
696
|
+
`${note.noteSeed}:chiff`,
|
|
697
|
+
Math.min(0.4 * sr, note.hz * 4),
|
|
698
|
+
3,
|
|
699
|
+
0.025,
|
|
700
|
+
chiff * 0.05 * Math.min(2, flueGain),
|
|
701
|
+
sr,
|
|
702
|
+
0.002,
|
|
703
|
+
),
|
|
704
|
+
);
|
|
705
|
+
this.relK = Math.exp(-1 / (0.035 * sr));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
get released(): boolean {
|
|
709
|
+
return !this.gate;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
noteOff(): void {
|
|
713
|
+
this.gate = false;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
setCents(cents: number): void {
|
|
717
|
+
this.cents = cents;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
process(out: Float64Array, offset: number, count: number): boolean {
|
|
721
|
+
const sr = this.sr;
|
|
722
|
+
for (let i = 0; i < count; i += 1) {
|
|
723
|
+
const t = this.n / sr;
|
|
724
|
+
if ((this.n & (CONTROL - 1)) === 0 || i === 0) {
|
|
725
|
+
const abs = this.note.startSec + t;
|
|
726
|
+
const trem = this.trem > 0 ? Math.sin(TAU * 5.6 * abs) : 0;
|
|
727
|
+
for (const rank of this.ranks) {
|
|
728
|
+
const wind =
|
|
729
|
+
this.wind * 0.6 * Math.sin(rank.wob + TAU * rank.wobHz * abs);
|
|
730
|
+
rank.inc =
|
|
731
|
+
(rank.hz *
|
|
732
|
+
2 ** ((this.cents + wind + this.trem * 4 * trem) / 1200)) /
|
|
733
|
+
sr;
|
|
734
|
+
}
|
|
735
|
+
this.amp = 1 + this.trem * 0.12 * trem;
|
|
736
|
+
for (const rank of this.ranks)
|
|
737
|
+
rank.level =
|
|
738
|
+
1 + this.wind * 0.03 * Math.sin(rank.wob + TAU * rank.wobHz * abs);
|
|
739
|
+
}
|
|
740
|
+
let y = 0;
|
|
741
|
+
for (const rank of this.ranks) {
|
|
742
|
+
rank.ph += rank.inc;
|
|
743
|
+
if (rank.ph >= 1) rank.ph -= Math.floor(rank.ph);
|
|
744
|
+
const x = rank.ph * TABLE;
|
|
745
|
+
const j = Math.floor(x);
|
|
746
|
+
const s = rank.t[j]! + (rank.t[j + 1]! - rank.t[j]!) * (x - j);
|
|
747
|
+
// Past 40 time constants 1 - exp(-t/ta) rounds to exactly 1.
|
|
748
|
+
const speak = t > 40 * rank.ta ? 1 : 1 - Math.exp(-t / rank.ta);
|
|
749
|
+
y += s * rank.gain * rank.level * speak;
|
|
750
|
+
}
|
|
751
|
+
if (!this.gate) this.relEnv *= this.relK;
|
|
752
|
+
y *= this.amp * this.relEnv;
|
|
753
|
+
for (const burst of this.bursts) if (!burst.done) y += burst.next();
|
|
754
|
+
out[offset + i]! += y;
|
|
755
|
+
this.n += 1;
|
|
756
|
+
}
|
|
757
|
+
return this.gate || this.relEnv > 1e-5;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ---------------------------------------------------------------------------
|
|
762
|
+
// OrganPost: scanner, drive, rotary on the track sum
|
|
763
|
+
|
|
764
|
+
/** Rotor speeds in Hz at stop, slow (chorale) and fast (tremolo). */
|
|
765
|
+
export const HORN_HZ = Object.freeze([0, 0.8, 6.8]);
|
|
766
|
+
export const DRUM_HZ = Object.freeze([0, 0.66, 5.9]);
|
|
767
|
+
|
|
768
|
+
const SCANNER_DEPTH: Readonly<Record<string, number>> = {
|
|
769
|
+
off: 0,
|
|
770
|
+
v1: 0.25,
|
|
771
|
+
v2: 0.5,
|
|
772
|
+
v3: 0.9,
|
|
773
|
+
c1: 0.25,
|
|
774
|
+
c2: 0.5,
|
|
775
|
+
c3: 0.9,
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
function readHermite(
|
|
779
|
+
buffer: Float64Array,
|
|
780
|
+
write: number,
|
|
781
|
+
delay: number,
|
|
782
|
+
): number {
|
|
783
|
+
const size = buffer.length;
|
|
784
|
+
const pos = write - delay;
|
|
785
|
+
const i = Math.floor(pos);
|
|
786
|
+
const f = pos - i;
|
|
787
|
+
const at = (j: number) => buffer[((j % size) + size) % size]!;
|
|
788
|
+
const xm1 = at(i - 1);
|
|
789
|
+
const x0 = at(i);
|
|
790
|
+
const x1 = at(i + 1);
|
|
791
|
+
const x2 = at(i + 2);
|
|
792
|
+
const c1 = 0.5 * (x1 - xm1);
|
|
793
|
+
const c2 = xm1 - 2.5 * x0 + 2 * x1 - 0.5 * x2;
|
|
794
|
+
const c3 = 0.5 * (x2 - xm1) + 1.5 * (x0 - x1);
|
|
795
|
+
return ((c3 * f + c2) * f + c1) * f + x0;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Makeup for the preamp at `k`: the RMS a -18 dBFS sine keeps through
|
|
800
|
+
* tanh(k·x)/k, so drive changes the timbre at a steady loudness.
|
|
801
|
+
*/
|
|
802
|
+
const makeups = new Map<number, number>();
|
|
803
|
+
function driveMakeup(k: number): number {
|
|
804
|
+
const key = Math.round(k * 1000);
|
|
805
|
+
const cached = makeups.get(key);
|
|
806
|
+
if (cached !== undefined) return cached;
|
|
807
|
+
const a = 10 ** (-18 / 20) * Math.SQRT2;
|
|
808
|
+
let dry = 0;
|
|
809
|
+
let wet = 0;
|
|
810
|
+
for (let i = 0; i < 256; i += 1) {
|
|
811
|
+
const x = a * Math.sin((TAU * (i + 0.5)) / 256);
|
|
812
|
+
const y = Math.tanh(k * x) / k;
|
|
813
|
+
dry += x * x;
|
|
814
|
+
wet += y * y;
|
|
815
|
+
}
|
|
816
|
+
const makeup = Math.sqrt(dry / wet);
|
|
817
|
+
makeups.set(key, makeup);
|
|
818
|
+
return makeup;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** The rotors' state at a window origin (`organRotorSeed`). */
|
|
822
|
+
export type RotorState = Readonly<{
|
|
823
|
+
horn: number;
|
|
824
|
+
drum: number;
|
|
825
|
+
hornHz: number;
|
|
826
|
+
drumHz: number;
|
|
827
|
+
}>;
|
|
828
|
+
|
|
829
|
+
export type OrganPostSettings = Readonly<{
|
|
830
|
+
scanner: string;
|
|
831
|
+
/** Preamp drive 0..1 (the `keys-drive` lane overrides per block). */
|
|
832
|
+
drive: number;
|
|
833
|
+
/** Rotary speed at the start: 0 stop, 1 slow, 2 fast. */
|
|
834
|
+
rotary: number;
|
|
835
|
+
/** Run the rotary stage (false bypasses it: combo, pipe, or `stop`). */
|
|
836
|
+
rotor: boolean;
|
|
837
|
+
}>;
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Scanner vibrato/chorus, then preamp drive, then a two-rotor rotary
|
|
841
|
+
* speaker with Doppler, amplitude modulation, stereo mics and inertia
|
|
842
|
+
* (horn about 0.7 s up / 1.1 s down, drum 3.5 s / 4.5 s). State carries
|
|
843
|
+
* across `process` calls, so a live player can feed it block by block.
|
|
844
|
+
*/
|
|
845
|
+
export class OrganPost {
|
|
846
|
+
private readonly line: Float64Array;
|
|
847
|
+
private w = 0;
|
|
848
|
+
private readonly hornLine: Float64Array;
|
|
849
|
+
private hw = 0;
|
|
850
|
+
private horn = 0;
|
|
851
|
+
private drum = 0;
|
|
852
|
+
private n = 0;
|
|
853
|
+
private hornTarget: number;
|
|
854
|
+
private drumTarget: number;
|
|
855
|
+
/** Current horn rotor speed in Hz (tests read the spin-up). */
|
|
856
|
+
hornHz: number;
|
|
857
|
+
drumHz: number;
|
|
858
|
+
private readonly lo1: OnePole;
|
|
859
|
+
private readonly lo2: OnePole;
|
|
860
|
+
private prev = 0;
|
|
861
|
+
|
|
862
|
+
constructor(
|
|
863
|
+
private readonly settings: OrganPostSettings,
|
|
864
|
+
private readonly sr: number,
|
|
865
|
+
private readonly startSec = 0,
|
|
866
|
+
seed?: RotorState,
|
|
867
|
+
) {
|
|
868
|
+
this.line = new Float64Array(Math.ceil(0.004 * sr) + 8);
|
|
869
|
+
this.hornLine = new Float64Array(Math.ceil(0.004 * sr) + 8);
|
|
870
|
+
this.lo1 = new OnePole(800, sr);
|
|
871
|
+
this.lo2 = new OnePole(800, sr);
|
|
872
|
+
const speed = clamp(Math.round(settings.rotary), 0, 2);
|
|
873
|
+
this.hornHz = this.hornTarget = HORN_HZ[speed]!;
|
|
874
|
+
this.drumHz = this.drumTarget = DRUM_HZ[speed]!;
|
|
875
|
+
// The rotors run on the song clock: a render starting at `startSec`
|
|
876
|
+
// (an arranged window, a live key) finds them where a whole-song pass
|
|
877
|
+
// at a steady speed would, so live notes share one speaker image.
|
|
878
|
+
this.horn = (TAU * ((this.hornHz * startSec) % 1)) % TAU;
|
|
879
|
+
this.drum = (TAU * ((this.drumHz * startSec) % 1)) % TAU;
|
|
880
|
+
// With a rotary lane the speeds before the window are not constant:
|
|
881
|
+
// the arranged render hands in the rotors integrated from song start.
|
|
882
|
+
if (seed) {
|
|
883
|
+
this.horn = seed.horn;
|
|
884
|
+
this.drum = seed.drum;
|
|
885
|
+
this.hornHz = this.hornTarget = seed.hornHz;
|
|
886
|
+
this.drumHz = this.drumTarget = seed.drumHz;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** The rotors now (an arranged window's seed). */
|
|
891
|
+
rotor(): RotorState {
|
|
892
|
+
return {
|
|
893
|
+
horn: this.horn,
|
|
894
|
+
drum: this.drum,
|
|
895
|
+
hornHz: this.hornHz,
|
|
896
|
+
drumHz: this.drumHz,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Runs only the rotors for `count` samples at control rate, in closed
|
|
902
|
+
* form per block (exactly what `process` does per sample): the cheap path
|
|
903
|
+
* an arranged window uses to find the rotors at its origin.
|
|
904
|
+
*/
|
|
905
|
+
spin(count: number, speedAt: (i: number) => number): void {
|
|
906
|
+
const sr = this.sr;
|
|
907
|
+
const glide = (
|
|
908
|
+
hz: number,
|
|
909
|
+
target: number,
|
|
910
|
+
up: number,
|
|
911
|
+
down: number,
|
|
912
|
+
n: number,
|
|
913
|
+
): [number, number] => {
|
|
914
|
+
if (hz === target) return [hz, (n * hz) / sr];
|
|
915
|
+
const r = 1 - 1 / ((target > hz ? up : down) * sr);
|
|
916
|
+
const rn = r ** n;
|
|
917
|
+
const sum = n * target + ((hz - target) * r * (1 - rn)) / (1 - r);
|
|
918
|
+
return [target + (hz - target) * rn, sum / sr];
|
|
919
|
+
};
|
|
920
|
+
for (let i = 0; i < count; i += CONTROL) {
|
|
921
|
+
const n = Math.min(CONTROL, count - i);
|
|
922
|
+
const speed = clamp(Math.round(speedAt(i)), 0, 2);
|
|
923
|
+
const [hornHz, hornTurns] = glide(
|
|
924
|
+
this.hornHz,
|
|
925
|
+
HORN_HZ[speed]!,
|
|
926
|
+
0.7,
|
|
927
|
+
1.1,
|
|
928
|
+
n,
|
|
929
|
+
);
|
|
930
|
+
const [drumHz, drumTurns] = glide(
|
|
931
|
+
this.drumHz,
|
|
932
|
+
DRUM_HZ[speed]!,
|
|
933
|
+
3.5,
|
|
934
|
+
4.5,
|
|
935
|
+
n,
|
|
936
|
+
);
|
|
937
|
+
this.hornHz = hornHz;
|
|
938
|
+
this.drumHz = drumHz;
|
|
939
|
+
this.horn = (this.horn + TAU * hornTurns) % TAU;
|
|
940
|
+
this.drum = (this.drum + TAU * drumTurns) % TAU;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Processes the mono sum in `left` into `left`/`right`. `speedAt(i)` gives
|
|
946
|
+
* the rotary speed (0..2) at sample `i` of this call, `driveAt(i)` the
|
|
947
|
+
* drive; both are read once per control block.
|
|
948
|
+
*/
|
|
949
|
+
process(
|
|
950
|
+
left: Float64Array,
|
|
951
|
+
right: Float64Array,
|
|
952
|
+
count = left.length,
|
|
953
|
+
speedAt?: (i: number) => number,
|
|
954
|
+
driveAt?: (i: number) => number,
|
|
955
|
+
): void {
|
|
956
|
+
const sr = this.sr;
|
|
957
|
+
const scanner = this.settings.scanner;
|
|
958
|
+
const depth = (SCANNER_DEPTH[scanner] ?? 0) * 1e-3 * sr;
|
|
959
|
+
const chorus = scanner.startsWith("c");
|
|
960
|
+
let drive = this.settings.drive;
|
|
961
|
+
let k = 1 + 6 * drive;
|
|
962
|
+
let tk = driveMakeup(k) / k;
|
|
963
|
+
const base = 0.0012 * sr;
|
|
964
|
+
const dop = 0.00045 * sr;
|
|
965
|
+
for (let i = 0; i < count; i += 1) {
|
|
966
|
+
if (i % CONTROL === 0) {
|
|
967
|
+
if (driveAt) {
|
|
968
|
+
drive = clamp(driveAt(i), 0, 1);
|
|
969
|
+
k = 1 + 6 * drive;
|
|
970
|
+
tk = driveMakeup(k) / k;
|
|
971
|
+
}
|
|
972
|
+
if (speedAt) {
|
|
973
|
+
const speed = clamp(Math.round(speedAt(i)), 0, 2);
|
|
974
|
+
this.hornTarget = HORN_HZ[speed]!;
|
|
975
|
+
this.drumTarget = DRUM_HZ[speed]!;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
let x = left[i]!;
|
|
979
|
+
if (depth > 0) {
|
|
980
|
+
const t = this.startSec + this.n / sr;
|
|
981
|
+
const d = 0.3e-3 * sr + depth * 0.5 * (1 + Math.sin(TAU * 6.9 * t));
|
|
982
|
+
this.line[this.w] = x;
|
|
983
|
+
const vib = readHermite(this.line, this.w, d);
|
|
984
|
+
this.w = (this.w + 1) % this.line.length;
|
|
985
|
+
x = chorus ? 0.5 * (x + vib) : vib;
|
|
986
|
+
}
|
|
987
|
+
this.n += 1;
|
|
988
|
+
if (drive > 0) {
|
|
989
|
+
// 2x oversampled tanh: the midpoint and the sample, averaged.
|
|
990
|
+
const mid = 0.5 * (x + this.prev);
|
|
991
|
+
this.prev = x;
|
|
992
|
+
x = 0.5 * (Math.tanh(k * mid) + Math.tanh(k * x)) * tk;
|
|
993
|
+
} else this.prev = x;
|
|
994
|
+
if (!this.settings.rotor) {
|
|
995
|
+
left[i] = x;
|
|
996
|
+
right[i] = x;
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
const hornTau = this.hornTarget > this.hornHz ? 0.7 : 1.1;
|
|
1000
|
+
const drumTau = this.drumTarget > this.drumHz ? 3.5 : 4.5;
|
|
1001
|
+
this.hornHz += (this.hornTarget - this.hornHz) / (hornTau * sr);
|
|
1002
|
+
this.drumHz += (this.drumTarget - this.drumHz) / (drumTau * sr);
|
|
1003
|
+
this.horn = (this.horn + (TAU * this.hornHz) / sr) % TAU;
|
|
1004
|
+
this.drum = (this.drum + (TAU * this.drumHz) / sr) % TAU;
|
|
1005
|
+
const lo = this.lo2.process(this.lo1.process(x));
|
|
1006
|
+
const hi = x - lo;
|
|
1007
|
+
this.hornLine[this.hw] = hi;
|
|
1008
|
+
const cl = Math.cos(this.horn + Math.PI / 2);
|
|
1009
|
+
const cr = Math.cos(this.horn - Math.PI / 2);
|
|
1010
|
+
const hl =
|
|
1011
|
+
readHermite(this.hornLine, this.hw, base + dop * 0.5 * (1 - cl)) *
|
|
1012
|
+
(0.6 + 0.4 * cl);
|
|
1013
|
+
const hr =
|
|
1014
|
+
readHermite(this.hornLine, this.hw, base + dop * 0.5 * (1 - cr)) *
|
|
1015
|
+
(0.6 + 0.4 * cr);
|
|
1016
|
+
this.hw = (this.hw + 1) % this.hornLine.length;
|
|
1017
|
+
const dl = lo * (0.75 + 0.25 * Math.cos(this.drum + Math.PI / 2));
|
|
1018
|
+
const dr = lo * (0.75 + 0.25 * Math.cos(this.drum - Math.PI / 2));
|
|
1019
|
+
left[i] = hl + dl;
|
|
1020
|
+
right[i] = hr + dr;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** The post settings of an organ track (no lanes). */
|
|
1026
|
+
export function organPostSettings(
|
|
1027
|
+
family: OrganFamily,
|
|
1028
|
+
values: Values,
|
|
1029
|
+
laned = false,
|
|
1030
|
+
): OrganPostSettings {
|
|
1031
|
+
const rotary = family === "pipe" ? 0 : rotarySpeed(values.rotary);
|
|
1032
|
+
return {
|
|
1033
|
+
scanner: family === "tonewheel" ? str(values, "scanner", "c3") : "off",
|
|
1034
|
+
drive: family === "pipe" ? 0 : clamp(num(values, "drive"), 0, 1),
|
|
1035
|
+
rotary,
|
|
1036
|
+
rotor: family !== "pipe" && (rotary > 0 || laned),
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// ---------------------------------------------------------------------------
|
|
1041
|
+
// Track render
|
|
1042
|
+
|
|
1043
|
+
type Live = {
|
|
1044
|
+
voice: OrganVoice;
|
|
1045
|
+
start: number;
|
|
1046
|
+
off: number;
|
|
1047
|
+
order: number;
|
|
1048
|
+
cents?: (seconds: number) => number;
|
|
1049
|
+
stolen: boolean;
|
|
1050
|
+
fade: number;
|
|
1051
|
+
fadeLength: number;
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* A note's first and note-off samples. `origin` is the render's first
|
|
1056
|
+
* sample on the song's own sample grid (an arranged window's exact start):
|
|
1057
|
+
* onsets round on that grid, so a window lands each key on the sample a
|
|
1058
|
+
* single pass does.
|
|
1059
|
+
*/
|
|
1060
|
+
function spanOf(
|
|
1061
|
+
note: PerformedNote,
|
|
1062
|
+
context: EngineContext,
|
|
1063
|
+
origin = 0,
|
|
1064
|
+
): { start: number; off: number } {
|
|
1065
|
+
const { warp, samplesPerTick } = context;
|
|
1066
|
+
const base = Math.round(origin);
|
|
1067
|
+
const at = (tick: number) =>
|
|
1068
|
+
Math.floor((warp ? warp.sample(tick) : tick * samplesPerTick) + origin) -
|
|
1069
|
+
base;
|
|
1070
|
+
if (warp) {
|
|
1071
|
+
const start = Math.max(0, at(note.startTick));
|
|
1072
|
+
const end = at(note.startTick + note.durationTicks);
|
|
1073
|
+
return { start, off: Math.max(start + 1, end) };
|
|
1074
|
+
}
|
|
1075
|
+
const start = Math.max(0, at(note.startTick));
|
|
1076
|
+
const length = Math.max(1, Math.floor(note.durationTicks * samplesPerTick));
|
|
1077
|
+
return { start, off: start + length };
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function tickAt(context: EngineContext, sample: number): number {
|
|
1081
|
+
return context.warp
|
|
1082
|
+
? context.warp.tick(sample)
|
|
1083
|
+
: sample / context.samplesPerTick;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** A voice of `family` for one note. */
|
|
1087
|
+
export function organVoice(
|
|
1088
|
+
family: OrganFamily,
|
|
1089
|
+
note: OrganNote,
|
|
1090
|
+
values: Values,
|
|
1091
|
+
sr: number,
|
|
1092
|
+
): OrganVoice {
|
|
1093
|
+
if (family === "tonewheel") return new TonewheelVoice(note, values, sr);
|
|
1094
|
+
if (family === "combo") return new ComboVoice(note, values, sr);
|
|
1095
|
+
return new PipeVoice(note, values, sr);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Renders an organ track: voices into `left` (mono), then the track's
|
|
1100
|
+
* OrganPost into `left`/`right`, then the track volume.
|
|
1101
|
+
*/
|
|
1102
|
+
export function renderOrganTrack(
|
|
1103
|
+
left: Float64Array,
|
|
1104
|
+
right: Float64Array,
|
|
1105
|
+
notes: readonly PerformedNote[],
|
|
1106
|
+
track: Track,
|
|
1107
|
+
context: EngineContext,
|
|
1108
|
+
): void {
|
|
1109
|
+
const family = track.instrument;
|
|
1110
|
+
if (!isOrganFamily(family)) return;
|
|
1111
|
+
const sr = context.sampleRate;
|
|
1112
|
+
const total = left.length;
|
|
1113
|
+
const values = resolvedKeys(family, track.keys);
|
|
1114
|
+
const trackSeed = `organ:${track.id}`;
|
|
1115
|
+
const seedTick = context.seedTick ?? 0;
|
|
1116
|
+
// Song seconds at this render's first sample: an arranged window's origin
|
|
1117
|
+
// in real time (a tempo map makes it more than ticks at the base tempo).
|
|
1118
|
+
const originSec =
|
|
1119
|
+
context.seedSeconds ?? (seedTick * context.samplesPerTick) / sr;
|
|
1120
|
+
const hzOf = (pitch: number) => noteHz(pitch, undefined, context.tuning);
|
|
1121
|
+
const keyAt = footageKeys(context.tuning);
|
|
1122
|
+
const percTau = (resolvedPercFast(values) ? 0.6 : 1.8) / 6.907755;
|
|
1123
|
+
let percAt = -Infinity;
|
|
1124
|
+
const onsets = notes
|
|
1125
|
+
.map((note) => {
|
|
1126
|
+
const hz = noteHz(note.pitch, note.cents, context.tuning);
|
|
1127
|
+
return { note, hz, ...spanOf(note, context, originSec * sr) };
|
|
1128
|
+
})
|
|
1129
|
+
.filter((x) => x.hz > 0 && Number.isFinite(x.hz) && x.start < total)
|
|
1130
|
+
.sort(
|
|
1131
|
+
(a, b) =>
|
|
1132
|
+
a.start - b.start ||
|
|
1133
|
+
a.note.startTick - b.note.startTick ||
|
|
1134
|
+
(a.note.id < b.note.id ? -1 : a.note.id > b.note.id ? 1 : 0),
|
|
1135
|
+
);
|
|
1136
|
+
const mono = new Float64Array(total);
|
|
1137
|
+
const live: Live[] = [];
|
|
1138
|
+
let next = 0;
|
|
1139
|
+
let order = 0;
|
|
1140
|
+
for (let i = 0; i < total; i += CONTROL) {
|
|
1141
|
+
const count = Math.min(CONTROL, total - i);
|
|
1142
|
+
while (next < onsets.length && onsets[next]!.start < i + count) {
|
|
1143
|
+
const onset = onsets[next]!;
|
|
1144
|
+
next += 1;
|
|
1145
|
+
const playing = live.filter((x) => !x.stolen);
|
|
1146
|
+
if (playing.length >= ORGAN_VOICES) {
|
|
1147
|
+
const released = playing.filter((x) => x.voice.released);
|
|
1148
|
+
const pool = released.length > 0 ? released : playing;
|
|
1149
|
+
let victim = pool[0]!;
|
|
1150
|
+
for (const x of pool) if (x.order < victim.order) victim = x;
|
|
1151
|
+
victim.fadeLength = Math.max(1, Math.round(STEAL_SECONDS * sr));
|
|
1152
|
+
victim.fade = victim.fadeLength;
|
|
1153
|
+
victim.stolen = true;
|
|
1154
|
+
}
|
|
1155
|
+
// Single-trigger percussion is one envelope per track: a key struck
|
|
1156
|
+
// with nothing held fires it; keys that join (a chord, a legato line)
|
|
1157
|
+
// sound it at the level it has decayed to.
|
|
1158
|
+
const held = live.some((x) => !x.voice.released && x.off > onset.start);
|
|
1159
|
+
if (!held) percAt = onset.start;
|
|
1160
|
+
const perc = Math.exp(-(onset.start - percAt) / sr / percTau);
|
|
1161
|
+
const { note } = onset;
|
|
1162
|
+
const voice = organVoice(
|
|
1163
|
+
family,
|
|
1164
|
+
{
|
|
1165
|
+
pitch: note.pitch,
|
|
1166
|
+
hz: onset.hz,
|
|
1167
|
+
hzOf,
|
|
1168
|
+
startSec: originSec + onset.start / sr,
|
|
1169
|
+
trackSeed,
|
|
1170
|
+
noteSeed: `${track.id}:${note.id}:${note.startTick + seedTick}`,
|
|
1171
|
+
perc,
|
|
1172
|
+
...(keyAt ? { keyAt } : {}),
|
|
1173
|
+
},
|
|
1174
|
+
values,
|
|
1175
|
+
sr,
|
|
1176
|
+
);
|
|
1177
|
+
const bend = note.performance?.cents;
|
|
1178
|
+
live.push({
|
|
1179
|
+
voice,
|
|
1180
|
+
start: onset.start,
|
|
1181
|
+
off: onset.off,
|
|
1182
|
+
order: order++,
|
|
1183
|
+
...(bend ? { cents: bend } : {}),
|
|
1184
|
+
stolen: false,
|
|
1185
|
+
fade: 0,
|
|
1186
|
+
fadeLength: 0,
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
for (let k = live.length - 1; k >= 0; k -= 1) {
|
|
1190
|
+
const x = live[k]!;
|
|
1191
|
+
const from = Math.max(i, x.start);
|
|
1192
|
+
const n = i + count - from;
|
|
1193
|
+
if (n <= 0) continue;
|
|
1194
|
+
if (x.cents) x.voice.setCents(x.cents((from - x.start) / sr));
|
|
1195
|
+
if (!x.voice.released && x.off < from + n) x.voice.noteOff();
|
|
1196
|
+
let alive: boolean;
|
|
1197
|
+
if (x.stolen) {
|
|
1198
|
+
const block = new Float64Array(n);
|
|
1199
|
+
alive = x.voice.process(block, 0, n);
|
|
1200
|
+
for (let s = 0; s < n; s += 1) {
|
|
1201
|
+
mono[from + s]! += (block[s]! * Math.max(0, x.fade)) / x.fadeLength;
|
|
1202
|
+
x.fade -= 1;
|
|
1203
|
+
}
|
|
1204
|
+
if (x.fade <= 0) alive = false;
|
|
1205
|
+
} else alive = x.voice.process(mono, from, n);
|
|
1206
|
+
if (!alive) live.splice(k, 1);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
const lane = (name: string) => {
|
|
1210
|
+
const points = track.fxAutomation?.[`keys-${name}` as FxLane];
|
|
1211
|
+
return points && points.length > 0 ? points : undefined;
|
|
1212
|
+
};
|
|
1213
|
+
const rotaryLane = family === "pipe" ? undefined : lane("rotary");
|
|
1214
|
+
const driveLane = family === "pipe" ? undefined : lane("drive");
|
|
1215
|
+
const settings = organPostSettings(family, values, rotaryLane !== undefined);
|
|
1216
|
+
const seed = rotaryLane ? context.seedState?.[track.id] : undefined;
|
|
1217
|
+
const post = new OrganPost(
|
|
1218
|
+
settings,
|
|
1219
|
+
sr,
|
|
1220
|
+
originSec,
|
|
1221
|
+
seed ? (JSON.parse(seed) as RotorState) : undefined,
|
|
1222
|
+
);
|
|
1223
|
+
const monoR = new Float64Array(total);
|
|
1224
|
+
post.process(
|
|
1225
|
+
mono,
|
|
1226
|
+
monoR,
|
|
1227
|
+
total,
|
|
1228
|
+
rotaryLane
|
|
1229
|
+
? (s) =>
|
|
1230
|
+
interpolateAutomation(rotaryLane, tickAt(context, s), settings.rotary)
|
|
1231
|
+
: undefined,
|
|
1232
|
+
driveLane
|
|
1233
|
+
? (s) =>
|
|
1234
|
+
interpolateAutomation(driveLane, tickAt(context, s), settings.drive)
|
|
1235
|
+
: undefined,
|
|
1236
|
+
);
|
|
1237
|
+
applyVolume(mono, monoR, track, context);
|
|
1238
|
+
for (let s = 0; s < total; s += 1) {
|
|
1239
|
+
left[s]! += mono[s]!;
|
|
1240
|
+
right[s]! += monoR[s]!;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function applyVolume(
|
|
1245
|
+
left: Float64Array,
|
|
1246
|
+
right: Float64Array,
|
|
1247
|
+
track: Track,
|
|
1248
|
+
context: EngineContext,
|
|
1249
|
+
): void {
|
|
1250
|
+
const volume = clamp(track.volume ?? 1, 0, 1);
|
|
1251
|
+
const points = track.volumeAutomation ?? [];
|
|
1252
|
+
if (points.length === 0 && volume === 1) return;
|
|
1253
|
+
for (let i = 0; i < left.length; i += CONTROL) {
|
|
1254
|
+
const gain =
|
|
1255
|
+
points.length === 0
|
|
1256
|
+
? volume
|
|
1257
|
+
: volume * interpolateAutomation(points, tickAt(context, i), 1);
|
|
1258
|
+
const end = Math.min(left.length, i + CONTROL);
|
|
1259
|
+
for (let s = i; s < end; s += 1) {
|
|
1260
|
+
left[s]! *= gain;
|
|
1261
|
+
right[s]! *= gain;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** Ring-out after the last note (the voices' release plus the rotor delay). */
|
|
1267
|
+
export function organTailSeconds(family: OrganFamily): number {
|
|
1268
|
+
return family === "pipe" ? 0.6 : family === "combo" ? 0.15 : 0.1;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/** The live note-off fade per family (a linear fade of the same energy). */
|
|
1272
|
+
export function organReleaseSeconds(family: OrganFamily): number {
|
|
1273
|
+
return family === "pipe" ? 0.15 : family === "combo" ? 0.03 : 0.012;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function resolvedPercFast(values: Values): boolean {
|
|
1277
|
+
return str(values, "percdecay", "fast") === "fast";
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* The rotors at sample `frames` of `history` (the song before an arranged
|
|
1282
|
+
* window), integrated from song start along the track's `keys-rotary`
|
|
1283
|
+
* lane. Undefined without a lane: steady rotors follow from song time.
|
|
1284
|
+
*/
|
|
1285
|
+
export function organRotorSeed(
|
|
1286
|
+
history: TimeScore & Readonly<{ ticksPerBeat: number; tempoBpm: number }>,
|
|
1287
|
+
track: Track,
|
|
1288
|
+
frames: number,
|
|
1289
|
+
sampleRate: number,
|
|
1290
|
+
): string | undefined {
|
|
1291
|
+
const family = track.instrument;
|
|
1292
|
+
if (!isOrganFamily(family) || family === "pipe") return undefined;
|
|
1293
|
+
const lane = track.fxAutomation?.["keys-rotary" as FxLane];
|
|
1294
|
+
if (!lane || lane.length === 0) return undefined;
|
|
1295
|
+
const values = resolvedKeys(family, track.keys);
|
|
1296
|
+
const settings = organPostSettings(family, values, true);
|
|
1297
|
+
const post = new OrganPost(settings, sampleRate);
|
|
1298
|
+
const warp = sampleWarpFor(history, sampleRate);
|
|
1299
|
+
const perTick = (sampleRate * 60) / (history.tempoBpm * history.ticksPerBeat);
|
|
1300
|
+
post.spin(frames, (s) =>
|
|
1301
|
+
interpolateAutomation(
|
|
1302
|
+
lane,
|
|
1303
|
+
warp ? warp.tick(s) : s / perTick,
|
|
1304
|
+
settings.rotary,
|
|
1305
|
+
),
|
|
1306
|
+
);
|
|
1307
|
+
return JSON.stringify(post.rotor());
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function organEngine(id: OrganFamily): InstrumentEngine {
|
|
1311
|
+
return {
|
|
1312
|
+
id,
|
|
1313
|
+
field: "keys",
|
|
1314
|
+
render(dry, dryR, notes, track, context) {
|
|
1315
|
+
if (dryR) {
|
|
1316
|
+
renderOrganTrack(dry, dryR, notes, track, context);
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
const left = new Float64Array(dry.length);
|
|
1320
|
+
const right = new Float64Array(dry.length);
|
|
1321
|
+
renderOrganTrack(left, right, notes, track, context);
|
|
1322
|
+
for (let i = 0; i < dry.length; i += 1)
|
|
1323
|
+
dry[i]! += 0.5 * (left[i]! + right[i]!);
|
|
1324
|
+
},
|
|
1325
|
+
windowSeed: organRotorSeed,
|
|
1326
|
+
tailSeconds: () => organTailSeconds(id),
|
|
1327
|
+
releaseSeconds: () => organReleaseSeconds(id),
|
|
1328
|
+
stereo: () => true,
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** One engine per organ family (KEYS_ENGINES appends them). */
|
|
1333
|
+
export const ORGAN_ENGINES: readonly InstrumentEngine[] = Object.freeze(
|
|
1334
|
+
(["tonewheel", "combo", "pipe"] as const).map(organEngine),
|
|
1335
|
+
);
|