mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-01-28 07:43:18 +00:00
🔊 Add frame-accurate audio sync verification system
Add comprehensive audio synchronization configuration and testing: - Create AudioSyncConfig types for audio events, background music, and scene audio configurations - Define audio timing for all 9 scenes with frame-accurate sync points - Add utilities for absolute frame calculation, volume interpolation, and sync verification with tolerance support - Include 94 tests covering timing calculations, overlap detection, volume consistency, and frame-accurate sync point verification The new audioSync.ts provides a centralized source of truth for all audio-visual synchronization, enabling automated verification of audio timing accuracy across the Portal Presentation composition. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
673
videos/src/config/audioSync.test.ts
Normal file
673
videos/src/config/audioSync.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
519
videos/src/config/audioSync.ts
Normal file
519
videos/src/config/audioSync.ts
Normal file
@@ -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<string, number> {
|
||||||
|
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<string, number> = {};
|
||||||
|
|
||||||
|
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<AudioEvent & { absoluteStartFrame: number }> {
|
||||||
|
const events: Array<AudioEvent & { absoluteStartFrame: number }> = [];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user