@hraness/dawg 0.4.0 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to dawg are recorded here. Versions follow [semantic version
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.4.1
8
+
9
+ Sound previews now crossfade instead of clicking, reverb changes are heard in under 100 ms, and the rhythm editor and chord settings hold changes until you keep them. dawg.sh gains a security policy and security headers.
10
+
11
+ ### Previewing changes
12
+
13
+ - **Edits crossfade instead of clicking.** When the loop (audition or song) swaps to a new render, the old and new buffers crossfade over 20 ms with equal-power gains at the same beat, so a change no longer steps the waveform. Playback only: renders and exports are byte-identical.
14
+ - **`/euclid` and the chord settings stage while the loop plays.** `Space` in the rhythm editor or the menu's Chords section (`/menu chords`) starts the audition loop; changes are then staged and heard (`●`, `B staged N`, `E(5,16) ← E(4,16)`, `Db ← C`), `a` flips A/B, `Enter` keeps them as one undo step and `Esc` reverts with nothing written. In the Chords section the loop plays the track's chord phrase under the current settings. With the loop off, both commit at once as before; their key footers and `?` panels use the shared audition keys.
15
+ - **Reverb changes are heard faster.** The stem cache keeps each track's reverb input and tail apart from its dry signal, so a reverb mix (or mix lane) change re-mixes the cached tail instead of re-rendering the voice and the room. Key to audio scheduled on a 4-track song: reverb mix on a wavetable pad in context 140 → 64 ms median (p90 151 → 68 ms); a filter change in context 100 → 74 ms. The bus mix loops also moved out of the large render function, which the JIT optimized late. Renders stay byte-identical (a new test checks cached re-mixes against cold renders and recorded digests).
16
+
17
+ ### Security
18
+
19
+ - **A security policy.** `SECURITY.md` and `https://dawg.sh/.well-known/security.txt` say how to report a vulnerability privately.
20
+ - **dawg.sh sends security headers** on every route: a Content Security Policy limited to the site's own origin and its analytics hosts, HSTS, `nosniff`, `DENY` framing, a strict referrer policy and a permissions policy that turns off camera, microphone and location.
21
+ - Dependabot also watches the site's dependencies (minor and patch updates, grouped).
22
+
7
23
  ## 0.4.0
8
24
 
9
25
  dawg now has a full sound engine with Strudel's synth, effect and sample parameters, Strudel sample packs, wavetables (including ones made from your own audio), Euclidean drum rows and a pattern library, Orchid-style chords, sound previews with A/B, and one consistent set of keys across every screen. Projects from 0.3.0 open and sound the same, and the `dawg` SDK is 1.13.0 (every step additive within v1).
package/DAWG.md CHANGED
@@ -678,10 +678,11 @@ Prompt grammar: `euclid kick 4 16`, `euclid hat 7 16 rotate 2`, `euclid hat swin
678
678
  | `← →` / `h l` / `- +` | nudge the selected parameter |
679
679
  | `Tab` / `Shift-Tab` (`] [`) | next / previous parameter |
680
680
  | digits, `.`, `-`, Backspace | type a value, Enter applies |
681
- | Enter | add a row for a voice without one |
682
- | Space | audition the voice |
681
+ | Enter | add a row for a voice without one; keep (looping) |
682
+ | Space | start or stop the audition loop (staging) |
683
+ | `a` / `c` | A/B / solo ↔ in context, while the loop plays |
683
684
  | `x` / Delete, `f` | remove the row and its notes / freeze it to notes |
684
- | Esc | back (cancels typing, then closes) |
685
+ | Esc | cancel typing, revert staged changes, then close |
685
686
 
686
687
  ## Chords
687
688
 
@@ -836,7 +837,7 @@ Hear a sound change before you keep it. In the edit menu (every section: Sound,
836
837
 
837
838
  The loop is the track's own notes over its loop region when that is four bars or shorter, otherwise the two bars under the playhead (or the track's first two bars with notes, if those are empty). A track with no notes plays a short phrase by role: a chord for pads and keys, a riff for bass and leads, a groove for kits and drums, and one held note for wavetables so position and envelope changes are audible. It plays solo by default; `c` switches to the whole mix with the track in it.
838
839
 
839
- While the loop plays, each change you make is **staged**, not committed. The loop re-renders only the changed track through the normal renderer and stem cache, in the render worker, and swaps it in within about 100 ms. Held keys are coalesced so only the latest value renders. The menu title shows `●` and `B staged N`, and each changed row shows the staged value beside the committed one (`mix 0.5 ← 0.3`).
840
+ While the loop plays, each change you make is **staged**, not committed. The loop re-renders only the changed track through the normal renderer and stem cache, in the render worker, and swaps it in within about 100 ms (a reverb mix change re-mixes the cached tail and is heard in about 65 ms). Every swap, here and in the song player, crossfades old to new over 20 ms at the same beat, so changes never click; renders and exports never pass through the crossfade. Held keys are coalesced so only the latest value renders. The menu title shows `●` and `B staged N`, and each changed row shows the staged value beside the committed one (`mix 0.5 ← 0.3`).
840
841
 
841
842
  | Key | While auditioning |
842
843
  | ------- | ------------------------------------------------------------ |
@@ -850,6 +851,8 @@ While the loop plays, each change you make is **staged**, not committed. The loo
850
851
 
851
852
  Kept changes are one `ScoreOperation` (`preview.commit`, listing the commands), so `Ctrl-Z` takes them all back at once, and they sync to other windows and the project files like any edit. If the score changes underneath (another window, the agent, an undo), the staged commands are re-applied on top of the new score; any that no longer apply are dropped, with a notice. Leaving the menu reverts anything staged. With the loop off, the menu behaves as before: each change is committed right away.
852
853
 
854
+ **The rhythm editor and the chord settings stage too.** `/euclid` and the chord settings (the menu's Chords section, `/menu chords`) use the same loop and keys. In `/euclid`, `Space` loops the drum track; pulses, steps, rotate, typed values, a new row, `off` and `freeze` are staged, the title shows `●` and `B staged N`, and a changed lane shows `E(5,16) ← E(4,16)`. Chord settings are window settings rather than score edits, so while the Chords section is open the loop plays the focused track's chord phrase (two bars of the song key's progression, voiced and performed by the current settings: inversion, spread, bass, sevenths, block/strum/arp, pattern); a staged setting changes the B phrase, and `a` flips back to the committed settings. `Enter` keeps every staged change as one undo step: rhythm edits as one `preview.commit` revision, and chord settings applied at once (a key change, the only one stored in the score, as one revision). `Esc` reverts with nothing written. With the loop off, both screens commit each change at once, as before.
855
+
853
856
  **Lists audition on hover.** With the loop on, moving the cursor through a list plays the highlighted item on the loop: the wavetable list (built-in, pack and project tables), instruments, drum kits and patterns, and every choice list (filter type, warp mode, presets). It is the browser-preview model of Ableton and Bitwig, applied to the loop you are already hearing. Each move replaces the previous hover, so the staged count stays at one, and fast moves skip straight to the latest item. A pack item that has to be fetched shows `fetching…` in the title; the cursor keeps moving and the item plays once it arrives. `Enter` chooses the item (it stays staged until you keep), `Esc` or `←` leaves the list and drops the hover. The `/kit` and `/pattern` pickers work the same way: `Space` starts the loop, moving hears each kit or groove, `Enter` keeps it, `Esc` cancels.
854
857
 
855
858
  | Key in a list | While auditioning |
package/README.md CHANGED
@@ -15,17 +15,17 @@ curl -fsSL https://dawg.sh/install | sh
15
15
  or install the release tarball from GitHub directly:
16
16
 
17
17
  ```sh
18
- bun add -g https://github.com/hraness/dawg/releases/download/v0.4.0/hraness-dawg-0.4.0.tgz
18
+ bun add -g https://github.com/hraness/dawg/releases/download/v0.4.1/hraness-dawg-0.4.1.tgz
19
19
  dawg --help
20
20
  ```
21
21
 
22
22
  Each [release](https://github.com/hraness/dawg/releases) is immutable and ships the tarball, a `SHA256SUMS` file and a build provenance attestation. To check a download before installing it:
23
23
 
24
24
  ```sh
25
- gh release download v0.4.0 --repo hraness/dawg
25
+ gh release download v0.4.1 --repo hraness/dawg
26
26
  shasum -a 256 -c SHA256SUMS
27
- gh attestation verify hraness-dawg-0.4.0.tgz --repo hraness/dawg
28
- bun add -g "$PWD/hraness-dawg-0.4.0.tgz"
27
+ gh attestation verify hraness-dawg-0.4.1.tgz --repo hraness/dawg
28
+ bun add -g "$PWD/hraness-dawg-0.4.1.tgz"
29
29
  ```
30
30
 
31
31
  dawg is also on npm as [`@hraness/dawg`](https://www.npmjs.com/package/@hraness/dawg):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/dawg",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,7 +21,15 @@ import {
21
21
  import { applyChorus, applyLeslie, applyPhaser } from "./modulation.ts";
22
22
  import { applyDelay, applyReverb } from "./space.ts";
23
23
 
24
- export { delayTailFor, reverbImpulse, reverbTailFor } from "./space.ts";
24
+ export {
25
+ addReverbWet,
26
+ delayTailFor,
27
+ reverbActive,
28
+ reverbImpulse,
29
+ reverbTailFor,
30
+ reverbWet,
31
+ type ReverbWet,
32
+ } from "./space.ts";
25
33
  export { interpolateAutomation, type EffectContext } from "./common.ts";
26
34
 
27
35
  type MonoStage = (
@@ -77,6 +85,7 @@ export function applyMonoChain(
77
85
  /**
78
86
  * Stereo stages, in chain order, after pan. A track on a shared orbit bus
79
87
  * (`sends: false`) skips delay and reverb: the bus applies them to the sum.
88
+ * `reverb: false` stops before the reverb (the stem cache runs it apart).
80
89
  */
81
90
  export function applyStereoChain(
82
91
  left: Float64Array,
@@ -84,12 +93,13 @@ export function applyStereoChain(
84
93
  track: Track,
85
94
  context: EffectContext,
86
95
  sends = true,
96
+ reverb = sends,
87
97
  ): void {
88
98
  for (const stage of FX_CHAIN.slice(PAN_INDEX + 1)) {
89
99
  if (stage === "delay") {
90
100
  if (sends) applyDelay(left, right, track, context);
91
101
  } else if (stage === "reverb") {
92
- if (sends) applyReverb(left, right, track, context);
102
+ if (reverb) applyReverb(left, right, track, context);
93
103
  } else {
94
104
  const values = track.fx?.[stage as FxName];
95
105
  const apply = STEREO[stage as FxName];
@@ -119,9 +119,48 @@ export function convolveStereo(
119
119
  gain: number,
120
120
  gainAt?: (index: number) => number,
121
121
  ): void {
122
+ const rawL = new Float64Array(input.length);
123
+ const rawR = new Float64Array(input.length);
124
+ const scale = convolveRaw(input, impulse, rawL, rawR);
125
+ if (scale === 0) return;
126
+ addConvolved(rawL, rawR, scale, outL, outR, gain, gainAt);
127
+ }
128
+
129
+ /**
130
+ * Adds the unscaled convolution from `convolveRaw` at `gain` (or `gainAt`
131
+ * per sample): `out += raw · (gain · scale)`, the order `convolveStereo`
132
+ * always used, so a cached raw signal mixes to the same samples.
133
+ */
134
+ export function addConvolved(
135
+ rawL: Float64Array,
136
+ rawR: Float64Array,
137
+ scale: number,
138
+ outL: Float64Array,
139
+ outR: Float64Array,
140
+ gain: number,
141
+ gainAt?: (index: number) => number,
142
+ ): void {
143
+ for (let index = 0; index < rawL.length; index += 1) {
144
+ const g = (gainAt ? gainAt(index) : gain) * scale;
145
+ outL[index]! += rawL[index]! * g;
146
+ outR[index]! += rawR[index]! * g;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The convolution input ∗ impulse into `rawL`/`rawR` before its inverse-FFT
152
+ * scale, which it returns. Samples past the input are left as they were
153
+ * (zero for fresh arrays), as is everything when the impulse is empty.
154
+ */
155
+ export function convolveRaw(
156
+ input: Float64Array,
157
+ impulse: Impulse,
158
+ rawL: Float64Array,
159
+ rawR: Float64Array,
160
+ ): number {
122
161
  const length = input.length;
123
162
  const irLength = impulse.left.length;
124
- if (length === 0 || irLength === 0) return;
163
+ if (length === 0 || irLength === 0) return 0;
125
164
  const block = partitionSize(irLength);
126
165
  const n = block * 2;
127
166
  const partitions = Math.ceil(irLength / block);
@@ -179,12 +218,11 @@ export function convolveStereo(
179
218
  const outStart = k * block;
180
219
  const count = Math.min(block, length - outStart);
181
220
  for (let i = 0; i < count; i += 1) {
182
- const index = outStart + i;
183
- const g = (gainAt ? gainAt(index) : gain) * scale;
184
- outL[index]! += accRe[block + i]! * g;
185
- outR[index]! += accIm[block + i]! * g;
221
+ rawL[outStart + i] = accRe[block + i]!;
222
+ rawR[outStart + i] = accIm[block + i]!;
186
223
  }
187
224
  }
225
+ return scale;
188
226
  }
189
227
 
190
228
  /** Built-in impulse names (`builtin:<name>`). */
@@ -5,8 +5,9 @@
5
5
  import type { Track } from "../../../core/score.ts";
6
6
  import type { DecodedSample } from "../samples.ts";
7
7
  import {
8
+ addConvolved,
8
9
  builtinImpulse,
9
- convolveStereo,
10
+ convolveRaw,
10
11
  sampleImpulse,
11
12
  type Impulse,
12
13
  } from "./convolution.ts";
@@ -181,6 +182,10 @@ export function reverbTailFor(
181
182
  * feedback for an exact -60 dB decay time; `dim` sets the in-loop damping
182
183
  * frequency, `lowpass` filters the input and `predelay` delays it.
183
184
  * `wetOnly` (a shared orbit bus) replaces the input with the tail alone.
185
+ *
186
+ * It runs in two steps: `reverbWet` (the tail before its mix gain, which
187
+ * does not depend on `mix`) and `addReverbWet`. The stem cache keeps the
188
+ * first, so a mix-only edit skips the room entirely.
184
189
  */
185
190
  export function applyReverb(
186
191
  left: Float64Array,
@@ -189,28 +194,50 @@ export function applyReverb(
189
194
  context: EffectContext,
190
195
  wetOnly = false,
191
196
  ): void {
192
- const reverb = track.reverb;
193
- if (!reverb) return;
194
- const mixParam = new Param(
195
- reverb.mix,
197
+ const wet = reverbWet(left, right, track, context);
198
+ if (wet) addReverbWet(left, right, wet, track, context, wetOnly);
199
+ }
200
+
201
+ /** A reverb's tail before its mix gain (`scale` for an impulse response). */
202
+ export type ReverbWet = Readonly<{
203
+ left: Float64Array;
204
+ right: Float64Array;
205
+ /** The convolution's inverse-FFT scale; absent for the algorithmic room. */
206
+ scale?: number;
207
+ }>;
208
+
209
+ function mixParamOf(track: Track, context: EffectContext): Param {
210
+ return new Param(
211
+ track.reverb?.mix ?? 0,
196
212
  track.fxAutomation?.["reverb-mix"],
197
213
  context.samplesPerTick,
198
214
  );
199
- if (reverb.mix <= 0 && !mixParam.automated) return;
215
+ }
216
+
217
+ /** Whether the track's reverb sounds at all (a mix above 0, or a lane). */
218
+ export function reverbActive(track: Track): boolean {
219
+ const reverb = track.reverb;
220
+ if (!reverb) return false;
221
+ return (
222
+ reverb.mix > 0 || (track.fxAutomation?.["reverb-mix"] ?? []).length > 0
223
+ );
224
+ }
225
+
226
+ /**
227
+ * The reverb tail of the pair, before the mix gain; `left`/`right` are
228
+ * read, not changed. Undefined when the reverb is off.
229
+ */
230
+ export function reverbWet(
231
+ left: Float64Array,
232
+ right: Float64Array,
233
+ track: Track,
234
+ context: EffectContext,
235
+ ): ReverbWet | undefined {
236
+ const reverb = track.reverb;
237
+ if (!reverb || !reverbActive(track)) return undefined;
200
238
  const { sampleRate } = context;
201
239
  const impulse = reverbImpulse(track, sampleRate, context.irs);
202
- if (impulse) {
203
- applyConvolutionReverb(
204
- left,
205
- right,
206
- track,
207
- context,
208
- impulse,
209
- mixParam,
210
- wetOnly,
211
- );
212
- return;
213
- }
240
+ if (impulse) return convolutionWet(left, right, track, context, impulse);
214
241
  const scale = sampleRate / 44_100;
215
242
  const sizeFeedback = 0.7 + 0.28 * reverb.size;
216
243
  const damp =
@@ -219,7 +246,6 @@ export function applyReverb(
219
246
  : Math.exp(
220
247
  (-2 * Math.PI * Math.min(reverb.dim, sampleRate * 0.45)) / sampleRate,
221
248
  );
222
- let wet = reverb.mix * REVERB_WET_SCALE;
223
249
  const channel = (spread: number) => ({
224
250
  combs: COMB_TUNING.map((tuning) => {
225
251
  const length = Math.max(1, Math.round((tuning + spread) * scale));
@@ -266,9 +292,9 @@ export function applyReverb(
266
292
  const predelaySamples = Math.round((reverb.predelay ?? 0) * sampleRate);
267
293
  const predelay =
268
294
  predelaySamples > 0 ? new Float64Array(predelaySamples) : undefined;
295
+ const wetL = new Float64Array(left.length);
296
+ const wetR = new Float64Array(left.length);
269
297
  for (let index = 0; index < left.length; index += 1) {
270
- if (mixParam.automated && index % CONTROL_SAMPLES === 0)
271
- wet = mixParam.at(index) * REVERB_WET_SCALE;
272
298
  let input = (left[index]! + right[index]!) * REVERB_INPUT_GAIN;
273
299
  if (inputFilter) input = inputFilter.process(input);
274
300
  if (predelay) {
@@ -277,12 +303,60 @@ export function applyReverb(
277
303
  predelay[slot] = input;
278
304
  input = delayed;
279
305
  }
306
+ wetL[index] = process(sides[0], input);
307
+ wetR[index] = process(sides[1], input);
308
+ }
309
+ return { left: wetL, right: wetR };
310
+ }
311
+
312
+ /**
313
+ * Add (or with `wetOnly`, write) a tail from `reverbWet` at the track's
314
+ * mix and mix lane, in the order the one-pass reverb always used, so a
315
+ * cached tail mixes to the same samples.
316
+ */
317
+ export function addReverbWet(
318
+ left: Float64Array,
319
+ right: Float64Array,
320
+ tail: ReverbWet,
321
+ track: Track,
322
+ context: EffectContext,
323
+ wetOnly = false,
324
+ ): void {
325
+ const mixParam = mixParamOf(track, context);
326
+ if (tail.scale !== undefined) {
327
+ if (wetOnly) {
328
+ left.fill(0);
329
+ right.fill(0);
330
+ }
331
+ if (tail.scale === 0) return;
332
+ let wet = mixParam.fallback * IR_WET_SCALE;
333
+ addConvolved(
334
+ tail.left,
335
+ tail.right,
336
+ tail.scale,
337
+ left,
338
+ right,
339
+ wet,
340
+ mixParam.automated
341
+ ? (index) => {
342
+ if (index % CONTROL_SAMPLES === 0)
343
+ wet = mixParam.at(index) * IR_WET_SCALE;
344
+ return wet;
345
+ }
346
+ : undefined,
347
+ );
348
+ return;
349
+ }
350
+ let wet = mixParam.fallback * REVERB_WET_SCALE;
351
+ for (let index = 0; index < left.length; index += 1) {
352
+ if (mixParam.automated && index % CONTROL_SAMPLES === 0)
353
+ wet = mixParam.at(index) * REVERB_WET_SCALE;
280
354
  if (wetOnly) {
281
- left[index] = process(sides[0], input) * wet;
282
- right[index] = process(sides[1], input) * wet;
355
+ left[index] = tail.left[index]! * wet;
356
+ right[index] = tail.right[index]! * wet;
283
357
  } else {
284
- left[index]! += process(sides[0], input) * wet;
285
- right[index]! += process(sides[1], input) * wet;
358
+ left[index]! += tail.left[index]! * wet;
359
+ right[index]! += tail.right[index]! * wet;
286
360
  }
287
361
  }
288
362
  }
@@ -293,15 +367,13 @@ export function applyReverb(
293
367
  * `size`, `fade` and `dim` describe the algorithmic tail and do nothing
294
368
  * here: the impulse is the room.
295
369
  */
296
- function applyConvolutionReverb(
370
+ function convolutionWet(
297
371
  left: Float64Array,
298
372
  right: Float64Array,
299
373
  track: Track,
300
374
  context: EffectContext,
301
375
  impulse: Impulse,
302
- mixParam: Param,
303
- wetOnly: boolean,
304
- ): void {
376
+ ): ReverbWet {
305
377
  const reverb = track.reverb!;
306
378
  const { sampleRate } = context;
307
379
  const input = new Float64Array(left.length);
@@ -315,23 +387,8 @@ function applyConvolutionReverb(
315
387
  if (inputFilter) value = inputFilter.process(value);
316
388
  input[index + offset] = value;
317
389
  }
318
- if (wetOnly) {
319
- left.fill(0);
320
- right.fill(0);
321
- }
322
- let wet = reverb.mix * IR_WET_SCALE;
323
- convolveStereo(
324
- input,
325
- impulse,
326
- left,
327
- right,
328
- wet,
329
- mixParam.automated
330
- ? (index) => {
331
- if (index % CONTROL_SAMPLES === 0)
332
- wet = mixParam.at(index) * IR_WET_SCALE;
333
- return wet;
334
- }
335
- : undefined,
336
- );
390
+ const wetL = new Float64Array(left.length);
391
+ const wetR = new Float64Array(left.length);
392
+ const scale = convolveRaw(input, impulse, wetL, wetR);
393
+ return { left: wetL, right: wetR, scale };
337
394
  }
@@ -170,6 +170,17 @@ function parseCommand(value: string): string[] {
170
170
  return parts;
171
171
  }
172
172
 
173
+ /**
174
+ * Swaps crossfade old to new over this window with equal-power gains
175
+ * (cos/sin), reading both loops at the same beat. 20 ms is long enough that
176
+ * the step a hard cut makes is spread below ~50 Hz (no click, even on a
177
+ * full-scale low bass), short enough to sit inside the transport's 30 ms
178
+ * resync tolerance and below the shortest audible echo (~30-50 ms), so an
179
+ * edit never sounds smeared or doubled. Playback only: renders, exports and
180
+ * the loop buffers themselves never pass through it.
181
+ */
182
+ export const SWAP_FADE_SECONDS = 0.02;
183
+
173
184
  /** Swaps re-anchor to the transport only past this disagreement. */
174
185
  const RESYNC_SECONDS = 0.03;
175
186
 
@@ -228,6 +239,16 @@ type Loop = Readonly<{
228
239
  framesPerBeat: number;
229
240
  }>;
230
241
 
242
+ /** A loop fading out after a swap, read on from where it was. */
243
+ type FadeOut = {
244
+ loop: Loop;
245
+ cursor: number;
246
+ /** Its gain when the fade began (below 1 when swapped out mid-fade). */
247
+ from: number;
248
+ /** Frames of the fade already written. */
249
+ done: number;
250
+ };
251
+
231
252
  /**
232
253
  * The click bus: the engine asks `beatAt` for the transport beat at a stream
233
254
  * frame's wall time (undefined while the transport is stopped; negative
@@ -293,6 +314,11 @@ export class AudioEngine {
293
314
  private child: PlayerProcess | undefined;
294
315
  private timer: ReturnType<typeof setInterval> | undefined;
295
316
  private loop: Loop | undefined;
317
+ /** Loops fading out after swaps (see `SWAP_FADE_SECONDS`). */
318
+ private fades: FadeOut[] = [];
319
+ /** Frames of the current loop's fade-in already written, while fading. */
320
+ private fadeIn: number | undefined;
321
+ private readonly fadeFrames: number;
296
322
  /** Monotonic ms that stream frame 0 belongs to. */
297
323
  private startMs = 0;
298
324
  /** Stream frames written since `startMs`. */
@@ -328,6 +354,10 @@ export class AudioEngine {
328
354
  ((options.leadMs ?? 200) * this.sampleRate) / 1000,
329
355
  );
330
356
  this.defaultLeadFrames = this.leadFrames;
357
+ this.fadeFrames = Math.max(
358
+ 1,
359
+ Math.round(SWAP_FADE_SECONDS * this.sampleRate),
360
+ );
331
361
  this.tickMs = options.tickMs ?? 20;
332
362
  this.now = options.now ?? (() => performance.now());
333
363
  this.useTimer = options.timer ?? true;
@@ -603,6 +633,7 @@ export class AudioEngine {
603
633
  if (this.monitoring && this.child) {
604
634
  // Play mode: the transport stopped, the live player keeps running.
605
635
  this.loop = undefined;
636
+ this.endFades();
606
637
  return;
607
638
  }
608
639
  if (this.timer) clearInterval(this.timer);
@@ -610,6 +641,7 @@ export class AudioEngine {
610
641
  const child = this.child;
611
642
  this.child = undefined;
612
643
  this.loop = undefined;
644
+ this.endFades();
613
645
  if (child) {
614
646
  try {
615
647
  child.stdin.end?.();
@@ -646,6 +678,7 @@ export class AudioEngine {
646
678
  if (remaining > this.sampleRate) {
647
679
  const skipped = remaining - this.sampleRate;
648
680
  if (loop) this.cursor = (this.cursor + skipped) % loop.frames;
681
+ this.endFades();
649
682
  this.written += skipped;
650
683
  remaining = this.sampleRate;
651
684
  }
@@ -663,6 +696,7 @@ export class AudioEngine {
663
696
  offset += take;
664
697
  this.cursor = (this.cursor + take) % loop.frames;
665
698
  }
699
+ if (loop) this.crossfade(out, remaining);
666
700
  this.mixLive(out, this.written, remaining);
667
701
  this.written += remaining;
668
702
  try {
@@ -673,6 +707,49 @@ export class AudioEngine {
673
707
  }
674
708
  }
675
709
 
710
+ /** Equal-power gain of a fade-in `done` frames along (0 to 1). */
711
+ private rampGain(done: number): number {
712
+ return Math.sin((Math.PI / 2) * Math.min(1, done / this.fadeFrames));
713
+ }
714
+
715
+ /**
716
+ * Blend the start of a block (already the current loop) with the loops
717
+ * fading out: new·sin + old·cos over `fadeFrames`, both read at the same
718
+ * beat, so a swap never steps the waveform.
719
+ */
720
+ private crossfade(out: Int16Array, frames: number): void {
721
+ if (this.fadeIn === undefined) return;
722
+ const start = this.fadeIn;
723
+ const count = Math.min(frames, this.fadeFrames - start);
724
+ for (let index = 0; index < count; index += 1) {
725
+ const gain = this.rampGain(start + index);
726
+ const at = index * RENDER_CHANNELS;
727
+ let left = out[at]! * gain;
728
+ let right = out[at + 1]! * gain;
729
+ for (const fade of this.fades) {
730
+ if (fade.done >= this.fadeFrames) continue;
731
+ const g =
732
+ fade.from * Math.cos((Math.PI / 2) * (fade.done / this.fadeFrames));
733
+ const from = fade.cursor * RENDER_CHANNELS;
734
+ left += fade.loop.pcm[from]! * g;
735
+ right += fade.loop.pcm[from + 1]! * g;
736
+ fade.cursor = (fade.cursor + 1) % fade.loop.frames;
737
+ fade.done += 1;
738
+ }
739
+ out[at] = clamp16(left);
740
+ out[at + 1] = clamp16(right);
741
+ }
742
+ this.fadeIn = start + count;
743
+ // Older fades that began earlier finish before the newest fade-in does.
744
+ this.fades = this.fades.filter((fade) => fade.done < this.fadeFrames);
745
+ if (this.fadeIn >= this.fadeFrames) this.endFades();
746
+ }
747
+
748
+ private endFades(): void {
749
+ this.fades = [];
750
+ this.fadeIn = undefined;
751
+ }
752
+
676
753
  /** Wall time (monotonic ms) stream frame `frame` sounds at. */
677
754
  private frameMs(frame: number): number {
678
755
  return this.startMs + (frame * 1000) / this.sampleRate;
@@ -763,6 +840,13 @@ export class AudioEngine {
763
840
  if (!previous || wrapped > this.sampleRate * RESYNC_SECONDS)
764
841
  cursor = anchored;
765
842
  }
843
+ if (previous && this.child) {
844
+ // The outgoing loop keeps its current gain (below 1 when it was
845
+ // itself still fading in) and fades from there.
846
+ const from = this.fadeIn === undefined ? 1 : this.rampGain(this.fadeIn);
847
+ this.fades.push({ loop: previous, cursor: this.cursor, from, done: 0 });
848
+ this.fadeIn = 0;
849
+ }
766
850
  this.cursor = cursor;
767
851
  this.loop = next;
768
852
  }
@@ -784,6 +868,7 @@ export class AudioEngine {
784
868
  this.spawns += 1;
785
869
  this.child = child;
786
870
  this.loop = loop;
871
+ this.endFades();
787
872
  this.startMs = this.now();
788
873
  this.written = 0;
789
874
  this.cursor = loop ? this.frameForBeat(loop, beat) : 0;
@@ -50,6 +50,12 @@ export type PreviewOptions = Readonly<{
50
50
  beat?: number;
51
51
  /** Overrides the region choice (tests, the agent tool). */
52
52
  region?: PreviewRegion;
53
+ /**
54
+ * Replaces the focused track's notes with this phrase (from tick 0 of the
55
+ * region). The chord settings screen plays a progression performed with
56
+ * the staged settings this way, so a voicing or arp change is audible.
57
+ */
58
+ phrase?: (score: TrackScore, track: Track, bars: number) => NoteInput[];
53
59
  }>;
54
60
 
55
61
  export type Preview = Readonly<{
@@ -288,17 +294,22 @@ export function previewScore(
288
294
  ...(context && anySolo ? { solo: true } : {}),
289
295
  };
290
296
  });
291
- const own = score.notes.some((note) => note.trackId === trackId);
297
+ const replaced = options.phrase !== undefined;
298
+ const own = !replaced && score.notes.some((note) => note.trackId === trackId);
292
299
  const notes: NoteInput[] = score.notes
293
300
  .filter(
294
301
  (note) =>
295
302
  (context || note.trackId === trackId) &&
303
+ !(replaced && note.trackId === trackId) &&
296
304
  note.startTick >= start &&
297
305
  note.startTick < end,
298
306
  )
299
307
  .map((note) => ({ ...note, startTick: note.startTick - start }));
300
308
  let role: PhraseRole | undefined;
301
- if (!own) {
309
+ if (options.phrase) {
310
+ role = "chord";
311
+ notes.push(...options.phrase(score, track, bars));
312
+ } else if (!own) {
302
313
  role = phraseRole(track);
303
314
  notes.push(...defaultPhrase(score, track, bars, role));
304
315
  }