phasecheck 0.1.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.
- phasecheck/__init__.py +5 -0
- phasecheck/_version.py +1 -0
- phasecheck/analysis.py +106 -0
- phasecheck/check.py +71 -0
- phasecheck/cli.py +59 -0
- phasecheck/report.py +54 -0
- phasecheck-0.1.0.dist-info/METADATA +98 -0
- phasecheck-0.1.0.dist-info/RECORD +12 -0
- phasecheck-0.1.0.dist-info/WHEEL +5 -0
- phasecheck-0.1.0.dist-info/entry_points.txt +2 -0
- phasecheck-0.1.0.dist-info/licenses/LICENSE +29 -0
- phasecheck-0.1.0.dist-info/top_level.txt +1 -0
phasecheck/__init__.py
ADDED
phasecheck/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
phasecheck/analysis.py
ADDED
|
@@ -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)
|
phasecheck/check.py
ADDED
|
@@ -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)
|
phasecheck/cli.py
ADDED
|
@@ -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())
|
phasecheck/report.py
ADDED
|
@@ -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,12 @@
|
|
|
1
|
+
phasecheck/__init__.py,sha256=NU8jM3FGHirdNrMciCC8ZWgh15pDXl4ZpUCyNWJdkHc,239
|
|
2
|
+
phasecheck/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
3
|
+
phasecheck/analysis.py,sha256=p0jV7DwCJkgAAaX7Zxoh2wFx8Jd9VYQyui5YDmHmZ54,5065
|
|
4
|
+
phasecheck/check.py,sha256=br7d5bpWIM9eAzzA-1TbiE5n7ZkP4Q1AqgeOKUQXhOk,3712
|
|
5
|
+
phasecheck/cli.py,sha256=vfOcAytojpBBiGwbE6ZlEhEVxIYTztMZD2VEzYZpRKM,2518
|
|
6
|
+
phasecheck/report.py,sha256=gBf0eSlaVj84E41UlOMD-18rK4A4qRYeV0E-FpvVOcQ,2109
|
|
7
|
+
phasecheck-0.1.0.dist-info/licenses/LICENSE,sha256=brmceFF8Uw_1KeQBOshibuDU12xBGtsi2Y4mzSoRlgE,1462
|
|
8
|
+
phasecheck-0.1.0.dist-info/METADATA,sha256=IW7kNF1rT1jX7K6yOPWrCL2Hs-w8nwFtD9IhdDXP0YM,5383
|
|
9
|
+
phasecheck-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
phasecheck-0.1.0.dist-info/entry_points.txt,sha256=NStxGJbjeumUbBfrkE0neQmGptOwt-bfySbwKFPTQc0,51
|
|
11
|
+
phasecheck-0.1.0.dist-info/top_level.txt,sha256=zFXbCFvHByiGDVFqyoUcgXHkpj43SYGFTQWN2TQyG1Q,11
|
|
12
|
+
phasecheck-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
phasecheck
|