emilstabil's picture
Cache verified model files across browser visits
1c7293d verified
Raw History Blame Contribute Delete
37.8 kB
const elements = {
app: document.querySelector("#app"),
ambient: document.querySelector("#ambient"),
orb: document.querySelector("#orb"),
ring: document.querySelector(".ring-1"),
statusDot: document.querySelector("#statusDot"),
statusText: document.querySelector("#statusText"),
statusDetail: document.querySelector("#statusDetail"),
progressTrack: document.querySelector("#progressTrack"),
progressBar: document.querySelector("#progressBar"),
recordButton: document.querySelector("#recordButton"),
recordLabel: document.querySelector("#recordLabel"),
emptyTranscript: document.querySelector("#emptyTranscript"),
transcriptPanel: document.querySelector("#transcriptPanel"),
segments: document.querySelector("#segments"),
pncStatus: document.querySelector("#pncStatus"),
themeButton: document.querySelector("#themeButton"),
copyButton: document.querySelector("#copyButton"),
downloadButton: document.querySelector("#downloadButton"),
clearButton: document.querySelector("#clearButton"),
infoButton: document.querySelector("#infoButton"),
settingsButton: document.querySelector("#settingsButton"),
historyList: document.querySelector("#historyList"),
historyEmpty: document.querySelector("#historyEmpty"),
clearHistoryButton: document.querySelector("#clearHistoryButton"),
microphoneDialog: document.querySelector("#microphoneDialog"),
microphoneTitle: document.querySelector("#microphoneTitle"),
microphoneDescription: document.querySelector("#microphoneDescription"),
microphoneHelp: document.querySelector("#microphoneHelp"),
microphoneCloseButton: document.querySelector("#microphoneCloseButton"),
microphoneCancelButton: document.querySelector("#microphoneCancelButton"),
microphoneContinueButton: document.querySelector("#microphoneContinueButton"),
infoDialog: document.querySelector("#infoDialog"),
settingsDialog: document.querySelector("#settingsDialog"),
};
const HISTORY_STORAGE_KEY = "ekko-tiny-browser:conversations:v1";
const THEME_STORAGE_KEY = "ekko-tiny-browser:theme:v1";
const HISTORY_LIMIT = 20;
function signedAsset(path) {
const url = new URL(path, location.href);
const signature = new URLSearchParams(location.search).get("__sign");
if (signature && url.origin === location.origin) url.searchParams.set("__sign", signature);
return url.href;
}
function setTheme(theme) {
const dark = theme === "dark";
document.documentElement.dataset.theme = dark ? "dark" : "light";
elements.themeButton.setAttribute("aria-pressed", String(dark));
const label = dark ? "Slå lys tilstand til" : "Slå mørk tilstand til";
elements.themeButton.setAttribute("aria-label", label);
elements.themeButton.title = label;
document.querySelector('meta[name="theme-color"]').content = dark ? "#10151c" : "#f3f4f0";
}
let savedTheme;
try { savedTheme = localStorage.getItem(THEME_STORAGE_KEY); } catch { /* Storage may be disabled. */ }
setTheme(savedTheme || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));
const state = {
ready: false,
busy: false,
recording: false,
stopping: false,
checkingMicrophonePermission: false,
startingRecording: false,
microphonePermissionGranted: false,
speechDetected: false,
pncReady: false,
pncFailed: false,
pncInitializationRequested: false,
pncRequestId: 0,
nextSegmentId: 0,
rawSegments: [],
segments: [],
previewSegment: null,
history: [],
currentConversationId: null,
currentConversationCreatedAt: null,
punctuationApplied: false,
microphone: null,
audioContext: null,
sourceNode: null,
captureNode: null,
silentGain: null,
resampler: null,
captureStopResolve: null,
captureDrainTimedOut: false,
level: 0,
};
const worker = new Worker(signedAsset("worker.js?v=20260927-2"));
const pncWorker = new Worker(signedAsset("pnc-worker.js?v=20260927-2"));
function setStatus(text, detail = "", kind = "loading") {
elements.statusText.textContent = text;
elements.statusDetail.textContent = detail;
elements.statusDot.classList.toggle("ready", kind === "ready");
elements.statusDot.classList.toggle("error", kind === "error");
}
function setProgress(value, complete = false) {
elements.progressBar.style.width = `${Math.max(0, Math.min(1, value)) * 100}%`;
elements.progressTrack.classList.toggle("complete", complete);
}
function formatBytes(value) {
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KiB`;
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
}
function formatTime(seconds) {
const safe = Math.max(0, Number(seconds) || 0);
const minutes = Math.floor(safe / 60);
const remainder = safe - minutes * 60;
return `${String(minutes).padStart(2, "0")}:${remainder.toFixed(1).padStart(4, "0")}`;
}
function transcriptText() {
return state.segments.map((segment) => segment.text).filter(Boolean).join(" ").trim();
}
function copySegments(segments) {
if (!Array.isArray(segments)) return [];
return segments.map((segment, index) => ({
id: typeof segment.id === "string" ? segment.id : `segment-${index}`,
text: String(segment.text || ""),
rawText: String(segment.rawText || segment.text || ""),
start: Number.isFinite(segment.start) ? segment.start : 0,
end: Number.isFinite(segment.end) ? segment.end : 0,
duration: Number.isFinite(segment.duration) ? segment.duration : 0,
words: Array.isArray(segment.words)
? segment.words.map((word) => ({
text: String(word.text || ""),
start: Number.isFinite(word.start) ? word.start : 0,
end: Number.isFinite(word.end) ? word.end : 0,
}))
: [],
}));
}
function readConversationHistory() {
try {
const records = JSON.parse(localStorage.getItem(HISTORY_STORAGE_KEY) || "[]");
if (!Array.isArray(records)) return [];
return records
.filter((record) => record && typeof record.id === "string" && Array.isArray(record.segments))
.slice(0, HISTORY_LIMIT)
.map((record) => ({
id: record.id,
createdAt: String(record.createdAt || new Date().toISOString()),
updatedAt: String(record.updatedAt || record.createdAt || new Date().toISOString()),
text: String(record.text || ""),
rawSegments: copySegments(record.rawSegments || record.segments),
segments: copySegments(record.segments),
punctuationApplied: Boolean(record.punctuationApplied),
}));
} catch (error) {
console.warn("Kunne ikke læse lokal samtalehistorik", error);
return [];
}
}
function formatHistoryDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Ukendt tidspunkt";
return new Intl.DateTimeFormat("da-DK", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function historyDuration(record) {
return record.segments.reduce((maximum, segment) => Math.max(maximum, segment.end || 0), 0);
}
function renderHistory() {
const hasHistory = state.history.length > 0;
const historyLocked = state.recording || state.stopping;
elements.historyList.replaceChildren();
elements.historyList.hidden = !hasHistory;
elements.historyEmpty.hidden = hasHistory;
elements.clearHistoryButton.disabled = !hasHistory || historyLocked;
for (const record of state.history) {
const item = document.createElement("li");
item.className = "history-item";
const openButton = document.createElement("button");
openButton.type = "button";
openButton.className = "history-open";
openButton.dataset.historyOpen = record.id;
openButton.disabled = historyLocked;
const date = document.createElement("time");
date.className = "history-date";
date.dateTime = record.createdAt;
date.textContent = formatHistoryDate(record.createdAt);
const preview = document.createElement("span");
preview.className = "history-preview";
preview.textContent = record.text;
const meta = document.createElement("span");
meta.className = "history-meta";
const wordCount = record.text.split(/\s+/).filter(Boolean).length;
meta.textContent = `${wordCount} ord · ${formatTime(historyDuration(record))}`;
openButton.append(date, preview, meta);
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "history-delete";
deleteButton.dataset.historyDelete = record.id;
deleteButton.disabled = historyLocked;
deleteButton.setAttribute("aria-label", `Slet samtale fra ${date.textContent}`);
deleteButton.title = "Slet samtale";
deleteButton.textContent = "×";
item.append(openButton, deleteButton);
elements.historyList.appendChild(item);
}
}
function writeConversationHistory() {
let records = state.history.slice(0, HISTORY_LIMIT);
while (records.length) {
try {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(records));
state.history = records;
renderHistory();
return;
} catch (error) {
records = records.slice(0, -1);
if (!records.length) console.warn("Kunne ikke gemme lokal samtalehistorik", error);
}
}
state.history = [];
try {
localStorage.removeItem(HISTORY_STORAGE_KEY);
} catch (error) {
console.warn("Kunne ikke rydde lokal samtalehistorik", error);
}
renderHistory();
}
function persistCurrentConversation() {
const text = transcriptText();
if (!state.currentConversationId || !text) return;
const timestamp = new Date().toISOString();
const record = {
id: state.currentConversationId,
createdAt: state.currentConversationCreatedAt || timestamp,
updatedAt: timestamp,
text,
rawSegments: copySegments(state.rawSegments),
segments: copySegments(state.segments),
punctuationApplied: state.punctuationApplied,
};
state.history = [record, ...state.history.filter((item) => item.id !== record.id)];
writeConversationHistory();
}
function loadConversation(recordId) {
const record = state.history.find((item) => item.id === recordId);
if (!record || state.recording || state.stopping) return;
state.pncRequestId += 1;
state.currentConversationId = record.id;
state.currentConversationCreatedAt = record.createdAt;
state.rawSegments = copySegments(record.rawSegments);
state.punctuationApplied = record.punctuationApplied;
state.segments = copySegments(record.punctuationApplied ? record.segments : record.rawSegments);
state.nextSegmentId = state.segments.length;
elements.app.classList.add("session-active");
document.body.classList.add("session-active");
setConversationVisible(true);
renderTranscript();
elements.recordLabel.textContent = "Tryk for at optage igen";
setStatus("Tidligere samtale", formatHistoryDate(record.createdAt), "ready");
elements.settingsDialog.close();
if (!record.punctuationApplied) requestPunctuation();
}
function deleteConversation(recordId) {
if (state.recording || state.stopping) return;
state.history = state.history.filter((item) => item.id !== recordId);
if (state.currentConversationId === recordId) state.currentConversationId = null;
writeConversationHistory();
}
function setConversationVisible(visible) {
elements.app.classList.toggle("conversation-visible", visible);
elements.transcriptPanel.classList.toggle("open", visible);
document.body.classList.toggle("conversation-visible", visible);
if (visible) {
requestAnimationFrame(() => {
elements.transcriptPanel.scrollTop = elements.transcriptPanel.scrollHeight;
});
}
}
function renderWords(paragraph, segment, animate) {
paragraph.replaceChildren();
const words = String(segment.text || "").split(/\s+/).filter(Boolean);
words.forEach((word, index) => {
if (index) paragraph.appendChild(document.createTextNode(" "));
const span = document.createElement("span");
span.className = `word${animate ? " reveal-word" : ""}`;
span.textContent = word;
if (animate) span.style.animationDelay = `${Math.min(index * 45, 180)}ms`;
paragraph.appendChild(span);
});
}
function updateWords(paragraph, segment, animate) {
const words = String(segment.text || "").split(/\s+/).filter(Boolean);
const spans = Array.from(paragraph.querySelectorAll(".word"));
if (!animate && spans.length === words.length) {
spans.forEach((span, index) => {
span.textContent = words[index];
});
return;
}
renderWords(paragraph, segment, animate);
}
function renderTranscript({animateNew = false} = {}) {
const hasText = state.segments.some((segment) => segment.text);
elements.emptyTranscript.hidden = hasText;
elements.segments.hidden = !hasText;
const existing = new Map(
Array.from(elements.segments.children, (item) => [item.dataset.segmentId, item]),
);
for (const segment of state.segments) {
if (!segment.text) continue;
let item = existing.get(segment.id);
const isNew = !item;
if (isNew) {
item = document.createElement("li");
item.className = "segment message user";
item.dataset.segmentId = segment.id;
const paragraph = document.createElement("p");
paragraph.className = "segment-text message-bubble";
item.append(paragraph);
elements.segments.appendChild(item);
}
item.classList.toggle("preview", segment.id === "live-preview");
existing.delete(segment.id);
const paragraph = item.querySelector(".segment-text");
if (paragraph.dataset.text !== segment.text) {
updateWords(paragraph, segment, animateNew && isNew);
paragraph.dataset.text = segment.text;
}
}
for (const item of existing.values()) item.remove();
elements.copyButton.disabled = !hasText;
elements.downloadButton.disabled = !hasText;
elements.clearButton.disabled = !hasText;
if (hasText) {
requestAnimationFrame(() => {
elements.transcriptPanel.scrollTop = elements.transcriptPanel.scrollHeight;
});
}
}
function clearTranscript() {
state.pncRequestId += 1;
state.nextSegmentId = 0;
state.rawSegments = [];
state.segments = [];
state.previewSegment = null;
state.punctuationApplied = false;
state.currentConversationId = null;
state.currentConversationCreatedAt = null;
elements.segments.replaceChildren();
renderTranscript();
setConversationVisible(false);
if (!state.recording) {
elements.app.classList.remove("session-active");
document.body.classList.remove("session-active");
}
}
function ensurePunctuation() {
if (state.pncInitializationRequested) return;
state.pncInitializationRequested = true;
pncWorker.postMessage({type: "init", configUrl: signedAsset("model-config.json")});
}
function requestPunctuation() {
if (state.pncFailed || !state.rawSegments.length) return;
ensurePunctuation();
state.pncRequestId += 1;
pncWorker.postMessage({
type: "punctuate",
requestId: state.pncRequestId,
configUrl: signedAsset("model-config.json"),
segments: state.rawSegments,
});
}
class StreamingLinearResampler {
constructor(sourceRate, targetRate) {
this.sourceRate = sourceRate;
this.targetRate = targetRate;
this.ratio = sourceRate / targetRate;
this.tail = new Float32Array(0);
this.position = 0;
}
process(samples) {
if (this.sourceRate === this.targetRate) return new Float32Array(samples);
const input = new Float32Array(this.tail.length + samples.length);
input.set(this.tail);
input.set(samples, this.tail.length);
const output = [];
while (this.position < input.length - 1) {
const left = Math.floor(this.position);
const fraction = this.position - left;
output.push(input[left] * (1 - fraction) + input[left + 1] * fraction);
this.position += this.ratio;
}
const consumed = Math.min(Math.floor(this.position), Math.max(0, input.length - 1));
this.tail = input.slice(consumed);
this.position -= consumed;
return Float32Array.from(output);
}
reset() {
this.tail = new Float32Array(0);
this.position = 0;
}
}
function appendResult(result, {animate = true} = {}) {
state.punctuationApplied = false;
state.previewSegment = null;
state.segments = state.segments.filter((segment) => segment.id !== "live-preview");
const segment = {
...result,
id: `segment-${state.nextSegmentId}`,
rawText: result.text,
};
state.nextSegmentId += 1;
state.rawSegments.push(segment);
state.segments.push({...segment});
setConversationVisible(true);
renderTranscript({animateNew: animate});
}
async function startRecording() {
if (!state.ready || state.recording || state.stopping || state.startingRecording) return;
state.startingRecording = true;
elements.recordButton.disabled = true;
setStatus("Venter på mikrofonadgang", "Vælg Tillad i browserens vindue");
try {
state.microphone = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: true,
},
});
state.microphonePermissionGranted = true;
state.audioContext = new AudioContext({sampleRate: 16000});
if (state.audioContext.state === "suspended") await state.audioContext.resume();
await state.audioContext.audioWorklet.addModule(signedAsset("capture-worklet.js?v=20260926-4"));
state.sourceNode = state.audioContext.createMediaStreamSource(state.microphone);
state.captureNode = new AudioWorkletNode(state.audioContext, "ekko-capture");
const captureSampleRate = state.audioContext.sampleRate;
state.resampler = new StreamingLinearResampler(captureSampleRate, 16000);
state.silentGain = state.audioContext.createGain();
state.silentGain.gain.value = 0;
state.captureNode.port.onmessage = (event) => {
if (event.data?.type === "stopped") {
state.captureStopResolve?.();
return;
}
if (!state.recording && !state.stopping) return;
const original = event.data;
const samples = state.resampler.process(original);
if (!samples.length) return;
let energy = 0;
for (const sample of samples) energy += sample * sample;
state.level = Math.min(1, Math.sqrt(energy / Math.max(1, samples.length)) * 8);
worker.postMessage({type: "live-chunk", samples: samples.buffer}, [samples.buffer]);
};
clearTranscript();
state.captureDrainTimedOut = false;
state.currentConversationId = typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
state.currentConversationCreatedAt = new Date().toISOString();
elements.app.classList.add("session-active");
document.body.classList.add("session-active");
ensurePunctuation();
worker.postMessage({type: "live-start"});
state.recording = true;
state.sourceNode.connect(state.captureNode);
state.captureNode.connect(state.silentGain);
state.silentGain.connect(state.audioContext.destination);
elements.recordButton.classList.add("recording");
elements.recordButton.setAttribute("aria-label", "Stop optagelse");
elements.recordLabel.textContent = "Lytter";
setStatus("Mikrofon aktiv", "Alt behandles lokalt", "ready");
} catch (error) {
state.recording = false;
if (error.name === "NotAllowedError" || error.name === "PermissionDeniedError") {
state.microphonePermissionGranted = false;
}
state.captureNode?.disconnect();
state.sourceNode?.disconnect();
state.silentGain?.disconnect();
state.microphone?.getTracks().forEach((track) => track.stop());
await state.audioContext?.close();
state.captureNode = null;
state.sourceNode = null;
state.silentGain = null;
state.microphone = null;
state.audioContext = null;
state.resampler = null;
state.currentConversationId = null;
state.currentConversationCreatedAt = null;
elements.app.classList.remove("session-active");
document.body.classList.remove("session-active");
setStatus(
"Mikrofonen kunne ikke startes",
error.name === "NotAllowedError" || error.name === "PermissionDeniedError"
? "Tillad mikrofonen i browserens indstillinger for denne side, og prøv igen"
: error.message,
"error",
);
} finally {
state.startingRecording = false;
elements.recordButton.disabled = !state.ready || state.stopping;
}
}
async function requestStartRecording() {
if (!state.ready || state.recording || state.stopping || state.startingRecording
|| state.checkingMicrophonePermission || elements.microphoneDialog.open) return;
state.checkingMicrophonePermission = true;
try {
let permissionState = null;
try {
permissionState = (await navigator.permissions?.query({name: "microphone"}))?.state || null;
} catch (_) {
// Some browsers do not expose microphone through the Permissions API.
}
if (permissionState === "granted" || (state.microphonePermissionGranted && permissionState !== "denied")) {
startRecording();
return;
}
const blocked = permissionState === "denied";
elements.microphoneTitle.textContent = blocked
? "Mikrofonen er blokeret"
: "Giv Ekko adgang til din mikrofon";
elements.microphoneDescription.textContent = blocked
? "Åbn browserens indstillinger for denne side, og tillad adgang til mikrofonen. Du kan derefter prøve igen."
: "Mikrofonen bruges kun, mens du optager. Lyd og tekst behandles på din enhed, og lyd gemmes ikke.";
elements.microphoneHelp.textContent = blocked
? "Se efter mikrofonikonet eller sideindstillingerne ved adresselinjen."
: "Når du fortsætter, beder browseren dig om at vælge Tillad.";
elements.microphoneCancelButton.textContent = blocked ? "Luk" : "Ikke nu";
elements.microphoneContinueButton.hidden = blocked;
elements.microphoneDialog.showModal();
} finally {
state.checkingMicrophonePermission = false;
}
}
async function stopRecording() {
if (!state.recording || state.stopping) return;
state.recording = false;
state.stopping = true;
elements.recordButton.disabled = true;
elements.recordButton.classList.remove("recording");
elements.recordButton.setAttribute("aria-label", "Begynd optagelse");
elements.recordLabel.textContent = "Færdiggør sidste segment";
if (state.captureNode) {
const drained = await new Promise((resolve) => {
const timeout = window.setTimeout(() => resolve(false), 1000);
state.captureStopResolve = () => {
window.clearTimeout(timeout);
resolve(true);
};
state.captureNode.port.postMessage({type: "stop"});
});
state.captureDrainTimedOut = !drained;
state.captureStopResolve = null;
}
state.captureNode?.disconnect();
state.sourceNode?.disconnect();
state.silentGain?.disconnect();
state.microphone?.getTracks().forEach((track) => track.stop());
await state.audioContext?.close();
state.captureNode = null;
state.sourceNode = null;
state.silentGain = null;
state.microphone = null;
state.audioContext = null;
state.resampler?.reset();
state.resampler = null;
state.level = 0;
worker.postMessage({type: "live-stop"});
}
worker.onmessage = (event) => {
const message = event.data || {};
if (message.type === "model-progress") {
const percent = Math.round(message.overall * 100);
setStatus(message.cached ? "Indlæser gemt model" : "Indlæser lokal model", `${percent}% · ${message.file}`);
setProgress(message.overall);
} else if (message.type === "ready") {
state.ready = true;
elements.recordButton.disabled = false;
elements.recordLabel.textContent = "Klik for at tale";
setStatus("Klar", `${formatBytes(message.bytes)} verificeret`, "ready");
setProgress(1, true);
ensurePunctuation();
} else if (message.type === "live-result") {
appendResult(message.result);
persistCurrentConversation();
requestPunctuation();
} else if (message.type === "live-preview") {
state.previewSegment = {...message.result, id: "live-preview", rawText: message.result.text};
state.segments = [
...state.segments.filter((segment) => segment.id !== "live-preview"),
state.previewSegment,
];
setConversationVisible(true);
renderTranscript();
} else if (message.type === "live-preview-clear") {
state.previewSegment = null;
state.segments = state.segments.filter((segment) => segment.id !== "live-preview");
renderTranscript();
} else if (message.type === "speech-state") {
state.speechDetected = Boolean(message.detected);
if (state.recording) elements.recordLabel.textContent = state.speechDetected ? "Tale registreret" : "Lytter";
} else if (message.type === "live-stopped") {
state.stopping = false;
elements.recordButton.disabled = false;
persistCurrentConversation();
elements.recordLabel.textContent = state.segments.length ? "Tryk for at optage igen" : "Klik for at tale";
setStatus(
state.captureDrainTimedOut ? "Optagelsen kan mangle afslutningen" : "Klar",
state.captureDrainTimedOut
? "Lydkøen blev ikke bekræftet ved stop"
: state.segments.length ? "Optagelse færdig" : "Ingen tale fundet",
state.captureDrainTimedOut ? "error" : "ready",
);
if (!state.segments.length) {
elements.app.classList.remove("session-active");
document.body.classList.remove("session-active");
}
} else if (message.type === "error") {
state.busy = false;
elements.recordButton.disabled = !state.ready;
setStatus("Noget gik galt", message.message, "error");
}
};
pncWorker.onmessage = (event) => {
const message = event.data || {};
if (message.type === "pnc-progress") {
elements.pncStatus.textContent = `${message.cached ? "Indlæser gemt model" : "Indlæser lokalt"} · ${Math.round(message.overall * 100)}%`;
} else if (message.type === "pnc-ready") {
state.pncReady = true;
elements.pncStatus.textContent = `Klar · ${formatBytes(message.bytes)}`;
requestPunctuation();
} else if (message.type === "pnc-result") {
if (message.requestId !== state.pncRequestId) return;
state.punctuationApplied = true;
state.segments = [...message.segments, ...(state.previewSegment ? [state.previewSegment] : [])];
renderTranscript();
persistCurrentConversation();
} else if (message.type === "pnc-error") {
state.pncFailed = true;
elements.pncStatus.textContent = "Tegnsætning utilgængelig";
console.error(message.message);
}
};
worker.onerror = (event) => {
console.error(event.message);
setStatus("WASM-runtime kunne ikke startes", "Genindlæs siden", "error");
};
elements.recordButton.addEventListener("click", () => state.recording ? stopRecording() : requestStartRecording());
elements.microphoneCloseButton.addEventListener("click", () => elements.microphoneDialog.close());
elements.microphoneCancelButton.addEventListener("click", () => elements.microphoneDialog.close());
elements.microphoneContinueButton.addEventListener("click", () => {
elements.microphoneDialog.close();
startRecording();
});
elements.clearButton.addEventListener("click", clearTranscript);
elements.historyList.addEventListener("click", (event) => {
const openButton = event.target.closest("[data-history-open]");
if (openButton) {
loadConversation(openButton.dataset.historyOpen);
return;
}
const deleteButton = event.target.closest("[data-history-delete]");
if (deleteButton) deleteConversation(deleteButton.dataset.historyDelete);
});
elements.clearHistoryButton.addEventListener("click", () => {
if (state.recording || state.stopping) return;
if (!window.confirm("Slet alle gemte samtaler fra denne browser?")) return;
state.history = [];
state.currentConversationId = null;
writeConversationHistory();
});
elements.copyButton.addEventListener("click", async () => {
await navigator.clipboard.writeText(transcriptText());
elements.copyButton.textContent = "✓";
window.setTimeout(() => { elements.copyButton.textContent = "⧉"; }, 1200);
});
elements.downloadButton.addEventListener("click", () => {
const payload = {
schema: "ekko-browser-transcript-v1",
model: "Ekko v1 Tiny int8",
generatedAt: new Date().toISOString(),
punctuation: state.punctuationApplied ? "Ekko PnC v2 int8" : "pending_or_unavailable",
text: transcriptText(),
segments: state.segments,
};
const url = URL.createObjectURL(new Blob([JSON.stringify(payload, null, 2)], {type: "application/json"}));
const link = document.createElement("a");
link.href = url;
link.download = "ekko-transcript.json";
link.click();
URL.revokeObjectURL(url);
});
elements.themeButton.addEventListener("click", () => {
const theme = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
setTheme(theme);
try { localStorage.setItem(THEME_STORAGE_KEY, theme); } catch { /* Storage may be disabled. */ }
});
elements.infoButton.addEventListener("click", () => elements.infoDialog.showModal());
elements.settingsButton.addEventListener("click", () => {
renderHistory();
elements.settingsDialog.showModal();
});
const ORB_VERTEX_SHADER = `#version 300 es
in vec2 position;
void main(){gl_Position=vec4(position,0.0,1.0);}`;
const ORB_FRAGMENT_SHADER = `#version 300 es
precision highp float;
uniform vec2 iResolution;
uniform float iTime;
uniform float uTimeSpeed;
uniform float uColorBalance;
uniform float uWarpStrength;
uniform float uWarpFrequency;
uniform float uWarpSpeed;
uniform float uWarpAmplitude;
uniform float uBlendAngle;
uniform float uBlendSoftness;
uniform float uRotationAmount;
uniform float uNoiseScale;
uniform float uGrainAmount;
uniform float uGrainScale;
uniform float uGrainAnimated;
uniform float uContrast;
uniform float uGamma;
uniform float uSaturation;
uniform vec2 uCenterOffset;
uniform float uZoom;
uniform vec3 uColor1;
uniform vec3 uColor2;
uniform vec3 uColor3;
out vec4 fragColor;
#define S(a,b,t) smoothstep(a,b,t)
mat2 Rot(float a){float s=sin(a),c=cos(a);return mat2(c,-s,s,c);}
vec2 hash(vec2 p){p=vec2(dot(p,vec2(2127.1,81.17)),dot(p,vec2(1269.5,283.37)));return fract(sin(p)*43758.5453);}
float noise(vec2 p){vec2 i=floor(p),f=fract(p),u=f*f*(3.0-2.0*f);float n=mix(mix(dot(-1.0+2.0*hash(i+vec2(0.0,0.0)),f-vec2(0.0,0.0)),dot(-1.0+2.0*hash(i+vec2(1.0,0.0)),f-vec2(1.0,0.0)),u.x),mix(dot(-1.0+2.0*hash(i+vec2(0.0,1.0)),f-vec2(0.0,1.0)),dot(-1.0+2.0*hash(i+vec2(1.0,1.0)),f-vec2(1.0,1.0)),u.x),u.y);return 0.5+0.5*n;}
void mainImage(out vec4 o,vec2 C){
float t=iTime*uTimeSpeed;
vec2 uv=C/iResolution.xy;
float ratio=iResolution.x/iResolution.y;
vec2 tuv=uv-0.5+uCenterOffset;
tuv/=max(uZoom,0.001);
float degree=noise(vec2(t*0.1,tuv.x*tuv.y)*uNoiseScale);
tuv.y*=1.0/ratio;
tuv*=Rot(radians((degree-0.5)*uRotationAmount+180.0));
tuv.y*=ratio;
float frequency=uWarpFrequency;
float ws=max(uWarpStrength,0.001);
float amplitude=uWarpAmplitude/ws;
float warpTime=t*uWarpSpeed;
tuv.x+=sin(tuv.y*frequency+warpTime)/amplitude;
tuv.y+=sin(tuv.x*(frequency*1.5)+warpTime)/(amplitude*0.5);
vec3 colLav=uColor1;vec3 colOrg=uColor2;vec3 colDark=uColor3;
float b=uColorBalance;float ss=max(uBlendSoftness,0.0);
mat2 blendRot=Rot(radians(uBlendAngle));
float blendX=(tuv*blendRot).x;
float edge0=-0.3-b-ss;float edge1=0.2-b+ss;float v0=0.5-b+ss;float v1=-0.3-b-ss;
vec3 layer1=mix(colDark,colOrg,S(edge0,edge1,blendX));
vec3 layer2=mix(colOrg,colLav,S(edge0,edge1,blendX));
vec3 col=mix(layer1,layer2,S(v0,v1,tuv.y));
vec2 grainUv=uv*max(uGrainScale,0.001);
if(uGrainAnimated>0.5){grainUv+=vec2(iTime*0.05);}
float grain=fract(sin(dot(grainUv,vec2(12.9898,78.233)))*43758.5453);
col+=(grain-0.5)*uGrainAmount;
col=(col-0.5)*uContrast+0.5;
float luma=dot(col,vec3(0.2126,0.7152,0.0722));
col=mix(vec3(luma),col,uSaturation);
col=pow(max(col,0.0),vec3(1.0/max(uGamma,0.001)));
col=clamp(col,0.0,1.0);
o=vec4(col,1.0);
}
void main(){vec4 o=vec4(0.0);mainImage(o,gl_FragCoord.xy);fragColor=o;}`;
function hexRgb(hex) {
const match = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return match
? new Float32Array([
parseInt(match[1], 16) / 255,
parseInt(match[2], 16) / 255,
parseInt(match[3], 16) / 255,
])
: new Float32Array([1, 1, 1]);
}
function initGrainient() {
const canvas = document.createElement("canvas");
elements.orb.appendChild(canvas);
const gl = canvas.getContext("webgl2", {alpha: false, antialias: false});
if (!gl) return;
const makeShader = (type, source) => {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
return shader;
};
const program = gl.createProgram();
gl.attachShader(program, makeShader(gl.VERTEX_SHADER, ORB_VERTEX_SHADER));
gl.attachShader(program, makeShader(gl.FRAGMENT_SHADER, ORB_FRAGMENT_SHADER));
gl.linkProgram(program);
gl.useProgram(program);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const position = gl.getAttribLocation(program, "position");
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
const uniform = (name) => gl.getUniformLocation(program, name);
const uniforms = {
time: uniform("iTime"), resolution: uniform("iResolution"), timeSpeed: uniform("uTimeSpeed"),
colorBalance: uniform("uColorBalance"), warpStrength: uniform("uWarpStrength"),
warpFrequency: uniform("uWarpFrequency"), warpSpeed: uniform("uWarpSpeed"),
warpAmplitude: uniform("uWarpAmplitude"), blendAngle: uniform("uBlendAngle"),
blendSoftness: uniform("uBlendSoftness"), rotation: uniform("uRotationAmount"),
noiseScale: uniform("uNoiseScale"), grainAmount: uniform("uGrainAmount"),
grainScale: uniform("uGrainScale"), grainAnimated: uniform("uGrainAnimated"),
contrast: uniform("uContrast"), gamma: uniform("uGamma"), saturation: uniform("uSaturation"),
center: uniform("uCenterOffset"), zoom: uniform("uZoom"), color1: uniform("uColor1"),
color2: uniform("uColor2"), color3: uniform("uColor3"),
};
gl.uniform1f(uniforms.colorBalance, 0);
gl.uniform1f(uniforms.blendAngle, 0);
gl.uniform1f(uniforms.blendSoftness, 0.05);
gl.uniform1f(uniforms.noiseScale, 2);
gl.uniform1f(uniforms.grainAmount, 0.045);
gl.uniform1f(uniforms.grainScale, 2);
gl.uniform1f(uniforms.grainAnimated, 0);
gl.uniform1f(uniforms.gamma, 1);
gl.uniform2f(uniforms.center, 0, 0);
gl.uniform1f(uniforms.timeSpeed, 0.25);
gl.uniform1f(uniforms.warpAmplitude, 16);
gl.uniform1f(uniforms.warpSpeed, 5);
gl.uniform1f(uniforms.warpStrength, 1);
gl.uniform1f(uniforms.warpFrequency, 1);
gl.uniform1f(uniforms.rotation, 100);
gl.uniform1f(uniforms.zoom, 0.95);
gl.uniform1f(uniforms.contrast, 1.5);
gl.uniform1f(uniforms.saturation, 1);
gl.uniform3fv(uniforms.color1, hexRgb("#27AE60"));
gl.uniform3fv(uniforms.color2, hexRgb("#8CBFFF"));
gl.uniform3fv(uniforms.color3, hexRgb("#FFAAAD"));
const resize = () => {
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const rectangle = elements.orb.getBoundingClientRect();
canvas.width = Math.ceil(rectangle.width * ratio);
canvas.height = Math.ceil(rectangle.height * ratio);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(uniforms.resolution, canvas.width, canvas.height);
};
new ResizeObserver(resize).observe(elements.orb);
resize();
const startedAt = performance.now();
let animationFrame = 0;
function render(time) {
animationFrame = requestAnimationFrame(render);
gl.uniform1f(uniforms.time, (time - startedAt) * 0.001);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
render(performance.now());
document.addEventListener("visibilitychange", () => {
if (document.hidden) cancelAnimationFrame(animationFrame);
else render(performance.now());
});
}
function resizeCanvas(canvas) {
const ratio = Math.min(2, window.devicePixelRatio || 1);
const rectangle = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.round(rectangle.width * ratio));
canvas.height = Math.max(1, Math.round(rectangle.height * ratio));
return ratio;
}
let ambientRatio = resizeCanvas(elements.ambient);
window.addEventListener("resize", () => { ambientRatio = resizeCanvas(elements.ambient); });
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let smoothedLevel = 0;
function drawVisuals(timestamp) {
const time = reduceMotion ? 0 : timestamp / 1000;
const target = state.recording ? state.level : 0;
smoothedLevel += (target - smoothedLevel) * (target > smoothedLevel ? 0.3 : 0.075);
if (smoothedLevel < 0.006) {
elements.orb.style.scale = "";
elements.ring.style.scale = "";
} else {
elements.orb.style.scale = String(1 + smoothedLevel * 0.085);
elements.ring.style.scale = String(1 + smoothedLevel * 0.18);
}
const ambient = elements.ambient.getContext("2d");
const aw = elements.ambient.width;
const ah = elements.ambient.height;
ambient.clearRect(0, 0, aw, ah);
if (state.recording) {
ambient.beginPath();
for (let x = 0; x <= aw; x += 12 * ambientRatio) {
const y = ah * 0.72 + Math.sin(x / (74 * ambientRatio) + time * 3) * (14 + state.level * 28) * ambientRatio;
x === 0 ? ambient.moveTo(x, y) : ambient.lineTo(x, y);
}
ambient.strokeStyle = "rgba(198,12,46,0.18)";
ambient.lineWidth = ambientRatio;
ambient.stroke();
}
state.level *= 0.9;
requestAnimationFrame(drawVisuals);
}
state.history = readConversationHistory();
renderHistory();
renderTranscript();
initGrainient();
requestAnimationFrame(drawVisuals);
worker.postMessage({type: "init", configUrl: signedAsset("model-config.json")});