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
22 changes: 11 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 28 additions & 10 deletions src/backend/pattern/extra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
1 change: 1 addition & 0 deletions src/backend/telegram/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
43 changes: 39 additions & 4 deletions src/backend/wsAudio.ts
Original file line number Diff line number Diff line change
@@ -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 })
Expand All @@ -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<number>(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
Expand All @@ -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
}
1 change: 1 addition & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const settings: ISettings = {
nightOverride: false,
geoOverride: false,
mixColorWithNoise: false,
syncToMusic: false,
mixRatio: 0,
effectSpeed: 1,
alive: new Date(),
Expand Down
1 change: 1 addition & 0 deletions src/types/music-tempo.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module 'music-tempo'
1 change: 1 addition & 0 deletions src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface ISettings {
nightOverride: boolean
geoOverride: boolean
mixColorWithNoise: boolean
syncToMusic: boolean
mixRatio: number
effectSpeed: number
alive: Date
Expand Down
36 changes: 29 additions & 7 deletions tests/wsAudio.test.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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)
})

})