CallOverlay.svelte 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972
  1. <script lang="ts">
  2. import { config, models, settings, showCallOverlay } from '$lib/stores';
  3. import { onMount, tick, getContext, onDestroy, createEventDispatcher } from 'svelte';
  4. const dispatch = createEventDispatcher();
  5. import { blobToFile } from '$lib/utils';
  6. import { generateEmoji } from '$lib/apis';
  7. import { synthesizeOpenAISpeech, transcribeAudio } from '$lib/apis/audio';
  8. import { toast } from 'svelte-sonner';
  9. import Tooltip from '$lib/components/common/Tooltip.svelte';
  10. import VideoInputMenu from './CallOverlay/VideoInputMenu.svelte';
  11. const i18n = getContext('i18n');
  12. export let eventTarget: EventTarget;
  13. export let submitPrompt: Function;
  14. export let stopResponse: Function;
  15. export let files;
  16. export let chatId;
  17. export let modelId;
  18. let wakeLock = null;
  19. let model = null;
  20. let loading = false;
  21. let confirmed = false;
  22. let interrupted = false;
  23. let assistantSpeaking = false;
  24. let emoji = null;
  25. let camera = false;
  26. let cameraStream = null;
  27. let chatStreaming = false;
  28. let rmsLevel = 0;
  29. let hasStartedSpeaking = false;
  30. let mediaRecorder;
  31. let audioStream = null;
  32. let audioChunks = [];
  33. let videoInputDevices = [];
  34. let selectedVideoInputDeviceId = null;
  35. const getVideoInputDevices = async () => {
  36. const devices = await navigator.mediaDevices.enumerateDevices();
  37. videoInputDevices = devices.filter((device) => device.kind === 'videoinput');
  38. if (!!navigator.mediaDevices.getDisplayMedia) {
  39. videoInputDevices = [
  40. ...videoInputDevices,
  41. {
  42. deviceId: 'screen',
  43. label: 'Screen Share'
  44. }
  45. ];
  46. }
  47. console.log(videoInputDevices);
  48. if (selectedVideoInputDeviceId === null && videoInputDevices.length > 0) {
  49. selectedVideoInputDeviceId = videoInputDevices[0].deviceId;
  50. }
  51. };
  52. const startCamera = async () => {
  53. await getVideoInputDevices();
  54. if (cameraStream === null) {
  55. camera = true;
  56. await tick();
  57. try {
  58. await startVideoStream();
  59. } catch (err) {
  60. console.error('Error accessing webcam: ', err);
  61. }
  62. }
  63. };
  64. const startVideoStream = async () => {
  65. const video = document.getElementById('camera-feed');
  66. if (video) {
  67. if (selectedVideoInputDeviceId === 'screen') {
  68. cameraStream = await navigator.mediaDevices.getDisplayMedia({
  69. video: {
  70. cursor: 'always'
  71. },
  72. audio: false
  73. });
  74. } else {
  75. cameraStream = await navigator.mediaDevices.getUserMedia({
  76. video: {
  77. deviceId: selectedVideoInputDeviceId ? { exact: selectedVideoInputDeviceId } : undefined
  78. }
  79. });
  80. }
  81. if (cameraStream) {
  82. await getVideoInputDevices();
  83. video.srcObject = cameraStream;
  84. await video.play();
  85. }
  86. }
  87. };
  88. const stopVideoStream = async () => {
  89. if (cameraStream) {
  90. const tracks = cameraStream.getTracks();
  91. tracks.forEach((track) => track.stop());
  92. }
  93. cameraStream = null;
  94. };
  95. const takeScreenshot = () => {
  96. const video = document.getElementById('camera-feed');
  97. const canvas = document.getElementById('camera-canvas');
  98. if (!canvas) {
  99. return;
  100. }
  101. const context = canvas.getContext('2d');
  102. // Make the canvas match the video dimensions
  103. canvas.width = video.videoWidth;
  104. canvas.height = video.videoHeight;
  105. // Draw the image from the video onto the canvas
  106. context.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
  107. // Convert the canvas to a data base64 URL and console log it
  108. const dataURL = canvas.toDataURL('image/png');
  109. console.log(dataURL);
  110. return dataURL;
  111. };
  112. const stopCamera = async () => {
  113. await stopVideoStream();
  114. camera = false;
  115. };
  116. const MIN_DECIBELS = -55;
  117. const VISUALIZER_BUFFER_LENGTH = 300;
  118. const transcribeHandler = async (audioBlob) => {
  119. // Create a blob from the audio chunks
  120. await tick();
  121. const file = blobToFile(audioBlob, 'recording.wav');
  122. const res = await transcribeAudio(localStorage.token, file).catch((error) => {
  123. toast.error(error);
  124. return null;
  125. });
  126. if (res) {
  127. console.log(res.text);
  128. if (res.text !== '') {
  129. const _responses = await submitPrompt(res.text, { _raw: true });
  130. console.log(_responses);
  131. }
  132. }
  133. };
  134. const stopRecordingCallback = async (_continue = true) => {
  135. if ($showCallOverlay) {
  136. console.log('%c%s', 'color: red; font-size: 20px;', '🚨 stopRecordingCallback 🚨');
  137. // deep copy the audioChunks array
  138. const _audioChunks = audioChunks.slice(0);
  139. audioChunks = [];
  140. mediaRecorder = false;
  141. if (_continue) {
  142. startRecording();
  143. }
  144. if (confirmed) {
  145. loading = true;
  146. emoji = null;
  147. if (cameraStream) {
  148. const imageUrl = takeScreenshot();
  149. files = [
  150. {
  151. type: 'image',
  152. url: imageUrl
  153. }
  154. ];
  155. }
  156. const audioBlob = new Blob(_audioChunks, { type: 'audio/wav' });
  157. await transcribeHandler(audioBlob);
  158. confirmed = false;
  159. loading = false;
  160. }
  161. } else {
  162. audioChunks = [];
  163. mediaRecorder = false;
  164. if (audioStream) {
  165. const tracks = audioStream.getTracks();
  166. tracks.forEach((track) => track.stop());
  167. }
  168. audioStream = null;
  169. }
  170. };
  171. const startRecording = async () => {
  172. if ($showCallOverlay) {
  173. if (!audioStream) {
  174. audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
  175. }
  176. mediaRecorder = new MediaRecorder(audioStream);
  177. mediaRecorder.onstart = () => {
  178. console.log('Recording started');
  179. audioChunks = [];
  180. analyseAudio(audioStream);
  181. };
  182. mediaRecorder.ondataavailable = (event) => {
  183. if (hasStartedSpeaking) {
  184. audioChunks.push(event.data);
  185. }
  186. };
  187. mediaRecorder.onstop = (e) => {
  188. console.log('Recording stopped', audioStream, e);
  189. stopRecordingCallback();
  190. };
  191. mediaRecorder.start();
  192. }
  193. };
  194. const stopAudioStream = async () => {
  195. try {
  196. if (mediaRecorder) {
  197. mediaRecorder.stop();
  198. }
  199. } catch (error) {
  200. console.log('Error stopping audio stream:', error);
  201. }
  202. if (!audioStream) return;
  203. audioStream.getAudioTracks().forEach(function (track) {
  204. track.stop();
  205. });
  206. audioStream = null;
  207. };
  208. // Function to calculate the RMS level from time domain data
  209. const calculateRMS = (data: Uint8Array) => {
  210. let sumSquares = 0;
  211. for (let i = 0; i < data.length; i++) {
  212. const normalizedValue = (data[i] - 128) / 128; // Normalize the data
  213. sumSquares += normalizedValue * normalizedValue;
  214. }
  215. return Math.sqrt(sumSquares / data.length);
  216. };
  217. const analyseAudio = (stream) => {
  218. const audioContext = new AudioContext();
  219. const audioStreamSource = audioContext.createMediaStreamSource(stream);
  220. const analyser = audioContext.createAnalyser();
  221. analyser.minDecibels = MIN_DECIBELS;
  222. audioStreamSource.connect(analyser);
  223. const bufferLength = analyser.frequencyBinCount;
  224. const domainData = new Uint8Array(bufferLength);
  225. const timeDomainData = new Uint8Array(analyser.fftSize);
  226. let lastSoundTime = Date.now();
  227. hasStartedSpeaking = false;
  228. console.log('🔊 Sound detection started', lastSoundTime, hasStartedSpeaking);
  229. const detectSound = () => {
  230. const processFrame = () => {
  231. if (!mediaRecorder || !$showCallOverlay) {
  232. return;
  233. }
  234. if (assistantSpeaking && !($settings?.voiceInterruption ?? false)) {
  235. // Mute the audio if the assistant is speaking
  236. analyser.maxDecibels = 0;
  237. analyser.minDecibels = -1;
  238. } else {
  239. analyser.minDecibels = MIN_DECIBELS;
  240. analyser.maxDecibels = -30;
  241. }
  242. analyser.getByteTimeDomainData(timeDomainData);
  243. analyser.getByteFrequencyData(domainData);
  244. // Calculate RMS level from time domain data
  245. rmsLevel = calculateRMS(timeDomainData);
  246. // Check if initial speech/noise has started
  247. const hasSound = domainData.some((value) => value > 0);
  248. if (hasSound) {
  249. // BIG RED TEXT
  250. console.log('%c%s', 'color: red; font-size: 20px;', '🔊 Sound detected');
  251. if (!hasStartedSpeaking) {
  252. hasStartedSpeaking = true;
  253. stopAllAudio();
  254. }
  255. lastSoundTime = Date.now();
  256. }
  257. // Start silence detection only after initial speech/noise has been detected
  258. if (hasStartedSpeaking) {
  259. if (Date.now() - lastSoundTime > 2000) {
  260. confirmed = true;
  261. if (mediaRecorder) {
  262. console.log('%c%s', 'color: red; font-size: 20px;', '🔇 Silence detected');
  263. mediaRecorder.stop();
  264. return;
  265. }
  266. }
  267. }
  268. window.requestAnimationFrame(processFrame);
  269. };
  270. window.requestAnimationFrame(processFrame);
  271. };
  272. detectSound();
  273. };
  274. let finishedMessages = {};
  275. let currentMessageId = null;
  276. let currentUtterance = null;
  277. const speakSpeechSynthesisHandler = (content) => {
  278. if ($showCallOverlay) {
  279. return new Promise((resolve) => {
  280. let voices = [];
  281. const getVoicesLoop = setInterval(async () => {
  282. voices = await speechSynthesis.getVoices();
  283. if (voices.length > 0) {
  284. clearInterval(getVoicesLoop);
  285. const voice =
  286. voices
  287. ?.filter(
  288. (v) => v.voiceURI === ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice)
  289. )
  290. ?.at(0) ?? undefined;
  291. currentUtterance = new SpeechSynthesisUtterance(content);
  292. currentUtterance.rate = $settings.audio?.tts?.playbackRate ?? 1;
  293. if (voice) {
  294. currentUtterance.voice = voice;
  295. }
  296. speechSynthesis.speak(currentUtterance);
  297. currentUtterance.onend = async (e) => {
  298. await new Promise((r) => setTimeout(r, 200));
  299. resolve(e);
  300. };
  301. }
  302. }, 100);
  303. });
  304. } else {
  305. return Promise.resolve();
  306. }
  307. };
  308. const playAudio = (audio) => {
  309. if ($showCallOverlay) {
  310. return new Promise((resolve) => {
  311. const audioElement = document.getElementById('audioElement') as HTMLAudioElement;
  312. if (audioElement) {
  313. audioElement.src = audio.src;
  314. audioElement.muted = true;
  315. audioElement.playbackRate = $settings.audio?.tts?.playbackRate ?? 1;
  316. audioElement
  317. .play()
  318. .then(() => {
  319. audioElement.muted = false;
  320. })
  321. .catch((error) => {
  322. console.error(error);
  323. });
  324. audioElement.onended = async (e) => {
  325. await new Promise((r) => setTimeout(r, 100));
  326. resolve(e);
  327. };
  328. }
  329. });
  330. } else {
  331. return Promise.resolve();
  332. }
  333. };
  334. const stopAllAudio = async () => {
  335. assistantSpeaking = false;
  336. interrupted = true;
  337. if (chatStreaming) {
  338. stopResponse();
  339. }
  340. if (currentUtterance) {
  341. speechSynthesis.cancel();
  342. currentUtterance = null;
  343. }
  344. const audioElement = document.getElementById('audioElement');
  345. if (audioElement) {
  346. audioElement.muted = true;
  347. audioElement.pause();
  348. audioElement.currentTime = 0;
  349. }
  350. };
  351. let audioAbortController = new AbortController();
  352. // Audio cache map where key is the content and value is the Audio object.
  353. const audioCache = new Map();
  354. const emojiCache = new Map();
  355. const fetchAudio = async (content) => {
  356. if (!audioCache.has(content)) {
  357. try {
  358. // Set the emoji for the content if needed
  359. if ($settings?.showEmojiInCall ?? false) {
  360. const emoji = await generateEmoji(localStorage.token, modelId, content, chatId);
  361. if (emoji) {
  362. emojiCache.set(content, emoji);
  363. }
  364. }
  365. if ($config.audio.tts.engine !== '') {
  366. const res = await synthesizeOpenAISpeech(
  367. localStorage.token,
  368. $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice,
  369. content
  370. ).catch((error) => {
  371. console.error(error);
  372. return null;
  373. });
  374. if (res) {
  375. const blob = await res.blob();
  376. const blobUrl = URL.createObjectURL(blob);
  377. audioCache.set(content, new Audio(blobUrl));
  378. }
  379. } else {
  380. audioCache.set(content, true);
  381. }
  382. } catch (error) {
  383. console.error('Error synthesizing speech:', error);
  384. }
  385. }
  386. return audioCache.get(content);
  387. };
  388. let messages = {};
  389. const monitorAndPlayAudio = async (id, signal) => {
  390. while (!signal.aborted) {
  391. if (messages[id] && messages[id].length > 0) {
  392. // Retrieve the next content string from the queue
  393. const content = messages[id].shift(); // Dequeues the content for playing
  394. if (audioCache.has(content)) {
  395. // If content is available in the cache, play it
  396. // Set the emoji for the content if available
  397. if (($settings?.showEmojiInCall ?? false) && emojiCache.has(content)) {
  398. emoji = emojiCache.get(content);
  399. } else {
  400. emoji = null;
  401. }
  402. if ($config.audio.tts.engine !== '') {
  403. try {
  404. console.log(
  405. '%c%s',
  406. 'color: red; font-size: 20px;',
  407. `Playing audio for content: ${content}`
  408. );
  409. const audio = audioCache.get(content);
  410. await playAudio(audio); // Here ensure that playAudio is indeed correct method to execute
  411. console.log(`Played audio for content: ${content}`);
  412. await new Promise((resolve) => setTimeout(resolve, 200)); // Wait before retrying to reduce tight loop
  413. } catch (error) {
  414. console.error('Error playing audio:', error);
  415. }
  416. } else {
  417. await speakSpeechSynthesisHandler(content);
  418. }
  419. } else {
  420. // If not available in the cache, push it back to the queue and delay
  421. messages[id].unshift(content); // Re-queue the content at the start
  422. console.log(`Audio for "${content}" not yet available in the cache, re-queued...`);
  423. await new Promise((resolve) => setTimeout(resolve, 200)); // Wait before retrying to reduce tight loop
  424. }
  425. } else if (finishedMessages[id] && messages[id] && messages[id].length === 0) {
  426. // If the message is finished and there are no more messages to process, break the loop
  427. assistantSpeaking = false;
  428. break;
  429. } else {
  430. // No messages to process, sleep for a bit
  431. await new Promise((resolve) => setTimeout(resolve, 200));
  432. }
  433. }
  434. console.log(`Audio monitoring and playing stopped for message ID ${id}`);
  435. };
  436. const chatStartHandler = async (e) => {
  437. const { id } = e.detail;
  438. chatStreaming = true;
  439. if (currentMessageId !== id) {
  440. console.log(`Received chat start event for message ID ${id}`);
  441. currentMessageId = id;
  442. if (audioAbortController) {
  443. audioAbortController.abort();
  444. }
  445. audioAbortController = new AbortController();
  446. assistantSpeaking = true;
  447. // Start monitoring and playing audio for the message ID
  448. monitorAndPlayAudio(id, audioAbortController.signal);
  449. }
  450. };
  451. const chatEventHandler = async (e) => {
  452. const { id, content } = e.detail;
  453. // "id" here is message id
  454. // if "id" is not the same as "currentMessageId" then do not process
  455. // "content" here is a sentence from the assistant,
  456. // there will be many sentences for the same "id"
  457. if (currentMessageId === id) {
  458. console.log(`Received chat event for message ID ${id}: ${content}`);
  459. try {
  460. if (messages[id] === undefined) {
  461. messages[id] = [content];
  462. } else {
  463. messages[id].push(content);
  464. }
  465. console.log(content);
  466. fetchAudio(content);
  467. } catch (error) {
  468. console.error('Failed to fetch or play audio:', error);
  469. }
  470. }
  471. };
  472. const chatFinishHandler = async (e) => {
  473. const { id, content } = e.detail;
  474. // "content" here is the entire message from the assistant
  475. finishedMessages[id] = true;
  476. chatStreaming = false;
  477. };
  478. onMount(async () => {
  479. const setWakeLock = async () => {
  480. try {
  481. wakeLock = await navigator.wakeLock.request('screen');
  482. } catch (err) {
  483. // The Wake Lock request has failed - usually system related, such as battery.
  484. console.log(err);
  485. }
  486. if (wakeLock) {
  487. // Add a listener to release the wake lock when the page is unloaded
  488. wakeLock.addEventListener('release', () => {
  489. // the wake lock has been released
  490. console.log('Wake Lock released');
  491. });
  492. }
  493. };
  494. if ('wakeLock' in navigator) {
  495. await setWakeLock();
  496. document.addEventListener('visibilitychange', async () => {
  497. // Re-request the wake lock if the document becomes visible
  498. if (wakeLock !== null && document.visibilityState === 'visible') {
  499. await setWakeLock();
  500. }
  501. });
  502. }
  503. model = $models.find((m) => m.id === modelId);
  504. startRecording();
  505. eventTarget.addEventListener('chat:start', chatStartHandler);
  506. eventTarget.addEventListener('chat', chatEventHandler);
  507. eventTarget.addEventListener('chat:finish', chatFinishHandler);
  508. return async () => {
  509. await stopAllAudio();
  510. stopAudioStream();
  511. eventTarget.removeEventListener('chat:start', chatStartHandler);
  512. eventTarget.removeEventListener('chat', chatEventHandler);
  513. eventTarget.removeEventListener('chat:finish', chatFinishHandler);
  514. audioAbortController.abort();
  515. await tick();
  516. await stopAllAudio();
  517. await stopRecordingCallback(false);
  518. await stopCamera();
  519. };
  520. });
  521. onDestroy(async () => {
  522. await stopAllAudio();
  523. await stopRecordingCallback(false);
  524. await stopCamera();
  525. await stopAudioStream();
  526. eventTarget.removeEventListener('chat:start', chatStartHandler);
  527. eventTarget.removeEventListener('chat', chatEventHandler);
  528. eventTarget.removeEventListener('chat:finish', chatFinishHandler);
  529. audioAbortController.abort();
  530. await tick();
  531. await stopAllAudio();
  532. });
  533. </script>
  534. {#if $showCallOverlay}
  535. <div class="max-w-lg w-full h-full max-h-[100dvh] flex flex-col justify-between p-3 md:p-6">
  536. {#if camera}
  537. <button
  538. type="button"
  539. class="flex justify-center items-center w-full h-20 min-h-20"
  540. on:click={() => {
  541. if (assistantSpeaking) {
  542. stopAllAudio();
  543. }
  544. }}
  545. >
  546. {#if emoji}
  547. <div
  548. class=" transition-all rounded-full"
  549. style="font-size:{rmsLevel * 100 > 4
  550. ? '4.5'
  551. : rmsLevel * 100 > 2
  552. ? '4.25'
  553. : rmsLevel * 100 > 1
  554. ? '3.75'
  555. : '3.5'}rem;width: 100%; text-align:center;"
  556. >
  557. {emoji}
  558. </div>
  559. {:else if loading || assistantSpeaking}
  560. <svg
  561. class="size-12 text-gray-900 dark:text-gray-400"
  562. viewBox="0 0 24 24"
  563. fill="currentColor"
  564. xmlns="http://www.w3.org/2000/svg"
  565. ><style>
  566. .spinner_qM83 {
  567. animation: spinner_8HQG 1.05s infinite;
  568. }
  569. .spinner_oXPr {
  570. animation-delay: 0.1s;
  571. }
  572. .spinner_ZTLf {
  573. animation-delay: 0.2s;
  574. }
  575. @keyframes spinner_8HQG {
  576. 0%,
  577. 57.14% {
  578. animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
  579. transform: translate(0);
  580. }
  581. 28.57% {
  582. animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
  583. transform: translateY(-6px);
  584. }
  585. 100% {
  586. transform: translate(0);
  587. }
  588. }
  589. </style><circle class="spinner_qM83" cx="4" cy="12" r="3" /><circle
  590. class="spinner_qM83 spinner_oXPr"
  591. cx="12"
  592. cy="12"
  593. r="3"
  594. /><circle class="spinner_qM83 spinner_ZTLf" cx="20" cy="12" r="3" /></svg
  595. >
  596. {:else}
  597. <div
  598. class=" {rmsLevel * 100 > 4
  599. ? ' size-[4.5rem]'
  600. : rmsLevel * 100 > 2
  601. ? ' size-16'
  602. : rmsLevel * 100 > 1
  603. ? 'size-14'
  604. : 'size-12'} transition-all rounded-full {(model?.info?.meta
  605. ?.profile_image_url ?? '/static/favicon.png') !== '/static/favicon.png'
  606. ? ' bg-cover bg-center bg-no-repeat'
  607. : 'bg-black dark:bg-white'} bg-black dark:bg-white"
  608. style={(model?.info?.meta?.profile_image_url ?? '/static/favicon.png') !==
  609. '/static/favicon.png'
  610. ? `background-image: url('${model?.info?.meta?.profile_image_url}');`
  611. : ''}
  612. />
  613. {/if}
  614. <!-- navbar -->
  615. </button>
  616. {/if}
  617. <div class="flex justify-center items-center flex-1 h-full w-full max-h-full">
  618. {#if !camera}
  619. <button
  620. type="button"
  621. on:click={() => {
  622. if (assistantSpeaking) {
  623. stopAllAudio();
  624. }
  625. }}
  626. >
  627. {#if emoji}
  628. <div
  629. class=" transition-all rounded-full"
  630. style="font-size:{rmsLevel * 100 > 4
  631. ? '13'
  632. : rmsLevel * 100 > 2
  633. ? '12'
  634. : rmsLevel * 100 > 1
  635. ? '11.5'
  636. : '11'}rem;width:100%;text-align:center;"
  637. >
  638. {emoji}
  639. </div>
  640. {:else if loading || assistantSpeaking}
  641. <svg
  642. class="size-44 text-gray-900 dark:text-gray-400"
  643. viewBox="0 0 24 24"
  644. fill="currentColor"
  645. xmlns="http://www.w3.org/2000/svg"
  646. ><style>
  647. .spinner_qM83 {
  648. animation: spinner_8HQG 1.05s infinite;
  649. }
  650. .spinner_oXPr {
  651. animation-delay: 0.1s;
  652. }
  653. .spinner_ZTLf {
  654. animation-delay: 0.2s;
  655. }
  656. @keyframes spinner_8HQG {
  657. 0%,
  658. 57.14% {
  659. animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
  660. transform: translate(0);
  661. }
  662. 28.57% {
  663. animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
  664. transform: translateY(-6px);
  665. }
  666. 100% {
  667. transform: translate(0);
  668. }
  669. }
  670. </style><circle class="spinner_qM83" cx="4" cy="12" r="3" /><circle
  671. class="spinner_qM83 spinner_oXPr"
  672. cx="12"
  673. cy="12"
  674. r="3"
  675. /><circle class="spinner_qM83 spinner_ZTLf" cx="20" cy="12" r="3" /></svg
  676. >
  677. {:else}
  678. <div
  679. class=" {rmsLevel * 100 > 4
  680. ? ' size-52'
  681. : rmsLevel * 100 > 2
  682. ? 'size-48'
  683. : rmsLevel * 100 > 1
  684. ? 'size-44'
  685. : 'size-40'} transition-all rounded-full {(model?.info?.meta
  686. ?.profile_image_url ?? '/static/favicon.png') !== '/static/favicon.png'
  687. ? ' bg-cover bg-center bg-no-repeat'
  688. : 'bg-black dark:bg-white'} "
  689. style={(model?.info?.meta?.profile_image_url ?? '/static/favicon.png') !==
  690. '/static/favicon.png'
  691. ? `background-image: url('${model?.info?.meta?.profile_image_url}');`
  692. : ''}
  693. />
  694. {/if}
  695. </button>
  696. {:else}
  697. <div class="relative flex video-container w-full max-h-full pt-2 pb-4 md:py-6 px-2 h-full">
  698. <video
  699. id="camera-feed"
  700. autoplay
  701. class="rounded-2xl h-full min-w-full object-cover object-center"
  702. playsinline
  703. />
  704. <canvas id="camera-canvas" style="display:none;" />
  705. <div class=" absolute top-4 md:top-8 left-4">
  706. <button
  707. type="button"
  708. class="p-1.5 text-white cursor-pointer backdrop-blur-xl bg-black/10 rounded-full"
  709. on:click={() => {
  710. stopCamera();
  711. }}
  712. >
  713. <svg
  714. xmlns="http://www.w3.org/2000/svg"
  715. viewBox="0 0 16 16"
  716. fill="currentColor"
  717. class="size-6"
  718. >
  719. <path
  720. d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z"
  721. />
  722. </svg>
  723. </button>
  724. </div>
  725. </div>
  726. {/if}
  727. </div>
  728. <div class="flex justify-between items-center pb-2 w-full">
  729. <div>
  730. {#if camera}
  731. <VideoInputMenu
  732. devices={videoInputDevices}
  733. on:change={async (e) => {
  734. console.log(e.detail);
  735. selectedVideoInputDeviceId = e.detail;
  736. await stopVideoStream();
  737. await startVideoStream();
  738. }}
  739. >
  740. <button class=" p-3 rounded-full bg-gray-50 dark:bg-gray-900" type="button">
  741. <svg
  742. xmlns="http://www.w3.org/2000/svg"
  743. viewBox="0 0 20 20"
  744. fill="currentColor"
  745. class="size-5"
  746. >
  747. <path
  748. fill-rule="evenodd"
  749. d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
  750. clip-rule="evenodd"
  751. />
  752. </svg>
  753. </button>
  754. </VideoInputMenu>
  755. {:else}
  756. <Tooltip content={$i18n.t('Camera')}>
  757. <button
  758. class=" p-3 rounded-full bg-gray-50 dark:bg-gray-900"
  759. type="button"
  760. on:click={async () => {
  761. await navigator.mediaDevices.getUserMedia({ video: true });
  762. startCamera();
  763. }}
  764. >
  765. <svg
  766. xmlns="http://www.w3.org/2000/svg"
  767. fill="none"
  768. viewBox="0 0 24 24"
  769. stroke-width="1.5"
  770. stroke="currentColor"
  771. class="size-5"
  772. >
  773. <path
  774. stroke-linecap="round"
  775. stroke-linejoin="round"
  776. d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z"
  777. />
  778. <path
  779. stroke-linecap="round"
  780. stroke-linejoin="round"
  781. d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z"
  782. />
  783. </svg>
  784. </button>
  785. </Tooltip>
  786. {/if}
  787. </div>
  788. <div>
  789. <button
  790. type="button"
  791. on:click={() => {
  792. if (assistantSpeaking) {
  793. stopAllAudio();
  794. }
  795. }}
  796. >
  797. <div class=" line-clamp-1 text-sm font-medium">
  798. {#if loading}
  799. {$i18n.t('Thinking...')}
  800. {:else if assistantSpeaking}
  801. {$i18n.t('Tap to interrupt')}
  802. {:else}
  803. {$i18n.t('Listening...')}
  804. {/if}
  805. </div>
  806. </button>
  807. </div>
  808. <div>
  809. <button
  810. class=" p-3 rounded-full bg-gray-50 dark:bg-gray-900"
  811. on:click={async () => {
  812. await stopAudioStream();
  813. await stopVideoStream();
  814. console.log(audioStream);
  815. console.log(cameraStream);
  816. showCallOverlay.set(false);
  817. dispatch('close');
  818. }}
  819. type="button"
  820. >
  821. <svg
  822. xmlns="http://www.w3.org/2000/svg"
  823. viewBox="0 0 20 20"
  824. fill="currentColor"
  825. class="size-5"
  826. >
  827. <path
  828. d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
  829. />
  830. </svg>
  831. </button>
  832. </div>
  833. </div>
  834. </div>
  835. {/if}