From 4c2286e70df190c8d2867dd394b89ccb4f7ebe78 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:44:04 +0300 Subject: [PATCH] feat(web): react wave and ambient visuals to live audio --- apps/web/src/pages/HomePage.tsx | 1 + apps/web/src/player/audio.test.ts | 48 ++++++++++++++++++++++++ apps/web/src/player/player.ts | 46 +++++++++++++++++++++++ apps/web/src/widgets/Ambient.module.css | 4 +- apps/web/src/widgets/Ambient.tsx | 29 ++++++++++++-- apps/web/src/widgets/AppShell.tsx | 5 ++- apps/web/src/widgets/wave/WaveButton.tsx | 17 ++++++++- apps/web/src/widgets/wave/draw.ts | 1 + 8 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/player/audio.test.ts diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx index af28a26..3c48f56 100644 --- a/apps/web/src/pages/HomePage.tsx +++ b/apps/web/src/pages/HomePage.tsx @@ -118,6 +118,7 @@ function WaveHome() { vi.restoreAllMocks()); + +function fixture() { + const player = new Player({} as AuthSession, document.createElement('audio')); + const sample = vi.fn((data: Uint8Array) => { + data.fill(0); + // 48 kHz / 1024: bins 1..4 cover the bass band. + data.fill(255, 1, 5); + }); + Object.assign(player, { + state: { ...player.getState(), playing: true }, + analyser: { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample }, + }); + return { player, sample }; +} + +describe('audio reactivity', () => { + it('separates bass from the other frequency bands and shares the frame', () => { + vi.spyOn(performance, 'now').mockReturnValue(100); + const { player, sample } = fixture(); + expect(player.readAudio()).toEqual({ bass: 1, mid: 0, treble: 0 }); + player.readAudio(); + expect(sample).toHaveBeenCalledTimes(1); + }); + + it('returns silence after pausing or muting', () => { + const clock = vi.spyOn(performance, 'now').mockReturnValue(100); + const { player, sample } = fixture(); + player.readAudio(); + Object.assign(player, { state: { ...player.getState(), playing: false } }); + clock.mockReturnValue(120); + expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 }); + Object.assign(player, { state: { ...player.getState(), playing: true, muted: true } }); + clock.mockReturnValue(140); + expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 }); + expect(sample).toHaveBeenCalledTimes(1); + }); + + it('works when Web Audio is unavailable', () => { + const player = new Player({} as AuthSession, document.createElement('audio')); + expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 }); + }); +}); diff --git a/apps/web/src/player/player.ts b/apps/web/src/player/player.ts index 097faf7..ae27c9e 100644 --- a/apps/web/src/player/player.ts +++ b/apps/web/src/player/player.ts @@ -279,6 +279,7 @@ export class Player { void this.load(true, this.state.positionMs); return; } + if (this.audioContext?.state === 'suspended') void this.audioContext.resume().catch(() => undefined); // play() в старых браузерах и jsdom может вернуть undefined вместо промиса. void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false })); } @@ -405,6 +406,7 @@ export class Player { void this.refillWave(); if (autoplay) { try { + if (this.audioContext?.state === 'suspended') await this.audioContext.resume(); await this.audio.play(); } catch { // Автозапуск заблокирован браузером — ждём нажатия «Слушать». @@ -454,6 +456,38 @@ export class Player { /** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */ private gain: GainNode | null = null; private wired = false; + private analyser: AnalyserNode | null = null; + private audioContext: AudioContext | null = null; + private spectrum = new Uint8Array(512); + private audioFrame = { bass: 0, mid: 0, treble: 0 }; + private sampledAt = -Infinity; + + /** Shared spectrum snapshot; animation consumers never trigger React renders. */ + readAudio = () => { + const now = performance.now(); + if (now - this.sampledAt < 12) return this.audioFrame; + this.sampledAt = now; + const active = this.state.playing && !this.state.loading && !this.state.muted; + const analyser = this.analyser; + if (!active || !analyser) { + this.audioFrame.bass = this.audioFrame.mid = this.audioFrame.treble = 0; + return this.audioFrame; + } + analyser.getByteFrequencyData(this.spectrum); + const band = (low: number, high: number) => { + const hz = analyser.context.sampleRate / analyser.fftSize; + const start = Math.max(1, Math.ceil(low / hz)); + const end = Math.min(this.spectrum.length, Math.ceil(high / hz)); + let sum = 0; + for (let i = start; i < end; i++) sum += (this.spectrum[i]! / 255) ** 2; + return Math.sqrt(sum / Math.max(1, end - start)); + }; + this.audioFrame.bass = band(40, 220); + this.audioFrame.mid = band(220, 2500); + this.audioFrame.treble = band(2500, 10000); + return this.audioFrame; + }; + private applyLoudness(mix = 1) { const track = this.state.track; @@ -462,10 +496,22 @@ export class Player { if (!this.wired && typeof AudioContext !== 'undefined') { try { const ctx = new AudioContext(); + this.audioContext = ctx; const src = ctx.createMediaElementSource(this.audio); this.gain = ctx.createGain(); src.connect(this.gain); this.gain.connect(ctx.destination); + this.analyser = ctx.createAnalyser(); + this.analyser.fftSize = 1024; + this.analyser.smoothingTimeConstant = 0.55; + this.gain.connect(this.analyser); + // The overlap is already audible through its own output; analyse it only. + try { + const overlapSource = ctx.createMediaElementSource(this.overlap); + overlapSource.connect(ctx.destination); + overlapSource.connect(this.analyser); + } catch { /* Crossfade analysis is optional. */ } + this.audio.volume = 1; this.wired = true; } catch { diff --git a/apps/web/src/widgets/Ambient.module.css b/apps/web/src/widgets/Ambient.module.css index 71e4386..f098046 100644 --- a/apps/web/src/widgets/Ambient.module.css +++ b/apps/web/src/widgets/Ambient.module.css @@ -11,8 +11,9 @@ .ambient span { position: absolute; border-radius: 50%; - filter: blur(30px); will-change: transform; + scale: var(--music-scale, 1); + filter: blur(30px) brightness(var(--music-light, 1)); } .a { @@ -104,5 +105,6 @@ .ambient, .ambient span { animation: none; + scale: 1; } } diff --git a/apps/web/src/widgets/Ambient.tsx b/apps/web/src/widgets/Ambient.tsx index 96965b4..8b93de5 100644 --- a/apps/web/src/widgets/Ambient.tsx +++ b/apps/web/src/widgets/Ambient.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from 'react'; +import { useEffect, useRef, type CSSProperties } from 'react'; import s from './Ambient.module.css'; @@ -6,9 +6,32 @@ import s from './Ambient.module.css'; * Ambient-фон (docs/design.md#1): три размытых дрейфующих пятна; третье — в цвете обложки текущего трека. * Отключается в настройках; при prefers-reduced-motion пятна неподвижны. */ -export function Ambient({ color }: { color: string }) { +export function Ambient({ color, readAudio }: { + color: string; + readAudio?: () => { bass: number; mid: number; treble: number }; +}) { + const root = useRef(null); + useEffect(() => { + if (!readAudio) return; + const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + let raf = 0; + let last = performance.now(); + let level = 0; + const frame = (now: number) => { + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + const audio = !mq?.matches && !document.hidden ? readAudio() : undefined; + const target = audio ? audio.bass * 0.65 + audio.mid * 0.25 + audio.treble * 0.1 : 0; + level += (target - level) * (1 - Math.exp(-dt * 7)); + root.current?.style.setProperty('--music-scale', String(1 + level * 0.16)); + root.current?.style.setProperty('--music-light', String(1 + level * 0.3)); + raf = requestAnimationFrame(frame); + }; + raf = requestAnimationFrame(frame); + return () => cancelAnimationFrame(raf); + }, [readAudio]); return ( -
+
diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx index 88fbc83..c65bb70 100644 --- a/apps/web/src/widgets/AppShell.tsx +++ b/apps/web/src/widgets/AppShell.tsx @@ -29,7 +29,7 @@ import { storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons'; import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui'; import { LanguageSwitch } from './LanguageSwitch'; -import { PlayerProvider, usePlayerState } from '../player/context'; +import { PlayerProvider, usePlayer, usePlayerState } from '../player/context'; import { Ambient } from './Ambient'; import { PlayerBar } from './PlayerBar'; import { SearchBox } from './SearchBox'; @@ -258,8 +258,9 @@ function Shell() { /** Третье пятно фона — в цвете обложки текущего трека. */ function ShellAmbient() { + const player = usePlayer(); const track = usePlayerState().track; - return ; + return ; } /** Файлов на рассмотрении — бейдж «Очереди» у админа (docs/uploads.md#10). */ diff --git a/apps/web/src/widgets/wave/WaveButton.tsx b/apps/web/src/widgets/wave/WaveButton.tsx index 6e7beed..ae8e62f 100644 --- a/apps/web/src/widgets/wave/WaveButton.tsx +++ b/apps/web/src/widgets/wave/WaveButton.tsx @@ -5,6 +5,7 @@ import { drawLiquid, drawOrb, step, type WaveState } from './draw'; export interface WaveButtonProps { variant?: 'orb' | 'liquid'; playing?: boolean; + readAudio?: () => { bass: number; mid: number; treble: number }; bpm?: number; energy?: number; colors?: [string, string, string]; @@ -28,6 +29,8 @@ function prefersReducedMotion(): boolean { */ export function WaveButton(props: WaveButtonProps) { const size = props.size ?? 320; + const audioRef = useRef(props.readAudio); + audioRef.current = props.readAudio; const canvasRef = useRef(null); const labelRef = useRef(null); const wrapRef = useRef(null); @@ -55,6 +58,7 @@ export function WaveButton(props: WaveButtonProps) { const onMq = () => (st.current.reduced = prefersReducedMotion()); mq?.addEventListener?.('change', onMq); let raf = 0; + let livePulse = 0; let last = performance.now(); const loop = (now: number) => { const dt = Math.min(0.05, (now - last) / 1000); @@ -67,12 +71,23 @@ export function WaveButton(props: WaveButtonProps) { cv.height = Math.round(S * dpr); } step(w, dt); + const audio = !w.reduced && w.playing ? audioRef.current?.() : undefined; + if (audioRef.current && !w.reduced) { + const target = audio ? Math.min(1, audio.bass * 0.7 + audio.mid * 0.2) : 0; + livePulse += (target - livePulse) * (1 - Math.exp(-dt * (target > livePulse ? 35 : 9))); + w.pulse = livePulse; + w.amp = audio ? Math.min(1, audio.mid * 0.8 + audio.treble * 0.7) : 0; + } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, S, S); const sc = 1 - w.press * 0.07 + w.hover * 0.02; - const r = w.size * 0.5 * sc; + const r = w.size * 0.5 * sc * (1 + w.pulse * 0.055); const cx = S / 2 + w.mx * w.hover * 6; const cy = S / 2 + w.my * w.hover * 6; + const ripple = audio ? w.amp * 0.025 * Math.sin(w.t * 13) : 0; + ctx.translate(cx, cy); + ctx.scale(1 + ripple, 1 - ripple); + ctx.translate(-cx, -cy); if (w.variant === 'liquid') drawLiquid(ctx, cx, cy, r, w, w.t); else drawOrb(ctx, cx, cy, r, w, w.t); if (labelRef.current) labelRef.current.style.transform = `scale(${sc + w.pulse * 0.03})`; diff --git a/apps/web/src/widgets/wave/draw.ts b/apps/web/src/widgets/wave/draw.ts index d88deed..b66abd8 100644 --- a/apps/web/src/widgets/wave/draw.ts +++ b/apps/web/src/widgets/wave/draw.ts @@ -77,6 +77,7 @@ export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number 0.04 * Math.sin(5 * th - t * (1.3 + L * 0.1) + L) + 0.03 * Math.sin(7 * th + t * 1.7) * (0.5 + s.amp) + 0.08 * s.pulse * Math.sin(2 * th + L) + + 0.018 * s.amp * Math.sin(11 * th + t * 18 + L) + 0.16 * s.hover * Math.pow(Math.max(0, Math.cos(th - s.ang)), 3); const rr = r * (0.86 - L * 0.07) * w; const x = cx + Math.cos(th) * rr;