feat(orchestrator): operator composer with signed text and voice
Stream -> Radio gets one line per station with state, play and stop, autoplay and CSP refusals as row state and toast, and the speaking agent pulsing there and in the Orbit and Chord views. The composer sends on all seven channels; push-to-talk records with MediaRecorder, whisper.cpp transcribes off the request thread under per-minute and per-hour caps, and the recording travels only when "Aufnahme anhängen" is ticked. Every operator message, the mayday clear, the day and night board actions and a settings save that adds a stream origin take the passkey; the hub verifies each signature once against the pinned keys before it relays or acts. The container gets TMPDIR=/scratch so that verification no longer fails on the read-only root. A denial now signs its recipient: the inbox and the signing step resolve it through one function, a claim set naming several agents leaves a direct denial without recipient, and a recipient that changed after the inbox showed it is refused with 409. operator_message returns only the signed reason for a denial, never the agent's own words the radio row quotes, and the permission hook hashes the signed recipient too. 🎯 Fix: 95 % 💥 Break: 3 % 💸 Cost: 99 % ⏱️ Time: 68 % 🛡️ Security: 100 % 🏆 Score: 100 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dbd9ec3311
commit
089f8dbb9f
49 files changed
+1971
-241
No files matched your search
@@ -405,6 +405,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
{% include "style-hierarchy.css" %}
|
||||
|
||||
{% include "style-inbox.css" %}
|
||||
|
||||
{% include "style-ui.css" %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -518,6 +520,10 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "page-radio.js" %}
|
||||
|
||||
{% include "page-radioview.js" %}
|
||||
|
||||
{% include "page-composer.js" %}
|
||||
|
||||
{% include "page-settings.js" %}
|
||||
|
||||
{% include "page-speech.js" %}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
const Nav = (() => {
|
||||
"use strict";
|
||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
|
||||
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"]]};
|
||||
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]};
|
||||
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
|
||||
const OWN = {agents: [["hierarchy", "Hierarchie"]]};
|
||||
const ICONS = {
|
||||
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", agents: "bi-people", artifacts: "bi-git",
|
||||
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", radio: "bi-boombox", agents: "bi-people", artifacts: "bi-git",
|
||||
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2",
|
||||
};
|
||||
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
function history(key) {
|
||||
const known = (people()[key] || {names: [nameOf(key)]}).names;
|
||||
const radio = state.radio.filter((row) => row.source === key || row.targets.includes(key)).map((row) => ({ts: row.ts, mine: row.from === HUMAN, who: row.from, text: row.text}));
|
||||
const sent = state.messages.filter((message) => known.includes(message.to) || message.to === "*").map((message) => ({ts: message.created_ts, mine: true, who: HUMAN, text: message.upload ? `${message.text}\n📎 ${message.upload}` : message.text, id: message.id}));
|
||||
const sent = state.messages.filter((message) => known.includes(message.to) || message.to === "*").map((message) => ({ts: message.created_ts, mine: true, who: HUMAN, text: message.upload ? `${message.text}\n📎 ${message.upload}` : message.text, id: message.id, voice: message.voice}));
|
||||
const hubIds = new Set(sent.map((entry) => entry.id));
|
||||
const relayed = radio.filter((entry) => !(entry.who === HUMAN && [...hubIds].some((id) => entry.text.includes(`[kvb-orchestrator msg ${id}]`))));
|
||||
return [...relayed, ...sent].sort((a, b) => a.ts - b.ts).slice(-80);
|
||||
@@ -27,7 +27,7 @@
|
||||
h("span", {class: "muted"}, `${dash(claim.runtime)} · seit ${dash(claim.since)}`)))) : h("p", {class: "muted"}, "Keine Claims."));
|
||||
const rows = history(ui.agent);
|
||||
drawerDom.history.replaceChildren(rows.length
|
||||
? h("div", {}, rows.map((row) => h("div", {class: `bubble ${row.mine ? "mine" : "theirs"}`}, h("span", {class: "muted"}, `${row.who} · `, when(row.ts)), row.text)))
|
||||
? h("div", {}, rows.map((row) => h("div", {class: `bubble ${row.mine ? "mine" : "theirs"}`}, h("span", {class: "muted"}, `${row.who} · `, when(row.ts)), row.text, voiceReplay(row.voice))))
|
||||
: h("p", {class: "muted"}, "Kein Verlauf im gewählten Zeitraum."));
|
||||
}
|
||||
|
||||
@@ -75,16 +75,8 @@
|
||||
return message(nameOf(ui.agent), text, upload, cls);
|
||||
}
|
||||
|
||||
async function message(to, text, upload, cls) {
|
||||
const label = TO_ONE.find(([id]) => id === cls)[1];
|
||||
const result = await sendJson("/api/messages", {to, text, upload, class: cls});
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label} an ${to} fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false);
|
||||
return false;
|
||||
}
|
||||
toast(`${label} an ${to}: ${result.data.ok ? "gesendet" : "gespeichert, Senden fehlgeschlagen"}`, [`kvb-orchestrator msg ${result.data.id}`, ...runLines(result.data)], result.data.ok);
|
||||
await load();
|
||||
return true;
|
||||
function message(to, text, upload, cls) {
|
||||
return radioSend({to, text, upload, cls, voice: ""});
|
||||
}
|
||||
|
||||
function openAgent(who) {
|
||||
@@ -97,7 +89,7 @@
|
||||
const button = h("button", {type: "submit", class: "btn primary icon-only", ...named("Senden")}, icon("bi-send"));
|
||||
const classes = choice("to-one", TO_ONE, "post");
|
||||
const form = h("form", {class: "drawer-form"}, text, file, classes,
|
||||
h("p", {class: "muted"}, "Post kommt am Ende des Zuges an, Call sofort mit Lesebestätigung; prüfbar mit operator_message."),
|
||||
h("p", {class: "muted"}, "Post kommt am Ende des Zuges an, Call sofort mit Lesebestätigung; dein Passkey signiert, prüfbar mit operator_message."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), micButton(text), button));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -11,10 +11,14 @@
|
||||
return result.ok && result.data && result.data.ok;
|
||||
}
|
||||
|
||||
function confirmAct(action) {
|
||||
async function confirmAct(action) {
|
||||
const [, label, question] = ACTIONS.find(([id]) => id === action);
|
||||
if (window.confirm(question)) {
|
||||
act(action, label, {});
|
||||
if (!window.confirm(question)) {
|
||||
return;
|
||||
}
|
||||
const signed = await signedProof("/api/board/sign", {action}, label);
|
||||
if (signed) {
|
||||
await act(action, label, {proof: signed.proof});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,17 +37,16 @@
|
||||
const classes = choice("to-all", TO_ALL, "newsletter");
|
||||
const send = async () => {
|
||||
const cls = picked(classes);
|
||||
const label = TO_ALL.find(([id]) => id === cls)[1];
|
||||
if (!ui.draft.trim() || (cls === "mayday" && !window.confirm("Mayday an alle senden? Alle Agenten pausieren schreibende Schritte, bis du ihn aufhebst."))) {
|
||||
if (!ui.draft.trim() || (cls === "mayday" && !confirmMayday())) {
|
||||
return;
|
||||
}
|
||||
if (await act("broadcast", `Funk an alle (${label})`, {text: ui.draft, class: cls})) {
|
||||
if (await radioSend({to: "*", text: ui.draft, upload: "", cls, voice: ""})) {
|
||||
ui.draft = "";
|
||||
text.value = "";
|
||||
}
|
||||
};
|
||||
return h("details", {class: "menu", id: "to-all"}, h("summary", named("Funk an alle"), icon("bi-broadcast-pin"), caret()), h("div", {class: "menu-box"},
|
||||
text, classes, h("p", {class: "muted"}, "Newsletter kommt als Post am Ende des Zuges; Broadcast, Pan-Pan, Security und Mayday sofort."),
|
||||
text, classes, h("p", {class: "muted"}, "Newsletter kommt als Post am Ende des Zuges; Broadcast, Pan-Pan, Security und Mayday sofort. Dein Passkey signiert jede Nachricht."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), iconBtn("bi-send", "Senden", "btn soft", send, "Senden an alle"))));
|
||||
}
|
||||
|
||||
|
||||
@@ -27,14 +27,7 @@
|
||||
window.addEventListener("resize", redrawSky);
|
||||
window.addEventListener("resize", refresh3d);
|
||||
skyPanel();
|
||||
radio.element = h("audio", {preload: "none"});
|
||||
radio.element.addEventListener("error", () => {
|
||||
if (radio.element.getAttribute("src")) {
|
||||
radio.playing = false;
|
||||
radio.url = "";
|
||||
tuneMusic(FADE / 1000);
|
||||
}
|
||||
});
|
||||
radio.element = radioElement();
|
||||
drawSky();
|
||||
musicButton();
|
||||
syncMusic();
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
const CHANNELS = [...TO_ONE, ...TO_ALL];
|
||||
const CHANNEL_HINTS = {
|
||||
post: "Post kommt am Ende des Zuges an.",
|
||||
call: "Call kommt sofort, mit Lesebestätigung.",
|
||||
newsletter: "Newsletter kommt als Post an alle, am Ende ihres Zuges.",
|
||||
broadcast: "Broadcast erreicht alle sofort.",
|
||||
"pan-pan": "PAN-PAN: dringend, an alle, sofort.",
|
||||
security: "SECURITE: Sicherheitshinweis an alle, sofort.",
|
||||
mayday: "MAYDAY: alle Agenten pausieren schreibende Schritte, bis du ihn aufhebst.",
|
||||
};
|
||||
const VOICE_POLL = 700;
|
||||
const composer = {voice: "", recorder: null, timer: 0, started: 0};
|
||||
|
||||
function confirmMayday() {
|
||||
return window.confirm("Mayday an alle senden? Alle Agenten pausieren schreibende Schritte, bis du ihn aufhebst.");
|
||||
}
|
||||
|
||||
async function radioSend(message) {
|
||||
const label = `${CLASSES[message.cls] || message.cls} an ${message.to === "*" ? "alle" : message.to}`;
|
||||
const fields = {to: message.to, text: message.text, upload: message.upload, class: message.cls, voice: message.voice};
|
||||
const signed = await signedProof("/api/messages/sign", fields, label);
|
||||
if (!signed) {
|
||||
return false;
|
||||
}
|
||||
const {proof} = signed;
|
||||
const result = message.to === "*"
|
||||
? await sendJson("/api/board/broadcast", {text: fields.text, class: fields.class, voice: fields.voice, proof})
|
||||
: await sendJson("/api/messages", {...fields, proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label} fehlgeschlagen`, fail(result), false);
|
||||
return false;
|
||||
}
|
||||
toast(`${label}: ${result.data.ok ? "gesendet" : "gespeichert, Senden fehlgeschlagen"}`, [...(result.data.id ? [`kvb-orchestrator msg ${result.data.id}`] : []), ...runLines(result.data)], result.data.ok);
|
||||
await load();
|
||||
return true;
|
||||
}
|
||||
|
||||
function attachVoice(parts, note) {
|
||||
composer.voice = note;
|
||||
parts.attach.checked = false;
|
||||
parts.replay.hidden = !note;
|
||||
parts.replay.replaceChildren(...(note ? [icon("bi-soundwave"), h("span", {class: "muted"}, "Sprachnachricht"), voiceReplay(note), h("label", {class: "check"}, parts.attach, "Aufnahme anhängen"), iconBtn("bi-trash", "Aufnahme entfernen", "btn small ghost", () => attachVoice(parts, ""))] : []));
|
||||
}
|
||||
|
||||
async function heardNote(note) {
|
||||
const until = Date.now() + (state.speech.seconds + 60) * 1000;
|
||||
while (Date.now() < until) {
|
||||
const response = await fetch(`/api/voice/${note}`, {cache: "no-store"});
|
||||
const data = response.ok ? await response.json() : {status: "failed", error: `${response.status} ${(await response.text()).trim()}`};
|
||||
if (data.status !== "pending") {
|
||||
return data;
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, VOICE_POLL));
|
||||
}
|
||||
return {status: "failed", error: "keine Mitschrift in der erwarteten Zeit"};
|
||||
}
|
||||
|
||||
async function keepRecording(blob, parts) {
|
||||
parts.record.disabled = true;
|
||||
try {
|
||||
const sound = context();
|
||||
if (!sound || !blob.size) {
|
||||
throw new Error("keine Aufnahme");
|
||||
}
|
||||
parts.status.textContent = "Wandelt um …";
|
||||
const stored = await send("/api/voice", await wavOf(blob, sound), "audio/wav");
|
||||
if (!stored.ok || !stored.data) {
|
||||
throw new Error(`${stored.status} ${stored.text}`.trim());
|
||||
}
|
||||
attachVoice(parts, stored.data.id);
|
||||
parts.status.textContent = "Schreibt mit …";
|
||||
const note = await heardNote(stored.data.id);
|
||||
if (note.status === "failed") {
|
||||
parts.status.textContent = "Ohne Mitschrift: schreib den Text selbst.";
|
||||
toast("Mitschrift fehlgeschlagen", [note.error], false);
|
||||
return;
|
||||
}
|
||||
parts.text.value = [parts.text.value.trim(), note.text].filter(Boolean).join(" ");
|
||||
parts.text.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
parts.text.focus();
|
||||
parts.status.textContent = note.text ? "Text prüfen, dann senden." : "Nichts verstanden: schreib den Text selbst.";
|
||||
} catch (error) {
|
||||
parts.status.textContent = "";
|
||||
toast("Aufnahme fehlgeschlagen", [String(error.message || error)], false);
|
||||
} finally {
|
||||
parts.record.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function recordLabel(parts, on) {
|
||||
parts.record.setAttribute("aria-pressed", String(on));
|
||||
parts.record.replaceChildren(icon(on ? "bi-stop-fill" : "bi-record-circle"), on ? "Stopp" : "Aufnehmen");
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (composer.recorder && composer.recorder.state === "recording") {
|
||||
composer.recorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function startRecording(parts) {
|
||||
let media = null;
|
||||
try {
|
||||
media = await navigator.mediaDevices.getUserMedia({audio: {channelCount: 1, echoCancellation: true, noiseSuppression: true}});
|
||||
} catch (error) {
|
||||
toast("Mikrofon nicht verfügbar", [String(error.message || error)], false);
|
||||
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());
|
||||
window.clearInterval(composer.timer);
|
||||
composer.recorder = null;
|
||||
recordLabel(parts, false);
|
||||
keepRecording(new Blob(chunks, {type: recorder.mimeType}), parts);
|
||||
}, {once: true});
|
||||
Object.assign(composer, {recorder, started: Date.now()});
|
||||
recorder.start();
|
||||
recordLabel(parts, true);
|
||||
composer.timer = window.setInterval(() => {
|
||||
const seconds = Math.floor((Date.now() - composer.started) / 1000);
|
||||
parts.status.textContent = `Nimmt auf ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} von höchstens ${state.speech.seconds} s`;
|
||||
if (seconds >= state.speech.seconds) {
|
||||
stopRecording();
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function composerForm() {
|
||||
const channel = h("select", {class: "field"}, CHANNELS.map(([id, label]) => h("option", {value: id}, label)));
|
||||
const recipient = h("select", {class: "field"});
|
||||
const toField = h("label", {}, "An", recipient);
|
||||
const text = h("textarea", {"aria-label": "Nachricht", placeholder: "Nachricht an die Agenten …", maxlength: 20000});
|
||||
const replay = h("div", {class: "voice-note", hidden: true});
|
||||
const hint = h("p", {class: "muted"});
|
||||
const status = h("span", {class: "muted composer-status", "aria-live": "polite"});
|
||||
const record = h("button", {type: "button", class: "btn small", "aria-pressed": "false", title: "Aufnehmen: ein Klick startet, noch einer beendet; der Hub schreibt mit, du prüfst den Text vor dem Senden"});
|
||||
const submit = h("button", {type: "submit", class: "btn primary"}, icon("bi-send"), "Senden");
|
||||
const attach = h("input", {type: "checkbox", title: "Aufnahme anhängen: die Agenten bekommen den Pfad der Tondatei; sonst geht nur der geprüfte Text"});
|
||||
const parts = {record, status, text, replay, attach};
|
||||
recordLabel(parts, false);
|
||||
const gap = micGap();
|
||||
if (gap) {
|
||||
record.disabled = true;
|
||||
record.title = `Aufnehmen: ${gap}`;
|
||||
}
|
||||
record.addEventListener("click", () => (composer.recorder ? stopRecording() : startRecording(parts)));
|
||||
const sync = () => {
|
||||
const direct = TO_ONE.some(([id]) => id === channel.value);
|
||||
toField.hidden = !direct;
|
||||
hint.textContent = `${CHANNEL_HINTS[channel.value]} Dein Passkey signiert jede Nachricht.`;
|
||||
};
|
||||
channel.addEventListener("change", sync);
|
||||
sync();
|
||||
const form = h("form", {class: "composer"}, h("div", {class: "composer-grid"}, h("label", {}, "Kanal", channel), toField), text, replay, hint,
|
||||
h("div", {class: "actions"}, record, status, h("span", {class: "spacer"}), submit));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const direct = TO_ONE.some(([id]) => id === channel.value);
|
||||
if (!text.value.trim()) {
|
||||
toast("Nichts zu senden", ["Schreib einen Text oder nimm einen auf; die Mitschrift landet zum Prüfen im Feld."], false);
|
||||
return;
|
||||
}
|
||||
if ((direct && !recipient.value) || (channel.value === "mayday" && !confirmMayday())) {
|
||||
return;
|
||||
}
|
||||
submit.disabled = true;
|
||||
if (await radioSend({to: direct ? recipient.value : "*", text: text.value, upload: "", cls: channel.value, voice: attach.checked ? composer.voice : ""})) {
|
||||
text.value = "";
|
||||
status.textContent = "";
|
||||
attachVoice(parts, "");
|
||||
}
|
||||
submit.disabled = false;
|
||||
});
|
||||
attachVoice(parts, composer.voice);
|
||||
return {form, recipient};
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
const DENY_CHANNELS = [["post", true, "sobald sein Zug endet"], ["call", true, "sofort, weckt ihn"], ["newsletter", false, "sobald ihr Zug endet"], ["broadcast", false, "sofort"], ["pan-pan", false, "sofort, dringend"], ["security", false, "sofort, als Sicherheitsmeldung"]];
|
||||
const DENY_REASON = 2000;
|
||||
const requesterOf = (item) => item.agent || "den anfragenden Agenten";
|
||||
const requesterOf = (item) => item.to || "niemanden (Empfänger unklar)";
|
||||
|
||||
function denyNow(item, channel, reason) {
|
||||
return submit([{...entryOf(item, "reject", ""), channel, reason}], `Ablehnung (${CLASSES[channel]}): ${short(item.title)}`);
|
||||
return submit([{...entryOf(item, "reject", ""), channel, reason, to: item.to}], `Ablehnung (${CLASSES[channel]}): ${short(item.title)}`);
|
||||
}
|
||||
|
||||
function denySheet(item) {
|
||||
@@ -23,7 +23,7 @@
|
||||
h("p", {class: "text"}, item.title),
|
||||
h("fieldset", {class: "deny-channels"}, h("legend", {}, "Kanal"), choices),
|
||||
h("label", {class: "deny-reason"}, "Grund (optional, der Agent liest ihn mit)", reason),
|
||||
h("p", {class: "muted"}, "Der Passkey signiert die Ablehnung samt Kanal und Grund; danach bleiben 3 s zum Rückgängigmachen.",
|
||||
h("p", {class: "muted"}, "Der Passkey signiert die Ablehnung samt Kanal, Empfänger und Grund; danach bleiben 3 s zum Rückgängigmachen.",
|
||||
" Mayday bleibt dem Operator vorbehalten."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm)], false);
|
||||
reason.focus();
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
|
||||
function nodeMark(node, at, gap) {
|
||||
const r = RADIUS[node.kind];
|
||||
const classes = ["node", `k-${node.kind}`, ui.node === node.id ? "selected" : "", node.claims.length ? "claimed" : ""].filter(Boolean).join(" ");
|
||||
const classes = ["node", `k-${node.kind}`, ui.node === node.id ? "selected" : "", node.claims.length ? "claimed" : "", speech.current && speech.current.who === node.id ? "speaking" : ""].filter(Boolean).join(" ");
|
||||
const tasks = node.tasks.length ? ` · ${node.tasks.length} Aufgaben` : "";
|
||||
return s("g", {
|
||||
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${NODE_KINDS[node.kind]}`,
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function proofFor(entries, label) {
|
||||
const result = await sendJson("/api/sign", {items: entries});
|
||||
async function signedProof(path, fields, label) {
|
||||
const result = await sendJson(path, fields);
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label}: nicht möglich`, fail(result), false);
|
||||
return null;
|
||||
@@ -45,6 +45,10 @@
|
||||
return signed ? {proof: {payload: result.data.payload, assertion: signed}} : null;
|
||||
}
|
||||
|
||||
function proofFor(entries, label) {
|
||||
return signedProof("/api/sign", {items: entries}, label);
|
||||
}
|
||||
|
||||
async function registerPasskey(output) {
|
||||
if (!onLocalhost()) {
|
||||
output.replaceChildren(h("p", {class: "muted"}, "nicht registriert: WebAuthn braucht die Domain ", localLink()));
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
const PLAY_REFUSED = {
|
||||
NotAllowedError: ["blocked", "Klick nötig", "Der Browser blockiert automatisches Abspielen: ein Klick auf ▶ oder irgendwo auf die Seite startet den Sender."],
|
||||
NotSupportedError: ["error", "nicht abspielbar", "Nicht abspielbar: der Browser versteht Format oder Adresse nicht, oder der Sender leitet auf eine Adresse um, von der die Seite keinen Ton laden darf."],
|
||||
};
|
||||
const MEDIA_ERRORS = {1: "Laden abgebrochen", 2: "Netzwerkfehler", 3: "Ton nicht dekodierbar", 4: "Format oder Adresse nicht abspielbar"};
|
||||
|
||||
function radioVolume() {
|
||||
if (radio.element) {
|
||||
radio.element.volume = Math.min(Math.max(radio.level * settings.music.volume * (music.ducked ? DUCK : 1), 0), 1);
|
||||
@@ -23,6 +29,21 @@
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function radioStatus(kind, label, note) {
|
||||
Object.assign(radio, {state: kind, short: label, note});
|
||||
drawStations();
|
||||
}
|
||||
|
||||
function radioTrouble(kind, label, note) {
|
||||
radioStatus(kind, label, note);
|
||||
const key = `${radio.url}|${kind}|${label}`;
|
||||
if (radio.told !== key) {
|
||||
radio.told = key;
|
||||
const stream = settings.streams[radio.index];
|
||||
toast(`Radio ${kind === "blocked" ? "blockiert" : "stumm"}: ${stream ? stream.name : radio.url}`, [note], false);
|
||||
}
|
||||
}
|
||||
|
||||
function stopStream() {
|
||||
radio.element.pause();
|
||||
radio.element.removeAttribute("src");
|
||||
@@ -31,14 +52,33 @@
|
||||
tuneMusic(FADE / 1000);
|
||||
}
|
||||
|
||||
function syncRadio() {
|
||||
function dueStation() {
|
||||
const sig = JSON.stringify(settings.streams);
|
||||
if (sig !== radio.sig) {
|
||||
radio.sig = sig;
|
||||
radio.pick = null;
|
||||
}
|
||||
if (radio.pick === -1) {
|
||||
return -1;
|
||||
}
|
||||
if (radio.pick !== null && settings.streams[radio.pick]) {
|
||||
return radio.pick;
|
||||
}
|
||||
const due = Ambience.activeStream(settings.streams, Ambience.slotOf(new Date()));
|
||||
const url = due ? due.url : "";
|
||||
return due ? settings.streams.indexOf(due) : -1;
|
||||
}
|
||||
|
||||
function syncRadio() {
|
||||
const index = dueStation();
|
||||
const url = index < 0 ? "" : settings.streams[index].url;
|
||||
if (url === radio.url && !radio.waiting) {
|
||||
drawStations();
|
||||
return;
|
||||
}
|
||||
const origin = url ? new URL(url).origin : "";
|
||||
radio.index = index;
|
||||
if (origin && !pageMedia.has(origin)) {
|
||||
radioStatus("reload", "lädt neu", "Neue Adresse: die Seite lädt neu, damit der Browser von dort Ton laden darf.");
|
||||
if (state.media.includes(origin)) {
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -46,6 +86,7 @@
|
||||
}
|
||||
radio.url = url;
|
||||
radio.waiting = false;
|
||||
radioStatus(url ? "loading" : "", url ? "verbindet" : "", url ? "Verbindet mit dem Sender …" : "");
|
||||
fadeRadio(0, () => {
|
||||
if (radio.url !== url) {
|
||||
return;
|
||||
@@ -57,14 +98,59 @@
|
||||
radio.element.src = url;
|
||||
radio.element.play().then(() => {
|
||||
radio.playing = true;
|
||||
radio.told = "";
|
||||
radioStatus("playing", "spielt", `spielt seit ${clock.format(new Date())}`);
|
||||
tuneMusic(FADE / 1000);
|
||||
fadeRadio(1, () => null);
|
||||
}).catch(() => {
|
||||
}).catch((error) => {
|
||||
if (radio.url !== url || error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
radio.waiting = true;
|
||||
const [kind, label, note] = PLAY_REFUSED[error.name] || ["error", "Fehler", `${error.name}: ${error.message}`];
|
||||
// Trip-wire: the media error event fires before this rejection and already named the cause; a second toast would hide it.
|
||||
if (!(kind === "error" && radio.state === "error")) {
|
||||
radioTrouble(kind, label, note);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function playStation(index) {
|
||||
const stream = settings.streams[index];
|
||||
radio.pick = index;
|
||||
if (!(radio.playing && radio.url === stream.url)) {
|
||||
radio.waiting = true;
|
||||
}
|
||||
syncRadio();
|
||||
}
|
||||
|
||||
function stopStation() {
|
||||
radio.pick = -1;
|
||||
syncRadio();
|
||||
}
|
||||
|
||||
function radioElement() {
|
||||
const element = h("audio", {preload: "none"});
|
||||
element.addEventListener("error", () => {
|
||||
if (!element.getAttribute("src")) {
|
||||
return;
|
||||
}
|
||||
const failure = element.error;
|
||||
radio.playing = false;
|
||||
radio.waiting = true;
|
||||
tuneMusic(FADE / 1000);
|
||||
const reason = failure ? MEDIA_ERRORS[failure.code] || `Fehler ${failure.code}` : "unbekannter Fehler";
|
||||
radioTrouble("error", "Fehler", `Der Sender spielt nicht: ${reason}${failure && failure.message ? ` (${failure.message})` : ""}.`);
|
||||
});
|
||||
document.addEventListener("securitypolicyviolation", (event) => {
|
||||
if (event.effectiveDirective === "media-src" && radio.url && !event.blockedURI.startsWith(window.location.origin)) {
|
||||
radioTrouble("error", "gesperrt", `Der Sender leitet auf ${event.blockedURI} um; die Seite spielt nur von den Adressen, die du mit deinem Passkey gespeichert hast.`);
|
||||
}
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
function duck(seconds) {
|
||||
music.ducked += 1;
|
||||
tuneMusic(0.15);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
const STATION_TONES = {playing: "ok", loading: "info", reload: "info", blocked: "warn", error: "bad", due: "teal", stopped: "neutral", idle: "neutral", off: "neutral"};
|
||||
const NOTE_ID = /^[0-9a-f]{16}$/;
|
||||
let radioDom = null;
|
||||
|
||||
function voiceReplay(note) {
|
||||
return typeof note === "string" && NOTE_ID.test(note) ? h("audio", {class: "voice-replay", controls: true, preload: "none", src: `/api/voice/${note}.wav`, "aria-label": "Sprachnachricht abspielen"}) : null;
|
||||
}
|
||||
|
||||
function daysText(days) {
|
||||
return {"0,1,2,3,4,5,6": "täglich", "0,1,2,3,4": "werktags", "5,6": "am Wochenende"}[days.join(",")] || days.map((day) => WEEKDAYS[day]).join(" ");
|
||||
}
|
||||
|
||||
function scheduleOf(stream) {
|
||||
return `${daysText(stream.days)}, ${stream.from === "00:00" && stream.to === "24:00" ? "rund um die Uhr" : `${stream.from} bis ${stream.to}`}`;
|
||||
}
|
||||
|
||||
function stationState(stream, index) {
|
||||
if (index === radio.index && radio.state) {
|
||||
return [radio.state, radio.short, radio.note];
|
||||
}
|
||||
if (!stream.on) {
|
||||
return ["off", "aus", "In den Einstellungen ausgeschaltet"];
|
||||
}
|
||||
if (!Ambience.activeStream([stream], Ambience.slotOf(new Date()))) {
|
||||
return ["idle", "nicht dran", `Spielt ${scheduleOf(stream)}; ▶ spielt ihn jetzt`];
|
||||
}
|
||||
return radio.pick === -1 ? ["stopped", "gestoppt", "Von Hand gestoppt; ▶ spielt ihn wieder"] : ["due", "dran", "Dran, sobald kein früherer Sender spielt"];
|
||||
}
|
||||
|
||||
function stationRow(stream, index) {
|
||||
const [kind, label, note] = stationState(stream, index);
|
||||
const live = index === radio.index && (kind === "playing" || kind === "loading");
|
||||
const action = live
|
||||
? iconBtn("bi-stop-fill", "Stopp", "btn small ghost", stopStation, `Stopp: ${stream.name} anhalten`)
|
||||
: iconBtn("bi-play-fill", "Abspielen", "btn small ghost", () => playStation(index), `Abspielen: ${stream.name} jetzt hören`);
|
||||
action.dataset.action = live ? "stop" : "play";
|
||||
return h("li", {class: "station", "data-station": index, "data-state": kind},
|
||||
h("span", {class: "station-name", title: stream.name}, stream.name),
|
||||
h("span", {class: "station-when", title: scheduleOf(stream)}, scheduleOf(stream)),
|
||||
h("span", {class: "pill station-note", "data-tone": STATION_TONES[kind], title: note}, label),
|
||||
action);
|
||||
}
|
||||
|
||||
function drawStations() {
|
||||
if (!radioDom) {
|
||||
return;
|
||||
}
|
||||
const sig = JSON.stringify([settings.streams, radio.index, radio.state, radio.note, radio.pick, Math.floor(Date.now() / 60000)]);
|
||||
if (sig === radioDom.stationsSig) {
|
||||
return;
|
||||
}
|
||||
radioDom.stationsSig = sig;
|
||||
radioDom.stations.replaceChildren(...(settings.streams.length
|
||||
? settings.streams.map(stationRow)
|
||||
: [h("li", {class: "muted"}, "Noch kein Sender: unter ⚙ Einstellungen → Radio fügst du einen hinzu.")]));
|
||||
}
|
||||
|
||||
function speakerChips() {
|
||||
return Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now)).map(([key, entry]) => h("li", {class: "speaker", "data-key": key, title: entry.now},
|
||||
h("span", {class: "speaker-face"}, face(entry.now, false)), h("span", {class: "speaker-name"}, entry.now)));
|
||||
}
|
||||
|
||||
function drawSpeaker() {
|
||||
const now = speech.current && speech.current.who ? speech.current : null;
|
||||
if (radioDom) {
|
||||
for (const chip of radioDom.speakers.querySelectorAll(".speaker")) {
|
||||
chip.classList.toggle("speaking", Boolean(now && chip.dataset.key === now.who));
|
||||
}
|
||||
radioDom.caption.hidden = !now;
|
||||
radioDom.caption.replaceChildren(...(now ? [
|
||||
h("span", {class: "speaker-face"}, face(nameOf(now.who), false)),
|
||||
h("strong", {}, `${nameOf(now.who)} spricht`),
|
||||
h("span", {class: "caption-line", title: now.line}, now.line),
|
||||
iconBtn("bi-stop-fill", "Vorlesen abbrechen", "btn small ghost", cancelSpeech),
|
||||
] : []));
|
||||
}
|
||||
if (ui.screen === "agents" && (ui.views.agents === "orbit" || ui.views.agents === "chord")) {
|
||||
stage();
|
||||
}
|
||||
}
|
||||
|
||||
function sentRows() {
|
||||
const rows = state.messages.slice(-5).reverse();
|
||||
return rows.length ? h("ul", {class: "mini"}, rows.map((message) => h("li", {},
|
||||
h("div", {class: "row"}, klass(message.class || "post"), when(message.created_ts)),
|
||||
h("span", {class: "muted"}, `an ${message.to === "*" ? "alle" : message.to}`),
|
||||
h("span", {}, short(message.text)),
|
||||
voiceReplay(message.voice)))) : h("p", {class: "muted"}, "Im gewählten Zeitraum noch nichts gesendet.");
|
||||
}
|
||||
|
||||
function drawRadio() {
|
||||
if (!radioDom) {
|
||||
return;
|
||||
}
|
||||
const crew = JSON.stringify(people());
|
||||
if (crew !== radioDom.crew) {
|
||||
radioDom.crew = crew;
|
||||
radioDom.speakers.replaceChildren(...speakerChips());
|
||||
drawSpeaker();
|
||||
}
|
||||
const names = agentNames();
|
||||
if (JSON.stringify(names) !== radioDom.names) {
|
||||
radioDom.names = JSON.stringify(names);
|
||||
const chosen = radioDom.recipient.value;
|
||||
radioDom.recipient.replaceChildren(...names.map((name) => h("option", {value: name, selected: name === chosen}, name)));
|
||||
}
|
||||
const sent = JSON.stringify(state.messages.slice(-5));
|
||||
if (sent !== radioDom.sent) {
|
||||
radioDom.sent = sent;
|
||||
radioDom.outbox.replaceChildren(sentRows());
|
||||
}
|
||||
drawStations();
|
||||
}
|
||||
|
||||
function radioScreen() {
|
||||
const speakers = h("ul", {class: "speakers", "aria-label": "Stationen"});
|
||||
const caption = h("div", {class: "radio-caption", role: "status", "aria-live": "polite", hidden: true});
|
||||
const stations = h("ul", {class: "stations", "aria-label": "Sender"});
|
||||
const outbox = h("div", {});
|
||||
const {form, recipient} = composerForm();
|
||||
radioDom = {speakers, caption, stations, outbox, recipient, crew: "", names: "", sent: "", stationsSig: ""};
|
||||
return h("div", {class: "stack radio-view"},
|
||||
h("h2", {class: "title"}, "Radio"),
|
||||
h("div", {class: "radio-grid"},
|
||||
h("section", {class: "card radio-talk", "aria-label": "Funk"}, h("h3", {class: "group-title"}, "Funk"), caption, speakers, form),
|
||||
h("div", {class: "stack"},
|
||||
h("section", {class: "card", "aria-label": "Internetradio"},
|
||||
h("div", {class: "card-head"}, h("h3", {class: "group-title"}, "Internetradio"), btn("Sender bearbeiten", "btn small ghost", settingsSheet)),
|
||||
stations),
|
||||
h("section", {class: "card", "aria-label": "Zuletzt gesendet"}, h("h3", {class: "group-title"}, "Zuletzt gesendet"), outbox))));
|
||||
}
|
||||
@@ -48,7 +48,7 @@
|
||||
}
|
||||
|
||||
function screenOf() {
|
||||
return {stream: streamScreen, inbox: inboxScreen, rules: rulesScreen, agents: agentsScreen, artifacts: artifactsScreen}[ui.screen]();
|
||||
return {stream: streamScreen, inbox: inboxScreen, rules: rulesScreen, radio: radioScreen, agents: agentsScreen, artifacts: artifactsScreen}[ui.screen]();
|
||||
}
|
||||
|
||||
function render(force) {
|
||||
@@ -63,6 +63,7 @@
|
||||
graphDom = null;
|
||||
stream.dom = null;
|
||||
tree.dom = null;
|
||||
radioDom = null;
|
||||
inboxDom = null;
|
||||
inboxSig = "";
|
||||
const main = document.getElementById("main");
|
||||
@@ -80,6 +81,7 @@
|
||||
drawHierarchy(force);
|
||||
drawToolbar();
|
||||
drawInbox();
|
||||
drawRadio();
|
||||
drawStatus();
|
||||
tick();
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
}
|
||||
|
||||
async function saveSettings(next) {
|
||||
const result = await sendJson("/api/settings", next);
|
||||
const signed = await signedProof("/api/settings/sign", next, "Einstellungen");
|
||||
if (!signed) {
|
||||
return false;
|
||||
}
|
||||
const result = await sendJson("/api/settings", {...next, proof: signed.proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast("Einstellungen nicht gespeichert", [`${result.status} ${result.text}`.trim()], false);
|
||||
return false;
|
||||
@@ -22,7 +26,7 @@
|
||||
const fresh = result.data.streams.map((stream) => new URL(stream.url).origin).filter((origin) => !pageMedia.has(origin));
|
||||
adoptSettings(result.data);
|
||||
if (fresh.length) {
|
||||
toast("Neuer Sender", ["Die Seite lädt neu, damit der Browser ihn abspielen darf."], true);
|
||||
toast("Neuer Sender", ["Die Seite lädt neu, damit der Browser ihn abspielen darf. Blockiert der Browser danach das Abspielen, startet ein Klick den Sender."], true);
|
||||
window.setTimeout(() => window.location.reload(), 1500);
|
||||
}
|
||||
return true;
|
||||
@@ -104,7 +108,7 @@
|
||||
const stations = Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now))
|
||||
.map(([key, entry]) => ({key, label: entry.now, box: h("input", {type: "checkbox", checked: !settings.speech.muted.includes(key), "aria-label": `${entry.now} vorlesen`})}));
|
||||
const absent = settings.speech.muted.filter((key) => !people()[key]);
|
||||
const sample = btn("Test", "btn small ghost", () => say({text: state.speech.sample, voice: state.speech.operator, tone: "direct", cls: "newsletter"}));
|
||||
const sample = btn("Test", "btn small ghost", () => say({text: state.speech.sample, voice: state.speech.operator, tone: "direct", cls: "newsletter", who: HUMAN, line: "radio check"}));
|
||||
sample.disabled = !state.speech.tts;
|
||||
const list = h("div", {class: "mini"});
|
||||
const rows = [];
|
||||
@@ -125,8 +129,8 @@
|
||||
h("div", {class: "days"}, h("label", {class: "check"}, speechOn, "Funk vorlesen"), sample),
|
||||
h("div", {class: "days", role: "group", "aria-label": "Klassen"}, spoken.map((entry) => h("label", {class: "check"}, entry.box, entry.label))),
|
||||
stations.length ? h("div", {class: "days", role: "group", "aria-label": "Stationen"}, stations.map((entry) => h("label", {class: "check"}, entry.box, entry.label))) : null),
|
||||
h("section", {}, h("h3", {}, "Radio"), h("p", {class: "muted"}, "Internetradio zu festen Zeiten mit 3 s Ein- und Ausblenden; die Musik pausiert solange. Die Seite darf nur von diesen Adressen Ton laden."),
|
||||
list, h("div", {}, btn("+ Sender", "btn small soft", () => streamRow({name: "", url: "", from: "07:00", to: "09:00", days: [0, 1, 2, 3, 4], on: true}, rows, list)))),
|
||||
h("section", {}, h("h3", {}, "Radio"), h("p", {class: "muted"}, "Internetradio zu festen Zeiten mit 3 s Ein- und Ausblenden; die Musik pausiert solange. Ein neuer Sender läuft rund um die Uhr, bis du seine Zeiten änderst; unter Stream → Radio startest und stoppst du ihn von Hand. Die Seite darf nur von diesen Adressen Ton laden; eine neue Adresse signiert dein Passkey."),
|
||||
list, h("div", {}, btn("+ Sender", "btn small soft", () => streamRow({name: "", url: "", from: "00:00", to: "24:00", days: [0, 1, 2, 3, 4, 5, 6], on: true}, rows, list)))),
|
||||
passkeySection(),
|
||||
h("div", {class: "sheet-foot"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), h("button", {type: "submit", class: "btn primary"}, "Speichern")));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
|
||||
@@ -41,8 +41,14 @@
|
||||
gain.gain.value = loudness(item.tone);
|
||||
source.buffer = buffer;
|
||||
source.connect(gain).connect(sound.destination);
|
||||
source.addEventListener("ended", next, {once: true});
|
||||
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)]);
|
||||
@@ -50,20 +56,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
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))) {
|
||||
say({text: row.speech, voice: row.voice, tone: Voice.toneOf(row, HUMAN), cls: row.class});
|
||||
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
|
||||
? "Das Mikrofon braucht einen sicheren Kontext: öffne den Hub über 127.0.0.1 oder localhost"
|
||||
: !window.MediaRecorder ? "Dieser Browser kann nicht aufnehmen" : !state.speech.stt ? `Spracheingabe ist aus: ${state.speech.stt_off}` : "";
|
||||
}
|
||||
|
||||
function micButton(field) {
|
||||
const button = h("button", {type: "button", class: "btn small ghost icon-only mic", "aria-pressed": "false", ...named("Spracheingabe", "Spracheingabe: gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet")}, icon("bi-mic"));
|
||||
const reason = !window.isSecureContext || !navigator.mediaDevices
|
||||
? "Das Mikrofon braucht einen sicheren Kontext: öffne den Hub über 127.0.0.1 oder localhost"
|
||||
: !window.MediaRecorder ? "Dieser Browser kann nicht aufnehmen" : !state.speech.stt ? `Spracheingabe ist aus: ${state.speech.stt_off}` : "";
|
||||
const reason = micGap();
|
||||
if (reason) {
|
||||
button.disabled = true;
|
||||
button.title = `Spracheingabe: ${reason}`;
|
||||
@@ -135,6 +153,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
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("zu kurz: sprich mindestens eine Viertelsekunde");
|
||||
}
|
||||
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) {
|
||||
@@ -143,19 +177,7 @@
|
||||
mic.busy = true;
|
||||
button.disabled = true;
|
||||
try {
|
||||
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("zu kurz: halte die Taste, solange du sprichst");
|
||||
}
|
||||
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();
|
||||
const result = await send("/api/stt", Voice.wav(rendered.getChannelData(0), rate), "audio/wav");
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
const pageMedia = new Set(state.media);
|
||||
const sky = {canvas: document.getElementById("sky"), stars: Ambience.stars(320, 7), mode: "", frame: 0};
|
||||
const music = {gain: null, filter: null, drone: [], timer: 0, tension: 0, ducked: 0};
|
||||
const radio = {element: null, url: "", level: 0, timer: 0, playing: false, waiting: false};
|
||||
const speech = {pending: [], busy: false, warned: 0};
|
||||
const radio = {element: null, url: "", level: 0, timer: 0, playing: false, waiting: false, index: -1, pick: null, sig: "", state: "", short: "", note: "", told: ""};
|
||||
const speech = {pending: [], busy: false, warned: 0, current: null, source: null};
|
||||
const mic = {recorder: null, held: false, busy: false, timer: 0};
|
||||
const space = {
|
||||
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0, look: false, compass: null,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
.rule-form { grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); }
|
||||
.rule-form > label, .settings .grid > label { min-width: 0; }
|
||||
.rule-form .field, .settings .grid .field { width: 100%; min-width: 0; }
|
||||
.rule-form [hidden] { display: none; }
|
||||
.rule-form > label.check { display: inline-flex; min-height: 34px; }
|
||||
.radio-grid { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: var(--sp-4); align-items: start; }
|
||||
@media (max-width: 960px) { .radio-grid { grid-template-columns: minmax(0, 1fr); } }
|
||||
.radio-talk { display: grid; gap: var(--sp-3); }
|
||||
.speakers { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin: 0; padding: 0; list-style: none; }
|
||||
.speaker { display: inline-flex; align-items: center; gap: 6px; max-width: 220px; padding: 3px 10px 3px 3px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); font-size: var(--fs-sm); }
|
||||
.speaker.speaking { border-color: var(--ok); background: var(--ok-soft); color: var(--ok); }
|
||||
.speaker-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.speaker-face { position: relative; display: inline-grid; flex: none; border-radius: 50%; }
|
||||
.speaker.speaking .speaker-face::after, .radio-caption .speaker-face::after { content: ""; position: absolute; inset: -4px; border: 2px solid var(--ok); border-radius: 50%; pointer-events: none; animation: speaking 1.3s var(--ease) infinite; }
|
||||
@keyframes speaking { from { opacity: .95; transform: scale(1); } to { opacity: 0; transform: scale(1.6); } }
|
||||
.radio-caption { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; padding: var(--sp-2) var(--sp-3); border: 1px solid color-mix(in srgb, var(--ok) 45%, var(--line)); border-radius: var(--radius-sm); background: var(--ok-soft); }
|
||||
.radio-caption[hidden] { display: none; }
|
||||
.radio-caption strong { white-space: nowrap; }
|
||||
.caption-line { flex: 1; min-width: 0; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speaking-node 1.3s ease-in-out infinite; }
|
||||
@keyframes speaking-node { 50% { stroke-width: 10; stroke-opacity: .45; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.speaker.speaking .speaker-face::after, .radio-caption .speaker-face::after, svg .node.speaking circle { animation: none; }
|
||||
}
|
||||
.stations { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
||||
.station { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto auto; align-items: center; gap: var(--sp-2); min-width: 0; padding: 3px 3px 3px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--sunken); }
|
||||
.station-name, .station-when { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.station-name { font-weight: 600; }
|
||||
.station-when { color: var(--muted); font-size: var(--fs-sm); }
|
||||
.station[data-state="playing"] { border-color: color-mix(in srgb, var(--ok) 55%, var(--line)); }
|
||||
.station[data-state="blocked"] { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }
|
||||
.station[data-state="error"] { border-color: color-mix(in srgb, var(--bad) 55%, var(--line)); }
|
||||
@media (max-width: 640px) {
|
||||
.station { grid-template-columns: minmax(0, 1fr) auto auto; }
|
||||
.station-when { display: none; }
|
||||
}
|
||||
.composer { display: grid; gap: var(--sp-2); }
|
||||
.composer-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: var(--sp-2) var(--sp-3); }
|
||||
.composer-grid label { display: grid; gap: 4px; min-width: 0; color: var(--muted); font-size: var(--fs-sm); }
|
||||
.composer-grid .field { width: 100%; min-width: 0; }
|
||||
.composer [hidden] { display: none; }
|
||||
.composer .actions { margin-top: 0; }
|
||||
.composer-status { min-width: 0; overflow-wrap: anywhere; }
|
||||
.voice-note { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
|
||||
.voice-replay { max-width: 100%; height: 34px; }
|
||||
.mini .voice-replay, .bubble .voice-replay { width: 100%; }
|
||||
Reference in new issue
Block a user