NavidromeApp/iOS/Views/Visualizer/MitsuhaVisualizerView.swift

1269 lines
60 KiB
Swift
Raw Normal View History

import SwiftUI
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// MARK: - Per-View Independent Configuration
/// Holds the full visual configuration for one visualizer context (Now Playing or Mini Player).
/// Each style stores its own point count and sensitivity so switching styles
/// never bleeds parameters from a different style.
struct ViewVisualizerConfig: Codable {
// MARK: Style & Color
var style: VisualizerSettings.Style = .wave
var colorMode: VisualizerSettings.ColorMode = .dynamic
var alpha: Double = 0.6
// Custom color stored as components Color is not Codable
var customColorR: Double = 1.0
var customColorG: Double = 0.176
var customColorB: Double = 0.333
// MARK: Per-style point counts (independent switching styles uses its own last value)
var wavePoints: Int = 9
var barPoints: Int = 12
var linePoints: Int = 10
var siriPoints: Int = 16
// MARK: Per-style sensitivity
var waveSensitivity: Double = 0.9
var barSensitivity: Double = 1.0
var lineSensitivity: Double = 0.9
var siriSensitivity: Double = 1.0
// MARK: Computed from active style
var numberOfPoints: Int {
get {
switch style {
case .wave: return wavePoints
case .bar: return barPoints
case .line: return linePoints
case .siriWave: return siriPoints
}
}
set {
switch style {
case .wave: wavePoints = newValue
case .bar: barPoints = newValue
case .line: linePoints = newValue
case .siriWave: siriPoints = newValue
}
}
}
var sensitivity: Double {
get {
switch style {
case .wave: return waveSensitivity
case .bar: return barSensitivity
case .line: return lineSensitivity
case .siriWave: return siriSensitivity
}
}
set {
switch style {
case .wave: waveSensitivity = newValue
case .bar: barSensitivity = newValue
case .line: lineSensitivity = newValue
case .siriWave: siriSensitivity = newValue
}
}
}
var customColor: Color {
Color(red: customColorR, green: customColorG, blue: customColorB)
}
// MARK: Defaults
static var nowPlayingDefault: ViewVisualizerConfig {
var c = ViewVisualizerConfig()
c.wavePoints = 9; c.barPoints = 12; c.linePoints = 10; c.siriPoints = 16
return c
}
static var miniPlayerDefault: ViewVisualizerConfig {
var c = ViewVisualizerConfig()
c.style = .wave; c.alpha = 0.5
c.wavePoints = 8; c.barPoints = 10; c.linePoints = 8; c.siriPoints = 12
return c
}
}
// MARK: - Visualizer Settings (global + per-view configs)
class VisualizerSettings: ObservableObject {
static let shared = VisualizerSettings()
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// MARK: Global toggles
@Published var enabled: Bool { didSet { save("vis_enabled", enabled) } }
@Published var nowPlayingEnabled: Bool { didSet { save("vis_nowplaying", nowPlayingEnabled) } }
@Published var miniPlayerEnabled: Bool { didSet { save("vis_miniplayer", miniPlayerEnabled) } }
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// MARK: Per-view independent configs
@Published var nowPlaying: ViewVisualizerConfig { didSet { saveConfig("vis_np_config", nowPlaying) } }
@Published var miniPlayer: ViewVisualizerConfig { didSet { saveConfig("vis_mini_config", miniPlayer) } }
// MARK: Global physics (shared between views)
@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) } }
@Published var viscosity: Double { didSet { save("vis_viscosity", viscosity) } }
@Published var frequencyCutoff: Int { didSet { save("vis_freq_cutoff", frequencyCutoff) } }
@Published var baseMultiplier: Double { didSet { save("vis_base_mult", baseMultiplier) } }
// MARK: Wave-specific global
@Published var waveStrokeThickness: Double { didSet { save("vis_wave_stroke", waveStrokeThickness) } }
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// MARK: Bar-specific global
@Published var barSpacing: Double { didSet { save("vis_bar_spacing", barSpacing) } }
@Published var barCornerRadius: Double { didSet { save("vis_bar_radius", barCornerRadius) } }
// MARK: Line-specific global
@Published var lineThickness: Double { didSet { save("vis_line_thick", lineThickness) } }
// MARK: Now Playing layout / depth
@Published var nowPlayingHeightPct: Double { didSet { save("vis_np_height", nowPlayingHeightPct) } }
@Published var waveOffsetTop: Double { didSet { save("vis_wave_offset", waveOffsetTop) } }
@Published var npAmplitude: Double { didSet { save("vis_np_amplitude", npAmplitude) } }
@Published var npBaseLift: Double { didSet { save("vis_np_baselift", npBaseLift) } }
@Published var depthOffset: Double { didSet { save("vis_depth_offset", depthOffset) } }
@Published var depthOpacity: Double { didSet { save("vis_depth_opacity",depthOpacity) } }
@Published var idleAmplitude: Double { didSet { save("vis_idle_amp", idleAmplitude) } }
// MARK: Mini Player layout / depth
@Published var miniPlayerHeight: Double { didSet { save("vis_mini_height", miniPlayerHeight) } }
@Published var miniOpacity: Double { didSet { save("vis_mini_opacity", miniOpacity) } }
@Published var miniAmplitude: Double { didSet { save("vis_mini_amplitude", miniAmplitude) } }
@Published var miniIdleAmplitude: Double { didSet { save("vis_mini_idle", miniIdleAmplitude) } }
@Published var miniDepthOffset: Double { didSet { save("vis_mini_depth", miniDepthOffset) } }
@Published var miniDepthOpacity: Double { didSet { save("vis_mini_depth_opacity", miniDepthOpacity) } }
enum Style: String, CaseIterable, Codable {
case wave = "Wave"
case bar = "Bar"
case line = "Line"
2026-04-09 23:11:40 -07:00
case siriWave = "Siri"
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
enum ColorMode: String, CaseIterable, Codable {
case dynamic = "Dynamic"
case albumArt = "Album Art"
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
case vibrant = "Vibrant"
case custom = "Custom"
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
private init() {
let d = UserDefaults.standard
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
enabled = d.object(forKey: "vis_enabled") as? Bool ?? true
nowPlayingEnabled = d.object(forKey: "vis_nowplaying") as? Bool ?? true
miniPlayerEnabled = d.object(forKey: "vis_miniplayer") as? Bool ?? true
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// Per-view configs load from JSON or use defaults
let dec = JSONDecoder()
if let data = d.data(forKey: "vis_np_config"),
let cfg = try? dec.decode(ViewVisualizerConfig.self, from: data) {
nowPlaying = cfg
} else {
nowPlaying = .nowPlayingDefault
}
if let data = d.data(forKey: "vis_mini_config"),
let cfg = try? dec.decode(ViewVisualizerConfig.self, from: data) {
miniPlayer = cfg
} else {
miniPlayer = .miniPlayerDefault
}
// Global physics
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
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
viscosity = { let v = d.double(forKey: "vis_viscosity"); return v > 0 ? v : 0.25 }()
frequencyCutoff = { let v = d.integer(forKey: "vis_freq_cutoff"); return v > 0 ? v : 80 }()
baseMultiplier = { let v = d.double(forKey: "vis_base_mult"); return v > 0 ? v : 25.0 }()
// Style-specific globals
waveStrokeThickness = { let v = d.double(forKey: "vis_wave_stroke"); return v > 0 ? v : 1.5 }()
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
barSpacing = { let v = d.double(forKey: "vis_bar_spacing"); return v > 0 ? v : 5.0 }()
barCornerRadius = d.object(forKey: "vis_bar_radius") as? Double ?? 0.0
lineThickness = { let v = d.double(forKey: "vis_line_thick"); return v > 0 ? v : 5.0 }()
// Now Playing layout
nowPlayingHeightPct = { let v = d.double(forKey: "vis_np_height"); return v > 0 ? v : 0.50 }()
waveOffsetTop = d.object(forKey: "vis_wave_offset") as? Double ?? 0.0
npAmplitude = { let v = d.double(forKey: "vis_np_amplitude"); return v > 0 ? v : 0.45 }()
npBaseLift = d.object(forKey: "vis_np_baselift") as? Double ?? 130.0
// Fix: use object(forKey:) ?? default so setting value to 0 is persisted correctly
depthOffset = d.object(forKey: "vis_depth_offset") as? Double ?? 15.0
depthOpacity = d.object(forKey: "vis_depth_opacity") as? Double ?? 0.2
idleAmplitude = { let v = d.double(forKey: "vis_idle_amp"); return v > 0 ? v : 0.03 }()
// Mini Player layout
miniPlayerHeight = { let v = d.double(forKey: "vis_mini_height"); return v > 0 ? v : 48.0 }()
miniOpacity = d.object(forKey: "vis_mini_opacity") as? Double ?? 0.5
miniAmplitude = { let v = d.double(forKey: "vis_mini_amplitude"); return v > 0 ? v : 0.7 }()
miniIdleAmplitude = { let v = d.double(forKey: "vis_mini_idle"); return v > 0 ? v : 0.03 }()
miniDepthOffset = d.object(forKey: "vis_mini_depth") as? Double ?? 8.0
miniDepthOpacity = d.object(forKey: "vis_mini_depth_opacity") as? Double ?? 0.2
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
var effectiveFPS: Double {
ProcessInfo.processInfo.isLowPowerModeEnabled ? min(fps, 24) : fps
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// MARK: - Persistence
private func save(_ key: String, _ value: Any) {
pendingSaves[key] = value
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
scheduleFlush()
}
private func saveConfig(_ key: String, _ config: ViewVisualizerConfig) {
if let data = try? JSONEncoder().encode(config) {
pendingSaves[key] = data
scheduleFlush()
}
}
overhaul AUDIT-036 — Slider/button fixes (direct Liquid Glass cause) scheduleFlush() now runs Task { @MainActor } instead of bare Task. The pendingSaves dictionary is now only ever read/written on the main thread. Before this fix, a UserDefaults write could race with a slider didSet, causing values to snap back or write the wrong value — which is exactly why buttons were switching state unexpectedly. AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause) TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0. When paused or not visible, the Canvas drops from 60fps to 2fps. This stops the continuous GPU wakeups that were fighting Liquid Glass gesture tracking, which is why sliders needed multiple attempts. AUDIT-001 — FFT real-time heap allocation processFFT no longer allocates any heap memory. The Hann window is computed once in init(). All four scratch buffers (fftWindow, fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and reused every render callback — zero allocations on the real-time audio thread. AUDIT-002 — WatchOfflineStore data race taskToSongId and pendingSongs now protected by a dedicated serial storeQueue. URLSession delegate reads and main thread writes are serialised. AUDIT-019 — URLSession per AsyncCoverArt render CompanionAPIService() no longer instantiated per render. Companion cover art URLs now built directly from CompanionSettings.shared.baseURL — no URLSession created. AUDIT-020 — Synchronous disk read on main thread CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the first check, then cachedImageAsync (disk read on ioQueue) for the second. Main thread never blocks on disk I/O. AUDIT-033 — Lost star/unstar actions offline Star/unstar now routes through OptimisticActionQueue — actions survive Tailscale reconnection and are retried automatically. AUDIT-035 — OptimisticActionQueue flush race flush() Task is now @MainActor — pendingActions only ever touched on main thread, no more race between rapid taps and in-flight flushes. AUDIT-038 — O(n²) deduplication deduplicateAlbums now O(n) using a frequency dictionary. For 843 albums: ~7.1M string comparisons/second during playback → ~1,700. AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize now uses totalSize directly
2026-04-11 11:17:40 -07:00
// Debounce UserDefaults writes to 500ms so rapid slider drags don't
// flood the defaults system. The Task is explicitly @MainActor so
// pendingSaves is only ever read/written on the main thread no data race.
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
private func scheduleFlush() {
saveTask?.cancel()
overhaul AUDIT-036 — Slider/button fixes (direct Liquid Glass cause) scheduleFlush() now runs Task { @MainActor } instead of bare Task. The pendingSaves dictionary is now only ever read/written on the main thread. Before this fix, a UserDefaults write could race with a slider didSet, causing values to snap back or write the wrong value — which is exactly why buttons were switching state unexpectedly. AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause) TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0. When paused or not visible, the Canvas drops from 60fps to 2fps. This stops the continuous GPU wakeups that were fighting Liquid Glass gesture tracking, which is why sliders needed multiple attempts. AUDIT-001 — FFT real-time heap allocation processFFT no longer allocates any heap memory. The Hann window is computed once in init(). All four scratch buffers (fftWindow, fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and reused every render callback — zero allocations on the real-time audio thread. AUDIT-002 — WatchOfflineStore data race taskToSongId and pendingSongs now protected by a dedicated serial storeQueue. URLSession delegate reads and main thread writes are serialised. AUDIT-019 — URLSession per AsyncCoverArt render CompanionAPIService() no longer instantiated per render. Companion cover art URLs now built directly from CompanionSettings.shared.baseURL — no URLSession created. AUDIT-020 — Synchronous disk read on main thread CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the first check, then cachedImageAsync (disk read on ioQueue) for the second. Main thread never blocks on disk I/O. AUDIT-033 — Lost star/unstar actions offline Star/unstar now routes through OptimisticActionQueue — actions survive Tailscale reconnection and are retried automatically. AUDIT-035 — OptimisticActionQueue flush race flush() Task is now @MainActor — pendingActions only ever touched on main thread, no more race between rapid taps and in-flight flushes. AUDIT-038 — O(n²) deduplication deduplicateAlbums now O(n) using a frequency dictionary. For 843 albums: ~7.1M string comparisons/second during playback → ~1,700. AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize now uses totalSize directly
2026-04-11 11:17:40 -07:00
saveTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 500_000_000)
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
guard !Task.isCancelled, let self else { return }
for (k, v) in self.pendingSaves {
UserDefaults.standard.set(v, forKey: k)
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
self.pendingSaves.removeAll()
}
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
private var pendingSaves: [String: Any] = [:]
private var saveTask: Task<Void, Never>?
}
// MARK: - Wave State Cache (shared between mini player and DI for morph continuity)
/// Single source of truth for compact visualizer levels.
/// Both MiniPlayerBar and DynamicIslandView seed from here on appear,
/// so the wave doesn't reset to idle when matchedGeometryEffect transitions between them.
final class WaveStateCache {
static let shared = WaveStateCache()
var compactLevels: [Float] = []
private init() {}
}
// MARK: - Level Box
2026-04-05 08:51:26 -07:00
/// Per-instance mutable render state. Zero @Published properties no observation warnings.
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
/// All working buffers pre-allocated here so the 60fps render loop does zero heap allocation.
fileprivate final class VisualizerLevelBox: ObservableObject {
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
var displayLevels: [Float] = []
var targetLevels: [Float] = [] // scratch buffer pre-allocated, mutated in-place each frame
var idleLevels: [Float] = [] // flat idle wave pre-allocated, updated only when count changes
var peakFollower: Float = 0.01
var levelHistoryBuf: [[Float]] = []
var historyWriteIdx: Int = 0
var wobblePhaseOffset: Double = 0
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
var lastTickTime: CFTimeInterval = 0
var resumeTickCount: Int = 0 // debug: counts first N ticks after resume
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
/// Resize all per-frame buffers when point count changes (rare settings slider).
func resizeIfNeeded(count: Int, idleAmplitude: Float) {
guard count > 0 else { return }
if targetLevels.count != count {
targetLevels = [Float](repeating: 0, count: count)
displayLevels = [Float](repeating: idleAmplitude, count: count)
idleLevels = [Float](repeating: idleAmplitude, count: count)
// Pre-fill history ring with idle so depth ghost doesn't start blank
levelHistoryBuf = [[Float]](
repeating: [Float](repeating: idleAmplitude, count: count),
count: MitsuhaVisualizerView.historySize
)
historyWriteIdx = 0
} else if idleLevels.first != idleAmplitude {
for i in 0..<count { idleLevels[i] = idleAmplitude }
}
}
}
// MARK: - Main Visualizer View
struct MitsuhaVisualizerView: View {
var previewLevels: [Float]? = nil
let isPlaying: Bool
2026-04-05 08:33:00 -07:00
var isSongLoaded: Bool = true
let accentColor: Color
var compact: Bool = false
var isVisible: Bool = true
overhaul AUDIT-036 — Slider/button fixes (direct Liquid Glass cause) scheduleFlush() now runs Task { @MainActor } instead of bare Task. The pendingSaves dictionary is now only ever read/written on the main thread. Before this fix, a UserDefaults write could race with a slider didSet, causing values to snap back or write the wrong value — which is exactly why buttons were switching state unexpectedly. AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause) TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0. When paused or not visible, the Canvas drops from 60fps to 2fps. This stops the continuous GPU wakeups that were fighting Liquid Glass gesture tracking, which is why sliders needed multiple attempts. AUDIT-001 — FFT real-time heap allocation processFFT no longer allocates any heap memory. The Hann window is computed once in init(). All four scratch buffers (fftWindow, fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and reused every render callback — zero allocations on the real-time audio thread. AUDIT-002 — WatchOfflineStore data race taskToSongId and pendingSongs now protected by a dedicated serial storeQueue. URLSession delegate reads and main thread writes are serialised. AUDIT-019 — URLSession per AsyncCoverArt render CompanionAPIService() no longer instantiated per render. Companion cover art URLs now built directly from CompanionSettings.shared.baseURL — no URLSession created. AUDIT-020 — Synchronous disk read on main thread CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the first check, then cachedImageAsync (disk read on ioQueue) for the second. Main thread never blocks on disk I/O. AUDIT-033 — Lost star/unstar actions offline Star/unstar now routes through OptimisticActionQueue — actions survive Tailscale reconnection and are retried automatically. AUDIT-035 — OptimisticActionQueue flush race flush() Task is now @MainActor — pendingActions only ever touched on main thread, no more race between rapid taps and in-flight flushes. AUDIT-038 — O(n²) deduplication deduplicateAlbums now O(n) using a frequency dictionary. For 843 albums: ~7.1M string comparisons/second during playback → ~1,700. AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize now uses totalSize directly
2026-04-11 11:17:40 -07:00
// Observe settings only for the enabled gate and config values.
// The Canvas itself does NOT observe settings we pass the values it
// needs as local constants captured at body evaluation time. This means
// a slider drag in VisualizerSettingsView does NOT invalidate the Canvas
// on every change only the outer view re-evaluates, and the Canvas
// only re-evaluates when isPlaying / isVisible / isAppActive changes.
@ObservedObject var settings = VisualizerSettings.shared
@ObservedObject var albumColors = AlbumColorExtractor.shared
2026-04-05 12:05:20 -07:00
@StateObject private var box = VisualizerLevelBox()
@State private var isAppActive = true
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
static let historySize = 16
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
/// Active view config mini player uses its own independent config
private var config: ViewVisualizerConfig {
compact ? settings.miniPlayer : settings.nowPlaying
}
private var isRenderingActive: Bool {
settings.enabled && isPlaying && isAppActive && isVisible
}
var body: some View {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
Group {
if settings.enabled {
overhaul AUDIT-036 — Slider/button fixes (direct Liquid Glass cause) scheduleFlush() now runs Task { @MainActor } instead of bare Task. The pendingSaves dictionary is now only ever read/written on the main thread. Before this fix, a UserDefaults write could race with a slider didSet, causing values to snap back or write the wrong value — which is exactly why buttons were switching state unexpectedly. AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause) TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0. When paused or not visible, the Canvas drops from 60fps to 2fps. This stops the continuous GPU wakeups that were fighting Liquid Glass gesture tracking, which is why sliders needed multiple attempts. AUDIT-001 — FFT real-time heap allocation processFFT no longer allocates any heap memory. The Hann window is computed once in init(). All four scratch buffers (fftWindow, fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and reused every render callback — zero allocations on the real-time audio thread. AUDIT-002 — WatchOfflineStore data race taskToSongId and pendingSongs now protected by a dedicated serial storeQueue. URLSession delegate reads and main thread writes are serialised. AUDIT-019 — URLSession per AsyncCoverArt render CompanionAPIService() no longer instantiated per render. Companion cover art URLs now built directly from CompanionSettings.shared.baseURL — no URLSession created. AUDIT-020 — Synchronous disk read on main thread CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the first check, then cachedImageAsync (disk read on ioQueue) for the second. Main thread never blocks on disk I/O. AUDIT-033 — Lost star/unstar actions offline Star/unstar now routes through OptimisticActionQueue — actions survive Tailscale reconnection and are retried automatically. AUDIT-035 — OptimisticActionQueue flush race flush() Task is now @MainActor — pendingActions only ever touched on main thread, no more race between rapid taps and in-flight flushes. AUDIT-038 — O(n²) deduplication deduplicateAlbums now O(n) using a frequency dictionary. For 843 albums: ~7.1M string comparisons/second during playback → ~1,700. AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize now uses totalSize directly
2026-04-11 11:17:40 -07:00
// Adaptive FPS: full rate when rendering, 2fps when idle/paused.
// This prevents the 60fps GPU wakeup that drains battery during pause
// and stops the render loop from fighting Liquid Glass gesture tracking.
let targetFPS = isRenderingActive ? settings.effectiveFPS : 2.0
let tickInterval = 1.0 / max(targetFPS, 1.0)
TimelineView(.periodic(from: .now, by: tickInterval)) { timeline in
2026-04-10 19:20:27 -07:00
let tickDate = timeline.date
Canvas { context, size in
_ = tickDate
box.resizeIfNeeded(count: config.numberOfPoints,
idleAmplitude: Float(settings.idleAmplitude))
guard box.displayLevels.count >= 2 else { return }
if isRenderingActive {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
let t = CACurrentMediaTime()
let rawLevels = previewLevels ?? AudioPlayer.shared.currentLevels()
if box.resumeTickCount < 3 {
box.resumeTickCount += 1
DebugLogger.shared.log(
"TL tick #\(box.resumeTickCount): " +
"rawCount=\(rawLevels.count) " +
"rawMax=\(String(format: "%.3f", rawLevels.max() ?? 0)) " +
"lastTick=\(String(format: "%.1f", box.lastTickTime))",
category: "VisDebug", level: .debug)
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
updateDisplayLevels(newRawLevels: rawLevels, t: t)
updateWobblePhase(t: t)
switch config.style {
case .wave: drawWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset)
case .bar: drawBars(ctx: context, size: size, levels: box.displayLevels)
case .line: drawLine(ctx: context, size: size, levels: box.displayLevels)
case .siriWave: drawSiriWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset)
}
2026-04-10 19:20:27 -07:00
} else {
// Idle/paused: draw last known state without updating levels
drawIdleState(ctx: context, size: size)
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
}
}
2026-04-09 23:11:40 -07:00
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
}
}
.opacity(isPlaying ? 1.0 : (isSongLoaded ? 0.35 : 1.0))
.animation(isSongLoaded ? .easeInOut(duration: 0.6) : nil, value: isPlaying)
.onAppear {
box.resizeIfNeeded(count: config.numberOfPoints,
idleAmplitude: Float(settings.idleAmplitude))
if compact, !WaveStateCache.shared.compactLevels.isEmpty {
let cached = WaveStateCache.shared.compactLevels
let count = box.displayLevels.count
for i in 0..<min(cached.count, count) {
box.displayLevels[i] = cached[i]
}
}
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
.onChange(of: isPlaying) { _, playing in
if !playing {
box.levelHistoryBuf.removeAll(keepingCapacity: true)
box.historyWriteIdx = 0
box.peakFollower = 0.01
box.lastTickTime = 0 // reset so first resume frame uses 1/60 fallback dt
DebugLogger.shared.log(
"PAUSE → levelHistoryBuf cleared, lastTickTime reset " +
"[targets:\(box.targetLevels.count) display:\(box.displayLevels.count)]",
category: "VisDebug", level: .info)
} else {
box.resumeTickCount = 0 // reset so we log the first 3 ticks
DebugLogger.shared.log(
"RESUME → isRenderingActive=\(isRenderingActive) " +
"isAppActive=\(isAppActive) isVisible=\(isVisible) " +
"[targets:\(box.targetLevels.count) display:\(box.displayLevels.count) " +
"history:\(box.levelHistoryBuf.count)]",
category: "VisDebug", level: .info)
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
}
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
isAppActive = true
box.lastTickTime = 0
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in
isAppActive = false
box.levelHistoryBuf.removeAll(keepingCapacity: true)
box.historyWriteIdx = 0
}
}
// MARK: - Idle State
private func drawIdleState(ctx: GraphicsContext, size: CGSize) {
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
guard box.idleLevels.count >= 2 else { return }
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
switch config.style {
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
case .wave: drawWave(ctx: ctx, size: size, levels: box.idleLevels, continuousTime: 0)
case .bar: drawBars(ctx: ctx, size: size, levels: box.idleLevels)
case .line: drawLine(ctx: ctx, size: size, levels: box.idleLevels)
case .siriWave: drawSiriWave(ctx: ctx, size: size, levels: box.idleLevels, continuousTime: 0)
}
}
2026-04-05 08:51:26 -07:00
// MARK: - Wobble Phase
2026-04-05 22:47:57 -07:00
private func updateWobblePhase(t: Double) {
2026-04-05 08:51:26 -07:00
if box.lastTickTime > 0 {
2026-04-05 22:47:57 -07:00
let delta = t - box.lastTickTime
box.wobblePhaseOffset += min(delta, 0.1)
}
2026-04-05 22:47:57 -07:00
box.lastTickTime = t
}
// MARK: - Temporal Smoothing & Log Binning
2026-04-05 08:33:00 -07:00
@discardableResult
2026-04-10 07:00:30 -07:00
private func updateDisplayLevels(newRawLevels: [Float], t: Double) -> Bool {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
let count = config.numberOfPoints
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
guard count > 0, !newRawLevels.isEmpty,
box.targetLevels.count == count else {
DebugLogger.shared.log(
"updateDisplayLevels GUARD FAILED: " +
"count=\(count) rawEmpty=\(newRawLevels.isEmpty) " +
"targetCount=\(box.targetLevels.count)",
category: "VisDebug", level: .warning)
return false
}
2026-04-10 07:00:30 -07:00
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
let dt = Float(box.lastTickTime > 0 ? min(t - box.lastTickTime, 0.1) : 1.0/60.0)
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
let sens = Float(config.sensitivity)
let isPreProcessed = AudioPlayer.shared.isUsingOfflineVis
2026-04-10 07:00:30 -07:00
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
// Write directly into pre-allocated box.targetLevels
if isPreProcessed {
if newRawLevels.count == count {
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
// Hot path: identical size single in-place pass, zero allocation
for i in 0..<count {
box.targetLevels[i] = min(1.0, newRawLevels[i] * sens)
}
} else {
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
// Resample via linear interpolation directly into box.targetLevels
let srcCount = Float(newRawLevels.count - 1)
let dstCount = Float(max(count - 1, 1))
for i in 0..<count {
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
let srcPos = Float(i) / dstCount * srcCount
let lo = Int(srcPos)
let hi = min(lo + 1, newRawLevels.count - 1)
let frac = srcPos - Float(lo)
box.targetLevels[i] = min(1.0,
(newRawLevels[lo] * (1.0 - frac) + newRawLevels[hi] * frac) * sens)
}
}
} else {
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
// Raw FFT log binning directly into box.targetLevels
let maxUsefulBin = min(newRawLevels.count - 1, settings.frequencyCutoff)
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
let mult = Float(settings.baseMultiplier)
for i in 0..<count {
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
let ni = Float(i + 1) / Float(count)
let centerBin = log10(ni * 9.0 + 1.0) * Float(maxUsefulBin)
let binWidth = max(1.0, Float(maxUsefulBin) / Float(count))
let startBin = max(1, Int(centerBin - binWidth * 0.5))
let endBin = min(maxUsefulBin, Int(centerBin + binWidth * 0.5))
var sum: Float = 0
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
var n = 0
for j in startBin...endBin where j < newRawLevels.count {
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
sum += newRawLevels[j]; n += 1
}
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
box.targetLevels[i] = min(1.0, (n > 0 ? sum / Float(n) : 0) * mult * sens)
}
}
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
// Dynamic Gain / Peak Follower (in-place on box.targetLevels)
2026-04-05 22:47:57 -07:00
let smoothFactor = min(Float(settings.viscosity) * 60.0 * dt, 1.0)
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
let frameMax = box.targetLevels.max() ?? 0.0
if settings.dynamicGainEnabled {
2026-04-09 23:39:52 -07:00
if frameMax > box.peakFollower {
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
box.peakFollower += (frameMax - box.peakFollower) * 0.3
2026-04-09 23:39:52 -07:00
} else {
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
box.peakFollower = max(box.peakFollower * pow(0.5, dt), 0.01)
2026-04-09 23:39:52 -07:00
}
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
let normFactor = 1.0 / max(box.peakFollower, 0.01)
for i in 0..<count {
box.targetLevels[i] = min(box.targetLevels[i] * normFactor, 1.0)
}
2026-04-09 23:39:52 -07:00
} else {
box.peakFollower = max(box.peakFollower, frameMax)
}
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
// Exponential smooth into displayLevels (in-place)
for i in 0..<count {
box.displayLevels[i] += (box.targetLevels[i] - box.displayLevels[i]) * smoothFactor
}
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
// Ring buffer: copy in-place into pre-allocated history slot
// Pre-allocated slots mean no COW trigger on assignment.
if box.levelHistoryBuf.count < MitsuhaVisualizerView.historySize {
box.levelHistoryBuf.append(box.displayLevels)
} else {
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
let idx = box.historyWriteIdx
if box.levelHistoryBuf[idx].count == count {
for i in 0..<count {
box.levelHistoryBuf[idx][i] = box.displayLevels[i]
}
} else {
box.levelHistoryBuf[idx] = box.displayLevels
}
box.historyWriteIdx = (box.historyWriteIdx + 1) % MitsuhaVisualizerView.historySize
}
if compact { WaveStateCache.shared.compactLevels = box.displayLevels }
2026-04-05 08:33:00 -07:00
return true
}
// MARK: - Colors
private var fillColors: [Color] {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
let a = config.alpha
switch config.colorMode {
case .dynamic: return [accentColor.opacity(a), accentColor.opacity(a * 0.6)]
case .albumArt: return [albumColors.primaryColor.opacity(a), albumColors.secondaryColor.opacity(a * 0.7)]
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
case .vibrant: return [.pink.opacity(a), .green.opacity(a), .cyan.opacity(a), .purple.opacity(a), .white.opacity(a * 0.5), .pink.opacity(a)]
case .custom: return [config.customColor.opacity(a), config.customColor.opacity(a * 0.6)]
}
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
private var strokeColor: Color {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
let a = min(1, config.alpha + 0.3)
switch config.colorMode {
case .dynamic: return accentColor.opacity(a)
case .albumArt: return albumColors.primaryColor.opacity(a)
case .vibrant: return Color.cyan.opacity(a)
case .custom: return config.customColor.opacity(a)
}
}
// MARK: - Catmull-Rom Spline (tension 0.3 = heavy liquid surface tension)
/// Tension: 0.3 gives rounded, rolling peaks. Lower = tighter. Higher = bouncier.
private let curveTension: CGFloat = 0.3
/// Curve path without initial moveTo used for fill shapes
private func smoothCurve(_ points: [CGPoint]) -> Path {
var p = Path()
guard points.count >= 2 else { return p }
for i in 0..<(points.count - 1) {
let p0 = i > 0 ? points[i - 1] : points[0]
let p1 = points[i]
let p2 = points[i + 1]
let p3 = i < points.count - 2 ? points[i + 2] : p2
let cp1 = CGPoint(
x: p1.x + (p2.x - p0.x) * curveTension,
y: p1.y + (p2.y - p0.y) * curveTension
)
let cp2 = CGPoint(
x: p2.x - (p3.x - p1.x) * curveTension,
y: p2.y - (p3.y - p1.y) * curveTension
)
p.addCurve(to: p2, control1: cp1, control2: cp2)
}
return p
}
/// Full curve path with moveTo used for strokes
private func strokeableCurve(_ points: [CGPoint]) -> Path {
var p = Path()
guard points.count >= 2 else { return p }
p.move(to: points[0])
for i in 0..<(points.count - 1) {
let p0 = i > 0 ? points[i - 1] : points[0]
let p1 = points[i]
let p2 = points[i + 1]
let p3 = i < points.count - 2 ? points[i + 2] : p2
let cp1 = CGPoint(
x: p1.x + (p2.x - p0.x) * curveTension,
y: p1.y + (p2.y - p0.y) * curveTension
)
let cp2 = CGPoint(
x: p2.x - (p3.x - p1.x) * curveTension,
y: p2.y - (p3.y - p1.y) * curveTension
)
p.addCurve(to: p2, control1: cp1, control2: cp2)
}
return p
}
// MARK: - Wave
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 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)
var points = levels.enumerated().map { i, lev -> CGPoint in
let x = CGFloat(i) * spacing
let baseAmp = CGFloat(lev)
let organicWobble = CGFloat(sin(now * 3.0 + (Double(i) * 0.8)) * 0.03)
let totalAmp = max(idleVal, baseAmp + organicWobble)
return CGPoint(x: x, y: baseline - (totalAmp * h * ampScale))
}
// 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 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))
depthFill.addLine(to: depthPts[0])
depthFill.addPath(depthCurve)
depthFill.addLine(to: CGPoint(x: w, y: h))
depthFill.closeSubpath()
ctx.fill(depthFill, with: .color(strokeColor.opacity(ctxDepthOpacity)))
// Layer 2: Main fill gradient from wave crest to true bottom
let curve = smoothCurve(points)
var fill = Path()
fill.move(to: CGPoint(x: 0, y: h))
fill.addLine(to: points[0])
fill.addPath(curve)
fill.addLine(to: CGPoint(x: w, y: h))
fill.closeSubpath()
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
let isSiri = config.colorMode == .vibrant
if isSiri {
ctx.fill(fill, with: .linearGradient(
Gradient(colors: fillColors),
startPoint: CGPoint(x: 0, y: 0),
endPoint: CGPoint(x: w, y: 0)
))
} else {
ctx.fill(fill, with: .linearGradient(
Gradient(colors: fillColors),
startPoint: CGPoint(x: w / 2, y: baseline - h * 0.3),
endPoint: CGPoint(x: w / 2, y: h)
))
}
// Layer 3: Stroke line
ctx.stroke(strokeableCurve(points), with: .color(strokeColor), lineWidth: CGFloat(settings.waveStrokeThickness))
}
// MARK: - Bars
private func drawBars(ctx: GraphicsContext, size: CGSize, levels: [Float]) {
let w = size.width
let h = size.height
let count = levels.count
guard count > 0 else { return }
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 gap = CGFloat(settings.barSpacing)
let cr = CGFloat(settings.barCornerRadius)
let totalGapSpace = gap * CGFloat(count - 1)
let barW = max(1, (w - totalGapSpace) / CGFloat(count))
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
let isSiri = config.colorMode == .vibrant
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
// Hoist fillColors outside loop was allocating [Color] count times per frame
let colors = fillColors
for i in 0..<count {
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
let amp = CGFloat(levels[i])
let barH = max(compact ? 2 : 4, amp * h * ampScale)
let x = CGFloat(i) * (barW + gap)
let barTop = baseline - barH
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
let rect = CGRect(x: x, y: barTop, width: barW, height: h - barTop)
let path = Path(roundedRect: rect, cornerRadius: cr)
let startPoint = isSiri ? CGPoint(x: 0, y: 0) : CGPoint(x: x, y: barTop)
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
let endPoint = isSiri ? CGPoint(x: w, y: 0) : CGPoint(x: x, y: h)
ctx.fill(path, with: .linearGradient(
Performance Improvements Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
2026-04-10 16:25:49 -07:00
Gradient(colors: colors),
startPoint: startPoint,
endPoint: endPoint
))
}
}
// MARK: - Line
private func drawLine(ctx: GraphicsContext, size: CGSize, levels: [Float]) {
let w = size.width
let h = size.height
guard levels.count >= 2 else { return }
let spacing = w / CGFloat(levels.count - 1)
let thick = CGFloat(settings.lineThickness)
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
let isSiri = config.colorMode == .vibrant
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
let offset = compact ? 0 : CGFloat(settings.waveOffsetTop)
let centerY = compact ? h * 0.5 : h - baseLift - offset
let ampMult: CGFloat = compact ? CGFloat(settings.miniAmplitude) * 0.5 : CGFloat(settings.npAmplitude) * 0.55
let points = levels.enumerated().map { i, lev -> CGPoint in
let dir: CGFloat = i % 2 == 0 ? -1 : 1
let amp = CGFloat(lev) * (h * ampMult)
return CGPoint(x: CGFloat(i) * spacing, y: centerY + (dir * amp))
}
let curve = strokeableCurve(points)
let strokeStyle = StrokeStyle(lineWidth: thick, lineCap: .round, lineJoin: .round)
// Glow
ctx.stroke(curve, with: .color(strokeColor.opacity(0.3)), style: StrokeStyle(lineWidth: thick + 6, lineCap: .round, lineJoin: .round))
// Core line
if isSiri {
ctx.stroke(curve, with: .linearGradient(
Gradient(colors: fillColors),
startPoint: CGPoint(x: 0, y: centerY),
endPoint: CGPoint(x: w, y: centerY)
), style: strokeStyle)
} else {
ctx.stroke(curve, with: .color(strokeColor), style: strokeStyle)
}
}
2026-04-09 23:11:40 -07:00
// MARK: - Siri Wave
/// Authentic multi-layer Siri waveform.
///
/// Five stroked paths are drawn over the same level data. Each layer has:
/// - A distinct phase offset so the peaks weave past each other organically.
/// - A distinct amplitude multiplier (some negative = inverted) creating the
/// crossing / figure-eight structure visible in the original Mitsuha Infinity.
/// - A sine-window (bell-curve) attenuation across the width: the wave is forced
/// to zero at both edges and reaches full amplitude only in the centre.
/// Mathematically: attenuation(x) = sin(π * x/w) which is 0 at x=0, peaks at 1
/// at x=w/2, and returns to 0 at x=w.
/// - `plusLighter` blend mode so where two bright lines overlap they add their
/// luminance, creating glowing intersection nodes like the original.
private func drawSiriWave(ctx: GraphicsContext, size: CGSize, levels: [Float], continuousTime: Double) {
let w = size.width
let h = size.height
guard levels.count >= 2 else { return }
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
let waveOffset = compact ? 0 : CGFloat(settings.waveOffsetTop)
let centerY = compact ? h * 0.5 : h - baseLift - waveOffset
let baseAmp: CGFloat = compact
? CGFloat(settings.miniAmplitude) * h * 0.4
: CGFloat(settings.npAmplitude) * h * 0.38
// Five layers: (phase offset in radians, amplitude multiplier, opacity, stroke width)
let layers: [(phase: Double, ampMult: CGFloat, opacity: Double, width: CGFloat)] = [
(phase: 0.0, ampMult: 1.00, opacity: 0.90, width: 2.5), // core
(phase: .pi * 0.35, ampMult: 0.60, opacity: 0.65, width: 1.8), // support A
(phase: .pi * 0.70, ampMult: 0.30, opacity: 0.50, width: 1.4), // support B
(phase: .pi * 1.10, ampMult: -0.40, opacity: 0.45, width: 1.2), // inverted A
(phase: .pi * 1.55, ampMult: -0.20, opacity: 0.30, width: 1.0), // inverted B
]
let count = levels.count
let spacing = w / CGFloat(count - 1)
// Build siri colour gradient once
let siriColors: [Color] = [.pink, .purple, .cyan, .green, .pink]
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
let useVibrant = config.colorMode == .vibrant
let a = config.alpha
2026-04-09 23:11:40 -07:00
for layer in layers {
var points: [CGPoint] = []
points.reserveCapacity(count)
for i in 0..<count {
let x = CGFloat(i) * spacing
let nx = x / w // normalised 01
// Sine-window attenuation zero at edges, 1 at centre
let attenuation = CGFloat(sin(.pi * nx))
// Sample level at this x with per-layer phase offset applied as a
// time warp: the effective sample position shifts along the level array
// by (phase / 2π) * count, wrapped with linear interpolation.
let shift = layer.phase / (2 * .pi) * Double(count - 1)
let rawPos = Double(i) + shift
let lo = Int(rawPos) % count
let hi = (lo + 1) % count
let frac = CGFloat(rawPos - floor(rawPos))
let sampledLevel = CGFloat(levels[lo]) * (1 - frac) + CGFloat(levels[hi]) * frac
// Organic wobble using continuous time + per-layer phase
let wobble = CGFloat(sin(continuousTime * 3.0 + Double(i) * 0.8 + layer.phase) * 0.03)
let totalAmp = (sampledLevel + wobble) * layer.ampMult * attenuation * baseAmp
points.append(CGPoint(x: x, y: centerY - totalAmp))
}
let curve = strokeableCurve(points)
let style = StrokeStyle(lineWidth: layer.width, lineCap: .round, lineJoin: .round)
// Draw with plusLighter blend mode for additive glow at intersections
ctx.drawLayer { layerCtx in
layerCtx.blendMode = .plusLighter
if useVibrant {
layerCtx.stroke(curve, with: .linearGradient(
Gradient(colors: siriColors.map { $0.opacity(layer.opacity * a) }),
startPoint: .zero,
endPoint: CGPoint(x: w, y: 0)
), style: style)
} else {
let c: Color
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
switch config.colorMode {
2026-04-09 23:11:40 -07:00
case .albumArt: c = AlbumColorExtractor.shared.primaryColor
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
case .custom: c = config.customColor
2026-04-09 23:11:40 -07:00
default: c = accentColor
}
layerCtx.stroke(curve, with: .color(c.opacity(layer.opacity * a)), style: style)
}
}
}
}
}
// MARK: - Compact Visualizer (for mini player)
struct CompactVisualizerView: View {
let isPlaying: Bool
2026-04-05 08:33:00 -07:00
var isSongLoaded: Bool = true
let accentColor: Color
let height: CGFloat
2026-04-05 08:33:00 -07:00
var body: some View {
MitsuhaVisualizerView(
isPlaying: isPlaying,
2026-04-05 08:33:00 -07:00
isSongLoaded: isSongLoaded,
accentColor: accentColor,
compact: true
)
.frame(height: height)
}
}
// MARK: - Visualizer Settings View
struct VisualizerSettingsView: View {
@ObservedObject var settings = VisualizerSettings.shared
@Environment(\.dismiss) private var dismiss
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
private let pink = Color(red: 1.0, green: 0.176, blue: 0.333)
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
var body: some View {
NavigationStack {
Form {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// Master toggles
Section {
Toggle("Enabled", isOn: $settings.enabled).tint(pink)
Toggle("Now Playing Screen", isOn: $settings.nowPlayingEnabled).tint(pink).disabled(!settings.enabled)
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
Toggle("Mini Player", isOn: $settings.miniPlayerEnabled).tint(pink).disabled(!settings.enabled)
} header: { Text("VISUALIZER") } footer: {
Text("Master toggle disables all visualizers. Individual toggles control each location.")
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// Per-view independent configs
Section {
NavigationLink {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
ViewConfigSettingsView(
title: "Now Playing",
config: $settings.nowPlaying,
amplitude: $settings.npAmplitude,
baseLift: $settings.npBaseLift,
waveOffset: $settings.waveOffsetTop,
depthOffset: $settings.depthOffset,
depthOpacity: $settings.depthOpacity,
idleAmplitude: $settings.idleAmplitude,
heightPct: $settings.nowPlayingHeightPct,
isCompact: false
)
} label: {
HStack {
Image(systemName: "play.rectangle.fill").foregroundColor(pink).frame(width: 28)
Text("Now Playing")
Spacer()
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
Text(settings.nowPlaying.style.rawValue)
.font(.caption).foregroundColor(.gray)
}
}
NavigationLink {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
ViewConfigSettingsView(
title: "Mini Player",
config: $settings.miniPlayer,
amplitude: $settings.miniAmplitude,
baseLift: .constant(0),
waveOffset: .constant(0),
depthOffset: $settings.miniDepthOffset,
depthOpacity: $settings.miniDepthOpacity,
idleAmplitude: $settings.miniIdleAmplitude,
heightPct: .constant(0),
isCompact: true
)
} label: {
HStack {
Image(systemName: "rectangle.bottomhalf.filled").foregroundColor(pink).frame(width: 28)
Text("Mini Player")
Spacer()
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
Text(settings.miniPlayer.style.rawValue)
.font(.caption).foregroundColor(.gray)
}
}
} header: { Text("PER-VIEW SETTINGS") } footer: {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
Text("Each view has its own style, color, point count, sensitivity, amplitude, and depth. Changes in one view never affect the other.")
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// Style-specific global params
Section {
sliderRowDouble("Wave stroke", value: $settings.waveStrokeThickness, range: 0.5...5.0, step: 0.5, format: "%.1f")
sliderRowDouble("Bar spacing", value: $settings.barSpacing, range: 0...20, step: 1, format: "%.0f")
sliderRowDouble("Bar radius", value: $settings.barCornerRadius, range: 0...15, step: 1, format: "%.0f")
sliderRowDouble("Line thickness",value: $settings.lineThickness, range: 1...12, step: 0.5, format: "%.1f")
} header: { Text("STYLE GLOBALS") } footer: {
Text("These shape parameters apply globally to their respective styles across both views.")
}
// Shared physics
Section {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
sliderRowDouble("Viscosity", value: $settings.viscosity, range: 0.05...1.0, step: 0.01, format: "%.2f")
sliderRow( "Frequency cutoff",value: $settings.frequencyCutoff, range: 40...150, step: 5)
sliderRowDouble("Base multiplier", value: $settings.baseMultiplier, range: 5.0...50.0, step: 1.0, format: "%.0f")
sliderRowDouble("FPS", value: $settings.fps, range: 15...60, step: 1, format: "%.0f")
Toggle("Real Audio Analysis", isOn: $settings.realAudioAnalysis).tint(pink)
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
Toggle("Dynamic Gain", isOn: $settings.dynamicGainEnabled).tint(pink)
} header: { Text("SHARED PHYSICS") } footer: {
Text("Viscosity: 0.100.20 = heavy ocean swell. 0.4+ = snappy EQ. Base Multiplier: 1525 for most music. Dynamic Gain normalises amplitude across loud and quiet tracks.")
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// Presets
Section {
Button(action: applyDeepOcean) {
HStack {
Image(systemName: "water.waves").foregroundColor(.cyan)
VStack(alignment: .leading) {
Text("Deep Ocean Swell").foregroundColor(.white)
Text("Slow, massive rolling waves").font(.caption2).foregroundColor(.gray)
}
}
}
Button(action: applyReactiveEQ) {
HStack {
Image(systemName: "waveform").foregroundColor(.green)
VStack(alignment: .leading) {
Text("Reactive EQ").foregroundColor(.white)
Text("Detailed, fast response").font(.caption2).foregroundColor(.gray)
}
}
}
Button(action: applySubtleAmbient) {
HStack {
Image(systemName: "moonphase.waning.crescent").foregroundColor(.purple)
VStack(alignment: .leading) {
Text("Subtle Ambient").foregroundColor(.white)
Text("Gentle, transparent shimmer").font(.caption2).foregroundColor(.gray)
}
}
}
Button(action: applyHeavyMercury) {
HStack {
Image(systemName: "drop.fill").foregroundColor(.gray)
VStack(alignment: .leading) {
Text("Heavy Liquid Mercury").foregroundColor(.white)
Text("Dense, weighty metallic flow").font(.caption2).foregroundColor(.gray)
}
}
}
} header: { Text("PRESETS") } footer: {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
Text("Presets apply to the Now Playing view. Fine-tune in Per-View Settings afterwards.")
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
Section { Button("Reset to Defaults", role: .destructive) { resetDefaults() } }
}
.navigationTitle("Visualizer")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }.foregroundColor(pink)
}
}
}
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// MARK: - Presets
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
private func applyDeepOcean() {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
settings.nowPlaying.wavePoints = 6
settings.viscosity = 0.12; settings.baseMultiplier = 22; settings.frequencyCutoff = 50
settings.npAmplitude = 0.75; settings.depthOffset = 30
settings.nowPlaying.waveSensitivity = 0.8
}
private func applyReactiveEQ() {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
settings.nowPlaying.barPoints = 18; settings.nowPlaying.style = .bar
settings.viscosity = 0.6; settings.baseMultiplier = 20; settings.frequencyCutoff = 120
settings.npAmplitude = 0.5; settings.depthOffset = 5
settings.nowPlaying.barSensitivity = 1.4
}
private func applySubtleAmbient() {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
settings.nowPlaying.wavePoints = 8; settings.nowPlaying.style = .wave
settings.viscosity = 0.18; settings.baseMultiplier = 15; settings.idleAmplitude = 0.04
settings.npAmplitude = 0.25; settings.nowPlaying.alpha = 0.3
settings.nowPlaying.waveSensitivity = 0.7
}
private func applyHeavyMercury() {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
settings.nowPlaying.wavePoints = 10; settings.nowPlaying.style = .wave
settings.viscosity = 0.12; settings.baseMultiplier = 60
settings.npAmplitude = 0.6; settings.depthOffset = 20
settings.nowPlaying.waveSensitivity = 2.5
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// MARK: - Slider helpers
func sliderRow(_ label: String, value: Binding<Int>, range: ClosedRange<Int>, step: Int) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.body)
HStack {
Slider(value: Binding(get: { Double(value.wrappedValue) }, set: { value.wrappedValue = Int($0) }),
in: Double(range.lowerBound)...Double(range.upperBound), step: Double(step)).tint(pink)
Text("\(value.wrappedValue)").foregroundColor(.gray).frame(width: 44, alignment: .trailing)
}
}
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
func sliderRowDouble(_ label: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, format: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.body)
HStack {
Slider(value: value, in: range, step: step).tint(pink)
Text(String(format: format, value.wrappedValue)).foregroundColor(.gray).frame(width: 44, alignment: .trailing)
}
}
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
private func resetDefaults() {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
settings.nowPlaying = .nowPlayingDefault
settings.miniPlayer = .miniPlayerDefault
settings.enabled = true; settings.nowPlayingEnabled = true; settings.miniPlayerEnabled = true
2026-04-10 07:00:30 -07:00
settings.fps = 60; settings.realAudioAnalysis = true; settings.dynamicGainEnabled = true
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
settings.waveStrokeThickness = 1.5; settings.barSpacing = 5; settings.barCornerRadius = 0; settings.lineThickness = 5
settings.viscosity = 0.17; settings.frequencyCutoff = 75; settings.baseMultiplier = 18.0
settings.waveOffsetTop = 0; settings.nowPlayingHeightPct = 0.50
settings.npAmplitude = 0.50; settings.npBaseLift = 130.0
2026-04-10 07:00:30 -07:00
settings.depthOffset = 14.0; settings.depthOpacity = 0.18; settings.idleAmplitude = 0.03
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
settings.miniPlayerHeight = 48.0; settings.miniOpacity = 0.5
settings.miniAmplitude = 0.7; settings.miniIdleAmplitude = 0.03
settings.miniDepthOffset = 8.0; settings.miniDepthOpacity = 0.2
}
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// MARK: - Per-View Config Settings (Now Playing & Mini Player share this)
struct ViewConfigSettingsView: View {
let title: String
@Binding var config: ViewVisualizerConfig
@Binding var amplitude: Double
@Binding var baseLift: Double
@Binding var waveOffset: Double
@Binding var depthOffset: Double
@Binding var depthOpacity: Double
@Binding var idleAmplitude:Double
@Binding var heightPct: Double
var isCompact: Bool
@ObservedObject private var settings = VisualizerSettings.shared
private let pink = Color(red: 1.0, green: 0.176, blue: 0.333)
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
var body: some View {
Form {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// Style
Section {
Picker("Style", selection: $config.style) {
ForEach(VisualizerSettings.Style.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}.pickerStyle(.segmented)
// Per-style point count independent, not shared with other style
sd("Points (\(config.style.rawValue))",
value: Binding(
get: { Double(config.numberOfPoints) },
set: { config.numberOfPoints = Int($0) }
),
range: 4...24, step: 1, format: "%.0f")
// Per-style sensitivity
sd("Sensitivity (\(config.style.rawValue))",
value: Binding(
get: { config.sensitivity },
set: { config.sensitivity = $0 }
),
range: 0.1...2.0, step: 0.1, format: "%.1f")
} header: { Text("STYLE") } footer: {
Text("Each style remembers its own point count and sensitivity independently. Switching styles never overwrites another style's values.")
}
// Color
Section {
Picker("Color", selection: $config.colorMode) {
ForEach(VisualizerSettings.ColorMode.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}.pickerStyle(.segmented)
sd("Alpha", value: $config.alpha, range: 0.1...1.0, step: 0.05, format: "%.2f")
if config.colorMode == .custom {
ColorPicker("Wave Color", selection: Binding(
get: { config.customColor },
set: { c in
if let components = UIColor(c).cgColor.components, components.count >= 3 {
config.customColorR = Double(components[0])
config.customColorG = Double(components[1])
config.customColorB = Double(components[2])
}
}
))
}
} header: { Text("COLOR") }
// Amplitude & Layout
Section {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
if !isCompact {
VStack(alignment: .leading, spacing: 4) {
Text("Screen height %").font(.body)
HStack {
Slider(value: $heightPct, in: 0.2...0.8, step: 0.05).tint(pink)
Text("\(Int(heightPct * 100))%").foregroundColor(.gray).frame(width: 52, alignment: .trailing)
}
}
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
sd("Amplitude", value: $amplitude, range: 0.1...1.0, step: 0.05, format: "%.2f")
if !isCompact {
sd("Base lift (from bottom)", value: $baseLift, range: 0...300, step: 5, format: "%.0f pt")
sd("Wave offset (top)", value: $waveOffset, range: -100...300, step: 5, format: "%.0f")
}
} header: { Text("LAYOUT & AMPLITUDE") }
// Depth & Idle
Section {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
sd("Depth offset", value: $depthOffset, range: 0...50, step: 2, format: "%.0f")
sd("Depth opacity", value: $depthOpacity, range: 0.0...0.5, step: 0.05, format: "%.2f")
sd("Idle amplitude",value: $idleAmplitude, range: 0.0...0.15, step: 0.005, format: "%.3f")
} header: { Text("DEPTH & IDLE") }
// Preview
Section {
ZStack {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
Color(white: isCompact ? 0.12 : 0.0)
MitsuhaVisualizerView(previewLevels: previewLevels, isPlaying: true,
accentColor: pink, compact: isCompact)
.frame(height: isCompact ? settings.miniPlayerHeight : 180)
.opacity(isCompact ? settings.miniOpacity : 1.0)
if isCompact {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 4).fill(Color.gray.opacity(0.3)).frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 2) {
Text("Song Title").font(.system(size: 14, weight: .medium)).foregroundColor(.white)
Text("Artist").font(.system(size: 12)).foregroundColor(.gray)
}
Spacer()
Image(systemName: "pause.fill").foregroundColor(.white)
Image(systemName: "forward.fill").foregroundColor(.white)
}.padding(.horizontal, 12)
}
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
.frame(height: isCompact ? 64 : 180)
.listRowInsets(EdgeInsets())
.listRowBackground(Color(white: isCompact ? 0.12 : 0.0))
} header: { Text("PREVIEW") }
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
private var previewLevels: [Float] {
(0..<30).map { Float(0.2 + 0.5 * sin(Float($0) * 0.4)) }
}
private func sd(_ label: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, format: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.body)
HStack {
Slider(value: value, in: range, step: step).tint(pink)
Text(String(format: format, value.wrappedValue)).foregroundColor(.gray).frame(width: 52, alignment: .trailing)
}
}
}
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
// MARK: - Legacy sub-settings views (kept for backward compat, now redirect to unified view)
struct NowPlayingVisSettingsView: View {
@ObservedObject var settings: VisualizerSettings
var body: some View {
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
ViewConfigSettingsView(
title: "Now Playing",
config: $settings.nowPlaying,
amplitude: $settings.npAmplitude,
baseLift: $settings.npBaseLift,
waveOffset: $settings.waveOffsetTop,
depthOffset: $settings.depthOffset,
depthOpacity: $settings.depthOpacity,
idleAmplitude: $settings.idleAmplitude,
heightPct: $settings.nowPlayingHeightPct,
isCompact: false
)
}
bug fixes Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00
}
struct MiniPlayerVisSettingsView: View {
@ObservedObject var settings: VisualizerSettings
var body: some View {
ViewConfigSettingsView(
title: "Mini Player",
config: $settings.miniPlayer,
amplitude: $settings.miniAmplitude,
baseLift: .constant(0),
waveOffset: .constant(0),
depthOffset: $settings.miniDepthOffset,
depthOpacity: $settings.miniDepthOpacity,
idleAmplitude: $settings.miniIdleAmplitude,
heightPct: .constant(0),
isCompact: true
)
}
}