Publish voice duplex architecture latency training and evaluation research
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
dist
|
||||
node_modules
|
||||
*.log
|
||||
work
|
||||
.env*
|
||||
@@ -0,0 +1,6 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.DS_Store
|
||||
.env*
|
||||
work/
|
||||
*.log
|
||||
+10
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 许可。外部链接中的论文、商标与内容遵循各自权利声明。
|
||||
@@ -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();
|
||||
@@ -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.`);
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 },
|
||||
];
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
+1625
File diff suppressed because it is too large
Load Diff
@@ -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.`,
|
||||
);
|
||||
Reference in New Issue
Block a user