@@ -46,19 +46,28 @@ export interface HfCarveBand {
4646 * once.
4747 */
4848export 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+ / s f x | f o l e y | w h o o s h | i m p a c t | r i s e r | s t i n g e r | s w o o s h | t h u d | b o o m | c l i c k | d i n g | b e e p | a m b i e n | r o o m [ - _ ] ? t o n e / i;
108+ /** A bed, which is the thing being carved rather than the thing carving it. */
109+ const MUSIC_NAME = / m u s i c | b g m | \b b e d \b | s o u n d t r a c k | s c o r e | \b s o n g \b | t h e m e | i n s t r u m e n t a l | t r a c k \d / i;
110+ /** Speech. */
111+ const VOICE_NAME =
112+ / v o i c e | \b v o \b | \b v o x \b | n a r r a t | s p e e c h | d i a l o g | m o n o l o g | a n n o u n c e | \b t t s \b | t a l k | i n t e r v i e w | p o d c a s t | r e c a p | s c r i p t / 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+
85172export 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 {
133220export 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. */
158297function powerSpectrum (
159298 mono : Float32Array ,
0 commit comments