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
32 changes: 32 additions & 0 deletions src/beat-grid/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Thin Web Audio decode adapter. Decodes an audio ArrayBuffer and returns
* a mono Float32Array with the track's sample rate. Not tested directly —
* detection math is tested with synthetic buffers in detection.test.ts.
*/
export async function decodeMono(
arrayBuffer: ArrayBuffer,
): Promise<{ samples: Float32Array; sampleRate: number }> {
const ctx = new AudioContext()
let audioBuffer: AudioBuffer
try {
audioBuffer = await ctx.decodeAudioData(arrayBuffer)
} finally {
await ctx.close()
}

const { length, numberOfChannels, sampleRate } = audioBuffer
const mono = new Float32Array(length)
for (let ch = 0; ch < numberOfChannels; ch++) {
const channelData = audioBuffer.getChannelData(ch)
for (let i = 0; i < length; i++) {
mono[i] += channelData[i]
}
}
if (numberOfChannels > 1) {
for (let i = 0; i < length; i++) {
mono[i] /= numberOfChannels
}
}

return { samples: mono, sampleRate }
}
73 changes: 73 additions & 0 deletions src/beat-grid/detection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { detectBeatGrid } from './detection'

/** Synthesize a mono click track: impulses every `period` samples starting at `offsetSec`. */
function generateClickTrack(
bpm: number,
offsetSecs: number,
durationSecs: number,
sampleRate: number,
): Float32Array {
const samples = new Float32Array(Math.ceil(durationSecs * sampleRate))
const beatIntervalSamples = (60 / bpm) * sampleRate
let pos = offsetSecs * sampleRate
while (pos < samples.length) {
const idx = Math.round(pos)
// Short exponential-decay click so energy is detectable within a hop window
for (let j = 0; j < 32 && idx + j < samples.length; j++) {
samples[idx + j] += Math.pow(0.7, j)
}
pos += beatIntervalSamples
}
return samples
}

describe('detectBeatGrid — BPM accuracy', () => {
it('detects BPM within ±2% from a 120 BPM synthesized click track', () => {
const sampleRate = 44100
const bpm = 120
const samples = generateClickTrack(bpm, 0.1, 10, sampleRate)
const grid = detectBeatGrid(samples, sampleRate)
expect(grid.bpm / bpm).toBeGreaterThan(0.98)
expect(grid.bpm / bpm).toBeLessThan(1.02)
})

it('detects BPM within ±2% from a 90 BPM synthesized click track', () => {
const sampleRate = 44100
const bpm = 90
const samples = generateClickTrack(bpm, 0.05, 10, sampleRate)
const grid = detectBeatGrid(samples, sampleRate)
expect(grid.bpm / bpm).toBeGreaterThan(0.98)
expect(grid.bpm / bpm).toBeLessThan(1.02)
})
})

describe('detectBeatGrid — first beat offset accuracy', () => {
it('detects first beat offset within ±30ms', () => {
const sampleRate = 44100
const bpm = 120
const offsetSecs = 0.1
const samples = generateClickTrack(bpm, offsetSecs, 10, sampleRate)
const grid = detectBeatGrid(samples, sampleRate)
expect(Math.abs(grid.firstBeatOffsetSecs - offsetSecs)).toBeLessThan(0.030)
})

it('detects near-zero offset within ±30ms', () => {
const sampleRate = 44100
const bpm = 120
const offsetSecs = 0.02
const samples = generateClickTrack(bpm, offsetSecs, 10, sampleRate)
const grid = detectBeatGrid(samples, sampleRate)
expect(Math.abs(grid.firstBeatOffsetSecs - offsetSecs)).toBeLessThan(0.030)
})
})

describe('detectBeatGrid — beat interval', () => {
it('returns beatIntervalSecs consistent with detected BPM', () => {
const sampleRate = 44100
const bpm = 120
const samples = generateClickTrack(bpm, 0.1, 10, sampleRate)
const grid = detectBeatGrid(samples, sampleRate)
expect(Math.abs(grid.beatIntervalSecs - 60 / grid.bpm)).toBeLessThan(0.001)
})
})
98 changes: 98 additions & 0 deletions src/beat-grid/detection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { BeatGrid } from './types'

const HOP_SIZE = 512

/** Compute RMS energy per hop. */
function computeEnergy(samples: Float32Array): Float32Array {
const nFrames = Math.floor(samples.length / HOP_SIZE)
const energy = new Float32Array(nFrames)
for (let f = 0; f < nFrames; f++) {
const start = f * HOP_SIZE
const end = start + HOP_SIZE
let sum = 0
for (let i = start; i < end; i++) {
sum += samples[i] * samples[i]
}
energy[f] = Math.sqrt(sum / HOP_SIZE)
}
return energy
}

/** Onset strength = positive first difference of energy. */
function computeOnsetStrength(energy: Float32Array): Float32Array {
const onset = new Float32Array(energy.length)
for (let f = 1; f < energy.length; f++) {
onset[f] = Math.max(0, energy[f] - energy[f - 1])
}
return onset
}

/**
* Find the lag in [minLag, maxLag] that maximises autocorrelation of the
* onset strength signal. Uses only the first `maxFrames` of the signal to
* keep runtime predictable on long tracks.
*/
function findBeatPeriodLag(
onset: Float32Array,
minLag: number,
maxLag: number,
maxFrames = 2600,
): number {
const n = Math.min(onset.length, maxFrames)
let bestLag = minLag
let bestCorr = -Infinity

for (let lag = minLag; lag <= maxLag; lag++) {
let corr = 0
for (let i = 0; i < n - lag; i++) {
corr += onset[i] * onset[i + lag]
}
if (corr > bestCorr) {
bestCorr = corr
bestLag = lag
}
}
return bestLag
}

/**
* Given a detected beat period in hops, find the phase offset (in hops from
* the start) that best aligns with the onset peaks.
*/
function findBeatPhase(onset: Float32Array, periodLag: number): number {
const phaseStrength = new Float32Array(periodLag)
for (let i = 0; i < onset.length; i++) {
phaseStrength[i % periodLag] += onset[i]
}
let bestPhase = 0
let bestStrength = -Infinity
for (let p = 0; p < periodLag; p++) {
if (phaseStrength[p] > bestStrength) {
bestStrength = phaseStrength[p]
bestPhase = p
}
}
return bestPhase
}

/**
* Estimate BPM, beat interval, and first-beat offset from raw mono PCM data.
* Pure math — no browser APIs.
*/
export function detectBeatGrid(samples: Float32Array, sampleRate: number): BeatGrid {
const energy = computeEnergy(samples)
const onset = computeOnsetStrength(energy)

// Lag range corresponding to [60, 200] BPM
const lagMin = Math.max(1, Math.floor((sampleRate * 60) / (200 * HOP_SIZE)))
const lagMax = Math.max(lagMin, Math.floor((sampleRate * 60) / (60 * HOP_SIZE)))

const bestLag = findBeatPeriodLag(onset, lagMin, lagMax)
const bpm = (60 * sampleRate) / (HOP_SIZE * bestLag)
const beatIntervalSecs = 60 / bpm

const bestPhase = findBeatPhase(onset, bestLag)
const firstBeatOffsetSecs = (bestPhase * HOP_SIZE) / sampleRate

return { bpm, beatIntervalSecs, firstBeatOffsetSecs }
}
4 changes: 4 additions & 0 deletions src/beat-grid/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type { BeatGrid } from './types'
export { detectBeatGrid } from './detection'
export { nudge } from './nudge'
export { decodeMono } from './adapter'
76 changes: 76 additions & 0 deletions src/beat-grid/nudge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { nudge } from './nudge'
import type { BeatGrid } from './types'

// 120 BPM @ 30fps → beatFrames = 15
const GRID_120: BeatGrid = { bpm: 120, beatIntervalSecs: 0.5, firstBeatOffsetSecs: 0 }
const FPS = 30
const BEAT_F = 15 // frames per beat at 120 BPM / 30fps

describe('nudge — beat sync on, energy=medium (AC2)', () => {
it('returns an exact beat multiple for an exact-beat target', () => {
const result = nudge(90, GRID_120, 'medium', FPS) // 6 beats exactly
expect(result % BEAT_F).toBe(0)
expect(result).toBe(90)
})

it('snaps and deviation from passed target is ≤ half a beat interval', () => {
// target=92 → nearest beat is 90 (6 beats), deviation=2 < 7.5
const result = nudge(92, GRID_120, 'medium', FPS)
expect(result % BEAT_F).toBe(0)
expect(Math.abs(result - 92)).toBeLessThanOrEqual(BEAT_F / 2)
})

it('snaps up when target is closer to the next beat', () => {
// target=99 → nearest beat is 105 (7 beats), deviation=6 < 7.5
const result = nudge(99, GRID_120, 'medium', FPS)
expect(result % BEAT_F).toBe(0)
expect(Math.abs(result - 99)).toBeLessThanOrEqual(BEAT_F / 2)
})

it('snapped result always lands on a beat (is a multiple of beatFrames)', () => {
for (const target of [60, 65, 72, 80, 90, 93, 99, 105]) {
const result = nudge(target, GRID_120, 'medium', FPS)
expect(result % BEAT_F).toBe(0)
}
})
})

describe('nudge — energy effect on average duration (AC4)', () => {
it('calm produces longer average duration than punchy', () => {
const targets = [60, 75, 90, 105, 120, 135, 150]
const avg = (vals: number[]) => vals.reduce((a, b) => a + b, 0) / vals.length

const calmResults = targets.map(t => nudge(t, GRID_120, 'calm', FPS))
const punchyResults = targets.map(t => nudge(t, GRID_120, 'punchy', FPS))

expect(avg(calmResults)).toBeGreaterThan(avg(punchyResults))
})

it('calm produces longer or equal average duration than medium', () => {
const targets = [60, 75, 90, 105, 120]
const avg = (vals: number[]) => vals.reduce((a, b) => a + b, 0) / vals.length

const calmResults = targets.map(t => nudge(t, GRID_120, 'calm', FPS))
const mediumResults = targets.map(t => nudge(t, GRID_120, 'medium', FPS))

expect(avg(calmResults)).toBeGreaterThanOrEqual(avg(mediumResults))
})

it('punchy produces shorter or equal average duration than medium', () => {
const targets = [60, 75, 90, 105, 120]
const avg = (vals: number[]) => vals.reduce((a, b) => a + b, 0) / vals.length

const punchyResults = targets.map(t => nudge(t, GRID_120, 'punchy', FPS))
const mediumResults = targets.map(t => nudge(t, GRID_120, 'medium', FPS))

expect(avg(punchyResults)).toBeLessThanOrEqual(avg(mediumResults))
})
})

describe('nudge — minimum duration', () => {
it('never returns zero or negative frames', () => {
const result = nudge(1, GRID_120, 'punchy', FPS)
expect(result).toBeGreaterThan(0)
})
})
31 changes: 31 additions & 0 deletions src/beat-grid/nudge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Energy } from '../timeline-core/settings'
import type { BeatGrid } from './types'

// Energy multipliers: calm extends target (longer slides), punchy compresses it (shorter).
const ENERGY_MULTIPLIER: Record<Energy, number> = {
calm: 1.5,
medium: 1.0,
punchy: 0.67,
}

/**
* Snap a slide's target duration to the nearest beat boundary.
*
* Energy scales the target before snapping so that calm energy tends toward
* longer slides and punchy toward shorter ones. For energy=medium the
* multiplier is 1.0, so deviation from the passed-in target is always
* ≤ half a beat interval — the basic beat-sync contract.
*/
export function nudge(
targetDurationFrames: number,
beatGrid: BeatGrid,
energy: Energy,
fps = 30,
): number {
const beatFrames = beatGrid.beatIntervalSecs * fps
if (beatFrames <= 0) return Math.round(targetDurationFrames)

const scaledTarget = targetDurationFrames * ENERGY_MULTIPLIER[energy]
const beatCount = Math.max(1, Math.round(scaledTarget / beatFrames))
return Math.round(beatCount * beatFrames)
}
5 changes: 5 additions & 0 deletions src/beat-grid/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type BeatGrid = {
bpm: number
firstBeatOffsetSecs: number
beatIntervalSecs: number
}
2 changes: 2 additions & 0 deletions src/project-store/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { GlobalSettings, SlideOverrides, ThemeName } from '../timeline-core/settings'
import type { BeatGrid } from '../beat-grid/types'

export const SCHEMA_VERSION = 1
export const SLIDESHOW_FILE = 'slideshow.json'
Expand Down Expand Up @@ -31,6 +32,7 @@ export type SlideshowJson = {
slides: SerializedSlide[]
soundtrackFilename?: string
themeName?: ThemeName
beatGridCache?: BeatGrid
}

export function isSlideshowJson(v: unknown): v is SlideshowJson {
Expand Down
Loading
Loading