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:
@@ -29,6 +29,8 @@ import {
|
||||
Terminal,
|
||||
Loader2,
|
||||
PhoneOff,
|
||||
Orbit,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -54,7 +56,9 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { VoiceVisualizer } from "@/components/ui/voice-visualizer";
|
||||
import { AuraVisualizer } from "@/components/ui/aura-visualizer";
|
||||
import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer";
|
||||
import { WaveVisualizer } from "@/components/ui/wave-visualizer";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -1662,32 +1666,76 @@ export function AssistantPage() {
|
||||
);
|
||||
}
|
||||
|
||||
type VizStyle = "aura" | "bars" | "wave";
|
||||
|
||||
const VIZ_ORDER: VizStyle[] = ["aura", "bars", "wave"];
|
||||
const VIZ_LABEL: Record<VizStyle, string> = {
|
||||
aura: "光环",
|
||||
bars: "频谱",
|
||||
wave: "波形",
|
||||
};
|
||||
|
||||
function DebugDrawer() {
|
||||
const [showTranscript, setShowTranscript] = useState(false);
|
||||
const [vizStyle, setVizStyle] = useState<VizStyle>("wave");
|
||||
|
||||
return (
|
||||
<aside className="hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-4">
|
||||
<div className="text-sm font-medium text-foreground">调试与预览</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant={showTranscript ? "default" : "outline"}
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full text-xs font-medium"
|
||||
onClick={() => setShowTranscript((value) => !value)}
|
||||
aria-label={showTranscript ? "显示音频可视化" : "显示文字聊天记录"}
|
||||
aria-pressed={showTranscript}
|
||||
>
|
||||
文
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{!showTranscript && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() =>
|
||||
setVizStyle(
|
||||
(value) =>
|
||||
VIZ_ORDER[
|
||||
(VIZ_ORDER.indexOf(value) + 1) % VIZ_ORDER.length
|
||||
],
|
||||
)
|
||||
}
|
||||
aria-label={`切换可视化样式(当前:${VIZ_LABEL[vizStyle]})`}
|
||||
title={`可视化:${VIZ_LABEL[vizStyle]}`}
|
||||
>
|
||||
{vizStyle === "aura" ? (
|
||||
<Orbit size={16} />
|
||||
) : vizStyle === "bars" ? (
|
||||
<AudioLines size={16} />
|
||||
) : (
|
||||
<Waves size={16} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant={showTranscript ? "default" : "outline"}
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full text-xs font-medium"
|
||||
onClick={() => setShowTranscript((value) => !value)}
|
||||
aria-label={showTranscript ? "显示音频可视化" : "显示文字聊天记录"}
|
||||
aria-pressed={showTranscript}
|
||||
>
|
||||
文
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DebugVoicePanel showTranscript={showTranscript} />
|
||||
<DebugVoicePanel showTranscript={showTranscript} vizStyle={vizStyle} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugVoicePanel({ showTranscript }: { showTranscript: boolean }) {
|
||||
function DebugVoicePanel({
|
||||
showTranscript,
|
||||
vizStyle,
|
||||
}: {
|
||||
showTranscript: boolean;
|
||||
vizStyle: VizStyle;
|
||||
}) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [micError, setMicError] = useState(false);
|
||||
|
||||
@@ -1696,7 +1744,7 @@ function DebugVoicePanel({ showTranscript }: { showTranscript: boolean }) {
|
||||
{showTranscript ? (
|
||||
<DebugTranscriptPanel />
|
||||
) : (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto px-5 py-3 text-center">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col items-center justify-center gap-3 overflow-y-auto px-6 py-3 text-center">
|
||||
<div
|
||||
className="pointer-events-none absolute left-1/2 top-2 h-72 w-72 -translate-x-1/2 rounded-full opacity-50 blur-3xl"
|
||||
style={{
|
||||
@@ -1718,22 +1766,32 @@ function DebugVoicePanel({ showTranscript }: { showTranscript: boolean }) {
|
||||
{recording ? "会话进行中" : "准备开始"}
|
||||
</Badge>
|
||||
|
||||
<VoiceVisualizer
|
||||
active={recording}
|
||||
size={220}
|
||||
barCount={96}
|
||||
className="relative -my-2 shrink-0"
|
||||
onError={() => {
|
||||
setMicError(true);
|
||||
setRecording(false);
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex h-[200px] w-[240px] shrink-0 items-center justify-center">
|
||||
{(() => {
|
||||
const onVizError = () => {
|
||||
setMicError(true);
|
||||
setRecording(false);
|
||||
};
|
||||
const shared = {
|
||||
active: recording,
|
||||
className: "relative shrink-0",
|
||||
onError: onVizError,
|
||||
} as const;
|
||||
if (vizStyle === "aura")
|
||||
return <AuraVisualizer {...shared} size={200} />;
|
||||
if (vizStyle === "bars")
|
||||
return (
|
||||
<SpectrumVisualizer {...shared} size={200} barCount={64} />
|
||||
);
|
||||
return <WaveVisualizer {...shared} size={200} />;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="relative -mt-1">
|
||||
<div className="font-display text-xl text-foreground">
|
||||
<div className="relative max-w-xs space-y-1.5">
|
||||
<div className="font-display display-sm text-foreground">
|
||||
{recording ? "我在聆听" : "开始一次语音对话"}
|
||||
</div>
|
||||
<p className="mx-auto mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
|
||||
<p className="mx-auto text-xs leading-5 text-muted-foreground">
|
||||
{micError
|
||||
? "无法访问麦克风,请检查浏览器权限后重试。"
|
||||
: recording
|
||||
@@ -1742,24 +1800,22 @@ function DebugVoicePanel({ showTranscript }: { showTranscript: boolean }) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-3 flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMicError(false);
|
||||
setRecording((value) => !value);
|
||||
}}
|
||||
size="icon"
|
||||
className={[
|
||||
"h-12 w-12 rounded-full shadow-md transition-transform hover:scale-105",
|
||||
recording
|
||||
? "bg-destructive text-white hover:bg-destructive/90"
|
||||
: "",
|
||||
].join(" ")}
|
||||
aria-label={recording ? "结束语音测试" : "开始语音测试"}
|
||||
>
|
||||
{recording ? <PhoneOff size={20} /> : <Mic size={21} />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMicError(false);
|
||||
setRecording((value) => !value);
|
||||
}}
|
||||
className={[
|
||||
"relative h-11 gap-2 rounded-full px-6 text-sm font-medium shadow-sm transition-transform hover:scale-[1.03]",
|
||||
recording
|
||||
? "bg-destructive text-white hover:bg-destructive/90"
|
||||
: "",
|
||||
].join(" ")}
|
||||
aria-label={recording ? "结束语音测试" : "开始语音测试"}
|
||||
>
|
||||
{recording ? <PhoneOff size={18} /> : <Mic size={18} />}
|
||||
{recording ? "结束对话" : "开始对话"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
262
frontend/src/components/ui/aura-visualizer.tsx
Normal file
262
frontend/src/components/ui/aura-visualizer.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
|
||||
import { readPalette, type RGB } from "@/lib/visualizer-palette";
|
||||
|
||||
export type AuraVisualizerProps = {
|
||||
/** 是否激活:true 时采集麦克风并随音量律动,false 时显示静态呼吸态 */
|
||||
active?: boolean;
|
||||
/** 外部分析器;提供后组件不再自行申请麦克风 */
|
||||
analyser?: AnalyserNode | null;
|
||||
/** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */
|
||||
stream?: MediaStream | null;
|
||||
/** 画布直径(px) */
|
||||
size?: number;
|
||||
/** 申请麦克风失败时回调 */
|
||||
onError?: (error: unknown) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const VERT = `
|
||||
attribute vec2 a_pos;
|
||||
void main() {
|
||||
gl_Position = vec4(a_pos, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// 光环本体:极坐标下一个被 fbm 噪声轻微扰动边缘的柔光环 + 中心柔光,
|
||||
// 由 u_volume(音量)驱动缩放 / 亮度。配色随半径在三色间平滑过渡。
|
||||
const FRAG = `
|
||||
precision highp float;
|
||||
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_time;
|
||||
uniform float u_volume; // 0~1,已平滑
|
||||
uniform float u_active; // 0 静态 / 1 激活
|
||||
uniform float u_theme; // 0 暗色 / 1 亮色
|
||||
uniform vec3 u_c0; // sky
|
||||
uniform vec3 u_c1; // lavender
|
||||
uniform vec3 u_c2; // rose
|
||||
|
||||
float hash(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(
|
||||
mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
|
||||
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
|
||||
u.y
|
||||
);
|
||||
}
|
||||
|
||||
float fbm(vec2 p) {
|
||||
float v = 0.0;
|
||||
float a = 0.5;
|
||||
mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
v += a * noise(p);
|
||||
p = m * p;
|
||||
a *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// 静态抖动,消除平滑渐变上的色带
|
||||
float dither(vec2 p) {
|
||||
return (hash(p) - 0.5) / 255.0;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y);
|
||||
float r = length(uv);
|
||||
float ang = atan(uv.y, uv.x);
|
||||
|
||||
float vol = clamp(u_volume, 0.0, 1.0) * u_active;
|
||||
float breathe = 0.5 + 0.5 * sin(u_time * 0.8);
|
||||
|
||||
// 单层缓慢扰动的有机边缘
|
||||
float n = fbm(vec2(cos(ang), sin(ang)) * 1.6 + u_time * 0.12);
|
||||
float baseR = 0.32 + 0.03 * breathe + 0.09 * vol;
|
||||
float edge = baseR + (n - 0.5) * (0.03 + 0.05 * vol);
|
||||
float dr = r - edge;
|
||||
|
||||
float ring = exp(-dr * dr * 120.0); // 柔和发光环
|
||||
float halo = exp(-r * r * 5.0); // 中心柔光
|
||||
// 亮色模式削弱中心光晕,避免在白底上铺成灰雾
|
||||
float intensity = ring * (0.9 + 0.6 * vol)
|
||||
+ halo * (mix(0.28, 0.12, u_theme) + 0.4 * vol);
|
||||
|
||||
// 平滑的径向配色,不随时间闪烁
|
||||
vec3 col = mix(u_c0, u_c1, smoothstep(0.0, 0.55, r));
|
||||
col = mix(col, u_c2, smoothstep(0.4, 0.95, r));
|
||||
|
||||
// 亮色 token 偏浅:提升饱和并适度加深,使颜色在浅背景上不发灰
|
||||
float luma = dot(col, vec3(0.299, 0.587, 0.114));
|
||||
col = clamp(mix(vec3(luma), col, mix(1.2, 1.65, u_theme)), 0.0, 1.0);
|
||||
col *= mix(1.0, 0.72, u_theme);
|
||||
|
||||
float bright = 0.85 + vol + 0.12 * breathe;
|
||||
vec3 hdr = col * intensity * bright;
|
||||
|
||||
// 暗色:辉光优雅泛白;亮色:保持饱和色,不向白过曝
|
||||
vec3 darkMap = vec3(1.0) - exp(-hdr * 1.5);
|
||||
vec3 lightMap = col * clamp(intensity * bright, 0.0, 1.0);
|
||||
vec3 mapped = mix(darkMap, lightMap, u_theme);
|
||||
mapped += dither(gl_FragCoord.xy);
|
||||
|
||||
float alpha = clamp(intensity * mix(1.1, 1.4, u_theme), 0.0, 1.0);
|
||||
gl_FragColor = vec4(mapped, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
function compile(gl: WebGLRenderingContext, type: number, src: string) {
|
||||
const sh = gl.createShader(type);
|
||||
if (!sh) return null;
|
||||
gl.shaderSource(sh, src);
|
||||
gl.compileShader(sh);
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||||
gl.deleteShader(sh);
|
||||
return null;
|
||||
}
|
||||
return sh;
|
||||
}
|
||||
|
||||
const norm = ({ r, g, b }: RGB): [number, number, number] => [
|
||||
r / 255,
|
||||
g / 255,
|
||||
b / 255,
|
||||
];
|
||||
|
||||
export function AuraVisualizer({
|
||||
active = false,
|
||||
analyser = null,
|
||||
stream = null,
|
||||
size = 220,
|
||||
onError,
|
||||
className,
|
||||
}: AuraVisualizerProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const volumeRef = React.useRef(0);
|
||||
const activeRef = React.useRef(active);
|
||||
const analyserRef = useAudioAnalyser({ active, analyser, stream, onError });
|
||||
|
||||
React.useEffect(() => {
|
||||
activeRef.current = active;
|
||||
}, [active]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const gl = canvas.getContext("webgl", {
|
||||
alpha: true,
|
||||
premultipliedAlpha: false,
|
||||
antialias: true,
|
||||
});
|
||||
if (!gl) return;
|
||||
|
||||
const vs = compile(gl, gl.VERTEX_SHADER, VERT);
|
||||
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
|
||||
const prog = gl.createProgram();
|
||||
if (!vs || !fs || !prog) return;
|
||||
gl.attachShader(prog, vs);
|
||||
gl.attachShader(prog, fs);
|
||||
gl.linkProgram(prog);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return;
|
||||
gl.useProgram(prog);
|
||||
|
||||
const buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
const aPos = gl.getAttribLocation(prog, "a_pos");
|
||||
gl.enableVertexAttribArray(aPos);
|
||||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const uRes = gl.getUniformLocation(prog, "u_resolution");
|
||||
const uTime = gl.getUniformLocation(prog, "u_time");
|
||||
const uVol = gl.getUniformLocation(prog, "u_volume");
|
||||
const uActive = gl.getUniformLocation(prog, "u_active");
|
||||
const uTheme = gl.getUniformLocation(prog, "u_theme");
|
||||
const uC0 = gl.getUniformLocation(prog, "u_c0");
|
||||
const uC1 = gl.getUniformLocation(prog, "u_c1");
|
||||
const uC2 = gl.getUniformLocation(prog, "u_c2");
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const px = Math.round(size * dpr);
|
||||
canvas.width = px;
|
||||
canvas.height = px;
|
||||
gl.viewport(0, 0, px, px);
|
||||
gl.uniform2f(uRes, px, px);
|
||||
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
const freq = new Uint8Array(256);
|
||||
let raf = 0;
|
||||
const start = performance.now();
|
||||
|
||||
const draw = () => {
|
||||
const t = (performance.now() - start) / 1000;
|
||||
|
||||
// 由频谱算出单一音量标量(低中频,人声主能量),再平滑
|
||||
const node = analyserRef.current;
|
||||
let target = 0;
|
||||
if (node) {
|
||||
node.getByteFrequencyData(freq);
|
||||
const bins = Math.floor(freq.length * 0.6);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < bins; i++) sum += freq[i];
|
||||
target = sum / bins / 255;
|
||||
}
|
||||
volumeRef.current += (target - volumeRef.current) * 0.18;
|
||||
|
||||
const { sky, lav, rose } = readPalette(canvas);
|
||||
const light = document.documentElement.classList.contains("dark")
|
||||
? 0
|
||||
: 1;
|
||||
gl.uniform1f(uTime, t);
|
||||
gl.uniform1f(uVol, volumeRef.current);
|
||||
gl.uniform1f(uActive, activeRef.current ? 1 : 0);
|
||||
gl.uniform1f(uTheme, light);
|
||||
gl.uniform3fv(uC0, norm(sky));
|
||||
gl.uniform3fv(uC1, norm(lav));
|
||||
gl.uniform3fv(uC2, norm(rose));
|
||||
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
gl.deleteProgram(prog);
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
gl.deleteBuffer(buf);
|
||||
};
|
||||
}, [size, analyserRef]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label="麦克风音频可视化(光环)"
|
||||
style={{ width: size, height: size }}
|
||||
className={cn("select-none", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
140
frontend/src/components/ui/spectrum-visualizer.tsx
Normal file
140
frontend/src/components/ui/spectrum-visualizer.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
|
||||
import { cyclicColor, readPalette, rgba } from "@/lib/visualizer-palette";
|
||||
|
||||
export type SpectrumVisualizerProps = {
|
||||
/** 是否激活:true 时采集麦克风并随音量律动,false 时显示静态呼吸态 */
|
||||
active?: boolean;
|
||||
/** 外部分析器;提供后组件不再自行申请麦克风 */
|
||||
analyser?: AnalyserNode | null;
|
||||
/** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */
|
||||
stream?: MediaStream | null;
|
||||
/** 画布直径(px) */
|
||||
size?: number;
|
||||
/** 环绕的频谱柱数量 */
|
||||
barCount?: number;
|
||||
/** 申请麦克风失败时回调 */
|
||||
onError?: (error: unknown) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 径向频谱:一圈围绕中心柔光的细长光柱,随频谱起伏。
|
||||
* 克制、对称,与光环模式共用同一套调色与柔光语言。
|
||||
*/
|
||||
export function SpectrumVisualizer({
|
||||
active = false,
|
||||
analyser = null,
|
||||
stream = null,
|
||||
size = 220,
|
||||
barCount = 64,
|
||||
onError,
|
||||
className,
|
||||
}: SpectrumVisualizerProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const smoothRef = React.useRef<Float32Array>(new Float32Array(barCount));
|
||||
const analyserRef = useAudioAnalyser({ active, analyser, stream, onError });
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = size * dpr;
|
||||
canvas.height = size * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
if (smoothRef.current.length !== barCount) {
|
||||
smoothRef.current = new Float32Array(barCount);
|
||||
}
|
||||
const smooth = smoothRef.current;
|
||||
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const innerR = size * 0.22;
|
||||
const maxBar = size * 0.2;
|
||||
const freq = new Uint8Array(256);
|
||||
|
||||
let raf = 0;
|
||||
let t = 0;
|
||||
|
||||
const draw = () => {
|
||||
t += 0.016;
|
||||
const palette = readPalette(canvas);
|
||||
const { sky, lav } = palette;
|
||||
|
||||
const node = analyserRef.current;
|
||||
if (node) node.getByteFrequencyData(freq);
|
||||
|
||||
let energy = 0;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
let target: number;
|
||||
if (node) {
|
||||
// 取低中频段(人声主能量),映射到一圈
|
||||
const bin = Math.floor((i / barCount) * (freq.length * 0.62));
|
||||
target = freq[bin] / 255;
|
||||
} else {
|
||||
// 静态呼吸态
|
||||
target = 0.08 + 0.05 * (0.5 + 0.5 * Math.sin(t * 1.5 + i * 0.4));
|
||||
}
|
||||
smooth[i] += (target - smooth[i]) * 0.22;
|
||||
energy += smooth[i];
|
||||
}
|
||||
energy /= barCount;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
// 中心柔光:和光环模式一致的呼吸光晕
|
||||
const breathe = 0.5 + 0.5 * Math.sin(t * 1.3);
|
||||
const glowR = innerR * (1 + energy * 0.4) + size * 0.04 * breathe;
|
||||
const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowR + maxBar);
|
||||
glow.addColorStop(0, rgba(sky, 0.32 + energy * 0.4));
|
||||
glow.addColorStop(0.5, rgba(lav, 0.12 + energy * 0.18));
|
||||
glow.addColorStop(1, rgba(lav, 0));
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
// 径向光柱:圆头、细、带柔光,缓慢旋转
|
||||
const rotation = t * 0.08;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineWidth = Math.max(1.5, size * 0.008);
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const angle = (i / barCount) * Math.PI * 2 + rotation;
|
||||
const v = smooth[i];
|
||||
const r0 = innerR + size * 0.012;
|
||||
const r1 = r0 + maxBar * (0.12 + v);
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const color = cyclicColor(palette, i / barCount);
|
||||
ctx.strokeStyle = rgba(color, 0.35 + v * 0.5);
|
||||
ctx.shadowColor = rgba(color, 0.7);
|
||||
ctx.shadowBlur = 6 + v * 14;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + cos * r0, cy + sin * r0);
|
||||
ctx.lineTo(cx + cos * r1, cy + sin * r1);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [size, barCount, analyserRef]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label="麦克风音频可视化(频谱)"
|
||||
style={{ width: size, height: size }}
|
||||
className={cn("select-none", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type VoiceVisualizerProps = {
|
||||
/** 是否激活:true 时采集麦克风并随音量律动,false 时显示静态呼吸态 */
|
||||
active?: boolean;
|
||||
/** 外部分析器;提供后组件不再自行申请麦克风(便于复用现有音频链路) */
|
||||
analyser?: AnalyserNode | null;
|
||||
/** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */
|
||||
stream?: MediaStream | null;
|
||||
/** 画布直径(px) */
|
||||
size?: number;
|
||||
/** 环绕的频谱柱数量 */
|
||||
barCount?: number;
|
||||
/** 申请麦克风失败时回调 */
|
||||
onError?: (error: unknown) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type RGB = { r: number; g: number; b: number };
|
||||
|
||||
function parseHex(hex: string, fallback: RGB): RGB {
|
||||
const m = hex.trim().replace("#", "");
|
||||
if (m.length === 6) {
|
||||
return {
|
||||
r: parseInt(m.slice(0, 2), 16),
|
||||
g: parseInt(m.slice(2, 4), 16),
|
||||
b: parseInt(m.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
if (m.length === 3) {
|
||||
return {
|
||||
r: parseInt(m[0] + m[0], 16),
|
||||
g: parseInt(m[1] + m[1], 16),
|
||||
b: parseInt(m[2] + m[2], 16),
|
||||
};
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function rgba({ r, g, b }: RGB, a: number) {
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
|
||||
function mix(a: RGB, b: RGB, t: number): RGB {
|
||||
return {
|
||||
r: Math.round(a.r + (b.r - a.r) * t),
|
||||
g: Math.round(a.g + (b.g - a.g) * t),
|
||||
b: Math.round(a.b + (b.b - a.b) * t),
|
||||
};
|
||||
}
|
||||
|
||||
/** 在一组首尾相连的颜色之间取环形插值 */
|
||||
function cyclicColor(stops: RGB[], p: number): RGB {
|
||||
const n = stops.length;
|
||||
const scaled = (p % 1) * n;
|
||||
const i = Math.floor(scaled);
|
||||
return mix(stops[i % n], stops[(i + 1) % n], scaled - i);
|
||||
}
|
||||
|
||||
export function VoiceVisualizer({
|
||||
active = false,
|
||||
analyser = null,
|
||||
stream = null,
|
||||
size = 200,
|
||||
barCount = 72,
|
||||
onError,
|
||||
className,
|
||||
}: VoiceVisualizerProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const analyserRef = React.useRef<AnalyserNode | null>(null);
|
||||
const smoothRef = React.useRef<Float32Array>(new Float32Array(barCount));
|
||||
const onErrorRef = React.useRef(onError);
|
||||
React.useEffect(() => {
|
||||
onErrorRef.current = onError;
|
||||
}, [onError]);
|
||||
|
||||
// 音频接入:优先用外部 analyser / stream,否则在 active 时申请麦克风
|
||||
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 = 256;
|
||||
node.smoothingTimeConstant = 0.82;
|
||||
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]);
|
||||
|
||||
// 绘制循环
|
||||
React.useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = size * dpr;
|
||||
canvas.height = size * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
if (smoothRef.current.length !== barCount) {
|
||||
smoothRef.current = new Float32Array(barCount);
|
||||
}
|
||||
const smooth = smoothRef.current;
|
||||
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const orbR = size * 0.18;
|
||||
const maxBar = size * 0.15;
|
||||
const freq = new Uint8Array(128);
|
||||
|
||||
let raf = 0;
|
||||
let t = 0;
|
||||
|
||||
const draw = () => {
|
||||
t += 0.016;
|
||||
|
||||
const fallbackSky = { r: 95, g: 134, b: 184 };
|
||||
const fallbackLav = { r: 138, g: 120, b: 173 };
|
||||
const fallbackRose = { r: 176, g: 125, b: 140 };
|
||||
const style = getComputedStyle(canvas);
|
||||
const sky = parseHex(
|
||||
style.getPropertyValue("--gradient-sky"),
|
||||
fallbackSky,
|
||||
);
|
||||
const lav = parseHex(
|
||||
style.getPropertyValue("--gradient-lavender"),
|
||||
fallbackLav,
|
||||
);
|
||||
const rose = parseHex(
|
||||
style.getPropertyValue("--gradient-rose"),
|
||||
fallbackRose,
|
||||
);
|
||||
const palette = [sky, lav, rose];
|
||||
|
||||
const node = analyserRef.current;
|
||||
if (node) {
|
||||
node.getByteFrequencyData(freq);
|
||||
}
|
||||
|
||||
let energy = 0;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
let target: number;
|
||||
if (node) {
|
||||
// 取低中频段(人声主要能量)映射到环形
|
||||
const bin = Math.floor((i / barCount) * (freq.length * 0.7));
|
||||
target = freq[bin] / 255;
|
||||
} else {
|
||||
// 静态呼吸态
|
||||
target = 0.1 + 0.06 * (0.5 + 0.5 * Math.sin(t * 1.6 + i * 0.35));
|
||||
}
|
||||
smooth[i] += (target - smooth[i]) * 0.28;
|
||||
energy += smooth[i];
|
||||
}
|
||||
energy /= barCount;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
const breathe = 0.5 + 0.5 * Math.sin(t * 1.4);
|
||||
const pulse = 1 + energy * 0.2 + breathe * 0.035;
|
||||
|
||||
// 整体环境光,让频谱像悬浮在空间中。
|
||||
const glow = ctx.createRadialGradient(
|
||||
cx,
|
||||
cy,
|
||||
orbR * 0.1,
|
||||
cx,
|
||||
cy,
|
||||
size * 0.5,
|
||||
);
|
||||
glow.addColorStop(0, rgba(sky, 0.24 + energy * 0.42));
|
||||
glow.addColorStop(0.38, rgba(lav, 0.12 + energy * 0.2));
|
||||
glow.addColorStop(0.72, rgba(rose, 0.04 + energy * 0.08));
|
||||
glow.addColorStop(1, rgba(lav, 0));
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
// 远景漂浮粒子,旋转速度很慢,提供空间纵深。
|
||||
for (let i = 0; i < 18; i++) {
|
||||
const phase = i * 2.399 + t * (0.035 + (i % 4) * 0.008);
|
||||
const radius = size * (0.31 + (i % 6) * 0.026);
|
||||
const drift = Math.sin(t * 0.45 + i * 1.7) * size * 0.018;
|
||||
const x = cx + Math.cos(phase) * (radius + drift);
|
||||
const y = cy + Math.sin(phase) * (radius + drift);
|
||||
const color = cyclicColor(palette, i / 18);
|
||||
const particleR = size * (0.004 + (i % 3) * 0.002);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, particleR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = rgba(color, 0.13 + energy * 0.28);
|
||||
ctx.shadowColor = rgba(color, 0.55);
|
||||
ctx.shadowBlur = 5 + energy * 10;
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 两条不完整的轨道弧光,制造环绕与旋转感。
|
||||
ctx.lineWidth = Math.max(0.8, size * 0.005);
|
||||
ctx.lineCap = "round";
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const radius = size * (0.32 + i * 0.07);
|
||||
const start = t * (i === 0 ? 0.12 : -0.08) + i * Math.PI;
|
||||
const arc = Math.PI * (0.55 + i * 0.18);
|
||||
const orbit = ctx.createLinearGradient(
|
||||
cx - radius,
|
||||
cy,
|
||||
cx + radius,
|
||||
cy,
|
||||
);
|
||||
orbit.addColorStop(0, rgba(lav, 0));
|
||||
orbit.addColorStop(0.5, rgba(i === 0 ? sky : rose, 0.22 + energy * 0.3));
|
||||
orbit.addColorStop(1, rgba(lav, 0));
|
||||
ctx.strokeStyle = orbit;
|
||||
ctx.shadowColor = rgba(i === 0 ? sky : rose, 0.4);
|
||||
ctx.shadowBlur = 7;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, start, start + arc);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 外层频谱环:更细、更轻,负责表现扩散。
|
||||
const rotation = t * 0.12;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineWidth = Math.max(1.2, size * 0.007);
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const angle = (i / barCount) * Math.PI * 2 + rotation;
|
||||
const v = smooth[i];
|
||||
const r0 = size * 0.285 + energy * size * 0.025;
|
||||
const r1 = r0 + maxBar * (0.2 + v * 0.95);
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const color = cyclicColor(palette, i / barCount);
|
||||
ctx.strokeStyle = rgba(color, 0.3 + v * 0.55);
|
||||
ctx.shadowColor = rgba(color, 0.8);
|
||||
ctx.shadowBlur = 5 + v * 18;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + cos * r0, cy + sin * r0);
|
||||
ctx.lineTo(cx + cos * r1, cy + sin * r1);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 内层频谱环:反向旋转,形成更有机的声场边缘。
|
||||
ctx.lineWidth = Math.max(1.5, size * 0.009);
|
||||
for (let i = 0; i < barCount; i += 2) {
|
||||
const angle = (i / barCount) * Math.PI * 2 - t * 0.09;
|
||||
const v = smooth[(i + Math.floor(barCount / 3)) % barCount];
|
||||
const r0 = orbR * pulse + size * 0.018;
|
||||
const r1 = r0 + size * (0.018 + v * 0.055);
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const color = cyclicColor(palette, 1 - i / barCount);
|
||||
ctx.strokeStyle = rgba(color, 0.38 + v * 0.5);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + cos * r0, cy + sin * r0);
|
||||
ctx.lineTo(cx + cos * r1, cy + sin * r1);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 中心声场核心:深色边缘包住高亮中心,音量越高越通透。
|
||||
const coreR = orbR * pulse;
|
||||
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR);
|
||||
core.addColorStop(0, rgba(sky, 0.42 + energy * 0.45));
|
||||
core.addColorStop(0.28, rgba(lav, 0.2 + energy * 0.28));
|
||||
core.addColorStop(0.68, rgba(rose, 0.08 + energy * 0.16));
|
||||
core.addColorStop(1, rgba(lav, 0.01));
|
||||
ctx.shadowColor = rgba(sky, 0.5 + energy * 0.35);
|
||||
ctx.shadowBlur = 18 + energy * 35;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, coreR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = core;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 核心表面的柔和高光,让它不只是一个平面渐变。
|
||||
const highlight = ctx.createRadialGradient(
|
||||
cx - coreR * 0.28,
|
||||
cy - coreR * 0.32,
|
||||
0,
|
||||
cx - coreR * 0.15,
|
||||
cy - coreR * 0.18,
|
||||
coreR * 0.85,
|
||||
);
|
||||
highlight.addColorStop(0, rgba(sky, 0.22 + energy * 0.22));
|
||||
highlight.addColorStop(1, rgba(sky, 0));
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, coreR * 0.96, 0, Math.PI * 2);
|
||||
ctx.fillStyle = highlight;
|
||||
ctx.fill();
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [size, barCount]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label="麦克风音频可视化"
|
||||
style={{ width: size, height: size }}
|
||||
className={cn("select-none", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
157
frontend/src/components/ui/wave-visualizer.tsx
Normal file
157
frontend/src/components/ui/wave-visualizer.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
|
||||
import { readPalette, rgba } from "@/lib/visualizer-palette";
|
||||
|
||||
export type WaveVisualizerProps = {
|
||||
/** 是否激活:true 时采集麦克风并随波形起伏,false 时显示静态呼吸态 */
|
||||
active?: boolean;
|
||||
/** 外部分析器;提供后组件不再自行申请麦克风 */
|
||||
analyser?: AnalyserNode | null;
|
||||
/** 外部音频流;提供后用它构建分析器,而不调用 getUserMedia */
|
||||
stream?: MediaStream | null;
|
||||
/** 画布直径(px,方形画布,波形居中横贯) */
|
||||
size?: number;
|
||||
/** 申请麦克风失败时回调 */
|
||||
onError?: (error: unknown) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 水平波形:一条横贯中线的柔光曲线,随时域波形起伏。
|
||||
* 与光环 / 频谱共用调色与柔光语言,节奏同样克制。
|
||||
*/
|
||||
export function WaveVisualizer({
|
||||
active = false,
|
||||
analyser = null,
|
||||
stream = null,
|
||||
size = 220,
|
||||
onError,
|
||||
className,
|
||||
}: WaveVisualizerProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const analyserRef = useAudioAnalyser({
|
||||
active,
|
||||
analyser,
|
||||
stream,
|
||||
onError,
|
||||
smoothingTimeConstant: 0.6,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = size * dpr;
|
||||
canvas.height = size * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const cy = size / 2;
|
||||
const pad = size * 0.1;
|
||||
const span = size - pad * 2;
|
||||
const steps = 96;
|
||||
const time = new Uint8Array(2048);
|
||||
|
||||
let raf = 0;
|
||||
let t = 0;
|
||||
let env = 0; // 平滑后的整体振幅包络
|
||||
|
||||
// 边缘淡出的窗函数,让波形两端自然收束
|
||||
const taper = (x: number) => Math.sin(Math.PI * x) ** 0.7;
|
||||
|
||||
const draw = () => {
|
||||
t += 0.016;
|
||||
const { sky, lav, rose } = readPalette(canvas);
|
||||
|
||||
const node = analyserRef.current;
|
||||
let level = 0;
|
||||
if (node) {
|
||||
node.getByteTimeDomainData(time);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < node.fftSize; i++) {
|
||||
const d = (time[i] - 128) / 128;
|
||||
sum += d * d;
|
||||
}
|
||||
level = Math.min(1, Math.sqrt(sum / node.fftSize) * 3.2);
|
||||
}
|
||||
env += (level - env) * 0.15;
|
||||
|
||||
const breathe = 0.5 + 0.5 * Math.sin(t * 1.2);
|
||||
const amp = size * (0.04 + 0.02 * breathe) + size * 0.26 * env;
|
||||
|
||||
// 取一段波形样本(激活时用真实时域,否则用两层正弦合成)
|
||||
const sample = (x: number) => {
|
||||
if (node) {
|
||||
const idx = Math.floor(x * (node.fftSize - 1));
|
||||
return (time[idx] - 128) / 128;
|
||||
}
|
||||
return (
|
||||
0.6 * Math.sin(x * 6.2 + t * 2.0) +
|
||||
0.4 * Math.sin(x * 11.0 - t * 1.3)
|
||||
);
|
||||
};
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
// 横向渐变描边:sky → lavender → rose
|
||||
const grad = ctx.createLinearGradient(pad, 0, pad + span, 0);
|
||||
grad.addColorStop(0, rgba(sky, 0));
|
||||
grad.addColorStop(0.2, rgba(sky, 0.85));
|
||||
grad.addColorStop(0.5, rgba(lav, 0.95));
|
||||
grad.addColorStop(0.8, rgba(rose, 0.85));
|
||||
grad.addColorStop(1, rgba(rose, 0));
|
||||
|
||||
const buildPath = (scale: number) => {
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const p = i / steps;
|
||||
const x = pad + p * span;
|
||||
const y = cy + sample(p) * amp * scale * taper(p);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
};
|
||||
|
||||
// 底层:更宽更淡的回声,制造纵深
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.globalAlpha = 0.25 + env * 0.2;
|
||||
ctx.lineWidth = Math.max(2, size * 0.018);
|
||||
ctx.shadowColor = rgba(lav, 0.6);
|
||||
ctx.shadowBlur = 14 + env * 22;
|
||||
buildPath(0.55);
|
||||
ctx.stroke();
|
||||
|
||||
// 主波形:清晰的柔光主线
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.lineWidth = Math.max(1.5, size * 0.011);
|
||||
ctx.shadowBlur = 8 + env * 14;
|
||||
buildPath(1);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [size, analyserRef]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label="麦克风音频可视化(波形)"
|
||||
style={{ width: size, height: size }}
|
||||
className={cn("select-none", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
90
frontend/src/hooks/use-audio-analyser.ts
Normal file
90
frontend/src/hooks/use-audio-analyser.ts
Normal 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;
|
||||
}
|
||||
62
frontend/src/lib/visualizer-palette.ts
Normal file
62
frontend/src/lib/visualizer-palette.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// 可视化共用的配色工具:从设计 token(--gradient-*)读取调色板,
|
||||
// 让三种可视化样式自动跟随明暗主题,并保持同一套视觉语言。
|
||||
|
||||
export type RGB = { r: number; g: number; b: number };
|
||||
|
||||
export type Palette = { sky: RGB; lav: RGB; rose: RGB };
|
||||
|
||||
/** 浅色主题下的回退值,避免首帧 token 未就绪时闪烁 */
|
||||
const FALLBACK: Palette = {
|
||||
sky: { r: 168, g: 200, b: 232 },
|
||||
lav: { r: 200, g: 184, b: 224 },
|
||||
rose: { r: 232, g: 184, b: 196 },
|
||||
};
|
||||
|
||||
export function parseHex(hex: string, fallback: RGB): RGB {
|
||||
const m = hex.trim().replace("#", "");
|
||||
if (m.length === 6) {
|
||||
return {
|
||||
r: parseInt(m.slice(0, 2), 16),
|
||||
g: parseInt(m.slice(2, 4), 16),
|
||||
b: parseInt(m.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
if (m.length === 3) {
|
||||
return {
|
||||
r: parseInt(m[0] + m[0], 16),
|
||||
g: parseInt(m[1] + m[1], 16),
|
||||
b: parseInt(m[2] + m[2], 16),
|
||||
};
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function rgba({ r, g, b }: RGB, a: number) {
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
|
||||
export function mix(a: RGB, b: RGB, t: number): RGB {
|
||||
return {
|
||||
r: Math.round(a.r + (b.r - a.r) * t),
|
||||
g: Math.round(a.g + (b.g - a.g) * t),
|
||||
b: Math.round(a.b + (b.b - a.b) * t),
|
||||
};
|
||||
}
|
||||
|
||||
/** 在 sky → lavender → rose 三色之间取环形插值(首尾相连,适合沿圆周/横向铺色) */
|
||||
export function cyclicColor({ sky, lav, rose }: Palette, p: number): RGB {
|
||||
const stops = [sky, lav, rose];
|
||||
const scaled = ((p % 1) + 1) % 1 * stops.length;
|
||||
const i = Math.floor(scaled);
|
||||
return mix(stops[i % stops.length], stops[(i + 1) % stops.length], scaled - i);
|
||||
}
|
||||
|
||||
/** 从元素上读取当前主题的 --gradient-* token,返回 0~255 的 RGB */
|
||||
export function readPalette(el: Element): Palette {
|
||||
const s = getComputedStyle(el);
|
||||
return {
|
||||
sky: parseHex(s.getPropertyValue("--gradient-sky"), FALLBACK.sky),
|
||||
lav: parseHex(s.getPropertyValue("--gradient-lavender"), FALLBACK.lav),
|
||||
rose: parseHex(s.getPropertyValue("--gradient-rose"), FALLBACK.rose),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user