Merge pull request #1003 from Yourdaylight/feature/tencent-realtime-asr

feat(voice): add Tencent Cloud realtime (streaming) speech recognition
This commit is contained in:
seed30
2026-10-10 14:45:44 +08:00
committed by GitHub
30 changed files with 2091 additions and 33 deletions
+1
View File
@@ -112,6 +112,7 @@
- 登录验证码新增极验行为验 v4(#870):设置页配置 captcha_id / captcha_key,登录弹窗完成验证,服务端按官方协议 HMAC-SHA256 签名后到 gcaptcha4 二次校验(form-urlencoded,仅 result=success 放行)
- 对话支持默认折叠思考与工具过程(浏览器本地偏好)(#718)
- 对话中可隐藏不常用的共享专家(浏览器本地偏好)(#589)
- 腾讯云语音新增实时(流式)识别:开启后输入框麦克风为按住说话,边说边显示中间结果、松开即出全文;凭据仅保存在服务端,浏览器只收到开关状态;配置存于语音 provider 的 extra_json,无数据库迁移
### 修复
+2
View File
@@ -26,6 +26,8 @@ export interface VoiceProviderRow {
export interface ActiveVoice {
stt: string;
tts: string;
/** True when the active STT provider streams results over WebSocket. */
stt_realtime?: boolean;
}
export interface VoiceProviderInput {
+8 -4
View File
@@ -30,10 +30,14 @@ export function prefetchVoiceConfig(): void {
}
export function useVoiceConfig() {
const [active, setActive] = useState<ActiveVoice>({
stt: "browser",
tts: "browser",
});
const [active, setActive] = useState<ActiveVoice>(
() =>
cachedActiveVoice() ?? {
stt: "browser",
tts: "browser",
stt_realtime: false,
},
);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
+166 -7
View File
@@ -1,8 +1,16 @@
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { getWsUrl } from "../api/config";
import { voiceApi, type ActiveVoice } from "../api/modules/voice";
import { getAuthToken } from "../api/request";
import { speechLocaleFromUi } from "../utils/localePrefs";
import { cachedActiveVoice, fetchActiveVoice } from "./useVoiceConfig";
import { PcmFrameBuffer, downsampleToPcm16 } from "../utils/pcmCapture";
import { RealtimeSttClient } from "../utils/realtimeStt";
import {
cachedActiveVoice,
fetchActiveVoice,
useVoiceConfig,
} from "./useVoiceConfig";
import { message as antMessage } from "@/utils/antdMessage";
@@ -237,18 +245,147 @@ export function isSttAvailable(): boolean {
return browserSttAvailable(); // browser STT (Chrome / Edge)
}
export function useVoiceInput(onText: (text: string) => void) {
export interface VoiceInputOptions {
/** Called when a capture session starts, before any transcript arrives. */
onStart?: () => void;
/** Cumulative transcript while the session is still being refined. */
onInterim?: (text: string) => void;
/** Cumulative transcript of the session; the realtime path calls it per sentence. */
onText: (text: string) => void;
}
/** Live 16 kHz capture feeding the realtime STT WebSocket. */
interface RealtimeCapture {
client: RealtimeSttClient;
stream: MediaStream;
ctx: AudioContext;
source: MediaStreamAudioSourceNode;
processor: ScriptProcessorNode;
sink: GainNode;
}
function realtimeSttUrl(language: string): string {
const params = new URLSearchParams({
locale: language.toLowerCase().startsWith("zh") ? "zh" : "en",
});
const token = getAuthToken();
if (token) params.set("token", token);
return `${getWsUrl("/voice/stt-stream")}?${params.toString()}`;
}
function audioContextCtor(): typeof AudioContext | null {
return (
window.AudioContext ??
(window as Window & { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext ??
null
);
}
export function useVoiceInput(options: VoiceInputOptions) {
const { t, i18n } = useTranslation();
const [recording, setRecording] = useState(false);
const [transcribing, setTranscribing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const captureRef = useRef<RealtimeCapture | null>(null);
// A press-and-hold release can land while `startRealtime` is still awaiting
// getUserMedia; remember it so the capture is not left running.
const pendingStopRef = useRef(false);
// Keep the latest callbacks without rebuilding every capture closure.
const optionsRef = useRef(options);
optionsRef.current = options;
const { active } = useVoiceConfig();
// Follow dashboard UI locale — not navigator.language (often en-US on
// machines used with Chinese UI / Chinese speech).
const language = speechLocaleFromUi(i18n.language);
const teardownRealtime = useCallback(() => {
const capture = captureRef.current;
captureRef.current = null;
if (!capture) return;
capture.processor.onaudioprocess = null;
capture.processor.disconnect();
capture.source.disconnect();
capture.sink.disconnect();
capture.stream.getTracks().forEach((track) => track.stop());
void capture.ctx.close();
}, []);
const finishRealtime = useCallback(() => {
teardownRealtime();
setRecording(false);
setTranscribing(false);
}, [teardownRealtime]);
const startRealtime = useCallback(async () => {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
},
});
const Ctor = audioContextCtor();
if (!Ctor) throw new Error("AudioContext unavailable");
const ctx = new Ctor();
const source = ctx.createMediaStreamSource(stream);
const processor = ctx.createScriptProcessor(1024, 1, 1);
const sink = ctx.createGain();
sink.gain.value = 0; // keep the graph running without echoing the mic
const client = new RealtimeSttClient(realtimeSttUrl(language), {
onInterim: (text) => optionsRef.current.onInterim?.(text),
onFinal: (text) => {
if (text) optionsRef.current.onText(text);
},
onDone: finishRealtime,
onError: (message) => {
finishRealtime();
antMessage.error(message || t("voice.realtimeFailed"));
},
});
captureRef.current = { client, stream, ctx, source, processor, sink };
const frames = new PcmFrameBuffer();
processor.onaudioprocess = (event) => {
const pcm = downsampleToPcm16(
event.inputBuffer.getChannelData(0),
ctx.sampleRate,
);
for (const frame of frames.push(pcm)) client.send(frame);
};
source.connect(processor);
processor.connect(sink);
sink.connect(ctx.destination);
// Frames are buffered until the server reports ready; failures surface
// through `onError`, so the connect rejection needs no extra handling.
void client.connect().catch(() => undefined);
setRecording(true);
}, [finishRealtime, language, t]);
const stopRealtime = useCallback(() => {
const capture = captureRef.current;
if (!capture) return;
capture.processor.onaudioprocess = null;
capture.processor.disconnect();
capture.source.disconnect();
capture.sink.disconnect();
capture.stream.getTracks().forEach((track) => track.stop());
setRecording(false);
setTranscribing(true);
// `end` makes the server flush its final sentences, then `onDone` clears.
capture.client.end();
}, []);
const stopRecording = useCallback(async () => {
pendingStopRef.current = true;
if (captureRef.current) {
stopRealtime();
return;
}
const recorder = mediaRecorderRef.current;
if (!recorder || recorder.state === "inactive") {
setRecording(false);
@@ -280,7 +417,7 @@ export function useVoiceInput(onText: (text: string) => void) {
const upload = await recordedBlobToWav(blob);
const result = await voiceApi.transcribe(upload, language);
const text = result.text?.trim() ?? "";
if (text) onText(text);
if (text) optionsRef.current.onText(text);
else antMessage.info(t("voice.sttEmpty"));
} catch (err) {
const msg = err instanceof Error ? err.message : "";
@@ -292,18 +429,32 @@ export function useVoiceInput(onText: (text: string) => void) {
} finally {
setTranscribing(false);
}
}, [onText, t, language]);
}, [t, language, stopRealtime]);
const startRecording = useCallback(async () => {
// Read cached config synchronously to stay in the user-gesture stack.
const active: ActiveVoice | null = cachedActiveVoice();
pendingStopRef.current = false;
optionsRef.current.onStart?.();
if (active?.stt_realtime && canRecordAudio() && audioContextCtor()) {
// Realtime STT: stream PCM and let results arrive while speaking.
try {
await startRealtime();
} catch {
antMessage.error(t("voice.micDenied"));
return;
}
if (pendingStopRef.current) stopRealtime();
return;
}
if ((active?.stt ?? "browser") === "browser" && browserSttAvailable()) {
// Browser STT: use the native SpeechRecognition API directly.
setTranscribing(true);
try {
const text = await transcribeWithBrowser(language);
if (text) onText(text);
if (text) optionsRef.current.onText(text);
else antMessage.info(t("voice.sttEmpty"));
} catch (err) {
const msg = err instanceof Error ? err.message : "";
@@ -343,7 +494,7 @@ export function useVoiceInput(onText: (text: string) => void) {
} catch {
antMessage.error(t("voice.micDenied"));
}
}, [onText, t, language]);
}, [t, language, startRealtime, stopRealtime]);
const toggle = useCallback(() => {
if (transcribing) return;
@@ -351,5 +502,13 @@ export function useVoiceInput(onText: (text: string) => void) {
else void startRecording();
}, [recording, transcribing, startRecording, stopRecording]);
return { recording, transcribing, toggle };
return {
recording,
transcribing,
/** True when the mic streams results live (press and hold, not click). */
realtime: active.stt_realtime === true,
start: startRecording,
stop: stopRecording,
toggle,
};
}
+7
View File
@@ -152,6 +152,7 @@
"VOICE_KIND_UNSUPPORTED": "Unsupported voice provider.",
"VOICE_CAPABILITY_MISMATCH": "Voice capability does not match.",
"VOICE_PROVIDER_DISABLED": "Voice provider is disabled.",
"VOICE_REALTIME_UNAVAILABLE": "Realtime speech recognition is unavailable. Check the Tencent Cloud voice settings.",
"INTERNAL_ERROR": "Internal server error.",
"TLS_NOT_ELIGIBLE": "TLS issuance is not available in the current server configuration.",
"TLS_ISSUE_IN_PROGRESS": "A certificate issuance task is already running.",
@@ -2784,6 +2785,12 @@
"probeSuccess": "Voice model probe succeeded",
"probeFailed": "Voice model probe failed",
"tencentHint": "Create SecretId / SecretKey in Tencent Cloud console. ASR and TTS include free trial quota.",
"realtimeStt": "Realtime speech recognition",
"realtimeSttHint": "When enabled, voice input in the composer streams text as you speak (AppID required).",
"realtimeAppIdRequired": "AppID is required to enable realtime speech recognition.",
"realtimeFailed": "Realtime speech recognition failed",
"holdToTalk": "Hold to talk",
"releaseToStop": "Release to finish",
"openaiHint": "Uses OpenAI Whisper for STT and OpenAI TTS for read-aloud.",
"mimoHint": "Xiaomi MiMo ASR/TTS. Choose endpoint and enter the corresponding API key.",
"mimoEndpoint": "API Endpoint",
+7
View File
@@ -152,6 +152,7 @@
"VOICE_KIND_UNSUPPORTED": "不支持的语音提供商。",
"VOICE_CAPABILITY_MISMATCH": "语音能力不匹配。",
"VOICE_PROVIDER_DISABLED": "语音提供商已禁用。",
"VOICE_REALTIME_UNAVAILABLE": "实时语音识别不可用,请检查腾讯云语音配置。",
"INTERNAL_ERROR": "服务器内部错误。",
"TLS_NOT_ELIGIBLE": "当前服务器配置不支持 TLS 证书申请。",
"TLS_ISSUE_IN_PROGRESS": "已有证书申请任务正在进行。",
@@ -2784,6 +2785,12 @@
"probeSuccess": "语音模型探测成功",
"probeFailed": "语音模型探测失败",
"tencentHint": "在腾讯云控制台创建 SecretId / SecretKey,语音识别与合成均有免费试用额度。",
"realtimeStt": "实时语音识别",
"realtimeSttHint": "开启后,聊天输入框的语音输入将边说边出字(需要 AppID)。",
"realtimeAppIdRequired": "开启实时语音识别需要填写 AppID。",
"realtimeFailed": "实时语音识别失败",
"holdToTalk": "按住说话",
"releaseToStop": "松开结束",
"openaiHint": "使用 OpenAI Whisper 识别、OpenAI TTS 朗读。",
"mimoHint": "小米 Mimo 语音识别与合成。选择接入点并填写对应的 API Key。",
"mimoEndpoint": "API 接入点",
@@ -17,6 +17,9 @@ vi.mock("../../../hooks/useVoiceInput", () => ({
useVoiceInput: () => ({
recording: false,
transcribing: false,
realtime: false,
start: vi.fn(),
stop: vi.fn(),
toggle: vi.fn(),
}),
}));
@@ -228,9 +228,19 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
setText(value);
}, []);
const handleVoiceText = useCallback(
const textRef = useRef(text);
useEffect(() => {
textRef.current = text;
}, [text]);
// Text that existed before the current voice session started; the session's
// (possibly still refining) transcript is appended to it on every update.
const voiceBaselineRef = useRef("");
const applyVoiceText = useCallback(
(spoken: string) => {
setText((prev) => (prev.trim() ? `${prev.trim()} ${spoken}` : spoken));
const base = voiceBaselineRef.current.trim();
const next = spoken.trim();
setText(base ? (next ? `${base} ${next}` : base) : next);
userHasEditedRef.current = true;
onUserInput?.();
},
@@ -239,8 +249,17 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
const {
recording,
transcribing,
realtime: realtimeVoice,
start: startVoice,
stop: stopVoice,
toggle: toggleVoice,
} = useVoiceInput(handleVoiceText);
} = useVoiceInput({
onStart: () => {
voiceBaselineRef.current = textRef.current;
},
onInterim: applyVoiceText,
onText: applyVoiceText,
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
const {
attachments,
@@ -885,6 +904,9 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
uploading={uploading}
recording={recording}
transcribing={transcribing}
voiceHoldToTalk={realtimeVoice}
onVoiceStart={() => void startVoice()}
onVoiceStop={() => void stopVoice()}
browserRecording={browserRecording}
browserReplayBusy={browserReplayBusy}
browserLastRecordingId={browserLastRecordingId}
@@ -4,6 +4,17 @@ import { describe, expect, it, vi } from "vitest";
import type { ResolvedModel } from "../../../api/types";
import ChatInputActionsRow from "./ChatInputActionsRow";
// `isSttAvailable()` runs at module load, so the browser APIs it probes must
// exist before this module graph is imported.
vi.hoisted(() => {
vi.stubGlobal("MediaRecorder", class {});
Object.defineProperty(navigator, "mediaDevices", {
value: { getUserMedia: () => Promise.resolve() },
configurable: true,
});
return true;
});
vi.mock("./ContextWindowRing", () => ({
default: () => null,
}));
@@ -222,3 +233,84 @@ describe("ChatInputActionsRow plus menu", () => {
).toBeInTheDocument();
});
});
function renderRow(overrides: Record<string, unknown> = {}) {
const handlers = {
onVoiceStart: vi.fn(),
onVoiceStop: vi.fn(),
onToggleVoice: vi.fn(),
};
const view = render(
<MemoryRouter>
<ChatInputActionsRow
isMobile={false}
isStreaming={false}
canSend={false}
text=""
polishing={false}
uploading={false}
recording={false}
transcribing={false}
slashPickerGroups={null}
slashMenuItems={[]}
onSlashShortcutSelect={vi.fn()}
onFileSelect={vi.fn()}
onNewChat={vi.fn()}
onPolish={vi.fn()}
onCancel={vi.fn()}
onSubmit={vi.fn()}
{...handlers}
{...overrides}
/>
</MemoryRouter>,
);
const mic = view.container
.querySelector("svg.lucide-mic")
?.closest("button") as HTMLButtonElement;
return { ...view, ...handlers, mic };
}
describe("ChatInputActionsRow voice button", () => {
it("dictates while held down when realtime STT is on", () => {
const { mic, onVoiceStart, onVoiceStop, onToggleVoice } = renderRow({
voiceHoldToTalk: true,
});
fireEvent.pointerDown(mic);
expect(onVoiceStart).toHaveBeenCalledTimes(1);
fireEvent.pointerUp(mic);
expect(onVoiceStop).toHaveBeenCalledTimes(1);
// A plain click must not toggle the recorder in hold-to-talk mode.
fireEvent.click(mic);
expect(onToggleVoice).not.toHaveBeenCalled();
expect(onVoiceStart).toHaveBeenCalledTimes(1);
});
it("finishes when the pointer slides off the button", () => {
const { mic, onVoiceStop } = renderRow({ voiceHoldToTalk: true });
fireEvent.pointerDown(mic);
fireEvent.pointerLeave(mic);
fireEvent.pointerUp(mic);
expect(onVoiceStop).toHaveBeenCalledTimes(1);
});
it("keeps click-to-toggle when realtime STT is off", () => {
const { mic, onToggleVoice, onVoiceStart } = renderRow();
fireEvent.click(mic);
expect(onToggleVoice).toHaveBeenCalledTimes(1);
fireEvent.pointerDown(mic);
expect(onVoiceStart).not.toHaveBeenCalled();
});
it("stays available while a reply is still streaming", () => {
const { mic } = renderRow({ voiceHoldToTalk: true, isStreaming: true });
expect(mic).not.toBeDisabled();
});
});
@@ -132,6 +132,10 @@ interface ChatInputActionsRowProps {
uploading: boolean;
recording: boolean;
transcribing: boolean;
/** Realtime STT streams while held down, so the mic is press-and-hold. */
voiceHoldToTalk?: boolean;
onVoiceStart?: () => void;
onVoiceStop?: () => void;
browserRecording?: boolean;
browserReplayBusy?: boolean;
browserLastRecordingId?: string | null;
@@ -195,6 +199,9 @@ export default function ChatInputActionsRow({
uploading,
recording,
transcribing,
voiceHoldToTalk = false,
onVoiceStart,
onVoiceStop,
browserRecording = false,
browserReplayBusy = false,
browserLastRecordingId = null,
@@ -243,6 +250,7 @@ export default function ChatInputActionsRow({
const remoteManaged = Boolean(agentId?.startsWith("bridge:"));
const skillDisplayName = useSkillDisplayName();
const actionsRowRef = useRef<HTMLDivElement | null>(null);
const voiceHeldRef = useRef(false);
const [plusMenuEl, setPlusMenuEl] = useState<HTMLDivElement | null>(null);
const [plusPanelEl, setPlusPanelEl] = useState<HTMLDivElement | null>(null);
const [plusPanelMaxHeight, setPlusPanelMaxHeight] = useState<number | null>(
@@ -256,6 +264,22 @@ export default function ChatInputActionsRow({
const [reasoningModelRef, setReasoningModelRef] = useState<string | null>(
null,
);
/** Mobile-only bottom drawer for the overflow ("more") menu. */
const [mobileOverflowOpen, setMobileOverflowOpen] = useState(false);
// Press-and-hold dictation: start on pointerdown, finish on release or when
// the pointer slides off the button (also covers touch cancel).
const handleVoicePressStart = () => {
if (voiceHeldRef.current) return;
voiceHeldRef.current = true;
onVoiceStart?.();
};
const handleVoicePressEnd = () => {
if (!voiceHeldRef.current) return;
voiceHeldRef.current = false;
onVoiceStop?.();
};
/** Plus-button menu (model / skills / …). */
const [overflowPopoverOpen, setOverflowPopoverOpen] = useState(false);
/** Picker panel opened from a plus-menu item. */
@@ -1142,6 +1166,10 @@ export default function ChatInputActionsRow({
title={
!_sttAvailable
? t("voice.sttNotAvailable", "此设备不支持语音输入(需要 HTTPS)")
: voiceHoldToTalk
? recording
? t("voice.releaseToStop", "松开结束")
: t("voice.holdToTalk", "按住说话")
: recording
? t("voice.stopRecording", "停止录音")
: transcribing
@@ -1155,8 +1183,29 @@ export default function ChatInputActionsRow({
recording || transcribing ? styles.secondaryBtnActive : ""
}`}
type="button"
disabled={disabled || isStreaming || transcribing || !_sttAvailable}
onClick={onToggleVoice}
// Dictation only fills the composer, so it stays available while a
// reply is still streaming.
disabled={disabled || transcribing || !_sttAvailable}
onClick={voiceHoldToTalk ? undefined : onToggleVoice}
onPointerDown={
voiceHoldToTalk
? (event) => {
event.preventDefault();
handleVoicePressStart();
}
: undefined
}
onPointerUp={voiceHoldToTalk ? handleVoicePressEnd : undefined}
onPointerLeave={voiceHoldToTalk ? handleVoicePressEnd : undefined}
onPointerCancel={voiceHoldToTalk ? handleVoicePressEnd : undefined}
onContextMenu={
voiceHoldToTalk ? (event) => event.preventDefault() : undefined
}
style={
voiceHoldToTalk
? { touchAction: "none", userSelect: "none" }
: undefined
}
>
<Mic size={16} />
</button>
+43 -3
View File
@@ -1,5 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Divider, Drawer, Form, Input, Select, Typography } from "antd";
import {
Button,
Divider,
Drawer,
Form,
Input,
Select,
Switch,
Typography,
} from "antd";
import { message } from "@/utils/antdMessage";
import { Activity, Mic2, Check, Settings2 } from "lucide-react";
@@ -37,6 +46,8 @@ export function VoiceSettingsPanel() {
const [apiKey, setApiKey] = useState("");
const [secretId, setSecretId] = useState("");
const [secretKey, setSecretKey] = useState("");
const [realtimeStt, setRealtimeStt] = useState(false);
const [appId, setAppId] = useState("");
const [mimoEndpoint, setMimoEndpoint] = useState<"payg" | "tokenplan">(
"payg",
);
@@ -101,6 +112,8 @@ export function VoiceSettingsPanel() {
const extra = existing?.extra ?? {};
setSecretId(String(extra.secret_id ?? ""));
setSecretKey(String(extra.secret_key ?? ""));
setRealtimeStt(extra.realtime_stt === true);
setAppId(String(extra.app_id ?? ""));
setMimoEndpoint(extra.endpoint_type === "tokenplan" ? "tokenplan" : "payg");
setMimoVoiceId(String(extra.voice_id ?? "冰糖"));
};
@@ -115,6 +128,8 @@ export function VoiceSettingsPanel() {
secret_id: secretId,
secret_key: secretKey,
region: "ap-guangzhou",
realtime_stt: realtimeStt,
app_id: appId.trim() || undefined,
};
} else if (preset.kind === "edge") {
extra = { voice_id: "zh-CN-XiaoxiaoNeural" };
@@ -152,6 +167,10 @@ export function VoiceSettingsPanel() {
? Boolean(secretId.trim() && secretKey.trim())
: Boolean(apiKey.trim());
if (!complete) message.warning(t("voice.credentialsRequired"));
if (complete && realtimeStt && !appId.trim()) {
message.warning(t("voice.realtimeAppIdRequired"));
return false;
}
return complete;
};
@@ -192,6 +211,9 @@ export function VoiceSettingsPanel() {
} else {
await voiceApi.createProvider(payload);
}
// The active payload carries `stt_realtime`, so drop the cache even when
// the active provider itself does not change (e.g. the switch was toggled).
invalidateVoiceConfigCache();
if (
preset.kind !== "browser" &&
(preset.capability === "stt" || preset.capability === "both") &&
@@ -199,7 +221,6 @@ export function VoiceSettingsPanel() {
) {
const next = await voiceApi.setActive({ stt: preset.id });
setActive(next);
invalidateVoiceConfigCache();
}
if (
preset.kind !== "browser" &&
@@ -208,7 +229,6 @@ export function VoiceSettingsPanel() {
) {
const next = await voiceApi.setActive({ tts: preset.id });
setActive(next);
invalidateVoiceConfigCache();
}
message.success(t("voice.saved"));
setConfigure(null);
@@ -419,6 +439,26 @@ export function VoiceSettingsPanel() {
onChange={(e) => setSecretKey(e.target.value)}
/>
</Form.Item>
<Form.Item
label={t("voice.realtimeStt")}
style={{ marginBottom: 8 }}
>
<Switch checked={realtimeStt} onChange={setRealtimeStt} />
</Form.Item>
<Form.Item label="AppID" required={realtimeStt}>
<Input
placeholder="1300000000"
value={appId}
disabled={!realtimeStt}
inputMode="numeric"
onChange={(e) => setAppId(e.target.value)}
/>
</Form.Item>
{realtimeStt && (
<div className={styles.drawerHint}>
{t("voice.realtimeSttHint")}
</div>
)}
</>
)}
{configure?.preset.kind === "openai" && (
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import {
FRAME_BYTES,
PcmFrameBuffer,
downsampleToPcm16,
floatToPcm16,
} from "./pcmCapture";
describe("floatToPcm16", () => {
it("clamps to the signed 16-bit range", () => {
const out = floatToPcm16(new Float32Array([0, 1, -1, 2, -2]));
expect([...out]).toEqual([0, 32767, -32768, 32767, -32768]);
});
});
describe("downsampleToPcm16", () => {
it("keeps 16 kHz input as is", () => {
const out = downsampleToPcm16(new Float32Array([0, 0.5, -0.5]), 16000);
expect([...out]).toEqual([0, 16383, -16384]);
});
it("interpolates 48 kHz input down to a third of the samples", () => {
const input = new Float32Array(480).fill(0.5);
const out = downsampleToPcm16(input, 48000);
expect(out.length).toBe(160);
expect([...out].every((sample) => sample === 16383)).toBe(true);
});
});
describe("PcmFrameBuffer", () => {
it("emits whole frames of 1280 bytes", () => {
const buffer = new PcmFrameBuffer();
const frames = buffer.push(new Int16Array(640).fill(1));
expect(frames).toHaveLength(1);
expect(frames[0].byteLength).toBe(FRAME_BYTES);
expect([...frames[0].slice(0, 2)]).toEqual([1, 0]);
});
it("carries the remainder into the next frame", () => {
const buffer = new PcmFrameBuffer();
expect(buffer.push(new Int16Array(320).fill(1))).toHaveLength(0);
const frames = buffer.push(new Int16Array(320).fill(2));
expect(frames).toHaveLength(1);
expect([...new Int16Array(frames[0].buffer)].slice(0, 320)).toEqual(
new Array(320).fill(1),
);
expect([...new Int16Array(frames[0].buffer)].slice(320)).toEqual(
new Array(320).fill(2),
);
});
it("emits several frames for a large chunk", () => {
const buffer = new PcmFrameBuffer();
expect(buffer.push(new Int16Array(1600))).toHaveLength(2);
});
it("drops the carry on reset", () => {
const buffer = new PcmFrameBuffer();
buffer.push(new Int16Array(320).fill(1));
buffer.reset();
expect(buffer.push(new Int16Array(320).fill(2))).toHaveLength(0);
const frames = buffer.push(new Int16Array(320).fill(2));
expect([...new Int16Array(frames[0].buffer)].slice(0, 320)).toEqual(
new Array(320).fill(2),
);
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* 16 kHz mono PCM helpers for realtime speech recognition.
*
* Tencent's realtime ASR expects 16-bit mono PCM at 16 kHz, sent in ~40 ms
* frames (640 samples / 1280 bytes) at roughly 1:1 realtime.
*/
export const REALTIME_SAMPLE_RATE = 16000;
export const FRAME_SAMPLES = 640;
export const FRAME_BYTES = FRAME_SAMPLES * 2;
function toPcm16(sample: number): number {
const clamped = Math.max(-1, Math.min(1, sample));
return clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff;
}
export function floatToPcm16(input: Float32Array): Int16Array {
const out = new Int16Array(input.length);
for (let i = 0; i < input.length; i += 1) {
out[i] = toPcm16(input[i]);
}
return out;
}
/** Linearly interpolated resample to 16 kHz PCM16; adequate for speech. */
export function downsampleToPcm16(
input: Float32Array,
inputRate: number,
): Int16Array {
if (inputRate <= REALTIME_SAMPLE_RATE) return floatToPcm16(input);
const ratio = inputRate / REALTIME_SAMPLE_RATE;
const outLength = Math.floor(input.length / ratio);
const out = new Int16Array(outLength);
for (let i = 0; i < outLength; i += 1) {
const position = i * ratio;
const left = Math.floor(position);
const right = Math.min(left + 1, input.length - 1);
const weight = position - left;
out[i] = toPcm16(input[left] * (1 - weight) + input[right] * weight);
}
return out;
}
/** Accumulates arbitrary PCM chunks into whole {@link FRAME_BYTES} frames. */
export class PcmFrameBuffer {
private carry = new Uint8Array(0);
push(samples: Int16Array): Uint8Array[] {
const bytes = new Uint8Array(
samples.buffer,
samples.byteOffset,
samples.byteLength,
);
const merged = new Uint8Array(this.carry.length + bytes.length);
merged.set(this.carry, 0);
merged.set(bytes, this.carry.length);
const frames: Uint8Array[] = [];
let offset = 0;
while (merged.length - offset >= FRAME_BYTES) {
frames.push(merged.slice(offset, offset + FRAME_BYTES));
offset += FRAME_BYTES;
}
this.carry = merged.slice(offset);
return frames;
}
reset(): void {
this.carry = new Uint8Array(0);
}
}
+187
View File
@@ -0,0 +1,187 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { RealtimeSttClient } from "./realtimeStt";
class FakeSocket {
static instances: FakeSocket[] = [];
binaryType = "";
onopen: (() => void) | null = null;
onmessage: ((event: { data: unknown }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: (() => void) | null = null;
sent: unknown[] = [];
closed = false;
readonly url: string;
constructor(url: string) {
this.url = url;
FakeSocket.instances.push(this);
}
send(data: unknown): void {
this.sent.push(data);
}
close(): void {
this.closed = true;
this.onclose?.();
}
emitMessage(payload: unknown): void {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitClose(): void {
this.onclose?.();
}
emitError(): void {
this.onerror?.();
}
}
function setup() {
FakeSocket.instances = [];
vi.stubGlobal("WebSocket", FakeSocket);
const handlers = {
onInterim: vi.fn(),
onFinal: vi.fn(),
onDone: vi.fn(),
onError: vi.fn(),
};
const client = new RealtimeSttClient(
"ws://test/api/voice/stt-stream",
handlers,
);
return { client, handlers };
}
function lastSocket(): FakeSocket {
return FakeSocket.instances[FakeSocket.instances.length - 1];
}
describe("RealtimeSttClient", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("buffers audio until the server is ready", async () => {
const { client } = setup();
const connected = client.connect();
const socket = lastSocket();
client.send(new Uint8Array([1, 2]));
expect(socket.sent).toHaveLength(0);
socket.emitMessage({ type: "ready" });
await connected;
expect(socket.sent).toEqual([new Uint8Array([1, 2])]);
});
it("reports the cumulative transcript", async () => {
const { client, handlers } = setup();
const connected = client.connect();
const socket = lastSocket();
socket.emitMessage({ type: "ready" });
await connected;
socket.emitMessage({ type: "interim", text: "你好", sentence_id: 0 });
expect(handlers.onInterim).toHaveBeenLastCalledWith("你好");
socket.emitMessage({ type: "final", text: "你好世界", sentence_id: 0 });
expect(handlers.onFinal).toHaveBeenLastCalledWith("你好世界");
socket.emitMessage({ type: "interim", text: "再见", sentence_id: 1 });
expect(handlers.onInterim).toHaveBeenLastCalledWith("你好世界再见");
});
it("does not duplicate a sentence confirmed twice", async () => {
const { client, handlers } = setup();
const connected = client.connect();
const socket = lastSocket();
socket.emitMessage({ type: "ready" });
await connected;
socket.emitMessage({ type: "final", text: "你好", sentence_id: 0 });
socket.emitMessage({ type: "final", text: "你好", sentence_id: 0 });
expect(handlers.onFinal).toHaveBeenLastCalledWith("你好");
});
it("commits a trailing interim sentence when the stream ends", async () => {
const { client, handlers } = setup();
const connected = client.connect();
const socket = lastSocket();
socket.emitMessage({ type: "ready" });
await connected;
socket.emitMessage({ type: "interim", text: "未确认" });
socket.emitMessage({ type: "done" });
expect(handlers.onFinal).toHaveBeenLastCalledWith("未确认");
expect(handlers.onDone).toHaveBeenCalledTimes(1);
expect(handlers.onError).not.toHaveBeenCalled();
expect(socket.closed).toBe(true);
});
it("sends the end frame through the socket", async () => {
const { client } = setup();
const connected = client.connect();
const socket = lastSocket();
socket.emitMessage({ type: "ready" });
await connected;
client.end();
expect(JSON.parse(String(socket.sent[socket.sent.length - 1]))).toEqual({
type: "end",
});
});
it("surfaces the server error message", async () => {
const { client, handlers } = setup();
const connected = client.connect();
const socket = lastSocket();
socket.emitMessage({ type: "ready" });
await connected;
socket.emitMessage({ type: "error", message: "资源包额度已耗尽。" });
expect(handlers.onError).toHaveBeenCalledWith("资源包额度已耗尽。");
expect(handlers.onDone).not.toHaveBeenCalled();
});
it("reports an unexpected close as a transport failure", async () => {
const { client, handlers } = setup();
const connected = client.connect();
const socket = lastSocket();
socket.emitMessage({ type: "ready" });
await connected;
socket.emitClose();
expect(handlers.onError).toHaveBeenCalledWith(null);
});
it("rejects connect when the socket fails before ready", async () => {
const { client } = setup();
const connected = client.connect();
lastSocket().emitError();
await expect(connected).rejects.toThrow();
});
it("stops sending once closed", async () => {
const { client } = setup();
const connected = client.connect();
const socket = lastSocket();
socket.emitMessage({ type: "ready" });
await connected;
client.close();
client.send(new Uint8Array([1]));
expect(socket.sent).toHaveLength(0);
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* Browser client for Octop's realtime STT WebSocket (`/api/voice/stt-stream`).
*
* Tencent refines a sentence (`interim`) before confirming it (`final`), so the
* client tracks confirmed sentences by id and reports the *cumulative*
* transcript of the session. The composer can therefore replace only the tail
* of the input it owns instead of appending duplicates.
*/
export interface RealtimeSttHandlers {
/** Cumulative transcript while the current sentence is still being refined. */
onInterim?: (text: string) => void;
/** Cumulative transcript once a sentence is confirmed. */
onFinal?: (text: string) => void;
/** The audio stream finished server-side. */
onDone?: () => void;
/** ``message`` comes from the server; ``null`` means a transport failure. */
onError?: (message: string | null) => void;
}
interface ServerFrame {
type?: string;
text?: string;
sentence_id?: number;
message?: string;
}
/** Sentence id used when committing a trailing interim sentence at ``done``. */
const TRAILING_SENTENCE_ID = Number.MAX_SAFE_INTEGER;
export class RealtimeSttClient {
private socket: WebSocket | null = null;
private readonly finals = new Map<number, string>();
private interim = "";
private pending: Uint8Array[] = [];
private ready = false;
private finished = false;
private endRequested = false;
private closed = false;
private readonly url: string;
private readonly handlers: RealtimeSttHandlers;
constructor(url: string, handlers: RealtimeSttHandlers = {}) {
this.url = url;
this.handlers = handlers;
}
/** Open the socket; resolves once the server reports ``ready``. */
connect(): Promise<void> {
return new Promise<void>((resolve, reject) => {
const socket = new WebSocket(this.url);
socket.binaryType = "arraybuffer";
this.socket = socket;
const failConnect = (error: unknown) => reject(error);
socket.onopen = () => {
// Nothing to send yet: PCM frames are buffered until `ready`.
};
socket.onmessage = (event: MessageEvent) => {
const frame = parseFrame(event.data);
if (!frame) return;
if (frame.type === "ready") {
this.ready = true;
this.flushPending();
resolve();
return;
}
this.handleFrame(frame);
};
socket.onerror = () => {
if (!this.ready) failConnect(new Error("realtime STT socket error"));
this.fail(null);
};
socket.onclose = () => {
if (!this.ready) {
failConnect(new Error("realtime STT socket closed before ready"));
}
if (!this.finished && !this.closed) this.fail(null);
};
});
}
/** Queue or send one PCM frame (16 kHz / 16-bit / mono). */
send(frame: Uint8Array): void {
if (this.closed) return;
if (!this.ready) {
this.pending.push(frame);
return;
}
this.socket?.send(frame);
}
/** Tell the server the audio stream ended and wait for its final results. */
end(): void {
if (this.closed) return;
this.endRequested = true;
if (this.ready) this.socket?.send(JSON.stringify({ type: "end" }));
}
/** Tear down without reporting an error (user cancelled). */
close(): void {
this.closed = true;
this.pending = [];
this.socket?.close();
this.socket = null;
}
private handleFrame(frame: ServerFrame): void {
if (frame.type === "interim") {
this.interim = frame.text ?? "";
this.handlers.onInterim?.(this.transcript());
return;
}
if (frame.type === "final") {
this.finals.set(frame.sentence_id ?? 0, frame.text ?? "");
this.interim = "";
this.handlers.onFinal?.(this.transcript());
return;
}
if (frame.type === "done") {
this.commitTrailingInterim();
this.finished = true;
this.handlers.onDone?.();
this.close();
return;
}
if (frame.type === "error") {
this.fail(frame.message ?? null);
}
}
private commitTrailingInterim(): void {
if (!this.interim) return;
this.finals.set(TRAILING_SENTENCE_ID, this.interim);
this.interim = "";
this.handlers.onFinal?.(this.transcript());
}
private transcript(): string {
const confirmed = [...this.finals.entries()]
.sort(([a], [b]) => a - b)
.map(([, text]) => text)
.filter(Boolean);
if (this.interim) confirmed.push(this.interim);
return confirmed.join("");
}
private flushPending(): void {
if (!this.ready) return;
const queued = this.pending;
this.pending = [];
for (const frame of queued) this.socket?.send(frame);
if (this.endRequested) this.end();
}
private fail(message: string | null): void {
if (this.finished || this.closed) return;
this.finished = true;
this.handlers.onError?.(message);
this.close();
}
}
function parseFrame(data: unknown): ServerFrame | null {
if (typeof data !== "string") return null;
try {
const parsed: unknown = JSON.parse(data);
return parsed && typeof parsed === "object"
? (parsed as ServerFrame)
: null;
} catch {
return null;
}
}
+187 -7
View File
@@ -2,16 +2,37 @@
from __future__ import annotations
import asyncio
import contextlib
import json
from collections.abc import AsyncIterator
from typing import Any, Literal
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
from fastapi import (
APIRouter,
Depends,
File,
Form,
Query,
Request,
UploadFile,
WebSocket,
WebSocketDisconnect,
)
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from starlette.websockets import WebSocketState
from octop.api.deps import current_user, get_server, require_permission
from octop.api.deps import (
current_user,
get_server,
require_permission,
resolve_user_from_token,
)
from octop.i18n.domains.voice import realtime_error_message
from octop.infra.errors import ErrorCode, OctopError
from octop.infra.utils.locale import resolve_request_locale
from octop.infra.utils.locale import Locale, normalize_locale, resolve_request_locale
from octop.infra.voice import realtime
from octop.infra.voice.manager import VoiceManager
from octop.infra.voice.presets import load_voice_presets
@@ -26,6 +47,17 @@ def _voice_manager(server: Any) -> VoiceManager:
)
def _active_payload(mgr: VoiceManager) -> dict[str, Any]:
"""Active provider names plus whether STT can stream.
The dashboard needs ``stt_realtime`` synchronously inside a user gesture to
decide between streaming and the one-shot upload path.
"""
active: dict[str, Any] = dict(mgr.get_active())
active["stt_realtime"] = mgr.realtime_stt_config() is not None
return active
def _row_to_dict(r: Any) -> dict[str, Any]:
return {
"id": r.id,
@@ -97,8 +129,8 @@ async def list_voice_providers(
async def get_active_voice(
_: Any = Depends(current_user),
server: Any = Depends(get_server),
) -> dict[str, str]:
return _voice_manager(server).get_active()
) -> dict[str, Any]:
return _active_payload(_voice_manager(server))
@router.put("/active")
@@ -106,8 +138,10 @@ async def set_active_voice(
body: ActiveVoiceBody,
_: Any = Depends(require_permission("voice")),
server: Any = Depends(get_server),
) -> dict[str, str]:
return _voice_manager(server).set_active(stt=body.stt, tts=body.tts)
) -> dict[str, Any]:
mgr = _voice_manager(server)
mgr.set_active(stt=body.stt, tts=body.tts)
return _active_payload(mgr)
@router.post("/stt")
@@ -152,6 +186,152 @@ async def synthesize_speech(
return StreamingResponse(_stream(), media_type=mgr.media_type(body.provider))
async def _ws_send_json(websocket: WebSocket, payload: dict[str, Any]) -> None:
if websocket.application_state != WebSocketState.CONNECTED:
return
with contextlib.suppress(WebSocketDisconnect, RuntimeError):
await websocket.send_text(json.dumps(payload, ensure_ascii=False))
async def _ws_error(
websocket: WebSocket,
code: ErrorCode,
locale: Locale,
*,
tencent_code: int | None = None,
) -> None:
message = (
realtime_error_message(tencent_code, locale)
if tencent_code is not None
else OctopError(code, "").localized_message(locale)
)
await _ws_send_json(
websocket,
{
"type": "error",
"code": code.value,
"message": message,
"tencent_code": tencent_code,
},
)
def _is_end_frame(raw: str) -> bool:
try:
control = json.loads(raw)
except json.JSONDecodeError:
return False
return isinstance(control, dict) and control.get("type") == "end"
async def _pump_events(
session: realtime.TencentRealtimeSession, websocket: WebSocket, locale: Locale
) -> None:
"""Forward upstream recognition events to the browser until the stream ends."""
async for event in session.events():
if event.kind == "error":
await _ws_error(
websocket,
ErrorCode.VOICE_REALTIME_UNAVAILABLE,
locale,
tencent_code=event.error_code,
)
await _ws_send_json(websocket, {"type": "done"})
return
if event.kind == "done":
await _ws_send_json(websocket, {"type": "done"})
return
await _ws_send_json(
websocket,
{
"type": event.kind,
"text": event.text,
"sentence_id": event.sentence_id,
},
)
@router.websocket("/stt-stream")
async def stream_transcription(
websocket: WebSocket,
token: str | None = Query(default=None),
locale: str | None = Query(default=None),
) -> None:
"""Proxy one realtime ASR session to Tencent Cloud over WebSocket.
The browser sends 16 kHz PCM16 mono frames as binary messages and
``{"type": "end"}`` when it is done; the server replies with ``ready`` /
``interim`` / ``final`` / ``done`` / ``error`` JSON messages.
"""
server = websocket.app.state.octop_server
if not token:
await websocket.close(code=4001, reason="missing token")
return
try:
resolve_user_from_token(server, token)
except OctopError as exc:
await websocket.close(code=4001, reason=f"auth failed: {exc.code.value}")
return
await websocket.accept()
lang = normalize_locale(locale) if locale else resolve_request_locale(websocket)
config = _voice_manager(server).realtime_stt_config()
if config is None:
await _ws_error(websocket, ErrorCode.VOICE_REALTIME_UNAVAILABLE, lang)
await websocket.close(code=4004, reason="realtime STT is not configured")
return
session = realtime.open_session(config)
reader: asyncio.Task[None] | None = None
try:
try:
await session.connect()
except OctopError as exc:
tencent_code = exc.details.get("tencent_code")
await _ws_error(
websocket,
exc.code,
lang,
tencent_code=tencent_code if isinstance(tencent_code, int) else None,
)
await websocket.close(code=4004, reason="upstream connect failed")
return
await _ws_send_json(websocket, {"type": "ready"})
reader = asyncio.create_task(_pump_events(session, websocket, lang))
while websocket.application_state == WebSocketState.CONNECTED:
message = await websocket.receive()
if message.get("type") == "websocket.disconnect":
break
chunk = message.get("bytes")
if chunk is not None:
await session.send_audio(chunk)
continue
raw = message.get("text")
if raw and _is_end_frame(raw):
await session.finish()
break
if reader is not None:
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(reader, timeout=realtime.READER_TIMEOUT_SECONDS)
except WebSocketDisconnect:
pass
finally:
if reader is not None and not reader.done():
reader.cancel()
with contextlib.suppress(asyncio.CancelledError):
await reader
await session.close()
if websocket.application_state == WebSocketState.CONNECTED:
with contextlib.suppress(RuntimeError, WebSocketDisconnect):
await websocket.close()
@admin_router.get("")
async def admin_list_voice_providers(
_: Any = Depends(require_permission("voice")),
+24 -1
View File
@@ -1,4 +1,4 @@
"""``voice.*`` — probe and Tencent provider error copy."""
"""``voice.*`` — probe, realtime and Tencent provider error copy."""
from __future__ import annotations
@@ -36,3 +36,26 @@ def format_voice_probe_error(exc: BaseException, locale: str | Locale) -> str:
if sep and lookup(f"voice.tencent.{code}", locale) is not None:
return tr(f"voice.tencent.{code}", locale)
return raw
#: Realtime ASR error codes worth their own copy; anything else falls back to
#: ``voice.realtime.error_unknown``.
_ASR_ERROR_KEYS = {
4002: "error_4002",
4003: "error_4003",
4004: "error_4004",
4005: "error_4005",
4006: "error_4006",
4007: "error_4007",
4008: "error_4008",
4009: "error_4009",
5000: "error_5000",
5001: "error_5000",
5002: "error_5000",
}
def realtime_error_message(tencent_code: int | None, locale: str | Locale) -> str:
"""Localized copy for a realtime ASR failure code (``None`` = unknown)."""
suffix = _ASR_ERROR_KEYS.get(int(tencent_code or 0), "error_unknown")
return tr(f"voice.realtime.{suffix}", locale)
+13
View File
@@ -440,6 +440,7 @@
"VOICE_KIND_UNSUPPORTED": "Unsupported voice provider.",
"VOICE_CAPABILITY_MISMATCH": "Voice capability does not match.",
"VOICE_PROVIDER_DISABLED": "Voice provider is disabled.",
"VOICE_REALTIME_UNAVAILABLE": "Realtime speech recognition is unavailable. Check the Tencent Cloud voice settings.",
"INTERNAL_ERROR": "Internal server error.",
"TLS_NOT_ELIGIBLE": "TLS issuance is not available in the current server configuration.",
"TLS_ISSUE_IN_PROGRESS": "A certificate issuance task is already running.",
@@ -819,6 +820,18 @@
"http_status": "provider returned HTTP {status}",
"network_error": "network error: {name}"
},
"realtime": {
"error_4002": "Realtime speech recognition authentication failed. Check the SecretId and SecretKey.",
"error_4003": "Realtime speech recognition is not enabled for this Tencent Cloud account.",
"error_4004": "The Tencent Cloud realtime speech recognition resource pack is exhausted.",
"error_4005": "The Tencent Cloud account is in arrears. Top up and retry.",
"error_4006": "Realtime speech recognition concurrency limit reached. Retry in a moment.",
"error_4007": "Audio decoding failed. Check the microphone input.",
"error_4008": "Audio upload timed out. Check the network connection.",
"error_4009": "The realtime speech recognition connection was closed.",
"error_5000": "The Tencent Cloud service is temporarily unavailable. Retry shortly.",
"error_unknown": "Realtime speech recognition failed. Retry in a moment."
},
"tencent": {
"AuthFailure": {
"SecretIdNotFound": "SecretId was not found. Check that the key is correct.",
+13
View File
@@ -440,6 +440,7 @@
"VOICE_KIND_UNSUPPORTED": "不支持的语音提供商。",
"VOICE_CAPABILITY_MISMATCH": "语音能力不匹配。",
"VOICE_PROVIDER_DISABLED": "语音提供商已禁用。",
"VOICE_REALTIME_UNAVAILABLE": "实时语音识别不可用,请检查腾讯云语音配置。",
"INTERNAL_ERROR": "服务器内部错误。",
"TLS_NOT_ELIGIBLE": "当前服务器配置不支持 TLS 证书申请。",
"TLS_ISSUE_IN_PROGRESS": "已有证书申请任务正在进行。",
@@ -819,6 +820,18 @@
"http_status": "服务商返回 HTTP {status}",
"network_error": "网络错误:{name}"
},
"realtime": {
"error_4002": "实时语音识别鉴权失败,请检查 SecretId / SecretKey。",
"error_4003": "当前腾讯云账号尚未开通实时语音识别服务。",
"error_4004": "腾讯云实时语音识别的资源包额度已耗尽。",
"error_4005": "腾讯云账号已欠费停服,请充值后重试。",
"error_4006": "实时语音识别并发数超限,请稍后再试。",
"error_4007": "音频解码失败,请检查麦克风输入。",
"error_4008": "音频发送超时,请保持网络稳定。",
"error_4009": "实时语音识别连接已断开。",
"error_5000": "腾讯云服务暂时不可用,请重试。",
"error_unknown": "实时语音识别失败,请稍后重试。"
},
"tencent": {
"AuthFailure": {
"SecretIdNotFound": "SecretId 不存在,请检查密钥是否填写正确。",
+2
View File
@@ -81,6 +81,7 @@ class ErrorCode(StrEnum):
VOICE_KIND_UNSUPPORTED = "VOICE_KIND_UNSUPPORTED"
VOICE_CAPABILITY_MISMATCH = "VOICE_CAPABILITY_MISMATCH"
VOICE_PROVIDER_DISABLED = "VOICE_PROVIDER_DISABLED"
VOICE_REALTIME_UNAVAILABLE = "VOICE_REALTIME_UNAVAILABLE"
INTERNAL_ERROR = "INTERNAL_ERROR"
TLS_NOT_ELIGIBLE = "TLS_NOT_ELIGIBLE"
TLS_ISSUE_IN_PROGRESS = "TLS_ISSUE_IN_PROGRESS"
@@ -216,6 +217,7 @@ _DEFAULT_STATUS: dict[ErrorCode, int] = {
ErrorCode.VOICE_KIND_UNSUPPORTED: 400,
ErrorCode.VOICE_CAPABILITY_MISMATCH: 400,
ErrorCode.VOICE_PROVIDER_DISABLED: 400,
ErrorCode.VOICE_REALTIME_UNAVAILABLE: 400,
ErrorCode.INTERNAL_ERROR: 500,
ErrorCode.TLS_NOT_ELIGIBLE: 403,
ErrorCode.TLS_ISSUE_IN_PROGRESS: 409,
+70
View File
@@ -0,0 +1,70 @@
"""Tencent Cloud realtime speech recognition (ASR V2) WebSocket URL signing.
This is a *different* scheme from :mod:`octop.infra.utils.tencent_sign`, which
implements TC3-HMAC-SHA256 for the JSON APIs (``SentenceRecognition`` /
``TextToVoice``). Realtime ASR V2 authenticates through query parameters:
1. every parameter except ``signature`` is sorted by key and joined into
``asr.cloud.tencent.com/asr/v2/<appid>?k1=v1&k2=v2`` (no ``wss://`` prefix,
and the values are *not* percent-encoded);
2. that string is signed with ``HMAC-SHA1(secret_key)`` and base64-encoded;
3. only the resulting ``signature`` value is percent-encoded when appended to
the final URL.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import time
from urllib.parse import quote
from uuid import uuid4
ASR_V2_HOST = "asr.cloud.tencent.com"
#: Large-model engine; supports Mandarin + English + Cantonese and 30 dialects.
DEFAULT_ENGINE = "16k_zh_en_2.0"
#: Tencent's ``voice_format`` token for raw 16-bit mono PCM.
PCM_VOICE_FORMAT = 1
DEFAULT_EXPIRED_SECONDS = 3600
def asr_v2_signature(origin: str, secret_key: str) -> str:
"""Return the base64 HMAC-SHA1 signature for a realtime ASR ``origin``."""
digest = hmac.new(secret_key.encode("utf-8"), origin.encode("utf-8"), hashlib.sha1).digest()
return base64.b64encode(digest).decode("ascii")
def build_asr_v2_url(
*,
app_id: str,
secret_id: str,
secret_key: str,
engine: str = DEFAULT_ENGINE,
voice_id: str | None = None,
timestamp: int | None = None,
nonce: int | None = None,
expired_seconds: int = DEFAULT_EXPIRED_SECONDS,
) -> str:
"""Build a signed ``wss://`` URL for one realtime ASR connection.
``voice_id`` must be unique per connection (a new one per call by default),
and ``timestamp`` / ``nonce`` are injectable so tests can pin the signature.
"""
issued_at = int(time.time()) if timestamp is None else int(timestamp)
params: dict[str, str] = {
"secretid": secret_id,
"timestamp": str(issued_at),
"expired": str(issued_at + int(expired_seconds)),
"nonce": str(secrets.randbelow(2**31) if nonce is None else int(nonce)),
"engine_model_type": engine,
"voice_id": voice_id or uuid4().hex,
"voice_format": str(PCM_VOICE_FORMAT),
"needvad": "1",
}
ordered = sorted(params)
path = f"{ASR_V2_HOST}/asr/v2/{app_id}"
query = "&".join(f"{key}={params[key]}" for key in ordered)
signature = quote(asr_v2_signature(f"{path}?{query}", secret_key), safe="")
return f"wss://{path}?{query}&signature={signature}"
+4 -4
View File
@@ -38,7 +38,7 @@ class BrowserOnlyError(Exception):
"""Raised when the active provider must run in the browser."""
def _parse_tencent_credentials(row: VoiceProviderRow) -> tuple[str, str]:
def parse_tencent_credentials(row: VoiceProviderRow) -> tuple[str, str]:
extra = row.get_extra()
secret_id = extra.get("secret_id") or ""
secret_key = extra.get("secret_key") or ""
@@ -163,7 +163,7 @@ async def transcribe_tencent(
mime: str,
language: str,
) -> STTResult:
secret_id, secret_key = _parse_tencent_credentials(row)
secret_id, secret_key = parse_tencent_credentials(row)
extra = row.get_extra()
eng = str(extra.get("eng_service_type") or "16k_zh")
if language.lower().startswith("en"):
@@ -209,7 +209,7 @@ async def synthesize_tencent(
voice_id: str | None,
speed: float,
) -> AsyncIterator[bytes]:
secret_id, secret_key = _parse_tencent_credentials(row)
secret_id, secret_key = parse_tencent_credentials(row)
extra = row.get_extra()
voice_type = int(voice_id or extra.get("voice_type") or 101001)
payload: dict[str, Any] = {
@@ -477,7 +477,7 @@ def _missing_credentials(row: VoiceProviderRow, kind: str, *, locale: str = "en"
"""Probe-time credential check; returns an error message when incomplete."""
if kind == "tencent":
try:
_parse_tencent_credentials(row)
parse_tencent_credentials(row)
except ValueError:
return voice_credentials_error(kind, locale)
return None
+30 -1
View File
@@ -9,7 +9,7 @@ from typing import Any
from octop.infra.db.repos.settings import SettingsRepo
from octop.infra.db.repos.voice_providers import VoiceProviderRepo, VoiceProviderRow
from octop.infra.errors import ErrorCode, OctopError
from octop.infra.voice import adapters
from octop.infra.voice import adapters, realtime
from octop.infra.voice.presets import is_builtin_preset
@@ -89,6 +89,35 @@ class VoiceManager:
kind = self.resolve(name).kind
return "audio/wav" if kind == "mimo" else "audio/mpeg"
def realtime_stt_config(self) -> realtime.RealtimeConfig | None:
"""Credentials for streaming STT, or ``None`` when it is not available.
Streaming only applies to Tencent Cloud, requires the ``realtime_stt``
switch on that provider row plus an ``app_id``, and is ignored unless the
provider is the active STT service.
"""
try:
resolved = self.resolve(self.get_active()["stt"])
except OctopError:
return None
if resolved.kind != "tencent" or resolved.row is None:
return None
extra = resolved.row.get_extra()
if not extra.get("realtime_stt"):
return None
app_id = str(extra.get("app_id") or "").strip()
if not app_id:
return None
try:
secret_id, secret_key = adapters.parse_tencent_credentials(resolved.row)
except ValueError:
return None
return realtime.RealtimeConfig(
app_id=app_id,
secret_id=secret_id,
secret_key=secret_key,
)
async def transcribe(
self,
audio: bytes,
+217
View File
@@ -0,0 +1,217 @@
"""Tencent Cloud realtime speech recognition (ASR V2) upstream session.
One :class:`TencentRealtimeSession` owns a single connection to
``wss://asr.cloud.tencent.com/asr/v2/<appid>``. It is deliberately transport
agnostic: it pushes raw PCM frames upstream and yields the parsed JSON events
back, leaving the browser-facing WebSocket, localisation and error mapping to
the HTTP layer.
"""
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any, Literal
from octop.infra.errors import ErrorCode, OctopError
from octop.infra.utils.tencent_asr_sign import DEFAULT_ENGINE, build_asr_v2_url
logger = logging.getLogger(__name__)
HANDSHAKE_TIMEOUT_SECONDS = 10.0
#: How long the router waits for ``final: 1`` after the client says "end".
READER_TIMEOUT_SECONDS = 15.0
CLOSE_TIMEOUT_SECONDS = 5.0
MAX_MESSAGE_BYTES = 1 << 20
@dataclass(frozen=True)
class RealtimeConfig:
"""Credentials needed to open one realtime ASR connection."""
app_id: str
secret_id: str
secret_key: str
engine: str = DEFAULT_ENGINE
@dataclass(frozen=True)
class RealtimeEvent:
"""One normalized upstream event.
``interim`` text is still being refined, ``final`` text replaces that
sentence, ``done`` marks the end of the stream and ``error`` carries a
Tencent error code.
"""
kind: Literal["interim", "final", "done", "error"]
text: str = ""
sentence_id: int = 0
error_code: int = 0
def _as_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def normalize_event(event: dict[str, Any]) -> RealtimeEvent | None:
"""Map a raw upstream frame onto a :class:`RealtimeEvent`.
Tencent returns two shapes for the same stream: ``16k_zh_en_2.0`` sends
``result`` (``slice_type`` 0 = 中间结果, 1 = 稳定结果, 2 = 该句最终结果,
``index`` = sentence id, ``voice_text_str`` = text), while the API docs
describe ``sentences``. Both are accepted.
"""
code = _as_int(event.get("code"))
if code != 0:
return RealtimeEvent(kind="error", error_code=code)
sentences = event.get("sentences")
if isinstance(sentences, dict) and sentences.get("sentence"):
return RealtimeEvent(
kind="final" if _as_int(sentences.get("sentence_type")) == 1 else "interim",
text=str(sentences["sentence"]),
sentence_id=_as_int(sentences.get("sentence_id")),
)
result = event.get("result")
if isinstance(result, dict) and result.get("voice_text_str"):
return RealtimeEvent(
kind="interim" if _as_int(result.get("slice_type")) == 0 else "final",
text=str(result["voice_text_str"]),
sentence_id=_as_int(result.get("index")),
)
if _as_int(event.get("final")) == 1:
return RealtimeEvent(kind="done")
return None
def _parse_control(raw: str | bytes) -> tuple[int, str] | None:
"""Return ``(code, message)`` of an upstream control frame, or ``None``."""
try:
data = json.loads(raw if isinstance(raw, str) else raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return None
if not isinstance(data, dict):
return None
try:
code = int(data.get("code") or 0)
except (TypeError, ValueError):
code = 0
return code, str(data.get("message") or "")
class TencentRealtimeSession:
"""A live realtime ASR stream for one browser session."""
def __init__(self, config: RealtimeConfig) -> None:
self._config = config
self._ws: Any = None
@property
def connected(self) -> bool:
return self._ws is not None
async def connect(self) -> None:
"""Open the upstream connection and verify the handshake."""
from websockets.asyncio.client import connect as ws_connect
from websockets.exceptions import WebSocketException
url = build_asr_v2_url(
app_id=self._config.app_id,
secret_id=self._config.secret_id,
secret_key=self._config.secret_key,
engine=self._config.engine,
)
try:
self._ws = await ws_connect(
url,
# websockets >= 14 defaults to proxy=True, which silently routes
# through HTTPS_PROXY / ALL_PROXY when the deployment sets one.
proxy=None,
# Tencent does not answer WebSocket pings; keepalive would abort.
ping_interval=None,
open_timeout=HANDSHAKE_TIMEOUT_SECONDS,
close_timeout=CLOSE_TIMEOUT_SECONDS,
max_size=MAX_MESSAGE_BYTES,
)
raw = await asyncio.wait_for(self._ws.recv(), timeout=HANDSHAKE_TIMEOUT_SECONDS)
except (WebSocketException, OSError, TimeoutError) as exc:
await self.close()
raise OctopError(
ErrorCode.VOICE_REALTIME_UNAVAILABLE,
f"realtime ASR connect failed: {exc}",
) from exc
control = _parse_control(raw)
if control is None:
await self.close()
raise OctopError(
ErrorCode.VOICE_REALTIME_UNAVAILABLE,
"realtime ASR sent an unexpected handshake frame",
)
code, message = control
if code != 0:
await self.close()
raise OctopError(
ErrorCode.VOICE_REALTIME_UNAVAILABLE,
f"realtime ASR handshake failed: {message or code}",
details={"tencent_code": code},
)
async def send_audio(self, pcm: bytes) -> None:
"""Forward one PCM frame (16 kHz / 16-bit / mono) upstream."""
if self._ws is None or not pcm:
return
await self._ws.send(pcm)
async def finish(self) -> None:
"""Tell Tencent the audio stream ended so it can flush ``final``."""
if self._ws is None:
return
await self._ws.send(json.dumps({"type": "end"}))
async def events(self) -> AsyncIterator[RealtimeEvent]:
"""Yield normalized events until the connection closes."""
from websockets.exceptions import ConnectionClosed
if self._ws is None:
return
while True:
try:
raw = await self._ws.recv()
except ConnectionClosed:
return
if isinstance(raw, (bytes, bytearray)):
continue
try:
frame = json.loads(raw)
except json.JSONDecodeError:
logger.debug("realtime ASR sent a non-JSON text frame")
continue
if not isinstance(frame, dict):
continue
event = normalize_event(frame)
if event is not None:
yield event
async def close(self) -> None:
ws, self._ws = self._ws, None
if ws is None:
return
try:
await ws.close()
except Exception: # a failed close must not mask the real error
logger.debug("realtime ASR close failed", exc_info=True)
def open_session(config: RealtimeConfig) -> TencentRealtimeSession:
"""Factory kept separate so tests can monkeypatch the upstream session."""
return TencentRealtimeSession(config)
@@ -0,0 +1,196 @@
"""Realtime (streaming) Tencent STT WebSocket."""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
import pytest
from starlette.websockets import WebSocketDisconnect
from octop.infra.errors import ErrorCode, OctopError
from octop.infra.voice import realtime
from octop.infra.voice.manager import VoiceManager
from octop.infra.voice.realtime import RealtimeEvent
from tests.support.app import octop_client
from tests.support.auth import auth_header, bootstrap_admin
from tests.support.http import ws_connect, ws_token
TENCENT_REALTIME_EXTRA = {
"secret_id": "sid",
"secret_key": "skey",
"region": "ap-guangzhou",
"realtime_stt": True,
"app_id": "1302566622",
}
@pytest.fixture
async def env(tmp_octop_home: Path) -> AsyncIterator[Any]:
async with octop_client(tmp_octop_home) as (c, srv):
await bootstrap_admin(c, tmp_octop_home)
yield c, srv, await auth_header(c)
def _manager(srv: Any) -> VoiceManager:
return VoiceManager(
settings_repo=srv.services.settings_repo,
voice_provider_repo=srv.services.voice_provider_repo,
)
def _enable_realtime(srv: Any, extra: dict[str, object] | None = None) -> None:
srv.services.voice_provider_repo.create(
name="tencent",
kind="tencent",
capability="both",
api_key="sid:skey",
extra_json=json.dumps(TENCENT_REALTIME_EXTRA if extra is None else extra),
)
_manager(srv).set_active(stt="tencent")
def _stream_ws(c: Any, auth: dict[str, str] | None = None) -> Any:
query = f"?token={ws_token(auth)}&locale=zh" if auth else "?locale=zh"
return ws_connect(c._octop_app, f"/api/voice/stt-stream{query}")
class FakeSession:
"""Stands in for the upstream Tencent connection."""
def __init__(
self,
frames: list[RealtimeEvent],
*,
connect_error: OctopError | None = None,
) -> None:
self._frames = frames
self._connect_error = connect_error
self.audio: list[bytes] = []
self.finished = False
self.closed = False
self.opened = False
async def connect(self) -> None:
if self._connect_error is not None:
raise self._connect_error
self.opened = True
async def send_audio(self, pcm: bytes) -> None:
self.audio.append(pcm)
async def finish(self) -> None:
self.finished = True
async def events(self) -> AsyncIterator[RealtimeEvent]:
for frame in self._frames:
yield frame
async def close(self) -> None:
self.closed = True
def _install_fake_session(
monkeypatch: pytest.MonkeyPatch,
frames: list[RealtimeEvent],
*,
connect_error: OctopError | None = None,
) -> FakeSession:
session = FakeSession(frames, connect_error=connect_error)
monkeypatch.setattr(realtime, "open_session", lambda _config: session)
return session
def _sentence(text: str, *, final: bool, sentence_id: int = 0) -> RealtimeEvent:
return RealtimeEvent(kind="final" if final else "interim", text=text, sentence_id=sentence_id)
async def test_missing_token_is_rejected(env: Any) -> None:
c, _srv, _auth = env
with pytest.raises(WebSocketDisconnect):
async with _stream_ws(c):
pass
async def test_streams_audio_and_relays_results(env: Any, monkeypatch: pytest.MonkeyPatch) -> None:
c, srv, auth = env
_enable_realtime(srv)
session = _install_fake_session(
monkeypatch,
[
_sentence("你好", final=False),
_sentence("你好世界", final=True),
RealtimeEvent(kind="done"),
],
)
async with _stream_ws(c, auth) as ws:
assert await ws.receive_json() == {"type": "ready"}
await ws.send_bytes(b"\x00" * 1280)
await ws.send_bytes(b"\x01" * 1280)
await ws.send_json({"type": "end"})
frames = await ws.drain_turn()
assert frames == [
{"type": "interim", "text": "你好", "sentence_id": 0},
{"type": "final", "text": "你好世界", "sentence_id": 0},
{"type": "done"},
]
assert session.audio == [b"\x00" * 1280, b"\x01" * 1280]
assert session.finished is True
assert session.closed is True
async def test_realtime_must_be_configured(env: Any, monkeypatch: pytest.MonkeyPatch) -> None:
c, srv, auth = env
_install_fake_session(monkeypatch, [])
async with _stream_ws(c, auth) as ws:
frame = await ws.receive_json()
assert frame["type"] == "error"
assert frame["code"] == ErrorCode.VOICE_REALTIME_UNAVAILABLE.value
with pytest.raises(WebSocketDisconnect):
await ws.receive_json()
async def test_upstream_handshake_failure_is_reported(
env: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
c, srv, auth = env
_enable_realtime(srv)
_install_fake_session(
monkeypatch,
[],
connect_error=OctopError(
ErrorCode.VOICE_REALTIME_UNAVAILABLE,
"handshake failed",
details={"tencent_code": 4003},
),
)
async with _stream_ws(c, auth) as ws:
frame = await ws.receive_json()
assert frame["type"] == "error"
assert frame["tencent_code"] == 4003
assert "实时语音识别" in frame["message"]
async def test_upstream_stream_error_is_forwarded(
env: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
c, srv, auth = env
_enable_realtime(srv)
_install_fake_session(monkeypatch, [RealtimeEvent(kind="error", error_code=4004)])
async with _stream_ws(c, auth) as ws:
assert await ws.receive_json() == {"type": "ready"}
await ws.send_bytes(b"\x00" * 1280)
error = await ws.receive_json()
assert error["type"] == "error"
assert error["code"] == ErrorCode.VOICE_REALTIME_UNAVAILABLE.value
assert error["tencent_code"] == 4004
assert "资源包" in error["message"]
assert await ws.receive_json() == {"type": "done"}
+3
View File
@@ -95,6 +95,9 @@ class ASGIWebSocketSession:
{"type": "websocket.receive", "text": json.dumps(payload, ensure_ascii=False)},
)
async def send_bytes(self, payload: bytes) -> None:
await self._to_app.put({"type": "websocket.receive", "bytes": payload})
async def receive_json(self, *, timeout: float = _WS_RECEIVE_TIMEOUT_S) -> dict[str, Any]:
event = await self._next_event(timeout)
if event["type"] == "websocket.close":
+21 -1
View File
@@ -4,7 +4,11 @@ from __future__ import annotations
import httpx
from octop.i18n.domains.voice import format_voice_probe_error, tencent_api_language
from octop.i18n.domains.voice import (
format_voice_probe_error,
realtime_error_message,
tencent_api_language,
)
def test_tencent_secret_id_is_localized() -> None:
@@ -32,3 +36,19 @@ def test_tencent_api_language_header() -> None:
assert tencent_api_language("zh") == "zh-CN"
assert tencent_api_language("en") == "en-US"
assert tencent_api_language(None) is None
def test_realtime_error_codes_are_localized() -> None:
assert realtime_error_message(4003, "zh") == "当前腾讯云账号尚未开通实时语音识别服务。"
assert realtime_error_message(4004, "en") == (
"The Tencent Cloud realtime speech recognition resource pack is exhausted."
)
# 5001 / 5002 是同一个偶发故障文案
assert realtime_error_message(5002, "zh") == "腾讯云服务暂时不可用,请重试。"
def test_unknown_realtime_code_falls_back() -> None:
assert realtime_error_message(4999, "zh") == "实时语音识别失败,请稍后重试。"
assert realtime_error_message(None, "en") == (
"Realtime speech recognition failed. Retry in a moment."
)
+87
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
@@ -11,6 +12,7 @@ from octop.infra.db.pool import SqlitePool
from octop.infra.db.repos.settings import SettingsRepo
from octop.infra.db.repos.voice_providers import VoiceProviderRepo, VoiceProviderRow
from octop.infra.errors import ErrorCode, OctopError
from octop.infra.utils.tencent_asr_sign import DEFAULT_ENGINE
from octop.infra.voice import adapters
from octop.infra.voice.manager import VoiceManager
@@ -24,6 +26,34 @@ def voice_mgr(tmp_path: Path) -> VoiceManager:
return VoiceManager(settings_repo=settings, voice_provider_repo=repo)
@pytest.fixture
def tencent_mgr(tmp_path: Path) -> tuple[VoiceManager, VoiceProviderRepo]:
db = SqlitePool(tmp_path / "octop.db")
run_migrations(db)
repo = VoiceProviderRepo(db)
manager = VoiceManager(settings_repo=SettingsRepo(db), voice_provider_repo=repo)
return manager, repo
def _add_tencent(repo: VoiceProviderRepo, extra: dict[str, object]) -> None:
repo.create(
name="tencent",
kind="tencent",
capability="both",
api_key="sid:skey",
extra_json=json.dumps(extra),
)
TENCENT_REALTIME_EXTRA: dict[str, object] = {
"secret_id": "sid",
"secret_key": "skey",
"region": "ap-guangzhou",
"realtime_stt": True,
"app_id": "1302566622",
}
def test_default_active_is_browser(voice_mgr: VoiceManager) -> None:
assert voice_mgr.get_active() == {"stt": "browser", "tts": "browser"}
@@ -88,3 +118,60 @@ async def test_configuration_probe_uses_unsaved_values(
assert captured_row.name == "draft"
assert captured_row.api_key == "sk-draft"
assert captured_row.base_url == "https://example.test/v1"
def test_realtime_config_is_none_by_default(voice_mgr: VoiceManager) -> None:
assert voice_mgr.realtime_stt_config() is None
def test_realtime_config_needs_tencent_as_active_stt(
tencent_mgr: tuple[VoiceManager, VoiceProviderRepo],
) -> None:
manager, repo = tencent_mgr
_add_tencent(repo, TENCENT_REALTIME_EXTRA)
assert manager.realtime_stt_config() is None
manager.set_active(stt="tencent")
config = manager.realtime_stt_config()
assert config is not None
assert config.app_id == "1302566622"
assert config.secret_id == "sid"
assert config.secret_key == "skey"
assert config.engine == DEFAULT_ENGINE
def test_realtime_config_needs_the_switch_on(
tencent_mgr: tuple[VoiceManager, VoiceProviderRepo],
) -> None:
manager, repo = tencent_mgr
_add_tencent(repo, {**TENCENT_REALTIME_EXTRA, "realtime_stt": False})
manager.set_active(stt="tencent")
assert manager.realtime_stt_config() is None
@pytest.mark.parametrize("app_id", ["", " ", None])
def test_realtime_config_needs_an_app_id(
tencent_mgr: tuple[VoiceManager, VoiceProviderRepo], app_id: str | None
) -> None:
manager, repo = tencent_mgr
_add_tencent(repo, {**TENCENT_REALTIME_EXTRA, "app_id": app_id})
manager.set_active(stt="tencent")
assert manager.realtime_stt_config() is None
def test_realtime_config_needs_complete_credentials(
tencent_mgr: tuple[VoiceManager, VoiceProviderRepo],
) -> None:
manager, repo = tencent_mgr
repo.create(
name="tencent",
kind="tencent",
capability="both",
extra_json=json.dumps({"realtime_stt": True, "app_id": "1302566622"}),
)
manager.set_active(stt="tencent")
assert manager.realtime_stt_config() is None
+231
View File
@@ -0,0 +1,231 @@
"""Unit tests for the Tencent realtime ASR upstream session."""
from __future__ import annotations
import json
from typing import Any
import pytest
import websockets.asyncio.client as ws_client
from websockets.exceptions import ConnectionClosed
from octop.infra.errors import ErrorCode, OctopError
from octop.infra.voice import realtime
from octop.infra.voice.realtime import RealtimeEvent
CONFIG = realtime.RealtimeConfig(app_id="1302566622", secret_id="sid", secret_key="skey")
class FakeConnection:
def __init__(self, frames: list[Any]) -> None:
self._frames = list(frames)
self.sent: list[Any] = []
self.closed = False
async def recv(self) -> Any:
if not self._frames:
raise ConnectionClosed(None, None)
return self._frames.pop(0)
async def send(self, payload: Any) -> None:
self.sent.append(payload)
async def close(self) -> None:
self.closed = True
def _patch_connect(
monkeypatch: pytest.MonkeyPatch,
connection: FakeConnection | None,
*,
error: Exception | None = None,
capture: list[tuple[str, dict[str, Any]]] | None = None,
) -> None:
async def fake_connect(url: str, **kwargs: Any) -> FakeConnection:
if capture is not None:
capture.append((url, kwargs))
if error is not None:
raise error
assert connection is not None
return connection
monkeypatch.setattr(ws_client, "connect", fake_connect)
def _handshake(code: int = 0, message: str = "success") -> str:
return json.dumps({"code": code, "message": message, "voice_id": "v"})
@pytest.mark.asyncio
async def test_connect_signs_the_url_and_accepts_the_handshake(
monkeypatch: pytest.MonkeyPatch,
) -> None:
conn = FakeConnection([_handshake()])
capture: list[tuple[str, dict[str, Any]]] = []
_patch_connect(monkeypatch, conn, capture=capture)
session = realtime.open_session(CONFIG)
await session.connect()
url, kwargs = capture[0]
assert url.startswith("wss://asr.cloud.tencent.com/asr/v2/1302566622?")
assert "engine_model_type=16k_zh_en_2.0" in url
assert "voice_format=1" in url
assert "&signature=" in url
assert kwargs["proxy"] is None
assert kwargs["ping_interval"] is None
assert session.connected is True
@pytest.mark.asyncio
async def test_connect_maps_a_tencent_error_code(
monkeypatch: pytest.MonkeyPatch,
) -> None:
conn = FakeConnection([_handshake(4003, "service is not open")])
_patch_connect(monkeypatch, conn)
session = realtime.open_session(CONFIG)
with pytest.raises(OctopError) as excinfo:
await session.connect()
assert excinfo.value.code == ErrorCode.VOICE_REALTIME_UNAVAILABLE
assert excinfo.value.details["tencent_code"] == 4003
assert conn.closed is True
assert session.connected is False
@pytest.mark.asyncio
async def test_connect_rejects_an_unexpected_handshake_frame(
monkeypatch: pytest.MonkeyPatch,
) -> None:
conn = FakeConnection(["definitely not json"])
_patch_connect(monkeypatch, conn)
session = realtime.open_session(CONFIG)
with pytest.raises(OctopError) as excinfo:
await session.connect()
assert excinfo.value.code == ErrorCode.VOICE_REALTIME_UNAVAILABLE
assert "unexpected handshake" in excinfo.value.message
@pytest.mark.asyncio
async def test_connect_wraps_transport_failures(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_connect(monkeypatch, None, error=OSError("network down"))
session = realtime.open_session(CONFIG)
with pytest.raises(OctopError) as excinfo:
await session.connect()
assert excinfo.value.code == ErrorCode.VOICE_REALTIME_UNAVAILABLE
assert "network down" in excinfo.value.message
@pytest.mark.asyncio
async def test_audio_and_end_frames_are_forwarded(
monkeypatch: pytest.MonkeyPatch,
) -> None:
conn = FakeConnection([_handshake()])
_patch_connect(monkeypatch, conn)
session = realtime.open_session(CONFIG)
await session.connect()
await session.send_audio(b"\x00" * 1280)
await session.finish()
assert conn.sent[0] == b"\x00" * 1280
assert json.loads(conn.sent[1]) == {"type": "end"}
@pytest.mark.asyncio
async def test_send_before_connect_is_a_noop() -> None:
session = realtime.open_session(CONFIG)
await session.send_audio(b"\x00" * 1280)
await session.finish()
await session.close()
@pytest.mark.asyncio
async def test_events_yields_only_recognized_frames(
monkeypatch: pytest.MonkeyPatch,
) -> None:
conn = FakeConnection(
[
_handshake(), # consumed by connect()
b"\x00\x01\x02", # binary frames are not control events
"not json",
json.dumps({"code": 0, "unrelated": {}}),
json.dumps(_result_frame("你好", slice_type=0)),
json.dumps({"code": 0, "final": 1}),
]
)
_patch_connect(monkeypatch, conn)
session = realtime.open_session(CONFIG)
await session.connect()
events = [event async for event in session.events()]
assert events == [
RealtimeEvent(kind="interim", text="你好", sentence_id=0),
RealtimeEvent(kind="done"),
]
def _result_frame(text: str, *, slice_type: int, index: int = 0) -> dict[str, Any]:
return {
"code": 0,
"result": {
"slice_type": slice_type,
"index": index,
"voice_text_str": text,
"start_time": 0,
"end_time": 500,
},
}
def test_normalize_result_frames() -> None:
"""`16k_zh_en_2.0` answers with `result`, not the documented `sentences`."""
interim = realtime.normalize_event(_result_frame("今天", slice_type=0))
stable = realtime.normalize_event(_result_frame("今天天气不错", slice_type=1))
ultimate = realtime.normalize_event(_result_frame("今天天气不错。", slice_type=2, index=1))
assert interim == RealtimeEvent(kind="interim", text="今天", sentence_id=0)
assert stable == RealtimeEvent(kind="final", text="今天天气不错", sentence_id=0)
assert ultimate == RealtimeEvent(kind="final", text="今天天气不错。", sentence_id=1)
def test_normalize_sentences_frames() -> None:
"""The documented `sentences` shape is still accepted."""
frame = {
"code": 0,
"sentences": {"sentence": "你好", "sentence_type": 1, "sentence_id": 3},
}
assert realtime.normalize_event(frame) == RealtimeEvent(
kind="final", text="你好", sentence_id=3
)
def test_normalize_terminal_and_error_frames() -> None:
assert realtime.normalize_event({"code": 0, "final": 1}) == RealtimeEvent(kind="done")
assert realtime.normalize_event({"code": 4004, "message": "exhausted"}) == RealtimeEvent(
kind="error", error_code=4004
)
assert realtime.normalize_event({"code": 0, "result": {"slice_type": 1}}) is None
@pytest.mark.asyncio
async def test_close_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None:
conn = FakeConnection([_handshake()])
_patch_connect(monkeypatch, conn)
session = realtime.open_session(CONFIG)
await session.connect()
await session.close()
await session.close()
assert conn.closed is True
assert session.connected is False
+84
View File
@@ -0,0 +1,84 @@
"""Tests for Tencent realtime ASR V2 WebSocket URL signing."""
from __future__ import annotations
import base64
import hashlib
import hmac
from urllib.parse import quote
from octop.infra.utils.tencent_asr_sign import (
ASR_V2_HOST,
DEFAULT_ENGINE,
DEFAULT_EXPIRED_SECONDS,
asr_v2_signature,
build_asr_v2_url,
)
_ORIGIN = (
f"{ASR_V2_HOST}/asr/v2/1302566622"
"?engine_model_type=16k_zh_en_2.0&expired=1700003600&needvad=1&nonce=12345"
"&secretid=AKIDSID&timestamp=1700000000&voice_format=1&voice_id=vtest"
)
_SIGNATURE = "rhoZKciBwud6h4DGanqKV1YVWLw="
def _fixed_url(**overrides: object) -> str:
kwargs: dict[str, object] = {
"app_id": "1302566622",
"secret_id": "AKIDSID",
"secret_key": "SKEY",
"voice_id": "vtest",
"timestamp": 1700000000,
"nonce": 12345,
}
kwargs.update(overrides)
return build_asr_v2_url(**kwargs) # type: ignore[arg-type]
def _query_of(url: str) -> str:
return url.split("?", 1)[1].rsplit("&signature=", 1)[0]
def test_signature_is_hmac_sha1_base64() -> None:
expected = base64.b64encode(
hmac.new(b"SKEY", _ORIGIN.encode("utf-8"), hashlib.sha1).digest()
).decode("ascii")
assert expected == _SIGNATURE
assert asr_v2_signature(_ORIGIN, "SKEY") == _SIGNATURE
def test_build_url_matches_locked_origin_and_encoded_signature() -> None:
assert _fixed_url() == f"wss://{_ORIGIN}&signature={quote(_SIGNATURE, safe='')}"
def test_signature_value_is_percent_encoded() -> None:
tail = _fixed_url().split("&signature=", 1)[1]
assert tail == "rhoZKciBwud6h4DGanqKV1YVWLw%3D"
assert not any(ch in tail for ch in "=+/")
def test_params_are_sorted_and_pcm_is_selected() -> None:
query = _query_of(_fixed_url())
keys = [part.split("=", 1)[0] for part in query.split("&")]
assert keys == sorted(keys)
assert "voice_format=1" in query
assert "needvad=1" in query
assert f"engine_model_type={DEFAULT_ENGINE}" in query
assert f"expired={1700000000 + DEFAULT_EXPIRED_SECONDS}" in query
def test_origin_values_are_not_percent_encoded() -> None:
url = _fixed_url(secret_id="sid+slash/eq=")
assert "secretid=sid+slash/eq=&" in url
def test_engine_is_overridable() -> None:
assert f"engine_model_type={'16k_zh'}&" in _fixed_url(engine="16k_zh") + "&"
def test_voice_id_is_regenerated_per_call() -> None:
first = build_asr_v2_url(app_id="a", secret_id="s", secret_key="k")
second = build_asr_v2_url(app_id="a", secret_id="s", secret_key="k")
assert first != second
assert "voice_id=" in first