195 lines
7.3 KiB
JavaScript
195 lines
7.3 KiB
JavaScript
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();
|