import VERTC, { IRTCEngine, LocalAudioPropertiesInfo, MediaType, RemoteAudioPropertiesInfo, RoomProfileType, } from "@volcengine/rtc"; import "./style.css"; const EngineEventsTypes = VERTC.events; type Phase = "idle" | "checking" | "ready" | "joining" | "waiting" | "active" | "stopping" | "error"; const state: { phase: Phase; engine?: IRTCEngine; conversation?: CreatedConversation; credentialExpiresAt?: string; heartbeatSequence: number; heartbeatTimer?: number; statusTimer?: number; startedAt?: number; durationTimer?: number; agentJoined: boolean; agentSpeaking: boolean; microphoneVolume: number; agentVolume: number; } = { phase: "idle", heartbeatSequence: 0, agentJoined: false, agentSpeaking: false, microphoneVolume: 0, agentVolume: 0, }; const app = document.querySelector("#app")!; app.innerHTML = `

WINDOWS · REAL RTC DEVICE

语音终端模拟器

使用真实麦克风和扬声器,验证“终端入房—豆包进房—双向语音—打断—结束”的完整链路。

尚未连接
本地平台未检测localhost
RTC 房间未加入等待开始
豆包智能体未进房等待 StartVoiceChat
通话时长00:00处理方式:—

LOCAL SETUP

本地设备配置

密钥仅驻留内存

RTC AppKey、火山 AK/SK 和豆包 Token 应配置在本地后端,本软件不会读取或保存它们。

LIVE CONVERSATION

真实双向语音

待机
准备开始
我的麦克风0
豆包语音0
通话编号
—
RTC 房间
—
终端用户
—
智能体用户
—

EVENT STREAM

实时事件

  1. 模拟器已启动,等待检测本地环境。
`; const $ = (id: string) => document.getElementById(id) as T; const controls = { apiURL: $("apiURL"), deviceID: $("deviceID"), deviceSecret: $("deviceSecret"), microphone: $("microphone"), speaker: $("speaker"), check: $("checkButton"), start: $("startButton"), stop: $("stopButton"), }; function setText(id: string, value: string) { $(id).textContent = value; } function log(message: string, level: "info" | "success" | "error" = "info") { const item = document.createElement("li"); item.className = level; const time = document.createElement("time"); time.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false }); const content = document.createElement("span"); content.textContent = message; item.append(time, content); $("eventLog").prepend(item); } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function setPhase(phase: Phase, detail?: string) { state.phase = phase; const labels: Record = { idle: "待机", checking: "检测中", ready: "已就绪", joining: "正在入房", waiting: "等待豆包", active: "通话中", stopping: "结束中", error: "异常", }; setText("phaseBadge", labels[phase]); setText("overallStatus", detail || labels[phase]); $("overallStatus").className = `overall phase-${phase}`; $("conversationOrb").className = `conversation-orb phase-${phase}${state.agentSpeaking ? " speaking" : ""}`; setText("orbLabel", detail || labels[phase]); controls.start.disabled = phase !== "ready"; controls.stop.disabled = !["joining", "waiting", "active", "error"].includes(phase) || !state.conversation; controls.check.disabled = ["joining", "waiting", "active", "stopping"].includes(phase); } async function configureAndProbe() { setPhase("checking", "正在检测"); try { await window.deviceSimulator.configure({ apiURL: controls.apiURL.value, deviceID: controls.deviceID.value, deviceSecret: controls.deviceSecret.value, }); const supported = await VERTC.isSupported(); if (!supported) throw new Error("当前 Electron 环境不支持火山 Web RTC SDK"); const permission = await VERTC.enableDevices({ audio: true, video: false }); if (!permission.audio) throw permission.audioExceptionError || new Error("未获得麦克风权限"); await refreshAudioDevices(); await window.deviceSimulator.probe(); setText("apiStatus", "连接正常"); setText("apiDetail", controls.apiURL.value); setPhase("ready", "可以开始通话"); log(`环境检测通过,RTC Web SDK ${VERTC.getSdkVersion()}。`, "success"); } catch (error) { setText("apiStatus", "检测失败"); setText("apiDetail", errorMessage(error)); setPhase("error", "检测失败"); controls.start.disabled = true; log(`环境检测失败:${errorMessage(error)}`, "error"); } } async function refreshAudioDevices() { const [microphones, speakers] = await Promise.all([ VERTC.enumerateAudioCaptureDevices(), VERTC.enumerateAudioPlaybackDevices(), ]); fillDeviceOptions(controls.microphone, microphones, "默认麦克风"); fillDeviceOptions(controls.speaker, speakers, "默认扬声器"); } function fillDeviceOptions(select: HTMLSelectElement, devices: MediaDeviceInfo[], fallback: string) { const previous = select.value; select.replaceChildren(new Option(fallback, "")); devices.forEach((device, index) => select.add(new Option(device.label || `${fallback} ${index + 1}`, device.deviceId))); if ([...select.options].some((option) => option.value === previous)) select.value = previous; } function registerRTCEvents(engine: IRTCEngine) { engine.on(EngineEventsTypes.onUserJoined, (event) => { const userID = event.userInfo.userId; log(`远端用户已进房:${userID}`, "success"); if (userID === state.conversation?.rtc.agent_user_id) { state.agentJoined = true; setText("agentStatus", "已进房"); setText("agentDetail", userID); } }); engine.on(EngineEventsTypes.onUserLeave, (event) => { const userID = event.userInfo.userId; log(`远端用户已离房:${userID}`); if (userID === state.conversation?.rtc.agent_user_id) { state.agentJoined = false; setText("agentStatus", "已离房"); } }); engine.on(EngineEventsTypes.onUserPublishStream, async (event) => { if ((event.mediaType & MediaType.AUDIO) === 0) return; log(`检测到远端音频流:${event.userId}`, "success"); try { await engine.subscribeStream(event.userId, MediaType.AUDIO); await engine.play(event.userId, MediaType.AUDIO); if (event.userId === state.conversation?.rtc.agent_user_id) { setText("agentStatus", "语音已连接"); setText("agentDetail", "正在接收豆包音频"); } } catch (error) { log(`播放远端音频失败:${errorMessage(error)}`, "error"); } }); engine.on(EngineEventsTypes.onLocalAudioPropertiesReport, (reports: LocalAudioPropertiesInfo[]) => { const volume = Math.max(0, ...reports.map((item) => item.audioPropertiesInfo.linearVolume || 0)); updateVolume("mic", volume); }); engine.on(EngineEventsTypes.onRemoteAudioPropertiesReport, (reports: RemoteAudioPropertiesInfo[]) => { const agentID = state.conversation?.rtc.agent_user_id; const volume = Math.max(0, ...reports.filter((item) => !agentID || item.streamKey.userId === agentID).map((item) => item.audioPropertiesInfo.linearVolume || 0)); state.agentSpeaking = volume > 5; updateVolume("agent", volume); setPhase(state.phase, state.phase === "active" ? (state.agentSpeaking ? "豆包正在说话" : "请开始说话") : undefined); }); engine.on(EngineEventsTypes.onConnectionStateChanged, (event) => { log(`RTC 连接状态:${String(event.state)}`); }); engine.on(EngineEventsTypes.onError, (event) => { log(`RTC 错误:${event.errorCode}`, "error"); }); engine.on(EngineEventsTypes.onTokenWillExpire, () => { log("RTC Token 即将过期,等待心跳续签。", "info"); }); } function updateVolume(kind: "mic" | "agent", raw: number) { const value = Math.max(0, Math.min(100, Math.round(raw))); if (kind === "mic") state.microphoneVolume = value; else state.agentVolume = value; $(`${kind}Meter`).style.width = `${value}%`; setText(`${kind}Value`, String(value)); } async function startConversation() { setPhase("joining", "正在申请房间"); controls.start.disabled = true; try { const created = await window.deviceSimulator.createConversation(); state.conversation = created; state.credentialExpiresAt = created.rtc.expires_at; state.heartbeatSequence = 0; state.agentJoined = false; renderSession(created); log(`平台已分配 RTC 房间 ${created.rtc.room_id}。`, "success"); const engine = VERTC.createEngine(created.rtc.app_id); state.engine = engine; registerRTCEvents(engine); engine.enableAudioPropertiesReport({ interval: 300 }); 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, }); setText("rtcStatus", "已加入"); setText("rtcDetail", created.rtc.room_id); log("终端已加入 RTC 房间并发布麦克风。", "success"); await window.deviceSimulator.activateConversation({ conversationID: created.conversation_id, roomID: created.rtc.room_id, userID: created.rtc.user_id, }); setPhase("waiting", "正在拉入豆包"); log("平台已提交 StartVoiceChat,等待豆包智能体激活。", "success"); await waitUntilActive(created.conversation_id); state.startedAt = Date.now(); setPhase("active", "请开始说话"); startTimers(created.session.heartbeat_interval_sec || 15); log("豆包语音任务已激活,可以开始双向聊天。", "success"); } catch (error) { setPhase("error", "启动失败"); log(`启动通话失败:${errorMessage(error)}`, "error"); await releaseRTC(); } } function renderSession(created: CreatedConversation) { setText("conversationID", created.conversation_id); setText("roomID", created.rtc.room_id); setText("userID", created.rtc.user_id); setText("agentUserID", created.rtc.agent_user_id); setText("modeDetail", `处理方式:${created.session.engine_mode}`); setText("agentStatus", "等待进房"); setText("agentDetail", created.rtc.agent_user_id); } async function waitUntilActive(conversationID: string) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const result = await window.deviceSimulator.getConversation(conversationID); if (result.action === "continue" && result.status === "ACTIVE") return; if (result.action === "leave_room") throw new Error(result.failure_code || `智能体启动失败:${result.status}`); await new Promise((resolve) => window.setTimeout(resolve, 600)); } throw new Error("等待豆包进房超时(30秒)"); } function startTimers(intervalSeconds: number) { stopTimers(); state.durationTimer = window.setInterval(() => { if (!state.startedAt) return; const seconds = Math.floor((Date.now() - state.startedAt) / 1000); setText("duration", `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`); }, 1000); state.heartbeatTimer = window.setInterval(() => void sendHeartbeat(), Math.max(5, intervalSeconds) * 1000); void sendHeartbeat(); } async function sendHeartbeat() { if (!state.conversation || !state.credentialExpiresAt || state.phase !== "active") return; try { state.heartbeatSequence += 1; const result = await window.deviceSimulator.heartbeat({ conversationID: state.conversation.conversation_id, sequenceNo: state.heartbeatSequence, credentialExpiresAt: state.credentialExpiresAt, }); for (const action of result.actions || []) { if (action.type === "renew_rtc_credential" && action.rtc?.token) { await state.engine?.updateToken(action.rtc.token); state.credentialExpiresAt = action.rtc.expires_at; log("RTC Token 已通过设备心跳完成续签。", "success"); } } } catch (error) { log(`设备心跳失败:${errorMessage(error)}`, "error"); } } async function stopConversation(reason = "windows_user_hangup") { if (!state.conversation) return; setPhase("stopping", "正在结束通话"); stopTimers(); const conversation = state.conversation; try { await releaseRTC(); await window.deviceSimulator.stopConversation({ conversationID: conversation.conversation_id, reason, lastHeartbeatSequence: state.heartbeatSequence, }); log("RTC 已离房,平台已提交 StopVoiceChat。", "success"); resetSession(); setPhase("ready", "可以再次通话"); } catch (error) { log(`结束通话失败:${errorMessage(error)}`, "error"); resetSession(); setPhase("ready", "已释放本地音频"); } } async function releaseRTC() { const engine = state.engine; state.engine = undefined; if (!engine) return; try { await engine.unpublishStream(MediaType.AUDIO); } catch { /* no-op */ } try { await engine.leaveRoom(); } catch { /* no-op */ } try { await engine.stopAudioCapture(); } catch { /* no-op */ } try { VERTC.destroyEngine(engine); } catch { /* no-op */ } } function stopTimers() { if (state.heartbeatTimer) window.clearInterval(state.heartbeatTimer); if (state.statusTimer) window.clearInterval(state.statusTimer); if (state.durationTimer) window.clearInterval(state.durationTimer); state.heartbeatTimer = undefined; state.statusTimer = undefined; state.durationTimer = undefined; } function resetSession() { state.conversation = undefined; state.credentialExpiresAt = undefined; state.startedAt = undefined; state.agentJoined = false; state.agentSpeaking = false; setText("rtcStatus", "未加入"); setText("rtcDetail", "等待开始"); setText("agentStatus", "未进房"); setText("agentDetail", "等待 StartVoiceChat"); setText("duration", "00:00"); updateVolume("mic", 0); updateVolume("agent", 0); } controls.check.addEventListener("click", () => void configureAndProbe()); controls.start.addEventListener("click", () => void startConversation()); controls.stop.addEventListener("click", () => void stopConversation()); $("clearLog").addEventListener("click", () => $("eventLog").replaceChildren()); window.addEventListener("beforeunload", () => { stopTimers(); void releaseRTC(); }); setPhase("idle", "尚未连接");