test_narration.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import io
  2. import json
  3. import sys
  4. import tempfile
  5. from contextlib import redirect_stderr, redirect_stdout
  6. import unittest
  7. from pathlib import Path
  8. from unittest.mock import patch
  9. import fixtures
  10. SCRIPT = (
  11. "This is smevals studio. Every eval on the shelf is a folder of tasks and "
  12. "graders."
  13. )
  14. class NarrationDriftRegression(unittest.TestCase):
  15. def drift(self, expected_exit: int, heard: str, script: str = SCRIPT) -> None:
  16. missing = fixtures.missing_executables("uv")
  17. if missing:
  18. self.skipTest(
  19. f"required executable(s) not on PATH: {', '.join(missing)}"
  20. )
  21. with tempfile.TemporaryDirectory() as directory:
  22. work = Path(directory)
  23. script_path = work / "script.txt"
  24. heard_path = work / "heard.txt"
  25. script_path.write_text(script, encoding="utf-8")
  26. heard_path.write_text(heard, encoding="utf-8")
  27. result = fixtures.run_tool(
  28. "narrate",
  29. ["--drift-check", str(script_path), str(heard_path)],
  30. cwd=work,
  31. )
  32. self.assertEqual(
  33. result.returncode, expected_exit, fixtures.output_text(result)
  34. )
  35. def test_mispronounced_jargon_passes(self):
  36. self.drift(
  37. 0,
  38. "This is Mevil studio. Every Yvel on the shelf is a folder of tasks "
  39. "and graders.",
  40. )
  41. def test_exact_transcript_passes(self):
  42. self.drift(0, SCRIPT)
  43. def test_dropped_clause_fails(self):
  44. self.drift(1, "This is smevals studio.")
  45. def test_invented_preamble_fails(self):
  46. self.drift(
  47. 1,
  48. "Sure, here it is, happy to help with that. This is smevals studio. "
  49. "Every eval on the shelf is a folder of tasks and graders.",
  50. )
  51. def test_empty_clip_fails(self):
  52. self.drift(1, "you")
  53. def test_inserted_runs_fail_even_when_total_length_is_close(self):
  54. words = [f"word{i}" for i in range(50)]
  55. for position in (0, 25, 50):
  56. with self.subTest(position=position):
  57. heard = words[:position] + "Before we begin please listen".split() + words[position:]
  58. self.drift(1, " ".join(heard), " ".join(words))
  59. def test_expanded_replacement_counts_the_added_words(self):
  60. words = [f"word{i}" for i in range(50)]
  61. heard = words[:25] + "Before we begin please listen".split() + words[26:]
  62. self.drift(1, " ".join(heard), " ".join(words))
  63. def test_short_insertions_keep_the_existing_tolerance(self):
  64. words = [f"word{i}" for i in range(50)]
  65. self.drift(0, "Please listen closely " + " ".join(words), " ".join(words))
  66. def test_cached_audio_requires_requested_verification(self):
  67. import json
  68. import sys
  69. module = fixtures.load_script("narrate")
  70. with tempfile.TemporaryDirectory() as tmp:
  71. work = Path(tmp)
  72. output = work / "voice"
  73. output.mkdir()
  74. (output / "clip.wav").write_bytes(b"cached audio fixture")
  75. (output / "manifest.json").write_text(json.dumps([
  76. {"id": "clip", "text": "Read this sentence.", "wav": "clip.wav",
  77. "duration": 1.0, "synthesis": {"engine": "piper",
  78. "voice": module.PIPER_VOICE, "model": module.PIPER_VOICE}}
  79. ]), encoding="utf-8")
  80. scenes = work / "scenes.yaml"
  81. scenes.write_text(json.dumps({"scenes": [
  82. {"id": "clip", "narration": "Read this sentence."}
  83. ]}), encoding="utf-8")
  84. argv = ["narrate", str(scenes), str(output),
  85. "--engine", "piper", "--verify", "on"]
  86. stdout, stderr = io.StringIO(), io.StringIO()
  87. with patch.object(sys, "argv", argv), \
  88. patch.object(module.shutil, "which", return_value="ffprobe"), \
  89. patch.object(module, "openai_key", return_value=None), \
  90. patch.object(module, "duration", return_value=1.0), \
  91. patch.object(module, "say_piper", side_effect=AssertionError("expected cached clip")), \
  92. patch.object(module, "transcribe_local", return_value=None), \
  93. redirect_stdout(stdout), redirect_stderr(stderr):
  94. self.assertNotEqual(module.main(), 0)
  95. self.assertIn("clip: required verification unavailable", stderr.getvalue())
  96. self.assertIn("FAILED verbatim delivery: ['clip']", stderr.getvalue())
  97. self.assertEqual(
  98. json.loads((output / "manifest.json").read_text(encoding="utf-8")),
  99. [],
  100. )
  101. self.assertEqual((output / "clip.wav").read_bytes(), b"cached audio fixture")
  102. def test_rejected_chat_audio_is_never_cached_but_accepted_audio_is(self):
  103. import json
  104. import sys
  105. module = fixtures.load_script("narrate")
  106. with tempfile.TemporaryDirectory() as directory:
  107. root = Path(directory)
  108. scenes = root / "scenes.yaml"
  109. scenes.write_text(json.dumps({"scenes": [
  110. {"id": "accepted", "narration": "Read this sentence exactly."},
  111. {"id": "rejected", "narration": "Keep this evidence out of the manifest."},
  112. ]}), encoding="utf-8")
  113. output = root / "voice"
  114. calls = []
  115. def synthesize(key, text, wav, voice):
  116. calls.append(text)
  117. wav.write_bytes(f"render {len(calls)}".encode())
  118. if text.startswith("Keep"):
  119. return "Unrelated invented preamble with entirely different words here."
  120. return text
  121. argv = ["narrate", str(scenes), str(output), "--engine", "openai-chat",
  122. "--verify", "off"]
  123. rejected_renders = []
  124. with patch.object(sys, "argv", argv), \
  125. patch.object(module.shutil, "which", return_value="ffprobe"), \
  126. patch.object(module, "openai_key", return_value="test-key"), \
  127. patch.object(module, "say_openai_chat", side_effect=synthesize), \
  128. patch.object(module, "duration", return_value=1.0):
  129. for _ in range(2):
  130. with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
  131. self.assertEqual(module.main(), 1)
  132. manifest = json.loads(
  133. (output / "manifest.json").read_text(encoding="utf-8")
  134. )
  135. self.assertEqual([entry["id"] for entry in manifest], ["accepted"])
  136. rejected_renders.append(sorted(
  137. path.read_bytes() for path in output.glob(".rejected.attempt-*.wav")
  138. ))
  139. self.assertEqual(calls.count("Read this sentence exactly."), 1)
  140. self.assertEqual(calls.count("Keep this evidence out of the manifest."), 4)
  141. self.assertLess(len(rejected_renders[0]), len(rejected_renders[1]))
  142. class TranscriptionProtocolRegression(unittest.TestCase):
  143. def test_owned_json_is_used_instead_of_library_stdout(self):
  144. import json
  145. import subprocess
  146. import sys
  147. import io
  148. module = fixtures.load_script("narrate")
  149. def child(argv, **kwargs):
  150. self.assertIn("--isolated", argv)
  151. self.assertIn("--no-project", argv)
  152. self.assertIn("--no-config", argv)
  153. self.assertEqual(argv[argv.index("--python") + 1], sys.executable)
  154. self.assertNotEqual(Path(kwargs["cwd"]), Path.cwd())
  155. Path(argv[-1]).write_text(json.dumps({"text": "Correct λ transcript"}), encoding="utf-8")
  156. return subprocess.CompletedProcess(argv, 0, "native library warning", "diagnostic")
  157. diagnostics = io.StringIO()
  158. with patch.object(module.subprocess, "run", side_effect=child), redirect_stderr(diagnostics):
  159. self.assertEqual(module.transcribe_local(Path("clip.wav")), "Correct λ transcript")
  160. self.assertIn("native library warning", diagnostics.getvalue())
  161. self.assertIn("diagnostic", diagnostics.getvalue())
  162. def test_failed_absent_and_malformed_child_results_are_unavailable(self):
  163. import subprocess
  164. module = fixtures.load_script("narrate")
  165. for payload, code in ((None, 0), ("garbage", 0), ('{"text": 7}', 0), ('{"text": ""}', 0), ('{"text": "words"}', 1)):
  166. with self.subTest(payload=payload, code=code):
  167. def child(argv, **kwargs):
  168. if payload is not None and "--isolated" in argv:
  169. Path(argv[-1]).write_text(payload, encoding="utf-8")
  170. return subprocess.CompletedProcess(argv, code, "misleading stdout", "error")
  171. diagnostics = io.StringIO()
  172. with patch.object(module.subprocess, "run", side_effect=child), \
  173. redirect_stderr(diagnostics):
  174. self.assertEqual(
  175. module.transcribe_local(Path("clip.wav")),
  176. "" if payload == '{"text": ""}' and code == 0 else None,
  177. )
  178. self.assertIn("local ASR", diagnostics.getvalue())
  179. def test_fresh_and_off_then_on_clips_require_asr(self):
  180. import json
  181. import sys
  182. module = fixtures.load_script("narrate")
  183. for cached in (False, True):
  184. with self.subTest(cached=cached), tempfile.TemporaryDirectory() as directory:
  185. root = Path(directory)
  186. scenes = root / "scenes.yaml"
  187. scenes.write_text(json.dumps({"scenes": [{"id": "clip", "narration": "Read this sentence."}]}), encoding="utf-8-sig")
  188. output = root / "voice"
  189. def synthesize(text, wav, voice):
  190. wav.write_bytes(b"branch policy fixture")
  191. argv = ["narrate", str(scenes), str(output), "--engine", "piper", "--verify"]
  192. with patch.object(module.shutil, "which", return_value="ffprobe"), patch.object(module, "openai_key", return_value=None), patch.object(module, "say_piper", side_effect=synthesize), patch.object(module, "duration", return_value=1.0), patch.object(module, "transcribe_local", return_value=None):
  193. if cached:
  194. with patch.object(sys, "argv", [*argv, "off"]), \
  195. redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
  196. self.assertEqual(module.main(), 0)
  197. stdout, stderr = io.StringIO(), io.StringIO()
  198. with patch.object(sys, "argv", [*argv, "on"]), \
  199. redirect_stdout(stdout), redirect_stderr(stderr):
  200. self.assertNotEqual(module.main(), 0)
  201. self.assertIn("clip: required verification unavailable", stderr.getvalue())
  202. class NarrationCacheRegression(unittest.TestCase):
  203. def setUp(self):
  204. self.module = fixtures.load_script("narrate")
  205. directory = tempfile.TemporaryDirectory()
  206. self.addCleanup(directory.cleanup)
  207. self.root = Path(directory.name)
  208. self.scenes = self.root / "scenes.yaml"
  209. self.text = "Read this sentence exactly."
  210. self.write_scenes(self.text)
  211. self.output = self.root / "voice"
  212. self.renders = []
  213. which = patch.object(self.module.shutil, "which", return_value="ffprobe")
  214. which.start()
  215. self.addCleanup(which.stop)
  216. def synthesize(*args):
  217. text, wav, voice = args[-3:]
  218. self.renders.append((text, voice))
  219. wav.write_bytes(f"render {len(self.renders)}".encode())
  220. return text
  221. for name, options in (
  222. ("openai_key", {"return_value": "test-key"}),
  223. ("duration", {"return_value": 1.0}),
  224. ("transcribe_local", {"return_value": None}),
  225. ("say_piper", {"side_effect": synthesize}),
  226. ("say_openai", {"side_effect": synthesize}),
  227. ("say_openai_chat", {"side_effect": synthesize}),
  228. ):
  229. mocked = patch.object(self.module, name, **options)
  230. mocked.start()
  231. self.addCleanup(mocked.stop)
  232. def write_scenes(self, text):
  233. self.scenes.write_text(json.dumps({"scenes": [
  234. {"id": "clip", "narration": text}
  235. ]}), encoding="utf-8")
  236. def narrate(self, *options, verify="off", expected_exit=0):
  237. argv = ["narrate", str(self.scenes), str(self.output),
  238. "--verify", verify, *options]
  239. stdout, stderr = io.StringIO(), io.StringIO()
  240. with patch.object(sys, "argv", argv), \
  241. redirect_stdout(stdout), redirect_stderr(stderr):
  242. self.assertEqual(self.module.main(), expected_exit, stderr.getvalue())
  243. return json.loads((self.output / "manifest.json").read_text(encoding="utf-8"))
  244. def test_engine_and_voice_changes_rerender(self):
  245. for index, options in enumerate((
  246. ("--engine", "piper", "--voice", "voice-a"),
  247. ("--engine", "piper", "--voice", "voice-b"),
  248. ("--engine", "openai", "--voice", "voice-b"),
  249. ("--engine", "openai-chat", "--voice", "voice-b"),
  250. ), 1):
  251. with self.subTest(options=options):
  252. self.narrate(*options)
  253. self.assertEqual(len(self.renders), index)
  254. self.assertEqual((self.output / "clip.wav").read_bytes(),
  255. f"render {index}".encode())
  256. def test_cloud_model_changes_rerender(self):
  257. for engine, constant in (("openai", "OPENAI_TTS_MODEL"),
  258. ("openai-chat", "OPENAI_CHAT_MODEL")):
  259. with self.subTest(engine=engine):
  260. self.narrate("--engine", engine)
  261. count = len(self.renders)
  262. with patch.object(self.module, constant, "another-model"):
  263. self.narrate("--engine", engine)
  264. self.assertEqual(len(self.renders), count + 1)
  265. def test_implicit_and_explicit_defaults_share_cache(self):
  266. for engine, voice in (("piper", self.module.PIPER_VOICE),
  267. ("openai", "nova"), ("openai-chat", "nova")):
  268. with self.subTest(engine=engine):
  269. self.narrate("--engine", engine)
  270. count = len(self.renders)
  271. self.narrate("--engine", engine, "--voice", voice)
  272. self.assertEqual(len(self.renders), count)
  273. self.narrate("--engine", "openai")
  274. count = len(self.renders)
  275. self.narrate()
  276. self.assertEqual(len(self.renders), count)
  277. with patch.object(self.module, "openai_key", return_value=None):
  278. self.narrate()
  279. self.assertEqual(len(self.renders), count + 1)
  280. def test_cache_without_synthesis_settings_rerenders(self):
  281. manifest = self.narrate()
  282. manifest[0].pop("synthesis", None)
  283. (self.output / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
  284. self.narrate()
  285. self.assertEqual(len(self.renders), 2)
  286. def test_changed_text_force_and_missing_wav_rerender(self):
  287. self.narrate()
  288. self.write_scenes("Read a different sentence exactly.")
  289. manifest = self.narrate()
  290. self.assertEqual(manifest[0]["text"], "Read a different sentence exactly.")
  291. self.assertEqual(len(self.renders), 2)
  292. self.narrate("--force")
  293. self.assertEqual(len(self.renders), 3)
  294. (self.output / "clip.wav").unlink()
  295. self.narrate()
  296. self.assertEqual(len(self.renders), 4)
  297. def test_cached_clip_is_reverified_with_requested_asr_model(self):
  298. self.narrate()
  299. with patch.object(self.module, "transcribe_local", return_value=self.text) as asr:
  300. self.narrate("--asr-model", "small.en", verify="on")
  301. asr.assert_called_once_with(self.output / "clip.wav", "small.en")
  302. self.assertEqual(len(self.renders), 1)
  303. def test_unavailable_asr_respects_each_verification_mode(self):
  304. for engine in ("piper", "openai", "openai-chat"):
  305. for mode in ("auto", "on", "off"):
  306. with self.subTest(engine=engine, mode=mode):
  307. self.module.transcribe_local.reset_mock()
  308. manifest = self.narrate("--engine", engine, verify=mode,
  309. expected_exit=1 if mode == "on" else 0)
  310. self.assertEqual(bool(manifest), mode != "on")
  311. self.assertEqual(self.module.transcribe_local.call_count,
  312. int(mode == "on" or (mode == "auto" and engine != "openai")))
  313. if __name__ == "__main__":
  314. unittest.main()