| | <script lang="ts"> |
| | import { config, models, settings, showCallOverlay } from '$lib/stores'; |
| | import { onMount, tick, getContext, onDestroy, createEventDispatcher } from 'svelte'; |
| | |
| | const dispatch = createEventDispatcher(); |
| | |
| | import { blobToFile } 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'); |
| | |
| | |
| | canvas.width = video.videoWidth; |
| | canvas.height = video.videoHeight; |
| | |
| | |
| | context.drawImage(video, 0, 0, video.videoWidth, video.videoHeight); |
| | |
| | |
| | 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) => { |
| | |
| | |
| | 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 🚨'); |
| | |
| | |
| | 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 () => { |
| | if ($showCallOverlay) { |
| | if (!audioStream) { |
| | 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', audioStream, e); |
| | stopRecordingCallback(); |
| | }; |
| | |
| | mediaRecorder.start(); |
| | } |
| | }; |
| | |
| | const stopAudioStream = async () => { |
| | try { |
| | if (mediaRecorder) { |
| | mediaRecorder.stop(); |
| | } |
| | } catch (error) { |
| | console.log('Error stopping audio stream:', error); |
| | } |
| | |
| | if (!audioStream) return; |
| | |
| | audioStream.getAudioTracks().forEach(function (track) { |
| | track.stop(); |
| | }); |
| | |
| | audioStream = null; |
| | }; |
| | |
| | |
| | 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 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)) { |
| | |
| | analyser.maxDecibels = 0; |
| | analyser.minDecibels = -1; |
| | } else { |
| | analyser.minDecibels = MIN_DECIBELS; |
| | analyser.maxDecibels = -30; |
| | } |
| | |
| | analyser.getByteTimeDomainData(timeDomainData); |
| | analyser.getByteFrequencyData(domainData); |
| | |
| | |
| | rmsLevel = calculateRMS(timeDomainData); |
| | |
| | |
| | const hasSound = domainData.some((value) => value > 0); |
| | if (hasSound) { |
| | |
| | console.log('%c%s', 'color: red; font-size: 20px;', '🔊 Sound detected'); |
| | |
| | if (!hasStartedSpeaking) { |
| | hasStartedSpeaking = true; |
| | stopAllAudio(); |
| | } |
| | |
| | lastSoundTime = Date.now(); |
| | } |
| | |
| | |
| | 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); |
| | currentUtterance.rate = $settings.audio?.tts?.playbackRate ?? 1; |
| | |
| | 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') as HTMLAudioElement; |
| | |
| | if (audioElement) { |
| | audioElement.src = audio.src; |
| | audioElement.muted = true; |
| | audioElement.playbackRate = $settings.audio?.tts?.playbackRate ?? 1; |
| | |
| | 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(); |
| | |
| | |
| | const audioCache = new Map(); |
| | const emojiCache = new Map(); |
| | |
| | const fetchAudio = async (content) => { |
| | if (!audioCache.has(content)) { |
| | try { |
| | |
| | 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?.defaultVoice === $config.audio.tts.voice |
| | ? ($settings?.audio?.tts?.voice ?? $config?.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) { |
| | |
| | const content = messages[id].shift(); |
| | |
| | if (audioCache.has(content)) { |
| | |
| | |
| | |
| | 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); |
| | console.log(`Played audio for content: ${content}`); |
| | await new Promise((resolve) => setTimeout(resolve, 200)); |
| | } catch (error) { |
| | console.error('Error playing audio:', error); |
| | } |
| | } else { |
| | await speakSpeechSynthesisHandler(content); |
| | } |
| | } else { |
| | |
| | messages[id].unshift(content); |
| | console.log(`Audio for "${content}" not yet available in the cache, re-queued...`); |
| | await new Promise((resolve) => setTimeout(resolve, 200)); |
| | } |
| | } else if (finishedMessages[id] && messages[id] && messages[id].length === 0) { |
| | |
| | assistantSpeaking = false; |
| | break; |
| | } else { |
| | |
| | await new Promise((resolve) => setTimeout(resolve, 200)); |
| | } |
| | } |
| | console.log(`Audio monitoring and playing stopped for message ID ${id}`); |
| | }; |
| | |
| | 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; |
| | |
| | monitorAndPlayAudio(id, audioAbortController.signal); |
| | } |
| | }; |
| | |
| | const chatEventHandler = async (e) => { |
| | const { id, content } = e.detail; |
| | |
| | |
| | |
| | |
| | |
| | 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; |
| | |
| | finishedMessages[id] = true; |
| | |
| | chatStreaming = false; |
| | }; |
| | |
| | onMount(async () => { |
| | const setWakeLock = async () => { |
| | try { |
| | wakeLock = await navigator.wakeLock.request('screen'); |
| | } catch (err) { |
| | |
| | console.log(err); |
| | } |
| | |
| | if (wakeLock) { |
| | |
| | wakeLock.addEventListener('release', () => { |
| | |
| | console.log('Wake Lock released'); |
| | }); |
| | } |
| | }; |
| | |
| | if ('wakeLock' in navigator) { |
| | await setWakeLock(); |
| | |
| | document.addEventListener('visibilitychange', async () => { |
| | |
| | if (wakeLock !== null && document.visibilityState === 'visible') { |
| | await setWakeLock(); |
| | } |
| | }); |
| | } |
| | |
| | model = $models.find((m) => m.id === modelId); |
| | |
| | startRecording(); |
| | |
| | eventTarget.addEventListener('chat:start', chatStartHandler); |
| | eventTarget.addEventListener('chat', chatEventHandler); |
| | eventTarget.addEventListener('chat:finish', chatFinishHandler); |
| | |
| | return async () => { |
| | await stopAllAudio(); |
| | |
| | stopAudioStream(); |
| | |
| | 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(); |
| | |
| | await stopAudioStream(); |
| | eventTarget.removeEventListener('chat:start', chatStartHandler); |
| | eventTarget.removeEventListener('chat', chatEventHandler); |
| | eventTarget.removeEventListener('chat:finish', chatFinishHandler); |
| | audioAbortController.abort(); |
| | |
| | await tick(); |
| | |
| | await stopAllAudio(); |
| | }); |
| | </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} |
| | |
| | </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 () => { |
| | await stopAudioStream(); |
| | await stopVideoStream(); |
| |
|
| | console.log(audioStream); |
| | console.log(cameraStream); |
| |
|
| | 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} |
| |
|