diff --git a/web/src/components/preview/previewEngine.ts b/web/src/components/preview/previewEngine.ts index 674ae7b..ce879aa 100644 --- a/web/src/components/preview/previewEngine.ts +++ b/web/src/components/preview/previewEngine.ts @@ -23,7 +23,7 @@ import { activeAudioClips, activeVisualClips, advancePlayhead, - clipVolume, + clipVolumeAt, frameForSourceTime, isExternalSeekWhilePlaying, shouldUseRustEngine, @@ -327,10 +327,16 @@ export function useTimelinePlaybackEngine(): void { const key = previewElementKey(m); const el = previewElements.get(key); if (!el) continue; // images carry no media element - const vol = clipVolume(m.track, m.clip); + // Frame-aware gain: static volume x dB keyframe automation x fade ramp + // (Clip::volume_at). The true gain can exceed 1 (boosted keyframes / a + // >1 static volume) but HTMLMediaElement.volume is capped to [0,1] and + // throws a RangeError above 1, so the clamp lives here at the + // assignment, not inside the pure helper. + // TODO(>0dB): route through a Web Audio GainNode to make >0 dB boosts audible. + const gain = clipVolumeAt(m.track, m.clip, r); const isVisualVideo = visuals.some((visual) => visual.clip.id === m.clip.id); - el.muted = vol <= 0 || (isVisualVideo && duplicatedVisualAudioRefs.has(m.clip.mediaRef)); - el.volume = vol; + el.muted = gain <= 0 || (isVisualVideo && duplicatedVisualAudioRefs.has(m.clip.mediaRef)); + el.volume = Math.min(1, gain); const desired = sourceTimeSec(m.clip, f, fps); const previousClipId = lastClipByKey.get(key) ?? null; lastClipByKey.set(key, m.clip.id); diff --git a/web/src/components/preview/timelinePlayback.test.ts b/web/src/components/preview/timelinePlayback.test.ts index 04629a5..ef088a0 100644 --- a/web/src/components/preview/timelinePlayback.test.ts +++ b/web/src/components/preview/timelinePlayback.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { linearFromDb } from "../../lib/clip"; import type { Clip, ClipType, Timeline, Track } from "../../lib/types"; import { activeAudioClips, @@ -8,6 +9,7 @@ import { clipCoversFrame, clipOpacity, clipVolume, + clipVolumeAt, frameForSourceTime, isExternalSeekWhilePlaying, playbackFrameFromActiveFrame, @@ -222,6 +224,65 @@ describe("clipVolume / clipOpacity", () => { }); }); +describe("clipVolumeAt (frame-aware envelope + fade, T2-7)", () => { + it("zeroes gain on a muted track regardless of the clip's envelope", () => { + const t = track({ id: "a", type: "audio", muted: true, clips: [] }); + const c = clip({ id: "c", mediaType: "audio", volume: 1 }); + expect(clipVolumeAt(t, c, 50)).toBe(0); + }); + + it("passes through the static volume when there is no keyframe track or fade", () => { + const t = track({ id: "a", type: "audio", clips: [] }); + const c = clip({ id: "c", mediaType: "audio", volume: 0.5, startFrame: 0, durationFrames: 100 }); + expect(clipVolumeAt(t, c, 50)).toBeCloseTo(0.5); + }); + + it("follows a dB volume keyframe via linearFromDb at a sampled frame", () => { + const t = track({ id: "a", type: "audio", clips: [] }); + const c = clip({ + id: "c", + mediaType: "audio", + volume: 1, + startFrame: 0, + durationFrames: 100, + volumeTrack: { + keyframes: [{ frame: 0, value: -6, interpolationOut: "hold" }], + }, + }); + // hold interpolation -> flat -6dB across the clip; gain = 1 * linearFromDb(-6). + expect(clipVolumeAt(t, c, 50)).toBeCloseTo(linearFromDb(-6)); + expect(clipVolumeAt(t, c, 50)).toBeLessThan(1); + }); + + it("ramps 0 -> full gain across fadeInFrames", () => { + const t = track({ id: "a", type: "audio", clips: [] }); + const c = clip({ + id: "c", + mediaType: "audio", + volume: 1, + startFrame: 0, + durationFrames: 100, + fadeInFrames: 10, + fadeInInterpolation: "linear", + }); + expect(clipVolumeAt(t, c, 0)).toBeCloseTo(0); + expect(clipVolumeAt(t, c, 5)).toBeCloseTo(0.5); + expect(clipVolumeAt(t, c, 10)).toBeCloseTo(1); + }); + + it("preserves a >1 static-volume boost pre-clamp (the [0,1] clamp is the caller's job)", () => { + const t = track({ id: "a", type: "audio", clips: [] }); + const c = clip({ id: "c", mediaType: "audio", volume: 4, startFrame: 0, durationFrames: 100 }); + expect(clipVolumeAt(t, c, 50)).toBeCloseTo(4); + }); + + it("falls back to 1 for a non-finite gain", () => { + const t = track({ id: "a", type: "audio", clips: [] }); + const c = clip({ id: "c", mediaType: "audio", volume: NaN, startFrame: 0, durationFrames: 100 }); + expect(clipVolumeAt(t, c, 50)).toBe(1); + }); +}); + describe("visualAudioIsDuplicated", () => { it("flags a video whose source is also on an audio track", () => { const visual = { clip: clip({ id: "v", mediaType: "video", mediaRef: "m1" }), track: track({ id: "v1", type: "video", clips: [] }), trackIndex: 0 }; diff --git a/web/src/components/preview/timelinePlayback.ts b/web/src/components/preview/timelinePlayback.ts index fc724b4..1b35193 100644 --- a/web/src/components/preview/timelinePlayback.ts +++ b/web/src/components/preview/timelinePlayback.ts @@ -10,6 +10,7 @@ * frame instead of switching to a separate ffmpeg/PNG render path. */ +import { volumeAt } from "../../lib/clip"; import type { Clip, Timeline, Track } from "../../lib/types"; /** A clip selected for playback at a frame, with its track context. */ @@ -111,6 +112,23 @@ export function clipVolume(track: Track, clip: Clip): number { return Math.max(0, Math.min(1, Number.isFinite(v) ? v : 1)); } +/** + * Frame-aware playback gain for a clip: 0 when the track is muted, otherwise + * the true `volumeAt` envelope (static volume × dB keyframe automation × + * fade-in/out ramp — `Clip::volume_at` / `Timeline.swift:189`). Unlike + * {@link clipVolume} this is NOT clamped to 1 — callers that assign to + * `HTMLMediaElement.volume` must clamp at the assignment site, since that + * setter throws a RangeError above 1. Floors at 0 (upstream never emits a + * negative gain, but this guards a still-authored fade edge) and falls back + * to 1 for a non-finite result (matches `clipVolume`'s NaN guard). + */ +export function clipVolumeAt(track: Track, clip: Clip, frame: number): number { + if (track.muted) return 0; + const gain = volumeAt(clip, frame); + if (!Number.isFinite(gain)) return 1; + return Math.max(0, gain); +} + /** Effective 0–1 opacity for a clip (clamped; defaults to 1). */ export function clipOpacity(clip: Clip): number { const o = clip.opacity;