From 0c197324f5444b448c285d2a57bd0a5834c2fc84 Mon Sep 17 00:00:00 2001 From: historia Date: Thu, 20 Aug 2026 22:39:04 +0000 Subject: feat: warn on empty transcripts and missing whisper backend in server.json tool --- converter/tts.py | 18 ++++++++ tests/test_make_audiocpp_server_json.py | 76 +++++++++++++++++++++++++++++++-- tools/make_audiocpp_server_json.py | 36 +++++++++++++++- 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/converter/tts.py b/converter/tts.py index afb0655..142cf6d 100644 --- a/converter/tts.py +++ b/converter/tts.py @@ -179,6 +179,24 @@ def transcribe_reference_audio(audio_path: str, model_name: str = "base") -> Opt return None +def whisper_backend_available() -> Optional[str]: + """Return the name of an importable Whisper backend, or None. + + Checks faster_whisper first (preferred), then the openai-whisper + package, without importing the heavy model code: a bare import probe + is enough to tell whether the package is installed in the current + environment. Used by the make_audiocpp_server_json tool to warn when + neither is present (e.g. the wrong conda environment is active). + """ + for backend in ("faster_whisper", "whisper"): + try: + __import__(backend) + except ImportError: + continue + return backend + return None + + # Duration sanity check: a response whose audio is far shorter than its # word count implies is treated as silently truncated, fails the request, # and goes through the normal retry logic. 150 wpm is a typical spoken diff --git a/tests/test_make_audiocpp_server_json.py b/tests/test_make_audiocpp_server_json.py index 63260ca..bdc3604 100644 --- a/tests/test_make_audiocpp_server_json.py +++ b/tests/test_make_audiocpp_server_json.py @@ -1,9 +1,11 @@ """Tests for the audio.cpp server.json generator tool.""" +import io import json import sys import tempfile import unittest +from contextlib import redirect_stdout from pathlib import Path from unittest.mock import MagicMock, patch @@ -262,14 +264,16 @@ class MainTests(unittest.TestCase): def tearDown(self): self._tmp.cleanup() - def _run(self, argv, inputs=None, transcribe=None): + def _run(self, argv, inputs=None, transcribe=None, whisper="faster_whisper"): argv = ["make_audiocpp_server_json.py"] + argv input_effect = inputs if inputs is not None else EOFError transcribe_effect = transcribe if transcribe is not None else MagicMock() with patch.object(sys, "argv", argv), \ patch("builtins.input", side_effect=input_effect), \ patch.object(make_server, "transcribe_reference_audio", - side_effect=transcribe_effect): + side_effect=transcribe_effect), \ + patch.object(make_server, "whisper_backend_available", + return_value=whisper): return make_server.main() def _defaults(self, models="", host="", port="", backend="", @@ -448,10 +452,76 @@ class MainTests(unittest.TestCase): def test_missing_positional_wav_dir_errors(self): with self.assertRaises(SystemExit) as ctx: self._run([str(self.folder / "nope"), - "--output", str(self.output)], + "--output", str(self.output)], inputs=self._defaults()) self.assertEqual(ctx.exception.code, 2) +class TranscriptWarningTests(unittest.TestCase): + """Empty transcripts and a missing Whisper backend produce loud warnings.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.folder = Path(self._tmp.name) + self.output = self.folder / "server.json" + self.fake_config = self.folder / "config.py" + self.fake_config.write_text(FAKE_CONFIG, encoding="utf-8") + patcher = patch.object(make_server, "CONFIG_PATH", self.fake_config) + patcher.start() + self.addCleanup(patcher.stop) + + def tearDown(self): + self._tmp.cleanup() + + def _run_capturing(self, argv, inputs, transcribe, whisper): + argv = ["make_audiocpp_server_json.py"] + argv + buf = io.StringIO() + with patch.object(sys, "argv", argv), \ + patch("builtins.input", side_effect=inputs), \ + patch.object(make_server, "transcribe_reference_audio", + side_effect=transcribe), \ + patch.object(make_server, "whisper_backend_available", + return_value=whisper), \ + redirect_stdout(buf): + code = make_server.main() + return code, buf.getvalue() + + def test_empty_transcript_prints_loud_end_warning(self): + (self.folder / "narrator.wav").write_bytes(b"x") + (self.folder / "alpha.wav").write_bytes(b"x") + # Clone-only run (menu choice 3); transcribe returns None (empty). + code, out = self._run_capturing( + [str(self.folder), "--output", str(self.output)], + inputs=["3", "", "", "", "", "", "y"], + transcribe=lambda path, model_name="base": None, + whisper="faster_whisper") + self.assertEqual(code, 0) + self.assertIn("MANUAL TRANSCRIPTION REQUIRED", out) + self.assertIn("narrator", out) + self.assertIn("alpha", out) + self.assertIn("will NOT work", out) + + def test_missing_whisper_backend_prints_conda_warning(self): + (self.folder / "narrator.wav").write_bytes(b"x") + code, out = self._run_capturing( + [str(self.folder), "--output", str(self.output)], + inputs=["3", "", "", "", "", "", "y"], + transcribe=lambda path, model_name="base": "a transcript", + whisper=None) + self.assertEqual(code, 0) + self.assertIn("conda activate qwen3-tts", out) + self.assertIn("faster_whisper", out) + + def test_all_transcripts_present_prints_no_end_warning(self): + (self.folder / "narrator.wav").write_bytes(b"x") + code, out = self._run_capturing( + [str(self.folder), "--output", str(self.output)], + inputs=["3", "", "", "", "", "", "y"], + transcribe=lambda path, model_name="base": "a real transcript", + whisper="faster_whisper") + self.assertEqual(code, 0) + self.assertNotIn("MANUAL TRANSCRIPTION REQUIRED", out) + + if __name__ == "__main__": unittest.main() diff --git a/tools/make_audiocpp_server_json.py b/tools/make_audiocpp_server_json.py index 40354eb..c999f49 100755 --- a/tools/make_audiocpp_server_json.py +++ b/tools/make_audiocpp_server_json.py @@ -38,7 +38,7 @@ from typing import Dict, Optional sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from converter import config -from converter.tts import transcribe_reference_audio +from converter.tts import transcribe_reference_audio, whisper_backend_available DEFAULT_HOST = "127.0.0.1" FALLBACK_PORT = 8080 @@ -307,6 +307,33 @@ def _print_next_steps(output_path: Path, include_custom: bool, "since speaker mode needs the CustomVoice model.") +def print_empty_transcript_warning(voice_presets: Dict[str, dict]) -> None: + """Print a loud, final warning for voices whose transcript is empty. + + A cloning preset with an empty ``reference_text`` will not produce a + usable voice (the server has nothing to match the reference audio + against for in-context cloning), so the user must edit server.json by + hand. This is printed last, after the next-steps, so it is the last + thing seen and hardest to miss. + """ + empty = sorted(name for name, preset in voice_presets.items() + if not preset.get("reference_text")) + if not empty: + return + bar = "=" * 70 + print() + print(bar) + print("[WARNING] MANUAL TRANSCRIPTION REQUIRED") + print(bar) + listing = " - " + "\n - ".join(empty) if len(empty) > 1 else f" - {empty[0]}" + print(f"The following voice preset(s) have an EMPTY reference_text in " + f"server.json:\n{listing}") + print("Those voices will NOT work until you add a manual transcription.") + print('Edit server.json and fill in the "reference_text" field for each ' + "voice above with an accurate transcript of its reference .wav.") + print(bar) + + def main() -> int: parser = argparse.ArgumentParser( description="Generate a server.json for the audio.cpp audiocpp_server " @@ -404,6 +431,12 @@ def main() -> int: if wav_dir is not None: wav_files = find_wav_files(wav_dir) if wav_files: + if whisper_backend_available() is None: + print("[WARNING] Neither faster_whisper nor whisper was found, so " + "reference .wav files cannot be transcribed automatically and " + "every reference_text will be empty.") + print(' Did you remember to "conda activate qwen3-tts"? ' + "Transcripts must be added by hand (see the warning at the end).") voice_presets = build_voice_presets(wav_files, args.whisper_model) else: print(f"[WARNING] No .wav files found in {wav_dir}; writing the " @@ -436,6 +469,7 @@ def main() -> int: print(f"\n[OK] Wrote {args.output} with {len(server_config['models'])} " f"model(s) and {len(voice_presets)} voice preset(s)") _print_next_steps(args.output, include_custom, include_clone, voice_presets) + print_empty_transcript_warning(voice_presets) return 0 -- cgit v1.2.3