fix(capture): pass a null segment end through instead of rounding it

`round(s.get("end", 0), 2)` does not defend against a stored None: the key is
present, so `.get` returns the None rather than the default, and `round` raises

    TypeError: type NoneType doesn't define __round__ method

`max(s.get("end", 0) for s in segments)` on the line above raises first when any
other segment is timed:

    TypeError: '>' not supported between instances of 'NoneType' and 'float'

Two engines reach these builders with end=None. `_sherpa_result` sets
duration=None when it cannot derive one from the sample rate, and sherpa is the
first capture engine. `OpenAICompatASRBackend._adapt_response` emits end=None for
every plain-text response, which is what a server that rejects verbose_json
returns, and that backend is selectable as the active one used by accurate mode.

Measure the duration from the segments that carry a number, and pass the nulls
through. That is the shape the segment list already renders since #1904 — it
shows whichever half of the range is known — and it keeps the honest null the
producers deliberately write instead of inventing a zero.

capture_ws.py has the same two lines and gets the same treatment; it also emits
end=None itself in five of its own streaming payloads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chang-Jin-Lee
2026-09-10 04:33:18 +09:00
co-authored by Claude Opus 5
parent 18f56a7940
commit 2e4465a3f4
4 changed files with 169 additions and 7 deletions
+1
View File
@@ -56,6 +56,7 @@ the frozen-backend fallback mirror it for their toolchains.
- Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang!
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
- Transcribing with an engine that reports no segment end no longer fails with a server error; the null timing is passed through the way the segment list already expects (#1904) — thanks @aeroglu!
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
- Voice reference preparation reclaims allocator memory before one bounded retry, then reports persistent GPU out-of-memory failures (#1811)
+20 -4
View File
@@ -28,6 +28,17 @@ router = APIRouter()
logger = logging.getLogger("omnivoice.capture")
def _timing(value):
"""A segment timing, or ``None`` when the engine could not determine one.
``dict.get(key, 0)`` hands back a stored ``None`` rather than the default,
because the key is present — so rounding it raised and took a transcript
that was otherwise fine down with it (#1904). Pass the null through instead:
the segment list renders whichever half of the range is known.
"""
return round(value, 2) if isinstance(value, (int, float)) else None
def _truthy(value: Optional[str]) -> bool:
"""Parse a multipart form flag. Treats '1'/'true'/'yes'/'on'/'auto'
(any case) as on; everything else — including None — as off."""
@@ -162,10 +173,15 @@ async def transcribe_audio(
from services.text_polish import polish_text
full_text = polish_text(full_text)
# Calculate audio duration from segments if available
# Calculate audio duration from segments if available. A segment whose
# timing the engine could not determine carries end=None (sherpa's
# _sherpa_result when the sample rate yields no duration, and every
# plain-text OpenAI-compatible response), so measure only the ones that
# have a number and keep 0.0 when none do.
duration = 0.0
if segments:
duration = max(s.get("end", 0) for s in segments)
ends = [e for e in (s.get("end") for s in segments) if isinstance(e, (int, float))]
duration = max(ends) if ends else 0.0
detected_lang = result.get("language", language or "unknown")
@@ -194,8 +210,8 @@ async def transcribe_audio(
"text": full_text,
"segments": [
{
"start": round(s.get("start", 0), 2),
"end": round(s.get("end", 0), 2),
"start": _timing(s.get("start", 0)),
"end": _timing(s.get("end", 0)),
"text": s.get("text", "").strip(),
}
for s in segments
+20 -3
View File
@@ -55,6 +55,17 @@ from services.text_polish import polish_text
router = APIRouter()
logger = logging.getLogger("omnivoice.capture_ws")
def _timing(value):
"""A segment timing, or ``None`` when the engine could not determine one.
``dict.get(key, 0)`` returns a stored ``None`` rather than the default, so
rounding it raised (#1904). The null is the honest answer here — this module
emits it deliberately for un-endpointed utterances — and the segment list
renders whichever half of the range is known.
"""
return round(value, 2) if isinstance(value, (int, float)) else None
SPEECH_PROTOCOL = "voicestudio.speech.v1"
PLATFORM_STREAM_PATH = "/v1/audio/transcriptions/stream"
@@ -1187,13 +1198,19 @@ async def _transcribe_buffer_full(
from services.refinement import collapse_repetitive_artifacts
full_text = collapse_repetitive_artifacts(full_text)
duration = max((s.get("end", 0) for s in segments), default=0.0)
# end=None means the engine could not determine the timing — this
# module writes exactly that in its own streaming payloads, and
# sherpa's _sherpa_result does too when the sample rate yields no
# duration. Measure only real numbers, and pass the nulls through
# rather than rounding them (#1904).
ends = [e for e in (s.get("end") for s in segments) if isinstance(e, (int, float))]
duration = max(ends) if ends else 0.0
return {
"text": full_text,
"segments": [
{"start": round(s.get("start", 0), 2),
"end": round(s.get("end", 0), 2),
{"start": _timing(s.get("start", 0)),
"end": _timing(s.get("end", 0)),
"text": s.get("text", "").strip()}
for s in segments
],
+128
View File
@@ -0,0 +1,128 @@
"""
A segment timing the engine could not determine arrives as ``end: None``, and
the REST `/transcribe` response builder used to raise on it.
`_sherpa_result()` (services/asr_backend.py) sets ``duration = None`` when it
cannot derive one from the sample rate, and `OpenAICompatASRBackend`
`_adapt_response()` emits ``end: None`` for every plain-text (`json`/`text`)
response from a server that rejects `verbose_json`. Both reach this endpoint —
sherpa is the first capture engine and the OpenAI-compatible backend is
selectable as the active one.
`round(s.get("end", 0), 2)` does not defend against that: ``.get`` returns the
stored ``None`` rather than the default, because the key is present. The route
answered 500 for a transcript that was otherwise fine, which is the server-side
half of #1904.
"""
import os
import pytest
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
pytestmark = pytest.mark.usefixtures("asr_model_installed")
class _UntimedBackend:
"""What sherpa and the OpenAI-compatible server both hand back when no
timing is available: text, a start of 0.0, and an honest null end."""
id = "untimed"
def transcribe(self, _path, **_kw):
return {
"text": "the meeting is at three",
"segments": [
{"start": 0.0, "end": None, "text": "the meeting is at three"},
],
"language": "en",
}
class _PartlyTimedBackend:
"""One timed segment and one the engine gave up on — the duration must come
from the half that is known, not from the null."""
id = "partly-timed"
def transcribe(self, _path, **_kw):
return {
"text": "first second",
"segments": [
{"start": 0.0, "end": 1.25, "text": "first"},
{"start": 1.25, "end": None, "text": "second"},
],
"language": "en",
}
def _client(monkeypatch, backend):
from fastapi.testclient import TestClient
monkeypatch.setattr(
"services.asr_backend.get_capture_asr_backend", lambda **_k: backend())
monkeypatch.setattr(
"services.asr_backend.get_active_asr_backend", lambda **_k: backend())
monkeypatch.setattr(
"services.asr_backend.load_active_asr_backend", lambda **_k: backend())
from main import app
return TestClient(app, client=("127.0.0.1", 50000))
def _post(client, **data):
return client.post(
"/transcribe",
files={"audio": ("a.wav", b"\x00" * 32000, "audio/wav")},
data=data,
)
@pytest.mark.parametrize("accurate", ["false", "true"])
def test_null_end_is_passed_through_not_rounded(monkeypatch, accurate):
client = _client(monkeypatch, _UntimedBackend)
r = _post(client, accurate=accurate)
assert r.status_code == 200, r.text
body = r.json()
assert body["segments"][0]["end"] is None
assert body["segments"][0]["start"] == 0.0
assert body["segments"][0]["text"] == "the meeting is at three"
# Nothing known to measure, so the duration stays 0 rather than becoming null.
assert body["duration_s"] == 0.0
def test_duration_comes_from_the_timed_segments(monkeypatch):
client = _client(monkeypatch, _PartlyTimedBackend)
r = _post(client)
assert r.status_code == 200, r.text
body = r.json()
assert [s["end"] for s in body["segments"]] == [1.25, None]
assert body["duration_s"] == 1.25
# ── The live-dictation socket's own final-result builder ────────────────────
#
# Every existing capture_ws test stubs `_transcribe_buffer_full` out, so its
# response builder was never exercised. Call it directly: sherpa is the first
# capture engine and its `_sherpa_result` degrades to end=None, so this half is
# reachable through the default dictation path.
def test_ws_full_result_passes_null_end_through(monkeypatch):
import asyncio
from api.routers import capture_ws as cw
monkeypatch.setattr(
"services.asr_backend.get_capture_asr_backend",
lambda **_k: _PartlyTimedBackend())
async def _straight_through(_pool, run, **_kw):
return run()
monkeypatch.setattr(
"services.asr_backend.run_transcribe_guarded", _straight_through)
result = asyncio.run(
cw._transcribe_buffer_full([b"\x00" * 32000], pcm_sr=16000))
assert [s["end"] for s in result["segments"]] == [1.25, None]
assert result["duration_s"] == 1.25