voice_audio_multi/examples/browser_extension/supertonic_speaker/popup.js
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

414 lines
12 KiB
JavaScript

"use strict";
const fields = {
serverUrl: document.getElementById("serverUrl"),
modelId: document.getElementById("modelId"),
voice: document.getElementById("voice"),
sampleRate: document.getElementById("sampleRate"),
chunkChars: document.getElementById("chunkChars"),
steps: document.getElementById("steps"),
textInput: document.getElementById("textInput"),
};
const buttons = {
selection: document.getElementById("speakSelection"),
page: document.getElementById("speakPage"),
stop: document.getElementById("stop"),
offline: document.getElementById("openOffline"),
};
const statusEl = document.getElementById("status");
const backendStatusEl = document.getElementById("backendStatus");
const metrics = {
chunk: document.getElementById("metricChunk"),
firstDelta: document.getElementById("metricFirstDelta"),
serverTtft: document.getElementById("metricServerTtft"),
deltas: document.getElementById("metricDeltas"),
};
let abortController = null;
let audioContext = null;
let nextAudioTime = 0;
let preloadedText = "";
let playbackTimer = null;
function setStatus(text) {
statusEl.textContent = text;
}
async function refreshBackendStatus() {
const url = fields.serverUrl.value.replace(/\/+$/, "");
backendStatusEl.textContent = "checking";
try {
const response = await fetch(`${url}/health`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const health = await response.json();
backendStatusEl.textContent = health.backend || "unknown";
} catch (error) {
backendStatusEl.textContent = "offline";
}
}
function loadSettings() {
for (const [key, input] of Object.entries(fields)) {
if (key === "textInput") {
continue;
}
const value = localStorage.getItem(key);
if (value !== null) {
input.value = value;
}
input.addEventListener("change", () => localStorage.setItem(key, input.value));
}
fields.serverUrl.addEventListener("change", () => {
refreshBackendStatus();
});
}
function resetMetrics(totalChunks = 0) {
if (playbackTimer !== null) {
clearInterval(playbackTimer);
playbackTimer = null;
}
metrics.chunk.textContent = totalChunks > 0 ? `0/${totalChunks}` : "-";
metrics.firstDelta.textContent = "-";
metrics.serverTtft.textContent = "-";
metrics.deltas.textContent = "-";
}
function formatMs(value) {
if (!Number.isFinite(value)) {
return "-";
}
return `${Math.round(value)} ms`;
}
function formatSeconds(value) {
if (!Number.isFinite(value)) {
return "-";
}
return `${Math.max(0, value).toFixed(1)}s`;
}
function settings() {
return {
serverUrl: fields.serverUrl.value.replace(/\/+$/, ""),
modelId: fields.modelId.value.trim(),
voice: fields.voice.value.trim(),
sampleRate: Number(fields.sampleRate.value),
chunkChars: Number(fields.chunkChars.value),
steps: Number(fields.steps.value),
};
}
async function activeTabText(selectionOnly) {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab || tab.id === undefined) {
throw new Error("No active tab.");
}
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
args: [selectionOnly],
func: (onlySelection) => {
const selected = window.getSelection ? window.getSelection().toString().trim() : "";
if (onlySelection) {
return selected;
}
return selected || document.body.innerText.trim();
},
});
return (result || "").replace(/\s+/g, " ").trim();
}
function directText() {
return (fields.textInput?.value || preloadedText || "").replace(/\s+/g, " ").trim();
}
function splitText(text, maxChars) {
if (!Number.isFinite(maxChars) || maxChars <= 0) {
throw new Error("Chunk chars must be positive.");
}
const sentences = text.match(/[^.!?。!?]+[.!?。!?]?/g) || [text];
const chunks = [];
let current = "";
for (const raw of sentences) {
const sentence = raw.trim();
if (!sentence) {
continue;
}
if (!current) {
current = sentence;
} else if ((current + " " + sentence).length <= maxChars) {
current += " " + sentence;
} else {
chunks.push(current);
current = sentence;
}
while (current.length > maxChars) {
let splitAt = current.lastIndexOf(" ", maxChars);
if (splitAt <= 0) {
splitAt = maxChars;
}
chunks.push(current.slice(0, splitAt).trim());
current = current.slice(splitAt).trim();
}
}
if (current) {
chunks.push(current);
}
return chunks;
}
function decodeBase64Pcm16(base64) {
const binary = atob(base64);
const samples = new Float32Array(binary.length / 2);
for (let i = 0; i < samples.length; ++i) {
const lo = binary.charCodeAt(i * 2);
const hi = binary.charCodeAt(i * 2 + 1);
let value = lo | (hi << 8);
if (value & 0x8000) {
value -= 0x10000;
}
samples[i] = Math.max(-1, Math.min(1, value / 32768));
}
return samples;
}
async function ensureAudioContext(sampleRate) {
if (!audioContext || audioContext.sampleRate !== sampleRate) {
if (audioContext) {
await audioContext.close();
}
audioContext = new AudioContext({ sampleRate });
nextAudioTime = audioContext.currentTime;
}
if (audioContext.state === "suspended") {
await audioContext.resume();
}
}
function playPcm(samples, sampleRate) {
const buffer = audioContext.createBuffer(1, samples.length, sampleRate);
buffer.copyToChannel(samples, 0);
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
const startAt = Math.max(audioContext.currentTime + 0.02, nextAudioTime);
source.start(startAt);
nextAudioTime = startAt + buffer.duration;
return buffer.duration;
}
function playbackRemainingSeconds() {
if (!audioContext) {
return 0;
}
return Math.max(0, nextAudioTime - audioContext.currentTime);
}
function startPlaybackCountdown() {
if (playbackTimer !== null) {
clearInterval(playbackTimer);
}
playbackTimer = setInterval(() => {
const remaining = playbackRemainingSeconds();
metrics.deltas.textContent = formatSeconds(remaining);
if (remaining <= 0.05) {
clearInterval(playbackTimer);
playbackTimer = null;
metrics.deltas.textContent = "done";
setStatus("Playback done.");
} else {
setStatus(`Playing, ${formatSeconds(remaining)} left.`);
}
}, 250);
}
function parseSseLines(buffer, onEvent) {
let offset = 0;
while (true) {
const newline = buffer.indexOf("\n", offset);
if (newline < 0) {
return buffer.slice(offset);
}
const line = buffer.slice(offset, newline).trim();
offset = newline + 1;
if (!line.startsWith("data:")) {
continue;
}
const data = line.slice(5).trim();
if (data && data !== "[DONE]") {
onEvent(JSON.parse(data));
}
}
}
async function speakChunk(text, index, total, opts) {
const startMs = performance.now();
const response = await fetch(`${opts.serverUrl}/v1/audio/speech`, {
method: "POST",
signal: abortController.signal,
headers: {
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
body: JSON.stringify({
model: opts.modelId,
input: text,
voice: opts.voice || undefined,
response_format: "pcm",
stream_format: "sse",
num_inference_steps: opts.steps,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
if (!response.body) {
throw new Error("Streaming response has no body.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let deltas = 0;
let firstDeltaMs = null;
let serverTtftMs = null;
let queuedSeconds = 0;
metrics.chunk.textContent = `${index + 1}/${total}`;
metrics.firstDelta.textContent = "waiting";
metrics.serverTtft.textContent = "waiting";
metrics.deltas.textContent = "0";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
buffer = parseSseLines(buffer, (event) => {
if (event.type === "speech.audio.delta") {
deltas += 1;
if (deltas === 1) {
firstDeltaMs = performance.now() - startMs;
metrics.firstDelta.textContent = formatMs(firstDeltaMs);
}
metrics.deltas.textContent = String(deltas);
queuedSeconds += playPcm(decodeBase64Pcm16(event.audio), opts.sampleRate);
metrics.deltas.textContent = `${formatSeconds(playbackRemainingSeconds())} queued`;
setStatus(`Speaking chunk ${index + 1}/${total}, delta ${deltas}.`);
} else if (event.type === "speech.audio.done") {
serverTtftMs = event.timing?.ttft_ms ?? null;
metrics.serverTtft.textContent = formatMs(serverTtftMs);
} else if (event.type === "error") {
throw new Error(event.error?.message || "Server streaming error.");
}
});
}
return {
deltas,
firstDeltaMs,
serverTtftMs,
queuedSeconds,
};
}
async function speakText(text) {
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
const opts = settings();
if (!opts.modelId) {
throw new Error("Model id is required.");
}
await ensureAudioContext(opts.sampleRate);
if (!text) {
throw new Error("No text to speak.");
}
const chunks = splitText(text, opts.chunkChars);
resetMetrics(chunks.length);
setStatus(`Sending ${chunks.length} text chunks.`);
for (let i = 0; i < chunks.length; ++i) {
if (abortController.signal.aborted) {
break;
}
const stat = await speakChunk(chunks[i], i, chunks.length, opts);
metrics.chunk.textContent = `${i + 1}/${chunks.length} queued`;
metrics.firstDelta.textContent = formatMs(stat.firstDeltaMs);
metrics.serverTtft.textContent = formatMs(stat.serverTtftMs);
metrics.deltas.textContent = `${formatSeconds(playbackRemainingSeconds())} queued`;
}
setStatus(`Requests done. Playing, ${formatSeconds(playbackRemainingSeconds())} left.`);
startPlaybackCountdown();
}
async function speak(selectionOnly) {
const typed = directText();
const text = typed || await activeTabText(selectionOnly);
if (fields.textInput) {
fields.textInput.value = text;
}
await speakText(text);
}
async function run(action) {
buttons.selection.disabled = true;
buttons.page.disabled = true;
try {
await action();
} catch (error) {
if (error.name === "AbortError") {
setStatus("Stopped.");
} else {
setStatus(error.message || String(error));
}
} finally {
abortController = null;
buttons.selection.disabled = false;
buttons.page.disabled = false;
}
}
buttons.selection.addEventListener("click", () => run(() => speak(true)));
buttons.page.addEventListener("click", () => run(() => speak(false)));
buttons.stop.addEventListener("click", async () => {
if (abortController) {
abortController.abort();
}
if (audioContext) {
await audioContext.close();
audioContext = null;
}
nextAudioTime = 0;
setStatus("Stopped.");
});
buttons.offline?.addEventListener("click", () => {
chrome.tabs.create({ url: chrome.runtime.getURL("offline.html") });
});
async function loadContextPayload() {
const params = new URLSearchParams(location.search);
const id = params.get("id");
if (!id) {
return;
}
const payload = (await chrome.storage.session.get(id))[id];
await chrome.storage.session.remove(id);
if (!payload?.text) {
return;
}
preloadedText = payload.text;
if (fields.textInput) {
fields.textInput.value = payload.text;
}
setStatus(`Loaded ${payload.source || "context"} text.`);
if (payload.autoStart) {
await run(() => speakText(payload.text));
}
}
loadSettings();
resetMetrics();
refreshBackendStatus();
loadContextPayload().catch((error) => setStatus(error.message || String(error)));