@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
@@ -7,14 +7,17 @@
7
7
  * render takes exactly today's code path.
8
8
  */
9
9
  import type { PerformedNote } from "../../core/expression.ts";
10
- import type { Track } from "../../core/score.ts";
10
+ import type { Track, TrackScore } from "../../core/score.ts";
11
11
  import type { TuningTable } from "../../core/tuning.ts";
12
+ import { WIND_ENGINE } from "./winds/engine.ts";
13
+ import { SING_ENGINE } from "./sing/engine.ts";
12
14
  import { MODAL_ENGINE } from "./resonators.ts";
13
15
  import type { SampleBank } from "./samples.ts";
14
16
  import type { RenderContext } from "./wav.ts";
15
17
  import { STRING_ENGINE } from "./strings/engine.ts";
16
18
  import { GRANULAR_ENGINE } from "./granular.ts";
17
19
  import { KEYS_ENGINES } from "./keys/engine.ts";
20
+ import { VOCODER_ENGINE } from "./vocoder/index.ts";
18
21
 
19
22
  /** What an engine renders with: the track's render context plus its tuning. */
20
23
  export type EngineContext = RenderContext &
@@ -24,6 +27,9 @@ export type EngineContext = RenderContext &
24
27
  tuning?: TuningTable;
25
28
  }>;
26
29
 
30
+ /** Fade at a ring-out cap (live note length, voice cap, loop fold), in seconds. */
31
+ export const RING_OUT_FADE_SECONDS = 0.25;
32
+
27
33
  export type InstrumentEngine = Readonly<{
28
34
  /** The `Track.instrument` value this engine plays. */
29
35
  id: string;
@@ -52,10 +58,34 @@ export type InstrumentEngine = Readonly<{
52
58
  * (a piano's damper); absent uses `tailSeconds`.
53
59
  */
54
60
  releaseSeconds?(track: Track, pitch: number, hz: number): number;
61
+ /**
62
+ * True when a released live key keeps ringing to the end of its rendered
63
+ * tail (an undamped bar or gong) instead of fading at note-off.
64
+ */
65
+ ringOut?(track: Track): boolean;
55
66
  /** True when the engine writes a separate right channel. */
56
67
  stereo(track: Track): boolean;
57
- /** Digests of any assets the engine reads, joined to the stem cache key. */
58
- assetDigests?(track: Track, bank: SampleBank): readonly string[];
68
+ /**
69
+ * State the engine carries into an arranged window that its pre-roll
70
+ * cannot rebuild (an organ's rotors along a lane), as a string: `history`
71
+ * is the song before the window and `frames` its length in samples.
72
+ * Arranged renders hand it back as `context.seedState[track.id]`.
73
+ */
74
+ windowSeed?(
75
+ history: TrackScore,
76
+ track: Track,
77
+ frames: number,
78
+ sampleRate: number,
79
+ ): string | undefined;
80
+ /**
81
+ * Digests of any assets the engine reads, joined to the stem cache key.
82
+ * `score` is the song, for engines that read other tracks' notes.
83
+ */
84
+ assetDigests?(
85
+ track: Track,
86
+ bank: SampleBank,
87
+ score?: TrackScore,
88
+ ): readonly string[];
59
89
  }>;
60
90
 
61
91
  const engines = new Map<string, InstrumentEngine>();
@@ -109,3 +139,6 @@ registerEngine(STRING_ENGINE);
109
139
  registerEngine(GRANULAR_ENGINE);
110
140
  for (const engine of KEYS_ENGINES) registerEngine(engine);
111
141
  registerEngine(MODAL_ENGINE);
142
+ registerEngine(WIND_ENGINE);
143
+ registerEngine(SING_ENGINE);
144
+ registerEngine(VOCODER_ENGINE);
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Calibration 1 (q08): per-preset, key-tracked level trims for the keys
3
+ * engine and the harpsichord. The modelled pianos, electric pianos and
4
+ * clavs were levelled one family at a time, so at velocity 0.8 they sat 2
5
+ * to 40 dB under the `piano` synth and fell steeply up the keyboard (the
6
+ * hammer and tangent pulses filter the upper keys' partials hard). Each
7
+ * row is the gain in dB, fitted from renders, that brings one note at
8
+ * velocity 0.8 to the `piano` reference (-18 LUFS momentary maximum), at
9
+ * keys 24, 36, ... 108, capped so the note's sample peak stays at or under
10
+ * -4 dBFS; the top keys' hammer click is all crest, so there the cap wins
11
+ * and they sit a few dB under. Between keys the trim is interpolated in
12
+ * dB, and outside it holds the end value. Trims are linear, so velocity
13
+ * response is kept. Legacy songs (no calibration) never read this table.
14
+ *
15
+ * Pinned: never edit a row; a later revision appends a new table.
16
+ */
17
+ export const KEYS_TRIM_KEYS = Object.freeze([
18
+ 24, 36, 48, 60, 72, 84, 96, 108,
19
+ ] as const);
20
+
21
+ /** Highest boost at any key, so the top octave is not pulled out of noise. */
22
+ export const KEYS_TRIM_MAX_DB = 40;
23
+
24
+ export const KEYS_TRIM_DB_1: Readonly<Record<string, readonly number[]>> =
25
+ Object.freeze({
26
+ harpsichord: [4.1, 7.0, 9.9, 13.1, 16.2, 18.6, 18.2, 17.5],
27
+ grand: [-1.4, -1.5, -0.6, 0.8, 3.8, 4.8, 5.5, 8.3],
28
+ ballad: [-4.1, -4.7, -3.4, -2.9, -0.5, 3.1, 5.4, 8.2],
29
+ upright: [1.4, 0.4, 0.5, 2.2, 4.0, 4.2, 4.8, 8.7],
30
+ felt: [3.9, 4.1, 4.5, 5.2, 6.6, 8.9, 10.6, 15.4],
31
+ lofi: [4.1, 4.1, 4.3, 5.7, 7.8, 8.2, 9.2, 15.0],
32
+ honkytonk: [1.2, 2.9, 3.2, 4.1, 4.0, 3.5, 3.6, 6.2],
33
+ prepared: [2.3, -1.5, 1.2, 5.7, 2.0, 6.2, 4.5, 7.6],
34
+ epiano: [6.0, 5.2, 4.8, 5.3, 7.3, 7.6, 6.5, 8.5],
35
+ suitcase: [7.6, 6.3, 5.6, 5.9, 7.7, 7.9, 6.6, 8.5],
36
+ dyno: [3.0, 4.1, 4.5, 5.1, 6.6, 7.5, 6.2, 7.7],
37
+ wurli: [9.6, 6.1, 5.3, 6.0, 7.8, 6.1, 7.6, 11.1],
38
+ clav: [8.1, 8.8, 8.9, 10.4, 11.2, 10.8, 8.5, 2.7],
39
+ funkclav: [7.1, 7.9, 9.0, 9.1, 9.0, 5.1, 7.8, 5.9],
40
+ });
41
+
42
+ /** Linear gain for a preset at a key under calibration 1. */
43
+ export function keysTrim(preset: string, key: number): number {
44
+ const row = KEYS_TRIM_DB_1[preset];
45
+ if (!row || row.length !== KEYS_TRIM_KEYS.length) return 1;
46
+ const keys = KEYS_TRIM_KEYS;
47
+ let db: number;
48
+ if (key <= keys[0]) db = row[0]!;
49
+ else if (key >= keys[keys.length - 1]!) db = row[row.length - 1]!;
50
+ else {
51
+ const i = Math.min(keys.length - 2, Math.floor((key - keys[0]) / 12));
52
+ const t = (key - keys[i]!) / 12;
53
+ db = row[i]! + (row[i + 1]! - row[i]!) * t;
54
+ }
55
+ return 10 ** (Math.min(KEYS_TRIM_MAX_DB, db) / 20);
56
+ }
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Electric keys (0.6.1 keys-electric lane): the Rhodes-style tine piano
3
+ * (`epiano`), the Wurlitzer-style reed piano (`wurli`) and the clavinet
4
+ * (`clav`), ported from the reviewed prototype
5
+ * (`.plans/dawg-06/proto/keys/keys.ts`, `TineVoice` and `ClavVoice`) with
6
+ * the review's round-2 fixes: the pickups run at twice the sample rate
7
+ * (band-limited both ways, so a hard-driven tine does not alias), the
8
+ * epiano carries its own suitcase stereo vibrato (`vibe`), the wurli its
9
+ * tremolo (`trem`), and clav pickup positions vary per track (seeded).
10
+ *
11
+ * Tine: a clamped-free cantilever, three modes at 1, 6.267 and 17.547 times
12
+ * the fundamental plus the tone bar, read through an electromagnetic pickup
13
+ * whose flux falls off with the tine's distance from the pole piece
14
+ * (1 / (1 + d^2)), differentiated (Faraday). After Shear and Wright, "The
15
+ * Electromagnetically Sustained Rhodes Piano" (NIME 2011) and Gabrielli et
16
+ * al., "Physical modeling of the Rhodes piano" (DAFx-10). Reed: the
17
+ * electrostatic pickup of the Wurlitzer 200 reads capacitance, 1 / (1 - k x),
18
+ * not a derivative; a peak at 1.1 kHz gives the nasal case. Clav: modal
19
+ * string struck near its end by a tangent, two pickup combs sin(n pi p) at
20
+ * neck and bridge, the mute slider damping upper partials and a small
21
+ * negative re-pluck when the string leaves the anvil (Gabrielli, Välimäki,
22
+ * Penttinen, Squartini and Bilbao, EURASIP JASP 2013:103).
23
+ */
24
+ import { DcBlock, OnePole } from "../dsp/filters.ts";
25
+ import { Oversample2x } from "../dsp/oversample.ts";
26
+ import { unit } from "../dsp/rng.ts";
27
+ import {
28
+ Biquad2,
29
+ Burst,
30
+ clamp,
31
+ hammerPulse,
32
+ lerp,
33
+ LN1000,
34
+ ModeBank,
35
+ panGains,
36
+ pulseMag,
37
+ TAU,
38
+ } from "./dsp.ts";
39
+
40
+ /** Resolved electric voice parameters (core/keys.ts `KEYS_PARAMS`). */
41
+ export type ElectricParams = Readonly<{
42
+ kind: "epiano" | "wurli" | "clav";
43
+ hardness: number;
44
+ touch: number;
45
+ decay: number;
46
+ release: number;
47
+ width: number;
48
+ bark: number;
49
+ bell: number;
50
+ /** Output low-pass in Hz; 0 is off. */
51
+ tone: number;
52
+ pickup: "neck" | "bridge" | "both" | "out";
53
+ mute: number;
54
+ }>;
55
+
56
+ export type ElectricNoteOn = Readonly<{
57
+ pitch: number;
58
+ hz: number;
59
+ velocity: number;
60
+ /** Track seed hash (clav pickup positions are per track). */
61
+ trackSeed: number;
62
+ noteSeed: string;
63
+ /** Song calibration (q08); 1+ keeps the tine hammer under the period. */
64
+ calibration?: number;
65
+ }>;
66
+
67
+ /** Clamped-free cantilever mode ratios. */
68
+ const BEAM = [1, 6.267, 17.547] as const;
69
+ /** Rest position of the tine relative to the pickup pole. */
70
+ const X0 = 0.4;
71
+
72
+ /** The voice interface the keys engine drives (shared with `PianoVoice`). */
73
+ export type KeysVoice = {
74
+ readonly gl: number;
75
+ readonly gr: number;
76
+ readonly released: boolean;
77
+ noteOff(): void;
78
+ setCents(cents: number): void;
79
+ damp(tau: number): void;
80
+ process(out: Float64Array, offset: number, count: number): boolean;
81
+ };
82
+
83
+ /** Seconds a released tine or reed takes to fall 60 dB. */
84
+ export function tineReleaseT60(pitch: number, release: number): number {
85
+ return release * (0.09 + 0.25 * clamp((60 - pitch) / 32, 0, 1));
86
+ }
87
+
88
+ /** Seconds a released clav string takes to fall 60 dB (the yarn damper). */
89
+ export function clavReleaseT60(release: number): number {
90
+ return release * 0.06;
91
+ }
92
+
93
+ /** Tine (epiano) and reed (wurli) voice. */
94
+ export class TineVoice implements KeysVoice {
95
+ private readonly bank: ModeBank;
96
+ private readonly pulse: Float64Array;
97
+ private pos = 0;
98
+ private readonly over = new Oversample2x();
99
+ private readonly dc: DcBlock;
100
+ private readonly lp: OnePole | undefined;
101
+ private readonly peak: Biquad2 | undefined;
102
+ private readonly norm: number;
103
+ readonly gl: number;
104
+ readonly gr: number;
105
+ private tmp = new Float64Array(0);
106
+ private readonly bursts: Burst[] = [];
107
+ private readonly rhodes: boolean;
108
+ /** Pickup state at the doubled rate. */
109
+ private prevX = 0;
110
+ private prevPhi = 0;
111
+ private releasedFlag = false;
112
+ private age = 0;
113
+ private readonly shape: (x: number) => number;
114
+
115
+ constructor(
116
+ private readonly note: ElectricNoteOn,
117
+ private readonly p: ElectricParams,
118
+ private readonly sr: number,
119
+ ) {
120
+ const v = clamp(lerp(0.8, note.velocity, p.touch), 0.01, 1);
121
+ const key = note.pitch;
122
+ const f = note.hz;
123
+ const rhodes = p.kind === "epiano";
124
+ this.rhodes = rhodes;
125
+ const T = rhodes
126
+ ? p.decay * 9 * 2 ** (-(key - 36) / 26)
127
+ : p.decay * 4.5 * 2 ** (-(key - 40) / 26);
128
+ const bell =
129
+ p.bell *
130
+ (rhodes ? 0.04 + 0.22 * v * v : 0.02 + 0.08 * v * v) *
131
+ 2 ** (-(key - 60) / 30);
132
+ const drive = lerp(0.35, rhodes ? 1.6 : 1.5, p.bark);
133
+ const A = (0.15 + 0.85 * v) * drive;
134
+ // Hardness scales the hammer contact (0.5 is the prototype's).
135
+ const legacy = lerp(2.2, 0.9, v) * (1.4 - 0.8 * p.hardness);
136
+ // Calibration 1: the contact stays under 0.6 of the tine's period, so
137
+ // the pulse's first null never lands under the fundamental (the top
138
+ // octave fell 35 dB).
139
+ const contact =
140
+ (note.calibration ?? 0) >= 1 ? Math.min(legacy, 600 / f) : legacy;
141
+ this.pulse = hammerPulse(Math.max(2, Math.round(contact * 1e-3 * sr)), 1.5);
142
+ this.bank = new ModeBank(4);
143
+ this.bank.add(f, T, A * 0.85, 1, sr);
144
+ // Tone bar: a slightly sharp, longer partner (the Rhodes tuning fork).
145
+ if (rhodes) this.bank.add(f * 2 ** (0.4 / 1200), T * 1.3, A * 0.15, 1, sr);
146
+ this.bank.add(f * BEAM[1], 0.35 * 2 ** (-(key - 60) / 24), A * bell, 1, sr);
147
+ if (rhodes) this.bank.add(f * BEAM[2], 0.06, A * bell * 0.3 * v, 1, sr);
148
+ // Normalise by the pickup's small-signal slope at the doubled rate. The
149
+ // tine's 0.36 puts a velocity-0.8 C-major triad near -6.5 dBFS, the
150
+ // house level (the reed's 0.32 already sits there).
151
+ const w = (TAU * f) / (2 * sr);
152
+ this.norm =
153
+ (rhodes ? 0.36 : 0.32) /
154
+ ((rhodes ? (2 * X0) / (1 + X0 * X0) ** 2 : 0.5) * w);
155
+ this.prevPhi = this.pickup(0);
156
+ this.dc = new DcBlock(sr, 8);
157
+ this.lp = p.tone > 0 ? new OnePole(p.tone, sr) : undefined;
158
+ this.peak = rhodes
159
+ ? undefined
160
+ : new Biquad2().set("peak", 1100, 0.9, 4, sr);
161
+ [this.gl, this.gr] = panGains(p.width * clamp((key - 60) / 30, -1, 1));
162
+ this.bursts.push(
163
+ new Burst(
164
+ `${note.noteSeed}:tine`,
165
+ rhodes ? 1800 : 1200,
166
+ 1.2,
167
+ 0.002,
168
+ 0.01 * v * v,
169
+ sr,
170
+ ),
171
+ );
172
+ // The pickup at 2x: linear interpolation of the modal output between
173
+ // base-rate samples is done by the half-band interpolator; this runs on
174
+ // each doubled-rate sample in order, so it may keep state.
175
+ this.shape = (x: number) => {
176
+ this.prevX = x;
177
+ const phi = this.pickup(x);
178
+ const dy = rhodes ? -(phi - this.prevPhi) : phi - this.prevPhi;
179
+ this.prevPhi = phi;
180
+ const y = dy * this.norm;
181
+ return rhodes ? y : Math.tanh(1.3 * y) / 1.3;
182
+ };
183
+ }
184
+
185
+ private pickup(x: number): number {
186
+ if (this.rhodes) {
187
+ const d = x - X0;
188
+ return 1 / (1 + d * d);
189
+ }
190
+ return 1 / (1 - 0.5 * Math.min(x, 1.6));
191
+ }
192
+
193
+ get released(): boolean {
194
+ return this.releasedFlag;
195
+ }
196
+
197
+ noteOff(): void {
198
+ if (this.releasedFlag) return;
199
+ this.releasedFlag = true;
200
+ const t = tineReleaseT60(this.note.pitch, this.p.release);
201
+ this.bank.damp(() => t, this.sr);
202
+ this.bursts.push(
203
+ new Burst(
204
+ `${this.note.noteSeed}:damper`,
205
+ 180,
206
+ 1,
207
+ 0.02,
208
+ 0.006 * this.note.velocity,
209
+ this.sr,
210
+ ),
211
+ );
212
+ }
213
+
214
+ setCents(cents: number): void {
215
+ this.bank.retune(2 ** (cents / 1200));
216
+ }
217
+
218
+ damp(tau: number): void {
219
+ this.bank.damp(() => LN1000 * tau, this.sr);
220
+ }
221
+
222
+ process(out: Float64Array, offset: number, count: number): boolean {
223
+ if (this.tmp.length < count) this.tmp = new Float64Array(count);
224
+ const tmp = this.tmp;
225
+ tmp.fill(0, 0, count);
226
+ let done = 0;
227
+ if (this.pos < this.pulse.length) {
228
+ done = Math.min(count, this.pulse.length - this.pos);
229
+ this.bank.run(tmp, 0, done, this.pulse, this.pos);
230
+ this.pos += done;
231
+ }
232
+ if (done < count) this.bank.run(tmp, done, count - done, null, 0);
233
+ for (let i = 0; i < count; i += 1) {
234
+ let y = this.over.process(tmp[i]!, this.shape);
235
+ y = this.dc.process(y);
236
+ if (this.peak) y = this.peak.process(y);
237
+ if (this.lp) y = this.lp.process(y);
238
+ for (const b of this.bursts) if (!b.done) y += b.next();
239
+ out[offset + i]! += y;
240
+ }
241
+ this.age += count;
242
+ if (this.age % 256 < count) this.bank.cull(1e-6);
243
+ // The oversampler and DC blocker hold a short tail after the modes go.
244
+ return (
245
+ this.bank.n > 0 ||
246
+ this.bursts.some((b) => !b.done) ||
247
+ Math.abs(this.prevX) > 1e-9
248
+ );
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Clav pickup positions (fractions of the string from the bridge end), with
254
+ * a seeded per-track spread of +/-6%: two clavinets never sound identical.
255
+ */
256
+ export function clavPositions(trackSeed: number): {
257
+ neck: number;
258
+ bridge: number;
259
+ tangent: number;
260
+ } {
261
+ const spread = (slot: number) => 1 + 0.12 * (unit(trackSeed, 0, slot) - 0.5);
262
+ return {
263
+ neck: 0.19 * spread(0),
264
+ bridge: 0.065 * spread(1),
265
+ tangent: 0.04 * spread(2),
266
+ };
267
+ }
268
+
269
+ export class ClavVoice implements KeysVoice {
270
+ private readonly bank: ModeBank;
271
+ private pulse: Float64Array;
272
+ private pos = 0;
273
+ private readonly lp: OnePole | undefined;
274
+ private readonly hp: Biquad2;
275
+ readonly gl: number;
276
+ readonly gr: number;
277
+ private tmp = new Float64Array(0);
278
+ private releasedFlag = false;
279
+ private age = 0;
280
+
281
+ constructor(
282
+ note: ElectricNoteOn,
283
+ private readonly p: ElectricParams,
284
+ private readonly sr: number,
285
+ ) {
286
+ const v = clamp(lerp(0.8, note.velocity, p.touch), 0.01, 1);
287
+ const key = note.pitch;
288
+ const B = 1.5e-4 * 2 ** ((key - 48) / 16);
289
+ // Partial 1 sits exactly at the tuned pitch.
290
+ const f0 = note.hz / Math.sqrt(1 + B);
291
+ const T = p.decay * 5 * 2 ** (-(key - 40) / 24);
292
+ const { neck, bridge, tangent } = clavPositions(note.trackSeed);
293
+ const contact = lerp(0.6, 0.25, v) * (1.4 - 0.8 * p.hardness);
294
+ this.pulse = hammerPulse(Math.max(2, Math.round(contact * 1e-3 * sr)), 1.2);
295
+ const bank = new ModeBank(80);
296
+ const modes: [number, number, number][] = [];
297
+ let energy = 0;
298
+ for (let n = 1; n <= 80; n += 1) {
299
+ const fn = n * f0 * Math.sqrt(1 + B * n * n);
300
+ if (fn >= 0.45 * sr) break;
301
+ const a = Math.sin(n * Math.PI * neck);
302
+ const b = Math.sin(n * Math.PI * bridge);
303
+ const w =
304
+ p.pickup === "neck"
305
+ ? a
306
+ : p.pickup === "bridge"
307
+ ? b * 2
308
+ : p.pickup === "out"
309
+ ? a - b
310
+ : (a + b) * 0.7;
311
+ const u = Math.sin(n * Math.PI * tangent) * w;
312
+ const t60 =
313
+ (T / (1 + (fn / 3000) ** 2)) *
314
+ (1 - 0.85 * p.mute * Math.min(1, (n - 1) / 4));
315
+ modes.push([fn, t60, u]);
316
+ // Level is set on what the pickup hears through the tangent's
317
+ // contact spectrum, so every pickup switch sits within about 1.5 dB
318
+ // of the others (a C-major triad at velocity 0.8 near -6 dBFS).
319
+ energy += (u * pulseMag(this.pulse, (2 * Math.PI * fn) / sr)) ** 2;
320
+ }
321
+ // Out of phase leaves a peakier, thinner sum: +1.6 dB brings its peak
322
+ // level to the other pickups' (measured at C3, velocity 0.8).
323
+ const trim = p.pickup === "out" ? 1.2 : 1;
324
+ const G = (0.146 * trim * v ** 1.2) / Math.sqrt(Math.max(1e-12, energy));
325
+ for (const [fn, t60, u] of modes)
326
+ bank.add(fn, Math.max(0.02, t60), u * G, 1, sr);
327
+ this.bank = bank;
328
+ this.lp = p.tone > 0 ? new OnePole(p.tone, sr) : undefined;
329
+ this.hp = new Biquad2().set("hpf", 70, 0.7, 0, sr);
330
+ [this.gl, this.gr] = panGains(p.width * clamp((key - 60) / 30, -1, 1));
331
+ }
332
+
333
+ get released(): boolean {
334
+ return this.releasedFlag;
335
+ }
336
+
337
+ noteOff(): void {
338
+ if (this.releasedFlag) return;
339
+ this.releasedFlag = true;
340
+ // The string leaves the anvil: a small negative pluck, then the yarn damps it.
341
+ const plunk = hammerPulse(Math.max(2, Math.round(0.3e-3 * this.sr)), 1);
342
+ for (let i = 0; i < plunk.length; i += 1) plunk[i]! *= -0.35;
343
+ this.pulse = plunk;
344
+ this.pos = 0;
345
+ const t = clavReleaseT60(this.p.release);
346
+ this.bank.damp(() => t, this.sr);
347
+ }
348
+
349
+ setCents(cents: number): void {
350
+ this.bank.retune(2 ** (cents / 1200));
351
+ }
352
+
353
+ damp(tau: number): void {
354
+ this.bank.damp(() => LN1000 * tau, this.sr);
355
+ }
356
+
357
+ process(out: Float64Array, offset: number, count: number): boolean {
358
+ if (this.tmp.length < count) this.tmp = new Float64Array(count);
359
+ const tmp = this.tmp;
360
+ tmp.fill(0, 0, count);
361
+ let done = 0;
362
+ if (this.pos < this.pulse.length) {
363
+ done = Math.min(count, this.pulse.length - this.pos);
364
+ this.bank.run(tmp, 0, done, this.pulse, this.pos);
365
+ this.pos += done;
366
+ }
367
+ if (done < count) this.bank.run(tmp, done, count - done, null, 0);
368
+ for (let i = 0; i < count; i += 1) {
369
+ let y = this.hp.process(tmp[i]!);
370
+ if (this.lp) y = this.lp.process(y);
371
+ out[offset + i]! += y;
372
+ }
373
+ this.age += count;
374
+ if (this.age % 256 < count) this.bank.cull(1e-6);
375
+ return this.bank.n > 0;
376
+ }
377
+ }
378
+
379
+ /** A voice for an electric family. */
380
+ export function electricVoice(
381
+ note: ElectricNoteOn,
382
+ p: ElectricParams,
383
+ sr: number,
384
+ ): KeysVoice {
385
+ return p.kind === "clav"
386
+ ? new ClavVoice(note, p, sr)
387
+ : new TineVoice(note, p, sr);
388
+ }
389
+
390
+ /**
391
+ * The electric post stage, run on the summed track: the epiano's suitcase
392
+ * stereo vibrato (antiphase left/right gain, `vibe` depth at `vibehz`) and
393
+ * the wurli's tremolo (`trem` depth at 5.6 Hz, both channels). The phase is
394
+ * song time (`startSample` of the buffer at the song's sample 0), so a loop
395
+ * and a full render agree. `depthAt(sample)` reads the lane.
396
+ */
397
+ export function electricPost(
398
+ left: Float64Array,
399
+ right: Float64Array,
400
+ kind: ElectricParams["kind"],
401
+ rateHz: number,
402
+ depthAt: (sample: number) => number,
403
+ sr: number,
404
+ startSample = 0,
405
+ ): void {
406
+ if (kind === "clav") return;
407
+ const block = 32;
408
+ for (let i = 0; i < left.length; i += block) {
409
+ const depth = clamp(depthAt(i), 0, 1);
410
+ const end = Math.min(left.length, i + block);
411
+ if (depth <= 0) continue;
412
+ for (let s = i; s < end; s += 1) {
413
+ const lfo = Math.sin((TAU * rateHz * (s + startSample)) / sr);
414
+ if (kind === "epiano") {
415
+ // Constant-power autopan between the two speakers of the suitcase.
416
+ const pan = depth * lfo;
417
+ const a = ((pan + 1) * Math.PI) / 4;
418
+ left[s]! *= Math.SQRT2 * Math.cos(a);
419
+ right[s]! *= Math.SQRT2 * Math.sin(a);
420
+ } else {
421
+ const gain = 1 - 0.5 * depth * (1 - lfo);
422
+ left[s]! *= gain;
423
+ right[s]! *= gain;
424
+ }
425
+ }
426
+ }
427
+ }