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 +7 -0
- loudcheck/analyze.py +174 -0
- loudcheck/cli.py +159 -0
- loudcheck/mcp_server.py +57 -0
- loudcheck/standards.py +94 -0
- loudcheck/tool.json +35 -0
- loudcheck/verdict.py +134 -0
- loudcheck-0.3.0.dist-info/METADATA +141 -0
- loudcheck-0.3.0.dist-info/RECORD +13 -0
- loudcheck-0.3.0.dist-info/WHEEL +5 -0
- loudcheck-0.3.0.dist-info/entry_points.txt +2 -0
- loudcheck-0.3.0.dist-info/licenses/LICENSE +21 -0
- loudcheck-0.3.0.dist-info/top_level.txt +1 -0
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())
|
loudcheck/mcp_server.py
ADDED
|
@@ -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,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
|