@hraness/dawg 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
@@ -0,0 +1,949 @@
1
+ /**
2
+ * The sing engine (0.7): a built-in singing voice for a `sing` track.
3
+ *
4
+ * - Source: an LF glottal pulse (`dsp/glottal.ts`, band-limited by f0) with
5
+ * per-period jitter and shimmer and aspiration noise gated to the open
6
+ * phase. `bright` and velocity set the voice quality (Rd): louder is more
7
+ * pressed and brighter, as in a real voice (Fant 1995).
8
+ * - Tract: a five-formant Klatt cascade (`dsp/formant.ts`) on the SATB
9
+ * tables, the vowel morphing in log frequency across each note, with
10
+ * F1/F2 tuning above the crossing for high voices and an optional
11
+ * singer's-formant "ring" near 3 kHz.
12
+ * - Lines: as winds, a lone note entering under a lone held note slurs on
13
+ * (no glottal phase reset, a short portamento) unless the track has its
14
+ * own `glide`; chords stay separate voices.
15
+ * - Ensemble: `voices` > 1 sings each line with seeded, slightly detuned,
16
+ * late and differently sized members, summed into two shared tracts per
17
+ * line (formant buckets at -0.3 and +0.3 st), panned apart.
18
+ * - Throat: with a `drone`, each phrase is one drone voice; the melody
19
+ * steers a sharp overtone filter onto the octave-folded harmonic of the
20
+ * drone nearest each note (khoomei, sygyt), and `sub` weakens every
21
+ * other pulse for a kargyraa subharmonic.
22
+ *
23
+ * Deterministic: seeds come from each note id and start, never Math.random.
24
+ * Causal: a window render is an exact prefix of the full render.
25
+ */
26
+ import { parseKey } from "../../../core/chords.ts";
27
+ import type { PerformedNote } from "../../../core/expression.ts";
28
+ import {
29
+ SCORE_LIMITS,
30
+ type AutomationPoint,
31
+ type Track,
32
+ } from "../../../core/score.ts";
33
+ import {
34
+ SING_LANE_PARAMS,
35
+ SING_VERSION,
36
+ autoPartVoice,
37
+ autoVoice,
38
+ parseVowel,
39
+ resolveSing,
40
+ singPresetOf,
41
+ singTailSeconds,
42
+ vowelOf,
43
+ type SingSettings,
44
+ } from "../../../core/sing.ts";
45
+ import { noteHz } from "../../../core/tuning.ts";
46
+ import {
47
+ Bandpass,
48
+ Cascade,
49
+ morphVowel,
50
+ tuneToPitch,
51
+ vowelAt,
52
+ type Formant,
53
+ type VoiceType,
54
+ } from "../dsp/formant.ts";
55
+ import { glottal, glottalSpectrum, openWeight } from "../dsp/glottal.ts";
56
+ import { seededRandom } from "../dsp/rng.ts";
57
+ import { interpolateAutomation } from "../effects/common.ts";
58
+ import type { EngineContext, InstrumentEngine } from "../instruments.ts";
59
+ import { applyModalKnee } from "../resonators.ts";
60
+ import { windLines } from "../winds/engine.ts";
61
+
62
+ /** Control-rate period in samples (tract, vibrato and lanes). */
63
+ export const SING_CONTROL = 32;
64
+ /** Lines sounding at once before the oldest is stolen. */
65
+ export const MAX_SING_LINES = 16;
66
+ /** Portamento between slurred notes, seconds (time constant). */
67
+ const SLUR_SECONDS = 0.03;
68
+ /**
69
+ * Vowel glide across a legato note change, seconds. A tract that jumped
70
+ * from one vowel's formants to the next in one control period rang a
71
+ * click about 10 dB over the voice (a la-li chorale part peaked at
72
+ * -0.2 dBFS at -19 LUFS); singers move the tract over tens of ms anyway.
73
+ */
74
+ const VOWEL_GLIDE_SECONDS = 0.04;
75
+ /** A throat phrase ends at a gap longer than this, seconds. */
76
+ const THROAT_GAP_SECONDS = 0.25;
77
+ /**
78
+ * Level the overtone filter adds at its centre, per unit of `overtone`.
79
+ * With the F2 resonance on the same harmonic this keeps the selected
80
+ * overtone 20 dB and more over its neighbours while the drone fundamental
81
+ * stays audible (about 20-25 dB under the whistle, as in recordings)
82
+ * instead of 40 dB under it.
83
+ */
84
+ const OVERTONE_GAIN = 10;
85
+ /** How much weaker every other pulse is at `sub` 1 (kargyraa period doubling). */
86
+ const SUB_DEPTH = 0.95;
87
+ /** A melody note this far outside the harmonic band still counts as in it. */
88
+ const BAND_SLACK_OCTAVES = 1 / 24;
89
+ /** Overtone glide, seconds (time constant). */
90
+ const OVERTONE_GLIDE_SECONDS = 0.04;
91
+ /** Steal fade, seconds. */
92
+ const STEAL_FADE_SECONDS = 0.08;
93
+ /** Rd push per articulation (accent and marcato press harder). */
94
+ const PUSH: Readonly<Record<string, number>> = Object.freeze({
95
+ accent: 0.3,
96
+ marcato: 0.5,
97
+ ghost: -0.6,
98
+ });
99
+
100
+ /** A standard normal draw (Box-Muller) from a uniform source. */
101
+ export function gauss(rand: () => number): number {
102
+ const u = Math.max(1e-12, rand());
103
+ return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rand());
104
+ }
105
+
106
+ const SQRT12 = Math.sqrt(12);
107
+ /** Harmonics the level normalization sums (the rest carry under 2%). */
108
+ const NORM_HARMONICS = 40;
109
+ /** Scratch for one filter's complex response (`responsePower`). */
110
+ const RESPONSE = new Float64Array(2);
111
+ /**
112
+ * Level normalization time constants. Gain falls fast (a formant landing
113
+ * on a harmonic is loud at once) and rises slowly: a narrow filter that
114
+ * glides off a harmonic keeps ringing at its old level for a while, so a
115
+ * fast rise would overshoot by 10 dB and more.
116
+ */
117
+ const LEVEL_FALL_SECONDS = 0.003;
118
+ const LEVEL_RISE_SECONDS = 0.12;
119
+ /** Gaussian line smear: offsets in standard deviations, and weights. */
120
+ const SMEAR_AT = [-2, -1.5, -1, -0.5, 0, 0.5, 1, 1.5, 2] as const;
121
+ const SMEAR = ((w) => w.map((x) => x / w.reduce((a, b) => a + b, 0)))(
122
+ SMEAR_AT.map((x) => Math.exp((-x * x) / 2)),
123
+ );
124
+ /** Control periods between level re-aims. */
125
+ const NORM_EVERY = 4;
126
+ let reference = 0;
127
+ /** Harmonic power of the reference voice (Rd 1.3) through a flat tract. */
128
+ function referencePower(): number {
129
+ if (reference === 0) {
130
+ const ref = glottalSpectrum(1.3, NORM_HARMONICS);
131
+ for (let h = 1; h <= NORM_HARMONICS; h += 1) reference += ref[h]!;
132
+ }
133
+ return reference;
134
+ }
135
+
136
+ /** One-pole low-passed unit-variance noise: slow pitch drift. */
137
+ class Drift {
138
+ private y = 0;
139
+ private readonly a: number;
140
+ private readonly g: number;
141
+ constructor(
142
+ private readonly rand: () => number,
143
+ cutoffHz: number,
144
+ controlRate: number,
145
+ ) {
146
+ this.a = Math.exp((-2 * Math.PI * cutoffHz) / controlRate);
147
+ this.g = Math.sqrt(1 - this.a * this.a);
148
+ }
149
+ next(): number {
150
+ this.y = this.a * this.y + this.g * gauss(this.rand);
151
+ return this.y;
152
+ }
153
+ }
154
+
155
+ type Source = Pick<SingSettings, "breath" | "jitter" | "shimmer" | "sub">;
156
+ type Shape = Pick<SingSettings, "ring" | "overtone">;
157
+
158
+ /** One glottis plus a vocal tract; `step` returns one sample. */
159
+ export class VoiceCore {
160
+ private phase: number;
161
+ private amp = 1;
162
+ private periodScale = 1;
163
+ private odd = false;
164
+ private hp = 0;
165
+ /** Smoothed loudness normalization; -1 until `aimLevel` first runs. */
166
+ private level = -1;
167
+ private levelTarget = 1;
168
+ private readonly riseRate: number;
169
+ private readonly fallRate: number;
170
+ readonly tract = new Cascade();
171
+ private readonly ringBand = new Bandpass();
172
+ private readonly ot1 = new Bandpass();
173
+ private readonly ot2 = new Bandpass();
174
+ /** aimLevel's inputs (pitch, source, every filter coefficient). */
175
+ private readonly aimKey = new Float64Array(6 + 5 * 3 + 3 * 4);
176
+ private readonly aimLast = new Float64Array(6 + 5 * 3 + 3 * 4);
177
+ constructor(
178
+ private readonly rand: () => number,
179
+ private readonly sampleRate: number,
180
+ ) {
181
+ this.phase = rand();
182
+ this.riseRate = 1 - Math.exp(-1 / (LEVEL_RISE_SECONDS * sampleRate));
183
+ this.fallRate = 1 - Math.exp(-1 / (LEVEL_FALL_SECONDS * sampleRate));
184
+ }
185
+ setTract(formants: readonly Formant[], scale: number, ringHz: number): void {
186
+ this.tract.set(formants, scale, this.sampleRate);
187
+ this.ringBand.set(ringHz * scale, 400, this.sampleRate);
188
+ }
189
+ setOvertone(hz: number, bw: number): void {
190
+ this.ot1.set(hz, bw, this.sampleRate);
191
+ this.ot2.set(hz, bw, this.sampleRate);
192
+ }
193
+ /** Glottal source plus open-phase breath, before the tract. */
194
+ source(f0: number, rd: number, s: Source): number {
195
+ let src = 0;
196
+ if (f0 > 0) {
197
+ const inc = (f0 * this.periodScale) / this.sampleRate;
198
+ this.phase += inc;
199
+ if (this.phase >= 1) {
200
+ this.phase -= Math.floor(this.phase);
201
+ // per-period perturbations (Klatt and Klatt 1990)
202
+ this.periodScale = 1 + s.jitter * 0.01 * gauss(this.rand);
203
+ this.amp = 1 + s.shimmer * 0.12 * gauss(this.rand);
204
+ this.odd = !this.odd;
205
+ }
206
+ // kargyraa: alternate pulses weaker (period doubling, f0/2 appears)
207
+ const alt = s.sub > 0 && this.odd ? 1 - SUB_DEPTH * s.sub : 1;
208
+ src = glottal(this.phase, rd, inc) * this.amp * alt;
209
+ }
210
+ // Unit-variance uniform noise: as white as a Gaussian for aspiration
211
+ // and a fifth of the cost (one draw, no log or trig per sample).
212
+ const n = (this.rand() - 0.5) * SQRT12;
213
+ const white = n - this.hp; // first difference: a bright aspiration
214
+ this.hp = n;
215
+ return (
216
+ src + white * s.breath * 0.35 * (f0 > 0 ? openWeight(this.phase, rd) : 1)
217
+ );
218
+ }
219
+ /**
220
+ * Re-aims the loudness normalization at the current filters: the power
221
+ * the tract, ring and overtone filters give the glottal source (`rd`)
222
+ * at `f0`, against the reference source (Rd 1.3) through a flat
223
+ * response. Narrow formants landing on or between harmonics swing
224
+ * that power by 10 dB and more, so without this a vowel, a note or a
225
+ * throat preset changes the level as much as velocity does.
226
+ */
227
+ aimLevel(
228
+ f0: number,
229
+ rd: number,
230
+ s: Shape & Pick<SingSettings, "sub" | "jitter">,
231
+ ): void {
232
+ // With `sub`, every other pulse is weaker by `a`: a period of 2/f0 whose
233
+ // lines at k f0/2 carry (1+a)/2 (even k) and (1-a)/2 (odd k).
234
+ const a = s.sub > 0 ? 1 - SUB_DEPTH * s.sub : 1;
235
+ const step = a < 1 ? 0.5 : 1;
236
+ const top = Math.min(
237
+ NORM_HARMONICS,
238
+ Math.floor((0.45 * this.sampleRate) / f0),
239
+ );
240
+ if (!(f0 > 0) || top < 1) return;
241
+ const spread = Math.max(0.006, s.jitter * 0.01);
242
+ // A held note re-aims with the same pitch, source and filters almost
243
+ // every time: the same inputs give the same target, so skip the sum.
244
+ const key = this.aimKey;
245
+ let at = 0;
246
+ key[at++] = f0;
247
+ key[at++] = rd;
248
+ key[at++] = a;
249
+ key[at++] = spread;
250
+ key[at++] = s.ring;
251
+ key[at++] = s.overtone;
252
+ for (const r of this.tract.res) at = r.coeffsInto(key, at);
253
+ at = this.ringBand.coeffsInto(key, at);
254
+ at = this.ot1.coeffsInto(key, at);
255
+ at = this.ot2.coeffsInto(key, at);
256
+ const last = this.aimLast;
257
+ let same = this.levelTarget > 0;
258
+ for (let i = 0; i < at && same; i += 1) same = key[i] === last[i];
259
+ if (same) return;
260
+ last.set(key);
261
+ const source = glottalSpectrum(rd, NORM_HARMONICS);
262
+ let power = 0;
263
+ for (let h = step; h <= top; h += step) {
264
+ const whole = Number.isInteger(h);
265
+ const g = whole
266
+ ? source[h]! * ((1 + a) / 2) ** 2
267
+ : (h < 1 ? source[1]! : (source[h - 0.5]! + source[h + 0.5]!) / 2) *
268
+ ((1 - a) / 2) ** 2;
269
+ const w = (2 * Math.PI * h * f0) / this.sampleRate;
270
+ // Jitter and drift smear each line over about +-spread; a narrow
271
+ // overtone filter sees that average, not the exact harmonic.
272
+ let r = 0;
273
+ for (let k = 0; k < SMEAR.length; k += 1)
274
+ r += SMEAR[k]! * this.responsePower(w * (1 + SMEAR_AT[k]! * spread), s);
275
+ power += g * r;
276
+ }
277
+ this.levelTarget = Math.min(
278
+ 8,
279
+ Math.max(0.001, Math.sqrt(referencePower() / power)),
280
+ );
281
+ if (this.level < 0) this.level = this.levelTarget;
282
+ }
283
+ /** |H|^2 of the tract, ring and overtone filters at `w` rad/sample. */
284
+ private responsePower(w: number, s: Shape): number {
285
+ const cw = Math.cos(w);
286
+ const sw = Math.sin(w);
287
+ const c2w = 2 * cw * cw - 1;
288
+ const s2w = 2 * sw * cw;
289
+ const h = RESPONSE;
290
+ let re = 1;
291
+ let im = 0;
292
+ const res = this.tract.res;
293
+ for (let k = 0; k < res.length; k += 1) {
294
+ res[k]!.responseInto(cw, sw, c2w, s2w, h);
295
+ const hr = h[0]!;
296
+ const hi = h[1]!;
297
+ const nr = re * hr - im * hi;
298
+ im = re * hi + im * hr;
299
+ re = nr;
300
+ }
301
+ let addRe = 1;
302
+ let addIm = 0;
303
+ if (s.ring > 0) {
304
+ this.ringBand.responseInto(cw, sw, c2w, s2w, h);
305
+ addRe += h[0]! * s.ring * 3;
306
+ addIm += h[1]! * s.ring * 3;
307
+ }
308
+ if (s.overtone > 0) {
309
+ this.ot1.responseInto(cw, sw, c2w, s2w, h);
310
+ const ar = h[0]!;
311
+ const ai = h[1]!;
312
+ this.ot2.responseInto(cw, sw, c2w, s2w, h);
313
+ const br = h[0]!;
314
+ const bi = h[1]!;
315
+ addRe += (ar * br - ai * bi) * s.overtone * OVERTONE_GAIN;
316
+ addIm += (ar * bi + ai * br) * s.overtone * OVERTONE_GAIN;
317
+ }
318
+ const yr = re * addRe - im * addIm;
319
+ const yi = re * addIm + im * addRe;
320
+ return yr * yr + yi * yi;
321
+ }
322
+ /** Tract, singer's formant and overtone filter (shared by a bucket). */
323
+ shape(x: number, s: Shape): number {
324
+ let y = this.tract.process(x);
325
+ if (s.ring > 0) y += this.ringBand.process(y) * s.ring * 3;
326
+ if (s.overtone > 0)
327
+ y += this.ot2.process(this.ot1.process(y)) * s.overtone * OVERTONE_GAIN;
328
+ if (this.level < 0) return y;
329
+ this.level +=
330
+ (this.levelTarget - this.level) *
331
+ (this.levelTarget > this.level ? this.riseRate : this.fallRate);
332
+ return y * this.level;
333
+ }
334
+ step(f0: number, rd: number, s: Source & Shape): number {
335
+ return this.shape(this.source(f0, rd, s), s);
336
+ }
337
+ }
338
+
339
+ /** Raised-cosine attack, smoothstep release at `t` s of a `len` s note. */
340
+ function envelope(
341
+ t: number,
342
+ len: number,
343
+ attack: number,
344
+ release: number,
345
+ ): number {
346
+ if (t < 0) return 0;
347
+ const a = t < attack ? 0.5 - 0.5 * Math.cos((Math.PI * t) / attack) : 1;
348
+ const r = t > len ? Math.max(0, 1 - (t - len) / release) : 1;
349
+ return a * (r * r * (3 - 2 * r));
350
+ }
351
+
352
+ /**
353
+ * The harmonic of `droneHz` a melody note selects: the note is
354
+ * octave-folded into the [lo, hi] harmonic band (the octave inside the band
355
+ * nearest the written pitch, else the nearest band edge), then rounded, so
356
+ * any melody in any register maps by pitch class.
357
+ */
358
+ export function overtoneFor(
359
+ noteHz: number,
360
+ droneHz: number,
361
+ lo: number,
362
+ hi: number,
363
+ ): number {
364
+ const bandLo = Math.log2(lo * droneHz);
365
+ const bandHi = Math.log2(hi * droneHz);
366
+ const x = Math.log2(noteHz);
367
+ let best = x;
368
+ let bestDist = Infinity;
369
+ let bestShift = Infinity;
370
+ for (let o = -10; o <= 10; o += 1) {
371
+ const y = x + o;
372
+ // Half a semitone of slack: A5 over a D3 drone in 12-EDO sits 2 cents
373
+ // under harmonic 6 and must sing it, not jump an octave to 12.
374
+ const dist =
375
+ y < bandLo - BAND_SLACK_OCTAVES
376
+ ? bandLo - y
377
+ : y > bandHi + BAND_SLACK_OCTAVES
378
+ ? y - bandHi
379
+ : 0;
380
+ if (
381
+ dist < bestDist - 1e-9 ||
382
+ (Math.abs(dist - bestDist) < 1e-9 && Math.abs(o) < bestShift)
383
+ ) {
384
+ best = y;
385
+ bestDist = dist;
386
+ bestShift = Math.abs(o);
387
+ }
388
+ }
389
+ return Math.min(hi, Math.max(lo, Math.round(2 ** best / droneHz)));
390
+ }
391
+
392
+ /** The vowel a note sings: its own, then its lyric's, then the track's. */
393
+ export function noteVowel(
394
+ note: Readonly<{ vowel?: string; lyric?: string }>,
395
+ fallback: string,
396
+ ): string {
397
+ const spec = note.vowel ?? vowelOf(note.lyric) ?? fallback;
398
+ try {
399
+ const [a, b] = parseVowel(spec);
400
+ return b ? `${a}>${b}` : a;
401
+ } catch {
402
+ return fallback;
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Melisma: a note whose lyric is `_` (the lyric grammar's hold) keeps
408
+ * singing the vowel of the note before it, in time order.
409
+ */
410
+ export function heldVowels(
411
+ notes: readonly PerformedNote[],
412
+ fallback: string,
413
+ ): Map<PerformedNote, string> {
414
+ const held = new Map<PerformedNote, string>();
415
+ if (!notes.some((note) => note.lyric === "_")) return held;
416
+ const ordered = [...notes].sort(
417
+ (a, b) => a.startTick - b.startTick || b.pitch - a.pitch,
418
+ );
419
+ let last = fallback;
420
+ for (const note of ordered) {
421
+ if (note.lyric === "_" && note.vowel === undefined) held.set(note, last);
422
+ else last = noteVowel(note, fallback);
423
+ }
424
+ return held;
425
+ }
426
+
427
+ function ringHzOf(voice: VoiceType): number {
428
+ return voice === "soprano" || voice === "alto" ? 3100 : 2800;
429
+ }
430
+
431
+ type Lanes = Map<string, readonly AutomationPoint[]>;
432
+
433
+ function lanesOf(track: Track): Lanes {
434
+ const lanes: Lanes = new Map();
435
+ const all = track.fxAutomation as
436
+ Readonly<Record<string, readonly AutomationPoint[]>> | undefined;
437
+ if (!all) return lanes;
438
+ for (const { param } of SING_LANE_PARAMS) {
439
+ const points = all[`sing-${param}`];
440
+ if (points && points.length > 0) lanes.set(param, points);
441
+ }
442
+ return lanes;
443
+ }
444
+
445
+ /** Mutable per-tick settings: the resolved values with lanes applied. */
446
+ type Live = {
447
+ -readonly [K in keyof SingSettings]: SingSettings[K];
448
+ };
449
+
450
+ type Segment = { at: number; end: number; hz: number; vowel: string };
451
+
452
+ type Planned = {
453
+ start: number;
454
+ length: number;
455
+ head: PerformedNote;
456
+ segments: Segment[];
457
+ steal: number;
458
+ };
459
+
460
+ /** Everything a render shares across lines. */
461
+ type Setup = {
462
+ s: SingSettings;
463
+ lanes: Lanes;
464
+ sampleRate: number;
465
+ samples: number;
466
+ tickAt: (index: number) => number;
467
+ gainAt: (index: number) => number;
468
+ seedTick: number;
469
+ /** `voice: "auto"`: the part's voice type, from its median pitch. */
470
+ partVoice?: VoiceType;
471
+ };
472
+
473
+ /** Applies the automation lanes at sample `index` onto `live`. */
474
+ function applyLanes(live: Live, setup: Setup, index: number): void {
475
+ if (setup.lanes.size === 0) return;
476
+ const tick = setup.tickAt(index);
477
+ for (const [param, points] of setup.lanes)
478
+ (live as Record<string, unknown>)[param] = interpolateAutomation(
479
+ points,
480
+ tick,
481
+ setup.s[param as keyof SingSettings] as number,
482
+ );
483
+ }
484
+
485
+ /** The ensemble member count a line may use under the per-track cap. */
486
+ export function singMembers(voices: number): number {
487
+ const v = Math.max(1, Math.round(voices));
488
+ return Math.max(1, Math.min(v, SCORE_LIMITS.singVoicesPerTrack));
489
+ }
490
+
491
+ /** Lines that may sound at once for `members` singers each. */
492
+ function lineCap(members: number): number {
493
+ return Math.max(
494
+ 1,
495
+ Math.min(
496
+ MAX_SING_LINES,
497
+ Math.floor(SCORE_LIMITS.singVoicesPerTrack / members),
498
+ ),
499
+ );
500
+ }
501
+
502
+ /**
503
+ * Renders one line (a note or a slurred chain) with `members` singers,
504
+ * into `left` (and `right` when stereo).
505
+ */
506
+ function renderLine(
507
+ line: Planned,
508
+ setup: Setup,
509
+ left: Float64Array,
510
+ right: Float64Array | undefined,
511
+ ): void {
512
+ const { s, sampleRate, samples } = setup;
513
+ const head = line.head;
514
+ const seed = `${head.id}:${head.startTick + setup.seedTick}`;
515
+ const voice: VoiceType =
516
+ s.voice === "auto"
517
+ ? (setup.partVoice ?? autoVoice(head.pitch))
518
+ : (s.voice as VoiceType);
519
+ const ringHz = ringHzOf(voice);
520
+ const lengthSec = line.length / sampleRate;
521
+ const members = singMembers(s.voices);
522
+ const ens = members > 1;
523
+ const buckets = ens ? 2 : 0;
524
+ const level = s.gain / Math.sqrt(members);
525
+ const baseRd = 2.5 - 2 * s.bright;
526
+ const push = PUSH[head.articulation ?? ""] ?? 0;
527
+ const bend = head.performance?.cents;
528
+ const ownVibrato = head.performance?.replaceVibrato === true;
529
+ const damp = head.performance?.damp;
530
+ const releaseFrames = Math.ceil((s.release + 0.03) * sampleRate);
531
+ const lineEnd = Math.min(
532
+ samples,
533
+ line.start + line.length + releaseFrames + Math.ceil(0.03 * sampleRate),
534
+ line.steal + Math.round(STEAL_FADE_SECONDS * sampleRate),
535
+ );
536
+ if (lineEnd <= line.start) return;
537
+ const frames = lineEnd - line.start;
538
+ const stealFade = Math.max(1, Math.round(STEAL_FADE_SECONDS * sampleRate));
539
+ const slur = Math.exp(-SING_CONTROL / (SLUR_SECONDS * sampleRate));
540
+ const segmentIndex = (offset: number): number => {
541
+ let k = 0;
542
+ while (k + 1 < line.segments.length && line.segments[k + 1]!.at <= offset)
543
+ k += 1;
544
+ return k;
545
+ };
546
+ const segmentAt = (offset: number): Segment =>
547
+ line.segments[segmentIndex(offset)]!;
548
+ const glideFrames = Math.max(1, VOWEL_GLIDE_SECONDS * sampleRate);
549
+ const vowelTable = (
550
+ offset: number,
551
+ live: Live,
552
+ scale: number,
553
+ hz: number,
554
+ ) => {
555
+ const k = segmentIndex(offset);
556
+ const seg = line.segments[k]!;
557
+ const len = Math.max(1, seg.end - seg.at);
558
+ const t = (Math.min(offset, seg.end) - seg.at) / len;
559
+ let table = vowelAt(voice, seg.vowel, t * live.morph);
560
+ const into = (offset - seg.at) / glideFrames;
561
+ const previous = k > 0 ? line.segments[k - 1]! : undefined;
562
+ if (previous && into < 1 && previous.vowel !== seg.vowel)
563
+ table = morphVowel(
564
+ vowelAt(voice, previous.vowel, live.morph),
565
+ table,
566
+ into,
567
+ );
568
+ return tuneToPitch(table, hz, scale);
569
+ };
570
+
571
+ // Shared tracts: one per bucket, fed by the sum of its members' sources.
572
+ const shared: {
573
+ core: VoiceCore;
574
+ st: number;
575
+ buf: Float64Array;
576
+ pan: number;
577
+ }[] = [];
578
+ const panRandom = seededRandom(`${seed}:pan`);
579
+ const width = (0.35 * (members - 1)) / 7;
580
+ for (let b = 0; b < buckets; b += 1) {
581
+ const st = -0.3 + (0.6 * b) / (buckets - 1);
582
+ shared.push({
583
+ core: new VoiceCore(seededRandom(`${seed}:bucket:${b}`), sampleRate),
584
+ st,
585
+ buf: new Float64Array(frames),
586
+ pan: (b === 0 ? -1 : 1) * width * (0.6 + 0.4 * panRandom()),
587
+ });
588
+ }
589
+ const live: Live = { ...s };
590
+ for (let m = 0; m < members; m += 1) {
591
+ const rand = seededRandom(m === 0 ? seed : `${seed}:${m}`);
592
+ const core = new VoiceCore(rand, sampleRate);
593
+ const detune = ens ? gauss(rand) * s.spread * 0.5 : 0;
594
+ const late = ens ? Math.round(rand() * 0.03 * sampleRate) : 0;
595
+ const vibRate = s.vib * (ens ? 1 + 0.08 * gauss(rand) : 1);
596
+ const vibPhase = ens ? rand() * 2 * Math.PI : 0;
597
+ const memberSt = ens ? gauss(rand) * 0.4 : 0;
598
+ const drift = new Drift(rand, 0.8, sampleRate / SING_CONTROL);
599
+ // Slow drift (0.8 Hz) is part of a solo voice's unsteadiness, so it
600
+ // scales with jitter (3 cents at the default 0.3; jitter 0 is a still
601
+ // voice that holds its pitch); a choir always scatters by 6.
602
+ const driftCents = ens ? 6 : 3 * Math.min(1, s.jitter / 0.3);
603
+ const bucket = buckets > 0 ? shared[m % buckets] : undefined;
604
+ let ratio = 1;
605
+ let hz = line.segments[0]!.hz;
606
+ let rd = baseRd;
607
+ for (let j = late; j < frames; j += 1) {
608
+ const offset = j - late;
609
+ const index = line.start + j;
610
+ if (offset % SING_CONTROL === 0) {
611
+ applyLanes(live, setup, index);
612
+ const t = offset / sampleRate;
613
+ const seg = segmentAt(offset);
614
+ hz = offset === 0 ? seg.hz : seg.hz + (hz - seg.hz) * slur;
615
+ const scale = 2 ** ((live.formant + memberSt) / 12);
616
+ if (!bucket)
617
+ core.setTract(vowelTable(offset, live, scale, hz), scale, ringHz);
618
+ // A note's own vibrato (performance.cents) replaces the voice's.
619
+ const vibDepth = ownVibrato
620
+ ? 0
621
+ : live.vibmod *
622
+ 100 *
623
+ Math.min(1, Math.max(0, (t - s.vibdelay) / 0.3));
624
+ const cents =
625
+ detune +
626
+ vibDepth * Math.sin(2 * Math.PI * vibRate * t + vibPhase) +
627
+ drift.next() * driftCents +
628
+ (bend ? bend(t) : 0);
629
+ ratio = 2 ** (cents / 1200);
630
+ rd = Math.min(
631
+ 2.7,
632
+ Math.max(
633
+ 0.3,
634
+ 2.5 - 2 * live.bright - 1.2 * (head.velocity - 0.6) - push,
635
+ ),
636
+ );
637
+ if (!bucket && offset % (SING_CONTROL * NORM_EVERY) === 0)
638
+ core.aimLevel(hz, rd, live);
639
+ }
640
+ let g =
641
+ level *
642
+ head.velocity *
643
+ envelope(offset / sampleRate, lengthSec, s.attack, s.release);
644
+ // Half pedal: the level fades from damp.from with time constant tau.
645
+ if (damp && offset / sampleRate > damp.from)
646
+ g *= Math.exp(-(offset / sampleRate - damp.from) / damp.tau);
647
+ if (index >= line.steal)
648
+ g *=
649
+ index - line.steal >= stealFade
650
+ ? 0
651
+ : 0.5 +
652
+ 0.5 * Math.cos((Math.PI * (index - line.steal)) / stealFade);
653
+ if (bucket)
654
+ bucket.buf[j] = bucket.buf[j]! + core.source(hz * ratio, rd, live) * g;
655
+ else {
656
+ const y = core.step(hz * ratio, rd, live) * g * setup.gainAt(index);
657
+ left[index] = left[index]! + y;
658
+ if (right) right[index] = right[index]! + y;
659
+ }
660
+ }
661
+ }
662
+ for (const b of shared)
663
+ shapeBucket(
664
+ b,
665
+ line,
666
+ setup,
667
+ live,
668
+ frames,
669
+ slur,
670
+ push,
671
+ ringHz,
672
+ segmentAt,
673
+ vowelTable,
674
+ left,
675
+ right,
676
+ );
677
+ }
678
+
679
+ /**
680
+ * One shared choir tract over its bucket's summed sources, mixed into the
681
+ * output. A function of its own so its per-sample loop is optimised even
682
+ * though `renderLine` runs only once per line.
683
+ */
684
+ function shapeBucket(
685
+ b: { core: VoiceCore; st: number; buf: Float64Array; pan: number },
686
+ line: Planned,
687
+ setup: Setup,
688
+ live: Live,
689
+ frames: number,
690
+ slur: number,
691
+ push: number,
692
+ ringHz: number,
693
+ segmentAt: (offset: number) => Segment,
694
+ vowelTable: (
695
+ offset: number,
696
+ live: Live,
697
+ scale: number,
698
+ hz: number,
699
+ ) => readonly Formant[],
700
+ left: Float64Array,
701
+ right: Float64Array | undefined,
702
+ ): void {
703
+ const head = line.head;
704
+ const pl = Math.cos(((b.pan + 1) * Math.PI) / 4) * Math.SQRT2;
705
+ const pr = Math.sin(((b.pan + 1) * Math.PI) / 4) * Math.SQRT2;
706
+ let hz = line.segments[0]!.hz;
707
+ for (let j = 0; j < frames; j += 1) {
708
+ const index = line.start + j;
709
+ if (j % SING_CONTROL === 0) {
710
+ applyLanes(live, setup, index);
711
+ const seg = segmentAt(j);
712
+ hz = j === 0 ? seg.hz : seg.hz + (hz - seg.hz) * slur;
713
+ const scale = 2 ** ((live.formant + b.st) / 12);
714
+ b.core.setTract(vowelTable(j, live, scale, hz), scale, ringHz);
715
+ if (j % (SING_CONTROL * NORM_EVERY) === 0) {
716
+ const rd = Math.min(
717
+ 2.7,
718
+ Math.max(
719
+ 0.3,
720
+ 2.5 - 2 * live.bright - 1.2 * (head.velocity - 0.6) - push,
721
+ ),
722
+ );
723
+ b.core.aimLevel(hz, rd, live);
724
+ }
725
+ }
726
+ const y = b.core.shape(b.buf[j]!, live) * setup.gainAt(index);
727
+ if (right) {
728
+ left[index] = left[index]! + y * pl;
729
+ right[index] = right[index]! + y * pr;
730
+ } else left[index] = left[index]! + y;
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Throat singing: each phrase (notes closer than a quarter second) is one
736
+ * drone voice from its first note to its last; each note steers the
737
+ * overtone filter (and F2) to its octave-folded harmonic of the drone.
738
+ */
739
+ function renderThroat(
740
+ lines: readonly Planned[],
741
+ setup: Setup,
742
+ context: EngineContext,
743
+ left: Float64Array,
744
+ ): void {
745
+ const { s, sampleRate, samples } = setup;
746
+ if (lines.length === 0 || s.drone === undefined) return;
747
+ const droneHz = noteHz(s.drone, undefined, context.tuning);
748
+ const voice: VoiceType =
749
+ s.voice === "auto" ? autoVoice(s.drone) : (s.voice as VoiceType);
750
+ const ringHz = ringHzOf(voice);
751
+ const [lo, hi] = s.harmonics;
752
+ const gap = Math.round(THROAT_GAP_SECONDS * sampleRate);
753
+ const sorted = [...lines].sort((a, b) => a.start - b.start);
754
+ const phrases: Planned[][] = [];
755
+ let phraseEnd = -Infinity;
756
+ for (const line of sorted) {
757
+ if (line.start > phraseEnd + gap) phrases.push([]);
758
+ phrases[phrases.length - 1]!.push(line);
759
+ phraseEnd = Math.max(phraseEnd, line.start + line.length);
760
+ }
761
+ const glide = Math.exp(-SING_CONTROL / (OVERTONE_GLIDE_SECONDS * sampleRate));
762
+ for (const phrase of phrases) {
763
+ const head = phrase[0]!.head;
764
+ const seed = `${head.id}:${head.startTick + setup.seedTick}:throat`;
765
+ const rand = seededRandom(seed);
766
+ const core = new VoiceCore(rand, sampleRate);
767
+ const drift = new Drift(rand, 0.5, sampleRate / SING_CONTROL);
768
+ const start = phrase[0]!.start;
769
+ const last = Math.max(...phrase.map((l) => l.start + l.length));
770
+ const lengthSec = (last - start) / sampleRate;
771
+ const end = Math.min(
772
+ samples,
773
+ last + Math.ceil((s.release + 0.03) * sampleRate),
774
+ );
775
+ const live: Live = { ...s };
776
+ const velocity = Math.max(...phrase.map((l) => l.head.velocity));
777
+ let target = droneHz * lo;
778
+ let current = target;
779
+ let ratio = 1;
780
+ let rd = 2.5 - 2 * s.bright;
781
+ let vowel = s.vowel;
782
+ let k = 0;
783
+ for (let i = start; i < end; i += 1) {
784
+ if ((i - start) % SING_CONTROL === 0) {
785
+ applyLanes(live, setup, i);
786
+ while (k + 1 < phrase.length && phrase[k + 1]!.start <= i) k += 1;
787
+ const line = phrase[k]!;
788
+ if (i < line.start + line.length) {
789
+ const seg =
790
+ [...line.segments]
791
+ .reverse()
792
+ .find((sg) => sg.at <= i - line.start) ?? line.segments[0]!;
793
+ target = overtoneFor(seg.hz, droneHz, lo, hi) * droneHz;
794
+ vowel = seg.vowel;
795
+ }
796
+ current = target + (current - target) * glide;
797
+ const scale = 2 ** (live.formant / 12);
798
+ core.setTract(vowelAt(voice, vowel, 0), scale, ringHz);
799
+ // F2 follows the selected overtone (merged F1/F2 resonance,
800
+ // Bloothooft et al. 1992; Levin and Edgerton 1999).
801
+ core.tract.res[1]!.set(current, 60, sampleRate);
802
+ core.setOvertone(current, 8 + 30 * (1 - live.overtone));
803
+ ratio = 2 ** ((drift.next() * 2) / 1200);
804
+ rd = Math.min(
805
+ 2.7,
806
+ Math.max(0.3, 2.5 - 2 * live.bright - 1.2 * (velocity - 0.6)),
807
+ );
808
+ if ((i - start) % (SING_CONTROL * NORM_EVERY) === 0)
809
+ core.aimLevel(droneHz, rd, live);
810
+ }
811
+ const t = (i - start) / sampleRate;
812
+ const y = core.step(droneHz * ratio, rd, live);
813
+ left[i] =
814
+ left[i]! +
815
+ y *
816
+ live.gain *
817
+ velocity *
818
+ envelope(t, lengthSec, s.attack, s.release) *
819
+ setup.gainAt(i);
820
+ }
821
+ }
822
+ }
823
+
824
+ let horizon = Infinity;
825
+ /**
826
+ * Runs `render` with sing voices computed only up to `frames` (silent after).
827
+ * The live first window needs 0.75 s but the renderer's shortest one-shot
828
+ * is 1 s: a quarter of the ensemble cost was spent on audio that is cut.
829
+ * Every voice is causal, so the frames kept are the full render's prefix.
830
+ */
831
+ export function withSingHorizon<T>(frames: number, render: () => T): T {
832
+ const previous = horizon;
833
+ horizon = Math.max(1, Math.floor(frames));
834
+ try {
835
+ return render();
836
+ } finally {
837
+ horizon = previous;
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Output trim that puts one sung note at velocity 0.8 on the same reference
843
+ * as the other engines (about -18 LUFS, like `piano`), so a choir in a mix
844
+ * sits beside the band instead of 15 dB over it and into the clip.
845
+ */
846
+ export const SING_OUTPUT_TRIM = 1.25;
847
+
848
+ /** Renders a sing track's performed notes into `dry` (and `dryR`). */
849
+ export function renderSingTrack(
850
+ dry: Float64Array,
851
+ dryR: Float64Array | undefined,
852
+ notes: readonly PerformedNote[],
853
+ track: Track,
854
+ context: EngineContext,
855
+ ): void {
856
+ const { sampleRate, samples } = context;
857
+ const keyRoot = parseKey(context.score.key ?? undefined)?.tonic;
858
+ const s = resolveSing(track.sing, keyRoot);
859
+ const tickAt = (index: number): number =>
860
+ context.warp ? context.warp.tick(index) : index / context.samplesPerTick;
861
+ const volumeLane = track.volumeAutomation ?? [];
862
+ const volume = Math.max(0, Math.min(1, track.volume ?? 1));
863
+ const gainAt = (index: number): number =>
864
+ SING_OUTPUT_TRIM *
865
+ volume *
866
+ (volumeLane.length > 0
867
+ ? interpolateAutomation(volumeLane, tickAt(index), 1)
868
+ : 1);
869
+ const setup: Setup = {
870
+ s,
871
+ lanes: lanesOf(track),
872
+ sampleRate,
873
+ samples: Math.min(samples, horizon),
874
+ tickAt,
875
+ gainAt,
876
+ seedTick: context.seedTick ?? 0,
877
+ };
878
+ if (s.voice === "auto") {
879
+ const part = autoPartVoice(
880
+ (context.score?.notes ?? [])
881
+ .filter((note) => note.trackId === track.id)
882
+ .map((note) => note.pitch),
883
+ );
884
+ if (part) setup.partVoice = part;
885
+ }
886
+ const throat = s.drone !== undefined;
887
+ const melisma = heldVowels(notes, s.vowel);
888
+ const lines = windLines(notes, context, track.glide === undefined);
889
+ const planned: Planned[] = lines.map((line) => ({
890
+ start: line.start,
891
+ length: line.length,
892
+ head: line.notes[0]!,
893
+ steal: Infinity,
894
+ segments: line.segments.map((seg, k) => ({
895
+ at: seg.at,
896
+ end:
897
+ k + 1 < line.segments.length ? line.segments[k + 1]!.at : line.length,
898
+ hz: seg.hz,
899
+ vowel: ((note) => melisma.get(note) ?? noteVowel(note, s.vowel))(
900
+ line.notes[k] ?? line.notes[0]!,
901
+ ),
902
+ })),
903
+ }));
904
+ if (throat) {
905
+ renderThroat(planned, setup, context, dry);
906
+ } else {
907
+ const cap = lineCap(singMembers(s.voices));
908
+ const sounding: Planned[] = [];
909
+ const tail = Math.ceil((s.release + 0.03) * sampleRate);
910
+ for (const entry of [...planned].sort((a, b) => a.start - b.start)) {
911
+ for (let k = sounding.length - 1; k >= 0; k -= 1) {
912
+ const other = sounding[k]!;
913
+ if (
914
+ Math.min(other.steal, other.start + other.length + tail) <=
915
+ entry.start
916
+ )
917
+ sounding.splice(k, 1);
918
+ }
919
+ while (sounding.length >= cap) sounding.shift()!.steal = entry.start;
920
+ sounding.push(entry);
921
+ }
922
+ for (const line of planned) renderLine(line, setup, dry, dryR);
923
+ }
924
+ // The modal engine's soft knee keeps chords from clipping; causal and
925
+ // stateless, so window renders stay exact prefixes.
926
+ applyModalKnee(dry, samples);
927
+ if (dryR) applyModalKnee(dryR, samples);
928
+ }
929
+
930
+ /** Whether a sing track renders stereo (an ensemble outside throat mode). */
931
+ export function singStereo(track: Track): boolean {
932
+ const s = resolveSing(track.sing);
933
+ return s.drone === undefined && singMembers(s.voices) > 1;
934
+ }
935
+
936
+ /** The sing engine as registered in `src/audio/instruments.ts`. */
937
+ export const SING_ENGINE: InstrumentEngine = Object.freeze({
938
+ id: "sing",
939
+ field: "sing",
940
+ render(dry, dryR, notes, track, context) {
941
+ renderSingTrack(dry, dryR, notes, track, context);
942
+ },
943
+ tailSeconds: (track: Track) => singTailSeconds(track.sing),
944
+ releaseSeconds: (track: Track) => resolveSing(track.sing).release,
945
+ stereo: singStereo,
946
+ assetDigests: (track: Track) => [
947
+ `sing:${SING_VERSION}:${singPresetOf(track.sing)}`,
948
+ ],
949
+ });