@hraness/dawg 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/DAWG.md +551 -33
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +41 -12
- package/core/expression.ts +1241 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/resonators.ts +574 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +717 -62
- package/core/sdk/eval-child.ts +40 -3
- package/core/sdk/print.ts +614 -27
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2824 -45
- package/core/sections.ts +2072 -0
- package/core/strings.ts +845 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +29 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +29 -0
- package/guides/tempo.md +27 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +9 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +68 -6
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +27 -4
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +574 -16
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +9 -0
- package/src/audio/arrange.ts +489 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +6 -1
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +100 -21
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +263 -17
- package/src/audio/loudness.ts +596 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +76 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +269 -34
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +147 -0
- package/src/audio/wav.ts +377 -30
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +941 -0
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +355 -27
- package/src/commands/keys.ts +353 -0
- package/src/commands/master.ts +361 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +967 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +919 -41
- package/src/project/check.ts +13 -0
- package/src/render.ts +139 -8
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +967 -23
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +459 -43
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The granular instrument (0.6): a seeded, streaming grain voice and the
|
|
3
|
+
* engine that plays a track's notes with it. Ported from the 0.6 granular
|
|
4
|
+
* prototype (`proto/granular/granular.ts`, round 3) with the review's
|
|
5
|
+
* changes: a per-semitone sinc bank (`dsp/bank.ts`) re-picked per 32-frame
|
|
6
|
+
* block from the step times the note's cents (bend, glide, vibrato), the
|
|
7
|
+
* `shimint` interval, beat-repeat latching (`repeat`, `hold`) and a slow
|
|
8
|
+
* seeded drift of the head (`drift`, `drate`).
|
|
9
|
+
*
|
|
10
|
+
* Synchronous-grid granulator with jittered onsets after Roads,
|
|
11
|
+
* *Microsound* (MIT Press 2001) and Bencina, "Implementing Real-Time
|
|
12
|
+
* Granular Synthesis"; structure similar to Mutable Instruments Clouds.
|
|
13
|
+
* Randomness is counter-based (`unit(seed, grain, slot)`), so changing one
|
|
14
|
+
* parameter does not reshuffle the others' draws.
|
|
15
|
+
*/
|
|
16
|
+
import type { PerformedNote } from "../../core/expression.ts";
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_GRANULAR_SOURCE,
|
|
19
|
+
GRANULAR_SOURCE_VOICE,
|
|
20
|
+
SYNTH_SOURCE_SECONDS,
|
|
21
|
+
granularTailSeconds,
|
|
22
|
+
parseSynthSource,
|
|
23
|
+
resolveGranular,
|
|
24
|
+
type GranularSettings,
|
|
25
|
+
} from "../../core/granular.ts";
|
|
26
|
+
import type { SampleRef, Track } from "../../core/score.ts";
|
|
27
|
+
import { SYNTH_PRESETS, type TrackSynth } from "../../core/synth.ts";
|
|
28
|
+
import { noteHz } from "../../core/tuning.ts";
|
|
29
|
+
import {
|
|
30
|
+
BANK_CHUNK_FRAMES,
|
|
31
|
+
bankChunk,
|
|
32
|
+
bankLevel,
|
|
33
|
+
type BankSource,
|
|
34
|
+
} from "./dsp/bank.ts";
|
|
35
|
+
import { seedHash, unit } from "./dsp/rng.ts";
|
|
36
|
+
import { WINDOW_TABLE, grainWindow } from "./dsp/window.ts";
|
|
37
|
+
import { interpolateAutomation } from "./effects/common.ts";
|
|
38
|
+
import type { EngineContext, InstrumentEngine } from "./instruments.ts";
|
|
39
|
+
import type { DecodedSample, SampleBank } from "./samples.ts";
|
|
40
|
+
import { renderSynthNote } from "./synth/voice.ts";
|
|
41
|
+
import { warpedSpan } from "./warp.ts";
|
|
42
|
+
|
|
43
|
+
/** Parameters are read, and the bank level re-picked, every 32 frames. */
|
|
44
|
+
export const CONTROL_FRAMES = 32;
|
|
45
|
+
/** Grains sounding at once per voice; later grains are skipped by index. */
|
|
46
|
+
export const MAX_GRAINS = 64;
|
|
47
|
+
/**
|
|
48
|
+
* Voices per track; the oldest released voice is stolen first, then the
|
|
49
|
+
* oldest held one, with a 5 ms fade.
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_VOICES = 16;
|
|
52
|
+
const STEAL_SECONDS = 0.005;
|
|
53
|
+
const CHUNK_MASK = BANK_CHUNK_FRAMES - 1;
|
|
54
|
+
|
|
55
|
+
/** A grain (a class, so the hot loop sees one shape). */
|
|
56
|
+
class Grain {
|
|
57
|
+
start = 0;
|
|
58
|
+
len = 0;
|
|
59
|
+
/** Read position at level 0 frames (all levels share the frame rate). */
|
|
60
|
+
pos = 0;
|
|
61
|
+
/** Signed source frames per output frame, before the note's cents. */
|
|
62
|
+
step = 0;
|
|
63
|
+
gl = 0;
|
|
64
|
+
gr = 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type GranularVoiceInit = Readonly<{
|
|
68
|
+
source: BankSource;
|
|
69
|
+
sr: number;
|
|
70
|
+
settings: GranularSettings;
|
|
71
|
+
/** Note frequency over the source root's frequency (tuning applied). */
|
|
72
|
+
baseRate: number;
|
|
73
|
+
velocity: number;
|
|
74
|
+
gateFrames: number;
|
|
75
|
+
/** NotePerformance.cents: bend, glide and vibrato in cents at t seconds. */
|
|
76
|
+
cents?: (t: number) => number;
|
|
77
|
+
/** Half pedal: the level fades from `from` seconds with `tau`. */
|
|
78
|
+
damp?: Readonly<{ from: number; tau: number }>;
|
|
79
|
+
seed: number;
|
|
80
|
+
}>;
|
|
81
|
+
|
|
82
|
+
export type GranularVoice = {
|
|
83
|
+
/** Adds `frames` frames at `offset`; false once silent for good. */
|
|
84
|
+
process(
|
|
85
|
+
outL: Float64Array,
|
|
86
|
+
outR: Float64Array,
|
|
87
|
+
offset: number,
|
|
88
|
+
frames: number,
|
|
89
|
+
): boolean;
|
|
90
|
+
readonly totalFrames: number;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** A streaming grain voice. */
|
|
94
|
+
export function granularVoice(init: GranularVoiceInit): GranularVoice {
|
|
95
|
+
const { source, sr, settings: p, seed } = init;
|
|
96
|
+
const data = source.data;
|
|
97
|
+
const srcRate = source.rate;
|
|
98
|
+
const n0 = Math.max(1, data.length);
|
|
99
|
+
const begin = Math.max(0, Math.min(Math.floor(p.begin * n0), n0 - 1));
|
|
100
|
+
const end = Math.max(begin + 1, Math.min(Math.ceil(p.end * n0), n0));
|
|
101
|
+
const region = end - begin;
|
|
102
|
+
const releaseFrames = Math.max(1, Math.round(p.release * sr));
|
|
103
|
+
const attackFrames = Math.max(1, Math.round(p.attack * sr));
|
|
104
|
+
const maxGrainFrames = Math.round(2 * sr);
|
|
105
|
+
const tailFrames =
|
|
106
|
+
releaseFrames + Math.round(Math.min(2, p.grain) * sr * 1.5);
|
|
107
|
+
const total = init.gateFrames + tailFrames;
|
|
108
|
+
const spawnEnd = init.gateFrames + releaseFrames;
|
|
109
|
+
const win = grainWindow(p.window);
|
|
110
|
+
const table = win.table;
|
|
111
|
+
const grainSec = Math.max(0.005, Math.min(2, p.grain));
|
|
112
|
+
const overlap = Math.max(0.05, Math.min(32, p.overlap));
|
|
113
|
+
const period = (grainSec * sr) / overlap;
|
|
114
|
+
const len = Math.max(16, Math.min(maxGrainFrames, Math.round(grainSec * sr)));
|
|
115
|
+
const winGain = Math.min(
|
|
116
|
+
1,
|
|
117
|
+
1 / Math.sqrt(Math.max(1e-9, overlap * win.meanSq)),
|
|
118
|
+
);
|
|
119
|
+
const frameStep = srcRate / sr;
|
|
120
|
+
const scanStep = p.freeze ? 0 : p.scan * frameStep;
|
|
121
|
+
const grains: Grain[] = [];
|
|
122
|
+
const pool: Grain[] = [];
|
|
123
|
+
let t = 0;
|
|
124
|
+
let scanned = 0;
|
|
125
|
+
let nextGrid = 0;
|
|
126
|
+
let k = 0;
|
|
127
|
+
let latch = 0;
|
|
128
|
+
let latched = 0;
|
|
129
|
+
let lp = 0;
|
|
130
|
+
let lpR = 0;
|
|
131
|
+
const lpA =
|
|
132
|
+
p.veltone > 0
|
|
133
|
+
? 1 -
|
|
134
|
+
Math.exp(
|
|
135
|
+
(-2 *
|
|
136
|
+
Math.PI *
|
|
137
|
+
Math.min(
|
|
138
|
+
0.45 * sr,
|
|
139
|
+
18000 * 2 ** (-6 * p.veltone * (1 - init.velocity)),
|
|
140
|
+
)) /
|
|
141
|
+
sr,
|
|
142
|
+
)
|
|
143
|
+
: 1;
|
|
144
|
+
const amp = init.velocity * p.gain;
|
|
145
|
+
const blockL = new Float64Array(CONTROL_FRAMES);
|
|
146
|
+
const blockR = new Float64Array(CONTROL_FRAMES);
|
|
147
|
+
const driftDepth = 0.5 * p.drift * region;
|
|
148
|
+
|
|
149
|
+
function driftAt(sec: number): number {
|
|
150
|
+
if (driftDepth === 0) return 0;
|
|
151
|
+
const x = sec * p.drate;
|
|
152
|
+
const i = Math.floor(x);
|
|
153
|
+
const f = x - i;
|
|
154
|
+
const a = 2 * unit(seed, i, 7) - 1;
|
|
155
|
+
const b = 2 * unit(seed, i + 1, 7) - 1;
|
|
156
|
+
const s = 0.5 - 0.5 * Math.cos(Math.PI * f);
|
|
157
|
+
return driftDepth * (a + (b - a) * s);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function spawn(): void {
|
|
161
|
+
const sec = nextGrid / sr;
|
|
162
|
+
// Beat repeat: a grid step may latch the head for `hold` steps.
|
|
163
|
+
if (latch > 0) latch -= 1;
|
|
164
|
+
else {
|
|
165
|
+
latched = scanned;
|
|
166
|
+
if (p.repeat > 0 && unit(seed, k, 6) < p.repeat) latch = p.hold - 1;
|
|
167
|
+
}
|
|
168
|
+
const head = latched;
|
|
169
|
+
const onset = nextGrid + p.jitter * unit(seed, k, 0) * period;
|
|
170
|
+
const semis =
|
|
171
|
+
p.pitch +
|
|
172
|
+
p.detune * (unit(seed, k, 2) - 0.5) +
|
|
173
|
+
(unit(seed, k, 5) < p.shimmer ? p.shimint : 0);
|
|
174
|
+
const step0 = init.baseRate * 2 ** (semis / 12) * frameStep;
|
|
175
|
+
let center =
|
|
176
|
+
begin +
|
|
177
|
+
p.pos * region +
|
|
178
|
+
head +
|
|
179
|
+
driftAt(sec) +
|
|
180
|
+
p.spray * srcRate * (2 * unit(seed, k, 1) - 1);
|
|
181
|
+
center = begin + ((((center - begin) % region) + region) % region);
|
|
182
|
+
const span = step0 * len;
|
|
183
|
+
const back = unit(seed, k, 4) < p.reverse;
|
|
184
|
+
const theta = ((1 + p.spread * (2 * unit(seed, k, 3) - 1)) * Math.PI) / 4;
|
|
185
|
+
if (grains.length < MAX_GRAINS) {
|
|
186
|
+
const g = pool.pop() ?? new Grain();
|
|
187
|
+
g.start = Math.round(onset);
|
|
188
|
+
g.len = len;
|
|
189
|
+
g.pos = back ? center + span / 2 : center - span / 2;
|
|
190
|
+
g.step = back ? -step0 : step0;
|
|
191
|
+
g.gl = Math.cos(theta) * Math.SQRT2 * winGain;
|
|
192
|
+
g.gr = Math.sin(theta) * Math.SQRT2 * winGain;
|
|
193
|
+
grains.push(g);
|
|
194
|
+
}
|
|
195
|
+
k += 1;
|
|
196
|
+
nextGrid += period;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function envelope(i: number): number {
|
|
200
|
+
const a = i < attackFrames ? i / attackFrames : 1;
|
|
201
|
+
let e: number;
|
|
202
|
+
if (i < init.gateFrames) e = a;
|
|
203
|
+
else {
|
|
204
|
+
const r = 1 - (i - init.gateFrames) / releaseFrames;
|
|
205
|
+
e = r > 0 ? a * r : 0;
|
|
206
|
+
}
|
|
207
|
+
const damp = init.damp;
|
|
208
|
+
if (damp) {
|
|
209
|
+
const sec = i / sr;
|
|
210
|
+
if (sec > damp.from) e *= Math.exp(-(sec - damp.from) / damp.tau);
|
|
211
|
+
}
|
|
212
|
+
return e;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function renderGrains(from: number, n: number, centsMul: number): void {
|
|
216
|
+
blockL.fill(0, 0, n);
|
|
217
|
+
blockR.fill(0, 0, n);
|
|
218
|
+
const to = from + n;
|
|
219
|
+
for (let gi = grains.length - 1; gi >= 0; gi -= 1) {
|
|
220
|
+
const g = grains[gi]!;
|
|
221
|
+
if (g.start >= to) continue;
|
|
222
|
+
const a = g.start > from ? g.start : from;
|
|
223
|
+
const gEnd = g.start + g.len;
|
|
224
|
+
const b = gEnd < to ? gEnd : to;
|
|
225
|
+
const step = g.step * centsMul;
|
|
226
|
+
const level = bankLevel(step);
|
|
227
|
+
let pos = g.pos;
|
|
228
|
+
let ci = Math.floor(pos) >> 12;
|
|
229
|
+
let chunk = bankChunk(source, level, ci);
|
|
230
|
+
const wScale = WINDOW_TABLE / g.len;
|
|
231
|
+
const gl = g.gl;
|
|
232
|
+
const gr = g.gr;
|
|
233
|
+
for (let i = a; i < b; i += 1) {
|
|
234
|
+
const ip = Math.floor(pos);
|
|
235
|
+
const c = ip >> 12;
|
|
236
|
+
if (c !== ci) {
|
|
237
|
+
ci = c;
|
|
238
|
+
chunk = bankChunk(source, level, ci);
|
|
239
|
+
}
|
|
240
|
+
const o = (ip & CHUNK_MASK) + 1;
|
|
241
|
+
const f = pos - ip;
|
|
242
|
+
const xm1 = chunk[o - 1]!;
|
|
243
|
+
const x0 = chunk[o]!;
|
|
244
|
+
const x1 = chunk[o + 1]!;
|
|
245
|
+
const x2 = chunk[o + 2]!;
|
|
246
|
+
const c1 = 0.5 * (x1 - xm1);
|
|
247
|
+
const c2 = xm1 - 2.5 * x0 + 2 * x1 - 0.5 * x2;
|
|
248
|
+
const c3 = 0.5 * (x2 - xm1) + 1.5 * (x0 - x1);
|
|
249
|
+
const sample = ((c3 * f + c2) * f + c1) * f + x0;
|
|
250
|
+
const u = (i - g.start) * wScale;
|
|
251
|
+
const wi = Math.floor(u);
|
|
252
|
+
const w = table[wi]! + (table[wi + 1]! - table[wi]!) * (u - wi);
|
|
253
|
+
const s = sample * w;
|
|
254
|
+
const j = i - from;
|
|
255
|
+
blockL[j] = blockL[j]! + s * gl;
|
|
256
|
+
blockR[j] = blockR[j]! + s * gr;
|
|
257
|
+
pos += step;
|
|
258
|
+
}
|
|
259
|
+
g.pos = pos;
|
|
260
|
+
if (gEnd <= to) {
|
|
261
|
+
const last = grains.pop()!;
|
|
262
|
+
if (last !== g) grains[gi] = last;
|
|
263
|
+
pool.push(g);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
totalFrames: total,
|
|
270
|
+
process(outL, outR, offset, frames) {
|
|
271
|
+
let done = 0;
|
|
272
|
+
while (done < frames) {
|
|
273
|
+
if (t >= total) return false;
|
|
274
|
+
const n = Math.min(
|
|
275
|
+
CONTROL_FRAMES - (t % CONTROL_FRAMES),
|
|
276
|
+
frames - done,
|
|
277
|
+
total - t,
|
|
278
|
+
);
|
|
279
|
+
// Grid points of this block: the grid keeps running through the
|
|
280
|
+
// release, so the release fades a living cloud (spec: release ~ T60).
|
|
281
|
+
while (nextGrid < t + n && t < spawnEnd) spawn();
|
|
282
|
+
const cents = init.cents ? init.cents(t / sr) : 0;
|
|
283
|
+
const centsMul = cents === 0 ? 1 : 2 ** (cents / 1200);
|
|
284
|
+
renderGrains(t, n, centsMul);
|
|
285
|
+
scanned += scanStep * n;
|
|
286
|
+
for (let i = 0; i < n; i += 1) {
|
|
287
|
+
let l = blockL[i]!;
|
|
288
|
+
let r = blockR[i]!;
|
|
289
|
+
if (lpA < 1) {
|
|
290
|
+
lp += lpA * (l - lp);
|
|
291
|
+
lpR += lpA * (r - lpR);
|
|
292
|
+
l = lp;
|
|
293
|
+
r = lpR;
|
|
294
|
+
}
|
|
295
|
+
const e = envelope(t + i) * amp;
|
|
296
|
+
const at = offset + done + i;
|
|
297
|
+
outL[at] = outL[at]! + l * e;
|
|
298
|
+
outR[at] = outR[at]! + r * e;
|
|
299
|
+
}
|
|
300
|
+
t += n;
|
|
301
|
+
done += n;
|
|
302
|
+
}
|
|
303
|
+
return t < total;
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ------------------------------------------------------------- sources
|
|
309
|
+
|
|
310
|
+
const synthSources = new Map<string, BankSource>();
|
|
311
|
+
const MAX_SYNTH_SOURCES = 16;
|
|
312
|
+
/** Envelope hop of the held-loop analysis. */
|
|
313
|
+
const LOOP_HOP_SECONDS = 0.01;
|
|
314
|
+
/** The loop starts once the render is within 6 dB of its loudest hop. */
|
|
315
|
+
const LOOP_START_DB = -6;
|
|
316
|
+
/** ... and ends at the last hop within 30 dB of it. */
|
|
317
|
+
const LOOP_END_DB = -30;
|
|
318
|
+
/** The loop is levelled by at most this much (a decaying bell or pluck). */
|
|
319
|
+
const LOOP_MAX_GAIN_DB = 30;
|
|
320
|
+
const LOOP_FADE_SECONDS = 0.05;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The built-in source: a deterministic offline render of a synth preset
|
|
324
|
+
* (or a sound with `synth` params) held at `note` for 4 s, made into a
|
|
325
|
+
* held, seamless loop so a long note never wraps into silence or through
|
|
326
|
+
* the attack: the sustained part (from 6 dB under the loudest point to
|
|
327
|
+
* 30 dB under it) is levelled to a flat envelope, crossfaded at its seam
|
|
328
|
+
* and tiled to at least 4 s, peak-normalised to 0.7.
|
|
329
|
+
*/
|
|
330
|
+
export function synthSource(
|
|
331
|
+
name: string,
|
|
332
|
+
note: number,
|
|
333
|
+
sampleRate: number,
|
|
334
|
+
synth?: TrackSynth,
|
|
335
|
+
): BankSource {
|
|
336
|
+
const preset = SYNTH_PRESETS[name];
|
|
337
|
+
const params = preset ? preset.synth : synth;
|
|
338
|
+
const id = `synth:${name}@${note}:${sampleRate}${
|
|
339
|
+
!preset && synth ? `:${JSON.stringify(synth)}` : ""
|
|
340
|
+
}`;
|
|
341
|
+
const cached = synthSources.get(id);
|
|
342
|
+
if (cached) return cached;
|
|
343
|
+
const frames = Math.round(SYNTH_SOURCE_SECONDS * sampleRate);
|
|
344
|
+
const track = {
|
|
345
|
+
id: "granular-source",
|
|
346
|
+
name: "granular source",
|
|
347
|
+
instrument: preset ? preset.instrument : name,
|
|
348
|
+
muted: false,
|
|
349
|
+
volume: 1,
|
|
350
|
+
pan: 0,
|
|
351
|
+
...(params ? { synth: params } : {}),
|
|
352
|
+
} as unknown as Track;
|
|
353
|
+
const left = new Float64Array(frames);
|
|
354
|
+
const ticksPerBeat = 480;
|
|
355
|
+
renderSynthNote(
|
|
356
|
+
left,
|
|
357
|
+
undefined,
|
|
358
|
+
{
|
|
359
|
+
id: "granular-source",
|
|
360
|
+
trackId: track.id,
|
|
361
|
+
startTick: 0,
|
|
362
|
+
durationTicks: ticksPerBeat * 8,
|
|
363
|
+
pitch: note,
|
|
364
|
+
velocity: 1,
|
|
365
|
+
},
|
|
366
|
+
track,
|
|
367
|
+
0,
|
|
368
|
+
frames,
|
|
369
|
+
{
|
|
370
|
+
sampleRate,
|
|
371
|
+
samples: frames,
|
|
372
|
+
samplesPerTick: (sampleRate * 0.5) / ticksPerBeat,
|
|
373
|
+
tempoBpm: 120,
|
|
374
|
+
ticksPerBeat,
|
|
375
|
+
},
|
|
376
|
+
() => 1,
|
|
377
|
+
);
|
|
378
|
+
const data = heldLoop(left, sampleRate);
|
|
379
|
+
const source = Object.freeze({ id, rate: sampleRate, data });
|
|
380
|
+
synthSources.set(id, source);
|
|
381
|
+
while (synthSources.size > MAX_SYNTH_SOURCES) {
|
|
382
|
+
const oldest = synthSources.keys().next().value as string;
|
|
383
|
+
synthSources.delete(oldest);
|
|
384
|
+
}
|
|
385
|
+
return source;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** A held render made into a levelled, seamless loop (see synthSource). */
|
|
389
|
+
export function heldLoop(
|
|
390
|
+
render: Float64Array,
|
|
391
|
+
sampleRate: number,
|
|
392
|
+
): Float32Array {
|
|
393
|
+
const frames = render.length;
|
|
394
|
+
const hop = Math.max(1, Math.round(LOOP_HOP_SECONDS * sampleRate));
|
|
395
|
+
const hops = Math.max(1, Math.floor(frames / hop));
|
|
396
|
+
const rms = new Float64Array(hops);
|
|
397
|
+
let loudest = 0;
|
|
398
|
+
for (let h = 0; h < hops; h += 1) {
|
|
399
|
+
let sum = 0;
|
|
400
|
+
for (let i = h * hop; i < (h + 1) * hop; i += 1)
|
|
401
|
+
sum += render[i]! * render[i]!;
|
|
402
|
+
rms[h] = Math.sqrt(sum / hop);
|
|
403
|
+
if (rms[h]! > loudest) loudest = rms[h]!;
|
|
404
|
+
}
|
|
405
|
+
if (loudest === 0) return new Float32Array(frames);
|
|
406
|
+
const startLevel = loudest * 10 ** (LOOP_START_DB / 20);
|
|
407
|
+
const endLevel = loudest * 10 ** (LOOP_END_DB / 20);
|
|
408
|
+
let first = 0;
|
|
409
|
+
while (first < hops - 1 && rms[first]! < startLevel) first += 1;
|
|
410
|
+
let last = hops - 1;
|
|
411
|
+
while (last > first && rms[last]! < endLevel) last -= 1;
|
|
412
|
+
const begin = first * hop;
|
|
413
|
+
const stop = Math.min(frames, (last + 1) * hop);
|
|
414
|
+
const fade = Math.max(
|
|
415
|
+
1,
|
|
416
|
+
Math.min(
|
|
417
|
+
Math.round(LOOP_FADE_SECONDS * sampleRate),
|
|
418
|
+
Math.floor((stop - begin) / 4),
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
const loop = Math.max(1, stop - begin - fade);
|
|
422
|
+
// Level the loop: gain = loudest / envelope (hop centres, interpolated).
|
|
423
|
+
const maxGain = 10 ** (LOOP_MAX_GAIN_DB / 20);
|
|
424
|
+
const levelled = new Float64Array(loop + fade);
|
|
425
|
+
for (let i = 0; i < loop + fade; i += 1) {
|
|
426
|
+
const frame = begin + i;
|
|
427
|
+
const x = frame / hop - 0.5;
|
|
428
|
+
const h0 = Math.max(0, Math.min(hops - 1, Math.floor(x)));
|
|
429
|
+
const h1 = Math.min(hops - 1, h0 + 1);
|
|
430
|
+
const f = Math.max(0, Math.min(1, x - h0));
|
|
431
|
+
const env = rms[h0]! + (rms[h1]! - rms[h0]!) * f;
|
|
432
|
+
const gain = env > 0 ? Math.min(maxGain, loudest / env) : maxGain;
|
|
433
|
+
levelled[i] = (render[frame] ?? 0) * gain;
|
|
434
|
+
}
|
|
435
|
+
// Seam: the head of the loop fades in while the frames after its end
|
|
436
|
+
// fade out (equal power), so loop[last] -> loop[0] continues the render.
|
|
437
|
+
const seamless = new Float64Array(loop);
|
|
438
|
+
for (let i = 0; i < loop; i += 1) seamless[i] = levelled[i]!;
|
|
439
|
+
for (let i = 0; i < fade && i < loop; i += 1) {
|
|
440
|
+
const u = (i + 0.5) / fade;
|
|
441
|
+
seamless[i] =
|
|
442
|
+
levelled[i]! * Math.sin((u * Math.PI) / 2) +
|
|
443
|
+
levelled[loop + i]! * Math.cos((u * Math.PI) / 2);
|
|
444
|
+
}
|
|
445
|
+
const copies = Math.max(1, Math.ceil(frames / loop));
|
|
446
|
+
const out = new Float32Array(loop * copies);
|
|
447
|
+
let peak = 0;
|
|
448
|
+
for (let i = 0; i < loop; i += 1)
|
|
449
|
+
peak = Math.max(peak, Math.abs(seamless[i]!));
|
|
450
|
+
const g = peak > 0 ? 0.7 / peak : 0;
|
|
451
|
+
for (let c = 0; c < copies; c += 1)
|
|
452
|
+
for (let i = 0; i < loop; i += 1) out[c * loop + i] = seamless[i]! * g;
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The bank key of a granular track's sample source. */
|
|
457
|
+
export function granularSampleKey(trackId: string): string {
|
|
458
|
+
return `${trackId}\u0000${GRANULAR_SOURCE_VOICE}`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
type ResolvedSource = Readonly<{ source: BankSource; root: number }>;
|
|
462
|
+
|
|
463
|
+
/** The track's source and the note that plays it at its own pitch. */
|
|
464
|
+
export function granularSource(
|
|
465
|
+
track: Track,
|
|
466
|
+
sampleRate: number,
|
|
467
|
+
bank: SampleBank,
|
|
468
|
+
): ResolvedSource | undefined {
|
|
469
|
+
const s = resolveGranular(track.granular);
|
|
470
|
+
const src = s.src;
|
|
471
|
+
const stored = track.granular?.root;
|
|
472
|
+
if (typeof src === "string") {
|
|
473
|
+
const parsed =
|
|
474
|
+
parseSynthSource(src) ?? parseSynthSource(DEFAULT_GRANULAR_SOURCE)!;
|
|
475
|
+
// A sound (not a preset) plays with the track's own synth params, so
|
|
476
|
+
// `grain on` grains the track's tuned sound.
|
|
477
|
+
return {
|
|
478
|
+
source: synthSource(parsed.name, parsed.note, sampleRate, track.synth),
|
|
479
|
+
root: stored ?? parsed.note,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
const decoded: DecodedSample | undefined = bank.voices.get(
|
|
483
|
+
granularSampleKey(track.id),
|
|
484
|
+
);
|
|
485
|
+
if (!decoded || decoded.frames === 0) return undefined;
|
|
486
|
+
const ref: SampleRef = src;
|
|
487
|
+
return {
|
|
488
|
+
source: {
|
|
489
|
+
id: decoded.sha256,
|
|
490
|
+
rate: decoded.sampleRate,
|
|
491
|
+
data: decoded.mono,
|
|
492
|
+
},
|
|
493
|
+
root: stored ?? ref.root ?? 60,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// --------------------------------------------------------------- engine
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Seeds by (seed, track, pitch, startTick, occurrence). The start is the
|
|
501
|
+
* performed one, so humanize timing (amount or seed) also changes the
|
|
502
|
+
* grain draws; edits elsewhere in the track do not.
|
|
503
|
+
*/
|
|
504
|
+
export function granularSeeds(
|
|
505
|
+
notes: readonly PerformedNote[],
|
|
506
|
+
seed: number,
|
|
507
|
+
trackId: string,
|
|
508
|
+
): number[] {
|
|
509
|
+
const seen = new Map<string, number>();
|
|
510
|
+
return notes.map((note) => {
|
|
511
|
+
const at = `${note.pitch}:${note.startTick}`;
|
|
512
|
+
const occurrence = seen.get(at) ?? 0;
|
|
513
|
+
seen.set(at, occurrence + 1);
|
|
514
|
+
return seedHash(`${seed}:${trackId}:${at}:${occurrence}`);
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function spanOf(
|
|
519
|
+
note: PerformedNote,
|
|
520
|
+
context: EngineContext,
|
|
521
|
+
): { start: number; length: number } {
|
|
522
|
+
if (context.warp)
|
|
523
|
+
return warpedSpan(context.warp, note.startTick, note.durationTicks);
|
|
524
|
+
const { tempoBpm, sampleRate } = context;
|
|
525
|
+
const tpb = context.ticksPerBeat;
|
|
526
|
+
return {
|
|
527
|
+
start: Math.max(
|
|
528
|
+
0,
|
|
529
|
+
Math.floor((((note.startTick / tpb) * 60) / tempoBpm) * sampleRate),
|
|
530
|
+
),
|
|
531
|
+
length: Math.max(
|
|
532
|
+
1,
|
|
533
|
+
Math.floor((((note.durationTicks / tpb) * 60) / tempoBpm) * sampleRate),
|
|
534
|
+
),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Renders a granular track's performed notes into `left`/`right`. */
|
|
539
|
+
export function renderGranularTrack(
|
|
540
|
+
left: Float64Array,
|
|
541
|
+
right: Float64Array,
|
|
542
|
+
notes: readonly PerformedNote[],
|
|
543
|
+
track: Track,
|
|
544
|
+
context: EngineContext,
|
|
545
|
+
bank: SampleBank,
|
|
546
|
+
): void {
|
|
547
|
+
const resolved = granularSource(track, context.sampleRate, bank);
|
|
548
|
+
if (!resolved) return;
|
|
549
|
+
const settings = resolveGranular(track.granular);
|
|
550
|
+
const sr = context.sampleRate;
|
|
551
|
+
const seeds = granularSeeds(notes, settings.seed, track.id);
|
|
552
|
+
const rootHz = noteHz(resolved.root, undefined, context.tuning);
|
|
553
|
+
// Plan: spans, then voice stealing (16 per track, oldest first).
|
|
554
|
+
type Plan = {
|
|
555
|
+
note: PerformedNote;
|
|
556
|
+
seed: number;
|
|
557
|
+
start: number;
|
|
558
|
+
length: number;
|
|
559
|
+
end: number;
|
|
560
|
+
cut: number;
|
|
561
|
+
};
|
|
562
|
+
const plans: Plan[] = notes.map((note, index) => {
|
|
563
|
+
const { start, length } = spanOf(note, context);
|
|
564
|
+
const tail = Math.round(granularTailSeconds(track.granular) * sr);
|
|
565
|
+
return {
|
|
566
|
+
note,
|
|
567
|
+
seed: seeds[index]!,
|
|
568
|
+
start,
|
|
569
|
+
length,
|
|
570
|
+
end: start + length + tail,
|
|
571
|
+
cut: Infinity,
|
|
572
|
+
};
|
|
573
|
+
});
|
|
574
|
+
const order = plans
|
|
575
|
+
.map((_, index) => index)
|
|
576
|
+
.sort((a, b) => plans[a]!.start - plans[b]!.start || a - b);
|
|
577
|
+
const active: Plan[] = [];
|
|
578
|
+
for (const index of order) {
|
|
579
|
+
const plan = plans[index]!;
|
|
580
|
+
for (let i = active.length - 1; i >= 0; i -= 1)
|
|
581
|
+
if (Math.min(active[i]!.end, active[i]!.cut) <= plan.start)
|
|
582
|
+
active.splice(i, 1);
|
|
583
|
+
if (active.length >= MAX_VOICES) {
|
|
584
|
+
// Oldest released voice first (active is in start order), else the
|
|
585
|
+
// oldest held one.
|
|
586
|
+
let at = active.findIndex(
|
|
587
|
+
(candidate) => candidate.start + candidate.length <= plan.start,
|
|
588
|
+
);
|
|
589
|
+
if (at < 0) at = 0;
|
|
590
|
+
const victim = active.splice(at, 1)[0]!;
|
|
591
|
+
victim.cut = plan.start;
|
|
592
|
+
}
|
|
593
|
+
active.push(plan);
|
|
594
|
+
}
|
|
595
|
+
const stealFrames = Math.max(1, Math.round(STEAL_SECONDS * sr));
|
|
596
|
+
const block = 4096;
|
|
597
|
+
const outL = new Float64Array(block);
|
|
598
|
+
const outR = new Float64Array(block);
|
|
599
|
+
const volume = Math.max(0, Math.min(1, track.volume ?? 1));
|
|
600
|
+
const lane = track.volumeAutomation ?? [];
|
|
601
|
+
for (const plan of plans) {
|
|
602
|
+
const { note } = plan;
|
|
603
|
+
if (plan.start >= left.length) continue;
|
|
604
|
+
const hz = noteHz(note.pitch, note.cents, context.tuning);
|
|
605
|
+
const performance = note.performance;
|
|
606
|
+
const voice = granularVoice({
|
|
607
|
+
source: resolved.source,
|
|
608
|
+
sr,
|
|
609
|
+
settings,
|
|
610
|
+
baseRate: hz / rootHz,
|
|
611
|
+
velocity: Math.max(0, Math.min(1, note.velocity)),
|
|
612
|
+
gateFrames: plan.length,
|
|
613
|
+
...(performance?.cents ? { cents: performance.cents } : {}),
|
|
614
|
+
...(performance?.damp ? { damp: performance.damp } : {}),
|
|
615
|
+
seed: plan.seed,
|
|
616
|
+
});
|
|
617
|
+
const stop = Math.min(
|
|
618
|
+
left.length,
|
|
619
|
+
plan.start + voice.totalFrames,
|
|
620
|
+
plan.cut === Infinity ? Infinity : plan.cut + stealFrames,
|
|
621
|
+
);
|
|
622
|
+
for (let at = plan.start; at < stop; at += block) {
|
|
623
|
+
const n = Math.min(block, stop - at);
|
|
624
|
+
outL.fill(0, 0, n);
|
|
625
|
+
outR.fill(0, 0, n);
|
|
626
|
+
const alive = voice.process(outL, outR, 0, n);
|
|
627
|
+
for (let i = 0; i < n; i += 1) {
|
|
628
|
+
const frame = at + i;
|
|
629
|
+
const tick = context.warp
|
|
630
|
+
? context.warp.tick(frame)
|
|
631
|
+
: note.startTick +
|
|
632
|
+
(frame - plan.start) / Math.max(1e-9, context.samplesPerTick);
|
|
633
|
+
let g =
|
|
634
|
+
volume * (lane.length > 0 ? interpolateAutomation(lane, tick, 1) : 1);
|
|
635
|
+
if (frame >= plan.cut)
|
|
636
|
+
g *= Math.max(0, 1 - (frame - plan.cut) / stealFrames);
|
|
637
|
+
left[frame] = left[frame]! + outL[i]! * g;
|
|
638
|
+
right[frame] = right[frame]! + outR[i]! * g;
|
|
639
|
+
}
|
|
640
|
+
if (!alive) break;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** The registered engine (`src/audio/instruments.ts`). */
|
|
646
|
+
export const GRANULAR_ENGINE: InstrumentEngine = Object.freeze({
|
|
647
|
+
id: "granular",
|
|
648
|
+
field: "granular",
|
|
649
|
+
render(dry, dryR, notes, track, context, bank) {
|
|
650
|
+
const right = dryR ?? new Float64Array(dry.length);
|
|
651
|
+
renderGranularTrack(dry, right, notes, track, context, bank);
|
|
652
|
+
if (!dryR)
|
|
653
|
+
for (let i = 0; i < dry.length; i += 1)
|
|
654
|
+
dry[i] = 0.5 * (dry[i]! + right[i]!);
|
|
655
|
+
},
|
|
656
|
+
tailSeconds: (track: Track) => granularTailSeconds(track.granular),
|
|
657
|
+
stereo: () => true,
|
|
658
|
+
assetDigests(track: Track, bank: SampleBank): readonly string[] {
|
|
659
|
+
const src = track.granular?.src;
|
|
660
|
+
if (src === undefined || typeof src === "string") return [];
|
|
661
|
+
const decoded = bank.voices.get(granularSampleKey(track.id));
|
|
662
|
+
return [decoded ? decoded.sha256 : "missing"];
|
|
663
|
+
},
|
|
664
|
+
});
|