"""A/B for the '국어책 읽기' cluster (reviewer sheet 2026-07-16, cluster B). 13 clips (apology x6, struggle x6, sulk x1) were flagged as sounding like someone reading a textbook. Three candidate causes, and we don't know the mix: 1. STYLE FLOOR. _build_voice_settings maps style = 0.20 + 0.25*|A|. These emotions annotate at |A|~0.1-0.3, so style pins at ~0.247 against a 0.20 floor, vs ~0.344 for clips that sound fine. The mapping uses |A| alone, which conflates "low arousal" with "low expressiveness" — but apology and struggle are low-arousal AND emotionally rich. hypot(V,A) separates them properly (neutral 0.04-0.11, cluster B 0.28-0.60, expressive 0.71-1.14). 2. TAG SEMANTICS. apology's head tag is literally "[resigned tone]" — we are asking v3 for the exact monotone the reviewer is complaining about. 3. TEXT REGISTER. Some of the 13 are formal 합쇼체 announcements ("악천후로 인해 출발이 늦어지고 있습니다") that read flat because they ARE written documents. No voice setting fixes that; only a rewrite would. This script isolates 1 vs 2 on two CASUAL apology clips (where cause 3 is not in play, so a flat read is unambiguously wrong). 2 clips x 5 arms = 10 API calls. ELEVENLABS_API_KEY=sk_... /bin/python scripts/_ab_cluster_b.py (--dry-run prints the exact text + voice_settings per arm, no API calls) Listen, then decide which lever to apply to the remaining 11. """ import argparse import asyncio import importlib.util import json import math import sys from pathlib import Path HERE = Path(__file__).resolve().parent ROOT = HERE.parent OUT = ROOT / "flagged_audio_review_4" # Casual dialogue apologies — no formal-register confound. CLIPS = ["daily_022_p0_t2_apology", "daily_gen_052_t1_apology"] def _load_tts(): spec = importlib.util.spec_from_file_location("_ab_tts", HERE / "compiler" / "tts.py") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def proposed_style(V, A): """Candidate replacement: intensity = distance from neutral in the V-A plane.""" intensity = min(1.0, math.hypot(float(V), float(A))) style = max(0.0, min(0.50, 0.20 + 0.30 * intensity)) stability = max(0.40, min(0.50, 0.50 - 0.10 * intensity)) return style, stability def main(): ap = argparse.ArgumentParser() ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() tts = _load_tts() man = {} for line in (ROOT / "data/audio_preview/manifest.jsonl").open(encoding="utf-8"): d = json.loads(line) man[d["file"]] = d orig_settings = tts._build_voice_settings orig_format = tts._format_text_with_emotion def patched_settings(vad): if vad is None: return orig_settings(vad) V, A, D = vad style, stability = proposed_style(V, A) s = orig_settings(vad) s["style"], s["stability"] = style, stability return s def maxed_settings(vad): """Deliberately extreme — a FALSIFICATION arm, not a shipping candidate. The proposed mapping only moves style 0.254 -> ~0.32, which is the same order of change we've measured as inaudible. If style is truly the lever, pinning it near the 0.50 cap must make an obvious difference; if THIS arm still sounds like a textbook, style is ruled out and the tag/text are the whole story. (Risk: high style + a head tag caused raspy/accent-drift distortion on 2026-05-07 — if this arm distorts, that reproduces it.) """ s = orig_settings(vad) s["style"], s["stability"] = 0.45, 0.44 return s def patched_format(text, emotion, use_v3_tags): # tag_map is a local inside the original, so swap on the way out. return orig_format(text, emotion, use_v3_tags).replace( "[resigned tone]", "[apologetic]") # (style_mode, swap_tag): style_mode None=current, "prop"=hypot candidate, # "max"=extreme falsification arm. ARMS = { "A0_control": (None, False), "A1_style": ("prop", False), "A2_tag": (None, True), "A3_both": ("prop", True), "A4_stylemax": ("max", False), } def _settings_for(mode): return {None: orig_settings, "prop": patched_settings, "max": maxed_settings}[mode] OUT.mkdir(parents=True, exist_ok=True) plan = [] for stem in CLIPS: d = man[stem + ".mp3"] base = tts.EMOTION_TO_BASE.get(d["emotion"], "neutral") vid = tts.FEMALE_BY_BASE[base] for arm, (mode, do_tag) in ARMS.items(): plan.append((stem, arm, d, vid, mode, do_tag)) print(f"[plan] {len(plan)} synths -> {OUT}") for stem, arm, d, vid, mode, do_tag in plan: tts._build_voice_settings = _settings_for(mode) tts._format_text_with_emotion = patched_format if do_tag else orig_format sent = tts._format_text_with_emotion(d["text"], d["emotion"], True) vs = tts._build_voice_settings(d["vad"]) print(f" {stem[:28]:28s} {arm:11s} style={vs['style']:.3f} stab={vs['stability']:.3f}") print(f" {sent!r}") if args.dry_run: print("[dry-run] no API calls made") return async def run(): ok_n = 0 for stem, arm, d, vid, mode, do_tag in plan: tts._build_voice_settings = _settings_for(mode) tts._format_text_with_emotion = patched_format if do_tag else orig_format out = OUT / f"{stem}__{arm}.mp3" if out.exists(): out.unlink() r = await tts.synth_all( [d["text"]], [out], backend="elevenlabs", concurrency=1, emotions=[d["emotion"]], vads=[d["vad"]], voice_seeds=[None], voice_ids=[vid], ) ok_n += 1 if r and r[0] else 0 print(f"\n[done] {ok_n}/{len(plan)}") if ok_n != len(plan): sys.exit(1) asyncio.run(run()) if __name__ == "__main__": main()