@hraness/dawg 0.6.1 → 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 (253) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/DAWG.md +343 -116
  3. package/README.md +27 -25
  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 +182 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/instruments.ts +19 -0
  11. package/core/keys.ts +3 -3
  12. package/core/loop.ts +5 -0
  13. package/core/lyrics.ts +297 -0
  14. package/core/master.ts +3 -3
  15. package/core/resonators.ts +16 -2
  16. package/core/routing.ts +165 -0
  17. package/core/score.ts +832 -13
  18. package/core/sdk/eval-child.ts +7 -2
  19. package/core/sdk/eval.ts +35 -6
  20. package/core/sdk/print.ts +276 -3
  21. package/core/sdk/sync-lyrics.ts +49 -0
  22. package/core/sdk/v1.ts +1849 -42
  23. package/core/sections.ts +292 -26
  24. package/core/sing.ts +815 -0
  25. package/core/style-provenance.ts +80 -0
  26. package/core/styles/africa-mena-southasia.ts +2893 -0
  27. package/core/styles/americas.ts +3810 -0
  28. package/core/styles/art.ts +4993 -0
  29. package/core/styles/base.ts +123 -0
  30. package/core/styles/cycles.ts +106 -0
  31. package/core/styles/electronic.ts +2723 -0
  32. package/core/styles/europe-asia-pacific.ts +2838 -0
  33. package/core/styles/excerpt.ts +29 -0
  34. package/core/styles/gamelan.ts +283 -0
  35. package/core/styles/generate.ts +2199 -0
  36. package/core/styles/index.ts +515 -0
  37. package/core/styles/parts.ts +106 -0
  38. package/core/styles/pop.ts +3189 -0
  39. package/core/styles/rock.ts +2993 -0
  40. package/core/styles/roots.ts +4175 -0
  41. package/core/styles/schema.ts +429 -0
  42. package/core/styles/taxonomy.ts +940 -0
  43. package/core/styles/validate.ts +528 -0
  44. package/core/tempo.ts +32 -2
  45. package/core/tuning.ts +19 -3
  46. package/core/vocoder.ts +524 -0
  47. package/guides/agent.md +29 -0
  48. package/guides/arrange.md +30 -0
  49. package/guides/audition.md +20 -14
  50. package/guides/automation.md +12 -7
  51. package/guides/chords.md +15 -15
  52. package/guides/effects.md +17 -16
  53. package/guides/faders.md +20 -16
  54. package/guides/files.md +13 -9
  55. package/guides/getting-started.md +15 -11
  56. package/guides/keys.md +19 -15
  57. package/guides/media.md +17 -12
  58. package/guides/mix.md +12 -6
  59. package/guides/music.md +23 -8
  60. package/guides/notes.md +15 -9
  61. package/guides/performance.md +15 -13
  62. package/guides/play.md +21 -13
  63. package/guides/project.md +25 -8
  64. package/guides/providers.md +20 -14
  65. package/guides/resample.md +15 -9
  66. package/guides/rhythm.md +17 -13
  67. package/guides/sessions.md +17 -7
  68. package/guides/show-me.md +31 -0
  69. package/guides/sound.md +25 -9
  70. package/guides/sounds.md +15 -13
  71. package/guides/styles.md +31 -0
  72. package/guides/tempo.md +15 -10
  73. package/guides/tracks.md +15 -10
  74. package/guides/tuning.md +32 -0
  75. package/guides/voice.md +31 -0
  76. package/guides/web-search.md +18 -8
  77. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  78. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  79. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  80. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  81. package/native/prebuilt/manifest.json +21 -0
  82. package/package.json +5 -2
  83. package/src/agent/agent.ts +126 -14
  84. package/src/agent/calibration-tools.ts +53 -0
  85. package/src/agent/clip-tools.ts +453 -0
  86. package/src/agent/command-agent.ts +369 -0
  87. package/src/agent/drum-tools.ts +2 -2
  88. package/src/agent/expression-tools.ts +1 -1
  89. package/src/agent/gateway.ts +246 -60
  90. package/src/agent/models.ts +53 -12
  91. package/src/agent/ops.ts +12 -1
  92. package/src/agent/pack-tools.ts +1 -1
  93. package/src/agent/planner.ts +13 -0
  94. package/src/agent/portable-schema.ts +80 -0
  95. package/src/agent/preview-tool.ts +4 -1
  96. package/src/agent/provider.ts +22 -8
  97. package/src/agent/rhythm-tools.ts +1 -1
  98. package/src/agent/section-tools.ts +1 -1
  99. package/src/agent/show-me.ts +497 -0
  100. package/src/agent/steer.ts +15 -0
  101. package/src/agent/style-tools.ts +217 -0
  102. package/src/agent/tool-error.ts +12 -0
  103. package/src/agent/tools.ts +108 -23
  104. package/src/agent/usage.ts +2 -2
  105. package/src/agent/voice-tools.ts +925 -0
  106. package/src/agent/xcb-agent.ts +11 -7
  107. package/src/argv.ts +38 -0
  108. package/src/audio/analysis.ts +253 -0
  109. package/src/audio/arrange.ts +37 -3
  110. package/src/audio/autotune-engine.ts +101 -0
  111. package/src/audio/autotune.ts +640 -0
  112. package/src/audio/clips.ts +240 -0
  113. package/src/audio/doctor.ts +86 -0
  114. package/src/audio/dsp/bandbank.ts +138 -0
  115. package/src/audio/dsp/envelope.ts +10 -0
  116. package/src/audio/dsp/follow.ts +120 -0
  117. package/src/audio/dsp/formant.ts +427 -0
  118. package/src/audio/dsp/glottal.ts +243 -0
  119. package/src/audio/dsp/interp.ts +7 -2
  120. package/src/audio/dsp/lpc.ts +50 -0
  121. package/src/audio/dsp/periodicity.ts +59 -0
  122. package/src/audio/dsp/pitch.ts +995 -0
  123. package/src/audio/dsp/psola.ts +199 -0
  124. package/src/audio/effects/chain.ts +3 -1
  125. package/src/audio/effects/common.ts +43 -0
  126. package/src/audio/effects/convolution.ts +7 -4
  127. package/src/audio/effects/filter.ts +48 -69
  128. package/src/audio/effects/formant.ts +263 -0
  129. package/src/audio/engine.ts +160 -28
  130. package/src/audio/fit.ts +35 -3
  131. package/src/audio/instrument-check.ts +59 -43
  132. package/src/audio/instruments.ts +4 -0
  133. package/src/audio/keys/calibration.ts +56 -0
  134. package/src/audio/keys/electric.ts +8 -1
  135. package/src/audio/keys/engine.ts +13 -1
  136. package/src/audio/keys/piano.ts +22 -2
  137. package/src/audio/kits.ts +135 -6
  138. package/src/audio/live.ts +114 -20
  139. package/src/audio/native.ts +615 -0
  140. package/src/audio/preview.ts +30 -2
  141. package/src/audio/render-worker.ts +2 -0
  142. package/src/audio/renderer.ts +2 -0
  143. package/src/audio/resample.ts +2 -1
  144. package/src/audio/sampler.ts +85 -4
  145. package/src/audio/samples.ts +20 -2
  146. package/src/audio/sing/analysis.ts +193 -0
  147. package/src/audio/sing/engine.ts +949 -0
  148. package/src/audio/strings/bow.ts +48 -5
  149. package/src/audio/strings/engine.ts +8 -2
  150. package/src/audio/synth/oscillators.ts +31 -21
  151. package/src/audio/synth/voice.ts +34 -1
  152. package/src/audio/vocoder/bank.ts +314 -0
  153. package/src/audio/vocoder/carrier.ts +165 -0
  154. package/src/audio/vocoder/control.ts +68 -0
  155. package/src/audio/vocoder/detect.ts +50 -0
  156. package/src/audio/vocoder/index.ts +304 -0
  157. package/src/audio/vocoder/talkbox.ts +143 -0
  158. package/src/audio/wav.ts +500 -77
  159. package/src/audio/winds/engine.ts +5 -1
  160. package/src/audio/winds/trim.ts +28 -4
  161. package/src/audio/winds/trims1.ts +297 -0
  162. package/src/audio/winds/voice.ts +15 -2
  163. package/src/auth/cli.ts +38 -36
  164. package/src/auth/credentials.ts +30 -1
  165. package/src/auth/login.ts +15 -9
  166. package/src/auth/tui.ts +19 -10
  167. package/src/commands/arrange.ts +44 -29
  168. package/src/commands/autotune.ts +421 -0
  169. package/src/commands/calibration.ts +74 -0
  170. package/src/commands/clips.ts +887 -0
  171. package/src/commands/drums.ts +3 -2
  172. package/src/commands/edit.ts +11 -4
  173. package/src/commands/expression.ts +1 -1
  174. package/src/commands/formant.ts +221 -0
  175. package/src/commands/fx.ts +101 -32
  176. package/src/commands/grammar.ts +558 -0
  177. package/src/commands/help.ts +610 -380
  178. package/src/commands/history.ts +18 -0
  179. package/src/commands/keys.ts +8 -8
  180. package/src/commands/modal.ts +1 -1
  181. package/src/commands/music.ts +1 -1
  182. package/src/commands/nearest.ts +53 -0
  183. package/src/commands/pack.ts +9 -2
  184. package/src/commands/param-range.ts +56 -0
  185. package/src/commands/parses.ts +130 -0
  186. package/src/commands/progression.ts +170 -0
  187. package/src/commands/rhythm.ts +3 -0
  188. package/src/commands/rig.ts +3 -24
  189. package/src/commands/sing.ts +478 -0
  190. package/src/commands/strum.ts +13 -1
  191. package/src/commands/style.ts +415 -0
  192. package/src/commands/time.ts +6 -3
  193. package/src/commands/tuning.ts +3 -3
  194. package/src/commands/vocal-pitch.ts +616 -0
  195. package/src/commands/vocal.ts +147 -0
  196. package/src/commands/vocoder.ts +627 -0
  197. package/src/commands/wind.ts +2 -2
  198. package/src/fs/durable.ts +50 -0
  199. package/src/lang/glossary.ts +493 -0
  200. package/src/launch-args.ts +163 -0
  201. package/src/main.ts +1397 -253
  202. package/src/media/cli.ts +20 -3
  203. package/src/media/import.ts +3 -1
  204. package/src/project/check.ts +21 -2
  205. package/src/project/clip-pins.ts +72 -0
  206. package/src/project/init.ts +23 -8
  207. package/src/project/sync.ts +418 -86
  208. package/src/render.ts +20 -1
  209. package/src/session/daemon.ts +3 -0
  210. package/src/session/meta.ts +14 -0
  211. package/src/session/origin.ts +154 -0
  212. package/src/session/port.ts +22 -4
  213. package/src/session/presence.ts +34 -4
  214. package/src/session/protocol.ts +5 -1
  215. package/src/session/rebase.ts +18 -5
  216. package/src/session/receipt.ts +258 -0
  217. package/src/session/store.ts +65 -43
  218. package/src/tui/arrange-menu.ts +65 -31
  219. package/src/tui/audition.ts +1 -1
  220. package/src/tui/euclid.ts +18 -13
  221. package/src/tui/fader.ts +228 -41
  222. package/src/tui/granular-menu.ts +2 -4
  223. package/src/tui/menu-clips.ts +297 -0
  224. package/src/tui/menu-time.ts +20 -13
  225. package/src/tui/menu-voice.ts +405 -0
  226. package/src/tui/menu.ts +759 -177
  227. package/src/tui/modal-menu.ts +6 -6
  228. package/src/tui/performance-menu.ts +5 -2
  229. package/src/tui/play-chords.ts +4 -2
  230. package/src/tui/play-mode.ts +15 -1
  231. package/src/tui/play-session.ts +47 -7
  232. package/src/tui/sing-menu.ts +278 -0
  233. package/src/tui/style-menu.ts +104 -0
  234. package/src/tui/vocoder-menu.ts +244 -0
  235. package/src/tui/wind-menu.ts +3 -3
  236. package/src/version.ts +8 -0
  237. package/src/web/fetch.ts +115 -29
  238. package/tui/activity.ts +180 -9
  239. package/tui/app.ts +274 -40
  240. package/tui/clip-row.ts +132 -0
  241. package/tui/delight.ts +144 -0
  242. package/tui/drawer.ts +70 -22
  243. package/tui/frame-gate.ts +76 -0
  244. package/tui/grammar.ts +112 -63
  245. package/tui/guide.ts +42 -4
  246. package/tui/highway.ts +269 -25
  247. package/tui/hints.ts +192 -0
  248. package/tui/input.ts +60 -9
  249. package/tui/keys.ts +1 -1
  250. package/tui/play-strip.ts +68 -14
  251. package/tui/prompt.ts +1 -1
  252. package/tui/screen.ts +144 -14
  253. package/tui/theme.ts +27 -0
package/core/keys.ts CHANGED
@@ -57,15 +57,15 @@ export function isElectricFamily(
57
57
 
58
58
  /**
59
59
  * Why una corda would not sound on this track (0.6.1), or undefined when it
60
- * does: only the modelled pianos with a `keys` object read `softPedal`.
60
+ * does: only the modeled pianos with a `keys` object read `softPedal`.
61
61
  */
62
62
  export function softPedalNote(
63
63
  track: Readonly<{ id: string; instrument: string; keys?: unknown }>,
64
64
  ): string | undefined {
65
65
  if (!isPianoFamily(track.instrument))
66
- return `${track.id} is ${track.instrument}; una corda acts on the modelled pianos (${PIANO_FAMILIES.join(" ")})`;
66
+ return `${track.id} is ${track.instrument}; una corda acts on the modeled pianos (${PIANO_FAMILIES.join(" ")})`;
67
67
  if (track.keys === undefined)
68
- return `${track.id} has no modelled piano yet; keys preset ${track.instrument} first`;
68
+ return `${track.id} has no modeled piano yet; keys preset ${track.instrument} first`;
69
69
  return undefined;
70
70
  }
71
71
 
package/core/loop.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  type Track,
11
11
  } from "./score.ts";
12
12
  import type { SongMaster } from "./master.ts";
13
+ import type { SongStyle } from "./style-provenance.ts";
13
14
  import type { Tuning } from "./tuning.ts";
14
15
 
15
16
  export const LOOP_FORMAT = "track.loop/v1" as const;
@@ -30,6 +31,8 @@ export type TrackLoopV1 = Readonly<{
30
31
  notes: readonly Note[];
31
32
  /** Song master (dawg 0.5); absent means none. */
32
33
  master?: SongMaster;
34
+ /** Which style and seed made the song (quality-08); absent means none. */
35
+ style?: SongStyle;
33
36
  /** Song sections (0.5); omitted when the song has none. */
34
37
  sections?: readonly Section[];
35
38
  /** Song form (0.5); omitted when empty. */
@@ -53,11 +56,13 @@ export function encodeLoopDocument(score: TrackScore): TrackLoopV1 {
53
56
  tracks: score.tracks,
54
57
  notes: score.notes,
55
58
  ...(score.master ? { master: score.master } : {}),
59
+ ...(score.style ? { style: score.style } : {}),
56
60
  ...(score.sections.length > 0 ? { sections: score.sections } : {}),
57
61
  ...(score.form.length > 0 ? { form: score.form } : {}),
58
62
  ...(score.loopSection === undefined
59
63
  ? {}
60
64
  : { loopSection: score.loopSection }),
65
+ ...(score.calibration ? { calibration: score.calibration } : {}),
61
66
  });
62
67
  }
63
68
 
package/core/lyrics.ts ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * The lyric grammar and syllable assignment (0.7) shared by `/lyrics`,
3
+ * `set_lyrics` and the SDK's `lyrics()`. No imports: the SDK carries a copy
4
+ * between marker lines (`bun core/sdk/sync-lyrics.ts`).
5
+ */
6
+
7
+ // ---- lyrics (copied into core/sdk/v1.ts)
8
+
9
+ /** Longest lyric on one note (SCORE_LIMITS.maxLyricLength). */
10
+ export const LYRIC_LIMIT = 32;
11
+
12
+ /** One lyric token: a syllable, a held note (`_`) or a skipped note (`~`). */
13
+ export type LyricToken = Readonly<{
14
+ syl: string;
15
+ /** Index of the word the token belongs to. */
16
+ word: number;
17
+ /** First syllable of its word. */
18
+ first: boolean;
19
+ kind: "syl" | "hold" | "rest";
20
+ }>;
21
+
22
+ /**
23
+ * The lyric grammar: words split by spaces, syllables by `-`, `_` holds the
24
+ * previous syllable over the next note (melisma), `~` skips a note.
25
+ * "sun-lit morn-ing _ glow" gives sun lit morn ing _ glow.
26
+ */
27
+ export function parseLyric(text: string): LyricToken[] {
28
+ const out: LyricToken[] = [];
29
+ let word = -1;
30
+ for (const raw of text.trim().split(/\s+/u)) {
31
+ if (raw === "") continue;
32
+ if (raw === "_") out.push({ syl: "_", word, first: false, kind: "hold" });
33
+ else if (raw === "~")
34
+ out.push({ syl: "~", word, first: false, kind: "rest" });
35
+ else {
36
+ word += 1;
37
+ raw
38
+ .split("-")
39
+ .filter(Boolean)
40
+ .forEach((syl, index) =>
41
+ out.push({ syl, word, first: index === 0, kind: "syl" }),
42
+ );
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ const VOWEL = /[aeiouàáâäèéêëìíîïòóôöùúûü]/u;
49
+ /** Consonant pairs that sound as one consonant and are never split. */
50
+ const SYL_DIGRAPHS = new Set(["th", "sh", "ch", "ph", "wh", "ng", "ck", "gh"]);
51
+ /** Digraphs that end a syllable (no English word starts with them). */
52
+ const SYL_CODA_ONLY = new Set(["ng", "ck", "gh", "x"]);
53
+ /** Consonant clusters a syllable may start with (maximal onset). */
54
+ const SYL_ONSETS = new Set(
55
+ (
56
+ "bl br cl cr dr fl fr gl gr pl pr sc sk sl sm sn sp st sw tr tw dw " +
57
+ "thr shr chr phr phl spl spr str scr squ skr"
58
+ ).split(" "),
59
+ );
60
+ /** Common words ending in a silent `e` that start compounds (some-thing). */
61
+ const SYL_SILENT_E_HEADS = (
62
+ "some home life time love fire side care where there here more one " +
63
+ "make lone like name game base wide grace face place space stone bone"
64
+ ).split(" ");
65
+ /** Suffixes kept whole after a silent `e` (love-ly, care-ful). */
66
+ const SYL_SUFFIXES = ["ly", "ful", "less", "ness", "ment"];
67
+ /** Unstressed endings that close a short vowel before them (nev-er). */
68
+ const SYL_CLOSING_ENDINGS = new Set([
69
+ "er",
70
+ "en",
71
+ "el",
72
+ "et",
73
+ "ed",
74
+ "es",
75
+ "est",
76
+ "ing",
77
+ ]);
78
+
79
+ /**
80
+ * Syllables of a word typed without hyphens: a guess for English, which a
81
+ * hyphen always overrides (`nev-er`). Each run of vowels (and `y` after a
82
+ * consonant) is one syllable; a final silent `e` does not count, but a
83
+ * consonant plus `le` is its own syllable (lit-tle, ta-ble). Consonant
84
+ * pairs that sound as one (th sh ch ph wh ng ck gh) never split. Between
85
+ * vowels a cluster gives the next syllable the longest onset English
86
+ * allows (mon-ster, chil-dren); one consonant goes with the next vowel
87
+ * (ba-by, to-night) unless the previous vowel is short before an
88
+ * unstressed ending (nev-er, sing-ing). "something" gives some thing,
89
+ * "forever" for ev er.
90
+ */
91
+ export function autoSyllabify(word: string): string[] {
92
+ const w = word.toLowerCase();
93
+ // Compounds and suffixes after a silent e: some-thing, love-ly.
94
+ if (w.length >= 6) {
95
+ for (const head of SYL_SILENT_E_HEADS)
96
+ if (w.startsWith(head) && VOWEL.test(w.slice(head.length)))
97
+ return [
98
+ word.slice(0, head.length),
99
+ ...autoSyllabify(word.slice(head.length)),
100
+ ];
101
+ for (const suffix of SYL_SUFFIXES) {
102
+ const stem = w.slice(0, -suffix.length);
103
+ if (
104
+ w.endsWith(suffix) &&
105
+ stem.length >= 3 &&
106
+ stem.endsWith("e") &&
107
+ !VOWEL.test(stem[stem.length - 2]!)
108
+ )
109
+ return [
110
+ ...autoSyllabify(word.slice(0, stem.length)),
111
+ word.slice(stem.length),
112
+ ];
113
+ }
114
+ }
115
+ // Letters into units: a vowel, a consonant, a digraph, or `qu`.
116
+ type Unit = { at: number; text: string; vowel: boolean };
117
+ const units: Unit[] = [];
118
+ for (let i = 0; i < w.length;) {
119
+ const pair = w.slice(i, i + 2);
120
+ if (pair === "qu" || SYL_DIGRAPHS.has(pair)) {
121
+ units.push({ at: i, text: pair, vowel: false });
122
+ i += 2;
123
+ continue;
124
+ }
125
+ const ch = w[i]!;
126
+ const prev = units.at(-1);
127
+ const vowel =
128
+ VOWEL.test(ch) || (ch === "y" && prev !== undefined && !prev.vowel);
129
+ units.push({ at: i, text: ch, vowel });
130
+ i += 1;
131
+ }
132
+ // Vowel groups as [first unit, last unit].
133
+ const groups: [number, number][] = [];
134
+ for (let u = 0; u < units.length;) {
135
+ if (units[u]!.vowel) {
136
+ let v = u;
137
+ while (v + 1 < units.length && units[v + 1]!.vowel) v += 1;
138
+ groups.push([u, v]);
139
+ u = v + 1;
140
+ } else u += 1;
141
+ }
142
+ const lastUnit = units.length - 1;
143
+ const finalLe =
144
+ w.endsWith("le") &&
145
+ units.length >= 3 &&
146
+ units[lastUnit - 1]!.text === "l" &&
147
+ !units[lastUnit - 2]!.vowel;
148
+ const last = groups.at(-1);
149
+ if (
150
+ groups.length > 1 &&
151
+ last &&
152
+ last[0] === lastUnit &&
153
+ last[1] === lastUnit &&
154
+ units[lastUnit]!.text === "e" &&
155
+ !units[lastUnit - 1]!.vowel &&
156
+ !finalLe
157
+ )
158
+ groups.pop();
159
+ if (groups.length <= 1) return [word];
160
+ const cuts: number[] = [];
161
+ for (let g = 1; g < groups.length; g += 1) {
162
+ const prev = groups[g - 1]!;
163
+ const next = groups[g]!;
164
+ const cluster = units.slice(prev[1] + 1, next[0]);
165
+ const n = cluster.length;
166
+ const isLast = g === groups.length - 1;
167
+ let onset: number; // units of the cluster that start the next syllable
168
+ if (isLast && finalLe && n >= 2)
169
+ onset = cluster[n - 2]!.text === "ck" ? 1 : 2;
170
+ else if (n === 1) {
171
+ const unit = cluster[0]!.text;
172
+ const prevText = units
173
+ .slice(prev[0], prev[1] + 1)
174
+ .map((u) => u.text)
175
+ .join("");
176
+ const ending = w.slice(units[next[0]]!.at);
177
+ const short = prevText.length === 1 && "eiou".includes(prevText);
178
+ const closes =
179
+ SYL_CODA_ONLY.has(unit) ||
180
+ (short && isLast && SYL_CLOSING_ENDINGS.has(ending)) ||
181
+ (unit === "r" && short && units[next[0]]!.text === "e");
182
+ onset = closes ? 0 : 1;
183
+ } else {
184
+ onset = 1;
185
+ for (let k = n - 1; k >= 2; k -= 1)
186
+ if (
187
+ SYL_ONSETS.has(
188
+ cluster
189
+ .slice(n - k)
190
+ .map((u) => u.text)
191
+ .join(""),
192
+ )
193
+ ) {
194
+ onset = k;
195
+ break;
196
+ }
197
+ if (SYL_CODA_ONLY.has(cluster[n - 1]!.text)) onset = 0;
198
+ }
199
+ const first = units[next[0] - onset]!;
200
+ cuts.push(onset === 0 ? units[next[0]]!.at : first.at);
201
+ }
202
+ const out: string[] = [];
203
+ let at = 0;
204
+ for (const cut of cuts) {
205
+ out.push(word.slice(at, cut));
206
+ at = cut;
207
+ }
208
+ out.push(word.slice(at));
209
+ return out.filter(Boolean);
210
+ }
211
+
212
+ /** What `assignLyrics` put on each note, and what did not fit. */
213
+ export type LyricAssignment = Readonly<{
214
+ /** Note id to its lyric (`_` holds); notes `~` skipped are absent. */
215
+ lyrics: ReadonlyMap<string, string>;
216
+ /** Syllables left over after the last note. */
217
+ dropped: readonly string[];
218
+ /** Words split automatically. */
219
+ split: readonly string[];
220
+ }>;
221
+
222
+ /**
223
+ * Lyrics onto `notes` in time order. Hyphens split syllables as typed;
224
+ * when the text has fewer syllables than there are notes, words typed
225
+ * whole are split by `autoSyllabify`, and any notes still left hold the
226
+ * last syllable (melisma) instead of failing. Syllables past the last note
227
+ * are reported in `dropped`. Notes sharing an onset take one token, on
228
+ * the top note, with `_` on the others. Each lyric is cut to the 32-character limit.
229
+ */
230
+ export function assignLyrics(
231
+ text: string,
232
+ notes: readonly Readonly<{ id: string; startTick: number; pitch: number }>[],
233
+ ): LyricAssignment {
234
+ const sorted = [...notes].sort(
235
+ (a, b) => a.startTick - b.startTick || b.pitch - a.pitch,
236
+ );
237
+ // Notes sharing an onset (a chord or a doubled note) take one token: the
238
+ // top note carries it and the rest hold.
239
+ const ordered: (typeof sorted)[number][] = [];
240
+ const under = new Map<string, string[]>();
241
+ for (const note of sorted) {
242
+ const top = ordered.at(-1);
243
+ if (top && top.startTick === note.startTick)
244
+ under.get(top.id)!.push(note.id);
245
+ else {
246
+ ordered.push(note);
247
+ under.set(note.id, []);
248
+ }
249
+ }
250
+ let tokens = parseLyric(text);
251
+ const split: string[] = [];
252
+ if (tokens.length < ordered.length) {
253
+ // Split every word typed whole; keep the split only if it still fits.
254
+ const out: LyricToken[] = [];
255
+ const words: string[] = [];
256
+ for (const token of tokens) {
257
+ const whole =
258
+ token.kind === "syl" &&
259
+ token.first &&
260
+ !tokens.some((t) => t.word === token.word && !t.first);
261
+ if (!whole) {
262
+ out.push(token);
263
+ continue;
264
+ }
265
+ const parts = autoSyllabify(token.syl);
266
+ if (parts.length > 1) words.push(token.syl);
267
+ parts.forEach((syl, index) =>
268
+ out.push({ syl, word: token.word, first: index === 0, kind: "syl" }),
269
+ );
270
+ }
271
+ if (out.length <= ordered.length) {
272
+ tokens = out;
273
+ split.push(...words);
274
+ }
275
+ }
276
+ const lyrics = new Map<string, string>();
277
+ const limit = LYRIC_LIMIT;
278
+ ordered.forEach((note, index) => {
279
+ const token = tokens[index];
280
+ if (!token) {
281
+ if (tokens.length > 0)
282
+ for (const id of [note.id, ...under.get(note.id)!]) lyrics.set(id, "_");
283
+ return;
284
+ }
285
+ if (token.kind === "rest") return;
286
+ lyrics.set(
287
+ note.id,
288
+ token.kind === "hold" ? "_" : token.syl.slice(0, limit),
289
+ );
290
+ for (const id of under.get(note.id)!) lyrics.set(id, "_");
291
+ });
292
+ const dropped = tokens
293
+ .slice(ordered.length)
294
+ .filter((token) => token.kind === "syl")
295
+ .map((token) => token.syl);
296
+ return { lyrics, dropped, split };
297
+ }
package/core/master.ts CHANGED
@@ -105,10 +105,10 @@ export const MASTER_SPECS: Readonly<Record<MasterUnit, MasterUnitSpec>> =
105
105
  low: db(-12, 12, 0, "low shelf gain"),
106
106
  lowfreq: hz(20, 1000, 100, "low shelf corner"),
107
107
  bell1: db(-12, 12, 0, "first bell gain"),
108
- bell1freq: hz(40, 16_000, 400, "first bell centre"),
108
+ bell1freq: hz(40, 16_000, 400, "first bell center"),
109
109
  bell1q: q("first bell width: higher is narrower"),
110
110
  bell2: db(-12, 12, 0, "second bell gain"),
111
- bell2freq: hz(200, 18_000, 3_000, "second bell centre"),
111
+ bell2freq: hz(200, 18_000, 3_000, "second bell center"),
112
112
  bell2q: q("second bell width: higher is narrower"),
113
113
  high: db(-12, 12, 0, "high shelf gain"),
114
114
  highfreq: hz(1_000, 20_000, 10_000, "high shelf corner"),
@@ -222,7 +222,7 @@ export const MASTER_SPECS: Readonly<Record<MasterUnit, MasterUnitSpec>> =
222
222
  default: 120,
223
223
  step: 10,
224
224
  unit: "Hz",
225
- doc: "below this the mix is mono (keeps bass centred); 0 is off",
225
+ doc: "below this the mix is mono (keeps bass centered); 0 is off",
226
226
  },
227
227
  },
228
228
  },
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { resolveInstrumentWord } from "./instruments.ts";
21
21
  import { WIND_INSTRUMENT, windPresetFor, type TrackWind } from "./winds.ts";
22
+ import { SING_INSTRUMENT, isSingPreset, type TrackSing } from "./sing.ts";
22
23
  import {
23
24
  FxValidationError,
24
25
  isRecord,
@@ -175,7 +176,7 @@ export const MODAL_PARAMS: Readonly<Record<string, ParamSpec>> = Object.freeze({
175
176
  hardness: num(0, 1, 0.4, 0.05, "mallet hardness: 0 yarn/felt, 1 brass", {
176
177
  automate: true,
177
178
  }),
178
- position: num(0, 1, 0.42, 0.05, "strike point: 0 end or edge, 0.5 centre", {
179
+ position: num(0, 1, 0.42, 0.05, "strike point: 0 end or edge, 0.5 center", {
179
180
  automate: true,
180
181
  }),
181
182
  ring: num(0.05, 30, 1.6, "log", "ring time (T60) at middle C", {
@@ -768,8 +769,20 @@ export function instrumentPatchForWord(word: string): {
768
769
  instrument: string;
769
770
  modal?: TrackModal;
770
771
  wind?: TrackWind;
772
+ sing?: TrackSing;
771
773
  } {
772
774
  const meaning = resolveInstrumentWord(word);
775
+ // f07-sing: `choir`, `aah`, `khoomei` sing a preset.
776
+ if (meaning?.field === "sing" && meaning.instrument === SING_INSTRUMENT) {
777
+ const preset =
778
+ meaning.preset && isSingPreset(meaning.preset)
779
+ ? meaning.preset
780
+ : undefined;
781
+ return {
782
+ instrument: SING_INSTRUMENT,
783
+ sing: Object.freeze(preset ? { preset } : {}) as TrackSing,
784
+ };
785
+ }
773
786
  // f061-gamelan-winds: `flute`, `sax`, `tuba` play a wind preset.
774
787
  if (meaning?.field === "wind" && meaning.instrument === WIND_INSTRUMENT) {
775
788
  const preset = meaning.preset ? windPresetFor(meaning.preset) : undefined;
@@ -793,7 +806,8 @@ export function isModalWord(word: string): boolean {
793
806
  const meaning = resolveInstrumentWord(word);
794
807
  return (
795
808
  meaning?.instrument === MODAL_INSTRUMENT ||
796
- (meaning?.field === "wind" && meaning.instrument === WIND_INSTRUMENT)
809
+ (meaning?.field === "wind" && meaning.instrument === WIND_INSTRUMENT) ||
810
+ (meaning?.field === "sing" && meaning.instrument === SING_INSTRUMENT)
797
811
  );
798
812
  }
799
813
 
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Cross-track references (0.7). A track field that names another track (the
3
+ * vocoder's `src`, autotune's `from`, 0.7.1 harmony's `guide`) registers
4
+ * here once, at module load, with the edges it reads and how it forgets a
5
+ * removed track. `removeTrack` then drops dangling references, the renderer
6
+ * orders stems with `routingOrder`, and stem digests include the notes a
7
+ * track follows through `notesDigestInputs`.
8
+ *
9
+ * Audio edges carry rendered sound (the target must render first, and a
10
+ * cycle is an error). Notes edges only read the other track's notes, so
11
+ * they never order rendering and never form a cycle.
12
+ */
13
+ import {
14
+ registerTrackRefDrop,
15
+ ScoreValidationError,
16
+ type Note,
17
+ type Track,
18
+ type TrackScore,
19
+ } from "./score.ts";
20
+ import { trackSlug } from "./slug.ts";
21
+
22
+ export type TrackRefKind = "audio" | "notes";
23
+
24
+ /** One reference from a track to another track. */
25
+ export type TrackRef = Readonly<{ trackId: string; kind: TrackRefKind }>;
26
+
27
+ export type TrackRefSpec = Readonly<{
28
+ /** The tracks `track` references through this field. */
29
+ refs(track: Track): readonly TrackRef[];
30
+ /** `track` without its references to `removedId`. */
31
+ drop(track: Track, removedId: string): Track;
32
+ }>;
33
+
34
+ const specs = new Map<string, TrackRefSpec>();
35
+
36
+ /** Registers a referencing field once; registering it again replaces it. */
37
+ export function registerTrackRefs(field: string, spec: TrackRefSpec): void {
38
+ specs.set(field, spec);
39
+ registerTrackRefDrop(field, spec.drop);
40
+ }
41
+
42
+ /** Every reference `track` holds, in field registration order. */
43
+ export function trackRefs(track: Track): readonly TrackRef[] {
44
+ const refs: TrackRef[] = [];
45
+ for (const spec of specs.values()) refs.push(...spec.refs(track));
46
+ return refs;
47
+ }
48
+
49
+ /**
50
+ * Finds a track by id, then by name slug (`Lead Vox` and `lead-vox` both
51
+ * match). Returns undefined when nothing matches.
52
+ */
53
+ export function resolveTrackRef(
54
+ score: TrackScore,
55
+ idOrSlug: string,
56
+ ): Track | undefined {
57
+ const byId = score.tracks.find((track) => track.id === idOrSlug);
58
+ if (byId) return byId;
59
+ const slug = trackSlug(idOrSlug);
60
+ return score.tracks.find((track) => trackSlug(track.name) === slug);
61
+ }
62
+
63
+ /**
64
+ * Track ids in render order: every audio source before the tracks that
65
+ * read it, otherwise score order. References to missing tracks are ignored.
66
+ * A cycle throws a ScoreValidationError that names it (`a -> b -> a`).
67
+ */
68
+ export function routingOrder(score: TrackScore): readonly string[] {
69
+ const ids = new Set(score.tracks.map((track) => track.id));
70
+ const sources = new Map<string, string[]>();
71
+ for (const track of score.tracks)
72
+ sources.set(
73
+ track.id,
74
+ trackRefs(track)
75
+ .filter((ref) => ref.kind === "audio" && ids.has(ref.trackId))
76
+ .map((ref) => ref.trackId),
77
+ );
78
+ const order: string[] = [];
79
+ const state = new Map<string, "visiting" | "done">();
80
+ const stack: string[] = [];
81
+ const visit = (id: string): void => {
82
+ const seen = state.get(id);
83
+ if (seen === "done") return;
84
+ if (seen === "visiting") {
85
+ const cycle = [...stack.slice(stack.indexOf(id)), id];
86
+ throw new ScoreValidationError(
87
+ `track routing has a cycle: ${cycle.join(" -> ")}`,
88
+ "routing-cycle",
89
+ );
90
+ }
91
+ state.set(id, "visiting");
92
+ stack.push(id);
93
+ for (const source of sources.get(id) ?? []) visit(source);
94
+ stack.pop();
95
+ state.set(id, "done");
96
+ order.push(id);
97
+ };
98
+ for (const track of score.tracks) visit(track.id);
99
+ return order;
100
+ }
101
+
102
+ /**
103
+ * The notes a track's render depends on through notes edges, by referenced
104
+ * track id (sorted, deduplicated), for its stem digest. Missing tracks and
105
+ * self references contribute nothing.
106
+ */
107
+ export function notesDigestInputs(
108
+ score: TrackScore,
109
+ trackId: string,
110
+ ): readonly Readonly<{ trackId: string; notes: readonly Note[] }>[] {
111
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
112
+ if (!track) return [];
113
+ const ids = new Set(score.tracks.map((candidate) => candidate.id));
114
+ const followed = [
115
+ ...new Set(
116
+ trackRefs(track)
117
+ .filter(
118
+ (ref) =>
119
+ ref.kind === "notes" &&
120
+ ref.trackId !== trackId &&
121
+ ids.has(ref.trackId),
122
+ )
123
+ .map((ref) => ref.trackId),
124
+ ),
125
+ ].sort();
126
+ return followed.map((id) => ({
127
+ trackId: id,
128
+ notes: score.notes.filter((note) => note.trackId === id),
129
+ }));
130
+ }
131
+
132
+ /**
133
+ * The vocoder's modulator (0.7): `vocoder.src` is an audio edge, so the
134
+ * modulator renders first and removing it drops the `src` (the carrier then
135
+ * plays dry until a new source is set).
136
+ */
137
+ registerTrackRefs("vocoder", {
138
+ refs: (track) =>
139
+ track.vocoder?.src !== undefined
140
+ ? [{ trackId: track.vocoder.src, kind: "audio" }]
141
+ : [],
142
+ drop: (track, removedId) => {
143
+ if (track.vocoder?.src !== removedId) return track;
144
+ const { src: _src, ...rest } = track.vocoder;
145
+ return { ...track, vocoder: rest };
146
+ },
147
+ });
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Registered reference fields (one block per lane).
151
+
152
+ // autotune: `Track.autotune.from` follows another track's notes.
153
+ registerTrackRefs("autotune.from", {
154
+ refs: (track) =>
155
+ track.autotune?.from !== undefined
156
+ ? [{ trackId: track.autotune.from, kind: "notes" }]
157
+ : [],
158
+ drop: (track, removedId) => {
159
+ if (track.autotune?.from !== removedId) return track;
160
+ // Without its melody the track follows its own notes; `to` and the
161
+ // preset stay as the user set them.
162
+ const { from: _from, ...autotune } = track.autotune;
163
+ return { ...track, autotune };
164
+ },
165
+ });