@hraness/dawg 0.5.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 +63 -0
- package/DAWG.md +592 -49
- package/README.md +4 -4
- package/core/chords.ts +492 -4
- package/core/diff.ts +9 -0
- package/core/expression.ts +143 -6
- package/core/fx.ts +689 -2
- package/core/granular.ts +619 -0
- package/core/instruments.ts +442 -0
- package/core/keys.ts +955 -0
- package/core/resonators.ts +862 -0
- package/core/score.ts +529 -8
- package/core/sdk/eval-child.ts +2 -0
- package/core/sdk/print.ts +247 -16
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2417 -40
- package/core/sections.ts +44 -10
- package/core/strings.ts +1080 -0
- package/core/winds.ts +652 -0
- package/guides/automation.md +1 -0
- package/guides/chords.md +3 -1
- package/guides/effects.md +5 -2
- package/guides/media.md +1 -1
- package/guides/performance.md +3 -0
- package/guides/resample.md +26 -0
- package/guides/sounds.md +7 -2
- package/guides/tempo.md +1 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +13 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +42 -2
- package/src/agent/preview-tool.ts +7 -1
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/tools.ts +726 -17
- package/src/agent/xcb-agent.ts +13 -0
- package/src/audio/arrange.ts +42 -3
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/envelope.ts +161 -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/shift.ts +256 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/chain.ts +23 -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/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/engine.ts +56 -6
- package/src/audio/fit.ts +447 -0
- package/src/audio/granular.ts +995 -0
- package/src/audio/instrument-check.ts +137 -0
- package/src/audio/instruments.ts +140 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/electric.ts +420 -0
- package/src/audio/keys/engine.ts +485 -0
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +476 -0
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/live-worker.ts +56 -0
- package/src/audio/live.ts +361 -15
- package/src/audio/loudness.ts +65 -20
- package/src/audio/preview.ts +17 -1
- package/src/audio/resample.ts +285 -0
- package/src/audio/resonators.ts +295 -0
- package/src/audio/sampler.ts +328 -35
- package/src/audio/samples.ts +56 -8
- package/src/audio/strings/body.ts +263 -0
- package/src/audio/strings/bow.ts +656 -0
- package/src/audio/strings/engine.ts +615 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +200 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/warp.ts +61 -0
- package/src/audio/wav.ts +191 -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 +111 -23
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +35 -1
- package/src/commands/granular.ts +432 -0
- package/src/commands/help.ts +159 -5
- package/src/commands/keys.ts +629 -0
- package/src/commands/modal.ts +310 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rig.ts +264 -0
- package/src/commands/sample.ts +52 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/string.ts +201 -0
- package/src/commands/strum.ts +473 -0
- package/src/commands/time.ts +4 -1
- package/src/commands/wind.ts +244 -0
- package/src/main.ts +339 -15
- package/src/project/check.ts +17 -0
- package/src/render.ts +49 -4
- package/src/session/presence.ts +16 -3
- package/src/tui/audition.ts +2 -2
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu.ts +934 -9
- package/src/tui/modal-menu.ts +145 -0
- 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 +70 -2
- package/src/tui/wind-menu.ts +144 -0
package/src/agent/xcb-agent.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { GRANULAR_PARAMS } from "../../core/granular.ts";
|
|
1
2
|
import { compositionBrief } from "./brief.ts";
|
|
2
3
|
import { CHORD_PROCESS } from "../../core/chords.ts";
|
|
3
4
|
import {
|
|
@@ -49,10 +50,22 @@ export const TEXT_AGENT_SYSTEM_PROMPT = [
|
|
|
49
50
|
"Song structure: list_sections shows named sections (bars count from 1); edit_section marks, adds, duplicates, moves, renames, deletes, mutes, varies (transpose/gain) and loops them; set_form sets the play order with repeats (intro verse chorus*2 outro); add_transition generates builds (riser, snare roll, filter sweep, uplifter), drops (cut plus impact) and drum fills at section boundaries.",
|
|
50
51
|
CHORD_PROCESS,
|
|
51
52
|
"Synth voices: set_synth shapes a synth track with Strudel params (ADSR, lpf/lpenv filter envelopes, fm/fmh, supersaw unison/detune, vib, penv, noise; z_* ZzFX sounds take slide/pitchJump/lfo/zcrush…) or a preset (pad lead pluck bass keys bell…).",
|
|
53
|
+
"Plucked strings: set_string makes a track a physical-model string (nylon steel electric jangle ebass slap upright motown sitar tanpura harpsichord lute oud setar tar santur dulcimer koto harp banjo tres requinto) and overrides params (ring bright damp pos mute buzz body sym). The bare words sitar, ebass, pluck keep their older voices; use set_string for the modelled ones.",
|
|
54
|
+
"Bowed strings: set_string preset violin|viola|cello|contrabass|fiddle|erhu|kamancheh|violins|violas|cellos|contrabasses|pizz|trem plays a bowed physical model (bow params pressure speed attack vib vibmod vibdelay tremhz sord dyn). Overlapping or legato notes slur in one bow. The bare words cello, contrabass and strings keep their older voices; use set_string (or the words violin, viola, fiddle, erhu, violins, cellos) for the bowed ones. Sampler voices take vel [lo, hi] velocity layers and rr round-robin groups via set_sample.",
|
|
55
|
+
"Pianos: set_instrument piano (or grand upright felt honkytonk prepared, ballad, lofi) loads a modelled piano with real hammers, stretched inharmonic strings, dampers and the sustain pedal; set_keys tweaks it (hardness touch decay release felt stretch body…, automate as keys-<param> lanes; sym 0.5 adds sympathetic bloom under the sustain pedal for pedalled Romantic and Impressionist playing). A track already stored as instrument piano keeps the legacy tone until you set it again.",
|
|
56
|
+
"Electric keys: set_instrument epiano (rhodes; suitcase stereo vibe, dyno bright 80s), wurli, clav (funkclav); set_keys tweaks bark bell tone vibe (epiano), trem (wurli), pickup neck|bridge|both|out and mute (clav). Soft and sostenuto pedals: set_piano_pedals on any modelled piano.",
|
|
57
|
+
'Organs: set_instrument tonewheel (hammond b3 gospel jazzorgan), combo (farfisa vox) or pipe (church flutes cornet reeds celeste) loads a modelled organ; instrument organ stays the legacy sine. set_keys drawbars "888800008" (16\' to 1\'), registers "08880" (combo), stops ["principal8","octave4"] or plenum|flutes|cornet|reeds|strings|full (pipe), rotary slow|fast|stop, params perc 3rd percdecay percvol click scanner c3 drive chiff wind trem (each family takes only its own rows); automate keys-rotary and keys-drive lanes.',
|
|
58
|
+
"Mallets and bells: set_instrument vibes (or marimba via modal, xylophone glock celesta chimes kalimba mbira steelpan bowl gong timpani) plays the modal engine; set_modal changes mallet (yarn…brass), hardness, position, ring, damp, motor/motordepth (vibes tremolo), buzz, click. Plain instrument marimba stays the legacy tone.",
|
|
59
|
+
"Gamelan: set_modal preset saron demung slenthem gangsa gender bonang kenong kethuk kempul (gong too) with tuning slendro or pelog; for ombak (the shimmer) put the same part on two gangsa tracks and set_modal pair <other track> on one: it sounds ombak Hz (default 6) above its partner. Frame drums: daf bodhran tabla.",
|
|
60
|
+
"Winds and brass: set_instrument flute recorder whistle ney shakuhachi panpipe suling bansuri clarinet bassclarinet oboe bassoon sax altosax barisax trumpet harmon plunger trombone tuba horn plays the wind engine; set_wind changes breath, bright, mute (open straight cup harmon plunger), players (2..8 spreads a section over chord tones), growl, vib. Lines slur unless re-tongued (gaps or articulations). Plain instrument wind stays the legacy tone.",
|
|
52
61
|
"Sampler voices: set_sample sets Strudel sample controls per voice (begin end speed unit loop loopBegin loopEnd clip/legato fit loopAt accelerate squiz cut); splice = slices with fit.",
|
|
62
|
+
"Fitting samples (0.6): fit_sample sets a voice's own bpm (or len in beats) so it follows the song's tempo map; fitmode beats for drums and speech (hits stay sharp), tones for pads and vocals (pitch kept), repitch (default) like tape.",
|
|
53
63
|
"Effects (set_fx; set_effects and set_automation): the brief's effects list is the fixed chain order with each effect's simple params. set_fx turns an effect on with good defaults, loads a preset or sets params by dawg or Strudel name. Lanes: volume, pan, filter, resonance, delay-feedback, delay-mix, wt and <effect>-<param> (e.g. autofilter-cutoff).",
|
|
64
|
+
"Guitar rig (set_rig): rig loads a whole rig (clean crunch punk ragged lead metal fuzz octave funk wah bachata spring bassdrive reese jangle alt) on a guitar or bass track; stomp, head and cab tweak the pedal, amp (level-matched, gate in dB) and cabinet. Tracks named jangle punk funk ragged gtr-lead gtr-metal bachata start with a guitar voice and that rig. `amp` is Strudel gain; the guitar amp is head.",
|
|
65
|
+
"Shoegaze: rig shoegaze|glide|dreampop|swell|ebow (or a track named shoegaze); set_fx wobble (tremolo-arm bend: depth cents, rate, drift), bloom (feedback swell on the top note), swell (volume swell: time), double (second take: time, drift, width); reverb ir builtin:reverse|gate|spring. Strumming: set_guitar (tune dadgad, capo, hand, ring), strum_chords (chords or the track's own, strokes folk|pop|punk… or D-DU-UDU, speed ms), write_chords perform guitar.",
|
|
54
66
|
"Previewing: preview_sound renders a track, or candidate sound tool calls (changes: [{tool, args}]), without committing, returns loudness, brightness and a comparison, and plays it once in the user's window. Use it when choosing between sounds (tables, presets, effect amounts), say how it sounds, then commit with the normal tools.",
|
|
55
67
|
"Wavetable synth: set_wavetable picks a table (built-ins offline, Strudel wt_ sets fetched once) and scans it by position wt.",
|
|
68
|
+
`Granular: set_granular makes a track a grain cloud (presets cloud hold sparkle swarm stutter microloop backwards dust) of a synth source (src synth:pad, the default; synth:<name>@<note>) or one of its own sampler voices, and overrides params (${Object.keys(GRANULAR_PARAMS).join(" ")}; repeat and hold latch the head for beat repeats, begin and end pick a region, root takes a MIDI number or note name); off returns to the previous voice; the same seed gives the same grains. Grain play: sync "1/16" puts grain onsets on the tempo grid, quant scale|chord snaps grain pitches, mono true glides legato notes through one cloud, pedal true freezes the head while the sustain pedal is down; set_automation grain-<param> lanes (grain-pos, grain-scan, grain-pitch …) move a parameter over beats. Resample: the resample tool renders a track, orbit or the mix (a section or bars) to a pinned WAV and adds a sampler track, or with grain a granular track, that plays it; set_sample shift (semitones, length kept, formant 0 keeps a voice's character) and fadeInTime/fadeTime shape a sampler voice.`,
|
|
56
69
|
"Mastering: set_master sets the song master and loudness target; measure_mix reports LUFS, true peak, balance and correlation. Master only when asked for loudness or a finished sound, and measure before and after.",
|
|
57
70
|
'Reply with exactly one JSON object and nothing else, shaped {"ops":[{"tool":"<tool name>","args":{...}}],"say":"<one short sentence describing the musical change>","done":true}.',
|
|
58
71
|
'Set "done":false only if you need to see the results of these ops before continuing; you will then get each op\'s result and can send more ops.',
|
package/src/audio/arrange.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
ScoreValidationError,
|
|
14
14
|
TrackScore,
|
|
15
15
|
type Note,
|
|
16
|
+
withNoteCap,
|
|
16
17
|
} from "../../core/score.ts";
|
|
17
18
|
import {
|
|
18
19
|
MAX_SONG_SECONDS,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
arrangedBars,
|
|
21
22
|
arrangedNotes,
|
|
22
23
|
arrangedSlice,
|
|
24
|
+
BAKED_NOTE_CAP,
|
|
23
25
|
bakeTrackTime,
|
|
24
26
|
barBeats,
|
|
25
27
|
barTicks,
|
|
@@ -49,6 +51,7 @@ import {
|
|
|
49
51
|
type RenderedAudio,
|
|
50
52
|
type WavCue,
|
|
51
53
|
} from "./wav.ts";
|
|
54
|
+
import { engineFor } from "./instruments.ts";
|
|
52
55
|
|
|
53
56
|
/** What one default render pass holds before the renderer cuts it. */
|
|
54
57
|
const DEFAULT_PASS_SECONDS = 30;
|
|
@@ -67,7 +70,7 @@ const WINDOW_BUDGET_SECONDS = 50;
|
|
|
67
70
|
export function exportScore(score: TrackScore): TrackScore {
|
|
68
71
|
// A form past the score's bar limit throws here; renderArranged renders
|
|
69
72
|
// such forms window by window instead, so it never needs this.
|
|
70
|
-
return flattenForm(bakeTrackTime(score));
|
|
73
|
+
return withNoteCap(BAKED_NOTE_CAP, () => flattenForm(bakeTrackTime(score)));
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
/**
|
|
@@ -109,7 +112,9 @@ export function loopedSection(score: TrackScore) {
|
|
|
109
112
|
export function playbackScore(score: TrackScore): TrackScore {
|
|
110
113
|
const section = loopedSection(score);
|
|
111
114
|
return section
|
|
112
|
-
?
|
|
115
|
+
? withNoteCap(BAKED_NOTE_CAP, () =>
|
|
116
|
+
sectionScore(bakeTrackTime(score), section),
|
|
117
|
+
)
|
|
113
118
|
: exportScore(score);
|
|
114
119
|
}
|
|
115
120
|
|
|
@@ -177,11 +182,22 @@ export function renderArranged(
|
|
|
177
182
|
fitsOnePass(renderer, score, options)
|
|
178
183
|
)
|
|
179
184
|
return renderer.render(score, options);
|
|
185
|
+
// Baked timed tracks (cycle, rate) may hold more notes than a stored score.
|
|
186
|
+
return withNoteCap(BAKED_NOTE_CAP, () =>
|
|
187
|
+
renderArrangedBaked(renderer, score, options),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function renderArrangedBaked(
|
|
192
|
+
renderer: StemRenderer,
|
|
193
|
+
score: TrackScore,
|
|
194
|
+
options: RenderOptions,
|
|
195
|
+
): RenderedAudio {
|
|
180
196
|
score = bakeTrackTime(score);
|
|
181
197
|
const section = options.loop ? loopedSection(score) : undefined;
|
|
182
198
|
if (!section && formSegments(score).length > 0)
|
|
183
199
|
return renderForm(renderer, score, options);
|
|
184
|
-
|
|
200
|
+
let played = section
|
|
185
201
|
? sectionScore(score, section)
|
|
186
202
|
: applySectionChanges(score);
|
|
187
203
|
if (played === score && fitsOnePass(renderer, score, options))
|
|
@@ -195,6 +211,9 @@ export function renderArranged(
|
|
|
195
211
|
);
|
|
196
212
|
if (seconds <= SINGLE_PASS_SECONDS)
|
|
197
213
|
return renderer.render(played, { ...options, maxSeconds: 60 });
|
|
214
|
+
// Windows pick notes by start tick, so timed tracks (cycle, rate) must be
|
|
215
|
+
// placed into song ticks first, sections or not.
|
|
216
|
+
played = bakeTrackTime(played, { always: true });
|
|
198
217
|
return renderWindows(
|
|
199
218
|
renderer,
|
|
200
219
|
played,
|
|
@@ -375,11 +394,31 @@ function renderWindows(
|
|
|
375
394
|
notes,
|
|
376
395
|
master: null,
|
|
377
396
|
});
|
|
397
|
+
// State an engine cannot rebuild in the pre-roll (organ rotors along a
|
|
398
|
+
// lane) is integrated over the song before the window.
|
|
399
|
+
const seedState: Record<string, string> = {};
|
|
400
|
+
if (
|
|
401
|
+
pre > 0 &&
|
|
402
|
+
window.tracks.some((track) => engineFor(track)?.windowSeed)
|
|
403
|
+
) {
|
|
404
|
+
const history = frame(0, pre);
|
|
405
|
+
for (const track of history.tracks) {
|
|
406
|
+
const seed = engineFor(track)?.windowSeed?.(
|
|
407
|
+
history,
|
|
408
|
+
track,
|
|
409
|
+
frameAt(pre),
|
|
410
|
+
sampleRate,
|
|
411
|
+
);
|
|
412
|
+
if (seed !== undefined) seedState[track.id] = seed;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
378
415
|
const audio = renderer.render(window, {
|
|
379
416
|
...options,
|
|
380
417
|
sampleRate,
|
|
381
418
|
loop: false,
|
|
382
419
|
seedTick: preTick,
|
|
420
|
+
...(pre > 0 ? { seedSeconds: secondsAtTick(timeline, pre * ticks) } : {}),
|
|
421
|
+
...(Object.keys(seedState).length > 0 ? { seedState } : {}),
|
|
383
422
|
// Past `end` too, so voices that fade where the buffer stops (the
|
|
384
423
|
// legacy tone voice) fade in frames this window drops.
|
|
385
424
|
maxSeconds: last
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared grain bank (0.6 granular, shared DSP): a source band-limited at
|
|
3
|
+
* one level per semitone of upward read speed, so grains read at any step
|
|
4
|
+
* stay alias-free with a 4-point Hermite read.
|
|
5
|
+
*
|
|
6
|
+
* Level `L` holds the source low-passed so that reading it at a step of up
|
|
7
|
+
* to 2^(L/12) puts the stopband edge at or below the output Nyquist (a
|
|
8
|
+
* Kaiser-windowed sinc, about -70 dB stopband, transition scaled with the
|
|
9
|
+
* step so the output passband reaches about 0.43 of the output rate at
|
|
10
|
+
* every level). Level 0 is the source itself (steps <= 1 never alias).
|
|
11
|
+
* Every level keeps the source's frame rate, so a voice that changes its
|
|
12
|
+
* step (bend, vibrato, glide) re-picks the level per 32-frame control
|
|
13
|
+
* block without moving its read position.
|
|
14
|
+
*
|
|
15
|
+
* This replaces the prototype's octave mipmap (`proto/granular/dsp.ts`,
|
|
16
|
+
* `buildMipmap`) after the 0.6 review: octave levels over-filter
|
|
17
|
+
* non-octave intervals (a shimmer fifth lost up to 11 semitones of top
|
|
18
|
+
* band) and full-length Float64 levels had no memory bound. Here a level
|
|
19
|
+
* is a sparse map of Float32 chunks filled on first read, and all chunks of
|
|
20
|
+
* all sources share one byte budget (64 MB) evicted least recently used;
|
|
21
|
+
* an eviction only costs a recompute. Output is deterministic: a chunk's
|
|
22
|
+
* contents depend only on the source, the level and the chunk index.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Frames per chunk (a power of two). */
|
|
26
|
+
export const BANK_CHUNK_FRAMES = 4096;
|
|
27
|
+
const CHUNK_SHIFT = 12;
|
|
28
|
+
/** Chunks carry one frame before and two after for the Hermite read. */
|
|
29
|
+
export const BANK_CHUNK_PAD = 3;
|
|
30
|
+
/** Highest level (five octaves up); faster reads use it and may alias. */
|
|
31
|
+
export const BANK_MAX_LEVEL = 60;
|
|
32
|
+
/** Byte budget shared by every source's chunks. */
|
|
33
|
+
export const BANK_MAX_BYTES = 64 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
const STOPBAND_DB = 70;
|
|
36
|
+
const KAISER_BETA = 0.1102 * (STOPBAND_DB - 8.7);
|
|
37
|
+
/** Taps at step 1 (scaled up with the step, capped). */
|
|
38
|
+
const BASE_TAPS = 96;
|
|
39
|
+
const MAX_TAPS = 511;
|
|
40
|
+
|
|
41
|
+
/** A mono source registered with the bank. */
|
|
42
|
+
export type BankSource = Readonly<{
|
|
43
|
+
/** Content id: a sample's sha256 or a built-in render key. */
|
|
44
|
+
id: string;
|
|
45
|
+
rate: number;
|
|
46
|
+
data: Float32Array;
|
|
47
|
+
}>;
|
|
48
|
+
|
|
49
|
+
type Chunk = Float32Array;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Chunks per source (`id` and rate) keyed by level and index as one number.
|
|
53
|
+
* Grains look a chunk up every 32-frame block, so the lookup avoids string
|
|
54
|
+
* keys and the LRU order is a use counter (scanned only to evict).
|
|
55
|
+
*/
|
|
56
|
+
type Entry = { chunk: Chunk; used: number };
|
|
57
|
+
const chunks = new Map<string, Map<number, Entry>>();
|
|
58
|
+
const sourceTables = new WeakMap<BankSource, Map<number, Entry>>();
|
|
59
|
+
let count = 0;
|
|
60
|
+
let clock = 0;
|
|
61
|
+
let bytes = 0;
|
|
62
|
+
let byteCap = BANK_MAX_BYTES;
|
|
63
|
+
const ZERO_CHUNK = new Float32Array(BANK_CHUNK_FRAMES + BANK_CHUNK_PAD);
|
|
64
|
+
const kernels = new Map<number, Float64Array>();
|
|
65
|
+
|
|
66
|
+
/** The level for a read step (frames of source per output frame). */
|
|
67
|
+
export function bankLevel(step: number): number {
|
|
68
|
+
const r = step < 0 ? -step : step;
|
|
69
|
+
if (r <= 1 + 1e-9) return 0;
|
|
70
|
+
const level = Math.ceil(12 * Math.log2(r) - 1e-6);
|
|
71
|
+
return level > BANK_MAX_LEVEL ? BANK_MAX_LEVEL : level;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function besselI0(x: number): number {
|
|
75
|
+
let sum = 1;
|
|
76
|
+
let term = 1;
|
|
77
|
+
const q = (x * x) / 4;
|
|
78
|
+
for (let k = 1; k < 64; k += 1) {
|
|
79
|
+
term *= q / (k * k);
|
|
80
|
+
sum += term;
|
|
81
|
+
if (term < sum * 1e-17) break;
|
|
82
|
+
}
|
|
83
|
+
return sum;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The low-pass kernel of a level (odd length, unity DC gain). */
|
|
87
|
+
export function bankKernel(level: number): Float64Array {
|
|
88
|
+
let h = kernels.get(level);
|
|
89
|
+
if (h) return h;
|
|
90
|
+
const r = 2 ** (level / 12);
|
|
91
|
+
let taps = Math.min(MAX_TAPS, Math.ceil(BASE_TAPS * r));
|
|
92
|
+
if (taps % 2 === 0) taps += 1;
|
|
93
|
+
// Kaiser transition width for the tap count, centred inside the band.
|
|
94
|
+
const transition = (STOPBAND_DB - 8) / (2.285 * 2 * Math.PI * (taps - 1));
|
|
95
|
+
const fc = Math.max(0.01, 0.5 / r - transition / 2);
|
|
96
|
+
h = new Float64Array(taps);
|
|
97
|
+
const mid = (taps - 1) / 2;
|
|
98
|
+
const norm = besselI0(KAISER_BETA);
|
|
99
|
+
let sum = 0;
|
|
100
|
+
for (let i = 0; i < taps; i += 1) {
|
|
101
|
+
const m = i - mid;
|
|
102
|
+
const sinc =
|
|
103
|
+
m === 0 ? 2 * fc : Math.sin(2 * Math.PI * fc * m) / (Math.PI * m);
|
|
104
|
+
const ratio = m / mid;
|
|
105
|
+
const w = besselI0(KAISER_BETA * Math.sqrt(1 - ratio * ratio)) / norm;
|
|
106
|
+
h[i] = sinc * w;
|
|
107
|
+
sum += h[i]!;
|
|
108
|
+
}
|
|
109
|
+
for (let i = 0; i < taps; i += 1) h[i] = h[i]! / sum;
|
|
110
|
+
kernels.set(level, h);
|
|
111
|
+
return h;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function fill(source: BankSource, level: number, index: number): Chunk {
|
|
115
|
+
const out = new Float32Array(BANK_CHUNK_FRAMES + BANK_CHUNK_PAD);
|
|
116
|
+
const data = source.data;
|
|
117
|
+
const n = data.length;
|
|
118
|
+
const first = index * BANK_CHUNK_FRAMES - 1;
|
|
119
|
+
if (level === 0) {
|
|
120
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
121
|
+
const k = first + i;
|
|
122
|
+
out[i] = k >= 0 && k < n ? data[k]! : 0;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
const h = bankKernel(level);
|
|
127
|
+
const taps = h.length;
|
|
128
|
+
const mid = (taps - 1) >> 1;
|
|
129
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
130
|
+
const centre = first + i;
|
|
131
|
+
const lo = centre - mid;
|
|
132
|
+
let from = 0;
|
|
133
|
+
let to = taps;
|
|
134
|
+
if (lo < 0) from = -lo;
|
|
135
|
+
if (lo + to > n) to = n - lo;
|
|
136
|
+
let acc = 0;
|
|
137
|
+
for (let j = from; j < to; j += 1) acc += h[j]! * data[lo + j]!;
|
|
138
|
+
out[i] = acc;
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The chunk holding frames `index * 4096 - 1 .. (index + 1) * 4096 + 2` of
|
|
145
|
+
* `level`; a frame `i` of the level is `chunk[(i & 4095) + 1]`. Chunks
|
|
146
|
+
* wholly outside the source are a shared zero chunk.
|
|
147
|
+
*/
|
|
148
|
+
export function bankChunk(
|
|
149
|
+
source: BankSource,
|
|
150
|
+
level: number,
|
|
151
|
+
index: number,
|
|
152
|
+
): Chunk {
|
|
153
|
+
if (index < 0 || index * BANK_CHUNK_FRAMES > source.data.length + 2)
|
|
154
|
+
return ZERO_CHUNK;
|
|
155
|
+
let table = sourceTables.get(source);
|
|
156
|
+
if (!table) {
|
|
157
|
+
const id = `${source.id}\u0000${source.rate}`;
|
|
158
|
+
table = chunks.get(id);
|
|
159
|
+
if (!table) chunks.set(id, (table = new Map()));
|
|
160
|
+
sourceTables.set(source, table);
|
|
161
|
+
}
|
|
162
|
+
const key = level * 2 ** 32 + index;
|
|
163
|
+
clock += 1;
|
|
164
|
+
const hit = table.get(key);
|
|
165
|
+
if (hit) {
|
|
166
|
+
hit.used = clock;
|
|
167
|
+
return hit.chunk;
|
|
168
|
+
}
|
|
169
|
+
const chunk = fill(source, level, index);
|
|
170
|
+
table.set(key, { chunk, used: clock });
|
|
171
|
+
count += 1;
|
|
172
|
+
bytes += chunk.byteLength;
|
|
173
|
+
if (bytes > byteCap) evict();
|
|
174
|
+
return chunk;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Drops least recently used chunks until under the budget (keeps one). */
|
|
178
|
+
function evict(): void {
|
|
179
|
+
const all: { table: Map<number, Entry>; key: number; used: number }[] = [];
|
|
180
|
+
for (const table of chunks.values())
|
|
181
|
+
for (const [key, entry] of table)
|
|
182
|
+
all.push({ table, key, used: entry.used });
|
|
183
|
+
all.sort((a, b) => a.used - b.used);
|
|
184
|
+
for (const { table, key } of all) {
|
|
185
|
+
if (bytes <= byteCap || count <= 1) break;
|
|
186
|
+
bytes -= table.get(key)!.chunk.byteLength;
|
|
187
|
+
table.delete(key);
|
|
188
|
+
count -= 1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Chunk index of a level frame. */
|
|
193
|
+
export function chunkIndex(frame: number): number {
|
|
194
|
+
return frame >> CHUNK_SHIFT;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Hermite read of `level` at a fractional frame (slow path, tests). */
|
|
198
|
+
export function bankRead(
|
|
199
|
+
source: BankSource,
|
|
200
|
+
level: number,
|
|
201
|
+
pos: number,
|
|
202
|
+
): number {
|
|
203
|
+
const i = Math.floor(pos);
|
|
204
|
+
const t = pos - i;
|
|
205
|
+
const chunk = bankChunk(source, level, i >> CHUNK_SHIFT);
|
|
206
|
+
const o = (i & (BANK_CHUNK_FRAMES - 1)) + 1;
|
|
207
|
+
const xm1 = chunk[o - 1]!;
|
|
208
|
+
const x0 = chunk[o]!;
|
|
209
|
+
const x1 = chunk[o + 1]!;
|
|
210
|
+
const x2 = chunk[o + 2]!;
|
|
211
|
+
const c1 = 0.5 * (x1 - xm1);
|
|
212
|
+
const c2 = xm1 - 2.5 * x0 + 2 * x1 - 0.5 * x2;
|
|
213
|
+
const c3 = 0.5 * (x2 - xm1) + 1.5 * (x0 - x1);
|
|
214
|
+
return ((c3 * t + c2) * t + c1) * t + x0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Bytes held by chunks now. */
|
|
218
|
+
export function bankBytes(): number {
|
|
219
|
+
return bytes;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Chunks held now. */
|
|
223
|
+
export function bankChunks(): number {
|
|
224
|
+
return count;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Drops every chunk; with `cap`, sets a new byte budget (tests). */
|
|
228
|
+
export function resetBank(cap: number = BANK_MAX_BYTES): void {
|
|
229
|
+
for (const table of chunks.values()) table.clear();
|
|
230
|
+
count = 0;
|
|
231
|
+
bytes = 0;
|
|
232
|
+
byteCap = Math.max(0, cap);
|
|
233
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cepstral spectral envelope (0.6 B2): the smooth outline of a magnitude
|
|
3
|
+
* spectrum, used by `shift.ts` to hold formants still while the pitch moves.
|
|
4
|
+
* Log magnitude, inverse transform to the real cepstrum, keep the
|
|
5
|
+
* quefrencies below the lifter, forward transform, exponentiate
|
|
6
|
+
* (Oppenheim and Schafer, *Discrete-Time Signal Processing*, ch. 13;
|
|
7
|
+
* J. O. Smith, *Spectral Audio Signal Processing*, "Cepstral Windowing";
|
|
8
|
+
* Röbel and Rodet, "Efficient spectral envelope estimation and its
|
|
9
|
+
* application to pitch shifting and envelope preservation", DAFx 2005, for
|
|
10
|
+
* the true-envelope iteration).
|
|
11
|
+
*/
|
|
12
|
+
import { fftInPlace } from "./fft.ts";
|
|
13
|
+
import { inverse } from "./stft.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default lifter: quefrencies below 2 ms (measured on a synthetic vowel:
|
|
17
|
+
* with 16 true-envelope passes F1 stays within 3% after +-7 and +-12 st at
|
|
18
|
+
* 110 Hz; 1 ms smeared F1 by up to 15%).
|
|
19
|
+
*/
|
|
20
|
+
export const ENVELOPE_LIFTER_SECONDS = 0.002;
|
|
21
|
+
/** True-envelope passes used by `pitchShift` when keeping formants. */
|
|
22
|
+
export const ENVELOPE_ITERATIONS = 16;
|
|
23
|
+
|
|
24
|
+
/** Adaptive lifter as a fraction of the cepstral period. */
|
|
25
|
+
export const PERIOD_FRACTION = 0.25;
|
|
26
|
+
|
|
27
|
+
/** Scratch buffers for one frame size, reused across frames. */
|
|
28
|
+
export type EnvelopeScratch = Readonly<{
|
|
29
|
+
re: Float64Array;
|
|
30
|
+
im: Float64Array;
|
|
31
|
+
log: Float64Array;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
export function envelopeScratch(n: number): EnvelopeScratch {
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
re: new Float64Array(n),
|
|
37
|
+
im: new Float64Array(n),
|
|
38
|
+
log: new Float64Array(n / 2 + 1),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Writes the cepstral envelope of `mag` (bins 0..n/2 of an n-point
|
|
44
|
+
* spectrum) into `out` (length n/2 + 1). `lifter` is in samples (at least
|
|
45
|
+
* 4). With `iterations` > 0 it is the true envelope (Röbel and Rodet 2005):
|
|
46
|
+
* the log spectrum is raised to the smoothed curve wherever it lies below
|
|
47
|
+
* it and smoothed again, so the envelope rides the harmonic peaks instead
|
|
48
|
+
* of the valleys between them. With `periodRange` (samples) the lifter
|
|
49
|
+
* shrinks to a quarter of the frame's cepstral pitch period when that is
|
|
50
|
+
* shorter, so higher voices keep their harmonics out of the envelope.
|
|
51
|
+
*/
|
|
52
|
+
export function cepstralEnvelope(
|
|
53
|
+
mag: Float64Array,
|
|
54
|
+
n: number,
|
|
55
|
+
lifter: number,
|
|
56
|
+
out: Float64Array,
|
|
57
|
+
scratch: EnvelopeScratch = envelopeScratch(n),
|
|
58
|
+
iterations = 0,
|
|
59
|
+
periodRange?: readonly [number, number],
|
|
60
|
+
): Float64Array {
|
|
61
|
+
const half = n / 2;
|
|
62
|
+
const { re, im, log } = scratch;
|
|
63
|
+
let peak = 0;
|
|
64
|
+
for (let k = 0; k <= half; k += 1) peak = Math.max(peak, mag[k]!);
|
|
65
|
+
// Floor 120 dB under the frame's peak (silence stays finite).
|
|
66
|
+
const floor = peak * 1e-6 + 1e-12;
|
|
67
|
+
for (let k = 0; k <= half; k += 1) log[k] = Math.log(mag[k]! + floor);
|
|
68
|
+
let cut = Math.max(4, Math.min(half, Math.round(lifter)));
|
|
69
|
+
for (let pass = 0; pass <= iterations; pass += 1) {
|
|
70
|
+
for (let k = 0; k < n; k += 1) {
|
|
71
|
+
re[k] = log[k <= half ? k : n - k]!;
|
|
72
|
+
im[k] = 0;
|
|
73
|
+
}
|
|
74
|
+
inverse(re, im);
|
|
75
|
+
if (pass === 0 && periodRange) {
|
|
76
|
+
// Pitch-adaptive lifter: a quarter of the period found as the
|
|
77
|
+
// strongest cepstral peak in range, never longer than `lifter`.
|
|
78
|
+
const [lo, hi] = periodRange;
|
|
79
|
+
let q = Math.max(2, Math.round(lo));
|
|
80
|
+
for (let i = q; i <= Math.min(half, Math.round(hi)); i += 1)
|
|
81
|
+
if (re[i]! > re[q]!) q = i;
|
|
82
|
+
cut = Math.max(4, Math.min(cut, Math.round(q * PERIOD_FRACTION)));
|
|
83
|
+
}
|
|
84
|
+
for (let q = cut; q <= n - cut; q += 1) {
|
|
85
|
+
re[q] = 0;
|
|
86
|
+
im[q] = 0;
|
|
87
|
+
}
|
|
88
|
+
fftInPlace(re, im);
|
|
89
|
+
if (pass < iterations)
|
|
90
|
+
for (let k = 0; k <= half; k += 1) log[k] = Math.max(log[k]!, re[k]!);
|
|
91
|
+
}
|
|
92
|
+
for (let k = 0; k <= half; k += 1) out[k] = Math.exp(re[k]!);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Harmonic-peak envelope (0.6.1): log magnitude interpolated linearly
|
|
98
|
+
* between the spectral peaks (each refined by a parabola to its true bin
|
|
99
|
+
* and height) of `mag` that stand within `range` of the
|
|
100
|
+
* frame's loudest, held flat past the first and last. Between harmonics it
|
|
101
|
+
* follows the line joining them instead of the smoothed valley, so it stays
|
|
102
|
+
* on the formant at any pitch where the cepstral envelope (whose lifter
|
|
103
|
+
* must shrink with the period) starts to smear or ripple. `smooth` (an
|
|
104
|
+
* envelope from `cepstralEnvelope`) is used where no peak stands.
|
|
105
|
+
* Measured with `formant 0` on a synthetic vowel: F1 within 1.1% of the
|
|
106
|
+
* same vowel sung at the new pitch for f0 110-330 Hz at +-7 and +12 st
|
|
107
|
+
* (the cepstral envelope alone: -29% to +46% above 200 Hz).
|
|
108
|
+
*/
|
|
109
|
+
export function peakEnvelope(
|
|
110
|
+
mag: Float64Array,
|
|
111
|
+
half: number,
|
|
112
|
+
out: Float64Array,
|
|
113
|
+
smooth: Float64Array,
|
|
114
|
+
peaks: number[],
|
|
115
|
+
range = 1e-4,
|
|
116
|
+
): Float64Array {
|
|
117
|
+
let top = 0;
|
|
118
|
+
for (let k = 0; k <= half; k += 1) top = Math.max(top, mag[k]!);
|
|
119
|
+
peaks.length = 0;
|
|
120
|
+
const floor = top * range;
|
|
121
|
+
for (let k = 2; k <= half - 2; k += 1) {
|
|
122
|
+
const v = mag[k]!;
|
|
123
|
+
if (
|
|
124
|
+
v > floor &&
|
|
125
|
+
v > mag[k - 1]! &&
|
|
126
|
+
v >= mag[k + 1]! &&
|
|
127
|
+
v > mag[k - 2]! &&
|
|
128
|
+
v >= mag[k + 2]!
|
|
129
|
+
)
|
|
130
|
+
peaks.push(k);
|
|
131
|
+
}
|
|
132
|
+
if (peaks.length < 2) {
|
|
133
|
+
out.set(smooth.subarray(0, half + 1));
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
// Each peak refined by a parabola on log magnitude: its true frequency
|
|
137
|
+
// (fractional bin) and height.
|
|
138
|
+
const at = (i: number) => {
|
|
139
|
+
const k = peaks[i]!;
|
|
140
|
+
const a = Math.log(mag[k - 1]! + 1e-30);
|
|
141
|
+
const b = Math.log(mag[k]! + 1e-30);
|
|
142
|
+
const c = Math.log(mag[k + 1]! + 1e-30);
|
|
143
|
+
const d = a - 2 * b + c;
|
|
144
|
+
const delta =
|
|
145
|
+
d < 0 ? Math.max(-0.5, Math.min(0.5, (0.5 * (a - c)) / d)) : 0;
|
|
146
|
+
return [k + delta, b - 0.25 * (a - c) * delta] as const;
|
|
147
|
+
};
|
|
148
|
+
let j = 0;
|
|
149
|
+
let [x0, y0] = at(0);
|
|
150
|
+
let [x1, y1] = at(1);
|
|
151
|
+
for (let k = 0; k <= half; k += 1) {
|
|
152
|
+
while (j + 2 < peaks.length && x1 <= k) {
|
|
153
|
+
j += 1;
|
|
154
|
+
[x0, y0] = [x1, y1];
|
|
155
|
+
[x1, y1] = at(j + 1);
|
|
156
|
+
}
|
|
157
|
+
const t = Math.max(0, Math.min(1, (k - x0) / (x1 - x0)));
|
|
158
|
+
out[k] = Math.exp(y0 + (y1 - y0) * t);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared radix-2 FFT: a re-export of the convolution reverb's
|
|
3
|
+
* `fftInPlace` (cached tables; `inverse` uses e^{+i} and no scaling).
|
|
4
|
+
* Lanes that need windows or STFTs build them on this one transform.
|
|
5
|
+
*/
|
|
6
|
+
export { fftInPlace } from "../effects/convolution.ts";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small per-sample filters for 0.6 instruments. `Biquad` is the existing
|
|
3
|
+
* RBJ biquad (src/audio/effects/common.ts), re-exported unchanged.
|
|
4
|
+
*/
|
|
5
|
+
export { Biquad, type FilterType } from "../effects/common.ts";
|
|
6
|
+
|
|
7
|
+
function clamp(x: number, lo: number, hi: number): number {
|
|
8
|
+
return x < lo ? lo : x > hi ? hi : x;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** One-pole lowpass y += (1 - p)(x - y), corner `hz`. */
|
|
12
|
+
export class OnePole {
|
|
13
|
+
p = 0;
|
|
14
|
+
y = 0;
|
|
15
|
+
|
|
16
|
+
constructor(hz: number, sampleRate: number) {
|
|
17
|
+
this.set(hz, sampleRate);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
set(hz: number, sampleRate: number): void {
|
|
21
|
+
this.p = Math.exp(
|
|
22
|
+
(-2 * Math.PI * clamp(hz, 1, sampleRate * 0.49)) / sampleRate,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
process(x: number): number {
|
|
27
|
+
this.y = x + this.p * (this.y - x);
|
|
28
|
+
return this.y;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
reset(): void {
|
|
32
|
+
this.y = 0;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** DC blocker y = x - x1 + R y1 (R ≈ 0.996 at 22.05 kHz for a 15 Hz corner). */
|
|
37
|
+
export class DcBlock {
|
|
38
|
+
r: number;
|
|
39
|
+
x1 = 0;
|
|
40
|
+
y1 = 0;
|
|
41
|
+
|
|
42
|
+
constructor(sampleRate: number, hz = 15) {
|
|
43
|
+
this.r = Math.exp((-2 * Math.PI * hz) / sampleRate);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
process(x: number): number {
|
|
47
|
+
const y = x - this.x1 + this.r * this.y1;
|
|
48
|
+
this.x1 = x;
|
|
49
|
+
this.y1 = y;
|
|
50
|
+
return y;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
reset(): void {
|
|
54
|
+
this.x1 = 0;
|
|
55
|
+
this.y1 = 0;
|
|
56
|
+
}
|
|
57
|
+
}
|