@hraness/dawg 0.6.0 → 0.6.1

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 (93) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/DAWG.md +338 -51
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +5 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +258 -6
  8. package/core/granular.ts +91 -0
  9. package/core/instruments.ts +161 -0
  10. package/core/keys.ts +573 -4
  11. package/core/resonators.ts +291 -3
  12. package/core/score.ts +349 -3
  13. package/core/sdk/print.ts +78 -3
  14. package/core/sdk/v1.ts +1320 -36
  15. package/core/sections.ts +12 -4
  16. package/core/strings.ts +237 -2
  17. package/core/winds.ts +652 -0
  18. package/guides/automation.md +1 -0
  19. package/guides/chords.md +3 -1
  20. package/guides/effects.md +3 -2
  21. package/guides/media.md +1 -1
  22. package/guides/performance.md +3 -0
  23. package/guides/resample.md +26 -0
  24. package/guides/sounds.md +7 -6
  25. package/package.json +1 -1
  26. package/src/agent/agent.ts +8 -2
  27. package/src/agent/chord-tools.ts +153 -1
  28. package/src/agent/expression-tools.ts +91 -0
  29. package/src/agent/ops.ts +4 -1
  30. package/src/agent/preview-tool.ts +2 -1
  31. package/src/agent/resample-tool.ts +131 -0
  32. package/src/agent/tools.ts +200 -15
  33. package/src/agent/xcb-agent.ts +8 -2
  34. package/src/audio/arrange.ts +21 -0
  35. package/src/audio/dsp/envelope.ts +161 -0
  36. package/src/audio/dsp/shift.ts +256 -0
  37. package/src/audio/effects/chain.ts +18 -3
  38. package/src/audio/effects/common.ts +14 -0
  39. package/src/audio/effects/convolution.ts +106 -1
  40. package/src/audio/effects/gaze.ts +354 -0
  41. package/src/audio/engine.ts +46 -5
  42. package/src/audio/fit.ts +45 -0
  43. package/src/audio/granular.ts +352 -21
  44. package/src/audio/instrument-check.ts +9 -5
  45. package/src/audio/instruments.ts +32 -3
  46. package/src/audio/keys/electric.ts +420 -0
  47. package/src/audio/keys/engine.ts +143 -19
  48. package/src/audio/keys/organ.ts +1335 -0
  49. package/src/audio/keys/piano.ts +50 -6
  50. package/src/audio/keys/sympathetic.ts +127 -0
  51. package/src/audio/live-worker.ts +3 -1
  52. package/src/audio/live.ts +136 -9
  53. package/src/audio/preview.ts +15 -1
  54. package/src/audio/resample.ts +285 -0
  55. package/src/audio/resonators.ts +10 -2
  56. package/src/audio/sampler.ts +168 -21
  57. package/src/audio/samples.ts +26 -1
  58. package/src/audio/strings/body.ts +29 -16
  59. package/src/audio/strings/bow.ts +656 -0
  60. package/src/audio/strings/engine.ts +266 -20
  61. package/src/audio/strings/measure.test-helpers.ts +2 -0
  62. package/src/audio/wav.ts +119 -19
  63. package/src/audio/winds/engine.ts +302 -0
  64. package/src/audio/winds/filters.ts +153 -0
  65. package/src/audio/winds/pitch.ts +104 -0
  66. package/src/audio/winds/trim.ts +80 -0
  67. package/src/audio/winds/trims.ts +917 -0
  68. package/src/audio/winds/voice.ts +479 -0
  69. package/src/commands/expression.ts +103 -22
  70. package/src/commands/fx.ts +4 -1
  71. package/src/commands/granular.ts +7 -2
  72. package/src/commands/help.ts +91 -8
  73. package/src/commands/keys.ts +287 -11
  74. package/src/commands/modal.ts +67 -4
  75. package/src/commands/resample.ts +281 -0
  76. package/src/commands/rig.ts +5 -1
  77. package/src/commands/sample.ts +49 -2
  78. package/src/commands/shift.ts +119 -0
  79. package/src/commands/string.ts +28 -2
  80. package/src/commands/strum.ts +473 -0
  81. package/src/commands/wind.ts +244 -0
  82. package/src/main.ts +129 -7
  83. package/src/project/check.ts +5 -1
  84. package/src/render.ts +16 -0
  85. package/src/session/presence.ts +16 -3
  86. package/src/tui/audition.ts +1 -1
  87. package/src/tui/menu.ts +637 -35
  88. package/src/tui/modal-menu.ts +34 -7
  89. package/src/tui/performance-menu.ts +42 -0
  90. package/src/tui/play-chords.ts +104 -3
  91. package/src/tui/play-mode.ts +1 -0
  92. package/src/tui/play-session.ts +34 -9
  93. package/src/tui/wind-menu.ts +144 -0
@@ -156,6 +156,12 @@ export type PianoNoteOn = Readonly<{
156
156
  trackSeed: number;
157
157
  /** Track seed plus note id (this strike's own randomness). */
158
158
  noteSeed: string;
159
+ /**
160
+ * Una corda (0.6.1) at the onset, 0..1 (half pedal 0.5); absent is 0.
161
+ * The hammer shifts: fewer strings struck, a softer felt spot, more of
162
+ * the unstruck string's aftersound.
163
+ */
164
+ soft?: number;
159
165
  }>;
160
166
 
161
167
  type Rattle = {
@@ -207,18 +213,47 @@ export class PianoVoice {
207
213
  p.decay *
208
214
  (1 - 0.15 * p.felt) *
209
215
  (prepKind === 1 ? 0.12 : prepKind >= 0 ? 0.5 : 1);
210
- const after = clamp(p.after, 0, 0.95);
216
+ const soft = clamp(note.soft ?? 0, 0, 1);
217
+ let after = clamp(p.after, 0, 0.95);
211
218
  const thMs =
212
219
  lerp(3.5, 0.8, keyPos) *
213
220
  lerp(1.6, 0.7, v) *
214
221
  (1 + 1.5 * p.felt) *
215
222
  (1.3 - 0.6 * p.hardness);
223
+ // Una corda (keys.md 2.1): the shifted hammer meets the strings with
224
+ // its softer felt. Modelled spectrally, so the pulse length stays and
225
+ // its nulls never land on a low partial: mode input gains get a smooth
226
+ // two-pole low-pass relative to partial 1 (cutoff at a tenth of the
227
+ // spec's hammer cutoff, lowered 25% at full pedal), floored at -12 dB
228
+ // so no low harmonic is notched out; more of the slow aftersound is
229
+ // excited. Measured at keys 48-84, velocity 0.5-0.9: centroid -12 to
230
+ // -20% through key 72 (-5% at 84), peak -2.3 to -4.8 dB, no partial of
231
+ // the first eight more than 9 dB down (src/audio/keys/pedals.test.ts).
232
+ const softFc =
233
+ 35 *
234
+ 2 ** (5 * v * (0.4 + 0.6 * p.hardness) + 1.5 * keyPos) *
235
+ 0.75 ** soft;
236
+ const softFloor = 1 - 0.75 * soft;
237
+ const softW = (hz: number) =>
238
+ soft > 0
239
+ ? Math.max(
240
+ softFloor,
241
+ ((1 + (f1 / softFc) ** 2) / (1 + (hz / softFc) ** 2)) ** soft,
242
+ )
243
+ : 1;
244
+ if (soft > 0) after = clamp(after + (1 - after) * 0.4 * soft, 0, 0.95);
216
245
  this.pulse = hammerPulse(
217
246
  Math.max(2, Math.round(thMs * 1e-3 * sr)),
218
247
  1 + 2 * v * p.hardness,
219
248
  );
220
249
  const strings = stringsFor(key);
221
- const spread = strings === 1 ? 0.08 : p.unison;
250
+ // Una corda strikes one string fewer: the unison spread narrows.
251
+ const spread =
252
+ strings === 1
253
+ ? 0.08
254
+ : soft > 0
255
+ ? p.unison * (1 - (soft * 1) / strings)
256
+ : p.unison;
222
257
  const maxHz = MODE_CEILING * sr;
223
258
  let partials = 0;
224
259
  while (partials < 160) {
@@ -234,6 +269,8 @@ export class PianoVoice {
234
269
  const shift = prepKind === 0 || prepKind === 2 ? 0.04 + 0.1 * prep() : 0;
235
270
  const candidates: { hz: number; t60: number; u: number }[] = [];
236
271
  let energy = 0;
272
+ let sum0 = 0;
273
+ let sumW = 0;
237
274
  for (let n = 1; n <= partials; n += 1) {
238
275
  let fn = n * f0 * Math.sqrt(1 + B * n * n);
239
276
  if (shift && n > 1) fn *= 1 - shift * Math.sin(n * Math.PI * xb) ** 2;
@@ -241,8 +278,11 @@ export class PianoVoice {
241
278
  if (Math.abs(shape) < 1e-3) continue;
242
279
  const tn = 1 / (1 / T1 + (fn / 3000) ** 2 / (1.2 * p.decay));
243
280
  const damped = prepKind === 1 && n > 3 ? 0.3 : 1;
244
- const u = shape * damped;
245
- energy += u * u;
281
+ const u0 = shape * damped;
282
+ energy += u0 * u0;
283
+ const u = u0 * softW(fn);
284
+ sum0 += Math.abs(u0);
285
+ sumW += Math.abs(u);
246
286
  if (n <= 12) {
247
287
  // Prompt sound at the centre, aftersound split into a symmetric pair.
248
288
  const d = (spread / 2) * (1 + 0.3 * (random() - 0.5));
@@ -266,7 +306,11 @@ export class PianoVoice {
266
306
  t60: 0.6 + 1.2 * prep(),
267
307
  u: 0.35,
268
308
  });
269
- const loud = v ** 1.4 * (1 - 0.4 * p.felt);
309
+ let loud = v ** 1.4 * (1 - 0.4 * p.felt);
310
+ // Una corda's level is 30% down: half the low-pass loss (in summed mode
311
+ // amplitude) is made up so the extra loss stays about 1 dB.
312
+ if (soft > 0)
313
+ loud *= (1 - 0.3 * soft) * Math.sqrt(sum0 / Math.max(sumW, 1e-9));
270
314
  const G = (0.35 * loud) / Math.sqrt(Math.max(energy, 1e-9));
271
315
  const mags = candidates.map(
272
316
  (c) => pulseMag(this.pulse, (TAU * c.hz) / sr) * Math.abs(c.u),
@@ -295,7 +339,7 @@ export class PianoVoice {
295
339
  2800,
296
340
  1.5,
297
341
  0.002,
298
- 0.02 * v * v * p.hardness * (1 - p.felt),
342
+ 0.02 * v * v * p.hardness * (1 - p.felt) * (1 - 0.3 * soft),
299
343
  sr,
300
344
  ),
301
345
  );
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Sympathetic resonance for the modelled pianos (0.6.1, keys-electric
3
+ * lane): a per-track bank of 36 tuned feedback combs (C2..B4), fed by the
4
+ * dry sum, ringing with T60 4 s while the sustain pedal is down (all
5
+ * dampers up) and 0.06 s when it is up. Ported from the design prototype
6
+ * (proto/keys/keys.ts `Sympathetic`).
7
+ *
8
+ * Gating (zero cost otherwise): it runs only on a piano track with `sym`
9
+ * above 0 and at least one sustain pedal `down` or `half` event; between
10
+ * pedal-down spans it stops processing once its energy is below -90 dB.
11
+ * At 44.1 kHz and above it runs at half rate (pairs averaged in, output
12
+ * linearly interpolated), which keeps 48 kHz near the 22.05 kHz cost; the
13
+ * combs top out at B4 (494 Hz), far below the halved Nyquist.
14
+ */
15
+
16
+ import { hermite4 } from "../dsp/interp.ts";
17
+
18
+ const TAU = 2 * Math.PI;
19
+ const LOW = 36;
20
+ const HIGH = 71;
21
+ /** Energy below which the bank goes quiet while the pedal is up. */
22
+ const QUIET = 1e-9;
23
+
24
+ export class Sympathetic {
25
+ private readonly lines: Float64Array[] = [];
26
+ private readonly delay: number[] = [];
27
+ private readonly lp: number[] = [];
28
+ private readonly ws: number[] = [];
29
+ private readonly gUp: number[] = [];
30
+ private readonly gDown: number[] = [];
31
+ private readonly a: number;
32
+ /** Internal decimation: 1 at 22.05 kHz, 2 at 44.1/48 kHz. */
33
+ readonly factor: number;
34
+
35
+ constructor(
36
+ hzOf: (pitch: number) => number,
37
+ readonly amount: number,
38
+ sr: number,
39
+ ) {
40
+ this.factor = sr >= 40_000 ? 2 : 1;
41
+ const rate = sr / this.factor;
42
+ this.a = Math.exp((-TAU * 4000) / rate);
43
+ const lpDelay = this.a / (1 - this.a);
44
+ for (let q = LOW; q <= HIGH; q += 1) {
45
+ const hz = hzOf(q);
46
+ // An unmapped tuning degree has no string to ring.
47
+ if (!(hz > 0) || !Number.isFinite(hz)) continue;
48
+ const d = Math.max(2, rate / hz - lpDelay);
49
+ this.lines.push(new Float64Array(Math.ceil(d) + 8));
50
+ this.delay.push(d);
51
+ this.lp.push(0);
52
+ this.ws.push(0);
53
+ this.gDown.push(10 ** ((-3 * d) / (rate * 4)));
54
+ this.gUp.push(10 ** ((-3 * d) / (rate * 0.06)));
55
+ }
56
+ }
57
+
58
+ /** One internal-rate step: input `x`, returns [left, right] resonance. */
59
+ private step(x: number, pedal: boolean, out: [number, number]): void {
60
+ let yl = 0;
61
+ let yr = 0;
62
+ const a = this.a;
63
+ for (let k = 0; k < this.lines.length; k += 1) {
64
+ const line = this.lines[k]!;
65
+ const size = line.length;
66
+ const w = this.ws[k]!;
67
+ const pos = w - this.delay[k]!;
68
+ const i = Math.floor(pos);
69
+ const at = (j: number) => line[((j % size) + size) % size]!;
70
+ const y = hermite4(at(i - 1), at(i), at(i + 1), at(i + 2), pos - i);
71
+ const f = (this.lp[k] = y + a * (this.lp[k]! - y));
72
+ line[w % size] = x + (pedal ? this.gDown[k]! : this.gUp[k]!) * f;
73
+ this.ws[k] = (w + 1) % size;
74
+ if (k & 1) yr += f;
75
+ else yl += f;
76
+ }
77
+ out[0] = yl * 0.5;
78
+ out[1] = yr * 0.5;
79
+ }
80
+
81
+ /**
82
+ * In place: adds resonance to `left`/`right`. `down(i)` says whether the
83
+ * sustain pedal is down at sample `i` (read per sample pair at most).
84
+ */
85
+ process(
86
+ left: Float64Array,
87
+ right: Float64Array,
88
+ down: (sample: number) => boolean,
89
+ ): void {
90
+ const c = 0.02 * this.amount;
91
+ const out: [number, number] = [0, 0];
92
+ const f = this.factor;
93
+ let prevL = 0;
94
+ let prevR = 0;
95
+ let energy = 0;
96
+ for (let i = 0; i < left.length; i += f) {
97
+ const pedal = down(i);
98
+ let x = 0;
99
+ for (let j = 0; j < f && i + j < left.length; j += 1)
100
+ x += left[i + j]! + right[i + j]!;
101
+ x = (x / (2 * f)) * c;
102
+ // Dampers down and the combs quiet: skip them entirely.
103
+ if (!pedal && energy < QUIET) {
104
+ prevL = 0;
105
+ prevR = 0;
106
+ continue;
107
+ }
108
+ this.step(x, pedal, out);
109
+ energy = 0.999 * energy + 0.001 * (out[0] * out[0] + out[1] * out[1]);
110
+ if (f === 1) {
111
+ left[i]! += out[0];
112
+ right[i]! += out[1];
113
+ } else {
114
+ // Linear interpolation back to the full rate (one sample latency
115
+ // at half rate, inaudible on a 4 s resonance).
116
+ left[i]! += 0.5 * (prevL + out[0]);
117
+ right[i]! += 0.5 * (prevR + out[1]);
118
+ if (i + 1 < left.length) {
119
+ left[i + 1]! += out[0];
120
+ right[i + 1]! += out[1];
121
+ }
122
+ }
123
+ prevL = out[0];
124
+ prevR = out[1];
125
+ }
126
+ }
127
+ }
@@ -18,6 +18,7 @@ export type LiveFullRequest = Readonly<{
18
18
  velocity: number;
19
19
  seconds: number;
20
20
  tick?: number;
21
+ clock?: number;
21
22
  samples?: SampleBank;
22
23
  }>;
23
24
 
@@ -32,7 +33,7 @@ declare const self: Worker;
32
33
  let synth: LiveSynth | undefined;
33
34
 
34
35
  self.onmessage = (event: MessageEvent<LiveFullRequest>) => {
35
- const { id, sampleRate, score, samples, tick, ...note } = event.data;
36
+ const { id, sampleRate, score, samples, tick, clock, ...note } = event.data;
36
37
  try {
37
38
  if (synth?.rate !== sampleRate) synth = new LiveSynth(sampleRate);
38
39
  const pcm = synth.render({
@@ -41,6 +42,7 @@ self.onmessage = (event: MessageEvent<LiveFullRequest>) => {
41
42
  full: true,
42
43
  ...(samples ? { samples } : {}),
43
44
  ...(tick === undefined ? {} : { tick }),
45
+ ...(clock === undefined ? {} : { clock }),
44
46
  });
45
47
  const reply: LiveFullReply = pcm ? { id, pcm } : { id };
46
48
  self.postMessage(reply);
package/src/audio/live.ts CHANGED
@@ -19,10 +19,15 @@ import {
19
19
  import { bpmAtTick } from "../../core/tempo.ts";
20
20
  import { EMPTY_SAMPLE_BANK, sampleKey, type SampleBank } from "./samples.ts";
21
21
  import { RENDER_CHANNELS, renderScorePcm } from "./wav.ts";
22
- import { engineFor } from "./instruments.ts";
22
+ import { engineFor, RING_OUT_FADE_SECONDS } from "./instruments.ts";
23
+ import { chordDigest, chordTimeline } from "./granular.ts";
24
+ import { resolveGranular } from "../../core/granular.ts";
23
25
  import { noteHz, resolveTuning } from "../../core/tuning.ts";
24
26
  import { liveFitPending, withLiveFit } from "./fit.ts";
27
+ import { isOrganFamily } from "../../core/keys.ts";
25
28
  import type { LiveFullReply, LiveFullRequest } from "./live-worker.ts";
29
+ import { isBowed, liveStringTrack } from "./strings/engine.ts";
30
+ import { resolveString } from "../../core/strings.ts";
26
31
 
27
32
  /** A rendered live note: interleaved stereo 16-bit PCM. */
28
33
  export type LiveNotePcm = Readonly<{
@@ -37,11 +42,22 @@ export type LiveNotePcm = Readonly<{
37
42
  */
38
43
  fitting?: true;
39
44
  /**
40
- * True when this is only the first window of a guitar-rig note
45
+ * True when this is only the first window of a guitar-rig or bowed note
41
46
  * (`LIVE_RIG_WINDOW_SECONDS`): call `LiveSynth.render` again with
42
47
  * `full: true` off the key path and swap the result in by voice id.
43
48
  */
44
49
  partial?: boolean;
50
+ /**
51
+ * The organ clock tick a first window rendered at: pass it back as the
52
+ * request's `clock` for the full pass so the wheels and rotors continue.
53
+ */
54
+ clock?: number;
55
+ /**
56
+ * The key's release does not fade the voice: it rings to the end of its
57
+ * rendered tail (0.6.1 modal `damp 0`), which ends in a `RING_OUT_FADE_SECONDS`
58
+ * fade when the length cap cut it.
59
+ */
60
+ ringOut?: true;
45
61
  }>;
46
62
 
47
63
  export type LiveNoteRequest = Readonly<{
@@ -58,6 +74,8 @@ export type LiveNoteRequest = Readonly<{
58
74
  tick?: number;
59
75
  /** Render the whole note even through a guitar rig (the background pass). */
60
76
  full?: boolean;
77
+ /** An organ note's clock tick from its first window (`LiveNotePcm.clock`). */
78
+ clock?: number;
61
79
  }>;
62
80
 
63
81
  /**
@@ -78,6 +96,8 @@ const CACHE_ENTRIES = 96;
78
96
  */
79
97
  export class LiveSynth {
80
98
  private readonly cache = new Map<string, LiveNotePcm>();
99
+ /** f061-organ: the live organ clock's origin (performance.now ms). */
100
+ private readonly organEpoch = performance.now();
81
101
 
82
102
  public constructor(private readonly sampleRate: number) {}
83
103
 
@@ -122,6 +142,38 @@ export class LiveSynth {
122
142
  );
123
143
  const velocity = Math.max(0, Math.min(1, request.velocity));
124
144
  const pitch = Math.max(0, Math.min(127, Math.round(request.pitch)));
145
+ // f061-organ: one rotating speaker and one set of free-running wheels
146
+ // per organ track. Each key renders from the song tick it sounds at
147
+ // (or the synth's own clock when stopped), so successive notes share
148
+ // the track's rotor and wheel phases instead of restarting them.
149
+ const organTick =
150
+ track.keys && isOrganFamily(track.instrument)
151
+ ? Math.max(
152
+ 0,
153
+ Math.round(
154
+ request.clock ??
155
+ request.tick ??
156
+ ((performance.now() - this.organEpoch) / 1000) * ticksPerSecond,
157
+ ),
158
+ )
159
+ : undefined;
160
+ // Granular `quant chord` (0.6.1): a live note snaps to the song's
161
+ // chords at the played tick, as the offline render of the same note.
162
+ const quantChord =
163
+ track.granular !== undefined &&
164
+ liveEngine?.id === "granular" &&
165
+ resolveGranular(track.granular).quant === "chord"
166
+ ? { score, tick: request.tick ?? 0 }
167
+ : undefined;
168
+ const chord = quantChord
169
+ ? chordDigest(
170
+ chordTimeline(score),
171
+ quantChord.tick,
172
+ quantChord.tick +
173
+ durationTicks +
174
+ Math.ceil(liveEngine!.tailSeconds(track, pitch) * ticksPerSecond),
175
+ )
176
+ : undefined;
125
177
  const key = JSON.stringify([
126
178
  liveTrack(track),
127
179
  pitch,
@@ -137,6 +189,7 @@ export class LiveSynth {
137
189
  ...(score.tuning || track.tuning
138
190
  ? [score.tuning ?? null, score.key]
139
191
  : []),
192
+ ...(organTick !== undefined ? [`organ:${organTick}`] : []),
140
193
  // A 0.6 engine's assets, and the key it may tune to.
141
194
  ...(liveEngine
142
195
  ? [
@@ -147,21 +200,44 @@ export class LiveSynth {
147
200
  score.key ?? null,
148
201
  ]
149
202
  : []),
203
+ ...(chord ? [chord] : []),
150
204
  ]);
151
- return { track, score, bpm, seconds, durationTicks, velocity, pitch, key };
205
+ return {
206
+ track,
207
+ score,
208
+ bpm,
209
+ seconds,
210
+ durationTicks,
211
+ velocity,
212
+ pitch,
213
+ key,
214
+ organTick,
215
+ quantChord,
216
+ };
152
217
  }
153
218
 
154
219
  public render(request: LiveNoteRequest): LiveNotePcm | undefined {
155
220
  const plan = this.plan(request);
156
221
  if (!plan) return undefined;
157
- const { track, score, bpm, seconds, durationTicks, velocity, pitch, key } =
158
- plan;
222
+ const {
223
+ track,
224
+ score,
225
+ bpm,
226
+ seconds,
227
+ durationTicks,
228
+ velocity,
229
+ pitch,
230
+ key,
231
+ organTick,
232
+ quantChord,
233
+ } = plan;
159
234
  const liveEngine = engineFor(track);
160
235
  // The first window of a rig note: its own cache entry, swapped for the
161
236
  // whole note once the background pass has rendered it.
237
+ // Organs too: wheels, rotors and drive cost 4-8 ms per note-second.
162
238
  const windowed =
163
239
  request.full !== true &&
164
- hasRig(track) &&
240
+ (hasRig(track) || organTick !== undefined || bowedTrack(track)) &&
165
241
  seconds > LIVE_RIG_WINDOW_SECONDS &&
166
242
  !this.cache.has(key);
167
243
  if (windowed) {
@@ -214,6 +290,8 @@ export class LiveSynth {
214
290
  ? 1
215
291
  : Math.max(MAX_LIVE_NOTE_SECONDS + 4, seconds + tail),
216
292
  ...(request.samples ? { samples: request.samples } : {}),
293
+ ...(organTick ? { seedTick: organTick } : {}),
294
+ ...(quantChord ? { quantChord } : {}),
217
295
  }),
218
296
  );
219
297
  const frames = windowed
@@ -233,9 +311,13 @@ export class LiveSynth {
233
311
  ),
234
312
  )
235
313
  : tail;
314
+ const ringOut = !windowed && liveEngine?.ringOut?.(track) === true;
315
+ const pcm = audio.pcm.subarray(0, frames * RENDER_CHANNELS);
316
+ if (ringOut) fadeCutTail(pcm, frames, this.sampleRate);
236
317
  const rendered: LiveNotePcm = {
237
- pcm: audio.pcm.subarray(0, frames * RENDER_CHANNELS),
318
+ pcm,
238
319
  frames,
320
+ ...(ringOut ? { ringOut: true as const } : {}),
239
321
  ...(liveEngine
240
322
  ? {
241
323
  releaseSeconds: Math.min(
@@ -245,8 +327,11 @@ export class LiveSynth {
245
327
  }
246
328
  : {}),
247
329
  ...(windowed ? { partial: true } : {}),
330
+ ...(windowed && organTick !== undefined ? { clock: organTick } : {}),
248
331
  };
249
332
  if (liveFitPending()) return { ...rendered, fitting: true };
333
+ // Organ keys render at their song tick: caching them would only evict.
334
+ if (organTick !== undefined) return rendered;
250
335
  this.cache.set(windowed ? `${key}#window` : key, rendered);
251
336
  this.trim();
252
337
  return rendered;
@@ -266,17 +351,32 @@ export class LiveSynth {
266
351
  * without track `time`, so a key sounds once, as played, not re-placed.
267
352
  */
268
353
  function liveTrack(track: Track): Track {
269
- // A held key is the performance: the recorded pedal and the render-time
354
+ // A held key is the performance: the recorded pedals and the render-time
270
355
  // humanize stay out of it. The velocity curve stays, so a key sounds like
271
356
  // the note it records as.
272
357
  const {
273
358
  solo: _solo,
274
359
  time: _time,
275
360
  pedal: _pedal,
361
+ softPedal: _softPedal,
362
+ sostenuto: _sostenuto,
276
363
  humanize: _humanize,
277
364
  ...rest
278
365
  } = track;
279
- return { ...rest, muted: false };
366
+ // A bowed section plays two players live (f061-bowed render budget).
367
+ return liveStringTrack({ ...rest, muted: false });
368
+ }
369
+
370
+ /**
371
+ * Whether the track is a bowed string: a held bow costs about 5 ms per
372
+ * second, so it windows like a rig (the bow loop is causal too).
373
+ */
374
+ function bowedTrack(track: Track): boolean {
375
+ return (
376
+ track.instrument === "string" &&
377
+ track.string !== undefined &&
378
+ isBowed(resolveString(track.string))
379
+ );
280
380
  }
281
381
 
282
382
  /** Whether the track plays through a guitar rig stage. */
@@ -299,6 +399,32 @@ function samplerDigest(track: Track, bank: SampleBank | undefined): string[] {
299
399
  }
300
400
 
301
401
  /** Frames up to the last non-silent sample, so mixing skips the dead tail. */
402
+ /**
403
+ * A ring-out note cut by the render length still sounds at its last frame:
404
+ * fade its final `RING_OUT_FADE_SECONDS` linearly to zero so the cap never
405
+ * clicks (each step stays under -60 dB of full scale). A tail that already
406
+ * decayed to silence is left alone.
407
+ */
408
+ export function fadeCutTail(
409
+ pcm: Int16Array,
410
+ frames: number,
411
+ sampleRate: number,
412
+ ): void {
413
+ if (frames === 0) return;
414
+ const last = (frames - 1) * RENDER_CHANNELS;
415
+ // Below -60 dBFS the cut is inaudible.
416
+ if (Math.abs(pcm[last]!) < 33 && Math.abs(pcm[last + 1]!) < 33) return;
417
+ const fade = Math.min(frames, Math.round(RING_OUT_FADE_SECONDS * sampleRate));
418
+ for (let i = 0; i < fade; i += 1) {
419
+ const frame = frames - fade + i;
420
+ const gain = (fade - 1 - i) / fade;
421
+ for (let c = 0; c < RENDER_CHANNELS; c += 1) {
422
+ const at = frame * RENDER_CHANNELS + c;
423
+ pcm[at] = Math.round(pcm[at]! * gain);
424
+ }
425
+ }
426
+ }
427
+
302
428
  function audibleFrames(pcm: Int16Array): number {
303
429
  let last = pcm.length - 1;
304
430
  while (last >= 0 && pcm[last] === 0) last -= 1;
@@ -344,6 +470,7 @@ export class LiveFullRenderer {
344
470
  velocity: request.velocity,
345
471
  seconds: request.seconds,
346
472
  ...(request.tick === undefined ? {} : { tick: request.tick }),
473
+ ...(request.clock === undefined ? {} : { clock: request.clock }),
347
474
  ...(request.samples ? { samples: request.samples } : {}),
348
475
  };
349
476
  try {
@@ -16,6 +16,12 @@
16
16
  * wavetables so position changes are audible.
17
17
  */
18
18
  import { isDrumInstrument } from "../../core/drums.ts";
19
+ import { slicePedals } from "../../core/expression.ts";
20
+ import {
21
+ isStringTrack,
22
+ resolveString,
23
+ stringPresetOf,
24
+ } from "../../core/strings.ts";
19
25
  import type { SongMaster } from "../../core/master.ts";
20
26
  import { MODES, parseKey } from "../../core/chords.ts";
21
27
  import { rhythmVoicePitch } from "../../core/rhythm.ts";
@@ -131,6 +137,14 @@ export function phraseRole(track: Track): PhraseRole {
131
137
  if (isWavetableInstrument(track.instrument)) return "drone";
132
138
  // Grain clouds are textures: held notes let them evolve.
133
139
  if (track.instrument === "granular") return "drone";
140
+ if (isStringTrack(track) && resolveString(track.string).exciter === "bow") {
141
+ // Bowed: low strings walk a line, solo strings sing a legato line,
142
+ // sections and pizz/trem hold chords.
143
+ const preset = stringPresetOf(track.string);
144
+ if (/^(contrabass|contrabasses|cello|cellos)$/.test(preset)) return "riff";
145
+ if (/^(violin|viola|fiddle|erhu|kamancheh)$/.test(preset)) return "lead";
146
+ return "chord";
147
+ }
134
148
  const words = `${track.instrument} ${track.name} ${track.id}`.toLowerCase();
135
149
  if (/\b(bass|sub|808)/.test(words)) return "riff";
136
150
  if (/\b(lead|arp|pluck|saw|square|pulse|z_|mono|melody|hook)/.test(words))
@@ -287,7 +301,7 @@ function sliceTrack(track: Track, start: number, end: number): Track {
287
301
  lanes[name] = sliceLane(points, start, end);
288
302
  sliced.fxAutomation = lanes;
289
303
  }
290
- return sliced as Track;
304
+ return slicePedals(sliced as Track, [{ from: start, to: end, offset: 0 }]);
291
305
  }
292
306
 
293
307
  /**