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:
co-authored by
Claude Opus 5
parent
18f56a7940
commit
2e4465a3f4
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user