123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946 |
- <script lang="ts">
- import { config, models, settings, showCallOverlay } from '$lib/stores';
- import { onMount, tick, getContext, onDestroy, createEventDispatcher } from 'svelte';
- const dispatch = createEventDispatcher();
- import {
- blobToFile,
- calculateSHA256,
- extractSentencesForAudio,
- findWordIndices
- } from '$lib/utils';
- import { generateEmoji } from '$lib/apis';
- import { synthesizeOpenAISpeech, transcribeAudio } from '$lib/apis/audio';
- import { toast } from 'svelte-sonner';
- import Tooltip from '$lib/components/common/Tooltip.svelte';
- import VideoInputMenu from './CallOverlay/VideoInputMenu.svelte';
- const i18n = getContext('i18n');
- export let eventTarget: EventTarget;
- export let submitPrompt: Function;
- export let stopResponse: Function;
- export let files;
- export let chatId;
- export let modelId;
- let wakeLock = null;
- let model = null;
- let loading = false;
- let confirmed = false;
- let interrupted = false;
- let assistantSpeaking = false;
- let emoji = null;
- let camera = false;
- let cameraStream = null;
- let chatStreaming = false;
- let rmsLevel = 0;
- let hasStartedSpeaking = false;
- let mediaRecorder;
- let audioStream = null;
- let audioChunks = [];
- let videoInputDevices = [];
- let selectedVideoInputDeviceId = null;
- const getVideoInputDevices = async () => {
- const devices = await navigator.mediaDevices.enumerateDevices();
- videoInputDevices = devices.filter((device) => device.kind === 'videoinput');
- if (!!navigator.mediaDevices.getDisplayMedia) {
- videoInputDevices = [
- ...videoInputDevices,
- {
- deviceId: 'screen',
- label: 'Screen Share'
- }
- ];
- }
- console.log(videoInputDevices);
- if (selectedVideoInputDeviceId === null && videoInputDevices.length > 0) {
- selectedVideoInputDeviceId = videoInputDevices[0].deviceId;
- }
- };
- const startCamera = async () => {
- await getVideoInputDevices();
- if (cameraStream === null) {
- camera = true;
- await tick();
- try {
- await startVideoStream();
- } catch (err) {
- console.error('Error accessing webcam: ', err);
- }
- }
- };
- const startVideoStream = async () => {
- const video = document.getElementById('camera-feed');
- if (video) {
- if (selectedVideoInputDeviceId === 'screen') {
- cameraStream = await navigator.mediaDevices.getDisplayMedia({
- video: {
- cursor: 'always'
- },
- audio: false
- });
- } else {
- cameraStream = await navigator.mediaDevices.getUserMedia({
- video: {
- deviceId: selectedVideoInputDeviceId ? { exact: selectedVideoInputDeviceId } : undefined
- }
- });
- }
- if (cameraStream) {
- await getVideoInputDevices();
- video.srcObject = cameraStream;
- await video.play();
- }
- }
- };
- const stopVideoStream = async () => {
- if (cameraStream) {
- const tracks = cameraStream.getTracks();
- tracks.forEach((track) => track.stop());
- }
- cameraStream = null;
- };
- const takeScreenshot = () => {
- const video = document.getElementById('camera-feed');
- const canvas = document.getElementById('camera-canvas');
- if (!canvas) {
- return;
- }
- const context = canvas.getContext('2d');
- // Make the canvas match the video dimensions
- canvas.width = video.videoWidth;
- canvas.height = video.videoHeight;
- // Draw the image from the video onto the canvas
- context.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
- // Convert the canvas to a data base64 URL and console log it
- const dataURL = canvas.toDataURL('image/png');
- console.log(dataURL);
- return dataURL;
- };
- const stopCamera = async () => {
- await stopVideoStream();
- camera = false;
- };
- const MIN_DECIBELS = -55;
- const VISUALIZER_BUFFER_LENGTH = 300;
- const transcribeHandler = async (audioBlob) => {
- // Create a blob from the audio chunks
- 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.text);
- if (res.text !== '') {
- const _responses = await submitPrompt(res.text, { _raw: true });
- console.log(_responses);
- }
- }
- };
- const stopRecordingCallback = async (_continue = true) => {
- if ($showCallOverlay) {
- console.log('%c%s', 'color: red; font-size: 20px;', '🚨 stopRecordingCallback 🚨');
- // deep copy the audioChunks array
- const _audioChunks = audioChunks.slice(0);
- audioChunks = [];
- mediaRecorder = false;
- if (_continue) {
- startRecording();
- }
- if (confirmed) {
- loading = true;
- emoji = null;
- if (cameraStream) {
- const imageUrl = takeScreenshot();
- files = [
- {
- type: 'image',
- url: imageUrl
- }
- ];
- }
- const audioBlob = new Blob(_audioChunks, { type: 'audio/wav' });
- await transcribeHandler(audioBlob);
- confirmed = false;
- loading = false;
- }
- } else {
- audioChunks = [];
- mediaRecorder = false;
- if (audioStream) {
- const tracks = audioStream.getTracks();
- tracks.forEach((track) => track.stop());
- }
- audioStream = null;
- }
- };
- const startRecording = async () => {
- audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
- mediaRecorder = new MediaRecorder(audioStream);
- mediaRecorder.onstart = () => {
- console.log('Recording started');
- audioChunks = [];
- analyseAudio(audioStream);
- };
- mediaRecorder.ondataavailable = (event) => {
- if (hasStartedSpeaking) {
- audioChunks.push(event.data);
- }
- };
- mediaRecorder.onstop = (e) => {
- console.log('Recording stopped', e);
- stopRecordingCallback();
- };
- mediaRecorder.start();
- };
- const stopAudioStream = async () => {
- if (audioStream) {
- const tracks = audioStream.getTracks();
- tracks.forEach((track) => track.stop());
- }
- audioStream = null;
- };
- // Function to calculate the RMS level from time domain data
- const calculateRMS = (data: Uint8Array) => {
- let sumSquares = 0;
- for (let i = 0; i < data.length; i++) {
- const normalizedValue = (data[i] - 128) / 128; // Normalize the data
- sumSquares += normalizedValue * normalizedValue;
- }
- return Math.sqrt(sumSquares / data.length);
- };
- 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();
- hasStartedSpeaking = false;
- console.log('🔊 Sound detection started', lastSoundTime, hasStartedSpeaking);
- const detectSound = () => {
- const processFrame = () => {
- if (!mediaRecorder || !$showCallOverlay) {
- return;
- }
- if (assistantSpeaking && !($settings?.voiceInterruption ?? false)) {
- // Mute the audio if the assistant is speaking
- analyser.maxDecibels = 0;
- analyser.minDecibels = -1;
- } else {
- analyser.minDecibels = MIN_DECIBELS;
- analyser.maxDecibels = -30;
- }
- analyser.getByteTimeDomainData(timeDomainData);
- analyser.getByteFrequencyData(domainData);
- // Calculate RMS level from time domain data
- rmsLevel = calculateRMS(timeDomainData);
- // Check if initial speech/noise has started
- const hasSound = domainData.some((value) => value > 0);
- if (hasSound) {
- // BIG RED TEXT
- console.log('%c%s', 'color: red; font-size: 20px;', '🔊 Sound detected');
- if (!hasStartedSpeaking) {
- hasStartedSpeaking = true;
- stopAllAudio();
- }
- lastSoundTime = Date.now();
- }
- // Start silence detection only after initial speech/noise has been detected
- if (hasStartedSpeaking) {
- if (Date.now() - lastSoundTime > 2000) {
- confirmed = true;
- if (mediaRecorder) {
- console.log('%c%s', 'color: red; font-size: 20px;', '🔇 Silence detected');
- mediaRecorder.stop();
- return;
- }
- }
- }
- window.requestAnimationFrame(processFrame);
- };
- window.requestAnimationFrame(processFrame);
- };
- detectSound();
- };
- let finishedMessages = {};
- let currentMessageId = null;
- let currentUtterance = null;
- const speakSpeechSynthesisHandler = (content) => {
- if ($showCallOverlay) {
- return new Promise((resolve) => {
- let voices = [];
- const getVoicesLoop = setInterval(async () => {
- voices = await speechSynthesis.getVoices();
- if (voices.length > 0) {
- clearInterval(getVoicesLoop);
- const voice =
- voices
- ?.filter(
- (v) => v.voiceURI === ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice)
- )
- ?.at(0) ?? undefined;
- currentUtterance = new SpeechSynthesisUtterance(content);
- if (voice) {
- currentUtterance.voice = voice;
- }
- speechSynthesis.speak(currentUtterance);
- currentUtterance.onend = async (e) => {
- await new Promise((r) => setTimeout(r, 200));
- resolve(e);
- };
- }
- }, 100);
- });
- } else {
- return Promise.resolve();
- }
- };
- const playAudio = (audio) => {
- if ($showCallOverlay) {
- return new Promise((resolve) => {
- const audioElement = document.getElementById('audioElement');
- if (audioElement) {
- audioElement.src = audio.src;
- audioElement.muted = true;
- audioElement
- .play()
- .then(() => {
- audioElement.muted = false;
- })
- .catch((error) => {
- console.error(error);
- });
- audioElement.onended = async (e) => {
- await new Promise((r) => setTimeout(r, 100));
- resolve(e);
- };
- }
- });
- } else {
- return Promise.resolve();
- }
- };
- const stopAllAudio = async () => {
- assistantSpeaking = false;
- interrupted = true;
- if (chatStreaming) {
- stopResponse();
- }
- if (currentUtterance) {
- speechSynthesis.cancel();
- currentUtterance = null;
- }
- const audioElement = document.getElementById('audioElement');
- if (audioElement) {
- audioElement.muted = true;
- audioElement.pause();
- audioElement.currentTime = 0;
- }
- };
- let audioAbortController = new AbortController();
- // Audio cache map where key is the content and value is the Audio object.
- const audioCache = new Map();
- const emojiCache = new Map();
- const fetchAudio = async (content) => {
- if (!audioCache.has(content)) {
- try {
- // Set the emoji for the content if needed
- if ($settings?.showEmojiInCall ?? false) {
- const emoji = await generateEmoji(localStorage.token, modelId, content, chatId);
- if (emoji) {
- emojiCache.set(content, emoji);
- }
- }
- if ($config.audio.tts.engine !== '') {
- const res = await synthesizeOpenAISpeech(
- localStorage.token,
- $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice,
- content
- ).catch((error) => {
- console.error(error);
- return null;
- });
- if (res) {
- const blob = await res.blob();
- const blobUrl = URL.createObjectURL(blob);
- audioCache.set(content, new Audio(blobUrl));
- }
- } else {
- audioCache.set(content, true);
- }
- } catch (error) {
- console.error('Error synthesizing speech:', error);
- }
- }
- return audioCache.get(content);
- };
- let messages = {};
- const monitorAndPlayAudio = async (id, signal) => {
- while (!signal.aborted) {
- if (messages[id] && messages[id].length > 0) {
- // Retrieve the next content string from the queue
- const content = messages[id].shift(); // Dequeues the content for playing
- if (audioCache.has(content)) {
- // If content is available in the cache, play it
- // Set the emoji for the content if available
- if (($settings?.showEmojiInCall ?? false) && emojiCache.has(content)) {
- emoji = emojiCache.get(content);
- } else {
- emoji = null;
- }
- if ($config.audio.tts.engine !== '') {
- try {
- console.log(
- '%c%s',
- 'color: red; font-size: 20px;',
- `Playing audio for content: ${content}`
- );
- const audio = audioCache.get(content);
- await playAudio(audio); // Here ensure that playAudio is indeed correct method to execute
- console.log(`Played audio for content: ${content}`);
- await new Promise((resolve) => setTimeout(resolve, 200)); // Wait before retrying to reduce tight loop
- } catch (error) {
- console.error('Error playing audio:', error);
- }
- } else {
- await speakSpeechSynthesisHandler(content);
- }
- } else {
- // If not available in the cache, push it back to the queue and delay
- messages[id].unshift(content); // Re-queue the content at the start
- console.log(`Audio for "${content}" not yet available in the cache, re-queued...`);
- await new Promise((resolve) => setTimeout(resolve, 200)); // Wait before retrying to reduce tight loop
- }
- } else if (finishedMessages[id] && messages[id] && messages[id].length === 0) {
- // If the message is finished and there are no more messages to process, break the loop
- assistantSpeaking = false;
- break;
- } else {
- // No messages to process, sleep for a bit
- await new Promise((resolve) => setTimeout(resolve, 200));
- }
- }
- console.log(`Audio monitoring and playing stopped for message ID ${id}`);
- };
- onMount(async () => {
- const setWakeLock = async () => {
- try {
- wakeLock = await navigator.wakeLock.request('screen');
- } catch (err) {
- // The Wake Lock request has failed - usually system related, such as battery.
- console.log(err);
- }
- if (wakeLock) {
- // Add a listener to release the wake lock when the page is unloaded
- wakeLock.addEventListener('release', () => {
- // the wake lock has been released
- console.log('Wake Lock released');
- });
- }
- };
- if ('wakeLock' in navigator) {
- await setWakeLock();
- document.addEventListener('visibilitychange', async () => {
- // Re-request the wake lock if the document becomes visible
- if (wakeLock !== null && document.visibilityState === 'visible') {
- await setWakeLock();
- }
- });
- }
- model = $models.find((m) => m.id === modelId);
- startRecording();
- const chatStartHandler = async (e) => {
- const { id } = e.detail;
- chatStreaming = true;
- if (currentMessageId !== id) {
- console.log(`Received chat start event for message ID ${id}`);
- currentMessageId = id;
- if (audioAbortController) {
- audioAbortController.abort();
- }
- audioAbortController = new AbortController();
- assistantSpeaking = true;
- // Start monitoring and playing audio for the message ID
- monitorAndPlayAudio(id, audioAbortController.signal);
- }
- };
- const chatEventHandler = async (e) => {
- const { id, content } = e.detail;
- // "id" here is message id
- // if "id" is not the same as "currentMessageId" then do not process
- // "content" here is a sentence from the assistant,
- // there will be many sentences for the same "id"
- if (currentMessageId === id) {
- console.log(`Received chat event for message ID ${id}: ${content}`);
- try {
- if (messages[id] === undefined) {
- messages[id] = [content];
- } else {
- messages[id].push(content);
- }
- console.log(content);
- fetchAudio(content);
- } catch (error) {
- console.error('Failed to fetch or play audio:', error);
- }
- }
- };
- const chatFinishHandler = async (e) => {
- const { id, content } = e.detail;
- // "content" here is the entire message from the assistant
- finishedMessages[id] = true;
- chatStreaming = false;
- };
- eventTarget.addEventListener('chat:start', chatStartHandler);
- eventTarget.addEventListener('chat', chatEventHandler);
- eventTarget.addEventListener('chat:finish', chatFinishHandler);
- return async () => {
- eventTarget.removeEventListener('chat:start', chatStartHandler);
- eventTarget.removeEventListener('chat', chatEventHandler);
- eventTarget.removeEventListener('chat:finish', chatFinishHandler);
- audioAbortController.abort();
- await tick();
- await stopAllAudio();
- await stopRecordingCallback(false);
- await stopCamera();
- };
- });
- onDestroy(async () => {
- await stopAllAudio();
- await stopRecordingCallback(false);
- await stopCamera();
- });
- </script>
- {#if $showCallOverlay}
- <div class="max-w-lg w-full h-full max-h-[100dvh] flex flex-col justify-between p-3 md:p-6">
- {#if camera}
- <button
- type="button"
- class="flex justify-center items-center w-full h-20 min-h-20"
- on:click={() => {
- if (assistantSpeaking) {
- stopAllAudio();
- }
- }}
- >
- {#if emoji}
- <div
- class=" transition-all rounded-full"
- style="font-size:{rmsLevel * 100 > 4
- ? '4.5'
- : rmsLevel * 100 > 2
- ? '4.25'
- : rmsLevel * 100 > 1
- ? '3.75'
- : '3.5'}rem;width: 100%; text-align:center;"
- >
- {emoji}
- </div>
- {:else if loading || assistantSpeaking}
- <svg
- class="size-12 text-gray-900 dark:text-gray-400"
- viewBox="0 0 24 24"
- fill="currentColor"
- xmlns="http://www.w3.org/2000/svg"
- ><style>
- .spinner_qM83 {
- animation: spinner_8HQG 1.05s infinite;
- }
- .spinner_oXPr {
- animation-delay: 0.1s;
- }
- .spinner_ZTLf {
- animation-delay: 0.2s;
- }
- @keyframes spinner_8HQG {
- 0%,
- 57.14% {
- animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
- transform: translate(0);
- }
- 28.57% {
- animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
- transform: translateY(-6px);
- }
- 100% {
- transform: translate(0);
- }
- }
- </style><circle class="spinner_qM83" cx="4" cy="12" r="3" /><circle
- class="spinner_qM83 spinner_oXPr"
- cx="12"
- cy="12"
- r="3"
- /><circle class="spinner_qM83 spinner_ZTLf" cx="20" cy="12" r="3" /></svg
- >
- {:else}
- <div
- class=" {rmsLevel * 100 > 4
- ? ' size-[4.5rem]'
- : rmsLevel * 100 > 2
- ? ' size-16'
- : rmsLevel * 100 > 1
- ? 'size-14'
- : 'size-12'} transition-all rounded-full {(model?.info?.meta
- ?.profile_image_url ?? '/static/favicon.png') !== '/static/favicon.png'
- ? ' bg-cover bg-center bg-no-repeat'
- : 'bg-black dark:bg-white'} bg-black dark:bg-white"
- style={(model?.info?.meta?.profile_image_url ?? '/static/favicon.png') !==
- '/static/favicon.png'
- ? `background-image: url('${model?.info?.meta?.profile_image_url}');`
- : ''}
- />
- {/if}
- <!-- navbar -->
- </button>
- {/if}
- <div class="flex justify-center items-center flex-1 h-full w-full max-h-full">
- {#if !camera}
- <button
- type="button"
- on:click={() => {
- if (assistantSpeaking) {
- stopAllAudio();
- }
- }}
- >
- {#if emoji}
- <div
- class=" transition-all rounded-full"
- style="font-size:{rmsLevel * 100 > 4
- ? '13'
- : rmsLevel * 100 > 2
- ? '12'
- : rmsLevel * 100 > 1
- ? '11.5'
- : '11'}rem;width:100%;text-align:center;"
- >
- {emoji}
- </div>
- {:else if loading || assistantSpeaking}
- <svg
- class="size-44 text-gray-900 dark:text-gray-400"
- viewBox="0 0 24 24"
- fill="currentColor"
- xmlns="http://www.w3.org/2000/svg"
- ><style>
- .spinner_qM83 {
- animation: spinner_8HQG 1.05s infinite;
- }
- .spinner_oXPr {
- animation-delay: 0.1s;
- }
- .spinner_ZTLf {
- animation-delay: 0.2s;
- }
- @keyframes spinner_8HQG {
- 0%,
- 57.14% {
- animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
- transform: translate(0);
- }
- 28.57% {
- animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
- transform: translateY(-6px);
- }
- 100% {
- transform: translate(0);
- }
- }
- </style><circle class="spinner_qM83" cx="4" cy="12" r="3" /><circle
- class="spinner_qM83 spinner_oXPr"
- cx="12"
- cy="12"
- r="3"
- /><circle class="spinner_qM83 spinner_ZTLf" cx="20" cy="12" r="3" /></svg
- >
- {:else}
- <div
- class=" {rmsLevel * 100 > 4
- ? ' size-52'
- : rmsLevel * 100 > 2
- ? 'size-48'
- : rmsLevel * 100 > 1
- ? 'size-44'
- : 'size-40'} transition-all rounded-full {(model?.info?.meta
- ?.profile_image_url ?? '/static/favicon.png') !== '/static/favicon.png'
- ? ' bg-cover bg-center bg-no-repeat'
- : 'bg-black dark:bg-white'} "
- style={(model?.info?.meta?.profile_image_url ?? '/static/favicon.png') !==
- '/static/favicon.png'
- ? `background-image: url('${model?.info?.meta?.profile_image_url}');`
- : ''}
- />
- {/if}
- </button>
- {:else}
- <div class="relative flex video-container w-full max-h-full pt-2 pb-4 md:py-6 px-2 h-full">
- <video
- id="camera-feed"
- autoplay
- class="rounded-2xl h-full min-w-full object-cover object-center"
- playsinline
- />
- <canvas id="camera-canvas" style="display:none;" />
- <div class=" absolute top-4 md:top-8 left-4">
- <button
- type="button"
- class="p-1.5 text-white cursor-pointer backdrop-blur-xl bg-black/10 rounded-full"
- on:click={() => {
- stopCamera();
- }}
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 16 16"
- fill="currentColor"
- class="size-6"
- >
- <path
- 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"
- />
- </svg>
- </button>
- </div>
- </div>
- {/if}
- </div>
- <div class="flex justify-between items-center pb-2 w-full">
- <div>
- {#if camera}
- <VideoInputMenu
- devices={videoInputDevices}
- on:change={async (e) => {
- console.log(e.detail);
- selectedVideoInputDeviceId = e.detail;
- await stopVideoStream();
- await startVideoStream();
- }}
- >
- <button class=" p-3 rounded-full bg-gray-50 dark:bg-gray-900" type="button">
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 20 20"
- fill="currentColor"
- class="size-5"
- >
- <path
- fill-rule="evenodd"
- 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"
- clip-rule="evenodd"
- />
- </svg>
- </button>
- </VideoInputMenu>
- {:else}
- <Tooltip content={$i18n.t('Camera')}>
- <button
- class=" p-3 rounded-full bg-gray-50 dark:bg-gray-900"
- type="button"
- on:click={async () => {
- await navigator.mediaDevices.getUserMedia({ video: true });
- startCamera();
- }}
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="1.5"
- stroke="currentColor"
- class="size-5"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- 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"
- />
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- 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"
- />
- </svg>
- </button>
- </Tooltip>
- {/if}
- </div>
- <div>
- <button
- type="button"
- on:click={() => {
- if (assistantSpeaking) {
- stopAllAudio();
- }
- }}
- >
- <div class=" line-clamp-1 text-sm font-medium">
- {#if loading}
- {$i18n.t('Thinking...')}
- {:else if assistantSpeaking}
- {$i18n.t('Tap to interrupt')}
- {:else}
- {$i18n.t('Listening...')}
- {/if}
- </div>
- </button>
- </div>
- <div>
- <button
- class=" p-3 rounded-full bg-gray-50 dark:bg-gray-900"
- on:click={async () => {
- stopAudioStream();
- stopVideoStream();
- showCallOverlay.set(false);
- dispatch('close');
- }}
- type="button"
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 20 20"
- fill="currentColor"
- class="size-5"
- >
- <path
- 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"
- />
- </svg>
- </button>
- </div>
- </div>
- </div>
- {/if}
|