Publish voice duplex architecture latency training and evaluation research

This commit is contained in:
wuyang6
2026-11-09 10:56:46 +08:00
commit a2e3ceb4ca
17 changed files with 3371 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
dist
node_modules
*.log
work
.env*
+6
View File
@@ -0,0 +1,6 @@
dist/
node_modules/
.DS_Store
.env*
work/
*.log
+10
View File
@@ -0,0 +1,10 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json build.mjs index.html styles.css app.js model.js favicon.svg robots.txt release.json ./
RUN npm run build
FROM nginx:1.28-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build --chmod=0644 /app/dist/ /usr/share/nginx/html/
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 K1412
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+23
View File
@@ -0,0 +1,23 @@
# 语音双工系统的架构、时延与评测
公开研究网页,比较三段式、两段式、原生语音与混合设计,解释时间片、计算耗时与用户交互时延,并整理双工训练和系统评测方法。
访问 [研究网页](https://voice-duplex.k1412.top/)。
## 本地运行
使用 Node.js 20 或更新版本及 Python 3。运行 `npm run check`、`npm run build`,随后运行 `npm run dev` 并访问终端显示的本地地址。
网页使用本地静态资源。队列实验的纯计算模型位于 `model.js`,对应回归用例位于 `model.test.mjs`。原始论文与官方资料见页面来源章节。来源报告值、工程推断和说明性模型分别标注。
## 内容与日期
资料核对日期为 2026 年 11 月 9 日。评测数据保留原始版本和条件,完整系统评测方法按方案呈现。
## 容器
Dockerfile 构建静态资源并使用 Nginx 在 8080 端口提供服务,健康检查路径为 `/healthz`。
## 许可
原创代码、文字和绘图代码采用 MIT 许可。外部链接中的论文、商标与内容遵循各自权利声明。
+194
View File
@@ -0,0 +1,194 @@
import { queueSteps, architectures, benchmarks } from "./model.js";
const chunkSlider = document.getElementById("chunk-slider");
const computeSlider = document.getElementById("compute-slider");
const queueChart = document.getElementById("queue-chart");
function renderQueue() {
const chunkMs = Number(chunkSlider.value);
const computeMs = Number(computeSlider.value);
const steps = queueSteps(chunkMs, computeMs);
const totalMs = Math.max(steps.at(-1).end, chunkMs * 9);
const percentage = (value) => `${(value / totalMs) * 100}%`;
document.getElementById("chunk-value").textContent = `${chunkMs} ms`;
document.getElementById("compute-value").textContent = `${computeMs} ms`;
const ratio = document.getElementById("rtf-value");
ratio.textContent = `${(computeMs / chunkMs).toFixed(2)}×`;
ratio.classList.toggle("overloaded", computeMs >= chunkMs);
document.getElementById("backlog-value").textContent =
`${steps.at(-1).wait} ms`;
document.getElementById("growth-value").textContent =
`${Math.max(0, computeMs - chunkMs)} ms`;
queueChart.replaceChildren();
const ticks = document.createElement("div");
ticks.className = "queue-ticks";
for (const fraction of [0, 0.25, 0.5, 0.75, 1]) {
const tick = document.createElement("span");
tick.textContent = `${Math.round(totalMs * fraction)}${fraction === 1 ? " ms" : ""}`;
ticks.append(tick);
}
queueChart.append(ticks);
for (const step of steps) {
const row = document.createElement("div");
row.className = "queue-row";
row.setAttribute(
"aria-label",
`第${step.index}块在${step.arrival}毫秒就绪,等待${step.wait}毫秒,在${step.end}毫秒计算完成`,
);
const label = document.createElement("span");
label.className = "queue-label";
label.textContent = `块 ${step.index}`;
const arrival = document.createElement("span");
arrival.className = "queue-arrival";
arrival.style.left = percentage(step.arrival);
const wait = document.createElement("span");
wait.className = "queue-wait";
wait.style.left = percentage(step.arrival);
wait.style.width = percentage(step.wait);
const compute = document.createElement("span");
compute.className = "queue-compute";
compute.style.left = percentage(step.start);
compute.style.width = percentage(computeMs);
row.append(label, wait, compute, arrival);
queueChart.append(row);
}
const explanation = document.getElementById("queue-explanation");
if (computeMs < chunkMs) {
explanation.textContent = `每块留有 ${chunkMs - computeMs} ms 计算余量。此理想条件下等待为零,单块 ${computeMs} ms 的计算延迟仍然存在。`;
} else if (computeMs === chunkMs) {
explanation.textContent =
"理想条件下刚好赶上输入节奏。出现额外计算或调度抖动时,队列就会开始积压。";
} else {
explanation.textContent = `每块增加 ${computeMs - chunkMs} ms 等待,第 8 块已等待 ${steps.at(-1).wait} ms。需要减少计算、增加处理能力或调整处理策略。`;
}
}
function selectArchitecture(key) {
const selected = architectures[key];
const flow = document.getElementById("architecture-flow");
flow.replaceChildren();
selected.nodes.forEach(([title, subtitle, type], index) => {
if (index) {
const arrow = document.createElement("span");
arrow.className = "flow-arrow";
arrow.textContent = "→";
arrow.setAttribute("aria-hidden", "true");
flow.append(arrow);
}
const node = document.createElement("div");
node.className = `flow-node ${type || ""}`;
const strong = document.createElement("strong");
strong.textContent = title;
const small = document.createElement("small");
small.textContent = subtitle;
node.append(strong, small);
flow.append(node);
});
document.getElementById("architecture-detail").innerHTML = selected.detail;
document.querySelectorAll("[data-architecture]").forEach((button) => {
button.setAttribute(
"aria-pressed",
String(button.dataset.architecture === key),
);
});
}
function renderBenchmarks() {
const chart = document.getElementById("benchmark-chart");
for (const model of benchmarks) {
const row = document.createElement("div");
row.className = "benchmark-row";
const label = document.createElement("span");
label.className = "benchmark-label";
label.textContent = model.name;
const tracks = document.createElement("div");
tracks.className = "benchmark-tracks";
for (const [key, className] of [
["first", "first"],
["answer", "answer"],
]) {
const bar = document.createElement("div");
bar.className = `benchmark-bar ${className}`;
bar.style.width = `${(model[key] / 12) * 100}%`;
const value = document.createElement("span");
value.className = "benchmark-value";
value.textContent = `${model[key].toFixed(2)} s`;
bar.append(value);
tracks.append(bar);
}
row.append(label, tracks);
chart.append(row);
}
const axis = document.createElement("div");
axis.className = "benchmark-axis";
for (const value of ["0", "3", "6", "9", "12 s"]) {
const tick = document.createElement("span");
tick.textContent = value;
axis.append(tick);
}
chart.append(axis);
}
chunkSlider.addEventListener("input", renderQueue);
computeSlider.addEventListener("input", renderQueue);
document.querySelectorAll("[data-preset]").forEach((button) => {
button.addEventListener("click", () => {
const [chunk, compute] = button.dataset.preset.split(",");
chunkSlider.value = chunk;
computeSlider.value = compute;
renderQueue();
});
});
document.querySelectorAll("[data-architecture]").forEach((button) => {
button.addEventListener("click", () =>
selectArchitecture(button.dataset.architecture),
);
});
const themeToggle = document.getElementById("theme-toggle");
try {
const preference = localStorage.getItem("voice-duplex-theme");
if (preference === "dark" || preference === "light")
document.documentElement.dataset.theme = preference;
} catch {
/* The page remains usable when storage is unavailable. */
}
themeToggle.addEventListener("click", () => {
const theme =
document.documentElement.dataset.theme === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = theme;
themeToggle.setAttribute(
"aria-label",
theme === "dark" ? "切换浅色主题" : "切换深色主题",
);
try {
localStorage.setItem("voice-duplex-theme", theme);
} catch {
/* Theme changes still apply for this visit. */
}
});
const navigationLinks = [...document.querySelectorAll(".contents nav a")];
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (!visible.length) return;
const currentId = visible[0].target.id;
navigationLinks.forEach((link) => {
const active = link.hash === `#${currentId}`;
link.classList.toggle("active", active);
if (active) link.setAttribute("aria-current", "location");
else link.removeAttribute("aria-current");
});
},
{ rootMargin: "-110px 0px -55% 0px", threshold: 0 },
);
document
.querySelectorAll(".article-section")
.forEach((section) => observer.observe(section));
selectArchitecture("cascade");
renderQueue();
renderBenchmarks();
+17
View File
@@ -0,0 +1,17 @@
import { mkdirSync, copyFileSync } from "node:fs";
const files = [
"index.html",
"styles.css",
"app.js",
"model.js",
"favicon.svg",
"robots.txt",
"release.json",
];
mkdirSync(new URL("./dist/", import.meta.url), { recursive: true });
for (const file of files)
copyFileSync(
new URL(file, import.meta.url),
new URL(`dist/${file}`, import.meta.url),
);
console.log(`Built ${files.length} public assets.`);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#287557"/><path d="M17 19v27m0-11 16-16m-13 13 15 14" stroke="#f7f6f2" stroke-width="5" fill="none" stroke-linecap="round"/><circle cx="45" cy="44" r="4" fill="#f7f6f2"/></svg>

After

Width:  |  Height:  |  Size: 280 B

+1319
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
export function queueSteps(chunkMs, computeMs, count = 8) {
if (
!Number.isFinite(chunkMs) ||
!Number.isFinite(computeMs) ||
chunkMs <= 0 ||
computeMs <= 0
) {
throw new RangeError("Time values must be finite and positive.");
}
let previousEnd = 0;
return Array.from({ length: count }, (_, index) => {
const arrival = (index + 1) * chunkMs;
const start = Math.max(arrival, previousEnd);
const end = start + computeMs;
previousEnd = end;
return { index: index + 1, arrival, start, end, wait: start - arrival };
});
}
export const architectures = {
cascade: {
nodes: [
["用户语音", "声音输入", "input"],
["ASR", "识别成文本"],
["文本 LLM", "生成回答"],
["TTS", "合成声音"],
["系统语音", "播放输出", "output"],
],
detail:
"<p><strong>两个独立的文本接口。</strong>ASR 把用户语音交给文本模型,TTS 再把回答读出。模块可以独立替换。仅传文字时,输入声学信息会在接口处被压缩。</p><p>持续双工需要在这些模块之间保持新输入、话轮状态、取消与播放进度的一致。</p>",
},
two: {
nodes: [
["用户语音", "声音输入", "input"],
["语音理解 LLM", "直接读取音频特征"],
["回答文本", "内容接口"],
["TTS", "合成声音"],
["系统语音", "播放输出", "output"],
],
detail:
"<p><strong>输入端直接理解声音。</strong>模型接收音频特征并生成回答文字,之后由独立 TTS 合成。输入端可以保留语气等线索,输出端仍通过文本接口组织。</p><p>本页以 Ultravox 核心加外接 TTS 作为这一工作定义的例子。</p>",
},
native: {
nodes: [
["用户语音", "声音输入", "input"],
["语音表示", "编码或特征"],
["联合交互模型", "声音与内容建模"],
["语音解码", "还原波形"],
["系统语音", "播放输出", "output"],
],
detail:
"<p><strong>语音直接参与内容与声学生成。</strong>编码器、音频 token、文字辅助流和波形解码器可以共同存在,具体组织依模型而异。</p><p>持续听说依赖输入更新方式、因果训练与实时服务。原生语音接口与全双工行为分别核验。</p>",
},
};
export const benchmarks = [
{ name: "级联管线", first: 8.78, answer: 10.12 },
{ name: "Ultravox 0.7", first: 3.88, answer: 8.4 },
{ name: "GPT-Realtime 1.5", first: 6.36, answer: 6.89 },
{ name: "Gemini Live 3.1", first: 3.95, answer: 4.25 },
];
+30
View File
@@ -0,0 +1,30 @@
import test from "node:test";
import assert from "node:assert/strict";
import { queueSteps, benchmarks } from "./model.js";
test("faster processing leaves no waiting queue", () => {
const steps = queueSteps(160, 80);
assert.equal(steps[0].arrival, 160);
assert.equal(steps[0].end, 240);
assert.ok(steps.every((step) => step.wait === 0));
});
test("equal processing and media intervals leave zero ideal headroom", () => {
assert.ok(queueSteps(160, 160).every((step) => step.wait === 0));
});
test("overload adds sixty milliseconds per subsequent input block", () => {
const steps = queueSteps(160, 220);
assert.equal(steps[0].wait, 0);
assert.equal(steps[7].wait, 420);
assert.equal(steps[7].end, 1920);
});
test("invalid media times fail explicitly", () => {
assert.throws(() => queueSteps(0, 80), RangeError);
assert.throws(() => queueSteps(160, Number.NaN), RangeError);
});
test("benchmark ordering and source values are preserved", () => {
assert.deepEqual(
benchmarks.map((model) => model.answer),
[10.12, 8.4, 6.89, 4.25],
);
assert.ok(benchmarks.every((model) => model.first <= model.answer));
});
+12
View File
@@ -0,0 +1,12 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
charset utf-8;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
location = /healthz { access_log off; default_type text/plain; return 200 "ok\n"; }
location / { try_files $uri $uri/ =404; }
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "voice-duplex-research",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"check": "node --check app.js && node --check model.js && node --test model.test.mjs && node validate.mjs",
"build": "node build.mjs",
"dev": "python3 -m http.server 4173 --bind 127.0.0.1 --directory dist"
},
"engines": {
"node": ">=20"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"release": "voice-duplex-20261109-v1",
"researchCutoff": "2026-11-09",
"canonicalUrl": "https://voice-duplex.k1412.top/",
"evidence": "published primary sources and clearly labeled explanatory simulations"
}
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
+1625
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
import { readFileSync } from "node:fs";
import assert from "node:assert/strict";
const html = readFileSync(new URL("./index.html", import.meta.url), "utf8");
const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]);
assert.equal(new Set(ids).size, ids.length, "HTML IDs must be unique");
for (const match of html.matchAll(/href="#([^"]+)"/g)) {
assert.ok(ids.includes(match[1]), `Missing anchor ${match[1]}`);
}
for (const pattern of [
/\/Users\//,
/git\.n\.xiaomi/,
/100\.94\./,
/human-authorization/,
/Bearer\s+[A-Za-z0-9]/,
]) {
assert.ok(!pattern.test(html), `Private content matched ${pattern}`);
}
assert.ok(html.includes("说明性模型"));
assert.ok(html.includes("来源报告值"));
assert.ok(html.includes("2026 年 11 月 9 日"));
console.log(
`Validated ${ids.length} unique anchors, public-content boundaries and evidence labels.`,
);