feat(stories): Pro Studio Phase 1 — real audiobook output, cast, persistence, reorder, i18n - #177
Conversation
…pro output, projects) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…order, i18n First phase of the pro-studio Stories Editor (spec: docs/superpowers/specs/2026-05-30-stories-editor-studio-design.md). Makes the editor actually produce audiobooks and remember your work: - Persistence: storiesSlice (tracks + cast) via zustand persist -> localStorage; transient fields (generating/audioUrl) stripped on persist; id counter reseeds from persisted tracks. Dropped the hardcoded sample seed -> clean empty state. - Cast: editable CastMember[] (name, color, voice) with a Cast panel; each line picks a character and inherits its voice (per-line override still available). - Real Generate: exportStoryAudio() stitches every line + [pause] gaps into one WAV via the Web Audio API (job-less /generate per chunk) with a % progress indicator and download. Per-line preview already shipped (#176). - Reorder: native HTML5 drag-and-drop (pure reorder() helper). - i18n: all Stories strings via t('stories.*') (en + zh-CN). - Tests: storiesSlice reducers, storyCast resolution, storyExport WAV/concat/ silence, storyReorder. 18 new unit tests. No DB/alembic; localStorage only. Same-origin + PIN-safe synth (apiFetch). No new deps. Cross-platform-identical default behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR adds a comprehensive Stories Editor for creating persisted audiobook projects. It introduces state management via a new Zustand slice, persists track and cast data, implements client-side WAV audio export, provides utility helpers for cast color management and drag-and-drop reordering, and reworks the UI component with internationalized labels and a cast-management panel. ChangesStories Editor — Pro Studio Foundation
🎯 3 (Moderate) | ⏱️ ~25 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| ...createDubSlice(set, get, api), | ||
| ...createGenerateSlice(set, get, api), | ||
| ...createPillSlice(set, get, api), | ||
| ...createStoriesSlice(set, get, api), |
| let state: any = {}; | ||
| const set = (fn: any) => { state = { ...state, ...(typeof fn === 'function' ? fn(state) : fn) }; }; | ||
| const get = () => state; | ||
| state = createStoriesSlice(set as any, get as any, {} as any); |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
frontend/src/store/storiesSlice.test.ts (1)
4-10: 💤 Low valueTest harness passes unused arguments.
The
harness()helper passes three arguments tocreateStoriesSlice, but the slice creator only uses the first (set). The extragetandapiarguments are superfluous.While this doesn't affect correctness, you can simplify to match the actual signature:
♻️ Optional simplification
function harness() { let state: any = {}; const set = (fn: any) => { state = { ...state, ...(typeof fn === 'function' ? fn(state) : fn) }; }; const get = () => state; - state = createStoriesSlice(set as any, get as any, {} as any); + state = createStoriesSlice(set as any, get as any, undefined as any); return { get }; }Or omit entirely if the signature allows:
- state = createStoriesSlice(set as any, get as any, {} as any); + state = createStoriesSlice(set as any, () => state, undefined as any);As per coding guidelines, the static analysis hint (CodeQL) flagged superfluous arguments.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/store/storiesSlice.test.ts` around lines 4 - 10, The test harness is invoking createStoriesSlice with three arguments though the slice only needs the setter; update the harness() function so it only passes the required set argument to createStoriesSlice (remove the extra get and api/{} arguments) and ensure the returned object still exposes get by keeping the local get function; target the createStoriesSlice call inside harness and remove the unused parameters to eliminate the superfluous arguments flagged by static analysis.docs/superpowers/specs/2026-05-30-stories-editor-studio-design.md (1)
27-38: 💤 Low valueAdd language identifier to fenced code block.
The ASCII art diagram should specify a language identifier (or use
text) to satisfy markdown linting.📝 Proposed fix
-``` +```text ┌ Stories ───── [⌜Paste & Auto-cast⌟] [+ Add Line] [ ▶ Generate ▾ ] ┐As per coding guidelines, the static analysis hint flagged this as missing a language specifier.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/superpowers/specs/2026-05-30-stories-editor-studio-design.md` around lines 27 - 38, The fenced ASCII-art block starting with "┌ Stories ───── [⌜Paste & Auto-cast⌟]..." is missing a language identifier; update the opening triple-backtick for that block to include a language (e.g., use "text") so the block becomes ```text and satisfies markdown linting rules, leaving the block contents unchanged and closing with ``` as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/StoriesEditor.jsx`:
- Around line 179-190: The simple-preview branch leaks blob URLs because the
created Audio(url) is played but the URL is never revoked; modify the code in
the non-marker branch that calls fetchChunkAudio and sets audioUrl (the block
using hasStoryMarkers, fetchChunkAudio, setTracks, and audioUrl) to attach ended
and error listeners to the Audio instance that call URL.revokeObjectURL(url)
(and remove the listeners), and also ensure any previous track.audioUrl is
revoked before overwriting it via setTracks to avoid accumulating unused object
URLs; keep setting generating:false as before.
- Line 262: The hardcoded aria-label on the StoriesEditor div should be replaced
with a translation key; change aria-label="Stories editor" to use the i18n
translator (e.g., aria-label={t('stories.editor')}) in the StoriesEditor
component and ensure the component has access to t (import/useTranslation from
react-i18next and call const { t } = useTranslation() if not already present);
also add the corresponding "stories.editor" entry to your locales JSON files.
---
Nitpick comments:
In `@docs/superpowers/specs/2026-05-30-stories-editor-studio-design.md`:
- Around line 27-38: The fenced ASCII-art block starting with "┌ Stories ─────
[⌜Paste & Auto-cast⌟]..." is missing a language identifier; update the opening
triple-backtick for that block to include a language (e.g., use "text") so the
block becomes ```text and satisfies markdown linting rules, leaving the block
contents unchanged and closing with ``` as before.
In `@frontend/src/store/storiesSlice.test.ts`:
- Around line 4-10: The test harness is invoking createStoriesSlice with three
arguments though the slice only needs the setter; update the harness() function
so it only passes the required set argument to createStoriesSlice (remove the
extra get and api/{} arguments) and ensure the returned object still exposes get
by keeping the local get function; target the createStoriesSlice call inside
harness and remove the unused parameters to eliminate the superfluous arguments
flagged by static analysis.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: c860d265-fbdb-4093-9379-b6d2f062488f
📒 Files selected for processing (14)
docs/superpowers/specs/2026-05-30-stories-editor-studio-design.mdfrontend/src/components/StoriesEditor.cssfrontend/src/components/StoriesEditor.jsxfrontend/src/i18n/locales/en.jsonfrontend/src/i18n/locales/zh-CN.jsonfrontend/src/store/index.tsfrontend/src/store/storiesSlice.test.tsfrontend/src/store/storiesSlice.tsfrontend/src/utils/storyCast.jsfrontend/src/utils/storyCast.test.jsfrontend/src/utils/storyExport.jsfrontend/src/utils/storyExport.test.jsfrontend/src/utils/storyReorder.jsfrontend/src/utils/storyReorder.test.js
| if (!hasStoryMarkers(raw)) { | ||
| try { | ||
| const url = await fetchChunkAudio(raw, track.profileId); | ||
| setTracks(prev => prev.map(t => t.id === track.id ? { ...t, audioUrl: url, generating: false } : t)); | ||
| const url = await fetchChunkAudio(raw, pid); | ||
| setTracks((prev) => prev.map((tk) => (tk.id === track.id ? { ...tk, audioUrl: url, generating: false } : tk))); | ||
| const audio = new Audio(url); | ||
| audio.play().catch(() => {}); | ||
| } catch (err) { | ||
| console.warn('Stories preview failed:', err); | ||
| setTracks(prev => prev.map(t => t.id === track.id ? { ...t, generating: false } : t)); | ||
| setTracks((prev) => prev.map((tk) => (tk.id === track.id ? { ...tk, generating: false } : tk))); | ||
| } | ||
| return; | ||
| } |
There was a problem hiding this comment.
Object URL leaked on simple-preview path.
In the non-marker branch the created blob URL is stored as audioUrl and played, but never revoked. Each preview (and each replacement of audioUrl) leaks a blob URL until page reload, unlike the marker path which revokes on ended/error. Revoke when playback finishes.
🧹 Proposed fix
try {
const url = await fetchChunkAudio(raw, pid);
setTracks((prev) => prev.map((tk) => (tk.id === track.id ? { ...tk, audioUrl: url, generating: false } : tk)));
const audio = new Audio(url);
- audio.play().catch(() => {});
+ const cleanup = () => URL.revokeObjectURL(url);
+ audio.onended = cleanup;
+ audio.onerror = cleanup;
+ audio.play().catch(cleanup);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/StoriesEditor.jsx` around lines 179 - 190, The
simple-preview branch leaks blob URLs because the created Audio(url) is played
but the URL is never revoked; modify the code in the non-marker branch that
calls fetchChunkAudio and sets audioUrl (the block using hasStoryMarkers,
fetchChunkAudio, setTracks, and audioUrl) to attach ended and error listeners to
the Audio instance that call URL.revokeObjectURL(url) (and remove the
listeners), and also ensure any previous track.audioUrl is revoked before
overwriting it via setTracks to avoid accumulating unused object URLs; keep
setting generating:false as before.
| const profileName = (id) => (profiles.find((p) => p.id === id) || {}).name; | ||
|
|
||
| return ( | ||
| <div className="stories-editor" role="region" aria-label="Stories editor"> |
There was a problem hiding this comment.
Hardcoded aria-label bypasses i18n.
aria-label="Stories editor" is user-facing (screen-reader) text that is not routed through the translation layer, unlike the rest of this component. Use a t('stories.*') key.
🌐 Proposed fix
- <div className="stories-editor" role="region" aria-label="Stories editor">
+ <div className="stories-editor" role="region" aria-label={t('stories.title')}>As per coding guidelines: "All user-facing text in the UI must go through the i18n translation layer using t('...') keys in locales/*.json files, never hardcode non-English text".
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className="stories-editor" role="region" aria-label="Stories editor"> | |
| <div className="stories-editor" role="region" aria-label={t('stories.title')}> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/StoriesEditor.jsx` at line 262, The hardcoded
aria-label on the StoriesEditor div should be replaced with a translation key;
change aria-label="Stories editor" to use the i18n translator (e.g.,
aria-label={t('stories.editor')}) in the StoriesEditor component and ensure the
component has access to t (import/useTranslation from react-i18next and call
const { t } = useTranslation() if not already present); also add the
corresponding "stories.editor" entry to your locales JSON files.
|
| Filename | Overview |
|---|---|
| frontend/src/components/StoriesEditor.jsx | Major rework adding Cast panel, drag-reorder, real export, and i18n. The HTTP error check from the original fetchChunkAudio was dropped during refactor, causing silent failures on non-2xx /generate responses. |
| frontend/src/utils/storyExport.js | New WAV stitching utility with clean pure helpers; silently truncates stereo decoded audio to channel 0 without mixing, which could produce asymmetric output for stereo TTS voices. |
| frontend/src/store/storiesSlice.ts | New slice for persisted story project state (tracks + cast); well-structured with DEFAULT_CAST deep-copy protection, clean reducer operations, and correct transient-field exclusion in the parent partialize. |
| frontend/src/store/index.ts | Correctly composes StoriesSlice and strips transient fields in partialize; store version stays at 4 with new persisted fields added, which is safe for fresh v4 stores but skips a clean migration seam. |
| frontend/src/utils/storyCast.js | New pure cast-resolution helpers (effectiveProfile, castMember, nextCastColor) — correct, well-tested, and safely handle null/empty cast arrays. |
| frontend/src/utils/storyReorder.js | Simple pure reorder helper with correct splice-after-remove insertion; handles same-id and missing-id edge cases cleanly. |
| frontend/src/i18n/locales/en.json | Adds complete stories.* namespace with 30+ keys; all keys present in both en.json and zh-CN.json with correct ICU interpolation patterns. |
| frontend/src/store/storiesSlice.test.ts | Solid unit tests covering all slice reducers, including the DEFAULT_CAST shared-reference guard; harness correctly simulates zustand's set/get pattern. |
Sequence Diagram
sequenceDiagram
participant User
participant StoriesEditor
participant storyExport
participant storyCast
participant generateSpeech as /generate (API)
participant WebAudio as Web Audio API
participant localStorage
User->>StoriesEditor: click Generate
StoriesEditor->>StoriesEditor: filter usable tracks
StoriesEditor->>storyExport: exportStoryAudio(tracks, resolveProfile, fetchChunkBlob, onProgress)
storyExport->>storyCast: effectiveProfile(track, cast)
storyCast-->>storyExport: profileId (track override → cast voice → null)
loop for each text segment
storyExport->>generateSpeech: POST /generate (text, profile_id)
generateSpeech-->>storyExport: WAV Blob
storyExport->>WebAudio: decodeAudioData(blob.arrayBuffer())
WebAudio-->>storyExport: AudioBuffer (resampled to ctx.sampleRate)
end
storyExport->>storyExport: concatBuffers([...AudioBuffers, ...silenceBuffers])
storyExport->>storyExport: encodeWav(combined, sampleRate) → ArrayBuffer
storyExport-->>StoriesEditor: Blob (audio/wav)
StoriesEditor->>User: download story.wav
StoriesEditor->>localStorage: persist storyTracks + cast (via zustand)
note over localStorage: transient fields (generating, audioUrl) stripped
Comments Outside Diff (1)
-
frontend/src/store/index.ts, line 97-114 (link)Persist version not bumped for new persisted fields
storyTracksandcastare now included inpartialize, but the storeversionstays at 4. Zustand'smigrateruns only when the stored version mismatches the current one — since no mismatch exists, migration never runs. For users who already have a v4 store, these keys will simply be absent from localStorage and will fall back to the slice defaults, which is safe. However, if any dev or staging build ever persistedstoryTracks/castin a different shape (e.g., a prototype from an earlier branch), those users would silently rehydrate invalid data with no sanitisation step. Bumping toversion: 5with a pass-through migration gives a clean migration seam and ensures thepartializeshape is always matched.
Reviews (1): Last reviewed commit: "feat(stories): Phase 1 — real audiobook ..." | Re-trigger Greptile
| @@ -437,18 +464,11 @@ export default function StoriesEditor({ profiles = [], onGenerate }) { | |||
| {tracks.length > 0 && ( | |||
| <div className="stories-editor__footer"> | |||
| <div className="stories-editor__stats"> | |||
| <span className="stories-editor__stat"> | |||
| 📝 {tracks.length} lines | |||
| </span> | |||
| <span className="stories-editor__stat"> | |||
| 🎭 {uniqueChars} characters | |||
| </span> | |||
| <span className="stories-editor__stat"> | |||
| ⏱ ~{estMinutes} min | |||
| </span> | |||
| <span className="stories-editor__stat"> | |||
| 📊 {totalChars.toLocaleString()} chars | |||
| </span> | |||
| <span className="stories-editor__stat">📝 {t('stories.lines', { count: tracks.length })}</span> | |||
There was a problem hiding this comment.
HTTP error check dropped on refactor
When fetchChunkAudio was split into fetchChunkBlob + fetchChunkAudio, the if (!res.ok) throw new Error(...) guard from the original code was lost. A non-2xx response from /generate (e.g., 422 / 503) is now silently treated as a valid audio blob: during export it propagates into ctx.decodeAudioData() which throws an opaque "unable to decode audio data" error, and during preview new Audio(url) fails silently. The user sees either a cryptic export failure or no feedback at all, with no indication of which line caused it.
| const fetchChunkBlob = useCallback(async (text, profileId) => { | ||
| const fd = new FormData(); | ||
| fd.append('text', text); | ||
| fd.append('speed', '1.0'); | ||
| if (profileId) fd.append('profile_id', profileId); | ||
| const res = await generateSpeech(fd); | ||
| if (!res.ok) throw new Error(`Preview HTTP ${res.status}`); | ||
| const blob = await res.blob(); | ||
| return URL.createObjectURL(blob); | ||
| const res = await generateSpeech(fd); // apiFetch: same-origin + PIN-aware | ||
| return res.blob(); | ||
| }, []); |
There was a problem hiding this comment.
Restore the HTTP status check that was present in the original
fetchChunkAudio. Without it, a non-2xx response silently flows into ctx.decodeAudioData() or new Audio(), producing either an opaque decode error or silent playback failure with no actionable message to the user.
| const fetchChunkBlob = useCallback(async (text, profileId) => { | |
| const fd = new FormData(); | |
| fd.append('text', text); | |
| fd.append('speed', '1.0'); | |
| if (profileId) fd.append('profile_id', profileId); | |
| const res = await generateSpeech(fd); | |
| if (!res.ok) throw new Error(`Preview HTTP ${res.status}`); | |
| const blob = await res.blob(); | |
| return URL.createObjectURL(blob); | |
| const res = await generateSpeech(fd); // apiFetch: same-origin + PIN-aware | |
| return res.blob(); | |
| }, []); | |
| const fetchChunkBlob = useCallback(async (text, profileId) => { | |
| const fd = new FormData(); | |
| fd.append('text', text); | |
| fd.append('speed', '1.0'); | |
| if (profileId) fd.append('profile_id', profileId); | |
| const res = await generateSpeech(fd); // apiFetch: same-origin + PIN-aware | |
| if (!res.ok) throw new Error(`Generate HTTP ${res.status}`); | |
| return res.blob(); | |
| }, []); |
| * @param tracks [{ text, character, profileId }] | ||
| * @param resolveProfile (track) => profileId|null // applies cast fallback | ||
| * @param fetchChunkBlob (text, profileId) => Promise<Blob> // /generate WAV | ||
| * @param onProgress (done, total) => void | ||
| * @returns Blob (audio/wav) | ||
| */ | ||
| export async function exportStoryAudio(tracks, resolveProfile, fetchChunkBlob, onProgress) { | ||
| const Ctx = window.AudioContext || window.webkitAudioContext; | ||
| const ctx = new Ctx(); |
There was a problem hiding this comment.
Stereo decoded audio silently truncated to channel 0
concatBuffers always calls b.getChannelData(0), so when ctx.decodeAudioData() returns a stereo AudioBuffer (numberOfChannels === 2), the right channel is silently discarded rather than being mixed or averaged. TTS engines that produce stereo output would yield an asymmetrically mixed audiobook without any indication to the user. A comment or an explicit channel-0 reference in the function's jsdoc would at least make the design intent auditable; a downmix would be safer.
First phase of the pro-studio Stories Editor (line-card model). Spec:
docs/superpowers/specs/2026-05-30-stories-editor-studio-design.md(full vision: auto-cast, rich per-line, pro output, projects/import — phased).Purpose: turn a written story/script into a fully-voiced audiobook — narrator + a distinct voice per character — and generate one cohesive file. Simple by default (paste → cast → Generate), deep on demand.
Phase 1 makes it actually work
storiesSlice(tracks + cast) via zustand persist → localStorage. Transient fields (generating/audioUrl) stripped on persist; track-id counter reseeds from persisted tracks. Dropped the hardcoded 'Once upon a time' seed → clean empty first-run.CastMember[](name, color, voice) + Cast panel. Each line picks a character and inherits its voice; per-line override still available.exportStoryAudio()stitches every line +[pause]gaps into a single WAV (Web Audio API, job-less/generateper chunk) with a % progress indicator + download. Per-line preview shipped in fix(stories): preview 404 — route through job-less /generate #176.reorder()).t('stories.*')(en + zh-CN).Tests / parity
Next phases (separate PRs): 2 auto-cast + import, 3 studio per-line depth (emotion/speed/inline-voice/regen), 4 pro output (stems, chapters, MP3) + named projects.
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
Documentation
Tests