1
0

VoiceRecording.svelte 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { createEventDispatcher, tick, getContext } from 'svelte';
  4. import { settings } from '$lib/stores';
  5. import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils';
  6. import { transcribeAudio } from '$lib/apis/audio';
  7. const i18n = getContext('i18n');
  8. const dispatch = createEventDispatcher();
  9. export let recording = false;
  10. let loading = false;
  11. let confirmed = false;
  12. let durationSeconds = 0;
  13. let durationCounter = null;
  14. const startDurationCounter = () => {
  15. durationCounter = setInterval(() => {
  16. durationSeconds++;
  17. }, 1000);
  18. };
  19. const stopDurationCounter = () => {
  20. clearInterval(durationCounter);
  21. durationSeconds = 0;
  22. };
  23. $: if (recording) {
  24. startRecording();
  25. } else {
  26. stopRecording();
  27. }
  28. const formatSeconds = (seconds) => {
  29. const minutes = Math.floor(seconds / 60);
  30. const remainingSeconds = seconds % 60;
  31. const formattedSeconds = remainingSeconds < 10 ? `0${remainingSeconds}` : remainingSeconds;
  32. return `${minutes}:${formattedSeconds}`;
  33. };
  34. let speechRecognition;
  35. let mediaRecorder;
  36. let audioChunks = [];
  37. const MIN_DECIBELS = -45;
  38. const VISUALIZER_BUFFER_LENGTH = 300;
  39. let visualizerData = Array(VISUALIZER_BUFFER_LENGTH).fill(0);
  40. // Function to calculate the RMS level from time domain data
  41. const calculateRMS = (data: Uint8Array) => {
  42. let sumSquares = 0;
  43. for (let i = 0; i < data.length; i++) {
  44. const normalizedValue = (data[i] - 128) / 128; // Normalize the data
  45. sumSquares += normalizedValue * normalizedValue;
  46. }
  47. return Math.sqrt(sumSquares / data.length);
  48. };
  49. const normalizeRMS = (rms) => {
  50. rms = rms * 10;
  51. const exp = 1.5; // Adjust exponent value; values greater than 1 expand larger numbers more and compress smaller numbers more
  52. const scaledRMS = Math.pow(rms, exp);
  53. // Scale between 0.01 (1%) and 1.0 (100%)
  54. return Math.min(1.0, Math.max(0.01, scaledRMS));
  55. };
  56. const analyseAudio = (stream) => {
  57. const audioContext = new AudioContext();
  58. const audioStreamSource = audioContext.createMediaStreamSource(stream);
  59. const analyser = audioContext.createAnalyser();
  60. analyser.minDecibels = MIN_DECIBELS;
  61. audioStreamSource.connect(analyser);
  62. const bufferLength = analyser.frequencyBinCount;
  63. const domainData = new Uint8Array(bufferLength);
  64. const timeDomainData = new Uint8Array(analyser.fftSize);
  65. let lastSoundTime = Date.now();
  66. const detectSound = () => {
  67. const processFrame = () => {
  68. if (recording && !loading) {
  69. analyser.getByteTimeDomainData(timeDomainData);
  70. analyser.getByteFrequencyData(domainData);
  71. // Calculate RMS level from time domain data
  72. const rmsLevel = calculateRMS(timeDomainData);
  73. // Push the calculated decibel level to visualizerData
  74. visualizerData.push(normalizeRMS(rmsLevel));
  75. // Ensure visualizerData array stays within the buffer length
  76. if (visualizerData.length >= VISUALIZER_BUFFER_LENGTH) {
  77. visualizerData.shift();
  78. }
  79. visualizerData = visualizerData;
  80. if (domainData.some((value) => value > 0)) {
  81. lastSoundTime = Date.now();
  82. }
  83. if (recording && Date.now() - lastSoundTime > 3000) {
  84. if ($settings?.speechAutoSend ?? false) {
  85. confirmRecording();
  86. }
  87. }
  88. }
  89. window.requestAnimationFrame(processFrame);
  90. };
  91. window.requestAnimationFrame(processFrame);
  92. };
  93. detectSound();
  94. };
  95. const transcribeHandler = async (audioBlob) => {
  96. // Create a blob from the audio chunks
  97. await tick();
  98. const file = blobToFile(audioBlob, 'recording.wav');
  99. const res = await transcribeAudio(localStorage.token, file).catch((error) => {
  100. toast.error(error);
  101. return null;
  102. });
  103. if (res) {
  104. console.log(res.text);
  105. dispatch('confirm', res.text);
  106. }
  107. };
  108. const saveRecording = (blob) => {
  109. const url = URL.createObjectURL(blob);
  110. const a = document.createElement('a');
  111. document.body.appendChild(a);
  112. a.style = 'display: none';
  113. a.href = url;
  114. a.download = 'recording.wav';
  115. a.click();
  116. window.URL.revokeObjectURL(url);
  117. };
  118. const startRecording = async () => {
  119. startDurationCounter();
  120. const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  121. mediaRecorder = new MediaRecorder(stream);
  122. mediaRecorder.onstart = () => {
  123. console.log('Recording started');
  124. audioChunks = [];
  125. analyseAudio(stream);
  126. };
  127. mediaRecorder.ondataavailable = (event) => audioChunks.push(event.data);
  128. mediaRecorder.onstop = async () => {
  129. console.log('Recording stopped');
  130. if (confirmed) {
  131. const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
  132. await transcribeHandler(audioBlob);
  133. confirmed = false;
  134. loading = false;
  135. }
  136. audioChunks = [];
  137. recording = false;
  138. };
  139. mediaRecorder.start();
  140. };
  141. const stopRecording = async () => {
  142. if (recording && mediaRecorder) {
  143. await mediaRecorder.stop();
  144. }
  145. stopDurationCounter();
  146. audioChunks = [];
  147. };
  148. const confirmRecording = async () => {
  149. loading = true;
  150. confirmed = true;
  151. if (recording && mediaRecorder) {
  152. await mediaRecorder.stop();
  153. }
  154. clearInterval(durationCounter);
  155. };
  156. </script>
  157. <div
  158. class="{loading
  159. ? ' bg-gray-100/50 dark:bg-gray-850/50'
  160. : 'bg-indigo-300/10 dark:bg-indigo-500/10 '} rounded-full flex p-2.5"
  161. >
  162. <div class="flex items-center mr-1">
  163. <button
  164. type="button"
  165. class="p-1.5
  166. {loading
  167. ? ' bg-gray-200 dark:bg-gray-700/50'
  168. : 'bg-indigo-400/20 text-indigo-600 dark:text-indigo-300 '}
  169. rounded-full"
  170. on:click={async () => {
  171. dispatch('cancel');
  172. stopRecording();
  173. }}
  174. >
  175. <svg
  176. xmlns="http://www.w3.org/2000/svg"
  177. fill="none"
  178. viewBox="0 0 24 24"
  179. stroke-width="3"
  180. stroke="currentColor"
  181. class="size-4"
  182. >
  183. <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
  184. </svg>
  185. </button>
  186. </div>
  187. <div
  188. class="flex flex-1 self-center items-center justify-between ml-2 mx-1 overflow-hidden h-6"
  189. dir="rtl"
  190. >
  191. <div class="flex-1 flex items-center gap-0.5 h-6">
  192. {#each visualizerData.slice().reverse() as rms}
  193. <div
  194. class="w-[2px]
  195. {loading
  196. ? ' bg-gray-500 dark:bg-gray-400 '
  197. : 'bg-indigo-500 dark:bg-indigo-400 '}
  198. inline-block h-full"
  199. style="height: {Math.min(100, Math.max(14, rms * 100))}%;"
  200. />
  201. {/each}
  202. </div>
  203. </div>
  204. <div class=" mx-1.5 pr-1 flex justify-center items-center">
  205. <div
  206. class="text-sm
  207. {loading ? ' text-gray-500 dark:text-gray-400 ' : ' text-indigo-400 '}
  208. font-medium flex-1 mx-auto text-center"
  209. >
  210. {formatSeconds(durationSeconds)}
  211. </div>
  212. </div>
  213. <div class="flex items-center mr-1">
  214. {#if loading}
  215. <div class=" text-gray-500 rounded-full cursor-not-allowed">
  216. <svg
  217. width="24"
  218. height="24"
  219. viewBox="0 0 24 24"
  220. xmlns="http://www.w3.org/2000/svg"
  221. fill="currentColor"
  222. ><style>
  223. .spinner_OSmW {
  224. transform-origin: center;
  225. animation: spinner_T6mA 0.75s step-end infinite;
  226. }
  227. @keyframes spinner_T6mA {
  228. 8.3% {
  229. transform: rotate(30deg);
  230. }
  231. 16.6% {
  232. transform: rotate(60deg);
  233. }
  234. 25% {
  235. transform: rotate(90deg);
  236. }
  237. 33.3% {
  238. transform: rotate(120deg);
  239. }
  240. 41.6% {
  241. transform: rotate(150deg);
  242. }
  243. 50% {
  244. transform: rotate(180deg);
  245. }
  246. 58.3% {
  247. transform: rotate(210deg);
  248. }
  249. 66.6% {
  250. transform: rotate(240deg);
  251. }
  252. 75% {
  253. transform: rotate(270deg);
  254. }
  255. 83.3% {
  256. transform: rotate(300deg);
  257. }
  258. 91.6% {
  259. transform: rotate(330deg);
  260. }
  261. 100% {
  262. transform: rotate(360deg);
  263. }
  264. }
  265. </style><g class="spinner_OSmW"
  266. ><rect x="11" y="1" width="2" height="5" opacity=".14" /><rect
  267. x="11"
  268. y="1"
  269. width="2"
  270. height="5"
  271. transform="rotate(30 12 12)"
  272. opacity=".29"
  273. /><rect
  274. x="11"
  275. y="1"
  276. width="2"
  277. height="5"
  278. transform="rotate(60 12 12)"
  279. opacity=".43"
  280. /><rect
  281. x="11"
  282. y="1"
  283. width="2"
  284. height="5"
  285. transform="rotate(90 12 12)"
  286. opacity=".57"
  287. /><rect
  288. x="11"
  289. y="1"
  290. width="2"
  291. height="5"
  292. transform="rotate(120 12 12)"
  293. opacity=".71"
  294. /><rect
  295. x="11"
  296. y="1"
  297. width="2"
  298. height="5"
  299. transform="rotate(150 12 12)"
  300. opacity=".86"
  301. /><rect x="11" y="1" width="2" height="5" transform="rotate(180 12 12)" /></g
  302. ></svg
  303. >
  304. </div>
  305. {:else}
  306. <button
  307. type="button"
  308. class="p-1.5 bg-indigo-500 text-white dark:bg-indigo-500 dark:text-blue-950 rounded-full"
  309. on:click={async () => {
  310. await confirmRecording();
  311. }}
  312. >
  313. <svg
  314. xmlns="http://www.w3.org/2000/svg"
  315. fill="none"
  316. viewBox="0 0 24 24"
  317. stroke-width="2.5"
  318. stroke="currentColor"
  319. class="size-4"
  320. >
  321. <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
  322. </svg>
  323. </button>
  324. {/if}
  325. </div>
  326. </div>
  327. <style>
  328. .visualizer {
  329. display: flex;
  330. height: 100%;
  331. }
  332. .visualizer-bar {
  333. width: 2px;
  334. background-color: #4a5aba; /* or whatever color you need */
  335. }
  336. </style>