From 31305d443d2d2ec6913036e3bfb8c8b4f81ef9ad Mon Sep 17 00:00:00 2001 From: cbaucom Date: Wed, 10 Jun 2026 23:11:36 -0400 Subject: [PATCH 1/3] Add video-audio ducking envelope and per-slide audio overrides. Planner now emits ramped soundtrack ducking around video spans; composition and slide settings apply the mix controls from issue #12. Co-authored-by: Cursor --- specs/issue-12-video-audio-ducking.md | 46 +++++++ src/composition/SlideshowComposition.tsx | 14 ++- src/composition/soundtrackVolume.ts | 45 +++++++ src/editor-shell/SlideSettingsDialog.tsx | 88 +++++++++++++ src/sequence-planner/ducking.test.ts | 124 +++++++++++++++++++ src/sequence-planner/ducking.ts | 151 +++++++++++++++++++++++ src/sequence-planner/index.ts | 2 +- src/sequence-planner/planner.test.ts | 9 +- src/sequence-planner/planner.ts | 14 ++- src/sequence-planner/types.ts | 20 ++- src/timeline-core/index.ts | 9 +- src/timeline-core/settings.ts | 9 +- 12 files changed, 520 insertions(+), 11 deletions(-) create mode 100644 specs/issue-12-video-audio-ducking.md create mode 100644 src/composition/soundtrackVolume.ts create mode 100644 src/sequence-planner/ducking.test.ts create mode 100644 src/sequence-planner/ducking.ts diff --git a/specs/issue-12-video-audio-ducking.md b/specs/issue-12-video-audio-ducking.md new file mode 100644 index 0000000..d42b3c0 --- /dev/null +++ b/specs/issue-12-video-audio-ducking.md @@ -0,0 +1,46 @@ +# Issue 12 — Video-audio ducking + per-slide audio overrides + +## Scope + +Planner computes a soundtrack ducking envelope; Composition applies volume curves. Per-slide audio overrides live in `SlideSettingsDialog`. + +## Types + +### SlideOverrides (timeline-core) + +| Field | Type | Effect | +| --- | --- | --- | +| `muteMusic` | `boolean?` | Soundtrack silent for slide span | +| `muteVideoAudio` | `boolean?` | Video plays muted; no ducking | +| `musicVolume` | `number?` | Custom soundtrack level (0–1) during slide | +| `videoVolume` | `number?` | Custom video audio level (0–1); default 1 | + +### RenderPlan (sequence-planner) + +- `SoundtrackTrack.duckingEnvelope`: `{ keyframes, rampFrames, segments }` +- `RenderPlanEntry.videoVolume`: resolved 0–1 for video slides + +Constants: `DUCK_LEVEL = 0.2`, `DUCK_RAMP_FRAMES = 6`. + +## Ducking rules + +1. Video slide, default: duck music to 20% for slide span with 6-frame attack/release ramps. +2. `muteVideoAudio`: music stays at full level; `videoVolume = 0`. +3. `muteMusic`: music at 0 for slide span; video audio unchanged unless `videoVolume` set. +4. `musicVolume` override replaces duck level (or sets level on non-video slides). +5. `videoVolume` override sets video playback volume. + +## Composition + +- `Html5Audio` uses envelope keyframes interpolated by composition frame. +- `Video` uses `volume` / `muted` from entry `videoVolume`. + +## UI + +`SlideSettingsDialog`: audio section on all slides; video-only mute-video control. + +## Tests + +- Golden: photo–video–photo produces one duck segment aligned to video entry. +- Override: muteVideoAudio → no segment; muteMusic → keyframes at 0. +- Property: duck segments exactly cover unmuted, non–mute-music video spans. diff --git a/src/composition/SlideshowComposition.tsx b/src/composition/SlideshowComposition.tsx index 81b7b95..f3181f7 100644 --- a/src/composition/SlideshowComposition.tsx +++ b/src/composition/SlideshowComposition.tsx @@ -9,6 +9,7 @@ import type { RenderPlanEntry, RenderPlan } from '../sequence-planner/types' import type { TransitionType } from '../timeline-core/settings' import { isTitleSlide } from '../timeline-core/types' import type { MediaSlide, TitleSlide } from '../timeline-core/types' +import { useSoundtrackVolume } from './soundtrackVolume' type MediaRenderPlanEntry = Omit & { slide: MediaSlide } type TitleRenderPlanEntry = Omit & { slide: TitleSlide } @@ -62,7 +63,7 @@ export function SlideshowComposition({ plan }: SlideshowProps) { return ( {plan.soundtrack ? ( - + ) : null} {plan.entries.map((entry) => ( @@ -87,6 +88,11 @@ export function SlideshowComposition({ plan }: SlideshowProps) { ) } +function SoundtrackAudio({ track }: { track: NonNullable }) { + const volume = useSoundtrackVolume(track.duckingEnvelope) + return +} + function TitleSlideView({ entry }: { entry: TitleRenderPlanEntry }) { const { slide } = entry const bg = slide.style === 'light' ? '#fff' : '#111' @@ -130,7 +136,7 @@ function TitleSlideView({ entry }: { entry: TitleRenderPlanEntry }) { } function MediaSlideView({ entry }: { entry: MediaRenderPlanEntry }) { - const { slide, fitMode, kenBurns, durationInFrames } = entry + const { fitMode, kenBurns, durationInFrames, slide, videoVolume } = entry const frame = useCurrentFrame() // Compute Ken Burns transform (images only; null for videos). @@ -152,8 +158,10 @@ function MediaSlideView({ entry }: { entry: MediaRenderPlanEntry }) { return ( ) diff --git a/src/composition/soundtrackVolume.ts b/src/composition/soundtrackVolume.ts new file mode 100644 index 0000000..46773f7 --- /dev/null +++ b/src/composition/soundtrackVolume.ts @@ -0,0 +1,45 @@ +import { interpolate, useCurrentFrame } from 'remotion' +import type { DuckingEnvelope, VolumeKeyframe } from '../sequence-planner/types' + +export function volumeAtFrame(envelope: DuckingEnvelope, frame: number): number { + const { keyframes, rampFrames } = envelope + if (keyframes.length === 0) return 1 + + let active = keyframes[0] + let next = keyframes[0] + + for (let index = 0; index < keyframes.length; index++) { + if (keyframes[index].frame <= frame) { + active = keyframes[index] + next = keyframes[Math.min(index + 1, keyframes.length - 1)] + continue + } + next = keyframes[index] + break + } + + if (active.frame === next.frame || frame <= active.frame) { + return active.volume + } + + const rampEndFrame = Math.min(active.frame + rampFrames, next.frame) + if (frame >= next.frame) { + return next.volume + } + if (frame <= rampEndFrame && active.volume !== next.volume) { + return interpolate(frame, [active.frame, rampEndFrame], [active.volume, next.volume], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }) + } + + return active.volume +} + +export function useSoundtrackVolume(envelope: VolumeKeyframe[] | DuckingEnvelope, rampFrames = 6) { + const frame = useCurrentFrame() + const duckingEnvelope = Array.isArray(envelope) + ? { keyframes: envelope, rampFrames, segments: [] } + : envelope + return volumeAtFrame(duckingEnvelope, frame) +} diff --git a/src/editor-shell/SlideSettingsDialog.tsx b/src/editor-shell/SlideSettingsDialog.tsx index dbe8f5d..bde708c 100644 --- a/src/editor-shell/SlideSettingsDialog.tsx +++ b/src/editor-shell/SlideSettingsDialog.tsx @@ -115,6 +115,94 @@ export function SlideSettingsDialog({ globalSettings, onClose, onOverride, slide ) : null} +
+ Mute music + { + if (e.target.checked) { + setField('muteMusic', true) + return + } + clearField('muteMusic') + }} + type="checkbox" + /> + {ov.muteMusic !== undefined + ? + : off + } +
+ +
+ Music level + { + const value = Number(e.target.value) / 100 + if (Number.isNaN(value)) return + setField('musicVolume', value) + }} + step={5} + type="range" + value={Math.round((ov.musicVolume ?? 1) * 100)} + /> + {Math.round((ov.musicVolume ?? 1) * 100)}% + {ov.musicVolume !== undefined + ? + : auto + } +
+ + {slide.type === 'video' ? ( + <> +
+ Mute video audio + { + if (e.target.checked) { + setField('muteVideoAudio', true) + return + } + clearField('muteVideoAudio') + }} + type="checkbox" + /> + {ov.muteVideoAudio !== undefined + ? + : off + } +
+ +
+ Video level + { + const value = Number(e.target.value) / 100 + if (Number.isNaN(value)) return + setField('videoVolume', value) + }} + step={5} + type="range" + value={Math.round((ov.videoVolume ?? 1) * 100)} + /> + {Math.round((ov.videoVolume ?? 1) * 100)}% + {ov.videoVolume !== undefined + ? + : 100% + } +
+ + ) : null} +
{hasOverrides ? (