初始提交:3 款纯音频游戏 + 共享引擎 + 完整文档与资产管线

- 共享引擎(app/src/main):GestureEngine 6 手势识别、AudioEngine 三层音频
  (ExoPlayer BGM 交叉淡入 / 对白 ducking / SoundPool 低延迟)、Game/GameEngine
  状态机接口、MainActivity 全屏封面 + TalkBack
- 《声命线》storyline:末世互动广播剧,3 幕 8 抉择点 4 结局
- 《声东击西》reflex:听声辨位反应,12 波战斗 + BGM 三档 BPM 变速
- 《暗室》escape:3x3 网格密室逃脱,钥匙→铁柜→保险丝→电箱→大门谜题链
- 文档:setup(从零搭建)/ architecture(架构)/ engine-api(引擎 API)/
  asset-pipeline(资产流水线)/ games/*(三款游戏设计文档)
- 资产管线:Qwen3-TTS 对白 + 程序合成音效/BGM + Mureka BGM 的 gen 脚本与台账,
  全部 ogg 资产入库可复现
- env.d 环境脚本:android/tts 入库;sfx/mureka 含密钥不入库,仅提交 .example.sh 模板
This commit is contained in:
SoundGame Dev
2026-08-29 13:00:34 +08:00
commit f0f0a9b50e
446 changed files with 2627836 additions and 0 deletions
@@ -0,0 +1,2 @@
# Batch files must keep CRLF line endings on every checkout (cmd.exe quirk-proofing).
*.bat text eol=crlf
@@ -0,0 +1,21 @@
# Model weights — pulled from HuggingFace, never committed here.
# (The SentencePiece tokenizer at models/tokenizer.model IS committed — it's
# small and the .tflite T5Gemma is encoder-only.)
*.tflite
# Generated audio — anywhere on the tree, including output/.
*.wav
*.mp3
output/
# LoRA adapter library (user .safetensors, scanned by the gradio dropdown) and
# the LoRA-merged DiT cache (patched multi-GB copies, regenerated on demand).
loras/
lora_cache/
# Python virtualenv created by install.sh
.venv/
# Python bytecode caches
__pycache__/
*.pyc
@@ -0,0 +1,416 @@
# sa3_tflite — Stable Audio 3 on CPU via LiteRT / TFLite
Portable CPU inference for **Stable Audio 3** — the LiteRT/TFLite sibling of the
[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 / Windows, x86 / ARM**
(Windows is x64-only — see [Windows](#windows)).
## Quick Install
One line on a fresh machine — installs everything and plays back ~30 seconds of
"Impending tribal, epic orchestral buildup":
```bash
curl -LsSf https://raw.githubusercontent.com/Stability-AI/stable-audio-3/main/optimized/tflite/bootstrap.sh | bash
```
Windows — stock PowerShell (no bash/git needed; the curl|bash line above requires Git Bash or WSL on Windows):
```powershell
irm https://raw.githubusercontent.com/Stability-AI/stable-audio-3/main/optimized/tflite/bootstrap.ps1 | iex
```
Already cloned the repo? Run from inside `optimized/tflite/`:
```bash
# macOS / Linux / Windows Git Bash
./install.sh # one-time setup
./sa3 --prompt "Impending tribal, epic orchestral buildup" --play # generates + plays
```
```bat
:: Windows cmd / PowerShell
install.bat
sa3.bat --prompt "Impending tribal, epic orchestral buildup" --play
:: PowerShell users can use .\sa3.ps1 instead of sa3.bat — same flags, and
:: Ctrl-C exits cleanly (cmd's "Terminate batch job (Y/N)?" prompt is a .bat quirk)
```
## Three models, four modes
| `--dit` | model | best for |
|------------|--------------------|--------------------------------|
| `sm-music` | sa3-sm-music (50 M block) | fast music generation |
| `sm-sfx` | sa3-sm-sfx (50 M block) | sound effects |
| `medium` | sa3-medium-ARC (1.4 B) | higher-quality music, slower |
| mode | flags | example |
|------------------|-----------------------------------------------|----------------------------------|
| text-to-audio | `--prompt P` | new clip from a description |
| audio-to-audio | `--prompt P --init-audio IN.wav --init-noise-level σ` | variation of an existing clip |
| inpainting | `--prompt P --init-audio IN.wav --inpaint-range "S,E"` | regenerate one section, keep rest |
| CFG + negative | `--cfg 3.0 --negative-prompt P_NEG` | steer toward / away from prompts |
```
prompt ─▶ T5Gemma encoder ─▶ DiT pingpong sampler ─▶ SAME-S/L decoder ─▶ WAV
▲
optional: encoder + init audio (audio-to-audio / inpaint)
```
## Precision variants (`--precision`)
Same flag as the TensorRT CLI's `--precision`; the values use the wXaY naming
(weights/activations bit-widths). One flag switches the DiT, the codec decoder,
and (for audio-to-audio / inpainting) the encoder together; T5Gemma stays
single-precision. All
variants keep the full feature set: variable length (any `--seconds`, odd or even
latent counts) and variable batch (batched or sequential CFG). Missing files
lazy-download from HuggingFace on first use.
| `--precision` | legacy name | size (sm DiT / medium DiT / codecs) | quality | CPU speed |
|---------------|-------------|-------------------------------------|---------|-----------|
| `fp32` (default) | — | 1.8 GB / 5.8 GB / 0.2–1.8 GB | reference | 1× — on CPU this is *also* the fast choice |
| `w16a32` | fp16 | 0.9 / 2.9 / 0.1–0.9 GB | ≈lossless (fp16 weights, fp32 activations; 62–75 dB per-forward) | 1.5–3× slower, model-dependent (XNNPACK dequantizes per-matmul) |
| `w8a32` | woint8 | 0.45 / 1.5 / 0.05–0.5 GB | GPTQ int8 weights — codecs transparent (40–46 dB); DiT gives a *different but plausible* sample | ≈fp32 |
| `w8a8-dyn` | dynint8 | 0.45 / 1.5 / 0.05–0.5 GB | lowest (int8 weights + activations, per-invoke dynamic scales) | fastest (~1.2–1.3×) |
*(Names follow the wXaY convention — weight/activation bit-widths, as in LLM releases:
quantization here touches the FULLY_CONNECTED weights; activations stay fp32 except
`w8a8-dyn`, whose int8×int8 matmuls are what make it the only faster-than-fp32 variant.
All bit-widths are per-channel weight grids; "16" is fp16 — there is no int16 variant.
Speed factors measured on an Apple M4 Pro MacBook Pro, XNNPACK, 8 threads.)*
Two things worth knowing, both counter-intuitive:
- **Quantization buys *size*, not speed, on CPU.** Weight-only int8 dequantizes
to fp32 before each matmul, so they run at fp32 speed; fp16 is *slower* than fp32.
Only `dynint8` (quantized activations → true int8 matmuls) is faster.
- **DiT quantization error compounds.** The 8-step sampler is chaotically sensitive:
per-step weight error turns into a *different* (still plausible) sample rather than
a noisy version of the fp32 one. Judge DiT precisions by ear; decoder precisions
by PSNR (they run once on a fixed latent).
One CFG note: batched and sequential CFG are bit-identical for `fp32`/`w8a32` and
inaudibly different (~80 dB) for `w16a32`, but under `w8a8-dyn` the batch=2
invoke shares activation-quantization scales across the cond/uncond rows, so batched
CFG yields a *different plausible sample* than sequential. Pass `--no-cfg-batched`
with `w8a8-dyn` when you need run-to-run reproducibility against sequential baselines.
```bash
# quarter-size models, same speed and near-identical quality on the decoder side
./sa3 --prompt "lofi house loop" --dit sm-music --decoder same-s --precision w8a32
# fastest CPU inference (quality tradeoff)
./sa3 --prompt "lofi house loop" --dit sm-music --decoder same-s --precision w8a8-dyn
```
**Encoder variants** (used by audio-to-audio / inpainting) exist at the same
precisions; int8 weights are GPTQ-calibrated on real audio, like the DiT and codec
(measured latent PSNR vs fp32, same-s / same-l): `w16a32` 66 / 71 dB (≈lossless),
`w8a32` 36 / 46 dB, `w8a8-dyn` 24 / 30 dB. For the most quality-sensitive
inpainting you can still pin `--encoder-precision fp32`.
`--dit-precision` / `--decoder-precision` / `--encoder-precision` override the shared
flag per component — useful because they react to quantization very differently (the
codec's precision maps directly to audio fidelity; the DiT's changes *which* sample
you get; the encoder's affects a2a/inpaint latents):
```bash
# fastest DiT, reference-quality codec
./sa3 --prompt "lofi house loop" --dit sm-music --decoder same-s \
--precision fp32 --dit-precision w8a8-dyn
```
## Install
```bash
./install.sh
```
`install.sh` is uv-based. On a fresh machine it will:
1. Install [uv](https://github.com/astral-sh/uv) via the official curl
installer if it's missing (prompts y/N; `-y` skips the prompt).
2. Create a project-local `.venv/` with managed Python 3.11.
3. `uv pip install` the runtime deps into the venv (much faster than pip).
4. Ask which DiT bundles to download from HuggingFace
(`stabilityai/stable-audio-3-optimized`). Each pick pulls its matching
audio codec; T5Gemma (the shared text encoder) is downloaded once.
Already-present weights are skipped.
End-to-end on a fresh machine: **~10 seconds** + weight downloads.
> Don't want to pre-pick bundles? Skip install entirely and just run
> `./sa3 --prompt …` — any missing model file is downloaded from HF on
> first use and symlinked into `models/tflite/` from the HuggingFace cache.
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
"$@"` that prompts to run `./install.sh` if uv or `.venv/` isn't set up.
```bash
# Text-to-audio
./sa3 --prompt "lofi house loop" --dit sm-music --decoder same-s --out lofi.wav
# Sound effects
./sa3 --prompt "footsteps on gravel" --dit sm-sfx --decoder same-s --out steps.wav
# Higher-quality music (medium DiT, chunked SAME-L decode)
./sa3 --prompt "A beautiful piano arpeggio grows into a cinematic climax" \
--dit medium --decoder same-l --seconds 30 --out piano.wav
# Audio-to-audio variation (σmax 0.4-0.8 typical)
./sa3 --prompt "jazz fusion with electric piano" --dit sm-music --decoder same-s \
--init-audio funk.wav --init-noise-level 0.7 --out funk_jazz.wav
# Inpaint seconds 4-7
./sa3 --prompt "explosive drum break" --dit sm-music --decoder same-s \
--init-audio funk.wav --inpaint-range "4,7" --out funk_drums.wav
# CFG + negative prompt
./sa3 --prompt "ambient drone" --cfg 3.0 --negative-prompt "drums, vocals" \
--dit sm-music --decoder same-s --out drone.wav
# Generate + play immediately (afplay/winsound/aplay; Ctrl-C stops both)
./sa3 --prompt "rainforest" --dit sm-sfx --decoder same-s --play
# All options + categorised examples
./sa3 --help
```
Omit `--dit` / `--decoder` for an interactive arrow-key picker. Omit
`--prompt` for a stdin prompt. Relative `--out` paths land in `output/`
(auto-created); absolute paths are honoured as-is. The output path is
printed prominently as a `▸ saved` line at the end of each run.
Use `--threads` to control the XNNPACK CPU thread count (default 8).
## Web UI (gradio)
A browser UI over the same TFLite backend — every generation mode plus a
spectrogram player, a history panel, and playback options (auto-play, infinite
radio with pre-generation, loop, hotswap, MP3/WAV save).
```bash
./sa3-gradio # sm-music, opens a public share link
./sa3-gradio --dit medium # start on the medium DiT
./sa3-gradio --no-share # local-only (http://localhost:7860)
./sa3-gradio --port 7871 # pick the port
```
`./sa3-gradio` is a wrapper around `.venv/bin/python scripts/sa3_gradio.py`; on
first run it offers to install the UI-only extras (`gradio`, `pillow`,
`soundfile`) into `.venv`. In the browser:
- **Precision** dropdown next to the model picker — `fp32` (default) / `w16a32`
/ `w8a32` / `w8a8-dyn`, applied to the DiT + codec.
- **LoRA** panel — upload a `.safetensors` adapter or pick one from
`loras/<model>/` (e.g. `loras/sa3-medium/`), set its strength, stack several;
settings are remembered per model. The panel is disabled under a quantized
precision (see the LoRA note above) and has no per-step range (frozen graph).
- **Audio-to-audio / inpainting** — upload a reference clip; inpainting adds a
start/end range. Both are combinable.
Generated WAV/MP3s land in `output/gradio/`. First use of a model/precision
loads its weights (cached after).
### Without the wrapper
```bash
.venv/bin/python scripts/sa3_tflite.py --prompt "..." --dit medium --decoder same-l
# or, after `source .venv/bin/activate`:
python scripts/sa3_tflite.py --prompt "..." --dit medium --decoder same-l
```
## Speed & memory
This is a **CPU** path — it trades the GPU releases' speed for portability. The
small models comfortably beat realtime on a modern laptop CPU; `medium` is slower
(its DiT is ~5.8 GB fp32 and it chunk-decodes SAME-L). Use ≥ ~20 s clips: very
short clips have too few latent tokens for the sampler to settle into a coherent
loop. Throughput scales with `--threads` up to your physical core count (4–8 is
the usual sweet spot; more threads on a short model adds overhead).
For sub-realtime latency on a supported device, prefer the GPU siblings:
[MLX](../mlx) on Apple Silicon, [TensorRT](../tensorRT) on NVIDIA.
## Flag reference
| Flag | Default | Notes |
|-----------------------|----------|-----------------------------------------------------------------------|
| `--prompt` | (asks) | Text prompt; empty string = unconditional |
| `--negative-prompt` | — | CFG uncond branch; only used when `--cfg ≠ 1.0` |
| `--dit` | (asks) | `sm-music`, `sm-sfx`, or `medium` |
| `--decoder` | (asks) | `same-s` (pairs with sm-*) or `same-l` (pairs with medium) |
| `--seconds` | 30 | Output length (use ≥ ~20 s) |
| `--steps` | 8 | Pingpong sampler steps; 1 = single forward (fastest), 8 = sweet spot |
| `--seed` | random | Set for reproducibility; the chosen seed is printed at the end |
| `--cfg` | 1.0 | Guidance scale; 1.0 = off, >1 toward prompt, <1 toward uncond. ≠1 runs cond+uncond each step |
| `--apg` | 1.0 | Adaptive Projected Guidance; only matters when `--cfg ≠ 1` |
| `--cfg-batched` | on | When `--cfg ≠ 1`, run cond+uncond as one batch=2 invoke on the variable-batch DiT (~7–29% faster on Apple-Silicon AMX). `--no-cfg-batched` → sequential batch=1 dual-pass. Bit-identical at `fp32`/`w8a32` — see [Precision variants](#precision-variants---precision) for `w16a32`/`w8a8-dyn` |
| `--lora` | — | A `.safetensors` LoRA adapter (SA3-native/underfit or PEFT) merged into the DiT, optional `strength=S`; repeat to stack. Requires `--dit-precision fp32` or `w16a32`. See [LoRA](#lora) |
| `--lora-strength` | 1.0 | Default strength for `--lora` adapters without their own `strength=` |
| `--init-audio` | — | WAV (any format via ffmpeg) input for audio-to-audio / inpaint |
| `--init-noise-level` | 1.0 | σmax; 0.4–0.8 typical for variation, 1.0 = full regen, >1 = overshoot |
| `--inpaint-range` | — | `START,END` seconds; regenerate that span, keep the rest |
| `--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/<file>`; absolute → as-is. 16-bit PCM stereo @ 44.1 kHz, trimmed to exactly `--seconds` |
| `--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
speed (XNNPACK dequantizes to fp32 to matmul), and int8 costs quality on the DiT
— so this release ships the fp32 graphs directly. (See "Notes on the design".)
## LoRA
Apply a LoRA finetune by merging it into the DiT's weight buffers at load —
same adapters and semantics as the MLX backend's `--lora`:
```bash
# one adapter
./sa3 --dit medium --decoder same-l --prompt "progressive metal" \
--lora plini-sa3-380.safetensors
# per-adapter strength; stack several
./sa3 --dit medium --decoder same-l --prompt "..." \
--lora a.safetensors strength=0.8 --lora b.safetensors strength=0.5
```
The adapter is a `.safetensors` file (SA3-native `train_lora.py` / underfit
output, or a PEFT adapter directory) — pickle `.ckpt/.pt` is refused. All nine
adapter types (lora, dora-rows/cols, bora, the four -xs) are supported; the base
must match `--dit`. The merge is written into a cached copy of the DiT under
`models/tflite/lora_cache/` (keyed by adapter + strength content hash) and reused
on repeat runs, so the ~5–15 s patch cost is paid once. A medium fp32 cache entry
is ~5.4 GB — delete `lora_cache/` to reclaim.
Requires `--dit-precision fp32` (default) or `w16a32`. **LoRA on the quantized
DiTs (`w8a32` / `w8a8-dyn` / `w4a32`) isn't figured out yet** — those store
weights as GPTQ-calibrated int8, so merging would mean dequantize → add the LoRA
delta → re-quantize, and a naive re-quant throws away the GPTQ error-feedback
grid (you'd get a model that's both LoRA-adapted *and* degraded). Doing it well
needs a GPTQ pass over the merged weights; until then `--lora` refuses quantized
precisions (the CLI errors, the web UI disables the LoRA panel). Use fp32/w16a32
for LoRA — on CPU fp32 is the fast-and-accurate choice anyway, so this is rarely
a real constraint. **Per-step gating (`steps=`) is also MLX-only** — a frozen
TFLite graph merges weights once at load; use `optimized/mlx` for step-gated LoRA.
## Files
```
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
├── output/ ← default landing zone for generated WAVs
├── scripts/
│ ├── sa3_tflite.py ← orchestrator CLI (invoked by ./sa3)
│ ├── weights.py ← weights manifest + HF auto-download
│ ├── examples.py ← shared examples block (--help + post-install)
│ └── install.py ← install.sh's Python half (bundle picker)
└── models/
├── tokenizer.model ← SentencePiece model, BUNDLED (~4 MB; T5Gemma tflite is encoder-only)
├── defs/
│ └── tflite_pipeline.py ← Tokenizer + T5Gemma front-end + pingpong schedule + sampler + WAV
└── tflite/ ← .tflite models (auto-downloaded; ~2.3 GB small, ~9.5 GB medium)
├── t5gemma/encoder_fp16.tflite 564 MB text encoder (fp16)
├── sa3-sm-music/dit_fp32.tflite 1.8 GB small music DiT (conditioner baked in)
├── sa3-sm-sfx/dit_fp32.tflite 1.8 GB small sfx DiT (conditioner baked in)
├── sa3-m/dit_fp32.tflite 5.8 GB medium DiT (conditioner baked in)
├── same-s/{enc,dec}_fp32.tflite ~220 MB each shared sm-* codec
└── same-l/{enc,dec}_fp32.tflite ~1.8 GB each medium codec
```
The DiT graphs are **baked-I/O**: the conditioner (prompt-padding + seconds
embedder) and the patch/unpatch are compiled into the graph, so the DiT takes the
raw T5Gemma output directly and the decoder emits audio directly. The two small
DiTs share the SAME-S codec (bit-exact between checkpoints), so only one set of
small-codec files is shipped.
## Auto-download from HuggingFace
Model files aren't bundled — they're pulled from
`stabilityai/stable-audio-3-optimized` (under `tflite/…`) on first use and
symlinked into `models/tflite/` from the HF cache. No duplication. Anonymous
downloads work but are rate-limited; `huggingface-cli login` with a free read-only
token lifts the cap. The SentencePiece tokenizer (`models/tokenizer.model`, ~4 MB)
is the one weight that IS committed, since the `.tflite` T5Gemma is encoder-only.
## Notes on the design
- **Baked-I/O varlen graphs.** Each `.tflite` is a single self-contained graph
with the conditioner and patch/unpatch in-graph, accepting a variable sequence
length — so one file serves any `--seconds`. The DiT is a 6-input graph
(`x, t, t5_hidden, t5_mask, seconds, local_add_cond`); feed raw T5 outputs and
the in-graph conditioner handles prompt-padding + seconds-embedding.
- **fp32 everywhere (except fp16 T5Gemma).** On CPU, quantizing buys size, not
speed — XNNPACK dequantizes int8/fp16 weights to fp32 to matmul, so fp16 is
actually *slower* and int8 gives no speedup. And the DiT will not go int8 at
quality: per-step error compounds over the 8 chaotic sampling steps into a
*different* (still plausible) sample, not a degraded one. So this release ships
the fp32 graphs directly. T5Gemma fp16 is the sole exception — it's numerically
lossless there and halves that file.
- **Monotonic audio-to-audio schedule.** The pingpong schedule applies the LogSNR
shift to the normalized `[1→0]` grid, then scales by σmax, so audio-to-audio
(σmax < 1) stays monotonically decreasing while keeping all N distilled steps.
σmax = 1.0 (text-to-audio) is bit-identical to the classic schedule.
- **SAME-L chunked decode.** The SAME-L decoder's dense sliding-window-attention
mask is O(T²), so long clips are decoded in overlap-8 windows of 64 latent
tokens (the throughput optimum) and stitched. SAME-S has a narrow receptive
field and decodes whole.
- **CFG (`--cfg ≠ 1`)** combines a cond and an uncond velocity in denoised space
(optional APG). The canonical DiT is **variable-batch**, so by default cond+uncond
run as **one batch=2 invoke per step** (`--cfg-batched`) — ~7–29% faster on
Apple-Silicon (the AMX matrix unit amortizes the weight loads across both rows;
measured on an M4 Pro MacBook Pro).
`--no-cfg-batched` falls back to a sequential batch=1 dual-pass (like the TensorRT
release, whose engine is static-batch=1); the two are bit-identical at `fp32`/`w8a32`
(~80 dB at `w16a32`; `w8a8-dyn` diverges by design — batch-shared activation scales).
## License & attribution
Model weights derived from Stability AI's Stable Audio 3 checkpoints.
T5Gemma text encoder from Google.
Use of the Stable Audio 3 weights is governed by the **Stability AI
Community License**. Please refer to the full terms at
<https://stability.ai/license>.
@@ -0,0 +1,88 @@
# sa3_tflite bootstrap for Windows - Stable Audio 3 inference on CPU (LiteRT/TFLite).
#
# The PowerShell twin of bootstrap.sh. Hosted at:
# https://raw.githubusercontent.com/Stability-AI/stable-audio-3/main/optimized/tflite/bootstrap.ps1
#
# Usage (default demo):
# irm https://raw.githubusercontent.com/Stability-AI/stable-audio-3/main/optimized/tflite/bootstrap.ps1 | iex
#
# Custom args (iex cannot forward arguments - download first):
# irm https://raw.githubusercontent.com/Stability-AI/stable-audio-3/main/optimized/tflite/bootstrap.ps1 -OutFile bootstrap.ps1
# .\bootstrap.ps1 --prompt "Death Metal" --dit medium --decoder same-l
#
# What it does:
# 1. Fetches the project (git clone if git is installed, else a zip pull).
# 2. Runs install.bat (python -m venv + pip install; needs Python 3.10-3.13
# on PATH - ai-edge-litert ships win_amd64 wheels for those versions).
# 3. Runs sa3.bat with your args (default: 30 s demo prompt + --play).
# Weights auto-download from HuggingFace on first use.
$ErrorActionPreference = "Stop"
# Older Windows PowerShell 5.1 hosts may default to TLS < 1.2, which GitHub
# rejects. Harmless no-op on PowerShell 7+ / current Win10+.
try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch {}
$RepoOwner = "Stability-AI"
$RepoName = "stable-audio-3"
$Branch = "main"
$SubDir = "optimized\tflite"
$LocalDir = "sa3_tflite"
function Step($msg) { Write-Host "`n-> $msg" -ForegroundColor Cyan }
function Ok($msg) { Write-Host " OK $msg" -ForegroundColor Green }
# --- 1. fetch the project -------------------------------------------------
if (Get-Command git -ErrorAction SilentlyContinue) {
$WorkDir = Join-Path $RepoName $SubDir
if (Test-Path (Join-Path $RepoName ".git")) {
Step "Reusing existing .\$RepoName (git pull --ff-only)"
git -C $RepoName pull --ff-only
} elseif (Test-Path $RepoName) {
throw ".\$RepoName exists but isn't a git repo - remove or rename it."
} else {
Step "git clone https://github.com/$RepoOwner/$RepoName -> .\$RepoName"
git clone --depth=1 "https://github.com/$RepoOwner/$RepoName" $RepoName
}
if (-not (Test-Path $WorkDir)) { throw "Expected '$SubDir' inside the repo but didn't find it." }
} else {
$WorkDir = $LocalDir
if (Test-Path (Join-Path $LocalDir "install.bat")) {
Step "Reusing existing $LocalDir\ (delete it to re-download)"
} elseif (Test-Path $LocalDir) {
throw ".\$LocalDir exists but doesn't look like a sa3_tflite checkout - remove or rename it."
} else {
Step "git not installed - downloading $RepoOwner/$RepoName ($Branch) zip -> .\$LocalDir"
$zip = Join-Path $env:TEMP "sa3_repo.zip"
$extract = Join-Path $env:TEMP "sa3_extract"
Invoke-WebRequest "https://github.com/$RepoOwner/$RepoName/archive/refs/heads/$Branch.zip" -OutFile $zip
if (Test-Path $extract) { Remove-Item $extract -Recurse -Force }
Expand-Archive $zip -DestinationPath $extract -Force
Move-Item (Join-Path $extract "$RepoName-$Branch\$SubDir") $LocalDir
Remove-Item $zip -Force
Remove-Item $extract -Recurse -Force
Ok "extracted to .\$LocalDir"
}
}
Set-Location $WorkDir
Ok "ready at $(Get-Location)"
# --- 2. install -----------------------------------------------------------
Step "Running install.bat"
cmd /c install.bat
if ($LASTEXITCODE -ne 0) { throw "install.bat failed (exit $LASTEXITCODE)." }
# --- 3. inference ---------------------------------------------------------
if ($args.Count -gt 0) {
Step "Running sa3.bat $($args -join ' ')"
cmd /c sa3.bat @args
} else {
Step 'Running demo: sa3.bat --prompt "Impending tribal, epic orchestral buildup" --dit sm-music --decoder same-s --seconds 30 --play'
Write-Host ' (for custom args, download bootstrap.ps1 first and run: .\bootstrap.ps1 --prompt "..." ...)' -ForegroundColor DarkGray
cmd /c sa3.bat --prompt "Impending tribal, epic orchestral buildup" --dit sm-music --decoder same-s --seconds 30 --play
}
Write-Host ""
Write-Host "You are set up in $(Get-Location)" -ForegroundColor Green
Write-Host " run .\sa3.bat --help for options" -ForegroundColor DarkGray
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
#
# sa3_tflite bootstrap — Stable Audio 3 inference on CPU (LiteRT/TFLite) in one command.
#
# Hosted at:
# https://raw.githubusercontent.com/Stability-AI/stable-audio-3/main/optimized/tflite/bootstrap.sh
#
# Usage:
# curl -LsSf https://raw.githubusercontent.com/Stability-AI/stable-audio-3/main/optimized/tflite/bootstrap.sh | bash
# curl -LsSf https://raw.githubusercontent.com/Stability-AI/stable-audio-3/main/optimized/tflite/bootstrap.sh | bash -s -- --prompt "Death Metal" --dit medium --decoder same-l
#
# Default demo prompt is "Impending tribal, epic orchestral buildup".
#
# What it does:
# 1. Verifies curl + tar are present (portable CPU stack — runs on macOS/Linux, x86/ARM).
# 2. Fetches the project:
# - If git is installed → `git clone --depth=1` into ./stable-audio-3/,
# then cd into optimized/tflite/ (real repo; pullable, modifiable).
# - If not → tarball pull via curl + tar, extracting only optimized/tflite/
# into ./sa3_tflite/ (no git needed).
# 3. Runs ./install.sh -y inside it (uv + Python 3.11 + venv + weight downloads).
# 4. Runs ./sa3 with whatever args you passed (default: "Impending tribal, epic orchestral buildup" demo + --play).
#
set -euo pipefail
# uv's curl installer drops the binary at $XDG_BIN_HOME (~/.local/bin by default)
# and updates the user's shell profile — but that profile only takes effect in
# *new* shells. We pre-emptively put both locations on PATH so the just-installed
# uv (and anything else from this run) is findable in the current process tree.
export PATH="${XDG_BIN_HOME:-$HOME/.local/bin}:$HOME/.local/bin:$PATH"
REPO_OWNER="Stability-AI"
REPO_NAME="stable-audio-3"
BRANCH="main"
SUBDIR_IN_REPO="optimized/tflite"
LOCAL_DIR="sa3_tflite"
DEFAULT_ARGS=(--prompt "Impending tribal, epic orchestral buildup" --dit sm-music --decoder same-s --seconds 30 --play)
TAR_URL="https://github.com/$REPO_OWNER/$REPO_NAME/archive/refs/heads/$BRANCH.tar.gz"
TAR_INNER="$REPO_NAME-$BRANCH/$SUBDIR_IN_REPO"
# ── colours ─────────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
BOLD=$'\033[1m'; CYAN=$'\033[1;36m'; RED=$'\033[1;31m'
YELLOW=$'\033[1;33m'; GREEN=$'\033[1;32m'; DIM=$'\033[2m'; RESET=$'\033[0m'
else
BOLD=""; CYAN=""; RED=""; YELLOW=""; GREEN=""; DIM=""; RESET=""
fi
step() { printf '\n%s→ %s%s\n' "$CYAN" "$1" "$RESET"; }
fail() { printf '\n%serror%s: %s\n' "$RED" "$RESET" "$1" >&2; exit 1; }
ok() { printf ' %s✓%s %s\n' "$GREEN" "$RESET" "$1"; }
warn() { printf '%swarning%s: %s\n' "$YELLOW" "$RESET" "$1" >&2; }
# ── 1. platform note (portable — no gate) ───────────────────────────────────
OS="$(uname -s)"; ARCH="$(uname -m)"
ok "platform: $OS/$ARCH (LiteRT/TFLite is CPU-portable)"
# ── 2. preflight: curl + tar (preinstalled on macOS/most Linux) ─────────────
for tool in curl tar; do
command -v "$tool" >/dev/null 2>&1 || \
fail "$tool not found on PATH. Install it and re-run."
done
ok "curl + tar present"
# ── 3. fetch the project ────────────────────────────────────────────────────
# Prefer `git clone` if git is on the machine — the user gets a real repo
# they can pull updates from / navigate sibling subdirs in (mlx/, tensorRT/).
# Falls back to a tarball pull (curl + tar) if git is missing.
if command -v git >/dev/null 2>&1; then
GIT_DIR="$REPO_NAME"
WORK_DIR="$GIT_DIR/$SUBDIR_IN_REPO"
if [[ -d "$GIT_DIR/.git" ]]; then
step "Reusing existing ./$GIT_DIR (git pull --ff-only)"
git -C "$GIT_DIR" pull --ff-only
elif [[ -e "$GIT_DIR" ]]; then
fail "./$GIT_DIR exists but isn't a git repo — remove or rename it."
else
step "git clone https://github.com/$REPO_OWNER/$REPO_NAME → ./$GIT_DIR"
git clone --depth=1 "https://github.com/$REPO_OWNER/$REPO_NAME" "$GIT_DIR"
fi
[[ -d "$WORK_DIR" ]] || \
fail "Expected '$SUBDIR_IN_REPO' inside the repo but didn't find it."
ok "ready at ./$WORK_DIR"
else
# No git — pull a tarball and extract only optimized/tflite/.
WORK_DIR="$LOCAL_DIR"
if [[ -d "$LOCAL_DIR" && -x "$LOCAL_DIR/install.sh" ]]; then
step "Reusing existing $LOCAL_DIR/ (delete it to re-download)"
else
if [[ -e "$LOCAL_DIR" ]]; then
fail "./$LOCAL_DIR exists but doesn't look like a sa3_tflite checkout — remove or rename it."
fi
step "git not installed — downloading $REPO_OWNER/$REPO_NAME ($BRANCH) tarball → ./$LOCAL_DIR"
TMP_TAR="$(mktemp -t sa3_repo.XXXXXX).tar.gz"
TMP_EXTRACT="$(mktemp -d -t sa3_extract.XXXXXX)"
trap 'rm -rf "$TMP_TAR" "$TMP_EXTRACT"' EXIT
# --progress-bar writes to stderr; -f makes 404/5xx a real curl error
curl -fL --progress-bar "$TAR_URL" -o "$TMP_TAR"
# BSD tar (macOS) / GNU tar both extract only paths matching the pattern.
tar -xz -f "$TMP_TAR" -C "$TMP_EXTRACT" "$TAR_INNER"
SRC="$TMP_EXTRACT/$TAR_INNER"
[[ -d "$SRC" ]] || fail "Expected '$TAR_INNER' inside the tarball but didn't find it."
mv "$SRC" "$LOCAL_DIR"
ok "extracted $(find "$LOCAL_DIR" -type f | wc -l | tr -d ' ') files to ./$LOCAL_DIR"
fi
fi
# ── 4. install ──────────────────────────────────────────────────────────────
cd "$WORK_DIR"
[[ -x ./install.sh ]] || fail "install.sh missing or not executable in ./$WORK_DIR."
step "Running ./install.sh -y"
./install.sh -y
# ── 5. inference ────────────────────────────────────────────────────────────
# Run as a subprocess (not `exec`) so we can drop the user into an
# interactive shell here when it finishes.
if [[ $# -gt 0 ]]; then
step "Running ./sa3 $*"
./sa3 "$@" || true
else
step "Running demo: ./sa3 ${DEFAULT_ARGS[*]}"
printf ' %s(pass your own args via: curl -LsSf https://raw.githubusercontent.com/Stability-AI/stable-audio-3/main/optimized/tflite/bootstrap.sh | bash -s -- --prompt "..." ...)%s\n' "$DIM" "$RESET"
./sa3 "${DEFAULT_ARGS[@]}" || true
fi
# ── 6. drop user into an interactive shell sitting in the project dir ──────
# A subprocess can't change its parent shell's cwd — but we CAN replace
# ourselves with a fresh interactive shell, leaving the user at a prompt
# inside ./$WORK_DIR. `exit` (or Ctrl-D) returns them to their original
# shell, at their original cwd, just like a normal subshell.
#
# `< /dev/tty` is essential when bootstrap.sh was invoked via curl|bash:
# stdin at this point is the (closed) pipe; an interactive shell needs a
# real terminal. /dev/tty always refers to the user's controlling TTY.
if [[ ! -e /dev/tty ]]; then
# Headless / scripted invocation — skip the shell drop.
exit 0
fi
USER_SHELL="${SHELL:-/bin/bash}"
printf '\n%s━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━%s\n' "$BOLD" "$RESET"
printf ' %s✓ you are now in%s %s%s%s\n' "$GREEN" "$RESET" "$BOLD" "$(pwd)" "$RESET"
printf ' %stype %s./sa3 --help%s for options, or %sexit%s to return to your previous shell%s\n' \
"$DIM" "$RESET$BOLD" "$RESET$DIM" "$RESET$BOLD" "$RESET$DIM" "$RESET"
printf '%s━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━%s\n\n' "$BOLD" "$RESET"
exec "$USER_SHELL" -i < /dev/tty
@@ -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%
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
#
# SA3 TFLite installer — uv-based.
#
# Creates a project-local .venv/ with the right Python and runtime deps,
# then hands off to install.py for the interactive weight-download prompt.
#
# Portable CPU stack (LiteRT / TFLite via ai_edge_litert) — runs on
# macOS/Linux, x86/ARM. No Apple-Silicon requirement.
#
# Usage:
# ./install.sh # auto-detect uv, prompt to install if missing
# ./install.sh -y # assume yes to "install uv?" prompt
# ./install.sh --python VER # pin a specific Python (default: 3.11)
#
# After install:
# source .venv/bin/activate # (.venv/Scripts/activate on Windows Git Bash)
# python scripts/sa3_tflite.py --prompt "lofi house" --dit sm-music --decoder same-s
# # or, without activating:
# .venv/bin/python scripts/sa3_tflite.py --prompt "lofi house" --dit sm-music
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="$SCRIPT_DIR/.venv"
# venv layout differs by OS: POSIX puts python at .venv/bin/python, Windows
# (incl. Git Bash / MSYS) at .venv/Scripts/python.exe. Resolve whichever exists.
venv_py() {
if [[ -x "$VENV_DIR/bin/python" ]]; then
echo "$VENV_DIR/bin/python"
elif [[ -x "$VENV_DIR/Scripts/python.exe" ]]; then
echo "$VENV_DIR/Scripts/python.exe"
else
echo "$VENV_DIR/bin/python" # default (error messages)
fi
}
PY_VERSION_DEFAULT="3.11"
# ── colours ─────────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
BOLD=$'\033[1m'; CYAN=$'\033[1;36m'; RED=$'\033[1;31m'
YELLOW=$'\033[1;33m'; GREEN=$'\033[1;32m'; DIM=$'\033[2m'; RESET=$'\033[0m'
else
BOLD=""; CYAN=""; RED=""; YELLOW=""; GREEN=""; DIM=""; RESET=""
fi
step() { printf '\n%s→ %s%s\n' "$CYAN" "$1" "$RESET"; }
fail() { printf '%serror%s: %s\n' "$RED" "$RESET" "$1" >&2; }
warn() { printf '%swarning%s: %s\n' "$YELLOW" "$RESET" "$1" >&2; }
ok() { printf ' %s✓%s %s\n' "$GREEN" "$RESET" "$1"; }
# ── arg parsing ─────────────────────────────────────────────────────────────
ASSUME_YES=0
PY_VERSION="$PY_VERSION_DEFAULT"
EXTRA_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
-y|--yes) ASSUME_YES=1; shift ;;
--python) PY_VERSION="$2"; shift 2 ;;
--python=*) PY_VERSION="${1#--python=}"; shift ;;
-h|--help)
sed -n '2,/^set -euo/p' "$0" | sed -e '$d' -e 's/^# \{0,1\}//'
exit 0 ;;
*) EXTRA_ARGS+=("$1"); shift ;;
esac
done
# ── platform note (portable — CPU only, no gate) ────────────────────────────
OS="$(uname -s)"; ARCH="$(uname -m)"
ok "platform: $OS/$ARCH (LiteRT/TFLite is CPU-portable; XNNPACK delegate)"
# ── ensure uv is installed ──────────────────────────────────────────────────
ensure_uv() {
if command -v uv >/dev/null 2>&1; then
ok "uv $(uv --version 2>/dev/null | awk '{print $2}') already installed at $(command -v uv)"
return 0
fi
step "uv not found — uv is required (much faster than pip, also manages Python versions)"
if [[ "$ASSUME_YES" -ne 1 ]]; then
printf ' Install uv now via the official installer? (curl + sh) %s[Y/n]%s ' "$DIM" "$RESET"
read -r REPLY < /dev/tty
case "$REPLY" in
""|y|Y|yes|YES) ;;
*)
fail "install aborted — install uv manually then re-run:"
printf ' curl -LsSf https://astral.sh/uv/install.sh | sh\n' >&2
printf ' or: brew install uv\n' >&2
exit 1 ;;
esac
fi
step "Installing uv (curl -LsSf https://astral.sh/uv/install.sh | sh)"
if ! curl -LsSf https://astral.sh/uv/install.sh | sh; then
fail "uv installer failed. Try a manual install:"
printf ' brew install uv\n' >&2
exit 1
fi
# The installer drops uv at ~/.local/bin/uv (or $XDG_BIN_HOME)
export PATH="$HOME/.local/bin:${XDG_BIN_HOME:-}:$PATH"
if ! command -v uv >/dev/null 2>&1; then
fail "uv was installed but isn't on PATH. Add ~/.local/bin to PATH, restart your shell, and re-run install.sh."
exit 1
fi
ok "uv $(uv --version | awk '{print $2}') installed"
}
ensure_uv
# ── create venv (uv auto-installs the requested Python if missing) ──────────
step "Creating virtual environment at .venv/ with Python $PY_VERSION"
if [[ -d "$VENV_DIR" ]]; then
EXISTING_PY=$("$(venv_py)" -c 'import sys; print(".".join(map(str, sys.version_info[:2])))' 2>/dev/null || echo "unknown")
if [[ "$EXISTING_PY" == "$PY_VERSION"* ]]; then
ok "reusing existing .venv (Python $EXISTING_PY)"
else
warn "existing .venv uses Python $EXISTING_PY (wanted $PY_VERSION) — recreating"
rm -rf "$VENV_DIR"
uv venv --seed --python "$PY_VERSION" "$VENV_DIR"
fi
else
uv venv --seed --python "$PY_VERSION" "$VENV_DIR"
fi
# ── install runtime deps ────────────────────────────────────────────────────
step "Installing dependencies (uv pip install -r requirements.txt)"
VIRTUAL_ENV="$VENV_DIR" uv pip install -r "$SCRIPT_DIR/requirements.txt"
# ── hand off to install.py ──────────────────────────────────────────────────
# Any unrecognized args we collected (e.g. --download medium,sm-music) get
# forwarded to install.py via EXTRA_ARGS.
step "Bundle picker"
INSTALL_SKIP_PIP=1 exec "$(venv_py)" "$SCRIPT_DIR/scripts/install.py" \
"${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}"
@@ -0,0 +1,4 @@
# Extras for the gradio web UI (./sa3-gradio) — not needed by the CLI.
gradio>=4.0
pillow>=10.0
soundfile>=0.12 # decode mp3/flac/24-bit/48kHz uploads without ffmpeg
@@ -0,0 +1,5 @@
ai_edge_litert>=1.0
numpy>=1.24
sentencepiece>=0.2
soundfile>=0.12
huggingface_hub>=0.20
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
#
# Wrapper around scripts/sa3_tflite.py — runs it via the project's .venv so no
# activation is needed.
#
# If setup is incomplete (no uv, no .venv) you'll be prompted to run
# install.sh; saying yes runs the installer and then resumes the command.
#
# Usage: ./sa3 --prompt "lofi house" --dit sm-music --decoder same-s --out a.wav
#
set -euo pipefail
# Pre-emptively add the uv installer's default bin dir to PATH. The user's
# shell profile only updates new shells; this lets us find uv in the current
# process tree (matters right after a fresh `curl|sh` install).
export PATH="${XDG_BIN_HOME:-$HOME/.local/bin}:$HOME/.local/bin:$PATH"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -t 1 ]]; then
RED=$'\033[1;31m'; YELLOW=$'\033[1;33m'; CYAN=$'\033[1;36m'
BOLD=$'\033[1m'; DIM=$'\033[2m'; RESET=$'\033[0m'
else
RED=""; YELLOW=""; CYAN=""; BOLD=""; DIM=""; RESET=""
fi
fail() { printf '\n%serror%s: %s\n' "$RED" "$RESET" "$1" >&2; exit 1; }
# Prompts the user to run install.sh. Honors `-y` / non-interactive stdin.
# Resumes (returns 0) on success so the caller can re-attempt the run;
# exits 1 if the user declines or install fails.
prompt_install() {
local reason="$1"
printf '\n%s%s%s\n' "$YELLOW" "$reason" "$RESET" >&2
if [[ ! -t 0 ]]; then
printf '%serror%s: stdin is not a TTY — re-run %s./install.sh%s manually.\n' \
"$RED" "$RESET" "$BOLD" "$RESET" >&2
exit 1
fi
printf ' %sRun ./install.sh now to set up?%s %s[Y/n]%s ' \
"$BOLD" "$RESET" "$DIM" "$RESET"
read -r REPLY < /dev/tty
case "$REPLY" in
""|y|Y|yes|YES) ;;
*) fail "install declined — re-run ./install.sh manually when ready." ;;
esac
printf '\n%s→ running ./install.sh%s\n' "$CYAN" "$RESET" >&2
if ! "$SCRIPT_DIR/install.sh"; then
fail "installer failed — see output above."
fi
}
# ── infrastructure checks ───────────────────────────────────────────────────
if [[ ! -f "$SCRIPT_DIR/scripts/sa3_tflite.py" ]]; then
fail "scripts/sa3_tflite.py not found — repo files are missing or moved."
fi
needs_install=0
missing=()
if ! command -v uv >/dev/null 2>&1; then
needs_install=1; missing+=("uv")
fi
if [[ ! -d "$SCRIPT_DIR/.venv" ]]; then
needs_install=1; missing+=(".venv/")
fi
if [[ $needs_install -eq 1 ]]; then
prompt_install "Setup incomplete — missing: ${missing[*]}"
fi
# ── hand off to scripts/sa3_tflite.py ───────────────────────────────────────
# Invoke .venv/bin/python directly rather than via `uv run`. `uv run` walks up
# looking for a pyproject.toml and (in newer uv versions) with --no-project
# creates an ephemeral env with no deps, ignoring our .venv. Direct invocation
# is shorter, faster, and side-steps both bugs.
cd "$SCRIPT_DIR"
PY="$SCRIPT_DIR/.venv/bin/python"
[[ -x "$PY" ]] || PY="$SCRIPT_DIR/.venv/Scripts/python.exe"
exec "$PY" scripts/sa3_tflite.py "$@"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Shortcut for running scripts/sa3_gradio.py with the project .venv.
# All args pass through unchanged.
#
# Examples:
# ./sa3-gradio # sm-music + same-s, public share link
# ./sa3-gradio --dit medium
# ./sa3-gradio --no-share # local-only
# ./sa3-gradio --help
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PY="${SCRIPT_DIR}/.venv/bin/python"
if [ ! -x "$PY" ]; then
echo "No .venv found — run ./install.sh (or ./bootstrap.sh) first."
exit 1
fi
# gradio + pillow + soundfile are UI-only extras, not part of the base requirements.
if ! "$PY" -c "import gradio, PIL, soundfile" >/dev/null 2>&1; then
echo "The gradio UI needs a few extra packages (gradio, pillow, soundfile)."
read -r -p "Install them into .venv now? [Y/n] " ans
case "${ans:-Y}" in
[Yy]*)
if command -v uv >/dev/null 2>&1; then
uv pip install -p "$PY" -r "${SCRIPT_DIR}/requirements-gradio.txt"
else
"$PY" -m pip install -r "${SCRIPT_DIR}/requirements-gradio.txt"
fi
;;
*) echo "Aborted — install manually: uv pip install -p .venv/bin/python -r requirements-gradio.txt"; exit 1 ;;
esac
fi
exec "$PY" "${SCRIPT_DIR}/scripts/sa3_gradio.py" "$@"
@@ -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%
@@ -0,0 +1,11 @@
# sa3.ps1 - PowerShell twin of the sa3 wrapper (and of sa3.bat).
# PowerShell users get clean Ctrl-C handling (no cmd.exe
# "Terminate batch job (Y/N)?" prompt, which is a .bat quirk).
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$Py = Join-Path $ScriptDir ".venv\Scripts\python.exe"
if (-not (Test-Path $Py)) {
$Py = Join-Path $ScriptDir ".venv/bin/python" # POSIX layout (pwsh on mac/linux)
}
if (-not (Test-Path $Py)) { $Py = "python" }
& $Py (Join-Path $ScriptDir "scripts/sa3_tflite.py") @args
exit $LASTEXITCODE
@@ -0,0 +1,158 @@
"""Shared, colored "Try these commands" block.
Used by:
- scripts/install.py — printed at the end of install.sh
- scripts/sa3_tflite.py — appended to `./sa3 --help`
Examples render with the user's `./sa3` wrapper when present (falling back to
`.venv/bin/python` or bare `python`), and only show entries for DiT bundles that
the user actually has installed. Bundles not yet on disk get a "re-run install.sh,
or just use them — weights auto-download" note at the bottom.
"""
from __future__ import annotations
import os
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 <project>/scripts/. SCRIPT_DIR points at the project
# root (where ./sa3, ./install.sh, models/, .venv/ live).
SCRIPT_DIR = Path(__file__).resolve().parent.parent
# ── ANSI colours (safe no-ops when stdout isn't a TTY) ──────────────────────
def _c(code: str) -> str:
return code if sys.stdout.isatty() else ""
BOLD = _c("\033[1m")
CYAN = _c("\033[1;36m")
GREEN = _c("\033[1;32m")
YELLOW = _c("\033[1;33m")
DIM = _c("\033[2m")
RESET = _c("\033[0m")
def _have(binary: str) -> bool:
from shutil import which
return which(binary) is not None
def _py_invocation() -> tuple[str, str, bool]:
"""Return (command-to-print, tip-or-empty, is_wrapper).
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
venv_py = SCRIPT_DIR / ".venv" / "bin" / "python"
if venv_py.exists():
return str(venv_py.relative_to(SCRIPT_DIR)), "", False
if Path(sys.prefix).resolve() == (SCRIPT_DIR / ".venv").resolve():
return ".venv/bin/python", "source .venv/bin/activate # to run `python` directly", False
return "python", "", False
def print_example_commands(header: str | None = None) -> None:
"""Print the categorized example-commands block.
`header` is the title line inside the rule block. Defaults to a neutral
"Examples:" for --help; install.sh passes "✓ Install complete. …".
"""
from weights import bundle_status
py, tip, is_wrapper = _py_invocation()
prefix = py if is_wrapper else f"{py} scripts/sa3_tflite.py"
def hdr(text: str) -> None:
print(f"\n {CYAN}{text}{RESET}")
def cmd(args: str, comment: str = "") -> None:
line = f"{prefix} {args}"
if comment:
print(f" {GREEN}$ {line}{RESET} {DIM}# {comment}{RESET}")
else:
print(f" {GREEN}$ {line}{RESET}")
if header is None:
header = f"{BOLD}Examples:{RESET}"
print(f"\n{BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}")
print(f" {header}")
print(f"{BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}")
if tip:
print(f"\n {DIM}tip: {tip}{RESET}")
have = {name: bundle_status(name) == (4, 4) for name in ("sm-music", "sm-sfx", "medium")}
# ── Basic generation ─────────────────────────────────────────────
hdr("🎵 Generate audio from a prompt")
if have["sm-music"]:
cmd('--prompt "lofi house loop, mellow piano" \\\n'
f' --dit sm-music --decoder same-s --seconds 30 --out lofi.wav',
"fast music generation")
if have["sm-sfx"]:
cmd('--prompt "footsteps on gravel, then a door slamming" \\\n'
f' --dit sm-sfx --decoder same-s --seconds 20 --out sfx.wav',
"sound-effect generation")
if have["medium"]:
cmd('--prompt "A beautiful piano arpeggio grows into a cinematic climax" \\\n'
f' --dit medium --decoder same-l --seconds 30 --out piano.wav',
"highest quality (chunked SAME-L decode)")
# ── Playback ─────────────────────────────────────────────────────
hdr("▶ Play immediately after generation")
one_dit = "sm-music" if have["sm-music"] else ("sm-sfx" if have["sm-sfx"] else "medium")
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 it (afplay/winsound/aplay; Ctrl-C stops both)")
# ── Audio-to-audio + inpaint ─────────────────────────────────────
hdr("🎚️ Audio-to-audio & inpainting (requires an input WAV)")
cmd(f'--prompt "jazz fusion with electric piano" --dit {one_dit} --decoder {one_dec} \\\n'
f' --init-audio funk.wav --init-noise-level 0.7 --out funk_jazz.wav',
"variation: 0.4-0.8 typical, higher = more change")
cmd(f'--prompt "explosive drum break" --dit {one_dit} --decoder {one_dec} \\\n'
f' --init-audio funk.wav --inpaint-range "4,7" --out funk_drums.wav',
"regenerate seconds 4-7, keep rest")
# ── CFG & negative prompts ───────────────────────────────────────
hdr("🎯 Steer with CFG + negative prompts")
cmd(f'--prompt "ambient drone" --cfg 3.0 \\\n'
f' --negative-prompt "drums, vocals, distortion" \\\n'
f' --dit {one_dit} --decoder {one_dec} --out clean_drone.wav',
"cfg > 1.0 toward prompt, neg pushes away (sequential dual-pass)")
# Bundles not installed (offer the on-demand path)
missing = [name for name, ok in have.items() if not ok]
if missing:
print(f"\n {YELLOW}note:{RESET} bundles not installed: {', '.join(missing)}.")
print(f" Re-run {BOLD}./install.sh{RESET} to pick them up, or just use them in"
f" {BOLD}./sa3{RESET} —")
print(f" weights auto-download from HuggingFace on first use.")
print(f"\n{BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}\n")
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Internal install plumbing — do NOT invoke directly. Use ../install.sh from the project root.
Called by install.sh after it has set up uv + .venv. Responsibilities:
1. (Optionally) pip-install requirements.txt — skipped when INSTALL_SKIP_PIP=1
is set (install.sh sets this since uv already handled deps).
2. Ask which DiT bundles to download, then fetch them from HuggingFace.
3. Print example commands the user can copy-paste.
Unlike the MLX release, this TFLite/LiteRT stack is portable CPU — it runs on
macOS/Linux, x86/ARM. No Apple-Silicon requirement.
"""
from __future__ import annotations
import os
import subprocess
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 <project>/scripts/. SCRIPT_DIR points at the project
# root (where models/, requirements.txt, .venv/, sa3 wrapper live).
SCRIPT_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(Path(__file__).resolve().parent)) # `from weights import …`
MIN_PY = (3, 9)
SKIP_PIP = os.environ.get("INSTALL_SKIP_PIP") == "1"
def step(msg: str) -> None:
print(f"\n\033[1;36m→ {msg}\033[0m")
def check_environment() -> None:
"""Bail out early with a clear message if the interpreter is too old."""
py = sys.version_info
if (py.major, py.minor) < MIN_PY:
print(
f"\n\033[1;31merror\033[0m: this script is running under Python "
f"{py.major}.{py.minor}, but this stack requires Python "
f"{MIN_PY[0]}.{MIN_PY[1]}+.\n\n"
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 Windows: install from https://www.python.org/downloads/ "
f"(check 'Add python.exe to PATH')\n",
file=sys.stderr,
)
sys.exit(1)
# TFLite / LiteRT (ai_edge_litert) is portable across macOS/Linux, x86/ARM —
# no platform gate here (unlike the Metal-backed MLX release).
def pip_install_requirements() -> None:
if SKIP_PIP:
step("Dependencies already installed by install.sh (uv) — skipping pip step")
return
step("Installing Python dependencies")
req = SCRIPT_DIR / "requirements.txt"
# Prefer pip; fall back to uv if pip isn't available in this interpreter
# (e.g. user is running install.py directly inside a uv-created venv
# that wasn't seeded with pip).
pip_available = subprocess.run(
[sys.executable, "-m", "pip", "--version"],
capture_output=True,
).returncode == 0
if pip_available:
cmd = [sys.executable, "-m", "pip", "install", "-r", str(req)]
elif _have("uv"):
cmd = ["uv", "pip", "install", "--python", sys.executable, "-r", str(req)]
else:
print(
f"\n\033[1;31merror\033[0m: neither pip nor uv is available in this "
f"interpreter ({sys.executable}).\n"
f"Install pip with `python -m ensurepip --upgrade`, or use ./install.sh "
f"which sets everything up via uv.",
file=sys.stderr,
)
sys.exit(1)
print(f" $ {' '.join(cmd)}")
subprocess.check_call(cmd)
def _have(binary: str) -> bool:
"""True if the given binary is on PATH."""
from shutil import which
return which(binary) is not None
def main() -> None:
import argparse
ap = argparse.ArgumentParser(
description="SA3 TFLite install — non-interactive bundle downloader + post-install help.",
epilog="Normally invoked via ../install.sh, not directly.",
)
ap.add_argument("--download", default="",
metavar="BUNDLES",
help="Comma-separated list of bundles to pre-download "
"(sm-music, sm-sfx, medium). Without this flag, "
"nothing is downloaded — sa3_tflite.py will fetch any "
"missing weights from HuggingFace on first use.")
cli = ap.parse_args()
check_environment()
pip_install_requirements()
# Import after pip install so a fresh checkout works.
from weights import DIT_BUNDLES, SHARED, BUNDLE_SIZES, bundle_status, ensure_local
step("Current weights status")
for name in DIT_BUNDLES:
present, total = bundle_status(name)
mark = "✓" if present == total else " "
print(f" [{mark}] {name:9s} {present}/{total} files present ({BUNDLE_SIZES[name]})")
chosen: list[str] = []
if cli.download.strip():
chosen = [b.strip() for b in cli.download.split(",") if b.strip()]
unknown = [b for b in chosen if b not in DIT_BUNDLES]
if unknown:
print(f"\n\033[1;31merror\033[0m: unknown bundle(s): {', '.join(unknown)}. "
f"Choices: {', '.join(DIT_BUNDLES)}", file=sys.stderr)
sys.exit(1)
if chosen:
step(f"Downloading {len(chosen)} bundle(s): {', '.join(chosen)}")
seen: set[str] = set()
for name in chosen:
print(f"\n[{name}]")
items = DIT_BUNDLES[name] + SHARED
for rel, _hf in items:
if rel in seen:
continue
seen.add(rel)
ensure_local(rel)
else:
step("No --download set — weights will auto-download on first ./sa3 use")
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
print_example_commands(header=f"{BOLD}{GREEN}✓ Install complete.{RESET} Try these commands:")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nAborted.")
sys.exit(130)
@@ -0,0 +1,321 @@
"""LoRA adapter parsing + per-layer merge math — torch/mlx-free (numpy only).
The TFLite route ships standalone (its own bootstrap, its own venv, runs on
Windows), so it can't import the MLX runtime. This module is a faithful vendored
copy of the *backend-agnostic* half of
``optimized/mlx/models/defs/lora_merge.py`` — the safetensors parsing and the
per-adapter-type ``W' = f(W0, adapter)`` math — with the two MLX touch-points
replaced by pure numpy:
* safetensors is read with a ~25-line numpy reader (JSON header + a raw blob),
instead of ``mx.load``;
* everything else (``_parse_adapter``, ``_group_native``/``_group_peft``,
``_merged_weight`` for all nine adapter types, ``_check_shapes``,
``_svd_bases`` for the ``-xs`` variants, ``parse_lora_spec``) is copied
verbatim, since it was already numpy-fp32.
KEEP IN SYNC with optimized/mlx/models/defs/lora_merge.py — the merge math must
match the MLX path bit-for-bit (both mirror ``LoRAParametrization.*_forward`` in
``stable_audio_3/models/lora/model.py``). What is intentionally NOT here: the
MLX weight-dict merge (``merge_loras_into_weights``) and the per-step
``LoraStepPlan`` — TFLite patches frozen FlatBuffer weight buffers instead (see
``lora_patch.py``), and per-step gating is not feasible on a frozen graph.
Trust boundary: only ``.safetensors`` adapters are accepted; a pickle
``.ckpt``/``.pt``/``.bin`` (which ``torch.load`` would execute code from) is
refused — this module never imports torch.
"""
from __future__ import annotations
import json
import os
import struct
import numpy as np
# Pickle-backed extensions we refuse to load (the trust boundary).
_PICKLE_EXTS = (".ckpt", ".pt", ".pth", ".bin")
# Adapter param names per type (mirrors utils._get_adapter_param_names).
_PARAMS_FOR = {
"lora": ("lora_A", "lora_B"),
"dora-rows": ("lora_A", "lora_B", "magnitude"),
"dora-cols": ("lora_A", "lora_B", "magnitude"),
"bora": ("lora_A", "lora_B", "magnitude_r", "magnitude_c"),
"lora-xs": ("M_xs",),
"dora-rows-xs": ("M_xs", "magnitude"),
"dora-cols-xs": ("M_xs", "magnitude"),
"bora-xs": ("M_xs", "magnitude_r", "magnitude_c"),
}
# Underfit saves the seconds conditioner Linear under this checkpoint name; the
# baked TFLite DiT carries it in-graph as the unique (768, 256) FULLY_CONNECTED
# (lora_patch maps it there).
COND_SECONDS_LAYER = "conditioners.seconds_total.embedder.embedding.1"
class LoraError(Exception):
"""An adapter could not be loaded or applied."""
# ── numpy safetensors reader (no torch, no safetensors pkg) ────────────────────
_ST_DTYPES = {
"F64": np.float64, "F32": np.float32, "F16": np.float16, "BF16": None,
"I64": np.int64, "I32": np.int32, "I16": np.int16, "I8": np.int8,
"U8": np.uint8, "BOOL": np.bool_,
}
def _load_safetensors(path: str):
"""Return ``(tensors: dict[str, np.ndarray fp32], metadata: dict)``.
Refuses pickle. bf16 is widened to fp32 via a uint16<<16 bit-cast (numpy has
no native bf16). Every returned tensor is fp32 so the merge math is uniform.
"""
lower = path.lower()
if lower.endswith(_PICKLE_EXTS):
raise LoraError(
f"refusing to load pickle-format adapter {os.path.basename(path)!r} — "
f"only .safetensors adapters are accepted (a .ckpt/.pt is unpickled by "
f"torch.load and can execute arbitrary code)"
)
if not lower.endswith(".safetensors"):
raise LoraError(f"not a .safetensors adapter: {path!r}")
with open(path, "rb") as f:
(hlen,) = struct.unpack("<Q", f.read(8))
hdr = json.loads(f.read(hlen))
blob = f.read()
meta = hdr.pop("__metadata__", {}) or {}
out: dict[str, np.ndarray] = {}
for k, v in hdr.items():
if v["dtype"] not in _ST_DTYPES:
raise LoraError(f"{os.path.basename(path)}: unsupported tensor dtype "
f"{v['dtype']!r} for {k}")
b0, b1 = v["data_offsets"]
n = 1
for d in v["shape"]:
n *= d
if v["dtype"] == "BF16":
u = np.frombuffer(blob, np.uint16, count=n or 1, offset=b0)
arr = (u.astype(np.uint32) << 16).view(np.float32)
else:
arr = np.frombuffer(blob[b0:b1], _ST_DTYPES[v["dtype"]])
out[k] = arr.reshape(v["shape"]).astype(np.float32, copy=False)
return out, meta
# ── SVD bases for -xs adapters (recomputed; mirrors model.py) ──────────────────
def _canonicalize_svd_signs(U: np.ndarray, Vh: np.ndarray):
"""Deterministic sign convention: largest-magnitude element of each U column
is positive (mirrors model._canonicalize_svd_signs)."""
max_abs_idx = np.argmax(np.abs(U), axis=0)
signs = np.sign(U[max_abs_idx, np.arange(U.shape[1])])
signs[signs == 0] = 1.0
return U * signs[None, :], Vh * signs[:, None]
def _svd_bases(W0: np.ndarray, rank: int):
"""``(U[:, :rank], V[:, :rank])`` from the SVD of W0 (fan_out, fan_in), with
V such that ``U @ diag(S) @ V.T`` reconstructs W0 (mirrors model.py)."""
U_full, _S, Vh_full = np.linalg.svd(W0, full_matrices=False)
U_full, Vh_full = _canonicalize_svd_signs(U_full, Vh_full)
return U_full[:, :rank], Vh_full[:rank, :].T
# ── per-type merge math (numpy, float32) — mirrors lora_merge._merged_weight ───
def _mag_2d(mag: np.ndarray, norm_dim: int) -> np.ndarray:
mag = np.atleast_1d(np.squeeze(mag))
return mag.reshape(-1, 1) if norm_dim == 1 else mag.reshape(1, -1)
def merged_weight(W0: np.ndarray, p: dict, adapter_type: str, scaling: float) -> np.ndarray:
"""LoRA-merged weight for one layer at full strength (lora_strength=1).
``W0`` is (fan_out, fan_in) float32; ``p`` holds the adapter tensors."""
if adapter_type == "lora":
return W0 + scaling * (p["lora_B"] @ p["lora_A"])
if adapter_type in ("dora-rows", "dora-cols"):
norm_dim = 1 if adapter_type == "dora-rows" else 0
V = W0 + scaling * (p["lora_B"] @ p["lora_A"])
V_hat = V / (np.linalg.norm(V, axis=norm_dim, keepdims=True) + 1e-12)
return V_hat * _mag_2d(p["magnitude"], norm_dim)
if adapter_type == "bora":
V = W0 + scaling * (p["lora_B"] @ p["lora_A"])
V_r = V / (np.linalg.norm(V, axis=1, keepdims=True) + 1e-12)
inter = p["magnitude_r"].reshape(-1, 1) * V_r
H_c = inter / (np.linalg.norm(inter, axis=0, keepdims=True) + 1e-12)
return H_c * p["magnitude_c"].reshape(1, -1)
if adapter_type.endswith("-xs"):
rank = p["M_xs"].shape[0]
U, V = _svd_bases(W0, rank)
Vfull = W0 + scaling * (U @ p["M_xs"] @ V.T)
if adapter_type == "lora-xs":
return Vfull
if adapter_type in ("dora-rows-xs", "dora-cols-xs"):
norm_dim = 1 if adapter_type == "dora-rows-xs" else 0
V_hat = Vfull / (np.linalg.norm(Vfull, axis=norm_dim, keepdims=True) + 1e-12)
return V_hat * _mag_2d(p["magnitude"], norm_dim)
if adapter_type == "bora-xs":
V_r = Vfull / (np.linalg.norm(Vfull, axis=1, keepdims=True) + 1e-12)
inter = p["magnitude_r"].reshape(-1, 1) * V_r
H_c = inter / (np.linalg.norm(inter, axis=0, keepdims=True) + 1e-12)
return H_c * p["magnitude_c"].reshape(1, -1)
raise LoraError(f"unknown adapter_type {adapter_type!r}")
def check_shapes(layer: str, W0: np.ndarray, p: dict, adapter_type: str) -> None:
"""Clear error when the adapter doesn't fit the base weight — almost always a
wrong-base-model mismatch. Without it the failure is a raw numpy broadcast."""
fan_out, fan_in = W0.shape
if adapter_type.endswith("-xs"):
rank = p["M_xs"].shape[0]
if rank > min(fan_out, fan_in):
raise LoraError(
f"{layer}: LoRA-XS rank {rank} exceeds base min-dim "
f"{min(fan_out, fan_in)} for weight {W0.shape} — wrong base model?"
)
return
b_out, b_rank = p["lora_B"].shape
a_rank, a_in = p["lora_A"].shape
if b_out != fan_out or a_in != fan_in or a_rank != b_rank:
raise LoraError(
f"{layer}: adapter lora_B{p['lora_B'].shape}·lora_A{p['lora_A'].shape} "
f"does not fit base weight {W0.shape} — wrong base model?"
)
# ── checkpoint parsing → normalized per-layer adapter ──────────────────────────
def _resolve_path(path: str) -> str:
"""Accept a .safetensors file or a PEFT adapter directory (resolve to the
adapter_model.safetensors inside it)."""
if os.path.isdir(path):
cand = os.path.join(path, "adapter_model.safetensors")
if os.path.isfile(cand):
return cand
hits = [f for f in os.listdir(path) if f.lower().endswith(".safetensors")]
if len(hits) == 1:
return os.path.join(path, hits[0])
raise LoraError(
f"{path!r}: expected one .safetensors adapter in the directory, found {hits}"
)
return path
def parse_adapter(path: str):
"""Load one adapter → ``(adapter_type, scaling, layers)`` where ``layers``
maps a checkpoint layer name → its param dict (numpy float32)."""
tensors, meta = _load_safetensors(path)
native_marker = ".parametrizations.weight.0."
if any(native_marker in k for k in tensors):
cfg = json.loads(meta.get("lora_config", "{}")) if meta else {}
layers = _group_native(tensors)
rank = int(cfg.get("rank") or _infer_rank(layers))
alpha = float(cfg.get("alpha", rank))
adapter_type = _resolve_native_type(cfg.get("adapter_type", "lora"))
return adapter_type, alpha / rank, layers
peft_marker = ".lora_A.weight"
if any(k.endswith(peft_marker) for k in tensors):
cfg = _read_peft_config(path)
rank = int(cfg["r"])
alpha = float(cfg.get("lora_alpha", rank))
use_dora = bool(cfg.get("use_dora", False))
use_rslora = bool(cfg.get("use_rslora", False))
adapter_type = "dora-rows" if use_dora else "lora"
scaling = alpha / (np.sqrt(rank) if use_rslora else rank)
return adapter_type, scaling, _group_peft(tensors)
raise LoraError(
f"{os.path.basename(path)!r}: not a recognised LoRA (no SA3-native "
f"parametrization keys and no PEFT lora_A/lora_B keys)"
)
def _group_native(tensors: dict) -> dict:
marker = ".parametrizations.weight.0."
layers: dict[str, dict] = {}
for k, v in tensors.items():
if marker not in k:
continue
layer, _, param = k.partition(marker)
layers.setdefault(layer, {})[param] = v
return layers
def _group_peft(tensors: dict) -> dict:
prefix = "base_model.model."
layers: dict[str, dict] = {}
for k, v in tensors.items():
name = k[len(prefix):] if k.startswith(prefix) else k
for suffix, param in ((".lora_A.weight", "lora_A"),
(".lora_B.weight", "lora_B"),
(".lora_magnitude_vector.weight", "magnitude")):
if name.endswith(suffix):
layers.setdefault(name[: -len(suffix)], {})[param] = v
break
return layers
def _read_peft_config(path: str) -> dict:
cfg_path = os.path.join(os.path.dirname(path), "adapter_config.json")
if not os.path.isfile(cfg_path):
raise LoraError(f"PEFT adapter at {path!r} is missing its adapter_config.json sibling")
with open(cfg_path) as fh:
return json.load(fh)
def _infer_rank(layers: dict) -> int:
for params in layers.values():
if "lora_A" in params:
return params["lora_A"].shape[0]
if "M_xs" in params:
return params["M_xs"].shape[0]
raise LoraError("cannot infer LoRA rank (no lora_A / M_xs tensors)")
def _resolve_native_type(adapter_type: str) -> str:
"""Legacy 'dora' → 'dora-rows' (mirrors utils.resolve_adapter_type)."""
return "dora-rows" if adapter_type == "dora" else adapter_type
# ── CLI spec parsing ────────────────────────────────────────────────────────
def parse_lora_spec(tokens, default_strength: float = 1.0) -> dict:
"""Parse one ``--lora`` group: ``PATH [strength=S]``.
Mirrors the MLX CLI's flag, minus ``steps=`` — per-step LoRA gating is not
feasible on a frozen TFLite graph (weights are packed into XNNPACK's private
buffers at allocate_tensors; there is no per-step re-merge), so ``steps=`` is
rejected with a pointer to the MLX backend.
"""
if not tokens:
raise LoraError("--lora needs an adapter path")
spec = {"path": tokens[0], "strength": float(default_strength)}
for tok in tokens[1:]:
key, eq, val = tok.partition("=")
if key == "steps":
raise LoraError(
"--lora steps=… (per-step gating) is not supported on the TFLite "
"backend — the frozen graph's weights are merged once at load. "
"Use the MLX backend (optimized/mlx) for step-gated LoRA."
)
if not eq or key != "strength":
raise LoraError(
f"--lora: unexpected token {tok!r} — one adapter per --lora flag; "
f"the only option after the path is strength=S "
f"(e.g. --lora a.safetensors strength=0.8)"
)
try:
spec["strength"] = float(val)
except ValueError:
raise LoraError(f"--lora: bad strength {val!r}") from None
return spec
@@ -0,0 +1,392 @@
"""Merge LoRA adapters into a baked SA3 DiT ``.tflite`` by patching its weight
buffers — the TFLite counterpart of the MLX weight-dict merge.
A ``.tflite`` is a frozen FlatBuffer: there is no weight dict to edit at load, so
LoRA inference means rewriting the FULLY_CONNECTED weight buffers in place. The
mechanism (validated on the 5.8 GB medium DiT):
1. Parse the FlatBuffer header (mmap, no full read) and map every
checkpoint layer name → the file offset/size/dtype of its FC weight buffer.
2. Clone the base file (APFS ``cp -c`` = a few ms and no extra disk until
written; plain copy elsewhere).
3. For each adapter layer: read W0 from the clone's mmap, compute W' with the
shared numpy merge math (``lora_core.merged_weight``), and overwrite the
buffer's bytes at its exact offset. Deltas from multiple adapters accumulate
against the ORIGINAL W0 (order-independent), matching the MLX path.
Patching happens BEFORE ``Interpreter(...)`` so XNNPACK packs the merged weights
at ``allocate_tensors`` exactly as for an unpatched model — no runtime cost, no
cache interaction. Patched files are cached by a content hash of (base, adapters,
strengths), so repeat runs reuse them instantly.
Precision: fp32 (weights are direct fp32 consts) and w16a32 (weights are fp16
consts behind a DEQUANTIZE) are supported. Quantized-int8 variants (w8a32,
w8a8-dyn, w4a32) are refused — merging requires a dequant→merge→requant that
sacrifices the GPTQ error-feedback grid (a phase-2 item).
Name mapping tiers (all hard-fail on ambiguity — never silently mis-patch):
1. Block linears (self_attn/cross_attn/ff): name carries ``TransformerBlock_N``
→ (block, module, leaf) + shape → unique.
2. ``to_local_embed.seq.{0,2}``: per-block but the tflite names carry NO block
index and their ``;NN`` counter runs BACKWARDS — block identity comes from
FC topological order within the group, never the counter.
3. Prefix embedders / project_in / project_out: singleton names → unique.
4. Seconds conditioner: the unique ``(768, 256)`` FC (in-graph, patchable).
"""
from __future__ import annotations
import hashlib
import mmap
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import lora_core as lc # noqa: E402
# Bump when the merge math or mapping changes so stale cache entries are missed.
_MERGE_VERSION = "1"
_QUANTIZED_PRECISIONS = ("w8a32", "w8a8-dyn", "w4a32")
# ── FlatBuffer FC-weight discovery ─────────────────────────────────────────────
def _fc_weight_buffers(buf):
"""Yield one record per FULLY_CONNECTED weight, in graph topological order:
``dict(name, shape, off, size, np_dtype, op_index)``.
Resolves the weight buffer through a DEQUANTIZE (w16a32: fp16 const) or takes
the FC's weight input directly (fp32: fp32 const). Refuses int8 weights.
"""
from ai_edge_litert import schema_py_generated as schema
BO = schema.BuiltinOperator
TT = schema.TensorType
_NP = {TT.FLOAT32: np.float32, TT.FLOAT16: np.float16}
m = schema.Model.GetRootAs(buf, 0)
sg = m.Subgraphs(0)
producer = {}
for oi in range(sg.OperatorsLength()):
op = sg.Operators(oi)
bc = m.OperatorCodes(op.OpcodeIndex()).BuiltinCode()
for j in range(op.OutputsLength()):
producer[op.Outputs(j)] = (bc, op)
for oi in range(sg.OperatorsLength()):
op = sg.Operators(oi)
if m.OperatorCodes(op.OpcodeIndex()).BuiltinCode() != BO.FULLY_CONNECTED:
continue
w_idx = op.Inputs(1)
wt = sg.Tensors(w_idx)
shape = tuple(wt.Shape(i) for i in range(wt.ShapeLength()))
name = (wt.Name() or b"?").decode()
prod = producer.get(w_idx)
if prod is not None and prod[0] == BO.DEQUANTIZE:
# w16a32: FC weight ← DEQUANTIZE ← fp16 const (the patch target).
src = sg.Tensors(prod[1].Inputs(0))
ttype = src.Type()
b = m.Buffers(src.Buffer())
src_name = (src.Name() or wt.Name() or b"?").decode()
else:
ttype, b, src_name = wt.Type(), m.Buffers(wt.Buffer()), name
if ttype not in _NP:
raise lc.LoraError(
f"FC weight {name!r} is {TT.__dict__ and _tt_name(ttype)} — LoRA merge "
f"on the TFLite backend supports fp32 and w16a32 (fp16) weights only. "
f"Quantized-int8 models (w8a32 / w8a8-dyn / w4a32) are not supported "
f"(merging would destroy the GPTQ grid). Use --precision fp32 or w16a32."
)
off, size = b.Offset(), b.Size()
if off == 0 or size == 0:
raise lc.LoraError(
f"FC weight {name!r} is not stored in an out-of-band buffer "
f"(off={off}, size={size}) — cannot patch in place."
)
yield dict(name=name, dbg=src_name, shape=shape, off=off, size=size,
np_dtype=_NP[ttype], op_index=oi)
def _tt_name(code):
from ai_edge_litert import schema_py_generated as schema
return next((k for k, v in schema.TensorType.__dict__.items() if v == code), str(code))
# ── name classification → checkpoint-layer resolution ──────────────────────────
_LEAF = re.compile(r"torch\.nn\.modules\.linear\.Linear_([A-Za-z0-9_]+)")
_BLOCK = re.compile(r"TransformerBlock_(\d+)/")
# checkpoint (post model.-strip) block-module suffix → (module tag, tflite leaf, glu)
_BLOCK_CKPT = {
"self_attn.to_qkv": ("self_attn", "to_qkv", False),
"self_attn.to_out": ("self_attn", "to_out", False),
"cross_attn.to_q": ("cross_attn", "to_q", False),
"cross_attn.to_kv": ("cross_attn", "to_kv", False),
"cross_attn.to_out": ("cross_attn", "to_out", False),
"ff.ff.0.proj": ("ff", "proj", True),
"ff.ff.2": ("ff", "2", False),
# to_local_embed handled separately (topological order); accept both the
# torch (.0/.2) and MLX (.seq.0/.seq.2) spellings.
"to_local_embed.0": ("local_embed", "0", False),
"to_local_embed.2": ("local_embed", "2", False),
"to_local_embed.seq.0": ("local_embed", "0", False),
"to_local_embed.seq.2": ("local_embed", "2", False),
}
# non-block singleton checkpoint names → a substring that uniquely identifies the
# tflite FC name (verified against the exported graph).
_SINGLETON_CKPT = {
"transformer.project_in": "Linear_project_in",
"transformer.project_out": "Linear_project_out",
"to_timestep_embed.0": "Sequential_to_timestep_embed/torch.nn.modules.linear.Linear_0",
"to_timestep_embed.2": "Sequential_to_timestep_embed/torch.nn.modules.linear.Linear_2",
"to_cond_embed.0": "Sequential_to_cond_embed/torch.nn.modules.linear.Linear_0",
"to_cond_embed.2": "Sequential_to_cond_embed/torch.nn.modules.linear.Linear_2",
"to_global_embed.0": "Sequential_to_global_embed/torch.nn.modules.linear.Linear_0",
"to_global_embed.2": "Sequential_to_global_embed/torch.nn.modules.linear.Linear_2",
"transformer.global_cond_embedder.0": "Sequential_global_cond_embedder/torch.nn.modules.linear.Linear_0",
"transformer.global_cond_embedder.2": "Sequential_global_cond_embedder/torch.nn.modules.linear.Linear_2",
}
def _module_tag(name: str) -> str | None:
if "SelfAttention_self_attn" in name:
return "self_attn"
if "CrossAttention_cross_attn" in name:
return "cross_attn"
if "Seq_to_local_embed" in name or "Sequential_seq" in name:
return "local_embed"
if "FeedForward_ff" in name:
return "ff"
return None
class _Mapper:
"""Resolves checkpoint layer names → FC weight records for one base model."""
def __init__(self, fcs):
self._fcs = fcs
# tier-1 block linears: (block, module, leaf, glu) → record
self._block = {}
# tier-2 local_embed: leaf ('0'/'2') → [records in topological order]
self._local = {"0": [], "2": []}
self._seconds = [] # tier-4
for rec in fcs: # fcs already in topological (op) order
name = rec["name"]
mod = _module_tag(name)
mleaf = _LEAF.search(name)
leaf = mleaf.group(1) if mleaf else None
mblk = _BLOCK.search(name)
if mod == "local_embed" and leaf in ("0", "2"):
self._local[leaf].append(rec)
elif mod in ("self_attn", "cross_attn", "ff") and mblk and leaf:
glu = "_GLUWrap_0" in name
self._block[(int(mblk.group(1)), mod, leaf, glu)] = rec
if rec["shape"] == (768, 256):
self._seconds.append(rec)
def resolve(self, layer: str, want_shape):
"""Return the FC record for a checkpoint layer, or raise LoraError."""
if layer == lc.COND_SECONDS_LAYER:
cands = [r for r in self._seconds if r["shape"] == want_shape]
return self._one(layer, want_shape, cands, "seconds conditioner")
stripped = layer[len("model."):] if layer.startswith("model.") else layer
mm = re.match(r"transformer\.layers\.(\d+)\.(.+)$", stripped)
if mm:
block, suffix = int(mm.group(1)), mm.group(2)
spec = _BLOCK_CKPT.get(suffix)
if spec is None:
raise lc.LoraError(f"{layer}: unrecognised block sub-module {suffix!r}")
module, leaf, glu = spec
if module == "local_embed":
# tier 2 — topological rank within the group is the block index.
group = self._local[leaf]
if block >= len(group):
raise lc.LoraError(
f"{layer}: to_local_embed block {block} out of range "
f"(only {len(group)} in the graph)")
rec = group[block]
if rec["shape"] != tuple(want_shape):
raise lc.LoraError(
f"{layer}: shape {rec['shape']} != adapter {tuple(want_shape)} "
f"— wrong base model?")
return rec
rec = self._block.get((block, module, leaf, glu))
cands = [rec] if rec is not None and rec["shape"] == tuple(want_shape) else []
return self._one(layer, want_shape, cands, "block linear")
sub = _SINGLETON_CKPT.get(stripped)
if sub is None:
raise lc.LoraError(f"{layer}: no TFLite tensor mapping for this layer")
cands = [r for r in self._fcs if sub in r["name"] and r["shape"] == tuple(want_shape)]
return self._one(layer, want_shape, cands, "singleton")
@staticmethod
def _one(layer, want_shape, cands, tier):
if len(cands) == 1:
return cands[0]
raise lc.LoraError(
f"{layer}: expected exactly 1 matching FC ({tier}, shape {tuple(want_shape)}), "
f"found {len(cands)} — wrong base model or unsupported target module?"
)
# ── clone + patch + cache ──────────────────────────────────────────────────────
def _clone(src: str, dst: str) -> None:
"""COW-clone src→dst (APFS ``cp -c``, instant) or fall back to a full copy."""
try:
r = subprocess.run(["cp", "-c", src, dst], capture_output=True)
if r.returncode == 0 and os.path.exists(dst):
return
except (OSError, FileNotFoundError):
pass
shutil.copyfile(src, dst)
def _cache_key(base_path: str, specs) -> str:
h = hashlib.sha256()
h.update(_MERGE_VERSION.encode())
st = os.stat(base_path)
h.update(f"{os.path.basename(base_path)}:{st.st_size}".encode())
for path, strength in sorted((lc._resolve_path(s["path"]), s["strength"]) for s in specs):
fh = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
fh.update(chunk)
h.update(f"{fh.hexdigest()}:{strength}".encode())
return h.hexdigest()[:12]
def get_patched_dit(base_path, specs, *, family: str, precision: str,
cache_dir=None, log=print) -> Path:
"""Return a path to a DiT ``.tflite`` with ``specs`` (from
:func:`lora_core.parse_lora_spec`) merged into its weights. Result is cached
by content hash; a hit returns instantly. Raises LoraError on any mapping,
shape, precision, or trust-boundary failure — never mis-patches silently.
"""
base_path = str(base_path)
if precision in _QUANTIZED_PRECISIONS:
raise lc.LoraError(
f"--lora is not supported with --precision {precision} (quantized-int8) — "
f"merging int8 weights would destroy the GPTQ grid. Use fp32 or w16a32."
)
# Cache next to the family dirs (models/tflite/lora_cache/) — use the
# base's OWN path, not resolve(): the base is usually a symlink into the HF
# cache, and resolving it would drop the patched (multi-GB) clones there.
cache_dir = Path(cache_dir or (Path(base_path).parent.parent / "lora_cache"))
cache_dir.mkdir(parents=True, exist_ok=True)
key = _cache_key(base_path, specs)
out = cache_dir / f"dit_{family}_{precision}_{key}.tflite"
if out.exists():
log(f"lora: reusing cached patched model {out.name}")
return out
# Parse all adapters up front (fail fast before the clone).
parsed = []
for s in specs:
path = lc._resolve_path(s["path"])
adapter_type, scaling, layers = lc.parse_adapter(path)
parsed.append((os.path.basename(path), adapter_type, scaling,
float(s["strength"]), layers))
log(f"lora: {os.path.basename(path)} — {adapter_type}, "
f"scaling {scaling:g}, strength {s['strength']:g}, {len(layers)} layers")
# Map every adapter layer against the base model BEFORE cloning (so a
# wrong-base adapter errors without leaving a stale clone behind).
with open(base_path, "rb") as f:
buf = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
fcs = list(_fc_weight_buffers(buf))
mapper = _Mapper(fcs)
# accum[off] = [record, W0(fp32), summed_delta(fp32)]
accum = {}
matched = 0
for name, atype, scaling, strength, layers in parsed:
need = lc._PARAMS_FOR.get(atype, ())
for layer, params in layers.items():
want = (params.get("lora_B", params.get("M_xs")).shape[0],
params["lora_A"].shape[1] if "lora_A" in params
else params["M_xs"].shape[1])
# For -xs, fan_in comes from the base weight; resolve then re-check.
rec = mapper.resolve(layer, want if atype not in
("lora-xs", "dora-rows-xs", "dora-cols-xs", "bora-xs")
else _shape_from_leaf(mapper, layer, params))
W0 = np.frombuffer(buf, rec["np_dtype"],
count=rec["size"] // np.dtype(rec["np_dtype"]).itemsize,
offset=rec["off"]).reshape(rec["shape"]).astype(np.float32)
missing = [n for n in need if n not in params]
if missing:
raise lc.LoraError(f"{layer}: adapter is {atype} but missing {missing}")
lc.check_shapes(layer, W0, params, atype)
merged = lc.merged_weight(W0, params, atype, scaling)
delta = strength * (merged - W0)
if rec["off"] in accum:
accum[rec["off"]][2] += delta
else:
accum[rec["off"]] = [rec, W0, delta]
matched += 1
buf.close()
if not matched:
raise lc.LoraError("no adapter layers matched this DiT (wrong base model?)")
log(f"lora: mapped {len(accum)} weight tensor(s) across {matched} adapter layer(s)")
# Clone, then patch each buffer once (W0 + summed delta).
tmp = out.with_suffix(".tflite.tmp")
if tmp.exists():
tmp.unlink()
_clone(base_path, str(tmp))
n_bytes = 0
with open(tmp, "r+b") as fw:
for off, (rec, W0, delta) in accum.items():
Wp = (W0 + delta).astype(rec["np_dtype"])
b = Wp.tobytes()
assert len(b) == rec["size"], (rec["name"], len(b), rec["size"])
fw.seek(off)
fw.write(b)
n_bytes += rec["size"]
fw.flush()
os.fsync(fw.fileno())
os.replace(tmp, out)
log(f"lora: patched {n_bytes / 1e6:.0f} MB → {out.name}")
return out
def _shape_from_leaf(mapper: _Mapper, layer: str, params: dict):
"""For -xs adapters the fan_in isn't in the params (only M_xs r×r), so map by
a shape probe: try each FC whose descriptor matches and return its shape.
Resolves by re-using the mapper's block/singleton/seconds index with a
wildcard shape, then reads the found record's shape."""
# Cheap approach: the -xs magnitude (rows/cols) or M_xs can't give fan_in, so
# locate the record ignoring shape via a temporary relaxation.
stripped = layer[len("model."):] if layer.startswith("model.") else layer
if layer == lc.COND_SECONDS_LAYER and mapper._seconds:
return mapper._seconds[0]["shape"]
mm = re.match(r"transformer\.layers\.(\d+)\.(.+)$", stripped)
if mm:
block, suffix = int(mm.group(1)), mm.group(2)
spec = _BLOCK_CKPT.get(suffix)
if spec:
module, leaf, glu = spec
if module == "local_embed":
grp = mapper._local[leaf]
if block < len(grp):
return grp[block]["shape"]
else:
rec = mapper._block.get((block, module, leaf, glu))
if rec:
return rec["shape"]
sub = _SINGLETON_CKPT.get(stripped)
if sub:
for r in mapper._fcs:
if sub in r["name"]:
return r["shape"]
raise lc.LoraError(f"{layer}: cannot locate a base weight for this -xs layer")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,932 @@
"""SA3 text-to-audio (+ audio-to-audio + inpainting + CFG) on CPU via the BAKED-I/O
varlen TFLite / LiteRT models — the portable-CPU sibling of the MLX and TensorRT
releases. CLI at feature parity with sa3_mlx.py / sa3_trt.py, sharing their flag names.
Baked I/O (ONNX / TensorRT convention — conditioner + patch/unpatch are IN-GRAPH):
DiT (6-input): x[1,256,L], t[1], t5_hidden[1,256,768], t5_mask[1,256],
seconds_total[1], local_add_cond[1,257,L] -> velocity[1,256,L]
(feed RAW T5 outputs; prompt-padding + seconds-embed happen in-graph)
Encoder (audio-in): audio[1,2,N] -> latents[1,256,N/4096] (patch-encode baked)
Decoder (audio-out): latents[1,256,L] -> audio[1,2,L*4096] (unpatch baked)
T5Gemma: t5gemma/encoder_fp16.tflite (fixed 256 text tokens; tokenizer bundled)
Modes (identical flags to sa3_mlx / sa3_trt):
text-to-audio --prompt P
audio-to-audio --prompt P --init-audio IN.wav [--init-noise-level σ]
inpainting --prompt P --init-audio IN.wav --inpaint-range START,END
negative CFG --prompt P --cfg N [--negative-prompt P_NEG] [--apg A]
CFG uses the TRT/ONNX baked-conditioner convention: the uncond branch feeds the negative
prompt's T5 output (or an all-zero hidden+mask, which the in-graph conditioner turns into
learned padding embeddings) — it does NOT zero cross_attn like MLX (our DiT bakes the
conditioner, so there's no raw cross_attn). CFG runs cond+uncond as ONE batch=2 invoke on the
variable-batch DiT (--cfg-batched, default; ~7-29%% faster on Apple-Silicon AMX) or as a
sequential batch=1 dual-pass (--no-cfg-batched). Bit-identical at fp32/w8a32; ~80 dB at
w16a32; w8a8-dyn diverges by design (activation scales shared across the batch).
All model files auto-download from HuggingFace (stabilityai/stable-audio-3-optimized)
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, 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
from models.defs import tflite_pipeline as P # Tokenizer, T5GemmaTFLite, build_pingpong_schedule, make_noise, sample, save_wav
from weights import ensure_local, is_present, PRECISIONS, dit_rel, dec_rel, enc_rel
SAMPLE_RATE = 44100
SAMPLES_PER_LATENT = 4096
COND_TOKENS = 256
COND_DIM = 768
MIN_SIGMA = 0.01 # rf_denoiser is undefined at t≈0 → NaN below this
# ─── Model manifest (local rel paths; resolved via weights.ensure_local at load) ───
# --precision picks the DiT + decoder variant (same flag as the TensorRT release's
# --precision; values here use the wXaY convention — weights/activations bit-widths,
# "16" = fp16, there is no int16). Encoders + T5Gemma have one precision, like TRT.
# fp32 reference models (default — on CPU fp16 is SLOWER than fp32, so unlike
# TRT the fastest-and-accurate choice is fp32)
# w16a32 fp16 weights / fp32 activations — half size, ≈lossless, 1.5-3× slower
# on an M4 Pro (legacy name: fp16; ≈ TRT's fp16 in spirit,
# storage-only here)
# w8a32 GPTQ weight-only int8 — ¼ size at fp32 speed (legacy: woint8)
# w8a8-dyn GPTQ dynamic int8 — fastest (~1.2-1.3×), lowest quality (legacy: dynint8)
# PRECISIONS + the path builders live in weights.py (single source of truth with
# the download manifest — a precision the CLI accepts is guaranteed downloadable).
DIT_REL = {d: dit_rel(d) for d in ("sm-music", "sm-sfx", "medium")} # fp32 (picker)
DEC_REL = {d: dec_rel(d) for d in ("same-s", "same-l")}
ENC_REL = {d: enc_rel(d) for d in ("same-s", "same-l")}
T5_REL = "models/tflite/t5gemma/encoder_fp16.tflite"
DEFAULT_DECODER = {"sm-music": "same-s", "sm-sfx": "same-s", "medium": "same-l"}
# SAME-L dense SWA mask is O(S^2): chunk long decodes. SAME-S has a narrow field: whole.
SAMEL_CHUNK = 64 # latent tokens/window — throughput optimum (6.5x RT) vs the O(S^2) dense SWA mask
SAMEL_OVERLAP = 8 # symmetric latent-token context each interior side (SAME-L needs >=8)
def _free():
import gc; gc.collect()
# ─── ANSI display (match sa3_mlx.py style) ─────────────────────────────────
_USE_COLOR = sys.stdout.isatty()
_RULE_W = 64
def _c(code, s): return f"\x1b[{code}m{s}\x1b[0m" if _USE_COLOR else s
def bold(s): return _c("1", s)
def dim(s): return _c("2", s)
def cyan(s): return _c("36", s)
def yellow(s): return _c("33", s)
def green(s): return _c("32", s)
def magenta(s): return _c("35", s)
def red(s): return _c("31", s)
def rule(ch="━"): print(cyan(ch * _RULE_W))
def banner(t):
rule(); print(f" {bold(t)}"); rule()
def stage(idx, label, ms=None):
head = f" {cyan(idx)} {bold(label)}"
if ms is None:
print(head, flush=True); return
visible = len(f" {idx} {label}")
dots = dim("·" * max(2, _RULE_W - visible - 9))
print(f"{head} {dots} {yellow(f'{ms:>5.0f} ms')}", flush=True)
def sub(t): print(f" {dim(t)}", flush=True)
# ─── Interactive arrow-key picker (from sa3_mlx.py) ────────────────────────
def _arrow_pick(prompt: str, options: list[str], default: str | None = None) -> str:
"""Tiny arrow-key picker — no external deps, posix termios only.
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 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 " "
print(f" {mark} [{i}] {o}")
s = input(f"Choose [0-{len(options)-1}] (Enter for default): ").strip()
if s == "":
return default or options[0]
if s.isdigit() and 0 <= int(s) < len(options):
return options[int(s)]
return s if s in options else (default or options[0])
idx = options.index(default) if default in options else 0
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
print(prompt)
for _ in options:
print()
try:
tty.setcbreak(fd)
while True:
sys.stdout.write(f"\x1b[{len(options)}A")
for i, o in enumerate(options):
if i == idx:
sys.stdout.write(f"\x1b[2K\x1b[36m▶ {o}\x1b[0m\n")
else:
sys.stdout.write(f"\x1b[2K {o}\n")
sys.stdout.flush()
ch = sys.stdin.read(1)
if ch == "\x1b":
seq = sys.stdin.read(2)
if seq == "[A":
idx = (idx - 1) % len(options)
elif seq == "[B":
idx = (idx + 1) % len(options)
elif ch in ("\n", "\r"):
return options[idx]
elif ch == "\x03":
raise KeyboardInterrupt
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def prompt_user_if_missing(args):
"""Interactive arrow-key selection when --dit / --decoder aren't supplied."""
if args.dit is None:
args.dit = _arrow_pick("Choose DiT model:", list(DIT_REL.keys()), default="sm-music")
print(f" → {args.dit}")
if args.decoder is None:
suggested = DEFAULT_DECODER[args.dit]
args.decoder = _arrow_pick("Choose audio decoder:", list(DEC_REL.keys()), default=suggested)
print(f" → {args.decoder}")
if args.seed is None:
args.seed = random.randint(0, 2**31 - 1)
return args
def _preflight_download(args, dec) -> None:
"""Resolve every model file this run needs and download any that are missing —
BEFORE the banner prints and BEFORE the wall-clock starts. Network time then
isn't charged against '×realtime' and the user sees download progress as a
clearly separate setup step."""
needed = [T5_REL, dit_rel(args.dit, args.dit_precision), dec_rel(dec, args.decoder_precision)]
if args.init_audio:
needed.append(enc_rel(dec, args.encoder_precision))
missing = [p for p in needed if not is_present(p)]
if not missing:
return
print(f" Fetching {len(missing)} missing model file(s) before starting:")
for rel in missing:
ensure_local(rel)
print()
# ─── WAV read (16-bit PCM fast path + ffmpeg fallback) ─────────────────────
def read_wav(path: str) -> np.ndarray:
"""Return (2, T) float32 in [-1, 1]. 16-bit/44.1k native; else via ffmpeg. Mono→stereo."""
try:
with wave.open(path, "rb") as w:
nch, sw, sr, n = w.getnchannels(), w.getsampwidth(), w.getframerate(), w.getnframes()
if sr == SAMPLE_RATE and sw == 2:
raw = np.frombuffer(w.readframes(n), np.int16).astype(np.float32) / 32767.0
if nch == 1:
return np.stack([raw, raw], 0)
return raw.reshape(-1, nch).T[:2]
except wave.Error:
pass
try:
r = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-f", "s16le",
"-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: {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
return raw.reshape(-1, 2).T
# ─── Baked DiT backend (6-input; batched OR sequential CFG + inpaint local_add_cond) ───
def _apply_cfg(x, t, v_cond, v_uncond, cfg, apg):
"""Combine cond/uncond velocities in denoised space (RF), optional APG. fp32.
Matches sa3_trt_core.model_fn. Returns cfg_v so `denoised = x - t*v` is the guided one."""
sigma = np.float32(t)
xf = x.astype(np.float32)
cond_d = xf - v_cond.astype(np.float32) * sigma
uncond_d = xf - v_uncond.astype(np.float32) * sigma
diff = cond_d - uncond_d
if apg <= 0.0:
cfg_diff = diff
else:
norm = np.sqrt((cond_d * cond_d).sum(axis=(-2, -1), keepdims=True))
unit = cond_d / np.maximum(norm, 1e-8)
parallel = (diff * unit).sum(axis=(-2, -1), keepdims=True) * unit
diff_orth = diff - parallel
cfg_diff = diff_orth if apg >= 1.0 else (apg * diff_orth + (1.0 - apg) * diff)
cfg_d = cond_d + (cfg - 1.0) * cfg_diff
return ((xf - cfg_d) / sigma).astype(np.float32)
class BakedDiT:
"""model_fn(x,t,cross,gcond)->v compatible with P.sample. cross/gcond are IGNORED
(conditioning is baked in-graph, driven by the raw T5 outputs held here).
cfg==1.0 -> one batch=1 forward with the (cond) T5 output.
cfg!=1.0 -> CFG combining a cond and an uncond (null_hidden/null_mask) velocity in
denoised space with optional APG. Two backends (bit-identical output at
fp32/w8a32; ~80 dB at w16a32; w8a8-dyn diverges — batch-shared activation
scales; auto-falls-back to batch=1 for cfg==1.0):
batched=True (default): ONE batch=2 invoke/step — cond=row0, uncond=row1 — on the
canonical variable-batch DiT (its batch axis is dynamic). ~7-29%% faster
on Apple-Silicon (the AMX matrix unit amortizes weight loads across the 2 rows).
batched=False: SEQUENTIAL dual-pass — two batch=1 invokes/step, like TensorRT (whose
engine is static-batch=1). Use if a backend/delegate dislikes batch=2.
Returns cfg_v so P.sample's `denoised = x - t*v` yields the guided denoised."""
def __init__(self, path, L, t5_hidden, t5_mask, seconds, threads=8,
cfg=1.0, apg=1.0, null_hidden=None, null_mask=None, local_add_cond=None,
batched=True):
from ai_edge_litert import interpreter as tfl
self.it = tfl.Interpreter(model_path=str(path), num_threads=threads)
det = self.it.get_input_details()
def pick(pred): return [d for d in det if pred(d)]
self.i_x = pick(lambda d: len(d["shape"]) == 3 and d["shape"][1] == 256 and d["shape"][2] != COND_DIM)[0]
self.i_lac = pick(lambda d: len(d["shape"]) == 3 and d["shape"][1] == 257)[0]
self.i_t5h = pick(lambda d: len(d["shape"]) == 3 and d["shape"][2] == COND_DIM)[0]
self.i_t5m = pick(lambda d: len(d["shape"]) == 2)[0]
scalars = sorted(pick(lambda d: len(d["shape"]) == 1), key=lambda d: d["name"])
self.i_t, self.i_sec = scalars[0], scalars[1] # args_1=t < args_4=seconds by name
self.out = self.it.get_output_details()[0]["index"]
# Track the currently-allocated (batch, length) so set_conditioning can skip the
# expensive resize+allocate when only the conditioning tensors change.
self._alloc_B = self._alloc_L = None
self.set_conditioning(L, t5_hidden, t5_mask, seconds, cfg=cfg, apg=apg,
null_hidden=null_hidden, null_mask=null_mask,
local_add_cond=local_add_cond, batched=batched)
def set_conditioning(self, L, t5_hidden, t5_mask, seconds, *, cfg=1.0, apg=1.0,
null_hidden=None, null_mask=None, local_add_cond=None, batched=True):
"""(Re)bind per-generation conditioning onto this (possibly reused) interpreter.
The baked DiT holds the conditioning (T5 hidden/mask, seconds, local_add_cond) as
RESIDENT input tensors — a fresh generation only changes those, not the graph. A
cached BakedDiT can therefore be re-pointed at a new prompt/length WITHOUT rebuilding
the interpreter or re-packing XNNPACK's weights: resize+allocate_tensors runs only
when the batch (cfg on/off) or length L actually changes, otherwise the residents are
overwritten in place (same reuse pattern BakedDecoder/BakedEncoder use for L). Called
from __init__ (so the CLI's one-shot path is unchanged) and by the gradio DiT cache."""
self.L = L
self.cfg = float(cfg); self.apg = float(apg)
self.n_fwd = 0
# Batched CFG only applies when there's an uncond branch to co-batch (cfg != 1.0).
self.batched = bool(batched) and self.cfg != 1.0
B = 2 if self.batched else 1
self.B = B
# Resize batch axis to B (the canonical DiT is variable-batch) + length axis to L.
if (self._alloc_B, self._alloc_L) != (B, L):
self.it.resize_tensor_input(self.i_x["index"], [B, 256, L], strict=False)
self.it.resize_tensor_input(self.i_lac["index"], [B, 257, L], strict=False)
self.it.resize_tensor_input(self.i_t5h["index"], [B, COND_TOKENS, COND_DIM], strict=False)
self.it.resize_tensor_input(self.i_t5m["index"], [B, COND_TOKENS], strict=False)
self.it.resize_tensor_input(self.i_t["index"], [B], strict=False)
self.it.resize_tensor_input(self.i_sec["index"], [B], strict=False)
self.it.allocate_tensors()
self._alloc_B, self._alloc_L = B, L
t5h = t5_hidden.astype(np.float32)
t5m = t5_mask.astype(np.float32)
sec = np.array([np.float32(seconds)], np.float32)
lac = (np.zeros((1, 257, L), np.float32) if local_add_cond is None
else local_add_cond.astype(np.float32))
self.null_h = None if null_hidden is None else null_hidden.astype(np.float32)
self.null_m = None if null_mask is None else null_mask.astype(np.float32)
if self.batched:
# row0 = cond, row1 = uncond. t5_hidden/mask differ per row; seconds + lac are
# shared → tiled. All four are constant across steps → set resident once.
self.it.set_tensor(self.i_t5h["index"], np.concatenate([t5h, self.null_h], axis=0))
self.it.set_tensor(self.i_t5m["index"], np.concatenate([t5m, self.null_m], axis=0))
self.it.set_tensor(self.i_sec["index"], np.concatenate([sec, sec], axis=0))
self.it.set_tensor(self.i_lac["index"], np.concatenate([lac, lac], axis=0))
else:
self.t5h, self.t5m = t5h, t5m
self.it.set_tensor(self.i_sec["index"], sec) # seconds + lac constant → resident
self.it.set_tensor(self.i_lac["index"], lac)
return self
def _fwd(self, x, t, t5h, t5m):
"""One batch=1 invoke (cfg==1.0 or sequential CFG)."""
self.it.set_tensor(self.i_x["index"], x.astype(np.float32))
self.it.set_tensor(self.i_t["index"], np.array([np.float32(t)], np.float32))
self.it.set_tensor(self.i_t5h["index"], t5h)
self.it.set_tensor(self.i_t5m["index"], t5m)
self.it.invoke()
self.n_fwd += 1
return self.it.get_tensor(self.out).copy()
def _fwd_batched(self, x, t):
"""One batch=2 invoke -> (v_cond, v_uncond). x is the shared batch=1 state (both CFG
branches denoise the same latent), tiled to [2,256,L]; t5h/t5m/sec/lac already resident."""
x2 = np.concatenate([x, x], axis=0).astype(np.float32)
self.it.set_tensor(self.i_x["index"], x2)
self.it.set_tensor(self.i_t["index"], np.array([np.float32(t), np.float32(t)], np.float32))
self.it.invoke()
self.n_fwd += 2
v2 = self.it.get_tensor(self.out)
return v2[0:1].copy(), v2[1:2].copy()
def __call__(self, x, t, cross=None, gcond=None):
if self.cfg == 1.0:
return self._fwd(x, t, self.t5h, self.t5m)
if self.batched:
v_cond, v_uncond = self._fwd_batched(x, t)
else:
v_cond = self._fwd(x, t, self.t5h, self.t5m)
v_uncond = self._fwd(x, t, self.null_h, self.null_m)
return _apply_cfg(x, t, v_cond, v_uncond, self.cfg, self.apg)
# ─── Baked audio-in encoder (audio -> latents; SAME-S needs even L) ────────
class BakedEncoder:
def __init__(self, path, threads=8, needs_even=False):
from ai_edge_litert import interpreter as tfl
self.it = tfl.Interpreter(model_path=str(path), num_threads=threads)
self.i = self.it.get_input_details()[0]["index"]
self.o = self.it.get_output_details()[0]["index"]
self.needs_even = needs_even
self._cur_N = None
def _resize(self, N):
if self._cur_N != N:
self.it.resize_tensor_input(self.i, [1, 2, N], strict=False)
self.it.allocate_tensors()
self._cur_N = N
def encode(self, audio, T_lat):
"""audio: (1,2,M), M a multiple of 4096 (caller pads to even L for SAME-S).
Returns latents (1,256,T_lat) — trimmed back to the natural (decoder-independent) T_lat."""
self._resize(audio.shape[-1])
self.it.set_tensor(self.i, audio.astype(np.float32))
self.it.invoke()
return self.it.get_tensor(self.o)[:, :, :T_lat].copy()
# ─── Baked audio-out decoder (whole for SAME-S; chunked for SAME-L) ─────────
class BakedDecoder:
def __init__(self, path, threads=8, needs_even=False):
from ai_edge_litert import interpreter as tfl
self.tfl = tfl
self.path = str(path)
self.threads = threads
self.needs_even = needs_even # SAME-S: T_aud=L*16 must be %32 -> pad odd L->even
self.it = tfl.Interpreter(model_path=self.path, num_threads=threads)
self.i = self.it.get_input_details()[0]["index"]
self.o = self.it.get_output_details()[0]["index"]
self.i_det = self.it.get_input_details()[0]
self._cur_L = None
def _resize(self, L):
if self._cur_L != L:
self.it.resize_tensor_input(self.i, [1, 256, L], strict=False)
self.it.allocate_tensors()
self._cur_L = L
def decode_whole(self, latents):
L = latents.shape[2]
if self.needs_even and L % 2 != 0:
# SAME-S needs even L. Pad one edge-replicated latent token, decode at L+1,
# trim the extra token's audio. Narrow receptive field => negligible boundary
# effect on the kept L*4096 samples. Keeps odd-L requests working without
# changing the DiT/noise path (which stays natural-ceil, MLX/TRT-matched).
latents = np.concatenate([latents, latents[:, :, -1:]], axis=2)
self._resize(L + 1)
self.it.set_tensor(self.i, latents.astype(np.float32))
self.it.invoke()
return self.it.get_tensor(self.o)[:, :, :L * SAMPLES_PER_LATENT].copy()
self._resize(L)
self.it.set_tensor(self.i, latents.astype(np.float32))
self.it.invoke()
return self.it.get_tensor(self.o).copy() # [1,2,L*4096]
def decode_chunked(self, latents, chunk, overlap, on_chunk=None):
"""Stitch audio directly (stride = 4096 samples per latent token)."""
B, C, L = latents.shape
if L <= chunk:
return self.decode_whole(latents)
S = SAMPLES_PER_LATENT
out = np.zeros((B, 2, L * S), np.float32)
K = chunk - 2 * overlap
assert K > 0, (chunk, overlap)
core = 0
n_windows = (L + K - 1) // K
wi = 0
while core < L:
core_end = min(core + K, L)
win_start = core - overlap
win_end = win_start + chunk
if win_start < 0:
win_start, win_end = 0, chunk
if win_end > L:
win_end, win_start = L, L - chunk
win = latents[:, :, win_start:win_end]
y = self.decode_whole(win) # [1,2,chunk*4096]
ks = (core - win_start) * S
ke = (core_end - win_start) * S
out[:, :, core * S: core_end * S] = y[:, :, ks:ke]
wi += 1
if on_chunk:
on_chunk(wi, n_windows)
core = core_end
return out
def valid_T_lat(seconds):
"""seconds -> T_lat via natural ceil, DECODER-INDEPENDENT and identical to MLX/TRT
(sa3_mlx / sa3_trt.resolve_T_lat = max(1, ceil(seconds*44100/4096))). So MLX == TRT ==
TFLite pick the SAME length -> no length-driven divergence, and true ODD-length
requests are honored. The DiT handles odd L natively; SAME-L takes any L; SAME-S
(even T_aud=L*16 % 32) pads odd->even at encode/decode and trims."""
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."""
def error(self, message):
sys.stderr.write(f"\nerror: {message}\n\n")
self.print_help(sys.stderr)
sys.exit(2)
def print_help(self, file=None):
super().print_help(file)
try:
from examples import print_example_commands
print_example_commands()
except Exception:
pass # never let an examples-block failure mask the actual --help
def main():
ap = _HelpfulParser(
description="SA3 text-to-audio (+ audio-to-audio + inpainting + CFG) — baked-I/O varlen TFLite / CPU",
allow_abbrev=False, # --pr/--di/--de became ambiguous once *-precision flags landed
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=("modes\n"
" text-to-audio --prompt P\n"
" audio-to-audio --prompt P --init-audio IN.wav [--init-noise-level σ]\n"
" inpainting --prompt P --init-audio IN.wav --inpaint-range START,END\n"
" negative CFG --prompt P --cfg N [--negative-prompt P_NEG] [--apg A]\n"))
# Inputs
ap.add_argument("--prompt", default=None,
help="Text prompt. Empty string = unconditional. If omitted, asked via stdin.")
ap.add_argument("--negative-prompt", default=None,
help="Negative prompt for CFG's uncond branch. Ignored when --cfg=1.0. "
"When unset and --cfg≠1.0, the uncond branch uses all-zero T5 hidden+mask "
"(→ learned padding embeddings in-graph).")
ap.add_argument("--init-audio", default=None,
help="WAV to start from. With --init-noise-level → audio-to-audio; with "
"--inpaint-range → inpainting. Encoder loaded automatically; audio is "
"trimmed/zero-padded to --seconds. Any format via ffmpeg fallback.")
ap.add_argument("--inpaint-range", default=None,
help="Inpaint time range 'START,END' in seconds (e.g. '5,10'). Requires "
"--init-audio. Regenerates the masked span, preserves the rest (paste-back).")
# Models
ap.add_argument("--dit", choices=["sm-music", "sm-sfx", "medium"], default=None,
help="DiT model (names match sa3_mlx / sa3_trt). If omitted, prompts "
"interactively with an arrow-key picker.")
ap.add_argument("--decoder", choices=["same-s", "same-l"], default=None,
help="Audio decoder. Default: same-s for sm-music/sm-sfx, same-l for medium. "
"If omitted, prompts interactively with an arrow-key picker.")
ap.add_argument("--precision", choices=list(PRECISIONS), default="fp32",
help="DiT + decoder precision, wXaY = weight/activation bits (same flag "
"as the TensorRT CLI): 'fp32' (default — reference; on CPU also the "
"fast choice) | 'w16a32' (fp16 weights, half size, ≈lossless, 1.5-3× "
"slower) | 'w8a32' (GPTQ int8 weights, ¼ size at fp32 speed) | "
"'w8a8-dyn' (dynamic int8, fastest, lower quality). "
"Applies to the DiT, codec decoder, and (for a2a/inpaint) the "
"encoder; T5Gemma is single-precision. Per-component overrides below.")
ap.add_argument("--dit-precision", choices=list(PRECISIONS), default=None,
help="Override --precision for the DiT only (e.g. a quantized DiT "
"with an fp32 codec).")
ap.add_argument("--decoder-precision", choices=list(PRECISIONS), default=None,
help="Override --precision for the SAME codec only. The codec runs "
"once on a fixed latent, so its precision maps directly to "
"audio quality (w8a32 is transparent at 40-46 dB).")
ap.add_argument("--encoder-precision", choices=list(PRECISIONS), default=None,
help="Override --precision for the SAME encoder (audio-to-audio / "
"inpainting only). Latent PSNR vs fp32 (same-s/same-l): "
"w16a32 ≈lossless (66/71 dB), w8a32 (GPTQ) 36/46 dB, "
"w8a8-dyn 24/30 dB.")
# LoRA (merged into the DiT weights at load; mirrors the MLX CLI's --lora)
ap.add_argument("--lora", action="append", nargs="+", default=None,
metavar=("ADAPTER", "strength=S"),
help="A LoRA adapter to merge into the DiT — repeat the flag to stack "
"several. Each --lora takes the adapter path followed by an optional "
"strength=S (default --lora-strength). Example: "
"--lora plini.safetensors strength=0.8 . The adapter is a .safetensors "
"file (SA3-native train_lora.py / underfit output) or a PEFT adapter "
"directory; ONLY .safetensors is accepted. The merged weights are "
"written into a cached copy of the DiT (models/tflite/lora_cache/) and "
"reused on repeat runs. Requires --dit-precision fp32 or w16a32 "
"(quantized-int8 DiTs can't be LoRA-merged). NOTE: per-step gating "
"(steps=) is MLX-only — the frozen TFLite graph merges once at load.")
ap.add_argument("--lora-strength", type=float, default=1.0,
help="Default strength for --lora adapters without their own strength= "
"(default 1.0). 0 disables (bit-identical to no LoRA); >1 amplifies.")
# Sampling
ap.add_argument("--seconds", type=float, default=30.0,
help="Output length. T_lat = ceil(seconds*44100/4096) (natural ceil, decoder-"
"independent, matches MLX/TRT). Final WAV trimmed to exactly --seconds.")
ap.add_argument("--steps", type=int, default=8,
help="Pingpong sampling steps (≥1). rf_denoiser is distilled for 8 (default).")
ap.add_argument("--seed", type=int, default=None,
help="Random seed. If omitted, a random seed is chosen and printed at the end.")
ap.add_argument("--init-noise-level", type=float, default=1.0,
help="σmax — schedule's starting noise level. With --init-audio: 0.4-0.8 varies, "
"1.0 = full regen (init ignored). Min %.2f (model NaNs at t≈0)." % MIN_SIGMA)
ap.add_argument("--cfg", type=float, default=1.0,
help="Classifier-Free Guidance scale. 1.0 = off (single pass). >1 toward prompt, "
"<1 toward uncond. Any value ≠1.0 runs cond+uncond each step (see --cfg-batched).")
ap.add_argument("--apg", type=float, default=1.0,
help="Adaptive Projected Guidance [0..1], only when --cfg≠1.0. 1.0 = full APG "
"(orthogonal projection), 0.0 = vanilla CFG. rf_denoiser default 1.0.")
ap.add_argument("--cfg-batched", action=argparse.BooleanOptionalAction, default=True,
help="When --cfg≠1.0, run cond+uncond as ONE batch=2 invoke on the variable-batch "
"DiT (default; ~7-29%% faster on Apple-Silicon AMX). --no-cfg-batched forces the "
"sequential batch=1 dual-pass (like TensorRT). Bit-identical at "
"fp32/w8a32; ~80 dB at w16a32; w8a8-dyn diverges by design (batch-"
"shared activation scales) — use --no-cfg-batched to reproduce "
"sequential baselines with w8a8-dyn.")
# Runtime / output
ap.add_argument("--threads", type=int, default=8, help="XNNPACK CPU threads.")
ap.add_argument("--free-models", action=argparse.BooleanOptionalAction, default=True,
help="Free each model after its last use to lower peak RAM (default on).")
ap.add_argument("--out", "-o", default=None,
help="Output WAV path. Relative → output/<file>; absolute → as-is. "
"If omitted, auto-named from the prompt + seed.")
ap.add_argument("--play", action="store_true",
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})")
# per-component overrides fall back to the shared --precision
args.dit_precision = args.dit_precision or args.precision
args.decoder_precision = args.decoder_precision or args.precision
args.encoder_precision = args.encoder_precision or args.precision
# Parse --lora specs up front (fail fast, before any model work).
args.lora_specs = None
if args.lora:
from lora_core import parse_lora_spec, LoraError
try:
args.lora_specs = [parse_lora_spec(toks, args.lora_strength) for toks in args.lora]
except LoraError as e:
ap.error(str(e))
# Interactive fills (match MLX/TRT).
args = prompt_user_if_missing(args)
if args.prompt is None:
args.prompt = input("Prompt: ").strip()
if args.seed is None:
args.seed = random.randint(0, 2**31 - 1)
dec = args.decoder or DEFAULT_DECODER[args.dit]
T_lat = valid_T_lat(args.seconds)
target_dur = T_lat * SAMPLES_PER_LATENT / SAMPLE_RATE
# Resolve output path — auto-name from prompt+seed when --out is not given.
# Relative paths land in output/ (auto-created); absolute paths honored as-is.
# A relative path that already starts with "output/" is taken as-is (relative to
# cwd) so `--out output/foo.wav` doesn't become output/output/foo.wav.
if args.out is None:
slug = re.sub(r'[^a-z0-9]+', '_', args.prompt.lower()).strip('_')[:48] or "out"
# non-fp32 runs get a precision suffix so same-prompt/seed A/B runs across
# --precision values don't overwrite each other
if args.dit_precision == args.decoder_precision:
prec_tag = "" if args.precision == "fp32" else f"_{args.precision}"
else:
prec_tag = f"_dit-{args.dit_precision}_dec-{args.decoder_precision}"
lora_tag = ""
if args.lora_specs:
names = "-".join(re.sub(r'[^a-z0-9]+', '', Path(s["path"]).stem.lower())[:12]
for s in args.lora_specs)
lora_tag = f"_lora-{names}"
args.out = f"{slug}{prec_tag}{lora_tag}_{args.seed}.wav"
out_path = Path(args.out)
if not out_path.is_absolute() and out_path.parts[:1] != ("output",):
out_path = REPO / "output" / out_path
out_path.parent.mkdir(parents=True, exist_ok=True)
args.out = str(out_path)
# Inpaint validation → latent range.
inpaint_range = None
inp_start_sec = inp_end_sec = None
if args.inpaint_range is not None:
if args.init_audio is None:
sys.exit("error: --inpaint-range requires --init-audio (the audio to inpaint into)")
try:
s_str, e_str = args.inpaint_range.split(",")
inp_start_sec = float(s_str.strip()); inp_end_sec = float(e_str.strip())
except ValueError:
sys.exit(f"error: --inpaint-range must be 'START,END' in seconds; got {args.inpaint_range!r}")
if not (0 <= inp_start_sec < inp_end_sec <= args.seconds):
sys.exit(f"error: invalid inpaint range {inp_start_sec}-{inp_end_sec}s "
f"(must satisfy 0 <= start < end <= {args.seconds}s)")
inp_start_lat = max(0, int(round(inp_start_sec * SAMPLE_RATE / SAMPLES_PER_LATENT)))
inp_end_lat = min(T_lat, int(round(inp_end_sec * SAMPLE_RATE / SAMPLES_PER_LATENT)))
inpaint_range = (inp_start_lat, inp_end_lat)
sigma_max = float(args.init_noise_level)
if sigma_max < MIN_SIGMA:
sys.exit(f"error: --init-noise-level={sigma_max} too low (min {MIN_SIGMA}; model NaNs at t≈0)")
mode = ("inpaint" if inpaint_range else
"audio-to-audio" if args.init_audio else "text-to-audio")
# ── Preflight: download any missing model files BEFORE the banner/wall-clock. ──
_preflight_download(args, dec)
# ── Banner ──
print()
banner(f"SA3 → TFLite/CPU {mode}")
k = lambda s: dim(f"{s:>11}")
print(f" {k('prompt')} {bold(repr(args.prompt))}")
if args.negative_prompt:
suffix = "" if args.cfg != 1.0 else dim(" (ignored: --cfg=1.0)")
print(f" {k('neg prompt')} {bold(repr(args.negative_prompt))}{suffix}")
line = f" {k('dit')} {magenta(args.dit)} {k('decoder')} {magenta(dec)}"
if args.init_audio:
enc_note = "" if args.encoder_precision == args.dit_precision else f" ({args.encoder_precision})"
line += f" {k('encoder')} {magenta(dec + enc_note)}"
prec_disp = (args.dit_precision if args.dit_precision == args.decoder_precision
else f"dit {args.dit_precision} · codec {args.decoder_precision}")
line += f" {k('precision')} {magenta(prec_disp)} {k('threads')} {args.threads}"
print(line)
if args.init_audio:
print(f" {k('init audio')} {bold(args.init_audio)}")
if inpaint_range:
s0, s1 = inpaint_range
print(f" {k('inpaint')} {bold(f'{inp_start_sec:.2f}s..{inp_end_sec:.2f}s')} "
f"{dim(f'(latent {s0}..{s1} of {T_lat})')}")
cfg_line = f" {k('σmax')} {bold(f'{sigma_max:.2f}')} {k('cfg')} {args.cfg}"
if args.cfg != 1.0:
cfg_line += dim(f" (apg={args.apg}, {'batched' if args.cfg_batched else 'sequential'} CFG)")
print(cfg_line)
if args.lora_specs:
lora_disp = ", ".join(
os.path.basename(s["path"].rstrip("/"))
+ (f"×{s['strength']:g}" if s["strength"] != 1.0 else "")
for s in args.lora_specs)
print(f" {k('lora')} {magenta(lora_disp)}")
print(f" {k('seconds')} {args.seconds}s {k('steps')} {args.steps} {k('seed')} {args.seed}")
print(f" {k('T_lat')} {T_lat} {dim(f'({target_dur:.2f}s → trimmed to {args.seconds:.2f}s)')}")
print()
# Stage numbering (extra stage when encoding init audio).
N = 3 + (1 if args.init_audio else 0)
TAG = {"t5": f"[1/{N}]"}
if args.init_audio:
TAG.update(enc=f"[2/{N}]", dit=f"[3/{N}]", dec=f"[4/{N}]")
else:
TAG.update(dit=f"[2/{N}]", dec=f"[3/{N}]")
t_wall = time.perf_counter()
# ── T5Gemma (tokenize + encode; + negative prompt for CFG) ──
stage(TAG["t5"], "T5Gemma (tokenize + encode)")
t0 = time.perf_counter()
tok = P.Tokenizer()
ids, mask = tok(args.prompt) # (1,256) int32 each
t5 = P.T5GemmaTFLite(ensure_local(T5_REL), args.threads)
t5_hidden = t5(ids, mask) # (1,256,768) fp32
null_h = null_m = None
if args.cfg != 1.0:
if args.negative_prompt:
n_ids, n_mask = tok(args.negative_prompt)
null_h = t5(n_ids, n_mask)
null_m = n_mask.astype(np.float32)
else:
# All-zero hidden+mask → in-graph conditioner emits learned padding embeds
# for every position (the standard unconditional branch). No extra T5 pass.
null_h = np.zeros((1, COND_TOKENS, COND_DIM), np.float32)
null_m = np.zeros((1, COND_TOKENS), np.float32)
t5_ms = (time.perf_counter() - t0) * 1000
stage(TAG["t5"], "T5Gemma (tokenize + encode)", t5_ms)
sub(f"t5_hidden {t5_hidden.shape} mask sum={int(mask.sum())}"
+ (f" neg ({'prompt' if args.negative_prompt else 'zeros'})" if null_h is not None else ""))
if args.free_models:
del t5; _free()
# ── (audio-to-audio / inpaint) Encode init audio → init_latents ──
init_latents = None
if args.init_audio:
stage(TAG["enc"], f"Encode init audio → latents ({dec})")
t0 = time.perf_counter()
# SAME-S encoder needs even L; round the encode grid up, trim latents to T_lat.
enc_L = T_lat + 1 if (dec == "same-s" and T_lat % 2 != 0) else T_lat
target_samples = enc_L * SAMPLES_PER_LATENT
audio_in = read_wav(args.init_audio) # (2, T_in)
if audio_in.shape[-1] >= target_samples:
audio_in = audio_in[:, :target_samples]
init_action = f"trimmed to {target_samples} samples"
else:
pad = target_samples - audio_in.shape[-1]
audio_in = np.pad(audio_in, ((0, 0), (0, pad)))
init_action = f"zero-padded by {pad} samples"
audio_in = audio_in[None] # (1,2,target_samples)
enc = BakedEncoder(ensure_local(enc_rel(dec, args.encoder_precision)), args.threads, needs_even=(dec == "same-s"))
init_latents = enc.encode(audio_in, T_lat) # (1,256,T_lat)
enc_ms = (time.perf_counter() - t0) * 1000
stage(TAG["enc"], f"Encode init audio → latents ({dec})", enc_ms)
sub(f"{init_action} latents {init_latents.shape}")
if args.free_models:
del enc; _free()
# ── Build inpaint local_add_cond + paste-back, and initial noise ──
local_add_cond = None
paste_back = None
if inpaint_range is not None:
s0, s1 = inpaint_range
keep = np.ones((1, 1, T_lat), np.float32); keep[:, :, s0:s1] = 0.0 # 1=keep, 0=regen
masked = init_latents.astype(np.float32) * keep
local_add_cond = np.concatenate([keep, masked], axis=1) # (1,257,T_lat), TRT layout
paste_back = (init_latents.astype(np.float32), keep)
x0, step_noise = P.make_noise(T_lat, args.steps, args.seed) # x0 = pure noise
if init_latents is not None and inpaint_range is None:
# rf_denoiser init mix (linear interp): noise = init*(1-σmax) + pure*σmax
x0 = init_latents.astype(np.float32) * (1.0 - sigma_max) + x0 * sigma_max
# ── DiT load + pingpong sample ──
stage(TAG["dit"], f"DiT — load + sample ({args.steps} steps, σmax={sigma_max:.2f})")
t0 = time.perf_counter()
cfg_note = (("CFG batched (1× batch=2 invoke/step)" if args.cfg_batched
else "CFG sequential (2× batch=1 invokes/step)") if args.cfg != 1.0 else "")
print(f" {dim('loading baked DiT ' + args.dit + ' ...')}", flush=True)
dit_path = ensure_local(dit_rel(args.dit, args.dit_precision))
if args.lora_specs:
from lora_patch import get_patched_dit
from lora_core import LoraError
try:
dit_path = get_patched_dit(dit_path, args.lora_specs, family=args.dit,
precision=args.dit_precision, log=sub)
except LoraError as e:
sys.exit(f"error: {e}")
backend = BakedDiT(dit_path, T_lat, t5_hidden, mask.astype(np.float32),
args.seconds, args.threads, cfg=args.cfg, apg=args.apg,
null_hidden=null_h, null_mask=null_m, local_add_cond=local_add_cond,
batched=args.cfg_batched)
load_ms = (time.perf_counter() - t0) * 1000
sub(f"load {load_ms/1000:.1f}s" + (f" {cfg_note}" if cfg_note else ""))
sig = P.build_pingpong_schedule(args.steps, sigma_max)
sched_str = " · ".join(f"{float(x):.3f}" for x in sig)
sub(f"schedule {sched_str}")
step_prev = [time.perf_counter()]
def on_step(i, total):
now = time.perf_counter(); el = (now - step_prev[0]) * 1000; step_prev[0] = now
if _USE_COLOR:
bar_w = 20; filled = int(round(bar_w * i / total))
bar = cyan("█" * filled) + dim("·" * (bar_w - filled))
sys.stdout.write(f"\r\x1b[K {dim('sampling')} {bar} "
f"{bold(f'step {i}/{total}')} {yellow(f'{el:.0f} ms')}")
sys.stdout.flush()
else:
print(f" sampling step {i}/{total} {el:.0f} ms", flush=True)
t0 = time.perf_counter()
latents = P.sample(backend, x0, step_noise, sig, None, None,
on_step=on_step, paste_back=paste_back)
samp_ms = (time.perf_counter() - t0) * 1000
if _USE_COLOR:
sys.stdout.write("\r\x1b[K")
stage(TAG["dit"], f"DiT — load + sample ({args.steps} steps, σmax={sigma_max:.2f})",
load_ms + samp_ms)
sub(f"sample {samp_ms:.0f} ms ({samp_ms/max(args.steps,1):.0f} ms/step, "
f"{backend.n_fwd} forwards) latents {latents.shape}")
if args.free_models:
del backend; _free()
# ── Decode (audio-out) + WAV ──
stage(TAG["dec"], f"Decoder ({dec}, audio-out) + WAV")
t0 = time.perf_counter()
print(f" {dim('loading baked decoder ' + dec + ' ...')}", flush=True)
decoder = BakedDecoder(ensure_local(dec_rel(dec, args.decoder_precision)), args.threads, needs_even=(dec == "same-s"))
load2_ms = (time.perf_counter() - t0) * 1000
sub(f"load {load2_ms:.0f} ms")
t0 = time.perf_counter()
if dec == "same-l" and T_lat > SAMEL_CHUNK:
print(f" {dim(f'SAME-L chunked decode (chunk={SAMEL_CHUNK}, ovl={SAMEL_OVERLAP}) ...')}", flush=True)
def on_chunk(i, n):
print(f" {dim(f'decode chunk {i}/{n}')}", flush=True)
audio = decoder.decode_chunked(latents, SAMEL_CHUNK, SAMEL_OVERLAP, on_chunk=on_chunk)
dmode = f"chunked (chunk={SAMEL_CHUNK}, ovl={SAMEL_OVERLAP})"
else:
print(f" {dim('whole decode ...')}", flush=True)
audio = decoder.decode_whole(latents)
dmode = "whole"
dec_ms = (time.perf_counter() - t0) * 1000
audio_np = audio[0] # (2, L*4096)
req = int(round(args.seconds * SAMPLE_RATE))
if audio_np.shape[-1] > req:
audio_np = audio_np[:, :req]
P.save_wav(args.out, audio_np)
stage(TAG["dec"], f"Decoder ({dec}, audio-out) + WAV", load2_ms + dec_ms)
peak = float(np.abs(audio_np).max()); rms = float(np.sqrt((audio_np**2).mean()))
sub(f"decode {dmode} {dec_ms:.0f} ms audio {audio_np.shape} peak {peak:.3f} rms {rms:.3f}")
if args.free_models:
del decoder; _free()
total = time.perf_counter() - t_wall
dur = audio_np.shape[-1] / SAMPLE_RATE
print()
rule()
print(f" {bold(green('done'))} {bold(f'{total:.2f}s')} wall → {dur:.1f}s audio → "
f"{bold(yellow(f'{dur/total:.2f}× realtime'))} {dim(f'seed {args.seed}')}")
abs_out = os.path.abspath(args.out)
try:
rel_out = os.path.relpath(abs_out)
except ValueError:
rel_out = abs_out
shown = rel_out if len(rel_out) <= len(abs_out) and not rel_out.startswith("..") else abs_out
print(f" {bold(green('▸ saved'))} {bold(shown)} {dim(f'({abs_out})' if shown != abs_out else '')}".rstrip())
rule()
if args.play:
kind, argv = _play_backend()
try:
if kind == "winsound":
print(f" {bold('▶ playing')} {args.out} {dim('(Ctrl-C to stop)')}")
import winsound
# SND_ASYNC + an interruptible sleep instead of the synchronous
# call: PlaySound(..., SND_FILENAME) blocks inside native code,
# so Python can't deliver Ctrl-C until playback finishes. This
# way Ctrl-C lands in time.sleep and SND_PURGE stops the audio.
with wave.open(args.out) as _w:
_dur = _w.getnframes() / _w.getframerate()
winsound.PlaySound(args.out,
winsound.SND_FILENAME | winsound.SND_ASYNC)
try:
time.sleep(_dur + 0.25)
except KeyboardInterrupt:
winsound.PlaySound(None, winsound.SND_PURGE)
raise
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()
if __name__ == "__main__":
main()
@@ -0,0 +1,180 @@
"""Mel-spectrogram renderer — numpy-only port of the TensorRT release's spec.py.
Same algorithm and constants as optimized/tensorRT/scripts/spec.py (the 3-band
tinted stereo mel spectrogram from our run dashboards), with the torch pieces
(STFT, resample, filterbank matmul) rewritten in numpy so the MLX release stays
torch-free. Numerics match: periodic Hann window, center=True reflect padding,
Slaney mel scale, librosa-compatible filterbank, linear resample with
align_corners=False sampling positions.
Exposes render_spectrogram_png(pcm_int16, sample_rate, width, height).
"""
from __future__ import annotations
import io
import math
from functools import lru_cache
import numpy as np
from PIL import Image
# 3-band tint
SPEC_BANDS = [
(0, 200, (1.0, 0.0, 0.0)), # Bass -> Red
(200, 1500, (0.0, 1.0, 0.0)), # Mid -> Green
(1500, 16000, (0.0, 0.0, 1.0)), # High -> Blue
]
_BAND_COLORS = np.array([c for _, _, c in SPEC_BANDS], dtype=np.float32)
# Slaney mel-scale constants (linear below 1 kHz, log above), matches librosa default.
_F_SP = 200.0 / 3
_MIN_LOG_HZ = 1000.0
_MIN_LOG_MEL = _MIN_LOG_HZ / _F_SP
_LOGSTEP = math.log(6.4) / 27.0
def _hz_to_mel(hz):
hz = np.asarray(hz, dtype=np.float64)
log_term = np.log(np.maximum(hz, _MIN_LOG_HZ) / _MIN_LOG_HZ) / _LOGSTEP
return np.where(hz >= _MIN_LOG_HZ, _MIN_LOG_MEL + log_term, hz / _F_SP)
def _mel_to_hz(mels):
mels = np.asarray(mels, dtype=np.float64)
return np.where(mels >= _MIN_LOG_MEL,
_MIN_LOG_HZ * np.exp(_LOGSTEP * (mels - _MIN_LOG_MEL)),
_F_SP * mels)
def _mel_frequencies(n_mels, fmax, fmin=0.0):
return _mel_to_hz(np.linspace(_hz_to_mel(fmin), _hz_to_mel(fmax), n_mels))
@lru_cache(maxsize=8)
def _mel_filterbank(n_mels, n_fft, sr, fmax):
pts = _mel_to_hz(np.linspace(_hz_to_mel(0.0), _hz_to_mel(fmax), n_mels + 2))
fft_f = np.linspace(0, sr / 2, n_fft // 2 + 1)
filt = np.zeros((n_mels, n_fft // 2 + 1), dtype=np.float32)
for i in range(n_mels):
lo, ce, hi = pts[i], pts[i + 1], pts[i + 2]
left = (fft_f - lo) / max(ce - lo, 1e-10)
right = (hi - fft_f) / max(hi - ce, 1e-10)
filt[i] = np.maximum(0, np.minimum(left, right))
enorm = (2.0 / (pts[2:n_mels + 2] - pts[0:n_mels])).astype(np.float32)
filt *= enorm[:, None]
return filt
def _stft_power(y, n_fft, hop):
"""|STFT|^2 with periodic Hann + center=True reflect pad (torch.stft-compatible).
Returns (n_fft//2+1, n_frames) float32."""
y = np.asarray(y, dtype=np.float32)
pad = n_fft // 2
y = np.pad(y, pad, mode="reflect")
n_frames = 1 + (len(y) - n_fft) // hop
idx = np.arange(n_fft)[None, :] + hop * np.arange(n_frames)[:, None]
# periodic Hann (torch.hann_window default), not numpy's symmetric hanning
win = (0.5 - 0.5 * np.cos(2.0 * np.pi * np.arange(n_fft) / n_fft)).astype(np.float32)
frames = y[idx] * win[None, :]
spec = np.fft.rfft(frames, n=n_fft, axis=1)
return (spec.real ** 2 + spec.imag ** 2).T.astype(np.float32)
def _melspectrogram(y_ch, sr, n_mels=30, fmax=16000, hop_length=2048, n_fft=2048):
power = _stft_power(y_ch, n_fft, hop_length)
return _mel_filterbank(n_mels, n_fft, sr, fmax) @ power
def _power_to_db(S, top_db=80.0):
log_spec = 10.0 * np.log10(np.maximum(S, 1e-10))
return np.maximum(log_spec - log_spec.max(), -top_db)
def _mel_channel(y_ch, sr, n_mels=30):
"""dB-scaled mel + band-tinted RGB for one mono channel."""
S = _melspectrogram(y_ch, sr, n_mels=n_mels, fmax=16000, hop_length=2048)
S_db = _power_to_db(S)
np.clip(S_db, -60, 0, out=S_db)
S_db += 60.0
S_db /= 60.0
np.power(S_db, 0.6, out=S_db)
mel_f = _mel_frequencies(n_mels, fmax=16000)
n_frames = S.shape[1]
band_norms = np.empty((3, n_frames), dtype=np.float32)
for i, (flo, fhi, _) in enumerate(SPEC_BANDS):
mask = (mel_f >= flo) & (mel_f < fhi)
if mask.any():
power = np.sum(S[mask], axis=0)
db = 10.0 * np.log10(power + 1e-10)
np.clip(db, -20, None, out=db)
db -= -20
mx = db.max()
if mx > 0:
db /= mx
band_norms[i] = db
else:
band_norms[i] = 0.0
rgb = band_norms.T @ _BAND_COLORS
for c in range(3):
mx = rgb[:, c].max()
if mx > 0:
rgb[:, c] /= mx
return S_db, rgb
def _resample_to_32k(pcm_int16, sr_in):
"""Resample (T, 2) int16 PCM to (2, T') float32 at 32 kHz.
Linear interpolation at align_corners=False sample positions (matches the
torch.nn.functional.interpolate call in the TRT original)."""
target_sr = 32000
y = pcm_int16.astype(np.float32).T / 32768.0 # (2, T)
if sr_in == target_sr:
return y, target_sr
in_len = y.shape[-1]
new_len = int(round(in_len * target_sr / sr_in))
scale = in_len / new_len
pos = np.clip((np.arange(new_len, dtype=np.float64) + 0.5) * scale - 0.5, 0, in_len - 1)
out = np.stack([np.interp(pos, np.arange(in_len), ch) for ch in y]).astype(np.float32)
return out, target_sr
def render_spectrogram_png(pcm_int16, sample_rate=44100, width=1200, height=240,
n_mels=30) -> bytes:
"""Render the 3-band tinted stereo mel spectrogram to PNG bytes.
Args:
pcm_int16: (T, 2) int16 numpy array — stereo PCM.
sample_rate: input PCM sample rate (default 44100).
width, height: final image size.
n_mels: mel filterbank size (30 matches the dashboards).
Returns: PNG bytes ready to base64 / save / etc.
"""
if pcm_int16.ndim != 2 or pcm_int16.shape[1] != 2:
raise ValueError(f"expected (T, 2) PCM, got {pcm_int16.shape}")
y, sr = _resample_to_32k(pcm_int16, sample_rate)
S_L, rgb_L = _mel_channel(y[0], sr, n_mels=n_mels)
S_R, rgb_R = _mel_channel(y[1], sr, n_mels=n_mels)
nf = min(S_L.shape[1], S_R.shape[1])
S_L, S_R = S_L[:, :nf], S_R[:, :nf]
rgb_L, rgb_R = rgb_L[:nf], rgb_R[:nf]
nm = S_L.shape[0]
# L channel: flip vertically so bass-band is at bottom.
S_L = S_L[::-1]
img = np.empty((nm * 2, nf, 3), dtype=np.float32)
img[:nm] = S_L[:, :, np.newaxis] * rgb_L[np.newaxis, :, :]
img[nm:] = S_R[:, :, np.newaxis] * rgb_R[np.newaxis, :, :]
np.clip(img, 0, 1, out=img)
img *= 255
pil = Image.fromarray(img.astype(np.uint8)).resize((width, height), Image.LANCZOS)
buf = io.BytesIO()
pil.save(buf, format="PNG", optimize=True)
return buf.getvalue()
@@ -0,0 +1,301 @@
"""Tests for the TFLite LoRA merge (lora_core.py + lora_patch.py).
Stdlib unittest, no torch / no mlx / no pytest (matches test_windows_compat.py).
Two tiers:
* CI-safe: safetensors reader, per-type merge math (independent re-derivation),
spec parser, quantized-precision refusal — no model files needed.
* Model-dependent (skipped unless a local DiT .tflite is present): the real
plini adapter's 169/169 name-mapping bijection on medium; w16a32 fp16
buffer discovery; and a synthetic FULL-target-set adapter on sm-music that
exercises every mapping tier + the clone/patch/read-back path.
Run: python scripts/test_lora_patch.py
"""
from __future__ import annotations
import json
import os
import struct
import sys
import tempfile
import unittest
import numpy as np
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
import lora_core as lc # noqa: E402
# Local model paths (present on a dev box with the tflite bundle installed;
# absent in CI → the model-dependent tests skip). SA3_TFLITE_MODELS_DIR
# overrides the location (e.g. pointing a worktree's tests at the main checkout).
_CHECKOUT = os.environ.get(
"SA3_TFLITE_MODELS_DIR",
os.path.abspath(os.path.join(_HERE, "..", "models", "tflite")))
_MODELS = {
("sm-music", "fp32"): f"{_CHECKOUT}/sa3-sm-music/dit_fp32.tflite",
("sm-music", "w16a32"): f"{_CHECKOUT}/sa3-sm-music/dit_w16a32.tflite",
("medium", "fp32"): f"{_CHECKOUT}/sa3-m/dit_fp32.tflite",
("sm-music", "w8a32"): f"{_CHECKOUT}/sa3-sm-music/dit_w8a32.tflite",
}
_PLINI = "/Users/cj/clod/speed-metal/scripts/lora_bench/plini-sa3-380.safetensors"
def _have(key):
p = _MODELS.get(key)
return p and os.path.exists(p)
def _save_native_safetensors(path, layers, cfg):
"""Write a minimal SA3-native adapter: {layer.parametrizations.weight.0.param}."""
tensors, blob, hdr = {}, bytearray(), {}
for layer, params in layers.items():
for pname, arr in params.items():
tensors[f"{layer}.parametrizations.weight.0.{pname}"] = np.ascontiguousarray(arr, np.float32)
for k, v in tensors.items():
off = len(blob)
blob += v.tobytes()
hdr[k] = {"dtype": "F32", "shape": list(v.shape), "data_offsets": [off, len(blob)]}
hdr["__metadata__"] = {"lora_config": json.dumps(cfg)}
hbytes = json.dumps(hdr).encode()
with open(path, "wb") as f:
f.write(struct.pack("<Q", len(hbytes)))
f.write(hbytes)
f.write(blob)
# ── CI-safe unit tests ─────────────────────────────────────────────────────────
class TestSafetensorsReader(unittest.TestCase):
def test_roundtrip_fp32_and_metadata(self):
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, "a.safetensors")
A = np.random.default_rng(0).standard_normal((4, 8)).astype(np.float32)
_save_native_safetensors(p, {"foo": {"lora_A": A, "lora_B": np.zeros((6, 4), np.float32)}},
{"adapter_type": "lora", "rank": 4, "alpha": 4})
t, meta = lc._load_safetensors(p)
self.assertIn("foo.parametrizations.weight.0.lora_A", t)
np.testing.assert_array_equal(t["foo.parametrizations.weight.0.lora_A"], A)
self.assertEqual(json.loads(meta["lora_config"])["adapter_type"], "lora")
def test_pickle_refused(self):
for bad in ("evil.ckpt", "evil.pt", "evil.bin", "x.npz"):
with self.assertRaises(lc.LoraError):
lc._load_safetensors(bad)
class TestMergeMath(unittest.TestCase):
"""Independent re-derivation of the four core types (guards the copy)."""
def setUp(self):
self.rng = np.random.default_rng(1)
self.W0 = self.rng.standard_normal((6, 8)).astype(np.float32)
def _p(self, t, r=3):
p = {}
if not t.endswith("-xs"):
p["lora_A"] = self.rng.standard_normal((r, 8)).astype(np.float32)
p["lora_B"] = self.rng.standard_normal((6, r)).astype(np.float32)
else:
p["M_xs"] = self.rng.standard_normal((r, r)).astype(np.float32)
if "magnitude" in lc._PARAMS_FOR[t]:
p["magnitude"] = np.linalg.norm(self.W0, axis=1 if "rows" in t else 0).astype(np.float32)
if "magnitude_r" in lc._PARAMS_FOR[t]:
p["magnitude_r"] = np.linalg.norm(self.W0, axis=1).astype(np.float32)
p["magnitude_c"] = np.linalg.norm(self.W0, axis=0).astype(np.float32)
return p
def test_lora(self):
p = self._p("lora")
ref = self.W0 + 2.0 * (p["lora_B"] @ p["lora_A"])
np.testing.assert_allclose(lc.merged_weight(self.W0, p, "lora", 2.0), ref, atol=1e-5)
def test_dora_rows(self):
p = self._p("dora-rows")
V = self.W0 + 1.0 * (p["lora_B"] @ p["lora_A"])
ref = V / (np.linalg.norm(V, axis=1, keepdims=True) + 1e-12) * p["magnitude"].reshape(-1, 1)
np.testing.assert_allclose(lc.merged_weight(self.W0, p, "dora-rows", 1.0), ref, atol=1e-5)
def test_dora_cols(self):
p = self._p("dora-cols")
V = self.W0 + 1.0 * (p["lora_B"] @ p["lora_A"])
ref = V / (np.linalg.norm(V, axis=0, keepdims=True) + 1e-12) * p["magnitude"].reshape(1, -1)
np.testing.assert_allclose(lc.merged_weight(self.W0, p, "dora-cols", 1.0), ref, atol=1e-5)
def test_all_types_finite_and_shaped(self):
for t in lc._PARAMS_FOR:
p = self._p(t)
lc.check_shapes("x", self.W0, p, t)
W = lc.merged_weight(self.W0, p, t, 1.0)
self.assertEqual(W.shape, self.W0.shape)
self.assertTrue(np.isfinite(W).all(), t)
def test_shape_mismatch_raises(self):
p = {"lora_A": np.zeros((3, 99), np.float32), "lora_B": np.zeros((6, 3), np.float32)}
with self.assertRaises(lc.LoraError):
lc.check_shapes("x", self.W0, p, "lora")
class TestSpecParser(unittest.TestCase):
def test_basic(self):
self.assertEqual(lc.parse_lora_spec(["a.safetensors"])["strength"], 1.0)
self.assertEqual(lc.parse_lora_spec(["a.safetensors", "strength=0.7"])["strength"], 0.7)
def test_steps_rejected_points_to_mlx(self):
with self.assertRaises(lc.LoraError) as cm:
lc.parse_lora_spec(["a.safetensors", "steps=2-8"])
self.assertIn("MLX", str(cm.exception))
def test_bad_tokens(self):
for bad in (["a", "b.st"], ["a", "strength=x"], []):
with self.assertRaises(lc.LoraError):
lc.parse_lora_spec(bad)
class TestQuantizedRefusal(unittest.TestCase):
def test_precision_refused_before_touching_files(self):
import lora_patch as lp
for prec in ("w8a32", "w8a8-dyn", "w4a32"):
with self.assertRaises(lc.LoraError) as cm:
lp.get_patched_dit("/nonexistent.tflite", [{"path": "x", "strength": 1.0}],
family="medium", precision=prec, log=lambda _m: None)
self.assertIn(prec, str(cm.exception))
# ── model-dependent tests ────────────────────────────────────────────────────
@unittest.skipUnless(_have(("medium", "fp32")) and os.path.exists(_PLINI),
"medium fp32 DiT + plini adapter not present")
class TestRealAdapterMapping(unittest.TestCase):
def test_plini_169_bijection_on_medium(self):
import mmap
import lora_patch as lp
f = open(_MODELS[("medium", "fp32")], "rb")
buf = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
try:
mapper = lp._Mapper(list(lp._fc_weight_buffers(buf)))
_at, _sc, layers = lc.parse_adapter(_PLINI)
offs = set()
for layer, p in layers.items():
want = (p["lora_B"].shape[0], p["lora_A"].shape[1])
rec = mapper.resolve(layer, want)
self.assertEqual(rec["shape"], want, layer)
offs.add(rec["off"])
self.assertEqual(len(layers), 169)
self.assertEqual(len(offs), 169, "buffer offsets must be unique (no collisions)")
finally:
del mapper
buf.close()
f.close()
@unittest.skipUnless(_have(("sm-music", "w16a32")), "sm-music w16a32 DiT not present")
class TestW16A32Discovery(unittest.TestCase):
def test_all_weights_fp16_via_dequant(self):
import mmap
import lora_patch as lp
f = open(_MODELS[("sm-music", "w16a32")], "rb")
buf = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
try:
fcs = list(lp._fc_weight_buffers(buf))
self.assertGreater(len(fcs), 100)
self.assertTrue(all(np.dtype(r["np_dtype"]) == np.float16 for r in fcs))
self.assertTrue(any(r["shape"] == (768, 256) for r in fcs), "seconds cond present")
finally:
del fcs
buf.close()
f.close()
@unittest.skipUnless(_have(("sm-music", "fp32")), "sm-music fp32 DiT not present")
class TestFullSetPatchRoundTrip(unittest.TestCase):
"""Synthetic all-target adapter (every mapping tier) → clone + patch +
byte-exact read-back on the smaller sm-music fp32 model."""
def test_map_patch_readback(self):
import mmap
import lora_patch as lp
base = _MODELS[("sm-music", "fp32")]
f = open(base, "rb")
buf = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
fcs = list(lp._fc_weight_buffers(buf))
mapper = lp._Mapper(fcs)
# Inverse-classify each FC → a checkpoint layer name, so we can build a
# synthetic adapter that targets every tier (block linears, local_embed,
# singleton embedders, seconds cond).
ckpt_for = {} # off -> (ckpt_layer_name, (fan_out, fan_in))
# tier 1
rev_block = {("self_attn", "to_qkv", False): "self_attn.to_qkv",
("self_attn", "to_out", False): "self_attn.to_out",
("cross_attn", "to_q", False): "cross_attn.to_q",
("cross_attn", "to_kv", False): "cross_attn.to_kv",
("cross_attn", "to_out", False): "cross_attn.to_out",
("ff", "proj", True): "ff.ff.0.proj", ("ff", "2", False): "ff.ff.2"}
for (blk, mod, leaf, glu), rec in mapper._block.items():
ckpt_for[rec["off"]] = (f"model.transformer.layers.{blk}.{rev_block[(mod, leaf, glu)]}", rec["shape"])
# tier 2 (topological rank == block)
for leaf, recs in mapper._local.items():
for blk, rec in enumerate(recs):
ckpt_for[rec["off"]] = (f"model.transformer.layers.{blk}.to_local_embed.seq.{leaf}", rec["shape"])
# tier 3 singletons
for ckpt, sub in lp._SINGLETON_CKPT.items():
hit = [r for r in fcs if sub in r["name"]]
if hit:
ckpt_for[hit[0]["off"]] = (ckpt, hit[0]["shape"])
# tier 4 seconds cond
if mapper._seconds:
ckpt_for[mapper._seconds[0]["off"]] = (lc.COND_SECONDS_LAYER, mapper._seconds[0]["shape"])
rng = np.random.default_rng(7)
layers = {}
for off, (name, (fo, fi)) in ckpt_for.items():
r = 4
layers[name] = {
"lora_A": (rng.standard_normal((r, fi)) * 0.01).astype(np.float32),
"lora_B": (rng.standard_normal((fo, r)) * 0.01).astype(np.float32),
"magnitude": np.linalg.norm(
rng.standard_normal((fo, fi)).astype(np.float32), axis=1).astype(np.float32),
}
buf.close()
f.close()
with tempfile.TemporaryDirectory() as d:
adapter = os.path.join(d, "full.safetensors")
_save_native_safetensors(adapter, layers,
{"adapter_type": "dora-rows", "rank": 4, "alpha": 4})
specs = [lc.parse_lora_spec([adapter, "strength=0.5"])]
out = lp.get_patched_dit(base, specs, family="sm-music", precision="fp32",
cache_dir=os.path.join(d, "cache"), log=lambda _m: None)
self.assertTrue(out.exists())
# read-back: every patched buffer == fp32(W0 + 0.5*(merged-W0))
fb = open(base, "rb"); bb = mmap.mmap(fb.fileno(), 0, access=mmap.ACCESS_READ)
fo2 = open(out, "rb"); bo = mmap.mmap(fo2.fileno(), 0, access=mmap.ACCESS_READ)
def owned(mm, off, size, dt):
# copy out of the mmap so no view keeps it open at close time
return np.array(np.frombuffer(mm, dt, count=size // np.dtype(dt).itemsize,
offset=off))
m2 = lp._Mapper(list(lp._fc_weight_buffers(bb)))
checked = 0
for name, p in layers.items():
rec = m2.resolve(name, (p["lora_B"].shape[0], p["lora_A"].shape[1]))
W0 = owned(bb, rec["off"], rec["size"], np.float32).reshape(rec["shape"])
got = owned(bo, rec["off"], rec["size"], np.float32).reshape(rec["shape"])
ref = (W0 + 0.5 * (lc.merged_weight(W0, p, "dora-rows", 1.0) - W0)).astype(np.float32)
self.assertTrue(np.array_equal(got, ref), name)
checked += 1
self.assertEqual(checked, len(layers))
# a non-target FC — any FC not in our set — must be unchanged
untouched = [r for r in lp._fc_weight_buffers(bb) if r["off"] not in ckpt_for]
if untouched:
r = untouched[0]
self.assertTrue(np.array_equal(owned(bb, r["off"], r["size"], np.uint8),
owned(bo, r["off"], r["size"], np.uint8)),
"untouched tensor changed")
del m2
bb.close(); bo.close(); fb.close(); fo2.close()
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -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 # <project>/scripts
PROJECT_DIR = SCRIPTS_DIR.parent # <project>
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)
@@ -0,0 +1,245 @@
"""Shared weights manifest + downloader.
Maps every TFLite / LiteRT model file the runtime needs to its position in the
`stabilityai/stable-audio-3-optimized` HuggingFace repo (under `tflite/…`).
`install.py` calls `ensure_local` upfront for the bundles the user picks.
`sa3_tflite.py` calls `ensure_local` lazily, just before each model loads — so
a fresh checkout with no weights still works if the user is willing to wait for
the first run to download them.
The SentencePiece tokenizer is BUNDLED at models/tokenizer.model (the .tflite
T5Gemma is encoder-only), so it's not in this manifest.
"""
from __future__ import annotations
from pathlib import Path
REPO_ID = "stabilityai/stable-audio-3-optimized"
# weights.py lives in <project>/scripts/; SCRIPT_DIR points at the project
# root so the local rel paths in the manifest ("models/tflite/foo.tflite")
# resolve against the actual project layout.
SCRIPT_DIR = Path(__file__).resolve().parent.parent
# Bundles the install script offers to the user. Each maps to a list of
# model files (local relative path on the left, HF repo path on the right).
# T5Gemma is in SHARED because every bundle needs it. The two small DiTs
# share the SAME-S codec; medium uses the SAME-L codec.
DIT_BUNDLES: dict[str, list[tuple[str, str]]] = {
"sm-music": [
("models/tflite/sa3-sm-music/dit_fp32.tflite", "tflite/sa3-sm-music/dit_fp32.tflite"),
("models/tflite/same-s/enc_fp32.tflite", "tflite/same-s/enc_fp32.tflite"),
("models/tflite/same-s/dec_fp32.tflite", "tflite/same-s/dec_fp32.tflite"),
],
"sm-sfx": [
("models/tflite/sa3-sm-sfx/dit_fp32.tflite", "tflite/sa3-sm-sfx/dit_fp32.tflite"),
("models/tflite/same-s/enc_fp32.tflite", "tflite/same-s/enc_fp32.tflite"),
("models/tflite/same-s/dec_fp32.tflite", "tflite/same-s/dec_fp32.tflite"),
],
"medium": [
("models/tflite/sa3-m/dit_fp32.tflite", "tflite/sa3-m/dit_fp32.tflite"),
("models/tflite/same-l/enc_fp32.tflite", "tflite/same-l/enc_fp32.tflite"),
("models/tflite/same-l/dec_fp32.tflite", "tflite/same-l/dec_fp32.tflite"),
],
}
SHARED: list[tuple[str, str]] = [
("models/tflite/t5gemma/encoder_fp16.tflite", "tflite/t5gemma/encoder_fp16.tflite"),
]
# Human-friendly bundle sizes (for the install prompt). Exact, from HF metadata.
BUNDLE_SIZES = {
"sm-music": "2.3 GB (small music DiT + SAME-S codec, all fp32)",
"sm-sfx": "2.3 GB (small sfx DiT + SAME-S codec, all fp32)",
"medium": "9.5 GB (medium DiT + SAME-L codec, all fp32)",
}
# T5Gemma (shared, fp16) adds ~0.6 GB the first time any bundle is fetched.
# Quantized DiT + decoder variants (selected via sa3_tflite.py --precision; wXaY =
# weight/activation bit-widths, "16" = fp16). Not part of the install bundles — they
# lazy-download on first use. w16a32 = fp16 weights (half size, ≈lossless, slower on
# CPU); w8a32 / w8a8-dyn = GPTQ-calibrated int8 weights.
QUANT_PRECISIONS = ("w16a32", "w8a32", "w8a8-dyn")
PRECISIONS = ("fp32",) + QUANT_PRECISIONS # everything --precision accepts
DIT_SUBDIR = {"sm-music": "sa3-sm-music", "sm-sfx": "sa3-sm-sfx", "medium": "sa3-m"}
def dit_rel(dit: str, precision: str = "fp32") -> str:
"""Local rel path of a DiT model file for (family, precision)."""
return f"models/tflite/{DIT_SUBDIR[dit]}/dit_{precision}.tflite"
def dec_rel(dec: str, precision: str = "fp32") -> str:
"""Local rel path of a SAME codec decoder file for (codec, precision)."""
return f"models/tflite/{dec}/dec_{precision}.tflite"
def enc_rel(dec: str, precision: str = "fp32") -> str:
"""Local rel path of a SAME codec encoder file for (codec, precision).
Encoder int8 is GPTQ-calibrated on real audio (torch-free, in-place on the
tflite graph) — w8a32 measures 36/46 dB latent PSNR (same-s/same-l)."""
return f"models/tflite/{dec}/enc_{precision}.tflite"
# Flat (local_rel_path → hf_path) lookup — used by sa3_tflite.py for lazy
# auto-download at load time.
FLAT_MANIFEST: dict[str, str] = {}
for _items in DIT_BUNDLES.values():
for _rel, _hf in _items:
FLAT_MANIFEST[_rel] = _hf
for _rel, _hf in SHARED:
FLAT_MANIFEST[_rel] = _hf
for _prec in QUANT_PRECISIONS:
for _fam in DIT_SUBDIR:
_rel = dit_rel(_fam, _prec)
FLAT_MANIFEST[_rel] = _rel.replace("models/tflite/", "tflite/", 1)
for _dec in ("same-s", "same-l"):
for _rel in (dec_rel(_dec, _prec), enc_rel(_dec, _prec)):
FLAT_MANIFEST[_rel] = _rel.replace("models/tflite/", "tflite/", 1)
def _hf_token_configured() -> bool:
"""True if any HF token is set — env var or cached login on disk."""
import os
if os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN"):
return True
try:
from huggingface_hub import get_token # huggingface_hub ≥ 0.19
return bool(get_token())
except ImportError:
try:
from huggingface_hub import HfFolder
return bool(HfFolder.get_token())
except Exception:
return False
except Exception:
return False
_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.
Anonymous downloads work but have a ~50 GB/day soft cap on HF's LFS CDN
and lower aggregate bandwidth — a free token effectively removes both.
Stays silent if a token is already in place.
"""
global _LOGIN_TIP_SHOWN
if _LOGIN_TIP_SHOWN:
return
_LOGIN_TIP_SHOWN = True
if _hf_token_configured():
return
import sys
YEL = "\033[1;33m" if sys.stdout.isatty() else ""
BOLD = "\033[1m" if sys.stdout.isatty() else ""
DIM = "\033[2m" if sys.stdout.isatty() else ""
RST = "\033[0m" if sys.stdout.isatty() else ""
print()
print(f" {YEL}⚠ not logged in to HuggingFace{RST} — anonymous downloads work but are")
print(f" rate-limited (~50 GB/day cap on the LFS CDN). For faster, higher-limit")
print(f" downloads, log in once with a free read-only token:")
print()
print(f" 1. create an account at {BOLD}https://huggingface.co/join{RST}")
print(f" 2. generate a token at {BOLD}https://huggingface.co/settings/tokens{RST}")
print(f" {DIM}('Read' scope is enough){RST}")
print(f" 3. save it on this machine — pick one:")
print(f" {BOLD}hf auth login{RST} {DIM}# modern (huggingface_hub ≥ 1.0){RST}")
print(f" {BOLD}huggingface-cli login{RST} {DIM}# classic; still works{RST}")
print(f" {BOLD}export HF_TOKEN=hf_xxx{RST} {DIM}# one-off / scripts{RST}")
print()
def ensure_local(local_rel_path: str, verbose: bool = True) -> Path:
"""Resolve a model file to an absolute local path, downloading if missing.
Files are streamed into the HuggingFace cache (~/.cache/huggingface/hub/)
and symlinked into the project at `local_rel_path` so the on-disk layout
looks the same whether the file was bundled or downloaded.
"""
target = SCRIPT_DIR / local_rel_path
if target.exists() or target.is_symlink():
return target
if local_rel_path not in FLAT_MANIFEST:
raise FileNotFoundError(
f"{local_rel_path} is not in the weights manifest — can't auto-download."
)
# First-download tip: nudge users toward logging in to HF for better limits.
# No-op if a token is already configured.
_show_hf_login_tip_once()
hf_filename = FLAT_MANIFEST[local_rel_path]
if verbose:
print(f" ↓ downloading {hf_filename} (from {REPO_ID})")
try:
from huggingface_hub import hf_hub_download
except ImportError as e:
raise RuntimeError(
"huggingface_hub is required to auto-download weights.\n"
"Run: pip install huggingface_hub\n"
"Or run the install.py script in this directory."
) from e
cached = hf_hub_download(repo_id=REPO_ID, filename=hf_filename)
target.parent.mkdir(parents=True, exist_ok=True)
# 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()
_link_or_copy(Path(cached), target)
return target
def is_present(local_rel_path: str) -> bool:
"""True if the file exists locally (does not trigger a download)."""
p = SCRIPT_DIR / local_rel_path
return p.exists() or p.is_symlink()
def bundle_status(bundle: str) -> tuple[int, int]:
"""Returns (present_count, total_count) for the bundle (including SHARED)."""
items = DIT_BUNDLES[bundle] + SHARED
present = sum(1 for rel, _ in items if is_present(rel))
return present, len(items)