@hraness/dawg 0.6.0 → 0.7.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 +126 -0
- package/DAWG.md +655 -141
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +759 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -99
- package/core/expression.ts +158 -6
- package/core/fx.ts +354 -32
- package/core/granular.ts +91 -0
- package/core/instruments.ts +180 -0
- package/core/keys.ts +573 -4
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +306 -4
- package/core/routing.ts +165 -0
- package/core/score.ts +1181 -16
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +354 -6
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +3160 -69
- package/core/sections.ts +298 -24
- package/core/sing.ts +815 -0
- package/core/strings.ts +237 -2
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/core/winds.ts +652 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +30 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -6
- package/guides/chords.md +16 -14
- package/guides/effects.md +17 -15
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +12 -6
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/performance.md +15 -10
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +32 -0
- package/guides/rhythm.md +17 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -12
- package/guides/styles.md +31 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +5 -2
- package/src/agent/agent.ts +131 -13
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +369 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +15 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +13 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +5 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +307 -37
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +16 -6
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +58 -3
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/doctor.ts +86 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +171 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/effects/chain.ts +21 -4
- package/src/audio/effects/common.ts +57 -0
- package/src/audio/effects/convolution.ts +113 -5
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/engine.ts +206 -33
- package/src/audio/fit.ts +80 -3
- package/src/audio/granular.ts +352 -21
- package/src/audio/instrument-check.ts +67 -47
- package/src/audio/instruments.ts +36 -3
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +427 -0
- package/src/audio/keys/engine.ts +156 -20
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +72 -8
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/kits.ts +135 -6
- package/src/audio/live-worker.ts +3 -1
- package/src/audio/live.ts +247 -26
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +45 -3
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +286 -0
- package/src/audio/resonators.ts +10 -2
- package/src/audio/sampler.ts +252 -24
- package/src/audio/samples.ts +46 -3
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/body.ts +29 -16
- package/src/audio/strings/bow.ts +699 -0
- package/src/audio/strings/engine.ts +274 -22
- package/src/audio/strings/measure.test-helpers.ts +2 -0
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +608 -85
- package/src/audio/winds/engine.ts +306 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +104 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +492 -0
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +44 -29
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +104 -23
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +105 -33
- package/src/commands/grammar.ts +558 -0
- package/src/commands/granular.ts +7 -2
- package/src/commands/help.ts +652 -339
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +293 -17
- package/src/commands/modal.ts +67 -4
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +130 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +8 -25
- package/src/commands/sample.ts +49 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/sing.ts +478 -0
- package/src/commands/string.ts +28 -2
- package/src/commands/strum.ts +485 -0
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +616 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +244 -0
- package/src/fs/durable.ts +50 -0
- package/src/lang/glossary.ts +493 -0
- package/src/launch-args.ts +163 -0
- package/src/main.ts +1521 -255
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +26 -3
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +36 -1
- package/src/session/daemon.ts +3 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +154 -0
- package/src/session/port.ts +22 -4
- package/src/session/presence.ts +50 -7
- package/src/session/protocol.ts +5 -1
- package/src/session/rebase.ts +18 -5
- package/src/session/receipt.ts +258 -0
- package/src/session/store.ts +65 -43
- package/src/tui/arrange-menu.ts +65 -31
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +18 -13
- package/src/tui/fader.ts +228 -41
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +1377 -193
- package/src/tui/modal-menu.ts +38 -11
- package/src/tui/performance-menu.ts +46 -1
- package/src/tui/play-chords.ts +108 -5
- package/src/tui/play-mode.ts +16 -1
- package/src/tui/play-session.ts +81 -16
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +144 -0
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +274 -40
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +144 -0
- package/tui/drawer.ts +70 -22
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +112 -63
- package/tui/guide.ts +42 -4
- package/tui/highway.ts +269 -25
- package/tui/hints.ts +192 -0
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/play-strip.ts +68 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +144 -14
- package/tui/theme.ts +27 -0
package/src/audio/granular.ts
CHANGED
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
* Randomness is counter-based (`unit(seed, grain, slot)`), so changing one
|
|
14
14
|
* parameter does not reshuffle the others' draws.
|
|
15
15
|
*/
|
|
16
|
-
import
|
|
16
|
+
import { parseKey, scaleOf } from "../../core/chords.ts";
|
|
17
|
+
import { pedalStateAt, type PerformedNote } from "../../core/expression.ts";
|
|
17
18
|
import {
|
|
18
19
|
DEFAULT_GRANULAR_SOURCE,
|
|
20
|
+
GRAIN_SYNC_BEATS,
|
|
21
|
+
GRANULAR_LANE_PARAMS,
|
|
19
22
|
GRANULAR_SOURCE_VOICE,
|
|
20
23
|
SYNTH_SOURCE_SECONDS,
|
|
21
24
|
granularTailSeconds,
|
|
@@ -23,7 +26,8 @@ import {
|
|
|
23
26
|
resolveGranular,
|
|
24
27
|
type GranularSettings,
|
|
25
28
|
} from "../../core/granular.ts";
|
|
26
|
-
import type {
|
|
29
|
+
import type { FxLane } from "../../core/fx.ts";
|
|
30
|
+
import type { SampleRef, Track, TrackScore } from "../../core/score.ts";
|
|
27
31
|
import { SYNTH_PRESETS, type TrackSynth } from "../../core/synth.ts";
|
|
28
32
|
import { noteHz } from "../../core/tuning.ts";
|
|
29
33
|
import {
|
|
@@ -44,6 +48,8 @@ import { warpedSpan } from "./warp.ts";
|
|
|
44
48
|
export const CONTROL_FRAMES = 32;
|
|
45
49
|
/** Grains sounding at once per voice; later grains are skipped by index. */
|
|
46
50
|
export const MAX_GRAINS = 64;
|
|
51
|
+
/** Most grains overlapping at once (synced or not). */
|
|
52
|
+
const MAX_OVERLAP = 32;
|
|
47
53
|
/**
|
|
48
54
|
* Voices per track; the oldest released voice is stolen first, then the
|
|
49
55
|
* oldest held one, with a 5 ms fade.
|
|
@@ -77,6 +83,19 @@ export type GranularVoiceInit = Readonly<{
|
|
|
77
83
|
/** Half pedal: the level fades from `from` seconds with `tau`. */
|
|
78
84
|
damp?: Readonly<{ from: number; tau: number }>;
|
|
79
85
|
seed: number;
|
|
86
|
+
/**
|
|
87
|
+
* `grain-<param>` lanes (0.6.1): each value at a voice frame, read every
|
|
88
|
+
* 32 frames; grain-scoped ones latch at each grain's onset.
|
|
89
|
+
*/
|
|
90
|
+
lanes?: readonly Readonly<{ name: string; at: (frame: number) => number }>[];
|
|
91
|
+
/** `sync` (0.6.1): the voice frame of grid point `k` on the tempo map. */
|
|
92
|
+
grid?: (k: number) => number;
|
|
93
|
+
/** The synced period at grain `k` (default `grid(k + 1) - grid(k)`). */
|
|
94
|
+
gridPeriod?: (k: number) => number;
|
|
95
|
+
/** `quant` (0.6.1): a grain's semitone offset snapped to allowed pitches. */
|
|
96
|
+
quant?: (semis: number, frame: number) => number;
|
|
97
|
+
/** `pedal` (0.6.1): true while the sustain pedal holds the head. */
|
|
98
|
+
held?: (frame: number) => boolean;
|
|
80
99
|
}>;
|
|
81
100
|
|
|
82
101
|
export type GranularVoice = {
|
|
@@ -92,7 +111,12 @@ export type GranularVoice = {
|
|
|
92
111
|
|
|
93
112
|
/** A streaming grain voice. */
|
|
94
113
|
export function granularVoice(init: GranularVoiceInit): GranularVoice {
|
|
95
|
-
const { source, sr,
|
|
114
|
+
const { source, sr, seed } = init;
|
|
115
|
+
const lanes = init.lanes ?? [];
|
|
116
|
+
// Automated rows read a mutable copy; without lanes `p` is the settings.
|
|
117
|
+
const p: { -readonly [K in keyof GranularSettings]: GranularSettings[K] } =
|
|
118
|
+
lanes.length > 0 ? { ...init.settings } : init.settings;
|
|
119
|
+
const live = p as unknown as Record<string, number>;
|
|
96
120
|
const data = source.data;
|
|
97
121
|
const srcRate = source.rate;
|
|
98
122
|
const n0 = Math.max(1, data.length);
|
|
@@ -108,22 +132,45 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
|
|
|
108
132
|
const spawnEnd = init.gateFrames + releaseFrames;
|
|
109
133
|
const win = grainWindow(p.window);
|
|
110
134
|
const table = win.table;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
135
|
+
let grainSec = Math.max(0.005, Math.min(2, p.grain));
|
|
136
|
+
let overlap = Math.max(0.05, Math.min(MAX_OVERLAP, p.overlap));
|
|
137
|
+
let period = (grainSec * sr) / overlap;
|
|
138
|
+
let len = Math.max(16, Math.min(maxGrainFrames, Math.round(grainSec * sr)));
|
|
139
|
+
let winGain = Math.min(
|
|
116
140
|
1,
|
|
117
141
|
1 / Math.sqrt(Math.max(1e-9, overlap * win.meanSq)),
|
|
118
142
|
);
|
|
119
143
|
const frameStep = srcRate / sr;
|
|
120
|
-
|
|
144
|
+
let scanStep = p.freeze ? 0 : p.scan * frameStep;
|
|
145
|
+
const grid = init.grid;
|
|
146
|
+
const gridPeriod =
|
|
147
|
+
init.gridPeriod ?? ((at: number) => grid!(at + 1) - grid!(at));
|
|
148
|
+
/** Grain shape from the current grain, overlap and (synced) period. */
|
|
149
|
+
function shape(): void {
|
|
150
|
+
grainSec = Math.max(0.005, Math.min(2, p.grain));
|
|
151
|
+
if (grid) {
|
|
152
|
+
period = Math.max(1, gridPeriod(k));
|
|
153
|
+
overlap = Math.max(0.05, (grainSec * sr) / period);
|
|
154
|
+
// The unsynced cap: a long grain on a fast grid shortens to 32
|
|
155
|
+
// overlapping grains, so the level matches the grains that sound.
|
|
156
|
+
if (overlap > MAX_OVERLAP) {
|
|
157
|
+
overlap = MAX_OVERLAP;
|
|
158
|
+
grainSec = (MAX_OVERLAP * period) / sr;
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
overlap = Math.max(0.05, Math.min(MAX_OVERLAP, p.overlap));
|
|
162
|
+
period = (grainSec * sr) / overlap;
|
|
163
|
+
}
|
|
164
|
+
len = Math.max(16, Math.min(maxGrainFrames, Math.round(grainSec * sr)));
|
|
165
|
+
winGain = Math.min(1, 1 / Math.sqrt(Math.max(1e-9, overlap * win.meanSq)));
|
|
166
|
+
}
|
|
121
167
|
const grains: Grain[] = [];
|
|
122
168
|
const pool: Grain[] = [];
|
|
123
169
|
let t = 0;
|
|
124
170
|
let scanned = 0;
|
|
125
|
-
let nextGrid = 0;
|
|
126
171
|
let k = 0;
|
|
172
|
+
let nextGrid = grid ? grid(0) : 0;
|
|
173
|
+
if (grid) shape();
|
|
127
174
|
let latch = 0;
|
|
128
175
|
let latched = 0;
|
|
129
176
|
let lp = 0;
|
|
@@ -144,9 +191,9 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
|
|
|
144
191
|
const amp = init.velocity * p.gain;
|
|
145
192
|
const blockL = new Float64Array(CONTROL_FRAMES);
|
|
146
193
|
const blockR = new Float64Array(CONTROL_FRAMES);
|
|
147
|
-
const driftDepth = 0.5 * p.drift * region;
|
|
148
194
|
|
|
149
195
|
function driftAt(sec: number): number {
|
|
196
|
+
const driftDepth = 0.5 * p.drift * region;
|
|
150
197
|
if (driftDepth === 0) return 0;
|
|
151
198
|
const x = sec * p.drate;
|
|
152
199
|
const i = Math.floor(x);
|
|
@@ -158,6 +205,7 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
|
|
|
158
205
|
}
|
|
159
206
|
|
|
160
207
|
function spawn(): void {
|
|
208
|
+
if (grid) shape();
|
|
161
209
|
const sec = nextGrid / sr;
|
|
162
210
|
// Beat repeat: a grid step may latch the head for `hold` steps.
|
|
163
211
|
if (latch > 0) latch -= 1;
|
|
@@ -167,10 +215,11 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
|
|
|
167
215
|
}
|
|
168
216
|
const head = latched;
|
|
169
217
|
const onset = nextGrid + p.jitter * unit(seed, k, 0) * period;
|
|
170
|
-
const
|
|
218
|
+
const raw =
|
|
171
219
|
p.pitch +
|
|
172
220
|
p.detune * (unit(seed, k, 2) - 0.5) +
|
|
173
221
|
(unit(seed, k, 5) < p.shimmer ? p.shimint : 0);
|
|
222
|
+
const semis = init.quant ? init.quant(raw, nextGrid) : raw;
|
|
174
223
|
const step0 = init.baseRate * 2 ** (semis / 12) * frameStep;
|
|
175
224
|
let center =
|
|
176
225
|
begin +
|
|
@@ -193,7 +242,7 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
|
|
|
193
242
|
grains.push(g);
|
|
194
243
|
}
|
|
195
244
|
k += 1;
|
|
196
|
-
nextGrid
|
|
245
|
+
nextGrid = grid ? grid(k) : nextGrid + period;
|
|
197
246
|
}
|
|
198
247
|
|
|
199
248
|
function envelope(i: number): number {
|
|
@@ -265,6 +314,7 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
|
|
|
265
314
|
}
|
|
266
315
|
}
|
|
267
316
|
|
|
317
|
+
let pedalDown = false;
|
|
268
318
|
return {
|
|
269
319
|
totalFrames: total,
|
|
270
320
|
process(outL, outR, offset, frames) {
|
|
@@ -276,13 +326,21 @@ export function granularVoice(init: GranularVoiceInit): GranularVoice {
|
|
|
276
326
|
frames - done,
|
|
277
327
|
total - t,
|
|
278
328
|
);
|
|
329
|
+
// Lanes are read on 32-frame control boundaries only, so any block
|
|
330
|
+
// split renders the same samples.
|
|
331
|
+
if (init.held && t % CONTROL_FRAMES === 0) pedalDown = init.held(t);
|
|
332
|
+
if (lanes.length > 0 && t % CONTROL_FRAMES === 0) {
|
|
333
|
+
for (const lane of lanes) live[lane.name] = lane.at(t);
|
|
334
|
+
if (!grid) shape();
|
|
335
|
+
scanStep = p.freeze ? 0 : p.scan * frameStep;
|
|
336
|
+
}
|
|
279
337
|
// Grid points of this block: the grid keeps running through the
|
|
280
338
|
// release, so the release fades a living cloud (spec: release ~ T60).
|
|
281
339
|
while (nextGrid < t + n && t < spawnEnd) spawn();
|
|
282
340
|
const cents = init.cents ? init.cents(t / sr) : 0;
|
|
283
341
|
const centsMul = cents === 0 ? 1 : 2 ** (cents / 1200);
|
|
284
342
|
renderGrains(t, n, centsMul);
|
|
285
|
-
scanned += scanStep * n;
|
|
343
|
+
if (!pedalDown) scanned += scanStep * n;
|
|
286
344
|
for (let i = 0; i < n; i += 1) {
|
|
287
345
|
let l = blockL[i]!;
|
|
288
346
|
let r = blockR[i]!;
|
|
@@ -559,9 +617,9 @@ export function renderGranularTrack(
|
|
|
559
617
|
end: number;
|
|
560
618
|
cut: number;
|
|
561
619
|
};
|
|
562
|
-
const
|
|
620
|
+
const tail = Math.round(granularTailSeconds(track.granular) * sr);
|
|
621
|
+
let plans: Plan[] = notes.map((note, index) => {
|
|
563
622
|
const { start, length } = spanOf(note, context);
|
|
564
|
-
const tail = Math.round(granularTailSeconds(track.granular) * sr);
|
|
565
623
|
return {
|
|
566
624
|
note,
|
|
567
625
|
seed: seeds[index]!,
|
|
@@ -571,6 +629,31 @@ export function renderGranularTrack(
|
|
|
571
629
|
cut: Infinity,
|
|
572
630
|
};
|
|
573
631
|
});
|
|
632
|
+
// Mono (0.6.1): notes that overlap the one before (legato) join its
|
|
633
|
+
// voice, which retargets pitch at each note's start and keeps its grain
|
|
634
|
+
// stream; a detached note cuts the previous voice's tail.
|
|
635
|
+
const chains = new Map<Plan, Plan[]>();
|
|
636
|
+
if (settings.mono && plans.length > 1) {
|
|
637
|
+
const sorted = [...plans].sort((a, b) => a.start - b.start);
|
|
638
|
+
const heads: Plan[] = [];
|
|
639
|
+
let head: Plan | undefined;
|
|
640
|
+
for (const plan of sorted) {
|
|
641
|
+
if (head && plan.start < head.start + head.length) {
|
|
642
|
+
chains.get(head)!.push(plan);
|
|
643
|
+
head.length = Math.max(
|
|
644
|
+
head.length,
|
|
645
|
+
plan.start + plan.length - head.start,
|
|
646
|
+
);
|
|
647
|
+
head.end = head.start + head.length + tail;
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (head && head.end > plan.start) head.cut = plan.start;
|
|
651
|
+
head = plan;
|
|
652
|
+
chains.set(plan, [plan]);
|
|
653
|
+
heads.push(plan);
|
|
654
|
+
}
|
|
655
|
+
plans = heads;
|
|
656
|
+
}
|
|
574
657
|
const order = plans
|
|
575
658
|
.map((_, index) => index)
|
|
576
659
|
.sort((a, b) => plans[a]!.start - plans[b]!.start || a - b);
|
|
@@ -598,11 +681,88 @@ export function renderGranularTrack(
|
|
|
598
681
|
const outR = new Float64Array(block);
|
|
599
682
|
const volume = Math.max(0, Math.min(1, track.volume ?? 1));
|
|
600
683
|
const lane = track.volumeAutomation ?? [];
|
|
684
|
+
const tickOf = (frame: number): number =>
|
|
685
|
+
context.warp
|
|
686
|
+
? context.warp.tick(frame)
|
|
687
|
+
: frame / Math.max(1e-9, context.samplesPerTick);
|
|
688
|
+
const frameOf = (tick: number): number =>
|
|
689
|
+
context.warp ? context.warp.sample(tick) : tick * context.samplesPerTick;
|
|
690
|
+
const grainLanes = GRANULAR_LANE_PARAMS.flatMap(({ param }) => {
|
|
691
|
+
const points = track.fxAutomation?.[`grain-${param}` as FxLane];
|
|
692
|
+
return points && points.length > 0 ? [{ param, points }] : [];
|
|
693
|
+
});
|
|
694
|
+
const syncBeats =
|
|
695
|
+
settings.sync === "off" ? undefined : GRAIN_SYNC_BEATS[settings.sync];
|
|
696
|
+
const allowed =
|
|
697
|
+
settings.quant === "off"
|
|
698
|
+
? undefined
|
|
699
|
+
: quantPitchClasses(context.score, settings.quant, context.quantChord);
|
|
700
|
+
const pedal = settings.pedal ? track.pedal : undefined;
|
|
601
701
|
for (const plan of plans) {
|
|
602
702
|
const { note } = plan;
|
|
603
703
|
if (plan.start >= left.length) continue;
|
|
604
704
|
const hz = noteHz(note.pitch, note.cents, context.tuning);
|
|
605
705
|
const performance = note.performance;
|
|
706
|
+
const chain = chains.get(plan);
|
|
707
|
+
const startTick = note.startTick;
|
|
708
|
+
const startFrame = plan.start;
|
|
709
|
+
let cents = performance?.cents;
|
|
710
|
+
if (chain && chain.length > 1) {
|
|
711
|
+
// Retarget: the cents of each chained note's pitch over the first's,
|
|
712
|
+
// from its start, plus that note's own bend/glide/vibrato.
|
|
713
|
+
const steps = chain.map((item) => ({
|
|
714
|
+
from: (item.start - startFrame) / sr,
|
|
715
|
+
cents:
|
|
716
|
+
1200 *
|
|
717
|
+
Math.log2(
|
|
718
|
+
noteHz(item.note.pitch, item.note.cents, context.tuning) / hz,
|
|
719
|
+
),
|
|
720
|
+
perf: item.note.performance?.cents,
|
|
721
|
+
offset: (item.start - startFrame) / sr,
|
|
722
|
+
}));
|
|
723
|
+
cents = (t: number) => {
|
|
724
|
+
let at = 0;
|
|
725
|
+
while (at + 1 < steps.length && steps[at + 1]!.from <= t) at += 1;
|
|
726
|
+
const step = steps[at]!;
|
|
727
|
+
return step.cents + (step.perf ? step.perf(t - step.offset) : 0);
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
const lanes = grainLanes.map(({ param, points }) => {
|
|
731
|
+
const fallback = settings[param as keyof typeof settings] as number;
|
|
732
|
+
return {
|
|
733
|
+
name: param,
|
|
734
|
+
at: (frame: number) =>
|
|
735
|
+
interpolateAutomation(points, tickOf(startFrame + frame), fallback),
|
|
736
|
+
};
|
|
737
|
+
});
|
|
738
|
+
let grid: ((k: number) => number) | undefined;
|
|
739
|
+
let gridPeriod: ((k: number) => number) | undefined;
|
|
740
|
+
if (syncBeats !== undefined) {
|
|
741
|
+
const stepTicks = syncBeats * context.ticksPerBeat;
|
|
742
|
+
const at = startTick / stepTicks;
|
|
743
|
+
const k0 = Math.round(at);
|
|
744
|
+
if (Math.abs(at - k0) < 1e-9)
|
|
745
|
+
grid = (k: number) => frameOf((k0 + k) * stepTicks) - startFrame;
|
|
746
|
+
else {
|
|
747
|
+
// Off the grid (humanize, swing, a played note): the first grain
|
|
748
|
+
// sounds at note-on, the rest follow the grid from the next point.
|
|
749
|
+
const first = Math.floor(at) + 1;
|
|
750
|
+
const point = (k: number) =>
|
|
751
|
+
frameOf((first + k) * stepTicks) - startFrame;
|
|
752
|
+
grid = (k: number) => (k === 0 ? 0 : point(k - 1));
|
|
753
|
+
gridPeriod = (k: number) =>
|
|
754
|
+
k === 0 ? point(1) - point(0) : point(k) - point(k - 1);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
const quant = allowed
|
|
758
|
+
? (semis: number, frame: number) =>
|
|
759
|
+
snapSemis(note.pitch, semis, allowed(tickOf(startFrame + frame)))
|
|
760
|
+
: undefined;
|
|
761
|
+
const held =
|
|
762
|
+
pedal && pedal.length > 0
|
|
763
|
+
? (frame: number) =>
|
|
764
|
+
pedalStateAt(pedal, tickOf(startFrame + frame)) === "down"
|
|
765
|
+
: undefined;
|
|
606
766
|
const voice = granularVoice({
|
|
607
767
|
source: resolved.source,
|
|
608
768
|
sr,
|
|
@@ -610,9 +770,14 @@ export function renderGranularTrack(
|
|
|
610
770
|
baseRate: hz / rootHz,
|
|
611
771
|
velocity: Math.max(0, Math.min(1, note.velocity)),
|
|
612
772
|
gateFrames: plan.length,
|
|
613
|
-
...(
|
|
773
|
+
...(cents ? { cents } : {}),
|
|
614
774
|
...(performance?.damp ? { damp: performance.damp } : {}),
|
|
615
775
|
seed: plan.seed,
|
|
776
|
+
...(lanes.length > 0 ? { lanes } : {}),
|
|
777
|
+
...(grid ? { grid } : {}),
|
|
778
|
+
...(gridPeriod ? { gridPeriod } : {}),
|
|
779
|
+
...(quant ? { quant } : {}),
|
|
780
|
+
...(held ? { held } : {}),
|
|
616
781
|
});
|
|
617
782
|
const stop = Math.min(
|
|
618
783
|
left.length,
|
|
@@ -642,6 +807,161 @@ export function renderGranularTrack(
|
|
|
642
807
|
}
|
|
643
808
|
}
|
|
644
809
|
|
|
810
|
+
/**
|
|
811
|
+
* The tracks whose notes make the `quant chord` harmony: pitched and not
|
|
812
|
+
* muted in the song. Granular tracks, kits, one-shot samplers and
|
|
813
|
+
* resampled audio (a ref with `from`, one held note) are left out, so a
|
|
814
|
+
* resampled bus does not add its trigger pitch to every chord. Solo is
|
|
815
|
+
* ignored: soloing a track (or a resample's soloed source) keeps the
|
|
816
|
+
* song's chords.
|
|
817
|
+
*/
|
|
818
|
+
function harmonicTrack(track: Track): boolean {
|
|
819
|
+
if (track.muted === true) return false;
|
|
820
|
+
if (
|
|
821
|
+
track.granular !== undefined ||
|
|
822
|
+
track.instrument === "kit" ||
|
|
823
|
+
track.kit !== undefined
|
|
824
|
+
)
|
|
825
|
+
return false;
|
|
826
|
+
if (track.sampler || track.instrument === "sampler") {
|
|
827
|
+
const sampler = track.sampler;
|
|
828
|
+
if (!sampler || sampler.mode !== "keyed") return false;
|
|
829
|
+
if (Object.values(sampler.voices).some((ref) => ref.from !== undefined))
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Pitch classes sounding on the harmonic tracks, as change points:
|
|
837
|
+
* `sets[i]` sounds from `ticks[i]` to `ticks[i + 1]`.
|
|
838
|
+
*/
|
|
839
|
+
export type ChordTimeline = Readonly<{
|
|
840
|
+
ticks: readonly number[];
|
|
841
|
+
sets: readonly (readonly number[])[];
|
|
842
|
+
}>;
|
|
843
|
+
|
|
844
|
+
const timelines = new WeakMap<TrackScore, ChordTimeline>();
|
|
845
|
+
|
|
846
|
+
/** The song's chord timeline (computed once per score object). */
|
|
847
|
+
export function chordTimeline(score: TrackScore): ChordTimeline {
|
|
848
|
+
const known = timelines.get(score);
|
|
849
|
+
if (known) return known;
|
|
850
|
+
const harmonic = new Set(
|
|
851
|
+
score.tracks.filter(harmonicTrack).map((track) => track.id),
|
|
852
|
+
);
|
|
853
|
+
const events = new Map<number, number[]>();
|
|
854
|
+
const add = (tick: number, pc: number, delta: number) => {
|
|
855
|
+
let row = events.get(tick);
|
|
856
|
+
if (!row) events.set(tick, (row = new Array<number>(12).fill(0)));
|
|
857
|
+
row[pc] = row[pc]! + delta;
|
|
858
|
+
};
|
|
859
|
+
for (const note of score.notes) {
|
|
860
|
+
if (!harmonic.has(note.trackId) || note.durationTicks <= 0) continue;
|
|
861
|
+
const pc = ((note.pitch % 12) + 12) % 12;
|
|
862
|
+
add(note.startTick, pc, 1);
|
|
863
|
+
add(note.startTick + note.durationTicks, pc, -1);
|
|
864
|
+
}
|
|
865
|
+
const counts = new Array<number>(12).fill(0);
|
|
866
|
+
const ticks: number[] = [];
|
|
867
|
+
const sets: number[][] = [];
|
|
868
|
+
for (const tick of [...events.keys()].sort((a, b) => a - b)) {
|
|
869
|
+
const row = events.get(tick)!;
|
|
870
|
+
for (let pc = 0; pc < 12; pc += 1) counts[pc] = counts[pc]! + row[pc]!;
|
|
871
|
+
ticks.push(tick);
|
|
872
|
+
sets.push(counts.flatMap((count, pc) => (count > 0 ? [pc] : [])));
|
|
873
|
+
}
|
|
874
|
+
const timeline = Object.freeze({ ticks, sets });
|
|
875
|
+
timelines.set(score, timeline);
|
|
876
|
+
return timeline;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** The classes sounding at `tick` (empty when nothing sounds). */
|
|
880
|
+
export function chordAt(
|
|
881
|
+
timeline: ChordTimeline,
|
|
882
|
+
tick: number,
|
|
883
|
+
): readonly number[] {
|
|
884
|
+
const { ticks, sets } = timeline;
|
|
885
|
+
let lo = 0;
|
|
886
|
+
let hi = ticks.length - 1;
|
|
887
|
+
let found = -1;
|
|
888
|
+
while (lo <= hi) {
|
|
889
|
+
const mid = (lo + hi) >> 1;
|
|
890
|
+
if (ticks[mid]! <= tick) {
|
|
891
|
+
found = mid;
|
|
892
|
+
lo = mid + 1;
|
|
893
|
+
} else hi = mid - 1;
|
|
894
|
+
}
|
|
895
|
+
return found < 0 ? [] : sets[found]!;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* The chord changes between two ticks, relative to `from`, as a stable
|
|
900
|
+
* string: what a `quant chord` render over that span reads.
|
|
901
|
+
*/
|
|
902
|
+
export function chordDigest(
|
|
903
|
+
timeline: ChordTimeline,
|
|
904
|
+
from = -Infinity,
|
|
905
|
+
to = Infinity,
|
|
906
|
+
): string {
|
|
907
|
+
const parts: string[] = [chordAt(timeline, from).join(".")];
|
|
908
|
+
for (let i = 0; i < timeline.ticks.length; i += 1) {
|
|
909
|
+
const tick = timeline.ticks[i]!;
|
|
910
|
+
if (tick > from && tick < to)
|
|
911
|
+
parts.push(
|
|
912
|
+
`${Number.isFinite(from) ? tick - from : tick}:${timeline.sets[i]!.join(".")}`,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
return `chord:${parts.join(",")}`;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* `quant` (0.6.1): the pitch classes grains may land on at a tick. `scale`
|
|
920
|
+
* is the song key's scale (chromatic without a key); `chord` is the pitch
|
|
921
|
+
* classes sounding on the song's harmonic tracks at the tick (see
|
|
922
|
+
* `harmonicTrack`), falling back to the scale when nothing else sounds
|
|
923
|
+
* (Ableton Granulator and Bitwig's scale-quantised grain pitch work the
|
|
924
|
+
* same way). `chord` reads `source` (the whole song for a live note) at
|
|
925
|
+
* `tick + offset`.
|
|
926
|
+
*/
|
|
927
|
+
export function quantPitchClasses(
|
|
928
|
+
score: TrackScore,
|
|
929
|
+
mode: "scale" | "chord",
|
|
930
|
+
source?: Readonly<{ score: TrackScore; tick: number }>,
|
|
931
|
+
): (tick: number) => readonly number[] {
|
|
932
|
+
const key = parseKey(score.key ?? undefined);
|
|
933
|
+
const scale = key ? scaleOf(key) : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
|
934
|
+
if (mode === "scale") return () => scale;
|
|
935
|
+
const timeline = chordTimeline(source?.score ?? score);
|
|
936
|
+
const offset = source?.tick ?? 0;
|
|
937
|
+
return (tick: number) => {
|
|
938
|
+
const set = chordAt(timeline, tick + offset);
|
|
939
|
+
return set.length > 0 ? set : scale;
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** A grain's semitone offset over `pitch`, snapped to the nearest class. */
|
|
944
|
+
export function snapSemis(
|
|
945
|
+
pitch: number,
|
|
946
|
+
semis: number,
|
|
947
|
+
classes: readonly number[],
|
|
948
|
+
): number {
|
|
949
|
+
const target = pitch + semis;
|
|
950
|
+
const base = Math.round(target);
|
|
951
|
+
let best = base;
|
|
952
|
+
let bestDistance = Infinity;
|
|
953
|
+
for (let d = 0; d <= 6; d += 1)
|
|
954
|
+
for (const candidate of d === 0 ? [base] : [base - d, base + d]) {
|
|
955
|
+
if (!classes.includes(((candidate % 12) + 12) % 12)) continue;
|
|
956
|
+
const distance = Math.abs(candidate - target);
|
|
957
|
+
if (distance < bestDistance) {
|
|
958
|
+
best = candidate;
|
|
959
|
+
bestDistance = distance;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return best - pitch;
|
|
963
|
+
}
|
|
964
|
+
|
|
645
965
|
/** The registered engine (`src/audio/instruments.ts`). */
|
|
646
966
|
export const GRANULAR_ENGINE: InstrumentEngine = Object.freeze({
|
|
647
967
|
id: "granular",
|
|
@@ -655,10 +975,21 @@ export const GRANULAR_ENGINE: InstrumentEngine = Object.freeze({
|
|
|
655
975
|
},
|
|
656
976
|
tailSeconds: (track: Track) => granularTailSeconds(track.granular),
|
|
657
977
|
stereo: () => true,
|
|
658
|
-
assetDigests(
|
|
978
|
+
assetDigests(
|
|
979
|
+
track: Track,
|
|
980
|
+
bank: SampleBank,
|
|
981
|
+
score?: TrackScore,
|
|
982
|
+
): readonly string[] {
|
|
659
983
|
const src = track.granular?.src;
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
984
|
+
const digests: string[] = [];
|
|
985
|
+
if (src !== undefined && typeof src !== "string") {
|
|
986
|
+
const decoded = bank.voices.get(granularSampleKey(track.id));
|
|
987
|
+
digests.push(decoded ? decoded.sha256 : "missing");
|
|
988
|
+
}
|
|
989
|
+
// `quant chord` reads the other tracks' notes: their chords join the
|
|
990
|
+
// stem key, so a cached stem re-renders when the harmony changes.
|
|
991
|
+
if (score && resolveGranular(track.granular).quant === "chord")
|
|
992
|
+
digests.push(chordDigest(chordTimeline(score)));
|
|
993
|
+
return digests;
|
|
663
994
|
},
|
|
664
995
|
});
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* playing a test tone silently.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { isGuideInstrument } from "../../core/clips.ts";
|
|
9
10
|
import { isDrumInstrument } from "../../core/drums.ts";
|
|
10
11
|
import { INSTRUMENT_WORDS, LEGACY_WORDS } from "../../core/instruments.ts";
|
|
11
|
-
import {
|
|
12
|
+
import { KEYS_FAMILIES } from "../../core/keys.ts";
|
|
12
13
|
import { isSamplerInstrument, type Track } from "../../core/score.ts";
|
|
14
|
+
import { nearest } from "../commands/nearest.ts";
|
|
13
15
|
import { engineFor, registeredEngines } from "./instruments.ts";
|
|
14
16
|
import { resolveOscillator } from "./synth/oscillators.ts";
|
|
15
17
|
import { AVAILABLE_INSTRUMENTS } from "./wav.ts";
|
|
@@ -19,7 +21,7 @@ const LEGACY_TONES = ["square", "saw", "triangle", "bass", "piano", "pluck"];
|
|
|
19
21
|
|
|
20
22
|
/** The 0.6 voice to point a plain-sine legacy word at. */
|
|
21
23
|
const LEGACY_ADVICE: Readonly<Record<string, string>> = Object.freeze({
|
|
22
|
-
cello: "synth preset strings for a
|
|
24
|
+
cello: "bowed cello for the bowed cello, synth preset strings for a pad",
|
|
23
25
|
strings: "synth preset strings for a string pad",
|
|
24
26
|
organ: "synth preset organ",
|
|
25
27
|
bell: "modal glock or synth preset bell",
|
|
@@ -34,13 +36,31 @@ function knownWords(): readonly string[] {
|
|
|
34
36
|
...new Set([
|
|
35
37
|
...AVAILABLE_INSTRUMENTS,
|
|
36
38
|
...INSTRUMENT_WORDS.map((row) => row.word),
|
|
37
|
-
...
|
|
39
|
+
...KEYS_FAMILIES,
|
|
38
40
|
...registeredEngines(),
|
|
39
41
|
"sampler",
|
|
40
42
|
]),
|
|
41
43
|
];
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/**
|
|
47
|
+
* `instrument list` rows: every exact instrument word, sorted, wrapped to
|
|
48
|
+
* `width` columns.
|
|
49
|
+
*/
|
|
50
|
+
export function instrumentListLines(width = 72): string[] {
|
|
51
|
+
const words = [...new Set(["sine", ...knownWords()])].sort();
|
|
52
|
+
const lines: string[] = [];
|
|
53
|
+
let line = "";
|
|
54
|
+
for (const word of words) {
|
|
55
|
+
if (line && line.length + 1 + word.length > width) {
|
|
56
|
+
lines.push(line);
|
|
57
|
+
line = word;
|
|
58
|
+
} else line = line ? `${line} ${word}` : word;
|
|
59
|
+
}
|
|
60
|
+
if (line) lines.push(line);
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
/** Whether `instrument` (on `track`) renders as the plain sine fallback. */
|
|
45
65
|
export function playsPlainSine(
|
|
46
66
|
instrument: string,
|
|
@@ -49,11 +69,12 @@ export function playsPlainSine(
|
|
|
49
69
|
const name = instrument.trim().toLowerCase();
|
|
50
70
|
if (name === "" || name.includes("sine")) return false;
|
|
51
71
|
if (track && engineFor(track)) return false;
|
|
52
|
-
// Engine ids and
|
|
72
|
+
// Engine ids and keys families name a 0.6 voice even without its field.
|
|
53
73
|
if (registeredEngines().includes(name)) return false;
|
|
54
|
-
if ((
|
|
74
|
+
if ((KEYS_FAMILIES as readonly string[]).includes(name)) return false;
|
|
55
75
|
if (isDrumInstrument(name) || isSamplerInstrument(name)) return false;
|
|
56
76
|
if (name === "wavetable" || resolveOscillator(name)) return false;
|
|
77
|
+
if (isGuideInstrument(name)) return false;
|
|
57
78
|
return !LEGACY_TONES.some((tone) => name.includes(tone));
|
|
58
79
|
}
|
|
59
80
|
|
|
@@ -73,18 +94,52 @@ export function plainSineAdvice(
|
|
|
73
94
|
const advice = LEGACY_ADVICE[name];
|
|
74
95
|
return `"${name}" is dawg's plain sine (kept for old projects)${advice ? ` · ${advice}` : ""}`;
|
|
75
96
|
}
|
|
76
|
-
|
|
97
|
+
// A resolver word stored raw (an older project): the word itself now
|
|
98
|
+
// picks a voice when typed again.
|
|
99
|
+
if (knownWords().includes(name))
|
|
100
|
+
return `"${name}" is stored as a bare word and plays a plain sine · type instrument ${name} again for its voice`;
|
|
101
|
+
const near = nearest(name, knownWords());
|
|
77
102
|
return `"${name}" is not a dawg instrument and plays a plain sine${near ? ` · did you mean ${near}?` : ""}`;
|
|
78
103
|
}
|
|
79
104
|
|
|
80
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Whether `word` names an instrument exactly: an instrument word, preset,
|
|
107
|
+
* engine, keys family, oscillator, drum, sampler or guide word, or a legacy
|
|
108
|
+
* tone. The write path (`instrument <word>`) refuses anything else, so a typo
|
|
109
|
+
* such as `sawtoth` is never stored. Stored projects are not checked here:
|
|
110
|
+
* the read and render paths keep playing whatever they hold.
|
|
111
|
+
*/
|
|
112
|
+
export function isExactInstrument(word: string): boolean {
|
|
113
|
+
const name = word.trim().toLowerCase();
|
|
114
|
+
if (name === "") return false;
|
|
115
|
+
if (
|
|
116
|
+
name === "sine" ||
|
|
117
|
+
LEGACY_TONES.includes(name) ||
|
|
118
|
+
LEGACY_WORDS.includes(name) ||
|
|
119
|
+
knownWords().includes(name)
|
|
120
|
+
)
|
|
121
|
+
return true;
|
|
122
|
+
if (registeredEngines().includes(name)) return true;
|
|
123
|
+
if ((KEYS_FAMILIES as readonly string[]).includes(name)) return true;
|
|
124
|
+
if (isDrumInstrument(name) || isSamplerInstrument(name)) return true;
|
|
125
|
+
if (isGuideInstrument(name)) return true;
|
|
126
|
+
return name === "wavetable" || resolveOscillator(name) !== undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Whether `word` is not an exact instrument name (see isExactInstrument). */
|
|
81
130
|
export function isUnknownInstrument(word: string): boolean {
|
|
131
|
+
return !isExactInstrument(word);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `✗ instrument sawtoth · did you mean sawtooth? · instrument list`: the
|
|
136
|
+
* refusal for an instrument write that names nothing exactly.
|
|
137
|
+
*/
|
|
138
|
+
export function unknownInstrumentMessage(word: string): string {
|
|
82
139
|
const name = word.trim().toLowerCase();
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
!INSTRUMENT_WORDS.some((row) => row.word === name)
|
|
87
|
-
);
|
|
140
|
+
const near = nearest(name, [...knownWords(), ...LEGACY_TONES, "sine"]);
|
|
141
|
+
const shown = name.length > 32 ? `${name.slice(0, 31)}…` : name;
|
|
142
|
+
return `instrument ${shown}${near ? ` · did you mean ${near}?` : ""} · instrument list`;
|
|
88
143
|
}
|
|
89
144
|
|
|
90
145
|
/** `dawg check` warnings for every track that plays the plain sine. */
|
|
@@ -96,38 +151,3 @@ export function plainSineWarnings(tracks: readonly Track[]): string[] {
|
|
|
96
151
|
}
|
|
97
152
|
return out;
|
|
98
153
|
}
|
|
99
|
-
|
|
100
|
-
/** The closest of `words` to `word` within two edits, if any. */
|
|
101
|
-
export function nearestWord(
|
|
102
|
-
word: string,
|
|
103
|
-
words: readonly string[],
|
|
104
|
-
): string | undefined {
|
|
105
|
-
let best: string | undefined;
|
|
106
|
-
let bestDistance = 3;
|
|
107
|
-
for (const candidate of words) {
|
|
108
|
-
const distance = editDistance(word, candidate);
|
|
109
|
-
if (distance < bestDistance) {
|
|
110
|
-
best = candidate;
|
|
111
|
-
bestDistance = distance;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return best;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function editDistance(a: string, b: string): number {
|
|
118
|
-
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
119
|
-
for (let i = 1; i <= a.length; i += 1) {
|
|
120
|
-
let previous = row[0]!;
|
|
121
|
-
row[0] = i;
|
|
122
|
-
for (let j = 1; j <= b.length; j += 1) {
|
|
123
|
-
const saved = row[j]!;
|
|
124
|
-
row[j] = Math.min(
|
|
125
|
-
row[j]! + 1,
|
|
126
|
-
row[j - 1]! + 1,
|
|
127
|
-
previous + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
128
|
-
);
|
|
129
|
-
previous = saved;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return row[b.length]!;
|
|
133
|
-
}
|