Skip to content

Commit 26d2c37

Browse files
committed
Harden audio capture, billing clamp, reconnect and UI captions/modals
Third batch of audit-driven fixes (low-risk, no happy-path behaviour change): - process_loopback: a persistent consumer-callback fault is no longer swallowed silently every frame — after 50 consecutive failures it sets the capture-error flag so billing stops and the dead-capture notice fires (single transient frames are still tolerated) - pipeline: clamp the per-heartbeat reported delta to the same ceiling the stop() tail already uses, so an OS sleep/hibernate clock jump can't bill the whole elapsed wall-clock as one delta (watermark advancement unchanged) - webui: ignore a resize whose dimensions match the monitor work area, so an OS-driven maximize (Win+Up) can't pollute the saved restore geometry - translator + openai_translator: treat an accept-then-immediately-close session as a transient failure (backoff + cap) instead of spinning an unbounded no-backoff reconnect loop - openai_translator: record sent/received seconds so the in-app usage/cost readout reflects OpenAI sessions instead of showing 0 - index.html: accumulate caption fragments correctly for length-changing case folds (Turkish İ, German ß) in history highlight; use a modal stack so a modal opened over the Settings drawer no longer breaks its focus trap - build_official: the official-flag tripwire strips a trailing comment before comparison so `= True # x` can no longer slip past the guard
1 parent 64fd27c commit 26d2c37

7 files changed

Lines changed: 195 additions & 17 deletions

File tree

app/build_official.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ def assert_clean_official_flag(file_path: pathlib.Path):
7373
if not m:
7474
raise ValueError(f"Could not locate IS_OFFICIAL_RELEASE in {file_path}")
7575

76-
rhs = m.group(1).strip()
76+
# Strip a trailing comment before comparing: the greedy (.+) capture also
77+
# swallows any " # ..." note, so 'True # default' must not slip past the guard.
78+
rhs = m.group(1).split("#", 1)[0].strip()
7779
# The committed value must be a resolver call (or False), never a hard True.
7880
if rhs == "True":
7981
raise RuntimeError(

app/openai_translator.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@
5252
# OpenAI realtime cap is ~60 min; plan a rotation well before it. Far less churn
5353
# than Gemini's 13-min cycle.
5454
_HARD_ROTATE_SECONDS = 58 * 60
55+
# A session the server accepts then closes in under this many seconds (no error,
56+
# no planned rotation) is treated as a covert transient failure, so an
57+
# account/region/endpoint reject can't spin a no-backoff reconnect loop forever.
58+
_MIN_SESSION_SECONDS = 5.0
59+
60+
# Usage is recorded into LiveTranslator's process-wide accumulator so the in-app
61+
# minutes/cost readout (webui get_usage) aggregates both engines into one total.
62+
# Imported lazily on first frame to keep google.genai off the OpenAI cold path.
63+
_USAGE_ADD = None
64+
65+
66+
def _record_usage(key: str, amount: float):
67+
global _USAGE_ADD
68+
if _USAGE_ADD is None:
69+
from .translator import _add_usage
70+
_USAGE_ADD = _add_usage
71+
_USAGE_ADD(key, amount)
5572

5673
# Terminal (non-retryable with the same key) OpenAI failure markers + 4xx codes.
5774
# 429 excluded: a bare rate-limit is transient (retry w/ backoff, bounded by
@@ -210,6 +227,9 @@ async def _main(self):
210227
last_error_text = None
211228
while not self._stopping.is_set():
212229
self._ready.clear()
230+
# Marks when the current session became live; stays None until ready so
231+
# the error/exit paths can tell a connect failure from a dropped session.
232+
started = None
213233
try:
214234
ws = await self._connect()
215235
try:
@@ -219,8 +239,11 @@ async def _main(self):
219239
self._reinject_carryover()
220240
self.on_status(t("st_connected", name=self.name, lang=self.target_lang))
221241
backoff = 1.0
222-
transient_failures = 0
223242
last_error_text = None
243+
# transient_failures is cleared only once the session proves
244+
# healthy (lived past _MIN_SESSION_SECONDS or rotated), not on the
245+
# bare connect — otherwise an accept-then-immediately-close server
246+
# would reset the cap every iteration and never stop reconnecting.
224247
started = time.monotonic()
225248
sender = asyncio.create_task(self._sender(ws, started))
226249
receiver = asyncio.create_task(self._receiver(ws))
@@ -249,8 +272,27 @@ async def _main(self):
249272
await ws.close()
250273
except Exception:
251274
pass
252-
if not self._stopping.is_set():
253-
self.on_status(t("st_renewing", name=self.name))
275+
# The session ended without raising. A planned rotation or a session
276+
# that stayed alive past the minimum lifetime is a healthy end: clear
277+
# the transient machinery and reconnect cleanly. A session the server
278+
# accepted then closed almost immediately (no error, no rotation) is a
279+
# covert failure — back off and bound it like any transient drop so
280+
# the client never spins reconnecting a dead endpoint forever.
281+
if rotating or (started is not None
282+
and time.monotonic() - started >= _MIN_SESSION_SECONDS):
283+
transient_failures = 0
284+
if not self._stopping.is_set():
285+
self.on_status(t("st_renewing", name=self.name))
286+
elif not self._stopping.is_set():
287+
transient_failures += 1
288+
if transient_failures >= _MAX_TRANSIENT_FAILURES:
289+
self.on_status(t("st_conn_err", name=self.name, s=0,
290+
e="server closed session immediately"))
291+
break
292+
self.on_status(t("st_conn_err", name=self.name, s=backoff,
293+
e="server closed session immediately"))
294+
await asyncio.sleep(backoff)
295+
backoff = min(backoff * 1.6, 6)
254296
except _Rotate:
255297
self._ready.clear()
256298
continue
@@ -264,6 +306,11 @@ async def _main(self):
264306
self.on_status(t("st_conn_err", name=self.name, s=0, e=e))
265307
traceback.print_exc()
266308
break
309+
# A session that ran past the minimum lifetime proves the path works;
310+
# a later drop starts a fresh failure streak (preserves the "a
311+
# successful connection clears the failure count" resilience).
312+
if started is not None and time.monotonic() - started >= _MIN_SESSION_SECONDS:
313+
transient_failures = 0
267314
transient_failures += 1
268315
if transient_failures >= _MAX_TRANSIENT_FAILURES:
269316
self.on_status(t("st_conn_err", name=self.name, s=0, e=e))
@@ -317,6 +364,8 @@ async def _sender(self, ws, started: float):
317364
"type": "session.input_audio_buffer.append",
318365
"audio": base64.b64encode(item).decode("ascii"),
319366
}))
367+
# 24 kHz mono PCM16 → 24000*2 = 48000 bytes/sec sent.
368+
_record_usage("in_sec", len(item) / 48000)
320369

321370
async def _receiver(self, ws):
322371
async for raw in ws:
@@ -330,7 +379,10 @@ async def _receiver(self, ws):
330379
if etype == "session.output_audio.delta":
331380
b64 = ev.get("delta") or ev.get("audio")
332381
if b64:
333-
self.on_audio(base64.b64decode(b64))
382+
pcm = base64.b64decode(b64)
383+
self.on_audio(pcm)
384+
# 24 kHz mono PCM16 → 48000 bytes/sec received.
385+
_record_usage("out_sec", len(pcm) / 48000)
334386
elif etype == "session.output_transcript.delta":
335387
txt = ev.get("delta") or ev.get("text")
336388
if txt:

app/pipeline.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -736,8 +736,15 @@ def _heartbeat_loop(self):
736736
if not sid:
737737
continue
738738
if delta > 0:
739+
# Same client clamp the stop() tail applies: one interval can never
740+
# legitimately exceed a heartbeat (+ bounded slack). On sleep/resume
741+
# the monotonic clock jumps while capture may not flag failed, which
742+
# would otherwise bill the whole wall-clock gap as one delta. Cap only
743+
# the REPORTED value — the watermark already advanced past the excess
744+
# in _consume_minutes, so the surplus is dropped, not deferred.
745+
max_beat = (self.HEARTBEAT_SECONDS + 2.0) / 60.0
739746
voxis_client.report_usage_async(
740-
sid, delta, source, self.current_engine() or "gemini",
747+
sid, min(delta, max_beat), source, self.current_engine() or "gemini",
741748
on_quota_exceeded=self._fire_quota_exceeded)
742749
self._dispatch_usage_reported()
743750

app/process_loopback.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,15 @@ def _processor(self):
269269
# this gate the loop guard below is immediately false and the processor
270270
# would exit before any audio ever arrives — leaving the engine deaf.
271271
self._go.wait()
272+
# Count consecutive consumer faults. A single bad frame is swallowed
273+
# (transient), but a persistent fault (ducker COM failure, resampler
274+
# error, downstream send) would otherwise be silently swallowed every
275+
# frame forever — capture stays "healthy", billing keeps running, yet no
276+
# audio reaches the translator. After a small run of back-to-back
277+
# failures, record it via the same _err the capture thread uses for
278+
# fatal faults so `failed` / _maybe_warn_capture_dead surfaces it and
279+
# billing stops. Reset on any success so it never trips on noise.
280+
fails = 0
272281
while self._run or self._queue:
273282
if not self._queue:
274283
self._has_data.wait(0.05)
@@ -280,7 +289,15 @@ def _processor(self):
280289
continue
281290
try:
282291
self._on_chunk(x)
283-
except Exception:
292+
fails = 0
293+
except Exception as e:
294+
fails += 1
295+
# Rate-limited logging: first failure, then every ~200th, so a
296+
# persistent fault is visible without flooding the log per frame.
297+
if fails == 1 or fails % 200 == 0:
298+
print(f"[ploopback] consumer fault #{fails}: {e!r}")
299+
if fails >= 50 and self._err is None:
300+
self._err = e
284301
continue
285302

286303
def start(self):

app/translator.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@
4444
# outage surfaces a single actionable status instead of spinning forever.
4545
_MAX_TRANSIENT_FAILURES = 8
4646

47+
# A session the server accepts then closes in under this many seconds (no
48+
# exception, no planned rotation) is treated as a covert transient failure —
49+
# otherwise an account/region/endpoint reject would spin a no-backoff reconnect
50+
# loop at full speed. A real session always lives far longer than this.
51+
_MIN_SESSION_SECONDS = 5.0
52+
4753
# Live API audio pricing, USD per minute (input + output).
4854
COST_IN_PER_MIN = 0.0053
4955
COST_OUT_PER_MIN = 0.0315
@@ -239,6 +245,9 @@ async def _main(self):
239245
# live": clear on every iteration so the gap during reconnect/rotation
240246
# is never reported ready.
241247
self._ready.clear()
248+
# Marks when the current session became live; stays None until ready so
249+
# the error/exit paths can tell a connect failure from a dropped session.
250+
started = None
242251
# Resume the prior session so a rotation / GoAway / drop reconnects
243252
# seamlessly instead of cold-starting (handle=None = fresh session).
244253
if _SUPPORTS_RESUMPTION:
@@ -262,8 +271,11 @@ async def _main(self):
262271
self.on_status(t("st_connected", name=self.name, lang=self.target_lang))
263272
self._ready.set()
264273
backoff = 1.0
265-
transient_failures = 0
266274
last_error_text = None
275+
# transient_failures is cleared only once the session proves
276+
# healthy (lived past _MIN_SESSION_SECONDS or rotated), not on the
277+
# bare connect — otherwise an accept-then-immediately-close server
278+
# would reset the cap every iteration and never stop reconnecting.
267279
started = time.monotonic()
268280
sender = asyncio.create_task(self._sender(session, started))
269281
receiver = asyncio.create_task(self._receiver(session))
@@ -295,8 +307,28 @@ async def _main(self):
295307
exc = task.exception()
296308
if exc and not isinstance(exc, _Rotate):
297309
raise exc
298-
if not self._stopping.is_set():
299-
self.on_status(t("st_renewing", name=self.name))
310+
# The session ended without raising. A planned rotation or a session
311+
# that stayed alive past the minimum lifetime is a healthy end: clear
312+
# the transient machinery and reconnect cleanly. A session the server
313+
# accepted then closed almost immediately (no exception, no rotation)
314+
# is a covert failure — back off and bound it like any transient drop
315+
# so the client never spins reconnecting a dead endpoint forever.
316+
if rotating or (started is not None
317+
and time.monotonic() - started >= _MIN_SESSION_SECONDS):
318+
transient_failures = 0
319+
if not self._stopping.is_set():
320+
self.on_status(t("st_renewing", name=self.name))
321+
elif not self._stopping.is_set():
322+
self._resume_handle = None
323+
transient_failures += 1
324+
if transient_failures >= _MAX_TRANSIENT_FAILURES:
325+
self.on_status(t("st_conn_err", name=self.name, s=0,
326+
e="server closed session immediately"))
327+
break
328+
self.on_status(t("st_conn_err", name=self.name, s=backoff,
329+
e="server closed session immediately"))
330+
await asyncio.sleep(backoff)
331+
backoff = min(backoff * 1.6, 6)
300332
except _Rotate:
301333
# Defensive: a _Rotate must not be treated as a transient error.
302334
self._ready.clear()
@@ -313,6 +345,11 @@ async def _main(self):
313345
self.on_status(t("st_conn_err", name=self.name, s=0, e=e))
314346
traceback.print_exc()
315347
break
348+
# A session that ran past the minimum lifetime proves the path works;
349+
# a later drop starts a fresh failure streak (preserves the original
350+
# "a successful connection clears the failure count" resilience).
351+
if started is not None and time.monotonic() - started >= _MIN_SESSION_SECONDS:
352+
transient_failures = 0
316353
transient_failures += 1
317354
# A failed reconnect may be a stale/expired resume handle — drop it
318355
# so the next attempt starts a fresh session.

app/web/index.html

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2768,7 +2768,18 @@ <h2 id="privacy-title" data-i18n="privacy_title">Gizlilik & veri akışı</h2>
27682768

27692769
/* ---------- modal focus management (drawer, consent, onboarding, auth) ----- */
27702770
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
2771+
// Modal STACK: a nested modal (e.g. topbar History/Privacy opened over the
2772+
// Settings drawer) must not blow away the parent's focus-trap/Escape on close.
2773+
// The top entry owns the trap, Escape and scrim; activeModal/modalOnEsc/
2774+
// modalReturnFocus mirror that top so existing references keep working.
27712775
let activeModal=null, modalReturnFocus=null, modalOnEsc=null;
2776+
const modalStack=[];
2777+
function syncModalTop(){
2778+
const top=modalStack[modalStack.length-1]||null;
2779+
activeModal = top ? top.el : null;
2780+
modalOnEsc = top ? top.onEsc : null;
2781+
modalReturnFocus = top ? top.returnFocus : null;
2782+
}
27722783
function trapTab(e){
27732784
if(e.key!=='Tab' || !activeModal) return;
27742785
const f=[...activeModal.querySelectorAll(FOCUSABLE)].filter(el=>el.offsetParent!==null || el===document.activeElement);
@@ -2778,12 +2789,14 @@ <h2 id="privacy-title" data-i18n="privacy_title">Gizlilik & veri akışı</h2>
27782789
else if(!e.shiftKey && document.activeElement===last){ e.preventDefault(); first.focus(); }
27792790
}
27802791
function openModal(el, onEsc, returnTo){
2781-
activeModal=el; modalOnEsc=onEsc||null; modalReturnFocus=returnTo||document.activeElement;
2792+
modalStack.push({el, onEsc:onEsc||null, returnFocus:returnTo||document.activeElement});
2793+
syncModalTop();
27822794
const first=el.querySelector(FOCUSABLE); if(first) try{ first.focus(); }catch(_){}
27832795
}
27842796
function closeModal(){
2785-
const ret=modalReturnFocus;
2786-
activeModal=null; modalOnEsc=null; modalReturnFocus=null;
2797+
const top=modalStack.pop();
2798+
syncModalTop(); // restore the parent modal's trap/Esc, if any
2799+
const ret=top ? top.returnFocus : null;
27872800
if(ret) try{ ret.focus(); }catch(_){}
27882801
}
27892802
document.addEventListener('keydown', e=>{
@@ -2994,9 +3007,29 @@ <h2 id="privacy-title" data-i18n="privacy_title">Gizlilik & veri akışı</h2>
29943007
function escHtml(s){ return (s||'').replace(/[&<>]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
29953008
function highlight(s, q){
29963009
if(!q) return escHtml(s);
2997-
const lo=(s||'').toLowerCase(); let out='', i=0;
2998-
while(true){ const j=lo.indexOf(q,i); if(j<0){ out+=escHtml(s.slice(i)); break; }
2999-
out+=escHtml(s.slice(i,j))+'<mark>'+escHtml(s.slice(j,j+q.length))+'</mark>'; i=j+q.length; }
3010+
s=s||''; const lo=s.toLowerCase();
3011+
// Fast path: most scripts case-fold without changing code-unit count, so
3012+
// lowercase offsets map 1:1 onto the original string.
3013+
if(lo.length===s.length){ let out='', i=0;
3014+
while(true){ const j=lo.indexOf(q,i); if(j<0){ out+=escHtml(s.slice(i)); break; }
3015+
out+=escHtml(s.slice(i,j))+'<mark>'+escHtml(s.slice(j,j+q.length))+'</mark>'; i=j+q.length; }
3016+
return out;
3017+
}
3018+
// Slow path: a length-changing fold (Turkish 'İ'→'i̇', German 'ß'→'ss')
3019+
// shifts lowercase offsets, so fold per-character and map each folded code
3020+
// unit back to its original index to keep <mark> on the right characters.
3021+
const map=[]; let folded='';
3022+
for(let k=0;k<s.length;k++){ const f=s[k].toLowerCase(); for(let m=0;m<f.length;m++){ folded+=f[m]; map.push(k); } }
3023+
let out='', i=0; // i indexes the ORIGINAL string
3024+
while(true){
3025+
let fi=0; while(fi<map.length && map[fi]<i) fi++; // folded offset for original i
3026+
const j=folded.indexOf(q,fi);
3027+
if(j<0){ out+=escHtml(s.slice(i)); break; }
3028+
const startOrig=map[j];
3029+
const endOrig=map[j+q.length-1]+1; // exclusive original end of the match
3030+
out+=escHtml(s.slice(i,startOrig))+'<mark>'+escHtml(s.slice(startOrig,endOrig))+'</mark>';
3031+
i=endOrig;
3032+
}
30003033
return out;
30013034
}
30023035
function renderHistoryTurns(){

app/webui.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -533,9 +533,39 @@ def _fixpoint(anchor):
533533
}.get(anchor, N | W)
534534

535535
# ── Window geometry persistence (size/position/maximized) ────────────────
536+
@staticmethod
537+
def _work_area_size():
538+
"""Primary-monitor work-area size (px), best-effort. Used to reject an
539+
OS-driven maximize (Win+Up / title-bar double-click) that fires `resized`
540+
before `maximized` — without ordering guarantees the full work-area size
541+
would otherwise be stored as the RESTORE geometry, sticking the window at
542+
screen size after un-maximize. Returns None on any failure."""
543+
try:
544+
import ctypes
545+
from ctypes import wintypes
546+
rect = wintypes.RECT()
547+
# SPI_GETWORKAREA = 0x0030
548+
if ctypes.windll.user32.SystemParametersInfoW(0x0030, 0, ctypes.byref(rect), 0):
549+
return rect.right - rect.left, rect.bottom - rect.top
550+
except Exception:
551+
pass
552+
try:
553+
import ctypes
554+
# SM_CXMAXIMIZED = 61, SM_CYMAXIMIZED = 62
555+
return (ctypes.windll.user32.GetSystemMetrics(61),
556+
ctypes.windll.user32.GetSystemMetrics(62))
557+
except Exception:
558+
return None
559+
536560
def _on_win_resized(self, *a):
537561
if len(a) >= 2 and not self._maximized:
538-
self._win_geom["w"], self._win_geom["h"] = int(a[0]), int(a[1])
562+
w, h = int(a[0]), int(a[1])
563+
# Skip a resize matching the work area: an OS maximize whose `resized`
564+
# arrives before `maximized` must not pollute the restore geometry.
565+
wa = self._work_area_size()
566+
if wa is not None and abs(w - wa[0]) <= 4 and abs(h - wa[1]) <= 4:
567+
return
568+
self._win_geom["w"], self._win_geom["h"] = w, h
539569

540570
def _on_win_moved(self, *a):
541571
if len(a) >= 2 and not self._maximized:

0 commit comments

Comments
 (0)