| | <script lang="ts"> |
| | import { onMount, onDestroy } from "svelte"; |
| | import CarbonClose from "~icons/carbon/close"; |
| | import CarbonCheckmark from "~icons/carbon/checkmark"; |
| | import IconArrowUp from "~icons/lucide/arrow-up"; |
| | import EosIconsLoading from "~icons/eos-icons/loading"; |
| | import IconLoading from "$lib/components/icons/IconLoading.svelte"; |
| | import AudioWaveform from "$lib/components/voice/AudioWaveform.svelte"; |
| | |
| | interface Props { |
| | isTranscribing: boolean; |
| | isTouchDevice: boolean; |
| | oncancel: () => void; |
| | onconfirm: (audioBlob: Blob) => void; |
| | onsend: (audioBlob: Blob) => void; |
| | onerror: (message: string) => void; |
| | } |
| | |
| | let { isTranscribing, isTouchDevice, oncancel, onconfirm, onsend, onerror }: Props = $props(); |
| | |
| | let mediaRecorder: MediaRecorder | null = $state(null); |
| | let audioChunks: Blob[] = $state([]); |
| | let analyser: AnalyserNode | null = $state(null); |
| | let frequencyData: Uint8Array = $state(new Uint8Array(32)); |
| | let animationFrameId: number | null = $state(null); |
| | let audioContext: AudioContext | null = $state(null); |
| | let mediaStream: MediaStream | null = $state(null); |
| | |
| | function startVisualization() { |
| | function update() { |
| | if (analyser) { |
| | const data = new Uint8Array(analyser.frequencyBinCount); |
| | analyser.getByteFrequencyData(data); |
| | |
| | frequencyData = data; |
| | } |
| | animationFrameId = requestAnimationFrame(update); |
| | } |
| | update(); |
| | } |
| | |
| | function stopVisualization() { |
| | if (animationFrameId !== null) { |
| | cancelAnimationFrame(animationFrameId); |
| | animationFrameId = null; |
| | } |
| | } |
| | |
| | async function startRecording() { |
| | try { |
| | const stream = await navigator.mediaDevices.getUserMedia({ |
| | audio: { |
| | channelCount: 1, |
| | sampleRate: 16000, |
| | echoCancellation: true, |
| | noiseSuppression: true, |
| | }, |
| | }); |
| | |
| | mediaStream = stream; |
| | |
| | |
| | audioContext = new AudioContext(); |
| | const source = audioContext.createMediaStreamSource(stream); |
| | analyser = audioContext.createAnalyser(); |
| | analyser.fftSize = 64; |
| | analyser.smoothingTimeConstant = 0.4; |
| | source.connect(analyser); |
| | frequencyData = new Uint8Array(analyser.frequencyBinCount); |
| | |
| | |
| | |
| | const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") |
| | ? "audio/webm;codecs=opus" |
| | : "audio/webm"; |
| | |
| | mediaRecorder = new MediaRecorder(stream, { mimeType }); |
| | audioChunks = []; |
| | |
| | mediaRecorder.ondataavailable = (e) => { |
| | if (e.data.size > 0) { |
| | audioChunks = [...audioChunks, e.data]; |
| | } |
| | }; |
| | |
| | mediaRecorder.start(100); |
| | startVisualization(); |
| | } catch (err) { |
| | if (err instanceof DOMException) { |
| | if (err.name === "NotAllowedError") { |
| | onerror("Microphone access denied. Please allow in browser settings."); |
| | } else if (err.name === "NotFoundError") { |
| | onerror("No microphone found."); |
| | } else { |
| | onerror(`Microphone error: ${err.message}`); |
| | } |
| | } else { |
| | onerror("Could not access microphone."); |
| | } |
| | } |
| | } |
| | |
| | function stopRecording(): Promise<Blob | null> { |
| | return new Promise((resolve) => { |
| | stopVisualization(); |
| | |
| | |
| | if (mediaStream) { |
| | mediaStream.getTracks().forEach((track) => track.stop()); |
| | mediaStream = null; |
| | } |
| | |
| | |
| | if (audioContext) { |
| | audioContext.close(); |
| | audioContext = null; |
| | } |
| | analyser = null; |
| | |
| | if (!mediaRecorder || mediaRecorder.state === "inactive") { |
| | mediaRecorder = null; |
| | resolve( |
| | audioChunks.length > 0 |
| | ? new Blob(audioChunks, { type: audioChunks[0]?.type || "audio/webm" }) |
| | : null |
| | ); |
| | return; |
| | } |
| | |
| | |
| | mediaRecorder.onstop = () => { |
| | const mimeType = audioChunks[0]?.type || "audio/webm"; |
| | const blob = audioChunks.length > 0 ? new Blob(audioChunks, { type: mimeType }) : null; |
| | mediaRecorder = null; |
| | resolve(blob); |
| | }; |
| | |
| | mediaRecorder.stop(); |
| | }); |
| | } |
| | |
| | async function handleCancel() { |
| | await stopRecording(); |
| | oncancel(); |
| | } |
| | |
| | async function handleConfirm() { |
| | const audioBlob = await stopRecording(); |
| | if (audioBlob && audioBlob.size > 0) { |
| | if (isTouchDevice) { |
| | onsend(audioBlob); |
| | } else { |
| | onconfirm(audioBlob); |
| | } |
| | } else { |
| | onerror("No audio recorded. Please try again."); |
| | } |
| | } |
| | |
| | onMount(() => { |
| | startRecording(); |
| | }); |
| | |
| | onDestroy(() => { |
| | |
| | stopRecording(); |
| | }); |
| | </script> |
| |
|
| | <div class="flex h-full w-full items-center justify-between px-3 py-1.5"> |
| | |
| | <button |
| | type="button" |
| | class="btn grid size-8 place-items-center rounded-full border bg-white text-black shadow transition-none hover:bg-gray-100 dark:border-transparent dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500 sm:size-7" |
| | onclick={handleCancel} |
| | aria-label="Cancel recording" |
| | > |
| | <CarbonClose class="size-4" /> |
| | </button> |
| |
|
| | |
| | <div class="flex h-12 flex-1 items-center overflow-hidden pl-2.5 pr-1.5"> |
| | {#if isTranscribing} |
| | <div class="flex h-full w-full items-center justify-center"> |
| | <IconLoading classNames="text-gray-400" /> |
| | </div> |
| | {:else} |
| | <AudioWaveform {frequencyData} minHeight={4} maxHeight={40} /> |
| | {/if} |
| | </div> |
| |
|
| | |
| | <button |
| | type="button" |
| | class="btn grid size-8 place-items-center rounded-full border shadow transition-none disabled:opacity-50 sm:size-7 {isTouchDevice |
| | ? 'border-transparent bg-black text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200' |
| | : 'bg-white text-black hover:bg-gray-100 dark:border-transparent dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500'}" |
| | onclick={handleConfirm} |
| | disabled={isTranscribing} |
| | aria-label={isTranscribing |
| | ? "Transcribing..." |
| | : isTouchDevice |
| | ? "Send message" |
| | : "Confirm and transcribe"} |
| | > |
| | {#if isTranscribing} |
| | <EosIconsLoading class="size-4" /> |
| | {:else if isTouchDevice} |
| | <IconArrowUp class="size-4" /> |
| | {:else} |
| | <CarbonCheckmark class="size-4" /> |
| | {/if} |
| | </button> |
| | </div> |
| |
|