diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe49da..fb614f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ Semantic Versioning independently of the version-1 trace and program schemas. ## Unreleased +### Fixed (narration review) + +- Coordinate browser-global speech synthesis between multiple Throughline players; newer narration takes over while displaced players pause and stop owning cancellation. +- Pause with an accessible error on unexpected speech interruption instead of stalling autoplay or advancing a failed utterance. +- Restart narration for repeated seeks to the same step and replace stale speech when authored guidance changes. +- Keep the dual-audio 360px breakpoint while restoring a generic three-row fallback at <=300px content widths, covering players with no audio, only narration, only step tones, or both. Verify embedded layouts with actual browser element bounds. + +### Added (opt-in caption narration) + +- Offer muted-by-default browser caption narration separately from step tones, using speech-synchronized playback without new runtime dependencies. +- Pause/resume speaking with playback; cancel on seek, scenario change and unmount; preserve the original timer when narration is off. + + ### Changed (development preview sound) - Expose the existing opt-in Web Audio speaker control in every local playground example and scenario comparison, initially muted. No default audio in the published React player and no new audio dependencies. diff --git a/README.md b/README.md index 6d33681..efb47c5 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,44 @@ from the diagram. The screen-reader step announcement remains active regardless of whether the disclosure is expanded. Details remain open between steps until closed, without changing playback state. +## Opt-in caption narration + +The optional React-only `narration` prop exposes a **Narrate captions** +button next to the independent `sonification` sound-effects toggle. +The button is **off initially**, even when a host offers it. Its first +activation speaks the current authored step from a user gesture using +the browser's Speech Synthesis API. It reads the authored step label, +notes, matching chapter and causal explanation, and transfer summaries. +It does not invent responses, state changes or causal explanations. +Very long utterances are shortened with an explicit spoken notice; the +full authored text remains available in Details. + +With narration enabled, **Play advances only after speech finishes**. +Pause pauses the current voice; Play resumes it. Manual seeking or +stepping cancels the previous voice and narrates the selected step. +Switching scenarios cancels the old voice and starts muted. Disabling +narration returns to timed playback and leaves step tones independent. +Playback speed applies to the next utterance, not the current one. +A manual seek to the **same** step (including Restart chapter) restarts its +narration. If an author updates guidance while the current frame is shown, +the old utterance is invalidated and the corrected explanation is spoken. + +Speech voices, available hardware, and user-activation policies depend +on the browser and OS. The button is disabled where speech synthesis is +unavailable; speech errors pause synchronized playback and are announced. +No text is uploaded by Throughline, but the browser may use a network +speech service. The core stays React- and browser-independent. + +**Multiple embedded players:** Browser speech synthesis has one global queue. +Throughline coordinates narration across its player instances: when B begins +speaking, B takes over and A is paused, unmutes no further audio, and reports +that another player is speaking. A's later seek/unmount does not interrupt B. +A may explicitly re-enable narration to take over again. External scripts +calling the browser speech API are not under Throughline's control; an +unexpected `interrupted` or `canceled` utterance is treated as an error: +narration-driven playback pauses and shows a recoverable message. This +behavior does not imply exclusive ownership of third-party speech. + ## Persistent workflow progress The React player retains a cumulative trail through the **selected** step. diff --git a/docs/architecture.md b/docs/architecture.md index fb602bc..de65d7a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,8 +60,43 @@ uses this internal boundary without adding a package export, trace schema, viewport control, or public selection API. Pan/zoom, automatic camera following and user-driven collapse are not part of TL-I12. +## Optional spoken explanation + +The React-only narration controller synthesizes authored caption text +(frame label, notes, guided chapter/causal content and explicit sends). +No new trace event or compiler schema is introduced. + +While narration is enabled, speech completion drives playback ticks +instead of a fixed interval. Only the active utterance may advance +the selected trace; seeking (including to the same step) and changed +authored guidance invalidate stale callbacks. Pause/resume +controls the voice, and unavailable or failed synthesis pauses the +narration clock with an accessible error. When narration is disabled, +the existing timed playback returns. Step tones remain independent. + +Browser speechSynthesis is a shared page resource. Throughline maintains a +per-engine, browser-local owner coordinator: the newest Throughline player +to begin narration takes over the global speech queue, pauses/disables the +previous player and announces displacement. Only the current owner may +call global `cancel()`, `pause()` or `resume()`; a displaced player's later +seek, toggle or unmount cannot cancel the new owner's speech. Deliberate +cancellation detaches event handlers before calling the browser API, while +unexpected `canceled`/`interrupted` errors trigger an accessible error +and pause rather than advancing the timeline. After a natural speech end, +ownership is released without global cancellation. This mechanism does not +coordinate with arbitrary third-party scripts using speechSynthesis and +cannot guarantee browser/OS voice isolation. Some voices may use +browser-managed remote services. + ## Footer layout and bounded explanations +The optional narration and step-tone controls share responsive rules. +Dual-audio players use a dedicated utilities row at player content widths up +to 360px; **all** players use the generic three-row fallback at content +widths up to 300px, regardless of whether audio features are offered. +Both breakpoints use the measured player container rather than the viewport, +preserving accessible controls in narrow embedded layouts. + The React player composes its navigation, chapter selection, timeline, and transport in one bottom bar adjacent to the diagram. Container queries use the player container's width, not global browser size, diff --git a/playground/main.tsx b/playground/main.tsx index 99c3031..b177187 100644 --- a/playground/main.tsx +++ b/playground/main.tsx @@ -90,6 +90,7 @@ function App() { scenarios={scenarios} initialScenarioId="retry-recovery" theme={theme} + narration sonification={{}} /> ) : current ? ( @@ -99,6 +100,7 @@ function App() { title={current.title} theme={theme} sonification={{}} + narration guidance={current.id === "distributed-checkout" ? CHECKOUT_GUIDANCE : undefined} /> ) : null} @@ -110,7 +112,10 @@ function App() {
Use the player controls to step, pan, zoom, collapse groups, inspect state
- and switch outcomes. Sound is available in every example: select the speaker
+ and switch outcomes. Narration is available in every example:
+ select Narrate captions to hear the authored explanation.
+ Play waits for speech to finish before advancing. The separate
+ step-tone sound control is still available: select the speaker
icon to unmute, then Play or step forward to hear short synthesized tones.
It starts muted and does not narrate the workflow. Resize the browser or
choose the 375px view to find layout problems. Changes to the source reload
diff --git a/src/react/__tests__/narration-review.browser.test.tsx b/src/react/__tests__/narration-review.browser.test.tsx
new file mode 100644
index 0000000..4c67cca
--- /dev/null
+++ b/src/react/__tests__/narration-review.browser.test.tsx
@@ -0,0 +1,244 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { page, userEvent } from "vitest/browser";
+import { render } from "vitest-browser-react";
+import { compile } from "../../domains/registry.js";
+import type { ThroughlineGuidance } from "../guidance.js";
+import { ThroughlinePlayer } from "../index.js";
+import "../../styles/throughline.css";
+
+const trace = compile(
+ 'thl flow\nnode a "Client" = 0\nstep "Request"\n set a = 1\n note "Request started"\nstep "Response"\n set a = 2\n note "Response received"',
+);
+
+function speechBoundary() {
+ class FakeUtterance {
+ rate = 1;
+ onend: (() => void) | null = null;
+ onerror: ((event: { error: string }) => void) | null = null;
+ constructor(public text: string) {}
+ }
+ const voices: FakeUtterance[] = [];
+ let active: FakeUtterance | null = null;
+ const synth = {
+ paused: false,
+ speak: vi.fn((voice: FakeUtterance) => {
+ active = voice;
+ voices.push(voice);
+ }),
+ cancel: vi.fn(() => {
+ const voice = active;
+ active = null;
+ voice?.onerror?.({ error: "interrupted" });
+ }),
+ pause: vi.fn(() => { synth.paused = true; }),
+ resume: vi.fn(() => { synth.paused = false; }),
+ };
+ vi.stubGlobal("SpeechSynthesisUtterance", FakeUtterance);
+ vi.stubGlobal("speechSynthesis", synth);
+ return { voices, synth };
+}
+afterEach(() => vi.unstubAllGlobals());
+
+it("reports an external interruption and pauses without completing the step", async () => {
+ const speech = speechBoundary();
+ const screen = await render(