Skip to content

Commit 414fc40

Browse files
authored
Merge pull request #44 from sliterok/codex/update-heartbeat-strobe-to-use-audio-bpm
feat: bpm sync
2 parents 4ee563b + 4ec3563 commit 414fc40

File tree

9 files changed

+119
-32
lines changed

9 files changed

+119
-32
lines changed

package.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@
2424
"@types/react": "^19.0.0",
2525
"@types/react-dom": "^19.0.0",
2626
"@types/tough-cookie": "^4.0.5",
27-
"@types/winston": "^2.4.4",
28-
"@types/ws": "^8.18.1",
29-
"axios": "^0.27.2",
27+
"@types/winston": "^2.4.4",
28+
"@types/ws": "^8.18.1",
29+
"axios": "^0.27.2",
3030
"axios-cookiejar-support": "^4.0.7",
3131
"eslint": "^8.57.0",
3232
"grammy": "^1.22.4",
@@ -52,16 +52,16 @@
5252
"d3-interpolate": "^3.0.1",
5353
"dotenv": "^16.4.5",
5454
"express": "^4.18.2",
55-
55+
"fft-js": "^0.0.12",
56+
"music-tempo": "^1.0.3",
57+
"node-record-lpcm16": "^1.0.1",
5658
"react": "^19.1.0",
5759
"react-colorful": "^5.6.1",
5860
"react-dom": "^19.1.0",
5961
"react-router-dom": "^6.23.0",
60-
"ring-buffer-ts": "^1.2.0",
61-
"ws": "^8.18.2",
62-
"fft-js": "^0.0.12",
63-
"node-record-lpcm16": "^1.0.1",
64-
"winston": "^3.17.0",
65-
"zod": "^3.25.57"
66-
}
62+
"ring-buffer-ts": "^1.2.0",
63+
"winston": "^3.17.0",
64+
"ws": "^8.18.2",
65+
"zod": "^3.25.57"
66+
}
6767
}

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/backend/pattern/extra.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { IColorGetter, IArrColor } from 'src/typings'
22
import { hslToRgb } from 'src/helpers'
33
import { settings } from 'src/settings'
44
import { pixelsCount, hueToColor } from '../shared'
5+
import { audioState } from '../wsAudio'
56

67
export const getHeartbeatColor: IColorGetter = (_, time) => {
7-
const cycle = 1000
8+
const beat = settings.syncToMusic && audioState.bpm ? 60000 / audioState.bpm : 1000
9+
const cycle = beat
810
const t = (time * settings.effectSpeed) % cycle
911

1012
if (t < lastHeartbeat) {
@@ -16,33 +18,35 @@ export const getHeartbeatColor: IColorGetter = (_, time) => {
1618
const fade = Math.min(t / 200, 1)
1719
const baseColor = prevHeartbeat.map((c, i) => c + (nextHeartbeat[i] - c) * fade) as IArrColor
1820

19-
const first = pulseIntensity(t, 0)
20-
const second = pulseIntensity(t, 250)
21+
const first = pulseIntensity(t, 0, cycle)
22+
const second = pulseIntensity(t, cycle / 4, cycle)
2123
const intensity = Math.max(first, second)
2224

2325
return baseColor.map(c => Math.round(c * intensity)) as IArrColor
2426
}
2527

26-
const pulseDuration = 200
27-
function pulseIntensity(t: number, offset: number) {
28+
const pulseBaseDuration = 200
29+
function pulseIntensity(t: number, offset: number, cycle: number) {
30+
const duration = settings.syncToMusic ? cycle * (pulseBaseDuration / 1000) : pulseBaseDuration
2831
const dt = t - offset
29-
if (dt < 0 || dt >= pulseDuration) return 0
30-
const ratio = dt / pulseDuration
32+
if (dt < 0 || dt >= duration) return 0
33+
const ratio = dt / duration
3134
return Math.sin(ratio * Math.PI)
3235
}
3336

3437
export const getStrobeColor: IColorGetter = (_, time) => {
35-
const interval = 200
38+
const interval = settings.syncToMusic && audioState.bpm ? 60000 / audioState.bpm : 200
3639
const t = (time * settings.effectSpeed) % interval
3740
if (t < lastStrobe) {
3841
strobeColor = hslToRgb(Math.random() * 360, 1, 0.5)
3942
}
4043
lastStrobe = t
41-
return t < 40 ? strobeColor : [0, 0, 0]
44+
const flash = settings.syncToMusic ? interval * 0.2 : 40
45+
return t < flash ? strobeColor : [0, 0, 0]
4246
}
4347

4448
export const getPulseColor: IColorGetter = (_, time) => {
45-
const cycle = 1000
49+
const cycle = settings.syncToMusic && audioState.bpm ? 60000 / audioState.bpm : 1000
4650
const t = (time * settings.effectSpeed) % cycle
4751
if (t < lastPulse) {
4852
pulseColor = hslToRgb(Math.random() * 360, 1, 0.5)
@@ -86,3 +90,17 @@ let lastMultiPulse = 0
8690
let prevHeartbeat: IArrColor = hslToRgb(Math.random() * 360, 1, 0.5)
8791
let nextHeartbeat: IArrColor = prevHeartbeat
8892
let lastHeartbeat = 0
93+
94+
export function resetExtraPatterns() {
95+
strobeColor = [255, 255, 255]
96+
lastStrobe = 0
97+
pulseColor = [255, 0, 0]
98+
lastPulse = 0
99+
multiPulseColors = Array(4)
100+
.fill(null)
101+
.map(() => hslToRgb(Math.random() * 360, 1, 0.5))
102+
lastMultiPulse = 0
103+
prevHeartbeat = hslToRgb(Math.random() * 360, 1, 0.5)
104+
nextHeartbeat = prevHeartbeat
105+
lastHeartbeat = 0
106+
}

src/backend/telegram/menu.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function createMenuTemplate() {
2929
toggleSetting(menuTemplate, 'Night override', 'nightOverride')
3030
toggleSetting(menuTemplate, 'GEO override', 'geoOverride')
3131
toggleSetting(menuTemplate, 'Mix color with noise', 'mixColorWithNoise')
32+
toggleSetting(menuTemplate, 'Sync to music', 'syncToMusic')
3233

3334
selectMode(menuTemplate)
3435

src/backend/wsAudio.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import { WebSocketServer } from 'ws'
22
import fftjs from 'fft-js'
3+
import MusicTempo from 'music-tempo'
4+
// eslint-disable-next-line import/default
5+
import RingBufferTs from 'ring-buffer-ts'
6+
const RingBuffer = RingBufferTs.RingBuffer
37
const { fft, util } = fftjs
48

59
export interface AudioState {
610
hue: number
711
level: number
812
freq: number
13+
bpm: number
914
bins: number[]
1015
}
1116

12-
export const audioState: AudioState = { hue: 0, level: 0, freq: 0, bins: [] }
17+
export const audioState: AudioState = { hue: 0, level: 0, freq: 0, bpm: 0, bins: [] }
1318

1419
export function startAudioServer(port = 8081) {
1520
const wss = new WebSocketServer({ port })
@@ -20,10 +25,18 @@ export function startAudioServer(port = 8081) {
2025
})
2126
}
2227

23-
export function processAudio(buffer: Buffer, sampleRate = 44100) {
28+
const sampleRateDefault = 44100
29+
const bpmWindow = sampleRateDefault * 8
30+
const sampleBuffer = new RingBuffer<number>(bpmWindow)
31+
let lastBpmUpdate = 0
32+
33+
export function processAudio(buffer: Buffer, sampleRate = sampleRateDefault) {
2434
const samples = new Int16Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 2)
2535
const input = Array.from(samples, s => s / 32768)
26-
const spectrum = fft(input)
36+
for (const s of input) sampleBuffer.add(s)
37+
38+
const size = 1 << Math.floor(Math.log2(input.length || 1))
39+
const spectrum = fft(input.slice(0, size))
2740
const mags = util.fftMag(spectrum)
2841
audioState.bins = mags.slice(0, mags.length / 2)
2942
let max = 0
@@ -34,8 +47,30 @@ export function processAudio(buffer: Buffer, sampleRate = 44100) {
3447
idx = i
3548
}
3649
}
37-
const freq = (idx * sampleRate) / input.length
50+
const freq = (idx * sampleRate) / size
3851
audioState.freq = freq
3952
audioState.hue = (idx / (mags.length / 2)) * 360
4053
audioState.level = max / (mags.length / 2)
54+
55+
const now = Date.now()
56+
if (now - lastBpmUpdate > 2000 && sampleBuffer.getBufferLength() >= sampleRate * 4) {
57+
lastBpmUpdate = now
58+
try {
59+
const mt = new MusicTempo(Float32Array.from(sampleBuffer.toArray()))
60+
const tempo = parseFloat(String(mt.tempo))
61+
if (!Number.isNaN(tempo)) audioState.bpm = tempo
62+
} catch {
63+
// ignore errors
64+
}
65+
}
66+
}
67+
68+
export function resetAudioState() {
69+
audioState.hue = 0
70+
audioState.level = 0
71+
audioState.freq = 0
72+
audioState.bpm = 0
73+
audioState.bins = []
74+
sampleBuffer.clear()
75+
lastBpmUpdate = 0
4176
}

src/settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const settings: ISettings = {
77
nightOverride: false,
88
geoOverride: false,
99
mixColorWithNoise: false,
10+
syncToMusic: false,
1011
mixRatio: 0,
1112
effectSpeed: 1,
1213
alive: new Date(),

src/types/music-tempo.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
declare module 'music-tempo'

src/typings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface ISettings {
3737
nightOverride: boolean
3838
geoOverride: boolean
3939
mixColorWithNoise: boolean
40+
syncToMusic: boolean
4041
mixRatio: number
4142
effectSpeed: number
4243
alive: Date

tests/wsAudio.test.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { processAudio, audioState } from '../src/backend/wsAudio'
1+
import { processAudio, audioState, resetAudioState } from '../src/backend/wsAudio'
22

33
function genSine(freq: number, samples: number, sampleRate: number) {
44
const buf = Buffer.alloc(samples * 2)
@@ -9,11 +9,33 @@ function genSine(freq: number, samples: number, sampleRate: number) {
99
return buf
1010
}
1111

12+
function genBeat(bpm: number, seconds: number, sampleRate: number) {
13+
const total = Math.round(seconds * sampleRate)
14+
const buf = Buffer.alloc(total * 2)
15+
const period = Math.round((60 / bpm) * sampleRate)
16+
for (let i = 0; i < total; i++) {
17+
const v = i % period === 0 ? 1 : 0
18+
buf.writeInt16LE(Math.round(v * 32767), i * 2)
19+
}
20+
return buf
21+
}
22+
1223
describe('processAudio', () => {
13-
test('detects frequency', () => {
14-
const buf = genSine(440, 1024, 44100)
15-
processAudio(buf)
16-
expect(audioState.freq).toBeGreaterThan(430)
17-
expect(audioState.freq).toBeLessThan(450)
18-
})
24+
beforeEach(() => {
25+
resetAudioState()
26+
})
27+
test('detects frequency', () => {
28+
const buf = genSine(440, 1234, 44100)
29+
processAudio(buf)
30+
expect(audioState.freq).toBeGreaterThan(430)
31+
expect(audioState.freq).toBeLessThan(450)
32+
})
33+
34+
test('detects bpm', () => {
35+
const buf = genBeat(120, 4, 44100)
36+
processAudio(buf)
37+
expect(audioState.bpm).toBeGreaterThan(110)
38+
expect(audioState.bpm).toBeLessThan(130)
39+
})
40+
1941
})

0 commit comments

Comments
 (0)