feat: establish v0.70 platform milestone
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-08-29 21:56:51 +08:00
parent d97bbf7cf5
commit e652516147
133 changed files with 12907 additions and 801 deletions
+40 -1
View File
@@ -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 }> }>;
};
}
+409 -16
View File
@@ -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();
+128
View File
@@ -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);
}
+9
View File
@@ -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; }