From 7e50640f975b8a6bae83ffcdbeebf84716271abe Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:15:52 +0530 Subject: [PATCH 1/7] fix: make subtitle timing directly adjustable --- CHANGELOG.md | 1 + frontend/src/components/DubSegmentRow.jsx | 77 +++++++++++++------ frontend/src/components/DubSegmentTable.jsx | 10 ++- frontend/src/index.css | 39 +++++++++- .../src/test/DubSegmentRowTiming.test.jsx | 20 +++++ 5 files changed, 119 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a580595..9475c039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- Subtitle rows now provide 100 ms timing steppers and flag adjacent overlaps without requiring precise timeline dragging (#1710) - Repair-sync failures now retain uv's final dependency error instead of reporting only an opaque exit status (#1705) - YouTube ingest now retries yt-dlp's transient “page needs to be reloaded” response (#1706) - Dictation model readiness now follows the live Hugging Face cache selected in Settings (#1707) diff --git a/frontend/src/components/DubSegmentRow.jsx b/frontend/src/components/DubSegmentRow.jsx index 02f71019..17a38002 100644 --- a/frontend/src/components/DubSegmentRow.jsx +++ b/frontend/src/components/DubSegmentRow.jsx @@ -10,6 +10,7 @@ import { Scissors, Merge, MoreHorizontal, + Minus, Plus, Sparkles, } from 'lucide-react'; @@ -85,6 +86,7 @@ function DubSegmentRow({ onDirect, onSeek, timelineSelected, + hasOverlap, }) { const { t } = useTranslation(); const textInputRef = useRef(null); @@ -204,6 +206,21 @@ function DubSegmentRow({ }); }; + const nudgeTime = (edge, delta) => { + const current = seg[edge]; + const limit = edge === 'start' ? seg.end - 0.001 : seg.start + 0.001; + const next = +( + edge === 'start' + ? Math.max(0, Math.min(limit, current + delta)) + : Math.max(limit, current + delta) + ).toFixed(3); + if (Math.abs(next - current) <= 1e-3) return; + onMoveResize(seg.id, { + start: edge === 'start' ? next : seg.start, + end: edge === 'end' ? next : seg.end, + }); + }; + const handleTextKeyDown = (e) => { if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D')) { e.preventDefault(); @@ -247,30 +264,39 @@ function DubSegmentRow({ title={t('segment.select_title')} /> + {['start', 'end'].map((edge) => ( + + + e.stopPropagation()} + onKeyDown={timeKeyDown(edge)} + onBlur={commitTime(edge)} + /> + + + ))} - e.stopPropagation()} - onKeyDown={timeKeyDown('start')} - onBlur={commitTime('start')} - /> - - e.stopPropagation()} - onKeyDown={timeKeyDown('end')} - onBlur={commitTime('end')} - /> {seg.speed && seg.speed !== 1.0 && ( )} + {hasOverlap && ( + + {t('timeline.overlap_warning')} + + )} {fitBadge && ( Number(seg.start) + 0.001) || + (next && Number(seg.end) > Number(next.start) + 0.001); return ( :nth-child(3), diff --git a/frontend/src/test/DubSegmentRowTiming.test.jsx b/frontend/src/test/DubSegmentRowTiming.test.jsx index 828834f0..3ec44e08 100644 --- a/frontend/src/test/DubSegmentRowTiming.test.jsx +++ b/frontend/src/test/DubSegmentRowTiming.test.jsx @@ -83,6 +83,26 @@ describe('DubSegmentRow timing fields', () => { expect(props.onEditField).not.toHaveBeenCalled(); }); + it('nudges either edge by 100 ms with accessible controls', () => { + const props = makeProps(); + render(); + + const decrement = screen.getAllByRole('button', { name: /−0\.1s/ }); + const increment = screen.getAllByRole('button', { name: /\+0\.1s/ }); + fireEvent.click(decrement[0]); + fireEvent.click(increment[1]); + + expect(props.onMoveResize).toHaveBeenNthCalledWith(1, 's1', { start: 0.9, end: 3 }); + expect(props.onMoveResize).toHaveBeenNthCalledWith(2, 's1', { start: 1, end: 3.1 }); + }); + + it('surfaces an adjacent overlap beside the timing controls', () => { + render(); + expect( + screen.getByText('Overlaps an adjacent segment — both lines will play together'), + ).toBeInTheDocument(); + }); + it('accepts raw seconds as well as m:ss.s', () => { const props = makeProps(); render(); From 0a20aeb0c967e408cabe28f2d194e86d0090522f Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:18:36 +0530 Subject: [PATCH 2/7] fix: reap backend when desktop owner exits --- CHANGELOG.md | 1 + backend/core/parent_liveness.py | 33 ++++++++++++++ backend/main.py | 6 +++ frontend/src-tauri/src/backend.rs | 7 ++- tests/backend/core/test_parent_liveness.py | 52 ++++++++++++++++++++++ 5 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 backend/core/parent_liveness.py create mode 100644 tests/backend/core/test_parent_liveness.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a580595..21ddd412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ the frozen-backend fallback mirror it for their toolchains. - Repair-sync failures now retain uv's final dependency error instead of reporting only an opaque exit status (#1705) - YouTube ingest now retries yt-dlp's transient “page needs to be reloaded” response (#1706) - Dictation model readiness now follows the live Hugging Face cache selected in Settings (#1707) +- Desktop-contained backends now exit when their owning app disappears instead of surviving as stale port-3900 processes (#1707) ## [0.5.1] — 2026-08-28 diff --git a/backend/core/parent_liveness.py b/backend/core/parent_liveness.py new file mode 100644 index 00000000..d9e82c4e --- /dev/null +++ b/backend/core/parent_liveness.py @@ -0,0 +1,33 @@ +"""Terminate a desktop-contained backend when its owning shell disappears.""" +from __future__ import annotations + +import os +import sys +import threading +from typing import BinaryIO, Callable + + +def _watch_parent_pipe(reader: BinaryIO, exit_process: Callable[[int], None]) -> None: + """Block until the desktop-owned stdin pipe closes, then exit immediately.""" + try: + while reader.read(1): + pass + except (OSError, ValueError): + pass + exit_process(0) + + +def arm_desktop_parent_watchdog() -> bool: + """Use stdin EOF as an unforgeable parent-liveness signal for desktop runs.""" + if os.environ.get("OMNIVOICE_DESKTOP_CONTAINED") != "1": + return False + reader = getattr(sys.stdin, "buffer", None) + if reader is None: + return False + threading.Thread( + target=_watch_parent_pipe, + args=(reader, os._exit), + name="desktop-parent-watchdog", + daemon=True, + ).start() + return True diff --git a/backend/main.py b/backend/main.py index 8f582944..ade1cec3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -77,6 +77,7 @@ os.environ.setdefault("FOR_DISABLE_CONSOLE_CTRL_HANDLER", "1") # (utils.hf_progress.SafeFileWrapper — same wrapper the patched hub tqdm # already uses for its own fp.) from utils.hf_progress import SafeFileWrapper as _SafeStdio # noqa: E402 +from core.parent_liveness import arm_desktop_parent_watchdog # noqa: E402 # Force UTF-8 stdio before wrapping (#1155): on Windows the spawned backend's # stdout defaults to cp1252, and any library that prints user text (kittentts @@ -89,6 +90,11 @@ for _stream in (sys.stdout, sys.stderr): except Exception: # noqa: BLE001 — pythonw/frozen builds may lack reconfigure pass +# The desktop keeps the backend's stdin pipe open for its own lifetime. EOF is +# therefore a stable ownership signal that survives PID reuse and lets a child +# terminate even when the shell crashes before its normal process-tree teardown. +arm_desktop_parent_watchdog() + if not getattr(sys.stdout, "_is_safe_wrapper", False): sys.stdout = _SafeStdio(sys.stdout) if not getattr(sys.stderr, "_is_safe_wrapper", False): diff --git a/frontend/src-tauri/src/backend.rs b/frontend/src-tauri/src/backend.rs index f2b07b98..1756c21b 100644 --- a/frontend/src-tauri/src/backend.rs +++ b/frontend/src-tauri/src/backend.rs @@ -652,7 +652,12 @@ pub(crate) fn spawn_backend( ]); } } - cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + // Keep stdin piped but unwritten. The backend's parent-liveness watchdog + // blocks on it; desktop exit closes the handle and the child terminates, + // including on macOS where parent death alone does not reap descendants. + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); let mut contained = match crate::tools::spawn_process_tree(&mut cmd) { Ok(c) => { log::info!( diff --git a/tests/backend/core/test_parent_liveness.py b/tests/backend/core/test_parent_liveness.py new file mode 100644 index 00000000..220cd9ef --- /dev/null +++ b/tests/backend/core/test_parent_liveness.py @@ -0,0 +1,52 @@ +import io +import os +import subprocess +import sys +from pathlib import Path + +from core.parent_liveness import _watch_parent_pipe, arm_desktop_parent_watchdog + + +def test_parent_pipe_eof_exits_cleanly(): + exits = [] + _watch_parent_pipe(io.BytesIO(b""), exits.append) + assert exits == [0] + + +def test_parent_pipe_ignores_bytes_until_eof(): + exits = [] + _watch_parent_pipe(io.BytesIO(b"keepalive"), exits.append) + assert exits == [0] + + +def test_watchdog_is_disabled_outside_desktop(monkeypatch): + monkeypatch.delenv("OMNIVOICE_DESKTOP_CONTAINED", raising=False) + assert arm_desktop_parent_watchdog() is False + + +def test_desktop_child_exits_when_parent_closes_stdin(): + env = os.environ.copy() + env["OMNIVOICE_DESKTOP_CONTAINED"] = "1" + child = subprocess.Popen( + [ + sys.executable, + "-c", + "from core.parent_liveness import arm_desktop_parent_watchdog; " + "arm_desktop_parent_watchdog(); print('ready', flush=True); " + "__import__('time').sleep(30)", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + cwd=Path(__file__).parents[3] / "backend", + text=True, + ) + try: + assert child.stdout.readline().strip() == "ready" + child.stdin.close() + assert child.wait(timeout=3) == 0 + finally: + if child.poll() is None: + child.kill() + child.wait() From d4f89ecdc7566ee83e1aa0212e2d6f5027b3702b Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:28:18 +0530 Subject: [PATCH 3/7] fix: clamp and localize subtitle steppers --- frontend/src/components/DubSegmentRow.jsx | 10 ++++++---- frontend/src/i18n/locales/ar.json | 4 ++++ frontend/src/i18n/locales/de.json | 4 ++++ frontend/src/i18n/locales/en.json | 4 ++++ frontend/src/i18n/locales/es.json | 4 ++++ frontend/src/i18n/locales/fr.json | 4 ++++ frontend/src/i18n/locales/hi.json | 4 ++++ frontend/src/i18n/locales/id.json | 4 ++++ frontend/src/i18n/locales/it.json | 4 ++++ frontend/src/i18n/locales/ja.json | 4 ++++ frontend/src/i18n/locales/ko.json | 4 ++++ frontend/src/i18n/locales/nl.json | 4 ++++ frontend/src/i18n/locales/pl.json | 4 ++++ frontend/src/i18n/locales/pt.json | 4 ++++ frontend/src/i18n/locales/ru.json | 4 ++++ frontend/src/i18n/locales/sv.json | 4 ++++ frontend/src/i18n/locales/th.json | 4 ++++ frontend/src/i18n/locales/tr.json | 4 ++++ frontend/src/i18n/locales/uk.json | 4 ++++ frontend/src/i18n/locales/vi.json | 4 ++++ frontend/src/i18n/locales/zh-CN.json | 4 ++++ frontend/src/i18n/locales/zh-TW.json | 4 ++++ .../src/test/DubSegmentRowTiming.test.jsx | 19 +++++++++++++++++-- 23 files changed, 107 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/DubSegmentRow.jsx b/frontend/src/components/DubSegmentRow.jsx index 17a38002..113e2632 100644 --- a/frontend/src/components/DubSegmentRow.jsx +++ b/frontend/src/components/DubSegmentRow.jsx @@ -16,6 +16,7 @@ import { } from 'lucide-react'; import { formatTime } from '../utils/format'; import { LANG_CODES } from '../utils/languages'; +import { MIN_SEG_DUR } from '../utils/timeline'; import { Menu, Button, Badge } from '../ui'; import VoiceSelector from './VoiceSelector'; @@ -190,7 +191,8 @@ function DubSegmentRow({ const commitTime = (edge) => (e) => { const v = parseTime(e.target.value); const current = seg[edge]; - const inRange = edge === 'start' ? v >= 0 && v < seg.end : v > seg.start; + const inRange = + edge === 'start' ? v >= 0 && v <= seg.end - MIN_SEG_DUR : v >= seg.start + MIN_SEG_DUR; if (v == null || !inRange) { e.target.value = formatTime(current); return; @@ -208,7 +210,7 @@ function DubSegmentRow({ const nudgeTime = (edge, delta) => { const current = seg[edge]; - const limit = edge === 'start' ? seg.end - 0.001 : seg.start + 0.001; + const limit = edge === 'start' ? seg.end - MIN_SEG_DUR : seg.start + MIN_SEG_DUR; const next = +( edge === 'start' ? Math.max(0, Math.min(limit, current + delta)) @@ -270,7 +272,7 @@ function DubSegmentRow({ type="button" onClick={() => nudgeTime(edge, -0.1)} disabled={disabled || (edge === 'start' && seg.start <= 0)} - aria-label={`−0.1s ${t(edge === 'start' ? 'segment.time_edit_title' : 'segment.time_edit_end_title')}`} + aria-label={t(`segment.time_nudge_${edge}_earlier`)} > @@ -290,7 +292,7 @@ function DubSegmentRow({ type="button" onClick={() => nudgeTime(edge, 0.1)} disabled={disabled} - aria-label={`+0.1s ${t(edge === 'start' ? 'segment.time_edit_title' : 'segment.time_edit_end_title')}`} + aria-label={t(`segment.time_nudge_${edge}_later`)} > diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index 5b9d5409..6b30a9b2 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "مكبر الصوت - اكتب اسمًا (لم يتم اكتشاف أي نسخ للكتابة)", "time_edit_title": "انقر لتعديل وقت البدء (m:ss.s). أدخل للالتزام، Esc للإلغاء.", "time_edit_end_title": "انقر لتعديل وقت الانتهاء (m:ss.s). أدخل للالتزام، Esc للإلغاء.", + "time_nudge_start_earlier": "حرّك وقت البدء 0.1 ثانية إلى وقت أبكر", + "time_nudge_start_later": "حرّك وقت البدء 0.1 ثانية إلى وقت لاحق", + "time_nudge_end_earlier": "حرّك وقت الانتهاء 0.1 ثانية إلى وقت أبكر", + "time_nudge_end_later": "حرّك وقت الانتهاء 0.1 ثانية إلى وقت لاحق", "fit_fits": "يناسب", "fit_fits_title": "يتناسب الصوت ذو المعدل الطبيعي داخل الفتحة.", "fit_overflows": "الفائض +{{seconds}}s", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index f1c8a203..691cc88a 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "Sprecher – Geben Sie einen Namen ein (keine Diarisierungsklone erkannt)", "time_edit_title": "Klicken Sie hier, um die Startzeit (m:ss.s) zu bearbeiten. Geben Sie zum Festschreiben die Eingabetaste ein, zum Abbrechen die Esc-Taste.", "time_edit_end_title": "Klicken Sie hier, um die Endzeit (m:ss.s) zu bearbeiten. Geben Sie zum Festschreiben die Eingabetaste ein, zum Abbrechen die Esc-Taste.", + "time_nudge_start_earlier": "Startzeit um 0,1 Sekunden vorverlegen", + "time_nudge_start_later": "Startzeit um 0,1 Sekunden nach hinten verschieben", + "time_nudge_end_earlier": "Endzeit um 0,1 Sekunden vorverlegen", + "time_nudge_end_later": "Endzeit um 0,1 Sekunden nach hinten verschieben", "fit_fits": "Passt", "fit_fits_title": "Audio mit natürlicher Geschwindigkeit passt in den Steckplatz.", "fit_overflows": "Überläufe +{{seconds}}s", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5497f574..c2671ed7 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1466,6 +1466,10 @@ "speaker_title_custom": "Speaker — type a name (no diarization clones detected)", "time_edit_title": "Click to edit start time (m:ss.s). Enter to commit, Esc to cancel.", "time_edit_end_title": "Click to edit end time (m:ss.s). Enter to commit, Esc to cancel.", + "time_nudge_start_earlier": "Move start time 0.1 seconds earlier", + "time_nudge_start_later": "Move start time 0.1 seconds later", + "time_nudge_end_earlier": "Move end time 0.1 seconds earlier", + "time_nudge_end_later": "Move end time 0.1 seconds later", "qc_verify": "Verify", "qc_verify_title": "Second-pass ASR heard: \"{{heard}}\" — re-listen or re-dub this line.", "fit_fits": "Fits", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 53b9181c..60a4b095 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "Orador: escriba un nombre (no se detectaron clones de diarización)", "time_edit_title": "Haga clic para editar la hora de inicio (m:ss.s). Ingrese para confirmar, Esc para cancelar.", "time_edit_end_title": "Haga clic para editar la hora de finalización (m:ss.s). Ingrese para confirmar, Esc para cancelar.", + "time_nudge_start_earlier": "Adelantar la hora de inicio 0,1 segundos", + "time_nudge_start_later": "Retrasar la hora de inicio 0,1 segundos", + "time_nudge_end_earlier": "Adelantar la hora de fin 0,1 segundos", + "time_nudge_end_later": "Retrasar la hora de fin 0,1 segundos", "fit_fits": "Se adapta", "fit_fits_title": "El audio de velocidad natural cabe dentro de la ranura.", "fit_overflows": "Se desborda +{{seconds}}s", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 7071d954..c68a977e 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "Haut-parleur : saisissez un nom (aucun clone de diarisation détecté)", "time_edit_title": "Cliquez pour modifier l'heure de début (m:ss.s). Entrez pour valider, Esc pour annuler.", "time_edit_end_title": "Cliquez pour modifier l'heure de fin (m:ss.s). Entrez pour valider, Esc pour annuler.", + "time_nudge_start_earlier": "Avancer le début de 0,1 seconde", + "time_nudge_start_later": "Reculer le début de 0,1 seconde", + "time_nudge_end_earlier": "Avancer la fin de 0,1 seconde", + "time_nudge_end_later": "Reculer la fin de 0,1 seconde", "fit_fits": "Convient", "fit_fits_title": "L'audio à débit naturel s'adapte à l'intérieur de la fente.", "fit_overflows": "Débordements +{{seconds}}s", diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 60b22e96..e31a249c 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "स्पीकर - एक नाम टाइप करें (कोई डायराइज़ेशन क्लोन नहीं पाया गया)", "time_edit_title": "प्रारंभ समय (m:ss.s) संपादित करने के लिए क्लिक करें। प्रतिबद्ध करने के लिए दर्ज करें, रद्द करने के लिए Esc।", "time_edit_end_title": "समाप्ति समय (m:ss.s) संपादित करने के लिए क्लिक करें। प्रतिबद्ध करने के लिए दर्ज करें, रद्द करने के लिए Esc।", + "time_nudge_start_earlier": "आरंभ समय को 0.1 सेकंड पहले करें", + "time_nudge_start_later": "आरंभ समय को 0.1 सेकंड बाद करें", + "time_nudge_end_earlier": "समाप्ति समय को 0.1 सेकंड पहले करें", + "time_nudge_end_later": "समाप्ति समय को 0.1 सेकंड बाद करें", "fit_fits": "फिट बैठता है", "fit_fits_title": "प्राकृतिक दर वाला ऑडियो स्लॉट के अंदर फ़िट हो जाता है।", "fit_overflows": "अतिप्रवाह +{{seconds}}s", diff --git a/frontend/src/i18n/locales/id.json b/frontend/src/i18n/locales/id.json index 02d4ce1a..13c01da5 100644 --- a/frontend/src/i18n/locales/id.json +++ b/frontend/src/i18n/locales/id.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "Pembicara — ketikkan nama (tidak ada klon diarisasi yang terdeteksi)", "time_edit_title": "Klik untuk mengedit waktu mulai (m:ss.s). Enter untuk melakukan, Esc untuk membatalkan.", "time_edit_end_title": "Klik untuk mengedit waktu selesai (m:ss.s). Enter untuk melakukan, Esc untuk membatalkan.", + "time_nudge_start_earlier": "Majukan waktu mulai 0,1 detik", + "time_nudge_start_later": "Mundurkan waktu mulai 0,1 detik", + "time_nudge_end_earlier": "Majukan waktu selesai 0,1 detik", + "time_nudge_end_later": "Mundurkan waktu selesai 0,1 detik", "fit_fits": "Cocok", "fit_fits_title": "Audio dengan kecepatan alami pas di dalam slot.", "fit_overflows": "Meluap +{{seconds}}s", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index b85128c0..08f7ba05 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "Altoparlante: digita un nome (nessun clone di diarizzazione rilevato)", "time_edit_title": "Fare clic per modificare l'ora di inizio (m:ss.s). Invio per confermare, Esc per annullare.", "time_edit_end_title": "Fare clic per modificare l'ora di fine (m:ss.s). Invio per confermare, Esc per annullare.", + "time_nudge_start_earlier": "Anticipa l’inizio di 0,1 secondi", + "time_nudge_start_later": "Posticipa l’inizio di 0,1 secondi", + "time_nudge_end_earlier": "Anticipa la fine di 0,1 secondi", + "time_nudge_end_later": "Posticipa la fine di 0,1 secondi", "fit_fits": "Adatto", "fit_fits_title": "L'audio a velocità naturale si adatta all'interno dello slot.", "fit_overflows": "Overflow +{{seconds}}s", diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 36b50062..e3ff43bc 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "スピーカー — 名前を入力します (ダイアライゼーション クローンは検出されません)。", "time_edit_title": "クリックして開始時刻 (分:ss.s) を編集します。 Enter を押してコミットし、Esc を押してキャンセルします。", "time_edit_end_title": "クリックして終了時刻 (分:ss.s) を編集します。 Enter を押してコミットし、Esc を押してキャンセルします。", + "time_nudge_start_earlier": "開始時刻を0.1秒早める", + "time_nudge_start_later": "開始時刻を0.1秒遅らせる", + "time_nudge_end_earlier": "終了時刻を0.1秒早める", + "time_nudge_end_later": "終了時刻を0.1秒遅らせる", "fit_fits": "適合", "fit_fits_title": "ナチュラルレートのオーディオがスロット内に収まります。", "fit_overflows": "オーバーフロー +{{seconds}}s", diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 48c8bd37..b991ec7d 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "발표자 - 이름을 입력하세요(분할 클론이 감지되지 않음)", "time_edit_title": "시작 시간(m:ss.s)을 편집하려면 클릭하세요. 커밋하려면 Enter를, 취소하려면 Esc를 누르세요.", "time_edit_end_title": "종료 시간(m:ss.s)을 편집하려면 클릭하세요. 커밋하려면 Enter를, 취소하려면 Esc를 누르세요.", + "time_nudge_start_earlier": "시작 시간을 0.1초 앞당기기", + "time_nudge_start_later": "시작 시간을 0.1초 늦추기", + "time_nudge_end_earlier": "종료 시간을 0.1초 앞당기기", + "time_nudge_end_later": "종료 시간을 0.1초 늦추기", "fit_fits": "적합", "fit_fits_title": "자연스러운 속도의 오디오가 슬롯에 맞습니다.", "fit_overflows": "오버플로 +{{seconds}}s", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 82a131e3..c43ea39c 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "Spreker — typ een naam (geen dagboekklonen gedetecteerd)", "time_edit_title": "Klik om de starttijd te bewerken (m:ss.s). Enter om vast te leggen, Esc om te annuleren.", "time_edit_end_title": "Klik om de eindtijd te bewerken (m:ss.s). Enter om vast te leggen, Esc om te annuleren.", + "time_nudge_start_earlier": "Starttijd 0,1 seconde vervroegen", + "time_nudge_start_later": "Starttijd 0,1 seconde verlaten", + "time_nudge_end_earlier": "Eindtijd 0,1 seconde vervroegen", + "time_nudge_end_later": "Eindtijd 0,1 seconde verlaten", "fit_fits": "Past", "fit_fits_title": "Audio met natuurlijke snelheid past in de sleuf.", "fit_overflows": "Overstromen +{{seconds}}s", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 34a78ecf..f8a8c16a 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "Głośnik — wpisz nazwę (nie wykryto klonów diaryzacji)", "time_edit_title": "Kliknij, aby edytować czas rozpoczęcia (m:ss.s). Enter, aby zatwierdzić, Esc, aby anulować.", "time_edit_end_title": "Kliknij, aby edytować czas zakończenia (m:ss.s). Enter, aby zatwierdzić, Esc, aby anulować.", + "time_nudge_start_earlier": "Przesuń czas rozpoczęcia o 0,1 s wcześniej", + "time_nudge_start_later": "Przesuń czas rozpoczęcia o 0,1 s później", + "time_nudge_end_earlier": "Przesuń czas zakończenia o 0,1 s wcześniej", + "time_nudge_end_later": "Przesuń czas zakończenia o 0,1 s później", "fit_fits": "Pasuje", "fit_fits_title": "Naturalny dźwięk mieści się w gnieździe.", "fit_overflows": "Przepełnienia +{{seconds}}s", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index b4a1c08b..194829c8 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "Palestrante — digite um nome (nenhum clone de diarização detectado)", "time_edit_title": "Clique para editar a hora de início (m:ss.s). Enter para confirmar, Esc para cancelar.", "time_edit_end_title": "Clique para editar a hora de término (m:ss.s). Enter para confirmar, Esc para cancelar.", + "time_nudge_start_earlier": "Adiantar o início em 0,1 segundo", + "time_nudge_start_later": "Atrasar o início em 0,1 segundo", + "time_nudge_end_earlier": "Adiantar o fim em 0,1 segundo", + "time_nudge_end_later": "Atrasar o fim em 0,1 segundo", "fit_fits": "Serve", "fit_fits_title": "Áudio de taxa natural cabe dentro do slot.", "fit_overflows": "Estouro +{{seconds}}s", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 2e8d97ba..2d2a8140 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "Спикер — введите имя (клоны диаризизации не обнаружены)", "time_edit_title": "Нажмите, чтобы изменить время начала (м:сс.с). Enter для фиксации, Esc для отмены.", "time_edit_end_title": "Нажмите, чтобы изменить время окончания (м:сс.с). Enter для фиксации, Esc для отмены.", + "time_nudge_start_earlier": "Сдвинуть начало на 0,1 секунды раньше", + "time_nudge_start_later": "Сдвинуть начало на 0,1 секунды позже", + "time_nudge_end_earlier": "Сдвинуть окончание на 0,1 секунды раньше", + "time_nudge_end_later": "Сдвинуть окончание на 0,1 секунды позже", "fit_fits": "Подходит", "fit_fits_title": "Звук естественной скорости помещается в слот.", "fit_overflows": "Переполнение +{{seconds}}с", diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 7cff7f1d..402c199e 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "Högtalare – skriv ett namn (inga diariseringskloner upptäcktes)", "time_edit_title": "Klicka för att redigera starttid (m:ss.s). Enter för att begå, Esc för att avbryta.", "time_edit_end_title": "Klicka för att redigera sluttid (m:ss.s). Enter för att begå, Esc för att avbryta.", + "time_nudge_start_earlier": "Flytta starttiden 0,1 sekunder tidigare", + "time_nudge_start_later": "Flytta starttiden 0,1 sekunder senare", + "time_nudge_end_earlier": "Flytta sluttiden 0,1 sekunder tidigare", + "time_nudge_end_later": "Flytta sluttiden 0,1 sekunder senare", "fit_fits": "Passar", "fit_fits_title": "Naturligt ljud passar in i kortplatsen.", "fit_overflows": "Bräddar +{{seconds}}s", diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json index ea1691e8..4a0cc9ea 100644 --- a/frontend/src/i18n/locales/th.json +++ b/frontend/src/i18n/locales/th.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "ผู้พูด — พิมพ์ชื่อ (ตรวจไม่พบโคลนไดอะไรเซชัน)", "time_edit_title": "คลิกเพื่อแก้ไขเวลาเริ่มต้น (m:ss.s) เข้าสู่เพื่อกระทำ Esc เพื่อยกเลิก", "time_edit_end_title": "คลิกเพื่อแก้ไขเวลาสิ้นสุด (m:ss.s) เข้าสู่เพื่อกระทำ Esc เพื่อยกเลิก", + "time_nudge_start_earlier": "เลื่อนเวลาเริ่มเร็วขึ้น 0.1 วินาที", + "time_nudge_start_later": "เลื่อนเวลาเริ่มช้าลง 0.1 วินาที", + "time_nudge_end_earlier": "เลื่อนเวลาสิ้นสุดเร็วขึ้น 0.1 วินาที", + "time_nudge_end_later": "เลื่อนเวลาสิ้นสุดช้าลง 0.1 วินาที", "fit_fits": "พอดี", "fit_fits_title": "เสียงที่มีอัตราธรรมชาติพอดีกับช่อง", "fit_overflows": "โอเวอร์โฟลว์ +{{seconds}}s", diff --git a/frontend/src/i18n/locales/tr.json b/frontend/src/i18n/locales/tr.json index be88ce0c..d16c8989 100644 --- a/frontend/src/i18n/locales/tr.json +++ b/frontend/src/i18n/locales/tr.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "Konuşmacı — bir ad yazın (günlük oluşturma klonu algılanmadı)", "time_edit_title": "Başlangıç saatini (m:ss.s) düzenlemek için tıklayın. Taahhüt etmek için Enter, iptal etmek için Esc.", "time_edit_end_title": "Bitiş saatini (m:ss.s) düzenlemek için tıklayın. Taahhüt etmek için Enter, iptal etmek için Esc.", + "time_nudge_start_earlier": "Başlangıç zamanını 0,1 saniye erkene al", + "time_nudge_start_later": "Başlangıç zamanını 0,1 saniye ileri al", + "time_nudge_end_earlier": "Bitiş zamanını 0,1 saniye erkene al", + "time_nudge_end_later": "Bitiş zamanını 0,1 saniye ileri al", "fit_fits": "uyar", "fit_fits_title": "Doğal oranlı ses yuvanın içine sığar.", "fit_overflows": "Taşmalar +{{seconds}}s", diff --git a/frontend/src/i18n/locales/uk.json b/frontend/src/i18n/locales/uk.json index fd76eebf..fa12041c 100644 --- a/frontend/src/i18n/locales/uk.json +++ b/frontend/src/i18n/locales/uk.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "Доповідач — введіть ім’я (клонів діаризації не виявлено)", "time_edit_title": "Клацніть, щоб змінити час початку (хв:сс.с). Enter, щоб прийняти, Esc, щоб скасувати.", "time_edit_end_title": "Клацніть, щоб змінити час завершення (хв:сс.с). Enter, щоб прийняти, Esc, щоб скасувати.", + "time_nudge_start_earlier": "Пересунути початок на 0,1 секунди раніше", + "time_nudge_start_later": "Пересунути початок на 0,1 секунди пізніше", + "time_nudge_end_earlier": "Пересунути завершення на 0,1 секунди раніше", + "time_nudge_end_later": "Пересунути завершення на 0,1 секунди пізніше", "fit_fits": "Підходить", "fit_fits_title": "Аудіо з природною швидкістю поміщається в слот.", "fit_overflows": "Переповнення +{{seconds}}s", diff --git a/frontend/src/i18n/locales/vi.json b/frontend/src/i18n/locales/vi.json index 8d4e1fc0..a0376dfa 100644 --- a/frontend/src/i18n/locales/vi.json +++ b/frontend/src/i18n/locales/vi.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "Người nói - nhập tên (không phát hiện thấy bản sao nhật ký)", "time_edit_title": "Bấm để chỉnh sửa thời gian bắt đầu (m:ss.s). Enter để cam kết, Esc để hủy.", "time_edit_end_title": "Bấm để chỉnh sửa thời gian kết thúc (m:ss.s). Enter để cam kết, Esc để hủy.", + "time_nudge_start_earlier": "Đưa thời gian bắt đầu sớm hơn 0,1 giây", + "time_nudge_start_later": "Đưa thời gian bắt đầu muộn hơn 0,1 giây", + "time_nudge_end_earlier": "Đưa thời gian kết thúc sớm hơn 0,1 giây", + "time_nudge_end_later": "Đưa thời gian kết thúc muộn hơn 0,1 giây", "fit_fits": "Phù hợp", "fit_fits_title": "Âm thanh tốc độ tự nhiên vừa vặn bên trong khe cắm.", "fit_overflows": "Tràn +{{seconds}}s", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 447ef99a..79f64acd 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1153,6 +1153,10 @@ "speaker_title_custom": "说话人 — 输入名称(未检测到分离克隆)", "time_edit_title": "单击可编辑开始时间 (m:ss.s)。 Enter 提交,Esc 取消。", "time_edit_end_title": "单击可编辑结束时间 (m:ss.s)。 Enter 提交,Esc 取消。", + "time_nudge_start_earlier": "将开始时间提前 0.1 秒", + "time_nudge_start_later": "将开始时间延后 0.1 秒", + "time_nudge_end_earlier": "将结束时间提前 0.1 秒", + "time_nudge_end_later": "将结束时间延后 0.1 秒", "fit_fits": "适合", "fit_fits_title": "自然速率音频适合插槽内。", "fit_overflows": "溢出 +{{seconds}}s", diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 42bd5cfa..8cbf61ea 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -1194,6 +1194,10 @@ "speaker_title_custom": "揚聲器 — 輸入名稱(未偵測到二值化複製)", "time_edit_title": "按一下可編輯開始時間 (m:ss.s)。 Enter 提交,Esc 取消。", "time_edit_end_title": "按一下可編輯結束時間 (m:ss.s)。 Enter 提交,Esc 取消。", + "time_nudge_start_earlier": "將開始時間提前 0.1 秒", + "time_nudge_start_later": "將開始時間延後 0.1 秒", + "time_nudge_end_earlier": "將結束時間提前 0.1 秒", + "time_nudge_end_later": "將結束時間延後 0.1 秒", "fit_fits": "適合", "fit_fits_title": "自然速率音訊適合插槽內。", "fit_overflows": "溢出 +{{seconds}}s", diff --git a/frontend/src/test/DubSegmentRowTiming.test.jsx b/frontend/src/test/DubSegmentRowTiming.test.jsx index 3ec44e08..80fb70d2 100644 --- a/frontend/src/test/DubSegmentRowTiming.test.jsx +++ b/frontend/src/test/DubSegmentRowTiming.test.jsx @@ -87,8 +87,8 @@ describe('DubSegmentRow timing fields', () => { const props = makeProps(); render(); - const decrement = screen.getAllByRole('button', { name: /−0\.1s/ }); - const increment = screen.getAllByRole('button', { name: /\+0\.1s/ }); + const decrement = screen.getAllByRole('button', { name: /0\.1 seconds earlier/ }); + const increment = screen.getAllByRole('button', { name: /0\.1 seconds later/ }); fireEvent.click(decrement[0]); fireEvent.click(increment[1]); @@ -96,6 +96,21 @@ describe('DubSegmentRow timing fields', () => { expect(props.onMoveResize).toHaveBeenNthCalledWith(2, 's1', { start: 1, end: 3.1 }); }); + it('preserves the timeline minimum duration for typed and stepped edits', () => { + const props = makeProps(); + render(); + const start = timeFields()[0]; + + fireEvent.change(start, { target: { value: '2.8' } }); + fireEvent.blur(start); + expect(props.onMoveResize).not.toHaveBeenCalled(); + + const nearLimit = makeProps({ seg: { id: 's1', start: 2.7, end: 3, text: 'x' } }); + render(); + fireEvent.click(screen.getAllByRole('button', { name: /start time 0\.1 seconds later/ })[1]); + expect(nearLimit.onMoveResize).not.toHaveBeenCalled(); + }); + it('surfaces an adjacent overlap beside the timing controls', () => { render(); expect( From e9c2c451e7c7745ccf1f33f964d7ca6ea1ce2a2a Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:28:53 +0530 Subject: [PATCH 4/7] test: resolve parent watchdog at execution time --- tests/backend/core/test_parent_liveness.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/backend/core/test_parent_liveness.py b/tests/backend/core/test_parent_liveness.py index 220cd9ef..f33eeb2c 100644 --- a/tests/backend/core/test_parent_liveness.py +++ b/tests/backend/core/test_parent_liveness.py @@ -4,22 +4,25 @@ import subprocess import sys from pathlib import Path -from core.parent_liveness import _watch_parent_pipe, arm_desktop_parent_watchdog - - def test_parent_pipe_eof_exits_cleanly(): + from core.parent_liveness import _watch_parent_pipe + exits = [] _watch_parent_pipe(io.BytesIO(b""), exits.append) assert exits == [0] def test_parent_pipe_ignores_bytes_until_eof(): + from core.parent_liveness import _watch_parent_pipe + exits = [] _watch_parent_pipe(io.BytesIO(b"keepalive"), exits.append) assert exits == [0] def test_watchdog_is_disabled_outside_desktop(monkeypatch): + from core.parent_liveness import arm_desktop_parent_watchdog + monkeypatch.delenv("OMNIVOICE_DESKTOP_CONTAINED", raising=False) assert arm_desktop_parent_watchdog() is False From 76588657358ba1d4a8b5ff5f30a3f9ed56e27ece Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:52:56 +0530 Subject: [PATCH 5/7] fix(i18n): clarify Dutch timing labels --- frontend/src/i18n/locales/nl.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index c43ea39c..c182d89b 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -1195,9 +1195,9 @@ "time_edit_title": "Klik om de starttijd te bewerken (m:ss.s). Enter om vast te leggen, Esc om te annuleren.", "time_edit_end_title": "Klik om de eindtijd te bewerken (m:ss.s). Enter om vast te leggen, Esc om te annuleren.", "time_nudge_start_earlier": "Starttijd 0,1 seconde vervroegen", - "time_nudge_start_later": "Starttijd 0,1 seconde verlaten", + "time_nudge_start_later": "Starttijd 0,1 seconde uitstellen", "time_nudge_end_earlier": "Eindtijd 0,1 seconde vervroegen", - "time_nudge_end_later": "Eindtijd 0,1 seconde verlaten", + "time_nudge_end_later": "Eindtijd 0,1 seconde uitstellen", "fit_fits": "Past", "fit_fits_title": "Audio met natuurlijke snelheid past in de sleuf.", "fit_overflows": "Overstromen +{{seconds}}s", From e35c6ad987a465838ae4edb62dd1a159a70fad72 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:53:15 +0530 Subject: [PATCH 6/7] fix(backend): document parent pipe closure --- backend/core/parent_liveness.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/core/parent_liveness.py b/backend/core/parent_liveness.py index d9e82c4e..e6707752 100644 --- a/backend/core/parent_liveness.py +++ b/backend/core/parent_liveness.py @@ -13,6 +13,7 @@ def _watch_parent_pipe(reader: BinaryIO, exit_process: Callable[[int], None]) -> while reader.read(1): pass except (OSError, ValueError): + # A broken or already-closed parent-owned pipe is equivalent to EOF. pass exit_process(0) From 7e777a3a0f1d820fe55a92bdc0cdfda389826e45 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:12:28 +0530 Subject: [PATCH 7/7] fix(dubbing): accept exact minimum timing boundary --- frontend/src/components/DubSegmentRow.jsx | 5 ++++- frontend/src/test/DubSegmentRowTiming.test.jsx | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/DubSegmentRow.jsx b/frontend/src/components/DubSegmentRow.jsx index 113e2632..2f4a0c13 100644 --- a/frontend/src/components/DubSegmentRow.jsx +++ b/frontend/src/components/DubSegmentRow.jsx @@ -22,6 +22,7 @@ import VoiceSelector from './VoiceSelector'; const CHAR_BUDGET_RATIO = 1.3; const SENTENCE_END = /[.!?。!?]/; +const TIME_EPSILON = 1e-9; function rowClass(isActive, isDone, selected, isPlaying, timelineSelected) { return `segment-row${isActive ? ' segment-active' : ''}${isDone ? ' segment-done' : ''}${selected ? ' segment-selected' : ''}${isPlaying ? ' segment-playing' : ''}${timelineSelected ? ' segment-timeline-selected' : ''}`; @@ -192,7 +193,9 @@ function DubSegmentRow({ const v = parseTime(e.target.value); const current = seg[edge]; const inRange = - edge === 'start' ? v >= 0 && v <= seg.end - MIN_SEG_DUR : v >= seg.start + MIN_SEG_DUR; + edge === 'start' + ? v >= 0 && v <= seg.end - MIN_SEG_DUR + TIME_EPSILON + : v >= seg.start + MIN_SEG_DUR - TIME_EPSILON; if (v == null || !inRange) { e.target.value = formatTime(current); return; diff --git a/frontend/src/test/DubSegmentRowTiming.test.jsx b/frontend/src/test/DubSegmentRowTiming.test.jsx index 80fb70d2..f1095529 100644 --- a/frontend/src/test/DubSegmentRowTiming.test.jsx +++ b/frontend/src/test/DubSegmentRowTiming.test.jsx @@ -111,6 +111,17 @@ describe('DubSegmentRow timing fields', () => { expect(nearLimit.onMoveResize).not.toHaveBeenCalled(); }); + it('accepts an exact 300 ms boundary despite decimal rounding', () => { + const props = makeProps({ seg: { id: 's1', start: 1, end: 3.3, text: 'x' } }); + render(); + const start = timeFields()[0]; + + fireEvent.change(start, { target: { value: '3.0' } }); + fireEvent.blur(start); + + expect(props.onMoveResize).toHaveBeenCalledWith('s1', { start: 3, end: 3.3 }); + }); + it('surfaces an adjacent overlap beside the timing controls', () => { render(); expect(