@hraness/dawg 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
@@ -6,10 +6,12 @@
6
6
  * playing a test tone silently.
7
7
  */
8
8
 
9
+ import { isGuideInstrument } from "../../core/clips.ts";
9
10
  import { isDrumInstrument } from "../../core/drums.ts";
10
11
  import { INSTRUMENT_WORDS, LEGACY_WORDS } from "../../core/instruments.ts";
11
12
  import { KEYS_FAMILIES } from "../../core/keys.ts";
12
13
  import { isSamplerInstrument, type Track } from "../../core/score.ts";
14
+ import { nearest } from "../commands/nearest.ts";
13
15
  import { engineFor, registeredEngines } from "./instruments.ts";
14
16
  import { resolveOscillator } from "./synth/oscillators.ts";
15
17
  import { AVAILABLE_INSTRUMENTS } from "./wav.ts";
@@ -41,6 +43,24 @@ function knownWords(): readonly string[] {
41
43
  ];
42
44
  }
43
45
 
46
+ /**
47
+ * `instrument list` rows: every exact instrument word, sorted, wrapped to
48
+ * `width` columns.
49
+ */
50
+ export function instrumentListLines(width = 72): string[] {
51
+ const words = [...new Set(["sine", ...knownWords()])].sort();
52
+ const lines: string[] = [];
53
+ let line = "";
54
+ for (const word of words) {
55
+ if (line && line.length + 1 + word.length > width) {
56
+ lines.push(line);
57
+ line = word;
58
+ } else line = line ? `${line} ${word}` : word;
59
+ }
60
+ if (line) lines.push(line);
61
+ return lines;
62
+ }
63
+
44
64
  /** Whether `instrument` (on `track`) renders as the plain sine fallback. */
45
65
  export function playsPlainSine(
46
66
  instrument: string,
@@ -54,6 +74,7 @@ export function playsPlainSine(
54
74
  if ((KEYS_FAMILIES as readonly string[]).includes(name)) return false;
55
75
  if (isDrumInstrument(name) || isSamplerInstrument(name)) return false;
56
76
  if (name === "wavetable" || resolveOscillator(name)) return false;
77
+ if (isGuideInstrument(name)) return false;
57
78
  return !LEGACY_TONES.some((tone) => name.includes(tone));
58
79
  }
59
80
 
@@ -73,22 +94,52 @@ export function plainSineAdvice(
73
94
  const advice = LEGACY_ADVICE[name];
74
95
  return `"${name}" is dawg's plain sine (kept for old projects)${advice ? ` · ${advice}` : ""}`;
75
96
  }
76
- const near = nearestWord(name, knownWords());
77
97
  // A resolver word stored raw (an older project): the word itself now
78
98
  // picks a voice when typed again.
79
- if (near === name)
99
+ if (knownWords().includes(name))
80
100
  return `"${name}" is stored as a bare word and plays a plain sine · type instrument ${name} again for its voice`;
101
+ const near = nearest(name, knownWords());
81
102
  return `"${name}" is not a dawg instrument and plays a plain sine${near ? ` · did you mean ${near}?` : ""}`;
82
103
  }
83
104
 
84
- /** Whether `word` is neither a known instrument nor a legacy word. */
105
+ /**
106
+ * Whether `word` names an instrument exactly: an instrument word, preset,
107
+ * engine, keys family, oscillator, drum, sampler or guide word, or a legacy
108
+ * tone. The write path (`instrument <word>`) refuses anything else, so a typo
109
+ * such as `sawtoth` is never stored. Stored projects are not checked here:
110
+ * the read and render paths keep playing whatever they hold.
111
+ */
112
+ export function isExactInstrument(word: string): boolean {
113
+ const name = word.trim().toLowerCase();
114
+ if (name === "") return false;
115
+ if (
116
+ name === "sine" ||
117
+ LEGACY_TONES.includes(name) ||
118
+ LEGACY_WORDS.includes(name) ||
119
+ knownWords().includes(name)
120
+ )
121
+ return true;
122
+ if (registeredEngines().includes(name)) return true;
123
+ if ((KEYS_FAMILIES as readonly string[]).includes(name)) return true;
124
+ if (isDrumInstrument(name) || isSamplerInstrument(name)) return true;
125
+ if (isGuideInstrument(name)) return true;
126
+ return name === "wavetable" || resolveOscillator(name) !== undefined;
127
+ }
128
+
129
+ /** Whether `word` is not an exact instrument name (see isExactInstrument). */
85
130
  export function isUnknownInstrument(word: string): boolean {
131
+ return !isExactInstrument(word);
132
+ }
133
+
134
+ /**
135
+ * `✗ instrument sawtoth · did you mean sawtooth? · instrument list`: the
136
+ * refusal for an instrument write that names nothing exactly.
137
+ */
138
+ export function unknownInstrumentMessage(word: string): string {
86
139
  const name = word.trim().toLowerCase();
87
- return (
88
- playsPlainSine(name) &&
89
- !LEGACY_WORDS.includes(name) &&
90
- !INSTRUMENT_WORDS.some((row) => row.word === name)
91
- );
140
+ const near = nearest(name, [...knownWords(), ...LEGACY_TONES, "sine"]);
141
+ const shown = name.length > 32 ? `${name.slice(0, 31)}…` : name;
142
+ return `instrument ${shown}${near ? ` · did you mean ${near}?` : ""} · instrument list`;
92
143
  }
93
144
 
94
145
  /** `dawg check` warnings for every track that plays the plain sine. */
@@ -100,38 +151,3 @@ export function plainSineWarnings(tracks: readonly Track[]): string[] {
100
151
  }
101
152
  return out;
102
153
  }
103
-
104
- /** The closest of `words` to `word` within two edits, if any. */
105
- export function nearestWord(
106
- word: string,
107
- words: readonly string[],
108
- ): string | undefined {
109
- let best: string | undefined;
110
- let bestDistance = 3;
111
- for (const candidate of words) {
112
- const distance = editDistance(word, candidate);
113
- if (distance < bestDistance) {
114
- best = candidate;
115
- bestDistance = distance;
116
- }
117
- }
118
- return best;
119
- }
120
-
121
- function editDistance(a: string, b: string): number {
122
- const row = Array.from({ length: b.length + 1 }, (_, index) => index);
123
- for (let i = 1; i <= a.length; i += 1) {
124
- let previous = row[0]!;
125
- row[0] = i;
126
- for (let j = 1; j <= b.length; j += 1) {
127
- const saved = row[j]!;
128
- row[j] = Math.min(
129
- row[j]! + 1,
130
- row[j - 1]! + 1,
131
- previous + (a[i - 1] === b[j - 1] ? 0 : 1),
132
- );
133
- previous = saved;
134
- }
135
- }
136
- return row[b.length]!;
137
- }
@@ -10,12 +10,14 @@ import type { PerformedNote } from "../../core/expression.ts";
10
10
  import type { Track, TrackScore } from "../../core/score.ts";
11
11
  import type { TuningTable } from "../../core/tuning.ts";
12
12
  import { WIND_ENGINE } from "./winds/engine.ts";
13
+ import { SING_ENGINE } from "./sing/engine.ts";
13
14
  import { MODAL_ENGINE } from "./resonators.ts";
14
15
  import type { SampleBank } from "./samples.ts";
15
16
  import type { RenderContext } from "./wav.ts";
16
17
  import { STRING_ENGINE } from "./strings/engine.ts";
17
18
  import { GRANULAR_ENGINE } from "./granular.ts";
18
19
  import { KEYS_ENGINES } from "./keys/engine.ts";
20
+ import { VOCODER_ENGINE } from "./vocoder/index.ts";
19
21
 
20
22
  /** What an engine renders with: the track's render context plus its tuning. */
21
23
  export type EngineContext = RenderContext &
@@ -138,3 +140,5 @@ registerEngine(GRANULAR_ENGINE);
138
140
  for (const engine of KEYS_ENGINES) registerEngine(engine);
139
141
  registerEngine(MODAL_ENGINE);
140
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
+ }
@@ -60,6 +60,8 @@ export type ElectricNoteOn = Readonly<{
60
60
  /** Track seed hash (clav pickup positions are per track). */
61
61
  trackSeed: number;
62
62
  noteSeed: string;
63
+ /** Song calibration (q08); 1+ keeps the tine hammer under the period. */
64
+ calibration?: number;
63
65
  }>;
64
66
 
65
67
  /** Clamped-free cantilever mode ratios. */
@@ -130,7 +132,12 @@ export class TineVoice implements KeysVoice {
130
132
  const drive = lerp(0.35, rhodes ? 1.6 : 1.5, p.bark);
131
133
  const A = (0.15 + 0.85 * v) * drive;
132
134
  // Hardness scales the hammer contact (0.5 is the prototype's).
133
- const contact = lerp(2.2, 0.9, v) * (1.4 - 0.8 * p.hardness);
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;
134
141
  this.pulse = hammerPulse(Math.max(2, Math.round(contact * 1e-3 * sr)), 1.5);
135
142
  this.bank = new ModeBank(4);
136
143
  this.bank.add(f, T, A * 0.85, 1, sr);
@@ -30,6 +30,7 @@ import { interpolateAutomation } from "../effects/common.ts";
30
30
  import type { EngineContext, InstrumentEngine } from "../instruments.ts";
31
31
  import { Biquad2, clamp, CONTROL, LN1000 } from "./dsp.ts";
32
32
  import { ORGAN_ENGINES } from "./organ.ts";
33
+ import { keysTrim } from "./calibration.ts";
33
34
  import {
34
35
  damperT60,
35
36
  physicalKey,
@@ -154,6 +155,8 @@ export function bodyOf(values: Values): string {
154
155
 
155
156
  type Live = {
156
157
  voice: KeysVoice;
158
+ /** Calibration 1 level trim (1 for legacy songs). */
159
+ trim: number;
157
160
  /** Sample index the voice started at. */
158
161
  start: number;
159
162
  /** Sample index of key-up. */
@@ -216,6 +219,7 @@ export function renderKeysTrack(
216
219
  const seedTick = context.seedTick ?? 0;
217
220
  const window = Math.round(KEYS_LIMITS.tailSeconds * sr);
218
221
  const taper = Math.round(KEYS_LIMITS.taperSeconds * sr);
222
+ const calibrated = (context.score.calibration ?? 0) >= 1;
219
223
  const onsets: Onset[] = [];
220
224
  notes.forEach((note) => {
221
225
  const hz = noteHz(note.pitch, note.cents, context.tuning);
@@ -267,7 +271,14 @@ export function renderKeysTrack(
267
271
  trackSeed,
268
272
  noteSeed: `${track.id}:${note.id}:${note.startTick + seedTick}`,
269
273
  ...(soft > 0 ? { soft } : {}),
274
+ ...(calibrated ? { calibration: 1 } : {}),
270
275
  };
276
+ const trim = calibrated
277
+ ? keysTrim(
278
+ track.keys?.preset ?? track.instrument ?? "grand",
279
+ note.pitch,
280
+ )
281
+ : 1;
271
282
  const voice: KeysVoice = electric
272
283
  ? electricVoice(
273
284
  noteOn,
@@ -292,6 +303,7 @@ export function renderKeysTrack(
292
303
  bend && wow ? (t: number) => bend(t) + wow(t) : (bend ?? wow);
293
304
  live.push({
294
305
  voice,
306
+ trim,
295
307
  start: onset.start,
296
308
  off: onset.off,
297
309
  end: onset.off + window,
@@ -331,7 +343,7 @@ export function renderKeysTrack(
331
343
  gain *= Math.max(0, x.fade) / x.fadeLength;
332
344
  x.fade -= 1;
333
345
  }
334
- const y = block[s]! * gain;
346
+ const y = block[s]! * gain * x.trim;
335
347
  left[index]! += y * gl;
336
348
  right[index]! += y * gr;
337
349
  }
@@ -162,6 +162,11 @@ export type PianoNoteOn = Readonly<{
162
162
  * the unstruck string's aftersound.
163
163
  */
164
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;
165
170
  }>;
166
171
 
167
172
  type Rattle = {
@@ -242,8 +247,13 @@ export class PianoVoice {
242
247
  )
243
248
  : 1;
244
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;
245
255
  this.pulse = hammerPulse(
246
- Math.max(2, Math.round(thMs * 1e-3 * sr)),
256
+ Math.max(2, Math.round(contactMs * 1e-3 * sr)),
247
257
  1 + 2 * v * p.hardness,
248
258
  );
249
259
  const strings = stringsFor(key);
@@ -311,10 +321,20 @@ export class PianoVoice {
311
321
  // amplitude) is made up so the extra loss stays about 1 dB.
312
322
  if (soft > 0)
313
323
  loud *= (1 - 0.3 * soft) * Math.sqrt(sum0 / Math.max(sumW, 1e-9));
314
- const G = (0.35 * loud) / Math.sqrt(Math.max(energy, 1e-9));
315
324
  const mags = candidates.map(
316
325
  (c) => pulseMag(this.pulse, (TAU * c.hz) / sr) * Math.abs(c.u),
317
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));
318
338
  const peak = Math.max(0, ...mags);
319
339
  candidates.forEach((c, i) => {
320
340
  // Below -80 dB after the hammer filter: not worth a mode.
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;