Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ asyncio_mode = "auto"
markers = [
"live: requires real audio in tests/fixtures/live/",
]
filterwarnings = [
"ignore:authlib.jose module is deprecated:authlib.deprecate.AuthlibDeprecationWarning",
"ignore::DeprecationWarning:matchering.*",
]

[dependency-groups]
dev = [
Expand Down
34 changes: 30 additions & 4 deletions src/phantom/_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,19 @@ class ReferenceProfile(BaseModel):
processing_notes: str


def _json_depth(obj, current: int = 1) -> int:
"""Return the maximum nesting depth of a parsed JSON structure."""
if isinstance(obj, dict):
if not obj:
return current
return max(_json_depth(v, current + 1) for v in obj.values())
if isinstance(obj, list):
if not obj:
return current
return max(_json_depth(v, current + 1) for v in obj)
return current


# mtime-based cache: {resolved_name: (mtime, ReferenceProfile)}
_profile_cache: dict[str, tuple[float, ReferenceProfile]] = {}

Expand Down Expand Up @@ -165,7 +178,12 @@ def _load_user_profile(name: str) -> dict | None:

text = path.read_text(encoding="utf-8")
try:
return json.loads(text)
# Depth guard: reject excessively nested JSON (could exhaust recursion)
decoder = json.JSONDecoder()
result = decoder.decode(text)
if _json_depth(result) > 10:
raise ProfileLoadError(f"Profile '{name}' exceeds maximum nesting depth")
return result
except json.JSONDecodeError as exc:
raise ProfileLoadError(
f"Profile '{name}' contains invalid JSON: {exc}"
Expand Down Expand Up @@ -268,7 +286,10 @@ def load_profile(name: str) -> ReferenceProfile:
user_path = _get_user_profile_path(resolved)
if user_path and resolved in _profile_cache:
cached_mtime, cached_profile = _profile_cache[resolved]
current_mtime = user_path.stat().st_mtime
try:
current_mtime = user_path.stat().st_mtime
except OSError:
current_mtime = None
if current_mtime == cached_mtime:
return cached_profile

Expand Down Expand Up @@ -302,8 +323,13 @@ def load_profile(name: str) -> ReferenceProfile:
except ValidationError as exc:
raise ProfileLoadError(f"Profile '{name}' is malformed: {exc}") from exc

# Cache with mtime for user profiles
# Cache with post-load mtime (avoids TOCTOU: if file changed during load,
# the next call sees a newer mtime and reloads)
if user_path:
_profile_cache[resolved] = (user_path.stat().st_mtime, profile)
try:
post_load_mtime = user_path.stat().st_mtime
except OSError:
post_load_mtime = 0.0
_profile_cache[resolved] = (post_load_mtime, profile)

return profile
16 changes: 14 additions & 2 deletions src/phantom/cli/setup_reaper.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ def _configure_startup_script(scripts_dir: Path, console, json_output: bool) ->
else:
new_content = _STARTUP_BLOCK

startup_file.write_text(new_content)
# Atomic write: temp file + rename
tmp_path = str(startup_file) + ".tmp"
Path(tmp_path).write_text(new_content)
os.replace(tmp_path, str(startup_file))
if not json_output:
console.print(
" Configured [green]__startup.lua[/green] — bridge will auto-start with Reaper"
Expand Down Expand Up @@ -113,7 +116,10 @@ def _merge_mcp_config(mcp_config: dict, console, yes: bool) -> str | None:
return None

servers["reaper"] = mcp_config["mcpServers"]["reaper"]
target.write_text(json.dumps(existing, indent=2) + "\n")
# Atomic write: temp file + rename
tmp_path = str(target) + ".tmp"
Path(tmp_path).write_text(json.dumps(existing, indent=2) + "\n")
os.replace(tmp_path, str(target))
return str(target)


Expand Down Expand Up @@ -168,6 +174,7 @@ def setup_reaper(install_dir: str | None, json_output: bool) -> None:
["git", "-C", str(install_path), "remote", "get-url", "origin"],
capture_output=True,
text=True,
timeout=10,
)
remote_url = result.stdout.strip()
except Exception:
Expand All @@ -178,6 +185,11 @@ def setup_reaper(install_dir: str | None, json_output: bool) -> None:
expected_remote in remote_url or "fadelabs/reaper-mcp" in remote_url
)
if remote_url and not is_fadelabs:
if not (install_path / ".git").is_dir():
raise click.ClickException(
f"{install_path} exists but is not a git repository. "
"Remove it manually or choose a different --install-dir."
)
shutil.rmtree(install_path)

if install_path.exists():
Expand Down
28 changes: 24 additions & 4 deletions src/phantom/comparison/match.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ def match_to_reference(
reference_path = validate_input_path(reference_path)

try:
import matchering as mg
import warnings

with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)
import matchering as mg
except ImportError:
raise DependencyMissingError(
package="Matchering",
Expand All @@ -56,13 +60,23 @@ def match_to_reference(
f"Reference file not found: {os.path.basename(reference_path)}"
)

if os.path.exists(output_path):
# Atomic existence check: lock file prevents concurrent writes to same output
lock_path = output_path + ".lock"
try:
lock_fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
raise AnalysisError(
f"Output file already exists: {os.path.basename(output_path)}. "
"Choose a different output path to avoid overwriting."
f"Output path is locked by another process: {os.path.basename(output_path)}. "
"Try again shortly or choose a different output path."
)

try:
if os.path.exists(output_path):
raise AnalysisError(
f"Output file already exists: {os.path.basename(output_path)}. "
"Choose a different output path to avoid overwriting."
)

before_audio = load_audio(target_path)
before_loudness = analyze_loudness(before_audio)
before_spectrum = analyze_spectrum(before_audio)
Expand Down Expand Up @@ -116,3 +130,9 @@ def _diff_metric(before_val, after_val) -> MetricDiff:
raise
except Exception as exc:
raise AnalysisError(f"Reference matching failed: {exc}") from exc
finally:
os.close(lock_fd)
try:
os.unlink(lock_path)
except OSError:
pass
24 changes: 20 additions & 4 deletions src/phantom/separation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

import hashlib
import os
import re
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout

from pydantic import BaseModel

Expand Down Expand Up @@ -94,17 +96,31 @@ def separate_stems(input_path: str, output_dir: str) -> SeparationResult:
ref = wav.mean(0)
wav = (wav - ref.mean()) / ref.std()

# Step 5: Run separation
with torch.no_grad():
sources = apply_model(model, wav[None], progress=False)
# Step 5: Run separation (with timeout to prevent indefinite hangs)
_SEPARATION_TIMEOUT = 600 # 10 minutes max for any file

def _run_model():
with torch.no_grad():
return apply_model(model, wav[None], progress=False)

with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_run_model)
try:
sources = future.result(timeout=_SEPARATION_TIMEOUT)
except FuturesTimeout:
raise AnalysisError(
f"Source separation timed out after {_SEPARATION_TIMEOUT}s. "
"Try a shorter audio file."
)
sources = sources[0]
sources = sources * ref.std() + ref.mean()

# Step 6: Save each stem as WAV (per D-01, D-02)
_SAFE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
result = {}
for i, stem_name in enumerate(model.sources):
safe_name = os.path.basename(stem_name)
if not safe_name or safe_name.startswith("."):
if not _SAFE_NAME_RE.match(safe_name):
safe_name = f"stem_{int(hashlib.md5(stem_name.encode()).hexdigest()[:8], 16) % 10000}"
stem_path = os.path.join(output_dir, f"{safe_name}.wav")
real_stem = os.path.realpath(stem_path)
Expand Down
7 changes: 5 additions & 2 deletions src/phantom/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -503,8 +503,11 @@ def main():
return
if "--tools" in sys.argv:
try:
tools = sorted(t.name for t in mcp._tool_manager._tools.values())
except AttributeError:
if hasattr(mcp, "list_tools"):
tools = sorted(t.name for t in mcp.list_tools())
else:
tools = sorted(t.name for t in mcp._tool_manager._tools.values())
except (AttributeError, TypeError):
print(
"Cannot list tools — FastMCP version may be incompatible",
file=sys.stderr,
Expand Down