From 35d645a186300343c29baf969c51ad058f6e3ae6 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Sun, 1 Mar 2026 03:03:24 +0800 Subject: [PATCH] feat: add mobile web frontend with AudioWorklet recording --- web/app.js | 313 +++++++++++++++++++++++++++++++++++++++++ web/audio-processor.js | 73 ++++++++++ web/index.html | 48 +++++++ web/style.css | 254 +++++++++++++++++++++++++++++++++ 4 files changed, 688 insertions(+) create mode 100644 web/app.js create mode 100644 web/audio-processor.js create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..49c0e57 --- /dev/null +++ b/web/app.js @@ -0,0 +1,313 @@ +/** + * VoicePaste — Main application logic. + * + * Modules: + * 1. WebSocket client (token auth, reconnect) + * 2. Audio pipeline (getUserMedia → AudioWorklet → resample → WS binary) + * 3. Recording controls (touch/mouse, state machine) + * 4. History (localStorage, tap to re-send) + * 5. UI state management + */ +;(function () { + "use strict"; + + // ── Constants ── + const TARGET_SAMPLE_RATE = 16000; + const WS_RECONNECT_BASE = 1000; + const WS_RECONNECT_MAX = 16000; + const HISTORY_KEY = "voicepaste_history"; + const HISTORY_MAX = 50; + + // ── DOM refs ── + const $ = (sel) => document.querySelector(sel); + const statusEl = $("#status"); + const statusText = $("#status-text"); + const previewText = $("#preview-text"); + const previewBox = $("#preview"); + const micBtn = $("#mic-btn"); + const historyList = $("#history-list"); + const historyEmpty = $("#history-empty"); + const clearHistoryBtn = $("#clear-history"); + + // ── State ── + const state = { + ws: null, + connected: false, + recording: false, + audioCtx: null, + workletNode: null, + stream: null, + reconnectDelay: WS_RECONNECT_BASE, + reconnectTimer: null, + }; + + // ── Utility ── + function getToken() { + const params = new URLSearchParams(location.search); + return params.get("token") || ""; + } + + function formatTime(ts) { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${hh}:${mm}`; + } + + // ── Resampler (linear interpolation, native rate → 16kHz 16-bit mono) ── + function resampleTo16kInt16(float32, srcRate) { + const ratio = srcRate / TARGET_SAMPLE_RATE; + const outLen = Math.floor(float32.length / ratio); + const out = new Int16Array(outLen); + for (let i = 0; i < outLen; i++) { + const srcIdx = i * ratio; + const lo = Math.floor(srcIdx); + const hi = Math.min(lo + 1, float32.length - 1); + const frac = srcIdx - lo; + const sample = float32[lo] + frac * (float32[hi] - float32[lo]); + // Clamp to [-1, 1] then scale to Int16 + out[i] = Math.max(-32768, Math.min(32767, Math.round(sample * 32767))); + } + return out; + } + // ── WebSocket ── + function wsUrl() { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const token = getToken(); + const q = token ? `?token=${encodeURIComponent(token)}` : ""; + return `${proto}//${location.host}/ws${q}`; + } + function setStatus(cls, text) { + statusEl.className = `status ${cls}`; + statusText.textContent = text; + } + function connectWS() { + if (state.ws) return; + setStatus("connecting", "连接中…"); + const ws = new WebSocket(wsUrl()); + ws.binaryType = "arraybuffer"; + ws.onopen = () => { + state.connected = true; + state.reconnectDelay = WS_RECONNECT_BASE; + setStatus("connected", "已连接"); + micBtn.disabled = false; + }; + ws.onmessage = (e) => handleServerMsg(e.data); + ws.onclose = () => { + state.connected = false; + state.ws = null; + micBtn.disabled = true; + if (state.recording) stopRecording(); + setStatus("disconnected", "已断开"); + scheduleReconnect(); + }; + ws.onerror = () => ws.close(); + state.ws = ws; + } + function scheduleReconnect() { + clearTimeout(state.reconnectTimer); + state.reconnectTimer = setTimeout(() => { + connectWS(); + }, state.reconnectDelay); + state.reconnectDelay = Math.min(state.reconnectDelay * 2, WS_RECONNECT_MAX); + } + function sendJSON(obj) { + if (state.ws && state.ws.readyState === WebSocket.OPEN) { + state.ws.send(JSON.stringify(obj)); + } + } + function sendBinary(int16arr) { + if (state.ws && state.ws.readyState === WebSocket.OPEN) { + state.ws.send(int16arr.buffer); + } + } + // ── Server message handler ── + function handleServerMsg(data) { + if (typeof data !== "string") return; + let msg; + try { msg = JSON.parse(data); } catch { return; } + switch (msg.type) { + case "partial": + setPreview(msg.text, false); + break; + case "final": + setPreview(msg.text, true); + if (msg.text) addHistory(msg.text); + break; + case "pasted": + showToast("✅ 已粘贴"); + break; + case "error": + showToast(`❌ ${msg.message || "错误"}`); + break; + } + } + function setPreview(text, isFinal) { + if (!text) { + previewText.textContent = "按住说话…"; + previewText.classList.add("placeholder"); + previewBox.classList.remove("active"); + return; + } + previewText.textContent = text; + previewText.classList.remove("placeholder"); + previewBox.classList.toggle("active", !isFinal); + } + function showToast(msg) { + // Lightweight toast — reuse or create + let toast = $("#toast"); + if (!toast) { + toast = document.createElement("div"); + toast.id = "toast"; + toast.style.cssText = + "position:fixed;bottom:calc(100px + var(--safe-bottom,0px));left:50%;" + + "transform:translateX(-50%);background:#222;color:#eee;padding:8px 18px;" + + "border-radius:20px;font-size:14px;z-index:999;opacity:0;transition:opacity .3s;"; + document.body.appendChild(toast); + } + toast.textContent = msg; + toast.style.opacity = "1"; + clearTimeout(toast._timer); + toast._timer = setTimeout(() => { toast.style.opacity = "0"; }, 2000); + } + // ── Audio pipeline ── + async function initAudio() { + if (state.audioCtx) return; + const audioCtx = new (window.AudioContext || window.webkitAudioContext)({ + sampleRate: TARGET_SAMPLE_RATE, // Request 16kHz directly (Chrome supports) + }); + await audioCtx.audioWorklet.addModule("audio-processor.js"); + state.audioCtx = audioCtx; + } + async function startRecording() { + if (state.recording) return; + try { + await initAudio(); + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 }, + }); + state.stream = stream; + const source = state.audioCtx.createMediaStreamSource(stream); + const worklet = new AudioWorkletNode(state.audioCtx, "audio-processor"); + worklet.port.onmessage = (e) => { + if (e.data.type === "audio") { + const int16 = resampleTo16kInt16(e.data.samples, e.data.sampleRate); + sendBinary(int16); + } + }; + source.connect(worklet); + // Don't connect worklet to destination (no playback) + state.workletNode = worklet; + state.recording = true; + sendJSON({ type: "start" }); + micBtn.classList.add("recording"); + setPreview("", false); + } catch (err) { + showToast(`麦克风错误: ${err.message}`); + } + } + function stopRecording() { + if (!state.recording) return; + state.recording = false; + // Stop worklet + if (state.workletNode) { + state.workletNode.port.postMessage({ command: "stop" }); + state.workletNode.disconnect(); + state.workletNode = null; + } + // Stop mic stream + if (state.stream) { + state.stream.getTracks().forEach((t) => t.stop()); + state.stream = null; + } + sendJSON({ type: "stop" }); + micBtn.classList.remove("recording"); + } + // ── History (localStorage) ── + function loadHistory() { + try { + return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; + } catch { + return []; + } + } + function saveHistory(items) { + localStorage.setItem(HISTORY_KEY, JSON.stringify(items)); + } + function addHistory(text) { + const items = loadHistory(); + items.unshift({ text, ts: Date.now() }); + if (items.length > HISTORY_MAX) items.length = HISTORY_MAX; + saveHistory(items); + renderHistory(); + } + function clearHistory() { + localStorage.removeItem(HISTORY_KEY); + renderHistory(); + } + function renderHistory() { + const items = loadHistory(); + historyList.innerHTML = ""; + if (!items.length) { + historyEmpty.style.display = ""; + return; + } + historyEmpty.style.display = "none"; + for (const item of items) { + const li = document.createElement("li"); + li.innerHTML = + `${escapeHtml(item.text)}` + + `${formatTime(item.ts)}`; + li.addEventListener("click", () => { + sendJSON({ type: "paste", text: item.text }); + showToast("发送粘贴…"); + }); + historyList.appendChild(li); + } + } + function escapeHtml(s) { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; + } + // ── Event bindings ── + function bindMicButton() { + // Touch events (mobile primary) + micBtn.addEventListener("touchstart", (e) => { + e.preventDefault(); + startRecording(); + }, { passive: false }); + micBtn.addEventListener("touchend", (e) => { + e.preventDefault(); + stopRecording(); + }, { passive: false }); + micBtn.addEventListener("touchcancel", (e) => { + e.preventDefault(); + stopRecording(); + }, { passive: false }); + // Mouse fallback (desktop testing) + micBtn.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + startRecording(); + }); + micBtn.addEventListener("mouseup", () => stopRecording()); + micBtn.addEventListener("mouseleave", () => { + if (state.recording) stopRecording(); + }); + } + // ── Init ── + function init() { + micBtn.disabled = true; + bindMicButton(); + if (clearHistoryBtn) { + clearHistoryBtn.addEventListener("click", clearHistory); + } + renderHistory(); + connectWS(); + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); \ No newline at end of file diff --git a/web/audio-processor.js b/web/audio-processor.js new file mode 100644 index 0000000..edf45f0 --- /dev/null +++ b/web/audio-processor.js @@ -0,0 +1,73 @@ +/** + * AudioWorklet processor for VoicePaste. + * + * Captures raw Float32 PCM from the microphone, accumulates samples into + * ~200ms frames, and posts them to the main thread for resampling + WS send. + * + * Communication: + * Main → Processor: { command: "start" | "stop" } + * Processor → Main: { type: "audio", samples: Float32Array, sampleRate: number } + */ +class AudioProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.recording = false; + this.buffer = []; + this.bufferLen = 0; + // ~200ms worth of samples at current sample rate + // sampleRate is a global in AudioWorkletGlobalScope + this.frameSize = Math.floor(sampleRate * 0.2); + + this.port.onmessage = (e) => { + if (e.data.command === "start") { + this.recording = true; + this.buffer = []; + this.bufferLen = 0; + } else if (e.data.command === "stop") { + // Flush remaining samples + if (this.bufferLen > 0) { + this._flush(); + } + this.recording = false; + } + }; + } + + process(inputs) { + if (!this.recording) return true; + + const input = inputs[0]; + if (!input || !input[0]) return true; + + // Mono channel 0 + const channelData = input[0]; + this.buffer.push(new Float32Array(channelData)); + this.bufferLen += channelData.length; + + if (this.bufferLen >= this.frameSize) { + this._flush(); + } + + return true; + } + + _flush() { + // Merge buffer chunks into a single Float32Array + const merged = new Float32Array(this.bufferLen); + let offset = 0; + for (const chunk of this.buffer) { + merged.set(chunk, offset); + offset += chunk.length; + } + + this.port.postMessage( + { type: "audio", samples: merged, sampleRate: sampleRate }, + [merged.buffer] // Transfer ownership for zero-copy + ); + + this.buffer = []; + this.bufferLen = 0; + } +} + +registerProcessor("audio-processor", AudioProcessor); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..56d2dde --- /dev/null +++ b/web/index.html @@ -0,0 +1,48 @@ + + + + + + + + VoicePaste + + + +
+
+

VoicePaste

+
+ + Connecting... +
+
+ +
+
+

Press and hold to speak

+
+
+ +
+ +
+ +
+
+

History

+ +
+
    +

    No history yet

    +
    +
    + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..7f67d02 --- /dev/null +++ b/web/style.css @@ -0,0 +1,254 @@ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg: #0a0a0a; + --surface: #161616; + --surface-hover: #1e1e1e; + --border: #2a2a2a; + --text: #e8e8e8; + --text-dim: #888; + --accent: #3b82f6; + --accent-glow: rgba(59, 130, 246, 0.3); + --danger: #ef4444; + --success: #22c55e; + --radius: 12px; + --safe-top: env(safe-area-inset-top, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); +} + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + user-select: none; + overflow: hidden; +} + +#app { + display: flex; + flex-direction: column; + height: 100%; + max-width: 480px; + margin: 0 auto; + padding: calc(16px + var(--safe-top)) 16px calc(16px + var(--safe-bottom)); +} + +/* Header */ +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0 16px; + flex-shrink: 0; +} + +header h1 { + font-size: 20px; + font-weight: 600; + letter-spacing: -0.02em; +} + +.status { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-dim); +} + +.status .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-dim); + transition: background 0.3s; +} + +.status.connected .dot { background: var(--success); } +.status.disconnected .dot { background: var(--danger); } +.status.connecting .dot { + background: var(--accent); + animation: pulse 1.2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + + +/* Preview */ +#preview-section { + flex-shrink: 0; + padding-bottom: 16px; +} + +.preview-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + min-height: 80px; + max-height: 160px; + overflow-y: auto; + transition: border-color 0.3s; +} + +.preview-box.active { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent-glow); +} + +#preview-text { + font-size: 16px; + line-height: 1.5; + word-break: break-word; +} + +#preview-text.placeholder { + color: var(--text-dim); + font-style: italic; +} + +/* Mic Button */ +#mic-section { + display: flex; + justify-content: center; + padding: 24px 0; + flex-shrink: 0; +} + +#mic-btn { + width: 88px; + height: 88px; + border-radius: 50%; + border: 2px solid var(--border); + background: var(--surface); + color: var(--text-dim); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + -webkit-user-select: none; + touch-action: none; +} + +#mic-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +#mic-btn:not(:disabled):active, +#mic-btn.recording { + background: var(--accent); + border-color: var(--accent); + color: #fff; + transform: scale(1.08); + box-shadow: 0 0 24px var(--accent-glow); +} + +#mic-btn.recording { + animation: mic-pulse 1s ease-in-out infinite; +} + +@keyframes mic-pulse { + 0%, 100% { box-shadow: 0 0 24px var(--accent-glow); } + 50% { box-shadow: 0 0 48px var(--accent-glow), 0 0 80px rgba(59, 130, 246, 0.15); } +} +/* History */ +#history-section { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.history-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 8px; + flex-shrink: 0; +} +.history-header h2 { + font-size: 15px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.text-btn { + background: none; + border: none; + color: var(--accent); + font-size: 13px; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + transition: background 0.2s; +} +.text-btn:active { + background: rgba(59, 130, 246, 0.1); +} +#history-list { + list-style: none; + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} +#history-list li { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 14px; + margin-bottom: 8px; + font-size: 14px; + line-height: 1.4; + cursor: pointer; + transition: background 0.15s; + display: flex; + align-items: flex-start; + gap: 10px; +} +#history-list li:active { + background: var(--surface-hover); +} +#history-list li .hist-text { + flex: 1; + word-break: break-word; +} +#history-list li .hist-time { + font-size: 11px; + color: var(--text-dim); + white-space: nowrap; + flex-shrink: 0; + padding-top: 2px; +} +#history-empty { + text-align: center; + padding: 32px 0; +} +.placeholder { + color: var(--text-dim); + font-size: 14px; +} +/* Scrollbar */ +#history-list::-webkit-scrollbar { + width: 4px; +} +#history-list::-webkit-scrollbar-track { + background: transparent; +} +#history-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} \ No newline at end of file