From 9cc75c3d91bf0965a6ec509ebcc7d606bf1da96d Mon Sep 17 00:00:00 2001 From: Yoann Date: Fri, 31 Jul 2026 14:24:28 +0200 Subject: [PATCH 1/3] feat: vox models lists transcription models per backend Derives the listing from the WhisperModel and OpenAIModel enums so the output can never drift from what --model actually accepts, and ships the matching JSON schema for agent introspection. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7cceyUMrUTJrqqVc28bHH --- README.md | 7 +++- src/vox/adapters/cli/app.py | 2 ++ src/vox/adapters/cli/models_cmd.py | 36 +++++++++++++++++++ src/vox/adapters/cli/output_formatter.py | 4 +-- src/vox/models/whisper_model.py | 4 +++ src/vox/schemas/models.json | 26 ++++++++++++++ src/vox/use_cases/list_models.py | 21 +++++++++++ tests/unit/adapters/test_models_cmd.py | 33 +++++++++++++++++ tests/unit/models/test_whisper_model.py | 15 ++++++++ tests/unit/use_cases/test_list_models.py | 46 ++++++++++++++++++++++++ 10 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 src/vox/adapters/cli/models_cmd.py create mode 100644 src/vox/schemas/models.json create mode 100644 src/vox/use_cases/list_models.py create mode 100644 tests/unit/adapters/test_models_cmd.py create mode 100644 tests/unit/use_cases/test_list_models.py diff --git a/README.md b/README.md index 2b9dddb..a3d8307 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ vox doctor # Download a model ahead of time vox init -m small +# List available models per backend +vox models +vox models -b openai --format json + # Get API schema (for AI agents) vox schema transcribe ``` @@ -80,6 +84,7 @@ vox schema transcribe | `vox channel ` | Batch transcribe a YouTube channel | | `vox init [-m MODEL]` | Download Whisper model | | `vox doctor` | Check dependency health | +| `vox models [-b BACKEND]` | List transcription models per backend | | `vox schema [COMMAND]` | JSON schema for agent introspection | ## Models @@ -139,7 +144,7 @@ Input (URL or file) ```bash uv sync -uv run pytest # Run tests (107 tests) +uv run pytest # Run tests (159 tests) uv run ruff check --fix # Lint uv run ruff format # Format uv run ty check # Type check diff --git a/src/vox/adapters/cli/app.py b/src/vox/adapters/cli/app.py index 6450152..eb465d6 100644 --- a/src/vox/adapters/cli/app.py +++ b/src/vox/adapters/cli/app.py @@ -5,6 +5,7 @@ from vox.adapters.cli.channel_cmd import channel from vox.adapters.cli.doctor_cmd import doctor from vox.adapters.cli.init_cmd import init +from vox.adapters.cli.models_cmd import models from vox.adapters.cli.schema_cmd import schema from vox.adapters.cli.transcribe_cmd import transcribe @@ -26,6 +27,7 @@ def main() -> None: main.add_command(init) main.add_command(doctor) main.add_command(schema) +main.add_command(models) def _is_agent_mode() -> bool: diff --git a/src/vox/adapters/cli/models_cmd.py b/src/vox/adapters/cli/models_cmd.py new file mode 100644 index 0000000..99804e7 --- /dev/null +++ b/src/vox/adapters/cli/models_cmd.py @@ -0,0 +1,36 @@ +import json +import sys + +import click + +from vox.adapters.cli.output_formatter import resolve_format +from vox.models.exceptions import VoxError +from vox.use_cases.list_models import ListModelsUseCase + + +@click.command() +@click.option("-b", "--backend", default=None, help="local | openai") +@click.option("--format", "fmt", default=None, help="json|table") +def models(backend, fmt): + try: + listed = ListModelsUseCase().execute(backend) + except VoxError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + click.echo(_render(listed, resolve_format(fmt))) + + +def _render(listed: dict[str, tuple[str, ...]], output_format: str) -> str: + if output_format == "json": + return json.dumps({k: list(v) for k, v in listed.items()}, indent=2) + return _format_table(listed) + + +def _format_table(listed: dict[str, tuple[str, ...]]) -> str: + max_backend = max(len(name) for name in listed) + rows = [ + f" {name.ljust(max_backend)} {model}" + for name, models_for_backend in listed.items() + for model in models_for_backend + ] + return "\n".join(rows) diff --git a/src/vox/adapters/cli/output_formatter.py b/src/vox/adapters/cli/output_formatter.py index f09d99f..7144784 100644 --- a/src/vox/adapters/cli/output_formatter.py +++ b/src/vox/adapters/cli/output_formatter.py @@ -5,7 +5,7 @@ def format_output(data: Any, fields: str | None, fmt: str | None) -> str: - output_format = _resolve_format(fmt) + output_format = resolve_format(fmt) as_dict = asdict(data) if hasattr(data, "__dataclass_fields__") else data filtered = _filter_fields(as_dict, fields) if output_format == "json": @@ -13,7 +13,7 @@ def format_output(data: Any, fields: str | None, fmt: str | None) -> str: return _format_table(filtered) -def _resolve_format(fmt: str | None) -> str: +def resolve_format(fmt: str | None) -> str: if fmt: return fmt return "json" if not sys.stdout.isatty() else "table" diff --git a/src/vox/models/whisper_model.py b/src/vox/models/whisper_model.py index 81f9958..4a98d75 100644 --- a/src/vox/models/whisper_model.py +++ b/src/vox/models/whisper_model.py @@ -20,6 +20,10 @@ class WhisperModel(Enum): def hf_repo(self) -> str: return self.value + @property + def cli_name(self) -> str: + return self.name.lower().replace("_", "-") + @classmethod def from_string(cls, name: str) -> "WhisperModel": normalized = _normalize(name) diff --git a/src/vox/schemas/models.json b/src/vox/schemas/models.json new file mode 100644 index 0000000..7716b8f --- /dev/null +++ b/src/vox/schemas/models.json @@ -0,0 +1,26 @@ +{ + "name": "models", + "description": "List the transcription models available for each backend", + "arguments": {}, + "options": { + "backend": { + "type": "string", + "enum": ["local", "openai"], + "description": "Restrict the listing to a single backend (default: all backends)" + }, + "format": { + "type": "string", + "enum": ["json", "table"], + "description": "Output format (auto-detected: json when piped, table when TTY)" + } + }, + "output": { + "local": "Model names accepted by --model on the local MLX backend", + "openai": "Model names accepted by --model on the OpenAI cloud backend" + }, + "examples": [ + "vox models", + "vox models -b openai --format json" + ], + "dependencies": [] +} diff --git a/src/vox/use_cases/list_models.py b/src/vox/use_cases/list_models.py new file mode 100644 index 0000000..82984a9 --- /dev/null +++ b/src/vox/use_cases/list_models.py @@ -0,0 +1,21 @@ +from vox.models.openai_model import OpenAIModel +from vox.models.transcription_backend import TranscriptionBackend +from vox.models.whisper_model import WhisperModel + + +class ListModelsUseCase: + def execute(self, backend: str | None) -> dict[str, tuple[str, ...]]: + if backend is None: + return {name: _models_for(name) for name in _backend_names()} + resolved = TranscriptionBackend.from_string(backend) + return {resolved.value: _models_for(resolved.value)} + + +def _backend_names() -> tuple[str, ...]: + return tuple(backend.value for backend in TranscriptionBackend) + + +def _models_for(backend_name: str) -> tuple[str, ...]: + if backend_name == TranscriptionBackend.OPENAI.value: + return tuple(model.api_name for model in OpenAIModel) + return tuple(model.cli_name for model in WhisperModel) diff --git a/tests/unit/adapters/test_models_cmd.py b/tests/unit/adapters/test_models_cmd.py new file mode 100644 index 0000000..fe355e9 --- /dev/null +++ b/tests/unit/adapters/test_models_cmd.py @@ -0,0 +1,33 @@ +import json + +from click.testing import CliRunner + +from vox.adapters.cli.models_cmd import models + + +class TestModelsCommand: + def test_models_when_json_format_then_lists_every_backend(self): + result = CliRunner().invoke(models, ["--format", "json"]) + + payload = json.loads(result.output) + assert set(payload) == {"local", "openai"} + assert "large-v3-turbo" in payload["local"] + assert "gpt-4o-transcribe" in payload["openai"] + + def test_models_when_backend_filter_then_lists_only_that_backend(self): + result = CliRunner().invoke(models, ["-b", "openai", "--format", "json"]) + + assert set(json.loads(result.output)) == {"openai"} + + def test_models_when_table_format_then_pairs_backend_and_model(self): + result = CliRunner().invoke(models, ["-b", "local", "--format", "table"]) + + assert "local" in result.output + assert "small" in result.output + assert result.output.count("local") == len(result.output.strip().splitlines()) + + def test_models_when_unknown_backend_then_exits_with_error(self): + result = CliRunner().invoke(models, ["-b", "vercel"]) + + assert result.exit_code == 1 + assert "Unknown backend" in result.output diff --git a/tests/unit/models/test_whisper_model.py b/tests/unit/models/test_whisper_model.py index d03b222..d3a4918 100644 --- a/tests/unit/models/test_whisper_model.py +++ b/tests/unit/models/test_whisper_model.py @@ -40,6 +40,21 @@ def test_from_string_when_large_v3_turbo_underscore_then_turbo_model(self): assert result == WhisperModel.LARGE_V3_TURBO +class TestWhisperModelCliName: + def test_cli_name_when_small_then_small(self): + assert WhisperModel.SMALL.cli_name == "small" + + def test_cli_name_when_large_v3_then_dashed(self): + assert WhisperModel.LARGE_V3.cli_name == "large-v3" + + def test_cli_name_when_large_v3_turbo_then_dashed(self): + assert WhisperModel.LARGE_V3_TURBO.cli_name == "large-v3-turbo" + + def test_cli_name_when_round_tripped_then_resolves_to_same_model(self): + for model in WhisperModel: + assert WhisperModel.from_string(model.cli_name) == model + + class TestWhisperModelHfRepo: def test_hf_repo_when_small_then_correct_path(self): assert WhisperModel.SMALL.hf_repo == "mlx-community/whisper-small-mlx" diff --git a/tests/unit/use_cases/test_list_models.py b/tests/unit/use_cases/test_list_models.py new file mode 100644 index 0000000..b1c8b94 --- /dev/null +++ b/tests/unit/use_cases/test_list_models.py @@ -0,0 +1,46 @@ +import pytest + +from vox.models.exceptions import ValidationError +from vox.use_cases.list_models import ListModelsUseCase + + +class TestListModelsUseCase: + def test_execute_when_no_backend_then_lists_every_backend(self): + result = ListModelsUseCase().execute(None) + + assert set(result) == {"local", "openai"} + + def test_execute_when_local_backend_then_lists_only_local(self): + result = ListModelsUseCase().execute("local") + + assert set(result) == {"local"} + + def test_execute_when_openai_backend_then_lists_only_openai(self): + result = ListModelsUseCase().execute("openai") + + assert set(result) == {"openai"} + + def test_execute_when_local_backend_then_returns_cli_model_names(self): + result = ListModelsUseCase().execute("local") + + assert result["local"] == ( + "tiny", + "base", + "small", + "medium", + "large-v3", + "large-v3-turbo", + ) + + def test_execute_when_openai_backend_then_returns_api_model_names(self): + result = ListModelsUseCase().execute("openai") + + assert result["openai"] == ( + "gpt-4o-transcribe", + "gpt-4o-mini-transcribe", + "whisper-1", + ) + + def test_execute_when_unknown_backend_then_raises(self): + with pytest.raises(ValidationError, match="Unknown backend"): + ListModelsUseCase().execute("vercel") From de93d57e0641c5fbec901dc7919a35e791f77bf7 Mon Sep 17 00:00:00 2001 From: Yoann Date: Fri, 31 Jul 2026 14:24:34 +0200 Subject: [PATCH 2/3] ci: lint/test on PR and PyPI release on version bump Tests run on macos-latest since mlx-whisper only installs on Apple Silicon; the publish job runs on ubuntu and uses PyPI trusted publishing (OIDC), so no token is stored. Publishing requires declaring the trusted publisher on pypi.org first. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7cceyUMrUTJrqqVc28bHH --- .github/workflows/ci.yml | 29 ++++++++++++++ .github/workflows/release.yml | 74 +++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..668cce8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + push: + branches: [master] + +jobs: + test: + # mlx-whisper only installs on Apple Silicon + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --frozen + + - name: Lint + run: uv run ruff check + + - name: Format check + run: uv run ruff format --check + + - name: Test + run: uv run pytest -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2a671ce --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Release + +on: + push: + branches: [master] + paths: + - pyproject.toml + +concurrency: + group: release + cancel-in-progress: false + +jobs: + test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --frozen + + - name: Lint + run: uv run ruff check + + - name: Test + run: uv run pytest -q + + publish: + needs: test + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Detect version change + id: version + run: | + LOCAL=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + PUBLISHED=$(curl -fsSL https://pypi.org/pypi/vox-transcribe/json \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['info']['version'])" 2>/dev/null || echo "0.0.0") + echo "local=$LOCAL" >> "$GITHUB_OUTPUT" + if [ "$LOCAL" != "$PUBLISHED" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Version changed: $PUBLISHED -> $LOCAL" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "Version unchanged ($LOCAL), skipping publish" + fi + + - name: Build distributions + if: steps.version.outputs.changed == 'true' + run: uv build + + - name: Publish to PyPI + if: steps.version.outputs.changed == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Tag and GitHub Release + if: steps.version.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.local }} + run: | + git tag "v$VERSION" + git push origin "v$VERSION" + gh release create "v$VERSION" --title "v$VERSION" --generate-notes From 1c3f9c423e8cceb8e321a815a9629d119fbc83f4 Mon Sep 17 00:00:00 2001 From: Yoann Date: Fri, 31 Jul 2026 14:24:43 +0200 Subject: [PATCH 3/3] docs: domain-neutral whisper-fixes reference for the skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves post-processing guidance out of SKILL.md into a reference covering what Whisper actually gets wrong on any audio — silence hallucinations, repetition loops, diacritics, homophones, segment boundaries — with the rule that corrections touch form, never content. Also documents the new models command. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7cceyUMrUTJrqqVc28bHH --- skills/vox/SKILL.md | 42 ++++---- skills/vox/references/whisper-fixes.md | 133 +++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 24 deletions(-) create mode 100644 skills/vox/references/whisper-fixes.md diff --git a/skills/vox/SKILL.md b/skills/vox/SKILL.md index f309c79..cbca6b2 100644 --- a/skills/vox/SKILL.md +++ b/skills/vox/SKILL.md @@ -33,32 +33,20 @@ vox transcribe audio.wav --fields text --format json ``` ### 3. Post-process the transcript -After receiving the raw transcript, fix common Whisper mistakes: +Read [references/whisper-fixes.md](references/whisper-fixes.md) before editing a +raw transcript — it lists the mistakes Whisper actually makes and how to fix +them without altering the record. -**Punctuation & Capitalization:** -- Fix sentence boundaries and misplaced commas -- Capitalize proper nouns and sentence starts +The high-value passes, in order: +1. Delete hallucinated blocks on silence/music ("Sous-titres réalisés par...", + "Thanks for watching!") and repetition loops +2. Restore sentence boundaries, capitalization, and diacritics +3. Fix homophones, numbers, units, and acronyms +4. Merge words truncated across segment boundaries -**Language-Specific Accents:** -- Spanish: como→cómo, esta→está, mas→más -- French: e→é, a→à, u→ù -- Portuguese: a→ã, o→ão - -**Technical Terms:** -- Fix domain-specific misspellings -- Correct proper nouns (product names, people) - -**Repeated Phrases:** -- Remove stutters and exact word duplicates at segment boundaries - -**Speaker Attribution:** -- Insert `[Speaker Name]:` markers when identifiable - -**Filler Words:** -- Remove um, uh, este, o sea, like, you know (if requested) - -**Timestamp Alignment:** -- Preserve SRT structure when editing text +Fix form, never content: keep timestamps and SRT structure intact, ask the user +for a glossary rather than guessing names, and mark unintelligible passages +`[inaudible]` instead of inventing. ### 4. Batch transcribe a channel ```bash @@ -78,6 +66,11 @@ vox channel "https://www.youtube.com/@ChannelName" --years 2025 --summarizer non ```bash vox schema transcribe vox schema init +vox schema models + +# Which models can I pass to --model? +vox models --format json +vox models -b openai --format json ``` ## Commands @@ -89,6 +82,7 @@ vox schema init | `vox channel ` | Batch transcribe a YouTube channel | | `vox init [-m MODEL] [-l LANG]` | Download Whisper model + check deps | | `vox doctor` | Check dependencies health | +| `vox models [-b BACKEND]` | List models available per backend | | `vox schema [COMMAND]` | JSON schema for agent introspection | ## Transcribe Flags diff --git a/skills/vox/references/whisper-fixes.md b/skills/vox/references/whisper-fixes.md new file mode 100644 index 0000000..ea8d4eb --- /dev/null +++ b/skills/vox/references/whisper-fixes.md @@ -0,0 +1,133 @@ +# Common Whisper Transcription Mistakes + +Reference for post-processing a raw transcript. Domain-agnostic: it covers the +errors Whisper makes on any audio, in any field. + +## Ground rules + +- Fix **form**, never **content**. Do not reword, summarize, or repair the + speaker's grammar — a transcript is a record of what was said. +- Keep SRT structure intact: same number of blocks, same timestamps. Edit the + text inside a block, never the timing line. +- Never invent. If a passage is unintelligible, mark it `[inaudible]` rather + than guessing. +- Ask the user for a glossary (names, acronyms, jargon) instead of guessing + domain-specific spellings. + +## Hallucinations on silence and music + +The single most common Whisper failure. On silence, music, or background noise +it emits training-set boilerplate that was never spoken. Delete these blocks — +do not try to "correct" them. + +Typical artifacts: + +- `Sous-titres réalisés par la communauté d'Amara.org` +- `Merci d'avoir regardé cette vidéo`, `Abonnez-vous !` +- `Thanks for watching!`, `Please subscribe`, `Subtitles by ...` +- `Amara.org`, `♪`, `[Music]`, `[Applause]` +- A closing credit line duplicated at the very end of the file + +Suspicion signals: the block sits over a long timestamp gap, its wording is +unrelated to everything around it, or it appears verbatim at both start and end. + +## Repetition loops + +Whisper can lock onto a phrase and repeat it across consecutive blocks, usually +over silence or noise. Keep the first occurrence, drop the loop. Check the +timestamps: a loop often spans an implausibly long stretch for that little text. + +## Punctuation and capitalization + +- Restore sentence boundaries. Whisper produces run-on sentences; split where + the topic changes, the speaker changes, or the SRT shows a pause > 1.5 s. +- Capitalize sentence starts and proper nouns. +- Languages with paired marks: Spanish needs the opening `¿` and `¡`, which + Whisper almost never emits. +- Quotation marks and apostrophes are often ASCII; normalize to the language's + convention if the user cares about typography. + +## Diacritics + +Whisper drops accents, especially on short function words. Restore from +grammatical context, never blindly search-and-replace. + +**French** — the frequent pairs: +`a`/`à`, `ou`/`où`, `sur`/`sûr`, `du`/`dû`, `la`/`là`, `des`/`dès`, +`ete`/`été`, `deja`/`déjà`, `tres`/`très`, `apres`/`après`. + +**Spanish**: +`como`/`cómo` (question), `esta`/`está` (verb), `mas`/`más` (quantity), +`si`/`sí` (yes), `el`/`él` (pronoun), `que`/`qué` (question), +`tambien`/`también`, `informacion`/`información`. + +**Portuguese**: +`nao`/`não`, `voce`/`você`, `esta`/`está`, `sao`/`são`, `ja`/`já`. + +**German**: umlauts and `ß` may come back as `ae/oe/ue/ss` — restore them. + +## Homophones + +Only context disambiguates these; Whisper picks the most frequent form. + +**French**: `et`/`est`, `on`/`ont`, `son`/`sont`, `ce`/`se`, `ces`/`ses`/`c'est`/`s'est`, +`peu`/`peut`, `quand`/`quant`, `leur`/`leurs`, `tout`/`tous`. + +**English**: `their`/`there`/`they're`, `its`/`it's`, `your`/`you're`, +`to`/`too`/`two`, `then`/`than`, `affect`/`effect`. + +**Spanish**: `haber`/`a ver`, `hay`/`ahí`/`ay`, `porque`/`por qué`. + +## Numbers, units, dates + +Whisper is inconsistent — sometimes digits, sometimes words, within the same +file. Pick one convention and apply it throughout: + +- Spelled-out numbers that should be digits: percentages, prices, measurements, + version numbers, years. +- Units glued to the number (`10km` → `10 km`), and locale spacing (French puts + a space before `%`, `€`, `:`, `?`, `!`). +- Decimal separator: `,` in French/Spanish/German, `.` in English. Whisper + mixes them. +- Spoken dates and times ("le premier mars", "half past three") — normalize only + if the user asked for it. + +## Proper nouns, acronyms, borrowed words + +- Acronyms come out spaced or lowercased (`s a s`, `sas` → `SAS`). +- Person, place, and product names are the most error-prone tokens in any + transcript. Prefer the user's glossary; otherwise flag rather than invent. +- Borrowed English words inside another language are often transcribed + phonetically — restore the standard spelling. + +## Segment boundaries + +- A word cut across two blocks appears truncated in one and duplicated in the + other. Merge it into the block where the word begins. +- Stutters and exact duplicates at a boundary (`the the`, `je je`) are usually + segmentation artifacts, not speech. +- A block with text but a near-zero duration is suspect. + +## Language detection + +Whisper detects the language from the first ~30 seconds. Consequences: + +- Audio that opens with music, an intro jingle, or a foreign greeting can be + detected wrong, and the whole file is then transcribed — or translated — + into that language. Re-run with `-l `. +- Code-switching mid-file is transcribed in the detected language, sometimes + phonetically. There is no fix in post-processing; re-run on the relevant + section. + +## Filler words + +`um`, `uh`, `euh`, `este`, `o sea`, `like`, `you know`, `voilà`, `en fait`. +Remove them **only if the user asks** — they are part of the record, and +stripping them silently changes the transcript's fidelity. + +## Speaker attribution + +Whisper does not do diarization: it produces one undifferentiated stream. If +speakers are identifiable from context, prefix blocks with `[Name]:`, but never +guess who is talking. For real speaker separation, a diarization tool is +required.