{"version":3,"file":"AudioRecorder.cjs","names":[],"sources":["../../../src/components/AudioRecorder/AudioRecorder.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, props-count, function-lines — recording has three\n * separate configurations that a caller genuinely picks between: the capture\n * (deviceId, audioBitsPerSecond, maxDurationMs), the output (format, wavOptions) and\n * the review step (review, footer, locale). The body holds permission, recording,\n * review and error as one state machine; splitting it would thread the same recorder\n * handle through props.\n */\nimport { Mic, Pause, Play, RotateCcw, Square } from \"lucide-react\";\nimport { useEffect, useState, type HTMLAttributes, type ReactNode } from \"react\";\n\nimport { formatDuration } from \"@/audio/duration\";\nimport { isAudioRecordingSupported } from \"@/audio/audio-recorder\";\nimport { useAudioRecorder } from \"@/audio/use-audio-recorder\";\nimport { useMicrophone } from \"@/audio/use-microphone\";\nimport { blobToWav, type WavOptions } from \"@/audio/wav\";\nimport type { AudioRecording } from \"@/audio/audio-recorder\";\nimport { useMediaPermission } from \"@/hooks/use-media-permission\";\nimport { cn } from \"@/utils/cn\";\n\nimport { AudioPlayer } from \"../AudioPlayer\";\nimport styles from \"./AudioRecorder.module.css\";\n\n/** DOM attributes this component redefines. */\ntype OverriddenDomProps = \"children\" | \"onError\";\n\nexport interface AudioRecorderProps extends Omit<\n    HTMLAttributes<HTMLDivElement>,\n    OverriddenDomProps\n> {\n    /**\n     * Called once a recording is finished and (when asked) converted.\n     *\n     * Fires on stop and on hitting `maxDurationMs`, never on cancel.\n     */\n    onRecorded?: (recording: AudioRecording) => void;\n    /** Stop automatically after this long. Strongly recommended on anything public. */\n    maxDurationMs?: number;\n    /** Specific microphone, from `useMediaDevices().audioInputs`. */\n    deviceId?: string;\n    /**\n     * Output container.\n     *\n     * `\"native\"` (default) keeps what the browser produced — Opus on Chromium and\n     * Firefox, AAC on Safari — which is the smallest and needs no work. `\"wav\"`\n     * converts on stop, for a backend that only accepts WAV; see `wavOptions`, and\n     * expect roughly ten times the bytes.\n     */\n    format?: \"native\" | \"wav\";\n    /** Passed to `blobToWav` when `format=\"wav\"`. `{ mono: true, sampleRate: 16000 }` suits speech. */\n    wavOptions?: WavOptions;\n    /** Target bitrate. 32000–64000 is plenty for speech in Opus. */\n    audioBitsPerSecond?: number;\n    /** Offer playback of the recording before handing it over. Default `true`. */\n    review?: boolean;\n    /** Locale for the labels. Default `\"pt-BR\"`. */\n    locale?: \"pt-BR\" | \"en\";\n    /** Rendered under the controls — a hint, a character count, a legal notice. */\n    footer?: ReactNode;\n    /** Recorder-level failure, or a failed WAV conversion. */\n    onError?: (error: unknown) => void;\n    disabled?: boolean;\n}\n\nconst STRINGS = {\n    \"pt-BR\": {\n        record: \"Gravar\",\n        stop: \"Parar\",\n        pause: \"Pausar\",\n        resume: \"Continuar\",\n        retake: \"Gravar de novo\",\n        level: \"Nível do microfone\",\n        asking: \"Pedindo acesso ao microfone…\",\n        denied: \"Acesso ao microfone bloqueado. Libere nas configurações do site e recarregue.\",\n        unsupported: \"Este navegador não grava áudio.\",\n        idleHint: \"Toque em Gravar para começar.\",\n        recording: \"Gravando\",\n        paused: \"Pausado\",\n        converting: \"Convertendo…\",\n    },\n    en: {\n        record: \"Record\",\n        stop: \"Stop\",\n        pause: \"Pause\",\n        resume: \"Resume\",\n        retake: \"Record again\",\n        level: \"Microphone level\",\n        asking: \"Requesting microphone access…\",\n        denied: \"Microphone access blocked. Allow it in site settings and reload.\",\n        unsupported: \"This browser cannot record audio.\",\n        idleHint: \"Press Record to start.\",\n        recording: \"Recording\",\n        paused: \"Paused\",\n        converting: \"Converting…\",\n    },\n} as const;\n\n/**\n * Record a voice note: one button to start, a live level meter, a clock, and a\n * playback review before the audio leaves the component.\n *\n * The permission flow is the part worth reading. The microphone is **not** opened on\n * mount — the prompt fires on the first press of Record, because a prompt the user did\n * not provoke is the most reliable way to earn a permanent block, after which\n * `getUserMedia` rejects without ever asking again. When the permission is already\n * `\"denied\"`, the component says so and how to fix it instead of offering a button\n * that cannot work.\n *\n * The level meter is not decoration either: a muted OS input or a headset with its mic\n * arm folded up produces a perfectly successful recording of silence, and without a\n * visible level the user only finds out after they finish talking.\n *\n * @example\n * <AudioRecorder\n *     maxDurationMs={120_000}\n *     format=\"wav\"\n *     wavOptions={{ mono: true, sampleRate: 16000 }}\n *     onRecorded={({ blob }) => upload(blob)}\n * />\n */\nexport function AudioRecorder({\n    onRecorded,\n    maxDurationMs,\n    deviceId,\n    format = \"native\",\n    wavOptions,\n    audioBitsPerSecond,\n    review = true,\n    locale = \"pt-BR\",\n    footer,\n    onError,\n    disabled = false,\n    className,\n    ...rest\n}: AudioRecorderProps) {\n    const strings = STRINGS[locale];\n    const permission = useMediaPermission(\"microphone\");\n    const mic = useMicrophone({ deviceId });\n    const [converting, setConverting] = useState(false);\n    const [result, setResult] = useState<AudioRecording | null>(null);\n    const [supported, setSupported] = useState(true);\n\n    useEffect(() => setSupported(isAudioRecordingSupported()), []);\n\n    /**\n     * Convert if asked, then publish.\n     *\n     * The conversion is awaited here rather than left to the caller so that\n     * `onRecorded` only ever fires with the format the caller asked for — a callback\n     * that sometimes hands over WebM and sometimes WAV is a bug waiting in the upload\n     * path.\n     */\n    const finish = async (recording: AudioRecording): Promise<void> => {\n        if (format !== \"wav\") {\n            setResult(recording);\n            onRecorded?.(recording);\n            return;\n        }\n        setConverting(true);\n        try {\n            const blob = await blobToWav(recording.blob, wavOptions);\n            const converted: AudioRecording = { ...recording, blob, mimeType: \"audio/wav\" };\n            setResult(converted);\n            onRecorded?.(converted);\n        } catch (error) {\n            onError?.(error);\n            setResult(recording);\n            onRecorded?.(recording);\n        } finally {\n            setConverting(false);\n        }\n    };\n\n    const recorder = useAudioRecorder(mic.stream, {\n        maxDurationMs,\n        audioBitsPerSecond,\n        onRecorded: (recording) => void finish(recording),\n        onError,\n    });\n\n    /**\n     * Start recording as soon as the stream lands.\n     *\n     * The user pressed Record before the permission prompt existed, so the press has\n     * to survive the round-trip: `mic.start()` opens the device, the stream arrives a\n     * few hundred milliseconds later, and recording begins then. Waiting for a second\n     * press would make the first one look broken.\n     */\n    const [armed, setArmed] = useState(false);\n    useEffect(() => {\n        if (!armed || !recorder.ready || recorder.status !== \"idle\") return;\n        setArmed(false);\n        recorder.start();\n    }, [armed, recorder.ready, recorder.status, recorder]);\n\n    const begin = (): void => {\n        setResult(null);\n        if (mic.stream && recorder.ready) {\n            recorder.start();\n            return;\n        }\n        setArmed(true);\n        mic.start();\n    };\n\n    const retake = (): void => {\n        setResult(null);\n        recorder.cancel();\n        begin();\n    };\n\n    const busy = recorder.status === \"recording\" || recorder.status === \"paused\";\n    const blocked = permission.state === \"denied\";\n\n    if (!supported) {\n        return (\n            <div className={cn(styles.recorder, className)} {...rest}>\n                <p className={styles.notice}>{strings.unsupported}</p>\n            </div>\n        );\n    }\n\n    return (\n        <div className={cn(styles.recorder, className)} {...rest}>\n            <div className={styles.row}>\n                {!busy && (\n                    <button\n                        type=\"button\"\n                        className={styles.record}\n                        onClick={result ? retake : begin}\n                        disabled={disabled || blocked || converting}\n                        aria-label={result ? strings.retake : strings.record}\n                    >\n                        {result ? (\n                            <RotateCcw size={16} aria-hidden />\n                        ) : (\n                            <Mic size={16} aria-hidden />\n                        )}\n                        <span>{result ? strings.retake : strings.record}</span>\n                    </button>\n                )}\n\n                {busy && (\n                    <>\n                        <button\n                            type=\"button\"\n                            className={styles.stop}\n                            onClick={() => void recorder.stop()}\n                            aria-label={strings.stop}\n                        >\n                            <Square size={14} aria-hidden />\n                            <span>{strings.stop}</span>\n                        </button>\n                        <button\n                            type=\"button\"\n                            className={styles.secondary}\n                            onClick={\n                                recorder.status === \"paused\" ? recorder.resume : recorder.pause\n                            }\n                            aria-label={\n                                recorder.status === \"paused\" ? strings.resume : strings.pause\n                            }\n                        >\n                            {recorder.status === \"paused\" ? (\n                                <Play size={14} aria-hidden />\n                            ) : (\n                                <Pause size={14} aria-hidden />\n                            )}\n                        </button>\n                    </>\n                )}\n\n                {busy && (\n                    <div\n                        className={styles.meter}\n                        role=\"meter\"\n                        aria-label={strings.level}\n                        aria-valuemin={0}\n                        aria-valuemax={100}\n                        aria-valuenow={Math.round(recorder.level * 100)}\n                    >\n                        <span\n                            className={styles.meterFill}\n                            style={{ transform: `scaleX(${recorder.level})` }}\n                        />\n                    </div>\n                )}\n\n                <span className={styles.clock}>\n                    {formatDuration(busy ? recorder.durationMs : (result?.durationMs ?? 0))}\n                    {maxDurationMs !== undefined && (\n                        <span className={styles.max}> / {formatDuration(maxDurationMs)}</span>\n                    )}\n                </span>\n            </div>\n\n            <p className={styles.status} role=\"status\">\n                {converting\n                    ? strings.converting\n                    : mic.status === \"requesting\"\n                      ? strings.asking\n                      : blocked\n                        ? strings.denied\n                        : mic.error\n                          ? mic.error.message\n                          : recorder.status === \"recording\"\n                            ? strings.recording\n                            : recorder.status === \"paused\"\n                              ? strings.paused\n                              : result\n                                ? \"\"\n                                : strings.idleHint}\n            </p>\n\n            {review && result && (\n                <AudioPlayer\n                    src={result.blob}\n                    durationMs={result.durationMs}\n                    locale={locale}\n                    onError={onError}\n                />\n            )}\n\n            {footer && <div className={styles.footer}>{footer}</div>}\n        </div>\n    );\n}\n"],"mappings":"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"}