Audio Tap FFT:

- MTAudioProcessingTap with lock-free ring buffer + pre-allocated vDSP FFT
- Tap installs on readyToPlay (fixes 'no audio track' on live streams)
- Shared tap serves FFT + Shazam simultaneously (no more tap conflict)
- Ring buffer reset on removeTap (prevents stale FFT frames)
- Simulation runs as placeholder until stream connects
- All 6 paths verified: start, station change, goLive, seekBack, bg/fg, radio→music

Bug fixes:
- playerItem tracking in radioGoLive/radioSeekBack (was orphaned)
- Combine sinks: .receive(on: .main) on all 4 notification handlers
- ShazamRecognizer stopAll: audio session now actually restores after mic fallback
- processTapBuffer/convertAndMatch methods restored (were dropped in refactor)

Lyrics:
- Bottom toolbar on overlay: Search, Timing (±0.1s/±0.5s inline), Edit
- LyricsManager.globalOffset applied to syncToTime + wordProgress
- openLyricsEditor notification wired to NowPlayingView

UI:
- Server selection button hidden when Dynamic Island active
- Debug: Capture Audio Tap (5s WAV) with share sheet in Visualizer Settings"
This commit is contained in:
Dallas Groot 2026-04-14 19:45:05 -07:00
parent 3385b88270
commit c6451b440b
8 changed files with 189 additions and 24 deletions

View file

@ -152,11 +152,13 @@ class AudioPlayer: NSObject, ObservableObject {
// Stop all visualizer timers when backgrounded they serve no purpose without
// a visible Canvas and burn CPU (causing XPC_EXIT_REASON_FAULT at ~169% CPU).
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.suspendVisTimers() }
.store(in: &cancellables)
// Restart the correct timer when returning to foreground
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.resumeVisTimers() }
.store(in: &cancellables)
@ -165,12 +167,14 @@ class AudioPlayer: NSObject, ObservableObject {
// other apps taking the audio session. Without this, playback stops but
// isPlaying stays true timers keep firing and music never auto-resumes.
NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in self?.handleAudioInterruption(notification) }
.store(in: &cancellables)
// Audio route change (headphones unplugged etc.)
// Apple HIG: pause on oldDeviceUnavailable (headphones pulled out).
NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in self?.handleRouteChange(notification) }
.store(in: &cancellables)
#endif
@ -747,7 +751,19 @@ class AudioPlayer: NSObject, ObservableObject {
player?.replaceCurrentItem(with: item)
player?.play()
#if os(iOS)
installAudioTapIfNeeded(on: item)
// Start simulation immediately tap installs when stream reaches readyToPlay
startRadioSimulation()
let liveItem = item
Task { @MainActor [weak self] in
for await status in liveItem.publisher(for: \.status).values {
guard let self, self.playerItem === liveItem else { break }
if status == .readyToPlay {
self.installAudioTapIfNeeded(on: liveItem)
break
}
if status == .failed { break }
}
}
#endif
alog("Radio: ▶ live")
}
@ -822,6 +838,7 @@ class AudioPlayer: NSObject, ObservableObject {
// errors that AVPlayer swallows silently otherwise
#if os(iOS)
let itemToObserve = playerItem!
let isRadio = isRadioStream
Task { @MainActor [weak self] in
for await status in itemToObserve.publisher(for: \.status).values {
guard let self = self, self.playerItem === itemToObserve else { break }
@ -833,6 +850,12 @@ class AudioPlayer: NSObject, ObservableObject {
category: "Audio", level: .error)
case .readyToPlay:
DebugLogger.shared.log("✓ Ready: \(url.lastPathComponent)", category: "Audio")
// Install audio tap AFTER the stream has connected and the asset
// has parsed its container. Before readyToPlay, loadTracks returns
// empty for live streams tap silently fails.
if isRadio {
self.installAudioTapIfNeeded(on: itemToObserve)
}
default:
break
}
@ -891,11 +914,12 @@ class AudioPlayer: NSObject, ObservableObject {
updateNowPlayingInfo()
fetchAndSetArtwork(coverArtId: currentSong?.coverArt)
// Radio streams: try real FFT via MTAudioProcessingTap first.
// Falls back to sinusoidal simulation if tap installation fails.
// Radio streams: start simulation immediately for visual feedback while
// the stream connects. The readyToPlay status observer (above) will install
// the real FFT audio tap once the asset has loaded its tracks.
#if os(iOS)
if isRadioStream {
installAudioTapIfNeeded(on: playerItem)
startRadioSimulation()
} else if !VisualizerSettings.shared.realAudioAnalysis {
// Simulation mode only real analysis waits for offline vis data
startLevelSimulation()
@ -2025,6 +2049,11 @@ class AudioPlayer: NSObject, ObservableObject {
}
private func stopAVPlayer() {
#if os(iOS)
// Clean up audio tap before nilling the playerItem prevents stale
// sourceFormat from confusing Shazam into skipping tap installation
AudioTapProcessor.shared.removeTap(from: playerItem)
#endif
player?.pause()
player?.replaceCurrentItem(with: nil)
if let observer = timeObserver {

View file

@ -1,5 +1,6 @@
import Foundation
import AVFoundation
import MediaToolbox
import Accelerate
// MARK: - Lock-free Ring Buffer for audio samples
@ -172,6 +173,7 @@ final class AudioTapProcessor {
func removeTap(from playerItem: AVPlayerItem?) {
playerItem?.audioMix = nil
sourceFormat = nil
ringBuffer.reset() // Prevent stale samples from previous stream bleeding into next FFT
stopDebugDump()
}

View file

@ -49,7 +49,8 @@ struct MainTabView: View {
MyMusicView(
navigateToPlaylistId: $navigateToPlaylistId,
navigateToAlbumId: $navigateToAlbumId,
navigateToArtistId: $navigateToArtistId
navigateToArtistId: $navigateToArtistId,
isDynamicIsland: $isDynamicIsland
)
.tabItem {
Image(systemName: "music.note")

View file

@ -18,6 +18,7 @@ struct MyMusicView: View {
@Binding var navigateToPlaylistId: String?
@Binding var navigateToAlbumId: String?
@Binding var navigateToArtistId: String?
@Binding var isDynamicIsland: Bool
@State private var recentAlbums: [Album] = []
@State private var recentlyPlayedAlbums: [Album] = []
@ -162,7 +163,7 @@ struct MyMusicView: View {
.foregroundColor(selectedAlbumIds.isEmpty ? .gray : accentPink)
}
.disabled(selectedAlbumIds.isEmpty)
} else {
} else if !isDynamicIsland {
Button(action: { showServerPicker = true }) {
Image(systemName: "server.rack")
.foregroundColor(accentPink)

View file

@ -28,6 +28,10 @@ final class LyricsManager: ObservableObject {
/// Whether the lyrics overlay is visible in Now Playing.
@Published var isLyricsVisible = false
/// Global timing offset in seconds. Positive = lyrics appear later, negative = earlier.
/// Applied in syncToTime and wordProgress instant feedback without editing timestamps.
@Published var globalOffset: Double = 0
// MARK: - Non-Published State (polled per-frame by views)
/// Current word index within the active line. Updated per-frame when visible.
@ -81,6 +85,7 @@ final class LyricsManager: ObservableObject {
currentLineIndex = 0
currentWordIndex = 0
lastSyncedTime = 0
globalOffset = 0
isLyricsVisible = false
}
@ -92,7 +97,10 @@ final class LyricsManager: ObservableObject {
lastSyncedTime = time
guard let lyrics = currentLyrics, !lyrics.lines.isEmpty else { return }
let newIndex = findLineIndex(at: time, in: lyrics.lines)
// Apply global offset: positive = lyrics later, so we look up an earlier time
let adjusted = time - globalOffset
let newIndex = findLineIndex(at: adjusted, in: lyrics.lines)
if newIndex != currentLineIndex {
currentLineIndex = newIndex
}
@ -101,7 +109,7 @@ final class LyricsManager: ObservableObject {
if isLyricsVisible,
newIndex < lyrics.lines.count,
let words = lyrics.lines[newIndex].words {
currentWordIndex = findWordIndex(at: time, in: words)
currentWordIndex = findWordIndex(at: adjusted, in: words)
}
}
@ -112,21 +120,24 @@ final class LyricsManager: ObservableObject {
return (0, 0, 0)
}
let lineIdx = findLineIndex(at: time, in: lyrics.lines)
// Apply global offset
let adjusted = time - globalOffset
let lineIdx = findLineIndex(at: adjusted, in: lyrics.lines)
let line = lyrics.lines[lineIdx]
guard let words = line.words, !words.isEmpty else {
return (lineIdx, 0, 0)
}
let wordIdx = findWordIndex(at: time, in: words)
let wordIdx = findWordIndex(at: adjusted, in: words)
let word = words[min(wordIdx, words.count - 1)]
let wordDuration = word.endTime - word.startTime
let fraction: Double
if wordDuration > 0 {
fraction = min(max((time - word.startTime) / wordDuration, 0), 1)
fraction = min(max((adjusted - word.startTime) / wordDuration, 0), 1)
} else {
fraction = time >= word.startTime ? 1 : 0
fraction = adjusted >= word.startTime ? 1 : 0
}
return (lineIdx, wordIdx, fraction)

View file

@ -15,22 +15,28 @@ import SwiftUI
struct LyricsOverlayView: View {
@ObservedObject var lyricsManager = LyricsManager.shared
@ObservedObject var audioPlayer = AudioPlayer.shared
@State private var showTimingAdjust = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
if let lyrics = lyricsManager.currentLyrics, !lyrics.lines.isEmpty {
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !audioPlayer.isPlaying)) { timeline in
let now = audioPlayer.currentTime
let progress = lyricsManager.wordProgress(at: now)
ZStack(alignment: .bottom) {
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !audioPlayer.isPlaying)) { timeline in
let now = audioPlayer.currentTime
let progress = lyricsManager.wordProgress(at: now)
lyricsContent(
lyrics: lyrics,
currentTime: now,
lineIndex: progress.lineIndex,
wordIndex: progress.wordIndex,
wordFraction: progress.wordFraction
)
}
lyricsContent(
lyrics: lyrics,
currentTime: now,
lineIndex: progress.lineIndex,
wordIndex: progress.wordIndex,
wordFraction: progress.wordFraction
)
// Bottom toolbar search, timing, edit
lyricsToolbar(hasSynced: lyrics.hasSynced)
}
} else if lyricsManager.isLoading {
VStack {
@ -55,7 +61,6 @@ struct LyricsOverlayView: View {
.padding(.top, 8)
Button {
// Open search sheet
NotificationCenter.default.post(name: .openLyricsSearch, object: nil)
} label: {
Text("Search Lyrics")
@ -73,6 +78,118 @@ struct LyricsOverlayView: View {
}
}
// MARK: - Lyrics Toolbar
@ViewBuilder
private func lyricsToolbar(hasSynced: Bool) -> some View {
VStack(spacing: 0) {
// Timing adjustment (expandable)
if showTimingAdjust && hasSynced {
HStack(spacing: 12) {
Button { lyricsManager.globalOffset -= 0.5 } label: {
Text("-0.5s")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
}
Button { lyricsManager.globalOffset -= 0.1 } label: {
Text("-0.1s")
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(Color.white.opacity(0.06))
.cornerRadius(8)
}
Text(String(format: "%+.1fs", lyricsManager.globalOffset))
.font(.system(size: 15, weight: .bold, design: .monospaced))
.foregroundColor(lyricsManager.globalOffset == 0 ? .white.opacity(0.5) : accentPink)
.frame(width: 60)
.onTapGesture { lyricsManager.globalOffset = 0 }
Button { lyricsManager.globalOffset += 0.1 } label: {
Text("+0.1s")
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(Color.white.opacity(0.06))
.cornerRadius(8)
}
Button { lyricsManager.globalOffset += 0.5 } label: {
Text("+0.5s")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
}
}
.padding(.vertical, 10)
.padding(.horizontal, 16)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// Main toolbar buttons
HStack(spacing: 0) {
// Re-search lyrics
Button {
NotificationCenter.default.post(name: .openLyricsSearch, object: nil)
} label: {
VStack(spacing: 3) {
Image(systemName: "magnifyingglass")
.font(.system(size: 16))
Text("Search")
.font(.system(size: 10, weight: .medium))
}
.foregroundColor(.white.opacity(0.6))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
// Timing offset toggle (only for synced lyrics)
if hasSynced {
Button {
withAnimation(.spring(response: 0.3)) { showTimingAdjust.toggle() }
} label: {
VStack(spacing: 3) {
Image(systemName: "timer")
.font(.system(size: 16))
Text("Timing")
.font(.system(size: 10, weight: .medium))
}
.foregroundColor(showTimingAdjust ? accentPink : .white.opacity(0.6))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
}
// Open full editor
Button {
NotificationCenter.default.post(name: .openLyricsEditor, object: nil)
} label: {
VStack(spacing: 3) {
Image(systemName: "pencil.line")
.font(.system(size: 16))
Text("Edit")
.font(.system(size: 10, weight: .medium))
}
.foregroundColor(.white.opacity(0.6))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
}
.background(.ultraThinMaterial.opacity(0.8))
}
}
// MARK: - Lyrics Content
@ViewBuilder
@ -258,4 +375,5 @@ struct FlowLayout: Layout {
extension Notification.Name {
static let openLyricsSearch = Notification.Name("openLyricsSearch")
static let openLyricsEditor = Notification.Name("openLyricsEditor")
}

View file

@ -355,6 +355,9 @@ struct NowPlayingView: View {
.onReceive(NotificationCenter.default.publisher(for: .openLyricsSearch)) { _ in
showLyricsSearch = true
}
.onReceive(NotificationCenter.default.publisher(for: .openLyricsEditor)) { _ in
showLyricsEditor = true
}
.onAppear {
dragOffset = 0
isStarred = audioPlayer.currentSong?.starred != nil

View file

@ -20,7 +20,7 @@ settings:
base:
SWIFT_VERSION: "5.9"
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "8"
CURRENT_PROJECT_VERSION: "9"
DEAD_CODE_STRIPPING: true
ENABLE_USER_SCRIPT_SANDBOXING: true
DEVELOPMENT_TEAM: E9C9AGS9K6