Compare commits

...

4 commits

Author SHA1 Message Date
Filip's AI Agent f268cde348 Unknown fixes. 2026-07-15 22:51:17 +02:00
Filip's AI Agent 4c96e9ba2b More accurate warnings. 2026-07-15 22:39:56 +02:00
Filip's AI Agent d5a131da0c Simple instructions for users. 2026-07-15 22:35:52 +02:00
Filip's AI Agent 16bf681dc6 Removed banner. 2026-07-15 22:31:48 +02:00
6 changed files with 188 additions and 41 deletions

View file

@ -19,7 +19,7 @@ Open the localhost URL, enter a Gemini API key, choose a target language, and cl
- Chrome or Edge is recommended for tab/system audio sharing
- Output-device selection appears only when `HTMLMediaElement.setSinkId()` is supported
Use headphones or a separate output device to prevent translated speech from being captured again. Relay also requests `selfBrowserSurface: "exclude"` where the browser supports it.
Prefer sharing the individual tab that is playing the source audio. Full system-audio capture may also include Relay's translated output in the digital mix. Relay requests `selfBrowserSurface: "exclude"` and `restrictOwnAudio: true` where supported; a separate output device can also help when the operating system isolates its audio from the captured source.
## Privacy and key handling

View file

@ -2,15 +2,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ArrowRight,
AudioLines,
BookOpen,
Captions,
Check,
ChevronDown,
ChevronUp,
CircleStop,
Clock3,
Eye,
EyeOff,
Gauge,
Headphones,
KeyRound,
Laptop,
Languages as LanguagesIcon,
@ -25,6 +26,7 @@ import {
TriangleAlert,
Upload,
Volume2,
VolumeX,
X,
} from 'lucide-react'
import { APP_LANGUAGES, APP_LANGUAGE_STORAGE, translate, type AppLanguage } from './i18n'
@ -35,7 +37,7 @@ import {
supportsOutputPicker,
supportsOutputSelection,
} from './lib/audio'
import { GeminiLiveSession, type Transcript } from './lib/geminiLive'
import { GeminiLiveSession, SessionClosedError, type Transcript } from './lib/geminiLive'
const API_KEY_STORAGE = 'relay-gemini-api-key'
const TARGET_LANGUAGE_STORAGE = 'relay-target-language'
@ -99,6 +101,7 @@ function friendlyError(error: unknown, t: T): string {
if (error.name === 'NotAllowedError') return t('errorShareCancelled')
if (error.name === 'NotFoundError') return t('errorNoSource')
if (error.name === 'NotReadableError') return t('errorSourceUnreadable')
if (error.name === 'AbortError') return t('errorStart')
}
if (error instanceof Error) {
const localizedMessages: Record<string, Parameters<typeof translate>[1]> = {
@ -145,6 +148,7 @@ function App() {
const [outputDevices, setOutputDevices] = useState<MediaDeviceInfo[]>([])
const [outputDeviceId, setOutputDeviceId] = useState('')
const [settingsOpen, setSettingsOpen] = useState(false)
const [guideOpen, setGuideOpen] = useState(false)
const [diagnostics, setDiagnostics] = useState<SessionDiagnostics>(INITIAL_DIAGNOSTICS)
const [capture] = useState(() => new DisplayAudioCapture())
@ -256,8 +260,12 @@ function App() {
try {
setStatus('picking')
await player.initialize()
const label = await capture.request(() => void stop(t('sourceEnded')))
const captureRequest = capture.request(() => void stop(t('sourceEnded')))
const playerInitialization = player.initialize()
const [captureResult, playerResult] = await Promise.allSettled([captureRequest, playerInitialization])
if (captureResult.status === 'rejected') throw captureResult.reason
if (playerResult.status === 'rejected') throw playerResult.reason
const label = captureResult.value
setSourceLabel(label)
sessionStartedAtRef.current = Date.now()
await refreshOutputDevices()
@ -307,9 +315,9 @@ function App() {
}))
})
captureStartedRef.current = true
setStatus('listening')
setStatus(session.isReady() ? 'listening' : 'reconnecting')
} catch (caught) {
if (caught instanceof DOMException && caught.name === 'AbortError') {
if (caught instanceof SessionClosedError) {
setStatus('idle')
return
}
@ -396,10 +404,60 @@ function App() {
</header>
<main id="top">
<section className="hero">
<div className="eyebrow"><span className="live-dot" /> {t('liveInterpreter')}</div>
<h1>{t('heroLineOne')}<br /><span>{t('heroLineTwo')}</span></h1>
<p className="hero-copy">{t('heroCopy')}</p>
<section className="guide-section" aria-labelledby="guide-button-title">
<button
className="guide-launch"
onClick={() => setGuideOpen((open) => !open)}
aria-expanded={guideOpen}
aria-controls="usage-guide"
>
<span className="guide-launch-icon"><BookOpen size={25} /></span>
<span className="guide-launch-copy">
<strong id="guide-button-title">{t('guideButtonTitle')}</strong>
<small>{t('guideButtonCopy')}</small>
</span>
{guideOpen ? <ChevronUp size={22} /> : <ChevronDown size={22} />}
</button>
{guideOpen && (
<div className="usage-guide" id="usage-guide">
<div className="guide-heading">
<span>{t('guideEyebrow')}</span>
<h2>{t('guideTitle')}</h2>
<p>{t('guideIntro')}</p>
</div>
<ol className="guide-steps">
<li>
<span>1</span>
<div><strong>{t('guideKeyTitle')}</strong><p>{t('guideKeyCopy')}</p></div>
</li>
<li>
<span>2</span>
<div><strong>{t('guideLanguageTitle')}</strong><p>{t('guideLanguageCopy')}</p></div>
</li>
<li>
<span>3</span>
<div><strong>{t('guideOutputTitle')}</strong><p>{t('guideOutputCopy')}</p></div>
</li>
<li>
<span>4</span>
<div><strong>{t('guideShareTitle')}</strong><p>{t('guideShareCopy')}</p></div>
</li>
<li>
<span>5</span>
<div><strong>{t('guidePickerTitle')}</strong><p>{t('guidePickerCopy')}</p></div>
</li>
<li>
<span>6</span>
<div><strong>{t('guideListenTitle')}</strong><p>{t('guideListenCopy')}</p></div>
</li>
</ol>
<div className="guide-tip">
<VolumeX size={20} />
<div><strong>{t('guideTipTitle')}</strong><p>{t('guideTipCopy')}</p></div>
</div>
</div>
)}
</section>
<section className="workspace" aria-label={t('translatorControls')}>
@ -583,7 +641,7 @@ function App() {
</section>
<section className="loop-card">
<div className="loop-icon"><Headphones size={23} /></div>
<div className="loop-icon"><VolumeX size={23} /></div>
<div>
<h2>{t('loopTitle')}</h2>
<p>{t('loopCopy')}</p>

View file

@ -12,10 +12,25 @@ const english = {
pageTitle: 'Relay — Live audio translation',
relayHome: 'Relay home',
translatorControls: 'Live translator controls',
liveInterpreter: 'Live audio interpreter',
heroLineOne: 'Hear anything.',
heroLineTwo: 'Understand everything.',
heroCopy: 'Translate audio playing on your computer in real time. Direct from your browser to Gemini—nothing passes through our servers.',
guideButtonTitle: 'How do I use Relay?',
guideButtonCopy: 'Open the detailed step-by-step guide',
guideEyebrow: 'Getting started',
guideTitle: 'Translate computer audio in six steps',
guideIntro: 'Relay runs entirely in your browser. Have your Gemini API key ready, then follow these steps.',
guideKeyTitle: 'Enter your Gemini API key',
guideKeyCopy: 'Paste your own API key into the key field. It is saved only in this browsers local storage and sent directly to Google when a session starts.',
guideLanguageTitle: 'Choose the translation language',
guideLanguageCopy: 'Select the language you want to hear. Turn on live transcripts only if you also want original and translated text; transcripts add billable text tokens.',
guideOutputTitle: 'Prefer sharing a single tab',
guideOutputCopy: 'Sharing only the tab playing the source audio normally keeps Relays translated output out of the capture. Full system-audio sharing can also capture Relay.',
guideShareTitle: 'Start the translation',
guideShareCopy: 'Click “Share audio & translate.” Your browser will open its standard screen-sharing picker.',
guidePickerTitle: 'Select a source and enable audio',
guidePickerCopy: 'Choose the tab, window, or screen that is playing the audio. Explicitly enable “Share audio” before confirming. Relay will reject sources that do not provide an audio track.',
guideListenTitle: 'Listen and monitor the session',
guideListenCopy: 'The translated voice begins automatically. The live panel shows elapsed time, sent or dropped packets, playback delay, and reconnect attempts. Click “Stop translation” when finished.',
guideTipTitle: 'Keep Relay out of the captured mix',
guideTipCopy: 'Relay does not use your microphone. If you share full system audio, its translated output may still be captured digitally. Prefer a single source tab, or use a separate output device where that isolates the audio.',
setupSession: 'Set up your session',
clientOnly: 'Client-side only',
geminiApiKey: 'Gemini API key',
@ -67,8 +82,8 @@ const english = {
emptyCopy: 'Set your language, share a source with audio, and Relay will begin speaking the translation.',
voiceTranslation: 'Live voice translation',
transcripts: 'Original + translated transcripts',
loopTitle: 'Keep the translation from hearing itself',
loopCopy: 'Headphones are the safest option. You can also send translated speech to a separate output device, or share a different tab—Relay asks supported browsers to exclude this tab.',
loopTitle: 'Keep Relays output out of the shared audio',
loopCopy: 'Prefer sharing only the tab playing the source audio. Full system-audio sharing may capture Relays output digitally; supported browsers are asked to exclude or suppress this apps own audio.',
chooseOutput: 'Choose output',
privacyFooter: 'Your key and audio stay between this browser and Googles Gemini API.',
appLanguage: 'App language',
@ -92,10 +107,25 @@ const slovene: Record<TranslationKey, string> = {
pageTitle: 'Relay — Prevajanje zvoka v živo',
relayHome: 'Domača stran Relay',
translatorControls: 'Kontrolniki prevajalnika v živo',
liveInterpreter: 'Tolmač zvoka v živo',
heroLineOne: 'Poslušajte kar koli.',
heroLineTwo: 'Razumite vse.',
heroCopy: 'V živo prevajajte zvok, ki se predvaja na vašem računalniku. Zvok gre neposredno iz brskalnika v Gemini—nič ne potuje prek naših strežnikov.',
guideButtonTitle: 'Kako uporabljam Relay?',
guideButtonCopy: 'Odprite podrobna navodila po korakih',
guideEyebrow: 'Prvi koraki',
guideTitle: 'Prevedite zvok računalnika v šestih korakih',
guideIntro: 'Relay deluje v celoti v vašem brskalniku. Pripravite ključ API Gemini in sledite tem korakom.',
guideKeyTitle: 'Vnesite ključ API Gemini',
guideKeyCopy: 'Prilepite svoj ključ API v polje za ključ. Shranjen je samo v lokalni shrambi tega brskalnika in se ob začetku seje pošlje neposredno Googlu.',
guideLanguageTitle: 'Izberite jezik prevoda',
guideLanguageCopy: 'Izberite jezik, v katerem želite poslušati prevod. Prepise v živo vključite le, če želite tudi izvirno in prevedeno besedilo; prepisi ustvarijo dodatne plačljive besedilne žetone.',
guideOutputTitle: 'Raje delite posamezen zavihek',
guideOutputCopy: 'Če delite samo zavihek, ki predvaja izvorni zvok, prevedeni izhod aplikacije Relay praviloma ni zajet. Skupna raba celotnega sistemskega zvoka lahko zajame tudi Relay.',
guideShareTitle: 'Začnite prevajanje',
guideShareCopy: 'Kliknite »Deli zvok in prevajaj«. Brskalnik bo odprl standardni izbirnik za skupno rabo zaslona.',
guidePickerTitle: 'Izberite vir in vključite zvok',
guidePickerCopy: 'Izberite zavihek, okno ali zaslon, ki predvaja zvok. Pred potrditvijo izrecno vključite možnost »Deli zvok«. Relay zavrne vire, ki ne zagotovijo zvočnega posnetka.',
guideListenTitle: 'Poslušajte in spremljajte sejo',
guideListenCopy: 'Prevedeni glas se začne predvajati samodejno. Plošča v živo prikazuje trajanje, poslane ali izpuščene pakete, zakasnitev predvajanja in poskuse ponovne povezave. Ko končate, kliknite »Ustavi prevajanje«.',
guideTipTitle: 'Izločite Relay iz zajetega zvoka',
guideTipCopy: 'Relay ne uporablja mikrofona. Če delite celotni sistemski zvok, se lahko prevedeni izhod vseeno zajame digitalno. Raje delite posamezen izvorni zavihek ali uporabite ločeno izhodno napravo, kadar ta loči zvok.',
setupSession: 'Nastavite sejo',
clientOnly: 'Samo v brskalniku',
geminiApiKey: 'Ključ API Gemini',
@ -147,8 +177,8 @@ const slovene: Record<TranslationKey, string> = {
emptyCopy: 'Izberite jezik in delite vir z zvokom. Relay bo začel predvajati prevod.',
voiceTranslation: 'Glasovno prevajanje v živo',
transcripts: 'Izvirni in prevedeni prepis',
loopTitle: 'Preprečite, da bi prevod poslušal samega sebe',
loopCopy: 'Najvarnejša izbira so slušalke. Prevedeni govor lahko pošljete tudi na drugo izhodno napravo ali delite drug zavihek—Relay podprte brskalnike zaprosi, naj ta zavihek izključijo.',
loopTitle: 'Izločite izhod Relay iz zvoka v skupni rabi',
loopCopy: 'Raje delite samo zavihek, ki predvaja izvorni zvok. Skupna raba celotnega sistemskega zvoka lahko digitalno zajame izhod Relay; podprte brskalnike zaprosimo, naj zvok te aplikacije izključijo ali zadušijo.',
chooseOutput: 'Izberi izhod',
privacyFooter: 'Vaš ključ in zvok ostaneta med tem brskalnikom in Googlovim API-jem Gemini.',
appLanguage: 'Jezik aplikacije',

View file

@ -18,12 +18,13 @@ export function supportsOutputSelection(): boolean {
}
export function supportsOutputPicker(): boolean {
return typeof (navigator.mediaDevices as SelectableMediaDevices).selectAudioOutput === 'function'
const devices = navigator.mediaDevices as SelectableMediaDevices | undefined
return typeof devices?.selectAudioOutput === 'function'
}
export async function chooseOutputDevice(): Promise<MediaDeviceInfo | null> {
const devices = navigator.mediaDevices as SelectableMediaDevices
if (!devices.selectAudioOutput) return null
const devices = navigator.mediaDevices as SelectableMediaDevices | undefined
if (!devices?.selectAudioOutput) return null
return devices.selectAudioOutput()
}
@ -136,6 +137,7 @@ export class DisplayAudioCapture {
noiseSuppression: false,
autoGainControl: false,
suppressLocalAudioPlayback: false,
restrictOwnAudio: true,
},
selfBrowserSurface: 'exclude',
surfaceSwitching: 'include',
@ -232,9 +234,9 @@ export class PcmPlayer {
}
async setOutputDevice(deviceId: string): Promise<void> {
this.sinkId = deviceId
if (!this.audio.setSinkId) throw new Error('This browser does not support selecting an output device.')
await this.audio.setSinkId(deviceId)
this.sinkId = deviceId
}
enqueue(base64: string): PlaybackQueueResult {

View file

@ -44,6 +44,32 @@ async function decodeMessageData(data: unknown): Promise<string> {
throw new Error(`Unsupported WebSocket frame type: ${Object.prototype.toString.call(data)}`)
}
function truncateWebSocketCloseReason(reason: string): string {
const encoder = new TextEncoder()
let truncated = ''
let byteLength = 0
for (const character of reason) {
const characterBytes = encoder.encode(character).byteLength
if (byteLength + characterBytes > 123) break
truncated += character
byteLength += characterBytes
}
return truncated
}
function isRetryableCloseCode(code: number): boolean {
return ![1002, 1003, 1007, 1008, 1009, 1010].includes(code)
}
export class SessionClosedError extends Error {
constructor() {
super('Session closed')
this.name = 'SessionClosedError'
}
}
export class GeminiLiveSession {
private socket: WebSocket | null = null
private closing = false
@ -173,6 +199,10 @@ export class GeminiLiveSession {
this.clearSetupTimer()
if (this.closing) return
const reason = event.reason || `Connection closed (code ${event.code})`
if (!isRetryableCloseCode(event.code)) {
this.fail(reason)
return
}
this.scheduleReconnect(reason)
})
}
@ -202,7 +232,9 @@ export class GeminiLiveSession {
this.clearTimers()
const socket = this.socket
this.socket = null
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1008, reason.slice(0, 120))
if (socket && socket.readyState < WebSocket.CLOSING) {
socket.close(1008, truncateWebSocketCloseReason(reason))
}
this.callbacks.onError(reason)
if (!this.initialSettled) {
@ -238,6 +270,10 @@ export class GeminiLiveSession {
return true
}
isReady(): boolean {
return this.ready && this.socket?.readyState === WebSocket.OPEN
}
close(): void {
if (this.closing) return
this.closing = true
@ -245,7 +281,7 @@ export class GeminiLiveSession {
this.clearTimers()
if (!this.initialSettled) {
this.initialSettled = true
this.rejectInitial?.(new DOMException('Session closed', 'AbortError'))
this.rejectInitial?.(new SessionClosedError())
this.resolveInitial = null
this.rejectInitial = null
}

View file

@ -58,14 +58,31 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible
.icon-button:hover { background: white; }
main { width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding-bottom: 64px; }
.hero { padding: 78px 0 52px; text-align: center; max-width: 820px; margin: 0 auto; }
.eyebrow { display: inline-flex; align-items: center; gap: 8px; text-transform: uppercase; letter-spacing: normal; font-size: 11px; font-weight: 700; color: var(--green); }
.live-dot { width: 7px; height: 7px; background: #2e9b77; border-radius: 50%; box-shadow: 0 0 0 5px rgba(46,155,119,.13); }
.hero h1 { margin: 20px 0 18px; font: 800 clamp(42px, 6vw, 70px)/1.02 Arial, sans-serif; letter-spacing: -.055em; }
.hero h1 span { color: var(--green); }
.hero-copy { max-width: 640px; margin: auto; font-size: 17px; line-height: 1.65; color: var(--muted); }
.workspace { display: grid; grid-template-columns: 1fr 1fr; min-height: 610px; border: 1px solid #d8d6cf; background: var(--paper); border-radius: 24px; overflow: hidden; box-shadow: 0 24px 70px rgba(32, 40, 36, .08); }
.guide-section { margin-top: 40px; }
.guide-launch { width: 100%; min-height: 88px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 16px; padding: 17px 22px; color: var(--ink); text-align: left; border: 1px solid #cfd9d4; border-radius: 17px; background: linear-gradient(135deg, #eef7f3, #faf8f3); cursor: pointer; box-shadow: 0 10px 28px rgba(32, 40, 36, .05); transition: transform .18s, border-color .18s, box-shadow .18s; }
.guide-launch:hover { transform: translateY(-1px); border-color: #9ebdaf; box-shadow: 0 14px 32px rgba(32, 40, 36, .08); }
.guide-launch-icon { width: 50px; height: 50px; display: grid; place-items: center; color: white; background: var(--green); border-radius: 14px 14px 14px 5px; }
.guide-launch-copy { min-width: 0; display: flex; flex-direction: column; gap: 5px; }
.guide-launch-copy strong { font-size: 17px; }
.guide-launch-copy small { color: var(--muted); font-size: 12px; }
.guide-launch > svg { color: var(--green); }
.usage-guide { margin-top: 12px; padding: 30px; border: 1px solid #d8d8d1; border-radius: 18px; background: rgba(251,250,247,.96); box-shadow: 0 18px 45px rgba(32,40,36,.06); }
.guide-heading { max-width: 720px; margin-bottom: 25px; }
.guide-heading > span { display: block; margin-bottom: 7px; color: var(--green); font-size: 10px; font-weight: 700; text-transform: uppercase; }
.guide-heading h2 { margin-bottom: 7px; font-size: 21px; }
.guide-heading p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.guide-steps { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 0; padding: 0; list-style: none; }
.guide-steps li { display: grid; grid-template-columns: auto 1fr; align-items: start; gap: 12px; padding: 16px; border: 1px solid #e0dfd9; border-radius: 12px; background: white; }
.guide-steps li > span { width: 28px; height: 28px; display: grid; place-items: center; color: var(--green); background: #e3f1eb; border-radius: 8px; font-size: 11px; font-weight: 800; }
.guide-steps strong { display: block; margin: 1px 0 5px; font-size: 12px; }
.guide-steps p { margin: 0; color: #747c78; font-size: 10px; line-height: 1.55; }
.guide-tip { display: flex; align-items: flex-start; gap: 12px; margin-top: 13px; padding: 15px 17px; color: #845134; border: 1px solid #ead2c2; border-radius: 12px; background: #fff3eb; }
.guide-tip > svg { flex: 0 0 auto; margin-top: 1px; }
.guide-tip strong { display: block; margin-bottom: 3px; font-size: 11px; }
.guide-tip p { margin: 0; color: #876957; font-size: 10px; line-height: 1.5; }
.workspace { display: grid; grid-template-columns: 1fr 1fr; min-height: 610px; margin-top: 18px; border: 1px solid #d8d6cf; background: var(--paper); border-radius: 24px; overflow: hidden; box-shadow: 0 24px 70px rgba(32, 40, 36, .08); }
.config-panel, .session-panel { padding: 38px 40px; }
.config-panel { border-right: 1px solid var(--line); background: rgba(251,250,247,.96); }
.session-panel { background: #f7f6f2; display: flex; flex-direction: column; }
@ -182,18 +199,22 @@ footer p { color: #888e8b; font-size: 10px; }
@media (max-width: 850px) {
.workspace { grid-template-columns: 1fr; }
.guide-steps { grid-template-columns: 1fr; }
.config-panel { border-right: 0; border-bottom: 1px solid var(--line); }
.session-panel { min-height: 550px; }
.hero { padding-top: 55px; }
}
@media (max-width: 560px) {
.topbar { padding: 0 16px; grid-template-columns: 1fr auto; }
.model-pill { display: none; }
main { width: min(100% - 24px, 1180px); }
.hero { padding: 44px 8px 35px; }
.hero-copy { font-size: 14px; }
.workspace { border-radius: 17px; }
.guide-section { margin-top: 24px; }
.guide-launch { min-height: 78px; padding: 14px 16px; }
.guide-launch-icon { width: 44px; height: 44px; }
.guide-launch-copy strong { font-size: 14px; }
.guide-launch-copy small { font-size: 10px; }
.usage-guide { padding: 21px; }
.workspace { margin-top: 14px; border-radius: 17px; }
.config-panel, .session-panel { padding: 27px 21px; }
.privacy-label { display: none; }
.language-flow { grid-template-columns: 1fr 25px 1fr; }