Merge branch 'main' of ssh://dallasgroot@10.0.0.224:22/Users/dallasgroot/NavidromePlayer/.git
This commit is contained in:
commit
5030cbc304
11 changed files with 1635 additions and 405 deletions
|
|
@ -28,20 +28,18 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
@Published var volume: Float = 0.8
|
||||
@Published var repeatMode: RepeatMode = .off
|
||||
@Published var shuffleEnabled = false
|
||||
// audioLevels is NOT @Published — avoids 60fps SwiftUI state thrashing
|
||||
// Visualizer pulls directly via AudioPlayer.shared.currentLevels()
|
||||
// No lock needed: writes happen on main thread (Timer callbacks),
|
||||
// reads happen from view body. A one-frame-stale read is imperceptible.
|
||||
// audioLevels is NOT @Published — avoids 60fps SwiftUI state thrashing.
|
||||
// Instead, levelTick increments every time levels change. The visualizer
|
||||
// observes levelTick: when it changes, body re-evaluates, Canvas re-executes,
|
||||
// and pulls the latest levels via currentLevels(). Guaranteed dependency.
|
||||
@Published private(set) var levelTick: Int = 0
|
||||
nonisolated(unsafe) private var _audioLevels: [Float] = Array(repeating: 0, count: 30)
|
||||
|
||||
/// Current visualizer levels (called from TimelineView body)
|
||||
func currentLevels() -> [Float] {
|
||||
_audioLevels
|
||||
}
|
||||
|
||||
/// Write visualizer levels (called from timers/taps on main thread)
|
||||
|
||||
func currentLevels() -> [Float] { _audioLevels }
|
||||
|
||||
private func setLevels(_ levels: [Float]) {
|
||||
_audioLevels = levels
|
||||
levelTick &+= 1
|
||||
}
|
||||
|
||||
@Published var isUsingRealFFT = false
|
||||
|
|
@ -724,9 +722,10 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
isPlaying = false
|
||||
#if os(iOS)
|
||||
stopOfflineVisTimer()
|
||||
stopLevelTimer()
|
||||
// Zero out levels immediately so the wave decays cleanly via viscosity
|
||||
setLevels(Array(repeating: 0, count: 30))
|
||||
// Don't stop levelTimer — the simulation timer's !isPlaying branch decays
|
||||
// internalLevels to zero and calls setLevels(zeros), which increments levelTick
|
||||
// so the Canvas keeps re-rendering and the wave visually decays during pause.
|
||||
// For offline vis path the offlineVisTimer (restarted above) handles this.
|
||||
internalLevels = Array(repeating: 0, count: 30)
|
||||
#endif
|
||||
updateNowPlayingInfo()
|
||||
|
|
@ -748,11 +747,35 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
}
|
||||
isPlaying = true
|
||||
#if os(iOS)
|
||||
// Prime levels immediately before any timer fires.
|
||||
// The CADisplayLink draws at 60fps; the level timers fire at 30fps.
|
||||
// Without this, the first 1-2 Canvas frames read stale zeroed _audioLevels
|
||||
// (zeroed by pause) and the wave starts flat then slowly ramps up.
|
||||
if isUsingOfflineVis {
|
||||
// Offline vis: prime with the last-rendered frame at current position
|
||||
if !offlineVisFrames.isEmpty {
|
||||
let dur = duration
|
||||
let pct = dur > 0 ? min(currentTime / dur, 1.0) : 0
|
||||
let idx = min(Int(pct * Double(offlineVisFrames.count)), offlineVisFrames.count - 1)
|
||||
setLevels(offlineVisFrames[idx])
|
||||
}
|
||||
startOfflineVisSync()
|
||||
} else if isUsingRealFFT, !isUsingOfflineVis {
|
||||
// Engine FFT path: rawFFTLevels still holds pre-pause data — push it immediately
|
||||
setLevels(rawFFTLevels)
|
||||
stopLevelTimer()
|
||||
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.setLevels(self.rawFFTLevels)
|
||||
}
|
||||
} else {
|
||||
// Restart level simulation for streaming / radio / crossfade paths
|
||||
startLevelSimulation()
|
||||
// Simulation path: seed internalLevels from a fresh random target
|
||||
// so the wave has height immediately rather than starting from floor.
|
||||
lastTargetPhase = -1
|
||||
targetLevels = (0..<30).map { _ in Float.random(in: 0.4...0.85) }
|
||||
internalLevels = targetLevels.map { $0 * 0.8 }
|
||||
setLevels(internalLevels)
|
||||
if levelTimer == nil { startLevelSimulation() }
|
||||
}
|
||||
#endif
|
||||
updateNowPlayingInfo()
|
||||
|
|
@ -1278,7 +1301,14 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
stopOfflineVisTimer()
|
||||
|
||||
offlineVisTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
|
||||
guard let self = self, self.isUsingOfflineVis, self.isPlaying else { return }
|
||||
guard let self = self, self.isUsingOfflineVis else { return }
|
||||
guard self.isPlaying else {
|
||||
// Keep pulsing zeros so levelTick increments and the visualizer can decay
|
||||
if self._audioLevels.contains(where: { $0 > 0.005 }) {
|
||||
self.setLevels(Array(repeating: 0, count: 30))
|
||||
}
|
||||
return
|
||||
}
|
||||
let frames = self.offlineVisFrames
|
||||
guard !frames.isEmpty else { return }
|
||||
|
||||
|
|
@ -1311,6 +1341,10 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
|
||||
private func startLevelSimulation() {
|
||||
stopLevelTimer()
|
||||
// Force a fresh target generation on the first tick after resume
|
||||
// by invalidating the cached phase. Otherwise currentTime hasn't
|
||||
// advanced yet and the wave smooths to a stale constant value.
|
||||
lastTargetPhase = -1
|
||||
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
||||
guard let self = self, self.isPlaying else {
|
||||
if self?.internalLevels.contains(where: { $0 > 0.01 }) == true {
|
||||
|
|
|
|||
34
companion-api/Dockerfile
Normal file
34
companion-api/Dockerfile
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Location: /home/pi/docker/navidrome/companion_api/Dockerfile
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
# System dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ffmpeg \
|
||||
libsndfile1 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Python dependencies
|
||||
RUN pip install --no-cache-dir \
|
||||
fastapi \
|
||||
"uvicorn[standard]" \
|
||||
mutagen \
|
||||
httpx \
|
||||
python-multipart \
|
||||
librosa \
|
||||
numpy \
|
||||
tqdm \
|
||||
websockets
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Data directories (mounted as volume, created as fallback)
|
||||
RUN mkdir -p /app/data /app/data/vis_cache
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
175
companion-api/diagnose.py
Normal file
175
companion-api/diagnose.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""
|
||||
Diagnostic script — run inside the music-companion container:
|
||||
docker exec -it music-companion python diagnose.py
|
||||
|
||||
Checks:
|
||||
1. What MUSIC_DIR points to and what's in it
|
||||
2. What paths are stored in smart_dj.db
|
||||
3. Whether those DB paths actually exist on disk
|
||||
4. Tests resolve_path() with sample paths
|
||||
"""
|
||||
import os, sqlite3, hashlib
|
||||
|
||||
MUSIC_DIR = os.getenv("MUSIC_DIR", "/music")
|
||||
DB_PATH = os.getenv("DB_PATH", "/app/data/smart_dj.db")
|
||||
VIS_CACHE_DIR = os.getenv("VIS_CACHE_DIR", "/app/data/vis_cache")
|
||||
|
||||
print("=" * 60)
|
||||
print("COMPANION API DIAGNOSTICS")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. Check MUSIC_DIR
|
||||
print(f"\n1. MUSIC_DIR = {MUSIC_DIR}")
|
||||
print(f" Exists: {os.path.isdir(MUSIC_DIR)}")
|
||||
|
||||
if os.path.isdir(MUSIC_DIR):
|
||||
top = os.listdir(MUSIC_DIR)
|
||||
dirs = sorted([d for d in top if os.path.isdir(os.path.join(MUSIC_DIR, d))])
|
||||
files = [f for f in top if os.path.isfile(os.path.join(MUSIC_DIR, f))]
|
||||
print(f" Top-level: {len(dirs)} folders, {len(files)} files")
|
||||
|
||||
# Show first 10 folders
|
||||
for d in dirs[:10]:
|
||||
print(f" 📁 {d}")
|
||||
if len(dirs) > 10:
|
||||
print(f" ... and {len(dirs) - 10} more")
|
||||
|
||||
# Check if there's a nested "music" folder (common misconfiguration)
|
||||
if "music" in dirs:
|
||||
nested = os.path.join(MUSIC_DIR, "music")
|
||||
nested_contents = os.listdir(nested)
|
||||
print(f"\n ⚠️ FOUND nested 'music/' folder inside MUSIC_DIR!")
|
||||
print(f" {nested} has {len(nested_contents)} items")
|
||||
print(f" This usually means docker-compose mounts the parent instead of the music dir")
|
||||
print(f" Your files are probably at /music/music/Artist/Album/song.flac")
|
||||
print(f" Fix: change volume mount from '/home/pi/navidrome:/music' to '/home/pi/navidrome/music:/music'")
|
||||
|
||||
# Count total audio files
|
||||
audio_count = 0
|
||||
sample_paths = []
|
||||
for root, _, fnames in os.walk(MUSIC_DIR):
|
||||
for f in fnames:
|
||||
if f.lower().endswith(('.mp3', '.flac', '.m4a', '.ogg', '.opus', '.wav')):
|
||||
audio_count += 1
|
||||
fp = os.path.join(root, f)
|
||||
rel = os.path.relpath(fp, MUSIC_DIR)
|
||||
if len(sample_paths) < 5:
|
||||
sample_paths.append((fp, rel))
|
||||
|
||||
print(f"\n Total audio files: {audio_count}")
|
||||
print(f"\n Sample file paths (absolute → relative):")
|
||||
for abs_path, rel_path in sample_paths:
|
||||
print(f" ABS: {abs_path}")
|
||||
print(f" REL: {rel_path}")
|
||||
print()
|
||||
else:
|
||||
print(" ❌ MUSIC_DIR does not exist!")
|
||||
|
||||
# 2. Check DB
|
||||
print(f"\n2. DATABASE = {DB_PATH}")
|
||||
print(f" Exists: {os.path.isfile(DB_PATH)}")
|
||||
|
||||
db_paths = []
|
||||
if os.path.isfile(DB_PATH):
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
count = c.execute("SELECT COUNT(*) FROM dj_profiles").fetchone()[0]
|
||||
print(f" Profiles: {count}")
|
||||
|
||||
rows = c.execute("SELECT file_path FROM dj_profiles LIMIT 10").fetchall()
|
||||
db_paths = [r[0] for r in rows]
|
||||
|
||||
print(f"\n Sample DB paths:")
|
||||
for p in db_paths:
|
||||
exists = os.path.isfile(p)
|
||||
icon = "✓" if exists else "✗"
|
||||
print(f" {icon} {p}")
|
||||
if not exists:
|
||||
# Try to find what DOES exist
|
||||
basename = os.path.basename(p)
|
||||
for root, _, fnames in os.walk(MUSIC_DIR):
|
||||
if basename in fnames:
|
||||
actual = os.path.join(root, basename)
|
||||
print(f" → FOUND at: {actual}")
|
||||
break
|
||||
|
||||
# Check how many DB paths actually exist
|
||||
all_paths = c.execute("SELECT file_path FROM dj_profiles").fetchall()
|
||||
existing = sum(1 for (p,) in all_paths if os.path.isfile(p))
|
||||
missing = len(all_paths) - existing
|
||||
print(f"\n DB path health: {existing} exist, {missing} broken")
|
||||
if missing > 0:
|
||||
print(f" ⚠️ {missing} profiles point to files that don't exist!")
|
||||
print(f" This means the DB was built with a different mount path")
|
||||
else:
|
||||
print(" ❌ Database does not exist! Run pre_analyze.py first")
|
||||
|
||||
# 3. Check vis cache
|
||||
print(f"\n3. VIS CACHE = {VIS_CACHE_DIR}")
|
||||
if os.path.isdir(VIS_CACHE_DIR):
|
||||
vis_files = os.listdir(VIS_CACHE_DIR)
|
||||
print(f" Cached: {len(vis_files)} files")
|
||||
else:
|
||||
print(f" Does not exist")
|
||||
|
||||
# 4. Test resolve_path with the sample paths
|
||||
print(f"\n4. RESOLVE_PATH TESTS")
|
||||
print(f" Testing with various path formats...")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
def resolve_path(relative):
|
||||
cleaned = relative.lstrip("/")
|
||||
direct = os.path.join(MUSIC_DIR, cleaned)
|
||||
if os.path.isfile(direct):
|
||||
return ("direct", direct)
|
||||
parts = Path(cleaned).parts
|
||||
for i in range(1, len(parts)):
|
||||
sub = os.path.join(MUSIC_DIR, *parts[i:])
|
||||
if os.path.isfile(sub):
|
||||
return (f"strip-{i}", sub)
|
||||
target = os.path.basename(cleaned)
|
||||
if target:
|
||||
for root, _, files in os.walk(MUSIC_DIR):
|
||||
if target in files:
|
||||
return ("filename-search", os.path.join(root, target))
|
||||
return ("FAILED", None)
|
||||
|
||||
if sample_paths:
|
||||
for abs_path, rel_path in sample_paths:
|
||||
# Test various formats the iOS app might send
|
||||
tests = [
|
||||
("exact relative", rel_path),
|
||||
("with music/ prefix", f"music/{rel_path}"),
|
||||
("with leading /", f"/{rel_path}"),
|
||||
("absolute", abs_path),
|
||||
]
|
||||
print(f"\n File: {os.path.basename(rel_path)}")
|
||||
for label, test_path in tests:
|
||||
strategy, result = resolve_path(test_path)
|
||||
icon = "✓" if result else "✗"
|
||||
print(f" {icon} {label}: '{test_path}' → {strategy}")
|
||||
break # Just test one file
|
||||
|
||||
# 5. Summary
|
||||
print(f"\n{'=' * 60}")
|
||||
print("SUMMARY")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
if os.path.isdir(MUSIC_DIR):
|
||||
if "music" in [d for d in os.listdir(MUSIC_DIR) if os.path.isdir(os.path.join(MUSIC_DIR, d))]:
|
||||
print("❌ LIKELY ISSUE: Nested 'music' folder detected.")
|
||||
print(" Your docker-compose probably mounts /home/pi/navidrome:/music")
|
||||
print(" but should mount /home/pi/navidrome/music:/music")
|
||||
elif audio_count == 0:
|
||||
print("❌ No audio files found in MUSIC_DIR!")
|
||||
elif db_paths and not os.path.isfile(db_paths[0]):
|
||||
print("❌ DB paths don't match filesystem. The DB was built with")
|
||||
print(" a different mount configuration. Fix mount and re-run:")
|
||||
print(" docker exec -it music-companion python pre_analyze.py --force")
|
||||
else:
|
||||
print("✓ Configuration looks correct")
|
||||
print(f" {audio_count} audio files, {count if os.path.isfile(DB_PATH) else 0} profiles")
|
||||
else:
|
||||
print("❌ MUSIC_DIR doesn't exist — check docker-compose volumes")
|
||||
|
||||
print()
|
||||
45
companion-api/docker-compose.yml
Normal file
45
companion-api/docker-compose.yml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Location: /home/pi/docker/navidrome/docker-compose.yml
|
||||
|
||||
services:
|
||||
navidrome:
|
||||
image: deluan/navidrome:latest
|
||||
container_name: navidrome
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
- ND_SCANSCHEDULE=1h
|
||||
- ND_BASEURL=/navidrome
|
||||
# ... other env vars ...
|
||||
volumes:
|
||||
# Absolute path to your music
|
||||
- /home/pi/navidrome:/music:ro
|
||||
- ./navidrome_data:/data
|
||||
|
||||
music-companion:
|
||||
build: ./companion_api
|
||||
container_name: music-companion
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
# Mount the ACTUAL music directory — not the parent navidrome folder
|
||||
# Host: /home/pi/navidrome/music → Container: /music
|
||||
- /home/pi/navidrome/music:/music:rw
|
||||
# Persistent data: Smart DJ DB + visualizer frame cache
|
||||
- ./companion_data:/app/data
|
||||
environment:
|
||||
- MUSIC_DIR=/music
|
||||
- DB_PATH=/app/data/smart_dj.db
|
||||
- VIS_CACHE_DIR=/app/data/vis_cache
|
||||
- NAVIDROME_URL=http://navidrome:4533/navidrome
|
||||
- SUBSONIC_USER=dallas
|
||||
- SUBSONIC_TOKEN=your_token
|
||||
- SUBSONIC_SALT=your_salt
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 1G
|
||||
depends_on:
|
||||
- navidrome
|
||||
786
companion-api/main.py
Normal file
786
companion-api/main.py
Normal file
|
|
@ -0,0 +1,786 @@
|
|||
"""
|
||||
Navidrome Companion API
|
||||
Location: /home/pi/docker/navidrome/companion_api/main.py
|
||||
|
||||
Endpoints:
|
||||
GET /health
|
||||
PATCH /edit-metadata
|
||||
PATCH /batch-edit-metadata — edit tags on multiple files at once
|
||||
POST /upload-track — single file upload
|
||||
POST /upload-tracks — multi-file batch upload with per-track metadata
|
||||
GET /smart-dj/profile
|
||||
GET /smart-dj/bulk-profiles
|
||||
POST /bulk-fix
|
||||
GET /visualizer/frames
|
||||
POST /visualizer/precompute
|
||||
WS /ws/push
|
||||
"""
|
||||
import os, re, json, asyncio, hashlib, sqlite3, subprocess, shutil, time, warnings
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from contextlib import asynccontextmanager
|
||||
from urllib.parse import unquote
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
from fastapi import (FastAPI, HTTPException, UploadFile, File, Form,
|
||||
BackgroundTasks, WebSocket, WebSocketDisconnect, Query)
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
warnings.filterwarnings("ignore", category=UserWarning)
|
||||
warnings.filterwarnings("ignore", category=FutureWarning)
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
MUSIC_DIR = os.getenv("MUSIC_DIR", "/music")
|
||||
DB_PATH = os.getenv("DB_PATH", "/app/data/smart_dj.db")
|
||||
VIS_CACHE_DIR = os.getenv("VIS_CACHE_DIR", "/app/data/vis_cache")
|
||||
NAVIDROME_URL = os.getenv("NAVIDROME_URL", "http://navidrome:4533/navidrome")
|
||||
SUBSONIC_USER = os.getenv("SUBSONIC_USER")
|
||||
SUBSONIC_TOKEN = os.getenv("SUBSONIC_TOKEN")
|
||||
SUBSONIC_SALT = os.getenv("SUBSONIC_SALT")
|
||||
|
||||
|
||||
# ── DB ─────────────────────────────────────────────────────
|
||||
def init_db():
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS dj_profiles (
|
||||
file_path TEXT PRIMARY KEY, bpm REAL,
|
||||
silence_start REAL, silence_end REAL,
|
||||
loudness_lufs REAL,
|
||||
analyzed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""")
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS file_index (
|
||||
basename TEXT,
|
||||
full_path TEXT,
|
||||
title_words TEXT,
|
||||
PRIMARY KEY (basename, full_path))""")
|
||||
|
||||
|
||||
def build_file_index():
|
||||
"""Scan MUSIC_DIR and index every audio file by basename and title words."""
|
||||
exts = ('.mp3', '.flac', '.m4a', '.ogg', '.opus', '.wav')
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
c.execute("DELETE FROM file_index")
|
||||
count = 0
|
||||
for root, _, files in os.walk(MUSIC_DIR):
|
||||
for f in files:
|
||||
if f.lower().endswith(exts):
|
||||
fp = os.path.join(root, f)
|
||||
stem = Path(f).stem.lower()
|
||||
words = set(re.split(r'[\s\-_\.]+', stem))
|
||||
words = {w for w in words if len(w) > 1 and not w.isdigit()}
|
||||
c.execute("INSERT OR REPLACE INTO file_index VALUES (?,?,?)",
|
||||
(f.lower(), fp, " ".join(sorted(words))))
|
||||
count += 1
|
||||
print(f" File index built: {count} files", flush=True)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
os.makedirs(VIS_CACHE_DIR, exist_ok=True)
|
||||
print(f"🎵 Companion API ready", flush=True)
|
||||
print(f" MUSIC_DIR = {MUSIC_DIR}", flush=True)
|
||||
print(f" DB_PATH = {DB_PATH}", flush=True)
|
||||
print(f" VIS_CACHE = {VIS_CACHE_DIR}", flush=True)
|
||||
try:
|
||||
top = os.listdir(MUSIC_DIR)
|
||||
dirs = [d for d in top if os.path.isdir(os.path.join(MUSIC_DIR, d))]
|
||||
print(f" MUSIC_DIR has {len(dirs)} folders at top level", flush=True)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Cannot list MUSIC_DIR: {e}", flush=True)
|
||||
build_file_index()
|
||||
yield
|
||||
|
||||
app = FastAPI(title="Navidrome Companion API", lifespan=lifespan)
|
||||
|
||||
|
||||
# ── Models ─────────────────────────────────────────────────
|
||||
class MetadataUpdate(BaseModel):
|
||||
relative_path: str
|
||||
title: Optional[str] = None
|
||||
artist: Optional[str] = None
|
||||
album: Optional[str] = None
|
||||
album_artist: Optional[str] = None
|
||||
genre: Optional[str] = None
|
||||
year: Optional[int] = None
|
||||
track_number: Optional[int] = None
|
||||
|
||||
class BatchMetadataUpdate(BaseModel):
|
||||
"""Edit the same tags on multiple files at once."""
|
||||
relative_paths: List[str]
|
||||
title: Optional[str] = None
|
||||
artist: Optional[str] = None
|
||||
album: Optional[str] = None
|
||||
album_artist: Optional[str] = None
|
||||
genre: Optional[str] = None
|
||||
year: Optional[int] = None
|
||||
|
||||
class TrackUploadMeta(BaseModel):
|
||||
"""Per-track metadata for multi-file upload."""
|
||||
filename: str
|
||||
title: str
|
||||
artist: str
|
||||
album: str
|
||||
track_number: Optional[int] = None
|
||||
genre: Optional[str] = None
|
||||
year: Optional[int] = None
|
||||
|
||||
|
||||
# ── Push Manager (WebSocket) ──────────────────────────────
|
||||
class PushManager:
|
||||
def __init__(self):
|
||||
self.connections: list[WebSocket] = []
|
||||
|
||||
async def connect(self, ws: WebSocket):
|
||||
await ws.accept()
|
||||
self.connections.append(ws)
|
||||
print(f"📱 Client connected ({len(self.connections)} total)")
|
||||
|
||||
def disconnect(self, ws: WebSocket):
|
||||
if ws in self.connections:
|
||||
self.connections.remove(ws)
|
||||
print(f"📱 Client disconnected ({len(self.connections)} total)")
|
||||
|
||||
async def broadcast(self, event: str, data: dict):
|
||||
msg = json.dumps({"event": event, "data": data})
|
||||
dead = []
|
||||
for ws in self.connections:
|
||||
try:
|
||||
await ws.send_text(msg)
|
||||
except:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self.connections.remove(ws)
|
||||
|
||||
async def send_to(self, ws: WebSocket, event: str, data: dict):
|
||||
await ws.send_text(json.dumps({"event": event, "data": data}))
|
||||
|
||||
push = PushManager()
|
||||
|
||||
|
||||
# ── Path Resolution ───────────────────────────────────────
|
||||
def resolve_path(relative: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve a Navidrome song.path to an actual file inside the container.
|
||||
|
||||
Handles:
|
||||
- URL-encoded paths (David%20Graham → David Graham)
|
||||
- Double/triple encoding (%2520 → %20 → space)
|
||||
- Navidrome's "music/" prefix when mounts differ
|
||||
- Filename-only search as fallback
|
||||
"""
|
||||
# Fully URL-decode — keep decoding until stable
|
||||
decoded = relative
|
||||
for _ in range(5):
|
||||
next_decoded = unquote(decoded)
|
||||
if next_decoded == decoded:
|
||||
break
|
||||
decoded = next_decoded
|
||||
|
||||
cleaned = decoded.lstrip("/")
|
||||
|
||||
# Log what we're looking for
|
||||
print(f" resolve_path: '{relative}' → decoded: '{cleaned}'", flush=True)
|
||||
|
||||
direct = os.path.join(MUSIC_DIR, cleaned)
|
||||
if os.path.isfile(direct):
|
||||
return direct
|
||||
|
||||
parts = Path(cleaned).parts
|
||||
for i in range(1, len(parts)):
|
||||
sub = os.path.join(MUSIC_DIR, *parts[i:])
|
||||
if os.path.isfile(sub):
|
||||
return sub
|
||||
|
||||
target = os.path.basename(cleaned)
|
||||
if target:
|
||||
for root, _, files in os.walk(MUSIC_DIR):
|
||||
if target in files:
|
||||
return os.path.join(root, target)
|
||||
|
||||
# Strategy 4: Fuzzy match via file_index DB
|
||||
target_stem = Path(target).stem.lower() if target else ""
|
||||
target_ext = Path(target).suffix.lower() if target else ""
|
||||
# Remove track number prefix like "09 - " or "09. "
|
||||
title_part = re.sub(r'^\d+[\s\.\-]+', '', target_stem).strip()
|
||||
search_words = set(re.split(r'[\s\-_\.]+', title_part))
|
||||
search_words = {w for w in search_words if len(w) > 1 and not w.isdigit()}
|
||||
|
||||
if search_words:
|
||||
try:
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
rows = c.execute("SELECT basename, full_path, title_words FROM file_index").fetchall()
|
||||
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
|
||||
for basename, full_path, title_words_str in rows:
|
||||
if not basename.endswith(target_ext):
|
||||
continue
|
||||
file_words = set(title_words_str.split())
|
||||
if not file_words:
|
||||
continue
|
||||
overlap = len(search_words & file_words)
|
||||
score = overlap / len(search_words)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = full_path
|
||||
|
||||
if best_match and best_score >= 0.5:
|
||||
print(f" resolve: fuzzy ({best_score:.0%}) → {os.path.basename(best_match)}", flush=True)
|
||||
return best_match
|
||||
except Exception as e:
|
||||
print(f" resolve: fuzzy error: {e}", flush=True)
|
||||
|
||||
print(f" resolve_path: FAILED for '{cleaned}'", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
# ── Navidrome Scan ────────────────────────────────────────
|
||||
async def trigger_scan():
|
||||
if not all([SUBSONIC_USER, SUBSONIC_TOKEN, SUBSONIC_SALT]):
|
||||
print("⚠️ Subsonic credentials not set — skipping scan")
|
||||
return
|
||||
params = {
|
||||
"u": SUBSONIC_USER, "t": SUBSONIC_TOKEN, "s": SUBSONIC_SALT,
|
||||
"v": "1.16.1", "c": "CompanionAPI", "f": "json"
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(f"{NAVIDROME_URL}/rest/startScan.view", params=params)
|
||||
print(f"Navidrome scan: {r.status_code}")
|
||||
except Exception as e:
|
||||
print(f"Scan failed: {e}")
|
||||
|
||||
|
||||
# ── Metadata ──────────────────────────────────────────────
|
||||
def apply_tags(path: str, u: MetadataUpdate):
|
||||
audio = MutagenFile(path, easy=True)
|
||||
if audio is None:
|
||||
raise ValueError(f"Unsupported format: {path}")
|
||||
if u.title: audio['title'] = u.title
|
||||
if u.artist: audio['artist'] = u.artist
|
||||
if u.album: audio['album'] = u.album
|
||||
if u.album_artist: audio['albumartist'] = u.album_artist
|
||||
if u.genre: audio['genre'] = u.genre
|
||||
if u.year: audio['date'] = str(u.year)
|
||||
if u.track_number: audio['tracknumber'] = str(u.track_number)
|
||||
audio.save()
|
||||
|
||||
|
||||
def apply_tags_dict(path: str, tags: dict):
|
||||
"""Apply tags from a plain dict — used by batch/multi-track endpoints."""
|
||||
audio = MutagenFile(path, easy=True)
|
||||
if audio is None:
|
||||
raise ValueError(f"Unsupported format: {path}")
|
||||
mapping = {
|
||||
'title': 'title', 'artist': 'artist', 'album': 'album',
|
||||
'album_artist': 'albumartist', 'genre': 'genre',
|
||||
'year': 'date', 'track_number': 'tracknumber'
|
||||
}
|
||||
for key, tag_name in mapping.items():
|
||||
val = tags.get(key)
|
||||
if val is not None and val != "":
|
||||
audio[tag_name] = str(val)
|
||||
audio.save()
|
||||
|
||||
|
||||
# ── Smart DJ Analysis ────────────────────────────────────
|
||||
def analyze(full_path: str) -> dict:
|
||||
import librosa
|
||||
cmd = ["ffmpeg", "-hide_banner", "-i", full_path,
|
||||
"-af", "silencedetect=noise=-50dB:d=0.5,ebur128", "-f", "null", "-"]
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=120).stderr
|
||||
|
||||
# ffmpeg silencedetect outputs pairs:
|
||||
# silence_start: 0 (leading silence begins)
|
||||
# silence_end: 0.5 (leading silence ends) ← skip to here
|
||||
# silence_start: 237 (trailing silence begins) ← crossfade trigger
|
||||
# silence_end: 240 (trailing silence ends)
|
||||
#
|
||||
# We need:
|
||||
# silence_start = LAST silence_start = where trailing silence begins
|
||||
# silence_end = FIRST silence_end = where leading silence ends
|
||||
all_starts = re.findall(r"silence_start: ([\d\.]+)", out)
|
||||
all_ends = re.findall(r"silence_end: ([\d\.]+)", out)
|
||||
lu = re.search(r"I:\s+([\-\d\.]+) LUFS", out)
|
||||
|
||||
# trailing silence start = last silence_start (for crossfade trigger)
|
||||
sil_start = float(all_starts[-1]) if all_starts else 0.0
|
||||
# leading silence end = first silence_end (for skip-to point)
|
||||
sil_end = float(all_ends[0]) if all_ends else 0.0
|
||||
loudness = float(lu.group(1)) if lu else -14.0
|
||||
|
||||
# Sanity: if sil_end is > 10s, it's probably not leading silence
|
||||
if sil_end > 10.0:
|
||||
sil_end = 0.0
|
||||
# Sanity: if sil_start is < 10s, it's probably not trailing silence
|
||||
# (could be leading silence start = 0)
|
||||
# Get track duration from ffmpeg to validate
|
||||
dur_match = re.search(r"Duration: (\d+):(\d+):(\d+\.\d+)", out)
|
||||
if dur_match:
|
||||
total_dur = int(dur_match.group(1)) * 3600 + int(dur_match.group(2)) * 60 + float(dur_match.group(3))
|
||||
if sil_start < total_dur * 0.5:
|
||||
sil_start = total_dur # No meaningful trailing silence found
|
||||
|
||||
print(f" analyze: trailing_start={sil_start:.1f}s, leading_end={sil_end:.1f}s, LUFS={loudness:.1f}", flush=True)
|
||||
|
||||
y, sr = librosa.load(full_path, sr=22050, duration=30)
|
||||
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
|
||||
del y
|
||||
import gc; gc.collect()
|
||||
|
||||
try:
|
||||
bpm = float(tempo)
|
||||
except TypeError:
|
||||
bpm = float(tempo[0]) if tempo is not None else 0.0
|
||||
|
||||
profile = {"bpm": round(bpm, 1), "silence_start": round(sil_start, 3),
|
||||
"silence_end": round(sil_end, 3), "loudness_lufs": round(loudness, 1)}
|
||||
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
c.execute("INSERT OR REPLACE INTO dj_profiles VALUES (?,?,?,?,?,CURRENT_TIMESTAMP)",
|
||||
(full_path, profile["bpm"], profile["silence_start"],
|
||||
profile["silence_end"], profile["loudness_lufs"]))
|
||||
return profile
|
||||
|
||||
|
||||
# ── Mitsuha Visualizer Frames ─────────────────────────────
|
||||
def gen_vis_frames(path: str, fps=30.0, fft_size=1024, pts=20) -> list:
|
||||
import librosa
|
||||
y, sr = librosa.load(path, sr=22050, mono=True)
|
||||
hop = int(sr / fps)
|
||||
frames = []
|
||||
for start in range(0, len(y) - fft_size, hop):
|
||||
chunk = y[start:start + fft_size] * np.hanning(fft_size)
|
||||
spec = np.sqrt(np.abs(np.fft.rfft(chunk)) / fft_size)
|
||||
half = len(spec)
|
||||
cutoff = min(half - 1, 90)
|
||||
fp = []
|
||||
for i in range(pts):
|
||||
ni = (i + 1) / pts
|
||||
li = np.log10(ni * 9 + 1)
|
||||
cb = li * cutoff
|
||||
bw = max(1, cutoff / pts * li)
|
||||
sb = max(1, int(cb - bw / 2))
|
||||
eb = min(cutoff, int(cb + bw / 2))
|
||||
avg = float(np.mean(spec[sb:eb + 1])) if sb <= eb < half else 0
|
||||
fp.append(avg * (1 + i / pts * 3.5))
|
||||
frames.append(fp)
|
||||
del y; import gc; gc.collect()
|
||||
vals = sorted(v for f in frames for v in f if v > 0.001)
|
||||
if vals:
|
||||
p95 = vals[min(int(len(vals) * 0.95), len(vals) - 1)]
|
||||
if p95 > 0.001:
|
||||
s = 0.8 / p95
|
||||
frames = [[min(1.0, v * s) for v in f] for f in frames]
|
||||
return frames
|
||||
|
||||
|
||||
def vis_cache_file(path: str) -> str:
|
||||
return os.path.join(VIS_CACHE_DIR, hashlib.md5(path.encode()).hexdigest() + ".json")
|
||||
|
||||
|
||||
def get_vis(path: str):
|
||||
cf = vis_cache_file(path)
|
||||
if os.path.exists(cf):
|
||||
try:
|
||||
with open(cf) as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
pass
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
frames = gen_vis_frames(path)
|
||||
with open(cf, "w") as f:
|
||||
json.dump(frames, f)
|
||||
return frames
|
||||
except Exception as e:
|
||||
print(f"Vis gen failed for {os.path.basename(path)}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# ENDPOINTS
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
pc = 0
|
||||
fc = 0
|
||||
try:
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
pc = c.execute("SELECT COUNT(*) FROM dj_profiles").fetchone()[0]
|
||||
fc = c.execute("SELECT COUNT(*) FROM file_index").fetchone()[0]
|
||||
except:
|
||||
pass
|
||||
vc = len(os.listdir(VIS_CACHE_DIR)) if os.path.isdir(VIS_CACHE_DIR) else 0
|
||||
return {
|
||||
"status": "healthy",
|
||||
"music_dir": MUSIC_DIR,
|
||||
"profiles": pc,
|
||||
"file_index": fc,
|
||||
"vis_cached": vc,
|
||||
"ws_clients": len(push.connections)
|
||||
}
|
||||
|
||||
|
||||
@app.post("/reindex")
|
||||
async def reindex():
|
||||
"""Rebuild the file index (call after adding/renaming files)."""
|
||||
build_file_index()
|
||||
return {"status": "reindexed"}
|
||||
|
||||
|
||||
# ── Single Metadata Edit ─────────────────────────────────
|
||||
@app.patch("/edit-metadata")
|
||||
async def edit_metadata(update: MetadataUpdate):
|
||||
fp = resolve_path(update.relative_path)
|
||||
if not fp:
|
||||
decoded = unquote(unquote(update.relative_path))
|
||||
raise HTTPException(404,
|
||||
f"File not found. "
|
||||
f"raw='{update.relative_path}' | "
|
||||
f"decoded='{decoded}' | "
|
||||
f"MUSIC_DIR='{MUSIC_DIR}'")
|
||||
try:
|
||||
apply_tags(fp, update)
|
||||
await trigger_scan()
|
||||
await push.broadcast("metadata_updated", {
|
||||
"path": update.relative_path,
|
||||
"title": update.title or "",
|
||||
"artist": update.artist or "",
|
||||
"album": update.album or ""
|
||||
})
|
||||
return {"status": "success", "file": update.relative_path, "resolved": fp}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
# ── Batch Metadata Edit ──────────────────────────────────
|
||||
@app.patch("/batch-edit-metadata")
|
||||
async def batch_edit_metadata(update: BatchMetadataUpdate):
|
||||
"""Apply the same tag changes to multiple files at once."""
|
||||
results = {"succeeded": [], "failed": []}
|
||||
tags = {}
|
||||
if update.title: tags["title"] = update.title
|
||||
if update.artist: tags["artist"] = update.artist
|
||||
if update.album: tags["album"] = update.album
|
||||
if update.album_artist: tags["album_artist"] = update.album_artist
|
||||
if update.genre: tags["genre"] = update.genre
|
||||
if update.year: tags["year"] = str(update.year)
|
||||
|
||||
for rp in update.relative_paths:
|
||||
fp = resolve_path(rp)
|
||||
if not fp:
|
||||
results["failed"].append({"path": rp, "error": "File not found"})
|
||||
continue
|
||||
try:
|
||||
apply_tags_dict(fp, tags)
|
||||
results["succeeded"].append(rp)
|
||||
except Exception as e:
|
||||
results["failed"].append({"path": rp, "error": str(e)})
|
||||
|
||||
# Single scan after all edits
|
||||
await trigger_scan()
|
||||
await push.broadcast("batch_metadata_updated", {
|
||||
"count": len(results["succeeded"]),
|
||||
"album": update.album or ""
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
# ── Single File Upload ────────────────────────────────────
|
||||
@app.post("/upload-track")
|
||||
async def upload_track(
|
||||
file: UploadFile = File(...),
|
||||
title: str = Form(...),
|
||||
artist: str = Form(...),
|
||||
album: str = Form(...)
|
||||
):
|
||||
dest = os.path.join(MUSIC_DIR, "uploads")
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
fp = os.path.join(dest, file.filename)
|
||||
with open(fp, "wb") as buf:
|
||||
shutil.copyfileobj(file.file, buf)
|
||||
try:
|
||||
u = MetadataUpdate(relative_path=f"uploads/{file.filename}",
|
||||
title=title, artist=artist, album=album)
|
||||
apply_tags(fp, u)
|
||||
profile = analyze(fp)
|
||||
await trigger_scan()
|
||||
await push.broadcast("track_uploaded", {
|
||||
"filename": file.filename,
|
||||
"profile": json.dumps(profile)
|
||||
})
|
||||
return {"status": "uploaded", "path": f"uploads/{file.filename}", "profile": profile}
|
||||
except Exception as e:
|
||||
if os.path.exists(fp):
|
||||
os.remove(fp)
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
# ── Multi-File Upload ─────────────────────────────────────
|
||||
@app.post("/upload-tracks")
|
||||
async def upload_tracks(
|
||||
files: List[UploadFile] = File(...),
|
||||
metadata_json: str = Form(...)
|
||||
):
|
||||
"""
|
||||
Upload multiple audio files with per-track metadata.
|
||||
|
||||
metadata_json is a JSON string containing a list of TrackUploadMeta objects:
|
||||
[
|
||||
{"filename": "01 - Song.mp3", "title": "Song", "artist": "Artist", "album": "Album", "track_number": 1},
|
||||
...
|
||||
]
|
||||
|
||||
Each entry's filename must match one of the uploaded files.
|
||||
Files are saved to MUSIC_DIR/Artist/Album/ with proper directory structure.
|
||||
"""
|
||||
try:
|
||||
meta_list = json.loads(metadata_json)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(400, "Invalid metadata_json — must be a JSON array")
|
||||
|
||||
# Index metadata by filename
|
||||
meta_by_name = {}
|
||||
for m in meta_list:
|
||||
meta_by_name[m["filename"]] = m
|
||||
|
||||
results = {"uploaded": [], "failed": []}
|
||||
|
||||
for file in files:
|
||||
meta = meta_by_name.get(file.filename)
|
||||
if not meta:
|
||||
# No metadata provided — use filename as title
|
||||
meta = {
|
||||
"filename": file.filename,
|
||||
"title": Path(file.filename).stem,
|
||||
"artist": "Unknown Artist",
|
||||
"album": "Uploads"
|
||||
}
|
||||
|
||||
# Build directory: MUSIC_DIR/Artist/Album/
|
||||
artist_name = meta.get("artist", "Unknown Artist")
|
||||
album_name = meta.get("album", "Uploads")
|
||||
safe_artist = re.sub(r'[<>:"/\\|?*]', '_', artist_name)
|
||||
safe_album = re.sub(r'[<>:"/\\|?*]', '_', album_name)
|
||||
dest_dir = os.path.join(MUSIC_DIR, safe_artist, safe_album)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
fp = os.path.join(dest_dir, file.filename)
|
||||
try:
|
||||
with open(fp, "wb") as buf:
|
||||
shutil.copyfileobj(file.file, buf)
|
||||
|
||||
# Apply tags
|
||||
tags = {
|
||||
"title": meta.get("title", Path(file.filename).stem),
|
||||
"artist": artist_name,
|
||||
"album": album_name,
|
||||
}
|
||||
if meta.get("track_number"):
|
||||
tags["track_number"] = str(meta["track_number"])
|
||||
if meta.get("genre"):
|
||||
tags["genre"] = meta["genre"]
|
||||
if meta.get("year"):
|
||||
tags["year"] = str(meta["year"])
|
||||
if meta.get("album_artist"):
|
||||
tags["album_artist"] = meta["album_artist"]
|
||||
else:
|
||||
tags["album_artist"] = artist_name
|
||||
|
||||
apply_tags_dict(fp, tags)
|
||||
|
||||
# Analyze (non-blocking — catch errors but don't fail the upload)
|
||||
try:
|
||||
analyze(fp)
|
||||
except Exception as e:
|
||||
print(f" Analysis failed for {file.filename}: {e}")
|
||||
|
||||
rel = os.path.relpath(fp, MUSIC_DIR)
|
||||
results["uploaded"].append({
|
||||
"filename": file.filename,
|
||||
"path": rel
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
if os.path.exists(fp):
|
||||
os.remove(fp)
|
||||
results["failed"].append({
|
||||
"filename": file.filename,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
# Single scan after all uploads
|
||||
await trigger_scan()
|
||||
await push.broadcast("tracks_uploaded", {
|
||||
"count": len(results["uploaded"]),
|
||||
"album": meta_list[0].get("album", "") if meta_list else ""
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── Smart DJ ──────────────────────────────────────────────
|
||||
@app.get("/smart-dj/profile")
|
||||
async def get_profile(relative_path: str):
|
||||
fp = resolve_path(relative_path)
|
||||
|
||||
if fp:
|
||||
try:
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
row = c.execute(
|
||||
"SELECT bpm, silence_start, silence_end, loudness_lufs "
|
||||
"FROM dj_profiles WHERE file_path = ?", (fp,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return {"bpm": row[0], "silence_start": row[1],
|
||||
"silence_end": row[2], "loudness_lufs": row[3]}
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"DB error: {e}", flush=True)
|
||||
return analyze(fp)
|
||||
|
||||
# File not found by path — try DB search by filename
|
||||
decoded = relative_path
|
||||
for _ in range(5):
|
||||
nd = unquote(decoded)
|
||||
if nd == decoded: break
|
||||
decoded = nd
|
||||
target = os.path.basename(decoded).lower()
|
||||
if target:
|
||||
try:
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
rows = c.execute(
|
||||
"SELECT file_path, bpm, silence_start, silence_end, loudness_lufs "
|
||||
"FROM dj_profiles WHERE LOWER(file_path) LIKE ?",
|
||||
(f"%{target}",)
|
||||
).fetchall()
|
||||
if rows:
|
||||
print(f" DB fallback: {rows[0][0]}", flush=True)
|
||||
return {"bpm": rows[0][1], "silence_start": rows[0][2],
|
||||
"silence_end": rows[0][3], "loudness_lufs": rows[0][4]}
|
||||
except Exception as e:
|
||||
print(f" DB fallback error: {e}", flush=True)
|
||||
|
||||
raise HTTPException(404,
|
||||
f"Not found. decoded='{decoded}', MUSIC_DIR='{MUSIC_DIR}'")
|
||||
|
||||
|
||||
@app.get("/smart-dj/bulk-profiles")
|
||||
async def bulk_profiles(paths: str = Query(...)):
|
||||
results = {}
|
||||
for rp in (p.strip() for p in paths.split(",") if p.strip()):
|
||||
fp = resolve_path(rp)
|
||||
if not fp:
|
||||
results[rp] = None
|
||||
continue
|
||||
try:
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
row = c.execute(
|
||||
"SELECT bpm, silence_start, silence_end, loudness_lufs "
|
||||
"FROM dj_profiles WHERE file_path = ?", (fp,)
|
||||
).fetchone()
|
||||
if row:
|
||||
results[rp] = {"bpm": row[0], "silence_start": row[1],
|
||||
"silence_end": row[2], "loudness_lufs": row[3]}
|
||||
else:
|
||||
results[rp] = analyze(fp)
|
||||
except:
|
||||
results[rp] = None
|
||||
return results
|
||||
|
||||
|
||||
# ── Visualizer ────────────────────────────────────────────
|
||||
@app.get("/visualizer/frames")
|
||||
async def vis_frames(relative_path: str):
|
||||
fp = resolve_path(relative_path)
|
||||
if not fp:
|
||||
raise HTTPException(404, "Not found")
|
||||
frames = get_vis(fp)
|
||||
if not frames:
|
||||
raise HTTPException(500, "Generation failed")
|
||||
return {"frame_count": len(frames), "fps": 30.0, "points": 20, "frames": frames}
|
||||
|
||||
|
||||
@app.post("/visualizer/precompute")
|
||||
async def precompute(background_tasks: BackgroundTasks, relative_path: str = ""):
|
||||
def compute_all():
|
||||
exts = ('.mp3', '.flac', '.m4a', '.ogg', '.opus', '.wav')
|
||||
n = 0
|
||||
for root, _, files in os.walk(MUSIC_DIR):
|
||||
for f in files:
|
||||
if f.lower().endswith(exts):
|
||||
fp = os.path.join(root, f)
|
||||
if not os.path.exists(vis_cache_file(fp)):
|
||||
try:
|
||||
get_vis(fp)
|
||||
n += 1
|
||||
except:
|
||||
pass
|
||||
print(f"✨ Pre-computed {n} vis caches")
|
||||
|
||||
if relative_path:
|
||||
background_tasks.add_task(lambda: get_vis(resolve_path(relative_path) or ""))
|
||||
return {"message": f"Computing: {relative_path}"}
|
||||
background_tasks.add_task(compute_all)
|
||||
return {"message": "Background vis computation started"}
|
||||
|
||||
|
||||
@app.post("/bulk-fix")
|
||||
async def bulk_fix(background_tasks: BackgroundTasks):
|
||||
background_tasks.add_task(trigger_scan)
|
||||
return {"message": "Scan triggered"}
|
||||
|
||||
|
||||
# ── WebSocket Push ────────────────────────────────────────
|
||||
@app.websocket("/ws/push")
|
||||
async def ws_push(ws: WebSocket):
|
||||
await push.connect(ws)
|
||||
try:
|
||||
while True:
|
||||
data = json.loads(await ws.receive_text())
|
||||
act = data.get("action")
|
||||
if act == "ping":
|
||||
await push.send_to(ws, "pong", {"t": str(time.time())})
|
||||
elif act == "get_profile":
|
||||
rp = data.get("path", "")
|
||||
fp = resolve_path(rp)
|
||||
if fp:
|
||||
try:
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
row = c.execute(
|
||||
"SELECT bpm, silence_start, silence_end, loudness_lufs "
|
||||
"FROM dj_profiles WHERE file_path = ?", (fp,)
|
||||
).fetchone()
|
||||
if row:
|
||||
await push.send_to(ws, "profile", {
|
||||
"path": rp, "bpm": str(row[0]),
|
||||
"silence_start": str(row[1]),
|
||||
"silence_end": str(row[2]),
|
||||
"loudness_lufs": str(row[3])
|
||||
})
|
||||
else:
|
||||
await push.send_to(ws, "profile",
|
||||
{"path": rp, "error": "not_analyzed"})
|
||||
except Exception as e:
|
||||
await push.send_to(ws, "error", {"message": str(e)})
|
||||
elif act == "get_vis":
|
||||
rp = data.get("path", "")
|
||||
fp = resolve_path(rp)
|
||||
if fp:
|
||||
frames = get_vis(fp)
|
||||
if frames:
|
||||
await push.send_to(ws, "vis_frames", {
|
||||
"path": rp, "count": str(len(frames)),
|
||||
"fps": "30", "frames": json.dumps(frames)
|
||||
})
|
||||
except WebSocketDisconnect:
|
||||
push.disconnect(ws)
|
||||
except Exception as e:
|
||||
print(f"WS error: {e}")
|
||||
push.disconnect(ws)
|
||||
302
companion-api/pre_analyze.py
Normal file
302
companion-api/pre_analyze.py
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
"""
|
||||
Pre-Analyzer (crash-safe)
|
||||
Location: /home/pi/docker/navidrome/companion_api/pre_analyze.py
|
||||
|
||||
Each track is analyzed in a subprocess. If librosa OOMs or hangs on a large FLAC,
|
||||
only the child process dies — the parent logs the failure and moves on.
|
||||
|
||||
Usage:
|
||||
docker compose exec music-companion python pre_analyze.py
|
||||
docker compose exec music-companion python pre_analyze.py --force
|
||||
docker compose exec music-companion python pre_analyze.py --dj
|
||||
docker compose exec music-companion python pre_analyze.py --vis
|
||||
docker compose exec music-companion python pre_analyze.py --skip-large 500
|
||||
"""
|
||||
import os, sys, json, hashlib, sqlite3, subprocess, time, warnings, multiprocessing
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
MUSIC_DIR = os.getenv("MUSIC_DIR", "/music")
|
||||
DB_PATH = os.getenv("DB_PATH", "/app/data/smart_dj.db")
|
||||
VIS_CACHE_DIR = os.getenv("VIS_CACHE_DIR", "/app/data/vis_cache")
|
||||
SUPPORTED = ('.mp3', '.flac', '.m4a', '.ogg', '.opus', '.wav')
|
||||
TRACK_TIMEOUT = int(os.getenv("TRACK_TIMEOUT", "180")) # 3 min per track
|
||||
|
||||
|
||||
def init_db():
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS dj_profiles (
|
||||
file_path TEXT PRIMARY KEY, bpm REAL,
|
||||
silence_start REAL, silence_end REAL,
|
||||
loudness_lufs REAL,
|
||||
analyzed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""")
|
||||
|
||||
|
||||
def is_dj_done(path):
|
||||
try:
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
return c.execute("SELECT 1 FROM dj_profiles WHERE file_path=?", (path,)).fetchone() is not None
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
def vis_path(path):
|
||||
return os.path.join(VIS_CACHE_DIR, hashlib.md5(path.encode()).hexdigest() + ".json")
|
||||
|
||||
|
||||
def is_vis_done(path):
|
||||
return os.path.exists(vis_path(path))
|
||||
|
||||
|
||||
def fmt(mb):
|
||||
return f"{mb/1024:.2f} GB" if mb >= 1024 else f"{mb:.1f} MB"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# CHILD PROCESS — runs in isolation, can be killed without
|
||||
# taking down the parent
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _worker(full_path, do_dj, do_vis, result_dict):
|
||||
"""Runs in a child process. Writes results to shared dict."""
|
||||
import re, gc, warnings
|
||||
warnings.filterwarnings("ignore")
|
||||
import numpy as np
|
||||
|
||||
dj_ok = False
|
||||
vis_ok = False
|
||||
error_msg = None
|
||||
|
||||
# ── DJ Analysis ───────────────────────────────────────
|
||||
if do_dj:
|
||||
try:
|
||||
import librosa
|
||||
|
||||
# ffmpeg for silence + loudness (streams, low memory)
|
||||
cmd = ["ffmpeg", "-hide_banner", "-i", full_path,
|
||||
"-af", "silencedetect=noise=-50dB:d=0.5,ebur128",
|
||||
"-f", "null", "-"]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
|
||||
out = r.stderr
|
||||
|
||||
ss = re.findall(r"silence_start: ([\d\.]+)", out)
|
||||
se = re.findall(r"silence_end: ([\d\.]+)", out)
|
||||
lu = re.search(r"I:\s+([\-\d\.]+) LUFS", out)
|
||||
|
||||
# trailing silence start = last silence_start (crossfade trigger)
|
||||
sil_start = float(ss[-1]) if ss else 0.0
|
||||
# leading silence end = first silence_end (skip-to point)
|
||||
sil_end = float(se[0]) if se else 0.0
|
||||
loudness = float(lu.group(1)) if lu else -14.0
|
||||
|
||||
# Sanity checks
|
||||
if sil_end > 10.0:
|
||||
sil_end = 0.0
|
||||
dur_match = re.search(r"Duration: (\d+):(\d+):(\d+\.\d+)", out)
|
||||
if dur_match:
|
||||
total_dur = int(dur_match.group(1)) * 3600 + int(dur_match.group(2)) * 60 + float(dur_match.group(3))
|
||||
if sil_start < total_dur * 0.5:
|
||||
sil_start = total_dur
|
||||
|
||||
# BPM — load only 30s at low rate
|
||||
y, sr = librosa.load(full_path, sr=22050, duration=30)
|
||||
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
|
||||
del y; gc.collect()
|
||||
|
||||
try:
|
||||
bpm = float(tempo)
|
||||
except TypeError:
|
||||
bpm = float(tempo[0]) if tempo is not None else 0.0
|
||||
|
||||
with sqlite3.connect(DB_PATH) as c:
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO dj_profiles VALUES (?,?,?,?,?,CURRENT_TIMESTAMP)",
|
||||
(full_path, round(bpm,1), round(sil_start,3),
|
||||
round(sil_end,3), round(loudness,1)))
|
||||
dj_ok = True
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = "ffmpeg timeout (>90s)"
|
||||
except MemoryError:
|
||||
error_msg = "OUT OF MEMORY during DJ analysis"
|
||||
except Exception as e:
|
||||
error_msg = f"DJ: {e}"
|
||||
|
||||
# ── Vis Frames ────────────────────────────────────────
|
||||
if do_vis:
|
||||
cache_file = vis_path(full_path)
|
||||
if os.path.exists(cache_file):
|
||||
vis_ok = False # already cached
|
||||
else:
|
||||
try:
|
||||
import librosa
|
||||
|
||||
# Load at 22050 to save memory
|
||||
y, sr = librosa.load(full_path, sr=22050, mono=True)
|
||||
fps = 30.0; fft_size = 1024; pts = 20
|
||||
hop = int(sr / fps)
|
||||
frames = []
|
||||
|
||||
for start in range(0, len(y) - fft_size, hop):
|
||||
chunk = y[start:start+fft_size] * np.hanning(fft_size)
|
||||
spec = np.sqrt(np.abs(np.fft.rfft(chunk)) / fft_size)
|
||||
half = len(spec); cutoff = min(half-1, 90)
|
||||
fp = []
|
||||
for i in range(pts):
|
||||
ni = (i+1)/pts; li = np.log10(ni*9+1)
|
||||
cb = li*cutoff; bw = max(1, cutoff/pts*li)
|
||||
sb = max(1, int(cb-bw/2)); eb = min(cutoff, int(cb+bw/2))
|
||||
avg = float(np.mean(spec[sb:eb+1])) if sb<=eb<half else 0
|
||||
fp.append(avg * (1 + i/pts*3.5))
|
||||
frames.append(fp)
|
||||
|
||||
del y; gc.collect()
|
||||
|
||||
# Normalize
|
||||
vals = sorted(v for f in frames for v in f if v > 0.001)
|
||||
if vals:
|
||||
p95 = vals[min(int(len(vals)*0.95), len(vals)-1)]
|
||||
if p95 > 0.001:
|
||||
s = 0.8/p95
|
||||
frames = [[min(1.0, v*s) for v in f] for f in frames]
|
||||
|
||||
with open(cache_file, "w") as f:
|
||||
json.dump(frames, f)
|
||||
del frames; gc.collect()
|
||||
vis_ok = True
|
||||
|
||||
except MemoryError:
|
||||
error_msg = (error_msg + " | " if error_msg else "") + "OUT OF MEMORY during vis"
|
||||
except Exception as e:
|
||||
error_msg = (error_msg + " | " if error_msg else "") + f"Vis: {e}"
|
||||
|
||||
result_dict["dj"] = dj_ok
|
||||
result_dict["vis"] = vis_ok
|
||||
result_dict["error"] = error_msg
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# MAIN — runs workers as subprocesses
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def scan(force=False, dj_only=False, vis_only=False, skip_large_mb=0):
|
||||
init_db()
|
||||
os.makedirs(VIS_CACHE_DIR, exist_ok=True)
|
||||
|
||||
print(f"🔍 Scanning {MUSIC_DIR}...")
|
||||
tracks = []
|
||||
total_bytes = 0
|
||||
for root, _, files in os.walk(MUSIC_DIR):
|
||||
for f in files:
|
||||
if f.lower().endswith(SUPPORTED):
|
||||
fp = os.path.join(root, f)
|
||||
tracks.append(fp)
|
||||
total_bytes += os.path.getsize(fp)
|
||||
|
||||
total_mb = total_bytes / 1048576
|
||||
mode = "FORCE" if force else "missing"
|
||||
what = "DJ only" if dj_only else "Vis only" if vis_only else "DJ + Vis"
|
||||
print(f"🚀 {len(tracks)} tracks ({fmt(total_mb)}) — {what} ({mode})")
|
||||
print(f" Timeout: {TRACK_TIMEOUT}s per track")
|
||||
if skip_large_mb:
|
||||
print(f" Skipping files > {skip_large_mb} MB")
|
||||
print(f" Each track runs in a subprocess (crash-safe)")
|
||||
print()
|
||||
|
||||
dj_n = vis_n = skip_n = fail_n = 0
|
||||
t0 = time.time()
|
||||
|
||||
for idx, path in enumerate(tracks):
|
||||
name = os.path.basename(path)
|
||||
size_mb = os.path.getsize(path) / 1048576
|
||||
tag = f"[{idx+1}/{len(tracks)}]"
|
||||
|
||||
# Skip oversized
|
||||
if skip_large_mb and size_mb > skip_large_mb:
|
||||
print(f" {tag} ⏭ SKIP ({fmt(size_mb)} > {skip_large_mb}MB): {name}")
|
||||
skip_n += 1
|
||||
continue
|
||||
|
||||
# Check what's needed
|
||||
need_dj = not vis_only and (force or not is_dj_done(path))
|
||||
need_vis = not dj_only and (force or not is_vis_done(path))
|
||||
|
||||
if not need_dj and not need_vis:
|
||||
skip_n += 1
|
||||
continue
|
||||
|
||||
tasks = []
|
||||
if need_dj: tasks.append("DJ")
|
||||
if need_vis: tasks.append("Vis")
|
||||
|
||||
print(f" {tag} 🎵 {'+'.join(tasks)} ({fmt(size_mb)}): {name}", end="", flush=True)
|
||||
t1 = time.time()
|
||||
|
||||
# Run in subprocess with shared dict for results
|
||||
manager = multiprocessing.Manager()
|
||||
result = manager.dict({"dj": False, "vis": False, "error": None})
|
||||
|
||||
proc = multiprocessing.Process(
|
||||
target=_worker,
|
||||
args=(path, need_dj, need_vis, result)
|
||||
)
|
||||
proc.start()
|
||||
proc.join(timeout=TRACK_TIMEOUT)
|
||||
|
||||
elapsed = time.time() - t1
|
||||
|
||||
if proc.is_alive():
|
||||
# Timed out — kill it
|
||||
proc.kill()
|
||||
proc.join()
|
||||
print(f" ⏰ KILLED after {elapsed:.0f}s (timeout)")
|
||||
fail_n += 1
|
||||
continue
|
||||
|
||||
if proc.exitcode != 0 and proc.exitcode is not None:
|
||||
# Crashed (OOM, segfault, etc.)
|
||||
print(f" 💥 CRASHED (exit code {proc.exitcode}, {elapsed:.1f}s)")
|
||||
fail_n += 1
|
||||
continue
|
||||
|
||||
# Success path
|
||||
err = result.get("error")
|
||||
if result.get("dj"):
|
||||
dj_n += 1
|
||||
if result.get("vis"):
|
||||
vis_n += 1
|
||||
|
||||
if err:
|
||||
print(f" ⚠ {elapsed:.1f}s — {err}")
|
||||
fail_n += 1
|
||||
else:
|
||||
print(f" ✓ {elapsed:.1f}s")
|
||||
|
||||
total_elapsed = time.time() - t0
|
||||
m = int(total_elapsed // 60)
|
||||
s = int(total_elapsed % 60)
|
||||
|
||||
print(f"\n✨ Done in {m}m {s}s")
|
||||
print(f" DJ profiles: {dj_n} new")
|
||||
print(f" Vis frames: {vis_n} new")
|
||||
print(f" Skipped: {skip_n} (already done)")
|
||||
if fail_n:
|
||||
print(f" ⚠ Failed: {fail_n} (see errors above)")
|
||||
print(f" Tip: re-run with --skip-large 200 to skip huge FLACs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[1:]
|
||||
force = "--force" in args
|
||||
dj_only = "--dj" in args
|
||||
vis_only = "--vis" in args
|
||||
|
||||
skip_large = 0
|
||||
if "--skip-large" in args:
|
||||
i = args.index("--skip-large")
|
||||
if i + 1 < len(args):
|
||||
try: skip_large = int(args[i+1])
|
||||
except: pass
|
||||
|
||||
scan(force=force, dj_only=dj_only, vis_only=vis_only, skip_large_mb=skip_large)
|
||||
|
|
@ -553,6 +553,7 @@ struct MiniPlayerBar: View {
|
|||
if VisualizerSettings.shared.enabled && VisualizerSettings.shared.miniPlayerEnabled && !showNowPlaying {
|
||||
CompactVisualizerView(
|
||||
isPlaying: audioPlayer.isPlaying,
|
||||
isSongLoaded: audioPlayer.currentSong != nil,
|
||||
accentColor: colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink,
|
||||
height: VisualizerSettings.shared.miniPlayerHeight
|
||||
)
|
||||
|
|
@ -694,6 +695,7 @@ struct DynamicIslandView: View {
|
|||
if VisualizerSettings.shared.enabled {
|
||||
CompactVisualizerView(
|
||||
isPlaying: audioPlayer.isPlaying,
|
||||
isSongLoaded: audioPlayer.currentSong != nil,
|
||||
accentColor: themeColor,
|
||||
height: 32
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import SwiftUI
|
|||
|
||||
struct CompanionSettingsView: View {
|
||||
@ObservedObject private var settings = CompanionSettings.shared
|
||||
@ObservedObject private var crossfade = SmartCrossfadeManager.shared
|
||||
|
||||
@State private var testStatus: TestStatus = .idle
|
||||
@State private var hostInput: String = ""
|
||||
|
|
@ -97,44 +96,8 @@ struct CompanionSettingsView: View {
|
|||
|
||||
// Smart DJ — available with or without Companion API
|
||||
Section {
|
||||
Toggle("Smart DJ", isOn: $settings.smartDJEnabled)
|
||||
.tint(accentPink)
|
||||
|
||||
if settings.smartDJEnabled {
|
||||
Toggle("Smart Crossfade", isOn: $crossfade.isEnabled)
|
||||
.tint(accentPink)
|
||||
|
||||
if crossfade.isEnabled {
|
||||
HStack {
|
||||
Text("Crossfade")
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("\(String(format: "%.0f", crossfade.crossfadeDuration))s")
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 30)
|
||||
Slider(value: $crossfade.crossfadeDuration, in: 1...12, step: 1)
|
||||
.tint(accentPink)
|
||||
.frame(width: 140)
|
||||
}
|
||||
HStack {
|
||||
Text("Target LUFS")
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("\(String(format: "%.0f", crossfade.targetLUFS))")
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 30)
|
||||
Slider(value: $crossfade.targetLUFS, in: -24 ... -8, step: 1)
|
||||
.tint(accentPink)
|
||||
.frame(width: 140)
|
||||
}
|
||||
Toggle("Skip Silence", isOn: $crossfade.skipSilence)
|
||||
.tint(accentPink)
|
||||
}
|
||||
}
|
||||
} header: { Text("Smart DJ") } footer: {
|
||||
Text(settings.isEnabled
|
||||
? "Uses server-side BPM, silence, and loudness analysis for seamless crossfades."
|
||||
: "On-device analysis (no Companion API needed): silence detection and loudness for downloaded songs. BPM requires the API.")
|
||||
Text("Smart DJ and Crossfade settings have moved to Settings → Smart DJ & Visualizer.")
|
||||
}
|
||||
|
||||
if settings.isEnabled {
|
||||
|
|
@ -148,7 +111,7 @@ struct CompanionSettingsView: View {
|
|||
} header: { Text("Upload") } footer: {
|
||||
Text("Import a .zip archive of audio files, batch-tag them, and upload to your server via the Companion API.")
|
||||
}
|
||||
|
||||
|
||||
Section {
|
||||
Button(action: triggerScan) {
|
||||
HStack {
|
||||
|
|
@ -156,18 +119,8 @@ struct CompanionSettingsView: View {
|
|||
Text("Trigger Navidrome Scan").foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Smart DJ Profiles Cached").foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("\(SmartDJCache.shared.cachedCount)").foregroundColor(.white)
|
||||
}
|
||||
Button(action: { SmartDJCache.shared.clearAll() }) {
|
||||
Text("Clear Profile Cache").foregroundColor(.red)
|
||||
}
|
||||
} header: { Text("Server Actions") }
|
||||
}
|
||||
|
||||
if settings.smartDJEnabled {
|
||||
|
||||
Section {
|
||||
Button(action: { triggerPreAnalyze(force: false) }) {
|
||||
HStack {
|
||||
|
|
@ -175,53 +128,32 @@ struct CompanionSettingsView: View {
|
|||
.foregroundColor(accentPink)
|
||||
.symbolEffect(.pulse, isActive: analyzeStatus == .analyzing)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Pre-Analyze Missing Songs").foregroundColor(.white)
|
||||
Text(settings.isEnabled
|
||||
? "Server + on-device for songs without a profile"
|
||||
: "On-device analysis for downloaded songs only")
|
||||
.font(.system(size: 11)).foregroundColor(.gray)
|
||||
Text("Pre-Compute Missing Visualizer Frames").foregroundColor(.white)
|
||||
Text("Generate Mitsuha FFT frames on the server for un-cached songs")
|
||||
.font(.caption).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(analyzeStatus == .analyzing)
|
||||
|
||||
|
||||
Button(action: { triggerPreAnalyze(force: true) }) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.clockwise").foregroundColor(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Re-Analyze All Songs").foregroundColor(.white)
|
||||
Text("Clear cache and re-analyze every downloaded track")
|
||||
.font(.system(size: 11)).foregroundColor(.gray)
|
||||
Text("Re-Compute All Visualizer Frames").foregroundColor(.white)
|
||||
Text("Force regenerate server-side frames for every track")
|
||||
.font(.caption).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(analyzeStatus == .analyzing)
|
||||
|
||||
if settings.isEnabled {
|
||||
Button(action: triggerVisPrecompute) {
|
||||
HStack {
|
||||
Image(systemName: "waveform.path").foregroundColor(accentPink)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Pre-Compute Visualizer Frames").foregroundColor(.white)
|
||||
Text("Generate Mitsuha FFT frames on the server")
|
||||
.font(.system(size: 11)).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(analyzeStatus == .analyzing)
|
||||
}
|
||||
|
||||
|
||||
if let msg = analyzeMessage {
|
||||
Text(msg)
|
||||
.font(.system(size: 12))
|
||||
Text(msg).font(.caption)
|
||||
.foregroundColor(analyzeStatus == .done ? .green : analyzeStatus == .failed ? .red : .gray)
|
||||
}
|
||||
} header: {
|
||||
Text(settings.isEnabled ? "Analysis (Server + On-Device)" : "Analysis (On-Device)")
|
||||
} footer: {
|
||||
Text(settings.isEnabled
|
||||
? "Server analysis runs on the Pi. On-device analysis handles downloaded songs. Both feed the same Smart DJ cache."
|
||||
: "Scans downloaded songs for silence boundaries and approximate loudness. Cached locally for crossfade use.")
|
||||
} header: { Text("Server Analysis (Runs on Pi)") } footer: {
|
||||
Text("These commands run on your Companion API server. Large libraries may take a few minutes.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -230,7 +162,7 @@ struct CompanionSettingsView: View {
|
|||
}
|
||||
|
||||
// MARK: - Trigger Server Scan
|
||||
|
||||
|
||||
private func triggerScan() {
|
||||
Task {
|
||||
do {
|
||||
|
|
@ -241,81 +173,12 @@ struct CompanionSettingsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pre-Analyze (server + on-device combined)
|
||||
|
||||
|
||||
// MARK: - Server Visualizer Precompute
|
||||
|
||||
private func triggerPreAnalyze(force: Bool) {
|
||||
analyzeStatus = .analyzing
|
||||
analyzeMessage = "Starting analysis..."
|
||||
|
||||
Task {
|
||||
var serverMsg: String? = nil
|
||||
var onDeviceCount = 0
|
||||
var onDeviceErrors = 0
|
||||
|
||||
// 1. Server-side analysis
|
||||
if CompanionSettings.shared.isEnabled {
|
||||
do {
|
||||
guard let base = CompanionSettings.shared.baseURL else { return }
|
||||
var req = URLRequest(url: base.appendingPathComponent("visualizer/precompute"))
|
||||
req.httpMethod = "POST"
|
||||
req.timeoutInterval = 10
|
||||
let (data, _) = try await URLSession.shared.data(for: req)
|
||||
serverMsg = (try? JSONDecoder().decode([String: String].self, from: data))?["message"] ?? "Server analysis started"
|
||||
} catch {
|
||||
serverMsg = "Server unavailable — on-device only"
|
||||
}
|
||||
}
|
||||
|
||||
// 2. On-device analysis for downloaded songs
|
||||
let offlineSongs = await MainActor.run { OfflineManager.shared.downloadedSongs }
|
||||
let total = offlineSongs.count
|
||||
|
||||
for (idx, downloaded) in offlineSongs.enumerated() {
|
||||
let song = downloaded.song
|
||||
guard let path = song.path else { continue }
|
||||
if !force && SmartDJCache.shared.get(path) != nil { continue }
|
||||
guard let localURL = OfflineManager.shared.localURL(for: song.id) else { continue }
|
||||
|
||||
await MainActor.run {
|
||||
analyzeMessage = "Analyzing \(idx + 1)/\(total): \(song.title)"
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ(
|
||||
url: localURL, pointsCount: 10, fps: 10.0, cutoff: 80,
|
||||
extractSmartDJ: true, progress: nil)
|
||||
SmartDJCache.shared.store(SmartDJProfile(
|
||||
bpm: nil, silenceStart: result.silenceStart,
|
||||
silenceEnd: result.silenceEnd, loudnessLUFS: result.loudnessLUFS
|
||||
), for: path)
|
||||
onDeviceCount += 1
|
||||
} catch {
|
||||
onDeviceErrors += 1
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Summary
|
||||
await MainActor.run {
|
||||
var parts: [String] = []
|
||||
if let s = serverMsg { parts.append(s) }
|
||||
if onDeviceCount > 0 { parts.append("✓ \(onDeviceCount) on-device") }
|
||||
if onDeviceErrors > 0 { parts.append("\(onDeviceErrors) failed") }
|
||||
if parts.isEmpty { parts.append("✓ Nothing new to analyze") }
|
||||
analyzeStatus = .done
|
||||
analyzeMessage = parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .seconds(6))
|
||||
await MainActor.run {
|
||||
if analyzeStatus == .done { analyzeStatus = .idle; analyzeMessage = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
private func triggerVisPrecompute() {
|
||||
analyzeStatus = .analyzing
|
||||
analyzeMessage = "Generating visualizer frames on server..."
|
||||
|
||||
analyzeMessage = force ? "Requesting full re-compute on server..." : "Requesting precompute on server..."
|
||||
Task {
|
||||
do {
|
||||
guard let base = CompanionSettings.shared.baseURL else { return }
|
||||
|
|
@ -323,26 +186,13 @@ struct CompanionSettingsView: View {
|
|||
req.httpMethod = "POST"
|
||||
req.timeoutInterval = 10
|
||||
let (data, _) = try await URLSession.shared.data(for: req)
|
||||
let msg = (try? JSONDecoder().decode([String:String].self, from: data))?["message"] ?? "Started"
|
||||
|
||||
await MainActor.run {
|
||||
analyzeStatus = .done
|
||||
analyzeMessage = "✓ \(msg)"
|
||||
}
|
||||
let msg = (try? JSONDecoder().decode([String: String].self, from: data))?["message"] ?? "Started"
|
||||
await MainActor.run { analyzeStatus = .done; analyzeMessage = "✓ \(msg)" }
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
analyzeStatus = .failed
|
||||
analyzeMessage = "✗ \(error.localizedDescription)"
|
||||
}
|
||||
await MainActor.run { analyzeStatus = .failed; analyzeMessage = "✗ \(error.localizedDescription)" }
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
await MainActor.run {
|
||||
if analyzeStatus != .analyzing {
|
||||
analyzeStatus = .idle
|
||||
analyzeMessage = nil
|
||||
}
|
||||
}
|
||||
await MainActor.run { if analyzeStatus != .analyzing { analyzeStatus = .idle; analyzeMessage = nil } }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -505,10 +505,7 @@ struct SettingsView: View {
|
|||
|
||||
@State private var showVisualizerSettings = false
|
||||
@State private var cacheSizeText = "..."
|
||||
@State private var visCacheText = "..."
|
||||
@State private var isAnalyzing = false
|
||||
@State private var analysisProgress = ""
|
||||
|
||||
|
||||
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||
|
||||
let streamOptions = StreamingQuality.formatOptions
|
||||
|
|
@ -638,15 +635,23 @@ struct SettingsView: View {
|
|||
.tint(accentPink)
|
||||
}
|
||||
|
||||
// Visualizer
|
||||
Section("Visualizer") {
|
||||
Button(action: { showVisualizerSettings = true }) {
|
||||
// Visualizer & Smart DJ
|
||||
Section("Audio & Visualizer") {
|
||||
NavigationLink {
|
||||
SmartDJVisualizerSettingsView()
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Visualizer Settings")
|
||||
.foregroundColor(.white)
|
||||
Text("Smart DJ & Visualizer")
|
||||
Spacer()
|
||||
Text(VisualizerSettings.shared.style.rawValue)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
Button(action: { showVisualizerSettings = true }) {
|
||||
HStack {
|
||||
Text("Visualizer Appearance")
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
|
|
@ -661,74 +666,18 @@ struct SettingsView: View {
|
|||
cacheSizeText = "0 MB"
|
||||
}) {
|
||||
HStack {
|
||||
Text("Clear Image Cache")
|
||||
.foregroundColor(.white)
|
||||
Text("Clear Image Cache").foregroundColor(.white)
|
||||
Spacer()
|
||||
Text(cacheSizeText)
|
||||
.foregroundColor(.gray)
|
||||
Text(cacheSizeText).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
Button(action: {
|
||||
LibraryCache.shared.clearAll()
|
||||
}) {
|
||||
Button(action: { LibraryCache.shared.clearAll() }) {
|
||||
HStack {
|
||||
Text("Clear Library Cache")
|
||||
.foregroundColor(.white)
|
||||
Text("Clear Library Cache").foregroundColor(.white)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
Button(action: {
|
||||
Task {
|
||||
await VisualizerStorageManager.shared.clearCache()
|
||||
await MainActor.run { visCacheText = "Cleared" }
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Text("Clear Visualizer Cache")
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text(visCacheText)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-analyze missing songs
|
||||
Button(action: { analyzeAllMissing(force: false) }) {
|
||||
HStack {
|
||||
Text("Pre-Analyze Missing Songs")
|
||||
.foregroundColor(isAnalyzing ? .gray : .white)
|
||||
Spacer()
|
||||
if isAnalyzing {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().tint(accentPink)
|
||||
Text(analysisProgress)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "waveform.badge.magnifyingglass")
|
||||
.foregroundColor(accentPink)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isAnalyzing)
|
||||
|
||||
// Force re-analyze all songs
|
||||
Button(action: { analyzeAllMissing(force: true) }) {
|
||||
HStack {
|
||||
Text("Re-Analyze All Songs")
|
||||
.foregroundColor(isAnalyzing ? .gray : .orange)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.foregroundColor(isAnalyzing ? .gray : .orange)
|
||||
}
|
||||
}
|
||||
.disabled(isAnalyzing)
|
||||
} header: {
|
||||
Text("Storage")
|
||||
} footer: {
|
||||
Text("Pre-Analyze scans downloaded songs without visualizer data. Re-Analyze clears all caches and rebuilds from scratch.")
|
||||
}
|
||||
} header: { Text("Storage") }
|
||||
|
||||
// About
|
||||
Section {
|
||||
|
|
@ -766,13 +715,6 @@ struct SettingsView: View {
|
|||
}
|
||||
.onAppear {
|
||||
cacheSizeText = computeCacheSize()
|
||||
Task {
|
||||
let size = await VisualizerStorageManager.shared.cacheSize()
|
||||
let count = await VisualizerStorageManager.shared.cachedTrackCount()
|
||||
await MainActor.run {
|
||||
visCacheText = "\(count) tracks · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -792,86 +734,184 @@ struct SettingsView: View {
|
|||
}
|
||||
return ByteCountFormatter.string(fromByteCount: total, countStyle: .file)
|
||||
}
|
||||
|
||||
private func analyzeAllMissing(force: Bool) {
|
||||
}
|
||||
|
||||
// MARK: - Smart DJ & Visualizer Settings
|
||||
struct SmartDJVisualizerSettingsView: View {
|
||||
@ObservedObject private var crossfade = SmartCrossfadeManager.shared
|
||||
@ObservedObject private var compSettings = CompanionSettings.shared
|
||||
@State private var visCacheText = "—"
|
||||
@State private var smartDJCacheCount = 0
|
||||
@State private var isAnalyzing = false
|
||||
@State private var analysisProgress = ""
|
||||
|
||||
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// Smart DJ
|
||||
Section {
|
||||
Toggle("Smart DJ", isOn: $compSettings.smartDJEnabled).tint(accentPink)
|
||||
if compSettings.smartDJEnabled {
|
||||
Toggle("Smart Crossfade", isOn: $crossfade.isEnabled).tint(accentPink)
|
||||
if crossfade.isEnabled {
|
||||
HStack {
|
||||
Text("Duration").foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("\(String(format: "%.1f", crossfade.crossfadeDuration))s").foregroundColor(.white)
|
||||
}
|
||||
Slider(value: $crossfade.crossfadeDuration, in: 1...12, step: 0.5).tint(accentPink)
|
||||
HStack {
|
||||
Text("Target LUFS").foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("\(String(format: "%.0f", crossfade.targetLUFS)) LUFS").foregroundColor(.white)
|
||||
}
|
||||
Slider(value: $crossfade.targetLUFS, in: -24 ... -8, step: 1).tint(accentPink)
|
||||
Toggle("Skip Silence", isOn: $crossfade.skipSilence).tint(accentPink)
|
||||
}
|
||||
}
|
||||
} header: { Text("Smart DJ") } footer: {
|
||||
Text("Smart DJ uses on-device analysis for downloaded songs (silence detection + loudness). When the Companion API is enabled, server-side BPM data is also used.")
|
||||
}
|
||||
|
||||
// Combined Analysis
|
||||
Section {
|
||||
Button(action: { runAnalysis(force: false) }) {
|
||||
HStack {
|
||||
Image(systemName: isAnalyzing ? "waveform" : "waveform.badge.magnifyingglass")
|
||||
.foregroundColor(accentPink)
|
||||
.symbolEffect(.pulse, isActive: isAnalyzing)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Pre-Analyze Missing Songs").foregroundColor(.white)
|
||||
Text("Vis frames + Smart DJ profile for un-analyzed songs")
|
||||
.font(.caption).foregroundColor(.gray)
|
||||
}
|
||||
Spacer()
|
||||
if isAnalyzing {
|
||||
Text(analysisProgress).font(.caption).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isAnalyzing)
|
||||
|
||||
Button(action: { runAnalysis(force: true) }) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.clockwise").foregroundColor(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Re-Analyze All Songs").foregroundColor(.white)
|
||||
Text("Clear all caches and rebuild from scratch")
|
||||
.font(.caption).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isAnalyzing)
|
||||
} header: { Text("Analysis (On-Device)") } footer: {
|
||||
Text("Each song is read once to produce both its Mitsuha visualizer frames and its Smart DJ profile (silence boundaries + loudness). Results are cached on-device.")
|
||||
}
|
||||
|
||||
// Cache management
|
||||
Section {
|
||||
HStack {
|
||||
Text("Visualizer Cache").foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text(visCacheText).foregroundColor(.white).font(.caption)
|
||||
}
|
||||
Button(action: {
|
||||
Task {
|
||||
await VisualizerStorageManager.shared.clearCache()
|
||||
await refreshVisCacheText()
|
||||
}
|
||||
}) {
|
||||
Text("Clear Visualizer Cache").foregroundColor(.red)
|
||||
}
|
||||
HStack {
|
||||
Text("Smart DJ Profiles Cached").foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("\(smartDJCacheCount)").foregroundColor(.white).font(.caption)
|
||||
}
|
||||
Button(action: {
|
||||
SmartDJCache.shared.clearAll()
|
||||
smartDJCacheCount = 0
|
||||
}) {
|
||||
Text("Clear Smart DJ Cache").foregroundColor(.red)
|
||||
}
|
||||
} header: { Text("Cache") }
|
||||
}
|
||||
.navigationTitle("Smart DJ & Visualizer")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
smartDJCacheCount = SmartDJCache.shared.cachedCount
|
||||
Task { await refreshVisCacheText() }
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshVisCacheText() async {
|
||||
let size = await VisualizerStorageManager.shared.cacheSize()
|
||||
let count = await VisualizerStorageManager.shared.cachedTrackCount()
|
||||
await MainActor.run {
|
||||
visCacheText = "\(count) tracks · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))"
|
||||
}
|
||||
}
|
||||
|
||||
private func runAnalysis(force: Bool) {
|
||||
guard !isAnalyzing else { return }
|
||||
isAnalyzing = true
|
||||
analysisProgress = "Scanning..."
|
||||
|
||||
|
||||
let downloaded = OfflineManager.shared.downloadedSongs
|
||||
let points = VisualizerSettings.shared.numberOfPoints
|
||||
let fps = VisualizerSettings.shared.effectiveFPS
|
||||
let cutoff = VisualizerSettings.shared.frequencyCutoff
|
||||
|
||||
|
||||
Task {
|
||||
let storage = VisualizerStorageManager.shared
|
||||
|
||||
if force {
|
||||
await storage.clearCache()
|
||||
SmartDJCache.shared.clearAll()
|
||||
}
|
||||
|
||||
// Find songs that need analysis
|
||||
var toAnalyze: [(id: String, url: URL)] = []
|
||||
for song in downloaded {
|
||||
if let url = OfflineManager.shared.localURL(for: song.id) {
|
||||
let hasCache = await storage.hasCache(for: song.id)
|
||||
if !hasCache || force {
|
||||
toAnalyze.append((id: song.id, url: url))
|
||||
|
||||
var toAnalyze: [(songId: String, path: String?, url: URL)] = []
|
||||
for dl in downloaded {
|
||||
if let url = OfflineManager.shared.localURL(for: dl.id) {
|
||||
let hasVis = await storage.hasCache(for: dl.id)
|
||||
let hasProfile = dl.song.path.map { SmartDJCache.shared.get($0) != nil } ?? false
|
||||
if !hasVis || !hasProfile || force {
|
||||
toAnalyze.append((songId: dl.id, path: dl.song.path, url: url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if toAnalyze.isEmpty {
|
||||
await MainActor.run {
|
||||
analysisProgress = "All songs cached"
|
||||
isAnalyzing = false
|
||||
refreshVisCacheText()
|
||||
}
|
||||
await MainActor.run { analysisProgress = "All up to date"; isAnalyzing = false }
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
await MainActor.run { analysisProgress = "" }
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
analysisProgress = "0/\(toAnalyze.count)"
|
||||
}
|
||||
|
||||
DebugLogger.shared.log("Starting batch analysis: \(toAnalyze.count) songs (force: \(force))", category: "FFT")
|
||||
|
||||
|
||||
for (idx, item) in toAnalyze.enumerated() {
|
||||
await MainActor.run {
|
||||
analysisProgress = "\(idx + 1)/\(toAnalyze.count)"
|
||||
}
|
||||
|
||||
await MainActor.run { analysisProgress = "\(idx + 1)/\(toAnalyze.count)" }
|
||||
do {
|
||||
let frames = try await OfflineAudioAnalyzer.shared.analyze(
|
||||
url: item.url,
|
||||
pointsCount: points,
|
||||
fps: fps,
|
||||
cutoff: cutoff
|
||||
)
|
||||
try? await storage.saveCache(frames: frames, for: item.id)
|
||||
DebugLogger.shared.log("Analyzed: \(item.id) → \(frames.count) frames", category: "FFT")
|
||||
let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ(
|
||||
url: item.url, pointsCount: points, fps: fps,
|
||||
cutoff: cutoff, extractSmartDJ: true)
|
||||
try? await storage.saveCache(frames: result.visFrames, for: item.songId)
|
||||
if let path = item.path {
|
||||
SmartDJCache.shared.store(SmartDJProfile(
|
||||
bpm: nil, silenceStart: result.silenceStart,
|
||||
silenceEnd: result.silenceEnd, loudnessLUFS: result.loudnessLUFS
|
||||
), for: path)
|
||||
}
|
||||
} catch {
|
||||
DebugLogger.shared.log("Failed: \(item.id) → \(error.localizedDescription)", category: "Error")
|
||||
DebugLogger.shared.log("Analysis failed: \(item.songId) — \(error.localizedDescription)", category: "FFT")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
analysisProgress = "Done (\(toAnalyze.count) songs)"
|
||||
analysisProgress = "Done — \(toAnalyze.count) songs"
|
||||
isAnalyzing = false
|
||||
refreshVisCacheText()
|
||||
}
|
||||
|
||||
DebugLogger.shared.log("Batch analysis complete: \(toAnalyze.count) songs", category: "FFT")
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshVisCacheText() {
|
||||
Task {
|
||||
let size = await VisualizerStorageManager.shared.cacheSize()
|
||||
let count = await VisualizerStorageManager.shared.cachedTrackCount()
|
||||
await MainActor.run {
|
||||
visCacheText = "\(count) tracks · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))"
|
||||
smartDJCacheCount = SmartDJCache.shared.cachedCount
|
||||
}
|
||||
Task { await refreshVisCacheText() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -382,7 +382,6 @@ struct NowPlayingView: View {
|
|||
|
||||
// MARK: - Visualizer Layer
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private func visualizerLayer(geo: GeometryProxy) -> some View {
|
||||
let height = isLandscape
|
||||
|
|
@ -392,7 +391,7 @@ struct NowPlayingView: View {
|
|||
if visSettings.enabled && visSettings.nowPlayingEnabled {
|
||||
VStack {
|
||||
Spacer()
|
||||
MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink)
|
||||
MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, isSongLoaded: audioPlayer.currentSong != nil, accentColor: accentPink)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: height)
|
||||
.allowsHitTesting(false)
|
||||
|
|
|
|||
|
|
@ -140,71 +140,62 @@ final class WaveStateCache {
|
|||
private init() {}
|
||||
}
|
||||
|
||||
// MARK: - Touch Ripple (file-private so VisualizerLevelBox can reference it before MitsuhaVisualizerView)
|
||||
// MARK: - Level Box
|
||||
/// Holds all mutable per-frame rendering state.
|
||||
/// Conforms to ObservableObject but has ZERO @Published properties,
|
||||
/// so SwiftUI never observes it and never warns "modifying state during view update".
|
||||
/// TimelineView drives rerenders independently; the Canvas just reads from this box directly.
|
||||
/// Per-instance mutable render state. Zero @Published properties → no observation warnings.
|
||||
fileprivate final class VisualizerLevelBox: ObservableObject {
|
||||
var displayLevels: [Float] = []
|
||||
var peakFollower: Float = 0.01
|
||||
var levelHistoryBuf: [[Float]] = []
|
||||
var historyWriteIdx: Int = 0
|
||||
var wobblePhaseOffset: Double = 0
|
||||
var lastTimelineDate: Date? = nil
|
||||
var lastTickTime: CFTimeInterval = 0
|
||||
}
|
||||
|
||||
// MARK: - Main Visualizer View
|
||||
struct MitsuhaVisualizerView: View {
|
||||
var previewLevels: [Float]? = nil
|
||||
let isPlaying: Bool
|
||||
var isSongLoaded: Bool = true
|
||||
let accentColor: Color
|
||||
var compact: Bool = false
|
||||
|
||||
@ObservedObject var settings = VisualizerSettings.shared
|
||||
@ObservedObject var albumColors = AlbumColorExtractor.shared
|
||||
|
||||
// @StateObject: SwiftUI holds instance across renders; no @Published = no observation warnings
|
||||
@StateObject private var box = VisualizerLevelBox()
|
||||
|
||||
private static let historySize = 16 // ~267ms at 60fps ≈ 250ms original dispatch_after
|
||||
private static let historySize = 16
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
// TimelineView(.animation) fires on every animation frame as long as the
|
||||
// view is on screen. It is NOT gated on isPlaying — it runs during pause,
|
||||
// during fade animations, during sheet presentations, always.
|
||||
// Levels are polled directly inside the Canvas via AudioPlayer.shared.currentLevels()
|
||||
// which is nonisolated(unsafe) — safe to call from any thread with no actor hopping.
|
||||
// This eliminates every @ObservedObject dependency chain failure mode.
|
||||
TimelineView(.animation) { timeline in
|
||||
if settings.enabled {
|
||||
// .animation fires as part of SwiftUI's animation pass — always redraws Canvas.
|
||||
// The Canvas closure must capture a CHANGING VALUE TYPE (timeline.date) so
|
||||
// SwiftUI knows to re-invoke the draw closure each tick. Without this, Canvas
|
||||
// sees only `box` (a class reference — SwiftUI can't track class mutations)
|
||||
// and skips the redraw, producing a static frozen wave.
|
||||
TimelineView(.animation(minimumInterval: 1.0 / settings.effectiveFPS)) { timeline in
|
||||
Canvas { context, size in
|
||||
// timeline.date changes every frame — SwiftUI must re-execute this closure.
|
||||
let t = timeline.date.timeIntervalSinceReferenceDate
|
||||
let rawLevels: [Float] = isPlaying
|
||||
? (previewLevels ?? AudioPlayer.shared.currentLevels())
|
||||
: Array(repeating: Float(0), count: max(settings.numberOfPoints, 1))
|
||||
let _ = updateDisplayLevels(newRawLevels: rawLevels)
|
||||
let _ = updateWobblePhase(date: timeline.date)
|
||||
// Capture the changing Date so SwiftUI detects a change and
|
||||
// calls the Canvas drawing closure on every timeline tick.
|
||||
let frameDate = timeline.date
|
||||
updateDisplayLevels(newRawLevels: rawLevels)
|
||||
updateWobblePhase(t: t)
|
||||
|
||||
Canvas(opaque: false) { context, size in
|
||||
// Reference frameDate so the compiler keeps the capture.
|
||||
// This tells SwiftUI the closure's environment changed → redraw.
|
||||
let _ = frameDate
|
||||
let pts = box.displayLevels.isEmpty
|
||||
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
|
||||
: box.displayLevels
|
||||
guard pts.count >= 2 else { return }
|
||||
switch settings.style {
|
||||
case .wave: drawWave(ctx: context, size: size, levels: pts, continuousTime: box.wobblePhaseOffset)
|
||||
case .bar: drawBars(ctx: context, size: size, levels: pts)
|
||||
case .line: drawLine(ctx: context, size: size, levels: pts)
|
||||
}
|
||||
let pts = box.displayLevels.isEmpty
|
||||
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
|
||||
: box.displayLevels
|
||||
guard pts.count >= 2 else { return }
|
||||
switch settings.style {
|
||||
case .wave: drawWave(ctx: context, size: size, levels: pts, continuousTime: box.wobblePhaseOffset)
|
||||
case .bar: drawBars(ctx: context, size: size, levels: pts)
|
||||
case .line: drawLine(ctx: context, size: size, levels: pts)
|
||||
}
|
||||
}
|
||||
.opacity(isPlaying ? 1.0 : 0.35)
|
||||
.animation(.easeInOut(duration: 0.6), value: isPlaying)
|
||||
.opacity(isPlaying ? 1.0 : (isSongLoaded ? 0.35 : 1.0))
|
||||
.animation(isSongLoaded ? .easeInOut(duration: 0.6) : nil, value: isPlaying)
|
||||
.onAppear {
|
||||
let cached = compact ? WaveStateCache.shared.compactLevels : []
|
||||
box.displayLevels = cached.isEmpty
|
||||
|
|
@ -215,23 +206,22 @@ struct MitsuhaVisualizerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Wobble Phase (continuous time accumulator)
|
||||
// MARK: - Wobble Phase
|
||||
|
||||
@discardableResult
|
||||
private func updateWobblePhase(date: Date) -> Bool {
|
||||
if let last = box.lastTimelineDate {
|
||||
let delta = date.timeIntervalSince(last)
|
||||
private func updateWobblePhase(t: Double) {
|
||||
if box.lastTickTime > 0 {
|
||||
let delta = t - box.lastTickTime
|
||||
box.wobblePhaseOffset += min(delta, 0.1)
|
||||
}
|
||||
box.lastTimelineDate = date
|
||||
return true
|
||||
box.lastTickTime = t
|
||||
}
|
||||
|
||||
// MARK: - Temporal Smoothing & Log Binning
|
||||
|
||||
private func updateDisplayLevels(newRawLevels: [Float]) {
|
||||
@discardableResult
|
||||
private func updateDisplayLevels(newRawLevels: [Float]) -> Bool {
|
||||
let count = settings.numberOfPoints
|
||||
guard count > 0, !newRawLevels.isEmpty else { return }
|
||||
guard count > 0, !newRawLevels.isEmpty else { return false }
|
||||
|
||||
let sens = Float(settings.sensitivity)
|
||||
let isPreProcessed = AudioPlayer.shared.isUsingOfflineVis
|
||||
|
|
@ -283,10 +273,13 @@ struct MitsuhaVisualizerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// Temporal Smoothing — frame-rate independent viscosity.
|
||||
let targetFPS = max(1.0, settings.effectiveFPS)
|
||||
let fpsScale = Float(60.0 / targetFPS)
|
||||
let smoothFactor = min(Float(settings.viscosity) * fpsScale, 1.0)
|
||||
// Temporal Smoothing — delta-time based so it's frame-rate independent.
|
||||
// Use elapsed time since last tick to compute smoothFactor rather than
|
||||
// assuming a fixed fps — this prevents lag when running at 60/120fps.
|
||||
let dt = Float(max(box.lastTickTime > 0 ? min(CACurrentMediaTime() - box.lastTickTime, 0.1) : 1.0/60.0, 0.001))
|
||||
// viscosity 0.05 = very slow, 1.0 = instant. Scale by 60*dt so behaviour
|
||||
// at 60fps matches the original fpsScale=1 viscosity settings.
|
||||
let smoothFactor = min(Float(settings.viscosity) * 60.0 * dt, 1.0)
|
||||
|
||||
// ── Dynamic Gain / Peak Follower ─────────────────────────────────────
|
||||
let frameMax = targetLevels.max() ?? 0.0
|
||||
|
|
@ -318,6 +311,7 @@ struct MitsuhaVisualizerView: View {
|
|||
|
||||
// Cache compact levels so DI morph picks up current wave shape
|
||||
if compact { WaveStateCache.shared.compactLevels = box.displayLevels }
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Colors
|
||||
|
|
@ -574,12 +568,14 @@ struct MitsuhaVisualizerView: View {
|
|||
// MARK: - Compact Visualizer (for mini player)
|
||||
struct CompactVisualizerView: View {
|
||||
let isPlaying: Bool
|
||||
var isSongLoaded: Bool = true
|
||||
let accentColor: Color
|
||||
let height: CGFloat
|
||||
|
||||
|
||||
var body: some View {
|
||||
MitsuhaVisualizerView(
|
||||
isPlaying: isPlaying,
|
||||
isSongLoaded: isSongLoaded,
|
||||
accentColor: accentColor,
|
||||
compact: true
|
||||
)
|
||||
|
|
@ -689,39 +685,6 @@ struct VisualizerSettingsView: View {
|
|||
Text("Viscosity controls how quickly the wave reacts. 0.15–0.25 = heavy liquid. 0.5 = responsive. 0.8+ = snappy EQ.\n\nSensitivity multiplies incoming audio. Base multiplier is a second gain stage for FFT.\n\nDynamic Gain automatically normalises the wave amplitude across loud and quiet tracks — keeps the wave full on soft passages without clipping on loud ones.\n\nFPS drops to 24 in Low Power Mode.")
|
||||
}
|
||||
|
||||
// Smart DJ Crossfade
|
||||
Section {
|
||||
let crossfade = SmartCrossfadeManager.shared
|
||||
Toggle("Smart Crossfade", isOn: Binding(
|
||||
get: { crossfade.isEnabled },
|
||||
set: { crossfade.isEnabled = $0 }
|
||||
)).tint(pink)
|
||||
|
||||
if crossfade.isEnabled {
|
||||
HStack {
|
||||
Text("Duration")
|
||||
Spacer()
|
||||
Text("\(String(format: "%.1f", crossfade.crossfadeDuration))s")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { crossfade.crossfadeDuration },
|
||||
set: { crossfade.crossfadeDuration = $0 }
|
||||
),
|
||||
in: 1...10,
|
||||
step: 0.5
|
||||
).tint(pink)
|
||||
|
||||
Toggle("Skip Silence", isOn: Binding(
|
||||
get: { crossfade.skipSilence },
|
||||
set: { crossfade.skipSilence = $0 }
|
||||
)).tint(pink)
|
||||
}
|
||||
} header: { Text("CROSSFADE") } footer: {
|
||||
Text("Smart Crossfade uses Companion API profiles to blend songs seamlessly. Duration controls how long the fade lasts. Skip Silence jumps past leading/trailing silence.")
|
||||
}
|
||||
|
||||
// Presets
|
||||
Section {
|
||||
Button(action: applyDeepOcean) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue