import "./style.css"; import audioProcessorUrl from "./audio-processor.ts?worker&url"; /** * 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 */ // ── Types ── interface HistoryItem { text: string; ts: number; } interface ServerMessage { type: "partial" | "final" | "pasted" | "error"; text?: string; message?: string; } interface AppState { ws: WebSocket | null; connected: boolean; recording: boolean; pendingStart: boolean; abortController: AbortController | null; audioCtx: AudioContext | null; workletNode: AudioWorkletNode | null; stream: MediaStream | null; reconnectDelay: number; reconnectTimer: ReturnType | null; } // ── 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 ── function q(sel: string): HTMLElement { const el = document.querySelector(sel); if (!el) throw new Error(`Element not found: ${sel}`); return el; } const statusEl = q("#status"); const statusText = q("#status-text"); const previewText = q("#preview-text"); const previewBox = q("#preview"); const micBtn = q("#mic-btn") as HTMLButtonElement; const historyList = q("#history-list"); const historyEmpty = q("#history-empty"); const clearHistoryBtn = document.querySelector("#clear-history"); // ── State ── const state: AppState = { ws: null, connected: false, recording: false, pendingStart: false, abortController: null, audioCtx: null, workletNode: null, stream: null, reconnectDelay: WS_RECONNECT_BASE, reconnectTimer: null, }; // ── Utility ── function getToken(): string { const params = new URLSearchParams(location.search); return params.get("token") || ""; } function formatTime(ts: number): string { 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: Float32Array, srcRate: number, ): Int16Array { 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(): string { 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: string, text: string): void { statusEl.classList.remove("connected", "disconnected", "connecting"); statusEl.classList.add(cls); statusText.textContent = text; } function connectWS(): void { 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: MessageEvent) => handleServerMsg(e.data); ws.onclose = () => { state.connected = false; state.ws = null; micBtn.disabled = true; if (state.recording) stopRecording(); if (state.pendingStart) { state.abortController?.abort(); state.pendingStart = false; micBtn.classList.remove("recording"); } setStatus("disconnected", "已断开"); scheduleReconnect(); }; ws.onerror = () => ws.close(); state.ws = ws; } function scheduleReconnect(): void { if (state.reconnectTimer !== null) clearTimeout(state.reconnectTimer); state.reconnectTimer = setTimeout(() => { connectWS(); }, state.reconnectDelay); state.reconnectDelay = Math.min(state.reconnectDelay * 2, WS_RECONNECT_MAX); } function sendJSON(obj: Record): void { if (state.ws && state.ws.readyState === WebSocket.OPEN) { state.ws.send(JSON.stringify(obj)); } } function sendBinary(int16arr: Int16Array): void { if (state.ws && state.ws.readyState === WebSocket.OPEN) { state.ws.send(int16arr.buffer); } } // ── Server message handler ── function handleServerMsg(data: unknown): void { if (typeof data !== "string") return; let msg: ServerMessage; try { msg = JSON.parse(data) as ServerMessage; } 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: string, isFinal: boolean): void { 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: string): void { const toast = q("#toast"); toast.textContent = msg; toast.classList.add("show"); const timer = ( toast as HTMLElement & { _timer?: ReturnType } )._timer; if (timer) clearTimeout(timer); (toast as HTMLElement & { _timer?: ReturnType })._timer = setTimeout(() => { toast.classList.remove("show"); }, 2000); } // ── Audio pipeline ── async function initAudio(): Promise { if (state.audioCtx) return; // Use device native sample rate — we resample to 16kHz in software const audioCtx = new AudioContext(); // Chrome requires resume() after user gesture if (audioCtx.state === "suspended") { await audioCtx.resume(); } await audioCtx.audioWorklet.addModule(audioProcessorUrl); state.audioCtx = audioCtx; } async function startRecording(): Promise { if (state.recording || state.pendingStart) return; state.pendingStart = true; const abortController = new AbortController(); state.abortController = abortController; try { await initAudio(); if (abortController.signal.aborted) { state.pendingStart = false; return; } const audioCtx = state.audioCtx as AudioContext; if (audioCtx.state === "suspended") { await audioCtx.resume(); } if (abortController.signal.aborted) { state.pendingStart = false; return; } const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1, }, }); if (abortController.signal.aborted) { stream.getTracks().forEach((t) => { t.stop(); }); state.pendingStart = false; return; } state.stream = stream; const source = audioCtx.createMediaStreamSource(stream); const worklet = new AudioWorkletNode(audioCtx, "audio-processor"); worklet.port.onmessage = (e: MessageEvent) => { if (e.data.type === "audio") { const int16 = resampleTo16kInt16(e.data.samples, e.data.sampleRate); sendBinary(int16); } }; source.connect(worklet); worklet.port.postMessage({ command: "start" }); state.workletNode = worklet; state.pendingStart = false; state.abortController = null; state.recording = true; sendJSON({ type: "start" }); micBtn.classList.add("recording"); setPreview("", false); } catch (err) { state.pendingStart = false; state.abortController = null; showToast(`麦克风错误: ${(err as Error).message}`); } } function stopRecording(): void { if (state.pendingStart) { state.abortController?.abort(); state.abortController = null; micBtn.classList.remove("recording"); return; } if (!state.recording) return; state.recording = false; if (state.workletNode) { state.workletNode.port.postMessage({ command: "stop" }); state.workletNode.disconnect(); state.workletNode = null; } if (state.stream) { state.stream.getTracks().forEach((t) => { t.stop(); }); state.stream = null; } sendJSON({ type: "stop" }); micBtn.classList.remove("recording"); } // ── History (localStorage) ── function loadHistory(): HistoryItem[] { try { return JSON.parse( localStorage.getItem(HISTORY_KEY) || "[]", ) as HistoryItem[]; } catch { return []; } } function saveHistory(items: HistoryItem[]): void { localStorage.setItem(HISTORY_KEY, JSON.stringify(items)); } function addHistory(text: string): void { const items = loadHistory(); items.unshift({ text, ts: Date.now() }); if (items.length > HISTORY_MAX) items.length = HISTORY_MAX; saveHistory(items); renderHistory(); } function clearHistory(): void { localStorage.removeItem(HISTORY_KEY); renderHistory(); } function renderHistory(): void { const items = loadHistory(); historyList.innerHTML = ""; if (!items.length) { (historyEmpty as HTMLElement).style.display = ""; return; } (historyEmpty as HTMLElement).style.display = "none"; for (let i = 0; i < items.length; i++) { const item = items[i]; const li = document.createElement("li"); li.style.setProperty("--i", String(Math.min(i, 10))); li.innerHTML = `${escapeHtml(item.text)}` + `${formatTime(item.ts)}`; li.addEventListener("click", () => { sendJSON({ type: "paste", text: item.text }); showToast("发送粘贴…"); }); historyList.appendChild(li); } } function escapeHtml(s: string): string { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } // ── Event bindings ── function bindMicButton(): void { // Pointer Events: unified touch + mouse, no double-trigger micBtn.addEventListener("pointerdown", (e: PointerEvent) => { if (e.button !== 0) return; e.preventDefault(); // Capture pointer so pointerleave won't fire while held micBtn.setPointerCapture(e.pointerId); startRecording(); }); micBtn.addEventListener("pointerup", (e: PointerEvent) => { e.preventDefault(); micBtn.releasePointerCapture(e.pointerId); stopRecording(); }); micBtn.addEventListener("pointerleave", () => { if (state.recording || state.pendingStart) stopRecording(); }); micBtn.addEventListener("pointercancel", () => { if (state.recording || state.pendingStart) stopRecording(); }); // Prevent context menu on long press micBtn.addEventListener("contextmenu", (e) => e.preventDefault()); } // ── Init ── function init(): void { micBtn.disabled = true; bindMicButton(); if (clearHistoryBtn) { clearHistoryBtn.addEventListener("click", clearHistory); } renderHistory(); connectWS(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); }