You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Real-time frequency spectrum visualizer rendered as an overlay bar graph, driven by the currently-playing application's audio output via a CoreAudio per-process tap.
Audio Source Options
Source
macOS floor
TCC prompt
Background daemon
Notes
CoreAudio per-process tap (CATapDescription + AudioHardwareCreateProcessTap)
LaunchAgent (gui/$UID) CAN present prompt; LaunchDaemon (session 0) cannot
Recommended — captures only the target app; no system-wide bleed
AVAudioEngine tap on default output
10.15+
kTCCServiceMicrophone (misleading)
Same LaunchAgent caveat
Captures all system audio; no per-app scoping
ScreenCaptureKit audio
12.3+
kTCCServiceScreenCapture
Same
Heavier API, intended for screen recording
Recommendation: CoreAudio per-process tap. Scope-correct, lightest API, no video overhead. Min-OS impact: hosts on macOS 14.0–14.1 get enabled = false no-op; implementer must choose between 14.2 (SDK floor) and 14.4 (conservative empirical floor) for the @available gate.
Prerequisites and Blockers
BLOCKER — Info.plist / codesign gap
lyra is ad-hoc signed with Info.plist not embedded in the binary. TCC grants kTCCServiceSystemAudioCapture to the executable path, but also requires NSAudioCaptureUsageDescription in a bound Info.plist. Without an embedded bundle identity, TCC denies the tap silently and every Homebrew reinstall resets the grant path.
Required before any tap code ships:
Add Info.plist with CFBundleIdentifier + NSAudioCaptureUsageDescription to the CLI target in Package.swift
Add a Makefile post-install codesign step that re-signs with the embedded plist: codesign --force --sign - --entitlements ... --identifier com.generald.lyra /usr/local/bin/lyra
Per-process tap correctness gaps
Three tap correctness issues require explicit design decisions before implementation:
No active audio — kAudioHardwareBadObjectError: AudioHardwareCreateProcessTap fails when the target process has no active audio output (e.g., music paused). Tap creation must gate on playbackRate > 0; on pause, destroy the tap and post zeroed bins.
Browser PID over-broad capture: MRMediaRemoteGetNowPlayingApplicationPID returns the browser PID for YouTube Music / Apple Music web. A process tap on a browser captures all browser audio, not just the music tab. Mitigation options: (a) allowlist known native apps and skip tap for browser PIDs, (b) fall back to system-wide tap when browser detected, (c) document as known limitation.
App-switch lifecycle: CATapDescription binds a fixed AudioObjectID. When the now-playing app changes (kMRMediaRemoteNowPlayingApplicationDidChangeNotification), the old tap must be destroyed and a new one created for the new PID.
PID pipeline gap
NowPlayingInfo (Entity) has no pid: Int? field. media-remote-helper.swift does not call MRMediaRemoteGetNowPlayingApplicationPID. Adding PID propagation spans five modules:
media-remote-helper.swift — add MRMediaRemoteGetNowPlayingApplicationPID call, emit "pid" in JSON
Entity/NowPlayingInfo.swift — add pid: Int?
NowPlayingRepository — pass through from DataSource
MediaRemoteDataSource — decode from JSON
TrackInteractor / WallpaperInteractor — surface to Presenters via TrackUpdate if needed
This prerequisite work (~120 lines) should be sequenced before the tap DataSource.
SpectrumStyle.placement (.underlay / .bottom / .top) adjusts z-position relative to lyrics via offset or ZStack reordering — exact mechanism is an open question.
Config Schema
TOML ([spectrum])
[spectrum]
enabled = truebar_count = 64bar_color = ["#1E3A5F", "#4A9EFF"] # ColorStyle — solid string or gradient arraybackground_color = "#00000066"# optional, solid onlybar_width_ratio = 0.7# bar width / (bar + gap), FlexibleDouble 0–1min_db = -80.0# FlexibleDoublemax_db = 0.0# FlexibleDoubledecay_rate = 0.85# FlexibleDouble, per-frame exponential decayfft_size = 1024# FlexibleDouble (decoded as Int at use site)placement = "bottom"# "bottom" | "top" | "underlay"height_ratio = 0.25# fraction of overlay height, FlexibleDouble
SwiftUI Canvas inside TimelineView — same pattern as RippleView. Zero new SPM dependencies, sufficient for 64 bars at 60 fps.
vDSP FFT (vDSP.FFT / vDSP_fft_zrip): ~15–25 µs per 1024-sample window on Apple Silicon. No new dependency; Accelerate is already available.
IOProc constraint: The CoreAudio render callback runs on a real-time audio thread. It must not allocate heap, call Swift async, or acquire locks. Use a lock-free ring buffer (e.g., C TPCircularBuffer bridged via a thin header, or Swift Atomics ManagedAtomic read/write indices on a fixed backing array) to hand off PCM frames to the interactor.
Idle suspension: When playbackRate == 0 or enabled == false, the tap is destroyed (not muted) and SpectrumPresenter.isAnimating = false pauses TimelineView — zero GPU work. OverlayContentView conditionally includes SpectrumView (not .opacity(0)) to avoid idle Canvas redraws per the bug: パフォーマンス低下と電力消費・発熱・ファン回転の悪化 #252 pattern.
Bin count: 64 bars map to FFT output linearly for simplicity; logarithmic mel-scale mapping is a future enhancement.
Exponential decay: Each frame applies bins[i] *= decayRate before writing new FFT magnitudes, giving a smooth falloff without storing history.
Open Questions
@available floor — 14.2 or 14.4? SDK annotates AudioHardwareCreateProcessTap as API_AVAILABLE(macos(14.2)); insidegui/AudioCap reports reliable behaviour only from 14.4. Which floor does this project target for the live implementation?
Info.plist ownership: Should Info.plist be added to the CLI Swift Package target (via Package.swiftinfoPlist: or a Resources bundle), or is it an install-time responsibility managed entirely by the Makefile/formula? Affects signing strategy.
Browser-PID mitigation: For YouTube Music / Apple Music web users (browser PID scenario), which strategy: (a) skip tap + show zeroed bars, (b) fall back to system-wide tap, or (c) document as unsupported?
Lock-free ring buffer strategy: Prefer (a) bridged C TPCircularBuffer (proven in audio apps, adds a C file), (b) Swift Atomics SPM dependency + fixed-size array, or (c) nonisolated(unsafe) + os_unfair_lock (simpler, technically blocks but contention is near-zero)?
PID pipeline sequencing: Should the NowPlayingInfo.pid prerequisite ship in a separate PR first, or be bundled with the initial spectrum DataSource PR?
Scope Estimate
Area
Lines
PID pipeline prerequisite (5 modules)
~120
AudioTapDataSource (IOProc + ring buffer + @available stub)
Real-time frequency spectrum visualizer rendered as an overlay bar graph, driven by the currently-playing application's audio output via a CoreAudio per-process tap.
Audio Source Options
CATapDescription+AudioHardwareCreateProcessTap)kTCCServiceSystemAudioCapture— yes, one-timekTCCServiceMicrophone(misleading)kTCCServiceScreenCaptureRecommendation: CoreAudio per-process tap. Scope-correct, lightest API, no video overhead. Min-OS impact: hosts on macOS 14.0–14.1 get
enabled = falseno-op; implementer must choose between 14.2 (SDK floor) and 14.4 (conservative empirical floor) for the@availablegate.Prerequisites and Blockers
BLOCKER — Info.plist / codesign gap
lyra is ad-hoc signed with
Info.plistnot embedded in the binary. TCC grantskTCCServiceSystemAudioCaptureto the executable path, but also requiresNSAudioCaptureUsageDescriptionin a boundInfo.plist. Without an embedded bundle identity, TCC denies the tap silently and every Homebrew reinstall resets the grant path.Required before any tap code ships:
Info.plistwithCFBundleIdentifier+NSAudioCaptureUsageDescriptionto the CLI target inPackage.swiftcodesignstep that re-signs with the embedded plist:codesign --force --sign - --entitlements ... --identifier com.generald.lyra /usr/local/bin/lyraPer-process tap correctness gaps
Three tap correctness issues require explicit design decisions before implementation:
No active audio —
kAudioHardwareBadObjectError:AudioHardwareCreateProcessTapfails when the target process has no active audio output (e.g., music paused). Tap creation must gate onplaybackRate > 0; on pause, destroy the tap and post zeroed bins.Browser PID over-broad capture:
MRMediaRemoteGetNowPlayingApplicationPIDreturns the browser PID for YouTube Music / Apple Music web. A process tap on a browser captures all browser audio, not just the music tab. Mitigation options: (a) allowlist known native apps and skip tap for browser PIDs, (b) fall back to system-wide tap when browser detected, (c) document as known limitation.App-switch lifecycle:
CATapDescriptionbinds a fixedAudioObjectID. When the now-playing app changes (kMRMediaRemoteNowPlayingApplicationDidChangeNotification), the old tap must be destroyed and a new one created for the new PID.PID pipeline gap
NowPlayingInfo(Entity) has nopid: Int?field.media-remote-helper.swiftdoes not callMRMediaRemoteGetNowPlayingApplicationPID. Adding PID propagation spans five modules:media-remote-helper.swift— addMRMediaRemoteGetNowPlayingApplicationPIDcall, emit"pid"in JSONEntity/NowPlayingInfo.swift— addpid: Int?NowPlayingRepository— pass through from DataSourceMediaRemoteDataSource— decode from JSONTrackInteractor/WallpaperInteractor— surface to Presenters viaTrackUpdateif neededThis prerequisite work (~120 lines) should be sequenced before the tap DataSource.
VIPER Component Plan
New files
Sources/Entity/Config/SpectrumConfig.swiftSpectrumConfig(all-optional Codable,FlexibleDouble,ColorStyle) +SpectrumStyle(all non-optional)Sources/Domain/DataSource/AudioTapDataSource.swiftAudioTapDataSourceprotocol +TestDependencyKeySources/Domain/Interactor/SpectrumInteractor.swiftSpectrumInteractorprotocol +TestDependencyKeySources/AudioTapDataSource/AudioTapDataSourceImpl.swiftCATapDescription+AudioHardwareCreateProcessTap; IOProc → lock-free ring buffer;@available(macOS 14.2, *)gatedSources/SpectrumInteractor/SpectrumInteractorImpl.swiftvDSP.FFT, publishes[Float]bin arraySources/Presenters/Spectrum/SpectrumPresenter.swift@MainActor ObservableObject;@Published var bins: [Float];@Published var isAnimating: Bool;isEnabledcomputed from configSources/Views/Spectrum/SpectrumView.swiftTimelineView(.animation(paused: !presenter.isAnimating))+Canvas; bar graph renderingSources/DependencyInjection/DataSourceRegistration+Spectrum.swiftliveValue = AudioTapDataSourceImpl()Sources/DependencyInjection/InteractorRegistration+Spectrum.swiftliveValue = SpectrumInteractorImpl()Modified files
Sources/Entity/Config/AppConfig.swiftlet spectrum: SpectrumConfig(non-optional, likeripple)Sources/Entity/Style/AppStyle.swiftlet spectrum: SpectrumStyle(non-optional)Sources/ConfigRepository/ConfigRepositoryImpl.swiftSpectrumConfig → SpectrumStyleSources/Views/Overlay/OverlayContentView.swiftSpectrumViewconditionally:if spectrumPresenter.isEnabled(NOT.opacity(0))Sources/AppRouter/AppRouter.swiftprivate var spectrumPresenter: SpectrumPresenter?; wire intowindowFactoryclosure; callspectrumPresenter?.tick()inonFramePackage.swiftAudioTapDataSourceandSpectrumInteractortargets + test targetsOverlayContentViewZStack layer order after changeSpectrumStyle.placement(.underlay/.bottom/.top) adjusts z-position relative to lyrics via offset or ZStack reordering — exact mechanism is an open question.Config Schema
TOML (
[spectrum])Swift Entity shape
Rendering & Performance Approach
CanvasinsideTimelineView— same pattern asRippleView. Zero new SPM dependencies, sufficient for 64 bars at 60 fps.vDSP.FFT/vDSP_fft_zrip): ~15–25 µs per 1024-sample window on Apple Silicon. No new dependency;Accelerateis already available.TPCircularBufferbridged via a thin header, or Swift AtomicsManagedAtomicread/write indices on a fixed backing array) to hand off PCM frames to the interactor.playbackRate == 0orenabled == false, the tap is destroyed (not muted) andSpectrumPresenter.isAnimating = falsepausesTimelineView— zero GPU work.OverlayContentViewconditionally includesSpectrumView(not.opacity(0)) to avoid idle Canvas redraws per the bug: パフォーマンス低下と電力消費・発熱・ファン回転の悪化 #252 pattern.bins[i] *= decayRatebefore writing new FFT magnitudes, giving a smooth falloff without storing history.Open Questions
@availablefloor — 14.2 or 14.4? SDK annotatesAudioHardwareCreateProcessTapasAPI_AVAILABLE(macos(14.2)); insidegui/AudioCap reports reliable behaviour only from 14.4. Which floor does this project target for the live implementation?Info.plist ownership: Should
Info.plistbe added to theCLISwift Package target (viaPackage.swiftinfoPlist:or a Resources bundle), or is it an install-time responsibility managed entirely by the Makefile/formula? Affects signing strategy.Browser-PID mitigation: For YouTube Music / Apple Music web users (browser PID scenario), which strategy: (a) skip tap + show zeroed bars, (b) fall back to system-wide tap, or (c) document as unsupported?
Lock-free ring buffer strategy: Prefer (a) bridged C
TPCircularBuffer(proven in audio apps, adds a C file), (b) Swift Atomics SPM dependency + fixed-size array, or (c)nonisolated(unsafe)+os_unfair_lock(simpler, technically blocks but contention is near-zero)?PID pipeline sequencing: Should the
NowPlayingInfo.pidprerequisite ship in a separate PR first, or be bundled with the initial spectrum DataSource PR?Scope Estimate
AudioTapDataSource(IOProc + ring buffer +@availablestub)SpectrumInteractor(vDSP FFT + bin decay)SpectrumPresenterSpectrumView(Canvas bar graph)SpectrumConfig,SpectrumStyle,SpectrumPlacement)AppRouter+OverlayContentVieweditsPackage.swift+AppConfig/AppStyleedits