File size: 6,905 Bytes
7c6ffa6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path


REQUIRED_TERMS_BY_CHAPTER: dict[str, set[str]] = {
    "sound-waves": {"compression", "rarefaction", "longitudinal", "vacuum", "particles", "vibration"},
    "lenses": {"refraction", "convex", "concave", "focal", "image", "virtual"},
    "world-of-colours-and-vision": {"retina", "dispersion", "spectrum", "cornea", "wavelength"},
    "magnetic-effect-electric-current": {"magnetic", "field", "solenoid", "electromagnet", "current"},
    "electric-energy": {"power", "energy", "resistance", "voltage", "watt"},
    "electromagnetic-induction": {"induction", "flux", "coil", "generator", "emf"},
    "mechanical-advantage": {"lever", "fulcrum", "effort", "load", "mechanical"},
    "humanism": {
        "renaissance", "humanism", "constantinople", "machiavelli", "brunelleschi",
        "gutenberg", "copernicus", "galileo", "vesalius", "reformation", "luther",
    },
}


def required_terms_for(props_path: Path) -> set[str]:
    """Infer the chapter from the props path so every chapter gets its own
    critical-vocabulary check instead of silently reusing Sound Waves' terms."""
    for part in props_path.resolve().parts:
        if part in REQUIRED_TERMS_BY_CHAPTER:
            return REQUIRED_TERMS_BY_CHAPTER[part]
    print(
        f"WARNING: could not infer chapter slug from {props_path}; "
        "no chapter-specific critical terms were checked.",
        file=sys.stderr,
    )
    return set()


def normalise(text: str) -> list[str]:
    return re.findall(r"[a-z0-9]+", text.lower())


def edit_distance(left: list[str], right: list[str]) -> int:
    previous = list(range(len(right) + 1))
    for left_index, left_word in enumerate(left, start=1):
        current = [left_index]
        for right_index, right_word in enumerate(right, start=1):
            current.append(
                min(
                    current[-1] + 1,
                    previous[right_index] + 1,
                    previous[right_index - 1] + (left_word != right_word),
                )
            )
        previous = current
    return previous[-1]


def resolve_audio_path(project_root: Path, audio_src: str) -> Path:
    candidate = Path(audio_src)
    if candidate.is_absolute() and candidate.exists():
        return candidate
    direct = project_root / str(audio_src).lstrip("/")
    if direct.exists():
        return direct
    return project_root / "public" / str(audio_src).lstrip("/")


def main() -> None:
    parser = argparse.ArgumentParser(description="Reject unclear teaching narration before rendering.")
    parser.add_argument("props", type=Path)
    parser.add_argument("--project-root", type=Path, required=True)
    parser.add_argument("--model", default="medium")
    parser.add_argument("--max-wer", type=float, default=0.10)
    parser.add_argument("--reuse-transcript", action="store_true", help="Reuse the prior full-chapter transcript after changing only the expected-text normalization.")
    args = parser.parse_args()

    import whisper

    props = json.loads(args.props.read_text(encoding="utf-8"))
    model = whisper.load_model(args.model, device="cuda")
    scene_reports = []
    all_expected: list[str] = []
    all_actual: list[str] = []

    if "scenes" in props:
        narration_units = [
            {
                "scene_id": scene["scene_id"],
                "audio_src": scene["audio_src"],
                "voice_text": scene["voice_text"],
            }
            for scene in props["scenes"]
        ]
    else:
        # Full production chapters use one loudness-normalised narration track
        # plus timed captions, rather than one audio file per scene.
        voice_text = " ".join(caption["text"] for caption in props["captions"])
        narration_manifest_path = args.props.with_name("narration-manifest.json")
        if narration_manifest_path.exists():
            narration_manifest = json.loads(narration_manifest_path.read_text(encoding="utf-8"))
            source_manifest_value = narration_manifest.get("sourceManifest")
            if source_manifest_value:
                source_manifest = json.loads((args.project_root / source_manifest_value).read_text(encoding="utf-8"))
                selected_ids = {chunk["id"] for chunk in props["audioTimeline"]["chunks"]}
                voice_text = " ".join(
                    chunk.get("spokenText", chunk["text"])
                    for chunk in source_manifest["chunks"]
                    if chunk["id"] in selected_ids
                )
        narration_units = [
            {
                "scene_id": "full-chapter",
                "audio_src": props["narrationAudioSrc"],
                "voice_text": voice_text,
            }
        ]

    prior_report_path = args.props.with_name("voice-qa-report.json")
    prior_report = json.loads(prior_report_path.read_text(encoding="utf-8")) if args.reuse_transcript and prior_report_path.exists() else None
    for scene in narration_units:
        audio_src = str(scene["audio_src"])
        audio_path = resolve_audio_path(args.project_root, audio_src)
        expected = normalise(scene["voice_text"])
        if prior_report and len(narration_units) == 1 and prior_report.get("scenes"):
            transcript = str(prior_report["scenes"][0]["transcript"]).strip()
        else:
            result = model.transcribe(str(audio_path), language="en", fp16=True, temperature=0)
            transcript = str(result["text"]).strip()
        actual = normalise(transcript)
        wer = edit_distance(expected, actual) / max(1, len(expected))
        all_expected.extend(expected)
        all_actual.extend(actual)
        scene_reports.append(
            {
                "scene_id": scene["scene_id"],
                "wer": round(wer, 4),
                "expected": scene["voice_text"],
                "transcript": transcript,
            }
        )

    overall_wer = edit_distance(all_expected, all_actual) / max(1, len(all_expected))
    expected_terms = required_terms_for(args.props).intersection(all_expected)
    missing_terms = sorted(expected_terms.difference(all_actual))
    passed = overall_wer <= args.max_wer and not missing_terms
    report = {
        "passed": passed,
        "overall_wer": round(overall_wer, 4),
        "max_wer": args.max_wer,
        "required_terms_checked": sorted(expected_terms),
        "missing_terms": missing_terms,
        "scenes": scene_reports,
    }
    report_path = args.props.with_name("voice-qa-report.json")
    report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")
    print(json.dumps(report, indent=2, ensure_ascii=False))
    raise SystemExit(0 if passed else 1)


if __name__ == "__main__":
    main()