@maravilla-labs/frames 0.5.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/README.md +59 -5
- package/dist/audio.d.ts +92 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +282 -0
- package/dist/audio.js.map +1 -0
- package/dist/ducking.d.ts +53 -0
- package/dist/ducking.d.ts.map +1 -0
- package/dist/ducking.js +85 -0
- package/dist/ducking.js.map +1 -0
- package/dist/fades.d.ts +38 -0
- package/dist/fades.d.ts.map +1 -0
- package/dist/fades.js +70 -0
- package/dist/fades.js.map +1 -0
- package/dist/index.d.ts +100 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +69 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/audio.ts +349 -0
- package/src/ducking.ts +103 -0
- package/src/fades.ts +81 -0
- package/src/index.ts +190 -2
package/src/audio.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side audio preview for a frames timeline.
|
|
3
|
+
*
|
|
4
|
+
* The render worker mixes audio with ffmpeg (the browser can't expose
|
|
5
|
+
* decoded audio to the capture pipeline), so this engine exists purely so
|
|
6
|
+
* the **editor / preview** plays the same mix the final video will have:
|
|
7
|
+
* multiple time-bound tracks (voiceover, background music, sfx), per-track
|
|
8
|
+
* gain + fades, and static-envelope ducking of music under voiceover (see
|
|
9
|
+
* [`ducking.ts`] — the exact curve the worker bakes in).
|
|
10
|
+
*
|
|
11
|
+
* Scrubbing (`applyState(t)`) stays silent by design — like every real NLE,
|
|
12
|
+
* audio is heard only during continuous playback. The preview host calls
|
|
13
|
+
* [`AudioEngine.playFrom`] on play and [`AudioEngine.stop`] on pause.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_DUCK,
|
|
18
|
+
duckAutomation,
|
|
19
|
+
mergeSegments,
|
|
20
|
+
type DuckParams,
|
|
21
|
+
type Segment,
|
|
22
|
+
} from "./ducking.js";
|
|
23
|
+
import { fadeAutomation, type FadeCurve } from "./fades.js";
|
|
24
|
+
|
|
25
|
+
/** A timeline audio track, resolved for browser playback. Times in ms. */
|
|
26
|
+
export type PreviewAudioTrack = {
|
|
27
|
+
/** Playable URL fetched + decoded for preview. */
|
|
28
|
+
src: string;
|
|
29
|
+
/** Timeline start, ms. */
|
|
30
|
+
at: number;
|
|
31
|
+
/** Play length on the timeline, ms. `null` = natural clip length. */
|
|
32
|
+
duration: number | null;
|
|
33
|
+
/** Start offset within the source, ms. */
|
|
34
|
+
seek: number;
|
|
35
|
+
/** Linear base gain (1 = unity). */
|
|
36
|
+
gain: number;
|
|
37
|
+
/** Fade-in / fade-out, ms. */
|
|
38
|
+
fadeIn: number;
|
|
39
|
+
fadeOut: number;
|
|
40
|
+
/** Shape of each fade (see `fades.ts`). Default `linear`. */
|
|
41
|
+
fadeInCurve?: FadeCurve;
|
|
42
|
+
fadeOutCurve?: FadeCurve;
|
|
43
|
+
/** "voiceover" drives ducking; "music" gets ducked when `duck`. */
|
|
44
|
+
role: "voiceover" | "music" | "sfx";
|
|
45
|
+
/** Whether this (music) track dips under voiceover. */
|
|
46
|
+
duck: boolean;
|
|
47
|
+
/** Optional per-track effect (structurally matches `AudioEffect`). */
|
|
48
|
+
effect?: { kind: "radio"; drive?: number; tone?: number; depth?: number };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type WindowAudioContext = typeof AudioContext;
|
|
52
|
+
|
|
53
|
+
function getAudioContextCtor(): WindowAudioContext | null {
|
|
54
|
+
if (typeof window === "undefined") return null;
|
|
55
|
+
return (
|
|
56
|
+
window.AudioContext ||
|
|
57
|
+
(window as unknown as { webkitAudioContext?: WindowAudioContext })
|
|
58
|
+
.webkitAudioContext ||
|
|
59
|
+
null
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class AudioEngine {
|
|
64
|
+
private tracks: PreviewAudioTrack[];
|
|
65
|
+
private duck: DuckParams;
|
|
66
|
+
private ctx: AudioContext | null = null;
|
|
67
|
+
private master: GainNode | null = null;
|
|
68
|
+
private buffers = new Map<string, AudioBuffer>();
|
|
69
|
+
private active: AudioBufferSourceNode[] = [];
|
|
70
|
+
private muted = false;
|
|
71
|
+
|
|
72
|
+
constructor(tracks: PreviewAudioTrack[], duck: DuckParams = DEFAULT_DUCK) {
|
|
73
|
+
this.tracks = tracks;
|
|
74
|
+
this.duck = duck;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Merged voiceover regions (seconds) — what music ducks under. */
|
|
78
|
+
private voiceoverSegments(): Segment[] {
|
|
79
|
+
const segs = this.tracks
|
|
80
|
+
.filter((t) => t.role === "voiceover")
|
|
81
|
+
.map((t) => ({
|
|
82
|
+
start: t.at / 1000,
|
|
83
|
+
end: (t.at + (t.duration ?? 0)) / 1000,
|
|
84
|
+
}))
|
|
85
|
+
.filter((s) => s.end > s.start);
|
|
86
|
+
return mergeSegments(segs);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Fetch + decode every track's source (idempotent, cached by URL). */
|
|
90
|
+
private async ensureBuffers(): Promise<void> {
|
|
91
|
+
const Ctor = getAudioContextCtor();
|
|
92
|
+
if (!Ctor) return;
|
|
93
|
+
if (!this.ctx) {
|
|
94
|
+
this.ctx = new Ctor();
|
|
95
|
+
this.master = this.ctx.createGain();
|
|
96
|
+
this.master.connect(this.ctx.destination);
|
|
97
|
+
}
|
|
98
|
+
const ctx = this.ctx;
|
|
99
|
+
await Promise.all(
|
|
100
|
+
this.tracks.map(async (t) => {
|
|
101
|
+
if (this.buffers.has(t.src)) return;
|
|
102
|
+
try {
|
|
103
|
+
const resp = await fetch(t.src);
|
|
104
|
+
const raw = await resp.arrayBuffer();
|
|
105
|
+
const buf = await ctx.decodeAudioData(raw);
|
|
106
|
+
this.buffers.set(t.src, buf);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// A bad/missing audio URL must never wedge the preview — skip it.
|
|
109
|
+
// eslint-disable-next-line no-console
|
|
110
|
+
console.warn(`[@maravilla-labs/frames] audio decode failed for ${t.src}:`, err);
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Start (or restart) playback from timeline position `fromMs`. */
|
|
117
|
+
async playFrom(fromMs: number): Promise<void> {
|
|
118
|
+
await this.ensureBuffers();
|
|
119
|
+
if (!this.ctx || !this.master) return;
|
|
120
|
+
this.stop();
|
|
121
|
+
// Resume may reject if there's no user activation (autoplay policy). Don't
|
|
122
|
+
// let that abort scheduling — the caller invokes this from a click handler,
|
|
123
|
+
// so activation is usually present; if not, the context stays suspended and
|
|
124
|
+
// simply produces no sound rather than throwing.
|
|
125
|
+
if (this.ctx.state === "suspended") {
|
|
126
|
+
try {
|
|
127
|
+
await this.ctx.resume();
|
|
128
|
+
} catch {
|
|
129
|
+
/* no user activation yet — stay silent, don't throw */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ctx = this.ctx;
|
|
134
|
+
const t0 = fromMs / 1000;
|
|
135
|
+
const startCtx = ctx.currentTime + 0.05; // small lead so scheduling lands
|
|
136
|
+
const segments = this.voiceoverSegments();
|
|
137
|
+
this.master.gain.value = this.muted ? 0 : 1;
|
|
138
|
+
|
|
139
|
+
for (const track of this.tracks) {
|
|
140
|
+
const buf = this.buffers.get(track.src);
|
|
141
|
+
if (!buf) continue;
|
|
142
|
+
|
|
143
|
+
const naturalMs = buf.duration * 1000 - track.seek;
|
|
144
|
+
const durMs = track.duration ?? Math.max(0, naturalMs);
|
|
145
|
+
const trackEnd = track.at + durMs;
|
|
146
|
+
if (t0 * 1000 >= trackEnd) continue; // already past this clip
|
|
147
|
+
|
|
148
|
+
// When (timeline ms) the clip actually begins sounding from the playhead.
|
|
149
|
+
const playStart = Math.max(t0 * 1000, track.at);
|
|
150
|
+
const delaySec = (playStart - t0 * 1000) / 1000;
|
|
151
|
+
const offsetInClip = (track.seek + (playStart - track.at)) / 1000;
|
|
152
|
+
const remainingSec = (trackEnd - playStart) / 1000;
|
|
153
|
+
if (remainingSec <= 0) continue;
|
|
154
|
+
|
|
155
|
+
// Map a timeline time (seconds) to this AudioContext's clock.
|
|
156
|
+
const toCtx = (timelineSec: number) =>
|
|
157
|
+
Math.max(startCtx, startCtx + (timelineSec - t0));
|
|
158
|
+
|
|
159
|
+
// Two gains, in series: the fade-in and the fade-out are separate
|
|
160
|
+
// automations that MULTIPLY where they overlap — the same as ffmpeg's two
|
|
161
|
+
// chained `afade` filters, and it keeps each curve's events on its own
|
|
162
|
+
// AudioParam so they can never collide.
|
|
163
|
+
const fadeGain = ctx.createGain();
|
|
164
|
+
const fadeOutGain = ctx.createGain();
|
|
165
|
+
fadeGain.connect(fadeOutGain);
|
|
166
|
+
this.applyFades(fadeGain, fadeOutGain, track, t0, toCtx);
|
|
167
|
+
|
|
168
|
+
let tail: AudioNode = fadeOutGain;
|
|
169
|
+
if (track.role === "music" && track.duck && segments.length) {
|
|
170
|
+
const duckGain = ctx.createGain();
|
|
171
|
+
this.applyDuck(duckGain, track.gain, segments, toCtx);
|
|
172
|
+
fadeOutGain.connect(duckGain);
|
|
173
|
+
tail = duckGain;
|
|
174
|
+
}
|
|
175
|
+
tail.connect(this.master);
|
|
176
|
+
|
|
177
|
+
// Optional effect (e.g. radio voice) sits between the source and the
|
|
178
|
+
// fade/duck gains, so it shapes the dry signal exactly like the ffmpeg
|
|
179
|
+
// render does.
|
|
180
|
+
const node = ctx.createBufferSource();
|
|
181
|
+
node.buffer = buf;
|
|
182
|
+
const radio = track.effect?.kind === "radio" ? buildRadioChain(ctx, track.effect) : null;
|
|
183
|
+
if (radio) {
|
|
184
|
+
node.connect(radio.input);
|
|
185
|
+
radio.output.connect(fadeGain);
|
|
186
|
+
} else {
|
|
187
|
+
node.connect(fadeGain);
|
|
188
|
+
}
|
|
189
|
+
node.start(startCtx + delaySec, offsetInClip, remainingSec);
|
|
190
|
+
this.active.push(node);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Per-track base gain + fade-in on `inGain`, fade-out on `outGain`, each
|
|
196
|
+
* shaped by its curve. `t0` is the playhead (seconds): a fade the playhead is
|
|
197
|
+
* already inside continues from where it is rather than restarting.
|
|
198
|
+
*/
|
|
199
|
+
private applyFades(
|
|
200
|
+
inGain: GainNode,
|
|
201
|
+
outGain: GainNode,
|
|
202
|
+
track: PreviewAudioTrack,
|
|
203
|
+
t0: number,
|
|
204
|
+
toCtx: (timelineSec: number) => number,
|
|
205
|
+
): void {
|
|
206
|
+
const base = track.role === "music" && track.duck ? 1 : track.gain;
|
|
207
|
+
const startSec = track.at / 1000;
|
|
208
|
+
const durMs = track.duration ?? 0;
|
|
209
|
+
const endSec = (track.at + durMs) / 1000;
|
|
210
|
+
const fadeInSec = Math.max(0, track.fadeIn) / 1000;
|
|
211
|
+
const fadeOutSec = Math.max(0, track.fadeOut) / 1000;
|
|
212
|
+
const inCurve = track.fadeInCurve ?? "linear";
|
|
213
|
+
const outCurve = track.fadeOutCurve ?? "linear";
|
|
214
|
+
|
|
215
|
+
// Fade-in (carries the base gain).
|
|
216
|
+
if (fadeInSec > 0) {
|
|
217
|
+
const inEnd = startSec + fadeInSec;
|
|
218
|
+
if (t0 >= inEnd) {
|
|
219
|
+
inGain.gain.setValueAtTime(base, toCtx(t0));
|
|
220
|
+
} else {
|
|
221
|
+
const from = Math.max(0, (t0 - startSec) / fadeInSec);
|
|
222
|
+
const at = Math.max(startSec, t0);
|
|
223
|
+
// A value event AT the curve's start time counts as an overlap and
|
|
224
|
+
// throws — hold silence only when the clip starts later than the playhead.
|
|
225
|
+
if (at > t0) inGain.gain.setValueAtTime(0, toCtx(t0));
|
|
226
|
+
inGain.gain.setValueCurveAtTime(fadeAutomation(inCurve, "in", base, from), toCtx(at), inEnd - at);
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
inGain.gain.setValueAtTime(base, toCtx(t0));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Fade-out (unity outside the fade).
|
|
233
|
+
const outStart = Math.max(startSec, endSec - fadeOutSec);
|
|
234
|
+
const hasOut = fadeOutSec > 0 && durMs > 0 && endSec > outStart && t0 < endSec;
|
|
235
|
+
const outAt = Math.max(outStart, t0);
|
|
236
|
+
if (!hasOut || outAt > t0) outGain.gain.setValueAtTime(1, toCtx(t0));
|
|
237
|
+
if (hasOut) {
|
|
238
|
+
const from = Math.max(0, (t0 - outStart) / (endSec - outStart));
|
|
239
|
+
outGain.gain.setValueCurveAtTime(fadeAutomation(outCurve, "out", 1, from), toCtx(outAt), endSec - outAt);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Ducking automation (scaled by the music track's base gain) on `g`. */
|
|
244
|
+
private applyDuck(
|
|
245
|
+
g: GainNode,
|
|
246
|
+
base: number,
|
|
247
|
+
segments: Segment[],
|
|
248
|
+
toCtx: (timelineSec: number) => number,
|
|
249
|
+
): void {
|
|
250
|
+
const points = duckAutomation(segments, base, this.duck);
|
|
251
|
+
let first = true;
|
|
252
|
+
for (const [timeSec, value] of points) {
|
|
253
|
+
if (first) {
|
|
254
|
+
g.gain.setValueAtTime(value, toCtx(timeSec));
|
|
255
|
+
first = false;
|
|
256
|
+
} else {
|
|
257
|
+
g.gain.linearRampToValueAtTime(value, toCtx(timeSec));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Stop all currently-playing sources (idempotent). */
|
|
263
|
+
stop(): void {
|
|
264
|
+
for (const node of this.active) {
|
|
265
|
+
try {
|
|
266
|
+
node.stop();
|
|
267
|
+
} catch {
|
|
268
|
+
/* already stopped */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
this.active = [];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
setMuted(muted: boolean): void {
|
|
275
|
+
this.muted = muted;
|
|
276
|
+
if (this.master) this.master.gain.value = muted ? 0 : 1;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function clamp01(v: number): number {
|
|
281
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Resolved "radio voice" parameters. The exact same formulas are mirrored by
|
|
286
|
+
* the ffmpeg render (worker `audio_mix.rs`) so preview == export:
|
|
287
|
+
* a band-pass (high-pass + low-pass) for the radio band, a low-shelf for
|
|
288
|
+
* "depth", and a tanh waveshaper (pushed by `pre`, tamed by `post`) for drive.
|
|
289
|
+
*/
|
|
290
|
+
export function radioParams(effect: { drive?: number; tone?: number; depth?: number }): {
|
|
291
|
+
hp: number;
|
|
292
|
+
lp: number;
|
|
293
|
+
shelfGainDb: number;
|
|
294
|
+
pre: number;
|
|
295
|
+
post: number;
|
|
296
|
+
k: number;
|
|
297
|
+
} {
|
|
298
|
+
const drive = clamp01(effect.drive ?? 0.4);
|
|
299
|
+
const tone = clamp01(effect.tone ?? 0.4);
|
|
300
|
+
const depth = clamp01(effect.depth ?? 0.5);
|
|
301
|
+
return {
|
|
302
|
+
hp: 500 - 350 * tone, // 500Hz (telephone) → 150Hz (open)
|
|
303
|
+
lp: 2400 + 2800 * tone, // 2400Hz (narrow) → 5200Hz (bright)
|
|
304
|
+
shelfGainDb: depth * 12, // up to +12 dB low-shelf
|
|
305
|
+
pre: 1 + drive * 3, // drive into the saturator
|
|
306
|
+
post: 1 / (1 + drive * 1.6), // make-up / tame
|
|
307
|
+
k: 1 + drive * 5, // tanh steepness
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function tanhCurve(k: number) {
|
|
312
|
+
const n = 1024;
|
|
313
|
+
const curve = new Float32Array(n);
|
|
314
|
+
for (let i = 0; i < n; i++) {
|
|
315
|
+
const x = (i / (n - 1)) * 2 - 1;
|
|
316
|
+
curve[i] = Math.tanh(k * x);
|
|
317
|
+
}
|
|
318
|
+
return curve;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function buildRadioChain(
|
|
322
|
+
ctx: AudioContext,
|
|
323
|
+
effect: { drive?: number; tone?: number; depth?: number },
|
|
324
|
+
): { input: AudioNode; output: AudioNode } {
|
|
325
|
+
const p = radioParams(effect);
|
|
326
|
+
const hp = ctx.createBiquadFilter();
|
|
327
|
+
hp.type = "highpass";
|
|
328
|
+
hp.frequency.value = p.hp;
|
|
329
|
+
const lp = ctx.createBiquadFilter();
|
|
330
|
+
lp.type = "lowpass";
|
|
331
|
+
lp.frequency.value = p.lp;
|
|
332
|
+
const shelf = ctx.createBiquadFilter();
|
|
333
|
+
shelf.type = "lowshelf";
|
|
334
|
+
shelf.frequency.value = 180;
|
|
335
|
+
shelf.gain.value = p.shelfGainDb;
|
|
336
|
+
const pre = ctx.createGain();
|
|
337
|
+
pre.gain.value = p.pre;
|
|
338
|
+
const shaper = ctx.createWaveShaper();
|
|
339
|
+
shaper.curve = tanhCurve(p.k);
|
|
340
|
+
shaper.oversample = "2x";
|
|
341
|
+
const post = ctx.createGain();
|
|
342
|
+
post.gain.value = p.post;
|
|
343
|
+
hp.connect(lp);
|
|
344
|
+
lp.connect(shelf);
|
|
345
|
+
shelf.connect(pre);
|
|
346
|
+
pre.connect(shaper);
|
|
347
|
+
shaper.connect(post);
|
|
348
|
+
return { input: hp, output: post };
|
|
349
|
+
}
|
package/src/ducking.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static-envelope ducking — the single source of truth for "music dips
|
|
3
|
+
* under voiceover" shared by the browser preview ([`audio.ts`]) and the
|
|
4
|
+
* Rust render worker (which mirrors this math when it builds the ffmpeg
|
|
5
|
+
* `volume` expression).
|
|
6
|
+
*
|
|
7
|
+
* The model is deliberately *static*: the dip is computed purely from the
|
|
8
|
+
* voiceover clips' timeline positions (`at` / `duration`), NOT from the
|
|
9
|
+
* voiceover's actual loudness. That makes it deterministic — the same
|
|
10
|
+
* timeline produces the same envelope every render — and, crucially, lets
|
|
11
|
+
* the browser preview reproduce the exact gain curve the final mp4 will
|
|
12
|
+
* have (a sidechain compressor could not be mirrored frame-for-frame in
|
|
13
|
+
* Web Audio).
|
|
14
|
+
*
|
|
15
|
+
* All times here are in **seconds** (Web Audio + ffmpeg both want seconds);
|
|
16
|
+
* the DSL stores milliseconds, so callers convert at the boundary.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Tunable ducking parameters. Defaults match the shorts editor. */
|
|
20
|
+
export type DuckParams = {
|
|
21
|
+
/** Music multiplier while voiceover is active. `0.25` ≈ −12 dB. */
|
|
22
|
+
level: number;
|
|
23
|
+
/** Linear ramp into / out of the dip, seconds. */
|
|
24
|
+
ramp: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_DUCK: DuckParams = { level: 0.25, ramp: 0.25 };
|
|
28
|
+
|
|
29
|
+
/** A half-open `[start, end)` interval on the timeline, seconds. */
|
|
30
|
+
export type Segment = { start: number; end: number };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Merge overlapping / touching voiceover segments into a disjoint, sorted
|
|
34
|
+
* set. Merging is what makes the preview automation and the ffmpeg
|
|
35
|
+
* expression agree even when voiceover clips overlap: a union of regions
|
|
36
|
+
* is unambiguous, whereas per-clip ramps could otherwise fight each other.
|
|
37
|
+
*/
|
|
38
|
+
export function mergeSegments(segments: Segment[]): Segment[] {
|
|
39
|
+
const sorted = segments
|
|
40
|
+
.filter((s) => s.end > s.start)
|
|
41
|
+
.sort((a, b) => a.start - b.start);
|
|
42
|
+
const out: Segment[] = [];
|
|
43
|
+
for (const s of sorted) {
|
|
44
|
+
const last = out[out.length - 1];
|
|
45
|
+
if (last && s.start <= last.end) {
|
|
46
|
+
last.end = Math.max(last.end, s.end);
|
|
47
|
+
} else {
|
|
48
|
+
out.push({ ...s });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The ducking multiplier at time `t` (seconds) for a music track, given the
|
|
56
|
+
* merged voiceover `segments`. `1` = full volume, `level` = fully ducked,
|
|
57
|
+
* with linear ramps of width `ramp` on each edge. Used by the preview to
|
|
58
|
+
* sample the curve and by tests to assert the Rust expression matches.
|
|
59
|
+
*/
|
|
60
|
+
export function duckMultiplier(
|
|
61
|
+
t: number,
|
|
62
|
+
segments: Segment[],
|
|
63
|
+
duck: DuckParams = DEFAULT_DUCK,
|
|
64
|
+
): number {
|
|
65
|
+
let m = 1;
|
|
66
|
+
for (const { start, end } of segments) {
|
|
67
|
+
const seg = segMultiplier(t, start, end, duck);
|
|
68
|
+
if (seg < m) m = seg;
|
|
69
|
+
}
|
|
70
|
+
return m;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function segMultiplier(t: number, s: number, e: number, duck: DuckParams): number {
|
|
74
|
+
const { level: d, ramp: r } = duck;
|
|
75
|
+
if (t <= s - r || t >= e + r) return 1;
|
|
76
|
+
if (t < s) return 1 - (1 - d) * (t - (s - r)) / r; // ramp down
|
|
77
|
+
if (t <= e) return d; // hold
|
|
78
|
+
return d + (1 - d) * (t - e) / r; // ramp up
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Gain-automation breakpoints `[time, value]` for a music track's
|
|
83
|
+
* `GainNode`, scaled by `base` gain. The preview replays these with
|
|
84
|
+
* `setValueAtTime` / `linearRampToValueAtTime` so the dip in the player
|
|
85
|
+
* matches the dip baked into the render. Times are absolute timeline
|
|
86
|
+
* seconds.
|
|
87
|
+
*/
|
|
88
|
+
export function duckAutomation(
|
|
89
|
+
segments: Segment[],
|
|
90
|
+
base: number,
|
|
91
|
+
duck: DuckParams = DEFAULT_DUCK,
|
|
92
|
+
): Array<[number, number]> {
|
|
93
|
+
const merged = mergeSegments(segments);
|
|
94
|
+
const points: Array<[number, number]> = [[0, base]];
|
|
95
|
+
const { level: d, ramp: r } = duck;
|
|
96
|
+
for (const { start, end } of merged) {
|
|
97
|
+
points.push([Math.max(0, start - r), base]); // begin ramp down
|
|
98
|
+
points.push([start, base * d]); // fully ducked
|
|
99
|
+
points.push([end, base * d]); // hold to clip end
|
|
100
|
+
points.push([end + r, base]); // ramp back up
|
|
101
|
+
}
|
|
102
|
+
return points;
|
|
103
|
+
}
|
package/src/fades.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fade curves for audio tracks — the shape of a fade-in / fade-out, not just
|
|
3
|
+
* its length.
|
|
4
|
+
*
|
|
5
|
+
* The render worker mixes with ffmpeg's `afade`, so these are ffmpeg's OWN
|
|
6
|
+
* curve formulas (libavfilter `af_afade.c`, `fade_gain`), evaluated here for the
|
|
7
|
+
* browser preview. The worker maps each name to the matching `afade` curve
|
|
8
|
+
* (`audio_mix.rs`, `ffmpeg_fade_curve`), which is what keeps preview == export.
|
|
9
|
+
*
|
|
10
|
+
* | name | afade | shape |
|
|
11
|
+
* |---------------|-------|----------------------------------------------------|
|
|
12
|
+
* | `linear` | tri | straight gain ramp — a dip in the middle |
|
|
13
|
+
* | `equal-power` | qsin | quarter sine — constant perceived loudness |
|
|
14
|
+
* | `s-curve` | hsin | half sine — gentle at both ends |
|
|
15
|
+
* | `exponential` | exp | slow start, fast finish (~-100 dB floor) |
|
|
16
|
+
* | `logarithmic` | log | fast start, slow finish |
|
|
17
|
+
*
|
|
18
|
+
* `linear` is the default because it is what `afade` does when no curve is
|
|
19
|
+
* named, so a track written before curves existed renders unchanged.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type FadeCurve = "linear" | "equal-power" | "s-curve" | "exponential" | "logarithmic";
|
|
23
|
+
|
|
24
|
+
/** Every curve, in the order a picker should offer them. */
|
|
25
|
+
export const FADE_CURVES: readonly FadeCurve[] = [
|
|
26
|
+
"linear",
|
|
27
|
+
"equal-power",
|
|
28
|
+
"s-curve",
|
|
29
|
+
"exponential",
|
|
30
|
+
"logarithmic",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Resolve an untrusted value to a known curve (unknown ⇒ `linear`). */
|
|
34
|
+
export function resolveFadeCurve(value: unknown): FadeCurve {
|
|
35
|
+
return typeof value === "string" && (FADE_CURVES as readonly string[]).includes(value)
|
|
36
|
+
? (value as FadeCurve)
|
|
37
|
+
: "linear";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Gain (0..1) of a FADE-IN at progress `x` (0 = start of the fade, 1 = end).
|
|
42
|
+
* A fade-out at progress `p` is `fadeGain(curve, 1 - p)` — exactly how `afade`
|
|
43
|
+
* runs its curve backwards for `t=out`.
|
|
44
|
+
*/
|
|
45
|
+
export function fadeGain(curve: FadeCurve, x: number): number {
|
|
46
|
+
const g = x <= 0 ? 0 : x >= 1 ? 1 : x;
|
|
47
|
+
switch (curve) {
|
|
48
|
+
case "equal-power":
|
|
49
|
+
return Math.sin((g * Math.PI) / 2);
|
|
50
|
+
case "s-curve":
|
|
51
|
+
return (1 - Math.cos(g * Math.PI)) / 2;
|
|
52
|
+
case "exponential":
|
|
53
|
+
return g >= 1 ? 1 : Math.exp(-11.512925464970227 * (1 - g));
|
|
54
|
+
case "logarithmic":
|
|
55
|
+
return g <= 0 ? 0 : Math.min(1, Math.max(0, 1 + 0.2 * Math.log10(g)));
|
|
56
|
+
case "linear":
|
|
57
|
+
default:
|
|
58
|
+
return g;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sampled gain automation for `AudioParam.setValueCurveAtTime`, scaled by
|
|
64
|
+
* `base`. `from` lets a curve start part-way through (playback that begins in
|
|
65
|
+
* the middle of a fade), so the preview never restarts a fade from silence.
|
|
66
|
+
*/
|
|
67
|
+
export function fadeAutomation(
|
|
68
|
+
curve: FadeCurve,
|
|
69
|
+
direction: "in" | "out",
|
|
70
|
+
base: number,
|
|
71
|
+
from = 0,
|
|
72
|
+
samples = 128,
|
|
73
|
+
): Float32Array {
|
|
74
|
+
const out = new Float32Array(Math.max(2, samples));
|
|
75
|
+
const start = from <= 0 ? 0 : from >= 1 ? 1 : from;
|
|
76
|
+
for (let i = 0; i < out.length; i++) {
|
|
77
|
+
const p = start + ((1 - start) * i) / (out.length - 1);
|
|
78
|
+
out[i] = base * fadeGain(curve, direction === "in" ? p : 1 - p);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|