@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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One oversampler per nonlinear rig section (stomp, head): 4x at 22.05 kHz
|
|
3
|
+
* and below, 2x at 44.1/48 kHz, from the shared half-band primitives in
|
|
4
|
+
* src/audio/dsp/oversample.ts. The section's linear-phase delay is removed
|
|
5
|
+
* so a rigged track stays on the grid.
|
|
6
|
+
*/
|
|
7
|
+
import { Adaa1, logCosh } from "../../dsp/shape.ts";
|
|
8
|
+
import { Oversample2x, Oversample4x } from "../../dsp/oversample.ts";
|
|
9
|
+
|
|
10
|
+
/** Oversampling factor of a nonlinear section at `sampleRate`. */
|
|
11
|
+
export function sectionFactor(sampleRate: number): 2 | 4 {
|
|
12
|
+
return sampleRate <= 32_000 ? 4 : 2;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Oversampler = { process(x: number, fn: (x: number) => number): number };
|
|
16
|
+
|
|
17
|
+
function oversampler(factor: 2 | 4): Oversampler {
|
|
18
|
+
return factor === 4 ? new Oversample4x() : new Oversample2x();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const latencies = new Map<number, number>();
|
|
22
|
+
|
|
23
|
+
/** Base-rate delay of the up/down pair (impulse peak), measured once. */
|
|
24
|
+
export function sectionLatency(factor: 2 | 4): number {
|
|
25
|
+
const known = latencies.get(factor);
|
|
26
|
+
if (known !== undefined) return known;
|
|
27
|
+
const os = oversampler(factor);
|
|
28
|
+
let peak = 0;
|
|
29
|
+
let at = 0;
|
|
30
|
+
for (let i = 0; i < 256; i += 1) {
|
|
31
|
+
const y = Math.abs(os.process(i === 0 ? 1 : 0, (x) => x));
|
|
32
|
+
if (y > peak) {
|
|
33
|
+
peak = y;
|
|
34
|
+
at = i;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
latencies.set(factor, at);
|
|
38
|
+
return at;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Runs `fn` over `buffer` at the oversampled rate, in place and latency
|
|
43
|
+
* compensated. `control(i)` runs at the base rate before sample i.
|
|
44
|
+
*/
|
|
45
|
+
export function runSection(
|
|
46
|
+
buffer: Float64Array,
|
|
47
|
+
factor: 2 | 4,
|
|
48
|
+
fn: (x: number) => number,
|
|
49
|
+
control?: (index: number) => void,
|
|
50
|
+
): void {
|
|
51
|
+
const os = oversampler(factor);
|
|
52
|
+
const latency = sectionLatency(factor);
|
|
53
|
+
const n = buffer.length;
|
|
54
|
+
for (let i = 0; i < n + latency; i += 1) {
|
|
55
|
+
if (control && i < n) control(i);
|
|
56
|
+
const y = os.process(i < n ? buffer[i]! : 0, fn);
|
|
57
|
+
if (i >= latency) buffer[i - latency] = y;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Static curves with closed-form antiderivatives for first-order ADAA.
|
|
62
|
+
|
|
63
|
+
/** Triode-like asymmetry: the positive half clips harder (grid current). */
|
|
64
|
+
const ASYM_B = 0.35;
|
|
65
|
+
const ASYM_T = Math.tanh(ASYM_B);
|
|
66
|
+
const asymF = (x: number) => Math.tanh(x + ASYM_B) - ASYM_T;
|
|
67
|
+
const asymAF = (x: number) => logCosh(x + ASYM_B) - ASYM_T * x;
|
|
68
|
+
|
|
69
|
+
export const adaaTanh = () => new Adaa1(Math.tanh, logCosh);
|
|
70
|
+
export const adaaTriode = () => new Adaa1(asymF, asymAF);
|
|
71
|
+
/** Full-wave rectifier (octave up): |x|, antiderivative x|x|/2. */
|
|
72
|
+
export const adaaRectify = () =>
|
|
73
|
+
new Adaa1(
|
|
74
|
+
(x) => Math.abs(x),
|
|
75
|
+
(x) => 0.5 * x * Math.abs(x),
|
|
76
|
+
);
|
|
77
|
+
/** RAT diode pair: tanh of a cubic-boosted input (no closed-form F). */
|
|
78
|
+
export const ratClip = (x: number) => Math.tanh(x + 0.18 * x * x * x);
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stomp boxes (`fx.stomp`): fuzz (Big Muff family), face (Fuzz Face), od
|
|
3
|
+
* (Tube Screamer), rat (RAT) and octave (Octavia). Circuit-inspired
|
|
4
|
+
* per-sample models ported from the 0.6 guitar prototype, run inside one
|
|
5
|
+
* oversampler with ADAA shapers.
|
|
6
|
+
*/
|
|
7
|
+
import type { Adaa1 } from "../../dsp/shape.ts";
|
|
8
|
+
import { Dc, Hp1, Lp1, clamp, dbToGain } from "./filters.ts";
|
|
9
|
+
import {
|
|
10
|
+
adaaRectify,
|
|
11
|
+
adaaTanh,
|
|
12
|
+
adaaTriode,
|
|
13
|
+
ratClip,
|
|
14
|
+
runSection,
|
|
15
|
+
} from "./section.ts";
|
|
16
|
+
|
|
17
|
+
export const STOMP_TYPES = ["fuzz", "face", "od", "rat", "octave"] as const;
|
|
18
|
+
export type StompType = (typeof STOMP_TYPES)[number];
|
|
19
|
+
|
|
20
|
+
export type StompSettings = {
|
|
21
|
+
gain: number;
|
|
22
|
+
tone: number;
|
|
23
|
+
level: number;
|
|
24
|
+
octave: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A stomp circuit at the oversampled rate `sr`. */
|
|
28
|
+
export type StompCircuit = {
|
|
29
|
+
process(x: number): number;
|
|
30
|
+
/** Re-reads gain, tone and level (control rate, automated only). */
|
|
31
|
+
set(settings: StompSettings): void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function stompCircuit(
|
|
35
|
+
type: string,
|
|
36
|
+
initial: StompSettings,
|
|
37
|
+
sr: number,
|
|
38
|
+
): StompCircuit {
|
|
39
|
+
switch (type) {
|
|
40
|
+
case "fuzz":
|
|
41
|
+
return fuzz(initial, sr);
|
|
42
|
+
case "face":
|
|
43
|
+
return face(initial, sr);
|
|
44
|
+
case "rat":
|
|
45
|
+
return rat(initial, sr);
|
|
46
|
+
case "octave":
|
|
47
|
+
return octave(initial, sr);
|
|
48
|
+
default:
|
|
49
|
+
return od(initial, sr);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fuzz(initial: StompSettings, sr: number): StompCircuit {
|
|
54
|
+
// Input HPF, two cascaded clipping stages with interstage lowpass, then
|
|
55
|
+
// the passive LP/HP tone blend (the Muff's mid scoop).
|
|
56
|
+
const hp = new Hp1(90, sr);
|
|
57
|
+
const s1 = adaaTanh();
|
|
58
|
+
const s2 = adaaTanh();
|
|
59
|
+
const lpA = new Lp1(3200, sr);
|
|
60
|
+
const hpB = new Hp1(60, sr);
|
|
61
|
+
const lpB = new Lp1(2600, sr);
|
|
62
|
+
const toneLp = new Lp1(800, sr);
|
|
63
|
+
const toneHp = new Hp1(1600, sr);
|
|
64
|
+
let pre = 1;
|
|
65
|
+
let tone = 0.5;
|
|
66
|
+
let out = 1;
|
|
67
|
+
const set = (p: StompSettings) => {
|
|
68
|
+
pre = 2 ** (0.5 + 0.55 * p.gain);
|
|
69
|
+
tone = clamp(p.tone, 0, 1);
|
|
70
|
+
out = dbToGain(p.level) * 0.7;
|
|
71
|
+
};
|
|
72
|
+
set(initial);
|
|
73
|
+
return {
|
|
74
|
+
set,
|
|
75
|
+
process(input) {
|
|
76
|
+
let x = hp.process(input) * pre;
|
|
77
|
+
x = lpA.process(s1.process(x)) * 6;
|
|
78
|
+
x = lpB.process(s2.process(hpB.process(x)));
|
|
79
|
+
const y = (1 - tone) * toneLp.process(x) + tone * toneHp.process(x) * 1.6;
|
|
80
|
+
return y * out;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function face(initial: StompSettings, sr: number): StompCircuit {
|
|
86
|
+
// Asymmetric and round; cleans up as the input drops.
|
|
87
|
+
const hp = new Hp1(40, sr);
|
|
88
|
+
const s = adaaTriode();
|
|
89
|
+
const dc = new Dc(sr, 12);
|
|
90
|
+
const lp = new Lp1(2500, sr);
|
|
91
|
+
let pre = 1;
|
|
92
|
+
let out = 1;
|
|
93
|
+
const set = (p: StompSettings) => {
|
|
94
|
+
pre = 2 ** (0.3 + 0.5 * p.gain);
|
|
95
|
+
lp.set(2500 + 4000 * clamp(p.tone, 0, 1), sr);
|
|
96
|
+
out = dbToGain(p.level) * 0.8;
|
|
97
|
+
};
|
|
98
|
+
set(initial);
|
|
99
|
+
return {
|
|
100
|
+
set,
|
|
101
|
+
process(input) {
|
|
102
|
+
return lp.process(dc.process(s.process(hp.process(input) * pre))) * out;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function od(initial: StompSettings, sr: number): StompCircuit {
|
|
108
|
+
// Clean path plus a soft-clipped 720 Hz high-passed gain path (diodes in
|
|
109
|
+
// the op-amp feedback), then the tone lowpass.
|
|
110
|
+
const hp = new Hp1(720, sr);
|
|
111
|
+
const lpGain = new Lp1(5600, sr);
|
|
112
|
+
const s = adaaTanh();
|
|
113
|
+
const tone = new Lp1(700, sr);
|
|
114
|
+
let pre = 1;
|
|
115
|
+
let out = 1;
|
|
116
|
+
const set = (p: StompSettings) => {
|
|
117
|
+
pre = 1 + 10 ** (p.gain / 5);
|
|
118
|
+
tone.set(700 * 2 ** (3 * clamp(p.tone, 0, 1)), sr);
|
|
119
|
+
out = dbToGain(p.level) * 0.6;
|
|
120
|
+
};
|
|
121
|
+
set(initial);
|
|
122
|
+
return {
|
|
123
|
+
set,
|
|
124
|
+
process(x) {
|
|
125
|
+
const y = x + s.process(lpGain.process(hp.process(x) * pre)) * 0.5;
|
|
126
|
+
return tone.process(y) * out;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function rat(initial: StompSettings, sr: number): StompCircuit {
|
|
132
|
+
// Op-amp gain whose bandwidth shrinks with gain (LM308 GBW), hard diode
|
|
133
|
+
// clip to ground, then the passive filter.
|
|
134
|
+
const hp = new Hp1(60, sr);
|
|
135
|
+
const lpOp = new Lp1(20_000, sr);
|
|
136
|
+
const filter = new Lp1(475, sr);
|
|
137
|
+
let gain = 1;
|
|
138
|
+
let out = 1;
|
|
139
|
+
const set = (p: StompSettings) => {
|
|
140
|
+
gain = Math.min(1 + 2 ** (0.67 * p.gain + 1.5), 2000);
|
|
141
|
+
lpOp.set(clamp(1.0e6 / 3 / gain, 900, 20_000), sr);
|
|
142
|
+
filter.set(475 * 2 ** (6 * clamp(p.tone, 0, 1)), sr);
|
|
143
|
+
out = dbToGain(p.level) * 0.55;
|
|
144
|
+
};
|
|
145
|
+
set(initial);
|
|
146
|
+
return {
|
|
147
|
+
set,
|
|
148
|
+
process(input) {
|
|
149
|
+
const x = lpOp.process(hp.process(input) * gain * 0.02);
|
|
150
|
+
return filter.process(ratClip(x)) * out;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function octave(initial: StompSettings, sr: number): StompCircuit {
|
|
156
|
+
// Rectified (octave-up) path blended into a fuzz.
|
|
157
|
+
const hp = new Hp1(120, sr);
|
|
158
|
+
const rect: Adaa1 = adaaRectify();
|
|
159
|
+
const dc = new Dc(sr, 20);
|
|
160
|
+
const s = adaaTanh();
|
|
161
|
+
const lp = new Lp1(3000, sr);
|
|
162
|
+
let pre = 1;
|
|
163
|
+
let mix = 0.7;
|
|
164
|
+
let out = 1;
|
|
165
|
+
const set = (p: StompSettings) => {
|
|
166
|
+
pre = 2 ** (0.5 + 0.5 * p.gain);
|
|
167
|
+
lp.set(3000 + 4000 * clamp(p.tone, 0, 1), sr);
|
|
168
|
+
mix = clamp(p.octave, 0, 1);
|
|
169
|
+
out = dbToGain(p.level) * 0.7;
|
|
170
|
+
};
|
|
171
|
+
set(initial);
|
|
172
|
+
return {
|
|
173
|
+
set,
|
|
174
|
+
process(input) {
|
|
175
|
+
const x = hp.process(input);
|
|
176
|
+
const o = dc.process(rect.process(x * 2)) * 2;
|
|
177
|
+
return lp.process(s.process(((1 - mix) * x + mix * o) * pre)) * out;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Stomp calibration input: a 196 Hz (G3) sine at guitar level. */
|
|
183
|
+
export const STOMP_CALIBRATION_DBFS = -18;
|
|
184
|
+
|
|
185
|
+
const stompMakeups = new Map<string, number>();
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Linear gain that brings a pedal at `settings` (level taken as 0 dB) back
|
|
189
|
+
* to the calibration sine's level, so switching or kicking on a stomp does
|
|
190
|
+
* not jump the track by 15-25 dB; `level` stays a trim on top. Measured on
|
|
191
|
+
* the circuit itself, deterministic, cached per settings and rate.
|
|
192
|
+
*/
|
|
193
|
+
export function stompMakeup(
|
|
194
|
+
type: string,
|
|
195
|
+
settings: StompSettings,
|
|
196
|
+
sampleRate: number,
|
|
197
|
+
factor: 2 | 4,
|
|
198
|
+
): number {
|
|
199
|
+
const key = `${sampleRate}|${factor}|${type}|${settings.gain}|${settings.tone}|${settings.octave}`;
|
|
200
|
+
const known = stompMakeups.get(key);
|
|
201
|
+
if (known !== undefined) return known;
|
|
202
|
+
const amplitude = 10 ** (STOMP_CALIBRATION_DBFS / 20) * Math.SQRT2;
|
|
203
|
+
const settle = Math.round(sampleRate * 0.1);
|
|
204
|
+
const length = settle + Math.round(sampleRate * 0.1);
|
|
205
|
+
const buffer = new Float64Array(length);
|
|
206
|
+
const w = (2 * Math.PI * 196) / sampleRate;
|
|
207
|
+
for (let i = 0; i < length; i += 1) buffer[i] = amplitude * Math.sin(w * i);
|
|
208
|
+
const circuit = stompCircuit(
|
|
209
|
+
type,
|
|
210
|
+
{ ...settings, level: 0 },
|
|
211
|
+
sampleRate * factor,
|
|
212
|
+
);
|
|
213
|
+
runSection(buffer, factor, (x) => circuit.process(x));
|
|
214
|
+
let sum = 0;
|
|
215
|
+
for (let i = settle; i < length; i += 1) sum += buffer[i]! * buffer[i]!;
|
|
216
|
+
const rms = Math.sqrt(sum / (length - settle));
|
|
217
|
+
const target = 10 ** (STOMP_CALIBRATION_DBFS / 20);
|
|
218
|
+
const makeup = rms > 1e-12 ? target / rms : 1;
|
|
219
|
+
stompMakeups.set(key, makeup);
|
|
220
|
+
if (stompMakeups.size > 4096)
|
|
221
|
+
stompMakeups.delete(stompMakeups.keys().next().value!);
|
|
222
|
+
return makeup;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Makeup for an automated gain: linear between whole steps. */
|
|
226
|
+
export function stompMakeupAt(
|
|
227
|
+
type: string,
|
|
228
|
+
settings: StompSettings,
|
|
229
|
+
sampleRate: number,
|
|
230
|
+
factor: 2 | 4,
|
|
231
|
+
): number {
|
|
232
|
+
const g = clamp(settings.gain, 0, 10);
|
|
233
|
+
const g0 = Math.min(9, Math.floor(g));
|
|
234
|
+
const at = (gain: number) =>
|
|
235
|
+
stompMakeup(type, { ...settings, gain }, sampleRate, factor);
|
|
236
|
+
const a = at(g0);
|
|
237
|
+
return a + (at(g0 + 1) - a) * (g - g0);
|
|
238
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
OnePole,
|
|
17
17
|
Param,
|
|
18
18
|
interpolateAutomation,
|
|
19
|
+
tickAtSample,
|
|
19
20
|
type EffectContext,
|
|
20
21
|
} from "./common.ts";
|
|
21
22
|
|
|
@@ -49,7 +50,12 @@ export function applyDelay(
|
|
|
49
50
|
const feedbackLane = track.delayFeedbackAutomation ?? [];
|
|
50
51
|
const mixLane = track.delayMixAutomation ?? [];
|
|
51
52
|
if (delay.mix <= 0 && mixLane.length === 0) return;
|
|
52
|
-
const { sampleRate
|
|
53
|
+
const { sampleRate } = context;
|
|
54
|
+
const synced = !(delay.time !== undefined && delay.time > 0);
|
|
55
|
+
if (context.warp && synced) {
|
|
56
|
+
applyWarpedDelay(left, right, track, context, wetOnly);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
53
59
|
const length = Math.max(
|
|
54
60
|
1,
|
|
55
61
|
Math.round(delaySeconds(track, context.tempoBpm) * sampleRate),
|
|
@@ -69,7 +75,7 @@ export function applyDelay(
|
|
|
69
75
|
];
|
|
70
76
|
for (let index = 0; index < left.length; index += 1) {
|
|
71
77
|
if (automated && index % CONTROL_SAMPLES === 0) {
|
|
72
|
-
const tick = index
|
|
78
|
+
const tick = tickAtSample(context, index);
|
|
73
79
|
feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
|
|
74
80
|
mix = interpolateAutomation(mixLane, tick, delay.mix);
|
|
75
81
|
}
|
|
@@ -98,6 +104,89 @@ export function applyDelay(
|
|
|
98
104
|
}
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The beat-synced delay under a tempo map: the read head trails the write
|
|
109
|
+
* head by `beats` of score time at every control block, read with linear
|
|
110
|
+
* interpolation, so each echo lands `beats` later on the grid through tempo
|
|
111
|
+
* steps, ramps and fermatas. Same routing and lanes as `applyDelay`.
|
|
112
|
+
*/
|
|
113
|
+
function applyWarpedDelay(
|
|
114
|
+
left: Float64Array,
|
|
115
|
+
right: Float64Array,
|
|
116
|
+
track: Track,
|
|
117
|
+
context: EffectContext,
|
|
118
|
+
wetOnly: boolean,
|
|
119
|
+
): void {
|
|
120
|
+
const delay = track.delay!;
|
|
121
|
+
const warp = context.warp!;
|
|
122
|
+
const { sampleRate } = context;
|
|
123
|
+
const feedbackLane = track.delayFeedbackAutomation ?? [];
|
|
124
|
+
const mixLane = track.delayMixAutomation ?? [];
|
|
125
|
+
// Ticks per beat, recovered from the context's start-tempo sample rate.
|
|
126
|
+
const ticksPerBeat = Math.round(
|
|
127
|
+
(sampleRate * 60) / (context.tempoBpm * context.samplesPerTick),
|
|
128
|
+
);
|
|
129
|
+
const lag = delay.beats * ticksPerBeat;
|
|
130
|
+
const lagAt = (index: number) =>
|
|
131
|
+
Math.max(2, index - warp.sample(warp.tick(index) - lag));
|
|
132
|
+
// Longest lag over the buffer sizes the lines.
|
|
133
|
+
let longest = 1;
|
|
134
|
+
for (let index = 0; index <= left.length; index += CONTROL_SAMPLES)
|
|
135
|
+
longest = Math.max(longest, lagAt(index));
|
|
136
|
+
const size = Math.ceil(longest) + 2;
|
|
137
|
+
const lineL = new Float64Array(size);
|
|
138
|
+
const lineR = new Float64Array(size);
|
|
139
|
+
const pingpong = delay.pingpong === true;
|
|
140
|
+
const highcut =
|
|
141
|
+
delay.highcut === undefined
|
|
142
|
+
? undefined
|
|
143
|
+
: [
|
|
144
|
+
new OnePole(delay.highcut, sampleRate),
|
|
145
|
+
new OnePole(delay.highcut, sampleRate),
|
|
146
|
+
];
|
|
147
|
+
let feedback = delay.feedback;
|
|
148
|
+
let mix = delay.mix;
|
|
149
|
+
let lagNow = lagAt(0);
|
|
150
|
+
let lagStep = 0;
|
|
151
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
152
|
+
if (index % CONTROL_SAMPLES === 0) {
|
|
153
|
+
const tick = tickAtSample(context, index);
|
|
154
|
+
feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
|
|
155
|
+
mix = interpolateAutomation(mixLane, tick, delay.mix);
|
|
156
|
+
lagNow = lagAt(index);
|
|
157
|
+
lagStep = (lagAt(index + CONTROL_SAMPLES) - lagNow) / CONTROL_SAMPLES;
|
|
158
|
+
} else lagNow += lagStep;
|
|
159
|
+
const read = index - Math.min(size - 2, lagNow);
|
|
160
|
+
const base = Math.floor(read);
|
|
161
|
+
const fraction = read - base;
|
|
162
|
+
const at = (line: Float64Array, position: number) =>
|
|
163
|
+
position < 0 ? 0 : line[position % size]!;
|
|
164
|
+
const wetL =
|
|
165
|
+
at(lineL, base) * (1 - fraction) + at(lineL, base + 1) * fraction;
|
|
166
|
+
const wetR =
|
|
167
|
+
at(lineR, base) * (1 - fraction) + at(lineR, base + 1) * fraction;
|
|
168
|
+
const dryL = left[index]!;
|
|
169
|
+
const dryR = right[index]!;
|
|
170
|
+
let writeL: number;
|
|
171
|
+
let writeR: number;
|
|
172
|
+
if (pingpong) {
|
|
173
|
+
writeL = (dryL + dryR) * 0.5 + wetR * feedback;
|
|
174
|
+
writeR = wetL * feedback;
|
|
175
|
+
} else {
|
|
176
|
+
writeL = dryL + wetR * feedback;
|
|
177
|
+
writeR = dryR + wetL * feedback;
|
|
178
|
+
}
|
|
179
|
+
if (highcut) {
|
|
180
|
+
writeL = highcut[0]!.process(writeL);
|
|
181
|
+
writeR = highcut[1]!.process(writeR);
|
|
182
|
+
}
|
|
183
|
+
lineL[index % size] = writeL;
|
|
184
|
+
lineR[index % size] = writeR;
|
|
185
|
+
left[index] = (wetOnly ? 0 : dryL) + wetL * mix;
|
|
186
|
+
right[index] = (wetOnly ? 0 : dryR) + wetR * mix;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
101
190
|
/** Seconds until the track's delay decays below -60 dB, bounded. */
|
|
102
191
|
export function delayTailFor(track: Track, tempoBpm: number): number {
|
|
103
192
|
if (!track.delay) return 0;
|
|
@@ -211,6 +300,7 @@ function mixParamOf(track: Track, context: EffectContext): Param {
|
|
|
211
300
|
track.reverb?.mix ?? 0,
|
|
212
301
|
track.fxAutomation?.["reverb-mix"],
|
|
213
302
|
context.samplesPerTick,
|
|
303
|
+
context.warp,
|
|
214
304
|
);
|
|
215
305
|
}
|
|
216
306
|
|
package/src/audio/engine.ts
CHANGED
|
@@ -2,10 +2,19 @@ import type { TrackScore } from "../../core/score.ts";
|
|
|
2
2
|
import { PlaybackLock } from "./lock.ts";
|
|
3
3
|
import { LoopPlayer } from "./player.ts";
|
|
4
4
|
import { LoopRenderer, type LoopRender } from "./renderer.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
clickSounds,
|
|
7
|
+
clicksIn,
|
|
8
|
+
type ClickGrid,
|
|
9
|
+
type ClickLevel,
|
|
10
|
+
} from "./click.ts";
|
|
6
11
|
import type { LiveNotePcm } from "./live.ts";
|
|
12
|
+
import { measureLoudness, pcmChannels } from "./loudness.ts";
|
|
13
|
+
import type { MasterReport } from "./master.ts";
|
|
7
14
|
import { levelOf, type SoundLevel } from "./preview.ts";
|
|
8
15
|
import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS } from "./wav.ts";
|
|
16
|
+
import { playbackTime } from "./arrange.ts";
|
|
17
|
+
import { transportMapFor, type TransportMap } from "./clock.ts";
|
|
9
18
|
|
|
10
19
|
/**
|
|
11
20
|
* How dawgd (or a file-mode window) makes sound.
|
|
@@ -237,8 +246,35 @@ type Loop = Readonly<{
|
|
|
237
246
|
pcm: Int16Array;
|
|
238
247
|
frames: number;
|
|
239
248
|
framesPerBeat: number;
|
|
249
|
+
/** The song's tempo map; absent, a beat is `framesPerBeat` frames. */
|
|
250
|
+
map?: TransportMap;
|
|
251
|
+
sampleRate: number;
|
|
240
252
|
}>;
|
|
241
253
|
|
|
254
|
+
/** Loop frame (unwrapped without a map) where transport `beat` sounds. */
|
|
255
|
+
function frameOfBeat(loop: Loop, beat: number): number {
|
|
256
|
+
const map = loop.map;
|
|
257
|
+
if (!map) return beat * loop.framesPerBeat;
|
|
258
|
+
const inner = ((beat % map.loopBeats) + map.loopBeats) % map.loopBeats;
|
|
259
|
+
return map.seconds(inner) * loop.sampleRate;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Transport beat at loop `frame` (inside one pass with a map). */
|
|
263
|
+
function beatOfFrame(loop: Loop, frame: number): number {
|
|
264
|
+
const map = loop.map;
|
|
265
|
+
if (!map) return frame / loop.framesPerBeat;
|
|
266
|
+
const seconds = frame / loop.sampleRate;
|
|
267
|
+
const inner =
|
|
268
|
+
((seconds % map.loopSeconds) + map.loopSeconds) % map.loopSeconds;
|
|
269
|
+
return map.beat(inner);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The beat `frames` stream frames after `beat`. */
|
|
273
|
+
function beatAfter(loop: Loop, beat: number, frames: number): number {
|
|
274
|
+
if (!loop.map) return beat + frames / loop.framesPerBeat;
|
|
275
|
+
return beatOfFrame(loop, frameOfBeat(loop, beat) + frames);
|
|
276
|
+
}
|
|
277
|
+
|
|
242
278
|
/** A loop fading out after a swap, read on from where it was. */
|
|
243
279
|
type FadeOut = {
|
|
244
280
|
loop: Loop;
|
|
@@ -259,6 +295,8 @@ export type ClickBus = Readonly<{
|
|
|
259
295
|
volume: number;
|
|
260
296
|
beatsPerBar: number;
|
|
261
297
|
subdivision: number;
|
|
298
|
+
/** Meter-aware clicks (core/tempo.ts) replacing the fixed grid. */
|
|
299
|
+
grid?: ClickGrid;
|
|
262
300
|
beatAt: (monotonicMs: number) => number | undefined;
|
|
263
301
|
}>;
|
|
264
302
|
|
|
@@ -334,6 +372,9 @@ export class AudioEngine {
|
|
|
334
372
|
/** The loop last swapped in, for `level` (measured on demand, once). */
|
|
335
373
|
private lastLoopPcm: Int16Array | undefined;
|
|
336
374
|
private measured: { pcm: Int16Array; level: SoundLevel } | undefined;
|
|
375
|
+
private loudnessOf: { pcm: Int16Array; report: MasterReport } | undefined;
|
|
376
|
+
/** Loudness the song master reached on the loop now playing. */
|
|
377
|
+
private lastMaster: MasterReport | undefined;
|
|
337
378
|
private respawns = 0;
|
|
338
379
|
private respawnTimer: ReturnType<typeof setTimeout> | undefined;
|
|
339
380
|
/** Play mode: keep a player running without a loop for live voices. */
|
|
@@ -421,6 +462,33 @@ export class AudioEngine {
|
|
|
421
462
|
return this.measured.level;
|
|
422
463
|
}
|
|
423
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Integrated loudness and true peak of the loop now playing, after the
|
|
467
|
+
* song master when there is one (the header meter). Without a master the
|
|
468
|
+
* loop's PCM is measured once per render, on first read. Undefined before
|
|
469
|
+
* any render.
|
|
470
|
+
*/
|
|
471
|
+
public get loudness(): MasterReport | undefined {
|
|
472
|
+
const pcm = this.lastLoopPcm;
|
|
473
|
+
if (!pcm) return undefined;
|
|
474
|
+
if (this.lastMaster) return this.lastMaster;
|
|
475
|
+
if (this.loudnessOf?.pcm !== pcm) {
|
|
476
|
+
const [left, right] = pcmChannels(pcm);
|
|
477
|
+
const measured = measureLoudness(left, right, this.sampleRate, {
|
|
478
|
+
loop: true,
|
|
479
|
+
});
|
|
480
|
+
this.loudnessOf = {
|
|
481
|
+
pcm,
|
|
482
|
+
report: {
|
|
483
|
+
gainDb: 0,
|
|
484
|
+
integrated: measured.integrated,
|
|
485
|
+
truePeak: measured.truePeak,
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
return this.loudnessOf.report;
|
|
490
|
+
}
|
|
491
|
+
|
|
424
492
|
public get canMonitor(): boolean {
|
|
425
493
|
return this.info.backend !== "none" && !this.fallback;
|
|
426
494
|
}
|
|
@@ -462,13 +530,14 @@ export class AudioEngine {
|
|
|
462
530
|
existing.pcm = note.pcm;
|
|
463
531
|
existing.frames = note.frames;
|
|
464
532
|
existing.fadeLeft = undefined;
|
|
533
|
+
existing.fadeFrames = this.releaseFrames(note);
|
|
465
534
|
} else
|
|
466
535
|
this.voices.set(id, {
|
|
467
536
|
pcm: note.pcm,
|
|
468
537
|
frames: note.frames,
|
|
469
538
|
position: 0,
|
|
470
539
|
fadeLeft: undefined,
|
|
471
|
-
fadeFrames:
|
|
540
|
+
fadeFrames: this.releaseFrames(note),
|
|
472
541
|
});
|
|
473
542
|
// Write what is due now so the voice joins the very next chunk.
|
|
474
543
|
const first = this.written;
|
|
@@ -476,6 +545,14 @@ export class AudioEngine {
|
|
|
476
545
|
return this.frameMs(first);
|
|
477
546
|
}
|
|
478
547
|
|
|
548
|
+
/** A live voice's note-off fade: 10 ms, or the instrument's release. */
|
|
549
|
+
private releaseFrames(note: LiveNotePcm): number {
|
|
550
|
+
return Math.max(
|
|
551
|
+
1,
|
|
552
|
+
Math.round(this.sampleRate * (note.releaseSeconds ?? 0.01)),
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
479
556
|
/** Fade a live voice out over ~10 ms (sustain lifted, mode left). */
|
|
480
557
|
public noteOff(id: number): void {
|
|
481
558
|
const voice = this.voices.get(id);
|
|
@@ -552,6 +629,7 @@ export class AudioEngine {
|
|
|
552
629
|
if (request.generation === this.generation) {
|
|
553
630
|
await this.apply(this.toLoop(render, request.score), request);
|
|
554
631
|
this.lastLoopPcm = render.pcm;
|
|
632
|
+
this.lastMaster = render.master;
|
|
555
633
|
}
|
|
556
634
|
for (const waiter of request.waiters) waiter.resolve();
|
|
557
635
|
} catch (error) {
|
|
@@ -571,10 +649,11 @@ export class AudioEngine {
|
|
|
571
649
|
const beat =
|
|
572
650
|
request.beat === undefined
|
|
573
651
|
? undefined
|
|
574
|
-
:
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
652
|
+
: beatAfter(
|
|
653
|
+
loop,
|
|
654
|
+
request.beat,
|
|
655
|
+
((this.now() - request.atMs) * this.sampleRate) / 1000,
|
|
656
|
+
);
|
|
578
657
|
if (this.child) {
|
|
579
658
|
this.swap(loop, beat);
|
|
580
659
|
return;
|
|
@@ -595,19 +674,10 @@ export class AudioEngine {
|
|
|
595
674
|
if (!this.loop || !this.child) return;
|
|
596
675
|
this.cursor = this.frameForBeat(
|
|
597
676
|
this.loop,
|
|
598
|
-
beat
|
|
677
|
+
beatAfter(this.loop, beat, this.queuedFrames()),
|
|
599
678
|
);
|
|
600
679
|
}
|
|
601
680
|
|
|
602
|
-
/**
|
|
603
|
-
* Beats already queued ahead of the wall clock. The next frame written
|
|
604
|
-
* sounds this far after now, so a transport beat maps to the write head
|
|
605
|
-
* by adding it.
|
|
606
|
-
*/
|
|
607
|
-
private queuedBeats(loop: Loop): number {
|
|
608
|
-
return this.queuedFrames() / loop.framesPerBeat;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
681
|
private queuedFrames(): number {
|
|
612
682
|
const elapsed = Math.floor(
|
|
613
683
|
((this.now() - this.startMs) * this.sampleRate) / 1000,
|
|
@@ -622,6 +692,7 @@ export class AudioEngine {
|
|
|
622
692
|
public async stopAsync(): Promise<void> {
|
|
623
693
|
this.generation += 1;
|
|
624
694
|
this.lastLoopPcm = undefined;
|
|
695
|
+
this.lastMaster = undefined;
|
|
625
696
|
this.respawns = 0;
|
|
626
697
|
if (this.respawnTimer) clearTimeout(this.respawnTimer);
|
|
627
698
|
this.respawnTimer = undefined;
|
|
@@ -813,15 +884,19 @@ export class AudioEngine {
|
|
|
813
884
|
}
|
|
814
885
|
|
|
815
886
|
private toLoop(render: LoopRender, score: TrackScore): Loop {
|
|
887
|
+
const timeline = playbackTime(score);
|
|
888
|
+
const map = transportMapFor(timeline);
|
|
816
889
|
return {
|
|
817
890
|
pcm: render.pcm,
|
|
818
891
|
frames: render.frames,
|
|
819
|
-
framesPerBeat: (60 * this.sampleRate) /
|
|
892
|
+
framesPerBeat: (60 * this.sampleRate) / timeline.tempoBpm,
|
|
893
|
+
sampleRate: this.sampleRate,
|
|
894
|
+
...(map ? { map } : {}),
|
|
820
895
|
};
|
|
821
896
|
}
|
|
822
897
|
|
|
823
898
|
private frameForBeat(loop: Loop, beat: number): number {
|
|
824
|
-
const frame = Math.round(Math.max(0, beat)
|
|
899
|
+
const frame = Math.round(frameOfBeat(loop, Math.max(0, beat)));
|
|
825
900
|
return ((frame % loop.frames) + loop.frames) % loop.frames;
|
|
826
901
|
}
|
|
827
902
|
|
|
@@ -829,12 +904,16 @@ export class AudioEngine {
|
|
|
829
904
|
const previous = this.loop;
|
|
830
905
|
// Keep the musical position: the same beat inside the new loop.
|
|
831
906
|
let cursor = previous
|
|
832
|
-
? this.frameForBeat(next, this.cursor
|
|
907
|
+
? this.frameForBeat(next, beatOfFrame(previous, this.cursor))
|
|
833
908
|
: 0;
|
|
834
909
|
if (beat !== undefined) {
|
|
835
910
|
// Re-anchor to the transport only when it disagrees audibly (a seek
|
|
836
911
|
// or a tempo change); sub-frame rounding must not click on edits.
|
|
837
|
-
|
|
912
|
+
// The next frame written sounds `queuedFrames` after now.
|
|
913
|
+
const anchored = this.frameForBeat(
|
|
914
|
+
next,
|
|
915
|
+
beatAfter(next, beat, this.queuedFrames()),
|
|
916
|
+
);
|
|
838
917
|
const distance = Math.abs(anchored - cursor);
|
|
839
918
|
const wrapped = Math.min(distance, next.frames - distance);
|
|
840
919
|
if (!previous || wrapped > this.sampleRate * RESYNC_SECONDS)
|
|
@@ -920,7 +999,7 @@ export class AudioEngine {
|
|
|
920
999
|
if (generation !== this.generation || this.child || this.starting)
|
|
921
1000
|
return;
|
|
922
1001
|
const frame = heard + ((this.now() - diedAt) * this.sampleRate) / 1000;
|
|
923
|
-
const beat = loop ? frame
|
|
1002
|
+
const beat = loop ? beatOfFrame(loop, frame) : 0;
|
|
924
1003
|
this.starting = this.start(loop, beat).finally(() => {
|
|
925
1004
|
this.starting = undefined;
|
|
926
1005
|
});
|