feat(web): react wave and ambient visuals to live audio
ci / ts / ts (packages + web) (push) Failing after 2m2s
ci / ts / ts (packages + web) (push) Failing after 2m2s
This commit is contained in:
1 parent
9223516547
commit
4c2286e70d
8 files changed
+144
-7
No files matched your search
@@ -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}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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})`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user