From 4f6389def6099a05163a8fb619ed2b69ae26bdf2 Mon Sep 17 00:00:00 2001 From: sliterok <12751644+sliterok@users.noreply.github.com> Date: Wed, 11 Jun 2025 22:42:51 +0200 Subject: [PATCH 1/2] feat: fft ripples --- src/backend/pattern/fftMirrorRipple.ts | 78 ++++++++++++++++++++++++++ src/backend/pattern/fftRandomRipple.ts | 76 +++++++++++++++++++++++++ src/backend/pattern/fftRipple.ts | 5 +- src/backend/pattern/index.ts | 6 +- src/backend/pattern/musicRipple.ts | 52 ----------------- src/backend/telegram/settings.ts | 4 +- src/typings.ts | 3 +- tests/fftMirrorRipple.test.ts | 17 ++++++ tests/fftRandomRipple.test.ts | 20 +++++++ tests/musicRipple.test.ts | 19 ------- 10 files changed, 204 insertions(+), 76 deletions(-) create mode 100644 src/backend/pattern/fftMirrorRipple.ts create mode 100644 src/backend/pattern/fftRandomRipple.ts delete mode 100644 src/backend/pattern/musicRipple.ts create mode 100644 tests/fftMirrorRipple.test.ts create mode 100644 tests/fftRandomRipple.test.ts delete mode 100644 tests/musicRipple.test.ts diff --git a/src/backend/pattern/fftMirrorRipple.ts b/src/backend/pattern/fftMirrorRipple.ts new file mode 100644 index 0000000..b60f24c --- /dev/null +++ b/src/backend/pattern/fftMirrorRipple.ts @@ -0,0 +1,78 @@ +import { IColorGetter, IColorMapper } 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[] = [] +let hueShift = 0 +const attenuation = 0.9 +const speed = 60 +const shiftSpeed = 40 + +function spawnRipple(bin: number, mag: number) { + const hue = ((bin / audioState.bins.length) * 360 + hueShift) % 360 + const pos = (bin / audioState.bins.length) * pixelsCount + const data = { + radius: 1, + brightness: Math.min(1, mag), + hue, + } + ripples.push({ pos, ...data }) + ripples.push({ pos: pixelsCount - pos, ...data }) +} + +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) + hueShift = (hueShift + (shiftSpeed * dt) / 1000) % 360 +} + +export const getFftMirrorRippleColor: 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 resetFftMirrorRipples() { + ripples = [] + lastTime = Date.now() + averages = [] +} + +export const fftMirrorRippleMapper: IColorMapper = () => callIndexedGetter(getFftMirrorRippleColor) diff --git a/src/backend/pattern/fftRandomRipple.ts b/src/backend/pattern/fftRandomRipple.ts new file mode 100644 index 0000000..af80e23 --- /dev/null +++ b/src/backend/pattern/fftRandomRipple.ts @@ -0,0 +1,76 @@ +import { IColorGetter, IColorMapper } 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[] = [] +let hueShift = 0 +const attenuation = 0.9 +const speed = 60 +const shiftSpeed = 40 + +function spawnRipple(bin: number, mag: number) { + const hue = ((bin / audioState.bins.length) * 360 + hueShift) % 360 + ripples.push({ + pos: Math.random() * 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) + hueShift = (hueShift + (shiftSpeed * dt) / 1000) % 360 +} + +export const getFftRandomRippleColor: 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 resetFftRandomRipples() { + ripples = [] + lastTime = Date.now() + averages = [] +} + +export const fftRandomRippleMapper: IColorMapper = () => callIndexedGetter(getFftRandomRippleColor) diff --git a/src/backend/pattern/fftRipple.ts b/src/backend/pattern/fftRipple.ts index df5ca29..cb03ab5 100644 --- a/src/backend/pattern/fftRipple.ts +++ b/src/backend/pattern/fftRipple.ts @@ -14,11 +14,13 @@ interface Ripple { let ripples: Ripple[] = [] let lastTime = Date.now() let averages: number[] = [] +let hueShift = 0 const attenuation = 0.9 const speed = 60 +const shiftSpeed = 40 function spawnRipple(bin: number, mag: number) { - const hue = (bin / audioState.bins.length) * 270 + const hue = ((bin / audioState.bins.length) * 360 + hueShift) % 360 ripples.push({ pos: (bin / audioState.bins.length) * pixelsCount, radius: 1, @@ -44,6 +46,7 @@ function update(time: number) { r.brightness *= Math.pow(attenuation, dt / 16) }) ripples = ripples.filter(r => r.brightness > 0.05) + hueShift = (hueShift + (shiftSpeed * dt) / 1000) % 360 } export const getFftRippleColor: IColorGetter = (index, time) => { diff --git a/src/backend/pattern/index.ts b/src/backend/pattern/index.ts index 7168c85..8cc86a9 100644 --- a/src/backend/pattern/index.ts +++ b/src/backend/pattern/index.ts @@ -9,8 +9,9 @@ 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 { fftRandomRippleMapper } from './fftRandomRipple' +import { fftMirrorRippleMapper } from './fftMirrorRipple' import { createIndexedMapper, createFlatMapper } from './mappers' const transitionDuration = 250 @@ -39,8 +40,9 @@ const mappers: Record = { [IMode.GradientPulse]: createIndexedMapper(getGradientPulseColor), [IMode.MultiPulse]: createIndexedMapper(getMultiPulseColor), [IMode.Ripple]: rippleMapper, - [IMode.MusicRipple]: musicRippleMapper, [IMode.FftRipple]: fftRippleMapper, + [IMode.FftRandomRipple]: fftRandomRippleMapper, + [IMode.FftMirrorRipple]: fftMirrorRippleMapper, } export function getPixels(mode: IMode): IArrColor[][] { diff --git a/src/backend/pattern/musicRipple.ts b/src/backend/pattern/musicRipple.ts deleted file mode 100644 index 77edf1c..0000000 --- a/src/backend/pattern/musicRipple.ts +++ /dev/null @@ -1,52 +0,0 @@ -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/telegram/settings.ts b/src/backend/telegram/settings.ts index 61af6d1..2596a0f 100644 --- a/src/backend/telegram/settings.ts +++ b/src/backend/telegram/settings.ts @@ -43,7 +43,9 @@ export function selectMode(menuTemplate: MenuTemplate) { [IMode.GradientPulse]: '🎇', [IMode.MultiPulse]: '🎆', [IMode.Ripple]: '💧', - [IMode.MusicRipple]: '🎶', + [IMode.FftRipple]: '🎶', + [IMode.FftRandomRipple]: '🔀', + [IMode.FftMirrorRipple]: '🔁', }, { formatState, diff --git a/src/typings.ts b/src/typings.ts index 6f0827b..1ade0ad 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -26,8 +26,9 @@ export enum IMode { GradientPulse, MultiPulse, Ripple, - MusicRipple, FftRipple, + FftRandomRipple, + FftMirrorRipple, } export interface ISettings { diff --git a/tests/fftMirrorRipple.test.ts b/tests/fftMirrorRipple.test.ts new file mode 100644 index 0000000..ca3bcc7 --- /dev/null +++ b/tests/fftMirrorRipple.test.ts @@ -0,0 +1,17 @@ +import { getFftMirrorRippleColor, resetFftMirrorRipples } from '../src/backend/pattern/fftMirrorRipple' +import { audioState } from '../src/backend/wsAudio' + +beforeEach(() => { + resetFftMirrorRipples() + audioState.bins = Array(8).fill(0) +}) + +describe('fft mirror ripple pattern', () => { + test('spawns ripple on spike', () => { + audioState.bins[0] = 2 + ;(getFftMirrorRippleColor as any)(0, 0) + audioState.bins[0] = 10 + const color = (getFftMirrorRippleColor as any)(0, 16) + expect(color).not.toEqual([0, 0, 0]) + }) +}) diff --git a/tests/fftRandomRipple.test.ts b/tests/fftRandomRipple.test.ts new file mode 100644 index 0000000..9a7dfa6 --- /dev/null +++ b/tests/fftRandomRipple.test.ts @@ -0,0 +1,20 @@ +import { getFftRandomRippleColor, resetFftRandomRipples } from '../src/backend/pattern/fftRandomRipple' +import { audioState } from '../src/backend/wsAudio' + +beforeEach(() => { + resetFftRandomRipples() + audioState.bins = Array(8).fill(0) +}) + +describe('fft random ripple pattern', () => { + test('spawns ripple on spike', () => { + const orig = Math.random + ;(Math as any).random = () => 0 + audioState.bins[0] = 2 + ;(getFftRandomRippleColor as any)(0, 0) + audioState.bins[0] = 10 + const color = (getFftRandomRippleColor as any)(0, 16) + expect(color).not.toEqual([0, 0, 0]) + Math.random = orig + }) +}) diff --git a/tests/musicRipple.test.ts b/tests/musicRipple.test.ts deleted file mode 100644 index 873ba62..0000000 --- a/tests/musicRipple.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 - }) -}) From d24b244331679eb0fe65b96eee7f04c54fea16fb Mon Sep 17 00:00:00 2001 From: sliterok <12751644+sliterok@users.noreply.github.com> Date: Thu, 12 Jun 2025 11:19:49 +0200 Subject: [PATCH 2/2] Improve random ripple --- src/backend/pattern/fftRandomRipple.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/backend/pattern/fftRandomRipple.ts b/src/backend/pattern/fftRandomRipple.ts index af80e23..8cbb5c3 100644 --- a/src/backend/pattern/fftRandomRipple.ts +++ b/src/backend/pattern/fftRandomRipple.ts @@ -18,11 +18,16 @@ let hueShift = 0 const attenuation = 0.9 const speed = 60 const shiftSpeed = 40 +const offsetRange = pixelsCount * 0.25 function spawnRipple(bin: number, mag: number) { const hue = ((bin / audioState.bins.length) * 360 + hueShift) % 360 + let pos = (bin / audioState.bins.length) * pixelsCount + pos += (Math.random() - 0.5) * offsetRange + if (pos < 0) pos = 0 + if (pos >= pixelsCount) pos = pixelsCount - 1 ripples.push({ - pos: Math.random() * pixelsCount, + pos, radius: 1, brightness: Math.min(1, mag), hue,