@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
package/src/audio/wav.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  type Track,
10
10
  type TrackScore,
11
11
  } from "../../core/score.ts";
12
+ import { autotuneStemDigests } from "./autotune.ts";
12
13
  import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
13
14
  import {
14
15
  performanceTimingFor,
@@ -21,9 +22,19 @@ import {
21
22
  renderSamplerVoices,
22
23
  samplerTailSeconds,
23
24
  } from "./sampler.ts";
24
- import { EFFECT_NAMES, FX_PRESETS, effectSpec } from "../../core/fx.ts";
25
+ import {
26
+ EFFECT_NAMES,
27
+ FX_CHAIN,
28
+ FX_PRESETS,
29
+ effectSpec,
30
+ } from "../../core/fx.ts";
25
31
  import { seededRandom } from "./random.ts";
26
- import { engineFor, engineTailSeconds } from "./instruments.ts";
32
+ import {
33
+ engineFor,
34
+ engineTailSeconds,
35
+ RING_OUT_FADE_SECONDS,
36
+ type InstrumentEngine,
37
+ } from "./instruments.ts";
27
38
  import {
28
39
  isStereoVoice,
29
40
  renderSynthNote,
@@ -31,7 +42,7 @@ import {
31
42
  usesSynthVoice,
32
43
  type VoiceContext,
33
44
  } from "./synth/voice.ts";
34
- import { legacyWave } from "./synth/oscillators.ts";
45
+ import { legacyWaveFor } from "./synth/oscillators.ts";
35
46
  import {
36
47
  EMPTY_SAMPLE_BANK,
37
48
  sampleKey,
@@ -39,13 +50,23 @@ import {
39
50
  type SampleBank,
40
51
  } from "./samples.ts";
41
52
  import { synthKit } from "../../core/kits.ts";
42
- import { kitDrumSample, kitTailSeconds, newKitVoiceState } from "./kits.ts";
53
+ import {
54
+ kitDrumSample,
55
+ kitTailSeconds,
56
+ METAL_SECONDS,
57
+ metalKindForPitch,
58
+ metalSample,
59
+ newKitVoiceState,
60
+ tomRatio,
61
+ } from "./kits.ts";
43
62
  import {
44
63
  addReverbWet,
45
64
  applyMonoChain,
46
65
  applyStereoChain,
47
66
  delayTailFor,
48
67
  interpolateAutomation,
68
+ needsEffectNotes,
69
+ type EffectNote,
49
70
  reverbActive,
50
71
  reverbImpulse,
51
72
  reverbTailFor,
@@ -73,8 +94,13 @@ import {
73
94
  slowestBpmOf,
74
95
  } from "../../core/tempo.ts";
75
96
  import { sampleWarpFor, warpedSpan, type SampleWarp } from "./warp.ts";
97
+ import { clipsDigest, hasClips, renderClips } from "./clips.ts";
98
+ import { isGuideInstrument } from "../../core/clips.ts";
76
99
  import { applyMaster, type MasterReport } from "./master.ts";
77
100
  import type { SongMaster } from "../../core/master.ts";
101
+ import { resolveTrackRef } from "../../core/routing.ts";
102
+ import { resolveVocoder, VOCODER_INSTRUMENT } from "../../core/vocoder.ts";
103
+ import { applyVocoder, autoGateDb } from "./vocoder/index.ts";
78
104
 
79
105
  export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
80
106
 
@@ -86,6 +112,12 @@ export type RenderOptions = WavOptions &
86
112
  * what the streaming engine plays; exports keep the default one-shot.
87
113
  */
88
114
  loop?: boolean;
115
+ /**
116
+ * 0.7 clips: a `vocal` track's guide notes sound as a soft sine. The
117
+ * live loop (audition, play mode) sets it; exports never do, so a
118
+ * rendered file holds only the clips.
119
+ */
120
+ guide?: boolean;
89
121
  /**
90
122
  * Decoded sampler voices (`SampleLibrary.load`). Without it sampler
91
123
  * tracks render silent; scores without samplers are unaffected.
@@ -97,6 +129,19 @@ export type RenderOptions = WavOptions &
97
129
  * a single pass of the whole song. Zero (the default) changes nothing.
98
130
  */
99
131
  seedTick?: number;
132
+ /**
133
+ * Song seconds at an arranged window's first frame (with a tempo map,
134
+ * more than `seedTick` at the base tempo); engines whose state runs on
135
+ * the song clock (organ wheels and rotors) start there.
136
+ */
137
+ seedSeconds?: number;
138
+ /** Per-track engine state at the window origin (`windowSeed`). */
139
+ seedState?: Readonly<Record<string, string>>;
140
+ /**
141
+ * The song whose chords a granular `quant chord` reads, at `tick` for
142
+ * this render's tick 0 (a live note renders alone). Absent: the score.
143
+ */
144
+ quantChord?: Readonly<{ score: TrackScore; tick: number }>;
100
145
  }>;
101
146
 
102
147
  /** Interleaved stereo 16-bit PCM plus its frame count. */
@@ -107,8 +152,36 @@ export type RenderedAudio = Readonly<{
107
152
  pcm: Int16Array;
108
153
  /** Loudness the song master reached; absent without a master. */
109
154
  master?: MasterReport;
155
+ /**
156
+ * Non-finite samples (NaN, Infinity) a track's source or effects made,
157
+ * zeroed before they could poison a feedback stage or the mix (q08);
158
+ * absent when every sample was finite.
159
+ */
160
+ nonFinite?: NonFiniteReport;
161
+ }>;
162
+
163
+ /** Where a render zeroed non-finite samples. */
164
+ export type NonFiniteReport = Readonly<{
165
+ samples: number;
166
+ /** Track ids in render order, then `bus` for the bus returns. */
167
+ tracks: readonly string[];
110
168
  }>;
111
169
 
170
+ /**
171
+ * Zeroes NaN and Infinity in `buffer` and returns how many it found. A
172
+ * NaN written into an Int16Array stores 0, and one fed to a filter, comb
173
+ * or reverb feedback silences everything after it.
174
+ */
175
+ export function scrubNonFinite(buffer: Float64Array): number {
176
+ let count = 0;
177
+ for (let i = 0; i < buffer.length; i += 1)
178
+ if (!Number.isFinite(buffer[i]!)) {
179
+ buffer[i] = 0;
180
+ count += 1;
181
+ }
182
+ return count;
183
+ }
184
+
112
185
  /** Output channel count for every render and export. */
113
186
  export const RENDER_CHANNELS = 2 as const;
114
187
  export const DEFAULT_SAMPLE_RATE = 22_050;
@@ -143,6 +216,8 @@ export const AVAILABLE_INSTRUMENTS = Object.freeze([
143
216
  // 0.6 instruments (src/audio/instruments.ts), one line per lane.
144
217
  "granular",
145
218
  "modal",
219
+ // 0.7 clips: a track of audio clips whose notes are guides.
220
+ "vocal",
146
221
  ] as const);
147
222
 
148
223
  /** Per-track effects understood by the renderer and the agent, in chain
@@ -191,12 +266,13 @@ const MAX_ENGINE_TAIL_SECONDS = 30;
191
266
  * The longest 0.6 engine ring-out in `score`, capped at 30 s. One-shot
192
267
  * renders (exports, previews) let engine tails reach their own cap; the
193
268
  * 8 s cap applies only to loop folding. Zero without an engine, so legacy
194
- * renders are unchanged.
269
+ * renders are unchanged. `ringing` counts only ring-out tracks (0.6.1).
195
270
  */
196
- function oneShotEngineTail(score: TrackScore): number {
271
+ function oneShotEngineTail(score: TrackScore, ringing = false): number {
197
272
  let tail = 0;
198
273
  for (const track of score.tracks) {
199
- if (!engineFor(track)) continue;
274
+ const engine = engineFor(track);
275
+ if (!engine || (ringing && engine.ringOut?.(track) !== true)) continue;
200
276
  let lowest: number | undefined;
201
277
  for (const note of score.notes)
202
278
  if (
@@ -219,6 +295,9 @@ export type RenderContext = Readonly<{
219
295
  /** Tempo map in samples (core/tempo.ts); absent: constant tempo. */
220
296
  warp?: SampleWarp;
221
297
  seedTick?: number;
298
+ seedSeconds?: number;
299
+ seedState?: Readonly<Record<string, string>>;
300
+ quantChord?: Readonly<{ score: TrackScore; tick: number }>;
222
301
  }>;
223
302
 
224
303
  /** Exact (fractional) loop length in frames at a sample rate. */
@@ -265,6 +344,8 @@ type Stem = {
265
344
  /** Stem, pre-reverb pair and tail (counted in the cache budget). */
266
345
  bytes: number;
267
346
  used: number;
347
+ /** Non-finite samples zeroed while rendering it (0 or absent: none). */
348
+ nonFinite?: number;
268
349
  /** For a track with a reverb: its stem before the reverb, and the tail. */
269
350
  room?: Readonly<{
270
351
  /** `stemKey` of the track with its reverb mix and mix lane left out. */
@@ -283,6 +364,9 @@ type Stem = {
283
364
  * byte-identical to a cold render; mute and solo only pick which stems are
284
365
  * summed. Scratch buffers are reused across renders.
285
366
  */
367
+ /** A guide note's level against a plain sine note (about -12 dB). */
368
+ const GUIDE_LEVEL = 0.25;
369
+
286
370
  export class StemRenderer {
287
371
  private readonly maxCacheBytes: number;
288
372
  private readonly stems = new Map<string, Stem>();
@@ -293,6 +377,11 @@ export class StemRenderer {
293
377
  >();
294
378
  private cacheBytes = 0;
295
379
  private renders = 0;
380
+ /** Vocoder modulator taps by source key (0.7), kept one render. */
381
+ private readonly modSources = new Map<
382
+ string,
383
+ { tap: Float64Array; used: number }
384
+ >();
296
385
  private scratch: {
297
386
  dry: Float64Array;
298
387
  dryR: Float64Array;
@@ -421,9 +510,16 @@ export class StemRenderer {
421
510
  ...(bank.irs ? { irs: bank.irs } : {}),
422
511
  ...(warp ? { warp } : {}),
423
512
  ...(options.seedTick ? { seedTick: options.seedTick } : {}),
513
+ ...(options.seedSeconds ? { seedSeconds: options.seedSeconds } : {}),
514
+ ...(options.seedState ? { seedState: options.seedState } : {}),
515
+ ...(options.quantChord ? { quantChord: options.quantChord } : {}),
424
516
  };
425
517
  this.renders += 1;
426
518
  const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
519
+ const nonFinite: { samples: number; tracks: string[] } = {
520
+ samples: 0,
521
+ tracks: [],
522
+ };
427
523
  mixL.fill(0);
428
524
  mixR.fill(0);
429
525
  const tracks = new Map(score.tracks.map((track) => [track.id, track]));
@@ -434,6 +530,14 @@ export class StemRenderer {
434
530
  if (group) group.push(note);
435
531
  else groups.set(note.trackId, [note]);
436
532
  }
533
+ // Tracks with audio clips (0.7) render even without notes, after the
534
+ // tracks with notes, so songs without clips keep their sum order.
535
+ for (const track of score.tracks)
536
+ if (hasClips(track) && !groups.has(track.id)) groups.set(track.id, []);
537
+ // A built-in vocoder carrier following chords or a drone needs no notes.
538
+ for (const track of score.tracks)
539
+ if (!groups.has(track.id) && vocoderPlaysWithoutNotes(track))
540
+ groups.set(track.id, []);
437
541
  // Note expression and track performance (core/expression.ts): the
438
542
  // notes as played. A track with neither gets its notes back unchanged.
439
543
  const timing = performanceTimingFor(score);
@@ -482,13 +586,31 @@ export class StemRenderer {
482
586
  const engine = engineFor(track);
483
587
  // The song key always joins an engine's stem key: engines may tune to
484
588
  // it (sympathetic strings) without a song tuning.
485
- const engineDigests =
589
+ const clipDigest = track
590
+ ? clipsDigest(track, bank, options.guide === true)
591
+ : undefined;
592
+ const baseDigests =
486
593
  engine && track
487
594
  ? [
488
- ...(engine.assetDigests?.(track, bank) ?? []),
595
+ ...(engine.assetDigests?.(track, bank, score) ?? []),
489
596
  `key:${score.key ?? ""}`,
597
+ ...(clipDigest === undefined ? [] : [`clips:${clipDigest}`]),
490
598
  ]
491
- : undefined;
599
+ : clipDigest === undefined
600
+ ? undefined
601
+ : [`clips:${clipDigest}`];
602
+ // A vocoder carrier (0.7) also keys on its modulator's tap.
603
+ const vocoderSource = track?.vocoder
604
+ ? vocoderSourceOf(track, groups, performed, context, bank)
605
+ : undefined;
606
+ const withVocoder = vocoderSource
607
+ ? [...(baseDigests ?? []), vocoderSource.key]
608
+ : baseDigests;
609
+ // 0.7 autotune: its targets (guide notes, chords, key, tuning) and the
610
+ // pitch engine version join the stem key; absent adds nothing.
611
+ const engineDigests = track?.autotune
612
+ ? [...(withVocoder ?? []), ...autotuneStemDigests(track, score)]
613
+ : withVocoder;
492
614
  // Wavetable hook: the oscillator factory for a wavetable track (its
493
615
  // table id joins the stem key), undefined for every other instrument.
494
616
  const wavetable = track ? wavetableHook(track, bank, context) : undefined;
@@ -544,67 +666,39 @@ export class StemRenderer {
544
666
  }
545
667
  : { left, right };
546
668
  dry.fill(0);
547
- const synthVoice = !engine && !sampler && usesSynthVoice(track);
548
- const stereo = engine
549
- ? engine.stereo(track!)
550
- : synthVoice && isStereoVoice(track);
551
- if (stereo) dryR.fill(0);
552
- if (engine && track) {
553
- engine.render(
669
+ const stereo = renderVoiceInto(
670
+ dry,
671
+ dryR,
672
+ played,
673
+ track,
674
+ context,
675
+ score,
676
+ bank,
677
+ engine,
678
+ sampler,
679
+ wavetable,
680
+ tuning,
681
+ options.guide === true,
682
+ );
683
+ // The vocoder stage (0.7): after the voice, before the chain.
684
+ if (track && vocoderSource)
685
+ applyVocoder(
554
686
  dry,
555
687
  stereo ? dryR : undefined,
556
- played,
688
+ this.vocoderTap(vocoderSource, context, bank),
557
689
  track,
558
- {
559
- ...context,
560
- ticksPerBeat: score.ticksPerBeat,
561
- ...(tuning ? { tuning } : {}),
562
- },
563
- bank,
690
+ context,
691
+ vocoderSource.gateDb,
564
692
  );
565
- } else if (sampler) {
566
- if (track) renderSamplerNotes(dry, played, track, context, bank);
567
- } else if (synthVoice && track) {
568
- const gainAt = (tick: number) => trackGainAt(track, tick);
569
- const voice = {
570
- ...context,
571
- ticksPerBeat: score.ticksPerBeat,
572
- ...(wavetable ? { oscillatorFor: wavetable.oscillatorFor } : {}),
573
- ...(tuning ? { tuning } : {}),
574
- };
575
- const warp = context.warp;
576
- for (const note of played) {
577
- const { start, length } = noteSpan(note, context);
578
- // The voice asks for `startTick + elapsed / samplesPerTick`;
579
- // through a tempo map that sample's tick comes from the map.
580
- const noteGainAt = warp
581
- ? (tick: number) =>
582
- gainAt(
583
- warp.tick(
584
- start + (tick - note.startTick) * context.samplesPerTick,
585
- ),
586
- )
587
- : gainAt;
588
- renderSynthNote(
589
- dry,
590
- stereo ? dryR : undefined,
591
- note,
592
- track,
593
- start,
594
- length,
595
- voice,
596
- noteGainAt,
597
- );
598
- }
599
- } else {
600
- const drums = isDrumInstrument(track?.instrument);
601
- for (const note of played) {
602
- if (drums) renderDrumNote(dry, note, track, context);
603
- else renderToneNote(dry, note, track, context, tuning);
604
- }
605
- }
606
- if (track) applyMonoChain(dry, track, context);
607
- if (stereo && track) applyMonoChain(dryR, track, context);
693
+ // A non-finite source sample would poison every feedback stage.
694
+ let bad = scrubNonFinite(dry) + (stereo ? scrubNonFinite(dryR) : 0);
695
+ // Note-aware stages (bloom, swell) see the notes; others never do.
696
+ const chain =
697
+ track && needsEffectNotes(track)
698
+ ? { ...context, notes: effectNotes(played, context, tuning) }
699
+ : context;
700
+ if (track) applyMonoChain(dry, track, chain);
701
+ if (stereo && track) applyMonoChain(dryR, track, chain);
608
702
  applyPan(
609
703
  dry,
610
704
  target.left,
@@ -620,10 +714,12 @@ export class StemRenderer {
620
714
  target.left,
621
715
  target.right,
622
716
  track,
623
- context,
717
+ chain,
624
718
  true,
625
719
  false,
626
720
  );
721
+ // The room's input: scrubbed so the saved pair is clean too.
722
+ bad += scrubNonFinite(target.left) + scrubNonFinite(target.right);
627
723
  const pre = {
628
724
  left: Float64Array.from(target.left),
629
725
  right: Float64Array.from(target.right),
@@ -638,9 +734,10 @@ export class StemRenderer {
638
734
  target.left,
639
735
  target.right,
640
736
  track,
641
- context,
737
+ chain,
642
738
  busOrbit === undefined,
643
739
  );
740
+ bad += scrubNonFinite(target.left) + scrubNonFinite(target.right);
644
741
  stem = {
645
742
  key,
646
743
  left: target.left,
@@ -648,9 +745,14 @@ export class StemRenderer {
648
745
  bytes: target.left.byteLength * (saved ? 6 : 2),
649
746
  used: this.renders,
650
747
  ...(saved ? { room: saved } : {}),
748
+ ...(bad > 0 ? { nonFinite: bad } : {}),
651
749
  };
652
750
  if (caching) this.store(trackId, stem);
653
751
  }
752
+ if (stem.nonFinite) {
753
+ nonFinite.samples += stem.nonFinite;
754
+ nonFinite.tracks.push(trackId);
755
+ }
654
756
  if (track && busOrbit !== undefined) {
655
757
  let bus = buses.get(busOrbit);
656
758
  if (!bus)
@@ -694,7 +796,27 @@ export class StemRenderer {
694
796
  if (!groups.has(trackId)) this.evict(trackId);
695
797
  // Linear effects superpose, so folding the tail onto the start yields
696
798
  // the steady state of the loop playing forever.
799
+ // A ring-out longer than the fold (a gong past 8 s) would be cut there:
800
+ // fade its last RING_OUT_FADE_SECONDS so the fold never clicks. Only
801
+ // ringing 0.6 engine tracks qualify, so legacy loops are untouched.
802
+ if (
803
+ options.loop &&
804
+ samples > frames &&
805
+ oneShotEngineTail(score, true) > (samples - frames) / sampleRate
806
+ )
807
+ fadeEnd(
808
+ mixL,
809
+ mixR,
810
+ samples,
811
+ Math.round(RING_OUT_FADE_SECONDS * sampleRate),
812
+ );
697
813
  if (samples > frames) foldTail(mixL, mixR, frames, samples);
814
+ // Bus returns: a last scrub before the master's own feedback stages.
815
+ const busBad = scrubNonFinite(mixL) + scrubNonFinite(mixR);
816
+ if (busBad > 0) {
817
+ nonFinite.samples += busBad;
818
+ nonFinite.tracks.push("bus");
819
+ }
698
820
  // The song master (src/audio/master.ts); none leaves the mix untouched.
699
821
  const mastered = applyMaster(
700
822
  mixL,
@@ -715,9 +837,40 @@ export class StemRenderer {
715
837
  frames,
716
838
  pcm,
717
839
  ...(mastered ? { master: mastered.report } : {}),
840
+ ...(nonFinite.samples > 0
841
+ ? {
842
+ nonFinite: Object.freeze({
843
+ samples: nonFinite.samples,
844
+ tracks: Object.freeze([...nonFinite.tracks]),
845
+ }),
846
+ }
847
+ : {}),
718
848
  });
719
849
  }
720
850
 
851
+ /**
852
+ * A vocoder modulator's mono tap over this render: the source's voice,
853
+ * through its mono chain for `tap: chain`, stereo summed (L+R)/2. Kept
854
+ * across renders under the stem cache budget, keyed by the source key.
855
+ */
856
+ private vocoderTap(
857
+ source: VocoderSource,
858
+ context: RenderContext,
859
+ bank: SampleBank,
860
+ ): Float64Array {
861
+ const known = this.modSources.get(source.key);
862
+ if (known && known.tap.length === context.samples) {
863
+ known.used = this.renders;
864
+ return known.tap;
865
+ }
866
+ const tap = renderVocoderTap(source, context, bank);
867
+ for (const [key, entry] of this.modSources)
868
+ if (entry.used < this.renders - 1) this.modSources.delete(key);
869
+ if (this.maxCacheBytes > 0)
870
+ this.modSources.set(source.key, { tap, used: this.renders });
871
+ return tap;
872
+ }
873
+
721
874
  private scratchFor(samples: number) {
722
875
  if (this.scratch.dry.length < samples) {
723
876
  this.scratch = {
@@ -800,6 +953,21 @@ function addStem(
800
953
  }
801
954
 
802
955
  /** Fold everything past `frames` back onto the loop start. */
956
+ /** Fade the last `fade` of `samples` mix frames linearly to zero. */
957
+ function fadeEnd(
958
+ mixL: Float64Array,
959
+ mixR: Float64Array,
960
+ samples: number,
961
+ fade: number,
962
+ ): void {
963
+ const n = Math.min(fade, samples);
964
+ for (let i = 0; i < n; i += 1) {
965
+ const gain = (n - 1 - i) / n;
966
+ mixL[samples - n + i]! *= gain;
967
+ mixR[samples - n + i]! *= gain;
968
+ }
969
+ }
970
+
803
971
  function foldTail(
804
972
  mixL: Float64Array,
805
973
  mixR: Float64Array,
@@ -925,8 +1093,14 @@ function stemKey(
925
1093
  ...(context.score.tuning || track?.tuning
926
1094
  ? [context.score.tuning ?? null, context.score.key]
927
1095
  : []),
1096
+ // Calibrated engines (0.7) render differently; absent keeps old keys.
1097
+ ...(context.score.calibration ? [`cal:${context.score.calibration}`] : []),
928
1098
  // Seeded noise follows the window origin of an arranged render.
929
1099
  ...(context.seedTick ? [`seed:${context.seedTick}`] : []),
1100
+ ...(context.seedSeconds ? [`at:${context.seedSeconds}`] : []),
1101
+ ...(track && context.seedState?.[track.id]
1102
+ ? [`state:${context.seedState[track.id]}`]
1103
+ : []),
930
1104
  ]);
931
1105
  }
932
1106
 
@@ -941,25 +1115,307 @@ function samplerVoiceDigest(track: Track, bank: SampleBank): string[] {
941
1115
  });
942
1116
  }
943
1117
 
944
- /** Sample voices of a sampler track into its mono dry buffer. */
1118
+ /** A vocoder carrier's modulator: its track, notes, key and gate. */
1119
+ /** FX_CHAIN index of pan: a `tap: chain` modulator runs the stages before. */
1120
+ const TAP_END = FX_CHAIN.indexOf("pan");
1121
+
1122
+ type VocoderSource = Readonly<{
1123
+ track: Track;
1124
+ played: readonly PerformedNote[];
1125
+ tap: "chain" | "dry";
1126
+ key: string;
1127
+ gateDb: number;
1128
+ }>;
1129
+
1130
+ /** True for a built-in carrier that sounds without notes (chords, drone). */
1131
+ function vocoderPlaysWithoutNotes(track: Track): boolean {
1132
+ if (!track.vocoder || track.instrument !== VOCODER_INSTRUMENT) return false;
1133
+ const follow = resolveVocoder(track.vocoder).follow;
1134
+ return follow === "chords" || follow === "drone";
1135
+ }
1136
+
1137
+ const assetGates = new WeakMap<DecodedSample, number>();
1138
+
1139
+ /**
1140
+ * The auto gate from a source's assets (its sampler files): the quietest
1141
+ * file's 10th-percentile 10 ms frame level + 6 dB, never the render span.
1142
+ * A source without assets (a synth) has no noise floor: the gate is off.
1143
+ */
1144
+ function sourceGateDb(source: Track, bank: SampleBank): number {
1145
+ let gate: number | undefined;
1146
+ for (const voice of Object.keys(source.sampler?.voices ?? {}).sort()) {
1147
+ const sample = bank.voices.get(sampleKey(source.id, voice));
1148
+ if (!sample) continue;
1149
+ let level = assetGates.get(sample);
1150
+ if (level === undefined) {
1151
+ level = autoGateDb(Float64Array.from(sample.mono), sample.sampleRate);
1152
+ assetGates.set(sample, level);
1153
+ }
1154
+ if (level > -120) gate = gate === undefined ? level : Math.min(gate, level);
1155
+ }
1156
+ return gate ?? -120;
1157
+ }
1158
+
1159
+ /** Resolves a carrier's modulator, or undefined (the stage is bypassed). */
1160
+ function vocoderSourceOf(
1161
+ track: Track,
1162
+ groups: ReadonlyMap<string, readonly Note[]>,
1163
+ performed: ReadonlyMap<string, readonly PerformedNote[]>,
1164
+ context: RenderContext,
1165
+ bank: SampleBank,
1166
+ ): VocoderSource | undefined {
1167
+ const ref = track.vocoder?.src;
1168
+ if (ref === undefined) return undefined;
1169
+ const source = resolveTrackRef(context.score, ref);
1170
+ if (!source || source.id === track.id) return undefined;
1171
+ const settings = resolveVocoder(track.vocoder);
1172
+ const notes = groups.get(source.id) ?? [];
1173
+ const engine = engineFor(source);
1174
+ // Post-tap fields never change the tap (mute, solo and volume included).
1175
+ const {
1176
+ vocoder: _vocoder,
1177
+ pan: _pan,
1178
+ volume: _volume,
1179
+ volumeAutomation: _volumeAutomation,
1180
+ panAutomation: _panAutomation,
1181
+ delay: _delay,
1182
+ delayFeedbackAutomation: _delayFeedback,
1183
+ delayMixAutomation: _delayMix,
1184
+ reverb: _reverb,
1185
+ ...kept
1186
+ } = source;
1187
+ // Only the stages the tap runs (before pan, and none for `tap: dry`):
1188
+ // a source's reverb, delay, double or other post-pan fx never re-render
1189
+ // its carriers. fxAutomation stays (rarely post-pan; a cheap re-render).
1190
+ const tapped = settings.tap === "chain" ? FX_CHAIN.slice(0, TAP_END) : [];
1191
+ const fx = kept.fx
1192
+ ? Object.fromEntries(
1193
+ Object.entries(kept.fx).filter(
1194
+ ([stage]) =>
1195
+ tapped.includes(stage as never) ||
1196
+ !FX_CHAIN.includes(stage as never),
1197
+ ),
1198
+ )
1199
+ : undefined;
1200
+ const stripped = {
1201
+ ...kept,
1202
+ fx: fx && Object.keys(fx).length > 0 ? fx : undefined,
1203
+ } as unknown as Track;
1204
+ const gateDb =
1205
+ settings.gate === "auto" ? sourceGateDb(source, bank) : settings.gate;
1206
+ // The modulator's clips (0.7) are heard; guide tones never are.
1207
+ const clipDigest = clipsDigest(source, bank, false);
1208
+ // A tuned modulator (0.7 autotune) keys on its targets too: its guide
1209
+ // track's notes, the chord timeline and the key change the tap.
1210
+ const clipKeys = [
1211
+ ...(clipDigest === undefined ? [] : [`clips:${clipDigest}`]),
1212
+ ...(source.autotune ? autotuneStemDigests(source, context.score) : []),
1213
+ ];
1214
+ const key = stemKey(
1215
+ stripped,
1216
+ notes,
1217
+ context,
1218
+ isSamplerInstrument(source.instrument) ? bank : undefined,
1219
+ wavetableHook(source, bank, context)?.id,
1220
+ undefined,
1221
+ engine
1222
+ ? [
1223
+ ...(engine.assetDigests?.(source, bank, context.score) ?? []),
1224
+ `key:${context.score.key ?? ""}`,
1225
+ ...clipKeys,
1226
+ ]
1227
+ : clipKeys.length > 0
1228
+ ? clipKeys
1229
+ : undefined,
1230
+ );
1231
+ return {
1232
+ track: source,
1233
+ played: performed.get(source.id) ?? [],
1234
+ tap: settings.tap,
1235
+ key: `vocsrc:${settings.tap}:${gateDb}:${key}`,
1236
+ gateDb,
1237
+ };
1238
+ }
1239
+
1240
+ /** Renders a modulator's mono tap (see `StemRenderer.vocoderTap`). */
1241
+ function renderVocoderTap(
1242
+ source: VocoderSource,
1243
+ context: RenderContext,
1244
+ bank: SampleBank,
1245
+ ): Float64Array {
1246
+ const { played } = source;
1247
+ // The tap is rendered at unity gain: the voice bakes track volume in
1248
+ // (trackGainAt), and volume is a post-tap field the cache key leaves out.
1249
+ const track: Track = {
1250
+ ...source.track,
1251
+ volume: 1,
1252
+ volumeAutomation: [],
1253
+ };
1254
+ const { score } = context;
1255
+ const dry = new Float64Array(context.samples);
1256
+ const dryR = new Float64Array(context.samples);
1257
+ const tuning = resolveTuning(score.tuning, track.tuning, score.key);
1258
+ const stereo = renderVoiceInto(
1259
+ dry,
1260
+ dryR,
1261
+ played,
1262
+ track,
1263
+ context,
1264
+ score,
1265
+ bank,
1266
+ engineFor(track),
1267
+ isSamplerInstrument(track.instrument),
1268
+ wavetableHook(track, bank, context),
1269
+ tuning,
1270
+ false,
1271
+ );
1272
+ if (source.tap === "chain") {
1273
+ const chain = needsEffectNotes(track)
1274
+ ? { ...context, notes: effectNotes(played, context, tuning) }
1275
+ : context;
1276
+ applyMonoChain(dry, track, chain);
1277
+ if (stereo) applyMonoChain(dryR, track, chain);
1278
+ }
1279
+ if (stereo)
1280
+ for (let i = 0; i < dry.length; i += 1) dry[i] = (dry[i]! + dryR[i]!) / 2;
1281
+ return dry;
1282
+ }
1283
+
1284
+ /**
1285
+ * Renders a track's voice (engine, sampler, synth or tone/drum notes) into
1286
+ * `dry` (and `dryR` when stereo) before any effect; returns whether it
1287
+ * wrote a right channel. Shared by the stem pass and the vocoder tap.
1288
+ */
1289
+ function renderVoiceInto(
1290
+ dry: Float64Array,
1291
+ dryR: Float64Array,
1292
+ played: readonly PerformedNote[],
1293
+ track: Track | undefined,
1294
+ context: RenderContext,
1295
+ score: TrackScore,
1296
+ bank: SampleBank,
1297
+ engine: InstrumentEngine | undefined,
1298
+ sampler: boolean,
1299
+ wavetable: ReturnType<typeof wavetableHook> | undefined,
1300
+ tuning: TuningTable | undefined,
1301
+ guide: boolean,
1302
+ ): boolean {
1303
+ const synthVoice = !engine && !sampler && usesSynthVoice(track);
1304
+ let stereo = engine
1305
+ ? engine.stereo(track!)
1306
+ : synthVoice && isStereoVoice(track);
1307
+ if (stereo) dryR.fill(0);
1308
+ if (engine && track) {
1309
+ engine.render(
1310
+ dry,
1311
+ stereo ? dryR : undefined,
1312
+ played,
1313
+ track,
1314
+ {
1315
+ ...context,
1316
+ ticksPerBeat: score.ticksPerBeat,
1317
+ ...(tuning ? { tuning } : {}),
1318
+ },
1319
+ bank,
1320
+ );
1321
+ } else if (sampler) {
1322
+ // A resampled stereo file plays as stereo (0.6.1).
1323
+ if (track)
1324
+ stereo = renderSamplerNotes(dry, played, track, context, bank, dryR);
1325
+ } else if (synthVoice && track) {
1326
+ const gainAt = (tick: number) => trackGainAt(track, tick);
1327
+ const voice = {
1328
+ ...context,
1329
+ ticksPerBeat: score.ticksPerBeat,
1330
+ ...(wavetable ? { oscillatorFor: wavetable.oscillatorFor } : {}),
1331
+ ...(tuning ? { tuning } : {}),
1332
+ };
1333
+ const warp = context.warp;
1334
+ for (const note of played) {
1335
+ const { start, length } = noteSpan(note, context);
1336
+ // The voice asks for `startTick + elapsed / samplesPerTick`;
1337
+ // through a tempo map that sample's tick comes from the map.
1338
+ const noteGainAt = warp
1339
+ ? (tick: number) =>
1340
+ gainAt(
1341
+ warp.tick(
1342
+ start + (tick - note.startTick) * context.samplesPerTick,
1343
+ ),
1344
+ )
1345
+ : gainAt;
1346
+ renderSynthNote(
1347
+ dry,
1348
+ stereo ? dryR : undefined,
1349
+ note,
1350
+ track,
1351
+ start,
1352
+ length,
1353
+ voice,
1354
+ noteGainAt,
1355
+ );
1356
+ }
1357
+ } else if (track?.clips && isGuideInstrument(track.instrument)) {
1358
+ // A `vocal` track's notes guide its clips: silent in a render,
1359
+ // a soft sine in the live loop with `guide` on. (Without clips,
1360
+ // an older project's `vocal` keeps its tone.)
1361
+ if (guide)
1362
+ for (const note of played)
1363
+ renderToneNote(
1364
+ dry,
1365
+ { ...note, velocity: note.velocity * GUIDE_LEVEL },
1366
+ { ...track, instrument: "sine" },
1367
+ context,
1368
+ tuning,
1369
+ );
1370
+ } else {
1371
+ const drums = isDrumInstrument(track?.instrument);
1372
+ // Calibration 1+ (0.7): a closed or pedal hat chokes a ringing open hat.
1373
+ const chokes =
1374
+ drums && (context.score.calibration ?? 0) >= 1
1375
+ ? hatChokes(played, context)
1376
+ : undefined;
1377
+ for (const note of played) {
1378
+ if (drums) renderDrumNote(dry, note, track, context, chokes?.get(note));
1379
+ else renderToneNote(dry, note, track, context, tuning);
1380
+ }
1381
+ }
1382
+ // Audio clips (0.7) sum into the dry buffer before the chain.
1383
+ if (track?.clips) {
1384
+ const gainAt = (tick: number) => trackGainAt(track, tick);
1385
+ renderClips(dry, track, context, bank, gainAt, score);
1386
+ if (stereo) renderClips(dryR, track, context, bank, gainAt, score);
1387
+ }
1388
+ return stereo;
1389
+ }
1390
+
1391
+ /**
1392
+ * Sample voices of a sampler track into its mono dry buffer. When a voice
1393
+ * plays a resampled stereo file (0.6.1), `target` takes the left channel,
1394
+ * `targetRight` the right, and the result is true (the track is stereo).
1395
+ */
945
1396
  function renderSamplerNotes(
946
1397
  target: Float64Array,
947
1398
  notes: readonly Note[],
948
1399
  track: Track,
949
1400
  context: RenderContext,
950
1401
  bank: SampleBank,
951
- ): void {
1402
+ targetRight: Float64Array,
1403
+ ): boolean {
952
1404
  const timing = {
953
1405
  score: context.score,
954
1406
  sampleRate: context.sampleRate,
955
1407
  ...(context.warp ? { warp: context.warp } : {}),
956
1408
  };
957
- renderSamplerVoices(
958
- target,
959
- planSamplerVoices(track, notes, bank, timing),
960
- timing,
961
- (tick) => trackGainAt(track, tick),
962
- );
1409
+ const voices = planSamplerVoices(track, notes, bank, timing);
1410
+ const gainAt = (tick: number) => trackGainAt(track, tick);
1411
+ if (!voices.some((voice) => voice.stereo)) {
1412
+ renderSamplerVoices(target, voices, timing, gainAt);
1413
+ return false;
1414
+ }
1415
+ targetRight.fill(0);
1416
+ renderSamplerVoices(target, voices, timing, gainAt, "left");
1417
+ renderSamplerVoices(targetRight, voices, timing, gainAt, "right");
1418
+ return true;
963
1419
  }
964
1420
 
965
1421
  /** Track volume and volume automation at a score tick (pan is applied in stereo). */
@@ -973,6 +1429,24 @@ function trackGainAt(track: Track | undefined, tick: number): number {
973
1429
  return trackGain * automatedGain;
974
1430
  }
975
1431
 
1432
+ /** The notes as note-aware effects see them (EffectContext.notes). */
1433
+ function effectNotes(
1434
+ notes: readonly Note[],
1435
+ context: RenderContext,
1436
+ tuning: TuningTable | undefined,
1437
+ ): EffectNote[] {
1438
+ return notes.map((note) => {
1439
+ const { start, length } = noteSpan(note, context);
1440
+ return {
1441
+ id: note.id,
1442
+ start,
1443
+ length,
1444
+ hz: noteHz(note.pitch, note.cents, tuning),
1445
+ velocity: note.velocity,
1446
+ };
1447
+ });
1448
+ }
1449
+
976
1450
  function noteSpan(
977
1451
  note: Note,
978
1452
  context: RenderContext,
@@ -1013,6 +1487,7 @@ function renderToneNote(
1013
1487
  const performance = (note as PerformedNote).performance;
1014
1488
  const cents = performance?.cents;
1015
1489
  let bent = 0;
1490
+ const wave = legacyWaveFor(instrument);
1016
1491
  for (let index = start; index < end; index += 1) {
1017
1492
  const elapsed = index - start;
1018
1493
  const remaining = end - index;
@@ -1039,7 +1514,7 @@ function renderToneNote(
1039
1514
  const t = elapsed / sampleRate;
1040
1515
  const held =
1041
1516
  damp && t > damp.from ? Math.exp(-(t - damp.from) / damp.tau) : 1;
1042
- target[index]! += legacyWave(instrument, phase) * envelope * held;
1517
+ target[index]! += wave(phase) * envelope * held;
1043
1518
  }
1044
1519
  }
1045
1520
 
@@ -1048,11 +1523,40 @@ function renderToneNote(
1048
1523
  * id and start tick, so a hit sounds identical on every render and in every
1049
1524
  * window regardless of note order.
1050
1525
  */
1526
+ /** Closed hat (42) and pedal hat (44) GM numbers that choke an open hat. */
1527
+ const CHOKING_HATS = new Set([42, 44]);
1528
+ /** Open hat fade once choked, in seconds. */
1529
+ const CHOKE_SECONDS = 0.008;
1530
+
1531
+ /**
1532
+ * For each open hat (46), the sample where the next closed or pedal hat
1533
+ * on the same track starts, as on a real hi-hat stand.
1534
+ */
1535
+ function hatChokes(
1536
+ notes: readonly Note[],
1537
+ context: RenderContext,
1538
+ ): Map<Note, number> {
1539
+ const closers = notes
1540
+ .filter((note) => CHOKING_HATS.has(note.pitch))
1541
+ .map((note) => noteSpan(note, context).start)
1542
+ .sort((a, b) => a - b);
1543
+ const chokes = new Map<Note, number>();
1544
+ if (closers.length === 0) return chokes;
1545
+ for (const note of notes) {
1546
+ if (note.pitch !== 46) continue;
1547
+ const start = noteSpan(note, context).start;
1548
+ const next = closers.find((closer) => closer > start);
1549
+ if (next !== undefined) chokes.set(note, next);
1550
+ }
1551
+ return chokes;
1552
+ }
1553
+
1051
1554
  function renderDrumNote(
1052
1555
  target: Float64Array,
1053
1556
  note: Note,
1054
1557
  track: Track | undefined,
1055
1558
  context: RenderContext,
1559
+ chokeAt?: number,
1056
1560
  ): void {
1057
1561
  const { sampleRate, samples, samplesPerTick } = context;
1058
1562
  const { start } = noteSpan(note, context);
@@ -1060,9 +1564,24 @@ function renderDrumNote(
1060
1564
  // one the voices below render exactly as they always have.
1061
1565
  const kit = synthKit(track?.kit);
1062
1566
  const kitState = kit ? newKitVoiceState() : undefined;
1567
+ // Calibration 1+ (0.7): pitched toms, GM cymbals and cowbell as metal
1568
+ // instead of a rim click, band-limited kit hats, and hat choke.
1569
+ const calibrated = (context.score.calibration ?? 0) >= 1;
1570
+ const metal = calibrated ? metalKindForPitch(note.pitch) : undefined;
1571
+ const metalState = metal ? newKitVoiceState() : undefined;
1572
+ const ratio = calibrated ? tomRatio(note.pitch) : 1;
1573
+ const chokeEnd =
1574
+ chokeAt === undefined
1575
+ ? Number.POSITIVE_INFINITY
1576
+ : chokeAt + Math.ceil(CHOKE_SECONDS * sampleRate);
1063
1577
  const end = Math.min(
1064
1578
  samples,
1065
- start + Math.ceil((kit?.seconds ?? MAX_DRUM_SECONDS) * sampleRate),
1579
+ chokeEnd,
1580
+ start +
1581
+ Math.ceil(
1582
+ (metal ? METAL_SECONDS : (kit?.seconds ?? MAX_DRUM_SECONDS)) *
1583
+ sampleRate,
1584
+ ),
1066
1585
  );
1067
1586
  const voice = drumVoiceForPitch(note.pitch);
1068
1587
  const random = seededRandom(
@@ -1079,7 +1598,10 @@ function renderDrumNote(
1079
1598
  const bright = noise - previousNoise;
1080
1599
  previousNoise = noise;
1081
1600
  let sample = 0;
1082
- if (kit && kitState)
1601
+ if (metal && metalState) {
1602
+ sample = metalSample(metal, t, bright, metalState, sampleRate);
1603
+ if (kit) sample *= kit.gain;
1604
+ } else if (kit && kitState)
1083
1605
  sample = kitDrumSample(
1084
1606
  kit,
1085
1607
  voice,
@@ -1088,6 +1610,7 @@ function renderDrumNote(
1088
1610
  bright,
1089
1611
  kitState,
1090
1612
  sampleRate,
1613
+ calibrated ? { tomRatio: ratio } : undefined,
1091
1614
  );
1092
1615
  else if (voice === "kick") {
1093
1616
  const frequency = 45 + 105 * Math.exp(-t * 28);
@@ -1096,7 +1619,7 @@ function renderDrumNote(
1096
1619
  Math.sin(2 * Math.PI * phase) * Math.exp(-t * 7.5) +
1097
1620
  noise * 0.12 * Math.exp(-t * 300);
1098
1621
  } else if (voice === "tom") {
1099
- const frequency = 105 + 95 * Math.exp(-t * 18);
1622
+ const frequency = (105 + 95 * Math.exp(-t * 18)) * ratio;
1100
1623
  phase += frequency / sampleRate;
1101
1624
  sample = Math.sin(2 * Math.PI * phase) * Math.exp(-t * 9);
1102
1625
  } else if (voice === "snare") {
@@ -1123,10 +1646,10 @@ function renderDrumNote(
1123
1646
  1,
1124
1647
  (index - start) / Math.max(1, sampleRate * 0.001),
1125
1648
  );
1126
- const release = Math.min(
1127
- 1,
1128
- (end - index) / Math.max(1, sampleRate * 0.005),
1129
- );
1649
+ const release =
1650
+ chokeAt !== undefined && index >= chokeAt
1651
+ ? Math.max(0, chokeEnd - index) / Math.max(1, chokeEnd - chokeAt)
1652
+ : Math.min(1, (end - index) / Math.max(1, sampleRate * 0.005));
1130
1653
  target[index]! +=
1131
1654
  sample *
1132
1655
  attack *