diff --git a/.github/workflows/tflite-cross-platform.yml b/.github/workflows/tflite-cross-platform.yml new file mode 100644 index 0000000..0bdb3fc --- /dev/null +++ b/.github/workflows/tflite-cross-platform.yml @@ -0,0 +1,57 @@ +name: TFLite cross-platform + +# The optimized/tflite release must run identically on macOS, Linux, and +# Windows. This checks, on every OS ai-edge-litert ships wheels for: +# byte-compilation of the scripts, LiteRT importability, the weights +# link-or-copy fallback (no symlink privilege needed on Windows), console +# encoding under the runner's native code page, and --play dispatch. +# NO model downloads happen in CI. + +on: + push: + paths: + - "optimized/tflite/**" + - ".github/workflows/tflite-cross-platform.yml" + pull_request: + paths: + - "optimized/tflite/**" + - ".github/workflows/tflite-cross-platform.yml" + +jobs: + test: + strategy: + fail-fast: false + # Exactly the set ai-edge-litert has wheels for: manylinux x86_64 + + # aarch64, macosx_12_0_arm64, win_amd64. No macOS-Intel (macos-13) and + # no Windows-ARM runners — those combos have no wheel. + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install runtime requirements + run: python -m pip install -r optimized/tflite/requirements.txt + + - name: Byte-compile all scripts + run: | + python -m py_compile \ + optimized/tflite/scripts/sa3_tflite.py \ + optimized/tflite/scripts/weights.py \ + optimized/tflite/scripts/install.py \ + optimized/tflite/scripts/examples.py \ + optimized/tflite/scripts/test_windows_compat.py \ + optimized/tflite/models/defs/tflite_pipeline.py + + - name: LiteRT interpreter imports + run: python -c "from ai_edge_litert.interpreter import Interpreter" + + - name: Windows-compat test suite + run: python optimized/tflite/scripts/test_windows_compat.py diff --git a/optimized/tflite/.gitattributes b/optimized/tflite/.gitattributes new file mode 100644 index 0000000..066e784 --- /dev/null +++ b/optimized/tflite/.gitattributes @@ -0,0 +1,2 @@ +# Batch files must keep CRLF line endings on every checkout (cmd.exe quirk-proofing). +*.bat text eol=crlf diff --git a/optimized/tflite/README.md b/optimized/tflite/README.md index cdf9d3d..9c0d06b 100644 --- a/optimized/tflite/README.md +++ b/optimized/tflite/README.md @@ -4,7 +4,8 @@ Portable CPU inference for **Stable Audio 3** — the LiteRT/TFLite sibling of t [MLX](../mlx) (Apple Silicon) and [TensorRT](../tensorRT) (NVIDIA GPU) releases. No PyTorch, transformers, or stable-audio-tools at runtime — just `ai_edge_litert` (LiteRT) driving fully self-contained `.tflite` graphs through the XNNPACK CPU -delegate. Runs anywhere LiteRT runs: **macOS / Linux, x86 / ARM**. +delegate. Runs anywhere LiteRT runs: **macOS / Linux / Windows, x86 / ARM** +(Windows is x64-only — see [Windows](#windows)). ## Quick Install @@ -133,6 +134,34 @@ End-to-end on a fresh machine: **~10 seconds** + weight downloads. Portable CPU (no GPU required). Python 3.9+. `./install.sh --python 3.12` to pin a different Python. +### Windows + +The stack runs natively on Windows x64 — no WSL needed. `ai-edge-litert` +ships `win_amd64` wheels for Python **3.10–3.13** (x64 only; no Windows-ARM +wheels), so install a Python in that range from +[python.org](https://www.python.org/downloads/) (check *"Add python.exe to +PATH"*), then from `optimized\tflite\`: + +```bat +install.bat :: one-time setup (python -m venv + pip) +sa3.bat --prompt "lofi house loop" --dit sm-music --decoder same-s +``` + +`install.bat` / `sa3.bat` are the Windows twins of `./install.sh` / `./sa3` +(plain venv + pip; uv not required). Notes: + +- **ffmpeg is optional** — only needed for mp3 / 24-bit / non-44.1 kHz + `--init-audio` inputs: `winget install ffmpeg` (or `choco install ffmpeg`). +- **Symlinks**: downloaded weights are exposed via symlinks where possible; + without Developer Mode, Windows disallows creating them, so the downloader + automatically falls back to a hardlink (zero-copy) or a plain copy. No + action needed either way. +- **Long paths**: the HuggingFace cache nests deeply — if downloads fail with + path-length errors, enable Windows long paths once (admin PowerShell): + `Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name LongPathsEnabled -Value 1` + (the `LongPathsEnabled` registry switch), then restart the terminal. +- `--play` uses the stdlib `winsound` player (macOS uses `afplay`). + ## Run `./sa3` is a thin shell wrapper around `.venv/bin/python scripts/sa3_tflite.py @@ -161,7 +190,7 @@ pin a different Python. ./sa3 --prompt "ambient drone" --cfg 3.0 --negative-prompt "drums, vocals" \ --dit sm-music --decoder same-s --out drone.wav -# Generate + play immediately (afplay; Ctrl-C stops both) +# Generate + play immediately (afplay/winsound/aplay; Ctrl-C stops both) ./sa3 --prompt "rainforest" --dit sm-sfx --decoder same-s --play # All options + categorised examples @@ -215,7 +244,7 @@ For sub-realtime latency on a supported device, prefer the GPU siblings: | `--threads` | 8 | XNNPACK CPU threads (all TFLite models run on CPU) | | `--free-models` | on | Free each model after its last use; `--no-free-models` keeps them resident | | `--out` / `-o` | (auto) | Relative → `output/`; absolute → as-is. 16-bit PCM stereo @ 44.1 kHz, trimmed to exactly `--seconds` | -| `--play` | off | After writing, play via `afplay` (macOS); Ctrl-C stops both | +| `--play` | off | After writing, play the WAV: `afplay` (macOS) / `winsound` (Windows) / `aplay` (Linux); Ctrl-C stops both | All `.tflite` models are **fp32** except T5Gemma, which is **fp16** (numerically lossless there). There is no dtype knob: on CPU, int8/fp16 weights buy size, not @@ -228,6 +257,7 @@ speed (XNNPACK dequantizes to fp32 to matmul), and int8 costs quality on the DiT sa3_tflite/ ├── sa3 ← shell wrapper (use this) ├── install.sh ← uv bootstrap (run once) +├── sa3.bat / install.bat ← Windows twins of sa3 / install.sh ├── bootstrap.sh ← one-line curl installer ├── README.md ├── requirements.txt ← ai_edge_litert, numpy, sentencepiece, soundfile, huggingface_hub diff --git a/optimized/tflite/install.bat b/optimized/tflite/install.bat new file mode 100644 index 0000000..1c54ef4 --- /dev/null +++ b/optimized/tflite/install.bat @@ -0,0 +1,43 @@ +@echo off +rem SA3 TFLite installer for Windows — the .bat twin of install.sh. +rem +rem Mirrors install.sh's steps with plain stdlib tooling (no uv required): +rem 1. create a project-local .venv\ (python -m venv) +rem 2. pip install -r requirements.txt into it +rem 3. hand off to scripts\install.py for the weight-download prompt +rem +rem Extra args are forwarded to install.py, e.g.: +rem install.bat --download sm-music +rem +rem Note: ai-edge-litert ships win_amd64 wheels for Python 3.10-3.13 — +rem use a Python in that range (3.11 recommended). +setlocal +set "SCRIPT_DIR=%~dp0" + +where python >nul 2>nul +if errorlevel 1 ( + echo error: python not found on PATH. 1>&2 + echo Install Python 3.10-3.13 from https://www.python.org/downloads/ 1>&2 + echo ^(check "Add python.exe to PATH" in the installer^) and re-run install.bat. 1>&2 + exit /b 1 +) + +if exist "%SCRIPT_DIR%.venv\Scripts\python.exe" ( + echo Reusing existing .venv\ +) else ( + echo Creating virtual environment at .venv\ ... + python -m venv "%SCRIPT_DIR%.venv" + if errorlevel 1 exit /b 1 +) + +echo Installing dependencies ^(pip install -r requirements.txt^) ... +"%SCRIPT_DIR%.venv\Scripts\python.exe" -m pip install --upgrade pip +if errorlevel 1 exit /b 1 +"%SCRIPT_DIR%.venv\Scripts\python.exe" -m pip install -r "%SCRIPT_DIR%requirements.txt" +if errorlevel 1 exit /b 1 + +rem install.py's pip step is skipped — deps were just installed above +rem (same contract install.sh uses). +set "INSTALL_SKIP_PIP=1" +"%SCRIPT_DIR%.venv\Scripts\python.exe" "%SCRIPT_DIR%scripts\install.py" %* +exit /b %ERRORLEVEL% diff --git a/optimized/tflite/sa3.bat b/optimized/tflite/sa3.bat new file mode 100644 index 0000000..f9c148b --- /dev/null +++ b/optimized/tflite/sa3.bat @@ -0,0 +1,22 @@ +@echo off +rem Windows twin of the `sa3` bash wrapper — runs scripts\sa3_tflite.py via the +rem project-local .venv (created by install.bat) so no activation is needed. +rem Falls back to whatever `python` is on PATH if .venv\ doesn't exist yet. +rem +rem Usage: sa3.bat --prompt "lofi house" --dit sm-music --decoder same-s --out a.wav +setlocal +set "SCRIPT_DIR=%~dp0" + +if not exist "%SCRIPT_DIR%scripts\sa3_tflite.py" ( + echo error: scripts\sa3_tflite.py not found - repo files are missing or moved. 1>&2 + exit /b 1 +) + +set "PY=%SCRIPT_DIR%.venv\Scripts\python.exe" +if not exist "%PY%" ( + echo warning: .venv\ not found - run install.bat for one-time setup. Trying `python` from PATH. 1>&2 + set "PY=python" +) + +"%PY%" "%SCRIPT_DIR%scripts\sa3_tflite.py" %* +exit /b %ERRORLEVEL% diff --git a/optimized/tflite/scripts/examples.py b/optimized/tflite/scripts/examples.py index d6a24a2..e988e04 100644 --- a/optimized/tflite/scripts/examples.py +++ b/optimized/tflite/scripts/examples.py @@ -16,6 +16,20 @@ import sys from pathlib import Path +# Windows consoles default to a legacy code page (cp1252/cp437) that can't encode +# the emoji/box-drawing characters below. Force UTF-8 with replacement; no-op on +# macOS/Linux. (sa3_tflite.py/install.py do this too — reconfigure is idempotent.) +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") + except AttributeError: + pass # exotic stream without reconfigure() — leave as-is + +# Enable ANSI/VT escape processing in legacy Windows consoles (cmd.exe); +# harmless elsewhere and on modern Windows Terminal. +if os.name == "nt": + os.system("") + # This file lives in /scripts/. SCRIPT_DIR points at the project # root (where ./sa3, ./install.sh, models/, .venv/ live). SCRIPT_DIR = Path(__file__).resolve().parent.parent @@ -41,9 +55,16 @@ def _have(binary: str) -> bool: def _py_invocation() -> tuple[str, str, bool]: """Return (command-to-print, tip-or-empty, is_wrapper). - Prefer the ./sa3 wrapper if present, else `.venv/bin/python scripts/sa3_tflite.py`, - else fall back to a bare `python scripts/sa3_tflite.py`. + Prefer the sa3 wrapper if present (./sa3 on POSIX, sa3.bat on Windows), else + the .venv python + scripts/sa3_tflite.py, else a bare `python scripts/sa3_tflite.py`. """ + if os.name == "nt": + if (SCRIPT_DIR / "sa3.bat").exists(): + return "sa3.bat", "the sa3.bat wrapper uses .venv automatically", True + venv_py = SCRIPT_DIR / ".venv" / "Scripts" / "python.exe" + if venv_py.exists(): + return str(venv_py.relative_to(SCRIPT_DIR)), "", False + return "python", "", False wrapper = SCRIPT_DIR / "sa3" if wrapper.exists() and os.access(wrapper, os.X_OK): return "./sa3", "the ./sa3 wrapper uses .venv automatically", True @@ -108,7 +129,7 @@ def cmd(args: str, comment: str = "") -> None: one_dec = "same-l" if one_dit == "medium" else "same-s" cmd(f'--prompt "ambient drone" --dit {one_dit} --decoder {one_dec} \\\n' f' --seconds 20 --out drone.wav --play', - "writes WAV + plays via afplay (Ctrl-C stops both)") + "writes WAV + plays it (afplay/winsound/aplay; Ctrl-C stops both)") # ── Audio-to-audio + inpaint ───────────────────────────────────── hdr("🎚️ Audio-to-audio & inpainting (requires an input WAV)") diff --git a/optimized/tflite/scripts/install.py b/optimized/tflite/scripts/install.py index d87ab7e..445b7a5 100644 --- a/optimized/tflite/scripts/install.py +++ b/optimized/tflite/scripts/install.py @@ -18,6 +18,19 @@ import sys from pathlib import Path +# Windows consoles default to a legacy code page (cp1252/cp437) that can't encode +# the ✓/→ status glyphs. Force UTF-8 with replacement; no-op on macOS/Linux. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") + except AttributeError: + pass # exotic stream without reconfigure() — leave as-is + +# Enable ANSI/VT escape processing in legacy Windows consoles (cmd.exe); +# harmless elsewhere and on modern Windows Terminal. +if os.name == "nt": + os.system("") + # This file lives in /scripts/. SCRIPT_DIR points at the project # root (where models/, requirements.txt, .venv/, sa3 wrapper live). SCRIPT_DIR = Path(__file__).resolve().parent.parent @@ -42,7 +55,9 @@ def check_environment() -> None: f"Re-run install.py with a Python {MIN_PY[0]}.{MIN_PY[1]}+ interpreter, e.g.:\n" f" /path/to/python3.11 install.py\n\n" f"On macOS: brew install python@3.11\n" - f"On Debian/Ubuntu: apt install python3.11\n", + f"On Debian/Ubuntu: apt install python3.11\n" + f"On Windows: install from https://www.python.org/downloads/ " + f"(check 'Add python.exe to PATH')\n", file=sys.stderr, ) sys.exit(1) @@ -136,7 +151,7 @@ def main() -> None: ensure_local(rel) else: step("No --download set — weights will auto-download on first ./sa3 use") - print(f" To pre-download instead, pass: {sys.executable.split('/')[-1]} install.py --download sm-music") + print(f" To pre-download instead, pass: {Path(sys.executable).name} install.py --download sm-music") print(f" or: ./install.sh --download sm-music,medium") from examples import print_example_commands, BOLD, GREEN, RESET diff --git a/optimized/tflite/scripts/sa3_tflite.py b/optimized/tflite/scripts/sa3_tflite.py index 82ae209..44262bc 100644 --- a/optimized/tflite/scripts/sa3_tflite.py +++ b/optimized/tflite/scripts/sa3_tflite.py @@ -28,10 +28,33 @@ on first use and symlink into models/tflite/ from the HF cache. See scripts/weights.py. """ from __future__ import annotations -import argparse, math, os, random, re, subprocess, sys, termios, time, tty, wave +import argparse, math, os, random, re, subprocess, sys, time, wave from pathlib import Path import numpy as np +# Windows consoles default to a legacy code page (cp1252/cp437) that can't encode +# the banners' box-drawing / emoji characters. Force UTF-8 with replacement so no +# print can crash the CLI. No-op on macOS/Linux, where stdout is UTF-8 already. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") + except AttributeError: + pass # exotic stream without reconfigure() (non-TextIOWrapper) — leave as-is + +# Enable ANSI/VT escape processing in legacy Windows consoles (cmd.exe). Calling +# os.system("") flips the console into VT mode; harmless elsewhere and on +# modern Windows Terminal, which has it on already. +if os.name == "nt": + os.system("") + +# The arrow-key picker below uses POSIX terminal APIs. Keep the non-interactive +# CLI usable on Windows, where those modules are not available. +try: + import termios + import tty +except ImportError: + termios = tty = None + REPO = Path(__file__).resolve().parent.parent # project root (scripts/ is one level down) sys.path.insert(0, str(REPO)) # so `from models.defs.* import` resolves sys.path.insert(0, str(REPO / "scripts")) # so `from weights import *` resolves @@ -104,7 +127,7 @@ def _arrow_pick(prompt: str, options: list[str], default: str | None = None) -> Up/Down to move, Enter to select, Ctrl-C to abort. Falls back to a numeric prompt when stdin isn't a TTY (piped input, CI, etc.). """ - if not sys.stdin.isatty(): + if termios is None or tty is None or not sys.stdin.isatty(): print(prompt) for i, o in enumerate(options): mark = "*" if o == default else " " @@ -196,8 +219,11 @@ def read_wav(path: str) -> np.ndarray: "-ar", str(SAMPLE_RATE), "-ac", "2", "-"], capture_output=True, check=True) except FileNotFoundError: + ffmpeg_hint = {"darwin": "brew install ffmpeg", + "win32": "winget install ffmpeg (or: choco install ffmpeg)", + }.get(sys.platform, "apt install ffmpeg") raise RuntimeError(f"{path}: unsupported WAV format. Install ffmpeg for 24/32-bit or " - f"non-44.1kHz input: brew install ffmpeg") + f"non-44.1kHz input: {ffmpeg_hint}") except subprocess.CalledProcessError as e: raise RuntimeError(f"{path}: ffmpeg failed — {e.stderr.decode().strip()}") raw = np.frombuffer(r.stdout, np.int16).astype(np.float32) / 32767.0 @@ -425,6 +451,28 @@ def valid_T_lat(seconds): return max(1, int(np.ceil(seconds * SAMPLE_RATE / SAMPLES_PER_LATENT))) +def _play_backend(platform: str | None = None) -> tuple[str, list[str] | None]: + """Pick the --play playback backend for `platform` (default: sys.platform) + WITHOUT executing anything. Split out of main() so it can be import-tested. + + Returns (kind, argv_prefix): + ("subprocess", ["afplay"]) macOS — argv_prefix + [wav_path] is run + ("subprocess", ["aplay", …]) Linux with alsa-utils installed + ("winsound", None) Windows — stdlib winsound.PlaySound (blocking) + ("none", None) no player available — caller prints the path + """ + plat = sys.platform if platform is None else platform + if plat == "darwin": + return ("subprocess", ["afplay"]) + if plat == "win32": + return ("winsound", None) + if plat.startswith("linux"): + from shutil import which + if which("aplay") is not None: + return ("subprocess", ["aplay", "-q"]) + return ("none", None) + + class _HelpfulParser(argparse.ArgumentParser): """argparse that prints full help (not just usage) when a flag is unknown / invalid, and tacks the shared example-commands block onto the end of -h / --help.""" @@ -524,7 +572,9 @@ def main(): help="Output WAV path. Relative → output/; absolute → as-is. " "If omitted, auto-named from the prompt + seed.") ap.add_argument("--play", action="store_true", - help="Play the WAV via macOS `afplay` after writing (blocking).") + help="Play the WAV after writing (blocking): `afplay` on macOS, " + "winsound on Windows, `aplay` on Linux (prints the path if " + "no player is available).") args = ap.parse_args() if args.steps < 1: ap.error(f"--steps must be ≥ 1 (got {args.steps})") @@ -785,9 +835,18 @@ def on_chunk(i, n): rule() if args.play: + kind, argv = _play_backend() try: - print(f" {bold('▶ playing')} {args.out} {dim('(Ctrl-C to stop)')}") - subprocess.run(["afplay", args.out], check=False) + if kind == "winsound": + print(f" {bold('▶ playing')} {args.out} {dim('(Ctrl-C to stop)')}") + import winsound + winsound.PlaySound(args.out, winsound.SND_FILENAME) # blocking, like afplay + elif kind == "subprocess": + print(f" {bold('▶ playing')} {args.out} {dim('(Ctrl-C to stop)')}") + subprocess.run(argv + [args.out], check=False) + else: + print(f" {bold('▶ play')} no audio player found ('aplay' missing) — " + f"file saved at {args.out}") except KeyboardInterrupt: print() diff --git a/optimized/tflite/scripts/test_windows_compat.py b/optimized/tflite/scripts/test_windows_compat.py new file mode 100644 index 0000000..00bbf15 --- /dev/null +++ b/optimized/tflite/scripts/test_windows_compat.py @@ -0,0 +1,187 @@ +"""Cross-platform (Windows/macOS/Linux) compatibility tests for the TFLite CLI. + +Run directly — stdlib unittest only, no pytest, NO model downloads, no network: + + python scripts/test_windows_compat.py + +Covers the three Windows-specific failure modes this release guards against: +1. weights.ensure_local materializes model files without symlink privileges + (symlink -> hardlink -> copy fallback chain); +2. the CLI's banners/emoji print cleanly under a legacy console code page + (the guarded sys.stdout.reconfigure(encoding="utf-8") at script top); +3. --play dispatches to the right playback backend per platform + (afplay / winsound / aplay) without executing anything. +""" +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +# Keep this test's own output safe under legacy Windows console code pages +# (the subprocess tests below spawn FRESH interpreters, so this does not mask them). +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") + except AttributeError: + pass + +SCRIPTS_DIR = Path(__file__).resolve().parent # /scripts +PROJECT_DIR = SCRIPTS_DIR.parent # +sys.path.insert(0, str(SCRIPTS_DIR)) # import weights / sa3_tflite / examples + +import weights # noqa: E402 + + +class TestEnsureLocalLinkOrCopy(unittest.TestCase): + """ensure_local must materialize the target on every OS, with or without + symlink privileges (Windows without Developer Mode raises WinError 1314).""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="sa3_citest_") + self.addCleanup(self._tmp.cleanup) + self.tmp = Path(self._tmp.name) + self.fake_cache = self.tmp / "hf_cache" + self.fake_cache.mkdir() + # Redirect the project root so nothing is written into the repo checkout. + self._orig_script_dir = weights.SCRIPT_DIR + weights.SCRIPT_DIR = self.tmp / "project" + self.addCleanup(lambda: setattr(weights, "SCRIPT_DIR", self._orig_script_dir)) + # Register a fake manifest entry + suppress the (non-ASCII) HF login tip + # so no in-process print depends on console encoding. + self.rel = "models/tflite/_citest/fake_model.bin" + weights.FLAT_MANIFEST[self.rel] = "tflite/_citest/fake_model.bin" + self.addCleanup(weights.FLAT_MANIFEST.pop, self.rel, None) + self._orig_tip = weights._LOGIN_TIP_SHOWN + weights._LOGIN_TIP_SHOWN = True + self.addCleanup(lambda: setattr(weights, "_LOGIN_TIP_SHOWN", self._orig_tip)) + self.payload = b"not a real tflite model \x00\x01\x02" * 100 + + def _fake_hf_hub_download(self, repo_id, filename): + self.assertEqual(repo_id, weights.REPO_ID) + cached = self.fake_cache / filename.replace("/", "__") + cached.write_bytes(self.payload) + return str(cached) + + def test_ensure_local_materializes_target(self): + """End-to-end: monkeypatched download -> target exists and is readable. + Uses whatever link mode the OS grants (symlink where privileged, + hardlink/copy otherwise) — all satisfy exists()/is_present().""" + import huggingface_hub + with mock.patch.object(huggingface_hub, "hf_hub_download", + self._fake_hf_hub_download): + target = weights.ensure_local(self.rel, verbose=False) + self.assertTrue(target.exists(), f"target not materialized: {target}") + self.assertEqual(target.read_bytes(), self.payload) + self.assertTrue(weights.is_present(self.rel)) + + def test_fallback_chain_needs_no_symlink_privilege(self): + """Force symlink_to to fail (as on Windows without Developer Mode, + WinError 1314) and verify the hardlink/copy fallback still materializes + a readable, non-symlink target. Runs identically on every OS.""" + cached = self.fake_cache / "src.bin" + cached.write_bytes(self.payload) + target = self.tmp / "project" / "models" / "linked.bin" + target.parent.mkdir(parents=True, exist_ok=True) + boom = OSError(1314, "A required privilege is not held by the client") + with mock.patch.object(type(target), "symlink_to", side_effect=boom): + weights._link_or_copy(cached, target) + self.assertTrue(target.exists()) + self.assertFalse(target.is_symlink()) + self.assertEqual(target.read_bytes(), self.payload) + + def test_copy_fallback_when_hardlink_also_fails(self): + """Symlink AND hardlink refused (e.g. cross-volume) -> plain copy.""" + cached = self.fake_cache / "src2.bin" + cached.write_bytes(self.payload) + target = self.tmp / "project" / "models" / "copied.bin" + target.parent.mkdir(parents=True, exist_ok=True) + with mock.patch.object(type(target), "symlink_to", + side_effect=OSError(1314, "no symlink privilege")), \ + mock.patch.object(weights, "_LINK_FALLBACK_SHOWN", True), \ + mock.patch("os.link", side_effect=OSError(17, "cross-device link")): + weights._link_or_copy(cached, target) + self.assertTrue(target.exists()) + self.assertFalse(target.is_symlink()) + self.assertEqual(target.read_bytes(), self.payload) + + +class TestConsoleEncoding(unittest.TestCase): + """The banners use box-drawing + emoji. Run the scripts in FRESH interpreters + with PYTHONUTF8/PYTHONIOENCODING stripped, so stdout is a pipe under the + runner's native default encoding (cp1252 on Windows without the in-script + reconfigure fix) — a UnicodeEncodeError would make these exit nonzero.""" + + @staticmethod + def _clean_env(): + env = os.environ.copy() + env.pop("PYTHONUTF8", None) + env.pop("PYTHONIOENCODING", None) + return env + + def test_sa3_tflite_help(self): + r = subprocess.run( + [sys.executable, str(SCRIPTS_DIR / "sa3_tflite.py"), "--help"], + capture_output=True, env=self._clean_env(), timeout=120) + self.assertEqual( + r.returncode, 0, + f"--help failed\nstdout:\n{r.stdout.decode('utf-8', 'replace')}\n" + f"stderr:\n{r.stderr.decode('utf-8', 'replace')}") + out = r.stdout.decode("utf-8", "replace") + self.assertIn("--prompt", out) + self.assertIn("--play", out) + + def test_examples_block(self): + # examples.py is a module (no __main__): render the full emoji block. + r = subprocess.run( + [sys.executable, "-c", + "import examples; examples.print_example_commands()"], + capture_output=True, env=self._clean_env(), cwd=str(SCRIPTS_DIR), + timeout=120) + self.assertEqual( + r.returncode, 0, + f"examples block failed\nstdout:\n{r.stdout.decode('utf-8', 'replace')}\n" + f"stderr:\n{r.stderr.decode('utf-8', 'replace')}") + self.assertIn("Generate audio", r.stdout.decode("utf-8", "replace")) + + +class TestPlayBackendDispatch(unittest.TestCase): + """--play platform dispatch, without playing anything.""" + + def _backend(self): + import sa3_tflite + return sa3_tflite._play_backend + + def test_darwin_uses_afplay(self): + self.assertEqual(self._backend()("darwin"), ("subprocess", ["afplay"])) + + def test_windows_uses_winsound(self): + kind, argv = self._backend()("win32") + self.assertEqual(kind, "winsound") + self.assertIsNone(argv) + if sys.platform == "win32": + import winsound # stdlib on Windows — the module the dispatcher relies on + self.assertTrue(hasattr(winsound, "PlaySound")) + + def test_linux_uses_aplay_or_prints_path(self): + kind, argv = self._backend()("linux") + if kind == "subprocess": + self.assertEqual(argv[0], "aplay") + else: + self.assertEqual((kind, argv), ("none", None)) + + def test_current_platform_resolves(self): + kind, argv = self._backend()() + self.assertIn(kind, ("subprocess", "winsound", "none")) + if kind == "subprocess": + self.assertIsInstance(argv, list) + else: + self.assertIsNone(argv) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/optimized/tflite/scripts/weights.py b/optimized/tflite/scripts/weights.py index 1439492..037d556 100644 --- a/optimized/tflite/scripts/weights.py +++ b/optimized/tflite/scripts/weights.py @@ -119,6 +119,39 @@ def _hf_token_configured() -> bool: _LOGIN_TIP_SHOWN = False +_LINK_FALLBACK_SHOWN = False + + +def _link_or_copy(cached: Path, target: Path) -> None: + """Expose the HF-cached file at `target`: symlink → hardlink → copy. + + A symlink keeps the HF cache canonical (one copy on disk) and is used + wherever possible. On Windows, creating symlinks requires Developer Mode + or admin rights (WinError 1314), so fall back to a hardlink (privilege- + free and zero-copy, but same-volume only), then to a plain copy (works + everywhere, at the cost of a second copy on disk). Unlike a symlink, a + hardlink/copy won't track future HF-cache updates — acceptable, because + downloads are content-addressed: a changed file resolves to a new cache + path, and `ensure_local` only ever links each target once. + """ + global _LINK_FALLBACK_SHOWN + try: + target.symlink_to(cached) + return + except OSError: + pass + import os + import shutil + try: + os.link(cached, target) # hardlink: no privilege needed, same volume only + mode = "hardlink" + except OSError: + shutil.copy2(cached, target) + mode = "copy" + if not _LINK_FALLBACK_SHOWN: + _LINK_FALLBACK_SHOWN = True + print(f" (symlinks unavailable on this system — materialized as a {mode} instead)") + def _show_hf_login_tip_once() -> None: """Print a one-time login suggestion if no HF token is configured. @@ -188,11 +221,14 @@ def ensure_local(local_rel_path: str, verbose: bool = True) -> Path: cached = hf_hub_download(repo_id=REPO_ID, filename=hf_filename) target.parent.mkdir(parents=True, exist_ok=True) - # Symlink keeps the HF cache canonical (one copy on disk) while exposing - # the file at the project-relative path the runtime expects. + # Expose the file at the project-relative path the runtime expects. + # Symlink when possible (keeps the HF cache canonical — one copy on disk); + # falls back to hardlink → copy on systems without symlink privileges + # (Windows without Developer Mode). The `exists() or is_symlink()` + # early-exits above and in is_present() are satisfied by every mode. if target.is_symlink(): target.unlink() - target.symlink_to(cached) + _link_or_copy(Path(cached), target) return target