From 5ea18a8df41365ae56bf9affc184c164cdf4ae9f Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 3 Jul 2026 19:37:16 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat(#23):=20CoreAudio=20process=20tap=20?= =?UTF-8?q?=E9=A7=86=E5=8B=95=E3=81=AE=E3=82=B9=E3=83=9A=E3=82=AF=E3=83=88?= =?UTF-8?q?=E3=83=A9=E3=83=A0=E3=82=A2=E3=83=8A=E3=83=A9=E3=82=A4=E3=82=B6?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TrackInteractor.audioSource: shared NowPlaying ストリームから pid/再生状態を派生 - AudioTapDataSource: ProcessTapEngine (macOS 14.4+ API, availability-erased) + swift-atomics SPSC リングバッファ (IOProc は RT-safe) - FrequencyAnalyzer: Hann 窓 → vDSP FFT → dB 正規化 → bar 変換 (純粋・依存なし) - SpectrumInteractor: audioSource を AsyncStream 直列キューで消費しタップ生成/破棄を逐次適用 - SpectrumPresenter: DisplayLink tick で指数減衰マージ、binHeights() は読み取り専用 - SpectrumView: #252/#258 パターン (条件付き include + TimelineView paused) でアイドルコストゼロ - [spectrum] 設定 (bar_count/bar_color/placement/decay_rate 等) と DI 配線 - 排他は NSLock でなく OSAllocatedUnfairLock(state:) を採用 - Info.plist に NSAudioCaptureUsageDescription を追加 --- Package.resolved | 11 +- Package.swift | 43 +++++ .../AppRouter/AppDependencyBootstrap.swift | 2 + Sources/AppRouter/AppRouter.swift | 45 +++-- .../AudioTapDataSourceImpl.swift | 47 ++++++ .../AudioTapDataSource/ProcessTapEngine.swift | 154 ++++++++++++++++++ .../AudioTapDataSource/SampleRingBuffer.swift | 55 +++++++ Sources/CLI/Info.plist | 2 + .../ConfigRepositoryImpl.swift | 13 ++ .../DataSourceRegistration.swift | 5 + .../InteractorRegistration.swift | 5 + .../DataSource/AudioTapDataSource.swift | 32 ++++ .../Interactor/SpectrumInteractor.swift | 38 +++++ .../Domain/Interactor/TrackInteractor.swift | 4 + Sources/Entity/AudioSourceState.swift | 17 ++ Sources/Entity/Config/AppConfig.swift | 7 +- Sources/Entity/Config/SpectrumConfig.swift | 68 ++++++++ Sources/Entity/Style/AppStyle.swift | 3 + Sources/Entity/Style/SpectrumPlacement.swift | 13 ++ Sources/Entity/Style/SpectrumStyle.swift | 47 ++++++ .../FrequencyAnalyzer/FrequencyAnalyzer.swift | 96 +++++++++++ .../Spectrum/SpectrumPresenter.swift | 76 +++++++++ .../SpectrumInteractorImpl.swift | 108 ++++++++++++ .../TrackInteractor/TrackInteractorImpl.swift | 11 ++ Sources/Views/Overlay/AppWindow.swift | 3 + .../Views/Overlay/OverlayContentView.swift | 7 + Sources/Views/Spectrum/SpectrumView.swift | 105 ++++++++++++ 27 files changed, 1001 insertions(+), 16 deletions(-) create mode 100644 Sources/AudioTapDataSource/AudioTapDataSourceImpl.swift create mode 100644 Sources/AudioTapDataSource/ProcessTapEngine.swift create mode 100644 Sources/AudioTapDataSource/SampleRingBuffer.swift create mode 100644 Sources/Domain/DataSource/AudioTapDataSource.swift create mode 100644 Sources/Domain/Interactor/SpectrumInteractor.swift create mode 100644 Sources/Entity/AudioSourceState.swift create mode 100644 Sources/Entity/Config/SpectrumConfig.swift create mode 100644 Sources/Entity/Style/SpectrumPlacement.swift create mode 100644 Sources/Entity/Style/SpectrumStyle.swift create mode 100644 Sources/FrequencyAnalyzer/FrequencyAnalyzer.swift create mode 100644 Sources/Presenters/Spectrum/SpectrumPresenter.swift create mode 100644 Sources/SpectrumInteractor/SpectrumInteractorImpl.swift create mode 100644 Sources/Views/Spectrum/SpectrumView.swift diff --git a/Package.resolved b/Package.resolved index ffafe758..84e70b09 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b7c3cd67fc1fb72f30e824878e47351c7918c8cc7c20ce59e93fc8f6f59d08f2", + "originHash" : "1bcb7e50f4416070b327bdc195cc0030f7e4b543cbd3536f84a551794a5524b9", "pins" : [ { "identity" : "alamofire", @@ -55,6 +55,15 @@ "version" : "1.7.0" } }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 99a3f611..b8bc6720 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,7 @@ let package = Package( .package(url: "https://github.com/LebJe/TOMLKit", from: "0.6.0"), .package(url: "https://github.com/joshuawright11/papyrus", from: "0.6.16"), .package(url: "https://github.com/JohnSundell/Files", from: "4.2.0"), + .package(url: "https://github.com/apple/swift-atomics", from: "1.2.0"), ], targets: [ // ── CLI (Entry Point) ── @@ -197,6 +198,7 @@ let package = Package( "TrackInteractor", "ScreenInteractor", "WallpaperInteractor", + "SpectrumInteractor", "ConfigUseCase", "PlaybackUseCase", "LyricsUseCase", @@ -212,6 +214,7 @@ let package = Package( "MetadataDataSource", "MediaRemoteDataSource", "WallpaperDataSource", + "AudioTapDataSource", "SQLiteDataStore", "DarwinGateway", "ProcessHandler", @@ -248,6 +251,20 @@ let package = Package( .product(name: "Dependencies", package: "swift-dependencies"), ] ), + .target( + name: "SpectrumInteractor", + dependencies: [ + "Domain", + "FrequencyAnalyzer", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + + // ── FrequencyAnalyzer ── + .target( + name: "FrequencyAnalyzer", + dependencies: [] + ), // ── UseCase ── .target( @@ -363,6 +380,13 @@ let package = Package( .product(name: "Files", package: "Files"), ] ), + .target( + name: "AudioTapDataSource", + dependencies: [ + "Domain", + .product(name: "Atomics", package: "swift-atomics"), + ] + ), // ── DataStore ── .target( @@ -423,6 +447,25 @@ let package = Package( .product(name: "Dependencies", package: "swift-dependencies"), ] ), + .testTarget( + name: "SpectrumInteractorTests", + dependencies: [ + "SpectrumInteractor", + "Domain", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .testTarget( + name: "FrequencyAnalyzerTests", + dependencies: ["FrequencyAnalyzer"] + ), + .testTarget( + name: "AudioTapDataSourceTests", + dependencies: [ + "AudioTapDataSource", + "Domain", + ] + ), .testTarget(name: "EntityTests", dependencies: ["Entity"]), .testTarget( name: "AsyncRunnableCommandTests", diff --git a/Sources/AppRouter/AppDependencyBootstrap.swift b/Sources/AppRouter/AppDependencyBootstrap.swift index 822ac9ef..c0397733 100644 --- a/Sources/AppRouter/AppDependencyBootstrap.swift +++ b/Sources/AppRouter/AppDependencyBootstrap.swift @@ -70,6 +70,7 @@ import Foundation let trackChange: AnyPublisher let artwork: AnyPublisher let playbackPosition: AnyPublisher + let audioSource: AnyPublisher let decodeEffectConfig: DecodeEffect let textLayout: TextLayout let artworkStyle: ArtworkStyle @@ -79,6 +80,7 @@ import Foundation trackChange = Just(fixture.trackUpdate).eraseToAnyPublisher() artwork = Just(nil).eraseToAnyPublisher() playbackPosition = Just(PlaybackPosition(rawElapsed: nil, timestamp: nil, playbackRate: 0)).eraseToAnyPublisher() + audioSource = Just(AudioSourceState(pid: nil, isPlaying: false)).eraseToAnyPublisher() decodeEffectConfig = decodeEffect textLayout = TextLayout(decodeEffect: decodeEffect) artworkStyle = ArtworkStyle(opacity: 0) diff --git a/Sources/AppRouter/AppRouter.swift b/Sources/AppRouter/AppRouter.swift index 5f859739..bb17940a 100644 --- a/Sources/AppRouter/AppRouter.swift +++ b/Sources/AppRouter/AppRouter.swift @@ -8,13 +8,16 @@ import Views @MainActor public final class AppRouter { private let bootstrap: AppDependencyBootstrap - private let windowFactory: @MainActor (ScreenLayout, HeaderPresenter, LyricsPresenter, RipplePresenter, WallpaperPresenter) -> any OverlayWindow + private let windowFactory: + @MainActor (ScreenLayout, HeaderPresenter, LyricsPresenter, RipplePresenter, SpectrumPresenter, WallpaperPresenter) + -> any OverlayWindow private let frameSchedulerFactory: @MainActor (@escaping @MainActor () -> Void) -> any FrameScheduler private var appPresenter: AppPresenter? private var headerPresenter: HeaderPresenter? private var lyricsPresenter: LyricsPresenter? private var wallpaperPresenter: WallpaperPresenter? private var ripplePresenter: RipplePresenter? + private var spectrumPresenter: SpectrumPresenter? private var appWindow: (any OverlayWindow)? private var frameScheduler: (any FrameScheduler)? @@ -28,12 +31,13 @@ public final class AppRouter { public convenience init(launchEnvironment: AppLaunchEnvironment = .current) { self.init( bootstrap: AppDependencyBootstrap(launchEnvironment: launchEnvironment), - windowFactory: { layout, headerPresenter, lyricsPresenter, ripplePresenter, wallpaperPresenter in + windowFactory: { layout, headerPresenter, lyricsPresenter, ripplePresenter, spectrumPresenter, wallpaperPresenter in AppWindow( initialLayout: layout, headerPresenter: headerPresenter, lyricsPresenter: lyricsPresenter, ripplePresenter: ripplePresenter, + spectrumPresenter: spectrumPresenter, wallpaperPresenter: wallpaperPresenter ) }, @@ -44,7 +48,9 @@ public final class AppRouter { convenience init( launchEnvironment: AppLaunchEnvironment, windowFactory: - @escaping @MainActor (ScreenLayout, HeaderPresenter, LyricsPresenter, RipplePresenter, WallpaperPresenter) -> any OverlayWindow, + @escaping @MainActor ( + ScreenLayout, HeaderPresenter, LyricsPresenter, RipplePresenter, SpectrumPresenter, WallpaperPresenter + ) -> any OverlayWindow, frameSchedulerFactory: @escaping @MainActor (@escaping @MainActor () -> Void) -> any FrameScheduler ) { self.init( @@ -57,7 +63,9 @@ public final class AppRouter { init( bootstrap: AppDependencyBootstrap, windowFactory: - @escaping @MainActor (ScreenLayout, HeaderPresenter, LyricsPresenter, RipplePresenter, WallpaperPresenter) -> any OverlayWindow, + @escaping @MainActor ( + ScreenLayout, HeaderPresenter, LyricsPresenter, RipplePresenter, SpectrumPresenter, WallpaperPresenter + ) -> any OverlayWindow, frameSchedulerFactory: @escaping @MainActor (@escaping @MainActor () -> Void) -> any FrameScheduler ) { self.bootstrap = bootstrap @@ -83,13 +91,17 @@ public final class AppRouter { let ripplePresenter = RipplePresenter( screenRect: CGRect(origin: layout.screenOrigin, size: layout.hostingFrame.size)) self.ripplePresenter = ripplePresenter + let spectrumPresenter = SpectrumPresenter() + self.spectrumPresenter = spectrumPresenter headerPresenter.start() lyricsPresenter.start() ripplePresenter.start() + spectrumPresenter.start() wallpaperPresenter.start() - let window = windowFactory(layout, headerPresenter, lyricsPresenter, ripplePresenter, wallpaperPresenter) + let window = windowFactory( + layout, headerPresenter, lyricsPresenter, ripplePresenter, spectrumPresenter, wallpaperPresenter) appWindow = window window.show() @@ -105,15 +117,19 @@ public final class AppRouter { window?.applyWallpaperScale(scale) } - let onFrame: @MainActor @Sendable () -> Void = + // Only enabled features pay a per-frame cost: each optional + // handler is included in the frame fan-out only when its feature + // is on, so a disabled ripple/spectrum adds zero work per tick. + let frameHandlers: [@MainActor @Sendable () -> Void] = [ ripplePresenter.isEnabled - ? { @MainActor @Sendable [weak self] in - self?.ripplePresenter?.idle() - self?.lyricsPresenter?.updateActiveLineTick() - } - : { @MainActor @Sendable [weak self] in - self?.lyricsPresenter?.updateActiveLineTick() - } + ? { @MainActor @Sendable [weak self] in self?.ripplePresenter?.idle() } : nil, + spectrumPresenter.isEnabled + ? { @MainActor @Sendable [weak self] in self?.spectrumPresenter?.tick() } : nil, + { @MainActor @Sendable [weak self] in self?.lyricsPresenter?.updateActiveLineTick() }, + ].compactMap { $0 } + let onFrame: @MainActor @Sendable () -> Void = { + for handler in frameHandlers { handler() } + } let scheduler = frameSchedulerFactory(onFrame) self.frameScheduler = scheduler scheduler.start(in: window) @@ -136,6 +152,9 @@ public final class AppRouter { ripplePresenter?.stop() defer { ripplePresenter = nil } + spectrumPresenter?.stop() + defer { spectrumPresenter = nil } + frameScheduler?.stop() defer { frameScheduler = nil } diff --git a/Sources/AudioTapDataSource/AudioTapDataSourceImpl.swift b/Sources/AudioTapDataSource/AudioTapDataSourceImpl.swift new file mode 100644 index 00000000..b85d150b --- /dev/null +++ b/Sources/AudioTapDataSource/AudioTapDataSourceImpl.swift @@ -0,0 +1,47 @@ +import Domain +import Foundation +import os + +/// Live `AudioTapDataSource` backed by a CoreAudio process tap (#23). +/// +/// Hosts below macOS 14.4 — the floor where `AudioHardwareCreateProcessTap` +/// behaves reliably (SDK declares 14.2, but real-world captures are unstable +/// before 14.4) — degrade to a permanent no-op: `startTap` returns `false` and +/// the spectrum overlay simply never animates. +public final class AudioTapDataSourceImpl: Sendable { + /// Large enough for the biggest supported FFT window (4096) with slack for + /// several IOProc cycles of overwrite headroom. + private static let ringCapacity = 16384 + + private let ring = SampleRingBuffer(capacity: ringCapacity) + /// The live `ProcessTapEngine`, held as `AnyObject` because its type is + /// `@available(macOS 14.4, *)` while this class must exist on macOS 14.0. + private let engine = OSAllocatedUnfairLock(uncheckedState: nil) + + public init() {} +} + +extension AudioTapDataSourceImpl: AudioTapDataSource { + public func startTap(pid: Int) async -> Bool { + guard #available(macOS 14.4, *) else { return false } + return engine.withLockUnchecked { current in + (current as? ProcessTapEngine)?.stop() + current = ProcessTapEngine(pid: pid, ring: ring) + return current != nil + } + } + + public func stopTap() async { + engine.withLockUnchecked { current in + if #available(macOS 14.4, *) { + (current as? ProcessTapEngine)?.stop() + } + current = nil + } + } + + public func latestSamples(count: Int) -> [Float] { + guard engine.withLockUnchecked({ $0 != nil }) else { return [] } + return ring.latest(count) + } +} diff --git a/Sources/AudioTapDataSource/ProcessTapEngine.swift b/Sources/AudioTapDataSource/ProcessTapEngine.swift new file mode 100644 index 00000000..f480f2b9 --- /dev/null +++ b/Sources/AudioTapDataSource/ProcessTapEngine.swift @@ -0,0 +1,154 @@ +import CoreAudio +import Foundation + +/// Owns one CoreAudio process-tap capture chain: process tap → private +/// aggregate device → IOProc writing a mono mixdown into the ring buffer. +/// +/// Construction performs the whole CoreAudio setup and fails (`nil`) on any +/// error — unknown pid, TCC denial, or a tap/aggregate/IOProc failure — after +/// rolling back whatever partial state was created. `stop()` is idempotent and +/// also runs on deinit, so a dropped engine never leaks CoreAudio objects. +@available(macOS 14.4, *) +final class ProcessTapEngine { + private static let scratchCapacity = 4096 + + private let ring: SampleRingBuffer + private var tapID = AudioObjectID(kAudioObjectUnknown) + private var aggregateID = AudioObjectID(kAudioObjectUnknown) + private var ioProcID: AudioDeviceIOProcID? + private var stopped = false + + init?(pid: Int, ring: SampleRingBuffer) { + self.ring = ring + + guard let processObject = Self.processObject(for: pid) else { return nil } + + // The tap is private (invisible in Audio MIDI Setup) and keeps the + // tapped app audible — the analyzer observes, never mutes. + let description = CATapDescription(stereoMixdownOfProcesses: [processObject]) + description.isPrivate = true + description.muteBehavior = .unmuted + guard AudioHardwareCreateProcessTap(description, &tapID) == noErr, + tapID != AudioObjectID(kAudioObjectUnknown) + else { return nil } + + // A tap only produces audio when read through an aggregate device that + // lists it. The aggregate is private and auto-starts the tap. + let aggregateDescription: [String: Any] = [ + kAudioAggregateDeviceNameKey: "lyra-spectrum-tap", + kAudioAggregateDeviceUIDKey: UUID().uuidString, + kAudioAggregateDeviceIsPrivateKey: true, + kAudioAggregateDeviceTapAutoStartKey: true, + kAudioAggregateDeviceTapListKey: [ + [ + kAudioSubTapUIDKey: description.uuid.uuidString, + kAudioSubTapDriftCompensationKey: true, + ] + ], + ] + guard + AudioHardwareCreateAggregateDevice(aggregateDescription as CFDictionary, &aggregateID) + == noErr, + aggregateID != AudioObjectID(kAudioObjectUnknown) + else { + rollBack() + return nil + } + + // The IO block runs on a real-time audio thread: no allocation, no + // locks, no Swift concurrency. It mixes the first buffer down to mono + // into a preallocated scratch and hands it to the wait-free ring. + // `ring` and `scratch` are captured directly (not `self`) so CoreAudio + // holding the block never retain-cycles the engine. + let scratch = UnsafeMutablePointer.allocate(capacity: Self.scratchCapacity) + scratch.initialize(repeating: 0, count: Self.scratchCapacity) + self.scratch = scratch + let capturedRing = ring + let status = AudioDeviceCreateIOProcIDWithBlock(&ioProcID, aggregateID, nil) { + _, inInputData, _, _, _ in + Self.mixDownToMono(inInputData, into: scratch, ring: capturedRing) + } + guard status == noErr, let ioProcID, AudioDeviceStart(aggregateID, ioProcID) == noErr + else { + rollBack() + return nil + } + } + + private var scratch: UnsafeMutablePointer? + + deinit { + stop() + scratch?.deallocate() + } + + /// Tears the capture chain down in reverse order of construction. + /// Idempotent — safe to call from both the owner and deinit. + func stop() { + guard !stopped else { return } + stopped = true + rollBack() + } + + private func rollBack() { + if let ioProcID, aggregateID != AudioObjectID(kAudioObjectUnknown) { + AudioDeviceStop(aggregateID, ioProcID) + AudioDeviceDestroyIOProcID(aggregateID, ioProcID) + } + ioProcID = nil + if aggregateID != AudioObjectID(kAudioObjectUnknown) { + AudioHardwareDestroyAggregateDevice(aggregateID) + aggregateID = AudioObjectID(kAudioObjectUnknown) + } + if tapID != AudioObjectID(kAudioObjectUnknown) { + AudioHardwareDestroyProcessTap(tapID) + tapID = AudioObjectID(kAudioObjectUnknown) + } + } + + /// Translates a pid into the CoreAudio process object required by + /// `CATapDescription`. Returns `nil` for processes CoreAudio doesn't know + /// (never launched audio, or no such pid). + private static func processObject(for pid: Int) -> AudioObjectID? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyTranslatePIDToProcessObject, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var processPid = pid_t(pid) + var processObject = AudioObjectID(kAudioObjectUnknown) + var size = UInt32(MemoryLayout.size) + let status = withUnsafeMutablePointer(to: &processPid) { pidPointer in + AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &address, + UInt32(MemoryLayout.size), pidPointer, + &size, &processObject + ) + } + guard status == noErr, processObject != AudioObjectID(kAudioObjectUnknown) else { + return nil + } + return processObject + } + + /// Real-time-safe mono mixdown of the first (interleaved) input buffer. + private static func mixDownToMono( + _ inputData: UnsafePointer, + into scratch: UnsafeMutablePointer, + ring: SampleRingBuffer + ) { + let buffers = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: inputData)) + guard let first = buffers.first, let data = first.mData else { return } + let channels = max(Int(first.mNumberChannels), 1) + let sampleCount = Int(first.mDataByteSize) / MemoryLayout.size + let frames = min(sampleCount / channels, scratchCapacity) + guard frames > 0 else { return } + let samples = data.assumingMemoryBound(to: Float.self) + for frame in 0.. + private let writeIndex = ManagedAtomic(0) + + /// - Parameter capacity: rounded up to the next power of two. + public init(capacity: Int) { + let rounded = Self.nextPowerOfTwo(max(capacity, 2)) + self.capacity = rounded + self.mask = rounded - 1 + self.storage = .allocate(capacity: rounded) + storage.initialize(repeating: 0, count: rounded) + } + + deinit { + storage.deallocate() + } + + /// Producer side (real-time thread): copies `count` samples in and then + /// publishes them with a single releasing store. No allocation, no locks. + public func write(_ samples: UnsafePointer, count: Int) { + guard count > 0 else { return } + let start = writeIndex.load(ordering: .relaxed) + for offset in 0.. [Float] { + guard count > 0, count <= capacity else { return [] } + let end = writeIndex.load(ordering: .acquiring) + guard end >= count else { return [] } + return ((end - count).. Int { + value <= 1 ? 1 : 1 << (Int.bitWidth - (value - 1).leadingZeroBitCount) + } +} diff --git a/Sources/CLI/Info.plist b/Sources/CLI/Info.plist index 70c29e90..289c76dd 100644 --- a/Sources/CLI/Info.plist +++ b/Sources/CLI/Info.plist @@ -10,5 +10,7 @@ lyra CFBundleInfoDictionaryVersion 6.0 + NSAudioCaptureUsageDescription + lyra taps the now-playing app's audio output to render the spectrum analyzer overlay. diff --git a/Sources/ConfigRepository/ConfigRepositoryImpl.swift b/Sources/ConfigRepository/ConfigRepositoryImpl.swift index 79a57076..a49b9974 100644 --- a/Sources/ConfigRepository/ConfigRepositoryImpl.swift +++ b/Sources/ConfigRepository/ConfigRepositoryImpl.swift @@ -35,6 +35,19 @@ extension ConfigRepositoryImpl: ConfigRepository { idle: config.ripple.idle.value, shape: config.ripple.shape ), + spectrum: SpectrumStyle( + enabled: config.spectrum.enabled, + barCount: Int(config.spectrum.barCount.value), + barColor: config.spectrum.barColor, + backgroundColor: config.spectrum.backgroundColor, + barWidthRatio: config.spectrum.barWidthRatio.value, + minDb: config.spectrum.minDb.value, + maxDb: config.spectrum.maxDb.value, + decayRate: config.spectrum.decayRate.value, + fftSize: Int(config.spectrum.fftSize.value), + placement: config.spectrum.placement, + heightRatio: config.spectrum.heightRatio.value + ), screen: config.screen, screenDebounce: config.screenDebounce.value, wallpaper: config.wallpaper.map { cfg in diff --git a/Sources/DependencyInjection/DataSourceRegistration.swift b/Sources/DependencyInjection/DataSourceRegistration.swift index c4132283..17062413 100644 --- a/Sources/DependencyInjection/DataSourceRegistration.swift +++ b/Sources/DependencyInjection/DataSourceRegistration.swift @@ -1,3 +1,4 @@ +import AudioTapDataSource import ConfigDataSource import Dependencies import Domain @@ -6,6 +7,10 @@ import MediaRemoteDataSource import MetadataDataSource import WallpaperDataSource +extension AudioTapDataSourceKey: DependencyKey { + public static let liveValue: any AudioTapDataSource = AudioTapDataSourceImpl() +} + extension ConfigDataSourceKey: DependencyKey { public static let liveValue: any ConfigDataSource = ConfigDataSourceImpl() } diff --git a/Sources/DependencyInjection/InteractorRegistration.swift b/Sources/DependencyInjection/InteractorRegistration.swift index 9f274504..267c7b28 100644 --- a/Sources/DependencyInjection/InteractorRegistration.swift +++ b/Sources/DependencyInjection/InteractorRegistration.swift @@ -2,6 +2,7 @@ import AppKitScreenProvider import Dependencies import Domain import ScreenInteractor +import SpectrumInteractor import TrackInteractor import WallpaperInteractor @@ -17,6 +18,10 @@ extension WallpaperInteractorKey: DependencyKey { public static let liveValue: any WallpaperInteractor = WallpaperInteractorImpl() } +extension SpectrumInteractorKey: DependencyKey { + public static let liveValue: any SpectrumInteractor = SpectrumInteractorImpl() +} + extension ScreenProviderKey: DependencyKey { public static let liveValue: any ScreenProvider = AppKitScreenProvider() } diff --git a/Sources/Domain/DataSource/AudioTapDataSource.swift b/Sources/Domain/DataSource/AudioTapDataSource.swift new file mode 100644 index 00000000..b8c181e8 --- /dev/null +++ b/Sources/Domain/DataSource/AudioTapDataSource.swift @@ -0,0 +1,32 @@ +import Dependencies + +/// Captures one process's audio output through a CoreAudio process tap and +/// exposes the newest PCM window for spectrum analysis (#23). +public protocol AudioTapDataSource: Sendable { + /// Starts capturing the process's audio output, replacing any active tap. + /// Returns `false` when tapping is unavailable — host below the macOS 14.4 + /// floor, TCC permission denied, unknown process, or a CoreAudio error. + func startTap(pid: Int) async -> Bool + /// Tears down the active tap, if any. Safe to call repeatedly. + func stopTap() async + /// The newest `count` captured mono samples, oldest first. Empty while no + /// tap is active or before the capture buffer has filled once. + func latestSamples(count: Int) -> [Float] +} + +public enum AudioTapDataSourceKey: TestDependencyKey { + public static let testValue: any AudioTapDataSource = UnimplementedAudioTapDataSource() +} + +extension DependencyValues { + public var audioTapDataSource: any AudioTapDataSource { + get { self[AudioTapDataSourceKey.self] } + set { self[AudioTapDataSourceKey.self] = newValue } + } +} + +private struct UnimplementedAudioTapDataSource: AudioTapDataSource { + func startTap(pid: Int) async -> Bool { false } + func stopTap() async {} + func latestSamples(count: Int) -> [Float] { [] } +} diff --git a/Sources/Domain/Interactor/SpectrumInteractor.swift b/Sources/Domain/Interactor/SpectrumInteractor.swift new file mode 100644 index 00000000..f06f3082 --- /dev/null +++ b/Sources/Domain/Interactor/SpectrumInteractor.swift @@ -0,0 +1,38 @@ +import Combine +import Dependencies + +/// Drives the spectrum analyzer overlay (#23): follows the now-playing audio +/// source, manages the process tap lifecycle, and converts captured PCM into +/// per-bar magnitudes. +public protocol SpectrumInteractor: Sendable { + var spectrumStyle: SpectrumStyle { get } + /// Emits whether the process tap is actively capturing audio. The + /// Presenter maps this to its animation state. + var isCapturing: AnyPublisher { get } + /// Begins observing the now-playing audio source and managing the tap. + func start() + /// Tears down the subscription and any active tap. + func stop() + /// Normalized magnitudes (0…1) of the newest PCM window, one per bar. + /// Empty while nothing is being captured. + func magnitudes() -> [Float] +} + +public enum SpectrumInteractorKey: TestDependencyKey { + public static let testValue: any SpectrumInteractor = UnimplementedSpectrumInteractor() +} + +extension DependencyValues { + public var spectrumInteractor: any SpectrumInteractor { + get { self[SpectrumInteractorKey.self] } + set { self[SpectrumInteractorKey.self] = newValue } + } +} + +private struct UnimplementedSpectrumInteractor: SpectrumInteractor { + var spectrumStyle: SpectrumStyle { .init() } + var isCapturing: AnyPublisher { Empty().eraseToAnyPublisher() } + func start() {} + func stop() {} + func magnitudes() -> [Float] { [] } +} diff --git a/Sources/Domain/Interactor/TrackInteractor.swift b/Sources/Domain/Interactor/TrackInteractor.swift index 01fd3eb3..1ebdf0f0 100644 --- a/Sources/Domain/Interactor/TrackInteractor.swift +++ b/Sources/Domain/Interactor/TrackInteractor.swift @@ -9,6 +9,9 @@ public protocol TrackInteractor: Sendable { var artwork: AnyPublisher { get } /// Emits continuously for playback position updates. var playbackPosition: AnyPublisher { get } + /// Emits when the now-playing app's process id or audibility changes. + /// The spectrum analyzer scopes its CoreAudio process tap with this (#23). + var audioSource: AnyPublisher { get } var decodeEffectConfig: DecodeEffect { get } var textLayout: TextLayout { get } var artworkStyle: ArtworkStyle { get } @@ -29,6 +32,7 @@ private struct UnimplementedTrackInteractor: TrackInteractor { var trackChange: AnyPublisher { Empty().eraseToAnyPublisher() } var artwork: AnyPublisher { Empty().eraseToAnyPublisher() } var playbackPosition: AnyPublisher { Empty().eraseToAnyPublisher() } + var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } var decodeEffectConfig: DecodeEffect { .init() } var textLayout: TextLayout { .init() } var artworkStyle: ArtworkStyle { .init() } diff --git a/Sources/Entity/AudioSourceState.swift b/Sources/Entity/AudioSourceState.swift new file mode 100644 index 00000000..6c69ad0b --- /dev/null +++ b/Sources/Entity/AudioSourceState.swift @@ -0,0 +1,17 @@ +/// Identity and audibility of the process that owns the now-playing session. +/// The spectrum analyzer uses it to scope its CoreAudio process tap to exactly +/// the audio source and to tear the tap down while playback is paused (#23). +public struct AudioSourceState { + /// Process id of the now-playing app; `nil` when no app owns the session. + public let pid: Int? + /// `true` while audio is actually advancing (`playbackRate > 0`). + public let isPlaying: Bool + + public init(pid: Int? = nil, isPlaying: Bool = false) { + self.pid = pid + self.isPlaying = isPlaying + } +} + +extension AudioSourceState: Sendable {} +extension AudioSourceState: Equatable {} diff --git a/Sources/Entity/Config/AppConfig.swift b/Sources/Entity/Config/AppConfig.swift index cfaf685f..665519af 100644 --- a/Sources/Entity/Config/AppConfig.swift +++ b/Sources/Entity/Config/AppConfig.swift @@ -4,6 +4,7 @@ public struct AppConfig { public let text: TextConfig public let artwork: ArtworkConfig public let ripple: RippleConfig + public let spectrum: SpectrumConfig public let screen: ScreenSelector public let screenDebounce: FlexibleDouble public let wallpaper: WallpaperConfig? @@ -14,12 +15,13 @@ extension AppConfig: Sendable {} extension AppConfig { public static let defaults = AppConfig( - text: .defaults, artwork: .defaults, ripple: .defaults, screen: .main, screenDebounce: 5, wallpaper: nil, ai: nil) + text: .defaults, artwork: .defaults, ripple: .defaults, spectrum: .defaults, screen: .main, screenDebounce: 5, + wallpaper: nil, ai: nil) } extension AppConfig: Codable { enum CodingKeys: String, CodingKey { - case text, artwork, ripple, screen + case text, artwork, ripple, spectrum, screen case screenDebounce = "screen_debounce" case wallpaper, ai } @@ -29,6 +31,7 @@ extension AppConfig: Codable { text = try c.decodeIfPresent(TextConfig.self, forKey: .text) ?? Self.defaults.text artwork = try c.decodeIfPresent(ArtworkConfig.self, forKey: .artwork) ?? Self.defaults.artwork ripple = try c.decodeIfPresent(RippleConfig.self, forKey: .ripple) ?? Self.defaults.ripple + spectrum = try c.decodeIfPresent(SpectrumConfig.self, forKey: .spectrum) ?? Self.defaults.spectrum screen = try c.decodeIfPresent(ScreenSelector.self, forKey: .screen) ?? Self.defaults.screen screenDebounce = try c.decodeIfPresent(FlexibleDouble.self, forKey: .screenDebounce) ?? Self.defaults.screenDebounce wallpaper = try c.decodeIfPresent(WallpaperConfig.self, forKey: .wallpaper) ?? Self.defaults.wallpaper diff --git a/Sources/Entity/Config/SpectrumConfig.swift b/Sources/Entity/Config/SpectrumConfig.swift new file mode 100644 index 00000000..32dab916 --- /dev/null +++ b/Sources/Entity/Config/SpectrumConfig.swift @@ -0,0 +1,68 @@ +/// Configuration for the spectrum analyzer overlay (#23), decoded from the +/// `[spectrum]` TOML section. Every field is optional in TOML; omitting the +/// section entirely is equivalent to `enabled = false`. +public struct SpectrumConfig { + public let enabled: Bool + public let barCount: FlexibleDouble + public let barColor: ColorStyle + public let backgroundColor: ColorConfig? + /// Bar width as a fraction of one bar slot (bar + gap), 0–1. + public let barWidthRatio: FlexibleDouble + public let minDb: FlexibleDouble + public let maxDb: FlexibleDouble + /// Per-frame exponential falloff applied to bar heights, 0–1. + public let decayRate: FlexibleDouble + public let fftSize: FlexibleDouble + public let placement: SpectrumPlacement + /// Fraction of the overlay height the bars may occupy, 0–1. + public let heightRatio: FlexibleDouble +} + +extension SpectrumConfig: Sendable {} + +extension SpectrumConfig { + static let defaults = SpectrumConfig( + enabled: false, + barCount: 64, + barColor: .gradient(["#1E3A5F", "#4A9EFF"]), + backgroundColor: nil, + barWidthRatio: 0.7, + minDb: -80, + maxDb: 0, + decayRate: 0.85, + fftSize: 1024, + placement: .bottom, + heightRatio: 0.25 + ) +} + +extension SpectrumConfig: Codable { + enum CodingKeys: String, CodingKey { + case enabled + case barCount = "bar_count" + case barColor = "bar_color" + case backgroundColor = "background_color" + case barWidthRatio = "bar_width_ratio" + case minDb = "min_db" + case maxDb = "max_db" + case decayRate = "decay_rate" + case fftSize = "fft_size" + case placement + case heightRatio = "height_ratio" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? Self.defaults.enabled + barCount = try c.decodeIfPresent(FlexibleDouble.self, forKey: .barCount) ?? Self.defaults.barCount + barColor = try c.decodeIfPresent(ColorStyle.self, forKey: .barColor) ?? Self.defaults.barColor + backgroundColor = try c.decodeIfPresent(ColorConfig.self, forKey: .backgroundColor) ?? Self.defaults.backgroundColor + barWidthRatio = try c.decodeIfPresent(FlexibleDouble.self, forKey: .barWidthRatio) ?? Self.defaults.barWidthRatio + minDb = try c.decodeIfPresent(FlexibleDouble.self, forKey: .minDb) ?? Self.defaults.minDb + maxDb = try c.decodeIfPresent(FlexibleDouble.self, forKey: .maxDb) ?? Self.defaults.maxDb + decayRate = try c.decodeIfPresent(FlexibleDouble.self, forKey: .decayRate) ?? Self.defaults.decayRate + fftSize = try c.decodeIfPresent(FlexibleDouble.self, forKey: .fftSize) ?? Self.defaults.fftSize + placement = try c.decodeIfPresent(SpectrumPlacement.self, forKey: .placement) ?? Self.defaults.placement + heightRatio = try c.decodeIfPresent(FlexibleDouble.self, forKey: .heightRatio) ?? Self.defaults.heightRatio + } +} diff --git a/Sources/Entity/Style/AppStyle.swift b/Sources/Entity/Style/AppStyle.swift index 1af5c295..0ac4d42c 100644 --- a/Sources/Entity/Style/AppStyle.swift +++ b/Sources/Entity/Style/AppStyle.swift @@ -4,6 +4,7 @@ public struct AppStyle { public let text: TextLayout public let artwork: ArtworkStyle public let ripple: RippleStyle + public let spectrum: SpectrumStyle public let screen: ScreenSelector public let screenDebounce: Double public let wallpaper: WallpaperStyle? @@ -14,6 +15,7 @@ public struct AppStyle { text: TextLayout = .init(), artwork: ArtworkStyle = .init(), ripple: RippleStyle = .init(), + spectrum: SpectrumStyle = .init(), screen: ScreenSelector = .main, screenDebounce: Double = 5, wallpaper: WallpaperStyle? = nil, @@ -23,6 +25,7 @@ public struct AppStyle { self.text = text self.artwork = artwork self.ripple = ripple + self.spectrum = spectrum self.screen = screen self.screenDebounce = screenDebounce self.wallpaper = wallpaper diff --git a/Sources/Entity/Style/SpectrumPlacement.swift b/Sources/Entity/Style/SpectrumPlacement.swift new file mode 100644 index 00000000..c3a45ba3 --- /dev/null +++ b/Sources/Entity/Style/SpectrumPlacement.swift @@ -0,0 +1,13 @@ +/// Where the spectrum analyzer bars sit inside the overlay (#23). +public enum SpectrumPlacement: String, CaseIterable { + /// Anchored to the bottom edge, occupying `heightRatio` of the overlay. + case bottom + /// Anchored to the top edge, bars growing downward. + case top + /// Bottom-anchored like `bottom`, but allowed the full overlay height so + /// the bars form a subtle backdrop behind the lyrics. + case underlay +} + +extension SpectrumPlacement: Sendable {} +extension SpectrumPlacement: Codable {} diff --git a/Sources/Entity/Style/SpectrumStyle.swift b/Sources/Entity/Style/SpectrumStyle.swift new file mode 100644 index 00000000..7fb11048 --- /dev/null +++ b/Sources/Entity/Style/SpectrumStyle.swift @@ -0,0 +1,47 @@ +/// Resolved, all-non-optional counterpart of `SpectrumConfig` (#23). +/// Produced by `ConfigRepository` and consumed by the spectrum Interactor, +/// Presenter, and View. +public struct SpectrumStyle { + public let enabled: Bool + public let barCount: Int + public let barColor: ColorStyle + public let backgroundColor: ColorConfig? + /// Bar width as a fraction of one bar slot (bar + gap), 0–1. + public let barWidthRatio: Double + public let minDb: Double + public let maxDb: Double + /// Per-frame exponential falloff applied to bar heights, 0–1. + public let decayRate: Double + public let fftSize: Int + public let placement: SpectrumPlacement + /// Fraction of the overlay height the bars may occupy, 0–1. + public let heightRatio: Double + + public init( + enabled: Bool = false, + barCount: Int = 64, + barColor: ColorStyle = .gradient(["#1E3A5F", "#4A9EFF"]), + backgroundColor: ColorConfig? = nil, + barWidthRatio: Double = 0.7, + minDb: Double = -80, + maxDb: Double = 0, + decayRate: Double = 0.85, + fftSize: Int = 1024, + placement: SpectrumPlacement = .bottom, + heightRatio: Double = 0.25 + ) { + self.enabled = enabled + self.barCount = barCount + self.barColor = barColor + self.backgroundColor = backgroundColor + self.barWidthRatio = barWidthRatio + self.minDb = minDb + self.maxDb = maxDb + self.decayRate = decayRate + self.fftSize = fftSize + self.placement = placement + self.heightRatio = heightRatio + } +} + +extension SpectrumStyle: Sendable {} diff --git a/Sources/FrequencyAnalyzer/FrequencyAnalyzer.swift b/Sources/FrequencyAnalyzer/FrequencyAnalyzer.swift new file mode 100644 index 00000000..b5722916 --- /dev/null +++ b/Sources/FrequencyAnalyzer/FrequencyAnalyzer.swift @@ -0,0 +1,96 @@ +import Accelerate + +/// Pure PCM-window → per-bar magnitude conversion for the spectrum analyzer +/// (#23): Hann window → real FFT → power spectrum → dB → 0…1 normalization → +/// linear grouping into `barCount` bars. Stateless between calls, so the same +/// input always yields the same output — smoothing (decay) is the Presenter's +/// display concern, not this module's. +public struct FrequencyAnalyzer { + private let fftSize: Int + private let barCount: Int + private let minDb: Float + private let maxDb: Float + private let fft: vDSP.FFT? + private let window: [Float] + + /// - Parameters: + /// - fftSize: window length in samples; rounded down to a power of two. + /// - barCount: number of output bars. + /// - minDb: power level mapped to bar height 0. + /// - maxDb: power level mapped to bar height 1. + public init(fftSize: Int, barCount: Int, minDb: Double, maxDb: Double) { + let clampedSize = max(64, fftSize) + let log2n = vDSP_Length(63 - UInt64(clampedSize).leadingZeroBitCount) + self.fftSize = 1 << log2n + self.barCount = max(1, barCount) + self.minDb = Float(minDb) + self.maxDb = Float(max(maxDb, minDb + 1)) + self.fft = vDSP.FFT(log2n: log2n, radix: .radix2, ofType: DSPSplitComplex.self) + self.window = vDSP.window( + ofType: Float.self, usingSequence: .hanningDenormalized, + count: 1 << log2n, isHalfWindow: false + ) + } + + /// Normalized (0…1) magnitudes of one PCM window, one value per bar. + /// Returns all-zero bars when fewer than `fftSize` samples are supplied. + public func magnitudes(of samples: [Float]) -> [Float] { + let silence = [Float](repeating: 0, count: barCount) + guard let fft, samples.count >= fftSize else { return silence } + + let windowed = vDSP.multiply(samples[(samples.count - fftSize)...], window) + let power = powerSpectrum(of: windowed, using: fft) + let range = maxDb - minDb + let normalized = power.map { value in + let db = 10 * log10(max(value, .leastNormalMagnitude)) + return min(max((db - minDb) / range, 0), 1) + } + return bars(grouping: normalized) + } + + /// Half-spectrum power values (DC bin dropped) of a windowed sample block. + private func powerSpectrum(of windowed: [Float], using fft: vDSP.FFT) -> [Float] { + let halfN = fftSize / 2 + var inputReal = [Float](repeating: 0, count: halfN) + var inputImaginary = [Float](repeating: 0, count: halfN) + var outputReal = [Float](repeating: 0, count: halfN) + var outputImaginary = [Float](repeating: 0, count: halfN) + var power = [Float](repeating: 0, count: halfN) + + inputReal.withUnsafeMutableBufferPointer { inRealPtr in + inputImaginary.withUnsafeMutableBufferPointer { inImagPtr in + outputReal.withUnsafeMutableBufferPointer { outRealPtr in + outputImaginary.withUnsafeMutableBufferPointer { outImagPtr in + var input = DSPSplitComplex( + realp: inRealPtr.baseAddress!, imagp: inImagPtr.baseAddress!) + var output = DSPSplitComplex( + realp: outRealPtr.baseAddress!, imagp: outImagPtr.baseAddress!) + windowed.withUnsafeBufferPointer { samplePtr in + samplePtr.baseAddress!.withMemoryRebound( + to: DSPComplex.self, capacity: halfN + ) { + vDSP_ctoz($0, 2, &input, 1, vDSP_Length(halfN)) + } + } + fft.forward(input: input, output: &output) + vDSP.squareMagnitudes(output, result: &power) + } + } + } + } + // Drop the DC bin — overall loudness offset, not a frequency band. + return Array(power.dropFirst()) + } + + /// Groups the half-spectrum linearly into `barCount` bars, taking each + /// group's maximum so narrow peaks stay visible. + private func bars(grouping values: [Float]) -> [Float] { + let groupSize = max(1, values.count / barCount) + return (0.. Self.silenceThreshold } + // Once the falloff finishes, drop the bins entirely so idle ticks can + // bail out on the guard above without per-frame array work. + if !animating { currentBins = [] } + setAnimating(animating) + } + + /// Bar heights (0…1) for the current frame. Read-only — safe to call from + /// inside a Canvas draw closure. + public func binHeights() -> [Float] { currentBins } + + /// Bars below this are treated as fully decayed (~1/4 pixel at 256 pt). + private static let silenceThreshold: Float = 0.001 + + private func merged(fresh: [Float], decayed: [Float], barCount: Int) -> [Float] { + guard !fresh.isEmpty || !decayed.isEmpty else { return [] } + return (0..? + var continuation: AsyncStream.Continuation? + } + + // Stored wrappers capture the dependency context at init, so instances + // built inside `withDependencies` keep their fakes when methods run + // outside that scope (sinks, the processor task). + @Dependency(\.configUseCase) private var configService + @Dependency(\.trackInteractor) private var trackInteractor + @Dependency(\.audioTapDataSource) private var tap + private let capturingSubject = CurrentValueSubject(false) + private let pipeline = OSAllocatedUnfairLock(uncheckedState: Pipeline()) + private lazy var analyzer = FrequencyAnalyzer( + fftSize: spectrumStyle.fftSize, + barCount: spectrumStyle.barCount, + minDb: spectrumStyle.minDb, + maxDb: spectrumStyle.maxDb + ) + + public init() {} +} + +extension SpectrumInteractorImpl: SpectrumInteractor { + public var spectrumStyle: SpectrumStyle { + configService.appStyle.spectrum + } + + public var isCapturing: AnyPublisher { + capturingSubject.eraseToAnyPublisher() + } + + public func start() { + guard spectrumStyle.enabled else { return } + let tap = tap + let subject = capturingSubject + // The stream is the serialization point: audio-source events queue up + // and the single processor task applies them one at a time, so a rapid + // pause→play→pause burst can never interleave tap create/destroy calls. + let (stream, continuation) = AsyncStream.makeStream() + let processor = Task { + for await source in stream { + guard let pid = source.pid, source.isPlaying else { + await tap.stopTap() + subject.send(false) + continue + } + subject.send(await tap.startTap(pid: pid)) + } + // Stream finished (stop()): tear the tap down after the last event. + guard !Task.isCancelled else { return } + await tap.stopTap() + subject.send(false) + } + let cancellable = trackInteractor.audioSource + .sink { continuation.yield($0) } + let adopted = pipeline.withLockUnchecked { state in + guard state.cancellable == nil else { return false } + state = Pipeline( + cancellable: cancellable, processor: processor, continuation: continuation) + return true + } + guard adopted else { + // Lost a start/start race: discard this pipeline without touching + // the winner's tap (the cancellation guard skips the teardown). + cancellable.cancel() + processor.cancel() + continuation.finish() + return + } + } + + public func stop() { + let stopped = pipeline.withLockUnchecked { state in + defer { state = Pipeline() } + return state + } + stopped.cancellable?.cancel() + stopped.continuation?.finish() + } + + public func magnitudes() -> [Float] { + let samples = tap.latestSamples(count: spectrumStyle.fftSize) + guard !samples.isEmpty else { return [] } + return analyzer.magnitudes(of: samples) + } +} diff --git a/Sources/TrackInteractor/TrackInteractorImpl.swift b/Sources/TrackInteractor/TrackInteractorImpl.swift index 2ec777d8..6b25835d 100644 --- a/Sources/TrackInteractor/TrackInteractorImpl.swift +++ b/Sources/TrackInteractor/TrackInteractorImpl.swift @@ -83,6 +83,17 @@ public final class TrackInteractorImpl: @unchecked Sendable { } .eraseToAnyPublisher() + /// Emits when the now-playing app's process id or audibility changes. + /// Built from the raw shared stream — not `activeNowPlaying` — so a + /// vanished session (nil payload) still tears the spectrum tap down (#23). + public lazy var audioSource: AnyPublisher = + shared + .map { np in + AudioSourceState(pid: np?.pid, isPlaying: (np?.playbackRate ?? 0) > 0) + } + .removeDuplicates() + .eraseToAnyPublisher() + public init() { @Dependency(\.playbackUseCase) var playback @Dependency(\.lyricsUseCase) var lyrics diff --git a/Sources/Views/Overlay/AppWindow.swift b/Sources/Views/Overlay/AppWindow.swift index 1aa13022..103fc2e5 100644 --- a/Sources/Views/Overlay/AppWindow.swift +++ b/Sources/Views/Overlay/AppWindow.swift @@ -45,12 +45,14 @@ public final class AppWindow: NSWindow { /// - headerPresenter: Presenter for the header view. /// - lyricsPresenter: Presenter for the lyrics view. /// - ripplePresenter: Presenter for the ripple effect. + /// - spectrumPresenter: Presenter for the spectrum analyzer bars. /// - wallpaperPresenter: Presenter for the wallpaper view. public init( initialLayout: ScreenLayout, headerPresenter: HeaderPresenter, lyricsPresenter: LyricsPresenter, ripplePresenter: RipplePresenter, + spectrumPresenter: SpectrumPresenter, wallpaperPresenter: WallpaperPresenter ) { let hostingView = NSHostingView( @@ -58,6 +60,7 @@ public final class AppWindow: NSWindow { headerPresenter: headerPresenter, lyricsPresenter: lyricsPresenter, ripplePresenter: ripplePresenter, + spectrumPresenter: spectrumPresenter, wallpaperPresenter: wallpaperPresenter )) hostingView.frame = initialLayout.hostingFrame diff --git a/Sources/Views/Overlay/OverlayContentView.swift b/Sources/Views/Overlay/OverlayContentView.swift index fbb451f3..ad36fd39 100644 --- a/Sources/Views/Overlay/OverlayContentView.swift +++ b/Sources/Views/Overlay/OverlayContentView.swift @@ -7,22 +7,28 @@ public struct OverlayContentView: View { let headerPresenter: HeaderPresenter let lyricsPresenter: LyricsPresenter let ripplePresenter: RipplePresenter + let spectrumPresenter: SpectrumPresenter @ObservedObject var wallpaperPresenter: WallpaperPresenter public init( headerPresenter: HeaderPresenter, lyricsPresenter: LyricsPresenter, ripplePresenter: RipplePresenter, + spectrumPresenter: SpectrumPresenter, wallpaperPresenter: WallpaperPresenter ) { self.headerPresenter = headerPresenter self.lyricsPresenter = lyricsPresenter self.ripplePresenter = ripplePresenter + self.spectrumPresenter = spectrumPresenter self.wallpaperPresenter = wallpaperPresenter } public var body: some View { ZStack { + // Below the ripple and lyrics so the bars read as a backdrop. + // SpectrumView includes itself only when enabled (#252 pattern). + SpectrumView(presenter: spectrumPresenter) RippleView(presenter: ripplePresenter) VStack(alignment: .leading, spacing: 32) { HeaderView(presenter: headerPresenter) @@ -193,6 +199,7 @@ private enum GeodesicGold { headerPresenter: HeaderPresenter(), lyricsPresenter: LyricsPresenter(), ripplePresenter: RipplePresenter(), + spectrumPresenter: SpectrumPresenter(), wallpaperPresenter: WallpaperPresenter() ) .frame(width: 800, height: 500) diff --git a/Sources/Views/Spectrum/SpectrumView.swift b/Sources/Views/Spectrum/SpectrumView.swift new file mode 100644 index 00000000..6419fd0f --- /dev/null +++ b/Sources/Views/Spectrum/SpectrumView.swift @@ -0,0 +1,105 @@ +import Dependencies +import Domain +import Presenters +import SwiftUI + +/// Bar-graph rendering of the spectrum analyzer (#23). Pure rendering: bar +/// heights, decay, and capture state all live in `SpectrumPresenter`. +@MainActor +public struct SpectrumView: View { + @ObservedObject var presenter: SpectrumPresenter + + public init(presenter: SpectrumPresenter) { + self.presenter = presenter + } + + public var body: some View { + if presenter.isEnabled { + let style = presenter.style + GeometryReader { proxy in + // Pause the per-frame timeline while nothing is captured and + // every bar has decayed away, so an enabled-but-silent + // spectrum stops redrawing the Canvas (#252 / #258 pattern). + TimelineView(.animation(paused: !presenter.isAnimating)) { timeline in + Canvas { context, size in + // Capturing the timeline's date ties the Canvas to the + // frame schedule; the bar data itself advances on the + // DisplayLink tick in the Presenter. + let _ = timeline.date + drawBars(&context, size: size, style: style) + } + } + .frame(height: barAreaHeight(in: proxy.size, style: style)) + .frame( + maxWidth: .infinity, maxHeight: .infinity, + alignment: Self.alignment(for: style.placement) + ) + } + .allowsHitTesting(false) + .accessibilityIdentifier("spectrum-view") + } + } + + @MainActor + private func drawBars(_ context: inout GraphicsContext, size: CGSize, style: SpectrumStyle) { + @Dependency(\.swiftUIResolver) var resolver + if let background = style.backgroundColor { + context.fill( + Path(CGRect(origin: .zero, size: size)), + with: .color( + red: background.red, green: background.green, + blue: background.blue, opacity: background.alpha) + ) + } + let path = spectrumBarsPath( + in: size, heights: presenter.binHeights(), + barWidthRatio: style.barWidthRatio, placement: style.placement + ) + context.fill(path, with: .style(resolver.shapeStyle(from: style.barColor))) + } + + private func barAreaHeight(in available: CGSize, style: SpectrumStyle) -> CGFloat { + switch style.placement { + case .underlay: available.height + case .bottom, .top: available.height * min(max(style.heightRatio, 0), 1) + } + } + + static func alignment(for placement: SpectrumPlacement) -> Alignment { + switch placement { + case .bottom, .underlay: .bottom + case .top: .top + } + } +} + +/// One path containing every visible bar, so a gradient fill spans the whole +/// bar row instead of restarting per bar. +func spectrumBarsPath( + in size: CGSize, heights: [Float], barWidthRatio: Double, placement: SpectrumPlacement +) -> Path { + guard !heights.isEmpty, size.width > 0, size.height > 0 else { return Path() } + let slotWidth = size.width / CGFloat(heights.count) + let barWidth = slotWidth * min(max(barWidthRatio, 0.05), 1) + let inset = (slotWidth - barWidth) / 2 + let cornerRadius = min(barWidth / 4, 3) + return heights.enumerated().reduce(into: Path()) { path, bar in + let height = size.height * CGFloat(min(max(bar.element, 0), 1)) + guard height > 0.5 else { return } + let rect = CGRect( + x: slotWidth * CGFloat(bar.offset) + inset, + y: placement == .top ? 0 : size.height - height, + width: barWidth, + height: height + ) + path.addRoundedRect(in: rect, cornerSize: CGSize(width: cornerRadius, height: cornerRadius)) + } +} + +#if DEBUG + #Preview("Spectrum") { + SpectrumView(presenter: SpectrumPresenter()) + .frame(width: 600, height: 300) + .background(.black) + } +#endif From aabb0f1dd590faef983e63b2531753ff2379f5f1 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 3 Jul 2026 19:41:03 +0900 Subject: [PATCH 02/13] =?UTF-8?q?test(#23):=20=E3=82=B9=E3=83=9A=E3=82=AF?= =?UTF-8?q?=E3=83=88=E3=83=A9=E3=83=A0=E3=82=A2=E3=83=8A=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=82=B6=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FrequencyAnalyzer: sine ピーク位置 / 無音 / 短入力 / クランプ / fftSize 丸め - SampleRingBuffer: 充填前 empty / 最新窓 / wraparound / 容量超過 / 容量丸め - AudioTapDataSourceImpl: タップなし empty / 不明 pid 失敗 (CI-safe, TCC 非発火) / stop 冪等 - SpectrumInteractorImpl: 再生でタップ開始 / 一時停止で破棄 / アプリ切替 re-tap / disabled 不活性 - SpectrumPresenter: tick の減衰マージ / アニメーションゲート / アイドル不活性 - [spectrum] TOML デコード (デフォルト / 全項目 / グラデーション / 部分指定 / 不正 placement) - 既存 TrackInteractor スタブ 8 箇所に audioSource を追加、テンプレートスナップショット更新 --- .../AppLaunchEnvironmentTests.swift | 13 +- .../AudioTapDataSourceImplTests.swift | 29 +++ .../SampleRingBufferTests.swift | 51 ++++++ .../ConfigDecodingTests.swift | 85 +++++++++ .../ConfigTemplateTests.swift | 27 +++ .../FrequencyAnalyzerTests.swift | 66 +++++++ .../HeaderPresenterDuplicateTests.swift | 1 + .../HeaderPresenterTests.swift | 1 + .../LyricsPresenterColumnsTests.swift | 1 + .../LyricsPresenterDuplicateTests.swift | 1 + .../LyricsPresenterTests.swift | 1 + .../SpectrumPresenterTests.swift | 138 +++++++++++++++ .../SpectrumInteractorImplTests.swift | 165 ++++++++++++++++++ Tests/ViewsTests/ViewRenderingTests.swift | 4 + 14 files changed, 579 insertions(+), 4 deletions(-) create mode 100644 Tests/AudioTapDataSourceTests/AudioTapDataSourceImplTests.swift create mode 100644 Tests/AudioTapDataSourceTests/SampleRingBufferTests.swift create mode 100644 Tests/FrequencyAnalyzerTests/FrequencyAnalyzerTests.swift create mode 100644 Tests/PresentersTests/SpectrumPresenterTests.swift create mode 100644 Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift diff --git a/Tests/AppRouterTests/AppLaunchEnvironmentTests.swift b/Tests/AppRouterTests/AppLaunchEnvironmentTests.swift index ef9571c1..d59cc198 100644 --- a/Tests/AppRouterTests/AppLaunchEnvironmentTests.swift +++ b/Tests/AppRouterTests/AppLaunchEnvironmentTests.swift @@ -99,6 +99,10 @@ private struct FixtureTrackInteractor: TrackInteractor, @unchecked Sendable { Empty().eraseToAnyPublisher() } + var audioSource: AnyPublisher { + Empty().eraseToAnyPublisher() + } + var decodeEffectConfig: DecodeEffect { .init(duration: 0) } var textLayout: TextLayout { .init(decodeEffect: .init(duration: 0)) } var artworkStyle: ArtworkStyle { .init(opacity: 0) } @@ -301,7 +305,7 @@ struct AppRouterTests { .lyricsLines: "One\nTwo", ] ), - windowFactory: { _, _, _, _, _ in window }, + windowFactory: { _, _, _, _, _, _ in window }, frameSchedulerFactory: { onFrame in driver.onFrame = onFrame return driver @@ -350,7 +354,7 @@ struct AppRouterTests { let router = AppRouter( launchEnvironment: .init(environment: [.uiTestMode: "true"]), - windowFactory: { _, _, _, _, _ in window }, + windowFactory: { _, _, _, _, _, _ in window }, frameSchedulerFactory: { onFrame in driver.onFrame = onFrame return driver @@ -408,7 +412,7 @@ struct AppRouterTests { dependencies.date = .init { Date(timeIntervalSinceReferenceDate: 0) } dependencies.continuousClock = ImmediateClock() }, - windowFactory: { _, _, _, _, _ in window }, + windowFactory: { _, _, _, _, _, _ in window }, frameSchedulerFactory: { onFrame in driver.onFrame = onFrame return driver @@ -455,7 +459,7 @@ struct AppRouterTests { dependencies.date = .init { Date(timeIntervalSinceReferenceDate: 0) } dependencies.continuousClock = ImmediateClock() }, - windowFactory: { _, _, _, _, _ in window }, + windowFactory: { _, _, _, _, _, _ in window }, frameSchedulerFactory: { onFrame in driver.onFrame = onFrame return driver @@ -624,6 +628,7 @@ struct AccessibilityHooksTests { headerPresenter: headerPresenter, lyricsPresenter: lyricsPresenter, ripplePresenter: overlayRipplePresenter, + spectrumPresenter: SpectrumPresenter(), wallpaperPresenter: wallpaperPresenter ), size: CGSize(width: 800, height: 500) diff --git a/Tests/AudioTapDataSourceTests/AudioTapDataSourceImplTests.swift b/Tests/AudioTapDataSourceTests/AudioTapDataSourceImplTests.swift new file mode 100644 index 00000000..d89631ab --- /dev/null +++ b/Tests/AudioTapDataSourceTests/AudioTapDataSourceImplTests.swift @@ -0,0 +1,29 @@ +import Testing + +@testable import AudioTapDataSource + +@Suite("AudioTapDataSourceImpl") +struct AudioTapDataSourceImplTests { + @Test("latestSamples is empty while no tap is active") + func emptyWithoutTap() { + let dataSource = AudioTapDataSourceImpl() + #expect(dataSource.latestSamples(count: 1024).isEmpty) + } + + @Test("startTap fails for a pid CoreAudio does not know") + func unknownPidFails() async { + let dataSource = AudioTapDataSourceImpl() + // A pid far beyond pid_max never has a CoreAudio process object, so + // the translation step fails before any tap (or TCC prompt) is created. + #expect(await dataSource.startTap(pid: 99_999_999) == false) + #expect(dataSource.latestSamples(count: 1024).isEmpty) + } + + @Test("stopTap without a tap is a safe no-op, repeatedly") + func stopIsIdempotent() async { + let dataSource = AudioTapDataSourceImpl() + await dataSource.stopTap() + await dataSource.stopTap() + #expect(dataSource.latestSamples(count: 1024).isEmpty) + } +} diff --git a/Tests/AudioTapDataSourceTests/SampleRingBufferTests.swift b/Tests/AudioTapDataSourceTests/SampleRingBufferTests.swift new file mode 100644 index 00000000..8be30115 --- /dev/null +++ b/Tests/AudioTapDataSourceTests/SampleRingBufferTests.swift @@ -0,0 +1,51 @@ +import Testing + +@testable import AudioTapDataSource + +@Suite("SampleRingBuffer") +struct SampleRingBufferTests { + @Test("latest is empty before enough samples were written") + func emptyBeforeFill() { + let ring = SampleRingBuffer(capacity: 8) + #expect(ring.latest(4).isEmpty) + + [Float](repeating: 1, count: 3).withUnsafeBufferPointer { + ring.write($0.baseAddress!, count: $0.count) + } + #expect(ring.latest(4).isEmpty) + #expect(ring.latest(3) == [1, 1, 1]) + } + + @Test("latest returns the newest samples oldest-first") + func newestWindow() { + let ring = SampleRingBuffer(capacity: 8) + let samples: [Float] = [1, 2, 3, 4, 5, 6] + samples.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: $0.count) } + #expect(ring.latest(4) == [3, 4, 5, 6]) + } + + @Test("writes wrap around the capacity and keep only the newest samples") + func wrapAround() { + let ring = SampleRingBuffer(capacity: 4) + let samples: [Float] = [1, 2, 3, 4, 5, 6] + samples.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: $0.count) } + #expect(ring.latest(4) == [3, 4, 5, 6]) + } + + @Test("requests beyond capacity yield empty") + func beyondCapacity() { + let ring = SampleRingBuffer(capacity: 4) + let samples: [Float] = [1, 2, 3, 4] + samples.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: $0.count) } + #expect(ring.latest(5).isEmpty) + } + + @Test("capacity is rounded up to the next power of two") + func capacityRounding() { + let ring = SampleRingBuffer(capacity: 5) + let samples: [Float] = [1, 2, 3, 4, 5, 6, 7, 8] + samples.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: $0.count) } + // Rounded to 8, so all 8 samples are retained. + #expect(ring.latest(8) == samples) + } +} diff --git a/Tests/ConfigDataSourceTests/ConfigDecodingTests.swift b/Tests/ConfigDataSourceTests/ConfigDecodingTests.swift index a5822e46..81c8ebd5 100644 --- a/Tests/ConfigDataSourceTests/ConfigDecodingTests.swift +++ b/Tests/ConfigDataSourceTests/ConfigDecodingTests.swift @@ -655,3 +655,88 @@ struct RippleShapeDecodingTests { #expect(json.contains("\"circle\"")) } } + +// MARK: - Spectrum config (TOML) + +@Suite("Spectrum TOML decoding") +struct SpectrumTomlDecodingTests { + @Test("missing [spectrum] section uses defaults (disabled)") + func noSection() throws { + let config = try decode("") + #expect(config.spectrum.enabled == false) + #expect(config.spectrum.barCount.value == 64) + #expect(config.spectrum.barColor == .gradient(["#1E3A5F", "#4A9EFF"])) + #expect(config.spectrum.backgroundColor == nil) + #expect(config.spectrum.barWidthRatio.value == 0.7) + #expect(config.spectrum.minDb.value == -80) + #expect(config.spectrum.maxDb.value == 0) + #expect(config.spectrum.decayRate.value == 0.85) + #expect(config.spectrum.fftSize.value == 1024) + #expect(config.spectrum.placement == .bottom) + #expect(config.spectrum.heightRatio.value == 0.25) + } + + @Test("full [spectrum] section decodes every field") + func fullSection() throws { + let config = try decode( + """ + [spectrum] + enabled = true + bar_count = 32 + bar_color = "#FF8800" + background_color = "#00000080" + bar_width_ratio = 0.5 + min_db = -60 + max_db = -10 + decay_rate = 0.9 + fft_size = 2048 + placement = "underlay" + height_ratio = 0.5 + """) + #expect(config.spectrum.enabled == true) + #expect(config.spectrum.barCount.value == 32) + #expect(config.spectrum.barColor == .solid("#FF8800")) + #expect(config.spectrum.backgroundColor == ColorConfig(hex: "#00000080")) + #expect(config.spectrum.barWidthRatio.value == 0.5) + #expect(config.spectrum.minDb.value == -60) + #expect(config.spectrum.maxDb.value == -10) + #expect(config.spectrum.decayRate.value == 0.9) + #expect(config.spectrum.fftSize.value == 2048) + #expect(config.spectrum.placement == .underlay) + #expect(config.spectrum.heightRatio.value == 0.5) + } + + @Test("gradient bar_color decodes from an array") + func gradientBarColor() throws { + let config = try decode( + """ + [spectrum] + bar_color = ["#FF0000", "#00FF00", "#0000FF"] + """) + #expect(config.spectrum.barColor == .gradient(["#FF0000", "#00FF00", "#0000FF"])) + } + + @Test("partial section keeps defaults for omitted keys") + func partialSection() throws { + let config = try decode( + """ + [spectrum] + enabled = true + """) + #expect(config.spectrum.enabled == true) + #expect(config.spectrum.barCount.value == 64) + #expect(config.spectrum.placement == .bottom) + #expect(config.spectrum.fftSize.value == 1024) + } + + @Test("unknown placement throws") + func unknownPlacementThrows() { + #expect(throws: DecodingError.self) { + _ = try decode( + """ + [spectrum] + placement = "sideways" + """) + } + } +} diff --git a/Tests/ConfigDataSourceTests/ConfigTemplateTests.swift b/Tests/ConfigDataSourceTests/ConfigTemplateTests.swift index a63c14cb..9b1080d7 100644 --- a/Tests/ConfigDataSourceTests/ConfigTemplateTests.swift +++ b/Tests/ConfigDataSourceTests/ConfigTemplateTests.swift @@ -69,6 +69,18 @@ radius = 60.0 [ripple.shape] type = 'circle' +[spectrum] +bar_color = [ '#1E3A5F', '#4A9EFF' ] +bar_count = 64.0 +bar_width_ratio = 0.7 +decay_rate = 0.85 +enabled = false +fft_size = 1024.0 +height_ratio = 0.25 +max_db = 0.0 +min_db = -80.0 +placement = 'bottom' + [text.artist] color = '#FFFFFFD9' fontName = 'Helvetica Neue' @@ -166,6 +178,21 @@ spacing = 6.0 }, "screen" : "main", "screen_debounce" : 5, + "spectrum" : { + "bar_color" : [ + "#1E3A5F", + "#4A9EFF" + ], + "bar_count" : 64, + "bar_width_ratio" : 0.7, + "decay_rate" : 0.85, + "enabled" : false, + "fft_size" : 1024, + "height_ratio" : 0.25, + "max_db" : 0, + "min_db" : -80, + "placement" : "bottom" + }, "text" : { "artist" : { "color" : "#FFFFFFD9", diff --git a/Tests/FrequencyAnalyzerTests/FrequencyAnalyzerTests.swift b/Tests/FrequencyAnalyzerTests/FrequencyAnalyzerTests.swift new file mode 100644 index 00000000..23248b50 --- /dev/null +++ b/Tests/FrequencyAnalyzerTests/FrequencyAnalyzerTests.swift @@ -0,0 +1,66 @@ +import Foundation +import Testing + +@testable import FrequencyAnalyzer + +@Suite("FrequencyAnalyzer") +struct FrequencyAnalyzerTests { + private static let fftSize = 1024 + private static let barCount = 32 + + private var analyzer: FrequencyAnalyzer { + FrequencyAnalyzer(fftSize: Self.fftSize, barCount: Self.barCount, minDb: -80, maxDb: 0) + } + + /// A full-scale sine whose frequency lands exactly on FFT bin `bin`. + private func sine(bin: Int, count: Int = Self.fftSize) -> [Float] { + (0.. 0.5) + } + + @Test("silence maps to all-zero bars") + func silenceIsAllZero() { + let bars = analyzer.magnitudes(of: [Float](repeating: 0, count: Self.fftSize)) + #expect(bars.allSatisfy { $0 == 0 }) + } + + @Test("fewer samples than the FFT window yields all-zero bars") + func shortInputIsAllZero() { + let bars = analyzer.magnitudes(of: sine(bin: 100, count: Self.fftSize / 2)) + #expect(bars.count == Self.barCount) + #expect(bars.allSatisfy { $0 == 0 }) + } + + @Test("values stay clamped to 0...1 even for a loud signal") + func valuesAreClamped() { + let loud = sine(bin: 100).map { $0 * 4 } + let bars = analyzer.magnitudes(of: loud) + #expect(bars.allSatisfy { (0...1).contains($0) }) + } + + @Test("non-power-of-two fft size is rounded down to a power of two") + func fftSizeRounding() { + let rounded = FrequencyAnalyzer(fftSize: 1500, barCount: 8, minDb: -80, maxDb: 0) + // Rounded to 1024: a 1024-sample input must be accepted (non-zero output). + let bars = rounded.magnitudes(of: sine(bin: 64)) + #expect(bars.contains { $0 > 0 }) + } +} diff --git a/Tests/PresentersTests/HeaderPresenterDuplicateTests.swift b/Tests/PresentersTests/HeaderPresenterDuplicateTests.swift index c40b9217..fce23ba9 100644 --- a/Tests/PresentersTests/HeaderPresenterDuplicateTests.swift +++ b/Tests/PresentersTests/HeaderPresenterDuplicateTests.swift @@ -19,6 +19,7 @@ private struct StubTrackInteractor: TrackInteractor, @unchecked Sendable { var trackChange: AnyPublisher { trackChangePublisher } var artwork: AnyPublisher { artworkPublisher } var playbackPosition: AnyPublisher { Empty().eraseToAnyPublisher() } + var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } } // MARK: - Helpers diff --git a/Tests/PresentersTests/HeaderPresenterTests.swift b/Tests/PresentersTests/HeaderPresenterTests.swift index 105a5c97..adb0b38b 100644 --- a/Tests/PresentersTests/HeaderPresenterTests.swift +++ b/Tests/PresentersTests/HeaderPresenterTests.swift @@ -17,6 +17,7 @@ private struct StubTrackInteractor: TrackInteractor, @unchecked Sendable { var trackChange: AnyPublisher { trackChangePublisher } var artwork: AnyPublisher { Empty().eraseToAnyPublisher() } var playbackPosition: AnyPublisher { Empty().eraseToAnyPublisher() } + var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } } // MARK: - Helpers diff --git a/Tests/PresentersTests/LyricsPresenterColumnsTests.swift b/Tests/PresentersTests/LyricsPresenterColumnsTests.swift index a2d07379..165468aa 100644 --- a/Tests/PresentersTests/LyricsPresenterColumnsTests.swift +++ b/Tests/PresentersTests/LyricsPresenterColumnsTests.swift @@ -15,6 +15,7 @@ private struct StubTrackInteractor: TrackInteractor, @unchecked Sendable { var trackChange: AnyPublisher { trackChangePublisher } var artwork: AnyPublisher { Empty().eraseToAnyPublisher() } var playbackPosition: AnyPublisher { Empty().eraseToAnyPublisher() } + var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } } @MainActor diff --git a/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift b/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift index 7a18c990..951ddec0 100644 --- a/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift +++ b/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift @@ -18,6 +18,7 @@ private struct StubTrackInteractor: TrackInteractor, @unchecked Sendable { var trackChange: AnyPublisher { trackChangePublisher } var artwork: AnyPublisher { Empty().eraseToAnyPublisher() } var playbackPosition: AnyPublisher { playbackPositionPublisher } + var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } } // MARK: - Helpers diff --git a/Tests/PresentersTests/LyricsPresenterTests.swift b/Tests/PresentersTests/LyricsPresenterTests.swift index b3d61349..bb69900e 100644 --- a/Tests/PresentersTests/LyricsPresenterTests.swift +++ b/Tests/PresentersTests/LyricsPresenterTests.swift @@ -18,6 +18,7 @@ private struct StubTrackInteractor: TrackInteractor, @unchecked Sendable { var trackChange: AnyPublisher { trackChangePublisher } var artwork: AnyPublisher { Empty().eraseToAnyPublisher() } var playbackPosition: AnyPublisher { playbackPositionPublisher } + var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } } // MARK: - Helpers diff --git a/Tests/PresentersTests/SpectrumPresenterTests.swift b/Tests/PresentersTests/SpectrumPresenterTests.swift new file mode 100644 index 00000000..0ca61fd0 --- /dev/null +++ b/Tests/PresentersTests/SpectrumPresenterTests.swift @@ -0,0 +1,138 @@ +import Combine +import Dependencies +import Domain +import Foundation +import Testing + +@testable import Presenters + +// MARK: - Fake + +private final class FakeSpectrumInteractor: SpectrumInteractor, @unchecked Sendable { + let spectrumStyle: SpectrumStyle + let capturingSubject = CurrentValueSubject(false) + var magnitudesValue: [Float] = [] + private(set) var startCount = 0 + private(set) var stopCount = 0 + + init(style: SpectrumStyle) { self.spectrumStyle = style } + + var isCapturing: AnyPublisher { capturingSubject.eraseToAnyPublisher() } + func start() { startCount += 1 } + func stop() { stopCount += 1 } + func magnitudes() -> [Float] { magnitudesValue } +} + +// MARK: - Tests + +@Suite("SpectrumPresenter") +struct SpectrumPresenterTests { + /// decayRate 0.5 keeps decay math exact in Float and short in test time. + private static let enabledStyle = SpectrumStyle(enabled: true, barCount: 4, decayRate: 0.5) + + @MainActor + private static func presenter(with interactor: FakeSpectrumInteractor) -> SpectrumPresenter { + withDependencies { + $0.spectrumInteractor = interactor + } operation: { + SpectrumPresenter() + } + } + + /// Ticks once per poll step until `condition` holds or the deadline hits — + /// the capturing flag arrives async via the main queue, so fixed sleeps + /// would be flaky on CI. + @MainActor + private static func tickUntil(_ presenter: SpectrumPresenter, _ condition: () -> Bool) async { + let deadline = ContinuousClock.now + .seconds(3) + presenter.tick() + while !condition(), ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + presenter.tick() + } + } + + @MainActor + @Test("isEnabled reflects the interactor style") + func isEnabledReflectsStyle() { + let on = Self.presenter(with: FakeSpectrumInteractor(style: Self.enabledStyle)) + #expect(on.isEnabled) + let off = Self.presenter(with: FakeSpectrumInteractor(style: SpectrumStyle(enabled: false))) + #expect(!off.isEnabled) + } + + @MainActor + @Test("start is inert while the spectrum is disabled") + func startSkipsWhenDisabled() { + let interactor = FakeSpectrumInteractor(style: SpectrumStyle(enabled: false)) + Self.presenter(with: interactor).start() + #expect(interactor.startCount == 0) + } + + @MainActor + @Test("start forwards to the interactor when enabled") + func startForwardsWhenEnabled() { + let interactor = FakeSpectrumInteractor(style: Self.enabledStyle) + Self.presenter(with: interactor).start() + #expect(interactor.startCount == 1) + } + + @MainActor + @Test("stop tears the interactor down") + func stopForwards() { + let interactor = FakeSpectrumInteractor(style: Self.enabledStyle) + let presenter = Self.presenter(with: interactor) + presenter.start() + presenter.stop() + #expect(interactor.stopCount == 1) + } + + @MainActor + @Test("ticks while capturing surface the magnitudes and animate") + func capturingTickPublishesBars() async { + let interactor = FakeSpectrumInteractor(style: Self.enabledStyle) + interactor.magnitudesValue = [1, 0.5, 0.25, 0.125] + let presenter = Self.presenter(with: interactor) + presenter.start() + interactor.capturingSubject.send(true) + + await Self.tickUntil(presenter) { !presenter.binHeights().isEmpty } + #expect(presenter.binHeights() == [1, 0.5, 0.25, 0.125]) + #expect(presenter.isAnimating) + } + + @MainActor + @Test("after capture ends the bars decay by decayRate, then clear") + func barsDecayAfterCapture() async { + let interactor = FakeSpectrumInteractor(style: Self.enabledStyle) + interactor.magnitudesValue = [1] + let presenter = Self.presenter(with: interactor) + presenter.start() + interactor.capturingSubject.send(true) + await Self.tickUntil(presenter) { !presenter.binHeights().isEmpty } + + // While the capturing flag is still propagating, ticks keep the bar at + // 1 (fresh wins the max-merge); the first tick after it lands yields + // exactly one decay step. + interactor.capturingSubject.send(false) + await Self.tickUntil(presenter) { (presenter.binHeights().first ?? 1) < 1 } + #expect(presenter.binHeights().first == 0.5) + #expect(presenter.isAnimating) + + // Keep ticking: 0.5 halves per frame and falls under the silence + // threshold, at which point the bins clear and the animation gate shuts. + await Self.tickUntil(presenter) { presenter.binHeights().isEmpty } + #expect(presenter.binHeights().isEmpty) + #expect(!presenter.isAnimating) + } + + @MainActor + @Test("ticks with nothing captured and no residue are inert") + func idleTickIsInert() { + let presenter = Self.presenter(with: FakeSpectrumInteractor(style: Self.enabledStyle)) + presenter.start() + presenter.tick() + #expect(presenter.binHeights().isEmpty) + #expect(!presenter.isAnimating) + } +} diff --git a/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift b/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift new file mode 100644 index 00000000..631a08bd --- /dev/null +++ b/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift @@ -0,0 +1,165 @@ +@preconcurrency import Combine +import Dependencies +import Domain +import Foundation +import Testing +import os + +@testable import SpectrumInteractor + +@Suite("SpectrumInteractorImpl") +struct SpectrumInteractorImplTests { + @Test("playing source starts a tap for its pid and reports capturing") + func playingStartsTap() async { + let harness = Harness() + harness.interactor.start() + harness.audioSource.send(AudioSourceState(pid: 4242, isPlaying: true)) + + await harness.pollUntil { harness.tap.startedPids == [4242] } + #expect(harness.tap.startedPids == [4242]) + await harness.pollUntil { harness.capturing.value == true } + #expect(harness.capturing.value == true) + } + + @Test("pausing tears the tap down and reports not capturing") + func pauseStopsTap() async { + let harness = Harness() + harness.interactor.start() + harness.audioSource.send(AudioSourceState(pid: 4242, isPlaying: true)) + await harness.pollUntil { harness.capturing.value == true } + + harness.audioSource.send(AudioSourceState(pid: 4242, isPlaying: false)) + await harness.pollUntil { harness.tap.stopCount > 0 } + #expect(harness.tap.stopCount > 0) + await harness.pollUntil { harness.capturing.value == false } + #expect(harness.capturing.value == false) + } + + @Test("app switch re-taps the new pid") + func appSwitchRetaps() async { + let harness = Harness() + harness.interactor.start() + harness.audioSource.send(AudioSourceState(pid: 1, isPlaying: true)) + harness.audioSource.send(AudioSourceState(pid: 2, isPlaying: true)) + + await harness.pollUntil { harness.tap.startedPids == [1, 2] } + #expect(harness.tap.startedPids == [1, 2]) + } + + @Test("disabled spectrum never subscribes nor taps") + func disabledIsInert() async { + let harness = Harness(enabled: false) + harness.interactor.start() + harness.audioSource.send(AudioSourceState(pid: 4242, isPlaying: true)) + + try? await Task.sleep(for: .milliseconds(50)) + #expect(harness.tap.startedPids.isEmpty) + } + + @Test("stop tears down the active tap") + func stopTearsDown() async { + let harness = Harness() + harness.interactor.start() + harness.audioSource.send(AudioSourceState(pid: 4242, isPlaying: true)) + await harness.pollUntil { harness.capturing.value == true } + + harness.interactor.stop() + await harness.pollUntil { harness.tap.stopCount > 0 } + #expect(harness.tap.stopCount > 0) + } + + @Test("magnitudes converts the captured window into one value per bar") + func magnitudesShape() { + let harness = Harness(samples: [Float](repeating: 0.5, count: 1024)) + let bins = harness.interactor.magnitudes() + #expect(bins.count == harness.style.barCount) + } + + @Test("magnitudes is empty while nothing is captured") + func magnitudesEmptyWithoutSamples() { + let harness = Harness() + #expect(harness.interactor.magnitudes().isEmpty) + } +} + +// MARK: - Harness + +/// Builds a `SpectrumInteractorImpl` whose dependencies are all fakes, and +/// keeps the fakes accessible for assertions. +private struct Harness { + let style: SpectrumStyle + let audioSource = PassthroughSubject() + let tap = FakeAudioTapDataSource() + let capturing = CurrentValueBox() + let interactor: SpectrumInteractorImpl + private let cancellable: AnyCancellable + + init(enabled: Bool = true, samples: [Float] = []) { + let style = SpectrumStyle(enabled: enabled, barCount: 16, fftSize: 1024) + self.style = style + tap.samples = samples + let interactor = withDependencies { [audioSource, tap] in + $0.configUseCase = StubConfigUseCase(appStyle: AppStyle(spectrum: style)) + $0.trackInteractor = StubTrackInteractor( + audioSource: audioSource.eraseToAnyPublisher()) + $0.audioTapDataSource = tap + } operation: { + SpectrumInteractorImpl() + } + self.interactor = interactor + self.cancellable = interactor.isCapturing.sink { [capturing] in capturing.value = $0 } + } + + func pollUntil(_ condition: () -> Bool) async { + let deadline = ContinuousClock.now + .seconds(3) + while !condition(), ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + } +} + +private final class CurrentValueBox: @unchecked Sendable { + private let state = OSAllocatedUnfairLock(initialState: false) + var value: Bool { + get { state.withLock { $0 } } + set { state.withLock { $0 = newValue } } + } +} + +private final class FakeAudioTapDataSource: AudioTapDataSource, @unchecked Sendable { + private let state = OSAllocatedUnfairLock(initialState: (started: [Int](), stops: 0)) + var samples: [Float] = [] + + var startedPids: [Int] { state.withLock { $0.started } } + var stopCount: Int { state.withLock { $0.stops } } + + func startTap(pid: Int) async -> Bool { + state.withLock { $0.started.append(pid) } + return true + } + + func stopTap() async { + state.withLock { $0.stops += 1 } + } + + func latestSamples(count: Int) -> [Float] { + samples.count >= count ? Array(samples.suffix(count)) : [] + } +} + +private struct StubConfigUseCase: ConfigUseCase { + let appStyle: AppStyle + func template(format: ConfigFormat) -> String? { nil } + func writeTemplate(format: ConfigFormat, force: Bool) throws -> String { "" } + var existingConfigPath: String? { nil } +} + +private struct StubTrackInteractor: TrackInteractor { + let audioSource: AnyPublisher + var trackChange: AnyPublisher { Empty().eraseToAnyPublisher() } + var artwork: AnyPublisher { Empty().eraseToAnyPublisher() } + var playbackPosition: AnyPublisher { Empty().eraseToAnyPublisher() } + var decodeEffectConfig: DecodeEffect { .init() } + var textLayout: TextLayout { .init() } + var artworkStyle: ArtworkStyle { .init() } +} diff --git a/Tests/ViewsTests/ViewRenderingTests.swift b/Tests/ViewsTests/ViewRenderingTests.swift index c03af584..faaf99ca 100644 --- a/Tests/ViewsTests/ViewRenderingTests.swift +++ b/Tests/ViewsTests/ViewRenderingTests.swift @@ -40,6 +40,7 @@ private struct IdleTrackInteractor: TrackInteractor, @unchecked Sendable { let trackChange: AnyPublisher = Empty().eraseToAnyPublisher() let artwork: AnyPublisher = Empty().eraseToAnyPublisher() let playbackPosition: AnyPublisher = Empty().eraseToAnyPublisher() + var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } var decodeEffectConfig: DecodeEffect { .init(duration: 0) } var textLayout: TextLayout { .init(decodeEffect: .init(duration: 0)) } var artworkStyle: ArtworkStyle { .init() } @@ -87,6 +88,7 @@ private struct FixtureTrackInteractor: TrackInteractor, @unchecked Sendable { } var artwork: AnyPublisher { Just(artworkData).eraseToAnyPublisher() } let playbackPosition: AnyPublisher = Empty().eraseToAnyPublisher() + var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } var decodeEffectConfig: DecodeEffect { .init(duration: 0) } var textLayout: TextLayout { .init(decodeEffect: .init(duration: 0)) } var artworkStyle: ArtworkStyle { .init(opacity: opacity) } @@ -417,6 +419,7 @@ struct OverlayContentViewLoadingTests { headerPresenter: headerPresenter, lyricsPresenter: lyricsPresenter, ripplePresenter: ripplePresenter, + spectrumPresenter: SpectrumPresenter(), wallpaperPresenter: wallpaperPresenter ), size: CGSize(width: 800, height: 500) @@ -462,6 +465,7 @@ struct OverlayContentViewLoadingTests { headerPresenter: headerPresenter, lyricsPresenter: lyricsPresenter, ripplePresenter: ripplePresenter, + spectrumPresenter: SpectrumPresenter(), wallpaperPresenter: wallpaperPresenter ), size: CGSize(width: 800, height: 500) From 49e2a7f9383618ec51add6c6e7fe79cdfac7a86a Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 3 Jul 2026 19:41:10 +0900 Subject: [PATCH 03/13] =?UTF-8?q?docs(#23):=20=E3=82=B9=E3=83=9A=E3=82=AF?= =?UTF-8?q?=E3=83=88=E3=83=A9=E3=83=A0=E3=82=A2=E3=83=8A=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=82=B6=E3=81=AE=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: mermaid グラフ / VIPER・Layer Summary / Key Design Decisions に spectrum 系モジュールを追記 - README: [spectrum] 設定リファレンス、ブラウザ全体タップの既知の制限、macOS 14.4+ 要件 --- .claude/CLAUDE.md | 24 +++++++++++++++++------- README.md | 27 ++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4388e16d..92b5c944 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -77,6 +77,7 @@ graph TD TrackInteractor[TrackInteractor] ScreenInteractor[ScreenInteractor] WallpaperInteractor[WallpaperInteractor] + SpectrumInteractor[SpectrumInteractor] end subgraph UseCase @@ -101,12 +102,14 @@ graph TD ConfigDataSource[ConfigDataSource] MediaRemoteDataSource[MediaRemoteDataSource] WallpaperDataSource[WallpaperDataSource] + AudioTapDataSource[AudioTapDataSource] end subgraph Support["Provider / Support"] AppKitScreenProvider[AppKitScreenProvider] StandardOutput[StandardOutput] DarwinGateway[DarwinGateway] + FrequencyAnalyzer[FrequencyAnalyzer] end subgraph DataStore @@ -133,6 +136,8 @@ graph TD TrackInteractor -.-> PlaybackUseCase & MetadataUseCase & LyricsUseCase & ConfigUseCase ScreenInteractor -.-> ConfigUseCase WallpaperInteractor -.-> WallpaperUseCase & ConfigUseCase + SpectrumInteractor -.-> ConfigUseCase & TrackInteractor & AudioTapDataSource + SpectrumInteractor --> FrequencyAnalyzer ConfigUseCase -.-> ConfigRepository ConfigRepository -.-> ConfigDataSource PlaybackUseCase -.-> NowPlayingRepository @@ -155,6 +160,7 @@ graph TD style TrackInteractor fill:#7b5,stroke:#333,color:#fff style ScreenInteractor fill:#7b5,stroke:#333,color:#fff style WallpaperInteractor fill:#7b5,stroke:#333,color:#fff + style SpectrumInteractor fill:#7b5,stroke:#333,color:#fff style DependencyInjection fill:#c44,stroke:#333,color:#fff style Entity fill:#4a9,stroke:#333,color:#fff style Domain fill:#38b,stroke:#333,color:#fff @@ -173,9 +179,11 @@ graph TD style ConfigDataSource fill:#c84,stroke:#333,color:#fff style MediaRemoteDataSource fill:#c84,stroke:#333,color:#fff style WallpaperDataSource fill:#c84,stroke:#333,color:#fff + style AudioTapDataSource fill:#c84,stroke:#333,color:#fff style AppKitScreenProvider fill:#a75,stroke:#333,color:#fff style StandardOutput fill:#a75,stroke:#333,color:#fff style DarwinGateway fill:#a75,stroke:#333,color:#fff + style FrequencyAnalyzer fill:#a75,stroke:#333,color:#fff style SQLiteDataStore fill:#a75,stroke:#333,color:#fff ``` @@ -183,9 +191,9 @@ graph TD | Component | Instances | Responsibility | |---|---|---| -| **View** | `HeaderView`, `LyricsColumnView`, `LyricLineView`, `RippleView`, `OverlayContentView`, `AppWindow` | Pure rendering. SwiftUI views get data from Presenters via `@ObservedObject`. `AppWindow` (NSWindow subclass) in Views module | -| **Presenter** | `HeaderPresenter`, `LyricsPresenter`, `WallpaperPresenter`, `RipplePresenter`, `AppPresenter` | Display logic, decode animations, Combine subscriptions. `@Published` state for Views. Each Presenter maps 1:1 to an Interactor | -| **Interactor** | `TrackInteractor`, `WallpaperInteractor`, `ScreenInteractor` | Business logic. Abstractions in Domain, implementations in dedicated modules. TrackInteractor uses Combine hot stream | +| **View** | `HeaderView`, `LyricsColumnView`, `LyricLineView`, `RippleView`, `SpectrumView`, `OverlayContentView`, `AppWindow` | Pure rendering. SwiftUI views get data from Presenters via `@ObservedObject`. `AppWindow` (NSWindow subclass) in Views module | +| **Presenter** | `HeaderPresenter`, `LyricsPresenter`, `WallpaperPresenter`, `RipplePresenter`, `SpectrumPresenter`, `AppPresenter` | Display logic, decode animations, Combine subscriptions. `@Published` state for Views. Each Presenter maps 1:1 to an Interactor | +| **Interactor** | `TrackInteractor`, `WallpaperInteractor`, `ScreenInteractor`, `SpectrumInteractor` | Business logic. Abstractions in Domain, implementations in dedicated modules. TrackInteractor uses Combine hot stream | | **Router** | `AppRouter` | Pure wireframe: creates Presenters in correct order, builds AppWindow, manages DisplayLink. For UI-test mode, app launch reads environment once and bootstraps fixture dependencies before Presenter creation | | **Entity** | `Entity` module | Pure data types (`TrackUpdate`, `PlaybackPosition`, `WallpaperState`, `ScreenLayout`, `AppStyle`, etc.) | @@ -208,14 +216,14 @@ Presenters subscribe to Interactors via Combine. Interactors access UseCases via | View | `Views` | SwiftUI views + `AppWindow` (NSWindow subclass). Feature dirs: `Header/`, `Lyrics/`, `Ripple/`, `Overlay/`, `Shared/` | | Presenter | `Presenters` | `Track/` (Header, Lyrics), `Wallpaper/` (Wallpaper, Ripple), `App/` (AppPresenter). DecodeEffect engine, RippleState | | Handler | `ProcessHandler`, `VersionHandler`, `ServiceHandler`, `HealthHandler`, `TrackHandler`, `ConfigHandler`, `BenchmarkHandler` | CLI command logic. ProcessHandler: process lifecycle. VersionHandler: version string. ServiceHandler: LaunchAgent install/uninstall. HealthHandler: connectivity checks. TrackHandler: now-playing info with metadata/lyrics resolution. ConfigHandler: config template/init/path resolution. BenchmarkHandler: CPU/memory measurement via `ProcessGateway`. Protocols in Domain, injected via `@Dependency`. All handlers return `Result` — never throw | -| Provider / Support | `AppKitScreenProvider`, `StandardOutput`, `DarwinGateway` | Platform/provider implementations that do not fit the core Clean Architecture layers directly. `AppKitScreenProvider` adapts `NSScreen` into `ScreenProvider`; `StandardOutput` owns CLI output rendering; `DarwinGateway` owns macOS process/system calls | -| Interactor | `TrackInteractor`, `ScreenInteractor`, `WallpaperInteractor` | Combine-based reactive pipelines over UseCases (GUI) | +| Provider / Support | `AppKitScreenProvider`, `StandardOutput`, `DarwinGateway`, `FrequencyAnalyzer` | Platform/provider implementations that do not fit the core Clean Architecture layers directly. `AppKitScreenProvider` adapts `NSScreen` into `ScreenProvider`; `StandardOutput` owns CLI output rendering; `DarwinGateway` owns macOS process/system calls; `FrequencyAnalyzer` owns the vDSP FFT → per-bar magnitude conversion (pure, dependency-free) | +| Interactor | `TrackInteractor`, `ScreenInteractor`, `WallpaperInteractor`, `SpectrumInteractor` | Combine-based reactive pipelines over UseCases (GUI) | | DI Wiring | `DependencyInjection` | All liveValue registrations, FontMetrics, HealthCheck | | Entity | `Entity` | Pure data types, zero external dependencies | | Domain | `Domain` | Protocols, DependencyKeys (`@_exported import Entity`) | | UseCase | `ConfigUseCase`, `PlaybackUseCase`, `LyricsUseCase`, `MetadataUseCase`, `WallpaperUseCase` | Business logic only, no cross-UseCase deps | | Repository | `ConfigRepository`, `LyricsRepository`, `MetadataRepository`, `NowPlayingRepository`, `WallpaperRepository` | DataSource + DataStore orchestration, cache strategy | -| DataSource | `LyricsDataSource`, `MetadataDataSource`, `ConfigDataSource`, `MediaRemoteDataSource`, `WallpaperDataSource` | API execution, file I/O, private framework access | +| DataSource | `LyricsDataSource`, `MetadataDataSource`, `ConfigDataSource`, `MediaRemoteDataSource`, `WallpaperDataSource`, `AudioTapDataSource` | API execution, file I/O, private framework access, CoreAudio process tap | | DataStore | `SQLiteDataStore` | GRDB SQLite cache | ### Key Design Decisions @@ -232,9 +240,11 @@ Presenters subscribe to Interactors via Combine. Interactors access UseCases via **FetchState\**: Generic enum (`.idle`, `.loading`, `.revealing(T)`, `.success(T)`, `.failure`) drives both data flow and UI animation. The `.revealing` → `.success` transition is timed by Presenters using `DecodeEffectState`. Use `FetchState` only when the payload `T` is genuinely consumed downstream (e.g. `LyricsPresenter.lyricsState`, whose content feeds `columns(in:)` and `updateActiveLineTick()`). When a Presenter only needs the animation lifecycle and the View already renders the text from a separate `display…` property, expose the payload-less `RevealPhase` (`.idle` / `.revealing` / `.revealed`) instead and keep the decode target in a private field — `HeaderPresenter` does this for `titlePhase` / `artistPhase` so the public surface never duplicates `displayTitle` / `displayArtist` (#275). +**Spectrum analyzer (#23)**: Real-time bars driven by the now-playing app's audio via a CoreAudio **process tap** (macOS 14.4+ APIs: `kAudioHardwarePropertyTranslatePIDToProcessObject` → `CATapDescription(stereoMixdownOfProcesses:)` → `AudioHardwareCreateProcessTap` → private aggregate device + `AudioDeviceCreateIOProcIDWithBlock`; requires the *System Audio Recording* TCC permission declared as `NSAudioCaptureUsageDescription` in the embedded Info.plist). The pipeline is: `TrackInteractor.audioSource` (an `AudioSourceState(pid:isPlaying:)` stream derived from the shared NowPlaying publisher — **never a second helper stream**, the MediaRemote helper supports one consumer) → `SpectrumInteractorImpl` serializes tap create/destroy transitions behind a chained `Task` so pause/play bursts cannot interleave → `AudioTapDataSourceImpl` owns `ProcessTapEngine` (`@available(macOS 14.4, *)`, availability-erased as `AnyObject` for the 14.0 target) and a lock-free SPSC `SampleRingBuffer` (swift-atomics `ManagedAtomic` monotonic write index; the IOProc callback is RT-safe — no allocation, locks, or Swift concurrency). `FrequencyAnalyzer` (pure, dependency-free module) converts the newest PCM window: Hann window → vDSP FFT → dB → 0…1 normalization → per-bar max grouping. `SpectrumPresenter.tick()` runs on the DisplayLink and folds fresh magnitudes into exponentially decaying bars (`decayRate`); `binHeights()` is read-only so the Canvas draw closure never mutates `@Published` state. `SpectrumView` follows the #252/#258 zero-idle-cost pattern (conditional inclusion + `TimelineView(.animation(paused:))`). Tap lifecycle: playing+pid → create; paused/pid-lost/session-gone → destroy (a dead tap costs zero CPU). Known limitation (by decision): the tap captures the whole process — for browsers that means every tab, documented in README. + **AI processing indicator (#57)**: While the AI (LLM) extractor resolves title/artist on a cache miss, the header scrambles in a configurable color so the user sees that work is happening. `TrackInteractorImpl.resolveTrack` emits an extra `TrackUpdate(aiResolving: true)` after the debounce only when an `[ai]` endpoint is configured **and** `MetadataUseCase.isAIMetadataCached(track:)` returns `false` (an LLM cache hit means no API round-trip, so no indicator). `HeaderPresenter` maps `aiResolving` to `DecodeEffectState.startLoading` (the indefinite scramble, distinct from `decode`'s settle) and swaps `titleColor` / `artistColor` to `DecodeEffect.processingColor` (default green `#4ADE80FF`, config key `text.decode_effect.processing_color`, solid or gradient). The resolved (non-`aiResolving`) update settles the scramble and restores the normal color. `HeaderView` reads the effective `titleColor` / `artistColor` (`@Published`) rather than the static `titleStyle.color`. -**Entity types**: `AppStyle`, `TextLayout`, `TextAppearance`, `ArtworkStyle`, `RippleStyle`, `WallpaperStyle`, `WallpaperItem`, `WallpaperPlaybackMode`, `DecodeEffect`, `AIEndpoint`, `ColorStyle`, `HealthCheckResult`, `ConfigValidationResult`, `MusicBrainzMetadata`, `MediaRemotePollResult`, `LocalWallpaper`, `RemoteWallpaper`, `YouTubeWallpaper`, `TrackUpdate`, `TrackLyricsState`, `WallpaperState`, `ResolvedWallpaperItem`, `ScreenLayout`, `WallpaperConfig`, `WallpaperItemConfig`, `NowPlayingInfo`, `LyricLine`, `LyricsContent`, `RevealPhase`. Config flows through Interactors, not via global `AppStyleKey`. +**Entity types**: `AppStyle`, `TextLayout`, `TextAppearance`, `ArtworkStyle`, `RippleStyle`, `WallpaperStyle`, `WallpaperItem`, `WallpaperPlaybackMode`, `DecodeEffect`, `AIEndpoint`, `ColorStyle`, `HealthCheckResult`, `ConfigValidationResult`, `MusicBrainzMetadata`, `MediaRemotePollResult`, `LocalWallpaper`, `RemoteWallpaper`, `YouTubeWallpaper`, `TrackUpdate`, `TrackLyricsState`, `WallpaperState`, `ResolvedWallpaperItem`, `ScreenLayout`, `WallpaperConfig`, `WallpaperItemConfig`, `NowPlayingInfo`, `LyricLine`, `LyricsContent`, `RevealPhase`, `SpectrumConfig`, `SpectrumStyle`, `SpectrumPlacement`, `AudioSourceState`. Config flows through Interactors, not via global `AppStyleKey`. **No AppStyleKey**: `@Dependency(\.appStyle)` was removed. All config access goes through the owning Interactor's computed properties (e.g., `trackInteractor.textLayout`, `wallpaperInteractor.rippleConfig`). This enforces the VIPER dependency rule. diff --git a/README.md b/README.md index 2761bdb4..28dcced9 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,26 @@ angle = 15 | `circle` | — | — | Same as omitting `shape` | | `polygon` | `sides` (int `3...256`) | `angle` (degrees, default `0`) | Out-of-range `sides` values fail config decoding. `angle = 0` orients one vertex straight up | +### `[spectrum]` + +Real-time spectrum analyzer bars driven by the now-playing app's audio, rendered on the overlay. Disabled by default. Requires **macOS 14.4+** (CoreAudio process tap); on the first run macOS asks for the *System Audio Recording* permission. + +| Key | Type | Default | Description | +|---|---|---|---| +| `enabled` | boolean | `false` | Set to `true` to show the analyzer | +| `bar_count` | number | `64` | Number of bars | +| `bar_color` | string / array | `["#1E3A5F", "#4A9EFF"]` | Solid hex or gradient array | +| `background_color` | string | — | Optional backdrop behind the bars | +| `bar_width_ratio` | number | `0.7` | Bar width as a fraction of one bar slot (0–1) | +| `min_db` | number | `-80` | Loudness floor mapped to bar height 0 | +| `max_db` | number | `0` | Loudness ceiling mapped to bar height 1 | +| `decay_rate` | number | `0.85` | Per-frame falloff of bar peaks (0–1) | +| `fft_size` | number | `1024` | FFT window size (rounded down to a power of two) | +| `placement` | string | `"bottom"` | `"bottom"`, `"top"`, or `"underlay"` (bars span the whole overlay behind the lyrics) | +| `height_ratio` | number | `0.25` | Fraction of the overlay height the bars may occupy (ignored for `underlay`) | + +> **Known limitation:** the audio is tapped per *process*. When the now-playing app is a browser, the tap captures the browser's entire audio output — every tab, not just the one playing music. + ### `[ai]` Optional LLM-based song title and artist extraction via any OpenAI-compatible API. When omitted, lyra uses regex-based parsing only. All three fields are required to enable this feature. @@ -363,6 +383,11 @@ color = "#AAAAFFFF" radius = 60 duration = 0.4 idle = 1.3 + +[spectrum] +enabled = true +bar_color = ["#1E3A5F", "#4A9EFF"] +placement = "bottom" ``` This example uses `Zen Maru Gothic` and `Zen Kaku Gothic New`. If those fonts are not installed, install them with Homebrew Cask: @@ -382,7 +407,7 @@ api_key = "sk-..." ## Requirements -- macOS 14+ +- macOS 14+ (spectrum analyzer requires macOS 14.4+) - Swift 6.0+ ## License From 002e5d76dd59de8d4b7213c4179665fab08ac279 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 3 Jul 2026 19:41:11 +0900 Subject: [PATCH 04/13] =?UTF-8?q?chore(#23):=20=E3=83=90=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=82=92=202.18.0=20=E3=81=AB=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/VersionHandler/Resources/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index d76bd2ba..cf869073 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.17.0 +2.18.0 From a3c72786fdfe95a81c2cd25d4a0d131737fd7cf2 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 3 Jul 2026 20:28:48 +0900 Subject: [PATCH 05/13] =?UTF-8?q?fix(#23):=20=E3=82=BF=E3=83=83=E3=83=97?= =?UTF-8?q?=E5=AF=BE=E8=B1=A1=E3=82=92=20now-playing=20pid=20=E3=81=AE?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=82=BB=E3=82=B9=E3=82=B5=E3=83=96=E3=83=84?= =?UTF-8?q?=E3=83=AA=E3=83=BC=E5=85=A8=E4=BD=93=E3=81=AB=E6=8B=A1=E5=BC=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium 系ブラウザは音声出力を専用ヘルパープロセス (例: Arc の Browser Helper) が担うため、メイン pid だけをタップすると無音になる。 kAudioHardwarePropertyProcessObjectList を列挙し ppid ウォークで サブツリーに属するプロセスオブジェクトを全て CATapDescription に渡す。 Arc + 実機検証でバー描画を確認済み。 --- .claude/CLAUDE.md | 2 +- README.md | 2 +- .../AudioTapDataSource/ProcessTapEngine.swift | 70 ++++++++++++++----- 3 files changed, 53 insertions(+), 21 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 92b5c944..cae4807e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -240,7 +240,7 @@ Presenters subscribe to Interactors via Combine. Interactors access UseCases via **FetchState\**: Generic enum (`.idle`, `.loading`, `.revealing(T)`, `.success(T)`, `.failure`) drives both data flow and UI animation. The `.revealing` → `.success` transition is timed by Presenters using `DecodeEffectState`. Use `FetchState` only when the payload `T` is genuinely consumed downstream (e.g. `LyricsPresenter.lyricsState`, whose content feeds `columns(in:)` and `updateActiveLineTick()`). When a Presenter only needs the animation lifecycle and the View already renders the text from a separate `display…` property, expose the payload-less `RevealPhase` (`.idle` / `.revealing` / `.revealed`) instead and keep the decode target in a private field — `HeaderPresenter` does this for `titlePhase` / `artistPhase` so the public surface never duplicates `displayTitle` / `displayArtist` (#275). -**Spectrum analyzer (#23)**: Real-time bars driven by the now-playing app's audio via a CoreAudio **process tap** (macOS 14.4+ APIs: `kAudioHardwarePropertyTranslatePIDToProcessObject` → `CATapDescription(stereoMixdownOfProcesses:)` → `AudioHardwareCreateProcessTap` → private aggregate device + `AudioDeviceCreateIOProcIDWithBlock`; requires the *System Audio Recording* TCC permission declared as `NSAudioCaptureUsageDescription` in the embedded Info.plist). The pipeline is: `TrackInteractor.audioSource` (an `AudioSourceState(pid:isPlaying:)` stream derived from the shared NowPlaying publisher — **never a second helper stream**, the MediaRemote helper supports one consumer) → `SpectrumInteractorImpl` serializes tap create/destroy transitions behind a chained `Task` so pause/play bursts cannot interleave → `AudioTapDataSourceImpl` owns `ProcessTapEngine` (`@available(macOS 14.4, *)`, availability-erased as `AnyObject` for the 14.0 target) and a lock-free SPSC `SampleRingBuffer` (swift-atomics `ManagedAtomic` monotonic write index; the IOProc callback is RT-safe — no allocation, locks, or Swift concurrency). `FrequencyAnalyzer` (pure, dependency-free module) converts the newest PCM window: Hann window → vDSP FFT → dB → 0…1 normalization → per-bar max grouping. `SpectrumPresenter.tick()` runs on the DisplayLink and folds fresh magnitudes into exponentially decaying bars (`decayRate`); `binHeights()` is read-only so the Canvas draw closure never mutates `@Published` state. `SpectrumView` follows the #252/#258 zero-idle-cost pattern (conditional inclusion + `TimelineView(.animation(paused:))`). Tap lifecycle: playing+pid → create; paused/pid-lost/session-gone → destroy (a dead tap costs zero CPU). Known limitation (by decision): the tap captures the whole process — for browsers that means every tab, documented in README. +**Spectrum analyzer (#23)**: Real-time bars driven by the now-playing app's audio via a CoreAudio **process tap** (macOS 14.4+ APIs: `kAudioHardwarePropertyProcessObjectList` filtered to the now-playing pid's **process subtree** → `CATapDescription(stereoMixdownOfProcesses:)` → `AudioHardwareCreateProcessTap` → private aggregate device + `AudioDeviceCreateIOProcIDWithBlock`; requires the *System Audio Recording* TCC permission declared as `NSAudioCaptureUsageDescription` in the embedded Info.plist). The subtree matching (ppid walk via `proc_pidinfo`) is load-bearing: Chromium-based browsers emit audio from a helper subprocess, so a tap scoped to the main pid alone captures silence — empirically hit with Arc, whose "Browser Helper" child owns the audio stream. The pipeline is: `TrackInteractor.audioSource` (an `AudioSourceState(pid:isPlaying:)` stream derived from the shared NowPlaying publisher — **never a second helper stream**, the MediaRemote helper supports one consumer) → `SpectrumInteractorImpl` serializes tap create/destroy transitions behind a chained `Task` so pause/play bursts cannot interleave → `AudioTapDataSourceImpl` owns `ProcessTapEngine` (`@available(macOS 14.4, *)`, availability-erased as `AnyObject` for the 14.0 target) and a lock-free SPSC `SampleRingBuffer` (swift-atomics `ManagedAtomic` monotonic write index; the IOProc callback is RT-safe — no allocation, locks, or Swift concurrency). `FrequencyAnalyzer` (pure, dependency-free module) converts the newest PCM window: Hann window → vDSP FFT → dB → 0…1 normalization → per-bar max grouping. `SpectrumPresenter.tick()` runs on the DisplayLink and folds fresh magnitudes into exponentially decaying bars (`decayRate`); `binHeights()` is read-only so the Canvas draw closure never mutates `@Published` state. `SpectrumView` follows the #252/#258 zero-idle-cost pattern (conditional inclusion + `TimelineView(.animation(paused:))`). Tap lifecycle: playing+pid → create; paused/pid-lost/session-gone → destroy (a dead tap costs zero CPU). Known limitation (by decision): the tap captures the whole process tree — for browsers that means every tab, documented in README. TCC caveat for dev runs: a daemon spawned from a terminal inherits the terminal as TCC responsible process, so the permission prompt never appears and the tap reads silence — launch via launchd (LaunchAgent) so lyra itself is the responsible process. **AI processing indicator (#57)**: While the AI (LLM) extractor resolves title/artist on a cache miss, the header scrambles in a configurable color so the user sees that work is happening. `TrackInteractorImpl.resolveTrack` emits an extra `TrackUpdate(aiResolving: true)` after the debounce only when an `[ai]` endpoint is configured **and** `MetadataUseCase.isAIMetadataCached(track:)` returns `false` (an LLM cache hit means no API round-trip, so no indicator). `HeaderPresenter` maps `aiResolving` to `DecodeEffectState.startLoading` (the indefinite scramble, distinct from `decode`'s settle) and swaps `titleColor` / `artistColor` to `DecodeEffect.processingColor` (default green `#4ADE80FF`, config key `text.decode_effect.processing_color`, solid or gradient). The resolved (non-`aiResolving`) update settles the scramble and restores the normal color. `HeaderView` reads the effective `titleColor` / `artistColor` (`@Published`) rather than the static `titleStyle.color`. diff --git a/README.md b/README.md index 28dcced9..432b2a0c 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ Real-time spectrum analyzer bars driven by the now-playing app's audio, rendered | `placement` | string | `"bottom"` | `"bottom"`, `"top"`, or `"underlay"` (bars span the whole overlay behind the lyrics) | | `height_ratio` | number | `0.25` | Fraction of the overlay height the bars may occupy (ignored for `underlay`) | -> **Known limitation:** the audio is tapped per *process*. When the now-playing app is a browser, the tap captures the browser's entire audio output — every tab, not just the one playing music. +> **Known limitation:** the audio is tapped per *process tree* (browsers emit audio from helper subprocesses, so the whole tree must be covered). When the now-playing app is a browser, the tap captures the browser's entire audio output — every tab, not just the one playing music. ### `[ai]` diff --git a/Sources/AudioTapDataSource/ProcessTapEngine.swift b/Sources/AudioTapDataSource/ProcessTapEngine.swift index f480f2b9..283a3af5 100644 --- a/Sources/AudioTapDataSource/ProcessTapEngine.swift +++ b/Sources/AudioTapDataSource/ProcessTapEngine.swift @@ -21,11 +21,12 @@ final class ProcessTapEngine { init?(pid: Int, ring: SampleRingBuffer) { self.ring = ring - guard let processObject = Self.processObject(for: pid) else { return nil } + let processObjects = Self.processObjects(forSubtreeOf: pid) + guard !processObjects.isEmpty else { return nil } // The tap is private (invisible in Audio MIDI Setup) and keeps the // tapped app audible — the analyzer observes, never mutes. - let description = CATapDescription(stereoMixdownOfProcesses: [processObject]) + let description = CATapDescription(stereoMixdownOfProcesses: processObjects) description.isPrivate = true description.muteBehavior = .unmuted guard AudioHardwareCreateProcessTap(description, &tapID) == noErr, @@ -106,29 +107,60 @@ final class ProcessTapEngine { } } - /// Translates a pid into the CoreAudio process object required by - /// `CATapDescription`. Returns `nil` for processes CoreAudio doesn't know - /// (never launched audio, or no such pid). - private static func processObject(for pid: Int) -> AudioObjectID? { + /// CoreAudio process objects for `pid` and every descendant process. + /// The now-playing pid alone is not enough: browsers (Chromium-based) + /// emit audio from a helper subprocess, so a tap scoped to the main pid + /// captures silence. Covering the whole subtree taps whichever family + /// member actually owns the audio stream. + private static func processObjects(forSubtreeOf pid: Int) -> [AudioObjectID] { var address = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyTranslatePIDToProcessObject, + mSelector: kAudioHardwarePropertyProcessObjectList, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) - var processPid = pid_t(pid) - var processObject = AudioObjectID(kAudioObjectUnknown) - var size = UInt32(MemoryLayout.size) - let status = withUnsafeMutablePointer(to: &processPid) { pidPointer in + var size: UInt32 = 0 + guard + AudioObjectGetPropertyDataSize( + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size) == noErr + else { return [] } + var objects = [AudioObjectID]( + repeating: AudioObjectID(kAudioObjectUnknown), + count: Int(size) / MemoryLayout.size) + guard AudioObjectGetPropertyData( - AudioObjectID(kAudioObjectSystemObject), &address, - UInt32(MemoryLayout.size), pidPointer, - &size, &processObject - ) - } - guard status == noErr, processObject != AudioObjectID(kAudioObjectUnknown) else { - return nil + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &objects) + == noErr + else { return [] } + return objects.filter { isInSubtree(processPid(of: $0), root: pid_t(pid)) } + } + + /// The owning pid of a CoreAudio process object, or `nil` when unreadable. + private static func processPid(of object: AudioObjectID) -> pid_t? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioProcessPropertyPID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var pid: pid_t = -1 + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(object, &address, 0, nil, &size, &pid) == noErr, + pid > 0 + else { return nil } + return pid + } + + /// Whether `pid` equals `root` or has `root` among its ancestors. + private static func isInSubtree(_ pid: pid_t?, root: pid_t) -> Bool { + guard var current = pid else { return false } + while current > 1 { + guard current != root else { return true } + var info = proc_bsdinfo() + let read = proc_pidinfo( + current, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout.size)) + guard read == Int32(MemoryLayout.size) else { return false } + current = pid_t(info.pbi_ppid) } - return processObject + return false } /// Real-time-safe mono mixdown of the first (interleaved) input buffer. From 70fb514ce778fbaf6192f1369d6374c6048a1e01 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 3 Jul 2026 21:04:33 +0900 Subject: [PATCH 06/13] =?UTF-8?q?refactor(#23):=20NowPlayingRepository=20?= =?UTF-8?q?=E3=82=92=20multicast=20=E5=8C=96=E3=81=97=E3=81=A6=20Interacto?= =?UTF-8?q?r=20=E9=96=93=E4=BE=9D=E5=AD=98=E3=82=92=E8=A7=A3=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MediaRemote helper のパイプが単一消費者である制約を TrackInteractor の 共有 publisher で吸収していたため、SpectrumInteractor → TrackInteractor という VIPER の層規律を破るエッジが生じていた。共有点を Repository に 下ろし、stream() を単一ポンプ + 全購読者ブロードキャスト + 直近値 replay の multicast にすることで、SpectrumInteractor は PlaybackUseCase.observeNowPlaying() を直接消費できるようになった。 - NowPlayingRepositoryImpl: 遅延起動の単一 poll ポンプで fan-out、 後着購読者には直近値を即時 replay、EOF で全購読者を finish - SpectrumInteractorImpl: for await 1 本で直列化(AsyncStream 中継を 廃止)、AudioSourceState の重複排除を自前で実施 - TrackInteractor: audioSource を protocol / 実装 / スタブから削除 --- .claude/CLAUDE.md | 6 +- .../AppRouter/AppDependencyBootstrap.swift | 2 - .../Domain/Interactor/TrackInteractor.swift | 4 - .../NowPlayingRepositoryImpl.swift | 101 +++++++++++++++--- .../SpectrumInteractorImpl.swift | 74 +++++++------ .../TrackInteractor/TrackInteractorImpl.swift | 11 -- .../AppLaunchEnvironmentTests.swift | 4 - .../NowPlayingRepositoryTests.swift | 86 +++++++++++++++ .../HeaderPresenterDuplicateTests.swift | 1 - .../HeaderPresenterTests.swift | 1 - .../LyricsPresenterColumnsTests.swift | 1 - .../LyricsPresenterDuplicateTests.swift | 1 - .../LyricsPresenterTests.swift | 1 - .../SpectrumInteractorImplTests.swift | 77 +++++++++---- Tests/ViewsTests/ViewRenderingTests.swift | 2 - 15 files changed, 269 insertions(+), 103 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index cae4807e..1bc3548e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -136,7 +136,7 @@ graph TD TrackInteractor -.-> PlaybackUseCase & MetadataUseCase & LyricsUseCase & ConfigUseCase ScreenInteractor -.-> ConfigUseCase WallpaperInteractor -.-> WallpaperUseCase & ConfigUseCase - SpectrumInteractor -.-> ConfigUseCase & TrackInteractor & AudioTapDataSource + SpectrumInteractor -.-> ConfigUseCase & PlaybackUseCase & AudioTapDataSource SpectrumInteractor --> FrequencyAnalyzer ConfigUseCase -.-> ConfigRepository ConfigRepository -.-> ConfigDataSource @@ -240,7 +240,7 @@ Presenters subscribe to Interactors via Combine. Interactors access UseCases via **FetchState\**: Generic enum (`.idle`, `.loading`, `.revealing(T)`, `.success(T)`, `.failure`) drives both data flow and UI animation. The `.revealing` → `.success` transition is timed by Presenters using `DecodeEffectState`. Use `FetchState` only when the payload `T` is genuinely consumed downstream (e.g. `LyricsPresenter.lyricsState`, whose content feeds `columns(in:)` and `updateActiveLineTick()`). When a Presenter only needs the animation lifecycle and the View already renders the text from a separate `display…` property, expose the payload-less `RevealPhase` (`.idle` / `.revealing` / `.revealed`) instead and keep the decode target in a private field — `HeaderPresenter` does this for `titlePhase` / `artistPhase` so the public surface never duplicates `displayTitle` / `displayArtist` (#275). -**Spectrum analyzer (#23)**: Real-time bars driven by the now-playing app's audio via a CoreAudio **process tap** (macOS 14.4+ APIs: `kAudioHardwarePropertyProcessObjectList` filtered to the now-playing pid's **process subtree** → `CATapDescription(stereoMixdownOfProcesses:)` → `AudioHardwareCreateProcessTap` → private aggregate device + `AudioDeviceCreateIOProcIDWithBlock`; requires the *System Audio Recording* TCC permission declared as `NSAudioCaptureUsageDescription` in the embedded Info.plist). The subtree matching (ppid walk via `proc_pidinfo`) is load-bearing: Chromium-based browsers emit audio from a helper subprocess, so a tap scoped to the main pid alone captures silence — empirically hit with Arc, whose "Browser Helper" child owns the audio stream. The pipeline is: `TrackInteractor.audioSource` (an `AudioSourceState(pid:isPlaying:)` stream derived from the shared NowPlaying publisher — **never a second helper stream**, the MediaRemote helper supports one consumer) → `SpectrumInteractorImpl` serializes tap create/destroy transitions behind a chained `Task` so pause/play bursts cannot interleave → `AudioTapDataSourceImpl` owns `ProcessTapEngine` (`@available(macOS 14.4, *)`, availability-erased as `AnyObject` for the 14.0 target) and a lock-free SPSC `SampleRingBuffer` (swift-atomics `ManagedAtomic` monotonic write index; the IOProc callback is RT-safe — no allocation, locks, or Swift concurrency). `FrequencyAnalyzer` (pure, dependency-free module) converts the newest PCM window: Hann window → vDSP FFT → dB → 0…1 normalization → per-bar max grouping. `SpectrumPresenter.tick()` runs on the DisplayLink and folds fresh magnitudes into exponentially decaying bars (`decayRate`); `binHeights()` is read-only so the Canvas draw closure never mutates `@Published` state. `SpectrumView` follows the #252/#258 zero-idle-cost pattern (conditional inclusion + `TimelineView(.animation(paused:))`). Tap lifecycle: playing+pid → create; paused/pid-lost/session-gone → destroy (a dead tap costs zero CPU). Known limitation (by decision): the tap captures the whole process tree — for browsers that means every tab, documented in README. TCC caveat for dev runs: a daemon spawned from a terminal inherits the terminal as TCC responsible process, so the permission prompt never appears and the tap reads silence — launch via launchd (LaunchAgent) so lyra itself is the responsible process. +**Spectrum analyzer (#23)**: Real-time bars driven by the now-playing app's audio via a CoreAudio **process tap** (macOS 14.4+ APIs: `kAudioHardwarePropertyProcessObjectList` filtered to the now-playing pid's **process subtree** → `CATapDescription(stereoMixdownOfProcesses:)` → `AudioHardwareCreateProcessTap` → private aggregate device + `AudioDeviceCreateIOProcIDWithBlock`; requires the *System Audio Recording* TCC permission declared as `NSAudioCaptureUsageDescription` in the embedded Info.plist). The subtree matching (ppid walk via `proc_pidinfo`) is load-bearing: Chromium-based browsers emit audio from a helper subprocess, so a tap scoped to the main pid alone captures silence — empirically hit with Arc, whose "Browser Helper" child owns the audio stream. The pipeline is: `PlaybackUseCase.observeNowPlaying()` (backed by the **multicast** `NowPlayingRepository.stream()`, so no Interactor→Interactor dependency and no second helper stream) → `SpectrumInteractorImpl` consumes the stream in a single `for await` processor task — inherent serialization, so pause/play bursts cannot interleave tap create/destroy — deduping `AudioSourceState(pid:isPlaying:)` transitions itself (the helper's 3 s ticks must not rebuild the tap) → `AudioTapDataSourceImpl` owns `ProcessTapEngine` (`@available(macOS 14.4, *)`, availability-erased as `AnyObject` for the 14.0 target) and a lock-free SPSC `SampleRingBuffer` (swift-atomics `ManagedAtomic` monotonic write index; the IOProc callback is RT-safe — no allocation, locks, or Swift concurrency). `FrequencyAnalyzer` (pure, dependency-free module) converts the newest PCM window: Hann window → vDSP FFT → dB → 0…1 normalization → per-bar max grouping. `SpectrumPresenter.tick()` runs on the DisplayLink and folds fresh magnitudes into exponentially decaying bars (`decayRate`); `binHeights()` is read-only so the Canvas draw closure never mutates `@Published` state. `SpectrumView` follows the #252/#258 zero-idle-cost pattern (conditional inclusion + `TimelineView(.animation(paused:))`). Tap lifecycle: playing+pid → create; paused/pid-lost/session-gone → destroy (a dead tap costs zero CPU). Known limitation (by decision): the tap captures the whole process tree — for browsers that means every tab, documented in README. TCC caveat for dev runs: a daemon spawned from a terminal inherits the terminal as TCC responsible process, so the permission prompt never appears and the tap reads silence — launch via launchd (LaunchAgent) so lyra itself is the responsible process. **AI processing indicator (#57)**: While the AI (LLM) extractor resolves title/artist on a cache miss, the header scrambles in a configurable color so the user sees that work is happening. `TrackInteractorImpl.resolveTrack` emits an extra `TrackUpdate(aiResolving: true)` after the debounce only when an `[ai]` endpoint is configured **and** `MetadataUseCase.isAIMetadataCached(track:)` returns `false` (an LLM cache hit means no API round-trip, so no indicator). `HeaderPresenter` maps `aiResolving` to `DecodeEffectState.startLoading` (the indefinite scramble, distinct from `decode`'s settle) and swaps `titleColor` / `artistColor` to `DecodeEffect.processingColor` (default green `#4ADE80FF`, config key `text.decode_effect.processing_color`, solid or gradient). The resolved (non-`aiResolving`) update settles the scramble and restores the normal color. `HeaderView` reads the effective `titleColor` / `artistColor` (`@Published`) rather than the static `titleStyle.color`. @@ -301,7 +301,7 @@ Cache is Repository's responsibility, not DataSource's. DataSources are pure API **Track command**: `lyra track` outputs currently playing track info as JSON. Flags: `--resolve` (`-r`) resolves metadata via MusicBrainz/regex, `--lyrics` (`-l`) fetches lyrics from LRCLIB. The two flags are independent and combinable (`-rl`). Default (no flags) returns raw MediaRemote data. Uses `PlaybackUseCase.fetchNowPlaying()` (one-shot) + `MetadataUseCase` + `LyricsUseCase` via `@Dependency`. Output type is `NowPlayingInfo` (Codable). -**NowPlayingRepository dual API**: `fetch()` for one-shot retrieval (used by CLI `track` command), `stream()` for continuous observation (used by GUI via `TrackInteractor`). `PlaybackUseCase` mirrors both: `fetchNowPlaying()` and `observeNowPlaying()`. +**NowPlayingRepository dual API**: `fetch()` for one-shot retrieval (used by CLI `track` command), `stream()` for continuous observation (used by GUI via `TrackInteractor` and `SpectrumInteractor`). `PlaybackUseCase` mirrors both: `fetchNowPlaying()` and `observeNowPlaying()`. **`stream()` is multicast (#23)**: the MediaRemote helper pipe supports only one poll loop, so the repository runs a single lazily-started pump, broadcasts every event to all subscriber continuations, and replays the last value to late subscribers. Any layer may therefore call `observeNowPlaying()` freely — never work around the single-consumer pipe by wiring one Interactor to another. **AsyncRunnableCommand vs AsyncParsableCommand**: `@main AsyncParsableCommand` starts Swift's cooperative thread pool and takes ownership of the main thread. `NSApplication.run()` must own the main thread exclusively for SwiftUI rendering. The two are fundamentally incompatible — when both compete, the overlay window is blank. `AsyncRunnableCommand` protocol solves this by keeping `RootCommand` as sync `ParsableCommand` (main thread free for NSApplication) while bridging async subcommands (`TrackCommand`, `HealthcheckCommand`) via `DispatchSemaphore` on a cooperative thread pool thread. `DaemonCommand` stays sync and enters `MainActor.assumeIsolated` to call the App foreground runner, which owns `NSApplication.run()` on the main thread. diff --git a/Sources/AppRouter/AppDependencyBootstrap.swift b/Sources/AppRouter/AppDependencyBootstrap.swift index c0397733..822ac9ef 100644 --- a/Sources/AppRouter/AppDependencyBootstrap.swift +++ b/Sources/AppRouter/AppDependencyBootstrap.swift @@ -70,7 +70,6 @@ import Foundation let trackChange: AnyPublisher let artwork: AnyPublisher let playbackPosition: AnyPublisher - let audioSource: AnyPublisher let decodeEffectConfig: DecodeEffect let textLayout: TextLayout let artworkStyle: ArtworkStyle @@ -80,7 +79,6 @@ import Foundation trackChange = Just(fixture.trackUpdate).eraseToAnyPublisher() artwork = Just(nil).eraseToAnyPublisher() playbackPosition = Just(PlaybackPosition(rawElapsed: nil, timestamp: nil, playbackRate: 0)).eraseToAnyPublisher() - audioSource = Just(AudioSourceState(pid: nil, isPlaying: false)).eraseToAnyPublisher() decodeEffectConfig = decodeEffect textLayout = TextLayout(decodeEffect: decodeEffect) artworkStyle = ArtworkStyle(opacity: 0) diff --git a/Sources/Domain/Interactor/TrackInteractor.swift b/Sources/Domain/Interactor/TrackInteractor.swift index 1ebdf0f0..01fd3eb3 100644 --- a/Sources/Domain/Interactor/TrackInteractor.swift +++ b/Sources/Domain/Interactor/TrackInteractor.swift @@ -9,9 +9,6 @@ public protocol TrackInteractor: Sendable { var artwork: AnyPublisher { get } /// Emits continuously for playback position updates. var playbackPosition: AnyPublisher { get } - /// Emits when the now-playing app's process id or audibility changes. - /// The spectrum analyzer scopes its CoreAudio process tap with this (#23). - var audioSource: AnyPublisher { get } var decodeEffectConfig: DecodeEffect { get } var textLayout: TextLayout { get } var artworkStyle: ArtworkStyle { get } @@ -32,7 +29,6 @@ private struct UnimplementedTrackInteractor: TrackInteractor { var trackChange: AnyPublisher { Empty().eraseToAnyPublisher() } var artwork: AnyPublisher { Empty().eraseToAnyPublisher() } var playbackPosition: AnyPublisher { Empty().eraseToAnyPublisher() } - var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } var decodeEffectConfig: DecodeEffect { .init() } var textLayout: TextLayout { .init() } var artworkStyle: ArtworkStyle { .init() } diff --git a/Sources/NowPlayingRepository/NowPlayingRepositoryImpl.swift b/Sources/NowPlayingRepository/NowPlayingRepositoryImpl.swift index bdb35785..bb3e8f9c 100644 --- a/Sources/NowPlayingRepository/NowPlayingRepositoryImpl.swift +++ b/Sources/NowPlayingRepository/NowPlayingRepositoryImpl.swift @@ -1,11 +1,31 @@ import Dependencies import Domain import Foundation +import os -public struct NowPlayingRepositoryImpl: Sendable { - @Dependency(\.mediaRemoteDataSource) private var dataSource +/// Multicasting repository over the single-consumer MediaRemote helper pipe. +/// +/// `MediaRemoteDataSource.poll()` advances one shared iterator, so only one +/// poll loop may run per process. `stream()` therefore fans a single pump out +/// to any number of subscribers instead of starting a loop per call: the +/// first subscriber lazily starts the pump, every event is broadcast to all +/// live continuations, and a late subscriber immediately receives the last +/// seen value so it doesn't wait for the next helper tick (#23). +public final class NowPlayingRepositoryImpl: Sendable { + private struct Hub: Sendable { + var continuations: [UUID: AsyncStream.Continuation] = [:] + /// Last broadcast payload; `.some(nil)` means "session gone" was seen. + var last: NowPlaying?? = nil + var pump: Task? + } + + private let dataSource: any MediaRemoteDataSource + private let hub = OSAllocatedUnfairLock(initialState: Hub()) - public init() {} + public init() { + @Dependency(\.mediaRemoteDataSource) var dataSource + self.dataSource = dataSource + } } extension NowPlayingRepositoryImpl: NowPlayingRepository { @@ -17,22 +37,69 @@ extension NowPlayingRepositoryImpl: NowPlayingRepository { } public func stream() -> AsyncStream { - let dataSource = self.dataSource - return AsyncStream { continuation in - let task = Task { - while !Task.isCancelled { - switch await dataSource.poll() { - case .info(let nowPlaying): - continuation.yield(nowPlaying) - case .noInfo: - continuation.yield(nil) - case .eof: - continuation.finish() - return - } + AsyncStream { continuation in + let id = UUID() + let replay = hub.withLock { state in + state.continuations[id] = continuation + return state.last + } + if let value = replay { continuation.yield(value) } + continuation.onTermination = { [hub] _ in + hub.withLock { $0.continuations[id] = nil } + } + ensurePumping() + } + } +} + +extension NowPlayingRepositoryImpl { + /// Starts the shared poll pump if it isn't running. The candidate task is + /// created outside the lock (task creation inside `withLock` trips region + /// isolation); a lost creation race is cancelled, and since every pump + /// broadcasts to all continuations, the overlap window loses no events. + private func ensurePumping() { + guard hub.withLock({ $0.pump == nil }) else { return } + let candidate = pumpTask() + let adopted = hub.withLock { state in + guard state.pump == nil else { return false } + state.pump = candidate + return true + } + guard adopted else { + candidate.cancel() + return + } + } + + private func pumpTask() -> Task { + Task { + while !Task.isCancelled { + switch await dataSource.poll() { + case .info(let nowPlaying): broadcast(nowPlaying) + case .noInfo: broadcast(nil) + case .eof: + finishAll() + return } } - continuation.onTermination = { _ in task.cancel() } } } + + private func broadcast(_ value: NowPlaying?) { + let continuations = hub.withLock { state in + state.last = .some(value) + return Array(state.continuations.values) + } + for continuation in continuations { continuation.yield(value) } + } + + /// The helper pipe hit EOF: finish every subscriber and reset the hub so + /// a later subscriber starts a fresh pump (which will re-observe EOF). + private func finishAll() { + let continuations = hub.withLock { state in + defer { state = Hub() } + return Array(state.continuations.values) + } + for continuation in continuations { continuation.finish() } + } } diff --git a/Sources/SpectrumInteractor/SpectrumInteractorImpl.swift b/Sources/SpectrumInteractor/SpectrumInteractorImpl.swift index 0c8f0591..39d5444f 100644 --- a/Sources/SpectrumInteractor/SpectrumInteractorImpl.swift +++ b/Sources/SpectrumInteractor/SpectrumInteractorImpl.swift @@ -5,9 +5,9 @@ import Foundation import FrequencyAnalyzer import os -/// Drives the spectrum analyzer (#23): follows `TrackInteractor.audioSource`, -/// keeps the CoreAudio process tap scoped to the now-playing app, and converts -/// the captured PCM window into per-bar magnitudes on demand. +/// Drives the spectrum analyzer (#23): follows the now-playing stream, keeps +/// the CoreAudio process tap scoped to the now-playing app, and converts the +/// captured PCM window into per-bar magnitudes on demand. /// /// Tap lifecycle rules: /// - playing + known pid → (re)create the tap for that pid @@ -18,20 +18,14 @@ import os /// `@unchecked` only because of the lazy `analyzer`, which is touched solely /// from the main-thread DisplayLink tick via `magnitudes()`. public final class SpectrumInteractorImpl: @unchecked Sendable { - private struct Pipeline { - var cancellable: AnyCancellable? - var processor: Task? - var continuation: AsyncStream.Continuation? - } - // Stored wrappers capture the dependency context at init, so instances // built inside `withDependencies` keep their fakes when methods run - // outside that scope (sinks, the processor task). + // outside that scope (the processor task). @Dependency(\.configUseCase) private var configService - @Dependency(\.trackInteractor) private var trackInteractor + @Dependency(\.playbackUseCase) private var playbackService @Dependency(\.audioTapDataSource) private var tap private let capturingSubject = CurrentValueSubject(false) - private let pipeline = OSAllocatedUnfairLock(uncheckedState: Pipeline()) + private let processor = OSAllocatedUnfairLock(initialState: Task?.none) private lazy var analyzer = FrequencyAnalyzer( fftSize: spectrumStyle.fftSize, barCount: spectrumStyle.barCount, @@ -55,12 +49,18 @@ extension SpectrumInteractorImpl: SpectrumInteractor { guard spectrumStyle.enabled else { return } let tap = tap let subject = capturingSubject - // The stream is the serialization point: audio-source events queue up - // and the single processor task applies them one at a time, so a rapid - // pause→play→pause burst can never interleave tap create/destroy calls. - let (stream, continuation) = AsyncStream.makeStream() - let processor = Task { - for await source in stream { + let playback = playbackService + // A single for-await loop is the serialization point: events apply + // one at a time, so a rapid pause→play burst can never interleave tap + // create/destroy calls. `previous` dedupes the helper's periodic + // ticks — `startTap` rebuilds the engine, so repeats must not pass. + let candidate = Task { + var previous: AudioSourceState? + for await info in playback.observeNowPlaying() { + let source = AudioSourceState( + pid: info?.pid, isPlaying: (info?.playbackRate ?? 0) > 0) + guard source != previous else { continue } + previous = source guard let pid = source.pid, source.isPlaying else { await tap.stopTap() subject.send(false) @@ -68,36 +68,40 @@ extension SpectrumInteractorImpl: SpectrumInteractor { } subject.send(await tap.startTap(pid: pid)) } - // Stream finished (stop()): tear the tap down after the last event. + // Upstream finished (helper EOF): tear the tap down. A cancelled + // task skips this — stop() owns that teardown, and a start/start + // race loser must not destroy the winner's tap. guard !Task.isCancelled else { return } await tap.stopTap() subject.send(false) } - let cancellable = trackInteractor.audioSource - .sink { continuation.yield($0) } - let adopted = pipeline.withLockUnchecked { state in - guard state.cancellable == nil else { return false } - state = Pipeline( - cancellable: cancellable, processor: processor, continuation: continuation) + let adopted = processor.withLock { task in + guard task == nil else { return false } + task = candidate return true } guard adopted else { - // Lost a start/start race: discard this pipeline without touching - // the winner's tap (the cancellation guard skips the teardown). - cancellable.cancel() - processor.cancel() - continuation.finish() + candidate.cancel() return } } public func stop() { - let stopped = pipeline.withLockUnchecked { state in - defer { state = Pipeline() } - return state + let stopped = processor.withLock { task in + defer { task = nil } + return task + } + guard let stopped else { return } + stopped.cancel() + let tap = tap + let subject = capturingSubject + Task { + // Awaiting the cancelled processor first keeps this teardown + // ordered after its in-flight tap call. + await stopped.value + await tap.stopTap() + subject.send(false) } - stopped.cancellable?.cancel() - stopped.continuation?.finish() } public func magnitudes() -> [Float] { diff --git a/Sources/TrackInteractor/TrackInteractorImpl.swift b/Sources/TrackInteractor/TrackInteractorImpl.swift index 6b25835d..2ec777d8 100644 --- a/Sources/TrackInteractor/TrackInteractorImpl.swift +++ b/Sources/TrackInteractor/TrackInteractorImpl.swift @@ -83,17 +83,6 @@ public final class TrackInteractorImpl: @unchecked Sendable { } .eraseToAnyPublisher() - /// Emits when the now-playing app's process id or audibility changes. - /// Built from the raw shared stream — not `activeNowPlaying` — so a - /// vanished session (nil payload) still tears the spectrum tap down (#23). - public lazy var audioSource: AnyPublisher = - shared - .map { np in - AudioSourceState(pid: np?.pid, isPlaying: (np?.playbackRate ?? 0) > 0) - } - .removeDuplicates() - .eraseToAnyPublisher() - public init() { @Dependency(\.playbackUseCase) var playback @Dependency(\.lyricsUseCase) var lyrics diff --git a/Tests/AppRouterTests/AppLaunchEnvironmentTests.swift b/Tests/AppRouterTests/AppLaunchEnvironmentTests.swift index d59cc198..a64ab678 100644 --- a/Tests/AppRouterTests/AppLaunchEnvironmentTests.swift +++ b/Tests/AppRouterTests/AppLaunchEnvironmentTests.swift @@ -99,10 +99,6 @@ private struct FixtureTrackInteractor: TrackInteractor, @unchecked Sendable { Empty().eraseToAnyPublisher() } - var audioSource: AnyPublisher { - Empty().eraseToAnyPublisher() - } - var decodeEffectConfig: DecodeEffect { .init(duration: 0) } var textLayout: TextLayout { .init(decodeEffect: .init(duration: 0)) } var artworkStyle: ArtworkStyle { .init(opacity: 0) } diff --git a/Tests/NowPlayingRepositoryTests/NowPlayingRepositoryTests.swift b/Tests/NowPlayingRepositoryTests/NowPlayingRepositoryTests.swift index 422f7d0e..9b46db8a 100644 --- a/Tests/NowPlayingRepositoryTests/NowPlayingRepositoryTests.swift +++ b/Tests/NowPlayingRepositoryTests/NowPlayingRepositoryTests.swift @@ -153,6 +153,70 @@ struct NowPlayingRepositoryTests { #expect(result?.timestamp == timestamp) } } + + // MARK: - multicast (#23) + + @Test("two subscribers both receive every event") + func multicastTwoSubscribers() async { + let track = NowPlaying( + title: "Shared", artist: "Artist", artworkData: nil, + duration: 100, rawElapsed: 0, playbackRate: 1, timestamp: Date() + ) + let gate = GatedMediaRemoteDataSource() + + await withDependencies { + $0.mediaRemoteDataSource = gate + } operation: { + let repo = NowPlayingRepositoryImpl() + let streamA = repo.stream() + let streamB = repo.stream() + let collectA = Task { + await streamA.reduce(into: [NowPlaying?]()) { $0.append($1) } + } + let collectB = Task { + await streamB.reduce(into: [NowPlaying?]()) { $0.append($1) } + } + + gate.send(.info(track)) + gate.send(.noInfo) + gate.send(.eof) + + let a = await collectA.value + let b = await collectB.value + #expect(a.count == 2) + #expect(a[0]?.title == "Shared") + #expect(a[1] == nil) + #expect(b.count == 2) + #expect(b[0]?.title == "Shared") + #expect(b[1] == nil) + } + } + + @Test("late subscriber immediately receives the last value") + func lateSubscriberReplay() async { + let track = NowPlaying( + title: "Replayed", artist: "Artist", artworkData: nil, + duration: 100, rawElapsed: 0, playbackRate: 1, timestamp: Date() + ) + let gate = GatedMediaRemoteDataSource() + + await withDependencies { + $0.mediaRemoteDataSource = gate + } operation: { + let repo = NowPlayingRepositoryImpl() + var iteratorA = repo.stream().makeAsyncIterator() + gate.send(.info(track)) + // Once A observes the broadcast, the hub has cached it for replay. + let first = await iteratorA.next() + #expect(first??.title == "Replayed") + + var iteratorB = repo.stream().makeAsyncIterator() + let replayed = await iteratorB.next() + #expect(replayed??.title == "Replayed") + + gate.send(.eof) + } + } } // MARK: - Mock @@ -172,3 +236,25 @@ private final class MockMediaRemoteDataSource: MediaRemoteDataSource, @unchecked return result } } + +/// Blocks `poll()` until the test feeds a result, so multicast tests control +/// exactly when the repository's pump observes each event. Only the single +/// pump consumes `iterator`, matching the live single-poller contract. +private final class GatedMediaRemoteDataSource: MediaRemoteDataSource, @unchecked Sendable { + private var iterator: AsyncStream.AsyncIterator + private let feed: AsyncStream.Continuation + + init() { + let (stream, continuation) = AsyncStream.makeStream() + iterator = stream.makeAsyncIterator() + feed = continuation + } + + func send(_ result: MediaRemotePollResult) { + feed.yield(result) + } + + func poll() async -> MediaRemotePollResult { + await iterator.next() ?? .eof + } +} diff --git a/Tests/PresentersTests/HeaderPresenterDuplicateTests.swift b/Tests/PresentersTests/HeaderPresenterDuplicateTests.swift index fce23ba9..c40b9217 100644 --- a/Tests/PresentersTests/HeaderPresenterDuplicateTests.swift +++ b/Tests/PresentersTests/HeaderPresenterDuplicateTests.swift @@ -19,7 +19,6 @@ private struct StubTrackInteractor: TrackInteractor, @unchecked Sendable { var trackChange: AnyPublisher { trackChangePublisher } var artwork: AnyPublisher { artworkPublisher } var playbackPosition: AnyPublisher { Empty().eraseToAnyPublisher() } - var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } } // MARK: - Helpers diff --git a/Tests/PresentersTests/HeaderPresenterTests.swift b/Tests/PresentersTests/HeaderPresenterTests.swift index adb0b38b..105a5c97 100644 --- a/Tests/PresentersTests/HeaderPresenterTests.swift +++ b/Tests/PresentersTests/HeaderPresenterTests.swift @@ -17,7 +17,6 @@ private struct StubTrackInteractor: TrackInteractor, @unchecked Sendable { var trackChange: AnyPublisher { trackChangePublisher } var artwork: AnyPublisher { Empty().eraseToAnyPublisher() } var playbackPosition: AnyPublisher { Empty().eraseToAnyPublisher() } - var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } } // MARK: - Helpers diff --git a/Tests/PresentersTests/LyricsPresenterColumnsTests.swift b/Tests/PresentersTests/LyricsPresenterColumnsTests.swift index 165468aa..a2d07379 100644 --- a/Tests/PresentersTests/LyricsPresenterColumnsTests.swift +++ b/Tests/PresentersTests/LyricsPresenterColumnsTests.swift @@ -15,7 +15,6 @@ private struct StubTrackInteractor: TrackInteractor, @unchecked Sendable { var trackChange: AnyPublisher { trackChangePublisher } var artwork: AnyPublisher { Empty().eraseToAnyPublisher() } var playbackPosition: AnyPublisher { Empty().eraseToAnyPublisher() } - var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } } @MainActor diff --git a/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift b/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift index 951ddec0..7a18c990 100644 --- a/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift +++ b/Tests/PresentersTests/LyricsPresenterDuplicateTests.swift @@ -18,7 +18,6 @@ private struct StubTrackInteractor: TrackInteractor, @unchecked Sendable { var trackChange: AnyPublisher { trackChangePublisher } var artwork: AnyPublisher { Empty().eraseToAnyPublisher() } var playbackPosition: AnyPublisher { playbackPositionPublisher } - var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } } // MARK: - Helpers diff --git a/Tests/PresentersTests/LyricsPresenterTests.swift b/Tests/PresentersTests/LyricsPresenterTests.swift index bb69900e..b3d61349 100644 --- a/Tests/PresentersTests/LyricsPresenterTests.swift +++ b/Tests/PresentersTests/LyricsPresenterTests.swift @@ -18,7 +18,6 @@ private struct StubTrackInteractor: TrackInteractor, @unchecked Sendable { var trackChange: AnyPublisher { trackChangePublisher } var artwork: AnyPublisher { Empty().eraseToAnyPublisher() } var playbackPosition: AnyPublisher { playbackPositionPublisher } - var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } } // MARK: - Helpers diff --git a/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift b/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift index 631a08bd..f37eaa1c 100644 --- a/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift +++ b/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift @@ -13,7 +13,7 @@ struct SpectrumInteractorImplTests { func playingStartsTap() async { let harness = Harness() harness.interactor.start() - harness.audioSource.send(AudioSourceState(pid: 4242, isPlaying: true)) + harness.send(pid: 4242, playbackRate: 1) await harness.pollUntil { harness.tap.startedPids == [4242] } #expect(harness.tap.startedPids == [4242]) @@ -25,10 +25,10 @@ struct SpectrumInteractorImplTests { func pauseStopsTap() async { let harness = Harness() harness.interactor.start() - harness.audioSource.send(AudioSourceState(pid: 4242, isPlaying: true)) + harness.send(pid: 4242, playbackRate: 1) await harness.pollUntil { harness.capturing.value == true } - harness.audioSource.send(AudioSourceState(pid: 4242, isPlaying: false)) + harness.send(pid: 4242, playbackRate: 0) await harness.pollUntil { harness.tap.stopCount > 0 } #expect(harness.tap.stopCount > 0) await harness.pollUntil { harness.capturing.value == false } @@ -39,18 +39,44 @@ struct SpectrumInteractorImplTests { func appSwitchRetaps() async { let harness = Harness() harness.interactor.start() - harness.audioSource.send(AudioSourceState(pid: 1, isPlaying: true)) - harness.audioSource.send(AudioSourceState(pid: 2, isPlaying: true)) + harness.send(pid: 1, playbackRate: 1) + harness.send(pid: 2, playbackRate: 1) await harness.pollUntil { harness.tap.startedPids == [1, 2] } #expect(harness.tap.startedPids == [1, 2]) } + @Test("repeated identical events tap only once (periodic tick dedup)") + func periodicTickDedup() async { + let harness = Harness() + harness.interactor.start() + harness.send(pid: 4242, playbackRate: 1) + harness.send(pid: 4242, playbackRate: 1) + harness.send(pid: 4242, playbackRate: 1) + + await harness.pollUntil { harness.tap.startedPids == [4242] } + try? await Task.sleep(for: .milliseconds(50)) + #expect(harness.tap.startedPids == [4242]) + } + + @Test("vanished session tears the tap down") + func sessionGoneStopsTap() async { + let harness = Harness() + harness.interactor.start() + harness.send(pid: 4242, playbackRate: 1) + await harness.pollUntil { harness.capturing.value == true } + + harness.sendSessionGone() + await harness.pollUntil { harness.capturing.value == false } + #expect(harness.capturing.value == false) + #expect(harness.tap.stopCount > 0) + } + @Test("disabled spectrum never subscribes nor taps") func disabledIsInert() async { let harness = Harness(enabled: false) harness.interactor.start() - harness.audioSource.send(AudioSourceState(pid: 4242, isPlaying: true)) + harness.send(pid: 4242, playbackRate: 1) try? await Task.sleep(for: .milliseconds(50)) #expect(harness.tap.startedPids.isEmpty) @@ -60,7 +86,7 @@ struct SpectrumInteractorImplTests { func stopTearsDown() async { let harness = Harness() harness.interactor.start() - harness.audioSource.send(AudioSourceState(pid: 4242, isPlaying: true)) + harness.send(pid: 4242, playbackRate: 1) await harness.pollUntil { harness.capturing.value == true } harness.interactor.stop() @@ -85,23 +111,26 @@ struct SpectrumInteractorImplTests { // MARK: - Harness /// Builds a `SpectrumInteractorImpl` whose dependencies are all fakes, and -/// keeps the fakes accessible for assertions. +/// keeps the fakes accessible for assertions. Now-playing events are fed +/// through the stubbed `PlaybackUseCase` stream, mirroring the live wiring +/// where the interactor consumes the repository's multicast stream directly. private struct Harness { let style: SpectrumStyle - let audioSource = PassthroughSubject() let tap = FakeAudioTapDataSource() let capturing = CurrentValueBox() let interactor: SpectrumInteractorImpl + private let feed: AsyncStream.Continuation private let cancellable: AnyCancellable init(enabled: Bool = true, samples: [Float] = []) { let style = SpectrumStyle(enabled: enabled, barCount: 16, fftSize: 1024) self.style = style tap.samples = samples - let interactor = withDependencies { [audioSource, tap] in + let (stream, continuation) = AsyncStream.makeStream() + feed = continuation + let interactor = withDependencies { [tap] in $0.configUseCase = StubConfigUseCase(appStyle: AppStyle(spectrum: style)) - $0.trackInteractor = StubTrackInteractor( - audioSource: audioSource.eraseToAnyPublisher()) + $0.playbackUseCase = StubPlaybackUseCase(stream: stream) $0.audioTapDataSource = tap } operation: { SpectrumInteractorImpl() @@ -110,6 +139,17 @@ private struct Harness { self.cancellable = interactor.isCapturing.sink { [capturing] in capturing.value = $0 } } + func send(pid: Int?, playbackRate: Double) { + feed.yield( + NowPlaying( + title: nil, artist: nil, artworkData: nil, duration: nil, + rawElapsed: nil, playbackRate: playbackRate, timestamp: nil, pid: pid)) + } + + func sendSessionGone() { + feed.yield(nil) + } + func pollUntil(_ condition: () -> Bool) async { let deadline = ContinuousClock.now + .seconds(3) while !condition(), ContinuousClock.now < deadline { @@ -154,12 +194,9 @@ private struct StubConfigUseCase: ConfigUseCase { var existingConfigPath: String? { nil } } -private struct StubTrackInteractor: TrackInteractor { - let audioSource: AnyPublisher - var trackChange: AnyPublisher { Empty().eraseToAnyPublisher() } - var artwork: AnyPublisher { Empty().eraseToAnyPublisher() } - var playbackPosition: AnyPublisher { Empty().eraseToAnyPublisher() } - var decodeEffectConfig: DecodeEffect { .init() } - var textLayout: TextLayout { .init() } - var artworkStyle: ArtworkStyle { .init() } +private struct StubPlaybackUseCase: PlaybackUseCase { + let stream: AsyncStream + func fetchNowPlaying() async -> NowPlaying? { nil } + func observeNowPlaying() -> AsyncStream { stream } + func elapsedTime(for nowPlaying: NowPlaying) -> TimeInterval? { nil } } diff --git a/Tests/ViewsTests/ViewRenderingTests.swift b/Tests/ViewsTests/ViewRenderingTests.swift index faaf99ca..dfb719e1 100644 --- a/Tests/ViewsTests/ViewRenderingTests.swift +++ b/Tests/ViewsTests/ViewRenderingTests.swift @@ -40,7 +40,6 @@ private struct IdleTrackInteractor: TrackInteractor, @unchecked Sendable { let trackChange: AnyPublisher = Empty().eraseToAnyPublisher() let artwork: AnyPublisher = Empty().eraseToAnyPublisher() let playbackPosition: AnyPublisher = Empty().eraseToAnyPublisher() - var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } var decodeEffectConfig: DecodeEffect { .init(duration: 0) } var textLayout: TextLayout { .init(decodeEffect: .init(duration: 0)) } var artworkStyle: ArtworkStyle { .init() } @@ -88,7 +87,6 @@ private struct FixtureTrackInteractor: TrackInteractor, @unchecked Sendable { } var artwork: AnyPublisher { Just(artworkData).eraseToAnyPublisher() } let playbackPosition: AnyPublisher = Empty().eraseToAnyPublisher() - var audioSource: AnyPublisher { Empty().eraseToAnyPublisher() } var decodeEffectConfig: DecodeEffect { .init(duration: 0) } var textLayout: TextLayout { .init(decodeEffect: .init(duration: 0)) } var artworkStyle: ArtworkStyle { .init(opacity: opacity) } From fe850c3deaaf955eb0d1fcf4f124e8c440f8ec1c Mon Sep 17 00:00:00 2001 From: YUMENOSUKE Date: Sat, 4 Jul 2026 01:16:29 +0900 Subject: [PATCH 07/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/AudioTapDataSource/ProcessTapEngine.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/AudioTapDataSource/ProcessTapEngine.swift b/Sources/AudioTapDataSource/ProcessTapEngine.swift index 283a3af5..0a9fd116 100644 --- a/Sources/AudioTapDataSource/ProcessTapEngine.swift +++ b/Sources/AudioTapDataSource/ProcessTapEngine.swift @@ -178,7 +178,8 @@ final class ProcessTapEngine { let samples = data.assumingMemoryBound(to: Float.self) for frame in 0.. Date: Sat, 4 Jul 2026 01:24:09 +0900 Subject: [PATCH 08/13] =?UTF-8?q?refactor(#23):=20Spectrum=20=E3=81=AB=20U?= =?UTF-8?q?seCase=20/=20Repository=20=E5=B1=A4=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=81=97=E3=81=A6=E5=B1=A4=E9=A3=9B=E3=81=B0=E3=81=97=E3=82=92?= =?UTF-8?q?=E8=A7=A3=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SpectrumInteractor が AudioTapDataSource に直結し、UseCase と Repository を飛ばす依存になっていた(既存コードに一つもなかった例外)。 SpectrumUseCase(ビジネスロジック: capture lifecycle + FrequencyAnalyzer による PCM→バー振幅変換)と AudioCaptureRepository(タップ DataSource の オーケストレーション)を新設し、Interactor の依存を ConfigUseCase / PlaybackUseCase / SpectrumUseCase のみに揃えた。 - SpectrumInteractor から FrequencyAnalyzer 直 import も撤去 - テスト: SpectrumUseCase / AudioCaptureRepository スイート新設、 Interactor テストは SpectrumUseCase スタブ化 + 固定 sleep を排除 (disabled は同期即時 assert、tick 重複排除は pause マーカーで決定的に) --- .claude/CLAUDE.md | 16 +++- Package.swift | 36 +++++++ .../AudioCaptureRepositoryImpl.swift | 25 +++++ .../RepositoryRegistration.swift | 5 + .../UseCaseRegistration.swift | 5 + .../Repository/AudioCaptureRepository.swift | 31 ++++++ Sources/Domain/UseCase/SpectrumUseCase.swift | 33 +++++++ .../SpectrumInteractorImpl.swift | 60 +++++------- .../SpectrumUseCase/SpectrumUseCaseImpl.swift | 53 ++++++++++ .../AudioCaptureRepositoryImplTests.swift | 72 ++++++++++++++ .../SpectrumInteractorImplTests.swift | 96 ++++++++++--------- .../SpectrumUseCaseImplTests.swift | 79 +++++++++++++++ 12 files changed, 428 insertions(+), 83 deletions(-) create mode 100644 Sources/AudioCaptureRepository/AudioCaptureRepositoryImpl.swift create mode 100644 Sources/Domain/Repository/AudioCaptureRepository.swift create mode 100644 Sources/Domain/UseCase/SpectrumUseCase.swift create mode 100644 Sources/SpectrumUseCase/SpectrumUseCaseImpl.swift create mode 100644 Tests/AudioCaptureRepositoryTests/AudioCaptureRepositoryImplTests.swift create mode 100644 Tests/SpectrumUseCaseTests/SpectrumUseCaseImplTests.swift diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1bc3548e..1501a17e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -86,6 +86,7 @@ graph TD LyricsUseCase[LyricsUseCase] MetadataUseCase[MetadataUseCase] WallpaperUseCase[WallpaperUseCase] + SpectrumUseCase[SpectrumUseCase] end subgraph Repository @@ -94,6 +95,7 @@ graph TD MetadataRepository[MetadataRepository] NowPlayingRepository[NowPlayingRepository] WallpaperRepository[WallpaperRepository] + AudioCaptureRepository[AudioCaptureRepository] end subgraph DataSource @@ -136,8 +138,10 @@ graph TD TrackInteractor -.-> PlaybackUseCase & MetadataUseCase & LyricsUseCase & ConfigUseCase ScreenInteractor -.-> ConfigUseCase WallpaperInteractor -.-> WallpaperUseCase & ConfigUseCase - SpectrumInteractor -.-> ConfigUseCase & PlaybackUseCase & AudioTapDataSource - SpectrumInteractor --> FrequencyAnalyzer + SpectrumInteractor -.-> ConfigUseCase & PlaybackUseCase & SpectrumUseCase + SpectrumUseCase -.-> AudioCaptureRepository + SpectrumUseCase --> FrequencyAnalyzer + AudioCaptureRepository -.-> AudioTapDataSource ConfigUseCase -.-> ConfigRepository ConfigRepository -.-> ConfigDataSource PlaybackUseCase -.-> NowPlayingRepository @@ -169,11 +173,13 @@ graph TD style LyricsUseCase fill:#59c,stroke:#333,color:#fff style MetadataUseCase fill:#59c,stroke:#333,color:#fff style WallpaperUseCase fill:#59c,stroke:#333,color:#fff + style SpectrumUseCase fill:#59c,stroke:#333,color:#fff style ConfigRepository fill:#86c,stroke:#333,color:#fff style LyricsRepository fill:#86c,stroke:#333,color:#fff style MetadataRepository fill:#86c,stroke:#333,color:#fff style NowPlayingRepository fill:#86c,stroke:#333,color:#fff style WallpaperRepository fill:#86c,stroke:#333,color:#fff + style AudioCaptureRepository fill:#86c,stroke:#333,color:#fff style LyricsDataSource fill:#c84,stroke:#333,color:#fff style MetadataDataSource fill:#c84,stroke:#333,color:#fff style ConfigDataSource fill:#c84,stroke:#333,color:#fff @@ -221,8 +227,8 @@ Presenters subscribe to Interactors via Combine. Interactors access UseCases via | DI Wiring | `DependencyInjection` | All liveValue registrations, FontMetrics, HealthCheck | | Entity | `Entity` | Pure data types, zero external dependencies | | Domain | `Domain` | Protocols, DependencyKeys (`@_exported import Entity`) | -| UseCase | `ConfigUseCase`, `PlaybackUseCase`, `LyricsUseCase`, `MetadataUseCase`, `WallpaperUseCase` | Business logic only, no cross-UseCase deps | -| Repository | `ConfigRepository`, `LyricsRepository`, `MetadataRepository`, `NowPlayingRepository`, `WallpaperRepository` | DataSource + DataStore orchestration, cache strategy | +| UseCase | `ConfigUseCase`, `PlaybackUseCase`, `LyricsUseCase`, `MetadataUseCase`, `WallpaperUseCase`, `SpectrumUseCase` | Business logic only, no cross-UseCase deps | +| Repository | `ConfigRepository`, `LyricsRepository`, `MetadataRepository`, `NowPlayingRepository`, `WallpaperRepository`, `AudioCaptureRepository` | DataSource + DataStore orchestration, cache strategy | | DataSource | `LyricsDataSource`, `MetadataDataSource`, `ConfigDataSource`, `MediaRemoteDataSource`, `WallpaperDataSource`, `AudioTapDataSource` | API execution, file I/O, private framework access, CoreAudio process tap | | DataStore | `SQLiteDataStore` | GRDB SQLite cache | @@ -240,7 +246,7 @@ Presenters subscribe to Interactors via Combine. Interactors access UseCases via **FetchState\**: Generic enum (`.idle`, `.loading`, `.revealing(T)`, `.success(T)`, `.failure`) drives both data flow and UI animation. The `.revealing` → `.success` transition is timed by Presenters using `DecodeEffectState`. Use `FetchState` only when the payload `T` is genuinely consumed downstream (e.g. `LyricsPresenter.lyricsState`, whose content feeds `columns(in:)` and `updateActiveLineTick()`). When a Presenter only needs the animation lifecycle and the View already renders the text from a separate `display…` property, expose the payload-less `RevealPhase` (`.idle` / `.revealing` / `.revealed`) instead and keep the decode target in a private field — `HeaderPresenter` does this for `titlePhase` / `artistPhase` so the public surface never duplicates `displayTitle` / `displayArtist` (#275). -**Spectrum analyzer (#23)**: Real-time bars driven by the now-playing app's audio via a CoreAudio **process tap** (macOS 14.4+ APIs: `kAudioHardwarePropertyProcessObjectList` filtered to the now-playing pid's **process subtree** → `CATapDescription(stereoMixdownOfProcesses:)` → `AudioHardwareCreateProcessTap` → private aggregate device + `AudioDeviceCreateIOProcIDWithBlock`; requires the *System Audio Recording* TCC permission declared as `NSAudioCaptureUsageDescription` in the embedded Info.plist). The subtree matching (ppid walk via `proc_pidinfo`) is load-bearing: Chromium-based browsers emit audio from a helper subprocess, so a tap scoped to the main pid alone captures silence — empirically hit with Arc, whose "Browser Helper" child owns the audio stream. The pipeline is: `PlaybackUseCase.observeNowPlaying()` (backed by the **multicast** `NowPlayingRepository.stream()`, so no Interactor→Interactor dependency and no second helper stream) → `SpectrumInteractorImpl` consumes the stream in a single `for await` processor task — inherent serialization, so pause/play bursts cannot interleave tap create/destroy — deduping `AudioSourceState(pid:isPlaying:)` transitions itself (the helper's 3 s ticks must not rebuild the tap) → `AudioTapDataSourceImpl` owns `ProcessTapEngine` (`@available(macOS 14.4, *)`, availability-erased as `AnyObject` for the 14.0 target) and a lock-free SPSC `SampleRingBuffer` (swift-atomics `ManagedAtomic` monotonic write index; the IOProc callback is RT-safe — no allocation, locks, or Swift concurrency). `FrequencyAnalyzer` (pure, dependency-free module) converts the newest PCM window: Hann window → vDSP FFT → dB → 0…1 normalization → per-bar max grouping. `SpectrumPresenter.tick()` runs on the DisplayLink and folds fresh magnitudes into exponentially decaying bars (`decayRate`); `binHeights()` is read-only so the Canvas draw closure never mutates `@Published` state. `SpectrumView` follows the #252/#258 zero-idle-cost pattern (conditional inclusion + `TimelineView(.animation(paused:))`). Tap lifecycle: playing+pid → create; paused/pid-lost/session-gone → destroy (a dead tap costs zero CPU). Known limitation (by decision): the tap captures the whole process tree — for browsers that means every tab, documented in README. TCC caveat for dev runs: a daemon spawned from a terminal inherits the terminal as TCC responsible process, so the permission prompt never appears and the tap reads silence — launch via launchd (LaunchAgent) so lyra itself is the responsible process. +**Spectrum analyzer (#23)**: Real-time bars driven by the now-playing app's audio via a CoreAudio **process tap** (macOS 14.4+ APIs: `kAudioHardwarePropertyProcessObjectList` filtered to the now-playing pid's **process subtree** → `CATapDescription(stereoMixdownOfProcesses:)` → `AudioHardwareCreateProcessTap` → private aggregate device + `AudioDeviceCreateIOProcIDWithBlock`; requires the *System Audio Recording* TCC permission declared as `NSAudioCaptureUsageDescription` in the embedded Info.plist). The subtree matching (ppid walk via `proc_pidinfo`) is load-bearing: Chromium-based browsers emit audio from a helper subprocess, so a tap scoped to the main pid alone captures silence — empirically hit with Arc, whose "Browser Helper" child owns the audio stream. The pipeline is: `PlaybackUseCase.observeNowPlaying()` (backed by the **multicast** `NowPlayingRepository.stream()`, so no Interactor→Interactor dependency and no second helper stream) → `SpectrumInteractorImpl` consumes the stream in a single `for await` processor task — inherent serialization, so pause/play bursts cannot interleave tap create/destroy — deduping `AudioSourceState(pid:isPlaying:)` transitions itself (the helper's 3 s ticks must not rebuild the tap) → `SpectrumUseCase` (business logic: capture lifecycle + PCM→per-bar conversion via the pure `FrequencyAnalyzer`) → `AudioCaptureRepository` (thin orchestration over the tap DataSource) → `AudioTapDataSourceImpl` owns `ProcessTapEngine` (`@available(macOS 14.4, *)`, availability-erased as `AnyObject` for the 14.0 target) and a lock-free SPSC `SampleRingBuffer` (swift-atomics `ManagedAtomic` monotonic write index; the IOProc callback is RT-safe — no allocation, locks, or Swift concurrency). `FrequencyAnalyzer` (pure, dependency-free module) converts the newest PCM window: Hann window → vDSP FFT → dB → 0…1 normalization → per-bar max grouping. `SpectrumPresenter.tick()` runs on the DisplayLink and folds fresh magnitudes into exponentially decaying bars (`decayRate`); `binHeights()` is read-only so the Canvas draw closure never mutates `@Published` state. `SpectrumView` follows the #252/#258 zero-idle-cost pattern (conditional inclusion + `TimelineView(.animation(paused:))`). Tap lifecycle: playing+pid → create; paused/pid-lost/session-gone → destroy (a dead tap costs zero CPU). Known limitation (by decision): the tap captures the whole process tree — for browsers that means every tab, documented in README. TCC caveat for dev runs: a daemon spawned from a terminal inherits the terminal as TCC responsible process, so the permission prompt never appears and the tap reads silence — launch via launchd (LaunchAgent) so lyra itself is the responsible process. **AI processing indicator (#57)**: While the AI (LLM) extractor resolves title/artist on a cache miss, the header scrambles in a configurable color so the user sees that work is happening. `TrackInteractorImpl.resolveTrack` emits an extra `TrackUpdate(aiResolving: true)` after the debounce only when an `[ai]` endpoint is configured **and** `MetadataUseCase.isAIMetadataCached(track:)` returns `false` (an LLM cache hit means no API round-trip, so no indicator). `HeaderPresenter` maps `aiResolving` to `DecodeEffectState.startLoading` (the indefinite scramble, distinct from `decode`'s settle) and swaps `titleColor` / `artistColor` to `DecodeEffect.processingColor` (default green `#4ADE80FF`, config key `text.decode_effect.processing_color`, solid or gradient). The resolved (non-`aiResolving`) update settles the scramble and restores the normal color. `HeaderView` reads the effective `titleColor` / `artistColor` (`@Published`) rather than the static `titleStyle.color`. diff --git a/Package.swift b/Package.swift index b8bc6720..b93e6391 100644 --- a/Package.swift +++ b/Package.swift @@ -204,11 +204,13 @@ let package = Package( "LyricsUseCase", "MetadataUseCase", "WallpaperUseCase", + "SpectrumUseCase", "ConfigRepository", "LyricsRepository", "MetadataRepository", "NowPlayingRepository", "WallpaperRepository", + "AudioCaptureRepository", "ConfigDataSource", "LyricsDataSource", "MetadataDataSource", @@ -253,6 +255,15 @@ let package = Package( ), .target( name: "SpectrumInteractor", + dependencies: [ + "Domain", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + + // ── SpectrumUseCase ── + .target( + name: "SpectrumUseCase", dependencies: [ "Domain", "FrequencyAnalyzer", @@ -260,6 +271,15 @@ let package = Package( ] ), + // ── AudioCaptureRepository ── + .target( + name: "AudioCaptureRepository", + dependencies: [ + "Domain", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + // ── FrequencyAnalyzer ── .target( name: "FrequencyAnalyzer", @@ -455,6 +475,22 @@ let package = Package( .product(name: "Dependencies", package: "swift-dependencies"), ] ), + .testTarget( + name: "SpectrumUseCaseTests", + dependencies: [ + "SpectrumUseCase", + "Domain", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .testTarget( + name: "AudioCaptureRepositoryTests", + dependencies: [ + "AudioCaptureRepository", + "Domain", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), .testTarget( name: "FrequencyAnalyzerTests", dependencies: ["FrequencyAnalyzer"] diff --git a/Sources/AudioCaptureRepository/AudioCaptureRepositoryImpl.swift b/Sources/AudioCaptureRepository/AudioCaptureRepositoryImpl.swift new file mode 100644 index 00000000..5cfbc21c --- /dev/null +++ b/Sources/AudioCaptureRepository/AudioCaptureRepositoryImpl.swift @@ -0,0 +1,25 @@ +import Dependencies +import Domain + +/// Repository over the CoreAudio process-tap DataSource (#23). Captured +/// audio has no cache layer, so orchestration is a thin forwarding of the +/// tap lifecycle and ring-buffer reads. +public struct AudioCaptureRepositoryImpl: Sendable { + @Dependency(\.audioTapDataSource) private var dataSource + + public init() {} +} + +extension AudioCaptureRepositoryImpl: AudioCaptureRepository { + public func startCapture(pid: Int) async -> Bool { + await dataSource.startTap(pid: pid) + } + + public func stopCapture() async { + await dataSource.stopTap() + } + + public func latestSamples(count: Int) -> [Float] { + dataSource.latestSamples(count: count) + } +} diff --git a/Sources/DependencyInjection/RepositoryRegistration.swift b/Sources/DependencyInjection/RepositoryRegistration.swift index e173c9d4..b425e505 100644 --- a/Sources/DependencyInjection/RepositoryRegistration.swift +++ b/Sources/DependencyInjection/RepositoryRegistration.swift @@ -1,3 +1,4 @@ +import AudioCaptureRepository import ConfigRepository import Dependencies import Domain @@ -25,3 +26,7 @@ extension NowPlayingRepositoryKey: DependencyKey { extension WallpaperRepositoryKey: DependencyKey { public static let liveValue: any WallpaperRepository = WallpaperRepositoryImpl() } + +extension AudioCaptureRepositoryKey: DependencyKey { + public static let liveValue: any AudioCaptureRepository = AudioCaptureRepositoryImpl() +} diff --git a/Sources/DependencyInjection/UseCaseRegistration.swift b/Sources/DependencyInjection/UseCaseRegistration.swift index cdcf7599..f3326e68 100644 --- a/Sources/DependencyInjection/UseCaseRegistration.swift +++ b/Sources/DependencyInjection/UseCaseRegistration.swift @@ -4,6 +4,7 @@ import Domain import LyricsUseCase import MetadataUseCase import PlaybackUseCase +import SpectrumUseCase import WallpaperUseCase extension ConfigUseCaseKey: DependencyKey { @@ -25,3 +26,7 @@ extension PlaybackUseCaseKey: DependencyKey { extension WallpaperUseCaseKey: DependencyKey { public static let liveValue: any WallpaperUseCase = WallpaperUseCaseImpl() } + +extension SpectrumUseCaseKey: DependencyKey { + public static let liveValue: any SpectrumUseCase = SpectrumUseCaseImpl() +} diff --git a/Sources/Domain/Repository/AudioCaptureRepository.swift b/Sources/Domain/Repository/AudioCaptureRepository.swift new file mode 100644 index 00000000..ab1fce76 --- /dev/null +++ b/Sources/Domain/Repository/AudioCaptureRepository.swift @@ -0,0 +1,31 @@ +import Dependencies + +/// Access to the captured audio of the now-playing process (#23): capture +/// lifecycle plus the newest PCM window read. +public protocol AudioCaptureRepository: Sendable { + /// Starts capturing the process's audio, replacing any active capture. + /// Returns `false` when capture is unavailable. + func startCapture(pid: Int) async -> Bool + /// Tears down the active capture, if any. Safe to call repeatedly. + func stopCapture() async + /// The newest `count` captured mono samples, oldest first. Empty while + /// no capture is active or before the buffer has filled once. + func latestSamples(count: Int) -> [Float] +} + +public enum AudioCaptureRepositoryKey: TestDependencyKey { + public static let testValue: any AudioCaptureRepository = UnimplementedAudioCaptureRepository() +} + +extension DependencyValues { + public var audioCaptureRepository: any AudioCaptureRepository { + get { self[AudioCaptureRepositoryKey.self] } + set { self[AudioCaptureRepositoryKey.self] = newValue } + } +} + +private struct UnimplementedAudioCaptureRepository: AudioCaptureRepository { + func startCapture(pid: Int) async -> Bool { false } + func stopCapture() async {} + func latestSamples(count: Int) -> [Float] { [] } +} diff --git a/Sources/Domain/UseCase/SpectrumUseCase.swift b/Sources/Domain/UseCase/SpectrumUseCase.swift new file mode 100644 index 00000000..e440dbe1 --- /dev/null +++ b/Sources/Domain/UseCase/SpectrumUseCase.swift @@ -0,0 +1,33 @@ +import Dependencies + +/// Business logic for the spectrum analyzer (#23): drives the audio capture +/// lifecycle and converts the newest captured PCM window into normalized +/// per-bar magnitudes. +public protocol SpectrumUseCase: Sendable { + /// Starts capturing the process's audio, replacing any active capture. + /// Returns `false` when capture is unavailable — OS below the macOS 14.4 + /// floor, TCC denial, unknown process, or a CoreAudio error. + func startCapture(pid: Int) async -> Bool + /// Tears down the active capture, if any. Safe to call repeatedly. + func stopCapture() async + /// Normalized magnitudes (0…1) of the newest PCM window, one per bar. + /// Empty while nothing is being captured. + func magnitudes(style: SpectrumStyle) -> [Float] +} + +public enum SpectrumUseCaseKey: TestDependencyKey { + public static let testValue: any SpectrumUseCase = UnimplementedSpectrumUseCase() +} + +extension DependencyValues { + public var spectrumUseCase: any SpectrumUseCase { + get { self[SpectrumUseCaseKey.self] } + set { self[SpectrumUseCaseKey.self] = newValue } + } +} + +private struct UnimplementedSpectrumUseCase: SpectrumUseCase { + func startCapture(pid: Int) async -> Bool { false } + func stopCapture() async {} + func magnitudes(style: SpectrumStyle) -> [Float] { [] } +} diff --git a/Sources/SpectrumInteractor/SpectrumInteractorImpl.swift b/Sources/SpectrumInteractor/SpectrumInteractorImpl.swift index 39d5444f..7648d994 100644 --- a/Sources/SpectrumInteractor/SpectrumInteractorImpl.swift +++ b/Sources/SpectrumInteractor/SpectrumInteractorImpl.swift @@ -2,36 +2,29 @@ import Combine import Dependencies import Domain import Foundation -import FrequencyAnalyzer import os -/// Drives the spectrum analyzer (#23): follows the now-playing stream, keeps -/// the CoreAudio process tap scoped to the now-playing app, and converts the -/// captured PCM window into per-bar magnitudes on demand. +/// Drives the spectrum analyzer (#23): follows the now-playing stream and +/// keeps the audio capture scoped to the now-playing app, exposing per-bar +/// magnitudes for the Presenter's DisplayLink tick. /// -/// Tap lifecycle rules: -/// - playing + known pid → (re)create the tap for that pid -/// - paused, pid lost, or session gone → destroy the tap (not mute — a dead -/// tap costs zero CPU, per the idle-suspension policy) -/// - pid change (app switch) → the destroy/create pair reruns for the new pid +/// Capture lifecycle rules: +/// - playing + known pid → (re)start the capture for that pid +/// - paused, pid lost, or session gone → stop the capture (not mute — a dead +/// capture costs zero CPU, per the idle-suspension policy) +/// - pid change (app switch) → the stop/start pair reruns for the new pid /// -/// `@unchecked` only because of the lazy `analyzer`, which is touched solely -/// from the main-thread DisplayLink tick via `magnitudes()`. +/// `@unchecked` only because Combine subjects are not `Sendable`; the +/// `capturingSubject` is the sole shared state and is immutable (`let`). public final class SpectrumInteractorImpl: @unchecked Sendable { // Stored wrappers capture the dependency context at init, so instances // built inside `withDependencies` keep their fakes when methods run // outside that scope (the processor task). @Dependency(\.configUseCase) private var configService @Dependency(\.playbackUseCase) private var playbackService - @Dependency(\.audioTapDataSource) private var tap + @Dependency(\.spectrumUseCase) private var spectrumService private let capturingSubject = CurrentValueSubject(false) private let processor = OSAllocatedUnfairLock(initialState: Task?.none) - private lazy var analyzer = FrequencyAnalyzer( - fftSize: spectrumStyle.fftSize, - barCount: spectrumStyle.barCount, - minDb: spectrumStyle.minDb, - maxDb: spectrumStyle.maxDb - ) public init() {} } @@ -47,13 +40,14 @@ extension SpectrumInteractorImpl: SpectrumInteractor { public func start() { guard spectrumStyle.enabled else { return } - let tap = tap + let spectrum = spectrumService let subject = capturingSubject let playback = playbackService // A single for-await loop is the serialization point: events apply - // one at a time, so a rapid pause→play burst can never interleave tap - // create/destroy calls. `previous` dedupes the helper's periodic - // ticks — `startTap` rebuilds the engine, so repeats must not pass. + // one at a time, so a rapid pause→play burst can never interleave + // capture start/stop calls. `previous` dedupes the helper's periodic + // ticks — restarting the capture rebuilds the tap, so repeats must + // not pass. let candidate = Task { var previous: AudioSourceState? for await info in playback.observeNowPlaying() { @@ -62,17 +56,17 @@ extension SpectrumInteractorImpl: SpectrumInteractor { guard source != previous else { continue } previous = source guard let pid = source.pid, source.isPlaying else { - await tap.stopTap() + await spectrum.stopCapture() subject.send(false) continue } - subject.send(await tap.startTap(pid: pid)) + subject.send(await spectrum.startCapture(pid: pid)) } - // Upstream finished (helper EOF): tear the tap down. A cancelled - // task skips this — stop() owns that teardown, and a start/start - // race loser must not destroy the winner's tap. + // Upstream finished (helper EOF): tear the capture down. A + // cancelled task skips this — stop() owns that teardown, and a + // start/start race loser must not destroy the winner's capture. guard !Task.isCancelled else { return } - await tap.stopTap() + await spectrum.stopCapture() subject.send(false) } let adopted = processor.withLock { task in @@ -93,20 +87,18 @@ extension SpectrumInteractorImpl: SpectrumInteractor { } guard let stopped else { return } stopped.cancel() - let tap = tap + let spectrum = spectrumService let subject = capturingSubject Task { // Awaiting the cancelled processor first keeps this teardown - // ordered after its in-flight tap call. + // ordered after its in-flight capture call. await stopped.value - await tap.stopTap() + await spectrum.stopCapture() subject.send(false) } } public func magnitudes() -> [Float] { - let samples = tap.latestSamples(count: spectrumStyle.fftSize) - guard !samples.isEmpty else { return [] } - return analyzer.magnitudes(of: samples) + spectrumService.magnitudes(style: spectrumStyle) } } diff --git a/Sources/SpectrumUseCase/SpectrumUseCaseImpl.swift b/Sources/SpectrumUseCase/SpectrumUseCaseImpl.swift new file mode 100644 index 00000000..10fbc8d0 --- /dev/null +++ b/Sources/SpectrumUseCase/SpectrumUseCaseImpl.swift @@ -0,0 +1,53 @@ +import Dependencies +import Domain +import FrequencyAnalyzer + +/// Business logic for the spectrum analyzer (#23): forwards the capture +/// lifecycle to the repository and converts the newest PCM window into +/// per-bar magnitudes via the pure `FrequencyAnalyzer`. +/// +/// `@unchecked` only because of the memoized `analyzer`, which is touched +/// solely from the main-thread DisplayLink tick via `magnitudes(style:)`. +public final class SpectrumUseCaseImpl: @unchecked Sendable { + // Stored wrapper captures the dependency context at init, so instances + // built inside `withDependencies` keep their fakes when methods run + // outside that scope. + @Dependency(\.audioCaptureRepository) private var repository + private var analyzer: FrequencyAnalyzer? + + public init() {} +} + +extension SpectrumUseCaseImpl: SpectrumUseCase { + public func startCapture(pid: Int) async -> Bool { + await repository.startCapture(pid: pid) + } + + public func stopCapture() async { + await repository.stopCapture() + } + + public func magnitudes(style: SpectrumStyle) -> [Float] { + let samples = repository.latestSamples(count: style.fftSize) + guard !samples.isEmpty else { return [] } + return resolvedAnalyzer(for: style).magnitudes(of: samples) + } +} + +extension SpectrumUseCaseImpl { + /// Config is launch-static, so the first style builds the one analyzer + /// used for the rest of the process lifetime. + private func resolvedAnalyzer(for style: SpectrumStyle) -> FrequencyAnalyzer { + guard let analyzer else { + let built = FrequencyAnalyzer( + fftSize: style.fftSize, + barCount: style.barCount, + minDb: style.minDb, + maxDb: style.maxDb + ) + self.analyzer = built + return built + } + return analyzer + } +} diff --git a/Tests/AudioCaptureRepositoryTests/AudioCaptureRepositoryImplTests.swift b/Tests/AudioCaptureRepositoryTests/AudioCaptureRepositoryImplTests.swift new file mode 100644 index 00000000..4dd2fc9b --- /dev/null +++ b/Tests/AudioCaptureRepositoryTests/AudioCaptureRepositoryImplTests.swift @@ -0,0 +1,72 @@ +import Dependencies +import Domain +import Foundation +import Testing +import os + +@testable import AudioCaptureRepository + +@Suite("AudioCaptureRepositoryImpl") +struct AudioCaptureRepositoryImplTests { + @Test("startCapture forwards the pid and returns the datasource result") + func startForwards() async { + let harness = Harness() + let started = await harness.repository.startCapture(pid: 42) + + #expect(started == true) + #expect(harness.dataSource.startedPids == [42]) + } + + @Test("stopCapture forwards to the datasource") + func stopForwards() async { + let harness = Harness() + await harness.repository.stopCapture() + + #expect(harness.dataSource.stopCount == 1) + } + + @Test("latestSamples forwards the requested count") + func latestSamplesForwards() { + let harness = Harness(samples: [1, 2, 3, 4]) + let samples = harness.repository.latestSamples(count: 2) + + #expect(samples == [3, 4]) + } +} + +// MARK: - Harness + +private struct Harness { + let dataSource = FakeAudioTapDataSource() + let repository: AudioCaptureRepositoryImpl + + init(samples: [Float] = []) { + dataSource.samples = samples + repository = withDependencies { [dataSource] in + $0.audioTapDataSource = dataSource + } operation: { + AudioCaptureRepositoryImpl() + } + } +} + +private final class FakeAudioTapDataSource: AudioTapDataSource, @unchecked Sendable { + private let state = OSAllocatedUnfairLock(initialState: (started: [Int](), stops: 0)) + var samples: [Float] = [] + + var startedPids: [Int] { state.withLock { $0.started } } + var stopCount: Int { state.withLock { $0.stops } } + + func startTap(pid: Int) async -> Bool { + state.withLock { $0.started.append(pid) } + return true + } + + func stopTap() async { + state.withLock { $0.stops += 1 } + } + + func latestSamples(count: Int) -> [Float] { + samples.count >= count ? Array(samples.suffix(count)) : [] + } +} diff --git a/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift b/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift index f37eaa1c..b82d0164 100644 --- a/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift +++ b/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift @@ -9,58 +9,61 @@ import os @Suite("SpectrumInteractorImpl") struct SpectrumInteractorImplTests { - @Test("playing source starts a tap for its pid and reports capturing") - func playingStartsTap() async { + @Test("playing source starts a capture for its pid and reports capturing") + func playingStartsCapture() async { let harness = Harness() harness.interactor.start() harness.send(pid: 4242, playbackRate: 1) - await harness.pollUntil { harness.tap.startedPids == [4242] } - #expect(harness.tap.startedPids == [4242]) + await harness.pollUntil { harness.spectrum.startedPids == [4242] } + #expect(harness.spectrum.startedPids == [4242]) await harness.pollUntil { harness.capturing.value == true } #expect(harness.capturing.value == true) } - @Test("pausing tears the tap down and reports not capturing") - func pauseStopsTap() async { + @Test("pausing stops the capture and reports not capturing") + func pauseStopsCapture() async { let harness = Harness() harness.interactor.start() harness.send(pid: 4242, playbackRate: 1) await harness.pollUntil { harness.capturing.value == true } harness.send(pid: 4242, playbackRate: 0) - await harness.pollUntil { harness.tap.stopCount > 0 } - #expect(harness.tap.stopCount > 0) + await harness.pollUntil { harness.spectrum.stopCount > 0 } + #expect(harness.spectrum.stopCount > 0) await harness.pollUntil { harness.capturing.value == false } #expect(harness.capturing.value == false) } - @Test("app switch re-taps the new pid") - func appSwitchRetaps() async { + @Test("app switch re-captures the new pid") + func appSwitchRecaptures() async { let harness = Harness() harness.interactor.start() harness.send(pid: 1, playbackRate: 1) harness.send(pid: 2, playbackRate: 1) - await harness.pollUntil { harness.tap.startedPids == [1, 2] } - #expect(harness.tap.startedPids == [1, 2]) + await harness.pollUntil { harness.spectrum.startedPids == [1, 2] } + #expect(harness.spectrum.startedPids == [1, 2]) } - @Test("repeated identical events tap only once (periodic tick dedup)") + @Test("repeated identical events capture only once (periodic tick dedup)") func periodicTickDedup() async { let harness = Harness() harness.interactor.start() harness.send(pid: 4242, playbackRate: 1) harness.send(pid: 4242, playbackRate: 1) harness.send(pid: 4242, playbackRate: 1) + // The pause marker is processed strictly after the three identical + // events (single serial consumer), so once it lands every duplicate + // has been evaluated — no fixed sleep needed. + harness.send(pid: 4242, playbackRate: 0) - await harness.pollUntil { harness.tap.startedPids == [4242] } - try? await Task.sleep(for: .milliseconds(50)) - #expect(harness.tap.startedPids == [4242]) + await harness.pollUntil { harness.spectrum.stopCount > 0 } + #expect(harness.spectrum.startedPids == [4242]) } - @Test("vanished session tears the tap down") - func sessionGoneStopsTap() async { + @Test("vanished session stops the capture") + func sessionGoneStopsCapture() async { let harness = Harness() harness.interactor.start() harness.send(pid: 4242, playbackRate: 1) @@ -69,20 +72,21 @@ struct SpectrumInteractorImplTests { harness.sendSessionGone() await harness.pollUntil { harness.capturing.value == false } #expect(harness.capturing.value == false) - #expect(harness.tap.stopCount > 0) + #expect(harness.spectrum.stopCount > 0) } - @Test("disabled spectrum never subscribes nor taps") - func disabledIsInert() async { + @Test("disabled spectrum never subscribes nor captures") + func disabledIsInert() { let harness = Harness(enabled: false) harness.interactor.start() harness.send(pid: 4242, playbackRate: 1) - try? await Task.sleep(for: .milliseconds(50)) - #expect(harness.tap.startedPids.isEmpty) + // Disabled start() returns before any task exists, so nothing can + // consume the event — the assertion is safe immediately. + #expect(harness.spectrum.startedPids.isEmpty) } - @Test("stop tears down the active tap") + @Test("stop tears down the active capture") func stopTearsDown() async { let harness = Harness() harness.interactor.start() @@ -90,19 +94,21 @@ struct SpectrumInteractorImplTests { await harness.pollUntil { harness.capturing.value == true } harness.interactor.stop() - await harness.pollUntil { harness.tap.stopCount > 0 } - #expect(harness.tap.stopCount > 0) + await harness.pollUntil { harness.spectrum.stopCount > 0 } + #expect(harness.spectrum.stopCount > 0) } - @Test("magnitudes converts the captured window into one value per bar") - func magnitudesShape() { - let harness = Harness(samples: [Float](repeating: 0.5, count: 1024)) - let bins = harness.interactor.magnitudes() - #expect(bins.count == harness.style.barCount) + @Test("magnitudes forwards the configured style to the use case") + func magnitudesForwards() { + let harness = Harness() + harness.spectrum.magnitudesResult = [0.25, 0.5] + + #expect(harness.interactor.magnitudes() == [0.25, 0.5]) + #expect(harness.spectrum.lastStyle?.barCount == 16) } @Test("magnitudes is empty while nothing is captured") - func magnitudesEmptyWithoutSamples() { + func magnitudesEmptyWithoutCapture() { let harness = Harness() #expect(harness.interactor.magnitudes().isEmpty) } @@ -116,22 +122,21 @@ struct SpectrumInteractorImplTests { /// where the interactor consumes the repository's multicast stream directly. private struct Harness { let style: SpectrumStyle - let tap = FakeAudioTapDataSource() + let spectrum = FakeSpectrumUseCase() let capturing = CurrentValueBox() let interactor: SpectrumInteractorImpl private let feed: AsyncStream.Continuation private let cancellable: AnyCancellable - init(enabled: Bool = true, samples: [Float] = []) { + init(enabled: Bool = true) { let style = SpectrumStyle(enabled: enabled, barCount: 16, fftSize: 1024) self.style = style - tap.samples = samples let (stream, continuation) = AsyncStream.makeStream() feed = continuation - let interactor = withDependencies { [tap] in + let interactor = withDependencies { [spectrum] in $0.configUseCase = StubConfigUseCase(appStyle: AppStyle(spectrum: style)) $0.playbackUseCase = StubPlaybackUseCase(stream: stream) - $0.audioTapDataSource = tap + $0.spectrumUseCase = spectrum } operation: { SpectrumInteractorImpl() } @@ -166,24 +171,27 @@ private final class CurrentValueBox: @unchecked Sendable { } } -private final class FakeAudioTapDataSource: AudioTapDataSource, @unchecked Sendable { - private let state = OSAllocatedUnfairLock(initialState: (started: [Int](), stops: 0)) - var samples: [Float] = [] +private final class FakeSpectrumUseCase: SpectrumUseCase, @unchecked Sendable { + private let state = OSAllocatedUnfairLock( + initialState: (started: [Int](), stops: 0, style: SpectrumStyle?.none)) + var magnitudesResult: [Float] = [] var startedPids: [Int] { state.withLock { $0.started } } var stopCount: Int { state.withLock { $0.stops } } + var lastStyle: SpectrumStyle? { state.withLock { $0.style } } - func startTap(pid: Int) async -> Bool { + func startCapture(pid: Int) async -> Bool { state.withLock { $0.started.append(pid) } return true } - func stopTap() async { + func stopCapture() async { state.withLock { $0.stops += 1 } } - func latestSamples(count: Int) -> [Float] { - samples.count >= count ? Array(samples.suffix(count)) : [] + func magnitudes(style: SpectrumStyle) -> [Float] { + state.withLock { $0.style = style } + return magnitudesResult } } diff --git a/Tests/SpectrumUseCaseTests/SpectrumUseCaseImplTests.swift b/Tests/SpectrumUseCaseTests/SpectrumUseCaseImplTests.swift new file mode 100644 index 00000000..f119c70e --- /dev/null +++ b/Tests/SpectrumUseCaseTests/SpectrumUseCaseImplTests.swift @@ -0,0 +1,79 @@ +import Dependencies +import Domain +import Foundation +import Testing +import os + +@testable import SpectrumUseCase + +@Suite("SpectrumUseCaseImpl") +struct SpectrumUseCaseImplTests { + @Test("startCapture forwards the pid to the repository") + func startForwards() async { + let harness = Harness() + let started = await harness.useCase.startCapture(pid: 77) + + #expect(started == true) + #expect(harness.repository.startedPids == [77]) + } + + @Test("stopCapture forwards to the repository") + func stopForwards() async { + let harness = Harness() + await harness.useCase.stopCapture() + + #expect(harness.repository.stopCount == 1) + } + + @Test("magnitudes converts the captured window into one value per bar") + func magnitudesShape() { + let harness = Harness(samples: [Float](repeating: 0.5, count: 1024)) + let bins = harness.useCase.magnitudes(style: SpectrumStyle(barCount: 16, fftSize: 1024)) + + #expect(bins.count == 16) + } + + @Test("magnitudes is empty while nothing is captured") + func magnitudesEmptyWithoutSamples() { + let harness = Harness() + + #expect(harness.useCase.magnitudes(style: SpectrumStyle()).isEmpty) + } +} + +// MARK: - Harness + +private struct Harness { + let repository = FakeAudioCaptureRepository() + let useCase: SpectrumUseCaseImpl + + init(samples: [Float] = []) { + repository.samples = samples + useCase = withDependencies { [repository] in + $0.audioCaptureRepository = repository + } operation: { + SpectrumUseCaseImpl() + } + } +} + +private final class FakeAudioCaptureRepository: AudioCaptureRepository, @unchecked Sendable { + private let state = OSAllocatedUnfairLock(initialState: (started: [Int](), stops: 0)) + var samples: [Float] = [] + + var startedPids: [Int] { state.withLock { $0.started } } + var stopCount: Int { state.withLock { $0.stops } } + + func startCapture(pid: Int) async -> Bool { + state.withLock { $0.started.append(pid) } + return true + } + + func stopCapture() async { + state.withLock { $0.stops += 1 } + } + + func latestSamples(count: Int) -> [Float] { + samples.count >= count ? Array(samples.suffix(count)) : [] + } +} From 7c3a37f10a43b13341cec9d7721af947c2416afb Mon Sep 17 00:00:00 2001 From: GeneralD Date: Sat, 4 Jul 2026 01:24:21 +0900 Subject: [PATCH 09/13] =?UTF-8?q?fix(#23):=20=E3=82=BF=E3=83=83=E3=83=97?= =?UTF-8?q?=E7=94=9F=E6=88=90=E3=82=92=E3=83=AD=E3=83=83=E3=82=AF=E5=A4=96?= =?UTF-8?q?=E3=81=AB=E7=A7=BB=E5=8B=95=E3=81=97=E3=81=A6=20latestSamples?= =?UTF-8?q?=20=E3=81=AE=E5=81=9C=E6=BB=9E=E3=82=92=E9=98=B2=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessTapEngine の構築(TCC 初回プロンプトで長時間ブロックし得る CoreAudio セットアップ)を engine ロックの臨界区間内で行っていたため、 DisplayLink 毎フレームの latestSamples が同じロックで停滞する恐れが あった。旧エンジン停止 → ロック外で構築 → ロック内で公開の順に変更。 SPSC リングに書き手が2つ存在する瞬間も無くなる。 --- .../AudioTapDataSourceImpl.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Sources/AudioTapDataSource/AudioTapDataSourceImpl.swift b/Sources/AudioTapDataSource/AudioTapDataSourceImpl.swift index b85d150b..0741d022 100644 --- a/Sources/AudioTapDataSource/AudioTapDataSourceImpl.swift +++ b/Sources/AudioTapDataSource/AudioTapDataSourceImpl.swift @@ -24,11 +24,20 @@ public final class AudioTapDataSourceImpl: Sendable { extension AudioTapDataSourceImpl: AudioTapDataSource { public func startTap(pid: Int) async -> Bool { guard #available(macOS 14.4, *) else { return false } - return engine.withLockUnchecked { current in - (current as? ProcessTapEngine)?.stop() - current = ProcessTapEngine(pid: pid, ring: ring) - return current != nil + // The old engine stops before the new one exists so the SPSC ring + // never sees two writers, and construction happens OUTSIDE the lock — + // CoreAudio setup (potentially a first-run TCC prompt) must not stall + // `latestSamples`' per-frame lock acquisition. The caller serializes + // start/stop through a single processor task, so the two lock + // sections cannot interleave with another mutation. + let previous = engine.withLockUnchecked { current -> AnyObject? in + defer { current = nil } + return current } + (previous as? ProcessTapEngine)?.stop() + let created = ProcessTapEngine(pid: pid, ring: ring) + engine.withLockUnchecked { $0 = created } + return created != nil } public func stopTap() async { From e9fbb473ce128a385e0684650b051ee7f6b56e61 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Sat, 4 Jul 2026 01:24:21 +0900 Subject: [PATCH 10/13] =?UTF-8?q?fix(#23):=20spectrum=20=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E3=81=AE=20bar=5Fcount=20/=20fft=5Fsize=20=E3=82=92=E4=B8=8B?= =?UTF-8?q?=E9=99=90=E3=82=AF=E3=83=A9=E3=83=B3=E3=83=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 負値やゼロの bar_count が SpectrumPresenter のバー生成 Range を、 極小の fft_size がリングバッファ読み出しをクラッシュさせ得たため、 config→style 変換で barCount ≥ 1 / fftSize ≥ 64 に正規化。 --- .../ConfigRepositoryImpl.swift | 6 ++++-- .../ConfigRepositoryTests.swift | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Sources/ConfigRepository/ConfigRepositoryImpl.swift b/Sources/ConfigRepository/ConfigRepositoryImpl.swift index a49b9974..b181b084 100644 --- a/Sources/ConfigRepository/ConfigRepositoryImpl.swift +++ b/Sources/ConfigRepository/ConfigRepositoryImpl.swift @@ -37,14 +37,16 @@ extension ConfigRepositoryImpl: ConfigRepository { ), spectrum: SpectrumStyle( enabled: config.spectrum.enabled, - barCount: Int(config.spectrum.barCount.value), + // Clamped here so downstream consumers (Presenter bar merge, + // ring-buffer reads) never see a zero/negative count. + barCount: max(1, Int(config.spectrum.barCount.value)), barColor: config.spectrum.barColor, backgroundColor: config.spectrum.backgroundColor, barWidthRatio: config.spectrum.barWidthRatio.value, minDb: config.spectrum.minDb.value, maxDb: config.spectrum.maxDb.value, decayRate: config.spectrum.decayRate.value, - fftSize: Int(config.spectrum.fftSize.value), + fftSize: max(64, Int(config.spectrum.fftSize.value)), placement: config.spectrum.placement, heightRatio: config.spectrum.heightRatio.value ), diff --git a/Tests/ConfigRepositoryTests/ConfigRepositoryTests.swift b/Tests/ConfigRepositoryTests/ConfigRepositoryTests.swift index f7d21f0e..ecb05f00 100644 --- a/Tests/ConfigRepositoryTests/ConfigRepositoryTests.swift +++ b/Tests/ConfigRepositoryTests/ConfigRepositoryTests.swift @@ -39,6 +39,21 @@ struct ConfigRepositoryTests { } } + @Test("clamps spectrum bar_count and fft_size to sane floors") + func spectrumClamped() { + let config = makeAppConfig(spectrum: ["bar_count": -3, "fft_size": 8]) + let result = ConfigLoadResult(config: config, configDir: "/tmp") + + withDependencies { + $0.configDataSource = StubConfigDataSource(loadResult: result) + } operation: { + let repo = ConfigRepositoryImpl() + let style = repo.loadAppStyle() + #expect(style.spectrum.barCount == 1) + #expect(style.spectrum.fftSize == 64) + } + } + @Test("passes wallpaper scale through") func wallpaperScale() { let config = makeAppConfig(wallpaper: ["location": "bg.mp4", "scale": 1.3]) @@ -330,13 +345,15 @@ private func makeAppConfig( wallpaper: Any? = nil, ai: AIConfig? = nil, ripple: [String: Any]? = nil, - text: [String: Any]? = nil + text: [String: Any]? = nil, + spectrum: [String: Any]? = nil ) -> AppConfig { var fields = [String: Any]() wallpaper.map { fields["wallpaper"] = $0 } ai.map { fields["ai"] = ["endpoint": $0.endpoint, "model": $0.model, "api_key": $0.apiKey] } ripple.map { fields["ripple"] = $0 } text.map { fields["text"] = $0 } + spectrum.map { fields["spectrum"] = $0 } let data = try! JSONSerialization.data(withJSONObject: fields) return try! JSONDecoder().decode(AppConfig.self, from: data) } From 755eaad62a4126efae01c86c86cb4aedf65bea65 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Sat, 4 Jul 2026 01:24:21 +0900 Subject: [PATCH 11/13] =?UTF-8?q?ci(#23):=20SwiftPM=20=E3=81=AE=E6=96=B0?= =?UTF-8?q?=E3=81=97=E3=81=84=20swift-syntax=20identity=20=E8=AD=A6?= =?UTF-8?q?=E5=91=8A=E6=96=87=E8=A8=80=E3=82=92=E9=99=A4=E5=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftPM が同一警告の文言を「conflicts with dependency on」から 「Conflicting identity for swift-syntax」に変更し、既存の除外 フィルタをすり抜けて警告ゲートが誤検知していた。新旧両文言を除外。 --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 34139c95..dc46a40c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,7 @@ jobs: WARNINGS=$(grep "warning:" /tmp/build.log \ | grep -v ".build/" \ | grep -v 'swift-syntax.*conflicts with dependency on' \ + | grep -v 'Conflicting identity for swift-syntax' \ || true) if [ -n "$WARNINGS" ]; then echo "::error::Build produced warnings in project sources" From ec6008737169e0855596e6590e5ad6b738188ea6 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Sat, 4 Jul 2026 01:52:59 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix(#23):=20NowPlayingRepository=20?= =?UTF-8?q?=E3=81=AE=20pump=20=E3=82=92=E4=BA=88=E7=B4=84=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E3=81=AB=E3=81=97=20fetch()=20=E3=82=82=20multicast?= =?UTF-8?q?=20=E7=B5=8C=E7=94=B1=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ensurePumping: .idle → .starting(token) → .running(task) の予約遷移で pump タスクの二重生成を構造的に排除(単一イテレータへの並行 poll 防止)。 即時 EOF で finishAll がハブをリセットした場合はトークン不一致で 完了済みタスクを格納しない(次の購読者の pump 起動を塞がない) - fetch(): dataSource.poll() の直接呼び出しを廃止し、multicast stream の 先頭値を返す形に変更。稼働中の pump とイテレータを奪い合う経路が消える - テスト追加: 並行初回購読者 8 本で maxActivePolls == 1 / EOF リセット後の pump 再起動(退行時はハングせず fail) --- .../NowPlayingRepositoryImpl.swift | 56 +++++--- .../NowPlayingRepositoryTests.swift | 125 ++++++++++++++++++ 2 files changed, 164 insertions(+), 17 deletions(-) diff --git a/Sources/NowPlayingRepository/NowPlayingRepositoryImpl.swift b/Sources/NowPlayingRepository/NowPlayingRepositoryImpl.swift index bb3e8f9c..6a924f23 100644 --- a/Sources/NowPlayingRepository/NowPlayingRepositoryImpl.swift +++ b/Sources/NowPlayingRepository/NowPlayingRepositoryImpl.swift @@ -12,11 +12,23 @@ import os /// live continuations, and a late subscriber immediately receives the last /// seen value so it doesn't wait for the next helper tick (#23). public final class NowPlayingRepositoryImpl: Sendable { + /// Pump lifecycle. The `.starting` reservation guarantees at most one + /// pump task ever exists — two tasks polling the shared iterator + /// concurrently is unsafe — and its token lets the reserving call detect + /// that an immediate EOF reset the hub before the task was stored: + /// adopting the already-finished task there would block every future + /// pump start. + private enum Pump: Sendable { + case idle + case starting(UUID) + case running(Task) + } + private struct Hub: Sendable { var continuations: [UUID: AsyncStream.Continuation] = [:] /// Last broadcast payload; `.some(nil)` means "session gone" was seen. var last: NowPlaying?? = nil - var pump: Task? + var pump: Pump = .idle } private let dataSource: any MediaRemoteDataSource @@ -29,11 +41,13 @@ public final class NowPlayingRepositoryImpl: Sendable { } extension NowPlayingRepositoryImpl: NowPlayingRepository { + /// One-shot snapshot, routed through the same multicast pump: a direct + /// `dataSource.poll()` here would compete with a running pump for the + /// single helper iterator, so the snapshot is the first broadcast value + /// instead — replayed immediately once the pump has seen one. public func fetch() async -> NowPlaying? { - switch await dataSource.poll() { - case .info(let nowPlaying): nowPlaying - case .noInfo, .eof: nil - } + for await value in stream() { return value } + return nil } public func stream() -> AsyncStream { @@ -53,21 +67,29 @@ extension NowPlayingRepositoryImpl: NowPlayingRepository { } extension NowPlayingRepositoryImpl { - /// Starts the shared poll pump if it isn't running. The candidate task is - /// created outside the lock (task creation inside `withLock` trips region - /// isolation); a lost creation race is cancelled, and since every pump - /// broadcasts to all continuations, the overlap window loses no events. + /// Starts the shared poll pump if it isn't running. The slot is reserved + /// under the lock before the task is created outside it (task creation + /// inside `withLock` trips region isolation), so a competing call can + /// never spawn a second pump; the token check keeps a pump that hit EOF + /// before being stored from occupying the freshly reset hub. private func ensurePumping() { - guard hub.withLock({ $0.pump == nil }) else { return } - let candidate = pumpTask() - let adopted = hub.withLock { state in - guard state.pump == nil else { return false } - state.pump = candidate + let token = UUID() + let reserved = hub.withLock { state in + guard case .idle = state.pump else { return false } + state.pump = .starting(token) return true } - guard adopted else { - candidate.cancel() - return + guard reserved else { return } + let task = pumpTask() + hub.withLock { state in + guard case .starting(let current) = state.pump, current == token else { + // The pump hit EOF and `finishAll()` reset the hub before + // this store ran; the task is already finished, and the + // fresh slot belongs to a future pump. + task.cancel() + return + } + state.pump = .running(task) } } diff --git a/Tests/NowPlayingRepositoryTests/NowPlayingRepositoryTests.swift b/Tests/NowPlayingRepositoryTests/NowPlayingRepositoryTests.swift index 9b46db8a..6b1bd330 100644 --- a/Tests/NowPlayingRepositoryTests/NowPlayingRepositoryTests.swift +++ b/Tests/NowPlayingRepositoryTests/NowPlayingRepositoryTests.swift @@ -2,6 +2,7 @@ import Dependencies import Domain import Foundation import Testing +import os @testable import NowPlayingRepository @@ -217,6 +218,76 @@ struct NowPlayingRepositoryTests { gate.send(.eof) } } + + @Test("concurrent first subscribers start exactly one pump") + func concurrentSubscribersSinglePump() async { + let track = NowPlaying( + title: "Solo", artist: "Artist", artworkData: nil, + duration: 100, rawElapsed: 0, playbackRate: 1, timestamp: Date() + ) + let counting = CountingGatedMediaRemoteDataSource() + + await withDependencies { + $0.mediaRemoteDataSource = counting + } operation: { + let repo = NowPlayingRepositoryImpl() + let collectors = (0..<8).map { _ in + Task { () -> NowPlaying?? in + for await value in repo.stream() { return value } + return nil + } + } + counting.send(.info(track)) + + for collector in collectors { + let received = await collector.value + #expect(received??.title == "Solo") + } + // Two pumps polling the single helper iterator concurrently + // would be a contract violation, so at most one poll may ever + // be in flight. + #expect(counting.maxActivePolls == 1) + counting.send(.eof) + } + } + + @Test("EOF resets the hub so a later subscriber starts a fresh pump") + func pumpRestartsAfterEOF() async { + let track = NowPlaying( + title: "Second Life", artist: "Artist", artworkData: nil, + duration: 100, rawElapsed: 0, playbackRate: 1, timestamp: Date() + ) + let counting = CountingGatedMediaRemoteDataSource() + + await withDependencies { + $0.mediaRemoteDataSource = counting + } operation: { + let repo = NowPlayingRepositoryImpl() + counting.send(.eof) + var first = 0 + for await _ in repo.stream() { first += 1 } + #expect(first == 0) + + // A dead pump left stored in the hub would block this second + // round forever; the collector is cancellable so a regression + // fails the test instead of hanging the suite. + let received = ReceivedBox() + let collector = Task { + for await value in repo.stream() { + received.value = value + return + } + } + counting.send(.info(track)) + let deadline = ContinuousClock.now + .seconds(3) + while received.value == nil, ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + collector.cancel() + #expect(received.value??.title == "Second Life") + counting.send(.eof) + } + } } // MARK: - Mock @@ -258,3 +329,57 @@ private final class GatedMediaRemoteDataSource: MediaRemoteDataSource, @unchecke await iterator.next() ?? .eof } } + +/// Gated data source that additionally records how many `poll()` calls are +/// in flight at once, proving the repository never runs two pumps against +/// the single-consumer helper iterator. +private final class CountingGatedMediaRemoteDataSource: MediaRemoteDataSource, @unchecked Sendable { + private struct State { + var active = 0 + var maxActive = 0 + var buffered: [MediaRemotePollResult] = [] + var waiters: [CheckedContinuation] = [] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + var maxActivePolls: Int { state.withLock { $0.maxActive } } + + func send(_ result: MediaRemotePollResult) { + let waiter = state.withLock { state -> CheckedContinuation? in + guard !state.waiters.isEmpty else { + state.buffered.append(result) + return nil + } + return state.waiters.removeFirst() + } + waiter?.resume(returning: result) + } + + func poll() async -> MediaRemotePollResult { + state.withLock { state in + state.active += 1 + state.maxActive = max(state.maxActive, state.active) + } + defer { state.withLock { $0.active -= 1 } } + return await withCheckedContinuation { continuation in + let buffered = state.withLock { state -> MediaRemotePollResult? in + guard !state.buffered.isEmpty else { + state.waiters.append(continuation) + return nil + } + return state.buffered.removeFirst() + } + if let buffered { continuation.resume(returning: buffered) } + } + } +} + +/// Thread-safe capture slot for the cancellable collector task. +private final class ReceivedBox: @unchecked Sendable { + private let state = OSAllocatedUnfairLock(initialState: NowPlaying??.none) + var value: NowPlaying?? { + get { state.withLock { $0 } } + set { state.withLock { $0 = newValue } } + } +} From ad6ec153730d9e40a5a3d2ac776874e57e3f5a3f Mon Sep 17 00:00:00 2001 From: GeneralD Date: Sat, 4 Jul 2026 01:52:59 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix(#23):=20SpectrumInteractor=20?= =?UTF-8?q?=E3=81=AE=20start=20=E4=BA=88=E7=B4=84=E5=8C=96=E3=81=A8=20stop?= =?UTF-8?q?=20=E3=81=AE=20stale=20teardown=20=E3=82=AC=E3=83=BC=E3=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - start(): processor を .idle → .starting(token) → .running(task) の 予約遷移に変更。競合する start() はタスクを生成すらしないため、 敗者がキャンセル前にタップ操作を実行する窓が消える - stop(): 排水後に新世代が採用済みなら teardown をスキップ。 stop 直後の再 start で古いクリーンアップが新しいキャプチャを 破壊して capturing を false に戻す競合を防ぐ - テスト追加: 二重 start で単一プロセッサ / stop→start の再開。 playback スタブを履歴 replay 付き multicast 化(live の NowPlayingRepository.stream() と同じ購読モデル) --- .../SpectrumInteractorImpl.swift | 53 +++++++++---- .../SpectrumInteractorImplTests.swift | 77 ++++++++++++++++--- 2 files changed, 107 insertions(+), 23 deletions(-) diff --git a/Sources/SpectrumInteractor/SpectrumInteractorImpl.swift b/Sources/SpectrumInteractor/SpectrumInteractorImpl.swift index 7648d994..7d85de9f 100644 --- a/Sources/SpectrumInteractor/SpectrumInteractorImpl.swift +++ b/Sources/SpectrumInteractor/SpectrumInteractorImpl.swift @@ -17,6 +17,17 @@ import os /// `@unchecked` only because Combine subjects are not `Sendable`; the /// `capturingSubject` is the sole shared state and is immutable (`let`). public final class SpectrumInteractorImpl: @unchecked Sendable { + /// Processor lifecycle. The `.starting` reservation guarantees at most + /// one event loop ever runs — a competing `start()` never even creates a + /// task, so no loser can drive the tap lifecycle before its cancellation + /// lands. The token lets the reserving call detect that `stop()` cleared + /// the slot while the task was being created. + private enum Processor: Sendable { + case idle + case starting(UUID) + case running(Task) + } + // Stored wrappers capture the dependency context at init, so instances // built inside `withDependencies` keep their fakes when methods run // outside that scope (the processor task). @@ -24,7 +35,7 @@ public final class SpectrumInteractorImpl: @unchecked Sendable { @Dependency(\.playbackUseCase) private var playbackService @Dependency(\.spectrumUseCase) private var spectrumService private let capturingSubject = CurrentValueSubject(false) - private let processor = OSAllocatedUnfairLock(initialState: Task?.none) + private let processor = OSAllocatedUnfairLock(initialState: Processor.idle) public init() {} } @@ -40,6 +51,13 @@ extension SpectrumInteractorImpl: SpectrumInteractor { public func start() { guard spectrumStyle.enabled else { return } + let token = UUID() + let reserved = processor.withLock { state in + guard case .idle = state else { return false } + state = .starting(token) + return true + } + guard reserved else { return } let spectrum = spectrumService let subject = capturingSubject let playback = playbackService @@ -63,36 +81,45 @@ extension SpectrumInteractorImpl: SpectrumInteractor { subject.send(await spectrum.startCapture(pid: pid)) } // Upstream finished (helper EOF): tear the capture down. A - // cancelled task skips this — stop() owns that teardown, and a - // start/start race loser must not destroy the winner's capture. + // cancelled task skips this — stop() owns that teardown. guard !Task.isCancelled else { return } await spectrum.stopCapture() subject.send(false) } - let adopted = processor.withLock { task in - guard task == nil else { return false } - task = candidate - return true - } - guard adopted else { - candidate.cancel() - return + processor.withLock { state in + guard case .starting(let current) = state, current == token else { + // stop() cleared the reservation while the task was being + // created; the candidate must not drive the tap lifecycle. + candidate.cancel() + return + } + state = .running(candidate) } } public func stop() { - let stopped = processor.withLock { task in - defer { task = nil } + let stopped = processor.withLock { state -> Task? in + defer { state = .idle } + guard case .running(let task) = state else { return nil } return task } guard let stopped else { return } stopped.cancel() let spectrum = spectrumService let subject = capturingSubject + let processor = processor Task { // Awaiting the cancelled processor first keeps this teardown // ordered after its in-flight capture call. await stopped.value + // A restart may have taken ownership while the old processor + // drained; the new generation owns the tap then, and a stale + // teardown must not destroy its capture. + let superseded = processor.withLock { state in + if case .idle = state { return false } + return true + } + guard !superseded else { return } await spectrum.stopCapture() subject.send(false) } diff --git a/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift b/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift index b82d0164..bf9553f0 100644 --- a/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift +++ b/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift @@ -98,6 +98,38 @@ struct SpectrumInteractorImplTests { #expect(harness.spectrum.stopCount > 0) } + @Test("second start() while running never spawns a competing processor") + func startTwiceKeepsSingleProcessor() async { + let harness = Harness() + harness.interactor.start() + harness.interactor.start() + harness.send(pid: 4242, playbackRate: 1) + // Pause marker: once it lands, the play event was fully evaluated + // by however many processors exist — a competing second processor + // would have doubled the capture start. + harness.send(pid: 4242, playbackRate: 0) + + await harness.pollUntil { harness.spectrum.stopCount > 0 } + #expect(harness.spectrum.startedPids == [4242]) + } + + @Test("stop then start captures again for the new session") + func restartAfterStopCapturesAgain() async { + let harness = Harness() + harness.interactor.start() + harness.send(pid: 4242, playbackRate: 1) + await harness.pollUntil { harness.capturing.value == true } + + harness.interactor.stop() + harness.interactor.start() + // The new processor replays the history and re-captures; the stale + // teardown from stop() must not destroy the new capture. + await harness.pollUntil { harness.spectrum.startedPids.count == 2 } + #expect(harness.spectrum.startedPids == [4242, 4242]) + await harness.pollUntil { harness.capturing.value == true } + #expect(harness.capturing.value == true) + } + @Test("magnitudes forwards the configured style to the use case") func magnitudesForwards() { let harness = Harness() @@ -123,19 +155,17 @@ struct SpectrumInteractorImplTests { private struct Harness { let style: SpectrumStyle let spectrum = FakeSpectrumUseCase() + let playback = StubPlaybackUseCase() let capturing = CurrentValueBox() let interactor: SpectrumInteractorImpl - private let feed: AsyncStream.Continuation private let cancellable: AnyCancellable init(enabled: Bool = true) { let style = SpectrumStyle(enabled: enabled, barCount: 16, fftSize: 1024) self.style = style - let (stream, continuation) = AsyncStream.makeStream() - feed = continuation - let interactor = withDependencies { [spectrum] in + let interactor = withDependencies { [spectrum, playback] in $0.configUseCase = StubConfigUseCase(appStyle: AppStyle(spectrum: style)) - $0.playbackUseCase = StubPlaybackUseCase(stream: stream) + $0.playbackUseCase = playback $0.spectrumUseCase = spectrum } operation: { SpectrumInteractorImpl() @@ -145,14 +175,14 @@ private struct Harness { } func send(pid: Int?, playbackRate: Double) { - feed.yield( + playback.send( NowPlaying( title: nil, artist: nil, artworkData: nil, duration: nil, rawElapsed: nil, playbackRate: playbackRate, timestamp: nil, pid: pid)) } func sendSessionGone() { - feed.yield(nil) + playback.send(nil) } func pollUntil(_ condition: () -> Bool) async { @@ -202,9 +232,36 @@ private struct StubConfigUseCase: ConfigUseCase { var existingConfigPath: String? { nil } } -private struct StubPlaybackUseCase: PlaybackUseCase { - let stream: AsyncStream +/// Multicast playback stub mirroring the live repository: every +/// `observeNowPlaying()` call gets its own stream, events fan out to all +/// subscribers, and history replays to late subscribers so events sent +/// before the processor task registers are never lost. +private final class StubPlaybackUseCase: PlaybackUseCase, @unchecked Sendable { + private struct State { + var subscribers: [AsyncStream.Continuation] = [] + var history: [NowPlaying?] = [] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + func fetchNowPlaying() async -> NowPlaying? { nil } - func observeNowPlaying() -> AsyncStream { stream } + + func observeNowPlaying() -> AsyncStream { + AsyncStream { continuation in + state.withLock { state in + for value in state.history { continuation.yield(value) } + state.subscribers.append(continuation) + } + } + } + func elapsedTime(for nowPlaying: NowPlaying) -> TimeInterval? { nil } + + func send(_ value: NowPlaying?) { + let subscribers = state.withLock { state -> [AsyncStream.Continuation] in + state.history.append(value) + return state.subscribers + } + for subscriber in subscribers { subscriber.yield(value) } + } }