---
phase: 32-audio-device-resilience
plan: 02
type: execute
wave: 2
depends_on: ["32-01"]
files_modified:
  - plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift
autonomous: false
requirements:
  - AUDIO-05
  - AUDIO-06
must_haves:
  truths:
    - "Health check timer runs every 30 seconds comparing engine device vs system default"
    - "Health check skips during active playback (audio is working if playing)"
    - "Device mismatch in health check triggers the debounced rebuild path"
    - "Health check timer starts on init and stops on deinit"
    - "Audio plays correctly on new device after switching (manual verification)"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift"
      provides: "Health check timer + full three-layer detection"
      contains: "startHealthCheck"
  key_links:
    - from: "AudioStreamPlayer.performHealthCheck()"
      to: "AudioStreamPlayer.triggerRebuild(source: .healthCheck)"
      via: "device ID mismatch"
      pattern: "triggerRebuild.*healthCheck"
---

<objective>
Add periodic health check timer (detection layer 3) to AudioStreamPlayer and verify the complete three-layer audio device resilience system with a manual device switch test.

Purpose: The health check is the safety net -- if HAL listener and AVAudioEngineConfigurationChange both miss a device change (rare but possible), the 30-second poll catches the stale device and triggers rebuild.

Output: Complete AudioStreamPlayer with all three detection layers functional. Manual verification that audio recovers after device switch.
</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/32-audio-device-resilience/32-CONTEXT.md
@.planning/phases/32-audio-device-resilience/32-RESEARCH.md
@.planning/phases/32-audio-device-resilience/32-01-SUMMARY.md

@plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift
@plugins/claude-tts-companion/Sources/CompanionCore/Config.swift
@plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift

<interfaces>
<!-- After Plan 01, AudioStreamPlayer will have these new members -->

From AudioStreamPlayer.swift (post Plan 01):

```swift
public final class AudioStreamPlayer: @unchecked Sendable {
    // ... existing ...
    private var halListenerRegistered: Bool
    private(set) var cachedOutputDeviceID: AudioDeviceID
    private var lastRebuildTime: CFAbsoluteTime
    private var rebuildWorkItem: DispatchWorkItem?

    enum RebuildSource: String {
        case halListener, configNotification, healthCheck
    }

    func setupHALListener()
    func removeHALListener()
    func getSystemDefaultOutputDeviceID() -> AudioDeviceID
    func getDeviceName(deviceID: AudioDeviceID) -> String
    func triggerRebuild(source: RebuildSource)
    private func rebuildEngine()
}
```

From Config.swift (post Plan 01):

```swift
static let audioHealthCheckInterval: TimeInterval = 30
static let audioRebuildDebounceMs: Double = 200
static let audioRebuildCooldownSeconds: TimeInterval = 5
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add periodic health check timer to 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/Config.swift
    - plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift
  </read_first>
  <action>
Add health check timer (detection layer 3, per D-04) to AudioStreamPlayer.swift.

**1. Add new property** alongside the other new properties from Plan 01:

```swift
/// Periodic health check timer comparing engine device vs system default.
private var healthCheckTimer: DispatchSourceTimer?
```

**2. Add `startHealthCheck()` method** in the `// MARK: - Device Change Detection` section:

```swift
func startHealthCheck() {
    let timer = DispatchSource.makeTimerSource(queue: .main)
    timer.schedule(
        deadline: .now() + Config.audioHealthCheckInterval,
        repeating: Config.audioHealthCheckInterval
    )
    timer.setEventHandler { [weak self] in
        self?.performHealthCheck()
    }
    timer.resume()
    healthCheckTimer = timer
    logger.info("Audio health check started (interval: \(Int(Config.audioHealthCheckInterval))s)")
}
```

**3. Add `stopHealthCheck()` method:**

```swift
func stopHealthCheck() {
    healthCheckTimer?.cancel()
    healthCheckTimer = nil
    logger.info("Audio health check stopped")
}
```

**4. Add `performHealthCheck()` method:**

```swift
private func performHealthCheck() {
    // Skip during active playback -- audio is clearly working if playing
    guard !playerNode.isPlaying else { return }

    let systemDevice = getSystemDefaultOutputDeviceID()
    let engineDevice = cachedOutputDeviceID

    if systemDevice != engineDevice && systemDevice != AudioDeviceID(kAudioDeviceUnknown) {
        let systemName = getDeviceName(deviceID: systemDevice)
        let engineName = getDeviceName(deviceID: engineDevice)
        logger.warning("Health check: device mismatch (engine=\(engineDevice) '\(engineName)', system=\(systemDevice) '\(systemName)')")
        triggerRebuild(source: .healthCheck)
    }
}
```

**5. Update `init()`** -- After `setupHALListener()`, add: `startHealthCheck()`.

**6. Update `deinit`** -- Before `removeHALListener()`, add: `stopHealthCheck()`.

**7. Verify `swift build` passes.**

This completes the three-layer detection architecture:

- Layer 1: CoreAudio HAL listener (immediate, from Plan 01)
- Layer 2: AVAudioEngineConfigurationChange (backup, enhanced in Plan 01)
- Layer 3: 30-second health check timer (safety net, this task)

All three layers feed into the same `triggerRebuild(source:)` -> debounce -> `rebuildEngine()` path.
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria> - AudioStreamPlayer.swift contains `private var healthCheckTimer: DispatchSourceTimer?` - AudioStreamPlayer.swift contains `func startHealthCheck()` - AudioStreamPlayer.swift contains `func stopHealthCheck()` - AudioStreamPlayer.swift contains `func performHealthCheck()` - performHealthCheck checks `!playerNode.isPlaying` before comparing device IDs - performHealthCheck calls `triggerRebuild(source: .healthCheck)` on mismatch - init() calls `startHealthCheck()` - deinit calls `stopHealthCheck()` - `swift build` succeeds with zero errors
</acceptance_criteria>
<done>Health check timer runs every 30s, skips during playback, detects device mismatch, triggers debounced rebuild. swift build passes.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 2: Verify audio device resilience with real hardware switch</name>
  <files>plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift</files>
  <action>
Human verifies the complete three-layer audio device resilience system works with real hardware. No code changes -- this is a verification-only checkpoint.
  </action>
  <verify>
    <automated>cd plugins/claude-tts-companion && swift build -c release 2>&1 | tail -3</automated>
  </verify>
  <what-built>
    Complete three-layer audio device resilience system in AudioStreamPlayer:
    1. CoreAudio HAL listener (fires immediately on device change)
    2. AVAudioEngineConfigurationChange notification (backup)
    3. 30-second periodic health check (safety net)
    All feed into debounced (200ms) + cooldown (5s) engine rebuild path.
  </what-built>
  <how-to-verify>
    1. Build and run: `cd plugins/claude-tts-companion && swift build -c release`
    2. Start the companion: `.build/release/claude-tts-companion`
    3. Trigger TTS playback via `tts_kokoro.sh "Testing audio device resilience with a longer sentence to give time for switching"`
    4. While audio is playing, switch audio output (connect/disconnect Bluetooth, or change output in System Settings > Sound)
    5. Verify in logs: "Rebuild triggered by halListener" and "Audio engine rebuilt successfully on {device name}"
    6. Trigger another TTS: `tts_kokoro.sh "Audio should now play on the new device"`
    7. Verify audio plays on the new device

    Expected log lines:
    - "CoreAudio HAL listener registered for default output device changes"
    - "Audio health check started (interval: 30s)"
    - "Rebuild triggered by halListener" (on device switch)
    - "Rebuilding audio engine: device X -> Y (DeviceName)"
    - "Audio engine rebuilt successfully on DeviceName (took Xms)"

  </how-to-verify>
  <resume-signal>Type "approved" or describe issues</resume-signal>
  <done>User confirms audio plays on new device after switching output. Log shows HAL listener detection and successful engine rebuild.</done>
</task>

</tasks>

<verification>
1. `cd plugins/claude-tts-companion && swift build` succeeds
2. `grep -c "startHealthCheck\|stopHealthCheck\|performHealthCheck\|healthCheckTimer" Sources/CompanionCore/AudioStreamPlayer.swift` returns at least 4
3. `swift test` passes (no regressions)
4. Manual device switch test confirms audio recovery
</verification>

<success_criteria>

- All three detection layers operational (HAL + notification + health check)
- Health check skips during active playback
- Device mismatch detected within 30 seconds even if HAL + notification miss it
- Audio plays correctly on new device after switch
- No regressions in existing TTS playback
  </success_criteria>

<output>
After completion, create `.planning/phases/32-audio-device-resilience/32-02-SUMMARY.md`
</output>
