@hraness/dawg 0.4.1 → 0.6.0
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 +99 -0
- package/DAWG.md +551 -33
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +41 -12
- package/core/expression.ts +1241 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/resonators.ts +574 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +717 -62
- package/core/sdk/eval-child.ts +40 -3
- package/core/sdk/print.ts +614 -27
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2824 -45
- package/core/sections.ts +2072 -0
- package/core/strings.ts +845 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +29 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +29 -0
- package/guides/tempo.md +27 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +9 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +68 -6
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +27 -4
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +574 -16
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +9 -0
- package/src/audio/arrange.ts +489 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +6 -1
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +100 -21
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +263 -17
- package/src/audio/loudness.ts +596 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +76 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +269 -34
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +147 -0
- package/src/audio/wav.ts +377 -30
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +941 -0
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +355 -27
- package/src/commands/keys.ts +353 -0
- package/src/commands/master.ts +361 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +967 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +919 -41
- package/src/project/check.ts +13 -0
- package/src/render.ts +139 -8
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +967 -23
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +459 -43
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The modelled acoustic piano (0.6 keys lane, families grand, upright, felt,
|
|
3
|
+
* honkytonk, prepared), ported from the reviewed prototype
|
|
4
|
+
* (`.plans/dawg-06/proto/keys/keys.ts` round 2, `PianoVoice`): modal strings
|
|
5
|
+
* after Bank (f_n = n f0 sqrt(1 + B n^2), partial 1 exactly at the tuned
|
|
6
|
+
* pitch), a spectral felt-hammer pulse (Chaigne and Askenfelt 1994), 1-3
|
|
7
|
+
* unison strings with prompt sound and aftersound (Weinreich 1977), dampers
|
|
8
|
+
* that let F6 and up ring, knock and key-off mechanics, and seeded per-key
|
|
9
|
+
* preparations. The numbers are the prototype's; only the octave stretch is
|
|
10
|
+
* new (the review's fix): derived from each key's own B rather than a fixed
|
|
11
|
+
* Railsback curve.
|
|
12
|
+
*/
|
|
13
|
+
import { seedHash, unit } from "../dsp/rng.ts";
|
|
14
|
+
import { seededRandom } from "../dsp/rng.ts";
|
|
15
|
+
import {
|
|
16
|
+
Biquad2,
|
|
17
|
+
Burst,
|
|
18
|
+
clamp,
|
|
19
|
+
hammerPulse,
|
|
20
|
+
lerp,
|
|
21
|
+
LN1000,
|
|
22
|
+
MODE_CEILING,
|
|
23
|
+
ModeBank,
|
|
24
|
+
panGains,
|
|
25
|
+
pulseMag,
|
|
26
|
+
TAU,
|
|
27
|
+
} from "./dsp.ts";
|
|
28
|
+
|
|
29
|
+
/** Resolved voice parameters (core/keys.ts `KEYS_PARAMS`). */
|
|
30
|
+
export type PianoParams = Readonly<{
|
|
31
|
+
hardness: number;
|
|
32
|
+
touch: number;
|
|
33
|
+
inharm: number;
|
|
34
|
+
unison: number;
|
|
35
|
+
decay: number;
|
|
36
|
+
release: number;
|
|
37
|
+
strike: number;
|
|
38
|
+
after: number;
|
|
39
|
+
knock: number;
|
|
40
|
+
noise: number;
|
|
41
|
+
felt: number;
|
|
42
|
+
prep: number;
|
|
43
|
+
width: number;
|
|
44
|
+
stretch: number;
|
|
45
|
+
}>;
|
|
46
|
+
|
|
47
|
+
export const PIANO_LOW = 21;
|
|
48
|
+
export const PIANO_HIGH = 108;
|
|
49
|
+
/** F6 and up have no dampers and ring on after key-up, as on a grand. */
|
|
50
|
+
export const NO_DAMPER_FROM = 89;
|
|
51
|
+
|
|
52
|
+
/** Inharmonicity coefficient B for a key (grand at mult 1): 1e-4 at A1, 0.02 at C8. */
|
|
53
|
+
export function pianoB(pitch: number, mult: number): number {
|
|
54
|
+
const p = clamp(pitch, PIANO_LOW, PIANO_HIGH);
|
|
55
|
+
const lg = p >= 33 ? -4 + ((p - 33) / 75) * 2.3 : -4 + ((33 - p) / 12) * 0.45;
|
|
56
|
+
return mult * 10 ** lg;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The physical key a frequency sounds at (12-TET place, fractional, on the
|
|
61
|
+
* 88-key range). Everything physical (B, strings, decay, dampers) is read
|
|
62
|
+
* here, so a 19-EDO degree near 750 Hz is voiced as the F#5 it sounds as,
|
|
63
|
+
* not as the MIDI number it is stored under. Exact in 12-TET.
|
|
64
|
+
*/
|
|
65
|
+
export function physicalKey(hz: number): number {
|
|
66
|
+
const key = 69 + 12 * Math.log2(hz / 440);
|
|
67
|
+
const near = Math.round(key);
|
|
68
|
+
return clamp(Math.abs(key - near) < 1e-6 ? near : key, PIANO_LOW, PIANO_HIGH);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when a key has no damper (F6 and up). */
|
|
72
|
+
export const undamped = (key: number): boolean =>
|
|
73
|
+
Math.round(key) >= NO_DAMPER_FROM;
|
|
74
|
+
|
|
75
|
+
/** Damper T60 of the fundamental at `f1` Hz when the key falls (seconds). */
|
|
76
|
+
export function damperT60(key: number, f1: number, release: number): number {
|
|
77
|
+
const base = release * (0.12 + 0.55 * clamp((64 - key) / 43, 0, 1));
|
|
78
|
+
return base / (1 + f1 / 2000);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const stringsFor = (pitch: number): number =>
|
|
82
|
+
pitch < 31 ? 1 : pitch < 42 ? 2 : 3;
|
|
83
|
+
|
|
84
|
+
/** Aftersound T60 of the fundamental in seconds (A0 28 s, A4 6.2 s, C8 1.8 s). */
|
|
85
|
+
export const pianoT60 = (pitch: number): number =>
|
|
86
|
+
28 * 2 ** (-(clamp(pitch, PIANO_LOW, PIANO_HIGH) - 21) / 22);
|
|
87
|
+
|
|
88
|
+
/** Partial `n` of a string whose partial 1 sounds at `f1`. */
|
|
89
|
+
export function pianoPartial(f1: number, b: number, n: number): number {
|
|
90
|
+
return ((n * f1) / Math.sqrt(1 + b)) * Math.sqrt(1 + b * n * n);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Cents an octave from `low` to `low + 12` is widened so the upper key's
|
|
95
|
+
* partial 1 meets the lower key's partials: pure 2:1 in the treble (from the
|
|
96
|
+
* C5 octave up), blending in half of the 4:2 match below C4, as an aural
|
|
97
|
+
* tuner does.
|
|
98
|
+
*/
|
|
99
|
+
function octaveWidening(low: number, inharm: number): number {
|
|
100
|
+
const bl = pianoB(low, inharm);
|
|
101
|
+
const bu = pianoB(low + 12, inharm);
|
|
102
|
+
const c21 = 600 * Math.log2((1 + 4 * bl) / (1 + bl));
|
|
103
|
+
const c42 =
|
|
104
|
+
600 * Math.log2(((1 + 16 * bl) * (1 + bu)) / ((1 + bl) * (1 + 4 * bu)));
|
|
105
|
+
const w42 = 0.5 * clamp((72 - (low + 12)) / 12, 0, 1);
|
|
106
|
+
return (1 - w42) * c21 + w42 * c42;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const stretchTables = new Map<number, Float64Array>();
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Stretch in cents for each key 0..127 at an inharmonicity multiplier: the
|
|
113
|
+
* temperament octave D#4..D5 stays put (A4 exact), every other key is tuned
|
|
114
|
+
* by octaves from its own strings' B.
|
|
115
|
+
*/
|
|
116
|
+
export function stretchCents(pitch: number, inharm: number): number {
|
|
117
|
+
let table = stretchTables.get(inharm);
|
|
118
|
+
if (!table) {
|
|
119
|
+
table = new Float64Array(128);
|
|
120
|
+
for (let p = 75; p < 128; p += 1)
|
|
121
|
+
table[p] = table[p - 12]! + octaveWidening(p - 12, inharm);
|
|
122
|
+
for (let p = 62; p >= 0; p -= 1)
|
|
123
|
+
table[p] = table[p + 12]! - octaveWidening(p, inharm);
|
|
124
|
+
if (stretchTables.size > 32) stretchTables.clear();
|
|
125
|
+
stretchTables.set(inharm, table);
|
|
126
|
+
}
|
|
127
|
+
return table[clamp(Math.round(pitch), 0, 127)]!;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The pitch of partial 1 for a note at a resolved `hz` with stretch. The
|
|
132
|
+
* stretch is read at the note's frequency (its 12-TET key position, linearly
|
|
133
|
+
* interpolated), so it follows octaves in any tuning table: a 19-EDO key an
|
|
134
|
+
* octave up gets the same widening as a 12-TET one.
|
|
135
|
+
*/
|
|
136
|
+
export function pianoF1(hz: number, p: PianoParams): number {
|
|
137
|
+
if (!(p.stretch > 0)) return hz;
|
|
138
|
+
const key = clamp(69 + 12 * Math.log2(hz / 440), 0, 127);
|
|
139
|
+
const low = Math.floor(key);
|
|
140
|
+
const high = Math.min(127, low + 1);
|
|
141
|
+
const cents = lerp(
|
|
142
|
+
stretchCents(low, p.inharm),
|
|
143
|
+
stretchCents(high, p.inharm),
|
|
144
|
+
key - low,
|
|
145
|
+
);
|
|
146
|
+
return hz * 2 ** ((p.stretch * cents) / 1200);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type PianoNoteOn = Readonly<{
|
|
150
|
+
pitch: number;
|
|
151
|
+
/** Resolved frequency: noteHz(pitch, cents, table); > 0. */
|
|
152
|
+
hz: number;
|
|
153
|
+
/** 0..1 after the 0.5 velocity curve. */
|
|
154
|
+
velocity: number;
|
|
155
|
+
/** Track seed hash (preparations are per track and key). */
|
|
156
|
+
trackSeed: number;
|
|
157
|
+
/** Track seed plus note id (this strike's own randomness). */
|
|
158
|
+
noteSeed: string;
|
|
159
|
+
}>;
|
|
160
|
+
|
|
161
|
+
type Rattle = {
|
|
162
|
+
hz: number;
|
|
163
|
+
gain: number;
|
|
164
|
+
k: number;
|
|
165
|
+
phase: number;
|
|
166
|
+
random: () => number;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export class PianoVoice {
|
|
170
|
+
private readonly bank: ModeBank;
|
|
171
|
+
private readonly pulse: Float64Array;
|
|
172
|
+
private pos = 0;
|
|
173
|
+
private readonly bursts: Burst[] = [];
|
|
174
|
+
readonly gl: number;
|
|
175
|
+
readonly gr: number;
|
|
176
|
+
private tmp = new Float64Array(0);
|
|
177
|
+
private readonly key: number;
|
|
178
|
+
private readonly rattle: Rattle | undefined;
|
|
179
|
+
private age = 0;
|
|
180
|
+
private releasedFlag = false;
|
|
181
|
+
|
|
182
|
+
constructor(
|
|
183
|
+
private readonly note: PianoNoteOn,
|
|
184
|
+
private readonly p: PianoParams,
|
|
185
|
+
private readonly sr: number,
|
|
186
|
+
) {
|
|
187
|
+
const v = clamp(lerp(0.8, note.velocity, p.touch), 0.01, 1);
|
|
188
|
+
// Physical place from the sounding frequency; the MIDI key only seeds
|
|
189
|
+
// the preparation slot.
|
|
190
|
+
const key = physicalKey(note.hz);
|
|
191
|
+
this.key = key;
|
|
192
|
+
const prepKey = clamp(note.pitch, PIANO_LOW, PIANO_HIGH);
|
|
193
|
+
const keyPos = (key - 21) / 87;
|
|
194
|
+
const random = seededRandom(`${note.noteSeed}:piano`);
|
|
195
|
+
// Preparation: drawn per track and key, so a prepared key always
|
|
196
|
+
// sounds the same in that track and differs between tracks.
|
|
197
|
+
let slot = 0;
|
|
198
|
+
const prep = () => unit(note.trackSeed, prepKey, slot++);
|
|
199
|
+
const prepared = p.prep > 0 && prep() < p.prep;
|
|
200
|
+
// 0 bolt, 1 rubber, 2 screw
|
|
201
|
+
const prepKind = prepared ? Math.floor(prep() * 3) : -1;
|
|
202
|
+
const B = pianoB(key, p.inharm);
|
|
203
|
+
const f1 = pianoF1(note.hz, p);
|
|
204
|
+
const f0 = f1 / Math.sqrt(1 + B);
|
|
205
|
+
const T1 =
|
|
206
|
+
pianoT60(key) *
|
|
207
|
+
p.decay *
|
|
208
|
+
(1 - 0.15 * p.felt) *
|
|
209
|
+
(prepKind === 1 ? 0.12 : prepKind >= 0 ? 0.5 : 1);
|
|
210
|
+
const after = clamp(p.after, 0, 0.95);
|
|
211
|
+
const thMs =
|
|
212
|
+
lerp(3.5, 0.8, keyPos) *
|
|
213
|
+
lerp(1.6, 0.7, v) *
|
|
214
|
+
(1 + 1.5 * p.felt) *
|
|
215
|
+
(1.3 - 0.6 * p.hardness);
|
|
216
|
+
this.pulse = hammerPulse(
|
|
217
|
+
Math.max(2, Math.round(thMs * 1e-3 * sr)),
|
|
218
|
+
1 + 2 * v * p.hardness,
|
|
219
|
+
);
|
|
220
|
+
const strings = stringsFor(key);
|
|
221
|
+
const spread = strings === 1 ? 0.08 : p.unison;
|
|
222
|
+
const maxHz = MODE_CEILING * sr;
|
|
223
|
+
let partials = 0;
|
|
224
|
+
while (partials < 160) {
|
|
225
|
+
const n = partials + 1;
|
|
226
|
+
if (n * f0 * Math.sqrt(1 + B * n * n) >= maxHz) break;
|
|
227
|
+
partials = n;
|
|
228
|
+
}
|
|
229
|
+
const extra = prepKind === 0 || prepKind === 2 ? 4 : 0;
|
|
230
|
+
const bank = new ModeBank(
|
|
231
|
+
Math.min(12, partials) * 3 + Math.max(0, partials - 12) + extra,
|
|
232
|
+
);
|
|
233
|
+
const xb = 0.1 + 0.3 * prep();
|
|
234
|
+
const shift = prepKind === 0 || prepKind === 2 ? 0.04 + 0.1 * prep() : 0;
|
|
235
|
+
const candidates: { hz: number; t60: number; u: number }[] = [];
|
|
236
|
+
let energy = 0;
|
|
237
|
+
for (let n = 1; n <= partials; n += 1) {
|
|
238
|
+
let fn = n * f0 * Math.sqrt(1 + B * n * n);
|
|
239
|
+
if (shift && n > 1) fn *= 1 - shift * Math.sin(n * Math.PI * xb) ** 2;
|
|
240
|
+
const shape = Math.sin(n * Math.PI * p.strike);
|
|
241
|
+
if (Math.abs(shape) < 1e-3) continue;
|
|
242
|
+
const tn = 1 / (1 / T1 + (fn / 3000) ** 2 / (1.2 * p.decay));
|
|
243
|
+
const damped = prepKind === 1 && n > 3 ? 0.3 : 1;
|
|
244
|
+
const u = shape * damped;
|
|
245
|
+
energy += u * u;
|
|
246
|
+
if (n <= 12) {
|
|
247
|
+
// Prompt sound at the centre, aftersound split into a symmetric pair.
|
|
248
|
+
const d = (spread / 2) * (1 + 0.3 * (random() - 0.5));
|
|
249
|
+
candidates.push({ hz: fn, t60: tn * 0.3, u: u * (1 - after) });
|
|
250
|
+
candidates.push({
|
|
251
|
+
hz: fn * 2 ** (-d / 1200),
|
|
252
|
+
t60: tn,
|
|
253
|
+
u: (u * after) / 2,
|
|
254
|
+
});
|
|
255
|
+
candidates.push({
|
|
256
|
+
hz: fn * 2 ** (d / 1200),
|
|
257
|
+
t60: tn,
|
|
258
|
+
u: (u * after) / 2,
|
|
259
|
+
});
|
|
260
|
+
} else candidates.push({ hz: fn, t60: tn * 0.6, u });
|
|
261
|
+
}
|
|
262
|
+
if (extra)
|
|
263
|
+
for (const r of [2.76, 5.4, 8.93, 13.34])
|
|
264
|
+
candidates.push({
|
|
265
|
+
hz: f1 * r * (1 + 0.05 * (prep() - 0.5)),
|
|
266
|
+
t60: 0.6 + 1.2 * prep(),
|
|
267
|
+
u: 0.35,
|
|
268
|
+
});
|
|
269
|
+
const loud = v ** 1.4 * (1 - 0.4 * p.felt);
|
|
270
|
+
const G = (0.35 * loud) / Math.sqrt(Math.max(energy, 1e-9));
|
|
271
|
+
const mags = candidates.map(
|
|
272
|
+
(c) => pulseMag(this.pulse, (TAU * c.hz) / sr) * Math.abs(c.u),
|
|
273
|
+
);
|
|
274
|
+
const peak = Math.max(0, ...mags);
|
|
275
|
+
candidates.forEach((c, i) => {
|
|
276
|
+
// Below -80 dB after the hammer filter: not worth a mode.
|
|
277
|
+
if (mags[i]! >= peak * 1e-4) bank.add(c.hz, c.t60, c.u * G, 1, sr);
|
|
278
|
+
});
|
|
279
|
+
this.bank = bank;
|
|
280
|
+
const pan = p.width * clamp((key - 64) / 44, -1, 1) * 0.8;
|
|
281
|
+
[this.gl, this.gr] = panGains(pan);
|
|
282
|
+
const s = note.noteSeed;
|
|
283
|
+
if (p.knock > 0) {
|
|
284
|
+
this.bursts.push(
|
|
285
|
+
new Burst(
|
|
286
|
+
`${s}:knock`,
|
|
287
|
+
lerp(90, 250, keyPos),
|
|
288
|
+
1.0,
|
|
289
|
+
0.02 + 0.03 * (1 - keyPos),
|
|
290
|
+
p.knock * 0.05 * v ** 1.5 * (prepKind === 1 ? 2 : 1),
|
|
291
|
+
sr,
|
|
292
|
+
),
|
|
293
|
+
new Burst(
|
|
294
|
+
`${s}:click`,
|
|
295
|
+
2800,
|
|
296
|
+
1.5,
|
|
297
|
+
0.002,
|
|
298
|
+
0.02 * v * v * p.hardness * (1 - p.felt),
|
|
299
|
+
sr,
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (p.felt > 0 && p.noise > 0)
|
|
304
|
+
this.bursts.push(
|
|
305
|
+
new Burst(`${s}:thud`, 300, 0.9, 0.03, 0.04 * p.felt * p.noise * v, sr),
|
|
306
|
+
);
|
|
307
|
+
this.rattle =
|
|
308
|
+
prepKind === 2
|
|
309
|
+
? {
|
|
310
|
+
hz: f1,
|
|
311
|
+
gain: 0.05 * v,
|
|
312
|
+
k: Math.exp(-1 / (0.35 * sr)),
|
|
313
|
+
phase: 0,
|
|
314
|
+
random: seededRandom(`${s}:rattle`),
|
|
315
|
+
}
|
|
316
|
+
: undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
get released(): boolean {
|
|
320
|
+
return this.releasedFlag;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Key up: the damper falls (none from F6 up), with key-off noise. */
|
|
324
|
+
noteOff(): void {
|
|
325
|
+
if (this.releasedFlag) return;
|
|
326
|
+
this.releasedFlag = true;
|
|
327
|
+
if (undamped(this.key)) return;
|
|
328
|
+
const base =
|
|
329
|
+
this.p.release * (0.12 + 0.55 * clamp((64 - this.key) / 43, 0, 1));
|
|
330
|
+
this.bank.damp((hz) => base / (1 + hz / 2000), this.sr);
|
|
331
|
+
if (this.p.noise > 0)
|
|
332
|
+
this.bursts.push(
|
|
333
|
+
new Burst(
|
|
334
|
+
`${this.note.noteSeed}:keyoff`,
|
|
335
|
+
500,
|
|
336
|
+
1.2,
|
|
337
|
+
0.015,
|
|
338
|
+
0.012 * this.p.noise,
|
|
339
|
+
this.sr,
|
|
340
|
+
),
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Pitch offset in cents (bend, vibrato); modes above the ceiling mute. */
|
|
345
|
+
setCents(cents: number): void {
|
|
346
|
+
this.bank.retune(2 ** (cents / 1200));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Half pedal (0.5 `damp`): every mode decays with time constant `tau`. */
|
|
350
|
+
damp(tau: number): void {
|
|
351
|
+
this.bank.damp(() => LN1000 * tau, this.sr);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Adds `count` mono samples at `offset`; false once the voice is silent. */
|
|
355
|
+
process(out: Float64Array, offset: number, count: number): boolean {
|
|
356
|
+
if (this.tmp.length < count) this.tmp = new Float64Array(count);
|
|
357
|
+
const tmp = this.tmp;
|
|
358
|
+
tmp.fill(0, 0, count);
|
|
359
|
+
let done = 0;
|
|
360
|
+
if (this.pos < this.pulse.length) {
|
|
361
|
+
done = Math.min(count, this.pulse.length - this.pos);
|
|
362
|
+
this.bank.run(tmp, 0, done, this.pulse, this.pos);
|
|
363
|
+
this.pos += done;
|
|
364
|
+
}
|
|
365
|
+
if (done < count) this.bank.run(tmp, done, count - done, null, 0);
|
|
366
|
+
for (const b of this.bursts)
|
|
367
|
+
if (!b.done) for (let i = 0; i < count; i += 1) tmp[i]! += b.next();
|
|
368
|
+
const r = this.rattle;
|
|
369
|
+
if (r)
|
|
370
|
+
for (let i = 0; i < count; i += 1) {
|
|
371
|
+
r.phase += r.hz / this.sr;
|
|
372
|
+
const gate = Math.abs(Math.sin(Math.PI * r.phase)) ** 16;
|
|
373
|
+
tmp[i]! += (r.random() * 2 - 1) * gate * r.gain;
|
|
374
|
+
r.gain *= r.k;
|
|
375
|
+
}
|
|
376
|
+
for (let i = 0; i < count; i += 1) out[offset + i]! += tmp[i]!;
|
|
377
|
+
this.age += count;
|
|
378
|
+
if (this.age % 256 < count) this.bank.cull(2e-6);
|
|
379
|
+
return (
|
|
380
|
+
this.bank.n > 0 ||
|
|
381
|
+
this.bursts.some((b) => !b.done) ||
|
|
382
|
+
(r !== undefined && r.gain > 1e-6)
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export type PianoBodyKind =
|
|
388
|
+
"grand" | "upright" | "felt" | "honkytonk" | "prepared";
|
|
389
|
+
|
|
390
|
+
/** Piano body: a fixed EQ per body type (soundboard and case colour). */
|
|
391
|
+
export function pianoBody(kind: string, sr: number): Biquad2[] {
|
|
392
|
+
const q = (
|
|
393
|
+
t: Parameters<Biquad2["set"]>[0],
|
|
394
|
+
hz: number,
|
|
395
|
+
qq: number,
|
|
396
|
+
db: number,
|
|
397
|
+
) => new Biquad2().set(t, hz, qq, db, sr);
|
|
398
|
+
switch (kind) {
|
|
399
|
+
case "upright":
|
|
400
|
+
return [
|
|
401
|
+
q("lowshelf", 120, 0.7, -4),
|
|
402
|
+
q("peak", 380, 1.2, 3),
|
|
403
|
+
q("peak", 1500, 1, 2),
|
|
404
|
+
q("highshelf", 5000, 0.7, -3),
|
|
405
|
+
];
|
|
406
|
+
case "honkytonk":
|
|
407
|
+
return [
|
|
408
|
+
q("lowshelf", 120, 0.7, -4),
|
|
409
|
+
q("peak", 1200, 1, 4),
|
|
410
|
+
q("highshelf", 5000, 0.7, 1),
|
|
411
|
+
];
|
|
412
|
+
case "felt":
|
|
413
|
+
return [
|
|
414
|
+
q("lowshelf", 150, 0.7, 2),
|
|
415
|
+
q("peak", 400, 0.9, 1.5),
|
|
416
|
+
q("highshelf", 2500, 0.7, -7),
|
|
417
|
+
];
|
|
418
|
+
case "prepared":
|
|
419
|
+
return [q("lowshelf", 120, 0.7, 1), q("peak", 250, 0.8, 2)];
|
|
420
|
+
default:
|
|
421
|
+
return [
|
|
422
|
+
q("lowshelf", 120, 0.7, 1.5),
|
|
423
|
+
q("peak", 250, 0.8, 2),
|
|
424
|
+
q("peak", 2500, 1, -2),
|
|
425
|
+
q("highshelf", 6000, 0.7, -2),
|
|
426
|
+
];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Track seed hash for preparations. */
|
|
431
|
+
export const trackSeedOf = (trackId: string): number =>
|
|
432
|
+
seedHash(`keys:${trackId}`);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Off-thread full pass for live guitar-rig notes. The key path renders only
|
|
3
|
+
* the first `LIVE_RIG_WINDOW_SECONDS` of a rig note; this worker renders the
|
|
4
|
+
* whole note so the TUI thread never blocks on an oversampled 8 s render.
|
|
5
|
+
* Requests are handled in arrival order, so a strummed chord's strings swap
|
|
6
|
+
* in the order they were played.
|
|
7
|
+
*/
|
|
8
|
+
import { scoreFromJSON } from "../../core/score.ts";
|
|
9
|
+
import { LiveSynth, type LiveNotePcm } from "./live.ts";
|
|
10
|
+
import type { SampleBank } from "./samples.ts";
|
|
11
|
+
|
|
12
|
+
export type LiveFullRequest = Readonly<{
|
|
13
|
+
id: number;
|
|
14
|
+
sampleRate: number;
|
|
15
|
+
score: unknown;
|
|
16
|
+
trackId: string;
|
|
17
|
+
pitch: number;
|
|
18
|
+
velocity: number;
|
|
19
|
+
seconds: number;
|
|
20
|
+
tick?: number;
|
|
21
|
+
samples?: SampleBank;
|
|
22
|
+
}>;
|
|
23
|
+
|
|
24
|
+
export type LiveFullReply = Readonly<{
|
|
25
|
+
id: number;
|
|
26
|
+
pcm?: LiveNotePcm;
|
|
27
|
+
error?: string;
|
|
28
|
+
}>;
|
|
29
|
+
|
|
30
|
+
declare const self: Worker;
|
|
31
|
+
|
|
32
|
+
let synth: LiveSynth | undefined;
|
|
33
|
+
|
|
34
|
+
self.onmessage = (event: MessageEvent<LiveFullRequest>) => {
|
|
35
|
+
const { id, sampleRate, score, samples, tick, ...note } = event.data;
|
|
36
|
+
try {
|
|
37
|
+
if (synth?.rate !== sampleRate) synth = new LiveSynth(sampleRate);
|
|
38
|
+
const pcm = synth.render({
|
|
39
|
+
...note,
|
|
40
|
+
score: scoreFromJSON(score),
|
|
41
|
+
full: true,
|
|
42
|
+
...(samples ? { samples } : {}),
|
|
43
|
+
...(tick === undefined ? {} : { tick }),
|
|
44
|
+
});
|
|
45
|
+
const reply: LiveFullReply = pcm ? { id, pcm } : { id };
|
|
46
|
+
self.postMessage(reply);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const reply: LiveFullReply = {
|
|
49
|
+
id,
|
|
50
|
+
error: error instanceof Error ? error.message : String(error),
|
|
51
|
+
};
|
|
52
|
+
self.postMessage(reply);
|
|
53
|
+
}
|
|
54
|
+
};
|