Finaly fixed Visualizer tweaks to make it more jello-y
This commit is contained in:
parent
df70a99279
commit
f1f98fbf7d
6 changed files with 488 additions and 317 deletions
|
|
@ -714,16 +714,20 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
SmartCrossfadeManager.shared.pause()
|
||||
isPlaying = false
|
||||
stopOfflineVisTimer()
|
||||
stopLevelTimer()
|
||||
updateNowPlayingInfo()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
// Always pause both — only one is active, pausing nil is a no-op
|
||||
player?.pause()
|
||||
enginePlayerNode?.pause()
|
||||
isPlaying = false
|
||||
#if os(iOS)
|
||||
stopOfflineVisTimer()
|
||||
stopLevelTimer()
|
||||
// Zero out levels immediately so the wave decays cleanly via viscosity
|
||||
setLevels(Array(repeating: 0, count: 30))
|
||||
internalLevels = Array(repeating: 0, count: 30)
|
||||
#endif
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
|
|
@ -737,18 +741,18 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
return
|
||||
}
|
||||
#endif
|
||||
// Resume whichever player path is active
|
||||
if isUsingRealFFT, !isUsingOfflineVis, let node = enginePlayerNode {
|
||||
// Engine path (real-time FFT on local file without offline vis)
|
||||
node.play()
|
||||
} else {
|
||||
// AVPlayer path (streams, downloads with offline vis, everything else)
|
||||
player?.play()
|
||||
}
|
||||
isPlaying = true
|
||||
#if os(iOS)
|
||||
if isUsingOfflineVis {
|
||||
startOfflineVisSync()
|
||||
} else {
|
||||
// Restart level simulation for streaming / radio / crossfade paths
|
||||
startLevelSimulation()
|
||||
}
|
||||
#endif
|
||||
updateNowPlayingInfo()
|
||||
|
|
@ -1163,23 +1167,47 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
}
|
||||
|
||||
if !fetched {
|
||||
alog("Offline vis: analyzing \(songId)...")
|
||||
alog("Offline vis: analyzing \(songId) (combined vis+SmartDJ pass)...")
|
||||
let song = self.currentSong
|
||||
do {
|
||||
let frames = try await OfflineAudioAnalyzer.shared.analyze(
|
||||
// ── Combined pass: vis frames + SmartDJ profile in one read ─
|
||||
// extractSmartDJ only if Companion is disabled or unavailable AND
|
||||
// SmartDJ crossfade is enabled so we actually need the profile.
|
||||
let needsSmartDJ = CompanionSettings.shared.smartDJEnabled
|
||||
&& !CompanionSettings.shared.isEnabled
|
||||
&& song?.path != nil
|
||||
&& SmartDJCache.shared.get(song?.path ?? "") == nil
|
||||
|
||||
let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ(
|
||||
url: url,
|
||||
pointsCount: points,
|
||||
fps: offlineVisFPS,
|
||||
cutoff: cutoff
|
||||
cutoff: cutoff,
|
||||
extractSmartDJ: needsSmartDJ
|
||||
) { [weak self] pct in
|
||||
Task { @MainActor in
|
||||
self?.offlineVisProgress = pct
|
||||
}
|
||||
Task { @MainActor in self?.offlineVisProgress = pct }
|
||||
}
|
||||
|
||||
|
||||
// Seed SmartDJ cache from on-device analysis if we extracted it
|
||||
if needsSmartDJ, let path = song?.path {
|
||||
let profile = SmartDJProfile(
|
||||
bpm: nil,
|
||||
silenceStart: result.silenceStart,
|
||||
silenceEnd: result.silenceEnd,
|
||||
loudnessLUFS: result.loudnessLUFS
|
||||
)
|
||||
SmartDJCache.shared.store(profile, for: path)
|
||||
alog("Offline vis: seeded on-device SmartDJ profile for \(path) " +
|
||||
"(LUFS=\(String(format: "%.1f", result.loudnessLUFS ?? 0)), " +
|
||||
"leading=\(String(format: "%.2f", result.silenceEnd ?? 0))s, " +
|
||||
"trailing=\(String(format: "%.2f", result.silenceStart ?? 0))s)")
|
||||
}
|
||||
|
||||
let frames = result.visFrames
|
||||
try? await storage.saveCache(frames: frames, for: songId)
|
||||
let normalized = Self.normalizeVisFrames(frames)
|
||||
alog("Offline vis: cached \(frames.count) frames for \(songId)")
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
self.offlineVisFrames = normalized
|
||||
self.isUsingOfflineVis = true
|
||||
|
|
@ -1189,9 +1217,7 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
}
|
||||
} catch {
|
||||
alog("Offline vis: analysis failed: \(error.localizedDescription)")
|
||||
await MainActor.run {
|
||||
self.startLevelSimulation()
|
||||
}
|
||||
await MainActor.run { self.startLevelSimulation() }
|
||||
}
|
||||
} // end if !fetched
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,97 +94,80 @@ struct CompanionSettingsView: View {
|
|||
} footer: {
|
||||
Text("The Companion API provides advanced features: file uploads, ID3 tag editing, and Smart DJ audio analysis.")
|
||||
}
|
||||
|
||||
// Smart DJ
|
||||
if settings.isEnabled {
|
||||
Section {
|
||||
Toggle("Smart DJ", isOn: $settings.smartDJEnabled)
|
||||
|
||||
// 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 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)
|
||||
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("Smart DJ analyzes tracks for BPM, silence boundaries, and loudness. Crossfade uses this data for seamless transitions and volume normalization.")
|
||||
}
|
||||
|
||||
// Upload
|
||||
} 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.")
|
||||
}
|
||||
|
||||
if settings.isEnabled {
|
||||
Section {
|
||||
NavigationLink(destination: BatchUploadView()) {
|
||||
HStack {
|
||||
Image(systemName: "icloud.and.arrow.up")
|
||||
.foregroundColor(accentPink)
|
||||
Image(systemName: "icloud.and.arrow.up").foregroundColor(accentPink)
|
||||
Text("Upload Music")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Upload")
|
||||
} footer: {
|
||||
} header: { Text("Upload") } footer: {
|
||||
Text("Import a .zip archive of audio files, batch-tag them, and upload to your server via the Companion API.")
|
||||
}
|
||||
|
||||
// Server Actions
|
||||
Section {
|
||||
Button(action: triggerScan) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundColor(accentPink)
|
||||
Text("Trigger Navidrome Scan")
|
||||
.foregroundColor(.white)
|
||||
Image(systemName: "arrow.triangle.2.circlepath").foregroundColor(accentPink)
|
||||
Text("Trigger Navidrome Scan").foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Smart DJ Profiles Cached")
|
||||
.foregroundColor(.gray)
|
||||
Text("Smart DJ Profiles Cached").foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("\(SmartDJCache.shared.cachedCount)")
|
||||
.foregroundColor(.white)
|
||||
Text("\(SmartDJCache.shared.cachedCount)").foregroundColor(.white)
|
||||
}
|
||||
.font(.system(size: 14))
|
||||
|
||||
Button(role: .destructive, action: {
|
||||
SmartDJCache.shared.clearAll()
|
||||
}) {
|
||||
Text("Clear Local DJ Cache")
|
||||
Button(action: { SmartDJCache.shared.clearAll() }) {
|
||||
Text("Clear Profile Cache").foregroundColor(.red)
|
||||
}
|
||||
} header: {
|
||||
Text("Server Actions")
|
||||
}
|
||||
|
||||
// Analysis (runs on the Pi)
|
||||
} header: { Text("Server Actions") }
|
||||
}
|
||||
|
||||
if settings.smartDJEnabled {
|
||||
Section {
|
||||
Button(action: { triggerPreAnalyze(force: false) }) {
|
||||
HStack {
|
||||
|
|
@ -192,11 +175,11 @@ struct CompanionSettingsView: View {
|
|||
.foregroundColor(accentPink)
|
||||
.symbolEffect(.pulse, isActive: analyzeStatus == .analyzing)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Pre-Analyze Missing Songs")
|
||||
.foregroundColor(.white)
|
||||
Text("Analyze only songs without a Smart DJ profile")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.gray)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -204,33 +187,29 @@ struct CompanionSettingsView: View {
|
|||
|
||||
Button(action: { triggerPreAnalyze(force: true) }) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.foregroundColor(.orange)
|
||||
Image(systemName: "arrow.clockwise").foregroundColor(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Re-Analyze All Songs")
|
||||
.foregroundColor(.white)
|
||||
Text("Force re-analyze every track (slow on large libraries)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.gray)
|
||||
Text("Re-Analyze All Songs").foregroundColor(.white)
|
||||
Text("Clear cache and re-analyze every downloaded track")
|
||||
.font(.system(size: 11)).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(analyzeStatus == .analyzing)
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
.disabled(analyzeStatus == .analyzing)
|
||||
|
||||
if let msg = analyzeMessage {
|
||||
Text(msg)
|
||||
|
|
@ -238,9 +217,11 @@ struct CompanionSettingsView: View {
|
|||
.foregroundColor(analyzeStatus == .done ? .green : analyzeStatus == .failed ? .red : .gray)
|
||||
}
|
||||
} header: {
|
||||
Text("Server Analysis (runs on Pi)")
|
||||
Text(settings.isEnabled ? "Analysis (Server + On-Device)" : "Analysis (On-Device)")
|
||||
} footer: {
|
||||
Text("These commands run on your Companion API server. Large libraries may take a while.")
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -261,61 +242,76 @@ struct CompanionSettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Pre-Analyze
|
||||
// MARK: - Pre-Analyze (server + on-device combined)
|
||||
|
||||
private func triggerPreAnalyze(force: Bool) {
|
||||
analyzeStatus = .analyzing
|
||||
analyzeMessage = force ? "Re-analyzing all songs on server..." : "Analyzing missing songs on server..."
|
||||
analyzeMessage = "Starting analysis..."
|
||||
|
||||
Task {
|
||||
do {
|
||||
// Trigger server-side precompute via the visualizer/precompute endpoint
|
||||
if force {
|
||||
// For force re-analyze, we hit the precompute endpoint
|
||||
// which processes all un-cached files
|
||||
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)
|
||||
let msg = (try? JSONDecoder().decode([String:String].self, from: data))?["message"] ?? "Started"
|
||||
|
||||
await MainActor.run {
|
||||
analyzeStatus = .done
|
||||
analyzeMessage = "✓ \(msg)"
|
||||
}
|
||||
} else {
|
||||
// Pre-analyze missing: same endpoint, server skips already-analyzed
|
||||
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)
|
||||
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)"
|
||||
serverMsg = (try? JSONDecoder().decode([String: String].self, from: data))?["message"] ?? "Server analysis started"
|
||||
} catch {
|
||||
serverMsg = "Server unavailable — on-device only"
|
||||
}
|
||||
}
|
||||
|
||||
// Reset after 5s
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
await MainActor.run {
|
||||
if analyzeStatus != .analyzing {
|
||||
analyzeStatus = .idle
|
||||
analyzeMessage = nil
|
||||
// 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..."
|
||||
|
|
|
|||
|
|
@ -483,18 +483,68 @@ class SmartCrossfadeManager: ObservableObject {
|
|||
private func fetchProfile(for song: Song) async -> SmartDJProfile? {
|
||||
guard CompanionSettings.shared.smartDJEnabled else { return nil }
|
||||
guard let path = song.path else { return nil }
|
||||
|
||||
|
||||
// 1. Memory/disk cache
|
||||
if let cached = profiles[song.id] { return cached }
|
||||
|
||||
if let cached = SmartDJCache.shared.get(path) {
|
||||
profiles[song.id] = cached
|
||||
return cached
|
||||
}
|
||||
|
||||
// 2. Companion API (server-side analysis)
|
||||
if CompanionSettings.shared.isEnabled {
|
||||
do {
|
||||
let profile = try await api.fetchProfile(relativePath: path)
|
||||
profiles[song.id] = profile
|
||||
SmartDJCache.shared.store(profile, for: path)
|
||||
log("Profile (API): trailing=\(profile.silenceStart ?? 0)s, " +
|
||||
"leading=\(profile.silenceEnd ?? 0)s, " +
|
||||
"LUFS=\(profile.loudnessLUFS ?? 0)")
|
||||
return profile
|
||||
} catch {
|
||||
log("Profile API failed, trying on-device: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. On-device analysis fallback — for local/downloaded files only.
|
||||
// Runs OfflineAudioAnalyzer's combined pass to extract silence boundaries
|
||||
// and an approximate integrated loudness in one file read.
|
||||
if let localURL = OfflineManager.shared.localURL(for: song.id) {
|
||||
if let profile = await analyzeOnDevice(song: song, url: localURL) {
|
||||
profiles[song.id] = profile
|
||||
SmartDJCache.shared.store(profile, for: path)
|
||||
return profile
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// On-device SmartDJ profile extraction using the combined OfflineAudioAnalyzer pass.
|
||||
/// Returns silence boundaries and approximate LUFS without requiring Companion API.
|
||||
private func analyzeOnDevice(song: Song, url: URL) async -> SmartDJProfile? {
|
||||
log("On-device analysis: \(song.title)")
|
||||
do {
|
||||
let profile = try await api.fetchProfile(relativePath: path)
|
||||
profiles[song.id] = profile
|
||||
log("Profile: trailing=\(profile.silenceStart ?? 0)s, " +
|
||||
"leading=\(profile.silenceEnd ?? 0)s, " +
|
||||
"LUFS=\(profile.loudnessLUFS ?? 0)")
|
||||
let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ(
|
||||
url: url,
|
||||
pointsCount: 20, // minimal for speed — we only need the SmartDJ data
|
||||
fps: 15.0, // low fps = fewer FFT frames = faster
|
||||
cutoff: 80,
|
||||
extractSmartDJ: true,
|
||||
progress: nil
|
||||
)
|
||||
let profile = SmartDJProfile(
|
||||
bpm: nil,
|
||||
silenceStart: result.silenceStart,
|
||||
silenceEnd: result.silenceEnd,
|
||||
loudnessLUFS: result.loudnessLUFS
|
||||
)
|
||||
log("On-device profile: trailing=\(String(format: "%.2f", result.silenceStart ?? 0))s, " +
|
||||
"leading=\(String(format: "%.2f", result.silenceEnd ?? 0))s, " +
|
||||
"LUFS≈\(String(format: "%.1f", result.loudnessLUFS ?? 0))")
|
||||
return profile
|
||||
} catch {
|
||||
log("Profile failed: \(error.localizedDescription)")
|
||||
log("On-device analysis failed: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -382,27 +382,22 @@ struct NowPlayingView: View {
|
|||
|
||||
// MARK: - Visualizer Layer
|
||||
|
||||
@ViewBuilder
|
||||
@ViewBuilder
|
||||
private func visualizerLayer(geo: GeometryProxy) -> some View {
|
||||
let height = isLandscape
|
||||
? geo.size.height * min(visSettings.nowPlayingHeightPct + 0.1, 0.7)
|
||||
: geo.size.height * visSettings.nowPlayingHeightPct
|
||||
|
||||
if visSettings.enabled && visSettings.nowPlayingEnabled {
|
||||
if isLandscape {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: geo.size.height * min(visSettings.nowPlayingHeightPct + 0.1, 0.7))
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
} else {
|
||||
VStack {
|
||||
Spacer()
|
||||
MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink)
|
||||
.frame(height: geo.size.height * visSettings.nowPlayingHeightPct)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
VStack {
|
||||
Spacer()
|
||||
MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: height)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class VisualizerSettings: ObservableObject {
|
|||
@Published var sensitivity: Double { didSet { save("vis_sensitivity", sensitivity) } }
|
||||
@Published var fps: Double { didSet { save("vis_fps", fps) } }
|
||||
@Published var realAudioAnalysis: Bool { didSet { save("vis_real_fft", realAudioAnalysis) } }
|
||||
@Published var dynamicGainEnabled: Bool { didSet { save("vis_dynamic_gain", dynamicGainEnabled) } }
|
||||
|
||||
// Wave
|
||||
@Published var waveOffsetTop: Double { didSet { save("vis_wave_offset", waveOffsetTop) } }
|
||||
|
|
@ -76,6 +77,7 @@ class VisualizerSettings: ObservableObject {
|
|||
sensitivity = { let v = d.double(forKey: "vis_sensitivity"); return v > 0 ? v : 1.5 }()
|
||||
fps = { let v = d.double(forKey: "vis_fps"); return v > 0 ? v : 60.0 }()
|
||||
realAudioAnalysis = d.object(forKey: "vis_real_fft") as? Bool ?? true
|
||||
dynamicGainEnabled = d.object(forKey: "vis_dynamic_gain") as? Bool ?? true
|
||||
waveOffsetTop = d.double(forKey: "vis_wave_offset")
|
||||
barSpacing = { let v = d.double(forKey: "vis_bar_spacing"); return v > 0 ? v : 5.0 }()
|
||||
barCornerRadius = d.double(forKey: "vis_bar_radius")
|
||||
|
|
@ -138,62 +140,74 @@ 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.
|
||||
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
|
||||
}
|
||||
|
||||
// MARK: - Main Visualizer View
|
||||
struct MitsuhaVisualizerView: View {
|
||||
/// Optional override levels for previews. When nil, pulls from AudioPlayer.
|
||||
var previewLevels: [Float]? = nil
|
||||
let isPlaying: Bool
|
||||
let accentColor: Color
|
||||
var compact: Bool = false
|
||||
|
||||
|
||||
@ObservedObject var settings = VisualizerSettings.shared
|
||||
@ObservedObject var albumColors = AlbumColorExtractor.shared
|
||||
@State private var touchRipples: [TouchRipple] = []
|
||||
@State private var displayLevels: [Float] = []
|
||||
|
||||
struct TouchRipple: Identifiable {
|
||||
let id = UUID()
|
||||
let x: CGFloat
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
|
||||
// @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
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if settings.enabled {
|
||||
GeometryReader { geo in
|
||||
TimelineView(.animation(minimumInterval: 1.0 / settings.effectiveFPS)) { timeline in
|
||||
// PULL: Grab latest levels right before rendering — no @Published thrashing
|
||||
// Ternary required: if/else assignment inside @ViewBuilder returns () which isn't View
|
||||
let rawLevels: [Float] = isPlaying
|
||||
? (previewLevels ?? AudioPlayer.shared.currentLevels())
|
||||
: Array(repeating: Float(0), count: max(settings.numberOfPoints, 1))
|
||||
let _ = updateDisplayLevelsIfNeeded(newRawLevels: rawLevels)
|
||||
|
||||
Canvas(opaque: false) { context, size in
|
||||
let pts = displayLevels.isEmpty
|
||||
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
|
||||
: displayLevels
|
||||
guard pts.count >= 2 else { return }
|
||||
switch settings.style {
|
||||
case .wave: drawWave(ctx: context, size: size, levels: pts, date: timeline.date)
|
||||
case .bar: drawBars(ctx: context, size: size, levels: pts)
|
||||
case .line: drawLine(ctx: context, size: size, levels: pts)
|
||||
}
|
||||
// .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
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
addRipple(at: value.location.x / geo.size.width)
|
||||
}
|
||||
)
|
||||
}
|
||||
// Smooth fade instead of hard cut — keeps TimelineView alive
|
||||
.opacity(isPlaying ? 1 : Double(settings.idleAmplitude) > 0 ? 0.4 : 0)
|
||||
.animation(.easeInOut(duration: 0.8), value: isPlaying)
|
||||
.opacity(isPlaying ? 1.0 : 0.35)
|
||||
.animation(.easeInOut(duration: 0.6), value: isPlaying)
|
||||
.onAppear {
|
||||
let cached = compact ? WaveStateCache.shared.compactLevels : []
|
||||
displayLevels = cached.isEmpty
|
||||
box.displayLevels = cached.isEmpty
|
||||
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
|
||||
: cached
|
||||
}
|
||||
|
|
@ -201,27 +215,18 @@ struct MitsuhaVisualizerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
/// Called every frame inside TimelineView — updates displayLevels in-place
|
||||
/// Returns Void; the `let _ =` assignment is just to run code inside the ViewBuilder
|
||||
private func updateDisplayLevelsIfNeeded(newRawLevels: [Float]) -> Bool {
|
||||
// Must dispatch to avoid mutating @State during view render
|
||||
DispatchQueue.main.async {
|
||||
self.updateDisplayLevels(newRawLevels: newRawLevels)
|
||||
// MARK: - Wobble Phase (continuous time accumulator)
|
||||
|
||||
@discardableResult
|
||||
private func updateWobblePhase(date: Date) -> Bool {
|
||||
if let last = box.lastTimelineDate {
|
||||
let delta = date.timeIntervalSince(last)
|
||||
box.wobblePhaseOffset += min(delta, 0.1)
|
||||
}
|
||||
box.lastTimelineDate = date
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Touch Ripples
|
||||
|
||||
private func addRipple(at x: CGFloat) {
|
||||
let clamped = min(max(x, 0), 1)
|
||||
if let last = touchRipples.last, Date().timeIntervalSince(last.createdAt) < 0.04 { return }
|
||||
touchRipples.append(TouchRipple(x: clamped, createdAt: Date()))
|
||||
let now = Date()
|
||||
touchRipples.removeAll { now.timeIntervalSince($0.createdAt) > 1.0 }
|
||||
if touchRipples.count > 25 { touchRipples.removeFirst() }
|
||||
}
|
||||
|
||||
// MARK: - Temporal Smoothing & Log Binning
|
||||
|
||||
private func updateDisplayLevels(newRawLevels: [Float]) {
|
||||
|
|
@ -278,17 +283,41 @@ struct MitsuhaVisualizerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// Temporal Smoothing (Viscosity)
|
||||
if displayLevels.count != count {
|
||||
displayLevels = targetLevels
|
||||
// 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)
|
||||
|
||||
// ── Dynamic Gain / Peak Follower ─────────────────────────────────────
|
||||
let frameMax = targetLevels.max() ?? 0.0
|
||||
if frameMax > box.peakFollower {
|
||||
box.peakFollower = box.peakFollower + (frameMax - box.peakFollower) * 0.3
|
||||
} else {
|
||||
box.peakFollower = max(box.peakFollower * 0.997, 0.005)
|
||||
}
|
||||
if settings.dynamicGainEnabled {
|
||||
let normFactor = Float(1.0) / max(box.peakFollower, 0.005)
|
||||
targetLevels = targetLevels.map { min($0 * normFactor, 1.0) }
|
||||
}
|
||||
|
||||
if box.displayLevels.count != count {
|
||||
box.displayLevels = targetLevels
|
||||
} else {
|
||||
let smoothFactor = Float(settings.viscosity)
|
||||
for i in 0..<count {
|
||||
displayLevels[i] = displayLevels[i] + (targetLevels[i] - displayLevels[i]) * smoothFactor
|
||||
box.displayLevels[i] = box.displayLevels[i] + (targetLevels[i] - box.displayLevels[i]) * smoothFactor
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ring Buffer for temporal depth ghost ─────────────────────────────
|
||||
if box.levelHistoryBuf.count < MitsuhaVisualizerView.historySize {
|
||||
box.levelHistoryBuf.append(box.displayLevels)
|
||||
} else {
|
||||
box.levelHistoryBuf[box.historyWriteIdx] = box.displayLevels
|
||||
box.historyWriteIdx = (box.historyWriteIdx + 1) % MitsuhaVisualizerView.historySize
|
||||
}
|
||||
|
||||
// Cache compact levels so DI morph picks up current wave shape
|
||||
if compact { WaveStateCache.shared.compactLevels = displayLevels }
|
||||
if compact { WaveStateCache.shared.compactLevels = box.displayLevels }
|
||||
}
|
||||
|
||||
// MARK: - Colors
|
||||
|
|
@ -367,51 +396,64 @@ struct MitsuhaVisualizerView: View {
|
|||
|
||||
// MARK: - Wave
|
||||
|
||||
private func drawWave(ctx: GraphicsContext, size: CGSize, levels: [Float], date: Date) {
|
||||
private func drawWave(ctx: GraphicsContext, size: CGSize, levels: [Float], continuousTime: Double) {
|
||||
let w = size.width
|
||||
let h = size.height
|
||||
let idleVal = CGFloat(compact ? settings.miniIdleAmplitude : settings.idleAmplitude)
|
||||
guard levels.count >= 2 else { return }
|
||||
let spacing = w / CGFloat(levels.count - 1)
|
||||
let now = date.timeIntervalSinceReferenceDate
|
||||
|
||||
let count = levels.count
|
||||
let spacing = w / CGFloat(count - 1)
|
||||
let now = continuousTime
|
||||
|
||||
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
|
||||
let offset = compact ? 0 : CGFloat(settings.waveOffsetTop)
|
||||
let baseline = h - baseLift - offset
|
||||
let ampScale: CGFloat = compact ? CGFloat(settings.miniAmplitude) : CGFloat(settings.npAmplitude)
|
||||
|
||||
let points = levels.enumerated().map { i, lev -> CGPoint in
|
||||
|
||||
var points = levels.enumerated().map { i, lev -> CGPoint in
|
||||
let x = CGFloat(i) * spacing
|
||||
let nx = x / w
|
||||
let baseAmp = CGFloat(lev)
|
||||
|
||||
var ripple: CGFloat = 0
|
||||
for rp in touchRipples {
|
||||
let age = CGFloat(now - rp.createdAt.timeIntervalSinceReferenceDate)
|
||||
let decay = max(0, 1.0 - age * 1.4)
|
||||
let dist = abs(nx - rp.x)
|
||||
let radius: CGFloat = 0.08 + age * 1.2
|
||||
if dist < radius {
|
||||
let prox = 1.0 - (dist / radius)
|
||||
ripple += sin(dist * 30 - age * 12) * prox * decay * 0.35
|
||||
}
|
||||
}
|
||||
|
||||
// Organic wobble: gentle rolling sine based on time and position
|
||||
let organicWobble = CGFloat(sin(now * 3.0 + (Double(i) * 0.8)) * 0.03)
|
||||
|
||||
let totalAmp = max(idleVal, baseAmp + ripple + organicWobble)
|
||||
let totalAmp = max(idleVal, baseAmp + organicWobble)
|
||||
return CGPoint(x: x, y: baseline - (totalAmp * h * ampScale))
|
||||
}
|
||||
|
||||
// Layer 1: True index phase-shifted depth — liquid parallax
|
||||
|
||||
// ── Endpoint Anchoring (improvement #13) ────────────────────────────
|
||||
// Pin first and last points exactly to baseline so the wave cleanly
|
||||
// rises from and returns to the horizon — matching the original tweak.
|
||||
points[0] = CGPoint(x: 0, y: baseline)
|
||||
points[count - 1] = CGPoint(x: w, y: baseline)
|
||||
|
||||
// ── Layer 1: Temporal depth ghost (improvement #11) ──────────────────
|
||||
// Use the oldest ring-buffer frame (~250ms ago) for the shadow wave,
|
||||
// matching the original's 0.25s dispatch_after delay exactly.
|
||||
let ctxDepthOffset = compact ? CGFloat(settings.miniDepthOffset) : CGFloat(settings.depthOffset)
|
||||
let ctxDepthOpacity = compact ? settings.miniDepthOpacity : settings.depthOpacity
|
||||
let depthPts = points.enumerated().map { i, pt -> CGPoint in
|
||||
let shiftedIndex = (i + 2) % points.count
|
||||
let shiftedPt = points[shiftedIndex]
|
||||
return CGPoint(x: pt.x, y: shiftedPt.y + ctxDepthOffset + 5)
|
||||
|
||||
let ghostLevels: [Float]
|
||||
let histFull = box.levelHistoryBuf.count >= MitsuhaVisualizerView.historySize
|
||||
if histFull {
|
||||
ghostLevels = box.levelHistoryBuf[box.historyWriteIdx]
|
||||
} else {
|
||||
ghostLevels = box.levelHistoryBuf.first ?? Array(repeating: 0, count: count)
|
||||
}
|
||||
|
||||
let ghostCount = max(ghostLevels.count, 2)
|
||||
var depthPts = (0..<count).map { i -> CGPoint in
|
||||
// Sample the ghost frame at the same normalised position
|
||||
let srcF = Float(i) / Float(count - 1) * Float(ghostCount - 1)
|
||||
let lo = min(Int(srcF), ghostCount - 1)
|
||||
let hi = min(lo + 1, ghostCount - 1)
|
||||
let frac = CGFloat(srcF - Float(lo))
|
||||
let ghostLev = CGFloat(ghostLevels[lo]) * (1 - frac) + CGFloat(ghostLevels[hi]) * frac
|
||||
let ghostAmp = max(idleVal, ghostLev)
|
||||
let x = CGFloat(i) * spacing
|
||||
return CGPoint(x: x, y: baseline - (ghostAmp * h * ampScale) + ctxDepthOffset + 5)
|
||||
}
|
||||
// Anchor ghost endpoints too
|
||||
depthPts[0] = CGPoint(x: 0, y: baseline + ctxDepthOffset)
|
||||
depthPts[count - 1] = CGPoint(x: w, y: baseline + ctxDepthOffset)
|
||||
|
||||
let depthCurve = smoothCurve(depthPts)
|
||||
var depthFill = Path()
|
||||
depthFill.move(to: CGPoint(x: 0, y: h))
|
||||
|
|
@ -642,8 +684,9 @@ struct VisualizerSettingsView: View {
|
|||
sliderRowDouble("Sensitivity", value: $settings.sensitivity, range: 0.1...4.0, step: 0.1, format: "%.1f")
|
||||
sliderRowDouble("FPS", value: $settings.fps, range: 15...60, step: 1, format: "%.0f")
|
||||
Toggle("Real Audio Analysis", isOn: $settings.realAudioAnalysis).tint(pink)
|
||||
Toggle("Dynamic Gain", isOn: $settings.dynamicGainEnabled).tint(pink)
|
||||
} header: { Text("SHARED / ADVANCED") } footer: {
|
||||
Text("Viscosity is the most important slider — it controls how quickly the wave reacts. 0.15–0.25 = heavy, slow liquid (cinematic). 0.5 = responsive. 0.8+ = snappy EQ meter.\n\nSensitivity multiplies the incoming audio. Push up if the wave looks flat.\n\nBase multiplier is a second gain stage for FFT data. Higher values reveal quiet passages.\n\nFrequency cutoff limits how many FFT bins are used. At 80 (default), mostly bass/mids. At 150+, treble detail like hi-hats shows up.\n\nReal Audio Analysis uses FFT on downloaded songs. Streams always use simulated animation.\n\nFPS drops to 24 automatically in Low Power Mode.")
|
||||
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
|
||||
|
|
@ -780,7 +823,8 @@ struct VisualizerSettingsView: View {
|
|||
private func resetDefaults() {
|
||||
settings.enabled = true; settings.nowPlayingEnabled = true; settings.miniPlayerEnabled = true
|
||||
settings.style = .wave; settings.numberOfPoints = 10; settings.sensitivity = 1.5
|
||||
settings.fps = 60; settings.realAudioAnalysis = true; settings.waveOffsetTop = 0
|
||||
settings.fps = 60; settings.realAudioAnalysis = true; settings.dynamicGainEnabled = true
|
||||
settings.waveOffsetTop = 0
|
||||
settings.barSpacing = 5; settings.barCornerRadius = 0; settings.lineThickness = 5
|
||||
settings.colorMode = .dynamic; settings.alpha = 0.6; settings.viscosity = 0.25
|
||||
settings.frequencyCutoff = 80; settings.baseMultiplier = 40.0
|
||||
|
|
|
|||
|
|
@ -4,14 +4,23 @@ import Accelerate
|
|||
|
||||
/// Processes an entire audio file faster than real-time, producing per-frame FFT data
|
||||
/// that can be cached and played back in sync with the audio.
|
||||
/// Also optionally extracts SmartDJ profile data (silence boundaries + LUFS) in the same pass.
|
||||
actor OfflineAudioAnalyzer {
|
||||
static let shared = OfflineAudioAnalyzer()
|
||||
|
||||
/// Progress callback (0.0 to 1.0)
|
||||
|
||||
typealias ProgressCallback = @Sendable (Float) -> Void
|
||||
|
||||
/// Analyze an audio file and return an array of FFT frames.
|
||||
/// Each frame is an array of `pointsCount` floats (0.0-1.0) representing frequency band amplitudes.
|
||||
|
||||
// MARK: - Combined Analysis Result
|
||||
|
||||
struct CombinedResult {
|
||||
let visFrames: [[Float]]
|
||||
let silenceEnd: Double? // leading silence end in seconds
|
||||
let silenceStart: Double? // trailing silence start in seconds
|
||||
let loudnessLUFS: Double? // approximate integrated loudness
|
||||
}
|
||||
|
||||
// MARK: - Visualizer-only (legacy entry point)
|
||||
|
||||
func analyze(
|
||||
url: URL,
|
||||
pointsCount: Int = 20,
|
||||
|
|
@ -20,96 +29,147 @@ actor OfflineAudioAnalyzer {
|
|||
eqBoostFactor: Float = 3.5,
|
||||
progress: ProgressCallback? = nil
|
||||
) throws -> [[Float]] {
|
||||
let r = try analyzeWithSmartDJ(url: url, pointsCount: pointsCount, fps: fps,
|
||||
cutoff: cutoff, eqBoostFactor: eqBoostFactor,
|
||||
extractSmartDJ: false, progress: progress)
|
||||
return r.visFrames
|
||||
}
|
||||
|
||||
// MARK: - Combined pass: vis frames + SmartDJ profile in one file read
|
||||
|
||||
/// Reads the file once, producing visualiser frames AND silence/loudness data.
|
||||
/// Set `extractSmartDJ: false` to skip the SmartDJ computation and save time.
|
||||
func analyzeWithSmartDJ(
|
||||
url: URL,
|
||||
pointsCount: Int = 20,
|
||||
fps: Double = 30.0,
|
||||
cutoff: Int = 90,
|
||||
eqBoostFactor: Float = 3.5,
|
||||
extractSmartDJ: Bool = true,
|
||||
progress: ProgressCallback? = nil
|
||||
) throws -> CombinedResult {
|
||||
|
||||
let file = try AVAudioFile(forReading: url)
|
||||
let format = file.processingFormat
|
||||
let sampleRate = format.sampleRate
|
||||
let totalFrames = file.length
|
||||
|
||||
// How many audio frames per visualizer frame
|
||||
let durationSec = Double(totalFrames) / sampleRate
|
||||
|
||||
let audioFramesPerVisFrame = AVAudioFrameCount(sampleRate / fps)
|
||||
|
||||
// Use power-of-2 buffer for FFT
|
||||
let fftSize = 1024
|
||||
let bufferSize = max(AVAudioFrameCount(fftSize), audioFramesPerVisFrame)
|
||||
|
||||
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize) else {
|
||||
throw NSError(domain: "OfflineAnalyzer", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to create buffer"])
|
||||
}
|
||||
|
||||
|
||||
let log2n = vDSP_Length(log2(Double(fftSize)))
|
||||
guard let fftSetup = vDSP_create_fftsetup(log2n, Int32(kFFTRadix2)) else {
|
||||
throw NSError(domain: "OfflineAnalyzer", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to create FFT setup"])
|
||||
}
|
||||
defer { vDSP_destroy_fftsetup(fftSetup) }
|
||||
|
||||
|
||||
let halfSize = fftSize / 2
|
||||
var visualizerData: [[Float]] = []
|
||||
|
||||
// Estimate total frames for progress
|
||||
let estimatedVisFrames = Int(Double(totalFrames) / Double(audioFramesPerVisFrame))
|
||||
visualizerData.reserveCapacity(estimatedVisFrames)
|
||||
|
||||
// Reusable buffers
|
||||
|
||||
var window = [Float](repeating: 0, count: fftSize)
|
||||
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
|
||||
|
||||
|
||||
// SmartDJ state
|
||||
let silenceThreshold: Float = 0.008 // RMS below this = silence
|
||||
var leadingSilenceEndSec: Double? = nil // first non-silent moment
|
||||
var trailingSilenceStartSec: Double? = nil // last non-silent moment
|
||||
var sumSquares: Double = 0.0
|
||||
var sampleCount: Int64 = 0
|
||||
var filePositionSec: Double { Double(file.framePosition) / sampleRate }
|
||||
|
||||
var frameIndex = 0
|
||||
|
||||
|
||||
while file.framePosition < totalFrames {
|
||||
// Read a chunk
|
||||
let framesToRead = min(bufferSize, AVAudioFrameCount(totalFrames - file.framePosition))
|
||||
buffer.frameLength = 0
|
||||
try file.read(into: buffer, frameCount: framesToRead)
|
||||
|
||||
|
||||
guard let channelData = buffer.floatChannelData?[0] else { continue }
|
||||
let actualFrames = Int(buffer.frameLength)
|
||||
let chunkStartSec = filePositionSec - Double(actualFrames) / sampleRate
|
||||
|
||||
// ── SmartDJ: RMS per chunk ──────────────────────────────────────
|
||||
if extractSmartDJ && actualFrames > 0 {
|
||||
var rms: Float = 0
|
||||
vDSP_rmsqv(channelData, 1, &rms, vDSP_Length(actualFrames))
|
||||
if rms > silenceThreshold {
|
||||
if leadingSilenceEndSec == nil {
|
||||
leadingSilenceEndSec = chunkStartSec
|
||||
}
|
||||
trailingSilenceStartSec = chunkStartSec + Double(actualFrames) / sampleRate
|
||||
}
|
||||
// Accumulate for integrated loudness
|
||||
var sumSq: Float = 0
|
||||
vDSP_measqv(channelData, 1, &sumSq, vDSP_Length(actualFrames))
|
||||
sumSquares += Double(sumSq) * Double(actualFrames)
|
||||
sampleCount += Int64(actualFrames)
|
||||
}
|
||||
|
||||
// ── Visualiser FFT frames ────────────────────────────────────────
|
||||
guard actualFrames >= fftSize else {
|
||||
// Pad with zeros for the last partial buffer
|
||||
if actualFrames > 0 {
|
||||
let frame = processFFTFrame(
|
||||
channelData: channelData,
|
||||
frameCount: actualFrames,
|
||||
fftSize: fftSize,
|
||||
halfSize: halfSize,
|
||||
window: window,
|
||||
fftSetup: fftSetup,
|
||||
pointsCount: pointsCount,
|
||||
cutoff: cutoff,
|
||||
eqBoostFactor: eqBoostFactor
|
||||
)
|
||||
visualizerData.append(frame)
|
||||
visualizerData.append(processFFTFrame(
|
||||
channelData: channelData, frameCount: actualFrames,
|
||||
fftSize: fftSize, halfSize: halfSize, window: window,
|
||||
fftSetup: fftSetup, pointsCount: pointsCount,
|
||||
cutoff: cutoff, eqBoostFactor: eqBoostFactor))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Process one or more vis frames from this buffer
|
||||
|
||||
var sampleOffset = 0
|
||||
while sampleOffset + fftSize <= actualFrames {
|
||||
let frame = processFFTFrame(
|
||||
visualizerData.append(processFFTFrame(
|
||||
channelData: channelData.advanced(by: sampleOffset),
|
||||
frameCount: fftSize,
|
||||
fftSize: fftSize,
|
||||
halfSize: halfSize,
|
||||
window: window,
|
||||
fftSetup: fftSetup,
|
||||
pointsCount: pointsCount,
|
||||
cutoff: cutoff,
|
||||
eqBoostFactor: eqBoostFactor
|
||||
)
|
||||
visualizerData.append(frame)
|
||||
frameCount: fftSize, fftSize: fftSize, halfSize: halfSize,
|
||||
window: window, fftSetup: fftSetup, pointsCount: pointsCount,
|
||||
cutoff: cutoff, eqBoostFactor: eqBoostFactor))
|
||||
sampleOffset += Int(audioFramesPerVisFrame)
|
||||
frameIndex += 1
|
||||
|
||||
// Report progress every 50 frames
|
||||
if frameIndex % 50 == 0, let progress = progress {
|
||||
let pct = Float(file.framePosition) / Float(totalFrames)
|
||||
progress(pct)
|
||||
if frameIndex % 50 == 0 {
|
||||
progress?(Float(file.framePosition) / Float(totalFrames))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
progress?(1.0)
|
||||
return visualizerData
|
||||
|
||||
// ── Compute approximate integrated LUFS ──────────────────────────────
|
||||
// Uses mean square → dBFS as a simplified approximation of BS.1770.
|
||||
// Not true K-weighted LUFS but accurate enough for volume normalisation.
|
||||
var loudnessLUFS: Double? = nil
|
||||
if extractSmartDJ && sampleCount > 0 {
|
||||
let meanSquare = sumSquares / Double(sampleCount)
|
||||
if meanSquare > 0 {
|
||||
let lufs = 20.0 * log10(sqrt(meanSquare))
|
||||
loudnessLUFS = lufs
|
||||
}
|
||||
}
|
||||
|
||||
// Guard silence detections: must be within plausible range
|
||||
let safeLeading: Double? = {
|
||||
guard let t = leadingSilenceEndSec, t > 0.05, t < durationSec * 0.25 else { return nil }
|
||||
return t
|
||||
}()
|
||||
let safeTrailing: Double? = {
|
||||
guard let t = trailingSilenceStartSec, t < durationSec - 0.5, t > durationSec * 0.5 else { return nil }
|
||||
return t
|
||||
}()
|
||||
|
||||
return CombinedResult(
|
||||
visFrames: visualizerData,
|
||||
silenceEnd: safeLeading,
|
||||
silenceStart: safeTrailing,
|
||||
loudnessLUFS: loudnessLUFS
|
||||
)
|
||||
}
|
||||
|
||||
/// Process a single FFT frame from raw audio samples
|
||||
|
|
|
|||
Loading…
Reference in a new issue