Skip to content

refactor(#299): スペクトラム平滑化をリフレッシュレート非依存化#304

Open
GeneralD wants to merge 4 commits into
mainfrom
feat/#299-spectrum-framerate
Open

refactor(#299): スペクトラム平滑化をリフレッシュレート非依存化#304
GeneralD wants to merge 4 commits into
mainfrom
feat/#299-spectrum-framerate

Conversation

@GeneralD

@GeneralD GeneralD commented Jul 4, 2026

Copy link
Copy Markdown
Owner

agent type breaking scope tests review follow--up

概要

#299 のうち 課題2(平滑化定数が 60fps 前提) に対応する。cava の平滑化定数は 66fps 基準(framerate_mod = 66 / fps)だが、移植時に 66 / 60 固定だったため、CADisplayLink 駆動の tick() が 120Hz ProMotion / 可変リフレッシュでは倍の頻度で発火し、バーの落下が 2倍速 にズレていた。実フレーム間隔を平滑化まで伝搬させて非依存化する。

変更内容

  • DisplayLinkDriver: CADisplayLink の実フレーム間隔(targetTimestamp - timestamp)を onFrame ファンアウト経由で SpectrumPresenter.tick(frameInterval:) まで伝搬。
  • 純粋関数 spectrumFramerateConstants(frameInterval:): framerateMod / integralMod / gravityScale をクランプ済み(24…240fps)レートから毎フレーム導出 → 落下速度・積分減衰・autosens が実時間でどのリフレッシュレートでも同じ速さに収束。
  • tick(frameInterval: Double = 1.0/60.0): デフォルト 1/60 で 60Hz 挙動は完全不変、タイミング非依存の呼び出し元/テストも無改修。

背景・動機

#298 のレビュー(CodeRabbit / Copilot)で挙がった HW 依存 2 件のうち 2 件目。1 件目(サンプルレート伝搬)は #303

⚠️ 実機再チューニングが必要(フレームワークのみ)

現在の見た目は実機 120Hz で 66/60 固定のまま詰めたものです。本 PR で物理的に正すと、120Hz での落下が正しく(従来比で遅く)なるため、体感が変わります。ベースの「詰め」定数(fallIncrement ・66 基準・autosens ステップ)は据え置きにしてあり、実機 120Hz での最終チューニングは別途お願いします(このフレームワーク導入後に定数だけ調整可能)。

テスト計画

  • spectrumFramerateConstants 純粋関数: 60fps で従来値一致 / 120fps で半減 / 絶対値・負値・ゼロのクランプ / 導出フィールドの関係
  • SpectrumPresenter: リフレッシュレート非依存の落下(120Hz は 60Hz より多フレームで消える)
  • AppRouter / DisplayLinkDriveronFrame シグネチャ変更に追従
  • 全テスト 1085 件パス、make lint クリーン

備考

  • 視覚差分は 120Hz 実機でのみ現れる(60Hz は不変)ため、キャプチャは実機確認時に。
  • バージョンを 2.20.2 に更新。

Refs #299, #298

Summary by CodeRabbit

  • New Features

    • Spectrum smoothing now adapts to the display’s refresh rate for more consistent animation behavior across different screens.
    • Frame updates now carry timing information, improving per-frame motion handling.
  • Bug Fixes

    • Spectrum visual decay and sensitivity changes now feel more uniform at 60 Hz, 120 Hz, and other refresh rates.
    • Extreme or invalid frame timings are safely handled with sensible fallbacks.
  • Chores

    • Updated the app version to 2.20.2.

GeneralD added 2 commits July 4, 2026 22:43
cava の平滑化定数は 66fps 基準 (framerate_mod = 66/fps) だが、移植時に
66/60 固定だったため 120Hz ProMotion / 可変リフレッシュでは CADisplayLink
の tick が倍の頻度で発火し、バーの落下が2倍速にズレていた。#298 レビュー
指摘の HW 依存 2 件目 (課題2)。

- DisplayLinkDriver が CADisplayLink の実フレーム間隔
  (targetTimestamp - timestamp) を onFrame ファンアウト経由で
  SpectrumPresenter.tick(frameInterval:) まで伝搬。
- 純粋関数 spectrumFramerateConstants(frameInterval:) が
  framerateMod / integralMod / gravityScale をクランプ済み (24…240fps)
  レートから毎フレーム導出 → 落下速度・積分減衰・autosens が実時間で
  どのリフレッシュレートでも同じ速さに収束。
- tick() は frameInterval を 1/60 デフォルトにし、60Hz 挙動は完全不変、
  タイミング非依存の呼び出し元/テストも無改修。
- ベースの「詰め」定数 (fallIncrement・66 基準・autosens ステップ) は
  据え置き。物理を正すと 120Hz で従来補正していた見た目が変わるため、
  実機 120Hz での再チューニングは別途 (フレームワークのみ本 PR)。

導出の純粋関数 (60fps一致・120fps半減・クランプ) と、リフレッシュレート
非依存の落下 (120Hz は 60Hz より多フレームで消える) をユニットテスト検証。

Refs #299
Copilot AI review requested due to automatic review settings July 4, 2026 13:43
@GeneralD GeneralD self-assigned this Jul 4, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR propagates real per-frame timing intervals from DisplayLinkDriver through AppRouter into SpectrumPresenter.tick, replacing fixed 60fps smoothing constants with interval-derived, clamped framerate compensation constants. Tests, documentation, and version file are updated accordingly.

Changes

Refresh-rate-independent spectrum smoothing

Layer / File(s) Summary
DisplayLinkDriver frame interval emission
Sources/Views/Shared/DisplayLinkDriver.swift
onFrame callback and initializer now accept a Double interval computed as targetTimestamp minus timestamp, replacing the parameterless callback.
AppRouter frame handler wiring with interval
Sources/AppRouter/AppRouter.swift
frameSchedulerFactory, defaultFrameSchedulerFactory, and init signatures accept (Double) -> Void; start() forwards the interval to feature handlers, with Spectrum calling tick(frameInterval:).
SpectrumPresenter framerate-derived constants
Sources/Presenters/Spectrum/SpectrumPresenter.swift
tick(frameInterval:) derives SpectrumFramerateConstants via a new spectrumFramerateConstants(frameInterval:) function with fps clamping (24-240) and 60fps fallback for non-finite intervals; replaces fixed constants in stepped filtering and adjustSens/sensFactor.
Tests and documentation for interval-aware behavior
Tests/AppRouterTests/AppLaunchEnvironmentTests.swift, Tests/PresentersTests/SpectrumPresenterTests.swift, .claude/CLAUDE.md, Sources/VersionHandler/Resources/version.txt
Updates frame-scheduler tests for Double intervals, adds spectrum-enabled tick test, validates framerate constants at 60/120Hz plus clamping and non-finite handling, tests refresh-rate-independent bar-fall timing, documents the design decision, and bumps version to 2.20.2.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DisplayLinkDriver
  participant AppRouter
  participant SpectrumPresenter

  DisplayLinkDriver->>DisplayLinkDriver: tick(_:) computes targetTimestamp - timestamp
  DisplayLinkDriver->>AppRouter: onFrame(interval)
  AppRouter->>SpectrumPresenter: tick(frameInterval: interval)
  SpectrumPresenter->>SpectrumPresenter: spectrumFramerateConstants(frameInterval:)
  SpectrumPresenter->>SpectrumPresenter: stepped(...) using gravityScale/integralMod
  SpectrumPresenter->>SpectrumPresenter: adjustSens(framerateMod:)
Loading

Possibly related PRs

  • GeneralD/lyra#280: Both PRs modify the AppRouter per-frame onFrame scheduling path affecting when ripplePresenter?.idle() is invoked.
  • GeneralD/lyra#296: The retrieved PR introduces the SpectrumPresenter overlay pipeline that this PR directly modifies via tick and frame callback plumbing.
  • GeneralD/lyra#298: Both PRs modify the same SpectrumPresenter smoothing/tuning logic, this PR extending it to be refresh-rate independent.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: making spectrum smoothing refresh-rate independent for #299.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#299-spectrum-framerate
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/#299-spectrum-framerate

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the Spectrum smoothing pipeline to be refresh-rate independent by propagating the real CADisplayLink frame interval through the frame fan-out and deriving cava-style smoothing constants per frame. This aligns Spectrum fall/decay/autosens behavior to wall-clock time across 60 Hz, 120 Hz ProMotion, and variable refresh displays while keeping the default 60 Hz behavior unchanged for timing-agnostic callers/tests.

Changes:

  • Propagate frameInterval (targetTimestamp - timestamp) from DisplayLinkDriverAppRouter frame fan-out → SpectrumPresenter.tick(frameInterval:).
  • Introduce spectrumFramerateConstants(frameInterval:) and use its derived constants to scale gravity/integral/autosens per frame (with 24…240 fps clamp).
  • Add/adjust tests and docs; bump version to 2.20.2.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
Tests/PresentersTests/SpectrumPresenterTests.swift Adds unit tests for framerate-derived constants and refresh-rate-independent fall behavior.
Tests/AppRouterTests/AppLaunchEnvironmentTests.swift Updates frame scheduler test scaffolding for the new onFrame(Double) signature.
Sources/Views/Shared/DisplayLinkDriver.swift Emits real per-frame interval to consumers via onFrame(frameInterval:).
Sources/Presenters/Spectrum/SpectrumPresenter.swift Adds tick(frameInterval:) and per-frame framerate compensation constants + pure derivation function.
Sources/AppRouter/AppRouter.swift Updates frame fan-out plumbing to pass frameInterval to enabled handlers (Spectrum).
Sources/VersionHandler/Resources/version.txt Version bump to 2.20.2.
.claude/CLAUDE.md Documents the refresh-rate-independent smoothing design and intent (#299).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +206 to +211
func spectrumFramerateConstants(frameInterval: Double) -> SpectrumFramerateConstants {
let fps = min(max(1 / max(frameInterval, .leastNormalMagnitude), 24), 240)
let mod = Float(66 / fps)
return SpectrumFramerateConstants(
framerateMod: mod, integralMod: pow(mod, 0.1), gravityScale: pow(mod, 2.5) * 2)
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent action

isFinite ガードを追加し、NaN / ±∞ のフレーム間隔は 60fps 基準にフォールバックするようにしました(min/max が NaN を素通りして framerateMod を汚染する問題を解消)。ゼロ/負の有限値は従来どおり 24…240fps クランプで処理します。非有限入力の回帰テストも追加しました。4558984

@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.37500% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
Sources/Views/Shared/DisplayLinkDriver.swift 20.00% 4 Missing ⚠️
Sources/AppRouter/AppRouter.swift 87.50% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

GeneralD added 2 commits July 5, 2026 01:44
NaN / ±∞ のフレームタイムスタンプは min/max を素通りして framerateMod を
NaN 汚染し、以降のスムージング定数を壊す。isFinite で弾いて 60fps 基準に
フォールバックさせる。ゼロ/負の有限値は従来どおり 24…240fps クランプで処理。

Copilot 指摘への対応。非有限入力の回帰テストを追加。
frameHandlers内のspectrumPresenter.tick(frameInterval:)呼び出しが
未カバーだったため、既存のripple版テストに倣いFixtureSpectrumInteractor
を追加してカバレッジの穴を埋める。

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
Tests/AppRouterTests/AppLaunchEnvironmentTests.swift (1)

78-86: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

New spectrum-tick test doesn't exercise tick(frameInterval:).

FixtureSpectrumInteractor.isCapturing returns a permanently-empty publisher (Line 81), so capturing on SpectrumPresenter never becomes true. Since motion starts empty, tick's guard (capturing || !motion.isEmpty) short-circuits before any of the new frame-interval logic runs. The test's only assertions — isEnabled == true and startCallCount == 1 (Lines 513, 517) — are already true before driver.fire(frameInterval:) is called, so this test doesn't actually validate that the propagated interval reaches SpectrumPresenter.tick(frameInterval:) or produces any effect, despite the PR's stated goal of covering the AppRouter spectrum tick path.

♻️ Suggested fix: make capturing controllable and assert on tick effects
 private struct FixtureSpectrumInteractor: SpectrumInteractor {
     let spectrumStyle: SpectrumStyle
+    let capturingSubject = CurrentValueSubject<Bool, Never>(false)

-    var isCapturing: AnyPublisher<Bool, Never> { Empty().eraseToAnyPublisher() }
+    var isCapturing: AnyPublisher<Bool, Never> { capturingSubject.eraseToAnyPublisher() }
     func start() {}
     func stop() {}
     func magnitudes(barCount: Int) -> [Float] { Array(repeating: 0.5, count: barCount) }
 }
+                let spectrumInteractor = FixtureSpectrumInteractor(spectrumStyle: SpectrumStyle(enabled: true))
-                dependencies.spectrumInteractor = FixtureSpectrumInteractor(spectrumStyle: SpectrumStyle(enabled: true))
+                dependencies.spectrumInteractor = spectrumInteractor
...
         let spectrumPresenter: SpectrumPresenter? = value(named: "spectrumPresenter", from: router)
         `#expect`(spectrumPresenter?.isEnabled == true)

+        spectrumInteractor.capturingSubject.send(true)
         driver.fire(frameInterval: 1.0 / 60.0)

+        `#expect`(spectrumPresenter?.isAnimating == true)
         `#expect`(driver.startCallCount == 1)

Also applies to: 486-519

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/AppRouterTests/AppLaunchEnvironmentTests.swift` around lines 78 - 86,
The new spectrum-tick test currently never drives
SpectrumPresenter.tick(frameInterval:) because
FixtureSpectrumInteractor.isCapturing always stays empty, so capturing never
becomes true and the tick guard short-circuits. Make FixtureSpectrumInteractor
controllable (or otherwise emit a capturing value) so the test can actually
exercise the frameInterval path through AppRouter and SpectrumPresenter. Then
assert on a tick-specific effect from driver.fire(frameInterval:) rather than
only on pre-existing state like isEnabled and startCallCount, using the
SpectrumPresenter and driver.fire hooks to prove the interval is propagated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@Tests/AppRouterTests/AppLaunchEnvironmentTests.swift`:
- Around line 78-86: The new spectrum-tick test currently never drives
SpectrumPresenter.tick(frameInterval:) because
FixtureSpectrumInteractor.isCapturing always stays empty, so capturing never
becomes true and the tick guard short-circuits. Make FixtureSpectrumInteractor
controllable (or otherwise emit a capturing value) so the test can actually
exercise the frameInterval path through AppRouter and SpectrumPresenter. Then
assert on a tick-specific effect from driver.fire(frameInterval:) rather than
only on pre-existing state like isEnabled and startCallCount, using the
SpectrumPresenter and driver.fire hooks to prove the interval is propagated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 47326dbb-52ad-4324-ad19-5faed5006def

📥 Commits

Reviewing files that changed from the base of the PR and between 06e0a58 and 37a4541.

📒 Files selected for processing (7)
  • .claude/CLAUDE.md
  • Sources/AppRouter/AppRouter.swift
  • Sources/Presenters/Spectrum/SpectrumPresenter.swift
  • Sources/VersionHandler/Resources/version.txt
  • Sources/Views/Shared/DisplayLinkDriver.swift
  • Tests/AppRouterTests/AppLaunchEnvironmentTests.swift
  • Tests/PresentersTests/SpectrumPresenterTests.swift

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants