diff --git a/agents/nextjs/quickstart/example/app/page.tsx b/agents/nextjs/quickstart/example/app/page.tsx index 9c399555..b4debd8e 100644 --- a/agents/nextjs/quickstart/example/app/page.tsx +++ b/agents/nextjs/quickstart/example/app/page.tsx @@ -37,7 +37,7 @@ type VoiceAgentPageProps = { setSessionError: (value: string | null) => void; setStarting: (value: boolean) => void; starting: boolean; - nextLineId: RefObject; + nextLineIdRef: RefObject; }; type ConversationMessage = { @@ -88,7 +88,7 @@ function VoiceAgentPage({ createError, creating, lines, - nextLineId, + nextLineIdRef, sessionError, setAgentIdInput, setAgentLookupError, @@ -167,7 +167,7 @@ function VoiceAgentPage({ } setStarting(true); - nextLineId.current = 0; + nextLineIdRef.current = 0; setLines([]); try { @@ -458,7 +458,7 @@ export default function Home() { createError={createError} creating={creating} lines={lines} - nextLineId={nextLineId} + nextLineIdRef={nextLineId} sessionError={sessionError} setAgentIdInput={setAgentIdInput} setAgentLookupError={setAgentLookupError} 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/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} diff --git a/scripts/generate-examples.sh b/scripts/generate-examples.sh index 8aa80ed3..25ff50ac 100644 --- a/scripts/generate-examples.sh +++ b/scripts/generate-examples.sh @@ -110,6 +110,45 @@ 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. 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 + if ! pnpm install --config.confirmModulesPurge=false; then + rm -rf node_modules + exit 1 + fi + fi + if [[ -f requirements.txt && ! -d .venv ]]; then + 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 + ) +} + format_duration() { local total_seconds="$1" @@ -354,24 +393,45 @@ 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=() + 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" 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") + dependency_install_only=1 + 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 + 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 @@ -398,16 +458,35 @@ 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}."$'\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." + 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 diff --git a/sound-effects/typescript/quickstart/example/index.ts b/sound-effects/typescript/quickstart/example/index.ts index 46614eb8..fc089c21 100644 --- a/sound-effects/typescript/quickstart/example/index.ts +++ b/sound-effects/typescript/quickstart/example/index.ts @@ -3,6 +3,7 @@ import { ElevenLabsClient, ElevenLabsError } from "@elevenlabs/elevenlabs-js"; import { createWriteStream } from "node:fs"; import { pipeline } from "node:stream/promises"; import { Readable } from "node:stream"; +import type { ReadableStream as NodeReadableStream } from "node:stream/web"; const DEFAULT_PROMPT = "Cinematic Braam, Horror"; const OUTPUT_FILE = "output.mp3"; @@ -25,7 +26,10 @@ async function main(): Promise { const client = new ElevenLabsClient(); const audio = await client.textToSoundEffects.convert({ text }); - const source = Readable.fromWeb(audio); + // The SDK returns the DOM ReadableStream type, which Node's typings reject. + const source = Readable.fromWeb( + audio as unknown as NodeReadableStream + ); await pipeline(source, createWriteStream(OUTPUT_FILE)); console.log(`Sound effect saved to ${OUTPUT_FILE}`); 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}
diff --git a/text-to-speech/typescript/quickstart/example/index.ts b/text-to-speech/typescript/quickstart/example/index.ts index b618f584..4f716584 100644 --- a/text-to-speech/typescript/quickstart/example/index.ts +++ b/text-to-speech/typescript/quickstart/example/index.ts @@ -16,7 +16,7 @@ try { }); // Collect all chunks into a buffer - const chunks: Buffer[] = []; + const chunks: Uint8Array[] = []; for await (const chunk of audio) { chunks.push(chunk); } 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-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