@hraness/dawg 0.3.0 → 0.4.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 +138 -0
- package/DAWG.md +608 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +109 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +339 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +394 -0
- package/src/audio/engine.ts +112 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +481 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +411 -295
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1222 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +521 -0
- package/src/tui/euclid.ts +516 -0
- package/src/tui/menu.ts +1305 -244
- package/src/tui/play-chords.ts +630 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +286 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk cache budgets and LRU pruning for pack downloads and decoded assets.
|
|
3
|
+
*
|
|
4
|
+
* Two caches hold audio on disk:
|
|
5
|
+
*
|
|
6
|
+
* - raw pack files, `~/.cache/dawg/packs/files/<sha256(url)>.bin`, shared by
|
|
7
|
+
* every project (default cap `DEFAULT_PACKS_CACHE_BYTES`, env
|
|
8
|
+
* `DAWG_PACKS_CACHE_MAX`);
|
|
9
|
+
* - decoded PCM, `<project>/.dawg/assets/<sha256>.pcm`, per project (default
|
|
10
|
+
* cap `DEFAULT_ASSETS_CACHE_BYTES`, env `DAWG_ASSETS_CACHE_MAX`).
|
|
11
|
+
*
|
|
12
|
+
* Both evict least recently used files first (hits refresh the mtime) and
|
|
13
|
+
* never evict a protected file: the ones the open project pins. An evicted
|
|
14
|
+
* pack file is fetched again by its pinned URL and checked against its
|
|
15
|
+
* pinned sha256, so eviction only ever costs a download.
|
|
16
|
+
*/
|
|
17
|
+
import { readdir, stat, unlink } from "node:fs/promises";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { SCORE_LIMITS } from "../../core/score.ts";
|
|
20
|
+
|
|
21
|
+
const MiB = 1024 * 1024;
|
|
22
|
+
const GiB = 1024 * MiB;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Raw pack downloads. 2 GiB holds every file a typical user touches across
|
|
26
|
+
* the built-in packs (a whole drum-machine bank is a few MiB, a GM
|
|
27
|
+
* instrument 1–4 MiB of MP3, a uzu wavetable 1–2 MiB) with room to spare,
|
|
28
|
+
* and stays small next to a browser cache on a laptop disk.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_PACKS_CACHE_BYTES = 2 * GiB;
|
|
31
|
+
/**
|
|
32
|
+
* Decoded float32 PCM per project. Decoding grows audio 2× (16-bit WAV) to
|
|
33
|
+
* ~10× (MP3), so 1 GiB is about 100 minutes of mono 44.1 kHz audio: far
|
|
34
|
+
* more than a project plays, while a few projects cannot fill a disk.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_ASSETS_CACHE_BYTES = 1 * GiB;
|
|
37
|
+
/** Decoded samples kept in memory by one `SampleLibrary`. */
|
|
38
|
+
export const DEFAULT_MEMORY_CACHE_BYTES = SCORE_LIMITS.maxSampleCacheBytes;
|
|
39
|
+
|
|
40
|
+
const SIZE =
|
|
41
|
+
/^\s*([0-9]+(?:\.[0-9]+)?)\s*(b|k|kb|kib|m|mb|mib|g|gb|gib|t|tb|tib)?\s*$/i;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Bytes from `"2GiB"`, `"512M"`, `"1.5g"`, `"1048576"` (units are binary);
|
|
45
|
+
* undefined when malformed or negative.
|
|
46
|
+
*/
|
|
47
|
+
export function parseByteSize(value: string): number | undefined {
|
|
48
|
+
const match = SIZE.exec(value);
|
|
49
|
+
if (!match) return undefined;
|
|
50
|
+
const amount = Number(match[1]);
|
|
51
|
+
const unit = (match[2] ?? "b").toLowerCase()[0]!;
|
|
52
|
+
const scale =
|
|
53
|
+
unit === "k"
|
|
54
|
+
? 1024
|
|
55
|
+
: unit === "m"
|
|
56
|
+
? MiB
|
|
57
|
+
: unit === "g"
|
|
58
|
+
? GiB
|
|
59
|
+
: unit === "t"
|
|
60
|
+
? 1024 * GiB
|
|
61
|
+
: 1;
|
|
62
|
+
const bytes = Math.floor(amount * scale);
|
|
63
|
+
return Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fromEnv(
|
|
67
|
+
name: string,
|
|
68
|
+
fallback: number,
|
|
69
|
+
env: NodeJS.ProcessEnv,
|
|
70
|
+
): number {
|
|
71
|
+
const raw = env[name];
|
|
72
|
+
if (raw === undefined || raw.trim() === "") return fallback;
|
|
73
|
+
return parseByteSize(raw) ?? fallback;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Raw pack file cap: `DAWG_PACKS_CACHE_MAX` or 2 GiB. */
|
|
77
|
+
export function packsCacheMax(env: NodeJS.ProcessEnv = process.env): number {
|
|
78
|
+
return fromEnv("DAWG_PACKS_CACHE_MAX", DEFAULT_PACKS_CACHE_BYTES, env);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Decoded asset cap: `DAWG_ASSETS_CACHE_MAX` or 1 GiB. */
|
|
82
|
+
export function assetsCacheMax(env: NodeJS.ProcessEnv = process.env): number {
|
|
83
|
+
return fromEnv("DAWG_ASSETS_CACHE_MAX", DEFAULT_ASSETS_CACHE_BYTES, env);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type CacheUsage = Readonly<{ files: number; bytes: number }>;
|
|
87
|
+
|
|
88
|
+
type Entry = { name: string; path: string; bytes: number; used: number };
|
|
89
|
+
|
|
90
|
+
async function entries(dir: string, pattern: RegExp): Promise<Entry[]> {
|
|
91
|
+
const names = await readdir(dir).catch(() => [] as string[]);
|
|
92
|
+
const out: Entry[] = [];
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
if (!pattern.test(name)) continue;
|
|
95
|
+
const path = join(dir, name);
|
|
96
|
+
const info = await stat(path).catch(() => undefined);
|
|
97
|
+
if (info?.isFile())
|
|
98
|
+
out.push({ name, path, bytes: info.size, used: info.mtimeMs });
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Files and bytes of the cache entries in `dir` matching `pattern`. */
|
|
104
|
+
export async function cacheUsage(
|
|
105
|
+
dir: string,
|
|
106
|
+
pattern: RegExp,
|
|
107
|
+
): Promise<CacheUsage> {
|
|
108
|
+
const list = await entries(dir, pattern);
|
|
109
|
+
return {
|
|
110
|
+
files: list.length,
|
|
111
|
+
bytes: list.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type PruneResult = Readonly<{
|
|
116
|
+
removed: number;
|
|
117
|
+
freed: number;
|
|
118
|
+
/** Bytes left, protected files included. */
|
|
119
|
+
bytes: number;
|
|
120
|
+
}>;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Deletes least recently used entries until `dir` holds at most `maxBytes`.
|
|
124
|
+
* `keep(name)` protects an entry (the open project's pins, the file being
|
|
125
|
+
* written); protected entries count toward the total but are never removed,
|
|
126
|
+
* so a project larger than the cap keeps everything it uses.
|
|
127
|
+
*/
|
|
128
|
+
export async function pruneLru(
|
|
129
|
+
dir: string,
|
|
130
|
+
pattern: RegExp,
|
|
131
|
+
maxBytes: number,
|
|
132
|
+
keep: (name: string) => boolean = () => false,
|
|
133
|
+
): Promise<PruneResult> {
|
|
134
|
+
const list = await entries(dir, pattern);
|
|
135
|
+
let total = list.reduce((sum, entry) => sum + entry.bytes, 0);
|
|
136
|
+
list.sort((a, b) => a.used - b.used || a.name.localeCompare(b.name));
|
|
137
|
+
let removed = 0;
|
|
138
|
+
let freed = 0;
|
|
139
|
+
for (const entry of list) {
|
|
140
|
+
if (total <= maxBytes) break;
|
|
141
|
+
if (keep(entry.name)) continue;
|
|
142
|
+
try {
|
|
143
|
+
await unlink(entry.path);
|
|
144
|
+
} catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
total -= entry.bytes;
|
|
148
|
+
freed += entry.bytes;
|
|
149
|
+
removed += 1;
|
|
150
|
+
}
|
|
151
|
+
return { removed, freed, bytes: total };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `1.5 GiB`, `320 MiB`, `12 KiB`. */
|
|
155
|
+
export function formatBytes(bytes: number): string {
|
|
156
|
+
if (bytes >= GiB) return `${Math.round((bytes / GiB) * 10) / 10} GiB`;
|
|
157
|
+
if (bytes >= MiB) return `${Math.round((bytes / MiB) * 10) / 10} MiB`;
|
|
158
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KiB`;
|
|
159
|
+
return `${bytes} B`;
|
|
160
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared orbit buses (Strudel orbits): tracks whose `fx.orbit` sets
|
|
3
|
+
* `shared` send into one delay and one reverb per orbit instead of running
|
|
4
|
+
* their own.
|
|
5
|
+
*
|
|
6
|
+
* Each member's finished stem (its chain up to, not including, delay and
|
|
7
|
+
* reverb) is sent to the bus delay at its `delay.mix` and to the bus reverb
|
|
8
|
+
* at its `reverb.mix`, both following their mix lanes. The bus delay takes
|
|
9
|
+
* its time, feedback, ping-pong and highcut from the first member in score
|
|
10
|
+
* order that has a delay; the bus reverb takes its settings (size, fade,
|
|
11
|
+
* impulse...) from the first member with a reverb. The two run in parallel
|
|
12
|
+
* and their wet outputs join the mix after every stem, in orbit order. Both
|
|
13
|
+
* are linear, so a bus with one member sounds like that track's own delay
|
|
14
|
+
* and reverb run in parallel.
|
|
15
|
+
*/
|
|
16
|
+
import type { Track } from "../../../core/score.ts";
|
|
17
|
+
import {
|
|
18
|
+
CONTROL_SAMPLES,
|
|
19
|
+
interpolateAutomation,
|
|
20
|
+
type EffectContext,
|
|
21
|
+
} from "./common.ts";
|
|
22
|
+
import { applyDelay, applyReverb } from "./space.ts";
|
|
23
|
+
|
|
24
|
+
/** The orbit whose shared bus a track sends to, if any. */
|
|
25
|
+
export function sharedOrbitOf(track: Track | undefined): number | undefined {
|
|
26
|
+
const orbit = track?.fx?.orbit;
|
|
27
|
+
return orbit?.shared === true ? Number(orbit.orbit) : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type OrbitBus = {
|
|
31
|
+
readonly orbit: number;
|
|
32
|
+
delayLead?: Track;
|
|
33
|
+
reverbLead?: Track;
|
|
34
|
+
readonly delayL: Float64Array;
|
|
35
|
+
readonly delayR: Float64Array;
|
|
36
|
+
readonly reverbL: Float64Array;
|
|
37
|
+
readonly reverbR: Float64Array;
|
|
38
|
+
/** Member stem keys in send order: the bus output's cache key. */
|
|
39
|
+
readonly keys: string[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function createOrbitBus(orbit: number, samples: number): OrbitBus {
|
|
43
|
+
return {
|
|
44
|
+
orbit,
|
|
45
|
+
delayL: new Float64Array(samples),
|
|
46
|
+
delayR: new Float64Array(samples),
|
|
47
|
+
reverbL: new Float64Array(samples),
|
|
48
|
+
reverbR: new Float64Array(samples),
|
|
49
|
+
keys: [],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Adds `level(t)·stem` into a send pair; the level steps per control block. */
|
|
54
|
+
function send(
|
|
55
|
+
outL: Float64Array,
|
|
56
|
+
outR: Float64Array,
|
|
57
|
+
stemL: Float64Array,
|
|
58
|
+
stemR: Float64Array,
|
|
59
|
+
base: number,
|
|
60
|
+
lane: readonly Readonly<{ tick: number; value: number }>[],
|
|
61
|
+
samplesPerTick: number,
|
|
62
|
+
): void {
|
|
63
|
+
if (base <= 0 && lane.length === 0) return;
|
|
64
|
+
let level = base;
|
|
65
|
+
for (let index = 0; index < outL.length; index += 1) {
|
|
66
|
+
if (lane.length > 0 && index % CONTROL_SAMPLES === 0)
|
|
67
|
+
level = interpolateAutomation(lane, index / samplesPerTick, base);
|
|
68
|
+
outL[index]! += stemL[index]! * level;
|
|
69
|
+
outR[index]! += stemR[index]! * level;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Sends one member's stem to its bus. */
|
|
74
|
+
export function sendToBus(
|
|
75
|
+
bus: OrbitBus,
|
|
76
|
+
track: Track,
|
|
77
|
+
stemL: Float64Array,
|
|
78
|
+
stemR: Float64Array,
|
|
79
|
+
context: EffectContext,
|
|
80
|
+
key: string,
|
|
81
|
+
): void {
|
|
82
|
+
bus.keys.push(key);
|
|
83
|
+
if (track.delay) {
|
|
84
|
+
bus.delayLead ??= track;
|
|
85
|
+
send(
|
|
86
|
+
bus.delayL,
|
|
87
|
+
bus.delayR,
|
|
88
|
+
stemL,
|
|
89
|
+
stemR,
|
|
90
|
+
track.delay.mix,
|
|
91
|
+
track.delayMixAutomation ?? [],
|
|
92
|
+
context.samplesPerTick,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (track.reverb) {
|
|
96
|
+
bus.reverbLead ??= track;
|
|
97
|
+
send(
|
|
98
|
+
bus.reverbL,
|
|
99
|
+
bus.reverbR,
|
|
100
|
+
stemL,
|
|
101
|
+
stemR,
|
|
102
|
+
track.reverb.mix,
|
|
103
|
+
track.fxAutomation?.["reverb-mix"] ?? [],
|
|
104
|
+
context.samplesPerTick,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Runs the bus delay and reverb on their sends, wet only, into a fresh
|
|
111
|
+
* stereo pair (the send buffers are consumed).
|
|
112
|
+
*/
|
|
113
|
+
export function renderOrbitBus(
|
|
114
|
+
bus: OrbitBus,
|
|
115
|
+
context: EffectContext,
|
|
116
|
+
): { left: Float64Array; right: Float64Array } {
|
|
117
|
+
const left = new Float64Array(bus.delayL.length);
|
|
118
|
+
const right = new Float64Array(bus.delayL.length);
|
|
119
|
+
const lead = bus.delayLead;
|
|
120
|
+
if (lead?.delay) {
|
|
121
|
+
const unity: Track = {
|
|
122
|
+
...lead,
|
|
123
|
+
delay: { ...lead.delay, mix: 1 },
|
|
124
|
+
delayMixAutomation: [],
|
|
125
|
+
};
|
|
126
|
+
applyDelay(bus.delayL, bus.delayR, unity, context, true);
|
|
127
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
128
|
+
left[index]! += bus.delayL[index]!;
|
|
129
|
+
right[index]! += bus.delayR[index]!;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const room = bus.reverbLead;
|
|
133
|
+
if (room?.reverb) {
|
|
134
|
+
const { "reverb-mix": _lane, ...fxAutomation } = room.fxAutomation ?? {};
|
|
135
|
+
const unity: Track = {
|
|
136
|
+
...room,
|
|
137
|
+
reverb: { ...room.reverb, mix: 1 },
|
|
138
|
+
fxAutomation,
|
|
139
|
+
};
|
|
140
|
+
applyReverb(bus.reverbL, bus.reverbR, unity, context, true);
|
|
141
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
142
|
+
left[index]! += bus.reverbL[index]!;
|
|
143
|
+
right[index]! += bus.reverbR[index]!;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { left, right };
|
|
147
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fixed per-track effects chain (`FX_CHAIN` in core/fx.ts):
|
|
3
|
+
*
|
|
4
|
+
* filter → djf → autofilter → vowel → crush → distort → tremolo →
|
|
5
|
+
* compressor → pan → phaser → chorus → leslie → postgain → delay → reverb
|
|
6
|
+
*
|
|
7
|
+
* Stages before pan run on the mono voice sum; pan spreads it to stereo;
|
|
8
|
+
* the rest run on the stereo pair. Disabled stages cost nothing.
|
|
9
|
+
*/
|
|
10
|
+
import { FX_CHAIN, type FxName, type FxValues } from "../../../core/fx.ts";
|
|
11
|
+
import type { Track } from "../../../core/score.ts";
|
|
12
|
+
import type { EffectContext } from "./common.ts";
|
|
13
|
+
import { applyCrush, applyDistort } from "./drive.ts";
|
|
14
|
+
import { applyCompressor, applyPostgain, applyTremolo } from "./dynamics.ts";
|
|
15
|
+
import {
|
|
16
|
+
applyAutoFilter,
|
|
17
|
+
applyDjFilter,
|
|
18
|
+
applyTrackFilter,
|
|
19
|
+
applyVowel,
|
|
20
|
+
} from "./filter.ts";
|
|
21
|
+
import { applyChorus, applyLeslie, applyPhaser } from "./modulation.ts";
|
|
22
|
+
import { applyDelay, applyReverb } from "./space.ts";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
addReverbWet,
|
|
26
|
+
delayTailFor,
|
|
27
|
+
reverbActive,
|
|
28
|
+
reverbImpulse,
|
|
29
|
+
reverbTailFor,
|
|
30
|
+
reverbWet,
|
|
31
|
+
type ReverbWet,
|
|
32
|
+
} from "./space.ts";
|
|
33
|
+
export { interpolateAutomation, type EffectContext } from "./common.ts";
|
|
34
|
+
|
|
35
|
+
type MonoStage = (
|
|
36
|
+
buffer: Float64Array,
|
|
37
|
+
track: Track,
|
|
38
|
+
values: FxValues,
|
|
39
|
+
context: EffectContext,
|
|
40
|
+
) => void;
|
|
41
|
+
type StereoStage = (
|
|
42
|
+
left: Float64Array,
|
|
43
|
+
right: Float64Array,
|
|
44
|
+
track: Track,
|
|
45
|
+
values: FxValues,
|
|
46
|
+
context: EffectContext,
|
|
47
|
+
) => void;
|
|
48
|
+
|
|
49
|
+
const MONO: Readonly<Partial<Record<FxName, MonoStage>>> = Object.freeze({
|
|
50
|
+
djf: applyDjFilter,
|
|
51
|
+
autofilter: applyAutoFilter,
|
|
52
|
+
vowel: applyVowel,
|
|
53
|
+
crush: applyCrush,
|
|
54
|
+
distort: applyDistort,
|
|
55
|
+
tremolo: applyTremolo,
|
|
56
|
+
compressor: applyCompressor,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const STEREO: Readonly<Partial<Record<FxName, StereoStage>>> = Object.freeze({
|
|
60
|
+
phaser: applyPhaser,
|
|
61
|
+
chorus: applyChorus,
|
|
62
|
+
leslie: applyLeslie,
|
|
63
|
+
postgain: applyPostgain,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const PAN_INDEX = FX_CHAIN.indexOf("pan");
|
|
67
|
+
|
|
68
|
+
/** Mono stages, in chain order, on the track's dry voice sum. */
|
|
69
|
+
export function applyMonoChain(
|
|
70
|
+
buffer: Float64Array,
|
|
71
|
+
track: Track,
|
|
72
|
+
context: EffectContext,
|
|
73
|
+
): void {
|
|
74
|
+
for (const stage of FX_CHAIN.slice(0, PAN_INDEX)) {
|
|
75
|
+
if (stage === "filter") {
|
|
76
|
+
applyTrackFilter(buffer, track, context);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const values = track.fx?.[stage as FxName];
|
|
80
|
+
const apply = MONO[stage as FxName];
|
|
81
|
+
if (values && apply) apply(buffer, track, values, context);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Stereo stages, in chain order, after pan. A track on a shared orbit bus
|
|
87
|
+
* (`sends: false`) skips delay and reverb: the bus applies them to the sum.
|
|
88
|
+
* `reverb: false` stops before the reverb (the stem cache runs it apart).
|
|
89
|
+
*/
|
|
90
|
+
export function applyStereoChain(
|
|
91
|
+
left: Float64Array,
|
|
92
|
+
right: Float64Array,
|
|
93
|
+
track: Track,
|
|
94
|
+
context: EffectContext,
|
|
95
|
+
sends = true,
|
|
96
|
+
reverb = sends,
|
|
97
|
+
): void {
|
|
98
|
+
for (const stage of FX_CHAIN.slice(PAN_INDEX + 1)) {
|
|
99
|
+
if (stage === "delay") {
|
|
100
|
+
if (sends) applyDelay(left, right, track, context);
|
|
101
|
+
} else if (stage === "reverb") {
|
|
102
|
+
if (reverb) applyReverb(left, right, track, context);
|
|
103
|
+
} else {
|
|
104
|
+
const values = track.fx?.[stage as FxName];
|
|
105
|
+
const apply = STEREO[stage as FxName];
|
|
106
|
+
if (values && apply) apply(left, right, track, values, context);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared DSP building blocks for the track effects chain. Everything is
|
|
3
|
+
* plain float64 arithmetic in a fixed order with no clocks or ambient
|
|
4
|
+
* randomness, so a render is byte-identical on every run, cold or cached.
|
|
5
|
+
*
|
|
6
|
+
* The DSP in `src/audio/effects/` is dawg's own, written from standard
|
|
7
|
+
* literature (RBJ Audio EQ Cookbook biquads, Schroeder/Freeverb reverb,
|
|
8
|
+
* Giannoulis–Massberg–Reiss compressor design, Zölzer DAFX modulation
|
|
9
|
+
* effects). Parameter names follow Strudel's documentation; no Strudel
|
|
10
|
+
* source is used.
|
|
11
|
+
*/
|
|
12
|
+
import type { AutomationPoint, Track } from "../../../core/score.ts";
|
|
13
|
+
import type { FxLane, FxValues } from "../../../core/fx.ts";
|
|
14
|
+
import type { DecodedSample } from "../samples.ts";
|
|
15
|
+
|
|
16
|
+
/** Effect parameters are refreshed at this sample interval. */
|
|
17
|
+
export const CONTROL_SAMPLES = 32;
|
|
18
|
+
|
|
19
|
+
export type EffectContext = Readonly<{
|
|
20
|
+
sampleRate: number;
|
|
21
|
+
samplesPerTick: number;
|
|
22
|
+
tempoBpm: number;
|
|
23
|
+
/** Decoded `reverb.ir` samples by track id (built-in impulses need none). */
|
|
24
|
+
irs?: ReadonlyMap<string, DecodedSample>;
|
|
25
|
+
}>;
|
|
26
|
+
|
|
27
|
+
/** Resolve a piecewise-linear automation lane, holding the static value before its first point. */
|
|
28
|
+
export function interpolateAutomation(
|
|
29
|
+
points: readonly AutomationPoint[],
|
|
30
|
+
tick: number,
|
|
31
|
+
fallback: number,
|
|
32
|
+
): number {
|
|
33
|
+
if (points.length === 0 || !Number.isFinite(tick)) return fallback;
|
|
34
|
+
const first = points[0]!;
|
|
35
|
+
if (tick < first.tick) return fallback;
|
|
36
|
+
const last = points[points.length - 1]!;
|
|
37
|
+
if (tick >= last.tick) return last.value;
|
|
38
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
39
|
+
const right = points[index]!;
|
|
40
|
+
if (tick > right.tick) continue;
|
|
41
|
+
const left = points[index - 1]!;
|
|
42
|
+
const span = right.tick - left.tick;
|
|
43
|
+
const ratio = span <= 0 ? 1 : (tick - left.tick) / span;
|
|
44
|
+
return left.value + (right.value - left.value) * ratio;
|
|
45
|
+
}
|
|
46
|
+
return last.value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One numeric effect parameter: its static value plus its automation lane
|
|
51
|
+
* (`<effect>-<param>` in `track.fxAutomation`), read at control rate.
|
|
52
|
+
*/
|
|
53
|
+
export class Param {
|
|
54
|
+
readonly automated: boolean;
|
|
55
|
+
private readonly points: readonly AutomationPoint[];
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
readonly fallback: number,
|
|
59
|
+
points: readonly AutomationPoint[] | undefined,
|
|
60
|
+
private readonly samplesPerTick: number,
|
|
61
|
+
) {
|
|
62
|
+
this.points = points ?? [];
|
|
63
|
+
this.automated = this.points.length > 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
at(index: number): number {
|
|
67
|
+
return this.automated
|
|
68
|
+
? interpolateAutomation(
|
|
69
|
+
this.points,
|
|
70
|
+
index / this.samplesPerTick,
|
|
71
|
+
this.fallback,
|
|
72
|
+
)
|
|
73
|
+
: this.fallback;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Reader for one `track.fx` effect's numbers, enums and lanes. */
|
|
78
|
+
export function fxReader(
|
|
79
|
+
track: Track,
|
|
80
|
+
effect: string,
|
|
81
|
+
values: FxValues,
|
|
82
|
+
context: EffectContext,
|
|
83
|
+
) {
|
|
84
|
+
return {
|
|
85
|
+
number(name: string): number {
|
|
86
|
+
return values[name] as number;
|
|
87
|
+
},
|
|
88
|
+
text(name: string): string {
|
|
89
|
+
return values[name] as string;
|
|
90
|
+
},
|
|
91
|
+
param(name: string): Param {
|
|
92
|
+
return new Param(
|
|
93
|
+
values[name] as number,
|
|
94
|
+
track.fxAutomation?.[`${effect}-${name}` as FxLane],
|
|
95
|
+
context.samplesPerTick,
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
102
|
+
return Math.min(max, Math.max(min, value));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Resonance 0..1 → biquad Q 0.707..8 (the original filter's mapping). */
|
|
106
|
+
export function resonanceQ(resonance: number): number {
|
|
107
|
+
return 0.707 + clamp(resonance, 0, 1) * 7.293;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type FilterType = "lpf" | "hpf" | "bpf";
|
|
111
|
+
|
|
112
|
+
/** Transposed direct form II biquad with RBJ cookbook coefficients. */
|
|
113
|
+
export class Biquad {
|
|
114
|
+
b0 = 1;
|
|
115
|
+
b1 = 0;
|
|
116
|
+
b2 = 0;
|
|
117
|
+
a1 = 0;
|
|
118
|
+
a2 = 0;
|
|
119
|
+
z1 = 0;
|
|
120
|
+
z2 = 0;
|
|
121
|
+
|
|
122
|
+
set(type: FilterType, cutoff: number, q: number, sampleRate: number): void {
|
|
123
|
+
const frequency = clamp(cutoff, 20, sampleRate * 0.45);
|
|
124
|
+
const omega = (2 * Math.PI * frequency) / sampleRate;
|
|
125
|
+
const alpha = Math.sin(omega) / (2 * q);
|
|
126
|
+
const cos = Math.cos(omega);
|
|
127
|
+
const a0 = 1 + alpha;
|
|
128
|
+
if (type === "lpf") {
|
|
129
|
+
this.b0 = (1 - cos) / 2 / a0;
|
|
130
|
+
this.b1 = (1 - cos) / a0;
|
|
131
|
+
this.b2 = this.b0;
|
|
132
|
+
} else if (type === "hpf") {
|
|
133
|
+
this.b0 = (1 + cos) / 2 / a0;
|
|
134
|
+
this.b1 = -(1 + cos) / a0;
|
|
135
|
+
this.b2 = this.b0;
|
|
136
|
+
} else {
|
|
137
|
+
// Constant 0 dB peak gain band-pass.
|
|
138
|
+
this.b0 = alpha / a0;
|
|
139
|
+
this.b1 = 0;
|
|
140
|
+
this.b2 = -alpha / a0;
|
|
141
|
+
}
|
|
142
|
+
this.a1 = (-2 * cos) / a0;
|
|
143
|
+
this.a2 = (1 - alpha) / a0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
process(input: number): number {
|
|
147
|
+
const output = this.b0 * input + this.z1;
|
|
148
|
+
this.z1 = this.b1 * input - this.a1 * output + this.z2;
|
|
149
|
+
this.z2 = this.b2 * input - this.a2 * output;
|
|
150
|
+
return output;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** One-pole low-pass; `set` takes a cutoff in Hz. */
|
|
155
|
+
export class OnePole {
|
|
156
|
+
private coefficient = 0;
|
|
157
|
+
state = 0;
|
|
158
|
+
|
|
159
|
+
constructor(cutoff: number, sampleRate: number) {
|
|
160
|
+
this.set(cutoff, sampleRate);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
set(cutoff: number, sampleRate: number): void {
|
|
164
|
+
this.coefficient =
|
|
165
|
+
1 -
|
|
166
|
+
Math.exp(
|
|
167
|
+
(-2 * Math.PI * clamp(cutoff, 1, sampleRate * 0.49)) / sampleRate,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
process(input: number): number {
|
|
172
|
+
this.state += this.coefficient * (input - this.state);
|
|
173
|
+
return this.state;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type LfoShape = "sine" | "tri" | "square" | "saw" | "ramp" | "random";
|
|
178
|
+
|
|
179
|
+
/** Integer hash → 0..1, for sample-and-hold steps keyed by cycle number. */
|
|
180
|
+
export function hashUnit(value: number, seed = 0): number {
|
|
181
|
+
let state = (Math.floor(value) ^ Math.imul(seed, 0x9e3779b1)) >>> 0;
|
|
182
|
+
state = Math.imul(state ^ (state >>> 16), 0x85ebca6b) >>> 0;
|
|
183
|
+
state = Math.imul(state ^ (state >>> 13), 0xc2b2ae35) >>> 0;
|
|
184
|
+
return ((state ^ (state >>> 16)) >>> 0) / 4_294_967_296;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Bipolar LFO, -1..1, at `phase` cycles (any real). `saw` falls, `ramp`
|
|
189
|
+
* rises; `random` holds one seeded value per cycle.
|
|
190
|
+
*/
|
|
191
|
+
export function lfo(shape: string, phase: number, seed = 0): number {
|
|
192
|
+
const cycle = phase - Math.floor(phase);
|
|
193
|
+
switch (shape) {
|
|
194
|
+
case "tri":
|
|
195
|
+
return 1 - 4 * Math.abs(cycle - 0.5);
|
|
196
|
+
case "square":
|
|
197
|
+
return cycle < 0.5 ? 1 : -1;
|
|
198
|
+
case "saw":
|
|
199
|
+
return 1 - 2 * cycle;
|
|
200
|
+
case "ramp":
|
|
201
|
+
return 2 * cycle - 1;
|
|
202
|
+
case "random":
|
|
203
|
+
return hashUnit(phase, seed) * 2 - 1;
|
|
204
|
+
default:
|
|
205
|
+
return Math.sin(2 * Math.PI * phase);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** LFO frequency in Hz: tempo-synced when `sync` (beats per cycle) > 0. */
|
|
210
|
+
export function lfoHz(sync: number, rate: number, tempoBpm: number): number {
|
|
211
|
+
return sync > 0 ? tempoBpm / 60 / sync : rate;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Sample-accurate LFO phase (cycles) with control-rate frequency changes. */
|
|
215
|
+
export class Phasor {
|
|
216
|
+
phase: number;
|
|
217
|
+
private increment = 0;
|
|
218
|
+
|
|
219
|
+
constructor(start: number) {
|
|
220
|
+
this.phase = start;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
setHz(hz: number, sampleRate: number): void {
|
|
224
|
+
this.increment = hz / sampleRate;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
next(): number {
|
|
228
|
+
const value = this.phase;
|
|
229
|
+
this.phase += this.increment;
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Read with linear interpolation `delay` samples behind `write` in a ring. */
|
|
235
|
+
export function readFractional(
|
|
236
|
+
buffer: Float64Array,
|
|
237
|
+
write: number,
|
|
238
|
+
delay: number,
|
|
239
|
+
): number {
|
|
240
|
+
const length = buffer.length;
|
|
241
|
+
const position = write - delay;
|
|
242
|
+
const base = Math.floor(position);
|
|
243
|
+
const fraction = position - base;
|
|
244
|
+
const a = buffer[((base % length) + length) % length]!;
|
|
245
|
+
const b = buffer[(((base + 1) % length) + length) % length]!;
|
|
246
|
+
return a + (b - a) * fraction;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function dbToGain(db: number): number {
|
|
250
|
+
return 10 ** (db / 20);
|
|
251
|
+
}
|