Merge branch 'main' of ssh://dallasgroot@10.0.0.224:22/Users/dallasgroot/NavidromePlayer/.git

This commit is contained in:
Dallas Groot 2026-04-06 12:53:07 -07:00
commit 5030cbc304
11 changed files with 1635 additions and 405 deletions

View file

@ -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
View 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
View 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()

View 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
View 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)

View 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)

View file

@ -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
)

View file

@ -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 } }
}
}

View file

@ -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() }
}
}
}

View file

@ -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)

View file

@ -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.150.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) {