@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
@@ -8,6 +8,7 @@
8
8
  * track (the `cloud` preset) holding one note across the range. The source
9
9
  * stays as it is; mute it to hear only the resample.
10
10
  */
11
+ import { newId } from "../../core/ids.ts";
11
12
  import { mkdir, realpath, rename, writeFile } from "node:fs/promises";
12
13
  import { join } from "node:path";
13
14
  import { MAX_ORBIT } from "../../core/fx.ts";
@@ -18,6 +19,7 @@ import {
18
19
  TrackScore,
19
20
  addTrack,
20
21
  type SampleRef,
22
+ requirePinnedRef,
21
23
  type TrackInput,
22
24
  } from "../../core/score.ts";
23
25
  import { trackDirectories } from "../../core/sdk/print.ts";
@@ -155,13 +157,16 @@ export function addResampleTrack(
155
157
  trackId: string,
156
158
  src: string,
157
159
  ): TrackScore {
158
- const ref: SampleRef = {
159
- src,
160
- sha256: render.sha256,
161
- from: render.from,
162
- // A bounce starts at full level: no anti-click fade-in.
163
- ...(command.grain ? {} : { gain: RESAMPLE_GAIN, fadeInTime: 0 }),
164
- };
160
+ const ref: SampleRef = requirePinnedRef(
161
+ {
162
+ src,
163
+ sha256: render.sha256,
164
+ from: render.from,
165
+ // A bounce starts at full level: no anti-click fade-in.
166
+ ...(command.grain ? {} : { gain: RESAMPLE_GAIN, fadeInTime: 0 }),
167
+ },
168
+ "resample",
169
+ );
165
170
  const track: TrackInput = command.grain
166
171
  ? {
167
172
  id: trackId,
@@ -183,15 +188,13 @@ export function addResampleTrack(
183
188
  // plays the source at its own pitch.
184
189
  const pitch = command.grain ? 60 : 36;
185
190
  const notes = next.notes;
186
- let n = 1;
187
- while (notes.some((note) => note.id === `${trackId}-${n}`)) n += 1;
188
191
  const ticks = Math.min(render.durationTicks, next.bars * barTicks(next));
189
192
  next = new TrackScore({
190
193
  ...next.toJSON(),
191
194
  notes: [
192
195
  ...notes,
193
196
  {
194
- id: `${trackId}-${n}`,
197
+ id: newId(trackId),
195
198
  trackId,
196
199
  startTick: render.startTick,
197
200
  durationTicks: Math.max(1, ticks),
@@ -120,6 +120,9 @@ export function parseRhythmCommand(prompt: string): RhythmCommand | undefined {
120
120
  fields[key] = Number(raw);
121
121
  }
122
122
  }
123
+ // `euclid hat` alone names a row to edit: the window opens the editor on
124
+ // it (`/euclid hat`), so it is not an empty edit here.
125
+ if (Object.keys(fields).length === 0) return undefined;
123
126
  return { type: "rhythm-set", voice: lowerDrum(voice), fields };
124
127
  }
125
128
 
@@ -45,6 +45,7 @@ import {
45
45
  type FxCommand,
46
46
  type FxResult,
47
47
  } from "./fx.ts";
48
+ import { nearest } from "./nearest.ts";
48
49
 
49
50
  export type RigCommand =
50
51
  | { type: "rig-show" }
@@ -67,33 +68,11 @@ function isStage(word: string): word is RigStage {
67
68
 
68
69
  /** `unknown rig crunh · did you mean crunch? · rigs clean crunch …` */
69
70
  function unknownRigMessage(name: string): string {
70
- let best: { name: string; distance: number } | undefined;
71
- for (const candidate of RIG_PRESET_NAMES) {
72
- const distance = editDistance(name, candidate);
73
- if (!best || distance < best.distance) best = { name: candidate, distance };
74
- }
75
- const near =
76
- best && best.distance <= Math.max(2, Math.floor(name.length / 3))
77
- ? ` · did you mean ${best.name}?`
78
- : "";
71
+ const match = nearest(name, RIG_PRESET_NAMES);
72
+ const near = match ? ` · did you mean ${match}?` : "";
79
73
  return `unknown rig ${name}${near} · rigs ${RIG_PRESET_NAMES.join(" ")} reset`;
80
74
  }
81
75
 
82
- function editDistance(a: string, b: string): number {
83
- let row = Array.from({ length: b.length + 1 }, (_, i) => i);
84
- for (let i = 1; i <= a.length; i++) {
85
- const next = [i];
86
- for (let j = 1; j <= b.length; j++)
87
- next[j] = Math.min(
88
- row[j]! + 1,
89
- next[j - 1]! + 1,
90
- row[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
91
- );
92
- row = next;
93
- }
94
- return row[b.length]!;
95
- }
96
-
97
76
  export function parseRigCommand(prompt: string): RigCommand | undefined {
98
77
  if (prompt.length > 1_024) return undefined;
99
78
  const words = prompt.trim().replace(/^\//, "").toLowerCase().split(/\s+/);
@@ -24,6 +24,9 @@ import {
24
24
  samplerVoiceSlots,
25
25
  type Sampler,
26
26
  type SampleRef,
27
+ type PinnedSampleRef,
28
+ requirePinnedRef,
29
+ ScoreValidationError,
27
30
  type Track,
28
31
  } from "../../core/score.ts";
29
32
  import { trackDirectories } from "../../core/sdk/print.ts";
@@ -317,8 +320,15 @@ export function addSampleVoice(
317
320
  score: TrackScore,
318
321
  trackId: string,
319
322
  voice: string,
320
- ref: SampleRef,
323
+ ref: PinnedSampleRef,
321
324
  ): AddVoiceResult {
325
+ try {
326
+ requirePinnedRef(ref, `sample ${voice}`);
327
+ } catch (error) {
328
+ if (error instanceof ScoreValidationError)
329
+ return { ok: false, message: `sample · ${error.message}` };
330
+ throw error;
331
+ }
322
332
  if (!VOICE.test(voice))
323
333
  return {
324
334
  ok: false,
@@ -0,0 +1,474 @@
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 { newId } from "../../core/ids.ts";
23
+ import { isGuideInstrument } from "../../core/clips.ts";
24
+ import { parseKey } from "../../core/chords.ts";
25
+ import { FxValidationError } from "../../core/params.ts";
26
+ import {
27
+ DEFAULT_SING_PRESET,
28
+ isSingPreset,
29
+ normalizeSing,
30
+ normalizeVowel,
31
+ parseDrone,
32
+ resolveSing,
33
+ SING_INSTRUMENT,
34
+ SING_PARAMS,
35
+ SING_PRESETS,
36
+ SING_PRESET_NAMES,
37
+ singNoteName,
38
+ singParamName,
39
+ singSummary,
40
+ type SingPreset,
41
+ type TrackSing,
42
+ } from "../../core/sing.ts";
43
+ import {
44
+ addNote,
45
+ ScoreValidationError,
46
+ updateTrack,
47
+ type TrackScore,
48
+ } from "../../core/score.ts";
49
+ import { nearest } from "./nearest.ts";
50
+ import {
51
+ describeTarget,
52
+ parseNoteTarget,
53
+ patchNotes,
54
+ targetNotes,
55
+ type NoteTarget,
56
+ } from "./expression.ts";
57
+ import { parseParamValue } from "./fx.ts";
58
+
59
+ export type SingValue = number | string | readonly [number, number] | null;
60
+
61
+ export type SingCommand =
62
+ | { type: "sing-show" }
63
+ | { type: "sing-list" }
64
+ | { type: "sing-reset" }
65
+ | { type: "sing-off" }
66
+ | { type: "sing-usage"; message: string }
67
+ | { type: "sing-vowels"; vowels: readonly string[] }
68
+ | { type: "note-vowel"; vowel: string | null; target: NoteTarget }
69
+ | {
70
+ type: "sing-set";
71
+ /** A preset applied before the values (`sing khoomei drone D3`). */
72
+ preset?: SingPreset;
73
+ /** `null` returns a parameter to the preset's value. */
74
+ values: Readonly<Record<string, SingValue>>;
75
+ };
76
+
77
+ export const SING_USAGE =
78
+ "sing <preset> | sing <param> <value> | sing drone <D3> | sing vowels a e i … | sing reset | sing off | sing list";
79
+
80
+ const PARAM_NAMES = [...Object.keys(SING_PARAMS), "harmonics"];
81
+
82
+ function rangeOf(name: string): string {
83
+ if (name === "vowel") return "vowel is a e i o u or a morph like a>o";
84
+ if (name === "drone")
85
+ return "drone is a note name C2..G4 (D3) or MIDI 36..67";
86
+ if (name === "harmonics") return "harmonics is a range like 6-12 (2..24)";
87
+ const spec = SING_PARAMS[name]!;
88
+ if (spec.kind === "number") return `${name} is ${spec.min}..${spec.max}`;
89
+ if (spec.kind === "enum") return `${name} is one of ${spec.values.join(" ")}`;
90
+ return `${name} is on or off`;
91
+ }
92
+
93
+ function presetFor(word: string): SingPreset | undefined {
94
+ const lower = word.toLowerCase();
95
+ return isSingPreset(lower) ? lower : undefined;
96
+ }
97
+
98
+ /** One value word for `name`; undefined when it is out of range. */
99
+ function valueOf(name: string, word: string): SingValue | undefined {
100
+ try {
101
+ if (name === "vowel") return normalizeVowel(word);
102
+ if (name === "drone") return parseDrone(word);
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ if (name === "harmonics") {
107
+ const match = /^(\d+)[-,:](\d+)$/.exec(word);
108
+ if (!match) return undefined;
109
+ const lo = Number(match[1]);
110
+ const hi = Number(match[2]);
111
+ return lo >= 2 && hi <= 24 && lo < hi
112
+ ? (Object.freeze([lo, hi]) as readonly [number, number])
113
+ : undefined;
114
+ }
115
+ const spec = SING_PARAMS[name];
116
+ if (!spec) return undefined;
117
+ const value = parseParamValue(spec, word);
118
+ return typeof value === "boolean" ? undefined : value;
119
+ }
120
+
121
+ function parseNoteVowel(words: readonly string[]): SingCommand | undefined {
122
+ const [first, ...rest] = words;
123
+ if (!first)
124
+ return { type: "sing-usage", message: "note vowel <a|a>u|off> [target]" };
125
+ let vowel: string | null;
126
+ if (first === "off" || first === "unset") vowel = null;
127
+ else {
128
+ try {
129
+ vowel = normalizeVowel(first);
130
+ } catch {
131
+ return {
132
+ type: "sing-usage",
133
+ message: "note vowel is a e i o u or a morph like a>u",
134
+ };
135
+ }
136
+ }
137
+ const target = parseNoteTarget(rest);
138
+ if (!target)
139
+ return {
140
+ type: "sing-usage",
141
+ message: "note vowel <v> [all|last|bar <n>|bars <a>-<b>|<ids>]",
142
+ };
143
+ return { type: "note-vowel", vowel, target };
144
+ }
145
+
146
+ export function parseSingCommand(prompt: string): SingCommand | undefined {
147
+ const words = prompt.trim().replace(/^\//, "").toLowerCase().split(/\s+/);
148
+ if (words[0] === "note" && words[1] === "vowel") {
149
+ if (prompt.length > 1_024) return undefined;
150
+ return parseNoteVowel(words.slice(2));
151
+ }
152
+ if (words[0] === "presets" && words[1] === "sing" && words.length === 2)
153
+ return { type: "sing-list" };
154
+ if (words[0] !== "sing") return undefined;
155
+ if (words.length === 1) return { type: "sing-show" };
156
+ if (prompt.length > 1_024) return undefined;
157
+ const rest = words.slice(1);
158
+ if (rest.length === 1 && rest[0] === "reset") return { type: "sing-reset" };
159
+ if (rest.length === 1 && rest[0] === "off") return { type: "sing-off" };
160
+ if (rest.length === 1 && (rest[0] === "list" || rest[0] === "presets"))
161
+ return { type: "sing-list" };
162
+ if (rest[0] === "vowels") {
163
+ const vowels: string[] = [];
164
+ for (const word of rest.slice(1)) {
165
+ try {
166
+ vowels.push(normalizeVowel(word));
167
+ } catch {
168
+ return {
169
+ type: "sing-usage",
170
+ message: `sing vowels · ${word.slice(0, 16)} is not a e i o u or a morph like a>o`,
171
+ };
172
+ }
173
+ }
174
+ return vowels.length > 0
175
+ ? { type: "sing-vowels", vowels }
176
+ : { type: "sing-usage", message: "sing vowels a e i o u (cycled)" };
177
+ }
178
+ if (rest[0] === "preset") {
179
+ const preset = rest.length === 2 ? presetFor(rest[1]!) : undefined;
180
+ return preset
181
+ ? { type: "sing-set", preset, values: {} }
182
+ : {
183
+ type: "sing-usage",
184
+ message: `sing preset is one of ${SING_PRESET_NAMES.join(" ")}`,
185
+ };
186
+ }
187
+ if (rest.length === 1) {
188
+ const preset = presetFor(rest[0]!);
189
+ if (preset) return { type: "sing-set", preset, values: {} };
190
+ const near = nearest(rest[0]!.slice(0, 24), SING_PRESET_NAMES);
191
+ return {
192
+ type: "sing-usage",
193
+ message: near ? `sing · did you mean ${near}?` : SING_USAGE,
194
+ };
195
+ }
196
+ // `harmonics 6 12` is the range `harmonics 6-12`.
197
+ for (let index = 0; index + 2 < rest.length; index += 1)
198
+ if (
199
+ rest[index] === "harmonics" &&
200
+ /^\d+$/.test(rest[index + 1]!) &&
201
+ /^\d+$/.test(rest[index + 2]!)
202
+ )
203
+ rest.splice(index + 1, 2, `${rest[index + 1]}-${rest[index + 2]}`);
204
+ // `sing <preset> <param> <value>…`: the preset, then its overrides.
205
+ const lead = rest.length % 2 === 1 ? presetFor(rest[0]!) : undefined;
206
+ if (lead) rest.shift();
207
+ if (rest.length % 2 !== 0) return { type: "sing-usage", message: SING_USAGE };
208
+ const values: Record<string, SingValue> = {};
209
+ for (let index = 0; index < rest.length; index += 2) {
210
+ const name = singParamName(rest[index]!);
211
+ if (!name) {
212
+ const word = rest[index]!.slice(0, 24);
213
+ const near = nearest(word, PARAM_NAMES);
214
+ return {
215
+ type: "sing-usage",
216
+ message: `sing has no parameter ${word}${near ? ` · did you mean ${near}?` : ""} · vowel voices bright breath vib drone …`,
217
+ };
218
+ }
219
+ const word = rest[index + 1]!;
220
+ if (word === "off" || word === "unset") {
221
+ values[name] = null;
222
+ continue;
223
+ }
224
+ const value = valueOf(name, word);
225
+ if (value === undefined)
226
+ return { type: "sing-usage", message: rangeOf(name) };
227
+ values[name] = value;
228
+ }
229
+ return lead
230
+ ? { type: "sing-set", preset: lead, values }
231
+ : { type: "sing-set", values };
232
+ }
233
+
234
+ /** `preset khoomei · drone D3 · voices 4`, overrides in canonical order. */
235
+ export function describeSing(sing: TrackSing | undefined): string {
236
+ if (!sing) return "not a sing track";
237
+ const preset = sing.preset ?? DEFAULT_SING_PRESET;
238
+ const overrides = Object.entries(sing)
239
+ .filter(([key]) => key !== "preset")
240
+ .map(([key, value]) =>
241
+ key === "drone"
242
+ ? `drone ${singNoteName(value as number)}`
243
+ : Array.isArray(value)
244
+ ? `${key} ${value.join("-")}`
245
+ : `${key} ${value}`,
246
+ );
247
+ return [`preset ${preset}`, ...overrides].join(" · ");
248
+ }
249
+
250
+ /** One line per preset, for `sing list` and the agent brief. */
251
+ export function singListLines(): string[] {
252
+ return SING_PRESET_NAMES.map(
253
+ (name) => `${name.padEnd(9)} ${SING_PRESETS[name].doc}`,
254
+ );
255
+ }
256
+
257
+ export type SingResult = Readonly<{
258
+ ok: boolean;
259
+ message: string;
260
+ next?: TrackScore;
261
+ kind?: string;
262
+ payload?: Record<string, unknown>;
263
+ }>;
264
+
265
+ /**
266
+ * The sing field a command leaves on a track (pure; the agent tool shares
267
+ * it). A preset switch keeps the overrides; `reset` keeps the preset.
268
+ */
269
+ export function nextSing(
270
+ current: TrackSing | undefined,
271
+ command: Extract<SingCommand, { type: "sing-reset" | "sing-set" }>,
272
+ ): TrackSing {
273
+ if (command.type === "sing-reset")
274
+ return normalizeSing(current?.preset ? { preset: current.preset } : {})!;
275
+ const base: Record<string, unknown> = { ...current };
276
+ if (command.preset) base.preset = command.preset;
277
+ for (const [key, value] of Object.entries(command.values)) {
278
+ if (value === null) delete base[key];
279
+ else base[key] = value;
280
+ }
281
+ return normalizeSing(base)!;
282
+ }
283
+
284
+ /**
285
+ * Set the vowels of a track's notes in time order, cycling `vowels`
286
+ * (`sing vowels a e i o u`). Pure; the `set_vowels` tool shares it.
287
+ * `noteIds` limits it to those notes (still in time order).
288
+ */
289
+ export function assignVowels(
290
+ score: TrackScore,
291
+ trackId: string,
292
+ vowels: readonly string[],
293
+ noteIds?: readonly string[],
294
+ ): { next: TrackScore; count: number } {
295
+ const ids = noteIds ? new Set(noteIds) : undefined;
296
+ const notes = score.notes
297
+ .filter((note) => (ids ? ids.has(note.id) : note.trackId === trackId))
298
+ .sort((a, b) => a.startTick - b.startTick || a.pitch - b.pitch);
299
+ if (notes.length === 0 || vowels.length === 0)
300
+ return { next: score, count: 0 };
301
+ const canonical = vowels.map((vowel) => normalizeVowel(vowel));
302
+ let next = score;
303
+ // Group notes by vowel so one patchNotes call per vowel builds the score.
304
+ const byVowel = new Map<string, Set<string>>();
305
+ notes.forEach((note, index) => {
306
+ const vowel = canonical[index % canonical.length]!;
307
+ const set = byVowel.get(vowel) ?? new Set<string>();
308
+ set.add(note.id);
309
+ byVowel.set(vowel, set);
310
+ });
311
+ for (const [vowel, set] of byVowel) next = patchNotes(next, set, { vowel });
312
+ return { next, count: notes.length };
313
+ }
314
+
315
+ /** The throat demo line in semitones over the drone (formant.md 4.4). */
316
+ export const THROAT_DEMO_STEPS = Object.freeze([
317
+ 12, 14, 16, 19, 21, 19, 16, 14,
318
+ ]);
319
+
320
+ /**
321
+ * Write the throat demo melody on an empty throat track: one note per
322
+ * two beats, one octave above the drone that plays, inside the song.
323
+ * Undefined when the voice has no drone. Pure.
324
+ */
325
+ export function throatDemo(
326
+ score: TrackScore,
327
+ trackId: string,
328
+ sing: TrackSing,
329
+ ): { next: TrackScore; count: number } | undefined {
330
+ const drone = resolveSing(sing, keyRootOf(score)).drone;
331
+ if (drone === undefined) return undefined;
332
+ const step = score.ticksPerBeat * 2;
333
+ const total = score.ticksPerBeat * score.beatsPerBar * score.bars;
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
+ next = addNote(next, {
340
+ id: newId(`${trackId}-demo`),
341
+ trackId,
342
+ startTick,
343
+ durationTicks: step,
344
+ pitch: Math.min(127, drone + offset),
345
+ velocity: 0.8,
346
+ });
347
+ count += 1;
348
+ }
349
+ return count > 0 ? { next, count } : undefined;
350
+ }
351
+
352
+ const keyRootOf = (score: TrackScore): number | undefined =>
353
+ parseKey(score.key)?.tonic;
354
+
355
+ /** The instrument a track plays after `sing off` (the default track voice). */
356
+ export const SING_OFF_INSTRUMENT = "sine";
357
+
358
+ export function applySingCommand(
359
+ score: TrackScore,
360
+ trackId: string,
361
+ command: SingCommand,
362
+ ): SingResult {
363
+ if (command.type === "sing-usage")
364
+ return {
365
+ ok: false,
366
+ message: command.message.startsWith("note")
367
+ ? command.message
368
+ : `sing · ${command.message}`,
369
+ };
370
+ if (command.type === "sing-list")
371
+ return {
372
+ ok: true,
373
+ message: `sing presets · ${SING_PRESET_NAMES.join(" ")}`,
374
+ };
375
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
376
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
377
+ if (command.type === "note-vowel") {
378
+ const notes = targetNotes(score, trackId, command.target);
379
+ if (notes.length === 0)
380
+ return {
381
+ ok: false,
382
+ message: `note vowel · no notes in ${describeTarget(command.target)}`,
383
+ };
384
+ const next = patchNotes(score, new Set(notes.map((note) => note.id)), {
385
+ vowel: command.vowel,
386
+ });
387
+ return {
388
+ ok: true,
389
+ message: `note vowel · ${command.vowel ?? "off"} · ${notes.length} note${notes.length === 1 ? "" : "s"}`,
390
+ next,
391
+ kind: "score.notes.vowel",
392
+ payload: {
393
+ trackId,
394
+ vowel: command.vowel,
395
+ noteIds: notes.map((note) => note.id),
396
+ },
397
+ };
398
+ }
399
+ if (command.type === "sing-vowels") {
400
+ const { next, count } = assignVowels(score, trackId, command.vowels);
401
+ if (count === 0)
402
+ return { ok: false, message: `sing vowels · ${trackId} has no notes` };
403
+ return {
404
+ ok: true,
405
+ message: `sing vowels · ${command.vowels.join(" ")} over ${count} note${count === 1 ? "" : "s"}`,
406
+ next,
407
+ kind: "score.notes.vowel",
408
+ payload: { trackId, vowels: command.vowels },
409
+ };
410
+ }
411
+ const current = track.instrument === SING_INSTRUMENT ? track.sing : undefined;
412
+ if (command.type === "sing-show")
413
+ return {
414
+ ok: true,
415
+ message: current
416
+ ? `sing · ${singSummary(current, keyRootOf(score))} · ${describeSing(current)}`
417
+ : track.instrument === SING_INSTRUMENT
418
+ ? `sing · off · sing <preset> turns it on (${SING_PRESET_NAMES.join(" ")})`
419
+ : isGuideInstrument(track.instrument)
420
+ ? `${trackId} sings with the vocal guide · try sing choir`
421
+ : `sing · ${trackId} plays ${track.instrument} · sing <preset> to switch (${SING_PRESET_NAMES.join(" ")})`,
422
+ };
423
+ if (track.sampler || track.instrument === "kit")
424
+ return {
425
+ ok: false,
426
+ message: `sing · ${trackId} is a ${track.sampler ? "sampler" : "drum"} track`,
427
+ };
428
+ if (command.type === "sing-off") {
429
+ if (!current) return { ok: true, message: "sing · already off" };
430
+ // Off is a plain sine, never instrument "sing" without its field: the
431
+ // word `sing` reads back as the default voice, so a field-less sing
432
+ // track would sound again after a save and reload.
433
+ const next = updateTrack(score, trackId, {
434
+ instrument: SING_OFF_INSTRUMENT,
435
+ sing: null,
436
+ });
437
+ return {
438
+ ok: true,
439
+ message: `sing · off (${SING_OFF_INSTRUMENT}) · sing ${current.preset ?? DEFAULT_SING_PRESET} turns it back on`,
440
+ next,
441
+ kind: "score.sing",
442
+ payload: { trackId, instrument: SING_OFF_INSTRUMENT, sing: null },
443
+ };
444
+ }
445
+ let sing: TrackSing;
446
+ let next: TrackScore;
447
+ try {
448
+ sing = nextSing(current, command);
449
+ next = updateTrack(score, trackId, { instrument: SING_INSTRUMENT, sing });
450
+ } catch (error) {
451
+ if (
452
+ error instanceof ScoreValidationError ||
453
+ error instanceof FxValidationError
454
+ )
455
+ return { ok: false, message: `sing · ${error.message}` };
456
+ throw error;
457
+ }
458
+ // A throat style needs a run of notes to steer the drone's overtones:
459
+ // on an empty track, write a short demo line so one step is audible.
460
+ const demo =
461
+ command.type === "sing-set" &&
462
+ command.preset !== undefined &&
463
+ !score.notes.some((note) => note.trackId === trackId)
464
+ ? throatDemo(next, trackId, sing)
465
+ : undefined;
466
+ if (demo) next = demo.next;
467
+ return {
468
+ ok: true,
469
+ message: `sing · ${singSummary(sing, keyRootOf(next))}${demo ? ` · ${demo.count} demo notes (^z removes them; clear before writing your own)` : ""}`,
470
+ next,
471
+ kind: "score.sing",
472
+ payload: { trackId, instrument: SING_INSTRUMENT, sing },
473
+ };
474
+ }
@@ -41,6 +41,7 @@ function parsePitch(text: string): number | undefined {
41
41
  }
42
42
  import {
43
43
  normalizeGuitar,
44
+ SCORE_LIMITS,
44
45
  ScoreValidationError,
45
46
  updateTrack,
46
47
  type Note,
@@ -50,6 +51,7 @@ import {
50
51
  } from "../../core/score.ts";
51
52
  import { resolveTuning, snapToTuning } from "../../core/tuning.ts";
52
53
  import { applyScoreOperations } from "../../core/diff.ts";
54
+ import { barTicks } from "../../core/sections.ts";
53
55
 
54
56
  export type GuitarCommand =
55
57
  | { type: "guitar-show" }
@@ -462,10 +464,20 @@ export function applyStrumCommand(
462
464
  return { ok: false, message: `strum · ${error.message}` };
463
465
  throw error;
464
466
  }
467
+ // Chords that run past the song's end grow it, so they are heard.
468
+ const end = next.notes.reduce(
469
+ (max, note) => Math.max(max, note.startTick + note.durationTicks),
470
+ 0,
471
+ );
472
+ const perBar = barTicks(next);
473
+ const needed = Math.min(SCORE_LIMITS.maxBars, Math.ceil(end / perBar));
474
+ const grew = needed > next.bars;
475
+ if (grew)
476
+ next = applyScoreOperations(next, [{ type: "setBars", bars: needed }]);
465
477
  const strings = guitarStrings(track.guitar?.tune).length;
466
478
  return {
467
479
  ok: true,
468
- message: `strum · ${plan.names.join(" ")} · ${command.options.strokes ?? "down"} · ${plan.noteCount} notes on ${strings} strings`,
480
+ message: `strum · ${plan.names.join(" ")} · ${command.options.strokes ?? "down"} · ${plan.noteCount} notes on ${strings} strings${grew ? ` · song now ${needed} bars` : ""}`,
469
481
  next,
470
482
  kind: "score.notes",
471
483
  payload: { trackId, strum: plan.names },