@hraness/dawg 0.6.1 → 0.8.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 +118 -0
- package/DAWG.md +454 -140
- package/README.md +30 -26
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +184 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/ids.ts +52 -0
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +8 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/range.ts +581 -0
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +955 -14
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +287 -5
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +2132 -46
- package/core/sections.ts +480 -35
- package/core/sing.ts +815 -0
- 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/guides/agent.md +29 -0
- package/guides/arrange.md +31 -0
- package/guides/audio.md +31 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- 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 +15 -7
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/panes.md +32 -0
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +26 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +18 -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 -13
- package/guides/styles.md +31 -0
- package/guides/tape.md +32 -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 +6 -2
- package/src/agent/agent.ts +130 -18
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +378 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +27 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +25 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/range-tools.ts +216 -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 +110 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +13 -10
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +49 -5
- package/src/audio/audio-command.ts +218 -0
- 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/devices.ts +264 -0
- package/src/audio/doctor.ts +157 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -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/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +360 -40
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- 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 +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- 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 +82 -32
- 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 +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +560 -0
- package/src/commands/help.ts +774 -366
- package/src/commands/history.ts +139 -10
- package/src/commands/keys.ts +25 -8
- package/src/commands/modal.ts +1 -1
- 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 +135 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +474 -0
- package/src/commands/strum.ts +13 -1
- 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 +617 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +511 -0
- package/src/launch-args.ts +267 -0
- package/src/main.ts +2508 -308
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- 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 +20 -1
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +199 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +174 -0
- package/src/session/port.ts +109 -14
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +365 -7
- package/src/session/rebase.ts +63 -12
- package/src/session/receipt.ts +265 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +126 -45
- package/src/tui/arrange-menu.ts +225 -23
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +113 -27
- package/src/tui/fader.ts +282 -42
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- 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 +987 -179
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +243 -28
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +491 -84
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +193 -0
- package/tui/drawer.ts +233 -27
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +143 -65
- package/tui/guide.ts +50 -5
- package/tui/highway.ts +309 -30
- package/tui/hints.ts +197 -0
- package/tui/hits.ts +7 -1
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +80 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +151 -18
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +46 -1
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/vocal pitch` and `/vocal notes` (0.7, pitch lane): read-only pitch
|
|
3
|
+
* analysis of the focused track's audio clip or sampler voice, and a
|
|
4
|
+
* guide-notes track made from it. Neither adds a Track field: `pitch` only
|
|
5
|
+
* reports, and `notes` adds an ordinary note track.
|
|
6
|
+
*
|
|
7
|
+
* The detected key uses the note durations (Krumhansl-Schmuckler profiles
|
|
8
|
+
* through `core/key.ts`, the same estimator `/media analyze` uses), so a
|
|
9
|
+
* long held tonic weighs more than a passing tone.
|
|
10
|
+
*/
|
|
11
|
+
import { newId } from "../../core/ids.ts";
|
|
12
|
+
import {
|
|
13
|
+
TrackScore,
|
|
14
|
+
SCORE_LIMITS,
|
|
15
|
+
addTrack,
|
|
16
|
+
isSamplerInstrument,
|
|
17
|
+
type AudioClip,
|
|
18
|
+
type NoteInput,
|
|
19
|
+
type Track,
|
|
20
|
+
type TrackInput,
|
|
21
|
+
} from "../../core/score.ts";
|
|
22
|
+
import { clipSongTick } from "../../core/clips.ts";
|
|
23
|
+
import { estimateKey } from "../../core/key.ts";
|
|
24
|
+
import { barTicks } from "../../core/sections.ts";
|
|
25
|
+
import { secondsAtTick, tickAtSeconds } from "../../core/tempo.ts";
|
|
26
|
+
import { pitchCurve } from "../audio/analysis.ts";
|
|
27
|
+
import {
|
|
28
|
+
centsOfHz,
|
|
29
|
+
isPitchVoice,
|
|
30
|
+
frameLevels,
|
|
31
|
+
pitchNotes,
|
|
32
|
+
PITCH_VOICE_NAMES,
|
|
33
|
+
type PitchCurve,
|
|
34
|
+
type PitchNote,
|
|
35
|
+
type PitchVoice,
|
|
36
|
+
} from "../audio/dsp/pitch.ts";
|
|
37
|
+
import {
|
|
38
|
+
SampleLibrary,
|
|
39
|
+
granularSample,
|
|
40
|
+
sampleKey,
|
|
41
|
+
type DecodedSample,
|
|
42
|
+
} from "../audio/samples.ts";
|
|
43
|
+
import type { PitchTracePoint } from "../../tui/highway.ts";
|
|
44
|
+
import { freeTrackId } from "./resample.ts";
|
|
45
|
+
import type { VocalContext, VocalResult, VocalVerb } from "./vocal.ts";
|
|
46
|
+
|
|
47
|
+
/** Something on a track that has audio to analyse. */
|
|
48
|
+
export type PitchTarget = Readonly<{
|
|
49
|
+
/** `clip <id>` or `voice <name>`. */
|
|
50
|
+
label: string;
|
|
51
|
+
kind: "clip" | "voice";
|
|
52
|
+
/** Clip id or sampler voice name. */
|
|
53
|
+
name: string;
|
|
54
|
+
clip?: AudioClip;
|
|
55
|
+
}>;
|
|
56
|
+
|
|
57
|
+
/** Clips first (unmuted ones before muted), then sampler voices. */
|
|
58
|
+
export function pitchTargets(track: Track): PitchTarget[] {
|
|
59
|
+
const out: PitchTarget[] = [];
|
|
60
|
+
const clips = [...(track.clips ?? [])].sort(
|
|
61
|
+
(a, b) => Number(a.mute ?? false) - Number(b.mute ?? false),
|
|
62
|
+
);
|
|
63
|
+
for (const clip of clips)
|
|
64
|
+
out.push({ label: `clip ${clip.id}`, kind: "clip", name: clip.id, clip });
|
|
65
|
+
if (isSamplerInstrument(track.instrument) && track.sampler)
|
|
66
|
+
for (const voice of Object.keys(track.sampler.voices).sort())
|
|
67
|
+
out.push({ label: `voice ${voice}`, kind: "voice", name: voice });
|
|
68
|
+
if (granularSample(track))
|
|
69
|
+
out.push({ label: "granular source", kind: "voice", name: "granular:src" });
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type PitchReport = Readonly<{
|
|
74
|
+
trackId: string;
|
|
75
|
+
target: PitchTarget;
|
|
76
|
+
voice: PitchVoice;
|
|
77
|
+
curve: PitchCurve;
|
|
78
|
+
/** Notes inside the played window, times in file seconds. */
|
|
79
|
+
notes: readonly PitchNote[];
|
|
80
|
+
/** e.g. `A minor`, or null when there are too few notes. */
|
|
81
|
+
key: string | null;
|
|
82
|
+
/** Median, lowest and highest voiced pitch in Hz (0 when silent). */
|
|
83
|
+
median: number;
|
|
84
|
+
low: number;
|
|
85
|
+
high: number;
|
|
86
|
+
/** Voiced share of the analysed window, 0..1. */
|
|
87
|
+
voiced: number;
|
|
88
|
+
/** The analysed window in file seconds. */
|
|
89
|
+
from: number;
|
|
90
|
+
to: number;
|
|
91
|
+
}>;
|
|
92
|
+
|
|
93
|
+
export type AnalyzeOptions = Readonly<{
|
|
94
|
+
/** Only clips, or only sample voices. */
|
|
95
|
+
kind?: "clip" | "voice";
|
|
96
|
+
/** Clip id or voice name (exact, then any case); default the first target. */
|
|
97
|
+
target?: string;
|
|
98
|
+
voice?: PitchVoice;
|
|
99
|
+
library?: SampleLibrary;
|
|
100
|
+
}>;
|
|
101
|
+
|
|
102
|
+
/** A clear failure message the commands print as is. */
|
|
103
|
+
export class PitchTargetError extends Error {}
|
|
104
|
+
|
|
105
|
+
async function decodeTarget(
|
|
106
|
+
score: TrackScore,
|
|
107
|
+
track: Track,
|
|
108
|
+
target: PitchTarget,
|
|
109
|
+
library: SampleLibrary,
|
|
110
|
+
): Promise<DecodedSample> {
|
|
111
|
+
if (target.kind === "clip") return library.decodeFile(target.clip!.src);
|
|
112
|
+
const only = new TrackScore({
|
|
113
|
+
...score.toJSON(),
|
|
114
|
+
tracks: [track],
|
|
115
|
+
notes: [],
|
|
116
|
+
} as never);
|
|
117
|
+
const bank = await library.load(only);
|
|
118
|
+
const sample = bank.voices.get(sampleKey(track.id, target.name));
|
|
119
|
+
if (sample) return sample;
|
|
120
|
+
const problem = bank.problems.find((p) => p.voice === target.name);
|
|
121
|
+
throw new PitchTargetError(
|
|
122
|
+
problem?.message ?? `${target.label} could not be loaded`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function median(values: number[]): number {
|
|
127
|
+
if (values.length === 0) return 0;
|
|
128
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
129
|
+
return sorted[sorted.length >> 1]!;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Key from note durations, pitch class 0 = C. */
|
|
133
|
+
export function keyOfNotes(notes: readonly PitchNote[]): string | null {
|
|
134
|
+
if (notes.length < 3) return null;
|
|
135
|
+
const histogram = new Array<number>(12).fill(0);
|
|
136
|
+
let total = 0;
|
|
137
|
+
for (const note of notes) {
|
|
138
|
+
histogram[((note.midi % 12) + 12) % 12]! += note.end - note.start;
|
|
139
|
+
total += note.end - note.start;
|
|
140
|
+
}
|
|
141
|
+
// A short line's first and last notes are strong tonic cues: they settle
|
|
142
|
+
// relatives (G major against b or e minor) the profile alone leaves close.
|
|
143
|
+
const cue = (note: PitchNote) =>
|
|
144
|
+
(histogram[((note.midi % 12) + 12) % 12]! += total * 0.15);
|
|
145
|
+
// Short blips at a phrase edge (a scoop, a breath) are not cues.
|
|
146
|
+
const held = notes.filter((note) => note.end - note.start >= 0.2);
|
|
147
|
+
if (held.length > 0) {
|
|
148
|
+
cue(held[0]!);
|
|
149
|
+
cue(held[held.length - 1]!);
|
|
150
|
+
}
|
|
151
|
+
return estimateKey(histogram);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Tracks (or reads from the cache) the focused track's audio. */
|
|
155
|
+
export async function analyzeTrackPitch(
|
|
156
|
+
score: TrackScore,
|
|
157
|
+
trackId: string,
|
|
158
|
+
cwd: string,
|
|
159
|
+
options: AnalyzeOptions = {},
|
|
160
|
+
): Promise<PitchReport> {
|
|
161
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
162
|
+
if (!track) throw new PitchTargetError(`no track ${trackId}`);
|
|
163
|
+
const targets = pitchTargets(track);
|
|
164
|
+
if (targets.length === 0)
|
|
165
|
+
throw new PitchTargetError(
|
|
166
|
+
`${trackId} has no audio clip or sample voice · import one with /vocal import <file> or /sample <file>`,
|
|
167
|
+
);
|
|
168
|
+
const pool = options.kind
|
|
169
|
+
? targets.filter((candidate) => candidate.kind === options.kind)
|
|
170
|
+
: targets;
|
|
171
|
+
const wanted = options.target;
|
|
172
|
+
const target = wanted
|
|
173
|
+
? (pool.find((candidate) => candidate.name === wanted) ??
|
|
174
|
+
pool.find(
|
|
175
|
+
(candidate) => candidate.name.toLowerCase() === wanted.toLowerCase(),
|
|
176
|
+
))
|
|
177
|
+
: pool[0];
|
|
178
|
+
if (!target)
|
|
179
|
+
throw new PitchTargetError(
|
|
180
|
+
`${trackId} has no ${options.kind ?? "clip or voice"}${wanted ? ` ${wanted}` : ""} · try ${targets.map((t) => t.label).join(", ")}`,
|
|
181
|
+
);
|
|
182
|
+
const library = options.library ?? new SampleLibrary({ projectRoot: cwd });
|
|
183
|
+
const sample = await decodeTarget(score, track, target, library);
|
|
184
|
+
const voice = options.voice ?? "auto";
|
|
185
|
+
const curve = await pitchCurve(sample, { voice, projectRoot: cwd });
|
|
186
|
+
const length = sample.frames / sample.sampleRate;
|
|
187
|
+
const from = Math.min(length, target.clip?.offset ?? 0);
|
|
188
|
+
const to = Math.min(length, from + (target.clip?.dur ?? length));
|
|
189
|
+
const notes = pitchNotes(curve, {
|
|
190
|
+
level: frameLevels(sample.mono, sample.sampleRate, curve),
|
|
191
|
+
}).filter((note) => note.end > from && note.start < to);
|
|
192
|
+
const hz: number[] = [];
|
|
193
|
+
let frames = 0;
|
|
194
|
+
for (let f = 0; f < curve.f0.length; f += 1) {
|
|
195
|
+
const t = curve.t0 + f * curve.hop;
|
|
196
|
+
if (t < from || t > to) continue;
|
|
197
|
+
frames += 1;
|
|
198
|
+
if (curve.f0[f]! > 0) hz.push(curve.f0[f]!);
|
|
199
|
+
}
|
|
200
|
+
hz.sort((a, b) => a - b);
|
|
201
|
+
return {
|
|
202
|
+
trackId,
|
|
203
|
+
target,
|
|
204
|
+
voice,
|
|
205
|
+
curve,
|
|
206
|
+
notes,
|
|
207
|
+
key: keyOfNotes(notes),
|
|
208
|
+
median: median(hz),
|
|
209
|
+
low: hz.length ? hz[Math.floor(hz.length * 0.02)]! : 0,
|
|
210
|
+
high: hz.length ? hz[Math.ceil(hz.length * 0.98) - 1]! : 0,
|
|
211
|
+
voiced: frames ? hz.length / frames : 0,
|
|
212
|
+
from,
|
|
213
|
+
to,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
218
|
+
|
|
219
|
+
/** `A4 +12c` for a frequency. */
|
|
220
|
+
export function hzName(hz: number): string {
|
|
221
|
+
if (!(hz > 0)) return "-";
|
|
222
|
+
const cents = centsOfHz(hz) + 6900;
|
|
223
|
+
const midi = Math.round(cents / 100);
|
|
224
|
+
const off = Math.round(cents - midi * 100);
|
|
225
|
+
const name = `${NAMES[((midi % 12) + 12) % 12]}${Math.floor(midi / 12) - 1}`;
|
|
226
|
+
return off === 0 ? name : `${name} ${off > 0 ? "+" : ""}${off}c`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The lines `/vocal pitch` prints. */
|
|
230
|
+
export function pitchReportLines(report: PitchReport): string[] {
|
|
231
|
+
const seconds = report.to - report.from;
|
|
232
|
+
return [
|
|
233
|
+
`${report.trackId} · ${report.target.label} · ${seconds.toFixed(1)} s · voice ${report.voice}`,
|
|
234
|
+
`key · ${report.key ?? "unclear (too few notes)"}`,
|
|
235
|
+
report.median > 0
|
|
236
|
+
? `pitch · median ${hzName(report.median)} (${report.median.toFixed(1)} Hz) · range ${hzName(report.low)} to ${hzName(report.high)}`
|
|
237
|
+
: "pitch · no voiced audio found",
|
|
238
|
+
`voiced · ${Math.round(report.voiced * 100)}% · ${report.notes.length} note${report.notes.length === 1 ? "" : "s"}`,
|
|
239
|
+
"/vocal notes makes a guide-notes track from it",
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* One receipt line for the prompt (cards show a single line): key, median,
|
|
245
|
+
* range and note count; the agent tool returns the full lines.
|
|
246
|
+
*/
|
|
247
|
+
export function pitchReportSummary(
|
|
248
|
+
report: PitchReport,
|
|
249
|
+
trace: boolean | undefined,
|
|
250
|
+
): string {
|
|
251
|
+
const parts = [`${report.trackId} · ${report.target.label}`];
|
|
252
|
+
parts.push(`key · ${report.key ?? "unclear"}`);
|
|
253
|
+
if (report.median > 0)
|
|
254
|
+
parts.push(
|
|
255
|
+
`median ${hzName(report.median)}`,
|
|
256
|
+
`range ${hzName(report.low)} to ${hzName(report.high)}`,
|
|
257
|
+
);
|
|
258
|
+
else parts.push("no voiced audio");
|
|
259
|
+
parts.push(
|
|
260
|
+
`${report.notes.length} note${report.notes.length === 1 ? "" : "s"}`,
|
|
261
|
+
);
|
|
262
|
+
if (trace === true) parts.push("trace · on");
|
|
263
|
+
if (trace === false) parts.push("trace · off");
|
|
264
|
+
return parts.join(" · ");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Score seconds of file time `t` for the report's target. */
|
|
268
|
+
/** The analysed clip as it stands in `score` now (it may have moved). */
|
|
269
|
+
function currentClip(
|
|
270
|
+
score: TrackScore,
|
|
271
|
+
report: PitchReport,
|
|
272
|
+
): AudioClip | undefined {
|
|
273
|
+
const clip = report.target.clip;
|
|
274
|
+
if (!clip) return undefined;
|
|
275
|
+
const track = score.tracks.find((t) => t.id === report.trackId);
|
|
276
|
+
return track?.clips?.find((c) => c.id === clip.id) ?? clip;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function songSeconds(
|
|
280
|
+
score: TrackScore,
|
|
281
|
+
report: PitchReport,
|
|
282
|
+
t: number,
|
|
283
|
+
voiceStart: number,
|
|
284
|
+
): number {
|
|
285
|
+
const clip = currentClip(score, report);
|
|
286
|
+
if (!clip) return voiceStart + t;
|
|
287
|
+
// As renderClips places it: track time, the take's nudge and clock drift,
|
|
288
|
+
// and a reversed clip playing its window backwards.
|
|
289
|
+
const track = score.tracks.find((item) => item.id === report.trackId);
|
|
290
|
+
const take = clip.take
|
|
291
|
+
? track?.takes?.find((item) => item.name === clip.take)
|
|
292
|
+
: undefined;
|
|
293
|
+
const drift = 1 + (take?.ppm ?? 0) / 1e6;
|
|
294
|
+
const into = clip.rev ? report.to - t : t - report.from;
|
|
295
|
+
return (
|
|
296
|
+
secondsAtTick(score, clipSongTick(clip, track?.time)) +
|
|
297
|
+
(take?.nudge ?? 0) / 1000 +
|
|
298
|
+
into / drift
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Adds a note track with one note per detected note, placed through the
|
|
304
|
+
* tempo map where the audio plays (a clip's start, or the first note that
|
|
305
|
+
* plays a sampler voice, else bar 1).
|
|
306
|
+
*/
|
|
307
|
+
export function guideNotesScore(
|
|
308
|
+
score: TrackScore,
|
|
309
|
+
report: PitchReport,
|
|
310
|
+
options: Readonly<{ as?: string }> = {},
|
|
311
|
+
): { next: TrackScore; trackId: string; count: number } {
|
|
312
|
+
if (score.tracks.length >= SCORE_LIMITS.maxTracks)
|
|
313
|
+
throw new PitchTargetError(
|
|
314
|
+
`the song already has ${SCORE_LIMITS.maxTracks} tracks`,
|
|
315
|
+
);
|
|
316
|
+
const trackId = options.as ?? freeTrackId(score, `${report.trackId}-notes`);
|
|
317
|
+
if (score.tracks.some((track) => track.id === trackId))
|
|
318
|
+
throw new PitchTargetError(`track ${trackId} already exists`);
|
|
319
|
+
let voiceStart = 0;
|
|
320
|
+
if (report.target.kind === "voice") {
|
|
321
|
+
const first = score.notes
|
|
322
|
+
.filter((note) => note.trackId === report.trackId)
|
|
323
|
+
.sort((a, b) => a.startTick - b.startTick)[0];
|
|
324
|
+
if (first) voiceStart = secondsAtTick(score, first.startTick);
|
|
325
|
+
}
|
|
326
|
+
const end = score.bars * barTicks(score);
|
|
327
|
+
const room = SCORE_LIMITS.maxNotes - score.notes.length;
|
|
328
|
+
const notes: NoteInput[] = [];
|
|
329
|
+
for (const note of report.notes) {
|
|
330
|
+
if (notes.length >= room) break;
|
|
331
|
+
const a = Math.max(note.start, report.from);
|
|
332
|
+
const b = Math.min(note.end, report.to);
|
|
333
|
+
const at = songSeconds(score, report, a, voiceStart);
|
|
334
|
+
const until = songSeconds(score, report, b, voiceStart);
|
|
335
|
+
const startTick = Math.round(tickAtSeconds(score, Math.min(at, until)));
|
|
336
|
+
const endTick = Math.min(
|
|
337
|
+
end,
|
|
338
|
+
Math.round(tickAtSeconds(score, Math.max(at, until))),
|
|
339
|
+
);
|
|
340
|
+
if (startTick < 0 || startTick >= end) continue;
|
|
341
|
+
notes.push({
|
|
342
|
+
id: newId(trackId),
|
|
343
|
+
trackId,
|
|
344
|
+
startTick,
|
|
345
|
+
durationTicks: Math.max(1, endTick - startTick),
|
|
346
|
+
pitch: Math.max(0, Math.min(127, note.midi)),
|
|
347
|
+
velocity: Math.round((0.55 + 0.4 * note.confidence) * 100) / 100,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
const track: TrackInput = { id: trackId, name: trackId };
|
|
351
|
+
const added = addTrack(score, track);
|
|
352
|
+
const next = new TrackScore({
|
|
353
|
+
...added.toJSON(),
|
|
354
|
+
notes: [...added.notes, ...notes],
|
|
355
|
+
} as never);
|
|
356
|
+
return { next, trackId, count: notes.length };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Trace points kept per track, at most this many. */
|
|
360
|
+
export const MAX_TRACE_POINTS = 4096;
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The trace the highway draws: one point per 20 ms of voiced audio (sparser
|
|
364
|
+
* on long takes so the whole window fits MAX_TRACE_POINTS), in score beats
|
|
365
|
+
* through the tempo map, fractional MIDI pitch.
|
|
366
|
+
*/
|
|
367
|
+
export function pitchTracePoints(
|
|
368
|
+
score: TrackScore,
|
|
369
|
+
report: PitchReport,
|
|
370
|
+
): PitchTracePoint[] {
|
|
371
|
+
const { curve } = report;
|
|
372
|
+
let voiceStart = 0;
|
|
373
|
+
if (report.target.kind === "voice") {
|
|
374
|
+
const first = score.notes
|
|
375
|
+
.filter((note) => note.trackId === report.trackId)
|
|
376
|
+
.sort((a, b) => a.startTick - b.startTick)[0];
|
|
377
|
+
if (first) voiceStart = secondsAtTick(score, first.startTick);
|
|
378
|
+
}
|
|
379
|
+
const first = Math.max(0, Math.ceil((report.from - curve.t0) / curve.hop));
|
|
380
|
+
const last = Math.min(
|
|
381
|
+
curve.f0.length - 1,
|
|
382
|
+
Math.floor((report.to - curve.t0) / curve.hop),
|
|
383
|
+
);
|
|
384
|
+
let voiced = 0;
|
|
385
|
+
for (let f = first; f <= last; f += 1) if (curve.f0[f]! > 0) voiced += 1;
|
|
386
|
+
const step = Math.max(
|
|
387
|
+
1,
|
|
388
|
+
Math.round(0.02 / curve.hop),
|
|
389
|
+
Math.ceil(voiced / MAX_TRACE_POINTS),
|
|
390
|
+
);
|
|
391
|
+
const points: PitchTracePoint[] = [];
|
|
392
|
+
for (let f = 0; f < curve.f0.length; f += step) {
|
|
393
|
+
const hz = curve.f0[f]!;
|
|
394
|
+
const t = curve.t0 + f * curve.hop;
|
|
395
|
+
if (!(hz > 0) || t < report.from || t > report.to) continue;
|
|
396
|
+
const seconds = songSeconds(score, report, t, voiceStart);
|
|
397
|
+
points.push({
|
|
398
|
+
beat: tickAtSeconds(score, seconds) / score.ticksPerBeat,
|
|
399
|
+
pitch: 69 + 12 * Math.log2(hz / 440),
|
|
400
|
+
});
|
|
401
|
+
if (points.length >= MAX_TRACE_POINTS) break;
|
|
402
|
+
}
|
|
403
|
+
// A reversed clip plays its window backwards; the highway wants beat order.
|
|
404
|
+
if (report.target.clip?.rev) points.reverse();
|
|
405
|
+
return points;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** What the session remembers of a track's last analysis (menu, highway). */
|
|
409
|
+
export type PitchSummary = Readonly<{
|
|
410
|
+
label: string;
|
|
411
|
+
key: string | null;
|
|
412
|
+
median: number;
|
|
413
|
+
/** Present while the highway trace is on. */
|
|
414
|
+
trace?: readonly PitchTracePoint[];
|
|
415
|
+
/** The analysis behind the trace, to re-place it after timing edits. */
|
|
416
|
+
report?: PitchReport;
|
|
417
|
+
/** The score the trace was placed against. */
|
|
418
|
+
placedOn?: TrackScore;
|
|
419
|
+
}>;
|
|
420
|
+
|
|
421
|
+
const summaries = new Map<string, PitchSummary>();
|
|
422
|
+
|
|
423
|
+
/** The last `/vocal pitch` result for a track in this session. */
|
|
424
|
+
export function pitchSummary(trackId: string): PitchSummary | undefined {
|
|
425
|
+
return summaries.get(trackId);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The highway trace for a track, when it is on. Given the current score, the
|
|
430
|
+
* trace is re-placed whenever the score changed since it was placed (tempo
|
|
431
|
+
* map, clip start, the first note of a sampler voice).
|
|
432
|
+
*/
|
|
433
|
+
export function pitchTraceFor(
|
|
434
|
+
trackId: string,
|
|
435
|
+
score?: TrackScore,
|
|
436
|
+
): readonly PitchTracePoint[] | undefined {
|
|
437
|
+
const summary = summaries.get(trackId);
|
|
438
|
+
if (!summary?.trace) return undefined;
|
|
439
|
+
if (!score || !summary.report || summary.placedOn === score)
|
|
440
|
+
return summary.trace;
|
|
441
|
+
const trace = pitchTracePoints(score, summary.report);
|
|
442
|
+
summaries.set(trackId, { ...summary, trace, placedOn: score });
|
|
443
|
+
return trace;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Forgets every remembered analysis (tests). */
|
|
447
|
+
export function clearPitchSummaries(): void {
|
|
448
|
+
summaries.clear();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function remember(
|
|
452
|
+
score: TrackScore,
|
|
453
|
+
report: PitchReport,
|
|
454
|
+
trace: boolean,
|
|
455
|
+
): void {
|
|
456
|
+
summaries.set(report.trackId, {
|
|
457
|
+
label: report.target.label,
|
|
458
|
+
key: report.key,
|
|
459
|
+
median: report.median,
|
|
460
|
+
...(trace
|
|
461
|
+
? { trace: pitchTracePoints(score, report), report, placedOn: score }
|
|
462
|
+
: {}),
|
|
463
|
+
});
|
|
464
|
+
if (summaries.size > 64)
|
|
465
|
+
summaries.delete(summaries.keys().next().value as string);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export type PitchArgs = Readonly<{
|
|
469
|
+
/** Only clips, or only sample voices. */
|
|
470
|
+
kind?: "clip" | "voice";
|
|
471
|
+
target?: string;
|
|
472
|
+
voice?: PitchVoice;
|
|
473
|
+
as?: string;
|
|
474
|
+
}>;
|
|
475
|
+
|
|
476
|
+
/** The usage line both pitch verbs print for a word they do not know. */
|
|
477
|
+
export const PITCH_USAGE = `[clip|voice] [<name>] [${PITCH_VOICE_NAMES.join("|")}] [as <track id>]`;
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* `[clip|voice] [<name>] [<range>] [as <track id>]`, words in any order and
|
|
481
|
+
* any case. `clip` and `voice` pick the source kind (`voice` = a sampler
|
|
482
|
+
* voice) and may be followed by its name; `voice <range>` and `range <range>`
|
|
483
|
+
* also set the search range.
|
|
484
|
+
*/
|
|
485
|
+
export function parsePitchArgs(args: string): PitchArgs | string {
|
|
486
|
+
const words = args.split(/\s+/).filter(Boolean);
|
|
487
|
+
const out: {
|
|
488
|
+
kind?: "clip" | "voice";
|
|
489
|
+
target?: string;
|
|
490
|
+
voice?: PitchVoice;
|
|
491
|
+
as?: string;
|
|
492
|
+
} = {};
|
|
493
|
+
const usage = (word: string): string =>
|
|
494
|
+
`unexpected ${word} · usage: ${PITCH_USAGE}`;
|
|
495
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
496
|
+
const word = words[i]!;
|
|
497
|
+
const lower = word.toLowerCase();
|
|
498
|
+
if (lower === "as") {
|
|
499
|
+
const id = words[++i];
|
|
500
|
+
if (!id || !/^[A-Za-z0-9_-]{1,64}$/.test(id))
|
|
501
|
+
return "as needs a track id (letters, digits, - and _)";
|
|
502
|
+
out.as = id;
|
|
503
|
+
} else if (lower === "range") {
|
|
504
|
+
const name = words[++i]?.toLowerCase();
|
|
505
|
+
if (!isPitchVoice(name))
|
|
506
|
+
return `range is one of ${PITCH_VOICE_NAMES.join(", ")} · usage: ${PITCH_USAGE}`;
|
|
507
|
+
out.voice = name;
|
|
508
|
+
} else if (
|
|
509
|
+
lower === "voice" &&
|
|
510
|
+
isPitchVoice(words[i + 1]?.toLowerCase()) &&
|
|
511
|
+
out.voice === undefined
|
|
512
|
+
) {
|
|
513
|
+
out.voice = words[++i]!.toLowerCase() as PitchVoice;
|
|
514
|
+
} else if ((lower === "clip" || lower === "voice") && !out.kind) {
|
|
515
|
+
out.kind = lower;
|
|
516
|
+
} else if (isPitchVoice(lower) && out.voice === undefined) {
|
|
517
|
+
out.voice = lower;
|
|
518
|
+
} else if (out.target === undefined) {
|
|
519
|
+
out.target = word;
|
|
520
|
+
} else return usage(word);
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function runPitch(
|
|
526
|
+
args: string,
|
|
527
|
+
context: VocalContext,
|
|
528
|
+
): Promise<VocalResult> {
|
|
529
|
+
const traceWord = /^\s*trace(?:\s+(on|off))?\b(.*)$/i.exec(args);
|
|
530
|
+
const before = summaries.get(context.trackId);
|
|
531
|
+
if (traceWord && traceWord[1]?.toLowerCase() === "off") {
|
|
532
|
+
if (before)
|
|
533
|
+
summaries.set(context.trackId, {
|
|
534
|
+
label: before.label,
|
|
535
|
+
key: before.key,
|
|
536
|
+
median: before.median,
|
|
537
|
+
});
|
|
538
|
+
return {
|
|
539
|
+
ok: true,
|
|
540
|
+
message: `vocal pitch: trace off for ${context.trackId}`,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const trace = traceWord !== null || before?.trace !== undefined;
|
|
544
|
+
const parsed = parsePitchArgs(traceWord ? traceWord[2]! : args);
|
|
545
|
+
if (typeof parsed === "string")
|
|
546
|
+
return { ok: false, message: `vocal pitch: ${parsed}` };
|
|
547
|
+
try {
|
|
548
|
+
const report = await analyzeTrackPitch(
|
|
549
|
+
context.score,
|
|
550
|
+
context.trackId,
|
|
551
|
+
context.cwd,
|
|
552
|
+
parsed,
|
|
553
|
+
);
|
|
554
|
+
remember(context.score, report, trace);
|
|
555
|
+
return {
|
|
556
|
+
ok: true,
|
|
557
|
+
message: pitchReportSummary(report, trace || undefined),
|
|
558
|
+
};
|
|
559
|
+
} catch (error) {
|
|
560
|
+
if (error instanceof PitchTargetError)
|
|
561
|
+
return { ok: false, message: `vocal pitch: ${error.message}` };
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function runNotes(
|
|
567
|
+
args: string,
|
|
568
|
+
context: VocalContext,
|
|
569
|
+
): Promise<VocalResult> {
|
|
570
|
+
const parsed = parsePitchArgs(args);
|
|
571
|
+
if (typeof parsed === "string")
|
|
572
|
+
return { ok: false, message: `vocal notes: ${parsed}` };
|
|
573
|
+
try {
|
|
574
|
+
const report = await analyzeTrackPitch(
|
|
575
|
+
context.score,
|
|
576
|
+
context.trackId,
|
|
577
|
+
context.cwd,
|
|
578
|
+
parsed,
|
|
579
|
+
);
|
|
580
|
+
if (report.notes.length === 0)
|
|
581
|
+
return {
|
|
582
|
+
ok: false,
|
|
583
|
+
message: `vocal notes: no notes found in ${report.trackId} · ${report.target.label}`,
|
|
584
|
+
};
|
|
585
|
+
const made = guideNotesScore(context.score, report, parsed);
|
|
586
|
+
return {
|
|
587
|
+
ok: true,
|
|
588
|
+
message: `vocal notes: ${made.count} note${made.count === 1 ? "" : "s"} from ${report.target.label} on new track ${made.trackId}${report.key ? ` · key ${report.key}` : ""} · /mute it if it is only a guide`,
|
|
589
|
+
next: made.next,
|
|
590
|
+
kind: "pitch.notes",
|
|
591
|
+
payload: { trackId: made.trackId, from: report.trackId },
|
|
592
|
+
};
|
|
593
|
+
} catch (error) {
|
|
594
|
+
if (error instanceof PitchTargetError)
|
|
595
|
+
return { ok: false, message: `vocal notes: ${error.message}` };
|
|
596
|
+
throw error;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** The pitch lane's `/vocal` verbs. */
|
|
601
|
+
export const PITCH_VERBS: readonly VocalVerb[] = [
|
|
602
|
+
{
|
|
603
|
+
verb: "pitch",
|
|
604
|
+
usage:
|
|
605
|
+
"pitch [trace on|off] [clip|voice] [<name>] [bass|tenor|alto|soprano]",
|
|
606
|
+
summary: "detected key, median pitch and range of the focused audio",
|
|
607
|
+
lane: "pitch",
|
|
608
|
+
run: runPitch,
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
verb: "notes",
|
|
612
|
+
usage: "notes [clip|voice] [<name>] [bass|tenor|alto|soprano] [as <track>]",
|
|
613
|
+
summary: "turn the focused audio's melody into a guide-notes track",
|
|
614
|
+
lane: "pitch",
|
|
615
|
+
run: runNotes,
|
|
616
|
+
},
|
|
617
|
+
];
|