|
<script lang="ts"> |
|
import { toast } from 'svelte-sonner'; |
|
import { createEventDispatcher, tick, getContext, onMount, onDestroy } from 'svelte'; |
|
import { config, settings } from '$lib/stores'; |
|
import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils'; |
|
|
|
import { transcribeAudio } from '$lib/apis/audio'; |
|
|
|
const i18n = getContext('i18n'); |
|
|
|
const dispatch = createEventDispatcher(); |
|
|
|
export let recording = false; |
|
export let className = ' p-2.5 w-full max-w-full'; |
|
|
|
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 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 transcribeHandler = async (audioBlob) => { |
|
|
|
|
|
await tick(); |
|
const file = blobToFile(audioBlob, 'recording.wav'); |
|
|
|
const res = await transcribeAudio(localStorage.token, file).catch((error) => { |
|
toast.error(error); |
|
return null; |
|
}); |
|
|
|
if (res) { |
|
console.log(res); |
|
dispatch('confirm', res); |
|
} |
|
}; |
|
|
|
const saveRecording = (blob) => { |
|
const url = URL.createObjectURL(blob); |
|
const a = document.createElement('a'); |
|
document.body.appendChild(a); |
|
a.style = 'display: none'; |
|
a.href = url; |
|
a.download = 'recording.wav'; |
|
a.click(); |
|
window.URL.revokeObjectURL(url); |
|
}; |
|
|
|
const startRecording = async () => { |
|
startDurationCounter(); |
|
|
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
|
mediaRecorder = new MediaRecorder(stream); |
|
mediaRecorder.onstart = () => { |
|
console.log('Recording started'); |
|
audioChunks = []; |
|
analyseAudio(stream); |
|
}; |
|
mediaRecorder.ondataavailable = (event) => audioChunks.push(event.data); |
|
mediaRecorder.onstop = async () => { |
|
console.log('Recording stopped'); |
|
if (($settings?.audio?.stt?.engine ?? '') === 'web') { |
|
audioChunks = []; |
|
} else { |
|
if (confirmed) { |
|
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' }); |
|
|
|
await transcribeHandler(audioBlob); |
|
|
|
confirmed = false; |
|
loading = false; |
|
} |
|
audioChunks = []; |
|
recording = false; |
|
} |
|
}; |
|
mediaRecorder.start(); |
|
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(); |
|
dispatch('confirm', transcription); |
|
|
|
confirmed = false; |
|
loading = false; |
|
}; |
|
|
|
|
|
speechRecognition.onerror = function (event) { |
|
console.log(event); |
|
toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error })); |
|
dispatch('cancel'); |
|
|
|
stopRecording(); |
|
}; |
|
} |
|
} |
|
}; |
|
|
|
const stopRecording = async () => { |
|
if (recording && mediaRecorder) { |
|
await mediaRecorder.stop(); |
|
} |
|
stopDurationCounter(); |
|
audioChunks = []; |
|
|
|
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); |
|
|
|
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); |
|
|
|
onMount(() => { |
|
|
|
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(() => { |
|
|
|
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 () => { |
|
dispatch('cancel'); |
|
stopRecording(); |
|
}} |
|
> |
|
<svg |
|
xmlns="http://www.w3.org/2000/svg" |
|
fill="none" |
|
viewBox="0 0 24 24" |
|
stroke-width="3" |
|
stroke="currentColor" |
|
class="size-4" |
|
> |
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> |
|
</svg> |
|
</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] flex-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 |
|
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> |
|
|