---
phase: 32-audio-device-resilience
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Sources/CompanionCore/Config.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift
autonomous: true
requirements:
  - AUDIO-01
  - AUDIO-02
  - AUDIO-03
  - AUDIO-04
must_haves:
  truths:
    - "CoreAudio HAL listener fires when default output device changes"
    - "Engine fully tears down and rebuilds graph on device change"
    - "Rapid device flapping (BT reconnect) collapses into single rebuild via 200ms debounce"
    - "No more than 1 rebuild per 5 seconds (cooldown)"
    - "AVAudioEngineConfigurationChange notification feeds into same debounced rebuild path"
    - "48kHz mono float32 format preserved after rebuild"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/Config.swift"
      provides: "Audio resilience constants"
      contains: "audioHealthCheckInterval"
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift"
      provides: "HAL listener + full rebuild + debounce"
      contains: "rebuildEngine"
  key_links:
    - from: "AudioStreamPlayer.defaultOutputDeviceChanged (HAL callback)"
      to: "AudioStreamPlayer.triggerRebuild(source:)"
      via: "DispatchQueue.main.async"
      pattern: "triggerRebuild.*halListener"
    - from: "AudioStreamPlayer.handleConfigurationChange()"
      to: "AudioStreamPlayer.triggerRebuild(source:)"
      via: "direct call"
      pattern: "triggerRebuild.*configNotification"
    - from: "AudioStreamPlayer.rebuildEngine()"
      to: "AudioStreamPlayer.onRouteChange?()"
      via: "callback after rebuild"
      pattern: "onRouteChange"
---

<objective>
Add CoreAudio HAL device-change listener, full AVAudioEngine teardown/rebuild, and debounce/cooldown to AudioStreamPlayer. Replace the current lightweight `handleConfigurationChange()` (which only calls `engine.start()`) with a robust three-step recovery: stop -> detach/reset -> re-attach/connect/start.

Purpose: Fix the stale aggregate device bug where switching audio output (Bluetooth, HDMI, speaker) leaves AVAudioEngine pointed at a dead device. The current code just tries engine.start() which fails when the aggregate device is invalid.

Output: AudioStreamPlayer with HAL listener + full rebuild + debounce, Config.swift with audio resilience constants.
</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

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

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

From AudioStreamPlayer.swift:

```swift
public final class AudioStreamPlayer: @unchecked Sendable {
    private let engine = AVAudioEngine()
    private let playerNode = AVAudioPlayerNode()
    private let format: AVAudioFormat  // 48kHz mono float32
    private(set) var isRunning: Bool = false
    private let engineLock = NSLock()
    private var scheduledBufferCount: Int = 0
    private let bufferCountLock = NSLock()
    private var configChangeObserver: NSObjectProtocol?
    var onRouteChange: (() -> Void)?
    var isPlaying: Bool { playerNode.isPlaying }
    var currentTime: TimeInterval { ... }
    func start() { ... }
    func stop() { ... }
    func reset() { ... }
    private func handleConfigurationChange() { ... }  // TO BE REPLACED
}
```

From Config.swift:

```swift
public enum Config {
    // ... existing constants ...
    // MARK: - sherpa-onnx Chinese TTS (last section, line ~165)
}
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add audio resilience constants to Config.swift</name>
  <files>plugins/claude-tts-companion/Sources/CompanionCore/Config.swift</files>
  <read_first>
    - plugins/claude-tts-companion/Sources/CompanionCore/Config.swift
  </read_first>
  <action>
Add a new section at the end of Config.swift (after the sherpa-onnx section) with these exact constants per D-05:

```swift
// MARK: - Audio Device Resilience

/// Interval between audio health checks (seconds). Compares engine output
/// device against system default to catch missed device changes.
static let audioHealthCheckInterval: TimeInterval = 30

/// Debounce window for audio engine rebuild triggers (milliseconds).
/// Prevents format mismatch races during Bluetooth reconnect flapping.
static let audioRebuildDebounceMs: Double = 200

/// Minimum time between audio engine rebuilds (seconds).
/// Prevents rebuild storms from rapid device cycling.
static let audioRebuildCooldownSeconds: TimeInterval = 5
```

These values come directly from CONTEXT.md D-04 and D-05.
</action>
<verify>
<automated>cd plugins/claude-tts-companion && grep -c "audioHealthCheckInterval\|audioRebuildDebounceMs\|audioRebuildCooldownSeconds" Sources/CompanionCore/Config.swift</automated>
</verify>
<acceptance_criteria> - Config.swift contains `static let audioHealthCheckInterval: TimeInterval = 30` - Config.swift contains `static let audioRebuildDebounceMs: Double = 200` - Config.swift contains `static let audioRebuildCooldownSeconds: TimeInterval = 5` - All three constants are under `// MARK: - Audio Device Resilience` comment
</acceptance_criteria>
<done>Three audio resilience constants exist in Config.swift with correct values and types</done>
</task>

<task type="auto">
  <name>Task 2: Replace handleConfigurationChange with full HAL listener + engine rebuild + debounce</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
    - .planning/phases/32-audio-device-resilience/32-RESEARCH.md
  </read_first>
  <action>
Modify AudioStreamPlayer.swift to add CoreAudio HAL listener, full engine rebuild, and debounce/cooldown. Per D-01 through D-06:

**1. Add `import CoreAudio` at the top** (alongside existing `import AVFoundation` and `import Foundation`).

**2. Add a free function ABOVE the class** (HAL listener C callback -- cannot be a method because it's a C function pointer):

```swift
/// CoreAudio HAL property listener callback (C function pointer).
/// Fires on an internal CoreAudio thread -- dispatches to main queue for thread safety.
private func defaultOutputDeviceChanged(
    _ objectID: AudioObjectID,
    _ numberAddresses: UInt32,
    _ addresses: UnsafePointer<AudioObjectPropertyAddress>,
    _ clientData: UnsafeMutableRawPointer?
) -> OSStatus {
    guard let clientData = clientData else { return noErr }
    let player = Unmanaged<AudioStreamPlayer>.fromOpaque(clientData).takeUnretainedValue()
    DispatchQueue.main.async {
        player.triggerRebuild(source: .halListener)
    }
    return noErr
}
```

**3. Add new properties** inside the class, after the existing `configChangeObserver` property:

```swift
/// Whether the CoreAudio HAL property listener is registered.
private var halListenerRegistered: Bool = false

/// Cached output device ID (set on engine start/rebuild, compared in health check).
private(set) var cachedOutputDeviceID: AudioDeviceID = AudioDeviceID(kAudioDeviceUnknown)

/// Timestamp of the last successful engine rebuild (for cooldown enforcement).
private var lastRebuildTime: CFAbsoluteTime = 0

/// Pending debounced rebuild work item (cancelled on each new trigger).
private var rebuildWorkItem: DispatchWorkItem?
```

**4. Add a RebuildSource enum** inside the class:

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

**5. Add new methods** in a new `// MARK: - Device Change Detection` section after the existing `// MARK: - Route Change Recovery` section:

- `setupHALListener()` -- Register `AudioObjectAddPropertyListener` on `kAudioObjectSystemObject` with `kAudioHardwarePropertyDefaultOutputDevice` selector. Use `kAudioObjectPropertyElementMain` (NOT deprecated `kAudioObjectPropertyElementMaster`). Pass `Unmanaged.passUnretained(self).toOpaque()` as clientData. Set `halListenerRegistered = true` on success. Log registration result.

- `removeHALListener()` -- Call `AudioObjectRemovePropertyListener` with same parameters. Set `halListenerRegistered = false`. Use the C function pointer variant (NOT the block variant which has a known Apple removal bug).

- `getSystemDefaultOutputDeviceID() -> AudioDeviceID` -- Query `kAudioHardwarePropertyDefaultOutputDevice` on `kAudioObjectSystemObject` via `AudioObjectGetPropertyData`. Return `kAudioDeviceUnknown` on failure.

- `getDeviceName(deviceID: AudioDeviceID) -> String` -- Query `kAudioDevicePropertyDeviceNameCFString` via `AudioObjectGetPropertyData`. Return "Unknown" on failure.

- `triggerRebuild(source: RebuildSource)` -- Log the trigger source. Cancel any pending `rebuildWorkItem`. Create a new `DispatchWorkItem` that: (a) checks cooldown (`CFAbsoluteTimeGetCurrent() - lastRebuildTime < Config.audioRebuildCooldownSeconds`), logs skip if active; (b) calls `rebuildEngine()`; (c) calls `onRouteChange?()` AFTER rebuild completes. Schedule the work item on `DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(Config.audioRebuildDebounceMs)))`. Store in `rebuildWorkItem`.

- `rebuildEngine()` -- Full teardown + rebuild per D-01:
  1. `engineLock.lock()` / `defer { engineLock.unlock() }`
  2. `let startTime = CFAbsoluteTimeGetCurrent()`
  3. Log old device ID + new device ID + name: `let oldDeviceID = cachedOutputDeviceID`, `let newDeviceID = getSystemDefaultOutputDeviceID()`, `let newDeviceName = getDeviceName(deviceID: newDeviceID)`
  4. `playerNode.stop()`
  5. Reset buffer count: `bufferCountLock.lock(); scheduledBufferCount = 0; bufferCountLock.unlock()`
  6. `if isRunning { engine.stop(); isRunning = false }`
  7. `engine.detach(playerNode)`
  8. `engine.reset()`
  9. `engine.attach(playerNode)`
  10. `engine.connect(playerNode, to: engine.mainMixerNode, format: format)` -- uses stored `format` (48kHz mono float32), NOT engine.outputNode.outputFormat
  11. `engine.prepare()`
  12. `try engine.start()` -- set `isRunning = true`, update `cachedOutputDeviceID = getSystemDefaultOutputDeviceID()`, `lastRebuildTime = CFAbsoluteTimeGetCurrent()`, log success with duration in ms
  13. On catch: log error, `isRunning = false`

**6. Modify existing `handleConfigurationChange()`** -- Replace the entire body with a single call: `triggerRebuild(source: .configNotification)`. This feeds the existing AVAudioEngineConfigurationChange notification into the unified debounced rebuild path per D-03.

**7. Update `init()`** -- After the existing `configChangeObserver` setup, add: `setupHALListener()`. Also after engine graph setup (attach + connect), set: `cachedOutputDeviceID = getSystemDefaultOutputDeviceID()` and log the initial device name.

**8. Update `start()`** -- After `try engine.start()` succeeds, add: `cachedOutputDeviceID = getSystemDefaultOutputDeviceID()`. Log the device name.

**9. Update `deinit`** -- Before the existing `stop()` call, add: `removeHALListener()`. Also cancel `rebuildWorkItem`: `rebuildWorkItem?.cancel()`.

**IMPORTANT anti-patterns to avoid (per RESEARCH.md):**

- Do NOT use `AudioObjectAddPropertyListenerBlock` -- known Apple bug with removal
- Do NOT use `kAudioObjectPropertyElementMaster` -- deprecated, use `kAudioObjectPropertyElementMain`
- Do NOT read format from `engine.outputNode.outputFormat` for player connection -- always use stored `format`
- Do NOT create a new AVAudioEngine instance -- reuse existing with reset()
  </action>
  <verify>
  <automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
  - AudioStreamPlayer.swift contains `import CoreAudio`
  - AudioStreamPlayer.swift contains `func defaultOutputDeviceChanged(` (free function)
  - AudioStreamPlayer.swift contains `func setupHALListener()`
  - AudioStreamPlayer.swift contains `func removeHALListener()`
  - AudioStreamPlayer.swift contains `func triggerRebuild(source: RebuildSource)`
  - AudioStreamPlayer.swift contains `func rebuildEngine()`
  - AudioStreamPlayer.swift contains `func getSystemDefaultOutputDeviceID() -> AudioDeviceID`
  - AudioStreamPlayer.swift contains `func getDeviceName(deviceID: AudioDeviceID) -> String`
  - AudioStreamPlayer.swift contains `enum RebuildSource`
  - AudioStreamPlayer.swift contains `kAudioObjectPropertyElementMain` (NOT kAudioObjectPropertyElementMaster)
  - AudioStreamPlayer.swift contains `engine.detach(playerNode)` inside rebuildEngine
  - AudioStreamPlayer.swift contains `engine.reset()` inside rebuildEngine
  - handleConfigurationChange body calls `triggerRebuild(source: .configNotification)`
  - `swift build` succeeds with zero errors
    </acceptance_criteria>
    <done>AudioStreamPlayer has three-layer detection (HAL + notification -> debounced rebuild), full engine teardown/rebuild, cooldown enforcement, and device telemetry logging. swift build passes.</done>
    </task>

</tasks>

<verification>
1. `cd plugins/claude-tts-companion && swift build` succeeds with zero errors
2. `grep -c "rebuildEngine\|triggerRebuild\|setupHALListener\|removeHALListener" Sources/CompanionCore/AudioStreamPlayer.swift` returns at least 4 (function definitions)
3. `grep "kAudioObjectPropertyElementMaster" Sources/CompanionCore/AudioStreamPlayer.swift` returns NO matches (deprecated constant not used)
4. `grep "audioRebuildDebounceMs\|audioRebuildCooldownSeconds\|audioHealthCheckInterval" Sources/CompanionCore/Config.swift` returns 3 matches
</verification>

<success_criteria>

- AudioStreamPlayer registers CoreAudio HAL listener for default output device changes on init
- AVAudioEngineConfigurationChange notification feeds into same debounced rebuild path
- rebuildEngine() performs full detach/reset/re-attach/connect/prepare/start sequence
- 200ms debounce and 5s cooldown prevent rebuild storms
- 48kHz mono float32 format is preserved after rebuild (uses stored format, not engine output format)
- Device ID + name logged on every engine start and rebuild
- swift build succeeds
  </success_criteria>

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