File size: 2,944 Bytes
b2d4d0e ded73f2 b2d4d0e ded73f2 b2d4d0e ded73f2 b2d4d0e 65949d0 b2d4d0e 65949d0 b2d4d0e 65949d0 b2d4d0e 65949d0 b2d4d0e 65949d0 b2d4d0e 65949d0 b2d4d0e 65949d0 b2d4d0e 65949d0 b2d4d0e 65949d0 b2d4d0e 65949d0 b2d4d0e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | import { useRef, useState } from "react";
import { Recorder } from "@/lib/audio";
import { addVoice } from "@/lib/idb";
import { encodeWav } from "@/lib/wav";
type Props = {
onSaved: () => void;
};
export default function VoiceComposer({ onSaved }: Props) {
const fileRef = useRef<HTMLInputElement>(null);
const recorderRef = useRef<Recorder | null>(null);
const [recState, setRecState] = useState<"idle" | "recording" | "stopping" | "error">("idle");
const [name, setName] = useState("");
async function importBlob(blob: Blob, defaultName: string) {
const arr = new Uint8Array(await blob.arrayBuffer());
const ctx = new AudioContext();
const buf = await ctx.decodeAudioData(arr.buffer.slice(0));
// Re-encode as 16-bit PCM mono WAV so the server (libsndfile) can decode it.
const wav = encodeWav(buf);
await addVoice({
name: name || defaultName || `voice-${Date.now()}`,
blob: wav,
sampleRate: buf.sampleRate,
durationMs: Math.round(buf.duration * 1000),
});
setName("");
onSaved();
}
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
await importBlob(f, f.name.replace(/\.[^.]+$/, ""));
e.target.value = "";
}
async function startRec() {
const r = new Recorder();
recorderRef.current = r;
try {
await r.start();
setRecState("recording");
} catch {
setRecState("error");
}
}
async function stopRec() {
setRecState("stopping");
const blob = await recorderRef.current?.stop();
setRecState("idle");
if (blob) await importBlob(blob, "recorded");
}
return (
<div className="space-y-3">
<input
type="text"
placeholder="Name this voice"
value={name}
onChange={(e) => setName(e.target.value)}
className="field-input"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => fileRef.current?.click()}
className="btn-ghost flex-1"
>
↑ Upload
</button>
<input ref={fileRef} type="file" accept="audio/*" hidden onChange={onFile} />
{recState === "recording" ? (
<button
type="button"
onClick={stopRec}
className="btn-primary flex-1 !py-2 flex items-center justify-center gap-2"
>
<span className="size-1.5 rounded-full bg-current animate-pulse-dot" />
Stop & save
</button>
) : (
<button
type="button"
onClick={startRec}
className="btn-ghost flex-1"
>
● Record
</button>
)}
</div>
{recState === "error" && (
<p className="text-[11px] text-red-400 font-mono uppercase tracking-wider">
microphone permission denied
</p>
)}
</div>
);
}
|