| <script lang="ts"> |
| import { toast } from 'svelte-sonner'; |
| import { tick, getContext, onMount, onDestroy } from 'svelte'; |
| import { config, settings } from '$lib/stores'; |
| import { blobToFile, calculateSHA256, extractCurlyBraceWords } from '$lib/utils'; |
| |
| import { transcribeAudio } from '$lib/apis/audio'; |
| import XMark from '$lib/components/icons/XMark.svelte'; |
| |
| import dayjs from 'dayjs'; |
| import LocalizedFormat from 'dayjs/plugin/localizedFormat'; |
| dayjs.extend(LocalizedFormat); |
| |
| const i18n = getContext('i18n'); |
| |
| export let recording = false; |
| export let transcribe = true; |
| export let displayMedia = false; |
| |
| export let echoCancellation = true; |
| export let noiseSuppression = true; |
| export let autoGainControl = true; |
| |
| export let className = ' p-2.5 w-full max-w-full'; |
| |
| export let onCancel = () => {}; |
| export let onConfirm = (data) => {}; |
| |
| let loading = false; |
| let confirmed = false; |
| |
| let durationSeconds = 0; |
| let durationCounter = null; |
| |
| let transcription = ''; |
| |
| const startDurationCounter = () => { |
| durationCounter = setInterval(() => { |
| durationSeconds++; |
| }, 1000); |
| }; |
| |
| const stopDurationCounter = () => { |
| clearInterval(durationCounter); |
| durationSeconds = 0; |
| }; |
| |
| $: if (recording) { |
| startRecording(); |
| } else { |
| stopRecording(); |
| } |
| |
| const formatSeconds = (seconds) => { |
| const minutes = Math.floor(seconds / 60); |
| const remainingSeconds = seconds % 60; |
| const formattedSeconds = remainingSeconds < 10 ? `0${remainingSeconds}` : remainingSeconds; |
| return `${minutes}:${formattedSeconds}`; |
| }; |
| |
| let wakeLock = null; |
| |
| const requestWakeLock = async () => { |
| if ('wakeLock' in navigator) { |
| try { |
| wakeLock = await navigator.wakeLock.request('screen'); |
| console.log('Wake Lock acquired'); |
| |
| wakeLock.addEventListener('release', () => { |
| console.log('Wake Lock released'); |
| }); |
| } catch (err) { |
| console.log('Wake Lock request failed:', err); |
| } |
| } |
| }; |
| |
| const releaseWakeLock = async () => { |
| if (wakeLock) { |
| try { |
| await wakeLock.release(); |
| } catch (err) { |
| console.log('Wake Lock release failed:', err); |
| } |
| wakeLock = null; |
| } |
| }; |
| |
| let stream; |
| let speechRecognition; |
| |
| let mediaRecorder; |
| let audioChunks = []; |
| |
| const MIN_DECIBELS = -45; |
| let VISUALIZER_BUFFER_LENGTH = 300; |
| |
| let visualizerData = Array(VISUALIZER_BUFFER_LENGTH).fill(0); |
| |
| |
| const calculateRMS = (data: Uint8Array) => { |
| let sumSquares = 0; |
| for (let i = 0; i < data.length; i++) { |
| const normalizedValue = (data[i] - 128) / 128; |
| sumSquares += normalizedValue * normalizedValue; |
| } |
| return Math.sqrt(sumSquares / data.length); |
| }; |
| |
| const normalizeRMS = (rms) => { |
| rms = rms * 10; |
| const exp = 1.5; |
| const scaledRMS = Math.pow(rms, exp); |
| |
| |
| return Math.min(1.0, Math.max(0.01, scaledRMS)); |
| }; |
| |
| const analyseAudio = (stream) => { |
| const audioContext = new AudioContext(); |
| const audioStreamSource = audioContext.createMediaStreamSource(stream); |
| |
| const analyser = audioContext.createAnalyser(); |
| analyser.minDecibels = MIN_DECIBELS; |
| audioStreamSource.connect(analyser); |
| |
| const bufferLength = analyser.frequencyBinCount; |
| |
| const domainData = new Uint8Array(bufferLength); |
| const timeDomainData = new Uint8Array(analyser.fftSize); |
| |
| let lastSoundTime = Date.now(); |
| |
| const detectSound = () => { |
| const processFrame = () => { |
| if (!recording || loading) return; |
| |
| if (recording && !loading) { |
| analyser.getByteTimeDomainData(timeDomainData); |
| analyser.getByteFrequencyData(domainData); |
| |
| |
| const rmsLevel = calculateRMS(timeDomainData); |
| |
| visualizerData.push(normalizeRMS(rmsLevel)); |
| |
| |
| if (visualizerData.length >= VISUALIZER_BUFFER_LENGTH) { |
| visualizerData.shift(); |
| } |
| |
| visualizerData = visualizerData; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| } |
| |
| window.requestAnimationFrame(processFrame); |
| }; |
| |
| window.requestAnimationFrame(processFrame); |
| }; |
| |
| detectSound(); |
| }; |
| |
| const onStopHandler = async (audioBlob, ext: string = 'wav') => { |
| |
| |
| await tick(); |
| const file = blobToFile(audioBlob, `Recording-${dayjs().format('L LT')}.${ext}`); |
| |
| if (transcribe) { |
| if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') { |
| |
| return; |
| } |
| |
| const res = await transcribeAudio( |
| localStorage.token, |
| file, |
| $settings?.audio?.stt?.language |
| ).catch((error) => { |
| toast.error(`${error}`); |
| return null; |
| }); |
| |
| if (res) { |
| console.log(res); |
| onConfirm(res); |
| } |
| } else { |
| onConfirm({ |
| file: file, |
| blob: audioBlob |
| }); |
| } |
| }; |
| |
| const startRecording = async () => { |
| loading = true; |
| |
| try { |
| if (displayMedia) { |
| const mediaStream = await navigator.mediaDevices.getDisplayMedia({ |
| audio: true |
| }); |
| |
| stream = new MediaStream(); |
| for (const track of mediaStream.getAudioTracks()) { |
| stream.addTrack(track); |
| } |
| |
| for (const track of mediaStream.getVideoTracks()) { |
| track.stop(); |
| } |
| } else { |
| stream = await navigator.mediaDevices.getUserMedia({ |
| audio: { |
| echoCancellation: echoCancellation, |
| noiseSuppression: noiseSuppression, |
| autoGainControl: autoGainControl |
| } |
| }); |
| } |
| } catch (err) { |
| console.error('Error accessing media devices.', err); |
| toast.error($i18n.t('Error accessing media devices.')); |
| loading = false; |
| recording = false; |
| return; |
| } |
| |
| const mineTypes = [ |
| 'audio/webm; codecs=opus', |
| 'audio/webm', |
| 'audio/ogg; codecs=opus', |
| 'audio/mp4', |
| 'audio/wav' |
| ]; |
| |
| mediaRecorder = new MediaRecorder(stream, { |
| mimeType: mineTypes.find((type) => MediaRecorder.isTypeSupported(type)) |
| }); |
| |
| mediaRecorder.onstart = async () => { |
| console.log('Recording started'); |
| loading = false; |
| startDurationCounter(); |
| |
| await requestWakeLock(); |
| |
| audioChunks = []; |
| analyseAudio(stream); |
| }; |
| mediaRecorder.ondataavailable = (event) => audioChunks.push(event.data); |
| mediaRecorder.onstop = async () => { |
| console.log('Recording stopped'); |
| |
| if (confirmed) { |
| |
| let type = audioChunks[0]?.type || mediaRecorder.mimeType || 'audio/webm'; |
| |
| |
| let ext = type.split('/')[1].split(';')[0] || 'webm'; |
| |
| |
| if (!type.startsWith('audio/')) { |
| ext = 'webm'; |
| } |
| |
| const audioBlob = new Blob(audioChunks, { type: type }); |
| await onStopHandler(audioBlob, ext); |
| |
| confirmed = false; |
| loading = false; |
| } |
| |
| audioChunks = []; |
| recording = false; |
| }; |
| |
| try { |
| mediaRecorder.start(); |
| } catch (error) { |
| console.error('Error starting recording:', error); |
| toast.error($i18n.t('Error starting recording.')); |
| loading = false; |
| recording = false; |
| return; |
| } |
| |
| if (transcribe) { |
| if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') { |
| if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) { |
| |
| speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)(); |
| |
| |
| speechRecognition.continuous = true; |
| |
| |
| const inactivityTimeout = 2000; |
| |
| let timeoutId; |
| |
| speechRecognition.start(); |
| |
| |
| speechRecognition.onresult = async (event) => { |
| |
| clearTimeout(timeoutId); |
| |
| |
| console.log(event); |
| const transcript = event.results[Object.keys(event.results).length - 1][0].transcript; |
| |
| transcription = `${transcription}${transcript}`; |
| |
| await tick(); |
| document.getElementById('chat-input')?.focus(); |
| |
| |
| timeoutId = setTimeout(() => { |
| console.log('Speech recognition turned off due to inactivity.'); |
| speechRecognition.stop(); |
| }, inactivityTimeout); |
| }; |
| |
| |
| speechRecognition.onend = function () { |
| |
| console.log('recognition ended'); |
| |
| confirmRecording(); |
| onConfirm({ |
| text: transcription |
| }); |
| confirmed = false; |
| loading = false; |
| }; |
| |
| |
| speechRecognition.onerror = function (event) { |
| console.log(event); |
| toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error })); |
| onCancel(); |
| |
| stopRecording(); |
| }; |
| } |
| } |
| } |
| }; |
| |
| const stopRecording = async () => { |
| if (recording && mediaRecorder) { |
| await mediaRecorder.stop(); |
| } |
| |
| if (speechRecognition) { |
| speechRecognition.stop(); |
| } |
| |
| await releaseWakeLock(); |
| |
| stopDurationCounter(); |
| audioChunks = []; |
| visualizerData = Array(VISUALIZER_BUFFER_LENGTH).fill(0); |
| |
| if (stream) { |
| const tracks = stream.getTracks(); |
| tracks.forEach((track) => track.stop()); |
| } |
| |
| stream = null; |
| }; |
| |
| const confirmRecording = async () => { |
| loading = true; |
| confirmed = true; |
| |
| if (recording && mediaRecorder) { |
| await mediaRecorder.stop(); |
| } |
| clearInterval(durationCounter); |
| |
| await releaseWakeLock(); |
| |
| if (stream) { |
| const tracks = stream.getTracks(); |
| tracks.forEach((track) => track.stop()); |
| } |
| |
| stream = null; |
| }; |
| |
| let resizeObserver; |
| let containerWidth; |
| |
| let maxVisibleItems = 300; |
| $: maxVisibleItems = Math.floor(containerWidth / 5); |
| |
| const handleKeyDown = (e) => { |
| if (e.key === 'Escape') { |
| e.preventDefault(); |
| stopRecording(); |
| onCancel(); |
| } |
| }; |
| |
| const handleVisibilityChange = async () => { |
| if (recording && document.visibilityState === 'visible') { |
| await requestWakeLock(); |
| } |
| }; |
| |
| onMount(() => { |
| window.addEventListener('keydown', handleKeyDown); |
| document.addEventListener('visibilitychange', handleVisibilityChange); |
| |
| |
| resizeObserver = new ResizeObserver(() => { |
| VISUALIZER_BUFFER_LENGTH = Math.floor(window.innerWidth / 4); |
| if (visualizerData.length > VISUALIZER_BUFFER_LENGTH) { |
| visualizerData = visualizerData.slice(visualizerData.length - VISUALIZER_BUFFER_LENGTH); |
| } else { |
| visualizerData = Array(VISUALIZER_BUFFER_LENGTH - visualizerData.length) |
| .fill(0) |
| .concat(visualizerData); |
| } |
| }); |
| |
| resizeObserver.observe(document.body); |
| }); |
| |
| onDestroy(() => { |
| window.removeEventListener('keydown', handleKeyDown); |
| document.removeEventListener('visibilitychange', handleVisibilityChange); |
| releaseWakeLock(); |
| |
| resizeObserver.disconnect(); |
| }); |
| </script> |
|
|
| <div |
| bind:clientWidth={containerWidth} |
| class="{loading |
| ? ' bg-gray-100/50 dark:bg-gray-850/50' |
| : 'bg-indigo-300/10 dark:bg-indigo-500/10 '} rounded-full flex justify-between {className}" |
| > |
| <div class="flex items-center mr-1"> |
| <button |
| type="button" |
| class="p-1.5 |
| |
| {loading |
| ? ' bg-gray-200 dark:bg-gray-700/50' |
| : 'bg-indigo-400/20 text-indigo-600 dark:text-indigo-300 '} |
| |
| |
| rounded-full" |
| on:click={async () => { |
| stopRecording(); |
| onCancel(); |
| }} |
| > |
| <XMark className={'size-4'} /> |
| </button> |
| </div> |
|
|
| <div |
| class="flex flex-1 self-center items-center justify-between ml-2 mx-1 overflow-hidden h-6" |
| dir="rtl" |
| > |
| <div |
| class="flex items-center gap-0.5 h-6 w-full max-w-full overflow-hidden overflow-x-hidden flex-wrap" |
| > |
| {#each visualizerData.slice().reverse() as rms} |
| <div class="flex items-center h-full"> |
| <div |
| class="w-[2px] shrink-0 |
| |
| {loading |
| ? ' bg-gray-500 dark:bg-gray-400 ' |
| : 'bg-indigo-500 dark:bg-indigo-400 '} |
| |
| inline-block h-full" |
| style="height: {Math.min(100, Math.max(14, rms * 100))}%;" |
| /> |
| </div> |
| {/each} |
| </div> |
| </div> |
|
|
| <div class="flex"> |
| <div class=" mx-1.5 pr-1 flex justify-center items-center"> |
| <div |
| class="text-sm |
| |
| |
| {loading ? ' text-gray-500 dark:text-gray-400 ' : ' text-indigo-400 '} |
| font-medium flex-1 mx-auto text-center" |
| > |
| {formatSeconds(durationSeconds)} |
| </div> |
| </div> |
|
|
| <div class="flex items-center"> |
| {#if loading} |
| <div class=" text-gray-500 rounded-full cursor-not-allowed"> |
| <svg |
| width="24" |
| height="24" |
| viewBox="0 0 24 24" |
| xmlns="http://www.w3.org/2000/svg" |
| fill="currentColor" |
| ><style> |
| .spinner_OSmW { |
| transform-origin: center; |
| animation: spinner_T6mA 0.75s step-end infinite; |
| } |
| @keyframes spinner_T6mA { |
| 8.3% { |
| transform: rotate(30deg); |
| } |
| 16.6% { |
| transform: rotate(60deg); |
| } |
| 25% { |
| transform: rotate(90deg); |
| } |
| 33.3% { |
| transform: rotate(120deg); |
| } |
| 41.6% { |
| transform: rotate(150deg); |
| } |
| 50% { |
| transform: rotate(180deg); |
| } |
| 58.3% { |
| transform: rotate(210deg); |
| } |
| 66.6% { |
| transform: rotate(240deg); |
| } |
| 75% { |
| transform: rotate(270deg); |
| } |
| 83.3% { |
| transform: rotate(300deg); |
| } |
| 91.6% { |
| transform: rotate(330deg); |
| } |
| 100% { |
| transform: rotate(360deg); |
| } |
| } |
| </style><g class="spinner_OSmW" |
| ><rect x="11" y="1" width="2" height="5" opacity=".14" /><rect |
| x="11" |
| y="1" |
| width="2" |
| height="5" |
| transform="rotate(30 12 12)" |
| opacity=".29" |
| /><rect |
| x="11" |
| y="1" |
| width="2" |
| height="5" |
| transform="rotate(60 12 12)" |
| opacity=".43" |
| /><rect |
| x="11" |
| y="1" |
| width="2" |
| height="5" |
| transform="rotate(90 12 12)" |
| opacity=".57" |
| /><rect |
| x="11" |
| y="1" |
| width="2" |
| height="5" |
| transform="rotate(120 12 12)" |
| opacity=".71" |
| /><rect |
| x="11" |
| y="1" |
| width="2" |
| height="5" |
| transform="rotate(150 12 12)" |
| opacity=".86" |
| /><rect x="11" y="1" width="2" height="5" transform="rotate(180 12 12)" /></g |
| ></svg |
| > |
| </div> |
| {:else} |
| <button |
| id="confirm-recording-button" |
| type="button" |
| class="p-1.5 bg-indigo-500 text-white dark:bg-indigo-500 dark:text-blue-950 rounded-full" |
| on:click={async () => { |
| await confirmRecording(); |
| }} |
| > |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke-width="2.5" |
| stroke="currentColor" |
| class="size-4" |
| > |
| <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> |
| </svg> |
| </button> |
| {/if} |
| </div> |
| </div> |
| </div> |
|
|
| <style> |
| .visualizer { |
| display: flex; |
| height: 100%; |
| } |
| |
| .visualizer-bar { |
| width: 2px; |
| background-color: #4a5aba; |
| } |
| </style> |
|
|