@hraness/dawg 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/DAWG.md +592 -49
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +9 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +689 -2
  8. package/core/granular.ts +619 -0
  9. package/core/instruments.ts +442 -0
  10. package/core/keys.ts +955 -0
  11. package/core/resonators.ts +862 -0
  12. package/core/score.ts +529 -8
  13. package/core/sdk/eval-child.ts +2 -0
  14. package/core/sdk/print.ts +247 -16
  15. package/core/sdk/sync-instruments.ts +58 -0
  16. package/core/sdk/v1.ts +2417 -40
  17. package/core/sections.ts +44 -10
  18. package/core/strings.ts +1080 -0
  19. package/core/winds.ts +652 -0
  20. package/guides/automation.md +1 -0
  21. package/guides/chords.md +3 -1
  22. package/guides/effects.md +5 -2
  23. package/guides/media.md +1 -1
  24. package/guides/performance.md +3 -0
  25. package/guides/resample.md +26 -0
  26. package/guides/sounds.md +7 -2
  27. package/guides/tempo.md +1 -0
  28. package/package.json +1 -1
  29. package/src/agent/agent.ts +13 -0
  30. package/src/agent/chord-tools.ts +153 -1
  31. package/src/agent/expression-tools.ts +91 -0
  32. package/src/agent/granular-tools.ts +138 -0
  33. package/src/agent/models.ts +4 -4
  34. package/src/agent/ops.ts +42 -2
  35. package/src/agent/preview-tool.ts +7 -1
  36. package/src/agent/resample-tool.ts +131 -0
  37. package/src/agent/tools.ts +726 -17
  38. package/src/agent/xcb-agent.ts +13 -0
  39. package/src/audio/arrange.ts +42 -3
  40. package/src/audio/dsp/bank.ts +233 -0
  41. package/src/audio/dsp/envelope.ts +161 -0
  42. package/src/audio/dsp/fft.ts +6 -0
  43. package/src/audio/dsp/filters.ts +57 -0
  44. package/src/audio/dsp/interp.ts +112 -0
  45. package/src/audio/dsp/modal.ts +684 -0
  46. package/src/audio/dsp/onset.ts +197 -0
  47. package/src/audio/dsp/oversample.ts +202 -0
  48. package/src/audio/dsp/rng.ts +37 -0
  49. package/src/audio/dsp/shape.ts +81 -0
  50. package/src/audio/dsp/shift.ts +256 -0
  51. package/src/audio/dsp/stft.ts +73 -0
  52. package/src/audio/dsp/window.ts +77 -0
  53. package/src/audio/effects/chain.ts +23 -3
  54. package/src/audio/effects/common.ts +14 -0
  55. package/src/audio/effects/convolution.ts +106 -1
  56. package/src/audio/effects/gaze.ts +354 -0
  57. package/src/audio/effects/rig/cab.ts +99 -0
  58. package/src/audio/effects/rig/filters.ts +152 -0
  59. package/src/audio/effects/rig/gate.ts +39 -0
  60. package/src/audio/effects/rig/head.ts +487 -0
  61. package/src/audio/effects/rig/index.ts +170 -0
  62. package/src/audio/effects/rig/section.ts +78 -0
  63. package/src/audio/effects/rig/stomp.ts +238 -0
  64. package/src/audio/engine.ts +56 -6
  65. package/src/audio/fit.ts +447 -0
  66. package/src/audio/granular.ts +995 -0
  67. package/src/audio/instrument-check.ts +137 -0
  68. package/src/audio/instruments.ts +140 -0
  69. package/src/audio/keys/dsp.ts +323 -0
  70. package/src/audio/keys/electric.ts +420 -0
  71. package/src/audio/keys/engine.ts +485 -0
  72. package/src/audio/keys/organ.ts +1335 -0
  73. package/src/audio/keys/piano.ts +476 -0
  74. package/src/audio/keys/sympathetic.ts +127 -0
  75. package/src/audio/live-worker.ts +56 -0
  76. package/src/audio/live.ts +361 -15
  77. package/src/audio/loudness.ts +65 -20
  78. package/src/audio/preview.ts +17 -1
  79. package/src/audio/resample.ts +285 -0
  80. package/src/audio/resonators.ts +295 -0
  81. package/src/audio/sampler.ts +328 -35
  82. package/src/audio/samples.ts +56 -8
  83. package/src/audio/strings/body.ts +263 -0
  84. package/src/audio/strings/bow.ts +656 -0
  85. package/src/audio/strings/engine.ts +615 -0
  86. package/src/audio/strings/loop.ts +119 -0
  87. package/src/audio/strings/measure.test-helpers.ts +200 -0
  88. package/src/audio/strings/pluck.ts +354 -0
  89. package/src/audio/warp.ts +61 -0
  90. package/src/audio/wav.ts +191 -19
  91. package/src/audio/winds/engine.ts +302 -0
  92. package/src/audio/winds/filters.ts +153 -0
  93. package/src/audio/winds/pitch.ts +104 -0
  94. package/src/audio/winds/trim.ts +80 -0
  95. package/src/audio/winds/trims.ts +917 -0
  96. package/src/audio/winds/voice.ts +479 -0
  97. package/src/commands/expression.ts +111 -23
  98. package/src/commands/fit.ts +135 -0
  99. package/src/commands/fx.ts +35 -1
  100. package/src/commands/granular.ts +432 -0
  101. package/src/commands/help.ts +159 -5
  102. package/src/commands/keys.ts +629 -0
  103. package/src/commands/modal.ts +310 -0
  104. package/src/commands/resample.ts +281 -0
  105. package/src/commands/rig.ts +264 -0
  106. package/src/commands/sample.ts +52 -2
  107. package/src/commands/shift.ts +119 -0
  108. package/src/commands/string.ts +201 -0
  109. package/src/commands/strum.ts +473 -0
  110. package/src/commands/time.ts +4 -1
  111. package/src/commands/wind.ts +244 -0
  112. package/src/main.ts +339 -15
  113. package/src/project/check.ts +17 -0
  114. package/src/render.ts +49 -4
  115. package/src/session/presence.ts +16 -3
  116. package/src/tui/audition.ts +2 -2
  117. package/src/tui/granular-menu.ts +278 -0
  118. package/src/tui/menu.ts +934 -9
  119. package/src/tui/modal-menu.ts +145 -0
  120. package/src/tui/performance-menu.ts +42 -0
  121. package/src/tui/play-chords.ts +104 -3
  122. package/src/tui/play-mode.ts +1 -0
  123. package/src/tui/play-session.ts +70 -2
  124. package/src/tui/wind-menu.ts +144 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Instrument words that fall through to dawg's plain sine voice. Unknown
3
+ * words have always rendered as a sine (old projects keep doing so), and a
4
+ * few pre-0.6 words (cello, organ, strings, bell, keys, lead, sitar) are that
5
+ * same sine. `dawg check` and the `instrument` prompt name them instead of
6
+ * playing a test tone silently.
7
+ */
8
+
9
+ import { isDrumInstrument } from "../../core/drums.ts";
10
+ import { INSTRUMENT_WORDS, LEGACY_WORDS } from "../../core/instruments.ts";
11
+ import { KEYS_FAMILIES } from "../../core/keys.ts";
12
+ import { isSamplerInstrument, type Track } from "../../core/score.ts";
13
+ import { engineFor, registeredEngines } from "./instruments.ts";
14
+ import { resolveOscillator } from "./synth/oscillators.ts";
15
+ import { AVAILABLE_INSTRUMENTS } from "./wav.ts";
16
+
17
+ /** Substrings `legacyWave` gives their own tone (square, saw, bass, ...). */
18
+ const LEGACY_TONES = ["square", "saw", "triangle", "bass", "piano", "pluck"];
19
+
20
+ /** The 0.6 voice to point a plain-sine legacy word at. */
21
+ const LEGACY_ADVICE: Readonly<Record<string, string>> = Object.freeze({
22
+ cello: "bowed cello for the bowed cello, synth preset strings for a pad",
23
+ strings: "synth preset strings for a string pad",
24
+ organ: "synth preset organ",
25
+ bell: "modal glock or synth preset bell",
26
+ keys: "piano grand or synth preset keys",
27
+ lead: "synth preset lead",
28
+ sitar: "string sitar for the plucked sitar",
29
+ });
30
+
31
+ /** Words worth suggesting for a typo. */
32
+ function knownWords(): readonly string[] {
33
+ return [
34
+ ...new Set([
35
+ ...AVAILABLE_INSTRUMENTS,
36
+ ...INSTRUMENT_WORDS.map((row) => row.word),
37
+ ...KEYS_FAMILIES,
38
+ ...registeredEngines(),
39
+ "sampler",
40
+ ]),
41
+ ];
42
+ }
43
+
44
+ /** Whether `instrument` (on `track`) renders as the plain sine fallback. */
45
+ export function playsPlainSine(
46
+ instrument: string,
47
+ track?: Track | undefined,
48
+ ): boolean {
49
+ const name = instrument.trim().toLowerCase();
50
+ if (name === "" || name.includes("sine")) return false;
51
+ if (track && engineFor(track)) return false;
52
+ // Engine ids and keys families name a 0.6 voice even without its field.
53
+ if (registeredEngines().includes(name)) return false;
54
+ if ((KEYS_FAMILIES as readonly string[]).includes(name)) return false;
55
+ if (isDrumInstrument(name) || isSamplerInstrument(name)) return false;
56
+ if (name === "wavetable" || resolveOscillator(name)) return false;
57
+ return !LEGACY_TONES.some((tone) => name.includes(tone));
58
+ }
59
+
60
+ /**
61
+ * Advice for an instrument word that plays the plain sine, or undefined.
62
+ * `marimba` and `wind` have their own note (legacyResonatorWarnings).
63
+ */
64
+ export function plainSineAdvice(
65
+ instrument: string,
66
+ track?: Track | undefined,
67
+ ): string | undefined {
68
+ const name = instrument.trim().toLowerCase();
69
+ if (name === "marimba" || name === "wind" || name === "modal")
70
+ return undefined;
71
+ if (!playsPlainSine(name, track)) return undefined;
72
+ if (LEGACY_WORDS.includes(name)) {
73
+ const advice = LEGACY_ADVICE[name];
74
+ return `"${name}" is dawg's plain sine (kept for old projects)${advice ? ` · ${advice}` : ""}`;
75
+ }
76
+ const near = nearestWord(name, knownWords());
77
+ // A resolver word stored raw (an older project): the word itself now
78
+ // picks a voice when typed again.
79
+ if (near === name)
80
+ return `"${name}" is stored as a bare word and plays a plain sine · type instrument ${name} again for its voice`;
81
+ return `"${name}" is not a dawg instrument and plays a plain sine${near ? ` · did you mean ${near}?` : ""}`;
82
+ }
83
+
84
+ /** Whether `word` is neither a known instrument nor a legacy word. */
85
+ export function isUnknownInstrument(word: string): boolean {
86
+ const name = word.trim().toLowerCase();
87
+ return (
88
+ playsPlainSine(name) &&
89
+ !LEGACY_WORDS.includes(name) &&
90
+ !INSTRUMENT_WORDS.some((row) => row.word === name)
91
+ );
92
+ }
93
+
94
+ /** `dawg check` warnings for every track that plays the plain sine. */
95
+ export function plainSineWarnings(tracks: readonly Track[]): string[] {
96
+ const out: string[] = [];
97
+ for (const track of tracks) {
98
+ const advice = plainSineAdvice(track.instrument, track);
99
+ if (advice) out.push(`track ${track.id}: instrument ${advice}`);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** The closest of `words` to `word` within two edits, if any. */
105
+ export function nearestWord(
106
+ word: string,
107
+ words: readonly string[],
108
+ ): string | undefined {
109
+ let best: string | undefined;
110
+ let bestDistance = 3;
111
+ for (const candidate of words) {
112
+ const distance = editDistance(word, candidate);
113
+ if (distance < bestDistance) {
114
+ best = candidate;
115
+ bestDistance = distance;
116
+ }
117
+ }
118
+ return best;
119
+ }
120
+
121
+ function editDistance(a: string, b: string): number {
122
+ const row = Array.from({ length: b.length + 1 }, (_, index) => index);
123
+ for (let i = 1; i <= a.length; i += 1) {
124
+ let previous = row[0]!;
125
+ row[0] = i;
126
+ for (let j = 1; j <= b.length; j += 1) {
127
+ const saved = row[j]!;
128
+ row[j] = Math.min(
129
+ row[j]! + 1,
130
+ row[j - 1]! + 1,
131
+ previous + (a[i - 1] === b[j - 1] ? 0 : 1),
132
+ );
133
+ previous = saved;
134
+ }
135
+ }
136
+ return row[b.length]!;
137
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The 0.6 instrument engine registry. A lane registers one engine
3
+ * (`registerEngine`) for its instrument id and its own optional Track field;
4
+ * `engineFor` dispatches to it only when the track names that instrument AND
5
+ * carries that field as an object, so a bare legacy word (a stored "piano",
6
+ * "marimba", "sitar") keeps today's voice. With nothing registered every
7
+ * render takes exactly today's code path.
8
+ */
9
+ import type { PerformedNote } from "../../core/expression.ts";
10
+ import type { Track, TrackScore } from "../../core/score.ts";
11
+ import type { TuningTable } from "../../core/tuning.ts";
12
+ import { WIND_ENGINE } from "./winds/engine.ts";
13
+ import { MODAL_ENGINE } from "./resonators.ts";
14
+ import type { SampleBank } from "./samples.ts";
15
+ import type { RenderContext } from "./wav.ts";
16
+ import { STRING_ENGINE } from "./strings/engine.ts";
17
+ import { GRANULAR_ENGINE } from "./granular.ts";
18
+ import { KEYS_ENGINES } from "./keys/engine.ts";
19
+
20
+ /** What an engine renders with: the track's render context plus its tuning. */
21
+ export type EngineContext = RenderContext &
22
+ Readonly<{
23
+ ticksPerBeat: number;
24
+ /** The merged song and track tuning; absent keeps 12-TET. */
25
+ tuning?: TuningTable;
26
+ }>;
27
+
28
+ /** Fade at a ring-out cap (live note length, voice cap, loop fold), in seconds. */
29
+ export const RING_OUT_FADE_SECONDS = 0.25;
30
+
31
+ export type InstrumentEngine = Readonly<{
32
+ /** The `Track.instrument` value this engine plays. */
33
+ id: string;
34
+ /** The optional Track field that holds its settings. */
35
+ field: keyof Track;
36
+ /**
37
+ * Adds the track's performed notes into `dry` (and `dryR` when `stereo`
38
+ * is true), before the track's effects, pan and the song master.
39
+ */
40
+ render(
41
+ dry: Float64Array,
42
+ dryR: Float64Array | undefined,
43
+ notes: readonly PerformedNote[],
44
+ track: Track,
45
+ context: EngineContext,
46
+ bank: SampleBank,
47
+ ): void;
48
+ /**
49
+ * Ring-out after the last note ends, in seconds (the live length cap).
50
+ * `lowestPitch`, when given, is the track's lowest note, for engines
51
+ * whose ring depends on pitch.
52
+ */
53
+ tailSeconds(track: Track, lowestPitch?: number): number;
54
+ /**
55
+ * The live note-off fade for one key at its sounding `hz`, in seconds
56
+ * (a piano's damper); absent uses `tailSeconds`.
57
+ */
58
+ releaseSeconds?(track: Track, pitch: number, hz: number): number;
59
+ /**
60
+ * True when a released live key keeps ringing to the end of its rendered
61
+ * tail (an undamped bar or gong) instead of fading at note-off.
62
+ */
63
+ ringOut?(track: Track): boolean;
64
+ /** True when the engine writes a separate right channel. */
65
+ stereo(track: Track): boolean;
66
+ /**
67
+ * State the engine carries into an arranged window that its pre-roll
68
+ * cannot rebuild (an organ's rotors along a lane), as a string: `history`
69
+ * is the song before the window and `frames` its length in samples.
70
+ * Arranged renders hand it back as `context.seedState[track.id]`.
71
+ */
72
+ windowSeed?(
73
+ history: TrackScore,
74
+ track: Track,
75
+ frames: number,
76
+ sampleRate: number,
77
+ ): string | undefined;
78
+ /**
79
+ * Digests of any assets the engine reads, joined to the stem cache key.
80
+ * `score` is the song, for engines that read other tracks' notes.
81
+ */
82
+ assetDigests?(
83
+ track: Track,
84
+ bank: SampleBank,
85
+ score?: TrackScore,
86
+ ): readonly string[];
87
+ }>;
88
+
89
+ const engines = new Map<string, InstrumentEngine>();
90
+
91
+ /** Registers an engine; one per instrument id. */
92
+ export function registerEngine(engine: InstrumentEngine): void {
93
+ if (engines.has(engine.id))
94
+ throw new Error(`instrument engine "${engine.id}" is already registered`);
95
+ engines.set(engine.id, engine);
96
+ }
97
+
98
+ /** Removes an engine (tests only). */
99
+ export function unregisterEngine(id: string): void {
100
+ engines.delete(id);
101
+ }
102
+
103
+ /** The ids with a registered engine. */
104
+ export function registeredEngines(): readonly string[] {
105
+ return [...engines.keys()];
106
+ }
107
+
108
+ /**
109
+ * The engine for a track: only when the track's instrument names a
110
+ * registered engine and the engine's Track field is present as an object.
111
+ */
112
+ export function engineFor(
113
+ track: Track | undefined,
114
+ ): InstrumentEngine | undefined {
115
+ if (!track || engines.size === 0) return undefined;
116
+ const engine = engines.get(track.instrument);
117
+ if (!engine) return undefined;
118
+ const settings: unknown = track[engine.field];
119
+ return typeof settings === "object" &&
120
+ settings !== null &&
121
+ !Array.isArray(settings)
122
+ ? engine
123
+ : undefined;
124
+ }
125
+
126
+ /** A track's engine ring-out in seconds; 0 without an engine. */
127
+ export function engineTailSeconds(
128
+ track: Track | undefined,
129
+ lowestPitch?: number,
130
+ ): number {
131
+ const engine = engineFor(track);
132
+ return engine ? Math.max(0, engine.tailSeconds(track!, lowestPitch)) : 0;
133
+ }
134
+
135
+ // 0.6 lanes register their engines below, one line each.
136
+ registerEngine(STRING_ENGINE);
137
+ registerEngine(GRANULAR_ENGINE);
138
+ for (const engine of KEYS_ENGINES) registerEngine(engine);
139
+ registerEngine(MODAL_ENGINE);
140
+ registerEngine(WIND_ENGINE);
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Keys-local DSP (0.6 keys lane), ported from the reviewed prototype
3
+ * (`.plans/dawg-06/proto/keys/dsp.ts`, round 2): a modal resonator bank, an
4
+ * RBJ biquad with shelves and peaks (the contract `Biquad` has only
5
+ * lpf/hpf/bpf), and a seeded filtered noise burst. Randomness comes only
6
+ * from `src/audio/dsp/rng.ts`.
7
+ */
8
+ import { seededRandom } from "../dsp/rng.ts";
9
+
10
+ export const TAU = 2 * Math.PI;
11
+ /** ln(1000): 60 dB in nepers. */
12
+ export const LN1000 = 6.907755278982137;
13
+ /** Samples per control step (bend, vibrato, damper and lane reads). */
14
+ export const CONTROL = 32;
15
+ /** Highest mode frequency kept, as a share of the sample rate. */
16
+ export const MODE_CEILING = 0.45;
17
+
18
+ export const clamp = (x: number, lo: number, hi: number): number =>
19
+ Math.min(hi, Math.max(lo, x));
20
+ export const lerp = (a: number, b: number, t: number): number =>
21
+ a + (b - a) * t;
22
+
23
+ /** Per-sample pole radius for a 60 dB decay in `t60` seconds. */
24
+ export const radiusFor = (t60: number, sampleRate: number): number =>
25
+ Math.exp(-LN1000 / (Math.max(1e-4, t60) * sampleRate));
26
+
27
+ /**
28
+ * A bank of complex one-pole resonators (z' = p z + u x, output Im z * g).
29
+ * Struct-of-arrays; modes can be retuned (bend) and re-damped (dampers).
30
+ * A retune that would lift a mode above `MODE_CEILING` mutes it, so a bend
31
+ * never folds partials back below Nyquist.
32
+ */
33
+ export class ModeBank {
34
+ n = 0;
35
+ readonly re: Float64Array;
36
+ readonly im: Float64Array;
37
+ readonly cr: Float64Array;
38
+ readonly ci: Float64Array;
39
+ /** Base angle per sample. */
40
+ readonly w: Float64Array;
41
+ readonly r: Float64Array;
42
+ readonly u: Float64Array;
43
+ /** Output gain in use (zero while muted by a bend). */
44
+ readonly g: Float64Array;
45
+ /** Output gain as created. */
46
+ readonly g0: Float64Array;
47
+ private ratio = 1;
48
+
49
+ constructor(capacity: number) {
50
+ this.re = new Float64Array(capacity);
51
+ this.im = new Float64Array(capacity);
52
+ this.cr = new Float64Array(capacity);
53
+ this.ci = new Float64Array(capacity);
54
+ this.w = new Float64Array(capacity);
55
+ this.r = new Float64Array(capacity);
56
+ this.u = new Float64Array(capacity);
57
+ this.g = new Float64Array(capacity);
58
+ this.g0 = new Float64Array(capacity);
59
+ }
60
+
61
+ add(
62
+ hz: number,
63
+ t60: number,
64
+ input: number,
65
+ output: number,
66
+ sampleRate: number,
67
+ ): void {
68
+ if (!(hz > 0) || hz >= sampleRate * MODE_CEILING) return;
69
+ if (this.n >= this.re.length) return;
70
+ const k = this.n++;
71
+ this.w[k] = (TAU * hz) / sampleRate;
72
+ this.r[k] = radiusFor(t60, sampleRate);
73
+ this.u[k] = input;
74
+ this.g[k] = output;
75
+ this.g0[k] = output;
76
+ this.cr[k] = this.r[k]! * Math.cos(this.w[k]!);
77
+ this.ci[k] = this.r[k]! * Math.sin(this.w[k]!);
78
+ }
79
+
80
+ /** Shortens every mode's decay to at most `t60For(hz)` (damper, key-off). */
81
+ damp(t60For: (hz: number) => number, sampleRate: number): void {
82
+ for (let k = 0; k < this.n; k += 1) {
83
+ const hz = (this.w[k]! * sampleRate) / TAU;
84
+ const r = radiusFor(t60For(hz), sampleRate);
85
+ if (r < this.r[k]!) {
86
+ this.r[k] = r;
87
+ this.setAngle(k);
88
+ }
89
+ }
90
+ }
91
+
92
+ /** Retunes every mode by a frequency ratio (bend, vibrato). */
93
+ retune(ratio: number): void {
94
+ if (ratio === this.ratio) return;
95
+ this.ratio = ratio;
96
+ for (let k = 0; k < this.n; k += 1) this.setAngle(k);
97
+ }
98
+
99
+ private setAngle(k: number): void {
100
+ const angle = this.w[k]! * this.ratio;
101
+ const muted = angle >= TAU * MODE_CEILING;
102
+ this.g[k] = muted ? 0 : this.g0[k]!;
103
+ const a = muted ? this.w[k]! : angle;
104
+ this.cr[k] = this.r[k]! * Math.cos(a);
105
+ this.ci[k] = this.r[k]! * Math.sin(a);
106
+ }
107
+
108
+ /** Drops modes below `floor` (absolute output amplitude), deterministically. */
109
+ cull(floor: number): void {
110
+ let k = 0;
111
+ while (k < this.n) {
112
+ const amp = Math.hypot(this.re[k]!, this.im[k]!) * Math.abs(this.g0[k]!);
113
+ if (amp < floor) {
114
+ const last = --this.n;
115
+ this.re[k] = this.re[last]!;
116
+ this.im[k] = this.im[last]!;
117
+ this.cr[k] = this.cr[last]!;
118
+ this.ci[k] = this.ci[last]!;
119
+ this.w[k] = this.w[last]!;
120
+ this.r[k] = this.r[last]!;
121
+ this.u[k] = this.u[last]!;
122
+ this.g[k] = this.g[last]!;
123
+ this.g0[k] = this.g0[last]!;
124
+ } else k += 1;
125
+ }
126
+ }
127
+
128
+ /** Runs `count` samples, adding Im(z)*g into out[offset..]; x is the input or null. */
129
+ run(
130
+ out: Float64Array,
131
+ offset: number,
132
+ count: number,
133
+ x: Float64Array | null,
134
+ xOffset: number,
135
+ ): void {
136
+ const { re, im, cr, ci, u, g } = this;
137
+ const n = this.n;
138
+ for (let s = 0; s < count; s += 1) {
139
+ const input = x ? (x[xOffset + s] ?? 0) : 0;
140
+ let acc = 0;
141
+ if (input !== 0) {
142
+ for (let k = 0; k < n; k += 1) {
143
+ const a = re[k]!;
144
+ const b = im[k]!;
145
+ const nr = cr[k]! * a - ci[k]! * b + u[k]! * input;
146
+ const ni = ci[k]! * a + cr[k]! * b;
147
+ re[k] = nr;
148
+ im[k] = ni;
149
+ acc += g[k]! * ni;
150
+ }
151
+ } else {
152
+ for (let k = 0; k < n; k += 1) {
153
+ const a = re[k]!;
154
+ const b = im[k]!;
155
+ const ni = ci[k]! * a + cr[k]! * b;
156
+ re[k] = cr[k]! * a - ci[k]! * b;
157
+ im[k] = ni;
158
+ acc += g[k]! * ni;
159
+ }
160
+ }
161
+ out[offset + s]! += acc;
162
+ }
163
+ }
164
+ }
165
+
166
+ export type BiquadKind =
167
+ "lpf" | "hpf" | "bpf" | "peak" | "lowshelf" | "highshelf";
168
+
169
+ /** RBJ cookbook biquad, transposed direct form II. */
170
+ export class Biquad2 {
171
+ b0 = 1;
172
+ b1 = 0;
173
+ b2 = 0;
174
+ a1 = 0;
175
+ a2 = 0;
176
+ z1 = 0;
177
+ z2 = 0;
178
+
179
+ set(
180
+ type: BiquadKind,
181
+ hz: number,
182
+ q: number,
183
+ gainDb: number,
184
+ sampleRate: number,
185
+ ): this {
186
+ const w = (TAU * clamp(hz, 10, sampleRate * 0.45)) / sampleRate;
187
+ const cw = Math.cos(w);
188
+ const sw = Math.sin(w);
189
+ const alpha = sw / (2 * q);
190
+ const A = 10 ** (gainDb / 40);
191
+ let b0 = 1;
192
+ let b1 = 0;
193
+ let b2 = 0;
194
+ let a0 = 1;
195
+ let a1 = 0;
196
+ let a2 = 0;
197
+ if (type === "lpf") {
198
+ b0 = (1 - cw) / 2;
199
+ b1 = 1 - cw;
200
+ b2 = b0;
201
+ a0 = 1 + alpha;
202
+ a1 = -2 * cw;
203
+ a2 = 1 - alpha;
204
+ } else if (type === "hpf") {
205
+ b0 = (1 + cw) / 2;
206
+ b1 = -(1 + cw);
207
+ b2 = b0;
208
+ a0 = 1 + alpha;
209
+ a1 = -2 * cw;
210
+ a2 = 1 - alpha;
211
+ } else if (type === "bpf") {
212
+ b0 = alpha;
213
+ b2 = -alpha;
214
+ a0 = 1 + alpha;
215
+ a1 = -2 * cw;
216
+ a2 = 1 - alpha;
217
+ } else if (type === "peak") {
218
+ b0 = 1 + alpha * A;
219
+ b1 = -2 * cw;
220
+ b2 = 1 - alpha * A;
221
+ a0 = 1 + alpha / A;
222
+ a1 = -2 * cw;
223
+ a2 = 1 - alpha / A;
224
+ } else {
225
+ const s = 2 * Math.sqrt(A) * alpha;
226
+ if (type === "lowshelf") {
227
+ b0 = A * (A + 1 - (A - 1) * cw + s);
228
+ b1 = 2 * A * (A - 1 - (A + 1) * cw);
229
+ b2 = A * (A + 1 - (A - 1) * cw - s);
230
+ a0 = A + 1 + (A - 1) * cw + s;
231
+ a1 = -2 * (A - 1 + (A + 1) * cw);
232
+ a2 = A + 1 + (A - 1) * cw - s;
233
+ } else {
234
+ b0 = A * (A + 1 + (A - 1) * cw + s);
235
+ b1 = -2 * A * (A - 1 + (A + 1) * cw);
236
+ b2 = A * (A + 1 + (A - 1) * cw - s);
237
+ a0 = A + 1 - (A - 1) * cw + s;
238
+ a1 = 2 * (A - 1 - (A + 1) * cw);
239
+ a2 = A + 1 - (A - 1) * cw - s;
240
+ }
241
+ }
242
+ this.b0 = b0 / a0;
243
+ this.b1 = b1 / a0;
244
+ this.b2 = b2 / a0;
245
+ this.a1 = a1 / a0;
246
+ this.a2 = a2 / a0;
247
+ return this;
248
+ }
249
+
250
+ process(x: number): number {
251
+ const y = this.b0 * x + this.z1;
252
+ this.z1 = this.b1 * x - this.a1 * y + this.z2;
253
+ this.z2 = this.b2 * x - this.a2 * y;
254
+ return y;
255
+ }
256
+ }
257
+
258
+ /** A seeded band-passed noise burst: knock, click, thud and key-off. */
259
+ export class Burst {
260
+ private readonly filter = new Biquad2();
261
+ private readonly random: () => number;
262
+ private n = 0;
263
+ readonly length: number;
264
+ private readonly attack: number;
265
+ private readonly k: number;
266
+ private env = 1;
267
+
268
+ constructor(
269
+ seed: string,
270
+ hz: number,
271
+ q: number,
272
+ tau: number,
273
+ readonly gain: number,
274
+ sampleRate: number,
275
+ attack = 0.0005,
276
+ ) {
277
+ this.random = seededRandom(seed);
278
+ this.filter.set("bpf", hz, q, 0, sampleRate);
279
+ this.attack = Math.max(1, Math.round(attack * sampleRate));
280
+ this.k = Math.exp(-1 / (tau * sampleRate));
281
+ this.length = this.attack + Math.ceil(tau * 7 * sampleRate);
282
+ }
283
+
284
+ next(): number {
285
+ const i = this.n++;
286
+ if (i >= this.length) return 0;
287
+ const e = i < this.attack ? i / this.attack : (this.env *= this.k);
288
+ return this.filter.process(this.random() * 2 - 1) * e * this.gain;
289
+ }
290
+
291
+ get done(): boolean {
292
+ return this.n >= this.length;
293
+ }
294
+ }
295
+
296
+ /** Equal-power pan gains for -1..1. */
297
+ export function panGains(pan: number): [number, number] {
298
+ const a = ((clamp(pan, -1, 1) + 1) * Math.PI) / 4;
299
+ return [Math.cos(a), Math.sin(a)];
300
+ }
301
+
302
+ /** Normalised hammer force pulse: half-sine^sharp over `length` samples, sum 1. */
303
+ export function hammerPulse(length: number, sharp: number): Float64Array {
304
+ const pulse = new Float64Array(length);
305
+ let sum = 0;
306
+ for (let m = 0; m < length; m += 1) {
307
+ pulse[m] = Math.sin((Math.PI * (m + 0.5)) / length) ** sharp;
308
+ sum += pulse[m]!;
309
+ }
310
+ for (let m = 0; m < length; m += 1) pulse[m]! /= sum;
311
+ return pulse;
312
+ }
313
+
314
+ /** |DTFT| of the pulse at angle `w` (rad/sample). */
315
+ export function pulseMag(pulse: Float64Array, w: number): number {
316
+ let re = 0;
317
+ let im = 0;
318
+ for (let m = 0; m < pulse.length; m += 1) {
319
+ re += pulse[m]! * Math.cos(w * m);
320
+ im -= pulse[m]! * Math.sin(w * m);
321
+ }
322
+ return Math.hypot(re, im);
323
+ }