---
phase: 21-pipeline-hardening
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
autonomous: true
requirements: [HARD-01, HARD-04]

must_haves:
  truths:
    - "Five notifications in 10 seconds process without crash or audio queue corruption"
    - "TTS test request arriving simultaneously with real notification does not produce interleaved audio"
    - "When TTS is busy, new requests either queue or show subtitle-only fallback (never silent drop)"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift"
      provides: "Centralized TTS pipeline access coordination"
      contains: "class TTSPipelineCoordinator"
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift"
      provides: "Updated TTS dispatch using coordinator"
      contains: "pipelineCoordinator"
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift"
      provides: "Updated TTS test using coordinator"
      contains: "pipelineCoordinator"
  key_links:
    - from: "TTSPipelineCoordinator"
      to: "AudioStreamPlayer"
      via: "exclusive ownership of reset/schedule lifecycle"
      pattern: "audioStreamPlayer\\.reset"
    - from: "TelegramBot.dispatchStreamingTTS"
      to: "TTSPipelineCoordinator"
      via: "coordinator.requestPipeline"
      pattern: "pipelineCoordinator"
    - from: "HTTPControlServer POST /tts/test"
      to: "TTSPipelineCoordinator"
      via: "coordinator.requestPipeline"
      pattern: "pipelineCoordinator"
---

<objective>
Eliminate race conditions between concurrent TTS pipeline consumers and ensure rapid-fire notifications degrade gracefully instead of producing interleaved audio or silent drops.

Purpose: Both TelegramBot.dispatchStreamingTTS and HTTPControlServer POST /tts/test independently reset AudioStreamPlayer and create SubtitleSyncDrivers. When both fire simultaneously, audio buffers interleave. Additionally, when isStreamingInProgress blocks a new TTS dispatch, the notification is silently dropped with no user feedback.

Output: TTSPipelineCoordinator that serializes pipeline access, plus subtitle-only fallback when TTS is busy.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift
@plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
@plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift
@plugins/claude-tts-companion/Sources/CompanionCore/PlaybackManager.swift
@plugins/claude-tts-companion/Sources/CompanionCore/SubtitleSyncDriver.swift
@plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift

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

From TelegramBot.swift:

```swift
private var syncDriver: SubtitleSyncDriver?
private var _isStreamingInProgress: Bool = false
private var isStreamingInProgress: Bool { /* NSLock-guarded */ }
private func dispatchStreamingTTS(text: String, voiceName: String) // creates driver, resets ASP
```

From HTTPControlServer.swift:

```swift
@MainActor private var activeSyncDriver: SubtitleSyncDriver?
// POST /tts/test: resets ASP, creates driver, starts batch playback
```

From PlaybackManager.swift:

```swift
public let audioStreamPlayer = AudioStreamPlayer()
func stopPlayback()  // stops AVAudioPlayer
```

From AudioStreamPlayer.swift:

```swift
func reset()  // cancels queued buffers, keeps engine warm
func scheduleChunk(samples: [Float], onComplete: ChunkCompletionHandler?)
func start()
func stop()
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create TTSPipelineCoordinator with exclusive pipeline access</name>
  <files>
    plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift
    plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift
    plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
    plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift
    plugins/claude-tts-companion/Sources/CompanionCore/PlaybackManager.swift
    plugins/claude-tts-companion/Sources/CompanionCore/SubtitleSyncDriver.swift
    plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
  </read_first>
  <action>
Create `TTSPipelineCoordinator.swift` as a `@MainActor` class (since SubtitleSyncDriver and PlaybackManager are both @MainActor). This coordinator serializes access to the shared AudioStreamPlayer + SubtitleSyncDriver lifecycle.

Implementation:

1. **Properties:**
   - `private let playbackManager: PlaybackManager` (injected)
   - `private let subtitlePanel: SubtitlePanel` (injected)
   - `private var activeSyncDriver: SubtitleSyncDriver?`
   - `private var isActive: Bool = false` (whether a pipeline session is running)
   - `private let logger = Logger(label: "tts-pipeline-coordinator")`

2. **`cancelCurrentPipeline()`:** Public method that:
   - Stops and nils `activeSyncDriver`
   - Calls `playbackManager.audioStreamPlayer.reset()`
   - Calls `playbackManager.stopPlayback()`
   - Sets `isActive = false`
   - Logs what was cancelled

3. **`startBatchPipeline(chunks:, subtitlePanel:, onComplete:)`:** Public method that:
   - Calls `cancelCurrentPipeline()` first (cancels any in-progress session)
   - Sets `isActive = true`
   - Creates a new `SubtitleSyncDriver` in streaming mode with `playbackManager.audioStreamPlayer`
   - Adds all chunks via `driver.addChunk()`
   - Calls `driver.startBatchPlayback()`
   - Stores driver in `activeSyncDriver`
   - The `onComplete` callback should set `isActive = false` and nil the driver

4. **`var isBusy: Bool`:** Returns `isActive` -- callers check this to decide subtitle-only fallback

Wire into CompanionApp.init(): create `TTSPipelineCoordinator(playbackManager:, subtitlePanel:)` and pass it to both TelegramBot and HTTPControlServer constructors (add parameter to their inits).
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria> - grep -q "class TTSPipelineCoordinator" plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift - grep -q "cancelCurrentPipeline" plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift - grep -q "startBatchPipeline" plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift - grep -q "pipelineCoordinator" plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
</acceptance_criteria>
<done>TTSPipelineCoordinator exists with cancelCurrentPipeline and startBatchPipeline methods. CompanionApp creates and injects it.</done>
</task>

<task type="auto">
  <name>Task 2: Migrate TelegramBot and HTTPControlServer to use TTSPipelineCoordinator</name>
  <files>
    plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift
    plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift
    plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift
    plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
  </read_first>
  <action>
**TelegramBot.swift changes:**

1. Add `private let pipelineCoordinator: TTSPipelineCoordinator` property. Add it to init parameters.

2. In `dispatchStreamingTTS()`:
   - Remove the manual `playbackManager.audioStreamPlayer.reset()`, `playbackManager.stopPlayback()`, `driverToStop?.stop()` block
   - Remove `self.syncDriver` property management
   - After `ttsEngine.synthesizeStreaming()` returns chunks, call `await MainActor.run { self.pipelineCoordinator.startBatchPipeline(chunks: chunks, ..., onComplete: { self.isStreamingInProgress = false; Task { await checkMemoryLifecycleRestart() } }) }`
   - If chunks is empty, call `self.pipelineCoordinator.cancelCurrentPipeline()` before showing fallback

3. In `dispatchTTS()` where `isStreamingInProgress` check currently silently returns:
   - Change the guard to: when `isStreamingInProgress` is true, log a warning AND show subtitle-only fallback (call `showSubtitleOnlyFallback(text: fullText)`) instead of silently dropping. This ensures rapid-fire notifications always produce visible output even if audio is busy.

4. In `dispatchFullTTS()`:
   - Remove manual `playbackManager.stopPlayback()` and `syncDriver` management
   - Use coordinator for single-shot mode too (add a `startSingleShotPipeline` method on coordinator if needed, or keep single-shot as-is since it uses AVAudioPlayer not AudioStreamPlayer -- the key conflict is AudioStreamPlayer)
   - Actually: single-shot mode uses `playbackManager.play()` (AVAudioPlayer), NOT AudioStreamPlayer. The race condition is only with AudioStreamPlayer. So for single-shot, just cancel via coordinator first: `pipelineCoordinator.cancelCurrentPipeline()` before starting AVAudioPlayer playback.

5. Remove `syncDriver` property from TelegramBot (coordinator owns it now).

**HTTPControlServer.swift changes:**

1. Add `private let pipelineCoordinator: TTSPipelineCoordinator` property. Add it to init parameters.

2. In `POST /tts/test` handler:
   - Remove manual `playbackManager.audioStreamPlayer.reset()`, `playbackManager.stopPlayback()`, `activeSyncDriver?.stop()` block
   - After chunks are synthesized, call `self.pipelineCoordinator.startBatchPipeline(chunks: chunks, ..., onComplete: { Task { await checkMemoryLifecycleRestart() } })`

3. Remove `activeSyncDriver` property from HTTPControlServer (coordinator owns it now).

**CompanionApp.swift changes:**

Update TelegramBot and HTTPControlServer constructor calls to pass the pipelineCoordinator.
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria> - grep -q "pipelineCoordinator" plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift - grep -q "pipelineCoordinator" plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift - grep -q "showSubtitleOnlyFallback" plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift | grep -c "isStreamingInProgress" should show the fallback is called when streaming is in progress - swift build succeeds with zero errors
</acceptance_criteria>
<done>Both TelegramBot and HTTPControlServer use TTSPipelineCoordinator for all AudioStreamPlayer lifecycle. No direct AudioStreamPlayer.reset() calls remain outside the coordinator. Rapid-fire notifications show subtitle-only fallback instead of silent drop. HARD-01 and HARD-04 satisfied.</done>
</task>

</tasks>

<verification>
1. `cd plugins/claude-tts-companion && swift build` succeeds
2. `grep -r "audioStreamPlayer.reset" Sources/CompanionCore/ | grep -v TTSPipelineCoordinator` returns empty (only coordinator resets ASP)
3. `grep -r "activeSyncDriver" Sources/CompanionCore/ | grep -v TTSPipelineCoordinator` returns empty (only coordinator owns driver)
4. `grep "showSubtitleOnlyFallback" Sources/CompanionCore/TelegramBot.swift` shows it's called in the isStreamingInProgress guard
</verification>

<success_criteria>

- TTSPipelineCoordinator is the single owner of AudioStreamPlayer lifecycle (reset, schedule) and SubtitleSyncDriver lifecycle
- Neither TelegramBot nor HTTPControlServer directly manipulate AudioStreamPlayer or store their own sync drivers
- When TTS is busy with a streaming session, new requests show subtitle-only fallback (not silent drop)
- swift build compiles cleanly
  </success_criteria>

<output>
After completion, create `.planning/phases/21-pipeline-hardening/21-01-SUMMARY.md`
</output>
