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.
Files changed (49) hide show
  1. phantom/__init__.py +113 -0
  2. phantom/__main__.py +9 -0
  3. phantom/_diagnostics.py +29 -0
  4. phantom/_profiles.py +335 -0
  5. phantom/_rounding.py +43 -0
  6. phantom/_utils.py +144 -0
  7. phantom/audio.py +218 -0
  8. phantom/cli/__init__.py +118 -0
  9. phantom/cli/_formatting.py +244 -0
  10. phantom/cli/analyze.py +376 -0
  11. phantom/cli/compare.py +392 -0
  12. phantom/cli/doctor.py +243 -0
  13. phantom/cli/render.py +180 -0
  14. phantom/cli/separate.py +82 -0
  15. phantom/cli/setup.py +217 -0
  16. phantom/cli/setup_reaper.py +284 -0
  17. phantom/cli/uninstall.py +233 -0
  18. phantom/cli/update.py +266 -0
  19. phantom/comparison/__init__.py +61 -0
  20. phantom/comparison/_common.py +280 -0
  21. phantom/comparison/match.py +138 -0
  22. phantom/comparison/profile.py +135 -0
  23. phantom/comparison/reference.py +143 -0
  24. phantom/dynamics.py +133 -0
  25. phantom/exceptions.py +52 -0
  26. phantom/loudness.py +137 -0
  27. phantom/masking.py +338 -0
  28. phantom/phase.py +304 -0
  29. phantom/problems.py +701 -0
  30. phantom/profiles/__init__.py +1 -0
  31. phantom/profiles/ambient.json +19 -0
  32. phantom/profiles/edm.json +19 -0
  33. phantom/profiles/electronic.json +19 -0
  34. phantom/profiles/hip-hop.json +19 -0
  35. phantom/profiles/lo-fi.json +19 -0
  36. phantom/profiles/metal.json +19 -0
  37. phantom/profiles/pop.json +19 -0
  38. phantom/profiles/rock-metal.json +19 -0
  39. phantom/profiles/rock.json +19 -0
  40. phantom/py.typed +0 -0
  41. phantom/separation.py +148 -0
  42. phantom/server.py +524 -0
  43. phantom/spectral.py +198 -0
  44. phantom/stereo.py +221 -0
  45. phantom_audio-1.1.0.dist-info/METADATA +338 -0
  46. phantom_audio-1.1.0.dist-info/RECORD +49 -0
  47. phantom_audio-1.1.0.dist-info/WHEEL +4 -0
  48. phantom_audio-1.1.0.dist-info/entry_points.txt +3 -0
  49. 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
@@ -0,0 +1,9 @@
1
+ """Allow running Phantom via: python -m phantom
2
+
3
+ Invokes the CLI. For the MCP server, use: phantom-mcp
4
+ """
5
+
6
+ from phantom.cli import cli
7
+
8
+ if __name__ == "__main__":
9
+ cli()
@@ -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