Skip to content

feat(player): opt-in caption narration synchronized with playback - #30

Merged
christophersesugh merged 11 commits into
mainfrom
feat/player-caption-narration
Nov 12, 2026
Merged

christophersesugh merged 11 commits into
mainfrom
feat/player-caption-narration

Conversation

@christophersesugh

@christophersesugh christophersesugh commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

This is a distinct follow-up to merged Throughline PR #29. Step sonification provides short tones, not caption narration. Learners should be able to opt into spoken, authored step explanations without disrupting the core or the existing sound-effect toggle.

Implementation

  • Add a separate, opt-in narration React prop, with a Narrate captions control that starts off and requires user activation.
  • Build speech text exclusively from the current compiled frame's author-supplied label, notes, guided chapter and causal explanation, and explicit transfer annotations. No invented replies or recorded audio.
  • Drive the automatic timeline from speech completion instead of the normal fixed timer while narration is enabled. Pause/resume speech alongside playback; stop old utterances on seek, scenario/trace replacement and unmount. Revert to fixed-time playback when narration is disabled.
  • Keep the existing opt-in sonification control and core compiler independent; no new dependencies, grammar changes, trace-schema changes, backend or audio files.
  • Add a user-facing preview toggle in the source-backed Vite playground. Provide error/accessibility handling for unavailable or failed speech synthesis.
  • Document behavior, device/browser limitations and browser-wide speech controller ownership.

Review follow-up (five findings addressed)

This update responds to the five inline review threads on e1ec376 (two P1 and three P2):

  • P1 / Unexpected speech interruption: deliberate cancellation detaches handlers; an unexpected canceled or interrupted event pauses playback and shows a recoverable error instead of hanging the speech clock.
  • P1 / Multiple players: a browser-engine-wide Throughline speech coordinator grants ownership to the newest narrator; it pauses/disables the displaced player. Only the current owner may cancel/pause/resume the browser-global speech queue. Embedded-player seek or unmount cannot cancel the new owner's utterance.
  • P2 / Narrow toolbar: dual-audio players use three rows at content widths up to 360px; all player configurations (no audio, narration only, step sounds only, or both) use the generic three-row fallback at content widths up to 300px. Browser regressions check actual control boundaries from 260–420px player widths.
  • P2 / Replaced guidance: spoken text is part of speech-session identity and the completion guard. Updating an author-supplied explanation on the same trace/frame replaces stale audio.
  • P2 / Same-frame seek: an internal explicit seek revision restarts narration on Restart chapter even when the frame index does not change, including after speech completes.

Each inline thread has a response with its corresponding implementation/test evidence. The additional follow-up finding (P2, generic narrow-control fallback) is addressed in 8bcf309 with four-configuration browser coverage. Threads remain unresolved pending final maintainer re-review; no approval is claimed. Updated public docs describe the ownership/takeover behavior and speech limitations.

Verification

  • Final GitHub Actions CI #43211760492 passed on branch head 8bcf309: 235 unit tests (33 files) and 82 Chromium browser tests (14 files).
  • Source boundary audit, Node 24/pnpm frozen install, TypeScript and Vite preview build, distributable/exports/license checks, packed React SSR/MDX and Node/TypeScript, Vite browser hydration/touch/mobile, independent Fumadocs/Next/Astro and Markdown-host consumer checks.
  • Narration regressions cover unexpected interruptions, global speech ownership and displaced-player cleanup, authored guidance refresh, same-frame restart; the new UI regression verifies zero, one and two offered audio controls at narrow embedded widths without clipping or horizontal overflow.
  • Independent real-audio browser testing, non-Chromium and screen-reader verification. Speech tests mock the Web Speech boundary; audible quality/hardware results are not claimed.
  • Canonical ticket TL-I17 context PR #17 awaiting maintainer review. Its GitHub Actions check failed before runner/job-step execution, as also happened on unchanged context main; no context check-refs success claimed.

Review boundary

This PR is unmerged, does not publish npm or deploy documentation, and awaits sole maintainer review. The underlying package publication, TL-I11 documentation-app integration and release acceptance gates remain independent.

@christophersesugh christophersesugh left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent technical review of PR #30 at e1ec376c9329814a96b406dd298e0b0e264484e9 against the proposed TL-I17 acceptance criteria and Throughline playback contract. Five actionable findings below (2 P1, 3 P2). The reported GitHub Actions run 43202793534 passed, but the speech tests use a mocked Web Speech API; audible hardware, non-Chromium, and screen-reader verification remain outstanding. Recommendation: resolve the P1 speech-ownership/interruption issues and control-width regression, add focused tests for the observed edge cases, then rerun CI and manually verify speech before maintainer merge. This is a technical assessment, not a maintainer approval.

Comment thread src/react/use-narration.ts Outdated
if (current.current !== session) return;
session.ended = true;
session.failed = true;
if (event.error === "canceled" || event.error === "interrupted") return;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1 — High] Unexpected speech cancellation leaves autoplay running with no clock or error. stop() clears its own utterance handlers before calling cancel(), so a canceled/interrupted event that reaches this handler is not one of this controller's deliberate seek/unmount cancellations. Here it sets session.failed = true and returns without pausing or announcing anything. If another speech consumer calls speechSynthesis.cancel() while this player is playing, externalClock remains active, the current step never advances, and the UI still says Pause indefinitely. Handle unexpected cancellation/interruption like a failed utterance (pause and surface a recoverable alert), while suppressing only the controller's explicitly owned cancellation. Add a browser test emitting interrupted on the active utterance while playback is running.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the follow-up commits (8d23188, 3f83e78). An unexpected canceled/interrupted event that reaches the active utterance handler is now treated as failure: it releases ownership, pauses speech-driven playback, and displays a recoverable alert. Deliberate Throughline cancellations detach handlers before canceling, so they are not misreported. Added a Chromium regression that interrupts an active autoplay utterance, verifies the player returns to Play without advancing, and tests retry. Full CI #43208921297 passed.

Comment thread src/react/use-narration.ts Outdated
// A browser cancellation may synchronously invoke onerror/onend.
voice.utterance.onend = null;
voice.utterance.onerror = null;
try { speechEngine()?.cancel(); } catch { /* Speech is optional. */ }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1 — High] Canceling one player cancels every other player's speech. window.speechSynthesis has a page-wide utterance queue and cancel() removes all queued/current utterances (see MDN). Each useNarration instance stores ownership only in its own ref; when two <ThroughlinePlayer narration /> instances are active, stepping, muting, or unmounting A calls this global cancel and interrupts B. B then remains in a failed speech session (see the separate cancellation-handling finding), so independently embedded players cannot reliably play. Introduce a shared speech owner/coordinator for Throughline instances, with explicit takeover/queue behavior and predictable pause/error for displaced sessions; never assume per-instance cancel() only affects that instance. Add a two-player integration test for both seeking and unmounting one player while the other narrates.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed with src/react/speech-owner.ts (8d23188) and multi-player regressions (3f83e78). A shared per-engine coordinator gives the newest Throughline narrator ownership and explicitly pauses/disables the displaced player. Only the owner can cancel/pause/resume global speech; a displaced player's seek or unmount cannot cancel the new owner's utterance. External speech scripts remain outside our coordinator, with unexpected interruption handled as an error. Two-player takeover, seek, and unmount tests passed in CI #43208921297.

}
/* A very narrow embedded player with both optional audio controls requires
a separate utility row; never clip or shrink accessible touch targets. */
@container (max-width: 300px) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 — Medium] Narrow desktop/mobile embeds can clip the new narration controls. The @container (max-width: 300px) split happens below the width needed for the two control rows. With default 16px fonts and both audio controls offered, mobile transport needs ~124px (three 40px buttons + gaps); utilities need ~182px (56px speed select + three 40px buttons + gaps), plus the 8px grid gap = ~314px. For example, a 350px-wide .thl has ~302px of content after its 24px side padding, so this query does not fire, the control rows cannot fit, and .thl { overflow: hidden } clips a control. The current browser test only checks a 320px viewport, where the three-row layout does fire. Raise the three-row breakpoint to at least the measured combined minimum (with some font/zoom margin), or make layout depend on actual available space, and test embedded content widths around 301–320px as well as 320px viewport.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 8d23188: the extra utility row is now activated at player content widths up to 360px, but only when both optional sound and narration buttons are present. Existing one-audio/no-audio toolbar geometry remains unchanged. Added embedded-width Chromium coverage across 348–420px player widths, including approximately 300–320px content widths and checks for every control staying within its container (3f83e78). Full CI #43208921297 passed.

Comment thread src/react/use-narration.ts Outdated
if (!voice || voice.trace !== trace || voice.step !== step) start();
}, [enabled, offered, trace, step, start, stop]);

// Play/Pause acts on the voice, not an independent timer. Do not

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 — Medium] Updating authored guidance leaves stale spoken text attached to the current frame. player.tsx derives text from the current guidance prop on every render, and latest.current.text updates, but this effect only compares trace and step. If a host replaces or corrects guidance while the same trace/frame is displayed, the visible caption changes and the old utterance keeps speaking the previous explanation; its onend may still tick to the next step. Track the utterance's text (or another guidance revision identity) in Session, invalidate/cancel stale speech when it changes, and resume/restart under a defined user-activation policy. Cover an in-place guidance update with a browser test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 8d23188: each speech session now records its exact narration text; a corrected guidance string invalidates/replaces the old utterance even when trace identity and frame index are unchanged. Completion also verifies the latest text before ticking, preventing a stale callback from advancing before the next effect. Added a same-frame guidance-update Chromium test (3f83e78) and documented this behavior. Full CI #43208921297 passed.

Comment thread src/react/use-narration.ts Outdated
}, [enabled, offered, trace, step, start, stop]);

// Play/Pause acts on the voice, not an independent timer. Do not
// immediately pause a voice that was just started by a manual seek.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 — Medium] Seeking/restarting the current step does not restart its narration. seekReducer accepts step actions even when to === state.step, including Restart chapter while already at that chapter's first frame. The effect compares only trace/step, so the old utterance remains (and can merely get paused by the play-state effect) instead of being canceled and reread. After a completed utterance, clicking Restart chapter on the same frame produces no new speech at all. Track an explicit seek/restart action generation separately from the frame index, or expose a narration restart path on an intentional seek, and test that repeating the current step restarts spoken content rather than preserving stale completion/position.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 8d23188: usePlayer now increments an internal seek revision on every explicit step dispatch, including a no-op frame index. Narration sessions track that revision, so Restart chapter on its current frame cancels/rereads, even after a completed utterance. Added a regression for both cases (3f83e78), passed in CI #43208921297.

@christophersesugh christophersesugh left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review of c968b2e against the five previous findings: the speech interruption, shared ownership, guidance refresh, and same-step seek fixes are implemented and have targeted Chromium regression coverage. The dual-audio narrow-layout fix is present, but its replacement of the previous generic <=300px fallback introduces one actionable responsive regression (inline). GitHub Actions run #43208921297 passed on this head; real audible output, non-Chromium, and screen-reader checks are still not independently verified. Recommendation: restore the fallback for single/no-audio players, add embedded-width tests across the offered-control combinations, and rerun CI before maintainer merge. This is not a merge approval.

only dual-audio players so regular 375px previews keep their compact row.
The query uses the player's CONTENT width, not the viewport width. */
@container (max-width: 360px) {
.thl-controls:has(.thl-narration):has(.thl-sound) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 — Medium] Keep the narrow layout fallback for players without both audio buttons. This replaces the old unconditional @container (max-width: 300px) three-row layout with a selector that only applies when both .thl-narration and .thl-sound exist. A player embedding just one audio feature (or neither) is now forced into the two-column transport+utilities row even below 300px of content width. At 16px root font, the transport alone takes 3×40px + gaps ≈124px, and utilities need at least ~98px with no audio (speed select + fullscreen) or ~140px with one audio toggle; with the grid gap, those layouts need ~230px/~272px respectively. A 300px-wide player has only ~252px of content after .thl's 24px side padding, so the one-audio layout overflows/clips. A 260px player also clips even without audio. Restore a generic three-row fallback at the narrowest widths, retaining the wider 360px split for dual-audio players. Add embedded-width assertions for narration only, sonification only, neither, and both (the new test only exercises both).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 8bcf309 while preserving the existing dual-audio adjustment. The layout now uses two complementary container rules: at content widths <=360px, three rows for players offering both narration and sonification; at <=300px, the original generic three-row fallback for every configuration, including neither, only narration, only step sounds, and both. The new Chromium regression runs all four configurations across embedded player widths 260, 280, 296, 300, 320, 348, 368, 380, 420px, verifying that each control is within the toolbar and the overall player has no horizontal overflow. Full CI #43211760492 passed: 235 unit tests, 82 Chromium browser tests, package/SSR/hydration and framework checks. Leaving this thread for final maintainer review rather than resolving it on the reviewer's behalf.

@christophersesugh
christophersesugh merged commit 26d4e81 into main Nov 12, 2026
1 check passed
@christophersesugh
christophersesugh deleted the feat/player-caption-narration branch November 12, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant