Add support for Xfyun ASR and TTS services in the backend

- Introduce new Xfyun ASR and TTS services, enabling integration with iFlytek's voice recognition and synthesis capabilities.
- Update AssistantConfig model to include interface types for STT and TTS.
- Enhance credential testing to validate Xfyun credentials.
- Modify service factory to create Xfyun services based on configuration.
- Update README with new configuration details for Xfyun integration.
- Add new frontend components for visualizing audio streams and managing user interactions.
This commit is contained in:
Xin Wang
2026-06-11 10:51:08 +08:00
parent c69dec04e0
commit e25dfd4003
19 changed files with 1595 additions and 71 deletions

View File

@@ -60,6 +60,7 @@ import { AuraVisualizer } from "@/components/ui/aura-visualizer";
import { NebulaVisualizer } from "@/components/ui/nebula-visualizer";
import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer";
import { WaveVisualizer } from "@/components/ui/wave-visualizer";
import { WaveformTimelinePanel } from "@/components/ui/waveform-timeline";
import {
Card,
CardContent,
@@ -1856,6 +1857,7 @@ function DebugVoicePanel({
error,
micWarning,
localStream,
remoteStream,
messages,
audioInputs,
selectedDeviceId,
@@ -2032,6 +2034,13 @@ function DebugVoicePanel({
</Button>
</div>
</div>
{/* 底部双轨波形:用户麦克风 + 助手音频,可折叠 */}
<WaveformTimelinePanel
userStream={localStream}
agentStream={remoteStream}
active={status === "connected"}
/>
</div>
);
}

View File

@@ -809,11 +809,18 @@ export function ComponentsModelsPage() {
htmlFor="model-api-key"
hint={{
description:
"访问模型服务的鉴权密钥,由服务商控制台生成,请妥善保管勿泄露。",
example: "sk-xxxxxxxx",
form.interfaceType === "xfyun"
? "讯飞需要三段凭证,使用 JSON 存入现有 API Key 字段。"
: "访问模型服务的鉴权密钥,由服务商控制台生成,请妥善保管勿泄露。",
example:
form.interfaceType === "xfyun"
? '{"appId":"...","apiKey":"...","apiSecret":"..."}'
: "sk-xxxxxxxx",
}}
>
API Key
{form.interfaceType === "xfyun"
? "Xfyun Credential JSON"
: "API Key"}
</FieldLabel>
{hasStoredApiKey && (
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
@@ -832,7 +839,9 @@ export function ComponentsModelsPage() {
placeholder={
hasStoredApiKey
? "已配置,留空则保持不变"
: "请输入 API Key"
: form.interfaceType === "xfyun"
? '{"appId":"...","apiKey":"...","apiSecret":"..."}'
: "请输入 API Key"
}
autoComplete="new-password"
className="border-hairline-strong bg-background pr-10 text-foreground placeholder:text-muted-soft"
@@ -852,6 +861,12 @@ export function ComponentsModelsPage() {
</p>
)}
{form.interfaceType === "xfyun" && (
<p className="mt-2 text-xs leading-5 text-muted-foreground">
ASR ID 使 iat TTS 使 tts TTS 使
supertts /private/ API URL
</p>
)}
</div>
</div>

View File

@@ -0,0 +1,256 @@
"use client";
import * as React from "react";
import { Activity, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import {
adaptPalette,
isDarkTheme,
readPalette,
rgba,
} from "@/lib/visualizer-palette";
/** 每格条形代表的音频时长(ms),决定时间轴滚动节奏 */
const SAMPLE_MS = 50;
/** 条形宽度/间距(px):滚动速度 = (BAR_WIDTH+BAR_GAP) * 1000/SAMPLE_MS px/s */
const BAR_WIDTH = 2;
const BAR_GAP = 1;
const BAR_STEP = BAR_WIDTH + BAR_GAP;
/** 历史保留上限:2 分钟,超出后丢最旧的样本 */
const MAX_SAMPLES = (2 * 60 * 1000) / SAMPLE_MS;
/** 时间刻度间隔(ms) */
const TICK_MS = 5_000;
/** 顶部时间轴高度(px) */
const AXIS_HEIGHT = 16;
type History = {
/** 每 SAMPLE_MS 一条的 RMS 强度(0~1),user/agent 等长同步推进 */
user: number[];
agent: number[];
/** 因超出上限被丢弃的最旧样本数,用于换算样本对应的会话时间 */
dropped: number;
/** 上次采样的时间戳(performance.now) */
lastSampleAt: number;
};
function makeHistory(): History {
return { user: [], agent: [], dropped: 0, lastSampleAt: 0 };
}
/** 当前时域 RMS 强度(0~1);放大系数与 WaveVisualizer 一致,让小音量也可见 */
function rmsLevel(node: AnalyserNode | null, buf: Uint8Array<ArrayBuffer>): number {
if (!node) return 0;
node.getByteTimeDomainData(buf);
let sum = 0;
for (let i = 0; i < node.fftSize; i++) {
const d = (buf[i] - 128) / 128;
sum += d * d;
}
return Math.min(1, Math.sqrt(sum / node.fftSize) * 3.2);
}
/** 会话内毫秒 → m:ss 刻度文本 */
function formatTick(ms: number): string {
const total = Math.round(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export type WaveformTimelineProps = {
/** 用户麦克风流(本地) */
userStream: MediaStream | null;
/** 助手音频流(WebRTC 远端) */
agentStream: MediaStream | null;
/** 会话进行中才采样;结束后画面冻结,新会话开始时清空重来 */
active: boolean;
className?: string;
};
/**
* 双轨波形时间轴:上轨「我」(麦克风)、下轨「助手」(远端音频),
* 按固定节拍采样 RMS 音量,最新样本贴右缘向左滚动,顶部带 m:ss 时间刻度。
* 配色取自设计 token(--gradient-*),自动跟随明暗主题。
*/
export function WaveformTimeline({
userStream,
agentStream,
active,
className,
}: WaveformTimelineProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const historyRef = React.useRef<History>(makeHistory());
const activeRef = React.useRef(active);
// active 传 stream 是否存在,避免 useAudioAnalyser 在缺流时去申请麦克风
const userAnalyserRef = useAudioAnalyser({
active: active && Boolean(userStream),
stream: userStream,
smoothingTimeConstant: 0.5,
});
const agentAnalyserRef = useAudioAnalyser({
active: active && Boolean(agentStream),
stream: agentStream,
smoothingTimeConstant: 0.5,
});
// 新会话开始时清空上一轮历史
React.useEffect(() => {
activeRef.current = active;
if (active) {
historyRef.current = makeHistory();
}
}, [active]);
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const timeBuf = new Uint8Array(2048);
let raf = 0;
const draw = () => {
raf = requestAnimationFrame(draw);
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (!w || !h) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (
canvas.width !== Math.round(w * dpr) ||
canvas.height !== Math.round(h * dpr)
) {
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// 采样:按固定节拍推入历史,帧率波动时补齐;长时间空窗(面板折叠)则跳过
const hist = historyRef.current;
if (activeRef.current) {
const now = performance.now();
if (now - hist.lastSampleAt > 1000) hist.lastSampleAt = now;
while (now - hist.lastSampleAt >= SAMPLE_MS) {
hist.lastSampleAt += SAMPLE_MS;
hist.user.push(rmsLevel(userAnalyserRef.current, timeBuf));
hist.agent.push(rmsLevel(agentAnalyserRef.current, timeBuf));
if (hist.user.length > MAX_SAMPLES) {
hist.user.shift();
hist.agent.shift();
hist.dropped += 1;
}
}
}
const palette = adaptPalette(readPalette(canvas), isDarkTheme());
const textColor = getComputedStyle(canvas).color;
const rowH = (h - AXIS_HEIGHT) / 2;
const n = hist.user.length;
const ticksEvery = TICK_MS / SAMPLE_MS;
ctx.font = '10px "Inter", system-ui, sans-serif';
ctx.textBaseline = "middle";
// 时间刻度:竖向网格线 + 顶部 m:ss 标签
ctx.textAlign = "center";
for (let i = 0; i < n; i++) {
const sampleIndex = hist.dropped + i;
if (sampleIndex % ticksEvery !== 0) continue;
const x = w - (n - i) * BAR_STEP;
if (x < 0) continue;
ctx.fillStyle = textColor;
ctx.globalAlpha = 0.12;
ctx.fillRect(x, AXIS_HEIGHT, 1, h - AXIS_HEIGHT);
ctx.globalAlpha = 0.75;
ctx.fillText(formatTick(sampleIndex * SAMPLE_MS), Math.max(14, x), AXIS_HEIGHT / 2);
}
const rows = [
{ label: "我", levels: hist.user, color: palette.sky },
{ label: "助手", levels: hist.agent, color: palette.lav },
];
rows.forEach((row, r) => {
const cy = AXIS_HEIGHT + rowH * r + rowH / 2;
// 中线
ctx.globalAlpha = 1;
ctx.fillStyle = rgba(row.color, 0.28);
ctx.fillRect(0, cy - 0.5, w, 1);
// 音量条:最新样本贴右缘,向左回溯到画布边界为止
ctx.fillStyle = rgba(row.color, 0.9);
const maxBarH = rowH * 0.86;
for (let i = n - 1; i >= 0; i--) {
const x = w - (n - i) * BAR_STEP;
if (x + BAR_WIDTH < 0) break;
const bh = Math.max(1.5, row.levels[i] * maxBarH);
ctx.fillRect(x, cy - bh / 2, BAR_WIDTH, bh);
}
// 轨道标签
ctx.globalAlpha = 0.85;
ctx.fillStyle = textColor;
ctx.textAlign = "left";
ctx.fillText(row.label, 8, cy);
ctx.textAlign = "center";
});
ctx.globalAlpha = 1;
};
raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}, [userAnalyserRef, agentAnalyserRef]);
return (
<canvas
ref={canvasRef}
role="img"
aria-label="用户与助手语音波形时间轴"
className={cn("block select-none text-muted-foreground", className)}
/>
);
}
export type WaveformTimelinePanelProps = WaveformTimelineProps & {
/** 初始是否展开 */
defaultOpen?: boolean;
};
/** 可折叠的波形底栏:头部一行可点击展开/收起,展开后显示双轨时间轴 */
export function WaveformTimelinePanel({
defaultOpen = true,
className,
...timeline
}: WaveformTimelinePanelProps) {
const [open, setOpen] = React.useState(defaultOpen);
return (
<div className={cn("shrink-0 border-t border-hairline", className)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex h-9 w-full items-center gap-2 px-4 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
<Activity size={13} className="shrink-0" />
<span className="ml-auto flex h-5 w-5 items-center justify-center text-muted-soft">
{open ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</span>
</button>
{open && (
<div className="h-28 px-3 pb-3">
<WaveformTimeline {...timeline} className="h-full w-full" />
</div>
)}
</div>
);
}

View File

@@ -74,6 +74,8 @@ export function useVoicePreview(assistantId: string | null) {
const [error, setError] = useState<string | null>(null);
const [micWarning, setMicWarning] = useState<string | null>(null);
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
// 远端(助手 TTS)媒体流:除挂到 <audio> 播放外,也暴露给波形可视化
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
// 可选麦克风列表与当前选择(空串表示交给浏览器选默认设备)
const [audioInputs, setAudioInputs] = useState<MediaDeviceInfo[]>([]);
@@ -92,7 +94,8 @@ export function useVoicePreview(assistantId: string | null) {
if (!navigator.mediaDevices?.enumerateDevices) return;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const inputs = devices.filter((d) => d.kind === "audioinput");
// 未授权时设备可能没有 deviceId(空串),无法选择,直接过滤掉
const inputs = devices.filter((d) => d.kind === "audioinput" && d.deviceId);
setAudioInputs(inputs);
// 选中的设备已被拔出时,回退到浏览器默认设备
if (
@@ -157,6 +160,7 @@ export function useVoicePreview(assistantId: string | null) {
const disconnect = useCallback(() => {
releaseResources();
setLocalStream(null);
setRemoteStream(null);
setError(null);
setMicWarning(null);
setStatus("idle");
@@ -166,6 +170,7 @@ export function useVoicePreview(assistantId: string | null) {
(message: string) => {
releaseResources();
setLocalStream(null);
setRemoteStream(null);
setError(message);
setStatus("failed");
},
@@ -312,9 +317,11 @@ export function useVoicePreview(assistantId: string | null) {
};
pc.ontrack = (e) => {
if (e.track.kind === "audio" && audioRef.current) {
audioRef.current.srcObject =
e.streams[0] ?? new MediaStream([e.track]);
if (e.track.kind !== "audio") return;
const remote = e.streams[0] ?? new MediaStream([e.track]);
setRemoteStream(remote);
if (audioRef.current) {
audioRef.current.srcObject = remote;
void audioRef.current.play().catch(() => {});
}
};
@@ -384,6 +391,7 @@ export function useVoicePreview(assistantId: string | null) {
error,
micWarning,
localStream,
remoteStream,
messages,
audioInputs,
selectedDeviceId,