@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,230 @@
1
+ /**
2
+ * Prompt grammar for generated rhythm rows (`core/euclid.ts`). Every
3
+ * command is one next score, so one revision and one undo step; the
4
+ * Euclidean editor (`src/tui/euclid.ts`) emits these same lines.
5
+ *
6
+ * euclid <voice> <pulses> [<steps>] [<field> <value> ...]
7
+ * euclid <voice> <field> <value> [<field> <value> ...]
8
+ * euclid <voice> off | freeze remove the row (freeze keeps notes)
9
+ * grid <voice> <steps> explicit steps: x hit, X accent, . rest
10
+ *
11
+ * Fields: steps, pulses, rotate (rot), division (div), repeats (rep), time,
12
+ * pace, ramp, velocity (vel), accent, accents, gate, legato on|off,
13
+ * probability (prob), seed, swing, nudge. `default` resets a field. Named
14
+ * fields merge into the voice's existing row, so `euclid hat rotate 2`
15
+ * only rotates.
16
+ */
17
+ import {
18
+ RHYTHM_DEFAULTS,
19
+ canonicalDivision,
20
+ normalizeRhythmRow,
21
+ rowSummary,
22
+ type RhythmRow,
23
+ } from "../../core/euclid.ts";
24
+ import {
25
+ removeRhythmRow,
26
+ rhythmVoicePitch,
27
+ setRhythmRow,
28
+ } from "../../core/rhythm.ts";
29
+ import { ScoreValidationError, type TrackScore } from "../../core/score.ts";
30
+
31
+ export type RhythmCommand =
32
+ | Readonly<{
33
+ type: "rhythm-set";
34
+ voice: string;
35
+ /** Field values to merge; `null` resets a field to its default. */
36
+ fields: Readonly<Record<string, number | string | boolean | null>>;
37
+ }>
38
+ | Readonly<{ type: "rhythm-remove"; voice: string; keepNotes: boolean }>;
39
+
40
+ const FIELD_ALIASES: Readonly<Record<string, string>> = Object.freeze({
41
+ steps: "steps",
42
+ pulses: "pulses",
43
+ hits: "pulses",
44
+ rotate: "rotate",
45
+ rot: "rotate",
46
+ division: "division",
47
+ div: "division",
48
+ repeats: "repeats",
49
+ rep: "repeats",
50
+ time: "time",
51
+ pace: "pace",
52
+ ramp: "ramp",
53
+ velocity: "velocity",
54
+ vel: "velocity",
55
+ accent: "accent",
56
+ accents: "accents",
57
+ gate: "gate",
58
+ sustain: "gate",
59
+ legato: "legato",
60
+ probability: "probability",
61
+ prob: "probability",
62
+ seed: "seed",
63
+ swing: "swing",
64
+ nudge: "nudge",
65
+ grid: "grid",
66
+ });
67
+
68
+ const DIVISION_FIELDS = new Set(["division", "time"]);
69
+ const NUMBER = /^-?\d+(?:\.\d+)?$/;
70
+ const VOICE = /^[A-Za-z0-9#_-]{1,32}$/;
71
+
72
+ /** Parses one rhythm command, or undefined when `prompt` is not one. */
73
+ export function parseRhythmCommand(prompt: string): RhythmCommand | undefined {
74
+ const text = prompt.trim().replace(/\s+/g, " ");
75
+ if (text.length > 512) return undefined;
76
+ const grid = text.match(/^grid ([A-Za-z0-9#_-]{1,32}) ([xX.\-]{1,64})$/i);
77
+ if (grid)
78
+ return {
79
+ type: "rhythm-set",
80
+ voice: grid[1]!.toLowerCase(),
81
+ fields: { grid: grid[2]! },
82
+ };
83
+ const head = text.match(/^euclid ([A-Za-z0-9#_-]{1,32})(?: (.*))?$/i);
84
+ if (!head) return undefined;
85
+ const voice = head[1]!;
86
+ if (!VOICE.test(voice)) return undefined;
87
+ const tokens = (head[2] ?? "").split(" ").filter((token) => token !== "");
88
+ if (tokens.length === 1 && /^(off|clear|remove)$/i.test(tokens[0]!))
89
+ return { type: "rhythm-remove", voice: lowerDrum(voice), keepNotes: false };
90
+ if (tokens.length === 1 && /^freeze$/i.test(tokens[0]!))
91
+ return { type: "rhythm-remove", voice: lowerDrum(voice), keepNotes: true };
92
+ const fields: Record<string, number | string | boolean | null> = {};
93
+ let index = 0;
94
+ if (tokens[index] !== undefined && /^\d+$/.test(tokens[index]!)) {
95
+ fields.pulses = Number(tokens[index]);
96
+ index += 1;
97
+ if (tokens[index] !== undefined && /^\d+$/.test(tokens[index]!)) {
98
+ fields.steps = Number(tokens[index]);
99
+ index += 1;
100
+ }
101
+ }
102
+ while (index < tokens.length) {
103
+ const key = FIELD_ALIASES[tokens[index]!.toLowerCase()];
104
+ const raw = tokens[index + 1];
105
+ if (!key || raw === undefined) return undefined;
106
+ index += 2;
107
+ if (/^default$/i.test(raw)) fields[key] = null;
108
+ else if (key === "legato") {
109
+ if (!/^(on|off|true|false)$/i.test(raw)) return undefined;
110
+ fields[key] = /^(on|true)$/i.test(raw);
111
+ } else if (DIVISION_FIELDS.has(key)) {
112
+ const division = canonicalDivision(raw);
113
+ if (!division) return undefined;
114
+ fields[key] = division;
115
+ } else if (key === "grid") {
116
+ if (!/^[xX.\-]{1,64}$/.test(raw)) return undefined;
117
+ fields[key] = raw;
118
+ } else {
119
+ if (!NUMBER.test(raw)) return undefined;
120
+ fields[key] = Number(raw);
121
+ }
122
+ }
123
+ return { type: "rhythm-set", voice: lowerDrum(voice), fields };
124
+ }
125
+
126
+ /** Pitch voices keep their case (`C#2`); drum and sampler names are lower-case. */
127
+ function lowerDrum(voice: string): string {
128
+ return /^[A-Ga-g][#b]?-?\d$/.test(voice) ? voice : voice.toLowerCase();
129
+ }
130
+
131
+ export type RhythmResult = Readonly<{
132
+ ok: boolean;
133
+ message: string;
134
+ next?: TrackScore;
135
+ kind?: string;
136
+ payload?: Record<string, unknown>;
137
+ }>;
138
+
139
+ /**
140
+ * The row `command` produces from `current` (the voice's existing row, if
141
+ * any). Throws `Error` naming the field when the result is invalid.
142
+ */
143
+ export function mergeRhythmRow(
144
+ current: RhythmRow | undefined,
145
+ voice: string,
146
+ fields: Readonly<Record<string, number | string | boolean | null>>,
147
+ ): RhythmRow {
148
+ const merged: Record<string, unknown> = { ...(current ?? { voice }) };
149
+ merged.voice = voice;
150
+ for (const [key, value] of Object.entries(fields)) {
151
+ if (value === null) delete merged[key];
152
+ else merged[key] = value;
153
+ }
154
+ // Naming pulses or steps switches a grid row back to a Euclidean one.
155
+ if ("pulses" in fields || "steps" in fields)
156
+ if (!("grid" in fields)) delete merged.grid;
157
+ if ("grid" in fields && fields.grid !== null) {
158
+ delete merged.steps;
159
+ delete merged.pulses;
160
+ delete merged.rotate;
161
+ }
162
+ // Keep pulses inside a shortened row instead of rejecting the edit.
163
+ if (merged.grid === undefined) {
164
+ const steps =
165
+ typeof merged.steps === "number" ? merged.steps : RHYTHM_DEFAULTS.steps;
166
+ const pulses =
167
+ typeof merged.pulses === "number"
168
+ ? merged.pulses
169
+ : RHYTHM_DEFAULTS.pulses;
170
+ if (!("pulses" in fields) && pulses > steps) merged.pulses = steps;
171
+ }
172
+ return normalizeRhythmRow(merged, voice);
173
+ }
174
+
175
+ /** Applies `command` to `trackId`; one next score, one session event. */
176
+ export function applyRhythmCommand(
177
+ score: TrackScore,
178
+ trackId: string,
179
+ command: RhythmCommand,
180
+ ): RhythmResult {
181
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
182
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
183
+ const existing = (track.rhythm ?? []).find(
184
+ (row) => row.voice === command.voice,
185
+ );
186
+ if (command.type === "rhythm-remove") {
187
+ if (!existing)
188
+ return { ok: false, message: `no ${command.voice} row on ${trackId}` };
189
+ return {
190
+ ok: true,
191
+ message: `euclid · ${command.voice} ${command.keepNotes ? "frozen" : "off"}`,
192
+ next: removeRhythmRow(score, trackId, command.voice, command.keepNotes),
193
+ kind: "score.rhythm",
194
+ payload: {
195
+ trackId,
196
+ voice: command.voice,
197
+ remove: true,
198
+ keepNotes: command.keepNotes,
199
+ },
200
+ };
201
+ }
202
+ if (rhythmVoicePitch(track, command.voice) === undefined)
203
+ return {
204
+ ok: false,
205
+ message: `${command.voice} is not a voice on ${trackId} · try instrument kit`,
206
+ };
207
+ let row: RhythmRow;
208
+ try {
209
+ row = mergeRhythmRow(existing, command.voice, command.fields);
210
+ } catch (error) {
211
+ return {
212
+ ok: false,
213
+ message: `euclid · ${error instanceof Error ? error.message : String(error)}`,
214
+ };
215
+ }
216
+ try {
217
+ const next = setRhythmRow(score, trackId, row);
218
+ return {
219
+ ok: true,
220
+ message: `euclid · ${row.voice} ${rowSummary(row)}`,
221
+ next,
222
+ kind: "score.rhythm",
223
+ payload: { trackId, row },
224
+ };
225
+ } catch (error) {
226
+ if (error instanceof ScoreValidationError)
227
+ return { ok: false, message: `euclid · ${error.message}` };
228
+ throw error;
229
+ }
230
+ }
@@ -0,0 +1,462 @@
1
+ /**
2
+ * `/sample <path> [as <voice>]` and `/sample`: add a sample voice to the
3
+ * focused track, or list its voices. The score edit is pure
4
+ * (`addSampleVoice`); the window copies the file into the project
5
+ * (`placeSampleFile`) and commits, and project sync reprints track.ts.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import {
9
+ copyFile,
10
+ mkdir,
11
+ readFile,
12
+ realpath,
13
+ rename,
14
+ stat,
15
+ } from "node:fs/promises";
16
+ import { basename, extname, join, relative, resolve, sep } from "node:path";
17
+ import {
18
+ SAMPLER_INSTRUMENT,
19
+ SCORE_LIMITS,
20
+ TrackScore,
21
+ addTrack,
22
+ isSamplerInstrument,
23
+ normalizeSampleRef,
24
+ samplerVoiceSlots,
25
+ type Sampler,
26
+ type SampleRef,
27
+ type Track,
28
+ } from "../../core/score.ts";
29
+ import { trackDirectories } from "../../core/sdk/print.ts";
30
+
31
+ export type SampleCommand =
32
+ | Readonly<{ kind: "list" }>
33
+ | Readonly<{ kind: "add"; path: string; voice?: string }>
34
+ | Readonly<{
35
+ kind: "set";
36
+ voice: string;
37
+ values: Readonly<Record<string, SampleControlValue>>;
38
+ }>;
39
+
40
+ /** A sample control value: a number, a switch, a unit, or null to unset. */
41
+ export type SampleControlValue = number | string | boolean | null;
42
+
43
+ /**
44
+ * Per-voice sample controls `/sample set` and `set_sample` change, by
45
+ * Strudel name. Aliases resolve to the stored field.
46
+ */
47
+ export const SAMPLE_CONTROLS = Object.freeze({
48
+ begin: "fraction 0..1 of the file where playback starts",
49
+ end: "fraction 0..1 where it stops",
50
+ gain: "linear 0..2",
51
+ speed: "rate (negative reverses); with unit c/s a duration",
52
+ unit: "r (rate), c (speed in cycles = bars), s (speed in seconds)",
53
+ loop: "on/off: sustain by looping",
54
+ loopBegin: "fraction where the loop starts (≥ begin); alias loopb",
55
+ loopEnd: "fraction where the loop ends (≤ end); alias loope",
56
+ clip: "voice lasts note length × clip, cutting the sample; alias legato",
57
+ fit: "on/off: the window lasts the note's length",
58
+ loopAt: "the window lasts n bars (sets speed 1/n, unit c)",
59
+ accelerate: "rate ramps by this × over the voice (−8..8)",
60
+ squiz: "pitch-raise ratio per zero-crossing cycle (1..32)",
61
+ cut: "choke group name: a new hit stops the previous one",
62
+ });
63
+ export type SampleControl = keyof typeof SAMPLE_CONTROLS;
64
+
65
+ const SAMPLE_ALIASES: Readonly<Record<string, string>> = {
66
+ loopb: "loopBegin",
67
+ loope: "loopEnd",
68
+ legato: "clip",
69
+ choke: "cut",
70
+ };
71
+
72
+ function controlName(raw: string): SampleControl | undefined {
73
+ const lower = raw.toLowerCase();
74
+ const name =
75
+ Object.keys(SAMPLE_CONTROLS).find((key) => key.toLowerCase() === lower) ??
76
+ SAMPLE_ALIASES[lower];
77
+ return name as SampleControl | undefined;
78
+ }
79
+
80
+ /** `/sample`, `/sample <path>`, `/sample <path> as <voice>`, `/sample set <voice> <control> <value>…`; else undefined. */
81
+ export function parseSampleCommand(command: string): SampleCommand | undefined {
82
+ const match = command.trim().match(/^\/samples?(?:\s+(.+?))?\s*$/i);
83
+ if (!match) return undefined;
84
+ const rest = match[1];
85
+ if (!rest) return { kind: "list" };
86
+ const set = rest.match(/^set\s+([A-Za-z][A-Za-z0-9_]*)\s+(.+)$/i);
87
+ if (set) {
88
+ const tokens = set[2]!.split(/\s+/);
89
+ const values: Record<string, SampleControlValue> = {};
90
+ for (let i = 0; i < tokens.length; i += 2)
91
+ values[tokens[i]!] = parseControlToken(tokens[i + 1]);
92
+ return { kind: "set", voice: set[1]!, values };
93
+ }
94
+ const named = rest.match(/^(.+?)\s+as\s+(\S+)$/i);
95
+ const path = unquote((named ? named[1]! : rest).trim());
96
+ return named
97
+ ? { kind: "add", path, voice: named[2]! }
98
+ : { kind: "add", path };
99
+ }
100
+
101
+ function parseControlToken(token: string | undefined): SampleControlValue {
102
+ if (token === undefined) return "";
103
+ const lower = token.toLowerCase();
104
+ if (lower === "on" || lower === "true") return true;
105
+ if (lower === "off" || lower === "false") return false;
106
+ if (lower === "none" || lower === "unset" || lower === "default") return null;
107
+ const value = Number(token);
108
+ return token.trim() !== "" && Number.isFinite(value) ? value : token;
109
+ }
110
+
111
+ export type SetSampleResult =
112
+ | Readonly<{ ok: true; next: TrackScore; message: string }>
113
+ | Readonly<{ ok: false; message: string }>;
114
+
115
+ /**
116
+ * Set or unset (`null`, or `off` for numbers) per-voice sample controls on
117
+ * `trackId`'s voice. Validation is the score's: limits and window rules.
118
+ */
119
+ export function setSampleControls(
120
+ score: TrackScore,
121
+ trackId: string,
122
+ voice: string,
123
+ values: Readonly<Record<string, SampleControlValue>>,
124
+ ): SetSampleResult {
125
+ const track = score.tracks.find((item) => item.id === trackId);
126
+ const sampler = track?.sampler;
127
+ if (!track || !sampler || !isSamplerInstrument(track.instrument))
128
+ return {
129
+ ok: false,
130
+ message: `sample · ${trackId} is not a sampler track · /sample <path> first`,
131
+ };
132
+ const ref = sampler.voices[voice];
133
+ if (!ref)
134
+ return {
135
+ ok: false,
136
+ message: `sample · ${trackId} has no voice "${voice}" · /sample lists them`,
137
+ };
138
+ const keys = Object.keys(values);
139
+ if (keys.length === 0)
140
+ return {
141
+ ok: false,
142
+ message: `sample · set needs a control · ${Object.keys(SAMPLE_CONTROLS).join(" ")}`,
143
+ };
144
+ const next: Record<string, unknown> = { ...ref };
145
+ const changed: string[] = [];
146
+ for (const raw of keys) {
147
+ const name = controlName(raw);
148
+ if (!name)
149
+ return {
150
+ ok: false,
151
+ message: `sample · unknown control "${raw.slice(0, 40)}" · ${Object.keys(SAMPLE_CONTROLS).join(" ")}`,
152
+ };
153
+ let value: SampleControlValue = values[raw] ?? null;
154
+ if (value === "")
155
+ return { ok: false, message: `sample · ${raw} needs a value` };
156
+ const field = name === "cut" ? "choke" : name;
157
+ // `off` unsets a control (back to its default): numbers, switches, cut.
158
+ if (value === false && name !== "unit") value = null;
159
+ if (name === "loopAt") {
160
+ if (value === null) {
161
+ delete next.speed;
162
+ delete next.unit;
163
+ } else if (typeof value !== "number" || value <= 0)
164
+ return {
165
+ ok: false,
166
+ message: "sample · loopAt needs a positive number of bars",
167
+ };
168
+ else {
169
+ next.speed = 1 / value;
170
+ next.unit = "c";
171
+ }
172
+ changed.push(value === null ? "loopAt off" : `loopAt ${value}`);
173
+ continue;
174
+ }
175
+ if (name === "cut" && typeof value === "number") value = `cut${value}`;
176
+ if (value === null) delete next[field];
177
+ else next[field] = value;
178
+ changed.push(`${raw} ${value === null ? "off" : String(value)}`);
179
+ }
180
+ let updated: TrackScore;
181
+ try {
182
+ const voices = {
183
+ ...sampler.voices,
184
+ [voice]: normalizeSampleRef(next, voice),
185
+ };
186
+ updated = new TrackScore({
187
+ ...score.toJSON(),
188
+ tracks: score.tracks.map((item) =>
189
+ item.id === trackId
190
+ ? { ...item, sampler: { ...sampler, voices } }
191
+ : item,
192
+ ),
193
+ });
194
+ } catch (error) {
195
+ return {
196
+ ok: false,
197
+ message: `sample · ${error instanceof Error ? error.message : String(error)}`,
198
+ };
199
+ }
200
+ return {
201
+ ok: true,
202
+ next: updated,
203
+ message: `sample · ${trackId}/${voice} · ${changed.join(" · ")}`,
204
+ };
205
+ }
206
+
207
+ function unquote(value: string): string {
208
+ const quoted = value.match(/^(["'])(.*)\1$/);
209
+ return quoted ? quoted[2]! : value;
210
+ }
211
+
212
+ const VOICE = /^[a-z][a-z0-9_]{0,31}$/;
213
+
214
+ /** A voice name from a file name: `Kick 01.wav` → `kick_01`. */
215
+ export function voiceNameFrom(path: string): string {
216
+ const stem = basename(path, extname(path))
217
+ .toLowerCase()
218
+ .replace(/[^a-z0-9_]+/g, "_")
219
+ .replace(/^[^a-z]+/, "")
220
+ .replace(/_+$/, "")
221
+ .slice(0, 32);
222
+ return stem || "sample";
223
+ }
224
+
225
+ /** Lines for `/sample` with no args: one per voice, in slot order. */
226
+ export function listSampleVoices(track: Track | undefined): string[] {
227
+ const sampler = track?.sampler;
228
+ if (!track || !isSamplerInstrument(track.instrument) || !sampler) return [];
229
+ const slots = samplerVoiceSlots(sampler);
230
+ return Object.keys(sampler.voices)
231
+ .sort((a, b) => (slots.get(a) ?? 0) - (slots.get(b) ?? 0) || cmp(a, b))
232
+ .map((voice) => {
233
+ const ref = sampler.voices[voice]!;
234
+ const slot = slots.get(voice);
235
+ const where =
236
+ slot === undefined ? `root ${ref.root ?? 60}` : `slot ${slot}`;
237
+ return `${voice} · ${ref.src} · ${where}`;
238
+ });
239
+ }
240
+
241
+ function cmp(a: string, b: string): number {
242
+ return a < b ? -1 : a > b ? 1 : 0;
243
+ }
244
+
245
+ export type AddVoiceResult =
246
+ | Readonly<{
247
+ ok: true;
248
+ next: TrackScore;
249
+ /** Track that received the voice (new when the focus was not a sampler). */
250
+ trackId: string;
251
+ voice: string;
252
+ message: string;
253
+ }>
254
+ | Readonly<{ ok: false; message: string }>;
255
+
256
+ /**
257
+ * Where a voice lands: the focused track when it is a sampler or an empty
258
+ * track (converted), otherwise a new `samples` track so existing notes keep
259
+ * their instrument.
260
+ */
261
+ export function samplerTarget(score: TrackScore, focusedId: string): string {
262
+ const focused = score.tracks.find((track) => track.id === focusedId);
263
+ if (focused && isSamplerInstrument(focused.instrument)) return focused.id;
264
+ const empty = !score.notes.some((note) => note.trackId === focusedId);
265
+ if (!focused || empty) return focusedId;
266
+ let id = "samples";
267
+ for (let n = 2; score.tracks.some((track) => track.id === id); n += 1)
268
+ id = `samples-${n}`;
269
+ return id;
270
+ }
271
+
272
+ /**
273
+ * Add `voice` → `ref` to `trackId`, creating or converting it to a oneshot
274
+ * sampler. One-shot hits are addressed by slot (36 + voice-name order), so
275
+ * existing hits are re-pitched to keep playing the same voice when a new
276
+ * name sorts before them.
277
+ */
278
+ export function addSampleVoice(
279
+ score: TrackScore,
280
+ trackId: string,
281
+ voice: string,
282
+ ref: SampleRef,
283
+ ): AddVoiceResult {
284
+ if (!VOICE.test(voice))
285
+ return {
286
+ ok: false,
287
+ message: `sample · voice "${voice.slice(0, 40)}" is not a valid name · use a-z, 0-9 and _, starting with a letter (/sample <path> as kick)`,
288
+ };
289
+ const existing = score.tracks.find((track) => track.id === trackId);
290
+ const previous =
291
+ existing && isSamplerInstrument(existing.instrument)
292
+ ? existing.sampler
293
+ : undefined;
294
+ if (previous && voice in previous.voices)
295
+ return {
296
+ ok: false,
297
+ message: `sample · ${trackId} already has a voice "${voice}" · pick another name with as <voice>`,
298
+ };
299
+ const count = Object.keys(previous?.voices ?? {}).length;
300
+ if (count >= SCORE_LIMITS.maxSamplerVoices)
301
+ return {
302
+ ok: false,
303
+ message: `sample · ${trackId} has ${count} voices, the limit · remove one in track.ts first`,
304
+ };
305
+ const sampler: Sampler = {
306
+ mode: previous?.mode ?? "oneshot",
307
+ voices: { ...(previous?.voices ?? {}), [voice]: ref },
308
+ };
309
+ let next: TrackScore;
310
+ try {
311
+ if (!existing)
312
+ next = addTrack(score, {
313
+ id: trackId,
314
+ name: trackId,
315
+ instrument: SAMPLER_INSTRUMENT,
316
+ sampler,
317
+ });
318
+ else {
319
+ const json = score.toJSON();
320
+ const oldSlots = previous ? samplerVoiceSlots(previous) : new Map();
321
+ const newSlots = samplerVoiceSlots(sampler);
322
+ const remap = new Map<number, number>();
323
+ for (const [name, slot] of oldSlots) {
324
+ const moved = newSlots.get(name);
325
+ if (moved !== undefined && moved !== slot) remap.set(slot, moved);
326
+ }
327
+ next = new TrackScore({
328
+ ...json,
329
+ tracks: score.tracks.map((track) =>
330
+ track.id === trackId
331
+ ? { ...track, instrument: SAMPLER_INSTRUMENT, sampler }
332
+ : track,
333
+ ),
334
+ notes: score.notes.map((note) =>
335
+ note.trackId === trackId && remap.has(note.pitch)
336
+ ? { ...note, pitch: remap.get(note.pitch)! }
337
+ : note,
338
+ ),
339
+ });
340
+ }
341
+ } catch (error) {
342
+ return {
343
+ ok: false,
344
+ message: `sample · ${error instanceof Error ? error.message : String(error)}`,
345
+ };
346
+ }
347
+ const slot = samplerVoiceSlots(sampler).get(voice);
348
+ return {
349
+ ok: true,
350
+ next,
351
+ trackId,
352
+ voice,
353
+ message: `sample · ${voice} on ${trackId}${slot === undefined ? "" : ` · slot ${slot}`} · ${Object.keys(sampler.voices).length} voice${Object.keys(sampler.voices).length === 1 ? "" : "s"}`,
354
+ };
355
+ }
356
+
357
+ /** First free voice name based on `base` (`kick`, `kick_2`, …). */
358
+ export function freeVoiceName(
359
+ score: TrackScore,
360
+ trackId: string,
361
+ base: string,
362
+ ): string {
363
+ const voices =
364
+ score.tracks.find((track) => track.id === trackId)?.sampler?.voices ?? {};
365
+ if (!(base in voices)) return base;
366
+ for (let n = 2; ; n += 1) {
367
+ const name = `${base.slice(0, 29)}_${n}`;
368
+ if (!(name in voices)) return name;
369
+ }
370
+ }
371
+
372
+ export class SamplePlacementError extends Error {}
373
+
374
+ /**
375
+ * Make `input` (relative to `cwd`) available to `trackId` and return its
376
+ * `src`, relative to the track directory. A file already under the track's
377
+ * directory is referenced in place; anything else is copied to
378
+ * `tracks/<slug>/samples/<voice><ext>`. Enforces the sample size limit.
379
+ */
380
+ export async function placeSampleFile(
381
+ options: Readonly<{
382
+ projectRoot: string;
383
+ cwd: string;
384
+ input: string;
385
+ score: TrackScore;
386
+ trackId: string;
387
+ voice: string;
388
+ }>,
389
+ ): Promise<Readonly<{ src: string; sha256: string; copied: boolean }>> {
390
+ let real: string;
391
+ try {
392
+ real = await realpath(resolve(options.cwd, options.input));
393
+ } catch {
394
+ throw new SamplePlacementError(`${options.input}: no such file`);
395
+ }
396
+ const info = await stat(real);
397
+ if (!info.isFile())
398
+ throw new SamplePlacementError(`${options.input} is not a file`);
399
+ if (info.size > SCORE_LIMITS.maxSampleFileBytes)
400
+ throw new SamplePlacementError(
401
+ `${options.input} is ${Math.ceil(info.size / 1048576)} MiB, over the ${SCORE_LIMITS.maxSampleFileBytes / 1048576} MiB sample limit`,
402
+ );
403
+ const root = await realpath(options.projectRoot);
404
+ // The track directory is decided by the score that will hold the voice.
405
+ const withTrack = options.score.tracks.some((t) => t.id === options.trackId)
406
+ ? options.score
407
+ : addTrack(options.score, { id: options.trackId, name: options.trackId });
408
+ const slug =
409
+ trackDirectories(withTrack).get(options.trackId) ?? options.trackId;
410
+ const trackDir = join(root, "tracks", slug);
411
+ const sha256 = createHash("sha256")
412
+ .update(await readFile(real))
413
+ .digest("hex");
414
+ const inTrack = relative(trackDir, real);
415
+ if (
416
+ inTrack &&
417
+ !inTrack.startsWith("..") &&
418
+ !inTrack.startsWith(sep) &&
419
+ !inTrack.includes(`..${sep}`)
420
+ )
421
+ return { src: inTrack.split(sep).join("/"), sha256, copied: false };
422
+ const ext =
423
+ extname(real)
424
+ .toLowerCase()
425
+ .match(/^\.[a-z0-9]{1,8}$/)?.[0] ?? ".wav";
426
+ const dir = join(trackDir, "samples");
427
+ await mkdir(dir, { recursive: true });
428
+ // The samples directory must still be inside the project after links.
429
+ const realDir = await realpath(dir);
430
+ const back = relative(root, realDir);
431
+ if (back.startsWith("..") || resolve(root, back) !== realDir)
432
+ throw new SamplePlacementError(
433
+ `tracks/${slug}/samples resolves outside the project`,
434
+ );
435
+ let name = `${options.voice}${ext}`;
436
+ for (let n = 2; await exists(join(realDir, name)); n += 1) {
437
+ if ((await sha256Of(join(realDir, name))) === sha256) break;
438
+ name = `${options.voice}_${n}${ext}`;
439
+ }
440
+ const target = join(realDir, name);
441
+ if (!(await exists(target))) {
442
+ const temporary = `${target}.${process.pid}.tmp`;
443
+ await copyFile(real, temporary);
444
+ await rename(temporary, target);
445
+ }
446
+ return { src: `samples/${name}`, sha256, copied: true };
447
+ }
448
+
449
+ async function exists(path: string): Promise<boolean> {
450
+ try {
451
+ await stat(path);
452
+ return true;
453
+ } catch {
454
+ return false;
455
+ }
456
+ }
457
+
458
+ async function sha256Of(path: string): Promise<string> {
459
+ return createHash("sha256")
460
+ .update(await readFile(path))
461
+ .digest("hex");
462
+ }