diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96edfcd..9995c22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,18 +1,22 @@ -# Contributing to StemDeck +# Contributing to LayerLab -Thanks for your interest in StemDeck - free, local stem separation for musicians. Contributions of -all kinds are welcome, whether you write code or not. +Thanks for your interest in LayerLab, an unofficial modified StemDeck fork test +build of StemDeck. Contributions of all kinds are welcome, whether you write +code or not. + +This fork is not an official upstream release and is not affiliated with or +endorsed by the original StemDeck project. By participating you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md). ## Ways to contribute -- **Report a bug** - open a [Bug report](https://github.com/stemdeckapp/stemdeck/issues/new/choose). - Include your OS, the StemDeck version (Help icon -> About), and clear steps to reproduce. -- **Suggest a feature** - open a [Feature request](https://github.com/stemdeckapp/stemdeck/issues/new/choose). +- **Report a bug** - open an issue on [this fork](https://github.com/bassmicrobe/stemdeck/issues). + Include your OS, the LayerLab version (Help icon -> About), and clear steps to reproduce. +- **Suggest a feature** - open an issue on [this fork](https://github.com/bassmicrobe/stemdeck/issues). - **Improve the docs** - fixes and clarifications to the README or these guides are always useful. - **Write code** - bug fixes and features. For anything large, please open an issue or a - [Discussion](https://github.com/stemdeckapp/stemdeck/discussions) first so we can agree on the + [Issue](https://github.com/bassmicrobe/stemdeck/issues) first so we can agree on the approach before you invest time. - **Found a security issue?** Do not open a public issue - see [SECURITY.md](SECURITY.md). @@ -73,10 +77,12 @@ New API endpoints and pipeline stages should come with tests under `tests/`. The ## License -StemDeck is licensed under the [Apache License 2.0](LICENSE). By contributing, you agree that your +LayerLab is based on the original StemDeck project and is licensed +under the [Apache License 2.0](LICENSE). See [NOTICE](NOTICE) for upstream +attribution and the modification notice. By contributing, you agree that your contributions are licensed under the same terms. ## Questions -Open a [Discussion](https://github.com/stemdeckapp/stemdeck/discussions) or join the -[Discord](https://discord.gg/2MVsWqaPRe). Thanks for helping make StemDeck better. +Open an issue on [this fork](https://github.com/bassmicrobe/stemdeck/issues). +Thanks for helping make LayerLab better. diff --git a/MANUAL.ja.md b/MANUAL.ja.md new file mode 100644 index 0000000..be4bcc9 --- /dev/null +++ b/MANUAL.ja.md @@ -0,0 +1,459 @@ +# LayerLab ユーザーマニュアル + +LayerLab は、音源をローカル環境で stem 分離するためのアプリです。MP3、WAV、FLAC、M4A、または YouTube URL を入力し、ボーカル、ドラム、ベース、ギター、ピアノ、その他の stem に分離できます。 + +このマニュアルは、アプリを使う人向けの操作ガイドです。開発、配布、署名、ライセンス詳細は [README.ja.md](README.ja.md) も参照してください。 + +## 重要な前提 + +- LayerLab は [stemdeckapp/stemdeck](https://github.com/stemdeckapp/stemdeck) をベースにした非公式 fork test build です。 +- 元プロジェクトの公式リリースではなく、元プロジェクトと提携・承認関係はありません。 +- 処理は基本的にローカルマシン上で完結します。音源をクラウドへアップロードする設計ではありません。 +- YouTube URL 入力は、処理する権利を持つコンテンツで使ってください。LayerLab はダウンローダーではなく stem 分離ツールです。 +- 再配布する場合は `LICENSE`、`NOTICE`、`THIRD_PARTY_NOTICES.txt` を同梱し、Apache License 2.0 と各依存関係のライセンス条件を確認してください。 + +## 起動方法 + +### ローカルWeb版 + +```sh +./run.sh setup +./run.sh start +``` + +ブラウザで `http://127.0.0.1:8765/` を開きます。 + +停止する場合: + +```sh +./run.sh stop +``` + +状態確認: + +```sh +./run.sh status +curl -s http://127.0.0.1:8765/api/health +``` + +### デスクトップ版 + +macOS の場合は `.dmg` を開き、`LayerLab.app` を Applications にコピーして起動します。初回起動時に Python runtime、FFmpeg、ffprobe、必要なモデルを確認または取得します。 + +初回セットアップにはインターネット接続と数GB程度の空き容量が必要です。Demucs model は初回分離時にキャッシュされ、2回目以降は再利用されます。 + +## 画面構成 + +### 上部バー + +- ファイル選択またはURL入力を行います。 +- `Extract` で抽出する stem を選びます。 +- `Quality` で分離品質を選びます。 +- `Device` で `Auto`、`CPU`、`Apple GPU`、`NVIDIA CUDA` のどれを使うか選びます。利用できないGPUは選択できません。 +- `Clean` で分離後のノイズ除去を選びます。 +- `Split stems` でジョブをキューに追加します。 + +### ライブラリ + +- 最近処理した曲、キュー中の曲、完了済みの曲を表示します。 +- フォルダ作成、検索、お気に入り、ゴミ箱を使えます。 +- 処理中の曲は `Queued`、`Processing`、`Separating` などの状態と進捗を表示します。 +- 完了済みの曲をクリックすると、プレイヤーとミキサーに読み込まれます。 +- 左レールの `Logs` から、現在のセッション全体または曲ごとの処理ログを確認できます。 + +### 中央エリア + +- 曲の解析情報、stem presence、波形、ミキサーが表示されます。 +- 処理中は進捗HUDが前面に表示されます。 +- 複数曲をキューに追加した場合、完了済みの選択曲は前面に残り、次の抽出はバックグラウンドで続きます。 + +### 下部プレイヤー + +- 再生、停止、ループ、書き出しを操作します。 +- `Export Mix` からミックスやstemを書き出せます。 +- 完了済み曲では `Extracted`、`Processed`、`Profile`、`Source`、`Quality`、`Repair` が表示されます。 + +## 基本ワークフロー + +1. 音源ファイルを選択するか、YouTube URL を入力します。 +2. 抽出したい stem を選びます。 +3. 必要に応じて `Quality` と `Clean` を設定します。 +4. `Split stems` を押します。 +5. 進捗バー、経過時間、残り推定時間を確認します。 +6. 完了後、曲をクリックして試聴します。 +7. 必要に応じてミックスや個別stemを書き出します。 + +## 対応入力 + +- MP3 +- WAV +- FLAC +- M4A +- YouTube URL + +長尺音源は処理時間とディスク使用量が大きくなります。既定では長すぎる音源は拒否されます。 + +## Stem選択 + +`All` を選ぶと利用可能な全stemを抽出対象にします。個別に `Vocals`、`Drums`、`Bass`、`Guitar`、`Piano`、`Other` を選べます。 + +`High` / `Max` / `Ultra` 品質では、品質優先のため基本的に4-stem構成になります。 + +## 品質設定 + +| 設定 | 目的 | 目安 | +|---|---|---| +| `Standard` | 通常利用向け | 速め、6-stem | +| `High` | 品質優先 | 遅め、float32、補正強化 | +| `Max` | 最高精度優先 | かなり遅い、shift average多め | +| `Ultra` | 最高品質の検証用 | 最も遅い、shift average最多、overlap強化 | + +`Ultra` は `Max` よりさらに処理時間が伸びます。Apple Silicon MPS や NVIDIA CUDA が使える環境でも、曲の長さやメモリ状況によって時間がかかります。 + +## Device設定 + +| 設定 | 内容 | 目安 | +|---|---|---| +| `Auto` | アプリが利用可能なGPUを優先して選択 | 通常はこれ | +| `Apple GPU` | Apple Silicon の MPS を使用 | Macで高速化したい場合 | +| `NVIDIA CUDA` | NVIDIA GPU の CUDA を使用 | Windows/LinuxのCUDA環境向け | +| `CPU` | GPUを使わずCPUで処理 | GPU不調時、比較検証、互換性重視 | + +`Device` はジョブごとに保存され、`Profile`、書き出しファイル名、stem ZIP内の `LAYERLAB_PROFILE.txt` にも記録されます。同じ曲でもCPU版とGPU版を別プロファイルとして比較できます。 + +## Clean設定 + +| 設定 | 内容 | 注意 | +|---|---|---| +| `Noise off` | ノイズ除去なし | 最も自然 | +| `Light denoise` | 軽いノイズ除去 | 迷ったらこちら | +| `Strong denoise` | 強めのノイズ除去 | ノイズは減るが音が水っぽくなる場合あり | + +ノイズ除去は分離後のstemに対する後処理です。元の分離精度そのものを上げる機能ではありません。 + +## 進捗表示 + +進捗バーはパイプライン全体の進捗です。Demucs単体の進捗ではありません。 + +主な段階: + +- `Preparing audio` +- `Analyzing` +- `Preparing separation input` +- `Separating` +- `Collecting stems` +- `Restoring stem levels` +- `Checking bass dropouts` +- `Checking phase coherence` +- `Checking stem denoise` +- `Stabilizing stems` +- `Rendering waveforms` + +`Elapsed` は処理開始からの経過時間です。進捗イベントとは別のタイマーで計算します。`ETA` は現在の進捗から推定するため、曲や処理段階によって揺れます。 + +## ログと診断 + +進捗HUDの `Logs` は表示中ジョブのログを開きます。左レールの `Logs` は全体ログを開き、上部の `Source` から対象曲を切り替えられます。 + +- 処理時刻、ログレベル、全体進捗率、パイプライン段階、メッセージを表示します。 +- ログ画面は1.5秒ごとに自動更新されます。 +- エラー/キャンセルジョブのログもレジストリへ保存されるため、原因確認に使えます。 +- 各ジョブは最新300件、現在のセッション全体は最新1000件まで保持します。 +- 古いジョブとログはジョブ保存期限に従って掃除されます。 + +## キューとバックグラウンド処理 + +複数曲を追加できます。完了済みの曲を選択している状態で次の曲が始まっても、前面のプレイヤーやダウンロード操作は維持されます。 + +同じ曲でも、`Quality`、`Device`、`Clean`、選択stemが異なれば別プロファイルとしてライブラリに残せます。たとえば同じ音源で `Standard / Noise off / Auto (Apple GPU) / 4-stem` と `Ultra / Strong denoise / CPU / Vocals+Bass` を比較できます。 + +選択中の曲はStemパネル上部に `Profile` として抽出設定が表示されます。個別stem、ミックス、リージョン、stem ZIPを書き出す場合もファイル名にプロファイル名が入るため、同じ曲の複数設定を書き出しても判別できます。 + +同時に重い解析・stem抽出を何本走らせるかは、マシン性能に応じて自動判定されます。 + +- MPS/CUDA: 安全優先で通常1本 +- CPUのみで十分なコアとメモリがある場合: 最大2本 +- 手動上書き: `STEMDECK_PIPELINE_CONCURRENCY=1` から `4` + +複数のローカルLayerLabバックエンドが同時起動しても、`STEMDECK_PIPELINE_LOCK` を基準に同時実行数ぶんの共有スロットを使い、マシン全体で過剰なDemucs同時実行を防ぎます。 + +## キャンセル + +処理中のジョブは `Cancel` でキャンセルできます。キャンセルすると、実行中のDemucs/ffmpegプロセスを停止し、途中生成物を削除します。 + +キャンセルできるのは前面で表示中のジョブです。バックグラウンドジョブを止めたい場合は、そのジョブを選択して状態を確認してください。 + +## 試聴とミキサー + +完了済み曲を選ぶと、stemごとの波形とミキサーが表示されます。 + +- `Play/Pause`: 再生/一時停止 +- `Stop`: 停止 +- `M`: stemをミュート +- `S`: stemをソロ +- `Monitor`: そのstemだけを確認 +- フェーダー: stem音量を調整 +- ダブルクリック: フェーダーを0 dBに戻す +- ループ範囲: ルーラー上で指定 +- ズーム: 波形表示を拡大/縮小 + +## 書き出し + +`Export Mix` から書き出します。 + +主な用途: + +- 選択stemだけのミックスを書き出す +- 個別stemを書き出す +- ループ範囲を書き出す +- 4分音符グリッド上で推定したコード進行をMIDIまたはCSVで書き出す +- WAV / MP3 / FLAC などの形式を選ぶ + +書き出しファイル名には、曲名に加えて `Quality` / `Clean` / 選択stem のプロファイルが入ります。`Export All Stems` のZIPには `LAYERLAB_PROFILE.txt` も同梱され、解凍後でも抽出設定を確認できます。 + +`Export Chord Guide` は、拍グリッドとchroma解析から推定した補助用のコード進行です。完全な採譜ではありませんが、DAWでコード進行の下書きとして使えます。書き出し時に `MIDI` / `CSV`、`Auto` / `Triads only` / `Allow 7ths`、`1/4 beat grid` / `1 bar blocks` を選べます。MIDIではDAW markerイベントも任意で含められます。 + +WAVは音質劣化が少ない一方、ファイルサイズが大きくなります。 + +## 曲ごとの記録 + +完了した曲には以下の情報が保存されます。 + +- `Extracted`: 抽出完了時刻 +- `Processed`: 抽出処理にかかった時間 +- `Source`: ローカルファイルまたはWeb +- `Quality`: 入力種別や品質目安 +- `Repair`: bass repair、phase repair、denoiseの適用状況 +- BPM +- Beat grid: 検出した拍数。波形上にも拍線を表示します(現在は解析対象の先頭180秒)。 +- Key +- LUFS +- Dynamic Range +- Tempo Stability +- Chord guide: 4分音符グリッド上で推定したコード進行MIDI/CSV +- Stem Presence + +古いバージョンで処理した曲は、`Processed` が `—` になる場合があります。新しいバージョンで処理した曲から正確に記録されます。 + +## データ保存場所 + +ローカルWeb版では、既定でリポジトリ内の `jobs/` に処理結果が保存されます。 + +デスクトップ版では、アプリデータ領域と `Documents/LayerLab` 系のフォルダを使います。実際の場所はセットアップ画面やアプリ内表示を確認してください。 + +長尺音源、`High` / `Max` / `Ultra`、float32 WAV、ノイズ除去、phase/bass repairでは一時ファイルも増えます。空き容量には余裕を持ってください。 + +## パフォーマンスの目安 + +処理速度は主に以下に依存します。 + +- CPU性能 +- GPU/MPS/CUDAの有無 +- メモリ容量 +- 音源の長さ +- `Quality` 設定 +- `Clean` 設定 +- 他のLayerLabプロセスや音声処理プロセスの有無 + +Apple Silicon ではMPSを使えますが、複数プロセスで同時にDemucsを走らせると遅くなったり、止まって見える場合があります。共有ロックにより基本的には同時実行を避けます。 + +## よくあるトラブル + +### `ffprobe` が見つからない + +`ffprobe` がPATHにない場合でも、LayerLab は可能な範囲で `ffmpeg` fallback を使います。それでも失敗する場合は、FFmpeg/ffprobeをインストールするか、デスクトップ版の初回セットアップをやり直してください。 + +### `Audio processing failed. Please try again.` + +原因候補: + +- 音源ファイルが壊れている +- 対応外形式 +- ディスク容量不足 +- Demucsモデル取得失敗 +- MPS/CUDA/CPUメモリ不足 +- FFmpeg処理失敗 + +まず短いMP3/WAV/FLAC/M4Aで再テストしてください。ローカルWeb版ではサーバーログも確認できます。 + +### 82%付近から進まない + +82%付近はDemucs分離後、stem収集と後処理へ入る境目です。 + +確認すること: + +- 他のLayerLab.appや古い開発サーバーが起動していないか +- Demucsプロセスが複数残っていないか +- ディスク容量が不足していないか +- `jobs/` に途中ファイルだけ残っていないか + +不要な古いプロセスを止め、アプリを再起動してください。新しいバージョンでは共有ロックにより同時Demucs実行を抑制します。 + +### プログレスバーが更新されない + +ブラウザをリロードしてください。サーバーは `GET /api/jobs/active` とSSEで状態同期します。古い表示が残る場合、サーバー側にジョブが存在しない古いローカル保存データの可能性があります。 + +### ベースが途切れる + +`High` / `Max` / `Ultra` では、bass dropout repair が有効になります。短い欠けを原音の低域残差から補います。ただし完全な復元ではなく、曲によっては原音の漏れや違和感が増える場合があります。 + +### 位相がおかしい + +`High` / `Max` / `Ultra` では、stem合計と原音の差分を使ったphase repairが有効になります。原音再構成は改善しますが、stem単体の分離感とはトレードオフがあります。 + +### CPUとGPUで音は変わる? + +基本的に品質を決める主因は `Quality` プリセットとモデル設定で、`Device` は主に処理速度とメモリ使用量に影響します。ただし CPU / MPS / CUDA では浮動小数点演算の順序や内部実装が異なるため、完全なバイナリ一致は保証されません。通常は聴感上ほぼ同じですが、厳密な再現性を優先する比較では `CPU` を固定し、速度を優先する通常利用では `Auto` またはGPUを使ってください。 + +### ノイズ除去で音が水っぽい + +`Strong denoise` を使うとFFT系の副作用が出ることがあります。自然さを優先する場合は `Noise off` または `Light denoise` を選んでください。 + +### 処理が遅い + +`Max` / `Ultra`、長尺音源、float32、denoise、phase/bass repairはすべて重い処理です。速度優先なら `Standard` と `Noise off` を使ってください。 + +## 設定変数 + +| 変数 | 用途 | +|---|---| +| `STEMDECK_QUALITY_PRESET` | 既定品質を指定します。`standard`、`high`、`max`、`ultra` | +| `STEMDECK_DEMUCS_DEVICE` | 起動時の既定デバイスを指定します。`cuda`、`mps`、`cpu` | +| `STEMDECK_DEMUCS_DEVICE` | `cuda`、`mps`、`cpu` を強制します | +| `STEMDECK_PIPELINE_CONCURRENCY` | 同一バックエンド内の重い処理の同時実行数 | +| `STEMDECK_PIPELINE_LOCK` | 複数バックエンド間の共有ロックファイル | +| `STEMDECK_MAX_PENDING_JOBS` | キュー受付上限 | +| `STEMDECK_MAX_DURATION_SEC` | 入力音源の最大長 | +| `STEMDECK_JOBS_DIR` | ジョブ保存先 | +| `STEMDECK_DATA_DIR` | portable modeのデータルート | +| `STEMDECK_FFMPEG` | ffmpeg実行ファイル | +| `STEMDECK_FFPROBE` | ffprobe実行ファイル | + +設定の詳細は [README.md](README.md) の `Configuration` を参照してください。 + +## 推奨設定 + +通常利用: + +```sh +STEMDECK_QUALITY_PRESET=standard +``` + +音質優先: + +```sh +STEMDECK_QUALITY_PRESET=high +``` + +時間がかかっても最高精度優先: + +```sh +STEMDECK_QUALITY_PRESET=max +``` + +さらに時間がかかっても最高品質を検証: + +```sh +STEMDECK_QUALITY_PRESET=ultra +``` + +Apple Siliconでは通常 `STEMDECK_DEMUCS_DEVICE` を指定しなくてもMPSが自動検出されます。 + +## 公開・再配布時の注意 + +公開する場合は、配布ページとアプリ内説明で以下を明記してください。 + +- LayerLab は非公式の変更版 fork test build であること +- 元プロジェクトの公式リリースではないこと +- 元プロジェクトと提携・承認関係がないこと +- Apache License 2.0 に基づく fork であること +- Apache License 2.0 は商標権を自動許諾しないこと +- Apache License 2.0 は商用利用・有償配布自体を禁止していないが、ライセンス条件、帰属表示、第三者依存のライセンス、商標・公式誤認回避は別途守る必要があること + +配布物には最低限以下を含めてください。 + +- `LICENSE` +- `NOTICE` +- `THIRD_PARTY_NOTICES.txt` + +FFmpeg、PyTorch、Demucs、Tauri/Rust crate、Python runtime など、実際に同梱する依存関係のライセンスも最終配布物に合わせて確認してください。 + +### 商用利用する場合の確認 + +Apache License 2.0 の範囲では、商用利用、社内利用、有償配布、改変版の配布は可能です。ただし、以下のような形は避けてください。 + +- 元プロジェクトの公式版や公式販売物のように見せる +- 元プロジェクトから承認、提携、認定、サポートを受けているように見せる +- `LICENSE`、`NOTICE`、`THIRD_PARTY_NOTICES.txt` を外して配布する +- `StemDeck` / `STEMDECK` の名称やロゴを、出所説明を超えて独自商品の商標のように使う +- FFmpeg、PyTorch、Demucs、yt-dlp などの同梱物のライセンス確認をせずに販売・再配布する +- ユーザーが権利を持たない音源を処理できるサービスとして、著作権や利用規約の整理なしに公開する + +公開販売や法人向け提供をする場合は、最終的に配布するバイナリ、同梱runtime、モデル、FFmpeg build、更新方式を前提に、法律・ライセンスの専門家へ確認することを推奨します。 + +## 品質評価ベンチマーク + +`scripts/benchmark_audio.py` を使うと、stem合計が原音にどれくらい近いかをJSONで確認できます。品質プリセット、denoise、phase repair、bass repairを比較する時の基準として使ってください。 + +```sh +uv run python scripts/benchmark_audio.py \ + --source /path/to/original.wav \ + --stems-dir jobs//stems \ + --metadata jobs//metadata.json \ + --out .build/benchmarks/.json +``` + +`source.*` がまだ残っているジョブなら、以下だけでも測定できます。 + +```sh +uv run python scripts/benchmark_audio.py --job-dir jobs/ +``` + +主に見る値: + +- `residual_percent`: stem合計と原音の残差。小さいほど原音再構成に近い。 +- `correlation`: 原音とstem合計の相関。1に近いほど近い。 +- `stem_sum_clipping_percent`: stem合計で1.0を超えたサンプル割合。大きい場合は合成時のクリップに注意。 +- `chords.segment_count` / `chords.average_confidence`: コードMIDI生成の区間数と平均信頼度。 + +注意: `residual_percent` が小さいほど常に「stem単体が良い」とは限りません。phase repairを強くすると原音再構成は改善しても、stem間の分離感や漏れとはトレードオフになる場合があります。 + +## 困った時の確認コマンド + +ローカルWeb版: + +```sh +./run.sh status +curl -s http://127.0.0.1:8765/api/health +curl -s http://127.0.0.1:8765/api/jobs/active +``` + +実行中プロセス: + +```sh +ps -axo pid,ppid,stat,etime,pcpu,pmem,command | rg -i "demucs|ffmpeg|uvicorn|stemdeck" +``` + +テスト: + +```sh +uv run --extra dev ruff check . +uv run --extra dev pytest +``` + +## 変更履歴メモ + +このマニュアルは、LayerLab の以下の拡張を前提にしています。 + +- Neon UI +- レスポンシブレイアウト +- バックグラウンドキュー処理 +- 経過時間/ETA表示 +- 曲ごとの処理時間記録 +- 高品質/最高品質プリセット +- bass dropout repair +- phase repair +- stem denoise +- 品質評価ベンチマーク +- cross-process Demucs lock +- portable FFmpeg fallback diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..38185c6 --- /dev/null +++ b/NOTICE @@ -0,0 +1,51 @@ +LayerLab + +LayerLab is an unofficial modified fork test build based on the +original StemDeck project. + +Original project: + StemDeck + https://github.com/stemdeckapp/stemdeck + +Original license: + Apache License, Version 2.0 + See LICENSE in this distribution, or: + https://www.apache.org/licenses/LICENSE-2.0 + +Attribution: + Portions of this software are based on StemDeck by the StemDeck contributors. + LayerLab retains the upstream Apache-2.0 license text and + attribution. This distribution is a modified fork and is not an official upstream release. + It is not affiliated with or endorsed by the original StemDeck project. + +Commercial use: + Apache License 2.0 permits commercial use and redistribution when its + conditions are satisfied. This notice does not grant trademark rights or + permission to present this fork as an official StemDeck release, official + commercial offering, certified build, or upstream-supported product. + Distributors remain responsible for retaining LICENSE/NOTICE files and for + verifying the licenses of all third-party components included in their + specific artifacts. + +Modification notice: + This fork uses the visible product branding LayerLab and includes + local changes for higher-quality stem separation, FFmpeg handling, UI + styling, asynchronous queue handling, bass dropout repair, and + phase-coherence repair. Additional changes include optional per-stem denoise, + progress/ETA surfacing, responsive neon UI refinements, desktop runtime + setup hardening, conservative cleanup/maintenance helpers, fork-separated + desktop storage paths, fork release update URLs, and optional release + signing/notarization hooks. + See the Git history for the complete change record. + +Third-party dependencies: + Runtime dependencies remain licensed by their respective authors. Packaged + builds include THIRD_PARTY_NOTICES.txt and generated dependency inventories + where available. + +Additional runtime helper: + imageio-ffmpeg + https://github.com/imageio/imageio-ffmpeg + License: BSD-2-Clause + Used as a portable FFmpeg executable fallback when no system or bundled + FFmpeg binary is available. diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 0000000..8088f7f --- /dev/null +++ b/README.ja.md @@ -0,0 +1,249 @@ +# LayerLab 日本語 README + +LayerLab は、音源をローカル環境で stem 分離するためのデスクトップ/ローカルWebアプリです。MP3、WAV、FLAC、M4A、または YouTube URL を入力し、ボーカル、ドラム、ベース、ギター、ピアノ、その他などの stem に分離します。処理は基本的にユーザーのマシン上で完結し、音源をクラウドへアップロードしない設計です。 + +このリポジトリは、元の StemDeck プロジェクトをベースにした非公式 fork test build です。元プロジェクトと本 fork は Apache License 2.0 のもとで配布されます。再配布時は `LICENSE` と `NOTICE` を同梱し、元プロジェクトの表示と本 fork の変更点を保持してください。本 fork は変更版であり、元プロジェクトの公式リリースではありません。元プロジェクトとは提携しておらず、元プロジェクトによる承認や推奨を受けたものでもありません。 + +## 元プロジェクト + +- 元プロジェクト: [stemdeckapp/stemdeck](https://github.com/stemdeckapp/stemdeck) +- ライセンス: Apache License 2.0 +- 本リポジトリ内のライセンス表記: [LICENSE](LICENSE), [NOTICE](NOTICE) +- 配布物に含める表記: `LICENSE`, `NOTICE`, `THIRD_PARTY_NOTICES.txt` + +Apache License 2.0 では、再配布時にライセンス本文を渡すこと、変更したファイルに変更があることを示すこと、NOTICE がある場合はその表示を保持することが求められます。本 fork では `NOTICE` に元プロジェクトへの帰属と変更概要を記載しています。 + +Apache License 2.0 は商標権の利用許諾を自動的に与えるものではありません。公開配布する場合は、説明文や配布ページで「元プロジェクトをベースにした変更版」であることを明示し、元プロジェクトの公式配布物と誤認されないようにしてください。 + +## 商用利用・有償配布について + +Apache License 2.0 は、ソフトウェアの商用利用、有償配布、社内利用、改変版の配布を禁止していません。したがって、`LayerLab` を商用プロジェクトで使うことや、有償サポート、インストーラー配布、業務利用に組み込むこと自体はライセンス上ただちに禁止されるものではありません。 + +ただし、以下は守ってください。 + +- `LICENSE`、`NOTICE`、`THIRD_PARTY_NOTICES.txt` を配布物に含める。 +- 元の StemDeck と本 fork の帰属表示、変更版であること、非公式であることを隠さない。 +- 元プロジェクトの公式版、公式販売、公式認定、公式サポートのように見せない。 +- `StemDeck` / `STEMDECK` などの名称・ロゴ・商標的表示は、出所説明に必要な範囲を超えて使わない。独自サービス名や独自ブランドで配布する場合も、説明文では「StemDeckをベースにした非公式変更版」と明記する。 +- 同梱する FFmpeg、PyTorch、Demucs、yt-dlp、Python runtime、Tauri/Rust crate など第三者依存のライセンスを、実際の配布物に合わせて確認する。 +- 音源処理サービスとして提供する場合は、処理対象音源の著作権、配信サイトの利用規約、ユーザーアップロード物の扱いを別途確認する。 + +つまり「勝手に商用利用してはいけない」というより、**Apache-2.0 の条件、NOTICE/帰属表示、商標・公式誤認の回避、第三者依存のライセンス確認を満たせば商用利用は可能**という整理です。本READMEは法的助言ではないため、公開販売や法人サービス化の前には実際の配布物を前提に専門家へ確認することを推奨します。 + +## この fork で加えた主な変更 + +- アプリ名と表示を `LayerLab` として整理し、非公式 StemDeck fork test build であることを明示。 +- Neon 系のUI、スマホ向けレスポンシブ調整、進捗バー視認性改善。 +- ジョブキュー、キャンセル、進捗率、残り推定時間表示を強化。 +- 画面内の `Logs` から、ジョブ単位または現在セッション全体の処理段階、進捗、警告、失敗理由を確認できる診断ビューを追加。 +- 複数曲キュー中も、完了済みの選択曲を前面に残して試聴やダウンロードを続けられるバックグラウンド抽出に対応。 +- 同じ曲を `Quality` / `Device` / `Clean` / 選択stem の組み合わせごとに別プロファイルとして複数回抽出できるように対応。画面表示、ダウンロードファイル名、stem ZIP内の `LAYERLAB_PROFILE.txt` で設定を確認可能。 +- クライアントマシンのCPU/GPU/メモリ状況に応じて、重い解析・stem分離パイプラインの同時実行数を自動判定。 +- ジョブごとに `Auto` / `CPU` / `Apple GPU(MPS)` / `NVIDIA CUDA` の処理デバイスを選択可能。 +- `ffprobe` がない環境でも `ffmpeg` fallback で duration を読めるよう改善。 +- `imageio-ffmpeg` を使った portable FFmpeg fallback を追加。 +- BPM、Tempo Stability、拍グリッド検出を追加。波形上に検出拍を表示し、曲ごとのメタデータとして保存。 +- piano/guitar stem を優先したchroma解析、bass root別解析、4分音符グリッド上の拍ごとの再推定からコード進行を推定生成し、弱い1拍誤検出を抑制したうえでExportメニューから `*_chords.mid` / `*_chords.csv` として書き出せるように追加。書き出し時に `Auto` / `Triads only` / `Allow 7ths`、`1/4 beat grid` / `1 bar blocks`、MIDI marker有無を選択可能。 +- 高精度プリセット `High` / `Max` / `Ultra` を追加し、`htdemucs_ft`、shift average、overlap、float32 出力を利用。 +- 音圧が高い音源向けに前処理、ゲイン復元、float32 維持、クリップ抑制を強化。 +- ベース欠け補正、stem 合計と原音の位相/残差補正を追加。 +- 分離後の各 stem に任意のノイズ除去 `Noise off` / `Light denoise` / `Strong denoise` を追加。 +- 評価用ベンチマーク `scripts/benchmark_audio.py` を追加し、stem合計と原音の残差、クリップリスク、コードMIDIメタデータをJSONで比較できるように追加。`--jobs-root` と `--baseline` で複数ジョブの回帰比較にも対応。 +- 実 ffmpeg による WAV 合成/置き換えテストとパイプラインテストを追加。 +- Tauri/Rust 側に runtime setup、FFmpeg取得、GPU検出、backend起動、保守/掃除、軽量WAV解析を実装。 +- macOS/Windows 配布向けに署名、Notarize、容量、ライセンス表記の確認導線を追加。 + +## 使い方 + +詳しい操作方法は [MANUAL.ja.md](MANUAL.ja.md) にまとめています。 + +### ローカルWebとして起動 + +```sh +./run.sh setup +./run.sh start +``` + +起動後、ブラウザで `http://127.0.0.1:8765/` を開きます。`ffmpeg` がPATHにない場合でも、Python側で `imageio-ffmpeg` fallback を使えるようにしています。 + +### 品質評価ベンチマーク + +品質プリセット、ノイズ除去、phase/bass repair の比較にはベンチマークスクリプトを使えます。stem WAVを合計し、原音との差分、相関、クリップリスク、コード進行メタデータをJSONで出力します。 + +```sh +uv run python scripts/benchmark_audio.py \ + --source /path/to/original.wav \ + --stems-dir jobs//stems \ + --metadata jobs//metadata.json \ + --out .build/benchmarks/.json +``` + +`source.*` がまだ残っているジョブなら、以下だけでも実行できます。 + +```sh +uv run python scripts/benchmark_audio.py --job-dir jobs/ +``` + +完了済みジョブでは容量節約のため原音が削除されている場合があります。その場合、stem合計誤差まで測るには `--source` で元音源を指定してください。 + +### macOSデスクトップアプリ + +Apple Silicon の場合: + +```sh +rustup target add aarch64-apple-darwin +ARCH=arm64 scripts/macos/make-runtime-pack.sh +ARCH=arm64 scripts/macos/make-app.sh +ARCH=arm64 scripts/macos/make-dmg.sh +``` + +ビルド後の `.app` は `desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/LayerLab.app` に生成されます。DMG は `.build/macos-dist/LayerLab-macOS-arm64.dmg` に生成されます。 + +### Windows portable + +Windows 環境で: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-portable.ps1 ` + -PackageName LayerLab-Windows-x64.NVIDIA ` + -StripVenv +``` + +CPU版は `-CpuOnly` とCPU用の `PackageName` を付けます。 + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-portable.ps1 ` + -PackageName LayerLab-Windows-x64 ` + -CpuOnly ` + -StripVenv +``` + +### Windows installer + +Windows 用の `.exe` インストーラーは、完成した portable フォルダを Inno Setup 6 で包む方式です。Tauri単体の `msi` / `nsis` では、現状の Python runtime と backend 一式をそのまま含められないため、このリポジトリでは portable 生成後に installer 化します。 + +```powershell +# NVIDIA/CUDA版 portable + installer +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-installer.ps1 ` + -PackageName LayerLab-Windows-x64.NVIDIA ` + -StripVenv + +# CPU版 portable + installer +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-installer.ps1 ` + -PackageName LayerLab-Windows-x64 ` + -CpuOnly ` + -StripVenv +``` + +生成物は `dist/LayerLab-Windows-x64.NVIDIA-Setup.exe` または `dist/LayerLab-Windows-x64-Setup.exe` です。インストール先は管理者権限なしで書き込みできる `%LocalAppData%\Programs\LayerLab` にしています。Start Menu ショートカット、任意のDesktopショートカット、アンインストーラーが作成されます。 + +既に portable ZIP を作成済みの場合は、再ビルドせずに包めます。 + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-installer.ps1 ` + -PackageName LayerLab-Windows-x64.NVIDIA ` + -SkipPortableBuild +``` + +## 初回セットアップ導線 + +デスクトップ版は薄い Tauri アプリとして起動し、初回セットアップでローカル処理に必要な runtime を用意します。LayerLab は upstream 版と衝突しにくいよう、既定のアプリデータと書き出し先を `LayerLab` 系のフォルダに分離します。 + +- Python runtime を確認またはダウンロード。 +- workspace と data folder を作成。 +- FFmpeg / ffprobe を確認またはダウンロード。 +- Apple Silicon MPS または NVIDIA CUDA を検出し、必要ならGPU向け設定を行う。 +- 同時実行数は既定で自動判定される。MPS/CUDA ではメモリ安全性を優先して通常1本、CPUのみで十分なコアとメモリがある環境では2本まで並列実行する。必要なら `STEMDECK_PIPELINE_CONCURRENCY=1` から `4` で上書き可能。 +- 複数のローカルLayerLabバックエンドが同時に起動しても、`STEMDECK_PIPELINE_LOCK` を基準に自動判定した同時実行数ぶんの共有スロットを使い、マシン全体で過剰な Demucs 同時実行を防ぐ。 +- Demucs model は初回分離時にキャッシュされる。 +- セットアップ画面に runtime download サイズ、jobs/cache 使用量、data folder、ライセンス表記の案内を表示する。 + +初回セットアップにはインターネット接続と数GB以上の空き容量が必要です。長尺音源や `High` / `Max` / `Ultra` の float32 出力では、stem WAV と一時ファイルでさらに容量を使います。 + +## 署名とNotarize + +認証情報はリポジトリに保存しません。必要な環境変数を渡した場合だけ署名処理が走ります。 + +### macOS app署名 + +```sh +APPLE_SIGNING_IDENTITY="Developer ID Application: Example (TEAMID)" \ +ARCH=arm64 scripts/macos/make-app.sh +``` + +### macOS DMG署名とNotarize + +```sh +APPLE_SIGNING_IDENTITY="Developer ID Application: Example (TEAMID)" \ +APPLE_NOTARIZE=1 \ +APPLE_NOTARY_KEYCHAIN_PROFILE="layerlab-notary" \ +ARCH=arm64 scripts/macos/make-dmg.sh +``` + +`APPLE_NOTARY_KEYCHAIN_PROFILE` を使わない場合は、`APPLE_ID`、`APPLE_TEAM_ID`、`APPLE_APP_SPECIFIC_PASSWORD` を指定します。公開配布する macOS 版は Developer ID 署名と Notarize を推奨します。 + +### Windows Authenticode署名 + +```powershell +$env:WINDOWS_SIGN_CERT_PATH = "C:\certs\layerlab.pfx" +$env:WINDOWS_SIGN_CERT_PASSWORD = "..." +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-installer.ps1 ` + -PackageName LayerLab-Windows-x64.NVIDIA ` + -StripVenv +``` + +署名証明書がない場合は無署名ZIP/インストーラーとして生成されます。公開配布では Authenticode 署名を推奨します。`make-installer.ps1` は同じ証明書設定で `LayerLab.exe` と installer 本体の両方に署名します。 + +## 更新 + +アプリ本体は fork 側の GitHub Releases の最新リリースを確認し、利用中のバージョンと違う場合は通知を表示します。デスクトップ版では、DMG更新後に runtime manifest のバージョンが変わっていれば初回セットアップ画面で runtime を更新します。 + +公開時は `unofficial fork test build` として GitHub Pre-release にする方針です。Release本文の冒頭で、元プロジェクトの公式版ではないこと、元プロジェクトと提携・承認関係がないこと、Apache License 2.0 に基づく fork であることを明記してください。 + +更新時も `LICENSE`、`NOTICE`、`THIRD_PARTY_NOTICES.txt` を配布物に含めてください。第三者ライブラリや FFmpeg build のライセンスは、最終的に配布する実バイナリに合わせて確認してください。 + +## 容量の目安 + +- macOS runtime pack: 数百MB規模。 +- Windows CPU portable: 約700MB目安。 +- Windows NVIDIA portable: CUDA/PyTorch を含むため約1.6GB目安。 +- Demucs model cache: 初回利用時に数百MB規模。 +- 10分の stereo 16-bit WAV: 約101MiB。 +- 10分の stereo float32 WAV: 約202MiB。 +- `High` / `Max` / `Ultra` では 4-stem float32 出力が中心になり、10分曲で stem だけでも約808MiB程度になります。 +- `Noise denoise` や phase/bass repair では一時WAVも作るため、長尺では数GBの作業領域を見てください。 + +## ライセンス上の注意 + +本READMEは法的助言ではありません。配布前には、実際に同梱する Python runtime、PyTorch、Demucs、FFmpeg build、Tauri/Rust crate、その他依存関係のライセンスを確認してください。 + +本 fork で守るべき最低限の方針: + +- 元プロジェクトの `LICENSE` と `NOTICE` を削除しない。 +- 変更点を `NOTICE`、README、Git履歴で追えるようにする。 +- 配布物のルートまたはアプリリソースに `LICENSE`、`NOTICE`、`THIRD_PARTY_NOTICES.txt` を含める。 +- 公開配布時は、元プロジェクトの公式版ではなく `LayerLab` という変更版 fork test build であることを明示する。 +- 元プロジェクトとの提携、承認、推奨を示唆しない。 +- FFmpeg は build により LGPL/GPL 条件が変わるため、使用する配布元とライセンスを明記する。 +- `THIRD_PARTY_NOTICES.txt` は最終成果物の依存関係に合わせて更新する。 + +## 確認コマンド + +```sh +uv run --extra dev ruff check . +uv run --extra dev pytest +scripts/check-distribution-notices.sh +``` + +macOS 署名確認例: + +```sh +codesign --verify --deep --strict --verbose=2 "path/to/LayerLab.app" +spctl -a -vv -t open path/to/LayerLab-macOS-arm64.dmg +``` + +Windows 署名確認例: + +```powershell +signtool verify /pa /v "LayerLab.exe" +``` diff --git a/README.md b/README.md index 63e9360..2fb4d8f 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,44 @@
-StemDeck +

LayerLab

-**Free, local stem separation. No account. No upload. No subscription.** +**Unofficial fork test build. Free, local stem separation. No account. No upload. No subscription.**
CI - GitHub Stars - Total Downloads - Latest Release - License + Fork GitHub Stars + Fork Downloads + Latest Fork Release + License

-

JOIN THE COMMUNITY

+

UNOFFICIAL FORK TEST BUILD

- GitHub - Discord - Reddit - Instagram - X - Website + Fork GitHub + Original StemDeck

-Drop in an MP3, WAV, or FLAC file, or paste a YouTube URL, and StemDeck splits the audio into up to six stems (vocals, drums, bass, guitar, piano, other). Play them back in a DAW-style multitrack mixer: mute, solo, balance levels, zoom the waveform, loop a region, and export individual stems or a custom mix. Everything runs locally on your own machine. +LayerLab is an unofficial modified fork of [StemDeck](https://github.com/stemdeckapp/stemdeck). It is a test build for higher-quality local stem separation and is not an official upstream release, not affiliated with, and not endorsed by the original StemDeck project. -> **What is this?** StemDeck is a stem separation tool, not a downloader. Its main job is processing audio you already own: drag an MP3, WAV, or FLAC onto the import bar and go. YouTube support is a convenience for content you have the right to process. StemDeck does not store, cache, or redistribute any downloaded content. Everything happens locally and nothing leaves your machine. +Drop in an MP3, WAV, FLAC, or M4A file, or paste a YouTube URL, and LayerLab splits the audio into up to six stems (vocals, drums, bass, guitar, piano, other). Play them back in a DAW-style multitrack mixer: mute, solo, balance levels, zoom the waveform, loop a region, and export individual stems or a custom mix. Everything runs locally on your own machine. -> StemDeck is a free, open alternative to cloud stem-splitters like Moises and LALAL.AI: no account, no quota, no uploads, no subscription. If you want stems for personal study and prefer to keep things local and free, StemDeck has you covered. If you need the polish, a mobile app, or deeper musician tooling, the commercial products are a better fit. +> **What is this?** LayerLab is a stem separation tool, not a downloader. Its main job is processing audio you already own: drag an MP3, WAV, FLAC, or M4A onto the import bar and go. YouTube support is a convenience for content you have the right to process. LayerLab does not store, cache, or redistribute any downloaded content. Everything happens locally and nothing leaves your machine. -![StemDeck screenshot](imgs/screenshot/stemdeck.png) +> LayerLab is a free, open alternative to cloud stem-splitters like Moises and LALAL.AI: no account, no quota, no uploads, no subscription. If you want stems for personal study and prefer to keep things local and free, LayerLab has you covered. If you need the polish, a mobile app, or deeper musician tooling, the commercial products are a better fit. + +日本語での概要、配布手順、ライセンス/NOTICE、そしてこの fork で加えた変更点は [README.ja.md](README.ja.md) にまとめています。操作マニュアルは [MANUAL.ja.md](MANUAL.ja.md) を参照してください。 + +![LayerLab screenshot](imgs/screenshot/stemdeck.png) ## We Recommend -StemDeck is free and **does not accept any money, sponsorship, or funding** - not from users, not from anyone listed below. We share these makers and artists purely for the joy of pointing you toward wonderful people doing beautiful work. Go meet them ❤️ +LayerLab is free and **does not accept any money, sponsorship, or funding** - not from users, not from anyone listed below. We share these makers and artists purely for the joy of pointing you toward wonderful people doing beautiful work. Go meet them ❤️ | Supporter | What they do | Link | |---|---|---| @@ -54,12 +54,16 @@ StemDeck is free and **does not accept any money, sponsorship, or funding** - no **6-stem separation** via Demucs `htdemucs_6s`, with auto-detection of the best Torch device (CUDA on NVIDIA, MPS on Apple Silicon, CPU fallback). -**YouTube and local file import.** Paste a YouTube URL or drop an MP3 or WAV directly onto the import bar. +**YouTube and local file import.** Paste a YouTube URL or drop an MP3, WAV, FLAC, or M4A directly onto the import bar. **DAW-style waveform editor** with min/max sample rendering across all stems, shared normalization, zoom in/out/Fit, loop drag on the ruler, gold playhead overlay, and stem-aligned lanes. **Stem subset extraction.** Click stem chips to choose which stems to keep. Clicking from "all selected" snaps to "only this one"; subsequent clicks add or remove. +**Multiple profiles per song.** Re-run the same source with different `Quality`, `Device`, `Clean`, or selected-stem settings and LayerLab keeps each result as a separate profile in the library. Exported filenames include the profile, and stem ZIPs include `LAYERLAB_PROFILE.txt`. + +**Device selection.** Choose `Auto`, `CPU`, Apple GPU (`mps`), or NVIDIA CUDA per job. Unavailable GPU options are disabled in the UI. + **"Original" backing track.** When you pick a subset, a 7th lane contains the complement (full song minus selected stems), perfect for A/B reference without doubling. **Downloadable selected mix.** A single `mix.wav` of just your selected stems, summed via ffmpeg amix. @@ -68,7 +72,11 @@ StemDeck is free and **does not accept any money, sponsorship, or funding** - no **Live VU meters** per stem. Post-gain RMS via Web Audio analysers with peak hold and slow falloff. -**Song analysis** including BPM (librosa beat tracker), key, scale, and confidence (Albrecht-Shanahan profiles), integrated LUFS (BS.1770), and sample peak in dBFS. +**Song analysis** including BPM and beat grid timestamps (librosa beat tracker, first 180 seconds), key, scale, and confidence (Albrecht-Shanahan profiles), integrated LUFS (BS.1770), and sample peak in dBFS. + +**Chord guide export.** LayerLab estimates quarter-note-grid chord labels from piano/guitar-weighted chroma plus bass-root hints, suppresses weak one-beat misreads, merges stable repeats into sustained chord blocks, and exports MIDI or CSV from the Export menu. Chord export can be switched between beat/bar grid and auto/triad/seventh styles, with optional DAW marker events in MIDI. + +**Local quality benchmark.** `scripts/benchmark_audio.py` compares a source file against exported stems, reports stem-sum residual error, clipping risk, chord metadata coverage, and writes machine-readable JSON for regression tracking. It can also scan a whole `jobs/` root and compare against a previous baseline. **Cancellable jobs.** Cancel mid-pipeline and the runner terminates the active subprocess immediately, deletes the partial job dir, and returns to ready. @@ -78,9 +86,9 @@ StemDeck is free and **does not accept any money, sponsorship, or funding** - no ## Honest Comparison -StemDeck is not trying to compete with commercial stem-separation products. It covers the core use case well and stops there. This table exists so you can make an informed choice rather than discover the gaps after the fact. +LayerLab is not trying to compete with commercial stem-separation products. It covers the core use case well and stops there. This table exists so you can make an informed choice rather than discover the gaps after the fact. -| | StemDeck | Moises / LALAL.AI / similar | +| | LayerLab | Moises / LALAL.AI / similar | |---|---|---| | **Price** | Free, forever | Freemium; credits or subscription required for regular use | | **Hosting** | Runs entirely on your machine | Cloud; audio must be uploaded to their servers | @@ -88,32 +96,33 @@ StemDeck is not trying to compete with commercial stem-separation products. It c | **Internet required** | Only for YouTube download and first model fetch (~170 MB, cached after) | Always; no offline use | | **Privacy** | Audio never leaves your machine | Audio is uploaded and processed on third-party servers | | **Data retention** | You control it; delete anytime | Governed by their privacy policy and retention period | -| **Stem model** | Demucs `htdemucs_6s` (open source, Meta AI) | Proprietary models, regularly updated, generally higher quality | +| **Stem model** | Demucs `htdemucs_6s` / `htdemucs_ft` depending on quality preset (open source, Meta AI) | Proprietary models, regularly updated, generally higher quality | | **Stem count** | 6 (vocals, drums, bass, guitar, piano, other) | Up to 10 depending on service and plan | -| **Input formats** | YouTube URL, MP3, WAV | MP3, WAV, FLAC, M4A, and more depending on service | +| **Input formats** | YouTube URL, MP3, WAV, FLAC, M4A | MP3, WAV, FLAC, M4A, and more depending on service | | **Processing speed** | Depends on your hardware; fast with a GPU, slow on CPU only | Fast regardless of your hardware (runs on their servers) | -| **Batch processing** | One job at a time | Yes, on paid plans | +| **Batch processing** | Local queue with background processing; concurrency auto-limited by CPU/GPU memory | Yes, on paid plans | +| **Diagnostics** | In-app per-job/session logs with stage, progress, warnings, and persisted failure details | Varies | | **Mobile app** | No | iOS and Android | -| **Extra features** | No (no pitch shift, chord detection, lyrics, click track, BPM tap) | Yes, varies by product | +| **Extra features** | BPM/beat grid, chord MIDI guide, local quality benchmark; no lyrics, pitch shift, or mobile tooling | Yes, varies by product | | **Polish** | Functional, hobby-grade UI | Polished, production-grade apps | | **Source code** | Open source, forkable, self-hostable | Closed source | -If you need speed, quality, mobile access, or the extra musician tooling, the commercial products are worth the money. If you want stems for personal study, prefer to keep audio private, or just want something that runs locally with no strings attached, StemDeck is enough. +If you need speed, quality, mobile access, or the extra musician tooling, the commercial products are worth the money. If you want stems for personal study, prefer to keep audio private, or just want something that runs locally with no strings attached, LayerLab is enough. --- ## Download -Pre-built installers and zips are attached to each [GitHub Release](https://github.com/stemdeckapp/stemdeck/releases). +Unofficial fork test builds are attached to [GitHub Releases](https://github.com/bassmicrobe/stemdeck/releases). For production use, prefer a Developer ID signed and notarized macOS build. **macOS** | DMG | GPU | Chip | |---|---|---| -| `StemDeck-macOS-arm64.dmg` | Apple Silicon (MPS) | M1 and later | -| `StemDeck-macOS-x64.dmg` | CPU only | Intel | +| `LayerLab-macOS-arm64.dmg` | Apple Silicon (MPS) | M1 and later | +| `LayerLab-macOS-x64.dmg` | CPU only | Intel | -Open the DMG, drag StemDeck to Applications, and launch it. On first launch the setup screen downloads the Python runtime (~500 MB), FFmpeg, and the Demucs model (~170 MB). Subsequent launches skip setup and start in seconds. No Python or system dependencies required. +Open the DMG, drag LayerLab to Applications, and launch it. On first launch the setup screen downloads the Python runtime, FFmpeg, and the Demucs model (~170 MB). Subsequent launches skip setup and start in seconds. No Python or system dependencies required. macOS may show a Gatekeeper prompt on first open — right-click the app and choose Open to bypass it. @@ -121,10 +130,46 @@ macOS may show a Gatekeeper prompt on first open — right-click the app and cho | Zip | GPU | Approx. size | |---|---|---| -| `StemDeck-Windows-x64.zip` | CPU only | ~700 MB | -| `StemDeck-Windows-x64.NVIDIA.zip` | NVIDIA CUDA | ~1.6 GB | +| `LayerLab-Windows-x64.zip` | CPU only | ~700 MB | +| `LayerLab-Windows-x64.NVIDIA.zip` | NVIDIA CUDA | ~1.6 GB | + +Extract the zip anywhere, run `LayerLab.exe`. On first launch the app verifies the bundled Python runtime and downloads FFmpeg and the Demucs model (~170 MB). Subsequent launches skip this and start in seconds. Everything is self-contained; no Python or system dependencies required. + +Windows installers are generated by wrapping the completed portable folder with Inno Setup 6: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-installer.ps1 ` + -PackageName LayerLab-Windows-x64.NVIDIA ` + -StripVenv + +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-installer.ps1 ` + -PackageName LayerLab-Windows-x64 ` + -CpuOnly ` + -StripVenv +``` -Extract the zip anywhere, run `StemDeck.exe`. On first launch the app verifies the bundled Python runtime and downloads FFmpeg and the Demucs model (~170 MB). Subsequent launches skip this and start in seconds. Everything is self-contained; no Python or system dependencies required. +The installer lands in `dist/*-Setup.exe`, installs per-user under `%LocalAppData%\Programs\LayerLab`, creates Start Menu/Desktop shortcut entries, and includes `LICENSE`, `NOTICE`, and `THIRD_PARTY_NOTICES.txt`. + +### Release Signing / Notarization + +Local builds are unsigned unless signing credentials are provided. Release scripts support optional signing without storing secrets in the repository: + +- macOS app signing: set `APPLE_SIGNING_IDENTITY` before `scripts/macos/make-app.sh`. +- macOS DMG signing: set `APPLE_SIGNING_IDENTITY` before `scripts/macos/make-dmg.sh`. +- macOS notarization: set `APPLE_NOTARIZE=1` and either `APPLE_NOTARY_KEYCHAIN_PROFILE` or `APPLE_ID`, `APPLE_TEAM_ID`, and `APPLE_APP_SPECIFIC_PASSWORD` before `scripts/macos/make-dmg.sh`. +- Windows executable signing: set `WINDOWS_SIGN_CERT_PATH` and optionally `WINDOWS_SIGN_CERT_PASSWORD`, `WINDOWS_SIGNTOOL_PATH`, and `WINDOWS_TIMESTAMP_URL` before `scripts/windows/make-portable.ps1`. +- Windows installer signing: use the same variables before `scripts/windows/make-installer.ps1`; the script signs both `LayerLab.exe` and the generated installer when credentials are present. + +Unsigned internal builds are acceptable for local testing, but public macOS builds should be Developer ID signed and notarized. Public Windows builds should be Authenticode signed when possible. + +### Distribution Size and Storage + +The desktop shell is intentionally thin. The Python runtime, FFmpeg/ffprobe, and Demucs model are prepared during first-run setup or first use. This keeps the app bundle smaller, but the first launch needs internet access and enough disk space. + +- macOS first-run setup downloads a runtime pack, FFmpeg/ffprobe, and later the model cache. +- Windows portable builds include a Python environment; the NVIDIA variant is larger because CUDA/PyTorch wheels are large. +- Stem WAVs are large: a 10-minute stereo 16-bit WAV is about 101 MiB, and a 10-minute stereo float32 WAV is about 202 MiB before multiplying by the number of stems. +- `LICENSE`, `NOTICE`, and platform `THIRD_PARTY_NOTICES.txt` are copied into release packages. Runtime dependency inventories are generated where available. --- @@ -138,9 +183,9 @@ Extract the zip anywhere, run `StemDeck.exe`. On first launch the app verifies t
-StemDeck is built on **[Python 3.12](https://python.org)** managed via **[uv](https://github.com/astral-sh/uv)**, with a **[FastAPI](https://fastapi.tiangolo.com)** backend serving REST and Server-Sent Events. Stem separation uses **[Demucs](https://github.com/facebookresearch/demucs)** (`htdemucs_6s`), Meta AI's open-source 6-stem neural network. YouTube audio is fetched via **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**; transcoding and mixing use **[FFmpeg](https://ffmpeg.org)**. BPM detection and key analysis run on **[librosa](https://librosa.org)**; loudness measurement uses **[pyloudnorm](https://github.com/csteinmetz1/pyloudnorm)** (ITU-R BS.1770). The macOS and Windows desktop shells are **[Tauri v2](https://tauri.app)** (Rust/WKWebView on macOS, Rust/WebView2 on Windows). The frontend is vanilla JS with the Web Audio API, no framework and no build step; waveforms are rendered on `` using min/max sample rendering. +LayerLab is built on **[Python 3.12](https://python.org)** managed via **[uv](https://github.com/astral-sh/uv)**, with a **[FastAPI](https://fastapi.tiangolo.com)** backend serving REST and Server-Sent Events. Stem separation uses **[Demucs](https://github.com/facebookresearch/demucs)** (`htdemucs_6s` for Standard, `htdemucs_ft` for High / Max / Ultra), Meta AI's open-source neural stem separation models. YouTube audio is fetched via **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**; transcoding and mixing use **[FFmpeg](https://ffmpeg.org)**. BPM detection and key analysis run on **[librosa](https://librosa.org)**; loudness measurement uses **[pyloudnorm](https://github.com/csteinmetz1/pyloudnorm)** (ITU-R BS.1770). The macOS and Windows desktop shells are **[Tauri v2](https://tauri.app)** (Rust/WKWebView on macOS, Rust/WebView2 on Windows). The frontend is vanilla JS with the Web Audio API, no framework and no build step; waveforms are rendered on `` using min/max sample rendering. -*Thanks to the creators and maintainers of all the open-source libraries that make StemDeck possible.* +*Thanks to the creators and maintainers of all the open-source libraries that make LayerLab possible.* --- @@ -166,21 +211,21 @@ ARCH=x64 scripts/macos/make-app.sh ARCH=x64 scripts/macos/make-dmg.sh ``` -The `.app` lands at `desktop/src-tauri/target//release/bundle/macos/StemDeck.app`. The DMG lands at `.build/macos-dist/StemDeck-macOS-.dmg`. +The `.app` lands at `desktop/src-tauri/target//release/bundle/macos/LayerLab.app`. The DMG lands at `.build/macos-dist/LayerLab-macOS-.dmg`. To run a fresh build directly without the DMG: ```sh -open desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app +open "desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/LayerLab.app" ``` If macOS blocks the app with a Gatekeeper prompt, run: ```sh -xattr -dr com.apple.quarantine desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app +xattr -dr com.apple.quarantine "desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/LayerLab.app" ``` -> **Note:** To test a clean first-launch during development, you can wipe previous app data first: `rm -rf ~/Library/Application\ Support/StemDeck`. Don't do this on a real install. +> **Note:** To test a clean first-launch during development, you can wipe previous app data first: `rm -rf ~/Library/Application\ Support/LayerLab`. Don't do this on a real install. --- @@ -193,7 +238,7 @@ Python 3.12 or newer, `ffmpeg` on your PATH, and [uv](https://github.com/astral- #### macOS / Linux (one-shot) ```sh -git clone https://github.com/stemdeckapp/stemdeck stemdeck && cd stemdeck +git clone https://github.com/bassmicrobe/stemdeck layerlab && cd layerlab ./run.sh setup # installs ffmpeg + uv, runs uv sync ./run.sh start ``` @@ -209,7 +254,7 @@ Install prerequisites: - [ffmpeg](https://ffmpeg.org/download.html) — `winget install Gyan.FFmpeg` (or Chocolatey: `choco install ffmpeg`) ```powershell -git clone https://github.com/stemdeckapp/stemdeck stemdeck; cd stemdeck +git clone https://github.com/bassmicrobe/stemdeck layerlab; cd layerlab uv sync uv run uvicorn app.main:app --host 127.0.0.1 --port 8000 ``` @@ -231,7 +276,7 @@ uv run uvicorn app.main:app --host 127.0.0.1 --port 8000 #### Manual (any platform) ```sh -git clone https://github.com/stemdeckapp/stemdeck stemdeck && cd stemdeck +git clone https://github.com/bassmicrobe/stemdeck layerlab && cd layerlab uv sync uv run uvicorn app.main:app --reload ``` @@ -254,34 +299,76 @@ Stems land in `./jobs/` on the host. Demucs weights are cached in a named volume ./run.sh status # is it running? ``` +If `ffmpeg` is not installed on your PATH, `./run.sh start` and the Python +runtime fall back to an `imageio-ffmpeg` binary automatically. + --- ## How to Use 1. On the import bar, click stem chips to choose which stems to extract (defaults to all 6). -2. Paste a YouTube URL **or** drop an MP3/WAV file, then click **Process**. +2. Paste a YouTube URL **or** drop an MP3/WAV/FLAC/M4A file, then click **Process**. 3. Wait through `Uploading...` / `Downloading...` → `Analyzing...` → `Separating...` → `Mixing tracks...`. -4. When done, the studio dashboard appears. If you picked a subset, the first lane is **Original** (full song minus your selection); the rest are your isolated stems. -5. Mix: **Play/Pause/Stop** controls the master transport. **M** mutes a stem, **S** solos it (additive; multiple solos stay audible), **Monitor** solos only that stem and clears others. The volume fader moves 1:1 with drag; double-click resets to 0 dB; `Shift+wheel` gives coarse adjustment and plain wheel gives fine. The **Reset**, **Mute**, and **Solo** toolbar buttons act on all stems at once. -6. Drag on the ruler to define a loop region; click `Loop` to enable. Use `+` / `-` / `Fit` or `Ctrl/Cmd+wheel` to zoom. -7. **Download Mix** in the footer gives you a WAV of your selected stems summed together. +4. You can add multiple tracks to the queue. Once a completed track is selected, later jobs continue in the background so preview and downloads stay available. +5. When done, the studio dashboard appears. If you picked a subset, the first lane is **Original** (full song minus your selection); the rest are your isolated stems. +6. Mix: **Play/Pause/Stop** controls the master transport. **M** mutes a stem, **S** solos it (additive; multiple solos stay audible), **Monitor** solos only that stem and clears others. The volume fader moves 1:1 with drag; double-click resets to 0 dB; `Shift+wheel` gives coarse adjustment and plain wheel gives fine. The **Reset**, **Mute**, and **Solo** toolbar buttons act on all stems at once. +7. Drag on the ruler to define a loop region; click `Loop` to enable. Use `+` / `-` / `Fit` or `Ctrl/Cmd+wheel` to zoom. +8. **Download Mix** in the footer gives you a WAV of your selected stems summed together. **Keyboard shortcuts:** `Space` play/pause · `[` seek -5s · `]` seek +5s · `L` loop · `I` loop in · `O` loop out --- +## Quality Benchmark + +Use the local benchmark helper when comparing quality presets, denoise settings, or phase/bass repair changes. It decodes the source and stem WAVs through ffmpeg, sums the stems, and reports residual error as JSON. + +```sh +uv run python scripts/benchmark_audio.py \ + --source /path/to/original.wav \ + --stems-dir jobs//stems \ + --metadata jobs//metadata.json \ + --out .build/benchmarks/.json +``` + +For an unswept or in-progress job that still has `source.*` in its job directory: + +```sh +uv run python scripts/benchmark_audio.py --job-dir jobs/ +``` + +Completed jobs may have their source audio removed to save disk space. In that case, pass `--source` explicitly if you want stem-sum residual metrics; without a source, the report still summarizes available stems and chord metadata. + +--- + ## Configuration | Variable | Default | Purpose | |---|---|---| +| `STEMDECK_QUALITY_PRESET` | `standard` | Separation quality preset: `standard`, `high`, `max`, or `ultra`. `high` / `max` / `ultra` use slower Demucs settings and preserve 32-bit float WAV output. | | `STEMDECK_DEMUCS_DEVICE` | auto | Force Torch device: `cuda`, `mps`, or `cpu`. | -| `STEMDECK_DEMUCS_MODEL` | `htdemucs_6s` | Demucs model name. | +| `STEMDECK_PIPELINE_CONCURRENCY` | auto | Heavy analysis/separation jobs to run in parallel. Auto keeps CUDA/MPS at `1` for memory safety and uses `2` only on roomy CPU-only machines. Set `1`-`4` to override. | +| `STEMDECK_PIPELINE_LOCK` | system temp file | Base path for cross-process processing-slot locks. LayerLab creates one shared slot per configured concurrency level. | +| `STEMDECK_DEMUCS_MODEL` | preset-dependent | Demucs model name. `standard` uses `htdemucs_6s`; `high` / `max` / `ultra` use `htdemucs_ft` unless overridden. | +| `STEMDECK_DEMUCS_SHIFTS` | preset-dependent | Number of Demucs shift averages. Higher is slower and can reduce artifacts. | +| `STEMDECK_DEMUCS_PRE_GAIN_DB` | preset-dependent | Optional input gain before Demucs. Negative values such as `-6` can help very loud masters separate more cleanly. | +| `STEMDECK_DEMUCS_FLOAT32` | preset-dependent | Write Demucs stems as 32-bit float WAVs when truthy. | +| `STEMDECK_DEMUCS_CLIP_MODE` | preset-dependent | Demucs output clipping mode: `rescale`, `clamp`, or `none`. | +| `STEMDECK_DEMUCS_OVERLAP` | `0` | Optional Demucs segment overlap override. `0` leaves the Demucs default untouched. | +| `STEMDECK_DEMUCS_SEGMENT` | `0` | Optional Demucs segment length override. `0` leaves the Demucs default untouched. | +| `STEMDECK_BASS_REPAIR` | `high`/`max`/`ultra`: on, `standard`: off | Repair short bass dropouts after separation by blending a low-frequency residual from the original mix. Set `0` to disable or `1` to force-enable. | +| `STEMDECK_BASS_REPAIR_LOW_PASS_HZ` | `180` | Low-pass cutoff used for the bass residual candidate. | +| `STEMDECK_BASS_REPAIR_TRIGGER_RATIO` | `1.9` | How much stronger the residual must be than the bass stem before repair blends in. Higher is more conservative. | +| `STEMDECK_BASS_REPAIR_MAX_BLEND` | `0.65` | Maximum amount of residual blended into detected bass dropouts. | +| `STEMDECK_PHASE_REPAIR` | `high`/`max`/`ultra`: on, `standard`: off | Repair stem-sum phase/residual mismatch against the original source. Set `0` to disable or `1` to force-enable. | +| `STEMDECK_PHASE_REPAIR_MAX_BLEND` | `standard`: `0.42`, `high`: `0.65`, `max`: `0.90`, `ultra`: `0.95` | Maximum source-minus-stem-sum residual blended back into active stems. Higher reconstructs the source more strongly but can increase bleed. | +| `STEMDECK_PHASE_REPAIR_FLOOR_DB` | `-58` | Residual floor below which phase repair stays inactive. Lower values are less conservative. | | `STEMDECK_JOBS_DIR` | `./jobs` | Where job directories land. | | `STEMDECK_DATA_DIR` | (none) | Portable mode root; sets all sub-dirs below to live inside it. | | `STEMDECK_CACHE_DIR` | `/cache` | Torch model cache directory. | | `STEMDECK_DOWNLOADS_DIR` | `/downloads` | yt-dlp download scratch space. | | `STEMDECK_MODELS_DIR` | `/models` | Demucs model weights directory. | -| `STEMDECK_LOGS_DIR` | `/logs` | Log file output directory. | +| `STEMDECK_LOGS_DIR` | `/logs` | Reserved log file output directory; structured job diagnostics are also available in the in-app Logs viewer. | | `STEMDECK_FFMPEG_DIR` | (none) | Directory containing a bundled ffmpeg binary. | | `STEMDECK_FFMPEG` | `ffmpeg` | Path to the ffmpeg executable. | | `STEMDECK_FFPROBE` | `ffprobe` | Path to the ffprobe executable. | @@ -350,13 +437,31 @@ Job state is in-memory. Restart the server and the job list resets, but files pe ## Disclaimer -StemDeck is a local audio stem separation tool intended for personal study, research, and experimentation. It is not a downloading service. It does not store, cache, or redistribute any audio content. All processing runs on the user's own machine and no audio is transmitted anywhere. +LayerLab is a local audio stem separation tool intended for personal study, research, and experimentation. It is not a downloading service. It does not store, cache, or redistribute any audio content. All processing runs on the user's own machine and no audio is transmitted anywhere. YouTube URL support is provided via [yt-dlp](https://github.com/yt-dlp/yt-dlp) as a convenience. Automated downloading may violate YouTube's Terms of Service. You, the user, are solely responsible for ensuring you have the right to process any audio you submit, complying with the terms of service of any site you download from, and respecting the copyright of the material you work with. You are also responsible for following the licenses of the underlying tools this project depends on (yt-dlp, Demucs, FFmpeg, PyTorch, and others listed in `pyproject.toml`). -The author(s) of StemDeck provide this software "as is", without warranty of any kind, and accept no responsibility or liability for how it is used. +The author(s) of LayerLab provide this software "as is", without warranty of any kind, and accept no responsibility or liability for how it is used. + +--- + +## License and Attribution + +LayerLab is based on the original [StemDeck](https://github.com/stemdeckapp/stemdeck) project. The original StemDeck project is licensed under the [Apache License 2.0](LICENSE), and this fork retains that license and attribution. + +See [NOTICE](NOTICE) for the upstream attribution and modification notice. Third-party runtime dependencies are licensed by their respective authors; packaged builds include `THIRD_PARTY_NOTICES.txt` and generated dependency inventories where available. + +This repository is an unofficial modified fork test build, not an official upstream release. It is not affiliated with or endorsed by the original StemDeck project. Apache-2.0 does not grant trademark rights, so public distributions should avoid implying upstream endorsement. + +### Commercial Use + +Apache License 2.0 does not prohibit commercial use, paid distribution, internal business use, or distribution of modified versions. Commercial use of this fork is therefore possible when the Apache-2.0 conditions and all third-party dependency licenses are respected. + +Do not remove `LICENSE`, `NOTICE`, or packaged third-party notices. Do not present this fork as an official StemDeck release, official commercial offering, certified build, or upstream-supported product. Trademark-like use of the StemDeck/STEMDECK name or logos is not granted by Apache-2.0 except as needed to describe the origin of the work and reproduce NOTICE content. + +If you sell, host, bundle, or provide services around this software, you are responsible for verifying the exact licenses of the shipped FFmpeg build, PyTorch, Demucs, yt-dlp, Python runtime, Tauri/Rust crates, and any other bundled components, and for ensuring that users have the rights to process the audio they submit. This documentation is not legal advice. --- @@ -364,12 +469,8 @@ The author(s) of StemDeck provide this software "as is", without warranty of any | Platform | Link | |---|---| -| GitHub | [stemdeckapp/stemdeck](https://github.com/stemdeckapp/stemdeck) | -| Discord | [discord.gg/2MVsWqaPRe](https://discord.gg/2MVsWqaPRe) | -| Reddit | [r/StemDeckApp](https://www.reddit.com/r/StemDeckApp/) | -| Instagram | [@stemdeck](https://www.instagram.com/stemdeck) | -| X | [@StemDeckApp](https://x.com/StemDeckApp) | -| Website | [stemdeck.app](https://stemdeck.app) *(coming soon)* | +| Fork GitHub | [bassmicrobe/stemdeck](https://github.com/bassmicrobe/stemdeck) | +| Original project | [stemdeckapp/stemdeck](https://github.com/stemdeckapp/stemdeck) | --- @@ -395,10 +496,10 @@ Issues, feature suggestions, and pull requests are welcome. See open issues for ## Star History - - - - - Star History Chart - + + + + + Fork Star History Chart + diff --git a/SECURITY.md b/SECURITY.md index 9b76a6f..4b46b8c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,9 +2,9 @@ ## Supported Versions -StemDeck is in active alpha. Only the latest release receives security fixes - -there are no long-term-support branches yet. Please update to the newest -release before reporting an issue. +LayerLab is an unofficial StemDeck fork test build in active alpha. Only the +latest fork release receives security fixes - there are no long-term-support +branches yet. Please update to the newest release before reporting an issue. | Version | Supported | | --------------- | --------- | @@ -34,9 +34,10 @@ What to expect: ## Scope and threat model -StemDeck is local-first and single-user by design: it runs on your own machine, -has no authentication, and is same-origin only. Reports that it "has no login" -or "no per-user access control" describe intended behavior, not vulnerabilities. +LayerLab is local-first and single-user by design: it runs on your own +machine, has no authentication, and is same-origin only. Reports that it "has +no login" or "no per-user access control" describe intended behavior, not +vulnerabilities. We are most interested in reports about: @@ -50,4 +51,4 @@ We are most interested in reports about: We will not pursue action against good-faith security research that respects user privacy and avoids data destruction or service disruption. Thank you for -helping keep StemDeck users safe. +helping keep LayerLab users safe. diff --git a/app/api/events.py b/app/api/events.py index 8f10b99..3fada8a 100644 --- a/app/api/events.py +++ b/app/api/events.py @@ -9,6 +9,7 @@ from app.core.config import JOB_ID_RE from app.core.registry import get as registry_get +from app.core.registry import refresh_queue_positions as registry_refresh_queue_positions router = APIRouter(tags=["events"]) @@ -45,6 +46,7 @@ async def stream() -> AsyncIterator[str]: loop = asyncio.get_running_loop() deadline = loop.time() + _MAX_SSE_SECONDS while loop.time() < deadline: + registry_refresh_queue_positions() snapshot = job.to_state() serialized = json.dumps(snapshot) if serialized != last: diff --git a/app/api/jobs.py b/app/api/jobs.py index 7fc8df8..8b41535 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -10,32 +10,47 @@ from pathlib import Path from fastapi import APIRouter, HTTPException, Request -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, field_validator, model_validator from app.core.config import ( JOB_ID_RE, JOBS_DIR, MAX_DURATION_SEC, MAX_PENDING_JOBS, - STEM_NAMES, + QUALITY_PRESET, + available_demucs_devices, + demucs_device_choice_available, + ffmpeg_executable, ffprobe_executable, + normalize_demucs_device_choice, + normalize_quality_preset, + normalize_stem_denoise_preset, + resolve_demucs_device_choice, + stem_names_for_quality_preset, ) -from app.core.models import Job +from app.core.files import atomic_write_text +from app.core.joblog import add_job_log +from app.core.models import Job, _set from app.core.registry import all_jobs as registry_all_jobs from app.core.registry import get as registry_get from app.core.registry import get_proc as registry_get_proc from app.core.registry import persist as registry_persist +from app.core.registry import refresh_queue_positions as registry_refresh_queue_positions from app.core.registry import register_if_capacity as registry_register_if_capacity from app.core.registry import remove as registry_remove from app.pipeline import run_local_pipeline, run_pipeline from app.pipeline.download import InvalidYouTubeURL, validate_youtube_url +from app.pipeline.process import terminate_process router = APIRouter(tags=["jobs"]) logger = logging.getLogger("stemdeck.api") -_ALLOWED_EXTS = frozenset((".mp3", ".wav", ".flac")) +ACTIVE_JOB_STATUSES = frozenset(("queued", "downloading", "analyzing", "separating", "processing")) +_ALLOWED_EXTS = frozenset((".mp3", ".wav", ".flac", ".m4a")) _MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB _WS_RE = re.compile(r"\s+") +_FFMPEG_DURATION_RE = re.compile(r"Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)") +_pipeline_tasks: dict[asyncio.Task, Job] = {} def _sanitize_title(filename: str) -> str: @@ -44,25 +59,46 @@ def _sanitize_title(filename: str) -> str: return _WS_RE.sub(" ", stem).strip()[:120] -def _probe_duration(path: Path) -> float: - """Run ffprobe to get file duration in seconds.""" +def _probe_duration_with_ffmpeg(path: Path) -> float: + """Fallback duration probe for local setups that have ffmpeg but not ffprobe.""" result = subprocess.run( - [ - ffprobe_executable(), - "-v", - "quiet", - "-show_entries", - "format=duration", - "-of", - "default=noprint_wrappers=1:nokey=1", - str(path), - ], + [ffmpeg_executable(), "-hide_banner", "-i", str(path)], capture_output=True, text=True, timeout=30, ) + output = f"{result.stderr}\n{result.stdout}" + if match := _FFMPEG_DURATION_RE.search(output): + hours, minutes, seconds = match.groups() + return (int(hours) * 3600) + (int(minutes) * 60) + float(seconds) + raise RuntimeError("ffmpeg could not determine duration") + + +def _probe_duration(path: Path) -> float: + """Run ffprobe to get file duration in seconds, falling back to ffmpeg.""" + try: + result = subprocess.run( + [ + ffprobe_executable(), + "-v", + "quiet", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(path), + ], + capture_output=True, + text=True, + timeout=30, + ) + except FileNotFoundError: + return _probe_duration_with_ffmpeg(path) if result.returncode != 0: - raise RuntimeError(f"ffprobe failed: {result.stderr.strip()}") + try: + return _probe_duration_with_ffmpeg(path) + except Exception as e: + raise RuntimeError(f"ffprobe failed: {result.stderr.strip()}") from e try: return float(result.stdout.strip()) except ValueError as e: @@ -95,6 +131,7 @@ def _rmtree_job(job_id: str) -> None: def _task_error_cb(task: asyncio.Task) -> None: + _pipeline_tasks.pop(task, None) if task.cancelled(): return exc = task.exception() @@ -102,6 +139,37 @@ def _task_error_cb(task: asyncio.Task) -> None: logger.error("pipeline task raised unhandled exception", exc_info=exc) +def _track_pipeline_task(task: asyncio.Task, job: Job) -> None: + _pipeline_tasks[task] = job + task.add_done_callback(_task_error_cb) + + +async def shutdown_pipeline_tasks() -> None: + tasks = list(_pipeline_tasks.items()) + for task, job in tasks: + job.cancel_requested = True + task.cancel() + if tasks: + await asyncio.gather(*(task for task, _ in tasks), return_exceptions=True) + + +def _selected_stems_for_quality(stems: list[str] | None, quality_preset: str) -> list[str]: + allowed = stem_names_for_quality_preset(quality_preset) + selected = [s for s in stems if s in allowed] if stems else list(allowed) + return selected or list(allowed) + + +def _device_choice_or_422(value: str | None) -> tuple[str, str]: + choice = normalize_demucs_device_choice(value) + if not demucs_device_choice_available(choice): + available = ", ".join(available_demucs_devices()) + raise HTTPException( + status_code=422, + detail=f"Selected device '{choice}' is not available on this machine. Available: {available}", + ) + return choice, resolve_demucs_device_choice(choice) + + class JobRequest(BaseModel): url: str # Subset of stems to include in the post-processing "selected mix" @@ -110,6 +178,9 @@ class JobRequest(BaseModel): # rejected, so a future model with extra stems doesn't break older # clients pinning the old set. stems: list[str] | None = None + quality_preset: str | None = None + stem_denoise: str | None = None + demucs_device: str | None = None @router.post("") @@ -137,22 +208,32 @@ async def _create_youtube_job(request: Request) -> dict[str, str]: except InvalidYouTubeURL as e: raise HTTPException(status_code=422, detail=str(e)) from e - selected = [s for s in payload.stems if s in STEM_NAMES] if payload.stems else list(STEM_NAMES) - if not selected: - selected = list(STEM_NAMES) + quality_preset = normalize_quality_preset(payload.quality_preset or QUALITY_PRESET) + stem_denoise_preset = normalize_stem_denoise_preset(payload.stem_denoise) + demucs_device, demucs_device_resolved = _device_choice_or_422(payload.demucs_device) + selected = _selected_stems_for_quality(payload.stems, quality_preset) - job = Job(id=uuid.uuid4().hex[:12], selected_stems=selected, source_url=url) + job = Job( + id=uuid.uuid4().hex[:12], + selected_stems=selected, + quality_preset=quality_preset, + stem_denoise_preset=stem_denoise_preset, + demucs_device=demucs_device, + demucs_device_resolved=demucs_device_resolved, + source_url=url, + ) if not registry_register_if_capacity(job, MAX_PENDING_JOBS): raise HTTPException(status_code=503, detail="Server busy, please try again later") + add_job_log(job, "URL job accepted", stage="queued", progress=0.0) task = asyncio.create_task(run_pipeline(job, url, JOBS_DIR)) - task.add_done_callback(_task_error_cb) + _track_pipeline_task(task, job) return {"job_id": job.id} async def _create_local_job(request: Request) -> dict[str, str]: # Fast pre-check: if already at capacity, reject before touching disk. # The real atomic check happens in register_if_capacity after the upload. - if sum(1 for j in registry_all_jobs().values() if j.status == "queued") >= MAX_PENDING_JOBS: + if sum(1 for j in registry_all_jobs().values() if j.status in ACTIVE_JOB_STATUSES) >= MAX_PENDING_JOBS: raise HTTPException(status_code=503, detail="Server busy, please try again later") # Quick pre-check on Content-Length to fail fast for obviously oversized @@ -168,6 +249,9 @@ async def _create_local_job(request: Request) -> dict[str, str]: form = await request.form() upload = form.get("file") stems_raw = form.get("stems", "[]") + quality_preset = normalize_quality_preset(str(form.get("quality_preset", QUALITY_PRESET))) + stem_denoise_preset = normalize_stem_denoise_preset(str(form.get("stem_denoise", "off"))) + demucs_device, demucs_device_resolved = _device_choice_or_422(str(form.get("demucs_device", "auto"))) if upload is None or not hasattr(upload, "filename"): raise HTTPException(status_code=422, detail="No file provided") @@ -177,7 +261,7 @@ async def _create_local_job(request: Request) -> dict[str, str]: if ext not in _ALLOWED_EXTS: raise HTTPException( status_code=422, - detail=f"Unsupported file type '{ext}': only .mp3, .wav, and .flac are accepted", + detail=f"Unsupported file type '{ext}': only .mp3, .wav, .flac, and .m4a are accepted", ) # Validate stems list from form field @@ -187,7 +271,7 @@ async def _create_local_job(request: Request) -> dict[str, str]: raise ValueError except (json.JSONDecodeError, ValueError): stems_list = [] - selected = [s for s in stems_list if s in STEM_NAMES] or list(STEM_NAMES) + selected = _selected_stems_for_quality(stems_list, quality_preset) # Check actual file size (SpooledTemporaryFile is already buffered at this # point; seek/tell are fast and don't re-read the body). @@ -223,12 +307,20 @@ async def _create_local_job(request: Request) -> dict[str, str]: except HTTPException: shutil.rmtree(job_dir, ignore_errors=True) raise + except Exception as exc: + shutil.rmtree(job_dir, ignore_errors=True) + logger.exception("failed to store uploaded audio") + raise HTTPException(status_code=500, detail="Could not store uploaded file") from exc title = _sanitize_title(filename) local_source_url = f"local:{title}" job = Job( id=job_id, selected_stems=selected, + quality_preset=quality_preset, + stem_denoise_preset=stem_denoise_preset, + demucs_device=demucs_device, + demucs_device_resolved=demucs_device_resolved, title=title, duration_sec=duration, source_url=local_source_url, @@ -236,8 +328,9 @@ async def _create_local_job(request: Request) -> dict[str, str]: if not registry_register_if_capacity(job, MAX_PENDING_JOBS): shutil.rmtree(job_dir, ignore_errors=True) raise HTTPException(status_code=503, detail="Server busy, please try again later") + add_job_log(job, "Local audio job accepted", stage="queued", progress=0.0) task = asyncio.create_task(run_local_pipeline(job, source_path, JOBS_DIR)) - task.add_done_callback(_task_error_cb) + _track_pipeline_task(task, job) return {"job_id": job.id} @@ -251,6 +344,17 @@ def list_jobs() -> list[dict]: ] +@router.get("/active") +def list_active_jobs() -> list[dict]: + """List queued and running jobs, sorted by creation time.""" + registry_refresh_queue_positions() + return [ + job.to_state() + for job in sorted(registry_all_jobs().values(), key=lambda j: j.created_at) + if job.status in ACTIVE_JOB_STATUSES + ] + + @router.get("/{job_id}") def get_job(job_id: str) -> dict: """Get the current state of a job by ID.""" @@ -269,14 +373,19 @@ def cancel_job(job_id: str) -> dict: if job.status in ("done", "error", "cancelled"): return job.to_state() job.cancel_requested = True + add_job_log(job, "Cancellation requested", level="warning", stage=job.status) proc = registry_get_proc(job_id) if proc is not None and proc.poll() is None: - proc.terminate() + terminate_process(proc) + elif job.status == "queued": + _set(job, status="cancelled", stage="Cancelled") + registry_refresh_queue_positions() + registry_persist(JOBS_DIR) return job.to_state() _SECTION_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$") -_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$") +_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$") class SectionItem(BaseModel): @@ -316,6 +425,24 @@ def _check_time(cls, v: float) -> float: class SectionsBody(BaseModel): sections: list[SectionItem] + @field_validator("sections") + @classmethod + def _check_count(cls, value: list[SectionItem]) -> list[SectionItem]: + if len(value) > 200: + raise ValueError("too many sections") + return value + + @model_validator(mode="after") + def _check_ranges(self): + ids = set() + for section in self.sections: + if section.end <= section.start: + raise ValueError("section end must be after start") + if section.id in ids: + raise ValueError("duplicate section id") + ids.add(section.id) + return self + @router.patch("/{job_id}/sections") def update_sections(job_id: str, body: SectionsBody) -> dict: @@ -325,10 +452,10 @@ def update_sections(job_id: str, body: SectionsBody) -> dict: job = registry_get(job_id) if job is None: raise HTTPException(status_code=404, detail="job not found") + if job.status != "done": + raise HTTPException(status_code=409, detail="job is not ready") validated = [s.model_dump() for s in body.sections] - job.sections = validated - job_dir = (JOBS_DIR / job_id).resolve() if not job_dir.is_relative_to(JOBS_DIR.resolve()): raise HTTPException(status_code=404, detail="job not found") @@ -342,11 +469,12 @@ def update_sections(job_id: str, body: SectionsBody) -> dict: pass meta["sections"] = validated try: - meta_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8") + atomic_write_text(meta_path, json.dumps(meta, indent=2) + "\n") except OSError as exc: logger.exception("failed to write sections for %s: %s", job_id, exc) raise HTTPException(status_code=500, detail="failed to save sections") from exc + job.sections = validated registry_persist(JOBS_DIR) return {"job_id": job_id, "sections": validated} diff --git a/app/api/logs.py b/app/api/logs.py new file mode 100644 index 0000000..08b50ae --- /dev/null +++ b/app/api/logs.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Query + +from app.core.config import JOB_ID_RE +from app.core.joblog import job_logs, system_logs +from app.core.registry import all_jobs as registry_all_jobs +from app.core.registry import get as registry_get + +router = APIRouter(tags=["logs"]) + + +@router.get("/logs") +def get_logs( + job_id: str | None = Query(default=None), + after: int = Query(default=0, ge=0), + limit: int = Query(default=200, ge=1, le=500), +) -> dict: + if job_id is not None: + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + entries = job_logs(job, after=after, limit=limit) + else: + entries = system_logs(after=after, limit=limit) + jobs = [ + { + "job_id": job.id, + "title": job.title or job.source_url or job.id, + "status": job.status, + "created_at": job.created_at, + } + for job in sorted( + registry_all_jobs().values(), + key=lambda item: item.created_at, + reverse=True, + ) + if job.logs + ][:100] + return {"entries": entries, "jobs": jobs} diff --git a/app/api/router.py b/app/api/router.py index de9c3c8..33ea453 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -5,10 +5,12 @@ from app.api.config import router as config_router from app.api.events import router as events_router from app.api.jobs import router as jobs_router +from app.api.logs import router as logs_router from app.api.stems import router as stems_router router = APIRouter() router.include_router(config_router, tags=["config"]) +router.include_router(logs_router, tags=["logs"]) router.include_router(jobs_router, prefix="/jobs", tags=["jobs"]) router.include_router(events_router, tags=["events"]) router.include_router(stems_router, tags=["stems"]) diff --git a/app/api/stems.py b/app/api/stems.py index 964d108..821e160 100644 --- a/app/api/stems.py +++ b/app/api/stems.py @@ -13,8 +13,21 @@ from fastapi.responses import FileResponse, Response, StreamingResponse from starlette.background import BackgroundTask -from app.core.config import JOB_ID_RE, JOBS_DIR, STEM_NAMES, TIMEOUT_FFMPEG, ffmpeg_executable +from app.core.config import ( + JOB_ID_RE, + JOBS_DIR, + STEM_NAMES, + TIMEOUT_FFMPEG, + ffmpeg_executable, + wav_codec_for_quality_preset, +) from app.core.registry import get as registry_get +from app.pipeline.chords import ( + chord_segments_from_metadata, + chord_segments_to_csv, + prepare_chord_midi_segments, + write_chord_midi, +) logger = logging.getLogger("stemdeck.api") @@ -40,10 +53,15 @@ "mp3": ["-q:a", "2"], "flac": ["-c:a", "flac"], } -MIXDOWN_CODECS = {ext: [*args, "-f", ext] for ext, args in _ENCODE_ARGS.items()} MIXDOWN_MEDIA_TYPES = {"wav": "audio/wav", "mp3": "audio/mpeg", "flac": "audio/flac"} +def _mixdown_codec_args(ext: str, job_quality_preset: str | None) -> list[str]: + if ext == "wav": + return ["-c:a", wav_codec_for_quality_preset(job_quality_preset), "-f", "wav"] + return [*_ENCODE_ARGS[ext], "-f", ext] + + def _validate_stem_path(job_id: str, name: str): """Shared guard: validate job_id, name, job state, and path. Returns resolved Path.""" if not JOB_ID_RE.match(job_id): @@ -96,6 +114,88 @@ async def get_stem_peaks(job_id: str) -> Response: ) +def _chord_export_segments(job, style: str, grid: str): + segments = chord_segments_from_metadata(job.chord_progression) + if not segments: + raise HTTPException(status_code=404, detail="chord metadata not found") + prepared = prepare_chord_midi_segments(segments, style=style, grid=grid) + if not prepared: + raise HTTPException(status_code=404, detail="chord metadata not found") + return prepared + + +def _chord_variant_suffix(style: str, grid: str, markers: bool = False) -> str: + parts = [] + if style != "auto": + parts.append(style) + if grid != "beat": + parts.append(grid) + if markers: + parts.append("markers") + return ("_" + "_".join(parts)) if parts else "" + + +@router.get("/jobs/{job_id}/chords.mid") +async def get_chord_midi( + job_id: str, + style: str = Query(default="auto", description="auto, triads, or sevenths"), + grid: str = Query(default="beat", description="beat or bar"), + markers: bool = Query(default=False, description="Write chord labels as MIDI marker events"), +) -> FileResponse: + """Download the estimated beat-grid chord progression as a Standard MIDI file.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_get(job_id) + if job is None or job.status != "done": + raise HTTPException(status_code=404, detail="job not ready") + normalized_style = style if style in ("auto", "triads", "sevenths") else "auto" + normalized_grid = grid if grid in ("beat", "bar") else "beat" + path = (JOBS_DIR / job_id / "stems" / "chords.mid").resolve() + default_export = normalized_style == "auto" and normalized_grid == "beat" and not markers + if default_export and path.is_file() and path.is_relative_to(JOBS_DIR.resolve()): + return FileResponse( + path, + media_type="audio/midi", + filename=f"{_download_base(job)}_chords.mid", + ) + segments = _chord_export_segments(job, normalized_style, normalized_grid) + fd, tmp = tempfile.mkstemp(prefix="layerlab_chords_", suffix=".mid") + os.close(fd) + tmp_path = Path(tmp) + write_chord_midi(tmp_path, segments, bpm=job.bpm, title=job.title, markers=markers) + suffix = _chord_variant_suffix(normalized_style, normalized_grid, markers) + return FileResponse( + tmp_path, + media_type="audio/midi", + filename=f"{_download_base(job)}_chords{suffix}.mid", + background=BackgroundTask(lambda: tmp_path.unlink(missing_ok=True)), + ) + + +@router.get("/jobs/{job_id}/chords.csv") +async def get_chord_csv( + job_id: str, + style: str = Query(default="auto", description="auto, triads, or sevenths"), + grid: str = Query(default="beat", description="beat or bar"), +) -> Response: + """Download the estimated chord progression as a DAW/spreadsheet-friendly CSV.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_get(job_id) + if job is None or job.status != "done": + raise HTTPException(status_code=404, detail="job not ready") + normalized_style = style if style in ("auto", "triads", "sevenths") else "auto" + normalized_grid = grid if grid in ("beat", "bar") else "beat" + segments = _chord_export_segments(job, normalized_style, normalized_grid) + suffix = _chord_variant_suffix(normalized_style, normalized_grid) + filename = f"{_download_base(job)}_chords{suffix}.csv" + return Response( + chord_segments_to_csv(segments), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @router.api_route("/jobs/{job_id}/stems/{name}.wav", methods=["GET", "HEAD"], response_model=None) async def get_stem( job_id: str, @@ -105,9 +205,12 @@ async def get_stem( ) -> FileResponse | StreamingResponse: """Download a WAV stem. Optional ?start=&end= trims to a time region.""" path = _validate_stem_path(job_id, name) + job = registry_get(job_id) + if job is None or job.status != "done": + raise HTTPException(status_code=404, detail="job not ready") if start is None and end is None: - return FileResponse(path, media_type="audio/wav", filename=f"{name}.wav") + return FileResponse(path, media_type="audio/wav", filename=_stem_download_filename(job, name, "wav")) if start is None or end is None or start >= end: raise HTTPException( @@ -127,7 +230,7 @@ async def get_stem( "-t", str(end - start), "-c:a", - "pcm_s16le", + wav_codec_for_quality_preset(job.quality_preset), "-f", "wav", "pipe:1", @@ -135,7 +238,7 @@ async def get_stem( return StreamingResponse( _stream_ffmpeg(cmd), media_type="audio/wav", - headers={"Content-Disposition": f'attachment; filename="{name}_region.wav"'}, + headers={"Content-Disposition": f'attachment; filename="{_stem_download_filename(job, name, "wav", region=True)}"'}, ) @@ -148,6 +251,9 @@ async def get_stem_mp3( ) -> StreamingResponse: """Stream a stem as MP3 (VBR ~190 kbps). Optional ?start=&end= trims to a time region.""" path = _validate_stem_path(job_id, name) + job = registry_get(job_id) + if job is None or job.status != "done": + raise HTTPException(status_code=404, detail="job not ready") if (start is None) != (end is None) or (start is not None and start >= end): raise HTTPException( @@ -173,11 +279,10 @@ async def get_stem_mp3( "mp3", "pipe:1", ] - filename = f"{name}_region.mp3" if start is not None else f"{name}.mp3" return StreamingResponse( _stream_ffmpeg(cmd), media_type="audio/mpeg", - headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + headers={"Content-Disposition": f'attachment; filename="{_stem_download_filename(job, name, "mp3", region=start is not None)}"'}, ) @@ -218,6 +323,10 @@ async def get_mixdown( detail="start and end are both required and start must be less than end", ) + job = registry_get(job_id) + if job is None or job.status != "done": + raise HTTPException(status_code=404, detail="job not ready") + # Validates job_id (404), job done (404), and path traversal (404) per stem. paths = [_validate_stem_path(job_id, name) for name in names] @@ -237,14 +346,14 @@ async def get_mixdown( out_label = "[mix]" else: out_label = "[a0]" - codec = MIXDOWN_CODECS[ext] + codec = _mixdown_codec_args(ext, job.quality_preset) cmd += ["-filter_complex", ";".join(filters), "-map", out_label, *post_seek, *codec, "pipe:1"] media_type = MIXDOWN_MEDIA_TYPES[ext] return StreamingResponse( _stream_ffmpeg(cmd), media_type=media_type, - headers={"Content-Disposition": f'attachment; filename="mixdown.{ext}"'}, + headers={"Content-Disposition": f'attachment; filename="{_mixdown_filename(job, ext, region=start is not None)}"'}, ) @@ -255,7 +364,56 @@ def _safe_title(title: str | None) -> str: return safe or "stems" -def _build_stems_zip(sources: list[tuple[str, Path]], fmt: str, dest: Path) -> None: +def _safe_profile(job) -> str: + safe = re.sub(r"[^a-zA-Z0-9]+", "_", job.profile_label()) + safe = re.sub(r"_{2,}", "_", safe).strip("_")[:64].strip("_") + return safe + + +def _download_base(job) -> str: + title = _safe_title(job.title) + profile = _safe_profile(job) + return f"{title}_{profile}" if profile else title + + +def _stem_download_filename(job, name: str, ext: str, region: bool = False) -> str: + suffix = "_region" if region else "" + return f"{_download_base(job)}_{name}{suffix}.{ext}" + + +def _mixdown_filename(job, ext: str, region: bool = False) -> str: + suffix = "region" if region else "mix" + return f"{_download_base(job)}_{suffix}.{ext}" + + +def _stems_zip_filename(job) -> str: + return f"{_download_base(job)}_stems.zip" + + +def _profile_manifest(job, stems: list[str], fmt: str) -> str: + return "\n".join( + [ + "LayerLab extraction profile", + f"Title: {job.title or 'Untitled'}", + f"Job ID: {job.id}", + f"Profile: {job.profile_label()}", + f"Quality: {job.quality_preset}", + f"Clean: {job.stem_denoise_preset}", + "Stem gate: " + + ( + f"on ({job.stem_gate_threshold_db:g} dB)" + if job.stem_gate_applied and job.stem_gate_threshold_db is not None + else ("on" if job.stem_gate_applied else "off") + ), + f"Selected stems: {', '.join(job.profile_stems())}", + f"Exported stems: {', '.join(stems)}", + f"Format: {fmt}", + "", + ] + ) + + +def _build_stems_zip(sources: list[tuple[str, Path]], fmt: str, dest: Path, manifest: str) -> None: """Blocking: write the stems into a ZIP. WAV files are stored as-is; MP3 and FLAC are transcoded per stem via ffmpeg. ZIP_STORED throughout - audio doesn't meaningfully compress, and STORED keeps the build fast. Runs in a thread.""" @@ -263,6 +421,7 @@ def _build_stems_zip(sources: list[tuple[str, Path]], fmt: str, dest: Path) -> N with zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf: for name, p in sources: zf.write(p, arcname=f"{name}.wav") + zf.writestr("LAYERLAB_PROFILE.txt", manifest) return encode = _ENCODE_ARGS[fmt] with tempfile.TemporaryDirectory() as td, zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf: @@ -291,6 +450,7 @@ def _build_stems_zip(sources: list[tuple[str, Path]], fmt: str, dest: Path) -> N tail = proc.stderr[-2000:].decode("utf-8", "replace") raise RuntimeError(f"ffmpeg failed for {name}: {tail}") zf.write(out, arcname=f"{name}.{fmt}") + zf.writestr("LAYERLAB_PROFILE.txt", manifest) @router.get("/jobs/{job_id}/stems/all.zip") @@ -336,17 +496,17 @@ async def get_all_stems_zip( fd, tmp = tempfile.mkstemp(prefix="stemdeck_zip_", suffix=".zip") os.close(fd) tmp_path = Path(tmp) + manifest = _profile_manifest(job, [name for name, _ in sources], fmt) try: - await asyncio.to_thread(_build_stems_zip, sources, fmt, tmp_path) + await asyncio.to_thread(_build_stems_zip, sources, fmt, tmp_path, manifest) except Exception: tmp_path.unlink(missing_ok=True) logger.exception("failed to build stems zip for job %s", job_id) raise HTTPException(status_code=500, detail="failed to build archive") from None - filename = f"{_safe_title(job.title)}_stems.zip" return FileResponse( tmp_path, media_type="application/zip", - filename=filename, + filename=_stems_zip_filename(job), background=BackgroundTask(lambda: tmp_path.unlink(missing_ok=True)), ) diff --git a/app/core/config.py b/app/core/config.py index 1e2481b..340043a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,6 +1,8 @@ import os import re +import shutil import sys +from dataclasses import dataclass from pathlib import Path @@ -12,6 +14,32 @@ def _env_int(name: str, default: int) -> int: return default +def _env_float(name: str, default: float) -> float: + raw = os.environ.get(name, "").strip() + try: + return float(raw) if raw else default + except ValueError: + return default + + +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.environ.get(name, "").strip().lower() + if not raw: + return default + if raw in ("1", "true", "yes", "on"): + return True + if raw in ("0", "false", "no", "off"): + return False + return default + + +def _env_choice(name: str, default: str | None, choices: set[str]) -> str | None: + raw = os.environ.get(name, "").strip().lower() + if not raw: + return default + return raw if raw in choices else default + + def _env_path(name: str, default: Path) -> Path: raw = os.environ.get(name, "").strip() return Path(raw).expanduser().resolve() if raw else default @@ -38,13 +66,266 @@ def _detect_device() -> str: return "cpu" +def _system_memory_gb() -> float | None: + try: + page_size = os.sysconf("SC_PAGE_SIZE") + page_count = os.sysconf("SC_PHYS_PAGES") + except (AttributeError, OSError, ValueError): + return None + return (page_size * page_count) / (1024**3) + + +def _detect_pipeline_concurrency(device: str) -> int: + """Choose a safe local pipeline parallelism level. + + Demucs is the dominant memory/compute consumer. GPU backends are kept at + one job by default because concurrent model runs can exhaust unified/VRAM + memory quickly; roomy CPU-only machines can run two jobs in parallel. + """ + raw = os.environ.get("STEMDECK_PIPELINE_CONCURRENCY", "").strip().lower() + if raw and raw != "auto": + try: + return max(1, min(4, int(raw))) + except ValueError: + pass + + if device in {"cuda", "mps"}: + return 1 + + cpu_count = os.cpu_count() or 1 + memory_gb = _system_memory_gb() + if cpu_count >= 12 and (memory_gb is None or memory_gb >= 24): + return 2 + return 1 + + +def _detect_background_cpu_threads() -> int: + """Keep background extraction from starving the UI event loop/WebView. + + This only changes CPU scheduling/worker count. It does not alter Demucs + model weights or audio math, so output quality is unchanged. + """ + raw = os.environ.get("STEMDECK_BACKGROUND_CPU_THREADS", "").strip().lower() + if raw and raw != "auto": + try: + return max(1, min(16, int(raw))) + except ValueError: + pass + cpu_count = os.cpu_count() or 1 + return max(1, min(4, cpu_count // 2 or 1)) + + ROOT = Path(__file__).resolve().parent.parent.parent STATIC_DIR = ROOT / "static" STEM_NAMES: tuple[str, ...] = ("vocals", "drums", "bass", "guitar", "piano", "other") JOB_ID_RE = re.compile(r"^[a-f0-9]{12}$") +SUPPORTED_QUALITY_PRESETS = frozenset(("standard", "high", "max", "ultra")) +SUPPORTED_STEM_DENOISE_PRESETS = frozenset(("off", "light", "strong")) +SUPPORTED_DEMUCS_DEVICE_CHOICES = frozenset(("auto", "cpu", "mps", "cuda")) + + +@dataclass(frozen=True) +class DemucsSettings: + quality_preset: str + model: str + shifts: int + pre_gain_db: float + float32: bool + clip_mode: str | None + overlap: float + segment: float + + +def normalize_quality_preset(value: str | None) -> str: + preset = (value or "").strip().lower() + return preset if preset in SUPPORTED_QUALITY_PRESETS else "standard" + + +def normalize_stem_denoise_preset(value: str | None) -> str: + preset = (value or "").strip().lower() + return preset if preset in SUPPORTED_STEM_DENOISE_PRESETS else "off" + + +def normalize_demucs_device_choice(value: str | None) -> str: + choice = (value or "").strip().lower() + return choice if choice in SUPPORTED_DEMUCS_DEVICE_CHOICES else "auto" + + +def available_demucs_devices() -> tuple[str, ...]: + """Return Torch devices this backend can actually run right now.""" + devices: list[str] = ["cpu"] + try: + import torch + + if torch.cuda.is_available(): + devices.append("cuda") + if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available(): + devices.append("mps") + except ImportError: + pass + return tuple(devices) + + +def demucs_device_choice_available(value: str | None) -> bool: + choice = normalize_demucs_device_choice(value) + return choice == "auto" or choice in available_demucs_devices() + + +def resolve_demucs_device_choice(value: str | None) -> str: + choice = normalize_demucs_device_choice(value) + if choice == "auto": + return globals().get("DEMUCS_DEVICE") or _detect_device() + return choice + + +def stem_names_for_quality_preset(preset: str | None) -> tuple[str, ...]: + quality_preset = normalize_quality_preset(preset) + if quality_preset in ("high", "max", "ultra"): + return ("vocals", "drums", "bass", "other") + return STEM_NAMES + + +QUALITY_PRESET = _env_choice( + "STEMDECK_QUALITY_PRESET", "standard", set(SUPPORTED_QUALITY_PRESETS) +) or "standard" +_QUALITY_DEFAULTS = { + "standard": { + "model": "htdemucs_6s", + "shifts": 0, + "pre_gain_db": 0.0, + "float32": False, + "clip_mode": None, + "overlap": 0.0, + "segment": 0.0, + }, + # Slower, cleaner 4-stem separation. htdemucs_ft is Demucs' fine-tuned + # model; shifts averages repeated runs and helps reduce random artifacts. + "high": { + "model": "htdemucs_ft", + "shifts": 4, + "pre_gain_db": -6.0, + "float32": True, + "clip_mode": "rescale", + "overlap": 0.0, + "segment": 0.0, + }, + "max": { + "model": "htdemucs_ft", + "shifts": 10, + "pre_gain_db": -6.0, + "float32": True, + "clip_mode": "rescale", + "overlap": 0.0, + "segment": 0.0, + }, + # Slowest local preset. Extra shift averaging and overlap can reduce + # random separation artifacts and segment-boundary roughness at the cost + # of noticeably longer extraction time. + "ultra": { + "model": "htdemucs_ft", + "shifts": 16, + "pre_gain_db": -8.0, + "float32": True, + "clip_mode": "rescale", + "overlap": 0.5, + "segment": 0.0, + }, +} + + +def demucs_settings_for_preset(preset: str | None) -> DemucsSettings: + quality_preset = normalize_quality_preset(preset) + quality = _QUALITY_DEFAULTS[quality_preset] + model = os.environ.get("STEMDECK_DEMUCS_MODEL", str(quality["model"])).strip() or str( + quality["model"] + ) + return DemucsSettings( + quality_preset=quality_preset, + model=model, + shifts=max(0, _env_int("STEMDECK_DEMUCS_SHIFTS", int(quality["shifts"]))), + pre_gain_db=_env_float("STEMDECK_DEMUCS_PRE_GAIN_DB", float(quality["pre_gain_db"])), + float32=_env_bool("STEMDECK_DEMUCS_FLOAT32", bool(quality["float32"])), + clip_mode=_env_choice( + "STEMDECK_DEMUCS_CLIP_MODE", quality["clip_mode"], {"rescale", "clamp", "none"} + ), + overlap=max(0.0, _env_float("STEMDECK_DEMUCS_OVERLAP", float(quality["overlap"]))), + segment=max(0.0, _env_float("STEMDECK_DEMUCS_SEGMENT", float(quality["segment"]))), + ) + + +def wav_codec_for_quality_preset(preset: str | None) -> str: + settings = demucs_settings_for_preset(preset) + return "pcm_f32le" if settings.float32 else "pcm_s16le" + + +_STEM_DENOISE_FILTERS = { + # Conservative broadband denoise. Safe for most stems, less likely to + # introduce watery FFT artifacts than heavy reduction. + "light": "afftdn=nr=8:nf=-55:rf=-45:tn=1:gs=8", + # Stronger cleanup for obviously noisy material; users can opt in when the + # artifact tradeoff is acceptable. + "strong": "afftdn=nr=14:nf=-50:rf=-38:tn=1:gs=12", +} + + +def stem_denoise_filter_for_preset(value: str | None) -> str | None: + return _STEM_DENOISE_FILTERS.get(normalize_stem_denoise_preset(value)) + + +def bass_repair_enabled_for_preset(preset: str | None) -> bool: + """Enable conservative bass dropout repair for quality-first presets. + + The env var is intentionally global so packaged builds can force the + behavior without changing per-job API shape. + """ + default = normalize_quality_preset(preset) in ("high", "max", "ultra") + return _env_bool("STEMDECK_BASS_REPAIR", default) + + +def phase_repair_enabled_for_preset(preset: str | None) -> bool: + """Enable stem-sum residual repair for quality-first presets. + + Demucs can leave tiny phase/residual errors between the sum of the stems + and the original mix. The repair pass is slower, so keep it on the + quality-first path unless explicitly overridden. + """ + default = normalize_quality_preset(preset) in ("high", "max", "ultra") + return _env_bool("STEMDECK_PHASE_REPAIR", default) + + +_PHASE_REPAIR_DEFAULT_MAX_BLEND = { + "standard": 0.42, + "high": 0.65, + "max": 0.90, + "ultra": 0.95, +} + + +def _clamp_phase_repair_blend(value: float) -> float: + return min(1.0, max(0.0, value)) + + +def phase_repair_max_blend_for_preset(preset: str | None) -> float: + """Return residual blend strength for stem-sum coherence repair. + + High stays moderately conservative to protect isolation. Max prioritizes + source reconstruction and can leave more bleed in isolated stems. + """ + quality_preset = normalize_quality_preset(preset) + default = _PHASE_REPAIR_DEFAULT_MAX_BLEND[quality_preset] + return _clamp_phase_repair_blend(_env_float("STEMDECK_PHASE_REPAIR_MAX_BLEND", default)) + + +def stem_gate_enabled_for_preset(_preset: str | None) -> bool: + """Mute only near-silent stem bleed while preserving timeline alignment.""" + return _env_bool("STEMDECK_STEM_GATE", True) + + +_demucs_settings = demucs_settings_for_preset(QUALITY_PRESET) + # Runtime knobs -- env-backed so Docker / desktop packaging / local dev can -# tune without a code edit. STEMDECK_DATA_DIR is the portable app root for +# tune without a code edit. STEMDECK_DATA_DIR is the legacy portable app root for # mutable runtime data; when unset, dev behavior remains the repo-local jobs/ # folder. PORTABLE_DATA_DIR_ENABLED = bool(os.environ.get("STEMDECK_DATA_DIR", "").strip()) @@ -66,8 +347,37 @@ def _detect_device() -> str: "STEMDECK_FFPROBE", FFMPEG_DIR / ("ffprobe.exe" if sys.platform.startswith("win") else "ffprobe"), ) -DEMUCS_MODEL = os.environ.get("STEMDECK_DEMUCS_MODEL", "htdemucs_6s").strip() or "htdemucs_6s" +DEMUCS_MODEL = _demucs_settings.model DEMUCS_DEVICE = _detect_device() +PIPELINE_CONCURRENCY = _detect_pipeline_concurrency(DEMUCS_DEVICE) +BACKGROUND_PROCESS_PRIORITY = _env_bool("STEMDECK_BACKGROUND_PROCESS_PRIORITY", True) +BACKGROUND_PROCESS_NICE = max(0, min(19, _env_int("STEMDECK_BACKGROUND_PROCESS_NICE", 8))) +BACKGROUND_CPU_THREADS = _detect_background_cpu_threads() +DEMUCS_SHIFTS = _demucs_settings.shifts +DEMUCS_PRE_GAIN_DB = _demucs_settings.pre_gain_db +DEMUCS_FLOAT32 = _demucs_settings.float32 +DEMUCS_CLIP_MODE = _demucs_settings.clip_mode +DEMUCS_OVERLAP = _demucs_settings.overlap +DEMUCS_SEGMENT = _demucs_settings.segment +BASS_REPAIR_LOW_PASS_HZ = max(40.0, _env_float("STEMDECK_BASS_REPAIR_LOW_PASS_HZ", 180.0)) +BASS_REPAIR_TRIGGER_RATIO = max(1.05, _env_float("STEMDECK_BASS_REPAIR_TRIGGER_RATIO", 1.9)) +BASS_REPAIR_MAX_BLEND = min(1.0, max(0.0, _env_float("STEMDECK_BASS_REPAIR_MAX_BLEND", 0.65))) +BASS_REPAIR_SHORT_GAP_MS = max(8, _env_int("STEMDECK_BASS_REPAIR_SHORT_GAP_MS", 220)) +BASS_REPAIR_SHORT_GAP_RATIO = max( + 1.05, _env_float("STEMDECK_BASS_REPAIR_SHORT_GAP_RATIO", 2.4) +) +PHASE_REPAIR_MAX_BLEND = phase_repair_max_blend_for_preset(QUALITY_PRESET) +PHASE_REPAIR_FLOOR_DB = min(-24.0, max(-96.0, _env_float("STEMDECK_PHASE_REPAIR_FLOOR_DB", -58.0))) +STEM_POST_LIMITER_PEAK = min(0.999, max(0.5, _env_float("STEMDECK_STEM_POST_LIMITER_PEAK", 0.98))) +STEM_GATE_THRESHOLD_DB = min(-24.0, max(-96.0, _env_float("STEMDECK_STEM_GATE_THRESHOLD_DB", -54.0))) +STEM_GATE_WINDOW_MS = max(5, _env_int("STEMDECK_STEM_GATE_WINDOW_MS", 20)) +STEM_GATE_HOLD_MS = max(0, _env_int("STEMDECK_STEM_GATE_HOLD_MS", 90)) +STEM_GATE_ATTACK_MS = max(1, _env_int("STEMDECK_STEM_GATE_ATTACK_MS", 8)) +STEM_GATE_RELEASE_MS = max(1, _env_int("STEMDECK_STEM_GATE_RELEASE_MS", 85)) +STEM_PREPROCESS_TARGET_I = _env_float("STEMDECK_PREPROCESS_TARGET_I", -18.0) +STEM_PREPROCESS_TRUE_PEAK = min( + -0.1, max(-6.0, _env_float("STEMDECK_PREPROCESS_TRUE_PEAK", -1.5)) +) MAX_DURATION_SEC = max(60, _env_int("STEMDECK_MAX_DURATION_SEC", 1200)) # 20 min default JOB_TTL_SECONDS = max(300, _env_int("STEMDECK_JOB_TTL_SECONDS", 24 * 3600)) # 24 h default MAX_PENDING_JOBS = max(1, min(50, _env_int("STEMDECK_MAX_PENDING_JOBS", 3))) @@ -76,6 +386,16 @@ def _detect_device() -> str: TIMEOUT_DEMUCS_STALL = _env_int("STEMDECK_TIMEOUT_DEMUCS_STALL", 1800) +def _imageio_ffmpeg_executable() -> str | None: + try: + import imageio_ffmpeg + + path = Path(imageio_ffmpeg.get_ffmpeg_exe()) + except Exception: + return None + return str(path) if path.is_file() else None + + def ffmpeg_executable() -> str: """Return the preferred FFmpeg executable. @@ -83,7 +403,17 @@ def ffmpeg_executable() -> str: binary when present; otherwise fall back to PATH so local dev and Docker keep working exactly as before. """ - return str(FFMPEG_BIN) if FFMPEG_BIN.is_file() else "ffmpeg" + if FFMPEG_BIN.is_file(): + return str(FFMPEG_BIN) + if path := shutil.which("ffmpeg"): + return path + if path := _imageio_ffmpeg_executable(): + return path + return "ffmpeg" + + +def ffmpeg_available() -> bool: + return Path(ffmpeg_executable()).is_file() or shutil.which(ffmpeg_executable()) is not None def ffprobe_executable() -> str: diff --git a/app/core/files.py b/app/core/files.py new file mode 100644 index 0000000..84138e4 --- /dev/null +++ b/app/core/files.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import os +import threading +from pathlib import Path + + +def atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp") + try: + with tmp.open("w", encoding=encoding) as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + tmp.replace(path) + finally: + tmp.unlink(missing_ok=True) diff --git a/app/core/joblog.py b/app/core/joblog.py new file mode 100644 index 0000000..806e505 --- /dev/null +++ b/app/core/joblog.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import itertools +import threading +import time +from collections import deque +from typing import Any + +from app.core.models import Job + +_MAX_JOB_ENTRIES = 300 +_MAX_SYSTEM_ENTRIES = 1000 +_counter = itertools.count(1) +_lock = threading.Lock() +_system_entries: deque[dict[str, Any]] = deque(maxlen=_MAX_SYSTEM_ENTRIES) + + +def _clean_message(message: object) -> str: + return " ".join(str(message or "").split())[:600] + + +def add_job_log( + job: Job, + message: object, + *, + level: str = "info", + stage: str | None = None, + progress: float | int | None = None, +) -> dict[str, Any] | None: + text = _clean_message(message) + if not text: + return None + normalized_level = level if level in {"debug", "info", "warning", "error"} else "info" + try: + progress_percent = round(max(0.0, min(1.0, float(progress))) * 100) + except (TypeError, ValueError): + progress_percent = None + + with _lock: + previous = job.logs[-1] if job.logs else None + if ( + previous + and previous.get("message") == text + and previous.get("level") == normalized_level + and previous.get("stage") == (_clean_message(stage) or None) + and previous.get("progress_percent") == progress_percent + ): + return previous + entry = { + "id": next(_counter), + "timestamp": time.time(), + "job_id": job.id, + "level": normalized_level, + "message": text, + "stage": _clean_message(stage) or None, + "progress_percent": progress_percent, + } + job.logs.append(entry) + if len(job.logs) > _MAX_JOB_ENTRIES: + del job.logs[:-_MAX_JOB_ENTRIES] + _system_entries.append(entry) + return entry + + +def add_system_log(message: object, *, level: str = "info") -> dict[str, Any] | None: + text = _clean_message(message) + if not text: + return None + normalized_level = level if level in {"debug", "info", "warning", "error"} else "info" + with _lock: + entry = { + "id": next(_counter), + "timestamp": time.time(), + "job_id": None, + "level": normalized_level, + "message": text, + "stage": None, + "progress_percent": None, + } + _system_entries.append(entry) + return entry + + +def job_logs(job: Job, *, after: int = 0, limit: int = 200) -> list[dict[str, Any]]: + safe_limit = max(1, min(500, int(limit))) + with _lock: + entries = list(job.logs) + return [entry for entry in entries if _entry_id(entry) > after][-safe_limit:] + + +def system_logs(*, after: int = 0, limit: int = 200) -> list[dict[str, Any]]: + safe_limit = max(1, min(500, int(limit))) + with _lock: + entries = list(_system_entries) + return [entry for entry in entries if _entry_id(entry) > after][-safe_limit:] + + +def _entry_id(entry: dict[str, Any]) -> int: + try: + return int(entry.get("id", 0)) + except (TypeError, ValueError): + return 0 diff --git a/app/core/models.py b/app/core/models.py index a99f520..b3c7a03 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -14,14 +14,62 @@ class JobCancelled(Exception): "queued", "downloading", "analyzing", "separating", "processing", "done", "error", "cancelled" ] +_STEM_ORDER = ("vocals", "drums", "bass", "guitar", "piano", "other") +_STEM_LABELS = { + "vocals": "Vocals", + "drums": "Drums", + "bass": "Bass", + "guitar": "Guitar", + "piano": "Piano", + "other": "Other", +} +_QUALITY_LABELS = { + "standard": "Standard", + "high": "High", + "max": "Max", + "ultra": "Ultra", +} +_DENOISE_LABELS = { + "off": "Noise off", + "light": "Light denoise", + "strong": "Strong denoise", +} +_DEVICE_LABELS = { + "auto": "Auto", + "cpu": "CPU", + "mps": "Apple GPU", + "cuda": "NVIDIA CUDA", +} + def _set(job: Job, **fields: object) -> None: """Mutate Job fields. SSE polling picks up the change automatically.""" + old_status = job.status + old_terminal = old_status in ("done", "error", "cancelled") + now = time.time() for k, v in fields.items(): if k == "stage": job.stage_message = v # type: ignore[assignment] else: setattr(job, k, v) + if "progress" in fields and job.progress_started_at is None: + try: + progress = float(fields["progress"] or 0.0) + except (TypeError, ValueError): + progress = 0.0 + if progress > 0.001 and job.status not in ("queued", "done", "error", "cancelled"): + job.progress_started_at = now + if "status" in fields and job.status != old_status: + job.status_started_at = now + if job.status not in ("queued", "done", "error", "cancelled"): + job.processing_started_at = job.processing_started_at or now + if job.status in ("done", "error", "cancelled") and not old_terminal: + job.completed_at = job.status_started_at + started_at = job.processing_started_at or job.progress_started_at + if started_at is not None: + job.processing_elapsed_seconds = max(0.0, job.completed_at - started_at) + else: + job.processing_elapsed_seconds = 0.0 @dataclass @@ -41,6 +89,9 @@ class Job: peak_db: float | None = None # sample peak in dBFS (close to true peak) dynamic_range: float | None = None # peak_db - integrated LUFS (dB) tempo_stability: int | None = None # 0-100, beat interval consistency + beat_times: list[float] | None = None # detected beat timestamps, seconds + chord_progression: list[dict] | None = None # estimated chord guide segments + chord_midi_url: str | None = None stem_presence: dict[str, int] | None = None # per-stem RMS 0-100 sections: list[dict] | None = None # [{id, name, start, end, color}] tags: list[str] | None = None # YouTube tags + categories, lowercased, max 8 @@ -50,22 +101,126 @@ class Job: # mix down only the selected ones into mix.wav so the user can # download a single track containing just their chosen stems. selected_stems: list[str] = field(default_factory=list) + quality_preset: str = "standard" + stem_denoise_preset: str = "off" + demucs_device: str = "auto" + demucs_device_resolved: str = "" mix_url: str | None = None # populated when a strict subset was selected source_url: str | None = None # original URL or "local:" for file uploads + demucs_gain_db: float | None = None # reversible gain applied to the Demucs working copy + bass_repair_applied: bool = False + phase_repair_applied: bool = False + phase_repair_residual_ratio: float | None = None + stem_denoise_applied: bool = False + stem_gate_applied: bool = False + stem_gate_threshold_db: float | None = None + queue_position: int | None = None # 1-based waiting position while status == queued + queue_size: int = 0 # current number of queued jobs, for UI context error: str | None = None + logs: list[dict[str, Any]] = field(default_factory=list) # Set by POST /api/jobs/{id}/cancel; consumed by pipeline stages. # Not surfaced via to_state() -- it's internal control state. cancel_requested: bool = False # Wall-clock timestamps for metadata-based sweep -- more predictable # than directory mtime, which can be touched by unrelated FS events. created_at: float = field(default_factory=time.time) + status_started_at: float = field(default_factory=time.time) + progress_started_at: float | None = None + processing_started_at: float | None = None + completed_at: float | None = None + processing_elapsed_seconds: float | None = None + + def elapsed_seconds(self) -> float: + return max(0.0, time.time() - self.status_started_at) + + def total_elapsed_seconds(self) -> float: + started_at = self.created_at + ended_at = self.completed_at or time.time() + return max(0.0, ended_at - started_at) + + def processing_seconds(self) -> float: + if self.processing_elapsed_seconds is not None: + return max(0.0, self.processing_elapsed_seconds) + started_at = self.processing_started_at or self.progress_started_at + if started_at is None: + return 0.0 + ended_at = self.completed_at or time.time() + return max(0.0, ended_at - started_at) + + def eta_seconds(self) -> float | None: + if self.status in ("done", "error", "cancelled"): + return None + progress = max(0.0, min(1.0, float(self.progress or 0.0))) + if progress < 0.01 or progress >= 0.995: + return None + elapsed = self.processing_seconds() + if elapsed <= 0.0 and self.processing_started_at is None and self.progress_started_at is None: + elapsed = max(0.0, time.time() - self.status_started_at) + if elapsed < 2.0: + return None + return max(0.0, (elapsed / progress) - elapsed) + + def profile_stems(self) -> tuple[str, ...]: + selected = set(self.selected_stems or []) + if not selected: + selected = { + stem["name"] + for stem in self.stems + if isinstance(stem, dict) and stem.get("name") in _STEM_ORDER + } + ordered = tuple(name for name in _STEM_ORDER if name in selected) + return ordered or _STEM_ORDER + + def profile_key(self) -> str: + stems = ",".join(self.profile_stems()) + quality = (self.quality_preset or "standard").lower() + denoise = (self.stem_denoise_preset or "off").lower() + device = (self.demucs_device or "auto").lower() + resolved = (self.demucs_device_resolved or "auto").lower() + return f"quality={quality}|denoise={denoise}|device={device}:{resolved}|stems={stems}" + + def device_label(self) -> str: + choice = (self.demucs_device or "auto").lower() + resolved = (self.demucs_device_resolved or "").lower() + choice_label = _DEVICE_LABELS.get(choice, choice.upper()) + resolved_label = _DEVICE_LABELS.get(resolved, resolved.upper()) if resolved else "" + if choice == "auto" and resolved_label: + return f"{choice_label} ({resolved_label})" + return choice_label + + def profile_label(self) -> str: + stems = self.profile_stems() + quality = _QUALITY_LABELS.get((self.quality_preset or "standard").lower(), self.quality_preset) + denoise = _DENOISE_LABELS.get( + (self.stem_denoise_preset or "off").lower(), + self.stem_denoise_preset or "Noise off", + ) + if stems == _STEM_ORDER: + stems_label = "All 6-stem" + elif len(stems) == 4 and set(stems) == {"vocals", "drums", "bass", "other"}: + stems_label = "4-stem" + else: + stems_label = "+".join(_STEM_LABELS.get(name, name.title()) for name in stems) + return f"{quality} / {denoise} / {self.device_label()} / {stems_label}" def to_state(self) -> dict[str, Any]: + eta = self.eta_seconds() + total_elapsed = self.total_elapsed_seconds() + processing_elapsed = self.processing_seconds() return { "job_id": self.id, "status": self.status, "progress": self.progress, + "progress_percent": round(max(0.0, min(1.0, float(self.progress or 0.0))) * 100), "stage": self.stage_message, + "elapsed_seconds": round(self.elapsed_seconds(), 1), + "total_elapsed_seconds": round(total_elapsed, 1), + "processing_elapsed_seconds": round(processing_elapsed, 1), + "timer_started_at": self.created_at, + "processing_started_at": self.processing_started_at, + "completed_at": self.completed_at, + "server_time": time.time(), + "eta_seconds": None if eta is None else round(eta, 1), "title": self.title, "duration": self.duration_sec, "thumbnail": self.thumbnail, @@ -77,13 +232,30 @@ def to_state(self) -> dict[str, Any]: "peak_db": self.peak_db, "dynamic_range": self.dynamic_range, "tempo_stability": self.tempo_stability, + "beat_times": self.beat_times, + "chord_progression": self.chord_progression, + "chord_midi_url": self.chord_midi_url, "stem_presence": self.stem_presence, "sections": self.sections, "tags": self.tags, "stems": self.stems, "selected_stems": self.selected_stems, + "quality_preset": self.quality_preset, + "stem_denoise_preset": self.stem_denoise_preset, + "demucs_device": self.demucs_device, + "demucs_device_resolved": self.demucs_device_resolved, + "profile_key": self.profile_key(), + "profile_label": self.profile_label(), "mix_url": self.mix_url, "source_url": self.source_url, + "bass_repair_applied": self.bass_repair_applied, + "phase_repair_applied": self.phase_repair_applied, + "phase_repair_residual_ratio": self.phase_repair_residual_ratio, + "stem_denoise_applied": self.stem_denoise_applied, + "stem_gate_applied": self.stem_gate_applied, + "stem_gate_threshold_db": self.stem_gate_threshold_db, + "queue_position": self.queue_position, + "queue_size": self.queue_size, "error": self.error, "created_at": self.created_at, } @@ -100,8 +272,13 @@ def from_record(cls, data: dict[str, Any]) -> Job: job = cls(id=job_id) for key, value in fields.items(): setattr(job, key, value) + if not isinstance(job.logs, list): + job.logs = [] + else: + job.logs = [entry for entry in job.logs[-300:] if isinstance(entry, dict)] job.cancel_requested = False return job -_JOB_FIELDS = frozenset(f.name for f in dataclasses.fields(Job) if f.name != "cancel_requested") +_TRANSIENT_FIELDS = frozenset(("cancel_requested", "queue_position", "queue_size")) +_JOB_FIELDS = frozenset(f.name for f in dataclasses.fields(Job) if f.name not in _TRANSIENT_FIELDS) diff --git a/app/core/registry.py b/app/core/registry.py index 06df682..c4e418b 100644 --- a/app/core/registry.py +++ b/app/core/registry.py @@ -7,6 +7,7 @@ from pathlib import Path from app.core.config import JOB_ID_RE, STEM_NAMES +from app.core.files import atomic_write_text from app.core.models import Job logger = logging.getLogger("stemdeck.registry") @@ -19,29 +20,55 @@ # of waiting for the pipeline thread to notice the cancel flag. _procs: dict[str, subprocess.Popen] = {} _lock = threading.Lock() +_persist_lock = threading.Lock() _REGISTRY_FILE = "registry.json" -_TERMINAL = {"done"} +_TERMINAL = {"done", "error", "cancelled"} +_ACTIVE_STATUSES = {"queued", "downloading", "analyzing", "separating", "processing"} + + +def _refresh_queue_positions_locked() -> None: + queued = sorted( + (job for job in _jobs.values() if job.status == "queued" and not job.cancel_requested), + key=lambda item: item.created_at, + ) + queue_size = len(queued) + queued_ids = {job.id for job in queued} + for index, job in enumerate(queued, start=1): + job.queue_position = index + job.queue_size = queue_size + for job in _jobs.values(): + if job.id not in queued_ids: + job.queue_position = None + job.queue_size = queue_size + + +def refresh_queue_positions() -> None: + with _lock: + _refresh_queue_positions_locked() def register(job: Job) -> Job: with _lock: _jobs[job.id] = job + _refresh_queue_positions_locked() return job def register_if_capacity(job: Job, max_pending: int) -> bool: - """Atomically check pending count and register if under capacity. + """Atomically check active count and register if under capacity. Returns True if registered, False if the queue is full.""" with _lock: - pending = sum(1 for j in _jobs.values() if j.status == "queued") - if pending >= max_pending: + active = sum(1 for j in _jobs.values() if j.status in _ACTIVE_STATUSES) + if active >= max_pending: return False _jobs[job.id] = job + _refresh_queue_positions_locked() return True def get(job_id: str) -> Job | None: with _lock: + _refresh_queue_positions_locked() return _jobs.get(job_id) @@ -54,6 +81,7 @@ def remove(job_id: str) -> None: def all_jobs() -> dict[str, Job]: """Return a snapshot of the registry for sweep / cleanup.""" with _lock: + _refresh_queue_positions_locked() return dict(_jobs) @@ -73,20 +101,18 @@ def persist(jobs_dir: Path) -> None: """Persist terminal jobs so completed library entries survive restarts.""" try: jobs_dir.mkdir(parents=True, exist_ok=True) - except OSError: - logger.warning("cannot create jobs dir %s; skipping persist", jobs_dir, exc_info=True) - return - with _lock: - records = [ - job.to_record() - for job in sorted(_jobs.values(), key=lambda item: item.created_at) - if job.status in _TERMINAL - ] - payload = json.dumps({"version": REGISTRY_VERSION, "jobs": records}, indent=2) + "\n" - path = jobs_dir / _REGISTRY_FILE - tmp = path.with_suffix(".json.tmp") - tmp.write_text(payload, encoding="utf-8") - tmp.replace(path) + path = jobs_dir / _REGISTRY_FILE + with _persist_lock: + with _lock: + records = [ + job.to_record() + for job in sorted(_jobs.values(), key=lambda item: item.created_at) + if job.status in _TERMINAL + ] + payload = json.dumps({"version": REGISTRY_VERSION, "jobs": records}, indent=2) + "\n" + atomic_write_text(path, payload) + except (OSError, TypeError, ValueError): + logger.warning("cannot persist registry under %s", jobs_dir, exc_info=True) def restore(jobs_dir: Path) -> None: @@ -99,7 +125,7 @@ def restore(jobs_dir: Path) -> None: to_add = {} for record in data.get("jobs", []): job = Job.from_record(record) - if JOB_ID_RE.match(job.id) and job.status in _TERMINAL and job.title: + if JOB_ID_RE.match(job.id) and job.status in _TERMINAL: to_add[job.id] = job with _lock: _jobs.update(to_add) @@ -135,6 +161,9 @@ def _recover_done_job(job_dir: Path) -> Job | None: mix_url = None if (stems_dir / "mix.wav").is_file(): mix_url = f"/api/jobs/{job_dir.name}/stems/mix.wav" + chord_midi_url = None + if (stems_dir / "chords.mid").is_file(): + chord_midi_url = f"/api/jobs/{job_dir.name}/chords.mid" selected = [stem["name"] for stem in stems if stem["name"] in STEM_NAMES] or list(STEM_NAMES) meta_path = job_dir / "metadata.json" if not meta_path.is_file(): @@ -150,7 +179,11 @@ def _recover_done_job(job_dir: Path) -> Job | None: progress=1.0, stage_message="Done", stems=stems, - selected_stems=selected, + selected_stems=meta.get("selected_stems") or selected, + quality_preset=str(meta.get("quality_preset") or "standard"), + stem_denoise_preset=str(meta.get("stem_denoise_preset") or "off"), + demucs_device=str(meta.get("demucs_device") or "auto"), + demucs_device_resolved=str(meta.get("demucs_device_resolved") or ""), mix_url=mix_url, created_at=job_dir.stat().st_mtime, title=meta.get("title"), @@ -164,9 +197,25 @@ def _recover_done_job(job_dir: Path) -> Job | None: peak_db=meta.get("peak_db"), dynamic_range=meta.get("dynamic_range"), tempo_stability=meta.get("tempo_stability"), + beat_times=meta.get("beat_times") if isinstance(meta.get("beat_times"), list) else None, + chord_progression=meta.get("chord_progression") + if isinstance(meta.get("chord_progression"), list) + else None, + chord_midi_url=meta.get("chord_midi_url") or chord_midi_url, stem_presence=meta.get("stem_presence"), sections=meta.get("sections"), tags=meta.get("tags"), + source_url=meta.get("source_url"), + bass_repair_applied=bool(meta.get("bass_repair_applied", False)), + phase_repair_applied=bool(meta.get("phase_repair_applied", False)), + phase_repair_residual_ratio=meta.get("phase_repair_residual_ratio"), + stem_denoise_applied=bool(meta.get("stem_denoise_applied", False)), + stem_gate_applied=bool(meta.get("stem_gate_applied", False)), + stem_gate_threshold_db=meta.get("stem_gate_threshold_db"), + processing_started_at=meta.get("processing_started_at"), + completed_at=meta.get("completed_at"), + processing_elapsed_seconds=meta.get("processing_elapsed_seconds"), + logs=meta.get("logs") if isinstance(meta.get("logs"), list) else [], ) @@ -181,3 +230,8 @@ def set_proc(job_id: str, proc: subprocess.Popen | None) -> None: def get_proc(job_id: str) -> subprocess.Popen | None: with _lock: return _procs.get(job_id) + + +def all_procs() -> list[subprocess.Popen]: + with _lock: + return list(_procs.values()) diff --git a/app/main.py b/app/main.py index 8d3e40b..b9eaddf 100644 --- a/app/main.py +++ b/app/main.py @@ -11,27 +11,47 @@ from importlib.metadata import version as package_version from fastapi import FastAPI, Request +from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from app.api.jobs import shutdown_pipeline_tasks from app.api.router import router from app.core.config import ( DEMUCS_DEVICE, + DEMUCS_FLOAT32, DEMUCS_MODEL, - FFMPEG_BIN, + DEMUCS_PRE_GAIN_DB, + DEMUCS_SHIFTS, JOBS_DIR, + PIPELINE_CONCURRENCY, + QUALITY_PRESET, + ROOT, STATIC_DIR, + available_demucs_devices, configure_portable_environment, ensure_runtime_dirs, + ffmpeg_available, ) +from app.core.joblog import add_system_log +from app.core.registry import all_procs from app.core.registry import restore as restore_registry from app.pipeline.collect import sweep_old_jobs +from app.pipeline.process import terminate_process # Show our INFO-level logs through uvicorn's root handler. Without this, # Python's default root level (WARNING) silently drops every # logger.info(...) call across the app, including the analyze # diagnostics ("chroma:", "key candidates:"). logging.getLogger("stemdeck").setLevel(logging.INFO) -logging.getLogger("stemdeck").info("demucs config: model=%s device=%s", DEMUCS_MODEL, DEMUCS_DEVICE) +logging.getLogger("stemdeck").info( + "demucs config: preset=%s model=%s device=%s shifts=%s pre_gain_db=%s float32=%s", + QUALITY_PRESET, + DEMUCS_MODEL, + DEMUCS_DEVICE, + DEMUCS_SHIFTS, + DEMUCS_PRE_GAIN_DB, + DEMUCS_FLOAT32, +) configure_portable_environment() @@ -72,7 +92,7 @@ def app_version() -> str: # metadata (set at install/build from the tag); fall back to the generated # app/_version.py for non-installed runs, then a dev placeholder. try: - return package_version("stemdeck") + return package_version("layerlab") except PackageNotFoundError: pass try: @@ -121,11 +141,22 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: wt = asyncio.create_task(_desktop_parent_watchdog(parent_pid_int)) _background_tasks.add(wt) wt.add_done_callback(_background_tasks.discard) - yield + add_system_log("LayerLab backend started") + try: + yield + finally: + add_system_log("LayerLab backend stopping", level="warning") + for proc in all_procs(): + terminate_process(proc) + await shutdown_pipeline_tasks() + for task in list(_background_tasks): + task.cancel() + if _background_tasks: + await asyncio.gather(*_background_tasks, return_exceptions=True) app = FastAPI( - title="StemDeck", + title="LayerLab", description="Paste a YouTube URL or upload an audio file, get audio stems split into a DAW-style player.", version=app_version(), lifespan=lifespan, @@ -140,15 +171,31 @@ def health_root() -> dict[str, object]: @app.get("/api/health", tags=["health"]) def health() -> dict[str, object]: return { - "name": "StemDeck", + "name": "LayerLab", "status": "ok", "version": app_version(), - "ffmpeg_configured": FFMPEG_BIN.is_file(), + "ffmpeg_configured": ffmpeg_available(), + "quality_preset": QUALITY_PRESET, "demucs_model": DEMUCS_MODEL, "demucs_device": DEMUCS_DEVICE, + "demucs_device_choices": ["auto", "cpu", "mps", "cuda"], + "demucs_available_devices": list(available_demucs_devices()), + "pipeline_concurrency": PIPELINE_CONCURRENCY, + "demucs_shifts": DEMUCS_SHIFTS, + "demucs_pre_gain_db": DEMUCS_PRE_GAIN_DB, } +@app.get("/LICENSE", include_in_schema=False) +def license_file() -> FileResponse: + return FileResponse(ROOT / "LICENSE", media_type="text/plain; charset=utf-8") + + +@app.get("/NOTICE", include_in_schema=False) +def notice_file() -> FileResponse: + return FileResponse(ROOT / "NOTICE", media_type="text/plain; charset=utf-8") + + # Content-Security-Policy. Defense-in-depth so an injected string in the webview # can't run script (and, in the desktop app, reach the exposed Tauri IPC) — #171. # script-src has no 'unsafe-inline'/'eval': all JS is same-origin modules and the diff --git a/app/pipeline/analyze.py b/app/pipeline/analyze.py index ab33cb0..7274d93 100644 --- a/app/pipeline/analyze.py +++ b/app/pipeline/analyze.py @@ -5,7 +5,9 @@ from pathlib import Path from app.core.config import JOBS_DIR, TIMEOUT_ANALYZE, ffmpeg_executable -from app.core.models import Job, _set +from app.core.models import Job, JobCancelled, _set +from app.pipeline.process import run_tracked_process +from app.pipeline.progress import set_stage_progress logger = logging.getLogger("stemdeck.analyze") @@ -161,7 +163,11 @@ def _measure_loudness(y: object, sr: int) -> tuple[float | None, float | None]: def _load_audio_ffmpeg( - source: Path, sr: int = 22050, duration: float = 180.0 + source: Path, + sr: int = 22050, + duration: float = 180.0, + *, + job: Job | None = None, ) -> tuple[object, int] | None: """Decode `source` to a mono float32 numpy array at `sr` via ffmpeg. Bypasses librosa's deprecated audioread fallback (which fires a @@ -204,11 +210,23 @@ def _load_audio_ffmpeg( "-", # write to stdout ] try: - proc = subprocess.run(cmd, capture_output=True, check=True, timeout=TIMEOUT_ANALYZE) + if job is None: + proc = subprocess.run(cmd, capture_output=True, check=True, timeout=TIMEOUT_ANALYZE) + stdout = proc.stdout + else: + result = run_tracked_process(job, cmd, timeout=TIMEOUT_ANALYZE) + if result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, + cmd, + output=result.stdout, + stderr=result.stderr, + ) + stdout = result.stdout except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e: logger.warning("ffmpeg decode failed for %s: %s", source, e) return None - y = np.frombuffer(proc.stdout, dtype=np.float32) + y = np.frombuffer(stdout, dtype=np.float32) if y.size == 0: return None return y, sr @@ -250,18 +268,19 @@ def analyze(job: Job, source: Path) -> tuple[int | None, str | None]: """Best-effort BPM and key detection. On failure, returns (None, None) and leaves job fields untouched -- the chips stay as placeholders.""" logger.info("analyze: entering for job %s, source=%s", job.id, source) - _set(job, status="analyzing", progress=0.0, stage="Analyzing audio...") + set_stage_progress(job, "analyze", 0.0, status="analyzing", stage="Analyzing audio...") try: import librosa except ImportError: logger.warning("librosa not installed -- skipping BPM/key analysis") + set_stage_progress(job, "analyze", 1.0, stage="Analysis skipped") return None, None try: # Analyse the first 180 s. Decode via ffmpeg directly into numpy # to avoid librosa's deprecated audioread fallback for # .webm/.m4a/.opus inputs. - loaded = _load_audio_ffmpeg(source, sr=22050, duration=180.0) + loaded = _load_audio_ffmpeg(source, sr=22050, duration=180.0, job=job) if loaded is None: return None, None y, sr = loaded @@ -304,6 +323,11 @@ def analyze(job: Job, source: Path) -> tuple[int | None, str | None]: import numpy as np beat_times = librosa.frames_to_time(beat_frames, sr=sr) + beat_times_list = [ + round(float(t), 3) + for t in beat_times + if float(t) >= 0.0 + ] if len(beat_times) > 2: intervals = np.diff(beat_times) mean_iv = float(intervals.mean()) @@ -321,11 +345,14 @@ def analyze(job: Job, source: Path) -> tuple[int | None, str | None]: peak_db=peak_db, dynamic_range=dynamic_range, tempo_stability=tempo_stability, - progress=1.0, + beat_times=beat_times_list, stage="Analysis complete", ) + set_stage_progress(job, "analyze", 1.0, stage="Analysis complete") return bpm, key + except JobCancelled: + raise except Exception as e: logger.exception("analyze failed for job %s", job.id) - _set(job, stage=f"Analysis skipped ({e})") + set_stage_progress(job, "analyze", 1.0, stage=f"Analysis skipped ({e})") return None, None diff --git a/app/pipeline/benchmark.py b/app/pipeline/benchmark.py new file mode 100644 index 0000000..a93eaa0 --- /dev/null +++ b/app/pipeline/benchmark.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +import json +import math +import subprocess +from collections import Counter +from pathlib import Path +from typing import Any + +import numpy as np + +from app.core.config import STEM_NAMES, TIMEOUT_ANALYZE, ffmpeg_executable +from app.pipeline.process import background_process_env + +_UNSTABLE_CHORD_SUFFIXES = ("dim", "sus2", "sus4", "maj7") + + +def _round(value: float | None, digits: int = 6) -> float | None: + if value is None or not math.isfinite(value): + return None + return round(float(value), digits) + + +def _rms(samples: np.ndarray) -> float: + if samples.size == 0: + return 0.0 + return float(np.sqrt(np.mean(np.square(samples, dtype=np.float64)))) + + +def _peak(samples: np.ndarray) -> float: + if samples.size == 0: + return 0.0 + return float(np.max(np.abs(samples))) + + +def _dbfs(value: float) -> float | None: + if value <= 1e-12: + return None + return 20.0 * math.log10(value) + + +def decode_audio_mono(path: Path, *, sr: int = 44100, duration: float | None = 180.0) -> np.ndarray: + """Decode an arbitrary local audio file to mono float32 samples with ffmpeg.""" + cmd = [ + ffmpeg_executable(), + "-nostdin", + "-loglevel", + "error", + "-i", + str(path), + "-ac", + "1", + "-ar", + str(sr), + "-f", + "f32le", + ] + if duration and duration > 0: + cmd += ["-t", str(duration)] + cmd.append("-") + proc = subprocess.run( + cmd, + capture_output=True, + check=True, + timeout=TIMEOUT_ANALYZE, + env=background_process_env(), + ) + samples = np.frombuffer(proc.stdout, dtype=np.float32) + if samples.size == 0: + raise ValueError(f"decoded audio is empty: {path}") + return samples + + +def find_job_source(job_dir: Path) -> Path | None: + """Return a retained source file for an in-progress or unswept job, if present.""" + for candidate in sorted(job_dir.glob("source.*")): + if candidate.is_file() and candidate.suffix != ".demucs.wav": + return candidate + return None + + +def present_stems(stems_dir: Path, stem_names: list[str] | None = None) -> list[str]: + names = stem_names or list(STEM_NAMES) + return [name for name in names if (stems_dir / f"{name}.wav").is_file()] + + +def stem_sum_metrics( + source: Path, + stems_dir: Path, + *, + stem_names: list[str] | None = None, + sr: int = 44100, + duration: float | None = 180.0, +) -> dict[str, Any]: + """Measure how closely the sum of available stems reconstructs the source.""" + names = stem_names or list(STEM_NAMES) + used = present_stems(stems_dir, names) + missing = [name for name in names if name not in used] + if not used: + return { + "available": False, + "reason": "no stem wav files found", + "used_stems": [], + "missing_stems": missing, + } + + source_audio = decode_audio_mono(source, sr=sr, duration=duration) + stem_audio = [decode_audio_mono(stems_dir / f"{name}.wav", sr=sr, duration=duration) for name in used] + length = min([len(source_audio), *(len(audio) for audio in stem_audio)]) + if length <= 0: + return { + "available": False, + "reason": "decoded audio has no overlapping samples", + "used_stems": used, + "missing_stems": missing, + } + + source_aligned = source_audio[:length].astype(np.float64, copy=False) + stem_sum = np.sum(np.stack([audio[:length].astype(np.float64, copy=False) for audio in stem_audio]), axis=0) + residual = source_aligned - stem_sum + + source_rms = _rms(source_aligned) + stem_sum_rms = _rms(stem_sum) + residual_rms = _rms(residual) + residual_ratio = residual_rms / max(source_rms, 1e-12) + denom = float(np.linalg.norm(source_aligned) * np.linalg.norm(stem_sum)) + correlation = float(np.dot(source_aligned, stem_sum) / denom) if denom > 1e-12 else None + clipping_samples = int(np.sum(np.abs(stem_sum) > 1.0)) + + return { + "available": True, + "source": str(source), + "stems_dir": str(stems_dir), + "used_stems": used, + "missing_stems": missing, + "sample_rate": sr, + "duration_sec": _round(length / sr, 3), + "source_rms": _round(source_rms), + "stem_sum_rms": _round(stem_sum_rms), + "residual_rms": _round(residual_rms), + "residual_ratio": _round(residual_ratio), + "residual_percent": _round(residual_ratio * 100.0, 3), + "snr_db": _round(20.0 * math.log10(max(source_rms, 1e-12) / max(residual_rms, 1e-12)), 3), + "correlation": _round(correlation), + "source_peak_dbfs": _round(_dbfs(_peak(source_aligned)), 3), + "stem_sum_peak_dbfs": _round(_dbfs(_peak(stem_sum)), 3), + "residual_peak_dbfs": _round(_dbfs(_peak(residual)), 3), + "stem_sum_clipping_samples": clipping_samples, + "stem_sum_clipping_percent": _round((clipping_samples / length) * 100.0, 5), + } + + +def chord_metrics(metadata_path: Path | None, stems_dir: Path | None = None) -> dict[str, Any]: + metadata: dict[str, Any] = {} + if metadata_path and metadata_path.is_file(): + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + metadata = {} + progression = metadata.get("chord_progression") + segments = progression if isinstance(progression, list) else [] + confidences = [ + float(item["confidence"]) + for item in segments + if isinstance(item, dict) and isinstance(item.get("confidence"), int | float) + ] + labels = [ + str(item.get("label")) + for item in segments + if isinstance(item, dict) and item.get("label") + ] + beat_lengths: list[float] = [] + second_lengths: list[float] = [] + unstable_short = 0 + for item in segments: + if not isinstance(item, dict): + continue + label = str(item.get("label") or "") + start_beat = item.get("start_beat") + end_beat = item.get("end_beat") + if isinstance(start_beat, int | float) and isinstance(end_beat, int | float): + beats = max(0.0, float(end_beat) - float(start_beat)) + if beats > 0: + beat_lengths.append(beats) + if beats <= 1.01 and label.endswith(_UNSTABLE_CHORD_SUFFIXES): + unstable_short += 1 + start = item.get("start") + end = item.get("end") + if isinstance(start, int | float) and isinstance(end, int | float): + seconds = max(0.0, float(end) - float(start)) + if seconds > 0: + second_lengths.append(seconds) + beat_times = metadata.get("beat_times") if isinstance(metadata.get("beat_times"), list) else [] + duration = metadata.get("duration_sec") + if not isinstance(duration, int | float) or duration <= 0: + duration = sum(second_lengths) if second_lengths else None + midi_path = stems_dir / "chords.mid" if stems_dir else None + return { + "metadata_available": bool(metadata), + "midi_available": bool(midi_path and midi_path.is_file()), + "segment_count": len(segments), + "unique_labels": sorted(set(labels)), + "label_counts": dict(sorted(Counter(labels).items())), + "average_confidence": _round(float(np.mean(confidences)) if confidences else None, 3), + "average_beats_per_segment": _round(float(np.mean(beat_lengths)) if beat_lengths else None, 3), + "short_segment_count": sum(1 for beats in beat_lengths if beats <= 1.01), + "unstable_short_segment_count": unstable_short, + "chord_changes_per_minute": _round( + ((len(segments) - 1) / max(float(duration), 1e-12)) * 60.0 + if duration and len(segments) > 1 + else None, + 3, + ), + "bpm": metadata.get("bpm"), + "beat_count": len(beat_times), + } + + +def benchmark_audio( + *, + source: Path | None, + stems_dir: Path, + metadata_path: Path | None = None, + sr: int = 44100, + duration: float | None = 180.0, +) -> dict[str, Any]: + metrics: dict[str, Any] = { + "schema": "layerlab-benchmark-v1", + "stems": { + "stems_dir": str(stems_dir), + "present": present_stems(stems_dir), + "missing": [name for name in STEM_NAMES if name not in present_stems(stems_dir)], + }, + "stem_sum": { + "available": False, + "reason": "source audio not provided or retained", + }, + "chords": chord_metrics(metadata_path, stems_dir), + } + if source is not None: + metrics["stem_sum"] = stem_sum_metrics(source, stems_dir, sr=sr, duration=duration) + return metrics + + +def benchmark_job_dir( + job_dir: Path, + *, + source: Path | None = None, + sr: int = 44100, + duration: float | None = 180.0, +) -> dict[str, Any]: + stems_dir = job_dir / "stems" + return benchmark_audio( + source=source or find_job_source(job_dir), + stems_dir=stems_dir, + metadata_path=job_dir / "metadata.json", + sr=sr, + duration=duration, + ) + + +def benchmark_jobs_root( + jobs_root: Path, + *, + sr: int = 44100, + duration: float | None = 180.0, +) -> dict[str, Any]: + job_dirs = sorted(path for path in jobs_root.iterdir() if path.is_dir()) + reports = [] + for job_dir in job_dirs: + metadata_path = job_dir / "metadata.json" + stems_dir = job_dir / "stems" + if not metadata_path.is_file() and not stems_dir.is_dir(): + continue + report = benchmark_job_dir(job_dir, sr=sr, duration=duration) + report["job_id"] = job_dir.name + reports.append(report) + + residuals = [ + item["stem_sum"].get("residual_percent") + for item in reports + if item.get("stem_sum", {}).get("available") + and isinstance(item["stem_sum"].get("residual_percent"), int | float) + ] + chord_confidences = [ + item["chords"].get("average_confidence") + for item in reports + if isinstance(item.get("chords", {}).get("average_confidence"), int | float) + ] + unstable_counts = [ + item["chords"].get("unstable_short_segment_count", 0) + for item in reports + if isinstance(item.get("chords"), dict) + ] + + return { + "schema": "layerlab-benchmark-suite-v1", + "jobs_root": str(jobs_root), + "job_count": len(reports), + "summary": { + "stem_sum_residual_percent_mean": _round(float(np.mean(residuals)) if residuals else None, 3), + "stem_sum_residual_percent_max": _round(float(np.max(residuals)) if residuals else None, 3), + "chord_confidence_mean": _round(float(np.mean(chord_confidences)) if chord_confidences else None, 3), + "unstable_short_segment_total": int(sum(unstable_counts)), + }, + "jobs": reports, + } + + +def compare_benchmark_reports(current: dict[str, Any], baseline: dict[str, Any]) -> dict[str, Any]: + current_summary = current.get("summary", {}) if isinstance(current.get("summary"), dict) else {} + baseline_summary = baseline.get("summary", {}) if isinstance(baseline.get("summary"), dict) else {} + keys = sorted(set(current_summary) | set(baseline_summary)) + deltas: dict[str, Any] = {} + for key in keys: + cur = current_summary.get(key) + base = baseline_summary.get(key) + if isinstance(cur, int | float) and isinstance(base, int | float): + deltas[key] = _round(float(cur) - float(base), 6) + else: + deltas[key] = None + return { + "schema": "layerlab-benchmark-compare-v1", + "baseline_schema": baseline.get("schema"), + "current_schema": current.get("schema"), + "baseline_job_count": baseline.get("job_count"), + "current_job_count": current.get("job_count"), + "summary_delta": deltas, + } diff --git a/app/pipeline/chords.py b/app/pipeline/chords.py new file mode 100644 index 0000000..2b957d0 --- /dev/null +++ b/app/pipeline/chords.py @@ -0,0 +1,997 @@ +from __future__ import annotations + +import csv +import logging +import unicodedata +from dataclasses import dataclass +from io import StringIO +from pathlib import Path + +import numpy as np + +from app.core.models import Job +from app.pipeline.analyze import _load_audio_ffmpeg + +logger = logging.getLogger("stemdeck.chords") + +_PITCHES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") +_TPB = 480 +_CHORD_STEM_WEIGHTS = { + "piano": 1.25, + "guitar": 1.10, + "other": 0.55, +} +CHORD_MIDI_STYLES = ("auto", "triads", "sevenths") +CHORD_MIDI_GRIDS = ("beat", "bar") +_BASS_ROOT_BOOST = 0.16 +_BASS_BLOCK_ROOT_BOOST = 0.24 +_MIN_STABLE_CHORD_CONFIDENCE = 0.56 + + +@dataclass(frozen=True) +class ChordSegment: + label: str + start: float + end: float + root: int | None + intervals: tuple[int, ...] + confidence: float + start_beat: int | None = None + end_beat: int | None = None + + +@dataclass(frozen=True) +class _ChromaSource: + name: str + weight: float + chroma: np.ndarray + frame_times: np.ndarray + + +@dataclass(frozen=True) +class _ChordCandidate: + label: str + root: int | None + intervals: tuple[int, ...] + score: float + confidence: float + + +_CHORD_TEMPLATES: tuple[tuple[str, tuple[int, ...], tuple[float, ...]], ...] = ( + ("", (0, 4, 7), (1.0, 0.82, 0.72)), + ("m", (0, 3, 7), (1.0, 0.82, 0.72)), + ("7", (0, 4, 7, 10), (1.0, 0.78, 0.68, 0.42)), + ("maj7", (0, 4, 7, 11), (1.0, 0.78, 0.68, 0.42)), + ("m7", (0, 3, 7, 10), (1.0, 0.78, 0.68, 0.42)), + ("sus2", (0, 2, 7), (1.0, 0.66, 0.72)), + ("sus4", (0, 5, 7), (1.0, 0.66, 0.72)), + ("dim", (0, 3, 6), (1.0, 0.72, 0.62)), +) +_MAJOR_KEY_PROFILE = np.asarray( + [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88], + dtype=np.float32, +) +_MINOR_KEY_PROFILE = np.asarray( + [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17], + dtype=np.float32, +) +_DIATONIC_MAJOR = {0, 2, 4, 5, 7, 9, 11} +_DIATONIC_MINOR = {0, 2, 3, 5, 7, 8, 10} +_PITCH_TO_INDEX = {pitch: idx for idx, pitch in enumerate(_PITCHES)} + + +def _varlen(value: int) -> bytes: + value = max(0, int(value)) + parts = [value & 0x7F] + value >>= 7 + while value: + parts.append((value & 0x7F) | 0x80) + value >>= 7 + return bytes(reversed(parts)) + + +def _meta(delta: int, meta_type: int, payload: bytes) -> bytes: + return _varlen(delta) + bytes((0xFF, meta_type)) + _varlen(len(payload)) + payload + + +def _midi_event(delta: int, status: int, note: int, velocity: int) -> bytes: + return _varlen(delta) + bytes((status, note, velocity)) + + +def _midi_text(value: str | None, fallback: str, limit: int) -> bytes: + """Return DAW-friendly MIDI meta text. + + Standard MIDI files do not define a reliable Unicode encoding for text meta + events. Some DAWs interpret UTF-8 bytes as a local 8-bit codepage, which + makes Japanese and other multibyte titles appear as mojibake. Keep metadata + ASCII-only so track names never display as broken text. + """ + text = unicodedata.normalize("NFKD", (value or "").strip()) + text = text.encode("ascii", "ignore").decode("ascii") + text = " ".join(text.split()) + if not text: + text = fallback + return text.encode("ascii")[:limit] + + +def _normalize_chroma(chroma: np.ndarray) -> np.ndarray | None: + total = float(np.sum(chroma)) + if total <= 1e-8: + return None + return chroma.astype(np.float32, copy=False) / total + + +def normalize_chord_midi_style(value: str | None) -> str: + style = (value or "auto").strip().lower() + return style if style in CHORD_MIDI_STYLES else "auto" + + +def normalize_chord_midi_grid(value: str | None) -> str: + grid = (value or "beat").strip().lower() + return grid if grid in CHORD_MIDI_GRIDS else "beat" + + +def _split_chord_label(label: str | None) -> tuple[int | None, str]: + text = (label or "").strip() + if not text or text == "N.C.": + return None, "" + for pitch in sorted(_PITCH_TO_INDEX, key=len, reverse=True): + if text.startswith(pitch): + return _PITCH_TO_INDEX[pitch], text[len(pitch) :] + return None, "" + + +def _segment_from_metadata(item: dict) -> ChordSegment | None: + if not isinstance(item, dict): + return None + label = str(item.get("label") or "N.C.") + root, suffix = _split_chord_label(label) + intervals = _intervals_for_suffix(suffix) if root is not None else () + try: + start = float(item.get("start", 0.0)) + end = float(item.get("end", start)) + confidence = float(item.get("confidence", 0.0)) + except (TypeError, ValueError): + return None + start_beat = _optional_int(item.get("start_beat")) + end_beat = _optional_int(item.get("end_beat")) + if end <= start and (start_beat is None or end_beat is None or end_beat <= start_beat): + return None + return ChordSegment(label, start, end, root, intervals, confidence, start_beat, end_beat) + + +def _optional_int(value: object) -> int | None: + try: + if value is None: + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _intervals_for_suffix(suffix: str) -> tuple[int, ...]: + if suffix == "m": + return (0, 3, 7) + if suffix == "7": + return (0, 4, 7, 10) + if suffix == "maj7": + return (0, 4, 7, 11) + if suffix == "m7": + return (0, 3, 7, 10) + if suffix == "sus2": + return (0, 2, 7) + if suffix == "sus4": + return (0, 5, 7) + if suffix == "dim": + return (0, 3, 6) + return (0, 4, 7) + + +def chord_segments_from_metadata(progression: object) -> list[ChordSegment]: + if not isinstance(progression, list): + return [] + segments = [_segment_from_metadata(item) for item in progression if isinstance(item, dict)] + return [segment for segment in segments if segment is not None] + + +def _simple_chord_for_style(seg: ChordSegment, style: str) -> ChordSegment: + if seg.root is None or not seg.intervals: + return seg + root_name = _PITCHES[seg.root] + _, suffix = _split_chord_label(seg.label) + is_minor = suffix in {"m", "m7", "dim"} + is_seventh = suffix in {"7", "maj7", "m7"} + if style == "auto": + return seg + if style == "sevenths" and is_seventh: + return seg + label = f"{root_name}m" if is_minor else root_name + intervals = (0, 3, 7) if is_minor else (0, 4, 7) + return ChordSegment( + label, + seg.start, + seg.end, + seg.root, + intervals, + seg.confidence, + seg.start_beat, + seg.end_beat, + ) + + +def _quantize_segments_to_bars(segments: list[ChordSegment], beats_per_bar: int = 4) -> list[ChordSegment]: + usable = [seg for seg in segments if seg.start_beat is not None and seg.end_beat is not None] + if not usable: + return segments + first_beat = min(int(seg.start_beat) for seg in usable) + last_beat = max(int(seg.end_beat) for seg in usable) + bar_start = (first_beat // beats_per_bar) * beats_per_bar + quantized: list[ChordSegment] = [] + + def time_for_beat(beat: int) -> float: + for segment in usable: + seg_start = int(segment.start_beat) + seg_end = int(segment.end_beat) + if seg_start <= beat <= seg_end and seg_end > seg_start: + ratio = (beat - seg_start) / (seg_end - seg_start) + return segment.start + ((segment.end - segment.start) * ratio) + if beat <= first_beat: + return min(segment.start for segment in usable) + return max(segment.end for segment in usable) + + for start in range(bar_start, last_beat, beats_per_bar): + end = start + beats_per_bar + overlaps: list[tuple[float, ChordSegment]] = [] + for seg in usable: + overlap = max(0, min(end, int(seg.end_beat)) - max(start, int(seg.start_beat))) + if overlap > 0: + overlaps.append((float(overlap), seg)) + if not overlaps: + continue + _, picked = max(overlaps, key=lambda item: (item[0], item[1].confidence)) + quantized_end = min(end, last_beat) + quantized.append( + ChordSegment( + picked.label, + time_for_beat(start), + time_for_beat(quantized_end), + picked.root, + picked.intervals, + picked.confidence, + start, + quantized_end, + ) + ) + return _merge_segments(quantized) + + +def prepare_chord_midi_segments( + segments: list[ChordSegment], + *, + style: str = "auto", + grid: str = "beat", +) -> list[ChordSegment]: + normalized_style = normalize_chord_midi_style(style) + normalized_grid = normalize_chord_midi_grid(grid) + transformed = [_simple_chord_for_style(seg, normalized_style) for seg in segments] + transformed = _smooth_short_segments(_merge_segments(transformed)) + if normalized_grid == "bar": + transformed = _quantize_segments_to_bars(transformed) + return _merge_segments(transformed) + + +def _normalize_chroma_frames(chroma: np.ndarray) -> np.ndarray: + matrix = np.asarray(chroma, dtype=np.float32) + totals = matrix.sum(axis=0, keepdims=True) + return np.divide(matrix, totals, out=np.zeros_like(matrix), where=totals > 1e-8) + + +def _template_vector(root: int, intervals: tuple[int, ...], weights: tuple[float, ...]) -> np.ndarray: + template = np.zeros(12, dtype=np.float32) + for interval, weight in zip(intervals, weights, strict=False): + template[(root + interval) % 12] = weight + template_sum = float(np.sum(template)) + if template_sum > 0: + template /= template_sum + return template + + +def _estimate_key_context(vectors: list[np.ndarray]) -> tuple[int, str] | None: + usable = [_normalize_chroma(vector) for vector in vectors] + usable = [vector for vector in usable if vector is not None] + if not usable: + return None + chroma = np.mean(np.stack(usable), axis=0) + chroma = chroma / float(np.sum(chroma)) + major = _MAJOR_KEY_PROFILE / float(np.sum(_MAJOR_KEY_PROFILE)) + minor = _MINOR_KEY_PROFILE / float(np.sum(_MINOR_KEY_PROFILE)) + + scores: list[tuple[float, int, str]] = [] + for root in range(12): + scores.append((float(np.dot(chroma, np.roll(major, root))), root, "major")) + scores.append((float(np.dot(chroma, np.roll(minor, root))), root, "minor")) + scores.sort(reverse=True) + if len(scores) > 1 and scores[0][0] - scores[1][0] < 0.0015: + return None + return scores[0][1], scores[0][2] + + +def _key_bias(root: int, intervals: tuple[int, ...], key_context: tuple[int, str] | None) -> float: + affinity = _key_affinity(root, intervals, key_context) + if affinity is None: + return 0.0 + tonic, _ = key_context or (0, "major") + bias = (affinity - 0.5) * 0.038 + root_degree = (root - tonic) % 12 + if root_degree == 0: + bias += 0.008 + elif root_degree in {5, 7}: + bias += 0.005 + if affinity < 0.75: + bias -= 0.020 + return bias + + +def _key_affinity( + root: int, intervals: tuple[int, ...], key_context: tuple[int, str] | None +) -> float | None: + if key_context is None: + return None + tonic, mode = key_context + scale = _DIATONIC_MAJOR if mode == "major" else _DIATONIC_MINOR + tones = {((root + interval) - tonic) % 12 for interval in intervals} + if not tones: + return None + return sum(1 for tone in tones if tone in scale) / len(tones) + + +def _parse_key_context(label: str | None) -> tuple[int, str] | None: + if not label: + return None + parts = label.strip().replace("major", "maj").replace("minor", "min").split() + if len(parts) < 2: + return None + root = parts[0].replace("♯", "#").replace("♭", "b") + flats = { + "Db": "C#", + "Eb": "D#", + "Gb": "F#", + "Ab": "G#", + "Bb": "A#", + } + root = flats.get(root, root) + mode = "minor" if parts[1].lower().startswith("min") else "major" + if root not in _PITCH_TO_INDEX: + return None + return _PITCH_TO_INDEX[root], mode + + +def _rank_chord_candidates( + chroma: np.ndarray, + *, + key_context: tuple[int, str] | None = None, + limit: int = 8, +) -> list[_ChordCandidate]: + normalized = _normalize_chroma(chroma) + if normalized is None: + return [_ChordCandidate("N.C.", None, (), 0.0, 0.0)] + entropy = -float(np.sum(normalized * np.log2(np.maximum(normalized, 1e-8)))) / np.log2(12) + ordered_energy = np.sort(normalized) + strongest = float(ordered_energy[-1]) + second = float(ordered_energy[-2]) if len(ordered_energy) > 1 else 0.0 + if strongest < 0.115 and entropy > 0.92: + return [_ChordCandidate("N.C.", None, (), 0.0, 0.0)] + + scored: list[tuple[float, str, int, tuple[int, ...]]] = [] + for root in range(12): + for suffix, intervals, weights in _CHORD_TEMPLATES: + template = _template_vector(root, intervals, weights) + support = float(np.dot(normalized, template)) + off_energy = float(np.sum(normalized[template <= 0.001])) + root_energy = float(normalized[root]) + fifth_energy = float(normalized[(root + 7) % 12]) + third_energy = float( + max(normalized[(root + 3) % 12], normalized[(root + 4) % 12]) + ) + score = ( + support + + (0.20 * root_energy) + + (0.035 * fifth_energy) + - (0.12 * off_energy) + + _key_bias(root, intervals, key_context) + ) + if suffix in {"7", "maj7", "m7"}: + seventh_interval = 11 if suffix == "maj7" else 10 + seventh_energy = float(normalized[(root + seventh_interval) % 12]) + score -= 0.012 if suffix == "7" else 0.018 + if seventh_energy < 0.055: + score -= 0.045 + else: + score += min(0.014, seventh_energy * 0.12) + elif suffix in {"sus2", "sus4"}: + score -= 0.038 + sus_interval = 2 if suffix == "sus2" else 5 + sus_energy = float(normalized[(root + sus_interval) % 12]) + if sus_energy < third_energy * 1.18: + score -= 0.040 + elif suffix == "dim": + score -= 0.045 + if float(normalized[(root + 6) % 12]) < 0.070: + score -= 0.040 + affinity = _key_affinity(root, intervals, key_context) + if affinity is not None: + if suffix in {"dim", "sus2", "sus4"} and affinity < 0.99: + score -= 0.040 + elif suffix in {"7", "maj7", "m7"} and affinity < 0.75: + score -= 0.020 + scored.append((score, f"{_PITCHES[root]}{suffix}", root, intervals)) + + scored.sort(reverse=True) + candidates: list[_ChordCandidate] = [] + for idx, (score, label, root, intervals) in enumerate(scored[: max(1, limit)]): + next_score = scored[idx + 1][0] if idx + 1 < len(scored) else score - 0.04 + margin = max(0.0, score - next_score) + confidence = max(0.0, min(1.0, (score / 0.17) + (margin * 1.8))) + candidates.append( + _ChordCandidate( + label=label, + root=root, + intervals=intervals, + score=score, + confidence=confidence, + ) + ) + + if not candidates or candidates[0].score < 0.062: + return [_ChordCandidate("N.C.", None, (), 0.0, 0.0)] + if entropy > 0.88 and candidates[0].confidence < 0.58 and strongest < second * 1.18: + return [_ChordCandidate("N.C.", None, (), 0.0, 0.0)] + return candidates + + +def _score_chord(chroma: np.ndarray) -> tuple[str, int | None, tuple[int, ...], float]: + candidates = _rank_chord_candidates(chroma, limit=1) + best = candidates[0] + if best.root is None: + return "N.C.", None, (), 0.0 + return best.label, best.root, best.intervals, best.confidence + + +def _transition_bonus(prev: _ChordCandidate, current: _ChordCandidate) -> float: + if prev.label == current.label: + return 0.026 + if prev.root is None or current.root is None: + return -0.012 + if prev.root == current.root: + return 0.004 + motion = (current.root - prev.root) % 12 + if motion in {5, 7}: + return 0.004 + penalty = -0.010 + if current.confidence < 0.72: + penalty -= 0.016 + return penalty + + +def _select_chord_sequence( + vectors: list[np.ndarray], key_context: tuple[int, str] | None = None +) -> list[_ChordCandidate]: + if not vectors: + return [] + key_context = key_context or _estimate_key_context(vectors) + ranked = [_rank_chord_candidates(vector, key_context=key_context, limit=8) for vector in vectors] + if len(ranked) == 1: + return [ranked[0][0]] + + dp: list[list[float]] = [] + back: list[list[int]] = [] + dp.append([candidate.score for candidate in ranked[0]]) + back.append([-1 for _ in ranked[0]]) + for idx in range(1, len(ranked)): + row: list[float] = [] + back_row: list[int] = [] + for current in ranked[idx]: + best_score = -1e9 + best_prev = 0 + for prev_idx, prev in enumerate(ranked[idx - 1]): + score = dp[idx - 1][prev_idx] + current.score + _transition_bonus(prev, current) + if score > best_score: + best_score = score + best_prev = prev_idx + row.append(best_score) + back_row.append(best_prev) + dp.append(row) + back.append(back_row) + + cursor = int(np.argmax(dp[-1])) + selected: list[_ChordCandidate] = [] + for idx in range(len(ranked) - 1, -1, -1): + selected.append(ranked[idx][cursor]) + cursor = back[idx][cursor] + if cursor < 0 and idx > 0: + cursor = 0 + selected.reverse() + return selected + + +def _merge_segments(segments: list[ChordSegment]) -> list[ChordSegment]: + merged: list[ChordSegment] = [] + for seg in segments: + if merged and merged[-1].label == seg.label: + prev = merged[-1] + merged[-1] = ChordSegment( + prev.label, + prev.start, + seg.end, + prev.root, + prev.intervals, + max(prev.confidence, seg.confidence), + prev.start_beat, + seg.end_beat, + ) + else: + merged.append(seg) + return merged + + +def _available_chord_source_paths( + source: Path, stems_dir: Path | None +) -> tuple[list[tuple[str, Path, float]], Path | None]: + paths: list[tuple[str, Path, float]] + bass_path: Path | None = None + if stems_dir is None: + return [("original", source, 1.0)], bass_path + harmonic_paths: list[tuple[str, Path, float]] = [] + for name, weight in _CHORD_STEM_WEIGHTS.items(): + path = stems_dir / f"{name}.wav" + if path.is_file(): + harmonic_paths.append((name, path, weight)) + # Once harmonic stems exist, the full mix becomes a fallback reference only: + # vocals, drums, and effects often pollute chroma enough to hurt chord calls. + paths = [("original", source, 0.35 if harmonic_paths else 1.0), *harmonic_paths] + candidate_bass = stems_dir / "bass.wav" + if candidate_bass.is_file(): + bass_path = candidate_bass + return paths, bass_path + + +def _load_chroma_source( + path: Path, + *, + name: str, + weight: float, + end_time: float, + harmonic: bool, +) -> _ChromaSource | None: + loaded = _load_audio_ffmpeg(path, sr=22050, duration=min(180.0, end_time + 1.0)) + if loaded is None: + return None + + import librosa + + y, sr = loaded + if float(np.sqrt(np.mean(np.square(y)))) < 1e-6: + return None + hop_length = 512 + y_chroma = librosa.effects.harmonic(y) if harmonic else y + chroma = librosa.feature.chroma_cqt(y=y_chroma, sr=sr, hop_length=hop_length) + try: + cens = librosa.feature.chroma_cens(y=y_chroma, sr=sr, hop_length=hop_length) + except Exception as exc: + logger.debug("chroma_cens unavailable for %s: %s", path, exc) + else: + frame_count = min(chroma.shape[1], cens.shape[1]) + if frame_count > 0: + cqt = _normalize_chroma_frames(chroma[:, :frame_count]) + cens = _normalize_chroma_frames(cens[:, :frame_count]) + # CQT is more detailed; CENS is more stable against timbre/noise. + chroma = (0.68 * cqt) + (0.32 * cens) + if chroma.shape[1] >= 3: + chroma = (np.roll(chroma, 1, axis=1) + (2.0 * chroma) + np.roll(chroma, -1, axis=1)) / 4.0 + chroma[:, 0] = chroma[:, 1] + chroma[:, -1] = chroma[:, -2] + frame_times = librosa.frames_to_time(np.arange(chroma.shape[1]), sr=sr, hop_length=hop_length) + return _ChromaSource(name=name, weight=weight, chroma=chroma, frame_times=frame_times) + + +def _mean_normalized_chroma(source: _ChromaSource, start: float, end: float) -> np.ndarray | None: + mask = (source.frame_times >= start) & (source.frame_times < end) + if not np.any(mask): + return None + frames = np.asarray(source.chroma[:, mask], dtype=np.float32) + frame_sums = frames.sum(axis=0, keepdims=True) + good = frame_sums[0] > 1e-8 + if not np.any(good): + return None + normalized_frames = frames[:, good] / frame_sums[:, good] + mean = normalized_frames.mean(axis=1) + upper = np.quantile(normalized_frames, 0.72, axis=1) + median = np.median(normalized_frames, axis=1) + # Mean catches arpeggios, upper quantile catches chord tones that appear + # strongly on part of the bar, median suppresses one-frame melody spikes. + vector = np.asarray((0.56 * mean) + (0.30 * upper) + (0.14 * median), dtype=np.float32) + total = float(np.sum(vector)) + if total <= 1e-8: + return None + return vector / total + + +def _combine_harmony_chroma( + chord_sources: list[_ChromaSource], start: float, end: float +) -> np.ndarray | None: + combined = np.zeros(12, dtype=np.float32) + total_weight = 0.0 + for source in chord_sources: + vector = _mean_normalized_chroma(source, start, end) + if vector is None: + continue + weight = source.weight + if source.name != "original": + _, _, _, confidence = _score_chord(vector) + if confidence < 0.25: + continue + weight *= 0.35 + (0.65 * confidence) + combined += vector * weight + total_weight += weight + + if total_weight <= 0: + return None + combined /= total_weight + return combined + + +def _bass_root_hint( + bass_source: _ChromaSource | None, start: float, end: float +) -> tuple[int, float] | None: + if bass_source is None: + return None + bass_vector = _mean_normalized_chroma(bass_source, start, end) + if bass_vector is None: + return None + root = int(np.argmax(bass_vector)) + ordered = np.sort(bass_vector) + strongest = float(ordered[-1]) + runner_up = float(ordered[-2]) if len(ordered) > 1 else 0.0 + if strongest < 0.16 or strongest < runner_up * 1.12: + return None + clarity = min(1.0, max(0.0, (strongest - runner_up) * 4.0)) + return root, clarity + + +def _apply_bass_root_hint( + vector: np.ndarray, hint: tuple[int, float] | None, *, boost: float = _BASS_ROOT_BOOST +) -> np.ndarray: + combined = np.asarray(vector, dtype=np.float32).copy() + if hint is None: + return combined + root, clarity = hint + harmony_support = float(combined[root]) + # Passing bass notes are common. Use bass as a strong hint only when the + # harmonic chroma also contains at least some evidence for that pitch. + support_scale = min(1.0, 0.30 + (harmony_support * 5.0)) + combined[root] += boost * clarity * support_scale + total = float(np.sum(combined)) + return combined / total if total > 0 else combined + + +def _candidate_from_segment(seg: ChordSegment) -> _ChordCandidate: + return _ChordCandidate(seg.label, seg.root, seg.intervals, seg.confidence, seg.confidence) + + +def _replace_segment_chord(seg: ChordSegment, chord: _ChordCandidate) -> ChordSegment: + return ChordSegment( + chord.label, + seg.start, + seg.end, + chord.root, + chord.intervals, + max(seg.confidence, chord.confidence), + seg.start_beat, + seg.end_beat, + ) + + +def _segment_beat_length(seg: ChordSegment) -> float: + if seg.start_beat is not None and seg.end_beat is not None: + return max(0.0, float(seg.end_beat - seg.start_beat)) + return max(0.0, seg.end - seg.start) + + +def _is_unstable_complex_label(label: str) -> bool: + return label.endswith(("dim", "sus2", "sus4", "maj7")) + + +def _smooth_short_segments(segments: list[ChordSegment]) -> list[ChordSegment]: + if len(segments) < 3: + return segments + smoothed = list(segments) + for idx in range(1, len(smoothed) - 1): + current = smoothed[idx] + prev = smoothed[idx - 1] + nxt = smoothed[idx + 1] + if _segment_beat_length(current) > 1.01: + continue + if prev.label == nxt.label and current.confidence < 0.72: + smoothed[idx] = _replace_segment_chord(current, _candidate_from_segment(prev)) + continue + if _is_unstable_complex_label(current.label): + neighbor = prev if prev.confidence >= nxt.confidence else nxt + smoothed[idx] = _replace_segment_chord(current, _candidate_from_segment(neighbor)) + continue + if current.confidence >= _MIN_STABLE_CHORD_CONFIDENCE: + continue + neighbor = prev if prev.confidence >= nxt.confidence else nxt + smoothed[idx] = _replace_segment_chord(current, _candidate_from_segment(neighbor)) + return _merge_segments(smoothed) + + +def _combine_segment_chroma( + chord_sources: list[_ChromaSource], + bass_source: _ChromaSource | None, + start: float, + end: float, +) -> np.ndarray | None: + combined = _combine_harmony_chroma(chord_sources, start, end) + if combined is None: + return None + return _apply_bass_root_hint(combined, _bass_root_hint(bass_source, start, end)) + + +def _combine_beatwise_chroma( + chord_sources: list[_ChromaSource], + bass_source: _ChromaSource | None, + beat_bounds: list[float], + start_beat: int, + end_beat: int, +) -> np.ndarray | None: + last_idx = len(beat_bounds) - 1 + start_idx = max(0, min(int(start_beat), last_idx)) + if start_idx >= last_idx: + return None + end_idx = max(start_idx + 1, min(int(end_beat), last_idx)) + start = beat_bounds[start_idx] + end = beat_bounds[end_idx] + whole = _combine_harmony_chroma(chord_sources, start, end) + + weighted: list[np.ndarray] = [] + weights: list[float] = [] + bass_votes = np.zeros(12, dtype=np.float32) + for idx in range(start_idx, end_idx): + beat_start = beat_bounds[idx] + beat_end = beat_bounds[idx + 1] + if beat_end <= beat_start: + continue + beat_vector = _combine_harmony_chroma(chord_sources, beat_start, beat_end) + if beat_vector is None: + continue + hint = _bass_root_hint(bass_source, beat_start, beat_end) + beat_with_bass = _apply_bass_root_hint(beat_vector, hint, boost=_BASS_ROOT_BOOST * 1.15) + _, _, _, confidence = _score_chord(beat_with_bass) + duration_weight = max(0.05, beat_end - beat_start) + weight = duration_weight * (0.70 + confidence) + weighted.append(beat_with_bass * weight) + weights.append(weight) + if hint is not None: + root, clarity = hint + bass_votes[root] += clarity * duration_weight + + if whole is not None: + weighted.append(whole * 1.35) + weights.append(1.35) + + if not weighted or not weights: + return None + combined = np.sum(np.stack(weighted), axis=0) / float(np.sum(weights)) + if float(np.sum(bass_votes)) > 0: + root = int(np.argmax(bass_votes)) + ordered = np.sort(bass_votes) + strongest = float(ordered[-1]) + runner_up = float(ordered[-2]) if len(ordered) > 1 else 0.0 + if strongest >= max(0.22, runner_up * 1.15): + clarity = min(1.0, max(0.0, strongest / max(1e-6, float(np.sum(bass_votes))))) + combined = _apply_bass_root_hint( + combined, + (root, clarity), + boost=_BASS_BLOCK_ROOT_BOOST, + ) + total = float(np.sum(combined)) + return combined / total if total > 0 else combined + + +def detect_chord_segments( + source: Path, + beat_times: list[float] | None, + *, + duration_sec: float | None = None, + beats_per_chord: int = 1, + stems_dir: Path | None = None, + key_context: tuple[int, str] | None = None, +) -> list[ChordSegment]: + """Estimate sustained chord labels between detected quarter-note beats. + + This is intentionally a guide-track generator, not a full polyphonic + transcription engine. We classify each beat window, smooth improbable + one-beat flips, then merge adjacent identical labels into sustained + chord blocks on the DAW grid. + """ + if not beat_times or len(beat_times) < 2: + return [] + end_time = min(float(duration_sec or beat_times[-1]), beat_times[-1]) + if end_time <= 0: + return [] + chord_paths, bass_path = _available_chord_source_paths(source, stems_dir) + chord_sources = [ + loaded + for name, path, weight in chord_paths + if (loaded := _load_chroma_source(path, name=name, weight=weight, end_time=end_time, harmonic=True)) + is not None + ] + if not chord_sources: + return [] + bass_source = ( + _load_chroma_source(bass_path, name="bass", weight=1.0, end_time=end_time, harmonic=False) + if bass_path is not None + else None + ) + + beat_bounds = [float(t) for t in beat_times if 0 <= float(t) <= end_time] + if len(beat_bounds) < 2: + return [] + stride = max(1, int(beats_per_chord)) + bounds = [(t, idx) for idx, t in enumerate(beat_bounds) if idx % stride == 0] + last_beat_index = len(beat_bounds) - 1 + if beat_bounds[-1] > bounds[-1][0]: + bounds.append((beat_bounds[-1], last_beat_index)) + if duration_sec and duration_sec > bounds[-1][0] + 0.2: + tail = bounds[-1][0] - bounds[-2][0] if len(bounds) > 1 else 60.0 / 120.0 + bounds.append((min(float(duration_sec), bounds[-1][0] + tail), last_beat_index + stride)) + + spans: list[tuple[float, int, float, int, np.ndarray]] = [] + for (start, start_beat), (end, end_beat) in zip(bounds, bounds[1:], strict=False): + if end <= start: + continue + vector = _combine_beatwise_chroma( + chord_sources, + bass_source, + beat_bounds, + start_beat, + end_beat, + ) + if vector is None: + vector = _combine_segment_chroma(chord_sources, bass_source, start, end) + if vector is None: + continue + spans.append((start, start_beat, end, end_beat, vector)) + + selected = _select_chord_sequence([vector for *_, vector in spans], key_context=key_context) + segments: list[ChordSegment] = [] + for (start, start_beat, end, end_beat, _), chord in zip(spans, selected, strict=False): + segments.append( + ChordSegment( + label=chord.label, + start=round(start, 3), + end=round(end, 3), + root=chord.root, + intervals=chord.intervals, + confidence=round(chord.confidence, 3), + start_beat=start_beat, + end_beat=end_beat, + ) + ) + return _smooth_short_segments(_merge_segments(segments)) + + +def _note_numbers(root: int, intervals: tuple[int, ...]) -> list[int]: + base = 48 + root # C3 octave, safe for chord-pad playback. + notes = [base + interval for interval in intervals] + if notes: + notes.append(base + 12) + return sorted({max(0, min(127, n)) for n in notes}) + + +def write_chord_midi( + path: Path, + segments: list[ChordSegment], + *, + bpm: int | None, + title: str | None = None, + markers: bool = False, +) -> None: + tempo_bpm = max(30, min(240, int(bpm or 120))) + tempo_us = int(round(60_000_000 / tempo_bpm)) + conductor_events = bytearray() + conductor_events += _meta(0, 0x03, _midi_text("LayerLab Tempo", "LayerLab Tempo", 64)) + conductor_events += _meta(0, 0x51, tempo_us.to_bytes(3, "big")) + conductor_events += _meta(0, 0x58, bytes((4, 2, 24, 8))) # 4/4, 24 MIDI clocks/click. + conductor_events += _meta(0, 0x01, _midi_text(f"BPM {tempo_bpm}", f"BPM {tempo_bpm}", 32)) + conductor_events += _meta(0, 0x2F, b"") + + events = bytearray() + events += _meta(0, 0x03, _midi_text(title, "LayerLab Chord Progression", 120)) + events += _varlen(0) + bytes((0xC0, 0)) # Acoustic Grand Piano + + cursor_ticks = 0 + for seg in segments: + if seg.start_beat is not None and seg.end_beat is not None: + start_tick = max(0, int(seg.start_beat) * _TPB) + end_tick = max(start_tick + 1, int(seg.end_beat) * _TPB) + else: + start_tick = int(round(seg.start * tempo_bpm / 60.0 * _TPB)) + end_tick = max(start_tick + 1, int(round(seg.end * tempo_bpm / 60.0 * _TPB))) + delta = max(0, start_tick - cursor_ticks) + label = _midi_text(seg.label, "N.C.", 48) + events += _meta(delta, 0x06 if markers else 0x01, label) + cursor_ticks = start_tick + if seg.root is not None and seg.intervals: + notes = _note_numbers(seg.root, seg.intervals) + delta = 0 + for note in notes: + events += _midi_event(delta, 0x90, note, 62) + delta = 0 + duration = max(1, end_tick - start_tick) + for idx, note in enumerate(notes): + events += _midi_event(duration if idx == 0 else 0, 0x80, note, 0) + cursor_ticks = end_tick + else: + events += _meta(max(1, end_tick - cursor_ticks), 0x01, b"") + cursor_ticks = end_tick + events += _meta(0, 0x2F, b"") + + header = b"MThd" + (6).to_bytes(4, "big") + (1).to_bytes(2, "big") + header += (2).to_bytes(2, "big") + _TPB.to_bytes(2, "big") + conductor = b"MTrk" + len(conductor_events).to_bytes(4, "big") + bytes(conductor_events) + track = b"MTrk" + len(events).to_bytes(4, "big") + bytes(events) + path.write_bytes(header + conductor + track) + + +def chord_segments_to_csv(segments: list[ChordSegment]) -> str: + out = StringIO() + writer = csv.writer(out) + writer.writerow(["label", "start_sec", "end_sec", "start_beat", "end_beat", "confidence"]) + for seg in segments: + writer.writerow( + [ + seg.label, + f"{seg.start:.3f}", + f"{seg.end:.3f}", + "" if seg.start_beat is None else seg.start_beat, + "" if seg.end_beat is None else seg.end_beat, + f"{seg.confidence:.3f}", + ] + ) + return out.getvalue() + + +def generate_chord_midi( + job: Job, source: Path, job_dir: Path, *, stems_dir: Path | None = None +) -> Path | None: + try: + segments = detect_chord_segments( + source, + job.beat_times, + duration_sec=job.duration_sec, + stems_dir=stems_dir or (job_dir / "stems"), + key_context=_parse_key_context(job.key), + ) + if not segments: + return None + out = job_dir / "stems" / "chords.mid" + out.parent.mkdir(exist_ok=True) + write_chord_midi(out, segments, bpm=job.bpm, title=job.title) + job.chord_progression = [ + { + "label": seg.label, + "start": seg.start, + "end": seg.end, + "start_beat": seg.start_beat, + "end_beat": seg.end_beat, + "confidence": seg.confidence, + } + for seg in segments + ] + job.chord_midi_url = f"/api/jobs/{job.id}/chords.mid" + logger.info("chord MIDI generated for job %s (%s segments)", job.id, len(segments)) + return out + except Exception: + logger.warning("chord MIDI generation skipped for job %s", job.id, exc_info=True) + return None diff --git a/app/pipeline/collect.py b/app/pipeline/collect.py index 6f99597..4cb37d8 100644 --- a/app/pipeline/collect.py +++ b/app/pipeline/collect.py @@ -1,31 +1,62 @@ from __future__ import annotations +import contextlib import json import logging import shutil import subprocess import time +from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path import numpy as np import soundfile as sf from app.core.config import ( - DEMUCS_MODEL, + BASS_REPAIR_LOW_PASS_HZ, + BASS_REPAIR_MAX_BLEND, + BASS_REPAIR_SHORT_GAP_MS, + BASS_REPAIR_SHORT_GAP_RATIO, + BASS_REPAIR_TRIGGER_RATIO, JOB_TTL_SECONDS, + PHASE_REPAIR_FLOOR_DB, + PHASE_REPAIR_MAX_BLEND, + STEM_GATE_ATTACK_MS, + STEM_GATE_HOLD_MS, + STEM_GATE_RELEASE_MS, + STEM_GATE_THRESHOLD_DB, + STEM_GATE_WINDOW_MS, STEM_NAMES, + STEM_POST_LIMITER_PEAK, TIMEOUT_FFMPEG, + bass_repair_enabled_for_preset, + demucs_settings_for_preset, ffmpeg_executable, + normalize_stem_denoise_preset, + phase_repair_enabled_for_preset, + phase_repair_max_blend_for_preset, + stem_denoise_filter_for_preset, + stem_gate_enabled_for_preset, + wav_codec_for_quality_preset, ) -from app.core.models import Job +from app.core.models import Job, _set from app.core.registry import all_jobs as registry_all from app.core.registry import persist as registry_persist from app.core.registry import remove as registry_remove from app.core.registry import set_proc +from app.pipeline.process import popen_background, terminate_process +from app.pipeline.progress import set_stage_progress logger = logging.getLogger("stemdeck.collect") +@dataclass(frozen=True) +class PhaseRepairResult: + changed: bool + residual_ratio: float | None = None + + def _rmtree(path: Path) -> None: try: shutil.rmtree(path) @@ -45,13 +76,17 @@ def _run_ffmpeg(job: Job, cmd: list[str]) -> bool: but the runner can't see it until subprocess.run returns. With set_proc, the cancel API can call proc.terminate() directly and communicate() returns within ~1s with a non-zero returncode.""" - proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + proc = popen_background( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) set_proc(job.id, proc) try: try: _, stderr = proc.communicate(timeout=TIMEOUT_FFMPEG) except subprocess.TimeoutExpired: - proc.kill() + terminate_process(proc, force=True) proc.communicate() logger.warning("ffmpeg timed out for job %s", job.id) return False @@ -80,17 +115,815 @@ def collect(job: Job, stems_root: Path, job_dir: Path) -> list[str]: target_dir = job_dir / "stems" target_dir.mkdir(exist_ok=True) found: list[str] = [] - for name in STEM_NAMES: + existing = [name for name in STEM_NAMES if (stems_root / f"{name}.wav").exists()] + total = max(1, len(existing)) + for idx, name in enumerate(existing): src = stems_root / f"{name}.wav" - if src.exists(): - shutil.move(str(src), target_dir / f"{name}.wav") - found.append(name) - _rmtree(job_dir / DEMUCS_MODEL) + set_stage_progress( + job, + "collect", + idx / total, + stage=f"Collecting stem {idx + 1}/{total}: {name}", + ) + shutil.move(str(src), target_dir / f"{name}.wav") + found.append(name) + set_stage_progress( + job, + "collect", + (idx + 1) / (total + 1), + stage=f"Collected stem {idx + 1}/{total}: {name}", + ) + set_stage_progress(job, "collect", 0.92, stage="Cleaning separation workspace...") + _rmtree(job_dir / demucs_settings_for_preset(job.quality_preset).model) if not found: raise RuntimeError("no stems produced by demucs") return found +def restore_demucs_gain(job: Job, stems_dir: Path, stem_names: list[str]) -> None: + """Undo any pre-Demucs gain applied to the working copy. + + Quality presets may feed Demucs a quieter file to reduce clipping-like + artifacts on hot masters. The model output follows that lower level, so + restore the inverse gain before downstream waveform, mix, and download + artifacts are generated. + """ + settings = demucs_settings_for_preset(job.quality_preset) + applied_gain_db = ( + float(job.demucs_gain_db) if job.demucs_gain_db is not None else settings.pre_gain_db + ) + if abs(applied_gain_db) < 0.001: + return + + restore_db = -applied_gain_db + old_stage = job.stage_message + _set(job, stage="Restoring stem levels...") + available = [name for name in stem_names if (stems_dir / f"{name}.wav").is_file()] + total = max(1, len(available)) + for idx, name in enumerate(available): + path = stems_dir / f"{name}.wav" + set_stage_progress( + job, + "restore_gain", + idx / total, + stage=f"Restoring stem levels {idx + 1}/{total}: {name}", + ) + tmp = path.with_suffix(".gain.wav") + cmd = [ + ffmpeg_executable(), + "-y", + "-nostdin", + "-loglevel", + "error", + "-i", + str(path), + "-filter:a", + f"volume={restore_db:g}dB", + "-c:a", + "pcm_f32le", + str(tmp), + ] + if not _run_ffmpeg(job, cmd): + tmp.unlink(missing_ok=True) + raise RuntimeError(f"ffmpeg gain restore failed for {name}") + tmp.replace(path) + set_stage_progress( + job, + "restore_gain", + (idx + 1) / total, + stage=f"Restored stem levels {idx + 1}/{total}: {name}", + ) + _set(job, stage=old_stage) + + +def _write_bass_residual_candidate( + job: Job, source: Path, stems_dir: Path, stem_names: list[str], out: Path +) -> bool: + """Render a low-passed residual candidate: source minus non-bass stems.""" + non_bass = [ + name for name in stem_names if name != "bass" and (stems_dir / f"{name}.wav").is_file() + ] + if not non_bass: + return False + + cmd: list[str] = [ + ffmpeg_executable(), + "-y", + "-nostdin", + "-loglevel", + "error", + "-i", + str(source), + ] + for name in non_bass: + cmd += ["-i", str(stems_dir / f"{name}.wav")] + + filter_parts = [f"[{idx}:a]volume=-1.0[neg{idx}]" for idx in range(1, len(non_bass) + 1)] + mix_inputs = "[0:a]" + "".join(f"[neg{idx}]" for idx in range(1, len(non_bass) + 1)) + filter_parts.append( + f"{mix_inputs}amix=inputs={len(non_bass) + 1}:normalize=0," + f"lowpass=f={BASS_REPAIR_LOW_PASS_HZ:g}[repair]" + ) + cmd += [ + "-filter_complex", + ";".join(filter_parts), + "-map", + "[repair]", + "-ar", + "44100", + "-ac", + "2", + "-c:a", + "pcm_f32le", + str(out), + ] + return _run_ffmpeg(job, cmd) + + +def _moving_average(values: np.ndarray, window: int) -> np.ndarray: + if values.size == 0: + return values + window = max(1, min(window, values.size)) + left = window // 2 + right = window - 1 - left + padded = np.pad(values, (left, right), mode="edge") + cumsum = np.cumsum(np.insert(padded, 0, 0.0, axis=0), dtype=np.float64) + return ((cumsum[window:] - cumsum[:-window]) / window).astype(np.float32) + + +def _rms_envelope(block: np.ndarray, window: int) -> np.ndarray: + mono_power = np.mean(block * block, axis=1, dtype=np.float32) + return np.sqrt(_moving_average(mono_power, window) + 1e-12) + + +def _boolean_moving_fill(mask: np.ndarray, window: int) -> np.ndarray: + if mask.size == 0: + return mask + smoothed = _moving_average(mask.astype(np.float32), window) + return smoothed > 0.08 + + +def _match_channels(block: np.ndarray, channels: int) -> np.ndarray: + if block.shape[1] == channels: + return block + if block.shape[1] == 1: + return np.repeat(block, channels, axis=1) + return block[:, :channels] + + +def _blend_bass_dropout_repair( + bass_path: Path, + residual_path: Path, + out_path: Path, + *, + max_blend: float = BASS_REPAIR_MAX_BLEND, + trigger_ratio: float = BASS_REPAIR_TRIGGER_RATIO, + subtype: str = "FLOAT", + progress_callback: Callable[[float], None] | None = None, +) -> bool: + """Blend low-passed residual into bass only where the bass stem drops out. + + This is deliberately conservative: the residual candidate is derived from + the original mix, then low-passed by ffmpeg. A smoothed energy comparison + gates the blend so normal bass passages are left untouched. + """ + changed = False + blocksize = 262_144 + floor = 10 ** (-54 / 20) + + with ( + sf.SoundFile(bass_path) as bass_file, + sf.SoundFile(residual_path) as residual_file, + ): + if bass_file.samplerate != residual_file.samplerate: + logger.warning( + "skip bass repair for %s: sample-rate mismatch %s != %s", + bass_path, + bass_file.samplerate, + residual_file.samplerate, + ) + return False + + channels = bass_file.channels + total_frames = max(1, bass_file.frames) + processed_frames = 0 + window = max(128, int(bass_file.samplerate * 0.028)) + smooth = max(64, int(bass_file.samplerate * 0.012)) + short_gap = max(16, int(bass_file.samplerate * (BASS_REPAIR_SHORT_GAP_MS / 1000))) + + with sf.SoundFile( + out_path, + mode="w", + samplerate=bass_file.samplerate, + channels=channels, + subtype=subtype, + ) as out_file: + while True: + bass_block = bass_file.read(blocksize, dtype="float32", always_2d=True) + if bass_block.size == 0: + break + residual_block = residual_file.read( + len(bass_block), dtype="float32", always_2d=True + ) + if len(residual_block) < len(bass_block): + pad = np.zeros( + (len(bass_block) - len(residual_block), residual_block.shape[1]), + dtype=np.float32, + ) + residual_block = np.vstack((residual_block, pad)) + residual_block = _match_channels(residual_block, channels) + + bass_env = _rms_envelope(bass_block, window) + residual_env = _rms_envelope(residual_block, window) + deficit = residual_env - (bass_env * trigger_ratio) + weight = np.clip(deficit / (residual_env + 1e-8), 0.0, max_blend) + short_gap_mask = _boolean_moving_fill( + (residual_env > floor) & (bass_env * BASS_REPAIR_SHORT_GAP_RATIO < residual_env), + short_gap, + ) + short_gap_weight = np.where(short_gap_mask, max_blend * 0.72, 0.0) + weight = np.maximum(weight, short_gap_weight) + weight = np.where(residual_env > floor, weight, 0.0).astype(np.float32) + weight = _moving_average(weight, smooth) + + if float(np.max(weight, initial=0.0)) > 0.001: + changed = True + repaired = bass_block + (residual_block * weight[:, None]) + repaired = _soft_limit_block(repaired) + out_file.write(repaired) + processed_frames += len(bass_block) + if progress_callback is not None: + progress_callback(min(1.0, processed_frames / total_frames)) + + return changed + + +def _soft_limit_block(block: np.ndarray, peak: float = STEM_POST_LIMITER_PEAK) -> np.ndarray: + """Clamp only unsafe overs after repair/mix math while preserving float32 output.""" + if block.size == 0: + return block + current = float(np.max(np.abs(block), initial=0.0)) + if current <= peak: + return block.astype(np.float32, copy=False) + return np.clip(block * (peak / current), -peak, peak).astype(np.float32, copy=False) + + +def stabilize_stem_outputs(job: Job, stems_dir: Path, stem_names: list[str]) -> None: + """Remove DC offset and prevent accidental clipping in generated stems. + + Demucs + residual repair are intentionally float-heavy. This pass keeps the + files as float for high-quality presets, but subtracts tiny DC offsets and + rescales only files that exceed the configured safety peak. + """ + if not demucs_settings_for_preset(job.quality_preset).float32: + return + old_stage = job.stage_message + _set(job, stage="Stabilizing stems...") + try: + for name in stem_names: + path = stems_dir / f"{name}.wav" + if not path.is_file(): + continue + tmp = path.with_suffix(".stable.wav") + should_replace = False + with sf.SoundFile(path) as src: + subtype = "FLOAT" + channels = src.channels + samplerate = src.samplerate + blocksize = 262_144 + sums = np.zeros(channels, dtype=np.float64) + frames = 0 + peak = 0.0 + while True: + block = src.read(blocksize, dtype="float32", always_2d=True) + if block.size == 0: + break + sums += np.sum(block, axis=0, dtype=np.float64) + frames += len(block) + peak = max(peak, float(np.max(np.abs(block), initial=0.0))) + if frames == 0: + continue + dc = (sums / frames).astype(np.float32) + needs_dc = float(np.max(np.abs(dc), initial=0.0)) > 1e-5 + gain = min(1.0, STEM_POST_LIMITER_PEAK / peak) if peak > 0 else 1.0 + needs_gain = gain < 0.9999 + if not needs_dc and not needs_gain: + continue + + src.seek(0) + with sf.SoundFile( + tmp, + mode="w", + samplerate=samplerate, + channels=channels, + subtype=subtype, + ) as out: + while True: + block = src.read(blocksize, dtype="float32", always_2d=True) + if block.size == 0: + break + if needs_dc: + block = block - dc[None, :] + if needs_gain: + block = block * gain + out.write(block.astype(np.float32, copy=False)) + should_replace = True + if should_replace: + tmp.replace(path) + logger.info("stabilized stem %s for job %s", name, job.id) + finally: + for tmp in stems_dir.glob("*.stable.wav"): + tmp.unlink(missing_ok=True) + _set(job, stage=old_stage) + + +def repair_bass_dropouts(job: Job, source: Path, stems_dir: Path, stem_names: list[str]) -> bool: + """Repair short bass dropouts using a low-frequency residual candidate. + + Demucs can occasionally under-estimate bass on dense, hot masters. For + high-quality jobs, derive a low-passed residual from the original mix minus + the other stems and blend it only into sections where bass energy is + suspiciously absent. + """ + if not bass_repair_enabled_for_preset(job.quality_preset): + return False + if "bass" not in stem_names or not (stems_dir / "bass.wav").is_file(): + return False + + old_stage = job.stage_message + residual_path = stems_dir / "bass.residual.wav" + repaired_path = stems_dir / "bass.repaired.wav" + _set(job, stage="Repairing bass dropouts...") + try: + if not _write_bass_residual_candidate(job, source, stems_dir, stem_names, residual_path): + return False + set_stage_progress(job, "bass_repair", 0.35, stage="Blending bass repair...") + subtype = ( + "FLOAT" if wav_codec_for_quality_preset(job.quality_preset) == "pcm_f32le" else "PCM_16" + ) + if not _blend_bass_dropout_repair( + stems_dir / "bass.wav", + residual_path, + repaired_path, + subtype=subtype, + progress_callback=lambda fraction: set_stage_progress( + job, + "bass_repair", + 0.35 + (0.6 * fraction), + stage=f"Blending bass repair {round(fraction * 100)}%", + ), + ): + return False + repaired_path.replace(stems_dir / "bass.wav") + logger.info("bass dropout repair applied for job %s", job.id) + return True + except Exception: + logger.warning("bass dropout repair skipped for job %s", job.id, exc_info=True) + return False + finally: + residual_path.unlink(missing_ok=True) + repaired_path.unlink(missing_ok=True) + _set(job, stage=old_stage) + + +def _write_phase_reference(job: Job, source: Path, out: Path) -> bool: + """Render the original source into the same float/stereo space as stems.""" + cmd = [ + ffmpeg_executable(), + "-y", + "-nostdin", + "-loglevel", + "error", + "-i", + str(source), + "-filter:a", + "aresample=44100,aformat=sample_fmts=flt:channel_layouts=stereo,highpass=f=12", + "-ar", + "44100", + "-ac", + "2", + "-c:a", + "pcm_f32le", + str(out), + ] + return _run_ffmpeg(job, cmd) + + +def _pad_block(block: np.ndarray, frames: int) -> np.ndarray: + if len(block) >= frames: + return block + channels = block.shape[1] if block.ndim == 2 and block.shape[1] > 0 else 1 + pad = np.zeros((frames - len(block), channels), dtype=np.float32) + return np.vstack((block, pad)) + + +def _blend_phase_residual( + reference_path: Path, + stems_dir: Path, + stem_names: list[str], + out_paths: list[Path], + *, + max_blend: float = PHASE_REPAIR_MAX_BLEND, + floor_db: float = PHASE_REPAIR_FLOOR_DB, + subtype: str = "FLOAT", + progress_callback: Callable[[float], None] | None = None, +) -> PhaseRepairResult: + """Distribute source-minus-stem-sum residual back into active stems. + + The correction is energy-weighted instead of copied into every stem. That + keeps the summed playback closer to the source while limiting bleed in + isolated stems. + """ + if max_blend <= 0 or len(stem_names) != len(out_paths): + return PhaseRepairResult(False) + + changed = False + before_power = 0.0 + after_power = 0.0 + blocksize = 262_144 + floor = 10 ** (floor_db / 20) + + with contextlib.ExitStack() as stack: + reference_file = stack.enter_context(sf.SoundFile(reference_path)) + samplerate = reference_file.samplerate + channels = reference_file.channels + total_frames = max(1, reference_file.frames) + processed_frames = 0 + + stem_files: list[sf.SoundFile] = [] + for name in stem_names: + path = stems_dir / f"{name}.wav" + stem_file = stack.enter_context(sf.SoundFile(path)) + if stem_file.samplerate != samplerate: + logger.warning( + "skip phase repair: sample-rate mismatch for %s (%s != %s)", + path, + stem_file.samplerate, + samplerate, + ) + return PhaseRepairResult(False) + stem_files.append(stem_file) + + out_files = [ + stack.enter_context( + sf.SoundFile( + out, + mode="w", + samplerate=samplerate, + channels=channels, + subtype=subtype, + ) + ) + for out in out_paths + ] + + window = max(128, int(samplerate * 0.026)) + smooth = max(64, int(samplerate * 0.010)) + + while True: + reference = reference_file.read(blocksize, dtype="float32", always_2d=True) + if reference.size == 0: + break + reference = _match_channels(reference, channels) + frames = len(reference) + + stem_blocks = [] + envelopes = [] + for stem_file in stem_files: + block = stem_file.read(frames, dtype="float32", always_2d=True) + block = _match_channels(_pad_block(block, frames), channels) + stem_blocks.append(block) + envelopes.append(_rms_envelope(block, window)) + + stem_sum = np.sum(np.stack(stem_blocks, axis=0), axis=0, dtype=np.float32) + residual = reference - stem_sum + before_power += float(np.sum(residual * residual, dtype=np.float64)) + residual_env = _rms_envelope(residual, window) + env_matrix = np.stack(envelopes, axis=1) + env_sum = np.sum(env_matrix, axis=1, dtype=np.float32) + active = (residual_env > floor) & (env_sum > floor) + + repaired_sum = np.zeros_like(reference, dtype=np.float32) + for idx, block in enumerate(stem_blocks): + weight = np.where(active, env_matrix[:, idx] / (env_sum + 1e-8), 0.0) + weight = _moving_average(weight.astype(np.float32), smooth) + correction = residual * (weight[:, None] * max_blend) + if float(np.max(np.abs(correction), initial=0.0)) > 1e-5: + changed = True + repaired = _soft_limit_block(block + correction) + repaired_sum += repaired + out_files[idx].write(repaired) + after_residual = reference - repaired_sum + after_power += float(np.sum(after_residual * after_residual, dtype=np.float64)) + processed_frames += frames + if progress_callback is not None: + progress_callback(min(1.0, processed_frames / total_frames)) + + ratio = after_power / before_power if before_power > 1e-18 else None + return PhaseRepairResult(changed, ratio) + + +def repair_phase_coherence( + job: Job, + source: Path, + job_dir: Path, + stems_dir: Path, + stem_names: list[str], +) -> bool: + """Reduce stem-sum phase/residual mismatch against the original source.""" + if not phase_repair_enabled_for_preset(job.quality_preset): + return False + available = [name for name in stem_names if (stems_dir / f"{name}.wav").is_file()] + if len(available) < 2: + return False + + old_stage = job.stage_message + reference_path = job_dir / "source.phase.wav" + tmp_paths = [stems_dir / f"{name}.phase.wav" for name in available] + _set(job, stage="Repairing phase coherence...") + try: + if not _write_phase_reference(job, source, reference_path): + return False + set_stage_progress(job, "phase_repair", 0.25, stage="Blending phase correction...") + subtype = ( + "FLOAT" if wav_codec_for_quality_preset(job.quality_preset) == "pcm_f32le" else "PCM_16" + ) + result = _blend_phase_residual( + reference_path, + stems_dir, + available, + tmp_paths, + max_blend=phase_repair_max_blend_for_preset(job.quality_preset), + subtype=subtype, + progress_callback=lambda fraction: set_stage_progress( + job, + "phase_repair", + 0.25 + (0.7 * fraction), + stage=f"Blending phase correction {round(fraction * 100)}%", + ), + ) + job.phase_repair_residual_ratio = result.residual_ratio + if not result.changed: + return False + for name, tmp in zip(available, tmp_paths, strict=False): + tmp.replace(stems_dir / f"{name}.wav") + job.phase_repair_applied = True + logger.info("phase coherence repair applied for job %s", job.id) + return True + except Exception: + logger.warning("phase coherence repair skipped for job %s", job.id, exc_info=True) + return False + finally: + reference_path.unlink(missing_ok=True) + for tmp in tmp_paths: + tmp.unlink(missing_ok=True) + _set(job, stage=old_stage) + + +def denoise_stem_outputs(job: Job, stems_dir: Path, stem_names: list[str]) -> bool: + """Optionally denoise each separated stem with ffmpeg's afftdn filter. + + The pass is all-or-nothing: all temporary denoised files must render before + any original stem is replaced. If ffmpeg cannot denoise a stem, the job + keeps the original separation and records stem_denoise_applied=False. + """ + preset = normalize_stem_denoise_preset(job.stem_denoise_preset) + job.stem_denoise_preset = preset + filter_expr = stem_denoise_filter_for_preset(preset) + if not filter_expr: + return False + + available = [name for name in stem_names if (stems_dir / f"{name}.wav").is_file()] + if not available: + return False + + old_stage = job.stage_message + tmp_pairs: list[tuple[Path, Path]] = [] + wav_codec = wav_codec_for_quality_preset(job.quality_preset) + _set(job, stage=f"Denoising stems ({preset})...") + try: + total = max(1, len(available)) + for idx, name in enumerate(available): + path = stems_dir / f"{name}.wav" + tmp = path.with_suffix(".denoise.wav") + tmp_pairs.append((path, tmp)) + set_stage_progress( + job, + "denoise", + idx / total, + stage=f"Denoising stem {idx + 1}/{total}: {name}", + ) + cmd = [ + ffmpeg_executable(), + "-y", + "-nostdin", + "-loglevel", + "error", + "-i", + str(path), + "-filter:a", + filter_expr, + "-ar", + "44100", + "-ac", + "2", + "-c:a", + wav_codec, + str(tmp), + ] + if not _run_ffmpeg(job, cmd): + return False + set_stage_progress( + job, + "denoise", + (idx + 1) / total, + stage=f"Denoised stem {idx + 1}/{total}: {name}", + ) + for path, tmp in tmp_pairs: + tmp.replace(path) + logger.info("stem denoise %s applied for job %s", preset, job.id) + return True + except Exception: + logger.warning("stem denoise skipped for job %s", job.id, exc_info=True) + return False + finally: + for _, tmp in tmp_pairs: + tmp.unlink(missing_ok=True) + _set(job, stage=old_stage) + + +def _iter_true_runs(mask: np.ndarray): + if mask.size == 0: + return + padded = np.concatenate(([False], mask.astype(bool, copy=False), [False])) + changes = np.diff(padded.astype(np.int8)) + starts = np.flatnonzero(changes == 1) + ends = np.flatnonzero(changes == -1) + yield from zip(starts, ends, strict=False) + + +def _gate_gain_from_mask( + active: np.ndarray, + *, + window_ms: int = STEM_GATE_WINDOW_MS, + hold_ms: int = STEM_GATE_HOLD_MS, + attack_ms: int = STEM_GATE_ATTACK_MS, + release_ms: int = STEM_GATE_RELEASE_MS, +) -> np.ndarray: + """Build per-envelope-frame gain so gate edges fade instead of clicking.""" + if active.size == 0: + return np.array([], dtype=np.float32) + active = active.astype(bool, copy=False) + expanded = active.copy() + hold_frames = max(0, int(np.ceil(hold_ms / max(1, window_ms)))) + if hold_frames: + for start, end in _iter_true_runs(active): + expanded[max(0, start - hold_frames) : min(active.size, end + hold_frames)] = True + + gain = np.zeros(active.size, dtype=np.float32) + attack_frames = max(1, int(np.ceil(attack_ms / max(1, window_ms)))) + release_frames = max(1, int(np.ceil(release_ms / max(1, window_ms)))) + for start, end in _iter_true_runs(expanded): + gain[start:end] = 1.0 + attack_start = max(0, start - attack_frames) + if start > attack_start: + fade = np.linspace(0.0, 1.0, start - attack_start, endpoint=False, dtype=np.float32) + gain[attack_start:start] = np.maximum(gain[attack_start:start], fade) + release_end = min(active.size, end + release_frames) + if release_end > end: + fade = np.linspace(1.0, 0.0, release_end - end, endpoint=False, dtype=np.float32) + gain[end:release_end] = np.maximum(gain[end:release_end], fade) + return gain + + +def _read_gate_envelope(path: Path, frame_len: int) -> np.ndarray: + envelopes: list[np.ndarray] = [] + blocksize = max(frame_len, frame_len * 512) + with sf.SoundFile(path) as src: + while True: + block = src.read(blocksize, dtype="float32", always_2d=True) + if block.size == 0: + break + remainder = len(block) % frame_len + if remainder: + pad = np.zeros((frame_len - remainder, block.shape[1]), dtype=np.float32) + block = np.vstack((block, pad)) + frames = block.reshape(-1, frame_len, block.shape[1]) + rms = np.sqrt(np.mean(frames * frames, axis=(1, 2), dtype=np.float64) + 1e-12) + envelopes.append(rms.astype(np.float32)) + if not envelopes: + return np.array([], dtype=np.float32) + return np.concatenate(envelopes) + + +def _write_gated_stem( + path: Path, + out_path: Path, + *, + threshold_db: float = STEM_GATE_THRESHOLD_DB, + subtype: str = "FLOAT", +) -> bool: + threshold = 10 ** (threshold_db / 20) + with sf.SoundFile(path) as src: + samplerate = src.samplerate + channels = src.channels + frame_len = max(64, int(samplerate * (STEM_GATE_WINDOW_MS / 1000))) + + envelope = _read_gate_envelope(path, frame_len) + if envelope.size == 0: + return False + active = envelope >= threshold + gain = _gate_gain_from_mask(active) + if gain.size == 0 or float(np.min(gain, initial=1.0)) >= 0.999: + return False + + blocksize = max(frame_len, frame_len * 512) + cursor = 0 + with ( + sf.SoundFile(path) as src, + sf.SoundFile( + out_path, + mode="w", + samplerate=samplerate, + channels=channels, + subtype=subtype, + ) as out, + ): + while True: + block = src.read(blocksize, dtype="float32", always_2d=True) + if block.size == 0: + break + start_frame = cursor // frame_len + end_frame = min(gain.size, (cursor + len(block) + frame_len - 1) // frame_len) + frame_gain = np.repeat(gain[start_frame:end_frame], frame_len) + offset = cursor - (start_frame * frame_len) + sample_gain = frame_gain[offset : offset + len(block)] + if sample_gain.size < len(block): + sample_gain = np.pad( + sample_gain, + (0, len(block) - sample_gain.size), + constant_values=float(gain[-1]), + ) + out.write((block * sample_gain[:, None]).astype(np.float32, copy=False)) + cursor += len(block) + return True + + +def gate_stem_outputs(job: Job, stems_dir: Path, stem_names: list[str]) -> bool: + """Mute near-silent stem bleed without shortening any stem files. + + The gate is deliberately post-separation and timeline-preserving: it zeros + quiet regions with short fades, rather than removing samples. That keeps all + stems aligned for playback/export while reducing residual hiss and bleed. + """ + if not stem_gate_enabled_for_preset(job.quality_preset): + return False + + available = [name for name in stem_names if (stems_dir / f"{name}.wav").is_file()] + if not available: + return False + + old_stage = job.stage_message + tmp_pairs: list[tuple[Path, Path]] = [] + subtype = "FLOAT" if wav_codec_for_quality_preset(job.quality_preset) == "pcm_f32le" else "PCM_16" + _set(job, stage="Gating near-silent stem bleed...") + try: + total = max(1, len(available)) + for idx, name in enumerate(available): + path = stems_dir / f"{name}.wav" + tmp = path.with_suffix(".gate.wav") + set_stage_progress( + job, + "gate", + idx / total, + stage=f"Gating stem {idx + 1}/{total}: {name}", + ) + if _write_gated_stem(path, tmp, threshold_db=STEM_GATE_THRESHOLD_DB, subtype=subtype): + tmp_pairs.append((path, tmp)) + else: + tmp.unlink(missing_ok=True) + set_stage_progress( + job, + "gate", + (idx + 1) / total, + stage=f"Checked stem gate {idx + 1}/{total}: {name}", + ) + if not tmp_pairs: + return False + for path, tmp in tmp_pairs: + tmp.replace(path) + job.stem_gate_threshold_db = STEM_GATE_THRESHOLD_DB + logger.info("stem gate applied to %s stem(s) for job %s", len(tmp_pairs), job.id) + return True + except Exception: + logger.warning("stem gate skipped for job %s", job.id, exc_info=True) + return False + finally: + for _, tmp in tmp_pairs: + tmp.unlink(missing_ok=True) + _set(job, stage=old_stage) + + def cleanup_source(job_dir: Path) -> None: """Delete the source audio file. Called after collect AND after any post-processing that re-encodes the source (make_original_track). @@ -116,6 +949,7 @@ def make_original_track(job: Job, job_dir: Path, stems_dir: Path) -> Path | None if not inputs: return None out = stems_dir / "original.wav" + wav_codec = wav_codec_for_quality_preset(job.quality_preset) cmd: list[str] = [ ffmpeg_executable(), "-y", @@ -129,14 +963,14 @@ def make_original_track(job: Job, job_dir: Path, stems_dir: Path) -> Path | None # Single complement stem -- copy as-is so we still produce a # canonical mix.wav-shaped output without invoking amix on a # 1-input graph (which is a no-op anyway). - cmd += ["-c:a", "pcm_s16le", str(out)] + cmd += ["-c:a", wav_codec, str(out)] else: filter_inputs = "".join(f"[{i}:a]" for i in range(len(inputs))) cmd += [ "-filter_complex", f"{filter_inputs}amix=inputs={len(inputs)}:normalize=0", "-c:a", - "pcm_s16le", + wav_codec, str(out), ] return out if _run_ffmpeg(job, cmd) else None @@ -163,6 +997,7 @@ def make_selected_mix(job: Job, stems_dir: Path, found: list[str]) -> Path | Non return stems_dir / f"{selected[0]}.wav" inputs = [stems_dir / f"{name}.wav" for name in selected] out = stems_dir / "mix.wav" + wav_codec = wav_codec_for_quality_preset(job.quality_preset) cmd: list[str] = [ ffmpeg_executable(), "-y", @@ -177,7 +1012,7 @@ def make_selected_mix(job: Job, stems_dir: Path, found: list[str]) -> Path | Non "-filter_complex", f"{filter_inputs}amix=inputs={len(inputs)}:normalize=0", "-c:a", - "pcm_s16le", + wav_codec, str(out), ] return out if _run_ffmpeg(job, cmd) else None @@ -249,5 +1084,9 @@ def sweep_old_jobs(jobs_dir: Path) -> None: _rmtree(d) registry_remove(d.name) removed = True + for job_id, job in jobs.items(): + if job.status in _TERMINAL and job.created_at < cutoff and not (jobs_dir / job_id).exists(): + registry_remove(job_id) + removed = True if removed: registry_persist(jobs_dir) diff --git a/app/pipeline/download.py b/app/pipeline/download.py index 2c759d4..c3cdf8e 100644 --- a/app/pipeline/download.py +++ b/app/pipeline/download.py @@ -10,6 +10,7 @@ from app.core.config import MAX_DURATION_SEC from app.core.models import Job, JobCancelled, _set +from app.pipeline.progress import set_stage_progress logger = logging.getLogger("stemdeck.download") @@ -151,7 +152,7 @@ def normalize_youtube_url(url: str) -> str: def download(job: Job, url: str, job_dir: Path) -> Path: url = normalize_youtube_url(url) logger.info("[%s] download starting: %s", job.id, url) - _set(job, status="downloading", progress=0.0, stage="Processing...") + set_stage_progress(job, "acquire", 0.0, status="downloading", stage="Fetching metadata...") # Fetch metadata first (no download) so we can reject videos that are # too long before wasting bandwidth and disk. @@ -173,9 +174,9 @@ def hook(d: dict) -> None: total = d.get("total_bytes") or d.get("total_bytes_estimate") if total: p = float(d.get("downloaded_bytes", 0)) / float(total) - _set(job, progress=p, stage=f"Downloading {int(p * 100)}%") + set_stage_progress(job, "acquire", p, stage=f"Downloading {int(p * 100)}%") elif d.get("status") == "finished": - _set(job, progress=1.0, stage="Download complete") + set_stage_progress(job, "acquire", 1.0, stage="Download complete") # No postprocessors -- Demucs reads the raw audio container (webm/m4a/opus/...) # directly via torchaudio + ffmpeg. Skipping the WAV transcode saves the slowest diff --git a/app/pipeline/process.py b/app/pipeline/process.py new file mode 100644 index 0000000..eb152c9 --- /dev/null +++ b/app/pipeline/process.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import logging +import os +import signal +import subprocess +from dataclasses import dataclass +from typing import BinaryIO + +from app.core.config import ( + BACKGROUND_CPU_THREADS, + BACKGROUND_PROCESS_NICE, + BACKGROUND_PROCESS_PRIORITY, +) +from app.core.models import Job, JobCancelled +from app.core.registry import set_proc + +logger = logging.getLogger("stemdeck.pipeline.process") + + +@dataclass(frozen=True) +class ProcessResult: + returncode: int + stdout: bytes + stderr: bytes + + +def background_process_env(base: dict[str, str] | None = None) -> dict[str, str]: + """Environment for long-running local audio workers. + + Restricting BLAS/OpenMP worker counts preserves UI responsiveness while the + extraction still runs in a separate process. Existing explicit env choices + win, so power users can override these knobs. + """ + env = dict(base or os.environ) + threads = str(max(1, int(BACKGROUND_CPU_THREADS))) + for name in ( + "OMP_NUM_THREADS", + "OPENBLAS_NUM_THREADS", + "MKL_NUM_THREADS", + "VECLIB_MAXIMUM_THREADS", + "NUMEXPR_NUM_THREADS", + "TORCH_NUM_THREADS", + ): + env.setdefault(name, threads) + return env + + +def lower_process_priority(proc: subprocess.Popen) -> None: + """Best-effort background priority for a child process. + + On macOS/Linux we can renice the child from the parent without using + preexec_fn, avoiding the usual thread-safety caveat. On platforms where + this is unavailable, silently keep the normal priority. + """ + if not BACKGROUND_PROCESS_PRIORITY: + return + try: + if hasattr(os, "setpriority") and hasattr(os, "PRIO_PROCESS"): + os.setpriority(os.PRIO_PROCESS, proc.pid, BACKGROUND_PROCESS_NICE) + except Exception: + logger.debug("could not lower process priority for pid %s", proc.pid, exc_info=True) + + +def popen_background( + cmd: list[str], + *, + stdout: int | BinaryIO | None = None, + stderr: int | BinaryIO | None = None, + text: bool = False, + bufsize: int = -1, + env: dict[str, str] | None = None, +) -> subprocess.Popen: + kwargs: dict[str, object] = { + "stdout": stdout, + "stderr": stderr, + "text": text, + "bufsize": bufsize, + "env": background_process_env(env), + } + if os.name == "nt": + kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + else: + kwargs["start_new_session"] = True + proc = subprocess.Popen(cmd, **kwargs) + lower_process_priority(proc) + return proc + + +def terminate_process(proc: subprocess.Popen, *, force: bool = False) -> None: + if proc.poll() is not None: + return + try: + if os.name != "nt" and os.getpgid(proc.pid) == proc.pid: + os.killpg(proc.pid, signal.SIGKILL if force else signal.SIGTERM) + elif force: + proc.kill() + else: + proc.terminate() + except OSError: + if proc.poll() is None: + try: + proc.kill() if force else proc.terminate() + except OSError: + pass + + +def run_tracked_process( + job: Job, + cmd: list[str], + *, + timeout: float, + env: dict[str, str] | None = None, +) -> ProcessResult: + proc = popen_background( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + set_proc(job.id, proc) + try: + try: + stdout, stderr = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + terminate_process(proc, force=True) + stdout, stderr = proc.communicate() + raise + finally: + set_proc(job.id, None) + if job.cancel_requested: + raise JobCancelled() + return ProcessResult( + proc.returncode or 0, + stdout if isinstance(stdout, bytes) else b"", + stderr if isinstance(stderr, bytes) else b"", + ) diff --git a/app/pipeline/progress.py b/app/pipeline/progress.py new file mode 100644 index 0000000..1e3b4fd --- /dev/null +++ b/app/pipeline/progress.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from app.core.joblog import add_job_log +from app.core.models import Job, JobStatus, _set + +# Overall job progress bands. Individual tools such as yt-dlp and Demucs report +# their own 0-100%, but the UI needs a single monotonic timeline for the whole +# pipeline so users can tell when the job is actually close to done. +_STAGE_RANGES: dict[str, tuple[float, float]] = { + "acquire": (0.02, 0.12), + "analyze": (0.12, 0.24), + "prepare_separation": (0.24, 0.30), + "separate": (0.30, 0.82), + "collect": (0.82, 0.84), + "restore_gain": (0.84, 0.86), + "bass_repair": (0.86, 0.89), + "phase_repair": (0.89, 0.92), + "denoise": (0.92, 0.95), + "gate": (0.95, 0.96), + "stabilize": (0.96, 0.97), + "presence": (0.97, 0.978), + "chords": (0.978, 0.985), + "mix": (0.985, 0.99), + "peaks": (0.99, 0.995), +} + + +def _clamp01(value: float | int | None) -> float: + try: + v = float(value if value is not None else 0.0) + except (TypeError, ValueError): + return 0.0 + return max(0.0, min(1.0, v)) + + +def set_stage_progress( + job: Job, + stage_key: str, + fraction: float | int | None = 0.0, + *, + status: JobStatus | None = None, + stage: str | None = None, +) -> None: + """Set monotonic overall progress for a pipeline stage. + + `fraction` is local to the stage (0.0-1.0). It is mapped into the fixed + whole-pipeline band above, then clamped so later stages never make the + progress bar jump backwards. + """ + if stage_key not in _STAGE_RANGES: + raise KeyError(f"unknown pipeline progress stage: {stage_key}") + start, end = _STAGE_RANGES[stage_key] + overall = start + ((end - start) * _clamp01(fraction)) + fields: dict[str, object] = {"progress": max(float(job.progress or 0.0), overall)} + if status is not None: + fields["status"] = status + if stage is not None: + fields["stage"] = stage + _set(job, **fields) + if stage is not None: + add_job_log( + job, + stage, + stage=stage_key, + progress=overall, + ) diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py index 34f36ac..d01d863 100644 --- a/app/pipeline/runner.py +++ b/app/pipeline/runner.py @@ -1,24 +1,45 @@ from __future__ import annotations import asyncio +import contextlib import json import logging +import os import shutil -import subprocess +import tempfile +import time from pathlib import Path -from app.core.config import TIMEOUT_FFMPEG +from app.core.config import ( + PIPELINE_CONCURRENCY, + STEM_PREPROCESS_TARGET_I, + STEM_PREPROCESS_TRUE_PEAK, + TIMEOUT_FFMPEG, + demucs_settings_for_preset, + ffmpeg_executable, +) +from app.core.files import atomic_write_text +from app.core.joblog import add_job_log from app.core.models import Job, JobCancelled, _set from app.core.registry import persist as persist_registry from app.pipeline.analyze import analyze, compute_stem_presence +from app.pipeline.chords import generate_chord_midi from app.pipeline.collect import ( cleanup_source, collect, compute_stem_peaks, + denoise_stem_outputs, + gate_stem_outputs, make_original_track, make_selected_mix, + repair_bass_dropouts, + repair_phase_coherence, + restore_demucs_gain, + stabilize_stem_outputs, ) from app.pipeline.download import download +from app.pipeline.process import run_tracked_process +from app.pipeline.progress import set_stage_progress from app.pipeline.separate import separate logger = logging.getLogger("stemdeck.pipeline") @@ -33,8 +54,101 @@ def _rmtree(path: Path) -> None: logger.warning("failed to remove %s", path, exc_info=True) -# Only one heavy job runs at a time -- Demucs is GPU/CPU-hungry. -_pipeline_lock = asyncio.Semaphore(1) +# Limit heavy pipeline parallelism by detected local capacity inside one +# backend process. A second file lock below also coordinates multiple local +# LayerLab backends, for example dev server + packaged desktop app. +_pipeline_lock = asyncio.Semaphore(PIPELINE_CONCURRENCY) + + +def _pipeline_lock_files() -> tuple[Path, ...]: + raw = os.environ.get("STEMDECK_PIPELINE_LOCK", "").strip() + base = ( + Path(raw).expanduser().resolve() + if raw + else Path(tempfile.gettempdir()) / "stemdeck-pipeline.lock" + ) + if PIPELINE_CONCURRENCY <= 1: + return (base,) + return tuple(base.with_name(f"{base.name}.{slot}") for slot in range(PIPELINE_CONCURRENCY)) + + +@contextlib.contextmanager +def _machine_pipeline_lock(job: Job): + paths = _pipeline_lock_files() + for path in paths: + path.parent.mkdir(parents=True, exist_ok=True) + lock_files = [path.open("a+", encoding="utf-8") for path in paths] + try: + lock_file, slot = _wait_for_machine_lock(job, lock_files) + try: + lock_file.seek(0) + lock_file.truncate() + lock_file.write(f"{os.getpid()} {job.id} slot={slot}\n") + lock_file.flush() + add_job_log(job, f"Local processing slot {slot + 1}/{len(lock_files)} acquired") + yield + finally: + _release_machine_lock(lock_file) + finally: + for handle in lock_files: + handle.close() + + +def _wait_for_machine_lock(job: Job, lock_files) -> tuple[object, int]: + waited = False + while True: + _check_cancel(job) + for slot, lock_file in enumerate(lock_files): + if _try_machine_lock(lock_file): + if waited: + logger.info("job %s acquired machine pipeline lock slot %s", job.id, slot) + return lock_file, slot + waited = True + if job.status == "queued": + _set(job, status="queued", stage="Waiting for local processing slot...") + add_job_log(job, "Waiting for an available local processing slot", stage="queued") + time.sleep(1.0) + + +def _try_machine_lock(lock_file) -> bool: + lock_file.seek(0) + if os.name == "nt": + import msvcrt + + try: + if not lock_file.read(1): + lock_file.seek(0) + lock_file.write(" ") + lock_file.flush() + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) + return True + except OSError: + return False + + import fcntl + + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except BlockingIOError: + return False + + +def _release_machine_lock(lock_file) -> None: + lock_file.seek(0) + if os.name == "nt": + import msvcrt + + try: + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + except OSError: + logger.warning("failed to release machine pipeline lock", exc_info=True) + return + + import fcntl + + fcntl.flock(lock_file, fcntl.LOCK_UN) def _check_cancel(job: Job) -> None: @@ -43,19 +157,22 @@ def _check_cancel(job: Job) -> None: def _prepare_local_source(job: Job, source: Path, job_dir: Path) -> Path: - """Transcode any local upload to 16-bit 44.1 kHz stereo WAV before + """Transcode any local upload to a 44.1 kHz stereo WAV before handing it to Demucs. Normalises MP3 and non-standard WAV formats (24-bit, 32-bit float, high sample rate, multi-channel) that Demucs - would otherwise process silently and output as silence. + would otherwise process silently and output as silence. High-quality + presets keep this normalization in 32-bit float so hot sources are not + truncated before the dedicated safety preprocessing pass. Deletes the original source file after a successful transcode.""" - from app.core.config import ffmpeg_executable - dest = job_dir / "source.wav" if source.resolve() == dest.resolve(): return source - _set(job, stage="Preparing audio...") + set_stage_progress(job, "acquire", 0.0, status="processing", stage="Preparing audio...") + settings = demucs_settings_for_preset(job.quality_preset) + sample_fmt = "flt" if settings.float32 else "s16" + codec = "pcm_f32le" if settings.float32 else "pcm_s16le" cmd = [ ffmpeg_executable(), "-nostdin", @@ -68,11 +185,13 @@ def _prepare_local_source(job: Job, source: Path, job_dir: Path) -> Path: "-ac", "2", "-sample_fmt", - "s16", + sample_fmt, + "-c:a", + codec, "-y", str(dest), ] - result = subprocess.run(cmd, capture_output=True, timeout=TIMEOUT_FFMPEG) + result = run_tracked_process(job, cmd, timeout=TIMEOUT_FFMPEG) if result.returncode != 0: raise RuntimeError( "ffmpeg transcode failed: " + result.stderr.decode("utf-8", errors="replace").strip() @@ -81,24 +200,122 @@ def _prepare_local_source(job: Job, source: Path, job_dir: Path) -> Path: return dest +def _prepare_demucs_source(job: Job, source: Path, job_dir: Path) -> Path: + """Optionally create a safer high-quality working copy for Demucs. + + Hot masters can provoke clipped or ragged stem edges. Feeding Demucs a + true-peak limited, DC-filtered, slightly quieter float WAV costs extra + ffmpeg time but preserves the source file and keeps the tweak reversible. + """ + settings = demucs_settings_for_preset(job.quality_preset) + loudness_gain = 0.0 + if job.lufs is not None and job.lufs > STEM_PREPROCESS_TARGET_I: + loudness_gain = STEM_PREPROCESS_TARGET_I - job.lufs + peak_gain = 0.0 + if job.peak_db is not None and job.peak_db > STEM_PREPROCESS_TRUE_PEAK: + peak_gain = STEM_PREPROCESS_TRUE_PEAK - job.peak_db + demucs_gain_db = min(settings.pre_gain_db, loudness_gain, peak_gain, 0.0) + job.demucs_gain_db = demucs_gain_db + if abs(demucs_gain_db) < 0.001 and not settings.float32: + return source + + dest = job_dir / "source.demucs.wav" + set_stage_progress( + job, + "prepare_separation", + 0.25, + status="processing", + stage="Preparing high-quality separation...", + ) + filters = [ + "aresample=44100", + "aformat=sample_fmts=flt:channel_layouts=stereo", + # A very low high-pass removes DC/near-DC offset without touching bass fundamentals. + "highpass=f=12", + ] + if abs(demucs_gain_db) >= 0.001: + filters.append(f"volume={demucs_gain_db:g}dB") + cmd = [ + ffmpeg_executable(), + "-nostdin", + "-loglevel", + "error", + "-i", + str(source), + "-filter:a", + ",".join(filters), + "-ar", + "44100", + "-ac", + "2", + "-c:a", + "pcm_f32le", + "-y", + str(dest), + ] + result = run_tracked_process(job, cmd, timeout=TIMEOUT_FFMPEG) + if result.returncode != 0: + raise RuntimeError( + "ffmpeg pre-gain failed: " + result.stderr.decode("utf-8", errors="replace").strip() + ) + return dest + + def _run_common(job: Job, source: Path, job_dir: Path) -> None: """Analyze → separate → collect → mix. Shared by both YouTube and local upload pipelines after their respective source acquisition steps.""" _check_cancel(job) analyze(job, source) _check_cancel(job) - stems_root = separate(job, source, job_dir) + set_stage_progress( + job, + "prepare_separation", + 0.0, + status="processing", + stage="Preparing separation input...", + ) + demucs_source = _prepare_demucs_source(job, source, job_dir) + set_stage_progress(job, "prepare_separation", 1.0, stage="Separation input ready") + _check_cancel(job) + stems_root = separate(job, demucs_source, job_dir) + set_stage_progress(job, "collect", 0.0, status="processing", stage="Collecting stems...") found = collect(job, stems_root, job_dir) + set_stage_progress(job, "collect", 1.0, stage="Stems collected") stems_dir = job_dir / "stems" + set_stage_progress(job, "restore_gain", 0.0, stage="Restoring stem levels...") + restore_demucs_gain(job, stems_dir, found) + set_stage_progress(job, "restore_gain", 1.0, stage="Stem levels restored") + set_stage_progress(job, "bass_repair", 0.0, stage="Checking bass dropouts...") + job.bass_repair_applied = repair_bass_dropouts(job, source, stems_dir, found) + set_stage_progress(job, "bass_repair", 1.0, stage="Bass repair complete") + set_stage_progress(job, "phase_repair", 0.0, stage="Checking phase coherence...") + repair_phase_coherence(job, source, job_dir, stems_dir, found) + set_stage_progress(job, "phase_repair", 1.0, stage="Phase repair complete") + set_stage_progress(job, "denoise", 0.0, stage="Checking stem denoise...") + job.stem_denoise_applied = denoise_stem_outputs(job, stems_dir, found) + set_stage_progress(job, "denoise", 1.0, stage="Stem denoise complete") + set_stage_progress(job, "gate", 0.0, stage="Gating near-silent stem bleed...") + job.stem_gate_applied = gate_stem_outputs(job, stems_dir, found) + set_stage_progress(job, "gate", 1.0, stage="Stem gate complete") + set_stage_progress(job, "stabilize", 0.0, stage="Stabilizing stems...") + stabilize_stem_outputs(job, stems_dir, found) + set_stage_progress(job, "stabilize", 1.0, stage="Stems stabilized") + _check_cancel(job) + set_stage_progress(job, "presence", 0.0, stage="Measuring stem presence...") job.stem_presence = compute_stem_presence(stems_dir, found) + set_stage_progress(job, "presence", 1.0, stage="Stem presence measured") + set_stage_progress(job, "chords", 0.0, stage="Estimating chord MIDI...") + generate_chord_midi(job, source, job_dir, stems_dir=stems_dir) + set_stage_progress(job, "chords", 1.0, stage="Chord MIDI ready") # Source (100-300 MB or the local upload) is no longer needed after # collect; delete it before the ffmpeg amix steps in case scratch space # is tight. cleanup_source(job_dir) job.stems = [{"name": name, "url": f"/api/jobs/{job.id}/stems/{name}.wav"} for name in found] _check_cancel(job) - _set(job, stage="Mixing tracks...") + set_stage_progress(job, "mix", 0.0, stage="Mixing tracks...") original_path = make_original_track(job, job_dir, stems_dir) + set_stage_progress(job, "mix", 0.45, stage="Mixing tracks...") if original_path is not None: job.stems.insert( 0, @@ -109,6 +326,7 @@ def _run_common(job: Job, source: Path, job_dir: Path) -> None: ) _check_cancel(job) mix_path = make_selected_mix(job, stems_dir, found) + set_stage_progress(job, "mix", 1.0, stage="Mixing complete") if mix_path is not None: job.mix_url = f"/api/jobs/{job.id}/stems/{mix_path.name}" _check_cancel(job) @@ -116,7 +334,9 @@ def _run_common(job: Job, source: Path, job_dir: Path) -> None: all_stem_names = [s["name"] for s in job.stems] if mix_path is not None and mix_path.stem not in all_stem_names: all_stem_names.append(mix_path.stem) + set_stage_progress(job, "peaks", 0.0, stage="Rendering waveforms...") compute_stem_peaks(stems_dir, all_stem_names) + set_stage_progress(job, "peaks", 1.0, stage="Waveforms ready") def _run_blocking(job: Job, url: str, job_dir: Path) -> None: @@ -144,11 +364,33 @@ def _write_metadata(job: Job, job_dir: Path) -> None: "peak_db": job.peak_db, "dynamic_range": job.dynamic_range, "tempo_stability": job.tempo_stability, + "beat_times": job.beat_times, + "chord_progression": job.chord_progression, + "chord_midi_url": job.chord_midi_url, "stem_presence": job.stem_presence, + "selected_stems": job.selected_stems, + "quality_preset": job.quality_preset, + "stem_denoise_preset": job.stem_denoise_preset, + "demucs_device": job.demucs_device, + "demucs_device_resolved": job.demucs_device_resolved, + "profile_key": job.profile_key(), + "profile_label": job.profile_label(), + "source_url": job.source_url, + "demucs_gain_db": job.demucs_gain_db, + "bass_repair_applied": job.bass_repair_applied, + "phase_repair_applied": job.phase_repair_applied, + "phase_repair_residual_ratio": job.phase_repair_residual_ratio, + "stem_denoise_applied": job.stem_denoise_applied, + "stem_gate_applied": job.stem_gate_applied, + "stem_gate_threshold_db": job.stem_gate_threshold_db, + "processing_started_at": job.processing_started_at, + "completed_at": job.completed_at, + "processing_elapsed_seconds": job.processing_elapsed_seconds, "tags": job.tags, + "logs": job.logs, } try: - (job_dir / "metadata.json").write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8") + atomic_write_text(job_dir / "metadata.json", json.dumps(meta, indent=2) + "\n") except OSError: logger.warning("could not write metadata.json for job %s", job.id, exc_info=True) @@ -164,11 +406,13 @@ async def _run_async( """Common async wrapper: acquires the pipeline lock, runs blocking_fn in a thread, then handles success / cancel / error outcomes uniformly.""" try: + add_job_log(job, "Job entered the processing queue", stage="queued", progress=job.progress) async with _pipeline_lock: - await asyncio.to_thread(blocking_fn, job, *fn_args, job_dir) + await asyncio.to_thread(_run_with_machine_lock, job, blocking_fn, *fn_args, job_dir) except Exception as e: if not isinstance(e, JobCancelled) and not job.cancel_requested: logger.exception("pipeline failed for job %s: %s", job.id, e) + add_job_log(job, e, level="error", stage="error", progress=job.progress) _set(job, status="error", stage="Error: Processing failed", error=error_msg) persist_registry(jobs_dir) _rmtree(job_dir) @@ -178,15 +422,23 @@ async def _run_async( " (wrapped)" if not isinstance(e, JobCancelled) else "", job.id, ) + add_job_log(job, "Job cancelled", level="warning", stage="cancelled", progress=job.progress) _set(job, status="cancelled", stage="Cancelled") persist_registry(jobs_dir) _rmtree(job_dir) return _set(job, status="done", progress=1.0, stage="Done") + add_job_log(job, "Processing completed successfully", stage="done", progress=1.0) _write_metadata(job, job_dir) persist_registry(jobs_dir) +def _run_with_machine_lock(job: Job, blocking_fn, *fn_args: object) -> None: + *args, job_dir = fn_args + with _machine_pipeline_lock(job): + blocking_fn(job, *args, job_dir) + + async def run_pipeline(job: Job, url: str, jobs_dir: Path) -> None: job_dir = jobs_dir / job.id try: diff --git a/app/pipeline/separate.py b/app/pipeline/separate.py index cf26dae..3fd9b54 100644 --- a/app/pipeline/separate.py +++ b/app/pipeline/separate.py @@ -9,9 +9,16 @@ import time from pathlib import Path -from app.core.config import DEMUCS_DEVICE, DEMUCS_MODEL, TIMEOUT_DEMUCS_STALL -from app.core.models import Job, JobCancelled, _set +from app.core.config import ( + DEMUCS_DEVICE, + TIMEOUT_DEMUCS_STALL, + DemucsSettings, + demucs_settings_for_preset, +) +from app.core.models import Job, JobCancelled from app.core.registry import set_proc +from app.pipeline.process import popen_background, terminate_process +from app.pipeline.progress import set_stage_progress logger = logging.getLogger("stemdeck.pipeline") @@ -21,21 +28,40 @@ # while still catching genuine hangs (GPU deadlock, OOM stall, etc.). -def separate(job: Job, source: Path, job_dir: Path) -> Path: - _set(job, status="separating", progress=0.0, stage="Separating stems...") - +def build_demucs_command( + source: Path, + job_dir: Path, + settings: DemucsSettings, + device: str | None = None, +) -> list[str]: cmd = [ sys.executable, "-m", "demucs", "-n", - DEMUCS_MODEL, + settings.model, "-d", - DEMUCS_DEVICE, - "-o", - str(job_dir), - str(source), + device or DEMUCS_DEVICE, ] + if settings.shifts > 0: + cmd += ["--shifts", str(settings.shifts)] + if settings.overlap > 0: + cmd += ["--overlap", f"{settings.overlap:g}"] + if settings.segment > 0: + cmd += ["--segment", f"{settings.segment:g}"] + if settings.float32: + cmd.append("--float32") + if settings.clip_mode: + cmd += ["--clip-mode", settings.clip_mode] + cmd += ["-o", str(job_dir), str(source)] + return cmd + + +def separate(job: Job, source: Path, job_dir: Path) -> Path: + set_stage_progress(job, "separate", 0.0, status="separating", stage="Separating stems...") + + settings = demucs_settings_for_preset(job.quality_preset) + cmd = build_demucs_command(source, job_dir, settings, job.demucs_device_resolved or None) env = os.environ.copy() try: import certifi @@ -45,7 +71,7 @@ def separate(job: Job, source: Path, job_dir: Path) -> Path: except ModuleNotFoundError: pass - proc = subprocess.Popen( + proc = popen_background( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, @@ -77,7 +103,7 @@ def _watchdog() -> None: TIMEOUT_DEMUCS_STALL, job.id, ) - proc.terminate() + terminate_process(proc) return wt = threading.Thread(target=_watchdog, daemon=True) @@ -96,7 +122,7 @@ def _watchdog() -> None: m = _PCT_RE.search(line) if m: pct = max(0, min(100, int(m.group(1)))) - _set(job, progress=pct / 100.0, stage=f"Separating {pct}%") + set_stage_progress(job, "separate", pct / 100.0, stage=f"Separating {pct}%") else: tail.append(line) if len(tail) > 40: @@ -121,7 +147,8 @@ def _watchdog() -> None: last = tail[-1] if tail else f"exit status {proc.returncode}" raise RuntimeError(f"demucs failed: {last}") - stems_root = job_dir / DEMUCS_MODEL / source.stem + stems_root = job_dir / settings.model / source.stem if not stems_root.is_dir(): raise RuntimeError(f"demucs output not found at {stems_root}") + set_stage_progress(job, "separate", 1.0, stage="Separation complete") return stems_root diff --git a/benchmarks/README.ja.md b/benchmarks/README.ja.md new file mode 100644 index 0000000..3d37fff --- /dev/null +++ b/benchmarks/README.ja.md @@ -0,0 +1,29 @@ +# LayerLab 評価ベンチマーク + +このフォルダは、stem分離やChord MIDI推定を変更した時に品質が悪化していないか確認するための評価セット置き場です。 + +音源ファイルは著作権や容量の都合でリポジトリには含めません。ローカルでは、同じ曲を同じ設定で処理した `jobs/` ディレクトリを残しておき、以下のように比較します。 + +```bash +uv run scripts/benchmark_audio.py --jobs-root jobs --out .build/benchmark-current.json +uv run scripts/benchmark_audio.py --jobs-root jobs --baseline .build/benchmark-baseline.json --out .build/benchmark-compare.json +``` + +推奨する評価曲の内訳: + +- 音圧が高くクリップ気味の曲 +- ベースが細かく動く曲 +- ピアノまたはギター主体のコードが明確な曲 +- ボーカルが大きく、chromaが濁りやすい曲 +- BPMが揺れる曲 +- m4a/mp3/flac/wav の入力形式違い +- 短い曲と長い曲 + +確認する主な指標: + +- `stem_sum.residual_percent`: stem合計と原音の残差 +- `stem_sum.stem_sum_clipping_percent`: 合成時のクリップ率 +- `chords.average_confidence`: コード推定の平均信頼度 +- `chords.short_segment_count`: 1拍程度の短いコード区間数 +- `chords.unstable_short_segment_count`: 1拍だけ出た不安定なdim/sus/maj7区間数 +- `summary.*`: 複数ジョブ全体の平均・最大・合計 diff --git a/benchmarks/manifest.example.json b/benchmarks/manifest.example.json new file mode 100644 index 0000000..83c9bc0 --- /dev/null +++ b/benchmarks/manifest.example.json @@ -0,0 +1,24 @@ +{ + "schema": "layerlab-benchmark-manifest-v1", + "notes": "Keep real audio outside git. Use stable local jobs or private fixtures.", + "cases": [ + { + "name": "loud-master", + "purpose": "clip-risk and stem-sum residual regression", + "input_format": "wav", + "suggested_profile": "Max / Noise off / Auto" + }, + { + "name": "moving-bass", + "purpose": "bass passing note vs chord root regression", + "input_format": "mp3", + "suggested_profile": "Max / Noise off / Auto" + }, + { + "name": "piano-chords", + "purpose": "Chord MIDI grid and triad/seventh export regression", + "input_format": "m4a", + "suggested_profile": "Ultra / Noise off / Auto" + } + ] +} diff --git a/build/docker-compose.yml b/build/docker-compose.yml index 6b9ec74..13e41e0 100644 --- a/build/docker-compose.yml +++ b/build/docker-compose.yml @@ -13,7 +13,7 @@ services: image: stemdeck container_name: stemdeck ports: - # Bind to loopback only -- StemDeck has no auth and is local-only. + # Bind to loopback only -- LayerLab has no auth and is local-only. - "127.0.0.1:8000:8000" volumes: # Stems and downloaded audio land in /jobs on the diff --git a/desktop/package-lock.json b/desktop/package-lock.json index aa7a082..25d7724 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { - "name": "stemdeck-desktop", - "version": "0.1.0-alpha.0", + "name": "layerlab-desktop", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "stemdeck-desktop", - "version": "0.1.0-alpha.0", + "name": "layerlab-desktop", + "version": "0.0.0", "devDependencies": { "@tauri-apps/cli": "^2.0.0" } diff --git a/desktop/package.json b/desktop/package.json index 3154a7d..6cc9e14 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,5 +1,5 @@ { - "name": "stemdeck-desktop", + "name": "layerlab-desktop", "version": "0.0.0", "private": true, "type": "module", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 0bfa8fa..512119c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1684,6 +1684,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "layerlab" +version = "0.0.0" +dependencies = [ + "flate2", + "libc", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "tar", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-store", + "tempfile", + "zip", + "zstd", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -3192,26 +3212,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "stemdeck" -version = "0.0.0" -dependencies = [ - "flate2", - "libc", - "reqwest 0.12.28", - "serde", - "serde_json", - "sha2", - "tar", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-store", - "tempfile", - "zip", - "zstd", -] - [[package]] name = "string_cache" version = "0.9.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index c18b23e..c89bb24 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "stemdeck" +name = "layerlab" # Placeholder — stamped from the git tag at build time (Woodpecker / make-app.sh). #169 version = "0.0.0" -description = "StemDeck desktop launcher" -authors = ["StemDeck contributors"] +description = "LayerLab desktop launcher" +authors = ["LayerLab contributors"] edition = "2021" rust-version = "1.88" diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 29dff59..52f35e8 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Default StemDeck desktop window permissions", + "description": "Default LayerLab desktop window permissions", "windows": ["main"], "permissions": ["core:default", "dialog:allow-save"] } diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index d243eb4..011392f 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ env, fs, - io::{Read, Write}, + io::{Read, Seek, SeekFrom, Write}, net::{TcpListener, TcpStream}, path::{Path, PathBuf}, process::{Child, Command, Output, Stdio}, @@ -13,7 +13,6 @@ use std::{ }; use tar::Archive; use tauri::{Emitter, Manager}; -use tauri_plugin_store::StoreExt; #[cfg(windows)] use zip::ZipArchive; @@ -43,6 +42,7 @@ struct BackendHandles { url: String, } +#[derive(Default)] struct BackendStateInner { handles: Option, /// True while start_backend is executing; prevents concurrent starts (#145). @@ -51,16 +51,6 @@ struct BackendStateInner { pip_pid: Option, } -impl Default for BackendStateInner { - fn default() -> Self { - BackendStateInner { - handles: None, - starting: false, - pip_pid: None, - } - } -} - struct BackendState { inner: Mutex, } @@ -132,6 +122,16 @@ struct BackendStarted { url: String, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BackendRuntimeStatus { + running: bool, + starting: bool, + pid: Option, + url: Option, + pip_pid: Option, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct AssetStatus { @@ -150,6 +150,42 @@ struct GpuSetup { cuda_verified: bool, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MaintenanceReport { + data_dir: String, + jobs_dir: String, + jobs_bytes: u64, + job_dirs: u64, + cache_bytes: u64, + downloads_bytes: u64, + removed_paths: Vec, + warnings: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AudioAnalysis { + path: String, + sample_rate: u32, + channels: u16, + duration_seconds: f64, + peak: f32, + rms: f32, + waveform_peaks: Vec, +} + +#[derive(Clone, Copy)] +struct WavFormat { + audio_format: u16, + channels: u16, + sample_rate: u32, + bits_per_sample: u16, + block_align: u16, + data_offset: u64, + data_size: u64, +} + fn main() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) @@ -158,7 +194,7 @@ fn main() { let data_dir = match local_data_dir() { Ok(d) => d, Err(e) => { - eprintln!("[stemdeck] could not resolve data_dir, skipping version check: {e}"); + eprintln!("[layerlab] could not resolve data_dir, skipping version check: {e}"); return Ok(()); } }; @@ -178,7 +214,7 @@ fn main() { // cleanup — a missing version file would otherwise cause every launch // to wipe WebKit data. if let Err(e) = fs::write(&version_file, current) { - eprintln!("[stemdeck] failed to write version file, skipping cleanup: {e}"); + eprintln!("[layerlab] failed to write version file, skipping cleanup: {e}"); } } let _ = app; // suppress unused warning @@ -195,43 +231,49 @@ fn main() { ensure_external_assets, ensure_torch_device, start_backend, + backend_status, + stop_backend_command, open_url, save_audio_file, store_get, store_set, + maintenance_status, + run_maintenance, + analyze_wav_file, mark_store_migration_done, ]) .build(tauri::generate_context!()) - .expect("failed to build StemDeck desktop app") - .run(|app_handle, event| match event { - tauri::RunEvent::WindowEvent { + .expect("failed to build LayerLab desktop app") + .run(|app_handle, event| { + if let tauri::RunEvent::WindowEvent { event: tauri::WindowEvent::CloseRequested { .. }, .. - } => { + } = event + { let state = app_handle.state::(); stop_backend(&state); app_handle.exit(0); } - _ => {} }); } -/// Returns ~/Documents/StemDeck/, creating it if needed. +/// Returns ~/Documents/LayerLab/, creating it if needed. /// All user-facing content (library metadata + stem audio) lives here so it is /// visible in Finder, eligible for iCloud backup, and survives app reinstalls. fn documents_stemdeck_dir(app: &tauri::AppHandle) -> Result { let documents = app.path().document_dir().map_err(|e| e.to_string())?; - let dir = documents.join("StemDeck"); - fs::create_dir_all(&dir).map_err(|e| format!("failed to create ~/Documents/StemDeck: {e}"))?; + let dir = documents.join("LayerLab"); + fs::create_dir_all(&dir) + .map_err(|e| format!("failed to create ~/Documents/LayerLab: {e}"))?; Ok(dir) } -/// Returns ~/Documents/StemDeck/user-data.json (library metadata store). +/// Returns ~/Documents/LayerLab/user-data.json (library metadata store). fn documents_store_path(app: &tauri::AppHandle) -> Result { Ok(documents_stemdeck_dir(app)?.join("user-data.json")) } -/// Returns ~/Documents/StemDeck/jobs/ (stem audio files). +/// Returns ~/Documents/LayerLab/jobs/ (stem audio files). /// Falls back to data_dir/jobs if document_dir is unavailable. fn documents_dir_for_jobs(app: &tauri::AppHandle) -> PathBuf { match documents_stemdeck_dir(app) { @@ -250,17 +292,65 @@ fn documents_dir_for_jobs(app: &tauri::AppHandle) -> PathBuf { #[tauri::command] fn store_get(app: tauri::AppHandle, key: String) -> Result, String> { let path = documents_store_path(&app)?; - let store = app.store(path).map_err(|e| e.to_string())?; - Ok(store.get(&key)) + let store = read_store_map(&path)?; + Ok(store.get(&key).cloned()) } /// Set a value in the persistent user-data store and immediately flush to disk. #[tauri::command] fn store_set(app: tauri::AppHandle, key: String, value: serde_json::Value) -> Result<(), String> { let path = documents_store_path(&app)?; - let store = app.store(path).map_err(|e| e.to_string())?; - store.set(key, value); - store.save().map_err(|e| e.to_string()) + let mut store = read_store_map(&path)?; + store.insert(key, value); + atomic_write_json(&path, &serde_json::Value::Object(store)) +} + +fn read_store_map(path: &Path) -> Result, String> { + if !path.is_file() { + return Ok(serde_json::Map::new()); + } + let text = fs::read_to_string(path) + .map_err(|e| format!("failed to read store {}: {e}", path.display()))?; + if text.trim().is_empty() { + return Ok(serde_json::Map::new()); + } + match serde_json::from_str::(&text) { + Ok(serde_json::Value::Object(map)) => Ok(map), + Ok(_) => Err(format!("store {} is not a JSON object", path.display())), + Err(e) => Err(format!("failed to parse store {}: {e}", path.display())), + } +} + +fn atomic_write_json(path: &Path, value: &serde_json::Value) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("failed to create {}: {e}", parent.display()))?; + } + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("invalid store path {}", path.display()))?; + let tmp = path.with_file_name(format!("{filename}.tmp")); + let mut file = fs::File::create(&tmp) + .map_err(|e| format!("failed to create temp store {}: {e}", tmp.display()))?; + file.write_all( + serde_json::to_string_pretty(value) + .map_err(|e| format!("failed to serialize store: {e}"))? + .as_bytes(), + ) + .map_err(|e| format!("failed to write temp store {}: {e}", tmp.display()))?; + file.write_all(b"\n") + .map_err(|e| format!("failed to finish temp store {}: {e}", tmp.display()))?; + file.sync_all() + .map_err(|e| format!("failed to flush temp store {}: {e}", tmp.display()))?; + drop(file); + fs::rename(&tmp, path).map_err(|e| { + format!( + "failed to replace store {} with {}: {e}", + path.display(), + tmp.display() + ) + }) } /// Called by JS after the one-time localStorage → store migration completes. @@ -271,10 +361,10 @@ fn mark_store_migration_done() { match local_data_dir() { Ok(d) => { if let Err(e) = fs::write(d.join("store_migration_done"), "") { - eprintln!("[stemdeck] failed to write migration flag: {e}"); + eprintln!("[layerlab] failed to write migration flag: {e}"); } } - Err(e) => eprintln!("[stemdeck] could not write migration flag: {e}"), + Err(e) => eprintln!("[layerlab] could not write migration flag: {e}"), } } @@ -288,13 +378,16 @@ fn clear_webkit_data() { Err(_) => return, }; let targets = [ + format!("{home}/Library/WebKit/com.bassmicrobe.layerlab"), + // Legacy StemDeck fork identifiers are cleanup-only migration targets. + format!("{home}/Library/WebKit/com.bassmicrobe.stemdeck.enhanced"), format!("{home}/Library/WebKit/app.stemdeck.desktop"), format!("{home}/Library/WebKit/stemdeck"), ]; for path in &targets { if let Err(e) = fs::remove_dir_all(path) { if e.kind() != std::io::ErrorKind::NotFound { - eprintln!("[stemdeck] WebKit cleanup failed for {path}: {e}"); + eprintln!("[layerlab] WebKit cleanup failed for {path}: {e}"); } } } @@ -539,7 +632,8 @@ fn start_backend( let backend_dir = backend_dir(&root)?; let data_dir = local_data_dir()?; let python = python_path(&root).filter(|p| p.is_file()).ok_or_else(|| { - "Python runtime not found. Expected python/ or .venv/ under StemDeck.".to_string() + "Python runtime not found. Expected python/ or .venv/ under the LayerLab app root." + .to_string() })?; patch_pyvenv_cfg(&python); let (port, port_guard) = free_port()?; @@ -575,7 +669,7 @@ fn start_backend( cmd.env("PYTHONHOME", pythonhome); } - // Jobs (stem audio files) live in ~/Documents/StemDeck/jobs/ so the user's + // Jobs (stem audio files) live in ~/Documents/LayerLab/jobs/ so the user's // library is visible in Finder, backed up by iCloud, and survives app reinstalls. let jobs_dir = documents_dir_for_jobs(&app_handle); @@ -635,9 +729,53 @@ fn start_backend( } } +/// Returns the backend process status tracked by the desktop shell. +#[tauri::command] +fn backend_status(state: tauri::State) -> Result { + let mut inner = state.inner.lock().map_err(|e| e.to_string())?; + let mut running = false; + let mut pid = None; + let mut url = None; + let mut clear_handles = false; + + if let Some(handles) = inner.handles.as_mut() { + match handles.child.try_wait() { + Ok(Some(_)) => { + clear_handles = true; + } + Ok(None) => { + running = true; + pid = Some(handles.child.id()); + url = Some(handles.url.clone()); + } + Err(_) => { + clear_handles = true; + } + } + } + if clear_handles { + inner.handles = None; + } + + Ok(BackendRuntimeStatus { + running, + starting: inner.starting, + pid, + url, + pip_pid: inner.pip_pid, + }) +} + +/// Stops the managed backend process and any tracked setup subprocess. +#[tauri::command] +fn stop_backend_command(state: tauri::State) -> Result<(), String> { + stop_backend(&state); + Ok(()) +} + /// Detects GPU hardware, installs CUDA torch if needed, and persists the chosen device. #[tauri::command] -fn ensure_torch_device(state: tauri::State) -> Result { +fn ensure_torch_device(_state: tauri::State) -> Result { let root = app_root()?; let data_dir = local_data_dir()?; @@ -681,7 +819,7 @@ fn ensure_torch_device(state: tauri::State) -> Result { let index_url = cuda_index_url(&cuda_version); - install_cuda_torch(&python, &index_url, &state)?; + install_cuda_torch(&python, &index_url, &_state)?; let cuda_verified = verify_cuda_torch(&python); GpuSetup { gpu_detected: true, @@ -966,6 +1104,7 @@ fn python_stdlib_present(venv_root: &Path) -> bool { /// Maps known pip/OS failure patterns to actionable user messages. /// Pure function — caller is responsible for logging the raw stderr before calling. +#[cfg(not(target_os = "macos"))] fn classify_cuda_install_error(stderr: &str) -> String { let lower = stderr.to_ascii_lowercase(); @@ -983,7 +1122,7 @@ fn classify_cuda_install_error(stderr: &str) -> String { } if lower.contains("access is denied") || lower.contains("permissionerror") { return "CUDA install failed: permission denied — antivirus software may be blocking \ - the install. Try adding StemDeck to your AV exclusions and click Retry." + the install. Try adding LayerLab to your AV exclusions and click Retry." .to_string(); } if lower.contains("could not connect") || lower.contains("connection timed out") { @@ -1069,7 +1208,7 @@ fn install_cuda_torch(python: &Path, index_url: &str, state: &BackendState) -> R { let _ = writeln!( f, - "[stemdeck] CUDA torch install failed. stderr:\n{}", + "[layerlab] CUDA torch install failed. stderr:\n{}", stderr.trim() ); } @@ -1221,9 +1360,344 @@ fn stop_backend(state: &BackendState) { }); } -/// Returns the persistent user data directory for StemDeck. -/// On Windows: %LocalAppData%\StemDeck -/// On macOS: ~/Library/Application Support/StemDeck +/// Reports desktop-owned data directories and safe cleanup candidates. +#[tauri::command] +fn maintenance_status(app: tauri::AppHandle) -> Result { + build_maintenance_report(&app, false) +} + +/// Runs conservative desktop-side cleanup for interrupted installs/downloads. +#[tauri::command] +fn run_maintenance(app: tauri::AppHandle) -> Result { + build_maintenance_report(&app, true) +} + +fn build_maintenance_report( + app: &tauri::AppHandle, + clean: bool, +) -> Result { + let data_dir = local_data_dir()?; + let jobs_dir = documents_dir_for_jobs(app); + let mut removed_paths = Vec::new(); + let mut warnings = Vec::new(); + + if clean { + for path in [data_dir.join("runtime.tmp"), data_dir.join("runtime.old")] { + if path.exists() { + remove_path_best_effort(&path, &mut removed_paths, &mut warnings); + } + } + cleanup_download_temps(&data_dir.join("downloads"), &mut removed_paths, &mut warnings); + cleanup_empty_job_dirs(&jobs_dir, &mut removed_paths, &mut warnings); + } + + Ok(MaintenanceReport { + data_dir: data_dir.display().to_string(), + jobs_dir: jobs_dir.display().to_string(), + jobs_bytes: dir_size(&jobs_dir), + job_dirs: count_direct_child_dirs(&jobs_dir), + cache_bytes: dir_size(&data_dir.join("cache")), + downloads_bytes: dir_size(&data_dir.join("downloads")), + removed_paths, + warnings, + }) +} + +fn cleanup_download_temps( + downloads_dir: &Path, + removed_paths: &mut Vec, + warnings: &mut Vec, +) { + let Ok(entries) = fs::read_dir(downloads_dir) else { + return; + }; + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if name.ends_with(".download") || name.ends_with(".tmp") { + remove_path_best_effort(&path, removed_paths, warnings); + } + } +} + +fn cleanup_empty_job_dirs( + jobs_dir: &Path, + removed_paths: &mut Vec, + warnings: &mut Vec, +) { + let Ok(entries) = fs::read_dir(jobs_dir) else { + return; + }; + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + if !path.is_dir() || !looks_like_job_dir(&path) || !dir_is_empty(&path) { + continue; + } + if path_age(&path).is_some_and(|age| age >= Duration::from_secs(24 * 60 * 60)) { + remove_path_best_effort(&path, removed_paths, warnings); + } + } +} + +fn remove_path_best_effort( + path: &Path, + removed_paths: &mut Vec, + warnings: &mut Vec, +) { + let result = if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + }; + match result { + Ok(_) => removed_paths.push(path.display().to_string()), + Err(e) => warnings.push(format!("failed to remove {}: {e}", path.display())), + } +} + +fn dir_size(path: &Path) -> u64 { + let Ok(metadata) = fs::symlink_metadata(path) else { + return 0; + }; + if metadata.is_file() { + return metadata.len(); + } + if !metadata.is_dir() { + return 0; + } + let Ok(entries) = fs::read_dir(path) else { + return 0; + }; + entries + .filter_map(Result::ok) + .map(|entry| dir_size(&entry.path())) + .sum() +} + +fn count_direct_child_dirs(path: &Path) -> u64 { + let Ok(entries) = fs::read_dir(path) else { + return 0; + }; + entries + .filter_map(Result::ok) + .filter(|entry| entry.path().is_dir()) + .count() as u64 +} + +fn dir_is_empty(path: &Path) -> bool { + fs::read_dir(path) + .map(|mut entries| entries.next().is_none()) + .unwrap_or(false) +} + +fn looks_like_job_dir(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.len() >= 8 + && name.len() <= 64 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + }) +} + +fn path_age(path: &Path) -> Option { + let modified = fs::metadata(path).ok()?.modified().ok()?; + SystemTime::now().duration_since(modified).ok() +} + +/// Analyzes a WAV file in Rust for quick waveform, peak, and RMS metadata. +#[tauri::command] +fn analyze_wav_file(path: String, bins: Option) -> Result { + let path_buf = PathBuf::from(&path); + let mut file = + fs::File::open(&path_buf).map_err(|e| format!("failed to open {path}: {e}"))?; + let format = parse_wav_format(&mut file)?; + if format.sample_rate == 0 || format.channels == 0 || format.block_align == 0 { + return Err("invalid WAV format".to_string()); + } + let frames = format.data_size / u64::from(format.block_align); + if frames == 0 { + return Ok(AudioAnalysis { + path, + sample_rate: format.sample_rate, + channels: format.channels, + duration_seconds: 0.0, + peak: 0.0, + rms: 0.0, + waveform_peaks: Vec::new(), + }); + } + + let bin_count = bins.unwrap_or(2048).clamp(1, 8192); + let mut waveform_peaks = vec![0.0_f32; bin_count]; + let mut frame = vec![0_u8; usize::from(format.block_align)]; + let bytes_per_sample = usize::from(format.bits_per_sample / 8); + let mut peak = 0.0_f32; + let mut sum_squares = 0.0_f64; + let mut sample_count = 0_u64; + + file.seek(SeekFrom::Start(format.data_offset)) + .map_err(|e| format!("failed to seek WAV data: {e}"))?; + for frame_index in 0..frames { + file.read_exact(&mut frame) + .map_err(|e| format!("failed to read WAV frame: {e}"))?; + let mut frame_peak = 0.0_f32; + for channel in 0..usize::from(format.channels) { + let offset = channel * bytes_per_sample; + let sample = decode_wav_sample(&frame[offset..offset + bytes_per_sample], format)?; + let amplitude = sample.abs().min(1.0); + frame_peak = frame_peak.max(amplitude); + peak = peak.max(amplitude); + sum_squares += f64::from(sample) * f64::from(sample); + sample_count += 1; + } + let bin = ((frame_index * bin_count as u64) / frames).min((bin_count - 1) as u64) as usize; + waveform_peaks[bin] = waveform_peaks[bin].max(frame_peak); + } + + let rms = if sample_count == 0 { + 0.0 + } else { + (sum_squares / sample_count as f64).sqrt() as f32 + }; + + Ok(AudioAnalysis { + path, + sample_rate: format.sample_rate, + channels: format.channels, + duration_seconds: frames as f64 / f64::from(format.sample_rate), + peak, + rms, + waveform_peaks, + }) +} + +fn parse_wav_format(file: &mut fs::File) -> Result { + let mut header = [0_u8; 12]; + file.read_exact(&mut header) + .map_err(|e| format!("failed to read WAV header: {e}"))?; + if &header[0..4] != b"RIFF" || &header[8..12] != b"WAVE" { + return Err("only RIFF/WAVE files are supported".to_string()); + } + + let mut format: Option = None; + let mut data_offset = None; + let mut data_size = None; + loop { + let mut chunk_header = [0_u8; 8]; + match file.read_exact(&mut chunk_header) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, + Err(e) => return Err(format!("failed to read WAV chunk: {e}")), + } + let chunk_id = &chunk_header[0..4]; + let chunk_size = u32::from_le_bytes([ + chunk_header[4], + chunk_header[5], + chunk_header[6], + chunk_header[7], + ]) as u64; + let chunk_start = file + .stream_position() + .map_err(|e| format!("failed to read WAV position: {e}"))?; + + if chunk_id == b"fmt " { + if chunk_size < 16 { + return Err("WAV fmt chunk is too small".to_string()); + } + let mut fmt = vec![0_u8; chunk_size as usize]; + file.read_exact(&mut fmt) + .map_err(|e| format!("failed to read WAV fmt chunk: {e}"))?; + let audio_format = u16::from_le_bytes([fmt[0], fmt[1]]); + let channels = u16::from_le_bytes([fmt[2], fmt[3]]); + let sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]); + let block_align = u16::from_le_bytes([fmt[12], fmt[13]]); + let bits_per_sample = u16::from_le_bytes([fmt[14], fmt[15]]); + format = Some(WavFormat { + audio_format, + channels, + sample_rate, + bits_per_sample, + block_align, + data_offset: data_offset.unwrap_or(0), + data_size: data_size.unwrap_or(0), + }); + } else if chunk_id == b"data" { + data_offset = Some(chunk_start); + data_size = Some(chunk_size); + } + + let next = chunk_start + chunk_size + (chunk_size % 2); + file.seek(SeekFrom::Start(next)) + .map_err(|e| format!("failed to seek WAV chunk: {e}"))?; + if format.is_some() && data_offset.is_some() { + break; + } + } + + let Some(mut wav) = format else { + return Err("WAV fmt chunk was not found".to_string()); + }; + wav.data_offset = data_offset.ok_or_else(|| "WAV data chunk was not found".to_string())?; + wav.data_size = data_size.ok_or_else(|| "WAV data chunk was not found".to_string())?; + validate_wav_format(wav)?; + Ok(wav) +} + +fn validate_wav_format(format: WavFormat) -> Result<(), String> { + match (format.audio_format, format.bits_per_sample) { + (1, 16) | (1, 24) | (1, 32) | (3, 32) => {} + _ => { + return Err(format!( + "unsupported WAV format {} with {} bits per sample", + format.audio_format, format.bits_per_sample + )) + } + } + if format.bits_per_sample & 7 != 0 { + return Err("unsupported non-byte-aligned WAV sample size".to_string()); + } + let expected = format.channels.saturating_mul(format.bits_per_sample / 8); + if expected == 0 || expected != format.block_align { + return Err("unsupported WAV block alignment".to_string()); + } + Ok(()) +} + +fn decode_wav_sample(bytes: &[u8], format: WavFormat) -> Result { + match (format.audio_format, format.bits_per_sample) { + (1, 16) => { + let value = i16::from_le_bytes([bytes[0], bytes[1]]); + Ok(value as f32 / 32768.0) + } + (1, 24) => { + let raw = i32::from(bytes[0]) | (i32::from(bytes[1]) << 8) | (i32::from(bytes[2]) << 16); + let signed = if raw & 0x80_0000 != 0 { + raw | !0xFF_FFFF + } else { + raw + }; + Ok(signed as f32 / 8_388_608.0) + } + (1, 32) => { + let value = i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + Ok(value as f32 / 2_147_483_648.0) + } + (3, 32) => { + let value = f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + Ok(value.clamp(-1.0, 1.0)) + } + _ => Err("unsupported WAV sample format".to_string()), + } +} + +/// Returns the persistent user data directory for LayerLab. +/// On Windows: %LocalAppData%\LayerLab +/// On macOS: ~/Library/Application Support/LayerLab /// On Linux: $XDG_DATA_HOME/stemdeck or ~/.local/share/stemdeck /// Can be overridden by STEMDECK_DATA_DIR for development. fn local_data_dir() -> Result { @@ -1234,7 +1708,7 @@ fn local_data_dir() -> Result { { let base = env::var("LOCALAPPDATA") .map_err(|_| "LOCALAPPDATA environment variable not set".to_string())?; - Ok(PathBuf::from(base).join("StemDeck")) + Ok(PathBuf::from(base).join("LayerLab")) } #[cfg(target_os = "macos")] { @@ -1242,18 +1716,18 @@ fn local_data_dir() -> Result { Ok(PathBuf::from(home) .join("Library") .join("Application Support") - .join("StemDeck")) + .join("LayerLab")) } #[cfg(all(unix, not(target_os = "macos")))] { if let Ok(xdg) = env::var("XDG_DATA_HOME") { - return Ok(PathBuf::from(xdg).join("stemdeck")); + return Ok(PathBuf::from(xdg).join("layerlab")); } let home = env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; Ok(PathBuf::from(home) .join(".local") .join("share") - .join("stemdeck")) + .join("layerlab")) } } @@ -1264,7 +1738,7 @@ fn append_to_setup_log(data_dir: &Path, msg: &str) { let _ = fs::create_dir_all(p); } if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&log) { - let _ = writeln!(f, "[stemdeck] {msg}"); + let _ = writeln!(f, "[layerlab] {msg}"); } } @@ -1349,7 +1823,7 @@ fn runtime_archive_path(data_dir: &Path, manifest: &RuntimeManifest) -> PathBuf .clone() .or_else(|| manifest.runtime_url.rsplit('/').next().map(str::to_string)) .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| format!("StemDeck-runtime-macOS-{}.tar.zst", manifest.arch)); + .unwrap_or_else(|| format!("LayerLab-runtime-macOS-{}.tar.zst", manifest.arch)); data_dir.join("downloads").join(name) } @@ -2196,6 +2670,7 @@ fn update_setup_config( /// Polls an already-spawned child until it exits or the timeout elapses. /// Mirrors command_output_with_timeout but accepts a pre-spawned Child so the /// caller can record the PID before waiting (e.g. to kill on window close). +#[cfg(not(target_os = "macos"))] fn child_output_with_timeout( mut child: Child, timeout: Duration, @@ -2351,7 +2826,10 @@ mod tests { // We can't safely delete real WebKit dirs in a test, but we can verify // the function handles NotFound gracefully by checking the logic: let tmp = make_tmp(); - let fake_webkit = tmp.path().join("WebKit").join("app.stemdeck.desktop"); + let fake_webkit = tmp + .path() + .join("WebKit") + .join("com.bassmicrobe.layerlab"); // Never created → remove_dir_all should return NotFound, which we ignore. let result = fs::remove_dir_all(&fake_webkit); assert!(result.is_err()); diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index fe34310..5debe64 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "StemDeck", + "productName": "LayerLab", "version": "0.0.0", - "identifier": "app.stemdeck.desktop", + "identifier": "com.bassmicrobe.layerlab", "build": { "frontendDist": "../ui", "beforeDevCommand": "", @@ -12,7 +12,7 @@ "withGlobalTauri": true, "windows": [ { - "title": "StemDeck", + "title": "LayerLab", "width": 1280, "height": 820, "minWidth": 1024, diff --git a/desktop/ui/index.html b/desktop/ui/index.html index 59574c0..f0880e3 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -3,18 +3,33 @@ - StemDeck Launcher + LayerLab Launcher
-

StemDeck

- ALPHA +

LayerLab

+ UNOFFICIAL TEST BUILD

Preparing the local audio engine, first run will take a while...

+
+
+ Runtime download + Checking... +
+
+ Workspace + Checking... +
+
+ Data folder + Resolving... +
+
+
  1. Setting up local runtime
  2. Creating workspace
  3. @@ -33,6 +48,13 @@

    StemDeck

    + +

    + LayerLab is an unofficial modified fork of the original + StemDeck project. It is distributed under the Apache License 2.0; + LICENSE, NOTICE, and third-party notices are included in the + app/package. +

diff --git a/desktop/ui/runtime-manifest.json b/desktop/ui/runtime-manifest.json index b0f27c3..b128a85 100644 --- a/desktop/ui/runtime-manifest.json +++ b/desktop/ui/runtime-manifest.json @@ -4,5 +4,5 @@ "runtimeUrl": "", "runtimeSha256": "", "runtimeSize": 0, - "archiveName": "StemDeck-runtime-macOS-arm64.tar.zst" + "archiveName": "LayerLab-runtime-macOS-arm64.tar.zst" } diff --git a/desktop/ui/setup.css b/desktop/ui/setup.css index cc3cc7f..d414cdf 100644 --- a/desktop/ui/setup.css +++ b/desktop/ui/setup.css @@ -67,6 +67,44 @@ h1 { color: var(--muted); } +.setup-facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 0 0 22px; +} + +.setup-facts div { + min-width: 0; + padding: 11px 12px; + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 10px; + background: rgba(3, 10, 15, 0.34); +} + +.setup-facts span, +.license-note { + color: var(--muted); +} + +.setup-facts span { + display: block; + margin-bottom: 5px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.setup-facts strong { + display: block; + overflow: hidden; + color: var(--ink); + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + .setup-steps { display: grid; gap: 12px; @@ -193,6 +231,12 @@ h1 { margin-top: 20px; } +.license-note { + margin: 18px 0 0; + font-size: 12px; + line-height: 1.55; +} + button { min-height: 42px; padding: 0 18px; @@ -213,3 +257,17 @@ button { transform: rotate(360deg); } } + +@media (max-width: 680px) { + .setup-shell { + padding: 18px; + } + + .setup-card { + padding: 22px; + } + + .setup-facts { + grid-template-columns: 1fr; + } +} diff --git a/desktop/ui/setup.js b/desktop/ui/setup.js index d8c3693..f295757 100644 --- a/desktop/ui/setup.js +++ b/desktop/ui/setup.js @@ -6,6 +6,9 @@ const statusEl = document.getElementById("status"); const detailsEl = document.getElementById("details"); const retryBtn = document.getElementById("retry"); const steps = [...document.querySelectorAll("[data-step]")]; +const metaRuntimeEl = document.getElementById("meta-runtime"); +const metaWorkspaceEl = document.getElementById("meta-workspace"); +const metaDataDirEl = document.getElementById("meta-data-dir"); function setStep(name, state) { const el = steps.find((item) => item.dataset.step === name); @@ -57,6 +60,44 @@ function formatElapsed(startedAt) { return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`; } +function formatBytes(bytes) { + const n = Number(bytes) || 0; + if (n <= 0) return "0 MB"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = n; + let idx = 0; + while (value >= 1024 && idx < units.length - 1) { + value /= 1024; + idx += 1; + } + return `${value >= 10 || idx < 2 ? value.toFixed(0) : value.toFixed(1)} ${units[idx]}`; +} + +async function refreshSetupFacts(runtime, runtimeStatus) { + const status = runtimeStatus ?? await invoke("runtime_pack_status").catch(() => null); + const maintenance = await invoke("maintenance_status").catch(() => null); + + if (metaRuntimeEl) { + const size = status?.manifest?.runtimeSize; + metaRuntimeEl.textContent = size + ? `${formatBytes(size)} runtime + model on first use` + : "Runtime manifest bundled"; + } + if (metaWorkspaceEl) { + if (maintenance) { + const cacheBytes = Number(maintenance.cacheBytes || 0) + Number(maintenance.downloadsBytes || 0); + metaWorkspaceEl.textContent = + `${formatBytes(maintenance.jobsBytes)} jobs, ${formatBytes(cacheBytes)} cache`; + } else { + metaWorkspaceEl.textContent = "Created on first launch"; + } + } + if (metaDataDirEl) { + metaDataDirEl.textContent = runtime?.dataDir || maintenance?.dataDir || "App data folder"; + metaDataDirEl.title = metaDataDirEl.textContent; + } +} + function startProgressStatus(messages) { const startedAt = Date.now(); let messageIndex = 0; @@ -87,7 +128,7 @@ async function installRuntimePack(appRoot) { if (!status.manifestReady) { throw Object.assign( new Error(`Python runtime not found under ${appRoot}.`), - { hint: "Try reinstalling StemDeck. If the problem persists, check that your disk has at least 2 GB free." } + { hint: "Try reinstalling LayerLab. If the problem persists, check that your disk has at least 2 GB free." } ); } @@ -117,10 +158,10 @@ async function installRuntimePack(appRoot) { const pct = Math.min(100, Math.round((received / total) * 100)); progressFill.style.width = `${pct}%`; progressFill.classList.remove("indeterminate"); - setStatus(`Downloading StemDeck runtime... ${mb} / ${(total / 1e6).toFixed(0)} MB`); + setStatus(`Downloading LayerLab runtime... ${mb} / ${(total / 1e6).toFixed(0)} MB`); } else { progressFill.classList.add("indeterminate"); - setStatus(`Downloading StemDeck runtime... ${mb} MB received`); + setStatus(`Downloading LayerLab runtime... ${mb} MB received`); } } ); @@ -144,7 +185,7 @@ async function installRuntimePack(appRoot) { } if (!verified) { progressWrap.classList.remove("hidden"); - setStatus("Downloading StemDeck runtime..."); + setStatus("Downloading LayerLab runtime..."); // Reset stall baseline when network download is actually about to start (#150). lastProgressAt = Date.now(); @@ -169,7 +210,7 @@ async function installRuntimePack(appRoot) { // startProgressStatus is assigned after stallTimer creation; the closure // above captures stopSlowMsg by reference, so it sees the updated value. stopSlowMsg = startProgressStatus([ - { afterSeconds: 0, text: "Downloading StemDeck runtime..." }, + { afterSeconds: 0, text: "Downloading LayerLab runtime..." }, { afterSeconds: 30, text: "Still downloading runtime... slow connection detected." }, { afterSeconds: 90, text: "Still downloading... large file on a slow connection can take a few minutes." }, ]); @@ -186,10 +227,10 @@ async function installRuntimePack(appRoot) { if (stopSlowMsg) { stopSlowMsg(); } } progressWrap.classList.add("hidden"); - setStatus("Verifying StemDeck runtime..."); + setStatus("Verifying LayerLab runtime..."); await invoke("verify_runtime_pack"); } - setStatus("Installing StemDeck runtime..."); + setStatus("Installing LayerLab runtime..."); const installed = await invoke("extract_runtime_pack"); if (!installed.runtimeReady) { throw Object.assign( @@ -223,6 +264,7 @@ async function runSetup() { // backend + frontend and the new release (e.g. new features, version) never // takes effect until the runtime is manually cleared. const runtimeStatus = await invoke("runtime_pack_status"); + await refreshSetupFacts(runtime, runtimeStatus); const expectedVersion = runtimeStatus.manifest?.version; const installedVersion = runtimeStatus.installedVersion; // Mismatch when this build expects a version the installed runtime isn't. @@ -241,9 +283,9 @@ async function runSetup() { } } await runStep("backend", async () => { - setStatus("Runtime is ready. Starting StemDeck backend..."); + setStatus("Runtime is ready. Starting LayerLab backend..."); const backend = await invoke("start_backend"); - setStatus("Opening StemDeck..."); + setStatus("Opening LayerLab..."); window.location.replace(backend.url); }); return; @@ -260,7 +302,7 @@ async function runSetup() { setStep("runtime", "error"); throw Object.assign( new Error(`Python runtime setup failed under: ${runtime.dataDir}`), - { hint: "Check that your disk has at least 2 GB free and click Retry. If it keeps failing, try reinstalling StemDeck." } + { hint: "Check that your disk has at least 2 GB free and click Retry. If it keeps failing, try reinstalling LayerLab." } ); } } @@ -271,6 +313,7 @@ async function runSetup() { let gpuSummary = ""; await runStep("workspace", () => invoke("ensure_workspace")); + await refreshSetupFacts(runtime); if (runtime.ffmpegReady) { setStep("ffmpeg", "done"); @@ -358,9 +401,9 @@ async function runSetup() { setStatus("AI separation model will download on first use (~340 MB)."); await runStep("backend", async () => { - setStatus(gpuSummary ? `${gpuSummary} - starting backend...` : "Starting StemDeck backend..."); + setStatus(gpuSummary ? `${gpuSummary} - starting backend...` : "Starting LayerLab backend..."); const backend = await invoke("start_backend"); - setStatus("Opening StemDeck..."); + setStatus("Opening LayerLab..."); window.location.replace(backend.url); }); } catch (error) { diff --git a/imgs/stemdeck-svg-assets/stemdeck-icon.svg b/imgs/stemdeck-svg-assets/stemdeck-icon.svg index 0ebce18..7fa4740 100644 --- a/imgs/stemdeck-svg-assets/stemdeck-icon.svg +++ b/imgs/stemdeck-svg-assets/stemdeck-icon.svg @@ -1,5 +1,5 @@ - StemDeck App Icon + LayerLab App Icon Dark rounded square app icon with a golden segmented waveform. diff --git a/imgs/stemdeck-svg-assets/stemdeck-logo-horizontal.svg b/imgs/stemdeck-svg-assets/stemdeck-logo-horizontal.svg index b999fac..6f89817 100644 --- a/imgs/stemdeck-svg-assets/stemdeck-logo-horizontal.svg +++ b/imgs/stemdeck-svg-assets/stemdeck-logo-horizontal.svg @@ -1,6 +1,6 @@ - StemDeck Horizontal Logo - StemDeck logo with golden waveform symbol and wordmark. + LayerLab Horizontal Logo + LayerLab logo with golden waveform symbol and wordmark. @@ -19,7 +19,7 @@ - Stem - Deck + Layer + Lab POWERED STEM SEPARATION diff --git a/imgs/stemdeck-svg-assets/stemdeck-logo-stacked.svg b/imgs/stemdeck-svg-assets/stemdeck-logo-stacked.svg index 010c71f..9a64f68 100644 --- a/imgs/stemdeck-svg-assets/stemdeck-logo-stacked.svg +++ b/imgs/stemdeck-svg-assets/stemdeck-logo-stacked.svg @@ -1,6 +1,6 @@ - StemDeck Stacked Logo - Stacked StemDeck logo with waveform symbol above the wordmark. + LayerLab Stacked Logo + Stacked LayerLab logo with waveform symbol above the wordmark. @@ -19,6 +19,6 @@ - Stem Deck + LayerLab POWERED STEM SEPARATION diff --git a/imgs/stemdeck-svg-assets/stemdeck-tray-light.svg b/imgs/stemdeck-svg-assets/stemdeck-tray-light.svg index 7408017..7138111 100644 --- a/imgs/stemdeck-svg-assets/stemdeck-tray-light.svg +++ b/imgs/stemdeck-svg-assets/stemdeck-tray-light.svg @@ -1,5 +1,5 @@ - StemDeck Tray Icon Light + LayerLab Tray Icon Light diff --git a/imgs/stemdeck-svg-assets/stemdeck-tray-monochrome.svg b/imgs/stemdeck-svg-assets/stemdeck-tray-monochrome.svg index bf2c320..f122354 100644 --- a/imgs/stemdeck-svg-assets/stemdeck-tray-monochrome.svg +++ b/imgs/stemdeck-svg-assets/stemdeck-tray-monochrome.svg @@ -1,5 +1,5 @@ - StemDeck Tray Icon Monochrome + LayerLab Tray Icon Monochrome diff --git a/imgs/stemdeck-svg-assets/stemdeck-waveform-symbol.svg b/imgs/stemdeck-svg-assets/stemdeck-waveform-symbol.svg index cc3dc11..c6eb8d4 100644 --- a/imgs/stemdeck-svg-assets/stemdeck-waveform-symbol.svg +++ b/imgs/stemdeck-svg-assets/stemdeck-waveform-symbol.svg @@ -1,6 +1,6 @@ - StemDeck Waveform Symbol - Golden segmented waveform symbol for StemDeck. + LayerLab Waveform Symbol + Golden segmented waveform symbol for LayerLab. diff --git a/imgs/stemdeck-svg-assets/stemdeck-wordmark.svg b/imgs/stemdeck-svg-assets/stemdeck-wordmark.svg index c7786f7..68842cd 100644 --- a/imgs/stemdeck-svg-assets/stemdeck-wordmark.svg +++ b/imgs/stemdeck-svg-assets/stemdeck-wordmark.svg @@ -1,6 +1,6 @@ - StemDeck Wordmark - Stem - Deck + LayerLab Wordmark + Layer + Lab POWERED STEM SEPARATION diff --git a/packaging/RELEASE_NOTES_UNOFFICIAL_TEST_BUILD.md b/packaging/RELEASE_NOTES_UNOFFICIAL_TEST_BUILD.md new file mode 100644 index 0000000..8611165 --- /dev/null +++ b/packaging/RELEASE_NOTES_UNOFFICIAL_TEST_BUILD.md @@ -0,0 +1,52 @@ +# LayerLab unofficial StemDeck fork test build + +This is an unofficial modified fork test build of StemDeck: +https://github.com/stemdeckapp/stemdeck + +It is not an official upstream release and is not affiliated with or endorsed by +the original StemDeck project. + +License: Apache License 2.0. This distribution includes LICENSE, NOTICE, and +THIRD_PARTY_NOTICES.txt. Please review those files before redistribution. + +Apache License 2.0 permits commercial use and paid redistribution when its +conditions are met. It does not grant trademark rights. Do not present this +test build as an official StemDeck release, official commercial offering, +certified build, or upstream-supported product. + +## Assets + +- `LayerLab-macOS-arm64.dmg` +- `LayerLab-runtime-macOS-arm64.tar.zst` +- `SHA256SUMS-macOS-arm64.txt` + +## Fork changes + +- Higher-quality separation presets and Demucs tuning. +- Float32-oriented processing and clipping-resistant preprocessing. +- Bass dropout repair and phase/residual repair. +- Optional per-stem denoise. +- Queue, cancellation, progress, and ETA improvements. +- Neon UI and responsive layout refinements. +- Desktop setup, maintenance, signing, and distribution notice improvements. + +## Notes + +- This is a test build. Use at your own risk. +- macOS public builds should be Developer ID signed and notarized before broad + distribution. +- First launch downloads a pinned runtime pack, FFmpeg/ffprobe, and later the + Demucs model cache. +- Audio processing runs locally on the user's machine. + +## 日本語 + +これは元の StemDeck をベースにした非公式の変更版 fork test build です。 +元プロジェクトの公式リリースではなく、元プロジェクトとの提携、承認、 +推奨を示すものではありません。 + +Apache License 2.0 に基づき、配布物には LICENSE、NOTICE、 +THIRD_PARTY_NOTICES.txt を含めています。再配布前に必ず確認してください。 +Apache License 2.0 は商用利用や有償配布自体を禁止していませんが、 +商標権を自動許諾するものではありません。この test build を公式版、 +公式販売物、認定ビルド、元プロジェクトのサポート対象のように表示しないでください。 diff --git a/packaging/macos/README-macOS.txt b/packaging/macos/README-macOS.txt index a550295..39835e7 100644 --- a/packaging/macos/README-macOS.txt +++ b/packaging/macos/README-macOS.txt @@ -1,31 +1,47 @@ -StemDeck for macOS -================== +LayerLab for macOS +============================ Install: -1. Open the StemDeck DMG. -2. Drag StemDeck.app to Applications. -3. Open StemDeck from Applications. +1. Open the LayerLab DMG. +2. Drag LayerLab.app to Applications. +3. Open LayerLab from Applications. First launch: -- StemDeck is a thin native app. It downloads a pinned, checksummed StemDeck - runtime pack on first launch. +- LayerLab is a thin native app. It downloads a pinned, checksummed + LayerLab runtime pack on first launch. +- The setup screen shows the expected runtime download size, current + workspace/cache usage, the data directory, and the bundled license notice. - The runtime installs to: - ~/Library/Application Support/StemDeck/runtime + ~/Library/Application Support/LayerLab/runtime - FFmpeg and ffprobe install to: - ~/Library/Application Support/StemDeck/ffmpeg + ~/Library/Application Support/LayerLab/ffmpeg - Demucs model weights download on first use and are cached under: - ~/Library/Application Support/StemDeck/models + ~/Library/Application Support/LayerLab/models Uninstall: -1. Delete /Applications/StemDeck.app. +1. Delete /Applications/LayerLab.app. 2. To remove runtime files, jobs, caches, models, and logs, delete: - ~/Library/Application Support/StemDeck + ~/Library/Application Support/LayerLab Notes: - Internet access is required for first-run setup. -- Public releases should be signed and notarized. +- Public releases should be Developer ID signed and notarized. Local/internal + builds may be unsigned; set APPLE_SIGNING_IDENTITY and APPLE_NOTARIZE=1 in + the release scripts to enable signing and notarization. - Unsigned local builds are for development and internal testing only. +- LayerLab is an unofficial modified fork test build based on the + original StemDeck project and is distributed under the Apache License 2.0. + See LICENSE and NOTICE in the DMG for attribution. +- This package is not an official upstream release and is not affiliated with or + endorsed by the original StemDeck project. +- Apache License 2.0 permits commercial use and paid redistribution when its + conditions are met, but it does not grant trademark rights. Do not present + this package as an official StemDeck release, official commercial offering, + certified build, or upstream-supported product. +- Third-party notices are included in THIRD_PARTY_NOTICES.txt. Final public + releases should verify the exact Python, FFmpeg, Demucs, PyTorch, Tauri, and + Rust dependency licenses used in the shipped artifacts. diff --git a/packaging/macos/THIRD_PARTY_NOTICES.txt b/packaging/macos/THIRD_PARTY_NOTICES.txt index 171b32c..d9cd7bb 100644 --- a/packaging/macos/THIRD_PARTY_NOTICES.txt +++ b/packaging/macos/THIRD_PARTY_NOTICES.txt @@ -1,16 +1,41 @@ THIRD-PARTY NOTICES =================== -StemDeck for macOS uses a thin Tauri app plus a downloaded runtime pack. +LayerLab for macOS uses a thin Tauri app plus a downloaded runtime pack. -Bundled in StemDeck.app: +Project License and Upstream Attribution +---------------------------------------- + +LayerLab is an unofficial modified fork test build based on the original StemDeck project: +https://github.com/stemdeckapp/stemdeck + +The original StemDeck project and this fork are licensed under the Apache License, Version 2.0. +This package is not an official upstream release and is not affiliated with or endorsed by the original StemDeck project. +See LICENSE and NOTICE in this distribution for the full license text, +upstream attribution, and modification notice. + +Bundled in LayerLab.app: - Tauri v2 - The system WebKit WebView provided by macOS +- Rust crates used by the Tauri shell, including reqwest, serde, sha2, tar, + zip, zstd, flate2, and Tauri plugins Downloaded during first-run setup: -- StemDeck runtime pack containing Python and Python package dependencies +- LayerLab runtime pack containing Python and Python package dependencies +- Python +- FastAPI +- Uvicorn +- yt-dlp +- Demucs +- PyTorch / Torch +- torchaudio +- librosa +- pyloudnorm +- soundfile +- imageio-ffmpeg (BSD-2-Clause), used by the Python runtime as a portable + FFmpeg executable fallback when needed - FFmpeg and ffprobe - Demucs model weights @@ -18,3 +43,6 @@ The runtime pack includes its own dependency inventory under runtime/licenses/pip-list.json. Before public release, generate and include full license texts for every packaged dependency and verify the exact FFmpeg build license/provenance. + +This notice is not legal advice. It is a distribution checklist for the current +packaging flow; the final shipped artifacts remain the source of truth. diff --git a/packaging/macos/dmg-background.svg b/packaging/macos/dmg-background.svg index 5b7cb97..adb1bbf 100644 --- a/packaging/macos/dmg-background.svg +++ b/packaging/macos/dmg-background.svg @@ -28,5 +28,5 @@ - Drag StemDeck to Applications + Drag LayerLab to Applications diff --git a/packaging/windows/README-WINDOWS.txt b/packaging/windows/README-WINDOWS.txt index 968658b..310c6a5 100644 --- a/packaging/windows/README-WINDOWS.txt +++ b/packaging/windows/README-WINDOWS.txt @@ -1,11 +1,11 @@ -StemDeck Windows Portable Alpha -=============================== +LayerLab Windows Portable Test Build +============================================== Run --- 1. Extract the zip folder. -2. Double-click StemDeck.exe. +2. Double-click LayerLab.exe. 3. Let first-run setup prepare local runtime assets. Notes @@ -13,9 +13,25 @@ Notes - This is a portable folder, not an installer. - No Start Menu shortcut, service, or registry integration is created. +- To create a Windows installer, build this portable package first and wrap it + with scripts/windows/make-installer.ps1 on a Windows machine with Inno Setup 6. +- Public releases should Authenticode-sign LayerLab.exe when possible. + Local unsigned zips are intended for development/internal testing. - Generated files stay under data/. - FFmpeg is downloaded during first-run setup into data/ffmpeg/. - Demucs model weights are downloaded by the backend on first use into data/models/. +- LayerLab is an unofficial modified fork test build based on the + original StemDeck project and is distributed under the Apache License 2.0. + See LICENSE and NOTICE in this folder for attribution. +- This package is not an official upstream release and is not affiliated with or + endorsed by the original StemDeck project. +- Apache License 2.0 permits commercial use and paid redistribution when its + conditions are met, but it does not grant trademark rights. Do not present + this package as an official StemDeck release, official commercial offering, + certified build, or upstream-supported product. +- Third-party notices are included in THIRD_PARTY_NOTICES.txt. Final public + releases should verify the exact Python, FFmpeg, Demucs, PyTorch, Tauri, and + Rust dependency licenses used in the shipped artifacts. Troubleshooting --------------- diff --git a/packaging/windows/THIRD_PARTY_NOTICES.txt b/packaging/windows/THIRD_PARTY_NOTICES.txt index 5e5294a..1744eb8 100644 --- a/packaging/windows/THIRD_PARTY_NOTICES.txt +++ b/packaging/windows/THIRD_PARTY_NOTICES.txt @@ -1,9 +1,20 @@ THIRD-PARTY NOTICES =================== -StemDeck includes third-party open-source software. Each component is +LayerLab includes third-party open-source software. Each component is copyrighted by its respective authors and distributed under its own license. +Project License and Upstream Attribution +---------------------------------------- + +LayerLab is an unofficial modified fork test build based on the original StemDeck project: +https://github.com/stemdeckapp/stemdeck + +The original StemDeck project and this fork are licensed under the Apache License, Version 2.0. +This package is not an official upstream release and is not affiliated with or endorsed by the original StemDeck project. +See LICENSE and NOTICE in this distribution for the full license text, +upstream attribution, and modification notice. + This starter notice is not a substitute for the full license inventory that must be generated from the final packaged Python runtime before a public release. @@ -19,6 +30,10 @@ Tauri License: MIT or Apache-2.0, depending on component Website: https://tauri.app/ +Rust crates used by the Tauri shell +License: varies by crate +Inventory source: desktop/src-tauri/Cargo.lock + FastAPI License: MIT Website: https://fastapi.tiangolo.com/ @@ -55,16 +70,20 @@ soundfile License: BSD Website: https://github.com/bastibe/python-soundfile +imageio-ffmpeg +License: BSD-2-Clause +Website: https://github.com/imageio/imageio-ffmpeg + Downloaded During First-Run Setup --------------------------------- -FFmpeg binaries are not bundled in the StemDeck alpha zip. They are downloaded +FFmpeg binaries are not bundled in the LayerLab test zip. They are downloaded during first-run setup. FFmpeg builds may be distributed under LGPL or GPL -terms depending on how they are compiled. StemDeck should display the selected +terms depending on how they are compiled. LayerLab should display the selected FFmpeg build source and license before or during download. -Demucs model weights are not bundled in the StemDeck alpha zip. They are -downloaded during first use. StemDeck should display the model source and +Demucs model weights are not bundled in the LayerLab test zip. They are +downloaded during first use. LayerLab should display the model source and license/usage terms before or during download. Disclaimer diff --git a/packaging/windows/layerlab.iss b/packaging/windows/layerlab.iss new file mode 100644 index 0000000..c1aeb58 --- /dev/null +++ b/packaging/windows/layerlab.iss @@ -0,0 +1,58 @@ +#define AppName "LayerLab" +#ifndef AppVersion +#define AppVersion "0.0.0" +#endif +#ifndef SourceDir +#define SourceDir "..\..\dist\LayerLab-Windows-x64.NVIDIA" +#endif +#ifndef OutputDir +#define OutputDir "..\..\dist" +#endif +#ifndef OutputBaseFilename +#define OutputBaseFilename "LayerLab-Windows-x64.NVIDIA-Setup" +#endif + +[Setup] +AppId={{A8EE5C18-8B1D-447C-9E3F-3BADDEC37136} +AppName={#AppName} +AppVersion={#AppVersion} +AppVerName={#AppName} {#AppVersion} +AppPublisher=LayerLab unofficial StemDeck fork test build +AppPublisherURL=https://github.com/bassmicrobe/stemdeck +AppSupportURL=https://github.com/bassmicrobe/stemdeck/issues +AppUpdatesURL=https://github.com/bassmicrobe/stemdeck/releases +DefaultDirName={localappdata}\Programs\LayerLab +DefaultGroupName=LayerLab +DisableProgramGroupPage=yes +DisableReadyPage=no +PrivilegesRequired=lowest +ArchitecturesAllowed=x64 +WizardStyle=modern +OutputDir={#OutputDir} +OutputBaseFilename={#OutputBaseFilename} +Compression=lzma2/ultra64 +SolidCompression=yes +UninstallDisplayIcon={app}\LayerLab.exe +LicenseFile={#SourceDir}\LICENSE +InfoBeforeFile={#SourceDir}\README-WINDOWS.txt + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" +Name: "japanese"; MessagesFile: "compiler:Languages\Japanese.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[InstallDelete] +Type: filesandordirs; Name: "{app}\backend" +Type: filesandordirs; Name: "{app}\python" + +[Files] +Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\LayerLab"; Filename: "{app}\LayerLab.exe"; WorkingDir: "{app}" +Name: "{autodesktop}\LayerLab"; Filename: "{app}\LayerLab.exe"; WorkingDir: "{app}"; Tasks: desktopicon + +[Run] +Filename: "{app}\LayerLab.exe"; Description: "{cm:LaunchProgram,LayerLab}"; Flags: nowait postinstall skipifsilent diff --git a/pyproject.toml b/pyproject.toml index 1ae36c2..7fc870b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [project] -name = "stemdeck" +name = "layerlab" # Version is derived from the git tag by hatch-vcs (see [tool.hatch.version]), # so it is never hand-edited — `git tag vX.Y.Z` is the single source of truth. # Builds between tags report a dev version (e.g. 0.7.0a5.dev3+g). (#169) dynamic = ["version"] -description = "Paste a YouTube URL, get audio stems split into a DAW-style player." +description = "LayerLab local audio stem separation with a DAW-style player." requires-python = ">=3.10,<3.14" dependencies = [ "fastapi>=0.115,!=0.136.3", @@ -40,6 +40,9 @@ dependencies = [ # FastAPI multipart/form-data (file uploads) unconditionally require # python-multipart — it is not an optional extra. "python-multipart>=0.0.9", + # Provides a portable ffmpeg binary fallback for local runs where ffmpeg is + # not installed on PATH. Desktop builds still prefer their bundled binary. + "imageio-ffmpeg>=0.5", # CVE-2026-44431 / CVE-2026-44432: sensitive header forwarding and # decompression-bomb bypass fixed in 2.7.0. urllib3 is a transitive # dep via requests; pin floor to pull in the fix. diff --git a/run.sh b/run.sh index 2f79507..97a27b7 100755 --- a/run.sh +++ b/run.sh @@ -6,7 +6,7 @@ set -euo pipefail cd "$(dirname "$0")" HOST="${HOST:-127.0.0.1}" -PORT="${PORT:-8000}" +PORT="${PORT:-8765}" RELOAD="${RELOAD:-0}" FOREGROUND="${FOREGROUND:-0}" PID_FILE=".run/uvicorn.pid" @@ -19,6 +19,23 @@ is_running() { [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null } +configure_ffmpeg() { + if [[ -n "${STEMDECK_FFMPEG:-}" ]]; then + return 0 + fi + if command -v ffmpeg >/dev/null 2>&1; then + return 0 + fi + if command -v uv >/dev/null 2>&1; then + echo "ffmpeg not found on PATH; using uv-managed imageio-ffmpeg binary" + STEMDECK_FFMPEG="$(uv run --with imageio-ffmpeg python -c 'import imageio_ffmpeg; print(imageio_ffmpeg.get_ffmpeg_exe())')" + export STEMDECK_FFMPEG + return 0 + fi + echo "ffmpeg not found. Run './run.sh setup' or set STEMDECK_FFMPEG=/path/to/ffmpeg" >&2 + exit 1 +} + start() { if is_running; then echo "already running (pid $(cat "$PID_FILE"))" @@ -28,6 +45,7 @@ start() { echo "uvicorn not found at $UVICORN — run: uv sync" >&2 exit 1 fi + configure_ffmpeg echo "starting on http://$HOST:$PORT" local args=(app.main:app --host "$HOST" --port "$PORT") if [[ "$RELOAD" == "1" ]]; then diff --git a/scripts/benchmark_audio.py b/scripts/benchmark_audio.py new file mode 100755 index 0000000..0cde41b --- /dev/null +++ b/scripts/benchmark_audio.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.pipeline.benchmark import ( # noqa: E402 + benchmark_audio, + benchmark_job_dir, + benchmark_jobs_root, + compare_benchmark_reports, +) + + +def _positive_float(value: str) -> float: + parsed = float(value) + if parsed <= 0: + raise argparse.ArgumentTypeError("must be greater than 0") + return parsed + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Measure LayerLab stem reconstruction and chord metadata quality." + ) + parser.add_argument("--job-dir", type=Path, help="Existing LayerLab job directory.") + parser.add_argument("--jobs-root", type=Path, help="Directory containing multiple LayerLab jobs.") + parser.add_argument("--source", type=Path, help="Original source audio to compare against.") + parser.add_argument("--stems-dir", type=Path, help="Directory containing stem WAV files.") + parser.add_argument("--metadata", type=Path, help="metadata.json containing chord progression.") + parser.add_argument("--baseline", type=Path, help="Previous suite JSON to compare against.") + parser.add_argument("--sr", type=int, default=44100, help="Benchmark decode sample rate.") + parser.add_argument( + "--duration", + type=_positive_float, + default=180.0, + help="Seconds to benchmark from the start of each file.", + ) + parser.add_argument("--out", type=Path, help="Write JSON report to this file.") + args = parser.parse_args() + + comparison = None + if args.jobs_root: + report = benchmark_jobs_root( + args.jobs_root, + sr=args.sr, + duration=args.duration, + ) + elif args.job_dir: + report = benchmark_job_dir( + args.job_dir, + source=args.source, + sr=args.sr, + duration=args.duration, + ) + else: + if not args.stems_dir: + parser.error("--stems-dir is required when --job-dir is not used") + report = benchmark_audio( + source=args.source, + stems_dir=args.stems_dir, + metadata_path=args.metadata, + sr=args.sr, + duration=args.duration, + ) + if args.baseline: + baseline = json.loads(args.baseline.read_text(encoding="utf-8")) + comparison = compare_benchmark_reports(report, baseline) + report = {"report": report, "comparison": comparison} + + text = json.dumps(report, indent=2, sort_keys=True) + "\n" + if args.out: + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(text, encoding="utf-8") + else: + print(text, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-distribution-notices.sh b/scripts/check-distribution-notices.sh new file mode 100755 index 0000000..579d215 --- /dev/null +++ b/scripts/check-distribution-notices.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +require_file() { + local path="$1" + if [[ ! -f "$REPO_ROOT/$path" ]]; then + echo "ERROR: missing required distribution file: $path" >&2 + exit 1 + fi +} + +require_text() { + local path="$1" + local text="$2" + if ! grep -Fq "$text" "$REPO_ROOT/$path"; then + echo "ERROR: '$path' does not contain required text: $text" >&2 + exit 1 + fi +} + +require_file "LICENSE" +require_file "NOTICE" +require_file "README.md" +require_file "README.ja.md" +require_file "packaging/macos/README-macOS.txt" +require_file "packaging/macos/THIRD_PARTY_NOTICES.txt" +require_file "packaging/windows/README-WINDOWS.txt" +require_file "packaging/windows/THIRD_PARTY_NOTICES.txt" +require_file "packaging/RELEASE_NOTES_UNOFFICIAL_TEST_BUILD.md" + +require_text "NOTICE" "https://github.com/stemdeckapp/stemdeck" +require_text "NOTICE" "Apache License, Version 2.0" +require_text "NOTICE" "Modification notice" +require_text "NOTICE" "LayerLab" +require_text "NOTICE" "not an official upstream release" +require_text "NOTICE" "not affiliated with or endorsed" +require_text "README.ja.md" "元プロジェクト" +require_text "README.ja.md" "Apache License 2.0" +require_text "README.ja.md" "LayerLab" +require_text "README.ja.md" "公式リリースではありません" +require_text "README.ja.md" "非公式 fork test build" +require_text "README.ja.md" "この fork で加えた主な変更" +require_text "README.md" "README.ja.md" +require_text "README.md" "unofficial modified fork" +require_text "README.md" "not affiliated with" +require_text "packaging/RELEASE_NOTES_UNOFFICIAL_TEST_BUILD.md" "unofficial modified fork test build" +require_text "packaging/RELEASE_NOTES_UNOFFICIAL_TEST_BUILD.md" "not affiliated with or endorsed" +require_text "packaging/macos/README-macOS.txt" "LayerLab" +require_text "packaging/macos/README-macOS.txt" "not affiliated with or" +require_text "packaging/windows/README-WINDOWS.txt" "LayerLab" +require_text "packaging/windows/README-WINDOWS.txt" "not affiliated with or" +require_text "packaging/macos/THIRD_PARTY_NOTICES.txt" "LayerLab" +require_text "packaging/windows/THIRD_PARTY_NOTICES.txt" "LayerLab" + +for script in scripts/macos/make-app.sh scripts/macos/make-dmg.sh scripts/windows/make-portable.ps1 scripts/windows/make-installer.ps1; do + require_text "$script" "LICENSE" + require_text "$script" "NOTICE" + require_text "$script" "THIRD_PARTY_NOTICES" +done + +require_text "scripts/macos/make-app.sh" "APPLE_SIGNING_IDENTITY" +require_text "scripts/macos/make-dmg.sh" "APPLE_NOTARIZE" +require_text "scripts/windows/make-portable.ps1" "WINDOWS_SIGN_CERT_PATH" +require_text "scripts/windows/make-installer.ps1" "WINDOWS_SIGN_CERT_PATH" + +echo "distribution notice check: OK" diff --git a/scripts/macos/make-app.sh b/scripts/macos/make-app.sh index 61fb095..0b14ad4 100755 --- a/scripts/macos/make-app.sh +++ b/scripts/macos/make-app.sh @@ -48,12 +48,12 @@ else TARGET_DIR="$REPO_ROOT/desktop/src-tauri/target/x86_64-apple-darwin/release" fi -APP_DIR="${TARGET_DIR}/bundle/macos/StemDeck.app" +APP_DIR="${TARGET_DIR}/bundle/macos/LayerLab.app" if [[ ! -d "$APP_DIR" ]]; then - APP_DIR="$(find "$REPO_ROOT/desktop/src-tauri/target" -path '*/bundle/macos/StemDeck.app' -type d | head -1)" + APP_DIR="$(find "$REPO_ROOT/desktop/src-tauri/target" -path '*/bundle/macos/LayerLab.app' -type d | head -1)" fi if [[ -z "$APP_DIR" || ! -d "$APP_DIR" ]]; then - echo "ERROR: could not find built StemDeck.app" >&2 + echo "ERROR: could not find built LayerLab.app" >&2 exit 1 fi @@ -70,5 +70,35 @@ if [[ -f "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" ]]; then cp "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" "$RESOURCES/THIRD_PARTY_NOTICES.txt" fi +if [[ -f "$REPO_ROOT/LICENSE" ]]; then + cp "$REPO_ROOT/LICENSE" "$RESOURCES/LICENSE" +fi + +if [[ -f "$REPO_ROOT/NOTICE" ]]; then + cp "$REPO_ROOT/NOTICE" "$RESOURCES/NOTICE" +fi + +xattr -cr "$APP_DIR" >/dev/null 2>&1 || true + +if [[ -n "${APPLE_SIGNING_IDENTITY:-}" ]]; then + echo "==> Signing app with identity: ${APPLE_SIGNING_IDENTITY}" + codesign_args=(--force --deep --options runtime) + if [[ -n "${APPLE_ENTITLEMENTS:-}" ]]; then + if [[ ! -f "$APPLE_ENTITLEMENTS" ]]; then + echo "ERROR: APPLE_ENTITLEMENTS does not exist: $APPLE_ENTITLEMENTS" >&2 + exit 1 + fi + codesign_args+=(--entitlements "$APPLE_ENTITLEMENTS") + fi + if [[ "${APPLE_CODESIGN_TIMESTAMP:-1}" == "1" ]]; then + codesign_args+=(--timestamp) + fi + codesign_args+=(--sign "$APPLE_SIGNING_IDENTITY" "$APP_DIR") + codesign "${codesign_args[@]}" + codesign --verify --deep --strict --verbose=2 "$APP_DIR" +else + echo "==> Skipping app signing (set APPLE_SIGNING_IDENTITY to sign)" +fi + echo "$APP_DIR" > "$BUILD_DIR/app-path-${ARCH}.txt" echo "==> App ready: $APP_DIR" diff --git a/scripts/macos/make-dmg.sh b/scripts/macos/make-dmg.sh index 834fb8f..f14dc59 100755 --- a/scripts/macos/make-dmg.sh +++ b/scripts/macos/make-dmg.sh @@ -7,15 +7,13 @@ VERSION="${VERSION#v}" REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" BUILD_DIR="${REPO_ROOT}/.build" DIST_DIR="${BUILD_DIR}/macos-dist" -DMG_STAGING="${BUILD_DIR}/dmg-staging-${ARCH}" -DMG_NAME="StemDeck-macOS-${ARCH}.dmg" +DMG_NAME="LayerLab-macOS-${ARCH}.dmg" DMG_PATH="${DIST_DIR}/${DMG_NAME}" -DMG_RW_PATH="${DIST_DIR}/StemDeck-macOS-${ARCH}.rw.dmg" -RUNTIME_NAME="StemDeck-runtime-macOS-${ARCH}.tar.zst" +DMG_RW_PATH="${DIST_DIR}/LayerLab-macOS-${ARCH}.rw.dmg" +RUNTIME_NAME="LayerLab-runtime-macOS-${ARCH}.tar.zst" RUNTIME_PATH="${BUILD_DIR}/${RUNTIME_NAME}" -BACKGROUND_SRC="${REPO_ROOT}/packaging/macos/dmg-background.svg" -BACKGROUND_DIR_NAME=".background" -BACKGROUND_PNG_NAME="dmg-background.png" +APP_BUNDLE_NAME="LayerLab.app" +DMG_SIZE="${DMG_SIZE:-256m}" if [[ "$(uname)" != "Darwin" ]]; then echo "ERROR: make-dmg.sh must run on macOS" >&2 @@ -27,7 +25,7 @@ if [[ "$ARCH" != "arm64" && "$ARCH" != "x64" ]]; then exit 1 fi -for cmd in ditto hdiutil qlmanage shasum; do +for cmd in ditto hdiutil shasum; do if ! command -v "$cmd" >/dev/null 2>&1; then echo "ERROR: required command not found on PATH: $cmd" >&2 exit 1 @@ -35,9 +33,9 @@ for cmd in ditto hdiutil qlmanage shasum; do done if [[ "$ARCH" == "arm64" ]]; then - APP_DIR="${REPO_ROOT}/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app" + APP_DIR="${REPO_ROOT}/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/${APP_BUNDLE_NAME}" else - APP_DIR="${REPO_ROOT}/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/StemDeck.app" + APP_DIR="${REPO_ROOT}/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/${APP_BUNDLE_NAME}" fi if [[ ! -d "$APP_DIR" ]]; then @@ -52,33 +50,16 @@ if [[ ! -f "$RUNTIME_PATH" ]]; then exit 1 fi -rm -rf "$DMG_STAGING" -mkdir -p "$DMG_STAGING" "$DIST_DIR" -mkdir -p "$DMG_STAGING/$BACKGROUND_DIR_NAME" - -ditto "$APP_DIR" "$DMG_STAGING/StemDeck.app" -ln -s /Applications "$DMG_STAGING/Applications" - -if [[ -f "$BACKGROUND_SRC" ]]; then - qlmanage -t -s 1320 -o "$DMG_STAGING/$BACKGROUND_DIR_NAME" "$BACKGROUND_SRC" >/dev/null 2>&1 - mv "$DMG_STAGING/$BACKGROUND_DIR_NAME/dmg-background.svg.png" "$DMG_STAGING/$BACKGROUND_DIR_NAME/$BACKGROUND_PNG_NAME" -fi - -if [[ -f "$REPO_ROOT/packaging/macos/README-macOS.txt" ]]; then - cp "$REPO_ROOT/packaging/macos/README-macOS.txt" "$DMG_STAGING/README-macOS.txt" -fi - -if [[ -f "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" ]]; then - cp "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" "$DMG_STAGING/THIRD_PARTY_NOTICES.txt" -fi - rm -f "$DMG_PATH" "$DMG_RW_PATH" +mkdir -p "$DIST_DIR" + hdiutil create \ - -volname "StemDeck" \ - -srcfolder "$DMG_STAGING" \ + -size "$DMG_SIZE" \ + -type UDIF \ + -fs APFS \ + -volname "LayerLab" \ -ov \ - -format UDRW \ - "$DMG_RW_PATH" + "$DMG_RW_PATH" >/dev/null MOUNT_DIR="$(mktemp -d /tmp/stemdeck-dmg.XXXXXX)" cleanup_mount() { @@ -89,35 +70,45 @@ trap cleanup_mount EXIT hdiutil attach "$DMG_RW_PATH" -readwrite -noverify -nobrowse -mountpoint "$MOUNT_DIR" >/dev/null -if command -v SetFile >/dev/null 2>&1; then - SetFile -a V "$MOUNT_DIR/$BACKGROUND_DIR_NAME" || true - SetFile -a V "$MOUNT_DIR/README-macOS.txt" || true - SetFile -a V "$MOUNT_DIR/THIRD_PARTY_NOTICES.txt" || true +ditto --noextattr "$APP_DIR" "$MOUNT_DIR/$APP_BUNDLE_NAME" +ln -s /Applications "$MOUNT_DIR/Applications" + +if [[ -f "$REPO_ROOT/packaging/macos/README-macOS.txt" ]]; then + cp "$REPO_ROOT/packaging/macos/README-macOS.txt" "$MOUNT_DIR/README-macOS.txt" +fi + +if [[ -f "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" ]]; then + cp "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" "$MOUNT_DIR/THIRD_PARTY_NOTICES.txt" +fi + +if [[ -f "$REPO_ROOT/LICENSE" ]]; then + cp "$REPO_ROOT/LICENSE" "$MOUNT_DIR/LICENSE" +fi + +if [[ -f "$REPO_ROOT/NOTICE" ]]; then + cp "$REPO_ROOT/NOTICE" "$MOUNT_DIR/NOTICE" fi -if [[ -f "$MOUNT_DIR/$BACKGROUND_DIR_NAME/$BACKGROUND_PNG_NAME" ]]; then - osascript </dev/null 2>&1 || true +if [[ -n "${APPLE_SIGNING_IDENTITY:-}" ]]; then + echo "==> Signing mounted app with identity: ${APPLE_SIGNING_IDENTITY}" + app_codesign_args=(--force --deep --options runtime) + if [[ -n "${APPLE_ENTITLEMENTS:-}" ]]; then + if [[ ! -f "$APPLE_ENTITLEMENTS" ]]; then + echo "ERROR: APPLE_ENTITLEMENTS does not exist: $APPLE_ENTITLEMENTS" >&2 + exit 1 + fi + app_codesign_args+=(--entitlements "$APPLE_ENTITLEMENTS") + fi + if [[ "${APPLE_CODESIGN_TIMESTAMP:-1}" == "1" ]]; then + app_codesign_args+=(--timestamp) + fi + app_codesign_args+=(--sign "$APPLE_SIGNING_IDENTITY" "$MOUNT_DIR/$APP_BUNDLE_NAME") + codesign "${app_codesign_args[@]}" + codesign --verify --deep --strict --verbose=2 "$MOUNT_DIR/$APP_BUNDLE_NAME" fi sync @@ -128,6 +119,45 @@ trap - EXIT hdiutil convert "$DMG_RW_PATH" -format UDZO -imagekey zlib-level=9 -o "$DMG_PATH" >/dev/null rm -f "$DMG_RW_PATH" +if [[ -n "${APPLE_SIGNING_IDENTITY:-}" ]]; then + echo "==> Signing DMG with identity: ${APPLE_SIGNING_IDENTITY}" + dmg_codesign_args=(--force) + if [[ "${APPLE_CODESIGN_TIMESTAMP:-1}" == "1" ]]; then + dmg_codesign_args+=(--timestamp) + fi + dmg_codesign_args+=(--sign "$APPLE_SIGNING_IDENTITY" "$DMG_PATH") + codesign "${dmg_codesign_args[@]}" + codesign --verify --verbose=2 "$DMG_PATH" +else + echo "==> Skipping DMG signing (set APPLE_SIGNING_IDENTITY to sign)" +fi + +if [[ "${APPLE_NOTARIZE:-0}" == "1" ]]; then + echo "==> Submitting DMG for notarization" + if ! command -v xcrun >/dev/null 2>&1; then + echo "ERROR: xcrun is required for notarization" >&2 + exit 1 + fi + if [[ -n "${APPLE_NOTARY_KEYCHAIN_PROFILE:-}" ]]; then + xcrun notarytool submit "$DMG_PATH" \ + --keychain-profile "$APPLE_NOTARY_KEYCHAIN_PROFILE" \ + --wait + else + : "${APPLE_ID:?APPLE_ID is required when APPLE_NOTARY_KEYCHAIN_PROFILE is not set}" + : "${APPLE_TEAM_ID:?APPLE_TEAM_ID is required when APPLE_NOTARY_KEYCHAIN_PROFILE is not set}" + : "${APPLE_APP_SPECIFIC_PASSWORD:?APPLE_APP_SPECIFIC_PASSWORD is required when APPLE_NOTARY_KEYCHAIN_PROFILE is not set}" + xcrun notarytool submit "$DMG_PATH" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --wait + fi + xcrun stapler staple "$DMG_PATH" + xcrun stapler validate "$DMG_PATH" +else + echo "==> Skipping notarization (set APPLE_NOTARIZE=1 to notarize)" +fi + CHECKSUMS_PATH="${DIST_DIR}/SHA256SUMS-macOS-${ARCH}.txt" { shasum -a 256 "$DMG_PATH" diff --git a/scripts/macos/make-runtime-pack.sh b/scripts/macos/make-runtime-pack.sh index fd7dd13..80b699e 100755 --- a/scripts/macos/make-runtime-pack.sh +++ b/scripts/macos/make-runtime-pack.sh @@ -4,7 +4,7 @@ set -euo pipefail ARCH="${ARCH:-arm64}" VERSION="${VERSION:-LOCAL_DEV_TEST}" VERSION="${VERSION#v}" -RELEASE_BASE_URL="${RELEASE_BASE_URL:-https://github.com/stemdeckapp/stemdeck/releases/download/v${VERSION}}" +RELEASE_BASE_URL="${RELEASE_BASE_URL:-https://github.com/bassmicrobe/stemdeck/releases/download/v${VERSION}}" REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" BUILD_DIR="${REPO_ROOT}/.build" STAGING="${BUILD_DIR}/runtime-staging-${ARCH}" @@ -153,6 +153,8 @@ cp -R "$REPO_ROOT/app" "$BACKEND_DIR/app" cp -R "$REPO_ROOT/static" "$BACKEND_DIR/static" cp "$REPO_ROOT/pyproject.toml" "$BACKEND_DIR/pyproject.toml" cp "$REPO_ROOT/uv.lock" "$BACKEND_DIR/uv.lock" +cp "$REPO_ROOT/LICENSE" "$BACKEND_DIR/LICENSE" +cp "$REPO_ROOT/NOTICE" "$BACKEND_DIR/NOTICE" cat > "$BACKEND_DIR/static/version.json" < Capturing dependency inventory" mkdir -p "$RUNTIME_DIR/licenses" uv pip list --system --python "$PYTHON_DIR/bin/python" --format=json > "$RUNTIME_DIR/licenses/pip-list.json" +cp "$REPO_ROOT/LICENSE" "$RUNTIME_DIR/licenses/LICENSE" +cp "$REPO_ROOT/NOTICE" "$RUNTIME_DIR/licenses/NOTICE" cat > "$RUNTIME_DIR/runtime-manifest.json" < Stripping Python caches" find "$PYTHON_DIR" -type d -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null || true find "$PYTHON_DIR" -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete -ARCHIVE_NAME="StemDeck-runtime-macOS-${ARCH}.tar.zst" +ARCHIVE_NAME="LayerLab-runtime-macOS-${ARCH}.tar.zst" ARCHIVE_PATH="${BUILD_DIR}/${ARCHIVE_NAME}" if command -v zstd >/dev/null 2>&1; then tar --zstd -cf "$ARCHIVE_PATH" -C "$STAGING" runtime else - ARCHIVE_NAME="StemDeck-runtime-macOS-${ARCH}.tar.gz" + ARCHIVE_NAME="LayerLab-runtime-macOS-${ARCH}.tar.gz" ARCHIVE_PATH="${BUILD_DIR}/${ARCHIVE_NAME}" tar -czf "$ARCHIVE_PATH" -C "$STAGING" runtime fi diff --git a/scripts/windows/make-installer.ps1 b/scripts/windows/make-installer.ps1 new file mode 100644 index 0000000..ef51505 --- /dev/null +++ b/scripts/windows/make-installer.ps1 @@ -0,0 +1,163 @@ +param( + [string]$Configuration = "release", + [string]$OutputRoot = "dist", + [string]$PackageName = "LayerLab-Windows-x64.NVIDIA", + [string]$PackageVersion, + [string]$PortableRoot, + [string]$InnoSetupCompiler, + [switch]$SkipPortableBuild, + [switch]$SkipTauriBuild, + [switch]$CpuOnly, + [switch]$StripVenv +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +if ($env:OS -ne "Windows_NT") { + throw "This installer script must run on Windows." +} + +$Root = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$Template = Join-Path $Root "packaging\windows\layerlab.iss" +$InstallerOutputRoot = Join-Path $Root $OutputRoot + +function Get-PackageVersion { + if ($PackageVersion) { + return $PackageVersion.TrimStart("v") + } + $tauriConfig = Get-Content -LiteralPath (Join-Path $Root "desktop\src-tauri\tauri.conf.json") -Raw | + ConvertFrom-Json + return ([string]$tauriConfig.version).TrimStart("v") +} + +function Resolve-InnoSetupCompiler { + if ($InnoSetupCompiler) { + if (-not (Test-Path $InnoSetupCompiler)) { + throw "Inno Setup compiler not found: $InnoSetupCompiler" + } + return (Resolve-Path $InnoSetupCompiler).Path + } + + $cmd = Get-Command "iscc.exe" -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + + $candidates = @( + (Join-Path ([Environment]::GetFolderPath("ProgramFilesX86")) "Inno Setup 6\ISCC.exe"), + (Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "Inno Setup 6\ISCC.exe") + ) + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path $candidate)) { + return $candidate + } + } + + throw "Inno Setup compiler (ISCC.exe) was not found. Install Inno Setup 6 or pass -InnoSetupCompiler." +} + +function Sign-FileIfConfigured([string]$Path) { + $certPath = $env:WINDOWS_SIGN_CERT_PATH + if (-not $certPath) { + Write-Host "Skipping Windows installer signing (set WINDOWS_SIGN_CERT_PATH to sign)." + return + } + if (-not (Test-Path $certPath)) { + throw "WINDOWS_SIGN_CERT_PATH does not exist: $certPath" + } + + $signTool = if ($env:WINDOWS_SIGNTOOL_PATH) { $env:WINDOWS_SIGNTOOL_PATH } else { "signtool.exe" } + $timestampUrl = if ($env:WINDOWS_TIMESTAMP_URL) { + $env:WINDOWS_TIMESTAMP_URL + } else { + "http://timestamp.digicert.com" + } + + if (-not (Get-Command $signTool -ErrorAction SilentlyContinue)) { + throw "signtool not found. Install Windows SDK or set WINDOWS_SIGNTOOL_PATH." + } + + $args = @( + "sign", + "/fd", "SHA256", + "/tr", $timestampUrl, + "/td", "SHA256", + "/f", $certPath + ) + if ($env:WINDOWS_SIGN_CERT_PASSWORD) { + $args += @("/p", $env:WINDOWS_SIGN_CERT_PASSWORD) + } + $args += $Path + + Write-Host "Signing Windows installer: $Path" + & $signTool @args + & $signTool verify /pa /v $Path +} + +if (-not (Test-Path $Template)) { + throw "Inno Setup template not found: $Template" +} + +if (-not $PortableRoot) { + $PortableRoot = Join-Path $Root "$OutputRoot\$PackageName" +} + +if (-not $SkipPortableBuild) { + $portableScript = Join-Path $PSScriptRoot "make-portable.ps1" + $portableArgs = @( + "-Configuration", $Configuration, + "-OutputRoot", $OutputRoot, + "-PackageName", $PackageName + ) + if ($PackageVersion) { $portableArgs += @("-PackageVersion", $PackageVersion) } + if ($SkipTauriBuild) { $portableArgs += "-SkipTauriBuild" } + if ($CpuOnly) { $portableArgs += "-CpuOnly" } + if ($StripVenv) { $portableArgs += "-StripVenv" } + & $portableScript @portableArgs +} + +$PortableRoot = (Resolve-Path $PortableRoot).Path +$exePath = Join-Path $PortableRoot "LayerLab.exe" +foreach ($required in @($exePath, (Join-Path $PortableRoot "LICENSE"), (Join-Path $PortableRoot "NOTICE"), (Join-Path $PortableRoot "THIRD_PARTY_NOTICES.txt"))) { + if (-not (Test-Path $required)) { + throw "Required portable artifact is missing: $required" + } +} + +New-Item -ItemType Directory -Force $InstallerOutputRoot | Out-Null + +$version = Get-PackageVersion +$installerBaseName = "$PackageName-Setup" +$installerPath = Join-Path $InstallerOutputRoot "$installerBaseName.exe" +$checksumPath = "$installerPath.sha256" +if (Test-Path $installerPath) { + Remove-Item -Force $installerPath +} +if (Test-Path $checksumPath) { + Remove-Item -Force $checksumPath +} + +$iscc = Resolve-InnoSetupCompiler +& $iscc ` + "/DAppVersion=$version" ` + "/DSourceDir=$PortableRoot" ` + "/DOutputDir=$InstallerOutputRoot" ` + "/DOutputBaseFilename=$installerBaseName" ` + $Template + +if (-not (Test-Path $installerPath)) { + throw "Installer was not created: $installerPath" +} + +Sign-FileIfConfigured $installerPath + +$hash = Get-FileHash -Algorithm SHA256 $installerPath +Set-Content -Path $checksumPath -Value "$($hash.Hash) $installerBaseName.exe" + +$variant = if ($CpuOnly) { "CPU-only" } else { "CUDA/GPU (NVIDIA)" } +Write-Host "Variant : $variant" +Write-Host "Portable root : $PortableRoot" +Write-Host "Installer : $installerPath" +Write-Host "Checksum : $checksumPath" diff --git a/scripts/windows/make-portable.ps1 b/scripts/windows/make-portable.ps1 index 9e7f0f0..748e6ef 100644 --- a/scripts/windows/make-portable.ps1 +++ b/scripts/windows/make-portable.ps1 @@ -1,7 +1,7 @@ param( [string]$Configuration = "release", [string]$OutputRoot = "dist", - [string]$PackageName = "StemDeck-Windows-x64", + [string]$PackageName = "LayerLab-Windows-x64.NVIDIA", [string]$PackageVersion, [switch]$SkipTauriBuild, [switch]$CpuOnly, @@ -25,7 +25,7 @@ $PythonExe = Join-Path $PythonDir "Scripts\python.exe" $BackendDir = Join-Path $Stage "backend" $DesktopDir = Join-Path $Root "desktop" $TauriDir = Join-Path $DesktopDir "src-tauri" -$TargetExe = Join-Path $TauriDir "target\$Configuration\stemdeck.exe" +$TargetExe = Join-Path $TauriDir "target\$Configuration\layerlab.exe" function Require-Command([string]$Name) { if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { @@ -134,7 +134,7 @@ function Assert-Fresh-TauriBuild { if ($newerSources.Count -gt 0) { $list = ($newerSources | Select-Object -First 8 | ForEach-Object { " - $($_.FullName)" }) -join "`n" throw @" --SkipTauriBuild would package a stale StemDeck.exe. +-SkipTauriBuild would package a stale LayerLab.exe. The existing executable is older than desktop UI/Tauri source files: $list @@ -144,6 +144,44 @@ Remove -SkipTauriBuild or run the NVIDIA package build first so the CPU package } } +function Sign-FileIfConfigured([string]$Path) { + $certPath = $env:WINDOWS_SIGN_CERT_PATH + if (-not $certPath) { + Write-Host "Skipping Windows code signing (set WINDOWS_SIGN_CERT_PATH to sign)." + return + } + if (-not (Test-Path $certPath)) { + throw "WINDOWS_SIGN_CERT_PATH does not exist: $certPath" + } + + $signTool = if ($env:WINDOWS_SIGNTOOL_PATH) { $env:WINDOWS_SIGNTOOL_PATH } else { "signtool.exe" } + $timestampUrl = if ($env:WINDOWS_TIMESTAMP_URL) { + $env:WINDOWS_TIMESTAMP_URL + } else { + "http://timestamp.digicert.com" + } + + if (-not (Get-Command $signTool -ErrorAction SilentlyContinue)) { + throw "signtool not found. Install Windows SDK or set WINDOWS_SIGNTOOL_PATH." + } + + $args = @( + "sign", + "/fd", "SHA256", + "/tr", $timestampUrl, + "/td", "SHA256", + "/f", $certPath + ) + if ($env:WINDOWS_SIGN_CERT_PASSWORD) { + $args += @("/p", $env:WINDOWS_SIGN_CERT_PASSWORD) + } + $args += $Path + + Write-Host "Signing Windows executable: $Path" + & $signTool @args + & $signTool verify /pa /v $Path +} + Require-Command "node" Require-Command "npm" Require-Command "cargo" @@ -181,8 +219,12 @@ $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText((Join-Path $BackendDir "static\version.json"), $VersionJson + "`n", $utf8NoBom) Copy-Item -Force (Join-Path $Root "pyproject.toml") (Join-Path $BackendDir "pyproject.toml") Copy-Item -Force (Join-Path $Root "uv.lock") (Join-Path $BackendDir "uv.lock") +Copy-Item -Force (Join-Path $Root "LICENSE") (Join-Path $BackendDir "LICENSE") +Copy-Item -Force (Join-Path $Root "NOTICE") (Join-Path $BackendDir "NOTICE") Copy-Item -Force (Join-Path $Root "packaging\windows\README-WINDOWS.txt") (Join-Path $Stage "README-WINDOWS.txt") Copy-Item -Force (Join-Path $Root "packaging\windows\THIRD_PARTY_NOTICES.txt") (Join-Path $Stage "THIRD_PARTY_NOTICES.txt") +Copy-Item -Force (Join-Path $Root "LICENSE") (Join-Path $Stage "LICENSE") +Copy-Item -Force (Join-Path $Root "NOTICE") (Join-Path $Stage "NOTICE") if (Get-Command "py" -ErrorAction SilentlyContinue) { & py -3.12 -m venv $PythonDir @@ -251,7 +293,8 @@ if (-not (Test-Path $TargetExe)) { throw "Tauri executable not found at $TargetExe" } -Copy-Item -Force $TargetExe (Join-Path $Stage "StemDeck.exe") +Copy-Item -Force $TargetExe (Join-Path $Stage "LayerLab.exe") +Sign-FileIfConfigured (Join-Path $Stage "LayerLab.exe") Compress-Archive -Path (Join-Path $Stage "*") -DestinationPath $ZipPath -Force $Hash = Get-FileHash -Algorithm SHA256 $ZipPath diff --git a/static/css/daw.css b/static/css/daw.css index b83c289..17ef2b5 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -1,10 +1,19 @@ /* ═══════════════════════════════════════════ - DAW UI — flat dark design + DAW UI — neon studio design ═══════════════════════════════════════════ */ /* ── Reset ── */ *, *::before, *::after { box-sizing: border-box; } -html, body { margin: 0; padding: 0; height: 100%; background: #0a0d10; overflow: hidden; } +html, body { + margin: 0; + padding: 0; + height: 100%; + background: + radial-gradient(circle at 12% -8%, rgba(255,61,139,0.2), transparent 30%), + radial-gradient(circle at 78% -10%, rgba(47,255,227,0.18), transparent 32%), + linear-gradient(135deg, #020406 0%, #061115 45%, #090b13 100%); + overflow: hidden; +} body { font-family: var(--font-sans); color: var(--fg); } button { font-family: inherit; } input, textarea { font-family: inherit; } @@ -13,7 +22,13 @@ input, textarea { font-family: inherit; } .daw { width: 100vw; height: 100vh; - background: var(--bg); + background: + linear-gradient(90deg, rgba(47,255,227,0.04) 1px, transparent 1px), + linear-gradient(0deg, rgba(255,255,255,0.026) 1px, transparent 1px), + radial-gradient(circle at 72% 18%, rgba(234,255,50,0.08), transparent 36%), + radial-gradient(circle at 16% 72%, rgba(255,61,139,0.08), transparent 34%), + var(--bg); + background-size: 42px 42px, 42px 42px, auto, auto, auto; color: var(--fg); font-family: var(--font-sans); font-size: 13px; @@ -44,8 +59,14 @@ input, textarea { font-family: inherit; } .daw-topbar { height: 77px; flex-shrink: 0; - background: var(--bg-2); - border-bottom: 1px solid var(--border); + background: + linear-gradient(90deg, rgba(47,255,227,0.1), transparent 28%), + linear-gradient(180deg, rgba(11,30,36,0.94), rgba(5,12,17,0.9)); + border-bottom: 1px solid var(--border-strong); + box-shadow: + 0 1px 0 rgba(255,255,255,0.04) inset, + 0 12px 34px rgba(0,0,0,0.36), + 0 0 30px rgba(47,255,227,0.08); display: flex; align-items: center; padding: 0 16px; @@ -62,12 +83,19 @@ input, textarea { font-family: inherit; } .daw-brand img { height: 30px; width: auto; display: block; } .daw-brand-name { font-size: 18px; - font-weight: 600; - letter-spacing: -0.015em; + font-weight: 700; + letter-spacing: 0.015em; line-height: 1; + text-transform: uppercase; + text-shadow: 0 0 18px rgba(47,255,227,0.34); } .daw-brand-name .fg { color: var(--fg); } -.daw-brand-name .accent{ color: var(--accent); } +.daw-brand-name .accent{ + color: var(--accent-2); + text-shadow: + 0 0 10px rgba(234,255,50,0.52), + 0 0 26px rgba(255,61,139,0.24); +} .daw-version { display: none !important; /* version shown in notification panel instead */ @@ -77,7 +105,8 @@ input, textarea { font-family: inherit; } .daw-sep { width: 1px; height: 28px; - background: var(--border); + background: linear-gradient(180deg, transparent, var(--accent), transparent); + box-shadow: 0 0 14px rgba(47,255,227,0.38); flex-shrink: 0; } @@ -88,10 +117,16 @@ input, textarea { font-family: inherit; } height: 50px; display: flex; align-items: stretch; - background: var(--panel); + background: + linear-gradient(90deg, rgba(47,255,227,0.08), rgba(255,61,139,0.03)), + var(--panel); border: 1px solid var(--border-strong); border-radius: var(--radius); - box-shadow: inset 0 1px 0 rgba(255,255,255,0.02), 0 1px 8px rgba(0,0,0,0.3); + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.05), + 0 0 0 1px rgba(47,255,227,0.08), + 0 10px 28px rgba(0,0,0,0.32), + 0 0 24px rgba(47,255,227,0.08); overflow: hidden; } @@ -101,7 +136,7 @@ input, textarea { font-family: inherit; } align-items: center; gap: 10px; padding: 0 14px; - flex: 1; + flex: 1 1 220px; min-width: 0; cursor: text; } @@ -131,14 +166,19 @@ input, textarea { font-family: inherit; } display: inline-flex; align-items: center; justify-content: center; - background: none; + background: rgba(47,255,227,0.04); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); cursor: pointer; - transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); + transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast); +} +.daw-upload-btn:hover { + background: rgba(47,255,227,0.12); + color: var(--accent); + border-color: var(--accent); + box-shadow: 0 0 14px rgba(47,255,227,0.18); } -.daw-upload-btn:hover { background: var(--panel-2); color: var(--fg-2); border-color: var(--border-strong); } /* File pill (shown when file selected) */ .file-pill { @@ -170,15 +210,15 @@ input, textarea { font-family: inherit; } /* url-wrap alias (JS uses .url-wrap for drag events) */ .daw-url-zone { position: relative; } .daw-url-zone.drag-over { - background: rgba(244,183,64,0.06); - box-shadow: inset 0 0 0 1px rgba(244,183,64,0.3); + background: rgba(47,255,227,0.08); + box-shadow: inset 0 0 0 1px rgba(47,255,227,0.48), 0 0 22px rgba(47,255,227,0.16); } .daw-url-zone.has-file input[type="url"] { display: none; } /* Composer vertical divider */ .daw-composer-sep { width: 1px; - background: var(--border); + background: linear-gradient(180deg, transparent, rgba(74,255,236,0.36), transparent); align-self: stretch; margin: 6px 0; flex-shrink: 0; @@ -190,8 +230,12 @@ input, textarea { font-family: inherit; } align-items: center; gap: 10px; padding: 0 14px; - flex-shrink: 0; + flex: 1 1 430px; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; } +.daw-stem-section::-webkit-scrollbar { display: none; } .daw-extract-label { font-size: 10px; font-weight: 600; @@ -199,7 +243,45 @@ input, textarea { font-family: inherit; } text-transform: uppercase; color: var(--muted); } -.daw-stem-chips { display: flex; gap: 4px; } +.daw-stem-chips { + display: flex; + gap: 4px; + min-width: max-content; +} + +.daw-quality-section { + display: flex; + align-items: center; + gap: 9px; + padding: 0 12px; + flex: 0 1 auto; + min-width: 0; +} + +.daw-quality-select { + height: 30px; + min-width: 132px; + padding: 0 28px 0 10px; + border-radius: 6px; + border: 1px solid var(--border); + background: + linear-gradient(180deg, rgba(47,255,227,0.08), rgba(9,21,27,0.82)), + var(--panel-2); + color: var(--fg); + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.daw-quality-select:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent); + outline-offset: 2px; +} + +.daw-denoise-section .daw-quality-select { + min-width: 118px; +} /* Stem choice buttons */ .stem-choice { @@ -217,18 +299,32 @@ input, textarea { font-family: inherit; } display: inline-flex; align-items: center; gap: 6px; - transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); + transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast), box-shadow var(--t-fast); } .stem-choice[aria-pressed="true"] { border-color: color-mix(in srgb, var(--color) 60%, transparent); - background: color-mix(in srgb, var(--color) 14%, transparent); + background: + linear-gradient(180deg, color-mix(in srgb, var(--color) 16%, transparent), rgba(2,8,11,0.28)), + color-mix(in srgb, var(--color) 9%, transparent); color: var(--color); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--color) 10%, transparent), + 0 0 14px color-mix(in srgb, var(--color) 16%, transparent); } + +.stem-choice:disabled { + cursor: not-allowed; + opacity: 0.38; + background: transparent; + color: var(--muted); +} + .stem-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; opacity: 0.85; + box-shadow: 0 0 10px currentColor; } .stem-choice[aria-pressed="false"] .stem-dot { opacity: 0.4; } @@ -240,30 +336,43 @@ input, textarea { font-family: inherit; } } .stem-choice-all[aria-pressed="true"] { border-color: var(--accent); - background: rgba(244,183,64,0.12); + background: rgba(47,255,227,0.13); color: var(--accent); + box-shadow: 0 0 16px rgba(47,255,227,0.18); } /* Process button */ .daw-process-btn { height: auto; padding: 0 20px; - background: linear-gradient(180deg, var(--accent), var(--accent-2)); + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 48%, var(--neon-pink) 120%); border: none; - border-left: 1px solid #b97f1c; - color: #1a1206; - font-weight: 600; + border-left: 1px solid rgba(47,255,227,0.4); + color: #021013; + font-weight: 700; font-size: 14px; font-family: inherit; display: inline-flex; align-items: center; gap: 7px; cursor: pointer; - box-shadow: inset 0 1px 0 rgba(255,255,255,0.22); - flex-shrink: 0; - transition: background var(--t-fast); + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.36), + 0 0 22px rgba(47,255,227,0.2), + 0 0 34px rgba(234,255,50,0.12); + flex: 0 0 auto; + min-width: 150px; + justify-content: center; + white-space: nowrap; + transition: background var(--t-fast), box-shadow var(--t-fast), transform var(--t-fast); +} +.daw-process-btn:hover { + background: linear-gradient(135deg, #72fff0 0%, #f5ff5c 48%, #ff66aa 120%); + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.42), + 0 0 28px rgba(47,255,227,0.32), + 0 0 42px rgba(255,61,139,0.16); } -.daw-process-btn:hover { background: linear-gradient(180deg, #f8c054, #e0a032); } .daw-process-btn:active { transform: translateY(1px); } .daw-process-btn:disabled { opacity: 0.8; @@ -308,10 +417,12 @@ input, textarea { font-family: inherit; } top: calc(100% + 8px); right: 0; width: 280px; - background: var(--panel-2); + background: + linear-gradient(180deg, rgba(18,42,49,0.96), rgba(5,12,17,0.98)), + var(--panel-2); border: 1px solid var(--border-strong); border-radius: var(--radius); - box-shadow: 0 8px 32px rgba(0,0,0,0.5); + box-shadow: var(--shadow-panel); z-index: 200; overflow: hidden; } @@ -341,11 +452,11 @@ input, textarea { font-family: inherit; } border: 1px solid var(--border); border-radius: 8px; } -.daw-notif-release { border-color: rgba(244,183,64,0.25); } +.daw-notif-release { border-color: rgba(47,255,227,0.28); } .daw-notif-card-icon { width: 28px; height: 28px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; - background: rgba(244,183,64,0.12); + background: rgba(47,255,227,0.13); border-radius: 6px; color: var(--accent); } @@ -355,7 +466,8 @@ input, textarea { font-family: inherit; } .daw-notif-badge { position: absolute; top: 4px; right: 4px; width: 7px; height: 7px; - background: #f4b740; border-radius: 50%; + background: var(--accent); border-radius: 50%; + box-shadow: 0 0 10px rgba(47,255,227,0.7); pointer-events: none; } .daw-notif-dismiss { @@ -389,8 +501,12 @@ input, textarea { font-family: inherit; } .sidebar { width: 390px; flex-shrink: 0; - background: var(--bg-2); - border-right: 1px solid var(--border); + background: + linear-gradient(180deg, rgba(47,255,227,0.05), transparent 38%), + linear-gradient(90deg, rgba(255,61,139,0.04), transparent 58%), + var(--bg-2); + border-right: 1px solid var(--border-strong); + box-shadow: 14px 0 38px rgba(0,0,0,0.28), inset -1px 0 0 rgba(47,255,227,0.06); display: flex; overflow: hidden; transition: width 0.28s cubic-bezier(0.4, 0, 0.2, 1); @@ -400,6 +516,7 @@ input, textarea { font-family: inherit; } .sidebar-rail { width: 66px; border-right: 1px solid var(--border); + background: linear-gradient(180deg, rgba(2,8,11,0.3), rgba(47,255,227,0.03)); display: flex; flex-direction: column; align-items: center; @@ -420,14 +537,23 @@ input, textarea { font-family: inherit; } font-size: 9px; font-family: var(--font-sans); cursor: pointer; - transition: background var(--t-fast), color var(--t-fast); - border: none; - background: none; + transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast); + border: 1px solid transparent; + background: transparent; /* remove old pseudo-element strip */ } .rail-btn::before { display: none !important; } -.rail-btn:hover { background: var(--panel); color: var(--fg-2); } -.rail-btn.active { color: var(--accent); background: var(--panel); } +.rail-btn:hover { + background: rgba(47,255,227,0.08); + color: var(--fg-2); + border-color: rgba(47,255,227,0.16); +} +.rail-btn.active { + color: var(--accent); + background: rgba(47,255,227,0.12); + border-color: rgba(47,255,227,0.32); + box-shadow: 0 0 18px rgba(47,255,227,0.15); +} .rail-btn svg { flex-shrink: 0; } .rail-btn span { white-space: nowrap; } @@ -448,7 +574,9 @@ input, textarea { font-family: inherit; } .daw-search { height: 32px; border: 1px solid var(--border); - background: var(--panel); + background: + linear-gradient(90deg, rgba(47,255,227,0.07), transparent), + var(--panel); border-radius: 8px; display: flex; align-items: center; @@ -459,6 +587,10 @@ input, textarea { font-family: inherit; } flex-shrink: 0; position: relative; } +.daw-search:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 1px rgba(47,255,227,0.16), 0 0 18px rgba(47,255,227,0.12); +} .daw-search svg { flex-shrink: 0; } .daw-search input { flex: 1; min-width: 0; @@ -521,7 +653,7 @@ input, textarea { font-family: inherit; } /* Thin auto-hide scrollbar shared by the library list, the mixer column and the waveform panel (both axes) — hidden until the area is hovered/focused, reserves - no visible gutter. `.daw .wave-scroll` overrides the gold bar from waves.css. */ + no visible gutter. `.daw .wave-scroll` overrides the accent bar from waves.css. */ .daw-lib-list, .mixer-column, .daw .wave-scroll { @@ -584,15 +716,18 @@ input, textarea { font-family: inherit; } position: relative; transition: background var(--t-fast); } -.lib-item:hover { background: var(--panel); } +.lib-item:hover { + background: rgba(47,255,227,0.06); +} .lib-item.active { - background: linear-gradient(90deg, rgba(244,183,64,0.10) 0%, var(--panel-2) 60%); + background: linear-gradient(90deg, rgba(47,255,227,0.13) 0%, rgba(255,61,139,0.06) 58%, var(--panel-2) 100%); } .lib-item.active::before { content: ''; position: absolute; left: -2px; top: 6px; bottom: 6px; width: 3px; border-radius: 2px; background: var(--accent); + box-shadow: 0 0 14px rgba(47,255,227,0.55); } .lib-cover { width: 36px; height: 36px; border-radius: 6px; @@ -618,7 +753,10 @@ input, textarea { font-family: inherit; } transition: background var(--t-fast), border-color var(--t-fast); } .cat-item:hover { background: var(--panel-2); border-color: var(--border); } -.cat-item.active { background: var(--panel-2); border-color: rgba(244,183,64,0.3); } +.cat-item.active { + background: linear-gradient(90deg, rgba(47,255,227,0.1), var(--panel-2)); + border-color: rgba(47,255,227,0.34); +} .cat-thumb { width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0; @@ -723,7 +861,7 @@ input, textarea { font-family: inherit; } .folder-editor-close:hover { background: rgba(21,31,39,0.8); color: var(--fg); } .folder-editor-field { display: grid; gap: 7px; margin-bottom: 12px; color: var(--muted); font-size: 10.5px; } .folder-editor-name { width: 100%; min-height: 34px; border: 1px solid var(--border); border-radius: 7px; background: rgba(10,17,24,0.78); color: var(--fg); font: inherit; font-size: 12px; padding: 0 10px; outline: none; } -.folder-editor-name:focus { border-color: rgba(244,183,64,0.5); box-shadow: 0 0 0 2px rgba(244,183,64,0.12); } +.folder-editor-name:focus { border-color: rgba(47,255,227,0.52); box-shadow: 0 0 0 2px rgba(47,255,227,0.14); } .folder-editor-colors { display: flex; align-items: center; gap: 9px; } .folder-editor-colors .folder-color-dot { width: 22px; height: 22px; } .folder-editor-msg { color: var(--danger); font-size: 11px; margin-top: 8px; } @@ -731,7 +869,7 @@ input, textarea { font-family: inherit; } .folder-editor-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; } .folder-editor-actions button { min-height: 30px; border-radius: 7px; font-family: var(--font-mono); font-size: 11px; cursor: pointer; padding: 0 11px; } .folder-editor-cancel { border: 1px solid var(--border); background: rgba(21,31,39,0.52); color: var(--muted); } -.folder-editor-save { border: 1px solid rgba(244,183,64,0.3); background: rgba(244,183,64,0.16); color: var(--accent); } +.folder-editor-save { border: 1px solid rgba(47,255,227,0.34); background: rgba(47,255,227,0.16); color: var(--accent); } /* Edit Library modal (Settings → opens this centered window) */ .library-editor-backdrop { @@ -771,7 +909,7 @@ input, textarea { font-family: inherit; } .library-editor-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 11px; } .library-editor-status { font-size: 10.5px; color: var(--muted); } .library-editor-status.out-of-sync { color: var(--danger); font-weight: 600; } -.library-editor-sync { min-height: 30px; border-radius: 7px; border: 1px solid rgba(244,183,64,0.3); background: rgba(244,183,64,0.16); color: var(--accent); font-family: var(--font-mono); font-size: 11px; padding: 0 13px; cursor: pointer; } +.library-editor-sync { min-height: 30px; border-radius: 7px; border: 1px solid rgba(47,255,227,0.34); background: rgba(47,255,227,0.16); color: var(--accent); font-family: var(--font-mono); font-size: 11px; padding: 0 13px; cursor: pointer; } .library-editor-sync:disabled { opacity: 0.55; cursor: default; } /* Color picker popover */ @@ -807,52 +945,236 @@ input, textarea { font-family: inherit; } overflow: hidden; } -/* ── Job progress overlay ── */ +/* ── Non-blocking job progress HUD ── */ .job { - position: absolute; - inset: 0; - z-index: 20; - background: rgb(7, 13, 19); + position: fixed; + right: clamp(16px, 2vw, 30px); + top: clamp(90px, 10vh, 132px); + bottom: auto; + width: min(500px, calc(100vw - 32px)); + z-index: 1000; + pointer-events: none; + background: + radial-gradient(circle at 18% 0%, rgba(234,255,50,0.22), transparent 34%), + radial-gradient(circle at 88% 100%, rgba(255,61,139,0.18), transparent 42%), + linear-gradient(145deg, rgba(9,32,39,0.96), rgba(2,8,12,0.96)); + border: 1px solid rgba(118,255,240,0.5); + border-radius: 24px; + backdrop-filter: blur(14px) saturate(1.28); + -webkit-backdrop-filter: blur(14px) saturate(1.28); display: flex; flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - padding: 32px; -} -/* Gold ambient glow -- matches the wave-loading-overlay aesthetic */ + align-items: stretch; + justify-content: flex-start; + gap: 12px; + padding: 18px 20px 20px; + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.12), + 0 0 0 1px rgba(234,255,50,0.08), + 0 18px 48px rgba(0,0,0,0.46), + 0 0 36px rgba(47,255,227,0.25), + 0 0 62px rgba(234,255,50,0.12); +} +/* Neon ambient glow -- matches the wave-loading-overlay aesthetic */ .job::before { content: ""; position: absolute; inset: 0; - background: radial-gradient(circle at 50% 44%, rgba(216, 168, 74, 0.13), transparent 55%); + z-index: 0; + background: + linear-gradient(90deg, rgba(47,255,227,0.1) 1px, transparent 1px), + linear-gradient(0deg, rgba(255,255,255,0.055) 1px, transparent 1px); + background-size: 24px 24px; + border-radius: inherit; + opacity: 0.55; + pointer-events: none; + -webkit-mask-image: linear-gradient(90deg, #000, transparent 82%); + mask-image: linear-gradient(90deg, #000, transparent 82%); +} +.job::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + border-radius: inherit; + border: 1px solid rgba(234,255,50,0.2); + background: + linear-gradient(90deg, rgba(47,255,227,0.22), transparent 28%, transparent 72%, rgba(234,255,50,0.18)); pointer-events: none; } -.job > * { position: relative; z-index: 1; } +.job > * { + position: relative; + z-index: 2; + width: 100%; +} +.job button, +.job a, +.job input, +.job select, +.job textarea { + pointer-events: auto; +} .job.hidden { display: none !important; } -.job .title { font-size: 15px; font-weight: 600; color: var(--fg); text-align: center; } -.job .stage { font-size: 12px; color: var(--muted); text-align: center; } +.job-header { + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + text-align: left; +} +.job .title { + min-width: 0; + font-size: clamp(15px, 1.4vw, 20px); + font-weight: 800; + color: var(--fg); + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-shadow: + 0 0 18px rgba(47,255,227,0.46), + 0 0 34px rgba(234,255,50,0.2); +} +.job-headline { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.job .stage { + font-size: 14px; + color: var(--fg-2); + text-align: left; +} +.job .job-eta { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 700; + color: var(--accent-2); + text-shadow: 0 0 14px rgba(234,255,50,0.5); +} .job progress { - width: 280px; max-width: 100%; - height: 4px; - border-radius: 2px; - appearance: none; border: none; - background: var(--panel-3); + width: 100%; + height: 18px; + border-radius: 999px; + appearance: none; + border: 1px solid rgba(118,255,240,0.76); + background: + linear-gradient(90deg, rgba(47,255,227,0.14) 1px, transparent 1px), + linear-gradient(180deg, rgba(236,255,251,0.18), rgba(0,0,0,0.04)), + rgba(5, 26, 32, 0.98); + background-size: 20px 100%, auto, auto; overflow: hidden; + box-shadow: + inset 0 0 0 1px rgba(255,255,255,0.1), + inset 0 0 18px rgba(47,255,227,0.16), + 0 0 0 3px rgba(47,255,227,0.08), + 0 0 28px rgba(47,255,227,0.4); +} +.job progress::-webkit-progress-bar { + background: + linear-gradient(90deg, rgba(47,255,227,0.18) 1px, transparent 1px), + linear-gradient(180deg, rgba(255,255,255,0.13), rgba(47,255,227,0.06)), + rgba(4, 23, 29, 0.98); + background-size: 18px 100%, auto, auto; + border-radius: 999px; +} +.job progress::-webkit-progress-value { + background: + linear-gradient(90deg, #7dfff2 0%, var(--accent) 30%, var(--accent-2) 68%, #ff78b4 100%); + border-radius: 999px; + box-shadow: + 0 0 18px rgba(47,255,227,0.9), + 0 0 34px rgba(234,255,50,0.48); + transition: width 220ms var(--easing); +} +.job progress::-moz-progress-bar { + background: + linear-gradient(90deg, #7dfff2 0%, var(--accent) 30%, var(--accent-2) 68%, #ff78b4 100%); + border-radius: 999px; + box-shadow: + 0 0 18px rgba(47,255,227,0.9), + 0 0 34px rgba(234,255,50,0.48); +} +.job .job-detail { + font-size: 13px; + color: var(--fg-2); + text-align: left; +} +.job-connection { + align-self: flex-start; + width: auto; + max-width: 100%; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid rgba(234,255,50,0.34); + background: rgba(234,255,50,0.1); + color: var(--accent-2); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.02em; + box-shadow: 0 0 18px rgba(234,255,50,0.16); +} +.job-connection.warn { + border-color: rgba(255,157,72,0.48); + background: rgba(255,157,72,0.12); + color: #ffd08a; +} +.job .job-progress-row { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 14px; +} +.job .job-percent { + min-width: 4ch; + color: var(--accent-2); + font-family: var(--font-mono); + font-size: 18px; + font-weight: 800; + letter-spacing: 0.02em; + text-align: right; + text-shadow: + 0 0 10px rgba(234,255,50,0.65), + 0 0 18px rgba(47,255,227,0.42); + font-variant-numeric: tabular-nums; } -.job progress::-webkit-progress-bar { background: var(--panel-3); border-radius: 2px; } -.job progress::-webkit-progress-value { background: var(--accent); border-radius: 2px; } -.job progress::-moz-progress-bar { background: var(--accent); border-radius: 2px; } -.job .job-detail { font-size: 11px; color: var(--muted-2); text-align: center; } .cancel-btn { - height: 30px; padding: 0 16px; - background: var(--panel-2); border: 1px solid var(--border-strong); - color: var(--fg-2); border-radius: 8px; font-size: 12px; + height: 34px; padding: 0 18px; + background: rgba(47,255,227,0.1); border: 1px solid rgba(118,255,240,0.44); + color: var(--fg); border-radius: 999px; font-size: 12px; font-family: inherit; cursor: pointer; - transition: background var(--t-fast); + box-shadow: 0 0 18px rgba(47,255,227,0.12); + transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast); +} +.cancel-btn:hover { + background: rgba(47,255,227,0.18); + border-color: rgba(234,255,50,0.58); + box-shadow: 0 0 24px rgba(47,255,227,0.22); } -.cancel-btn:hover { background: var(--panel-3); color: var(--fg); } .cancel-btn.hidden { display: none !important; } +.job-logs-btn, +.logs-refresh { + height: 34px; + padding: 0 14px; + border: 1px solid rgba(234,255,50,0.38); + border-radius: 999px; + background: rgba(234,255,50,0.09); + color: var(--accent-2); + font: 800 11px var(--font-mono); + letter-spacing: 0.04em; + cursor: pointer; + box-shadow: 0 0 18px rgba(234,255,50,0.1); +} +.job-logs-btn:hover, +.logs-refresh:hover { + border-color: rgba(47,255,227,0.72); + background: rgba(47,255,227,0.14); + color: var(--fg); +} /* Error banner */ .error { @@ -1137,7 +1459,7 @@ input, textarea { font-family: inherit; } border: 1.5px solid var(--accent); display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; color: var(--accent); - background: rgba(244,183,64,0.08); + background: rgba(47,255,227,0.08); font-family: var(--font-mono); } @@ -1159,6 +1481,13 @@ input, textarea { font-family: inherit; } } .daw-label-title { font-size: 12px; font-weight: 600; } .daw-label-sub { font-size: 9px; } +.daw-mixer-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} .daw-sections-header { justify-content: space-between; } .daw-sections-area { flex: 1; @@ -1380,6 +1709,42 @@ input, textarea { font-family: inherit; } .drag-handle { display: none; } .mini-meter { display: none; } +.stem-profile-badge { + display: flex; + align-items: center; + gap: 6px; + max-width: 176px; + min-width: 0; + padding: 4px 8px; + border: 1px solid rgba(112, 245, 225, 0.28); + border-radius: 999px; + background: + linear-gradient(135deg, rgba(112, 245, 225, 0.14), rgba(213, 255, 74, 0.08)), + rgba(3, 18, 20, 0.78); + box-shadow: 0 0 18px rgba(112, 245, 225, 0.12); + color: var(--fg); + line-height: 1; +} + +.stem-profile-badge span { + color: var(--muted); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + flex-shrink: 0; +} + +.stem-profile-badge b { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--accent); + font-size: 10px; + font-weight: 800; +} + /* ── Mixer column (JS-built horizontal lanes) ── */ .mixer-column { display: flex; @@ -1548,7 +1913,12 @@ input, textarea { font-family: inherit; } .lane-icon-toggle.mx-btn.mute:not(.active) { opacity: 0.35; } /* Solo button: "active" = soloed */ -.ms-btn.mx-btn.active { background: rgba(244,183,64,0.18); color: var(--accent); border-color: rgba(244,183,64,0.4); } +.ms-btn.mx-btn.active { + background: rgba(47,255,227,0.18); + color: var(--accent); + border-color: rgba(47,255,227,0.42); + box-shadow: 0 0 12px rgba(47,255,227,0.12); +} /* Download button */ .lane-dl.mx-btn { @@ -1565,7 +1935,7 @@ input, textarea { font-family: inherit; } /* ── Waveform panel (right) ── */ /* Layout only — waves.css handles all visual styling inside (.wave-scroll bg, - golden scrollbar, .loop-region, .waves-grid, .stem-waveform-layer etc.) */ + accent scrollbar, .loop-region, .waves-grid, .stem-waveform-layer etc.) */ .daw-wave-panel { flex: 1; min-width: 0; @@ -1663,12 +2033,14 @@ input, textarea { font-family: inherit; } display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; - background: rgb(11, 15, 18); + background: + radial-gradient(circle at 52% 44%, rgba(47,255,227,0.1), transparent 48%), + rgb(4, 9, 13); } .wave-loading-overlay.hidden { display: none !important; } .wave-loading-glow { width: 80px; height: 80px; border-radius: 50%; - background: radial-gradient(circle, rgba(244,183,64,0.2) 0%, transparent 70%); + background: radial-gradient(circle, rgba(47,255,227,0.24) 0%, rgba(255,61,139,0.12) 42%, transparent 72%); animation: daw-pulse 2s ease-in-out infinite; } @keyframes daw-pulse { 0%,100%{opacity:0.5;transform:scale(0.9);} 50%{opacity:1;transform:scale(1.1);} } @@ -1733,19 +2105,19 @@ input, textarea { font-family: inherit; } display: flex; align-items: center; justify-content: center; gap: 8px; } .daw-play-btn { - background: var(--accent); - color: #1a1206; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: #021013; width: 56px; height: 56px; border-radius: 50%; border: none; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; - box-shadow: 0 2px 10px rgba(244,183,64,0.35), inset 0 1px 0 rgba(255,255,255,0.18); + box-shadow: 0 2px 14px rgba(47,255,227,0.32), inset 0 1px 0 rgba(255,255,255,0.22); transition: transform var(--t-fast), box-shadow var(--t-fast), background var(--t-fast); flex-shrink: 0; } -.daw-play-btn:hover { box-shadow: 0 2px 16px rgba(244,183,64,0.5), inset 0 1px 0 rgba(255,255,255,0.22); } +.daw-play-btn:hover { box-shadow: 0 2px 18px rgba(47,255,227,0.5), inset 0 1px 0 rgba(255,255,255,0.25); } .daw-play-btn:active { transform: scale(0.96); } -/* Playing = green (go); idle/ready stays gold. */ +/* Playing = green (go); idle/ready stays neon accent. */ .daw-play-btn.playing { background: #4caf7d; box-shadow: 0 2px 12px rgba(76,175,125,0.45), inset 0 1px 0 rgba(255,255,255,0.2); @@ -1839,7 +2211,7 @@ input, textarea { font-family: inherit; } } .footer-track-body .daw-track-meta { flex: 1; min-width: 0; gap: 4px; } .footer-track .daw-track-details { gap: 2px; } -.footer-track .daw-detail-label { font-size: 10px; width: 58px; } +.footer-track .daw-detail-label { font-size: 10px; width: 66px; } .footer-track .daw-detail-val { font-size: 10px; } .footer-art { width: 44px; height: 44px; border-radius: 6px; @@ -1977,6 +2349,53 @@ input, textarea { font-family: inherit; } .export-fmt:hover { color: var(--fg-2); } .export-fmt.active { background: var(--panel-3); color: var(--accent); } +.chord-export-options { + margin: 6px 2px; + padding: 9px; + border: 1px solid rgba(47,255,227,0.16); + border-radius: 12px; + background: + linear-gradient(135deg, rgba(47,255,227,0.08), transparent 58%), + rgba(2,8,12,0.72); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.chord-export-options label { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + color: var(--muted); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.09em; + text-transform: uppercase; +} +.chord-export-options select { + width: 100%; + min-height: 32px; + border: 1px solid rgba(118,255,240,0.22); + border-radius: 9px; + background: rgba(4,18,23,0.88); + color: var(--fg); + font: 700 12px var(--font-sans); + padding: 0 8px; +} +.chord-marker-toggle { + grid-column: 1 / -1; + flex-direction: row !important; + align-items: center; + min-height: 28px; + cursor: pointer; +} +.chord-marker-toggle input { + accent-color: var(--accent); +} +.chord-marker-toggle input:disabled + span { + opacity: 0.45; +} + /* Action rows — two-line: icon + (title/desc) + trailing check/count */ .export-item { align-items: center; gap: 11px; @@ -2020,13 +2439,17 @@ input, textarea { font-family: inherit; } .about-close:hover { background: var(--panel-3); color: var(--fg); } .about-logo { width: 56px; height: 56px; border-radius: 14px; - background: linear-gradient(135deg, rgba(139,92,246,0.2), rgba(244,183,64,0.15)); - border: 1px solid rgba(139,92,246,0.25); + background: linear-gradient(135deg, rgba(47,255,227,0.22), rgba(255,61,139,0.16)); + border: 1px solid rgba(47,255,227,0.24); + box-shadow: 0 0 26px rgba(47,255,227,0.12); display: flex; align-items: center; justify-content: center; color: var(--accent); margin-bottom: 14px; } .about-card h2 { margin: 0 0 4px; font-size: 18px; font-family: var(--font-mono); font-weight: 700; } .about-tagline { margin: 0 0 12px; font-size: 12px; color: var(--muted); } +.about-license { margin: -4px 0 12px; max-width: 360px; font-size: 11px; line-height: 1.45; color: var(--muted-2); } +.about-license a { color: var(--accent); text-decoration: none; } +.about-license a:hover { text-decoration: underline; } .about-version-badge { display: inline-block; font-size: 11px; font-family: var(--font-mono); color: var(--muted); background: var(--panel); border: 1px solid var(--border); @@ -2066,6 +2489,132 @@ input, textarea { font-family: inherit; } } .about-social-btn:hover { background: var(--accent); border-color: var(--accent); color: #000; } +/* ── Processing logs dialog ── */ +#logsDialog { z-index: 2200; } +.logs-card { + width: min(760px, calc(100vw - 30px)); + height: min(690px, calc(100dvh - 36px)); + padding: 24px; + align-items: stretch; + text-align: left; + overflow: hidden; + background: + radial-gradient(circle at 4% 0%, rgba(47,255,227,0.13), transparent 34%), + radial-gradient(circle at 96% 100%, rgba(234,255,50,0.08), transparent 38%), + rgba(4,13,18,0.98); +} +.logs-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 18px; + padding-right: 34px; + margin-bottom: 16px; +} +.logs-heading h2 { + margin-top: 3px; + margin-bottom: 0; + color: var(--fg); + font-size: 22px; +} +.logs-filter { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 12px; + margin-bottom: 12px; + color: var(--muted); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.logs-filter select { + min-width: 0; + height: 38px; + padding: 0 12px; + border: 1px solid rgba(118,255,240,0.24); + border-radius: 10px; + background: rgba(3,17,22,0.94); + color: var(--fg); + font: 700 12px var(--font-sans); +} +.logs-list { + flex: 1; + min-height: 0; + overflow: auto; + pointer-events: auto; + border: 1px solid rgba(118,255,240,0.17); + border-radius: 14px; + background: + linear-gradient(90deg, rgba(47,255,227,0.035) 1px, transparent 1px), + linear-gradient(0deg, rgba(47,255,227,0.035) 1px, transparent 1px), + rgba(1,7,10,0.92); + background-size: 24px 24px; +} +.log-entry { + display: grid; + grid-template-columns: 116px minmax(0, 1fr); + gap: 14px; + padding: 11px 13px; + border-bottom: 1px solid rgba(118,255,240,0.09); +} +.log-entry:last-child { border-bottom: none; } +.log-entry-meta { + display: flex; + align-items: center; + align-content: flex-start; + gap: 6px; + flex-wrap: wrap; + color: var(--muted); + font: 700 10px var(--font-mono); + font-variant-numeric: tabular-nums; +} +.log-entry-level { + padding: 2px 5px; + border-radius: 5px; + background: rgba(47,255,227,0.1); + color: var(--accent); +} +.log-warning .log-entry-level { + background: rgba(255,157,72,0.12); + color: #ffb66f; +} +.log-error .log-entry-level { + background: rgba(255,61,139,0.14); + color: #ff72ac; +} +.log-entry-progress { color: var(--accent-2); } +.log-entry-body { + min-width: 0; + display: flex; + align-items: baseline; + gap: 9px; +} +.log-entry-stage { + flex: 0 0 auto; + color: var(--muted); + font: 700 10px var(--font-mono); + text-transform: uppercase; +} +.log-entry-body p { + margin: 0; + color: var(--fg-2); + font: 500 12px/1.55 var(--font-mono); + overflow-wrap: anywhere; +} +.logs-empty { + margin: auto; + color: var(--muted); + font: 600 12px var(--font-mono); +} +.logs-empty.hidden { display: none; } +.logs-status { + margin: 10px 2px 0; + color: var(--muted); + font: 600 10px var(--font-mono); +} + /* ── Library sections (Recent · Stem Collections · Tags) ── */ .lib-section { display: flex; @@ -2143,9 +2692,9 @@ input, textarea { font-family: inherit; } border-color: rgba(255,255,255,0.15); } .lib-tag-chip.active { - background: rgba(244,183,64,0.12); + background: rgba(47,255,227,0.12); color: var(--accent); - border-color: rgba(244,183,64,0.3); + border-color: rgba(47,255,227,0.3); } .lib-tag-count { color: var(--muted); font-size: 11px; } @@ -2260,17 +2809,35 @@ input, textarea { font-family: inherit; } /* No track loaded: hide main waveform content */ .daw.is-import .daw-track-header, .daw.is-import .daw-section-ribbon, -.daw.is-import .daw-wave-header, -.daw.is-import .daw-content { opacity: 0.3; pointer-events: none; } -.daw.is-import .daw-content .mixer-column { opacity: 1; pointer-events: auto; } +.daw.is-import .daw-wave-header { opacity: 0.3; pointer-events: none; } +.daw.is-import .daw-content { + opacity: 1; + pointer-events: auto; +} +.daw.is-import .daw-content .mixer-column, +.daw.is-import .daw-content .waves-column { + opacity: 0.28; + pointer-events: none; +} .daw.no-track .daw-track-header, .daw.no-track .daw-section-ribbon, -.daw.no-track .daw-wave-header, -.daw.no-track .daw-content { opacity: 0.3; pointer-events: none; } -/* Overlay and job progress live inside .daw-content but must stay fully visible while processing */ +.daw.no-track .daw-wave-header { opacity: 0.3; pointer-events: none; } +.daw.no-track .daw-content { + opacity: 1; + pointer-events: auto; +} +.daw.no-track .daw-content .mixer-column, +.daw.no-track .daw-content .waves-column { + opacity: 0.28; + pointer-events: none; +} +/* Overlay and job progress must stay fully visible while processing. */ +.daw.is-import .daw-content .job, .daw.no-track .daw-content .wave-loading-overlay, -.daw.no-track .daw-content .job { opacity: 1; pointer-events: auto; } +.daw.no-track .daw-content .job { opacity: 1; pointer-events: none; } +.daw.is-import .daw-content .job button, +.daw.no-track .daw-content .job button { pointer-events: auto; } .app.no-track #t-export-btn, .app.is-import #t-export-btn { @@ -2284,7 +2851,960 @@ input, textarea { font-family: inherit; } /* ── Responsive: hide zoom in small windows ── */ @media (max-width: 900px) { + .job { + left: 14px; + right: 14px; + top: 92px; + bottom: auto; + width: auto; + padding: 16px; + border-radius: 20px; + } + .job-header { + flex-direction: column; + } + .job .title { + white-space: normal; + } + .job .job-progress-row { + gap: 10px; + } .daw-info-row { flex-wrap: wrap; } .daw-track-card { width: 100%; border-right: none; border-bottom: 1px solid var(--border); } .stem-presence-panel { grid-template-columns: repeat(3, 1fr); } } + +/* ═══════════════════════════════════ + Responsive phone / small tablet layout + ═══════════════════════════════════ */ +@media (max-width: 1180px) { + .daw-topbar { + height: auto; + min-height: 77px; + flex-wrap: wrap; + align-content: center; + padding: 10px 12px; + gap: 10px; + } + + .daw-sep { display: none; } + + .daw-composer { + order: 3; + flex: 1 0 100%; + height: auto; + min-height: 52px; + flex-wrap: wrap; + align-items: stretch; + overflow: visible; + border-radius: 18px; + } + + .daw-composer-sep { + display: none; + } + + .daw-url-zone { + flex: 1 1 260px; + min-height: 46px; + } + + .daw-stem-section, + .daw-quality-section { + min-height: 42px; + padding: 0 10px 8px; + } + + .daw-stem-section { + flex: 1 1 520px; + } + + .daw-quality-section { + flex: 1 1 220px; + justify-content: flex-start; + } + + .daw-quality-select { + flex: 1; + min-width: 0; + } + + .daw-process-btn { + flex: 1 1 180px; + min-height: 42px; + margin: 0 8px 8px; + border-left: none; + border-radius: 12px; + } +} + +@media (max-width: 900px) { + html, + body, + .daw { + height: 100dvh; + min-height: 100dvh; + } + + .daw { + font-size: 12px; + } + + .daw-topbar { + padding: max(10px, env(safe-area-inset-top)) 10px 10px; + } + + .daw-brand-name { + font-size: 17px; + } + + .daw-composer { + height: auto; + min-height: 52px; + flex-wrap: wrap; + align-items: stretch; + overflow: visible; + border-radius: 18px; + } + + .daw-url-zone { + flex: 1 0 100%; + min-height: 46px; + padding: 0 12px; + } + + .daw-url-zone input[type="url"] { + font-size: 16px; /* avoids iOS input zoom */ + } + + .file-name { + max-width: min(46vw, 220px); + } + + .daw-stem-section, + .daw-quality-section { + min-height: 42px; + padding: 0 10px 8px; + } + + .daw-stem-section { + flex: 1 1 360px; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + } + + .daw-stem-section::-webkit-scrollbar { + display: none; + } + + .daw-stem-chips { + min-width: max-content; + } + + .daw-quality-section { + flex: 0 1 220px; + justify-content: flex-end; + } + + .daw-process-btn { + flex: 1 0 180px; + min-height: 42px; + margin: 0 8px 8px; + justify-content: center; + border-left: none; + border-radius: 12px; + } + + .daw-notif-panel { + position: fixed; + top: calc(env(safe-area-inset-top) + 64px); + right: 10px; + left: 10px; + width: auto; + max-height: min(70dvh, 420px); + overflow-y: auto; + } + + .daw-body { + flex-direction: column; + overflow: hidden; + } + + .sidebar { + width: 100%; + height: clamp(160px, 28dvh, 230px); + border-right: none; + border-bottom: 1px solid var(--border-strong); + box-shadow: 0 14px 34px rgba(0,0,0,0.24), inset 0 -1px 0 rgba(47,255,227,0.06); + } + + .sidebar-rail { + width: 58px; + padding: 8px 0; + } + + .rail-btn { + width: 42px; + height: 38px; + font-size: 8px; + } + + .sidebar-body { + padding: 8px; + gap: 8px; + } + + .daw-search { + height: 34px; + } + + .search-kbd { + display: none; + } + + .daw-main-col { + flex: 1 1 auto; + min-height: 0; + } + + .error { + top: auto; + left: 10px; + right: 10px; + bottom: calc(132px + env(safe-area-inset-bottom)); + border: 1px solid rgba(214,90,74,0.34); + border-radius: 10px; + box-shadow: 0 14px 34px rgba(0,0,0,0.34); + } + + .daw-track-header { + max-height: 190px; + overflow-y: auto; + } + + .daw-info-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .daw-track-card { + grid-column: 1 / -1; + padding: 10px 12px; + } + + .daw-cover { + width: 52px; + height: 52px; + } + + .daw-track-title { + font-size: 14px; + } + + .daw-meta-card { + min-width: 0; + padding: 10px 12px; + } + + .meta-card-value { + font-size: 18px; + } + + .stem-presence-panel { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .stem-card { + padding: 9px 12px; + } + + .stem-card-pct { + font-size: 20px; + } + + .daw-mixer-label, + .daw-wave-label, + .daw-stems-panel { + width: clamp(176px, 42vw, 230px); + } + + .stem-profile-badge { + max-width: 116px; + } + + .daw-sections-area, + .daw-ruler-area, + .daw-wave-panel { + min-width: 0; + } + + .daw-content { + overflow: hidden; + } + + .lane-header.mx-row { + gap: 5px; + padding: 0 8px; + } + + .lane-name-vu { + width: 58px; + } + + .mx-name { + font-size: 12px; + } + + .mx-val { + width: 30px; + font-size: 9px; + } + + .lane-icon-toggle.mx-btn, + .ms-btn.mx-btn, + .lane-dl.mx-btn { + width: 24px; + height: 24px; + } + + .daw-footer { + height: 150px; + padding-bottom: env(safe-area-inset-bottom); + } + + .footer-content { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "track controls" + "chips chips"; + gap: 8px 10px; + align-items: center; + padding: 8px 12px; + } + + .footer-track { + grid-area: track; + } + + .footer-center { + grid-area: controls; + padding: 0; + } + + .footer-chips { + grid-area: chips; + justify-content: flex-start; + overflow-x: auto; + padding-bottom: 2px; + scrollbar-width: none; + } + + .footer-chips::-webkit-scrollbar { + display: none; + } + + .footer-track-body { + align-items: center; + } + + .footer-cover { + width: 44px !important; + height: 44px !important; + } + + .footer-track-body .daw-track-meta { + display: none; + } + + .footer-track-title { + font-size: 13px; + } + + .footer-wave-bar { + height: 28px; + } + + .footer-transport { + gap: 7px; + } + + .footer-transport .daw-iconbtn.btn-transport { + width: 38px; + height: 38px; + } + + .daw-play-btn { + width: 50px; + height: 50px; + } + + .footer-chip, + .daw-footer .footer-btn { + height: 34px; + padding: 0 11px; + font-size: 11px; + } + + .footer-chip-panel { + position: fixed; + left: 10px; + right: 10px; + bottom: calc(150px + env(safe-area-inset-bottom)); + width: auto; + max-height: 46dvh; + overflow-y: auto; + } + + .chip-panel-export { + min-width: 0; + } +} + +@media (max-width: 640px) { + .daw-topbar { + gap: 8px; + padding-inline: 8px; + } + + .daw-brand { + min-height: 30px; + } + + .daw-brand-name { + font-size: 16px; + } + + .daw-notif-wrap { + margin-left: auto; + } + + .daw-composer { + border-radius: 16px; + } + + .daw-extract-label { + display: none; + } + + .stem-choice { + height: 32px; + padding: 0 10px; + } + + .daw-quality-section { + flex: 1 1 170px; + justify-content: flex-start; + } + + .daw-quality-select { + flex: 1; + min-width: 0; + font-size: 12px; + } + + .daw-process-btn { + flex: 1 1 150px; + margin: 0 8px 8px 0; + } + + .sidebar { + height: clamp(136px, 24dvh, 188px); + } + + .sidebar-rail { + width: 50px; + } + + .rail-btn { + width: 38px; + } + + .rail-btn span { + display: none; + } + + .daw-lib-header { + display: none; + } + + .lib-section { + padding-top: 8px; + } + + .daw-track-header { + max-height: 152px; + } + + .daw-info-row { + grid-template-columns: 1fr; + } + + .daw-meta-card { + border-right: none; + border-bottom: 1px solid var(--border); + } + + .daw-meta-card:nth-of-type(n + 3) { + display: none; + } + + .stem-presence-panel { + display: flex; + overflow-x: auto; + scrollbar-width: none; + } + + .stem-presence-panel::-webkit-scrollbar { + display: none; + } + + .stem-card { + min-width: 88px; + border-right: 1px solid var(--border); + } + + .daw-mixer-label, + .daw-wave-label, + .daw-stems-panel { + width: clamp(142px, 39vw, 176px); + } + + .daw-label-sub, + .stem-profile-badge, + .sections-save-indicator, + .mx-val { + display: none; + } + + .daw-mixer-label, + .daw-wave-label { + padding-inline: 10px; + } + + .lane-name-vu { + width: 48px; + } + + .mx-name { + font-size: 11px; + } + + .mx-fader-input::-webkit-slider-thumb { + width: 18px; + height: 18px; + margin-top: -7px; + } + + .mx-fader-input::-moz-range-thumb { + width: 18px; + height: 18px; + } + + .daw-footer { + height: 138px; + } + + .footer-content { + grid-template-columns: 1fr; + grid-template-areas: + "controls" + "track" + "chips"; + justify-items: center; + gap: 6px; + padding: 7px 10px; + } + + .footer-track, + .footer-chips { + width: 100%; + } + + .footer-track-body { + justify-content: center; + } + + .footer-cover { + display: none !important; + } + + .footer-track-title-row { + justify-content: center; + } + + .footer-track-title { + text-align: center; + max-width: 100%; + } + + .footer-time-row { + display: none; + } + + .footer-chip-panel { + bottom: calc(138px + env(safe-area-inset-bottom)); + } +} + +@media (max-width: 480px) { + .daw { + font-size: 11px; + } + + .daw-topbar { + padding-bottom: 8px; + } + + .daw-url-zone { + min-height: 42px; + } + + .file-size { + display: none; + } + + .daw-stem-section, + .daw-quality-section { + min-height: 38px; + padding-bottom: 7px; + } + + .stem-choice { + height: 30px; + padding-inline: 9px; + font-size: 11px; + } + + .daw-upload-btn, + .daw-iconbtn { + width: 34px; + height: 34px; + } + + .sidebar { + height: clamp(118px, 22dvh, 160px); + } + + .sidebar-body { + padding: 7px; + } + + .daw-search { + height: 32px; + } + + .daw-track-header { + max-height: 124px; + } + + .daw-track-card { + gap: 10px; + padding: 8px 10px; + } + + .daw-cover { + width: 44px; + height: 44px; + } + + .daw-meta-card { + padding: 8px 10px; + } + + .meta-card-value { + font-size: 16px; + } + + .stem-card { + min-width: 76px; + padding: 8px 10px; + } + + .stem-card-pct { + font-size: 17px; + } + + .daw-mixer-label, + .daw-wave-label, + .daw-stems-panel { + width: 136px; + } + + .daw-label-title { + font-size: 11px; + } + + .lane-header.mx-row { + gap: 4px; + padding-inline: 6px; + } + + .lane-name-vu { + width: 42px; + } + + .lane-icon-toggle.mx-btn, + .ms-btn.mx-btn, + .lane-dl.mx-btn { + width: 22px; + height: 22px; + font-size: 9px; + } + + .job { + padding: 22px 16px; + } + + .job .job-progress-row { + grid-template-columns: 1fr; + gap: 7px; + } + + .job .job-percent { + text-align: center; + } + + .daw-footer { + height: 128px; + } + + .daw-play-btn { + width: 46px; + height: 46px; + } + + .footer-transport .daw-iconbtn.btn-transport { + width: 36px; + height: 36px; + } + + .footer-chip, + .daw-footer .footer-btn { + height: 32px; + padding-inline: 10px; + } + + .footer-chip-panel { + bottom: calc(128px + env(safe-area-inset-bottom)); + } + + .about-card, + .friends-card { + width: min(360px, calc(100vw - 20px)); + max-height: calc(100dvh - 28px); + overflow-y: auto; + padding-inline: 18px; + } + + .about-primary-links { + flex-direction: column; + } +} + +@media (max-width: 640px) and (max-height: 720px) { + .daw-topbar { + padding-top: max(8px, env(safe-area-inset-top)); + padding-bottom: 7px; + } + + .daw-brand { + min-height: 24px; + } + + .daw-brand-name { + font-size: 15px; + } + + .daw-url-zone { + min-height: 38px; + } + + .daw-stem-section, + .daw-quality-section { + min-height: 34px; + padding-bottom: 5px; + } + + .stem-choice { + height: 28px; + } + + .daw-process-btn { + min-height: 36px; + font-size: 13px; + } + + .sidebar { + height: 104px; + } + + .daw-lib-header, + .lib-tags-row { + display: none; + } + + .daw-track-header { + max-height: 96px; + } + + .daw-footer { + height: 110px; + } + + .footer-wave-bar { + height: 18px; + } + + .footer-chip-panel { + bottom: calc(110px + env(safe-area-inset-bottom)); + } +} + +@media (max-width: 760px) { + .daw-url-zone, + .daw-quality-select, + .stem-choice, + .daw-process-btn, + .footer-chip, + .daw-footer .footer-btn { + min-height: 44px; + } + + .stem-choice { + padding-inline: 13px; + } + + .daw-quality-select { + font-size: 14px; + padding-inline: 12px; + } + + .rail-btn { + width: 46px; + min-height: 44px; + font-size: 9px; + } + + .daw-search { + min-height: 42px; + } + + .footer-chip-panel { + right: 8px; + left: 8px; + min-width: 0; + width: auto; + } + + .chip-panel-export { + max-height: min(72vh, 520px); + overflow-y: auto; + } + + .chord-export-options { + grid-template-columns: 1fr; + } + + .logs-card { + width: calc(100vw - 16px); + height: calc(100dvh - 16px); + padding: 18px 12px 12px; + border-radius: 16px; + } + + .logs-heading { + align-items: flex-start; + padding-right: 32px; + } + + .logs-filter { + grid-template-columns: 1fr; + gap: 5px; + } + + .logs-filter select, + .logs-refresh { + min-height: 44px; + } + + .log-entry { + grid-template-columns: 1fr; + gap: 6px; + } + + .log-entry-body { + flex-direction: column; + gap: 4px; + } +} + +@media (max-height: 560px) and (orientation: landscape) { + .daw-topbar { + min-height: 58px; + } + + .daw-composer { + flex-wrap: nowrap; + } + + .daw-url-zone { + flex: 1 1 200px; + } + + .daw-stem-section { + flex: 1 1 180px; + } + + .daw-quality-section { + flex: 0 0 150px; + } + + .daw-extract-label { + display: none; + } + + .daw-stem-section, + .daw-quality-section, + .daw-process-btn { + padding-bottom: 0; + margin-bottom: 0; + } + + .daw-process-btn { + flex: 0 0 108px; + padding-inline: 12px; + } + + .sidebar { + height: 106px; + } + + .daw-track-header { + display: none; + } + + .daw-footer { + height: 92px; + } + + .footer-wave-bar { + display: none; + } +} + +@media (pointer: coarse) { + .daw button, + .daw a, + .daw input, + .daw select { + touch-action: manipulation; + } + + .daw-lib-list, + .mixer-column, + .daw .wave-scroll { + -webkit-overflow-scrolling: touch; + } +} diff --git a/static/css/job.css b/static/css/job.css index 3b0fd29..3473a6d 100644 --- a/static/css/job.css +++ b/static/css/job.css @@ -26,24 +26,76 @@ flex-shrink: 0; font-variant-numeric: tabular-nums; } +.job-headline { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + min-width: 150px; +} +.job-eta { + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.job-progress-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; +} progress { width: 100%; - height: 4px; + height: 12px; appearance: none; - background: var(--line); - border: 0; - border-radius: 2px; + background: + linear-gradient(180deg, rgba(236,255,251,0.09), rgba(0,0,0,0.08)), + rgba(2, 13, 18, 0.92); + border: 1px solid rgba(47,255,227,0.34); + border-radius: 999px; overflow: hidden; + box-shadow: + inset 0 0 0 1px rgba(255,255,255,0.045), + inset 0 0 18px rgba(0,0,0,0.52), + 0 0 20px rgba(47,255,227,0.22); } progress::-webkit-progress-bar { - background: var(--line); + background: + linear-gradient(90deg, rgba(47,255,227,0.08) 1px, transparent 1px), + rgba(2, 13, 18, 0.92); + background-size: 18px 100%; + border-radius: 999px; } progress::-webkit-progress-value { - background: var(--ink); + background: + linear-gradient(90deg, var(--accent), var(--accent-2) 52%, var(--neon-pink)); + border-radius: 999px; + box-shadow: + 0 0 16px rgba(47,255,227,0.72), + 0 0 26px rgba(234,255,50,0.38); transition: width var(--t-base) var(--easing); } progress::-moz-progress-bar { - background: var(--ink); + background: + linear-gradient(90deg, var(--accent), var(--accent-2) 52%, var(--neon-pink)); + border-radius: 999px; + box-shadow: + 0 0 16px rgba(47,255,227,0.72), + 0 0 26px rgba(234,255,50,0.38); +} +.job-percent { + min-width: 4ch; + text-align: right; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 800; + color: var(--accent-2); + text-shadow: + 0 0 10px rgba(234,255,50,0.65), + 0 0 18px rgba(47,255,227,0.42); + font-variant-numeric: tabular-nums; } /* Compact technical detail line under the progress bar -- shows the diff --git a/static/css/variables.css b/static/css/variables.css index a6f2366..10ea4eb 100644 --- a/static/css/variables.css +++ b/static/css/variables.css @@ -3,48 +3,51 @@ color-scheme: dark; /* Surfaces */ - --bg: #0b0f12; - --bg-2: #0f1418; - --panel: #131a1f; - --panel-2: #182026; - --panel-3: #1d262d; - --border: #232c34; - --border-strong: #2e3942; + --bg: #03070a; + --bg-2: rgba(7,16,20,0.86); + --panel: rgba(8,19,24,0.9); + --panel-2: rgba(11,28,34,0.92); + --panel-3: rgba(16,41,47,0.95); + --border: rgba(74,255,236,0.16); + --border-strong: rgba(74,255,236,0.34); /* Text */ - --fg: #e8ecf0; - --fg-2: #c2c9d1; - --muted: #8a939c; - --muted-2: #5d666e; + --fg: #ecfffb; + --fg-2: #bdf7ef; + --muted: #75a8a7; + --muted-2: #4c7479; /* Stem colours */ - --vocals: #ef4444; - --drums: #f97316; - --bass: #eab308; - --guitar: #22c55e; - --piano: #a855f7; - --other: #9ca3af; + --vocals: #ff3d8b; + --drums: #ff7a1a; + --bass: #eaff32; + --guitar: #38ff8f; + --piano: #7c8cff; + --other: #42e8ff; /* Accent */ - --accent: #f4b740; - --accent-2: #d99a2b; + --accent: #2fffe3; + --accent-2: #eaff32; + --neon-pink: #ff3d8b; + --neon-orange: #ff7a1a; + --neon-blue: #42e8ff; /* Legacy compat aliases used by waves.css / player.js */ --gold: var(--accent); - --gold-bright: #f8c054; - --gold-soft: rgba(244,183,64,0.16); - --gold-line: rgba(244,183,64,0.28); + --gold-bright: var(--accent-2); + --gold-soft: rgba(47,255,227,0.16); + --gold-line: rgba(47,255,227,0.32); --ink: var(--fg); --ink-dim: var(--fg-2); --ink-faint: var(--muted); --line: var(--border); --line-strong: var(--border-strong); --glass-line: var(--border); - --danger: #d65a4a; - --focus-ring: rgba(236,236,236,0.55); + --danger: #ff5470; + --focus-ring: rgba(47,255,227,0.58); /* Typography */ - --font-sans: 'Inter', -apple-system, system-ui, sans-serif; + --font-sans: 'Space Grotesk', -apple-system, system-ui, sans-serif; --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; /* Layout */ @@ -58,7 +61,10 @@ --t-fast: 80ms; --t-base: 120ms; --easing: cubic-bezier(0.2,0.8,0.2,1); - --shadow-panel: 0 4px 24px rgba(0,0,0,0.4); + --shadow-panel: + 0 0 0 1px rgba(47,255,227,0.12), + 0 18px 46px rgba(0,0,0,0.52), + 0 0 34px rgba(47,255,227,0.08); } @media (prefers-reduced-motion: reduce) { diff --git a/static/css/waves.css b/static/css/waves.css index ebdfcfc..6be32a1 100644 --- a/static/css/waves.css +++ b/static/css/waves.css @@ -689,6 +689,20 @@ background: rgba(148, 163, 184, 0.14); } +.waves-grid .beat-grid-line { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: rgba(198, 255, 39, 0.28); + box-shadow: 0 0 10px rgba(198, 255, 39, 0.12); +} + +.waves-grid .beat-grid-line-strong { + background: rgba(109, 255, 240, 0.42); + box-shadow: 0 0 12px rgba(109, 255, 240, 0.18); +} + #multitrack-container { width: 100%; position: relative; diff --git a/static/css/widgets.css b/static/css/widgets.css index 38b604a..7f6892a 100644 --- a/static/css/widgets.css +++ b/static/css/widgets.css @@ -613,7 +613,7 @@ /* ─── 5. Job progress: fix always-hidden CSS bug ─── */ .job:not(.hidden) { - display: block !important; + display: flex !important; } /* ─── 6. Grafana-style collapsible widget ─── */ diff --git a/static/imgs/stemdeck-logo-horizontal.svg b/static/imgs/stemdeck-logo-horizontal.svg index b999fac..6f89817 100644 --- a/static/imgs/stemdeck-logo-horizontal.svg +++ b/static/imgs/stemdeck-logo-horizontal.svg @@ -1,6 +1,6 @@ - StemDeck Horizontal Logo - StemDeck logo with golden waveform symbol and wordmark. + LayerLab Horizontal Logo + LayerLab logo with golden waveform symbol and wordmark. @@ -19,7 +19,7 @@ - Stem - Deck + Layer + Lab POWERED STEM SEPARATION diff --git a/static/index.html b/static/index.html index f8bb80f..5ce70a7 100644 --- a/static/index.html +++ b/static/index.html @@ -3,10 +3,10 @@ - StemDeck — split any track into stems + LayerLab - unofficial StemDeck fork test build - + @@ -20,7 +20,7 @@
- StemDeck + LayerLab
@@ -57,7 +57,7 @@ - + @@ -89,6 +89,44 @@ +
+ + + + +
+ + + + +
+ + + + + - +
+ + 0% +
@@ -539,6 +595,14 @@ Extracted
+
+ Processed + +
+
+ Profile + +
Source @@ -547,6 +611,14 @@ Quality
+
+ Repair + +
+
+ Beat grid + +
@@ -623,6 +695,43 @@ All stems as a .zip +
+ + + + +
+ +
+
+ Diagnostics +

Processing logs

+
+
+ +
+

No diagnostic entries yet.

+

Auto refresh on

diff --git a/static/js/catalog.js b/static/js/catalog.js index 7618f6c..8730972 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -3,8 +3,8 @@ import { STEM_NAMES } from "./constants.js"; import { wireUpAudio, updateFooterTrack } from "./player.js"; import { initSections } from "./sections.js"; import { bpmChip, keyChip, saveSelectedStems, selectedStems, titleEl } from "./state.js"; -import { showError, importFromUrl } from "./job.js"; -import { fmtTime, storeGet, storeSet } from "./utils.js"; +import { showError, importFromUrl, showJobProgress } from "./job.js"; +import { fmtBeatGrid, fmtTime, storeGet, storeSet } from "./utils.js"; // Escape user-supplied strings before inserting into innerHTML. function esc(s) { @@ -60,6 +60,37 @@ const TRASH_ID = "trash"; // The default landing folder for unorganized tracks — protected from deletion. const UNSORTED_ID = "f-unsorted"; const PROCESSING_STATUSES = new Set(["queued", "downloading", "analyzing", "separating", "processing"]); +const RUNNING_STATUS_LABELS = { + downloading: "Downloading", + analyzing: "Analyzing", + separating: "Separating", + processing: "Processing", +}; +const QUALITY_LABELS = { + standard: "Standard", + high: "High", + max: "Max", + ultra: "Ultra", +}; +const DENOISE_LABELS = { + off: "Noise off", + light: "Light denoise", + strong: "Strong denoise", +}; +const DEVICE_LABELS = { + auto: "Auto", + cpu: "CPU", + mps: "Apple GPU", + cuda: "NVIDIA CUDA", +}; +const STEM_LABELS = { + vocals: "Vocals", + drums: "Drums", + bass: "Bass", + guitar: "Guitar", + piano: "Piano", + other: "Other", +}; const FOLDER_COLORS = ["#d8a84a", "#e85f6f", "#64c86f", "#4f9de8", "#a985f4"]; const DEFAULT_FOLDER_COLOR = FOLDER_COLORS[0]; const TRACK_DRAG_TYPE = "application/x-stemdeck-track"; @@ -111,6 +142,58 @@ function normalizeSource(value) { return s; } +function stemNamesFromValue(value) { + if (!Array.isArray(value)) return []; + return value + .map((item) => (typeof item === "string" ? item : item?.name)) + .filter((name) => STEM_NAMES.includes(name)); +} + +function trackProfileStems(track) { + const selected = stemNamesFromValue(track?.selectedStems); + const fallback = stemNamesFromValue(track?.stems).concat(stemNamesFromValue(track?.audioStems)); + const source = selected.length ? selected : fallback; + const unique = new Set(source); + const ordered = STEM_NAMES.filter((name) => unique.has(name)); + return ordered.length ? ordered : [...STEM_NAMES]; +} + +function trackProfileKey(track) { + if (track?.profileKey && String(track.profileKey).includes("|device=")) return track.profileKey; + const quality = String(track?.qualityPreset || "standard").toLowerCase(); + const denoise = String(track?.stemDenoisePreset || "off").toLowerCase(); + const device = String(track?.demucsDevice || "auto").toLowerCase(); + const resolved = String(track?.demucsDeviceResolved || "auto").toLowerCase(); + const stems = trackProfileStems(track).join(","); + return `quality=${quality}|denoise=${denoise}|device=${device}:${resolved}|stems=${stems}`; +} + +function trackProfileLabel(track) { + if (track?.profileLabel && String(track?.profileKey || "").includes("|device=")) return track.profileLabel; + const qualityValue = String(track?.qualityPreset || "standard").toLowerCase(); + const denoiseValue = String(track?.stemDenoisePreset || "off").toLowerCase(); + const deviceValue = String(track?.demucsDevice || "auto").toLowerCase(); + const resolvedValue = String(track?.demucsDeviceResolved || "").toLowerCase(); + const stems = trackProfileStems(track); + const quality = QUALITY_LABELS[qualityValue] || track?.qualityPreset || "Standard"; + const denoise = DENOISE_LABELS[denoiseValue] || track?.stemDenoisePreset || "Noise off"; + const device = deviceValue === "auto" && resolvedValue + ? `Auto (${DEVICE_LABELS[resolvedValue] || resolvedValue.toUpperCase()})` + : (DEVICE_LABELS[deviceValue] || track?.demucsDevice || "Auto"); + let stemsLabel; + if (stems.length === STEM_NAMES.length) { + stemsLabel = "All 6-stem"; + } else if ( + stems.length === 4 + && ["vocals", "drums", "bass", "other"].every((name) => stems.includes(name)) + ) { + stemsLabel = "4-stem"; + } else { + stemsLabel = stems.map((name) => STEM_LABELS[name] || name).join("+"); + } + return `${quality} / ${denoise} / ${device} / ${stemsLabel}`; +} + function normalizeSearch(value) { return String(value || "").trim().toLowerCase(); } @@ -126,18 +209,20 @@ function trackMatchesSearch(track) { return [ track?.title, track?.channel, + trackProfileLabel(track), track?.sourceUrl, ...(track?.stems || []), ...(track?.tags || []), ].some((value) => String(value || "").toLowerCase().includes(q)); } -function findTrackBySource(sourceUrl, exceptId) { - const source = normalizeSource(sourceUrl); +function findTrackBySourceProfile(candidate, exceptId) { + const source = normalizeSource(candidate?.sourceUrl); if (!source) return null; + const profile = trackProfileKey(candidate); for (const [id, track] of Object.entries(tracks)) { if (id === exceptId) continue; - if (normalizeSource(track.sourceUrl) === source) return id; + if (normalizeSource(track.sourceUrl) === source && trackProfileKey(track) === profile) return id; } return null; } @@ -229,27 +314,45 @@ function saveState() { export function addTrackToLibrary(track) { // track: { id, title, channel, thumb, stems, status, sourceUrl } - const existingId = findTrackBySource(track.sourceUrl, track.id); + const normalizedTrack = { + ...track, + profileKey: track.profileKey || trackProfileKey(track), + profileLabel: track.profileLabel || trackProfileLabel(track), + }; + const existingId = findTrackBySourceProfile(normalizedTrack, normalizedTrack.id); if (existingId) { - const trash = getTrashFolder(); - const inTrash = trash?.items.includes(existingId); - if (inTrash) { - // Old track was trashed — delete it silently so the new import lands - // in the library instead of inheriting the trash placement. - delete tracks[existingId]; - for (const f of folders) f.items = f.items.filter((id) => id !== existingId); + const existingTrack = tracks[existingId]; + const incomingIsActive = PROCESSING_STATUSES.has(track.status); + const keepPlayableExisting = incomingIsActive && isTrackPlayable(existingTrack); + const keepCompletedDuplicate = + track.status === "done" + && isTrackPlayable(existingTrack) + && existingId !== track.id; + if (keepPlayableExisting || keepCompletedDuplicate) { + // Preserve the track the user can currently preview/export. The new + // extraction is added as a separate background job instead of replacing + // the playable entry mid-workflow. } else { - replaceTrackId(existingId, track.id); + const trash = getTrashFolder(); + const inTrash = trash?.items.includes(existingId); + if (inTrash) { + // Old track was trashed — delete it silently so the new import lands + // in the library instead of inheriting the trash placement. + delete tracks[existingId]; + for (const f of folders) f.items = f.items.filter((id) => id !== existingId); + } else { + replaceTrackId(existingId, track.id); + } } } - const existing = tracks[track.id] || {}; - tracks[track.id] = { + const existing = tracks[normalizedTrack.id] || {}; + tracks[normalizedTrack.id] = { ...existing, - ...track, - createdAt: existing.createdAt ?? track.createdAt ?? (Date.now() / 1000), + ...normalizedTrack, + createdAt: existing.createdAt ?? normalizedTrack.createdAt ?? (Date.now() / 1000), favorite: existing.favorite ?? false, }; - const alreadyPlaced = folders.some((folder) => folder.items.includes(track.id)); + const alreadyPlaced = folders.some((folder) => folder.items.includes(normalizedTrack.id)); if (!alreadyPlaced) { // Put into first non-trash folder or create an "Unsorted" folder. let target = folders.find((folder) => folder.id !== TRASH_ID); @@ -257,7 +360,7 @@ export function addTrackToLibrary(track) { target = makeFolder({ id: "f-unsorted", name: "Unsorted" }); folders.unshift(target); } - target.items.unshift(track.id); + target.items.unshift(normalizedTrack.id); } saveState(); render(); @@ -297,8 +400,18 @@ function stateMetadataToTrack(state, fallbackTrack) { stems: state.selected_stems || fallbackTrack.stems, selectedStems: state.selected_stems || fallbackTrack.selectedStems, audioStems: state.stems || fallbackTrack.audioStems || [], + qualityPreset: state.quality_preset || fallbackTrack.qualityPreset || "standard", + stemDenoisePreset: state.stem_denoise_preset || fallbackTrack.stemDenoisePreset || "off", + demucsDevice: state.demucs_device || fallbackTrack.demucsDevice || "auto", + demucsDeviceResolved: state.demucs_device_resolved || fallbackTrack.demucsDeviceResolved || "", + profileKey: state.profile_key || fallbackTrack.profileKey, + profileLabel: state.profile_label || fallbackTrack.profileLabel, duration: state.duration || fallbackTrack.duration, status: state.status || fallbackTrack.status, + progressPercent: state.progress_percent ?? fallbackTrack.progressPercent ?? null, + queuePosition: state.queue_position ?? fallbackTrack.queuePosition ?? null, + queueSize: state.queue_size ?? fallbackTrack.queueSize ?? 0, + stage: state.stage ?? fallbackTrack.stage ?? "", bpm: state.bpm ?? fallbackTrack.bpm, key: state.key ?? fallbackTrack.key, scale: state.scale ?? fallbackTrack.scale, @@ -306,8 +419,26 @@ function stateMetadataToTrack(state, fallbackTrack) { lufs: state.lufs ?? fallbackTrack.lufs, peakDb: state.peak_db ?? fallbackTrack.peakDb, stemPresence: state.stem_presence ?? fallbackTrack.stemPresence, + bassRepairApplied: state.bass_repair_applied ?? fallbackTrack.bassRepairApplied ?? false, + phaseRepairApplied: state.phase_repair_applied ?? fallbackTrack.phaseRepairApplied ?? false, + phaseRepairResidualRatio: state.phase_repair_residual_ratio + ?? fallbackTrack.phaseRepairResidualRatio + ?? null, + stemDenoiseApplied: state.stem_denoise_applied ?? fallbackTrack.stemDenoiseApplied ?? false, + stemGateApplied: state.stem_gate_applied ?? fallbackTrack.stemGateApplied ?? false, + stemGateThresholdDb: state.stem_gate_threshold_db ?? fallbackTrack.stemGateThresholdDb ?? null, + processingSeconds: state.processing_elapsed_seconds + ?? state.total_elapsed_seconds + ?? fallbackTrack.processingSeconds + ?? null, + processingStartedAt: state.processing_started_at ?? fallbackTrack.processingStartedAt ?? null, + timerStartedAt: state.timer_started_at ?? state.created_at ?? fallbackTrack.timerStartedAt ?? null, + completedAt: state.completed_at ?? fallbackTrack.completedAt ?? null, dynamicRange: state.dynamic_range ?? fallbackTrack.dynamicRange, tempoStability: state.tempo_stability ?? fallbackTrack.tempoStability, + beatTimes: state.beat_times ?? fallbackTrack.beatTimes ?? null, + chordProgression: state.chord_progression ?? fallbackTrack.chordProgression ?? null, + chordMidiUrl: state.chord_midi_url ?? fallbackTrack.chordMidiUrl ?? null, tags: state.tags ?? fallbackTrack.tags ?? [], sections: state.sections ?? fallbackTrack.sections ?? null, sourceUrl: state.source_url || fallbackTrack.sourceUrl, @@ -317,6 +448,27 @@ function stateMetadataToTrack(state, fallbackTrack) { }; } +function trackSubText(track, { inTrash = false } = {}) { + const profile = trackProfileLabel(track); + if (inTrash) return "Removed"; + if (track?.status === "unavailable") return [profile, "Audio unavailable"].filter(Boolean).join(" · "); + if (track?.status === "queued") { + if (track.queuePosition != null && track.queueSize) { + return [profile, `Queued #${track.queuePosition} of ${track.queueSize}`].filter(Boolean).join(" · "); + } + return [profile, "Queued"].filter(Boolean).join(" · "); + } + if (PROCESSING_STATUSES.has(track?.status)) { + const label = RUNNING_STATUS_LABELS[track.status] || "Processing"; + const progress = track.progressPercent != null ? `${label} ${track.progressPercent}%` : label; + const elapsed = track.processingSeconds != null ? fmtTime(Number(track.processingSeconds)) : ""; + return [profile, progress, elapsed].filter(Boolean).join(" · "); + } + const duration = track.duration ? fmtTime(track.duration) : ""; + const stemCount = track.stems?.length ?? 0; + return [profile, duration, `${stemCount} stem${stemCount !== 1 ? "s" : ""}`].filter(Boolean).join(" · "); +} + function fmtExtracted(ts) { if (!ts) return "—"; return new Date(ts * 1000).toLocaleString("en-US", { @@ -346,6 +498,29 @@ function deriveQuality(sourceUrl) { return "—"; } +function deriveRepair(track) { + const repairs = []; + if (track?.bassRepairApplied) repairs.push("Bass"); + if (track?.phaseRepairApplied) { + const ratio = Number(track.phaseRepairResidualRatio); + if (Number.isFinite(ratio)) { + const improved = Math.max(0, Math.min(99, Math.round((1 - ratio) * 100))); + repairs.push(`Phase ${improved}% better`); + } else { + repairs.push("Phase"); + } + } + if (track?.stemDenoiseApplied) { + const preset = track.stemDenoisePreset === "strong" ? "Strong" : "Light"; + repairs.push(`${preset} denoise`); + } + if (track?.stemGateApplied) { + const threshold = Number(track.stemGateThresholdDb); + repairs.push(Number.isFinite(threshold) ? `Gate ${threshold} dB` : "Gate"); + } + return repairs.length ? repairs.join(" + ") : "—"; +} + function drLabel(dr) { if (dr < 7) return "Compressed"; if (dr < 10) return "Moderate"; @@ -385,6 +560,7 @@ function applyTrackInfoToPanel(track) { thumbnail: track.thumb, key: track.key, bpm: track.bpm, + profileLabel: trackProfileLabel(track), stemCount: (track.audioStems || track.stems || []).filter((s) => (s.name ?? s) !== "original").length || null, }); applyStemPresenceCards(track.stemPresence); @@ -408,12 +584,24 @@ function applyTrackInfoToPanel(track) { if (summaryDuration) summaryDuration.textContent = track.duration ? fmtTime(track.duration) : "—"; const trackExtracted = document.getElementById("track-extracted"); + const trackProcessed = document.getElementById("track-processed"); + const trackProfile = document.getElementById("track-profile"); const trackSource = document.getElementById("track-source"); const trackQuality = document.getElementById("track-quality"); + const trackRepair = document.getElementById("track-repair"); + const trackBeats = document.getElementById("track-beats"); const favBtn = document.getElementById("fav-btn"); - if (trackExtracted) trackExtracted.textContent = fmtExtracted(track.createdAt); + if (trackExtracted) trackExtracted.textContent = fmtExtracted(track.completedAt ?? track.createdAt); + if (trackProcessed) { + trackProcessed.textContent = track.processingSeconds != null + ? fmtTime(Number(track.processingSeconds)) + : "—"; + } + if (trackProfile) trackProfile.textContent = trackProfileLabel(track); if (trackSource) trackSource.textContent = deriveSource(track.sourceUrl); if (trackQuality) trackQuality.textContent = deriveQuality(track.sourceUrl); + if (trackRepair) trackRepair.textContent = deriveRepair(track); + if (trackBeats) trackBeats.textContent = fmtBeatGrid(track.beatTimes, track.duration); if (favBtn) { favBtn.classList.toggle("active", Boolean(track.favorite)); favBtn.setAttribute("aria-pressed", String(Boolean(track.favorite))); @@ -530,7 +718,10 @@ async function loadTrackIntoStudio(trackId) { // A reprocessing track may still carry the previous extraction's stems // (hadStoredAudio), but it isn't ready — loading it would replace the live // job-progress overlay with stale audio. Leave the progress UI in place. - if (PROCESSING_STATUSES.has(track.status)) return; + if (PROCESSING_STATUSES.has(track.status)) { + showJobProgress(trackId); + return; + } if (!track.audioStems?.length) return; if (track.status !== "done" && !hadStoredAudio) return; applyStoredStemSelection(track); @@ -544,7 +735,19 @@ async function loadTrackIntoStudio(trackId) { } applyTrackInfoToPanel(track); - wireUpAudio(trackId, track.audioStems, track.duration || 0, track.thumb, track.mixUrl ?? null, track.title || "", peaksPromise); + wireUpAudio( + trackId, + track.audioStems, + track.duration || 0, + track.thumb, + track.mixUrl ?? null, + track.title || "", + peaksPromise, + trackProfileLabel(track), + trackProfileKey(track), + track.beatTimes || [], + track.chordMidiUrl || null, + ); initSections(trackId, track.sections, track.duration || 0); } @@ -556,6 +759,14 @@ export function setCurrentTrack(trackId) { for (const el of document.querySelectorAll(`.strip-thumb[data-id="${trackId}"]`)) el.classList.add("active"); } +export function getCurrentTrack() { + return _currentTrackId ? tracks[_currentTrackId] || null : null; +} + +export function isTrackPlayable(track) { + return track?.status === "done" && Boolean(track?.audioStems?.length); +} + // ─── Folder operations ─── function createFolder() { @@ -937,9 +1148,7 @@ function renderRecentItem(trackId) { const isUnavailable = track.status === "unavailable"; el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}`; el.dataset.id = trackId; - const duration = track.duration ? fmtTime(track.duration) : ""; - const stemCount = track.stems?.length ?? 0; - const sub = [duration, `${stemCount} stem${stemCount !== 1 ? "s" : ""}`].filter(Boolean).join(" · "); + const sub = trackSubText(track); el.innerHTML = `
${thumbHtml(track)}
@@ -986,15 +1195,16 @@ function renderTrackItem(trackId, { inTrash = false } = {}) { el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}`; el.dataset.id = trackId; - const stemCount = track.stems?.length ?? 0; + const sub = trackSubText(track, { inTrash }); + const channel = isUnavailable ? "Unavailable" : track.channel ?? ""; el.innerHTML = `
${thumbHtml(track)}
${esc(track.title ?? "Unknown track")}
- ${esc(track.channel ?? "")} + ${esc(channel)} · - ${inTrash ? "Removed" : `${stemCount} stem${stemCount !== 1 ? "s" : ""}`} + ${esc(sub)}
@@ -1500,9 +1710,11 @@ function wireWidgets() { const FALLBACK_VERSION = "0.1.0"; let currentVersion = FALLBACK_VERSION; -const REPO_URL = "https://github.com/stemdeckapp/stemdeck"; -const RELEASES_URL = "https://github.com/stemdeckapp/stemdeck/releases"; -const RELEASES_API = "https://api.github.com/repos/stemdeckapp/stemdeck/releases/latest"; +const REPO_URL = "https://github.com/bassmicrobe/stemdeck"; +const RELEASES_URL = "https://github.com/bassmicrobe/stemdeck/releases"; +// GitHub's /releases/latest endpoint ignores pre-releases. Enhanced test builds +// are intentionally published as pre-releases, so read the release list instead. +const RELEASES_API = "https://api.github.com/repos/bassmicrobe/stemdeck/releases?per_page=10"; const DISMISSED_UPDATE_KEY = "stemdeck.dismissed_update"; function normalizeVersion(value) { @@ -1545,7 +1757,8 @@ async function checkForUpdate() { const res = await fetch(RELEASES_API, { headers: { Accept: "application/vnd.github+json" } }); if (!res.ok) return; const data = await res.json(); - const latest = normalizeVersion(data.tag_name); + const latestRelease = Array.isArray(data) ? data.find((release) => release?.tag_name) : data; + const latest = normalizeVersion(latestRelease?.tag_name); // Compare canonically so a PEP440 current version (0.7.0a9) matches the // release tag form (0.7.0-alpha.9) and we don't nag an already-current app. if (!latest || canonicalVersion(latest) === canonicalVersion(currentVersion)) return; @@ -1690,6 +1903,48 @@ async function syncWithServer() { } catch (e) { console.warn("[catalog] failed to load jobs from backend:", e); } } +async function syncActiveWithServer() { + try { + const res = await fetch("/api/jobs/active", { cache: "no-store" }); + if (!res.ok) return; + const activeStates = await res.json(); + const activeIds = new Set(activeStates.map((state) => state.job_id)); + for (const state of activeStates) { + const track = stateMetadataToTrack(state, tracks[state.job_id] || { id: state.job_id }); + track.id = state.job_id; + addTrackToLibrary(track); + } + + let changed = false; + for (const [trackId, track] of Object.entries(tracks)) { + if (!PROCESSING_STATUSES.has(track.status) || activeIds.has(trackId)) continue; + try { + const probe = await fetch(`/api/jobs/${trackId}`, { cache: "no-store" }); + if (probe.ok) { + tracks[trackId] = stateMetadataToTrack(await probe.json(), track); + changed = true; + } else if (probe.status === 404) { + tracks[trackId] = { + ...track, + channel: "Unavailable", + status: "unavailable", + progressPercent: null, + queuePosition: null, + queueSize: 0, + }; + changed = true; + } + } catch { + // Keep the local state if the server is temporarily unreachable. + } + } + if (changed) { + saveState(); + render(); + } + } catch (e) { console.warn("[catalog] failed to sync active jobs:", e); } +} + // ─── Settings menu + Library editor ─── let libraryEditor = null; @@ -1889,6 +2144,9 @@ async function resyncLibrary() { const jobId = await importFromUrl(t.sourceUrl, { title: t.title, stems: t.selectedStems, + quality: t.qualityPreset, + denoise: t.stemDenoisePreset, + device: t.demucsDevice, }); if (jobId) await waitForJobTerminal(jobId); } @@ -1934,4 +2192,5 @@ export async function initCatalog() { loadCurrentVersion().finally(checkForUpdate); syncWithServer(); + syncActiveWithServer(); } diff --git a/static/js/constants.js b/static/js/constants.js index 46000cd..651ec20 100644 --- a/static/js/constants.js +++ b/static/js/constants.js @@ -3,6 +3,12 @@ export let STEM_NAMES = ["vocals", "drums", "bass", "guitar", "piano", "other"]; export let TRACK_NAMES = ["original", ...STEM_NAMES]; +const FOUR_STEM_NAMES = ["vocals", "drums", "bass", "other"]; + +export function supportedStemNamesForQuality(qualityPreset = "standard") { + return ["high", "max", "ultra"].includes(qualityPreset) ? FOUR_STEM_NAMES : STEM_NAMES; +} + export async function syncStemNamesFromAPI() { try { const res = await fetch("/api/config"); @@ -44,4 +50,4 @@ export const PROGRESS_COLOR = "#3a3a3a"; export const LOOP_DEFAULT_START_FRAC = 0.25; export const LOOP_DEFAULT_END_FRAC = 0.5; -export const LANE_VOLUME_MAX = 2; \ No newline at end of file +export const LANE_VOLUME_MAX = 2; diff --git a/static/js/job.js b/static/js/job.js index 2733ab2..27d8f7d 100644 --- a/static/js/job.js +++ b/static/js/job.js @@ -1,13 +1,23 @@ import { form, urlInput, submitBtn, errorEl, jobBox, jobTitleEl, jobStageEl, - jobDetailEl, jobCancelBtn, progressEl, titleEl, bpmChip, keyChip, + jobDetailEl, jobEtaEl, jobPercentEl, jobCancelBtn, progressEl, titleEl, bpmChip, keyChip, eventSource, setEventSource, setCurrentJobId, currentJobId, - selectedStems, + demucsDevicePreset, effectiveSelectedStems, qualityPreset, selectedStems, stemDenoisePreset, } from "./state.js"; import { destroyPlayer, wireUpAudio, setWaveformLoading, updateFooterTrack } from "./player.js"; import { stagePhrases } from "./phrases.js"; -import { addTrackToLibrary, setCurrentTrack, updateTrackStatus, applyStemPresenceCards } from "./catalog.js"; +import { + addTrackToLibrary, + applyStemPresenceCards, + getCurrentTrack, + isTrackPlayable, + setCurrentTrack, + updateTrackStatus, +} from "./catalog.js"; import { initSections } from "./sections.js"; +import { supportedStemNamesForQuality } from "./constants.js"; +import { fmtBeatGrid } from "./utils.js"; +import { openLogViewer } from "./logs.js"; // Playful stage label rotation (Claude-Code-style flair). The backend // emits truthful stage strings; we surface them in the small #job-detail @@ -16,11 +26,26 @@ const ROTATION_MS = 2500; let phraseTimerId = null; let lastStatus = null; let jobPollTimerId = null; +let activeJobSyncTimerId = null; +let jobTimerId = null; +let jobTimerState = null; +let monitoredJobId = null; +let monitoredJobOwnsStudio = false; const renderedJobs = new Set(); const jobSources = new Map(); +const activeJobIds = new Set(); +let activeJobSyncFailures = 0; const TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]); +function normalizeStemsForQuality(stems, preset) { + const allowed = supportedStemNamesForQuality(preset); + const selected = (stems?.length ? stems : [...selectedStems]).filter((name) => + allowed.includes(name) + ); + return selected.length > 0 ? selected : [...allowed]; +} + function setSubmitProcessing(processing) { submitBtn.disabled = processing; submitBtn.classList.toggle("loading", processing); @@ -29,6 +54,145 @@ function setSubmitProcessing(processing) { if (label) label.textContent = processing ? "Processing" : "Process"; } +function setJobConnectionStatus(message = "", tone = "info") { + let el = jobBox.querySelector(".job-connection"); + if (!message) { + el?.remove(); + return; + } + if (!el) { + el = document.createElement("div"); + el.className = "job-connection"; + jobBox.insertBefore(el, jobBox.querySelector(".job-progress-row") || jobCancelBtn); + } + el.className = `job-connection ${tone}`; + el.textContent = message; +} + +function shouldForegroundNewJob() { + if (!jobBox.classList.contains("hidden")) return false; + return !isTrackPlayable(getCurrentTrack()); +} + +function showJobMonitor(jobId, status = "queued", { ownStudio = false } = {}) { + monitoredJobId = jobId; + monitoredJobOwnsStudio = ownStudio; + if (ownStudio && !isTrackPlayable(getCurrentTrack())) setCurrentTrack(jobId); + jobBox.classList.remove("hidden"); + jobCancelBtn.classList.remove("hidden"); + startPhraseRotation(status); + lastStatus = status; + connectEvents(jobId); +} + +export function showJobProgress(jobId, { ownStudio = false } = {}) { + if (!jobId) return; + showJobMonitor(jobId, "queued", { ownStudio }); + probeJob(jobId).catch((err) => { + console.warn("[job] could not open job monitor:", err); + }); +} + +function formatClock(seconds) { + const n = Math.max(0, Math.round(Number(seconds) || 0)); + const h = Math.floor(n / 3600); + const m = Math.floor((n % 3600) / 60); + const s = n % 60; + if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + return `${m}:${String(s).padStart(2, "0")}`; +} + +function epochMs(seconds) { + const n = Number(seconds); + return Number.isFinite(n) && n > 0 ? n * 1000 : null; +} + +function timerFromState(state, pct = null) { + const localNow = Date.now(); + const serverNow = epochMs(state.server_time) ?? localNow; + return { + jobId: state.job_id, + status: state.status, + progressPercent: pct ?? Math.round(state.progress_percent ?? ((state.progress || 0) * 100)), + timerStartedAtMs: epochMs(state.timer_started_at ?? state.created_at), + processingStartedAtMs: epochMs(state.processing_started_at), + completedAtMs: epochMs(state.completed_at), + serverOffsetMs: serverNow - localNow, + etaSeconds: state.eta_seconds, + etaSampledAtMs: serverNow, + queuePosition: state.queue_position, + queueSize: state.queue_size, + }; +} + +function serverNowMs(timer) { + return Date.now() + (timer?.serverOffsetMs ?? 0); +} + +function elapsedFrom(timer, startedAtMs) { + if (!startedAtMs) return null; + const endedAtMs = timer.completedAtMs ?? serverNowMs(timer); + return Math.max(0, (endedAtMs - startedAtMs) / 1000); +} + +function etaFrom(timer) { + if (timer.etaSeconds == null) return null; + const elapsedSinceSample = Math.max(0, (serverNowMs(timer) - timer.etaSampledAtMs) / 1000); + return Math.max(0, Number(timer.etaSeconds) - elapsedSinceSample); +} + +function timerLabel(timer) { + if (!timer || TERMINAL_STATUSES.has(timer.status)) return ""; + if (timer.status === "queued") { + const waiting = elapsedFrom(timer, timer.timerStartedAtMs); + const waitingLabel = waiting != null ? `Waiting ${formatClock(waiting)}` : "Waiting"; + if (timer.queuePosition != null) { + const queueLabel = `Queue #${timer.queuePosition}${timer.queueSize ? ` of ${timer.queueSize}` : ""}`; + return [queueLabel, waitingLabel].filter(Boolean).join(" · "); + } + return waitingLabel; + } + + const startedAtMs = timer.processingStartedAtMs ?? timer.timerStartedAtMs; + const elapsed = elapsedFrom(timer, startedAtMs); + const elapsedLabel = elapsed != null ? `Elapsed ${formatClock(elapsed)}` : ""; + const eta = etaFrom(timer); + if (eta != null) { + return [elapsedLabel, `ETA ${formatClock(eta)}`].filter(Boolean).join(" · "); + } + if (timer.status === "separating" && timer.progressPercent > 0 && timer.progressPercent < 100) { + return [elapsedLabel, "ETA estimating..."].filter(Boolean).join(" · "); + } + return elapsedLabel; +} + +function etaLabel(state, pct) { + return timerLabel(timerFromState(state, pct)); +} + +function renderJobTimer() { + if (!jobTimerState) return; + jobEtaEl.textContent = timerLabel(jobTimerState); +} + +function syncJobTimer(state, pct) { + if (!state?.job_id || TERMINAL_STATUSES.has(state.status)) { + stopJobTimer(); + return; + } + jobTimerState = timerFromState(state, pct); + renderJobTimer(); + if (!jobTimerId) jobTimerId = setInterval(renderJobTimer, 1000); +} + +function stopJobTimer() { + if (jobTimerId) { + clearInterval(jobTimerId); + jobTimerId = null; + } + jobTimerState = null; +} + function pickPhrase(status) { const pool = stagePhrases[status] || stagePhrases.default; return pool[Math.floor(Math.random() * pool.length)]; @@ -90,8 +254,11 @@ export function reset() { setEventSource(null); } stopJobPolling(); + stopJobTimer(); stopPhraseRotation(); lastStatus = null; + monitoredJobId = null; + monitoredJobOwnsStudio = false; destroyPlayer(); errorEl.classList.add("hidden"); errorEl.textContent = ""; @@ -100,22 +267,42 @@ export function reset() { jobTitleEl.textContent = ""; jobStageEl.textContent = ""; jobDetailEl.textContent = ""; + jobEtaEl.textContent = ""; + jobPercentEl.textContent = "0%"; progressEl.value = 0; + setJobConnectionStatus(""); setSubmitProcessing(false); setCurrentJobId(null); } -function applyState(state) { +function channelForStatus(state) { + if (state.status === "done") return "Extracted"; + if (state.status === "queued") return "Queued"; + return "Processing"; +} + +function applyState(state, { focus = true, ownStudio = monitoredJobOwnsStudio } = {}) { + const currentTrack = getCurrentTrack(); + const ownsStudio = focus && ownStudio && ( + currentJobId === state.job_id + || !isTrackPlayable(currentTrack) + || currentTrack?.id === state.job_id + ); + if (state.job_id) { addTrackToLibrary({ id: state.job_id, - title: state.title || urlInput.value || "Processing track", - channel: state.status === "done" ? "Extracted" : "Processing", + title: state.title || state.source_url || urlInput.value || "Processing track", + channel: channelForStatus(state), thumb: state.thumbnail, stems: state.selected_stems || state.stems?.map((stem) => stem.name) || [...selectedStems], selectedStems: state.selected_stems || [...selectedStems], audioStems: state.stems || [], status: state.status, + progressPercent: state.progress_percent ?? null, + queuePosition: state.queue_position ?? null, + queueSize: state.queue_size ?? 0, + stage: state.stage || "", duration: state.duration, bpm: state.bpm, key: state.key, @@ -123,26 +310,56 @@ function applyState(state) { keyConfidence: state.key_confidence, lufs: state.lufs, peakDb: state.peak_db, + dynamicRange: state.dynamic_range, + tempoStability: state.tempo_stability, + beatTimes: state.beat_times ?? null, + chordProgression: state.chord_progression ?? null, + chordMidiUrl: state.chord_midi_url ?? null, stemPresence: state.stem_presence, - sourceUrl: jobSources.get(state.job_id) || urlInput.value, + bassRepairApplied: state.bass_repair_applied ?? false, + phaseRepairApplied: state.phase_repair_applied ?? false, + phaseRepairResidualRatio: state.phase_repair_residual_ratio ?? null, + stemDenoisePreset: state.stem_denoise_preset || "off", + demucsDevice: state.demucs_device || "auto", + demucsDeviceResolved: state.demucs_device_resolved || "", + stemDenoiseApplied: state.stem_denoise_applied ?? false, + stemGateApplied: state.stem_gate_applied ?? false, + stemGateThresholdDb: state.stem_gate_threshold_db ?? null, + profileKey: state.profile_key, + profileLabel: state.profile_label, + processingSeconds: state.processing_elapsed_seconds ?? state.total_elapsed_seconds ?? null, + processingStartedAt: state.processing_started_at ?? null, + timerStartedAt: state.timer_started_at ?? state.created_at ?? null, + completedAt: state.completed_at ?? null, + sourceUrl: jobSources.get(state.job_id) || state.source_url || urlInput.value, createdAt: state.created_at, }); - setCurrentTrack(state.job_id); + if (focus && !isTrackPlayable(getCurrentTrack())) setCurrentTrack(state.job_id); + } + const terminal = TERMINAL_STATUSES.has(state.status); + if (terminal) activeJobIds.delete(state.job_id); + + if (!focus) { + if (state.status === "done") updateTrackStatus(state.job_id, "done"); + else if (state.status === "error") updateTrackStatus(state.job_id, "error"); + else if (state.status === "cancelled") updateTrackStatus(state.job_id, "cancelled"); + return; } if (state.title) { jobTitleEl.textContent = state.title; - titleEl.textContent = state.title; + if (ownsStudio) titleEl.textContent = state.title; } - if (state.bpm) bpmChip.textContent = `${state.bpm} BPM`; - if (state.key) keyChip.textContent = state.key; - if (state.title || state.bpm || state.key || state.thumbnail) { - updateFooterTrack({ - title: state.title, - thumbnail: state.thumbnail, - key: state.key, - bpm: state.bpm, - stemCount: state.stems ? state.stems.filter((s) => s.name !== "original").length : null, - }); + if (ownsStudio && state.bpm) bpmChip.textContent = `${state.bpm} BPM`; + if (ownsStudio && state.key) keyChip.textContent = state.key; + if (ownsStudio && (state.title || state.bpm || state.key || state.thumbnail)) { + updateFooterTrack({ + title: state.title, + thumbnail: state.thumbnail, + key: state.key, + bpm: state.bpm, + profileLabel: state.profile_label, + stemCount: state.stems ? state.stems.filter((s) => s.name !== "original").length : null, + }); } const summaryKey = document.getElementById("summary-key"); const summaryBpm = document.getElementById("summary-bpm"); @@ -153,18 +370,27 @@ function applyState(state) { const summaryLufs = document.getElementById("summary-lufs"); const summaryPeak = document.getElementById("summary-peak"); const summaryDuration = document.getElementById("summary-duration"); - if (summaryKey && state.key) summaryKey.textContent = state.key; - if (summaryBpm && state.bpm) summaryBpm.textContent = String(state.bpm); - if (summaryScale && state.scale) summaryScale.textContent = state.scale; - if (summaryScaleName && state.scale) summaryScaleName.textContent = state.scale; - if (summaryLufs && state.lufs != null) summaryLufs.textContent = state.lufs.toFixed(1); - if (summaryPeak && state.peak_db != null) summaryPeak.textContent = `Peak ${state.peak_db.toFixed(1)} dB`; - if (summaryDuration && state.duration) { + const trackProcessed = document.getElementById("track-processed"); + const trackBeats = document.getElementById("track-beats"); + if (ownsStudio && summaryKey && state.key) summaryKey.textContent = state.key; + if (ownsStudio && summaryBpm && state.bpm) summaryBpm.textContent = String(state.bpm); + if (ownsStudio && summaryScale && state.scale) summaryScale.textContent = state.scale; + if (ownsStudio && summaryScaleName && state.scale) summaryScaleName.textContent = state.scale; + if (ownsStudio && summaryLufs && state.lufs != null) summaryLufs.textContent = state.lufs.toFixed(1); + if (ownsStudio && summaryPeak && state.peak_db != null) summaryPeak.textContent = `Peak ${state.peak_db.toFixed(1)} dB`; + if (ownsStudio && summaryDuration && state.duration) { const m = Math.floor(state.duration / 60); const s = Math.floor(state.duration % 60).toString().padStart(2, "0"); summaryDuration.textContent = `${m.toString().padStart(2, "0")}:${s}`; } - if (summaryConfidence && state.key_confidence != null) { + if (ownsStudio && trackProcessed) { + const elapsed = state.processing_elapsed_seconds ?? state.total_elapsed_seconds; + trackProcessed.textContent = elapsed != null ? formatClock(elapsed) : "—"; + } + if (ownsStudio && trackBeats) { + trackBeats.textContent = fmtBeatGrid(state.beat_times, state.duration); + } + if (ownsStudio && summaryConfidence && state.key_confidence != null) { const confidence = Math.max(0, Math.min(100, Number(state.key_confidence))); const confSpan = document.createElement("span"); confSpan.textContent = `${confidence}%`; @@ -178,30 +404,33 @@ function applyState(state) { const summaryDrLabel = document.getElementById("summary-dr-label"); const summaryStability = document.getElementById("summary-stability"); const summaryStabilityLabel = document.getElementById("summary-stability-label"); - if (summaryDr && state.dynamic_range != null) summaryDr.textContent = String(state.dynamic_range); - if (summaryDrLabel && state.dynamic_range != null) { + if (ownsStudio && summaryDr && state.dynamic_range != null) summaryDr.textContent = String(state.dynamic_range); + if (ownsStudio && summaryDrLabel && state.dynamic_range != null) { const dr = state.dynamic_range; summaryDrLabel.textContent = dr < 7 ? "Compressed" : dr < 10 ? "Moderate" : dr < 14 ? "High" : "Wide"; } - if (summaryStability && state.tempo_stability != null) { + if (ownsStudio && summaryStability && state.tempo_stability != null) { summaryStability.textContent = `${state.tempo_stability}%`; summaryStability.className = "meta-card-value" + (state.tempo_stability >= 80 ? " stability-high" : ""); } - if (summaryStabilityLabel && state.tempo_stability != null) { + if (ownsStudio && summaryStabilityLabel && state.tempo_stability != null) { const s = state.tempo_stability; summaryStabilityLabel.textContent = s >= 90 ? "Very Stable" : s >= 70 ? "Stable" : s >= 50 ? "Moderate" : "Variable"; } - if (state.stem_presence != null) { + if (ownsStudio && state.stem_presence != null) { applyStemPresenceCards(state.stem_presence); } // Stage label is owned by the phrase-rotation timer below; we don't // overwrite it from each SSE tick. The truthful backend stage goes // to the small detail line instead. jobDetailEl.textContent = state.stage || ""; - progressEl.value = Math.round((state.progress || 0) * 100); + const pct = Math.max(0, Math.min(100, Math.round(state.progress_percent ?? ((state.progress || 0) * 100)))); + progressEl.value = pct; + jobPercentEl.textContent = `${pct}%`; + jobEtaEl.textContent = etaLabel(state, pct); + syncJobTimer(state, pct); // Cancel button is visible exactly while the job is in a non-terminal state. - const terminal = TERMINAL_STATUSES.has(state.status); jobCancelBtn.classList.toggle("hidden", terminal); if (state.status !== lastStatus) { @@ -212,21 +441,32 @@ function applyState(state) { if (state.status === "error") { stopJobPolling(); + stopJobTimer(); updateTrackStatus(state.job_id, "error"); - setWaveformLoading(false); + if (ownsStudio) setWaveformLoading(false); showError(state.error || "Unknown error"); setSubmitProcessing(false); } else if (state.status === "cancelled") { stopJobPolling(); + stopJobTimer(); updateTrackStatus(state.job_id, "cancelled"); - setWaveformLoading(false); - jobBox.classList.add("hidden"); + if (ownsStudio) setWaveformLoading(false); + if (monitoredJobId === state.job_id) { + monitoredJobId = null; + monitoredJobOwnsStudio = false; + jobBox.classList.add("hidden"); + } setSubmitProcessing(false); } else if (state.status === "done") { stopJobPolling(); + stopJobTimer(); updateTrackStatus(state.job_id, "done"); - jobBox.classList.add("hidden"); - if (!renderedJobs.has(state.job_id)) { + if (monitoredJobId === state.job_id) { + monitoredJobId = null; + monitoredJobOwnsStudio = false; + jobBox.classList.add("hidden"); + } + if (ownsStudio && !renderedJobs.has(state.job_id)) { renderedJobs.add(state.job_id); wireUpAudio( state.job_id, @@ -235,6 +475,11 @@ function applyState(state) { state.thumbnail, state.mix_url ?? null, state.title || "", + null, + state.profile_label || "", + state.profile_key || "", + state.beat_times || [], + state.chord_midi_url || null, ); initSections(state.job_id, state.sections, state.duration || 0); } @@ -242,14 +487,14 @@ function applyState(state) { } } -async function probeJob(jobId) { +async function probeJob(jobId, options = {}) { const r = await fetch(`/api/jobs/${jobId}`); if (!r.ok) { if (r.status === 404) throw new Error("Job no longer exists on the server"); throw new Error(`Job probe failed: ${r.status}`); } const s = await r.json(); - applyState(s); + applyState(s, options); return s; } @@ -267,6 +512,42 @@ function startJobPolling(jobId) { jobPollTimerId = setInterval(tick, 1000); } +async function syncActiveJobs() { + try { + const res = await fetch("/api/jobs/active", { cache: "no-store" }); + if (!res.ok) return; + activeJobSyncFailures = 0; + setJobConnectionStatus(""); + const states = await res.json(); + const nextIds = new Set(states.map((state) => state.job_id)); + for (const state of states) { + activeJobIds.add(state.job_id); + applyState(state, { focus: false }); + } + for (const id of [...activeJobIds]) { + if (nextIds.has(id)) continue; + activeJobIds.delete(id); + try { + await probeJob(id, { focus: false }); + } catch { + /* job may have been swept or deleted */ + } + } + } catch (err) { + activeJobSyncFailures += 1; + if (activeJobSyncFailures >= 2) { + setJobConnectionStatus("Reconnecting to background queue...", "warn"); + } + console.warn("[job] active queue sync failed:", err); + } +} + +function startActiveJobSync() { + if (activeJobSyncTimerId) return; + syncActiveJobs(); + activeJobSyncTimerId = setInterval(syncActiveJobs, 2500); +} + // Connect (or reconnect) to the SSE stream for a job. On unexpected // disconnect we probe /api/jobs/{id} to decide: if the job is already // terminal, accept its final state; otherwise reconnect with backoff. @@ -276,17 +557,29 @@ function connectEvents(jobId) { let stopped = false; const open = () => { + if (monitoredJobId !== jobId) return; + if (eventSource) { + eventSource.close(); + setEventSource(null); + } const es = new EventSource(`/api/jobs/${jobId}/events`); setEventSource(es); + es.onopen = () => { + attempt = 0; + setJobConnectionStatus(""); + }; + es.onmessage = (ev) => { attempt = 0; // any successful frame resets backoff + setJobConnectionStatus(""); let s; try { s = JSON.parse(ev.data); } catch { return; } // Defer by one tick so synchronous user event handlers (clicks, // input events) always complete before SSE state is applied. setTimeout(() => { - applyState(s); + if (monitoredJobId !== jobId) return; + applyState(s, { ownStudio: monitoredJobOwnsStudio }); if (TERMINAL_STATUSES.has(s.status)) { stopped = true; es.close(); @@ -297,13 +590,18 @@ function connectEvents(jobId) { es.onerror = async () => { if (stopped) return; + if (monitoredJobId !== jobId) { + stopped = true; + es.close(); + return; + } es.close(); setEventSource(null); // Probe REST once before declaring failure -- handles dev-server // reloads and brief network blips where the job is actually fine. try { - const s = await probeJob(jobId); + const s = await probeJob(jobId, { ownStudio: monitoredJobOwnsStudio }); if (TERMINAL_STATUSES.has(s.status)) { stopped = true; return; @@ -321,11 +619,13 @@ function connectEvents(jobId) { attempt += 1; if (attempt > 6) { // SSE gave up — activate REST polling as the fallback. + setJobConnectionStatus("Live updates interrupted. Polling job status...", "warn"); startJobPolling(jobId); return; } // 0.5s, 1s, 2s, 4s, 8s, 16s const delay = 500 * Math.pow(2, attempt - 1); + setJobConnectionStatus(`Connection interrupted. Retrying in ${Math.round(delay / 1000)}s...`, "warn"); setTimeout(() => { if (!stopped) open(); }, delay); }; }; @@ -334,7 +634,7 @@ function connectEvents(jobId) { } async function cancelCurrentJob() { - const id = currentJobId; + const id = monitoredJobId; if (!id) return; jobCancelBtn.disabled = true; jobCancelBtn.textContent = "Cancelling…"; @@ -360,70 +660,103 @@ function sanitizeFilename(name) { .slice(0, 120); } -// Programmatic URL import — re-uses the full studio/SSE pipeline (same as the -// import form's URL path). Used by the library "Sync again" auto-restore to -// re-download + re-separate a track whose backend audio was swept. Takes over -// the studio like a normal import. Returns the new job id, or null on failure. -export async function importFromUrl(url, { title, stems } = {}) { +// Programmatic URL import. Used by the library "Sync again" auto-restore to +// re-download + re-separate a track whose backend audio was swept. If a +// playable track is already selected, the restore runs in the background. +export async function importFromUrl(url, { title, stems, quality, denoise, device } = {}) { if (!url || url.startsWith("local:")) return null; // local files can't auto-restore - reset(); + const foreground = shouldForegroundNewJob(); + if (foreground) { + reset(); + setWaveformLoading(true, ""); + } else { + errorEl.classList.add("hidden"); + } setSubmitProcessing(true); - setWaveformLoading(true, ""); - const stemSel = stems?.length ? stems : [...selectedStems]; + const preset = quality || qualityPreset; + const denoisePreset = denoise || stemDenoisePreset; + const devicePreset = device || demucsDevicePreset; + const stemSel = normalizeStemsForQuality(stems, preset); let jobId; try { const res = await fetch("/api/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url, stems: stemSel }), + body: JSON.stringify({ + url, + stems: stemSel, + quality_preset: preset, + stem_denoise: denoisePreset, + demucs_device: devicePreset, + }), }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || res.statusText); jobId = data.job_id; } catch (err) { + if (foreground) setWaveformLoading(false); showError(`Failed to restore track: ${err.message}`); setSubmitProcessing(false); return null; } - setCurrentJobId(jobId); jobSources.set(jobId, url); + activeJobIds.add(jobId); // Merges into the existing library entry by sourceUrl (replaceTrackId), // preserving its folder placement; status updates as SSE frames arrive. addTrackToLibrary({ id: jobId, title: title || url || "Processing track", - channel: "Processing", + channel: "Queued", thumb: "", stems: stemSel, selectedStems: stemSel, + qualityPreset: preset, + stemDenoisePreset: denoisePreset, + demucsDevice: devicePreset, + demucsDeviceResolved: "", audioStems: [], - status: "processing", + status: "queued", + progressPercent: 0, + queuePosition: null, + queueSize: 0, bpm: null, key: null, scale: null, keyConfidence: null, lufs: null, peakDb: null, + bassRepairApplied: false, + phaseRepairApplied: false, + phaseRepairResidualRatio: null, + stemDenoiseApplied: false, + stemGateApplied: false, + stemGateThresholdDb: null, + processingStartedAt: null, + timerStartedAt: null, + processingSeconds: null, + completedAt: null, sourceUrl: url, }); - setCurrentTrack(jobId); - - jobBox.classList.add("hidden"); - jobCancelBtn.classList.add("hidden"); - startPhraseRotation("queued"); - lastStatus = "queued"; - connectEvents(jobId); + showJobMonitor(jobId, "queued", { ownStudio: foreground }); + syncActiveJobs(); + setSubmitProcessing(false); return jobId; } export function wireJobForm() { jobCancelBtn.addEventListener("click", cancelCurrentJob); + document.getElementById("job-logs")?.addEventListener("click", () => { + openLogViewer(monitoredJobId); + }); + startActiveJobSync(); form.addEventListener("submit", async (e) => { e.preventDefault(); - reset(); + const foreground = shouldForegroundNewJob(); + if (foreground) reset(); + else errorEl.classList.add("hidden"); setSubmitProcessing(true); const fileInput = document.getElementById("fileInput"); @@ -433,14 +766,19 @@ export function wireJobForm() { const sanitized = file ? sanitizeFilename(file.name) : null; const sourceUrl = file ? `local:${sanitized}` : urlInput.value; const displayTitle = sanitized ?? (urlInput.value || "Processing track"); + const preset = qualityPreset; + const denoisePreset = stemDenoisePreset; + const devicePreset = demucsDevicePreset; + const stemSel = effectiveSelectedStems(preset); const postUrlText = document.getElementById("post-url-text"); if (postUrlText) postUrlText.textContent = displayTitle; - // Show overlay immediately for both paths. File uploads show "Uploading…" - // in the overlay phrase until the fetch completes and SSE takes over. - setWaveformLoading(true, file ? "Uploading…" : ""); - if (file) { + // If no playable track is loaded yet, keep the empty studio in a loading + // state. The job monitor itself is non-modal, so queueing never blocks + // library browsing, playback, export, or adding another track. + if (foreground) setWaveformLoading(true, file ? "Uploading…" : ""); + if (file && foreground) { lastStatus = "queued"; } @@ -448,7 +786,10 @@ export function wireJobForm() { if (file) { const fd = new FormData(); fd.append("file", file); - fd.append("stems", JSON.stringify([...selectedStems])); + fd.append("stems", JSON.stringify(stemSel)); + fd.append("quality_preset", preset); + fd.append("stem_denoise", denoisePreset); + fd.append("demucs_device", devicePreset); fetchInit = { method: "POST", body: fd }; } else { fetchInit = { @@ -458,7 +799,10 @@ export function wireJobForm() { url: urlInput.value, // Backend uses this to decide whether to ffmpeg-amix a // "selected stems" track (mix.wav) at the end of the pipeline. - stems: [...selectedStems], + stems: stemSel, + quality_preset: preset, + stem_denoise: denoisePreset, + demucs_device: devicePreset, }), }; } @@ -470,40 +814,54 @@ export function wireJobForm() { if (!res.ok) throw new Error(data.detail || res.statusText); jobId = data.job_id; } catch (err) { - if (file) jobBox.classList.add("hidden"); + if (foreground) { + setWaveformLoading(false); + if (file) jobBox.classList.add("hidden"); + } showError(`Failed to start job: ${err.message}`); setSubmitProcessing(false); return; } - setCurrentJobId(jobId); jobSources.set(jobId, sourceUrl); + activeJobIds.add(jobId); addTrackToLibrary({ id: jobId, title: displayTitle, - channel: "Processing", + channel: "Queued", thumb: "", - stems: [...selectedStems], - selectedStems: [...selectedStems], + stems: stemSel, + selectedStems: stemSel, + qualityPreset: preset, + stemDenoisePreset: denoisePreset, + demucsDevice: devicePreset, + demucsDeviceResolved: "", audioStems: [], - status: "processing", + status: "queued", + progressPercent: 0, + queuePosition: null, + queueSize: 0, bpm: null, key: null, scale: null, keyConfidence: null, lufs: null, peakDb: null, + bassRepairApplied: false, + phaseRepairApplied: false, + phaseRepairResidualRatio: null, + stemDenoiseApplied: false, + stemGateApplied: false, + stemGateThresholdDb: null, + processingStartedAt: null, + timerStartedAt: null, + processingSeconds: null, + completedAt: null, sourceUrl, }); - setCurrentTrack(jobId); - // Both paths: keep job box hidden, overlay drives the UI. - // Start phrase rotation now that the job exists on the server. - jobBox.classList.add("hidden"); - jobCancelBtn.classList.add("hidden"); - startPhraseRotation("queued"); - lastStatus = "queued"; - - connectEvents(jobId); + showJobMonitor(jobId, "queued", { ownStudio: foreground }); + syncActiveJobs(); + setSubmitProcessing(false); }); } diff --git a/static/js/logs.js b/static/js/logs.js new file mode 100644 index 0000000..0771286 --- /dev/null +++ b/static/js/logs.js @@ -0,0 +1,171 @@ +const POLL_MS = 1500; + +let dialog = null; +let listEl = null; +let emptyEl = null; +let statusEl = null; +let jobSelect = null; +let pollTimer = null; +let requestedJobId = null; +let requestGeneration = 0; +let lastRenderKey = ""; +let previousFocus = null; + +function formatTime(timestamp) { + const date = new Date(Number(timestamp) * 1000); + return Number.isNaN(date.getTime()) + ? "--:--:--" + : date.toLocaleTimeString([], { hour12: false }); +} + +function renderEntries(entries) { + const previousScrollTop = listEl.scrollTop; + const wasNearBottom = + listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight < 40; + listEl.textContent = ""; + emptyEl.classList.toggle("hidden", entries.length > 0); + for (const entry of entries) { + const row = document.createElement("article"); + row.className = `log-entry log-${entry.level || "info"}`; + + const meta = document.createElement("div"); + meta.className = "log-entry-meta"; + const time = document.createElement("time"); + const parsedTime = new Date(Number(entry.timestamp) * 1000); + if (!Number.isNaN(parsedTime.getTime())) time.dateTime = parsedTime.toISOString(); + time.textContent = formatTime(entry.timestamp); + const level = document.createElement("span"); + level.className = "log-entry-level"; + level.textContent = String(entry.level || "info").toUpperCase(); + meta.append(time, level); + + if (entry.progress_percent != null) { + const progress = document.createElement("span"); + progress.className = "log-entry-progress"; + progress.textContent = `${entry.progress_percent}%`; + meta.append(progress); + } + + const body = document.createElement("div"); + body.className = "log-entry-body"; + if (entry.stage) { + const stage = document.createElement("span"); + stage.className = "log-entry-stage"; + stage.textContent = entry.stage; + body.append(stage); + } + const message = document.createElement("p"); + message.textContent = entry.message || ""; + body.append(message); + row.append(meta, body); + listEl.append(row); + } + listEl.scrollTop = wasNearBottom ? listEl.scrollHeight : previousScrollTop; +} + +function updateJobOptions(jobs) { + const current = requestedJobId || jobSelect.value; + const seen = new Set(); + const options = [{ value: "", label: "Current session / all jobs" }]; + for (const job of jobs || []) { + if (!job.job_id || seen.has(job.job_id)) continue; + seen.add(job.job_id); + options.push({ + value: job.job_id, + label: `${job.title || job.job_id} · ${job.status}`, + }); + } + if (current && !seen.has(current)) { + options.push({ value: current, label: `${current} · selected job` }); + } + jobSelect.textContent = ""; + for (const option of options) { + const el = document.createElement("option"); + el.value = option.value; + el.textContent = option.label; + jobSelect.append(el); + } + jobSelect.value = current || ""; +} + +async function refreshLogs() { + if (!dialog || dialog.classList.contains("hidden")) return; + const generation = ++requestGeneration; + const selected = requestedJobId || jobSelect.value; + const query = new URLSearchParams({ limit: "300" }); + if (selected) query.set("job_id", selected); + statusEl.textContent = "Refreshing..."; + try { + const response = await fetch(`/api/logs?${query}`, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const payload = await response.json(); + if (generation !== requestGeneration) return; + updateJobOptions(payload.jobs); + requestedJobId = null; + const entries = payload.entries || []; + const renderKey = `${selected}:${entries.map((entry) => `${entry.id}:${entry.message}`).join("|")}`; + if (renderKey !== lastRenderKey) { + renderEntries(entries); + lastRenderKey = renderKey; + } + statusEl.textContent = `${payload.entries?.length || 0} entries · auto refresh on`; + } catch (error) { + if (generation !== requestGeneration) return; + statusEl.textContent = `Could not load logs: ${error?.message || String(error)}`; + } +} + +function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } +} + +function startPolling() { + stopPolling(); + refreshLogs(); + pollTimer = setInterval(refreshLogs, POLL_MS); +} + +function closeLogViewer() { + stopPolling(); + requestGeneration += 1; + dialog?.classList.add("hidden"); + previousFocus?.focus?.(); + previousFocus = null; +} + +export function openLogViewer(jobId = null) { + if (!dialog) return; + previousFocus = document.activeElement; + requestedJobId = jobId; + jobSelect.value = jobId || ""; + lastRenderKey = ""; + dialog.classList.remove("hidden"); + document.getElementById("logsClose")?.focus(); + startPolling(); +} + +export function initLogViewer() { + dialog = document.getElementById("logsDialog"); + listEl = document.getElementById("logsList"); + emptyEl = document.getElementById("logsEmpty"); + statusEl = document.getElementById("logsStatus"); + jobSelect = document.getElementById("logsJobSelect"); + if (!dialog || !listEl || !emptyEl || !statusEl || !jobSelect) return; + + document.getElementById("logsBtn")?.addEventListener("click", () => openLogViewer()); + document.getElementById("logsClose")?.addEventListener("click", closeLogViewer); + document.getElementById("logsRefresh")?.addEventListener("click", refreshLogs); + jobSelect.addEventListener("change", () => { + requestedJobId = jobSelect.value || null; + refreshLogs(); + }); + dialog.addEventListener("mousedown", (event) => { + if (event.target === dialog) closeLogViewer(); + }); + dialog.addEventListener("keydown", (event) => { + if (event.code === "Escape") closeLogViewer(); + }); +} diff --git a/static/js/main.js b/static/js/main.js index 0d71b6d..9f2d72f 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1,15 +1,27 @@ import { playBtn, loopBtn, multitrack, totalDuration, loopEnabled, loopStart, loopEnd, setLoopStart, setLoopEnd, selectedStems, saveSelectedStems, stemSelectionReady, + qualityPreset, qualityPresetReady, qualitySelect, setQualityPreset, + stemDenoisePreset, stemDenoiseReady, denoiseSelect, setStemDenoisePreset, + demucsDevicePreset, demucsDeviceReady, demucsDeviceSelect, setDemucsDevicePreset, } from "./state.js"; -import { STEM_NAMES, syncStemNamesFromAPI } from "./constants.js"; -import { renderEmptyShell, buildStripStems, downloadCurrentMix, downloadAllStemsZip, downloadRegionMix, drawFooterPlaceholder } from "./player.js"; +import { supportedStemNamesForQuality, syncStemNamesFromAPI } from "./constants.js"; +import { + renderEmptyShell, + buildStripStems, + downloadChordMidi, + downloadCurrentMix, + downloadAllStemsZip, + downloadRegionMix, + drawFooterPlaceholder, +} from "./player.js"; import { wireJobForm, showError } from "./job.js"; import { wireTransportButtons } from "./transport.js"; import { togglePlayPause, updateLoopRegionVisual } from "./transport.js"; import { wireStemListControls, wireMixerToolbar } from "./mixer.js"; import { initCatalog } from "./catalog.js"; import { runStoreMigrationIfNeeded } from "./utils.js"; +import { initLogViewer } from "./logs.js"; // ─── Stem choice toggles on the import page ─── // @@ -31,25 +43,39 @@ import { runStoreMigrationIfNeeded } from "./utils.js"; // Persisted across reloads so the next song honors the user's last // chosen subset, but a 0-selection state is normalized to all 6. function refreshStemChoiceVisuals() { + const allowed = supportedStemNamesForQuality(qualityPreset); + const selectedAllowed = [...selectedStems].filter((name) => allowed.includes(name)); + const fallbackToAll = selectedAllowed.length === 0; for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) { + const enabled = allowed.includes(btn.dataset.stem); + btn.disabled = !enabled; + btn.setAttribute("aria-disabled", String(!enabled)); btn.setAttribute( "aria-pressed", - String(selectedStems.has(btn.dataset.stem)), + String(enabled && (fallbackToAll || selectedStems.has(btn.dataset.stem))), ); + if (!enabled) { + btn.title = `${btn.textContent.trim()} is only available in Standard 6-stem mode`; + } else { + btn.removeAttribute("title"); + } } } function handleStemChoiceClick(stem) { - const allSelected = selectedStems.size === STEM_NAMES.length; + const allowed = supportedStemNamesForQuality(qualityPreset); + if (!allowed.includes(stem)) return; + const selectedAllowed = [...selectedStems].filter((name) => allowed.includes(name)); + const allSelected = selectedAllowed.length === allowed.length; if (allSelected) { // Default state -> switch to "only this stem". - selectedStems.clear(); + for (const name of allowed) selectedStems.delete(name); selectedStems.add(stem); } else if (selectedStems.has(stem)) { selectedStems.delete(stem); - if (selectedStems.size === 0) { + if (!allowed.some((name) => selectedStems.has(name))) { // Empty out wraps back to "all" so the user is never stuck. - for (const n of STEM_NAMES) selectedStems.add(n); + for (const n of allowed) selectedStems.add(n); } } else { selectedStems.add(stem); @@ -71,15 +97,19 @@ function wireAllButton() { if (!allBtn) return; function syncAllBtn() { - allBtn.setAttribute("aria-pressed", String(selectedStems.size === STEM_NAMES.length)); + const allowed = supportedStemNamesForQuality(qualityPreset); + const selectedAllowed = allowed.filter((name) => selectedStems.has(name)); + const allSelected = selectedAllowed.length === 0 || selectedAllowed.length === allowed.length; + allBtn.setAttribute("aria-pressed", String(allSelected)); } allBtn.addEventListener("click", () => { - const allSelected = selectedStems.size === STEM_NAMES.length; + const allowed = supportedStemNamesForQuality(qualityPreset); + const allSelected = allowed.every((name) => selectedStems.has(name)); if (allSelected) { - selectedStems.clear(); + for (const name of allowed) selectedStems.delete(name); } else { - for (const n of STEM_NAMES) selectedStems.add(n); + for (const n of allowed) selectedStems.add(n); } saveSelectedStems(); refreshStemChoiceVisuals(); @@ -95,6 +125,62 @@ function wireAllButton() { syncAllBtn(); } +function wireQualitySelect() { + if (!qualitySelect) return; + qualitySelect.addEventListener("change", () => { + setQualityPreset(qualitySelect.value); + refreshStemChoiceVisuals(); + buildStripStems(); + const allowed = supportedStemNamesForQuality(qualityPreset); + const selectedAllowed = allowed.filter((name) => selectedStems.has(name)); + document + .getElementById("stemAllBtn") + ?.setAttribute( + "aria-pressed", + String(selectedAllowed.length === 0 || selectedAllowed.length === allowed.length), + ); + }); +} + +function wireDenoiseSelect() { + if (!denoiseSelect) return; + denoiseSelect.addEventListener("change", () => { + setStemDenoisePreset(denoiseSelect.value); + }); +} + +async function refreshDeviceSelectAvailability() { + if (!demucsDeviceSelect) return; + try { + const res = await fetch("/api/health"); + if (!res.ok) return; + const health = await res.json(); + const available = new Set(health.demucs_available_devices || ["cpu"]); + const detected = health.demucs_device || "cpu"; + for (const option of demucsDeviceSelect.options) { + if (option.value === "auto") { + option.textContent = `Auto (${detected.toUpperCase()})`; + option.disabled = false; + } else { + option.disabled = !available.has(option.value); + } + } + if (demucsDeviceSelect.value !== "auto" && !available.has(demucsDeviceSelect.value)) { + setDemucsDevicePreset("auto"); + demucsDeviceSelect.value = "auto"; + } + } catch (e) { + console.warn("[main] failed to refresh demucs device availability:", e); + } +} + +function wireDeviceSelect() { + if (!demucsDeviceSelect) return; + demucsDeviceSelect.addEventListener("change", () => { + setDemucsDevicePreset(demucsDeviceSelect.value); + }); +} + // ─── Wire everything up ─── syncStemNamesFromAPI().then(() => buildStripStems()); @@ -106,12 +192,22 @@ wireStemListControls(); wireMixerToolbar(); wireStemChoiceButtons(); wireAllButton(); +wireQualitySelect(); +wireDenoiseSelect(); +wireDeviceSelect(); wireFileDrop(); wireAppShellControls(); (async () => { await runStoreMigrationIfNeeded(); await stemSelectionReady; + await qualityPresetReady; + await stemDenoiseReady; + await demucsDeviceReady; + if (qualitySelect) qualitySelect.value = qualityPreset; + if (denoiseSelect) denoiseSelect.value = stemDenoisePreset; + if (demucsDeviceSelect) demucsDeviceSelect.value = demucsDevicePreset; + await refreshDeviceSelectAvailability(); refreshStemChoiceVisuals(); await initCatalog(); })().catch(console.error); @@ -132,8 +228,13 @@ function wireFooterControls() { const fmtFlac = document.getElementById("t-fmt-flac"); const itemMix = document.getElementById("t-export-mix"); const itemStems = document.getElementById("t-export-stems"); + const itemChords = document.getElementById("t-export-chords"); const itemRegion = document.getElementById("t-export-region"); - const actionItems = () => [itemMix, itemStems, itemRegion]; + const chordFormat = document.getElementById("t-chord-format"); + const chordStyle = document.getElementById("t-chord-style"); + const chordGrid = document.getElementById("t-chord-grid"); + const chordMarkers = document.getElementById("t-chord-markers"); + const actionItems = () => [itemMix, itemStems, itemChords, itemRegion]; let format = "wav"; let busy = false; @@ -159,6 +260,12 @@ function wireFooterControls() { fmtWav?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("wav"); }); fmtMp3?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("mp3"); }); fmtFlac?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("flac"); }); + for (const el of [chordFormat, chordStyle, chordGrid, chordMarkers]) { + el?.addEventListener("click", (e) => e.stopPropagation()); + el?.addEventListener("change", () => { + if (chordMarkers) chordMarkers.disabled = chordFormat?.value === "csv"; + }); + } function resetBusy() { busy = false; @@ -166,6 +273,7 @@ function wireFooterControls() { if (exportLabel) exportLabel.textContent = "Export Mix"; itemMix?.removeAttribute("aria-disabled"); itemStems?.removeAttribute("aria-disabled"); + itemChords?.removeAttribute("aria-disabled"); updateLoopRegionVisual(); // restores the region item's disabled state } @@ -210,6 +318,19 @@ function wireFooterControls() { flashBusy(); }); + itemChords?.addEventListener("click", (e) => { + e.stopPropagation(); + if (busy) return; + const options = { + format: chordFormat?.value || "midi", + style: chordStyle?.value || "auto", + grid: chordGrid?.value || "beat", + markers: chordMarkers?.checked ?? true, + }; + if (!downloadChordMidi(options)) { showError("Chord guide is not available for this track yet."); return; } + flashBusy(); + }); + // Keyboard: ↓ opens/moves into the menu, ↑/↓ cycle rows, Esc closes + restores focus. exportBtn?.addEventListener("keydown", (e) => { if (e.key === "ArrowDown") { @@ -276,8 +397,8 @@ function wireFileDrop() { function applyFile(file) { if (!file) return; const lower = file.name.toLowerCase(); - if (!lower.endsWith(".mp3") && !lower.endsWith(".wav") && !lower.endsWith(".flac")) { - showError("Only MP3, WAV, and FLAC files are supported."); + if (!lower.endsWith(".mp3") && !lower.endsWith(".wav") && !lower.endsWith(".flac") && !lower.endsWith(".m4a")) { + showError("Only MP3, WAV, FLAC, and M4A files are supported."); return; } if (file.size > MAX_UPLOAD_BYTES) { @@ -413,3 +534,4 @@ window.addEventListener("unhandledrejection", (e) => { buildStripStems(); renderEmptyShell(); +initLogViewer(); diff --git a/static/js/player.js b/static/js/player.js index 92066ca..e020567 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -1,7 +1,7 @@ import Multitrack from "/vendor/multitrack.js"; import { fmtTime } from "./utils.js"; import { - STEM_NAMES, TRACK_NAMES, STEM_COLORS, PROGRESS_COLOR, + STEM_NAMES, TRACK_NAMES, STEM_COLORS, STEM_DISPLAY, PROGRESS_COLOR, LOOP_DEFAULT_START_FRAC, LOOP_DEFAULT_END_FRAC, LANE_VOLUME_MAX, } from "./constants.js"; import { @@ -618,6 +618,11 @@ export function destroyPlayer() { wavesGrid.innerHTML = ""; titleEl.textContent = ""; + _currentTitle = ""; + _currentProfileLabel = ""; + _currentProfileSlug = ""; + _chordMidiUrl = null; + updateStemProfileBadge(""); bpmChip.textContent = "\u2014 BPM"; keyChip.textContent = "\u2014 \u2014"; stemsChip.textContent = "\u2014 Stems"; @@ -670,6 +675,7 @@ export function renderEmptyShell() { requestAnimationFrame(() => _applyLaneHeight(1 + STEM_NAMES.length)); applyStemSelectionFilter(new Set(STEM_NAMES)); titleEl.textContent = "Ready to import a track"; + updateStemProfileBadge(""); bpmChip.textContent = "\u2014 BPM"; keyChip.textContent = "\u2014 \u2014"; stemsChip.textContent = "\u2014 Stems"; @@ -705,7 +711,45 @@ let _loadingShownAt = 0; const _LOADING_MIN_MS = 900; let _currentStems = []; let _mixUrl = null; +let _chordMidiUrl = null; let _currentTitle = ""; +let _currentProfileLabel = ""; +let _currentProfileSlug = ""; + +function _safeFilenamePart(value, fallback = "audio") { + const safe = String(value || "") + .replace(/[^a-zA-Z0-9]+/g, "_") + .replace(/_{2,}/g, "_") + .slice(0, 80) + .replace(/^_+|_+$/g, ""); + return safe || fallback; +} + +function _profileSlug(label, key) { + const raw = label || key || ""; + return raw ? _safeFilenamePart(raw, "profile").slice(0, 64).replace(/^_+|_+$/g, "") : ""; +} + +function _exportBase() { + const title = _safeFilenamePart(_currentTitle, "layerlab"); + return _currentProfileSlug ? `${title}_${_currentProfileSlug}` : title; +} + +function _stemFilename(stemName, ext = "wav", region = false) { + const suffix = region ? "_region" : ""; + return `${_exportBase()}_${stemName}${suffix}.${ext}`; +} + +function updateStemProfileBadge(label) { + const badge = document.getElementById("stem-profile-badge"); + const value = document.getElementById("stem-profile-label"); + const text = label || "—"; + if (value) value.textContent = text; + if (badge) { + badge.classList.toggle("is-empty", !label); + badge.title = label ? `Extraction profile: ${label}` : "Extraction profile"; + } +} export function setWaveformLoading(loading, phrase) { const el = document.getElementById("waveLoadingOverlay"); @@ -760,7 +804,19 @@ function _applyLaneHeight(count) { return laneH; } -export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, title = "", peaksPromise = null) { +export function wireUpAudio( + jobId, + stems, + duration, + thumbnail, + mixUrl = null, + title = "", + peaksPromise = null, + profileLabel = "", + profileKey = "", + beatTimes = [], + chordMidiUrl = null, +) { const app = document.querySelector(".app"); app?.classList.remove("is-import"); app?.classList.remove("no-track"); @@ -809,9 +865,17 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti stems = stems.filter((s) => s.name === "original" || selectedStems.has(s.name)); _currentStems = stems; _mixUrl = mixUrl || null; + _chordMidiUrl = chordMidiUrl || null; _currentTitle = title || ""; + _currentProfileLabel = profileLabel || ""; + _currentProfileSlug = _profileSlug(profileLabel, profileKey); + updateStemProfileBadge(_currentProfileLabel); applyStemSelectionFilter(new Set(stems.map((s) => s.name))); - updateFooterTrack({ thumbnail, stemCount: stems.filter((s) => s.name !== "original").length }); + updateFooterTrack({ + thumbnail, + stemCount: stems.filter((s) => s.name !== "original").length, + profileLabel: _currentProfileLabel, + }); // Reset footer waveform state — will be re-populated below after peaks fetch. _footerWavePeaks = null; @@ -843,7 +907,9 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti const dl = row.querySelector(".lane-dl"); if (dl) { dl.href = stem.url; - dl.download = `${stem.name}.wav`; + dl.download = _stemFilename(stem.name, "wav"); + const display = STEM_DISPLAY[stem.name] || stem.name; + dl.title = `Download ${display} (${_currentProfileLabel || "current profile"})`; } } @@ -976,7 +1042,7 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti }); if (!totalDuration) setTotalDuration(mt.getDuration() || 0); timeEl.textContent = `00:00 / ${fmtTime(totalDuration)}`; - buildRuler(totalDuration); + buildRuler(totalDuration, beatTimes); buildPresenceRuler(totalDuration); updateFooterTimes(0); updatePresencePlayhead(0); @@ -1229,7 +1295,7 @@ async function initFooterWaveform(stemUrl) { } } -export function updateFooterTrack({ title, thumbnail, key, bpm, stemCount } = {}) { +export function updateFooterTrack({ title, thumbnail, key, bpm, stemCount, profileLabel } = {}) { if (footerThumb) { const artEl = footerThumb.closest(".footer-art"); if (thumbnail) { @@ -1251,10 +1317,11 @@ export function updateFooterTrack({ title, thumbnail, key, bpm, stemCount } = {} if (footerTitle && title !== undefined) footerTitle.textContent = title; if (footerMeta) { const parts = []; + if (profileLabel) parts.push(profileLabel); if (key) parts.push(key); if (bpm) parts.push(`${Math.round(bpm)} BPM`); if (stemCount != null) parts.push(`${stemCount} Stems`); - if (key !== undefined || bpm !== undefined || stemCount !== undefined) + if (profileLabel !== undefined || key !== undefined || bpm !== undefined || stemCount !== undefined) footerMeta.textContent = parts.join(" • "); } } @@ -1312,12 +1379,7 @@ function _mixdownUrl(ext, region) { } function _exportFilename(ext) { - const safe = _currentTitle - .replace(/[^a-zA-Z0-9]+/g, "_") - .replace(/_{2,}/g, "_") - .slice(0, 80) - .replace(/^_+|_+$/g, ""); - return safe ? `${safe}_exported_mix.${ext}` : `exported_mix.${ext}`; + return `${_exportBase()}_mix.${ext}`; } // The download functions return true when a download was triggered and false @@ -1334,18 +1396,12 @@ export function downloadCurrentStems(format = "wav", onProgress) { const stems = _currentStems.filter((s) => s.name !== "original"); const total = stems.length; if (!total) { onProgress?.(0, 0); return; } - // Name each file "_." using the same title - // sanitization as the mix/region exports. - const safe = _currentTitle - .replace(/[^a-zA-Z0-9]+/g, "_") - .replace(/_{2,}/g, "_") - .slice(0, 80) - .replace(/^_+|_+$/g, ""); + // Include the extraction profile so exported files remain identifiable even + // after multiple profiles of the same source are compared outside the app. stems.forEach((s, i) => { window.setTimeout(() => { const url = format === "mp3" ? s.url.replace(/\.wav(\?|$)/, ".mp3$1") : s.url; - const fname = safe ? `${safe}_${s.name}.${format}` : `${s.name}.${format}`; - _triggerDownload(url, fname); + _triggerDownload(url, _stemFilename(s.name, format)); onProgress?.(i + 1, total); }, i * 150); }); @@ -1356,23 +1412,39 @@ export function downloadAllStemsZip(format = "wav") { // Only the active (selected) stems loaded in the DAW — not all 6. const names = _currentStems.filter((s) => s.name !== "original").map((s) => s.name); if (!names.length) return; - const safe = _currentTitle - .replace(/[^a-zA-Z0-9]+/g, "_") - .replace(/_{2,}/g, "_") - .slice(0, 80) - .replace(/^_+|_+$/g, ""); - const name = safe ? `${safe}_stems.zip` : "stems.zip"; + const name = `${_exportBase()}_stems.zip`; const q = new URLSearchParams({ format, stems: names.join(",") }); _triggerDownload(`/api/jobs/${currentJobId}/stems/all.zip?${q}`, name); } +function _chordExportSuffix({ style = "auto", grid = "beat", markers = false } = {}) { + const parts = []; + if (style && style !== "auto") parts.push(style); + if (grid && grid !== "beat") parts.push(grid); + if (markers) parts.push("markers"); + return parts.length ? `_${parts.join("_")}` : ""; +} + +export function downloadChordMidi(options = {}) { + if (!currentJobId || !_chordMidiUrl) return false; + const format = options.format === "csv" ? "csv" : "midi"; + const style = options.style || "auto"; + const grid = options.grid || "beat"; + const markers = Boolean(options.markers) && format === "midi"; + const q = new URLSearchParams({ style, grid }); + if (markers) q.set("markers", "true"); + const suffix = _chordExportSuffix({ style, grid, markers }); + if (format === "csv") { + _triggerDownload(`/api/jobs/${currentJobId}/chords.csv?${q}`, `${_exportBase()}_chords${suffix}.csv`); + return true; + } + const sep = _chordMidiUrl.includes("?") ? "&" : "?"; + _triggerDownload(`${_chordMidiUrl}${sep}${q}`, `${_exportBase()}_chords${suffix}.mid`); + return true; +} + function _regionFilename(ext) { - const safe = _currentTitle - .replace(/[^a-zA-Z0-9]+/g, "_") - .replace(/_{2,}/g, "_") - .slice(0, 80) - .replace(/^_+|_+$/g, ""); - return `${safe || "region"}_region.${ext}`; + return `${_exportBase()}_region.${ext}`; } export function downloadRegionMix(ext = "wav") { diff --git a/static/js/state.js b/static/js/state.js index 51fc550..4fb4ee0 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -1,11 +1,14 @@ import { $, storeGet, storeSet } from "./utils.js"; -import { STEM_NAMES } from "./constants.js"; +import { STEM_NAMES, supportedStemNamesForQuality } from "./constants.js"; // ─── DOM refs ─── export const form = $("job-form"); export const urlInput = $("url"); export const submitBtn = $("submit"); +export const qualitySelect = $("qualityPreset"); +export const denoiseSelect = $("stemDenoise"); +export const demucsDeviceSelect = $("demucsDevice"); export const playBtn = $("t-play"); export const playMiniBtn = $("t-play-mini"); @@ -24,6 +27,8 @@ export const jobBox = $("job"); export const jobTitleEl = $("job-title"); export const jobStageEl = $("job-stage"); export const jobDetailEl = $("job-detail"); +export const jobEtaEl = $("job-eta"); +export const jobPercentEl = $("job-percent"); export const jobCancelBtn = $("job-cancel"); export const progressEl = $("progress"); @@ -75,6 +80,12 @@ export let loopEnd = 0; // so a user who turns off "Vocals" stays set up that way for the // next song. const _STEM_SEL_KEY = "stemdeck:selected-stems"; +const _QUALITY_PRESET_KEY = "stemdeck:quality-preset"; +const _STEM_DENOISE_KEY = "stemdeck:stem-denoise"; +const _DEMUCS_DEVICE_KEY = "stemdeck:demucs-device"; +const QUALITY_PRESETS = new Set(["standard", "high", "max", "ultra"]); +const STEM_DENOISE_PRESETS = new Set(["off", "light", "strong"]); +const DEMUCS_DEVICE_PRESETS = new Set(["auto", "cpu", "mps", "cuda"]); // Start with all stems selected (safe default). The async store load below // updates this binding once the store is available; ES module live bindings @@ -98,6 +109,64 @@ export const stemSelectionReady = (async () => { // Keep the all-stems default. })(); +export let qualityPreset = "standard"; +export let stemDenoisePreset = "off"; +export let demucsDevicePreset = "auto"; + +export const qualityPresetReady = (async () => { + try { + const stored = await storeGet(_QUALITY_PRESET_KEY, null); + if (typeof stored === "string" && QUALITY_PRESETS.has(stored)) { + qualityPreset = stored; + } + } catch (e) { console.warn("[state] failed to load quality preset:", e); } +})(); + +export const stemDenoiseReady = (async () => { + try { + const stored = await storeGet(_STEM_DENOISE_KEY, null); + if (typeof stored === "string" && STEM_DENOISE_PRESETS.has(stored)) { + stemDenoisePreset = stored; + } + } catch (e) { console.warn("[state] failed to load stem denoise preset:", e); } +})(); + +export const demucsDeviceReady = (async () => { + try { + const stored = await storeGet(_DEMUCS_DEVICE_KEY, null); + if (typeof stored === "string" && DEMUCS_DEVICE_PRESETS.has(stored)) { + demucsDevicePreset = stored; + } + } catch (e) { console.warn("[state] failed to load demucs device preset:", e); } +})(); + +export function setQualityPreset(value) { + qualityPreset = QUALITY_PRESETS.has(value) ? value : "standard"; + storeSet(_QUALITY_PRESET_KEY, qualityPreset).catch((e) => + console.warn("[state] failed to save quality preset:", e) + ); +} + +export function setStemDenoisePreset(value) { + stemDenoisePreset = STEM_DENOISE_PRESETS.has(value) ? value : "off"; + storeSet(_STEM_DENOISE_KEY, stemDenoisePreset).catch((e) => + console.warn("[state] failed to save stem denoise preset:", e) + ); +} + +export function setDemucsDevicePreset(value) { + demucsDevicePreset = DEMUCS_DEVICE_PRESETS.has(value) ? value : "auto"; + storeSet(_DEMUCS_DEVICE_KEY, demucsDevicePreset).catch((e) => + console.warn("[state] failed to save demucs device preset:", e) + ); +} + +export function effectiveSelectedStems(preset = qualityPreset) { + const allowed = supportedStemNamesForQuality(preset); + const selected = [...selectedStems].filter((name) => allowed.includes(name)); + return selected.length > 0 ? selected : [...allowed]; +} + export function saveSelectedStems() { storeSet(_STEM_SEL_KEY, [...selectedStems]).catch((e) => console.warn("[state] failed to save stem selection:", e) diff --git a/static/js/transport.js b/static/js/transport.js index 03c38f3..ce4ef3a 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -54,7 +54,7 @@ function setPlayheadTime(sec) { updatePresencePlayhead(next); } -export function buildRuler(durationSec) { +export function buildRuler(durationSec, beatTimes = []) { rulerTime.innerHTML = ""; wavesGrid.innerHTML = ""; const marker = document.createElement("div"); @@ -79,6 +79,16 @@ export function buildRuler(durationSec) { grid.style.left = `${leftPct}%`; wavesGrid.appendChild(grid); } + const beats = Array.isArray(beatTimes) ? beatTimes : []; + beats.forEach((beat, idx) => { + const t = Number(beat); + if (!Number.isFinite(t) || t < 0 || t > durationSec) return; + const line = document.createElement("div"); + line.className = `beat-grid-line${idx % 4 === 0 ? " beat-grid-line-strong" : ""}`; + line.style.left = `${(t / durationSec) * 100}%`; + line.setAttribute("aria-hidden", "true"); + wavesGrid.appendChild(line); + }); } export function updatePlayheadMarker(currentSec) { diff --git a/static/js/utils.js b/static/js/utils.js index 4090382..b49ddfd 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -1,8 +1,8 @@ -// ─── Persistent store (tauri-plugin-store via custom commands) ─── +// ─── Persistent store (Tauri atomic JSON commands) ─── // // Falls back to localStorage when running outside Tauri (browser dev mode). -// The store is backed by ~/Library/Application Support/app.stemdeck.desktop/user-data.json -// on macOS — outside WebKit's reach, so WebView resets can never destroy user data. +// The store is backed by ~/Documents/LayerLab/user-data.json on macOS — +// outside WebKit's reach, so WebView resets can never destroy user data. export async function storeGet(key, fallback = null) { if (window.__TAURI__?.core?.invoke) { @@ -27,6 +27,34 @@ export async function storeSet(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { console.warn("[store] localStorage set failed", e); } } +export async function desktopBackendStatus() { + if (!window.__TAURI__?.core?.invoke) return null; + try { + return await window.__TAURI__.core.invoke("backend_status"); + } catch (e) { console.warn("[desktop] backend_status failed", e); return null; } +} + +export async function desktopMaintenanceStatus() { + if (!window.__TAURI__?.core?.invoke) return null; + try { + return await window.__TAURI__.core.invoke("maintenance_status"); + } catch (e) { console.warn("[desktop] maintenance_status failed", e); return null; } +} + +export async function desktopRunMaintenance() { + if (!window.__TAURI__?.core?.invoke) return null; + try { + return await window.__TAURI__.core.invoke("run_maintenance"); + } catch (e) { console.warn("[desktop] run_maintenance failed", e); return null; } +} + +export async function desktopAnalyzeWavFile(path, bins = 2048) { + if (!window.__TAURI__?.core?.invoke) return null; + try { + return await window.__TAURI__.core.invoke("analyze_wav_file", { path, bins }); + } catch (e) { console.warn("[desktop] analyze_wav_file failed", e); return null; } +} + // Debounced variant — coalesces rapid writes (e.g. mixer slider moves) into // a single store write ~300ms after the last call. Each call snapshots the // value so no mutation races occur. @@ -46,6 +74,9 @@ const _MIGRATE_KEYS = [ "stemdeck.folders", "stemdeck.deleted_jobs", "stemdeck:selected-stems", + "stemdeck:quality-preset", + "stemdeck:stem-denoise", + "stemdeck:demucs-device", ]; // One-time bootstrap: copy localStorage → store for existing users. @@ -109,4 +140,15 @@ export function fmtTickLabel(s) { return `${m}:${sec}`; } -export const $ = (id) => document.getElementById(id); \ No newline at end of file +export function fmtBeatGrid(beatTimes, durationSec = null) { + const beats = Array.isArray(beatTimes) + ? beatTimes.map(Number).filter((t) => Number.isFinite(t) && t >= 0) + : []; + if (!beats.length) return "—"; + const lastBeat = beats[beats.length - 1]; + const capped = Number.isFinite(durationSec) && durationSec > lastBeat + 5; + const suffix = capped ? ` · first ${fmtTime(lastBeat)}` : ""; + return `${beats.length} beat${beats.length === 1 ? "" : "s"}${suffix}`; +} + +export const $ = (id) => document.getElementById(id); diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py new file mode 100644 index 0000000..b163fc2 --- /dev/null +++ b/tests/test_benchmark.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import soundfile as sf + +from app.pipeline.benchmark import ( + benchmark_audio, + benchmark_job_dir, + benchmark_jobs_root, + chord_metrics, + compare_benchmark_reports, + stem_sum_metrics, +) + + +def _write_wav(path: Path, samples: np.ndarray, sr: int = 8000) -> None: + sf.write(path, samples.astype(np.float32), sr, subtype="FLOAT") + + +def test_stem_sum_metrics_reports_near_zero_residual(tmp_path: Path): + sr = 8000 + t = np.linspace(0, 1, sr, endpoint=False) + bass = np.sin(2 * np.pi * 110 * t) * 0.2 + drums = np.sin(2 * np.pi * 440 * t) * 0.1 + source = bass + drums + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + _write_wav(tmp_path / "source.wav", source, sr) + _write_wav(stems_dir / "bass.wav", bass, sr) + _write_wav(stems_dir / "drums.wav", drums, sr) + + metrics = stem_sum_metrics( + tmp_path / "source.wav", + stems_dir, + stem_names=["bass", "drums"], + sr=sr, + duration=1.0, + ) + + assert metrics["available"] is True + assert metrics["used_stems"] == ["bass", "drums"] + assert metrics["residual_percent"] < 0.01 + assert metrics["correlation"] > 0.999 + + +def test_stem_sum_metrics_reports_residual_when_stem_missing(tmp_path: Path): + sr = 8000 + t = np.linspace(0, 1, sr, endpoint=False) + bass = np.sin(2 * np.pi * 110 * t) * 0.2 + drums = np.sin(2 * np.pi * 440 * t) * 0.1 + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + _write_wav(tmp_path / "source.wav", bass + drums, sr) + _write_wav(stems_dir / "bass.wav", bass, sr) + + metrics = stem_sum_metrics( + tmp_path / "source.wav", + stems_dir, + stem_names=["bass", "drums"], + sr=sr, + duration=1.0, + ) + + assert metrics["missing_stems"] == ["drums"] + assert metrics["residual_percent"] > 40 + + +def test_chord_metrics_summarizes_metadata(tmp_path: Path): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + (stems_dir / "chords.mid").write_bytes(b"MThd") + metadata = tmp_path / "metadata.json" + metadata.write_text( + json.dumps( + { + "bpm": 128, + "duration_sec": 2.0, + "beat_times": [0.0, 0.5, 1.0], + "chord_progression": [ + {"label": "C", "confidence": 0.8, "start": 0.0, "end": 1.0, "start_beat": 0, "end_beat": 2}, + {"label": "Gmaj7", "confidence": 0.6, "start": 1.0, "end": 1.5, "start_beat": 2, "end_beat": 3}, + ], + } + ), + encoding="utf-8", + ) + + metrics = chord_metrics(metadata, stems_dir) + + assert metrics["metadata_available"] is True + assert metrics["midi_available"] is True + assert metrics["segment_count"] == 2 + assert metrics["unique_labels"] == ["C", "Gmaj7"] + assert metrics["average_confidence"] == 0.7 + assert metrics["average_beats_per_segment"] == 1.5 + assert metrics["short_segment_count"] == 1 + assert metrics["unstable_short_segment_count"] == 1 + + +def test_benchmark_job_dir_uses_retained_source(tmp_path: Path): + sr = 8000 + source = np.zeros(sr, dtype=np.float32) + job_dir = tmp_path / "abcdefabcdef" + stems_dir = job_dir / "stems" + stems_dir.mkdir(parents=True) + _write_wav(job_dir / "source.wav", source, sr) + _write_wav(stems_dir / "vocals.wav", source, sr) + (job_dir / "metadata.json").write_text("{}", encoding="utf-8") + + report = benchmark_job_dir(job_dir, sr=sr, duration=1.0) + + assert report["schema"] == "layerlab-benchmark-v1" + assert report["stem_sum"]["available"] is True + + +def test_benchmark_audio_without_source_reports_chords_only(tmp_path: Path): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + _write_wav(stems_dir / "vocals.wav", np.zeros(16, dtype=np.float32), 8000) + + report = benchmark_audio(source=None, stems_dir=stems_dir, sr=8000, duration=1.0) + + assert report["stem_sum"]["available"] is False + assert report["stems"]["present"] == ["vocals"] + + +def test_benchmark_jobs_root_and_compare_reports(tmp_path: Path): + sr = 8000 + job_dir = tmp_path / "abcdefabcdef" + stems_dir = job_dir / "stems" + stems_dir.mkdir(parents=True) + samples = np.zeros(sr, dtype=np.float32) + _write_wav(job_dir / "source.wav", samples, sr) + _write_wav(stems_dir / "vocals.wav", samples, sr) + (stems_dir / "chords.mid").write_bytes(b"MThd") + (job_dir / "metadata.json").write_text( + json.dumps( + { + "duration_sec": 1.0, + "chord_progression": [ + {"label": "C", "confidence": 0.9, "start": 0.0, "end": 1.0, "start_beat": 0, "end_beat": 4} + ], + } + ), + encoding="utf-8", + ) + + report = benchmark_jobs_root(tmp_path, sr=sr, duration=1.0) + comparison = compare_benchmark_reports( + report, + { + "schema": "layerlab-benchmark-suite-v1", + "job_count": 1, + "summary": {"chord_confidence_mean": 0.8, "unstable_short_segment_total": 2}, + }, + ) + + assert report["schema"] == "layerlab-benchmark-suite-v1" + assert report["job_count"] == 1 + assert report["summary"]["chord_confidence_mean"] == 0.9 + assert comparison["summary_delta"]["chord_confidence_mean"] == 0.1 + assert comparison["summary_delta"]["unstable_short_segment_total"] == -2 diff --git a/tests/test_chords.py b/tests/test_chords.py new file mode 100644 index 0000000..5340f74 --- /dev/null +++ b/tests/test_chords.py @@ -0,0 +1,434 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np + +from app.core.models import Job +from app.pipeline import chords as chords_mod +from app.pipeline.chords import ( + ChordSegment, + _apply_bass_root_hint, + _available_chord_source_paths, + _ChromaSource, + _combine_beatwise_chroma, + _combine_segment_chroma, + _estimate_key_context, + _midi_text, + _parse_key_context, + _score_chord, + _select_chord_sequence, + _smooth_short_segments, + _varlen, + chord_segments_from_metadata, + chord_segments_to_csv, + detect_chord_segments, + generate_chord_midi, + prepare_chord_midi_segments, + write_chord_midi, +) + + +def _fake_chroma_source(name: str, weight: float, notes: dict[int, float]) -> _ChromaSource: + chroma = np.zeros((12, 1), dtype=np.float32) + for note, value in notes.items(): + chroma[note, 0] = value + return _ChromaSource(name=name, weight=weight, chroma=chroma, frame_times=np.array([0.5])) + + +def _vector(notes: dict[int, float]) -> np.ndarray: + chroma = np.zeros(12, dtype=np.float32) + for note, value in notes.items(): + chroma[note] = value + return chroma + + +def _multi_frame_source( + name: str, + weight: float, + frames: list[dict[int, float]], + frame_times: list[float], +) -> _ChromaSource: + chroma = np.zeros((12, len(frames)), dtype=np.float32) + for idx, notes in enumerate(frames): + for note, value in notes.items(): + chroma[note, idx] = value + return _ChromaSource(name=name, weight=weight, chroma=chroma, frame_times=np.array(frame_times)) + + +def _read_varlen(data: bytes, offset: int) -> tuple[int, int]: + value = 0 + while True: + byte = data[offset] + offset += 1 + value = (value << 7) | (byte & 0x7F) + if byte < 0x80: + return value, offset + + +def _track_chunks(data: bytes) -> list[bytes]: + chunks: list[bytes] = [] + offset = 14 + while offset < len(data): + assert data[offset : offset + 4] == b"MTrk" + length = int.from_bytes(data[offset + 4 : offset + 8], "big") + start = offset + 8 + chunks.append(data[start : start + length]) + offset = start + length + return chunks + + +def _last_track_tick(track: bytes) -> int: + offset = 0 + end = len(track) + tick = 0 + running_status = None + while offset < end: + delta, offset = _read_varlen(track, offset) + tick += delta + status = track[offset] + offset += 1 + if status == 0xFF: + meta_type = track[offset] + offset += 1 + size, offset = _read_varlen(track, offset) + offset += size + if meta_type == 0x2F: + return tick + elif status in (0xF0, 0xF7): + size, offset = _read_varlen(track, offset) + offset += size + else: + if status < 0x80: + if running_status is None: + raise AssertionError("running status without previous status") + offset -= 1 + status = running_status + else: + running_status = status + event_type = status & 0xF0 + offset += 1 if event_type in (0xC0, 0xD0) else 2 + return tick + + +def _last_midi_tick(data: bytes) -> int: + return max(_last_track_tick(track) for track in _track_chunks(data)) + + +def test_varlen_encoding_matches_midi_spec(): + assert _varlen(0) == b"\x00" + assert _varlen(127) == b"\x7f" + assert _varlen(128) == b"\x81\x00" + assert _varlen(480) == b"\x83\x60" + + +def test_available_chord_sources_prefer_harmonic_stems(tmp_path: Path): + source = tmp_path / "source.wav" + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + source.write_bytes(b"wav") + for name in ("piano", "guitar", "bass", "drums"): + (stems_dir / f"{name}.wav").write_bytes(b"wav") + + chord_paths, bass_path = _available_chord_source_paths(source, stems_dir) + + assert [name for name, _, _ in chord_paths] == ["original", "piano", "guitar"] + assert chord_paths[0][2] == 0.35 + assert bass_path == stems_dir / "bass.wav" + + +def test_combine_segment_chroma_uses_stems_and_bass_root_hint(): + original = _fake_chroma_source("original", 1.0, {0: 0.7, 4: 0.8, 7: 0.6, 9: 0.4}) + piano = _fake_chroma_source("piano", 0.9, {9: 1.0, 0: 0.82, 4: 0.72, 7: 0.42}) + bass = _fake_chroma_source("bass", 1.0, {9: 1.0}) + + combined = _combine_segment_chroma([original, piano], bass, 0.0, 1.0) + + assert combined is not None + label, root, _, confidence = _score_chord(combined) + assert label in {"Am", "Am7"} + assert root == 9 + assert confidence > 0.8 + + +def test_combine_beatwise_chroma_uses_bass_root_across_beats(): + harmony = _multi_frame_source( + "piano", + 1.25, + [ + {0: 0.82, 4: 0.74, 7: 0.62, 9: 0.55}, + {0: 0.80, 4: 0.70, 7: 0.60, 9: 0.58}, + {0: 0.84, 4: 0.72, 7: 0.63, 9: 0.56}, + {0: 0.78, 4: 0.69, 7: 0.59, 9: 0.60}, + ], + [0.5, 1.5, 2.5, 3.5], + ) + bass = _multi_frame_source( + "bass", + 1.0, + [{9: 1.0}, {9: 0.9}, {9: 1.0}, {9: 0.92}], + [0.5, 1.5, 2.5, 3.5], + ) + + combined = _combine_beatwise_chroma([harmony], bass, [0, 1, 2, 3, 4], 0, 4) + assert combined is not None + label, root, _, confidence = _score_chord(combined) + + assert label in {"Am", "Am7"} + assert root == 9 + assert confidence > 0.75 + + +def test_combine_beatwise_chroma_skips_tail_without_next_beat(): + harmony = _multi_frame_source( + "piano", + 1.25, + [{0: 1.0, 4: 0.82, 7: 0.72}], + [0.5], + ) + + assert _combine_beatwise_chroma([harmony], None, [0.0, 1.0], 1, 5) is None + + +def test_bass_passing_note_does_not_override_supported_harmony(): + harmony = _vector({0: 1.0, 4: 0.84, 7: 0.74}) + harmony = harmony / float(np.sum(harmony)) + + combined = _apply_bass_root_hint(harmony, (2, 1.0), boost=0.24) + label, root, _, confidence = _score_chord(combined) + + assert label == "C" + assert root == 0 + assert confidence > 0.75 + + +def test_estimate_key_context_returns_confident_minor_key(): + vectors = [ + _vector({2: 1.0, 5: 0.82, 9: 0.72}), + _vector({10: 1.0, 2: 0.82, 5: 0.72}), + _vector({9: 1.0, 0: 0.82, 4: 0.72}), + ] + + assert _estimate_key_context(vectors) == (2, "minor") + + +def test_parse_key_context_accepts_analysis_labels(): + assert _parse_key_context("B min") == (11, "minor") + assert _parse_key_context("Db major") == (1, "major") + + +def test_select_chord_sequence_smooths_weak_same_root_variant(): + c = _vector({0: 1.0, 4: 0.82, 7: 0.72}) + noisy_same_root = _vector({7: 0.9, 11: 0.68, 2: 0.62, 0: 0.8, 4: 0.6}) + + selected = _select_chord_sequence([c, noisy_same_root, c]) + + assert [candidate.label for candidate in selected] == ["C", "C", "C"] + + +def test_select_chord_sequence_keeps_strong_progression_change(): + c = _vector({0: 1.0, 4: 0.82, 7: 0.72}) + g = _vector({7: 1.0, 11: 0.82, 2: 0.72}) + + selected = _select_chord_sequence([c, g, c]) + + assert [candidate.label for candidate in selected] == ["C", "G", "C"] + + +def test_select_chord_sequence_uses_key_hint_to_avoid_unstable_complex_chords(): + key_context = _parse_key_context("B min") + f_sharp_dim_like = _vector({6: 1.0, 9: 0.78, 0: 0.62}) + d_maj7_like = _vector({2: 1.0, 6: 0.82, 9: 0.72, 1: 0.45}) + + selected = _select_chord_sequence([f_sharp_dim_like, d_maj7_like], key_context=key_context) + + assert [candidate.label for candidate in selected] == ["F#m", "D"] + + +def test_smooth_short_segments_removes_weak_one_beat_flip(): + segments = [ + ChordSegment("C", 0.0, 1.0, 0, (0, 4, 7), 0.82, start_beat=0, end_beat=1), + ChordSegment("G", 1.0, 2.0, 7, (0, 4, 7), 0.34, start_beat=1, end_beat=2), + ChordSegment("C", 2.0, 3.0, 0, (0, 4, 7), 0.86, start_beat=2, end_beat=3), + ] + + smoothed = _smooth_short_segments(segments) + + assert len(smoothed) == 1 + assert smoothed[0].label == "C" + assert smoothed[0].start_beat == 0 + assert smoothed[0].end_beat == 3 + + +def test_smooth_short_segments_removes_single_beat_complex_label(): + segments = [ + ChordSegment("F#7", 0.0, 1.0, 6, (0, 4, 7, 10), 0.86, start_beat=0, end_beat=1), + ChordSegment("Cmaj7", 1.0, 2.0, 0, (0, 4, 7, 11), 1.0, start_beat=1, end_beat=2), + ChordSegment("Bm7", 2.0, 3.0, 11, (0, 3, 7, 10), 0.92, start_beat=2, end_beat=3), + ] + + smoothed = _smooth_short_segments(segments) + + assert [(seg.label, seg.start_beat, seg.end_beat) for seg in smoothed] == [ + ("F#7", 0, 1), + ("Bm7", 1, 3), + ] + + +def test_prepare_chord_midi_segments_supports_triads_and_bar_grid(): + segments = chord_segments_from_metadata( + [ + {"label": "Bm7", "start": 0.0, "end": 1.0, "start_beat": 0, "end_beat": 1, "confidence": 0.9}, + {"label": "Dmaj7", "start": 1.0, "end": 3.0, "start_beat": 1, "end_beat": 3, "confidence": 0.8}, + {"label": "Gmaj7", "start": 3.0, "end": 4.0, "start_beat": 3, "end_beat": 4, "confidence": 0.7}, + ] + ) + + prepared = prepare_chord_midi_segments(segments, style="triads", grid="bar") + + assert [(seg.label, seg.start_beat, seg.end_beat) for seg in prepared] == [("D", 0, 4)] + assert prepared[0].intervals == (0, 4, 7) + assert prepared[0].start == 0.0 + assert prepared[0].end == 4.0 + + +def test_chord_segments_to_csv_writes_beat_metadata(): + segments = [ChordSegment("C", 0.0, 1.5, 0, (0, 4, 7), 0.876, start_beat=0, end_beat=4)] + + text = chord_segments_to_csv(segments) + + assert "label,start_sec,end_sec,start_beat,end_beat,confidence" in text + assert "C,0.000,1.500,0,4,0.876" in text + + +def test_detect_chord_segments_uses_quarter_note_grid(monkeypatch, tmp_path: Path): + source = tmp_path / "source.wav" + stems_dir = tmp_path / "stems" + source.write_bytes(b"wav") + stems_dir.mkdir() + (stems_dir / "piano.wav").write_bytes(b"wav") + + piano = _multi_frame_source( + "piano", + 1.25, + [ + {0: 1.0, 4: 0.82, 7: 0.72}, + {0: 1.0, 4: 0.82, 7: 0.72}, + {7: 1.0, 11: 0.82, 2: 0.72}, + {7: 1.0, 11: 0.82, 2: 0.72}, + ], + [0.5, 1.5, 2.5, 3.5], + ) + + def fake_load_chroma_source(*args, **kwargs): + return piano if kwargs["name"] == "piano" else None + + monkeypatch.setattr(chords_mod, "_load_chroma_source", fake_load_chroma_source) + + segments = detect_chord_segments( + source, + [0.0, 1.0, 2.0, 3.0, 4.0], + duration_sec=4.0, + stems_dir=stems_dir, + ) + + assert [(seg.label, seg.start_beat, seg.end_beat) for seg in segments] == [ + ("C", 0, 2), + ("G", 2, 4), + ] + + +def test_write_chord_midi_writes_standard_midi_file(tmp_path: Path): + out = tmp_path / "chords.mid" + segments = [ + ChordSegment("C", 0.0, 2.0, 0, (0, 4, 7), 0.9), + ChordSegment("Am", 2.0, 4.0, 9, (0, 3, 7), 0.8), + ] + + write_chord_midi(out, segments, bpm=120, title="Guide") + + data = out.read_bytes() + assert data.startswith(b"MThd") + assert int.from_bytes(data[8:10], "big") == 1 + assert int.from_bytes(data[10:12], "big") == 2 + assert len(_track_chunks(data)) == 2 + assert b"Guide" in data + assert b"Am" in data + assert b"\xff\x51\x03" in data + assert b"\xff\x58\x04\x04\x02\x18\x08" in data + assert _last_midi_tick(data) == 3840 + + +def test_write_chord_midi_uses_ascii_safe_title_metadata(tmp_path: Path): + out = tmp_path / "multibyte-title.mid" + title = "譜医〜煌椰け(bassmicrobe remix)" + segments = [ChordSegment("C", 0.0, 2.0, 0, (0, 4, 7), 0.9)] + + write_chord_midi(out, segments, bpm=120, title=title) + + data = out.read_bytes() + assert title.encode("utf-8") not in data + assert b"bassmicrobe remix" in data + + +def test_midi_text_falls_back_when_title_has_no_ascii(): + assert _midi_text("曲名だけ", "LayerLab Chord Progression", 120) == b"LayerLab Chord Progression" + + +def test_write_chord_midi_quantizes_detected_beats_as_quarter_notes(tmp_path: Path): + out = tmp_path / "quantized.mid" + segments = [ + ChordSegment("C", 2.368, 4.226, 0, (0, 4, 7), 0.9, start_beat=0, end_beat=4), + ChordSegment("G", 4.226, 6.084, 7, (0, 4, 7), 0.8, start_beat=4, end_beat=8), + ] + + write_chord_midi(out, segments, bpm=129, title="Quantized") + + assert _last_midi_tick(out.read_bytes()) == 8 * 480 + + +def test_write_chord_midi_keeps_no_chord_time_on_grid(tmp_path: Path): + out = tmp_path / "no-chord-tail.mid" + segments = [ + ChordSegment("C", 0.0, 1.0, 0, (0, 4, 7), 0.9, start_beat=0, end_beat=4), + ChordSegment("N.C.", 1.0, 2.0, None, (), 0.0, start_beat=4, end_beat=8), + ] + + write_chord_midi(out, segments, bpm=120, title="No Chord Tail") + + assert _last_midi_tick(out.read_bytes()) == 8 * 480 + + +def test_write_chord_midi_can_write_marker_events(tmp_path: Path): + out = tmp_path / "markers.mid" + segments = [ChordSegment("C", 0.0, 1.0, 0, (0, 4, 7), 0.9, start_beat=0, end_beat=4)] + + write_chord_midi(out, segments, bpm=120, title="Markers", markers=True) + + assert b"\xff\x06\x01C" in out.read_bytes() + + +def test_generate_chord_midi_sets_job_metadata(tmp_path: Path, monkeypatch): + segments = [ChordSegment("C", 0.0, 1.0, 0, (0, 4, 7), 0.9, start_beat=0, end_beat=4)] + monkeypatch.setattr(chords_mod, "detect_chord_segments", lambda *args, **kwargs: segments) + job = Job(id="abcdefabcdef", bpm=120, title="Song") + job_dir = tmp_path / job.id + source = job_dir / "source.wav" + source.parent.mkdir(parents=True) + source.write_bytes(b"wav") + + out = generate_chord_midi(job, source, job_dir) + + assert out == job_dir / "stems" / "chords.mid" + assert out is not None and out.is_file() + assert job.chord_midi_url == f"/api/jobs/{job.id}/chords.mid" + assert job.chord_progression == [ + { + "label": "C", + "start": 0.0, + "end": 1.0, + "start_beat": 0, + "end_beat": 4, + "confidence": 0.9, + } + ] diff --git a/tests/test_config.py b/tests/test_config.py index e86ed94..f606282 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -65,6 +65,18 @@ def test_ffmpeg_executable_prefers_portable_binary(monkeypatch, tmp_path: Path): importlib.reload(original) +def test_ffmpeg_executable_uses_imageio_fallback(monkeypatch, tmp_path: Path): + import app.core.config as config + + ffmpeg = tmp_path / "imageio-ffmpeg" + ffmpeg.write_text("#!/bin/sh\n", encoding="utf-8") + monkeypatch.setattr(config.shutil, "which", lambda _name: None) + monkeypatch.setattr(config, "_imageio_ffmpeg_executable", lambda: str(ffmpeg)) + + assert config.ffmpeg_executable() == str(ffmpeg) + assert config.ffmpeg_available() is True + + def test_configure_portable_environment_leaves_dev_cache_env_alone(monkeypatch): import app.core.config as config @@ -82,3 +94,187 @@ def test_configure_portable_environment_leaves_dev_cache_env_alone(monkeypatch): monkeypatch.delenv("XDG_CACHE_HOME", raising=False) monkeypatch.delenv("TORCH_HOME", raising=False) importlib.reload(original) + + +def test_high_quality_preset_sets_slower_demucs_defaults(monkeypatch): + import app.core.config as config + + original = config + monkeypatch.setenv("STEMDECK_QUALITY_PRESET", "high") + monkeypatch.delenv("STEMDECK_DEMUCS_MODEL", raising=False) + monkeypatch.delenv("STEMDECK_DEMUCS_SHIFTS", raising=False) + monkeypatch.delenv("STEMDECK_DEMUCS_PRE_GAIN_DB", raising=False) + try: + reloaded = importlib.reload(config) + assert reloaded.QUALITY_PRESET == "high" + assert reloaded.DEMUCS_MODEL == "htdemucs_ft" + assert reloaded.DEMUCS_SHIFTS == 4 + assert reloaded.DEMUCS_PRE_GAIN_DB == -6.0 + assert reloaded.DEMUCS_FLOAT32 is True + finally: + monkeypatch.delenv("STEMDECK_QUALITY_PRESET", raising=False) + importlib.reload(original) + + +def test_ultra_quality_preset_sets_slowest_demucs_defaults(monkeypatch): + import app.core.config as config + + original = config + monkeypatch.setenv("STEMDECK_QUALITY_PRESET", "ultra") + monkeypatch.delenv("STEMDECK_DEMUCS_MODEL", raising=False) + monkeypatch.delenv("STEMDECK_DEMUCS_SHIFTS", raising=False) + monkeypatch.delenv("STEMDECK_DEMUCS_PRE_GAIN_DB", raising=False) + monkeypatch.delenv("STEMDECK_DEMUCS_OVERLAP", raising=False) + try: + reloaded = importlib.reload(config) + assert reloaded.QUALITY_PRESET == "ultra" + assert reloaded.DEMUCS_MODEL == "htdemucs_ft" + assert reloaded.DEMUCS_SHIFTS == 16 + assert reloaded.DEMUCS_PRE_GAIN_DB == -8.0 + assert reloaded.DEMUCS_FLOAT32 is True + assert reloaded.DEMUCS_CLIP_MODE == "rescale" + assert reloaded.DEMUCS_OVERLAP == 0.5 + finally: + monkeypatch.delenv("STEMDECK_QUALITY_PRESET", raising=False) + importlib.reload(original) + + +def test_explicit_demucs_options_win_over_quality_preset(monkeypatch): + import app.core.config as config + + original = config + monkeypatch.setenv("STEMDECK_QUALITY_PRESET", "max") + monkeypatch.setenv("STEMDECK_DEMUCS_MODEL", "htdemucs_6s") + monkeypatch.setenv("STEMDECK_DEMUCS_SHIFTS", "2") + monkeypatch.setenv("STEMDECK_DEMUCS_PRE_GAIN_DB", "-3") + try: + reloaded = importlib.reload(config) + assert reloaded.QUALITY_PRESET == "max" + assert reloaded.DEMUCS_MODEL == "htdemucs_6s" + assert reloaded.DEMUCS_SHIFTS == 2 + assert reloaded.DEMUCS_PRE_GAIN_DB == -3.0 + finally: + monkeypatch.delenv("STEMDECK_QUALITY_PRESET", raising=False) + monkeypatch.delenv("STEMDECK_DEMUCS_MODEL", raising=False) + monkeypatch.delenv("STEMDECK_DEMUCS_SHIFTS", raising=False) + monkeypatch.delenv("STEMDECK_DEMUCS_PRE_GAIN_DB", raising=False) + importlib.reload(original) + + +def test_pipeline_concurrency_auto_is_conservative(monkeypatch): + import app.core.config as config + + monkeypatch.delenv("STEMDECK_PIPELINE_CONCURRENCY", raising=False) + monkeypatch.setattr(config.os, "cpu_count", lambda: 12) + monkeypatch.setattr(config, "_system_memory_gb", lambda: 32.0) + + assert config._detect_pipeline_concurrency("mps") == 1 + assert config._detect_pipeline_concurrency("cuda") == 1 + assert config._detect_pipeline_concurrency("cpu") == 2 + + monkeypatch.setattr(config, "_system_memory_gb", lambda: 16.0) + assert config._detect_pipeline_concurrency("cpu") == 1 + + +def test_pipeline_concurrency_env_override(monkeypatch): + import app.core.config as config + + monkeypatch.setenv("STEMDECK_PIPELINE_CONCURRENCY", "3") + assert config._detect_pipeline_concurrency("mps") == 3 + + +def test_demucs_device_choice_normalization_and_resolution(monkeypatch): + import app.core.config as config + + monkeypatch.setattr(config, "DEMUCS_DEVICE", "mps", raising=False) + assert config.normalize_demucs_device_choice("CPU") == "cpu" + assert config.normalize_demucs_device_choice("wat") == "auto" + assert config.resolve_demucs_device_choice("auto") == "mps" + assert config.resolve_demucs_device_choice("cpu") == "cpu" + + monkeypatch.setenv("STEMDECK_PIPELINE_CONCURRENCY", "0") + assert config._detect_pipeline_concurrency("cpu") == 1 + + monkeypatch.setenv("STEMDECK_PIPELINE_CONCURRENCY", "99") + assert config._detect_pipeline_concurrency("cpu") == 4 + + +def test_high_quality_preset_supports_four_stems(): + from app.core.config import ( + bass_repair_enabled_for_preset, + normalize_stem_denoise_preset, + phase_repair_enabled_for_preset, + phase_repair_max_blend_for_preset, + stem_denoise_filter_for_preset, + stem_gate_enabled_for_preset, + stem_names_for_quality_preset, + wav_codec_for_quality_preset, + ) + + assert stem_names_for_quality_preset("standard") == ( + "vocals", + "drums", + "bass", + "guitar", + "piano", + "other", + ) + assert stem_names_for_quality_preset("high") == ("vocals", "drums", "bass", "other") + assert stem_names_for_quality_preset("max") == ("vocals", "drums", "bass", "other") + assert stem_names_for_quality_preset("ultra") == ("vocals", "drums", "bass", "other") + assert wav_codec_for_quality_preset("standard") == "pcm_s16le" + assert wav_codec_for_quality_preset("high") == "pcm_f32le" + assert wav_codec_for_quality_preset("max") == "pcm_f32le" + assert wav_codec_for_quality_preset("ultra") == "pcm_f32le" + assert bass_repair_enabled_for_preset("standard") is False + assert bass_repair_enabled_for_preset("high") is True + assert bass_repair_enabled_for_preset("max") is True + assert bass_repair_enabled_for_preset("ultra") is True + assert phase_repair_enabled_for_preset("standard") is False + assert phase_repair_enabled_for_preset("high") is True + assert phase_repair_enabled_for_preset("max") is True + assert phase_repair_enabled_for_preset("ultra") is True + assert stem_gate_enabled_for_preset("standard") is True + assert stem_gate_enabled_for_preset("high") is True + assert stem_gate_enabled_for_preset("max") is True + assert stem_gate_enabled_for_preset("ultra") is True + assert phase_repair_max_blend_for_preset("standard") == 0.42 + assert phase_repair_max_blend_for_preset("high") == 0.65 + assert phase_repair_max_blend_for_preset("max") == 0.9 + assert phase_repair_max_blend_for_preset("ultra") == 0.95 + assert normalize_stem_denoise_preset("light") == "light" + assert normalize_stem_denoise_preset("STRONG") == "strong" + assert normalize_stem_denoise_preset("watery") == "off" + assert stem_denoise_filter_for_preset("off") is None + assert "afftdn=" in (stem_denoise_filter_for_preset("light") or "") + + +def test_bass_repair_env_override(monkeypatch): + from app.core.config import bass_repair_enabled_for_preset + + monkeypatch.setenv("STEMDECK_BASS_REPAIR", "0") + assert bass_repair_enabled_for_preset("high") is False + monkeypatch.setenv("STEMDECK_BASS_REPAIR", "1") + assert bass_repair_enabled_for_preset("standard") is True + + +def test_phase_repair_env_override(monkeypatch): + from app.core.config import phase_repair_enabled_for_preset, phase_repair_max_blend_for_preset + + monkeypatch.setenv("STEMDECK_PHASE_REPAIR", "0") + assert phase_repair_enabled_for_preset("high") is False + monkeypatch.setenv("STEMDECK_PHASE_REPAIR", "1") + assert phase_repair_enabled_for_preset("standard") is True + monkeypatch.setenv("STEMDECK_PHASE_REPAIR_MAX_BLEND", "0.8") + assert phase_repair_max_blend_for_preset("high") == 0.8 + monkeypatch.setenv("STEMDECK_PHASE_REPAIR_MAX_BLEND", "2") + assert phase_repair_max_blend_for_preset("max") == 1.0 + + +def test_stem_gate_env_override(monkeypatch): + from app.core.config import stem_gate_enabled_for_preset + + monkeypatch.setenv("STEMDECK_STEM_GATE", "0") + assert stem_gate_enabled_for_preset("high") is False + monkeypatch.setenv("STEMDECK_STEM_GATE", "1") + assert stem_gate_enabled_for_preset("standard") is True diff --git a/tests/test_health_api.py b/tests/test_health_api.py index fd38368..6c68d79 100644 --- a/tests/test_health_api.py +++ b/tests/test_health_api.py @@ -1,5 +1,8 @@ from __future__ import annotations +from importlib.metadata import PackageNotFoundError +from unittest.mock import patch + from fastapi.testclient import TestClient @@ -11,9 +14,34 @@ def test_health_endpoints_report_ok(): r = client.get(path) assert r.status_code == 200 body = r.json() - assert body["name"] == "StemDeck" + assert body["name"] == "LayerLab" assert body["status"] == "ok" assert body["version"] assert "ffmpeg_configured" in body assert "jobs_dir" not in body assert "data_dir" not in body + + +def test_license_and_notice_are_served(): + from app.main import app + + with TestClient(app) as client: + license_response = client.get("/LICENSE") + assert license_response.status_code == 200 + assert "Apache License" in license_response.text + + notice_response = client.get("/NOTICE") + assert notice_response.status_code == 200 + assert "unofficial modified fork test build" in notice_response.text + assert "https://github.com/stemdeckapp/stemdeck" in notice_response.text + + +def test_app_version_uses_layerlab_distribution_name(): + from app import main + + def fake_version(name: str) -> str: + assert name == "layerlab" + raise PackageNotFoundError + + with patch.object(main, "package_version", side_effect=fake_version): + assert main.app_version() diff --git a/tests/test_job_progress.py b/tests/test_job_progress.py new file mode 100644 index 0000000..6011616 --- /dev/null +++ b/tests/test_job_progress.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import time + +from app.core.models import Job, _set +from app.pipeline.progress import set_stage_progress + + +def test_job_state_includes_progress_percent_and_eta(): + job = Job(id="abcdefabcdef", status="separating", progress=0.5) + job.status_started_at = time.time() - 20 + + state = job.to_state() + + assert state["progress_percent"] == 50 + assert state["elapsed_seconds"] >= 19 + assert state["total_elapsed_seconds"] >= 0 + assert 18 <= state["eta_seconds"] <= 22 + + +def test_job_eta_resets_on_status_change_and_hides_when_done(): + job = Job(id="abcdefabcdef", status="queued", progress=0.0) + old_started_at = job.status_started_at + time.sleep(0.01) + + _set(job, status="separating", progress=0.0) + + assert job.status_started_at > old_started_at + assert job.to_state()["eta_seconds"] is None + + _set(job, status="done", progress=1.0) + + state = job.to_state() + assert state["progress_percent"] == 100 + assert state["eta_seconds"] is None + + +def test_processing_timer_starts_independently_from_progress_updates(): + job = Job(id="abcdefabcdef", status="queued", progress=0.0) + + _set(job, status="processing", stage="Waiting for audio worker...") + + assert job.processing_started_at is not None + assert job.progress_started_at is None + assert job.to_state()["processing_elapsed_seconds"] >= 0 + + +def test_terminal_job_elapsed_time_is_frozen(): + job = Job(id="abcdefabcdef", status="processing", progress=0.5) + job.progress_started_at = time.time() - 20 + + _set(job, status="done", progress=1.0) + first = job.to_state()["processing_elapsed_seconds"] + time.sleep(0.01) + second = job.to_state()["processing_elapsed_seconds"] + + assert job.completed_at is not None + assert first == second + assert 19 <= first <= 21 + + +def test_job_eta_uses_overall_progress_clock_after_status_change(): + job = Job(id="abcdefabcdef", status="processing", progress=0.5) + job.progress_started_at = time.time() - 20 + job.status_started_at = time.time() - 2 + + eta = job.to_state()["eta_seconds"] + + assert eta is not None + assert 18 <= eta <= 22 + + +def test_pipeline_stage_progress_is_overall_and_monotonic(): + job = Job(id="abcdefabcdef") + + set_stage_progress(job, "acquire", 1.0, status="downloading", stage="Download complete") + assert job.to_state()["progress_percent"] == 12 + + set_stage_progress(job, "analyze", 0.0, status="analyzing", stage="Analyzing") + assert job.to_state()["progress_percent"] == 12 + + set_stage_progress(job, "analyze", 1.0, stage="Analysis complete") + assert job.to_state()["progress_percent"] == 24 + + set_stage_progress(job, "separate", 0.0, status="separating", stage="Separating stems") + assert job.to_state()["progress_percent"] == 30 + + set_stage_progress(job, "separate", 1.0, stage="Separating 100%") + assert job.to_state()["progress_percent"] == 82 + + # Post-processing starts at local 0%, but the overall bar must not jump + # back to 0 after Demucs finishes. + set_stage_progress(job, "collect", 0.0, status="processing", stage="Collecting stems") + assert job.to_state()["progress_percent"] == 82 + + set_stage_progress(job, "gate", 1.0, stage="Stem gate complete") + assert job.to_state()["progress_percent"] == 96 + assert job.logs[-1]["message"] == "Stem gate complete" + assert job.logs[-1]["progress_percent"] == 96 + + +def test_job_state_includes_repair_metrics(): + job = Job( + id="abcdefabcdef", + bass_repair_applied=True, + phase_repair_applied=True, + phase_repair_residual_ratio=0.37, + stem_denoise_preset="light", + stem_denoise_applied=True, + stem_gate_applied=True, + stem_gate_threshold_db=-54.0, + ) + + state = job.to_state() + + assert state["bass_repair_applied"] is True + assert state["phase_repair_applied"] is True + assert state["phase_repair_residual_ratio"] == 0.37 + assert state["stem_denoise_preset"] == "light" + assert state["stem_denoise_applied"] is True + assert state["stem_gate_applied"] is True + assert state["stem_gate_threshold_db"] == -54.0 + + +def test_job_state_includes_detected_beat_times(): + progression = [{"label": "C", "start": 0.0, "end": 2.0, "confidence": 0.9}] + job = Job( + id="abcdefabcdef", + bpm=128, + tempo_stability=92, + beat_times=[0.511, 0.976, 1.44], + chord_progression=progression, + chord_midi_url="/api/jobs/abcdefabcdef/chords.mid", + ) + + state = job.to_state() + + assert state["bpm"] == 128 + assert state["tempo_stability"] == 92 + assert state["beat_times"] == [0.511, 0.976, 1.44] + assert state["chord_progression"] == progression + assert state["chord_midi_url"] == "/api/jobs/abcdefabcdef/chords.mid" + + +def test_job_state_includes_profile_identity(): + job = Job( + id="abcdefabcdef", + selected_stems=["vocals", "bass"], + quality_preset="max", + stem_denoise_preset="strong", + demucs_device="mps", + demucs_device_resolved="mps", + ) + + state = job.to_state() + + assert state["profile_key"] == "quality=max|denoise=strong|device=mps:mps|stems=vocals,bass" + assert state["profile_label"] == "Max / Strong denoise / Apple GPU / Vocals+Bass" diff --git a/tests/test_jobs_api.py b/tests/test_jobs_api.py index e492581..243fc88 100644 --- a/tests/test_jobs_api.py +++ b/tests/test_jobs_api.py @@ -1,7 +1,9 @@ from __future__ import annotations +import asyncio import io import json +import subprocess from unittest.mock import patch import pytest @@ -73,6 +75,120 @@ def test_post_accepts_youtube_url(client): assert len(r.json()["job_id"]) == 12 +def test_post_accepts_quality_preset(client): + r = client.post( + "/api/jobs", + json={"url": "https://youtu.be/dQw4w9WgXcQ", "quality_preset": "ultra"}, + ) + assert r.status_code == 200 + assert _jobs[r.json()["job_id"]].quality_preset == "ultra" + + +def test_post_accepts_stem_denoise_preset(client): + r = client.post( + "/api/jobs", + json={"url": "https://youtu.be/dQw4w9WgXcQ", "stem_denoise": "strong"}, + ) + assert r.status_code == 200 + assert _jobs[r.json()["job_id"]].stem_denoise_preset == "strong" + + +def test_post_accepts_demucs_device_choice(client): + r = client.post( + "/api/jobs", + json={"url": "https://youtu.be/dQw4w9WgXcQ", "demucs_device": "cpu"}, + ) + assert r.status_code == 200 + job = _jobs[r.json()["job_id"]] + assert job.demucs_device == "cpu" + assert job.demucs_device_resolved == "cpu" + + +def test_same_source_can_create_distinct_extraction_profiles(client): + url = "https://youtu.be/dQw4w9WgXcQ" + standard = client.post( + "/api/jobs", + json={ + "url": url, + "quality_preset": "standard", + "stem_denoise": "off", + "demucs_device": "cpu", + "stems": ["vocals", "drums", "bass", "other"], + }, + ) + max_clean = client.post( + "/api/jobs", + json={ + "url": url, + "quality_preset": "max", + "stem_denoise": "strong", + "demucs_device": "cpu", + "stems": ["vocals", "bass"], + }, + ) + + assert standard.status_code == 200 + assert max_clean.status_code == 200 + first = _jobs[standard.json()["job_id"]] + second = _jobs[max_clean.json()["job_id"]] + + assert first.id != second.id + assert first.source_url == second.source_url == "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + assert first.demucs_device == "cpu" + assert first.demucs_device_resolved == "cpu" + assert first.profile_key() == "quality=standard|denoise=off|device=cpu:cpu|stems=vocals,drums,bass,other" + assert second.profile_key() == "quality=max|denoise=strong|device=cpu:cpu|stems=vocals,bass" + + +def test_post_invalid_stem_denoise_falls_back_to_off(client): + r = client.post( + "/api/jobs", + json={"url": "https://youtu.be/dQw4w9WgXcQ", "stem_denoise": "destructive"}, + ) + assert r.status_code == 200 + assert _jobs[r.json()["job_id"]].stem_denoise_preset == "off" + + +def test_quality_preset_filters_unsupported_stems(client): + r = client.post( + "/api/jobs", + json={ + "url": "https://youtu.be/dQw4w9WgXcQ", + "quality_preset": "high", + "stems": ["guitar", "piano"], + }, + ) + assert r.status_code == 200 + assert _jobs[r.json()["job_id"]].selected_stems == ["vocals", "drums", "bass", "other"] + + +def test_probe_duration_falls_back_to_ffmpeg_when_ffprobe_missing(monkeypatch, tmp_path): + import app.api.jobs as jobs + + audio = tmp_path / "audio.wav" + audio.write_bytes(b"wav") + calls = [] + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + if cmd[0] == "ffprobe": + raise FileNotFoundError("ffprobe") + return subprocess.CompletedProcess( + cmd, + 1, + stdout="", + stderr="Duration: 00:01:02.50, start: 0.000000, bitrate: 1411 kb/s\n", + ) + + monkeypatch.setattr(jobs, "ffprobe_executable", lambda: "ffprobe") + monkeypatch.setattr(jobs, "ffmpeg_executable", lambda: "ffmpeg") + monkeypatch.setattr(jobs.subprocess, "run", fake_run) + + assert jobs._probe_duration(audio) == 62.5 + assert calls[0][0][0] == "ffprobe" + assert calls[1][0][:3] == ["ffmpeg", "-hide_banner", "-i"] + + def test_get_unknown_job_returns_404(client): r = client.get("/api/jobs/000000000000") assert r.status_code == 404 @@ -130,6 +246,67 @@ def test_upload_503_when_queue_full(upload_client): assert r.status_code == 503 +def test_active_jobs_include_queue_positions(client): + first = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"}) + second = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"}) + + assert first.status_code == 200 + assert second.status_code == 200 + + r = client.get("/api/jobs/active") + assert r.status_code == 200 + body = r.json() + assert [item["job_id"] for item in body] == [first.json()["job_id"], second.json()["job_id"]] + assert [item["queue_position"] for item in body] == [1, 2] + assert [item["queue_size"] for item in body] == [2, 2] + + +def test_cancel_queued_job_opens_queue_slot(client): + created = [ + client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"}).json()["job_id"] + for _ in range(MAX_PENDING_JOBS) + ] + + r = client.post(f"/api/jobs/{created[0]}/cancel") + assert r.status_code == 200 + assert r.json()["status"] == "cancelled" + + replacement = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"}) + assert replacement.status_code == 200 + + +def test_cancel_queued_job_sets_completion_timestamp(client): + created = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"}) + job_id = created.json()["job_id"] + + response = client.post(f"/api/jobs/{job_id}/cancel") + + assert response.status_code == 200 + assert response.json()["status"] == "cancelled" + assert response.json()["completed_at"] is not None + assert _jobs[job_id].logs[-1]["message"] == "Cancellation requested" + + +@pytest.mark.asyncio +async def test_shutdown_marks_tracked_pipeline_jobs_cancelled(): + import app.api.jobs as jobs_mod + + job = Job(id="abcdefabcde4") + + async def wait_forever(): + await asyncio.Event().wait() + + task = asyncio.create_task(wait_forever()) + jobs_mod._track_pipeline_task(task, job) + + await jobs_mod.shutdown_pipeline_tasks() + await asyncio.sleep(0) + + assert job.cancel_requested is True + assert task.cancelled() + assert task not in jobs_mod._pipeline_tasks + + # ─── File upload ───────────────────────────────────────────────────────────── @@ -163,6 +340,28 @@ def test_upload_mp3_returns_job_id(upload_client): assert len(r.json()["job_id"]) == 12 +def test_upload_accepts_quality_preset(upload_client): + data = io.BytesIO(b"ID3" + b"\x00" * 128) + r = upload_client.post( + "/api/jobs", + data={"quality_preset": "high"}, + files={"file": ("my_track.mp3", data, "audio/mpeg")}, + ) + assert r.status_code == 200 + assert _jobs[r.json()["job_id"]].quality_preset == "high" + + +def test_upload_accepts_stem_denoise_preset(upload_client): + data = io.BytesIO(b"ID3" + b"\x00" * 128) + r = upload_client.post( + "/api/jobs", + data={"stem_denoise": "light"}, + files={"file": ("my_track.mp3", data, "audio/mpeg")}, + ) + assert r.status_code == 200 + assert _jobs[r.json()["job_id"]].stem_denoise_preset == "light" + + def test_upload_wav_returns_job_id(upload_client): data = io.BytesIO(b"RIFF" + b"\x00" * 128) r = upload_client.post( @@ -183,6 +382,35 @@ def test_upload_flac_returns_job_id(upload_client): assert "job_id" in r.json() +def test_upload_m4a_returns_job_id(upload_client): + data = io.BytesIO(b"\x00\x00\x00\x18ftypM4A " + b"\x00" * 128) + r = upload_client.post( + "/api/jobs", + files={"file": ("my_track.m4a", data, "audio/mp4")}, + ) + assert r.status_code == 200 + job = _jobs[r.json()["job_id"]] + assert job.title == "my_track" + assert job.source_url == "local:my_track" + + +def test_upload_copy_failure_removes_partial_job_dir(upload_client, tmp_path, monkeypatch): + import app.api.jobs as jobs_mod + + def fail_copy(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr(jobs_mod, "_copy_to_dest", fail_copy) + response = upload_client.post( + "/api/jobs", + files={"file": ("my_track.mp3", io.BytesIO(b"ID3data"), "audio/mpeg")}, + ) + + assert response.status_code == 500 + assert response.json()["detail"] == "Could not store uploaded file" + assert list(tmp_path.iterdir()) == [] + + # ─── Sections endpoint ──────────────────────────────────────────────────────── @@ -238,6 +466,16 @@ def test_sections_invalid_color_returns_422(client, done_job): assert r.status_code == 422 +def test_sections_reject_invalid_hex_color_length(client, done_job): + payload = { + "sections": [ + {"id": "sec1", "name": "Intro", "start": 0.0, "end": 10.0, "color": "#12345"} + ] + } + response = client.patch(f"/api/jobs/{done_job.id}/sections", json=payload) + assert response.status_code == 422 + + def test_sections_invalid_id_returns_422(client, done_job): payload = { "sections": [{"id": "has space", "name": "x", "start": 0.0, "end": 5.0, "color": "#fff"}] @@ -246,6 +484,53 @@ def test_sections_invalid_id_returns_422(client, done_job): assert r.status_code == 422 +def test_sections_reject_end_before_start(client, done_job): + payload = { + "sections": [{"id": "bad", "name": "Bad", "start": 8.0, "end": 3.0, "color": "#fff"}] + } + response = client.patch(f"/api/jobs/{done_job.id}/sections", json=payload) + assert response.status_code == 422 + + +def test_sections_reject_duplicate_ids(client, done_job): + payload = { + "sections": [ + {"id": "same", "name": "A", "start": 0.0, "end": 1.0, "color": "#fff"}, + {"id": "same", "name": "B", "start": 1.0, "end": 2.0, "color": "#fff"}, + ] + } + response = client.patch(f"/api/jobs/{done_job.id}/sections", json=payload) + assert response.status_code == 422 + + +def test_sections_write_failure_keeps_previous_in_memory_state( + client, done_job, monkeypatch +): + import app.api.jobs as jobs_mod + + done_job.sections = [{"id": "old"}] + monkeypatch.setattr( + jobs_mod, + "atomic_write_text", + lambda *args, **kwargs: (_ for _ in ()).throw(OSError("disk full")), + ) + payload = { + "sections": [{"id": "new", "name": "New", "start": 0.0, "end": 2.0, "color": "#fff"}] + } + + response = client.patch(f"/api/jobs/{done_job.id}/sections", json=payload) + + assert response.status_code == 500 + assert done_job.sections == [{"id": "old"}] + + +def test_sections_reject_running_job(client): + job = Job(id="abcdefabcde3", status="processing") + _jobs[job.id] = job + response = client.patch(f"/api/jobs/{job.id}/sections", json={"sections": []}) + assert response.status_code == 409 + + # ─── SSE job_id validation ──────────────────────────────────────────────────── diff --git a/tests/test_logs_api.py b/tests/test_logs_api.py new file mode 100644 index 0000000..b8d336a --- /dev/null +++ b/tests/test_logs_api.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.core.joblog import add_job_log +from app.core.models import Job +from app.core.registry import _jobs + + +@pytest.fixture(autouse=True) +def _isolate_registry(): + _jobs.clear() + yield + _jobs.clear() + + +def test_job_logs_endpoint_returns_structured_entries(): + job = Job(id="abcdefabcdef", title="Logged song") + _jobs[job.id] = job + add_job_log(job, "Separating stems", stage="separate", progress=0.42) + + from app.main import app + + with TestClient(app) as client: + response = client.get(f"/api/logs?job_id={job.id}") + + assert response.status_code == 200 + payload = response.json() + assert payload["entries"][-1]["message"] == "Separating stems" + assert payload["entries"][-1]["stage"] == "separate" + assert payload["entries"][-1]["progress_percent"] == 42 + assert payload["jobs"][0]["job_id"] == job.id + + +def test_job_logs_endpoint_rejects_unknown_job(): + from app.main import app + + with TestClient(app) as client: + response = client.get("/api/logs?job_id=abcdefabcdef") + + assert response.status_code == 404 + + +def test_job_log_is_bounded_and_deduplicates_identical_entries(): + job = Job(id="abcdefabcdea") + add_job_log(job, "Queued", stage="queued", progress=0) + add_job_log(job, "Queued", stage="queued", progress=0) + for index in range(350): + add_job_log(job, f"Progress {index}", stage="separate", progress=index / 350) + + assert len(job.logs) == 300 + assert job.logs[-1]["message"] == "Progress 349" diff --git a/tests/test_pipeline_collect.py b/tests/test_pipeline_collect.py index f69810f..89ab649 100644 --- a/tests/test_pipeline_collect.py +++ b/tests/test_pipeline_collect.py @@ -6,8 +6,25 @@ from pathlib import Path import numpy as np +import soundfile as sf -from app.pipeline.collect import _PEAK_POINTS, compute_stem_peaks +from app.core.models import Job +from app.pipeline.collect import ( + _PEAK_POINTS, + PhaseRepairResult, + _blend_bass_dropout_repair, + _blend_phase_residual, + _write_bass_residual_candidate, + collect, + compute_stem_peaks, + denoise_stem_outputs, + gate_stem_outputs, + make_selected_mix, + repair_bass_dropouts, + repair_phase_coherence, + restore_demucs_gain, + stabilize_stem_outputs, +) def _write_wav(path: Path, samples: list[float], sample_rate: int = 44100) -> None: @@ -80,6 +97,22 @@ def test_no_output_when_all_stems_missing(tmp_path): assert not (stems_dir / "peaks.json").exists() +def test_collect_reports_post_demucs_progress(tmp_path): + job_dir = tmp_path / "job" + job_dir.mkdir() + stems_root = tmp_path / "demucs-out" + stems_root.mkdir(parents=True) + for name in ("vocals", "drums", "bass"): + (stems_root / f"{name}.wav").write_bytes(b"wav") + job = Job(id="abcdefabcdef", progress=0.82) + + found = collect(job, stems_root, job_dir) + + assert found == ["vocals", "drums", "bass"] + assert job.progress > 0.82 + assert job.stage_message == "Cleaning separation workspace..." + + def test_writes_atomically(tmp_path): """No partial peaks.json.tmp should survive a successful run.""" stems_dir = tmp_path / "stems" @@ -104,3 +137,421 @@ def test_non_fatal_on_corrupt_wav(tmp_path): data = json.loads((stems_dir / "peaks.json").read_text()) assert "drums" in data assert "vocals" not in data + + +def test_restore_demucs_gain_applies_inverse_pregain(tmp_path, monkeypatch): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + stem = stems_dir / "vocals.wav" + stem.write_bytes(b"wav") + calls = [] + + def fake_run_ffmpeg(job, cmd): + calls.append((job, cmd)) + Path(cmd[-1]).write_bytes(b"boosted") + return True + + import app.pipeline.collect as collect_mod + + monkeypatch.setattr(collect_mod, "_run_ffmpeg", fake_run_ffmpeg) + job = Job(id="abcdefabcdef", quality_preset="high") + + restore_demucs_gain(job, stems_dir, ["vocals"]) + + assert stem.read_bytes() == b"boosted" + _, cmd = calls[0] + assert cmd[cmd.index("-filter:a") : cmd.index("-filter:a") + 2] == [ + "-filter:a", + "volume=6dB", + ] + assert cmd[cmd.index("-c:a") : cmd.index("-c:a") + 2] == ["-c:a", "pcm_f32le"] + + +def test_restore_demucs_gain_uses_actual_adaptive_gain(tmp_path, monkeypatch): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + stem = stems_dir / "bass.wav" + stem.write_bytes(b"wav") + calls = [] + + def fake_run_ffmpeg(job, cmd): + calls.append(cmd) + Path(cmd[-1]).write_bytes(b"boosted") + return True + + import app.pipeline.collect as collect_mod + + monkeypatch.setattr(collect_mod, "_run_ffmpeg", fake_run_ffmpeg) + job = Job(id="abcdefabcdef", quality_preset="high", demucs_gain_db=-11.0) + + restore_demucs_gain(job, stems_dir, ["bass"]) + + assert stem.read_bytes() == b"boosted" + cmd = calls[0] + assert cmd[cmd.index("-filter:a") : cmd.index("-filter:a") + 2] == [ + "-filter:a", + "volume=11dB", + ] + + +def test_restore_demucs_gain_noops_without_pregain(tmp_path, monkeypatch): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + (stems_dir / "vocals.wav").write_bytes(b"wav") + + import app.pipeline.collect as collect_mod + + monkeypatch.setattr( + collect_mod, + "_run_ffmpeg", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("unexpected ffmpeg")), + ) + + restore_demucs_gain(Job(id="abcdefabcdef"), stems_dir, ["vocals"]) + + +def test_high_quality_mix_uses_float32_wav_codec(tmp_path, monkeypatch): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + for name in ("vocals", "drums"): + (stems_dir / f"{name}.wav").write_bytes(b"wav") + calls = [] + + def fake_run_ffmpeg(job, cmd): + calls.append(cmd) + Path(cmd[-1]).write_bytes(b"mix") + return True + + import app.pipeline.collect as collect_mod + + monkeypatch.setattr(collect_mod, "_run_ffmpeg", fake_run_ffmpeg) + job = Job(id="abcdefabcdef", quality_preset="high", selected_stems=["vocals", "drums"]) + + out = make_selected_mix(job, stems_dir, ["vocals", "drums"]) + + assert out == stems_dir / "mix.wav" + cmd = calls[0] + assert cmd[cmd.index("-c:a") : cmd.index("-c:a") + 2] == ["-c:a", "pcm_f32le"] + + +def test_denoise_stem_outputs_replaces_all_stems_after_success(tmp_path, monkeypatch): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + for name in ("vocals", "drums"): + (stems_dir / f"{name}.wav").write_bytes(f"old-{name}".encode()) + calls = [] + + def fake_run_ffmpeg(job, cmd): + calls.append(cmd) + Path(cmd[-1]).write_bytes(b"clean") + return True + + import app.pipeline.collect as collect_mod + + monkeypatch.setattr(collect_mod, "_run_ffmpeg", fake_run_ffmpeg) + job = Job(id="abcdefabcdef", quality_preset="high", stem_denoise_preset="light") + + assert denoise_stem_outputs(job, stems_dir, ["vocals", "drums"]) + + assert (stems_dir / "vocals.wav").read_bytes() == b"clean" + assert (stems_dir / "drums.wav").read_bytes() == b"clean" + assert job.progress >= 0.95 + assert len(calls) == 2 + first = calls[0] + assert "afftdn=" in first[first.index("-filter:a") + 1] + assert first[first.index("-c:a") : first.index("-c:a") + 2] == ["-c:a", "pcm_f32le"] + assert not list(stems_dir.glob("*.denoise.wav")) + + +def test_denoise_stem_outputs_preserves_originals_on_failure(tmp_path, monkeypatch): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + vocals = stems_dir / "vocals.wav" + drums = stems_dir / "drums.wav" + vocals.write_bytes(b"old-vocals") + drums.write_bytes(b"old-drums") + + def fake_run_ffmpeg(job, cmd): + Path(cmd[-1]).write_bytes(b"partial") + return False + + import app.pipeline.collect as collect_mod + + monkeypatch.setattr(collect_mod, "_run_ffmpeg", fake_run_ffmpeg) + job = Job(id="abcdefabcdef", stem_denoise_preset="strong") + + assert not denoise_stem_outputs(job, stems_dir, ["vocals", "drums"]) + + assert vocals.read_bytes() == b"old-vocals" + assert drums.read_bytes() == b"old-drums" + assert not list(stems_dir.glob("*.denoise.wav")) + + +def test_denoise_stem_outputs_noops_when_off(tmp_path, monkeypatch): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + (stems_dir / "vocals.wav").write_bytes(b"wav") + + import app.pipeline.collect as collect_mod + + monkeypatch.setattr( + collect_mod, + "_run_ffmpeg", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("unexpected denoise")), + ) + + assert not denoise_stem_outputs(Job(id="abcdefabcdef"), stems_dir, ["vocals"]) + + +def test_gate_stem_outputs_mutes_near_silent_regions_without_shortening(tmp_path): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + sr = 44100 + t = np.arange(sr // 2, dtype=np.float32) / sr + quiet = np.full((sr // 2, 2), 1e-5, dtype=np.float32) + tone = (np.sin(2 * np.pi * 220 * t) * 0.2).astype(np.float32) + tone = np.column_stack((tone, tone)) + samples = np.vstack((quiet, tone, quiet)) + sf.write(stems_dir / "vocals.wav", samples, sr, subtype="FLOAT") + + job = Job(id="abcdefabcdef", quality_preset="high") + + assert gate_stem_outputs(job, stems_dir, ["vocals"]) + + processed, out_sr = sf.read(stems_dir / "vocals.wav", dtype="float32", always_2d=True) + assert out_sr == sr + assert len(processed) == len(samples) + assert float(np.max(np.abs(processed[: sr // 4]), initial=0.0)) < 1e-6 + assert float(np.mean(np.abs(processed[sr // 2 : sr]))) > 0.05 + assert job.stem_gate_threshold_db == -54.0 + assert job.progress >= 0.96 + + +def test_gate_stem_outputs_noops_when_disabled(tmp_path, monkeypatch): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + samples = np.full((1024, 2), 1e-5, dtype=np.float32) + sf.write(stems_dir / "vocals.wav", samples, 44100, subtype="FLOAT") + monkeypatch.setenv("STEMDECK_STEM_GATE", "0") + + assert not gate_stem_outputs(Job(id="abcdefabcdef", quality_preset="high"), stems_dir, ["vocals"]) + + processed, _ = sf.read(stems_dir / "vocals.wav", dtype="float32", always_2d=True) + np.testing.assert_allclose(processed, samples, atol=1e-7) + + +def test_bass_residual_candidate_subtracts_non_bass_stems(tmp_path, monkeypatch): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + source = tmp_path / "source.wav" + source.write_bytes(b"source") + for name in ("bass", "drums", "vocals"): + (stems_dir / f"{name}.wav").write_bytes(b"wav") + calls = [] + + def fake_run_ffmpeg(job, cmd): + calls.append(cmd) + Path(cmd[-1]).write_bytes(b"residual") + return True + + import app.pipeline.collect as collect_mod + + monkeypatch.setattr(collect_mod, "_run_ffmpeg", fake_run_ffmpeg) + out = stems_dir / "bass.residual.wav" + + assert _write_bass_residual_candidate( + Job(id="abcdefabcdef", quality_preset="high"), + source, + stems_dir, + ["bass", "drums", "vocals"], + out, + ) + + cmd = calls[0] + graph = cmd[cmd.index("-filter_complex") + 1] + assert "[1:a]volume=-1.0[neg1]" in graph + assert "[2:a]volume=-1.0[neg2]" in graph + assert "amix=inputs=3:normalize=0" in graph + assert "lowpass=f=" in graph + assert str(stems_dir / "bass.wav") not in cmd + assert out.read_bytes() == b"residual" + + +def test_blend_bass_dropout_repair_fills_only_missing_section(tmp_path): + sr = 8000 + t = np.arange(sr, dtype=np.float32) / sr + residual = (np.sin(2 * np.pi * 90 * t) * 0.34).astype(np.float32) + bass = residual.copy() + bass[int(0.4 * sr) : int(0.55 * sr)] = 0.0 + + bass_path = tmp_path / "bass.wav" + residual_path = tmp_path / "residual.wav" + repaired_path = tmp_path / "bass.repaired.wav" + sf.write(bass_path, bass, sr, subtype="FLOAT") + sf.write(residual_path, residual, sr, subtype="FLOAT") + + changed = _blend_bass_dropout_repair( + bass_path, + residual_path, + repaired_path, + max_blend=0.8, + trigger_ratio=1.2, + subtype="FLOAT", + ) + + assert changed + repaired, _ = sf.read(repaired_path, dtype="float32") + dropout = slice(int(0.43 * sr), int(0.52 * sr)) + stable = slice(int(0.1 * sr), int(0.25 * sr)) + assert float(np.mean(np.abs(repaired[dropout]))) > float(np.mean(np.abs(bass[dropout]))) + 0.05 + assert float(np.max(np.abs(repaired[stable] - bass[stable]))) < 0.01 + + +def test_repair_bass_dropouts_noops_for_standard_preset(tmp_path, monkeypatch): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + (stems_dir / "bass.wav").write_bytes(b"wav") + + import app.pipeline.collect as collect_mod + + monkeypatch.setattr( + collect_mod, + "_write_bass_residual_candidate", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("unexpected repair")), + ) + + assert not repair_bass_dropouts( + Job(id="abcdefabcdef", quality_preset="standard"), + tmp_path / "source.wav", + stems_dir, + ["bass", "drums"], + ) + + +def test_blend_phase_residual_reduces_stem_sum_error(tmp_path): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + sr = 8000 + t = np.arange(sr, dtype=np.float32) / sr + vocals = (np.sin(2 * np.pi * 220 * t) * 0.22).astype(np.float32) + drums = (np.sin(2 * np.pi * 880 * t) * 0.12).astype(np.float32) + missing_phase = (np.sin(2 * np.pi * 330 * t + 0.9) * 0.06).astype(np.float32) + source = vocals + drums + missing_phase + + reference_path = tmp_path / "source.phase.wav" + sf.write(reference_path, source, sr, subtype="FLOAT") + sf.write(stems_dir / "vocals.wav", vocals, sr, subtype="FLOAT") + sf.write(stems_dir / "drums.wav", drums, sr, subtype="FLOAT") + out_vocals = stems_dir / "vocals.phase.wav" + out_drums = stems_dir / "drums.phase.wav" + + result = _blend_phase_residual( + reference_path, + stems_dir, + ["vocals", "drums"], + [out_vocals, out_drums], + max_blend=1.0, + floor_db=-90.0, + subtype="FLOAT", + ) + + assert result.changed + assert result.residual_ratio is not None + repaired_vocals, _ = sf.read(out_vocals, dtype="float32") + repaired_drums, _ = sf.read(out_drums, dtype="float32") + before = source - (vocals + drums) + after = source - (repaired_vocals + repaired_drums) + assert float(np.mean(after * after)) < float(np.mean(before * before)) * 0.08 + assert result.residual_ratio < 0.08 + + +def test_repair_phase_coherence_noops_for_standard_preset(tmp_path, monkeypatch): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + (stems_dir / "vocals.wav").write_bytes(b"wav") + (stems_dir / "drums.wav").write_bytes(b"wav") + + import app.pipeline.collect as collect_mod + + monkeypatch.setattr( + collect_mod, + "_write_phase_reference", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("unexpected repair")), + ) + + assert not repair_phase_coherence( + Job(id="abcdefabcdef", quality_preset="standard"), + tmp_path / "source.wav", + tmp_path, + stems_dir, + ["vocals", "drums"], + ) + + +def test_repair_phase_coherence_replaces_changed_stems(tmp_path, monkeypatch): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + source = tmp_path / "source.wav" + source.write_bytes(b"source") + for name in ("vocals", "drums"): + (stems_dir / f"{name}.wav").write_bytes(b"old") + + import app.pipeline.collect as collect_mod + + def fake_write_reference(job, src, out): + assert src == source + out.write_bytes(b"reference") + return True + + def fake_blend(reference, stem_dir, names, out_paths, **kwargs): + assert reference.read_bytes() == b"reference" + assert names == ["vocals", "drums"] + assert kwargs["max_blend"] == 0.65 + for idx, out in enumerate(out_paths): + out.write_bytes(f"new-{idx}".encode()) + return PhaseRepairResult(True, 0.42) + + monkeypatch.setattr(collect_mod, "_write_phase_reference", fake_write_reference) + monkeypatch.setattr(collect_mod, "_blend_phase_residual", fake_blend) + + job = Job(id="abcdefabcdef", quality_preset="high") + + assert repair_phase_coherence( + job, + source, + tmp_path, + stems_dir, + ["vocals", "drums"], + ) + assert (stems_dir / "vocals.wav").read_bytes() == b"new-0" + assert (stems_dir / "drums.wav").read_bytes() == b"new-1" + assert job.phase_repair_applied is True + assert job.phase_repair_residual_ratio == 0.42 + assert not (tmp_path / "source.phase.wav").exists() + + +def test_stabilize_stem_outputs_removes_dc_and_limits_float_peak(tmp_path): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + sr = 44100 + t = np.linspace(0, 0.1, int(sr * 0.1), endpoint=False, dtype=np.float32) + hot = (np.sin(2 * np.pi * 90 * t) * 1.2 + 0.04).astype(np.float32) + sf.write(stems_dir / "bass.wav", hot, sr, subtype="FLOAT") + + stabilize_stem_outputs(Job(id="abcdefabcdef", quality_preset="high"), stems_dir, ["bass"]) + + data, _ = sf.read(stems_dir / "bass.wav", dtype="float32", always_2d=True) + assert abs(float(np.mean(data[:, 0]))) < 1e-3 + assert float(np.max(np.abs(data))) <= 0.981 + + +def test_stabilize_stem_outputs_noops_for_standard_preset(tmp_path): + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + path = stems_dir / "bass.wav" + sf.write(path, np.array([0.25, -0.25], dtype=np.float32), 44100, subtype="FLOAT") + before = path.read_bytes() + + stabilize_stem_outputs(Job(id="abcdefabcdef", quality_preset="standard"), stems_dir, ["bass"]) + + assert path.read_bytes() == before diff --git a/tests/test_pipeline_runner.py b/tests/test_pipeline_runner.py index 68bb384..f4fb1b4 100644 --- a/tests/test_pipeline_runner.py +++ b/tests/test_pipeline_runner.py @@ -7,7 +7,13 @@ from app.core.models import Job, JobCancelled from app.core.registry import _jobs -from app.pipeline.runner import run_local_pipeline, run_pipeline +from app.pipeline.runner import ( + _pipeline_lock_files, + _prepare_demucs_source, + _prepare_local_source, + run_local_pipeline, + run_pipeline, +) @pytest.mark.asyncio @@ -136,3 +142,109 @@ def boom(*args, **kwargs): assert job.status == "error" assert not (tmp_path / job.id).exists(), "job dir should be removed on local error" + + +def test_machine_lock_uses_one_file_per_concurrency_slot(tmp_path: Path, monkeypatch): + import app.pipeline.runner as runner + + base = tmp_path / "layerlab.lock" + monkeypatch.setenv("STEMDECK_PIPELINE_LOCK", str(base)) + monkeypatch.setattr(runner, "PIPELINE_CONCURRENCY", 3) + + assert _pipeline_lock_files() == ( + tmp_path / "layerlab.lock.0", + tmp_path / "layerlab.lock.1", + tmp_path / "layerlab.lock.2", + ) + + +def test_prepare_demucs_source_creates_pregain_working_copy(tmp_path: Path, monkeypatch): + import app.pipeline.runner as runner + + job = Job(id="abcdefabcde6") + job.quality_preset = "high" + source = tmp_path / "source.wav" + source.write_bytes(b"wav") + calls = [] + + class Result: + returncode = 0 + stderr = b"" + + def fake_run(job_arg, cmd, **kwargs): + assert job_arg is job + calls.append((cmd, kwargs)) + Path(cmd[-1]).write_bytes(b"processed") + return Result() + + monkeypatch.setattr(runner, "run_tracked_process", fake_run) + + dest = _prepare_demucs_source(job, source, tmp_path) + + assert dest == tmp_path / "source.demucs.wav" + assert dest.read_bytes() == b"processed" + cmd, kwargs = calls[0] + filter_chain = cmd[cmd.index("-filter:a") + 1] + assert "aresample=44100" in filter_chain + assert "highpass=f=12" in filter_chain + assert "volume=-6dB" in filter_chain + assert job.demucs_gain_db == -6.0 + assert cmd[cmd.index("-c:a") : cmd.index("-c:a") + 2] == ["-c:a", "pcm_f32le"] + assert kwargs["timeout"] == runner.TIMEOUT_FFMPEG + + +def test_prepare_demucs_source_uses_reversible_loudness_safety_gain( + tmp_path: Path, monkeypatch +): + import app.pipeline.runner as runner + + job = Job(id="abcdefabcde4", quality_preset="high", lufs=-7.0, peak_db=1.2) + source = tmp_path / "source.wav" + source.write_bytes(b"wav") + calls = [] + + class Result: + returncode = 0 + stderr = b"" + + def fake_run(job_arg, cmd, **kwargs): + assert job_arg is job + calls.append((cmd, kwargs)) + Path(cmd[-1]).write_bytes(b"processed") + return Result() + + monkeypatch.setattr(runner, "run_tracked_process", fake_run) + + _prepare_demucs_source(job, source, tmp_path) + + filter_chain = calls[0][0][calls[0][0].index("-filter:a") + 1] + assert "volume=-11dB" in filter_chain + assert job.demucs_gain_db == -11.0 + + +def test_prepare_local_source_keeps_float_for_high_quality(tmp_path: Path, monkeypatch): + import app.pipeline.runner as runner + + job = Job(id="abcdefabcde5", quality_preset="high") + source = tmp_path / "upload.mp3" + source.write_bytes(b"ID3") + calls = [] + + class Result: + returncode = 0 + stderr = b"" + + def fake_run(job_arg, cmd, **kwargs): + assert job_arg is job + calls.append((cmd, kwargs)) + Path(cmd[-1]).write_bytes(b"processed") + return Result() + + monkeypatch.setattr(runner, "run_tracked_process", fake_run) + + dest = _prepare_local_source(job, source, tmp_path) + + assert dest == tmp_path / "source.wav" + cmd, _ = calls[0] + assert cmd[cmd.index("-sample_fmt") : cmd.index("-sample_fmt") + 2] == ["-sample_fmt", "flt"] + assert cmd[cmd.index("-c:a") : cmd.index("-c:a") + 2] == ["-c:a", "pcm_f32le"] diff --git a/tests/test_process.py b/tests/test_process.py new file mode 100644 index 0000000..d7d05c0 --- /dev/null +++ b/tests/test_process.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import subprocess +import sys + +import pytest + +from app.core.models import Job +from app.core.registry import get_proc +from app.pipeline.process import run_tracked_process + + +def test_run_tracked_process_captures_output_and_clears_registry(): + job = Job(id="abcdefabcdef") + + result = run_tracked_process( + job, + [sys.executable, "-c", "print('layerlab')"], + timeout=5, + ) + + assert result.returncode == 0 + assert result.stdout.strip() == b"layerlab" + assert get_proc(job.id) is None + + +def test_run_tracked_process_kills_timed_out_child(): + job = Job(id="abcdefabcdee") + + with pytest.raises(subprocess.TimeoutExpired): + run_tracked_process( + job, + [sys.executable, "-c", "import time; time.sleep(5)"], + timeout=0.05, + ) + + assert get_proc(job.id) is None diff --git a/tests/test_registry_persistence.py b/tests/test_registry_persistence.py index 3684075..0156c19 100644 --- a/tests/test_registry_persistence.py +++ b/tests/test_registry_persistence.py @@ -42,13 +42,97 @@ def test_persist_and_restore_terminal_job(tmp_path: Path): assert restored.cancel_requested is False +def test_persist_excludes_transient_queue_fields(tmp_path: Path): + job = Job( + id="abcdefabcdea", + status="done", + title="Saved song", + queue_position=1, + queue_size=2, + ) + _jobs[job.id] = job + + persist_registry(tmp_path) + + data = json.loads((tmp_path / "registry.json").read_text(encoding="utf-8")) + assert "queue_position" not in data["jobs"][0] + assert "queue_size" not in data["jobs"][0] + + +def test_persist_and_restore_failed_job_logs(tmp_path: Path): + job = Job( + id="abcdefabcde1", + status="error", + title="Failed song", + error="Processing failed", + logs=[ + { + "id": 1, + "timestamp": 1_700_000_000.0, + "job_id": "abcdefabcde1", + "level": "error", + "message": "demucs failed", + "stage": "error", + "progress_percent": 82, + } + ], + ) + _jobs[job.id] = job + + persist_registry(tmp_path) + _jobs.clear() + restore_registry(tmp_path) + + restored = _jobs[job.id] + assert restored.status == "error" + assert restored.logs[0]["message"] == "demucs failed" + + +def test_persist_failure_is_non_fatal(tmp_path: Path, monkeypatch): + import app.core.registry as registry + + job = Job(id="abcdefabcde2", status="done", title="Saved song") + _jobs[job.id] = job + monkeypatch.setattr( + registry, + "atomic_write_text", + lambda *args, **kwargs: (_ for _ in ()).throw(OSError("disk full")), + ) + + persist_registry(tmp_path) + + def test_restore_recovers_orphan_done_job_from_stems(tmp_path: Path): job_dir = tmp_path / "abcdefabcdee" stems_dir = job_dir / "stems" stems_dir.mkdir(parents=True) (stems_dir / "vocals.wav").write_bytes(b"RIFF") (stems_dir / "drums.wav").write_bytes(b"RIFF") - (job_dir / "metadata.json").write_text(json.dumps({"title": "Test Song"}), encoding="utf-8") + (stems_dir / "chords.mid").write_bytes(b"MThd") + (job_dir / "metadata.json").write_text( + json.dumps( + { + "title": "Test Song", + "bass_repair_applied": True, + "phase_repair_applied": True, + "phase_repair_residual_ratio": 0.37, + "stem_denoise_preset": "light", + "stem_denoise_applied": True, + "stem_gate_applied": True, + "stem_gate_threshold_db": -54.0, + "beat_times": [0.5, 1.0, 1.5], + "chord_progression": [ + {"label": "C", "start": 0.5, "end": 1.5, "confidence": 0.9} + ], + "selected_stems": ["vocals"], + "source_url": "local:Test Song.wav", + "processing_started_at": 1_699_999_876.6, + "completed_at": 1_700_000_000.0, + "processing_elapsed_seconds": 123.4, + } + ), + encoding="utf-8", + ) restore_registry(tmp_path) @@ -57,6 +141,23 @@ def test_restore_recovers_orphan_done_job_from_stems(tmp_path: Path): assert restored.progress == 1.0 assert restored.title == "Test Song" assert {stem["name"] for stem in restored.stems} == {"vocals", "drums"} + assert restored.bass_repair_applied is True + assert restored.phase_repair_applied is True + assert restored.phase_repair_residual_ratio == 0.37 + assert restored.stem_denoise_preset == "light" + assert restored.stem_denoise_applied is True + assert restored.stem_gate_applied is True + assert restored.stem_gate_threshold_db == -54.0 + assert restored.beat_times == [0.5, 1.0, 1.5] + assert restored.chord_progression == [ + {"label": "C", "start": 0.5, "end": 1.5, "confidence": 0.9} + ] + assert restored.chord_midi_url == "/api/jobs/abcdefabcdee/chords.mid" + assert restored.selected_stems == ["vocals"] + assert restored.source_url == "local:Test Song.wav" + assert restored.processing_started_at == 1_699_999_876.6 + assert restored.completed_at == 1_700_000_000.0 + assert restored.processing_elapsed_seconds == 123.4 def test_restore_skips_orphan_without_metadata(tmp_path: Path): diff --git a/tests/test_separate_quality.py b/tests/test_separate_quality.py new file mode 100644 index 0000000..2e767e8 --- /dev/null +++ b/tests/test_separate_quality.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path + +import app.pipeline.separate as separate +from app.core.config import DemucsSettings + + +def test_build_demucs_command_includes_quality_flags(monkeypatch, tmp_path: Path): + monkeypatch.setattr(separate, "DEMUCS_DEVICE", "cpu") + settings = DemucsSettings( + quality_preset="high", + model="htdemucs_ft", + shifts=4, + pre_gain_db=-6.0, + float32=True, + clip_mode="rescale", + overlap=0.25, + segment=7.8, + ) + + source = tmp_path / "source.demucs.wav" + cmd = separate.build_demucs_command(source, tmp_path, settings, device="cpu") + + assert cmd[:6] == [separate.sys.executable, "-m", "demucs", "-n", "htdemucs_ft", "-d"] + assert "cpu" in cmd + assert cmd[cmd.index("--shifts") : cmd.index("--shifts") + 2] == ["--shifts", "4"] + assert cmd[cmd.index("--overlap") : cmd.index("--overlap") + 2] == ["--overlap", "0.25"] + assert cmd[cmd.index("--segment") : cmd.index("--segment") + 2] == ["--segment", "7.8"] + assert "--float32" in cmd + assert cmd[cmd.index("--clip-mode") : cmd.index("--clip-mode") + 2] == [ + "--clip-mode", + "rescale", + ] + assert cmd[-2:] == ["-o", str(tmp_path), str(source)][-2:] + + +def test_build_demucs_command_omits_standard_quality_flags(monkeypatch, tmp_path: Path): + settings = DemucsSettings( + quality_preset="standard", + model="htdemucs_6s", + shifts=0, + pre_gain_db=0.0, + float32=False, + clip_mode=None, + overlap=0.0, + segment=0.0, + ) + + cmd = separate.build_demucs_command(tmp_path / "source.wav", tmp_path, settings) + + assert "--shifts" not in cmd + assert "--overlap" not in cmd + assert "--segment" not in cmd + assert "--float32" not in cmd + assert "--clip-mode" not in cmd diff --git a/tests/test_stems_api.py b/tests/test_stems_api.py index cf81c28..4766882 100644 --- a/tests/test_stems_api.py +++ b/tests/test_stems_api.py @@ -5,6 +5,7 @@ import pytest from fastapi.testclient import TestClient +from app.api.stems import _mixdown_codec_args from app.core.models import Job from app.core.registry import _jobs @@ -66,6 +67,7 @@ def test_serves_done_job_stem(client, tmp_path): assert r.status_code == 200 assert r.content == b"RIFF1234" assert r.headers["content-type"] == "audio/wav" + assert "stems_Standard_Noise_off_Auto_All_6_stem_vocals.wav" in r.headers["content-disposition"] # --- peaks endpoint --- @@ -117,6 +119,65 @@ def test_peaks_rejects_malformed_job_id(client): assert r.status_code == 404, f"id {bad_id!r} should 404" +def test_chord_midi_returns_file_for_done_job(client, tmp_path): + job = Job(id="abcdefabcda1", status="done", title="Chord Song") + _jobs[job.id] = job + stems_dir = tmp_path / job.id / "stems" + stems_dir.mkdir(parents=True, exist_ok=True) + (stems_dir / "chords.mid").write_bytes(b"MThd1234") + + r = client.get(f"/api/jobs/{job.id}/chords.mid") + + assert r.status_code == 200 + assert r.content == b"MThd1234" + assert "Chord_Song" in r.headers["content-disposition"] + assert r.headers["content-disposition"].endswith('_chords.mid"') + + +def test_chord_midi_variant_renders_from_metadata(client, tmp_path): + job = Job(id="abcdefabcda3", status="done", title="Chord Song", bpm=120) + job.chord_progression = [ + {"label": "Bm7", "start": 0.0, "end": 1.0, "start_beat": 0, "end_beat": 1, "confidence": 0.9}, + {"label": "Dmaj7", "start": 1.0, "end": 3.0, "start_beat": 1, "end_beat": 3, "confidence": 0.8}, + {"label": "Gmaj7", "start": 3.0, "end": 4.0, "start_beat": 3, "end_beat": 4, "confidence": 0.7}, + ] + _jobs[job.id] = job + (tmp_path / job.id / "stems").mkdir(parents=True, exist_ok=True) + + r = client.get(f"/api/jobs/{job.id}/chords.mid?style=triads&grid=bar&markers=true") + + assert r.status_code == 200 + assert r.content.startswith(b"MThd") + assert b"\xff\x06" in r.content + assert "triads_bar_markers" in r.headers["content-disposition"] + + +def test_chord_csv_variant_renders_from_metadata(client, tmp_path): + job = Job(id="abcdefabcda4", status="done", title="Chord CSV") + job.chord_progression = [ + {"label": "Cmaj7", "start": 0.0, "end": 2.0, "start_beat": 0, "end_beat": 4, "confidence": 0.8}, + ] + _jobs[job.id] = job + (tmp_path / job.id / "stems").mkdir(parents=True, exist_ok=True) + + r = client.get(f"/api/jobs/{job.id}/chords.csv?style=triads") + + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/csv") + assert "Chord_CSV" in r.headers["content-disposition"] + assert "C,0.000,2.000,0,4,0.800" in r.text + + +def test_chord_midi_404_when_missing(client, tmp_path): + job = Job(id="abcdefabcda2", status="done") + _jobs[job.id] = job + (tmp_path / job.id / "stems").mkdir(parents=True, exist_ok=True) + + r = client.get(f"/api/jobs/{job.id}/chords.mid") + + assert r.status_code == 404 + + # ── Export All Stems (.zip) ── @@ -135,11 +196,14 @@ def test_all_stems_zip_all_when_no_subset(client, tmp_path): r = client.get(f"/api/jobs/{job.id}/stems/all.zip") assert r.status_code == 200 assert r.headers["content-type"] == "application/zip" - assert "My_Song_Live_stems.zip" in r.headers["content-disposition"] + assert "My_Song_Live_Standard_Noise_off_Auto_All_6_stem_stems.zip" in r.headers["content-disposition"] zf = zipfile.ZipFile(io.BytesIO(r.content)) - assert sorted(zf.namelist()) == ["bass.wav", "drums.wav", "vocals.wav"] + assert sorted(zf.namelist()) == ["LAYERLAB_PROFILE.txt", "bass.wav", "drums.wav", "vocals.wav"] assert zf.read("vocals.wav") == b"RIFFvocals" + manifest = zf.read("LAYERLAB_PROFILE.txt").decode() + assert "Profile: Standard / Noise off / Auto / All 6-stem" in manifest + assert "Exported stems: vocals, drums, bass" in manifest def test_all_stems_zip_only_active_subset(client, tmp_path): @@ -156,7 +220,8 @@ def test_all_stems_zip_only_active_subset(client, tmp_path): r = client.get(f"/api/jobs/{job.id}/stems/all.zip?stems=vocals,bass") assert r.status_code == 200 zf = zipfile.ZipFile(io.BytesIO(r.content)) - assert sorted(zf.namelist()) == ["bass.wav", "vocals.wav"] + assert sorted(zf.namelist()) == ["LAYERLAB_PROFILE.txt", "bass.wav", "vocals.wav"] + assert "Exported stems: vocals, bass" in zf.read("LAYERLAB_PROFILE.txt").decode() def test_all_stems_zip_rejects_unknown_stem(client, tmp_path): @@ -228,7 +293,7 @@ def test_all_stems_zip_mp3(client, tmp_path): r = client.get(f"/api/jobs/{job.id}/stems/all.zip?format=mp3") assert r.status_code == 200 zf = zipfile.ZipFile(io.BytesIO(r.content)) - assert zf.namelist() == ["vocals.mp3"] + assert sorted(zf.namelist()) == ["LAYERLAB_PROFILE.txt", "vocals.mp3"] assert len(zf.read("vocals.mp3")) > 0 @@ -327,6 +392,7 @@ def test_mixdown_wav_happy(client, tmp_path): r = client.get(f"/api/jobs/{job.id}/mixdown.wav?stems=vocals,drums&gains=1.000,0.500") assert r.status_code == 200 assert r.headers["content-type"] == "audio/wav" + assert "Track_Standard_Noise_off_All_6_stem_mix.wav" in r.headers["content-disposition"] assert r.content[:4] == b"RIFF" @@ -372,6 +438,13 @@ def test_mixdown_rejects_unknown_ext_still(client): assert r.status_code == 404 +def test_mixdown_wav_codec_follows_quality_preset(): + assert _mixdown_codec_args("wav", "standard") == ["-c:a", "pcm_s16le", "-f", "wav"] + assert _mixdown_codec_args("wav", "max") == ["-c:a", "pcm_f32le", "-f", "wav"] + assert _mixdown_codec_args("wav", "ultra") == ["-c:a", "pcm_f32le", "-f", "wav"] + assert _mixdown_codec_args("flac", "max") == ["-c:a", "flac", "-f", "flac"] + + def test_all_stems_zip_flac(client, tmp_path): _skip_without_ffmpeg() job = _done_job_with_stems(tmp_path, "abcdef000015", ["vocals"]) @@ -382,5 +455,5 @@ def test_all_stems_zip_flac(client, tmp_path): r = client.get(f"/api/jobs/{job.id}/stems/all.zip?format=flac") assert r.status_code == 200 zf = zipfile.ZipFile(io.BytesIO(r.content)) - assert zf.namelist() == ["vocals.flac"] + assert sorted(zf.namelist()) == ["LAYERLAB_PROFILE.txt", "vocals.flac"] assert zf.read("vocals.flac")[:4] == b"fLaC" diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 9d727d6..1f4e542 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -69,6 +69,17 @@ def test_keeps_recent_terminal_job(tmp_path: Path): assert job.id in _jobs +def test_sweeps_old_failed_job_without_directory(tmp_path: Path): + job = Job(id="abcdefabcde1", status="error", title="Failed song") + job.created_at = time.time() - 999_999 + _jobs[job.id] = job + + with patch("app.pipeline.collect.JOB_TTL_SECONDS", 60): + sweep_old_jobs(tmp_path) + + assert job.id not in _jobs + + def test_orphan_dir_falls_back_to_mtime(tmp_path: Path): """Directories with no registry entry (e.g. left over from a prior server run) still get swept by mtime.""" diff --git a/uv.lock b/uv.lock index 62c61f3..09f4a74 100644 --- a/uv.lock +++ b/uv.lock @@ -462,6 +462,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] +[[package]] +name = "imageio-ffmpeg" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" }, + { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -543,6 +557,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/38/f885a81684491c23b1a5c7e35ef1e5a142a89f1eb3b5678983a82f492d43/lameenc-1.8.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7827c19ee1e1cbee360ec3fb424050d8a2caa0c2a7736f2638b678c020e6f5ee", size = 235560, upload-time = "2026-03-07T19:56:43.687Z" }, ] +[[package]] +name = "layerlab" +source = { editable = "." } +dependencies = [ + { name = "demucs" }, + { name = "fastapi" }, + { name = "imageio-ffmpeg" }, + { name = "librosa" }, + { name = "numba", version = "0.61.2", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "pyloudnorm" }, + { name = "python-multipart" }, + { name = "soundfile" }, + { name = "torch", version = "2.2.2", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "torch", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "torchaudio", version = "2.2.2", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "torchaudio", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "urllib3" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "yt-dlp" }, +] + +[package.optional-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "demucs", specifier = ">=4.0.1" }, + { name = "fastapi", specifier = ">=0.115,!=0.136.3" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, + { name = "imageio-ffmpeg", specifier = ">=0.5" }, + { name = "librosa", specifier = ">=0.10" }, + { name = "numba", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = ">=0.61,<0.62" }, + { name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = "<2" }, + { name = "pyloudnorm", specifier = ">=0.1.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, + { name = "soundfile", specifier = ">=0.12" }, + { name = "torch", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = ">=2.6,<2.7" }, + { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = ">=2.2,<2.3" }, + { name = "torchaudio", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = ">=2.6,<2.7" }, + { name = "torchaudio", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = ">=2.2,<2.3" }, + { name = "urllib3", specifier = ">=2.7.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, + { name = "yt-dlp", specifier = ">=2024.12.0" }, +] +provides-extras = ["dev"] + [[package]] name = "lazy-loader" version = "0.5" @@ -1763,59 +1832,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] -[[package]] -name = "stemdeck" -source = { editable = "." } -dependencies = [ - { name = "demucs" }, - { name = "fastapi" }, - { name = "librosa" }, - { name = "numba", version = "0.61.2", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "pyloudnorm" }, - { name = "python-multipart" }, - { name = "soundfile" }, - { name = "torch", version = "2.2.2", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "torch", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, - { name = "torchaudio", version = "2.2.2", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "torchaudio", version = "2.6.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, - { name = "urllib3" }, - { name = "uvicorn", extra = ["standard"] }, - { name = "yt-dlp" }, -] - -[package.optional-dependencies] -dev = [ - { name = "httpx" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "demucs", specifier = ">=4.0.1" }, - { name = "fastapi", specifier = ">=0.115,!=0.136.3" }, - { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, - { name = "librosa", specifier = ">=0.10" }, - { name = "numba", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = ">=0.61,<0.62" }, - { name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = "<2" }, - { name = "pyloudnorm", specifier = ">=0.1.1" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, - { name = "python-multipart", specifier = ">=0.0.9" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, - { name = "soundfile", specifier = ">=0.12" }, - { name = "torch", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = ">=2.6,<2.7" }, - { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = ">=2.2,<2.3" }, - { name = "torchaudio", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = ">=2.6,<2.7" }, - { name = "torchaudio", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = ">=2.2,<2.3" }, - { name = "urllib3", specifier = ">=2.7.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, - { name = "yt-dlp", specifier = ">=2024.12.0" }, -] -provides-extras = ["dev"] - [[package]] name = "submitit" version = "1.5.4"