Skip to content
Closed
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: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ combined selection looks tight for your RAM) and Stable Audio 3 (generated pads
and tracks) land in the app-owned `~/Library/Application Support/LSDJ`
(`MAGENTA_HOME` / `SA3_MLX_HOME` override the locations). `just migrate-models`
relocates an existing install—including one under the previous app name—into
that folder without re-downloading.
that folder without re-downloading. The Rust host owns the cross-platform
config/data/cache/assets/staging mapping and passes it explicitly to Python; see
[the platform path contract](docs/platform-paths.md).

## Run

Expand Down
22 changes: 11 additions & 11 deletions backend/lsdj/loras.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Stable Audio 3 LoRA adapter registry — the read side (issue #66, ADR-0028).

Adapters live on disk under the app-owned data dir, one directory per
adapter, organised by the DiT family they ride:
Adapters live under the host-resolved asset root, one directory per adapter,
organised by the DiT family they ride:

~/Library/Application Support/LSDJ/sa3-loras/<base>/<slug>/
$SA3_LORAS_HOME/<base>/<slug>/

``base`` is ``small`` (the 1024-wide sm-sfx / sm-music DiTs) or ``medium``
(the 1536-wide track DiT). An adapter directory holds its ``.safetensors``
Expand All @@ -18,6 +18,8 @@
import pathlib
import re

from . import runtime_paths

# The two DiT families an adapter can ride, and which generation kind uses
# which. sm-sfx and sm-music share one architecture, so a "small" adapter
# applies to both kinds; the medium DiT is the track engine (sa3.KINDS).
Expand Down Expand Up @@ -47,15 +49,13 @@ class UnknownAdapter(Exception):
def loras_dir(
env: dict | None = None, home: pathlib.Path | None = None
) -> pathlib.Path:
"""The registry root. $SA3_LORAS_HOME wins (tests, dev overrides);
otherwise the app-owned data dir, beside the SA3 checkout. Mirrors the
Rust `loras::loras_dir`."""
"""The registry root explicitly supplied by the Rust host."""
env = os.environ if env is None else env
home = pathlib.Path.home() if home is None else home
override = env.get("SA3_LORAS_HOME", "")
if override:
return pathlib.Path(override).expanduser()
return home / "Library" / "Application Support" / "LSDJ" / "sa3-loras"
del home # retained for API compatibility; platform paths come from Rust.
root = runtime_paths.loras_home(env)
if root is None:
raise RuntimeError("LSDJ asset roots were not supplied by the desktop host")
return root


def _adapter_file(adapter_dir: pathlib.Path) -> pathlib.Path | None:
Expand Down
63 changes: 63 additions & 0 deletions backend/lsdj/runtime_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Filesystem roots supplied by the native Rust host.

The desktop host is the only component that knows platform conventions. Python
services consume these explicit values and never reconstruct Windows, XDG, or
macOS locations from a user home directory. Compatibility variables remain for
the current upstream runtimes, but they are populated by the same Rust contract.
"""

import os
import pathlib
import sys
from collections.abc import Mapping


def _path(env: Mapping[str, str], name: str) -> pathlib.Path | None:
value = env.get(name, "")
return pathlib.Path(value) if value else None


def config_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
return _path(os.environ if env is None else env, "LSDJ_CONFIG_HOME")


def data_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
return _path(os.environ if env is None else env, "LSDJ_DATA_HOME")


def cache_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
return _path(os.environ if env is None else env, "LSDJ_CACHE_HOME")


def assets_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
return _path(os.environ if env is None else env, "LSDJ_ASSETS_HOME")


def staging_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
return _path(os.environ if env is None else env, "LSDJ_STAGING_HOME")


def sa3_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
env = os.environ if env is None else env
override = _path(env, "SA3_MLX_HOME")
if override is not None:
return override
assets = assets_home(env)
return None if assets is None else assets / "stable-audio-3"


def loras_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
env = os.environ if env is None else env
override = _path(env, "SA3_LORAS_HOME")
if override is not None:
return override
assets = assets_home(env)
return None if assets is None else assets / "sa3-loras"


def venv_python(venv: pathlib.Path, *, platform: str | None = None) -> pathlib.Path:
"""Return a venv interpreter as a path/argv item, never a shell string."""
platform = sys.platform if platform is None else platform
if platform == "win32":
return venv / "Scripts" / "python.exe"
return venv / "bin" / "python"
36 changes: 17 additions & 19 deletions backend/lsdj/sa3.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import tempfile
from collections.abc import Sequence

from . import runtime_paths

# CLI vocabulary of scripts/sa3_mlx.py at the pinned commit (sa3-pin.json).
# Pads use the small DiTs with the SAME-S decoder; tracks (M19, ADR-0013)
# the medium DiT, which pairs with SAME-L.
Expand Down Expand Up @@ -89,29 +91,25 @@ class GenerationFailed(Exception):
WARMED_STAMP = ".lsdj-warmed"


def _checkout_candidates(env: dict, home: pathlib.Path) -> list[pathlib.Path]:
"""Checkout roots to probe, in order. $SA3_MLX_HOME wins (pointing at the
checkout root); otherwise the app-owned data dir, where the in-app installer
puts the checkout. Mirrors the Rust `models::sa3_candidates`."""
candidates = []
override = env.get("SA3_MLX_HOME", "")
if override:
candidates.append(pathlib.Path(override).expanduser())
candidates.append(
home / "Library" / "Application Support" / "LSDJ" / "stable-audio-3"
)
return candidates
def _checkout_candidates(env: dict) -> list[pathlib.Path]:
"""The checkout root explicitly supplied by the Rust host.

No platform fallback lives here: independently rebuilding a macOS/XDG/
Windows location is precisely how the two sides drifted before issue #107.
"""
checkout = runtime_paths.sa3_home(env)
return [] if checkout is None else [checkout]


def resolve_mlx_dir(
env: dict | None = None, home: pathlib.Path | None = None
) -> pathlib.Path | None:
"""First checkout whose optimized/mlx has a venv and the CLI script."""
env = os.environ if env is None else env
home = pathlib.Path.home() if home is None else home
for checkout in _checkout_candidates(env, home):
del home # retained for API compatibility; platform paths come from Rust.
for checkout in _checkout_candidates(env):
mlx_dir = checkout / "optimized" / "mlx"
python = mlx_dir / ".venv" / "bin" / "python"
python = runtime_paths.venv_python(mlx_dir / ".venv")
script = mlx_dir / "scripts" / "sa3_mlx.py"
if python.is_file() and script.is_file():
return mlx_dir
Expand All @@ -133,16 +131,16 @@ def readiness(env: dict | None = None, home: pathlib.Path | None = None) -> dict
Rust `model_status` mirrors this exact logic and these exact identifiers.
"""
env = os.environ if env is None else env
home = pathlib.Path.home() if home is None else home
del home # retained for API compatibility; platform paths come from Rust.

first_with_mlx: tuple[pathlib.Path, pathlib.Path] | None = None
for checkout in _checkout_candidates(env, home):
for checkout in _checkout_candidates(env):
mlx_dir = checkout / "optimized" / "mlx"
if not mlx_dir.is_dir():
continue
if first_with_mlx is None:
first_with_mlx = (checkout, mlx_dir)
python = mlx_dir / ".venv" / "bin" / "python"
python = runtime_paths.venv_python(mlx_dir / ".venv")
script = mlx_dir / "scripts" / "sa3_mlx.py"
if not (python.is_file() and script.is_file()):
continue
Expand Down Expand Up @@ -191,7 +189,7 @@ async def generate(
with tempfile.TemporaryDirectory(prefix="sa3-") as tmp:
out_path = pathlib.Path(tmp) / "out.wav"
argv = [
str(mlx_dir / ".venv" / "bin" / "python"),
str(runtime_paths.venv_python(mlx_dir / ".venv")),
str(mlx_dir / "scripts" / "sa3_mlx.py"),
"--prompt",
prompt,
Expand Down
10 changes: 7 additions & 3 deletions backend/tests/test_loras.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ def test_env_override_wins(self, tmp_path):
== tmp_path / "elsewhere"
)

def test_defaults_to_the_app_support_home(self, tmp_path):
def test_uses_the_host_supplied_assets_home(self, tmp_path):
assert (
loras.loras_dir(env={}, home=tmp_path)
== tmp_path / "Library" / "Application Support" / "LSDJ" / "sa3-loras"
loras.loras_dir(env={"LSDJ_ASSETS_HOME": str(tmp_path / "assets")})
== tmp_path / "assets" / "sa3-loras"
)

def test_refuses_to_guess_a_platform_home(self):
with pytest.raises(RuntimeError, match="desktop host"):
loras.loras_dir(env={})


class TestResolve:
def test_resolves_an_installed_adapter(self, tmp_path):
Expand Down
50 changes: 50 additions & 0 deletions backend/tests/test_runtime_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""The Rust→Python storage and executable-layout contract."""

import pathlib

from lsdj import runtime_paths


def test_all_roots_preserve_spaces_and_non_ascii():
env = {
"LSDJ_CONFIG_HOME": "/profiles/DJ Name/音楽/config",
"LSDJ_DATA_HOME": "/profiles/DJ Name/音楽/data",
"LSDJ_CACHE_HOME": "/profiles/DJ Name/音楽/cache",
"LSDJ_ASSETS_HOME": "/profiles/DJ Name/音楽/assets",
"LSDJ_STAGING_HOME": "/profiles/DJ Name/音楽/staging",
}
assert runtime_paths.config_home(env) == pathlib.Path(env["LSDJ_CONFIG_HOME"])
assert runtime_paths.data_home(env) == pathlib.Path(env["LSDJ_DATA_HOME"])
assert runtime_paths.cache_home(env) == pathlib.Path(env["LSDJ_CACHE_HOME"])
assert runtime_paths.assets_home(env) == pathlib.Path(env["LSDJ_ASSETS_HOME"])
assert runtime_paths.staging_home(env) == pathlib.Path(env["LSDJ_STAGING_HOME"])
assert (
runtime_paths.sa3_home(env)
== pathlib.Path(env["LSDJ_ASSETS_HOME"]) / "stable-audio-3"
)
assert (
runtime_paths.loras_home(env)
== pathlib.Path(env["LSDJ_ASSETS_HOME"]) / "sa3-loras"
)


def test_compatibility_overrides_win_without_home_guessing():
env = {
"LSDJ_ASSETS_HOME": "/host/assets",
"SA3_MLX_HOME": "/custom/SA 3",
"SA3_LORAS_HOME": "/custom/适配器",
}
assert runtime_paths.sa3_home(env) == pathlib.Path("/custom/SA 3")
assert runtime_paths.loras_home(env) == pathlib.Path("/custom/适配器")
assert runtime_paths.sa3_home({}) is None
assert runtime_paths.loras_home({}) is None


def test_venv_interpreter_layout_is_platform_specific_and_structured():
venv = pathlib.Path("/profiles/DJ Name/模型/.venv")
assert (
runtime_paths.venv_python(venv, platform="win32")
== venv / "Scripts" / "python.exe"
)
assert runtime_paths.venv_python(venv, platform="linux") == venv / "bin" / "python"
assert runtime_paths.venv_python(venv, platform="darwin") == venv / "bin" / "python"
23 changes: 9 additions & 14 deletions backend/tests/test_sa3.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,25 +61,20 @@ def test_env_override_wins(self, tmp_path):
)
assert resolved == mlx_dir

def test_resolves_the_app_support_home(self, tmp_path):
# In-app installs (and `just setup-sa3`) put the checkout in the app-owned
# data dir — the only non-override candidate.
mlx_dir = make_checkout(
tmp_path / "Library" / "Application Support" / "LSDJ" / "stable-audio-3",
SUCCESS_STUB,
)
assert sa3.resolve_mlx_dir(env={}, home=tmp_path) == mlx_dir
def test_resolves_the_host_supplied_assets_home(self, tmp_path):
assets = tmp_path / "DJ Name" / "模型 assets"
mlx_dir = make_checkout(assets / "stable-audio-3", SUCCESS_STUB)
assert sa3.resolve_mlx_dir(env={"LSDJ_ASSETS_HOME": str(assets)}) == mlx_dir

def test_checkout_without_venv_is_skipped(self, tmp_path):
checkout = (
tmp_path / "Library" / "Application Support" / "LSDJ" / "stable-audio-3"
)
assets = tmp_path / "assets"
checkout = assets / "stable-audio-3"
(checkout / "optimized" / "mlx" / "scripts").mkdir(parents=True)
(checkout / "optimized" / "mlx" / "scripts" / "sa3_mlx.py").write_text("#")
assert sa3.resolve_mlx_dir(env={}, home=tmp_path) is None
assert sa3.resolve_mlx_dir(env={"LSDJ_ASSETS_HOME": str(assets)}) is None

def test_nothing_resolves_to_none(self, tmp_path):
assert sa3.resolve_mlx_dir(env={}, home=tmp_path) is None
assert sa3.resolve_mlx_dir(env={}) is None


@pytest.fixture
Expand Down Expand Up @@ -211,7 +206,7 @@ def test_timeout_scales_with_the_requested_length(self):

def test_no_checkout_raises_unavailable(self, monkeypatch, tmp_path):
monkeypatch.delenv("SA3_MLX_HOME", raising=False)
monkeypatch.setattr(sa3.pathlib.Path, "home", staticmethod(lambda: tmp_path))
monkeypatch.setenv("LSDJ_ASSETS_HOME", str(tmp_path / "assets"))
with pytest.raises(sa3.GenerationUnavailable):
asyncio.run(sa3.generate("anything", 3.0, "sfx"))

Expand Down
48 changes: 48 additions & 0 deletions docs/platform-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Platform filesystem contract

The Rust desktop host resolves LSDJ's filesystem roots once during Tauri setup,
before it starts the deck sidecars, generation server, watchers, or installers.
Python services receive the resolved paths through environment variables and do
not derive platform locations from a home directory.

| Ownership | macOS | Windows | Linux |
| --- | --- | --- | --- |
| Configuration | `~/Library/Application Support/works.protocol.lsdj` | `%LOCALAPPDATA%\LSDJ\config` | `$XDG_CONFIG_HOME/lsdj` |
| Durable user data | `~/Documents/LSDJ` | `%LOCALAPPDATA%\LSDJ\data` | `$XDG_DATA_HOME/lsdj` |
| Disposable cache | `~/Library/Caches/works.protocol.lsdj` | `%LOCALAPPDATA%\LSDJ\cache` | `$XDG_CACHE_HOME/lsdj` |
| Downloaded assets | `~/Library/Application Support/LSDJ` | `%LOCALAPPDATA%\LSDJ\assets` | `$XDG_DATA_HOME/lsdj/assets` |
| Install staging | `~/Library/Application Support/LSDJ/.staging` | `%LOCALAPPDATA%\LSDJ\staging` | `$XDG_DATA_HOME/lsdj/staging` |

On Linux, absent or invalid XDG variables use the standard fallbacks
`~/.config`, `~/.local/share`, and `~/.cache`. On Windows, every root is
non-roaming and the short `LSDJ` directory deliberately avoids consuming path
budget when long-path support is disabled. Staging and downloaded assets always
share a filesystem so a validated install can be promoted atomically.

The host exports `LSDJ_CONFIG_HOME`, `LSDJ_DATA_HOME`, `LSDJ_CACHE_HOME`,
`LSDJ_ASSETS_HOME`, and `LSDJ_STAGING_HOME`. It also supplies the current
compatibility variables `MAGENTA_HOME`, `SA3_MLX_HOME`, and `SA3_LORAS_HOME`;
explicit developer/user values for those three are captured into the contract
at startup. Paths are passed as native process-environment values and executable
arguments, not interpolated into shell command strings.

## macOS compatibility and migration

The contract preserves the current visible locations: generated songs and
samples remain in Documents, model assets remain in Application Support, and
settings/MCP credentials remain under the bundle identifier. Startup retains
the existing one-time migrations from `LSDJai` and from
`~/Documents/Magenta/magenta-rt-v2`.

Each migration is an atomic same-filesystem rename attempted only when the
destination does not exist. A restart sees the destination and does nothing. If
preparing or renaming fails, the process contract points the relevant backend at
the old directory for that run, so a migration failure cannot hide an installed
model or adapter.

## Virtual environments

Virtual-environment interpreters are resolved centrally as `bin/python` on
macOS/Linux and `Scripts/python.exe` on Windows. The interpreter and each
argument remain separate process arguments, including when a profile path
contains spaces or non-ASCII characters.
Loading