diff --git a/src/backend/pattern/fftMirrorRipple.ts b/src/backend/pattern/fftMirrorRipple.ts new file mode 100644 index 0000000..377c463 --- /dev/null +++ b/src/backend/pattern/fftMirrorRipple.ts @@ -0,0 +1,84 @@ +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 cooldowns: number[] = [] +let hueShift = 0 +const attenuation = 0.9 +const speed = 60 +const spawnCooldown = 200 + +function spawnRipple(bin: number, mag: number, avg: number) { + const hue = (bin / audioState.bins.length) * 360 + hueShift + const brightness = Math.min(1, Math.max(0, (mag - avg) / avg)) + const pos = (bin / audioState.bins.length) * pixelsCount + ripples.push({ pos, radius: 1, brightness, hue }) + ripples.push({ pos: pixelsCount - pos, radius: 1, brightness, hue }) +} + +function update(time: number) { + const dt = (time - lastTime) * settings.effectSpeed + lastTime = time + hueShift = (hueShift + dt * 0.05) % 360 + const bins = audioState.bins + if (bins && bins.length) { + if (averages.length !== bins.length) { + averages = bins.slice() + cooldowns = new Array(bins.length).fill(0) + } + for (let i = 0; i < bins.length; i++) { + const m = bins[i] + averages[i] = averages[i] * 0.9 + m * 0.1 + cooldowns[i] -= dt + if (m > averages[i] * 1.5 && cooldowns[i] <= 0) { + spawnRipple(i, m, averages[i]) + cooldowns[i] = spawnCooldown + } + } + } + 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 getFftMirrorColor: 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)) / ripple.radius + const { r: rr, g: gg, b: bb } = hueToColor(ripple.hue).rgb() + r = Math.max(r, rr * intensity) + g = Math.max(g, gg * intensity) + b = Math.max(b, bb * intensity) + } + } + return [Math.min(255, Math.round(r)), Math.min(255, Math.round(g)), Math.min(255, Math.round(b))] +} + +export function resetFftMirror() { + ripples = [] + lastTime = 0 + averages = [] + cooldowns = [] + hueShift = 0 +} + +export const fftMirrorMapper: IColorMapper = () => callIndexedGetter(getFftMirrorColor) diff --git a/src/backend/pattern/fftRandomRipple.ts b/src/backend/pattern/fftRandomRipple.ts new file mode 100644 index 0000000..44e1e2d --- /dev/null +++ b/src/backend/pattern/fftRandomRipple.ts @@ -0,0 +1,87 @@ +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 cooldowns: number[] = [] +let hueShift = 0 +const attenuation = 0.9 +const speed = 60 +const spawnCooldown = 200 + +function spawnRipple(bin: number, mag: number, avg: number) { + const hue = (bin / audioState.bins.length) * 360 + hueShift + const brightness = Math.min(1, Math.max(0, (mag - avg) / avg)) + ripples.push({ + pos: Math.random() * pixelsCount, + radius: 1, + brightness, + hue, + }) +} + +function update(time: number) { + const dt = (time - lastTime) * settings.effectSpeed + lastTime = time + hueShift = (hueShift + dt * 0.05) % 360 + const bins = audioState.bins + if (bins && bins.length) { + if (averages.length !== bins.length) { + averages = bins.slice() + cooldowns = new Array(bins.length).fill(0) + } + for (let i = 0; i < bins.length; i++) { + const m = bins[i] + averages[i] = averages[i] * 0.9 + m * 0.1 + cooldowns[i] -= dt + if (m > averages[i] * 1.5 && cooldowns[i] <= 0) { + spawnRipple(i, m, averages[i]) + cooldowns[i] = spawnCooldown + } + } + } + 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 getFftRandomColor: 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)) / ripple.radius + const { r: rr, g: gg, b: bb } = hueToColor(ripple.hue).rgb() + r = Math.max(r, rr * intensity) + g = Math.max(g, gg * intensity) + b = Math.max(b, bb * intensity) + } + } + return [Math.min(255, Math.round(r)), Math.min(255, Math.round(g)), Math.min(255, Math.round(b))] +} + +export function resetFftRandom() { + ripples = [] + lastTime = 0 + averages = [] + cooldowns = [] + hueShift = 0 +} + +export const fftRandomMapper: IColorMapper = () => callIndexedGetter(getFftRandomColor) diff --git a/src/backend/pattern/fftRipple.ts b/src/backend/pattern/fftRipple.ts index df5ca29..520e464 100644 --- a/src/backend/pattern/fftRipple.ts +++ b/src/backend/pattern/fftRipple.ts @@ -14,15 +14,19 @@ interface Ripple { let ripples: Ripple[] = [] let lastTime = Date.now() let averages: number[] = [] +let cooldowns: number[] = [] +let hueShift = 0 const attenuation = 0.9 const speed = 60 +const spawnCooldown = 200 -function spawnRipple(bin: number, mag: number) { - const hue = (bin / audioState.bins.length) * 270 +function spawnRipple(bin: number, mag: number, avg: number) { + const hue = (bin / audioState.bins.length) * 360 + hueShift + const brightness = Math.min(1, Math.max(0, (mag - avg) / avg)) ripples.push({ pos: (bin / audioState.bins.length) * pixelsCount, radius: 1, - brightness: Math.min(1, mag), + brightness, hue, }) } @@ -30,13 +34,21 @@ function spawnRipple(bin: number, mag: number) { function update(time: number) { const dt = (time - lastTime) * settings.effectSpeed lastTime = time + hueShift = (hueShift + dt * 0.05) % 360 const bins = audioState.bins if (bins && bins.length) { - if (averages.length !== bins.length) averages = bins.slice() + if (averages.length !== bins.length) { + averages = bins.slice() + cooldowns = new Array(bins.length).fill(0) + } 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) + cooldowns[i] -= dt + if (m > averages[i] * 1.5 && cooldowns[i] <= 0) { + spawnRipple(i, m, averages[i]) + cooldowns[i] = spawnCooldown + } } } ripples.forEach(r => { @@ -54,11 +66,11 @@ export const getFftRippleColor: IColorGetter = (index, time) => { 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 intensity = (ripple.brightness * (1 - dist / ripple.radius)) / ripple.radius const { r: rr, g: gg, b: bb } = hueToColor(ripple.hue).rgb() - r += rr * intensity - g += gg * intensity - b += bb * intensity + r = Math.max(r, rr * intensity) + g = Math.max(g, gg * intensity) + b = Math.max(b, bb * intensity) } } return [Math.min(255, Math.round(r)), Math.min(255, Math.round(g)), Math.min(255, Math.round(b))] @@ -66,8 +78,10 @@ export const getFftRippleColor: IColorGetter = (index, time) => { export function resetFftRipples() { ripples = [] - lastTime = Date.now() + lastTime = 0 averages = [] + cooldowns = [] + hueShift = 0 } export const fftRippleMapper: IColorMapper = () => callIndexedGetter(getFftRippleColor) diff --git a/src/backend/pattern/index.ts b/src/backend/pattern/index.ts index 7168c85..07684e9 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 { fftRandomMapper } from './fftRandomRipple' +import { fftMirrorMapper } 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]: fftRandomMapper, + [IMode.FftMirrorRipple]: fftMirrorMapper, } 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..57c26e5 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..4500b05 --- /dev/null +++ b/tests/fftMirrorRipple.test.ts @@ -0,0 +1,17 @@ +import { getFftMirrorColor, resetFftMirror } from '../src/backend/pattern/fftMirrorRipple' +import { audioState } from '../src/backend/wsAudio' + +beforeEach(() => { + resetFftMirror() + audioState.bins = Array(8).fill(0) +}) + +describe('fft mirror ripple', () => { + test('spawns ripple on spike', () => { + audioState.bins[0] = 2 + ;(getFftMirrorColor as any)(0, 0) + audioState.bins[0] = 10 + const color = (getFftMirrorColor 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..0460988 --- /dev/null +++ b/tests/fftRandomRipple.test.ts @@ -0,0 +1,20 @@ +import { getFftRandomColor, resetFftRandom } from '../src/backend/pattern/fftRandomRipple' +import { audioState } from '../src/backend/wsAudio' + +beforeEach(() => { + resetFftRandom() + audioState.bins = Array(8).fill(0) +}) + +describe('fft random ripple', () => { + test('spawns ripple on spike', () => { + const orig = Math.random + ;(Math as any).random = () => 0 + audioState.bins[0] = 2 + ;(getFftRandomColor as any)(0, 0) + audioState.bins[0] = 10 + const color = (getFftRandomColor 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 - }) -})