This commit is contained in:
Timothy Jaeryang Baek 2026-10-07 12:10:52 +04:00
parent 71167131d2
commit 4f1f385419
5 changed files with 620 additions and 13 deletions

View file

@ -0,0 +1,316 @@
<script lang="ts">
import { createEventDispatcher, getContext, onMount } from 'svelte';
import { showCallOverlay } from '$lib/stores';
import type { RealtimeCall } from '$lib/utils/realtime';
import VoiceOrb from './VoiceOrb.svelte';
export let bridge: RealtimeCall;
const i18n = getContext<any>('i18n');
const dispatch = createEventDispatcher();
$: unavailable = !bridge.connected;
$: status = bridge.error
? $i18n.t('Call disconnected')
: bridge.connecting
? $i18n.t('Connecting...')
: bridge.speaking
? $i18n.t('Speaking')
: bridge.muted
? $i18n.t('Microphone off')
: $i18n.t('Listening');
const endCall = () => {
bridge.end();
showCallOverlay.set(false);
dispatch('close');
};
onMount(() => {
const handleKeydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
if (
event.key.toLowerCase() === 'm' &&
!event.repeat &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!target.closest('input, textarea, select, [contenteditable="true"], [role="textbox"]') &&
bridge.connected
) {
event.preventDefault();
bridge.mute();
}
};
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
});
</script>
<section class="bridge-call" aria-label={$i18n.t('Voice call')}>
<div class="call-stage">
<button
type="button"
class="orb-button"
disabled={!bridge.speaking}
aria-label={$i18n.t('Stop speaking')}
on:click={() => bridge.stopSpeaking()}
>
<VoiceOrb
speaking={bridge.speaking && !unavailable}
level={unavailable
? 0
: bridge.speaking
? bridge.outputLevel
: bridge.muted
? 0
: bridge.inputLevel}
muted={unavailable || (bridge.muted && !bridge.speaking)}
/>
</button>
<div class="call-status" role="status" aria-live="polite" aria-atomic="true">
<p class="status-label">
{#if bridge.connecting}<span class="connecting-dot" aria-hidden="true"></span>{/if}
{status}
</p>
<p class="status-hint">
{#if bridge.speaking}
{bridge.muted ? $i18n.t('Microphone off') : $i18n.t('Tap to interrupt')}
{:else if bridge.muted && bridge.connected}
{$i18n.t('Unmute to continue')}
{/if}
</p>
</div>
{#if bridge.error}
<div class="call-error">
<p role="alert">{bridge.error}</p>
<button
type="button"
class="text-action"
on:click={() => bridge.connect(localStorage.token)}
>
{$i18n.t('Retry')}
</button>
</div>
{/if}
</div>
<footer>
<div class="call-controls">
<button
type="button"
class="call-control mute-control"
class:is-muted={bridge.muted}
aria-label={bridge.muted ? $i18n.t('Unmute') : $i18n.t('Mute')}
aria-pressed={bridge.muted}
title={`${bridge.muted ? $i18n.t('Unmute') : $i18n.t('Mute')} (M)`}
disabled={unavailable}
on:click={() => bridge.mute()}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 15.75a3 3 0 0 0 3-3V4.5a3 3 0 0 0-6 0v8.25a3 3 0 0 0 3 3Zm-6-4.5v1.5a6 6 0 0 0 12 0v-1.5M12 18.75v3M9 21.75h6"
/>
{#if bridge.muted}<path stroke-linecap="round" d="m3 3 18 18" />{/if}
</svg>
<span>{bridge.muted ? $i18n.t('Unmute') : $i18n.t('Mute')}</span>
</button>
<button type="button" class="call-control end-control" on:click={endCall}>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.3 15.4c-.8.3-1.7-.3-1.7-1.2v-2.1c0-.8.3-1.5 1-1.9 5.5-3.5 13.3-3.5 18.8 0 .7.4 1 1.1 1 1.9v2.1c0 .9-.9 1.5-1.7 1.2l-4-1.4a1.3 1.3 0 0 1-.9-1.2v-1.3a15 15 0 0 0-7.6 0v1.3c0 .5-.4 1-.9 1.2Z"
/>
</svg>
<span>{$i18n.t('End call')}</span>
</button>
</div>
</footer>
</section>
<style>
.bridge-call {
display: flex;
flex-direction: column;
width: 100%;
min-height: 100%;
padding: 24px 16px max(24px, env(safe-area-inset-bottom));
color: var(--color-gray-900);
}
:global(.dark) .bridge-call {
color: var(--color-gray-100);
}
.call-stage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 260px;
padding: 40px 16px;
}
.orb-button {
flex-shrink: 0;
padding: 12px;
border-radius: 50%;
}
.orb-button:disabled {
cursor: default;
}
.call-status {
margin-top: 16px;
text-align: center;
}
.status-label {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 14px;
line-height: 20px;
font-weight: 400;
}
.status-hint {
min-height: 18px;
margin-top: 8px;
color: var(--color-gray-600);
font-size: 12px;
line-height: 18px;
}
:global(.dark) .status-hint {
color: var(--color-gray-400);
}
.connecting-dot {
flex-shrink: 0;
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
.connecting-dot {
animation: pulse 1.4s ease-in-out infinite;
}
.call-error {
max-width: 260px;
margin-top: 12px;
text-align: center;
font-size: 12px;
line-height: 18px;
}
.call-error .text-action {
margin-top: 8px;
}
footer {
flex-shrink: 0;
}
.text-action {
min-height: 44px;
text-align: start;
font-size: 12px;
font-weight: 500;
text-underline-offset: 3px;
}
.text-action:hover {
text-decoration: underline;
}
.call-controls {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 12px;
}
.call-control {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 104px;
min-height: 44px;
padding: 10px 16px;
border-radius: 999px;
font-size: 12px;
line-height: 20px;
font-weight: 500;
transition:
background 160ms ease,
color 160ms ease;
}
.call-control svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.mute-control {
background: var(--color-gray-100);
}
.mute-control:hover {
background: var(--color-gray-200);
}
:global(.dark) .mute-control {
background: var(--color-gray-800);
}
:global(.dark) .mute-control:hover {
background: var(--color-gray-700);
}
.mute-control.is-muted {
background: var(--color-gray-900);
color: white;
}
:global(.dark) .mute-control.is-muted {
background: var(--color-gray-100);
color: var(--color-gray-900);
}
.end-control {
background: #fff0ef;
color: #b42324;
}
.end-control:hover {
background: #ffe1df;
}
:global(.dark) .end-control {
background: #392526;
color: #f2a3a3;
}
:global(.dark) .end-control:hover {
background: #482a2c;
}
button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 4px;
}
.call-control:disabled,
.text-action:disabled {
opacity: 0.45;
cursor: default;
}
@keyframes pulse {
50% {
opacity: 0.3;
}
}
@media (prefers-reduced-motion: reduce) {
.connecting-dot {
animation: none;
}
.call-control {
transition: none;
}
}
</style>

View file

@ -0,0 +1,247 @@
<script lang="ts">
import { onMount } from 'svelte';
let {
level = 0,
muted = false,
speaking = false
}: { level?: number; muted?: boolean; speaking?: boolean } = $props();
let canvas: HTMLCanvasElement;
let refresh = () => {};
$effect(() => {
level;
muted;
speaking;
refresh();
});
onMount(() => {
const gl = canvas.getContext('webgl', {
alpha: true,
antialias: false,
premultipliedAlpha: false
});
if (!gl) return;
const vertex = gl.createShader(gl.VERTEX_SHADER)!;
const fragment = gl.createShader(gl.FRAGMENT_SHADER)!;
const program = gl.createProgram()!;
const buffer = gl.createBuffer()!;
const dispose = () => {
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(fragment);
};
gl.shaderSource(
vertex,
`
attribute vec2 position;
varying vec2 uv;
void main() { uv = position; gl_Position = vec4(position, 0.0, 1.0); }
`
);
gl.shaderSource(
fragment,
`
precision highp float;
varying vec2 uv;
uniform float time;
uniform float energy;
uniform float speech;
uniform float dark;
uniform float resolution;
// Smooth, evolving material coordinates on a sphere. No image textures.
float material(vec3 p) {
float t = time * 0.34;
vec3 warp = vec3(
sin(p.y * 2.1 + p.z * 1.3 + t),
sin(p.z * 2.3 - p.x * 1.2 - t * 0.8),
sin(p.x * 1.8 + p.y * 1.4 + t * 0.65)
);
vec3 q = p + (0.38 + speech * 0.05 + energy * (0.12 + speech * 0.08)) * warp;
return sin(q.y * 2.2 + q.x * 1.5 + t * 0.3)
+ 0.28 * sin(q.x * 3.2 - q.z * 2.4 - t * 0.7);
}
void main() {
float r = length(uv);
float edge = 0.94;
float alpha = 1.0 - smoothstep(edge - 2.0 / resolution, edge, r);
if (alpha <= 0.0) { gl_FragColor = vec4(0.0); return; }
vec2 xy = uv / edge;
vec3 p = vec3(xy, sqrt(max(0.0, 1.0 - dot(xy, xy))));
float field = material(p);
float e = 0.004;
vec3 gradient = vec3(
material(p + vec3(e, 0.0, 0.0)),
material(p + vec3(0.0, e, 0.0)),
material(p + vec3(0.0, 0.0, e))
) - field;
gradient /= e;
// Tangential displacement changes the lighting without distorting the silhouette.
vec3 tangent = gradient - p * dot(gradient, p);
float crease = exp(-field * field * 2.0);
vec3 n = normalize(p - tangent * crease * (0.10 + energy * 0.03));
vec3 light = normalize(vec3(0.55, 0.9, 1.4));
float diffuse = max(dot(n, light), 0.0);
float fill = max(dot(n, normalize(vec3(-1.0, 0.15, 0.8))), 0.0);
float highlight = pow(max(dot(n, normalize(light + vec3(0.0, 0.0, 1.0))), 0.0), 8.0);
float flow = smoothstep(-0.95, 0.95, field);
vec3 low = mix(vec3(0.145, 0.15, 0.16), vec3(0.22, 0.225, 0.235), dark);
vec3 high = mix(vec3(0.33, 0.335, 0.345), vec3(0.50, 0.505, 0.515), dark);
float emphasis = speech * (0.35 + 0.20 * energy);
low *= 1.0 - 0.30 * emphasis;
high += vec3(0.10) * emphasis;
vec3 base = mix(low, high, flow);
vec3 color = base * (0.64 + 0.30 * diffuse + 0.06 * fill);
color += vec3(0.04 + 0.02 * emphasis) * highlight;
color = pow(color, vec3(1.0 / 2.2));
gl_FragColor = vec4(color, alpha);
}
`
);
gl.compileShader(vertex);
gl.compileShader(fragment);
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
dispose();
return;
}
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW
);
const position = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
const timeUniform = gl.getUniformLocation(program, 'time');
const energyUniform = gl.getUniformLocation(program, 'energy');
const speechUniform = gl.getUniformLocation(program, 'speech');
const darkUniform = gl.getUniformLocation(program, 'dark');
const resolutionUniform = gl.getUniformLocation(program, 'resolution');
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
let frame = 0;
let lastTime = 0;
let elapsed = 0;
let amplitude = 0;
let speech = 0;
let disposed = false;
const draw = (now: number) => {
frame = 0;
if (disposed || gl.isContextLost() || document.hidden) return;
const delta = lastTime ? Math.min((now - lastTime) / 1000, 0.05) : 0;
lastTime = now;
const active = !muted && !reducedMotion.matches;
const target = active
? Math.min(1, Math.pow(Math.max(0, level * (speaking ? 12 : 6)), speaking ? 0.7 : 1))
: 0;
amplitude += (target - amplitude) * (1 - Math.exp(-delta * 6));
speech += ((active && speaking ? 1 : 0) - speech) * (1 - Math.exp(-delta * 4));
if (active) elapsed += delta * (0.8 + amplitude * 0.3 + speech * (0.6 + amplitude * 0.4));
gl.uniform1f(timeUniform, reducedMotion.matches ? 0 : elapsed);
gl.uniform1f(energyUniform, active ? amplitude : 0);
gl.uniform1f(speechUniform, active ? speech : 0);
gl.uniform1f(darkUniform, canvas.closest('.dark') ? 1 : 0);
gl.uniform1f(resolutionUniform, canvas.width);
gl.drawArrays(gl.TRIANGLES, 0, 6);
if (!canvas.classList.contains('rendered')) canvas.classList.add('rendered');
if (active) frame = requestAnimationFrame(draw);
};
refresh = () => {
if (!frame && !disposed) {
lastTime = 0;
frame = requestAnimationFrame(draw);
}
};
const resize = () => {
const size = Math.max(1, Math.round(canvas.clientWidth * 2));
canvas.width = canvas.height = size;
gl.viewport(0, 0, size, size);
refresh();
};
const onVisibility = () => {
cancelAnimationFrame(frame);
frame = 0;
if (!document.hidden) refresh();
};
const onContextLost = () => {
cancelAnimationFrame(frame);
frame = 0;
canvas.classList.remove('rendered');
};
const observer = new ResizeObserver(resize);
observer.observe(canvas);
const themeObserver = new MutationObserver(refresh);
themeObserver.observe(document.documentElement, {
attributes: true,
subtree: true,
attributeFilter: ['class']
});
reducedMotion.addEventListener('change', refresh);
document.addEventListener('visibilitychange', onVisibility);
canvas.addEventListener('webglcontextlost', onContextLost);
resize();
return () => {
disposed = true;
cancelAnimationFrame(frame);
observer.disconnect();
themeObserver.disconnect();
reducedMotion.removeEventListener('change', refresh);
refresh = () => {};
document.removeEventListener('visibilitychange', onVisibility);
canvas.removeEventListener('webglcontextlost', onContextLost);
dispose();
};
});
</script>
<span class="voice-orb" class:muted aria-hidden="true">
<canvas bind:this={canvas}></canvas>
</span>
<style>
.voice-orb {
display: block;
position: relative;
width: 112px;
aspect-ratio: 1;
transition: opacity 240ms ease;
}
canvas {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
background: radial-gradient(circle at 68% 22%, #9a9ca1, #7b7d82 49%, #5d6065);
}
:global(.dark) canvas {
background: radial-gradient(circle at 68% 22%, #b9bbc0, #93959a 49%, #6d7076);
}
canvas:global(.rendered) {
background: transparent;
}
.muted {
opacity: 0.55;
}
@media (prefers-reduced-motion: reduce) {
.voice-orb {
transition: none;
}
}
</style>

View file

@ -3,6 +3,7 @@
import { config, showCallOverlay, showControls } from '$lib/stores';
import type { RealtimeCall } from '$lib/utils/realtime';
import CallOverlay from './CallOverlay.svelte';
import BridgeCallOverlay from './CallOverlay/BridgeCallOverlay.svelte';
import { toast } from 'svelte-sonner';
const i18n = getContext<any>('i18n');
@ -36,18 +37,28 @@
started = true;
});
$: if (started && callMode === 'bridge' && !bridge?.connected && !bridge?.connecting) close();
$: if (
started &&
callMode === 'bridge' &&
!bridge?.connected &&
!bridge?.connecting &&
!bridge?.error
)
close();
</script>
{#if started}
<CallOverlay
bind:files
{submitPrompt}
{stopResponse}
{modelId}
{chatId}
{eventTarget}
bridge={callMode === 'bridge' ? bridge : undefined}
on:close
/>
{#if callMode === 'bridge'}
<BridgeCallOverlay {bridge} on:close />
{:else}
<CallOverlay
bind:files
{submitPrompt}
{stopResponse}
{modelId}
{chatId}
{eventTarget}
on:close
/>
{/if}
{/if}

View file

@ -13,6 +13,9 @@ class RealtimeAudioProcessor extends AudioWorkletProcessor {
this.rendered = new Map();
this.ended = new Set();
this.ticks = 0;
this.inputEnergy = 0;
this.outputEnergy = 0;
this.levelSamples = 0;
this.port.onmessage = ({ data }) => {
if (data.type === 'capture') {
this.enabled = data.enabled;
@ -86,8 +89,21 @@ class RealtimeAudioProcessor extends AudioWorkletProcessor {
}
if (!this.queued) this.playing = false;
}
for (let i = 0; i < output.length; i++) {
const sample = this.enabled ? (input?.[i] ?? 0) : 0;
this.inputEnergy += sample * sample;
this.outputEnergy += output[i] * output[i];
}
this.levelSamples += output.length;
if (++this.ticks % 8 === 0) {
this.port.postMessage({ type: 'playback', queued: this.queued, received: this.received });
this.port.postMessage({
type: 'playback',
queued: this.queued,
received: this.received,
inputLevel: Math.sqrt(this.inputEnergy / this.levelSamples),
outputLevel: Math.sqrt(this.outputEnergy / this.levelSamples)
});
this.inputEnergy = this.outputEnergy = this.levelSamples = 0;
}
return true;
}

View file

@ -75,6 +75,9 @@ export class RealtimeCall {
approval = false;
model = '';
voice = '';
inputLevel = 0;
outputLevel = 0;
error = '';
private ws?: WebSocket;
private context?: AudioContext;
private stream?: MediaStream;
@ -109,6 +112,7 @@ export class RealtimeCall {
async connect(token: string) {
if (this.connected || this.connecting) return;
this.error = '';
this.connecting = true;
this.options.change();
const session = ++this.session;
@ -192,12 +196,21 @@ export class RealtimeCall {
audio: btoa(String.fromCharCode(...new Uint8Array(data.pcm)))
});
} else if (data.type === 'playback') {
const inputLevel = this.muted ? 0 : (data.inputLevel ?? 0);
const outputLevel = data.outputLevel ?? 0;
const levelsChanged =
Math.abs(inputLevel - this.inputLevel) > 0.002 ||
Math.abs(outputLevel - this.outputLevel) > 0.002;
const nextSpeaking = data.queued > 0 || this.sentSamples > data.received;
const speakingChanged = this.speaking !== nextSpeaking;
this.speaking = nextSpeaking;
if (!this.speaking && !this.activeResponse && !this.responseRequested)
this.speakingResponses.clear();
if (speakingChanged) this.options.change();
if (levelsChanged || speakingChanged) {
this.inputLevel = inputLevel;
this.outputLevel = outputLevel;
this.options.change();
}
this.flush();
} else if (data.type === 'overflow') {
this.stopSpeaking();
@ -591,6 +604,7 @@ export class RealtimeCall {
}
this.speakingResponses.clear();
this.speaking = false;
this.outputLevel = 0;
const id = ++this.clearId;
this.clears.set(id, responses);
this.audio?.port.postMessage({ type: 'clear', id });
@ -617,6 +631,7 @@ export class RealtimeCall {
mute() {
this.muted = !this.muted;
if (this.muted) this.inputLevel = 0;
this.stream?.getAudioTracks().forEach((track) => {
track.enabled = !this.muted;
});
@ -626,6 +641,7 @@ export class RealtimeCall {
}
private fail(message: string) {
this.error = message;
this.options.error(message);
this.end();
}
@ -657,6 +673,7 @@ export class RealtimeCall {
this.context = undefined;
this.audio = undefined;
this.connected = this.connecting = this.speaking = this.working = this.approval = false;
this.inputLevel = this.outputLevel = 0;
this.responseRequested = false;
this.cancelRequested = false;
this.receivingSpeech = false;