@hraness/dawg 0.6.1 → 0.8.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 (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
@@ -0,0 +1,627 @@
1
+ /**
2
+ * `/vocoder` (0.7, vocoder.md §8): put a vocoder on a track or tune it.
3
+ *
4
+ * - `/vocoder` on a track with audio (a vocal) and no vocoder makes a
5
+ * `<name> vocoder` carrier track driven by it and mutes the vocal, in one
6
+ * undo step; on a vocoder track it shows the settings and the modulator's
7
+ * licence; with nothing to vocode it changes nothing and says how to get a
8
+ * voice in (the Entry guidance).
9
+ * - `/vocoder <preset>`, `/vocoder src <track>`, `/vocoder <param> <value>`
10
+ * (every VOCODER_PARAMS key), `/vocoder <param> reset`, `/vocoder reset`,
11
+ * `/vocoder off`, `/vocoder presets`.
12
+ *
13
+ * Preset words always win over track names: a track named `robot` is chosen
14
+ * with `/vocoder src robot`. The agent tools share `nextVocoder`,
15
+ * `vocodeTrack` and `vocoderProvenance`.
16
+ */
17
+ import { parseKey } from "../../core/chords.ts";
18
+ import { FxValidationError } from "../../core/fx.ts";
19
+ import { resolveTrackRef } from "../../core/routing.ts";
20
+ import {
21
+ addTrack,
22
+ ScoreValidationError,
23
+ SCORE_LIMITS,
24
+ updateTrack,
25
+ type Track,
26
+ type TrackScore,
27
+ } from "../../core/score.ts";
28
+ import { trackSlug } from "../../core/slug.ts";
29
+ import {
30
+ DEFAULT_VOCODER_PRESET,
31
+ isVocoderPreset,
32
+ normalizeVocoder,
33
+ resolveVocoder,
34
+ VOCODER_GATE_AUTO,
35
+ VOCODER_INSTRUMENT,
36
+ VOCODER_PARAMS,
37
+ VOCODER_PRESET_NAMES,
38
+ VOCODER_PRESETS,
39
+ vocoderParamName,
40
+ type TrackVocoder,
41
+ type VocoderCarrier,
42
+ type VocoderFollow,
43
+ type VocoderPresetName,
44
+ } from "../../core/vocoder.ts";
45
+ import { chordTimeline } from "../audio/granular.ts";
46
+ import { nearest } from "./nearest.ts";
47
+ import { parseParamValue } from "./fx.ts";
48
+ import { VOCAL_VERBS } from "./vocal.ts";
49
+
50
+ export type VocoderValue = number | string | boolean | null;
51
+
52
+ export type VocoderCommand =
53
+ /** `fresh` (`/vocoder new`) makes another carrier for a vocal. */
54
+ | { type: "vocoder-show"; fresh?: boolean }
55
+ | { type: "vocoder-list" }
56
+ | { type: "vocoder-reset" }
57
+ | { type: "vocoder-off" }
58
+ | { type: "vocoder-usage"; message: string }
59
+ | {
60
+ type: "vocoder-set";
61
+ /** A preset applied before the values (`vocoder talkbox formant 3`). */
62
+ preset?: VocoderPresetName;
63
+ /** The modulator, an id or a name slug. */
64
+ src?: string;
65
+ /** `null` returns a parameter to the preset's value. */
66
+ values: Readonly<Record<string, VocoderValue>>;
67
+ };
68
+
69
+ export const VOCODER_USAGE =
70
+ "vocoder [preset] | vocoder new | vocoder src <track> | vocoder <param> <value|reset> | vocoder reset | vocoder off | vocoder presets";
71
+
72
+ /** The keys `/vocoder <param>` takes: exactly VOCODER_PARAMS. */
73
+ export const VOCODER_COMMAND_KEYS: readonly string[] = Object.freeze(
74
+ Object.keys(VOCODER_PARAMS),
75
+ );
76
+
77
+ function rangeOf(name: string): string {
78
+ const spec = VOCODER_PARAMS[name]!;
79
+ if (name === "gate")
80
+ return `gate is ${spec.kind === "number" ? `${spec.min}..${spec.max} dB` : ""} or auto`;
81
+ if (spec.kind === "number") return `${name} is ${spec.min}..${spec.max}`;
82
+ if (spec.kind === "enum") return `${name} is one of ${spec.values.join(" ")}`;
83
+ return `${name} is on or off`;
84
+ }
85
+
86
+ /** One `<param> <value>` pair; undefined value means a range error. */
87
+ function parseValue(name: string, word: string): VocoderValue | undefined {
88
+ if (word === "reset" || word === "unset") return null;
89
+ if (name === "gate" && word === VOCODER_GATE_AUTO) return VOCODER_GATE_AUTO;
90
+ // `+3` reads as 3 (`/vocoder formant +3`).
91
+ const text = /^\+\d/.test(word) ? word.slice(1) : word;
92
+ return parseParamValue(VOCODER_PARAMS[name]!, text);
93
+ }
94
+
95
+ export function parseVocoderCommand(
96
+ prompt: string,
97
+ ): VocoderCommand | undefined {
98
+ if (prompt.length > 512) return undefined;
99
+ const raw = prompt.trim().replace(/^\//, "").split(/\s+/);
100
+ const words = raw.map((word) => word.toLowerCase());
101
+ if (words[0] === "presets" && words[1] === "vocoder" && words.length === 2)
102
+ return { type: "vocoder-list" };
103
+ if (words[0] !== "vocoder") return undefined;
104
+ if (words.length === 1) return { type: "vocoder-show" };
105
+ const rest = words.slice(1);
106
+ const restRaw = raw.slice(1);
107
+ if (rest.length === 1) {
108
+ if (rest[0] === "reset") return { type: "vocoder-reset" };
109
+ if (rest[0] === "off") return { type: "vocoder-off" };
110
+ if (rest[0] === "new") return { type: "vocoder-show", fresh: true };
111
+ if (rest[0] === "list" || rest[0] === "presets")
112
+ return { type: "vocoder-list" };
113
+ }
114
+ let preset: VocoderPresetName | undefined;
115
+ if (rest[0] === "preset") {
116
+ if (rest.length !== 2 || !isVocoderPreset(rest[1]!))
117
+ return {
118
+ type: "vocoder-usage",
119
+ message: `preset is one of ${VOCODER_PRESET_NAMES.join(" ")}`,
120
+ };
121
+ return {
122
+ type: "vocoder-set",
123
+ preset: rest[1] as VocoderPresetName,
124
+ values: {},
125
+ };
126
+ }
127
+ if (isVocoderPreset(rest[0]!)) {
128
+ preset = rest[0] as VocoderPresetName;
129
+ rest.shift();
130
+ restRaw.shift();
131
+ }
132
+ let src: string | undefined;
133
+ const values: Record<string, VocoderValue> = {};
134
+ for (let index = 0; index < rest.length;) {
135
+ const key = rest[index]!;
136
+ if (key === "src" || key === "source") {
137
+ // A quoted run of words, or every word up to the next parameter
138
+ // (`src Lead Vox formant 3` names the track `Lead Vox`).
139
+ const words: string[] = [];
140
+ let next = index + 1;
141
+ const first = restRaw[next];
142
+ const quote = first?.match(/^["']/)?.[0];
143
+ if (quote) {
144
+ for (; next < restRaw.length; next += 1) {
145
+ words.push(restRaw[next]!);
146
+ if (
147
+ (words.length > 1 || first!.length > 1) &&
148
+ restRaw[next]!.endsWith(quote)
149
+ ) {
150
+ next += 1;
151
+ break;
152
+ }
153
+ }
154
+ } else {
155
+ for (; next < restRaw.length; next += 1) {
156
+ const word = rest[next]!;
157
+ if (vocoderParamName(word) || word === "src" || word === "source")
158
+ break;
159
+ words.push(restRaw[next]!);
160
+ }
161
+ }
162
+ const value = words
163
+ .join(" ")
164
+ .replace(/^["']|["']$/g, "")
165
+ .trim();
166
+ if (value === "")
167
+ return { type: "vocoder-usage", message: "vocoder src <track>" };
168
+ src = value;
169
+ index = next;
170
+ continue;
171
+ }
172
+ const name = vocoderParamName(key);
173
+ if (!name) {
174
+ const word = key.slice(0, 24);
175
+ const near = nearest(word, [...VOCODER_COMMAND_KEYS, "src"]);
176
+ const asPreset = nearest(word, [...VOCODER_PRESET_NAMES]);
177
+ return {
178
+ type: "vocoder-usage",
179
+ message: `vocoder has no parameter or preset ${word}${near || asPreset ? ` · did you mean ${near ?? asPreset}?` : ""} · ${VOCODER_USAGE}`,
180
+ };
181
+ }
182
+ const word = rest[index + 1];
183
+ if (word === undefined)
184
+ return { type: "vocoder-usage", message: rangeOf(name) };
185
+ const value = parseValue(name, word);
186
+ if (value === undefined)
187
+ return { type: "vocoder-usage", message: rangeOf(name) };
188
+ values[name] = value;
189
+ index += 2;
190
+ }
191
+ return {
192
+ type: "vocoder-set",
193
+ ...(preset ? { preset } : {}),
194
+ ...(src !== undefined ? { src } : {}),
195
+ values,
196
+ };
197
+ }
198
+
199
+ /** True when a track plays recorded audio a vocoder can follow. */
200
+ export function trackHasAudio(track: Track): boolean {
201
+ if (track.sampler && Object.keys(track.sampler.voices).length > 0)
202
+ return true;
203
+ if ((track.clips?.length ?? 0) > 0) return true;
204
+ return track.instrument === "vocal";
205
+ }
206
+
207
+ /**
208
+ * Tracks that could drive a vocoder on `carrierId`: audio tracks first (the
209
+ * vocals), never the carrier itself or another vocoder carrier.
210
+ */
211
+ export function vocoderCandidates(
212
+ score: TrackScore,
213
+ carrierId?: string,
214
+ ): readonly Track[] {
215
+ return score.tracks.filter(
216
+ (track) =>
217
+ track.id !== carrierId &&
218
+ track.instrument !== VOCODER_INSTRUMENT &&
219
+ trackHasAudio(track),
220
+ );
221
+ }
222
+
223
+ /**
224
+ * What to do when nothing can be vocoded (vocoder.md §5 Entry). Names
225
+ * `/vocal import` and `/vocal stem` only once those verbs are registered.
226
+ */
227
+ export function vocoderEntryLines(): readonly string[] {
228
+ const has = (verb: string) =>
229
+ VOCAL_VERBS.some((candidate) => candidate.verb === verb);
230
+ return [
231
+ "vocoder · no voice to vocode yet · nothing changed",
232
+ has("import")
233
+ ? " import a file: /vocal import <file.wav> (or /sample <file>)"
234
+ : " import a file: /sample <file.wav>",
235
+ ...(has("stem")
236
+ ? [" split a song: /vocal stem <file> to get its vocal stem"]
237
+ : []),
238
+ " or point it at any track: /vocoder src <track>",
239
+ ];
240
+ }
241
+
242
+ /**
243
+ * The modulator's licence and provenance lines, the way pack.ts returns
244
+ * `license`. Restricted sources print one plain line each.
245
+ */
246
+ export function vocoderProvenance(score: TrackScore, srcId: string): string[] {
247
+ const track = score.tracks.find((candidate) => candidate.id === srcId);
248
+ if (!track) return [];
249
+ const lines = new Set<string>();
250
+ for (const ref of Object.values(track.sampler?.voices ?? {})) {
251
+ if (ref.license) lines.add(`modulator license ${ref.license} (${ref.src})`);
252
+ }
253
+ for (const clip of track.clips ?? []) {
254
+ if (clip.say) {
255
+ const restricted = clip.say.engine === "say";
256
+ lines.add(
257
+ restricted
258
+ ? `vocoded from a macOS say voice (${clip.say.voice}): ${clip.say.license}`
259
+ : `vocoded from ${clip.say.engine} voice ${clip.say.voice}: ${clip.say.license}`,
260
+ );
261
+ }
262
+ }
263
+ return [...lines];
264
+ }
265
+
266
+ /** `talkbox · src vox · formant 3`, overrides in canonical order. */
267
+ export function describeVocoder(
268
+ score: TrackScore,
269
+ vocoder: TrackVocoder,
270
+ ): string {
271
+ const parts = [`preset ${vocoder.preset ?? DEFAULT_VOCODER_PRESET}`];
272
+ if (vocoder.src !== undefined) {
273
+ const source = score.tracks.find((track) => track.id === vocoder.src);
274
+ parts.push(`src ${source ? source.name : vocoder.src}`);
275
+ } else parts.push("no src (plays dry)");
276
+ for (const key of VOCODER_COMMAND_KEYS) {
277
+ const value = (vocoder as Record<string, unknown>)[key];
278
+ if (value !== undefined)
279
+ parts.push(
280
+ `${key} ${value === true ? "on" : value === false ? "off" : value}`,
281
+ );
282
+ }
283
+ return parts.join(" · ");
284
+ }
285
+
286
+ /** One line per preset, for `vocoder presets` and the agent brief. */
287
+ export function vocoderListLines(): string[] {
288
+ return VOCODER_PRESET_NAMES.map(
289
+ (name) => `${name.padEnd(8)} ${VOCODER_PRESETS[name].doc}`,
290
+ );
291
+ }
292
+
293
+ /** A rough render-cost hint (vocoder.md §3b.9: talkbox and >24 bands). */
294
+ export function vocoderCostHint(vocoder: TrackVocoder): string {
295
+ const resolved = resolveVocoder(vocoder);
296
+ if (resolved.mode === "talkbox") return "cost: talkbox (LPC), light";
297
+ if (resolved.bands > 24) return `cost: ${resolved.bands} bands, heavy`;
298
+ return `cost: ${resolved.bands} bands`;
299
+ }
300
+
301
+ export type VocoderResult = Readonly<{
302
+ ok: boolean;
303
+ message: string;
304
+ next?: TrackScore;
305
+ kind?: string;
306
+ payload?: Record<string, unknown>;
307
+ /** The track the command changed or created. */
308
+ trackId?: string;
309
+ }>;
310
+
311
+ /**
312
+ * The vocoder field a set leaves on a track (pure; the agent tool shares
313
+ * it). A preset switch keeps the overrides; `reset` keeps the preset and
314
+ * the source.
315
+ */
316
+ export function nextVocoder(
317
+ current: TrackVocoder | undefined,
318
+ change: Readonly<{
319
+ reset?: boolean;
320
+ preset?: VocoderPresetName;
321
+ src?: string;
322
+ values?: Readonly<Record<string, VocoderValue>>;
323
+ }>,
324
+ ): TrackVocoder {
325
+ const base: Record<string, unknown> = change.reset
326
+ ? {
327
+ ...(current?.src !== undefined ? { src: current.src } : {}),
328
+ ...(current?.preset ? { preset: current.preset } : {}),
329
+ }
330
+ : { ...current };
331
+ if (change.preset) base.preset = change.preset;
332
+ if (change.src !== undefined) base.src = change.src;
333
+ for (const [key, value] of Object.entries(change.values ?? {})) {
334
+ if (value === null) delete base[key];
335
+ else base[key] = value;
336
+ }
337
+ return normalizeVocoder(base)!;
338
+ }
339
+
340
+ /** Resolve a typed modulator to a track id, or an error message. */
341
+ export function resolveVocoderSrc(
342
+ score: TrackScore,
343
+ carrierId: string,
344
+ text: string,
345
+ ): { id: string } | { error: string } {
346
+ const byId = score.tracks.find((track) => track.id === text);
347
+ if (!byId) {
348
+ const slug = trackSlug(text);
349
+ const matches = score.tracks.filter(
350
+ (track) => trackSlug(track.name) === slug,
351
+ );
352
+ if (matches.length > 1)
353
+ return {
354
+ error: `src ${text} matches ${matches.length} tracks (${matches.map((track) => track.id).join(", ")}); use an id`,
355
+ };
356
+ }
357
+ const found = byId ?? resolveTrackRef(score, text);
358
+ if (!found) {
359
+ const near = nearest(
360
+ text.slice(0, 48),
361
+ score.tracks.map((track) => track.id),
362
+ );
363
+ return {
364
+ error: `src ${text.slice(0, 48)} names no track${near ? ` · did you mean ${near}?` : ""}`,
365
+ };
366
+ }
367
+ if (found.id === carrierId)
368
+ return { error: `${carrierId} cannot vocode itself; pick another src` };
369
+ return { id: found.id };
370
+ }
371
+
372
+ function nameFor(
373
+ score: TrackScore,
374
+ base: string,
375
+ ): { id: string; name: string } {
376
+ const stem = trackSlug(`${base} vocoder`).slice(0, 40);
377
+ let id = stem;
378
+ for (let n = 2; score.tracks.some((track) => track.id === id); n += 1)
379
+ id = `${stem}-${n}`;
380
+ const suffix = id === stem ? "" : ` ${id.slice(stem.length + 1)}`;
381
+ const name = `${base.slice(0, 48 - 9 - suffix.length)} vocoder${suffix}`;
382
+ return { id, name };
383
+ }
384
+
385
+ /**
386
+ * One step (vocoder.md §5): a new `vocoder` track driven by `srcId`, chords
387
+ * from the song when it has harmonic tracks, else a drone on the key's root,
388
+ * and the source muted. Pure; one score change.
389
+ */
390
+ export function vocodeTrack(
391
+ score: TrackScore,
392
+ srcId: string,
393
+ options: Readonly<{
394
+ preset?: VocoderPresetName;
395
+ name?: string;
396
+ carrier?: VocoderCarrier;
397
+ follow?: VocoderFollow;
398
+ keepSource?: boolean;
399
+ }> = {},
400
+ ): VocoderResult {
401
+ const source = score.tracks.find((track) => track.id === srcId);
402
+ if (!source) return { ok: false, message: `vocoder · no track ${srcId}` };
403
+ if (score.tracks.length >= SCORE_LIMITS.maxTracks)
404
+ return { ok: false, message: "vocoder · the song is full of tracks" };
405
+ const preset = options.preset ?? DEFAULT_VOCODER_PRESET;
406
+ const presetFollow = VOCODER_PRESETS[preset].values.follow;
407
+ const hasChords = chordTimeline(score).ticks.length > 0;
408
+ // The preset's follow, unless it follows notes: a fresh track has none, so
409
+ // it follows the song's chords, or drones on the key's root.
410
+ let follow: VocoderFollow | undefined = options.follow;
411
+ let root: number | undefined;
412
+ if (follow === undefined && (presetFollow ?? "notes") === "notes")
413
+ follow = hasChords ? "chords" : "drone";
414
+ // A drone (the preset's or the fallback's) sits on the song key's tonic.
415
+ if ((follow ?? presetFollow) === "drone") {
416
+ const key = parseKey(score.key);
417
+ // The key's tonic in the octave around A2..G#3 (45..56).
418
+ root = key ? 45 + ((((key.tonic - 45) % 12) + 12) % 12) : undefined;
419
+ }
420
+ const { id, name: autoName } = nameFor(score, source.name);
421
+ const name = options.name?.trim().slice(0, 48) || autoName;
422
+ let vocoder: TrackVocoder;
423
+ let next: TrackScore;
424
+ try {
425
+ vocoder = normalizeVocoder({
426
+ src: srcId,
427
+ preset,
428
+ ...(follow !== undefined && follow !== presetFollow ? { follow } : {}),
429
+ ...(root !== undefined ? { root } : {}),
430
+ ...(options.carrier ? { carrier: options.carrier } : {}),
431
+ })!;
432
+ next = addTrack(score, {
433
+ id,
434
+ name,
435
+ instrument: VOCODER_INSTRUMENT,
436
+ vocoder,
437
+ });
438
+ if (!options.keepSource && source.muted !== true)
439
+ next = updateTrack(next, srcId, { muted: true });
440
+ } catch (error) {
441
+ if (
442
+ error instanceof ScoreValidationError ||
443
+ error instanceof FxValidationError
444
+ )
445
+ return { ok: false, message: `vocoder · ${error.message}` };
446
+ throw error;
447
+ }
448
+ const muted = !options.keepSource && source.muted !== true;
449
+ const lines = [
450
+ `vocoder · new track ${name} · ${describeVocoder(next, vocoder)}`,
451
+ muted
452
+ ? ` muted ${source.name}; the vocoder still hears it (the tap ignores mute) · /unmute ${srcId} to hear both`
453
+ : ` ${source.name} still plays`,
454
+ ...vocoderProvenance(next, srcId).map((line) => ` ${line}`),
455
+ ...((presetFollow ?? "notes") === "notes" && options.follow === undefined
456
+ ? [
457
+ ` ${preset} plays a line: /vocoder follow notes, then write the melody on ${id}`,
458
+ ]
459
+ : []),
460
+ ];
461
+ return {
462
+ ok: true,
463
+ message: lines.join("\n"),
464
+ next,
465
+ kind: "score.vocoder",
466
+ payload: { trackId: id, src: srcId, vocoder, muted },
467
+ trackId: id,
468
+ };
469
+ }
470
+
471
+ export function applyVocoderCommand(
472
+ score: TrackScore,
473
+ trackId: string,
474
+ command: VocoderCommand,
475
+ ): VocoderResult {
476
+ if (command.type === "vocoder-usage")
477
+ return { ok: false, message: `vocoder · ${command.message}` };
478
+ if (command.type === "vocoder-list")
479
+ return {
480
+ ok: true,
481
+ message: [
482
+ "vocoder presets:",
483
+ ...vocoderListLines().map((l) => ` ${l}`),
484
+ ].join("\n"),
485
+ };
486
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
487
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
488
+ const current = track.vocoder;
489
+ // A vocal without a vocoder of its own: commands go to the carrier it
490
+ // drives, or make one (one step), so `/vocoder talkbox` on the vocal
491
+ // gives a talkbox carrier rather than a src-less field on the vocal.
492
+ const vocalSide =
493
+ !current &&
494
+ trackHasAudio(track) &&
495
+ (command.type === "vocoder-show" ||
496
+ (command.type === "vocoder-set" && command.src === undefined));
497
+ if (vocalSide) {
498
+ const fresh = command.type === "vocoder-show" && command.fresh === true;
499
+ const carrier = fresh
500
+ ? undefined
501
+ : score.tracks.find(
502
+ (candidate) =>
503
+ candidate.vocoder?.src !== undefined &&
504
+ resolveTrackRef(score, candidate.vocoder.src)?.id === trackId,
505
+ );
506
+ if (carrier) {
507
+ const result = applyVocoderCommand(score, carrier.id, command);
508
+ return result.trackId ? result : { ...result, trackId: carrier.id };
509
+ }
510
+ if (command.type === "vocoder-show") return vocodeTrack(score, trackId);
511
+ if (command.type === "vocoder-set") {
512
+ const follow = command.values.follow;
513
+ const made = vocodeTrack(score, trackId, {
514
+ ...(command.preset ? { preset: command.preset } : {}),
515
+ ...(typeof follow === "string"
516
+ ? { follow: follow as VocoderFollow }
517
+ : {}),
518
+ });
519
+ if (!made.ok || !made.next || !made.trackId) return made;
520
+ const { follow: _follow, ...values } = command.values;
521
+ if (Object.keys(values).length === 0) return made;
522
+ const tuned = applyVocoderCommand(made.next, made.trackId, {
523
+ type: "vocoder-set",
524
+ values,
525
+ });
526
+ if (!tuned.ok || !tuned.next) return tuned;
527
+ return {
528
+ ...made,
529
+ message: `${made.message}\n ${tuned.message}`,
530
+ next: tuned.next,
531
+ payload: {
532
+ ...made.payload,
533
+ vocoder: tuned.next.tracks.find((t) => t.id === made.trackId)
534
+ ?.vocoder,
535
+ },
536
+ };
537
+ }
538
+ }
539
+ if (command.type === "vocoder-show") {
540
+ if (current) {
541
+ const lines = [
542
+ `vocoder · ${describeVocoder(score, current)} · ${vocoderCostHint(current)}`,
543
+ ...(current.src !== undefined
544
+ ? vocoderProvenance(score, current.src).map((line) => ` ${line}`)
545
+ : [" /vocoder src <track> picks the voice"]),
546
+ ];
547
+ return { ok: true, message: lines.join("\n") };
548
+ }
549
+ // Another track: drive it from the one vocal.
550
+ const candidates = vocoderCandidates(score, trackId);
551
+ if (candidates.length === 0)
552
+ return { ok: true, message: vocoderEntryLines().join("\n") };
553
+ if (candidates.length > 1)
554
+ return {
555
+ ok: true,
556
+ message: `vocoder · pick the voice: ${candidates.map((t) => `/vocoder src ${t.id}`).join(" · ")}`,
557
+ };
558
+ command = { type: "vocoder-set", src: candidates[0]!.id, values: {} };
559
+ }
560
+ if (track.instrument === "kit")
561
+ return { ok: false, message: `vocoder · ${trackId} is a drum track` };
562
+ if (command.type === "vocoder-off") {
563
+ if (!current) return { ok: true, message: "vocoder · already off" };
564
+ const next = updateTrack(score, trackId, { vocoder: null });
565
+ return {
566
+ ok: true,
567
+ message:
568
+ track.instrument === VOCODER_INSTRUMENT
569
+ ? "vocoder · off · the track plays its plain supersaw carrier · /vocoder turns it back on"
570
+ : "vocoder · off",
571
+ next,
572
+ kind: "score.vocoder",
573
+ payload: { trackId, vocoder: null },
574
+ trackId,
575
+ };
576
+ }
577
+ let src: string | undefined;
578
+ if (command.type === "vocoder-set" && command.src !== undefined) {
579
+ const found = resolveVocoderSrc(score, trackId, command.src);
580
+ if ("error" in found)
581
+ return { ok: false, message: `vocoder · ${found.error}` };
582
+ src = found.id;
583
+ }
584
+ // No voice yet and exactly one to follow: pick it (one step to a sound).
585
+ if (
586
+ src === undefined &&
587
+ current?.src === undefined &&
588
+ command.type === "vocoder-set"
589
+ ) {
590
+ const candidates = vocoderCandidates(score, trackId);
591
+ if (candidates.length === 1) src = candidates[0]!.id;
592
+ }
593
+ let vocoder: TrackVocoder;
594
+ let next: TrackScore;
595
+ try {
596
+ vocoder =
597
+ command.type === "vocoder-reset"
598
+ ? nextVocoder(current, { reset: true })
599
+ : nextVocoder(current, {
600
+ ...(command.preset ? { preset: command.preset } : {}),
601
+ ...(src !== undefined ? { src } : {}),
602
+ values: command.values,
603
+ });
604
+ next = updateTrack(score, trackId, { vocoder });
605
+ } catch (error) {
606
+ if (
607
+ error instanceof ScoreValidationError ||
608
+ error instanceof FxValidationError
609
+ )
610
+ return { ok: false, message: `vocoder · ${error.message}` };
611
+ throw error;
612
+ }
613
+ const lines = [
614
+ `vocoder · ${describeVocoder(next, vocoder)}`,
615
+ ...(src !== undefined
616
+ ? vocoderProvenance(next, src).map((line) => ` ${line}`)
617
+ : []),
618
+ ];
619
+ return {
620
+ ok: true,
621
+ message: lines.join("\n"),
622
+ next,
623
+ kind: "score.vocoder",
624
+ payload: { trackId, vocoder },
625
+ trackId,
626
+ };
627
+ }
@@ -41,7 +41,7 @@ import {
41
41
  updateTrack,
42
42
  type TrackScore,
43
43
  } from "../../core/score.ts";
44
- import { nearestWord } from "../audio/instrument-check.ts";
44
+ import { nearest } from "./nearest.ts";
45
45
  import { parseParamValue } from "./fx.ts";
46
46
 
47
47
  export type WindCommand =
@@ -107,7 +107,7 @@ export function parseWindCommand(prompt: string): WindCommand | undefined {
107
107
  const name = windParamName(rest[index]!);
108
108
  if (!name) {
109
109
  const word = rest[index]!.slice(0, 24);
110
- const near = nearestWord(word, Object.keys(WIND_PARAMS));
110
+ const near = nearest(word, Object.keys(WIND_PARAMS));
111
111
  return {
112
112
  type: "wind-usage",
113
113
  message: `wind has no parameter ${word}${near ? ` · did you mean ${near}?` : ""} · ${WIND_SIMPLE.join(" ")} …`,
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Crash-safe file replacement: write a uniquely named temp file, fsync it,
3
+ * rename it over the target and fsync the parent directory so the rename
4
+ * itself survives a power loss. A reader never sees a partial file.
5
+ */
6
+
7
+ import { mkdir, open, rename, rm } from "node:fs/promises";
8
+ import { dirname } from "node:path";
9
+
10
+ let counter = 0;
11
+
12
+ export async function durableWrite(
13
+ path: string,
14
+ contents: string,
15
+ options: Readonly<{ mode?: number; mkdir?: boolean }> = {},
16
+ ): Promise<void> {
17
+ const dir = dirname(path);
18
+ if (options.mkdir !== false) await mkdir(dir, { recursive: true });
19
+ const temporary = `${path}.${process.pid}.${++counter}.tmp`;
20
+ const handle = await open(temporary, "w", options.mode ?? 0o644);
21
+ try {
22
+ await handle.writeFile(contents, "utf8");
23
+ await handle.sync();
24
+ } catch (error) {
25
+ await handle.close().catch(() => undefined);
26
+ await rm(temporary, { force: true }).catch(() => undefined);
27
+ throw error;
28
+ }
29
+ await handle.close();
30
+ try {
31
+ await rename(temporary, path);
32
+ } catch (error) {
33
+ await rm(temporary, { force: true }).catch(() => undefined);
34
+ throw error;
35
+ }
36
+ await syncDirectory(dir);
37
+ }
38
+
39
+ /** Best effort: some platforms refuse fsync on a directory handle. */
40
+ export async function syncDirectory(dir: string): Promise<void> {
41
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
42
+ try {
43
+ handle = await open(dir, "r");
44
+ await handle.sync();
45
+ } catch {
46
+ // EISDIR/EPERM/EINVAL on platforms without directory fsync.
47
+ } finally {
48
+ await handle?.close().catch(() => undefined);
49
+ }
50
+ }