phantom-audio 1.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.
- phantom/__init__.py +113 -0
- phantom/__main__.py +9 -0
- phantom/_diagnostics.py +29 -0
- phantom/_profiles.py +335 -0
- phantom/_rounding.py +43 -0
- phantom/_utils.py +144 -0
- phantom/audio.py +218 -0
- phantom/cli/__init__.py +118 -0
- phantom/cli/_formatting.py +244 -0
- phantom/cli/analyze.py +376 -0
- phantom/cli/compare.py +392 -0
- phantom/cli/doctor.py +243 -0
- phantom/cli/render.py +180 -0
- phantom/cli/separate.py +82 -0
- phantom/cli/setup.py +217 -0
- phantom/cli/setup_reaper.py +284 -0
- phantom/cli/uninstall.py +233 -0
- phantom/cli/update.py +266 -0
- phantom/comparison/__init__.py +61 -0
- phantom/comparison/_common.py +280 -0
- phantom/comparison/match.py +138 -0
- phantom/comparison/profile.py +135 -0
- phantom/comparison/reference.py +143 -0
- phantom/dynamics.py +133 -0
- phantom/exceptions.py +52 -0
- phantom/loudness.py +137 -0
- phantom/masking.py +338 -0
- phantom/phase.py +304 -0
- phantom/problems.py +701 -0
- phantom/profiles/__init__.py +1 -0
- phantom/profiles/ambient.json +19 -0
- phantom/profiles/edm.json +19 -0
- phantom/profiles/electronic.json +19 -0
- phantom/profiles/hip-hop.json +19 -0
- phantom/profiles/lo-fi.json +19 -0
- phantom/profiles/metal.json +19 -0
- phantom/profiles/pop.json +19 -0
- phantom/profiles/rock-metal.json +19 -0
- phantom/profiles/rock.json +19 -0
- phantom/py.typed +0 -0
- phantom/separation.py +148 -0
- phantom/server.py +524 -0
- phantom/spectral.py +198 -0
- phantom/stereo.py +221 -0
- phantom_audio-1.1.0.dist-info/METADATA +338 -0
- phantom_audio-1.1.0.dist-info/RECORD +49 -0
- phantom_audio-1.1.0.dist-info/WHEEL +4 -0
- phantom_audio-1.1.0.dist-info/entry_points.txt +3 -0
- phantom_audio-1.1.0.dist-info/licenses/LICENSE +664 -0
phantom/__init__.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Phantom: AI audio engineering system."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.1.0"
|
|
4
|
+
|
|
5
|
+
from phantom.audio import AudioData, load_audio
|
|
6
|
+
from phantom.exceptions import (
|
|
7
|
+
AnalysisError,
|
|
8
|
+
AudioLoadError,
|
|
9
|
+
DependencyMissingError,
|
|
10
|
+
PathSecurityError,
|
|
11
|
+
PhantomError,
|
|
12
|
+
ProfileLoadError,
|
|
13
|
+
)
|
|
14
|
+
from phantom.loudness import analyze_loudness, LoudnessResult
|
|
15
|
+
from phantom.spectral import analyze_spectrum, SpectralResult
|
|
16
|
+
from phantom.dynamics import analyze_dynamics, DynamicsResult
|
|
17
|
+
from phantom.stereo import analyze_stereo, StereoResult, PanoramaDistribution
|
|
18
|
+
from phantom.phase import analyze_phase, compare_phase, PhaseResult, PhaseCompareResult
|
|
19
|
+
from phantom.problems import (
|
|
20
|
+
detect_problems,
|
|
21
|
+
build_summary,
|
|
22
|
+
ProblemsResult,
|
|
23
|
+
ProblemItem,
|
|
24
|
+
ProblemSummary,
|
|
25
|
+
)
|
|
26
|
+
from phantom.masking import (
|
|
27
|
+
analyze_masking,
|
|
28
|
+
analyze_masking_matrix,
|
|
29
|
+
MaskingResult,
|
|
30
|
+
MaskingBand,
|
|
31
|
+
MaskingMatrixResult,
|
|
32
|
+
MaskingPair,
|
|
33
|
+
)
|
|
34
|
+
from phantom._profiles import ReferenceProfile, load_profile, list_profiles
|
|
35
|
+
from phantom.comparison import (
|
|
36
|
+
compare_to_profile,
|
|
37
|
+
compare_to_reference,
|
|
38
|
+
match_to_reference,
|
|
39
|
+
DeviationResult,
|
|
40
|
+
RangeDeviationResult,
|
|
41
|
+
MonoBelowResult,
|
|
42
|
+
LoudnessProfileComparisonSection,
|
|
43
|
+
DynamicsComparisonSection,
|
|
44
|
+
StereoProfileComparisonSection,
|
|
45
|
+
LoudnessReferenceComparisonSection,
|
|
46
|
+
DynamicsReferenceComparisonSection,
|
|
47
|
+
StereoReferenceComparisonSection,
|
|
48
|
+
MetricDiff,
|
|
49
|
+
MatchAdjustments,
|
|
50
|
+
ProfileComparisonResult,
|
|
51
|
+
ReferenceComparisonResult,
|
|
52
|
+
MatchResult,
|
|
53
|
+
)
|
|
54
|
+
from phantom.separation import separate_stems, SeparationResult
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"AudioData",
|
|
58
|
+
"load_audio",
|
|
59
|
+
"analyze_spectrum",
|
|
60
|
+
"analyze_loudness",
|
|
61
|
+
"analyze_dynamics",
|
|
62
|
+
"analyze_stereo",
|
|
63
|
+
"analyze_masking",
|
|
64
|
+
"analyze_masking_matrix",
|
|
65
|
+
"analyze_phase",
|
|
66
|
+
"compare_phase",
|
|
67
|
+
"compare_to_profile",
|
|
68
|
+
"compare_to_reference",
|
|
69
|
+
"detect_problems",
|
|
70
|
+
"build_summary",
|
|
71
|
+
"load_profile",
|
|
72
|
+
"list_profiles",
|
|
73
|
+
"match_to_reference",
|
|
74
|
+
"ReferenceProfile",
|
|
75
|
+
"separate_stems",
|
|
76
|
+
"PhantomError",
|
|
77
|
+
"PathSecurityError",
|
|
78
|
+
"AudioLoadError",
|
|
79
|
+
"AnalysisError",
|
|
80
|
+
"DependencyMissingError",
|
|
81
|
+
"ProfileLoadError",
|
|
82
|
+
"__version__",
|
|
83
|
+
# Response models
|
|
84
|
+
"SpectralResult",
|
|
85
|
+
"LoudnessResult",
|
|
86
|
+
"DynamicsResult",
|
|
87
|
+
"StereoResult",
|
|
88
|
+
"PanoramaDistribution",
|
|
89
|
+
"PhaseResult",
|
|
90
|
+
"PhaseCompareResult",
|
|
91
|
+
"ProblemsResult",
|
|
92
|
+
"ProblemItem",
|
|
93
|
+
"ProblemSummary",
|
|
94
|
+
"MaskingResult",
|
|
95
|
+
"MaskingBand",
|
|
96
|
+
"MaskingMatrixResult",
|
|
97
|
+
"MaskingPair",
|
|
98
|
+
"DeviationResult",
|
|
99
|
+
"RangeDeviationResult",
|
|
100
|
+
"MonoBelowResult",
|
|
101
|
+
"LoudnessProfileComparisonSection",
|
|
102
|
+
"DynamicsComparisonSection",
|
|
103
|
+
"StereoProfileComparisonSection",
|
|
104
|
+
"LoudnessReferenceComparisonSection",
|
|
105
|
+
"DynamicsReferenceComparisonSection",
|
|
106
|
+
"StereoReferenceComparisonSection",
|
|
107
|
+
"MetricDiff",
|
|
108
|
+
"MatchAdjustments",
|
|
109
|
+
"ProfileComparisonResult",
|
|
110
|
+
"ReferenceComparisonResult",
|
|
111
|
+
"MatchResult",
|
|
112
|
+
"SeparationResult",
|
|
113
|
+
]
|
phantom/__main__.py
ADDED
phantom/_diagnostics.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared diagnostic utilities for doctor and server startup preflight."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
CORE_DEPS = {
|
|
6
|
+
"numpy": "numpy",
|
|
7
|
+
"scipy": "scipy",
|
|
8
|
+
"soundfile": "soundfile",
|
|
9
|
+
"essentia": "essentia",
|
|
10
|
+
"pydantic": "pydantic",
|
|
11
|
+
"fastmcp": "fastmcp",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
OPTIONAL_DEPS = {
|
|
15
|
+
"demucs": "separation",
|
|
16
|
+
"matchering": "matching",
|
|
17
|
+
"pedalboard": "processing",
|
|
18
|
+
"librosa": "analysis",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def try_import(name: str) -> tuple[bool, str]:
|
|
23
|
+
"""Try to import a package. Returns (success, version_or_error)."""
|
|
24
|
+
try:
|
|
25
|
+
mod = __import__(name)
|
|
26
|
+
version = getattr(mod, "__version__", getattr(mod, "VERSION", "?"))
|
|
27
|
+
return True, str(version)
|
|
28
|
+
except Exception as exc:
|
|
29
|
+
return False, str(exc)
|
phantom/_profiles.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Reference profile data models and loader functions.
|
|
2
|
+
|
|
3
|
+
Provides the ReferenceProfile Pydantic model hierarchy for genre-specific
|
|
4
|
+
audio engineering targets. Profiles define what "good" sounds like for a
|
|
5
|
+
genre: loudness, frequency balance, stereo conventions, and spatial processing.
|
|
6
|
+
|
|
7
|
+
Public API:
|
|
8
|
+
load_profile(name) — Load a genre profile by name (case-insensitive, alias-aware).
|
|
9
|
+
list_profiles() — List all available profile names (built-in + user).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.resources
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
21
|
+
|
|
22
|
+
from phantom.exceptions import ProfileLoadError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LoudnessTargets(BaseModel):
|
|
26
|
+
"""Target loudness range for a genre."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(frozen=True)
|
|
29
|
+
|
|
30
|
+
lufs_range: tuple[float, float]
|
|
31
|
+
crest_factor_range: tuple[float, float]
|
|
32
|
+
true_peak_max_dbtp: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FrequencyTargets(BaseModel):
|
|
36
|
+
"""Target frequency balance as dB offsets from flat per octave band."""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(frozen=True)
|
|
39
|
+
|
|
40
|
+
bands: dict[str, float]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StereoConventions(BaseModel):
|
|
44
|
+
"""Stereo field conventions for a genre."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(frozen=True)
|
|
47
|
+
|
|
48
|
+
width: str
|
|
49
|
+
mono_below_hz: float
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SpatialConventions(BaseModel):
|
|
53
|
+
"""Reverb and spatial processing conventions."""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(frozen=True)
|
|
56
|
+
|
|
57
|
+
reverb_type: str
|
|
58
|
+
reverb_amount: str
|
|
59
|
+
pre_delay_ms: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ReferenceProfile(BaseModel):
|
|
63
|
+
"""Complete reference profile for a genre.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
genre: Genre identifier (e.g. "rock", "hip-hop").
|
|
67
|
+
description: Human-readable genre description.
|
|
68
|
+
loudness: Target loudness ranges (LUFS, crest factor, true peak).
|
|
69
|
+
frequency: Target frequency balance per octave band.
|
|
70
|
+
stereo: Stereo field conventions (width, mono-below).
|
|
71
|
+
spatial: Reverb and spatial processing conventions.
|
|
72
|
+
processing_notes: Genre-specific mixing/mastering approach text.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
model_config = ConfigDict(frozen=True)
|
|
76
|
+
|
|
77
|
+
genre: str
|
|
78
|
+
description: str
|
|
79
|
+
loudness: LoudnessTargets
|
|
80
|
+
frequency: FrequencyTargets
|
|
81
|
+
stereo: StereoConventions
|
|
82
|
+
spatial: SpatialConventions
|
|
83
|
+
processing_notes: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _json_depth(obj, current: int = 1) -> int:
|
|
87
|
+
"""Return the maximum nesting depth of a parsed JSON structure."""
|
|
88
|
+
if isinstance(obj, dict):
|
|
89
|
+
if not obj:
|
|
90
|
+
return current
|
|
91
|
+
return max(_json_depth(v, current + 1) for v in obj.values())
|
|
92
|
+
if isinstance(obj, list):
|
|
93
|
+
if not obj:
|
|
94
|
+
return current
|
|
95
|
+
return max(_json_depth(v, current + 1) for v in obj)
|
|
96
|
+
return current
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# mtime-based cache: {resolved_name: (mtime, ReferenceProfile)}
|
|
100
|
+
_profile_cache: dict[str, tuple[float, ReferenceProfile]] = {}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _get_user_profile_path(name: str) -> Path | None:
|
|
104
|
+
"""Return the path to a user profile file, or None if not applicable."""
|
|
105
|
+
user_dir = os.environ.get("PHANTOM_PROFILES_DIR")
|
|
106
|
+
if not user_dir:
|
|
107
|
+
return None
|
|
108
|
+
path = Path(user_dir) / f"{name}.json"
|
|
109
|
+
resolved = path.resolve()
|
|
110
|
+
base = Path(user_dir).resolve()
|
|
111
|
+
if not str(resolved).startswith(str(base) + os.sep) and resolved != base:
|
|
112
|
+
return None
|
|
113
|
+
return resolved if resolved.is_file() else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _has_builtin_profile(name: str) -> bool:
|
|
117
|
+
"""Check if a built-in profile exists with this name."""
|
|
118
|
+
profiles_pkg = importlib.resources.files("phantom.profiles")
|
|
119
|
+
resource = profiles_pkg.joinpath(f"{name}.json")
|
|
120
|
+
return resource.is_file()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Alias mapping (D-07 case-insensitive, D-08 common aliases)
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
_ALIASES: dict[str, str] = {
|
|
128
|
+
"hiphop": "hip-hop",
|
|
129
|
+
"hip hop": "hip-hop",
|
|
130
|
+
"lofi": "lo-fi",
|
|
131
|
+
"lo fi": "lo-fi",
|
|
132
|
+
"rockmetal": "rock-metal",
|
|
133
|
+
"rock metal": "rock-metal",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _resolve_name(name: str) -> str:
|
|
138
|
+
"""Resolve a profile name: strip, lowercase, then alias lookup."""
|
|
139
|
+
key = name.strip().lower()
|
|
140
|
+
resolved = _ALIASES.get(key, key)
|
|
141
|
+
# Reject path traversal attempts
|
|
142
|
+
if "/" in resolved or "\\" in resolved or ".." in resolved:
|
|
143
|
+
raise ProfileLoadError(
|
|
144
|
+
f"Invalid profile name: '{name}'. "
|
|
145
|
+
"Profile names must not contain path separators or '..'."
|
|
146
|
+
)
|
|
147
|
+
return resolved
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Internal loaders
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _load_user_profile(name: str) -> dict | None:
|
|
156
|
+
"""Try loading a profile from the user's custom profiles directory.
|
|
157
|
+
|
|
158
|
+
Returns None if PHANTOM_PROFILES_DIR is not set or file not found.
|
|
159
|
+
"""
|
|
160
|
+
user_dir = os.environ.get("PHANTOM_PROFILES_DIR")
|
|
161
|
+
if not user_dir:
|
|
162
|
+
return None
|
|
163
|
+
path = Path(user_dir) / f"{name}.json"
|
|
164
|
+
|
|
165
|
+
# Realpath containment check (X-WR-03, S-WR-04)
|
|
166
|
+
resolved_path = path.resolve()
|
|
167
|
+
base = Path(user_dir).resolve()
|
|
168
|
+
if not str(resolved_path).startswith(str(base) + os.sep) and resolved_path != base:
|
|
169
|
+
raise ProfileLoadError(f"Invalid profile name: '{name}'")
|
|
170
|
+
path = resolved_path
|
|
171
|
+
|
|
172
|
+
if not path.is_file():
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
# File size guard (X-WR-02): reject profiles larger than 1 MB
|
|
176
|
+
if path.stat().st_size > 1_000_000:
|
|
177
|
+
raise ProfileLoadError(f"Profile file too large: {name}")
|
|
178
|
+
|
|
179
|
+
text = path.read_text(encoding="utf-8")
|
|
180
|
+
try:
|
|
181
|
+
# Depth guard: reject excessively nested JSON (could exhaust recursion)
|
|
182
|
+
decoder = json.JSONDecoder()
|
|
183
|
+
result = decoder.decode(text)
|
|
184
|
+
if _json_depth(result) > 10:
|
|
185
|
+
raise ProfileLoadError(f"Profile '{name}' exceeds maximum nesting depth")
|
|
186
|
+
return result
|
|
187
|
+
except json.JSONDecodeError as exc:
|
|
188
|
+
raise ProfileLoadError(
|
|
189
|
+
f"Profile '{name}' contains invalid JSON: {exc}"
|
|
190
|
+
) from exc
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _load_builtin_profile(name: str) -> dict | None:
|
|
194
|
+
"""Load a built-in profile JSON file from the phantom.profiles subpackage.
|
|
195
|
+
|
|
196
|
+
Returns None if the profile does not exist.
|
|
197
|
+
"""
|
|
198
|
+
profiles_pkg = importlib.resources.files("phantom.profiles")
|
|
199
|
+
resource = profiles_pkg.joinpath(f"{name}.json")
|
|
200
|
+
if not resource.is_file():
|
|
201
|
+
return None
|
|
202
|
+
text = resource.read_text(encoding="utf-8")
|
|
203
|
+
try:
|
|
204
|
+
return json.loads(text)
|
|
205
|
+
except json.JSONDecodeError as exc:
|
|
206
|
+
raise ProfileLoadError(
|
|
207
|
+
f"Built-in profile '{name}' contains invalid JSON: {exc}"
|
|
208
|
+
) from exc
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
213
|
+
"""Recursively merge override into base. Override values win."""
|
|
214
|
+
merged = {**base}
|
|
215
|
+
for k, v in override.items():
|
|
216
|
+
if k in merged and isinstance(merged[k], dict) and isinstance(v, dict):
|
|
217
|
+
merged[k] = _deep_merge(merged[k], v)
|
|
218
|
+
else:
|
|
219
|
+
merged[k] = v
|
|
220
|
+
return merged
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
# Public API
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def list_profiles() -> list[str]:
|
|
229
|
+
"""List all available reference profile names.
|
|
230
|
+
|
|
231
|
+
Returns profiles from both the user's custom directory (PHANTOM_PROFILES_DIR)
|
|
232
|
+
and the built-in profiles bundled with Phantom. User profiles that share a
|
|
233
|
+
name with a built-in profile will appear once (the user version takes
|
|
234
|
+
precedence when loaded).
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Sorted list of profile names (without .json extension).
|
|
238
|
+
"""
|
|
239
|
+
names: set[str] = set()
|
|
240
|
+
|
|
241
|
+
# Scan user directory first
|
|
242
|
+
user_dir = os.environ.get("PHANTOM_PROFILES_DIR")
|
|
243
|
+
if user_dir:
|
|
244
|
+
user_path = Path(user_dir)
|
|
245
|
+
if user_path.is_dir():
|
|
246
|
+
for item in user_path.iterdir():
|
|
247
|
+
if item.is_file() and item.suffix == ".json":
|
|
248
|
+
names.add(item.stem)
|
|
249
|
+
|
|
250
|
+
# Scan built-in profiles
|
|
251
|
+
profiles_pkg = importlib.resources.files("phantom.profiles")
|
|
252
|
+
for item in profiles_pkg.iterdir():
|
|
253
|
+
if hasattr(item, "name") and item.name.endswith(".json") and item.is_file():
|
|
254
|
+
names.add(item.name.removesuffix(".json"))
|
|
255
|
+
|
|
256
|
+
return sorted(names)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def load_profile(name: str) -> ReferenceProfile:
|
|
260
|
+
"""Load a genre reference profile by name.
|
|
261
|
+
|
|
262
|
+
Profile names are case-insensitive. Common aliases are supported:
|
|
263
|
+
"hiphop" resolves to "hip-hop", "lofi" to "lo-fi", "rockmetal" to
|
|
264
|
+
"rock-metal".
|
|
265
|
+
|
|
266
|
+
Search order (per REF-06):
|
|
267
|
+
1. PHANTOM_PROFILES_DIR environment variable directory (user overrides)
|
|
268
|
+
2. Built-in profiles bundled with Phantom
|
|
269
|
+
|
|
270
|
+
A user profile completely replaces the built-in profile of the same name
|
|
271
|
+
(no partial merging).
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
name: Genre name (e.g. "rock", "hip-hop", "lofi").
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
ReferenceProfile with the genre's loudness, frequency, stereo,
|
|
278
|
+
and spatial targets.
|
|
279
|
+
|
|
280
|
+
Raises:
|
|
281
|
+
ProfileLoadError: If the profile is not found or is malformed.
|
|
282
|
+
"""
|
|
283
|
+
resolved = _resolve_name(name)
|
|
284
|
+
|
|
285
|
+
# mtime-based cache: check if user profile changed since last load
|
|
286
|
+
user_path = _get_user_profile_path(resolved)
|
|
287
|
+
if user_path and resolved in _profile_cache:
|
|
288
|
+
cached_mtime, cached_profile = _profile_cache[resolved]
|
|
289
|
+
try:
|
|
290
|
+
current_mtime = user_path.stat().st_mtime
|
|
291
|
+
except OSError:
|
|
292
|
+
current_mtime = None
|
|
293
|
+
if current_mtime == cached_mtime:
|
|
294
|
+
return cached_profile
|
|
295
|
+
|
|
296
|
+
# Search order: user directory first, then builtins (D-09)
|
|
297
|
+
user_raw = _load_user_profile(resolved)
|
|
298
|
+
builtin_raw = _load_builtin_profile(resolved)
|
|
299
|
+
|
|
300
|
+
if user_raw is not None and builtin_raw is not None:
|
|
301
|
+
if not os.environ.get("PHANTOM_PROFILE_OVERRIDE_QUIET"):
|
|
302
|
+
print(
|
|
303
|
+
f"[phantom] User profile '{resolved}' overrides built-in. "
|
|
304
|
+
f"Set PHANTOM_PROFILE_OVERRIDE_QUIET=1 to silence.",
|
|
305
|
+
file=sys.stderr,
|
|
306
|
+
)
|
|
307
|
+
if os.environ.get("PHANTOM_PROFILE_MERGE"):
|
|
308
|
+
raw = _deep_merge(builtin_raw, user_raw)
|
|
309
|
+
else:
|
|
310
|
+
raw = user_raw
|
|
311
|
+
elif user_raw is not None:
|
|
312
|
+
raw = user_raw
|
|
313
|
+
elif builtin_raw is not None:
|
|
314
|
+
raw = builtin_raw
|
|
315
|
+
else:
|
|
316
|
+
available = list_profiles()
|
|
317
|
+
raise ProfileLoadError(
|
|
318
|
+
f"No profile found for '{name}'. Available profiles: {', '.join(available)}"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
profile = ReferenceProfile.model_validate(raw)
|
|
323
|
+
except ValidationError as exc:
|
|
324
|
+
raise ProfileLoadError(f"Profile '{name}' is malformed: {exc}") from exc
|
|
325
|
+
|
|
326
|
+
# Cache with post-load mtime (avoids TOCTOU: if file changed during load,
|
|
327
|
+
# the next call sees a newer mtime and reloads)
|
|
328
|
+
if user_path:
|
|
329
|
+
try:
|
|
330
|
+
post_load_mtime = user_path.stat().st_mtime
|
|
331
|
+
except OSError:
|
|
332
|
+
post_load_mtime = 0.0
|
|
333
|
+
_profile_cache[resolved] = (post_load_mtime, profile)
|
|
334
|
+
|
|
335
|
+
return profile
|
phantom/_rounding.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Shared numeric rounding utilities for Pydantic model validators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def round_db(v: float | None, dp: int = 2) -> float | None:
|
|
7
|
+
"""Round a dB value. Returns None unchanged."""
|
|
8
|
+
return round(v, dp) if v is not None else v
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def round_hz(v: float | None, dp: int = 1) -> float | None:
|
|
12
|
+
"""Round a Hz value. Returns None unchanged."""
|
|
13
|
+
return round(v, dp) if v is not None else v
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def round_db_list(v: list[float] | None, dp: int = 2) -> list[float] | None:
|
|
17
|
+
"""Round a list of dB values. Returns None unchanged."""
|
|
18
|
+
return [round(x, dp) for x in v] if v is not None else v
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def round_ratio(v: float | None, dp: int = 4) -> float | None:
|
|
22
|
+
"""Round a dimensionless ratio. Returns None unchanged."""
|
|
23
|
+
return round(v, dp) if v is not None else v
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def round_ratio_list(v: list[float] | None, dp: int = 4) -> list[float] | None:
|
|
27
|
+
"""Round a list of dimensionless ratios. Returns None unchanged."""
|
|
28
|
+
return [round(x, dp) for x in v] if v is not None else v
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def round_ms(v: float | None, dp: int = 2) -> float | None:
|
|
32
|
+
"""Round a milliseconds value. Returns None unchanged."""
|
|
33
|
+
return round(v, dp) if v is not None else v
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def round_pct(v: float | None, dp: int = 1) -> float | None:
|
|
37
|
+
"""Round a percentage value. Returns None unchanged."""
|
|
38
|
+
return round(v, dp) if v is not None else v
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def round_db_dict(v: dict[str, float] | None, dp: int = 2) -> dict[str, float] | None:
|
|
42
|
+
"""Round all values in a dict of dB values. Returns None unchanged."""
|
|
43
|
+
return {k: round(val, dp) for k, val in v.items()} if v is not None else v
|
phantom/_utils.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Shared utility functions for Phantom analysis modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from phantom.exceptions import PathSecurityError
|
|
10
|
+
|
|
11
|
+
# Silence threshold in dBFS -- signals below this are treated as silence.
|
|
12
|
+
SILENCE_THRESHOLD_DB = -80.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _block_rms_db(
|
|
16
|
+
mono: np.ndarray,
|
|
17
|
+
block_size: int = 4096,
|
|
18
|
+
hop: int = 2048,
|
|
19
|
+
) -> list[float]:
|
|
20
|
+
"""Compute per-block RMS levels in dBFS.
|
|
21
|
+
|
|
22
|
+
Splits *mono* into overlapping blocks and returns the RMS of each
|
|
23
|
+
block converted to dB. Silent blocks (RMS == 0) are excluded.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
mono: 1-D float32/float64 audio array.
|
|
27
|
+
block_size: Number of samples per block (must be > 0).
|
|
28
|
+
hop: Hop size between consecutive blocks (must be > 0).
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
List of RMS values in dBFS, one per non-silent block.
|
|
32
|
+
"""
|
|
33
|
+
if block_size <= 0:
|
|
34
|
+
raise ValueError(f"block_size must be positive, got {block_size}")
|
|
35
|
+
if hop <= 0:
|
|
36
|
+
raise ValueError(f"hop must be positive, got {hop}")
|
|
37
|
+
levels: list[float] = []
|
|
38
|
+
for i in range(0, len(mono) - block_size + 1, hop):
|
|
39
|
+
block = mono[i : i + block_size]
|
|
40
|
+
rms = float(np.sqrt(np.mean(block**2)))
|
|
41
|
+
if rms > 0:
|
|
42
|
+
levels.append(20.0 * np.log10(rms))
|
|
43
|
+
return levels
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_near_silent(mono: np.ndarray) -> bool:
|
|
47
|
+
"""Check whether a mono signal is near-silent.
|
|
48
|
+
|
|
49
|
+
Returns True if the RMS level is below SILENCE_THRESHOLD_DB.
|
|
50
|
+
"""
|
|
51
|
+
rms = float(np.sqrt(np.mean(mono**2)))
|
|
52
|
+
if rms == 0:
|
|
53
|
+
return True
|
|
54
|
+
rms_db = 20 * np.log10(rms)
|
|
55
|
+
return rms_db < SILENCE_THRESHOLD_DB
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_input_path(path: str) -> str:
|
|
59
|
+
"""Validate and resolve an input audio file path.
|
|
60
|
+
|
|
61
|
+
When PHANTOM_AUDIO_DIR is set:
|
|
62
|
+
- Relative paths are resolved relative to PHANTOM_AUDIO_DIR (D-01)
|
|
63
|
+
- Symlinks resolved via os.path.realpath() (D-02)
|
|
64
|
+
- Both base and path get realpath treatment (D-03)
|
|
65
|
+
- Paths outside the allowed directory raise PathSecurityError
|
|
66
|
+
|
|
67
|
+
When PHANTOM_AUDIO_DIR is unset:
|
|
68
|
+
- Returns path unchanged (D-13, backwards compatible)
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
path: File path string to validate.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The validated (possibly resolved) path string.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
PathSecurityError: If the resolved path is outside PHANTOM_AUDIO_DIR.
|
|
78
|
+
"""
|
|
79
|
+
audio_dir = os.environ.get("PHANTOM_AUDIO_DIR")
|
|
80
|
+
if not audio_dir:
|
|
81
|
+
return path # No restriction (D-13)
|
|
82
|
+
|
|
83
|
+
# D-01: Resolve relative paths against PHANTOM_AUDIO_DIR
|
|
84
|
+
if not os.path.isabs(path):
|
|
85
|
+
path = os.path.join(audio_dir, path)
|
|
86
|
+
|
|
87
|
+
# D-02, D-03: Resolve both sides via realpath
|
|
88
|
+
real_base = os.path.realpath(audio_dir)
|
|
89
|
+
real_path = os.path.realpath(path)
|
|
90
|
+
|
|
91
|
+
# SC-7: Directory existence check
|
|
92
|
+
if not os.path.isdir(real_base):
|
|
93
|
+
raise PathSecurityError(
|
|
94
|
+
"PHANTOM_AUDIO_DIR points to a directory that does not exist: "
|
|
95
|
+
"check the path and create the directory."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Containment check (pattern from _profiles.py, with os.sep suffix)
|
|
99
|
+
if not (real_path.startswith(real_base + os.sep) or real_path == real_base):
|
|
100
|
+
raise PathSecurityError(
|
|
101
|
+
"Access denied: audio file is outside the allowed directory. "
|
|
102
|
+
"Set PHANTOM_AUDIO_DIR to a directory containing your audio files."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return real_path
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate_output_path(path: str) -> str:
|
|
109
|
+
"""Validate an output path against PHANTOM_OUTPUT_DIR restriction.
|
|
110
|
+
|
|
111
|
+
When PHANTOM_OUTPUT_DIR is set:
|
|
112
|
+
- Resolved path must be within the allowed output directory
|
|
113
|
+
- Symlinks resolved via os.path.realpath()
|
|
114
|
+
|
|
115
|
+
When PHANTOM_OUTPUT_DIR is unset:
|
|
116
|
+
- Returns path unchanged (D-11, backwards compatible)
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
path: Output path string to validate.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The validated (possibly resolved) path string.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
PathSecurityError: If the resolved path is outside PHANTOM_OUTPUT_DIR.
|
|
126
|
+
"""
|
|
127
|
+
output_dir = os.environ.get("PHANTOM_OUTPUT_DIR")
|
|
128
|
+
if not output_dir:
|
|
129
|
+
return path # No restriction (D-11)
|
|
130
|
+
|
|
131
|
+
# Resolve relative paths against PHANTOM_OUTPUT_DIR (consistent with input)
|
|
132
|
+
if not os.path.isabs(path):
|
|
133
|
+
path = os.path.join(output_dir, path)
|
|
134
|
+
|
|
135
|
+
real_base = os.path.realpath(output_dir)
|
|
136
|
+
real_path = os.path.realpath(path)
|
|
137
|
+
|
|
138
|
+
if not (real_path.startswith(real_base + os.sep) or real_path == real_base):
|
|
139
|
+
raise PathSecurityError(
|
|
140
|
+
"Access denied: output path is outside the allowed directory. "
|
|
141
|
+
"Set PHANTOM_OUTPUT_DIR to a directory where outputs should be written."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return real_path
|