# Decrypt Parity Matrix — TapListenSDK ↔ Speechify SSDK

**Purpose.** Single living document tracking every Speechify SSDK component (from `recon/`) against TapListenSDK's implementation status. Drives wave planning. Updated whenever a wave lands or a new gap is discovered.

**Sources.**
- Speechify recon: `/Users/kohuyn/StudioProjects/DecryptTTS/recon/` (class dumps, strings, pipeline notes)
- TapListenSDK code: `/Users/kohuyn/StudioProjects/TapListenSDK/`
- Plan reference: `/Users/kohuyn/StudioProjects/DecryptTTS/plans/260429-1908-bookcast-tts-reader/`

## Status legend

| Status | Meaning |
|---|---|
| `DONE` | Implemented, build clean, behavior verified or 1:1 mapping |
| `PARTIAL` | Implemented with documented deficits — see Notes |
| `SCAFFOLDED` | Protocol/types exist but throw `notConfigured` (cloud features) |
| `MISSING` | Not implemented, no scaffold |
| `INTENTIONAL_SKIP` | Anti-goal per `plan.md` (Tier 3+, CarPlay, AI, multi-device sync, etc.) |

## Severity legend

| Severity | Meaning |
|---|---|
| **Critical** | User-visible bug or App Store blocker |
| **High** | Feature parity loss, affects UX or correctness |
| **Medium** | Polish, nice-to-have visible diff |
| **Low** | Edge case or rarely-hit |
| **N/A** | Severity not applicable (e.g. INTENTIONAL_SKIP) |

## Domain coverage

| Domain | Scope decision | Status |
|---|---|---|
| D1 — Content Pipeline (import/extract/bundle) | In scope | Pilot done |
| D2 — Reading Bundle (runtime) | In scope | Audited |
| D3 — Reader UI + Block types | In scope | Audited |
| D4 — Highlight & Polygon | In scope | Audited |
| D5 — TTS Engine + Voice | In scope | Audited |
| D6 — Playback / Audio session / CarPlay | In scope (no CarPlay) | Audited |
| D7 — Persistence & Library | In scope | Audited |
| D8 — User Highlights & Notes | Tier 2 | TBD (cross-linked from D4.6 + D7.6) |
| D9 — In-document Search | Tier 2 | TBD (cross-linked from D3.8.5 + D4.7 + D7.8) |
| D10 — Skip detection (heuristic + ML) | Heuristic only; ML INTENTIONAL_SKIP | Folded into D2.4 |
| D11 — Sync / Cloud accounts | INTENTIONAL_SKIP (offline-first) | Skipped |
| D12 — Auth & Account | INTENTIONAL_SKIP | Skipped |
| D13 — Onboarding & Paywall | INTENTIONAL_SKIP | Skipped |
| D14 — AI features (summarize/quiz/podcast) | INTENTIONAL_SKIP | Skipped |
| D15 — SDK platform (DI, factories, plugins) | In scope | Audited |

---

## D1 — Content Pipeline

**Speechify role:** Ingest files from various sources → unified content representation. Entry point `SSDKContentImporter`, output `SSDKContentBundle` (sealed).

**Key recon files:** `recon/ssdk-full-pipeline.md` §D1, `recon/speechify-import-classes.txt`, `recon/speechify-strings.txt:138315-138969`.

### D1.1 — Importer state machine

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D1.1.1 | `SSDKContentImporter` | strings:138957 | `Core/Services/ImportService.swift` | DONE | — | Same role: state-machine-driven import orchestrator |
| D1.1.2 | `SSDKContentImporterState` (sealed) | strings:138960 | `ImportState` enum in `ImportService.swift` | PARTIAL | Medium | We have 5 cases (`idle/starting/importing/finished/failed/duplicate`); SSDK has 6 (`NotImported/Starting/Importing/Imported/ImportedToLibrary/Failed`). Missing: distinct "ImportedToLibrary" stage after Imported (we collapse them) |
| D1.1.3 | `SSDKContentImporterStateInfo` | strings:138959 | (none) | MISSING | Low | SSDK wraps state with metadata struct; we use enum associated values inline. Functionally equivalent for now |
| D1.1.4 | `SSDKContentImporterStateFailed` (with error payload) | strings:138961 | `.failed(message: String)` case | PARTIAL | Medium | We carry only message string; SSDK carries typed `Error`. Can recover later if we move to typed `ImportError` in state |
| D1.1.5 | Per-state class hierarchy (Kotlin sealed → ObjC binding) | strings:138962-138969 | Swift enum (more idiomatic) | DONE | — | Idiomatic Swift; no parity issue |

### D1.2 — Content providers (input sources)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D1.2.1 | `SSDKFileContentProvider` (local file) | ssdk-full-pipeline §D1 | `ImportService.importFile(at:)` + `DocumentFormat.detect(from:)` | DONE | — | Function-based, not protocol-based; equivalent capability |
| D1.2.2 | `SSDKScanContentProvider` (camera scan) | ssdk-full-pipeline §D1 | (none) | INTENTIONAL_SKIP | N/A | Camera/scan multi-page OCR is anti-goal per `plan.md` "Anti-Goals (Defer Past Tier 2)" |
| D1.2.3 | `SSDKSpeechifyBookContentProvider` (book store) | ssdk-full-pipeline §D1 | (none) | INTENTIONAL_SKIP | N/A | Speechify-internal book catalog; not applicable |
| D1.2.4 | URL/web content provider | speechify-import-classes:22 (`URLImportService`) | `ImportService.importWebURL` + `WebPageExtractor` | DONE | — | Web URL import flow exists |
| D1.2.5 | `WebFileImportService` (multi-format web import dispatcher) | speechify-import-classes:38-49 | (none — single `WebPageExtractor`) | PARTIAL | Medium | SSDK has dedicated dispatcher routing web URL to `xmlFileImportModule` / `pdfImportModule` / `epubImportModule` based on detected MIME. We route only HTML; downloading a PDF URL won't trigger PDFExtractor |

### D1.3 — Per-format extractors / import modules

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D1.3.1 | `PDFImportModule` | speechify-import-classes:15 | `Core/Services/Extractors/PDFExtractor.swift` | DONE | — | PDFKit-based, with OCR fallback |
| D1.3.2 | `XMLFileImportModule` (uses `ConvertToPDFServiceInterface`) | speechify-import-classes:31 | `DOCXExtractor.swift` (different path: native zip+XML walk) | PARTIAL | Medium | SSDK relies on cloud-side PDF conversion for XML/DOC formats; we walk DOCX zip locally. Better for offline; risks coverage gaps on weird docx variants |
| D1.3.3 | `LocalFileImportModule` | speechify-import-classes:53 | `PlainTextExtractor.swift` + format dispatch in `ImportService` | DONE | — | Equivalent role |
| D1.3.4 | EPUB import module | (implied via `_epubImportModule` lazy storage in WebFileImportService:45) | `Core/Services/Extractors/EPUBExtractor.swift` | DONE | — | Custom zip+OPF walker, no EPUBKit dep |
| D1.3.5 | Markdown parser (`apple/swift-markdown` + `cmark-gfm`) | strings-all:50050-50100 + 30+ AST node files | `MarkdownExtractor.swift` (AST walker via `swift-markdown` SPM 0.7.3 + cmark-gfm 0.7.1) | DONE | — | **Wave 4** — same library Speechify uses. Closes ~21 gaps: ordered lists, GFM tables, frontmatter strip, intra-word `_`, escaped chars, autolinks, HTML entities, task list markers, etc. |
| D1.3.6 | RTF | (implicit; not separately enumerated in recon) | `RTFExtractor.swift` | DONE | — | NSAttributedString native |
| D1.3.7 | Plain text | (implicit) | `PlainTextExtractor.swift` | DONE | — | |
| D1.3.8 | Image import module (`_imageImportModule`) | speechify-import-classes:46 | (none — OCR handled inside PDFExtractor only) | PARTIAL | Low | Speechify has dedicated image-import → OCR path. We only do OCR inside PDF flow. Standalone JPG/PNG import → text would need a new path |
| D1.3.9 | Locale file import module (`_localeFileImportModule`) | speechify-import-classes:47 | (none) | MISSING | Low | Unclear purpose from recon alone — possibly localization-aware fallback. Defer until use case clear |

### D1.4 — Cloud conversion service

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D1.4.1 | `ConvertToPDFServiceInterface` | speechify-import-classes:33,40 | `Core/Services/Cloud/CloudConvertService.swift` | SCAFFOLDED | High | Protocol exists with `CloudConvertStub` throwing `providerNotConfigured`. Real impl needs CloudConvert API key. Used by `DOCExtractor` for legacy `.doc` |
| D1.4.2 | `convertToPdfService` global injection | speechify-strings (used in `XMLFileImportModule`, `WebFileImportService`) | (no DI yet — direct instantiation in `DOCExtractor`) | PARTIAL | Medium | Should inject via `DependencyContainer` not hard-code stub. Wave 9 candidate (bundler refactor) |

### D1.5 — Content bundle (output type)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D1.5.1 | `SSDKContentBundle` (sealed parent) | strings:138881 | `RawDocument` + `CleanDocument` structs | PARTIAL | Medium | We split intermediate (raw) vs cleaned, but no sealed hierarchy. Functionally fine for Tier 1 |
| D1.5.2 | `SSDKContentBundleBookBundle` (PDF) | strings:138882 | `RawDocument` from `PDFExtractor` | DONE | — | Same data captured (blocks + metadata + TOC) |
| D1.5.3 | `SSDKContentBundleEpubBundle` | strings:138883 | `RawDocument` from `EPUBExtractor` | DONE | — | |
| D1.5.4 | `SSDKContentBundleMarkdownBundle` | strings:138884 | `RawDocument` from `MarkdownExtractor` (AST-backed) | DONE | — | **Wave 4** — see D1.3.5 |
| D1.5.5 | `SSDKContentBundlePlainTextBundle` | strings:138885 | `RawDocument` from `PlainTextExtractor` | DONE | — | |
| D1.5.6 | `SSDKContentBundleStandardBundle` (rich web) | strings:138886 | `RawDocument` from `WebPageExtractor` | PARTIAL | Medium | Speechify's StandardBundle is the post-bundler "ready-to-listen" form (see D1.6). We don't have a bundler stage at all |
| D1.5.7 | `SSDKContentBundleStandardBundleCompanion` | strings:138887 | (none) | MISSING | Medium | "Companion" carries display markup alongside TTS text. Speechify ships TWO outputs from bundler: text-for-TTS + markup-for-rendering. We conflate both into `TextBlock.text` |
| D1.5.8 | `SSDKContentBundleWebPageBundle` (raw HTML) | strings:138888 | (none — we go straight to cleaned web) | MISSING | Low | SSDK keeps raw HTML alongside cleaned bundle. We don't preserve raw input. Could matter for re-parsing |

### D1.6 — Bundler stage (extract → bundle conversion)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D1.6.1 | `SSDKContentBundler` | strings:138889 | (no separate stage — `ImportService.runImport` chains extract+clean+save inline) | MISSING | **High** | **Architecturally significant.** Speechify isolates bundling (text-for-TTS vs display markup separation) as a discrete stage. We don't. Affects Markdown rendering and future cloud TTS chunking. Wave 9 refactor candidate |
| D1.6.2 | `SSDKContentBundlerConfig` | strings:138890 | `SkipConfig` struct | PARTIAL | Medium | `SkipConfig` covers heuristic flags but not parsing strategy / OCR fallback config |
| D1.6.3 | `SSDKContentBundlerOptions` (11 skip flags + parsing modes) | ssdk-full-pipeline §D2 + strings:138891 | `SkipConfig` (5 flags) | PARTIAL | High | Missing: `shouldSkipFootnotes`, `shouldSkipBraces`, `shouldSkipCitations`, `shouldSkipParentheses`, `shouldSkipBrackets`, `shouldSkipUrls`, `shouldSkipCaptions`, `shouldSkipTables`, `shouldSkipFormulas`. We have only headers/footers/page-numbers/hyphens/wrapped-lines |
| D1.6.4 | `SSDKBundlerFactory` + `Config` + `Plugins` | strings:138871-138875 | (no factory, direct instantiation) | MISSING | Low | Plugin-extensible bundler factory. Over-engineered for Tier 1. Defer until Wave 9 |
| D1.6.5 | `mlParsingMode` (on-device ML parsing) | ssdk-full-pipeline §D2 | (none — heuristic only) | INTENTIONAL_SKIP | N/A | Plan says "ML là Tier 3" |
| D1.6.6 | `sdkHeuristicParsingMode` | ssdk-full-pipeline §D2 | `TextCleaner` | DONE | — | Heuristic noise removal exists |
| D1.6.7 | `ocrFallbackStrategy` | ssdk-full-pipeline §D2 | `PDFExtractor.enableOCRFallback` + `OCRService.shouldRunOCR` | PARTIAL | Medium | Boolean toggle + page-text-density heuristic. SSDK has typed strategy enum (likely `.never/.fallback/.always`). Our binary toggle is sufficient for Tier 1 |

### D1.7 — Supporting types (cursors, boundaries, references)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D1.7.1 | `SSDKContentDescriptor` | strings:138623 | `DocumentFormat` enum | DONE | — | Type tag for content; enum is Swift-idiomatic |
| D1.7.2 | `SSDKContentBoundary` (START/END markers) | strings:138315-138318 | `(charOffset, charLength)` on `TextBlock`/`CleanBlock` | PARTIAL | Low | We use offsets directly. SSDK uses opaque boundary type (probably for cross-format polymorphism). Matters when adding cloud TTS chunking |
| D1.7.3 | `SSDKContentCursor` (immutable position pointer) | strings:138319 | `ReaderCursor` struct | DONE | — | `(blockIndex, charOffsetInBlock)` |
| D1.7.4 | `SSDKContentCursorComparator` | strings:138323 | (none — `Equatable` + manual compare) | MISSING | Low | Comparator class for sorting cursors. We can derive from struct compare. Wire when needed for search/highlights ordering |
| D1.7.5 | `SSDKContentCursorCompanion` (factory methods) | strings:138320 | `ReaderCursor.zero` + initializers | DONE | — | |
| D1.7.6 | `SSDKContentElementReference` (deep link to element) | strings:138327 | (none) | MISSING | Medium | Used for bookmark/note targeting and deep linking. Currently we only store block index — can't deep-link to mid-block element. Affects D8 (User Highlights) when implemented |
| D1.7.7 | `SSDKContentAccess` (free/preview/paid permission) | strings:139100 | (none) | INTENTIONAL_SKIP | N/A | Speechify monetization layer; we're free/offline-first |

### D1.8 — Import record schema (audit trail)

| # | Speechify field | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D1.8.1 | `speechifyUri` | ssdk-full-pipeline §D1 method sig | `Document.id: UUID` | DONE | — | Different format, same role |
| D1.8.2 | `primaryFileBlobStorageKey` | ssdk-full-pipeline §D1 | `Document.fileRelativePath` | DONE | — | **Wave 10** — relative path under `Documents/Imports/` resolved at runtime. Sandbox restore safe |
| D1.8.3 | `scannedPages` | ssdk-full-pipeline §D1 | (none) | INTENTIONAL_SKIP | N/A | See D1.2.2 |
| D1.8.4 | `sourceURL` | ssdk-full-pipeline §D1 | `Document.sourceURLString` | DONE | — | |
| D1.8.5 | `importOptions` | ssdk-full-pipeline §D1 | (none persisted) | MISSING | Low | Skip-config used at import time isn't persisted with the document. If user changes config later, can't replay |
| D1.8.6 | `htmlContentLoadOptions` | ssdk-full-pipeline §D1 | (none) | MISSING | Low | Web-specific options |
| D1.8.7 | `importType` | ssdk-full-pipeline §D1 | `Document.format` | DONE | — | |
| D1.8.8 | `attemptsPerformedCount` | ssdk-full-pipeline §D1 | (none) | MISSING | Low | Retry telemetry. Not user-visible |
| D1.8.9 | `owner` | ssdk-full-pipeline §D1 | (none — single user) | INTENTIONAL_SKIP | N/A | No multi-user support |
| D1.8.10 | `lastUpdatedAt` | ssdk-full-pipeline §D1 | `Document.lastReadDate` (semantic mismatch) | PARTIAL | Low | We track lastRead, not lastUpdated of the import record itself |
| D1.8.11 | `listeningProgress` | ssdk-full-pipeline §D1 | `Document.lastReadCharOffset` + new `lastReadBlockOrderIndex`/`lastReadCharOffsetInBlock` | DONE | — | Wave 2 fix |
| D1.8.12 | `mimeType` | ssdk-full-pipeline §D1 | (derived from `Document.format`) | PARTIAL | Low | Not persisted as MIME string; derived |
| D1.8.13 | `importStatus` | ssdk-full-pipeline §D1 | (none persisted — only in-memory `ImportState`) | MISSING | Low | We don't write status to Document; on success, we just persist the doc. No "Failed" record kept. Acceptable for Tier 1 |

### D1 summary

| Severity | Count | Top items |
|---|---|---|
| **Critical** | 1 | D1.8.2 absolute file URL (App Store blocker, sandbox restore breaks) |
| **High** | 4 | D1.3.5 markdown regex, D1.5.4 markdown bundle, D1.6.1 bundler stage missing, D1.6.3 skip config gaps |
| **Medium** | 8 | importer state collapse, web file import dispatcher, content companion, cloud DI, ocr strategy enum, element reference, content boundary type, raw web bundle |
| **Low** | 6 | state info wrapper, locale module, image module, attempts count, html load options, mime persistence, import status persistence, content cursor comparator |
| **Intentional skip** | 6 | scan provider, speechify book, content access, scanned pages field, owner field, ML parsing |

**D1 wave assignment proposal:**
- **Wave 4** (already proposed) — D1.3.5 + D1.5.4 (Markdown via swift-markdown SPM)
- **Wave 9** (architectural refactor) — D1.5.7 + D1.6.1 + D1.6.3 (Bundler stage with text-for-TTS vs display-markup split, expanded skip flags)
- **Wave 10** (P0 tech debt) — D1.8.2 (relative file URL)
- Backlog — D1.2.5 (web dispatcher routing), D1.4.2 (DI for convert service), D1.7.6 (element reference for D8 prerequisite)

---

## D2 — Reading Bundle

**Speechify role:** Convert an imported `SSDKContentBundle` into a ready-to-listen runtime object (`SSDKReadingBundle` subtype) via a discrete Bundler factory step. The bundle carries all block data, TOC, metadata, and playback config needed to drive both the Reader UI and the audio controller — separated from the content pipeline by a factory seam.

**Key recon files:** `recon/ssdk-full-pipeline.md` §D2 (lines 136–191), `recon/speechify-strings.txt:138871–138956`, `recon/speechify-strings.txt:136398–136969`, `recon/ssdk-full-pipeline.md` Pipeline B (lines 729–814).

### D2.1 — ReadingBundle type hierarchy

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D2.1.1 | `SSDKReadingBundle` (root interface) | strings:138923 | (no explicit type — `Document` + `[TextBlock]` loaded by `ReaderViewModel.bind`) | MISSING | Medium | We have no `ReadingBundle` concept. `ReaderViewModel` does inline assembly from `Document` + sorted `textBlocks`. Functionally sufficient for Tier 1 but structurally no factory seam |
| D2.1.2 | `SSDKBasicReadingBundle` | strings:138904 | (none) | MISSING | Low | SSDK root abstract bundle; no direct equivalent. Gap is structural, not user-visible |
| D2.1.3 | `SSDKBasicReadingBundleWithContent` | strings:138905 | (none) | MISSING | Low | Second abstract level in hierarchy. Same note as D2.1.2 |
| D2.1.4 | `SSDKReadingBundleWithContent` | strings:138924 | (none) | MISSING | Low | Third abstract level. Carries actual `[Block]`. Our `ReaderViewModel.blocks: [TextBlock]` fills the same slot but is not a bundle type |
| D2.1.5 | `SSDKClassicReadingBundle` (flowing-text reader) | strings:138933 | `Document` + `[TextBlock]` assembled by `ReaderViewModel.bind(_:modelContext:)` | PARTIAL | High | **Key gap.** SSDK's ClassicReadingBundle is the output of a discrete bundler stage that re-processes ContentBundle blocks at open time. We skip that stage; blocks are persisted directly from `TextCleaner`. No re-processing at open time. Affects skip-flag changes between imports and opens |
| D2.1.6 | `SSDKBookReadingBundle` (fixed-layout PDF) | strings:138928 | (none) | INTENTIONAL_SKIP | N/A | Anti-goal: PDF rendered as reflowed text only |
| D2.1.7 | `SSDKEpubReadingBundle` | strings:138956 | (none — EPUB collapses into same `Document`/`TextBlock` path) | INTENTIONAL_SKIP | N/A | EPUB folded into linear `TextBlock` list. Acceptable for Tier 1 |
| D2.1.8 | `SSDKEmbeddedReadingBundle*` | strings:138951–138952 | (none) | INTENTIONAL_SKIP | N/A | Snippet/preview embed bundle for share-sheet/widgets. Not a Tier-1 goal |
| D2.1.9 | `SSDKDynamicStandardViewReadingBundle` | strings:138949 | (none) | INTENTIONAL_SKIP | N/A | Adaptive web reader mode. Anti-goal |
| D2.1.10 | `SSDKListeningBundle` | strings:138895 | (none — audio payloads assembled inline in `ReaderViewModel.buildPlayQueue()`) | MISSING | Medium | SSDK pre-stages audio chunks for the audio controller. We build TTS queue on-the-fly |

### D2.2 — Bundler factory (ContentBundle → ReadingBundle conversion)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D2.2.1 | `SSDKReadingBundlerBase` | strings:138926 | (none) | MISSING | Medium | Abstract base for all bundlers. We have no bundler tier — `ImportService.runImport` chains extract→clean→persist inline |
| D2.2.2 | `SSDKReadingBundler` | strings:138925 | (none) | MISSING | Medium | Concrete bundler entry point. Closest equivalent is `ImportService.runImport`, but no typed bundle artifact |
| D2.2.3 | `SSDKClassicReadingBundler` | strings:138936 | (none — split between `TextCleaner.swift` and `ReaderViewModel.bind`) | MISSING | High | **Architecturally significant.** Runs layout analysis at open time against extracted content. We apply `TextCleaner` once at import; no re-bundle path if `SkipConfig` changes |
| D2.2.4 | `SSDKBookReadingBundler` | strings:138929 | (none) | INTENTIONAL_SKIP | N/A | Fixed-layout PDF |
| D2.2.5 | `SSDKUniversalSourcesReadingBundler` | strings:138927 | (none) | MISSING | Low | Auto-selects best bundler. We always use the same flat path — acceptable since we have one reader mode |
| D2.2.6 | `SSDKEmbeddedReadingBundler` | strings:138953 | (none) | INTENTIONAL_SKIP | N/A | Embedded snippet bundle |
| D2.2.7 | `SSDKListeningBundler` | strings:138896 | (none) | MISSING | Medium | Pre-stages audio chunks for `SSDKAudioController`. We produce `TTSPlayBlock` array inline at play time |
| D2.2.8 | `SSDKBundlerFactory` + Config + Plugins | strings:138871, 138873, 138874 | (none) | MISSING | Low | Plugin-extensible bundler factory. Overengineered for Tier 1 |
| D2.2.9 | `createBundleForLibraryItem:bundleMetadata:callback:` (async factory) | strings:137040 | `ReaderViewModel.bind` — synchronous, no callback | PARTIAL | Medium | SSDK creates bundles asynchronously off main thread. Long documents may cause frame drop on open |

### D2.3 — Master bundler config (composition)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D2.3.1 | `SSDKBundlerFactoryConfig` (master config, 5 sub-configs) | strings:138873; selector at strings:136919 | (none — no bundler, no master config) | MISSING | Medium | SSDK composes 5 typed sub-configs into one injectable object |
| D2.3.2 | `SSDKClassicReadingBundlerConfig`/`Options` | strings:138937–138938 | `SkipConfig` in `TextCleaner.swift` | PARTIAL | High | Covers 5 of 11 skip flags; see D2.4 |
| D2.3.3 | `SSDKBookReadingBundlerConfig`/`Options` | strings:138930–138931 | (none) | INTENTIONAL_SKIP | N/A | Fixed-layout PDF config |
| D2.3.4 | `SSDKEmbeddedReadingBundlerConfig`/`Options` | strings:138954–138955 | (none) | INTENTIONAL_SKIP | N/A | Embedded snippet config |
| D2.3.5 | `SSDKListeningBundlerConfig`/`Options` | strings:138898–138899 | (none) | MISSING | Medium | Audio pre-staging config. Our audio settings scattered across `ReaderViewModel.rate`, `voiceID` — not composable |
| D2.3.6 | `SSDKContentBundlerConfig` | strings:138890 | `SkipConfig` | PARTIAL | High | Same gap as D2.3.2; additionally not persisted alongside import record |
| D2.3.7 | `SSDKBookReadingContentProcessingOptions` | strings:138932 | (none) | INTENTIONAL_SKIP | N/A | PDF-specific options |

### D2.4 — Content bundler options (skip flags + parsing modes)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D2.4.1 | `shouldSkipHeaders` | strings:136949 | `SkipConfig.skipHeaders` | DONE | — | Recurring-text heuristic |
| D2.4.2 | `shouldSkipFooters` | strings:136953 | `SkipConfig.skipFooters` | DONE | — | Same heuristic |
| D2.4.3 | `shouldSkipPageNumbers` | (implicit) | `SkipConfig.skipPageNumbers` + `TextCleaner.isPageNumber` | DONE | — | Regex `(page\s*)?\d{1,4}(/\d{1,4})?` |
| D2.4.4 | `shouldSkipFootnotes` | strings:136957 | (none) | MISSING | High | High-value for academic PDFs. Wave 4 candidate |
| D2.4.5 | `shouldSkipBraces` | strings:136398 | (none) | MISSING | Medium | Skips `{...}` |
| D2.4.6 | `shouldSkipCitations` | strings:136400 | (none) | MISSING | Medium | Skips `[1]`, `(Author, 2023)`. Academic value |
| D2.4.7 | `shouldSkipParentheses` | strings:136402 | (none) | MISSING | Low | Can break readability if too aggressive |
| D2.4.8 | `shouldSkipBrackets` | strings:136404 | (none) | MISSING | Medium | Editorial annotations |
| D2.4.9 | `shouldSkipUrls` | strings:136406 | (none) | MISSING | Medium | High value — reading raw URLs is jarring |
| D2.4.10 | `shouldSkipCaptions` | strings:136959 | (none) | MISSING | Low | Lower value for audio-only |
| D2.4.11 | `shouldSkipTables` | strings:136961 | (none) | MISSING | Medium | Tables read linearly are confusing |
| D2.4.12 | `shouldSkipFormulas` | strings:136963 | (none) | MISSING | High | LaTeX/MathML symbol strings unlistenable |
| D2.4.13 | `fixHyphens` | (Pipeline B:782) | `SkipConfig.fixHyphens` + `TextCleaner.fixHyphens` | DONE | — | |
| D2.4.14 | `mergeWrappedLines` | (Pipeline B:781) | `SkipConfig.mergeWrappedLines` + `TextCleaner.mergeWrapped` | DONE | — | |
| D2.4.15 | `sdkHeuristicParsingMode` | strings:136967 | `TextCleaner` (all heuristics) | DONE | — | |
| D2.4.16 | `mlParsingMode` | strings:136965 | (none) | INTENTIONAL_SKIP | N/A | "ML là Tier 3" |
| D2.4.17 | `ocrFallbackStrategy` (typed enum) | strings:136969 | `PDFExtractor.enableOCRFallback: Bool` + `OCRService.shouldRunOCR` | PARTIAL | Medium | Boolean toggle vs typed enum. Sufficient for Tier 1 |

### D2.5 — Bundle metadata and prepared blocks

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D2.5.1 | `SSDKBundleMetadata` | strings:138908 | `DocumentMetadata` (import) + `Document` fields (runtime) | PARTIAL | Low | SSDK adds analytics fields (contentSubType, importFlow, contentHostname, etc.). We carry only title/author/lang/pageCount/coverImage |
| D2.5.2 | `SSDKEmbeddedBundleMetadata` | strings:138914 | (none) | INTENTIONAL_SKIP | N/A | |
| D2.5.3 | `SSDKContentProperties`/`SSDKEventProperties` | strings:138909–138910 | (none) | MISSING | Low | Telemetry only |
| D2.5.4 | `SSDKPreparedBlock` (text + audio + ref) | strings:138943–138946 | `PreparedBlock` struct (index + text + startCharOffset + blockIndex ref) in `Core/Services/CloudTTS/PreparedBlock.swift` | DONE | — | **Wave 5** — same shape. Audio attached via `PreparedAudio` enum |
| D2.5.5 | `SSDKPreparedAudio*` (URL/Data) | strings:138939–138942 | `PreparedAudio` enum: `.data(AudioData) / .url(URL) / .fetching(Task<AudioData, Error>)` | DONE | — | **Wave 5** — sealed hierarchy matching SSDK. `.fetching` exposes in-flight prefetch task without blocking |
| D2.5.6 | `SSDKMutableObservableContentTitle` | strings:138922 | `Document.title` (static) | MISSING | Low | SSDK exposes title as observable Flow for live updates |

### D2.6 — ReaderViewModel as bundle consumer

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D2.6.1 | `SSDKAudioController.fromBundle(...)` | Pipeline B:769–791 | `ReaderViewModel.play()` builds queue from blocks array | PARTIAL | Medium | SSDK binds audio controller to bundle. We pass raw `[TTSPlayBlock]` at play time |
| D2.6.2 | `SSDKNavigationIntent` (10 cases) | §D13, Pipeline B:768 | `jumpTo(blockIndex:)` + `tapWord(...)` | PARTIAL | Medium | We have 2 navigation primitives. Missing GoToPage, GoToSearchMatch, GoToUserHighlight |
| D2.6.3 | TOC backed by bundle | Pipeline B:755 | `Document.tableOfContents` from `tocJSON` | DONE | — | Same data; different transport |
| D2.6.4 | Cursor restoration from bundle open intent | Pipeline B | `ReaderViewModel.restoredCursor(for:)` | DONE | — | Wave 2 stable cursor |
| D2.6.5 | `LazyContentProvider` (sliding window block access) | Pipeline B:799–800 | `ReaderViewModel.blocks: [TextBlock]` eager load | PARTIAL | Low | Memory pressure for large books |

### D2 summary

| Severity | Count | Top items |
|---|---|---|
| **Critical** | 0 | — |
| **High** | 5 | D2.2.3 no ClassicReadingBundler stage; D2.3.2/D2.3.6 SkipConfig 5/11 flags; D2.4.4 footnote skip; D2.4.12 formula skip |
| **Medium** | 13 | D2.1.1 no ReadingBundle type; D2.1.10 no ListeningBundle; D2.2.1/D2.2.2 no bundler tier; D2.2.7 no audio pre-staging; D2.2.9 sync bundle creation; D2.3.1 no master config; D2.3.5 no ListeningBundlerConfig; D2.4.5/6/8/9/11 skip flags; D2.5.4/5 PreparedBlock/Audio; D2.6.1/2 audio binding + navigation |
| **Low** | 7 | abstract hierarchy levels; universal bundler; parens/captions skip; metadata analytics; mutable title; eager block load |
| **Intentional skip** | 8 | BookReadingBundle; EpubReadingBundle; EmbeddedReadingBundle; DynamicStandardView; book/embedded bundlers; ML parsing; embedded metadata |

**D2 wave assignment proposal:**
- **Wave 4** (alongside Markdown) — D2.4.4 footnote skip + D2.4.9 URL skip + D2.4.6 citation skip (3 high-value flags, pure `TextCleaner` extension)
- **Wave 9** (architectural refactor with D1.6.1) — D2.2.3 ClassicReadingBundler stage, D2.3.1 master config, D2.3.5 ListeningBundlerConfig, D2.4.11 table skip, D2.4.12 formula skip, D2.5.4/D2.5.5 PreparedBlock/Audio pre-staging, D2.6.1 audio controller bundle binding
- **Backlog** — D2.2.9 async bundle, D2.4.5/7/8 brace/paren/bracket, D2.6.5 lazy block loading, D2.5.6 observable title

## D3 — Reader UI + Block Types

**Speechify role:** Render a reading bundle as interactive, speech-synchronized scrolling text. Five distinct reader surfaces exist in SSDK (Classic, FixedLayout/PDF, Epub, Kindle/Scan, AudioBrowser). TapListenSDK targets only the Classic-equivalent surface per `plan.md`. Block and element type hierarchies drive both rendering and TTS chunking fidelity.

**Key recon files:** `recon/ssdk-full-pipeline.md§D3` (lines 194–254), `recon/speechify-class-dump-readers.txt:13–95`, `recon/speechify-strings.txt:122098–124830`, `139386–139495`, `390611–390620`.

### D3.1 — Reader surface selection

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D3.1.1 | `ClassicReaderViewModel` (UICollectionView+TextKit) | class-dump-readers:13; strings:123815, 164993 | `ReaderViewModel` + `ReaderView` + `BlockTextView` | PARTIAL | High | Functional parity in SwiftUI+UITextView, not UICollectionView+TextKit. No virtualization/sliding-window paging — see D3.3 |
| D3.1.2 | `FixedLayoutReaderViewModel` (PDF page-curl) | class-dump-readers:138; strings:123401, 124296 | (none) | INTENTIONAL_SKIP | N/A | Anti-goal per `plan.md` |
| D3.1.3 | `EpubReaderViewModel` (vertical + horizontal pagination) | strings:122024, 169619 | (none) | INTENTIONAL_SKIP | N/A | EPUB extracted and re-rendered in Classic surface |
| D3.1.4 | `KindleReader` / `ScanReader` | strings:122670, 373411 | (none) | INTENTIONAL_SKIP | N/A | DRM + camera-scan readers anti-goal |
| D3.1.5 | `AudioBrowserViewModel` (browser overlay listener) | strings:122899, 131403 | (none) | INTENTIONAL_SKIP | N/A | Tier 3+ |

### D3.2 — ClassicBlock type hierarchy

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D3.2.1 | `SSDKClassicBlock` (sealed parent) | strings:139353; pipeline§D3 | `BlockKind` enum (9 cases) | PARTIAL | High | SSDK uses sealed class hierarchy; we use flat enum. Subtypes carry rich associated data; our enum carries only `level: Int` on heading |
| D3.2.2 | `SSDKClassicBlockHeading` | strings:139354 | `BlockKind.heading(level: Int)` | DONE | — | |
| D3.2.3 | `SSDKClassicBlockParagraph` | strings:139368 | `BlockKind.paragraph` | DONE | — | |
| D3.2.4 | `SSDKClassicBlockImageLocal/Remote` | strings:139355–139357 | `BlockKind.image` | PARTIAL | Low | No local/remote distinction; no URL on image blocks |
| D3.2.5 | `SSDKClassicBlockList` + 6 subtypes | strings:139358–139367 | `BlockKind.list` (single flat case) | PARTIAL | Medium | Nested list indentation and list-style markers lost |
| D3.2.6 | `SSDKClassicBlockTable` + Row/Cell | strings:139369–139371 | (none — table text inlined) | MISSING | Medium | No `table` case; cells flattened into paragraph text |
| D3.2.7 | `SSDKClassicBlockListStyle` enum | strings:139367 | (none) | MISSING | Low | No bullet/numbered/alpha discriminator |

### D3.3 — StandardBlock type hierarchy (web + rich)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D3.3.1 | `SSDKStandardBlock` (8 subtypes) | strings:138426–138438 | `BlockKind` partial overlap | PARTIAL | Medium | Missing Formula, TextInsideImage, Caption |
| D3.3.2 | `SSDKStandardBlockCaption` | strings:138427 | (none) | MISSING | Low | Caption text folded into paragraphs |
| D3.3.3 | `SSDKStandardBlockFooter` | strings:138429 | `BlockKind.footer` | DONE | — | |
| D3.3.4 | `SSDKStandardBlockFootnote` | strings:138430 | (none) | MISSING | Low | Footnotes become paragraphs |
| D3.3.5 | `SSDKStandardBlockFormula` | strings:138431 | (none) | MISSING | Low | LaTeX as raw text |
| D3.3.6 | `SSDKStandardBlockHeader` | strings:138432 | `BlockKind.header` | DONE | — | |
| D3.3.7 | `SSDKStandardBlockHeading` | strings:138433 | `BlockKind.heading(level:)` | DONE | — | |
| D3.3.8 | `SSDKStandardBlockList` | strings:138436 | `BlockKind.list` | PARTIAL | Low | Same gap as D3.2.5 |
| D3.3.9 | `SSDKStandardBlockTable` | strings:138437 | (none) | MISSING | Medium | HTML/EPUB tables not preserved |
| D3.3.10 | `SSDKStandardBlockTextInsideImage` | strings:138438 | (none) | MISSING | Low | OCR'd text inside `<img>` not extracted |

### D3.4 — StandardElement granular inline types

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D3.4.1 | `SSDKStandardElement` (21 subtypes) | strings:138441–138515 | `markdownSource: String?` on RawBlock/CleanBlock/TextBlock + `NSAttributedString(markdown:)` rendering | PARTIAL | Low | **Wave 6.1** — bold/italic/code/strike/link runs now preserved across all 6 extractors and rendered with merged font traits. Image inline rendering still missing (D3.4.5); sub-word element seek not exposed |
| D3.4.2 | `SSDKStandardElementAnchor` (external + internal) | strings:138442–138444 | Markdown link runs preserved; `Coordinator.handleTap` opens `.link` URLs via `UIApplication.shared.open` | DONE | — | **Wave 6.1** — external links work. Internal/intra-doc anchors not yet implemented (would need TOC anchor map) |
| D3.4.3 | `SSDKStandardElementBold/Italics/Underlined` | strings:138445, 138451, 138515 | per-run UIFont symbolic traits merged with base font in `BlockTextView.mergedFont` | DONE | — | **Wave 6.1** — bold + italic + bold-italic rendered for MD/DOCX/RTF/PDF/EPUB/Web. Underline (`<u>`) dropped (no MD equivalent) |
| D3.4.4 | `SSDKStandardElementCode` | strings:138446 | inline `` ` `` runs → `traitMonoSpace` font in renderer | DONE | — | **Wave 6.1** — both block-level and inline code spans rendered with monospace |
| D3.4.5 | `SSDKStandardElementImage` (local + remote inline) | strings:138448–138450 | (none) | MISSING | Low | Inline images not rendered |
| D3.4.6 | `SSDKStandardElementTable*` | strings:138509–138512 | (none) | MISSING | Low | Same as D3.3.9 element-level |

### D3.5 — Line-level decomposition

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D3.5.1 | `SSDKHeadingLines` | strings:139029 | `NSLayoutManager.usedRect` derived | PARTIAL | Low | We compute geometry on-the-fly; sufficient for word highlight |
| D3.5.2 | `SSDKParagraphLines` | strings:139033 | (same — layout manager) | PARTIAL | Low | |
| D3.5.3 | `SSDKParagraphParts` (text-run decomposition) | strings:139034 | (none — block text is one string) | MISSING | Medium | No run metadata. Affects inline formatting (D3.4.3) and seek accuracy |
| D3.5.4 | `SSDKParagraphPartsWithImplicitWhitespace` | strings:139036 | (none) | MISSING | Low | Whitespace preservation for TTS |
| D3.5.5 | `SSDKTopLevelItem` (root container) | strings:139028 | `RawDocument`/`CleanDocument` struct | PARTIAL | Low | Functionally equivalent |
| D3.5.6 | `SSDKPageParagraph` (paragraph + page binding) | strings:139484 | `TextBlock.pageIndex: Int?` | PARTIAL | Low | Sufficient for TOC navigation |

### D3.6 — ReaderViewModel architecture

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D3.6.1 | `ClassicReaderViewModel : NSObject <UIScrollViewDelegate>` (Combine, 30+ subjects) | class-dump-readers:13–95 | `@Observable @MainActor ReaderViewModel` (Swift Observation) | PARTIAL | Medium | Idiomatic SwiftUI deviation. No interactor decomposition |
| D3.6.2 | `ClassicReader.UserAction` (event bus) | class-dump-readers:25; strings:122376 | `tapWord(blockIndex:range:)` direct method call | PARTIAL | Low | No middleware composability |
| D3.6.3 | `ClassicReader.NavigationIntent` (10-case rich type) | class-dump-readers:31; strings:139470–139495 | `jumpTo(blockIndex:)` only | PARTIAL | High | Missing GoToPage, GoToSearchMatch, GoToUserHighlight, relative intents (Sentence/Word) |
| D3.6.4 | `ClassicReader.HighlightState` (CurrentValueSubject<HighlightState>) | class-dump-readers:45, 62 | `highlightedBlockIndex` + `highlightedWordRange` | PARTIAL | High | No polygon-based cursor; multi-line bounding gap (see D4) |
| D3.6.5 | `ComponentActionBundle` (compound gesture results) | class-dump-readers:26 | (none) | MISSING | Low | |
| D3.6.6 | `TapActionHandler` | class-dump-readers:71 | `BlockTextView.Coordinator.handleTap` | PARTIAL | Medium | Inlined coordinator; not independently testable |
| D3.6.7 | `LongPressActionHandler` | class-dump-readers:72 | (none) | MISSING | Medium | Long-press selection + context menu absent. `isSelectable = false` disables system selection |
| D3.6.8 | `DragActionHandler` | class-dump-readers:73 | (none) | MISSING | Low | No drag-scrub seek |
| D3.6.9 | `SelectionToolbarPresentationManager` | class-dump-readers:80 | (none) | MISSING | Medium | Floating selection toolbar (copy/define/translate/highlight). Prerequisite for D8 |
| D3.6.10 | `ContentProvider` (3 variants: Classic/Lazy/Static) | class-dump-readers:51 | (none — VM holds `blocks: [TextBlock]` directly) | MISSING | High | **Memory gap.** Static-equivalent only. Large docs (>500 blocks) cause memory pressure. LazyContentProvider needed for production |
| D3.6.11 | `ClassicReaderNavigator` + `InDocumentNavigationBundle` | class-dump-readers:69–70 | inline `scrollTo` in view | PARTIAL | Low | No navigation isolation |
| D3.6.12 | `PreferencesInteractor` + `TextCustomizations` + `CursorStyle` | class-dump-readers:74, 55–58 | `deps.preferences.readerTextSize` only | PARTIAL | Medium | Missing font picker, line-height, column width, cursor style |
| D3.6.13 | `SelectionInteractor`/`CopyTextInteractor`/`UserHighlightInteractor` | class-dump-readers:75–78 | (none) | MISSING | Medium | Prerequisite for D8 |
| D3.6.14 | `TableLayoutInteractor` | class-dump-readers:75 | (none) | MISSING | Low | Depends on D3.2.6 |

### D3.7 — Rendered view architecture

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D3.7.1 | `ClassicReaderRenderer` (UICollectionView+compositional layout+TextKit 1) | class-dump-readers:42, 60 | `LazyVStack` + `BlockTextView` | PARTIAL | High | **Architectural deviation.** No cell recycling; `LazyVStack` keeps each block's UITextView once visible |
| D3.7.2 | `visibleComponentSubject` (viewport tracking) | class-dump-readers:38, 61 | `onChange(of: cursor.blockIndex)` + scrollTo | PARTIAL | Low | No explicit visible-viewport tracking |
| D3.7.3 | `ClassicReaderConfiguration` (typed config struct) | class-dump-readers:83 | scattered `deps.preferences` reads | PARTIAL | Low | No single config object |
| D3.7.4 | Word-tap → TTS seek | class-dump-readers:71 | `Coordinator.handleTap` → `WordBoundaryFinder` → `tapWord` | DONE | — | Wave 1 fix |
| D3.7.5 | Sentence + word dual-layer highlight | class-dump-readers:99 | `TappableTextView.sentenceLayer` + `wordLayer` | DONE | — | Wave 3 |
| D3.7.6 | Auto-scroll on cursor advance | strings:137667 (`enableAutoscroll`) | `scrollTo(..., anchor: .center)` | DONE | — | |

### D3.8 — Auxiliary panels

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D3.8.1 | TOC panel | strings:139471–139472 | `TableOfContentsView.swift` | DONE | — | |
| D3.8.2 | Bookmarks sheet | strings:139471 | `BookmarksSheet.swift` + `Bookmark` model | DONE | — | |
| D3.8.3 | Voice / TTS settings | (implicit) | `VoicePickerView.swift` | DONE | — | |
| D3.8.4 | MiniPlayer | (Speechify full transport: skip-back, scrubber, AirPlay, sleep timer) | `MiniPlayer.swift` (state-aware loading + progress bar + transport + speed) | PARTIAL | Medium | **Wave 6.A** — added: state-aware status header (loading spinner / "Block N of M %" progress / error / finished), animated transitions, play button disabled while loading. Still missing: skip-back 30s, chapter prev/next, AirPlay target, sleep timer |
| D3.8.5 | In-document search UI | strings:139477; class-dump-readers:53 | (none) | MISSING | Medium | Tier 2 |
| D3.8.6 | User highlight overlay | strings:139479; class-dump-readers:78 | (none) | MISSING | Medium | Tier 2 |
| D3.8.7 | Quick-back action | class-dump-readers:81 | (none) | MISSING | Low | Return-to-prior-position button |
| D3.8.8 | Full-screen toggle | class-dump-readers:23 | (none) | MISSING | Low | |
| D3.8.9 | Skip-to-main-content | class-dump-readers:24; strings:139475 | (none) | MISSING | Low | |
| D3.8.10 | ReaderEditText (OCR text correction) | class-dump-readers:97–135 | (none) | INTENTIONAL_SKIP | N/A | We never render page images |
| D3.8.11 | Translation overlay | class-dump-readers:18 | (none) | INTENTIONAL_SKIP | N/A | Anti-goal |

### D3 summary

| Severity | Count | Top items |
|---|---|---|
| **Critical** | 0 | — |
| **High** | 6 | D3.4.1 inline element tree absent; D3.6.3 navigation intent richness; D3.6.4 HighlightState scalar (no polygon); D3.6.10 no LazyContentProvider; D3.7.1 no cell virtualization; D3.2.1 ClassicBlock sealed vs flat |
| **Medium** | 10 | list/table block granularity; inline anchor/bold/italic; VM monolith; long-press; SelectionToolbar; preferences interactor; MiniPlayer gaps |
| **Low** | 13 | image local/remote, list-style, caption/footnote/formula/OCR-image blocks; inline code/image/table; paragraph parts; whitespace; ComponentActionBundle; DragActionHandler; navigator isolation; visible-component; reader config; quick-back; full-screen; skip-main |
| **Intentional skip** | 6 | FixedLayout, EpubNative, Kindle/Scan, AudioBrowser, ReaderEditText, Translation |

**D3 wave assignment proposal:**
- **Wave 3 (current):** D3.7.5 + D3.7.4 + D3.7.6 — DONE
- **Wave 5:** D3.2.6 + D3.3.9 (table block + basic table rendering), D3.8.4 (skip-back + scrubber)
- **Wave 6:** D3.4.1–D3.4.3 (inline element tree + bold/italic), D3.6.7 + D3.6.9 (long-press + SelectionToolbar — D8 prereq)
- **Wave 7:** D3.6.10 (LazyContentProvider — required before production with real corpus)
- **Wave 8:** D3.5.3 (paragraph parts / run-level model — architectural)
- **Tier 2:** D3.6.3 rich NavigationIntent, D3.8.5 search, D3.8.6 highlights/notes UI

## D4 — Highlight & Polygon

**Speechify role:** Three-tier polygon rendering (paragraph / sentence / word) driven by per-reader `HighlightInteractor` subclasses, wired to Combine publishers on `ClassicReaderViewModel` / `FixedLayoutReaderViewModel`. Color, blend-mode, and toggle state passed as typed parameters on the EPUB config. User-created highlights live in a parallel `UserHighlightInteractor` system.

**Key recon files:** `recon/speechify-class-dump-readers.txt:37,45,59,62,78,99`, `recon/speechify-class-dump-render.txt:206-215`, `recon/speechify-strings.txt:123089,123173,123286-123288,123428,123566-123567,123868,124202,124290-124291,124799,131430,131692`, `137667`, `138710-138716`, `162751`, `385036,385357-385359,389040,391637,391712,393577-393578,393605,437958`.

### D4.1 — Polygon tier model

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D4.1.1 | `_wordPolygon` (`@Published CGPolygon?`) | strings:385357, 393578, 439387 | `wordLayer: CAShapeLayer` in `TappableTextView` | PARTIAL | Medium | Word polygon as `CAShapeLayer` rect-union path, not `CGPolygon` struct. Loses sub-glyph detail |
| D4.1.2 | `_sentencePolygon` (`@Published CGPolygon?`) | strings:385359, 391637 | `sentenceLayer: CAShapeLayer` | PARTIAL | Medium | Same approach as D4.1.1 |
| D4.1.3 | `_paragraphPolygons` (`@Published [CGPolygon]`) | strings:391712, 437958 | (none) | MISSING | Low | Used in OCR-edit view. Not needed for TTS playback in Tier 1 |
| D4.1.4 | `CGPolygon` struct | strings:124201,130098,135187 | (none — `[CGRect]` array) | MISSING | Low | Adequate for straight text; breaks on rotated PDF pages |
| D4.1.5 | `_sections` (`@Published [CGPolygon]`) | strings:391712 | (none) | MISSING | Low | Section/chapter visual separators |

### D4.2 — Word-polygon geometry refinements

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D4.2.1 | `wordPolygonVerticalEdges` | strings:389040, 393605 | (none) | MISSING | Medium | Edge-handling for word polygons (rounded vs flat caps at line-break boundaries). We apply static `cornerRadius: 4` → unnecessary rounding at line-join edges |
| D4.2.2 | `roundedPath(rects:cornerRadius:)` | `HighlightShapeBuilder.swift` | same | PARTIAL | Low | Uniform radius vs SSDK's per-edge table |
| D4.2.3 | wordRange/sentenceRange threading | strings:385357-385359 | `highlightedWordRange` + `highlightedBlockIndex` | DONE | — | Equivalent data flow |

### D4.3 — Blend mode & compositing

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D4.3.1 | `highlightMixBlendMode` | strings:137667 | (none — alpha compositing only) | MISSING | Medium | Visible defect on dark-mode PDFs. Fix: `CAShapeLayer.compositingFilter = "multiplyBlendMode"` |
| D4.3.2 | `sentenceHighlightHexColor` | strings:137667 | hardcoded `systemBlue.alpha(0.10)` | PARTIAL | Medium | No theme-aware token |
| D4.3.3 | `wordHighlightHexColor` | strings:137667 | hardcoded `systemYellow.alpha(0.55)` | PARTIAL | Medium | Harsh in dark theme |
| D4.3.4 | `hoveredSentenceHighlightHexColor` | strings:137667 | (none) | INTENTIONAL_SKIP | N/A | iPad pointer hover; phone-primary target |

### D4.4 — Reader-mode HighlightInteractor variants

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D4.4.1 | `HighlightInteractor` (base) | strings:123089 | (no protocol — inline `applyHighlights`) | MISSING | Low | No interactor abstraction |
| D4.4.2 | `EpubReaderVerticalHighlightInteractor` | strings:123173 | (none) | INTENTIONAL_SKIP | N/A | EPUB out of scope |
| D4.4.3 | `EpubReaderHorizontalHighlightInteractor` | strings:124202 | (none) | INTENTIONAL_SKIP | N/A | |
| D4.4.4 | `KindleHighlightInteractor` | strings:124799 | (none) | INTENTIONAL_SKIP | N/A | |
| D4.4.5 | `AudioBrowserHighlightInteractor` | strings:131430 | (none) | INTENTIONAL_SKIP | N/A | |
| D4.4.6 | Classic reader highlight path | class-dump-readers:37,45 | `applyHighlights` + `HighlightShapeBuilder` | PARTIAL | Medium | Refactor when Wave 7 PDF reader starts |

### D4.5 — Highlight state & Combine streaming

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D4.5.1 | `currentHighlightStatePublisher` | class-dump-readers:45; strings:384995 | (none — direct `@Observable` mutation) | MISSING | High | **Wave 1 Bug D root cause.** SSDK isolates highlight in dedicated publisher. We collapse with cursor → mid-seek `onWord` can clobber. `lastEventSequence` partial mitigation only |
| D4.5.2 | `currentHighlightStateSubject: CurrentValueSubject` | class-dump-readers:62 | (none) | MISSING | High | Backing store for D4.5.1 |
| D4.5.3 | `HighlightPosition` (PassthroughSubject value type) | strings:123868; class-dump-readers:37 | (none) | MISSING | Medium | Drives auto-scroll position |
| D4.5.4 | `highlightPositionSubject` | class-dump-readers:37,161 | (none) | MISSING | Medium | Separate subject from state |
| D4.5.5 | `isSentenceHighlightEnabled` toggle | strings:385533, 386018; class-dump-readers:59 | (none — always on) | MISSING | Medium | User toggle missing |
| D4.5.6 | `isSentenceHighlightEnabledSubject` | class-dump-readers:59,183 | (none) | MISSING | Medium | Reactive backing |

### D4.6 — UserHighlight system (D8 territory)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D4.6.1 | `UserHighlightInteractor` | strings:123286 | (none) | MISSING | Low | D8 prereq |
| D4.6.2 | `UserHighlightInteractorIntegration` | strings:123287 | (none) | MISSING | Low | D8 prereq |
| D4.6.3 | `UserHighlightInteractorError` | strings:123288 | (none) | MISSING | Low | |
| D4.6.4 | `MutableUserHighlight` + `SDKBackedMutableUserHighlight` | strings:123566-123567 | (none) | MISSING | Low | |
| D4.6.5 | `UserHighlightStyle` (5-color palette) | strings:138710-138716 | (none) | MISSING | Low | |
| D4.6.6 | `UserHighlightStyleState` | strings:124290 | (none) | MISSING | Low | |
| D4.6.7 | `_userHighlightPolygons` | strings:391638 | (none) | MISSING | Low | |
| D4.6.8 | `userHighlightColorTokens` (EPUB only) | strings:137667 | (none) | MISSING | Low | EPUB context |

### D4.7 — Selection & search highlight tracks

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D4.7.1 | `selectionBGHexColor` | strings:137667 | `isSelectable = false` | INTENTIONAL_SKIP | N/A | Selection disabled |
| D4.7.2 | `searchHighlightConfig` | strings:137667 | (none) | MISSING | High | Two-track architecture — blocks D9 |
| D4.7.3 | `_focusedSearchMatchPolygon` + `_searchMatchPolygons` | strings:391638 | (none) | MISSING | Low | D9 prereq |
| D4.7.4 | `_regionsOfInterestPolygons` | strings | (none) | MISSING | Low | PDF polish |

### D4.8 — SpeechTextRenderer & cursor style

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D4.8.1 | `SpeechTextRenderer` | strings:131692 | (folded into `ReaderViewModel.wireTTS`) | MISSING | Low | |
| D4.8.2 | `cursorStyleSubject: CurrentValueSubject<CursorStyle>` | strings:385374; class-dump-readers:58 | (none — fixed style) | MISSING | Medium | Underline/fill/karaoke style picker missing |
| D4.8.3 | `_isSentenceHighlightEnabled` (component iVar) | strings:385376; class-dump-readers:59 | (none) | MISSING | Medium | Component shadow of D4.5.5 |

### D4 summary

| Severity | Count | Top items |
|---|---|---|
| **Critical** | 0 | — |
| **High** | 3 | D4.5.1/D4.5.2 currentHighlightStatePublisher (Wave 1 Bug D); D4.7.2 searchHighlightConfig (blocks D9) |
| **Medium** | 9 | wordPolygonVerticalEdges; highlightMixBlendMode; hardcoded colors; no interactor abstraction; HighlightPosition; isSentenceHighlightEnabled; CursorStyle |
| **Low** | 11 | paragraph polygon tier; CGPolygon struct; sections; uniform corner radius; UserHighlight system (D8); search polygon iVars; regions-of-interest; SpeechTextRenderer |
| **Intentional skip** | 5 | hover; Epub vertical/horizontal; Kindle; AudioBrowser; selection BG |

**D4 wave assignment proposal:**
- **Wave 1 Bug D follow-up** — D4.5.1/D4.5.2: dedicated `CurrentValueSubject<HighlightState>` decoupled from cursor
- **Wave 3 polish** — D4.3.2/D4.3.3 theme-aware tokens; D4.3.1 blend mode
- **Wave 5 UI** — D4.5.5/D4.5.6 sentence toggle; D4.8.2 CursorStyle picker
- **Wave 6 geometry** — D4.2.1 line-boundary flat caps
- **Wave 7 PDF** — D4.4.1/D4.4.6 HighlightInteractor protocol
- **Wave 9 / D9** — D4.7.2/D4.7.3 two-track search renderer
- **D8** — D4.6.1–D4.6.7 UserHighlight system

## D5 — TTS Engine + Voice

**Speechify role:** Drive text-to-speech for any reading bundle — on-device via `SSDKLocalSpeechSynthesisAdapter` or cloud via `SSDKLocalStreamingMediaPlayer` — with word-level alignment (`SSDKSpeechMarks`), typed voice catalog (`SSDKVoiceSpec` sealed hierarchy), and an assembly layer (`SSDKListeningBundler` / `SSDKListeningExperience`) that wires TTS+playback+alignment.

**Key recon files:** `recon/ssdk-full-pipeline.md` §D5 – §D7, `recon/speechify-strings.txt:138095–138277,138896–138903,139039–139082,139468–139469`, `recon/speechify-recon-and-comparison.md` §Pipeline C.

### D5.1 — TTS abstraction protocol

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D5.1.1 | `SSDKLocalSpeechSynthesisAdapter` | strings:138095 | `TTSService` protocol | DONE | — | Single protocol covers local + cloud |
| D5.1.2 | `SSDKLocalSpeechSynthesisPlayer` | strings:138108 | `AVSpeechTTSService.swift` | DONE | — | AVSpeechSynthesizer + delegate; Wave 1 Bug D race fixed |
| D5.1.3 | `SSDKLocalStreamingMediaPlayer` (cloud streaming, 3 read modes) | strings:138132-138133 | `AudioStreamingMode` enum + `CloudTTSService` mode parameter | PARTIAL | High | **Wave 5+** — enum scaffolded with 3 cases matching SSDK read-modes (`.fullFetch / .chunked / .range`). Only `.fullFetch` implemented. `.chunked` blocked on backend (probe confirmed `api.taplisten.com` returns JSON with full Content-Length, no `Transfer-Encoding: chunked`). `.range` defers to Wave 7+ (audiobook content). `precondition(mode.isImplemented)` early-fails on unsupported modes |
| D5.1.4 | `SSDKOnDeviceMediaSynthesisAdapter` (neural TTS) | strings:138153-138154 | (none) | INTENTIONAL_SKIP | N/A | Tier 3 |
| D5.1.5 | `SSDKSynthesisLocation` (`.device/.server/.auto`) | strings:138277 | (none — hardcoded per impl class) | MISSING | Medium | No auto-routing to cloud when backend configured |
| D5.1.6 | `SSDKLocalSpeechSynthesisEvent` (5 cases) | strings:138097–138107 | 5 closure callbacks | PARTIAL | Low | Closures vs sealed event type |
| D5.1.7 | `SSDKOnDeviceSynthesisSupport` | strings:138217–138223 | (none) | MISSING | Low | No device capability check |

### D5.2 — Speech marks / alignment data model

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D5.2.1 | `SSDKSpeechMarks` + `Impl` | strings:138258, 138264 | `SpeechMarksAdapter.swift` (static mapping) | PARTIAL | High | Stateless adapter vs persistent object. Alignment re-derived each chunk |
| D5.2.2 | `SSDKSpeechMarksChunk` | strings:138261 | `[TTSAlignmentItem]` keyed per `TTSChunk` | PARTIAL | Medium | Plain array vs typed chunk; discarded after play |
| D5.2.3 | `SSDKSentenceAndWordLocation` (sentenceIdx + wordIdx + range) | strings:138354 | `TTSWordEvent(sequence, blockIndex, nsRangeInBlock, word)` | PARTIAL | High | **No sentence-level granularity.** Critical for sentence highlight (D4) and sentence-level skip |
| D5.2.4 | `SSDKWordAndSentenceOverlay` + `OverlayHelper` (tick-driven) | strings:139040, 139042 | `ReaderViewModel.wireTTS()` inline closures + `BlockTextView` | PARTIAL | Medium | No dedicated overlay type; tightly coupled |
| D5.2.5 | `SSDKHighlightedWordPosition` (`.InFocus/.Left/.Right`) | strings:139039, 139627–139631 | (none) | MISSING | Medium | No viewport-relative position type for auto-scroll |
| D5.2.6 | `SSDKHighlightMixBlendMode` config | recon-comparison §6 | (none — solid fill) | MISSING | Low | See D4.3.1 |
| D5.2.7 | Sentence + paragraph alignment marks | (`SSDKSentenceAndWordLocation`) | Word-level only | PARTIAL | High | Same as D5.2.3 |
| D5.2.8 | 50ms tick rate | Pipeline C | 50ms `Timer` in `CloudTTSService` | DONE | — | Matches |

### D5.3 — Voice catalog and voice spec

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D5.3.1 | `SSDKVoiceSpec` (sealed: 5 types — Local/Available, Local/SynthesisBacked, Static, VMS/FromAudioServer, VMS/Persisted) | strings:138292–138311 | `VoiceInfo` (local) + `CloudVoice` (cloud) — flat structs | PARTIAL | High | Type information erased; `play()` accepts `voiceID: String?` only |
| D5.3.2 | `SSDKVoiceGender` enum | strings:138286 | `CloudVoice.gender: String?` | PARTIAL | Low | Untyped string |
| D5.3.3 | `SSDKVoiceMetadata` (rich) | strings:138287–138291 | `VoiceInfo.quality: String` | PARTIAL | Medium | Missing accent, style, sample-rate, engine ID |
| D5.3.4 | `SSDKVoiceRef` (opaque handle) | strings:138118 | `String` | PARTIAL | Low | Plain string IDs |
| D5.3.5 | `SSDKVoicePreferences` (defaultPremium/Free/Offline) | strings:138903 | `PreferencesService.ttsVoiceID` + `ttsRate` | PARTIAL | High | Single voice ID; no premium/offline split, no per-language |
| D5.3.6 | `SSDKVoicePreferenceWithStaticValue` (fallback chain) | strings:138900–138901 | (none) | MISSING | Medium | If voice unavailable, silently falls back without notification |
| D5.3.7 | `SSDKLocalSpeechSynthesisVoicesProvider` | strings:138096 | `AVSpeechTTSService.availableVoices(languageCode:)` | DONE | — | |
| D5.3.8 | Voice preview URL / sample playback | strings:415710 | `CloudVoice.previewURL` (stored, not surfaced) | PARTIAL | Medium | `VoicePickerView` does not expose play-preview button |
| D5.3.9 | `availableVoices` cloud catalog | strings:388079 | `BackendAPIClient.listVoices()` (stub throws) | SCAFFOLDED | High | Real VMS integration absent. Hardcoded "Backend not configured" status |
| D5.3.10 | `SSDKLocalSynthesisOptions` (pitch, rate, delays) | strings:138112 | `AVSpeechTTSService.mappedRate` + hardcoded pitch/delay | PARTIAL | Medium | Pitch and delays not configurable |

### D5.4 — Voice rate calibration

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D5.4.1 | Per-voice WPM calibration | Pipeline C: `speedInWordsPerMinute`; strings:418709, 418244, 387951 | `mappedRate` linear | PARTIAL | Medium | Perceived speed varies by voice |
| D5.4.2 | `currentSpeedFactor` + `currentVoice` observable | strings:387951-387952 | `PreferencesService.ttsRate` + `ttsVoiceID` | DONE | — | Same role; SSDK additionally stores WPM |

### D5.5 — Chunk planning and audio chunking

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D5.5.1 | Text chunk boundary algorithm | Pipeline C; strings:136981–136990 | `ChunkPlanner` (1500-char ceiling) | PARTIAL | Medium | Fixed ceiling; no sentence-boundary awareness, no network-adaptive |
| D5.5.2 | `SSDKAudioStreamingStrategy` | strings:137918–137926 | `AudioStreamingStrategy` enum (`.always / .auto(lookahead:) / .never`) — wired into `ListeningBundler` | DONE | — | **Wave 5** — enum drives prefetch state machine. Default `.auto(lookahead: 2)` keeps queue saturated during playback |
| D5.5.3 | Audio cache (per-voice/rate/text-hash) | Pipeline §4.4 | `AudioCache.swift` SHA-256-keyed disk cache | DONE | — | Solid implementation. SSDK additionally has in-memory layer |
| D5.5.4 | Gapless / pre-buffered next-chunk | Pipeline C | `ListeningBundle.audio(at:)` + `GaplessAudioPlayer.preloadNext(data:)` | DONE | — | **Wave 5** — pipeline: bundle.audio(at:) awaits prefetch (lookahead=2 ahead) → player.preloadNext schedules next file in `AVAudioPlayerNode` queue → `handleSegmentFinished` promotes nextFile when current ends → 0ms transition. Wave 6 client-side prewarm cuts cold-start ~500-900ms |

### D5.6 — ListeningBundler / ListeningExperience assembly

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D5.6.1 | `SSDKListeningBundler` (assembles AudioConfig + voices + prefs + options) | strings:138896 | `ListeningBundler` class + `ListeningBundle` runtime in `Core/Services/CloudTTS/ListeningBundler.swift` | DONE | — | **Wave 5** — bundler.bundle(blocks:voice:) returns ListeningBundle owning [Int: Task<AudioData>]. Strategy-driven prefetch state machine. CloudTTSService delegates fully — no longer inline in ReaderViewModel |
| D5.6.2 | `SSDKListeningBundlerConfig`/`Options` | strings:138898–138899 | `AudioStreamingStrategy` + `AudioStreamingMode` parameters on `ListeningBundler.init` and `CloudTTSService.init` | PARTIAL | Low | **Wave 5** — strategy + mode are explicit init params (typed, default-friendly). Not wrapped in single config struct; each consumer picks defaults. Acceptable for Tier 1 |
| D5.6.3 | `SSDKListeningExperience` (transferable session) | strings:139468–139469 | (none — TTS lives in ReaderViewModel) | MISSING | Medium | No transferable session object |
| D5.6.4 | `SSDKListeningBundle` (output of bundler) | strings:138893 | (none) | MISSING | Low | |
| D5.6.5 | `listeningBundlerConfig` as sub-config | strings:136917-136919 | (none) | MISSING | Low | Composition layer absent |

### D5.7 — Playback control events and state

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D5.7.1 | `SSDKAudioControllerEvent` (10 cases) | strings:138191–138211 | `PlaybackState` (6 cases) | PARTIAL | Medium | Missing Seeking, ChangedVoice, ChangedVolume, Buffering detail, Destroyed |
| D5.7.2 | `pause/resume/seekTo(cursor:)` symmetry | strings:136288-136366 | pause + resume present; **no seekTo** — replaced by stop+replay | PARTIAL | High | Word tap restarts TTS. Long-form gap |
| D5.7.3 | `SSDKWordsListenedHelper` (analytics) | strings:139058–139064 | (none) | INTENTIONAL_SKIP | N/A | Analytics anti-goal |
| D5.7.4 | `SSDKPlaybackControls` + `SSDKPlayPauseButton` (6 sub-states) | strings:139076–139078 | `PlaybackState` → `MiniPlayer` | DONE | — | We collapse to fewer states |
| D5.7.5 | `SSDKPlaybackTime` (NotReady/Ready) | strings:139079–139082 | `.loading` for both fetch and decode | MISSING | Low | No timing-readiness signal |
| D5.7.6 | `SSDKNavigableSpeechProvider` | strings:138357 | (none) | MISSING | Medium | No navigation while preserving stream |

### D5.8 — Multi-provider routing and plugin

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D5.8.1 | `SSDKVoiceSpecVMSVoiceSpec` (cloud provider abstraction) | strings:138305, 138310–138312 | `BackendAPIClient` protocol + Stub + HTTP impls | PARTIAL | Medium | Right shape but only one invented contract; no voice-selection routing |
| D5.8.2 | `voiceFactory` injection | strings:136923, 443879 | (none) | MISSING | Medium | No factory pattern |
| D5.8.3 | `SSDKDocumentVoiceInfo` (per-doc voice override) | strings:138893 | (none — global) | MISSING | Medium | One global voice for all docs |
| D5.8.4 | Cloud synthesize endpoint contract | Pipeline C | `HTTPBackendAPIClient.synthesize` (invented contract) | PARTIAL | High | **Fabricated contract** — incompatible with ElevenLabs/Google/Azure/Speechify. No URL-based audio streaming |

### D5 summary

| Severity | Count | Top items |
|---|---|---|
| **Critical** | 0 | — |
| **High** | 6 | D5.1.3 chunked streaming (backend-gated); D5.2.3/D5.2.7 no sentence alignment; D5.3.1 voice spec erasure; D5.3.5 flat preferences; D5.3.9 cloud catalog scaffolded; D5.7.2 no seekTo; D5.8.4 invented contract — Wave 5 closed: D5.5.2, D5.5.4, D5.6.1 |
| **Medium** | 13 | synthesis-location auto-routing; overlay helper; viewport position type; voice metadata; preference fallback chain; preview button; rate WPM calibration; chunk ceiling; ListeningBundlerConfig; transferable experience; missing events; NavigableSpeechProvider; multi-provider routing; voice factory; per-doc voice |
| **Low** | 5 | closures vs sealed event; device capability check; blend mode; typed VoiceRef; PlaybackTime.NotReady |
| **Intentional skip** | 2 | on-device neural TTS; words-listened analytics |

**D5 wave assignment proposal:**
- **Wave 5 (TTS quality)** — DONE: D5.5.2 (AudioStreamingStrategy), D5.5.4 (gapless prefetch via ListeningBundler), D5.6.1 (ListeningBundler), D2.5.4 (PreparedBlock), D2.5.5 (PreparedAudio); REMAINING: D5.7.2 (in-chunk seekTo for cloud)
- **Wave 6 (voice catalog)** — D5.3.8 (preview playback), D5.3.5 (structured prefs with fallback), D5.3.9 (real backend), D5.8.4 (real contract)
- **Wave 7 (alignment)** — D5.2.3 + D5.2.7 (sentence-level), D5.2.5 (viewport position type)
- **Wave 9 (architecture)** — D5.6.1 + D5.6.2 + D5.6.3 (extract ListeningBundler), D5.1.5 (auto-routing)
- **Backlog** — D5.8.3 (per-doc voice), D5.4.1 (WPM calibration), D5.3.1 (typed VoiceSpec)

## D6 — Playback / Audio session / CarPlay

**Speechify role:** Master audio orchestration — audio session lifecycle, OS-level integrations (lock screen, Control Center, headphone remote), interruption handling, Now Playing metadata, platform extensions (CarPlay). Entry: `SSDKAudioController`; supporting: `audioSessionManager`, `nowPlayingPublisher`, `nowPlayingCenter`, `routeChangeObserver`, `interruptionStream`, `sleepTimerService`.

**Key recon files:** `recon/speechify-strings.txt:384751,412750,415078,440518,450501-505,457353-358,412372-373,418202-204,441477,385250`, `recon/ssdk-full-pipeline.md §D4`, `recon/strings-all.txt:63677,67175`.

### D6.1 — Audio session category & activation

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D6.1.1 | `audioSessionManager` (dedicated class) | strings:384751,440519 | `AudioSessionManager.swift` | DONE | — | `.playback + .spokenAudio + .duckOthers`; activated/deactivated correctly |
| D6.1.2 | Category/mode/options | strings:133122-133126 | matches | DONE | — | |
| D6.1.3 | `UIBackgroundModes: audio` Info.plist key | (App Store requirement) | `Info.plist` at project root with `UIBackgroundModes [audio]` | DONE | — | **Wave 10** — verified via `plutil -p` on built bundle |
| D6.1.4 | Session deactivation with `.notifyOthersOnDeactivation` | (inferred) | correct | DONE | — | |
| D6.1.5 | `audioSessionSetupFailed` error event | strings:412708,412930 | `print(...)` only | PARTIAL | Medium | Errors swallowed; should call `onError` |

### D6.2 — Audio engine (gapless vs gapped)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D6.2.1 | `AVAudioEngine` + `AVAudioPlayerNode` (on-device gapless) | strings:241690-691; `scheduleBuffer` at 453654 | `GaplessAudioPlayer` in `Core/Services/CloudTTS/GaplessAudioPlayer.swift` | DONE | — | **Wave 5** — full chain: engine.attach(playerNode) → timePitch → mainMixerNode. `scheduleFile(file, at: nil, completionCallbackType: .dataPlayedBack)` for both current + preloaded next. callbackGeneration / preloadGeneration counters guard stale completions. AVAudioUnitTimePitch.rate for variable-speed (avoids AVAudioPlayer enableRate MP3 hardware-decoder glitches). Wave 6 added `prepareEngine()` for app-launch pre-warm. Still used only by CloudTTSService — local AVSpeechTTSService keeps direct AVSpeechSynthesizer |
| D6.2.2 | `SSDKLocalStreamingMediaPlayer` (cloud chunked HLS/MP3) | Pipeline C | `CloudTTSService` + `ListeningBundler` lookahead prefetch | PARTIAL | Medium | **Wave 5** — chunk boundary gap eliminated via prefetch lookahead=2. True HTTP chunked streaming (`AudioStreamingMode.chunked`) blocked on backend; see D5.1.3 |
| D6.2.3 | `SSDKAudioStreamingStrategy` (Always/Auto/Never) | §D4 | `AudioStreamingStrategy` enum on `ListeningBundler.init` | DONE | — | **Wave 5** — see D5.5.2. Same enum drives both cloud TTS prefetch (D5.5.2) and audio session prefetch behavior (D6.2.3) |

### D6.3 — Interruption handling

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D6.3.1 | `interruptionStream` / `interruptionContinuation` | strings:415160-162,448045-049 | `PlaybackCoordinator.handleInterruption` in `Core/Services/PlaybackCoordinator.swift` | DONE | — | **Wave 10** — observes `AVAudioSession.interruptionNotification`, pauses on `.began`, auto-resumes on `.ended` only when `shouldResume` flag set |
| D6.3.2 | `interruptionResumeEnabled` (honour `shouldResume` hint) | strings:418282,448047 | `PlaybackCoordinator.handleInterruption` checks `AVAudioSession.InterruptionOptions.shouldResume` | DONE | — | **Wave 10** |
| D6.3.3 | `audioSessionInterrupted:` legacy ObjC selector | strings:440518 | (none) | MISSING | Low | Pre-iOS-15 path |

### D6.4 — Route change / headphone disconnect

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D6.4.1 | `routeChangeObserver` (`AVAudioSession.routeChangeNotification`) | strings:412750-852,415161-163 | `PlaybackCoordinator.handleRouteChange` | DONE | — | **Wave 10** — pauses on `oldDeviceUnavailable` (headphone unplug, AirPods removal) |
| D6.4.2 | `NAVAudioSessionRouteChangeReason` typed enum | strings:132092 | (none) | MISSING | Medium | Minor — raw constants work |
| D6.4.3 | AirPlay / Bluetooth routing | (implicit via category) | implicit (untested without background mode plist) | PARTIAL | Low | Document in integration guide |

### D6.5 — Now Playing Info (lock screen / Control Center)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D6.5.1 | `nowPlayingCenter` / `MPNowPlayingInfoCenter.default()` | strings:387873,450501-505 | `PlaybackCoordinator.updateNowPlayingInfo(...)` | DONE | — | **Wave 10** — `import MediaPlayer`, populates `MPNowPlayingInfoCenter.default().nowPlayingInfo` |
| D6.5.2 | `MPMediaItemPropertyTitle` | strings:450503 | wired from `document.title` | DONE | — | **Wave 10** |
| D6.5.3 | `MPMediaItemPropertyArtist` | (inferred) | wired from `document.author` | DONE | — | **Wave 10** |
| D6.5.4 | `MPMediaItemPropertyArtwork` | (inferred) | `Document.coverImageData` → `UIImage` → `MPMediaItemArtwork(boundsSize:requestHandler:)` | DONE | — | **Wave 10** |
| D6.5.5 | `MPNowPlayingInfoPropertyElapsedPlaybackTime` | strings:450503 | computed from `absoluteCharOffsetForCursor() / charsPerSecond` | DONE | — | **Wave 10** — char-offset estimate; coarse but functional |
| D6.5.6 | `MPNowPlayingInfoPropertyPlaybackRate` | strings:456027 | set to `viewModel.rate` when playing, `0.0` when paused | DONE | — | **Wave 10** |
| D6.5.7 | Periodic update of nowPlaying | (inferred) | `ReaderViewModel.refreshNowPlaying()` called on every word event + state change | DONE | — | **Wave 10** — sub-second granularity (every word) |

### D6.6 — Remote Command Center (headphone / lock / AirPods)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D6.6.1 | `playCommand` / `pauseCommand` / `togglePlayPauseCommand` | strings:451507,451314,418202-204 | `PlaybackCoordinator.registerRemoteCommands` | DONE | — | **Wave 10** — all 3 wired via `MPRemoteCommandCenter.shared()` to `delegate?.coordinatorDidRequestPlay/Pause/Toggle` |
| D6.6.2 | `skipForwardCommand` (preferred 30s) | strings:412372-373,457353-357 | wired with `preferredIntervals = [30]` | DONE | — | **Wave 10** — converts to char-offset delta via `200 WPM × rate` |
| D6.6.3 | `skipBackwardCommand` (preferred 15s) | strings:457354 | wired with `preferredIntervals = [15]` | DONE | — | **Wave 10** |
| D6.6.4 | `nextTrackCommand` / `previousTrackCommand` | strings:450386,451915 | both `.isEnabled = false` | DONE | — | **Wave 10** — explicitly disabled rather than silently ignoring |
| D6.6.5 | `changePlaybackPositionCommand` (lock-screen scrubber seek) | strings:441477 | wired to `seekToTime(_:)` | DONE | — | **Wave 10** — char-offset estimate maps position to block + offset |
| D6.6.6 | `playCommandTask` (async back-pressure) | strings:420892,430976 | (none) | MISSING | Low | Implementation detail |

### D6.7 — Sleep timer

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D6.7.1 | `sleepTimerService` / `Interactor` / `Configurator` | strings:385786,457364,163213 | (none) | MISSING | Low | Wave 7+ |
| D6.7.2 | `sleepTimerStateObserver` / `sleepTimerRemainingTime` | strings:388047,438825 | (none) | MISSING | Low | Depends on D6.7.1 |

### D6.8 — Mini-player UI parity

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D6.8.1 | Play / Pause / Stop | (inferred) | `MiniPlayer.swift` | DONE | — | |
| D6.8.2 | Speed slider (rate control) | `ChangedSpeedFactor` event | continuous 0.3–1.0 slider | PARTIAL | Low | Speechify uses discrete WPM steps (1x/1.5x/2x/3x/4.5x); we cap at 1.0x |
| D6.8.3 | Voice picker button | `ChangedVoice` event | voice button → `VoicePickerView` | DONE | — | |
| D6.8.4 | Buffering indicator | `Buffering` event | `hourglass.circle.fill` during `.loading` | DONE | — | |
| D6.8.5 | Progress bar / scrubber | (inferred from full mini-player) | (none) | MISSING | Medium | Add `ProgressView(value:total:)` tied to `progressFraction` |
| D6.8.6 | Chapter / block auto-advance | (sequential streaming) | `speakNext()` / `playNextChunk()` | DONE | — | |
| D6.8.7 | Seek-by-tap-on-word | NavigationIntent | `tapWord` → playFromCursor | DONE | — | Wave 1 |

### D6.9 — CarPlay

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D6.9.1 | `CPListItem`, `CPListTemplate`, `CPNowPlayingTemplate` | strings-all:63677,67175 | (none) | INTENTIONAL_SKIP | N/A | Anti-goal. Document for host apps |
| D6.9.2 | `CPApplicationDelegate` / `CPInterfaceController` | (inferred) | (none) | INTENTIONAL_SKIP | N/A | |

### D6 summary (after Wave 5/6/10)

| Severity | Count | Top items |
|---|---|---|
| **Critical** | **0** | All 4 closed in Wave 10 P0 |
| **High** | **2** | `shouldResume` flag (still missing); `changePlaybackPositionCommand` (still missing) — Wave 5 closed: D6.2.1 gapless engine, D6.2.2 cloud prefetch |
| **Medium** | 4 | session error swallowed; route-change reason enum; artwork; mini-player skip-back/scrubber polish |
| **Low** | 6 | session option; legacy ObjC interruption; AirPlay/BT untested; playback rate; nextTrack/previousTrack disabled; sleep timer |
| **Intentional skip** | 2 | CarPlay templates |

**D6 wave assignment proposal:**
- **Wave 10 P0 — DONE** ✓ — D6.1.3 (UIBackgroundModes), D6.3.1+D6.3.2 (interruption), D6.4.1 (route-change), D6.6.1+D6.6.2+D6.6.3 (RemoteCommandCenter), D6.5.1-D6.5.7 (Now Playing wiring)
- **Wave 5 — DONE** ✓ — D6.2.1 (AVAudioEngine + AVAudioPlayerNode via GaplessAudioPlayer), D6.2.2 (cloud prefetch via ListeningBundler), D6.2.3 (AudioStreamingStrategy)
- **Wave 6 — DONE** ✓ — first-byte client-side optimization (voice catalog disk cache + engine pre-warm + warmUp())
- **Wave 6.A — DONE** ✓ — MiniPlayer state-aware loading + progress UI; ReaderViewModel state transition fix
- **Backlog** — D6.6.5 changePlaybackPositionCommand (lock-screen scrubber seek), sleep timer, AirPlay surface, mini-player skip-back 30s

## D7 — Persistence & Library

**Speechify role:** Local persistence via KMP-shared SQLite (GRDB adapter, `SSDKDatabaseManager`), cloud sync via Firebase Firestore (10 syncers per entity), library browsing (`SSDKLibraryItem` sealed), full-text search, archive management.

**Key recon files:** `recon/speechify-strings.txt:122808,123247,135373,136725-136728,138638-138705,139048,139114-139247,139312-139545`, `recon/ssdk-full-pipeline.md §D10, §D11, Pipeline A/G`.

### D7.1 — Storage technology

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D7.1.1 | `SSDKDatabase` + `SSDKDatabaseManager` (KMP SQLite via GRDB) | strings:139114,139201; class-dump:123247 | SwiftData `modelContainer(for:)` | PARTIAL | Low | iOS 17+; same guarantees for our single-platform target |
| D7.1.2 | `SSDKDatabaseQuery` DSL (9 Where variants) | strings:139203-139228 | `FetchDescriptor` + `#Predicate` | PARTIAL | Low | Missing compound-key cursor queries |
| D7.1.3 | `SSDKDatabaseTransaction` + `WriteIntent` | strings:139247,139231-139234 | `modelContext.save()` | DONE | — | |
| D7.1.4 | `SSDKDatabaseTableIndexDefinitionFullTextSearch` | strings:139244-139246 | (none) | MISSING | Medium | No FTS; library search must load all docs into memory |
| D7.1.5 | Schema migration (`DatabaseMigrator`) | strings:135562,137337 | (none — SwiftData v1, no `VersionedSchema`) | MISSING | High | Tech-debt H5. Any field rename triggers destructive migration |

### D7.2 — Library item model

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D7.2.1 | `SSDKLibraryItem` (sealed: Content/Folder) | strings:138672-138674 | flat `[Document]` | PARTIAL | Medium | No folder concept |
| D7.2.2 | `SSDKLibraryItemFolder` | strings:138674 | (none) | MISSING | Medium | Tier 2 |
| D7.2.3 | `SSDKFolderQuery` + `SSDKFolderReference` | strings:138659-138668 | (none) | MISSING | Low | Folder DSL |
| D7.2.4 | `SSDKContentType` | strings:138646-138647 | `DocumentFormat` | DONE | — | |
| D7.2.5 | `SSDKListeningProgress` | strings:139048-139049 | `lastReadCharOffset` + `lastReadBlockOrderIndex` + `lastReadCharOffsetInBlock` | DONE | — | Wave 2 stable cursor |

### D7.3 — Filter and sort

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D7.3.1 | `SSDKFilterAndSortOptions` | strings:138649 | (none — fixed sort) | MISSING | Medium | Hardcoded `lastReadDate DESC, importDate DESC` |
| D7.3.2 | `SSDKFilterTypeANY/RECORDS/FOLDERS/ARCHIVED` | strings:138652-138658 | (none) | MISSING | Medium | No filter UI |
| D7.3.3 | 7 sort variants (`RECENTLY_LISTENED`, `DATE_ADDED`, `ALPHABETICAL`, `LISTENING_PROGRESS`, `DATE_ARCHIVED`) | strings:138693-138703 | 2 sort keys hardcoded | PARTIAL | Low | No sort picker |
| D7.3.4 | `lastListenedAt` vs `lastUpdatedAt` distinction | strings:136725 | `lastReadDate` (collapsed) | PARTIAL | Low | |

### D7.4 — Archived state

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D7.4.1 | `isArchived` | strings:136727 | (none — delete only) | MISSING | Medium | Hard-delete on swipe; no recovery |
| D7.4.2 | Bulk archive ops | strings:136727-136728,136686-136687 | (none) | MISSING | Low | Depends on D7.4.1 |
| D7.4.3 | `SortByDATE_ARCHIVED` | strings:138699 | (none) | MISSING | Low | |

### D7.5 — Bookmarks

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D7.5.1 | `SSDKBookmark` | strings:138638 | `Bookmark` @Model | DONE | — | id/createdAt/blockOrderIndex/charOffset/note?/snippet |
| D7.5.2 | `SSDKBookmarkHelper` (CRUD service) | strings:139439 | inline `modelContext.delete` in `BookmarksSheet` | PARTIAL | Low | Refactor into `BookmarkService` in Wave 8 |
| D7.5.3 | `SSDKBookmarksList` + `Item` | strings:139440-139441 | `document.bookmarks` sorted in view | DONE | — | |
| D7.5.4 | `BookmarkActionState` (HasInViewport/NoneInViewport) | strings:139435-139438 | (none — always Add) | MISSING | Low | Users may create duplicates at same block |

### D7.6 — User highlights and notes (D8 territory)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D7.6.1 | `SSDKUserHighlight` + `Coordinates` + `Style` | strings:138708-138716,139622-139623 | (none) | MISSING | High | Range-based annotations; D8 scope. Cross-link D4.6 |
| D7.6.2 | `SSDKUserHighlightsHelper` + observer | strings:139542-139545 | (none) | MISSING | High | No CRUD service |
| D7.6.3 | Note text on highlight | (D9 inferred) | `Bookmark.note: String?` | PARTIAL | Medium | Notes only on point bookmarks, not ranges |

### D7.7 — Import status persistence

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D7.7.1 | `SSDKImportStatus` (Finished/Uploading) | strings:138669-138671 | (in-memory `ImportState` only) | MISSING | Low | Acceptable for offline-first. Cross-link D1.8.13 |

### D7.8 — Library search

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D7.8.1 | `SSDKSearchHelper` + `Query` + `State` | strings:139500-139507 | (none) | MISSING | Medium | Tier 2. Cross-link D7.1.4 + D9 |

### D7.9 — Cover image strategy

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D7.9.1 | `SSDKBookThumbnailsHelper` (CDN URLs) | strings:139640-139643 | `Document.coverImageData: Data?` (JPEG embedded in SwiftData) | PARTIAL | Low | Works offline; bloats store. Move to file-backed in Wave 8 |

### D7.10 — Per-document settings

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D7.10.1 | `SSDKVoicePreference` (per-doc) | strings:138900-138903 | global `PreferencesService.ttsVoiceID` | MISSING | Low | All docs share one voice |
| D7.10.2 | Per-doc speed/text-size | (inferred) | global only | MISSING | Low | Same gap |

### D7.11 — File hash dedup

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D7.11.1 | (no explicit symbol found) | — | `Document.fileHash` SHA-256 + `#Predicate` check | DONE | — | Positive differentiator |

### D7.12 — iCloud / CloudKit

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D7.12.1 | `SSDKSyncProvider` (10 syncers) + Firestore | strings:139313-139331; pipeline G | (none — local SwiftData only) | INTENTIONAL_SKIP | N/A | Multi-device sync explicit anti-goal |

### D7.13 — Backup and file-URL integrity

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D7.13.1 | Server-canonical blob key (sandbox-agnostic) | Pipeline A | `Document.fileRelativePath` resolved at runtime | DONE | — | **Wave 10** — see D1.8.2 |
| D7.13.2 | `OpenConnectionIsBlockingDatabaseMigration` event | strings:139198 | (none) | MISSING | High | Downstream of D7.1.5 |

### D7 summary

| Severity | Count | Top items |
|---|---|---|
| **Critical** | 1 | D7.13.1 absolute file URL (Bug B5, cross-links D1.8.2) |
| **High** | 3 | D7.1.5 no migration plan (H5); D7.6.1/D7.6.2 user highlights absent (D8); D7.13.2 no migration-blocking UI |
| **Medium** | 5 | folder hierarchy; filter/sort UI; archive (soft-delete); range notes; library search bar |
| **Low** | 8 | compound-key cursor; sort variants; lastListenedAt split; bulk archive; BookmarkHelper refactor; viewport bookmark state; import status persistence; cover image bloat; per-doc voice/speed |
| **Intentional skip** | 1 | CloudKit/multi-device sync |
| **Done / positive** | 5 | transactions; content type; listening progress cursor; bookmark model; SHA-256 dedup |

**D7 wave assignment proposal:**
- **Wave 8** (annotations + library polish) — D7.4.1 archive, D7.5.2 BookmarkService, D7.9.1 cover → file-backed
- **Wave 8 prereq** — D7.1.5 `VersionedSchema` + `SchemaMigrationPlan`
- **Wave 9** (search + filter UI) — D7.1.4 FTS, D7.8.1 search bar, D7.3.1/D7.3.2 filter+sort picker
- **P0 tech debt (Bug B5)** — D7.13.1 relative file URL (paired with D1.8.2)
- **Tier 2** — D7.2.2 folders, D7.6.1 user highlights (D8), D7.10.1 per-doc voice

## D15 — SDK Platform (DI, factories, plugins)

**Speechify role:** Platform scaffolding wiring SSDK services together — FactoryKit DI, config-driven bundler factory, plugin extension point, multi-service root client (`SSDKSpeechifyClient`), environment/telemetry/analytics infrastructure. Entry: `SSDKSpeechifyClientFactory` → `SSDKSpeechifyClient`.

**Key recon files:** `recon/speechify-strings.txt:127345-127360,137903-137948,138871-138875`, `recon/ssdk-full-pipeline.md §1.1`, `recon/ssdk-entry-points.txt`.

### D15.1 — Dependency injection architecture

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D15.1.1 | `FactoryKit` (`Container`, `SharedContainer`, scopes) | strings:127345-127352, 424522-424528 | `DependencyContainer` singleton (`lazy`) | PARTIAL | Low | No scope enforcement, no override-for-testing |
| D15.1.2 | `LazyInjected` / `WeakLazyInjected` property wrappers | strings:127359-127360, class-dump-render:201 | (none — direct construction) | MISSING | Low | Blocks constructor-injection unit testing |
| D15.1.3 | Multi-level DI hierarchy (App/MainShell/Service/Listening/SDK) | strings:123443,124244,124305,124362,123506; `DI/SDK/SDKDIContainer.swift` at 169596 | Single flat singleton | MISSING | Low | Adding per-screen lifetimes later requires rewrite |
| D15.1.4 | Constructor injection vs service-locator mixing | (FactoryKit `Resolving`) | Mix: `DependencyContainer.shared` + `@Environment(\.deps)` | PARTIAL | Low | One injection mechanism + one service-locator pull |
| D15.1.5 | DI override seam for tests | FactoryKit `register` mechanism | (none — `private init()` singleton) | MISSING | Medium | Tests cannot substitute mocks. Wave 9 candidate |

### D15.2 — Root client and environment

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D15.2.1 | `SSDKSpeechifyClient` (root service object) | strings:137945, §1.1 | `DependencyContainer` (1 service) | PARTIAL | Low | SSDK owns 7+ named services; we own 1. Will grow |
| D15.2.2 | `SSDKSpeechifyClientFactory` | strings:137948 | (none — initialized unconditionally in App) | MISSING | Low | No construction phase |
| D15.2.3 | `SSDKAppEnvironment` (dev/staging/prod) + config | strings:137909, 137948; selector at 136081 | (none — single env) | MISSING | Low | Add when staging/prod split needed |
| D15.2.4 | `SSDKSpeechifyClientServices` (typed accessor bag) | strings:137946 | flat properties on container | MISSING | Low | Equivalent at current scale |
| D15.2.5 | `SSDKAdaptersProvider` (Firebase shim layer) | strings:137903-137904 | (none — no Firebase) | INTENTIONAL_SKIP | N/A | Not applicable |

### D15.3 — Factory and plugin system

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D15.3.1 | `SSDKBundlerFactory` | strings:138871 | (none — inline in `ImportService`) | MISSING | Low | See D1.6.1 |
| D15.3.2 | `SSDKBundlerFactoryConfig` (composable) | strings:138873 | `SkipConfig` (5 flags only) | PARTIAL | Low | Documented in D1.6.3 |
| D15.3.3 | `SSDKBundlerPlugins` + Companion | strings:138874-138875 | (none) | MISSING | Low | Plugin layer for third-party hooks. Single-tenant — not needed for Tier 1 |

### D15.4 — Service decomposition

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D15.4.1 | `SSDKDatabase*` (KMP cross-platform mini-ORM) | strings:139114-139225 | SwiftData (iOS-native) | PARTIAL | Low | No cross-platform portability — intentional |
| D15.4.2 | `SSDKLoggingService` (structured logger) | strings:138732 | `print(...)` (1 call) | MISSING | Low | Wave 9: replace with `os.Logger` |
| D15.4.3 | `SSDKTelemetryOptions` / `DiagnosticReporter` | strings:138743 | (none) | INTENTIONAL_SKIP | N/A | OSLog-only per plan |
| D15.4.4 | 20+ `*Analytics` protocols | strings:123102, 122698, 127959 | (none) | INTENTIONAL_SKIP | N/A | Anti-goal |
| D15.4.5 | Firebase Crashlytics | strings:122885 | (none) | MISSING | Low | Revisit before broad rollout |
| D15.4.6 | `SSDKFirebaseAuthService` | strings:138011-138016 | (none) | INTENTIONAL_SKIP | N/A | D12 anti-goal |
| D15.4.7 | `SSDKFirebaseFirestoreService` + Storage | strings:138018-138030 | (none) | INTENTIONAL_SKIP | N/A | D11 anti-goal |

### D15.5 — Feature flags / A-B

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D15.5.1 | `SSDKNextGenConfig*` (16 classes — Firebase Remote Config) | §4.3 | (none) | INTENTIONAL_SKIP | N/A | No A/B requirement |

### D15.6 — Cloud provider registry (our addition)

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D15.6.1 | (Speechify uses `URLImportService` + backend processing) | — | `CloudProviderRegistry.shared` (Dropbox + GDrive registered) | DONE | — | Idiomatic; OneDrive etc. slot in without touching call sites |

### D15.7 — App wiring: URL scheme + App Groups

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D15.7.1 | URL scheme registered in Info.plist | (Speechify `speechify://`) | `Info.plist` `CFBundleURLTypes` registered for `taplisten` scheme | DONE | — | **Wave 10** — switched project to manual `INFOPLIST_FILE`; verified via `plutil -p` |
| D15.7.2 | Deep link parser | — | `DeepLinkHandler.parse(_:)` | DONE | — | Logic complete once D15.7.1 registered |
| D15.7.3 | App Group entitlement | (Speechify uses Firebase dynamic links) | `TapListenSDK.entitlements` at project root with `com.apple.security.application-groups = group.com.tap.listen.sdk.shared` | DONE | — | **Wave 10** — entitlement wired via `CODE_SIGN_ENTITLEMENTS` build setting. Note: Apple Developer Portal registration needed for paid-team device builds |
| D15.7.4 | Environment propagation in App scene | — | `@Environment(coordinator)` + `@Environment(\.deps)` + `modelContainer` | DONE | — | Clean SwiftUI scene setup |

### D15.8 — Module boundaries

| # | Speechify symbol | Recon evidence | Our equivalent | Status | Severity | Notes |
|---|---|---|---|---|---|---|
| D15.8.1 | Multi-module (SSDK / SpeechifyMobile / SpeechifyCommon / FactoryKit / SubscriptionsService) | strings:169596,169751; class-dump-render:201 | Single module `TapListenSDK` | MISSING | Low | No API boundary enforcement. Add SPM local packages when team grows |

### D15 summary

| Severity | Count | Top items |
|---|---|---|
| **Critical** | 2 | D15.7.1 URL scheme not in Info.plist (deep link dead); D15.7.3 App Group entitlement missing (SharedURLStore broken) |
| **High** | 0 | — |
| **Medium** | 1 | D15.1.5 no DI override seam (testability) |
| **Low** | 8 | FactoryKit vs singleton; LazyInjected; container hierarchy; mixed DI; root client/factory/env config gaps; bundler factory + plugins; structured logger; crash reporting; single-module |
| **Intentional skip** | 7 | adapter provider; telemetry; analytics; Crashlytics; Firebase Auth; Firestore/Storage; NextGenConfig |

**D15 wave assignment proposal:**
- **Immediate / pre-ship (P0)** — D15.7.1 (register `taplisten://`), D15.7.3 (create `.entitlements` with `com.apple.security.application-groups`)
- **Wave 9** (architectural refactor) — D15.1.5 constructor injection seam, D15.4.2 `os.Logger`, D15.3.1 BundlerFactory if D1.6.1 lands
- **Backlog** — FactoryKit / scoped DI (only worthwhile if service count > 10), local SPM packages (team-growth concern)

---

## Cross-domain aggregate

Aggregated severity across all 8 audited domains (D1 + D2 + D3 + D4 + D5 + D6 + D7 + D15).

**Updated after Wave 5/6/6.A (2026-04-29).**

| Severity | Open | Done so far | % done |
|---|---|---|---|
| **Critical** | **0** | 8 | **100%** |
| **High** | ~17 | ~14 | ~45% |
| **Medium** | ~52 | ~13 | ~20% |
| **Low** | ~72 | ~8 | ~10% |
| **Intentional skip** | (37 rows, n/a) | — | — |

**Wave 5/6/6.A closures (~9 High rows + ~4 Medium):**
- D2.5.4 PreparedBlock — Wave 5
- D2.5.5 PreparedAudio — Wave 5
- D5.5.2 AudioStreamingStrategy — Wave 5
- D5.5.4 Gapless prefetch — Wave 5
- D5.6.1 ListeningBundler — Wave 5
- D6.2.1 AVAudioEngine + AVAudioPlayerNode — Wave 5
- D6.2.2 Cloud chunk prefetch — Wave 5 (lookahead-based)
- D6.2.3 AudioStreamingStrategy — Wave 5
- D5.6.2 ListeningBundlerConfig — Wave 5 (partial — strategy + mode params)
- D5.1.3 SSDKLocalStreamingMediaPlayer — Wave 5+ (AudioStreamingMode enum scaffold; .fullFetch DONE; .chunked backend-gated)
- D3.8.4 MiniPlayer status header + progress bar — Wave 6.A
- Wave 6 client-side prewarm — voice catalog disk cache + engine pre-warm + warmUp() — first-play ~500-900 ms saved (no specific Speechify symbol; client-only optimization)

### Critical-path Wave 10 — ALL CLOSED ✓

| Row | Item | Status |
|---|---|---|
| D6.1.3 | `UIBackgroundModes: audio` | ✓ DONE |
| D6.3.1 | `AVAudioSession.interruptionNotification` handler | ✓ DONE |
| D6.3.2 | `shouldResume` flag handling | ✓ DONE |
| D6.4.1 | `routeChangeNotification` handler | ✓ DONE |
| D6.5.1-7 | `MPNowPlayingInfoCenter` (title, author, artwork, elapsed, rate, periodic) | ✓ DONE (all 7 sub-items) |
| D6.6.1 | `playCommand` / `pauseCommand` / `togglePlayPauseCommand` | ✓ DONE |
| D6.6.2-3 | `skipForwardCommand` / `skipBackwardCommand` | ✓ DONE |
| D6.6.4 | `nextTrackCommand` / `previousTrackCommand` (disabled cleanly) | ✓ DONE |
| D6.6.5 | `changePlaybackPositionCommand` | ✓ DONE |
| D15.7.1 | `taplisten://` URL scheme registration | ✓ DONE |
| D15.7.3 | App Group entitlement | ✓ DONE |
| D1.8.2 / D7.13.1 | Relative file URL in `Document` | ✓ DONE |

### Highest-priority remaining work

| Wave | Open Critical/High items |
|---|---|
| **Wave 4** | D1.3.5 + D1.5.4 + D2.4.4/6/9 (markdown SPM swap + skip flags) |
| **Wave 5** | D5.5.4 (gapless prefetch) + D5.7.2 (true seek) + D4.5.5 (sentence toggle) + D3.2.6 (table block) |
| **Wave 6** | D5.3.5 + D5.3.9 + D5.8.4 (real backend wiring) + D3.4.1-3 (inline element tree) |
| **Wave 7** | D5.2.3 (sentence alignment) + D3.6.10 (LazyContentProvider) |
| **Wave 8** | D7.1.5 (`SchemaMigrationPlan` — bomb hẹn giờ) + UserHighlight system |
| **Wave 9** | D1.6.1 + D2.2.3 (Bundler stage) + D5.6.1-3 (ListeningBundler) + D7.1.4 (FTS) |

### Tech-debt outside the matrix (still open)

- **B4** Share Extension target chưa tạo (high)
- **H2** Regex HTML parser (high) — Wave 2 partial fix only
- **H3** DOCX `<w:t>`-only extraction (high)
- **H4** EPUB no NCX TOC parsing (high)
- **H7** AudioCache no LRU/size cap (medium)
- 0 unit tests, 0 UI tests, no CI (high — testing infrastructure)
- Localization: hardcoded English UI strings (medium)
- Apple Developer Portal: paid team + App Group registration + provisioning profiles (high)
- Accessibility: VoiceOver labels, Dynamic Type respect (high)

### Wave proposals (consolidated across domains)

| Wave | Focus | Domain rows |
|---|---|---|
| Wave 4 | Markdown via swift-markdown SPM + footnote/citation/URL skip | D1.3.5, D1.5.4, D2.4.4, D2.4.6, D2.4.9 |
| Wave 5 ✓ | Cloud TTS strict Speechify pattern (ListeningBundler + GaplessAudioPlayer + PreparedBlock/Audio + AudioStreamingStrategy) | D2.5.4, D2.5.5, D5.5.2, D5.5.4, D5.6.1, D5.6.2, D6.2.1, D6.2.2, D6.2.3 |
| Wave 6 ✓ | First-byte latency client-side optimization (voice catalog disk cache + engine pre-warm + warmUp + AudioStreamingMode enum) | D5.1.3 (partial), Wave 6 client-side gains |
| Wave 6.A ✓ | MiniPlayer state-aware progress UI + state transition fix | D3.8.4 (improved) |
| Wave 5 remaining | Sentence highlight toggle, cursor style, tables, true in-chunk seekTo | D5.7.2, D4.5.5, D4.8.2, D3.2.6, D3.3.9, D6.6.5, D6.8.5 |
| Wave 6 | Voice catalog (preview + structured prefs + real backend) + inline element tree + long-press + audio quality | D5.3.5, D5.3.8, D5.3.9, D5.8.4, D3.4.1-3, D3.6.7, D3.6.9, D6.2.x, D4.2.1 |
| Wave 7 | Sentence alignment + LazyContentProvider + PDF reader (HighlightInteractor protocol) | D5.2.3, D5.2.7, D5.2.5, D3.6.10, D4.4.1, D4.4.6 |
| Wave 8 | Annotations + library polish + schema migration | D7.4.1, D7.5.2, D7.9.1, D7.1.5, D8 (UserHighlight system from D4.6) |
| Wave 9 | Architectural refactor (ContentBundler stage + DI seam + structured logger) + library search + filter UI | D1.6.1, D2.2.3, D2.3.1, D15.1.5, D15.4.2, D7.1.4, D7.8.1, D7.3.1, D4.7.2 (D5.6.1 already done in Wave 5) |
| Wave 10 (P0) | App Store unblock — Critical row 1-7 above | (see Critical list) |
| Backlog | Per-doc voice, WPM calibration, sleep timer, full-screen toggle, etc. | many Low rows |
| Tier 2 | D8 search match navigation, in-doc search UI, EPUB native reader (if rev'd) | D3.6.3, D3.8.5, D3.8.6 |

---

## Update protocol

When a wave lands or a gap is discovered:
1. Update the relevant row's **Status** column.
2. If status flipped to `DONE`, add the wave/commit reference in **Notes**.
3. Re-run severity counts in domain summary table.
4. Cross-link from `plans/260429-1908-bookcast-tts-reader/plan.md` Implementation Status section.

When adding a new row:
1. Always cite recon evidence (`file:line` or section).
2. Always specify our equivalent path (or explicit `(none)`).
3. Severity is judged from a Vietnamese-user-shipping-Tier-1 perspective, not Speechify-feature-completeness perspective.
