---
phase: quick
plan: 260330-9js
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Sources/CompanionCore/TTSQueue.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
autonomous: true
requirements: [STREAM-01, STREAM-02, STREAM-03]
must_haves:
  truths:
    - "Long text with \\n\\n breaks starts playing after first paragraph synthesizes, not after all paragraphs"
    - "Short text (no \\n\\n or single paragraph) follows the existing single-call path unchanged"
    - "POST /tts/stop cancels in-flight synthesis and stops audio immediately"
    - "Subtitle karaoke sync works correctly across paragraph chunk boundaries"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/TTSQueue.swift"
      provides: "Paragraph-chunked streaming synthesis loop"
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift"
      provides: "Incremental chunk feeding to SubtitleSyncDriver"
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift"
      provides: "POST /tts/stop endpoint"
  key_links:
    - from: "TTSQueue.executeWorkItem"
      to: "TTSEngine.synthesizeStreamingAutoRoute"
      via: "per-paragraph calls in a loop with cancellation checks"
    - from: "TTSQueue.executeWorkItem"
      to: "TTSPipelineCoordinator.addStreamingChunk"
      via: "feed each paragraph's chunks as they arrive"
    - from: "HTTPControlServer /tts/stop"
      to: "TTSPipelineCoordinator.cancelCurrentPipeline + TTSQueue preemption"
      via: "HTTP route handler"
---

<objective>
Implement streaming paragraph-chunked TTS synthesis so long text starts playing after the first paragraph is ready, not after the entire text is synthesized.

Purpose: Eliminate 50-100s initial silence for long passages (articles, full pages). Enable mid-stream cancellation via /tts/stop.
Output: Modified TTSQueue, TTSPipelineCoordinator, and HTTPControlServer with streaming paragraph pipeline.
</objective>

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

<context>
@plugins/claude-tts-companion/Sources/CompanionCore/TTSQueue.swift
@plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift
@plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
@plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
@plugins/claude-tts-companion/Sources/CompanionCore/AudioStreamPlayer.swift
@plugins/claude-tts-companion/Sources/CompanionCore/PronunciationProcessor.swift

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

From TTSEngine (actor):

```swift
func synthesizeStreamingAutoRoute(text: String, speed: Float = 1.2, cancellationCheck: (() -> Bool)? = nil) async -> [ChunkResult]

struct ChunkResult: Sendable {
    let wavPath: String
    let text: String
    let wordTimings: [TimeInterval]
    let audioDuration: TimeInterval
    let chunkIndex: Int
    let totalChunks: Int
    let wordOnsets: [TimeInterval]?
    let samples: [Float]?
    let wordTexts: [String]?
}
```

From TTSPipelineCoordinator (@MainActor):

```swift
func cancelCurrentPipeline()
func startBatchPipeline(chunks: [TTSEngine.ChunkResult], onComplete: (() -> Void)? = nil)
var isBusy: Bool { isActive }
var shouldUseSubtitleOnly: Bool { isMemoryConstrained }
```

From AudioStreamPlayer:

```swift
func scheduleChunk(samples: [Float], onComplete: ChunkCompletionHandler? = nil)
func reset()
var isPlaying: Bool
```

From SubtitleSyncDriver (created by TTSPipelineCoordinator):

```swift
func addChunk(wavPath:samples:pages:wordTimings:nativeOnsets:)
func startBatchPlayback()
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add streaming paragraph pipeline to TTSPipelineCoordinator and TTSQueue</name>
  <files>
    plugins/claude-tts-companion/Sources/CompanionCore/TTSPipelineCoordinator.swift
    plugins/claude-tts-companion/Sources/CompanionCore/TTSQueue.swift
  </files>
  <action>
**TTSPipelineCoordinator changes:**

Add a new method `startStreamingPipeline()` that allows incremental chunk feeding (unlike `startBatchPipeline()` which expects all chunks upfront):

```swift
/// Start a streaming pipeline session. Creates the SubtitleSyncDriver upfront,
/// then callers feed chunks incrementally via addStreamingChunk().
/// Call finalizeStreamingPipeline() after the last chunk is fed.
func startStreamingPipeline(onComplete: (() -> Void)? = nil) -> Bool
```

- Cancel any in-progress session first (same as startBatchPipeline)
- Set `isActive = true`
- Create `SubtitleSyncDriver` with the onComplete callback, store as `activeSyncDriver`
- Store the onComplete callback for use when finalizing
- Return true (false if some error)

Add method to feed a chunk:

```swift
/// Feed a synthesized paragraph chunk to the active streaming pipeline.
/// The driver accumulates chunks for gapless playback.
func addStreamingChunk(_ chunks: [TTSEngine.ChunkResult])
```

- Guard `activeSyncDriver != nil` (no-op if pipeline was cancelled)
- Use the SAME subtitle logic as `startBatchPipeline` for paragraph scope (the `chunks.count == 1` branch for single-chunk paragraphs, or the multi-chunk merge branch)
- Each paragraph should be treated as its own chunk to the driver (with its own word onsets, samples, etc.)
- After adding the chunk, if this is the first chunk, call `driver.startBatchPlayback()` to begin playback immediately

Add method to finalize:

```swift
/// Signal that all chunks have been fed. No more chunks will arrive.
func finalizeStreamingPipeline()
```

- This is mostly a no-op since SubtitleSyncDriver already handles completion via buffer callbacks
- Log that streaming pipeline is finalized with total chunk count

Add a `streamingChunkCount` private var to track how many chunks have been fed (for the "start playback on first chunk" logic).

**TTSQueue changes:**

Modify `executeWorkItem()` to split long text by `\n\n` paragraphs and synthesize/play incrementally:

1. After building `fullText`, check if it contains `\n\n`:
   - If NO `\n\n` (or only one paragraph): use the existing single-call path unchanged (call `synthesizeStreamingAutoRoute` once, then `startBatchPipeline`)
   - If YES `\n\n`: split into paragraphs, filter empty ones, then use streaming path

2. Streaming path:

   ```swift
   let paragraphs = fullText.components(separatedBy: "\n\n")
       .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
       .filter { !$0.isEmpty }
   ```

   - Call `pipelineCoordinator.startStreamingPipeline(onComplete:)` (via MainActor.run) to set up the driver BEFORE synthesis
   - Use a continuation like the existing code, but the continuation resumes from the onComplete callback
   - Loop over paragraphs:
     - Check cancellation (`token.isCancelled || Task.isCancelled`) before each paragraph
     - Call `ttsEngine.synthesizeStreamingAutoRoute(text: paragraph, cancellationCheck: { token.isCancelled })`
     - If chunks empty (synthesis failed), log warning and continue to next paragraph
     - Feed chunks to coordinator via `MainActor.run { pipelineCoordinator.addStreamingChunk(chunks) }`
   - After loop, call `MainActor.run { pipelineCoordinator.finalizeStreamingPipeline() }`
   - Await the continuation (playback completion)

3. Caption history: record the full text once (not per-paragraph). Accumulate word count, onset count, duration from all paragraph chunks for the `captionHistory.record()` call. Move the record call to after synthesis completes but before awaiting playback.

4. Log paragraph streaming metrics: "Streaming N paragraphs (M total chars)" at start, per-paragraph synthesis time.

Important: The `showSubtitleFallback` path (memory pressure, circuit breaker) stays unchanged -- it only fires for the full text, not per-paragraph.
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
</verify>
<done> - Long text with \n\n is split into paragraphs and synthesized one at a time - Playback starts after first paragraph synthesizes (not after all paragraphs) - Short text (no \n\n) uses the existing single-call path unchanged - Cancellation token is checked between paragraphs - Caption history records the full text once with accumulated metrics
</done>
</task>

<task type="auto">
  <name>Task 2: Add POST /tts/stop endpoint to HTTPControlServer</name>
  <files>
    plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
  </files>
  <action>
Add a new route in the `start()` method, after the existing `/tts/speak` route:

```swift
// API-10: TTS stop — cancel in-flight synthesis and stop audio immediately
await server.appendRoute("POST /tts/stop") { [self] _ in
    logger.info("TTS stop requested via HTTP")
    await ttsQueue.stopAll()
    return jsonResponse(OkResponse(ok: true))
}
```

Add a `stopAll()` method to TTSQueue (in TTSQueue.swift):

```swift
/// Stop all TTS activity: cancel in-flight synthesis, drain queue, stop playback.
/// Called from HTTP /tts/stop endpoint.
public func stopAll() {
    currentToken?.cancel()
    currentTask?.cancel()
    currentTask = nil
    queue.removeAll()
    userRequestActive = false
    DispatchQueue.main.async { [pipelineCoordinator] in
        pipelineCoordinator.cancelCurrentPipeline()
    }
    logger.info("All TTS activity stopped (queue drained, pipeline cancelled)")
}
```

This is similar to the existing `preempt()` method but:

- Does NOT set `userRequestActive = true` (stop is not a new request)
- Also clears `currentToken` reference
- Resets all state to idle

Note: `cancelCurrentPipeline()` already stops the SubtitleSyncDriver, resets AudioStreamPlayer, and stops AVAudioPlayer playback, so this covers the full stop chain.
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
</verify>
<done> - POST /tts/stop endpoint exists and returns {"ok": true} - Calling /tts/stop cancels in-flight synthesis, drains queue, stops audio - Builds without errors
</done>
</task>

</tasks>

<verification>
1. `cd plugins/claude-tts-companion && swift build` compiles without errors
2. Short text path (no \n\n) unchanged — single synthesizeStreamingAutoRoute call, startBatchPipeline
3. Long text path (with \n\n) — paragraphs synthesized sequentially, playback starts after first paragraph
4. /tts/stop cancels mid-stream and returns 200
</verification>

<success_criteria>

- swift build succeeds
- No regressions to existing single-chunk TTS path
- Paragraph chunking only activates when text contains \n\n
- /tts/stop endpoint registered and functional
- Cancellation checked between each paragraph synthesis call
  </success_criteria>

<output>
After completion, create `.planning/quick/260330-9js-streaming-paragraph-chunking-for-long-tt/260330-9js-SUMMARY.md`
</output>
