---
phase: 24-chinese-tts-fallback
plan: 02
type: execute
wave: 2
depends_on: ["24-01"]
files_modified:
  - plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/LanguageDetector.swift
autonomous: true
requirements:
  - CJK-01
  - CJK-02
  - CJK-03
  - CJK-04

must_haves:
  truths:
    - "Text with >20% CJK characters is synthesized via sherpa-onnx, not kokoro-ios MLX"
    - "English text continues to use kokoro-ios MLX engine with zero behavior change"
    - "If sherpa-onnx model is missing or synthesis fails, system logs a warning and shows subtitle-only"
    - "CJK synthesis produces audio that plays through the same AudioStreamPlayer pipeline"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift"
      provides: "CJK routing in synthesizeStreaming and synthesize methods"
      contains: "sherpaOnnxEngine"
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift"
      provides: "CJK-aware TTS dispatch"
      contains: "langResult.lang.*cmn"
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift"
      provides: "SherpaOnnxEngine instantiation and wiring"
      contains: "SherpaOnnxEngine"
  key_links:
    - from: "TelegramBot.swift"
      to: "TTSEngine.swift"
      via: "synthesizeStreaming with CJK routing"
      pattern: "synthesizeStreaming"
    - from: "TTSEngine.swift"
      to: "SherpaOnnxEngine.swift"
      via: "sherpaOnnxEngine.synthesize"
      pattern: "sherpaOnnxEngine.*synthesize"
    - from: "CompanionApp.swift"
      to: "SherpaOnnxEngine"
      via: "initialization and injection"
      pattern: "SherpaOnnxEngine"
---

<objective>
Wire SherpaOnnxEngine into the TTS pipeline so CJK text routes to sherpa-onnx while English stays on kokoro-ios MLX.

Purpose: Completes the Chinese TTS fallback feature by connecting the sherpa-onnx engine (from plan 01) into the existing synthesis pipeline. CJK detection already exists in LanguageDetector; this plan makes TTSEngine actually use it for routing.

Output: Working CJK-to-sherpa-onnx synthesis path, English unchanged, graceful fallback on failure
</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/24-chinese-tts-fallback/24-01-SUMMARY.md

@plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
@plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift
@plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
@plugins/claude-tts-companion/Sources/CompanionCore/LanguageDetector.swift
@plugins/claude-tts-companion/Sources/CompanionCore/SherpaOnnxEngine.swift

<interfaces>
<!-- From TTSEngine.swift (actor) -->
```swift
public actor TTSEngine {
    let playbackManager: PlaybackManager
    func synthesize(text:voiceName:speed:) async throws -> SynthesisResult
    func synthesizeWithTimestamps(text:voiceName:speed:) async throws -> TTSResult
    func synthesizeStreaming(text:voiceName:speed:) async -> [ChunkResult]
}
```

<!-- From SherpaOnnxEngine.swift (plan 01 output) -->

```swift
public final class SherpaOnnxEngine: @unchecked Sendable {
    let modelAvailable: Bool
    func synthesize(text:speakerId:speed:) -> (samples: [Float], sampleRate: Int32)?
    func unloadModel()
    var isLoaded: Bool
}
```

<!-- From LanguageDetector.swift -->

```swift
public struct LanguageResult {
    let lang: String       // "en-us" or "cmn"
    let speakerId: Int32
    let voiceName: String
}
public enum LanguageDetector {
    static func detect(text: String) -> LanguageResult
}
```

<!-- From TelegramBot.swift dispatchTTS -->

```swift
private func dispatchTTS(text: String, greeting: String?) async {
    // ...
    let langResult = LanguageDetector.detect(text: fullText)
    if langResult.lang == "cmn" {
        logger.warning("CJK text detected but kokoro-ios is English-only...")
    }
    // ...
    dispatchStreamingTTS(text: fullText, voiceName: langResult.voiceName)
}
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Wire SherpaOnnxEngine into TTSEngine + CompanionApp</name>
  <files>
    plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
    plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
    plugins/claude-tts-companion/Sources/CompanionCore/LanguageDetector.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
    plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
    plugins/claude-tts-companion/Sources/CompanionCore/LanguageDetector.swift
    plugins/claude-tts-companion/Sources/CompanionCore/SherpaOnnxEngine.swift
  </read_first>
  <action>
1. **TTSEngine.swift** -- Add sherpa-onnx integration:

a. Add a `let sherpaOnnxEngine: SherpaOnnxEngine` property to the TTSEngine actor (injected at init, alongside playbackManager).

b. Update `init(playbackManager:)` to `init(playbackManager:sherpaOnnxEngine:)` and store it.

c. Add a new method for CJK synthesis that returns ChunkResult-compatible output:
```swift
/// Synthesize CJK text via sherpa-onnx engine.
/// Returns a single ChunkResult with the full text (no sentence splitting for CJK).
/// Returns nil if synthesis fails (CJK-04 graceful fallback).
func synthesizeCJK(text: String, speed: Float = 1.0) async -> ChunkResult? {
guard let result = sherpaOnnxEngine.synthesize(text: text, speed: speed) else {
logger.warning("CJK synthesis failed -- falling back to subtitle-only")
return nil
}

          let wavPath = NSTemporaryDirectory() + "tts-cjk-\(UUID().uuidString).wav"
          let audioDuration = Double(result.samples.count) / Double(result.sampleRate)

          // Add trailing silence (same as English streaming path)
          let paddedSamples = result.samples + [Float](repeating: 0.0, count: Self.trailingSilenceSamples)

          do {
              try Self.writeWav(samples: paddedSamples, sampleRate: Double(result.sampleRate), to: wavPath)
          } catch {
              logger.error("CJK WAV write failed: \(error)")
              return nil
          }

          // CJK karaoke timing is out of scope -- use uniform word timing as fallback.
          // Split text into characters for subtitle display (each char = one "word").
          let words = text.map { String($0) }.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
          let perWordDuration = words.isEmpty ? audioDuration : audioDuration / Double(words.count)
          let wordTimings = Array(repeating: perWordDuration, count: words.count)

          synthesisCount += 1
          logger.info("CJK synthesis complete: \(String(format: "%.2f", audioDuration))s audio, count=\(synthesisCount)")

          return ChunkResult(
              wavPath: wavPath,
              text: text,
              wordTimings: wordTimings,
              audioDuration: audioDuration,
              chunkIndex: 0,
              totalChunks: 1,
              wordOnsets: nil,
              samples: paddedSamples
          )
      }
      ```

d. Add a convenience method that routes based on language:
```swift
/// Synthesize text, automatically routing CJK to sherpa-onnx and English to kokoro-ios MLX.
/// Returns empty array if synthesis fails entirely.
func synthesizeStreamingAutoRoute(text: String, speed: Float = 1.2) async -> [ChunkResult] {
let langResult = LanguageDetector.detect(text: text)

          if langResult.lang == "cmn" {
              logger.info("CJK text detected (\(text.count) chars) -- routing to sherpa-onnx")
              if let chunk = await synthesizeCJK(text: text, speed: 1.0) {
                  return [chunk]
              }
              // Fallback: subtitle-only (CJK-04)
              logger.warning("CJK synthesis failed -- returning empty for subtitle-only fallback")
              return []
          }

          // English path: use existing kokoro-ios MLX streaming (CJK-02)
          return await synthesizeStreaming(text: text, voiceName: langResult.voiceName, speed: speed)
      }
      ```

2. **CompanionApp.swift** -- Instantiate SherpaOnnxEngine and pass to TTSEngine:

   a. Add a `let sherpaOnnxEngine = SherpaOnnxEngine()` property.
   b. Update the TTSEngine initialization to pass sherpaOnnxEngine: `TTSEngine(playbackManager: playbackManager, sherpaOnnxEngine: sherpaOnnxEngine)`.

1. **LanguageDetector.swift** -- Update the CJK detection to return `Config.chineseVoiceName` instead of `Config.defaultVoiceName` (now that CJK TTS actually works):
   Change the `cmn` return to:

   ```swift
   return LanguageResult(
       lang: "cmn",
       speakerId: Config.chineseSpeakerId,
       voiceName: Config.chineseVoiceName  // Now routes to sherpa-onnx, not graceful degradation
   )
   ```

1. Verify: `cd plugins/claude-tts-companion && swift build 2>&1 | tail -5`
   </action>

  <verify>
    <automated>cd /Users/terryli/eon/cc-skills/plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - TTSEngine has sherpaOnnxEngine property
    - TTSEngine.synthesizeCJK returns ChunkResult with CJK audio samples
    - TTSEngine.synthesizeStreamingAutoRoute routes based on LanguageDetector.detect
    - CompanionApp creates SherpaOnnxEngine and passes to TTSEngine init
    - LanguageDetector returns chineseVoiceName for CJK text
    - `swift build` succeeds
  </acceptance_criteria>
  <done>TTSEngine routes CJK text to sherpa-onnx and English to kokoro-ios MLX; CompanionApp wires SherpaOnnxEngine; swift build passes</done>
</task>

<task type="auto">
  <name>Task 2: Update TelegramBot CJK dispatch + build verification</name>
  <files>
    plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/CompanionCore/TelegramBot.swift
    plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
  </read_first>
  <action>
1. **TelegramBot.swift** -- Update `dispatchTTS` to use the auto-routing method:

a. In `dispatchTTS(text:greeting:)`, remove the CJK warning log that says "kokoro-ios is English-only". Replace it with an info-level log: `logger.info("CJK text detected -- will route to sherpa-onnx engine")`.

b. Update `dispatchStreamingTTS(text:voiceName:)` to call `synthesizeStreamingAutoRoute` instead of `synthesizeStreaming`. The method signature change: - Old: `let chunks = await self.ttsEngine.synthesizeStreaming(text: text, voiceName: voiceName)` - New: `let chunks = await self.ttsEngine.synthesizeStreamingAutoRoute(text: text)` - The auto-route method handles language detection internally, so `voiceName` parameter is no longer needed by the streaming path.

c. Similarly update `dispatchFullTTS` if it exists for the non-streaming path -- use `synthesizeStreamingAutoRoute` there too (it returns ChunkResult array, same playback path). If `dispatchFullTTS` uses `synthesizeWithTimestamps`, add CJK routing there as well: check `LanguageDetector.detect(text:)`, and if `cmn`, use `synthesizeCJK` instead of `synthesizeWithTimestamps`.

d. Verify CJK-04 graceful fallback: when `synthesizeStreamingAutoRoute` returns an empty array for CJK text, the existing empty-chunks guard in `dispatchStreamingTTS` already calls `showSubtitleOnlyFallback(text:)` -- this is the correct behavior. Confirm this path exists and log message is appropriate.

1. Verify full build: `cd plugins/claude-tts-companion && swift build 2>&1 | tail -10`

2. Verify the binary links correctly (no missing symbols): `cd plugins/claude-tts-companion && swift build -c release 2>&1 | tail -10`
   </action>

  <verify>
    <automated>cd /Users/terryli/eon/cc-skills/plugins/claude-tts-companion && swift build -c release 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - TelegramBot.dispatchStreamingTTS calls synthesizeStreamingAutoRoute (not synthesizeStreaming with hardcoded voiceName)
    - No "kokoro-ios is English-only" warning log remains for CJK text
    - Empty chunks from CJK failure triggers subtitle-only fallback
    - `swift build` succeeds in both debug and release modes
    - No linker errors for sherpa-onnx symbols in release build
  </acceptance_criteria>
  <done>CJK text routes to sherpa-onnx via auto-routing; English unchanged; graceful fallback on failure; release build passes</done>
</task>

</tasks>

<verification>
- `swift build -c release` compiles with zero errors and zero linker warnings
- CJK routing path: LanguageDetector.detect -> TTSEngine.synthesizeStreamingAutoRoute -> synthesizeCJK -> SherpaOnnxEngine.synthesize
- English path: LanguageDetector.detect -> TTSEngine.synthesizeStreamingAutoRoute -> synthesizeStreaming (unchanged)
- Fallback path: SherpaOnnxEngine returns nil -> empty ChunkResult array -> subtitle-only fallback
</verification>

<success_criteria>

- CJK text detected by LanguageDetector routes to sherpa-onnx engine (CJK-01)
- English text continues through kokoro-ios MLX path unchanged (CJK-02)
- sherpa-onnx model loads on first CJK request via SherpaOnnxEngine (CJK-03)
- Missing model or synthesis failure falls back to subtitle-only display with warning log (CJK-04)
- Both debug and release builds pass
  </success_criteria>

<output>
After completion, create `.planning/phases/24-chinese-tts-fallback/24-02-SUMMARY.md`
</output>
