@hraness/dawg 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +655 -141
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +759 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -99
  8. package/core/expression.ts +158 -6
  9. package/core/fx.ts +354 -32
  10. package/core/granular.ts +91 -0
  11. package/core/instruments.ts +180 -0
  12. package/core/keys.ts +573 -4
  13. package/core/loop.ts +5 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/resonators.ts +306 -4
  17. package/core/routing.ts +165 -0
  18. package/core/score.ts +1181 -16
  19. package/core/sdk/eval-child.ts +7 -2
  20. package/core/sdk/eval.ts +35 -6
  21. package/core/sdk/print.ts +354 -6
  22. package/core/sdk/sync-lyrics.ts +49 -0
  23. package/core/sdk/v1.ts +3160 -69
  24. package/core/sections.ts +298 -24
  25. package/core/sing.ts +815 -0
  26. package/core/strings.ts +237 -2
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/core/winds.ts +652 -0
  50. package/guides/agent.md +29 -0
  51. package/guides/arrange.md +30 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -6
  54. package/guides/chords.md +16 -14
  55. package/guides/effects.md +17 -15
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +12 -6
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/performance.md +15 -10
  65. package/guides/play.md +21 -13
  66. package/guides/project.md +25 -8
  67. package/guides/providers.md +20 -14
  68. package/guides/resample.md +32 -0
  69. package/guides/rhythm.md +17 -13
  70. package/guides/sessions.md +17 -7
  71. package/guides/show-me.md +31 -0
  72. package/guides/sound.md +25 -9
  73. package/guides/sounds.md +15 -12
  74. package/guides/styles.md +31 -0
  75. package/guides/tempo.md +15 -10
  76. package/guides/tracks.md +15 -10
  77. package/guides/tuning.md +32 -0
  78. package/guides/voice.md +31 -0
  79. package/guides/web-search.md +18 -8
  80. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  81. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  82. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  83. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  84. package/native/prebuilt/manifest.json +21 -0
  85. package/package.json +5 -2
  86. package/src/agent/agent.ts +131 -13
  87. package/src/agent/calibration-tools.ts +53 -0
  88. package/src/agent/chord-tools.ts +153 -1
  89. package/src/agent/clip-tools.ts +453 -0
  90. package/src/agent/command-agent.ts +369 -0
  91. package/src/agent/drum-tools.ts +2 -2
  92. package/src/agent/expression-tools.ts +91 -0
  93. package/src/agent/gateway.ts +246 -60
  94. package/src/agent/models.ts +53 -12
  95. package/src/agent/ops.ts +15 -1
  96. package/src/agent/pack-tools.ts +1 -1
  97. package/src/agent/planner.ts +13 -0
  98. package/src/agent/portable-schema.ts +80 -0
  99. package/src/agent/preview-tool.ts +5 -1
  100. package/src/agent/provider.ts +22 -8
  101. package/src/agent/resample-tool.ts +131 -0
  102. package/src/agent/rhythm-tools.ts +1 -1
  103. package/src/agent/section-tools.ts +1 -1
  104. package/src/agent/show-me.ts +497 -0
  105. package/src/agent/steer.ts +15 -0
  106. package/src/agent/style-tools.ts +217 -0
  107. package/src/agent/tool-error.ts +12 -0
  108. package/src/agent/tools.ts +307 -37
  109. package/src/agent/usage.ts +2 -2
  110. package/src/agent/voice-tools.ts +925 -0
  111. package/src/agent/xcb-agent.ts +16 -6
  112. package/src/argv.ts +38 -0
  113. package/src/audio/analysis.ts +253 -0
  114. package/src/audio/arrange.ts +58 -3
  115. package/src/audio/autotune-engine.ts +101 -0
  116. package/src/audio/autotune.ts +640 -0
  117. package/src/audio/clips.ts +240 -0
  118. package/src/audio/doctor.ts +86 -0
  119. package/src/audio/dsp/bandbank.ts +138 -0
  120. package/src/audio/dsp/envelope.ts +171 -0
  121. package/src/audio/dsp/follow.ts +120 -0
  122. package/src/audio/dsp/formant.ts +427 -0
  123. package/src/audio/dsp/glottal.ts +243 -0
  124. package/src/audio/dsp/interp.ts +7 -2
  125. package/src/audio/dsp/lpc.ts +50 -0
  126. package/src/audio/dsp/periodicity.ts +59 -0
  127. package/src/audio/dsp/pitch.ts +995 -0
  128. package/src/audio/dsp/psola.ts +199 -0
  129. package/src/audio/dsp/shift.ts +256 -0
  130. package/src/audio/effects/chain.ts +21 -4
  131. package/src/audio/effects/common.ts +57 -0
  132. package/src/audio/effects/convolution.ts +113 -5
  133. package/src/audio/effects/filter.ts +48 -69
  134. package/src/audio/effects/formant.ts +263 -0
  135. package/src/audio/effects/gaze.ts +354 -0
  136. package/src/audio/engine.ts +206 -33
  137. package/src/audio/fit.ts +80 -3
  138. package/src/audio/granular.ts +352 -21
  139. package/src/audio/instrument-check.ts +67 -47
  140. package/src/audio/instruments.ts +36 -3
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +427 -0
  143. package/src/audio/keys/engine.ts +156 -20
  144. package/src/audio/keys/organ.ts +1335 -0
  145. package/src/audio/keys/piano.ts +72 -8
  146. package/src/audio/keys/sympathetic.ts +127 -0
  147. package/src/audio/kits.ts +135 -6
  148. package/src/audio/live-worker.ts +3 -1
  149. package/src/audio/live.ts +247 -26
  150. package/src/audio/native.ts +615 -0
  151. package/src/audio/preview.ts +45 -3
  152. package/src/audio/render-worker.ts +2 -0
  153. package/src/audio/renderer.ts +2 -0
  154. package/src/audio/resample.ts +286 -0
  155. package/src/audio/resonators.ts +10 -2
  156. package/src/audio/sampler.ts +252 -24
  157. package/src/audio/samples.ts +46 -3
  158. package/src/audio/sing/analysis.ts +193 -0
  159. package/src/audio/sing/engine.ts +949 -0
  160. package/src/audio/strings/body.ts +29 -16
  161. package/src/audio/strings/bow.ts +699 -0
  162. package/src/audio/strings/engine.ts +274 -22
  163. package/src/audio/strings/measure.test-helpers.ts +2 -0
  164. package/src/audio/synth/oscillators.ts +31 -21
  165. package/src/audio/synth/voice.ts +34 -1
  166. package/src/audio/vocoder/bank.ts +314 -0
  167. package/src/audio/vocoder/carrier.ts +165 -0
  168. package/src/audio/vocoder/control.ts +68 -0
  169. package/src/audio/vocoder/detect.ts +50 -0
  170. package/src/audio/vocoder/index.ts +304 -0
  171. package/src/audio/vocoder/talkbox.ts +143 -0
  172. package/src/audio/wav.ts +608 -85
  173. package/src/audio/winds/engine.ts +306 -0
  174. package/src/audio/winds/filters.ts +153 -0
  175. package/src/audio/winds/pitch.ts +104 -0
  176. package/src/audio/winds/trim.ts +104 -0
  177. package/src/audio/winds/trims.ts +917 -0
  178. package/src/audio/winds/trims1.ts +297 -0
  179. package/src/audio/winds/voice.ts +492 -0
  180. package/src/auth/cli.ts +38 -36
  181. package/src/auth/credentials.ts +30 -1
  182. package/src/auth/login.ts +15 -9
  183. package/src/auth/tui.ts +19 -10
  184. package/src/commands/arrange.ts +44 -29
  185. package/src/commands/autotune.ts +421 -0
  186. package/src/commands/calibration.ts +74 -0
  187. package/src/commands/clips.ts +887 -0
  188. package/src/commands/drums.ts +3 -2
  189. package/src/commands/edit.ts +11 -4
  190. package/src/commands/expression.ts +104 -23
  191. package/src/commands/formant.ts +221 -0
  192. package/src/commands/fx.ts +105 -33
  193. package/src/commands/grammar.ts +558 -0
  194. package/src/commands/granular.ts +7 -2
  195. package/src/commands/help.ts +652 -339
  196. package/src/commands/history.ts +18 -0
  197. package/src/commands/keys.ts +293 -17
  198. package/src/commands/modal.ts +67 -4
  199. package/src/commands/music.ts +1 -1
  200. package/src/commands/nearest.ts +53 -0
  201. package/src/commands/pack.ts +9 -2
  202. package/src/commands/param-range.ts +56 -0
  203. package/src/commands/parses.ts +130 -0
  204. package/src/commands/progression.ts +170 -0
  205. package/src/commands/resample.ts +281 -0
  206. package/src/commands/rhythm.ts +3 -0
  207. package/src/commands/rig.ts +8 -25
  208. package/src/commands/sample.ts +49 -2
  209. package/src/commands/shift.ts +119 -0
  210. package/src/commands/sing.ts +478 -0
  211. package/src/commands/string.ts +28 -2
  212. package/src/commands/strum.ts +485 -0
  213. package/src/commands/style.ts +415 -0
  214. package/src/commands/time.ts +6 -3
  215. package/src/commands/tuning.ts +3 -3
  216. package/src/commands/vocal-pitch.ts +616 -0
  217. package/src/commands/vocal.ts +147 -0
  218. package/src/commands/vocoder.ts +627 -0
  219. package/src/commands/wind.ts +244 -0
  220. package/src/fs/durable.ts +50 -0
  221. package/src/lang/glossary.ts +493 -0
  222. package/src/launch-args.ts +163 -0
  223. package/src/main.ts +1521 -255
  224. package/src/media/cli.ts +20 -3
  225. package/src/media/import.ts +3 -1
  226. package/src/project/check.ts +26 -3
  227. package/src/project/clip-pins.ts +72 -0
  228. package/src/project/init.ts +23 -8
  229. package/src/project/sync.ts +418 -86
  230. package/src/render.ts +36 -1
  231. package/src/session/daemon.ts +3 -0
  232. package/src/session/meta.ts +14 -0
  233. package/src/session/origin.ts +154 -0
  234. package/src/session/port.ts +22 -4
  235. package/src/session/presence.ts +50 -7
  236. package/src/session/protocol.ts +5 -1
  237. package/src/session/rebase.ts +18 -5
  238. package/src/session/receipt.ts +258 -0
  239. package/src/session/store.ts +65 -43
  240. package/src/tui/arrange-menu.ts +65 -31
  241. package/src/tui/audition.ts +1 -1
  242. package/src/tui/euclid.ts +18 -13
  243. package/src/tui/fader.ts +228 -41
  244. package/src/tui/granular-menu.ts +2 -4
  245. package/src/tui/menu-clips.ts +297 -0
  246. package/src/tui/menu-time.ts +20 -13
  247. package/src/tui/menu-voice.ts +405 -0
  248. package/src/tui/menu.ts +1377 -193
  249. package/src/tui/modal-menu.ts +38 -11
  250. package/src/tui/performance-menu.ts +46 -1
  251. package/src/tui/play-chords.ts +108 -5
  252. package/src/tui/play-mode.ts +16 -1
  253. package/src/tui/play-session.ts +81 -16
  254. package/src/tui/sing-menu.ts +278 -0
  255. package/src/tui/style-menu.ts +104 -0
  256. package/src/tui/vocoder-menu.ts +244 -0
  257. package/src/tui/wind-menu.ts +144 -0
  258. package/src/version.ts +8 -0
  259. package/src/web/fetch.ts +115 -29
  260. package/tui/activity.ts +180 -9
  261. package/tui/app.ts +274 -40
  262. package/tui/clip-row.ts +132 -0
  263. package/tui/delight.ts +144 -0
  264. package/tui/drawer.ts +70 -22
  265. package/tui/frame-gate.ts +76 -0
  266. package/tui/grammar.ts +112 -63
  267. package/tui/guide.ts +42 -4
  268. package/tui/highway.ts +269 -25
  269. package/tui/hints.ts +192 -0
  270. package/tui/input.ts +60 -9
  271. package/tui/keys.ts +1 -1
  272. package/tui/play-strip.ts +68 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +144 -14
  275. package/tui/theme.ts +27 -0
@@ -20,8 +20,15 @@ import { interpolateAutomation } from "../effects/common.ts";
20
20
  import { seedHash, unit } from "../dsp/rng.ts";
21
21
  import type { EngineContext, InstrumentEngine } from "../instruments.ts";
22
22
  import { applyBody, bodyFor, symKeys, sympathetic } from "./body.ts";
23
+ import { keysTrim } from "../keys/calibration.ts";
23
24
  import { clamp } from "./loop.ts";
24
25
  import { PluckString, type Exciter, type PluckSpec } from "./pluck.ts";
26
+ import {
27
+ BowedString,
28
+ type BowControl,
29
+ type BowSpec,
30
+ type BowNote,
31
+ } from "./bow.ts";
25
32
 
26
33
  const BLOCK = 128;
27
34
  /** Longest a single string may ring (seconds). */
@@ -84,8 +91,155 @@ export function noteSpec(
84
91
  };
85
92
  }
86
93
 
94
+ /** Whether resolved values play the bowed voice (`exciter bow`). */
95
+ export function isBowed(values: StringValues): boolean {
96
+ return values.exciter === "bow";
97
+ }
98
+
99
+ /** Play mode keeps a bowed section to two players (live budget). */
100
+ export const LIVE_BOW_UNISON = 2;
101
+
102
+ /** The track as play mode hears it: a bowed section capped at 2 players. */
103
+ export function liveStringTrack(track: Track): Track {
104
+ if (track.instrument !== "string" || !track.string) return track;
105
+ const values = resolveString(track.string);
106
+ if (!isBowed(values) || (values.unison as number) <= LIVE_BOW_UNISON)
107
+ return track;
108
+ return { ...track, string: { ...track.string, unison: LIVE_BOW_UNISON } };
109
+ }
110
+
111
+ /** Bow lanes read every 32 samples (`string-<name>`, absent: the value). */
112
+ const BOW_LANES = ["pressure", "speed", "sord", "dyn"] as const;
113
+
114
+ /** The bowed spec and note fields for one note. */
115
+ export function bowSpec(
116
+ values: StringValues,
117
+ note: PerformedNote,
118
+ track: Track,
119
+ context: EngineContext,
120
+ ): { spec: BowSpec; velocity: number; control?: (t: number) => BowControl } {
121
+ const at = (name: string) => valueAt(track, values, name, note.startTick);
122
+ let velocity = clamp(note.velocity, 0, 1);
123
+ const articulation = note.articulation;
124
+ const sens = clamp(values.vel as number, 0, 1);
125
+ velocity = 1 - sens * (1 - velocity);
126
+ let attack = values.attack as number;
127
+ let release = values.release as number;
128
+ // Detache (spec 4): staccato and ghost are short strokes, quick on and off.
129
+ if (articulation === "staccato" || articulation === "ghost") {
130
+ attack = Math.min(attack, 0.01);
131
+ release = Math.min(release, 0.03);
132
+ }
133
+ // A harder stroke presses harder (spec 4: pressure += 0.3 (vel - 0.5)).
134
+ const press = (p: number) => clamp(p + 0.3 * (velocity - 0.5), 0, 1);
135
+ // Accent and marcato bite: pressure +0.2 for the first 80 ms.
136
+ const bite = articulation === "accent" || articulation === "marcato";
137
+ const spec: BowSpec = {
138
+ decay: Math.max(0.02, at("ring")),
139
+ track: values.track as number,
140
+ damp: clamp(values.damp as number, 0, 1),
141
+ pos: clamp(at("pos"), 0.01, 0.5),
142
+ pressure: press(at("pressure")),
143
+ speed: clamp(at("speed"), 0, 1),
144
+ attack,
145
+ release,
146
+ tremhz: values.tremhz as number,
147
+ sord: clamp(at("sord"), 0, 1),
148
+ };
149
+ const lanes = BOW_LANES.filter(
150
+ (name) =>
151
+ (track.fxAutomation?.[`string-${name}` as FxLane]?.length ?? 0) > 0,
152
+ );
153
+ const dyn = clamp(values.dyn as number, 0, 1);
154
+ if (lanes.length === 0 && dyn === 1 && !bite) return { spec, velocity };
155
+ const fixed: BowControl = {
156
+ pressure: spec.pressure,
157
+ speed: spec.speed,
158
+ sord: spec.sord,
159
+ dyn,
160
+ };
161
+ const bitten: BowControl = {
162
+ ...fixed,
163
+ pressure: clamp(spec.pressure + 0.2, 0, 1),
164
+ };
165
+ if (lanes.length === 0)
166
+ return {
167
+ spec,
168
+ velocity,
169
+ control: bite ? (t) => (t < 0.08 ? bitten : fixed) : () => fixed,
170
+ };
171
+ // Lanes are read in ticks; warped songs read them at the note's tempo.
172
+ const ticksPerSecond = context.sampleRate / context.samplesPerTick;
173
+ const control = (t: number): BowControl => {
174
+ const tick = note.startTick + t * ticksPerSecond;
175
+ const lane = (name: string) =>
176
+ clamp(valueAt(track, values, name, tick), 0, 1);
177
+ return {
178
+ pressure: clamp(
179
+ press(lane("pressure")) + (bite && t < 0.08 ? 0.2 : 0),
180
+ 0,
181
+ 1,
182
+ ),
183
+ speed: lane("speed"),
184
+ sord: lane("sord"),
185
+ dyn: lane("dyn"),
186
+ };
187
+ };
188
+ return { spec, velocity, control };
189
+ }
190
+
191
+ /** The preset vibrato alone (a slur keeps it running). */
192
+ function vibratoCurve(
193
+ note: PerformedNote,
194
+ values: StringValues,
195
+ track: Track,
196
+ player: Readonly<{ rate: number; depth: number; phase: number }> = SOLO,
197
+ ): ((t: number) => number) | undefined {
198
+ if (note.performance?.replaceVibrato) return undefined;
199
+ const rate = valueAt(track, values, "vib", note.startTick) * player.rate;
200
+ const depth =
201
+ valueAt(track, values, "vibmod", note.startTick) * 100 * player.depth;
202
+ const delay = values.vibdelay as number;
203
+ if (!(rate > 0 && depth > 0)) return undefined;
204
+ const phase = player.phase;
205
+ return (t: number) => {
206
+ if (t <= delay) return 0;
207
+ const fade = Math.min(1, (t - delay) / 0.15);
208
+ return depth * fade * Math.sin(2 * Math.PI * (rate * (t - delay) + phase));
209
+ };
210
+ }
211
+
212
+ const SOLO = { rate: 1, depth: 1, phase: 0 } as const;
213
+
214
+ /** Longest seeded onset spread of a bowed section's players (spec 2). */
215
+ export const SECTION_SPREAD = 0.025;
216
+
217
+ /**
218
+ * One section player (spec 2, Ensemble): seeded vibrato rate x0.92-1.08,
219
+ * depth x0.8-1.2 and phase, and an onset up to 25 ms late (scaled down
220
+ * for notes under 0.25 s). The first player stays on the grid.
221
+ */
222
+ export function sectionPlayer(
223
+ seed: number,
224
+ index: number,
225
+ hold: number,
226
+ ): { rate: number; depth: number; phase: number; onset: number } {
227
+ const h = seedHash(`${seed}:${index}:player`);
228
+ return {
229
+ rate: 0.92 + 0.16 * unit(h, 0, 1),
230
+ depth: 0.8 + 0.4 * unit(h, 1, 1),
231
+ phase: unit(h, 2, 1),
232
+ onset:
233
+ index === 0
234
+ ? 0
235
+ : SECTION_SPREAD * unit(h, 3, 1) * Math.min(1, hold / 0.25),
236
+ };
237
+ }
238
+
87
239
  type Voice = {
88
- string: PluckString;
240
+ string: PluckString | BowedString;
241
+ /** The string's pitch over the note's base pitch (a slur keeps it). */
242
+ ratio: number;
89
243
  start: number;
90
244
  pitch: number;
91
245
  group: number;
@@ -94,6 +248,26 @@ type Voice = {
94
248
  damp?: Readonly<{ from: number; tau: number }>;
95
249
  };
96
250
 
251
+ /**
252
+ * Whether pending note `index` (starting at `start`) is a lone note: no
253
+ * other note starts with it, so a held single note may slur into it.
254
+ */
255
+ function slurs(
256
+ pending: readonly { start: number }[],
257
+ index: number,
258
+ start: number,
259
+ ): boolean {
260
+ return (
261
+ pending[index - 1]?.start !== start && pending[index + 1]?.start !== start
262
+ );
263
+ }
264
+
265
+ /** A 64th note in samples at the song's start tempo (the slur window). */
266
+ function slurWindow(context: EngineContext): number {
267
+ const ticks = (context.score.ticksPerBeat ?? 480) / 16;
268
+ return ticks * context.samplesPerTick;
269
+ }
270
+
97
271
  /** Ring-out after the last note-off, in seconds. */
98
272
  export function stringTailSeconds(track: Track): number {
99
273
  const values = resolveString(track.string);
@@ -163,6 +337,11 @@ export function renderStrings(
163
337
  const octbelow = values.octbelow as number;
164
338
  const cap = Math.max(1, Math.round(values.voices as number));
165
339
  const tuning = context.tuning;
340
+ const bowed = isBowed(values);
341
+ // Calibration 1 (q08): the keyboard string presets (the harpsichord) take
342
+ // the keys' key-tracked level trim; other presets have no row.
343
+ const trimPreset =
344
+ (context.score.calibration ?? 0) >= 1 ? track.string?.preset : undefined;
166
345
  const L = new Float64Array(total);
167
346
  const R = dryR ? new Float64Array(total) : undefined;
168
347
  const voices: Voice[] = [];
@@ -183,6 +362,47 @@ export function renderStrings(
183
362
  next += 1;
184
363
  const baseHz = noteHz(note.pitch, note.cents, tuning);
185
364
  if (!(baseHz > 0) || baseHz >= 0.45 * sr) continue;
365
+ // Slur (bowed): a single note that starts while one earlier single
366
+ // note is still bowed moves that bow to the new pitch in 7 ms, with
367
+ // no new attack (legato articulation overlaps its notes, so it
368
+ // slurs too). Chords and double stops start new strokes.
369
+ if (bowed && slurs(pending, next - 1, start)) {
370
+ const held = voices.filter(
371
+ (v) => !v.string.done && v.string.offAt > start - v.start,
372
+ );
373
+ const groups = new Set(held.map((v) => v.group));
374
+ const pitches = new Set(held.map((v) => v.pitch));
375
+ // A true legato only: the held note lets go within a short window
376
+ // of the new one (a 64th note or 150 ms, legato's own overlap), or
377
+ // the new note is marked legato. A pedal or a note held under a
378
+ // moving line keeps sounding and the new note takes a new stroke.
379
+ const window = Math.max(0.15 * sr, slurWindow(context));
380
+ const letsGo =
381
+ note.articulation === "legato" ||
382
+ held.every((v) => v.start + v.string.offAt <= start + window);
383
+ const range = held.every((v) =>
384
+ (v.string as BowedString).canSlurTo(baseHz * v.ratio),
385
+ );
386
+ if (
387
+ letsGo &&
388
+ range &&
389
+ groups.size === 1 &&
390
+ pitches.size === 1 &&
391
+ held[0]!.start < start
392
+ ) {
393
+ const hold = holdSamples(note, start, context) / sr;
394
+ for (const v of held) {
395
+ (v.string as BowedString).slurTo(
396
+ baseHz * v.ratio,
397
+ start - v.start,
398
+ Math.max(0.01, hold),
399
+ note.performance?.cents,
400
+ );
401
+ v.pitch = note.pitch;
402
+ }
403
+ continue;
404
+ }
405
+ }
186
406
  // Restrike: the same key damps its previous string.
187
407
  for (const v of voices)
188
408
  if (v.pitch === note.pitch) v.string.releaseAt(start - v.start);
@@ -197,11 +417,11 @@ export function renderStrings(
197
417
  for (const v of held)
198
418
  if (v.group === oldest) v.string.releaseAt(start - v.start);
199
419
  }
200
- const { spec, velocity } = noteSpec(values, note, track);
201
420
  const seed = seedHash(
202
421
  `${note.id}:${note.startTick + (context.seedTick ?? 0)}:string`,
203
422
  );
204
- const strings: { hz: number; gain: number }[] = [];
423
+ const strings: { hz: number; gain: number; ratio: number }[] = [];
424
+ const trim = trimPreset ? keysTrim(trimPreset, note.pitch) : 1;
205
425
  // The course's tuning error belongs to the key, not to the strike: it
206
426
  // is seeded by track and key (so every velocity of a key beats the
207
427
  // same way) and centred, so the course's mean pitch is the table's.
@@ -213,42 +433,74 @@ export function renderStrings(
213
433
  for (let i = 0; i < unison; i += 1) {
214
434
  const d = unison > 1 ? (i / (unison - 1) - 0.5) * detune : 0;
215
435
  const jitter = jitters[i]! - meanJitter;
436
+ const ratio = 2 ** ((d * 100 + jitter) / 1200);
216
437
  strings.push({
217
- hz: baseHz * 2 ** ((d * 100 + jitter) / 1200),
218
- gain: 1 / Math.sqrt(unison),
438
+ hz: baseHz * ratio,
439
+ gain: trim / Math.sqrt(unison),
440
+ ratio,
219
441
  });
220
442
  }
221
443
  if (oct > 0 && note.pitch < octbelow) {
222
444
  const up = noteHz(Math.min(127, note.pitch + 12), note.cents, tuning);
223
445
  const hz = up > 0 ? up : baseHz * 2;
224
- if (hz < 0.45 * sr)
446
+ if (hz < 0.45 * sr) {
447
+ const tuned =
448
+ hz * 2 ** (((unit(course, unison, 1) - 0.5) * 4) / 1200);
225
449
  strings.push({
226
- hz: hz * 2 ** (((unit(course, unison, 1) - 0.5) * 4) / 1200),
227
- gain: oct / Math.sqrt(unison),
450
+ hz: tuned,
451
+ gain: (trim * oct) / Math.sqrt(unison),
452
+ ratio: tuned / baseHz,
228
453
  });
454
+ }
229
455
  }
230
456
  const hold = holdSamples(note, start, context) / sr;
231
- const cents = centsCurve(note, values, track);
457
+ const pluck = bowed ? undefined : noteSpec(values, note, track);
458
+ const bow = bowed ? bowSpec(values, note, track, context) : undefined;
459
+ const cents = bowed
460
+ ? note.performance?.cents
461
+ : centsCurve(note, values, track);
462
+ const section = bowed && unison > 1;
232
463
  strings.forEach((s, i) => {
464
+ // Section players (not the octave string) are seeded as players.
465
+ const player =
466
+ section && i < unison ? sectionPlayer(seed, i, hold) : undefined;
467
+ const onset = player ? Math.round(player.onset * sr) : 0;
468
+ const vibrato = bowed
469
+ ? vibratoCurve(note, values, track, player)
470
+ : undefined;
233
471
  const pan =
234
472
  strings.length > 1 ? (i / (strings.length - 1) - 0.5) * spread : 0;
235
- const string = new PluckString(
236
- spec,
237
- {
238
- hz: s.hz,
239
- velocity,
240
- hold: Math.max(0.01, hold),
241
- seed: seedHash(`${seed}:${i}`),
242
- ...(cents ? { cents } : {}),
243
- },
244
- sr,
245
- MAX_RING,
246
- );
473
+ const common = {
474
+ hz: s.hz,
475
+ hold: Math.max(0.01, hold - onset / sr),
476
+ seed: seedHash(`${seed}:${i}`),
477
+ ...(cents ? { cents } : {}),
478
+ };
479
+ const string = bow
480
+ ? new BowedString(
481
+ bow.spec,
482
+ {
483
+ ...common,
484
+ velocity: bow.velocity,
485
+ ...(vibrato ? { vibrato } : {}),
486
+ ...(bow.control ? { control: bow.control } : {}),
487
+ } satisfies BowNote,
488
+ sr,
489
+ // A bow sustains for as long as the note is held.
490
+ Math.max(MAX_RING, hold + bow.spec.release + 1),
491
+ )
492
+ : new PluckString(
493
+ pluck!.spec,
494
+ { ...common, velocity: pluck!.velocity },
495
+ sr,
496
+ MAX_RING,
497
+ );
247
498
  const gl = R ? s.gain * Math.sqrt(0.5 - pan / 2) * Math.SQRT2 : s.gain;
248
499
  const gr = s.gain * Math.sqrt(0.5 + pan / 2) * Math.SQRT2;
249
500
  voices.push({
250
501
  string,
251
- start,
502
+ ratio: s.ratio,
503
+ start: start + onset,
252
504
  pitch: note.pitch,
253
505
  group: start,
254
506
  gainL: gl,
@@ -24,6 +24,7 @@ export type TestNote = {
24
24
  seconds?: number;
25
25
  velocity?: number;
26
26
  performance?: PerformedNote["performance"];
27
+ articulation?: PerformedNote["articulation"];
27
28
  };
28
29
 
29
30
  /** Renders notes on a string track (mono, before effects). */
@@ -58,6 +59,7 @@ export function render(
58
59
  startTick: Math.round(((n.start ?? 0) * SR) / SPT),
59
60
  durationTicks: Math.round(((n.seconds ?? seconds) * SR) / SPT),
60
61
  ...(n.performance ? { performance: n.performance } : {}),
62
+ ...(n.articulation ? { articulation: n.articulation } : {}),
61
63
  })) as unknown as PerformedNote[];
62
64
  const dry = new Float64Array(samples);
63
65
  renderStrings(dry, undefined, performed, track, context);
@@ -110,28 +110,38 @@ function triangle(phase: number): number {
110
110
  * names when a track sets synth parameters.
111
111
  */
112
112
  export function legacyWave(instrument: string, phase: number): number {
113
+ return legacyWaveFor(instrument)(phase);
114
+ }
115
+
116
+ /**
117
+ * `legacyWave` with the tone chosen once: `legacyWaveFor(name)(phase)`
118
+ * equals `legacyWave(name, phase)`, without matching the name per sample.
119
+ */
120
+ export function legacyWaveFor(instrument: string): (phase: number) => number {
113
121
  const name = instrument.trim().toLowerCase();
114
- const cycle = phase - Math.floor(phase);
115
- const sine = Math.sin(2 * Math.PI * phase);
116
- if (name.includes("square")) return cycle < 0.5 ? 1 : -1;
117
- if (name.includes("saw")) return 2 * cycle - 1;
118
- if (name.includes("triangle")) return 1 - 4 * Math.abs(cycle - 0.5);
119
- if (name.includes("bass")) {
122
+ if (name.includes("square"))
123
+ return (phase) => (phase - Math.floor(phase) < 0.5 ? 1 : -1);
124
+ if (name.includes("saw"))
125
+ return (phase) => 2 * (phase - Math.floor(phase)) - 1;
126
+ if (name.includes("triangle"))
127
+ return (phase) => 1 - 4 * Math.abs(phase - Math.floor(phase) - 0.5);
128
+ if (name.includes("bass"))
120
129
  // A rounded fundamental plus a quiet octave gives bass tracks useful weight.
121
- return Math.tanh(
122
- 0.9 * Math.sin(2 * Math.PI * phase) +
123
- 0.25 * Math.sin(4 * Math.PI * phase),
124
- );
125
- }
126
- if (name.includes("piano") || name.includes("pluck")) {
130
+ return (phase) =>
131
+ Math.tanh(
132
+ 0.9 * Math.sin(2 * Math.PI * phase) +
133
+ 0.25 * Math.sin(4 * Math.PI * phase),
134
+ );
135
+ if (name.includes("piano") || name.includes("pluck"))
127
136
  // Add stable harmonics; the envelope above supplies the note decay.
128
- const harmonic =
129
- Math.sin(4 * Math.PI * phase) * 0.28 +
130
- Math.sin(6 * Math.PI * phase) * 0.12;
131
- return Math.tanh(sine + harmonic);
132
- }
137
+ return (phase) => {
138
+ const harmonic =
139
+ Math.sin(4 * Math.PI * phase) * 0.28 +
140
+ Math.sin(6 * Math.PI * phase) * 0.12;
141
+ return Math.tanh(Math.sin(2 * Math.PI * phase) + harmonic);
142
+ };
133
143
  // Unknown instruments deliberately fall back to the original sine voice.
134
- return sine;
144
+ return (phase) => Math.sin(2 * Math.PI * phase);
135
145
  }
136
146
 
137
147
  /**
@@ -230,9 +240,9 @@ const BUILT_IN: Readonly<Record<string, OscillatorFactory>> = Object.freeze({
230
240
  pulse: (init) => (phase, increment) => pulse(phase, increment, init.width()),
231
241
  triangle: (init) => (init.partials ? additive(init) : triangle),
232
242
  user: (init) => additive(init),
233
- piano: () => (phase) => legacyWave("piano", phase),
234
- pluck: () => (phase) => legacyWave("pluck", phase),
235
- bass: () => (phase) => legacyWave("bass", phase),
243
+ piano: () => legacyWaveFor("piano"),
244
+ pluck: () => legacyWaveFor("pluck"),
245
+ bass: () => legacyWaveFor("bass"),
236
246
  white: (init) => () => (init.random() * 2 - 1) * 0.6,
237
247
  pink: (init) => pinkNoise(init.random),
238
248
  brown: (init) => {
@@ -31,6 +31,7 @@ import {
31
31
  } from "../../../core/synth.ts";
32
32
  import {
33
33
  Biquad,
34
+ Svf,
34
35
  CONTROL_SAMPLES,
35
36
  clamp,
36
37
  interpolateAutomation,
@@ -197,6 +198,8 @@ const BUTTERWORTH_Q2 = 1.3066;
197
198
  class NoteFilter {
198
199
  private readonly first = new Biquad();
199
200
  private readonly second = new Biquad();
201
+ private readonly svfFirst: Svf;
202
+ private readonly svfSecond: Svf;
200
203
  private readonly ladder = new Float64Array(4);
201
204
  private ladderG = 0;
202
205
  private ladderK = 0;
@@ -210,7 +213,22 @@ class NoteFilter {
210
213
  readonly anchor: number,
211
214
  readonly envelope: Envelope,
212
215
  private readonly sampleRate: number,
213
- ) {}
216
+ ) {
217
+ this.svfFirst = new Svf(type);
218
+ this.svfSecond = new Svf(type);
219
+ }
220
+
221
+ /**
222
+ * A negative depth sweeps the cutoff down during the attack, often to
223
+ * the 20 Hz floor within a few milliseconds. A direct-form biquad
224
+ * re-designed every control block blows up under that fall (its state
225
+ * belongs to the old coefficients), so those notes run on the SVF, whose
226
+ * state stays valid. Positive and zero depths keep the biquad, so their
227
+ * renders are unchanged.
228
+ */
229
+ private get isSvf(): boolean {
230
+ return this.depth < 0;
231
+ }
214
232
 
215
233
  private get isLadder(): boolean {
216
234
  return this.slope === "ladder" && this.type === "lpf";
@@ -231,6 +249,17 @@ class NoteFilter {
231
249
  this.ladderK = 3.9 * clamp((q - 0.707) / 11.3, 0, 1);
232
250
  return;
233
251
  }
252
+ if (this.isSvf) {
253
+ if (this.slope === "24db") {
254
+ this.svfFirst.set(frequency, BUTTERWORTH_Q1, this.sampleRate);
255
+ this.svfSecond.set(
256
+ frequency,
257
+ (q * BUTTERWORTH_Q2) / 0.707,
258
+ this.sampleRate,
259
+ );
260
+ } else this.svfFirst.set(frequency, q, this.sampleRate);
261
+ return;
262
+ }
234
263
  if (this.slope === "24db") {
235
264
  this.first.set(this.type, frequency, BUTTERWORTH_Q1, this.sampleRate);
236
265
  this.second.set(
@@ -255,6 +284,10 @@ class NoteFilter {
255
284
  stages[3]! += g * (stages[2]! - stages[3]!);
256
285
  return stages[3]! * (1 + 0.5 * this.ladderK);
257
286
  }
287
+ if (this.isSvf) {
288
+ const once = this.svfFirst.process(input);
289
+ return this.slope === "24db" ? this.svfSecond.process(once) : once;
290
+ }
258
291
  const once = this.first.process(input);
259
292
  return this.slope === "24db" ? this.second.process(once) : once;
260
293
  }