phasecheck 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Boobalan Arjunan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+ This project depends on third-party libraries (numpy, scipy, soundfile,
25
+ librosa, pyloudnorm, scikit-learn, tqdm, langdetect, matplotlib, and
26
+ optionally openai-whisper, torch/torchaudio). Each carries its own license
27
+ (mostly BSD/MIT/Apache-2.0) -- check requirements.txt and each package's own
28
+ license before redistributing, especially if your GTV program has its own
29
+ licensing rules.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: phasecheck
3
+ Version: 0.1.0
4
+ Summary: Find stereo audio files whose channels cancel each other when mixed down to mono (out-of-phase or inverted channels).
5
+ Author-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
6
+ Maintainer-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/BoobalanArjunan/Phasecheck
9
+ Project-URL: Issues, https://github.com/BoobalanArjunan/Phasecheck/issues
10
+ Project-URL: Source, https://github.com/BoobalanArjunan/Phasecheck
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy>=1.23
18
+ Requires-Dist: scipy>=1.9
19
+ Requires-Dist: soundfile>=0.12
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # phasecheck
25
+
26
+ Finds stereo files that lose level or sound thin when played in mono, usually because one channel is polarity-inverted
27
+ or partly out of phase with the other.
28
+
29
+ > **Status: 0.1.0.** Checked on real stereo music (MUSDB18-HQ) with planted problems and untouched originals; results below.
30
+ > The thresholds (6 dB and 10 dB) were set on generated audio and are unchanged. They are provisional.
31
+
32
+ ```bash
33
+ pip install -e ".[dev]"
34
+ phasecheck check my_songs/ --out report.csv
35
+ phasecheck check song.wav --json
36
+ ```
37
+
38
+ ## What it measures
39
+ Over the loud parts of the file (frames within 30 dB of the loudest, middle 2 minutes of long files):
40
+
41
+ - **Downmix loss (dB):** how much quieter the mono sum is than the average of the two channels. 0 dB means identical channels,
42
+ about 3 dB means unrelated channels, large values mean the channels cancel.
43
+ - **Correlation** between the channels (-1 to 1), and the loss in four bands (20-250 Hz, 250 Hz-2 kHz, 2-8 kHz, above 8 kHz;
44
+ a band counts only if it holds at least 1% of the energy).
45
+ - **Dual mono** (identical channels) and a **silent channel**.
46
+
47
+ | Verdict | When |
48
+ |---|---|
49
+ | FAIL | full-band loss of 10 dB or more |
50
+ | WARN | loss of 6 dB or more, or one band at 10 dB or more |
51
+ | REVIEW | one channel is silent, or too little audio to judge |
52
+ | SKIP | mono, or more than two channels |
53
+ | PASS | otherwise (identical channels are noted) |
54
+
55
+ Exit code 1 only when a FAIL exists; 2 for a bad path or settings.
56
+
57
+ ## Real-data results (MUSDB18-HQ, 200 files per run)
58
+ Two runs on different songs: `test` split first, then `train`. The plan for each was fixed and tagged before the run
59
+ (`eval-spec-pc-v1`, `eval-spec-pc-v2`; see `docs/`). **The first run failed its plan** (inverted-channel copies were expected
60
+ to reach FAIL in 99% of cases; they reached 54%). The criterion was wrong, because how much an inversion loses depends on how
61
+ correlated the channels were; the v2 plan corrected it and was run once on new songs, with no code or threshold change.
62
+
63
+ | `train` split (v2 run) | result |
64
+ |---|---|
65
+ | Untouched originals flagged WARN or FAIL | 1 of 200 (range 0-3%) |
66
+ | Right channel inverted, original correlation 0.5 or more: flagged WARN or FAIL | 168 of 169 = 99% (97-100%) |
67
+ | Same copies reaching FAIL (10 dB or more) | 128 of 169 = 76% |
68
+ | Same inversion, original correlation under 0.5 | 1 of 11 flagged: **not reliably detectable** |
69
+ | Right channel inverted, all copies | 169 of 180 = 94% flagged, 128 of 180 = 71% FAIL |
70
+ | Only full mixtures | 39 of 39 flagged, 28 of 39 FAIL |
71
+ | 2-8 kHz band inverted | 62 of 71 = 87% (78-93%) |
72
+ | Dual mono recognised | 180 of 180 |
73
+
74
+ On the first (`test`) run: 0 of 199 originals flagged; all-copies detection 180 of 194 = 93% flagged, 104 of 194 = 54% FAIL; band 31 of 38 = 82%.
75
+
76
+ The one flagged original (train: a vocals stem, correlation -0.79) measures 9.7 dB quieter in a mono mix (bass about 19.5 dB quieter); not yet checked by ear.
77
+
78
+ Ranges are 95% Wilson intervals. Stems and mixtures of the same song are not independent, so the real uncertainty is wider.
79
+ The planted copies are made by one method; real polarity mistakes may differ. Only MUSDB18-HQ was used.
80
+
81
+ ## Limits (please read)
82
+ - **One dataset.** Tested on MUSDB18-HQ only (western-style studio stems and mixtures). Field recordings, spaced microphones and other styles may behave differently. Wide stereo can have low
83
+ correlation without any mono problem; that is why the verdict uses the downmix loss, not the correlation.
84
+ - **Swapped left and right channels cannot be detected.** Nothing in the audio says which side is which.
85
+ - **A passage that cancels is averaged into the whole file.** A short out-of-phase section in a long song may not reach WARN.
86
+ - A flagged file is not proof of a mistake: some recordings really cancel in mono. Listen to the mono mix.
87
+ - Only the middle 2 minutes of a long file are judged.
88
+ - Inverted channels whose correlation was under about 0.5 are not reliably caught: the mono mix hardly changes.
89
+ - An inverted mixture usually shows as WARN, not FAIL (about 72% of inverted mixtures reached FAIL).
90
+ - Thresholds (6 / 10 dB) are provisional and were chosen on generated audio.
91
+
92
+ ## Tests
93
+ `pytest -q` (57 tests). The checker itself was also tested by planting 21 deliberate bugs in the code and confirming
94
+ the tests catch them (the two that survived the first round now have their own tests).
95
+
96
+ ## License
97
+
98
+ MIT. Copyright (c) 2026 Boobalan Arjunan.
@@ -0,0 +1,75 @@
1
+ # phasecheck
2
+
3
+ Finds stereo files that lose level or sound thin when played in mono, usually because one channel is polarity-inverted
4
+ or partly out of phase with the other.
5
+
6
+ > **Status: 0.1.0.** Checked on real stereo music (MUSDB18-HQ) with planted problems and untouched originals; results below.
7
+ > The thresholds (6 dB and 10 dB) were set on generated audio and are unchanged. They are provisional.
8
+
9
+ ```bash
10
+ pip install -e ".[dev]"
11
+ phasecheck check my_songs/ --out report.csv
12
+ phasecheck check song.wav --json
13
+ ```
14
+
15
+ ## What it measures
16
+ Over the loud parts of the file (frames within 30 dB of the loudest, middle 2 minutes of long files):
17
+
18
+ - **Downmix loss (dB):** how much quieter the mono sum is than the average of the two channels. 0 dB means identical channels,
19
+ about 3 dB means unrelated channels, large values mean the channels cancel.
20
+ - **Correlation** between the channels (-1 to 1), and the loss in four bands (20-250 Hz, 250 Hz-2 kHz, 2-8 kHz, above 8 kHz;
21
+ a band counts only if it holds at least 1% of the energy).
22
+ - **Dual mono** (identical channels) and a **silent channel**.
23
+
24
+ | Verdict | When |
25
+ |---|---|
26
+ | FAIL | full-band loss of 10 dB or more |
27
+ | WARN | loss of 6 dB or more, or one band at 10 dB or more |
28
+ | REVIEW | one channel is silent, or too little audio to judge |
29
+ | SKIP | mono, or more than two channels |
30
+ | PASS | otherwise (identical channels are noted) |
31
+
32
+ Exit code 1 only when a FAIL exists; 2 for a bad path or settings.
33
+
34
+ ## Real-data results (MUSDB18-HQ, 200 files per run)
35
+ Two runs on different songs: `test` split first, then `train`. The plan for each was fixed and tagged before the run
36
+ (`eval-spec-pc-v1`, `eval-spec-pc-v2`; see `docs/`). **The first run failed its plan** (inverted-channel copies were expected
37
+ to reach FAIL in 99% of cases; they reached 54%). The criterion was wrong, because how much an inversion loses depends on how
38
+ correlated the channels were; the v2 plan corrected it and was run once on new songs, with no code or threshold change.
39
+
40
+ | `train` split (v2 run) | result |
41
+ |---|---|
42
+ | Untouched originals flagged WARN or FAIL | 1 of 200 (range 0-3%) |
43
+ | Right channel inverted, original correlation 0.5 or more: flagged WARN or FAIL | 168 of 169 = 99% (97-100%) |
44
+ | Same copies reaching FAIL (10 dB or more) | 128 of 169 = 76% |
45
+ | Same inversion, original correlation under 0.5 | 1 of 11 flagged: **not reliably detectable** |
46
+ | Right channel inverted, all copies | 169 of 180 = 94% flagged, 128 of 180 = 71% FAIL |
47
+ | Only full mixtures | 39 of 39 flagged, 28 of 39 FAIL |
48
+ | 2-8 kHz band inverted | 62 of 71 = 87% (78-93%) |
49
+ | Dual mono recognised | 180 of 180 |
50
+
51
+ On the first (`test`) run: 0 of 199 originals flagged; all-copies detection 180 of 194 = 93% flagged, 104 of 194 = 54% FAIL; band 31 of 38 = 82%.
52
+
53
+ The one flagged original (train: a vocals stem, correlation -0.79) measures 9.7 dB quieter in a mono mix (bass about 19.5 dB quieter); not yet checked by ear.
54
+
55
+ Ranges are 95% Wilson intervals. Stems and mixtures of the same song are not independent, so the real uncertainty is wider.
56
+ The planted copies are made by one method; real polarity mistakes may differ. Only MUSDB18-HQ was used.
57
+
58
+ ## Limits (please read)
59
+ - **One dataset.** Tested on MUSDB18-HQ only (western-style studio stems and mixtures). Field recordings, spaced microphones and other styles may behave differently. Wide stereo can have low
60
+ correlation without any mono problem; that is why the verdict uses the downmix loss, not the correlation.
61
+ - **Swapped left and right channels cannot be detected.** Nothing in the audio says which side is which.
62
+ - **A passage that cancels is averaged into the whole file.** A short out-of-phase section in a long song may not reach WARN.
63
+ - A flagged file is not proof of a mistake: some recordings really cancel in mono. Listen to the mono mix.
64
+ - Only the middle 2 minutes of a long file are judged.
65
+ - Inverted channels whose correlation was under about 0.5 are not reliably caught: the mono mix hardly changes.
66
+ - An inverted mixture usually shows as WARN, not FAIL (about 72% of inverted mixtures reached FAIL).
67
+ - Thresholds (6 / 10 dB) are provisional and were chosen on generated audio.
68
+
69
+ ## Tests
70
+ `pytest -q` (57 tests). The checker itself was also tested by planting 21 deliberate bugs in the code and confirming
71
+ the tests catch them (the two that survived the first round now have their own tests).
72
+
73
+ ## License
74
+
75
+ MIT. Copyright (c) 2026 Boobalan Arjunan.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "phasecheck"
7
+ dynamic = ["version"]
8
+ description = "Find stereo audio files whose channels cancel each other when mixed down to mono (out-of-phase or inverted channels)."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Boobalan Arjunan", email = "boobalan_arjunan@composermind.com" },
14
+ ]
15
+ maintainers = [
16
+ { name = "Boobalan Arjunan", email = "boobalan_arjunan@composermind.com" },
17
+ ]
18
+ dependencies = ["numpy>=1.23", "scipy>=1.9", "soundfile>=0.12"]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Topic :: Multimedia :: Sound/Audio :: Analysis",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/BoobalanArjunan/Phasecheck"
27
+ Issues = "https://github.com/BoobalanArjunan/Phasecheck/issues"
28
+ Source = "https://github.com/BoobalanArjunan/Phasecheck"
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest"]
32
+
33
+ [project.scripts]
34
+ phasecheck = "phasecheck.cli:main"
35
+
36
+ [tool.setuptools.dynamic]
37
+ version = { attr = "phasecheck._version.__version__" }
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """phasecheck: do the two channels of a stereo file cancel each other when mixed down to mono?"""
2
+ from ._version import __version__
3
+ from .check import Verdict, check_file, judge
4
+
5
+ __all__ = ["__version__", "Verdict", "check_file", "judge"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,106 @@
1
+ """Stereo audio -> how much of it survives a mono downmix.
2
+
3
+ Mixing a stereo file to mono (the average of the two channels) keeps sound that is the same in both channels and
4
+ cancels sound that is opposite in the two channels. A file whose channels are partly or wholly out of phase can lose
5
+ level or vanish when a training pipeline converts it to mono. phasecheck measures that loss.
6
+
7
+ downmix_loss_db: 0 dB when the channels are identical (nothing is lost), about 3 dB when they are unrelated (the normal
8
+ case for wide stereo), and large when they are in opposite phase (the mono mix is much quieter than the channels).
9
+ Light on purpose: numpy, scipy, soundfile only.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Dict, List, Optional
15
+
16
+ import numpy as np
17
+ import soundfile as sf
18
+ from scipy.signal import stft
19
+
20
+ MAX_SECONDS = 120.0 # long files: only the middle 2 minutes are used
21
+ MIN_SECONDS = 2.0
22
+ ACTIVE_FLOOR_DB = -30.0 # frames more than 30 dB below the loudest frame are ignored
23
+ MIN_ACTIVE_FRAMES = 4
24
+ NPERSEG = 2048
25
+ BANDS_HZ = ((20.0, 250.0), (250.0, 2000.0), (2000.0, 8000.0), (8000.0, 1e9))
26
+ BAND_NAMES = ("20-250 Hz", "250 Hz-2 kHz", "2-8 kHz", "above 8 kHz")
27
+ MIN_BAND_SHARE_DB = -20.0 # a band counts only if it holds at least 1% of the energy
28
+ SILENT_CHANNEL_DB = -60.0 # a channel this far below the other is treated as silent (wavewash finds dead channels)
29
+ LOSS_CAP_DB = 60.0
30
+ IDENTICAL_DB = 0.2 # channels are "identical" if the mono mix loses less than this and their correlation is above 0.9999
31
+
32
+
33
+ @dataclass
34
+ class PhaseResult:
35
+ sample_rate: int
36
+ seconds: float
37
+ active_seconds: float
38
+ loss_db: float # full-band downmix loss
39
+ correlation: float # full-band, -1 (opposite) .. +1 (identical)
40
+ band_loss_db: Dict[str, Optional[float]] = field(default_factory=dict) # None for a band that is empty or holds too little energy
41
+ dual_mono: bool = False # both channels identical (mono stored as stereo)
42
+ silent_channel: Optional[int] = None # 0 or 1 if one channel is silent next to the other
43
+
44
+
45
+ def load_stereo(path, max_seconds: float = MAX_SECONDS):
46
+ """Reads the file at its own sample rate -> (array of shape (samples, channels), sample rate). Long files: the middle part."""
47
+ x, sr = sf.read(str(path), dtype="float64", always_2d=True)
48
+ n = int(max_seconds * sr)
49
+ if len(x) > n:
50
+ s = (len(x) - n) // 2
51
+ x = x[s:s + n]
52
+ return x, int(sr)
53
+
54
+
55
+ def _loss_db(p_mono: float, p_l: float, p_r: float) -> float:
56
+ mean_ch = (p_l + p_r) / 2
57
+ if mean_ch <= 0:
58
+ return 0.0
59
+ return float(min(LOSS_CAP_DB, -10 * np.log10(max(p_mono, mean_ch * 10 ** (-LOSS_CAP_DB / 10)) / mean_ch)))
60
+
61
+
62
+ def analyse(left: np.ndarray, right: np.ndarray, sr: int) -> Optional[PhaseResult]:
63
+ """Downmix loss of a stereo pair, measured over its loud parts. None if too short, silent or too few loud frames."""
64
+ n = min(len(left), len(right))
65
+ if n < MIN_SECONDS * sr:
66
+ return None
67
+ left, right = left[:n], right[:n]
68
+ if not (np.any(np.abs(left) > 1e-9) or np.any(np.abs(right) > 1e-9)):
69
+ return None
70
+ nper = min(NPERSEG, 2 ** int(np.log2(max(n // 4, 256))))
71
+ f, _, ZL = stft(left, fs=sr, nperseg=nper, noverlap=nper // 2, boundary=None, padded=False)
72
+ _, _, ZR = stft(right, fs=sr, nperseg=nper, noverlap=nper // 2, boundary=None, padded=False)
73
+ PL, PR = np.abs(ZL) ** 2, np.abs(ZR) ** 2
74
+ PM = np.abs(ZL + ZR) ** 2 / 4
75
+ cross = np.real(ZL * np.conj(ZR))
76
+ energy = PL.sum(axis=0) + PR.sum(axis=0)
77
+ if energy.max() <= 0:
78
+ return None
79
+ active = energy > energy.max() * 10 ** (ACTIVE_FLOOR_DB / 10)
80
+ if active.sum() < MIN_ACTIVE_FRAMES:
81
+ return None
82
+ PL, PR, PM, cross = PL[:, active], PR[:, active], PM[:, active], cross[:, active]
83
+ hop = nper // 2
84
+ tl, tr, tm = PL.sum(), PR.sum(), PM.sum()
85
+ silent = None
86
+ if tl > 0 and 10 * np.log10(max(tr, 1e-30) / tl) < SILENT_CHANNEL_DB:
87
+ silent = 1
88
+ elif tr > 0 and 10 * np.log10(max(tl, 1e-30) / tr) < SILENT_CHANNEL_DB:
89
+ silent = 0
90
+ denom = np.sqrt(tl * tr)
91
+ corr = float(cross.sum() / denom) if denom > 0 else 0.0
92
+ loss = _loss_db(tm, tl, tr)
93
+ total = tl + tr
94
+ bands: Dict[str, Optional[float]] = {}
95
+ for (lo, hi), name in zip(BANDS_HZ, BAND_NAMES):
96
+ sel = (f >= lo) & (f < min(hi, sr / 2 + 1))
97
+ if not sel.any():
98
+ bands[name] = None
99
+ continue
100
+ bl, br, bm = PL[sel].sum(), PR[sel].sum(), PM[sel].sum()
101
+ if total <= 0 or 10 * np.log10(max(bl + br, 1e-30) / total) < MIN_BAND_SHARE_DB:
102
+ bands[name] = None
103
+ continue
104
+ bands[name] = _loss_db(bm, bl, br)
105
+ dual = loss < IDENTICAL_DB and corr > 0.9999 and abs(10 * np.log10(max(tl, 1e-30) / max(tr, 1e-30))) < 0.5
106
+ return PhaseResult(sr, n / sr, float(active.sum() * hop / sr), loss, float(np.clip(corr, -1, 1)), bands, bool(dual), silent)
@@ -0,0 +1,71 @@
1
+ """Turn a downmix measurement into a verdict."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional
7
+
8
+ from .analysis import PhaseResult, analyse, load_stereo
9
+
10
+ WARN_DB = 6.0 # full-band downmix loss at or above this: WARN (provisional; not yet tuned on real recordings)
11
+ FAIL_DB = 10.0 # at or above this: FAIL
12
+ BAND_FAIL_DB = 10.0 # one frequency band (holding at least 1% of the energy) at or above this: WARN
13
+
14
+ OK, LIMITED, FAKE, UNSURE, SKIPPED, ERROR = "OK", "LIMITED", "FAKE", "UNSURE", "SKIPPED", "ERROR"
15
+
16
+
17
+ @dataclass
18
+ class Verdict:
19
+ file: str
20
+ sample_rate: Optional[int]
21
+ channels: int
22
+ status: str
23
+ reason: str
24
+ loss_db: Optional[float] = None
25
+ correlation: Optional[float] = None
26
+ worst_band: str = ""
27
+ worst_band_loss_db: Optional[float] = None
28
+ dual_mono: bool = False
29
+ seconds: float = 0.0
30
+
31
+ @property
32
+ def level(self) -> str:
33
+ return {OK: "PASS", LIMITED: "WARN", UNSURE: "REVIEW", FAKE: "FAIL", SKIPPED: "SKIP", ERROR: "ERROR"}[self.status]
34
+
35
+
36
+ def judge(res: Optional[PhaseResult], file: str = "", sample_rate: Optional[int] = None, channels: int = 2,
37
+ warn_db: float = WARN_DB, fail_db: float = FAIL_DB) -> Verdict:
38
+ if res is None:
39
+ return Verdict(file, sample_rate, channels, UNSURE, "too short, silent or too few loud parts to measure")
40
+ base = dict(file=file, sample_rate=res.sample_rate, channels=channels, loss_db=round(res.loss_db, 1),
41
+ correlation=round(res.correlation, 3), seconds=res.active_seconds)
42
+ present = {k: v for k, v in res.band_loss_db.items() if v is not None}
43
+ wb, wl = max(present.items(), key=lambda kv: kv[1]) if present else ("", None)
44
+ base.update(worst_band=wb, worst_band_loss_db=None if wl is None else round(wl, 1), dual_mono=res.dual_mono)
45
+ if res.silent_channel is not None:
46
+ side = "left" if res.silent_channel == 0 else "right"
47
+ return Verdict(status=UNSURE, reason=f"the {side} channel is silent next to the other, so there is nothing to cancel (a dead channel is wavewash's job)", **base)
48
+ if res.dual_mono:
49
+ return Verdict(status=OK, reason="both channels are identical (mono stored as stereo); the mono mix loses nothing", **base)
50
+ if res.loss_db >= fail_db:
51
+ why = " The channels are close to opposite in phase." if res.correlation <= -0.5 else ""
52
+ return Verdict(status=FAKE, reason=f"a mono mix is {res.loss_db:.1f} dB quieter than the channels (correlation {res.correlation:.2f}).{why}", **base)
53
+ if res.loss_db >= warn_db:
54
+ return Verdict(status=LIMITED, reason=f"a mono mix is {res.loss_db:.1f} dB quieter than the channels (correlation {res.correlation:.2f}); partly out of phase", **base)
55
+ if wl is not None and wl >= BAND_FAIL_DB:
56
+ return Verdict(status=LIMITED, reason=f"the {wb} band cancels in a mono mix ({wl:.1f} dB loss) although the whole file does not", **base)
57
+ return Verdict(status=OK, reason=f"a mono mix loses {res.loss_db:.1f} dB (correlation {res.correlation:.2f}); nothing cancels", **base)
58
+
59
+
60
+ def check_file(path, warn_db: float = WARN_DB, fail_db: float = FAIL_DB) -> Verdict:
61
+ p = Path(path)
62
+ try:
63
+ x, sr = load_stereo(p)
64
+ except Exception as e: # unreadable audio
65
+ return Verdict(p.name, None, 0, ERROR, f"could not read audio: {e}")
66
+ ch = x.shape[1]
67
+ if ch == 1:
68
+ return Verdict(p.name, sr, 1, SKIPPED, "mono file; nothing to check")
69
+ if ch > 2:
70
+ return Verdict(p.name, sr, ch, SKIPPED, f"{ch} channels; only two-channel files are checked")
71
+ return judge(analyse(x[:, 0], x[:, 1], sr), p.name, sr, 2, warn_db, fail_db)
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from concurrent.futures import ProcessPoolExecutor
6
+ from functools import partial
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .check import FAIL_DB, WARN_DB, check_file
11
+ from .report import text, to_json, write_csv
12
+
13
+ AUDIO_EXT = {".wav", ".flac", ".mp3", ".ogg", ".m4a", ".aiff", ".aif"}
14
+
15
+
16
+ def find_audio(source: Path):
17
+ if source.is_file():
18
+ return [source]
19
+ return sorted(p for p in source.rglob("*") if p.suffix.lower() in AUDIO_EXT and not p.name.startswith("._") and "__MACOSX" not in p.parts)
20
+
21
+
22
+ def main(argv=None) -> int:
23
+ ap = argparse.ArgumentParser(prog="phasecheck", description="Find stereo audio files whose channels cancel each other in a mono downmix.")
24
+ ap.add_argument("--version", action="version", version=f"phasecheck {__version__}")
25
+ sub = ap.add_subparsers(dest="cmd", required=True)
26
+ c = sub.add_parser("check", help="check one file or every audio file in a folder")
27
+ c.add_argument("source", type=Path)
28
+ c.add_argument("--warn-db", type=float, default=WARN_DB, help=f"downmix loss that earns a WARN (default {WARN_DB:g} dB)")
29
+ c.add_argument("--fail-db", type=float, default=FAIL_DB, help=f"downmix loss that earns a FAIL (default {FAIL_DB:g} dB)")
30
+ c.add_argument("--out", type=Path, help="write every verdict to this CSV")
31
+ c.add_argument("--json", action="store_true", help="print the full report as JSON (instead of text)")
32
+ c.add_argument("--show-pass", action="store_true", help="also list files that passed or were skipped")
33
+ c.add_argument("--jobs", type=int, default=1, help="files analysed in parallel (default 1)")
34
+ a = ap.parse_args(argv)
35
+
36
+ if not a.source.exists():
37
+ print(f"error: {a.source} not found", file=sys.stderr)
38
+ return 2
39
+ if a.warn_db > a.fail_db:
40
+ print("error: --warn-db must not be larger than --fail-db", file=sys.stderr)
41
+ return 2
42
+ files = find_audio(a.source)
43
+ if not files:
44
+ print("error: no audio files found", file=sys.stderr)
45
+ return 2
46
+ job = partial(check_file, warn_db=a.warn_db, fail_db=a.fail_db)
47
+ if a.jobs > 1:
48
+ with ProcessPoolExecutor(a.jobs) as ex:
49
+ vs = list(ex.map(job, files, chunksize=1))
50
+ else:
51
+ vs = [job(f) for f in files]
52
+ print(to_json(vs) if a.json else text(vs, a.show_pass))
53
+ if a.out:
54
+ write_csv(a.out, vs)
55
+ return 1 if any(v.level == "FAIL" for v in vs) else 0
56
+
57
+
58
+ if __name__ == "__main__":
59
+ sys.exit(main())
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ from collections import Counter
6
+ from typing import List
7
+
8
+ from . import __version__
9
+ from .check import Verdict
10
+
11
+ EVIDENCE = ("alpha. Tested on generated audio only; not yet measured on real recordings. The 6 dB and 10 dB limits are provisional. "
12
+ "See the README.")
13
+ LEVELS = ("PASS", "WARN", "FAIL", "REVIEW", "SKIP", "ERROR")
14
+
15
+
16
+ def summary_line(vs: List[Verdict]) -> str:
17
+ c = Counter(v.level for v in vs)
18
+ return (f"{len(vs)} files: {c['PASS']} pass, {c['WARN']} warn (partly out of phase), {c['FAIL']} fail (cancel in a mono mix), "
19
+ f"{c['REVIEW']} need a look, {c['SKIP']} skipped (not two-channel), {c['ERROR']} unreadable")
20
+
21
+
22
+ def text(vs: List[Verdict], show_pass: bool = False) -> str:
23
+ L = []
24
+ for v in vs:
25
+ if v.level in ("PASS", "SKIP") and not show_pass:
26
+ continue
27
+ L.append(f"{v.level:6} {v.file} {v.sample_rate or '-'} Hz {v.reason}")
28
+ return "\n".join(L + ([""] if L else []) + [summary_line(vs)])
29
+
30
+
31
+ def _row(v: Verdict) -> dict:
32
+ return {"file": v.file, "sample_rate": v.sample_rate, "channels": v.channels, "verdict": v.level, "reason": v.reason,
33
+ "downmix_loss_db": v.loss_db, "correlation": v.correlation, "worst_band": v.worst_band,
34
+ "worst_band_loss_db": v.worst_band_loss_db, "dual_mono": v.dual_mono, "seconds_analysed": round(v.seconds, 1)}
35
+
36
+
37
+ def write_csv(path, vs: List[Verdict]) -> None:
38
+ rows = [_row(v) for v in vs]
39
+ cols = list(_row(Verdict("", None, 0, "OK", "")).keys())
40
+ with open(path, "w", newline="", encoding="utf-8") as f:
41
+ w = csv.writer(f)
42
+ w.writerow(cols)
43
+ for r in rows:
44
+ w.writerow(["" if r[c] is None else r[c] for c in cols])
45
+
46
+
47
+ def to_dict(vs: List[Verdict]) -> dict:
48
+ c = Counter(v.level for v in vs)
49
+ return {"tool": "phasecheck", "version": __version__, "evidence": EVIDENCE,
50
+ "summary": {"files": len(vs), **{k: c[k] for k in LEVELS}}, "files": [_row(v) for v in vs]}
51
+
52
+
53
+ def to_json(vs: List[Verdict]) -> str:
54
+ return json.dumps(to_dict(vs), indent=2)
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: phasecheck
3
+ Version: 0.1.0
4
+ Summary: Find stereo audio files whose channels cancel each other when mixed down to mono (out-of-phase or inverted channels).
5
+ Author-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
6
+ Maintainer-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/BoobalanArjunan/Phasecheck
9
+ Project-URL: Issues, https://github.com/BoobalanArjunan/Phasecheck/issues
10
+ Project-URL: Source, https://github.com/BoobalanArjunan/Phasecheck
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy>=1.23
18
+ Requires-Dist: scipy>=1.9
19
+ Requires-Dist: soundfile>=0.12
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # phasecheck
25
+
26
+ Finds stereo files that lose level or sound thin when played in mono, usually because one channel is polarity-inverted
27
+ or partly out of phase with the other.
28
+
29
+ > **Status: 0.1.0.** Checked on real stereo music (MUSDB18-HQ) with planted problems and untouched originals; results below.
30
+ > The thresholds (6 dB and 10 dB) were set on generated audio and are unchanged. They are provisional.
31
+
32
+ ```bash
33
+ pip install -e ".[dev]"
34
+ phasecheck check my_songs/ --out report.csv
35
+ phasecheck check song.wav --json
36
+ ```
37
+
38
+ ## What it measures
39
+ Over the loud parts of the file (frames within 30 dB of the loudest, middle 2 minutes of long files):
40
+
41
+ - **Downmix loss (dB):** how much quieter the mono sum is than the average of the two channels. 0 dB means identical channels,
42
+ about 3 dB means unrelated channels, large values mean the channels cancel.
43
+ - **Correlation** between the channels (-1 to 1), and the loss in four bands (20-250 Hz, 250 Hz-2 kHz, 2-8 kHz, above 8 kHz;
44
+ a band counts only if it holds at least 1% of the energy).
45
+ - **Dual mono** (identical channels) and a **silent channel**.
46
+
47
+ | Verdict | When |
48
+ |---|---|
49
+ | FAIL | full-band loss of 10 dB or more |
50
+ | WARN | loss of 6 dB or more, or one band at 10 dB or more |
51
+ | REVIEW | one channel is silent, or too little audio to judge |
52
+ | SKIP | mono, or more than two channels |
53
+ | PASS | otherwise (identical channels are noted) |
54
+
55
+ Exit code 1 only when a FAIL exists; 2 for a bad path or settings.
56
+
57
+ ## Real-data results (MUSDB18-HQ, 200 files per run)
58
+ Two runs on different songs: `test` split first, then `train`. The plan for each was fixed and tagged before the run
59
+ (`eval-spec-pc-v1`, `eval-spec-pc-v2`; see `docs/`). **The first run failed its plan** (inverted-channel copies were expected
60
+ to reach FAIL in 99% of cases; they reached 54%). The criterion was wrong, because how much an inversion loses depends on how
61
+ correlated the channels were; the v2 plan corrected it and was run once on new songs, with no code or threshold change.
62
+
63
+ | `train` split (v2 run) | result |
64
+ |---|---|
65
+ | Untouched originals flagged WARN or FAIL | 1 of 200 (range 0-3%) |
66
+ | Right channel inverted, original correlation 0.5 or more: flagged WARN or FAIL | 168 of 169 = 99% (97-100%) |
67
+ | Same copies reaching FAIL (10 dB or more) | 128 of 169 = 76% |
68
+ | Same inversion, original correlation under 0.5 | 1 of 11 flagged: **not reliably detectable** |
69
+ | Right channel inverted, all copies | 169 of 180 = 94% flagged, 128 of 180 = 71% FAIL |
70
+ | Only full mixtures | 39 of 39 flagged, 28 of 39 FAIL |
71
+ | 2-8 kHz band inverted | 62 of 71 = 87% (78-93%) |
72
+ | Dual mono recognised | 180 of 180 |
73
+
74
+ On the first (`test`) run: 0 of 199 originals flagged; all-copies detection 180 of 194 = 93% flagged, 104 of 194 = 54% FAIL; band 31 of 38 = 82%.
75
+
76
+ The one flagged original (train: a vocals stem, correlation -0.79) measures 9.7 dB quieter in a mono mix (bass about 19.5 dB quieter); not yet checked by ear.
77
+
78
+ Ranges are 95% Wilson intervals. Stems and mixtures of the same song are not independent, so the real uncertainty is wider.
79
+ The planted copies are made by one method; real polarity mistakes may differ. Only MUSDB18-HQ was used.
80
+
81
+ ## Limits (please read)
82
+ - **One dataset.** Tested on MUSDB18-HQ only (western-style studio stems and mixtures). Field recordings, spaced microphones and other styles may behave differently. Wide stereo can have low
83
+ correlation without any mono problem; that is why the verdict uses the downmix loss, not the correlation.
84
+ - **Swapped left and right channels cannot be detected.** Nothing in the audio says which side is which.
85
+ - **A passage that cancels is averaged into the whole file.** A short out-of-phase section in a long song may not reach WARN.
86
+ - A flagged file is not proof of a mistake: some recordings really cancel in mono. Listen to the mono mix.
87
+ - Only the middle 2 minutes of a long file are judged.
88
+ - Inverted channels whose correlation was under about 0.5 are not reliably caught: the mono mix hardly changes.
89
+ - An inverted mixture usually shows as WARN, not FAIL (about 72% of inverted mixtures reached FAIL).
90
+ - Thresholds (6 / 10 dB) are provisional and were chosen on generated audio.
91
+
92
+ ## Tests
93
+ `pytest -q` (57 tests). The checker itself was also tested by planting 21 deliberate bugs in the code and confirming
94
+ the tests catch them (the two that survived the first round now have their own tests).
95
+
96
+ ## License
97
+
98
+ MIT. Copyright (c) 2026 Boobalan Arjunan.
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/phasecheck/__init__.py
5
+ src/phasecheck/_version.py
6
+ src/phasecheck/analysis.py
7
+ src/phasecheck/check.py
8
+ src/phasecheck/cli.py
9
+ src/phasecheck/report.py
10
+ src/phasecheck.egg-info/PKG-INFO
11
+ src/phasecheck.egg-info/SOURCES.txt
12
+ src/phasecheck.egg-info/dependency_links.txt
13
+ src/phasecheck.egg-info/entry_points.txt
14
+ src/phasecheck.egg-info/requires.txt
15
+ src/phasecheck.egg-info/top_level.txt
16
+ tests/test_phasecheck.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ phasecheck = phasecheck.cli:main
@@ -0,0 +1,6 @@
1
+ numpy>=1.23
2
+ scipy>=1.9
3
+ soundfile>=0.12
4
+
5
+ [dev]
6
+ pytest
@@ -0,0 +1 @@
1
+ phasecheck
@@ -0,0 +1,402 @@
1
+ import csv
2
+ import json
3
+
4
+ import numpy as np
5
+ import pytest
6
+ import soundfile as sf
7
+
8
+ import phasecheck.check as chk
9
+ import synth
10
+ from conftest import pair, sound, write
11
+ from phasecheck.analysis import LOSS_CAP_DB, PhaseResult, analyse, load_stereo
12
+ from phasecheck.check import FAKE, LIMITED, OK, SKIPPED, UNSURE, Verdict, check_file, judge
13
+ from phasecheck.cli import find_audio, main
14
+ from phasecheck.report import summary_line, text, to_dict, to_json, write_csv
15
+
16
+ SR = 44100
17
+
18
+
19
+ def res(left, right, sr=SR):
20
+ return analyse(synth.to_pcm16(left).astype(float) / 32767, synth.to_pcm16(right).astype(float) / 32767, sr)
21
+
22
+
23
+ def rot(degrees, sr=SR, seed=0):
24
+ """The sound and the same sound shifted by a fixed phase angle (see synth.rotate)."""
25
+ x = sound(sr, seed)
26
+ return x, synth.rotate(x, degrees)
27
+
28
+
29
+ def expected_loss(degrees):
30
+ return -10 * np.log10((1 + np.cos(np.deg2rad(degrees))) / 2)
31
+
32
+
33
+ # ---------------------------------------------------------------- the basic relationships
34
+ def test_identical_channels_lose_nothing_and_are_dual_mono():
35
+ x = sound(SR)
36
+ r = res(x, x)
37
+ assert r.loss_db < 0.2 and r.correlation > 0.999 and r.dual_mono
38
+ v = judge(r, "a.wav", SR)
39
+ assert v.status == OK and v.dual_mono and "mono stored as stereo" in v.reason
40
+
41
+
42
+ def test_channels_a_quarter_turn_apart_lose_3_db_and_pass():
43
+ r = res(*rot(90))
44
+ assert abs(r.loss_db - 3.01) < 0.3 and abs(r.correlation) < 0.05 and not r.dual_mono
45
+ assert judge(r, "a.wav", SR).status == OK
46
+
47
+
48
+ def test_independent_noise_channels_pass():
49
+ """Real stereo is somewhere between identical and unrelated; unrelated channels lose about 2-4 dB."""
50
+ r = res(sound(SR, 0), sound(SR, 1))
51
+ assert 1.0 < r.loss_db < 5.0 and not r.dual_mono
52
+ assert judge(r, "a.wav", SR).status == OK
53
+
54
+
55
+ def test_inverted_channels_fail_with_a_large_loss():
56
+ x = sound(SR)
57
+ r = res(x, -x)
58
+ assert r.loss_db >= 40 and r.correlation < -0.99
59
+ v = judge(r, "a.wav", SR)
60
+ assert v.status == FAKE and v.level == "FAIL" and "opposite in phase" in v.reason
61
+
62
+
63
+ def test_the_loss_is_capped():
64
+ x = sound(SR)
65
+ assert res(x, -x).loss_db <= LOSS_CAP_DB
66
+
67
+
68
+ def test_a_quieter_right_channel_in_phase_is_fine_and_not_dual_mono():
69
+ x = sound(SR)
70
+ r = res(x, 0.5 * x)
71
+ assert r.loss_db < 1.0 and r.correlation > 0.999 and not r.dual_mono
72
+ assert judge(r, "a.wav", SR).status == OK
73
+
74
+
75
+ def test_partly_out_of_phase_channels_warn():
76
+ r = res(*rot(130)) # 7.5 dB
77
+ assert abs(r.loss_db - expected_loss(130)) < 0.4 and 6 <= r.loss_db < 10
78
+ v = judge(r, "a.wav", SR)
79
+ assert v.status == LIMITED and v.level == "WARN" and "partly out of phase" in v.reason
80
+
81
+
82
+ def test_strongly_out_of_phase_channels_fail():
83
+ r = res(*rot(160)) # 15 dB
84
+ assert abs(r.loss_db - expected_loss(160)) < 1.0 and r.loss_db >= 10
85
+ assert judge(r, "a.wav", SR).status == FAKE
86
+
87
+
88
+ def test_correlation_sign_follows_the_phase():
89
+ x = sound(SR)
90
+ assert res(x, x).correlation > 0.99 and res(x, -x).correlation < -0.99
91
+ assert abs(res(*rot(90)).correlation) < 0.05
92
+ assert res(*rot(60)).correlation > 0.4 and res(*rot(120)).correlation < -0.4
93
+
94
+
95
+ # ---------------------------------------------------------------- one band out of phase
96
+ def band_flipped(lo, hi, sr=SR):
97
+ x = sound(sr)
98
+ return x, x - 2 * synth.band(x, sr, lo, hi)
99
+
100
+
101
+ def test_a_band_inverted_in_one_channel_is_found_by_its_band():
102
+ x, y = band_flipped(2000, 8000)
103
+ r = res(x, y)
104
+ assert r.band_loss_db["2-8 kHz"] is not None and r.band_loss_db["2-8 kHz"] >= 10
105
+ v = judge(r, "a.wav", SR)
106
+ assert v.status in (LIMITED, FAKE) and v.worst_band == "2-8 kHz"
107
+
108
+
109
+ def test_a_band_holding_almost_no_energy_is_ignored():
110
+ """The sound ends at 5 kHz; a trace of sound above 8 kHz (under 1% of the energy) is inverted in one channel."""
111
+ low = synth.band(sound(SR, 0), SR, 100, 5000)
112
+ trace = 0.01 * synth.band(sound(SR, 1), SR, 9000, 15000)
113
+ r = res(low + trace, low - trace)
114
+ assert r.band_loss_db["above 8 kHz"] is None
115
+ assert judge(r, "a.wav", SR).status == OK
116
+
117
+
118
+ def test_the_same_trace_counts_when_it_holds_enough_energy():
119
+ low = synth.band(sound(SR, 0), SR, 100, 5000)
120
+ trace = 0.5 * synth.band(sound(SR, 1), SR, 9000, 15000)
121
+ r = res(low + trace, low - trace)
122
+ assert r.band_loss_db["above 8 kHz"] is not None and r.band_loss_db["above 8 kHz"] >= 10
123
+ assert judge(r, "a.wav", SR).status == LIMITED
124
+
125
+
126
+ def test_band_rule_warns_when_the_whole_file_does_not_cancel():
127
+ x, y = band_flipped(2000, 8000)
128
+ r = res(x, y)
129
+ assert r.loss_db < chk.WARN_DB # the file as a whole is fine
130
+ v = judge(r, "a.wav", SR)
131
+ assert v.status == LIMITED and "band cancels" in v.reason
132
+
133
+
134
+ def test_bands_that_do_not_exist_at_a_low_sample_rate_are_none():
135
+ r = res(sound(16000), sound(16000, 1), 16000)
136
+ assert r.band_loss_db["above 8 kHz"] is None
137
+
138
+
139
+ # ---------------------------------------------------------------- only the loud parts count
140
+ def test_a_quiet_inverted_passage_is_ignored():
141
+ x = sound(SR, 0, 8.0)
142
+ half = len(x) // 2
143
+ left = x
144
+ right = np.concatenate([x[:half], -x[half:] * 0.003]) # second half inverted but about 50 dB quieter
145
+ left = np.concatenate([x[:half], x[half:] * 0.003])
146
+ r = res(left, right)
147
+ assert r.loss_db < 1.0
148
+ assert judge(r, "a.wav", SR).status == OK
149
+
150
+
151
+ def test_a_loud_inverted_passage_counts_but_is_averaged_into_the_file():
152
+ """Known limit: a passage that cancels completely only costs the file as a whole its share of the energy, so it can pass."""
153
+ x = sound(SR, 0, 8.0)
154
+ half = len(x) // 2
155
+ right = np.concatenate([x[:half], -x[half:]])
156
+ r = res(x, right)
157
+ kept = float(np.sum(x[:half] ** 2) / np.sum(x ** 2)) # share of the energy that stays in phase
158
+ assert abs(r.loss_db - (-10 * np.log10(kept))) < 0.4
159
+ assert 1.0 < r.loss_db < 6.0
160
+ assert judge(r, "a.wav", SR).status == OK
161
+
162
+
163
+ def test_a_long_file_is_judged_by_its_middle(tmp_path):
164
+ x = sound(SR, 0, 12.0)
165
+ third = len(x) // 3
166
+ right = np.concatenate([-x[:third], x[third:2 * third], -x[2 * third:]])
167
+ p = write(tmp_path / "long.wav", pair(x, right), SR)
168
+ data, sr = load_stereo(p, max_seconds=4)
169
+ assert data.shape[0] == 4 * SR
170
+ r = analyse(data[:, 0], data[:, 1], sr)
171
+ assert r.loss_db < 0.3 # the in-phase middle, not the inverted ends
172
+
173
+
174
+ # ---------------------------------------------------------------- files that cannot be judged
175
+ def test_a_silent_channel_is_not_a_phase_problem(tmp_path):
176
+ x = sound(SR)
177
+ v = check_file(write(tmp_path / "s.wav", pair(x, np.zeros_like(x)), SR))
178
+ assert v.status == UNSURE and "silent" in v.reason and "right" in v.reason
179
+ v = check_file(write(tmp_path / "s2.wav", pair(np.zeros_like(x), x), SR))
180
+ assert v.status == UNSURE and "left" in v.reason
181
+
182
+
183
+ def test_a_mono_file_is_skipped(tmp_path):
184
+ v = check_file(write(tmp_path / "m.wav", sound(SR), SR))
185
+ assert v.status == SKIPPED and v.level == "SKIP" and v.channels == 1
186
+
187
+
188
+ def test_a_file_with_more_than_two_channels_is_skipped(tmp_path):
189
+ x = sound(SR)
190
+ p = tmp_path / "q.wav"
191
+ sf.write(str(p), synth.to_pcm16(np.stack([x, x, x], axis=1)), SR, subtype="PCM_16")
192
+ v = check_file(p)
193
+ assert v.status == SKIPPED and v.channels == 3 and "3 channels" in v.reason
194
+
195
+
196
+ def test_a_short_file_needs_a_look(tmp_path):
197
+ x = sound(SR, 0, 1.0)
198
+ assert check_file(write(tmp_path / "s.wav", pair(x, -x), SR)).status == UNSURE
199
+
200
+
201
+ def test_an_all_silent_file_needs_a_look(tmp_path):
202
+ z = np.zeros(SR * 4)
203
+ assert check_file(write(tmp_path / "z.wav", pair(z, z), SR)).status == UNSURE
204
+
205
+
206
+ def test_an_unreadable_file_is_an_error(tmp_path):
207
+ p = tmp_path / "bad.wav"
208
+ p.write_bytes(b"not audio")
209
+ v = check_file(p)
210
+ assert v.status == chk.ERROR and v.level == "ERROR" and "could not read" in v.reason
211
+
212
+
213
+ def test_judge_of_nothing_needs_a_look():
214
+ assert judge(None, "a.wav", SR).status == UNSURE
215
+
216
+
217
+ # ---------------------------------------------------------------- thresholds
218
+ def test_thresholds_can_be_changed():
219
+ r = res(*rot(130)) # 7.5 dB
220
+ assert judge(r, "a", SR).status == LIMITED
221
+ assert judge(r, "a", SR, warn_db=8.5, fail_db=12).status == OK
222
+ assert judge(r, "a", SR, warn_db=3, fail_db=7).status == FAKE
223
+
224
+
225
+ def test_defaults_are_the_documented_values():
226
+ assert (chk.WARN_DB, chk.FAIL_DB, chk.BAND_FAIL_DB) == (6.0, 10.0, 10.0)
227
+
228
+
229
+ # ---------------------------------------------------------------- sample rates
230
+ @pytest.mark.parametrize("sr", [16000, 22050, 48000])
231
+ def test_the_same_relationship_gives_the_same_loss_at_every_rate(sr):
232
+ x = sound(sr)
233
+ assert res(x, x, sr).dual_mono
234
+ assert res(x, -x, sr).loss_db >= 40
235
+ assert abs(res(x, synth.rotate(x, 90), sr).loss_db - 3.01) < 0.3
236
+
237
+
238
+ # ---------------------------------------------------------------- reports
239
+ def test_csv_columns_and_values(tmp_path):
240
+ out = tmp_path / "v.csv"
241
+ write_csv(out, [Verdict("a.wav", 44100, 2, FAKE, "r", 31.2, -0.98, "2-8 kHz", 40.0, False, 7.0)])
242
+ row = list(csv.DictReader(open(out, encoding="utf-8")))[0]
243
+ assert list(row) == ["file", "sample_rate", "channels", "verdict", "reason", "downmix_loss_db", "correlation", "worst_band",
244
+ "worst_band_loss_db", "dual_mono", "seconds_analysed"]
245
+ assert (row["verdict"], row["downmix_loss_db"], row["correlation"], row["worst_band"], row["channels"]) == ("FAIL", "31.2", "-0.98", "2-8 kHz", "2")
246
+
247
+
248
+ def test_json_report_has_the_summary_and_every_file():
249
+ vs = [Verdict("a.wav", 44100, 2, FAKE, "r", 31.2, -0.98),
250
+ Verdict("b.wav", 44100, 2, OK, "ok", 3.0, 0.0),
251
+ Verdict("m.wav", 44100, 1, SKIPPED, "mono")]
252
+ d = to_dict(vs)
253
+ assert d["tool"] == "phasecheck" and d["version"] and "alpha" in d["evidence"]
254
+ assert d["summary"] == {"files": 3, "PASS": 1, "WARN": 0, "FAIL": 1, "REVIEW": 0, "SKIP": 1, "ERROR": 0}
255
+ a = d["files"][0]
256
+ assert (a["file"], a["verdict"], a["downmix_loss_db"], a["correlation"], a["channels"]) == ("a.wav", "FAIL", 31.2, -0.98, 2)
257
+ assert json.loads(to_json(vs))["summary"]["files"] == 3
258
+
259
+
260
+ def test_text_report_hides_pass_and_skip_unless_asked():
261
+ vs = [Verdict("a.wav", 44100, 2, FAKE, "r"), Verdict("b.wav", 44100, 2, OK, "ok"), Verdict("m.wav", 44100, 1, SKIPPED, "mono")]
262
+ t = text(vs)
263
+ assert "a.wav" in t and "b.wav" not in t and "m.wav" not in t
264
+ t2 = text(vs, show_pass=True)
265
+ assert "b.wav" in t2 and "m.wav" in t2
266
+ assert "1 pass" in summary_line(vs) and "1 skipped" in summary_line(vs) and "1 fail" in summary_line(vs)
267
+
268
+
269
+ # ---------------------------------------------------------------- command line
270
+ def make_folder(tmp_path):
271
+ x = sound(SR)
272
+ write(tmp_path / "good.wav", pair(sound(SR, 0), sound(SR, 1)), SR)
273
+ write(tmp_path / "bad.wav", pair(x, -x), SR)
274
+ (tmp_path / "sub").mkdir()
275
+ write(tmp_path / "sub" / "mono.wav", x, SR)
276
+ return tmp_path
277
+
278
+
279
+ def test_find_audio_walks_folders_and_skips_junk(tmp_path):
280
+ make_folder(tmp_path)
281
+ (tmp_path / "._hidden.wav").write_bytes(b"x")
282
+ (tmp_path / "notes.txt").write_text("x")
283
+ names = [p.name for p in find_audio(tmp_path)]
284
+ assert names == ["bad.wav", "good.wav", "mono.wav"]
285
+
286
+
287
+ def test_cli_exit_code_is_1_when_something_fails(tmp_path, capsys):
288
+ make_folder(tmp_path)
289
+ assert main(["check", str(tmp_path)]) == 1
290
+ out = capsys.readouterr().out
291
+ assert "FAIL" in out and "bad.wav" in out and "good.wav" not in out
292
+
293
+
294
+ def test_cli_exit_code_is_0_when_nothing_fails(tmp_path, capsys):
295
+ write(tmp_path / "good.wav", pair(sound(SR, 0), sound(SR, 1)), SR)
296
+ assert main(["check", str(tmp_path)]) == 0
297
+
298
+
299
+ def test_cli_json_prints_valid_json(tmp_path, capsys):
300
+ make_folder(tmp_path)
301
+ rc = main(["check", str(tmp_path), "--json"])
302
+ d = json.loads(capsys.readouterr().out)
303
+ assert rc == 1 and d["summary"]["files"] == 3
304
+ assert (d["summary"]["PASS"], d["summary"]["FAIL"], d["summary"]["SKIP"]) == (1, 1, 1)
305
+
306
+
307
+ def test_cli_writes_csv(tmp_path):
308
+ make_folder(tmp_path)
309
+ out = tmp_path / "v.csv"
310
+ main(["check", str(tmp_path), "--out", str(out)])
311
+ assert len(list(csv.DictReader(open(out, encoding="utf-8")))) == 3
312
+
313
+
314
+ def test_cli_threshold_options_change_the_verdict(tmp_path, capsys):
315
+ write(tmp_path / "p.wav", pair(*rot(130)), SR)
316
+ assert main(["check", str(tmp_path)]) == 0 # WARN only
317
+ assert main(["check", str(tmp_path), "--fail-db", "7", "--warn-db", "3"]) == 1
318
+
319
+
320
+ def test_cli_rejects_warn_above_fail(tmp_path, capsys):
321
+ make_folder(tmp_path)
322
+ assert main(["check", str(tmp_path), "--warn-db", "12", "--fail-db", "8"]) == 2
323
+
324
+
325
+ def test_cli_missing_path_and_empty_folder(tmp_path, capsys):
326
+ assert main(["check", str(tmp_path / "nope")]) == 2
327
+ assert main(["check", str(tmp_path)]) == 2
328
+
329
+
330
+ def test_cli_parallel_jobs_same_result(tmp_path, capsys):
331
+ make_folder(tmp_path)
332
+ main(["check", str(tmp_path), "--json"])
333
+ a = json.loads(capsys.readouterr().out)
334
+ main(["check", str(tmp_path), "--json", "--jobs", "2"])
335
+ b = json.loads(capsys.readouterr().out)
336
+ assert a["summary"] == b["summary"] and [f["file"] for f in a["files"]] == [f["file"] for f in b["files"]]
337
+
338
+
339
+ def test_cli_version(capsys):
340
+ with pytest.raises(SystemExit):
341
+ main(["--version"])
342
+ assert "phasecheck 0.1.0" in capsys.readouterr().out
343
+
344
+
345
+ # ---------------------------------------------------------------- the limits themselves (judge on a hand-made measurement)
346
+ def made(loss, corr=0.0, bands=None, dual=False, silent=None):
347
+ return PhaseResult(SR, 8.0, 8.0, loss, corr, bands or {"20-250 Hz": 3.0, "250 Hz-2 kHz": 3.0, "2-8 kHz": 3.0, "above 8 kHz": 3.0}, dual, silent)
348
+
349
+
350
+ @pytest.mark.parametrize("loss,expected", [(5.99, OK), (6.0, LIMITED), (9.99, LIMITED), (10.0, FAKE), (30.0, FAKE)])
351
+ def test_full_band_limits_are_at_6_and_10_db(loss, expected):
352
+ assert judge(made(loss), "a", SR).status == expected
353
+
354
+
355
+ @pytest.mark.parametrize("band_loss,expected", [(9.99, OK), (10.0, LIMITED), (25.0, LIMITED)])
356
+ def test_band_limit_is_10_db(band_loss, expected):
357
+ v = judge(made(3.0, bands={"20-250 Hz": 3.0, "250 Hz-2 kHz": band_loss, "2-8 kHz": 3.0, "above 8 kHz": None}), "a", SR)
358
+ assert v.status == expected
359
+ if expected == LIMITED:
360
+ assert v.worst_band == "250 Hz-2 kHz" and v.worst_band_loss_db == band_loss
361
+
362
+
363
+ def test_the_worst_band_is_the_one_with_the_largest_loss():
364
+ v = judge(made(3.0, bands={"20-250 Hz": 2.0, "250 Hz-2 kHz": 4.0, "2-8 kHz": 9.0, "above 8 kHz": 6.0}), "a", SR)
365
+ assert v.worst_band == "2-8 kHz" and v.worst_band_loss_db == 9.0
366
+
367
+
368
+ def test_a_correlation_below_minus_half_is_called_opposite_in_phase():
369
+ assert "opposite in phase" in judge(made(12.0, corr=-0.5), "a", SR).reason
370
+ assert "opposite in phase" not in judge(made(12.0, corr=-0.49), "a", SR).reason
371
+
372
+
373
+ def test_a_silent_channel_wins_over_everything_else():
374
+ v = judge(made(60.0, corr=-1.0, silent=1), "a", SR)
375
+ assert v.status == UNSURE
376
+
377
+
378
+ def test_dual_mono_wins_over_the_loss_rules():
379
+ assert judge(made(0.0, corr=1.0, dual=True), "a", SR).status == OK
380
+
381
+
382
+ def test_verdict_levels_and_fraction_free_properties():
383
+ assert [Verdict("a", 1, 2, s, "").level for s in (OK, LIMITED, FAKE, UNSURE, SKIPPED, chk.ERROR)] == ["PASS", "WARN", "FAIL", "REVIEW", "SKIP", "ERROR"]
384
+
385
+
386
+ # ---------------------------------------------------------------- planted-bug follow-ups
387
+ def test_a_level_difference_of_nearly_a_decibel_is_not_dual_mono():
388
+ x = sound(SR)
389
+ r = res(x, 0.9 * x) # right is 0.9 dB quieter: in phase, loses nothing, but not identical
390
+ assert r.loss_db < 0.2 and r.correlation > 0.9999
391
+ assert not r.dual_mono
392
+ assert res(x, 0.97 * x).dual_mono # 0.26 dB apart still counts as identical
393
+
394
+
395
+ def test_only_the_loud_part_counts_as_active_time():
396
+ x = sound(SR, seconds=8.0)
397
+ half = len(x) // 2
398
+ y = x.copy()
399
+ y[half:] *= 10 ** (-50 / 20) # second half 50 dB quieter, below the loud-frame floor
400
+ r = res(y, y)
401
+ assert 3.0 < r.active_seconds < 4.5
402
+ assert res(x, x).active_seconds > 7.0