@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.
- package/CHANGELOG.md +34 -0
- package/DAWG.md +338 -51
- package/README.md +4 -4
- package/core/chords.ts +492 -4
- package/core/diff.ts +5 -0
- package/core/expression.ts +143 -6
- package/core/fx.ts +258 -6
- package/core/granular.ts +91 -0
- package/core/instruments.ts +161 -0
- package/core/keys.ts +573 -4
- package/core/resonators.ts +291 -3
- package/core/score.ts +349 -3
- package/core/sdk/print.ts +78 -3
- package/core/sdk/v1.ts +1320 -36
- package/core/sections.ts +12 -4
- package/core/strings.ts +237 -2
- package/core/winds.ts +652 -0
- package/guides/automation.md +1 -0
- package/guides/chords.md +3 -1
- package/guides/effects.md +3 -2
- package/guides/media.md +1 -1
- package/guides/performance.md +3 -0
- package/guides/resample.md +26 -0
- package/guides/sounds.md +7 -6
- package/package.json +1 -1
- package/src/agent/agent.ts +8 -2
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/ops.ts +4 -1
- package/src/agent/preview-tool.ts +2 -1
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/tools.ts +200 -15
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/arrange.ts +21 -0
- package/src/audio/dsp/envelope.ts +161 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/effects/chain.ts +18 -3
- package/src/audio/effects/common.ts +14 -0
- package/src/audio/effects/convolution.ts +106 -1
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/engine.ts +46 -5
- package/src/audio/fit.ts +45 -0
- package/src/audio/granular.ts +352 -21
- package/src/audio/instrument-check.ts +9 -5
- package/src/audio/instruments.ts +32 -3
- package/src/audio/keys/electric.ts +420 -0
- package/src/audio/keys/engine.ts +143 -19
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +50 -6
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/live-worker.ts +3 -1
- package/src/audio/live.ts +136 -9
- package/src/audio/preview.ts +15 -1
- package/src/audio/resample.ts +285 -0
- package/src/audio/resonators.ts +10 -2
- package/src/audio/sampler.ts +168 -21
- package/src/audio/samples.ts +26 -1
- package/src/audio/strings/body.ts +29 -16
- package/src/audio/strings/bow.ts +656 -0
- package/src/audio/strings/engine.ts +266 -20
- package/src/audio/strings/measure.test-helpers.ts +2 -0
- package/src/audio/wav.ts +119 -19
- package/src/audio/winds/engine.ts +302 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +80 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/voice.ts +479 -0
- package/src/commands/expression.ts +103 -22
- package/src/commands/fx.ts +4 -1
- package/src/commands/granular.ts +7 -2
- package/src/commands/help.ts +91 -8
- package/src/commands/keys.ts +287 -11
- package/src/commands/modal.ts +67 -4
- package/src/commands/resample.ts +281 -0
- package/src/commands/rig.ts +5 -1
- package/src/commands/sample.ts +49 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/string.ts +28 -2
- package/src/commands/strum.ts +473 -0
- package/src/commands/wind.ts +244 -0
- package/src/main.ts +129 -7
- package/src/project/check.ts +5 -1
- package/src/render.ts +16 -0
- package/src/session/presence.ts +16 -3
- package/src/tui/audition.ts +1 -1
- package/src/tui/menu.ts +637 -35
- package/src/tui/modal-menu.ts +34 -7
- package/src/tui/performance-menu.ts +42 -0
- package/src/tui/play-chords.ts +104 -3
- package/src/tui/play-mode.ts +1 -0
- package/src/tui/play-session.ts +34 -9
- package/src/tui/wind-menu.ts +144 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shoegaze stages (0.6.1): wobble, bloom, swell (mono, after the cab) and
|
|
3
|
+
* double (stereo, right after pan). Ported from the guitar design lane's
|
|
4
|
+
* prototype (proto/guitar/rig.ts wobbleCurve, addFeedback, applySwell,
|
|
5
|
+
* applyDouble).
|
|
6
|
+
*
|
|
7
|
+
* - wobble: a held tremolo arm (the "glide guitar" of Kevin Shields). One
|
|
8
|
+
* seeded curve per track in render time bends the whole voice sum
|
|
9
|
+
* together. The voice is read through a moving, non-causal delay whose
|
|
10
|
+
* slope is the pitch ratio, so a sine of peak `depth` cents is exact.
|
|
11
|
+
* - bloom: feedback. The highest note held past `delay` grows a partial at
|
|
12
|
+
* `harm` × f0 with a slow seeded drift of a few cents (one seed per
|
|
13
|
+
* note id), the way a loud amp makes the top note sing.
|
|
14
|
+
* - swell: a Slow Gear style volume swell; the gain resets at each onset
|
|
15
|
+
* cluster and rises over `time`.
|
|
16
|
+
* - double: automatic double tracking. A second take is the input delayed
|
|
17
|
+
* `time` ms with a seeded wander of ± `drift` ms and a darker tilt,
|
|
18
|
+
* spread against the dry take by `width`.
|
|
19
|
+
*
|
|
20
|
+
* Every random number comes from the track id (and note id), so a render
|
|
21
|
+
* is byte-identical on every run.
|
|
22
|
+
*/
|
|
23
|
+
import type { FxValues } from "../../../core/fx.ts";
|
|
24
|
+
import type { Track } from "../../../core/score.ts";
|
|
25
|
+
import { seededRandom } from "../dsp/rng.ts";
|
|
26
|
+
import { OnePole } from "../dsp/filters.ts";
|
|
27
|
+
import {
|
|
28
|
+
CONTROL_SAMPLES,
|
|
29
|
+
clamp,
|
|
30
|
+
fxReader,
|
|
31
|
+
type EffectContext,
|
|
32
|
+
type EffectNote,
|
|
33
|
+
} from "./common.ts";
|
|
34
|
+
|
|
35
|
+
const CENTS_TO_LOG = Math.LN2 / 1200;
|
|
36
|
+
|
|
37
|
+
/** Catmull-Rom (4-point Hermite) read of `x` at a fractional position. */
|
|
38
|
+
function readAt(x: Float64Array, position: number): number {
|
|
39
|
+
const last = x.length - 1;
|
|
40
|
+
if (last < 0) return 0;
|
|
41
|
+
const p = clamp(position, 0, last);
|
|
42
|
+
const i = Math.floor(p);
|
|
43
|
+
const t = p - i;
|
|
44
|
+
const a = x[Math.max(0, i - 1)]!;
|
|
45
|
+
const b = x[i]!;
|
|
46
|
+
const c = x[Math.min(last, i + 1)]!;
|
|
47
|
+
const d = x[Math.min(last, i + 2)]!;
|
|
48
|
+
const c1 = 0.5 * (c - a);
|
|
49
|
+
const c2 = a - 2.5 * b + 2 * c - 0.5 * d;
|
|
50
|
+
const c3 = 0.5 * (d - a) + 1.5 * (b - c);
|
|
51
|
+
return ((c3 * t + c2) * t + c1) * t + b;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type WobbleShape = Readonly<{
|
|
55
|
+
/** Read offset in seconds at time t (positive: earlier audio). */
|
|
56
|
+
offset: (t: number) => number;
|
|
57
|
+
/** Pitch deviation in cents at time t. */
|
|
58
|
+
cents: (t: number) => number;
|
|
59
|
+
/** Largest possible |offset| in seconds (the components' summed amplitude). */
|
|
60
|
+
span: number;
|
|
61
|
+
}>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Most a wobble moves a note in time, in seconds. Slow, deep settings
|
|
65
|
+
* (100 c at 0.05 Hz) would otherwise drag strums 170 ms off the grid; past
|
|
66
|
+
* this bound the depth gives way instead.
|
|
67
|
+
*/
|
|
68
|
+
export const WOBBLE_MAX_OFFSET = 0.015;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The track's wobble curve: three seeded sines (the first at `rate`, the
|
|
72
|
+
* others off-ratio) weighted by `drift`. Each component's delay amplitude
|
|
73
|
+
* is chosen so its pitch contribution peaks at its share of `depth`.
|
|
74
|
+
*/
|
|
75
|
+
export function wobbleShape(
|
|
76
|
+
depth: number,
|
|
77
|
+
rate: number,
|
|
78
|
+
drift: number,
|
|
79
|
+
seed: string,
|
|
80
|
+
): WobbleShape {
|
|
81
|
+
const r = seededRandom(`wobble:${seed}`);
|
|
82
|
+
const phase = [r(), r(), r()].map((x) => x * 2 * Math.PI);
|
|
83
|
+
const ratio = [1, 0.61 + 0.1 * r(), 1.53 + 0.2 * r()];
|
|
84
|
+
const d = clamp(drift, 0, 1);
|
|
85
|
+
const weight = [1 - d, 0.6 * d, 0.4 * d];
|
|
86
|
+
const norm = weight[0]! + weight[1]! + weight[2]!;
|
|
87
|
+
const hz = ratio.map((k) => Math.max(0.01, rate) * k);
|
|
88
|
+
// Delay amplitude (s) whose derivative peaks at the component's cents.
|
|
89
|
+
const amp = hz.map(
|
|
90
|
+
(f, k) => ((weight[k]! / norm) * depth * CENTS_TO_LOG) / (2 * Math.PI * f),
|
|
91
|
+
);
|
|
92
|
+
return {
|
|
93
|
+
span: amp[0]! + amp[1]! + amp[2]!,
|
|
94
|
+
// Reading at t + offset(t): ratio = 1 + offset'(t) ≈ 2^(cents/1200).
|
|
95
|
+
offset: (t) => {
|
|
96
|
+
let s = 0;
|
|
97
|
+
for (let k = 0; k < 3; k += 1)
|
|
98
|
+
s += amp[k]! * Math.sin(2 * Math.PI * hz[k]! * t + phase[k]!);
|
|
99
|
+
return s;
|
|
100
|
+
},
|
|
101
|
+
cents: (t) => {
|
|
102
|
+
let s = 0;
|
|
103
|
+
for (let k = 0; k < 3; k += 1)
|
|
104
|
+
s +=
|
|
105
|
+
amp[k]! *
|
|
106
|
+
2 *
|
|
107
|
+
Math.PI *
|
|
108
|
+
hz[k]! *
|
|
109
|
+
Math.cos(2 * Math.PI * hz[k]! * t + phase[k]!);
|
|
110
|
+
return s / CENTS_TO_LOG;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function applyWobble(
|
|
116
|
+
buffer: Float64Array,
|
|
117
|
+
track: Track,
|
|
118
|
+
values: FxValues,
|
|
119
|
+
context: EffectContext,
|
|
120
|
+
): void {
|
|
121
|
+
const read = fxReader(track, "wobble", values, context);
|
|
122
|
+
const depth = read.param("depth");
|
|
123
|
+
const rate = read.number("rate");
|
|
124
|
+
const drift = read.number("drift");
|
|
125
|
+
const sr = context.sampleRate;
|
|
126
|
+
const peak = depth.automated ? 100 : depth.at(0);
|
|
127
|
+
if (peak <= 0 || buffer.length === 0) return;
|
|
128
|
+
// Unit-depth shape, scaled by the (possibly automated) depth.
|
|
129
|
+
const shape = wobbleShape(1, rate, drift, track.id);
|
|
130
|
+
// Depth that keeps every read within WOBBLE_MAX_OFFSET of the grid.
|
|
131
|
+
const most = shape.span > 0 ? WOBBLE_MAX_OFFSET / shape.span : Infinity;
|
|
132
|
+
const source = buffer.slice();
|
|
133
|
+
let scale = Math.min(most, depth.at(0));
|
|
134
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
135
|
+
if (depth.automated && index % CONTROL_SAMPLES === 0)
|
|
136
|
+
scale = Math.min(most, depth.at(index));
|
|
137
|
+
const t = index / sr;
|
|
138
|
+
buffer[index] = readAt(source, index + scale * shape.offset(t) * sr);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Feedback bloom. Only the highest note sounding at once feeds back (one
|
|
144
|
+
* dominant loop), and only once it has been held past `delay`. One sweep:
|
|
145
|
+
* the dominant note is found per 32-sample block from the sorted note
|
|
146
|
+
* starts and a small active set, and a hand-over crossfades 20 ms, so the
|
|
147
|
+
* cost is O(samples + notes log notes) even on a dense strum.
|
|
148
|
+
*
|
|
149
|
+
* The partial feeds back from the signal itself: its level follows an
|
|
150
|
+
* envelope of the incoming buffer (50 ms attack, `time` release), so a
|
|
151
|
+
* quiet, faded or muted track blooms quietly or not at all, and the
|
|
152
|
+
* partial never sits more than about -6 dB under the input's peak.
|
|
153
|
+
*/
|
|
154
|
+
export function applyBloom(
|
|
155
|
+
buffer: Float64Array,
|
|
156
|
+
track: Track,
|
|
157
|
+
values: FxValues,
|
|
158
|
+
context: EffectContext,
|
|
159
|
+
): void {
|
|
160
|
+
const notes = context.notes;
|
|
161
|
+
if (!notes || notes.length === 0) return;
|
|
162
|
+
const read = fxReader(track, "bloom", values, context);
|
|
163
|
+
const amount = read.number("amount");
|
|
164
|
+
if (amount <= 0) return;
|
|
165
|
+
const harm = read.number("harm");
|
|
166
|
+
const sr = context.sampleRate;
|
|
167
|
+
const delay = Math.round(read.number("delay") * sr);
|
|
168
|
+
const grow = Math.max(0.05, read.number("time"));
|
|
169
|
+
const release = Math.round(0.08 * sr);
|
|
170
|
+
const fade = Math.max(1, Math.round(0.02 * sr));
|
|
171
|
+
// Envelope (peak-equivalent of a smoothed RMS) of the input.
|
|
172
|
+
const follow = new Float64Array(buffer.length);
|
|
173
|
+
const up = 1 - Math.exp(-1 / (0.05 * sr));
|
|
174
|
+
const down = 1 - Math.exp(-1 / (Math.max(0.1, grow) * sr));
|
|
175
|
+
let power = 0;
|
|
176
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
177
|
+
const x2 = buffer[index]! * buffer[index]!;
|
|
178
|
+
power += (x2 > power ? up : down) * (x2 - power);
|
|
179
|
+
follow[index] = Math.sqrt(2 * power);
|
|
180
|
+
}
|
|
181
|
+
const sorted = notes
|
|
182
|
+
.filter((note) => note.length > delay)
|
|
183
|
+
.sort((x, y) => x.start - y.start || y.hz - x.hz);
|
|
184
|
+
type Loop = {
|
|
185
|
+
note: EffectNote;
|
|
186
|
+
phase: number;
|
|
187
|
+
driftHz: number;
|
|
188
|
+
level: number;
|
|
189
|
+
/** Hand-over gain 0..1 and its direction. */
|
|
190
|
+
gain: number;
|
|
191
|
+
rising: boolean;
|
|
192
|
+
};
|
|
193
|
+
const loops: Loop[] = [];
|
|
194
|
+
const active: EffectNote[] = [];
|
|
195
|
+
let next = 0;
|
|
196
|
+
let current: EffectNote | undefined;
|
|
197
|
+
for (let block = 0; block < buffer.length; block += BLOCK) {
|
|
198
|
+
while (next < sorted.length && sorted[next]!.start <= block)
|
|
199
|
+
active.push(sorted[next++]!);
|
|
200
|
+
for (let k = active.length - 1; k >= 0; k -= 1)
|
|
201
|
+
if (active[k]!.start + active[k]!.length <= block) active.splice(k, 1);
|
|
202
|
+
let top: EffectNote | undefined;
|
|
203
|
+
for (const note of active)
|
|
204
|
+
if (note.start + delay <= block && (!top || note.hz > top.hz)) top = note;
|
|
205
|
+
if (top !== current) {
|
|
206
|
+
for (const loop of loops) if (loop.note !== top) loop.rising = false;
|
|
207
|
+
if (top && !loops.some((loop) => loop.note === top)) {
|
|
208
|
+
const r = seededRandom(`bloom:${track.id}:${top.id}`);
|
|
209
|
+
loops.push({
|
|
210
|
+
note: top,
|
|
211
|
+
phase: r() * 2 * Math.PI,
|
|
212
|
+
driftHz: 0.2 + 0.3 * r(),
|
|
213
|
+
// Relative to the input envelope; at most -6 dB under it.
|
|
214
|
+
level: Math.min(0.5, 0.6 * amount * (0.5 + 0.5 * top.velocity)),
|
|
215
|
+
gain: current ? 0 : 1,
|
|
216
|
+
rising: true,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
current = top;
|
|
220
|
+
}
|
|
221
|
+
const end = Math.min(buffer.length, block + BLOCK);
|
|
222
|
+
for (let k = loops.length - 1; k >= 0; k -= 1) {
|
|
223
|
+
const loop = loops[k]!;
|
|
224
|
+
const from = loop.note.start + delay;
|
|
225
|
+
const off = loop.note.start + loop.note.length;
|
|
226
|
+
for (let index = block; index < end; index += 1) {
|
|
227
|
+
const t = (index - from) / sr;
|
|
228
|
+
const cents = 3 * Math.sin(2 * Math.PI * loop.driftHz * t);
|
|
229
|
+
loop.phase +=
|
|
230
|
+
(2 * Math.PI * harm * loop.note.hz * 2 ** (cents / 1200)) / sr;
|
|
231
|
+
loop.gain = loop.rising
|
|
232
|
+
? Math.min(1, loop.gain + 1 / fade)
|
|
233
|
+
: Math.max(0, loop.gain - 1 / fade);
|
|
234
|
+
let env =
|
|
235
|
+
follow[index]! * loop.level * loop.gain * (1 - Math.exp(-t / grow));
|
|
236
|
+
if (index > off) env *= Math.max(0, 1 - (index - off) / release);
|
|
237
|
+
buffer[index]! += env * Math.sin(loop.phase);
|
|
238
|
+
}
|
|
239
|
+
if (loop.gain <= 0 || end > off + release) {
|
|
240
|
+
loops.splice(k, 1);
|
|
241
|
+
if (current === loop.note) current = undefined;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Control block of the bloom sweep, in samples. */
|
|
248
|
+
const BLOCK = 32;
|
|
249
|
+
|
|
250
|
+
/** Onset clusters (notes within 40 ms of the cluster's first), sorted. */
|
|
251
|
+
function onsets(notes: readonly EffectNote[], sr: number): number[] {
|
|
252
|
+
const gap = 0.04 * sr;
|
|
253
|
+
const out: number[] = [];
|
|
254
|
+
for (const start of notes.map((note) => note.start).sort((a, b) => a - b))
|
|
255
|
+
if (out.length === 0 || start - out[out.length - 1]! > gap) out.push(start);
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function applySwell(
|
|
260
|
+
buffer: Float64Array,
|
|
261
|
+
track: Track,
|
|
262
|
+
values: FxValues,
|
|
263
|
+
context: EffectContext,
|
|
264
|
+
): void {
|
|
265
|
+
const notes = context.notes;
|
|
266
|
+
if (!notes || notes.length === 0) return;
|
|
267
|
+
const read = fxReader(track, "swell", values, context);
|
|
268
|
+
const mix = read.param("mix");
|
|
269
|
+
const sr = context.sampleRate;
|
|
270
|
+
// Reaches about -0.4 dB at `time`.
|
|
271
|
+
const tau = (Math.max(0.01, read.number("time")) / 3) * sr;
|
|
272
|
+
// A new onset over a ringing chord first ducks the gain to 0 over at
|
|
273
|
+
// most `duck` (scaled by how high it was), then rises again: no click.
|
|
274
|
+
const duck = 0.006 * sr;
|
|
275
|
+
const starts = onsets(notes, sr);
|
|
276
|
+
let next = 0;
|
|
277
|
+
let last = Number.NEGATIVE_INFINITY;
|
|
278
|
+
let g = 1;
|
|
279
|
+
let from = 0;
|
|
280
|
+
let fall = 0;
|
|
281
|
+
let wet = mix.at(0);
|
|
282
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
283
|
+
if (mix.automated && index % CONTROL_SAMPLES === 0) wet = mix.at(index);
|
|
284
|
+
while (next < starts.length && starts[next]! <= index) {
|
|
285
|
+
// Before the first onset nothing sounds: rise from silence.
|
|
286
|
+
from = last === Number.NEGATIVE_INFINITY ? 0 : g;
|
|
287
|
+
fall = Math.round(duck * from);
|
|
288
|
+
last = starts[next++]!;
|
|
289
|
+
}
|
|
290
|
+
if (last !== Number.NEGATIVE_INFINITY) {
|
|
291
|
+
const elapsed = index - last;
|
|
292
|
+
g =
|
|
293
|
+
elapsed < fall
|
|
294
|
+
? from * (1 - elapsed / fall)
|
|
295
|
+
: 1 - Math.exp(-(elapsed - fall) / tau);
|
|
296
|
+
}
|
|
297
|
+
buffer[index] = buffer[index]! * (1 - wet + wet * g * g);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Seeded wandering delay of one channel, read non-causally from `x`. */
|
|
302
|
+
function secondTake(
|
|
303
|
+
x: Float64Array,
|
|
304
|
+
time: number,
|
|
305
|
+
drift: number,
|
|
306
|
+
seed: string,
|
|
307
|
+
sr: number,
|
|
308
|
+
): Float64Array {
|
|
309
|
+
const r = seededRandom(`double:${seed}`);
|
|
310
|
+
const ph1 = r() * 2 * Math.PI;
|
|
311
|
+
const ph2 = r() * 2 * Math.PI;
|
|
312
|
+
const f1 = 0.35 + 0.3 * r();
|
|
313
|
+
const f2 = 0.11 + 0.1 * r();
|
|
314
|
+
const tilt = new OnePole(5500, sr);
|
|
315
|
+
const out = new Float64Array(x.length);
|
|
316
|
+
let d = (time / 1000) * sr;
|
|
317
|
+
for (let index = 0; index < x.length; index += 1) {
|
|
318
|
+
if (index % CONTROL_SAMPLES === 0) {
|
|
319
|
+
const t = index / sr;
|
|
320
|
+
const wander =
|
|
321
|
+
0.65 * Math.sin(2 * Math.PI * f1 * t + ph1) +
|
|
322
|
+
0.35 * Math.sin(2 * Math.PI * f2 * t + ph2);
|
|
323
|
+
d = ((time + drift * wander) / 1000) * sr;
|
|
324
|
+
}
|
|
325
|
+
const position = index - d;
|
|
326
|
+
out[index] = tilt.process(position < 0 ? 0 : readAt(x, position));
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function applyDouble(
|
|
332
|
+
left: Float64Array,
|
|
333
|
+
right: Float64Array,
|
|
334
|
+
track: Track,
|
|
335
|
+
values: FxValues,
|
|
336
|
+
context: EffectContext,
|
|
337
|
+
): void {
|
|
338
|
+
const read = fxReader(track, "double", values, context);
|
|
339
|
+
const time = read.number("time");
|
|
340
|
+
const drift = read.number("drift");
|
|
341
|
+
const width = clamp(read.number("width"), 0, 1);
|
|
342
|
+
const sr = context.sampleRate;
|
|
343
|
+
const copyL = secondTake(left, time, drift, track.id, sr);
|
|
344
|
+
const copyR = secondTake(right, time, drift, track.id, sr);
|
|
345
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
346
|
+
const l = left[index]!;
|
|
347
|
+
const r = right[index]!;
|
|
348
|
+
const cl = copyL[index]!;
|
|
349
|
+
const cr = copyR[index]!;
|
|
350
|
+
// width 1: the dry take left, the second take right; 0: both centred.
|
|
351
|
+
left[index] = 0.5 * (l + cl) + 0.5 * width * (l - cl);
|
|
352
|
+
right[index] = 0.5 * (r + cr) - 0.5 * width * (r - cr);
|
|
353
|
+
}
|
|
354
|
+
}
|
package/src/audio/engine.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type ClickLevel,
|
|
10
10
|
} from "./click.ts";
|
|
11
11
|
import type { LiveNotePcm } from "./live.ts";
|
|
12
|
+
import { RING_OUT_FADE_SECONDS } from "./instruments.ts";
|
|
12
13
|
import { measureLoudness, pcmChannels } from "./loudness.ts";
|
|
13
14
|
import type { MasterReport } from "./master.ts";
|
|
14
15
|
import { levelOf, type SoundLevel } from "./preview.ts";
|
|
@@ -308,8 +309,16 @@ type LiveVoice = {
|
|
|
308
309
|
/** Fade-out frames left once released early; undefined while sounding. */
|
|
309
310
|
fadeLeft: number | undefined;
|
|
310
311
|
fadeFrames: number;
|
|
312
|
+
/** Rings to its end on note-off (0.6.1 modal ring-out). */
|
|
313
|
+
ringOut?: boolean;
|
|
311
314
|
};
|
|
312
315
|
|
|
316
|
+
/**
|
|
317
|
+
* At most this many ring-out voices sound at once; the oldest beyond it fades
|
|
318
|
+
* over `RING_OUT_FADE_SECONDS` (spec 7.3: 32 resonator voices).
|
|
319
|
+
*/
|
|
320
|
+
export const MAX_RING_OUT_VOICES = 32;
|
|
321
|
+
|
|
313
322
|
type PlayRequest = {
|
|
314
323
|
score: TrackScore;
|
|
315
324
|
beat: number | undefined;
|
|
@@ -531,14 +540,18 @@ export class AudioEngine {
|
|
|
531
540
|
existing.frames = note.frames;
|
|
532
541
|
existing.fadeLeft = undefined;
|
|
533
542
|
existing.fadeFrames = this.releaseFrames(note);
|
|
534
|
-
|
|
543
|
+
existing.ringOut = note.ringOut === true;
|
|
544
|
+
} else {
|
|
535
545
|
this.voices.set(id, {
|
|
536
546
|
pcm: note.pcm,
|
|
537
547
|
frames: note.frames,
|
|
538
548
|
position: 0,
|
|
539
549
|
fadeLeft: undefined,
|
|
540
550
|
fadeFrames: this.releaseFrames(note),
|
|
551
|
+
...(note.ringOut ? { ringOut: true } : {}),
|
|
541
552
|
});
|
|
553
|
+
if (note.ringOut) this.capRingOut();
|
|
554
|
+
}
|
|
542
555
|
// Write what is due now so the voice joins the very next chunk.
|
|
543
556
|
const first = this.written;
|
|
544
557
|
this.pump();
|
|
@@ -553,11 +566,39 @@ export class AudioEngine {
|
|
|
553
566
|
);
|
|
554
567
|
}
|
|
555
568
|
|
|
556
|
-
/**
|
|
557
|
-
|
|
569
|
+
/**
|
|
570
|
+
* Past `MAX_RING_OUT_VOICES` ringing voices, fade the oldest still at full
|
|
571
|
+
* level over `RING_OUT_FADE_SECONDS` (Map order is start order).
|
|
572
|
+
*/
|
|
573
|
+
private capRingOut(): void {
|
|
574
|
+
let ringing = 0;
|
|
575
|
+
for (const voice of this.voices.values())
|
|
576
|
+
if (voice.ringOut && voice.fadeLeft === undefined) ringing += 1;
|
|
577
|
+
for (const voice of this.voices.values()) {
|
|
578
|
+
if (ringing <= MAX_RING_OUT_VOICES) break;
|
|
579
|
+
if (!voice.ringOut || voice.fadeLeft !== undefined) continue;
|
|
580
|
+
this.fadeRingOut(voice);
|
|
581
|
+
ringing -= 1;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private fadeRingOut(voice: LiveVoice): void {
|
|
586
|
+
voice.fadeFrames = Math.max(
|
|
587
|
+
1,
|
|
588
|
+
Math.round(this.sampleRate * RING_OUT_FADE_SECONDS),
|
|
589
|
+
);
|
|
590
|
+
voice.fadeLeft = voice.fadeFrames;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Fade a live voice out over ~10 ms (sustain lifted, mode left). A
|
|
595
|
+
* ring-out voice keeps ringing; `noteOff(id, true)` silences it too.
|
|
596
|
+
*/
|
|
597
|
+
public noteOff(id: number, force = false): void {
|
|
558
598
|
const voice = this.voices.get(id);
|
|
559
|
-
if (voice
|
|
560
|
-
|
|
599
|
+
if (!voice || voice.fadeLeft !== undefined) return;
|
|
600
|
+
if (!voice.ringOut) voice.fadeLeft = voice.fadeFrames;
|
|
601
|
+
else if (force) this.fadeRingOut(voice);
|
|
561
602
|
}
|
|
562
603
|
|
|
563
604
|
/** Frames of live voice `id` already mixed, or undefined once finished. */
|
package/src/audio/fit.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
sampleAt,
|
|
32
32
|
} from "./dsp/stft.ts";
|
|
33
33
|
import { hermite4 } from "./dsp/interp.ts";
|
|
34
|
+
import { pitchShiftJob } from "./dsp/shift.ts";
|
|
34
35
|
import type { FitMap } from "./warp.ts";
|
|
35
36
|
|
|
36
37
|
export type FitAlgorithm = "repitch" | "beats" | "tones";
|
|
@@ -146,6 +147,50 @@ export function fittedBuffer(
|
|
|
146
147
|
return out;
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Live renders pitch-shift buffers up to this long synchronously; longer
|
|
152
|
+
* ones run between blocks while the voice plays a repitch. A phase-vocoder
|
|
153
|
+
* shift with formant keeping costs about 30 ms per second of 22.05 kHz
|
|
154
|
+
* audio, so only very short sounds fit the 10 ms note-on budget.
|
|
155
|
+
*/
|
|
156
|
+
export const LIVE_SYNC_SHIFT_SECONDS = 0.1;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `buffer` pitch-shifted by `semitones` (formant semitones, 0 keeps),
|
|
160
|
+
* cached under `key` with the fits (0.6.1 `shift`, `formant`). Returns
|
|
161
|
+
* undefined only in a live render while a long shift is still running.
|
|
162
|
+
*/
|
|
163
|
+
export function shiftedBuffer(
|
|
164
|
+
key: string,
|
|
165
|
+
buffer: Float32Array,
|
|
166
|
+
sampleRate: number,
|
|
167
|
+
semitones: number,
|
|
168
|
+
formant: number | undefined,
|
|
169
|
+
): Float32Array | undefined {
|
|
170
|
+
const hit = cache.get(key);
|
|
171
|
+
if (hit) {
|
|
172
|
+
cache.delete(key);
|
|
173
|
+
cache.set(key, hit);
|
|
174
|
+
return hit;
|
|
175
|
+
}
|
|
176
|
+
const work = () =>
|
|
177
|
+
pitchShiftJob(buffer, sampleRate, semitones, {
|
|
178
|
+
...(formant === undefined ? {} : { formant }),
|
|
179
|
+
});
|
|
180
|
+
if (live && buffer.length > LIVE_SYNC_SHIFT_SECONDS * sampleRate) {
|
|
181
|
+
missed = true;
|
|
182
|
+
if (!pending.has(key)) {
|
|
183
|
+
pending.set(key, { job: work(), window: key });
|
|
184
|
+
schedule();
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
pending.delete(key);
|
|
189
|
+
const out = drain(work());
|
|
190
|
+
remember(key, out);
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
149
194
|
function drain(job: Generator<void, Float32Array>): Float32Array {
|
|
150
195
|
for (;;) {
|
|
151
196
|
const step = job.next();
|