Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions .claude/CLAUDE.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

79 changes: 79 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -382,7 +407,7 @@ api_key = "sk-..."

## Requirements

- macOS 14+
- macOS 14+ (spectrum analyzer requires macOS 14.4+)
- Swift 6.0+

## License
Expand Down
45 changes: 32 additions & 13 deletions Sources/AppRouter/AppRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -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
)
},
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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()

Expand All @@ -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)
Expand All @@ -136,6 +152,9 @@ public final class AppRouter {
ripplePresenter?.stop()
defer { ripplePresenter = nil }

spectrumPresenter?.stop()
defer { spectrumPresenter = nil }

frameScheduler?.stop()
defer { frameScheduler = nil }

Expand Down
25 changes: 25 additions & 0 deletions Sources/AudioCaptureRepository/AudioCaptureRepositoryImpl.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading