aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--converter/tts.py18
-rw-r--r--tests/test_make_audiocpp_server_json.py76
-rwxr-xr-xtools/make_audiocpp_server_json.py36
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