@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
@@ -156,6 +156,17 @@ export type PianoNoteOn = Readonly<{
156
156
  trackSeed: number;
157
157
  /** Track seed plus note id (this strike's own randomness). */
158
158
  noteSeed: string;
159
+ /**
160
+ * Una corda (0.6.1) at the onset, 0..1 (half pedal 0.5); absent is 0.
161
+ * The hammer shifts: fewer strings struck, a softer felt spot, more of
162
+ * the unstruck string's aftersound.
163
+ */
164
+ soft?: number;
165
+ /**
166
+ * Song calibration (q08); 1+ keeps the hammer contact shorter than the
167
+ * fundamental's period and levels on what the hammer excites.
168
+ */
169
+ calibration?: number;
159
170
  }>;
160
171
 
161
172
  type Rattle = {
@@ -207,18 +218,52 @@ export class PianoVoice {
207
218
  p.decay *
208
219
  (1 - 0.15 * p.felt) *
209
220
  (prepKind === 1 ? 0.12 : prepKind >= 0 ? 0.5 : 1);
210
- const after = clamp(p.after, 0, 0.95);
221
+ const soft = clamp(note.soft ?? 0, 0, 1);
222
+ let after = clamp(p.after, 0, 0.95);
211
223
  const thMs =
212
224
  lerp(3.5, 0.8, keyPos) *
213
225
  lerp(1.6, 0.7, v) *
214
226
  (1 + 1.5 * p.felt) *
215
227
  (1.3 - 0.6 * p.hardness);
228
+ // Una corda (keys.md 2.1): the shifted hammer meets the strings with
229
+ // its softer felt. Modelled spectrally, so the pulse length stays and
230
+ // its nulls never land on a low partial: mode input gains get a smooth
231
+ // two-pole low-pass relative to partial 1 (cutoff at a tenth of the
232
+ // spec's hammer cutoff, lowered 25% at full pedal), floored at -12 dB
233
+ // so no low harmonic is notched out; more of the slow aftersound is
234
+ // excited. Measured at keys 48-84, velocity 0.5-0.9: centroid -12 to
235
+ // -20% through key 72 (-5% at 84), peak -2.3 to -4.8 dB, no partial of
236
+ // the first eight more than 9 dB down (src/audio/keys/pedals.test.ts).
237
+ const softFc =
238
+ 35 *
239
+ 2 ** (5 * v * (0.4 + 0.6 * p.hardness) + 1.5 * keyPos) *
240
+ 0.75 ** soft;
241
+ const softFloor = 1 - 0.75 * soft;
242
+ const softW = (hz: number) =>
243
+ soft > 0
244
+ ? Math.max(
245
+ softFloor,
246
+ ((1 + (f1 / softFc) ** 2) / (1 + (hz / softFc) ** 2)) ** soft,
247
+ )
248
+ : 1;
249
+ if (soft > 0) after = clamp(after + (1 - after) * 0.4 * soft, 0, 0.95);
250
+ const calibrated = (note.calibration ?? 0) >= 1;
251
+ // Calibration 1: a contact longer than about 0.6 of the fundamental's
252
+ // period puts the pulse's first spectral null under partial 1, and
253
+ // the upper keys came out as hammer noise (40 dB down at C7).
254
+ const contactMs = calibrated ? Math.min(thMs, 600 / f1) : thMs;
216
255
  this.pulse = hammerPulse(
217
- Math.max(2, Math.round(thMs * 1e-3 * sr)),
256
+ Math.max(2, Math.round(contactMs * 1e-3 * sr)),
218
257
  1 + 2 * v * p.hardness,
219
258
  );
220
259
  const strings = stringsFor(key);
221
- const spread = strings === 1 ? 0.08 : p.unison;
260
+ // Una corda strikes one string fewer: the unison spread narrows.
261
+ const spread =
262
+ strings === 1
263
+ ? 0.08
264
+ : soft > 0
265
+ ? p.unison * (1 - (soft * 1) / strings)
266
+ : p.unison;
222
267
  const maxHz = MODE_CEILING * sr;
223
268
  let partials = 0;
224
269
  while (partials < 160) {
@@ -234,6 +279,8 @@ export class PianoVoice {
234
279
  const shift = prepKind === 0 || prepKind === 2 ? 0.04 + 0.1 * prep() : 0;
235
280
  const candidates: { hz: number; t60: number; u: number }[] = [];
236
281
  let energy = 0;
282
+ let sum0 = 0;
283
+ let sumW = 0;
237
284
  for (let n = 1; n <= partials; n += 1) {
238
285
  let fn = n * f0 * Math.sqrt(1 + B * n * n);
239
286
  if (shift && n > 1) fn *= 1 - shift * Math.sin(n * Math.PI * xb) ** 2;
@@ -241,8 +288,11 @@ export class PianoVoice {
241
288
  if (Math.abs(shape) < 1e-3) continue;
242
289
  const tn = 1 / (1 / T1 + (fn / 3000) ** 2 / (1.2 * p.decay));
243
290
  const damped = prepKind === 1 && n > 3 ? 0.3 : 1;
244
- const u = shape * damped;
245
- energy += u * u;
291
+ const u0 = shape * damped;
292
+ energy += u0 * u0;
293
+ const u = u0 * softW(fn);
294
+ sum0 += Math.abs(u0);
295
+ sumW += Math.abs(u);
246
296
  if (n <= 12) {
247
297
  // Prompt sound at the centre, aftersound split into a symmetric pair.
248
298
  const d = (spread / 2) * (1 + 0.3 * (random() - 0.5));
@@ -266,11 +316,25 @@ export class PianoVoice {
266
316
  t60: 0.6 + 1.2 * prep(),
267
317
  u: 0.35,
268
318
  });
269
- const loud = v ** 1.4 * (1 - 0.4 * p.felt);
270
- const G = (0.35 * loud) / Math.sqrt(Math.max(energy, 1e-9));
319
+ let loud = v ** 1.4 * (1 - 0.4 * p.felt);
320
+ // Una corda's level is 30% down: half the low-pass loss (in summed mode
321
+ // amplitude) is made up so the extra loss stays about 1 dB.
322
+ if (soft > 0)
323
+ loud *= (1 - 0.3 * soft) * Math.sqrt(sum0 / Math.max(sumW, 1e-9));
271
324
  const mags = candidates.map(
272
325
  (c) => pulseMag(this.pulse, (TAU * c.hz) / sr) * Math.abs(c.u),
273
326
  );
327
+ // Calibration 1 levels on the partials the hammer actually excites
328
+ // (as the electric keys do), so the hammer filter no longer sets the
329
+ // level; legacy levels on the unfiltered strike.
330
+ const heard = calibrated
331
+ ? mags.reduce((sum, m) => sum + m * m, 0) /
332
+ Math.max(
333
+ 1e-12,
334
+ candidates.reduce((sum, c) => sum + c.u * c.u, 0),
335
+ )
336
+ : 1;
337
+ const G = (0.35 * loud) / Math.sqrt(Math.max(energy * heard, 1e-9));
274
338
  const peak = Math.max(0, ...mags);
275
339
  candidates.forEach((c, i) => {
276
340
  // Below -80 dB after the hammer filter: not worth a mode.
@@ -295,7 +359,7 @@ export class PianoVoice {
295
359
  2800,
296
360
  1.5,
297
361
  0.002,
298
- 0.02 * v * v * p.hardness * (1 - p.felt),
362
+ 0.02 * v * v * p.hardness * (1 - p.felt) * (1 - 0.3 * soft),
299
363
  sr,
300
364
  ),
301
365
  );
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Sympathetic resonance for the modelled pianos (0.6.1, keys-electric
3
+ * lane): a per-track bank of 36 tuned feedback combs (C2..B4), fed by the
4
+ * dry sum, ringing with T60 4 s while the sustain pedal is down (all
5
+ * dampers up) and 0.06 s when it is up. Ported from the design prototype
6
+ * (proto/keys/keys.ts `Sympathetic`).
7
+ *
8
+ * Gating (zero cost otherwise): it runs only on a piano track with `sym`
9
+ * above 0 and at least one sustain pedal `down` or `half` event; between
10
+ * pedal-down spans it stops processing once its energy is below -90 dB.
11
+ * At 44.1 kHz and above it runs at half rate (pairs averaged in, output
12
+ * linearly interpolated), which keeps 48 kHz near the 22.05 kHz cost; the
13
+ * combs top out at B4 (494 Hz), far below the halved Nyquist.
14
+ */
15
+
16
+ import { hermite4 } from "../dsp/interp.ts";
17
+
18
+ const TAU = 2 * Math.PI;
19
+ const LOW = 36;
20
+ const HIGH = 71;
21
+ /** Energy below which the bank goes quiet while the pedal is up. */
22
+ const QUIET = 1e-9;
23
+
24
+ export class Sympathetic {
25
+ private readonly lines: Float64Array[] = [];
26
+ private readonly delay: number[] = [];
27
+ private readonly lp: number[] = [];
28
+ private readonly ws: number[] = [];
29
+ private readonly gUp: number[] = [];
30
+ private readonly gDown: number[] = [];
31
+ private readonly a: number;
32
+ /** Internal decimation: 1 at 22.05 kHz, 2 at 44.1/48 kHz. */
33
+ readonly factor: number;
34
+
35
+ constructor(
36
+ hzOf: (pitch: number) => number,
37
+ readonly amount: number,
38
+ sr: number,
39
+ ) {
40
+ this.factor = sr >= 40_000 ? 2 : 1;
41
+ const rate = sr / this.factor;
42
+ this.a = Math.exp((-TAU * 4000) / rate);
43
+ const lpDelay = this.a / (1 - this.a);
44
+ for (let q = LOW; q <= HIGH; q += 1) {
45
+ const hz = hzOf(q);
46
+ // An unmapped tuning degree has no string to ring.
47
+ if (!(hz > 0) || !Number.isFinite(hz)) continue;
48
+ const d = Math.max(2, rate / hz - lpDelay);
49
+ this.lines.push(new Float64Array(Math.ceil(d) + 8));
50
+ this.delay.push(d);
51
+ this.lp.push(0);
52
+ this.ws.push(0);
53
+ this.gDown.push(10 ** ((-3 * d) / (rate * 4)));
54
+ this.gUp.push(10 ** ((-3 * d) / (rate * 0.06)));
55
+ }
56
+ }
57
+
58
+ /** One internal-rate step: input `x`, returns [left, right] resonance. */
59
+ private step(x: number, pedal: boolean, out: [number, number]): void {
60
+ let yl = 0;
61
+ let yr = 0;
62
+ const a = this.a;
63
+ for (let k = 0; k < this.lines.length; k += 1) {
64
+ const line = this.lines[k]!;
65
+ const size = line.length;
66
+ const w = this.ws[k]!;
67
+ const pos = w - this.delay[k]!;
68
+ const i = Math.floor(pos);
69
+ const at = (j: number) => line[((j % size) + size) % size]!;
70
+ const y = hermite4(at(i - 1), at(i), at(i + 1), at(i + 2), pos - i);
71
+ const f = (this.lp[k] = y + a * (this.lp[k]! - y));
72
+ line[w % size] = x + (pedal ? this.gDown[k]! : this.gUp[k]!) * f;
73
+ this.ws[k] = (w + 1) % size;
74
+ if (k & 1) yr += f;
75
+ else yl += f;
76
+ }
77
+ out[0] = yl * 0.5;
78
+ out[1] = yr * 0.5;
79
+ }
80
+
81
+ /**
82
+ * In place: adds resonance to `left`/`right`. `down(i)` says whether the
83
+ * sustain pedal is down at sample `i` (read per sample pair at most).
84
+ */
85
+ process(
86
+ left: Float64Array,
87
+ right: Float64Array,
88
+ down: (sample: number) => boolean,
89
+ ): void {
90
+ const c = 0.02 * this.amount;
91
+ const out: [number, number] = [0, 0];
92
+ const f = this.factor;
93
+ let prevL = 0;
94
+ let prevR = 0;
95
+ let energy = 0;
96
+ for (let i = 0; i < left.length; i += f) {
97
+ const pedal = down(i);
98
+ let x = 0;
99
+ for (let j = 0; j < f && i + j < left.length; j += 1)
100
+ x += left[i + j]! + right[i + j]!;
101
+ x = (x / (2 * f)) * c;
102
+ // Dampers down and the combs quiet: skip them entirely.
103
+ if (!pedal && energy < QUIET) {
104
+ prevL = 0;
105
+ prevR = 0;
106
+ continue;
107
+ }
108
+ this.step(x, pedal, out);
109
+ energy = 0.999 * energy + 0.001 * (out[0] * out[0] + out[1] * out[1]);
110
+ if (f === 1) {
111
+ left[i]! += out[0];
112
+ right[i]! += out[1];
113
+ } else {
114
+ // Linear interpolation back to the full rate (one sample latency
115
+ // at half rate, inaudible on a 4 s resonance).
116
+ left[i]! += 0.5 * (prevL + out[0]);
117
+ right[i]! += 0.5 * (prevR + out[1]);
118
+ if (i + 1 < left.length) {
119
+ left[i + 1]! += out[0];
120
+ right[i + 1]! += out[1];
121
+ }
122
+ }
123
+ prevL = out[0];
124
+ prevR = out[1];
125
+ }
126
+ }
127
+ }
package/src/audio/kits.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * arithmetic on the seeded noise `wav.ts` passes in, so a kit renders the
8
8
  * same buffer on every run.
9
9
  */
10
- import type { DrumVoice } from "../../core/drums.ts";
10
+ import { type DrumVoice, isDrumInstrument } from "../../core/drums.ts";
11
11
  import {
12
12
  SYNTH_KITS,
13
13
  type DrumBodyParams,
@@ -48,9 +48,10 @@ function body(
48
48
  noise: number,
49
49
  state: KitVoiceState,
50
50
  sampleRate: number,
51
+ ratio = 1,
51
52
  ): number {
52
53
  const frequency =
53
- params.base + params.sweep * Math.exp(-t * params.sweepRate);
54
+ (params.base + params.sweep * Math.exp(-t * params.sweepRate)) * ratio;
54
55
  state.phase += frequency / sampleRate;
55
56
  return (
56
57
  Math.sin(2 * Math.PI * state.phase) *
@@ -60,19 +61,52 @@ function body(
60
61
  );
61
62
  }
62
63
 
64
+ /** PolyBLEP residual for a unit step at phase 0 (Välimäki). */
65
+ function polyBlep(phase: number, step: number): number {
66
+ if (phase < step) {
67
+ const x = phase / step;
68
+ return x + x - x * x - 1;
69
+ }
70
+ if (phase > 1 - step) {
71
+ const x = (phase - 1) / step;
72
+ return x * x + x + x + 1;
73
+ }
74
+ return 0;
75
+ }
76
+
77
+ /** A band-limited square at `hz`, `t` seconds in (closed-form phase). */
78
+ export function blepSquare(hz: number, t: number, sampleRate: number): number {
79
+ const phase = (t * hz) % 1;
80
+ const step = Math.min(0.5, hz / sampleRate);
81
+ return (
82
+ (phase < 0.5 ? 1 : -1) +
83
+ polyBlep(phase, step) -
84
+ polyBlep((phase + 0.5) % 1, step)
85
+ );
86
+ }
87
+
63
88
  function hat(
64
89
  params: HatParams,
65
90
  t: number,
66
91
  noise: number,
67
92
  bright: number,
68
93
  state: KitVoiceState,
94
+ sampleRate = 0,
69
95
  ): number {
70
96
  state.dark += 0.3 * (noise - state.dark);
71
97
  const noisy = params.tone * bright + (1 - params.tone) * state.dark * 0.8;
72
98
  let source = noisy;
73
99
  if (params.metal > 0) {
74
100
  let square = 0;
75
- for (const hz of METAL_HZ) square += (t * hz) % 1 < 0.5 ? 1 : -1;
101
+ // Calibrated (sampleRate > 0): band-limited squares, so the metal
102
+ // does not alias and sounds the same at every rate.
103
+ for (const hz of METAL_HZ)
104
+ square +=
105
+ sampleRate > 0
106
+ ? blepSquare(hz, t, sampleRate)
107
+ : (t * hz) % 1 < 0.5
108
+ ? 1
109
+ : -1;
76
110
  // First difference: keep the metallic top end, drop the low beating.
77
111
  const metal = (square / METAL_HZ.length - state.metalPrevious) * 1.6;
78
112
  state.metalPrevious = square / METAL_HZ.length;
@@ -90,10 +124,21 @@ export function kitDrumSample(
90
124
  bright: number,
91
125
  state: KitVoiceState,
92
126
  sampleRate: number,
127
+ /** Calibration 1+ (0.7): pitch ratio for toms, band-limited metal. */
128
+ calibrated?: Readonly<{ tomRatio: number }>,
93
129
  ): number {
94
130
  let sample: number;
131
+ const metalRate = calibrated ? sampleRate : 0;
95
132
  if (voice === "kick") sample = body(kit.kick, t, noise, state, sampleRate);
96
- else if (voice === "tom") sample = body(kit.tom, t, noise, state, sampleRate);
133
+ else if (voice === "tom")
134
+ sample = body(
135
+ kit.tom,
136
+ t,
137
+ noise,
138
+ state,
139
+ sampleRate,
140
+ calibrated?.tomRatio ?? 1,
141
+ );
97
142
  else if (voice === "snare") {
98
143
  const p = kit.snare;
99
144
  state.phase += p.tone / sampleRate;
@@ -112,9 +157,10 @@ export function kitDrumSample(
112
157
  state.band *
113
158
  p.level *
114
159
  (burst + 0.8 * Math.exp(-(t - span) * p.tail) * +(t >= span));
115
- } else if (voice === "hat") sample = hat(kit.hat, t, noise, bright, state);
160
+ } else if (voice === "hat")
161
+ sample = hat(kit.hat, t, noise, bright, state, metalRate);
116
162
  else if (voice === "openhat")
117
- sample = hat(kit.openhat, t, noise, bright, state);
163
+ sample = hat(kit.openhat, t, noise, bright, state, metalRate);
118
164
  else {
119
165
  const p = kit.rim;
120
166
  sample =
@@ -142,9 +188,92 @@ export function kitDrumSample(
142
188
  return sample * kit.gain;
143
189
  }
144
190
 
191
+ // ── calibrated GM extras (0.7) ─────────────────────────────────────────
192
+
193
+ /** GM cymbal and bell numbers that calibration 1+ plays as metal. */
194
+ export type MetalKind = "crash" | "ride" | "cowbell";
195
+
196
+ const METAL_PITCHES: Readonly<Record<number, MetalKind>> = Object.freeze({
197
+ 49: "crash",
198
+ 52: "crash",
199
+ 55: "crash",
200
+ 57: "crash",
201
+ 51: "ride",
202
+ 53: "ride",
203
+ 59: "ride",
204
+ 56: "cowbell",
205
+ });
206
+
207
+ /** The metal voice for a GM pitch, or undefined for every other number. */
208
+ export function metalKindForPitch(pitch: number): MetalKind | undefined {
209
+ return METAL_PITCHES[pitch];
210
+ }
211
+
212
+ /**
213
+ * GM tom pitch ratio: 45 (low tom) is the voice's own tuning, and each GM
214
+ * semitone moves the body by two thirds of a semitone, so 41 (low floor)
215
+ * through 50 (high tom) spread across about half an octave.
216
+ */
217
+ export function tomRatio(pitch: number): number {
218
+ const clamped = Math.max(35, Math.min(55, pitch));
219
+ return 2 ** (((clamped - 45) * 2) / 3 / 12);
220
+ }
221
+
222
+ /**
223
+ * One sample of a crash, ride or cowbell, `t` seconds after the hit.
224
+ * Crash and ride are band-limited inharmonic squares through a first
225
+ * difference plus bright noise; the cowbell is the classic 540/800 Hz
226
+ * square pair. Deterministic: closed-form phase and the caller's noise.
227
+ */
228
+ export function metalSample(
229
+ kind: MetalKind,
230
+ t: number,
231
+ bright: number,
232
+ state: KitVoiceState,
233
+ sampleRate: number,
234
+ ): number {
235
+ if (kind === "cowbell") {
236
+ const tone =
237
+ (blepSquare(540, t, sampleRate) + blepSquare(800, t, sampleRate)) / 2;
238
+ state.band += 0.45 * (tone - state.band);
239
+ const envelope = 0.7 * Math.exp(-t * 60) + 0.3 * Math.exp(-t * 9);
240
+ return state.band * 0.5 * envelope;
241
+ }
242
+ let square = 0;
243
+ for (const hz of METAL_HZ) square += blepSquare(hz * 1.9, t, sampleRate);
244
+ const metal = (square / METAL_HZ.length - state.metalPrevious) * 1.6;
245
+ state.metalPrevious = square / METAL_HZ.length;
246
+ if (kind === "crash") {
247
+ const swell = Math.min(1, t / 0.004);
248
+ return (0.55 * bright + 0.45 * metal) * 0.42 * swell * Math.exp(-t * 4.2);
249
+ }
250
+ // Ride: a pinged bell over a long, quieter wash.
251
+ const ping = (0.6 * metal + 0.4 * bright) * 0.3 * Math.exp(-t * 28);
252
+ const wash = (0.35 * metal + 0.65 * bright) * 0.16 * Math.exp(-t * 3.5);
253
+ return ping + wash;
254
+ }
255
+
256
+ /** Length of a calibrated crash, ride or cowbell one-shot. */
257
+ export const METAL_SECONDS = 1.4;
258
+
145
259
  /** Longest synth-kit one-shot in the score; 0 without kits (so renders without kits are unchanged). */
146
260
  export function kitTailSeconds(score: TrackScore): number {
147
261
  let seconds = 0;
262
+ if ((score.calibration ?? 0) >= 1) {
263
+ const drums = new Set(
264
+ score.tracks
265
+ .filter((track) => isDrumInstrument(track.instrument))
266
+ .map((track) => track.id),
267
+ );
268
+ if (
269
+ score.notes.some(
270
+ (note) =>
271
+ drums.has(note.trackId) &&
272
+ metalKindForPitch(note.pitch) !== undefined,
273
+ )
274
+ )
275
+ seconds = METAL_SECONDS;
276
+ }
148
277
  for (const track of score.tracks)
149
278
  seconds = Math.max(seconds, synthKit(track.kit)?.seconds ?? 0);
150
279
  return seconds;
@@ -18,6 +18,7 @@ export type LiveFullRequest = Readonly<{
18
18
  velocity: number;
19
19
  seconds: number;
20
20
  tick?: number;
21
+ clock?: number;
21
22
  samples?: SampleBank;
22
23
  }>;
23
24
 
@@ -32,7 +33,7 @@ declare const self: Worker;
32
33
  let synth: LiveSynth | undefined;
33
34
 
34
35
  self.onmessage = (event: MessageEvent<LiveFullRequest>) => {
35
- const { id, sampleRate, score, samples, tick, ...note } = event.data;
36
+ const { id, sampleRate, score, samples, tick, clock, ...note } = event.data;
36
37
  try {
37
38
  if (synth?.rate !== sampleRate) synth = new LiveSynth(sampleRate);
38
39
  const pcm = synth.render({
@@ -41,6 +42,7 @@ self.onmessage = (event: MessageEvent<LiveFullRequest>) => {
41
42
  full: true,
42
43
  ...(samples ? { samples } : {}),
43
44
  ...(tick === undefined ? {} : { tick }),
45
+ ...(clock === undefined ? {} : { clock }),
44
46
  });
45
47
  const reply: LiveFullReply = pcm ? { id, pcm } : { id };
46
48
  self.postMessage(reply);