diff --git a/package.json b/package.json index e20a0ae..74ca811 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/tough-cookie": "^4.0.5", - "@types/winston": "^2.4.4", - "@types/ws": "^8.18.1", - "axios": "^0.27.2", + "@types/winston": "^2.4.4", + "@types/ws": "^8.18.1", + "axios": "^0.27.2", "axios-cookiejar-support": "^4.0.7", "eslint": "^8.57.0", "grammy": "^1.22.4", @@ -52,16 +52,16 @@ "d3-interpolate": "^3.0.1", "dotenv": "^16.4.5", "express": "^4.18.2", - + "fft-js": "^0.0.12", + "music-tempo": "^1.0.3", + "node-record-lpcm16": "^1.0.1", "react": "^19.1.0", "react-colorful": "^5.6.1", "react-dom": "^19.1.0", "react-router-dom": "^6.23.0", - "ring-buffer-ts": "^1.2.0", - "ws": "^8.18.2", - "fft-js": "^0.0.12", - "node-record-lpcm16": "^1.0.1", - "winston": "^3.17.0", - "zod": "^3.25.57" - } + "ring-buffer-ts": "^1.2.0", + "winston": "^3.17.0", + "ws": "^8.18.2", + "zod": "^3.25.57" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a9cd6c..7fef3ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: fft-js: specifier: ^0.0.12 version: 0.0.12 + music-tempo: + specifier: ^1.0.3 + version: 1.0.3 node-record-lpcm16: specifier: ^1.0.1 version: 1.0.1 @@ -2297,6 +2300,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + music-tempo@1.0.3: + resolution: {integrity: sha512-qAocTKLp3jaSeJLeGs98mkkpLYDFM1VCevA1OPFJvLPkHlzwTLUxChXMgnxZRHKSJQ13DuaT+Fr51BEUb2D4pQ==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -5493,6 +5499,8 @@ snapshots: ms@2.1.3: {} + music-tempo@1.0.3: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 diff --git a/src/backend/pattern/extra.ts b/src/backend/pattern/extra.ts index 47aa2cd..3603b41 100644 --- a/src/backend/pattern/extra.ts +++ b/src/backend/pattern/extra.ts @@ -2,9 +2,11 @@ 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 + const beat = settings.syncToMusic && audioState.bpm ? 60000 / audioState.bpm : 1000 + const cycle = beat const t = (time * settings.effectSpeed) % cycle if (t < lastHeartbeat) { @@ -16,33 +18,35 @@ export const getHeartbeatColor: IColorGetter = (_, time) => { const fade = Math.min(t / 200, 1) const baseColor = prevHeartbeat.map((c, i) => c + (nextHeartbeat[i] - c) * fade) as IArrColor - const first = pulseIntensity(t, 0) - const second = pulseIntensity(t, 250) + const first = pulseIntensity(t, 0, cycle) + const second = pulseIntensity(t, cycle / 4, cycle) const intensity = Math.max(first, second) return baseColor.map(c => Math.round(c * intensity)) as IArrColor } -const pulseDuration = 200 -function pulseIntensity(t: number, offset: number) { +const pulseBaseDuration = 200 +function pulseIntensity(t: number, offset: number, cycle: number) { + const duration = settings.syncToMusic ? cycle * (pulseBaseDuration / 1000) : pulseBaseDuration const dt = t - offset - if (dt < 0 || dt >= pulseDuration) return 0 - const ratio = dt / pulseDuration + if (dt < 0 || dt >= duration) return 0 + const ratio = dt / duration return Math.sin(ratio * Math.PI) } export const getStrobeColor: IColorGetter = (_, time) => { - const interval = 200 + const interval = settings.syncToMusic && audioState.bpm ? 60000 / audioState.bpm : 200 const t = (time * settings.effectSpeed) % interval if (t < lastStrobe) { strobeColor = hslToRgb(Math.random() * 360, 1, 0.5) } lastStrobe = t - return t < 40 ? strobeColor : [0, 0, 0] + const flash = settings.syncToMusic ? interval * 0.2 : 40 + return t < flash ? strobeColor : [0, 0, 0] } export const getPulseColor: IColorGetter = (_, time) => { - const cycle = 1000 + const cycle = settings.syncToMusic && audioState.bpm ? 60000 / audioState.bpm : 1000 const t = (time * settings.effectSpeed) % cycle if (t < lastPulse) { pulseColor = hslToRgb(Math.random() * 360, 1, 0.5) @@ -86,3 +90,17 @@ let lastMultiPulse = 0 let prevHeartbeat: IArrColor = hslToRgb(Math.random() * 360, 1, 0.5) let nextHeartbeat: IArrColor = prevHeartbeat let lastHeartbeat = 0 + +export function resetExtraPatterns() { + strobeColor = [255, 255, 255] + lastStrobe = 0 + pulseColor = [255, 0, 0] + lastPulse = 0 + multiPulseColors = Array(4) + .fill(null) + .map(() => hslToRgb(Math.random() * 360, 1, 0.5)) + lastMultiPulse = 0 + prevHeartbeat = hslToRgb(Math.random() * 360, 1, 0.5) + nextHeartbeat = prevHeartbeat + lastHeartbeat = 0 +} diff --git a/src/backend/telegram/menu.ts b/src/backend/telegram/menu.ts index def6603..52bb78e 100644 --- a/src/backend/telegram/menu.ts +++ b/src/backend/telegram/menu.ts @@ -29,6 +29,7 @@ export function createMenuTemplate() { toggleSetting(menuTemplate, 'Night override', 'nightOverride') toggleSetting(menuTemplate, 'GEO override', 'geoOverride') toggleSetting(menuTemplate, 'Mix color with noise', 'mixColorWithNoise') + toggleSetting(menuTemplate, 'Sync to music', 'syncToMusic') selectMode(menuTemplate) diff --git a/src/backend/wsAudio.ts b/src/backend/wsAudio.ts index 7b5f268..0690789 100644 --- a/src/backend/wsAudio.ts +++ b/src/backend/wsAudio.ts @@ -1,15 +1,20 @@ import { WebSocketServer } from 'ws' import fftjs from 'fft-js' +import MusicTempo from 'music-tempo' +// eslint-disable-next-line import/default +import RingBufferTs from 'ring-buffer-ts' +const RingBuffer = RingBufferTs.RingBuffer const { fft, util } = fftjs export interface AudioState { hue: number level: number freq: number + bpm: number bins: number[] } -export const audioState: AudioState = { hue: 0, level: 0, freq: 0, bins: [] } +export const audioState: AudioState = { hue: 0, level: 0, freq: 0, bpm: 0, bins: [] } export function startAudioServer(port = 8081) { const wss = new WebSocketServer({ port }) @@ -20,10 +25,18 @@ export function startAudioServer(port = 8081) { }) } -export function processAudio(buffer: Buffer, sampleRate = 44100) { +const sampleRateDefault = 44100 +const bpmWindow = sampleRateDefault * 8 +const sampleBuffer = new RingBuffer(bpmWindow) +let lastBpmUpdate = 0 + +export function processAudio(buffer: Buffer, sampleRate = sampleRateDefault) { const samples = new Int16Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 2) const input = Array.from(samples, s => s / 32768) - const spectrum = fft(input) + for (const s of input) sampleBuffer.add(s) + + const size = 1 << Math.floor(Math.log2(input.length || 1)) + const spectrum = fft(input.slice(0, size)) const mags = util.fftMag(spectrum) audioState.bins = mags.slice(0, mags.length / 2) let max = 0 @@ -34,8 +47,30 @@ export function processAudio(buffer: Buffer, sampleRate = 44100) { idx = i } } - const freq = (idx * sampleRate) / input.length + const freq = (idx * sampleRate) / size audioState.freq = freq audioState.hue = (idx / (mags.length / 2)) * 360 audioState.level = max / (mags.length / 2) + + const now = Date.now() + if (now - lastBpmUpdate > 2000 && sampleBuffer.getBufferLength() >= sampleRate * 4) { + lastBpmUpdate = now + try { + const mt = new MusicTempo(Float32Array.from(sampleBuffer.toArray())) + const tempo = parseFloat(String(mt.tempo)) + if (!Number.isNaN(tempo)) audioState.bpm = tempo + } catch { + // ignore errors + } + } +} + +export function resetAudioState() { + audioState.hue = 0 + audioState.level = 0 + audioState.freq = 0 + audioState.bpm = 0 + audioState.bins = [] + sampleBuffer.clear() + lastBpmUpdate = 0 } diff --git a/src/settings.ts b/src/settings.ts index 9f47ff8..54521d8 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -7,6 +7,7 @@ export const settings: ISettings = { nightOverride: false, geoOverride: false, mixColorWithNoise: false, + syncToMusic: false, mixRatio: 0, effectSpeed: 1, alive: new Date(), diff --git a/src/types/music-tempo.d.ts b/src/types/music-tempo.d.ts new file mode 100644 index 0000000..66c0a80 --- /dev/null +++ b/src/types/music-tempo.d.ts @@ -0,0 +1 @@ +declare module 'music-tempo' diff --git a/src/typings.ts b/src/typings.ts index 6f0827b..1f93704 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -37,6 +37,7 @@ export interface ISettings { nightOverride: boolean geoOverride: boolean mixColorWithNoise: boolean + syncToMusic: boolean mixRatio: number effectSpeed: number alive: Date diff --git a/tests/wsAudio.test.ts b/tests/wsAudio.test.ts index 82de93c..b52fefa 100644 --- a/tests/wsAudio.test.ts +++ b/tests/wsAudio.test.ts @@ -1,4 +1,4 @@ -import { processAudio, audioState } from '../src/backend/wsAudio' +import { processAudio, audioState, resetAudioState } from '../src/backend/wsAudio' function genSine(freq: number, samples: number, sampleRate: number) { const buf = Buffer.alloc(samples * 2) @@ -9,11 +9,33 @@ function genSine(freq: number, samples: number, sampleRate: number) { return buf } +function genBeat(bpm: number, seconds: number, sampleRate: number) { + const total = Math.round(seconds * sampleRate) + const buf = Buffer.alloc(total * 2) + const period = Math.round((60 / bpm) * sampleRate) + for (let i = 0; i < total; i++) { + const v = i % period === 0 ? 1 : 0 + buf.writeInt16LE(Math.round(v * 32767), i * 2) + } + return buf +} + describe('processAudio', () => { - test('detects frequency', () => { - const buf = genSine(440, 1024, 44100) - processAudio(buf) - expect(audioState.freq).toBeGreaterThan(430) - expect(audioState.freq).toBeLessThan(450) - }) + beforeEach(() => { + resetAudioState() + }) + test('detects frequency', () => { + const buf = genSine(440, 1234, 44100) + processAudio(buf) + expect(audioState.freq).toBeGreaterThan(430) + expect(audioState.freq).toBeLessThan(450) + }) + + test('detects bpm', () => { + const buf = genBeat(120, 4, 44100) + processAudio(buf) + expect(audioState.bpm).toBeGreaterThan(110) + expect(audioState.bpm).toBeLessThan(130) + }) + })