@hraness/dawg 0.2.0 → 0.4.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.
Files changed (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The click track (metronome): an accented downbeat, a beat click and a
3
+ * lighter subdivision click at the transport tempo and time signature.
4
+ *
5
+ * The click is a monitoring bus only. The engine mixes it into the live
6
+ * stream; it is never part of a loop render, `dawg render` or an export, and
7
+ * nothing here touches the score.
8
+ */
9
+
10
+ export type ClickLevel = "accent" | "beat" | "sub";
11
+
12
+ export type ClickSettings = Readonly<{
13
+ /** 0..1 output level. */
14
+ volume: number;
15
+ beatsPerBar: number;
16
+ /** Clicks per beat: 1 = quarters, 2 = eighths, 4 = sixteenths. */
17
+ subdivision: number;
18
+ }>;
19
+
20
+ export const DEFAULT_CLICK_VOLUME = 0.6;
21
+ export const MAX_SUBDIVISION = 4;
22
+
23
+ /** One click due inside a block of stream frames. */
24
+ export type ClickEvent = Readonly<{
25
+ /** Frame offset inside the block. */
26
+ offset: number;
27
+ level: ClickLevel;
28
+ /** Absolute step (beat × subdivision); negative during a count-in. */
29
+ step: number;
30
+ }>;
31
+
32
+ /** Level of absolute step `step` (beat × subdivision). */
33
+ export function clickLevel(
34
+ step: number,
35
+ beatsPerBar: number,
36
+ subdivision: number,
37
+ ): ClickLevel {
38
+ const perBar = Math.max(1, beatsPerBar) * Math.max(1, subdivision);
39
+ const inBar = ((step % perBar) + perBar) % perBar;
40
+ if (inBar === 0) return "accent";
41
+ return inBar % Math.max(1, subdivision) === 0 ? "beat" : "sub";
42
+ }
43
+
44
+ /**
45
+ * Clicks whose time falls in the half-open beat span [fromBeat, toBeat)
46
+ * covered by `frames` stream frames. Contiguous blocks never repeat or skip
47
+ * a click, and a jump (seek, loop wrap) never fires a stray mid-beat click.
48
+ */
49
+ export function clicksIn(
50
+ fromBeat: number,
51
+ toBeat: number,
52
+ frames: number,
53
+ settings: Pick<ClickSettings, "beatsPerBar" | "subdivision">,
54
+ ): ClickEvent[] {
55
+ if (!(toBeat > fromBeat) || frames <= 0) return [];
56
+ const subdivision = Math.max(
57
+ 1,
58
+ Math.min(MAX_SUBDIVISION, Math.round(settings.subdivision)),
59
+ );
60
+ const events: ClickEvent[] = [];
61
+ const span = toBeat - fromBeat;
62
+ // Half-open: a click exactly at `toBeat` belongs to the next block, whose
63
+ // `fromBeat` is computed identically, so it fires once.
64
+ const first = Math.ceil(fromBeat * subdivision) + 0;
65
+ for (let step = first; step / subdivision < toBeat; step += 1) {
66
+ const offset = Math.floor(
67
+ ((step / subdivision - fromBeat) / span) * frames,
68
+ );
69
+ if (offset < 0 || offset >= frames) continue;
70
+ events.push({
71
+ offset,
72
+ step,
73
+ level: clickLevel(step, settings.beatsPerBar, subdivision),
74
+ });
75
+ if (events.length > 64) break;
76
+ }
77
+ return events;
78
+ }
79
+
80
+ const CLICK_TONES: Readonly<
81
+ Record<ClickLevel, { hz: number; gain: number; ms: number }>
82
+ > = Object.freeze({
83
+ accent: { hz: 1760, gain: 1, ms: 45 },
84
+ beat: { hz: 1320, gain: 0.7, ms: 35 },
85
+ sub: { hz: 990, gain: 0.35, ms: 25 },
86
+ });
87
+
88
+ /** Mono click waveforms (−1..1) for each level at `sampleRate`. */
89
+ export function clickSounds(
90
+ sampleRate: number,
91
+ ): Readonly<Record<ClickLevel, Float32Array>> {
92
+ const make = (level: ClickLevel): Float32Array => {
93
+ const tone = CLICK_TONES[level];
94
+ const frames = Math.max(1, Math.round((tone.ms * sampleRate) / 1000));
95
+ const out = new Float32Array(frames);
96
+ const attack = Math.max(1, Math.round(0.001 * sampleRate));
97
+ for (let index = 0; index < frames; index += 1) {
98
+ const t = index / sampleRate;
99
+ const decay = Math.exp((-index / frames) * 6);
100
+ const env = Math.min(1, index / attack) * decay;
101
+ out[index] = Math.sin(2 * Math.PI * tone.hz * t) * env * tone.gain;
102
+ }
103
+ return out;
104
+ };
105
+ return { accent: make("accent"), beat: make("beat"), sub: make("sub") };
106
+ }
107
+
108
+ /** `/click on|off|<volume>` → settings change, or an error message. */
109
+ export function parseClickArgument(
110
+ argument: string,
111
+ current: Readonly<{ on: boolean; volume: number }>,
112
+ ): { on: boolean; volume: number } | { error: string } {
113
+ const value = argument.trim().toLowerCase();
114
+ if (value === "" || value === "toggle")
115
+ return { on: !current.on, volume: current.volume };
116
+ if (value === "on") return { on: true, volume: current.volume };
117
+ if (value === "off") return { on: false, volume: current.volume };
118
+ const percent = value.endsWith("%");
119
+ const number = Number(percent ? value.slice(0, -1) : value);
120
+ if (!Number.isFinite(number) || number < 0)
121
+ return { error: "usage: /click on|off|<volume 0..1 or 0..100%>" };
122
+ const volume = percent || number > 1 ? number / 100 : number;
123
+ if (volume > 1) return { error: "click volume is 0..1 (or 0..100%)" };
124
+ return { on: volume > 0, volume };
125
+ }
@@ -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,99 @@
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 { delayTailFor, reverbImpulse, reverbTailFor } from "./space.ts";
25
+ export { interpolateAutomation, type EffectContext } from "./common.ts";
26
+
27
+ type MonoStage = (
28
+ buffer: Float64Array,
29
+ track: Track,
30
+ values: FxValues,
31
+ context: EffectContext,
32
+ ) => void;
33
+ type StereoStage = (
34
+ left: Float64Array,
35
+ right: Float64Array,
36
+ track: Track,
37
+ values: FxValues,
38
+ context: EffectContext,
39
+ ) => void;
40
+
41
+ const MONO: Readonly<Partial<Record<FxName, MonoStage>>> = Object.freeze({
42
+ djf: applyDjFilter,
43
+ autofilter: applyAutoFilter,
44
+ vowel: applyVowel,
45
+ crush: applyCrush,
46
+ distort: applyDistort,
47
+ tremolo: applyTremolo,
48
+ compressor: applyCompressor,
49
+ });
50
+
51
+ const STEREO: Readonly<Partial<Record<FxName, StereoStage>>> = Object.freeze({
52
+ phaser: applyPhaser,
53
+ chorus: applyChorus,
54
+ leslie: applyLeslie,
55
+ postgain: applyPostgain,
56
+ });
57
+
58
+ const PAN_INDEX = FX_CHAIN.indexOf("pan");
59
+
60
+ /** Mono stages, in chain order, on the track's dry voice sum. */
61
+ export function applyMonoChain(
62
+ buffer: Float64Array,
63
+ track: Track,
64
+ context: EffectContext,
65
+ ): void {
66
+ for (const stage of FX_CHAIN.slice(0, PAN_INDEX)) {
67
+ if (stage === "filter") {
68
+ applyTrackFilter(buffer, track, context);
69
+ continue;
70
+ }
71
+ const values = track.fx?.[stage as FxName];
72
+ const apply = MONO[stage as FxName];
73
+ if (values && apply) apply(buffer, track, values, context);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Stereo stages, in chain order, after pan. A track on a shared orbit bus
79
+ * (`sends: false`) skips delay and reverb: the bus applies them to the sum.
80
+ */
81
+ export function applyStereoChain(
82
+ left: Float64Array,
83
+ right: Float64Array,
84
+ track: Track,
85
+ context: EffectContext,
86
+ sends = true,
87
+ ): void {
88
+ for (const stage of FX_CHAIN.slice(PAN_INDEX + 1)) {
89
+ if (stage === "delay") {
90
+ if (sends) applyDelay(left, right, track, context);
91
+ } else if (stage === "reverb") {
92
+ if (sends) applyReverb(left, right, track, context);
93
+ } else {
94
+ const values = track.fx?.[stage as FxName];
95
+ const apply = STEREO[stage as FxName];
96
+ if (values && apply) apply(left, right, track, values, context);
97
+ }
98
+ }
99
+ }
@@ -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
+ }