From b62964b243a54ed6ea6e09ffc11f7d4327d468d5 Mon Sep 17 00:00:00 2001 From: cbaucom Date: Mon, 22 Jun 2026 10:10:07 -0400 Subject: [PATCH] Add loudness normalization with per-clip gain overrides. Analyze audio RMS to -18 dBFS target, cache offsets in loudnessCache, and apply effective gain in playback while manual clip gainDb overrides auto normalization. Closes #47 Co-authored-by: Cursor --- specs/multi-track-audio-timeline.md | 6 ++ src/audio-analysis/gain.test.ts | 26 ++++++ src/audio-analysis/gain.ts | 17 ++++ src/audio-analysis/index.ts | 10 +++ src/audio-analysis/loudness.test.ts | 52 +++++++++++ src/audio-analysis/loudness.ts | 59 +++++++++++++ src/audio-analysis/types.ts | 6 ++ src/editor-shell/App.tsx | 18 +++- src/editor-shell/EditorSidebar.tsx | 4 + src/editor-shell/SoundtrackPanel.tsx | 102 ++++++++++++++++------ src/editor-shell/slidePersistence.test.ts | 30 +++++++ src/editor-shell/slidePersistence.ts | 3 + src/editor-shell/useLoudness.ts | 57 ++++++++++++ src/editor-shell/useProject.ts | 12 ++- src/project-store/audio-loader.ts | 3 +- src/project-store/schema.ts | 2 + 16 files changed, 375 insertions(+), 32 deletions(-) create mode 100644 src/audio-analysis/gain.test.ts create mode 100644 src/audio-analysis/gain.ts create mode 100644 src/audio-analysis/index.ts create mode 100644 src/audio-analysis/loudness.test.ts create mode 100644 src/audio-analysis/loudness.ts create mode 100644 src/audio-analysis/types.ts create mode 100644 src/editor-shell/useLoudness.ts diff --git a/specs/multi-track-audio-timeline.md b/specs/multi-track-audio-timeline.md index 638921b..0edebcb 100644 --- a/specs/multi-track-audio-timeline.md +++ b/specs/multi-track-audio-timeline.md @@ -38,3 +38,9 @@ type RenderPlan = { ### UI (minimal) SoundtrackPanel: ordered clip list, add / reorder / remove. Beat grid uses first clip until #49. + +## Slice 20 — Loudness normalization (done) + +`audio-analysis` module: RMS normalization to -18 dBFS. `loudnessCache` in slideshow.json (filename + byteLength). Manual `gainDb` on clip overrides auto offset. Per-clip dB input in SoundtrackPanel. + +Closes #47. diff --git a/src/audio-analysis/gain.test.ts b/src/audio-analysis/gain.test.ts new file mode 100644 index 0000000..7b22fc5 --- /dev/null +++ b/src/audio-analysis/gain.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { isLoudnessCacheEntryValid, resolveEffectiveGainDb } from './gain' + +describe('resolveEffectiveGainDb', () => { + it('uses manual gain when set', () => { + expect(resolveEffectiveGainDb(-3, { byteLength: 100, offsetDb: 6 })).toBe(-3) + }) + + it('uses cache offset when manual gain is absent', () => { + expect(resolveEffectiveGainDb(undefined, { byteLength: 100, offsetDb: 6 })).toBe(6) + }) + + it('returns 0 when neither manual nor cache exists', () => { + expect(resolveEffectiveGainDb(undefined, undefined)).toBe(0) + }) +}) + +describe('isLoudnessCacheEntryValid', () => { + it('is valid when byteLength matches', () => { + expect(isLoudnessCacheEntryValid({ byteLength: 42, offsetDb: 1 }, 42)).toBe(true) + }) + + it('is invalid when byteLength differs', () => { + expect(isLoudnessCacheEntryValid({ byteLength: 42, offsetDb: 1 }, 99)).toBe(false) + }) +}) diff --git a/src/audio-analysis/gain.ts b/src/audio-analysis/gain.ts new file mode 100644 index 0000000..713242b --- /dev/null +++ b/src/audio-analysis/gain.ts @@ -0,0 +1,17 @@ +import type { LoudnessCacheEntry } from './types' + +export function resolveEffectiveGainDb( + manualGainDb: number | undefined, + cacheEntry: LoudnessCacheEntry | undefined, +): number { + if (manualGainDb !== undefined) return manualGainDb + if (cacheEntry) return cacheEntry.offsetDb + return 0 +} + +export function isLoudnessCacheEntryValid( + entry: LoudnessCacheEntry | undefined, + byteLength: number, +): boolean { + return entry !== undefined && entry.byteLength === byteLength +} diff --git a/src/audio-analysis/index.ts b/src/audio-analysis/index.ts new file mode 100644 index 0000000..0ca6c29 --- /dev/null +++ b/src/audio-analysis/index.ts @@ -0,0 +1,10 @@ +export { + computePeak, + computeRms, + linearToDb, + recommendedGainDb, + rmsDbAfterGain, + TARGET_RMS_DBFS, +} from './loudness' +export { isLoudnessCacheEntryValid, resolveEffectiveGainDb } from './gain' +export type { LoudnessCache, LoudnessCacheEntry } from './types' diff --git a/src/audio-analysis/loudness.test.ts b/src/audio-analysis/loudness.test.ts new file mode 100644 index 0000000..19ec901 --- /dev/null +++ b/src/audio-analysis/loudness.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + computeRms, + linearToDb, + recommendedGainDb, + rmsDbAfterGain, + TARGET_RMS_DBFS, +} from './loudness' + +function constantAmplitudeSamples(amplitude: number, length = 44100): Float32Array { + const samples = new Float32Array(length) + for (let index = 0; index < length; index++) { + samples[index] = amplitude + } + return samples +} + +describe('recommendedGainDb', () => { + it('normalizes tracks with different RMS to within ±1 dB of target', () => { + const quiet = constantAmplitudeSamples(0.01) + const loud = constantAmplitudeSamples(0.1) + + const quietGain = recommendedGainDb(quiet) + const loudGain = recommendedGainDb(loud) + + expect(rmsDbAfterGain(quiet, quietGain)).toBeGreaterThanOrEqual(TARGET_RMS_DBFS - 1) + expect(rmsDbAfterGain(quiet, quietGain)).toBeLessThanOrEqual(TARGET_RMS_DBFS + 1) + expect(rmsDbAfterGain(loud, loudGain)).toBeGreaterThanOrEqual(TARGET_RMS_DBFS - 1) + expect(rmsDbAfterGain(loud, loudGain)).toBeLessThanOrEqual(TARGET_RMS_DBFS + 1) + }) + + it('matches table expectations for known RMS levels', () => { + const cases = [ + { amplitude: 0.1, expectedCurrentDb: -20 }, + { amplitude: 0.01, expectedCurrentDb: -40 }, + ] + + for (const { amplitude, expectedCurrentDb } of cases) { + const samples = constantAmplitudeSamples(amplitude) + expect(linearToDb(computeRms(samples))).toBeCloseTo(expectedCurrentDb, 5) + const gainDb = recommendedGainDb(samples) + expect(rmsDbAfterGain(samples, gainDb)).toBeCloseTo(TARGET_RMS_DBFS, 1) + } + }) + + it('caps gain so peak does not exceed 0 dBFS', () => { + const samples = constantAmplitudeSamples(0.5) + const gainDb = recommendedGainDb(samples) + const peakAfter = 0.5 * Math.pow(10, gainDb / 20) + expect(peakAfter).toBeLessThanOrEqual(1) + }) +}) diff --git a/src/audio-analysis/loudness.ts b/src/audio-analysis/loudness.ts new file mode 100644 index 0000000..a186377 --- /dev/null +++ b/src/audio-analysis/loudness.ts @@ -0,0 +1,59 @@ +/** Target RMS level in dBFS for normalized soundtrack playback. */ +export const TARGET_RMS_DBFS = -18 + +const MIN_LINEAR = 1e-10 + +export function computePeak(samples: Float32Array): number { + let peak = 0 + for (let index = 0; index < samples.length; index++) { + const absolute = Math.abs(samples[index]) + if (absolute > peak) peak = absolute + } + return peak +} + +export function computeRms(samples: Float32Array): number { + if (samples.length === 0) return 0 + let sum = 0 + for (let index = 0; index < samples.length; index++) { + const sample = samples[index] + sum += sample * sample + } + return Math.sqrt(sum / samples.length) +} + +export function linearToDb(linear: number): number { + if (linear <= MIN_LINEAR) return -100 + return 20 * Math.log10(linear) +} + +/** + * Recommended gain (dB) to bring `samples` RMS to `targetDbfs`, capped so peak + * does not exceed 0 dBFS after applying the gain. + */ +export function recommendedGainDb( + samples: Float32Array, + targetDbfs = TARGET_RMS_DBFS, +): number { + const rms = computeRms(samples) + const peak = computePeak(samples) + const currentRmsDb = linearToDb(rms) + if (!Number.isFinite(currentRmsDb) || currentRmsDb <= -100) return 0 + + const rmsGainDb = targetDbfs - currentRmsDb + const peakDb = linearToDb(peak) + const peakLimitGainDb = Number.isFinite(peakDb) ? -peakDb : rmsGainDb + + return Math.min(rmsGainDb, peakLimitGainDb) +} + +export function rmsDbAfterGain(samples: Float32Array, gainDb: number): number { + const gain = Math.pow(10, gainDb / 20) + if (samples.length === 0) return -100 + let sum = 0 + for (let index = 0; index < samples.length; index++) { + const scaled = samples[index] * gain + sum += scaled * scaled + } + return linearToDb(Math.sqrt(sum / samples.length)) +} diff --git a/src/audio-analysis/types.ts b/src/audio-analysis/types.ts new file mode 100644 index 0000000..a6fb159 --- /dev/null +++ b/src/audio-analysis/types.ts @@ -0,0 +1,6 @@ +export type LoudnessCacheEntry = { + byteLength: number + offsetDb: number +} + +export type LoudnessCache = Record diff --git a/src/editor-shell/App.tsx b/src/editor-shell/App.tsx index a8c59df..e332cf7 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -25,6 +25,8 @@ import { SlideSettingsDialog } from './SlideSettingsDialog' import { TitleSlideDialog } from './TitleSlideDialog' import { useProject } from './useProject' import { useBeatGrid } from './useBeatGrid' +import { useLoudness } from './useLoudness' +import { resolveEffectiveGainDb } from '../audio-analysis' export function App() { const playerRef = useRef(null) @@ -43,12 +45,14 @@ export function App() { globalSettings, setGlobalSettings, manualBeatGrid, + loudnessCache, slides, setSlides, themeName, setThemeName, updateAudioClips, updateBeatGridPersist, + updateLoudnessCache, loading, error, corruptError, @@ -66,6 +70,12 @@ export function App() { primaryClipFilename, }) + useLoudness({ + audioTracks, + loudnessCache, + onPersistChange: updateLoudnessCache, + }) + const handleReorder = useCallback((fromIndex: number, toIndex: number) => { setSlides(prev => moveSlide(prev, fromIndex, toIndex)) }, [setSlides]) @@ -106,13 +116,14 @@ export function App() { () => audioClips.map((clip) => { const track = audioTracks.find((entry) => entry.filename === clip.filename) if (!track) return null + const gainDb = resolveEffectiveGainDb(clip.gainDb, loudnessCache?.[clip.filename]) return { blobUrl: track.blobUrl, durationInFrames: track.durationInFrames, - ...(clip.gainDb !== undefined ? { gainDb: clip.gainDb } : {}), + gainDb, } }).filter((clip) => clip !== null), - [audioClips, audioTracks], + [audioClips, audioTracks, loudnessCache], ) const renderPlan = useMemo( @@ -226,8 +237,9 @@ export function App() { onSettingsChange={handleSettingsChange} onAudioClipsChange={updateAudioClips} onThemeChange={handleThemeChange} - settings={globalSettings} audioClips={audioClips} + loudnessCache={loudnessCache} + settings={globalSettings} themeName={themeName} /> } diff --git a/src/editor-shell/EditorSidebar.tsx b/src/editor-shell/EditorSidebar.tsx index 802fab6..740e5d6 100644 --- a/src/editor-shell/EditorSidebar.tsx +++ b/src/editor-shell/EditorSidebar.tsx @@ -9,6 +9,7 @@ import type { AspectRatio, GlobalSettings, ThemeName } from '../timeline-core' import type { AudioClip } from '../timeline-core/types' import type { AudioTrack } from '../project-store' import type { BeatGrid } from '../beat-grid/types' +import type { LoudnessCache } from '../audio-analysis/types' import type { JamendoAttribution, JamendoTrack } from '../jamendo/types' import { GlobalSettingsPanel } from './GlobalSettingsPanel' import { SoundtrackPanel } from './SoundtrackPanel' @@ -32,6 +33,7 @@ type Props = { onSettingsChange: (updated: GlobalSettings) => void onThemeChange: (name: ThemeName) => void audioClips: AudioClip[] + loudnessCache: LoudnessCache | undefined settings: GlobalSettings themeName: ThemeName | null } @@ -53,6 +55,7 @@ export function EditorSidebar({ onSettingsChange, onThemeChange, audioClips, + loudnessCache, settings, themeName, }: Props) { @@ -90,6 +93,7 @@ export function EditorSidebar({ audioTracks={audioTracks} beatSync={settings.beatSync !== false} effectiveBeatGrid={effectiveBeatGrid} + loudnessCache={loudnessCache} manualBeatGrid={manualBeatGrid} onApplyManualBpm={onApplyManualBpm} onApplyTapTimestamps={onApplyTapTimestamps} diff --git a/src/editor-shell/SoundtrackPanel.tsx b/src/editor-shell/SoundtrackPanel.tsx index f347491..34e44ba 100644 --- a/src/editor-shell/SoundtrackPanel.tsx +++ b/src/editor-shell/SoundtrackPanel.tsx @@ -1,5 +1,6 @@ import { useRef } from 'react' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, @@ -12,6 +13,7 @@ import type { AudioTrack } from '../project-store' import type { AudioClip } from '../timeline-core/types' import { addAudioClip, moveAudioClip, removeAudioClip } from '../timeline-core' import type { BeatGrid } from '../beat-grid/types' +import type { LoudnessCache } from '../audio-analysis/types' import { BeatGridPanel } from './BeatGridPanel' import type { BeatGridAnalysisStatus } from './useBeatGrid' @@ -23,6 +25,7 @@ type Props = { audioTracks: AudioTrack[] beatSync: boolean effectiveBeatGrid: BeatGrid | undefined + loudnessCache: LoudnessCache | undefined manualBeatGrid: BeatGrid | undefined onApplyManualBpm: (bpm: number, firstBeatOffsetSecs: number) => void onApplyTapTimestamps: (tapTimestampsMs: number[]) => void @@ -30,12 +33,21 @@ type Props = { onClearManualBeatGrid: () => void } +function updateClipGain(clips: AudioClip[], index: number, gainDb: number | undefined): AudioClip[] { + return clips.map((clip, clipIndex) => { + if (clipIndex !== index) return clip + if (gainDb === undefined) return { filename: clip.filename } + return { filename: clip.filename, gainDb } + }) +} + export function SoundtrackPanel({ analysisStatus, audioClips, audioTracks, beatSync, effectiveBeatGrid, + loudnessCache, manualBeatGrid, onApplyManualBpm, onApplyTapTimestamps, @@ -58,34 +70,70 @@ export function SoundtrackPanel({
    - {audioClips.map((clip, index) => ( -
  • { dragIndexRef.current = null }} - onDragOver={(event) => event.preventDefault()} - onDragStart={() => { dragIndexRef.current = index }} - onDrop={(event) => { - event.preventDefault() - if (dragIndexRef.current !== null && dragIndexRef.current !== index) { - onChange(moveAudioClip(audioClips, dragIndexRef.current, index)) - } - dragIndexRef.current = null - }} - > - {clip.filename} - -
  • - ))} + {clip.filename} +
    + { + const raw = event.target.value.trim() + if (raw === '') { + onChange(updateClipGain(audioClips, index, undefined)) + return + } + const parsed = Number(raw) + if (!Number.isNaN(parsed)) { + onChange(updateClipGain(audioClips, index, parsed)) + } + }} + placeholder={autoGainDb !== undefined ? autoGainDb.toFixed(1) : '0'} + step="0.5" + type="number" + value={clip.gainDb ?? ''} + /> + dB + {clip.gainDb !== undefined ? ( + + ) : null} + +
    + + ) + })}
) : null} diff --git a/src/editor-shell/slidePersistence.test.ts b/src/editor-shell/slidePersistence.test.ts index 791ee6d..840dd3b 100644 --- a/src/editor-shell/slidePersistence.test.ts +++ b/src/editor-shell/slidePersistence.test.ts @@ -121,6 +121,36 @@ describe('slidesToJson', () => { expect(json.manualBeatGrid).toBeUndefined() }) + it('includes loudnessCache when provided', () => { + const json = slidesToJson( + DEFAULT_GLOBAL_SETTINGS, + [makeSlide('a.jpg')], + [{ filename: 'track.mp3' }], + null, + null, + null, + null, + null, + { 'track.mp3': { byteLength: 42, offsetDb: -3 } }, + ) + expect(json.loudnessCache).toEqual({ 'track.mp3': { byteLength: 42, offsetDb: -3 } }) + }) + + it('omits loudnessCache when empty', () => { + const json = slidesToJson( + DEFAULT_GLOBAL_SETTINGS, + [makeSlide('a.jpg')], + [{ filename: 'track.mp3' }], + null, + null, + null, + null, + null, + {}, + ) + expect(json.loudnessCache).toBeUndefined() + }) + it('serializes gainDb on audio clips', () => { const json = slidesToJson( DEFAULT_GLOBAL_SETTINGS, diff --git a/src/editor-shell/slidePersistence.ts b/src/editor-shell/slidePersistence.ts index 05502e4..c807379 100644 --- a/src/editor-shell/slidePersistence.ts +++ b/src/editor-shell/slidePersistence.ts @@ -1,4 +1,5 @@ import type { BeatGrid } from '../beat-grid/types' +import type { LoudnessCache } from '../audio-analysis/types' import type { AudioClip, MediaSlide, Slide } from '../timeline-core/types' import { isTitleSlide } from '../timeline-core/types' import type { AspectRatio, GlobalSettings, ThemeName } from '../timeline-core' @@ -51,6 +52,7 @@ export function slidesToJson( aspectRatio?: AspectRatio | null, beatGridCache?: BeatGrid | null, manualBeatGrid?: BeatGrid | null, + loudnessCache?: LoudnessCache | null, ): SlideshowJson { return { globalSettings, @@ -65,6 +67,7 @@ export function slidesToJson( } : {}), ...(beatGridCache ? { beatGridCache } : {}), + ...(loudnessCache && Object.keys(loudnessCache).length > 0 ? { loudnessCache } : {}), ...(manualBeatGrid ? { manualBeatGrid } : {}), ...(themeName ? { themeName } : {}), ...(soundtrackAttribution ? { soundtrackAttribution } : {}), diff --git a/src/editor-shell/useLoudness.ts b/src/editor-shell/useLoudness.ts new file mode 100644 index 0000000..d81a403 --- /dev/null +++ b/src/editor-shell/useLoudness.ts @@ -0,0 +1,57 @@ +import { useEffect } from 'react' +import { decodeMono } from '../beat-grid' +import { recommendedGainDb } from '../audio-analysis' +import type { LoudnessCache } from '../audio-analysis/types' +import { isLoudnessCacheEntryValid } from '../audio-analysis/gain' +import type { AudioTrack } from '../project-store' + +type Options = { + audioTracks: AudioTrack[] + loudnessCache: LoudnessCache | undefined + onPersistChange: (cache: LoudnessCache) => void +} + +export function useLoudness({ + audioTracks, + loudnessCache, + onPersistChange, +}: Options) { + useEffect(() => { + const pending = audioTracks.filter((track) => { + const cached = loudnessCache?.[track.filename] + return !isLoudnessCacheEntryValid(cached, track.byteLength) + }) + + if (pending.length === 0) return + + let cancelled = false + + async function analyzeAll() { + const updates: LoudnessCache = { ...loudnessCache } + + for (const track of pending) { + try { + const response = await fetch(track.blobUrl) + const buffer = await response.arrayBuffer() + const { samples } = await decodeMono(buffer) + if (cancelled) return + updates[track.filename] = { + byteLength: track.byteLength, + offsetDb: recommendedGainDb(samples), + } + } catch { + if (cancelled) return + } + } + + if (!cancelled && Object.keys(updates).length > 0) { + onPersistChange(updates) + } + } + + void analyzeAll() + return () => { cancelled = true } + }, [audioTracks, loudnessCache, onPersistChange]) + + return { loudnessCache } +} diff --git a/src/editor-shell/useProject.ts b/src/editor-shell/useProject.ts index e6e4081..962198e 100644 --- a/src/editor-shell/useProject.ts +++ b/src/editor-shell/useProject.ts @@ -23,6 +23,7 @@ import { audioClipsFromJson, reconcileSlides, slidesToJson } from './slidePersis import type { JamendoAttribution, JamendoTrack } from '../jamendo/types' import { downloadTrack, sanitizeFilename } from '../jamendo' import type { BeatGrid } from '../beat-grid' +import type { LoudnessCache } from '../audio-analysis/types' import { FPS } from './PlayerPane' const AUTOSAVE_DELAY = 2000 @@ -53,6 +54,7 @@ export function useProject({ onFolderLoaded }: Options = {}) { const [soundtrackAttribution, setSoundtrackAttribution] = useState(null) const [themeName, setThemeName] = useState(null) const [beatGridCache, setBeatGridCache] = useState() + const [loudnessCache, setLoudnessCache] = useState() const [manualBeatGrid, setManualBeatGrid] = useState() const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -108,9 +110,10 @@ export function useProject({ onFolderLoaded }: Options = {}) { aspectRatio, beatGridCache, manualBeatGrid, + loudnessCache, )).catch(console.error) }, AUTOSAVE_DELAY) - }, [aspectRatio, audioClips, beatGridCache, globalSettings, manualBeatGrid, slides, soundtrackAttribution, themeName]) + }, [aspectRatio, audioClips, beatGridCache, globalSettings, loudnessCache, manualBeatGrid, slides, soundtrackAttribution, themeName]) const loadFolder = useCallback( async (handle: FileSystemDirectoryHandle, savedData?: SlideshowJson) => { @@ -149,6 +152,7 @@ export function useProject({ onFolderLoaded }: Options = {}) { setSlides(finalSlides) setSoundtrackAttribution(restoredAttribution) setBeatGridCache(restoredClips.length > 0 ? savedData?.beatGridCache : undefined) + setLoudnessCache(savedData?.loudnessCache) setManualBeatGrid(restoredClips.length > 0 ? savedData?.manualBeatGrid : undefined) setProjectName(handle.name) setFolderOpen(true) @@ -252,6 +256,10 @@ export function useProject({ onFolderLoaded }: Options = {}) { setAudioClips(clips) }, [audioClips]) + const updateLoudnessCache = useCallback((cache: LoudnessCache) => { + setLoudnessCache(cache) + }, []) + const updateBeatGridPersist = useCallback((update: { beatGridCache?: BeatGrid manualBeatGrid?: BeatGrid @@ -307,6 +315,7 @@ export function useProject({ onFolderLoaded }: Options = {}) { beatGridCache, globalSettings, setGlobalSettings, + loudnessCache, manualBeatGrid, slides, setSlides, @@ -315,6 +324,7 @@ export function useProject({ onFolderLoaded }: Options = {}) { setThemeName, updateAudioClips, updateBeatGridPersist, + updateLoudnessCache, loading, error, corruptError, diff --git a/src/project-store/audio-loader.ts b/src/project-store/audio-loader.ts index cf73001..249e0e6 100644 --- a/src/project-store/audio-loader.ts +++ b/src/project-store/audio-loader.ts @@ -6,6 +6,7 @@ const FALLBACK_AUDIO_FRAMES = 30 * FPS export type AudioTrack = { blobUrl: string + byteLength: number durationInFrames: number filename: string } @@ -46,7 +47,7 @@ export async function enumerateAudioTracks( const blobUrl = URL.createObjectURL(file) createdUrls.push(blobUrl) const durationInFrames = await getAudioDurationFrames(file) - tracks.push({ blobUrl, durationInFrames, filename }) + tracks.push({ blobUrl, byteLength: file.size, durationInFrames, filename }) } return tracks } catch (error) { diff --git a/src/project-store/schema.ts b/src/project-store/schema.ts index 55a0081..629c0a7 100644 --- a/src/project-store/schema.ts +++ b/src/project-store/schema.ts @@ -1,6 +1,7 @@ import type { AspectRatio } from '../timeline-core/aspect' import type { GlobalSettings, SlideOverrides, ThemeName } from '../timeline-core/settings' import type { BeatGrid } from '../beat-grid/types' +import type { LoudnessCache } from '../audio-analysis/types' import type { JamendoAttribution } from '../jamendo/types' export const SCHEMA_VERSION = 1 @@ -37,6 +38,7 @@ export type SlideshowJson = { aspectRatio?: AspectRatio audioClips?: SerializedAudioClip[] globalSettings?: GlobalSettings + loudnessCache?: LoudnessCache schemaVersion: number slides: SerializedSlide[] soundtrackFilename?: string