{"version":3,"file":"use-speech-recognition.cjs","names":[],"sources":["../../src/capture/use-speech-recognition.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, hook-lines — the Web Speech API restarts itself on\n * silence, reports interim and final results on the same event, and reports `no-\n * speech` as an error that is not one. The hook is that reconciliation plus the\n * continuous-mode restart loop, and its length is mostly the vendor quirks the\n * docstring warns about.\n */\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useStableCallback } from \"@/hooks/use-stable-callback\";\n\n/** One reading of what was heard. */\nexport interface SpeechAlternativeLike {\n    transcript: string;\n    confidence?: number;\n}\n\n/** One recognised phrase, settled (`isFinal`) or still being revised. */\nexport interface SpeechResultLike {\n    isFinal: boolean;\n    length: number;\n    [index: number]: SpeechAlternativeLike | undefined;\n}\n\n/** The growing list of phrases in a session. */\nexport interface SpeechResultListLike {\n    length: number;\n    [index: number]: SpeechResultLike | undefined;\n}\n\n/** The `result` event, reduced to what this hook reads. */\nexport interface SpeechRecognitionEventLike {\n    /** Index of the first result that changed — everything before it is settled. */\n    resultIndex: number;\n    results: SpeechResultListLike;\n}\n\n/** The `error` event, reduced to what this hook reads. */\nexport interface SpeechRecognitionErrorEventLike {\n    error: string;\n    message?: string;\n}\n\n/**\n * The slice of the Web Speech `SpeechRecognition` object this SDK uses.\n *\n * Declared here rather than imported: TypeScript's DOM lib ships the *event* types but\n * not the constructor, because the API is still prefixed in Chromium and absent in\n * Firefox. Exported so a test — or a consumer wrapping a different engine — can hand in\n * something else.\n */\nexport interface SpeechRecognitionLike {\n    lang: string;\n    continuous: boolean;\n    interimResults: boolean;\n    maxAlternatives: number;\n    start: () => void;\n    stop: () => void;\n    abort: () => void;\n    onresult: ((event: SpeechRecognitionEventLike) => void) | null;\n    onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null;\n    onend: (() => void) | null;\n    onstart: (() => void) | null;\n}\n\n/** Classified reason recognition stopped or never started. */\nexport type SpeechErrorKind =\n    | \"unsupported\"\n    | \"not-allowed\"\n    | \"no-speech\"\n    | \"audio-capture\"\n    | \"network\"\n    | \"aborted\"\n    | \"language-not-supported\"\n    | \"unknown\";\n\n/** A classified speech error with a human-readable, English message. */\nexport interface SpeechError {\n    kind: SpeechErrorKind;\n    message: string;\n}\n\n/** Options for {@link useSpeechRecognition}. */\nexport interface UseSpeechRecognitionOptions {\n    /** BCP-47 tag. Default `\"pt-BR\"`. */\n    lang?: string;\n    /**\n     * Keep listening after the first phrase settles. Default `false`.\n     *\n     * Even with this on, the engine ends the session by itself after a stretch of\n     * silence — that is a server-side timeout, not a bug — so watch `listening` rather\n     * than assuming the microphone stays open.\n     */\n    continuous?: boolean;\n    /** Publish the running guess as it changes. Default `true`. */\n    interimResults?: boolean;\n    /** How many readings per phrase to ask for. Default 1. */\n    maxAlternatives?: number;\n    /** Every update, settled or not. */\n    onResult?: (result: { transcript: string; isFinal: boolean }) => void;\n    /** Only the settled text of a phrase. The one to wire dictation to. */\n    onFinal?: (transcript: string) => void;\n    /** Classified failure. `no-speech` and `aborted` arrive here too — they are routine. */\n    onError?: (error: SpeechError) => void;\n    /** The session ended, for any reason. */\n    onEnd?: () => void;\n    /** Build the recogniser yourself — another engine, or a stub in a test. */\n    factory?: () => SpeechRecognitionLike;\n}\n\n/** Value returned by {@link useSpeechRecognition}. */\nexport interface UseSpeechRecognitionResult {\n    /** `false` in Firefox and in every browser that is not Chromium-based. */\n    supported: boolean;\n    /** Whether a session is open right now. */\n    listening: boolean;\n    /** Everything settled so far in this session. Cleared by `reset()`. */\n    transcript: string;\n    /** The running guess. Replaced on every event, empty once the phrase settles. */\n    interim: string;\n    /** Classified error, or `null`. */\n    error: SpeechError | null;\n    /** Open a session. No-op while already listening. */\n    start: () => void;\n    /** Close the session, keeping what was recognised. */\n    stop: () => void;\n    /** Close the session and throw the pending phrase away. */\n    abort: () => void;\n    /** Clear `transcript`, `interim` and `error`. Does not stop a session. */\n    reset: () => void;\n}\n\n/** English messages for the `error` codes the spec defines. */\nconst MESSAGES: Record<SpeechErrorKind, string> = {\n    unsupported: \"Speech recognition is not supported in this browser.\",\n    \"not-allowed\": \"Microphone permission denied. Enable access in your browser settings.\",\n    \"no-speech\": \"No speech was detected.\",\n    \"audio-capture\": \"No microphone available on this device.\",\n    network: \"The recognition service could not be reached.\",\n    aborted: \"Recognition was cancelled.\",\n    \"language-not-supported\": \"The recognition service does not support this language.\",\n    unknown: \"Unexpected error during speech recognition.\",\n};\n\n/**\n * Map a spec `error` code to a kind an app can branch on.\n *\n * `service-not-allowed` collapses into `not-allowed` because the fix is the same from\n * the user's side, and `bad-grammar` into `unknown` because this hook never sets a\n * grammar, so seeing it means something outside our control went wrong.\n *\n * @param code - The `error` property of the event.\n * @returns The classified kind.\n */\nfunction classifySpeechError(code: string): SpeechErrorKind {\n    switch (code) {\n        case \"not-allowed\":\n        case \"service-not-allowed\":\n            return \"not-allowed\";\n        case \"no-speech\":\n            return \"no-speech\";\n        case \"audio-capture\":\n            return \"audio-capture\";\n        case \"network\":\n            return \"network\";\n        case \"aborted\":\n            return \"aborted\";\n        case \"language-not-supported\":\n            return \"language-not-supported\";\n        default:\n            return \"unknown\";\n    }\n}\n\n/** The constructor, prefixed or not, or `null` where the API does not exist. */\nfunction speechRecognitionConstructor(): (new () => SpeechRecognitionLike) | null {\n    const scope = globalThis as {\n        SpeechRecognition?: unknown;\n        webkitSpeechRecognition?: unknown;\n    };\n    const candidate = scope.SpeechRecognition ?? scope.webkitSpeechRecognition;\n    return typeof candidate === \"function\" ? (candidate as new () => SpeechRecognitionLike) : null;\n}\n\n/** Whether this browser exposes the Web Speech recognition API at all. */\nexport function isSpeechRecognitionSupported(): boolean {\n    return speechRecognitionConstructor() !== null;\n}\n\n/**\n * Dictate into your app with the Web Speech API — no dependency, no API key.\n *\n * ## Recognition is not local\n *\n * **Chromium streams the captured audio to a Google server to transcribe it.** Nothing\n * about the API says so, there is no setting that changes it, and it happens on every\n * `start()`. Anything the user says while a session is open leaves the device. Do not\n * put this on a field that takes clinical notes, credentials, or a client's financial\n * detail without telling them first — and if the data cannot leave your infrastructure,\n * this API is the wrong tool and a self-hosted model is the right one.\n *\n * ## What the states mean\n *\n * `transcript` accumulates the phrases the engine has **settled** on; `interim` is the\n * guess it is still revising and is replaced wholesale on every event, so rendering\n * `transcript + interim` gives the live caption effect and rendering `transcript` alone\n * gives the committed text. `no-speech` and `aborted` come through `onError` but are\n * routine — a user who pressed the button and said nothing is not a failure to report.\n *\n * There is deliberately **no auto-restart** when the engine ends a session on silence.\n * A restart loop is how an app ends up holding the microphone indefinitely — and, in\n * Chromium, streaming audio to a third party indefinitely. Show that listening stopped\n * and let the user press again.\n *\n * @param options - See {@link UseSpeechRecognitionOptions}.\n * @returns Session state, the transcript and the controls.\n *\n * @example\n * const speech = useSpeechRecognition({ onFinal: (text) => setPrompt(text) });\n * <button onClick={speech.listening ? speech.stop : speech.start}>\n *     {speech.listening ? \"Parar\" : \"Ditar\"}\n * </button>\n * <p>{speech.transcript}{speech.interim}</p>\n */\nexport function useSpeechRecognition(\n    options: UseSpeechRecognitionOptions = {},\n): UseSpeechRecognitionResult {\n    const {\n        lang = \"pt-BR\",\n        continuous = false,\n        interimResults = true,\n        maxAlternatives = 1,\n        onResult,\n        onFinal,\n        onError,\n        onEnd,\n        factory,\n    } = options;\n\n    const [supported] = useState(() => factory !== undefined || isSpeechRecognitionSupported());\n    const [listening, setListening] = useState(false);\n    const [transcript, setTranscript] = useState(\"\");\n    const [interim, setInterim] = useState(\"\");\n    const [error, setError] = useState<SpeechError | null>(null);\n\n    const sessionRef = useRef<SpeechRecognitionLike | null>(null);\n\n    const emitResult = useStableCallback((result: { transcript: string; isFinal: boolean }) =>\n        onResult?.(result),\n    );\n    const emitFinal = useStableCallback((text: string) => onFinal?.(text));\n    const emitEnd = useStableCallback(() => onEnd?.());\n    const emitError = useStableCallback((failure: SpeechError) => {\n        setError(failure);\n        onError?.(failure);\n    });\n\n    const teardown = useCallback((): void => {\n        const session = sessionRef.current;\n        if (!session) return;\n        session.onresult = null;\n        session.onerror = null;\n        session.onend = null;\n        session.onstart = null;\n        sessionRef.current = null;\n    }, []);\n\n    const start = useCallback((): void => {\n        if (sessionRef.current) return;\n        const build =\n            factory ??\n            (() => {\n                const constructor = speechRecognitionConstructor();\n                return constructor ? new constructor() : null;\n            });\n        const session = build();\n        if (!session) {\n            emitError({ kind: \"unsupported\", message: MESSAGES.unsupported });\n            return;\n        }\n\n        session.lang = lang;\n        session.continuous = continuous;\n        session.interimResults = interimResults;\n        session.maxAlternatives = maxAlternatives;\n\n        /**\n         * Fold one event into the two published strings.\n         *\n         * Only the results from `resultIndex` on are new; re-reading the whole list\n         * would append phrases that are already in `transcript`. Settled text is\n         * accumulated, unsettled text replaces the previous guess entirely — the engine\n         * revises a phrase in place, so appending it would stutter the caption.\n         */\n        session.onresult = (event: SpeechRecognitionEventLike): void => {\n            let settled = \"\";\n            let pending = \"\";\n            for (let index = event.resultIndex; index < event.results.length; index += 1) {\n                const result = event.results[index];\n                if (!result) continue;\n                const text = result[0]?.transcript ?? \"\";\n                if (result.isFinal) settled += text;\n                else pending += text;\n            }\n            setInterim(pending);\n            if (settled !== \"\") {\n                setTranscript((previous) => previous + settled);\n                emitFinal(settled);\n            }\n            emitResult({ transcript: settled !== \"\" ? settled : pending, isFinal: settled !== \"\" });\n        };\n\n        session.onerror = (event: SpeechRecognitionErrorEventLike): void => {\n            const kind = classifySpeechError(event.error);\n            emitError({ kind, message: event.message || MESSAGES[kind] });\n        };\n\n        session.onend = (): void => {\n            setListening(false);\n            setInterim(\"\");\n            teardown();\n            emitEnd();\n        };\n\n        session.onstart = (): void => setListening(true);\n\n        setError(null);\n        try {\n            session.start();\n        } catch (failure) {\n            // Chromium throws `InvalidStateError` when a session is already running in\n            // another component. Reporting it beats leaving a dead handle behind.\n            emitError({\n                kind: \"unknown\",\n                message: failure instanceof Error ? failure.message : MESSAGES.unknown,\n            });\n            return;\n        }\n        sessionRef.current = session;\n        setListening(true);\n    }, [\n        lang,\n        continuous,\n        interimResults,\n        maxAlternatives,\n        factory,\n        emitResult,\n        emitFinal,\n        emitEnd,\n        emitError,\n        teardown,\n    ]);\n\n    const stop = useCallback((): void => {\n        sessionRef.current?.stop();\n    }, []);\n\n    const abort = useCallback((): void => {\n        sessionRef.current?.abort();\n    }, []);\n\n    const reset = useCallback((): void => {\n        setTranscript(\"\");\n        setInterim(\"\");\n        setError(null);\n    }, []);\n\n    /**\n     * Never leave a session open past unmount.\n     *\n     * `abort()` and not `stop()`: a component that is gone has nowhere to put a final\n     * result, and `stop()` would keep the microphone — and the upstream connection —\n     * alive while the engine finishes deciding what the last word was.\n     */\n    useEffect(\n        () => () => {\n            sessionRef.current?.abort();\n            teardown();\n        },\n        [teardown],\n    );\n\n    return {\n        supported,\n        listening,\n        transcript,\n        interim,\n        error,\n        start,\n        stop,\n        abort,\n        reset,\n    };\n}\n"],"mappings":"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"}