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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
256
frontend/src/components/ui/waveform-timeline.tsx
Normal file
256
frontend/src/components/ui/waveform-timeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user