diff --git a/src/BloomBrowserUI/bookEdit/bloomField/BloomField.ts b/src/BloomBrowserUI/bookEdit/bloomField/BloomField.ts
index c2d5fb8d4014..13f5448897fe 100644
--- a/src/BloomBrowserUI/bookEdit/bloomField/BloomField.ts
+++ b/src/BloomBrowserUI/bookEdit/bloomField/BloomField.ts
@@ -1,7 +1,7 @@
///
///
-import AudioRecording from "../toolbox/talkingBook/audioRecording";
+import { createValidXhtmlUniqueId } from "../js/xhtmlIdUtils";
import { get, post } from "../../utils/bloomApi";
import BloomMessageBoxSupport from "../../utils/bloomMessageBoxSupport";
import { tryProcessHyperlink } from "./hyperlinks";
@@ -520,7 +520,7 @@ export default class BloomField {
nodelist.forEach(
(span: Element, key: number, parent: NodeListOf) => {
const oldId = span.getAttribute("id");
- const newId = AudioRecording.createValidXhtmlUniqueId();
+ const newId = createValidXhtmlUniqueId();
span.setAttribute("id", newId);
post(`audio/copyAudioFile?oldId=${oldId}&newId=${newId}`);
},
diff --git a/src/BloomBrowserUI/bookEdit/js/audioUtils.ts b/src/BloomBrowserUI/bookEdit/js/audioUtils.ts
new file mode 100644
index 000000000000..ab2325e9c72d
--- /dev/null
+++ b/src/BloomBrowserUI/bookEdit/js/audioUtils.ts
@@ -0,0 +1,24 @@
+// taken out of audioRecording.ts to avoid the need for other
+// files to import that big file just to use a little bit of
+// code.
+
+import axios, { AxiosResponse } from "axios";
+
+export const kAnyRecordingApiUrl = "/bloom/api/audio/checkForAnyRecording?ids=";
+
+export async function audioExistsForIdsAsync(ids: string[]): Promise {
+ try {
+ const response: AxiosResponse = await axios.get(
+ `${kAnyRecordingApiUrl}${ids}`,
+ );
+ return doesNarrationExist(response);
+ } catch {
+ return false;
+ }
+}
+
+// Given a response from kAnyRecordingApiUrl, determines whether narration audio exists
+// for any of the specified IDs.
+export function doesNarrationExist(response: AxiosResponse): boolean {
+ return response && response.data === true;
+}
diff --git a/src/BloomBrowserUI/bookEdit/js/bloomImages.ts b/src/BloomBrowserUI/bookEdit/js/bloomImages.ts
index 9a0cd0579a6b..7165d4d90e86 100644
--- a/src/BloomBrowserUI/bookEdit/js/bloomImages.ts
+++ b/src/BloomBrowserUI/bookEdit/js/bloomImages.ts
@@ -21,7 +21,7 @@ import { updateCanvasElementClass } from "../toolbox/canvas/canvasElementDomUtil
import { farthest } from "../../utils/elementUtils";
import { EditableDivUtils } from "./editableDivUtils";
import { playingBloomGame } from "../toolbox/games/DragActivityTabControl";
-import { kPlaybackOrderContainerClass } from "../toolbox/talkingBook/audioRecording";
+import { kPlaybackOrderContainerClass } from "./talkingBookMarkupConstants";
import { getWorkspaceBundleExports } from "./workspaceFrames";
import {
changeImage,
diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementContextControls.tsx b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementContextControls.tsx
index ec60a37290df..03f3f908a2a6 100644
--- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementContextControls.tsx
+++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementContextControls.tsx
@@ -21,7 +21,7 @@ import { BloomTooltip } from "../../../react_components/BloomToolTip";
import { useL10n } from "../../../react_components/l10nHooks";
import { kBloomDisabledOpacity } from "../../../utils/colorUtils";
import { getAsync, useApiObject } from "../../../utils/bloomApi";
-import AudioRecording from "../../toolbox/talkingBook/audioRecording";
+import { audioExistsForIdsAsync } from "../audioUtils";
import { getAudioSentencesOfVisibleEditables } from "bloom-player";
import { canvasElementControlRegistry } from "../../toolbox/canvas/canvasElementControlRegistry";
import { buildCanvasElementControlRegistryContext } from "../../toolbox/canvas/buildCanvasElementControlRegistryContext";
@@ -156,7 +156,7 @@ const CanvasElementContextControls: React.FunctionComponent<{
props.canvasElement,
);
const ids = audioSentences.map((sentence) => sentence.id);
- AudioRecording.audioExistsForIdsAsync(ids)
+ audioExistsForIdsAsync(ids)
.then((audioExists) => {
setTextHasAudio(audioExists);
})
diff --git a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementDuplication.ts b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementDuplication.ts
index 35848ef349cb..343ca6816adf 100644
--- a/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementDuplication.ts
+++ b/src/BloomBrowserUI/bookEdit/js/canvasElementManager/CanvasElementDuplication.ts
@@ -5,7 +5,7 @@ import {
kBloomButtonClass,
kImageFitModeAttribute,
} from "../../toolbox/canvas/canvasElementConstants";
-import AudioRecording from "../../toolbox/talkingBook/audioRecording";
+import { createValidXhtmlUniqueId } from "../xhtmlIdUtils";
import { postData, postJson } from "../../../utils/bloomApi";
import { cloneCanvasElementHtmlStructure } from "./canvasElementCloneCleanup";
@@ -369,7 +369,7 @@ export class CanvasElementDuplication {
sourceId: string,
copiedElement: Element,
): void {
- const newId = AudioRecording.createValidXhtmlUniqueId();
+ const newId = createValidXhtmlUniqueId();
copiedElement.setAttribute("id", newId);
void copyAudioFileAsync(sourceId, newId); // we don't need to wait for this to finish
const duration = sourceElement.getAttribute("data-duration");
diff --git a/src/BloomBrowserUI/bookEdit/js/talkingBookChecksum.ts b/src/BloomBrowserUI/bookEdit/js/talkingBookChecksum.ts
new file mode 100644
index 000000000000..b0fdc4258623
--- /dev/null
+++ b/src/BloomBrowserUI/bookEdit/js/talkingBookChecksum.ts
@@ -0,0 +1,26 @@
+// taken out of audioRecording.ts to avoid the need for other
+// files to import that big file just to use a little bit of
+// code.
+
+import { getMd5 } from "../toolbox/talkingBook/md5Util";
+
+export function getChecksum(message: string): string {
+ if (message === null || message === undefined) {
+ // should not normally happen, but seems to in tests.
+ // The function is supposed to return a string, and I don't want to change
+ // all the callers, so making it return a string that's a bit unique so if
+ // we ever see it in production we can search for it.
+ return "undefind";
+ }
+ // Vertical line character ("|") acts as a phrase delimiter in Talking Books.
+ // To perform phrase-level recording, the user can insert a temporary "|" character where he wants a phrase split to happen.
+ // This is now recognized in the list of sentence delimiters, so it will be broken up as an audio-sentence.
+ // Then the user records the audio.
+ // Then the user deletes the vertical line characters.
+ // Now the text should be the desired final state, and audio recordings are possible at a sub-sentence level.
+ // However, we don't want the sentence markup to be updated because the checksums differ (since a character was deleted).
+ //
+ // Thus, our checksum function needs to ignore the vertical line character when computing the checksum.
+ const adjustedMessage = message.replace("|", "");
+ return getMd5(adjustedMessage);
+}
diff --git a/src/BloomBrowserUI/bookEdit/js/talkingBookMarkupConstants.ts b/src/BloomBrowserUI/bookEdit/js/talkingBookMarkupConstants.ts
new file mode 100644
index 000000000000..89660b3e6a0a
--- /dev/null
+++ b/src/BloomBrowserUI/bookEdit/js/talkingBookMarkupConstants.ts
@@ -0,0 +1,8 @@
+// taken out of audioRecording.ts to avoid the need for other
+// files to import that big file just to use a little bit of
+// code.
+
+export const kPlaybackOrderContainerClass: string =
+ "bloom-playbackOrderControlsContainer";
+
+export const kAudioCurrent = "ui-audioCurrent";
diff --git a/src/BloomBrowserUI/bookEdit/js/xhtmlIdUtils.ts b/src/BloomBrowserUI/bookEdit/js/xhtmlIdUtils.ts
new file mode 100644
index 000000000000..c7b7e14a9fd1
--- /dev/null
+++ b/src/BloomBrowserUI/bookEdit/js/xhtmlIdUtils.ts
@@ -0,0 +1,14 @@
+// taken out of audioRecording.ts to avoid the need for other
+// files to import that big file just to use a little bit of
+// code.
+
+import { EditableDivUtils } from "./editableDivUtils";
+
+export function createValidXhtmlUniqueId(): string {
+ let newId = EditableDivUtils.createUuid();
+ if (/^\d/.test(newId)) {
+ newId = "i" + newId; // valid ID in XHTML can't start with digit
+ }
+
+ return newId;
+}
diff --git a/src/BloomBrowserUI/bookEdit/toolbox/ToolboxRoot.tsx b/src/BloomBrowserUI/bookEdit/toolbox/ToolboxRoot.tsx
index 5b0cc5975668..69ffae4b0990 100644
--- a/src/BloomBrowserUI/bookEdit/toolbox/ToolboxRoot.tsx
+++ b/src/BloomBrowserUI/bookEdit/toolbox/ToolboxRoot.tsx
@@ -69,10 +69,6 @@ const toolIconPathByToolId: Record = {
"/bloom/bookEdit/toolbox/impairmentVisualizer/blind-eye-white.svg",
};
-const legacyToolSubPathByToolId: Record = {
- talkingBook: "talkingBook/talkingBookToolboxTool.html",
-};
-
const toolboxHeaderIconStyles = css`
width: 16px;
height: 16px;
@@ -154,7 +150,6 @@ const makeSectionFromToolId = (toolId: string): ToolboxSection => {
id: toolId,
englishLabel: labelInfo.englishLabel,
l10nKey: labelInfo.l10nKey,
- legacyToolHtmlSubPath: legacyToolSubPathByToolId[toolId],
};
};
@@ -261,12 +256,7 @@ const ensureReactToolBodyElement = (
}
const normalizedToolId = normalizeToolId(toolId);
- // Do not create elements for legacy tools; they load their content from
- // legacyToolHtmlSubPath and their makeRootElement() implementations throw
- // "Method not implemented." if called directly.
- if (legacyToolSubPathByToolId[normalizedToolId]) {
- return undefined;
- }
+
const tool = getMasterToolList().find((candidate) => {
return candidate.id() === normalizedToolId;
});
@@ -457,36 +447,6 @@ export const ToolboxRoot: React.FunctionComponent = () => {
return;
}
- const legacyToolHtmlSubPath = legacyToolSubPathByToolId[toolId];
- if (legacyToolHtmlSubPath) {
- try {
- const legacyToolBodyHtml = await loadLegacyToolBodyHtml({
- id: toolId,
- englishLabel: "",
- l10nKey: "",
- legacyToolHtmlSubPath,
- });
- setSections((previousSections) =>
- previousSections.map((section) => {
- if (section.id !== toolId) {
- return section;
- }
- return {
- ...section,
- legacyToolBodyHtml,
- };
- }),
- );
- } catch (error) {
- hydratedToolIds.current.delete(toolId);
- console.error(
- `Failed to load legacy toolbox HTML for ${toolId}.`,
- error,
- );
- }
- return;
- }
-
hydratedToolIds.current.delete(toolId);
}, []);
diff --git a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlRegistry.ts b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlRegistry.ts
index 070f04e7fbe2..0801848597cc 100644
--- a/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlRegistry.ts
+++ b/src/BloomBrowserUI/bookEdit/toolbox/canvas/canvasControlRegistry.ts
@@ -73,7 +73,7 @@ import {
playSound,
showDialogToChooseSoundFileAsync,
} from "../games/GameTool";
-import AudioRecording from "../talkingBook/audioRecording";
+import { showTalkingBookTool } from "../talkingBook/showTalkingBookTool";
import { showLinkTargetChooserDialog } from "../../../react_components/LinkTargetChooser/LinkTargetChooserDialogLauncher";
import { kBloomBlue } from "../../../bloomMaterialUITheme";
import {
@@ -334,7 +334,7 @@ const makeChooseAudioMenuItemForText = (
englishLabel: "Use Talking Book Tool",
onSelect: () => {
runtime.closeMenu(false);
- AudioRecording.showTalkingBookTool();
+ showTalkingBookTool();
},
},
],
diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/AdjustTimingsDialog.tsx b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/AdjustTimingsDialog.tsx
index b5a5d5794f39..4880e11540cb 100644
--- a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/AdjustTimingsDialog.tsx
+++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/AdjustTimingsDialog.tsx
@@ -20,7 +20,7 @@ import {
} from "../../../react_components/BloomDialog/commonDialogComponents";
import { useL10n } from "../../../react_components/l10nHooks";
import { getAsync, postBoolean, postJsonAsync } from "../../../utils/bloomApi";
-import { kAudioCurrent } from "./audioRecording";
+import { kAudioCurrent } from "../../js/talkingBookMarkupConstants";
import { AdjustTimingsControl, TimedTextSegment } from "./AdjustTimingsControl";
import {
getUrlPrefixFromWindowHref,
diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/IAudioRecorder.ts b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/IAudioRecorder.ts
index 3d7979f2ca91..341595c94fc4 100644
--- a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/IAudioRecorder.ts
+++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/IAudioRecorder.ts
@@ -3,11 +3,16 @@
// anything implementation-specific.
import { RecordingMode } from "./recordingMode"; // only holds the RecordingMode enum
+import { TalkingBookUiState } from "./TalkingBookUiState";
export interface IAudioRecorder {
autoSegmentBasedOnTextLength: () => number[];
markAudioSplit: () => void;
+ insertSegmentMarker(): void;
+ setShowPlaybackOrder(isOn: boolean): Promise;
setShowingImageDescriptions: (boolean) => void;
+ setRecordingMode(recordingMode: RecordingMode): Promise;
+ handleImportRecordingClick(): void;
removeRecordingSetup: () => void;
getUpdateMarkupAction: () => Promise<() => void>;
setupForRecordingAsync: () => Promise;
@@ -22,4 +27,21 @@ export interface IAudioRecorder {
getPageDocBody: () => HTMLElement | null;
getCurrentTextBox: () => HTMLElement | null;
recordingMode: RecordingMode;
+ setLevelCanvas(canvas: HTMLCanvasElement | null): void;
+ closeDeviceSelectMenu(): void;
+ changeInputDevice(): void;
+ setInputDevice(device: any): void;
+ startRecordCurrentAsync(): Promise;
+ endRecordCurrentAsync(): Promise;
+ togglePlayCurrentAsync(): Promise;
+ playESpeakPreview(): void;
+ showAdjustTimingsDialog(): Promise;
+ moveToNextAudioElement(): Promise;
+ moveToPrevAudioElementAsync(): Promise;
+ clearRecordingAsync(): Promise;
+ listenAsync(canvasToExclude?: HTMLElement): Promise;
+ getTalkingBookUiState(): TalkingBookUiState;
+ registerStateListener(
+ listener: (state: TalkingBookUiState) => void,
+ ): () => void;
}
diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/TalkingBook-React-Conversion-Plan.md b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/TalkingBook-React-Conversion-Plan.md
new file mode 100644
index 000000000000..86395fd1f9a6
--- /dev/null
+++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/TalkingBook-React-Conversion-Plan.md
@@ -0,0 +1,532 @@
+# Plan: Convert the Talking Book tool to React
+
+**Status:** proposed / not yet started.
+**Scope of this document:** Phase A (the React conversion, one PR) and Phase B (moving the
+engine into the page iframe, a separate follow-up PR). Step 6 (retiring the old non-React
+toolbox framework) is a third PR, described briefly at the end so it isn't lost.
+
+Talking Book is the **last** non-React toolbox tool. Converting it lets us later delete
+the entire legacy non-React tool framework (Step 6).
+
+## 0. Sequencing rationale — why the engine move is a separate PR
+
+An earlier draft of this plan did the React conversion and the engine move in one PR.
+They are now split, for two reasons:
+
+1. **The engine move is not needed for the goal.** Deleting the legacy framework only
+ requires the tool *UI* to be React. The engine can keep living in the toolbox iframe,
+ reaching into the page exactly as it does today.
+2. **The page iframe reloads on every page switch.** `switchContentPage` in
+ `workspaceRoot.ts` (~line 134) sets `iframe.src = newSource`, destroying the page
+ iframe's whole JS realm. Today `theOneAudioRecorder` lives in the toolbox iframe and
+ **survives for the whole editing session**; moved page-side, it would be torn down and
+ reconstructed on *every page switch*. That is a real architectural change (see §6), not
+ the mechanical "flip the wiring" the earlier draft implied — it needs its own design
+ and its own PR.
+
+The split is also what makes Phase B safe: after Phase A the engine no longer touches any
+toolbox DOM (it talks to the UI only through a state object and a couple of registered
+element refs), so Phase B becomes a pure relocation-and-lifetime problem.
+
+---
+
+## 1. Current architecture (verified against source, 2026-07)
+
+### 1.1 `talkingBook.ts`
+`TalkingBookTool implements ITool` directly (not via `ToolboxToolReactAdaptor`). Its
+`makeRootElement()` **throws** — the tool's UI is the pug file
+`talkingBookToolboxTool.pug`, routed in through two legacy hard-coded maps:
+
+- `subpath` in `toolbox.ts` (~line 1273): `talkingBookTool: "talkingBook/talkingBookToolboxTool.html"`
+- `legacyToolSubPathByToolId` in `ToolboxRoot.tsx` (~line 71): `talkingBook: "talkingBook/talkingBookToolboxTool.html"`
+
+It is a thin lifecycle shim over the `AudioRecording` singleton, but note the exact
+forwards (the conversion must preserve all of them):
+
+- `showTool()` → module-level `initializeTalkingBookToolAsync()` (lazily constructs the
+ singleton) then `getAudioRecorder().setupForRecordingAsync()`.
+- `newPageReady()` → `showImageDescriptionsIfAny()` (its own page-DOM logic, BL-8515)
+ then `getAudioRecorder().handleNewPageReady(TalkingBookTool.deshroudPhraseDelimiters)`.
+- `hideTool()` → `handleToolHiding()`.
+- `detachFromPage()` → `removeRecordingSetup()`, then `hideImageDescriptions(page)` and
+ `TalkingBookTool.enshroudPhraseDelimiters(page)`.
+- `updateMarkupAsync()` → `getAudioRecorder().getUpdateMarkupAction()`;
+ `isUpdateMarkupAsync()` returns `true`.
+- `beginRestoreSettings()` → `beginLoadSynphonySettings()` (shares sentence-ending
+ punctuation settings with the Leveled Reader tool — easy to lose in conversion, since
+ the adaptor's default `beginRestoreSettings` is a no-op).
+- `isAlwaysEnabled()` returns `true` (adaptor default is `false` — must override).
+- The static `enshroudPhraseDelimiters`/`deshroudPhraseDelimiters` helpers operate on the
+ *page* DOM and stay with the tool.
+
+The lifecycle comment at the top of the file (lines ~38–48) enumerates when
+showTool/newPageReady/updateMarkup fire — it is the manual-test matrix (§8).
+
+### 1.2 `audioRecording.ts` (~4,900 lines) — engine **and** tool UI, running in the toolbox iframe
+The important, counter-intuitive fact: **the `AudioRecording` object runs in the
+_toolbox_ iframe, not the page iframe.** Evidence:
+
+- The constructor reads toolbox controls via its own `document`: `#audio-split`
+ (→ `this.audioSplitButton`) and `#audio-meter` (→ `this.levelCanvas`), and calls
+ `updateDisplay()` which touches `#audio-split-wrapper` and
+ `#advanced-talking-book-controls-react-container`. (It does *not* touch `#player`;
+ that is wired in `initializeTalkingBookToolAsync`, and `#disablingOverlay` is grabbed
+ in `setupForRecordingAsync`.)
+- `initializeTalkingBookToolAsync` (the class method, ~line 229) wires jQuery handlers to
+ the toolbox buttons: `#audio-record` mousedown/mouseup, click handlers on
+ `#audio-play` (with a **ctrl-click eSpeak-preview easter egg** — preserve it),
+ `#audio-split` (opens the Adjust Timings dialog via `getWorkspaceBundleExports()`),
+ `#audio-next`, `#audio-prev`, `#audio-clear`, `#audio-listen`, `#audio-input-dev`;
+ plus `#player` events (`onended`, `onerror`, `ondurationchange`), `toastr.options`
+ (position `toast-toolbox-bottom`), a `WholeTextBoxAudio` feature-status fetch, and
+ `pullDefaultRecordingModeAsync()`.
+- It reaches _into_ the page iframe for content via `getPageFrame()`
+ (`parent.window.document.getElementById("page")`, ~line 2366) and `getPageDocBody()`
+ — ~26 direct call sites plus ~9 via `getPageDocBodyJQuery()`.
+- It also installs listeners **in the page document**: a capture-phase `mousedown` on the
+ page body (`moveRecordingHighlightToClick`) and a `MutationObserver` watching
+ visibility-affecting class changes (`watchElementsThatMightChangeAffectingVisibility`).
+ These already run cross-iframe today and are re-installed in `handleNewPageReady`.
+
+So the object owns two very different kinds of responsibility:
+
+1. **Tool UI** (toolbox side): the 7 main buttons, their counters, the level meter
+ canvas, device selection (`#audio-input-dev` icon + `#audio-devlist` jQuery menu),
+ the `