Files
old-books/_pipeline/tests/test_pipeline.py
T
Audiobook Producer 7f0b598441 Fix paragraph breaks, drop caps, headers and captions for chapters 2-6 of the Clark book
Root cause: clean-up found paragraph breaks from the layout indent of pdftotext -layout
(columns, relative to the smallest indent in the whole chapter). Chapter 1 happened to
have a clean left margin; later pages are skewed and drift 1-2 columns, drop caps push
the first lines right by varying amounts, and some pages wrap text around a label, so
continuation lines were taken as paragraph starts (a blank line inside a sentence).

Fix (all deterministic, all logged to warnings.log):
- pdftext: read each line's position in points (pdftotext -bbox-layout) and attach it to
  the layout line it belongs to (units() now carries 'geom'; merged/partial lines get none).
- cleanup: a paragraph starts when a line sits >= 4.5 pt right of the line before it, or
  >= 2.5 pt lower than the page's normal line pitch. A line without a position or starting
  in lower case never starts a paragraph. Text wrapped beside a drop cap is not a start.
  Without positions the old indent rule is still used.
- cleanup: drop cap + small caps on ONE layout line ('F     OR MANY YEARS') is repaired like
  the two-line form (chapters 4 and 5).
- pageparts.header_zone: the running header is found even when text follows it without a
  blank line, and the right-hand header (chapter title, page number sometimes misread as
  't5' or 'a') is recognised from the chapter title.
- cleanup: all-capitals lines below the text are captions (removed); an all-capitals line
  above the text is kept as a section heading in title case ('The Deferred Preface');
  'Right: MARK TWIN' style captions removed; '5. JOY' labels set inside a sentence removed.
- cleanup: paragraph starting with a lone quote mark + lower-case word joins the paragraph
  before it; paragraph-leading ' + capital becomes '"' (not elisions such as 'Tis); a bullet
  in place of an opening quote becomes '"'; closing quote with no opening quote, and a '1'
  that is probably the letter I, are logged only.
- cleanup: mid-line 'worth- while' joined only when certain, otherwise logged.
- tests: new _pipeline/tests/test_cleanup.py (36 tests, 27 fail on the previous code);
  test_pipeline.py helper test_piece renamed make_test_piece so pytest stops collecting it.

Chapter 1 text is byte-identical to the approved pilot text.
Re-run for chapters 1-6: cleanup, figures, script, license. Text, script, warnings and
book.json for chapters 2-6 added.
2026-10-17 17:07:52 -05:00

152 lines
6.9 KiB
Python

"""Tests. Everything audio here is a synthetic TEST signal made locally, never pilot audio.
Run: python3 -m unittest discover -s _pipeline/tests -v (from the repo root)"""
import http.server
import json
import os
import sys
import tempfile
import threading
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
import acx_check
import assemble
import audiomeasure as am
import script
import settings
import voice
CFG = settings.load()
def make_test_piece(path, hz, seconds, level):
"""TEST signal: speech-like (harmonics, 4 Hz syllable rhythm, a little noise) at amplitude `level`."""
expr = "%g*(0.6*sin(2*PI*%g*t)+0.3*sin(4*PI*%g*t)+0.1*sin(6*PI*%g*t))*(0.55+0.45*sin(2*PI*4*t))" % (level, hz, hz, hz)
am.ffmpeg(["-f", "lavfi", "-i", "aevalsrc='%s':s=24000:d=%g" % (expr, seconds), "-ar", "24000", "-ac", "1", path])
class AudioTests(unittest.TestCase):
def test_assemble_then_check_passes(self):
with tempfile.TemporaryDirectory() as d:
items = []
for i, (hz, lvl) in enumerate([(120, 0.02), (150, 0.05), (130, 0.03)]):
p = os.path.join(d, "t%d.wav" % i)
make_test_piece(p, hz, 4, lvl)
items.append({"wav": p, "gap_after_s": 0.7})
if i == 0:
items.append({"cue": "chime", "gap_after_s": 0.25})
out = os.path.join(d, "audio", "chapter-99.mp3")
gain = assemble.assemble_items(CFG, items, out)
m = acx_check.measure(CFG, out)
print("\nTEST assembled: gain %.1f dB, measured %s" % (gain, {k: (round(v, 2) if isinstance(v, float) else v) for k, v in m.items()}))
for name, value, passed in acx_check.judge(CFG, out, m):
self.assertTrue(passed, "%s %s" % (name, value))
def test_checker_fails_bad_files(self):
with tempfile.TemporaryDirectory() as d:
raw = os.path.join(d, "raw.wav")
make_test_piece(raw, 130, 4, 1.4) # hot test signal, clips at full scale
bad = os.path.join(d, "bad.mp3") # stereo, 128k, 22.05 kHz, no room tone
am.ffmpeg(["-i", raw, "-ac", "2", "-ar", "22050", "-c:a", "libmp3lame", "-b:a", "128k", bad])
failed = {n for n, v, p in acx_check.judge(CFG, bad, acx_check.measure(CFG, bad)) if not p}
print("\nTEST bad file 1 fails on:", sorted(failed))
self.assertTrue({"format", "rms dB", "noise floor dB", "head s", "tail s"} <= failed)
hot = os.path.join(d, "hot.mp3") # correct format, but too loud
am.ffmpeg(["-i", raw, "-ac", "1", "-ar", "44100", "-c:a", "libmp3lame", "-b:a", "192k", hot])
m = acx_check.measure(CFG, hot)
failed = {n for n, v, p in acx_check.judge(CFG, hot, m) if not p}
print("TEST bad file 2 (peak %.1f dB) fails on:" % m["peak_db"], sorted(failed))
self.assertIn("peak dB", failed)
self.assertNotIn("format", failed)
class StubHandler(http.server.BaseHTTPRequestHandler):
seen = []
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
StubHandler.seen.append((self.path, body))
if self.path.endswith("/audio/speech") or self.path == "/tts":
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, "x.wav")
make_test_piece(p, 130, 1, 0.1)
data = open(p, "rb").read()
self.send_response(200)
self.send_header("Content-Type", "audio/wav")
else:
data = json.dumps({"choices": [{"message": {"content": "A test summary."}}]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *a):
pass
class ClientTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = http.server.HTTPServer(("127.0.0.1", 0), StubHandler)
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
cls.url = "http://127.0.0.1:%d" % cls.server.server_port
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
def cfg(self):
c = json.loads(json.dumps(CFG))
c["voice"]["endpoint"] = self.url + "/v1/audio/speech"
c["voice"]["api"] = "openai"
c["voice"]["extra"] = {}
c["llm"].update(endpoint=self.url + "/v1/chat/completions", model="m")
return c
def test_voice_request_and_wav(self):
StubHandler.seen.clear()
data = voice.speak(self.cfg(), "Hello there.")
path, body = StubHandler.seen[0]
self.assertEqual(path, "/v1/audio/speech")
self.assertEqual(body["input"], "Hello there.")
self.assertEqual(body["response_format"], "wav")
self.assertEqual(data[:4], b"RIFF")
def test_locked_voice_uses_native_route_with_locked_settings(self):
StubHandler.seen.clear()
c = json.loads(json.dumps(CFG))
c["voice"]["endpoint"] = self.url + "/tts"
voice.speak(c, "Hello there.")
path, body = StubHandler.seen[0]
self.assertEqual(path, "/tts")
self.assertEqual(body["text"], "Hello there.")
self.assertEqual(body["predefined_voice_id"], "Michael.wav")
self.assertEqual((body["exaggeration"], body["cfg_weight"], body["temperature"], body["seed"], body["chunk_size"]), (0.25, 0.2, 0.6, 7, 240))
self.assertNotIn("speed_factor", body)
def test_sentence_split_keeps_abbreviations(self):
s = voice.sentences('One day Dr. Alexis Carrel sent word. "The world," he said. E. Stanley Jones came.')
self.assertEqual(len(s), 3)
def test_script_wraps_inserted_parts_when_switches_on(self):
c = self.cfg()
c["summaries"].update(chapter_preview=True, chapter_summary=True, concept_summary=True)
ch = {"number": 1, "title": "We Go Seeking", "switches": {}}
segs = script.chapter_script(c, {"summaries": {}}, ch, ["Para one is here.", "Para two is here.", "Para three is here."])
kinds = [s["kind"] for s in segs]
self.assertEqual(kinds[:5], ["header", "cue", "label", "chapter_preview", "cue"])
self.assertEqual(kinds[-4:], ["cue", "label", "chapter_summary", "cue"])
self.assertEqual(kinds.count("concept_summary"), 1)
self.assertEqual([s["text"] for s in segs if s["kind"] == "author"], ["Para one is here.", "Para two is here.", "Para three is here."])
def test_switches_default_off_and_chapter_wins(self):
ch = {"number": 1, "title": "x", "switches": {"chapter_summary": True}}
self.assertFalse(script.switch(CFG, {"summaries": {}}, {"switches": {}}, "chapter_summary"))
self.assertTrue(script.switch(CFG, {"summaries": {"chapter_summary": False}}, ch, "chapter_summary"))
if __name__ == "__main__":
unittest.main()