Skip to content
Closed
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
91 changes: 91 additions & 0 deletions src/backend/bpm.ts
Original file line number Diff line number Diff line change
@@ -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<number, number> = {}
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)]
}
3 changes: 2 additions & 1 deletion src/backend/pattern/breathe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 6 additions & 5 deletions src/backend/pattern/extra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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) => {
Expand All @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion src/backend/pattern/fftRipple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
2 changes: 1 addition & 1 deletion src/backend/pattern/musicRipple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
3 changes: 2 additions & 1 deletion src/backend/pattern/wave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
6 changes: 5 additions & 1 deletion src/backend/wsAudio.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { WebSocketServer } from 'ws'
import fftjs from 'fft-js'
import { processBeat } from './bpm'
const { fft, util } = fftjs

export interface AudioState {
hue: number
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 })
Expand All @@ -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)
Expand Down