diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b48cfe2..ae8ff79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,3 +82,24 @@ jobs: - name: Run mypy run: uv run mypy src/ --ignore-missing-imports + + schemas: + name: Schema Drift Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.11' + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Install dependencies + run: uv sync --extra dev + + - name: Check committed schemas match Pydantic models + run: make check-schemas diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33aaa99..20a072e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,7 +75,9 @@ jobs: - name: Create Release uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: - files: dist/* + files: | + dist/* + docs/schemas/*.json draft: false prerelease: false generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a8156..e4922b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.2] - 2026-05-22 + +### Changed + +- Word-level `speaker` in combined transcripts now falls back to the enclosing segment's speaker when WhisperX's word-level diarization join doesn't intersect any pyannote span (typically a boundary-overlap gap on short, low-confidence trailing words). Previously such words were emitted with `speaker: null` despite the segment carrying a confident label. The Pydantic contract is unchanged — word-level `speaker` is still nullable in principle — but in practice the field is now populated whenever the segment-level assignment is confident. + +### Added + +- Published JSON Schemas for the combined transcript and batch summary documents under `docs/schemas/` (`combined-transcript-v1.json`, `batch-summary-v1.json`). They are generated from the Pydantic source-of-truth in serialization mode, attached as GitHub release assets, and kept in lockstep with the models by a CI drift check. Consumers without access to the Python package can now fetch the schema for a pinned AR version directly from the release tag and validate transcripts against it. See `docs/service.md` for the consumption pattern. +- `make generate-schemas` and `make check-schemas` Makefile targets. The drift check now runs as part of `make all-checks` and as a dedicated CI job (`Schema Drift Check` in `ci.yml`). + +### Documentation + +- Refreshed the README banner image. + ## [0.2.1] - 2026-05-21 ### Fixed diff --git a/Makefile b/Makefile index 335e149..a1494f7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-dev install-whisperx test test-verbose test-integration test-coverage lint lint-fix format format-check type-check clean pre-commit pre-commit-install all-checks ci dev-setup stats build-image run-service-local +.PHONY: help install install-dev install-whisperx test test-verbose test-integration test-coverage lint lint-fix format format-check type-check clean pre-commit pre-commit-install all-checks ci dev-setup stats build-image run-service-local generate-schemas check-schemas # Read the version from pyproject.toml so build-image stays in sync with releases. VERSION := $(shell awk -F'"' '/^version = /{print $$2; exit}' pyproject.toml) @@ -65,7 +65,19 @@ clean: ## Remove generated files and caches find . -type d -name __pycache__ -exec rm -rf {} + find . -type f -name '*.pyc' -delete -all-checks: lint type-check test ## Run all checks (lint, type-check, test) +generate-schemas: ## Regenerate docs/schemas/*.json from the Pydantic models + uv run python scripts/generate_schemas.py + +check-schemas: ## Fail if docs/schemas/*.json is out of sync with the Pydantic models + @uv run python scripts/generate_schemas.py >/dev/null + @if [ -n "$$(git status --porcelain docs/schemas/)" ]; then \ + echo ""; \ + echo "docs/schemas/ is out of sync — run 'make generate-schemas' and commit."; \ + git status --short docs/schemas/; \ + exit 1; \ + fi + +all-checks: lint type-check test check-schemas ## Run all checks (lint, type-check, test, schema drift) @echo "" @echo "All checks passed!" diff --git a/docs/images/audio-refinery-banner.jpg b/docs/images/audio-refinery-banner.jpg index d432708..624ee51 100644 Binary files a/docs/images/audio-refinery-banner.jpg and b/docs/images/audio-refinery-banner.jpg differ diff --git a/docs/schemas/batch-summary-v1.json b/docs/schemas/batch-summary-v1.json new file mode 100644 index 0000000..57ae28b --- /dev/null +++ b/docs/schemas/batch-summary-v1.json @@ -0,0 +1,192 @@ +{ + "$defs": { + "BatchTotals": { + "description": "Per-batch aggregate counts. Always sums to ``submitted``.", + "properties": { + "completed": { + "minimum": 0, + "title": "Completed", + "type": "integer" + }, + "failed": { + "minimum": 0, + "title": "Failed", + "type": "integer" + }, + "submitted": { + "minimum": 0, + "title": "Submitted", + "type": "integer" + } + }, + "required": [ + "submitted", + "completed", + "failed" + ], + "title": "BatchTotals", + "type": "object" + }, + "JobSummaryEntry": { + "description": "One per-job record inside a ``BatchSummary``.\n\nThe ``status`` field discriminates between success and failure shapes.\nOn ``completed``, ``completed_at`` and ``duration_seconds`` are populated\nand ``failed_at``/``stage``/``error``/``retryable`` are None. On ``failed``,\nthe reverse holds.", + "properties": { + "completed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Completed At" + }, + "duration_seconds": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Duration Seconds" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" + }, + "failed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Failed At" + }, + "input_uri": { + "title": "Input Uri", + "type": "string" + }, + "job_id": { + "title": "Job Id", + "type": "string" + }, + "output_uri": { + "title": "Output Uri", + "type": "string" + }, + "retryable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retryable" + }, + "stage": { + "anyOf": [ + { + "enum": [ + "download", + "transcribe", + "upload", + "thermal_shutdown" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stage" + }, + "started_at": { + "format": "date-time", + "title": "Started At", + "type": "string" + }, + "status": { + "enum": [ + "completed", + "failed" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "job_id", + "input_uri", + "output_uri", + "status", + "started_at" + ], + "title": "JobSummaryEntry", + "type": "object" + } + }, + "description": "One summary JSON per accepted batch.\n\nWritten to the caller-supplied ``summary_uri`` after every job in the\nbatch has reached a terminal state. The canonical surface for the\norchestrator to discover batch outcomes \u2014 successful job transcripts are\nalready on the analysis pipeline's wake signal by the time this is\nwritten.", + "properties": { + "batch_id": { + "title": "Batch Id", + "type": "string" + }, + "completed_at": { + "format": "date-time", + "title": "Completed At", + "type": "string" + }, + "jobs": { + "items": { + "$ref": "#/$defs/JobSummaryEntry" + }, + "title": "Jobs", + "type": "array" + }, + "schema_version": { + "default": "1.0.0", + "description": "Semver of the summary schema.", + "title": "Schema Version", + "type": "string" + }, + "submitted_at": { + "format": "date-time", + "title": "Submitted At", + "type": "string" + }, + "totals": { + "$ref": "#/$defs/BatchTotals" + } + }, + "required": [ + "batch_id", + "submitted_at", + "completed_at", + "jobs", + "totals" + ], + "title": "BatchSummary", + "type": "object" +} diff --git a/docs/schemas/combined-transcript-v1.json b/docs/schemas/combined-transcript-v1.json new file mode 100644 index 0000000..32c225c --- /dev/null +++ b/docs/schemas/combined-transcript-v1.json @@ -0,0 +1,566 @@ +{ + "$defs": { + "AudioFileInfo": { + "description": "Metadata about an audio file, typically from soundfile.info().", + "properties": { + "channels": { + "title": "Channels", + "type": "integer" + }, + "duration_seconds": { + "title": "Duration Seconds", + "type": "number" + }, + "format_str": { + "description": "Audio format, e.g. 'WAV'", + "title": "Format Str", + "type": "string" + }, + "frames": { + "title": "Frames", + "type": "integer" + }, + "path": { + "format": "path", + "title": "Path", + "type": "string" + }, + "sample_rate": { + "title": "Sample Rate", + "type": "integer" + }, + "subtype": { + "description": "Audio subtype, e.g. 'PCM_16'", + "title": "Subtype", + "type": "string" + } + }, + "required": [ + "path", + "sample_rate", + "channels", + "duration_seconds", + "frames", + "format_str", + "subtype" + ], + "title": "AudioFileInfo", + "type": "object" + }, + "DiarizationResult": { + "description": "Full provenance record of a Pyannote diarization run.", + "properties": { + "completed_at": { + "format": "date-time", + "title": "Completed At", + "type": "string" + }, + "device": { + "default": "cuda", + "title": "Device", + "type": "string" + }, + "input_file": { + "format": "path", + "title": "Input File", + "type": "string" + }, + "input_info": { + "$ref": "#/$defs/AudioFileInfo" + }, + "max_speakers": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Speakers" + }, + "min_speakers": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Min Speakers" + }, + "model_name": { + "default": "pyannote/speaker-diarization-3.1", + "title": "Model Name", + "type": "string" + }, + "num_speakers": { + "description": "Number of unique speaker clusters detected", + "title": "Num Speakers", + "type": "integer" + }, + "processing_time_seconds": { + "title": "Processing Time Seconds", + "type": "number" + }, + "segments": { + "items": { + "$ref": "#/$defs/SpeakerSegment" + }, + "title": "Segments", + "type": "array" + }, + "started_at": { + "format": "date-time", + "title": "Started At", + "type": "string" + } + }, + "required": [ + "input_file", + "input_info", + "segments", + "num_speakers", + "processing_time_seconds", + "started_at", + "completed_at" + ], + "title": "DiarizationResult", + "type": "object" + }, + "SegmentSentiment": { + "description": "Sentiment analysis result for a single transcript segment.", + "properties": { + "end": { + "title": "End", + "type": "number" + }, + "primary_sentiment": { + "title": "Primary Sentiment", + "type": "string" + }, + "scores": { + "items": { + "$ref": "#/$defs/SentimentScore" + }, + "title": "Scores", + "type": "array" + }, + "speaker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Speaker" + }, + "start": { + "title": "Start", + "type": "number" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Text" + } + }, + "required": [ + "start", + "end", + "scores", + "primary_sentiment" + ], + "title": "SegmentSentiment", + "type": "object" + }, + "SentimentResult": { + "description": "Full provenance record of a sentiment analysis run.", + "properties": { + "completed_at": { + "format": "date-time", + "title": "Completed At", + "type": "string" + }, + "device": { + "title": "Device", + "type": "string" + }, + "model_name": { + "default": "cardiffnlp/twitter-roberta-base-sentiment-latest", + "title": "Model Name", + "type": "string" + }, + "processing_time_seconds": { + "title": "Processing Time Seconds", + "type": "number" + }, + "segments": { + "items": { + "$ref": "#/$defs/SegmentSentiment" + }, + "title": "Segments", + "type": "array" + }, + "started_at": { + "format": "date-time", + "title": "Started At", + "type": "string" + }, + "transcription_file": { + "format": "path", + "title": "Transcription File", + "type": "string" + } + }, + "required": [ + "transcription_file", + "segments", + "device", + "processing_time_seconds", + "started_at", + "completed_at" + ], + "title": "SentimentResult", + "type": "object" + }, + "SentimentScore": { + "description": "A single sentiment class score from the classifier.", + "properties": { + "label": { + "title": "Label", + "type": "string" + }, + "score": { + "title": "Score", + "type": "number" + } + }, + "required": [ + "label", + "score" + ], + "title": "SentimentScore", + "type": "object" + }, + "SpeakerSegment": { + "description": "A single speaker turn detected by diarization.", + "properties": { + "duration_seconds": { + "readOnly": true, + "title": "Duration Seconds", + "type": "number" + }, + "end_seconds": { + "title": "End Seconds", + "type": "number" + }, + "speaker_label": { + "description": "Speaker cluster label, e.g. 'SPEAKER_00'", + "title": "Speaker Label", + "type": "string" + }, + "start_seconds": { + "title": "Start Seconds", + "type": "number" + } + }, + "required": [ + "speaker_label", + "start_seconds", + "end_seconds", + "duration_seconds" + ], + "title": "SpeakerSegment", + "type": "object" + }, + "TranscriptSegment": { + "description": "A sentence or clause boundary segment with word-level breakdown.", + "properties": { + "end": { + "title": "End", + "type": "number" + }, + "sentiment": { + "anyOf": [ + { + "$ref": "#/$defs/SegmentSentiment" + }, + { + "type": "null" + } + ], + "default": null + }, + "speaker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Speaker" + }, + "start": { + "title": "Start", + "type": "number" + }, + "text": { + "title": "Text", + "type": "string" + }, + "words": { + "items": { + "$ref": "#/$defs/WordSegment" + }, + "title": "Words", + "type": "array" + } + }, + "required": [ + "text", + "start", + "end" + ], + "title": "TranscriptSegment", + "type": "object" + }, + "TranscriptionResult": { + "description": "Full provenance record of a WhisperX transcription run.", + "properties": { + "alignment_fallback": { + "default": false, + "title": "Alignment Fallback", + "type": "boolean" + }, + "batch_size": { + "title": "Batch Size", + "type": "integer" + }, + "completed_at": { + "format": "date-time", + "title": "Completed At", + "type": "string" + }, + "compute_type": { + "title": "Compute Type", + "type": "string" + }, + "device": { + "title": "Device", + "type": "string" + }, + "diarization_applied": { + "default": false, + "title": "Diarization Applied", + "type": "boolean" + }, + "diarization_file": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Diarization File" + }, + "input_file": { + "format": "path", + "title": "Input File", + "type": "string" + }, + "input_info": { + "$ref": "#/$defs/AudioFileInfo" + }, + "language": { + "description": "Language code, e.g. 'en'", + "title": "Language", + "type": "string" + }, + "language_probability": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Language Probability" + }, + "model_name": { + "default": "large-v3", + "title": "Model Name", + "type": "string" + }, + "processing_time_seconds": { + "title": "Processing Time Seconds", + "type": "number" + }, + "segments": { + "items": { + "$ref": "#/$defs/TranscriptSegment" + }, + "title": "Segments", + "type": "array" + }, + "sentiment_applied": { + "default": false, + "title": "Sentiment Applied", + "type": "boolean" + }, + "started_at": { + "format": "date-time", + "title": "Started At", + "type": "string" + } + }, + "required": [ + "input_file", + "input_info", + "language", + "segments", + "device", + "compute_type", + "batch_size", + "processing_time_seconds", + "started_at", + "completed_at" + ], + "title": "TranscriptionResult", + "type": "object" + }, + "WordSegment": { + "description": "A single word with timing and optional speaker assignment.", + "properties": { + "end": { + "title": "End", + "type": "number" + }, + "score": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Score" + }, + "speaker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Speaker" + }, + "start": { + "title": "Start", + "type": "number" + }, + "word": { + "description": "Transcribed word (may include surrounding whitespace)", + "title": "Word", + "type": "string" + } + }, + "required": [ + "word", + "start", + "end" + ], + "title": "WordSegment", + "type": "object" + } + }, + "description": "One combined-output JSON per successful job.\n\nWraps the existing per-stage Pydantic models so the consuming analysis\npipeline reads a single document instead of stitching diarization +\ntranscription + sentiment together itself.", + "properties": { + "audio": { + "$ref": "#/$defs/AudioFileInfo", + "description": "Input audio metadata." + }, + "audio_refinery_version": { + "description": "Version of audio-refinery that produced this document.", + "title": "Audio Refinery Version", + "type": "string" + }, + "diarization": { + "$ref": "#/$defs/DiarizationResult", + "description": "Full pyannote diarization result." + }, + "model_versions": { + "additionalProperties": { + "type": "string" + }, + "description": "Per-stage model identifiers for quick lookup.", + "title": "Model Versions", + "type": "object" + }, + "processed_at": { + "description": "When this combined document was assembled (UTC).", + "format": "date-time", + "title": "Processed At", + "type": "string" + }, + "schema_version": { + "default": "1.0.0", + "description": "Semver of the transcript schema.", + "title": "Schema Version", + "type": "string" + }, + "sentiment": { + "anyOf": [ + { + "$ref": "#/$defs/SentimentResult" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Sentiment result, present only when enabled." + }, + "transcription": { + "$ref": "#/$defs/TranscriptionResult", + "description": "Full WhisperX transcription result." + } + }, + "required": [ + "audio_refinery_version", + "processed_at", + "audio", + "diarization", + "transcription", + "model_versions" + ], + "title": "CombinedTranscript", + "type": "object" +} diff --git a/docs/service.md b/docs/service.md index e6b83e6..f597b67 100644 --- a/docs/service.md +++ b/docs/service.md @@ -225,6 +225,56 @@ batch outcomes. `stage` is one of `download`, `transcribe`, `upload`, `thermal_shutdown`. +### Validating against the schema + +JSON Schemas for both documents above ship with every release. They live in +the repo under `docs/schemas/` and are attached as assets on each GitHub +release, so consumers who don't import the Python package have an +authoritative artifact to validate against: + +- `combined-transcript-v1.json` +- `batch-summary-v1.json` + +Each file is generated from the same Pydantic models the service uses, in +serialization mode (matching the bytes you'll actually receive). A CI drift +check fails the build if a model change isn't accompanied by a schema regen, +so the artifact stays in lockstep with the producer. + +**Pin to a release tag, not `main`.** Fetch the schema for the version of +Audio Refinery you're consuming: + +``` +https://raw.githubusercontent.com/LunarCommand/audio-refinery/v0.2.2/docs/schemas/combined-transcript-v1.json +``` + +The major-version suffix in the filename only changes when the document +shape breaks (e.g., when v0.3.0 alignment lands, a `-v2.json` will appear +next to the v1 file). Minor and patch AR releases will not change +`-v1.json` in a breaking way; consumers can safely pin to a tag and bump on +their own cadence. + +Example with Python `jsonschema`: + +```python +import json +import urllib.request + +import jsonschema + +url = "https://raw.githubusercontent.com/LunarCommand/audio-refinery/v0.2.2/docs/schemas/combined-transcript-v1.json" +schema = json.loads(urllib.request.urlopen(url).read()) + +with open("transcript.json") as f: + jsonschema.validate(instance=json.load(f), schema=schema) +``` + +Note: `WordSegment.speaker`, `TranscriptSegment.speaker`, and the sentiment +`speaker` fields are nullable strings. Word-level speaker can be `null` +when WhisperX's word-level diarization join doesn't intersect any +pyannote span; from v0.2.2 onward the producer falls back to the enclosing +segment's speaker in that case, so this is now rare in practice but +remains permitted by the schema. + --- ## Environment variables diff --git a/pyproject.toml b/pyproject.toml index 8c750bc..5822cd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "audio-refinery" -version = "0.2.1" +version = "0.2.2" description = "GPU-accelerated audio processing pipeline: vocal separation, speaker diarization, transcription, and sentiment analysis." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/generate_schemas.py b/scripts/generate_schemas.py new file mode 100644 index 0000000..2d21258 --- /dev/null +++ b/scripts/generate_schemas.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Regenerate the published JSON Schemas under docs/schemas/. + +The Pydantic models in ``src/`` are the source of truth for audio-refinery's +output shapes; this script materializes them as JSON Schema artifacts so +downstream consumers (who don't have access to the Python package) can +validate transcript and batch-summary documents. + +Run via ``make generate-schemas``. CI invokes ``make check-schemas`` which +runs this script then ``git diff --exit-code docs/schemas/`` — so any model +change that affects the published shape must be accompanied by a regen. + +Note: Pydantic's JSON Schema output is deterministic within a Pydantic +version. A Pydantic upgrade may legitimately reshuffle the schema (key +order, ``$defs`` naming); the drift check will fail and force a deliberate +regen, which is the intended behavior. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from src.service.schemas import BatchSummary, CombinedTranscript # noqa: E402 + +SCHEMAS_DIR = REPO_ROOT / "docs" / "schemas" + +SCHEMA_TARGETS: list[tuple[str, Any]] = [ + ("combined-transcript-v1.json", CombinedTranscript), + ("batch-summary-v1.json", BatchSummary), +] + + +def _dump(schema: dict[str, Any]) -> str: + return json.dumps(schema, indent=2, sort_keys=True) + "\n" + + +def generate() -> list[Path]: + SCHEMAS_DIR.mkdir(parents=True, exist_ok=True) + written: list[Path] = [] + for filename, model in SCHEMA_TARGETS: + path = SCHEMAS_DIR / filename + path.write_text(_dump(model.model_json_schema(mode="serialization"))) + written.append(path) + return written + + +if __name__ == "__main__": + for path in generate(): + print(f"wrote {path.relative_to(REPO_ROOT)}") diff --git a/src/transcriber.py b/src/transcriber.py index 57bd59d..d7af9dd 100644 --- a/src/transcriber.py +++ b/src/transcriber.py @@ -235,15 +235,18 @@ def _build_segments(raw_segments: list[dict]) -> list[TranscriptSegment]: """Convert whisperx segment dicts to TranscriptSegment models.""" segments = [] for seg in raw_segments: + seg_speaker = seg.get("speaker") words = [] for w in seg.get("words", []): + # Fall back to the enclosing segment's speaker when WhisperX's + # word-level diarization join misses (boundary-overlap gap). words.append( WordSegment( word=w.get("word", ""), start=w.get("start") or 0.0, end=w.get("end") or 0.0, score=w.get("score"), - speaker=w.get("speaker"), + speaker=w.get("speaker") or seg_speaker, ) ) segments.append( @@ -252,7 +255,7 @@ def _build_segments(raw_segments: list[dict]) -> list[TranscriptSegment]: start=seg.get("start", 0.0), end=seg.get("end", 0.0), words=words, - speaker=seg.get("speaker"), + speaker=seg_speaker, ) ) return segments diff --git a/tests/test_schemas_drift.py b/tests/test_schemas_drift.py new file mode 100644 index 0000000..83177fc --- /dev/null +++ b/tests/test_schemas_drift.py @@ -0,0 +1,27 @@ +"""Drift check: the committed docs/schemas/*.json files must match what +``scripts/generate_schemas.py`` would produce against the current Pydantic +models. CI runs this; if it fails, run ``make generate-schemas`` to bring +the committed artifacts back in sync. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from pydantic import BaseModel + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from generate_schemas import SCHEMA_TARGETS, SCHEMAS_DIR, _dump # noqa: E402 + + +@pytest.mark.parametrize(("filename", "model"), SCHEMA_TARGETS) +def test_committed_schema_matches_model(filename: str, model: type[BaseModel]) -> None: + expected = _dump(model.model_json_schema(mode="serialization")) + committed = (SCHEMAS_DIR / filename).read_text() + assert committed == expected, ( + f"docs/schemas/{filename} is out of sync with the Pydantic model. Run `make generate-schemas` to regenerate." + ) diff --git a/tests/test_transcriber.py b/tests/test_transcriber.py index cbedf70..c376f09 100644 --- a/tests/test_transcriber.py +++ b/tests/test_transcriber.py @@ -156,6 +156,42 @@ def test_missing_optional_fields_handled(self): assert segs[0].words == [] assert segs[0].speaker is None + def test_word_speaker_falls_back_to_segment(self): + """When the WhisperX↔pyannote word-level join misses, the word inherits + the enclosing segment's speaker rather than being emitted as null.""" + raw = [ + { + "text": "looks really well", + "start": 135.0, + "end": 136.0, + "speaker": "SPEAKER_00", + "words": [ + {"word": "looks", "start": 135.28, "end": 135.54, "speaker": "SPEAKER_00"}, + {"word": "really", "start": 135.56, "end": 135.74, "speaker": "SPEAKER_00"}, + # Trailing-edge word whose diarization join missed — no + # "speaker" key in the dict. + {"word": "well", "start": 135.90, "end": 135.98}, + ], + } + ] + segs = _build_segments(raw) + assert [w.speaker for w in segs[0].words] == ["SPEAKER_00", "SPEAKER_00", "SPEAKER_00"] + + def test_word_speaker_stays_none_when_segment_also_none(self): + """When diarization wasn't applied, both segment and word speakers + remain None — the fallback doesn't fabricate a label.""" + raw = [ + { + "text": "Hi", + "start": 0.0, + "end": 0.5, + "words": [{"word": "Hi", "start": 0.0, "end": 0.4}], + } + ] + segs = _build_segments(raw) + assert segs[0].speaker is None + assert segs[0].words[0].speaker is None + # --------------------------------------------------------------------------- # transcribe() — error paths diff --git a/uv.lock b/uv.lock index 13d3da7..7ee073d 100644 --- a/uv.lock +++ b/uv.lock @@ -139,7 +139,7 @@ wheels = [ [[package]] name = "audio-refinery" -version = "0.2.1" +version = "0.2.2" source = { editable = "." } dependencies = [ { name = "click" },