Tower worker now persists faster-whisper's segment-level output
(start/end/text + word-level timing when available) alongside the
existing joined `transcript_text`. The text column stays the canonical
input the extractor reads — this is additive.
Changes:
- alembic v4: sources.transcript_segments JSONB NULL. JSONB rather than
JSON so future equality/containment queries are indexable without a
re-migration. Same lovebug-no-CREATE-on-petalbrain guard as prior
migrations.
- ORM model: Optional[list] mapped to JSONB (postgresql dialect).
- transcribe.py:
- Always pass word_timestamps=True to faster-whisper.transcribe.
- New segment_to_dict() flattens the upstream NamedTuple-shaped
Segment/Word into JSON-safe plain dicts so the JSONB write doesn't
drag faster-whisper into any reader.
- Per-word defensive conversion: a single malformed word can't drop
the surrounding segment.
- transcribe_worker._advance: after a successful transcribe, persist
segments into source.transcript_segments inside a try/except. If the
JSONB write fails (oversize row, malformed dict, etc.) we log a
warning and still commit transcript_text + status=TRANSCRIBED — the
pipeline never crashes over the additive index.
- Tests: three new unit tests against fake Segment/Word objects cover
the happy path (word entries serialise), the no-words case
(`segment.words is None` → empty list), and the malformed-word skip.
json.dumps(d) asserts JSONB-binding compatibility.
Live-verified: migration applied clean against petalbrain (`\d sources`
shows transcript_segments jsonb); ORM round-trip writes and reads the
sample payload identically. GPU large-v3 word-timestamp behaviour is
unchanged from upstream — only the tower can validate that hot path.
227 lines
7.1 KiB
Python
227 lines
7.1 KiB
Python
"""Unit + integration coverage for second_brain.transcribe.
|
|
|
|
Unit-level: resolve_settings + write_srt are pure-Python and run anywhere.
|
|
|
|
Integration: an end-to-end CPU/int8 transcribe of a synthesized audio
|
|
clip — auto-skipped when ffmpeg or faster-whisper is missing (the dev
|
|
side doesn't install faster-whisper by default). Only the *tower* with
|
|
GPU large-v3 can validate the production hot path; this test proves the
|
|
wiring + the SRT contract + the lazy-import path on cheap CPU.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pure helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_resolve_settings_picks_int8_for_cpu_by_default():
|
|
from second_brain.transcribe import resolve_settings
|
|
|
|
out = resolve_settings({"whisper_device": "cpu"})
|
|
assert out["device"] == "cpu"
|
|
assert out["compute_type"] == "int8"
|
|
assert out["model"] == "large-v3"
|
|
|
|
|
|
def test_resolve_settings_picks_float16_for_cuda_by_default():
|
|
from second_brain.transcribe import resolve_settings
|
|
|
|
out = resolve_settings({"whisper_device": "cuda"})
|
|
assert out["device"] == "cuda"
|
|
assert out["compute_type"] == "float16"
|
|
|
|
|
|
def test_resolve_settings_env_overrides_block(monkeypatch):
|
|
from second_brain.transcribe import resolve_settings
|
|
|
|
monkeypatch.setenv("WHISPER_MODEL", "small")
|
|
monkeypatch.setenv("WHISPER_DEVICE", "cpu")
|
|
monkeypatch.setenv("WHISPER_COMPUTE_TYPE", "int8_float16")
|
|
out = resolve_settings({"whisper_device": "cuda"})
|
|
assert out["model"] == "small"
|
|
assert out["device"] == "cpu"
|
|
assert out["compute_type"] == "int8_float16"
|
|
|
|
|
|
def test_write_srt_roundtrip(tmp_path):
|
|
from second_brain.transcribe import write_srt
|
|
|
|
segs = [
|
|
{"start": 0.0, "end": 1.5, "text": "hello"},
|
|
{"start": 1.5, "end": 3.25, "text": "world"},
|
|
]
|
|
out = tmp_path / "x.srt"
|
|
write_srt(segs, out)
|
|
body = out.read_text()
|
|
assert "1\n00:00:00,000 --> 00:00:01,500\nhello" in body
|
|
assert "2\n00:00:01,500 --> 00:00:03,250\nworld" in body
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Segment / word conversion — exercised on faked faster-whisper objects so
|
|
# we don't need a real model + audio to validate the JSONB-bound shape.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeWord:
|
|
def __init__(self, start, end, word, probability):
|
|
self.start = start
|
|
self.end = end
|
|
self.word = word
|
|
self.probability = probability
|
|
|
|
|
|
class _FakeSegment:
|
|
def __init__(self, id, start, end, text, words=None):
|
|
self.id = id
|
|
self.start = start
|
|
self.end = end
|
|
self.text = text
|
|
self.words = words
|
|
|
|
|
|
def test_segment_to_dict_with_words():
|
|
"""Happy path: faster-whisper segment + word_timestamps → JSON-safe dict."""
|
|
import json
|
|
|
|
from second_brain.transcribe import segment_to_dict
|
|
|
|
seg = _FakeSegment(
|
|
id=3,
|
|
start=1.25,
|
|
end=3.5,
|
|
text=" hello world ",
|
|
words=[
|
|
_FakeWord(1.25, 1.6, " hello", 0.92),
|
|
_FakeWord(1.6, 3.5, " world", 0.87),
|
|
],
|
|
)
|
|
d = segment_to_dict(seg)
|
|
|
|
assert d == {
|
|
"id": 3,
|
|
"start": 1.25,
|
|
"end": 3.5,
|
|
"text": "hello world", # stripped
|
|
"words": [
|
|
{"start": 1.25, "end": 1.6, "word": " hello", "probability": 0.92},
|
|
{"start": 1.6, "end": 3.5, "word": " world", "probability": 0.87},
|
|
],
|
|
}
|
|
# Must be JSON-serialisable for the JSONB column.
|
|
json.dumps(d)
|
|
|
|
|
|
def test_segment_to_dict_without_words():
|
|
"""word_timestamps=False (or older faster-whisper) → segment.words is None."""
|
|
from second_brain.transcribe import segment_to_dict
|
|
|
|
seg = _FakeSegment(id=0, start=0.0, end=1.0, text="just text", words=None)
|
|
d = segment_to_dict(seg)
|
|
assert d["text"] == "just text"
|
|
assert d["words"] == []
|
|
|
|
|
|
def test_segment_to_dict_skips_malformed_words():
|
|
"""A single bad word entry must not drop the whole segment."""
|
|
from second_brain.transcribe import segment_to_dict
|
|
|
|
class _BadWord:
|
|
# Missing start → float(None) raises in the helper, must be skipped.
|
|
start = None
|
|
end = 1.0
|
|
word = "broken"
|
|
probability = 0.5
|
|
|
|
seg = _FakeSegment(
|
|
id=1,
|
|
start=0.0,
|
|
end=1.0,
|
|
text="hi",
|
|
words=[_FakeWord(0.0, 0.5, "hi", 0.99), _BadWord()],
|
|
)
|
|
d = segment_to_dict(seg)
|
|
# Good word survives; bad word is filtered.
|
|
assert len(d["words"]) == 1
|
|
assert d["words"][0]["word"] == "hi"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration: synthesized audio → CPU/int8 faster-whisper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _have_faster_whisper() -> bool:
|
|
try:
|
|
import faster_whisper # type: ignore[import-not-found] # noqa: F401
|
|
except ImportError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _have_numpy() -> bool:
|
|
try:
|
|
import numpy # noqa: F401
|
|
except ImportError:
|
|
return False
|
|
return True
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not (_have_faster_whisper() and _have_numpy()),
|
|
reason="needs faster-whisper (uv sync --extra tower) + numpy to exercise CPU path",
|
|
)
|
|
def test_cpu_transcribe_smoke(tmp_path):
|
|
"""Hand faster-whisper a synthesized audio array on CPU/int8 with `tiny`.
|
|
|
|
Pure-tone input gives empty / minimal recognition — the point is the
|
|
wiring: model loads, the segments iterator drains, an SRT is writable.
|
|
Using `tiny` to keep this under ~30s coldcache. Skipped on dev where
|
|
faster-whisper isn't installed; only the tower's `uv sync --extra
|
|
tower` brings it in.
|
|
|
|
Bypassing ffmpeg by passing a numpy array — faster-whisper.transcribe
|
|
accepts (path | file-like | np.ndarray). This exercises the same code
|
|
path the worker uses, only with zero external binaries.
|
|
"""
|
|
import numpy as np
|
|
|
|
from second_brain.transcribe import write_srt
|
|
|
|
# Build 2s of 440Hz mono at 16kHz, the model's native sample rate.
|
|
sr = 16000
|
|
duration = 2.0
|
|
t = np.arange(0, int(sr * duration)) / sr
|
|
audio = (0.2 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
|
|
|
|
# Load `tiny` to keep this cheap; we're not asserting transcription
|
|
# quality, just that the pipeline executes and returns the documented
|
|
# shape.
|
|
from faster_whisper import WhisperModel # type: ignore[import-not-found]
|
|
|
|
model = WhisperModel("tiny", device="cpu", compute_type="int8")
|
|
segments_iter, _ = model.transcribe(audio, language="en", beam_size=1)
|
|
|
|
# Materialize to mirror what Transcriber.transcribe does internally.
|
|
segments: list[dict] = []
|
|
text_parts: list[str] = []
|
|
for seg in segments_iter:
|
|
s = (seg.text or "").strip()
|
|
segments.append({"start": float(seg.start), "end": float(seg.end), "text": s})
|
|
if s:
|
|
text_parts.append(s)
|
|
text = " ".join(text_parts)
|
|
|
|
assert isinstance(text, str)
|
|
assert isinstance(segments, list)
|
|
|
|
srt = tmp_path / "out.srt"
|
|
write_srt(segments, srt)
|
|
assert srt.exists()
|