Skip to content

Commit 5596a64

Browse files
vanceingallsclaude
andcommitted
feat(core): carve against every voice over a bed, always dynamically
A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9bab207 commit 5596a64

2 files changed

Lines changed: 250 additions & 12 deletions

File tree

packages/core/src/audioCarve.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {
66
analyseCarveDynamics,
77
carveBandsToChain,
88
carveProfile,
9+
classifyAudioName,
10+
mixCarveSources,
11+
couldBeCarveSource,
912
DEFAULT_CARVE,
1013
normalizeCarveSettings,
1114
} from "./audioCarve.js";
@@ -458,3 +461,99 @@ describe("analyseCarveDynamics", () => {
458461
expect(analyseCarveDynamics(new Float32Array(0), SR, [BAND])).toEqual([]);
459462
});
460463
});
464+
465+
describe("classifyAudioName", () => {
466+
it("reads a track's kind from its id and its filename together", () => {
467+
// Either can be the informative one: elements named a1/a2 may still have
468+
// narration.mp3 and bgm.mp3 behind them.
469+
expect(classifyAudioName("narration")).toBe("voice");
470+
expect(classifyAudioName("a1", "voiceover-take3.wav")).toBe("voice");
471+
expect(classifyAudioName("music-bed")).toBe("music");
472+
expect(classifyAudioName("a2", "bgm_loop.m4a")).toBe("music");
473+
expect(classifyAudioName("sfx-explosion")).toBe("sfx");
474+
expect(classifyAudioName("whoosh-01")).toBe("sfx");
475+
});
476+
477+
it("says nothing about a name that says nothing", () => {
478+
// The common case, and the reason nothing downstream may treat "unknown" as
479+
// "not a voice": it would hide the one track somebody needs to pick.
480+
expect(classifyAudioName("a1")).toBe("unknown");
481+
expect(classifyAudioName("clip-2", "0f9c1a.mp3")).toBe("unknown");
482+
expect(classifyAudioName(undefined, null)).toBe("unknown");
483+
});
484+
485+
it("prefers voice when a name carries both hints", () => {
486+
// A file called voiceover-over-music-bed.wav is the voiceover, and a track
487+
// matching both is better offered than hidden.
488+
expect(classifyAudioName("voiceover-over-music-bed.wav")).toBe("voice");
489+
});
490+
491+
it("offers speech and unnamed tracks as carve sources, never music or effects", () => {
492+
expect(couldBeCarveSource("recap-audio")).toBe(true);
493+
expect(couldBeCarveSource("a1")).toBe(true);
494+
expect(couldBeCarveSource("music-bed")).toBe(false);
495+
expect(couldBeCarveSource("sfx-explosion")).toBe(false);
496+
});
497+
});
498+
499+
describe("mixCarveSources", () => {
500+
const tone = (seconds: number, level: number, sampleRate = 48000) =>
501+
new Float32Array(Math.round(seconds * sampleRate)).fill(level);
502+
503+
it("places every voice where it starts on the bed's clock", () => {
504+
// Three people talking at different times is still one question — where and
505+
// when is speech masking this bed — so they become one signal.
506+
const mixed = mixCarveSources(
507+
[
508+
{ samples: tone(1, 0.5), offsetSeconds: 1 },
509+
{ samples: tone(1, 0.25), offsetSeconds: 3 },
510+
],
511+
48000,
512+
);
513+
expect(mixed.length).toBe(4 * 48000);
514+
const at = (t: number) => mixed[Math.round(t * 48000)];
515+
expect(at(0.5)).toBe(0); // before anyone speaks
516+
expect(at(1.5)).toBeCloseTo(0.5, 5);
517+
expect(at(2.5)).toBe(0); // the gap between them
518+
expect(at(3.5)).toBeCloseTo(0.25, 5);
519+
});
520+
521+
it("sums voices that overlap, because two at once mask more than one", () => {
522+
const mixed = mixCarveSources(
523+
[
524+
{ samples: tone(1, 0.3), offsetSeconds: 0 },
525+
{ samples: tone(1, 0.3), offsetSeconds: 0 },
526+
],
527+
48000,
528+
);
529+
expect(mixed[0]).toBeCloseTo(0.6, 5);
530+
});
531+
532+
it("drops the part of a voice that plays before the bed starts", () => {
533+
// It masks nothing there, and folding it in at zero would put a cut where
534+
// there is no voice.
535+
const mixed = mixCarveSources([{ samples: tone(1, 0.5), offsetSeconds: -0.5 }], 48000);
536+
expect(mixed.length).toBe(0.5 * 48000);
537+
expect(mixed[0]).toBeCloseTo(0.5, 5);
538+
});
539+
540+
it("has nothing to mix when there are no voices", () => {
541+
expect(mixCarveSources([], 48000)).toHaveLength(0);
542+
});
543+
});
544+
545+
describe("carve settings written before this took a list of voices", () => {
546+
it("reads a single `source` as a one-voice list, and forgets `dynamic`", () => {
547+
// Every carve is dynamic now: a static one thinned the bed through every pause,
548+
// and nobody wanted that once they had heard both.
549+
const read = normalizeCarveSettings({ source: "vo", strength: 0.4, dynamic: false } as never);
550+
expect(read.sources).toEqual(["vo"]);
551+
expect(read.strength).toBe(0.4);
552+
expect("dynamic" in read).toBe(false);
553+
});
554+
555+
it("drops empty ids rather than carrying a source that names nothing", () => {
556+
expect(normalizeCarveSettings({ sources: ["", "vo", ""] } as never).sources).toEqual(["vo"]);
557+
expect(normalizeCarveSettings({ source: "" } as never).sources).toEqual([]);
558+
});
559+
});

packages/core/src/audioCarve.ts

Lines changed: 151 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,28 @@ export interface HfCarveBand {
4646
* once.
4747
*/
4848
export interface HfCarveSettings {
49-
/** Element id of the voice track to analyse. */
50-
source: string;
49+
/**
50+
* Element ids of every voice track this bed makes room for.
51+
*
52+
* More than one because a bed usually runs under a whole sequence: a narrator, an
53+
* interview answer, a second presenter. Each occupies its own stretch of the bed,
54+
* and carving against only one of them leaves the others fighting it. They are
55+
* analysed together — see `mixCarveSources` — so the cuts follow whoever is
56+
* speaking rather than averaging strangers.
57+
*/
58+
sources: string[];
5159
/** How hard to carve, 0..1. */
5260
strength: number;
5361
/**
54-
* Follow the voice rather than sitting at a fixed depth.
62+
* Whether the carve is applied at all.
5563
*
56-
* A static carve holds its cuts for the whole clip, including every pause — the
57-
* bed is thinned where there is nothing to make room for. Dynamic turns every
58-
* value into an envelope of the voice's own level, so silence leaves the bed
59-
* alone and a loud passage pushes the carve to full depth.
64+
* A bed under a voice wants carving, so a track that has never been configured
65+
* is treated as on and carved without being asked. That default needs an off
66+
* switch that survives: with "off" represented by having no settings at all,
67+
* selecting the clip again would read it as never-configured and re-apply. So
68+
* switching it off writes `enabled: false` and the default stops applying.
6069
*/
61-
dynamic: boolean;
70+
enabled: boolean;
6271
}
6372

6473
/** The numbers the analysis actually works in, all derived from `strength`. */
@@ -82,13 +91,91 @@ export interface HfCarveProfile {
8291
headroomDb: number;
8392
}
8493

94+
/**
95+
* What a track's name suggests it holds.
96+
*
97+
* Only ever a hint — a name is what the author called something, not what is in the
98+
* file — so this is used to order and to filter a list of candidates, never to
99+
* decide alone. `unknown` is deliberately common: a track called `a1` could be
100+
* anything, and treating an unrecognised name as "not a voice" would hide the one
101+
* track somebody needs to pick.
102+
*/
103+
export type HfAudioNameKind = "voice" | "music" | "sfx" | "unknown";
104+
105+
/** Short, deliberately dull effects. Nothing here is ever a voiceover. */
106+
const SFX_NAME =
107+
/sfx|foley|whoosh|impact|riser|stinger|swoosh|thud|boom|click|ding|beep|ambien|room[-_ ]?tone/i;
108+
/** A bed, which is the thing being carved rather than the thing carving it. */
109+
const MUSIC_NAME = /music|bgm|\bbed\b|soundtrack|score|\bsong\b|theme|instrumental|track\d/i;
110+
/** Speech. */
111+
const VOICE_NAME =
112+
/voice|\bvo\b|\bvox\b|narrat|speech|dialog|monolog|announce|\btts\b|talk|interview|podcast|recap|script/i;
113+
114+
/**
115+
* Classify a track from its id and filename together.
116+
*
117+
* Both, because either can be the informative one: an author naming elements `a1`
118+
* and `a2` may still have `narration.mp3` and `bgm.mp3` as their sources, and one
119+
* naming them `voice` and `music` may have opaque hashes for filenames.
120+
*
121+
* Voice is tested first: a file called `voiceover-music-bed.wav` is more likely the
122+
* voiceover than the bed, and a track matching both hints is better offered than
123+
* hidden.
124+
*/
125+
export function classifyAudioName(
126+
...parts: readonly (string | null | undefined)[]
127+
): HfAudioNameKind {
128+
const text = parts.filter(Boolean).join(" ");
129+
if (VOICE_NAME.test(text)) return "voice";
130+
if (SFX_NAME.test(text)) return "sfx";
131+
if (MUSIC_NAME.test(text)) return "music";
132+
return "unknown";
133+
}
134+
135+
/** A clip's place on the timeline. A duration that is not a number is unbounded. */
136+
export interface HfClipSpan {
137+
start: number;
138+
duration?: number | null;
139+
}
140+
141+
/**
142+
* Do these two clips share any time at all?
143+
*
144+
* A voice that never plays while the bed does cannot mask it, so it has no business
145+
* in the carve: it would contribute silence to the analysis and, worse, invite the
146+
* author to wonder why including it changed nothing.
147+
*
148+
* An unknown duration counts as unbounded rather than as zero. Refusing a track
149+
* because its length is not written down would drop the commonest case there is — a
150+
* clip whose duration the composition leaves to the media itself.
151+
*/
152+
export function clipsOverlap(a: HfClipSpan, b: HfClipSpan): boolean {
153+
const end = (clip: HfClipSpan): number =>
154+
typeof clip.duration === "number" && Number.isFinite(clip.duration)
155+
? clip.start + clip.duration
156+
: Number.POSITIVE_INFINITY;
157+
return a.start < end(b) && b.start < end(a);
158+
}
159+
160+
/**
161+
* Could this track be the voice a carve listens to?
162+
*
163+
* Music and SFX are out: a bed is the thing being carved, and a 200 ms whoosh has
164+
* no speech to make room for. Everything else stays in, including names that say
165+
* nothing — see `HfAudioNameKind`.
166+
*/
167+
export function couldBeCarveSource(...parts: readonly (string | null | undefined)[]): boolean {
168+
const kind = classifyAudioName(...parts);
169+
return kind === "voice" || kind === "unknown";
170+
}
171+
85172
export const DEFAULT_CARVE: HfCarveSettings = {
86-
source: "",
173+
enabled: true,
174+
sources: [],
87175
// A quarter, because the knob's range was doubled and this is the point on the
88176
// new scale that produces what the panel has always defaulted to. Switching
89177
// carve on sounds the same as it did; the extra range is above, not under.
90178
strength: 0.25,
91-
dynamic: false,
92179
};
93180

94181
/**
@@ -133,10 +220,16 @@ export function carveProfile(strength: number): HfCarveProfile {
133220
export function normalizeCarveSettings(
134221
raw: Partial<HfCarveSettings & HfCarveProfile> | undefined,
135222
): HfCarveSettings {
223+
// `source` and `dynamic` are gone from the type but still out there in files.
224+
const legacy = raw as (Partial<HfCarveSettings> & { source?: unknown }) | undefined;
136225
const num = (v: unknown): number | null => {
137226
const n = typeof v === "number" ? v : Number(v);
138227
return Number.isFinite(n) ? n : null;
139228
};
229+
// No attribute at all is not a carve to read, it is the absence of one — so the
230+
// defaults apply whole, dynamic included. Only a stored object gets the reading
231+
// below, where a missing `dynamic` means the static carve it was written as.
232+
if (raw === undefined || raw === null) return { ...DEFAULT_CARVE };
140233
const strength = num(raw?.strength);
141234
const legacyDepth = num(raw?.maxCutDb);
142235
const resolved =
@@ -147,13 +240,59 @@ export function normalizeCarveSettings(
147240
// reads back as the strength that produces 6 dB.
148241
(legacyDepth - 2) / 16
149242
: DEFAULT_CARVE.strength;
243+
// A carve written before this took a list names its one voice in `source`.
244+
const stored = Array.isArray(raw?.sources)
245+
? raw.sources
246+
: typeof legacy?.source === "string"
247+
? [legacy.source]
248+
: [];
150249
return {
151-
source: typeof raw?.source === "string" ? raw.source : "",
250+
// Absent means on: every carve written before the flag existed was applied.
251+
enabled: raw?.enabled !== false,
252+
sources: stored.filter((id): id is string => typeof id === "string" && id !== ""),
152253
strength: Math.min(1, Math.max(0, resolved)),
153-
dynamic: raw?.dynamic === true,
154254
};
155255
}
156256

257+
/**
258+
* Every voice as one signal on the BED's clock.
259+
*
260+
* The analysis asks one question — where and when is speech masking this bed — and
261+
* that question has one answer even when three people are talking at different
262+
* times. Summing them onto the bed's timeline first means the existing analysis
263+
* needs no notion of "which voice": bands come out of all the speech there is, and
264+
* the envelopes rise wherever any of it is happening.
265+
*
266+
* `offsetSeconds` is where each voice starts relative to the bed. Audio before the
267+
* bed begins is dropped rather than folded in at zero: it plays over nothing and
268+
* cannot mask anything, and shifting it would put a cut where there is no voice.
269+
*
270+
* Summed, not averaged. Two people speaking at once mask more than either alone,
271+
* which is exactly what the carve should answer to.
272+
*/
273+
export function mixCarveSources(
274+
parts: readonly { samples: Float32Array; offsetSeconds: number }[],
275+
sampleRate: number,
276+
): Float32Array {
277+
const placed = parts.map((part) => ({
278+
samples: part.samples,
279+
at: Math.round(part.offsetSeconds * sampleRate),
280+
}));
281+
const length = placed.reduce((max, p) => Math.max(max, p.at + p.samples.length), 0);
282+
if (length <= 0) return new Float32Array(0);
283+
const mixed = new Float32Array(length);
284+
for (const { samples, at } of placed) {
285+
// A voice starting before the bed contributes only the part that overlaps it.
286+
const from = at < 0 ? -at : 0;
287+
for (let i = from; i < samples.length; i += 1) {
288+
const target = at + i;
289+
if (target < 0 || target >= length) continue;
290+
mixed[target] = (mixed[target] ?? 0) + (samples[i] ?? 0);
291+
}
292+
}
293+
return mixed;
294+
}
295+
157296
/** Averaged power spectrum, Welch-style. */
158297
function powerSpectrum(
159298
mono: Float32Array,

0 commit comments

Comments
 (0)