loudcheck 0.3.0__tar.gz

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.
@@ -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,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,123 @@
1
+ # loudcheck
2
+
3
+ A **loudness compliance verdict**, not raw meter output. `loudcheck` measures
4
+ a media file with ffmpeg and answers the question that actually matters —
5
+ *does this file pass the spec?* — against formal published standards:
6
+
7
+ - **EBU R 128** (European broadcast: −23.0 LUFS ±0.5 LU, max −1 dBTP)
8
+ - **ATSC A/85** (US television: −24 LKFS ±2 dB, true peak below −2 dBTP)
9
+ - **BS.1770** (measure-only: BS.1770 defines no compliance target, so this
10
+ mode returns verdict `measured` with the numbers and no judgment)
11
+
12
+ ```
13
+ $ loudcheck master.wav --standard EBU_R128
14
+ FAIL — EBU R 128
15
+ ✗ integrated -19.4 LUFS (target -23.0 ±0.5, delta +3.6)
16
+ ✓ true peak -16.3 dBTP (max -1.0)
17
+ · LRA 0.0 LU (informational)
18
+ → apply -3.6 LU gain to reach -23.0 LUFS (e.g. ffmpeg -af volume=-3.6dB, or loudnorm I=-23)
19
+ ```
20
+
21
+ Ships as a CLI and an MCP tool over one engine, so agents and humans get the
22
+ identical verdict.
23
+
24
+ ## Why this exists
25
+
26
+ An agent (or an engineer) can run ffmpeg's `ebur128` filter and get numbers.
27
+ What it can't get from a shell is the *verdict* — that requires knowing the
28
+ standard's target, tolerance, and gating, and interpreting integrated
29
+ loudness vs. LRA vs. true peak against them. Loudness is one of the most
30
+ common causes of delivery rejection, and the gap is not measurement — it's
31
+ the standards-aware answer. That's the whole tool.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install -e . # CLI (requires ffmpeg >= 5.0 on PATH)
37
+ pip install -e ".[mcp]" # + MCP server
38
+ pip install -e ".[dev]" # + pytest
39
+ ```
40
+
41
+ ## CLI
42
+
43
+ ```bash
44
+ loudcheck file.wav # EBU R128 by default
45
+ loudcheck file.mp4 --standard ATSC_A85 # first audio stream of a video
46
+ loudcheck file.wav --json # full structured verdict
47
+ loudcheck file.wav --standard BS_1770 # measurement only, no gates
48
+ loudcheck file.mov --all-streams # verdict every audio track
49
+ loudcheck file.mov --stream 1 # a specific audio track
50
+ loudcheck file.wav --detailed # + max momentary / short-term
51
+ loudcheck masters/ --standard EBU_R128 # batch a directory -> table
52
+ loudcheck a.wav b.wav c.wav # batch multiple files
53
+ loudcheck --schema # print the tool definition
54
+ ```
55
+
56
+ Batch mode prints one line per file (plus remediation for fails) and a
57
+ summary; `--json` in batch emits an array. Exit code is `1` if *any* file
58
+ fails.
59
+
60
+ ## For agents
61
+
62
+ - **Exit codes carry the verdict:** `0` = pass, `1` = fail (non-compliant),
63
+ `2` = error (missing file, no audio stream, no ffmpeg). Gate a delivery on
64
+ the exit code alone.
65
+ - **`--json` is the full contract:** overall `verdict`, per-metric
66
+ `measured/target/tolerance/delta/pass` with the spec citation attached to
67
+ every gated metric, `failures` in plain English, and `remediation` with the
68
+ **exact correction** — a fail 2.3 LU over target tells you to apply
69
+ −2.3 LU gain and hands you the ffmpeg incantation. This tool never applies
70
+ the fix (measurement and verdict only); the agent one-shots it with
71
+ `loudnorm` using the delta provided.
72
+ - **MCP:** register `python -m loudcheck.mcp_server` (stdio). Tools:
73
+ `check_loudness(path, standard)` → same JSON as the CLI, and
74
+ `list_standards()` → the catalog with citations. Verified against
75
+ `mcp==1.28.1`.
76
+ - **`tool.json`** at the repo root describes the surface machine-readably —
77
+ or fetch it live from any install with `loudcheck --schema` (the file ships
78
+ inside the package; a test keeps the two copies in sync).
79
+ - **ffmpeg version is part of the contract:** every verdict includes
80
+ `measurement_context.ffmpeg_version`. Minimum supported: 5.0. Developed and
81
+ verified against 8.1.
82
+
83
+ ## The scope guardrail (read before contributing)
84
+
85
+ **Only formal, stable standards live in this repo; per-platform delivery
86
+ templates (Netflix, DPP, Apple TV+, Amazon, broadcaster specs) never do.**
87
+ Platform specs change unilaterally and cover far more than loudness — the
88
+ moment they enter, this stops being a near-zero-maintenance community tool
89
+ and becomes a yearly-maintenance product. If a PR adds a target that a
90
+ platform can change on its own, it belongs in a separate template layer
91
+ built *on top of* this primitive, not here.
92
+
93
+ Contributions of additional *formal* standards (e.g. a plain ITU-R BS.1770
94
+ mode) are welcome: a standard is pure data in
95
+ [`loudcheck/standards.py`](loudcheck/standards.py) — targets, tolerances, and
96
+ citations. No code changes required.
97
+
98
+ ## How it measures
99
+
100
+ One ffmpeg pass with `loudnorm=print_format=json` (analysis mode) yields
101
+ integrated loudness, loudness range, true peak (oversampled dBTP per
102
+ BS.1770), and the gating threshold. The test suite cross-checks loudnorm's
103
+ reading against ffmpeg's independent `ebur128` implementation — the two must
104
+ agree within 1 LU for CI to pass, so an ffmpeg release that changes filter
105
+ behavior is caught by the suite, not by users.
106
+
107
+ ## Verification corpus
108
+
109
+ `pytest` generates calibrated test tones on the fly (no binaries in the
110
+ repo): per BS.1770's calibration statement, a mono 997 Hz sine at 0 dBFS
111
+ reads −3.01 LKFS, so tones are generated at exact known loudness — compliant,
112
+ too loud, too quiet, and true-peak-hot — and every verdict must match its
113
+ known expectation.
114
+
115
+ ## Out of scope, permanently
116
+
117
+ Loudness *correction* (use ffmpeg `loudnorm` with the delta this tool gives
118
+ you) · full-file QC (codec/colour/cadence) · real-time monitoring · GUIs ·
119
+ platform delivery templates (see guardrail).
120
+
121
+ ## License
122
+
123
+ MIT
@@ -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"
@@ -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
+ )
@@ -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()