Files
skills/orchestrator/templates/page-speech.js
T
Kevin Veen-BirkenbachandClaude Opus 5.5 c035373518 feat(orchestrator)!: S0 English everywhere, Sécurité, NATO hashes, ICAO digits
Requirement 001 phase 6, S0:

- Speech: the security class shows as "Sécurité" and is spoken as in
  maritime radio, "say-cure-ee-tay" three times. A hex run of 7 or more
  characters with a letter and a digit (commit, session, message ids) is
  spelled in the NATO alphabet; every other digit is spoken on its own
  in ICAO pronunciation (42305: four two tree zero fife). The spelling
  runs inside clip() before the length cut, so a phrase still fits
  SPEECH_LIMIT; the displayed text keeps its digits and hashes.
- English: every orchestrator label, tooltip, message, error, script
  output, document and test string is English (Regeln -> Firewall,
  Hierarchie -> Hierarchy, Steuerung -> Controls, Ausführen -> Run, ...);
  the page declares lang="en" and formats dates as en-GB. Task filter
  ids are English. German stays only in the skill trigger phrases that
  quote the operator, and as identifiers: the view keys (view=) and the
  leitstand id.
- tests/test_english.py scans every tracked text file for umlauts and
  German words, with an allowlist of the quoted trigger phrases.

BREAKING CHANGE: the Stream search tokens art: and seit: are now kind:
and since:; an old art: or seit: token counts as a plain word.

Evidence: make test 657 OK, ruff and markdownlint clean
(/tmp/s0-make-test-1.log); e2e 17 OK, none skipped (/tmp/s0-e2e-1.log);
MEDUSA 86 files, 24 findings on reworded strings accepted after an
independent skeptic (21 path:line, 0 rejected, no behaviour change in
signing, approving, rules, denying, offices, events), rescan 0 open.

🎯 Fix: 93 %
💥 Break: 4 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 89
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:26:51 +00:00

196 lines
6.4 KiB
JavaScript

function loudness(kind) {
return Voice.loudness(kind, settings);
}
function speechFailed(lines) {
if (Date.now() - speech.warned > SPEECH_QUIET) {
speech.warned = Date.now();
toast("Reading radio aloud failed", lines, false);
}
}
function say(item) {
speech.pending = Voice.enqueue(speech.pending, item, SPEECH_QUEUE);
pump();
}
async function pump() {
if (speech.busy || !speech.pending.length) {
return;
}
const sound = context();
if (!sound) {
speech.pending = [];
return;
}
const [item, ...rest] = speech.pending;
speech.pending = rest;
speech.busy = true;
const next = () => {
speech.busy = false;
pump();
};
try {
const response = await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: item.text, voice: item.voice}), cache: "no-store"});
if (!response.ok) {
throw new Error(`${response.status} ${(await response.text()).trim()}`);
}
const buffer = await sound.decodeAudioData(await response.arrayBuffer());
const source = sound.createBufferSource();
const gain = sound.createGain();
gain.gain.value = loudness(item.tone);
source.buffer = buffer;
source.connect(gain).connect(sound.destination);
source.addEventListener("ended", () => {
Object.assign(speech, {current: null, source: null});
drawSpeaker();
next();
}, {once: true});
duck(buffer.duration);
Object.assign(speech, {current: item, source});
drawSpeaker();
source.start();
} catch (error) {
speechFailed([String(error.message || error)]);
next();
}
}
function cancelSpeech() {
speech.pending = [];
if (speech.source) {
speech.source.stop();
}
}
function speakRows(rows) {
if (!settings.speech.on || !state.speech.tts) {
return;
}
for (const row of rows.filter((entry) => entry.ts >= state.now - SPEECH_FRESH && Voice.speakable(entry, settings.speech))) {
const line = row.text.split("\n").map((part) => part.trim()).find(Boolean) || "";
say({text: row.speech, voice: row.voice, tone: Voice.toneOf(row, HUMAN), cls: row.class, who: row.source, line: short(line)});
}
}
function micGap() {
return !window.isSecureContext || !navigator.mediaDevices
? "The microphone needs a secure context: open the hub via 127.0.0.1 or localhost"
: !window.MediaRecorder ? "This browser cannot record" : !state.speech.stt ? `Voice input is off: ${state.speech.stt_off}` : "";
}
function micButton(field) {
const button = h("button", {type: "button", class: "btn small ghost icon-only mic", "aria-pressed": "false", ...named("Voice input", "Voice input: hold and speak; the text lands in the field and is not sent")}, icon("bi-mic"));
const reason = micGap();
if (reason) {
button.disabled = true;
button.title = `Voice input: ${reason}`;
return button;
}
const press = (event) => {
event.preventDefault();
record(field, button);
};
button.addEventListener("pointerdown", press);
for (const name of ["pointerup", "pointerleave", "pointercancel"]) {
button.addEventListener(name, release);
}
button.addEventListener("keydown", (event) => {
if ((event.key === " " || event.key === "Enter") && !event.repeat) {
press(event);
}
});
button.addEventListener("keyup", (event) => {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
release();
}
});
return button;
}
async function record(field, button) {
if (mic.held || mic.recorder || mic.busy) {
return;
}
mic.held = true;
let media = null;
try {
media = await navigator.mediaDevices.getUserMedia({audio: {channelCount: 1, echoCancellation: true, noiseSuppression: true}});
} catch (error) {
mic.held = false;
toast("Microphone not available", [String(error.message || error)], false);
return;
}
if (!mic.held) {
media.getTracks().forEach((track) => track.stop());
return;
}
const recorder = new MediaRecorder(media);
const chunks = [];
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size) {
chunks.push(event.data);
}
});
recorder.addEventListener("stop", () => {
media.getTracks().forEach((track) => track.stop());
mic.recorder = null;
button.setAttribute("aria-pressed", "false");
transcribe(new Blob(chunks, {type: recorder.mimeType}), field, button);
}, {once: true});
mic.recorder = recorder;
recorder.start();
button.setAttribute("aria-pressed", "true");
mic.timer = window.setTimeout(release, state.speech.seconds * 1000);
}
function release() {
mic.held = false;
window.clearTimeout(mic.timer);
if (mic.recorder && mic.recorder.state === "recording") {
mic.recorder.stop();
}
}
async function wavOf(blob, sound) {
const decoded = await sound.decodeAudioData(await blob.arrayBuffer());
const rate = state.speech.rate;
const length = Voice.frames(decoded.duration, rate, state.speech.seconds);
if (length < rate / 4) {
throw new Error("too short: speak for at least a quarter second");
}
const offline = new OfflineAudioContext(1, length, rate);
const source = offline.createBufferSource();
source.buffer = decoded;
source.connect(offline.destination);
source.start();
const rendered = await offline.startRendering();
return Voice.wav(rendered.getChannelData(0), rate);
}
async function transcribe(blob, field, button) {
const sound = context();
if (!sound || !blob.size) {
return;
}
mic.busy = true;
button.disabled = true;
try {
const result = await send("/api/stt", await wavOf(blob, sound), "audio/wav");
if (!result.ok || !result.data) {
throw new Error(`${result.status} ${result.text}`.trim());
}
if (result.data.text) {
field.value = [field.value.trim(), result.data.text].filter(Boolean).join(" ");
field.dispatchEvent(new Event("input", {bubbles: true}));
}
field.focus();
} catch (error) {
toast("Voice input failed", [String(error.message || error)], false);
} finally {
mic.busy = false;
button.disabled = false;
}
}