Files
voicepaste/web/app.ts

396 lines
11 KiB
TypeScript

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<typeof setTimeout> | 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<HTMLElement>(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<HTMLElement>("#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<string, unknown>): 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<typeof setTimeout> }
)._timer;
if (timer) clearTimeout(timer);
(toast as HTMLElement & { _timer?: ReturnType<typeof setTimeout> })._timer =
setTimeout(() => {
toast.classList.remove("show");
}, 2000);
}
// ── Audio pipeline ──
async function initAudio(): Promise<void> {
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<void> {
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 =
`<span class="hist-text">${escapeHtml(item.text)}</span>` +
`<span class="hist-time">${formatTime(item.ts)}</span>`;
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();
}