Update voice/static/index.html
Browse files- voice/static/index.html +93 -43
voice/static/index.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
-
<title>
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Pixelify+Sans:wght@400;500;600;700&family=VT323&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
|
@@ -1146,8 +1146,8 @@
|
|
| 1146 |
</svg>
|
| 1147 |
</div>
|
| 1148 |
<div class="brand-text">
|
| 1149 |
-
<h1>
|
| 1150 |
-
<div class="sub">
|
| 1151 |
</div>
|
| 1152 |
</div>
|
| 1153 |
<div class="header-meta">
|
|
@@ -1761,18 +1761,74 @@ document.getElementById("msg-input").addEventListener("keydown", (e) => {
|
|
| 1761 |
if (e.key === "Enter") sendText();
|
| 1762 |
});
|
| 1763 |
|
| 1764 |
-
// ─── Voice mode ────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1765 |
async function toggleRecording() {
|
| 1766 |
if (!sessionId || terminated) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1767 |
|
| 1768 |
if (!isRecording) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1769 |
try {
|
| 1770 |
-
|
| 1771 |
-
mediaRecorder = new MediaRecorder(stream, {mimeType: "audio/webm;codecs=opus"});
|
| 1772 |
-
recordedChunks = [];
|
| 1773 |
-
mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) recordedChunks.push(e.data); };
|
| 1774 |
-
mediaRecorder.onstop = () => sendVoice();
|
| 1775 |
-
mediaRecorder.start();
|
| 1776 |
isRecording = true;
|
| 1777 |
recordStart = performance.now();
|
| 1778 |
document.getElementById("mic-btn").classList.add("recording");
|
|
@@ -1783,56 +1839,50 @@ async function toggleRecording() {
|
|
| 1783 |
}, 50);
|
| 1784 |
} catch (err) {
|
| 1785 |
toast("Microphone access denied", "error");
|
|
|
|
| 1786 |
}
|
| 1787 |
} else {
|
| 1788 |
-
|
| 1789 |
-
mediaRecorder.stream.getTracks().forEach(t => t.stop());
|
| 1790 |
-
isRecording = false;
|
| 1791 |
-
clearInterval(recordTimer);
|
| 1792 |
-
document.getElementById("mic-btn").classList.remove("recording");
|
| 1793 |
-
document.getElementById("rec-indicator").classList.remove("active");
|
| 1794 |
}
|
| 1795 |
}
|
| 1796 |
|
| 1797 |
-
async function
|
| 1798 |
-
const
|
| 1799 |
-
|
| 1800 |
-
|
| 1801 |
-
|
| 1802 |
-
|
| 1803 |
-
|
| 1804 |
-
|
| 1805 |
-
log.appendChild(placeholder);
|
| 1806 |
-
log.scrollTop = log.scrollHeight;
|
| 1807 |
|
| 1808 |
appendTypingBubble("rp");
|
| 1809 |
-
|
| 1810 |
-
const fd = new FormData();
|
| 1811 |
-
fd.append("audio", blob, "clip.webm");
|
| 1812 |
try {
|
| 1813 |
-
const res = await fetch(`/api/step
|
| 1814 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1815 |
const j = await res.json();
|
| 1816 |
-
placeholder.querySelector(".bubble").classList.remove("transcribing");
|
| 1817 |
-
placeholder.querySelector(".bubble").innerHTML = `<div class="meta">You</div>${escapeHtml(j.transcribed || "")}`;
|
| 1818 |
if (pendingTypingBubble) { pendingTypingBubble.remove(); pendingTypingBubble = null; }
|
| 1819 |
applyObservation(j.observation);
|
| 1820 |
} catch (e) {
|
| 1821 |
-
placeholder.querySelector(".bubble").innerHTML = `<div class="meta">You</div><em style="color:var(--red)">voice step failed</em>`;
|
| 1822 |
if (pendingTypingBubble) { pendingTypingBubble.remove(); pendingTypingBubble = null; }
|
| 1823 |
-
toast("
|
| 1824 |
}
|
| 1825 |
}
|
| 1826 |
|
| 1827 |
-
|
|
|
|
| 1828 |
try {
|
| 1829 |
-
|
| 1830 |
-
|
| 1831 |
-
const
|
| 1832 |
-
const
|
| 1833 |
-
|
| 1834 |
-
|
| 1835 |
-
|
|
|
|
| 1836 |
} catch (e) { /* swallow */ }
|
| 1837 |
}
|
| 1838 |
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>CIPHER — Contextual-Integrity Privacy</title>
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Pixelify+Sans:wght@400;500;600;700&family=VT323&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
|
|
|
| 1146 |
</svg>
|
| 1147 |
</div>
|
| 1148 |
<div class="brand-text">
|
| 1149 |
+
<h1>CIPHER</h1>
|
| 1150 |
+
<div class="sub">CONTEXTUAL-INTEGRITY PRIVACY <span class="dim">·</span> ADVERSARY-AWARE</div>
|
| 1151 |
</div>
|
| 1152 |
</div>
|
| 1153 |
<div class="header-meta">
|
|
|
|
| 1761 |
if (e.key === "Enter") sendText();
|
| 1762 |
});
|
| 1763 |
|
| 1764 |
+
// ─── Voice mode (browser Web Speech API — no server deps) ────────────────────
|
| 1765 |
+
const _SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| 1766 |
+
let recognition = null;
|
| 1767 |
+
let voiceTranscript = "";
|
| 1768 |
+
let voicePlaceholder = null;
|
| 1769 |
+
|
| 1770 |
+
if (_SR) {
|
| 1771 |
+
recognition = new _SR();
|
| 1772 |
+
recognition.continuous = true;
|
| 1773 |
+
recognition.interimResults = true;
|
| 1774 |
+
recognition.lang = "en-US";
|
| 1775 |
+
recognition.onresult = (e) => {
|
| 1776 |
+
let interim = "";
|
| 1777 |
+
for (let i = e.resultIndex; i < e.results.length; i++) {
|
| 1778 |
+
const t = e.results[i][0].transcript;
|
| 1779 |
+
if (e.results[i].isFinal) voiceTranscript += t;
|
| 1780 |
+
else interim += t;
|
| 1781 |
+
}
|
| 1782 |
+
if (voicePlaceholder) {
|
| 1783 |
+
const display = (voiceTranscript + interim).trim();
|
| 1784 |
+
voicePlaceholder.querySelector(".bubble").innerHTML =
|
| 1785 |
+
`<div class="meta">You</div>${display ? escapeHtml(display) : "<em>listening…</em>"}`;
|
| 1786 |
+
}
|
| 1787 |
+
};
|
| 1788 |
+
recognition.onerror = (e) => {
|
| 1789 |
+
if (voicePlaceholder) {
|
| 1790 |
+
voicePlaceholder.querySelector(".bubble").innerHTML =
|
| 1791 |
+
`<div class="meta">You</div><em style="color:var(--red)">voice error: ${escapeHtml(e.error || "unknown")}</em>`;
|
| 1792 |
+
voicePlaceholder = null;
|
| 1793 |
+
}
|
| 1794 |
+
if (isRecording) {
|
| 1795 |
+
isRecording = false;
|
| 1796 |
+
clearInterval(recordTimer);
|
| 1797 |
+
document.getElementById("mic-btn").classList.remove("recording");
|
| 1798 |
+
document.getElementById("rec-indicator").classList.remove("active");
|
| 1799 |
+
}
|
| 1800 |
+
};
|
| 1801 |
+
recognition.onend = () => {
|
| 1802 |
+
if (isRecording) {
|
| 1803 |
+
isRecording = false;
|
| 1804 |
+
clearInterval(recordTimer);
|
| 1805 |
+
document.getElementById("mic-btn").classList.remove("recording");
|
| 1806 |
+
document.getElementById("rec-indicator").classList.remove("active");
|
| 1807 |
+
}
|
| 1808 |
+
finalizeVoice();
|
| 1809 |
+
};
|
| 1810 |
+
}
|
| 1811 |
+
|
| 1812 |
async function toggleRecording() {
|
| 1813 |
if (!sessionId || terminated) return;
|
| 1814 |
+
if (!recognition) {
|
| 1815 |
+
toast("Voice mode needs Chrome / Edge / Safari", "error");
|
| 1816 |
+
return;
|
| 1817 |
+
}
|
| 1818 |
|
| 1819 |
if (!isRecording) {
|
| 1820 |
+
voiceTranscript = "";
|
| 1821 |
+
const log = document.getElementById("chat-log");
|
| 1822 |
+
voicePlaceholder = document.createElement("div");
|
| 1823 |
+
voicePlaceholder.className = "msg you";
|
| 1824 |
+
voicePlaceholder.innerHTML = `
|
| 1825 |
+
<div class="avatar you">P1</div>
|
| 1826 |
+
<div class="bubble"><div class="meta">You</div><em>listening…</em></div>`;
|
| 1827 |
+
log.appendChild(voicePlaceholder);
|
| 1828 |
+
log.scrollTop = log.scrollHeight;
|
| 1829 |
+
|
| 1830 |
try {
|
| 1831 |
+
recognition.start();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1832 |
isRecording = true;
|
| 1833 |
recordStart = performance.now();
|
| 1834 |
document.getElementById("mic-btn").classList.add("recording");
|
|
|
|
| 1839 |
}, 50);
|
| 1840 |
} catch (err) {
|
| 1841 |
toast("Microphone access denied", "error");
|
| 1842 |
+
if (voicePlaceholder) { voicePlaceholder.remove(); voicePlaceholder = null; }
|
| 1843 |
}
|
| 1844 |
} else {
|
| 1845 |
+
try { recognition.stop(); } catch (e) {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1846 |
}
|
| 1847 |
}
|
| 1848 |
|
| 1849 |
+
async function finalizeVoice() {
|
| 1850 |
+
const text = voiceTranscript.trim();
|
| 1851 |
+
if (voicePlaceholder) {
|
| 1852 |
+
voicePlaceholder.querySelector(".bubble").innerHTML =
|
| 1853 |
+
`<div class="meta">You</div>${text ? escapeHtml(text) : "<em>(no speech detected)</em>"}`;
|
| 1854 |
+
voicePlaceholder = null;
|
| 1855 |
+
}
|
| 1856 |
+
if (!text) return;
|
|
|
|
|
|
|
| 1857 |
|
| 1858 |
appendTypingBubble("rp");
|
|
|
|
|
|
|
|
|
|
| 1859 |
try {
|
| 1860 |
+
const res = await fetch(`/api/step/${sessionId}`, {
|
| 1861 |
+
method: "POST",
|
| 1862 |
+
headers: {"Content-Type": "application/json"},
|
| 1863 |
+
body: JSON.stringify({message: text}),
|
| 1864 |
+
});
|
| 1865 |
+
if (!res.ok) throw new Error("step failed");
|
| 1866 |
const j = await res.json();
|
|
|
|
|
|
|
| 1867 |
if (pendingTypingBubble) { pendingTypingBubble.remove(); pendingTypingBubble = null; }
|
| 1868 |
applyObservation(j.observation);
|
| 1869 |
} catch (e) {
|
|
|
|
| 1870 |
if (pendingTypingBubble) { pendingTypingBubble.remove(); pendingTypingBubble = null; }
|
| 1871 |
+
toast("Step failed", "error");
|
| 1872 |
}
|
| 1873 |
}
|
| 1874 |
|
| 1875 |
+
function playTTS(text, _voice) {
|
| 1876 |
+
if (!window.speechSynthesis || !text) return;
|
| 1877 |
try {
|
| 1878 |
+
speechSynthesis.cancel();
|
| 1879 |
+
const u = new SpeechSynthesisUtterance(text);
|
| 1880 |
+
const voices = speechSynthesis.getVoices();
|
| 1881 |
+
const en = voices.find(v => /^en[-_]/i.test(v.lang)) || voices[0];
|
| 1882 |
+
if (en) u.voice = en;
|
| 1883 |
+
u.rate = 1.0;
|
| 1884 |
+
u.pitch = 1.0;
|
| 1885 |
+
speechSynthesis.speak(u);
|
| 1886 |
} catch (e) { /* swallow */ }
|
| 1887 |
}
|
| 1888 |
|