diff --git a/src/backend/bpm.ts b/src/backend/bpm.ts new file mode 100644 index 0000000..be2ffd0 --- /dev/null +++ b/src/backend/bpm.ts @@ -0,0 +1,91 @@ +// BPM detection from microphone audio +// Uses envelope follower and onset detection with simple autocorrelation +import { audioState } from './wsAudio' + +let lastSample = 0 +let envelope = 0 +let prevEnv = 0 +const envelopeWindow = 0.05 // 50 ms +const thresholdWindow = 1 // 1 second +let sampleCount = 0 +let sampleRate = 44100 +const energyHistory: number[] = [] +const onsetTimes: number[] = [] +const bpmHistory: number[] = [] + +export function resetBeat() { + lastSample = 0 + envelope = 0 + prevEnv = 0 + sampleCount = 0 + energyHistory.length = 0 + onsetTimes.length = 0 + bpmHistory.length = 0 + audioState.bpm = 0 + audioState.beat = 0 +} + +export function processBeat(samples: number[], rate: number) { + sampleRate = rate + const envAlpha = 1 / (rate * envelopeWindow) + for (const s of samples) { + const diff = s - lastSample + lastSample = s + const energy = diff > 0 ? diff * diff : 0 + envelope += (energy - envelope) * envAlpha + energyHistory.push(envelope) + if (energyHistory.length > rate * thresholdWindow) energyHistory.shift() + if (energyHistory.length === rate * thresholdWindow) { + const mean = energyHistory.reduce((a, b) => a + b, 0) / energyHistory.length + const std = Math.sqrt(energyHistory.reduce((a, b) => a + (b - mean) ** 2, 0) / energyHistory.length) + const threshold = mean + std * 1.5 + if (envelope > threshold && prevEnv <= threshold) registerOnset(sampleCount / rate) + prevEnv = envelope + } + sampleCount++ + } + decayBeat() +} + +function registerOnset(time: number) { + onsetTimes.push(time) + while (onsetTimes.length && time - onsetTimes[0] > 8) onsetTimes.shift() + computeBpm() + audioState.beat = 1 + lastBeat = Date.now() +} + +let lastBeat = 0 +function decayBeat() { + const dt = Date.now() - lastBeat + audioState.beat = Math.max(0, 1 - dt / 200) +} + +function computeBpm() { + if (onsetTimes.length < 2) return + const counts: Record = {} + for (let i = 1; i < onsetTimes.length; i++) { + for (let j = 0; j < i; j++) { + const lag = onsetTimes[i] - onsetTimes[j] + if (lag < 0.33 || lag > 1) continue + const key = Math.round(lag * 100) + counts[key] = (counts[key] || 0) + 1 + } + } + let bestKey = 0 + let bestVal = 0 + for (const k in counts) { + const v = counts[k] + if (v > bestVal) { + bestVal = v + bestKey = parseInt(k) + } + } + if (bestVal === 0) return + const interval = bestKey / 100 + const bpm = Math.round(60 / interval) + bpmHistory.push(bpm) + if (bpmHistory.length > 3) bpmHistory.shift() + const sorted = bpmHistory.slice().sort((a, b) => a - b) + audioState.bpm = sorted[Math.floor(sorted.length / 2)] +} diff --git a/src/backend/pattern/breathe.ts b/src/backend/pattern/breathe.ts index 900dd1c..7af6681 100644 --- a/src/backend/pattern/breathe.ts +++ b/src/backend/pattern/breathe.ts @@ -2,11 +2,12 @@ import { IColorGetter } from 'src/typings' import { hslToRgb } from 'src/helpers' import { dynamic } from '../shared' import { settings } from 'src/settings' +import { audioState } from '../wsAudio' export const getBreatheColor: IColorGetter = (_, time) => { const t = (time * settings.effectSpeed) / 1000 const hue = (t * 20) % 360 - let amplitude = 0.25 * (1 - dynamic.overrideRatio) + let amplitude = 0.25 * (1 - dynamic.overrideRatio) * (1 + audioState.beat) if (dynamic.overrideRatio > 0.8) amplitude = 0 const light = 0.5 + amplitude * Math.sin(t / 2) return hslToRgb(hue, 1, light) diff --git a/src/backend/pattern/extra.ts b/src/backend/pattern/extra.ts index 47aa2cd..369fa5e 100644 --- a/src/backend/pattern/extra.ts +++ b/src/backend/pattern/extra.ts @@ -2,6 +2,7 @@ import { IColorGetter, IArrColor } from 'src/typings' import { hslToRgb } from 'src/helpers' import { settings } from 'src/settings' import { pixelsCount, hueToColor } from '../shared' +import { audioState } from '../wsAudio' export const getHeartbeatColor: IColorGetter = (_, time) => { const cycle = 1000 @@ -18,7 +19,7 @@ export const getHeartbeatColor: IColorGetter = (_, time) => { const first = pulseIntensity(t, 0) const second = pulseIntensity(t, 250) - const intensity = Math.max(first, second) + const intensity = Math.min(1, Math.max(first, second) * (1 + audioState.beat)) return baseColor.map(c => Math.round(c * intensity)) as IArrColor } @@ -38,7 +39,7 @@ export const getStrobeColor: IColorGetter = (_, time) => { strobeColor = hslToRgb(Math.random() * 360, 1, 0.5) } lastStrobe = t - return t < 40 ? strobeColor : [0, 0, 0] + return t < 40 * (1 + audioState.beat) ? strobeColor : [0, 0, 0] } export const getPulseColor: IColorGetter = (_, time) => { @@ -48,7 +49,7 @@ export const getPulseColor: IColorGetter = (_, time) => { pulseColor = hslToRgb(Math.random() * 360, 1, 0.5) } lastPulse = t - const intensity = Math.sin((t / cycle) * Math.PI) + const intensity = Math.min(1, Math.sin((t / cycle) * Math.PI) * (1 + audioState.beat)) return pulseColor.map(c => Math.round(c * intensity)) as IArrColor } @@ -59,7 +60,7 @@ export const getGradientPulseColor: IColorGetter = (index, time) => { const { r, g, b } = hueToColor(hue).rgb() const cycle = 1000 const pulse = t % cycle - const intensity = Math.sin((pulse / cycle) * Math.PI) + const intensity = Math.min(1, Math.sin((pulse / cycle) * Math.PI) * (1 + audioState.beat)) return [Math.round(r * intensity), Math.round(g * intensity), Math.round(b * intensity)] as IArrColor } @@ -71,7 +72,7 @@ export const getMultiPulseColor: IColorGetter = (index, time) => { } lastMultiPulse = t const segment = Math.min(Math.floor((index / pixelsCount) * multiPulseColors.length), multiPulseColors.length - 1) - const intensity = Math.sin((t / cycle) * Math.PI) + const intensity = Math.min(1, Math.sin((t / cycle) * Math.PI) * (1 + audioState.beat)) return multiPulseColors[segment].map((c: number) => Math.round(c * intensity)) as IArrColor } diff --git a/src/backend/pattern/fftRipple.ts b/src/backend/pattern/fftRipple.ts index df5ca29..1b09c33 100644 --- a/src/backend/pattern/fftRipple.ts +++ b/src/backend/pattern/fftRipple.ts @@ -36,7 +36,7 @@ function update(time: number) { for (let i = 0; i < bins.length; i++) { const m = bins[i] averages[i] = averages[i] * 0.9 + m * 0.1 - if (m > averages[i] * 1.5) spawnRipple(i, m) + if (m > averages[i] * (1.5 - audioState.beat)) spawnRipple(i, m) } } ripples.forEach(r => { diff --git a/src/backend/pattern/musicRipple.ts b/src/backend/pattern/musicRipple.ts index 77edf1c..3eb42ea 100644 --- a/src/backend/pattern/musicRipple.ts +++ b/src/backend/pattern/musicRipple.ts @@ -22,7 +22,7 @@ function spawnRipple() { function update(time: number) { const dt = (time - lastTime) * settings.effectSpeed lastTime = time - if (audioState.level > 0.1) spawnRipple() + if (audioState.level > 0.1 || audioState.beat > 0.5) spawnRipple() ripples.forEach(r => (r.radius += (r.speed * dt) / 1000)) ripples = ripples.filter(r => r.radius < pixelsCount * 2) } diff --git a/src/backend/pattern/wave.ts b/src/backend/pattern/wave.ts index bcc0b4f..fc16a63 100644 --- a/src/backend/pattern/wave.ts +++ b/src/backend/pattern/wave.ts @@ -2,11 +2,12 @@ import { IColorGetter } from 'src/typings' import { hslToRgb } from 'src/helpers' import { pixelsCount } from '../shared' import { settings } from 'src/settings' +import { audioState } from '../wsAudio' export const getWaveColor: IColorGetter = (index, time) => { const x = index / pixelsCount const t = (time * settings.effectSpeed) / 1000 const hue = (t * 15 + x * 120) % 360 - const light = 0.5 + 0.25 * Math.sin(x * 4 - t) + const light = 0.5 + 0.25 * Math.sin(x * 4 - t) * (1 + audioState.beat) return hslToRgb(hue, 1, light) } diff --git a/src/backend/wsAudio.ts b/src/backend/wsAudio.ts index 7b5f268..31df6be 100644 --- a/src/backend/wsAudio.ts +++ b/src/backend/wsAudio.ts @@ -1,5 +1,6 @@ import { WebSocketServer } from 'ws' import fftjs from 'fft-js' +import { processBeat } from './bpm' const { fft, util } = fftjs export interface AudioState { @@ -7,9 +8,11 @@ export interface AudioState { level: number freq: number bins: number[] + bpm: number + beat: number } -export const audioState: AudioState = { hue: 0, level: 0, freq: 0, bins: [] } +export const audioState: AudioState = { hue: 0, level: 0, freq: 0, bins: [], bpm: 0, beat: 0 } export function startAudioServer(port = 8081) { const wss = new WebSocketServer({ port }) @@ -23,6 +26,7 @@ export function startAudioServer(port = 8081) { export function processAudio(buffer: Buffer, sampleRate = 44100) { const samples = new Int16Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 2) const input = Array.from(samples, s => s / 32768) + processBeat(input, sampleRate) const spectrum = fft(input) const mags = util.fftMag(spectrum) audioState.bins = mags.slice(0, mags.length / 2)