hirescheck 1.5.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.
hirescheck/__init__.py ADDED
@@ -0,0 +1,2462 @@
1
+ #!/usr/bin/env python3
2
+ """Inspect a PCM or DSD file's bandwidth, look for upsampling or PCM origin, and
3
+ estimate a 48 kHz round trip for high-rate PCM.
4
+
5
+ This measures signal properties, not audibility or recording provenance.
6
+ Python 3.10+; numpy, scipy; FFmpeg (ffmpeg + ffprobe on PATH); matplotlib for optional plots.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import csv
13
+ import json
14
+ import math
15
+ import os
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ from pathlib import Path
21
+
22
+
23
+
24
+ def detect_lang() -> str:
25
+ """'zh' when the system locale is Chinese, otherwise 'en'."""
26
+ for var in ("LC_ALL", "LC_MESSAGES", "LANG"):
27
+ value = os.environ.get(var, "")
28
+ if value:
29
+ return "zh" if value.lower().startswith("zh") else "en"
30
+ try:
31
+ import locale
32
+ value = locale.getlocale()[0] or ""
33
+ except (ValueError, ImportError):
34
+ value = ""
35
+ if not value and sys.platform == "darwin":
36
+ try:
37
+ value = subprocess.run(["defaults", "read", "-g", "AppleLanguages"], capture_output=True,
38
+ text=True, timeout=2).stdout.replace("(", "").strip().strip('"')
39
+ except (OSError, subprocess.SubprocessError):
40
+ value = ""
41
+ return "zh" if value.lower().startswith(("zh", "chinese")) else "en"
42
+
43
+
44
+ LANG = detect_lang()
45
+
46
+
47
+ def set_lang(lang: str) -> None:
48
+ global LANG
49
+ LANG = lang
50
+
51
+
52
+ def tr(zh: str, en: str) -> str:
53
+ """The text in the output language; both versions sit side by side at each call site."""
54
+ return zh if LANG == "zh" else en
55
+
56
+
57
+ def sep() -> str:
58
+ return tr(";", "; ")
59
+
60
+
61
+ try:
62
+ import numpy as np
63
+ from scipy import ndimage, signal
64
+ except ImportError as exc:
65
+ raise SystemExit(
66
+ tr(f"缺少依赖:{exc.name}。请运行:\n", f"Missing dependency: {exc.name}. Install with:\n")
67
+ + 'python -m pip install numpy scipy matplotlib'
68
+ ) from exc
69
+
70
+
71
+ VERSION = "1.5.0"
72
+ REFERENCE_RATE = 48000
73
+ FLOOR = 1e-300
74
+ STANDARD_RATES = (44100, 48000, 88200, 96000, 176400, 192000, 352800, 384000)
75
+ # Empirical band-limit rules; all are written to report.json.
76
+ EDGE_BIN_HZ = 100
77
+ EDGE_SEARCH_FROM_HZ = 10000
78
+ EDGE_MIN_DROP_DB = 25
79
+ # A brick wall (core 20-80% of the drop within 1.2 kHz, onto a flat floor) counts from
80
+ # a smaller drop and margin: weak treble over a low floor, common in CD-sourced SACDs.
81
+ EDGE_BRICKWALL_DROP_DB = 15
82
+ EDGE_BRICKWALL_CORE_HZ = 1200
83
+ EDGE_BRICKWALL_FLOOR_STD_DB = 1.5
84
+ EDGE_MAX_WIDTH_HZ = 3000
85
+ EDGE_MIN_SLOPE_DB_PER_OCTAVE = 120
86
+ EDGE_MIN_LEVEL_DB = -175
87
+ EDGE_EMPTY_MARGIN_DB = 20
88
+ EDGE_IMAGED_MARGIN_DB = 6
89
+ IMAGE_MIN_CORRELATION = 0.7
90
+ IMAGE_STANDALONE_CORRELATION = 0.9
91
+ IMAGE_MIN_SHAPE_DB = 1.5
92
+ LOSSY_EDGE_BELOW_HZ = 19000
93
+ # A source rate R is plausible when the edge midpoint lies within this share of R/2:
94
+ # filters pass to at least ~80% of Nyquist and cannot keep content far above it.
95
+ SOURCE_NYQUIST_RATIO = (0.80, 1.03)
96
+ # Steep resamplers put the midpoint a little below Nyquist, FFmpeg's swr right at
97
+ # it; candidates are ranked by how close the midpoint is to this share of R/2.
98
+ SOURCE_TYPICAL_RATIO = 0.975
99
+ # A cliff this deep at a standard Nyquist is a band limit even with weaker energy
100
+ # returning above it (strong images or noise added after the band was limited).
101
+ EDGE_STANDARD_CLIFF_DB = 30
102
+ # DSD: FFmpeg decodes to PCM at 1/8 of the bit rate, and its decimation filter is
103
+ # faithful (<1 dB) only up to about half of that PCM Nyquist; analysis stops there.
104
+ DSD_ANALYSIS_SHARE = 0.5
105
+ # Lossy-codec checks (calibrated on MP3/AAC/Opus/Vorbis round trips of CD rips):
106
+ # codecs low-pass at 16-20.4 kHz with a cliff; CD anti-alias filters sit at
107
+ # 20.75 kHz and above. Psychoacoustic coders also leave short-time holes above 16 kHz.
108
+ LOSSY_MAX_CUTOFF_HZ = 20500
109
+ LOSSY_SEARCH_FROM_HZ = 14000
110
+ LOSSY_STEP_DB = 25
111
+ LOSSY_HOLE_DB = 30
112
+ LOSSY_HOLES_PERCENT = 1.0
113
+ LOSSY_HOLES_NOTE_PERCENT = 0.5
114
+ # A frame with at least this share of its 16 kHz+ band empty is a musical pause, not a codec hole.
115
+ LOSSY_GAP_SHARE = 0.5
116
+ LOSSY_FRAME_SECONDS = 0.093
117
+ LOSSY_MAX_FRAMES = 30000
118
+ DSD_OBSERVABLE_RISE_DB = 20
119
+ # Processing-chain fingerprints (--check-sr / --learn-chain). A chain is a fixed
120
+ # production path (e.g. one shop's converter set-up) that leaves the same narrow
121
+ # tones and the same fine ultrasonic spectral shape on every file it touches.
122
+ CHAIN_FORMAT = 1
123
+ CHAIN_MIN_RATE = 88200 # below this there is no ultrasonic band to fingerprint
124
+ CHAIN_BAND_LOW_HZ = 30000 # shape band: 30 kHz .. 0.495 fs
125
+ CHAIN_POOL_BINS = 4 # shape resolution: 4 FFT bins (~12 Hz at 192 kHz)
126
+ CHAIN_SMOOTH_BINS = 101 # trend removed from the shape (~1.2 kHz at 192 kHz)
127
+ CHAIN_TONE_HALF_HZ = 15 # tone peak searched within +-15 Hz
128
+ CHAIN_TONE_SIDE_HZ = (300, 1500) # local floor: median 0.3-1.5 kHz either side
129
+ CHAIN_TONE_NOTCH_HZ = 200 # tone bins are left out of the shape
130
+ CHAIN_SILENCE_DBFS = -90 # frames quieter than this are not averaged
131
+ CHAIN_LEARN_TONE_DB = 9 # a learned tone must stand this far out in every file
132
+ CHAIN_TONE_MARGIN_DB = 3 # match threshold = weakest learned prominence - 3 dB,
133
+ CHAIN_TONE_DB_RANGE = (8, 12) # clamped to 8-12 dB
134
+ CHAIN_MAX_TONES = 8
135
+ CHAIN_SHAPE_STRONG = 0.15 # shape that upgrades a tone match to "very high"
136
+ CHAIN_SHAPE_ONLY = 0.30 # shape strong enough to match without the tones
137
+ CHAIN_MIN_FILES = 5 # --learn-chain: at least 5 files ...
138
+ CHAIN_MIN_FOLDERS = 3 # ... from at least 3 different folders (albums)
139
+ CHAIN_BUILTIN_DIR = Path(__file__).resolve().parent / "chains" # package data
140
+ CHAIN_USER_DIR = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "hirescheck" / "chains"
141
+ DSD_HUMP_DB = 6
142
+
143
+
144
+ def db(power: float) -> float:
145
+ """Power dB; use a finite display floor for exact digital silence."""
146
+ return float(10 * np.log10(max(float(power), 1e-30)))
147
+
148
+
149
+ def band_power(freq: np.ndarray, psd: np.ndarray, lo: float, hi: float) -> float:
150
+ """One-sided PSD integral. psd can contain channels in its leading axes."""
151
+ mask = (freq >= lo) & (freq < hi)
152
+ if hi >= freq[-1]:
153
+ mask |= freq == freq[-1]
154
+ return float(np.mean(np.sum(psd[..., mask], axis=-1)) * (freq[1] - freq[0]))
155
+
156
+
157
+ def relative_db(power: float, reference: float) -> float | None:
158
+ return db(power / reference) if reference > FLOOR else None
159
+
160
+
161
+ def ffmpeg_input(path: Path) -> str:
162
+ # "file:" keeps names such as "-x.flac" or "concat:a" from being read as options/protocols.
163
+ return "file:" + str(path.resolve())
164
+
165
+
166
+ def missing_ffmpeg(tool: str) -> RuntimeError:
167
+ return RuntimeError(tr(f"找不到 {tool}。请安装 FFmpeg,并确保 ffmpeg 与 ffprobe 在 PATH 中。",
168
+ f"{tool} not found. Install FFmpeg and make sure ffmpeg and ffprobe are on PATH."))
169
+
170
+
171
+ def probe(path: Path) -> dict:
172
+ """Stream facts from ffprobe; the file extension is never trusted."""
173
+ try:
174
+ result = subprocess.run(
175
+ ["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries",
176
+ "stream=codec_name,sample_rate,channels,sample_fmt,bits_per_raw_sample,"
177
+ "bits_per_sample,duration:format=format_name,duration",
178
+ "-of", "json", ffmpeg_input(path)],
179
+ capture_output=True, check=False,
180
+ )
181
+ except FileNotFoundError:
182
+ raise missing_ffmpeg("ffprobe") from None
183
+ if result.returncode:
184
+ raise ValueError(tr("FFmpeg 无法读取该文件:", "FFmpeg cannot read this file: ")
185
+ + result.stderr.decode(errors="replace").strip())
186
+ data = json.loads(result.stdout or b"{}")
187
+ if not data.get("streams"):
188
+ raise ValueError(tr("文件中没有音频流。", "The file has no audio stream."))
189
+ stream, container = data["streams"][0], data.get("format", {})
190
+ codec = stream.get("codec_name", "")
191
+ rate = int(stream.get("sample_rate") or 0)
192
+ if rate <= 0 or int(stream.get("channels") or 0) <= 0:
193
+ # FFmpeg guesses a container from the extension even for non-audio files.
194
+ raise ValueError(tr("无法读出采样率或声道数,文件可能损坏,或并不是音频。",
195
+ "Cannot read the sample rate or channel count; the file may be damaged or not audio."))
196
+ dsd = codec.startswith(("dsd_", "dst"))
197
+ bits = [int(v) for v in (stream.get("bits_per_raw_sample"), stream.get("bits_per_sample"))
198
+ if str(v).isdigit() and int(v) > 0]
199
+ durations = [float(v) for v in (stream.get("duration"), container.get("duration"))
200
+ if v not in (None, "N/A")]
201
+ return {
202
+ "container": container.get("format_name", ""), "codec": codec,
203
+ "sample_rate_hz": rate, "channels": int(stream["channels"]),
204
+ "dsd_rate_hz": rate * 8 if dsd else None,
205
+ "sample_format": stream.get("sample_fmt", ""), "bits_per_sample": bits[0] if bits else None,
206
+ "duration_seconds": durations[0] if durations else None,
207
+ }
208
+
209
+
210
+ class PcmStream:
211
+ """float64 PCM from FFmpeg at the file's own rate and channels: no resampling, mixing or dither."""
212
+
213
+ def __init__(self, path: Path, channels: int, start: float = 0.0, seconds: float | None = None):
214
+ cmd = ["ffmpeg", "-nostdin", "-v", "error"]
215
+ if start > 0:
216
+ cmd += ["-ss", f"{start:.6f}"]
217
+ if seconds is not None:
218
+ cmd += ["-t", f"{seconds:.6f}"]
219
+ cmd += ["-i", ffmpeg_input(path), "-map", "0:a:0", "-f", "f64le", "-c:a", "pcm_f64le", "pipe:1"]
220
+ self.channels = channels
221
+ self.finished = False
222
+ self.warnings = ""
223
+ # A file rather than a pipe, so a flood of decoder warnings cannot stall FFmpeg.
224
+ self.log = tempfile.TemporaryFile()
225
+ try:
226
+ self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=self.log)
227
+ except FileNotFoundError:
228
+ self.log.close()
229
+ raise missing_ffmpeg("ffmpeg") from None
230
+
231
+ def read(self, frames: int) -> np.ndarray:
232
+ size = 8 * self.channels
233
+ raw = self.proc.stdout.read(frames * size)
234
+ if len(raw) < frames * size:
235
+ self.finished = True
236
+ raw = raw[:len(raw) - len(raw) % size]
237
+ return np.frombuffer(raw, "<f8").reshape(-1, self.channels)
238
+
239
+ def __enter__(self) -> PcmStream:
240
+ return self
241
+
242
+ def __exit__(self, exc_type, *_) -> None:
243
+ self.proc.stdout.close()
244
+ if exc_type or not self.finished:
245
+ self.proc.kill() # Stopped early on purpose; the exit status means nothing.
246
+ code = self.proc.wait()
247
+ self.log.seek(0)
248
+ self.warnings = self.log.read().decode(errors="replace").strip()
249
+ self.log.close()
250
+ if exc_type is None and self.finished and code:
251
+ raise RuntimeError(tr("FFmpeg 解码失败:", "FFmpeg decoding failed: ")
252
+ + (self.warnings or tr(f"退出码 {code}", f"exit code {code}")))
253
+
254
+
255
+ class LossyScan:
256
+ """Short-window statistics above 12 kHz for lossy-codec checks.
257
+
258
+ The long analysis window averages away codec artefacts; ~93 ms frames keep
259
+ the holes a psychoacoustic coder leaves when it drops a band for a while.
260
+ Per frame it stores the 2-8 kHz level and the 12-24 kHz spectrum pooled to
261
+ ~50 Hz, at most LOSSY_MAX_FRAMES frames (evenly strided for long files).
262
+ """
263
+
264
+ def __init__(self, fs: int, bits: int | None, expected_seconds: float | None):
265
+ self.n = 2 ** round(math.log2(fs * LOSSY_FRAME_SECONDS))
266
+ self.hop = self.n // 2
267
+ self.window = signal.windows.hann(self.n, sym=False)
268
+ self.scale = 2 / (fs * np.sum(self.window**2))
269
+ freq = np.fft.rfftfreq(self.n, 1 / fs)
270
+ self.mid = (freq >= 2000) & (freq < 8000)
271
+ per = max(1, round(50 / (freq[1] - freq[0])))
272
+ lo = int(np.searchsorted(freq, 12000))
273
+ hi = int(np.searchsorted(freq, min(fs / 2, 24000)))
274
+ self.groups = max(0, (hi - lo) // per)
275
+ self.lo, self.per = lo, per
276
+ self.freq = freq[lo:lo + self.groups * per].reshape(self.groups, per).mean(axis=1) if self.groups else np.array([])
277
+ expected = (expected_seconds or 0) * fs / self.hop
278
+ self.stride = max(1, math.ceil(expected / LOSSY_MAX_FRAMES))
279
+ # Rounding noise of the stored word length; content below it cannot be told from silence.
280
+ self.floor_db = (10 * math.log10((2.0 ** (1 - bits)) ** 2 / 12 / (fs / 2))
281
+ if bits and bits <= 24 else None)
282
+ self.carry = None
283
+ self.count = 0
284
+ self.mids: list[np.ndarray] = []
285
+ self.bands: list[np.ndarray] = []
286
+
287
+ def feed(self, block: np.ndarray) -> None:
288
+ if not self.groups:
289
+ return
290
+ data = block if self.carry is None else np.concatenate((self.carry, block), axis=0)
291
+ if len(data) < self.n:
292
+ self.carry = data
293
+ return
294
+ frames = np.lib.stride_tricks.sliding_window_view(data, self.n, axis=0)[::self.hop]
295
+ self.carry = data[len(frames) * self.hop:].copy()
296
+ keep = (self.count + np.arange(len(frames))) % self.stride == 0
297
+ self.count += len(frames)
298
+ if not keep.any():
299
+ return
300
+ frames = frames[keep]
301
+ power = (np.abs(np.fft.rfft(frames * self.window, axis=-1)) ** 2).mean(axis=1) * self.scale
302
+ self.mids.append(power[:, self.mid].mean(axis=1).astype(np.float32))
303
+ top = power[:, self.lo:self.lo + self.groups * self.per]
304
+ self.bands.append(top.reshape(len(top), self.groups, self.per).mean(axis=2).astype(np.float32))
305
+
306
+ def result(self, freq: np.ndarray, psd: np.ndarray, fs: int) -> dict | None:
307
+ if not self.mids:
308
+ return None
309
+ mid = 10 * np.log10(np.concatenate(self.mids).astype(np.float64) + 1e-30)
310
+ band = np.concatenate(self.bands).astype(np.float64)
311
+ loud = mid > np.percentile(mid, 40)
312
+ if loud.sum() < 20:
313
+ return None
314
+ level = 10 * np.log10(band[loud] + 1e-30)
315
+ # Per-frame cutoff: highest band within 60 dB of the frame's mid level
316
+ # and clearly above the word-length noise floor.
317
+ need = mid[loud][:, None] - 60
318
+ if self.floor_db is not None:
319
+ need = np.maximum(need, self.floor_db + 31.5)
320
+ above = level > need
321
+ last = np.where(above.any(axis=1), self.groups - 1 - np.argmax(above[:, ::-1], axis=1), -1)
322
+ cutoffs = np.where(last >= 0, self.freq[np.maximum(last, 0)], 12000.0)
323
+ p95 = float(np.percentile(cutoffs, 95))
324
+ # Steepest cliff of the average spectrum between 14 kHz and the codec range.
325
+ top = min(fs / 2 - 900, 21800)
326
+ avg = 10 * np.log10(np.maximum(psd, 1e-30))
327
+ step = step_at = pre = None
328
+ for c in np.arange(LOSSY_SEARCH_FROM_HZ, top, 50.0):
329
+ b = float(np.median(avg[(freq >= c - 800) & (freq < c - 100)]))
330
+ a = float(np.median(avg[(freq >= c + 100) & (freq < c + 800)]))
331
+ if step is None or b - a > step:
332
+ step, step_at, pre = b - a, float(c), b
333
+ floor_ok = self.floor_db is None or (pre is not None and pre >= self.floor_db + 20)
334
+ cliff = (step is not None and step >= LOSSY_STEP_DB and step_at <= LOSSY_MAX_CUTOFF_HZ and floor_ok)
335
+ # Holes: time-frequency cells far below that band's own median in loud frames.
336
+ # Codecs drop part of the band; a pause or gated hi-hat empties all of it,
337
+ # so frames that are mostly empty are left out.
338
+ ceiling = min(step_at if cliff else p95, 21000) - 300
339
+ cells = (self.freq >= 16000) & (self.freq < ceiling)
340
+ holes = gaps = None
341
+ if cells.sum() >= 20:
342
+ sub = band[loud][:, cells]
343
+ hole = sub < np.median(sub, axis=0) * 10 ** (-LOSSY_HOLE_DB / 10)
344
+ share = hole.mean(axis=1)
345
+ partial = share < LOSSY_GAP_SHARE
346
+ gaps = float(np.mean(~partial) * 100)
347
+ holes = float(hole[partial].mean() * 100) if partial.any() else 0.0
348
+ return {
349
+ "frames_analyzed": int(loud.sum()), "frame_seconds": self.n / fs,
350
+ "cliff_detected": bool(cliff), "cliff_hz": step_at, "cliff_drop_db": step,
351
+ "frame_cutoff_p95_hz": p95,
352
+ "loud_frames_above_20khz_percent": float(np.mean(cutoffs >= 20000) * 100),
353
+ "holes_percent": holes, "full_band_gap_frames_percent": gaps,
354
+ "holes_detected": bool(holes is not None and holes >= LOSSY_HOLES_PERCENT),
355
+ }
356
+
357
+
358
+ class ChainScan:
359
+ """Long-window average power spectrum of the mono mix, for chain fingerprints.
360
+
361
+ ~fs/3-sample frames (65536 at 192 kHz, ~2.9 Hz bins) resolve the narrow
362
+ converter tones; frames below CHAIN_SILENCE_DBFS are skipped.
363
+ """
364
+
365
+ def __init__(self, fs: int):
366
+ self.fs = fs
367
+ self.n = 2 ** round(math.log2(fs / 3))
368
+ self.hop = self.n // 2
369
+ self.window = signal.windows.hann(self.n, sym=False)
370
+ self.scale = 2 / (fs * np.sum(self.window**2))
371
+ self.sum = np.zeros(self.n // 2 + 1)
372
+ self.count = 0
373
+ self.carry = np.empty(0)
374
+
375
+ def feed(self, block: np.ndarray) -> None:
376
+ data = np.concatenate((self.carry, block.mean(axis=1)))
377
+ if len(data) < self.n:
378
+ self.carry = data
379
+ return
380
+ frames = np.lib.stride_tricks.sliding_window_view(data, self.n)[::self.hop]
381
+ self.carry = data[len(frames) * self.hop:].copy()
382
+ keep = np.mean(frames**2, axis=1) > 10 ** (CHAIN_SILENCE_DBFS / 10)
383
+ if keep.any():
384
+ x = frames[keep]
385
+ x = x - x.mean(axis=1, keepdims=True)
386
+ self.sum += (np.abs(np.fft.rfft(x * self.window, axis=1)) ** 2).sum(axis=0) * self.scale
387
+ self.count += int(keep.sum())
388
+
389
+ def result(self) -> tuple[np.ndarray, np.ndarray] | None:
390
+ if self.count < 4:
391
+ return None
392
+ return np.fft.rfftfreq(self.n, 1 / self.fs), self.sum / self.count
393
+
394
+
395
+ def chain_spectrum(path: Path, max_seconds: float | None) -> tuple[dict, tuple | None]:
396
+ """Decode one file only for its chain spectrum (used by --learn-chain)."""
397
+ meta = probe(path)
398
+ if meta["dsd_rate_hz"]:
399
+ return meta, None
400
+ scan = ChainScan(meta["sample_rate_hz"])
401
+ with PcmStream(path, meta["channels"], seconds=max_seconds) as stream:
402
+ while True:
403
+ block = stream.read(1 << 18)
404
+ if not len(block):
405
+ break
406
+ scan.feed(block)
407
+ return meta, scan.result()
408
+
409
+
410
+ def tone_prominence(freq: np.ndarray, level: np.ndarray, hz: float) -> float | None:
411
+ """Peak within +-15 Hz of `hz` above the median level 0.3-1.5 kHz either side (dB)."""
412
+ near, far = CHAIN_TONE_SIDE_HZ
413
+ peak = level[np.abs(freq - hz) <= CHAIN_TONE_HALF_HZ]
414
+ gap = np.abs(freq - hz)
415
+ side = level[(gap >= near) & (gap <= far)]
416
+ if not len(peak) or not len(side):
417
+ return None
418
+ return float(peak.max() - np.median(side))
419
+
420
+
421
+ def chain_shape(freq: np.ndarray, psd: np.ndarray, fs: int, notch: list[float]) -> np.ndarray:
422
+ """Detrended, standardised fine shape of the ultrasonic spectrum; tone bins are zeroed."""
423
+ index = np.nonzero((freq >= CHAIN_BAND_LOW_HZ) & (freq < 0.495 * fs))[0]
424
+ index = index[:len(index) // CHAIN_POOL_BINS * CHAIN_POOL_BINS]
425
+ if len(index) < CHAIN_POOL_BINS * CHAIN_SMOOTH_BINS * 2:
426
+ return np.zeros(0)
427
+ pooled_f = freq[index].reshape(-1, CHAIN_POOL_BINS).mean(axis=1)
428
+ pooled = 10 * np.log10(np.maximum(psd[index].reshape(-1, CHAIN_POOL_BINS).mean(axis=1), 1e-30))
429
+ fine = pooled - ndimage.uniform_filter1d(pooled, CHAIN_SMOOTH_BINS, mode="nearest")
430
+ keep = np.ones(len(fine), dtype=bool)
431
+ for hz in notch:
432
+ keep &= np.abs(pooled_f - hz) >= CHAIN_TONE_NOTCH_HZ
433
+ shape = np.zeros(len(fine))
434
+ spread = fine[keep].std()
435
+ if spread > 1e-9:
436
+ shape[keep] = (fine[keep] - fine[keep].mean()) / spread
437
+ return shape
438
+
439
+
440
+ def load_chains() -> list[dict]:
441
+ """Built-in chains shipped next to the program, then the user's learned ones."""
442
+ chains, names = [], set()
443
+ for folder, origin in ((CHAIN_BUILTIN_DIR, "builtin"), (CHAIN_USER_DIR, "user")):
444
+ if not folder.is_dir():
445
+ continue
446
+ for file in sorted(folder.glob("*.json")):
447
+ try:
448
+ chain = json.loads(file.read_text(encoding="utf-8"))
449
+ except (OSError, ValueError) as exc:
450
+ print(tr(f"警告:无法读取加工链指纹 {file}:{exc}",
451
+ f"Warning: cannot read chain fingerprint {file}: {exc}"), file=sys.stderr)
452
+ continue
453
+ if chain.get("format") != CHAIN_FORMAT or chain.get("name") in names:
454
+ continue
455
+ names.add(chain["name"])
456
+ chains.append({**chain, "origin": origin, "file": str(file)})
457
+ return chains
458
+
459
+
460
+ def match_chain(freq: np.ndarray, psd: np.ndarray, fs: int, chain: dict) -> dict:
461
+ level = 10 * np.log10(np.maximum(psd, 1e-30))
462
+ tones = []
463
+ for tone in chain["tones"]:
464
+ prominence = tone_prominence(freq, level, tone["hz"])
465
+ tones.append({"hz": tone["hz"], "minimum_db": tone["min_db"], "prominence_db": prominence,
466
+ "present": prominence is not None and prominence >= tone["min_db"]})
467
+ template = np.asarray(chain["shape_template"], dtype=float)
468
+ shape = chain_shape(freq, psd, fs, [t["hz"] for t in chain["tones"]])
469
+ r = float(shape @ template / len(template)) if len(shape) == len(template) and len(shape) else None
470
+ all_tones = len(tones) >= 2 and all(t["present"] for t in tones)
471
+ strong = chain.get("shape_strong", CHAIN_SHAPE_STRONG)
472
+ alone = chain.get("shape_only") # None: this chain's shape is not distinctive enough on its own
473
+ shape_ok = r is not None and r >= strong
474
+ if all_tones and shape_ok:
475
+ level_ = VERY_HIGH
476
+ elif all_tones or (r is not None and alone is not None and r >= alone):
477
+ level_ = HIGH
478
+ else:
479
+ level_ = None
480
+ partial = level_ is None and (any(t["present"] for t in tones) or shape_ok)
481
+ return {"name": chain["name"], "label": chain_field(chain, "label") or chain["name"], "origin": chain["origin"],
482
+ "tones": tones, "shape_correlation": r, "shape_strong": strong, "shape_only": alone,
483
+ "matched": level_ is not None, "level": level_, "partial": partial,
484
+ "note": chain_field(chain, "note"), "source_max_hz": chain.get("source_max_hz")}
485
+
486
+
487
+ def chain_field(chain: dict, key: str) -> str:
488
+ """A text field of a chain file in the output language ("label_en" etc.), else the default one."""
489
+ return (LANG == "en" and chain.get(f"{key}_en")) or chain.get(key) or ""
490
+
491
+
492
+ def chain_check(info: dict, data: dict) -> dict:
493
+ fs = info["sample_rate_hz"]
494
+ if info["dsd_rate_hz"]:
495
+ return {"performed": False, "results": [], "reason": tr(
496
+ "DSD 文件不做加工链检查(目前的指纹都是 PCM 的)。",
497
+ "DSD files are not checked against chains (all fingerprints are PCM).")}
498
+ if fs < CHAIN_MIN_RATE:
499
+ return {"performed": False, "results": [], "reason": tr(
500
+ f"{khz(fs)} 没有超声频段,不做加工链检查。", f"{khz(fs)} has no ultrasonic band; no chain check.")}
501
+ if data["chain"] is None:
502
+ return {"performed": False, "results": [], "reason": tr(
503
+ "有效声音太少,无法做加工链检查。", "Too little signal for a chain check.")}
504
+ chains = [c for c in load_chains() if c["sample_rate_hz"] == fs]
505
+ if not chains:
506
+ return {"performed": False, "results": [], "reason": tr(
507
+ f"没有 {khz(fs)} 的加工链指纹(可用 --learn-chain 学习)。",
508
+ f"No chain fingerprint for {khz(fs)} (create one with --learn-chain).")}
509
+ freq, psd = data["chain"]
510
+ results = [match_chain(freq, psd, fs, c) for c in chains]
511
+ order = {VERY_HIGH: 0, HIGH: 1, None: 2}
512
+ results.sort(key=lambda m: (order[m["level"]], not m["partial"], -(m["shape_correlation"] or -1)))
513
+ return {"performed": True, "reason": "", "results": results,
514
+ "best": results[0] if results[0]["matched"] else None}
515
+
516
+
517
+ def analyze_spectrum(
518
+ path: Path, max_seconds: float | None, n_fft: int = 32768, check_sr: bool = False
519
+ ) -> tuple[dict, dict]:
520
+ """Stream overlapping Hann windows; average *power*, never channel waveforms.
521
+
522
+ Keep PSD sums in 5 dB RMS buckets so silence can be excluded after the
523
+ recording's level is known. Memory is bounded independently of duration.
524
+ Per-frame scalar metrics are pooled to at most 600 time columns.
525
+ """
526
+ meta = probe(path)
527
+ fs, channels = meta["sample_rate_hz"], meta["channels"]
528
+ expected = meta["duration_seconds"]
529
+ if max_seconds is not None:
530
+ expected = min(expected, max_seconds) if expected else max_seconds
531
+ # The time grid is laid out before decoding; containers report duration reliably enough.
532
+ timeline = expected if expected and expected > 0 else 3600.0
533
+
534
+ hop = n_fft // 2
535
+ freq = np.fft.rfftfreq(n_fft, 1 / fs)
536
+ df = fs / n_fft
537
+ window = signal.windows.hann(n_fft, sym=False)
538
+ bucket_edges = np.arange(-160, 10, 5, dtype=float)
539
+ sums = np.zeros((len(bucket_edges), channels, len(freq)))
540
+ counts = np.zeros(len(bucket_edges), dtype=np.int64)
541
+ # A compact, linear-frequency spectrogram and level timeline.
542
+ n_time = max(1, min(600, math.ceil(timeline * fs / hop) - 1))
543
+ spec_indices = np.array_split(np.arange(len(freq)), min(512, len(freq)))
544
+ spec_starts = np.array([s[0] for s in spec_indices])
545
+ spec_sizes = np.array([len(s) for s in spec_indices])
546
+ spec_freq = np.array([np.mean(freq[s]) for s in spec_indices])
547
+ heat = np.zeros((n_time, len(spec_indices)))
548
+ time_counts = np.zeros(n_time, dtype=int)
549
+ # For each RMS bucket and time column: count, audible and HF power.
550
+ timeline_levels = np.zeros((len(bucket_edges), n_time, 3))
551
+ masks = [(freq >= 20) & (freq < min(20000, fs / 2)), freq > 24000]
552
+ frame_start = 0
553
+ total_frames = 0
554
+ decoded = 0
555
+ peak = 0.0
556
+
557
+ def consume(samples: np.ndarray, starts: np.ndarray) -> None:
558
+ nonlocal total_frames
559
+ # samples: [windows, channels, samples per window]
560
+ # Taper a short final window over its actual length. Appending
561
+ # zeros before tapering would create a false high-frequency edge.
562
+ actual_length = samples.shape[-1]
563
+ local_window = window if actual_length == n_fft else signal.windows.hann(actual_length, sym=False)
564
+ scale = 1 / (fs * np.sum(local_window**2))
565
+ rms_power = np.mean(samples**2, axis=(1, 2))
566
+ levels = 10 * np.log10(np.maximum(rms_power, FLOOR))
567
+ buckets = np.clip(
568
+ np.floor((levels + 160) / 5).astype(int), 0, len(bucket_edges) - 1
569
+ )
570
+ centered = samples - samples.mean(axis=-1, keepdims=True)
571
+ transformed = np.fft.rfft(centered * local_window, n=n_fft, axis=-1)
572
+ powers = np.abs(transformed) ** 2 * scale
573
+ powers[..., 1:-1] *= 2
574
+ mono_power = powers.mean(axis=1)
575
+ times = np.minimum((starts + actual_length / 2) / fs, timeline)
576
+ columns = np.minimum((times / timeline * n_time).astype(int), n_time - 1)
577
+ pooled = np.add.reduceat(mono_power, spec_starts, axis=-1) / spec_sizes
578
+ audible = np.sum(mono_power[:, masks[0]], axis=1) * df
579
+ ultrasonic = np.sum(mono_power[:, masks[1]], axis=1) * df
580
+ for i, bucket in enumerate(buckets):
581
+ sums[bucket] += powers[i]
582
+ counts[bucket] += 1
583
+ col = columns[i]
584
+ heat[col] += pooled[i]
585
+ time_counts[col] += 1
586
+ timeline_levels[bucket, col] += (1, audible[i], ultrasonic[i])
587
+ total_frames += len(samples)
588
+
589
+ carry = np.empty((0, channels))
590
+ lossy = None if meta["dsd_rate_hz"] else LossyScan(fs, meta["bits_per_sample"], expected)
591
+ chain = ChainScan(fs) if check_sr and not meta["dsd_rate_hz"] and fs >= CHAIN_MIN_RATE else None
592
+ with PcmStream(path, channels, seconds=max_seconds) as stream:
593
+ while True:
594
+ block = stream.read(hop * 16)
595
+ if not len(block):
596
+ break
597
+ decoded += len(block)
598
+ peak = max(peak, float(np.max(np.abs(block))))
599
+ if lossy is not None:
600
+ lossy.feed(block)
601
+ if chain is not None:
602
+ chain.feed(block)
603
+ data = np.concatenate((carry, block), axis=0)
604
+ if len(data) >= n_fft:
605
+ frames = np.lib.stride_tricks.sliding_window_view(data, n_fft, axis=0)[::hop]
606
+ starts = frame_start + np.arange(len(frames)) * hop
607
+ consume(frames, starts)
608
+ used = len(frames) * hop
609
+ frame_start += used
610
+ carry = data[used:].copy()
611
+ else:
612
+ carry = data
613
+ if not decoded:
614
+ raise ValueError(tr("文件为空,或指定的分析时长不足一个采样点。",
615
+ "The file is empty, or the analysis length is shorter than one sample."))
616
+
617
+ # Cover the tail, including inputs shorter than one FFT window.
618
+ if len(carry) > hop or not total_frames:
619
+ consume(carry.T[None, ...], np.array([frame_start]))
620
+
621
+ # Gate silence relative to the approximate 95th percentile frame RMS.
622
+ cumulative = np.cumsum(counts)
623
+ p95_bucket = int(np.searchsorted(cumulative, 0.95 * total_frames))
624
+ gate = max(-80.0, float(bucket_edges[p95_bucket] - 35))
625
+ active_buckets = bucket_edges >= gate
626
+ active_count = int(counts[active_buckets].sum())
627
+ selected = active_buckets if active_count else np.ones(len(counts), dtype=bool)
628
+ selected_count = int(counts[selected].sum())
629
+ psd_channels = sums[selected].sum(axis=0) / selected_count
630
+ psd = psd_channels.mean(axis=0)
631
+ levels_time = timeline_levels[selected].sum(axis=0)
632
+ valid_times = levels_time[:, 0] > 0
633
+ time_audible = np.divide(
634
+ levels_time[:, 1], levels_time[:, 0],
635
+ out=np.zeros(n_time), where=valid_times,
636
+ )
637
+ time_ultra = np.divide(
638
+ levels_time[:, 2], levels_time[:, 0],
639
+ out=np.zeros(n_time), where=valid_times,
640
+ )
641
+ corr_mask = valid_times & (time_audible > 1e-16) & (time_ultra > 1e-20)
642
+ correlation = None
643
+ if corr_mask.sum() >= 8:
644
+ a = 10 * np.log10(time_audible[corr_mask])
645
+ b = 10 * np.log10(time_ultra[corr_mask])
646
+ if np.std(a) > 1 and np.std(b) > 1:
647
+ correlation = float(np.corrcoef(a, b)[0, 1])
648
+ info = {
649
+ "path": str(path.resolve()), "container": meta["container"], "codec": meta["codec"],
650
+ "bits_per_sample": meta["bits_per_sample"], "sample_format": meta["sample_format"],
651
+ "sample_rate_hz": fs, "channels": channels, "dsd_rate_hz": meta["dsd_rate_hz"],
652
+ "analysis_nyquist_hz": fs / 2 * (DSD_ANALYSIS_SHARE if meta["dsd_rate_hz"] else 1),
653
+ "duration_seconds": meta["duration_seconds"] or decoded / fs,
654
+ "analyzed_seconds": decoded / fs,
655
+ "full_file_scanned": max_seconds is None or (
656
+ meta["duration_seconds"] is not None and meta["duration_seconds"] <= max_seconds),
657
+ "fft_size": n_fft, "frequency_resolution_hz": df,
658
+ "window_count": total_frames, "active_window_count": active_count,
659
+ "rms_gate_dbfs": gate, "peak_sample_dbfs": db(peak**2),
660
+ "decoder_warnings": stream.warnings or None,
661
+ "level_convention": "dBFS = 10 log10(mean square); full-scale sine RMS is -3.01 dBFS",
662
+ }
663
+ return info, {
664
+ "freq": freq, "psd": psd, "psd_channels": psd_channels,
665
+ "spectrogram": heat / np.maximum(time_counts[:, None], 1),
666
+ "spectrogram_freq": spec_freq, "time_extent": timeline,
667
+ "time": (np.arange(n_time) + 0.5) * timeline / n_time,
668
+ "time_audible": time_audible, "time_ultra": time_ultra,
669
+ "time_valid": valid_times, "envelope_correlation": correlation,
670
+ "lossy": lossy.result(freq, psd, fs) if lossy is not None else None,
671
+ "chain": chain.result() if chain is not None else None,
672
+ }
673
+
674
+
675
+ def source_rates(onset: float, mid: float, width: float) -> list[int]:
676
+ """Standard rates whose Nyquist can explain a filter starting to fall at `onset`.
677
+
678
+ A filter passes at least ~80% of its Nyquist and cannot keep content far
679
+ above it. Either the onset or the midpoint may be the better estimate:
680
+ treble already sloping before the edge pulls the onset down, a lax filter
681
+ pushes the midpoint up. A wide transition may also be a lax filter leaking
682
+ past Nyquist, so a Nyquist just below the onset is accepted too. The rate
683
+ whose Nyquist best matches the midpoint comes first.
684
+ """
685
+ low, high = SOURCE_NYQUIST_RATIO
686
+ rates = [r for r in STANDARD_RATES
687
+ if low <= onset / (r / 2) <= high or low <= mid / (r / 2) <= high
688
+ or (width > EDGE_MAX_WIDTH_HZ and onset - width <= r / 2 <= onset)]
689
+ return sorted(rates, key=lambda r: abs(mid / (r / 2) - SOURCE_TYPICAL_RATIO))
690
+
691
+
692
+ def binned_level(freq: np.ndarray, psd: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
693
+ """dB of mean power in ~EDGE_BIN_HZ bins; averaging first keeps FFT variance out of edges."""
694
+ per = max(1, round(EDGE_BIN_HZ / (freq[1] - freq[0])))
695
+ n = (len(freq) - 1) // per
696
+ f = freq[1:1 + n * per].reshape(n, per).mean(axis=1)
697
+ return f, 10 * np.log10(np.maximum(psd[1:1 + n * per].reshape(n, per).mean(axis=1), 1e-30))
698
+
699
+
700
+ def mirror_score(f: np.ndarray, level: np.ndarray, rate: int, top: float, nyquist: float):
701
+ """Correlation of the passband below rate/2 with the spectrum mirrored above it.
702
+
703
+ Upsampling images content at m to rate - m. Natural spectra fall with
704
+ frequency, so they correlate negatively with their own mirror image.
705
+ """
706
+ m = f[(f >= max(1000, rate / 2 - 10000)) & (f <= top - 300)]
707
+ m = m[rate - m <= 0.98 * nyquist]
708
+ if len(m) < 10:
709
+ return None, None
710
+ passband, mirrored = np.interp(m, f, level), np.interp(rate - m, f, level)
711
+ if np.std(passband) < IMAGE_MIN_SHAPE_DB or np.std(mirrored) == 0:
712
+ return None, None
713
+ return float(np.corrcoef(passband, mirrored)[0, 1]), float(np.median(passband - mirrored))
714
+
715
+
716
+ def find_imaging(freq: np.ndarray, psd: np.ndarray, fs: int) -> list[dict]:
717
+ """Resampler images without a cliff: cheap (e.g. linear) interpolation mirrors the
718
+ passband almost unattenuated, so the spectrum folds back up at the old Nyquist."""
719
+ f, level = binned_level(freq, psd)
720
+ found = []
721
+ for rate in STANDARD_RATES:
722
+ if rate >= fs:
723
+ break
724
+ corr, atten = mirror_score(f, level, rate, rate / 2, fs / 2)
725
+ if corr is not None and corr >= IMAGE_STANDALONE_CORRELATION:
726
+ found.append({"source_rate_hz": rate, "correlation": corr, "attenuation_db": atten})
727
+ return found
728
+
729
+
730
+ def find_band_limits(freq: np.ndarray, psd: np.ndarray, fs: int, check_images: bool = True) -> list[dict]:
731
+ """Steep, sustained drops in the averaged spectrum and the source rates each allows.
732
+
733
+ A resampler or codec low-pass leaves a step from the content (or the old
734
+ quantization/dither floor, so dark masters still show it) down to a lower
735
+ floor. The step is a band limit when nothing above it comes back near the
736
+ pre-edge level, when what comes back mirrors the passband (resampler
737
+ imaging), or when a clear gap is followed only by rising noise (noise
738
+ shaping, e.g. a PCM master converted through DSD).
739
+ """
740
+ f, level = binned_level(freq, psd)
741
+ n = len(f)
742
+ if n < 64:
743
+ return []
744
+ step = f[1] - f[0]
745
+ nyquist = fs / 2
746
+
747
+ # Two scales: steep filters at 0.3/1.5 kHz, lax ones (slow skirts) at 1/3 kHz.
748
+ candidates = []
749
+ for gap_hz, span_hz, max_width in [(300, 1500, EDGE_MAX_WIDTH_HZ), (1000, 3000, 2.5 * EDGE_MAX_WIDTH_HZ)]:
750
+ gap, span = max(1, round(gap_hz / step)), max(2, round(span_hz / step))
751
+ if n < 2 * (gap + span) + 2:
752
+ continue
753
+ # Median of each span-wide run; compare the run ending `gap` below a bin with the one starting above.
754
+ run = np.median(np.lib.stride_tricks.sliding_window_view(level, span), axis=1)
755
+ centers = np.arange(gap + span, n - gap - span)
756
+ below, above = run[centers - gap - span], run[centers + gap + 1]
757
+ for k in np.nonzero((below - above >= EDGE_BRICKWALL_DROP_DB) & (f[centers] >= EDGE_SEARCH_FROM_HZ))[0]:
758
+ candidates.append((float(below[k] - above[k]), int(centers[k]), float(below[k]),
759
+ float(above[k]), gap + span, max_width))
760
+ picked = []
761
+ for cand in sorted(candidates, reverse=True):
762
+ if all(abs(f[cand[1]] - f[p[1]]) > 2000 for p in picked):
763
+ picked.append(cand)
764
+
765
+ edges = []
766
+ for d, c, b, a, reach, max_width in picked:
767
+ lo, hi = c - reach, min(c + reach, n - 1)
768
+ under = np.nonzero(level[lo:hi + 1] < (a + b) / 2)[0]
769
+ if not len(under) or under[0] == 0 or b < EDGE_MIN_LEVEL_DB:
770
+ continue # Already below the midpoint at the window start: not a content-to-floor step.
771
+ j = lo + under[0]
772
+ # Midpoint of the transition, interpolated in dB between neighbouring bins.
773
+ y0, y1 = level[j - 1], level[j]
774
+ mid = f[j - 1] + float(np.clip((y0 - (a + b) / 2) / max(y0 - y1, 1e-9), 0, 1)) * step
775
+ top = j
776
+ while top > lo and level[top] < b - 0.1 * d:
777
+ top -= 1
778
+ bottom = j
779
+ while bottom < hi and level[bottom] > a + 0.1 * d:
780
+ bottom += 1
781
+ width = float(f[bottom] - f[top])
782
+ slope = d / math.log2(f[bottom] / f[top]) if bottom > top else math.inf
783
+ if width > max_width or slope < EDGE_MIN_SLOPE_DB_PER_OCTAVE:
784
+ continue # A gentle analog or mastering roll-off, not a digital filter.
785
+ core_top, core_bottom = j, j
786
+ while core_top > top and level[core_top - 1] < b - 0.2 * d:
787
+ core_top -= 1
788
+ while core_bottom < bottom and level[core_bottom] > a + 0.2 * d:
789
+ core_bottom += 1
790
+ core = float(f[core_bottom] - f[core_top])
791
+ rest_mask = (f >= f[bottom] + 500) & (f <= 0.98 * nyquist)
792
+ rest = level[rest_mask]
793
+ margin = float(b - np.percentile(rest, 90)) if len(rest) else None
794
+ gap_region = level[rest_mask & (f < f[bottom] + 8500)]
795
+ gap_margin = float(b - np.percentile(gap_region, 90)) if len(gap_region) >= 10 else None
796
+ brickwall = (core <= EDGE_BRICKWALL_CORE_HZ and gap_margin is not None
797
+ and np.std(gap_region) <= EDGE_BRICKWALL_FLOOR_STD_DB)
798
+ if d < EDGE_MIN_DROP_DB and not brickwall:
799
+ continue
800
+ # Margins are judged against the drop a brick wall needs, not the general one.
801
+ need = EDGE_BRICKWALL_DROP_DB if brickwall else EDGE_EMPTY_MARGIN_DB
802
+ rising = (gap_margin is not None and len(rest) >= 3 * len(gap_region)
803
+ and np.median(rest[-len(rest) // 3:]) - np.median(gap_region) >= 15)
804
+ rates = source_rates(float(f[top]), float(mid), width)
805
+ image_corr = image_atten = None
806
+ if rates and check_images:
807
+ image_corr, image_atten = mirror_score(f, level, rates[0], float(f[top]), nyquist)
808
+ imaged = (image_corr is not None and image_corr >= IMAGE_MIN_CORRELATION
809
+ and image_atten >= EDGE_IMAGED_MARGIN_DB)
810
+ noise_above = bool(rising and gap_margin >= need and (margin is None or margin < need))
811
+ standard_cliff = bool(rates) and d >= EDGE_STANDARD_CLIFF_DB and margin is not None and margin >= EDGE_IMAGED_MARGIN_DB
812
+ limit = margin is None or margin >= need or imaged or noise_above or standard_cliff
813
+ edges.append({
814
+ "frequency_hz": float(mid), "onset_hz": float(f[top]), "stop_hz": float(f[bottom]),
815
+ "drop_db": d, "transition_width_hz": width, "core_transition_hz": core,
816
+ "brick_wall": bool(brickwall), "slope_db_per_octave": float(slope),
817
+ "pre_edge_density_db_per_hz": b, "empty_margin_db": margin, "gap_margin_db": gap_margin,
818
+ "band_limit": bool(limit), "possible_source_rates_hz": rates,
819
+ "image_correlation": image_corr, "image_attenuation_db": image_atten,
820
+ "imaging_detected": bool(imaged), "rising_noise_above": noise_above,
821
+ "weak_energy_above": bool(standard_cliff and not (margin >= need or imaged or noise_above)),
822
+ "role": None,
823
+ })
824
+ # Both scales can see one transition; keep the deeper reading of overlapping ones.
825
+ kept: list[dict] = []
826
+ for e in sorted(edges, key=lambda e: -e["drop_db"]):
827
+ if all(e["stop_hz"] + 300 < k["onset_hz"] or k["stop_hz"] + 300 < e["onset_hz"] for k in kept):
828
+ kept.append(e)
829
+ kept.sort(key=lambda e: e["frequency_hz"])
830
+ # Above the source limit only a step at a higher standard rate is a further
831
+ # band limit (an intermediate conversion); others end image bands or floors.
832
+ limits = [e for e in kept if e["band_limit"]]
833
+ ceiling = max(limits[0]["possible_source_rates_hz"], default=0) if limits else 0
834
+ for e in kept:
835
+ if not e["band_limit"]:
836
+ e["role"] = "not_band_limit"
837
+ elif e is limits[0]:
838
+ e["role"] = "source_limit"
839
+ elif (e["possible_source_rates_hz"] and min(e["possible_source_rates_hz"]) > ceiling
840
+ and e["transition_width_hz"] <= EDGE_MAX_WIDTH_HZ): # A resampler stage is steep.
841
+ e["role"] = "intermediate_limit"
842
+ else:
843
+ e["role"] = "secondary_step"
844
+ e["band_limit"] = False
845
+ return kept
846
+
847
+
848
+ def dsd_label(rate: int) -> str:
849
+ for base, family in [(44100, ""), (48000, tr("(48k 系)", " (48k family)"))]:
850
+ if rate % base == 0:
851
+ return f"DSD{rate // base}{family}"
852
+ return f"DSD {rate / 1e6:g}MHz"
853
+
854
+
855
+ def dsd_noise_profile(freq: np.ndarray, psd: np.ndarray, limit: float) -> dict | None:
856
+ """Shape of the modulator noise above the audio band, on a 1/6-octave grid.
857
+
858
+ Noise shaping lifts quantization noise with frequency. A PCM band limit is
859
+ only visible while the content still stands above that noise, so the
860
+ profile also gives the highest frequency where an edge could be seen.
861
+ A second hump (rise, fall by DSD_HUMP_DB, rise again) means two modulators:
862
+ a lower-rate DSD converted through PCM and remodulated at this rate.
863
+ """
864
+ if limit <= 20000 * 2 ** (1 / 3):
865
+ return None
866
+ centers = 20000 * 2 ** (np.arange(int(math.log2(limit / 20000) * 6) + 1) / 6)
867
+ levels = []
868
+ for c in centers:
869
+ mask = (freq >= c * 2 ** (-1 / 12)) & (freq < min(c * 2 ** (1 / 12), limit))
870
+ levels.append(db(float(np.median(psd[mask]))) if mask.sum() >= 3 else math.nan)
871
+ levels = np.array(levels)
872
+ valid = ~np.isnan(levels)
873
+ centers, levels = centers[valid], levels[valid]
874
+ # Walk up in frequency: trough (content gives way to noise), rise, peak, dip, rise.
875
+ # The first trough, not the global minimum, starts the noise: with two
876
+ # modulators the dip between their humps can be the lowest point.
877
+ trough, peak, dip, hump = 0, None, None, None
878
+ for k in range(1, len(levels)):
879
+ if peak is None:
880
+ if levels[k] < levels[trough]:
881
+ trough = k
882
+ elif levels[k] - levels[trough] >= DSD_HUMP_DB:
883
+ peak = k
884
+ elif dip is None:
885
+ if levels[k] > levels[peak]:
886
+ peak = k
887
+ elif levels[peak] - levels[k] >= DSD_HUMP_DB:
888
+ dip = k
889
+ elif levels[k] < levels[dip]:
890
+ dip = k
891
+ elif levels[k] - levels[dip] >= DSD_HUMP_DB:
892
+ hump = {"peak_hz": float(centers[peak]), "dip_hz": float(centers[dip]),
893
+ "depth_db": float(levels[peak] - levels[dip])}
894
+ break
895
+ low = trough if peak is not None else int(np.argmin(levels))
896
+ risen = np.nonzero(levels[low:] >= levels[low] + DSD_OBSERVABLE_RISE_DB)[0]
897
+ observable = float(centers[low + risen[0]]) if len(risen) else float(limit)
898
+ return {
899
+ "floor_hz": float(centers[low]), "floor_db_per_hz": float(levels[low]),
900
+ "observable_limit_hz": observable, "second_hump": hump,
901
+ "profile": [[float(c), float(v)] for c, v in zip(centers, levels)],
902
+ }
903
+
904
+
905
+ def measure_bands(info: dict, data: dict) -> dict:
906
+ nyquist = info["analysis_nyquist_hz"]
907
+ keep = data["freq"] <= nyquist
908
+ freq, psd = data["freq"][keep], data["psd"][keep]
909
+ psd_channels = data["psd_channels"][:, keep]
910
+ dsd = info["dsd_rate_hz"] is not None
911
+ audible = band_power(freq, psd, 20, min(20000, nyquist))
912
+ total = band_power(freq, psd, 20, nyquist)
913
+ bands = []
914
+ for lo, hi in [(20, 20000), (20000, 22050), (22050, 24000),
915
+ (24000, 26000), (26000, 40000), (40000, 80000),
916
+ (80000, nyquist)]:
917
+ hi = min(hi, nyquist)
918
+ if hi <= lo:
919
+ continue
920
+ power = band_power(freq, psd, lo, hi)
921
+ bands.append({"low_hz": lo, "high_hz": hi, "rms_dbfs": db(power),
922
+ "relative_to_20hz_20khz_db": relative_db(power, audible)})
923
+
924
+ high = band_power(freq, psd, np.nextafter(24000.0, np.inf), nyquist) if nyquist > 24000 else 0.0
925
+ high_edge_guard = band_power(freq, psd, 24500, nyquist) if nyquist > 24500 else 0.0
926
+ high_guard = band_power(freq, psd, 26000, nyquist) if nyquist > 26000 else 0.0
927
+ # 1 kHz pooled density reduces dependence on individual FFT-bin variance.
928
+ densities = []
929
+ for lo in np.arange(26000, nyquist - 1000, 1000):
930
+ mask = (freq >= lo) & (freq < lo + 1000)
931
+ densities.append(float(np.mean(psd[mask])))
932
+ flatness = None
933
+ if len(densities) >= 4 and high_guard > 1e-20:
934
+ d = np.maximum(densities, FLOOR)
935
+ flatness = float(np.exp(np.mean(np.log(d))) / np.mean(d))
936
+ channel_high = [
937
+ relative_db(band_power(freq, p, np.nextafter(24000.0, np.inf), nyquist),
938
+ band_power(freq, p, 20, min(20000, nyquist)))
939
+ if nyquist > 24000 else None for p in psd_channels
940
+ ]
941
+ return {
942
+ "bands": bands, "total_rms_dbfs": db(total),
943
+ "above_24khz_rms_dbfs": db(high),
944
+ "above_24khz_relative_db": relative_db(high, audible),
945
+ "above_24_5khz_rms_dbfs": db(high_edge_guard),
946
+ "above_24_5khz_relative_db": relative_db(high_edge_guard, audible),
947
+ "above_26khz_relative_db": relative_db(high_guard, audible),
948
+ "above_24khz_channel_relative_db": channel_high,
949
+ # Images need a PCM upsampler; a DSD modulator leaves none, and its rising
950
+ # noise would only fake a mirror of the falling treble.
951
+ "band_edges": find_band_limits(freq, psd, int(2 * nyquist), check_images=not dsd),
952
+ "imaging": [] if dsd else find_imaging(freq, psd, info["sample_rate_hz"]),
953
+ "dsd_noise": dsd_noise_profile(freq, psd, nyquist) if dsd else None,
954
+ "lossy": data["lossy"],
955
+ "ultrasonic_spectral_flatness": flatness,
956
+ "ultrasonic_audible_envelope_correlation": data["envelope_correlation"],
957
+ }
958
+
959
+
960
+ def resampling_filter(fs: int) -> tuple[int, int, np.ndarray]:
961
+ """Symmetric FIR: 20 kHz passband, 24 kHz stopband, ~110 dB rejection.
962
+
963
+ The same common-rate FIR is used in both directions. scipy.resample_poly
964
+ compensates its group delay; no loudness normalization is performed.
965
+ """
966
+ divisor = math.gcd(fs, REFERENCE_RATE)
967
+ up, down = REFERENCE_RATE // divisor, fs // divisor
968
+ common_rate = fs * up
969
+ taps, beta = signal.kaiserord(110, 4000 / (common_rate / 2))
970
+ taps += 1 - taps % 2
971
+ fir = signal.firwin(taps, 22000, fs=common_rate, window=("kaiser", beta))
972
+ return up, down, fir
973
+
974
+
975
+ def compare_roundtrip(path: Path, info: dict, seconds: float) -> dict:
976
+ fs = info["sample_rate_hz"]
977
+ if info["dsd_rate_hz"]:
978
+ return {"performed": False, "reason": tr(
979
+ "DSD 文件不做 48kHz 差分:超声区主要是调制噪声,差分结果没有意义。",
980
+ "No 48 kHz round trip for DSD: its ultrasonic band is mostly modulator noise, so the difference means nothing.")}
981
+ if fs <= REFERENCE_RATE or seconds <= 0 or info["analyzed_seconds"] < 0.25:
982
+ return {"performed": False, "reason": tr(
983
+ "采样率不高于 48kHz、片段过短,或已关闭差分。",
984
+ "Sample rate is 48 kHz or lower, the excerpt is too short, or the round trip is turned off.")}
985
+ up, down, fir = resampling_filter(fs)
986
+ total_seconds = min(seconds, info["analyzed_seconds"])
987
+ count = min(3, max(1, int(total_seconds // 2)))
988
+ length = int(total_seconds / count * fs)
989
+ max_start = max(0, int(info["analyzed_seconds"] * fs) - length)
990
+ starts = np.linspace(0, max_start, count).astype(int) if count > 1 else np.array([max_start // 2])
991
+ totals = np.zeros(4)
992
+ per_segment = []
993
+ trim = int(0.05 * fs) # Remove independently processed excerpt boundaries.
994
+ for start in starts:
995
+ with PcmStream(path, info["channels"], start=int(start) / fs, seconds=length / fs) as stream:
996
+ original = stream.read(length)
997
+ if len(original) <= 2 * trim + 256:
998
+ continue
999
+ lower = signal.resample_poly(original, up, down, axis=0, window=fir, padtype="line")
1000
+ restored = signal.resample_poly(lower, down, up, axis=0, window=fir, padtype="line")
1001
+ restored = restored[:len(original)]
1002
+ original = original[trim:-trim]
1003
+ residual = original - restored[trim:-trim]
1004
+ n = min(32768, len(original))
1005
+ f, p = signal.welch(original, fs, nperseg=n, axis=0)
1006
+ _, r = signal.welch(residual, fs, nperseg=n, axis=0)
1007
+ original_power = float(np.mean(original**2))
1008
+ error_power = float(np.mean(residual**2))
1009
+ audible_power = band_power(f, p.T, 20, 20000)
1010
+ audible_error = band_power(f, r.T, 20, 20000)
1011
+ weight = len(original)
1012
+ totals += np.array([original_power, error_power, audible_power, audible_error]) * weight
1013
+ per_segment.append({
1014
+ "start_seconds": int(start) / fs, "measured_seconds": weight / fs,
1015
+ "residual_relative_db": relative_db(error_power, original_power),
1016
+ "audible_residual_relative_db": relative_db(audible_error, audible_power),
1017
+ })
1018
+ if not per_segment:
1019
+ return {"performed": False, "reason": tr("选段解码后过短,未执行差分。",
1020
+ "The decoded excerpts were too short; no round trip.")}
1021
+ measured = sum(s["measured_seconds"] for s in per_segment)
1022
+ samples = measured * fs
1023
+ return {
1024
+ "performed": True, "reference_rate_hz": REFERENCE_RATE,
1025
+ "filter": "linear-phase Kaiser FIR; 20 kHz passband / 24 kHz stopband; target 110 dB rejection",
1026
+ "normalization": "none", "boundary_trim_seconds_each_end": 0.05,
1027
+ "measured_seconds": measured, "segments": per_segment,
1028
+ "residual_relative_db": relative_db(totals[1], totals[0]),
1029
+ "residual_rms_dbfs": db(totals[1] / samples),
1030
+ "audible_residual_relative_db": relative_db(totals[3], totals[2]),
1031
+ "audible_residual_rms_dbfs": db(totals[3] / samples),
1032
+ }
1033
+
1034
+
1035
+ def khz(hz: float) -> str:
1036
+ return f"{hz / 1000:g}kHz"
1037
+
1038
+
1039
+ def rates_text(rates: list[int]) -> str:
1040
+ others = "/".join(khz(r) for r in rates[1:])
1041
+ return khz(rates[0]) + (tr(f"(也可能 {others})", f" (or {others})") if len(rates) > 1 else "")
1042
+
1043
+
1044
+ def above_edge(edge: dict) -> str:
1045
+ if edge["rising_noise_above"]:
1046
+ return tr("截止以上先空出一段,之后只有随频率上升的噪声(噪声整形特征,例如经 DSD 转换)",
1047
+ "above the cut there is a gap, then only noise rising with frequency (noise shaping, e.g. from a DSD conversion)")
1048
+ if edge["imaging_detected"]:
1049
+ return tr("截止以上的残留与通带呈镜像,像是升频滤波器留下的镜像",
1050
+ "what remains above the cut mirrors the passband, like images left by an upsampling filter")
1051
+ if edge["weak_energy_above"]:
1052
+ return tr("截止以上仍有较弱的能量(可能是升频镜像或后加的噪声)",
1053
+ "some weak energy remains above the cut (upsampling images or noise added later)")
1054
+ return tr("截止以上几乎为空", "almost nothing above the cut")
1055
+
1056
+
1057
+ def dsd_verdict(info: dict, metrics: dict) -> tuple[str, str]:
1058
+ """PCM origin shows as a band limit below the noise-shaping rise; DSD-rate
1059
+ upsampling as a second noise hump left by the first modulator."""
1060
+ label = dsd_label(info["dsd_rate_hz"])
1061
+ noise = metrics["dsd_noise"]
1062
+ hump = noise and noise["second_hump"]
1063
+ source = next((e for e in metrics["band_edges"] if e["role"] == "source_limit"), None)
1064
+ if info["active_window_count"] < 3 or info["analyzed_seconds"] < 0.5:
1065
+ return "insufficient_signal", tr("有效信号过少或过短,无法可靠判断。",
1066
+ "Too little or too short a signal for a reliable verdict.")
1067
+ if source is not None:
1068
+ where = tr(f"内容约在 {source['frequency_hz'] / 1000:.1f}kHz 陡降,{above_edge(source)};",
1069
+ f"Content drops steeply at about {source['frequency_hz'] / 1000:.1f} kHz; {above_edge(source)}. ")
1070
+ if source["frequency_hz"] < LOSSY_EDGE_BELOW_HZ:
1071
+ code, text = "dsd_lossy_origin", where + tr(f"疑似由有损或严重限带的来源转为 {label}。",
1072
+ f"Probably converted to {label} from a lossy or heavily band-limited source.")
1073
+ elif source["possible_source_rates_hz"]:
1074
+ code = "dsd_pcm_origin"
1075
+ rates = rates_text(source["possible_source_rates_hz"])
1076
+ text = where + tr(f"与 {rates} PCM 来源转为 {label} 一致。",
1077
+ f"Consistent with {rates} PCM converted to {label}.")
1078
+ else:
1079
+ code, text = "dsd_band_limited_nonstandard", where + tr(
1080
+ "该位置不对应常见采样率,可能是母带低通。",
1081
+ "The position matches no common sample rate; possibly a mastering low-pass.")
1082
+ elif hump:
1083
+ code = "dsd_remodulated"
1084
+ text = ""
1085
+ else:
1086
+ seen = noise["observable_limit_hz"] if noise else info["analysis_nyquist_hz"]
1087
+ higher = next((r for r in STANDARD_RATES if r / 2 >= seen), None)
1088
+ code = "dsd_no_pcm_edge"
1089
+ floor_rate = khz(higher) if higher else tr("该频率两倍", "twice that frequency")
1090
+ text = tr(f"在约 {seen / 1000:.0f}kHz 以下未见 PCM 截止;可能是原生 DSD,"
1091
+ f"也可能来自不低于 {floor_rate} 的 PCM(例如 DXD 352.8kHz),这超出了可检出范围。",
1092
+ f"No PCM band limit below about {seen / 1000:.0f} kHz: native DSD, or PCM at {floor_rate} "
1093
+ "or higher (e.g. DXD 352.8 kHz), which is beyond what can be seen.")
1094
+ if hump:
1095
+ also = tr("另:", "Also: ") if text else ""
1096
+ text += (" " if text and LANG != "zh" else "") + also + tr(
1097
+ f"调制噪声在约 {hump['peak_hz'] / 1000:.0f}kHz 先隆起,回落 {hump['depth_db']:.0f}dB 后再上升,"
1098
+ f"像是两级调制:低倍率 DSD 经 PCM 中转后重新调制为 {label}(DSD 升频)。",
1099
+ f"the modulator noise humps at about {hump['peak_hz'] / 1000:.0f} kHz, falls {hump['depth_db']:.0f} dB, "
1100
+ f"then rises again, like two modulators: a lower-rate DSD re-modulated to {label} via PCM (DSD upsampling).")
1101
+ return code, text
1102
+
1103
+
1104
+ def codec_hint(cutoff: float) -> str:
1105
+ """Encoders whose default low-pass lands near `cutoff` (LAME, FFmpeg AAC, Opus, Vorbis)."""
1106
+ for limit, zh, en in [(16700, "MP3 128kbps 及以下", "MP3 at 128 kbps or less"),
1107
+ (17600, "MP3/AAC 约 128–160kbps", "MP3/AAC at about 128-160 kbps"),
1108
+ (19000, "MP3 约 192kbps 或 VBR V2", "MP3 at about 192 kbps or VBR V2"),
1109
+ (19700, "MP3 约 256kbps", "MP3 at about 256 kbps"),
1110
+ (LOSSY_MAX_CUTOFF_HZ, "MP3 320kbps、Opus 或高码率 Vorbis", "MP3 320 kbps, Opus or high-rate Vorbis")]:
1111
+ if cutoff < limit:
1112
+ return tr(zh, en)
1113
+ return tr("高码率有损编码", "a high-rate lossy codec")
1114
+
1115
+
1116
+ def lossy_findings(lossy: dict | None) -> tuple[bool, str]:
1117
+ """Whether the short-window checks point at a lossy source, and a sentence on why."""
1118
+ if not lossy:
1119
+ return False, ""
1120
+ parts = []
1121
+ if lossy["cliff_detected"]:
1122
+ parts.append(tr(f"平均谱在约 {lossy['cliff_hz'] / 1000:.1f}kHz 陡降 {lossy['cliff_drop_db']:.0f}dB,"
1123
+ f"位置接近 {codec_hint(lossy['cliff_hz'])} 的低通",
1124
+ f"the average spectrum drops {lossy['cliff_drop_db']:.0f} dB at about "
1125
+ f"{lossy['cliff_hz'] / 1000:.1f} kHz, near the low-pass of {codec_hint(lossy['cliff_hz'])}"))
1126
+ if lossy["holes_detected"]:
1127
+ parts.append(tr(f"16kHz 以上有 {lossy['holes_percent']:.1f}% 的时频点出现空洞"
1128
+ "(校准用的 CD 原盘都低于 0.1%),像 MP3/Vorbis 的频谱空洞",
1129
+ f"{lossy['holes_percent']:.1f}% of the time-frequency cells above 16 kHz are holes "
1130
+ "(every CD rip used for calibration was below 0.1%), like MP3/Vorbis spectral holes"))
1131
+ return bool(parts), sep().join(parts)
1132
+
1133
+
1134
+ def format_label(info: dict) -> str:
1135
+ rate = info["dsd_rate_hz"]
1136
+ if rate:
1137
+ return dsd_label(rate)
1138
+ bits = f"/{info['bits_per_sample']}bit" if info["bits_per_sample"] else ""
1139
+ return f"{info['sample_rate_hz'] / 1000:g}kHz{bits}"
1140
+
1141
+
1142
+ def verdict(info: dict, metrics: dict) -> dict:
1143
+ common = tr("单文件分析无法证明原生录音采样率,也无法证明听感明显更好。",
1144
+ "A single file cannot prove the native recording rate, nor that it sounds better.")
1145
+ if info["dsd_rate_hz"]:
1146
+ code, text = dsd_verdict(info, metrics)
1147
+ return {"code": code, "summary": text,
1148
+ "limitation": tr("单文件分析无法证明录音的原始格式;许多正规 SACD 也经过 PCM(如 DXD)剪辑。",
1149
+ "A single file cannot prove the original recording format; many legitimate "
1150
+ "SACDs are edited in PCM (e.g. DXD).")}
1151
+ fs = info["sample_rate_hz"]
1152
+ source = next((e for e in metrics["band_edges"] if e["role"] == "source_limit"), None)
1153
+ # An edge implies a lower source only if no plausible rate reaches the file's own rate.
1154
+ def below_own(e: dict) -> bool:
1155
+ return bool(e["possible_source_rates_hz"]) and max(e["possible_source_rates_hz"]) < fs
1156
+ lossy = source is not None and source["frequency_hz"] < LOSSY_EDGE_BELOW_HZ
1157
+ mirror = metrics["imaging"][0] if metrics["imaging"] else None
1158
+ above = above_edge(source) if source is not None else ""
1159
+ lossy_hit, lossy_why = lossy_findings(metrics["lossy"])
1160
+
1161
+ def drop_at(edge: dict) -> str:
1162
+ return tr(f"内容约在 {edge['frequency_hz'] / 1000:.1f}kHz 陡降,{above};",
1163
+ f"Content drops steeply at about {edge['frequency_hz'] / 1000:.1f} kHz; {above}. ")
1164
+
1165
+ if info["active_window_count"] < 3 or info["analyzed_seconds"] < 0.5:
1166
+ code, text = "insufficient_signal", tr("有效信号过少或过短,无法可靠判断。",
1167
+ "Too little or too short a signal for a reliable verdict.")
1168
+ elif fs <= REFERENCE_RATE:
1169
+ found = metrics["lossy"]
1170
+ if lossy_hit:
1171
+ code = "lossy_suspect"
1172
+ text = tr(f"{lossy_why}。疑似由有损文件转成无损格式;也可能是母带主动低通或降噪处理。",
1173
+ f"{lossy_why[:1].upper()}{lossy_why[1:]}. Probably converted from a lossy file to a lossless "
1174
+ "format; a deliberate mastering low-pass or noise reduction can look the same.")
1175
+ elif found is None:
1176
+ code, text = "insufficient_signal", tr("响亮的片段太少,无法做有损检测。",
1177
+ "Too few loud passages for the lossy check.")
1178
+ else:
1179
+ code = "no_lossy_sign"
1180
+ share, top = found["loud_frames_above_20khz_percent"], found["frame_cutoff_p95_hz"] / 1000
1181
+ text = tr(f"未见有损编码的截止或频谱空洞({share:.0f}% 的响亮片段有 20kHz 以上的内容,最高约 {top:.1f}kHz)。",
1182
+ f"No lossy-codec cut or spectral holes ({share:.0f}% of loud passages reach above 20 kHz, "
1183
+ f"up to about {top:.1f} kHz). ")
1184
+ if found["holes_percent"] is not None and found["holes_percent"] >= LOSSY_HOLES_NOTE_PERCENT:
1185
+ text += tr(f"16kHz 以上有少量空洞({found['holes_percent']:.2f}%),接近 {LOSSY_HOLES_PERCENT:g}% 的门槛,"
1186
+ "可以看看频谱图里是否有成块缺失的高频。",
1187
+ f"A few holes above 16 kHz ({found['holes_percent']:.2f}%), close to the "
1188
+ f"{LOSSY_HOLES_PERCENT:g}% threshold; check the spectrogram for missing blocks of treble. ")
1189
+ text += tr("高码率 AAC(256kbps 以上)和 Opus 的频谱可能与无损相同,无法据此排除。",
1190
+ "High-rate AAC (256 kbps and up) and Opus can look lossless, so they cannot be ruled out.")
1191
+ elif lossy:
1192
+ code = "band_limited_low"
1193
+ text = drop_at(source) + tr(f"疑似有损来源或严重限带的母带,后被转为 {khz(fs)}。",
1194
+ f"Probably a lossy or heavily band-limited source, later converted to {khz(fs)}.")
1195
+ elif (source is not None and below_own(source)) or mirror:
1196
+ code = "upsampled"
1197
+ if mirror and not (source is not None and below_own(source)
1198
+ and source["possible_source_rates_hz"][0] <= mirror["source_rate_hz"]):
1199
+ origin = mirror["source_rate_hz"]
1200
+ text = tr(f"{khz(origin / 2)} 以上的频谱与其下方呈镜像(相关 {mirror['correlation']:.2f},"
1201
+ f"约低 {mirror['attenuation_db']:.0f} dB),是升频滤波不足留下的镜像;"
1202
+ f"与 {khz(origin)} 来源升频到 {khz(fs)} 一致。",
1203
+ f"The spectrum above {khz(origin / 2)} mirrors the one below (correlation "
1204
+ f"{mirror['correlation']:.2f}, about {mirror['attenuation_db']:.0f} dB lower): images left by a "
1205
+ f"weak upsampling filter. Consistent with a {khz(origin)} source upsampled to {khz(fs)}.")
1206
+ else:
1207
+ origin = source["possible_source_rates_hz"][0]
1208
+ rates = rates_text(source["possible_source_rates_hz"])
1209
+ text = drop_at(source) + tr(f"与 {rates} 来源升频到 {khz(fs)} 一致。",
1210
+ f"Consistent with a {rates} source upsampled to {khz(fs)}.")
1211
+ for stage in metrics["band_edges"]:
1212
+ if (stage["role"] in ("source_limit", "intermediate_limit") and below_own(stage)
1213
+ and min(stage["possible_source_rates_hz"]) > origin):
1214
+ rates = rates_text(stage["possible_source_rates_hz"])
1215
+ text += tr(f"约 {stage['frequency_hz'] / 1000:.1f}kHz 处另有截止,可能经过 {rates} 中间转换。",
1216
+ f" Another cut at about {stage['frequency_hz'] / 1000:.1f} kHz suggests an "
1217
+ f"intermediate {rates} step.")
1218
+ elif source is not None and not source["possible_source_rates_hz"] and source["frequency_hz"] < 0.9 * fs / 2:
1219
+ code = "band_limited_nonstandard"
1220
+ text = drop_at(source) + tr("该位置不对应常见采样率,可能是母带低通或非常规转换。",
1221
+ "The position matches no common sample rate; possibly a mastering low-pass "
1222
+ "or an unusual conversion.")
1223
+ else:
1224
+ relative = metrics["above_24khz_relative_db"]
1225
+ negligible = relative is None or relative < -75 or metrics["above_24khz_rms_dbfs"] < -95
1226
+ flatness = metrics["ultrasonic_spectral_flatness"]
1227
+ corr = metrics["ultrasonic_audible_envelope_correlation"]
1228
+ guarded_relative = metrics["above_24_5khz_relative_db"]
1229
+ clear_above_edge = (guarded_relative is not None and guarded_relative > -65
1230
+ and metrics["above_24_5khz_rms_dbfs"] > -95)
1231
+ if negligible:
1232
+ code = "little_ultrasonic_energy"
1233
+ text = tr("未检出显著的 >24kHz 能量,也未见明确的截止;未见 192kHz 的额外频宽价值。",
1234
+ "No significant energy above 24 kHz and no clear cut; the high rate adds no bandwidth.")
1235
+ elif flatness is not None and flatness > 0.5 and (corr is None or abs(corr) < 0.25):
1236
+ code = "ultrasonic_noise_like"
1237
+ text = tr("存在超声能量,呈宽带噪声特征;不能据此认定有更多音乐信息。",
1238
+ "There is ultrasonic energy, but it looks like broadband noise, not more music.")
1239
+ elif clear_above_edge:
1240
+ code = "ultrasonic_content"
1241
+ text = tr("检测到超出 48kHz 频宽的能量,且未见升频截止;其音乐意义及可听优势仍不确定。",
1242
+ "Energy beyond the 48 kHz bandwidth and no upsampling cut; its musical value and "
1243
+ "audibility are still uncertain.")
1244
+ else:
1245
+ code = "uncertain"
1246
+ text = tr("超声能量较弱或证据混合,无法可靠判断是否升采样。",
1247
+ "Weak ultrasonic energy or mixed evidence; upsampling cannot be judged reliably.")
1248
+ if fs > REFERENCE_RATE and lossy_hit and code in ("upsampled", "band_limited_low", "band_limited_nonstandard"):
1249
+ text += tr(f"另:{lossy_why},来源可能是有损文件。", f" Also, {lossy_why}: the source may be a lossy file.")
1250
+ if fs <= REFERENCE_RATE:
1251
+ common = tr("单文件分析只能找有损编码留下的痕迹,不能证明来源一定是无损母带。",
1252
+ "A single file only shows traces a lossy codec leaves; it cannot prove a lossless master.")
1253
+ chain = (metrics.get("chain_check") or {}).get("best")
1254
+ if chain:
1255
+ text += tr(f"超声指纹与加工链「{chain['label']}」吻合({chain_evidence(chain)}),超声部分可能不是原始录音。",
1256
+ f" The ultrasonic fingerprint matches processing chain \"{chain['label']}\" "
1257
+ f"({chain_evidence(chain)}); the ultrasonic band may not come from the original recording.")
1258
+ return {"code": code, "summary": text, "limitation": common, "chain": chain["name"] if chain else None}
1259
+
1260
+
1261
+ def chain_evidence(match: dict) -> str:
1262
+ parts = [tr(f"{t['hz'] / 1000:g}kHz 尖峰高出 {t['prominence_db']:.1f}dB",
1263
+ f"{t['hz'] / 1000:g} kHz tone {t['prominence_db']:.1f} dB above its surroundings")
1264
+ for t in match["tones"] if t["present"]]
1265
+ if match["shape_correlation"] is not None:
1266
+ parts.append(tr(f"超声细节形状相关 {match['shape_correlation']:.2f}",
1267
+ f"fine ultrasonic shape correlation {match['shape_correlation']:.2f}"))
1268
+ return tr(",", ", ").join(parts)
1269
+
1270
+
1271
+ # Verdicts whose "looks real" reading a chain match overturns.
1272
+ CHAIN_OVERRIDES = {"ultrasonic_content", "ultrasonic_noise_like", "uncertain", "little_ultrasonic_energy"}
1273
+
1274
+
1275
+ def number(value: float | None, digits: int = 1) -> str:
1276
+ return "N/A" if value is None else f"{value:.{digits}f}"
1277
+
1278
+
1279
+ def edge_line(e: dict, fs: int) -> str:
1280
+ f, drop, width = e["frequency_hz"] / 1000, e["drop_db"], e["transition_width_hz"] / 1000
1281
+ head = tr(f" 约 {f:.2f} kHz:下降 {drop:.0f} dB,过渡带 {width:.1f} kHz,",
1282
+ f" About {f:.2f} kHz: drops {drop:.0f} dB over {width:.1f} kHz; ")
1283
+ if e["role"] == "secondary_step":
1284
+ return head + tr("位于来源截止以上,不对应常见采样率,视为镜像带边缘或底噪变化。",
1285
+ "above the source limit and at no common rate: an image-band edge or a floor change.")
1286
+ margin = number(e["empty_margin_db"], 0)
1287
+ if not e["band_limit"]:
1288
+ return head + tr(f"以上仍有接近的能量(余量 {margin} dB),不视为频宽上限。",
1289
+ f"similar energy continues above (margin {margin} dB), so not a band limit.")
1290
+ if e["rising_noise_above"]:
1291
+ head += tr(f"其上 8kHz 内比截止前低 ≥{number(e['gap_margin_db'], 0)} dB,再往上是上升的噪声;",
1292
+ f"the 8 kHz above is >= {number(e['gap_margin_db'], 0)} dB lower, then noise rises; ")
1293
+ elif e["empty_margin_db"] is None:
1294
+ head += tr("其上已到分析上限;", "the analysis range ends above it; ")
1295
+ elif e["weak_energy_above"]:
1296
+ head += tr(f"以上仍有较弱的能量(低 {margin} dB),截止足够深,仍视为频宽上限;",
1297
+ f"weaker energy remains above ({margin} dB lower), but the cut is deep enough to be a band limit; ")
1298
+ else:
1299
+ head += tr(f"以上比截止前低 ≥{margin} dB;", f">= {margin} dB lower above; ")
1300
+ rates = e["possible_source_rates_hz"]
1301
+ if e["frequency_hz"] < LOSSY_EDGE_BELOW_HZ:
1302
+ tail = tr("低于常见采样率滤波位置,疑似有损或限带来源", "below any common rate's filter: a lossy or band-limited source")
1303
+ elif not rates:
1304
+ tail = tr("不对应常见采样率", "matches no common sample rate")
1305
+ elif max(rates) < fs:
1306
+ tail = tr(f"对应来源 {rates_text(rates)}", f"points to a {rates_text(rates)} source")
1307
+ else:
1308
+ tail = tr(f"与 {rates_text(rates)} 相容,可能是本采样率自身的滤波",
1309
+ f"compatible with {rates_text(rates)}, possibly this rate's own filter")
1310
+ if e["image_correlation"] is not None:
1311
+ tail += tr(f";镜像相关 {e['image_correlation']:.2f}", f"; image correlation {e['image_correlation']:.2f}")
1312
+ if e["imaging_detected"]:
1313
+ tail += tr(f"(检出镜像,约低 {e['image_attenuation_db']:.0f} dB)",
1314
+ f" (images found, about {e['image_attenuation_db']:.0f} dB lower)")
1315
+ return head + tail + tr("。", ".")
1316
+
1317
+
1318
+ # Plain-language grades. They rank the strength of the evidence; they are not
1319
+ # measured probabilities (no labelled real-world corpus exists to calibrate those).
1320
+ # Codes are stored in report.json; level_text() gives the words.
1321
+ VERY_HIGH, HIGH, UNSURE, LOW, VERY_LOW, UNKNOWN = "very_high", "high", "unsure", "low", "very_low", "unknown"
1322
+ LEVEL_WORDS = {VERY_HIGH: ("很高", "very high"), HIGH: ("较高", "high"), UNSURE: ("说不准", "unsure"),
1323
+ LOW: ("较低", "low"), VERY_LOW: ("很低", "very low"), UNKNOWN: ("无法判断", "cannot tell")}
1324
+
1325
+
1326
+ def level_text(level: str | None) -> str:
1327
+ return tr(*LEVEL_WORDS[level]) if level in LEVEL_WORDS else ""
1328
+
1329
+
1330
+ def plain_summary(info: dict, metrics: dict, verdict_: dict) -> dict:
1331
+ """A headline and a few graded answers for readers who are not audio engineers."""
1332
+ code, fs = verdict_["code"], info["sample_rate_hz"]
1333
+ dsd = info["dsd_rate_hz"] is not None
1334
+ label = format_label(info)
1335
+ lossy = metrics["lossy"]
1336
+ lossy_hit, _ = lossy_findings(lossy)
1337
+ edges = metrics["band_edges"]
1338
+ source = next((e for e in edges if e["role"] == "source_limit"), None)
1339
+ mirror = metrics["imaging"][0] if metrics["imaging"] else None
1340
+ corr = metrics["ultrasonic_audible_envelope_correlation"]
1341
+ chain = (metrics.get("chain_check") or {}).get("best")
1342
+ items: list[dict] = []
1343
+
1344
+ def add(question: str, level: str, why: str) -> None:
1345
+ items.append({"question": question, "level": level, "level_text": level_text(level), "why": why})
1346
+
1347
+ def rate_words(rates: list[int]) -> str:
1348
+ return khz(rates[0]) if len(rates) == 1 else tr(f"{khz(rates[0])} 或 {khz(rates[1])}",
1349
+ f"{khz(rates[0])} or {khz(rates[1])}")
1350
+
1351
+ def clean_cut(edge: dict) -> bool:
1352
+ return edge["drop_db"] >= 60 and (edge["empty_margin_db"] or 0) >= 40
1353
+
1354
+ cut = f"{source['frequency_hz'] / 1000:.1f}" if source else ""
1355
+
1356
+ # 1) Lossy compression (MP3, AAC ...)
1357
+ q_lossy = tr("经过有损压缩(MP3/AAC 等)", "Made from a lossy file (MP3, AAC ...)")
1358
+ if code in ("band_limited_low", "dsd_lossy_origin"):
1359
+ add(q_lossy, HIGH, tr(f"声音在约 {cut}kHz 以上被整齐地切掉了,这是有损格式为了省空间的常见做法。",
1360
+ f"Everything above about {cut} kHz is cut off cleanly, which lossy formats do to save space."))
1361
+ elif lossy_hit:
1362
+ reasons = []
1363
+ if lossy["cliff_detected"]:
1364
+ at = f"{lossy['cliff_hz'] / 1000:.1f}"
1365
+ reasons.append(tr(f"高频在约 {at}kHz 处被一刀切断,位置和 {codec_hint(lossy['cliff_hz'])} 的习惯一致",
1366
+ f"the treble is cut off sharply at about {at} kHz, where {codec_hint(lossy['cliff_hz'])} usually cuts"))
1367
+ if lossy["holes_detected"]:
1368
+ reasons.append(tr("高频里有很多一小块一小块的缺口,这是有损编码丢掉细节时留下的",
1369
+ "the treble is full of small gaps, left where a lossy codec threw detail away"))
1370
+ why = sep().join(reasons) + tr("。", ".")
1371
+ add(q_lossy, VERY_HIGH if len(reasons) == 2 else HIGH, why[:1].upper() + why[1:])
1372
+ elif lossy is None or dsd:
1373
+ if not dsd:
1374
+ add(q_lossy, UNKNOWN, tr("能用来判断的响亮片段太少。", "Too few loud passages to judge."))
1375
+ elif code == "ultrasonic_content" and corr is not None and corr >= 0.5 and not chain:
1376
+ add(q_lossy, VERY_LOW, tr("有损格式会把 20kHz 左右以上的声音切掉,而这个文件的高频一直延伸到 24kHz 以上,还跟着音乐起伏。",
1377
+ "Lossy formats cut everything above about 20 kHz; this file's treble goes past "
1378
+ "24 kHz and moves with the music."))
1379
+ elif lossy["holes_percent"] is not None and lossy["holes_percent"] >= LOSSY_HOLES_NOTE_PERCENT:
1380
+ add(q_lossy, UNSURE, tr("没有明显的切断,但高频里有少量缺口,接近判断门槛,建议看一下频谱图。",
1381
+ "No clear cut, but a few gaps in the treble, close to the threshold; have a look at the spectrogram."))
1382
+ else:
1383
+ add(q_lossy, LOW, tr("没找到有损格式常见的高频切断和缺口。256kbps 以上的高码率 AAC 用这种方法看不出来,所以不能说完全没有。",
1384
+ "No sign of the treble cut or gaps lossy formats leave. AAC at 256 kbps and above cannot "
1385
+ "be seen this way, so it cannot be ruled out completely."))
1386
+
1387
+ # 2) Was the sample rate raised?
1388
+ q_up = tr(f"由低采样率升频到 {khz(fs)}", f"Upsampled to {khz(fs)} from a lower rate")
1389
+ if dsd:
1390
+ pass
1391
+ elif fs <= REFERENCE_RATE:
1392
+ add(tr("采样率被提升过", "Sample rate was raised"), VERY_LOW,
1393
+ tr(f"{khz(fs)} 本身就是 CD/普通无损的规格,不存在“假高解析”的问题。",
1394
+ f"{khz(fs)} is the ordinary CD/lossless rate, so there is no \"fake hi-res\" question."))
1395
+ elif code == "upsampled":
1396
+ if mirror and not (source and source["possible_source_rates_hz"]):
1397
+ add(q_up, HIGH, tr(f"高频是把 {khz(mirror['source_rate_hz'] / 2)} 以下的声音“镜像”复制上去的,这是廉价升频留下的痕迹;"
1398
+ f"原始采样率约 {khz(mirror['source_rate_hz'])}。",
1399
+ f"The treble is a mirror copy of what lies below {khz(mirror['source_rate_hz'] / 2)}, a trace "
1400
+ f"of cheap upsampling; the original rate is about {khz(mirror['source_rate_hz'])}."))
1401
+ else:
1402
+ rates = source["possible_source_rates_hz"]
1403
+ both = tr(";两者分不清", "; the two cannot be told apart") if len(rates) > 1 else ""
1404
+ add(q_up, VERY_HIGH if clean_cut(source) else HIGH,
1405
+ tr(f"声音只到约 {cut}kHz,再往上几乎是空的,正好是 {rate_words(rates)} 录音能记录的上限;"
1406
+ f"原始采样率约 {rate_words(rates)}{both}。",
1407
+ f"The sound stops at about {cut} kHz and is nearly empty above, exactly the limit of a "
1408
+ f"{rate_words(rates)} recording; the original rate is about {rate_words(rates)}{both}."))
1409
+ elif code == "band_limited_low":
1410
+ add(tr(f"由低规格来源提升到 {khz(fs)}", f"Raised to {khz(fs)} from a low-quality source"), HIGH,
1411
+ tr(f"声音只到约 {cut}kHz,连 CD 的水平都不到。", f"The sound stops at about {cut} kHz, below even CD."))
1412
+ elif code == "ultrasonic_content":
1413
+ level = HIGH if corr is not None and corr >= 0.5 else UNSURE
1414
+ follow = (tr(",而且跟着音乐一起起伏,像是真的乐器高频", ", and it moves with the music like real instrument overtones")
1415
+ if level == HIGH else
1416
+ tr(",但看不出它是否跟着音乐变化,也可能只是噪声", ", but it is unclear whether it follows the music; it may be noise"))
1417
+ add(q_up, LOW if level == HIGH else UNSURE,
1418
+ tr(f"文件里有 24kHz 以上的声音(CD 和 48kHz 录音记录不到这一段){follow}。"
1419
+ "原始采样率应高于 48kHz,但是 96kHz 还是 192kHz 无法细分。",
1420
+ f"There is sound above 24 kHz (CD and 48 kHz recordings cannot hold it){follow}. "
1421
+ "The original rate should be above 48 kHz; 96 vs 192 kHz cannot be told."))
1422
+ elif code == "ultrasonic_noise_like":
1423
+ add(q_up, UNSURE, tr("24kHz 以上有声音,但更像均匀的噪声而不是音乐,无法说明是真高解析。",
1424
+ "There is sound above 24 kHz, but it is more like even noise than music, so it proves nothing."))
1425
+ elif code == "little_ultrasonic_energy":
1426
+ add(tr("实际内容超过 48kHz 规格", "Holds more than a 48 kHz file can"), LOW,
1427
+ tr("24kHz 以上几乎没有声音,高采样率并没有带来额外的内容;但也没看到明显的升频痕迹。",
1428
+ "There is almost nothing above 24 kHz, so the high rate adds nothing; but no clear sign of upsampling either."))
1429
+ elif code == "band_limited_nonstandard":
1430
+ add(q_up, UNSURE, tr(f"声音在约 {cut}kHz 被切断,这个位置不对应常见的采样率,可能是母带处理。",
1431
+ f"The sound is cut at about {cut} kHz, which matches no common rate; perhaps mastering."))
1432
+ else:
1433
+ add(q_up, UNKNOWN, tr("证据不足。", "Not enough evidence."))
1434
+
1435
+ # 2b) Known processing chain (--check-sr): the ultrasonic band was made, not recorded.
1436
+ if chain and not dsd:
1437
+ if code in CHAIN_OVERRIDES:
1438
+ items.pop()
1439
+ limit = chain["source_max_hz"]
1440
+ if limit:
1441
+ add(tr(f"实际是 {khz(limit)} 及以下的音源,被加工成 {khz(fs)}",
1442
+ f"Really a source of {khz(limit)} or less, processed into {khz(fs)}"), chain["level"],
1443
+ tr(f"超声部分像是加工时加上的,不是原始录音里的;这条加工链的原始音源大多不高于 {khz(limit)}。",
1444
+ "The ultrasonic band looks added during processing rather than recorded; this chain's "
1445
+ f"sources are mostly {khz(limit)} or lower."))
1446
+ else:
1447
+ add(q_up, UNSURE, tr("超声部分可能是加工出来的,不能再当作真高解析的证据;原始采样率看不出来。",
1448
+ "The ultrasonic band may have been added in processing, so it no longer counts as "
1449
+ "evidence of real hi-res; the original rate cannot be seen."))
1450
+ why = sep().join(filter(None, [chain_plain(chain), chain["note"]])) + tr("。", ".")
1451
+ add(tr(f"经过已知加工链「{chain['label']}」", f"Went through known processing chain \"{chain['label']}\""),
1452
+ chain["level"], why[:1].upper() + why[1:])
1453
+
1454
+ # 3) DSD origin
1455
+ if dsd:
1456
+ q_pcm = tr("由普通 PCM 音频转成 DSD", "Converted to DSD from ordinary PCM")
1457
+ if code == "dsd_pcm_origin":
1458
+ rates = source["possible_source_rates_hz"]
1459
+ cd = tr("(多半是 CD 母带)", " (most likely a CD master)") if rates[0] == 44100 else ""
1460
+ add(q_pcm, VERY_HIGH if source["rising_noise_above"] and clean_cut(source) else HIGH,
1461
+ tr(f"声音只到约 {cut}kHz,正好是 {rate_words(rates)} PCM 的上限;来源约为 {rate_words(rates)}{cd}。",
1462
+ f"The sound stops at about {cut} kHz, exactly the limit of {rate_words(rates)} PCM; "
1463
+ f"the source is about {rate_words(rates)}{cd}."))
1464
+ elif code == "dsd_lossy_origin":
1465
+ add(q_pcm, HIGH, tr("而且来源的频宽比 CD 还低。", "And the source has less bandwidth than a CD."))
1466
+ elif code == "dsd_no_pcm_edge":
1467
+ noise = metrics["dsd_noise"]
1468
+ seen = noise["observable_limit_hz"] if noise else info["analysis_nyquist_hz"]
1469
+ higher = next((r for r in STANDARD_RATES if r / 2 >= seen), None)
1470
+ ruled = [r for r in STANDARD_RATES if r / 2 < seen]
1471
+ checked = (tr(f"{khz(ruled[-1])} 及以下", f"{khz(ruled[-1])} or lower") if ruled
1472
+ else tr("常见规格", "common-rate"))
1473
+ top = khz(higher) if higher else tr("更高规格", "higher-rate")
1474
+ add(q_pcm, UNSURE, tr(f"没发现 {checked} 的 PCM 留下的切断;可能是原生 DSD,"
1475
+ f"也可能来自 {top} 及以上的高解析 PCM(这种看不出来)。",
1476
+ f"No cut left by {checked} PCM; it may be native DSD, or come from hi-res PCM at "
1477
+ f"{top} or above (which cannot be seen)."))
1478
+ elif code == "dsd_band_limited_nonstandard":
1479
+ add(q_pcm, UNSURE, tr(f"声音在约 {cut}kHz 被切断,位置不常见。", f"The sound is cut at an unusual {cut} kHz."))
1480
+ else:
1481
+ add(q_pcm, UNKNOWN, tr("证据不足。", "Not enough evidence."))
1482
+ hump = metrics["dsd_noise"] and metrics["dsd_noise"]["second_hump"]
1483
+ add(tr(f"由低规格 DSD 提升到 {dsd_label(info['dsd_rate_hz'])}",
1484
+ f"Raised to {dsd_label(info['dsd_rate_hz'])} from a lower DSD rate"), HIGH if hump else LOW,
1485
+ tr("噪声曲线上有两次隆起,像是低规格 DSD 转换后重新生成的。",
1486
+ "The noise curve humps twice, as if a lower-rate DSD was converted and re-modulated.") if hump else
1487
+ tr("没看到这种提升通常会留下的两段式噪声。", "None of the two-stage noise such a conversion usually leaves."))
1488
+
1489
+ headlines = {
1490
+ "lossy_suspect": tr("这首很可能是用 MP3/AAC 等有损文件转成的“假无损”。",
1491
+ "Probably a \"fake lossless\" file made from an MP3, AAC or other lossy file."),
1492
+ "no_lossy_sign": tr(f"没发现有损压缩的痕迹,像是正常的 {label} 无损音乐。",
1493
+ f"No sign of lossy compression; looks like normal {label} lossless music."),
1494
+ "band_limited_low": tr(f"“假高解析”:内容连 CD 水平都不到,很可能来自有损文件,再被提升成 {label}。",
1495
+ f"\"Fake hi-res\": the content is below CD quality, probably from a lossy file, raised to {label}."),
1496
+ "band_limited_nonstandard": tr("高频被限制在一个不常见的位置,来源不明。",
1497
+ "The treble is limited at an unusual frequency; the source is unclear."),
1498
+ "little_ultrasonic_energy": tr(f"虽然是 {label},但几乎没有 CD/48kHz 之外的高频内容,高采样率意义不大。",
1499
+ f"Although {label}, there is almost nothing beyond what CD/48 kHz holds; "
1500
+ "the high rate adds little."),
1501
+ "ultrasonic_noise_like": tr("高采样率多出来的部分主要是噪声,难以说明是真高解析。",
1502
+ "What the high rate adds is mostly noise; it does not show real hi-res."),
1503
+ "uncertain": tr("证据混合,无法给出明确结论。", "Mixed evidence; no clear conclusion."),
1504
+ "insufficient_signal": tr("有效声音太少或太短,无法判断。", "Too little or too short a sound to judge."),
1505
+ "dsd_lossy_origin": tr("这个 DSD 的来源很可能是有损或低规格文件。",
1506
+ "This DSD probably comes from a lossy or low-quality file."),
1507
+ "dsd_band_limited_nonstandard": tr("这个 DSD 的高频被限制在一个不常见的位置,来源不明。",
1508
+ "This DSD's treble is limited at an unusual frequency; the source is unclear."),
1509
+ "dsd_remodulated": tr("这个 DSD 像是从更低规格的 DSD 提升上来的。",
1510
+ "This DSD looks raised from a lower DSD rate."),
1511
+ "dsd_no_pcm_edge": tr("没发现由 CD 等常见规格转制的痕迹;可能是原生 DSD,也可能来自高规格 PCM。",
1512
+ "No sign of conversion from CD or other common rates; native DSD or hi-res PCM."),
1513
+ }
1514
+ if code == "upsampled":
1515
+ rates = (source["possible_source_rates_hz"] if source and source["possible_source_rates_hz"]
1516
+ else [mirror["source_rate_hz"]])
1517
+ head = tr(f"“假高解析”:实际只相当于 {rate_words(rates)} 的水平,被提升成了 {label}。",
1518
+ f"\"Fake hi-res\": really only {rate_words(rates)} quality, raised to {label}.")
1519
+ if lossy_hit:
1520
+ head += tr("而且来源还可能是有损文件。", " The source may also be a lossy file.")
1521
+ elif code == "ultrasonic_content":
1522
+ head = (tr("像是真正的高解析录音:含有 CD/48kHz 记录不到的高频。",
1523
+ "Looks like a real hi-res recording: it holds treble CD/48 kHz cannot.")
1524
+ if corr is not None and corr >= 0.5 else
1525
+ tr("可能是真高解析,但多出来的高频也可能只是噪声。",
1526
+ "May be real hi-res, but the extra treble may also be just noise."))
1527
+ elif code == "dsd_pcm_origin":
1528
+ rates = source["possible_source_rates_hz"]
1529
+ head = tr(f"不是原生 DSD 录音:是由 {rate_words(rates)} 的普通音频"
1530
+ f"{'(多半是 CD 母带)' if rates[0] == 44100 else ''}转成的。",
1531
+ f"Not a native DSD recording: converted from ordinary {rate_words(rates)} audio"
1532
+ f"{' (most likely a CD master)' if rates[0] == 44100 else ''}.")
1533
+ else:
1534
+ head = headlines.get(code, verdict_["summary"])
1535
+ if chain and not dsd:
1536
+ if code in CHAIN_OVERRIDES:
1537
+ limit = chain["source_max_hz"]
1538
+ head = (tr(f"超声部分可能不是原始录音:和已知加工链「{chain['label']}」的指纹吻合,像是加工时加上的。",
1539
+ f"The ultrasonic band may not be from the original recording: it matches known processing "
1540
+ f"chain \"{chain['label']}\" and looks added in processing.")
1541
+ + (tr(f"原始音源大概不高于 {khz(limit)}。", f" The source is probably {khz(limit)} or lower.")
1542
+ if limit else ""))
1543
+ else:
1544
+ head += tr(f"另外,超声部分和已知加工链「{chain['label']}」的指纹吻合。",
1545
+ f" The ultrasonic band also matches known processing chain \"{chain['label']}\".")
1546
+ return {"headline": head, "items": items,
1547
+ "note": tr("“可能性”是按证据强弱分的等级,不是统计出来的概率。",
1548
+ "Likelihoods are grades of evidence strength, not measured probabilities.")}
1549
+
1550
+
1551
+ def chain_plain(match: dict) -> str:
1552
+ tones = [f"{t['hz'] / 1000:g}kHz" for t in match["tones"] if t["present"]]
1553
+ parts = []
1554
+ if tones:
1555
+ parts.append(tr(f"在 {'、'.join(tones)} 有和这条链一样的固定尖峰(转换设备的杂音,音乐里不会有)",
1556
+ f"the same fixed tones as this chain at {', '.join(tones)} (converter noise, never part of music)"))
1557
+ r = match["shape_correlation"]
1558
+ if r is not None and r >= match["shape_strong"]:
1559
+ parts.append(tr("超声部分的细节形状也和这条链的其他文件对得上",
1560
+ "the fine shape of the ultrasonic band also matches this chain's other files"))
1561
+ return tr(",", ", and ").join(parts)
1562
+
1563
+
1564
+ def format_plain(plain: dict) -> list[str]:
1565
+ lines = [tr("结论", "Conclusion"), "-" * 54, plain["headline"], ""]
1566
+ for item in plain["items"]:
1567
+ answer = (tr(f"可能性{item['level_text']}", f"likelihood {item['level_text']}")
1568
+ if item["level"] != UNKNOWN else item["level_text"])
1569
+ lines.append(f" {item['question']}{tr(':', ': ')}{answer}")
1570
+ lines.append(f" {item['why']}")
1571
+ lines += ["", f" {tr('(', '(')}{plain['note']}{tr(')', ')')}", ""]
1572
+ return lines
1573
+
1574
+
1575
+ def format_report(report: dict) -> str:
1576
+ i, m, r, v = (report[k] for k in ["file", "spectral_metrics", "roundtrip_48khz", "verdict"])
1577
+ bits = f"{i['bits_per_sample']} bit" if i["bits_per_sample"] else i["sample_format"]
1578
+ dsd = i["dsd_rate_hz"] is not None
1579
+ colon = tr(":", ": ")
1580
+ if dsd:
1581
+ layout = tr(f"{dsd_label(i['dsd_rate_hz'])}({i['dsd_rate_hz'] / 1e6:g} MHz)/ {i['channels']} 声道;"
1582
+ f"由 FFmpeg 解码为 {i['sample_rate_hz']} Hz PCM,分析至 {i['analysis_nyquist_hz'] / 1000:g} kHz",
1583
+ f"{dsd_label(i['dsd_rate_hz'])} ({i['dsd_rate_hz'] / 1e6:g} MHz) / {i['channels']} channels; "
1584
+ f"decoded by FFmpeg to {i['sample_rate_hz']} Hz PCM, analysed up to {i['analysis_nyquist_hz'] / 1000:g} kHz")
1585
+ else:
1586
+ layout = tr(f"{i['sample_rate_hz']} Hz / {i['channels']} 声道 / {bits}",
1587
+ f"{i['sample_rate_hz']} Hz / {i['channels']} channels / {bits}")
1588
+ lines = [
1589
+ tr("DSD 来源检查", "DSD source check") if dsd else tr("PCM 采样率 / 频宽检查", "PCM sample rate / bandwidth check"),
1590
+ "=" * 54,
1591
+ tr("文件:", "File: ") + i["path"],
1592
+ tr(f"格式:{format_label(i)}({i['codec']})", f"Format: {format_label(i)} ({i['codec']})"), "",
1593
+ *format_plain(report["plain"]),
1594
+ tr("技术细节", "Technical details"), "=" * 54,
1595
+ tr(f"格式:{i['codec']}({i['container']})/ {layout}", f"Format: {i['codec']} ({i['container']}) / {layout}"),
1596
+ tr(f"文件时长:{i['duration_seconds']:.2f} 秒;扫描:{i['analyzed_seconds']:.2f} 秒",
1597
+ f"Duration: {i['duration_seconds']:.2f} s; scanned: {i['analyzed_seconds']:.2f} s"),
1598
+ tr("扫描范围:", "Scan range: ") + (tr("全曲", "whole file") if i["full_file_scanned"]
1599
+ else tr("仅文件开头,后续未分析", "start of the file only; the rest was not analysed")),
1600
+ tr(f"频率分辨率:{i['frequency_resolution_hz']:.2f} Hz;有效窗:{i['active_window_count']}/{i['window_count']};"
1601
+ f"静音门限:{i['rms_gate_dbfs']:.0f} dBFS",
1602
+ f"Frequency resolution: {i['frequency_resolution_hz']:.2f} Hz; active windows: "
1603
+ f"{i['active_window_count']}/{i['window_count']}; silence gate: {i['rms_gate_dbfs']:.0f} dBFS"),
1604
+ ]
1605
+ if i["decoder_warnings"]:
1606
+ lines.append(tr("解码警告:", "Decoder warning: ") + i["decoder_warnings"].splitlines()[0])
1607
+ lines += ["", tr(f"技术判断({v['code']}):", f"Technical verdict ({v['code']}): ") + v["summary"], v["limitation"], ""]
1608
+ rel = tr("相对 20Hz–20kHz", "relative to 20 Hz-20 kHz")
1609
+ if dsd:
1610
+ lines.append(tr("DSD 的超声区主要是调制噪声,超声能量、平坦度等指标不说明内容,见下方噪声分析。",
1611
+ "DSD's ultrasonic band is mostly modulator noise; ultrasonic energy and flatness say nothing "
1612
+ "about content here. See the noise analysis below."))
1613
+ else:
1614
+ lines += [
1615
+ tr(">24kHz 能量:", "Energy >24 kHz: ") + f"{m['above_24khz_rms_dbfs']:.1f} dBFS{tr(';', '; ')}"
1616
+ f"{rel}{colon}{number(m['above_24khz_relative_db'])} dB",
1617
+ tr(">26kHz 能量(避开转换过渡带):", "Energy >26 kHz (clear of the conversion transition band): ")
1618
+ + f"{number(m['above_26khz_relative_db'])} dB{tr(',', ', ')}{rel}",
1619
+ tr(">24.5kHz 能量(自动判断的截止边缘保护区):", "Energy >24.5 kHz (guard band for the automatic edge check): ")
1620
+ + f"{number(m['above_24_5khz_relative_db'])} dB{tr(',', ', ')}{rel}",
1621
+ tr("每声道 >24kHz 相对能量:", "Per-channel relative energy >24 kHz: ")
1622
+ + ", ".join(number(x) + " dB" for x in m["above_24khz_channel_relative_db"]),
1623
+ tr("超声谱平坦度:", "Ultrasonic spectral flatness: ") + number(m["ultrasonic_spectral_flatness"], 3)
1624
+ + tr("(接近 1 较平坦;不能单独证明是噪声)", " (near 1 is flat; this alone does not prove noise)"),
1625
+ tr("超声/可听频段电平相关性:", "Ultrasonic/audible level correlation: ")
1626
+ + number(m["ultrasonic_audible_envelope_correlation"], 3),
1627
+ ]
1628
+ lines += ["", tr("频段 RMS dBFS 相对 20Hz–20kHz dB",
1629
+ "Band RMS dBFS rel. 20 Hz-20 kHz dB")]
1630
+ for b in m["bands"]:
1631
+ label = f"{b['low_hz']/1000:g}–{b['high_hz']/1000:g} kHz"
1632
+ lines.append(f"{label:<24}{b['rms_dbfs']:>9.1f} {number(b['relative_to_20hz_20khz_db']):>10}")
1633
+ lines += ["", tr("频宽截止(全曲平均谱中陡峭、持续的下降,含量化/抖动底噪的台阶):",
1634
+ "Band limits (steep, sustained drops in the whole-file average spectrum, including "
1635
+ "quantisation/dither floor steps):")]
1636
+ if m["band_edges"]:
1637
+ lines += [edge_line(e, i["dsd_rate_hz"] or i["sample_rate_hz"]) for e in m["band_edges"]]
1638
+ else:
1639
+ lines.append(tr(" 未发现陡峭且持续的截止;这不代表一定是原生高采样率。",
1640
+ " No steep, sustained cut found; that does not prove a native high rate."))
1641
+ for mirror in m["imaging"]:
1642
+ half, src = khz(mirror["source_rate_hz"] / 2), khz(mirror["source_rate_hz"])
1643
+ lines.append(tr(f" 镜像:{half} 两侧频谱对称(相关 {mirror['correlation']:.2f},"
1644
+ f"镜像约低 {mirror['attenuation_db']:.0f} dB),提示由 {src} 升频。",
1645
+ f" Images: the spectrum is symmetric around {half} (correlation {mirror['correlation']:.2f}, "
1646
+ f"images about {mirror['attenuation_db']:.0f} dB lower), pointing to upsampling from {src}."))
1647
+ found = m["lossy"]
1648
+ if found:
1649
+ lines += ["", tr(f"有损编码检查({found['frame_seconds'] * 1000:.0f}ms 短窗,{found['frames_analyzed']} 个响亮片段):",
1650
+ f"Lossy-codec check ({found['frame_seconds'] * 1000:.0f} ms windows, "
1651
+ f"{found['frames_analyzed']} loud frames):")]
1652
+ at, drop = found["cliff_hz"] / 1000, found["cliff_drop_db"]
1653
+ if found["cliff_detected"]:
1654
+ lines.append(tr(f" 平均谱约 {at:.2f}kHz 处陡降 {drop:.0f}dB,接近 {codec_hint(found['cliff_hz'])} 的低通位置。",
1655
+ f" The average spectrum drops {drop:.0f} dB at about {at:.2f} kHz, near the low-pass "
1656
+ f"of {codec_hint(found['cliff_hz'])}."))
1657
+ else:
1658
+ lines.append(tr(f" 14–{LOSSY_MAX_CUTOFF_HZ / 1000:g}kHz 内未见 ≥{LOSSY_STEP_DB}dB 的陡降"
1659
+ f"(最陡处约 {at:.2f}kHz,{drop:.0f}dB)。",
1660
+ f" No drop of >= {LOSSY_STEP_DB} dB within 14-{LOSSY_MAX_CUTOFF_HZ / 1000:g} kHz "
1661
+ f"(steepest: about {at:.2f} kHz, {drop:.0f} dB)."))
1662
+ p95, share = found["frame_cutoff_p95_hz"] / 1000, found["loud_frames_above_20khz_percent"]
1663
+ lines.append(tr(f" 响亮片段的内容上限:95% 分位约 {p95:.1f}kHz;{share:.0f}% 的片段延伸到 20kHz 以上。",
1664
+ f" Upper limit of loud frames: 95th percentile about {p95:.1f} kHz; {share:.0f}% reach above 20 kHz."))
1665
+ if found["holes_percent"] is None:
1666
+ holes = tr("频段太窄,未统计。", "band too narrow, not counted.")
1667
+ else:
1668
+ holes = tr(f"{found['holes_percent']:.2f}%(≥{LOSSY_HOLES_PERCENT:g}% 视为有损迹象;校准用的 CD 原盘都低于 0.1%);"
1669
+ f"另有 {found['full_band_gap_frames_percent']:.1f}% 的片段整段高频停顿,视为音乐本身,不计入。",
1670
+ f"{found['holes_percent']:.2f}% (>= {LOSSY_HOLES_PERCENT:g}% points to lossy; every CD rip used "
1671
+ f"for calibration was below 0.1%); another {found['full_band_gap_frames_percent']:.1f}% of frames "
1672
+ "have the whole treble pause, taken as the music itself and not counted.")
1673
+ lines.append(tr(" 16kHz 以上频谱空洞:", " Spectral holes above 16 kHz: ") + holes)
1674
+ check = m.get("chain_check")
1675
+ if check is not None:
1676
+ lines += ["", tr("加工链检查(--check-sr):", "Processing-chain check (--check-sr):")]
1677
+ if not check["performed"]:
1678
+ lines.append(" " + check["reason"])
1679
+ for c in check["results"]:
1680
+ tones = tr(",", ", ").join(
1681
+ tr(f"{t['hz'] / 1000:g}kHz 尖峰 {number(t['prominence_db'])}dB(需 ≥{t['minimum_db']:g})",
1682
+ f"{t['hz'] / 1000:g} kHz tone {number(t['prominence_db'])} dB (needs >= {t['minimum_db']:g})")
1683
+ for t in c["tones"]) or tr("无尖峰特征", "no tones")
1684
+ state = (tr(f"吻合(可能性{level_text(c['level'])})", f"match (likelihood {level_text(c['level'])})")
1685
+ if c["matched"] else
1686
+ tr("部分特征吻合,不足以下结论", "partial match, not enough for a conclusion") if c["partial"]
1687
+ else tr("不吻合", "no match"))
1688
+ alone = (tr(f"≥{c['shape_only']:g} 可单独成立", f">= {c['shape_only']:g} matches on its own")
1689
+ if c["shape_only"] is not None else
1690
+ tr("这条链不能只凭形状判定", "this chain cannot match on shape alone"))
1691
+ lines.append(tr(f" 「{c['label']}」:{state};{tones};超声细节形状相关 {number(c['shape_correlation'], 2)}"
1692
+ f"(≥{c['shape_strong']:g} 加强尖峰证据;{alone})",
1693
+ f" \"{c['label']}\": {state}; {tones}; fine ultrasonic shape correlation "
1694
+ f"{number(c['shape_correlation'], 2)} (>= {c['shape_strong']:g} strengthens the tones; {alone})"))
1695
+ noise = m["dsd_noise"]
1696
+ if noise:
1697
+ lines += [
1698
+ "", tr("DSD 调制噪声(1/6 倍频程中位数):", "DSD modulator noise (1/6-octave medians):"),
1699
+ tr(f" 最低点约 {noise['floor_hz'] / 1000:.0f} kHz,{noise['floor_db_per_hz']:.0f} dBFS/Hz;"
1700
+ f"约 {noise['observable_limit_hz'] / 1000:.0f} kHz 起高出 {DSD_OBSERVABLE_RISE_DB} dB,"
1701
+ "更高频的 PCM 截止会被噪声淹没,无法检出。",
1702
+ f" Lowest at about {noise['floor_hz'] / 1000:.0f} kHz, {noise['floor_db_per_hz']:.0f} dBFS/Hz; "
1703
+ f"{DSD_OBSERVABLE_RISE_DB} dB higher from about {noise['observable_limit_hz'] / 1000:.0f} kHz, so a PCM "
1704
+ "cut above that is buried in noise and cannot be seen."),
1705
+ ]
1706
+ hump = noise["second_hump"]
1707
+ lines.append(
1708
+ tr(f" 约 {hump['peak_hz'] / 1000:.0f} kHz 处有第二个噪声隆起(回落 {hump['depth_db']:.0f} dB 后再上升),提示两级调制。",
1709
+ f" A second noise hump at about {hump['peak_hz'] / 1000:.0f} kHz (falls {hump['depth_db']:.0f} dB, "
1710
+ "then rises again) points to two modulators.")
1711
+ if hump else tr(" 噪声随频率单调上升或持平,未见两级调制的隆起。",
1712
+ " The noise rises steadily or stays flat; no two-modulator hump."))
1713
+ lines += ["", tr("原采样率 → 48kHz → 原采样率差分:", "Round trip: original rate -> 48 kHz -> original rate:")]
1714
+ if r["performed"]:
1715
+ lines += [
1716
+ tr(f" 均匀选取 {len(r['segments'])} 个片段,共测量 {r['measured_seconds']:.2f} 秒。",
1717
+ f" {len(r['segments'])} evenly spread excerpts, {r['measured_seconds']:.2f} s measured."),
1718
+ tr(" 转换滤波器通带至 20kHz、阻带从 24kHz 起;保留电平,补偿群延迟,并排除片段边缘。",
1719
+ " Filter passband to 20 kHz, stopband from 24 kHz; level kept, group delay compensated, excerpt edges excluded."),
1720
+ tr(f" 全频段残差:{number(r['residual_relative_db'])} dB(相对原信号),{r['residual_rms_dbfs']:.1f} dBFS。",
1721
+ f" Full-band residual: {number(r['residual_relative_db'])} dB (relative to the original), "
1722
+ f"{r['residual_rms_dbfs']:.1f} dBFS."),
1723
+ tr(f" 20Hz–20kHz 残差:{number(r['audible_residual_relative_db'])} dB(相对同频段原信号),"
1724
+ f"{r['audible_residual_rms_dbfs']:.1f} dBFS。",
1725
+ f" 20 Hz-20 kHz residual: {number(r['audible_residual_relative_db'])} dB (relative to the original "
1726
+ f"in that band), {r['audible_residual_rms_dbfs']:.1f} dBFS."),
1727
+ tr(" 例如 -60 dB 的残差能量约为原信号的百万分之一;差分也包含滤波器误差。",
1728
+ " A -60 dB residual holds about a millionth of the original's energy; it includes filter error."),
1729
+ tr(" 差分只覆盖这些片段;20–24kHz 过渡带的损失也会计入全频段残差。",
1730
+ " Only these excerpts are covered; losses in the 20-24 kHz transition band count in the full-band residual."),
1731
+ ]
1732
+ else:
1733
+ lines.append(" " + r["reason"])
1734
+ lines += ["", tr("解释与边界:", "Interpretation and limits:")] + [" • " + note for note in report["notes"]]
1735
+ return "\n".join(lines) + "\n"
1736
+
1737
+
1738
+ def save_plot(path: Path, info: dict, data: dict, metrics: dict) -> bool:
1739
+ try:
1740
+ import matplotlib
1741
+ matplotlib.use("Agg")
1742
+ import matplotlib.pyplot as plt
1743
+ except ImportError:
1744
+ return False
1745
+ freq = data["freq"] / 1000
1746
+ top_khz = info["analysis_nyquist_hz"] / 1000
1747
+ fig, axes = plt.subplots(3, 1, figsize=(12, 11), constrained_layout=True)
1748
+ top, middle, bottom = axes
1749
+ for channel, psd in enumerate(data["psd_channels"]):
1750
+ top.plot(freq, 10 * np.log10(np.maximum(psd, 1e-20)), lw=0.6, alpha=0.45,
1751
+ label=f"Channel {channel + 1}")
1752
+ top.plot(freq, 10 * np.log10(np.maximum(data["psd"], 1e-20)), color="#136f63", lw=1.2,
1753
+ label="Mean channel power (active frames)")
1754
+ for cutoff, color in [(20, "#9b6500"), (24, "#cf4138")]:
1755
+ if cutoff < top_khz:
1756
+ top.axvline(cutoff, color=color, ls="--", label=f"{cutoff} kHz")
1757
+ for n, edge in enumerate(e for e in metrics["band_edges"] if e["band_limit"]):
1758
+ top.axvline(edge["frequency_hz"] / 1000, color="#5b3cc4", ls=":", lw=1.2,
1759
+ label="Detected band limit" if n == 0 else None)
1760
+ top.set(xlim=(0, top_khz), ylim=(-190, 0), xlabel="Frequency (kHz)",
1761
+ ylabel="PSD (dBFS/Hz)",
1762
+ title=("DSD source check - decoded by FFmpeg" if info["dsd_rate_hz"] else "PCM bandwidth check")
1763
+ + " - signal evidence, not an audibility test")
1764
+ top.grid(alpha=0.2)
1765
+ top.legend(loc="upper right", fontsize=8)
1766
+ heat = 10 * np.log10(np.maximum(data["spectrogram"].T, 1e-20))
1767
+ im = middle.imshow(heat, origin="lower", aspect="auto", cmap="magma", vmin=-160, vmax=-50,
1768
+ extent=(0, data["time_extent"], 0, freq[-1]))
1769
+ middle.set_ylim(0, top_khz)
1770
+ if top_khz > 24:
1771
+ middle.axhline(24, color="cyan", ls="--", lw=0.8)
1772
+ middle.set(xlabel="Time (s)", ylabel="Frequency (kHz)", title="Power spectrogram (all frames)")
1773
+ fig.colorbar(im, ax=middle, label="dBFS/Hz", pad=0.01)
1774
+ for label, key, color in [("20 Hz - 20 kHz", "time_audible", "#136f63"),
1775
+ (">24 kHz", "time_ultra", "#cf4138")]:
1776
+ values = 10 * np.log10(np.maximum(data[key], 1e-20))
1777
+ values[~data["time_valid"]] = np.nan
1778
+ bottom.plot(data["time"], values, label=label, color=color, lw=1)
1779
+ bottom.set(xlim=(0, data["time_extent"]), ylim=(-160, 5),
1780
+ xlabel="Time (s)", ylabel="Band RMS (dBFS)",
1781
+ title=f"Band levels - >24 kHz relative energy: {number(metrics['above_24khz_relative_db'])} dB")
1782
+ bottom.legend(fontsize=9)
1783
+ bottom.grid(alpha=0.2)
1784
+ fig.savefig(path, dpi=160)
1785
+ plt.close(fig)
1786
+ return True
1787
+
1788
+
1789
+ def positive(value: str) -> float:
1790
+ number_ = float(value)
1791
+ if not math.isfinite(number_) or number_ <= 0:
1792
+ raise argparse.ArgumentTypeError(tr("请输入大于 0 的有限数值。", "enter a finite number above 0"))
1793
+ return number_
1794
+
1795
+
1796
+ def nonnegative(value: str) -> float:
1797
+ number_ = float(value)
1798
+ if not math.isfinite(number_) or number_ < 0:
1799
+ raise argparse.ArgumentTypeError(tr("请输入不小于 0 的有限数值。", "enter a finite number of 0 or more"))
1800
+ return number_
1801
+
1802
+
1803
+ REPORT_FILES = ("report.txt", "report.json", "spectrum.csv", "spectrum.png")
1804
+ # Extensions picked up when scanning folders; single files are probed whatever their name.
1805
+ AUDIO_EXTENSIONS = {
1806
+ ".flac", ".wav", ".wave", ".w64", ".rf64", ".aif", ".aiff", ".aifc", ".caf", ".m4a", ".alac",
1807
+ ".mka", ".ape", ".wv", ".tta", ".tak", ".dsf", ".dff",
1808
+ }
1809
+ # Lossy codecs are skipped in folder scans: the checks target lossless and hi-res files.
1810
+ LOSSY_CODECS = {
1811
+ "mp1", "mp2", "mp3", "aac", "aac_latm", "opus", "vorbis", "wmav1", "wmav2", "wmapro", "wmavoice",
1812
+ "ac3", "eac3", "dts", "atrac3", "atrac3p", "cook", "speex", "amr_nb", "amr_wb", "musepack7", "musepack8",
1813
+ }
1814
+
1815
+
1816
+ def build_report(info: dict, metrics: dict, roundtrip: dict) -> dict:
1817
+ report = {
1818
+ "schema_version": VERSION, "file": info, "spectral_metrics": metrics,
1819
+ "roundtrip_48khz": roundtrip, "verdict": verdict(info, metrics),
1820
+ "heuristics": {
1821
+ "edge_bin_hz": EDGE_BIN_HZ, "edge_search_from_hz": EDGE_SEARCH_FROM_HZ,
1822
+ "edge_minimum_drop_db": EDGE_MIN_DROP_DB,
1823
+ "edge_brickwall_minimum_drop_and_margin_db": EDGE_BRICKWALL_DROP_DB,
1824
+ "edge_brickwall_maximum_core_transition_hz": EDGE_BRICKWALL_CORE_HZ,
1825
+ "edge_brickwall_maximum_floor_std_db": EDGE_BRICKWALL_FLOOR_STD_DB, "edge_maximum_transition_hz": EDGE_MAX_WIDTH_HZ,
1826
+ "edge_minimum_slope_db_per_octave": EDGE_MIN_SLOPE_DB_PER_OCTAVE,
1827
+ "edge_minimum_pre_edge_density_db_per_hz": EDGE_MIN_LEVEL_DB,
1828
+ "edge_empty_margin_db": EDGE_EMPTY_MARGIN_DB,
1829
+ "edge_empty_margin_with_imaging_db": EDGE_IMAGED_MARGIN_DB,
1830
+ "image_minimum_correlation": IMAGE_MIN_CORRELATION,
1831
+ "image_standalone_minimum_correlation": IMAGE_STANDALONE_CORRELATION,
1832
+ "image_minimum_passband_shape_db": IMAGE_MIN_SHAPE_DB,
1833
+ "lossy_edge_below_hz": LOSSY_EDGE_BELOW_HZ,
1834
+ "lossy_max_cutoff_hz": LOSSY_MAX_CUTOFF_HZ, "lossy_search_from_hz": LOSSY_SEARCH_FROM_HZ,
1835
+ "lossy_minimum_cliff_db": LOSSY_STEP_DB, "lossy_hole_depth_db": LOSSY_HOLE_DB,
1836
+ "lossy_minimum_holes_percent": LOSSY_HOLES_PERCENT, "lossy_frame_seconds": LOSSY_FRAME_SECONDS,
1837
+ "lossy_holes_note_percent": LOSSY_HOLES_NOTE_PERCENT, "lossy_gap_frame_share": LOSSY_GAP_SHARE,
1838
+ "dsd_analysis_share_of_decoded_nyquist": DSD_ANALYSIS_SHARE,
1839
+ "dsd_observable_rise_db": DSD_OBSERVABLE_RISE_DB, "dsd_second_hump_db": DSD_HUMP_DB,
1840
+ "source_nyquist_ratio_range": list(SOURCE_NYQUIST_RATIO),
1841
+ "source_typical_ratio": SOURCE_TYPICAL_RATIO, "edge_standard_cliff_db": EDGE_STANDARD_CLIFF_DB,
1842
+ "standard_rates_hz": list(STANDARD_RATES),
1843
+ "negligible_ultrasonic_relative_db": -75, "negligible_ultrasonic_rms_dbfs": -95,
1844
+ "clear_ultrasonic_relative_db": -65, "clear_ultrasonic_rms_dbfs": -95,
1845
+ "clear_ultrasonic_edge_guard_hz": 24500,
1846
+ "noise_like_minimum_flatness": 0.5, "noise_like_maximum_absolute_correlation": 0.25,
1847
+ },
1848
+ "notes": [tr(zh, en) for zh, en in [
1849
+ ("48kHz 的奈奎斯特频率为 24kHz;常见可听频率上限约 20kHz,且因人而异。",
1850
+ "48 kHz has a 24 kHz Nyquist frequency; the usual upper limit of hearing is about 20 kHz and varies by person."),
1851
+ (">24kHz 能量可能来自乐器、话筒噪声、噪声整形、DSD 转 PCM 或后期非线性处理。能量多不等于音质好。",
1852
+ "Energy above 24 kHz can come from instruments, microphone noise, noise shaping, DSD-to-PCM conversion or "
1853
+ "non-linear processing. More energy does not mean better sound."),
1854
+ ("截止检测在全曲平均谱上连续扫描两种尺度:截止前后中位数相差 ≥25dB、斜率 ≥120dB/倍频程(模拟滚降通常只有 12–48),"
1855
+ "且截止以上的 90 分位电平比截止前低 ≥20dB,才算频宽上限。量化/抖动底噪的台阶同样计入,所以高频很弱的录音也能检出。",
1856
+ "Cuts are searched in the whole-file average spectrum at two scales: a band limit needs >= 25 dB between the "
1857
+ "medians on either side, a slope of >= 120 dB/octave (analogue roll-offs are 12-48), and a 90th-percentile level "
1858
+ "above the cut >= 20 dB below the level before it. Quantisation/dither floor steps count too, so recordings "
1859
+ "with very weak treble are still caught."),
1860
+ ("截止以上若是与通带对称的镜像(升频滤波不足),或先空出一段再出现上升的噪声(噪声整形,如经 DSD 转换),也视为频宽上限。"
1861
+ "线性插值等廉价升频几乎不衰减镜像、没有陡降,另由镜像对称性单独检测。",
1862
+ "A cut still counts as a band limit when what lies above mirrors the passband (a weak upsampling filter), or "
1863
+ "when a gap is followed by rising noise (noise shaping, e.g. via DSD). Cheap upsampling such as linear "
1864
+ "interpolation barely attenuates images and has no steep drop; it is caught separately by image symmetry."),
1865
+ ("来源采样率按截止起点或中点推断:须落在该采样率奈奎斯特频率的 80%–103%。滤波器较缓时 44.1k 与 48k 无法区分,报告会同时列出。",
1866
+ "The source rate is inferred from the start or midpoint of the cut, which must lie within 80-103% of that "
1867
+ "rate's Nyquist frequency. With gentle filters 44.1k and 48k cannot be told apart, and both are listed."),
1868
+ ("多级升频只有在中间步骤留下痕迹(镜像、噪声、第二个截止)时才能看出;转换干净时,与一次直接升频在数学上无法区分。",
1869
+ "Multi-stage upsampling shows only when a middle step leaves traces (images, noise, a second cut); clean "
1870
+ "conversions are mathematically identical to one direct upsampling."),
1871
+ ("截止也可能来自录音设备、母带处理或主动低通,不能证明有人伪造采样率。",
1872
+ "A cut can also come from recording equipment, mastering or a deliberate low-pass; it does not prove "
1873
+ "anyone faked the sample rate."),
1874
+ ("升采样后加入噪声/失真能重新产生超声能量,所以本脚本也无法证明没有升采样。",
1875
+ "Noise or distortion added after upsampling can recreate ultrasonic energy, so this tool cannot prove "
1876
+ "that no upsampling happened."),
1877
+ ("判定门限仅为可复查的经验规则,不是统计置信度或听觉阈值。极弱高频、窄带音乐、短片段可能误判。",
1878
+ "Thresholds are checkable rules of thumb, not statistical confidence or hearing thresholds. Very weak "
1879
+ "treble, narrow-band music and short excerpts can be misjudged."),
1880
+ ("频谱按声道功率平均,避免反相声道抵消;剔除极静窗。PSD 去除每窗直流;FFT 泄漏和量化噪声仍会影响结果。",
1881
+ "Channels are averaged by power so out-of-phase channels cannot cancel; near-silent windows are dropped. "
1882
+ "Each window's DC is removed; FFT leakage and quantisation noise still affect the result."),
1883
+ ("dBFS 以样本幅度 1 为参考:满幅正弦的 RMS 为 -3.01 dBFS;dB 相对值表示能量比。",
1884
+ "dBFS is relative to a sample value of 1: a full-scale sine has an RMS of -3.01 dBFS; relative dB are energy ratios."),
1885
+ ("滤波后可听频段残差很小,支持这次转换对该频段影响很小;不等于已经完成听觉测试。",
1886
+ "A small audible-band residual after filtering supports that the conversion barely changes that band; "
1887
+ "it is not a listening test."),
1888
+ ("需要比较听感时,应使用同一母带、电平匹配且转换质量足够的版本,做随机双盲 ABX。",
1889
+ "To compare how they sound, use the same master, level-matched, with good conversion, in a randomised "
1890
+ "blind ABX test."),
1891
+ ]],
1892
+ }
1893
+ if (metrics.get("chain_check") or {}).get("performed"):
1894
+ report["notes"] += [tr(zh, en) for zh, en in [
1895
+ ("加工链检查(--check-sr)比对两类指纹:一是对准采样率分数的窄带尖峰(如 192kHz 的 48k/72kHz,转换设备的固有杂音),"
1896
+ f"二是 {CHAIN_BAND_LOW_HZ / 1000:g}kHz 以上频谱的细节形状。真实录音之间这两者互不相关;同一条加工链做出来的文件则一致。",
1897
+ "The chain check (--check-sr) compares two fingerprints: narrow tones at simple fractions of the sample rate "
1898
+ "(e.g. 48k/72 kHz at 192 kHz, a converter's own noise), and the fine shape of the spectrum above "
1899
+ f"{CHAIN_BAND_LOW_HZ / 1000:g} kHz. Unrelated recordings do not share them; files from one chain do."),
1900
+ ("尖峰必须全部出现才算吻合,单独一根尖峰在原生录音里也见过(2L 的 192k 录音曾出现 7.9dB 的 48kHz 尖峰)。",
1901
+ "All of a chain's tones must be present: a single tone has been seen in native recordings (a 2L 192k "
1902
+ "recording showed a 7.9 dB tone at 48 kHz)."),
1903
+ ("吻合只说明和学习用的那批文件经过同一种加工,说不出加工方式(回录、AI 或其他);同一套设备做的真录音也可能被误认。",
1904
+ "A match only says the file went through the same processing as the files the chain was learned from; it "
1905
+ "cannot say how (re-recording, AI or anything else), and real recordings made on the same equipment may match too."),
1906
+ ]]
1907
+ report["heuristics"].update({
1908
+ "chain_band_low_hz": CHAIN_BAND_LOW_HZ, "chain_pool_bins": CHAIN_POOL_BINS,
1909
+ "chain_smooth_bins": CHAIN_SMOOTH_BINS, "chain_tone_half_width_hz": CHAIN_TONE_HALF_HZ,
1910
+ "chain_tone_side_hz": list(CHAIN_TONE_SIDE_HZ), "chain_silence_dbfs": CHAIN_SILENCE_DBFS,
1911
+ })
1912
+ if not info["dsd_rate_hz"] and info["sample_rate_hz"] <= REFERENCE_RATE:
1913
+ report["notes"] = [tr(zh, en) for zh, en in [
1914
+ ("44.1/48kHz 文件只做有损检测:有损编码器会在 16–20.4kHz 加陡峭低通,心理声学模型还会让 16kHz 以上的频段时有时无,形成频谱空洞。",
1915
+ "44.1/48 kHz files only get the lossy check: lossy encoders apply a steep low-pass at 16-20.4 kHz, and "
1916
+ "their psychoacoustic model makes bands above 16 kHz come and go, leaving spectral holes."),
1917
+ (f"判为疑似有损的条件:平均谱在 {LOSSY_SEARCH_FROM_HZ / 1000:g}–{LOSSY_MAX_CUTOFF_HZ / 1000:g}kHz 内有 ≥{LOSSY_STEP_DB}dB 的陡降,"
1918
+ f"或 16kHz 以上响亮片段中 ≥{LOSSY_HOLES_PERCENT:g}% 的时频点比该频点中位数低 {LOSSY_HOLE_DB}dB 以上(整段高频停顿的片段不计入)。"
1919
+ "门限用 CD 原盘与其 MP3/AAC/Opus/Vorbis 转码校准,属经验规则。",
1920
+ f"Suspected lossy when the average spectrum drops >= {LOSSY_STEP_DB} dB within "
1921
+ f"{LOSSY_SEARCH_FROM_HZ / 1000:g}-{LOSSY_MAX_CUTOFF_HZ / 1000:g} kHz, or when >= {LOSSY_HOLES_PERCENT:g}% of "
1922
+ f"time-frequency cells above 16 kHz in loud frames are {LOSSY_HOLE_DB} dB or more below that bin's median "
1923
+ "(frames where the whole treble pauses are left out). Thresholds were calibrated on CD rips and their "
1924
+ "MP3/AAC/Opus/Vorbis transcodes; they are rules of thumb."),
1925
+ ("CD 播放链路的抗混叠滤波器通常在 20.75kHz 以上,所以 20.5kHz 以上的截止不算有损迹象;这也意味着截止高于 20.5kHz、空洞又少的高码率编码检不出来。",
1926
+ "CD anti-alias filters usually sit at 20.75 kHz or higher, so a cut above 20.5 kHz is not taken as lossy; "
1927
+ "it also means high-rate encodes that cut above 20.5 kHz with few holes cannot be caught."),
1928
+ ("实测检不出:FFmpeg AAC 256/320kbps;容易检出:MP3(含 320kbps 与 VBR V0)、128kbps 级别的 AAC、Opus、Vorbis。",
1929
+ "Not caught in tests: FFmpeg AAC 256/320 kbps. Easily caught: MP3 (including 320 kbps and VBR V0), "
1930
+ "AAC around 128 kbps, Opus, Vorbis."),
1931
+ ("母带主动低通、老录音、降噪处理也会产生截止;“疑似有损”需要结合来源判断,“未见有损迹象”也不能证明一定是无损母带。",
1932
+ "Mastering low-passes, old recordings and noise reduction also make cuts; weigh \"suspected lossy\" against "
1933
+ "the source, and \"no lossy sign\" does not prove a lossless master."),
1934
+ ("dBFS 以样本幅度 1 为参考;频谱按声道功率平均,并剔除极静窗。",
1935
+ "dBFS is relative to a sample value of 1; channels are averaged by power and near-silent windows dropped."),
1936
+ ]]
1937
+ if info["dsd_rate_hz"]:
1938
+ report["notes"] = [tr(zh, en) for zh, en in [
1939
+ ("DSD 由 sigma-delta 调制产生,噪声整形把量化噪声推到超声区;无论来源是不是 PCM,都会有这段上升的噪声,它本身不能证明真假。",
1940
+ "DSD comes from sigma-delta modulation, whose noise shaping pushes quantisation noise into the ultrasonic "
1941
+ "band; every DSD has this rising noise whatever its source, so it proves nothing by itself."),
1942
+ ("PCM 来源只能在调制噪声抬起之前看到:DSD64 通常只能分辨 44.1/48kHz 及部分 88.2/96kHz 来源,DSD128/256 能看得更高;DXD(352.8kHz)来源一般无法检出。",
1943
+ "A PCM source can only be seen below where the modulator noise rises: DSD64 usually shows 44.1/48 kHz and "
1944
+ "some 88.2/96 kHz sources, DSD128/256 higher; DXD (352.8 kHz) sources generally cannot be seen."),
1945
+ ("许多正规 SACD 也在 PCM/DXD 中剪辑或做母带,“PCM 来源”不等于伪造;但 22.05kHz 附近的硬截止通常说明来自 CD 规格的母带。",
1946
+ "Many legitimate SACDs are edited or mastered in PCM/DXD, so \"PCM source\" is not forgery; but a hard cut "
1947
+ "near 22.05 kHz usually means a CD-resolution master."),
1948
+ ("两级噪声隆起提示 DSD 升频(低倍率 DSD 经 PCM 中转后重新调制)。不同调制器的噪声曲线差异很大,这一项只是线索,不是定论。",
1949
+ "Two noise humps suggest DSD upsampling (a lower-rate DSD re-modulated via PCM). Modulator noise curves "
1950
+ "vary a lot, so this is a clue, not proof."),
1951
+ ("只分析 FFmpeg 解码结果中可信的频段(解码后奈奎斯特频率的一半,DSD64 为 88.2kHz);截止门限与 PCM 检测相同,均为经验规则。",
1952
+ "Only the trustworthy part of FFmpeg's decode is analysed (half the decoded Nyquist frequency, 88.2 kHz for "
1953
+ "DSD64); the cut thresholds are the same rules of thumb as for PCM."),
1954
+ ("dBFS 以样本幅度 1 为参考;DSD 的 0dB 参考电平按 FFmpeg 解码结果计。",
1955
+ "dBFS is relative to a sample value of 1; DSD's 0 dB reference follows FFmpeg's decoder."),
1956
+ ]]
1957
+ return report
1958
+
1959
+
1960
+ def check_file(path: Path, out: Path, max_seconds: float | None, compare_seconds: float,
1961
+ no_plot: bool, progress=None, check_sr: bool = False) -> tuple[dict, str]:
1962
+ """Analyse one file and write its four report files into `out`."""
1963
+ step = progress or (lambda message: None)
1964
+ input_path = path.resolve()
1965
+ # Never overwrite the input, even if it happens to use a report filename.
1966
+ if any((out / name).resolve() == input_path for name in REPORT_FILES):
1967
+ raise ValueError(tr("输出文件会覆盖输入音频,请更换 --out 目录。",
1968
+ "A report file would overwrite the input audio; choose another --out folder."))
1969
+ step(tr("[1/3] 流式扫描频谱…", "[1/3] Scanning the spectrum..."))
1970
+ info, data = analyze_spectrum(path, max_seconds, check_sr=check_sr)
1971
+ metrics = measure_bands(info, data)
1972
+ if check_sr:
1973
+ metrics["chain_check"] = chain_check(info, data)
1974
+ step(tr("[2/3] 测量 48kHz 转换差分…", "[2/3] Measuring the 48 kHz round trip..."))
1975
+ roundtrip = compare_roundtrip(path, info, compare_seconds)
1976
+ report = build_report(info, metrics, roundtrip)
1977
+ report["language"] = LANG
1978
+ report["plain"] = plain_summary(info, metrics, report["verdict"])
1979
+ out.mkdir(parents=True, exist_ok=True)
1980
+ step(tr("[3/3] 写入报告…", "[3/3] Writing the report..."))
1981
+ plotted = False if no_plot else save_plot(out / "spectrum.png", info, data, metrics)
1982
+ report["plot_generated"] = plotted
1983
+ report["plot_status"] = "created" if plotted else ("disabled" if no_plot else "matplotlib_unavailable")
1984
+ text = format_report(report)
1985
+ (out / "report.txt").write_text(text, encoding="utf-8")
1986
+ (out / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
1987
+ with (out / "spectrum.csv").open("w", encoding="utf-8", newline="") as handle:
1988
+ writer = csv.writer(handle)
1989
+ writer.writerow(["frequency_hz", "mean_psd_dbfs_per_hz"] +
1990
+ [f"channel_{c + 1}_psd_dbfs_per_hz" for c in range(info["channels"])])
1991
+ for index, frequency in enumerate(data["freq"]):
1992
+ writer.writerow([f"{frequency:.6f}", f"{db(data['psd'][index]):.6f}"] +
1993
+ [f"{db(p[index]):.6f}" for p in data["psd_channels"]])
1994
+ return report, text
1995
+
1996
+
1997
+ def find_audio(paths: list[Path], skip: Path) -> list[tuple[Path, Path]]:
1998
+ """(file, path relative to the report root) for every input; folders are walked recursively.
1999
+
2000
+ Hidden entries and macOS "._" resource files are ignored, and symlinked
2001
+ folders are not followed, so a link back up the tree cannot loop.
2002
+ """
2003
+ found = []
2004
+ for given in paths:
2005
+ if given.is_file():
2006
+ found.append((given, Path(given.name)))
2007
+ continue
2008
+ root = given.resolve()
2009
+ # With several inputs, each folder keeps its own name under the report root.
2010
+ prefix = Path(root.name) if len(paths) > 1 else Path()
2011
+ for folder, dirs, files in os.walk(root):
2012
+ here = Path(folder)
2013
+ dirs[:] = sorted(d for d in dirs if not d.startswith(".") and (here / d).resolve() != skip)
2014
+ for name in sorted(files):
2015
+ if name.startswith(".") or Path(name).suffix.lower() not in AUDIO_EXTENSIONS:
2016
+ continue
2017
+ found.append((here / name, prefix / (here / name).relative_to(root)))
2018
+ return found
2019
+
2020
+
2021
+ def report_dirs(found: list[tuple[Path, Path]], root: Path) -> list[Path]:
2022
+ """One report folder per file, mirroring the input tree; same-stem files keep their extension."""
2023
+ stems: dict[Path, int] = {}
2024
+ for _, rel in found:
2025
+ stems[rel.with_suffix("")] = stems.get(rel.with_suffix(""), 0) + 1
2026
+ dirs, used = [], set()
2027
+ for _, rel in found:
2028
+ target = rel.with_suffix("")
2029
+ if stems[target] > 1:
2030
+ target = rel.with_name(f"{rel.stem}_{rel.suffix.lstrip('.').lower()}")
2031
+ candidate, n = target, 2
2032
+ while candidate in used:
2033
+ candidate, n = target.with_name(f"{target.name} ({n})"), n + 1
2034
+ used.add(candidate)
2035
+ dirs.append(root / candidate)
2036
+ return dirs
2037
+
2038
+
2039
+ def source_rate(report: dict) -> int | None:
2040
+ """The lowest source rate the verdict relies on: a band limit or resampler images."""
2041
+ metrics = report["spectral_metrics"]
2042
+ source = next((e for e in metrics["band_edges"] if e["role"] == "source_limit"), None)
2043
+ rates = [source["possible_source_rates_hz"][0]] if source and source["possible_source_rates_hz"] else []
2044
+ rates += [m["source_rate_hz"] for m in metrics["imaging"]]
2045
+ return min(rates) if rates else None
2046
+
2047
+
2048
+ VERDICT_TAGS = {
2049
+ "lossy_suspect": ("疑似有损转无损", "probably from lossy"), "no_lossy_sign": ("未见有损痕迹", "no lossy sign"),
2050
+ "upsampled": ("假高解析(升频)", "fake hi-res (upsampled)"),
2051
+ "band_limited_low": ("假高解析(来源频宽很低)", "fake hi-res (low-bandwidth source)"),
2052
+ "band_limited_nonstandard": ("来源不明", "unclear source"),
2053
+ "little_ultrasonic_energy": ("高解析意义不大", "hi-res adds little"),
2054
+ "ultrasonic_content": ("像真高解析", "looks like real hi-res"),
2055
+ "ultrasonic_noise_like": ("高频像噪声", "treble looks like noise"),
2056
+ "uncertain": ("无法判断", "cannot tell"), "insufficient_signal": ("无法判断", "cannot tell"),
2057
+ "dsd_pcm_origin": ("PCM 转制的 DSD", "DSD made from PCM"),
2058
+ "dsd_lossy_origin": ("低规格来源转制的 DSD", "DSD from a low-quality source"),
2059
+ "dsd_remodulated": ("DSD 升频", "upsampled DSD"), "dsd_no_pcm_edge": ("未见转制痕迹", "no conversion sign"),
2060
+ "dsd_band_limited_nonstandard": ("来源不明", "unclear source"),
2061
+ }
2062
+
2063
+
2064
+ def row_tag(row: dict) -> str:
2065
+ if row["status"] != "ok":
2066
+ return {"skipped": tr("已跳过", "skipped"), "error": tr("出错", "error")}[row["status"]]
2067
+ if row.get("chain") and row["verdict"] in CHAIN_OVERRIDES:
2068
+ return tr(f"疑似加工链:{row['chain']}", f"processing chain: {row['chain']}")
2069
+ return tr(*VERDICT_TAGS[row["verdict"]]) if row["verdict"] in VERDICT_TAGS else row["verdict"]
2070
+
2071
+
2072
+ # Verdicts whose text names a source rate (the summary table repeats it).
2073
+ SOURCE_VERDICTS = {"upsampled", "dsd_pcm_origin"}
2074
+
2075
+
2076
+ def batch_one(path: str, rel: str, out: str, max_seconds: float | None,
2077
+ compare_seconds: float, no_plot: bool, check_sr: bool = False, lang: str | None = None) -> dict:
2078
+ """Summary row for one file of a folder scan; runs in a worker process with -j."""
2079
+ if lang:
2080
+ set_lang(lang) # worker processes started by "spawn" (macOS, Windows) do not inherit it
2081
+ row = {"file": rel, "status": "ok", "verdict": "", "conclusion": "", "grades": "", "summary": "",
2082
+ "format": "", "sample_rate_hz": "", "source_rate_hz": "", "chain": "", "report_dir": out, "error": ""}
2083
+ try:
2084
+ meta = probe(Path(path))
2085
+ row["format"] = f"{meta['codec']} {format_label(meta)}"
2086
+ row["sample_rate_hz"] = meta["dsd_rate_hz"] or meta["sample_rate_hz"]
2087
+ if meta["codec"] in LOSSY_CODECS:
2088
+ row.update(status="skipped", report_dir="", conclusion=tr(
2089
+ f"本身就是有损格式({meta['codec']}),已跳过。", f"Already a lossy format ({meta['codec']}); skipped."))
2090
+ return row
2091
+ report, _ = check_file(Path(path), Path(out), max_seconds, compare_seconds, no_plot, check_sr=check_sr)
2092
+ best = (report["spectral_metrics"].get("chain_check") or {}).get("best")
2093
+ row["chain"] = best["label"] if best else ""
2094
+ rate = source_rate(report)
2095
+ plain = report["plain"]
2096
+ row.update(verdict=report["verdict"]["code"], summary=report["verdict"]["summary"],
2097
+ conclusion=plain["headline"],
2098
+ grades=sep().join(f"{i['question']}{tr(':', ': ')}{i['level_text']}" for i in plain["items"]),
2099
+ source_rate_hz=rate if rate and report["verdict"]["code"] in SOURCE_VERDICTS else "")
2100
+ except (OSError, RuntimeError, ValueError) as exc:
2101
+ row.update(status="error", error=str(exc), report_dir="")
2102
+ except Exception as exc: # One unexpected failure must not end a library scan.
2103
+ row.update(status="error", error=tr("内部错误 ", "Internal error ") + f"{type(exc).__name__}: {exc}",
2104
+ report_dir="")
2105
+ return row
2106
+
2107
+
2108
+
2109
+ def write_summary(root: Path, rows: list[dict]) -> None:
2110
+ fields = ["file", "status", "conclusion", "grades", "verdict", "source_rate_hz", "chain", "format",
2111
+ "sample_rate_hz", "summary", "report_dir", "error"]
2112
+ with (root / "summary.csv").open("w", encoding="utf-8-sig", newline="") as handle: # BOM so Excel reads UTF-8
2113
+ writer = csv.DictWriter(handle, fieldnames=fields)
2114
+ writer.writeheader()
2115
+ writer.writerows(rows)
2116
+ counts: dict[str, int] = {}
2117
+ for row in rows:
2118
+ counts[row_tag(row)] = counts.get(row_tag(row), 0) + 1
2119
+ lines = [tr(f"hirescheck {VERSION} 批量检查:共 {len(rows)} 个文件", f"hirescheck {VERSION} batch check: {len(rows)} files"), "",
2120
+ tr("(“可能性”是按证据强弱分的等级,不是统计出来的概率;每首的技术细节见各自的 report.txt。)",
2121
+ "(Likelihoods are grades of evidence strength, not measured probabilities; technical details are in "
2122
+ "each file's report.txt.)"), ""]
2123
+ lines += [f" {key:<30}{count:>5}" for key, count in sorted(counts.items(), key=lambda kv: -kv[1])]
2124
+ lines.append("")
2125
+ for row in rows:
2126
+ lines.append(f"[{row_tag(row)}] {row['file']}" + (tr(f"({row['format']})", f" ({row['format']})")
2127
+ if row["format"] else ""))
2128
+ lines.append(f" {row['conclusion'] or row['error']}")
2129
+ if row["grades"]:
2130
+ lines.append(f" {row['grades']}")
2131
+ (root / "summary.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
2132
+
2133
+
2134
+ def run_batch(paths: list[Path], root: Path, args: argparse.Namespace) -> int:
2135
+ found = find_audio(paths, root.resolve())
2136
+ if not found:
2137
+ print(tr("错误:没有找到可检查的音频文件。", "Error: no audio files to check were found."), file=sys.stderr)
2138
+ return 1
2139
+ outs = report_dirs(found, root)
2140
+ root.mkdir(parents=True, exist_ok=True)
2141
+ print(tr(f"找到 {len(found)} 个音频文件;报告写入 {root.resolve()}",
2142
+ f"Found {len(found)} audio files; reports go to {root.resolve()}"), file=sys.stderr, flush=True)
2143
+ jobs = [(str(f), str(rel), str(out), args.max_seconds, args.compare_seconds, args.no_plot, args.check_sr, LANG)
2144
+ for (f, rel), out in zip(found, outs)]
2145
+ rows: list[dict | None] = [None] * len(jobs)
2146
+ done = 0
2147
+
2148
+ def show(index: int, row: dict) -> None:
2149
+ nonlocal done
2150
+ done += 1
2151
+ rows[index] = row
2152
+ fmt = tr(f"({row['format']})", f" ({row['format']})") if row["format"] else ""
2153
+ print(f"[{done}/{len(jobs)}] {row['file']}{fmt}{tr(':', ': ')}{row['conclusion'] or row['error']}", flush=True)
2154
+
2155
+ if args.jobs > 1:
2156
+ from concurrent.futures import ProcessPoolExecutor, as_completed
2157
+ with ProcessPoolExecutor(max_workers=args.jobs) as pool:
2158
+ futures = {pool.submit(batch_one, *job): i for i, job in enumerate(jobs)}
2159
+ for future in as_completed(futures):
2160
+ show(futures[future], future.result())
2161
+ else:
2162
+ for i, job in enumerate(jobs):
2163
+ show(i, batch_one(*job))
2164
+ write_summary(root, rows)
2165
+ errors = sum(row["status"] == "error" for row in rows)
2166
+ print(tr(f"完成:{len(rows) - errors} 个已处理,{errors} 个出错;汇总见 {(root / 'summary.txt').resolve()}",
2167
+ f"Done: {len(rows) - errors} processed, {errors} failed; summary in {(root / 'summary.txt').resolve()}"))
2168
+ return 1 if errors else 0
2169
+
2170
+
2171
+ def chain_spectrum_job(path: str, max_seconds: float | None) -> tuple:
2172
+ try:
2173
+ meta, spectrum = chain_spectrum(Path(path), max_seconds)
2174
+ return meta, spectrum, ""
2175
+ except (OSError, RuntimeError, ValueError) as exc:
2176
+ return None, None, str(exc)
2177
+
2178
+
2179
+ def tone_curve(freq: np.ndarray, psd: np.ndarray, fs: int) -> np.ndarray:
2180
+ """Prominence of every bin over a 2.4 kHz running median, for finding learned tones."""
2181
+ df = freq[1] - freq[0]
2182
+ level = 10 * np.log10(np.maximum(psd, 1e-30))
2183
+ floor = ndimage.median_filter(level, size=int(2400 / df) | 1, mode="nearest")
2184
+ peak = ndimage.maximum_filter1d(level, size=int(2 * CHAIN_TONE_HALF_HZ / df) | 1, mode="nearest")
2185
+ curve = peak - floor
2186
+ curve[(freq < 20000) | (freq > 0.49 * fs)] = -np.inf
2187
+ return curve
2188
+
2189
+
2190
+ def chain_slug(name: str) -> str:
2191
+ return re.sub(r"[^\w.-]+", "_", name).strip("._") or "chain"
2192
+
2193
+
2194
+ def learn_chain(name: str, paths: list[Path], out: Path | None, note: str | None,
2195
+ max_seconds: float | None, jobs: int) -> int:
2196
+ """Build a chain fingerprint from files known to share one production path."""
2197
+ builtin = {c["name"] for c in load_chains() if c["origin"] == "builtin"}
2198
+ if name in builtin and out is None:
2199
+ print(tr(f"错误:「{name}」是内置加工链的名字,请换一个。",
2200
+ f"Error: \"{name}\" is the name of a built-in chain; choose another."), file=sys.stderr)
2201
+ return 1
2202
+ files = [f for f, _ in find_audio(paths, Path("/"))]
2203
+ if len(files) < CHAIN_MIN_FILES:
2204
+ print(tr(f"错误:学习加工链至少需要 {CHAIN_MIN_FILES} 个文件(来自至少 {CHAIN_MIN_FOLDERS} 个不同文件夹),"
2205
+ f"现在只有 {len(files)} 个。",
2206
+ f"Error: learning a chain needs at least {CHAIN_MIN_FILES} files (from at least {CHAIN_MIN_FOLDERS} "
2207
+ f"different folders); got {len(files)}."), file=sys.stderr)
2208
+ return 1
2209
+ print(tr(f"读取 {len(files)} 个文件的长窗频谱…", f"Reading long-window spectra of {len(files)} files..."),
2210
+ file=sys.stderr, flush=True)
2211
+ if jobs > 1:
2212
+ from concurrent.futures import ProcessPoolExecutor
2213
+ with ProcessPoolExecutor(max_workers=jobs, initializer=set_lang, initargs=(LANG,)) as pool:
2214
+ results = list(pool.map(chain_spectrum_job, [str(f) for f in files], [max_seconds] * len(files)))
2215
+ else:
2216
+ results = [chain_spectrum_job(str(f), max_seconds) for f in files]
2217
+ problems, rates = [], {}
2218
+ for f, (meta, spectrum, error) in zip(files, results):
2219
+ if error:
2220
+ problems.append(f"{f}:{error}")
2221
+ elif meta["dsd_rate_hz"] or meta["codec"] in LOSSY_CODECS:
2222
+ problems.append(tr(f"{f}:只能用 PCM 无损文件学习(这是 {meta['codec']})",
2223
+ f"{f}: only lossless PCM files can be used (this is {meta['codec']})"))
2224
+ elif spectrum is None:
2225
+ problems.append(tr(f"{f}:有效声音太少", f"{f}: too little signal"))
2226
+ else:
2227
+ rates.setdefault(meta["sample_rate_hz"], []).append((f, spectrum))
2228
+ if problems:
2229
+ print(tr("错误:以下文件不能用来学习:", "Error: these files cannot be used for learning:")
2230
+ + "\n " + "\n ".join(problems), file=sys.stderr)
2231
+ return 1
2232
+ if len(rates) > 1:
2233
+ detail = tr(",", ", ").join(tr(f"{khz(r)} {len(v)} 个", f"{len(v)} at {khz(r)}") for r, v in sorted(rates.items()))
2234
+ print(tr(f"错误:一条加工链只对应一种采样率,这批文件混有 {detail}。请分开学习。",
2235
+ f"Error: a chain covers one sample rate, but these files mix {detail}. Learn them separately."),
2236
+ file=sys.stderr)
2237
+ return 1
2238
+ fs, members = next(iter(rates.items()))
2239
+ if fs < CHAIN_MIN_RATE:
2240
+ print(tr(f"错误:{khz(fs)} 没有超声频段,无法提取加工链指纹(至少 {khz(CHAIN_MIN_RATE)})。",
2241
+ f"Error: {khz(fs)} has no ultrasonic band to fingerprint (needs {khz(CHAIN_MIN_RATE)} or more)."),
2242
+ file=sys.stderr)
2243
+ return 1
2244
+ folders = {f.resolve().parent for f, _ in members}
2245
+ if len(folders) < CHAIN_MIN_FOLDERS:
2246
+ print(tr(f"错误:文件只来自 {len(folders)} 个文件夹。至少需要 {CHAIN_MIN_FOLDERS} 个不同文件夹(专辑),"
2247
+ "否则学到的可能只是某张专辑自己的母带特征。",
2248
+ f"Error: the files come from only {len(folders)} folder(s). At least {CHAIN_MIN_FOLDERS} different "
2249
+ "folders (albums) are needed, or the fingerprint may just be one album's mastering."), file=sys.stderr)
2250
+ return 1
2251
+
2252
+ # Tones: bins that stand out in every file.
2253
+ curves = np.array([tone_curve(fr, ps, fs) for _, (fr, ps) in members])
2254
+ freq = members[0][1][0]
2255
+ weakest = curves.min(axis=0)
2256
+ weakest[~np.isfinite(weakest)] = -100
2257
+ peaks, props = signal.find_peaks(weakest, height=CHAIN_LEARN_TONE_DB,
2258
+ distance=max(1, int(CHAIN_TONE_NOTCH_HZ / (freq[1] - freq[0]))))
2259
+ # Refine each candidate to the peak of the files' mean level (the max filter smears it).
2260
+ mean_level = np.mean([10 * np.log10(np.maximum(ps, 1e-30)) for _, (_, ps) in members], axis=0)
2261
+ picked = []
2262
+ for i in peaks[np.argsort(-props["peak_heights"])][:CHAIN_MAX_TONES]:
2263
+ near = np.nonzero(np.abs(freq - freq[i]) <= 2 * CHAIN_TONE_HALF_HZ)[0]
2264
+ picked.append(float(round(freq[near[np.argmax(mean_level[near])]])))
2265
+ tones = []
2266
+ for hz in sorted(picked):
2267
+ values = [tone_prominence(fr, 10 * np.log10(np.maximum(ps, 1e-30)), hz) for _, (fr, ps) in members]
2268
+ if all(v is not None for v in values) and min(values) >= CHAIN_LEARN_TONE_DB:
2269
+ low, high = CHAIN_TONE_DB_RANGE
2270
+ tones.append({"hz": hz, "min_db": round(float(min(max(min(values) - CHAIN_TONE_MARGIN_DB, low), high)), 1),
2271
+ "learned_min_db": round(min(values), 1), "learned_values_db": [round(v, 1) for v in values]})
2272
+ notch = [t["hz"] for t in tones]
2273
+ shapes = np.array([chain_shape(fr, ps, fs, notch) for _, (fr, ps) in members])
2274
+ if shapes.shape[1] == 0:
2275
+ print(tr("错误:超声频段太窄,无法提取形状指纹。", "Error: the ultrasonic band is too narrow for a shape fingerprint."),
2276
+ file=sys.stderr)
2277
+ return 1
2278
+
2279
+ def template_of(rows: np.ndarray) -> np.ndarray:
2280
+ t = rows.mean(axis=0)
2281
+ keep = t != 0
2282
+ out_ = np.zeros(len(t))
2283
+ out_[keep] = (t[keep] - t[keep].mean()) / max(t[keep].std(), 1e-9)
2284
+ return out_
2285
+
2286
+ loo = []
2287
+ for i in range(len(shapes)):
2288
+ t = template_of(np.delete(shapes, i, axis=0))
2289
+ loo.append(float(shapes[i] @ t / len(t)))
2290
+ print(tr(f"\n{len(members)} 个文件,{len(folders)} 个文件夹,{khz(fs)}:",
2291
+ f"\n{len(members)} files, {len(folders)} folders, {khz(fs)}:"))
2292
+ for (f, _), r, i in zip(members, loo, range(len(members))):
2293
+ tone_text = ", ".join(f"{t['hz'] / 1000:g}k {t['learned_values_db'][i]:.1f}dB" for t in tones)
2294
+ print(f" {f.name[:40]:<40} {tr('留一法形状相关', 'leave-one-out shape r')} {r:5.2f} {tone_text}")
2295
+ shape_only_ok = min(loo) >= CHAIN_SHAPE_ONLY
2296
+ sys.stdout.flush()
2297
+ if len(tones) < 2 and not shape_only_ok:
2298
+ print(tr(f"\n错误:没找到这批文件的共同指纹:共同尖峰 {len(tones)} 根(至少要 2 根),"
2299
+ f"留一法形状相关最低 {min(loo):.2f}(只靠形状需要每个文件都 ≥{CHAIN_SHAPE_ONLY:g})。"
2300
+ "它们可能并非出自同一条加工链。",
2301
+ f"\nError: these files share no fingerprint: {len(tones)} common tone(s) (at least 2 needed), "
2302
+ f"lowest leave-one-out shape correlation {min(loo):.2f} (shape alone needs every file >= "
2303
+ f"{CHAIN_SHAPE_ONLY:g}). They may not come from one processing chain."), file=sys.stderr)
2304
+ return 1
2305
+ chain = {
2306
+ "format": CHAIN_FORMAT, "name": name, "label": name, "sample_rate_hz": fs,
2307
+ "note": note or f"和你用 --learn-chain 学习的那 {len(members)} 个文件经过同一种加工",
2308
+ "note_en": None if note else f"processed the same way as the {len(members)} files you learned it from",
2309
+ "source_max_hz": None,
2310
+ "tones": [{k: t[k] for k in ("hz", "min_db", "learned_min_db")} for t in tones],
2311
+ "shape_strong": CHAIN_SHAPE_STRONG, "shape_only": CHAIN_SHAPE_ONLY if shape_only_ok else None,
2312
+ "learned": {"files": len(members), "folders": len(folders), "program_version": VERSION,
2313
+ "loo_shape_correlation_min": round(min(loo), 3),
2314
+ "loo_shape_correlation_median": round(float(np.median(loo)), 3)},
2315
+ "shape_template": [round(float(v), 3) for v in template_of(shapes)],
2316
+ }
2317
+ target = out or CHAIN_USER_DIR / f"{chain_slug(name)}.json"
2318
+ target.parent.mkdir(parents=True, exist_ok=True)
2319
+ existed = target.exists()
2320
+ target.write_text(json.dumps(chain, ensure_ascii=False) + "\n", encoding="utf-8")
2321
+ found_tones = tr("、", ", ").join(tr(f"{t['hz'] / 1000:g}kHz(匹配门槛 {t['min_db']:g}dB)",
2322
+ f"{t['hz'] / 1000:g} kHz (match threshold {t['min_db']:g} dB)")
2323
+ for t in tones) or tr("无", "none")
2324
+ print(tr("\n共同尖峰:", "\nCommon tones: ") + found_tones)
2325
+ if np.median(loo) < CHAIN_SHAPE_STRONG:
2326
+ print(tr(f"注意:形状相关的中位数只有 {np.median(loo):.2f},形状指纹较弱,匹配主要靠尖峰。",
2327
+ f"Note: the median shape correlation is only {np.median(loo):.2f}; the shape fingerprint is weak "
2328
+ "and matching relies mostly on the tones."))
2329
+ print(tr(f"{'已覆盖' if existed else '已保存'}加工链指纹:{target}",
2330
+ f"Chain fingerprint {'overwritten' if existed else 'saved'}: {target}"))
2331
+ print(tr("以后加 --check-sr 检查时会自动比对。建议先用几首确定不是这条链的文件试一下,确认不会误报。",
2332
+ "It will be compared whenever you check with --check-sr. Try a few files you know are not from this "
2333
+ "chain first, to make sure it does not give false matches."))
2334
+ return 0
2335
+
2336
+
2337
+ def list_chains() -> int:
2338
+ chains = load_chains()
2339
+ if not chains:
2340
+ print(tr("没有可用的加工链指纹。", "No chain fingerprints available."))
2341
+ return 0
2342
+ for c in chains:
2343
+ tones = tr("、", ", ").join(f"{t['hz'] / 1000:g}kHz" for t in c["tones"]) or tr("无", "none")
2344
+ origin = tr("内置", "built-in") if c["origin"] == "builtin" else tr("自己学习", "learned")
2345
+ label = chain_field(c, "label") or c["name"]
2346
+ print(tr(f"「{label}」({origin}):{khz(c['sample_rate_hz'])},尖峰 {tones},由 {c['learned']['files']} 个文件学习",
2347
+ f"\"{label}\" ({origin}): {khz(c['sample_rate_hz'])}, tones {tones}, learned from "
2348
+ f"{c['learned']['files']} files") + f"\n {c['file']}")
2349
+ return 0
2350
+
2351
+
2352
+ def pick_lang(argv: list[str]) -> str | None:
2353
+ """--lang value from the raw arguments, read before the parser so --help is translated too."""
2354
+ for i, arg in enumerate(argv):
2355
+ if arg == "--lang" and i + 1 < len(argv):
2356
+ return argv[i + 1]
2357
+ if arg.startswith("--lang="):
2358
+ return arg.split("=", 1)[1]
2359
+ return None
2360
+
2361
+
2362
+ def main(argv: list[str] | None = None) -> int:
2363
+ argv = sys.argv[1:] if argv is None else list(argv)
2364
+ lang = pick_lang(argv)
2365
+ if lang in ("zh", "en"):
2366
+ set_lang(lang)
2367
+ parser = argparse.ArgumentParser(
2368
+ prog="hirescheck", formatter_class=argparse.RawDescriptionHelpFormatter,
2369
+ description=tr("检查 PCM / DSD 文件的真实规格:44.1/48k 是否由有损文件转来,高采样率是否升频而来、"
2370
+ "原始采样率大概是多少,DSD 是否由 PCM 转制。结果是证据强弱的分级,不是鉴定结论。",
2371
+ "Check what a PCM or DSD file really holds: whether 44.1/48k came from a lossy file, whether a "
2372
+ "high-rate file was upsampled and from what rate, and whether a DSD was made from PCM. Results "
2373
+ "are graded evidence, not proof."))
2374
+ parser.add_argument("paths", nargs="*", type=Path, metavar=tr("路径", "PATH"),
2375
+ help=tr("待检查的音频文件或文件夹(FLAC、WAV、AIFF、ALAC 等 PCM,或 DSF/DFF);文件夹会递归扫描",
2376
+ "audio files or folders to check (FLAC, WAV, AIFF, ALAC and other PCM, or DSF/DFF); "
2377
+ "folders are scanned recursively"))
2378
+ parser.add_argument("--out", type=Path,
2379
+ help=tr("报告目录;单个文件默认为 ./<文件名>_hirescheck,文件夹默认为 ./<文件夹名>_hirescheck",
2380
+ "report folder; defaults to ./<file name>_hirescheck for a file and "
2381
+ "./<folder name>_hirescheck for a folder"))
2382
+ parser.add_argument("--max-seconds", type=positive,
2383
+ help=tr("仅扫描开头 N 秒;默认扫描全曲", "scan only the first N seconds; default: the whole file"))
2384
+ parser.add_argument("--compare-seconds", type=nonnegative, default=24,
2385
+ help=tr("差分选段总时长,默认 24 秒;0 为关闭",
2386
+ "total length of the round-trip excerpts, default 24 s; 0 turns it off"))
2387
+ parser.add_argument("--no-plot", action="store_true",
2388
+ help=tr("不生成 PNG,也不需要 matplotlib", "skip the PNG; matplotlib is then not needed"))
2389
+ parser.add_argument("-j", "--jobs", type=int, default=1,
2390
+ help=tr("批量检查时同时分析的文件数,默认 1", "files analysed in parallel in a batch, default 1"))
2391
+ parser.add_argument("--lang", choices=("zh", "en"),
2392
+ help=tr("输出语言:zh 中文,en 英文;默认跟随系统语言",
2393
+ "output language: zh Chinese, en English; default follows the system language"))
2394
+ chain = parser.add_argument_group(
2395
+ tr("加工链检查(检查超声部分是不是由某条已知的加工链做出来的,只对 88.2kHz 及以上的 PCM 有效)",
2396
+ "processing-chain check (whether the ultrasonic band was made by a known processing chain; "
2397
+ "PCM at 88.2 kHz and above only)"))
2398
+ chain.add_argument("--check-sr", action="store_true",
2399
+ help=tr("检查时比对内置和自己学习的加工链指纹;不加则不做这项检查",
2400
+ "compare against the built-in and learned chain fingerprints; off by default"))
2401
+ chain.add_argument("--learn-chain", metavar=tr("名字", "NAME"),
2402
+ help=tr(f"用给出的文件学习一条新的加工链指纹,不做常规检查。至少 {CHAIN_MIN_FILES} 个文件,"
2403
+ f"来自至少 {CHAIN_MIN_FOLDERS} 个不同文件夹(专辑),采样率必须相同(≥{khz(CHAIN_MIN_RATE)});"
2404
+ f"建议 10 个以上。默认保存到 {CHAIN_USER_DIR},可用 --out 指定文件",
2405
+ f"learn a new chain fingerprint from the given files instead of checking them. Needs at "
2406
+ f"least {CHAIN_MIN_FILES} files from at least {CHAIN_MIN_FOLDERS} different folders "
2407
+ f"(albums), all at one sample rate (>= {khz(CHAIN_MIN_RATE)}); 10 or more recommended. "
2408
+ f"Saved to {CHAIN_USER_DIR} unless --out names a file"))
2409
+ chain.add_argument("--chain-note", metavar=tr("说明", "TEXT"),
2410
+ help=tr("配合 --learn-chain:匹配时显示的说明文字", "with --learn-chain: text shown on a match"))
2411
+ chain.add_argument("--list-chains", action="store_true",
2412
+ help=tr("列出可用的加工链指纹", "list the available chain fingerprints"))
2413
+ parser.add_argument("--version", action="version", version=f"hirescheck {VERSION}")
2414
+ args = parser.parse_args(argv)
2415
+ if args.list_chains:
2416
+ return list_chains()
2417
+ if not args.paths:
2418
+ parser.error(tr("请给出要检查的文件或文件夹。", "give the files or folders to check"))
2419
+ for path in args.paths:
2420
+ if not path.exists():
2421
+ parser.error(tr(f"找不到文件或文件夹:{path}", f"no such file or folder: {path}"))
2422
+ if args.jobs < 1:
2423
+ parser.error(tr("-j 必须是正整数。", "-j must be a positive integer"))
2424
+ if args.learn_chain is not None:
2425
+ if not args.learn_chain.strip():
2426
+ parser.error(tr("--learn-chain 需要一个名字。", "--learn-chain needs a name"))
2427
+ return learn_chain(args.learn_chain.strip(), args.paths, args.out, args.chain_note,
2428
+ args.max_seconds, args.jobs)
2429
+
2430
+ if len(args.paths) == 1 and args.paths[0].is_file():
2431
+ path = args.paths[0]
2432
+ out = args.out or Path.cwd() / (path.stem + "_hirescheck")
2433
+ try:
2434
+ report, text = check_file(path, out, args.max_seconds, args.compare_seconds, args.no_plot,
2435
+ progress=lambda message: print(message, file=sys.stderr, flush=True),
2436
+ check_sr=args.check_sr)
2437
+ except (OSError, RuntimeError, ValueError) as exc:
2438
+ print(tr("错误:", "Error: ") + str(exc), file=sys.stderr)
2439
+ return 1
2440
+ print(text)
2441
+ print(tr("报告目录:", "Report folder: ") + str(out.resolve()))
2442
+ if not args.no_plot and not report["plot_generated"]:
2443
+ print(tr("未安装 matplotlib,已跳过 PNG;其余报告已生成。",
2444
+ "matplotlib is not installed, so no PNG; the rest of the report was written."), file=sys.stderr)
2445
+ return 0
2446
+
2447
+ if args.out:
2448
+ root = args.out
2449
+ elif len(args.paths) == 1:
2450
+ root = Path.cwd() / (args.paths[0].resolve().name + "_hirescheck")
2451
+ else:
2452
+ root = Path.cwd() / "hirescheck_reports"
2453
+ try:
2454
+ return run_batch(args.paths, root, args)
2455
+ except KeyboardInterrupt:
2456
+ print(tr("\n已中断;已完成的报告保留在各自目录中,未生成汇总。",
2457
+ "\nInterrupted; finished reports are kept in their folders, but no summary was written."), file=sys.stderr)
2458
+ return 130
2459
+
2460
+
2461
+ if __name__ == "__main__":
2462
+ raise SystemExit(main())