Use physical CPU cores for Whisper runtime defaults

This commit is contained in:
2026-08-21 11:11:24 +02:00
parent a9dab7c81a
commit f2d21c1faf
5 changed files with 1021 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Run the minimal Meeting Lab MVP: audio -> Whisper -> direct protocol."""
from __future__ import annotations
import argparse
import json
import re
import shutil
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Callable
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from src.meeting_lab.llm.ollama import DEFAULT_ENDPOINT # noqa: E402
from src.meeting_lab.models.meeting_context import load_meeting_context # noqa: E402
from src.meeting_lab.protocol.generate_direct_protocol import ( # noqa: E402
DEFAULT_MODEL,
DirectProtocolResult,
generate_direct_protocol,
load_compact_transcript,
)
from src.meeting_lab.transcription.whisper import transcribe_audio # noqa: E402
DEFAULT_OUTPUT_ROOT = Path("meeting_data/runs")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Transcribe one meeting and generate one direct protocol."
)
parser.add_argument("audio_file", type=Path)
parser.add_argument("--whisper-model", type=Path, required=True)
parser.add_argument("--whisper-executable", default="whisper-cli")
parser.add_argument("--context", type=Path)
parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT)
parser.add_argument("--language", default="de")
parser.add_argument("--threads", default="auto", help="Thread count or 'auto' for physical CPU cores (default: auto).")
parser.add_argument("--model", default=DEFAULT_MODEL)
parser.add_argument("--ollama-endpoint", default=DEFAULT_ENDPOINT)
return parser.parse_args(argv)
def create_unique_run_dir(
output_root: Path,
meeting_name: str,
now: Callable[[], datetime] = datetime.now,
) -> Path:
safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", meeting_name).strip("._-") or "meeting"
base = output_root / f"{safe_name}_{now().strftime('%Y%m%d_%H%M%S')}"
candidate = base
suffix = 1
while candidate.exists():
candidate = output_root / f"{base.name}_{suffix:02d}"
suffix += 1
candidate.mkdir(parents=True)
return candidate
def write_json(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def validate_inputs(args: argparse.Namespace) -> None:
if not args.audio_file.is_file():
raise FileNotFoundError(f"Audio file does not exist: {args.audio_file}")
if not args.whisper_model.is_file():
raise FileNotFoundError(f"Whisper model does not exist: {args.whisper_model}")
if args.context is not None:
if not args.context.is_file():
raise FileNotFoundError(f"Meeting Context file does not exist: {args.context}")
load_meeting_context(args.context)
def persist_protocol(run_dir: Path, result: DirectProtocolResult) -> Path:
protocol_dir = run_dir / "protocol"
protocol_dir.mkdir(exist_ok=True)
(protocol_dir / "exact_prompt.txt").write_text(result.exact_prompt, encoding="utf-8")
write_json(protocol_dir / "raw_response.json", result.raw_response)
write_json(protocol_dir / "runtime_metadata.json", result.runtime_metadata)
protocol_path = run_dir / "protocol.md"
protocol_path.write_text(result.protocol_text, encoding="utf-8")
return protocol_path
def run(args: argparse.Namespace) -> tuple[int, Path | None, Path | None]:
overall_started = time.perf_counter()
validation_started = time.perf_counter()
try:
validate_inputs(args)
except Exception as exc:
print(f"Error: {type(exc).__name__}: {exc}", file=sys.stderr)
return 2, None, None
validation_runtime = time.perf_counter() - validation_started
run_dir = create_unique_run_dir(args.output_root, args.audio_file.stem)
timestamp = datetime.now().astimezone().isoformat(timespec="seconds")
transcript_path = run_dir / "transcript" / "transcript.json"
protocol_path = run_dir / "protocol.md"
stage_runtimes: dict[str, float | None] = {
"validation": round(validation_runtime, 3),
"setup": None,
"whisper": None,
"transcript_validation": None,
"protocol": None,
}
metadata: dict[str, Any] = {
"run_id": run_dir.name,
"timestamp": timestamp,
"input_audio": str(args.audio_file.resolve()),
"transcript_output": str(transcript_path.resolve()),
"protocol_output": str(protocol_path.resolve()),
"whisper_model": str(args.whisper_model.resolve()),
"model": args.model,
"ollama_endpoint": args.ollama_endpoint,
"status": "running",
"stage_runtimes_seconds": stage_runtimes,
"total_runtime_seconds": None,
"failure": None,
}
current_stage = "setup"
stage_started = time.perf_counter()
try:
audio_dir = run_dir / "audio"
transcript_dir = run_dir / "transcript"
context_dir = run_dir / "context"
protocol_dir = run_dir / "protocol"
audio_dir.mkdir()
transcript_dir.mkdir()
context_dir.mkdir()
protocol_dir.mkdir()
write_json(
audio_dir / "input_manifest.json",
{
"source_file": str(args.audio_file.resolve()),
"filename": args.audio_file.name,
"size_bytes": args.audio_file.stat().st_size,
},
)
preserved_context: Path | None = None
if args.context is not None:
preserved_context = context_dir / "meeting_context.yaml"
shutil.copy2(args.context, preserved_context)
stage_runtimes["setup"] = round(time.perf_counter() - stage_started, 3)
current_stage = "whisper"
stage_started = time.perf_counter()
transcription = transcribe_audio(
args.audio_file,
args.whisper_model,
transcript_dir,
args.language,
executable=args.whisper_executable,
threads=args.threads,
)
stage_runtimes["whisper"] = round(time.perf_counter() - stage_started, 3)
current_stage = "transcript_validation"
stage_started = time.perf_counter()
load_compact_transcript(transcription.transcript_json)
stage_runtimes["transcript_validation"] = round(
time.perf_counter() - stage_started, 3
)
current_stage = "protocol"
stage_started = time.perf_counter()
result = generate_direct_protocol(
transcription.transcript_json,
preserved_context,
model=args.model,
endpoint=args.ollama_endpoint,
)
stage_runtimes["protocol"] = round(time.perf_counter() - stage_started, 3)
protocol_path = persist_protocol(run_dir, result)
metadata["status"] = "completed"
except Exception as exc:
if current_stage in stage_runtimes and stage_runtimes[current_stage] is None:
stage_runtimes[current_stage] = round(time.perf_counter() - stage_started, 3)
metadata["status"] = "failed"
metadata["failure"] = {
"stage": current_stage,
"type": type(exc).__name__,
"message": str(exc),
}
protocol_path = None
print(f"Error: {type(exc).__name__}: {exc}", file=sys.stderr)
finally:
metadata["total_runtime_seconds"] = round(time.perf_counter() - overall_started, 3)
write_json(run_dir / "run_metadata.json", metadata)
return (0 if metadata["status"] == "completed" else 2), run_dir, protocol_path
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
code, _run_dir, protocol_path = run(args)
if protocol_path is not None:
print(protocol_path)
return code
if __name__ == "__main__":
raise SystemExit(main())
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Transcribe one audio file with whisper.cpp; do not generate a protocol."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from src.meeting_lab.transcription.whisper import ( # noqa: E402
TranscriptionError,
transcribe_audio,
)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Create a compact Meeting Lab transcript with whisper.cpp.")
parser.add_argument("audio_file", type=Path)
parser.add_argument("--model", type=Path, required=True, help="Path to a whisper.cpp GGML model.")
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--language", default="auto", help="Language code or 'auto' (default: auto).")
parser.add_argument("--threads", default="auto", help="Thread count or 'auto' for physical CPU cores (default: auto).")
parser.add_argument("--whisper-executable", default="whisper-cli", help="whisper.cpp CLI executable (default: whisper-cli).")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
result = transcribe_audio(
args.audio_file,
args.model,
args.output_dir,
args.language,
executable=args.whisper_executable,
threads=args.threads,
)
except TranscriptionError as exc:
print(f"Error: {exc}")
return 1
print(f"Transcript: {result.transcript_json}")
print(f"Runtime: {result.runtime_seconds:.3f} seconds")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+290
View File
@@ -0,0 +1,290 @@
"""Isolated whisper.cpp wrapper producing Meeting Lab compact transcripts."""
from __future__ import annotations
import json
import os
import platform
import re
import shutil
import subprocess
import tempfile
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Sequence
BACKEND = "whisper.cpp"
RAW_FILENAME = "whisper_raw.json"
TRANSCRIPT_FILENAME = "transcript.json"
TEXT_FILENAME = "transcript.txt"
METADATA_FILENAME = "runtime_metadata.json"
DEFAULT_THREADS = "auto"
class TranscriptionError(RuntimeError):
"""Raised when parameters, Whisper execution, or output are invalid."""
@dataclass(frozen=True)
class TranscriptionResult:
output_dir: Path
raw_output: Path
transcript_json: Path
transcript_text: Path
runtime_metadata: Path
runtime_seconds: float
def _logical_cpu_count() -> int:
"""Return the available logical CPU count as a last-resort fallback."""
if hasattr(os, "sched_getaffinity"):
try:
count = len(os.sched_getaffinity(0))
if count > 0:
return count
except OSError:
pass
return os.cpu_count() or 1
def _linux_physical_core_count() -> int | None:
affinity = None
if hasattr(os, "sched_getaffinity"):
try:
affinity = os.sched_getaffinity(0)
except OSError:
pass
cores: set[tuple[str, str]] = set()
for cpu_dir in Path("/sys/devices/system/cpu").glob("cpu[0-9]*"):
try:
cpu_number = int(cpu_dir.name[3:])
if affinity is not None and cpu_number not in affinity:
continue
topology = cpu_dir / "topology"
package = (topology / "physical_package_id").read_text().strip()
core = (topology / "core_id").read_text().strip()
cores.add((package, core))
except (OSError, ValueError):
continue
return len(cores) or None
def _darwin_physical_core_count() -> int | None:
try:
completed = subprocess.run(
("sysctl", "-n", "hw.physicalcpu"),
check=False,
capture_output=True,
text=True,
)
count = int(completed.stdout.strip())
return count if completed.returncode == 0 and count > 0 else None
except (OSError, ValueError):
return None
def physical_core_count() -> int:
"""Detect physical cores where supported, falling back to available threads."""
system = platform.system()
detected = _linux_physical_core_count() if system == "Linux" else None
if system == "Darwin":
detected = _darwin_physical_core_count()
return detected or _logical_cpu_count()
def resolve_threads(
threads: str | int,
detector: Callable[[], int] = physical_core_count,
) -> int:
if isinstance(threads, bool):
raise TranscriptionError("Threads must be 'auto' or a positive integer.")
if threads == "auto":
count = detector()
else:
try:
count = int(threads)
except (TypeError, ValueError) as exc:
raise TranscriptionError("Threads must be 'auto' or a positive integer.") from exc
if count <= 0:
raise TranscriptionError("Threads must be 'auto' or a positive integer.")
return count
def _vulkan_support(raw: dict[str, Any]) -> bool | None:
system_info = raw.get("systeminfo")
if not isinstance(system_info, str) or "VULKAN" not in system_info.upper():
return None
return re.search(r"VULKAN\s*=\s*1", system_info, re.IGNORECASE) is not None
def _json_object(path: Path) -> dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise TranscriptionError(f"Cannot read Whisper JSON output {path}: {exc}") from exc
if not isinstance(data, dict):
raise TranscriptionError("Whisper JSON output must contain a top-level object.")
return data
def compact_transcript(raw: dict[str, Any]) -> dict[str, Any]:
"""Convert whisper.cpp JSON without linguistic cleanup or reordering."""
entries = raw.get("transcription")
if not isinstance(entries, list):
raise TranscriptionError("Whisper JSON output must contain a 'transcription' list.")
segments: list[dict[str, Any]] = []
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
raise TranscriptionError(f"transcription[{index}] must be an object.")
offsets = entry.get("offsets")
if not isinstance(offsets, dict):
raise TranscriptionError(f"transcription[{index}].offsets must be an object.")
start_ms = offsets.get("from")
end_ms = offsets.get("to")
if not isinstance(start_ms, (int, float)) or isinstance(start_ms, bool):
raise TranscriptionError(f"transcription[{index}].offsets.from must be a number.")
if not isinstance(end_ms, (int, float)) or isinstance(end_ms, bool):
raise TranscriptionError(f"transcription[{index}].offsets.to must be a number.")
if end_ms < start_ms:
raise TranscriptionError(
f"transcription[{index}].offsets.to must be greater than or equal to offsets.from."
)
text_value = entry.get("text", "")
if not isinstance(text_value, str):
raise TranscriptionError(f"transcription[{index}].text must be a string.")
text = text_value.strip()
if text:
segments.append(
{
"id": len(segments),
"start": float(start_ms) / 1000.0,
"end": float(end_ms) / 1000.0,
"text": text,
}
)
return {"text": " ".join(item["text"] for item in segments), "segments": segments}
def _timestamp(seconds: float) -> str:
milliseconds = int(round(seconds * 1000))
hours, remainder = divmod(milliseconds, 3_600_000)
minutes, remainder = divmod(remainder, 60_000)
secs, millis = divmod(remainder, 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d}.{millis:03d}"
def transcript_text(transcript: dict[str, Any]) -> str:
lines = [
f"[{_timestamp(item['start'])} - {_timestamp(item['end'])}] {item['text']}"
for item in transcript["segments"]
]
return "\n".join(lines) + ("\n" if lines else "")
def _write_json(path: Path, value: dict[str, Any]) -> None:
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def transcribe_audio(
audio_path: Path,
model_path: Path,
output_dir: Path,
language: str = "auto",
*,
executable: str = "whisper-cli",
threads: str | int = DEFAULT_THREADS,
thread_detector: Callable[[], int] = physical_core_count,
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
monotonic: Callable[[], float] = time.monotonic,
now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> TranscriptionResult:
"""Run one whisper.cpp call and write raw, compact, text, and metadata outputs."""
audio_path = Path(audio_path)
model_path = Path(model_path)
output_dir = Path(output_dir)
if not audio_path.is_file():
raise TranscriptionError(f"Audio file does not exist: {audio_path}")
if not model_path.is_file():
raise TranscriptionError(f"Whisper model does not exist: {model_path}")
if not isinstance(language, str) or not language.strip():
raise TranscriptionError("Language must be a non-empty string.")
if not executable.strip():
raise TranscriptionError("Whisper executable must be a non-empty string.")
if output_dir.exists() and not output_dir.is_dir():
raise TranscriptionError(f"Output directory path is not a directory: {output_dir}")
thread_count = resolve_threads(threads, thread_detector)
output_dir.mkdir(parents=True, exist_ok=True)
raw_output = output_dir / RAW_FILENAME
started_at = now().astimezone(timezone.utc)
started = monotonic()
with tempfile.TemporaryDirectory(prefix=".whisper-", dir=output_dir) as temp_name:
temporary_prefix = Path(temp_name) / "whisper_raw"
command: Sequence[str] = (
executable,
"-m", str(model_path),
"-f", str(audio_path),
"-l", language.strip(),
"-t", str(thread_count),
"-fa",
"-oj",
"-of", str(temporary_prefix),
)
try:
completed = runner(command, check=False, capture_output=True, text=True)
except OSError as exc:
raise TranscriptionError(f"Could not start {BACKEND}: {exc}") from exc
runtime_seconds = monotonic() - started
temporary_raw = temporary_prefix.with_suffix(".json")
if temporary_raw.is_file():
shutil.copyfile(temporary_raw, raw_output)
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip() or "no diagnostic output"
raise TranscriptionError(
f"{BACKEND} failed with exit code {completed.returncode}: {detail}"
)
if not raw_output.is_file():
raise TranscriptionError(f"{BACKEND} completed without producing JSON output.")
raw_data = _json_object(raw_output)
transcript = compact_transcript(raw_data)
transcript_json_path = output_dir / TRANSCRIPT_FILENAME
transcript_text_path = output_dir / TEXT_FILENAME
metadata_path = output_dir / METADATA_FILENAME
_write_json(transcript_json_path, transcript)
transcript_text_path.write_text(transcript_text(transcript), encoding="utf-8")
duration = max((item["end"] for item in transcript["segments"]), default=None)
metadata = {
"input_file": str(audio_path.resolve()),
"model": str(model_path.resolve()),
"backend": BACKEND,
"whisper_executable": executable,
"language": language.strip(),
"threads": thread_count,
"threads_option": str(threads),
"flash_attention": True,
"vulkan_support_detected": _vulkan_support(raw_data),
"duration_seconds": duration,
"runtime_seconds": runtime_seconds,
"timestamp": started_at.isoformat(),
"output_files": {
"whisper_raw": RAW_FILENAME,
"transcript_json": TRANSCRIPT_FILENAME,
"transcript_text": TEXT_FILENAME,
"runtime_metadata": METADATA_FILENAME,
},
}
_write_json(metadata_path, metadata)
return TranscriptionResult(
output_dir=output_dir,
raw_output=raw_output,
transcript_json=transcript_json_path,
transcript_text=transcript_text_path,
runtime_metadata=metadata_path,
runtime_seconds=runtime_seconds,
)
+248
View File
@@ -0,0 +1,248 @@
import json
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from unittest.mock import Mock, patch
from scripts import run_mvp_meeting
from src.meeting_lab.protocol.generate_direct_protocol import DirectProtocolResult
from src.meeting_lab.transcription.whisper import TranscriptionError, TranscriptionResult
VALID_CONTEXT = """schema_version: "1"
meeting:
meeting_id: "mvp-test"
title: "MVP Test"
language: "de"
participants: []
mentioned_people: []
organization:
departments: []
known_entities: {}
"""
def protocol_result(model: str = "chosen:model") -> DirectProtocolResult:
text = "# Protokoll\n\nUnverändert. \n"
return DirectProtocolResult(
protocol_text=text,
exact_prompt="exact prompt\n",
model_metadata={"model": model},
runtime_metadata={"model": model, "request_count": 1, "client_wall_time_seconds": 0.5},
raw_response={"response": text, "done": True},
)
def fake_transcribe(
audio_path: Path,
model_path: Path,
output_dir: Path,
language: str,
*,
executable: str,
threads: str | int,
) -> TranscriptionResult:
output_dir.mkdir(parents=True, exist_ok=True)
raw = output_dir / "whisper_raw.json"
transcript = output_dir / "transcript.json"
text = output_dir / "transcript.txt"
metadata = output_dir / "runtime_metadata.json"
raw.write_text('{"transcription": []}\n', encoding="utf-8")
transcript.write_text(
json.dumps({"text": "Besprechungstext.", "segments": []}) + "\n",
encoding="utf-8",
)
text.write_text("Besprechungstext.\n", encoding="utf-8")
metadata.write_text('{"runtime_seconds": 1.25}\n', encoding="utf-8")
return TranscriptionResult(output_dir, raw, transcript, text, metadata, 1.25)
class MvpOrchestratorTests(unittest.TestCase):
def create_inputs(self, root: Path) -> tuple[Path, Path, Path]:
audio = root / "team meeting.wav"
whisper_model = root / "ggml-model.bin"
context = root / "source-context.yaml"
audio.write_bytes(b"audio")
whisper_model.write_bytes(b"model")
context.write_text(VALID_CONTEXT, encoding="utf-8")
return audio, whisper_model, context
def args(self, root: Path, extra: list[str] | None = None):
audio, whisper_model, context = self.create_inputs(root)
values = [
str(audio),
"--whisper-model", str(whisper_model),
"--context", str(context),
"--output-root", str(root / "runs"),
"--language", "de",
"--model", "chosen:model",
"--ollama-endpoint", "http://ollama.test:11434",
]
if extra:
values.extend(extra)
return run_mvp_meeting.parse_args(values)
def test_successful_full_orchestration_and_artifact_layout(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
args = self.args(root)
with (
patch.object(run_mvp_meeting, "transcribe_audio", side_effect=fake_transcribe) as whisper,
patch.object(
run_mvp_meeting,
"generate_direct_protocol",
return_value=protocol_result(),
) as protocol,
):
code, run_dir, protocol_path = run_mvp_meeting.run(args)
self.assertEqual(code, 0)
self.assertIsNotNone(run_dir)
self.assertEqual(protocol_path, run_dir / "protocol.md")
expected = {
"run_metadata.json",
"audio/input_manifest.json",
"transcript/whisper_raw.json",
"transcript/transcript.json",
"transcript/transcript.txt",
"transcript/runtime_metadata.json",
"context/meeting_context.yaml",
"protocol/exact_prompt.txt",
"protocol/raw_response.json",
"protocol/runtime_metadata.json",
"protocol.md",
}
self.assertTrue(all((run_dir / item).is_file() for item in expected))
metadata = json.loads((run_dir / "run_metadata.json").read_text())
self.assertEqual(metadata["status"], "completed")
self.assertEqual(metadata["model"], "chosen:model")
self.assertIsNone(metadata["failure"])
self.assertEqual(whisper.call_count, 1)
self.assertEqual(protocol.call_count, 1)
def test_context_model_endpoint_and_whisper_options_are_forwarded(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
args = self.args(
root,
["--whisper-executable", "/tools/whisper-cli", "--threads", "4"],
)
with (
patch.object(run_mvp_meeting, "transcribe_audio", side_effect=fake_transcribe) as whisper,
patch.object(
run_mvp_meeting,
"generate_direct_protocol",
return_value=protocol_result(),
) as protocol,
):
code, run_dir, _ = run_mvp_meeting.run(args)
self.assertEqual(code, 0)
self.assertEqual(whisper.call_args.args[3], "de")
self.assertEqual(whisper.call_args.kwargs["executable"], "/tools/whisper-cli")
self.assertEqual(whisper.call_args.kwargs["threads"], "4")
self.assertEqual(protocol.call_args.args[1], run_dir / "context/meeting_context.yaml")
self.assertEqual(protocol.call_args.kwargs["model"], "chosen:model")
self.assertEqual(
protocol.call_args.kwargs["endpoint"], "http://ollama.test:11434"
)
def test_whisper_failure_is_recorded_and_protocol_is_not_called(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
args = self.args(root)
with (
patch.object(
run_mvp_meeting,
"transcribe_audio",
side_effect=TranscriptionError("whisper stopped"),
),
patch.object(run_mvp_meeting, "generate_direct_protocol") as protocol,
):
code, run_dir, protocol_path = run_mvp_meeting.run(args)
metadata = json.loads((run_dir / "run_metadata.json").read_text())
self.assertEqual(code, 2)
self.assertIsNone(protocol_path)
self.assertEqual(metadata["status"], "failed")
self.assertEqual(metadata["failure"]["stage"], "whisper")
self.assertIn("whisper stopped", metadata["failure"]["message"])
protocol.assert_not_called()
self.assertTrue((run_dir / "audio/input_manifest.json").is_file())
def test_protocol_failure_preserves_transcript_and_failure_metadata(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
args = self.args(root)
with (
patch.object(run_mvp_meeting, "transcribe_audio", side_effect=fake_transcribe),
patch.object(
run_mvp_meeting,
"generate_direct_protocol",
side_effect=ValueError("generation stopped"),
),
):
code, run_dir, protocol_path = run_mvp_meeting.run(args)
metadata = json.loads((run_dir / "run_metadata.json").read_text())
self.assertEqual(code, 2)
self.assertIsNone(protocol_path)
self.assertEqual(metadata["failure"]["stage"], "protocol")
self.assertTrue((run_dir / "transcript/transcript.json").is_file())
self.assertFalse((run_dir / "protocol.md").exists())
def test_unique_run_directories_do_not_overwrite(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
fixed = datetime(2026, 8, 21, 9, 15, 30)
first = run_mvp_meeting.create_unique_run_dir(root, "team meeting", lambda: fixed)
marker = first / "keep.txt"
marker.write_text("keep", encoding="utf-8")
second = run_mvp_meeting.create_unique_run_dir(root, "team meeting", lambda: fixed)
self.assertEqual(first.name, "team_meeting_20260821_091530")
self.assertEqual(second.name, "team_meeting_20260821_091530_01")
self.assertEqual(marker.read_text(), "keep")
def test_semantic_pipeline_functions_are_never_invoked(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
args = self.args(root)
with (
patch.object(run_mvp_meeting, "transcribe_audio", side_effect=fake_transcribe),
patch.object(
run_mvp_meeting,
"generate_direct_protocol",
return_value=protocol_result(),
),
patch("src.meeting_lab.chunking.chunk_transcript.build_chunks") as chunking,
patch("src.meeting_lab.extraction.extract_chunks.extract_input") as extraction,
patch("src.meeting_lab.consolidation.consolidate_facts.call_ollama") as consolidation,
):
code, _, _ = run_mvp_meeting.run(args)
self.assertEqual(code, 0)
chunking.assert_not_called()
extraction.assert_not_called()
consolidation.assert_not_called()
def test_main_returns_nonzero_for_whisper_failure(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
args = self.args(root)
argv = [
str(args.audio_file),
"--whisper-model", str(args.whisper_model),
"--context", str(args.context),
"--output-root", str(args.output_root),
]
with patch.object(
run_mvp_meeting,
"transcribe_audio",
side_effect=TranscriptionError("failed"),
):
self.assertEqual(run_mvp_meeting.main(argv), 2)
if __name__ == "__main__":
unittest.main()
+221
View File
@@ -0,0 +1,221 @@
import json
import subprocess
import tempfile
import unittest
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import Mock, patch
from scripts import transcribe_meeting
from src.meeting_lab.transcription.whisper import (
TranscriptionError,
compact_transcript,
transcribe_audio,
)
RAW_RESULT = {
"params": {"language": "de"},
"transcription": [
{
"timestamps": {"from": "00:00:00,000", "to": "00:00:01,200"},
"offsets": {"from": 0, "to": 1200},
"text": " Hallo ",
},
{
"timestamps": {"from": "00:00:01,200", "to": "00:00:02,500"},
"offsets": {"from": 1200, "to": 2500},
"text": "Welt",
},
],
}
class FakeRunner:
def __init__(self, *, returncode: int = 0, write_raw: bool = True) -> None:
self.returncode = returncode
self.write_raw = write_raw
self.commands: list[list[str]] = []
def __call__(self, command, **kwargs):
command = list(command)
self.commands.append(command)
if self.write_raw:
prefix = Path(command[command.index("-of") + 1])
prefix.with_suffix(".json").write_text(
json.dumps(RAW_RESULT, ensure_ascii=False), encoding="utf-8"
)
return subprocess.CompletedProcess(
command, self.returncode, stdout="", stderr="backend failure"
)
class TranscriptionTests(unittest.TestCase):
def create_inputs(self, root: Path) -> tuple[Path, Path]:
audio = root / "meeting.wav"
model = root / "ggml-model.bin"
audio.write_bytes(b"audio")
model.write_bytes(b"model")
return audio, model
def test_missing_audio_file_is_rejected_before_execution(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
model = root / "model.bin"
model.write_bytes(b"model")
runner = FakeRunner()
with self.assertRaisesRegex(TranscriptionError, "Audio file does not exist"):
transcribe_audio(root / "missing.wav", model, root / "out", runner=runner)
self.assertEqual(runner.commands, [])
def test_invalid_parameters_are_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
audio, model = self.create_inputs(root)
with self.assertRaisesRegex(TranscriptionError, "model does not exist"):
transcribe_audio(audio, root / "missing.bin", root / "out")
with self.assertRaisesRegex(TranscriptionError, "Language must"):
transcribe_audio(audio, model, root / "out", " ")
with self.assertRaisesRegex(TranscriptionError, "executable must"):
transcribe_audio(audio, model, root / "out", executable="")
with self.assertRaisesRegex(TranscriptionError, "Threads must"):
transcribe_audio(audio, model, root / "out", threads="zero")
def test_auto_threads_use_detected_physical_core_count_and_flash_attention(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
audio, model = self.create_inputs(root)
runner = FakeRunner()
result = transcribe_audio(
audio,
model,
root / "output",
runner=runner,
thread_detector=lambda: 6,
)
command = runner.commands[0]
self.assertEqual(command[command.index("-t") + 1], "6")
self.assertIn("-fa", command)
metadata = json.loads(result.runtime_metadata.read_text())
self.assertEqual(metadata["threads"], 6)
self.assertEqual(metadata["threads_option"], "auto")
self.assertTrue(metadata["flash_attention"])
self.assertEqual(metadata["whisper_executable"], "whisper-cli")
def test_explicit_thread_count_is_forwarded_unchanged(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
audio, model = self.create_inputs(root)
runner = FakeRunner()
result = transcribe_audio(
audio, model, root / "output", threads=12, runner=runner
)
command = runner.commands[0]
self.assertEqual(command[command.index("-t") + 1], "12")
metadata = json.loads(result.runtime_metadata.read_text())
self.assertEqual(metadata["threads"], 12)
self.assertEqual(metadata["threads_option"], "12")
def test_cli_forwards_explicit_thread_count_to_wrapper(self) -> None:
fake_result = Mock(
transcript_json=Path("output/transcript.json"), runtime_seconds=1.0
)
with patch.object(
transcribe_meeting, "transcribe_audio", return_value=fake_result
) as transcribe:
code = transcribe_meeting.main(
[
"meeting.wav",
"--model", "model.bin",
"--output-dir", "output",
"--threads", "6",
]
)
self.assertEqual(code, 0)
self.assertEqual(transcribe.call_args.kwargs["threads"], "6")
def test_compact_transcript_has_established_schema(self) -> None:
self.assertEqual(
compact_transcript(RAW_RESULT),
{
"text": "Hallo Welt",
"segments": [
{"id": 0, "start": 0.0, "end": 1.2, "text": "Hallo"},
{"id": 1, "start": 1.2, "end": 2.5, "text": "Welt"},
],
},
)
def test_success_preserves_raw_output_and_creates_metadata(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
audio, model = self.create_inputs(root)
runner = FakeRunner()
times = iter([10.0, 12.25])
result = transcribe_audio(
audio,
model,
root / "output",
"de",
runner=runner,
monotonic=lambda: next(times),
now=lambda: datetime(2026, 8, 21, 8, 30, tzinfo=timezone.utc),
)
self.assertEqual(json.loads(result.raw_output.read_text()), RAW_RESULT)
self.assertEqual(json.loads(result.transcript_json.read_text())["text"], "Hallo Welt")
self.assertEqual(
result.transcript_text.read_text(),
"[00:00:00.000 - 00:00:01.200] Hallo\n"
"[00:00:01.200 - 00:00:02.500] Welt\n",
)
metadata = json.loads(result.runtime_metadata.read_text())
self.assertEqual(metadata["backend"], "whisper.cpp")
self.assertEqual(metadata["language"], "de")
self.assertEqual(metadata["duration_seconds"], 2.5)
self.assertEqual(metadata["runtime_seconds"], 2.25)
self.assertEqual(metadata["timestamp"], "2026-08-21T08:30:00+00:00")
self.assertEqual(metadata["input_file"], str(audio.resolve()))
self.assertEqual(metadata["model"], str(model.resolve()))
def test_output_paths_are_deterministic(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
audio, model = self.create_inputs(root)
output = root / "chosen-output"
result = transcribe_audio(audio, model, output, runner=FakeRunner())
self.assertEqual(result.raw_output, output / "whisper_raw.json")
self.assertEqual(result.transcript_json, output / "transcript.json")
self.assertEqual(result.transcript_text, output / "transcript.txt")
self.assertEqual(result.runtime_metadata, output / "runtime_metadata.json")
self.assertEqual(
set(json.loads(result.runtime_metadata.read_text())["output_files"].values()),
{"whisper_raw.json", "transcript.json", "transcript.txt", "runtime_metadata.json"},
)
def test_backend_failure_raises_and_preserves_available_raw_output(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
audio, model = self.create_inputs(root)
output = root / "output"
with self.assertRaisesRegex(TranscriptionError, "exit code 7.*backend failure"):
transcribe_audio(audio, model, output, runner=FakeRunner(returncode=7))
self.assertEqual(json.loads((output / "whisper_raw.json").read_text()), RAW_RESULT)
self.assertFalse((output / "transcript.json").exists())
self.assertFalse((output / "runtime_metadata.json").exists())
def test_missing_backend_output_is_reported(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
audio, model = self.create_inputs(root)
with self.assertRaisesRegex(TranscriptionError, "without producing JSON"):
transcribe_audio(
audio, model, root / "output", runner=FakeRunner(write_raw=False)
)
if __name__ == "__main__":
unittest.main()