This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
# Windows 语音终端模拟器
|
||||
|
||||
该程序使用Electron和火山引擎Web RTC SDK模拟一台真实语音终端。它采集Windows麦克风、加入平台分配的RTC房间、发布本地音频、订阅并播放豆包智能体音频,并通过设备v2接口完成激活、状态等待、心跳、Token续签和结束通话。
|
||||
该程序使用Electron和火山引擎Web RTC SDK模拟一台真实语音终端。它采集Windows麦克风、加入平台分配的RTC房间、发布本地音频、订阅并播放豆包智能体音频,并通过设备v2接口完成激活、状态等待、心跳、Token续签和结束通话。进房后还会启动RTC实时字幕,收集长者与智能体的最终话轮并通过现有`turns:batch`设备接口可靠上传;字幕未成功启动时拒绝继续激活通话,避免生成没有聊天记录的完成通话。
|
||||
|
||||
创建通话返回的隐私策略允许原录音时,模拟器同时通过RTC原始音频帧回调采集长者`near`和智能体`far`轨道。结束通话后自动封装为单声道WAV PCM16、按平台限制分片、申请短期对象地址直传,并等待后台完整性与容器校验完成。家庭工作台的通话记录会标记“可听聊天语音”,获得独立PLAY授权的账号可在详情中选择“听长者语音”或“听智能体语音”。
|
||||
|
||||
## 构建
|
||||
|
||||
@@ -12,12 +14,34 @@ npm run build
|
||||
|
||||
输出:`release/voice-device-simulator-0.1.0-windows-x64.exe`。
|
||||
|
||||
连接公网测试服务器时,可以直接双击EXE,也可以使用`launch-cloud-device.ps1`启动。Electron主进程优先读取启动脚本传入的临时环境变量,否则从当前Windows用户的`%LOCALAPPDATA%\DataTerminalVoiceDevice\server-device-secret`读取设备签名密钥。密钥不写入源码、安装包或浏览器存储,也不会回传到渲染进程;输入框留空并显示“已从Windows安全凭据文件加载”即表示可直接检测。
|
||||
|
||||
## 自动连续性验证
|
||||
|
||||
本地回归时可让模拟器自动启动通话、播放一段测试音频,并把双方最终字幕写入JSON结果文件。该入口只接受本机文件和回环地址,不影响正常交互使用:
|
||||
|
||||
```powershell
|
||||
$env:SIMULATOR_AUTO_TEST = '1'
|
||||
$env:SIMULATOR_AUTOMATED_AUDIO_PATH = 'C:\path\to\question.wav'
|
||||
$env:SIMULATOR_AUTO_RESULT_PATH = 'C:\path\to\result.json'
|
||||
& .\release\voice-device-simulator-0.1.0-windows-x64.exe
|
||||
```
|
||||
|
||||
测试会等待长者与智能体的最终字幕,结束通话后退出;结果文件包含通话ID、最终话轮和通过/失败状态。测试音频支持WAV、MP3和OGG,大小不得超过10 MB。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- RTC AppKey、火山AK/SK和豆包Access Token只存在本地后端的受保护文件中;
|
||||
- 模拟器安装包只接收平台签发的短期RTC Token;
|
||||
- 设备签名密钥只驻留Electron主进程内存,不写入浏览器存储;
|
||||
- 当前构建只允许设备API地址指向`localhost`或`127.0.0.1`,避免误连服务器环境;
|
||||
- 当前测试构建只允许设备API地址指向`localhost`、`127.0.0.1`或固定测试服务器`https://121.41.46.238`,避免误连其他环境;
|
||||
- 关闭软件或结束通话时停止音频采集并销毁RTC引擎。
|
||||
- 未取得`record_near`或`record_far`授权的轨道不会注册PCM回调;`upload_allowed=false`时不会采集或上传任何原录音。
|
||||
|
||||
## 实时字幕前提
|
||||
|
||||
测试所用RTC AppID必须在火山RTC控制台开启“实时字幕”和“流式语音识别”。流式语音识别凭据来自豆包语音控制台创建的“流式语音识别”应用,包括APP ID、Access Token和Cluster ID;“音视频字幕生成”类型不能替代该应用。未开通时程序会显示`ServiceAccessDenied`并清理本次未激活通话。
|
||||
|
||||
最终字幕会在程序的“实时字幕与上传”区域显示。每条最终字幕使用通话内稳定键和严格递增序号上传;网络失败时留在内存队列,并在心跳及结束通话前重试。存在未上传字幕时,程序不会结束并丢弃当前通话。
|
||||
|
||||
完整本地启动顺序见[本地真实RTC联调说明](../deploy/local-rtc/README.md)。
|
||||
|
||||
@@ -2,8 +2,10 @@ const { app, BrowserWindow, ipcMain, session } = require("electron");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const tls = require("node:tls");
|
||||
|
||||
app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required");
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
let deviceConfig = null;
|
||||
let desiredConfigRevision = 1;
|
||||
@@ -27,6 +29,29 @@ function createWindow() {
|
||||
|
||||
const devArgument = process.argv.find((argument) => argument.startsWith("--dev-server-url="));
|
||||
const devServerURL = devArgument ? devArgument.slice("--dev-server-url=".length) : "";
|
||||
window.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => {
|
||||
console.error("renderer load failed", { errorCode, errorDescription, validatedURL });
|
||||
});
|
||||
window.webContents.on("render-process-gone", (_event, details) => {
|
||||
console.error("renderer process exited", details);
|
||||
});
|
||||
window.webContents.on("console-message", (_event, ...args) => {
|
||||
const details = args[0];
|
||||
const message = typeof details === "object" && details ? details.message : String(args[1] ?? details ?? "");
|
||||
console.log(`[renderer] ${message}`);
|
||||
});
|
||||
window.webContents.once("did-finish-load", async () => {
|
||||
try {
|
||||
const diagnostics = await window.webContents.executeJavaScript(`JSON.stringify({readyState: document.readyState, bodyLength: document.body.innerText.length, appChildren: document.querySelector('#app')?.childElementCount || 0})`);
|
||||
console.log(`[renderer-ready] ${diagnostics}`);
|
||||
} catch (error) {
|
||||
console.error("renderer diagnostics failed", error);
|
||||
}
|
||||
if (process.env.SIMULATOR_AUTO_TEST === "1") {
|
||||
void runAutomatedSmoke(window);
|
||||
}
|
||||
});
|
||||
|
||||
if (devServerURL) {
|
||||
window.loadURL(devServerURL);
|
||||
} else {
|
||||
@@ -45,29 +70,123 @@ function createWindow() {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoopbackURL(value) {
|
||||
async function runAutomatedSmoke(window) {
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
await delay(800);
|
||||
await window.webContents.executeJavaScript(`document.querySelector('#checkButton')?.click()`);
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await delay(500);
|
||||
const state = await window.webContents.executeJavaScript(`JSON.stringify({phase: document.querySelector('#phaseBadge')?.textContent, canStart: !document.querySelector('#startButton')?.disabled, detail: document.querySelector('#apiDetail')?.textContent})`);
|
||||
console.log(`[auto-test] ${state}`);
|
||||
const parsed = JSON.parse(state);
|
||||
if (parsed.canStart) {
|
||||
await window.webContents.executeJavaScript(`document.querySelector('#startButton')?.click()`);
|
||||
console.log("[auto-test] start requested");
|
||||
await collectAutomatedConversationResult(window);
|
||||
return;
|
||||
}
|
||||
if (parsed.phase === "异常") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.error("[auto-test] environment check did not become ready");
|
||||
}
|
||||
|
||||
async function collectAutomatedConversationResult(window) {
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
const deadline = Date.now() + 120_000;
|
||||
let snapshot = {};
|
||||
while (Date.now() < deadline) {
|
||||
await delay(1000);
|
||||
const encoded = await window.webContents.executeJavaScript(`JSON.stringify({
|
||||
phase: document.querySelector('#phaseBadge')?.textContent || '',
|
||||
conversation_id: document.querySelector('#conversationID')?.textContent || '',
|
||||
subject: [...document.querySelectorAll('.transcript-turn--subject span')].map((item) => item.textContent || ''),
|
||||
agent: [...document.querySelectorAll('.transcript-turn--agent span')].map((item) => item.textContent || ''),
|
||||
turns: [...document.querySelectorAll('.transcript-turn')].map((item) => ({speaker: item.classList.contains('transcript-turn--subject') ? 'subject' : 'agent', text: item.querySelector('span')?.textContent || ''})),
|
||||
events: [...document.querySelectorAll('#eventLog li span')].map((item) => item.textContent || '')
|
||||
})`);
|
||||
snapshot = JSON.parse(encoded);
|
||||
console.log(`[auto-test] conversation ${encoded}`);
|
||||
const subjectIndex = snapshot.turns?.findIndex((turn) => turn.speaker === "subject") ?? -1;
|
||||
const hasAgentReply = subjectIndex >= 0 && snapshot.turns.slice(subjectIndex + 1).some((turn) => turn.speaker === "agent");
|
||||
if (hasAgentReply) {
|
||||
await window.webContents.executeJavaScript(`document.querySelector('#stopButton')?.click()`);
|
||||
snapshot.result = "PASS";
|
||||
break;
|
||||
}
|
||||
if (snapshot.phase === "异常") {
|
||||
snapshot.result = "ERROR";
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!snapshot.result) snapshot.result = "TIMEOUT";
|
||||
const resultPath = process.env.SIMULATOR_AUTO_RESULT_PATH;
|
||||
if (resultPath) {
|
||||
fs.writeFileSync(path.resolve(resultPath), JSON.stringify(snapshot, null, 2), "utf8");
|
||||
}
|
||||
await delay(4000);
|
||||
app.quit();
|
||||
}
|
||||
|
||||
function ensureAllowedAPIURL(value) {
|
||||
const parsed = new URL(String(value || ""));
|
||||
const loopback = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "[::1]";
|
||||
if (!loopback || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
|
||||
throw new Error("本地测试只允许连接 localhost 或 127.0.0.1");
|
||||
const deployedTestServer = parsed.protocol === "https:" && parsed.hostname === "121.41.46.238" && parsed.port === "";
|
||||
if ((!loopback || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) && !deployedTestServer) {
|
||||
throw new Error("测试设备只允许连接本机平台或已部署的测试服务器");
|
||||
}
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
function configure(input) {
|
||||
const apiURL = ensureLoopbackURL(input?.apiURL);
|
||||
const apiURL = ensureAllowedAPIURL(input?.apiURL);
|
||||
const deviceID = String(input?.deviceID || "").trim();
|
||||
const deviceSecret = String(input?.deviceSecret || "");
|
||||
const deviceSecret = String(input?.deviceSecret || "") || loadDefaultDeviceSecret();
|
||||
if (!/^[0-9a-f-]{36}$/i.test(deviceID)) {
|
||||
throw new Error("设备 ID 必须是 UUID");
|
||||
}
|
||||
if (deviceSecret.length < 32) {
|
||||
throw new Error("设备密钥至少需要 32 个字符");
|
||||
throw new Error("未找到有效的设备密钥。请使用云端设备启动脚本,或在设备签名密钥中输入至少32个字符");
|
||||
}
|
||||
deviceConfig = { apiURL, deviceID, deviceSecret };
|
||||
return { apiURL, deviceID };
|
||||
}
|
||||
|
||||
function loadDefaultDeviceSecret() {
|
||||
const environmentSecret = String(process.env.SIMULATOR_DEFAULT_DEVICE_SECRET || "").trim();
|
||||
if (environmentSecret) return environmentSecret;
|
||||
const candidates = [];
|
||||
const localAppData = String(process.env.LOCALAPPDATA || "").trim();
|
||||
const userProfile = String(process.env.USERPROFILE || "").trim();
|
||||
const portableDirectory = String(process.env.PORTABLE_EXECUTABLE_DIR || "").trim();
|
||||
if (localAppData) candidates.push(path.join(localAppData, "DataTerminalVoiceDevice", "server-device-secret"));
|
||||
if (userProfile) candidates.push(path.join(userProfile, "AppData", "Local", "DataTerminalVoiceDevice", "server-device-secret"));
|
||||
try {
|
||||
const home = app.getPath("home");
|
||||
if (home) candidates.push(path.join(home, "AppData", "Local", "DataTerminalVoiceDevice", "server-device-secret"));
|
||||
} catch { /* app path is unavailable before ready */ }
|
||||
if (portableDirectory) candidates.push(path.join(portableDirectory, ".server-device-secret"));
|
||||
candidates.push(path.join(path.dirname(process.execPath), ".server-device-secret"));
|
||||
|
||||
for (const secretPath of new Set(candidates)) {
|
||||
try {
|
||||
const stats = fs.statSync(secretPath);
|
||||
if (!stats.isFile() || stats.size < 32 || stats.size > 4096) continue;
|
||||
const value = fs.readFileSync(secretPath, "utf8").trim();
|
||||
if (value.length >= 32) return value;
|
||||
} catch { /* try the next fixed credential location */ }
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
ipcMain.handle("sim:get-runtime-defaults", () => ({
|
||||
apiURL: process.env.SIMULATOR_DEFAULT_API_URL || "https://121.41.46.238",
|
||||
deviceID: process.env.SIMULATOR_DEFAULT_DEVICE_ID || "10000000-0000-0000-0000-000000000511",
|
||||
deviceSecret: "",
|
||||
deviceSecretConfigured: loadDefaultDeviceSecret().length >= 32,
|
||||
}));
|
||||
|
||||
function nonce() {
|
||||
return crypto.randomBytes(16).toString("hex");
|
||||
}
|
||||
@@ -130,6 +249,53 @@ ipcMain.handle("sim:probe", async () => {
|
||||
return { bootstrap, configuration, desiredConfigRevision };
|
||||
});
|
||||
|
||||
const rtcWebEndpoints = [
|
||||
"access.rtc.volcvideo.com",
|
||||
"access2-hl.rtc.volcvideo.com",
|
||||
"common.rtc.volcvideo.com",
|
||||
"rtcg-access.volcvideos.com",
|
||||
"rtcg.volcvideos.com",
|
||||
"web-log-report.rtc.volcvideo.com",
|
||||
"web-log-report.volcvideos.com",
|
||||
];
|
||||
|
||||
function probeTLSEndpoint(hostname) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = tls.connect({ host: hostname, port: 443, servername: hostname, rejectUnauthorized: true });
|
||||
let finished = false;
|
||||
const finish = (result) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
socket.setTimeout(5_000, () => finish({ hostname, ok: false, error: "连接超时" }));
|
||||
socket.once("secureConnect", () => finish({ hostname, ok: true }));
|
||||
socket.once("error", (error) => finish({ hostname, ok: false, error: String(error?.code || error?.message || "连接失败") }));
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle("sim:probe-rtc-network", async () => {
|
||||
const endpoints = await Promise.all(rtcWebEndpoints.map(probeTLSEndpoint));
|
||||
return { endpoints };
|
||||
});
|
||||
|
||||
ipcMain.handle("sim:get-automation-audio", async () => {
|
||||
const configuredPath = String(process.env.SIMULATOR_AUTOMATED_AUDIO_PATH || "").trim();
|
||||
if (!configuredPath) return undefined;
|
||||
const resolvedPath = path.resolve(configuredPath);
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (!stats.isFile() || stats.size < 1 || stats.size > 10 * 1024 * 1024) {
|
||||
throw new Error("自动验证音频必须是 1 字节到 10MB 的文件");
|
||||
}
|
||||
const extension = path.extname(resolvedPath).toLowerCase();
|
||||
const mime = extension === ".mp3" ? "audio/mpeg" : extension === ".ogg" ? "audio/ogg" : "audio/wav";
|
||||
return {
|
||||
data_url: `data:${mime};base64,${fs.readFileSync(resolvedPath).toString("base64")}`,
|
||||
source_name: path.basename(resolvedPath),
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("sim:create-conversation", async () => deviceRequest("POST", "/device/v2/conversations", {
|
||||
device_time: new Date().toISOString(),
|
||||
monotonic_ms: Math.max(0, Math.round(performance.now())),
|
||||
@@ -180,6 +346,22 @@ ipcMain.handle("sim:heartbeat", async (_event, input) => deviceRequest(
|
||||
},
|
||||
));
|
||||
|
||||
ipcMain.handle("sim:submit-transcript-batch", async (_event, input) => {
|
||||
const conversationID = String(input?.conversationID || "");
|
||||
const turns = Array.isArray(input?.turns) ? input.turns : [];
|
||||
if (!conversationID || turns.length === 0) {
|
||||
throw new Error("通话编号和最终话轮不能为空");
|
||||
}
|
||||
const payload = { turns };
|
||||
const digest = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 32);
|
||||
return deviceRequest(
|
||||
"POST",
|
||||
`/device/v2/conversations/${encodeURIComponent(conversationID)}/turns:batch`,
|
||||
payload,
|
||||
`windows-turns-${digest}`,
|
||||
);
|
||||
});
|
||||
|
||||
ipcMain.handle("sim:stop-conversation", async (_event, input) => deviceRequest(
|
||||
"POST",
|
||||
`/device/v2/conversations/${encodeURIComponent(input.conversationID)}/stop`,
|
||||
@@ -188,11 +370,62 @@ ipcMain.handle("sim:stop-conversation", async (_event, input) => deviceRequest(
|
||||
device_time: new Date().toISOString(),
|
||||
last_heartbeat_sequence: input.lastHeartbeatSequence || 0,
|
||||
rtc_left: true,
|
||||
recording: { near_segments: 0, far_segments: 0 },
|
||||
recording: { near_segments: Number(input.nearSegments || 0), far_segments: Number(input.farSegments || 0) },
|
||||
},
|
||||
`windows-stop-${nonce()}`,
|
||||
));
|
||||
|
||||
ipcMain.handle("sim:upload-conversation-recording", async (_event, input) => {
|
||||
const conversationID = String(input?.conversationID || "");
|
||||
const supplied = Array.isArray(input?.segments) ? input.segments : [];
|
||||
if (!conversationID || supplied.length === 0) throw new Error("通话编号和录音分片不能为空");
|
||||
|
||||
const segments = supplied.map((segment) => {
|
||||
const bytes = Buffer.from(segment.bytes);
|
||||
if (!bytes.length || bytes.length > 64 * 1024 * 1024) throw new Error("单个录音分片必须在 1 字节到 64MiB 之间");
|
||||
return {
|
||||
track: segment.track,
|
||||
sequence_no: Number(segment.sequence_no),
|
||||
codec: segment.codec,
|
||||
content_type: segment.content_type,
|
||||
duration_ms: Number(segment.duration_ms),
|
||||
expected_size: bytes.length,
|
||||
expected_digest: crypto.createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes,
|
||||
};
|
||||
});
|
||||
const manifest = { segments: segments.map(({ bytes: _bytes, ...segment }) => segment) };
|
||||
const manifestDigest = crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex").slice(0, 40);
|
||||
const batch = await deviceRequest(
|
||||
"POST",
|
||||
`/device/v2/conversations/${encodeURIComponent(conversationID)}/upload-batches`,
|
||||
manifest,
|
||||
`windows-media-${manifestDigest}`,
|
||||
);
|
||||
|
||||
for (const local of segments) {
|
||||
const remote = batch.segments?.find((segment) => segment.track === local.track && Number(segment.sequence_no) === local.sequence_no);
|
||||
if (!remote) throw new Error(`平台未返回${local.track}轨道第${local.sequence_no}段录音`);
|
||||
if (remote.status === "VERIFIED" || remote.status === "VERIFYING") continue;
|
||||
if (!remote.upload_url) throw new Error(`平台未签发${local.track}轨道第${local.sequence_no}段上传地址`);
|
||||
const response = await fetch(remote.upload_url, { method: "PUT", headers: { "Content-Type": local.content_type }, body: local.bytes });
|
||||
if (!response.ok) throw new Error(`录音对象上传失败:HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
await deviceRequest("POST", `/device/v2/upload-batches/${encodeURIComponent(batch.id)}/complete`, {});
|
||||
const deadline = Date.now() + 90_000;
|
||||
while (Date.now() < deadline) {
|
||||
const current = await deviceRequest("GET", `/device/v2/upload-batches/${encodeURIComponent(batch.id)}`);
|
||||
if (current.status === "COMPLETED") return current;
|
||||
if (current.status === "FAILED" || current.status === "EXPIRED") {
|
||||
const rejected = current.segments?.find((segment) => segment.status === "REJECTED");
|
||||
throw new Error(rejected?.last_error_code || current.last_error_code || `录音批次${current.status}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||
}
|
||||
throw new Error("录音已上传,但等待服务器校验超时");
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => permission === "media");
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => callback(permission === "media"));
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("deviceSimulator", {
|
||||
getRuntimeDefaults: () => ipcRenderer.invoke("sim:get-runtime-defaults"),
|
||||
configure: (input) => ipcRenderer.invoke("sim:configure", input),
|
||||
probe: () => ipcRenderer.invoke("sim:probe"),
|
||||
probeRTCNetwork: () => ipcRenderer.invoke("sim:probe-rtc-network"),
|
||||
getAutomationAudio: () => ipcRenderer.invoke("sim:get-automation-audio"),
|
||||
createConversation: () => ipcRenderer.invoke("sim:create-conversation"),
|
||||
activateConversation: (input) => ipcRenderer.invoke("sim:activate-conversation", input),
|
||||
getConversation: (conversationID) => ipcRenderer.invoke("sim:get-conversation", conversationID),
|
||||
heartbeat: (input) => ipcRenderer.invoke("sim:heartbeat", input),
|
||||
submitTranscriptBatch: (input) => ipcRenderer.invoke("sim:submit-transcript-batch", input),
|
||||
stopConversation: (input) => ipcRenderer.invoke("sim:stop-conversation", input),
|
||||
uploadConversationRecording: (input) => ipcRenderer.invoke("sim:upload-conversation-recording", input),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self' https: wss: http://127.0.0.1:* http://localhost:*; media-src 'self' blob:; worker-src 'self' blob:; img-src 'self' data:;" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' data:; style-src 'self'; connect-src 'self' data: https://121.41.46.238 https://*.rtc.volcvideo.com https://*.volcvideos.com wss: http://127.0.0.1:* http://localhost:*; media-src 'self' blob:; worker-src 'self' blob: data:; img-src 'self' data:;" />
|
||||
<title>语音终端模拟器</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "SECRET_FILE=%~dp0.server-device-secret"
|
||||
set "DEVICE_EXE=%~dp0voice-device-simulator-0.1.0-windows-x64.exe"
|
||||
|
||||
if not exist "%SECRET_FILE%" (
|
||||
echo Cloud device credential is missing: %SECRET_FILE%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%DEVICE_EXE%" (
|
||||
echo Voice device simulator is missing: %DEVICE_EXE%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set /p SIMULATOR_DEFAULT_DEVICE_SECRET=<"%SECRET_FILE%"
|
||||
set "SIMULATOR_DEFAULT_API_URL=https://121.41.46.238"
|
||||
set "SIMULATOR_DEFAULT_DEVICE_ID=10000000-0000-0000-0000-000000000511"
|
||||
start "" "%DEVICE_EXE%"
|
||||
@@ -0,0 +1,25 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$secretPath = Join-Path $env:LOCALAPPDATA "DataTerminalVoiceDevice\server-device-secret"
|
||||
$executablePath = Join-Path $PSScriptRoot "voice-device-simulator-0.1.0-windows-x64.exe"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $secretPath -PathType Leaf)) {
|
||||
throw "Cloud device credential is missing: $secretPath"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $executablePath -PathType Leaf)) {
|
||||
throw "Voice device simulator is missing: $executablePath"
|
||||
}
|
||||
|
||||
$env:SIMULATOR_DEFAULT_API_URL = "https://121.41.46.238"
|
||||
$env:SIMULATOR_DEFAULT_DEVICE_ID = "10000000-0000-0000-0000-000000000511"
|
||||
$env:SIMULATOR_DEFAULT_DEVICE_SECRET = [IO.File]::ReadAllText($secretPath).Trim()
|
||||
try {
|
||||
Start-Process -FilePath $executablePath
|
||||
}
|
||||
finally {
|
||||
Remove-Item Env:SIMULATOR_DEFAULT_API_URL -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:SIMULATOR_DEFAULT_DEVICE_ID -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:SIMULATOR_DEFAULT_DEVICE_SECRET -ErrorAction SilentlyContinue
|
||||
}
|
||||
+40
-1
@@ -4,6 +4,10 @@ type SimulatorConfiguration = {
|
||||
deviceSecret: string;
|
||||
};
|
||||
|
||||
type SimulatorRuntimeDefaults = SimulatorConfiguration & {
|
||||
deviceSecretConfigured: boolean;
|
||||
};
|
||||
|
||||
type RTCGrant = {
|
||||
app_id: string;
|
||||
room_id: string;
|
||||
@@ -17,6 +21,12 @@ type CreatedConversation = {
|
||||
conversation_id: string;
|
||||
status: string;
|
||||
rtc: RTCGrant;
|
||||
privacy: {
|
||||
retention_mode: "none" | "transcript_only" | "transcript_and_audio";
|
||||
record_near: boolean;
|
||||
record_far: boolean;
|
||||
upload_allowed: boolean;
|
||||
};
|
||||
session: {
|
||||
engine_mode: string;
|
||||
heartbeat_interval_sec: number;
|
||||
@@ -24,10 +34,23 @@ type CreatedConversation = {
|
||||
};
|
||||
};
|
||||
|
||||
type DeviceFinalTurn = {
|
||||
turn_key: string;
|
||||
sequence_no: number;
|
||||
speaker: "subject" | "agent";
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
final_text: string;
|
||||
interrupted: boolean;
|
||||
};
|
||||
|
||||
interface Window {
|
||||
deviceSimulator: {
|
||||
getRuntimeDefaults(): Promise<SimulatorRuntimeDefaults>;
|
||||
configure(input: SimulatorConfiguration): Promise<{ apiURL: string; deviceID: string }>;
|
||||
probe(): Promise<Record<string, unknown>>;
|
||||
probeRTCNetwork(): Promise<{ endpoints: Array<{ hostname: string; ok: boolean; error?: string }> }>;
|
||||
getAutomationAudio(): Promise<{ data_url: string; source_name: string } | undefined>;
|
||||
createConversation(): Promise<CreatedConversation>;
|
||||
activateConversation(input: { conversationID: string; roomID: string; userID: string }): Promise<Record<string, unknown>>;
|
||||
getConversation(conversationID: string): Promise<{ status: string; action: string; failure_code?: string }>;
|
||||
@@ -35,6 +58,22 @@ interface Window {
|
||||
actions?: Array<{ type: string; rtc?: RTCGrant }>;
|
||||
desired_config_revision?: number;
|
||||
}>;
|
||||
stopConversation(input: { conversationID: string; reason: string; lastHeartbeatSequence: number }): Promise<Record<string, unknown>>;
|
||||
submitTranscriptBatch(input: { conversationID: string; turns: DeviceFinalTurn[] }): Promise<{
|
||||
conversation_id: string;
|
||||
accepted_count: number;
|
||||
duplicate_count: number;
|
||||
}>;
|
||||
stopConversation(input: { conversationID: string; reason: string; lastHeartbeatSequence: number; nearSegments: number; farSegments: number }): Promise<Record<string, unknown>>;
|
||||
uploadConversationRecording(input: {
|
||||
conversationID: string;
|
||||
segments: Array<{
|
||||
track: "near" | "far";
|
||||
sequence_no: number;
|
||||
codec: "wav_pcm_s16le";
|
||||
content_type: "audio/wav";
|
||||
duration_ms: number;
|
||||
bytes: Uint8Array;
|
||||
}>;
|
||||
}): Promise<{ id: string; status: string; segments: Array<{ id: string; track: "near" | "far"; sequence_no: number; status: string }> }>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import VERTC, {
|
||||
AudioSourceType,
|
||||
AudioFrameData,
|
||||
IRTCEngine,
|
||||
LocalAudioPropertiesInfo,
|
||||
MediaType,
|
||||
RemoteAudioPropertiesInfo,
|
||||
RoomProfileType,
|
||||
RoomProfileType,
|
||||
StreamIndex,
|
||||
SUBTITLE_MODE,
|
||||
SubtitleEvent,
|
||||
SubtitleEventType,
|
||||
SubtitleMessage,
|
||||
} from "@volcengine/rtc";
|
||||
import { PCMTrackRecorder, RecordedAudioSegment } from "./recording";
|
||||
|
||||
VERTC.setLogConfig({ logLevel: "trace", LogfileSize: 20 });
|
||||
import "./style.css";
|
||||
|
||||
const EngineEventsTypes = VERTC.events;
|
||||
@@ -25,6 +35,19 @@ const state: {
|
||||
agentSpeaking: boolean;
|
||||
microphoneVolume: number;
|
||||
agentVolume: number;
|
||||
subtitleStarted: boolean;
|
||||
transcriptSequence: number;
|
||||
transcriptFirstSeen: Map<string, number>;
|
||||
collectedTurnKeys: Set<string>;
|
||||
pendingTurns: DeviceFinalTurn[];
|
||||
transcriptUpload?: Promise<void>;
|
||||
subtitleStartWaiter?: { resolve: () => void; reject: (error: Error) => void };
|
||||
automationAudio?: HTMLAudioElement;
|
||||
automationAudioContext?: AudioContext;
|
||||
automationAudioTrack?: MediaStreamTrack;
|
||||
automationAudioObjectURL?: string;
|
||||
nearRecorder?: PCMTrackRecorder;
|
||||
farRecorder?: PCMTrackRecorder;
|
||||
} = {
|
||||
phase: "idle",
|
||||
heartbeatSequence: 0,
|
||||
@@ -32,6 +55,11 @@ const state: {
|
||||
agentSpeaking: false,
|
||||
microphoneVolume: 0,
|
||||
agentVolume: 0,
|
||||
subtitleStarted: false,
|
||||
transcriptSequence: 0,
|
||||
transcriptFirstSeen: new Map(),
|
||||
collectedTurnKeys: new Set(),
|
||||
pendingTurns: [],
|
||||
};
|
||||
|
||||
const app = document.querySelector<HTMLElement>("#app")!;
|
||||
@@ -47,7 +75,7 @@ app.innerHTML = `
|
||||
</header>
|
||||
|
||||
<section class="status-grid" aria-label="链路状态">
|
||||
<article class="status-card"><span>本地平台</span><strong id="apiStatus">未检测</strong><small id="apiDetail">localhost</small></article>
|
||||
<article class="status-card"><span>云端平台</span><strong id="apiStatus">未检测</strong><small id="apiDetail">121.41.46.238</small></article>
|
||||
<article class="status-card"><span>RTC 房间</span><strong id="rtcStatus">未加入</strong><small id="rtcDetail">等待开始</small></article>
|
||||
<article class="status-card"><span>豆包智能体</span><strong id="agentStatus">未进房</strong><small id="agentDetail">等待 StartVoiceChat</small></article>
|
||||
<article class="status-card"><span>通话时长</span><strong id="duration">00:00</strong><small id="modeDetail">处理方式:—</small></article>
|
||||
@@ -55,16 +83,16 @@ app.innerHTML = `
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="panel setup-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">LOCAL SETUP</p><h2>本地设备配置</h2></div><span class="safe">密钥仅驻留内存</span></div>
|
||||
<label>本地平台地址<input id="apiURL" value="http://localhost:58080" autocomplete="off" /></label>
|
||||
<div class="panel-heading"><div><p class="eyebrow">DEVICE SETUP</p><h2>测试设备配置</h2></div><span class="safe" id="credentialStatus">正在检查凭据</span></div>
|
||||
<label>云端平台地址<input id="apiURL" value="https://121.41.46.238" autocomplete="off" /></label>
|
||||
<label>模拟设备 ID<input id="deviceID" value="10000000-0000-0000-0000-000000000511" autocomplete="off" /></label>
|
||||
<label>设备签名密钥<input id="deviceSecret" type="password" value="local-simulated-device-secret-32-bytes-minimum" autocomplete="off" /></label>
|
||||
<label>设备签名密钥<input id="deviceSecret" type="password" value="" autocomplete="off" /></label>
|
||||
<div class="row">
|
||||
<label>麦克风<select id="microphone"><option value="">默认麦克风</option></select></label>
|
||||
<label>扬声器<select id="speaker"><option value="">默认扬声器</option></select></label>
|
||||
</div>
|
||||
<button class="secondary" id="checkButton">检测平台和音频设备</button>
|
||||
<p class="hint">RTC AppKey、火山 AK/SK 和豆包 Token 应配置在本地后端,本软件不会读取或保存它们。</p>
|
||||
<p class="hint">RTC AppKey、火山 AK/SK 和豆包 Token 配置在云端后台,本软件不会读取或保存它们。</p>
|
||||
</article>
|
||||
|
||||
<article class="panel call-panel">
|
||||
@@ -83,14 +111,20 @@ app.innerHTML = `
|
||||
<div><dt>RTC 房间</dt><dd id="roomID">—</dd></div>
|
||||
<div><dt>终端用户</dt><dd id="userID">—</dd></div>
|
||||
<div><dt>智能体用户</dt><dd id="agentUserID">—</dd></div>
|
||||
<div><dt>聊天语音</dt><dd id="recordingStatus">等待通话</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel log-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">EVENT STREAM</p><h2>实时事件</h2></div><button class="text-button" id="clearLog">清空</button></div>
|
||||
<div class="panel-heading"><div><p class="eyebrow">EVENT STREAM</p><h2>实时事件</h2></div><div><button class="text-button" id="downloadRtcLog">导出 RTC 日志</button><button class="text-button" id="clearLog">清空</button></div></div>
|
||||
<ol id="eventLog" aria-live="polite"><li><time>系统</time><span>模拟器已启动,等待检测本地环境。</span></li></ol>
|
||||
</section>
|
||||
|
||||
<section class="panel transcript-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">LIVE TRANSCRIPT</p><h2>实时字幕与上传</h2></div><span class="phase-badge" id="transcriptStatus">未启动</span></div>
|
||||
<ol id="liveTranscript" aria-live="polite"><li class="transcript-empty">通话开始后,这里会显示双方的最终字幕。</li></ol>
|
||||
</section>
|
||||
</section>
|
||||
`;
|
||||
|
||||
@@ -106,11 +140,25 @@ const controls = {
|
||||
stop: $<HTMLButtonElement>("stopButton"),
|
||||
};
|
||||
|
||||
void window.deviceSimulator.getRuntimeDefaults().then((defaults) => {
|
||||
controls.apiURL.value = defaults.apiURL;
|
||||
controls.deviceID.value = defaults.deviceID;
|
||||
controls.deviceSecret.value = defaults.deviceSecret;
|
||||
if (defaults.deviceSecretConfigured) {
|
||||
controls.deviceSecret.placeholder = "已从Windows安全凭据文件加载";
|
||||
setText("credentialStatus", "凭据已安全加载");
|
||||
} else {
|
||||
controls.deviceSecret.placeholder = "请输入至少32字符的设备密钥";
|
||||
setText("credentialStatus", "凭据未加载");
|
||||
}
|
||||
}).catch((error) => log(`读取测试设备启动配置失败:${errorMessage(error)}`, "error"));
|
||||
|
||||
function setText(id: string, value: string) {
|
||||
$(id).textContent = value;
|
||||
}
|
||||
|
||||
function log(message: string, level: "info" | "success" | "error" = "info") {
|
||||
console.log(`[${level}] ${message}`);
|
||||
const item = document.createElement("li");
|
||||
item.className = level;
|
||||
const time = document.createElement("time");
|
||||
@@ -154,10 +202,16 @@ async function configureAndProbe() {
|
||||
if (!permission.audio) throw permission.audioExceptionError || new Error("未获得麦克风权限");
|
||||
await refreshAudioDevices();
|
||||
await window.deviceSimulator.probe();
|
||||
const rtcNetwork = await window.deviceSimulator.probeRTCNetwork();
|
||||
const blockedEndpoints = rtcNetwork.endpoints.filter((endpoint) => !endpoint.ok);
|
||||
if (blockedEndpoints.length > 0) {
|
||||
const details = blockedEndpoints.map((endpoint) => `${endpoint.hostname}(${endpoint.error || "连接失败"})`).join("、");
|
||||
throw new Error(`RTC 网络检测失败:${details}。请关闭代理/VPN、放行 TCP 443,或改用手机热点后重试`);
|
||||
}
|
||||
setText("apiStatus", "连接正常");
|
||||
setText("apiDetail", controls.apiURL.value);
|
||||
setPhase("ready", "可以开始通话");
|
||||
log(`环境检测通过,RTC Web SDK ${VERTC.getSdkVersion()}。`, "success");
|
||||
log(`环境检测通过,RTC Web SDK ${VERTC.getSdkVersion()},火山 RTC 接入域名可达。`, "success");
|
||||
} catch (error) {
|
||||
setText("apiStatus", "检测失败");
|
||||
setText("apiDetail", errorMessage(error));
|
||||
@@ -184,6 +238,30 @@ function fillDeviceOptions(select: HTMLSelectElement, devices: MediaDeviceInfo[]
|
||||
}
|
||||
|
||||
function registerRTCEvents(engine: IRTCEngine) {
|
||||
engine.on(EngineEventsTypes.onSubtitleStateChanged, (event: SubtitleEvent) => {
|
||||
if (event.event === SubtitleEventType.STARTED) {
|
||||
state.subtitleStarted = true;
|
||||
state.subtitleStartWaiter?.resolve();
|
||||
setText("transcriptStatus", "识别中");
|
||||
log("RTC 实时字幕已启动,双方最终话轮将自动上传。", "success");
|
||||
return;
|
||||
}
|
||||
if (event.event === SubtitleEventType.STOPPED) {
|
||||
state.subtitleStarted = false;
|
||||
state.subtitleStartWaiter?.reject(new Error("RTC 实时字幕在启动期间被停止"));
|
||||
setText("transcriptStatus", "已停止");
|
||||
return;
|
||||
}
|
||||
if (event.event === SubtitleEventType.ERROR) {
|
||||
state.subtitleStarted = false;
|
||||
state.subtitleStartWaiter?.reject(new Error(`RTC 实时字幕启动失败:${event.errorCode || "UNKNOWN"}${event.errorMessage ? ` · ${event.errorMessage}` : ""}`));
|
||||
setText("transcriptStatus", "识别失败");
|
||||
log(`RTC 实时字幕错误:${event.errorCode || "UNKNOWN"}${event.errorMessage ? ` · ${event.errorMessage}` : ""}`, "error");
|
||||
}
|
||||
});
|
||||
engine.on(EngineEventsTypes.onSubtitleMessageReceived, (messages: SubtitleMessage[]) => {
|
||||
collectSubtitleMessages(messages);
|
||||
});
|
||||
engine.on(EngineEventsTypes.onUserJoined, (event) => {
|
||||
const userID = event.userInfo.userId;
|
||||
log(`远端用户已进房:${userID}`, "success");
|
||||
@@ -227,7 +305,17 @@ function registerRTCEvents(engine: IRTCEngine) {
|
||||
setPhase(state.phase, state.phase === "active" ? (state.agentSpeaking ? "豆包正在说话" : "请开始说话") : undefined);
|
||||
});
|
||||
engine.on(EngineEventsTypes.onConnectionStateChanged, (event) => {
|
||||
log(`RTC 连接状态:${String(event.state)}`);
|
||||
const names: Record<number, string> = {
|
||||
0: "准备连接",
|
||||
1: "已断开",
|
||||
2: "连接中",
|
||||
3: "已连接",
|
||||
4: "重连中",
|
||||
5: "已重连",
|
||||
6: "连接丢失",
|
||||
};
|
||||
const level = event.state === 1 || event.state === 6 ? "error" : "info";
|
||||
log(`RTC 连接状态:${String(event.state)}(${names[event.state] || "未知"})${event.reason !== undefined ? `,原因 ${String(event.reason)}` : ""}`, level);
|
||||
});
|
||||
engine.on(EngineEventsTypes.onError, (event) => {
|
||||
log(`RTC 错误:${event.errorCode}`, "error");
|
||||
@@ -237,6 +325,106 @@ function registerRTCEvents(engine: IRTCEngine) {
|
||||
});
|
||||
}
|
||||
|
||||
function subtitleIdentity(message: SubtitleMessage) {
|
||||
const conversation = state.conversation;
|
||||
if (!conversation) return undefined;
|
||||
if (message.userId === conversation.rtc.user_id) return { speaker: "subject" as const, label: "长者" };
|
||||
if (message.userId === conversation.rtc.agent_user_id) return { speaker: "agent" as const, label: "豆包" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function subtitleKey(message: SubtitleMessage) {
|
||||
return `${message.userId}:${message.sequence}`;
|
||||
}
|
||||
|
||||
function collectSubtitleMessages(messages: SubtitleMessage[]) {
|
||||
const now = Date.now();
|
||||
for (const message of messages) {
|
||||
const identity = subtitleIdentity(message);
|
||||
const text = message.text.trim();
|
||||
if (!identity || !text || message.mode !== SUBTITLE_MODE.ASR_ONLY) continue;
|
||||
const sourceKey = subtitleKey(message);
|
||||
if (!state.transcriptFirstSeen.has(sourceKey)) state.transcriptFirstSeen.set(sourceKey, now);
|
||||
if (!message.definite || state.collectedTurnKeys.has(sourceKey)) continue;
|
||||
|
||||
state.collectedTurnKeys.add(sourceKey);
|
||||
state.transcriptSequence += 1;
|
||||
const turn: DeviceFinalTurn = {
|
||||
turn_key: `rtc-subtitle-${identity.speaker}-${message.sequence}`,
|
||||
sequence_no: state.transcriptSequence,
|
||||
speaker: identity.speaker,
|
||||
start_at: new Date(state.transcriptFirstSeen.get(sourceKey) || now).toISOString(),
|
||||
end_at: new Date(now).toISOString(),
|
||||
final_text: text,
|
||||
interrupted: false,
|
||||
};
|
||||
state.pendingTurns.push(turn);
|
||||
renderTranscriptTurn(turn, identity.label);
|
||||
}
|
||||
void flushTranscriptTurns();
|
||||
}
|
||||
|
||||
function renderTranscriptTurn(turn: DeviceFinalTurn, label: string) {
|
||||
const list = $("liveTranscript");
|
||||
list.querySelector(".transcript-empty")?.remove();
|
||||
const item = document.createElement("li");
|
||||
item.className = `transcript-turn transcript-turn--${turn.speaker}`;
|
||||
const speaker = document.createElement("strong");
|
||||
speaker.textContent = label;
|
||||
const text = document.createElement("span");
|
||||
text.textContent = turn.final_text;
|
||||
item.append(speaker, text);
|
||||
list.append(item);
|
||||
}
|
||||
|
||||
async function flushTranscriptTurns() {
|
||||
if (state.transcriptUpload) return state.transcriptUpload;
|
||||
if (!state.conversation || state.pendingTurns.length === 0) return;
|
||||
const conversationID = state.conversation.conversation_id;
|
||||
const batch = state.pendingTurns.slice(0, 500);
|
||||
state.transcriptUpload = (async () => {
|
||||
try {
|
||||
const result = await window.deviceSimulator.submitTranscriptBatch({ conversationID, turns: batch });
|
||||
state.pendingTurns.splice(0, batch.length);
|
||||
const uploaded = state.transcriptSequence - state.pendingTurns.length;
|
||||
setText("transcriptStatus", state.subtitleStarted ? `识别中 · 已上传 ${uploaded}` : `已上传 ${uploaded}`);
|
||||
log(`最终字幕已上传:新增 ${result.accepted_count} 条,重复 ${result.duplicate_count} 条。`, "success");
|
||||
} catch (error) {
|
||||
setText("transcriptStatus", `待重试 ${state.pendingTurns.length} 条`);
|
||||
log(`最终字幕上传失败,将在心跳或结束通话时重试:${errorMessage(error)}`, "error");
|
||||
} finally {
|
||||
state.transcriptUpload = undefined;
|
||||
}
|
||||
})();
|
||||
await state.transcriptUpload;
|
||||
if (state.pendingTurns.length > 0 && state.pendingTurns.length !== batch.length) void flushTranscriptTurns();
|
||||
}
|
||||
|
||||
async function flushTranscriptTurnsWithRetry(attempts: number) {
|
||||
for (let attempt = 1; attempt <= attempts && state.pendingTurns.length > 0; attempt += 1) {
|
||||
await flushTranscriptTurns();
|
||||
if (state.pendingTurns.length === 0) return;
|
||||
if (attempt < attempts) await new Promise((resolve) => window.setTimeout(resolve, attempt * 500));
|
||||
}
|
||||
}
|
||||
|
||||
async function startSubtitleAndWait(engine: IRTCEngine) {
|
||||
let timeoutID: number | undefined;
|
||||
const stateEvent = new Promise<void>((resolve, reject) => {
|
||||
state.subtitleStartWaiter = { resolve, reject };
|
||||
});
|
||||
const timeout = new Promise<void>((_resolve, reject) => {
|
||||
timeoutID = window.setTimeout(() => reject(new Error("等待 RTC 实时字幕启动超时")), 10_000);
|
||||
});
|
||||
try {
|
||||
await engine.startSubtitle({ mode: SUBTITLE_MODE.ASR_ONLY });
|
||||
await Promise.race([stateEvent, timeout]);
|
||||
} finally {
|
||||
if (timeoutID) window.clearTimeout(timeoutID);
|
||||
state.subtitleStartWaiter = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function updateVolume(kind: "mic" | "agent", raw: number) {
|
||||
const value = Math.max(0, Math.min(100, Math.round(raw)));
|
||||
if (kind === "mic") state.microphoneVolume = value;
|
||||
@@ -254,6 +442,15 @@ async function startConversation() {
|
||||
state.credentialExpiresAt = created.rtc.expires_at;
|
||||
state.heartbeatSequence = 0;
|
||||
state.agentJoined = false;
|
||||
state.subtitleStarted = false;
|
||||
state.transcriptSequence = 0;
|
||||
state.transcriptFirstSeen.clear();
|
||||
state.collectedTurnKeys.clear();
|
||||
state.pendingTurns = [];
|
||||
state.transcriptUpload = undefined;
|
||||
state.nearRecorder = undefined;
|
||||
state.farRecorder = undefined;
|
||||
$("liveTranscript").innerHTML = '<li class="transcript-empty">正在等待双方的最终字幕。</li>';
|
||||
renderSession(created);
|
||||
log(`平台已分配 RTC 房间 ${created.rtc.room_id}。`, "success");
|
||||
|
||||
@@ -261,18 +458,27 @@ async function startConversation() {
|
||||
state.engine = engine;
|
||||
registerRTCEvents(engine);
|
||||
engine.enableAudioPropertiesReport({ interval: 300 });
|
||||
await engine.startAudioCapture(controls.microphone.value || undefined);
|
||||
configureRecording(engine, created);
|
||||
const automatedAudio = await prepareAutomationAudio(engine);
|
||||
if (!automatedAudio) await engine.startAudioCapture(controls.microphone.value || undefined);
|
||||
if (controls.speaker.value) await engine.setAudioPlaybackDevice(controls.speaker.value);
|
||||
await engine.joinRoom(created.rtc.token, created.rtc.room_id, { userId: created.rtc.user_id }, {
|
||||
isAutoPublish: true,
|
||||
isAutoSubscribeAudio: true,
|
||||
isAutoSubscribeVideo: false,
|
||||
roomProfileType: RoomProfileType.chat,
|
||||
});
|
||||
log("正在连接火山 RTC 信令并加入房间。", "info");
|
||||
await Promise.race([
|
||||
engine.joinRoom(created.rtc.token, created.rtc.room_id, { userId: created.rtc.user_id }, {
|
||||
isAutoPublish: true,
|
||||
isAutoSubscribeAudio: true,
|
||||
isAutoSubscribeVideo: false,
|
||||
roomProfileType: RoomProfileType.chat,
|
||||
}),
|
||||
new Promise<never>((_resolve, reject) => window.setTimeout(() => reject(new Error("RTC 入房超过 15 秒。请关闭代理/VPN、允许本程序通过 Windows 防火墙,或改用手机热点后重试")), 15_000)),
|
||||
]);
|
||||
setText("rtcStatus", "已加入");
|
||||
setText("rtcDetail", created.rtc.room_id);
|
||||
log("终端已加入 RTC 房间并发布麦克风。", "success");
|
||||
|
||||
setText("transcriptStatus", "正在启动");
|
||||
await startSubtitleAndWait(engine);
|
||||
|
||||
await window.deviceSimulator.activateConversation({
|
||||
conversationID: created.conversation_id,
|
||||
roomID: created.rtc.room_id,
|
||||
@@ -285,13 +491,96 @@ async function startConversation() {
|
||||
setPhase("active", "请开始说话");
|
||||
startTimers(created.session.heartbeat_interval_sec || 15);
|
||||
log("豆包语音任务已激活,可以开始双向聊天。", "success");
|
||||
if (automatedAudio) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 4_000));
|
||||
await state.automationAudioContext?.resume();
|
||||
await state.automationAudio?.play();
|
||||
log("自动验证音频已通过 RTC 自定义音轨发送。", "success");
|
||||
}
|
||||
} catch (error) {
|
||||
setPhase("error", "启动失败");
|
||||
log(`启动通话失败:${errorMessage(error)}`, "error");
|
||||
await releaseRTC();
|
||||
if (state.conversation) {
|
||||
try {
|
||||
await window.deviceSimulator.stopConversation({
|
||||
conversationID: state.conversation.conversation_id,
|
||||
reason: "windows_start_failed",
|
||||
lastHeartbeatSequence: state.heartbeatSequence,
|
||||
nearSegments: 0,
|
||||
farSegments: 0,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
log(`清理失败通话时发生错误:${errorMessage(cleanupError)}`, "error");
|
||||
}
|
||||
}
|
||||
resetSession();
|
||||
}
|
||||
}
|
||||
|
||||
function configureRecording(engine: IRTCEngine, conversation: CreatedConversation) {
|
||||
const privacy = conversation.privacy;
|
||||
if (!privacy.upload_allowed || (!privacy.record_near && !privacy.record_far)) {
|
||||
setText("recordingStatus", "本次未授权录音");
|
||||
log("本次通话的隐私策略未授权保存原录音,不采集音频帧。", "info");
|
||||
return;
|
||||
}
|
||||
if (privacy.record_near) {
|
||||
state.nearRecorder = new PCMTrackRecorder();
|
||||
engine.setAudioFrameCallback(StreamIndex.STREAM_INDEX_MAIN, undefined, (frame: AudioFrameData) => state.nearRecorder?.append(frame), 4096);
|
||||
}
|
||||
if (privacy.record_far) {
|
||||
state.farRecorder = new PCMTrackRecorder();
|
||||
engine.setAudioFrameCallback(StreamIndex.STREAM_INDEX_MAIN, conversation.rtc.agent_user_id, (frame: AudioFrameData) => state.farRecorder?.append(frame), 4096);
|
||||
}
|
||||
setText("recordingStatus", "录音中");
|
||||
log(`已按授权开始采集${privacy.record_near ? "长者近端" : ""}${privacy.record_near && privacy.record_far ? "和" : ""}${privacy.record_far ? "智能体远端" : ""} PCM 音频。`, "success");
|
||||
}
|
||||
|
||||
function finishRecording(engine: IRTCEngine | undefined, conversation: CreatedConversation) {
|
||||
try { engine?.setAudioFrameCallback(StreamIndex.STREAM_INDEX_MAIN, undefined, undefined); } catch { /* no-op */ }
|
||||
try { engine?.setAudioFrameCallback(StreamIndex.STREAM_INDEX_MAIN, conversation.rtc.agent_user_id, undefined); } catch { /* no-op */ }
|
||||
const segments = [
|
||||
...(state.nearRecorder?.finish("near") ?? []),
|
||||
...(state.farRecorder?.finish("far") ?? []),
|
||||
];
|
||||
state.nearRecorder = undefined;
|
||||
state.farRecorder = undefined;
|
||||
return segments;
|
||||
}
|
||||
|
||||
async function prepareAutomationAudio(engine: IRTCEngine) {
|
||||
const asset = await window.deviceSimulator.getAutomationAudio();
|
||||
if (!asset) return false;
|
||||
const comma = asset.data_url.indexOf(",");
|
||||
if (comma < 0) throw new Error("自动验证音频编码无效");
|
||||
const metadata = asset.data_url.slice(0, comma);
|
||||
const mime = metadata.match(/^data:([^;]+)/)?.[1] || "audio/wav";
|
||||
const bytes = Uint8Array.from(atob(asset.data_url.slice(comma + 1)), (value) => value.charCodeAt(0));
|
||||
const objectURL = URL.createObjectURL(new Blob([bytes], { type: mime }));
|
||||
const audio = new Audio(objectURL);
|
||||
audio.preload = "auto";
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
audio.addEventListener("canplaythrough", () => resolve(), { once: true });
|
||||
audio.addEventListener("error", () => reject(new Error(`无法加载自动验证音频:${asset.source_name}`)), { once: true });
|
||||
audio.load();
|
||||
});
|
||||
const context = new AudioContext({ sampleRate: 48_000 });
|
||||
const source = context.createMediaElementSource(audio);
|
||||
const destination = context.createMediaStreamDestination();
|
||||
source.connect(destination);
|
||||
const track = destination.stream.getAudioTracks()[0];
|
||||
if (!track) throw new Error("自动验证音频没有可发布的音轨");
|
||||
await engine.setAudioSourceType(StreamIndex.STREAM_INDEX_MAIN, AudioSourceType.AUDIO_SOURCE_TYPE_EXTERNAL);
|
||||
await engine.setExternalAudioTrack(StreamIndex.STREAM_INDEX_MAIN, track);
|
||||
state.automationAudio = audio;
|
||||
state.automationAudioContext = context;
|
||||
state.automationAudioTrack = track;
|
||||
state.automationAudioObjectURL = objectURL;
|
||||
log(`已准备自动验证音频:${asset.source_name}。`, "success");
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderSession(created: CreatedConversation) {
|
||||
setText("conversationID", created.conversation_id);
|
||||
setText("roomID", created.rtc.room_id);
|
||||
@@ -340,6 +629,7 @@ async function sendHeartbeat() {
|
||||
log("RTC Token 已通过设备心跳完成续签。", "success");
|
||||
}
|
||||
}
|
||||
await flushTranscriptTurns();
|
||||
} catch (error) {
|
||||
log(`设备心跳失败:${errorMessage(error)}`, "error");
|
||||
}
|
||||
@@ -350,15 +640,59 @@ async function stopConversation(reason = "windows_user_hangup") {
|
||||
setPhase("stopping", "正在结束通话");
|
||||
stopTimers();
|
||||
const conversation = state.conversation;
|
||||
let recordingResult = "等待通话";
|
||||
try {
|
||||
if (state.subtitleStarted) state.engine?.stopSubtitle();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 800));
|
||||
await flushTranscriptTurnsWithRetry(3);
|
||||
if (state.pendingTurns.length > 0) {
|
||||
log(`仍有 ${state.pendingTurns.length} 条最终字幕未上传,通话暂不结束并继续自动重试。`, "error");
|
||||
try {
|
||||
setText("transcriptStatus", "正在恢复识别");
|
||||
await state.engine?.startSubtitle({ mode: SUBTITLE_MODE.ASR_ONLY });
|
||||
} catch (error) {
|
||||
log(`恢复实时字幕失败:${errorMessage(error)}`, "error");
|
||||
}
|
||||
setPhase("active", "字幕待上传");
|
||||
startTimers(conversation.session.heartbeat_interval_sec || 15);
|
||||
return;
|
||||
}
|
||||
setText("recordingStatus", "正在封装录音");
|
||||
const recordingSegments = finishRecording(state.engine, conversation);
|
||||
await releaseRTC();
|
||||
await window.deviceSimulator.stopConversation({
|
||||
conversationID: conversation.conversation_id,
|
||||
reason,
|
||||
lastHeartbeatSequence: state.heartbeatSequence,
|
||||
nearSegments: recordingSegments.filter((segment) => segment.track === "near").length,
|
||||
farSegments: recordingSegments.filter((segment) => segment.track === "far").length,
|
||||
});
|
||||
log("RTC 已离房,平台已提交 StopVoiceChat。", "success");
|
||||
if (recordingSegments.length > 0) {
|
||||
try {
|
||||
setText("recordingStatus", "等待通话结束");
|
||||
await waitUntilTerminal(conversation.conversation_id);
|
||||
setText("recordingStatus", "正在上传");
|
||||
const totalBytes = recordingSegments.reduce((sum, segment) => sum + segment.bytes.byteLength, 0);
|
||||
log(`正在上传 ${recordingSegments.length} 个聊天语音分片(${formatRecordingBytes(totalBytes)})。`, "info");
|
||||
const batch = await uploadRecordingWithRetry(conversation.conversation_id, recordingSegments, 3);
|
||||
setText("recordingStatus", "已上传并校验");
|
||||
recordingResult = "本次已上传";
|
||||
log(`聊天语音已上传并通过服务器校验,批次 ${batch.id}。`, "success");
|
||||
} catch (uploadError) {
|
||||
setText("recordingStatus", "上传失败");
|
||||
recordingResult = "本次上传失败";
|
||||
log(`聊天语音上传失败:${errorMessage(uploadError)}`, "error");
|
||||
}
|
||||
} else if (conversation.privacy.upload_allowed) {
|
||||
setText("recordingStatus", "没有可上传音频");
|
||||
recordingResult = "本次没有音频";
|
||||
log("本次没有收到可封装的 PCM 音频帧,未创建录音上传批次。", "info");
|
||||
} else {
|
||||
recordingResult = "本次未授权录音";
|
||||
}
|
||||
resetSession();
|
||||
setText("recordingStatus", recordingResult);
|
||||
setPhase("ready", "可以再次通话");
|
||||
} catch (error) {
|
||||
log(`结束通话失败:${errorMessage(error)}`, "error");
|
||||
@@ -367,10 +701,53 @@ async function stopConversation(reason = "windows_user_hangup") {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilTerminal(conversationID: string) {
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await window.deviceSimulator.getConversation(conversationID);
|
||||
if (result.status === "COMPLETED" || result.status === "FAILED") return;
|
||||
if (result.status === "EXPIRED") throw new Error("通话已过期,平台不接受录音上传");
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 750));
|
||||
}
|
||||
throw new Error("等待通话进入结束状态超时");
|
||||
}
|
||||
|
||||
async function uploadRecordingWithRetry(conversationID: string, segments: RecordedAudioSegment[], attempts: number) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
return await window.deviceSimulator.uploadConversationRecording({ conversationID, segments });
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < attempts) {
|
||||
log(`录音上传第 ${attempt} 次尝试失败,正在重试:${errorMessage(error)}`, "error");
|
||||
await new Promise((resolve) => window.setTimeout(resolve, attempt * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function formatRecordingBytes(value: number) {
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
async function releaseRTC() {
|
||||
state.automationAudio?.pause();
|
||||
state.automationAudioTrack?.stop();
|
||||
if (state.automationAudioObjectURL) URL.revokeObjectURL(state.automationAudioObjectURL);
|
||||
if (state.automationAudioContext) {
|
||||
try { await state.automationAudioContext.close(); } catch { /* no-op */ }
|
||||
}
|
||||
state.automationAudio = undefined;
|
||||
state.automationAudioTrack = undefined;
|
||||
state.automationAudioContext = undefined;
|
||||
state.automationAudioObjectURL = undefined;
|
||||
const engine = state.engine;
|
||||
state.engine = undefined;
|
||||
if (!engine) return;
|
||||
try { engine.stopSubtitle(); } catch { /* no-op */ }
|
||||
try { await engine.unpublishStream(MediaType.AUDIO); } catch { /* no-op */ }
|
||||
try { await engine.leaveRoom(); } catch { /* no-op */ }
|
||||
try { await engine.stopAudioCapture(); } catch { /* no-op */ }
|
||||
@@ -392,6 +769,15 @@ function resetSession() {
|
||||
state.startedAt = undefined;
|
||||
state.agentJoined = false;
|
||||
state.agentSpeaking = false;
|
||||
state.subtitleStarted = false;
|
||||
state.transcriptSequence = 0;
|
||||
state.transcriptFirstSeen.clear();
|
||||
state.collectedTurnKeys.clear();
|
||||
state.pendingTurns = [];
|
||||
state.transcriptUpload = undefined;
|
||||
state.subtitleStartWaiter = undefined;
|
||||
state.nearRecorder = undefined;
|
||||
state.farRecorder = undefined;
|
||||
setText("rtcStatus", "未加入");
|
||||
setText("rtcDetail", "等待开始");
|
||||
setText("agentStatus", "未进房");
|
||||
@@ -399,12 +785,19 @@ function resetSession() {
|
||||
setText("duration", "00:00");
|
||||
updateVolume("mic", 0);
|
||||
updateVolume("agent", 0);
|
||||
setText("transcriptStatus", "未启动");
|
||||
setText("recordingStatus", "等待通话");
|
||||
$("liveTranscript").innerHTML = '<li class="transcript-empty">通话开始后,这里会显示双方的最终字幕。</li>';
|
||||
}
|
||||
|
||||
controls.check.addEventListener("click", () => void configureAndProbe());
|
||||
controls.start.addEventListener("click", () => void startConversation());
|
||||
controls.stop.addEventListener("click", () => void stopConversation());
|
||||
$("clearLog").addEventListener("click", () => $("eventLog").replaceChildren());
|
||||
$("downloadRtcLog").addEventListener("click", () => {
|
||||
VERTC.downloadLog();
|
||||
log("已导出火山 RTC SDK 日志,请在 Windows“下载”目录查找最新日志文件。", "success");
|
||||
});
|
||||
window.addEventListener("beforeunload", () => {
|
||||
stopTimers();
|
||||
void releaseRTC();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
export type AudioFrame = {
|
||||
channels: Float32Array[];
|
||||
channelCount: number;
|
||||
sampleRate: number;
|
||||
};
|
||||
|
||||
export type RecordedAudioSegment = {
|
||||
track: "near" | "far";
|
||||
sequence_no: number;
|
||||
codec: "wav_pcm_s16le";
|
||||
content_type: "audio/wav";
|
||||
duration_ms: number;
|
||||
bytes: Uint8Array;
|
||||
};
|
||||
|
||||
const WAV_HEADER_SIZE = 44;
|
||||
const MAX_SEGMENT_DURATION_MS = 9 * 60 * 1000;
|
||||
const MAX_SEGMENT_SIZE = 64 * 1024 * 1024;
|
||||
|
||||
export class PCMTrackRecorder {
|
||||
private sampleRate = 0;
|
||||
private chunks: Float32Array[] = [];
|
||||
private sampleCount = 0;
|
||||
|
||||
append(frame: AudioFrame) {
|
||||
if (!Number.isFinite(frame.sampleRate) || frame.sampleRate < 8_000 || frame.sampleRate > 192_000) return;
|
||||
const availableChannels = frame.channels.slice(0, Math.max(1, frame.channelCount)).filter((channel) => channel.length > 0);
|
||||
if (availableChannels.length === 0) return;
|
||||
const frameLength = Math.min(...availableChannels.map((channel) => channel.length));
|
||||
if (frameLength < 1) return;
|
||||
|
||||
const mono = new Float32Array(frameLength);
|
||||
for (let index = 0; index < frameLength; index += 1) {
|
||||
let value = 0;
|
||||
for (const channel of availableChannels) value += channel[index];
|
||||
mono[index] = value / availableChannels.length;
|
||||
}
|
||||
|
||||
if (this.sampleRate === 0) this.sampleRate = frame.sampleRate;
|
||||
const normalized = frame.sampleRate === this.sampleRate ? mono : resampleLinear(mono, frame.sampleRate, this.sampleRate);
|
||||
if (normalized.length === 0) return;
|
||||
this.chunks.push(normalized);
|
||||
this.sampleCount += normalized.length;
|
||||
}
|
||||
|
||||
finish(track: "near" | "far"): RecordedAudioSegment[] {
|
||||
if (this.sampleRate === 0 || this.sampleCount === 0) return [];
|
||||
const durationLimit = Math.floor(this.sampleRate * MAX_SEGMENT_DURATION_MS / 1000);
|
||||
const sizeLimit = Math.floor((MAX_SEGMENT_SIZE - WAV_HEADER_SIZE) / 2);
|
||||
const samplesPerSegment = Math.max(1, Math.min(durationLimit, sizeLimit));
|
||||
const segments: RecordedAudioSegment[] = [];
|
||||
|
||||
for (let offset = 0, sequence = 0; offset < this.sampleCount; offset += samplesPerSegment, sequence += 1) {
|
||||
const length = Math.min(samplesPerSegment, this.sampleCount - offset);
|
||||
const durationMS = Math.floor(length * 1000 / this.sampleRate);
|
||||
if (durationMS < 1) continue;
|
||||
segments.push({
|
||||
track,
|
||||
sequence_no: sequence,
|
||||
codec: "wav_pcm_s16le",
|
||||
content_type: "audio/wav",
|
||||
duration_ms: durationMS,
|
||||
bytes: encodeMonoWAV(this.chunks, offset, length, this.sampleRate),
|
||||
});
|
||||
}
|
||||
|
||||
this.chunks = [];
|
||||
this.sampleCount = 0;
|
||||
this.sampleRate = 0;
|
||||
return segments;
|
||||
}
|
||||
}
|
||||
|
||||
function resampleLinear(input: Float32Array, sourceRate: number, targetRate: number) {
|
||||
const outputLength = Math.max(1, Math.round(input.length * targetRate / sourceRate));
|
||||
const output = new Float32Array(outputLength);
|
||||
const ratio = sourceRate / targetRate;
|
||||
for (let index = 0; index < outputLength; index += 1) {
|
||||
const position = index * ratio;
|
||||
const left = Math.min(input.length - 1, Math.floor(position));
|
||||
const right = Math.min(input.length - 1, left + 1);
|
||||
const fraction = position - left;
|
||||
output[index] = input[left] + (input[right] - input[left]) * fraction;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function encodeMonoWAV(chunks: Float32Array[], startSample: number, sampleCount: number, sampleRate: number) {
|
||||
const dataSize = sampleCount * 2;
|
||||
const bytes = new Uint8Array(WAV_HEADER_SIZE + dataSize);
|
||||
const view = new DataView(bytes.buffer);
|
||||
writeASCII(bytes, 0, "RIFF");
|
||||
view.setUint32(4, bytes.length - 8, true);
|
||||
writeASCII(bytes, 8, "WAVE");
|
||||
writeASCII(bytes, 12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeASCII(bytes, 36, "data");
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
let skipped = 0;
|
||||
let written = 0;
|
||||
for (const chunk of chunks) {
|
||||
if (skipped + chunk.length <= startSample) {
|
||||
skipped += chunk.length;
|
||||
continue;
|
||||
}
|
||||
const chunkStart = Math.max(0, startSample - skipped);
|
||||
const available = Math.min(chunk.length - chunkStart, sampleCount - written);
|
||||
for (let index = 0; index < available; index += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, chunk[chunkStart + index]));
|
||||
view.setInt16(WAV_HEADER_SIZE + (written + index) * 2, sample < 0 ? Math.round(sample * 32768) : Math.round(sample * 32767), true);
|
||||
}
|
||||
written += available;
|
||||
skipped += chunk.length;
|
||||
if (written === sampleCount) break;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function writeASCII(target: Uint8Array, offset: number, value: string) {
|
||||
for (let index = 0; index < value.length; index += 1) target[offset + index] = value.charCodeAt(index);
|
||||
}
|
||||
@@ -68,6 +68,15 @@ input:focus, select:focus { border-color: #d6783f; box-shadow: 0 0 0 3px rgba(21
|
||||
#eventLog li.success span { color: #247252; }
|
||||
#eventLog li.error span { color: #ae3b2f; }
|
||||
|
||||
.transcript-panel { margin-top: 16px; }
|
||||
.transcript-panel .panel-heading { margin-bottom: 14px; }
|
||||
#liveTranscript { display: grid; gap: 10px; margin: 0; padding: 0; max-height: 260px; overflow: auto; list-style: none; }
|
||||
.transcript-empty { color: #829198; font-size: 13px; }
|
||||
.transcript-turn { display: grid; gap: 4px; max-width: 80%; padding: 11px 14px; border-radius: 15px; background: #f1f6f4; }
|
||||
.transcript-turn--agent { justify-self: end; background: #edf3fb; }
|
||||
.transcript-turn strong { color: #315966; font-size: 12px; }
|
||||
.transcript-turn span { color: #263f49; font-size: 14px; line-height: 1.55; }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.status-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.content-grid { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
云端语音测试设备使用说明
|
||||
|
||||
1. 将整个 ZIP 解压到普通文件夹,不要只从压缩包内直接运行。
|
||||
2. 双击“启动云端测试设备.cmd”,不要直接双击 EXE。
|
||||
3. 首次运行时允许 Windows 麦克风访问。
|
||||
4. 点击“检测平台和音频设备”,检测通过后即可开始语音聊天。
|
||||
|
||||
云端后台:https://121.41.46.238
|
||||
测试设备:10000000-0000-0000-0000-000000000511
|
||||
|
||||
网络需要允许 HTTPS 443、WSS 以及火山 RTC 所需的出站网络连接。
|
||||
|
||||
如果检测平台时提示 RTC 网络检测失败,或入房超过 15 秒:
|
||||
- 关闭 VPN、系统代理和网络加速器;
|
||||
- 在 Windows 防火墙/杀毒软件中允许本程序联网;
|
||||
- 优先改用手机热点复测,以排除公司、校园或酒店网络限制;
|
||||
- 需要放行 TCP 443 到火山 RTC Web SDK 4.69.0 使用的 *.rtc.volcvideo.com 和 *.volcvideos.com。
|
||||
|
||||
如果手机热点、关闭VPN/代理后仍然无法入房,请在失败后点击页面底部“导出 RTC 日志”,把 Windows“下载”目录中最新生成的 RTC 日志文件交给开发人员分析。
|
||||
|
||||
安全提示:本测试包包含服务器测试设备凭据,仅限受控测试使用,请勿公开上传或转发。测试完成后请删除其他电脑上的解压目录和 ZIP 文件。
|
||||
Reference in New Issue
Block a user