feat: 前端迁移至 TypeScript,集成 Biome 格式化与代码检查
- app.js → app.ts:添加完整类型标注、接口定义 - audio-processor.js → audio-processor.ts:AudioWorklet 类型化 - vite.config.js → vite.config.ts - 添加 tsconfig.json、vite-env.d.ts - 集成 Biome 默认配置(lint + format),通过全部检查 - package.json 添加 check/typecheck 脚本 - index.html 修复无障碍问题(button type、SVG title)
This commit is contained in:
409
web/app.ts
Normal file
409
web/app.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
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;
|
||||
startCancelled: boolean;
|
||||
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,
|
||||
startCancelled: false,
|
||||
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.className = `status ${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();
|
||||
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 {
|
||||
let toast = document.getElementById("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 as HTMLElement & { _timer?: ReturnType<typeof setTimeout> })._timer,
|
||||
);
|
||||
(toast as HTMLElement & { _timer?: ReturnType<typeof setTimeout> })._timer =
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = "0";
|
||||
}, 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;
|
||||
state.startCancelled = false;
|
||||
try {
|
||||
await initAudio();
|
||||
if (state.startCancelled) {
|
||||
state.pendingStart = false;
|
||||
return;
|
||||
}
|
||||
const audioCtx = state.audioCtx as AudioContext;
|
||||
// Ensure AudioContext is running (may suspend between recordings)
|
||||
if (audioCtx.state === "suspended") {
|
||||
await audioCtx.resume();
|
||||
}
|
||||
if (state.startCancelled) {
|
||||
state.pendingStart = false;
|
||||
return;
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
channelCount: 1,
|
||||
},
|
||||
});
|
||||
if (state.startCancelled) {
|
||||
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" });
|
||||
// Don't connect worklet to destination (no playback)
|
||||
state.workletNode = worklet;
|
||||
state.pendingStart = false;
|
||||
state.recording = true;
|
||||
sendJSON({ type: "start" });
|
||||
micBtn.classList.add("recording");
|
||||
setPreview("", false);
|
||||
} catch (err) {
|
||||
state.pendingStart = false;
|
||||
showToast(`麦克风错误: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
function stopRecording(): void {
|
||||
// Cancel pending async start if still initializing
|
||||
if (state.pendingStart) {
|
||||
state.startCancelled = true;
|
||||
micBtn.classList.remove("recording");
|
||||
return;
|
||||
}
|
||||
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(): 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 (const item of items) {
|
||||
const li = document.createElement("li");
|
||||
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 {
|
||||
// Touch events (mobile primary)
|
||||
micBtn.addEventListener(
|
||||
"touchstart",
|
||||
(e: TouchEvent) => {
|
||||
e.preventDefault();
|
||||
startRecording();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
micBtn.addEventListener(
|
||||
"touchend",
|
||||
(e: TouchEvent) => {
|
||||
e.preventDefault();
|
||||
stopRecording();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
micBtn.addEventListener(
|
||||
"touchcancel",
|
||||
(e: TouchEvent) => {
|
||||
e.preventDefault();
|
||||
stopRecording();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
// Mouse fallback (desktop testing)
|
||||
micBtn.addEventListener("mousedown", (e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
startRecording();
|
||||
});
|
||||
micBtn.addEventListener("mouseup", () => stopRecording());
|
||||
micBtn.addEventListener("mouseleave", () => {
|
||||
if (state.recording) stopRecording();
|
||||
});
|
||||
}
|
||||
// ── 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();
|
||||
}
|
||||
Reference in New Issue
Block a user