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:
33
web/src/App.tsx
Normal file
33
web/src/App.tsx
Normal 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
112
web/src/app.css
Normal 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);
|
||||
}
|
||||
}
|
||||
67
web/src/components/HistoryList.tsx
Normal file
67
web/src/components/HistoryList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
web/src/components/MicButton.tsx
Normal file
106
web/src/components/MicButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
web/src/components/PreviewBox.tsx
Normal file
25
web/src/components/PreviewBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
web/src/components/StatusBadge.tsx
Normal file
35
web/src/components/StatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
web/src/components/Toast.tsx
Normal file
29
web/src/components/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
web/src/hooks/useRecorder.ts
Normal file
127
web/src/hooks/useRecorder.ts
Normal 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 };
|
||||
}
|
||||
107
web/src/hooks/useWebSocket.ts
Normal file
107
web/src/hooks/useWebSocket.ts
Normal 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
23
web/src/lib/resample.ts
Normal 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
13
web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
86
web/src/stores/app-store.ts
Normal file
86
web/src/stores/app-store.ts
Normal 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 }),
|
||||
}));
|
||||
88
web/src/workers/audio-processor.ts
Normal file
88
web/src/workers/audio-processor.ts
Normal 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);
|
||||
Reference in New Issue
Block a user