loudcheck 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
loudcheck/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """loudcheck — standards-based loudness compliance verdicts (EBU R128, ATSC A/85)."""
2
+
3
+ from .analyze import AnalysisError, Measurement, measure # noqa: F401
4
+ from .standards import STANDARDS # noqa: F401
5
+ from .verdict import check # noqa: F401
6
+
7
+ __version__ = "0.2.0"
loudcheck/analyze.py ADDED
@@ -0,0 +1,174 @@
1
+ """
2
+ Measurement engine — run ffmpeg's loudnorm analysis pass and parse metrics.
3
+
4
+ Design decisions:
5
+ - Single ffmpeg invocation using `loudnorm=print_format=json` in analysis
6
+ mode. The input_* measurement fields are independent of the normalization
7
+ parameters, so one pass yields integrated loudness, LRA, true peak, and
8
+ threshold. (The alternative `ebur128` filter is used in the test suite as
9
+ a cross-check so the two ffmpeg implementations must agree for CI to pass.)
10
+ - ffmpeg version is part of the contract: it is captured and returned with
11
+ every measurement, because filter behavior across ffmpeg releases is the
12
+ one realistic maintenance surface this tool has. Minimum supported: 5.0.
13
+ - Errors are specific, `Error:`-prefixed, and never a raw trace (P0.5).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ from dataclasses import dataclass
23
+ from typing import Optional
24
+
25
+ MIN_FFMPEG_MAJOR = 5
26
+
27
+
28
+ class AnalysisError(Exception):
29
+ """Raised with a clean, user-facing message (no traceback semantics)."""
30
+
31
+
32
+ @dataclass
33
+ class Measurement:
34
+ integrated: float # LUFS/LKFS (identical scales; name differs by spec)
35
+ lra: float # LU
36
+ true_peak: float # dBTP
37
+ threshold: float # LUFS, gating threshold used
38
+ sample_rate: Optional[int]
39
+ channels: Optional[int]
40
+ duration_seconds: Optional[float]
41
+ ffmpeg_version: str
42
+ stream_index: int = 0
43
+ max_momentary: Optional[float] = None # LUFS, 400 ms window (--detailed)
44
+ max_short_term: Optional[float] = None # LUFS, 3 s window (--detailed)
45
+
46
+
47
+ def _ffmpeg_path() -> str:
48
+ path = shutil.which("ffmpeg")
49
+ if not path:
50
+ raise AnalysisError(
51
+ "Error: ffmpeg not found on PATH (loudcheck requires ffmpeg >= "
52
+ f"{MIN_FFMPEG_MAJOR}.0)")
53
+ return path
54
+
55
+
56
+ def ffmpeg_version(path: Optional[str] = None) -> str:
57
+ out = subprocess.run(
58
+ [path or _ffmpeg_path(), "-version"],
59
+ capture_output=True, text=True).stdout
60
+ m = re.match(r"ffmpeg version (\S+)", out)
61
+ return m.group(1) if m else "unknown"
62
+
63
+
64
+ def _probe(path: str, ffmpeg: str) -> dict:
65
+ """Light probe via ffmpeg itself (no ffprobe dependency): stream info
66
+ from the -i banner on stderr."""
67
+ r = subprocess.run(
68
+ [ffmpeg, "-hide_banner", "-i", path],
69
+ capture_output=True, text=True)
70
+ err = r.stderr
71
+ if "No such file or directory" in err:
72
+ raise AnalysisError(f"Error: file not found: {path}")
73
+ if "Invalid data found" in err:
74
+ raise AnalysisError(f"Error: unreadable or unsupported file: {path}")
75
+ info: dict = {"has_audio": "Stream" in err and "Audio:" in err}
76
+ info["audio_streams"] = len(re.findall(r"Stream #\d+:\d+.*?: Audio:", err))
77
+ m = re.search(r"Audio:.*?(\d+) Hz.*?(mono|stereo|(\d+)(?:\.\d+)? channels)", err)
78
+ if m:
79
+ info["sample_rate"] = int(m.group(1))
80
+ ch = m.group(2)
81
+ info["channels"] = 1 if ch == "mono" else 2 if ch == "stereo" \
82
+ else int(m.group(3) or 0)
83
+ d = re.search(r"Duration: (\d+):(\d+):(\d+\.\d+)", err)
84
+ if d:
85
+ h, mi, s = int(d.group(1)), int(d.group(2)), float(d.group(3))
86
+ info["duration"] = h * 3600 + mi * 60 + s
87
+ return info
88
+
89
+
90
+ def audio_stream_count(path: str) -> int:
91
+ """Number of audio streams in the file (0 if none)."""
92
+ return _probe(path, _ffmpeg_path()).get("audio_streams", 0)
93
+
94
+
95
+ def _detailed_pass(ffmpeg: str, path: str, stream: int) -> tuple[float, float]:
96
+ """Second pass with ebur128 to extract max momentary / max short-term.
97
+ The filter logs M/S every 100 ms; we take the maxima."""
98
+ r = subprocess.run(
99
+ [ffmpeg, "-hide_banner", "-nostats", "-i", path,
100
+ "-map", f"0:a:{stream}",
101
+ "-af", "ebur128=peak=true", "-f", "null", "-"],
102
+ capture_output=True, text=True)
103
+ momentary = [float(m) for m in re.findall(r"M:\s*(-?[\d.]+)", r.stderr)]
104
+ short_term = [float(s) for s in re.findall(r"S:\s*(-?[\d.]+)", r.stderr)]
105
+ if not momentary or not short_term:
106
+ raise AnalysisError(
107
+ "Error: ebur128 produced no momentary/short-term readings")
108
+ return max(momentary), max(short_term)
109
+
110
+
111
+ def measure(path: str, stream: int = 0, detailed: bool = False) -> Measurement:
112
+ """Measure integrated loudness, LRA, and true peak of one audio stream.
113
+
114
+ stream: zero-based audio stream index (0:a:N).
115
+ detailed: also run an ebur128 pass for max momentary / max short-term.
116
+ """
117
+ ffmpeg = _ffmpeg_path()
118
+
119
+ version = ffmpeg_version(ffmpeg)
120
+ major = re.match(r"(\d+)", version)
121
+ if major and int(major.group(1)) < MIN_FFMPEG_MAJOR:
122
+ raise AnalysisError(
123
+ f"Error: ffmpeg {version} is below the supported minimum "
124
+ f"({MIN_FFMPEG_MAJOR}.0); loudnorm measurement behavior is "
125
+ "not verified on older releases")
126
+
127
+ info = _probe(path, ffmpeg)
128
+ if not info.get("has_audio"):
129
+ raise AnalysisError("Error: no audio stream found")
130
+ n_streams = info.get("audio_streams", 1)
131
+ if stream >= n_streams:
132
+ raise AnalysisError(
133
+ f"Error: audio stream {stream} not found "
134
+ f"(file has {n_streams} audio stream(s))")
135
+
136
+ r = subprocess.run(
137
+ [ffmpeg, "-hide_banner", "-nostats", "-i", path,
138
+ "-map", f"0:a:{stream}",
139
+ "-af", "loudnorm=print_format=json",
140
+ "-f", "null", "-"],
141
+ capture_output=True, text=True)
142
+ # loudnorm prints its JSON block to stderr after processing.
143
+ m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", r.stderr, re.S)
144
+ if not m:
145
+ raise AnalysisError(
146
+ "Error: ffmpeg loudnorm produced no measurement "
147
+ f"(ffmpeg exit {r.returncode})")
148
+ data = json.loads(m.group(0))
149
+
150
+ def f(key: str) -> float:
151
+ v = data.get(key)
152
+ if v in (None, "-inf", "inf"):
153
+ raise AnalysisError(
154
+ f"Error: measurement '{key}' unavailable (silent or "
155
+ "too-short audio?)")
156
+ return float(v)
157
+
158
+ max_m = max_s = None
159
+ if detailed:
160
+ max_m, max_s = _detailed_pass(ffmpeg, path, stream)
161
+
162
+ return Measurement(
163
+ integrated=f("input_i"),
164
+ lra=f("input_lra"),
165
+ true_peak=f("input_tp"),
166
+ threshold=f("input_thresh"),
167
+ sample_rate=info.get("sample_rate"),
168
+ channels=info.get("channels"),
169
+ duration_seconds=info.get("duration"),
170
+ ffmpeg_version=version,
171
+ stream_index=stream,
172
+ max_momentary=max_m,
173
+ max_short_term=max_s,
174
+ )
loudcheck/cli.py ADDED
@@ -0,0 +1,159 @@
1
+ """
2
+ loudcheck CLI — one engine, two surfaces (this and mcp_server.py).
3
+
4
+ Exit codes (agent contract): 0 = pass (or "measured" for gate-free
5
+ standards like BS_1770), 1 = fail — in batch mode, any fail,
6
+ 2 = error (missing file, no audio, no ffmpeg, unknown standard).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import importlib.resources
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from .analyze import AnalysisError, audio_stream_count, measure
18
+ from .standards import STANDARDS
19
+ from .verdict import check
20
+
21
+ MEDIA_EXTS = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".opus",
22
+ ".mp4", ".mov", ".mkv", ".mka", ".webm", ".mxf", ".ts", ".aiff"}
23
+
24
+
25
+ def tool_schema() -> str:
26
+ """The machine-readable tool definition, shipped inside the package
27
+ (the repo-root tool.json is a synced copy for agents browsing GitHub)."""
28
+ return importlib.resources.files("loudcheck").joinpath(
29
+ "tool.json").read_text(encoding="utf-8")
30
+
31
+
32
+ def human(result: dict) -> str:
33
+ lines = [f"{result['verdict'].upper()} — {result['standard_name']}"]
34
+ m = result["metrics"]
35
+
36
+ integ = m["integrated"]
37
+ if integ.get("informational"):
38
+ lines.append(f" · integrated {integ['measured']:.1f} {integ['unit']}")
39
+ else:
40
+ mark = "✓" if integ["pass"] else "✗"
41
+ lines.append(
42
+ f" {mark} integrated {integ['measured']:.1f} {integ['unit']} "
43
+ f"(target {integ['target']:.1f} ±{integ['tolerance']}, "
44
+ f"delta {integ['delta']:+.1f})")
45
+
46
+ tp = m["true_peak"]
47
+ if tp.get("informational"):
48
+ lines.append(f" · true peak {tp['measured']:.1f} dBTP")
49
+ else:
50
+ mark = "✓" if tp["pass"] else "✗"
51
+ lines.append(
52
+ f" {mark} true peak {tp['measured']:.1f} dBTP (max {tp['max']:.1f})")
53
+
54
+ lines.append(f" · LRA {m['lra']['measured']:.1f} LU (informational)")
55
+ for key, label in (("max_momentary", "max momentary"),
56
+ ("max_short_term", "max short-term")):
57
+ if key in m:
58
+ lines.append(
59
+ f" · {label} {m[key]['measured']:.1f} {m[key]['unit']}")
60
+ for r in result["remediation"]:
61
+ lines.append(f" → {r}")
62
+ return "\n".join(lines)
63
+
64
+
65
+ def expand_targets(paths: list[str]) -> list[str]:
66
+ """Files stay files; a directory expands to its media files (sorted)."""
67
+ out: list[str] = []
68
+ for p in paths:
69
+ pp = Path(p)
70
+ if pp.is_dir():
71
+ out.extend(sorted(
72
+ str(f) for f in pp.iterdir()
73
+ if f.suffix.lower() in MEDIA_EXTS and f.is_file()))
74
+ else:
75
+ out.append(p)
76
+ return out
77
+
78
+
79
+ def main(argv=None) -> int:
80
+ p = argparse.ArgumentParser(
81
+ prog="loudcheck",
82
+ description="Loudness compliance verdict against formal published "
83
+ "standards (EBU R128, ATSC A/85, BS.1770 measure-only).")
84
+ p.add_argument("files", nargs="*",
85
+ help="media file(s) or a directory (batch mode)")
86
+ p.add_argument("--schema", action="store_true",
87
+ help="print the machine-readable tool definition "
88
+ "(tool.json) and exit")
89
+ p.add_argument("--standard", default="EBU_R128",
90
+ choices=sorted(STANDARDS),
91
+ help="standard to verdict against (default: EBU_R128)")
92
+ p.add_argument("--stream", type=int, default=0,
93
+ help="audio stream index to check (default: 0)")
94
+ p.add_argument("--all-streams", action="store_true",
95
+ help="verdict every audio stream in the file")
96
+ p.add_argument("--detailed", action="store_true",
97
+ help="add max momentary / max short-term (extra ffmpeg pass)")
98
+ p.add_argument("--json", action="store_true",
99
+ help="emit the full verdict JSON")
100
+ args = p.parse_args(argv)
101
+
102
+ if args.schema:
103
+ print(tool_schema())
104
+ return 0
105
+ if not args.files:
106
+ p.error("files required (or --schema)")
107
+
108
+ targets = expand_targets(args.files)
109
+ if not targets:
110
+ print("Error: no media files found", file=sys.stderr)
111
+ return 2
112
+
113
+ results = []
114
+ try:
115
+ for path in targets:
116
+ if args.all_streams:
117
+ n = audio_stream_count(path)
118
+ if n == 0:
119
+ raise AnalysisError("Error: no audio stream found")
120
+ for s in range(n):
121
+ r = check(measure(path, stream=s, detailed=args.detailed),
122
+ args.standard)
123
+ r["file"] = path
124
+ results.append(r)
125
+ else:
126
+ r = check(measure(path, stream=args.stream,
127
+ detailed=args.detailed), args.standard)
128
+ r["file"] = path
129
+ results.append(r)
130
+ except (AnalysisError, ValueError) as e:
131
+ print(str(e), file=sys.stderr)
132
+ return 2
133
+
134
+ batch = len(results) > 1
135
+ if args.json:
136
+ print(json.dumps(results if batch else results[0], indent=2))
137
+ elif batch:
138
+ # table: one line per file/stream
139
+ for r in results:
140
+ m = r["metrics"]
141
+ stream = r["measurement_context"]["audio_stream"]
142
+ tag = f"{r['file']}" + (f" [a:{stream}]" if args.all_streams else "")
143
+ lines = [f"{r['verdict'].upper():8} {tag} "
144
+ f"I {m['integrated']['measured']:.1f} "
145
+ f"TP {m['true_peak']['measured']:.1f}"]
146
+ for rem in r["remediation"]:
147
+ lines.append(f" → {rem}")
148
+ print("\n".join(lines))
149
+ n_fail = sum(1 for r in results if r["verdict"] == "fail")
150
+ print(f"-- {len(results)} checked, "
151
+ f"{len(results) - n_fail} pass, {n_fail} fail")
152
+ else:
153
+ print(human(results[0]))
154
+
155
+ return 1 if any(r["verdict"] == "fail" for r in results) else 0
156
+
157
+
158
+ if __name__ == "__main__":
159
+ raise SystemExit(main())
@@ -0,0 +1,57 @@
1
+ """
2
+ MCP surface — thin wrapper over the same engine the CLI uses (P0.6).
3
+
4
+ Register as a stdio MCP server:
5
+ python -m loudcheck.mcp_server
6
+ Import path verified against mcp==1.28.1.
7
+ """
8
+
9
+ from mcp.server.fastmcp import FastMCP
10
+
11
+ from .analyze import AnalysisError, measure
12
+ from .standards import STANDARDS
13
+ from .verdict import check
14
+
15
+ mcp = FastMCP("loudcheck")
16
+
17
+
18
+ @mcp.tool()
19
+ def check_loudness(path: str, standard: str = "EBU_R128",
20
+ stream: int = 0, detailed: bool = False) -> dict:
21
+ """
22
+ Loudness compliance verdict for a media file against a formal published
23
+ standard. Measures integrated loudness, loudness range, and true peak
24
+ (via ffmpeg), then evaluates them against the named standard's targets
25
+ and tolerances.
26
+
27
+ Args:
28
+ path: media file to check (any format ffmpeg can read).
29
+ standard: EBU_R128 (broadcast, -23 LUFS), ATSC_A85 (US TV, -24 LKFS),
30
+ or BS_1770 (measurement only — verdict "measured", no gates).
31
+ stream: zero-based audio stream index for multi-track files.
32
+ detailed: also report max momentary / max short-term loudness
33
+ (one extra ffmpeg pass).
34
+
35
+ Returns:
36
+ dict with: verdict (pass|fail|measured), per-metric
37
+ measured/target/delta and pass booleans, failures (plain-English
38
+ causes), remediation (exact corrections, e.g. "apply -2.3 LU gain"),
39
+ and measurement_context (ffmpeg version, stream info).
40
+ """
41
+ try:
42
+ result = check(measure(path, stream=stream, detailed=detailed), standard)
43
+ result["file"] = path # echoed for batch-calling agents; matches CLI JSON
44
+ return result
45
+ except (AnalysisError, ValueError) as e:
46
+ return {"verdict": "error", "error": str(e), "file": path}
47
+
48
+
49
+ @mcp.tool()
50
+ def list_standards() -> dict:
51
+ """List the standards this tool can verdict against, with their targets,
52
+ tolerances, and spec citations."""
53
+ return {"standards": STANDARDS}
54
+
55
+
56
+ if __name__ == "__main__":
57
+ mcp.run()
loudcheck/standards.py ADDED
@@ -0,0 +1,94 @@
1
+ """
2
+ Standards catalog — PURE DATA, no logic.
3
+
4
+ Every entry cites the published spec text it encodes. Only formal, stable
5
+ standards belong here; per-platform delivery templates (Netflix/DPP/...)
6
+ are explicitly out of scope — see the scope guardrail in the README. A
7
+ standard is: targets + tolerances + citations. Nothing else.
8
+
9
+ Verified against published text on 2026-07-02:
10
+ - EBU R 128 (v4, 2020): "the Programme Loudness Level shall be normalised
11
+ to a Target Level of -23.0 LUFS. The deviation from the Target Level
12
+ shall not exceed +/-0.5 LU" ... "The Maximum Permitted True Peak Level
13
+ of a programme during production shall be -1 dBTP." R 128 sets NO hard
14
+ limit on Loudness Range; LRA is reported as informational.
15
+ https://tech.ebu.ch/docs/r/r128.pdf
16
+ - ATSC A/85:2013 (§5.4, §5.5): "the Target Loudness value should be
17
+ -24 LKFS. Minor measurement variations of up to approximately +/-2 dB
18
+ about this value are anticipated, due to measurement uncertainty, and
19
+ are acceptable" ... "The true-peak level should be kept below -2 dB TP
20
+ in order to provide headroom to avoid potential clipping due to
21
+ downstream processing."
22
+ https://www.atsc.org/atsc-documents/a85-techniques-for-establishing-and-maintaining-audio-loudness-for-digital-television/
23
+ """
24
+
25
+ STANDARDS = {
26
+ "EBU_R128": {
27
+ "name": "EBU R 128",
28
+ "citation": "EBU R 128 (https://tech.ebu.ch/docs/r/r128.pdf)",
29
+ "unit": "LUFS",
30
+ "metrics": {
31
+ "integrated": {
32
+ "target": -23.0,
33
+ "tolerance": 0.5, # LU; R128 permits ±1.0 for live programmes
34
+ "gated": True,
35
+ "citation": "R 128: Target Level -23.0 LUFS, deviation shall not exceed ±0.5 LU",
36
+ },
37
+ "true_peak": {
38
+ "max": -1.0, # dBTP
39
+ "gated": True,
40
+ "citation": "R 128: Maximum Permitted True Peak Level -1 dBTP",
41
+ },
42
+ "lra": {
43
+ "gated": False, # R128 core sets no LRA limit — informational
44
+ "citation": "R 128 defines LRA measurement (EBU Tech 3342) but sets no limit",
45
+ },
46
+ },
47
+ },
48
+ "BS_1770": {
49
+ # Measure-only base mode: ITU-R BS.1770 defines the MEASUREMENT
50
+ # algorithm (K-weighted loudness, true peak, gating) but sets no
51
+ # compliance target — so this entry gates nothing and the verdict is
52
+ # "measured", not pass/fail. This is deliberately NOT a place to
53
+ # invent targets; see the scope guardrail.
54
+ "name": "ITU-R BS.1770 (measurement only)",
55
+ "citation": "ITU-R BS.1770-5 (https://www.itu.int/rec/R-REC-BS.1770)",
56
+ "unit": "LUFS",
57
+ "metrics": {
58
+ "integrated": {
59
+ "gated": False,
60
+ "citation": "BS.1770 defines measurement; no compliance target exists in the spec",
61
+ },
62
+ "true_peak": {
63
+ "gated": False,
64
+ "citation": "BS.1770 Annex 2 defines true-peak measurement; no limit exists in the spec",
65
+ },
66
+ "lra": {
67
+ "gated": False,
68
+ "citation": "LRA per EBU Tech 3342; informational",
69
+ },
70
+ },
71
+ },
72
+ "ATSC_A85": {
73
+ "name": "ATSC A/85:2013",
74
+ "citation": "ATSC A/85:2013 (https://www.atsc.org/atsc-documents/a85-techniques-for-establishing-and-maintaining-audio-loudness-for-digital-television/)",
75
+ "unit": "LKFS",
76
+ "metrics": {
77
+ "integrated": {
78
+ "target": -24.0,
79
+ "tolerance": 2.0, # dB; A/85 §5.4 "variations of up to approximately ±2 dB ... are acceptable"
80
+ "gated": True,
81
+ "citation": "A/85: Target Loudness -24 LKFS, ±2 dB measurement variation acceptable",
82
+ },
83
+ "true_peak": {
84
+ "max": -2.0, # dBTP
85
+ "gated": True,
86
+ "citation": "A/85: true-peak level should be kept below -2 dBTP",
87
+ },
88
+ "lra": {
89
+ "gated": False,
90
+ "citation": "A/85 sets no loudness-range limit — informational",
91
+ },
92
+ },
93
+ },
94
+ }
loudcheck/tool.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "loudcheck",
3
+ "description": "Loudness compliance verdict against formal published standards (EBU R128, ATSC A/85). Measures integrated loudness, loudness range, and true peak via ffmpeg, returns pass/fail with per-metric deltas and exact remediation — a compliance answer, not raw meter output.",
4
+ "usage": "loudcheck <file> [--standard EBU_R128|ATSC_A85] [--json] | loudcheck --schema",
5
+ "requires": "ffmpeg >= 5.0 on PATH",
6
+ "exit_codes": {
7
+ "0": "pass — file complies with the standard",
8
+ "1": "fail — non-compliant; remediation included in output",
9
+ "2": "error — missing file, no audio stream, ffmpeg absent, unknown standard"
10
+ },
11
+ "output": {
12
+ "json_flag": "--json emits the full verdict object on stdout",
13
+ "shape": {
14
+ "verdict": "pass | fail",
15
+ "standard": "standard id",
16
+ "metrics": "integrated {measured,target,tolerance,delta,pass,citation}, true_peak {measured,max,delta,pass,citation}, lra {measured,informational}",
17
+ "failures": "plain-English causes, one per failed metric",
18
+ "remediation": "exact corrections, e.g. 'apply -2.3 LU gain ... loudnorm I=-23'",
19
+ "measurement_context": "ffmpeg_version, sample_rate, channels, duration_seconds, gating_threshold"
20
+ },
21
+ "errors": "Error:-prefixed text on stderr, never a traceback"
22
+ },
23
+ "mcp": {
24
+ "server": "python -m loudcheck.mcp_server",
25
+ "transport": "stdio",
26
+ "tools": [
27
+ "check_loudness(path, standard='EBU_R128') -> verdict dict (identical to CLI --json)",
28
+ "list_standards() -> catalog with targets, tolerances, citations"
29
+ ]
30
+ },
31
+ "scope": {
32
+ "in": "formal, stable standards only (EBU R128, ATSC A/85; PRs adding formal standards welcome as pure data)",
33
+ "out": "per-platform delivery templates (Netflix/DPP/...), loudness correction, full-file QC, real-time monitoring"
34
+ }
35
+ }
loudcheck/verdict.py ADDED
@@ -0,0 +1,134 @@
1
+ """
2
+ Verdict engine — evaluate a Measurement against a named standard.
3
+
4
+ Output contract (P0.3, P0.4): a JSON-serializable dict with a top-level
5
+ verdict, per-metric measured/target/tolerance/delta/pass, and — on any
6
+ fail — concrete remediation text with the exact correction (e.g. "apply
7
+ -2.3 LU gain"). Gain remediation maps directly onto ffmpeg's loudnorm/volume
8
+ filters; this tool never applies it (measurement and verdict only).
9
+
10
+ Gating is data-driven from the standards catalog: a metric only gates the
11
+ verdict if its catalog entry says `gated: True`. A standard with no gated
12
+ metrics (BS_1770) yields verdict "measured" — measurement without judgment,
13
+ because the spec defines no target to judge against.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from .analyze import Measurement
21
+ from .standards import STANDARDS
22
+
23
+
24
+ def check(measurement: Measurement, standard: str) -> dict[str, Any]:
25
+ if standard not in STANDARDS:
26
+ raise ValueError(
27
+ f"Error: unknown standard '{standard}' "
28
+ f"(available: {', '.join(sorted(STANDARDS))})")
29
+ spec = STANDARDS[standard]
30
+ unit = spec["unit"]
31
+ metrics: dict[str, Any] = {}
32
+ failures: list[str] = []
33
+ remediation: list[str] = []
34
+ any_gate = False
35
+
36
+ # --- integrated loudness (gate: target ± tolerance) ---------------------
37
+ integ = spec["metrics"]["integrated"]
38
+ entry: dict[str, Any] = {
39
+ "measured": round(measurement.integrated, 2),
40
+ "unit": unit,
41
+ "citation": integ["citation"],
42
+ }
43
+ if integ["gated"]:
44
+ any_gate = True
45
+ delta = round(measurement.integrated - integ["target"], 2)
46
+ integ_pass = abs(delta) <= integ["tolerance"]
47
+ entry.update(target=integ["target"], tolerance=integ["tolerance"],
48
+ delta=delta)
49
+ entry["pass"] = integ_pass
50
+ if not integ_pass:
51
+ direction = "over" if delta > 0 else "under"
52
+ failures.append(
53
+ f"integrated loudness {measurement.integrated:.1f} {unit} is "
54
+ f"{abs(delta):.1f} LU {direction} target {integ['target']:.1f}")
55
+ remediation.append(
56
+ f"apply {-delta:+.1f} LU gain to reach {integ['target']:.1f} "
57
+ f"{unit} (e.g. ffmpeg -af volume={-delta:.1f}dB, or loudnorm "
58
+ f"I={integ['target']:.0f})")
59
+ else:
60
+ entry["pass"] = True
61
+ entry["informational"] = True
62
+ metrics["integrated"] = entry
63
+
64
+ # --- true peak (gate: hard maximum) --------------------------------------
65
+ tp = spec["metrics"]["true_peak"]
66
+ entry = {
67
+ "measured": round(measurement.true_peak, 2),
68
+ "unit": "dBTP",
69
+ "citation": tp["citation"],
70
+ }
71
+ if tp["gated"]:
72
+ any_gate = True
73
+ overage = round(measurement.true_peak - tp["max"], 2)
74
+ tp_pass = measurement.true_peak <= tp["max"]
75
+ entry.update(max=tp["max"], delta=overage)
76
+ entry["pass"] = tp_pass
77
+ if not tp_pass:
78
+ failures.append(
79
+ f"true peak {measurement.true_peak:.1f} dBTP exceeds the "
80
+ f"{tp['max']:.1f} dBTP limit by {overage:.1f} dB")
81
+ remediation.append(
82
+ f"reduce peaks by at least {overage:.1f} dB (a "
83
+ f"{-overage:.1f} dB gain reduction, or a true-peak limiter at "
84
+ f"{tp['max']:.1f} dBTP)")
85
+ else:
86
+ entry["pass"] = True
87
+ entry["informational"] = True
88
+ metrics["true_peak"] = entry
89
+
90
+ # --- loudness range (informational unless a standard gates it) -----------
91
+ lra = spec["metrics"]["lra"]
92
+ metrics["lra"] = {
93
+ "measured": round(measurement.lra, 2),
94
+ "unit": "LU",
95
+ "pass": True,
96
+ "informational": not lra["gated"],
97
+ "citation": lra["citation"],
98
+ }
99
+
100
+ # --- optional detailed metrics (P1.3) -------------------------------------
101
+ if measurement.max_momentary is not None:
102
+ metrics["max_momentary"] = {
103
+ "measured": round(measurement.max_momentary, 2),
104
+ "unit": unit, "pass": True, "informational": True,
105
+ "citation": "max momentary loudness (400 ms window), diagnostic",
106
+ }
107
+ if measurement.max_short_term is not None:
108
+ metrics["max_short_term"] = {
109
+ "measured": round(measurement.max_short_term, 2),
110
+ "unit": unit, "pass": True, "informational": True,
111
+ "citation": "max short-term loudness (3 s window), diagnostic",
112
+ }
113
+
114
+ if not any_gate:
115
+ verdict = "measured"
116
+ else:
117
+ verdict = "pass" if not failures else "fail"
118
+ return {
119
+ "verdict": verdict,
120
+ "standard": standard,
121
+ "standard_name": spec["name"],
122
+ "citation": spec["citation"],
123
+ "metrics": metrics,
124
+ "failures": failures,
125
+ "remediation": remediation,
126
+ "measurement_context": {
127
+ "ffmpeg_version": measurement.ffmpeg_version,
128
+ "audio_stream": measurement.stream_index,
129
+ "sample_rate": measurement.sample_rate,
130
+ "channels": measurement.channels,
131
+ "duration_seconds": measurement.duration_seconds,
132
+ "gating_threshold": measurement.threshold,
133
+ },
134
+ }
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: loudcheck
3
+ Version: 0.3.0
4
+ Summary: Loudness compliance verdicts against formal published standards (EBU R128, ATSC A/85) — a pass/fail answer with exact deltas, not raw meter output.
5
+ Author-email: chaoz23 <chaoz23@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/chaoz23/loudcheck
8
+ Project-URL: Issues, https://github.com/chaoz23/loudcheck/issues
9
+ Keywords: loudness,ebu-r128,atsc-a85,lufs,true-peak,ffmpeg,compliance,mcp,agents,broadcast
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Provides-Extra: mcp
14
+ Requires-Dist: mcp==1.28.1; extra == "mcp"
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7; extra == "dev"
17
+ Dynamic: license-file
18
+
19
+ # loudcheck
20
+
21
+ A **loudness compliance verdict**, not raw meter output. `loudcheck` measures
22
+ a media file with ffmpeg and answers the question that actually matters —
23
+ *does this file pass the spec?* — against formal published standards:
24
+
25
+ - **EBU R 128** (European broadcast: −23.0 LUFS ±0.5 LU, max −1 dBTP)
26
+ - **ATSC A/85** (US television: −24 LKFS ±2 dB, true peak below −2 dBTP)
27
+ - **BS.1770** (measure-only: BS.1770 defines no compliance target, so this
28
+ mode returns verdict `measured` with the numbers and no judgment)
29
+
30
+ ```
31
+ $ loudcheck master.wav --standard EBU_R128
32
+ FAIL — EBU R 128
33
+ ✗ integrated -19.4 LUFS (target -23.0 ±0.5, delta +3.6)
34
+ ✓ true peak -16.3 dBTP (max -1.0)
35
+ · LRA 0.0 LU (informational)
36
+ → apply -3.6 LU gain to reach -23.0 LUFS (e.g. ffmpeg -af volume=-3.6dB, or loudnorm I=-23)
37
+ ```
38
+
39
+ Ships as a CLI and an MCP tool over one engine, so agents and humans get the
40
+ identical verdict.
41
+
42
+ ## Why this exists
43
+
44
+ An agent (or an engineer) can run ffmpeg's `ebur128` filter and get numbers.
45
+ What it can't get from a shell is the *verdict* — that requires knowing the
46
+ standard's target, tolerance, and gating, and interpreting integrated
47
+ loudness vs. LRA vs. true peak against them. Loudness is one of the most
48
+ common causes of delivery rejection, and the gap is not measurement — it's
49
+ the standards-aware answer. That's the whole tool.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install -e . # CLI (requires ffmpeg >= 5.0 on PATH)
55
+ pip install -e ".[mcp]" # + MCP server
56
+ pip install -e ".[dev]" # + pytest
57
+ ```
58
+
59
+ ## CLI
60
+
61
+ ```bash
62
+ loudcheck file.wav # EBU R128 by default
63
+ loudcheck file.mp4 --standard ATSC_A85 # first audio stream of a video
64
+ loudcheck file.wav --json # full structured verdict
65
+ loudcheck file.wav --standard BS_1770 # measurement only, no gates
66
+ loudcheck file.mov --all-streams # verdict every audio track
67
+ loudcheck file.mov --stream 1 # a specific audio track
68
+ loudcheck file.wav --detailed # + max momentary / short-term
69
+ loudcheck masters/ --standard EBU_R128 # batch a directory -> table
70
+ loudcheck a.wav b.wav c.wav # batch multiple files
71
+ loudcheck --schema # print the tool definition
72
+ ```
73
+
74
+ Batch mode prints one line per file (plus remediation for fails) and a
75
+ summary; `--json` in batch emits an array. Exit code is `1` if *any* file
76
+ fails.
77
+
78
+ ## For agents
79
+
80
+ - **Exit codes carry the verdict:** `0` = pass, `1` = fail (non-compliant),
81
+ `2` = error (missing file, no audio stream, no ffmpeg). Gate a delivery on
82
+ the exit code alone.
83
+ - **`--json` is the full contract:** overall `verdict`, per-metric
84
+ `measured/target/tolerance/delta/pass` with the spec citation attached to
85
+ every gated metric, `failures` in plain English, and `remediation` with the
86
+ **exact correction** — a fail 2.3 LU over target tells you to apply
87
+ −2.3 LU gain and hands you the ffmpeg incantation. This tool never applies
88
+ the fix (measurement and verdict only); the agent one-shots it with
89
+ `loudnorm` using the delta provided.
90
+ - **MCP:** register `python -m loudcheck.mcp_server` (stdio). Tools:
91
+ `check_loudness(path, standard)` → same JSON as the CLI, and
92
+ `list_standards()` → the catalog with citations. Verified against
93
+ `mcp==1.28.1`.
94
+ - **`tool.json`** at the repo root describes the surface machine-readably —
95
+ or fetch it live from any install with `loudcheck --schema` (the file ships
96
+ inside the package; a test keeps the two copies in sync).
97
+ - **ffmpeg version is part of the contract:** every verdict includes
98
+ `measurement_context.ffmpeg_version`. Minimum supported: 5.0. Developed and
99
+ verified against 8.1.
100
+
101
+ ## The scope guardrail (read before contributing)
102
+
103
+ **Only formal, stable standards live in this repo; per-platform delivery
104
+ templates (Netflix, DPP, Apple TV+, Amazon, broadcaster specs) never do.**
105
+ Platform specs change unilaterally and cover far more than loudness — the
106
+ moment they enter, this stops being a near-zero-maintenance community tool
107
+ and becomes a yearly-maintenance product. If a PR adds a target that a
108
+ platform can change on its own, it belongs in a separate template layer
109
+ built *on top of* this primitive, not here.
110
+
111
+ Contributions of additional *formal* standards (e.g. a plain ITU-R BS.1770
112
+ mode) are welcome: a standard is pure data in
113
+ [`loudcheck/standards.py`](loudcheck/standards.py) — targets, tolerances, and
114
+ citations. No code changes required.
115
+
116
+ ## How it measures
117
+
118
+ One ffmpeg pass with `loudnorm=print_format=json` (analysis mode) yields
119
+ integrated loudness, loudness range, true peak (oversampled dBTP per
120
+ BS.1770), and the gating threshold. The test suite cross-checks loudnorm's
121
+ reading against ffmpeg's independent `ebur128` implementation — the two must
122
+ agree within 1 LU for CI to pass, so an ffmpeg release that changes filter
123
+ behavior is caught by the suite, not by users.
124
+
125
+ ## Verification corpus
126
+
127
+ `pytest` generates calibrated test tones on the fly (no binaries in the
128
+ repo): per BS.1770's calibration statement, a mono 997 Hz sine at 0 dBFS
129
+ reads −3.01 LKFS, so tones are generated at exact known loudness — compliant,
130
+ too loud, too quiet, and true-peak-hot — and every verdict must match its
131
+ known expectation.
132
+
133
+ ## Out of scope, permanently
134
+
135
+ Loudness *correction* (use ffmpeg `loudnorm` with the delta this tool gives
136
+ you) · full-file QC (codec/colour/cadence) · real-time monitoring · GUIs ·
137
+ platform delivery templates (see guardrail).
138
+
139
+ ## License
140
+
141
+ MIT
@@ -0,0 +1,13 @@
1
+ loudcheck/__init__.py,sha256=vJwKa9Ys9d1DOpCiaP1R-480VmPn-EWfV-zl2i-13GM,271
2
+ loudcheck/analyze.py,sha256=-Mo8NI_0pBnxckDw4xH0aTM7k9NxKrFb8sN7Vo8QEr0,6518
3
+ loudcheck/cli.py,sha256=Kw3uYbzlpJ1aj6fUbzIvH7kZkNVIjVIaXoMT4lbZdIs,5939
4
+ loudcheck/mcp_server.py,sha256=9UwqTRtLrAVmcfET6BXbNCKMvBRBXDU_BnkJmxlvAZE,2003
5
+ loudcheck/standards.py,sha256=-BxUc1IGbuJRZxbSofZf90UwPgXUh_IElx1FyX0eWuE,4173
6
+ loudcheck/tool.json,sha256=oC9GnA7-VSIfTK-GGn_GFclcYsuGJpG9x1NJ2IdODGo,1853
7
+ loudcheck/verdict.py,sha256=kiXq6gPCMOiudAfOOXfWT3PT7OpPGGaUIrDYBlG9Bt8,5206
8
+ loudcheck-0.3.0.dist-info/licenses/LICENSE,sha256=IR3NcBta7PqYBrYZxTwEOvVeWeqBJLk7IRiZ-LnqQMA,1064
9
+ loudcheck-0.3.0.dist-info/METADATA,sha256=3Lt772pL40P9r7ZqCvY_RgbEUhEvrN85jlQz8qVf7No,6246
10
+ loudcheck-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ loudcheck-0.3.0.dist-info/entry_points.txt,sha256=MC4t7DjSPczcG2XC79HuIw3EedvvgECC2Exnv3hl63I,49
12
+ loudcheck-0.3.0.dist-info/top_level.txt,sha256=2J7bj-M6rxZ9Sptljbz2ss2AJVIF_R5L-GK2Mzix_dY,10
13
+ loudcheck-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ loudcheck = loudcheck.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 chaoz23
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ loudcheck