diff --git a/client/src/Color.tsx b/client/src/Color.tsx index 3404e33..ebfa957 100644 --- a/client/src/Color.tsx +++ b/client/src/Color.tsx @@ -9,7 +9,6 @@ let lastTimestamp: number export default function Color() { const tgWebAppDataRef = useRef(null) const [color, setColor] = useState({ r: 255, g: 0, b: 255, a: 0 }) - useEffect(() => { const initData = urlParseHashParams(location.hash) tgWebAppDataRef.current = initData.tgWebAppData @@ -27,7 +26,6 @@ export default function Color() { body: JSON.stringify({ color: [c.r, c.g, c.b], alpha: c.a, tgWebAppData }), }) } - return (
{ + const mic = record.start({ sampleRate: 44100, channels: 1 }) + mic.on('data', chunk => ws.send(chunk)) +}) diff --git a/package.json b/package.json index 7e81bf9..e20a0ae 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,9 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/tough-cookie": "^4.0.5", - "@types/winston": "^2.4.4", - "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", @@ -51,12 +52,16 @@ "d3-interpolate": "^3.0.1", "dotenv": "^16.4.5", "express": "^4.18.2", + "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", - "winston": "^3.17.0", - "zod": "^3.25.57" - } + "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" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0c9c1b..0a9cd6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,12 @@ importers: express: specifier: ^4.18.2 version: 4.21.2 + fft-js: + specifier: ^0.0.12 + version: 0.0.12 + node-record-lpcm16: + specifier: ^1.0.1 + version: 1.0.1 react: specifier: ^19.1.0 version: 19.1.0 @@ -53,6 +59,9 @@ importers: winston: specifier: ^3.17.0 version: 3.17.0 + ws: + specifier: ^8.18.2 + version: 8.18.2 zod: specifier: ^3.25.57 version: 3.25.57 @@ -87,6 +96,9 @@ importers: '@types/winston': specifier: ^2.4.4 version: 2.4.4 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 axios: specifier: ^0.27.2 version: 0.27.2 @@ -1079,6 +1091,9 @@ packages: resolution: {integrity: sha512-BVGCztsypW8EYwJ+Hq+QNYiT/MUyCif0ouBH+flrY66O5W+KIXAMML6E/0fJpm7VjIzgangahl5S03bJJQGrZw==} deprecated: This is a stub types definition. winston provides its own type definitions, so you do not need this installed. + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1221,6 +1236,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bit-twiddle@1.0.2: + resolution: {integrity: sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA==} + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1352,6 +1370,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@2.7.1: + resolution: {integrity: sha512-5qK/Wsc2fnRCiizV1JlHavWrSGAXQI7AusK423F8zJLwIGq8lmtO5GmO8PVMrtDUJMwTXOFBzSN6OCRD8CEMWw==} + engines: {node: '>= 0.6.x'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1669,6 +1691,10 @@ packages: fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + fft-js@0.0.12: + resolution: {integrity: sha512-nLOa0/SYYnN2NPcLrI81UNSPxyg3q0sGiltfe9G1okg0nxs5CqAwtmaqPQdGcOryeGURaCoQx8Y4AUkhGTh7IQ==} + engines: {node: '>=0.12.0'} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -1803,6 +1829,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graceful-readlink@1.0.1: + resolution: {integrity: sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==} + grammy-inline-menu@8.0.1: resolution: {integrity: sha512-+Yxp/K+CTY1YYL6GZ23rv6S37JLTu2D47nvSJdnluNqRu6F7Cz7ZrnnosSJ2CkqM92LoPxq3eUsMFwf8msG27w==} engines: {node: '>=14'} @@ -2302,6 +2331,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-record-lpcm16@1.0.1: + resolution: {integrity: sha512-H75GMOP8ErnF67m21+qSgj4USnzv5RLfm7OkEItdIi+soNKoJZpMQPX6umM8Cn9nVPSgd/dBUtc1msst5MmABA==} + node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} @@ -3045,6 +3077,18 @@ packages: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + ws@8.18.2: + resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3972,6 +4016,10 @@ snapshots: dependencies: winston: 3.17.0 + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.0 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -4143,6 +4191,8 @@ snapshots: balanced-match@1.0.2: {} + bit-twiddle@1.0.2: {} + body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -4288,6 +4338,10 @@ snapshots: commander@13.1.0: {} + commander@2.7.1: + dependencies: + graceful-readlink: 1.0.1 + commander@4.1.1: {} concat-map@0.0.1: {} @@ -4685,6 +4739,11 @@ snapshots: fecha@4.2.3: {} + fft-js@0.0.12: + dependencies: + bit-twiddle: 1.0.2 + commander: 2.7.1 + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -4830,6 +4889,8 @@ snapshots: graceful-fs@4.2.11: {} + graceful-readlink@1.0.1: {} + grammy-inline-menu@8.0.1(grammy@1.36.3): dependencies: grammy: 1.36.3 @@ -5454,6 +5515,12 @@ snapshots: node-int64@0.4.0: {} + node-record-lpcm16@1.0.1: + dependencies: + debug: 2.6.9 + transitivePeerDependencies: + - supports-color + node-releases@2.0.19: {} normalize-path@3.0.0: {} @@ -6173,6 +6240,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 3.0.7 + ws@8.18.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/src/backend/index.ts b/src/backend/index.ts index 57383c4..c3db664 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -4,6 +4,7 @@ import { startRouterIntegration } from './router' import { startUdpServer } from './udp' import { config } from './config' import { startNightChecks } from './night' +import { startAudioServer } from './wsAudio' // eslint-disable-next-line no-undef process.env.TZ = config.TZ @@ -14,6 +15,7 @@ export function init() { if (initted) return initted = true startLoop() + startAudioServer() startNightChecks() startTelegram() startUdpServer() diff --git a/src/backend/pattern/fftRipple.ts b/src/backend/pattern/fftRipple.ts new file mode 100644 index 0000000..df5ca29 --- /dev/null +++ b/src/backend/pattern/fftRipple.ts @@ -0,0 +1,73 @@ +import { IColorGetter, IColorMapper, IArrColor } from 'src/typings' +import { callIndexedGetter } from './mappers' +import { pixelsCount, hueToColor } from '../shared' +import { settings } from 'src/settings' +import { audioState } from '../wsAudio' + +interface Ripple { + pos: number + radius: number + brightness: number + hue: number +} + +let ripples: Ripple[] = [] +let lastTime = Date.now() +let averages: number[] = [] +const attenuation = 0.9 +const speed = 60 + +function spawnRipple(bin: number, mag: number) { + const hue = (bin / audioState.bins.length) * 270 + ripples.push({ + pos: (bin / audioState.bins.length) * pixelsCount, + radius: 1, + brightness: Math.min(1, mag), + hue, + }) +} + +function update(time: number) { + const dt = (time - lastTime) * settings.effectSpeed + lastTime = time + const bins = audioState.bins + if (bins && bins.length) { + if (averages.length !== bins.length) averages = bins.slice() + 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) + } + } + ripples.forEach(r => { + r.radius += (speed * dt) / 1000 + r.brightness *= Math.pow(attenuation, dt / 16) + }) + ripples = ripples.filter(r => r.brightness > 0.05) +} + +export const getFftRippleColor: IColorGetter = (index, time) => { + if (index === 0) update(time) + let r = 0 + let g = 0 + let b = 0 + for (const ripple of ripples) { + const dist = Math.abs(index - ripple.pos) + if (dist <= ripple.radius) { + const intensity = ripple.brightness * (1 - dist / ripple.radius) + const { r: rr, g: gg, b: bb } = hueToColor(ripple.hue).rgb() + r += rr * intensity + g += gg * intensity + b += bb * intensity + } + } + return [Math.min(255, Math.round(r)), Math.min(255, Math.round(g)), Math.min(255, Math.round(b))] +} + +export function resetFftRipples() { + ripples = [] + lastTime = Date.now() + averages = [] +} + +export const fftRippleMapper: IColorMapper = () => callIndexedGetter(getFftRippleColor) diff --git a/src/backend/pattern/index.ts b/src/backend/pattern/index.ts index a9acb1e..7168c85 100644 --- a/src/backend/pattern/index.ts +++ b/src/backend/pattern/index.ts @@ -8,6 +8,9 @@ import { getPlasmaColor } from './plasma' import { getBreatheColor } from './breathe' import { getWaveColor } from './wave' import { getHeartbeatColor, getStrobeColor, getPulseColor, getGradientPulseColor, getMultiPulseColor } from './extra' +import { rippleMapper } from './ripple' +import { musicRippleMapper } from './musicRipple' +import { fftRippleMapper } from './fftRipple' import { createIndexedMapper, createFlatMapper } from './mappers' const transitionDuration = 250 @@ -35,6 +38,9 @@ const mappers: Record = { [IMode.Pulse]: createIndexedMapper(getPulseColor), [IMode.GradientPulse]: createIndexedMapper(getGradientPulseColor), [IMode.MultiPulse]: createIndexedMapper(getMultiPulseColor), + [IMode.Ripple]: rippleMapper, + [IMode.MusicRipple]: musicRippleMapper, + [IMode.FftRipple]: fftRippleMapper, } export function getPixels(mode: IMode): IArrColor[][] { diff --git a/src/backend/pattern/musicRipple.ts b/src/backend/pattern/musicRipple.ts new file mode 100644 index 0000000..77edf1c --- /dev/null +++ b/src/backend/pattern/musicRipple.ts @@ -0,0 +1,52 @@ +import { IColorGetter, IColorMapper, IArrColor } from 'src/typings' +import { callIndexedGetter } from './mappers' +import { pixelsCount, hueToColor } from '../shared' +import { settings } from 'src/settings' +import { audioState } from '../wsAudio' + +interface Ripple { + pos: number + radius: number + speed: number + color: IArrColor +} + +let ripples: Ripple[] = [] +let lastTime = Date.now() + +function spawnRipple() { + const { r, g, b } = hueToColor(audioState.hue).rgb() + ripples.push({ pos: Math.random() * pixelsCount, radius: 1, speed: 50 + audioState.level * 200, color: [r, g, b] }) +} + +function update(time: number) { + const dt = (time - lastTime) * settings.effectSpeed + lastTime = time + if (audioState.level > 0.1) spawnRipple() + ripples.forEach(r => (r.radius += (r.speed * dt) / 1000)) + ripples = ripples.filter(r => r.radius < pixelsCount * 2) +} + +export const getMusicRippleColor: IColorGetter = (index, time) => { + if (index === 0) update(time) + let r = 0 + let g = 0 + let b = 0 + for (const ripple of ripples) { + const dist = Math.abs(index - ripple.pos) + const t = ripple.radius > 0 ? 1 - dist / ripple.radius : dist === 0 ? 1 : 0 + if (t > 0) { + r += ripple.color[0] * t + g += ripple.color[1] * t + b += ripple.color[2] * t + } + } + return [Math.min(255, Math.round(r)), Math.min(255, Math.round(g)), Math.min(255, Math.round(b))] +} + +export function resetMusicRipples() { + ripples = [] + lastTime = 0 +} + +export const musicRippleMapper: IColorMapper = () => callIndexedGetter(getMusicRippleColor) diff --git a/src/backend/pattern/ripple.ts b/src/backend/pattern/ripple.ts new file mode 100644 index 0000000..102ef0e --- /dev/null +++ b/src/backend/pattern/ripple.ts @@ -0,0 +1,58 @@ +import { IColorMapper, IArrColor, IColorGetter } from 'src/typings' +import { callIndexedGetter } from './mappers' +import { pixelsCount, hueToColor } from '../shared' +import { settings } from 'src/settings' + +interface Ripple { + pos: number + radius: number + speed: number + color: IArrColor +} + +let ripples: Ripple[] = [] +let lastTime = Date.now() +let spawnTimer = 0 +const spawnInterval = 1000 + +function spawnRipple() { + const { r, g, b } = hueToColor(Math.random() * 360).rgb() + ripples.push({ pos: Math.random() * pixelsCount, radius: 1, speed: 50, color: [r, g, b] }) +} + +function update(time: number) { + const dt = (time - lastTime) * settings.effectSpeed + lastTime = time + spawnTimer += dt + while (spawnTimer >= spawnInterval) { + spawnTimer -= spawnInterval + spawnRipple() + } + ripples.forEach(r => (r.radius += (r.speed * dt) / 1000)) + ripples = ripples.filter(r => r.radius < pixelsCount * 2) +} + +export const getRippleColor: IColorGetter = (index, time) => { + if (index === 0) update(time) + let r = 0 + let g = 0 + let b = 0 + for (const ripple of ripples) { + const dist = Math.abs(index - ripple.pos) + const t = ripple.radius > 0 ? 1 - dist / ripple.radius : dist === 0 ? 1 : 0 + if (t > 0) { + r += ripple.color[0] * t + g += ripple.color[1] * t + b += ripple.color[2] * t + } + } + return [Math.min(255, Math.round(r)), Math.min(255, Math.round(g)), Math.min(255, Math.round(b))] +} + +export function resetRipples() { + ripples = [] + lastTime = 0 + spawnTimer = 0 +} + +export const rippleMapper: IColorMapper = () => callIndexedGetter(getRippleColor) diff --git a/src/backend/telegram/settings.ts b/src/backend/telegram/settings.ts index 5626a59..61af6d1 100644 --- a/src/backend/telegram/settings.ts +++ b/src/backend/telegram/settings.ts @@ -42,6 +42,8 @@ export function selectMode(menuTemplate: MenuTemplate) { [IMode.Pulse]: '🔆', [IMode.GradientPulse]: '🎇', [IMode.MultiPulse]: '🎆', + [IMode.Ripple]: '💧', + [IMode.MusicRipple]: '🎶', }, { formatState, diff --git a/src/backend/wsAudio.ts b/src/backend/wsAudio.ts new file mode 100644 index 0000000..7b5f268 --- /dev/null +++ b/src/backend/wsAudio.ts @@ -0,0 +1,41 @@ +import { WebSocketServer } from 'ws' +import fftjs from 'fft-js' +const { fft, util } = fftjs + +export interface AudioState { + hue: number + level: number + freq: number + bins: number[] +} + +export const audioState: AudioState = { hue: 0, level: 0, freq: 0, bins: [] } + +export function startAudioServer(port = 8081) { + const wss = new WebSocketServer({ port }) + wss.on('connection', ws => { + ws.on('message', data => { + if (Buffer.isBuffer(data)) processAudio(data) + }) + }) +} + +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) + const spectrum = fft(input) + const mags = util.fftMag(spectrum) + audioState.bins = mags.slice(0, mags.length / 2) + let max = 0 + let idx = 0 + for (let i = 1; i < mags.length / 2; i++) { + if (mags[i] > max) { + max = mags[i] + idx = i + } + } + const freq = (idx * sampleRate) / input.length + audioState.freq = freq + audioState.hue = (idx / (mags.length / 2)) * 360 + audioState.level = max / (mags.length / 2) +} diff --git a/src/settings.ts b/src/settings.ts index adb0ef4..9f47ff8 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -6,8 +6,8 @@ export const settings: ISettings = { progress: { current: 0, total: 0, lastUpdate: null }, nightOverride: false, geoOverride: false, + mixColorWithNoise: false, mixRatio: 0, effectSpeed: 1, - mixColorWithNoise: false, alive: new Date(), } diff --git a/src/types/fft-js.d.ts b/src/types/fft-js.d.ts new file mode 100644 index 0000000..6c145b8 --- /dev/null +++ b/src/types/fft-js.d.ts @@ -0,0 +1 @@ +declare module 'fft-js' diff --git a/src/typings.ts b/src/typings.ts index 79462ac..6f0827b 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -25,6 +25,9 @@ export enum IMode { Pulse, GradientPulse, MultiPulse, + Ripple, + MusicRipple, + FftRipple, } export interface ISettings { diff --git a/tests/fftRipple.test.ts b/tests/fftRipple.test.ts new file mode 100644 index 0000000..c15e164 --- /dev/null +++ b/tests/fftRipple.test.ts @@ -0,0 +1,17 @@ +import { getFftRippleColor, resetFftRipples } from '../src/backend/pattern/fftRipple' +import { audioState } from '../src/backend/wsAudio' + +beforeEach(() => { + resetFftRipples() + audioState.bins = Array(8).fill(0) +}) + +describe('fft ripple pattern', () => { + test('spawns ripple on spike', () => { + audioState.bins[0] = 2 + ;(getFftRippleColor as any)(0, 0) + audioState.bins[0] = 10 + const color = (getFftRippleColor as any)(0, 16) + expect(color).not.toEqual([0, 0, 0]) + }) +}) diff --git a/tests/musicRipple.test.ts b/tests/musicRipple.test.ts new file mode 100644 index 0000000..873ba62 --- /dev/null +++ b/tests/musicRipple.test.ts @@ -0,0 +1,19 @@ +import { getMusicRippleColor, resetMusicRipples } from '../src/backend/pattern/musicRipple' +import { audioState } from '../src/backend/wsAudio' + +beforeEach(() => { + resetMusicRipples() + audioState.hue = 0 + audioState.level = 0 +}) + +describe('music ripple pattern', () => { + test('spawns ripple when level high', () => { + const orig = Math.random + ;(Math as any).random = () => 0 + audioState.level = 1 + audioState.hue = 0 + expect((getMusicRippleColor as any)(0, 0)).toEqual([255, 0, 0]) + Math.random = orig + }) +}) diff --git a/tests/ripple.test.ts b/tests/ripple.test.ts new file mode 100644 index 0000000..dfdd1f9 --- /dev/null +++ b/tests/ripple.test.ts @@ -0,0 +1,16 @@ +import { getRippleColor, resetRipples } from '../src/backend/pattern/ripple' + +beforeEach(() => { + resetRipples() +}) + +describe('ripple pattern', () => { + test('spawns ripple after interval', () => { + const orig = Math.random + ;(Math as any).random = () => 0 + expect((getRippleColor as any)(0, 0)).toEqual([0, 0, 0]) + const color = (getRippleColor as any)(0, 1000) + expect(color).toEqual([255, 0, 0]) + Math.random = orig + }) +}) diff --git a/tests/wsAudio.test.ts b/tests/wsAudio.test.ts new file mode 100644 index 0000000..82de93c --- /dev/null +++ b/tests/wsAudio.test.ts @@ -0,0 +1,19 @@ +import { processAudio, audioState } from '../src/backend/wsAudio' + +function genSine(freq: number, samples: number, sampleRate: number) { + const buf = Buffer.alloc(samples * 2) + for (let i = 0; i < samples; i++) { + const v = Math.sin((2 * Math.PI * freq * i) / sampleRate) + 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) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index bcec80e..0a95265 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,4 +15,6 @@ "src/*": ["src/*"] } } + , + "include": ["src/types/**/*.d.ts", "**/*.ts", "**/*.tsx"] }