Implement audio visualizers and refactor AssistantPage

- Introduce three new audio visualizer components: AuraVisualizer, SpectrumVisualizer, and WaveVisualizer, enhancing the audio interaction experience.
- Replace the deprecated VoiceVisualizer with the new visualizers, ensuring a cohesive visual language across components.
- Update the AssistantPage to support dynamic visualization style switching, improving user engagement during audio interactions.
- Refactor DebugVoicePanel to accommodate the new visualizer props and enhance the overall debugging interface.
This commit is contained in:
Xin Wang
2026-06-09 16:28:45 +08:00
parent 4f0f639e8f
commit b3fbfac5df
7 changed files with 813 additions and 397 deletions
+90
View File
@@ -0,0 +1,90 @@
"use client";
import * as React from "react";
export type UseAudioAnalyserOptions = {
/** 是否激活:true 时采集麦克风,false 时不连接(用于静态呼吸态) */
active?: boolean;
/** 外部分析器;提供后直接复用,不再申请麦克风 */
analyser?: AnalyserNode | null;
/** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */
stream?: MediaStream | null;
fftSize?: number;
smoothingTimeConstant?: number;
onError?: (error: unknown) => void;
};
/**
* 三种可视化共用的音频接入逻辑:优先用外部 analyser / stream,
* 否则在 active 时申请麦克风。返回一个始终指向当前 AnalyserNode 的 ref。
*/
export function useAudioAnalyser({
active = false,
analyser = null,
stream = null,
fftSize = 512,
smoothingTimeConstant = 0.7,
onError,
}: UseAudioAnalyserOptions) {
const analyserRef = React.useRef<AnalyserNode | null>(null);
const onErrorRef = React.useRef(onError);
React.useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
React.useEffect(() => {
let cancelled = false;
let ctx: AudioContext | null = null;
let micStream: MediaStream | null = null;
async function setup() {
if (analyser) {
analyserRef.current = analyser;
return;
}
if (!active) {
analyserRef.current = null;
return;
}
try {
const AudioCtor =
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext })
.webkitAudioContext;
ctx = new AudioCtor();
let source: MediaStreamAudioSourceNode;
if (stream) {
source = ctx.createMediaStreamSource(stream);
} else {
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (cancelled) {
micStream.getTracks().forEach((track) => track.stop());
return;
}
source = ctx.createMediaStreamSource(micStream);
}
const node = ctx.createAnalyser();
node.fftSize = fftSize;
node.smoothingTimeConstant = smoothingTimeConstant;
source.connect(node);
analyserRef.current = node;
} catch (error) {
if (!cancelled) onErrorRef.current?.(error);
}
}
setup();
return () => {
cancelled = true;
analyserRef.current = analyser ?? null;
micStream?.getTracks().forEach((track) => track.stop());
ctx?.close().catch(() => {});
};
}, [active, analyser, stream, fftSize, smoothingTimeConstant]);
return analyserRef;
}