diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4388e16d..1501a17e 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 @@ -85,6 +86,7 @@ graph TD LyricsUseCase[LyricsUseCase] MetadataUseCase[MetadataUseCase] WallpaperUseCase[WallpaperUseCase] + SpectrumUseCase[SpectrumUseCase] end subgraph Repository @@ -93,6 +95,7 @@ graph TD MetadataRepository[MetadataRepository] NowPlayingRepository[NowPlayingRepository] WallpaperRepository[WallpaperRepository] + AudioCaptureRepository[AudioCaptureRepository] end subgraph DataSource @@ -101,12 +104,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 +138,10 @@ graph TD TrackInteractor -.-> PlaybackUseCase & MetadataUseCase & LyricsUseCase & ConfigUseCase ScreenInteractor -.-> ConfigUseCase WallpaperInteractor -.-> WallpaperUseCase & ConfigUseCase + SpectrumInteractor -.-> ConfigUseCase & PlaybackUseCase & SpectrumUseCase + SpectrumUseCase -.-> AudioCaptureRepository + SpectrumUseCase --> FrequencyAnalyzer + AudioCaptureRepository -.-> AudioTapDataSource ConfigUseCase -.-> ConfigRepository ConfigRepository -.-> ConfigDataSource PlaybackUseCase -.-> NowPlayingRepository @@ -155,6 +164,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 @@ -163,19 +173,23 @@ 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 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 +197,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 +222,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 | +| 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 | ### Key Design Decisions @@ -232,9 +246,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: `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`. -**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. @@ -291,7 +307,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/.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" 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..b93e6391 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,21 +198,25 @@ let package = Package( "TrackInteractor", "ScreenInteractor", "WallpaperInteractor", + "SpectrumInteractor", "ConfigUseCase", "PlaybackUseCase", "LyricsUseCase", "MetadataUseCase", "WallpaperUseCase", + "SpectrumUseCase", "ConfigRepository", "LyricsRepository", "MetadataRepository", "NowPlayingRepository", "WallpaperRepository", + "AudioCaptureRepository", "ConfigDataSource", "LyricsDataSource", "MetadataDataSource", "MediaRemoteDataSource", "WallpaperDataSource", + "AudioTapDataSource", "SQLiteDataStore", "DarwinGateway", "ProcessHandler", @@ -248,6 +253,38 @@ let package = Package( .product(name: "Dependencies", package: "swift-dependencies"), ] ), + .target( + name: "SpectrumInteractor", + dependencies: [ + "Domain", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + + // ── SpectrumUseCase ── + .target( + name: "SpectrumUseCase", + dependencies: [ + "Domain", + "FrequencyAnalyzer", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + + // ── AudioCaptureRepository ── + .target( + name: "AudioCaptureRepository", + dependencies: [ + "Domain", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + + // ── FrequencyAnalyzer ── + .target( + name: "FrequencyAnalyzer", + dependencies: [] + ), // ── UseCase ── .target( @@ -363,6 +400,13 @@ let package = Package( .product(name: "Files", package: "Files"), ] ), + .target( + name: "AudioTapDataSource", + dependencies: [ + "Domain", + .product(name: "Atomics", package: "swift-atomics"), + ] + ), // ── DataStore ── .target( @@ -423,6 +467,41 @@ let package = Package( .product(name: "Dependencies", package: "swift-dependencies"), ] ), + .testTarget( + name: "SpectrumInteractorTests", + dependencies: [ + "SpectrumInteractor", + "Domain", + .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"] + ), + .testTarget( + name: "AudioTapDataSourceTests", + dependencies: [ + "AudioTapDataSource", + "Domain", + ] + ), .testTarget(name: "EntityTests", dependencies: ["Entity"]), .testTarget( name: "AsyncRunnableCommandTests", diff --git a/README.md b/README.md index 2761bdb4..432b2a0c 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 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]` 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 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/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/AudioTapDataSource/AudioTapDataSourceImpl.swift b/Sources/AudioTapDataSource/AudioTapDataSourceImpl.swift new file mode 100644 index 00000000..0741d022 --- /dev/null +++ b/Sources/AudioTapDataSource/AudioTapDataSourceImpl.swift @@ -0,0 +1,56 @@ +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 } + // 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 { + 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..0a9fd116 --- /dev/null +++ b/Sources/AudioTapDataSource/ProcessTapEngine.swift @@ -0,0 +1,187 @@ +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 + + 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: processObjects) + 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) + } + } + + /// 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: kAudioHardwarePropertyProcessObjectList, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + 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, 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 false + } + + /// 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..b181b084 100644 --- a/Sources/ConfigRepository/ConfigRepositoryImpl.swift +++ b/Sources/ConfigRepository/ConfigRepositoryImpl.swift @@ -35,6 +35,21 @@ extension ConfigRepositoryImpl: ConfigRepository { idle: config.ripple.idle.value, shape: config.ripple.shape ), + spectrum: SpectrumStyle( + enabled: config.spectrum.enabled, + // 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: max(64, 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/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/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/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/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..) + } + + 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: Pump = .idle + } - public init() {} + private let dataSource: any MediaRemoteDataSource + private let hub = OSAllocatedUnfairLock(initialState: Hub()) + + public init() { + @Dependency(\.mediaRemoteDataSource) var dataSource + self.dataSource = dataSource + } } 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 { - 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 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() { + let token = UUID() + let reserved = hub.withLock { state in + guard case .idle = state.pump else { return false } + state.pump = .starting(token) + return true + } + 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) + } + } + + 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/Presenters/Spectrum/SpectrumPresenter.swift b/Sources/Presenters/Spectrum/SpectrumPresenter.swift new file mode 100644 index 00000000..c0a8d900 --- /dev/null +++ b/Sources/Presenters/Spectrum/SpectrumPresenter.swift @@ -0,0 +1,76 @@ +import Combine +import Dependencies +import Domain +import Foundation + +/// Display state for the spectrum analyzer overlay (#23). +/// +/// The DisplayLink calls `tick()` once per frame to fold the newest FFT +/// magnitudes into an exponentially decaying bar array; the View's Canvas +/// reads the result through `binHeights()`, which never mutates state — a +/// Canvas draw closure runs during view update, where publishing changes is +/// illegal. `isAnimating` gates the View's `TimelineView` exactly like +/// `RipplePresenter` (#258): while nothing is captured and every bar has +/// decayed away, the timeline pauses and the Canvas stops redrawing. +@MainActor +public final class SpectrumPresenter: ObservableObject { + @Dependency(\.spectrumInteractor) private var interactor + + @Published public private(set) var isAnimating = false + private var currentBins: [Float] = [] + private var capturing = false + private var cancellable: AnyCancellable? + + public init() {} + + public var isEnabled: Bool { interactor.spectrumStyle.enabled } + public var style: SpectrumStyle { interactor.spectrumStyle } + + public func start() { + guard isEnabled else { return } + interactor.start() + cancellable = interactor.isCapturing + .receive(on: DispatchQueue.main) + .sink { [weak self] value in self?.capturing = value } + } + + public func stop() { + cancellable = nil + interactor.stop() + capturing = false + } + + /// DisplayLink frame tick: merges the newest magnitudes with the decayed + /// previous frame and updates the animation flag. + public func tick() { + guard capturing || !currentBins.isEmpty else { return } + let style = interactor.spectrumStyle + let fresh = capturing ? interactor.magnitudes() : [] + let decayed = currentBins.map { $0 * Float(style.decayRate) } + currentBins = merged(fresh: fresh, decayed: decayed, barCount: style.barCount) + let animating = capturing || currentBins.contains { $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..) + } + + // 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(\.spectrumUseCase) private var spectrumService + private let capturingSubject = CurrentValueSubject(false) + private let processor = OSAllocatedUnfairLock(initialState: Processor.idle) + + 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 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 + // A single for-await loop is the serialization point: events apply + // 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() { + 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 spectrum.stopCapture() + subject.send(false) + continue + } + subject.send(await spectrum.startCapture(pid: pid)) + } + // Upstream finished (helper EOF): tear the capture down. A + // cancelled task skips this — stop() owns that teardown. + guard !Task.isCancelled else { return } + await spectrum.stopCapture() + subject.send(false) + } + 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 { 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) + } + } + + public func magnitudes() -> [Float] { + 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/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 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 diff --git a/Tests/AppRouterTests/AppLaunchEnvironmentTests.swift b/Tests/AppRouterTests/AppLaunchEnvironmentTests.swift index ef9571c1..a64ab678 100644 --- a/Tests/AppRouterTests/AppLaunchEnvironmentTests.swift +++ b/Tests/AppRouterTests/AppLaunchEnvironmentTests.swift @@ -301,7 +301,7 @@ struct AppRouterTests { .lyricsLines: "One\nTwo", ] ), - windowFactory: { _, _, _, _, _ in window }, + windowFactory: { _, _, _, _, _, _ in window }, frameSchedulerFactory: { onFrame in driver.onFrame = onFrame return driver @@ -350,7 +350,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 +408,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 +455,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 +624,7 @@ struct AccessibilityHooksTests { headerPresenter: headerPresenter, lyricsPresenter: lyricsPresenter, ripplePresenter: overlayRipplePresenter, + spectrumPresenter: SpectrumPresenter(), wallpaperPresenter: wallpaperPresenter ), size: CGSize(width: 800, height: 500) 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/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/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) } 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/NowPlayingRepositoryTests/NowPlayingRepositoryTests.swift b/Tests/NowPlayingRepositoryTests/NowPlayingRepositoryTests.swift index 422f7d0e..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 @@ -153,6 +154,140 @@ 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) + } + } + + @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 @@ -172,3 +307,79 @@ 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 + } +} + +/// 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 } } + } +} 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..bf9553f0 --- /dev/null +++ b/Tests/SpectrumInteractorTests/SpectrumInteractorImplTests.swift @@ -0,0 +1,267 @@ +@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 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.spectrum.startedPids == [4242] } + #expect(harness.spectrum.startedPids == [4242]) + await harness.pollUntil { harness.capturing.value == true } + #expect(harness.capturing.value == true) + } + + @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.spectrum.stopCount > 0 } + #expect(harness.spectrum.stopCount > 0) + await harness.pollUntil { harness.capturing.value == false } + #expect(harness.capturing.value == false) + } + + @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.spectrum.startedPids == [1, 2] } + #expect(harness.spectrum.startedPids == [1, 2]) + } + + @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.spectrum.stopCount > 0 } + #expect(harness.spectrum.startedPids == [4242]) + } + + @Test("vanished session stops the capture") + func sessionGoneStopsCapture() 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.spectrum.stopCount > 0) + } + + @Test("disabled spectrum never subscribes nor captures") + func disabledIsInert() { + let harness = Harness(enabled: false) + harness.interactor.start() + harness.send(pid: 4242, playbackRate: 1) + + // 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 capture") + func stopTearsDown() async { + let harness = Harness() + harness.interactor.start() + harness.send(pid: 4242, playbackRate: 1) + await harness.pollUntil { harness.capturing.value == true } + + harness.interactor.stop() + await harness.pollUntil { harness.spectrum.stopCount > 0 } + #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() + 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 magnitudesEmptyWithoutCapture() { + 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. 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 spectrum = FakeSpectrumUseCase() + let playback = StubPlaybackUseCase() + let capturing = CurrentValueBox() + let interactor: SpectrumInteractorImpl + private let cancellable: AnyCancellable + + init(enabled: Bool = true) { + let style = SpectrumStyle(enabled: enabled, barCount: 16, fftSize: 1024) + self.style = style + let interactor = withDependencies { [spectrum, playback] in + $0.configUseCase = StubConfigUseCase(appStyle: AppStyle(spectrum: style)) + $0.playbackUseCase = playback + $0.spectrumUseCase = spectrum + } operation: { + SpectrumInteractorImpl() + } + self.interactor = interactor + self.cancellable = interactor.isCapturing.sink { [capturing] in capturing.value = $0 } + } + + func send(pid: Int?, playbackRate: Double) { + playback.send( + NowPlaying( + title: nil, artist: nil, artworkData: nil, duration: nil, + rawElapsed: nil, playbackRate: playbackRate, timestamp: nil, pid: pid)) + } + + func sendSessionGone() { + playback.send(nil) + } + + 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 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 startCapture(pid: Int) async -> Bool { + state.withLock { $0.started.append(pid) } + return true + } + + func stopCapture() async { + state.withLock { $0.stops += 1 } + } + + func magnitudes(style: SpectrumStyle) -> [Float] { + state.withLock { $0.style = style } + return magnitudesResult + } +} + +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 } +} + +/// 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 { + 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) } + } +} 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)) : [] + } +} diff --git a/Tests/ViewsTests/ViewRenderingTests.swift b/Tests/ViewsTests/ViewRenderingTests.swift index c03af584..dfb719e1 100644 --- a/Tests/ViewsTests/ViewRenderingTests.swift +++ b/Tests/ViewsTests/ViewRenderingTests.swift @@ -417,6 +417,7 @@ struct OverlayContentViewLoadingTests { headerPresenter: headerPresenter, lyricsPresenter: lyricsPresenter, ripplePresenter: ripplePresenter, + spectrumPresenter: SpectrumPresenter(), wallpaperPresenter: wallpaperPresenter ), size: CGSize(width: 800, height: 500) @@ -462,6 +463,7 @@ struct OverlayContentViewLoadingTests { headerPresenter: headerPresenter, lyricsPresenter: lyricsPresenter, ripplePresenter: ripplePresenter, + spectrumPresenter: SpectrumPresenter(), wallpaperPresenter: wallpaperPresenter ), size: CGSize(width: 800, height: 500)