@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 +16 -0
- package/DAWG.md +7 -4
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/audio/effects/chain.ts +12 -2
- package/src/audio/effects/convolution.ts +43 -5
- package/src/audio/effects/space.ts +105 -48
- package/src/audio/engine.ts +85 -0
- package/src/audio/preview.ts +13 -2
- package/src/audio/wav.ts +148 -34
- package/src/main.ts +117 -14
- package/src/tui/audition.ts +21 -1
- package/src/tui/euclid.ts +50 -6
- package/src/tui/menu.ts +21 -2
- package/src/tui/play-chords.ts +93 -1
- package/tui/grammar.ts +24 -19
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 |
|
|
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 |
|
|
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.
|
|
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.
|
|
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.
|
|
28
|
-
bun add -g "$PWD/hraness-dawg-0.4.
|
|
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
|
@@ -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 {
|
|
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 (
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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] =
|
|
282
|
-
right[index] =
|
|
355
|
+
left[index] = tail.left[index]! * wet;
|
|
356
|
+
right[index] = tail.right[index]! * wet;
|
|
283
357
|
} else {
|
|
284
|
-
left[index]! +=
|
|
285
|
-
right[index]! +=
|
|
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
|
|
370
|
+
function convolutionWet(
|
|
297
371
|
left: Float64Array,
|
|
298
372
|
right: Float64Array,
|
|
299
373
|
track: Track,
|
|
300
374
|
context: EffectContext,
|
|
301
375
|
impulse: Impulse,
|
|
302
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
}
|
package/src/audio/engine.ts
CHANGED
|
@@ -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;
|
package/src/audio/preview.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
}
|