Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions specs/multi-track-audio-timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 26 additions & 0 deletions src/audio-analysis/gain.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
17 changes: 17 additions & 0 deletions src/audio-analysis/gain.ts
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 10 additions & 0 deletions src/audio-analysis/index.ts
Original file line number Diff line number Diff line change
@@ -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'
52 changes: 52 additions & 0 deletions src/audio-analysis/loudness.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
59 changes: 59 additions & 0 deletions src/audio-analysis/loudness.ts
Original file line number Diff line number Diff line change
@@ -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))
}
6 changes: 6 additions & 0 deletions src/audio-analysis/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type LoudnessCacheEntry = {
byteLength: number
offsetDb: number
}

export type LoudnessCache = Record<string, LoudnessCacheEntry>
18 changes: 15 additions & 3 deletions src/editor-shell/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlayerRef>(null)
Expand All @@ -43,12 +45,14 @@ export function App() {
globalSettings,
setGlobalSettings,
manualBeatGrid,
loudnessCache,
slides,
setSlides,
themeName,
setThemeName,
updateAudioClips,
updateBeatGridPersist,
updateLoudnessCache,
loading,
error,
corruptError,
Expand All @@ -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])
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -226,8 +237,9 @@ export function App() {
onSettingsChange={handleSettingsChange}
onAudioClipsChange={updateAudioClips}
onThemeChange={handleThemeChange}
settings={globalSettings}
audioClips={audioClips}
loudnessCache={loudnessCache}
settings={globalSettings}
themeName={themeName}
/>
}
Expand Down
4 changes: 4 additions & 0 deletions src/editor-shell/EditorSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -32,6 +33,7 @@ type Props = {
onSettingsChange: (updated: GlobalSettings) => void
onThemeChange: (name: ThemeName) => void
audioClips: AudioClip[]
loudnessCache: LoudnessCache | undefined
settings: GlobalSettings
themeName: ThemeName | null
}
Expand All @@ -53,6 +55,7 @@ export function EditorSidebar({
onSettingsChange,
onThemeChange,
audioClips,
loudnessCache,
settings,
themeName,
}: Props) {
Expand Down Expand Up @@ -90,6 +93,7 @@ export function EditorSidebar({
audioTracks={audioTracks}
beatSync={settings.beatSync !== false}
effectiveBeatGrid={effectiveBeatGrid}
loudnessCache={loudnessCache}
manualBeatGrid={manualBeatGrid}
onApplyManualBpm={onApplyManualBpm}
onApplyTapTimestamps={onApplyTapTimestamps}
Expand Down
Loading
Loading