Files
story/device-simulator-windows/src/main.ts
T

414 lines
18 KiB
TypeScript

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<HTMLElement>("#app")!;
app.innerHTML = `
<section class="shell">
<header class="hero">
<div>
<p class="eyebrow">WINDOWS · REAL RTC DEVICE</p>
<h1>语音终端模拟器</h1>
<p class="subtitle">使用真实麦克风和扬声器,验证“终端入房—豆包进房—双向语音—打断—结束”的完整链路。</p>
</div>
<div class="overall" id="overallStatus"><span class="pulse"></span><strong>尚未连接</strong></div>
</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>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>
</section>
<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>
<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>
<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>
</article>
<article class="panel call-panel">
<div class="panel-heading"><div><p class="eyebrow">LIVE CONVERSATION</p><h2>真实双向语音</h2></div><span class="phase-badge" id="phaseBadge">待机</span></div>
<div class="conversation-orb" id="conversationOrb"><div class="orb-core"><span id="orbLabel">准备开始</span></div></div>
<div class="meters">
<div><div class="meter-label"><span>我的麦克风</span><strong id="micValue">0</strong></div><div class="meter"><i id="micMeter"></i></div></div>
<div><div class="meter-label"><span>豆包语音</span><strong id="agentValue">0</strong></div><div class="meter agent"><i id="agentMeter"></i></div></div>
</div>
<div class="call-actions">
<button class="primary" id="startButton" disabled>开始语音聊天</button>
<button class="danger" id="stopButton" disabled>结束通话</button>
</div>
<dl class="session-info">
<div><dt>通话编号</dt><dd id="conversationID">—</dd></div>
<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>
</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>
<ol id="eventLog" aria-live="polite"><li><time>系统</time><span>模拟器已启动,等待检测本地环境。</span></li></ol>
</section>
</section>
`;
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const controls = {
apiURL: $<HTMLInputElement>("apiURL"),
deviceID: $<HTMLInputElement>("deviceID"),
deviceSecret: $<HTMLInputElement>("deviceSecret"),
microphone: $<HTMLSelectElement>("microphone"),
speaker: $<HTMLSelectElement>("speaker"),
check: $<HTMLButtonElement>("checkButton"),
start: $<HTMLButtonElement>("startButton"),
stop: $<HTMLButtonElement>("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<Phase, string> = {
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", "尚未连接");