Compare commits

..

No commits in common. "f268cde348dfd6cc604e7c2146bdd7f823f04b20" and "a4cb799978ed09eaa894a4771c15213d485fe3eb" have entirely different histories.

6 changed files with 41 additions and 188 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
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.
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.
## Privacy and key handling

View file

@ -2,16 +2,15 @@ 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,
@ -26,7 +25,6 @@ import {
TriangleAlert,
Upload,
Volume2,
VolumeX,
X,
} from 'lucide-react'
import { APP_LANGUAGES, APP_LANGUAGE_STORAGE, translate, type AppLanguage } from './i18n'
@ -37,7 +35,7 @@ import {
supportsOutputPicker,
supportsOutputSelection,
} from './lib/audio'
import { GeminiLiveSession, SessionClosedError, type Transcript } from './lib/geminiLive'
import { GeminiLiveSession, type Transcript } from './lib/geminiLive'
const API_KEY_STORAGE = 'relay-gemini-api-key'
const TARGET_LANGUAGE_STORAGE = 'relay-target-language'
@ -101,7 +99,6 @@ 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]> = {
@ -148,7 +145,6 @@ 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())
@ -260,12 +256,8 @@ function App() {
try {
setStatus('picking')
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
await player.initialize()
const label = await capture.request(() => void stop(t('sourceEnded')))
setSourceLabel(label)
sessionStartedAtRef.current = Date.now()
await refreshOutputDevices()
@ -315,9 +307,9 @@ function App() {
}))
})
captureStartedRef.current = true
setStatus(session.isReady() ? 'listening' : 'reconnecting')
setStatus('listening')
} catch (caught) {
if (caught instanceof SessionClosedError) {
if (caught instanceof DOMException && caught.name === 'AbortError') {
setStatus('idle')
return
}
@ -404,60 +396,10 @@ function App() {
</header>
<main id="top">
<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 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>
<section className="workspace" aria-label={t('translatorControls')}>
@ -641,7 +583,7 @@ function App() {
</section>
<section className="loop-card">
<div className="loop-icon"><VolumeX size={23} /></div>
<div className="loop-icon"><Headphones size={23} /></div>
<div>
<h2>{t('loopTitle')}</h2>
<p>{t('loopCopy')}</p>

View file

@ -12,25 +12,10 @@ const english = {
pageTitle: 'Relay — Live audio translation',
relayHome: 'Relay home',
translatorControls: 'Live translator controls',
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.',
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.',
setupSession: 'Set up your session',
clientOnly: 'Client-side only',
geminiApiKey: 'Gemini API key',
@ -82,8 +67,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 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.',
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.',
chooseOutput: 'Choose output',
privacyFooter: 'Your key and audio stay between this browser and Googles Gemini API.',
appLanguage: 'App language',
@ -107,25 +92,10 @@ const slovene: Record<TranslationKey, string> = {
pageTitle: 'Relay — Prevajanje zvoka v živo',
relayHome: 'Domača stran Relay',
translatorControls: 'Kontrolniki prevajalnika v živo',
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.',
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.',
setupSession: 'Nastavite sejo',
clientOnly: 'Samo v brskalniku',
geminiApiKey: 'Ključ API Gemini',
@ -177,8 +147,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: '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.',
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.',
chooseOutput: 'Izberi izhod',
privacyFooter: 'Vaš ključ in zvok ostaneta med tem brskalnikom in Googlovim API-jem Gemini.',
appLanguage: 'Jezik aplikacije',

View file

@ -18,13 +18,12 @@ export function supportsOutputSelection(): boolean {
}
export function supportsOutputPicker(): boolean {
const devices = navigator.mediaDevices as SelectableMediaDevices | undefined
return typeof devices?.selectAudioOutput === 'function'
return typeof (navigator.mediaDevices as SelectableMediaDevices).selectAudioOutput === 'function'
}
export async function chooseOutputDevice(): Promise<MediaDeviceInfo | null> {
const devices = navigator.mediaDevices as SelectableMediaDevices | undefined
if (!devices?.selectAudioOutput) return null
const devices = navigator.mediaDevices as SelectableMediaDevices
if (!devices.selectAudioOutput) return null
return devices.selectAudioOutput()
}
@ -137,7 +136,6 @@ export class DisplayAudioCapture {
noiseSuppression: false,
autoGainControl: false,
suppressLocalAudioPlayback: false,
restrictOwnAudio: true,
},
selfBrowserSurface: 'exclude',
surfaceSwitching: 'include',
@ -234,9 +232,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,32 +44,6 @@ 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
@ -199,10 +173,6 @@ 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)
})
}
@ -232,9 +202,7 @@ export class GeminiLiveSession {
this.clearTimers()
const socket = this.socket
this.socket = null
if (socket && socket.readyState < WebSocket.CLOSING) {
socket.close(1008, truncateWebSocketCloseReason(reason))
}
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1008, reason.slice(0, 120))
this.callbacks.onError(reason)
if (!this.initialSettled) {
@ -270,10 +238,6 @@ export class GeminiLiveSession {
return true
}
isReady(): boolean {
return this.ready && this.socket?.readyState === WebSocket.OPEN
}
close(): void {
if (this.closing) return
this.closing = true
@ -281,7 +245,7 @@ export class GeminiLiveSession {
this.clearTimers()
if (!this.initialSettled) {
this.initialSettled = true
this.rejectInitial?.(new SessionClosedError())
this.rejectInitial?.(new DOMException('Session closed', 'AbortError'))
this.resolveInitial = null
this.rejectInitial = null
}

View file

@ -58,31 +58,14 @@ 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); }
.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); }
.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); }
.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; }
@ -199,22 +182,18 @@ 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); }
.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; }
.hero { padding: 44px 8px 35px; }
.hero-copy { font-size: 14px; }
.workspace { border-radius: 17px; }
.config-panel, .session-panel { padding: 27px 21px; }
.privacy-label { display: none; }
.language-flow { grid-template-columns: 1fr 25px 1fr; }