From 900b7849bb18b7496de6da9e3ff017978d4247d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 16:31:16 +0000 Subject: [PATCH 01/10] Install example dependencies before verification runs generate-examples.sh skips setup.sh whenever example/ already exists, but example/ is committed without node_modules or .venv. The prompt preamble still told the agent dependencies were installed, so on a fresh clone the agent spent minutes searching the web and other directories for SDK types before installing them itself, pushing slow examples past the 600s timeout. Install dependencies when they are missing, and point the agent at the installed packages for SDK signature checks. Co-authored-by: tadas --- scripts/generate-examples.sh | 46 ++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/scripts/generate-examples.sh b/scripts/generate-examples.sh index 8aa80ed3..5d82ce1f 100644 --- a/scripts/generate-examples.sh +++ b/scripts/generate-examples.sh @@ -110,6 +110,33 @@ run_with_timeout() { wait "${cmd_pid}" } +example_needs_dependencies() { + local example_dir="$1" + + if [[ -f "${example_dir}/package.json" && ! -d "${example_dir}/node_modules" ]]; then + return 0 + fi + if [[ -f "${example_dir}/requirements.txt" && ! -d "${example_dir}/.venv" ]]; then + return 0 + fi + return 1 +} + +# Mirrors the install step at the end of each setup.sh. +install_example_dependencies() { + ( + cd "$1" || exit 1 + if [[ -f package.json && ! -d node_modules ]]; then + pnpm install --config.confirmModulesPurge=false || exit $? + fi + if [[ -f requirements.txt && ! -d .venv ]]; then + python3 -m venv .venv || exit $? + .venv/bin/pip install --upgrade pip || exit $? + .venv/bin/pip install -r requirements.txt || exit $? + fi + ) +} + format_duration() { local total_seconds="$1" @@ -354,21 +381,32 @@ for prompt_file in "${PROMPT_FILES[@]}"; do printf "[%s/%s] %s\n" "${CURRENT_PROMPT}" "${TOTAL_PROMPTS}" "${relative_project_dir}" fi - # Run setup script if present, but skip if example/ already exists + # Run setup script if present, but skip if example/ already exists. + # A skipped setup still needs dependencies installed for the agent to verify against. if [[ -f "${project_dir}/setup.sh" ]]; then + setup_cmd=() if [[ -d "${project_dir}/example" ]]; then if (( VERBOSE_OUTPUT == 1 )); then echo "Skipping setup (example/ already exists): ${relative_project_dir}/setup.sh" fi + if example_needs_dependencies "${project_dir}/example"; then + if (( VERBOSE_OUTPUT == 1 )); then + echo "Installing dependencies: ${relative_project_dir}/example" + fi + setup_cmd=(install_example_dependencies "${project_dir}/example") + fi else if (( VERBOSE_OUTPUT == 1 )); then echo "Running setup: ${relative_project_dir}/setup.sh" fi + setup_cmd=(bash "${project_dir}/setup.sh") + fi + if (( ${#setup_cmd[@]} > 0 )); then if (( VERBOSE_OUTPUT == 1 )); then - bash "${project_dir}/setup.sh" + "${setup_cmd[@]}" SETUP_EXIT=$? else - setup_output="$(bash "${project_dir}/setup.sh" 2>&1)" + setup_output="$("${setup_cmd[@]}" 2>&1)" SETUP_EXIT=$? fi if [[ ${SETUP_EXIT} -ne 0 ]]; then @@ -405,7 +443,7 @@ for prompt_file in "${PROMPT_FILES[@]}"; do setup_detail+=" and sample assets (${asset_list})" fi fi - preamble+="${setup_detail}."$'\n\n' + preamble+="${setup_detail}. Check SDK signatures against the packages installed in \`example/\` instead of searching the web or other directories."$'\n\n' fi preamble+="Implement in \`example/\` only." if [[ -f "${REPO_ROOT}/DESIGN.md" ]]; then From d1568ab7ba651e83de4babc5fcfe59ff35d39092 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 16:31:30 +0000 Subject: [PATCH 02/10] Point dubbing, voice-changer, and voice-isolator prompts at their own skills These prompts referenced /text-to-speech, so generate-examples.sh loaded the TTS SDK reference instead of the product skill, and the agent had to go find the right API docs on its own. Co-authored-by: tadas --- dubbing/nextjs/quickstart/PROMPT.md | 2 +- voice-changer/nextjs/quickstart/PROMPT.md | 2 +- voice-isolator/nextjs/quickstart/PROMPT.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dubbing/nextjs/quickstart/PROMPT.md b/dubbing/nextjs/quickstart/PROMPT.md index af287078..5dd95393 100644 --- a/dubbing/nextjs/quickstart/PROMPT.md +++ b/dubbing/nextjs/quickstart/PROMPT.md @@ -1,4 +1,4 @@ -Before writing any code, invoke the `/text-to-speech` skill to learn the correct ElevenLabs SDK patterns. +Before writing any code, invoke the `/dubbing` skill to learn the correct ElevenLabs SDK patterns. This example uses the Dubbing Projects API (dubbing v2): a **project** holds the source media and its transcript, and each **language target** produces one dubbed output in a single language. diff --git a/voice-changer/nextjs/quickstart/PROMPT.md b/voice-changer/nextjs/quickstart/PROMPT.md index f1ee591e..73b3cfa0 100644 --- a/voice-changer/nextjs/quickstart/PROMPT.md +++ b/voice-changer/nextjs/quickstart/PROMPT.md @@ -1,4 +1,4 @@ -Before writing any code, invoke the `/text-to-speech` skill to learn the correct ElevenLabs SDK patterns. +Before writing any code, invoke the `/voice-changer` skill to learn the correct ElevenLabs SDK patterns. ## 1. `app/api/voice-changer/route.ts` diff --git a/voice-isolator/nextjs/quickstart/PROMPT.md b/voice-isolator/nextjs/quickstart/PROMPT.md index 72985f03..3a5a5231 100644 --- a/voice-isolator/nextjs/quickstart/PROMPT.md +++ b/voice-isolator/nextjs/quickstart/PROMPT.md @@ -1,4 +1,4 @@ -Before writing any code, invoke the `/text-to-speech` skill to learn the correct ElevenLabs SDK patterns. +Before writing any code, invoke the `/voice-isolator` skill to learn the correct ElevenLabs SDK patterns. ## 1. `app/api/isolate/route.ts` From 8ce04ea45394ea6b5aa9b28d12b0b103839a7552 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 16:32:49 +0000 Subject: [PATCH 03/10] Tell the generator agent to verify with typecheck and lint only Traces of timed-out runs showed the agent starting dev servers and installing playwright-core for headless browser checks, which eats the 600s budget and can hang the run. Co-authored-by: tadas --- scripts/generate-examples.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/generate-examples.sh b/scripts/generate-examples.sh index 5d82ce1f..12bceb05 100644 --- a/scripts/generate-examples.sh +++ b/scripts/generate-examples.sh @@ -445,7 +445,7 @@ for prompt_file in "${PROMPT_FILES[@]}"; do fi preamble+="${setup_detail}. Check SDK signatures against the packages installed in \`example/\` instead of searching the web or other directories."$'\n\n' fi - preamble+="Implement in \`example/\` only." + preamble+="Implement in \`example/\` only. Verify with typecheck and lint only; do not start dev servers, run browsers, or install extra tooling, because this run has a fixed time limit." if [[ -f "${REPO_ROOT}/DESIGN.md" ]]; then preamble+=$'\n\n'"Read \`DESIGN.md\` at the repo root for styling rules. Preserve any existing template header and layout container." fi From 76af7358bd2559cea81df673777250136543c59d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 16:32:49 +0000 Subject: [PATCH 04/10] Fix dubbing example object URL cleanup and prompt mismatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One cleanup effect depended on both object URLs, so setting the dubbed URL revoked the original recording and broke its player. Revoke each URL only when it is replaced or on unmount. Also show "Transcribing your recording…" as soon as polling starts and use the "Dub Recording" label, as PROMPT.md requires. The generator kept attempting these fixes and timing out midway. Co-authored-by: tadas --- .../nextjs/quickstart/example/app/page.tsx | 44 +++++++------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/dubbing/nextjs/quickstart/example/app/page.tsx b/dubbing/nextjs/quickstart/example/app/page.tsx index d3a8c116..584fa8d7 100644 --- a/dubbing/nextjs/quickstart/example/app/page.tsx +++ b/dubbing/nextjs/quickstart/example/app/page.tsx @@ -64,11 +64,14 @@ export default function Home() { const recordStartRef = useRef(0); useEffect(() => { - return () => { - if (originalUrl) URL.revokeObjectURL(originalUrl); - if (dubbedUrl) URL.revokeObjectURL(dubbedUrl); - }; - }, [originalUrl, dubbedUrl]); + if (!originalUrl) return; + return () => URL.revokeObjectURL(originalUrl); + }, [originalUrl]); + + useEffect(() => { + if (!dubbedUrl) return; + return () => URL.revokeObjectURL(dubbedUrl); + }, [dubbedUrl]); const stopTimer = () => { if (timerRef.current) { @@ -79,15 +82,9 @@ export default function Home() { const startRecording = async () => { setError(null); - setOriginalUrl(prev => { - if (prev) URL.revokeObjectURL(prev); - return null; - }); + setOriginalUrl(null); setWavFile(null); - setDubbedUrl(prev => { - if (prev) URL.revokeObjectURL(prev); - return null; - }); + setDubbedUrl(null); setProjectId(null); try { @@ -122,10 +119,7 @@ export default function Home() { setPhase("preparing"); try { const wav = await recordedBlobToWavFile(rawBlob); - setOriginalUrl(prev => { - if (prev) URL.revokeObjectURL(prev); - return URL.createObjectURL(wav); - }); + setOriginalUrl(URL.createObjectURL(wav)); setWavFile(wav); setPhase("idle"); } catch (e) { @@ -175,13 +169,10 @@ export default function Home() { const startDubbing = async () => { if (!wavFile) return; setError(null); - setDubbedUrl(prev => { - if (prev) URL.revokeObjectURL(prev); - return null; - }); + setDubbedUrl(null); setPhase("polling"); setProjectId(null); - setPollNote(null); + setPollNote("Transcribing your recording…"); const body = new FormData(); body.set("audio", wavFile); @@ -264,10 +255,7 @@ export default function Home() { } const blob = await audioRes.blob(); if (cancelled) return; - setDubbedUrl(prev => { - if (prev) URL.revokeObjectURL(prev); - return URL.createObjectURL(blob); - }); + setDubbedUrl(URL.createObjectURL(blob)); setPhase("ready"); } catch (e) { if (!cancelled) { @@ -320,7 +308,7 @@ export default function Home() { case "preparing": return "Preparing audio…"; case "polling": - return pollNote ?? "Dubbing in progress… checking every 5s."; + return pollNote ?? "Transcribing your recording…"; case "ready": return "Dubbing complete."; case "error": @@ -448,7 +436,7 @@ export default function Home() { )} onClick={startDubbing} > - {phase === "polling" ? "Dubbing…" : "Dub recording"} + {phase === "polling" ? "Dubbing…" : "Dub Recording"} ) : null} From 6be1629e8de2fbfe5fda11897bb0e34da525ff87 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 16:55:44 +0000 Subject: [PATCH 05/10] Make example dependency installs non-fatal and prefer uv for Python On runners without python3-venv, the new dependency install turned every Python example into a setup failure even though the agent can verify those examples without a virtualenv. Use uv when it is available, remove partial installs, warn instead of failing, and only tell the agent dependencies are installed when they actually are. Co-authored-by: tadas --- scripts/generate-examples.sh | 59 ++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/scripts/generate-examples.sh b/scripts/generate-examples.sh index 12bceb05..25ff50ac 100644 --- a/scripts/generate-examples.sh +++ b/scripts/generate-examples.sh @@ -122,17 +122,29 @@ example_needs_dependencies() { return 1 } -# Mirrors the install step at the end of each setup.sh. +# Mirrors the install step at the end of each setup.sh. Partial installs are +# removed so example_needs_dependencies stays accurate for the prompt preamble. install_example_dependencies() { ( cd "$1" || exit 1 if [[ -f package.json && ! -d node_modules ]]; then - pnpm install --config.confirmModulesPurge=false || exit $? + if ! pnpm install --config.confirmModulesPurge=false; then + rm -rf node_modules + exit 1 + fi fi if [[ -f requirements.txt && ! -d .venv ]]; then - python3 -m venv .venv || exit $? - .venv/bin/pip install --upgrade pip || exit $? - .venv/bin/pip install -r requirements.txt || exit $? + if command -v uv >/dev/null 2>&1; then + uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt + else + python3 -m venv .venv && + .venv/bin/pip install --upgrade pip && + .venv/bin/pip install -r requirements.txt + fi + if [[ $? -ne 0 ]]; then + rm -rf .venv + exit 1 + fi fi ) } @@ -385,6 +397,7 @@ for prompt_file in "${PROMPT_FILES[@]}"; do # A skipped setup still needs dependencies installed for the agent to verify against. if [[ -f "${project_dir}/setup.sh" ]]; then setup_cmd=() + dependency_install_only=0 if [[ -d "${project_dir}/example" ]]; then if (( VERBOSE_OUTPUT == 1 )); then echo "Skipping setup (example/ already exists): ${relative_project_dir}/setup.sh" @@ -394,6 +407,7 @@ for prompt_file in "${PROMPT_FILES[@]}"; do echo "Installing dependencies: ${relative_project_dir}/example" fi setup_cmd=(install_example_dependencies "${project_dir}/example") + dependency_install_only=1 fi else if (( VERBOSE_OUTPUT == 1 )); then @@ -409,7 +423,15 @@ for prompt_file in "${PROMPT_FILES[@]}"; do setup_output="$("${setup_cmd[@]}" 2>&1)" SETUP_EXIT=$? fi - if [[ ${SETUP_EXIT} -ne 0 ]]; then + if [[ ${SETUP_EXIT} -ne 0 && ${dependency_install_only} -eq 1 ]]; then + # The agent can still verify without dependencies, just more slowly. + if (( VERBOSE_OUTPUT == 1 )); then + echo " ! Dependency install failed; continuing without dependencies" >&2 + else + echo " ! Dependency install failed; continuing without dependencies" >&2 + printf "%s\n" "${setup_output}" | tail -n 5 >&2 + fi + elif [[ ${SETUP_EXIT} -ne 0 ]]; then FAILED_RUNS=$((FAILED_RUNS + 1)) if (( VERBOSE_OUTPUT == 1 )); then echo " ✗ Setup failed" >&2 @@ -436,14 +458,33 @@ for prompt_file in "${PROMPT_FILES[@]}"; do preamble+="IMPORTANT: Check the existing code in example/ first. Only make changes if the requirements below are not already met. If everything is already implemented correctly, confirm that no changes are needed."$'\n\n' fi if [[ -f "setup.sh" ]]; then - setup_detail="Prerequisite: \`setup.sh\` has already been run. \`example/\` is ready with dependencies installed" + dependencies_ready=1 + if [[ -d "example" ]] && example_needs_dependencies "example"; then + dependencies_ready=0 + fi + setup_items=() + if (( dependencies_ready == 1 )); then + setup_items+=("dependencies installed") + fi if [[ -d "assets" ]]; then asset_list="$(ls -1 assets/ 2>/dev/null | paste -sd ', ' -)" if [[ -n "${asset_list}" ]]; then - setup_detail+=" and sample assets (${asset_list})" + setup_items+=("sample assets (${asset_list})") fi fi - preamble+="${setup_detail}. Check SDK signatures against the packages installed in \`example/\` instead of searching the web or other directories."$'\n\n' + setup_detail="Prerequisite: \`setup.sh\` has already been run. \`example/\` is ready" + if (( ${#setup_items[@]} == 2 )); then + setup_detail+=" with ${setup_items[0]} and ${setup_items[1]}" + elif (( ${#setup_items[@]} == 1 )); then + setup_detail+=" with ${setup_items[0]}" + fi + preamble+="${setup_detail}." + if (( dependencies_ready == 1 )); then + preamble+=" Check SDK signatures against the packages installed in \`example/\` instead of searching the web or other directories." + else + preamble+=" Its dependencies are not installed." + fi + preamble+=$'\n\n' fi preamble+="Implement in \`example/\` only. Verify with typecheck and lint only; do not start dev servers, run browsers, or install extra tooling, because this run has a fixed time limit." if [[ -f "${REPO_ROOT}/DESIGN.md" ]]; then From 67e104c59e548538f5a995997200e290ad77bd9b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 17:26:28 +0000 Subject: [PATCH 06/10] Align realtime speech-to-text example with DESIGN.md and lint The page used bordered boxes, a wrapped waveform, and uppercase headings, which DESIGN.md disallows, and it cleared state with setState inside an effect, which fails react-hooks/set-state-in-effect. The generator rewrote this page on almost every run and usually timed out partway. Flatten the layout, clear transient state in onDisconnect/onError, surface real token and connection errors, show connecting/error status, show the waveform processing state while connecting, and key history by id. Co-authored-by: tadas --- .../nextjs/realtime/example/app/page.tsx | 214 +++++++++--------- 1 file changed, 107 insertions(+), 107 deletions(-) diff --git a/speech-to-text/nextjs/realtime/example/app/page.tsx b/speech-to-text/nextjs/realtime/example/app/page.tsx index 992acccb..f55689e3 100644 --- a/speech-to-text/nextjs/realtime/example/app/page.tsx +++ b/speech-to-text/nextjs/realtime/example/app/page.tsx @@ -1,13 +1,25 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useRef } from "react"; import { useScribe, CommitStrategy } from "@elevenlabs/react"; import { LiveWaveform } from "@/components/ui/live-waveform"; +type CommittedEntry = { + id: number; + text: string; +}; + +function errorMessage(err: unknown, fallback: string): string { + return err instanceof Error && err.message ? err.message : fallback; +} + export default function Home() { const [error, setError] = useState(null); const [partialTranscript, setPartialTranscript] = useState(""); - const [committedHistory, setCommittedHistory] = useState([]); + const [committedHistory, setCommittedHistory] = useState( + [] + ); + const nextEntryIdRef = useRef(0); const scribe = useScribe({ modelId: "scribe_v2_realtime", @@ -18,43 +30,58 @@ export default function Home() { setPartialTranscript(data.text || ""); }, onCommittedTranscript: data => { - if (data.text && data.text.trim()) { - setCommittedHistory(prev => [data.text, ...prev]); + const text = data.text?.trim(); + if (text) { + const id = nextEntryIdRef.current++; + setCommittedHistory(prev => [{ id, text }, ...prev]); } setPartialTranscript(""); }, + onDisconnect: () => { + setPartialTranscript(""); + }, onError: err => { console.error("Scribe error:", err); - setError("Connection error occurred. Please try again."); + setPartialTranscript(""); + setError( + errorMessage(err, "Connection error occurred. Please try again.") + ); }, }); - useEffect(() => { - if (scribe.status === "disconnected" || scribe.status === "error") { - setPartialTranscript(""); - } - }, [scribe.status]); - - // Check both connected and transcribing states to properly show active status + // Status moves to "transcribing" while speech is processed, so both states mean the session is live. const isActive = scribe.status === "connected" || scribe.status === "transcribing"; const isConnecting = scribe.status === "connecting"; + const statusLabel = + scribe.status === "connecting" + ? "Connecting" + : scribe.status === "transcribing" + ? "Transcribing" + : scribe.status === "connected" + ? "Connected" + : scribe.status === "error" + ? "Error" + : "Disconnected"; + const handleStart = useCallback(async () => { try { setError(null); setPartialTranscript(""); - // Fetch a fresh single-use token from our API + // Tokens are single-use, so fetch a fresh one for every session. const response = await fetch("/api/scribe-token"); - if (!response.ok) { - throw new Error("Failed to get transcription token"); + const body = (await response.json().catch(() => null)) as { + token?: string; + error?: string; + } | null; + if (!response.ok || !body?.token) { + throw new Error(body?.error || "Failed to get transcription token"); } - const { token } = await response.json(); - // Connect with microphone access await scribe.connect({ - token, + token: body.token, microphone: { echoCancellation: true, noiseSuppression: true, @@ -64,7 +91,10 @@ export default function Home() { } catch (err) { console.error("Failed to start transcription:", err); setError( - "Failed to start transcription. Please check your permissions and try again." + errorMessage( + err, + "Failed to start transcription. Please check your permissions and try again." + ) ); } }, [scribe]); @@ -98,106 +128,76 @@ export default function Home() {

-
- {/* Controls */} -
-
+
+
+ + {committedHistory.length > 0 ? ( - {committedHistory.length > 0 && ( - - )} -
-
- {isActive ? ( - - - {scribe.status === "transcribing" - ? "Transcribing" - : "Connected"} - - ) : ( - Disconnected - )} -
+ ) : null} +

{statusLabel}

- {/* Error message */} - {error && ( -
+ {error ? ( +

{error} -

- )} - - {/* Live waveform */} -
- -
- - {/* Partial transcript */} - {(isActive || partialTranscript) && ( +

+ ) : null} + + + + {isActive || partialTranscript ? (
-

- Live Transcript -

-
-

- {partialTranscript || ( - Listening... - )} -

-
+

Live

+

+ {partialTranscript || "Listening…"} +

- )} - - {/* Committed transcript history */} - {committedHistory.length > 0 && ( -
-

- History -

-
- {committedHistory.map((text, index) => ( -
- {text} -
+ ) : null} + + {committedHistory.length > 0 ? ( +
+

History

+
+ {committedHistory.map(entry => ( +

+ {entry.text} +

))}
- )} + ) : null} - {/* Instructions when not started */} - {!isActive && !isConnecting && committedHistory.length === 0 && ( -
- Click "Start" to begin transcribing audio from your microphone. -
- )} + {!isActive && !isConnecting && committedHistory.length === 0 ? ( +

+ Click Start to transcribe audio from your microphone. +

+ ) : null}
From 4d364f7b4332f85f17d076957358e820426c3ffd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 17:26:28 +0000 Subject: [PATCH 07/10] Fix voice-changer example lint errors and stale conversions Object URLs and stale-result handling were driven by setState inside effects, which fails react-hooks/set-state-in-effect. Create URLs in the event handlers and revoke each one when it is replaced or on unmount. Changing the voice or recording again now also ignores any in-flight conversion, and the player is labeled "Converted Audio" per PROMPT.md. Co-authored-by: tadas --- .../nextjs/quickstart/example/app/page.tsx | 87 ++++++++----------- 1 file changed, 37 insertions(+), 50 deletions(-) diff --git a/voice-changer/nextjs/quickstart/example/app/page.tsx b/voice-changer/nextjs/quickstart/example/app/page.tsx index bed49d39..93bad713 100644 --- a/voice-changer/nextjs/quickstart/example/app/page.tsx +++ b/voice-changer/nextjs/quickstart/example/app/page.tsx @@ -29,14 +29,14 @@ export default function Home() { const [originalUrl, setOriginalUrl] = useState(null); const [isConverting, setIsConverting] = useState(false); - const [convertedBlob, setConvertedBlob] = useState(null); const [convertedUrl, setConvertedUrl] = useState(null); const [convertError, setConvertError] = useState(null); const [micError, setMicError] = useState(null); const recorderRef = useRef(null); const streamRef = useRef(null); - const prevVoiceIdRef = useRef(voiceId); + // Bumped whenever a conversion result would be stale, so in-flight requests are ignored. + const conversionIdRef = useRef(0); useEffect(() => { let cancelled = false; @@ -65,53 +65,33 @@ export default function Home() { }, []); useEffect(() => { - if (!recordingBlob) { - setOriginalUrl(prev => { - if (prev) URL.revokeObjectURL(prev); - return null; - }); - return; - } - const url = URL.createObjectURL(recordingBlob); - setOriginalUrl(prev => { - if (prev) URL.revokeObjectURL(prev); - return url; - }); - return () => { - URL.revokeObjectURL(url); - }; - }, [recordingBlob]); + if (!originalUrl) return; + return () => URL.revokeObjectURL(originalUrl); + }, [originalUrl]); useEffect(() => { - if (!convertedBlob) { - setConvertedUrl(prev => { - if (prev) URL.revokeObjectURL(prev); - return null; - }); - return; - } - const url = URL.createObjectURL(convertedBlob); - setConvertedUrl(prev => { - if (prev) URL.revokeObjectURL(prev); - return url; - }); - return () => { - URL.revokeObjectURL(url); - }; - }, [convertedBlob]); + if (!convertedUrl) return; + return () => URL.revokeObjectURL(convertedUrl); + }, [convertedUrl]); - useEffect(() => { - if (prevVoiceIdRef.current !== voiceId) { - prevVoiceIdRef.current = voiceId; - setConvertedBlob(null); - setConvertError(null); - } - }, [voiceId]); + const discardConversion = useCallback(() => { + conversionIdRef.current += 1; + setConvertedUrl(null); + setConvertError(null); + setIsConverting(false); + }, []); + + const changeVoice = useCallback( + (nextVoiceId: string) => { + setVoiceId(nextVoiceId); + discardConversion(); + }, + [discardConversion] + ); useEffect(() => { if (!isRecording) return; const start = Date.now(); - setRecordingElapsed(0); const id = window.setInterval(() => { setRecordingElapsed(Math.floor((Date.now() - start) / 1000)); }, 250); @@ -147,11 +127,13 @@ export default function Home() { type: recorder.mimeType || mimeType || "audio/webm", }); setRecordingBlob(blob); + setOriginalUrl(URL.createObjectURL(blob)); setIsRecording(false); }; recorderRef.current = recorder; recorder.start(); + setRecordingElapsed(0); setIsRecording(true); } catch (e) { const denied = e instanceof DOMException && e.name === "NotAllowedError"; @@ -173,16 +155,18 @@ export default function Home() { }, []); const recordAgain = useCallback(() => { + discardConversion(); setRecordingBlob(null); - setConvertedBlob(null); - setConvertError(null); + setOriginalUrl(null); setRecordingElapsed(0); setMicError(null); - }, []); + }, [discardConversion]); const convertVoice = useCallback(async () => { if (!recordingBlob) return; + const conversionId = ++conversionIdRef.current; setIsConverting(true); + setConvertedUrl(null); setConvertError(null); try { const file = new File([recordingBlob], "recording.webm", { @@ -211,12 +195,15 @@ export default function Home() { } const blob = await res.blob(); - setConvertedBlob(blob); + if (conversionId !== conversionIdRef.current) return; + setConvertedUrl(URL.createObjectURL(blob)); } catch (e) { - setConvertedBlob(null); + if (conversionId !== conversionIdRef.current) return; setConvertError(e instanceof Error ? e.message : "Conversion failed."); } finally { - setIsConverting(false); + if (conversionId === conversionIdRef.current) { + setIsConverting(false); + } } }, [recordingBlob, voiceId]); @@ -267,7 +254,7 @@ export default function Home() { id="voice-select" disabled={voiceSelectDisabled} value={voiceId} - onChange={e => setVoiceId(e.target.value)} + onChange={e => changeVoice(e.target.value)} className="w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm font-medium text-neutral-900 disabled:cursor-not-allowed disabled:opacity-50" > {voices.length === 0 ? ( @@ -361,7 +348,7 @@ export default function Home() { {convertedUrl && !isConverting && (
-

Converted audio

+

Converted Audio