---
phase: quick
plan: 260327-0rt
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleSyncDriver.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitlePanel.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleStyle.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
autonomous: true
requirements: []
must_haves:
  truths:
    - "Audio plays via AVAudioPlayer with prepareToPlay(), not afplay subprocess"
    - "Subtitle karaoke highlights are driven by polling player.currentTime via CADisplayLink, not pre-scheduled DispatchWorkItems"
    - "No magic audioLaunchDelay constant exists anywhere in the codebase"
    - "Page transitions happen when currentTime crosses page word boundaries"
    - "Linger-then-hide fires after audio finishes + lingerDuration"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleSyncDriver.swift"
      provides: "CADisplayLink-based audio-subtitle sync coordinator"
      min_lines: 80
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift"
      provides: "AVAudioPlayer-based playback replacing afplay"
      contains: "AVAudioPlayer"
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleStyle.swift"
      provides: "Style constants without audioLaunchDelay"
  key_links:
    - from: "SubtitleSyncDriver"
      to: "AVAudioPlayer.currentTime"
      via: "CADisplayLink tick callback"
      pattern: "player\\.currentTime"
    - from: "SubtitleSyncDriver"
      to: "SubtitlePanel.highlightWord"
      via: "word index advancement on each frame"
      pattern: "highlightWord"
    - from: "TelegramBot.dispatchTTS"
      to: "SubtitleSyncDriver"
      via: "creating driver with player + pages + timings"
      pattern: "SubtitleSyncDriver"
---

<objective>
Replace afplay subprocess with AVAudioPlayer + CADisplayLink polling loop for magic-number-free audio-subtitle synchronization.

Purpose: Eliminate the 0.3s audioLaunchDelay magic number and DispatchWorkItem timer scheduling. CADisplayLink polls AVAudioPlayer.currentTime each frame, self-correcting if frames are late. This makes subtitle sync drift-proof regardless of system load.

Output: Five modified/new Swift files with AVAudioPlayer playback, CADisplayLink-driven SubtitleSyncDriver, simplified SubtitlePanel, and updated TelegramBot orchestration.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitlePanel.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleStyle.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleChunker.swift

<interfaces>
<!-- Key types and contracts the executor needs -->

From SubtitleChunker.swift:

```swift
struct SubtitlePage {
    let words: [String]
    let startWordIndex: Int
    var wordCount: Int { words.count }
}
```

From TTSEngine.swift:

```swift
struct TTSResult {
    let wavPath: String
    let text: String
    let wordTimings: [TimeInterval]  // per-word DURATIONS (not onsets)
    let audioDuration: TimeInterval
}
```

From SubtitlePanel.swift (already exists, reuse as-is):

```swift
func highlightWord(at index: Int, in words: [String])  // karaoke coloring
func hide()
func show(text: String)
```

From SubtitleStyle.swift:

```swift
static let lingerDuration: TimeInterval = 2.0
static let audioLaunchDelay: TimeInterval = 0.3  // TO BE DELETED
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Replace afplay with AVAudioPlayer in TTSEngine + delete audioLaunchDelay</name>
  <files>
    plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift
    plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleStyle.swift
  </files>
  <action>
**TTSEngine.swift changes:**

1. Add `import AVFoundation` at the top.

2. Replace `playbackProcess: Process?` with `audioPlayer: AVAudioPlayer?` (still private var).

3. Rewrite `play(wavPath:completion:)` to return the AVAudioPlayer instance so the caller can pass it to SubtitleSyncDriver:
   - New signature: `func play(wavPath: String, completion: (() -> Void)? = nil) -> AVAudioPlayer?`
   - Runs on the MAIN thread (not the serial queue) because AVAudioPlayer completion delegate needs run loop.
   - Creates `AVAudioPlayer(contentsOf: URL(fileURLWithPath: wavPath))`.
   - Calls `prepareToPlay()` to pre-fill audio buffers (eliminates launch delay).
   - Sets a delegate (use a small private helper class `PlaybackDelegate: NSObject, AVAudioPlayerDelegate` nested inside TTSEngine or as a file-level private class) that calls the completion closure and cleans up the WAV file when `audioPlayerDidFinishPlaying` fires.
   - Calls `player.play()`.
   - Stores reference in `self.audioPlayer`.
   - Returns the player instance.
   - IMPORTANT: Remove the `queue.async` wrapping -- AVAudioPlayer does NOT need to run on the serial TTS queue. Audio plays on CoreAudio threads automatically.

4. Rewrite `stopPlayback()`: call `audioPlayer?.stop()` then set `audioPlayer = nil`.

5. In `deinit`: change `playbackProcess?.terminate()` to `audioPlayer?.stop()`.

6. Remove `private var playbackProcess: Process?` entirely.

**SubtitleStyle.swift changes:**

Delete the `audioLaunchDelay` property and its doc comment entirely (lines 71-77). This magic number is no longer needed since CADisplayLink polls real playback position.
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
</verify>
<done>TTSEngine uses AVAudioPlayer with prepareToPlay() instead of afplay Process. play() returns the player instance. audioLaunchDelay deleted from SubtitleStyle. Project compiles.</done>
</task>

<task type="auto">
  <name>Task 2: Create SubtitleSyncDriver with CADisplayLink polling</name>
  <files>
    plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleSyncDriver.swift
  </files>
  <action>
Create a new file `SubtitleSyncDriver.swift`. This is the core of the change -- it replaces all DispatchWorkItem scheduling with a single CADisplayLink that polls `player.currentTime`.

```
import AppKit
import AVFoundation
import Logging
```

`@MainActor final class SubtitleSyncDriver` with these properties:

- `private let logger = Logger(label: "subtitle-sync")`
- `private let player: AVAudioPlayer` -- the active audio player
- `private let pages: [SubtitlePage]` -- chunked subtitle pages
- `private let wordOnsets: [TimeInterval]` -- CUMULATIVE onset times computed from wordTimings durations
- `private let subtitlePanel: SubtitlePanel`
- `private var displayLink: CADisplayLink?`
- `private var currentPageIndex: Int = 0`
- `private var currentLocalWordIndex: Int = -1` -- tracks which word is highlighted to avoid redundant updates
- `private var totalWordCount: Int` -- sum of all page word counts
- `private var didFinish: Bool = false`

**init(player:pages:wordTimings:subtitlePanel:)**

- Store player, pages, subtitlePanel.
- Compute `wordOnsets` from `wordTimings` (which are DURATIONS): cumulative sum where `wordOnsets[0] = 0`, `wordOnsets[1] = wordTimings[0]`, `wordOnsets[i] = wordOnsets[i-1] + wordTimings[i-1]`.
- Compute `totalWordCount = pages.reduce(0) { $0 + $1.wordCount }`.
- Log: page count, total words, total duration.

**func start()**

- Show first page immediately: `subtitlePanel.highlightWord(at: 0, in: pages[0].words)`.
- Create `CADisplayLink(target: self, selector: #selector(tick))`.
- Add to `.main` RunLoop, `.common` mode.
- Store in `displayLink`.

**@objc private func tick()**

- Guard `!didFinish`.
- Read `let t = player.currentTime`.
- If `!player.isPlaying && t == 0 && didFinish == false`: player stopped externally (interruption). Call `stop()` and return.
- Binary search or linear scan `wordOnsets` to find the global word index where `wordOnsets[globalIdx] <= t < wordOnsets[globalIdx+1]`. Clamp to `totalWordCount - 1`.
- Convert global index to page + local index: iterate pages, find which page contains globalIdx using `page.startWordIndex` and `page.wordCount`.
- If `currentPageIndex` changed: update `currentPageIndex`, call `subtitlePanel.highlightWord(at: localIndex, in: pages[newPageIndex].words)`, update `currentLocalWordIndex`.
- Else if `currentLocalWordIndex` changed: call `subtitlePanel.highlightWord(at: localIndex, in: pages[currentPageIndex].words)`, update `currentLocalWordIndex`.
- If `!player.isPlaying` (finished naturally): set `didFinish = true`, schedule linger-then-hide: `DispatchQueue.main.asyncAfter(deadline: .now() + SubtitleStyle.lingerDuration) { [weak self] in self?.subtitlePanel.hide() }`. Then call `stop()`.

**func stop()**

- `displayLink?.invalidate()`
- `displayLink = nil`

**deinit**: call `displayLink?.invalidate()`.

Key design points:

- Only updates UI when word index actually changes (cheap: one comparison per frame).
- Self-correcting: if a frame is late, next frame reads true position and jumps to correct word.
- Page transitions are natural: when globalIdx crosses into a new page's range, the page switches.
- Uses `@MainActor` because CADisplayLink must run on main thread and SubtitlePanel is `@MainActor`.
  </action>
  <verify>
  <automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
  </verify>
  <done>SubtitleSyncDriver.swift exists, compiles, implements CADisplayLink polling of AVAudioPlayer.currentTime with page-aware word index tracking.</done>
  </task>

<task type="auto">
  <name>Task 3: Wire SubtitleSyncDriver into TelegramBot.dispatchTTS + simplify SubtitlePanel</name>
  <files>
    plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
    plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitlePanel.swift
  </files>
  <action>
**TelegramBot.swift -- rewrite dispatchTTS():**

In the `.success` branch of `synthesizeWithTimestamps` callback, replace the current `DispatchQueue.main.async` block:

1. Keep the `SubtitleChunker.chunkIntoPages(text:)` call (still needed).
2. Instead of calling `self.subtitlePanel.showPages(pages, wordTimings:)` followed by `self.ttsEngine.play(wavPath:)`:
   - Call `let player = self.ttsEngine.play(wavPath: ttsResult.wavPath)` to get the AVAudioPlayer.
   - Guard `let player` (play might return nil on error).
   - Create `let driver = SubtitleSyncDriver(player: player, pages: pages, wordTimings: ttsResult.wordTimings, subtitlePanel: self.subtitlePanel)`.
   - Call `driver.start()`.
   - Store `driver` so it isn't deallocated. Add a `private var syncDriver: SubtitleSyncDriver?` property to `TelegramBot`. Set `self.syncDriver = driver`.
3. The play completion closure should log "TTS playback complete" as before. The WAV cleanup is handled by the AVAudioPlayer delegate (Task 1).
4. Before starting new TTS (where `ttsEngine.stopPlayback()` is called), also call `syncDriver?.stop()` and set `syncDriver = nil` to cancel any in-progress subtitle sync.

**SubtitlePanel.swift -- simplify showPages():**

The `showPages(_:wordTimings:)` method is no longer called from dispatchTTS. However, it is still used by `showUtterance()` which is used by `demo()`. Two options:

- Option A (preferred): Keep `showPages` for demo/testing but acknowledge it uses the old timer approach. This is fine since demo() doesn't involve real audio.
- Actually, leave `showPages` and `showUtterance` and `demo()` AS-IS for now. They still work correctly for the demo path which doesn't use real audio. The key change is that the PRODUCTION path (dispatchTTS) now uses SubtitleSyncDriver instead.

No changes needed to SubtitlePanel.swift -- the existing `highlightWord(at:in:)` method is the interface SubtitleSyncDriver calls, and `showPages`/`demo` still work for non-audio use cases.

Wait -- actually remove the reference to `SubtitleStyle.audioLaunchDelay` in `showPages()` (line 173) since we deleted it in Task 1. Replace:

```swift
let scheduleStart = DispatchTime.now() + SubtitleStyle.audioLaunchDelay
```

with:

```swift
let scheduleStart = DispatchTime.now()
```

This keeps `showPages` working for demo() without the deleted constant.
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift build -c release 2>&1 | tail -5</automated>
</verify>
<done>dispatchTTS() creates SubtitleSyncDriver with AVAudioPlayer + pages + timings. SubtitlePanel.showPages no longer references audioLaunchDelay. Full release build succeeds with zero errors.</done>
</task>

</tasks>

<verification>
1. `cd plugins/claude-tts-companion && swift build -c release` compiles with zero errors
2. `grep -r "afplay" plugins/claude-tts-companion/Sources/` returns no matches
3. `grep -r "audioLaunchDelay" plugins/claude-tts-companion/Sources/` returns no matches
4. `grep -r "AVAudioPlayer" plugins/claude-tts-companion/Sources/` finds TTSEngine.swift and SubtitleSyncDriver.swift
5. `grep -r "CADisplayLink" plugins/claude-tts-companion/Sources/` finds SubtitleSyncDriver.swift
</verification>

<success_criteria>

- afplay subprocess completely removed from TTSEngine
- AVAudioPlayer with prepareToPlay() handles all audio playback
- CADisplayLink in SubtitleSyncDriver polls player.currentTime each frame
- Word highlights advance based on real playback position, not pre-scheduled timers
- audioLaunchDelay magic number deleted from SubtitleStyle
- Page transitions happen naturally when currentTime crosses page boundaries
- Release build compiles successfully
  </success_criteria>

<output>
After completion, create `.planning/quick/260327-0rt-replace-afplay-with-avaudioplayer-plus-c/260327-0rt-SUMMARY.md`
</output>
