feat(web): react wave and ambient visuals to live audio
ci / ts / ts (packages + web) (push) Failing after 2m2s

This commit is contained in:
mrleo1nid committed 2026-10-21 16:44:04 +03:00
1 parent 9223516547
commit 4c2286e70d
8 files changed
+144 -7

No files matched your search

+1
View File
@@ -118,6 +118,7 @@ function WaveHome() {
<WaveButton <WaveButton
variant={user.ui_prefs.wave_variant ?? 'orb'} variant={user.ui_prefs.wave_variant ?? 'orb'}
playing={playing} playing={playing}
readAudio={player.readAudio}
bpm={track?.features?.bpm ?? undefined} bpm={track?.features?.bpm ?? undefined}
energy={track?.features?.energy ?? undefined} energy={track?.features?.energy ?? undefined}
colors={colors} colors={colors}
+48
View File
@@ -0,0 +1,48 @@
import type { AuthSession } from '@pipu/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Player } from './player';
afterEach(() => 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 });
});
});
+46
View File
@@ -279,6 +279,7 @@ export class Player {
void this.load(true, this.state.positionMs); void this.load(true, this.state.positionMs);
return; return;
} }
if (this.audioContext?.state === 'suspended') void this.audioContext.resume().catch(() => undefined);
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса. // play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false })); void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
} }
@@ -405,6 +406,7 @@ export class Player {
void this.refillWave(); void this.refillWave();
if (autoplay) { if (autoplay) {
try { try {
if (this.audioContext?.state === 'suspended') await this.audioContext.resume();
await this.audio.play(); await this.audio.play();
} catch { } catch {
// Автозапуск заблокирован браузером — ждём нажатия «Слушать». // Автозапуск заблокирован браузером — ждём нажатия «Слушать».
@@ -454,6 +456,38 @@ export class Player {
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */ /** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */
private gain: GainNode | null = null; private gain: GainNode | null = null;
private wired = false; 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) { private applyLoudness(mix = 1) {
const track = this.state.track; const track = this.state.track;
@@ -462,10 +496,22 @@ export class Player {
if (!this.wired && typeof AudioContext !== 'undefined') { if (!this.wired && typeof AudioContext !== 'undefined') {
try { try {
const ctx = new AudioContext(); const ctx = new AudioContext();
this.audioContext = ctx;
const src = ctx.createMediaElementSource(this.audio); const src = ctx.createMediaElementSource(this.audio);
this.gain = ctx.createGain(); this.gain = ctx.createGain();
src.connect(this.gain); src.connect(this.gain);
this.gain.connect(ctx.destination); 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.audio.volume = 1;
this.wired = true; this.wired = true;
} catch { } catch {
+3 -1
View File
@@ -11,8 +11,9 @@
.ambient span { .ambient span {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
filter: blur(30px);
will-change: transform; will-change: transform;
scale: var(--music-scale, 1);
filter: blur(30px) brightness(var(--music-light, 1));
} }
.a { .a {
@@ -104,5 +105,6 @@
.ambient, .ambient,
.ambient span { .ambient span {
animation: none; animation: none;
scale: 1;
} }
} }
+26 -3
View File
@@ -1,4 +1,4 @@
import type { CSSProperties } from 'react'; import { useEffect, useRef, type CSSProperties } from 'react';
import s from './Ambient.module.css'; import s from './Ambient.module.css';
@@ -6,9 +6,32 @@ import s from './Ambient.module.css';
* Ambient-фон (docs/design.md#1): три размытых дрейфующих пятна; третье — в цвете обложки текущего трека. * Ambient-фон (docs/design.md#1): три размытых дрейфующих пятна; третье — в цвете обложки текущего трека.
* Отключается в настройках; при prefers-reduced-motion пятна неподвижны. * Отключается в настройках; при 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<HTMLDivElement>(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 ( return (
<div className={s.ambient} aria-hidden> <div ref={root} className={s.ambient} aria-hidden>
<span className={s.a} /> <span className={s.a} />
<span className={s.b} /> <span className={s.b} />
<span className={s.c} style={{ '--amb-c3': `${color}99` } as CSSProperties} /> <span className={s.c} style={{ '--amb-c3': `${color}99` } as CSSProperties} />
+3 -2
View File
@@ -29,7 +29,7 @@ import {
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons'; storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui'; import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch'; import { LanguageSwitch } from './LanguageSwitch';
import { PlayerProvider, usePlayerState } from '../player/context'; import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
import { Ambient } from './Ambient'; import { Ambient } from './Ambient';
import { PlayerBar } from './PlayerBar'; import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox'; import { SearchBox } from './SearchBox';
@@ -258,8 +258,9 @@ function Shell() {
/** Третье пятно фона — в цвете обложки текущего трека. */ /** Третье пятно фона — в цвете обложки текущего трека. */
function ShellAmbient() { function ShellAmbient() {
const player = usePlayer();
const track = usePlayerState().track; const track = usePlayerState().track;
return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} />; return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} readAudio={player.readAudio} />;
} }
/** Файлов на рассмотрении — бейдж «Очереди» у админа (docs/uploads.md#10). */ /** Файлов на рассмотрении — бейдж «Очереди» у админа (docs/uploads.md#10). */
+16 -1
View File
@@ -5,6 +5,7 @@ import { drawLiquid, drawOrb, step, type WaveState } from './draw';
export interface WaveButtonProps { export interface WaveButtonProps {
variant?: 'orb' | 'liquid'; variant?: 'orb' | 'liquid';
playing?: boolean; playing?: boolean;
readAudio?: () => { bass: number; mid: number; treble: number };
bpm?: number; bpm?: number;
energy?: number; energy?: number;
colors?: [string, string, string]; colors?: [string, string, string];
@@ -28,6 +29,8 @@ function prefersReducedMotion(): boolean {
*/ */
export function WaveButton(props: WaveButtonProps) { export function WaveButton(props: WaveButtonProps) {
const size = props.size ?? 320; const size = props.size ?? 320;
const audioRef = useRef(props.readAudio);
audioRef.current = props.readAudio;
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const labelRef = useRef<HTMLDivElement>(null); const labelRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
@@ -55,6 +58,7 @@ export function WaveButton(props: WaveButtonProps) {
const onMq = () => (st.current.reduced = prefersReducedMotion()); const onMq = () => (st.current.reduced = prefersReducedMotion());
mq?.addEventListener?.('change', onMq); mq?.addEventListener?.('change', onMq);
let raf = 0; let raf = 0;
let livePulse = 0;
let last = performance.now(); let last = performance.now();
const loop = (now: number) => { const loop = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000); const dt = Math.min(0.05, (now - last) / 1000);
@@ -67,12 +71,23 @@ export function WaveButton(props: WaveButtonProps) {
cv.height = Math.round(S * dpr); cv.height = Math.round(S * dpr);
} }
step(w, dt); 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.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, S, S); ctx.clearRect(0, 0, S, S);
const sc = 1 - w.press * 0.07 + w.hover * 0.02; 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 cx = S / 2 + w.mx * w.hover * 6;
const cy = S / 2 + w.my * 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); if (w.variant === 'liquid') drawLiquid(ctx, cx, cy, r, w, w.t);
else drawOrb(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})`; if (labelRef.current) labelRef.current.style.transform = `scale(${sc + w.pulse * 0.03})`;
+1
View File
@@ -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.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.03 * Math.sin(7 * th + t * 1.7) * (0.5 + s.amp) +
0.08 * s.pulse * Math.sin(2 * th + L) + 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); 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 rr = r * (0.86 - L * 0.07) * w;
const x = cx + Math.cos(th) * rr; const x = cx + Math.cos(th) * rr;