---
phase: 19-ttsengine-decomposition-actor-migration
plan: 02
type: execute
wave: 2
depends_on: [19-01]
files_modified:
  - plugins/claude-tts-companion/Sources/CompanionCore/PlaybackManager.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/SubtitleSyncDriver.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
  - plugins/claude-tts-companion/Sources/CompanionCore/MemoryLifecycle.swift
autonomous: true
requirements: [ARCH-02, ARCH-05, ARCH-06, CONC-01, CONC-02, CONC-03, CONC-04]

must_haves:
  truths:
    - "PlaybackManager is a @MainActor class owning AVAudioPlayer lifecycle, pre-buffering, warm-up, and AudioStreamPlayer"
    - "TTSEngine is a Swift actor (not class + NSLock) with all mutable state actor-isolated"
    - "Blocking TTS synthesis runs on dedicated DispatchQueue via withCheckedThrowingContinuation"
    - "SubtitleSyncDriver receives AudioStreamPlayer directly (not through TTSEngine)"
    - "All 4 callers compile and work against the decomposed API"
    - "Zero @unchecked Sendable on TTSEngine-related types"
    - "swift build succeeds with zero errors"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/PlaybackManager.swift"
      provides: "@MainActor class with play(), preparePlayer(), stopPlayback(), warmUpAudioHardware(), audioStreamPlayer"
      contains: "@MainActor"
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift"
      provides: "Actor facade delegating to PlaybackManager, WordTimingAligner, PronunciationProcessor"
      contains: "public actor TTSEngine"
  key_links:
    - from: "CompanionApp.swift"
      to: "PlaybackManager.swift"
      via: "Creates PlaybackManager, passes to TTSEngine and SubtitleSyncDriver"
      pattern: "PlaybackManager\\(\\)"
    - from: "TTSEngine.swift"
      to: "PlaybackManager.swift"
      via: "Delegates play/stop/prepare to PlaybackManager"
      pattern: "playbackManager\\."
    - from: "SubtitleSyncDriver.swift"
      to: "PlaybackManager.swift"
      via: "Receives audioStreamPlayer directly at init"
      pattern: "audioStreamPlayer"
    - from: "TelegramBot.swift"
      to: "TTSEngine.swift"
      via: "await ttsEngine.synthesizeStreaming() calls"
      pattern: "await.*ttsEngine"
    - from: "MemoryLifecycle.swift"
      to: "TTSEngine.swift"
      via: "Async shouldRestartForMemory check"
      pattern: "shouldRestartForMemory"
---

<objective>
Extract PlaybackManager as @MainActor class and migrate TTSEngine from class+NSLock to Swift actor.

Purpose: Complete the decomposition (per D-01, D-04, D-07, D-08, D-09) by extracting all playback-related code into PlaybackManager (@MainActor) and converting TTSEngine into a proper Swift actor. This eliminates all NSLock usage and @unchecked Sendable annotations, giving compile-time concurrency safety. Update all 4 callers (per D-12/ARCH-06) to work with the new async API.

Output: PlaybackManager.swift + refactored actor TTSEngine.swift + all callers updated. Zero @unchecked Sendable on TTSEngine types.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-ttsengine-decomposition-actor-migration/19-CONTEXT.md
@.planning/phases/19-ttsengine-decomposition-actor-migration/19-RESEARCH.md
@.planning/phases/19-ttsengine-decomposition-actor-migration/19-01-SUMMARY.md

@plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
@plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
@plugins/claude-tts-companion/Sources/CompanionCore/SubtitleSyncDriver.swift
@plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift
@plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
@plugins/claude-tts-companion/Sources/CompanionCore/MemoryLifecycle.swift
@plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift
@plugins/claude-tts-companion/Sources/CompanionCore/CircuitBreaker.swift
</context>

<interfaces>
<!-- Key types the executor needs from Plan 01 extraction -->

From WordTimingAligner.swift (created in Plan 01):

```swift
public struct WordTimingAligner: Sendable {
    struct NativeTimings { ... }
    struct ResolvedTimings { let durations: [TimeInterval]; let onsets: [TimeInterval]? }
    static func resolveWordTimings(tokenArray:text:audioDuration:logger:) -> ResolvedTimings
    static func extractWordTimings(text:audioDuration:) -> [TimeInterval]
}
```

From PronunciationProcessor.swift (created in Plan 01):

```swift
public struct PronunciationProcessor: Sendable {
    static func preprocessText(_ text: String) -> String
}
```

From SentenceSplitter.swift (created in Plan 01):

```swift
public struct SentenceSplitter: Sendable {
    static func splitIntoSentences(_ text: String) -> [String]
}
```

From PlaybackDelegate.swift (created in Plan 01):

```swift
@MainActor public final class PlaybackDelegate: NSObject, AVAudioPlayerDelegate { ... }
```

From TTSError.swift (created in Plan 01):

```swift
public enum TTSError: Error, CustomStringConvertible {
    case modelLoadFailed(path: String)
    case synthesisReturnedNil
    case wavWriteFailed(path: String)
    case circuitBreakerOpen
}
```

Current caller patterns to migrate:

```swift
// TelegramBot.swift (lines 250, 270-271, 290-292, 357-372):
ttsEngine.isDisabledDueToMissingModel  // sync property read
ttsEngine.isTTSCircuitBreakerOpen       // sync property read
ttsEngine.audioStreamPlayer.reset()     // sync AudioStreamPlayer access
ttsEngine.stopPlayback()                // sync method
ttsEngine.synthesizeStreaming(...)       // callback-based
ttsEngine.synthesizeWithTimestamps(...) // callback-based
ttsEngine.play(wavPath:completion:)     // sync method

// HTTPControlServer.swift (lines 193-207, 295-302):
ttsEngine.audioStreamPlayer.reset()     // sync AudioStreamPlayer access
ttsEngine.stopPlayback()                // sync method
ttsEngine.synthesizeStreaming(...)       // callback-based
ttsEngine.memoryDiagnostics()           // sync method

// SubtitleSyncDriver.swift (line 89, 182, 282):
private var ttsEngine: TTSEngine?       // stored reference
init(subtitlePanel:ttsEngine:...)       // receives TTSEngine
ttsEngine.audioStreamPlayer             // property access in 60Hz timer

// CompanionApp.swift (lines 17, 32, 50, 273-275, 331-339):
private let ttsEngine: TTSEngine        // stored property
ttsEngine = TTSEngine()                 // creation
MemoryLifecycle.register(ttsEngine:...) // registration
ttsEngine.shouldRestartForMemory        // sync property
ttsEngine.synthesizeWithTimestamps(...) // callback-based
ttsEngine.play(wavPath:...)             // sync method
```

</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Create PlaybackManager @MainActor class and migrate TTSEngine to actor</name>
  <files>
    plugins/claude-tts-companion/Sources/CompanionCore/PlaybackManager.swift
    plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
    plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift
    plugins/claude-tts-companion/Sources/CompanionCore/CircuitBreaker.swift
    plugins/claude-tts-companion/Sources/CompanionCore/PlaybackDelegate.swift
  </read_first>
  <action>
**Create PlaybackManager.swift** (per D-01, D-09 -- @MainActor class):

Extract from TTSEngine.swift all playback-related code into a new `@MainActor public final class PlaybackManager`:

Properties to move:

- `audioHardwareWarmed: Bool`
- `lastPlaybackTime: CFAbsoluteTime`
- `audioIdleThreshold: CFAbsoluteTime` (static)
- `warmUpPlayer: AVAudioPlayer?`
- `audioPlayer: AVAudioPlayer?`
- `playbackDelegate: PlaybackDelegate?`
- `audioStreamPlayer: AudioStreamPlayer` (public let, per D-01)

Methods to move:

- `play(wavPath:completion:) -> AVAudioPlayer?` -- the full implementation from TTSEngine including idle re-warm check
- `preparePlayer(wavPath:completion:) -> (player: AVAudioPlayer, delegate: PlaybackDelegate)?`
- `stopPlayback()` -- stops audioPlayer, nils references
- `warmUpAudioHardware()` -- private, called from init and play() on idle re-warm

Init:

- `init()` calls `warmUpAudioHardware()` and `audioStreamPlayer.start()`
- Import AVFoundation, Foundation, Logging

**Migrate TTSEngine.swift to actor** (per D-04, D-07, D-08, CONC-01/02/03/04):

Transform `public final class TTSEngine: @unchecked Sendable` into `public actor TTSEngine`:

1. Change declaration to `public actor TTSEngine`
2. Remove `@unchecked Sendable` annotation
3. Remove `private let lock = NSLock()` and all `lock.lock()/lock.unlock()` calls -- actor isolation replaces them
4. Remove `private let circuitBreakerLock = NSLock()` and all CB lock calls -- replace inline circuit breaker with an instance of the existing `CircuitBreaker` class stored as actor-isolated property
5. Add `let playbackManager: PlaybackManager` property (received at init, per D-04)
6. Change `init()` to `init(playbackManager: PlaybackManager)` -- remove all playback setup code (now in PlaybackManager)
7. Keep the dedicated `synthesisQueue = DispatchQueue(label: "com.terryli.tts-engine", qos: .userInitiated)` for blocking GPU work

**Convert synthesis methods to async** (per D-11):

- `synthesize(text:voiceName:speed:completion:)` becomes `func synthesize(text:voiceName:speed:) async throws -> SynthesisResult`
  - Use `withCheckedThrowingContinuation` to bridge `synthesisQueue.async` block (per D-08, CONC-03)
  - Increment `synthesisCount` after the await returns (actor-isolated, no lock needed)
  - Call `PronunciationProcessor.preprocessText()` before the await (sync, actor-isolated)

- `synthesizeWithTimestamps(text:voiceName:speed:completion:)` becomes `func synthesizeWithTimestamps(text:voiceName:speed:) async throws -> TTSResult`
  - Same DispatchQueue bridge pattern
  - Call `WordTimingAligner.resolveWordTimings()` after the await

- `synthesizeStreaming(text:voiceName:speed:onChunkReady:onAllComplete:)` becomes `func synthesizeStreaming(text:voiceName:speed:onChunkReady:onAllComplete:) async`
  - Mark callbacks as `@Sendable @escaping` (per Research recommendation -- keep callback pattern for Phase 19)
  - Bridge each chunk's synthesis to the DispatchQueue
  - Use `SentenceSplitter.splitIntoSentences()` and `PronunciationProcessor.preprocessText()`

**Handle MemoryLifecycle** (per Research pitfall 4):

- Make `shouldRestartForMemory` a `nonisolated` property if possible. The simplest approach: since the actor serializes access to `synthesisCount` and the check is a simple threshold comparison, expose it as a regular actor-isolated property. Make `checkMemoryLifecycleRestart()` async.
- Alternative: keep `shouldRestartForMemory` as actor-isolated and update MemoryLifecycle.swift to use `Task { await ... }` pattern.

**Handle circuit breaker replacement:**

- Remove all inline circuit breaker code (consecutiveFailures, circuitBreakerTrippedAt, circuitBreakerLock, recordSynthesisSuccess, recordSynthesisFailure, isTTSCircuitBreakerOpen)
- Add `private let circuitBreaker = CircuitBreaker(maxFailures: 3, cooldownSeconds: 300)` as actor-isolated property
- Use `circuitBreaker.isOpen` instead of `isTTSCircuitBreakerOpen`
- Use `circuitBreaker.recordSuccess()` and `circuitBreaker.recordFailure()` instead of inline methods
- Expose `var isTTSCircuitBreakerOpen: Bool` as a computed property delegating to `circuitBreaker.isOpen`

**Critical CONC-02 (reentrancy safety):**

- `ensureModelLoaded()` is synchronous and idempotent -- runs before the await, safe
- `synthesisCount += 1` after await -- monotonic counter, safe regardless of interleaving
- Serial DispatchQueue ensures only one `generateAudio()` runs at a time
- No state read before await is used in a conditional after await (safe pattern)
  </action>
  <verify>
  <automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -10</automated>
  </verify>
  <acceptance_criteria>
  - `swift build` succeeds (may have warnings from callers not yet updated -- that's OK, they're updated in Task 2)
  - `grep "public actor TTSEngine" plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift` matches
  - `grep "@MainActor" plugins/claude-tts-companion/Sources/CompanionCore/PlaybackManager.swift` matches
  - `grep -c "NSLock" plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift` returns 0
  - `grep -c "@unchecked Sendable" plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift` returns 0
  - `grep "withCheckedThrowingContinuation" plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift` matches (DispatchQueue bridge)
  - `grep "audioStreamPlayer" plugins/claude-tts-companion/Sources/CompanionCore/PlaybackManager.swift` shows AudioStreamPlayer owned by PlaybackManager
  - `grep "CircuitBreaker(" plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift` matches (using existing CB class)
    </acceptance_criteria>
    <done>
    PlaybackManager.swift created as @MainActor class. TTSEngine.swift is now a Swift actor with async methods, DispatchQueue bridge for blocking synthesis, no NSLock, no @unchecked Sendable. CircuitBreaker delegated to existing class.
    </done>
    </task>

<task type="auto">
  <name>Task 2: Update all callers to work with actor TTSEngine and PlaybackManager</name>
  <files>
    plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
    plugins/claude-tts-companion/Sources/CompanionCore/SubtitleSyncDriver.swift
    plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift
    plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
    plugins/claude-tts-companion/Sources/CompanionCore/MemoryLifecycle.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
    plugins/claude-tts-companion/Sources/CompanionCore/SubtitleSyncDriver.swift
    plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift
    plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
    plugins/claude-tts-companion/Sources/CompanionCore/MemoryLifecycle.swift
    plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
    plugins/claude-tts-companion/Sources/CompanionCore/PlaybackManager.swift
  </read_first>
  <action>
Update all 4 callers + MemoryLifecycle to work with actor TTSEngine and @MainActor PlaybackManager (per D-12, ARCH-06):

**1. CompanionApp.swift:**

- Add `private let playbackManager: PlaybackManager` property
- In `@MainActor init()`: create `playbackManager = PlaybackManager()` BEFORE creating TTSEngine
- Change TTSEngine creation: `ttsEngine = TTSEngine(playbackManager: playbackManager)`
- Pass `playbackManager` to SubtitleSyncDriver and any other subsystem that needs AudioStreamPlayer
- Update `start()`: MemoryLifecycle.register now takes actor TTSEngine -- wrap shouldRestartForMemory check in Task/await
- Update demo mode synthesizeWithTimestamps call: `Task { let result = try await ttsEngine.synthesizeWithTimestamps(...) }` instead of callback
- Update demo mode play call: use `playbackManager.play(wavPath:completion:)` instead of `ttsEngine.play(...)`
- Pass `playbackManager` to TelegramBot and HTTPControlServer (they need stopPlayback and audioStreamPlayer)
- Update `shouldRestartForMemory` check: wrap in `Task { if await ttsEngine.shouldRestartForMemory { ... } }`

**2. SubtitleSyncDriver.swift** (per Research pitfall 3):

- Change stored `ttsEngine: TTSEngine?` to `audioStreamPlayer: AudioStreamPlayer?` -- SubtitleSyncDriver only ever accesses `ttsEngine.audioStreamPlayer`, never synthesis methods
- Update init signature: `init(subtitlePanel:audioStreamPlayer:onStreamingComplete:)` instead of receiving TTSEngine
- Update all `ttsEngine?.audioStreamPlayer` references to direct `audioStreamPlayer?` references
- This eliminates the actor-crossing problem entirely -- AudioStreamPlayer is @unchecked Sendable with its own NSLock, already safe for cross-thread access from the 60Hz timer

**3. TelegramBot.swift:**

- Add `private let playbackManager: PlaybackManager` property
- Update init to accept `playbackManager: PlaybackManager` parameter
- `ttsEngine.isDisabledDueToMissingModel` -- wrap in `await` (actor property)
- `ttsEngine.isTTSCircuitBreakerOpen` -- wrap in `await`
- `ttsEngine.audioStreamPlayer.reset()` becomes `playbackManager.audioStreamPlayer.reset()`
- `ttsEngine.stopPlayback()` becomes `await playbackManager.stopPlayback()` (or direct call if already on MainActor)
- `ttsEngine.synthesizeStreaming(...)` becomes `await ttsEngine.synthesizeStreaming(...)` -- mark callbacks `@Sendable`
- `ttsEngine.synthesizeWithTimestamps(text:) { result in ... }` becomes `Task { let result = try await ttsEngine.synthesizeWithTimestamps(...) }`
- `ttsEngine.play(wavPath:...)` becomes `playbackManager.play(wavPath:...)`
- `TTSEngine.ChunkResult` references stay as-is (it's a type, not an instance)
- Create SubtitleSyncDriver with `audioStreamPlayer: playbackManager.audioStreamPlayer` instead of `ttsEngine: ttsEngine`

**4. HTTPControlServer.swift:**

- Add `private let playbackManager: PlaybackManager` property
- Update init to accept `playbackManager: PlaybackManager` parameter
- `ttsEngine.audioStreamPlayer.reset()` becomes `playbackManager.audioStreamPlayer.reset()`
- `ttsEngine.stopPlayback()` becomes `await playbackManager.stopPlayback()` (or schedule on MainActor)
- `ttsEngine.synthesizeStreaming(...)` becomes `await ttsEngine.synthesizeStreaming(...)`
- `ttsEngine.memoryDiagnostics()` becomes `await ttsEngine.memoryDiagnostics()`
- `TTSEngine.maxSynthesisBeforeRestart` -- if it's a static let on the actor, access it directly (static lets on actors are nonisolated)

**5. MemoryLifecycle.swift:**

- Change `nonisolated(unsafe) static var ttsEngine: TTSEngine?` to accept the actor type (actors are Sendable, so this is fine)
- Make `checkMemoryLifecycleRestart()` an async function: `func checkMemoryLifecycleRestart() async`
- Inside: `if await engine.shouldRestartForMemory { let diag = await engine.memoryDiagnostics() ... }`
- Update all call sites of `checkMemoryLifecycleRestart()` to use `Task { await checkMemoryLifecycleRestart() }` or direct `await`

**Critical: Ensure no @unchecked Sendable remains on TTSEngine-related types (CONC-04):**

- TTSEngine: actor (implicitly Sendable)
- PlaybackManager: @MainActor (implicitly Sendable)
- WordTimingAligner, PronunciationProcessor, SentenceSplitter: Sendable structs (from Plan 01)
- PlaybackDelegate: @MainActor (from Plan 01)
- AudioStreamPlayer: KEEP @unchecked Sendable (it has its own NSLock, not a TTSEngine type -- out of scope)
- CompanionApp: if still @unchecked Sendable, that's out of Phase 19 scope (it holds many subsystems)
  </action>
  <verify>
  <automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -10</automated>
  </verify>
  <acceptance_criteria>
  - `swift build` succeeds with zero errors
  - `swift test` passes (run full suite)
  - `grep -c "ttsEngine: TTSEngine" plugins/claude-tts-companion/Sources/CompanionCore/SubtitleSyncDriver.swift` returns 0 (replaced with audioStreamPlayer)
  - `grep "audioStreamPlayer: AudioStreamPlayer" plugins/claude-tts-companion/Sources/CompanionCore/SubtitleSyncDriver.swift` matches
  - `grep "playbackManager: PlaybackManager" plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift` matches
  - `grep "playbackManager: PlaybackManager" plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift` matches
  - `grep "playbackManager = PlaybackManager" plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift` matches
  - `grep "async" plugins/claude-tts-companion/Sources/CompanionCore/MemoryLifecycle.swift` matches (checkMemoryLifecycleRestart is async)
  - `grep -c "@unchecked Sendable" plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift` returns 0
  - `grep -rn "@unchecked Sendable" plugins/claude-tts-companion/Sources/CompanionCore/PlaybackManager.swift plugins/claude-tts-companion/Sources/CompanionCore/PlaybackDelegate.swift plugins/claude-tts-companion/Sources/CompanionCore/WordTimingAligner.swift plugins/claude-tts-companion/Sources/CompanionCore/PronunciationProcessor.swift plugins/claude-tts-companion/Sources/CompanionCore/SentenceSplitter.swift` returns no matches
    </acceptance_criteria>
    <done>
    All 4 callers + MemoryLifecycle updated. SubtitleSyncDriver decoupled from TTSEngine (receives AudioStreamPlayer directly). TelegramBot and HTTPControlServer use playbackManager for audio ops and await for actor methods. CompanionApp creates PlaybackManager and passes it through. `swift build` and `swift test` both pass. Zero @unchecked Sendable on any TTSEngine-related type.
    </done>
    </task>

</tasks>

<verification>
1. `cd plugins/claude-tts-companion && swift build` -- zero errors
2. `cd plugins/claude-tts-companion && swift test` -- all tests pass
3. `grep -rn "@unchecked Sendable" plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift plugins/claude-tts-companion/Sources/CompanionCore/PlaybackManager.swift plugins/claude-tts-companion/Sources/CompanionCore/PlaybackDelegate.swift plugins/claude-tts-companion/Sources/CompanionCore/WordTimingAligner.swift plugins/claude-tts-companion/Sources/CompanionCore/PronunciationProcessor.swift plugins/claude-tts-companion/Sources/CompanionCore/SentenceSplitter.swift` -- zero matches
4. `grep -c "NSLock" plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift` -- returns 0
5. `grep "public actor TTSEngine" plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift` -- matches
6. `grep "@MainActor" plugins/claude-tts-companion/Sources/CompanionCore/PlaybackManager.swift` -- matches
</verification>

<success_criteria>

- PlaybackManager exists as @MainActor class owning all playback concerns
- TTSEngine is a Swift actor with zero NSLock, zero @unchecked Sendable
- Blocking synthesis bridges to DispatchQueue via withCheckedThrowingContinuation
- SubtitleSyncDriver receives AudioStreamPlayer directly (decoupled from TTSEngine)
- All 4 callers (TelegramBot, HTTPControlServer, SubtitleSyncDriver, CompanionApp) compile and work
- MemoryLifecycle uses async pattern for actor access
- Inline circuit breaker replaced with existing CircuitBreaker class instance
- `swift build` and `swift test` pass
  </success_criteria>

<output>
After completion, create `.planning/phases/19-ttsengine-decomposition-actor-migration/19-02-SUMMARY.md`
</output>
