Skip to content

feat(cdg): overhaul CD+G playback for correctness and robustness#113

Open
thedavidweng wants to merge 13 commits into
mainfrom
feat/cdg-harden-99
Open

feat(cdg): overhaul CD+G playback for correctness and robustness#113
thedavidweng wants to merge 13 commits into
mainfrom
feat/cdg-harden-99

Conversation

@thedavidweng

@thedavidweng thedavidweng commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Summary

Comprehensive overhaul of CD+G playback to improve correctness, efficiency, and robustness across local, fullscreen, and AirPlay timelines.

  • Split timeline state: CDG state is now split into per-timeline (Local/AirPlay) mutable state with shared immutable packet storage, so AirPlay cannot rewind or corrupt local playback
  • Binary frame protocol: Replaced raw RGBA IPC with a binary frame protocol (32-byte header + optional RGBA payload) carrying transport generation and frame version, letting the frontend skip redundant redraws and discard stale frames
  • Status IPC: Added get_cdg_status IPC for availability/error reporting
  • Authoritative transport: Made transport lifecycle helpers authoritative through PlaybackCoordinator (play/pause/resume/seek/stop/failure)
  • AirPlay gating: Gate AirPlay CDG decoding by native output phase
  • Renderer snapshots: Added CdgRendererSnapshot for checkpoint save/restore with bounded policy
  • Decoder correctness: Fixed instruction 28 (transparent color), scroll offset clamping, scroll-copy wrap, and change-only process_packet return
  • Golden fixture tests: Added deterministic golden fixture tests to prevent regressions
  • Parser diagnostics: Added parser diagnostics for malformed CDG packets

Closes #99.

Test plan

  • TypeScript type checks pass (tsc --noEmit)
  • Frontend tests pass (1325 tests, 137 test files)
  • Rust tests pass (cargo test)
  • Debug build compiles successfully
  • Patch coverage at 100% (67/67 instrumented lines)
  • Pre-commit hooks pass (cargo-fmt, oxfmt)
  • Pre-push hooks pass (lint, format-check, cargo-fmt-check, patch-coverage)

Open in Devin Review

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 79.04% (🎯 65%) 3618 / 4577
🔵 Statements 78.77% (🎯 65%) 3852 / 4890
🔵 Functions 70.06% (🎯 60%) 990 / 1413
🔵 Branches 71.35% (🎯 60%) 2112 / 2960
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/hooks/use-cdg-frame-receiver.ts 97.56% 80% 100% 97.36% 92
src/hooks/use-cdg-sync.ts 85.71% 72.5% 92.1% 85.41% 179, 221-227, 242-243, 259-260, 273-281, 351, 379, 393-396, 414, 419-422, 484, 488
src/lib/cdg-canvas-painter.ts 100% 91.66% 100% 100%
src/lib/cdg-protocol.ts 100% 100% 100% 100%
src/lib/cdg-sync-channel.ts 81.08% 69.23% 75% 81.08% 44, 62-67, 113, 140
src/stores/cdg-store.ts 100% 100% 100% 100%
Generated in workflow #840 for commit e47a03b by the Vitest Coverage Report Action

@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.55422% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.0%. Comparing base (ec54066) to head (e47a03b).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/hooks/use-cdg-sync.ts 83.3% 19 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##            main    #113     +/-   ##
=======================================
+ Coverage   77.3%   79.0%   +1.6%     
=======================================
  Files        126     127      +1     
  Lines       6266    6374    +108     
  Branches    1984    2044     +60     
=======================================
+ Hits        4846    5037    +191     
+ Misses      1376    1293     -83     
  Partials      44      44             
Flag Coverage Δ
frontend 79.0% <88.5%> (+1.6%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
frontend 79.0% <88.5%> (+1.6%) ⬆️
rust ∅ <ø> (∅)
Files with missing lines Coverage Δ
src/hooks/use-cdg-frame-receiver.ts 97.9% <100.0%> (+33.5%) ⬆️
src/lib/cdg-canvas-painter.ts 100.0% <100.0%> (ø)
src/lib/cdg-protocol.ts 100.0% <100.0%> (ø)
src/lib/cdg-sync-channel.ts 80.0% <100.0%> (ø)
src/stores/cdg-store.ts 100.0% <100.0%> (ø)
src/hooks/use-cdg-sync.ts 86.8% <83.3%> (+72.6%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@greptile-apps

greptile-apps Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR overhauls CD+G playback across local, fullscreen, and AirPlay paths. The main changes are:

  • Split CDG playback into separate local and AirPlay timelines with shared immutable packet storage.
  • Added a versioned binary CDG frame protocol and a get_cdg_status IPC command.
  • Moved CDG loading, ready, error, seek, and generation updates through the playback coordinator.
  • Added AirPlay phase gating for CDG decoding.
  • Added renderer snapshots, checkpoint handling, parser diagnostics, and CDG decoder correctness fixes.
  • Updated frontend sync, BroadcastChannel, Tauri wrappers, store state, and tests for frame versions and status reporting.

Confidence Score: 5/5

Safe to merge based on the reviewed CDG playback changes.

The backend lifecycle, frontend protocol handling, status reporting, and prior reported CDG issues are consistent on HEAD.

No files require special attention.

T-Rex T-Rex Logs

What T-Rex did

  • The frontend TypeScript compilation was executed with a timestamped start and finish, and it completed with exit code 0.
  • A focused Vitest run for the frontend was executed with verbose per-test output and completed with exit code 0, confirming all tests in the run passed.
  • The changed render tests were included in the focused run and passed, despite a non-fatal act warning output.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
src-tauri/src/commands/cdg.rs Implements per-timeline CDG state, checkpoint restore, binary frame responses, and status IPC.
src-tauri/src/audio/coordinator.rs Routes CDG loading, attachment, error, generation, and seek lifecycle through the playback coordinator.
src-tauri/src/services/cdg.rs Adds CDG slot lifecycle helpers and load-result propagation for readiness and error status.
src-tauri/src/cdg/renderer.rs Refactors renderer state, fixes visible-change detection, scroll bounds, wrap behavior, and transparency handling.
src/hooks/use-cdg-sync.ts Introduces a serialized frame coordinator, backend status probing, and generation-aware frame fetches.
src/lib/cdg-protocol.ts Adds the TypeScript parser for the 32-byte CDG binary frame envelope.
src/stores/cdg-store.ts Extends CDG store state with backend availability, error code, frame version, and transport generation.
src-tauri/src/commands/airplay.rs Advances AirPlay CDG on its own timeline gated by native output phase.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant UI as React CDG sync
participant IPC as Tauri CDG IPC
participant Coord as PlaybackCoordinator
participant Slot as CdgPlaybackSlot
participant Local as Local timeline
participant AirPlay as AirPlay timeline

Coord->>Slot: mark loading / attach packets / mark error
UI->>IPC: get_cdg_frame(songId, generation, positionMs, lastFrameVersion)
IPC->>Slot: validate songId + transportGeneration
alt active ready CDG
    IPC->>Local: advance_cdg_timeline(positionMs)
    Local-->>IPC: header-only or RGBA frame
    IPC-->>UI: binary envelope
else no CDG / stale / error
    IPC-->>UI: 0-byte response
    UI->>IPC: get_cdg_status(songId, generation)
    IPC-->>UI: availability + errorCode
end
AirPlay->>Slot: native phase-gated runtime tick
Slot->>AirPlay: advance separate AirPlay timeline
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant UI as React CDG sync
participant IPC as Tauri CDG IPC
participant Coord as PlaybackCoordinator
participant Slot as CdgPlaybackSlot
participant Local as Local timeline
participant AirPlay as AirPlay timeline

Coord->>Slot: mark loading / attach packets / mark error
UI->>IPC: get_cdg_frame(songId, generation, positionMs, lastFrameVersion)
IPC->>Slot: validate songId + transportGeneration
alt active ready CDG
    IPC->>Local: advance_cdg_timeline(positionMs)
    Local-->>IPC: header-only or RGBA frame
    IPC-->>UI: binary envelope
else no CDG / stale / error
    IPC-->>UI: 0-byte response
    UI->>IPC: get_cdg_status(songId, generation)
    IPC-->>UI: availability + errorCode
end
AirPlay->>Slot: native phase-gated runtime tick
Slot->>AirPlay: advance separate AirPlay timeline
Loading

Reviews (5): Last reviewed commit: "fix(cdg): report backend error states in..." | Re-trigger Greptile

greptile-apps[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Owner Author

Maintainer acceptance — isolated high-risk slot 13

BLOCKED — changes required.

Blocking CDG request overlap

The song-probe effect sends api.getCdgFrame(..., lastFrameVersion=0) independently of pendingRef. It marks CDG active before that probe resolves, so the position-driven hot loop can issue another frame request concurrently. #99 explicitly requires that probe and hot loop cannot overlap; the shared in-flight guard currently protects only hot-loop calls.

Use one request coordinator/sequence for probe and hot paths, and add a deterministic delayed-probe + position-tick test proving one request at a time and that the latest generation/version wins.

This PR also still needs the substantial local/fullscreen/AirPlay manual matrix required by #99. Keep it isolated from routine merges after the automated fix and fresh CI.

Copy link
Copy Markdown
Owner Author

Re-review: blocking CD+G request-coordination fix and manual timeline matrix required

The song-probe effect and the hot playback loop currently issue getCdgFrame independently. pendingRef protects only one path, so probes/ticks can overlap and a late stale frame can paint after a newer timeline state.

Implement one explicit request coordinator shared by all CD+G frame callers:

  1. Route initial song probing, the local tick loop, fullscreen, and AirPlay timeline reads through one coordinator. At most one decode/IPC request may be active for a given active timeline; a new desired frame may be coalesced as the next request rather than started concurrently.
  2. Track a generation/request serial that changes on song replacement, seek, stop, timeline switch, and unmount. Apply a result only if it matches the still-current song, timeline, generation, and latest accepted frame/version.
  3. Cancel/invalidate outstanding work on cleanup. A late result must not draw into a stale canvas or mutate local/AirPlay state.
  4. Keep local and AirPlay timelines logically independent; do not serialize them by accidentally sharing mutable renderer state.

Add deferred-promise regression tests that:

  • cause a song probe and playback tick to overlap, resolve them in reverse order, and prove stale output is ignored;
  • prove a rapid tick burst does not create parallel requests for one timeline;
  • prove seek/song change/unmount invalidates late responses;
  • cover both local and AirPlay rendering paths.

Then perform the required packaged manual matrix for local, fullscreen, and AirPlay timelines (start, pause/resume, seek, song change, handoff/return). Attach concise evidence and exact test results. This remains a high-risk late merge candidate even after CI is green.

devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Owner Author

Re-review — blocked: the CDG coordinator invalidates every slow in-flight frame request

Reviewed at b917527. createCdgFrameCoordinator.request() increments serial for every position tick, even when it only coalesces a newer request behind an existing IPC call. The in-flight request retains its old serial, so its completion immediately fails req.serial !== serial and is discarded.

At the intended 33ms cadence, any getCdgFrame call that takes longer than one bucket can be invalidated by the next tick. The queued latest request then starts, but the following tick invalidates that one too. Under sustained >33ms IPC latency, CDG can freeze indefinitely despite the single-flight queue.

Separate lifecycle invalidation from request coalescing:

  1. Increment the invalidation epoch only for invalidate() / song-or-generation replacement, not for every same-timeline position request.
  2. Keep one in-flight request and replace only the pending request with the latest desired position. A valid in-flight response for the current song/generation must be allowed to update the frame before the coalesced request runs.
  3. Retain both stale-song/generation protections: epoch invalidation and isCurrent(req). If a separate request-order counter is needed, do not use it to reject every in-flight tick.
  4. Update the inaccurate request return documentation/type as part of the refactor (it currently promises a boolean but returns void).

Add a deferred-promise regression with position ticks arriving faster than IPC completion: enqueue A, enqueue B/C while A is in flight, resolve A, assert A is applied and only the latest pending request is issued, then resolve it and assert it is applied. Keep the existing invalidation test proving a song/generation change drops the old response.

This is a real playback-display liveness failure; no LGTM until it is fixed and covered.

Copy link
Copy Markdown
Owner Author

Re-review — liveness fix verified; one exported contract correction remains

Reviewed at e09240b. The serial now advances only on invalidate(), the slow-IPC regression test demonstrates that the in-flight response is applied, and isCurrent() still protects song/generation changes. The functional fix is sound.

Please still correct the public CdgFrameCoordinator.request documentation: it says “returns false if dropped as identical/noop”, while its TypeScript signature returns void and the implementation neither returns a boolean nor has an identical/noop drop path. Remove that claim or implement the declared contract, then rerun the focused test. The PR remains held for the previously required end-to-end CDG/manual acceptance; this is not an LGTM yet.

thedavidweng added a commit that referenced this pull request Jul 16, 2026
The JSDoc claimed request() 'returns false if dropped as identical/
noop', but the method returns void. Updated the doc to accurately
describe the coalescing behavior and direct callers to isInFlight()
for test assertions.
@thedavidweng

Copy link
Copy Markdown
Owner Author

@greptile review

greptile-apps[bot]

This comment was marked as resolved.

thedavidweng added a commit that referenced this pull request Jul 16, 2026
…eck (#113)

The frontend songHasCdgMedia() early-return skipped the getCdgFrame
probe whenever a song lacked an explicit cdg_path or Media+G container,
but the backend still loads implicit sidecars via
audio_path.with_extension("cdg") when song.cdg_path is absent. Songs
with a colocated .cdg sidecar were wrongly cleared and reported
hasCdg=false even though load_cdg_packets_from_sidecar would make them
playable.

Remove the early-return so the probe fires for every song and the
backend's 0-byte response determines CDG availability. Add a hasCdg
field to the coordinator's onProbeResolved callback to distinguish
"no active CDG slot" (null envelope, 0 bytes) from "CDG active but
frame unchanged" (header-only response). The hook's onProbeResolved
now clears the display and reports hasCdg=false only when the backend
confirms no CDG is active.
@thedavidweng

Copy link
Copy Markdown
Owner Author

@greptile review

devin-ai-integration[bot]

This comment was marked as resolved.

greptile-apps[bot]

This comment was marked as resolved.

@thedavidweng

Copy link
Copy Markdown
Owner Author

@greptile review

1 similar comment
@thedavidweng

Copy link
Copy Markdown
Owner Author

@greptile review

Copy link
Copy Markdown
Owner Author

Re-review of head 3cef50de380cb28808aba0478550ed31fe51c9a0:

The slow-IPC production fix is correct: request() no longer increments the serial, same-song requests are coalesced, invalidate() drops lifecycle-stale work, and isCurrent() guards song/generation changes. The latest CI is green.

One documentation correction is still needed in this concurrency-critical code before code-review sign-off: the top coordinator JSDoc currently says “A monotonic serial invalidates late results after song/generation change,” and invalidate is documented as “song change / unmount.” In the actual hook, non-null song/generation changes do not call invalidate(); they are rejected by isCurrent(). Update the public comments to state the exact split:

  • serial: explicit invalidation/unmount/empty-song lifecycle;
  • isCurrent: song ID and transport-generation staleness.

Do not change the fixed request/coalescing behavior merely to make the old comment true. After this docs-only correction and green CI, the remaining gate is the declared manual CDG/local-window/AirPlay acceptance.

devin-ai-integration[bot]

This comment was marked as resolved.

Split CDG state into per-timeline (Local/AirPlay) mutable state with
shared immutable packet storage so AirPlay cannot rewind or corrupt
local playback. Replace raw RGBA IPC with a binary frame protocol
(32-byte header + optional RGBA payload) that carries transport
generation and frame version, letting the frontend skip redundant
redraws and discard stale frames.

Add get_cdg_status IPC for availability/error reporting. Make
transport lifecycle helpers authoritative through PlaybackCoordinator.
Gate AirPlay CDG decoding by native output phase. Add
CdgRendererSnapshot for checkpoint save/restore with bounded policy.

Implement decoder correctness fixes: instruction 28 (transparent
color), scroll offset clamping, scroll-copy wrap, and change-only
process_packet return. Add golden fixture tests and parser diagnostics.

Closes #99.
Rust 1.97 clippy promotes these lints under -D warnings. Replace
manual Default impls on CdgAvailability, CdgStatus, and
CdgPlaybackSlot with derive(Default) (using #[default] on the
None variant). Collapse the match-with-wildcard in the CDG
timeline benchmark into an if let pattern.
Four review comments addressed:

1. Right/down scroll in preset mode wraps pixels instead of filling
   with preset color (renderer.rs): The modular arithmetic
   (x + FULL_WIDTH - 6) % FULL_WIDTH always produced in-range indices,
   so the copy/color branch was never reached for right/down scrolls.
   Rewrote scroll_horizontal and scroll_vertical to explicitly check
   whether the source index is in range, and only wrap in copy mode.
   In preset mode, vacated columns/rows are filled with the preset color.

2. Stale callers lose frames (commands/cdg.rs): When advance_cdg_timeline
   returns NoChange but the caller's last_frame_version differs, the
   response was header-only without RGBA data. Now the cached RGBA frame
   is included so remounted receivers or dropped IPC responses can recover
   without waiting for the position to advance.

3. Load failures hide status (services/playback.rs + coordinator.rs):
   ReadFailed and ZipFailed were collapsed to None by
   load_cdg_packets_as_arc, causing the coordinator to clear the CDG
   slot instead of marking the error. Added cdg_error field to
   ReadyTrack and changed load_cdg_packets_as_arc to return the error
   code, so the coordinator calls mark_cdg_error for load failures.
Probe and position ticks share createCdgFrameCoordinator so only one
getCdgFrame is in flight; pending requests coalesce and a serial drops
stale results after song/generation invalidate.
parseCdgFrameResponse returns Uint8Array; narrowing onFrame avoids
Uint8ClampedArray union failures under tsc for drawFrame/emitCdgFrame.
createCdgFrameCoordinator incremented its serial on every request() call,
so when a newer frame request was enqueued while an older one was still in
flight (common under slow IPC), the older response was always dropped
because its serial no longer matched. Under sustained slow IPC this froze
CDG playback entirely — every response was superseded before it could be
drawn. The serial is now only incremented by invalidate() (song change /
unmount), not by request(), so in-flight responses for the current song
are still processed while newer requests coalesce in the pending slot.
The JSDoc claimed request() 'returns false if dropped as identical/
noop', but the method returns void. Updated the doc to accurately
describe the coalescing behavior and direct callers to isInFlight()
for test assertions.
…eck (#113)

The frontend songHasCdgMedia() early-return skipped the getCdgFrame
probe whenever a song lacked an explicit cdg_path or Media+G container,
but the backend still loads implicit sidecars via
audio_path.with_extension("cdg") when song.cdg_path is absent. Songs
with a colocated .cdg sidecar were wrongly cleared and reported
hasCdg=false even though load_cdg_packets_from_sidecar would make them
playable.

Remove the early-return so the probe fires for every song and the
backend's 0-byte response determines CDG availability. Add a hasCdg
field to the coordinator's onProbeResolved callback to distinguish
"no active CDG slot" (null envelope, 0 bytes) from "CDG active but
frame unchanged" (header-only response). The hook's onProbeResolved
now clears the display and reports hasCdg=false only when the backend
confirms no CDG is active.
…es (#113)

Finding 1 — Status errors are hidden:
The 0-byte frame response also represents backend CDG error states
(invalid, empty, unreadable, or broken ZIP CDG). The coordinator already
consults getCdgStatus on 0-byte responses and forwards the backend
availability/errorCode to onProbeResolved, which calls setStatus so the
UI can surface error states instead of silently hiding them as
audio-only.

Finding 2 — Graphics stay blank for songs still loading when first probed:
The onProbeResolved handler treated every 0-byte probe response
identically — calling setSong(sid, false) and clearing the display.
When a song's CDG stream was still decoding (e.g., Media+G ZIPs and
remote tracks), the first probe returned 0-byte with
availability="loading", which permanently marked hasCdg=false. The
hot loop's !hasCdg guard then blocked all further probes, so graphics
never appeared for the rest of the song.

Fix: when availability is "loading", keep hasCdg optimistic (set by
the song-detection effect) and only update the status to "loading".
This allows the hot loop to re-probe on subsequent position ticks;
once decoding completes, the backend returns frames and availability
transitions to "ready".

Add coordinator test for 0-byte + loading availability forwarding, and
a render test verifying hasCdg stays true during loading and the hot
loop eventually draws frames once the backend finishes decoding.
The top coordinator JSDoc and invalidate() doc previously said "song
change / unmount", but non-null song/generation changes are rejected by
isCurrent(), not invalidate(). invalidate() is only called for unmount
cleanup and empty-song (null songId) reset. Update the comments to state
the exact split required by #113 re-review.

Copy link
Copy Markdown
Owner Author

Re-review — code fixes present, but current branch conflicts and manual CDG acceptance remains

Reviewed head e47a03b89f7afd21df9c0c69e67abdf61126dc8f.

The slow-IPC coordinator fix, loading-state re-probe, sidecar probing, and status propagation are present, and CI is green on this old base. The PR is now conflicting with current main after #103.

Rebase onto the repaired current mainline, keep the diff CDG-only, and rerun full CI/Packaging. Final LGTM also remains gated on packaged/manual validation with representative local and Media+G/sidecar content across:

  • normal and fullscreen playback;
  • pause/resume/seek/song change;
  • slow loading and slow IPC;
  • local vs AirPlay timelines;
  • malformed/unsupported CDG error reporting.

Request re-review on the rebased head with that evidence.

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.

fix(cdg): restore and harden CD+G playback across local, fullscreen, and AirPlay timelines

1 participant