@hraness/dawg 0.6.0 → 0.7.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 (275) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +655 -141
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +759 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -99
  8. package/core/expression.ts +158 -6
  9. package/core/fx.ts +354 -32
  10. package/core/granular.ts +91 -0
  11. package/core/instruments.ts +180 -0
  12. package/core/keys.ts +573 -4
  13. package/core/loop.ts +5 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/resonators.ts +306 -4
  17. package/core/routing.ts +165 -0
  18. package/core/score.ts +1181 -16
  19. package/core/sdk/eval-child.ts +7 -2
  20. package/core/sdk/eval.ts +35 -6
  21. package/core/sdk/print.ts +354 -6
  22. package/core/sdk/sync-lyrics.ts +49 -0
  23. package/core/sdk/v1.ts +3160 -69
  24. package/core/sections.ts +298 -24
  25. package/core/sing.ts +815 -0
  26. package/core/strings.ts +237 -2
  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/core/winds.ts +652 -0
  50. package/guides/agent.md +29 -0
  51. package/guides/arrange.md +30 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -6
  54. package/guides/chords.md +16 -14
  55. package/guides/effects.md +17 -15
  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 +12 -6
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/performance.md +15 -10
  65. package/guides/play.md +21 -13
  66. package/guides/project.md +25 -8
  67. package/guides/providers.md +20 -14
  68. package/guides/resample.md +32 -0
  69. package/guides/rhythm.md +17 -13
  70. package/guides/sessions.md +17 -7
  71. package/guides/show-me.md +31 -0
  72. package/guides/sound.md +25 -9
  73. package/guides/sounds.md +15 -12
  74. package/guides/styles.md +31 -0
  75. package/guides/tempo.md +15 -10
  76. package/guides/tracks.md +15 -10
  77. package/guides/tuning.md +32 -0
  78. package/guides/voice.md +31 -0
  79. package/guides/web-search.md +18 -8
  80. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  81. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  82. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  83. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  84. package/native/prebuilt/manifest.json +21 -0
  85. package/package.json +5 -2
  86. package/src/agent/agent.ts +131 -13
  87. package/src/agent/calibration-tools.ts +53 -0
  88. package/src/agent/chord-tools.ts +153 -1
  89. package/src/agent/clip-tools.ts +453 -0
  90. package/src/agent/command-agent.ts +369 -0
  91. package/src/agent/drum-tools.ts +2 -2
  92. package/src/agent/expression-tools.ts +91 -0
  93. package/src/agent/gateway.ts +246 -60
  94. package/src/agent/models.ts +53 -12
  95. package/src/agent/ops.ts +15 -1
  96. package/src/agent/pack-tools.ts +1 -1
  97. package/src/agent/planner.ts +13 -0
  98. package/src/agent/portable-schema.ts +80 -0
  99. package/src/agent/preview-tool.ts +5 -1
  100. package/src/agent/provider.ts +22 -8
  101. package/src/agent/resample-tool.ts +131 -0
  102. package/src/agent/rhythm-tools.ts +1 -1
  103. package/src/agent/section-tools.ts +1 -1
  104. package/src/agent/show-me.ts +497 -0
  105. package/src/agent/steer.ts +15 -0
  106. package/src/agent/style-tools.ts +217 -0
  107. package/src/agent/tool-error.ts +12 -0
  108. package/src/agent/tools.ts +307 -37
  109. package/src/agent/usage.ts +2 -2
  110. package/src/agent/voice-tools.ts +925 -0
  111. package/src/agent/xcb-agent.ts +16 -6
  112. package/src/argv.ts +38 -0
  113. package/src/audio/analysis.ts +253 -0
  114. package/src/audio/arrange.ts +58 -3
  115. package/src/audio/autotune-engine.ts +101 -0
  116. package/src/audio/autotune.ts +640 -0
  117. package/src/audio/clips.ts +240 -0
  118. package/src/audio/doctor.ts +86 -0
  119. package/src/audio/dsp/bandbank.ts +138 -0
  120. package/src/audio/dsp/envelope.ts +171 -0
  121. package/src/audio/dsp/follow.ts +120 -0
  122. package/src/audio/dsp/formant.ts +427 -0
  123. package/src/audio/dsp/glottal.ts +243 -0
  124. package/src/audio/dsp/interp.ts +7 -2
  125. package/src/audio/dsp/lpc.ts +50 -0
  126. package/src/audio/dsp/periodicity.ts +59 -0
  127. package/src/audio/dsp/pitch.ts +995 -0
  128. package/src/audio/dsp/psola.ts +199 -0
  129. package/src/audio/dsp/shift.ts +256 -0
  130. package/src/audio/effects/chain.ts +21 -4
  131. package/src/audio/effects/common.ts +57 -0
  132. package/src/audio/effects/convolution.ts +113 -5
  133. package/src/audio/effects/filter.ts +48 -69
  134. package/src/audio/effects/formant.ts +263 -0
  135. package/src/audio/effects/gaze.ts +354 -0
  136. package/src/audio/engine.ts +206 -33
  137. package/src/audio/fit.ts +80 -3
  138. package/src/audio/granular.ts +352 -21
  139. package/src/audio/instrument-check.ts +67 -47
  140. package/src/audio/instruments.ts +36 -3
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +427 -0
  143. package/src/audio/keys/engine.ts +156 -20
  144. package/src/audio/keys/organ.ts +1335 -0
  145. package/src/audio/keys/piano.ts +72 -8
  146. package/src/audio/keys/sympathetic.ts +127 -0
  147. package/src/audio/kits.ts +135 -6
  148. package/src/audio/live-worker.ts +3 -1
  149. package/src/audio/live.ts +247 -26
  150. package/src/audio/native.ts +615 -0
  151. package/src/audio/preview.ts +45 -3
  152. package/src/audio/render-worker.ts +2 -0
  153. package/src/audio/renderer.ts +2 -0
  154. package/src/audio/resample.ts +286 -0
  155. package/src/audio/resonators.ts +10 -2
  156. package/src/audio/sampler.ts +252 -24
  157. package/src/audio/samples.ts +46 -3
  158. package/src/audio/sing/analysis.ts +193 -0
  159. package/src/audio/sing/engine.ts +949 -0
  160. package/src/audio/strings/body.ts +29 -16
  161. package/src/audio/strings/bow.ts +699 -0
  162. package/src/audio/strings/engine.ts +274 -22
  163. package/src/audio/strings/measure.test-helpers.ts +2 -0
  164. package/src/audio/synth/oscillators.ts +31 -21
  165. package/src/audio/synth/voice.ts +34 -1
  166. package/src/audio/vocoder/bank.ts +314 -0
  167. package/src/audio/vocoder/carrier.ts +165 -0
  168. package/src/audio/vocoder/control.ts +68 -0
  169. package/src/audio/vocoder/detect.ts +50 -0
  170. package/src/audio/vocoder/index.ts +304 -0
  171. package/src/audio/vocoder/talkbox.ts +143 -0
  172. package/src/audio/wav.ts +608 -85
  173. package/src/audio/winds/engine.ts +306 -0
  174. package/src/audio/winds/filters.ts +153 -0
  175. package/src/audio/winds/pitch.ts +104 -0
  176. package/src/audio/winds/trim.ts +104 -0
  177. package/src/audio/winds/trims.ts +917 -0
  178. package/src/audio/winds/trims1.ts +297 -0
  179. package/src/audio/winds/voice.ts +492 -0
  180. package/src/auth/cli.ts +38 -36
  181. package/src/auth/credentials.ts +30 -1
  182. package/src/auth/login.ts +15 -9
  183. package/src/auth/tui.ts +19 -10
  184. package/src/commands/arrange.ts +44 -29
  185. package/src/commands/autotune.ts +421 -0
  186. package/src/commands/calibration.ts +74 -0
  187. package/src/commands/clips.ts +887 -0
  188. package/src/commands/drums.ts +3 -2
  189. package/src/commands/edit.ts +11 -4
  190. package/src/commands/expression.ts +104 -23
  191. package/src/commands/formant.ts +221 -0
  192. package/src/commands/fx.ts +105 -33
  193. package/src/commands/grammar.ts +558 -0
  194. package/src/commands/granular.ts +7 -2
  195. package/src/commands/help.ts +652 -339
  196. package/src/commands/history.ts +18 -0
  197. package/src/commands/keys.ts +293 -17
  198. package/src/commands/modal.ts +67 -4
  199. package/src/commands/music.ts +1 -1
  200. package/src/commands/nearest.ts +53 -0
  201. package/src/commands/pack.ts +9 -2
  202. package/src/commands/param-range.ts +56 -0
  203. package/src/commands/parses.ts +130 -0
  204. package/src/commands/progression.ts +170 -0
  205. package/src/commands/resample.ts +281 -0
  206. package/src/commands/rhythm.ts +3 -0
  207. package/src/commands/rig.ts +8 -25
  208. package/src/commands/sample.ts +49 -2
  209. package/src/commands/shift.ts +119 -0
  210. package/src/commands/sing.ts +478 -0
  211. package/src/commands/string.ts +28 -2
  212. package/src/commands/strum.ts +485 -0
  213. package/src/commands/style.ts +415 -0
  214. package/src/commands/time.ts +6 -3
  215. package/src/commands/tuning.ts +3 -3
  216. package/src/commands/vocal-pitch.ts +616 -0
  217. package/src/commands/vocal.ts +147 -0
  218. package/src/commands/vocoder.ts +627 -0
  219. package/src/commands/wind.ts +244 -0
  220. package/src/fs/durable.ts +50 -0
  221. package/src/lang/glossary.ts +493 -0
  222. package/src/launch-args.ts +163 -0
  223. package/src/main.ts +1521 -255
  224. package/src/media/cli.ts +20 -3
  225. package/src/media/import.ts +3 -1
  226. package/src/project/check.ts +26 -3
  227. package/src/project/clip-pins.ts +72 -0
  228. package/src/project/init.ts +23 -8
  229. package/src/project/sync.ts +418 -86
  230. package/src/render.ts +36 -1
  231. package/src/session/daemon.ts +3 -0
  232. package/src/session/meta.ts +14 -0
  233. package/src/session/origin.ts +154 -0
  234. package/src/session/port.ts +22 -4
  235. package/src/session/presence.ts +50 -7
  236. package/src/session/protocol.ts +5 -1
  237. package/src/session/rebase.ts +18 -5
  238. package/src/session/receipt.ts +258 -0
  239. package/src/session/store.ts +65 -43
  240. package/src/tui/arrange-menu.ts +65 -31
  241. package/src/tui/audition.ts +1 -1
  242. package/src/tui/euclid.ts +18 -13
  243. package/src/tui/fader.ts +228 -41
  244. package/src/tui/granular-menu.ts +2 -4
  245. package/src/tui/menu-clips.ts +297 -0
  246. package/src/tui/menu-time.ts +20 -13
  247. package/src/tui/menu-voice.ts +405 -0
  248. package/src/tui/menu.ts +1377 -193
  249. package/src/tui/modal-menu.ts +38 -11
  250. package/src/tui/performance-menu.ts +46 -1
  251. package/src/tui/play-chords.ts +108 -5
  252. package/src/tui/play-mode.ts +16 -1
  253. package/src/tui/play-session.ts +81 -16
  254. package/src/tui/sing-menu.ts +278 -0
  255. package/src/tui/style-menu.ts +104 -0
  256. package/src/tui/vocoder-menu.ts +244 -0
  257. package/src/tui/wind-menu.ts +144 -0
  258. package/src/version.ts +8 -0
  259. package/src/web/fetch.ts +115 -29
  260. package/tui/activity.ts +180 -9
  261. package/tui/app.ts +274 -40
  262. package/tui/clip-row.ts +132 -0
  263. package/tui/delight.ts +144 -0
  264. package/tui/drawer.ts +70 -22
  265. package/tui/frame-gate.ts +76 -0
  266. package/tui/grammar.ts +112 -63
  267. package/tui/guide.ts +42 -4
  268. package/tui/highway.ts +269 -25
  269. package/tui/hints.ts +192 -0
  270. package/tui/input.ts +60 -9
  271. package/tui/keys.ts +1 -1
  272. package/tui/play-strip.ts +68 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +144 -14
  275. package/tui/theme.ts +27 -0
@@ -0,0 +1,478 @@
1
+ /**
2
+ * The `sing` prompt command (0.7): the built-in singing voice on the
3
+ * focused track (core/sing.ts). The menu and the `set_sing` / `set_vowels`
4
+ * agent tools build the same edits.
5
+ *
6
+ * sing show the track's preset and overrides
7
+ * sing <preset> aah ooh choir oohchoir chorale airy
8
+ * glass lament soprano basso drone
9
+ * khoomei sygyt kargyraa
10
+ * sing <preset> <param> <value>… a preset with overrides
11
+ * sing <param> <value> [<param> <value>…] vowel o, voices 6, voice bass
12
+ * sing drone <D3|50> throat mode on the current preset
13
+ * sing harmonics 6-12 the overtone range a throat track picks
14
+ * sing <param> off back to the preset's value
15
+ * sing vowels a e i o>u … the track's notes' vowels, in order, cycled
16
+ * sing reset | sing off | sing list
17
+ * note vowel <a|a>u|off> [target] the sung vowel of some notes
18
+ *
19
+ * Any edit makes the track a sing track (`instrument "sing"` plus a `sing`
20
+ * field). One `updateTrack` revision and one undo step per command.
21
+ */
22
+ import { isGuideInstrument } from "../../core/clips.ts";
23
+ import { parseKey } from "../../core/chords.ts";
24
+ import { FxValidationError } from "../../core/params.ts";
25
+ import {
26
+ DEFAULT_SING_PRESET,
27
+ isSingPreset,
28
+ normalizeSing,
29
+ normalizeVowel,
30
+ parseDrone,
31
+ resolveSing,
32
+ SING_INSTRUMENT,
33
+ SING_PARAMS,
34
+ SING_PRESETS,
35
+ SING_PRESET_NAMES,
36
+ singNoteName,
37
+ singParamName,
38
+ singSummary,
39
+ type SingPreset,
40
+ type TrackSing,
41
+ } from "../../core/sing.ts";
42
+ import {
43
+ addNote,
44
+ ScoreValidationError,
45
+ updateTrack,
46
+ type TrackScore,
47
+ } from "../../core/score.ts";
48
+ import { nearest } from "./nearest.ts";
49
+ import {
50
+ describeTarget,
51
+ parseNoteTarget,
52
+ patchNotes,
53
+ targetNotes,
54
+ type NoteTarget,
55
+ } from "./expression.ts";
56
+ import { parseParamValue } from "./fx.ts";
57
+
58
+ export type SingValue = number | string | readonly [number, number] | null;
59
+
60
+ export type SingCommand =
61
+ | { type: "sing-show" }
62
+ | { type: "sing-list" }
63
+ | { type: "sing-reset" }
64
+ | { type: "sing-off" }
65
+ | { type: "sing-usage"; message: string }
66
+ | { type: "sing-vowels"; vowels: readonly string[] }
67
+ | { type: "note-vowel"; vowel: string | null; target: NoteTarget }
68
+ | {
69
+ type: "sing-set";
70
+ /** A preset applied before the values (`sing khoomei drone D3`). */
71
+ preset?: SingPreset;
72
+ /** `null` returns a parameter to the preset's value. */
73
+ values: Readonly<Record<string, SingValue>>;
74
+ };
75
+
76
+ export const SING_USAGE =
77
+ "sing <preset> | sing <param> <value> | sing drone <D3> | sing vowels a e i … | sing reset | sing off | sing list";
78
+
79
+ const PARAM_NAMES = [...Object.keys(SING_PARAMS), "harmonics"];
80
+
81
+ function rangeOf(name: string): string {
82
+ if (name === "vowel") return "vowel is a e i o u or a morph like a>o";
83
+ if (name === "drone")
84
+ return "drone is a note name C2..G4 (D3) or MIDI 36..67";
85
+ if (name === "harmonics") return "harmonics is a range like 6-12 (2..24)";
86
+ const spec = SING_PARAMS[name]!;
87
+ if (spec.kind === "number") return `${name} is ${spec.min}..${spec.max}`;
88
+ if (spec.kind === "enum") return `${name} is one of ${spec.values.join(" ")}`;
89
+ return `${name} is on or off`;
90
+ }
91
+
92
+ function presetFor(word: string): SingPreset | undefined {
93
+ const lower = word.toLowerCase();
94
+ return isSingPreset(lower) ? lower : undefined;
95
+ }
96
+
97
+ /** One value word for `name`; undefined when it is out of range. */
98
+ function valueOf(name: string, word: string): SingValue | undefined {
99
+ try {
100
+ if (name === "vowel") return normalizeVowel(word);
101
+ if (name === "drone") return parseDrone(word);
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ if (name === "harmonics") {
106
+ const match = /^(\d+)[-,:](\d+)$/.exec(word);
107
+ if (!match) return undefined;
108
+ const lo = Number(match[1]);
109
+ const hi = Number(match[2]);
110
+ return lo >= 2 && hi <= 24 && lo < hi
111
+ ? (Object.freeze([lo, hi]) as readonly [number, number])
112
+ : undefined;
113
+ }
114
+ const spec = SING_PARAMS[name];
115
+ if (!spec) return undefined;
116
+ const value = parseParamValue(spec, word);
117
+ return typeof value === "boolean" ? undefined : value;
118
+ }
119
+
120
+ function parseNoteVowel(words: readonly string[]): SingCommand | undefined {
121
+ const [first, ...rest] = words;
122
+ if (!first)
123
+ return { type: "sing-usage", message: "note vowel <a|a>u|off> [target]" };
124
+ let vowel: string | null;
125
+ if (first === "off" || first === "unset") vowel = null;
126
+ else {
127
+ try {
128
+ vowel = normalizeVowel(first);
129
+ } catch {
130
+ return {
131
+ type: "sing-usage",
132
+ message: "note vowel is a e i o u or a morph like a>u",
133
+ };
134
+ }
135
+ }
136
+ const target = parseNoteTarget(rest);
137
+ if (!target)
138
+ return {
139
+ type: "sing-usage",
140
+ message: "note vowel <v> [all|last|bar <n>|bars <a>-<b>|<ids>]",
141
+ };
142
+ return { type: "note-vowel", vowel, target };
143
+ }
144
+
145
+ export function parseSingCommand(prompt: string): SingCommand | undefined {
146
+ const words = prompt.trim().replace(/^\//, "").toLowerCase().split(/\s+/);
147
+ if (words[0] === "note" && words[1] === "vowel") {
148
+ if (prompt.length > 1_024) return undefined;
149
+ return parseNoteVowel(words.slice(2));
150
+ }
151
+ if (words[0] === "presets" && words[1] === "sing" && words.length === 2)
152
+ return { type: "sing-list" };
153
+ if (words[0] !== "sing") return undefined;
154
+ if (words.length === 1) return { type: "sing-show" };
155
+ if (prompt.length > 1_024) return undefined;
156
+ const rest = words.slice(1);
157
+ if (rest.length === 1 && rest[0] === "reset") return { type: "sing-reset" };
158
+ if (rest.length === 1 && rest[0] === "off") return { type: "sing-off" };
159
+ if (rest.length === 1 && (rest[0] === "list" || rest[0] === "presets"))
160
+ return { type: "sing-list" };
161
+ if (rest[0] === "vowels") {
162
+ const vowels: string[] = [];
163
+ for (const word of rest.slice(1)) {
164
+ try {
165
+ vowels.push(normalizeVowel(word));
166
+ } catch {
167
+ return {
168
+ type: "sing-usage",
169
+ message: `sing vowels · ${word.slice(0, 16)} is not a e i o u or a morph like a>o`,
170
+ };
171
+ }
172
+ }
173
+ return vowels.length > 0
174
+ ? { type: "sing-vowels", vowels }
175
+ : { type: "sing-usage", message: "sing vowels a e i o u (cycled)" };
176
+ }
177
+ if (rest[0] === "preset") {
178
+ const preset = rest.length === 2 ? presetFor(rest[1]!) : undefined;
179
+ return preset
180
+ ? { type: "sing-set", preset, values: {} }
181
+ : {
182
+ type: "sing-usage",
183
+ message: `sing preset is one of ${SING_PRESET_NAMES.join(" ")}`,
184
+ };
185
+ }
186
+ if (rest.length === 1) {
187
+ const preset = presetFor(rest[0]!);
188
+ if (preset) return { type: "sing-set", preset, values: {} };
189
+ const near = nearest(rest[0]!.slice(0, 24), SING_PRESET_NAMES);
190
+ return {
191
+ type: "sing-usage",
192
+ message: near ? `sing · did you mean ${near}?` : SING_USAGE,
193
+ };
194
+ }
195
+ // `harmonics 6 12` is the range `harmonics 6-12`.
196
+ for (let index = 0; index + 2 < rest.length; index += 1)
197
+ if (
198
+ rest[index] === "harmonics" &&
199
+ /^\d+$/.test(rest[index + 1]!) &&
200
+ /^\d+$/.test(rest[index + 2]!)
201
+ )
202
+ rest.splice(index + 1, 2, `${rest[index + 1]}-${rest[index + 2]}`);
203
+ // `sing <preset> <param> <value>…`: the preset, then its overrides.
204
+ const lead = rest.length % 2 === 1 ? presetFor(rest[0]!) : undefined;
205
+ if (lead) rest.shift();
206
+ if (rest.length % 2 !== 0) return { type: "sing-usage", message: SING_USAGE };
207
+ const values: Record<string, SingValue> = {};
208
+ for (let index = 0; index < rest.length; index += 2) {
209
+ const name = singParamName(rest[index]!);
210
+ if (!name) {
211
+ const word = rest[index]!.slice(0, 24);
212
+ const near = nearest(word, PARAM_NAMES);
213
+ return {
214
+ type: "sing-usage",
215
+ message: `sing has no parameter ${word}${near ? ` · did you mean ${near}?` : ""} · vowel voices bright breath vib drone …`,
216
+ };
217
+ }
218
+ const word = rest[index + 1]!;
219
+ if (word === "off" || word === "unset") {
220
+ values[name] = null;
221
+ continue;
222
+ }
223
+ const value = valueOf(name, word);
224
+ if (value === undefined)
225
+ return { type: "sing-usage", message: rangeOf(name) };
226
+ values[name] = value;
227
+ }
228
+ return lead
229
+ ? { type: "sing-set", preset: lead, values }
230
+ : { type: "sing-set", values };
231
+ }
232
+
233
+ /** `preset khoomei · drone D3 · voices 4`, overrides in canonical order. */
234
+ export function describeSing(sing: TrackSing | undefined): string {
235
+ if (!sing) return "not a sing track";
236
+ const preset = sing.preset ?? DEFAULT_SING_PRESET;
237
+ const overrides = Object.entries(sing)
238
+ .filter(([key]) => key !== "preset")
239
+ .map(([key, value]) =>
240
+ key === "drone"
241
+ ? `drone ${singNoteName(value as number)}`
242
+ : Array.isArray(value)
243
+ ? `${key} ${value.join("-")}`
244
+ : `${key} ${value}`,
245
+ );
246
+ return [`preset ${preset}`, ...overrides].join(" · ");
247
+ }
248
+
249
+ /** One line per preset, for `sing list` and the agent brief. */
250
+ export function singListLines(): string[] {
251
+ return SING_PRESET_NAMES.map(
252
+ (name) => `${name.padEnd(9)} ${SING_PRESETS[name].doc}`,
253
+ );
254
+ }
255
+
256
+ export type SingResult = Readonly<{
257
+ ok: boolean;
258
+ message: string;
259
+ next?: TrackScore;
260
+ kind?: string;
261
+ payload?: Record<string, unknown>;
262
+ }>;
263
+
264
+ /**
265
+ * The sing field a command leaves on a track (pure; the agent tool shares
266
+ * it). A preset switch keeps the overrides; `reset` keeps the preset.
267
+ */
268
+ export function nextSing(
269
+ current: TrackSing | undefined,
270
+ command: Extract<SingCommand, { type: "sing-reset" | "sing-set" }>,
271
+ ): TrackSing {
272
+ if (command.type === "sing-reset")
273
+ return normalizeSing(current?.preset ? { preset: current.preset } : {})!;
274
+ const base: Record<string, unknown> = { ...current };
275
+ if (command.preset) base.preset = command.preset;
276
+ for (const [key, value] of Object.entries(command.values)) {
277
+ if (value === null) delete base[key];
278
+ else base[key] = value;
279
+ }
280
+ return normalizeSing(base)!;
281
+ }
282
+
283
+ /**
284
+ * Set the vowels of a track's notes in time order, cycling `vowels`
285
+ * (`sing vowels a e i o u`). Pure; the `set_vowels` tool shares it.
286
+ * `noteIds` limits it to those notes (still in time order).
287
+ */
288
+ export function assignVowels(
289
+ score: TrackScore,
290
+ trackId: string,
291
+ vowels: readonly string[],
292
+ noteIds?: readonly string[],
293
+ ): { next: TrackScore; count: number } {
294
+ const ids = noteIds ? new Set(noteIds) : undefined;
295
+ const notes = score.notes
296
+ .filter((note) => (ids ? ids.has(note.id) : note.trackId === trackId))
297
+ .sort((a, b) => a.startTick - b.startTick || a.pitch - b.pitch);
298
+ if (notes.length === 0 || vowels.length === 0)
299
+ return { next: score, count: 0 };
300
+ const canonical = vowels.map((vowel) => normalizeVowel(vowel));
301
+ let next = score;
302
+ // Group notes by vowel so one patchNotes call per vowel builds the score.
303
+ const byVowel = new Map<string, Set<string>>();
304
+ notes.forEach((note, index) => {
305
+ const vowel = canonical[index % canonical.length]!;
306
+ const set = byVowel.get(vowel) ?? new Set<string>();
307
+ set.add(note.id);
308
+ byVowel.set(vowel, set);
309
+ });
310
+ for (const [vowel, set] of byVowel) next = patchNotes(next, set, { vowel });
311
+ return { next, count: notes.length };
312
+ }
313
+
314
+ /** The throat demo line in semitones over the drone (formant.md 4.4). */
315
+ export const THROAT_DEMO_STEPS = Object.freeze([
316
+ 12, 14, 16, 19, 21, 19, 16, 14,
317
+ ]);
318
+
319
+ /**
320
+ * Write the throat demo melody on an empty throat track: one note per
321
+ * two beats, one octave above the drone that plays, inside the song.
322
+ * Undefined when the voice has no drone. Pure.
323
+ */
324
+ export function throatDemo(
325
+ score: TrackScore,
326
+ trackId: string,
327
+ sing: TrackSing,
328
+ ): { next: TrackScore; count: number } | undefined {
329
+ const drone = resolveSing(sing, keyRootOf(score)).drone;
330
+ if (drone === undefined) return undefined;
331
+ const step = score.ticksPerBeat * 2;
332
+ const total = score.ticksPerBeat * score.beatsPerBar * score.bars;
333
+ const ids = new Set(score.notes.map((note) => note.id));
334
+ let next = score;
335
+ let count = 0;
336
+ for (const [index, offset] of THROAT_DEMO_STEPS.entries()) {
337
+ const startTick = index * step;
338
+ if (startTick + step > total) break;
339
+ let id = `${trackId}-demo-${index + 1}`;
340
+ for (let n = 2; ids.has(id); n += 1)
341
+ id = `${trackId}-demo-${index + 1}-${n}`;
342
+ ids.add(id);
343
+ next = addNote(next, {
344
+ id,
345
+ trackId,
346
+ startTick,
347
+ durationTicks: step,
348
+ pitch: Math.min(127, drone + offset),
349
+ velocity: 0.8,
350
+ });
351
+ count += 1;
352
+ }
353
+ return count > 0 ? { next, count } : undefined;
354
+ }
355
+
356
+ const keyRootOf = (score: TrackScore): number | undefined =>
357
+ parseKey(score.key)?.tonic;
358
+
359
+ /** The instrument a track plays after `sing off` (the default track voice). */
360
+ export const SING_OFF_INSTRUMENT = "sine";
361
+
362
+ export function applySingCommand(
363
+ score: TrackScore,
364
+ trackId: string,
365
+ command: SingCommand,
366
+ ): SingResult {
367
+ if (command.type === "sing-usage")
368
+ return {
369
+ ok: false,
370
+ message: command.message.startsWith("note")
371
+ ? command.message
372
+ : `sing · ${command.message}`,
373
+ };
374
+ if (command.type === "sing-list")
375
+ return {
376
+ ok: true,
377
+ message: `sing presets · ${SING_PRESET_NAMES.join(" ")}`,
378
+ };
379
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
380
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
381
+ if (command.type === "note-vowel") {
382
+ const notes = targetNotes(score, trackId, command.target);
383
+ if (notes.length === 0)
384
+ return {
385
+ ok: false,
386
+ message: `note vowel · no notes in ${describeTarget(command.target)}`,
387
+ };
388
+ const next = patchNotes(score, new Set(notes.map((note) => note.id)), {
389
+ vowel: command.vowel,
390
+ });
391
+ return {
392
+ ok: true,
393
+ message: `note vowel · ${command.vowel ?? "off"} · ${notes.length} note${notes.length === 1 ? "" : "s"}`,
394
+ next,
395
+ kind: "score.notes.vowel",
396
+ payload: {
397
+ trackId,
398
+ vowel: command.vowel,
399
+ noteIds: notes.map((note) => note.id),
400
+ },
401
+ };
402
+ }
403
+ if (command.type === "sing-vowels") {
404
+ const { next, count } = assignVowels(score, trackId, command.vowels);
405
+ if (count === 0)
406
+ return { ok: false, message: `sing vowels · ${trackId} has no notes` };
407
+ return {
408
+ ok: true,
409
+ message: `sing vowels · ${command.vowels.join(" ")} over ${count} note${count === 1 ? "" : "s"}`,
410
+ next,
411
+ kind: "score.notes.vowel",
412
+ payload: { trackId, vowels: command.vowels },
413
+ };
414
+ }
415
+ const current = track.instrument === SING_INSTRUMENT ? track.sing : undefined;
416
+ if (command.type === "sing-show")
417
+ return {
418
+ ok: true,
419
+ message: current
420
+ ? `sing · ${singSummary(current, keyRootOf(score))} · ${describeSing(current)}`
421
+ : track.instrument === SING_INSTRUMENT
422
+ ? `sing · off · sing <preset> turns it on (${SING_PRESET_NAMES.join(" ")})`
423
+ : isGuideInstrument(track.instrument)
424
+ ? `${trackId} sings with the vocal guide · try sing choir`
425
+ : `sing · ${trackId} plays ${track.instrument} · sing <preset> to switch (${SING_PRESET_NAMES.join(" ")})`,
426
+ };
427
+ if (track.sampler || track.instrument === "kit")
428
+ return {
429
+ ok: false,
430
+ message: `sing · ${trackId} is a ${track.sampler ? "sampler" : "drum"} track`,
431
+ };
432
+ if (command.type === "sing-off") {
433
+ if (!current) return { ok: true, message: "sing · already off" };
434
+ // Off is a plain sine, never instrument "sing" without its field: the
435
+ // word `sing` reads back as the default voice, so a field-less sing
436
+ // track would sound again after a save and reload.
437
+ const next = updateTrack(score, trackId, {
438
+ instrument: SING_OFF_INSTRUMENT,
439
+ sing: null,
440
+ });
441
+ return {
442
+ ok: true,
443
+ message: `sing · off (${SING_OFF_INSTRUMENT}) · sing ${current.preset ?? DEFAULT_SING_PRESET} turns it back on`,
444
+ next,
445
+ kind: "score.sing",
446
+ payload: { trackId, instrument: SING_OFF_INSTRUMENT, sing: null },
447
+ };
448
+ }
449
+ let sing: TrackSing;
450
+ let next: TrackScore;
451
+ try {
452
+ sing = nextSing(current, command);
453
+ next = updateTrack(score, trackId, { instrument: SING_INSTRUMENT, sing });
454
+ } catch (error) {
455
+ if (
456
+ error instanceof ScoreValidationError ||
457
+ error instanceof FxValidationError
458
+ )
459
+ return { ok: false, message: `sing · ${error.message}` };
460
+ throw error;
461
+ }
462
+ // A throat style needs a run of notes to steer the drone's overtones:
463
+ // on an empty track, write a short demo line so one step is audible.
464
+ const demo =
465
+ command.type === "sing-set" &&
466
+ command.preset !== undefined &&
467
+ !score.notes.some((note) => note.trackId === trackId)
468
+ ? throatDemo(next, trackId, sing)
469
+ : undefined;
470
+ if (demo) next = demo.next;
471
+ return {
472
+ ok: true,
473
+ message: `sing · ${singSummary(sing, keyRootOf(next))}${demo ? ` · ${demo.count} demo notes (^z removes them; clear before writing your own)` : ""}`,
474
+ next,
475
+ kind: "score.sing",
476
+ payload: { trackId, instrument: SING_INSTRUMENT, sing },
477
+ };
478
+ }
@@ -9,6 +9,9 @@
9
9
  * string <param> off back to the preset's value
10
10
  * string reset drop the overrides, keep the preset
11
11
  * string off back to the track's synth voice
12
+ * bowed play the cello preset (bowed strings)
13
+ * bowed <preset> a bowed preset (violin viola cello …)
14
+ * bowed presets the bowed presets
12
15
  *
13
16
  * `string sitar`, `string buzz 0.8 sym 0.5`, `string ring 6`. Each command
14
17
  * is one `updateTrack` revision and one undo step.
@@ -20,6 +23,8 @@ import {
20
23
  type TrackScore,
21
24
  } from "../../core/score.ts";
22
25
  import {
26
+ BOWED_PRESET_NAMES,
27
+ DEFAULT_BOWED_PRESET,
23
28
  DEFAULT_STRING_PRESET,
24
29
  isStringTrack,
25
30
  normalizeString,
@@ -37,7 +42,7 @@ import { parseParamValue } from "./fx.ts";
37
42
 
38
43
  export type StringCommand =
39
44
  | { type: "string-list" }
40
- | { type: "string-presets" }
45
+ | { type: "string-presets"; bowed?: boolean }
41
46
  | { type: "string-reset" }
42
47
  | { type: "string-off" }
43
48
  | { type: "string-preset"; preset: string }
@@ -49,6 +54,7 @@ export type StringCommand =
49
54
 
50
55
  export function parseStringCommand(prompt: string): StringCommand | undefined {
51
56
  const words = prompt.trim().split(/\s+/);
57
+ if (words[0]?.toLowerCase() === "bowed") return parseBowed(words.slice(1));
52
58
  if (words[0]?.toLowerCase() !== "string") return undefined;
53
59
  if (words.length === 1) return { type: "string-list" };
54
60
  if (prompt.length > 1_024) return undefined;
@@ -82,6 +88,26 @@ export function parseStringCommand(prompt: string): StringCommand | undefined {
82
88
  return { type: "string-set", values };
83
89
  }
84
90
 
91
+ /**
92
+ * `bowed` (0.6.1): the string engine's bowed family. `bowed` plays cello,
93
+ * `bowed <preset>` a bowed preset, `bowed presets` lists them, and
94
+ * `bowed <param> <value>…` is `string <param> <value>…`.
95
+ */
96
+ function parseBowed(words: string[]): StringCommand | undefined {
97
+ if (words.length === 0)
98
+ return { type: "string-preset", preset: DEFAULT_BOWED_PRESET };
99
+ const rest = words.map((word) => word.toLowerCase());
100
+ if (rest.length === 1 && (rest[0] === "presets" || rest[0] === "list"))
101
+ return { type: "string-presets", bowed: true };
102
+ if (rest.length === 1) {
103
+ const preset = stringPresetName(rest[0]!);
104
+ return preset && BOWED_PRESET_NAMES.includes(preset)
105
+ ? { type: "string-preset", preset }
106
+ : undefined;
107
+ }
108
+ return parseStringCommand(`string ${rest.join(" ")}`);
109
+ }
110
+
85
111
  /** `sitar · buzz 0.8 · sym 0.5`, or `nylon` for a bare preset. */
86
112
  export function describeString(settings: TrackString | undefined): string {
87
113
  const parts = [stringPresetOf(settings)];
@@ -108,7 +134,7 @@ export function applyStringCommand(
108
134
  if (command.type === "string-presets")
109
135
  return {
110
136
  ok: true,
111
- message: `string presets · ${STRING_PRESET_NAMES.map((name) => `${name} (${STRING_PRESETS[name]!.doc})`).join(" · ")}`,
137
+ message: `${command.bowed ? "bowed" : "string"} presets · ${(command.bowed ? BOWED_PRESET_NAMES : STRING_PRESET_NAMES).map((name) => `${name} (${STRING_PRESETS[name]!.doc})`).join(" · ")}`,
112
138
  };
113
139
  if (track.sampler || track.kit)
114
140
  return {