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:
parent
3385b88270
commit
c6451b440b
8 changed files with 189 additions and 24 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ struct MainTabView: View {
|
|||
MyMusicView(
|
||||
navigateToPlaylistId: $navigateToPlaylistId,
|
||||
navigateToAlbumId: $navigateToAlbumId,
|
||||
navigateToArtistId: $navigateToArtistId
|
||||
navigateToArtistId: $navigateToArtistId,
|
||||
isDynamicIsland: $isDynamicIsland
|
||||
)
|
||||
.tabItem {
|
||||
Image(systemName: "music.note")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue