refactor: 迁移前端到 React 19 + Zustand + Tailwind CSS v4

- 将 vanilla TS 单文件 (app.ts 395行) 拆分为 React 组件化架构
- 引入 Zustand 管理全局状态 (连接/录音/预览/历史/toast)
- 自定义 hooks 封装 WebSocket 连接和音频录制管线
- CSS 全面 Tailwind 化,style.css 从 234 行精简到 114 行 (仅保留 tokens + keyframes)
- 新增依赖: react, react-dom, zustand, @vitejs/plugin-react
- Go 后端 embed 路径 web/dist 不变,无需改动
This commit is contained in:
2026-03-02 06:36:02 +08:00
parent ea46ad71bf
commit 70344bcd98
21 changed files with 914 additions and 687 deletions

33
web/src/App.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { HistoryList } from "./components/HistoryList";
import { MicButton } from "./components/MicButton";
import { PreviewBox } from "./components/PreviewBox";
import { StatusBadge } from "./components/StatusBadge";
import { Toast } from "./components/Toast";
import { useRecorder } from "./hooks/useRecorder";
import { useWebSocket } from "./hooks/useWebSocket";
export function App() {
const { sendJSON, sendBinary } = useWebSocket();
const { startRecording, stopRecording } = useRecorder({
sendJSON,
sendBinary,
});
return (
<>
<div className="relative z-1 mx-auto flex h-full max-w-[480px] flex-col px-5 pt-[calc(16px+env(safe-area-inset-top,0px))] pb-[calc(16px+env(safe-area-inset-bottom,0px))]">
<header className="flex shrink-0 items-center justify-between pt-2 pb-5">
<h1 className="font-bold text-[22px] tracking-[-0.03em]">
VoicePaste
</h1>
<StatusBadge />
</header>
<PreviewBox />
<MicButton onStart={startRecording} onStop={stopRecording} />
<HistoryList sendJSON={sendJSON} />
</div>
<Toast />
</>
);
}

112
web/src/app.css Normal file
View File

@@ -0,0 +1,112 @@
@import "tailwindcss";
/* ─── Design Tokens ─── */
@theme {
--color-bg: #08080d;
--color-surface: #111117;
--color-surface-hover: #17171e;
--color-surface-active: #1c1c25;
--color-edge: #1e1e2a;
--color-edge-active: #2c2c3e;
--color-fg: #eaeaef;
--color-fg-secondary: #9e9eb5;
--color-fg-dim: #5a5a6e;
--color-accent: #6366f1;
--color-accent-hover: #818cf8;
--color-danger: #f43f5e;
--color-success: #34d399;
--radius-card: 14px;
--animate-pulse-dot: pulse-dot 1.4s ease-in-out infinite;
--animate-mic-breathe: mic-breathe 1.8s ease-in-out infinite;
--animate-ring-expand: ring-expand 2.4s cubic-bezier(0.2, 0, 0.2, 1) infinite;
--animate-slide-up: slide-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* ─── Base ─── */
@layer base {
html,
body {
font-family:
"SF Pro Display", -apple-system, BlinkMacSystemFont, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
}
body::after {
content: "";
@apply pointer-events-none fixed z-0;
top: -30%;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 600px;
background: radial-gradient(
circle,
rgba(99, 102, 241, 0.04) 0%,
transparent 70%
);
}
}
/* ─── Scrollbar ─── */
.scrollbar-thin::-webkit-scrollbar {
width: 3px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
@apply bg-edge;
border-radius: 3px;
}
/* ─── Keyframes ─── */
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
@keyframes mic-breathe {
0%,
100% {
box-shadow:
0 0 32px rgba(99, 102, 241, 0.35),
0 0 80px rgba(99, 102, 241, 0.2);
}
50% {
box-shadow:
0 0 48px rgba(99, 102, 241, 0.35),
0 0 120px rgba(99, 102, 241, 0.2),
0 0 200px rgba(99, 102, 241, 0.08);
}
}
@keyframes ring-expand {
0% {
transform: scale(1);
opacity: 0.35;
}
100% {
transform: scale(2);
opacity: 0;
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,67 @@
import { useCallback } from "react";
import { useAppStore } from "../stores/app-store";
function formatTime(ts: number): string {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
interface HistoryListProps {
sendJSON: (obj: Record<string, unknown>) => void;
}
export function HistoryList({ sendJSON }: HistoryListProps) {
const history = useAppStore((s) => s.history);
const clearHistory = useAppStore((s) => s.clearHistory);
const showToast = useAppStore((s) => s.showToast);
const handleItemClick = useCallback(
(text: string) => {
sendJSON({ type: "paste", text });
showToast("\u53d1\u9001\u7c98\u8d34\u2026");
},
[sendJSON, showToast],
);
return (
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between pb-2.5">
<h2 className="font-semibold text-[13px] text-fg-dim uppercase tracking-[0.06em]">
{"\u5386\u53f2\u8bb0\u5f55"}
</h2>
<button
type="button"
onClick={clearHistory}
className="cursor-pointer rounded-lg border-none bg-transparent px-2.5 py-1 font-medium text-fg-dim text-xs transition-all duration-150 active:bg-danger/[0.08] active:text-danger"
>
{"\u6e05\u7a7a"}
</button>
</div>
{history.length === 0 ? (
<p className="py-10 text-center text-fg-dim text-sm">
{"\u6682\u65e0\u8bb0\u5f55"}
</p>
) : (
<div className="scrollbar-thin flex-1 overflow-y-auto">
{history.map((item, i) => (
<button
type="button"
key={`${item.ts}-${i}`}
onClick={() => handleItemClick(item.text)}
className="mb-2 flex w-full animate-slide-up cursor-pointer items-start gap-3 rounded-card border border-edge bg-surface px-4 py-3.5 text-left text-sm leading-relaxed transition-all duration-150 active:scale-[0.985] active:border-edge-active active:bg-surface-active"
style={{
animationDelay: `${Math.min(i, 10) * 40}ms`,
}}
>
<span className="flex-1 break-words">{item.text}</span>
<span className="shrink-0 whitespace-nowrap pt-0.5 text-[11px] text-fg-dim tabular-nums">
{formatTime(item.ts)}
</span>
</button>
))}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,106 @@
import { useCallback } from "react";
import { useAppStore } from "../stores/app-store";
interface MicButtonProps {
onStart: () => void;
onStop: () => void;
}
export function MicButton({ onStart, onStop }: MicButtonProps) {
const connected = useAppStore((s) => s.connectionStatus === "connected");
const recording = useAppStore((s) => s.recording);
const pendingStart = useAppStore((s) => s.pendingStart);
const isActive = recording || pendingStart;
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
onStart();
},
[onStart],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
e.preventDefault();
e.currentTarget.releasePointerCapture(e.pointerId);
onStop();
},
[onStop],
);
// Read latest state to avoid stale closures
const handlePointerLeave = useCallback(() => {
const s = useAppStore.getState();
if (s.recording || s.pendingStart) onStop();
}, [onStop]);
const handlePointerCancel = useCallback(() => {
const s = useAppStore.getState();
if (s.recording || s.pendingStart) onStop();
}, [onStop]);
const disabledClasses =
"cursor-not-allowed border-edge bg-linear-to-br from-surface-hover to-surface text-fg-secondary opacity-30 shadow-[0_2px_12px_rgba(0,0,0,0.3),inset_0_1px_0_rgba(255,255,255,0.04)]";
const activeClasses =
"animate-mic-breathe scale-[1.06] border-accent-hover bg-accent text-white shadow-[0_0_32px_rgba(99,102,241,0.35),0_0_80px_rgba(99,102,241,0.2)]";
const idleClasses =
"border-edge bg-linear-to-br from-surface-hover to-surface text-fg-secondary shadow-[0_2px_12px_rgba(0,0,0,0.3),inset_0_1px_0_rgba(255,255,255,0.04)]";
return (
<section className="flex shrink-0 flex-col items-center gap-3.5 pt-5 pb-4">
<div className="relative flex touch-none items-center justify-center">
<button
type="button"
disabled={!connected}
className={`relative z-1 flex size-24 cursor-pointer touch-none select-none items-center justify-center rounded-full border-2 transition-all duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)] ${
!connected
? disabledClasses
: isActive
? activeClasses
: idleClasses
}`}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
onPointerCancel={handlePointerCancel}
onContextMenu={(e) => e.preventDefault()}
>
<svg
viewBox="0 0 24 24"
width={48}
height={48}
fill="currentColor"
aria-label="\u9ea6\u514b\u98ce"
role="img"
>
<title>{"\u9ea6\u514b\u98ce"}</title>
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
</svg>
</button>
{/* Wave rings */}
<div
className="pointer-events-none absolute inset-0"
aria-hidden="true"
>
{[0, 0.8, 1.6].map((delay) => (
<span
key={delay}
className={`absolute inset-0 rounded-full border-[1.5px] border-accent ${
isActive ? "animate-ring-expand" : "opacity-0"
}`}
style={isActive ? { animationDelay: `${delay}s` } : undefined}
/>
))}
</div>
</div>
<p className="font-medium text-fg-dim text-sm">
{"\u6309\u4f4f\u8bf4\u8bdd"}
</p>
</section>
);
}

View File

@@ -0,0 +1,25 @@
import { useAppStore } from "../stores/app-store";
export function PreviewBox() {
const text = useAppStore((s) => s.previewText);
const active = useAppStore((s) => s.previewActive);
const hasText = text.length > 0;
return (
<section className="shrink-0 pb-3">
<div
className={`scrollbar-thin max-h-40 min-h-20 overflow-y-auto rounded-card border px-[18px] py-4 transition-all duration-300 ${
active
? "border-accent/40 bg-linear-to-b from-accent/[0.03] to-surface shadow-[0_0_0_1px_rgba(99,102,241,0.2),0_4px_24px_-4px_rgba(99,102,241,0.15)]"
: "border-edge bg-surface"
}`}
>
<p
className={`break-words text-base leading-relaxed ${hasText ? "" : "text-fg-dim"}`}
>
{hasText ? text : "\u6309\u4f4f\u8bf4\u8bdd\u2026"}
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,35 @@
import { useAppStore } from "../stores/app-store";
const statusConfig = {
connected: {
text: "\u5df2\u8fde\u63a5",
dotClass: "bg-success shadow-[0_0_6px_rgba(52,211,153,0.5)]",
borderClass: "border-success/15",
},
disconnected: {
text: "\u5df2\u65ad\u5f00",
dotClass: "bg-danger shadow-[0_0_6px_rgba(244,63,94,0.4)]",
borderClass: "border-edge",
},
connecting: {
text: "\u8fde\u63a5\u4e2d\u2026",
dotClass: "bg-accent animate-pulse-dot",
borderClass: "border-edge",
},
} as const;
export function StatusBadge() {
const status = useAppStore((s) => s.connectionStatus);
const { text, dotClass, borderClass } = statusConfig[status];
return (
<div
className={`flex items-center gap-[7px] rounded-full border bg-surface px-3 py-[5px] font-medium text-fg-dim text-xs transition-all ${borderClass}`}
>
<span
className={`size-[7px] shrink-0 rounded-full transition-all duration-300 ${dotClass}`}
/>
<span>{text}</span>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { useEffect, useRef } from "react";
import { useAppStore } from "../stores/app-store";
export function Toast() {
const toast = useAppStore((s) => s.toast);
const dismissToast = useAppStore((s) => s.dismissToast);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
if (!toast) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(dismissToast, 2000);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [toast, dismissToast]);
return (
<div
className={`pointer-events-none fixed bottom-[calc(80px+env(safe-area-inset-bottom,0px))] left-1/2 z-[999] rounded-3xl border border-edge bg-[rgba(28,28,37,0.85)] px-[22px] py-2.5 font-medium text-[13px] text-fg shadow-[0_8px_32px_rgba(0,0,0,0.4)] backdrop-blur-[16px] transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
toast
? "-translate-x-1/2 translate-y-0 opacity-100"
: "-translate-x-1/2 translate-y-2 opacity-0"
}`}
>
{toast}
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useCallback, useRef } from "react";
import { resampleTo16kInt16 } from "../lib/resample";
import { useAppStore } from "../stores/app-store";
import audioProcessorUrl from "../workers/audio-processor.ts?worker&url";
interface UseRecorderOptions {
sendJSON: (obj: Record<string, unknown>) => void;
sendBinary: (data: Int16Array) => void;
}
export function useRecorder({ sendJSON, sendBinary }: UseRecorderOptions) {
const audioCtxRef = useRef<AudioContext | null>(null);
const workletRef = useRef<AudioWorkletNode | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Keep stable refs so callbacks never go stale
const sendJSONRef = useRef(sendJSON);
const sendBinaryRef = useRef(sendBinary);
sendJSONRef.current = sendJSON;
sendBinaryRef.current = sendBinary;
const initAudio = useCallback(async () => {
if (audioCtxRef.current) return;
// Use device native sample rate — we resample to 16kHz in software
const ctx = new AudioContext();
// Chrome requires resume() after user gesture
if (ctx.state === "suspended") await ctx.resume();
await ctx.audioWorklet.addModule(audioProcessorUrl);
audioCtxRef.current = ctx;
}, []);
const startRecording = useCallback(async () => {
const store = useAppStore.getState();
if (store.recording || store.pendingStart) return;
store.setPendingStart(true);
const abort = new AbortController();
abortRef.current = abort;
try {
await initAudio();
if (abort.signal.aborted) {
store.setPendingStart(false);
return;
}
const ctx = audioCtxRef.current as AudioContext;
if (ctx.state === "suspended") await ctx.resume();
if (abort.signal.aborted) {
store.setPendingStart(false);
return;
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
channelCount: 1,
},
});
if (abort.signal.aborted) {
stream.getTracks().forEach((t) => {
t.stop();
});
store.setPendingStart(false);
return;
}
streamRef.current = stream;
const source = ctx.createMediaStreamSource(stream);
const worklet = new AudioWorkletNode(ctx, "audio-processor");
worklet.port.onmessage = (e: MessageEvent) => {
if (e.data.type === "audio") {
sendBinaryRef.current(
resampleTo16kInt16(e.data.samples, e.data.sampleRate),
);
}
};
source.connect(worklet);
worklet.port.postMessage({ command: "start" });
workletRef.current = worklet;
store.setPendingStart(false);
abortRef.current = null;
store.setRecording(true);
sendJSONRef.current({ type: "start" });
store.clearPreview();
} catch (err) {
useAppStore.getState().setPendingStart(false);
abortRef.current = null;
useAppStore
.getState()
.showToast(`\u9ea6\u514b\u98ce\u9519\u8bef: ${(err as Error).message}`);
}
}, [initAudio]);
const stopRecording = useCallback(() => {
const store = useAppStore.getState();
if (store.pendingStart) {
abortRef.current?.abort();
abortRef.current = null;
store.setPendingStart(false);
return;
}
if (!store.recording) return;
store.setRecording(false);
if (workletRef.current) {
workletRef.current.port.postMessage({ command: "stop" });
workletRef.current.disconnect();
workletRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => {
t.stop();
});
streamRef.current = null;
}
sendJSONRef.current({ type: "stop" });
}, []);
return { startRecording, stopRecording };
}

View File

@@ -0,0 +1,107 @@
import { useCallback, useEffect, useRef } from "react";
import { useAppStore } from "../stores/app-store";
function getWsUrl(): string {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const params = new URLSearchParams(location.search);
const token = params.get("token") || "";
const q = token ? `?token=${encodeURIComponent(token)}` : "";
return `${proto}//${location.host}/ws${q}`;
}
const WS_RECONNECT_BASE = 1000;
const WS_RECONNECT_MAX = 16000;
export function useWebSocket() {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectDelayRef = useRef(WS_RECONNECT_BASE);
const sendJSON = useCallback((obj: Record<string, unknown>) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
}
}, []);
const sendBinary = useCallback((data: Int16Array) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(data.buffer);
}
}, []);
useEffect(() => {
function connect() {
if (wsRef.current) return;
useAppStore.getState().setConnectionStatus("connecting");
const ws = new WebSocket(getWsUrl());
ws.binaryType = "arraybuffer";
ws.onopen = () => {
reconnectDelayRef.current = WS_RECONNECT_BASE;
useAppStore.getState().setConnectionStatus("connected");
};
ws.onmessage = (e: MessageEvent) => {
if (typeof e.data !== "string") return;
try {
const msg = JSON.parse(e.data);
const store = useAppStore.getState();
switch (msg.type) {
case "partial":
store.setPreview(msg.text || "", false);
break;
case "final":
store.setPreview(msg.text || "", true);
if (msg.text) store.addHistory(msg.text);
break;
case "pasted":
store.showToast("\u2705 \u5df2\u7c98\u8d34");
break;
case "error":
store.showToast(`\u274c ${msg.message || "\u9519\u8bef"}`);
break;
}
} catch {
// Ignore malformed messages
}
};
ws.onclose = () => {
wsRef.current = null;
const store = useAppStore.getState();
store.setConnectionStatus("disconnected");
if (store.recording) store.setRecording(false);
if (store.pendingStart) store.setPendingStart(false);
scheduleReconnect();
};
ws.onerror = () => ws.close();
wsRef.current = ws;
}
function scheduleReconnect() {
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = setTimeout(
connect,
reconnectDelayRef.current,
);
reconnectDelayRef.current = Math.min(
reconnectDelayRef.current * 2,
WS_RECONNECT_MAX,
);
}
connect();
return () => {
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
wsRef.current?.close();
wsRef.current = null;
};
}, []);
return { sendJSON, sendBinary };
}

23
web/src/lib/resample.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Linear interpolation resampler: native sample rate -> 16kHz 16-bit mono PCM.
*/
const TARGET_SAMPLE_RATE = 16000;
export 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;
}

13
web/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./app.css";
const root = document.getElementById("root");
if (!root) throw new Error("Root element not found");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,86 @@
import { create } from "zustand";
export interface HistoryItem {
text: string;
ts: number;
}
const HISTORY_KEY = "voicepaste_history";
const HISTORY_MAX = 50;
type ConnectionStatus = "connected" | "disconnected" | "connecting";
interface AppState {
// Connection
connectionStatus: ConnectionStatus;
// Recording
recording: boolean;
pendingStart: boolean;
// Preview
previewText: string;
previewActive: boolean;
// History
history: HistoryItem[];
// Toast
toast: string | null;
// Actions
setConnectionStatus: (status: ConnectionStatus) => void;
setRecording: (recording: boolean) => void;
setPendingStart: (pending: boolean) => void;
setPreview: (text: string, isFinal: boolean) => void;
clearPreview: () => void;
addHistory: (text: string) => void;
clearHistory: () => void;
showToast: (message: string) => void;
dismissToast: () => void;
}
function loadHistoryFromStorage(): HistoryItem[] {
try {
return JSON.parse(
localStorage.getItem(HISTORY_KEY) || "[]",
) as HistoryItem[];
} catch {
return [];
}
}
export const useAppStore = create<AppState>((set, get) => ({
connectionStatus: "connecting",
recording: false,
pendingStart: false,
previewText: "",
previewActive: false,
history: loadHistoryFromStorage(),
toast: null,
setConnectionStatus: (connectionStatus) => set({ connectionStatus }),
setRecording: (recording) => set({ recording }),
setPendingStart: (pendingStart) => set({ pendingStart }),
setPreview: (text, isFinal) =>
set({
previewText: text,
previewActive: !isFinal && text.length > 0,
}),
clearPreview: () => set({ previewText: "", previewActive: false }),
addHistory: (text) => {
const items = [{ text, ts: Date.now() }, ...get().history].slice(
0,
HISTORY_MAX,
);
localStorage.setItem(HISTORY_KEY, JSON.stringify(items));
set({ history: items });
},
clearHistory: () => {
localStorage.removeItem(HISTORY_KEY);
set({ history: [] });
},
showToast: (message) => set({ toast: message }),
dismissToast: () => set({ toast: null }),
}));

View File

@@ -0,0 +1,88 @@
/**
* 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 }
*/
// AudioWorkletGlobalScope globals (not in standard lib)
declare const sampleRate: number;
declare class AudioWorkletProcessor {
readonly port: MessagePort;
constructor();
process(
inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>,
): boolean;
}
declare function registerProcessor(
name: string,
ctor: new () => AudioWorkletProcessor,
): void;
class VoicePasteProcessor extends AudioWorkletProcessor {
private recording = false;
private buffer: Float32Array[] = [];
private bufferLen = 0;
private readonly frameSize: number;
constructor() {
super();
// ~200ms worth of samples at current sample rate
this.frameSize = Math.floor(sampleRate * 0.2);
this.port.onmessage = (e: MessageEvent) => {
if (e.data.command === "start") {
this.recording = true;
this.buffer = [];
this.bufferLen = 0;
} else if (e.data.command === "stop") {
if (this.bufferLen > 0) {
this.flush();
}
this.recording = false;
}
};
}
process(inputs: Float32Array[][]): boolean {
if (!this.recording) return true;
const input = inputs[0];
if (!input || !input[0]) return true;
const channelData = input[0];
this.buffer.push(new Float32Array(channelData));
this.bufferLen += channelData.length;
if (this.bufferLen >= this.frameSize) {
this.flush();
}
return true;
}
private flush(): void {
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],
);
this.buffer = [];
this.bufferLen = 0;
}
}
registerProcessor("audio-processor", VoicePasteProcessor);