---
phase: 21-pipeline-hardening
plan: 02
type: execute
wave: 2
depends_on: [21-01]
files_modified:
  - plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
autonomous: true
requirements: [HARD-02, HARD-03]

must_haves:
  truths:
    - "Disconnecting Bluetooth headphones mid-playback recovers gracefully with audio on default output or subtitle-only fallback"
    - "Under simulated memory pressure during synthesis, binary degrades to subtitle-only mode rather than crashing"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift"
      provides: "AVAudioEngine configuration change recovery"
      contains: "configurationChangeNotification"
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift"
      provides: "Memory pressure monitoring and subtitle-only degradation"
      contains: "memoryPressure"
  key_links:
    - from: "AudioStreamPlayer"
      to: "AVAudioEngine.configurationChangeNotification"
      via: "NotificationCenter observer"
      pattern: "configurationChangeNotification"
    - from: "TTSPipelineCoordinator"
      to: "DispatchSource.makeMemoryPressureSource"
      via: "memory pressure event handler"
      pattern: "memoryPressureSource"
---

<objective>
Handle macOS hardware events (audio route changes, memory pressure) gracefully so the TTS pipeline degrades to subtitle-only mode instead of crashing.

Purpose: Bluetooth headphone disconnect mid-playback causes AVAudioEngine configuration invalidation. Memory pressure during MLX synthesis (561MB peak RSS) can trigger jetsam kills. Both scenarios need graceful degradation.

Output: Audio route change recovery in AudioStreamPlayer, memory pressure monitoring in TTSPipelineCoordinator.
</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
@.planning/phases/21-pipeline-hardening/21-01-SUMMARY.md
@plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift
@plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift
@plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
@plugins/claude-tts-companion/Sources/CompanionCore/PlaybackManager.swift
@plugins/claude-tts-companion/Sources/CompanionCore/SubtitleSyncDriver.swift

<interfaces>
<!-- Key types from Plan 01 output -->

From AudioStreamPlayer.swift:

```swift
public final class AudioStreamPlayer: @unchecked Sendable {
    private let engine = AVAudioEngine()
    private let playerNode = AVAudioPlayerNode()
    private let format: AVAudioFormat  // 24kHz mono float32
    private(set) var isRunning: Bool = false
    private let engineLock = NSLock()
    func start()   // idempotent
    func stop()    // cancels all buffers
    func reset()   // stop player, keep engine warm
    func scheduleChunk(samples: [Float], onComplete: ChunkCompletionHandler?)
}
```

From TTSPipelineCoordinator.swift (created in Plan 01):

```swift
@MainActor class TTSPipelineCoordinator {
    var isBusy: Bool
    func cancelCurrentPipeline()
    func startBatchPipeline(chunks:, subtitlePanel:, onComplete:)
}
```

From CompanionApp.swift:

```swift
public final class CompanionApp: @unchecked Sendable {
    @MainActor public func start()    // wires subsystems
    @MainActor public func shutdown() // cleanup
    func plannedRestart(reason: String) // exit(42) for launchd restart
}
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Audio route change recovery in AudioStreamPlayer</name>
  <files>
    plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift
    plugins/claude-tts-companion/Sources/CompanionCore/PlaybackManager.swift
  </read_first>
  <action>
Add AVAudioEngine configuration change handling to AudioStreamPlayer. When Bluetooth headphones disconnect mid-playback, macOS invalidates the AVAudioEngine configuration. Without handling, the engine enters a broken state where all subsequent scheduleChunk() calls silently fail.

**Implementation in AudioStreamPlayer.swift:**

1. **Add a route change callback property:**

   ```swift
   /// Called when audio route changes (e.g., Bluetooth disconnect). Fires on main queue.
   /// TTSPipelineCoordinator uses this to cancel current pipeline and optionally restart.
   var onRouteChange: (() -> Void)?
   ```

2. **In init(), observe `AVAudioEngine.configurationChangeNotification`:**

   ```swift
   NotificationCenter.default.addObserver(
       forName: .AVAudioEngineConfigurationChange,
       object: engine,
       queue: .main
   ) { [weak self] _ in
       self?.handleConfigurationChange()
   }
   ```

3. **Add `handleConfigurationChange()` method:**
   - Log warning: "Audio configuration changed (hardware route change)"
   - Lock `engineLock`
   - Stop playerNode (cancels queued buffers)
   - Reset scheduledBufferCount to 0
   - Try to restart engine: `try engine.start()` -- if this succeeds, audio resumes on the new default output device
   - If restart fails, set `isRunning = false` and log error
   - Unlock
   - Call `onRouteChange?()` to notify coordinator

4. **In deinit, remove observer:**

   ```swift
   NotificationCenter.default.removeObserver(self)
   ```

   Note: Since we use the closure-based addObserver, we need to store the observer token and remove it in deinit. Store as `private var configChangeObserver: NSObjectProtocol?` and use `NotificationCenter.default.removeObserver(configChangeObserver!)` in deinit.

This approach automatically recovers to the default audio output (built-in speakers) when Bluetooth disconnects. If recovery fails, the onRouteChange callback lets the coordinator cancel the pipeline (subtitle-only fallback).
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria> - grep -q "configurationChangeNotification" plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift - grep -q "handleConfigurationChange" plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift - grep -q "onRouteChange" plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift - swift build succeeds
</acceptance_criteria>
<done>AudioStreamPlayer observes AVAudioEngine configuration changes, automatically restarts the engine on the new audio device, and notifies the coordinator via onRouteChange callback. HARD-02 audio recovery path implemented.</done>
</task>

<task type="auto">
  <name>Task 2: Memory pressure monitoring and subtitle-only degradation</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/TTSPipelineCoordinator.swift
    plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
    plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift
  </read_first>
  <action>
Add memory pressure monitoring to TTSPipelineCoordinator and wire the audio route change callback.

**TTSPipelineCoordinator.swift changes:**

1. **Add memory pressure source:**

   ```swift
   private var memoryPressureSource: DispatchSourceMemoryPressure?
   private var isMemoryConstrained: Bool = false
   ```

2. **Add `startMonitoring()` method** (called from CompanionApp.start()):
   - Create `DispatchSource.makeMemoryPressureSource(eventMask: [.warning, .critical], queue: .main)`
   - In the event handler:
     - On `.warning`: set `isMemoryConstrained = true`, log warning "Memory pressure WARNING -- new TTS requests will use subtitle-only mode"
     - On `.critical`: set `isMemoryConstrained = true`, call `cancelCurrentPipeline()` (stop any in-progress synthesis from playing), log critical "Memory pressure CRITICAL -- cancelled active TTS pipeline"
   - Resume the source
   - Also create a periodic timer (every 60s) that checks `ProcessInfo.processInfo.physicalMemory` and clears `isMemoryConstrained` when pressure subsides (or rely on the DispatchSource sending a `.normal` event -- but the event mask only includes warning/critical, so add a 60s auto-recovery: `DispatchQueue.main.asyncAfter(deadline: .now() + 60) { self.isMemoryConstrained = false }` on each pressure event, so it auto-clears after 60s of no new pressure events)

3. **Update `isBusy` or add `shouldUseSubtitleOnly`:**
   Add a `var shouldUseSubtitleOnly: Bool { return isMemoryConstrained }` property. Callers (TelegramBot, HTTPControlServer) check this before starting synthesis. If true, skip TTS synthesis entirely and show subtitle-only.

4. **Wire audio route change callback:**
   In `startMonitoring()`, set `playbackManager.audioStreamPlayer.onRouteChange = { [weak self] in self?.handleAudioRouteChange() }`.

   `handleAudioRouteChange()` method:
   - Log warning "Audio route changed -- cancelling current pipeline for graceful recovery"
   - Call `cancelCurrentPipeline()`
   - The audio engine already restarted itself in AudioStreamPlayer.handleConfigurationChange()
   - Next TTS request will work on the new audio device automatically

5. **Add `stopMonitoring()` method:**
   - Cancel and nil memoryPressureSource
   - Clear onRouteChange callback

**CompanionApp.swift changes:**

1. In `start()`, after creating pipelineCoordinator, call `pipelineCoordinator.startMonitoring()`
2. In `shutdown()`, call `pipelineCoordinator.stopMonitoring()`

**TelegramBot.swift changes (minor):**

In `dispatchTTS()`, before the `isStreamingInProgress` check, add:

```swift
if await pipelineCoordinator.shouldUseSubtitleOnly {
    logger.warning("Memory pressure -- subtitle-only mode (\(fullText.count) chars)")
    showSubtitleOnlyFallback(text: fullText)
    return
}
```

Note: Since TTSPipelineCoordinator is @MainActor, the check needs `await MainActor.run { pipelineCoordinator.shouldUseSubtitleOnly }` or the dispatchTTS method needs to hop to main actor for this check.

**HTTPControlServer.swift changes (minor):**

In `POST /tts/test` handler, add similar memory pressure check before synthesis. If constrained, return a JSON response indicating subtitle-only mode was used.
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria> - grep -q "memoryPressureSource" plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift - grep -q "isMemoryConstrained" plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift - grep -q "shouldUseSubtitleOnly" plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift - grep -q "handleAudioRouteChange" plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift - grep -q "startMonitoring" plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift - swift build succeeds
</acceptance_criteria>
<done>Memory pressure monitoring degrades to subtitle-only mode under .warning and cancels active pipeline under .critical. Audio route change recovery wired through coordinator. HARD-02 and HARD-03 satisfied. swift build compiles cleanly.</done>
</task>

</tasks>

<verification>
1. `cd plugins/claude-tts-companion && swift build` succeeds
2. `grep -q "configurationChangeNotification" Sources/CompanionCore/AudioStreamPlayer.swift` confirms audio route handling
3. `grep -q "memoryPressureSource" Sources/CompanionCore/TTSPipelineCoordinator.swift` confirms memory monitoring
4. `grep -q "shouldUseSubtitleOnly" Sources/CompanionCore/TTSPipelineCoordinator.swift` confirms degradation API
5. `grep -q "startMonitoring" Sources/CompanionCore/CompanionApp.swift` confirms wiring
</verification>

<success_criteria>

- AudioStreamPlayer recovers from AVAudioEngine configuration changes (Bluetooth disconnect) by restarting the engine
- TTSPipelineCoordinator monitors memory pressure and sets subtitle-only mode under .warning/.critical
- Both TelegramBot and HTTPControlServer check shouldUseSubtitleOnly before synthesis
- Active pipeline is cancelled under .critical memory pressure
- Audio route change fires coordinator callback to cancel current pipeline
- swift build compiles cleanly
  </success_criteria>

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