---
phase: 24-chinese-tts-fallback
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Sources/CSherpaOnnx/include/module.modulemap
  - plugins/claude-tts-companion/Sources/CSherpaOnnx/include/c-api.h
  - plugins/claude-tts-companion/Sources/CSherpaOnnx/shim.c
  - plugins/claude-tts-companion/Sources/CompanionCore/SherpaOnnxEngine.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/Config.swift
  - plugins/claude-tts-companion/Package.swift
autonomous: true
requirements:
  - CJK-01
  - CJK-03
  - CJK-04

must_haves:
  truths:
    - "CSherpaOnnx C module compiles and exposes sherpa-onnx TTS C API to Swift"
    - "SherpaOnnxEngine loads the kokoro-int8-multi-lang model on first call, not at startup"
    - "SherpaOnnxEngine synthesizes Chinese text to float32 PCM samples"
    - "SherpaOnnxEngine unloads model after 30 seconds of idle (load-on-demand cooldown)"
    - "If model files are missing, SherpaOnnxEngine returns nil gracefully without crashing"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/CSherpaOnnx/include/module.modulemap"
      provides: "C module map for sherpa-onnx header"
    - path: "plugins/claude-tts-companion/Sources/CSherpaOnnx/include/c-api.h"
      provides: "Vendored sherpa-onnx C API header"
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/SherpaOnnxEngine.swift"
      provides: "On-demand sherpa-onnx TTS engine for CJK text"
      exports: ["SherpaOnnxEngine"]
  key_links:
    - from: "SherpaOnnxEngine.swift"
      to: "CSherpaOnnx"
      via: "import CSherpaOnnx"
      pattern: "import CSherpaOnnx"
    - from: "Package.swift"
      to: "sherpa-onnx static libs"
      via: "linkerSettings unsafeFlags"
      pattern: "unsafeFlags.*sherpa-onnx"
---

<objective>
Create the CSherpaOnnx C module target and SherpaOnnxEngine Swift class that wraps the sherpa-onnx C API for on-demand Chinese TTS synthesis.

Purpose: Provides the low-level foundation for CJK text-to-speech using the pre-built sherpa-onnx static libraries and the kokoro-int8-multi-lang-v1_0 model. The engine loads lazily on first CJK request and unloads after 30 seconds of idle to minimize RSS impact.

Output: CSherpaOnnx module, SherpaOnnxEngine class, updated Package.swift and Config.swift
</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
@plugins/claude-tts-companion/Package.swift
@plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
@plugins/claude-tts-companion/Sources/CompanionCore/Config.swift
@plugins/claude-tts-companion/Sources/CompanionCore/TTSError.swift

<interfaces>
<!-- sherpa-onnx C API (from /Users/terryli/fork-tools/sherpa-onnx/build-swift-macos/install/include/sherpa-onnx/c-api/c-api.h) -->

Key structs and functions needed:

```c
typedef struct SherpaOnnxOfflineTtsKokoroModelConfig {
  const char *model;
  const char *voices;
  const char *tokens;
  const char *data_dir;
  float length_scale;
  const char *dict_dir;
  const char *lexicon;
  const char *lang;
} SherpaOnnxOfflineTtsKokoroModelConfig;

typedef struct SherpaOnnxOfflineTtsModelConfig {
  SherpaOnnxOfflineTtsVitsModelConfig vits;
  int32_t num_threads;
  int32_t debug;
  const char *provider;
  // ... other model types ...
  SherpaOnnxOfflineTtsKokoroModelConfig kokoro;
} SherpaOnnxOfflineTtsModelConfig;

typedef struct SherpaOnnxOfflineTtsConfig {
  SherpaOnnxOfflineTtsModelConfig model;
  const char *rule_fsts;
  int32_t max_num_sentences;
  const char *rule_fars;
  float silence_scale;
} SherpaOnnxOfflineTtsConfig;

typedef struct SherpaOnnxGeneratedAudio {
  const float *samples;
  int32_t n;
  int32_t sample_rate;
} SherpaOnnxGeneratedAudio;

const SherpaOnnxOfflineTts *SherpaOnnxCreateOfflineTts(const SherpaOnnxOfflineTtsConfig *config);
void SherpaOnnxDestroyOfflineTts(const SherpaOnnxOfflineTts *tts);
const SherpaOnnxGeneratedAudio *SherpaOnnxOfflineTtsGenerate(
    const SherpaOnnxOfflineTts *tts, const char *text, int32_t sid, float speed);
void SherpaOnnxDestroyOfflineTtsGeneratedAudio(const SherpaOnnxGeneratedAudio *audio);
int32_t SherpaOnnxOfflineTtsSampleRate(const SherpaOnnxOfflineTts *tts);
```

From Config.swift:

```swift
static let chineseSpeakerId: Int32 = 45
static let chineseVoiceName: String = "zf_xiaobei"
static let cjkDetectionThreshold: Double = 20.0
```

From TTSError.swift:

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

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: CSherpaOnnx C module + Package.swift linker settings</name>
  <files>
    plugins/claude-tts-companion/Sources/CSherpaOnnx/include/module.modulemap
    plugins/claude-tts-companion/Sources/CSherpaOnnx/include/c-api.h
    plugins/claude-tts-companion/Sources/CSherpaOnnx/shim.c
    plugins/claude-tts-companion/Package.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Package.swift
    /Users/terryli/fork-tools/sherpa-onnx/build-swift-macos/install/include/sherpa-onnx/c-api/c-api.h
  </read_first>
  <action>
1. Create `Sources/CSherpaOnnx/include/module.modulemap`:
   ```
   module CSherpaOnnx {
       header "c-api.h"
       export *
   }
   ```

1. Copy the vendored header from `/Users/terryli/fork-tools/sherpa-onnx/build-swift-macos/install/include/sherpa-onnx/c-api/c-api.h` to `Sources/CSherpaOnnx/include/c-api.h`. This vendored copy avoids system-level pkg-config dependency.

2. Create `Sources/CSherpaOnnx/shim.c` with a single empty function (SwiftPM requires at least one .c file in a C target):

   ```c
   // Shim file required by SwiftPM for C targets
   void _csherpaonnx_shim(void) {}
   ```

3. Update `Package.swift`:
   - Add a `.systemLibrary` or `.target` for CSherpaOnnx:

     ```swift
     .target(
         name: "CSherpaOnnx",
         path: "Sources/CSherpaOnnx",
         publicHeadersPath: "include"
     ),
     ```

   - Add "CSherpaOnnx" to CompanionCore's dependencies array.
   - Add linker settings to CompanionCore for the sherpa-onnx static libraries. The libs are at the path from env var SHERPA_ONNX_PATH (default: `/Users/terryli/fork-tools/sherpa-onnx/build-swift-macos/install`). Use unsafeFlags for the library search path and link each required static lib:

     ```swift
     .unsafeFlags([
         "-L/Users/terryli/fork-tools/sherpa-onnx/build-swift-macos/install/lib",
     ]),
     .linkedLibrary("sherpa-onnx-c-api"),
     .linkedLibrary("sherpa-onnx-core"),
     .linkedLibrary("sherpa-onnx"),
     .linkedLibrary("onnxruntime"),
     .linkedLibrary("espeak-ng"),
     .linkedLibrary("piper_phonemize"),
     .linkedLibrary("ssentencepiece_core"),
     .linkedLibrary("ucd"),
     .linkedLibrary("kaldi-decoder-core"),
     .linkedLibrary("kaldi-native-fbank-core"),
     .linkedLibrary("sherpa-onnx-kaldifst-core"),
     .linkedLibrary("sherpa-onnx-fst"),
     .linkedLibrary("sherpa-onnx-fstfar"),
     .linkedLibrary("kissfft-float"),
     ```

   - Also add `.linkedLibrary("c++")` since sherpa-onnx is C++ under the hood.
   - IMPORTANT: Do NOT add onnxruntime-swift-package-manager SPM package -- it conflicts with sherpa-onnx's bundled libonnxruntime.a.

4. Verify with `cd plugins/claude-tts-companion && swift build 2>&1 | tail -20` -- must compile without errors.
   </action>

  <verify>
    <automated>cd /Users/terryli/eon/cc-skills/plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - CSherpaOnnx target exists in Package.swift with publicHeadersPath "include"
    - c-api.h vendored in Sources/CSherpaOnnx/include/
    - module.modulemap declares module CSherpaOnnx
    - CompanionCore depends on CSherpaOnnx
    - linkerSettings include -L path to sherpa-onnx install/lib
    - All 12+ sherpa-onnx static libs linked
    - `swift build` succeeds with zero errors
  </acceptance_criteria>
  <done>CSherpaOnnx C module compiles; CompanionCore can `import CSherpaOnnx`; swift build passes</done>
</task>

<task type="auto">
  <name>Task 2: SherpaOnnxEngine + Config constants for CJK TTS</name>
  <files>
    plugins/claude-tts-companion/Sources/CompanionCore/SherpaOnnxEngine.swift
    plugins/claude-tts-companion/Sources/CompanionCore/Config.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
    plugins/claude-tts-companion/Sources/CompanionCore/Config.swift
    plugins/claude-tts-companion/Sources/CompanionCore/TTSError.swift
    plugins/claude-tts-companion/Sources/CompanionCore/CircuitBreaker.swift
  </read_first>
  <action>
1. Add to Config.swift:
   ```swift
   // MARK: - sherpa-onnx Chinese TTS

/// Path to sherpa-onnx kokoro-int8-multi-lang model directory.
/// Contains model.int8.onnx, tokens.txt, voices.bin, dict/, espeak-ng-data/, lexicon files, FST files.
static let sherpaOnnxModelDir: String = {
let home = ProcessInfo.processInfo.environment["HOME"] ?? "/Users/terryli"
return ProcessInfo.processInfo.environment["KOKORO_MODEL_PATH"]
?? "\(home)/.local/share/kokoro/models/kokoro-int8-multi-lang-v1_0"
}()

/// Idle timeout before unloading sherpa-onnx model to reclaim RSS (seconds).
/// Per milestone decision: 30-second idle cooldown for load-on-demand.
static let sherpaOnnxIdleTimeoutSeconds: TimeInterval = 30

/// Number of CPU threads for sherpa-onnx inference.
static let sherpaOnnxNumThreads: Int32 = 2

````

2. Create `SherpaOnnxEngine.swift` -- a Sendable class (NOT an actor, since synthesis is synchronous C code that should run on a DispatchQueue). Key design:

```swift
import CSherpaOnnx
import Foundation
import Logging

/// On-demand sherpa-onnx TTS engine for CJK text synthesis.
///
/// Loads the kokoro-int8-multi-lang model lazily on first synthesis request (CJK-03).
/// Unloads after 30 seconds of idle to minimize RSS impact.
/// Thread-safe via NSLock (synthesis is blocking C code on a DispatchQueue, not async).
///
/// Returns nil from synthesize() if model files are missing (CJK-04 graceful fallback).
public final class SherpaOnnxEngine: @unchecked Sendable {
    private let logger = Logger(label: "sherpa-onnx-engine")
    private let lock = NSLock()

    /// Opaque pointer to the loaded sherpa-onnx TTS instance.
    private var ttsPtr: OpaquePointer?  // const SherpaOnnxOfflineTts*

    /// Sample rate of the loaded model (typically 24000).
    private(set) var sampleRate: Int32 = 24000

    /// Timer that fires after idle timeout to unload the model.
    private var idleTimer: DispatchWorkItem?

    /// Whether model files exist on disk (checked once at init).
    let modelAvailable: Bool

    init() {
        let fm = FileManager.default
        let modelPath = Config.sherpaOnnxModelDir + "/model.int8.onnx"
        let tokensPath = Config.sherpaOnnxModelDir + "/tokens.txt"
        let voicesPath = Config.sherpaOnnxModelDir + "/voices.bin"
        modelAvailable = fm.fileExists(atPath: modelPath)
            && fm.fileExists(atPath: tokensPath)
            && fm.fileExists(atPath: voicesPath)
        if !modelAvailable {
            logger.warning("sherpa-onnx model not found at \(Config.sherpaOnnxModelDir) -- CJK TTS disabled")
        } else {
            logger.info("sherpa-onnx model validated at \(Config.sherpaOnnxModelDir) (will load on first CJK request)")
        }
    }

    deinit {
        unloadModel()
    }

    /// Synthesize CJK text to float32 PCM samples.
    ///
    /// Returns nil if model is unavailable or synthesis fails (CJK-04).
    /// Caller is responsible for writing samples to WAV if needed.
    ///
    /// - Parameters:
    ///   - text: Chinese text to synthesize
    ///   - speakerId: Speaker ID (default: Config.chineseSpeakerId = 45)
    ///   - speed: Speech speed (default: 1.0)
    /// - Returns: (samples: [Float], sampleRate: Int32) or nil on failure
    func synthesize(text: String, speakerId: Int32 = Config.chineseSpeakerId, speed: Float = 1.0) -> (samples: [Float], sampleRate: Int32)? {
        guard modelAvailable else {
            logger.warning("CJK synthesis skipped -- model not available")
            return nil
        }

        lock.lock()
        defer { lock.unlock() }

        // Load model on demand (CJK-03)
        if ttsPtr == nil {
            guard loadModel() else { return nil }
        }

        // Reset idle timer
        resetIdleTimer()

        guard let tts = ttsPtr else { return nil }

        // Call sherpa-onnx C API
        guard let audio = SherpaOnnxOfflineTtsGenerate(tts, text, speakerId, speed) else {
            logger.error("SherpaOnnxOfflineTtsGenerate returned nil for: \(text.prefix(50))")
            return nil
        }
        defer { SherpaOnnxDestroyOfflineTtsGeneratedAudio(audio) }

        let count = Int(audio.pointee.n)
        guard count > 0 else {
            logger.error("sherpa-onnx generated 0 samples for: \(text.prefix(50))")
            return nil
        }

        // Copy samples out before destroying the audio struct
        let samples = Array(UnsafeBufferPointer(start: audio.pointee.samples, count: count))
        let rate = audio.pointee.sample_rate

        logger.info("sherpa-onnx synthesis: \(count) samples at \(rate)Hz (\(String(format: "%.2f", Double(count) / Double(rate)))s)")

        return (samples: samples, sampleRate: rate)
    }

    /// Explicitly unload the model to reclaim memory.
    func unloadModel() {
        lock.lock()
        defer { lock.unlock() }
        idleTimer?.cancel()
        idleTimer = nil
        if let tts = ttsPtr {
            SherpaOnnxDestroyOfflineTts(tts)
            ttsPtr = nil
            logger.info("sherpa-onnx model unloaded")
        }
    }

    /// Whether the model is currently loaded in memory.
    var isLoaded: Bool {
        lock.lock()
        defer { lock.unlock() }
        return ttsPtr != nil
    }

    // MARK: - Private

    /// Load the sherpa-onnx model. Must be called with lock held.
    private func loadModel() -> Bool {
        let modelDir = Config.sherpaOnnxModelDir
        let startTime = CFAbsoluteTimeGetCurrent()

        // Build config using the kokoro model type
        var config = SherpaOnnxOfflineTtsConfig()

        // Zero-initialize all fields (C structs may have garbage)
        memset(&config, 0, MemoryLayout<SherpaOnnxOfflineTtsConfig>.size)

        let modelPath = modelDir + "/model.int8.onnx"
        let tokensPath = modelDir + "/tokens.txt"
        let voicesPath = modelDir + "/voices.bin"
        let dataDirPath = modelDir + "/espeak-ng-data"
        let dictDirPath = modelDir + "/dict"
        let lexiconPaths = [
            modelDir + "/lexicon-us-en.txt",
            modelDir + "/lexicon-zh.txt",
        ].joined(separator: ",")
        let ruleFsts = [
            modelDir + "/date-zh.fst",
            modelDir + "/number-zh.fst",
            modelDir + "/phone-zh.fst",
        ].joined(separator: ",")

        // Use withCString chains to keep strings alive during C call
        let tts: OpaquePointer? = modelPath.withCString { modelCStr in
            tokensPath.withCString { tokensCStr in
                voicesPath.withCString { voicesCStr in
                    dataDirPath.withCString { dataDirCStr in
                        dictDirPath.withCString { dictDirCStr in
                            lexiconPaths.withCString { lexiconCStr in
                                ruleFsts.withCString { ruleFstsCStr in
                                    config.model.kokoro.model = modelCStr
                                    config.model.kokoro.tokens = tokensCStr
                                    config.model.kokoro.voices = voicesCStr
                                    config.model.kokoro.data_dir = dataDirCStr
                                    config.model.kokoro.dict_dir = dictDirCStr
                                    config.model.kokoro.lexicon = lexiconCStr
                                    config.model.kokoro.length_scale = 1.0
                                    config.model.num_threads = Config.sherpaOnnxNumThreads
                                    config.model.debug = 0
                                    config.rule_fsts = ruleFstsCStr
                                    config.max_num_sentences = 1
                                    return SherpaOnnxCreateOfflineTts(&config)
                                }
                            }
                        }
                    }
                }
            }
        }

        guard let tts = tts else {
            logger.error("SherpaOnnxCreateOfflineTts returned nil -- model load failed")
            return false
        }

        ttsPtr = tts
        sampleRate = SherpaOnnxOfflineTtsSampleRate(tts)
        let elapsed = CFAbsoluteTimeGetCurrent() - startTime
        logger.info("sherpa-onnx model loaded in \(String(format: "%.2f", elapsed))s (sample rate: \(sampleRate)Hz)")
        return true
    }

    /// Reset the idle unload timer. Must be called with lock held.
    private func resetIdleTimer() {
        idleTimer?.cancel()
        let work = DispatchWorkItem { [weak self] in
            self?.unloadModel()
        }
        idleTimer = work
        DispatchQueue.global(qos: .utility).asyncAfter(
            deadline: .now() + Config.sherpaOnnxIdleTimeoutSeconds,
            execute: work
        )
    }
}
````

Key design points:

- `@unchecked Sendable` + NSLock because the sherpa-onnx C API is blocking synchronous code (NOT async). This matches the pre-Phase-19 pattern that was appropriate for C library wrappers.
- `loadModel()` uses nested `withCString` to keep all C strings alive during `SherpaOnnxCreateOfflineTts` call.
- `memset` zero-initializes the C config struct to avoid garbage in unused fields (vits, matcha, etc.).
- `resetIdleTimer()` creates a new DispatchWorkItem each call, cancelling the previous one. After 30s of no synthesis, `unloadModel()` fires.
- `synthesize()` returns `nil` (not throws) for graceful fallback -- callers just fall back to subtitle-only.
- Model path uses `KOKORO_MODEL_PATH` env var which is already set in the launchd plist.

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>
    - Config.sherpaOnnxModelDir reads from KOKORO_MODEL_PATH env var
    - Config.sherpaOnnxIdleTimeoutSeconds equals 30
    - SherpaOnnxEngine.modelAvailable checks model.int8.onnx, tokens.txt, voices.bin at init
    - SherpaOnnxEngine.synthesize() returns nil when modelAvailable is false
    - SherpaOnnxEngine.synthesize() calls SherpaOnnxOfflineTtsGenerate via CSherpaOnnx import
    - SherpaOnnxEngine.unloadModel() calls SherpaOnnxDestroyOfflineTts
    - Idle timer calls unloadModel after 30 seconds
    - `swift build` succeeds
  </acceptance_criteria>
  <done>SherpaOnnxEngine can synthesize Chinese text via sherpa-onnx C API with on-demand loading and 30s idle unload; swift build passes</done>
</task>

</tasks>

<verification>
- `swift build` compiles without errors
- CSherpaOnnx module is importable from CompanionCore
- SherpaOnnxEngine class exists with synthesize/unloadModel public API
- Config has sherpaOnnxModelDir, sherpaOnnxIdleTimeoutSeconds, sherpaOnnxNumThreads
</verification>

<success_criteria>

- CSherpaOnnx C module target compiles with vendored header
- Package.swift links all sherpa-onnx static libraries
- SherpaOnnxEngine provides on-demand model loading with 30s idle cooldown
- Graceful nil return when model files are missing
- swift build passes with zero errors
  </success_criteria>

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