diff --git a/videos/src/config/audioSync.test.ts b/videos/src/config/audioSync.test.ts new file mode 100644 index 0000000..8f08a26 --- /dev/null +++ b/videos/src/config/audioSync.test.ts @@ -0,0 +1,673 @@ +import { describe, it, expect } from "vitest"; +import { + STANDARD_FPS, + TOTAL_DURATION_FRAMES, + TOTAL_DURATION_SECONDS, + BACKGROUND_MUSIC_CONFIG, + SCENE_START_FRAMES, + SCENE_AUDIO_CONFIGS, + LOGO_REVEAL_AUDIO, + PORTAL_TITLE_AUDIO, + DASHBOARD_OVERVIEW_AUDIO, + MEETUP_SHOWCASE_AUDIO, + COUNTRY_STATS_AUDIO, + TOP_MEETUPS_AUDIO, + ACTIVITY_FEED_AUDIO, + CALL_TO_ACTION_AUDIO, + OUTRO_AUDIO, + calculateSceneStartFrames, + getSceneAudioEvents, + getAbsoluteAudioFrame, + getAllAudioEventsAbsolute, + calculateBackgroundMusicVolume, + isAudioEventInSync, + getAudioTimingDeviation, + frameDeviationToMs, + type AudioEvent, +} from "./audioSync"; +import { SCENE_DURATIONS, secondsToFrames } from "./timing"; + +describe("Audio Sync Configuration Constants", () => { + it("defines correct standard FPS", () => { + expect(STANDARD_FPS).toBe(30); + }); + + it("defines correct total duration in frames", () => { + expect(TOTAL_DURATION_FRAMES).toBe(2700); + }); + + it("defines correct total duration in seconds", () => { + expect(TOTAL_DURATION_SECONDS).toBe(90); + }); + + it("duration frames equals seconds times FPS", () => { + expect(TOTAL_DURATION_FRAMES).toBe(TOTAL_DURATION_SECONDS * STANDARD_FPS); + }); +}); + +describe("Background Music Configuration", () => { + it("has correct audio file path", () => { + expect(BACKGROUND_MUSIC_CONFIG.audioFile).toBe("music/background-music.mp3"); + }); + + it("has correct base volume", () => { + expect(BACKGROUND_MUSIC_CONFIG.baseVolume).toBe(0.25); + }); + + it("has 1 second fade-in duration", () => { + expect(BACKGROUND_MUSIC_CONFIG.fadeInDuration).toBe(1); + }); + + it("has 3 second fade-out duration", () => { + expect(BACKGROUND_MUSIC_CONFIG.fadeOutDuration).toBe(3); + }); + + it("is configured to loop", () => { + expect(BACKGROUND_MUSIC_CONFIG.loop).toBe(true); + }); + + it("fade-in frames equals duration times FPS", () => { + const fadeInFrames = BACKGROUND_MUSIC_CONFIG.fadeInDuration * STANDARD_FPS; + expect(fadeInFrames).toBe(30); + }); + + it("fade-out frames equals duration times FPS", () => { + const fadeOutFrames = BACKGROUND_MUSIC_CONFIG.fadeOutDuration * STANDARD_FPS; + expect(fadeOutFrames).toBe(90); + }); +}); + +describe("Scene Start Frames Calculation", () => { + it("Logo Reveal starts at frame 0", () => { + expect(SCENE_START_FRAMES.LOGO_REVEAL).toBe(0); + }); + + it("Portal Title starts after Logo Reveal", () => { + const expectedFrame = SCENE_DURATIONS.LOGO_REVEAL * STANDARD_FPS; + expect(SCENE_START_FRAMES.PORTAL_TITLE).toBe(expectedFrame); + expect(SCENE_START_FRAMES.PORTAL_TITLE).toBe(180); + }); + + it("Dashboard Overview starts after Portal Title", () => { + const expectedFrame = + (SCENE_DURATIONS.LOGO_REVEAL + SCENE_DURATIONS.PORTAL_TITLE) * STANDARD_FPS; + expect(SCENE_START_FRAMES.DASHBOARD_OVERVIEW).toBe(expectedFrame); + expect(SCENE_START_FRAMES.DASHBOARD_OVERVIEW).toBe(300); + }); + + it("Meine Meetups starts at correct frame", () => { + const expectedFrame = + (SCENE_DURATIONS.LOGO_REVEAL + + SCENE_DURATIONS.PORTAL_TITLE + + SCENE_DURATIONS.DASHBOARD_OVERVIEW) * + STANDARD_FPS; + expect(SCENE_START_FRAMES.MEINE_MEETUPS).toBe(expectedFrame); + expect(SCENE_START_FRAMES.MEINE_MEETUPS).toBe(660); + }); + + it("Top Laender starts at correct frame", () => { + expect(SCENE_START_FRAMES.TOP_LAENDER).toBe(1020); + }); + + it("Top Meetups starts at correct frame", () => { + expect(SCENE_START_FRAMES.TOP_MEETUPS).toBe(1380); + }); + + it("Activity Feed starts at correct frame", () => { + expect(SCENE_START_FRAMES.ACTIVITY_FEED).toBe(1680); + }); + + it("Call to Action starts at correct frame", () => { + expect(SCENE_START_FRAMES.CALL_TO_ACTION).toBe(1980); + }); + + it("Outro starts at correct frame", () => { + expect(SCENE_START_FRAMES.OUTRO).toBe(2340); + }); + + it("all scenes sum to total duration", () => { + const lastSceneStart = SCENE_START_FRAMES.OUTRO; + const lastSceneDuration = SCENE_DURATIONS.OUTRO * STANDARD_FPS; + expect(lastSceneStart + lastSceneDuration).toBe(TOTAL_DURATION_FRAMES); + }); + + it("calculateSceneStartFrames returns same values at 30fps", () => { + const calculated = calculateSceneStartFrames(30); + expect(calculated).toEqual(SCENE_START_FRAMES); + }); + + it("calculateSceneStartFrames scales with different FPS", () => { + const calculated60fps = calculateSceneStartFrames(60); + expect(calculated60fps.LOGO_REVEAL).toBe(0); + expect(calculated60fps.PORTAL_TITLE).toBe(360); // 6 seconds * 60fps + expect(calculated60fps.DASHBOARD_OVERVIEW).toBe(600); // (6 + 4) * 60fps + }); +}); + +describe("Logo Reveal Audio Events", () => { + it("has correct number of audio events", () => { + expect(LOGO_REVEAL_AUDIO.length).toBe(2); + }); + + it("logo-whoosh starts at frame 0", () => { + const event = LOGO_REVEAL_AUDIO.find((e) => e.id === "logo-whoosh"); + expect(event).toBeDefined(); + expect(event!.startFrame).toBe(0); + expect(event!.volume).toBe(0.7); + expect(event!.toleranceFrames).toBe(0); + }); + + it("logo-reveal starts at correct delay", () => { + const event = LOGO_REVEAL_AUDIO.find((e) => e.id === "logo-reveal"); + expect(event).toBeDefined(); + expect(event!.startFrame).toBe(15); // 0.5 seconds + expect(event!.volume).toBe(0.6); + }); + + it("all events have required properties", () => { + for (const event of LOGO_REVEAL_AUDIO) { + expect(event.id).toBeDefined(); + expect(event.audioFile).toMatch(/^sfx\//); + expect(event.startFrame).toBeGreaterThanOrEqual(0); + expect(event.durationInFrames).toBeGreaterThan(0); + expect(event.volume).toBeGreaterThan(0); + expect(event.volume).toBeLessThanOrEqual(1); + expect(event.visualSyncTarget).toBeDefined(); + } + }); +}); + +describe("Portal Title Audio Events", () => { + it("has ui-appear sound", () => { + const event = PORTAL_TITLE_AUDIO.find((e) => e.id === "title-ui-appear"); + expect(event).toBeDefined(); + expect(event!.audioFile).toBe("sfx/ui-appear.mp3"); + }); +}); + +describe("Dashboard Overview Audio Events", () => { + it("has card slide sounds for staggered cards", () => { + const cardSlideEvents = DASHBOARD_OVERVIEW_AUDIO.filter((e) => + e.id.includes("card-slide") + ); + expect(cardSlideEvents.length).toBe(2); + }); + + it("card slides are staggered to avoid overlap", () => { + const slide1 = DASHBOARD_OVERVIEW_AUDIO.find((e) => e.id === "dashboard-card-slide-1"); + const slide2 = DASHBOARD_OVERVIEW_AUDIO.find((e) => e.id === "dashboard-card-slide-2"); + expect(slide1).toBeDefined(); + expect(slide2).toBeDefined(); + // Second card starts after first card audio finishes + expect(slide2!.startFrame).toBeGreaterThanOrEqual( + slide1!.startFrame + slide1!.durationInFrames + ); + }); +}); + +describe("Call to Action Audio Events", () => { + it("has success fanfare for celebration", () => { + const event = CALL_TO_ACTION_AUDIO.find((e) => e.id === "cta-success-fanfare"); + expect(event).toBeDefined(); + expect(event!.volume).toBe(0.6); + expect(event!.durationInFrames).toBe(90); // 3 seconds + }); + + it("has URL emphasis audio synced with typing", () => { + const event = CALL_TO_ACTION_AUDIO.find((e) => e.id === "cta-url-emphasis"); + expect(event).toBeDefined(); + expect(event!.startFrame).toBe(75); // 2.5 seconds + }); + + it("has final chime for button emphasis", () => { + const event = CALL_TO_ACTION_AUDIO.find((e) => e.id === "cta-final-chime"); + expect(event).toBeDefined(); + expect(event!.startFrame).toBe(180); // 6 seconds into scene + }); +}); + +describe("Outro Audio Events", () => { + it("outro-entrance starts immediately", () => { + const event = OUTRO_AUDIO.find((e) => e.id === "outro-entrance"); + expect(event).toBeDefined(); + expect(event!.startFrame).toBe(0); + expect(event!.toleranceFrames).toBe(0); + }); + + it("outro logo reveal syncs with logo animation", () => { + const event = OUTRO_AUDIO.find((e) => e.id === "outro-logo-reveal"); + expect(event).toBeDefined(); + expect(event!.startFrame).toBe(30); // 1 second + }); +}); + +describe("Scene Audio Configurations", () => { + it("has configurations for all 9 scenes", () => { + expect(SCENE_AUDIO_CONFIGS.length).toBe(9); + }); + + it("all scenes have correct start frames", () => { + for (const config of SCENE_AUDIO_CONFIGS) { + expect(config.sceneStartFrame).toBe(SCENE_START_FRAMES[config.sceneId]); + } + }); + + it("all scenes have matching duration frames", () => { + for (const config of SCENE_AUDIO_CONFIGS) { + const expectedDuration = + SCENE_DURATIONS[config.sceneId as keyof typeof SCENE_DURATIONS] * STANDARD_FPS; + expect(config.sceneDurationInFrames).toBe(expectedDuration); + } + }); + + it("no audio events exceed scene duration", () => { + for (const config of SCENE_AUDIO_CONFIGS) { + for (const event of config.audioEvents) { + const eventEnd = event.startFrame + event.durationInFrames; + expect(eventEnd).toBeLessThanOrEqual(config.sceneDurationInFrames); + } + } + }); + + it("all audio event IDs are unique across scenes", () => { + const allIds = SCENE_AUDIO_CONFIGS.flatMap((c) => c.audioEvents.map((e) => e.id)); + const uniqueIds = new Set(allIds); + expect(uniqueIds.size).toBe(allIds.length); + }); +}); + +describe("getSceneAudioEvents", () => { + it("returns audio events for valid scene", () => { + const events = getSceneAudioEvents("LOGO_REVEAL"); + expect(events).toEqual(LOGO_REVEAL_AUDIO); + }); + + it("returns empty array for invalid scene", () => { + const events = getSceneAudioEvents("INVALID_SCENE"); + expect(events).toEqual([]); + }); + + it("returns events for all scenes", () => { + const sceneIds = [ + "LOGO_REVEAL", + "PORTAL_TITLE", + "DASHBOARD_OVERVIEW", + "MEINE_MEETUPS", + "TOP_LAENDER", + "TOP_MEETUPS", + "ACTIVITY_FEED", + "CALL_TO_ACTION", + "OUTRO", + ]; + for (const sceneId of sceneIds) { + const events = getSceneAudioEvents(sceneId); + expect(events.length).toBeGreaterThan(0); + } + }); +}); + +describe("getAbsoluteAudioFrame", () => { + it("returns correct absolute frame for logo-whoosh", () => { + const frame = getAbsoluteAudioFrame("LOGO_REVEAL", "logo-whoosh"); + expect(frame).toBe(0); + }); + + it("returns correct absolute frame for logo-reveal", () => { + const frame = getAbsoluteAudioFrame("LOGO_REVEAL", "logo-reveal"); + expect(frame).toBe(15); // Scene starts at 0, event at 15 + }); + + it("returns correct absolute frame for title-ui-appear", () => { + const frame = getAbsoluteAudioFrame("PORTAL_TITLE", "title-ui-appear"); + expect(frame).toBe(180 + 15); // Scene starts at 180, event at 15 + }); + + it("returns correct absolute frame for CTA events", () => { + const fanfareFrame = getAbsoluteAudioFrame("CALL_TO_ACTION", "cta-success-fanfare"); + expect(fanfareFrame).toBe(1980 + 30); // Scene at 1980, event at 30 + + const urlFrame = getAbsoluteAudioFrame("CALL_TO_ACTION", "cta-url-emphasis"); + expect(urlFrame).toBe(1980 + 75); // Scene at 1980, event at 75 + }); + + it("returns null for invalid scene", () => { + const frame = getAbsoluteAudioFrame("INVALID", "logo-whoosh"); + expect(frame).toBeNull(); + }); + + it("returns null for invalid event", () => { + const frame = getAbsoluteAudioFrame("LOGO_REVEAL", "invalid-event"); + expect(frame).toBeNull(); + }); +}); + +describe("getAllAudioEventsAbsolute", () => { + it("returns all events with absolute frames", () => { + const events = getAllAudioEventsAbsolute(); + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(event.absoluteStartFrame).toBeDefined(); + expect(event.absoluteStartFrame).toBeGreaterThanOrEqual(0); + expect(event.absoluteStartFrame).toBeLessThan(TOTAL_DURATION_FRAMES); + } + }); + + it("events are sorted by absolute start frame", () => { + const events = getAllAudioEventsAbsolute(); + for (let i = 1; i < events.length; i++) { + expect(events[i].absoluteStartFrame).toBeGreaterThanOrEqual( + events[i - 1].absoluteStartFrame + ); + } + }); + + it("first event is at frame 0", () => { + const events = getAllAudioEventsAbsolute(); + expect(events[0].absoluteStartFrame).toBe(0); + }); + + it("total events equals sum from all scenes", () => { + const events = getAllAudioEventsAbsolute(); + const expectedCount = SCENE_AUDIO_CONFIGS.reduce( + (sum, config) => sum + config.audioEvents.length, + 0 + ); + expect(events.length).toBe(expectedCount); + }); +}); + +describe("calculateBackgroundMusicVolume", () => { + describe("fade-in phase", () => { + it("returns 0 at frame 0", () => { + const volume = calculateBackgroundMusicVolume(0); + expect(volume).toBe(0); + }); + + it("returns half base volume midway through fade-in", () => { + const volume = calculateBackgroundMusicVolume(15); // Half of 30 frames + expect(volume).toBeCloseTo(0.125, 4); // Half of 0.25 + }); + + it("returns base volume at end of fade-in", () => { + const volume = calculateBackgroundMusicVolume(30); + expect(volume).toBeCloseTo(0.25, 4); + }); + }); + + describe("normal volume phase", () => { + it("returns base volume in middle of video", () => { + const volume = calculateBackgroundMusicVolume(1350); // Midpoint + expect(volume).toBe(0.25); + }); + + it("returns base volume just before fade-out starts", () => { + const volume = calculateBackgroundMusicVolume(2609); // Frame before fade-out + expect(volume).toBe(0.25); + }); + }); + + describe("fade-out phase", () => { + it("starts fade-out at correct frame", () => { + const fadeOutStart = TOTAL_DURATION_FRAMES - 90; // 2610 + const volumeJustBefore = calculateBackgroundMusicVolume(fadeOutStart - 1); + const volumeAtStart = calculateBackgroundMusicVolume(fadeOutStart); + const volumeAfterStart = calculateBackgroundMusicVolume(fadeOutStart + 1); + expect(volumeJustBefore).toBe(0.25); + // At exact fade-out start, volume begins decreasing (may still be 0.25 or just below) + expect(volumeAtStart).toBeLessThanOrEqual(0.25); + expect(volumeAfterStart).toBeLessThan(0.25); + }); + + it("returns half volume midway through fade-out", () => { + const fadeOutStart = 2610; + const midPoint = fadeOutStart + 45; // 45 frames into 90-frame fade + const volume = calculateBackgroundMusicVolume(midPoint); + expect(volume).toBeCloseTo(0.125, 4); + }); + + it("returns 0 at final frame", () => { + const volume = calculateBackgroundMusicVolume(2700); + expect(volume).toBe(0); + }); + }); + + describe("with custom FPS", () => { + it("calculates correctly at 60fps", () => { + // At 60fps, fade-in is 60 frames (1 second) + const volumeAtStart = calculateBackgroundMusicVolume(0, 60, 5400); + const volumeMid = calculateBackgroundMusicVolume(30, 60, 5400); + const volumeEnd = calculateBackgroundMusicVolume(60, 60, 5400); + expect(volumeAtStart).toBe(0); + expect(volumeMid).toBeCloseTo(0.125, 4); + expect(volumeEnd).toBeCloseTo(0.25, 4); + }); + }); +}); + +describe("isAudioEventInSync", () => { + it("returns true for exact match", () => { + expect(isAudioEventInSync(100, 100)).toBe(true); + }); + + it("returns true within tolerance", () => { + expect(isAudioEventInSync(102, 100, 3)).toBe(true); + expect(isAudioEventInSync(98, 100, 3)).toBe(true); + }); + + it("returns false outside tolerance", () => { + expect(isAudioEventInSync(104, 100, 3)).toBe(false); + expect(isAudioEventInSync(96, 100, 3)).toBe(false); + }); + + it("returns true at exactly tolerance boundary", () => { + expect(isAudioEventInSync(103, 100, 3)).toBe(true); + expect(isAudioEventInSync(97, 100, 3)).toBe(true); + }); + + it("defaults to zero tolerance", () => { + expect(isAudioEventInSync(100, 100)).toBe(true); + expect(isAudioEventInSync(101, 100)).toBe(false); + }); +}); + +describe("getAudioTimingDeviation", () => { + it("returns 0 for exact match", () => { + expect(getAudioTimingDeviation(100, 100)).toBe(0); + }); + + it("returns negative for early audio", () => { + expect(getAudioTimingDeviation(95, 100)).toBe(-5); + }); + + it("returns positive for late audio", () => { + expect(getAudioTimingDeviation(105, 100)).toBe(5); + }); +}); + +describe("frameDeviationToMs", () => { + it("converts frames to milliseconds at 30fps", () => { + expect(frameDeviationToMs(1)).toBeCloseTo(33.33, 1); + expect(frameDeviationToMs(30)).toBeCloseTo(1000, 0); + expect(frameDeviationToMs(15)).toBeCloseTo(500, 0); + }); + + it("converts negative frames", () => { + expect(frameDeviationToMs(-3)).toBeCloseTo(-100, 0); + }); + + it("handles custom FPS", () => { + expect(frameDeviationToMs(60, 60)).toBeCloseTo(1000, 0); + expect(frameDeviationToMs(24, 24)).toBeCloseTo(1000, 0); + }); +}); + +describe("Audio Event Validation", () => { + const validateAudioEvent = (event: AudioEvent) => { + // Volume bounds + expect(event.volume).toBeGreaterThan(0); + expect(event.volume).toBeLessThanOrEqual(1); + + // Frame bounds + expect(event.startFrame).toBeGreaterThanOrEqual(0); + expect(event.durationInFrames).toBeGreaterThan(0); + + // File path format + expect(event.audioFile).toMatch(/\.(mp3|wav|ogg)$/); + + // Tolerance bounds + if (event.toleranceFrames !== undefined) { + expect(event.toleranceFrames).toBeGreaterThanOrEqual(0); + expect(event.toleranceFrames).toBeLessThanOrEqual(5); + } + }; + + it("all Logo Reveal events are valid", () => { + LOGO_REVEAL_AUDIO.forEach(validateAudioEvent); + }); + + it("all Portal Title events are valid", () => { + PORTAL_TITLE_AUDIO.forEach(validateAudioEvent); + }); + + it("all Dashboard Overview events are valid", () => { + DASHBOARD_OVERVIEW_AUDIO.forEach(validateAudioEvent); + }); + + it("all Meetup Showcase events are valid", () => { + MEETUP_SHOWCASE_AUDIO.forEach(validateAudioEvent); + }); + + it("all Country Stats events are valid", () => { + COUNTRY_STATS_AUDIO.forEach(validateAudioEvent); + }); + + it("all Top Meetups events are valid", () => { + TOP_MEETUPS_AUDIO.forEach(validateAudioEvent); + }); + + it("all Activity Feed events are valid", () => { + ACTIVITY_FEED_AUDIO.forEach(validateAudioEvent); + }); + + it("all Call to Action events are valid", () => { + CALL_TO_ACTION_AUDIO.forEach(validateAudioEvent); + }); + + it("all Outro events are valid", () => { + OUTRO_AUDIO.forEach(validateAudioEvent); + }); +}); + +describe("Frame-Accurate Timing Verification", () => { + it("logo-whoosh syncs exactly with video start", () => { + const frame = getAbsoluteAudioFrame("LOGO_REVEAL", "logo-whoosh"); + expect(frame).toBe(0); + const tolerance = LOGO_REVEAL_AUDIO.find((e) => e.id === "logo-whoosh")?.toleranceFrames; + expect(tolerance).toBe(0); // Must be exact + }); + + it("logo-reveal syncs with logo entrance delay", () => { + const frame = getAbsoluteAudioFrame("LOGO_REVEAL", "logo-reveal"); + // TIMING.LOGO_ENTRANCE_DELAY = 0.5 seconds = 15 frames + expect(frame).toBe(15); + }); + + it("outro-entrance starts exactly when outro scene begins", () => { + const frame = getAbsoluteAudioFrame("OUTRO", "outro-entrance"); + expect(frame).toBe(SCENE_START_FRAMES.OUTRO); + expect(frame).toBe(2340); + }); + + it("CTA fanfare plays after overlay animation settles", () => { + const frame = getAbsoluteAudioFrame("CALL_TO_ACTION", "cta-success-fanfare"); + const sceneStart = SCENE_START_FRAMES.CALL_TO_ACTION; + // Event starts 30 frames (1 second) into scene + expect(frame).toBe(sceneStart + 30); + }); + + it("all critical sync points have zero or low tolerance", () => { + const criticalEvents = ["logo-whoosh", "outro-entrance"]; + const allEvents = getAllAudioEventsAbsolute(); + + for (const eventId of criticalEvents) { + const event = allEvents.find((e) => e.id === eventId); + expect(event).toBeDefined(); + expect(event!.toleranceFrames ?? 0).toBeLessThanOrEqual(2); + } + }); +}); + +describe("Audio Overlap Detection", () => { + it("no overlapping audio events within same scene", () => { + for (const config of SCENE_AUDIO_CONFIGS) { + const events = config.audioEvents; + for (let i = 0; i < events.length; i++) { + for (let j = i + 1; j < events.length; j++) { + const e1Start = events[i].startFrame; + const e1End = e1Start + events[i].durationInFrames; + const e2Start = events[j].startFrame; + const e2End = e2Start + events[j].durationInFrames; + + // Check if same audio file overlaps with itself + if (events[i].audioFile === events[j].audioFile) { + const overlaps = e1Start < e2End && e2Start < e1End; + if (overlaps) { + // Same audio file should not overlap + expect(overlaps).toBe(false); + } + } + } + } + } + }); +}); + +describe("Volume Consistency", () => { + it("background music never exceeds base volume", () => { + for (let frame = 0; frame <= TOTAL_DURATION_FRAMES; frame += 10) { + const volume = calculateBackgroundMusicVolume(frame); + expect(volume).toBeLessThanOrEqual(BACKGROUND_MUSIC_CONFIG.baseVolume); + } + }); + + it("SFX volumes are reasonable", () => { + const allEvents = getAllAudioEventsAbsolute(); + for (const event of allEvents) { + // SFX should be between 0.3 and 0.7 typically + expect(event.volume).toBeGreaterThanOrEqual(0.3); + expect(event.volume).toBeLessThanOrEqual(0.7); + } + }); + + it("total potential volume at any frame is reasonable", () => { + // At any given frame, the sum of volumes should not cause clipping + // Background is 0.25, individual SFX up to 0.7 + // Max potential is around 0.95 which is safe + const allEvents = getAllAudioEventsAbsolute(); + for (const event of allEvents) { + const bgVolume = BACKGROUND_MUSIC_CONFIG.baseVolume; + const totalPotentialVolume = bgVolume + event.volume; + expect(totalPotentialVolume).toBeLessThanOrEqual(1.0); + } + }); +}); + +describe("Timing Integration with TIMING config", () => { + it("logo audio syncs with TIMING.LOGO_ENTRANCE_DELAY", () => { + const logoReveal = LOGO_REVEAL_AUDIO.find((e) => e.id === "logo-reveal"); + const expectedFrame = secondsToFrames(0.5); // TIMING.LOGO_ENTRANCE_DELAY = 0.5 + expect(logoReveal?.startFrame).toBe(expectedFrame); + }); + + it("outro logo audio syncs with TIMING.OUTRO_LOGO_DELAY", () => { + const outroLogo = OUTRO_AUDIO.find((e) => e.id === "outro-logo-reveal"); + const expectedFrame = secondsToFrames(1.0); // TIMING.OUTRO_LOGO_DELAY = 1.0 + expect(outroLogo?.startFrame).toBe(expectedFrame); + }); + + it("CTA URL audio syncs with TIMING.CTA_URL_DELAY", () => { + const ctaUrl = CALL_TO_ACTION_AUDIO.find((e) => e.id === "cta-url-emphasis"); + const expectedFrame = secondsToFrames(2.5); // TIMING.CTA_URL_DELAY = 2.5 + expect(ctaUrl?.startFrame).toBe(expectedFrame); + }); +}); diff --git a/videos/src/config/audioSync.ts b/videos/src/config/audioSync.ts new file mode 100644 index 0000000..b3e02f9 --- /dev/null +++ b/videos/src/config/audioSync.ts @@ -0,0 +1,519 @@ +/** + * Audio Sync Configuration + * + * Frame-accurate audio timing definitions for the Portal Presentation. + * All audio events are defined with their exact frame positions and expected visual sync points. + * + * This configuration serves as the source of truth for audio-visual synchronization + * and enables automated verification of audio timing accuracy. + */ + +import { SCENE_DURATIONS, secondsToFrames } from "./timing"; + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Represents a single audio event with its timing and visual sync target. + */ +export interface AudioEvent { + /** Unique identifier for the audio event */ + id: string; + /** Audio file path (relative to public directory) */ + audioFile: string; + /** Frame number when audio should start (absolute, from composition start) */ + startFrame: number; + /** Duration in frames for the audio playback */ + durationInFrames: number; + /** Volume level (0-1) */ + volume: number; + /** Description of what visual element this syncs with */ + visualSyncTarget: string; + /** Frame offset tolerance for sync verification (default 0 = exact) */ + toleranceFrames?: number; +} + +/** + * Background music configuration with fade parameters. + */ +export interface BackgroundMusicConfig { + /** Audio file path */ + audioFile: string; + /** Base volume level */ + baseVolume: number; + /** Fade-in duration in seconds */ + fadeInDuration: number; + /** Fade-out duration in seconds */ + fadeOutDuration: number; + /** Whether to loop the audio */ + loop: boolean; +} + +/** + * Scene audio configuration containing all audio events for a scene. + */ +export interface SceneAudioConfig { + /** Scene identifier */ + sceneId: string; + /** Scene start frame (absolute) */ + sceneStartFrame: number; + /** Scene duration in frames */ + sceneDurationInFrames: number; + /** Audio events within this scene (frames relative to scene start) */ + audioEvents: AudioEvent[]; +} + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +/** Standard FPS for all calculations */ +export const STANDARD_FPS = 30; + +/** Total composition duration in frames */ +export const TOTAL_DURATION_FRAMES = 2700; + +/** Total composition duration in seconds */ +export const TOTAL_DURATION_SECONDS = 90; + +// ============================================================================ +// BACKGROUND MUSIC CONFIGURATION +// ============================================================================ + +export const BACKGROUND_MUSIC_CONFIG: BackgroundMusicConfig = { + audioFile: "music/background-music.mp3", + baseVolume: 0.25, + fadeInDuration: 1, // 1 second = 30 frames + fadeOutDuration: 3, // 3 seconds = 90 frames + loop: true, +}; + +// ============================================================================ +// SCENE START FRAMES (calculated from SCENE_DURATIONS) +// ============================================================================ + +/** + * Calculate scene start frames based on durations. + * Returns an object with scene names and their absolute start frame positions. + */ +export function calculateSceneStartFrames(fps: number = STANDARD_FPS): Record { + let currentFrame = 0; + const sceneOrder = [ + "LOGO_REVEAL", + "PORTAL_TITLE", + "DASHBOARD_OVERVIEW", + "MEINE_MEETUPS", + "TOP_LAENDER", + "TOP_MEETUPS", + "ACTIVITY_FEED", + "CALL_TO_ACTION", + "OUTRO", + ]; + + const startFrames: Record = {}; + + for (const scene of sceneOrder) { + startFrames[scene] = currentFrame; + const duration = SCENE_DURATIONS[scene as keyof typeof SCENE_DURATIONS]; + currentFrame += duration * fps; + } + + return startFrames; +} + +/** + * Pre-calculated scene start frames at 30fps for quick reference. + */ +export const SCENE_START_FRAMES = calculateSceneStartFrames(STANDARD_FPS); + +// ============================================================================ +// AUDIO EVENT DEFINITIONS BY SCENE +// ============================================================================ + +/** + * Audio events for the Logo Reveal scene (Scene 1). + * Duration: 6 seconds (180 frames) + */ +export const LOGO_REVEAL_AUDIO: AudioEvent[] = [ + { + id: "logo-whoosh", + audioFile: "sfx/logo-whoosh.mp3", + startFrame: 0, // Starts immediately + durationInFrames: 60, // 2 seconds + volume: 0.7, + visualSyncTarget: "Background zoom animation start", + toleranceFrames: 0, + }, + { + id: "logo-reveal", + audioFile: "sfx/logo-reveal.mp3", + startFrame: 15, // 0.5 seconds (TIMING.LOGO_ENTRANCE_DELAY) + durationInFrames: 60, // 2 seconds + volume: 0.6, + visualSyncTarget: "Logo entrance animation start", + toleranceFrames: 2, + }, +]; + +/** + * Audio events for the Portal Title scene (Scene 2). + * Duration: 4 seconds (120 frames) + */ +export const PORTAL_TITLE_AUDIO: AudioEvent[] = [ + { + id: "title-ui-appear", + audioFile: "sfx/ui-appear.mp3", + startFrame: 15, // 0.5 seconds into scene + durationInFrames: 30, // 1 second + volume: 0.5, + visualSyncTarget: "Title text entrance", + toleranceFrames: 2, + }, +]; + +/** + * Audio events for the Dashboard Overview scene (Scene 3). + * Duration: 12 seconds (360 frames) + */ +export const DASHBOARD_OVERVIEW_AUDIO: AudioEvent[] = [ + { + id: "dashboard-card-slide-1", + audioFile: "sfx/card-slide.mp3", + startFrame: 30, // 1 second into scene + durationInFrames: 30, + volume: 0.4, + visualSyncTarget: "First dashboard card entrance", + toleranceFrames: 3, + }, + { + id: "dashboard-card-slide-2", + audioFile: "sfx/card-slide.mp3", + startFrame: 75, // 2.5 seconds (after first card-slide finishes at frame 60) + durationInFrames: 30, + volume: 0.4, + visualSyncTarget: "Second dashboard card entrance", + toleranceFrames: 3, + }, +]; + +/** + * Audio events for the Meetup Showcase scene (Scene 4). + * Duration: 12 seconds (360 frames) + */ +export const MEETUP_SHOWCASE_AUDIO: AudioEvent[] = [ + { + id: "meetup-ui-appear", + audioFile: "sfx/ui-appear.mp3", + startFrame: 15, + durationInFrames: 30, + volume: 0.5, + visualSyncTarget: "Meetup header entrance", + toleranceFrames: 2, + }, + { + id: "meetup-badge-appear", + audioFile: "sfx/badge-appear.mp3", + startFrame: 60, // 2 seconds + durationInFrames: 30, + volume: 0.5, + visualSyncTarget: "Meetup count badge pop-in", + toleranceFrames: 2, + }, +]; + +/** + * Audio events for the Country Stats scene (Scene 5). + * Duration: 12 seconds (360 frames) + */ +export const COUNTRY_STATS_AUDIO: AudioEvent[] = [ + { + id: "country-ui-appear", + audioFile: "sfx/ui-appear.mp3", + startFrame: 15, + durationInFrames: 30, + volume: 0.5, + visualSyncTarget: "Country stats header entrance", + toleranceFrames: 2, + }, +]; + +/** + * Audio events for the Top Meetups scene (Scene 6). + * Duration: 10 seconds (300 frames) + */ +export const TOP_MEETUPS_AUDIO: AudioEvent[] = [ + { + id: "top-meetups-ui-appear", + audioFile: "sfx/ui-appear.mp3", + startFrame: 15, + durationInFrames: 30, + volume: 0.5, + visualSyncTarget: "Top meetups header entrance", + toleranceFrames: 2, + }, + { + id: "top-meetups-slide-in", + audioFile: "sfx/slide-in.mp3", + startFrame: 45, // 1.5 seconds + durationInFrames: 30, + volume: 0.4, + visualSyncTarget: "Ranking list slide-in", + toleranceFrames: 3, + }, +]; + +/** + * Audio events for the Activity Feed scene (Scene 7). + * Duration: 10 seconds (300 frames) + */ +export const ACTIVITY_FEED_AUDIO: AudioEvent[] = [ + { + id: "activity-ui-appear", + audioFile: "sfx/ui-appear.mp3", + startFrame: 15, + durationInFrames: 30, + volume: 0.5, + visualSyncTarget: "Activity feed header entrance", + toleranceFrames: 2, + }, + { + id: "activity-item-1", + audioFile: "sfx/button-click.mp3", + startFrame: 45, + durationInFrames: 15, + volume: 0.3, + visualSyncTarget: "First activity item entrance", + toleranceFrames: 3, + }, + { + id: "activity-item-2", + audioFile: "sfx/button-click.mp3", + startFrame: 65, + durationInFrames: 15, + volume: 0.3, + visualSyncTarget: "Second activity item entrance", + toleranceFrames: 3, + }, +]; + +/** + * Audio events for the Call to Action scene (Scene 8). + * Duration: 12 seconds (360 frames) + */ +export const CALL_TO_ACTION_AUDIO: AudioEvent[] = [ + { + id: "cta-success-fanfare", + audioFile: "sfx/success-fanfare.mp3", + startFrame: 30, // 1 second + durationInFrames: 90, // 3 seconds + volume: 0.6, + visualSyncTarget: "CTA glassmorphism overlay entrance", + toleranceFrames: 3, + }, + { + id: "cta-url-emphasis", + audioFile: "sfx/url-emphasis.mp3", + startFrame: 75, // 2.5 seconds (TIMING.CTA_URL_DELAY) + durationInFrames: 45, + volume: 0.5, + visualSyncTarget: "URL typing animation start", + toleranceFrames: 2, + }, + { + id: "cta-final-chime", + audioFile: "sfx/final-chime.mp3", + startFrame: 180, // 6 seconds + durationInFrames: 60, + volume: 0.6, + visualSyncTarget: "Final CTA button pulse", + toleranceFrames: 3, + }, +]; + +/** + * Audio events for the Outro scene (Scene 9). + * Duration: 12 seconds (360 frames) + */ +export const OUTRO_AUDIO: AudioEvent[] = [ + { + id: "outro-entrance", + audioFile: "sfx/outro-entrance.mp3", + startFrame: 0, + durationInFrames: 60, + volume: 0.5, + visualSyncTarget: "Outro fade-in start", + toleranceFrames: 0, + }, + { + id: "outro-logo-reveal", + audioFile: "sfx/logo-reveal.mp3", + startFrame: 30, // 1 second (TIMING.OUTRO_LOGO_DELAY) + durationInFrames: 60, + volume: 0.5, + visualSyncTarget: "Outro logo entrance", + toleranceFrames: 2, + }, +]; + +// ============================================================================ +// SCENE AUDIO CONFIGURATIONS +// ============================================================================ + +/** + * Complete audio configuration for all scenes. + */ +export const SCENE_AUDIO_CONFIGS: SceneAudioConfig[] = [ + { + sceneId: "LOGO_REVEAL", + sceneStartFrame: SCENE_START_FRAMES.LOGO_REVEAL, + sceneDurationInFrames: secondsToFrames(SCENE_DURATIONS.LOGO_REVEAL), + audioEvents: LOGO_REVEAL_AUDIO, + }, + { + sceneId: "PORTAL_TITLE", + sceneStartFrame: SCENE_START_FRAMES.PORTAL_TITLE, + sceneDurationInFrames: secondsToFrames(SCENE_DURATIONS.PORTAL_TITLE), + audioEvents: PORTAL_TITLE_AUDIO, + }, + { + sceneId: "DASHBOARD_OVERVIEW", + sceneStartFrame: SCENE_START_FRAMES.DASHBOARD_OVERVIEW, + sceneDurationInFrames: secondsToFrames(SCENE_DURATIONS.DASHBOARD_OVERVIEW), + audioEvents: DASHBOARD_OVERVIEW_AUDIO, + }, + { + sceneId: "MEINE_MEETUPS", + sceneStartFrame: SCENE_START_FRAMES.MEINE_MEETUPS, + sceneDurationInFrames: secondsToFrames(SCENE_DURATIONS.MEINE_MEETUPS), + audioEvents: MEETUP_SHOWCASE_AUDIO, + }, + { + sceneId: "TOP_LAENDER", + sceneStartFrame: SCENE_START_FRAMES.TOP_LAENDER, + sceneDurationInFrames: secondsToFrames(SCENE_DURATIONS.TOP_LAENDER), + audioEvents: COUNTRY_STATS_AUDIO, + }, + { + sceneId: "TOP_MEETUPS", + sceneStartFrame: SCENE_START_FRAMES.TOP_MEETUPS, + sceneDurationInFrames: secondsToFrames(SCENE_DURATIONS.TOP_MEETUPS), + audioEvents: TOP_MEETUPS_AUDIO, + }, + { + sceneId: "ACTIVITY_FEED", + sceneStartFrame: SCENE_START_FRAMES.ACTIVITY_FEED, + sceneDurationInFrames: secondsToFrames(SCENE_DURATIONS.ACTIVITY_FEED), + audioEvents: ACTIVITY_FEED_AUDIO, + }, + { + sceneId: "CALL_TO_ACTION", + sceneStartFrame: SCENE_START_FRAMES.CALL_TO_ACTION, + sceneDurationInFrames: secondsToFrames(SCENE_DURATIONS.CALL_TO_ACTION), + audioEvents: CALL_TO_ACTION_AUDIO, + }, + { + sceneId: "OUTRO", + sceneStartFrame: SCENE_START_FRAMES.OUTRO, + sceneDurationInFrames: secondsToFrames(SCENE_DURATIONS.OUTRO), + audioEvents: OUTRO_AUDIO, + }, +]; + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +/** + * Get all audio events for a specific scene. + */ +export function getSceneAudioEvents(sceneId: string): AudioEvent[] { + const config = SCENE_AUDIO_CONFIGS.find((c) => c.sceneId === sceneId); + return config?.audioEvents ?? []; +} + +/** + * Get absolute frame position for an audio event. + * Converts scene-relative frame to absolute composition frame. + */ +export function getAbsoluteAudioFrame(sceneId: string, audioEventId: string): number | null { + const config = SCENE_AUDIO_CONFIGS.find((c) => c.sceneId === sceneId); + if (!config) return null; + + const event = config.audioEvents.find((e) => e.id === audioEventId); + if (!event) return null; + + return config.sceneStartFrame + event.startFrame; +} + +/** + * Get all audio events flattened with absolute frame positions. + */ +export function getAllAudioEventsAbsolute(): Array { + const events: Array = []; + + for (const config of SCENE_AUDIO_CONFIGS) { + for (const event of config.audioEvents) { + events.push({ + ...event, + absoluteStartFrame: config.sceneStartFrame + event.startFrame, + }); + } + } + + return events.sort((a, b) => a.absoluteStartFrame - b.absoluteStartFrame); +} + +/** + * Calculate expected background music volume at a specific frame. + */ +export function calculateBackgroundMusicVolume( + frame: number, + fps: number = STANDARD_FPS, + durationInFrames: number = TOTAL_DURATION_FRAMES +): number { + const { baseVolume, fadeInDuration, fadeOutDuration } = BACKGROUND_MUSIC_CONFIG; + const fadeInFrames = fadeInDuration * fps; + const fadeOutFrames = fadeOutDuration * fps; + const fadeOutStart = durationInFrames - fadeOutFrames; + + // Fade-in phase + if (frame < fadeInFrames) { + return (frame / fadeInFrames) * baseVolume; + } + + // Fade-out phase + if (frame >= fadeOutStart) { + const fadeOutProgress = (frame - fadeOutStart) / fadeOutFrames; + return baseVolume * (1 - fadeOutProgress); + } + + // Normal volume phase + return baseVolume; +} + +/** + * Verify if an audio event would be within tolerance of its expected frame. + */ +export function isAudioEventInSync( + actualFrame: number, + expectedFrame: number, + tolerance: number = 0 +): boolean { + return Math.abs(actualFrame - expectedFrame) <= tolerance; +} + +/** + * Get timing deviation for an audio event. + * Returns negative if too early, positive if too late. + */ +export function getAudioTimingDeviation(actualFrame: number, expectedFrame: number): number { + return actualFrame - expectedFrame; +} + +/** + * Convert frame deviation to milliseconds. + */ +export function frameDeviationToMs(frames: number, fps: number = STANDARD_FPS): number { + return (frames / fps) * 1000; +}