Skip to content

feat(stories): Pro Studio Phase 1 — real audiobook output, cast, persistence, reorder, i18n - #177

Merged
debpalash merged 2 commits into
mainfrom
feat/stories-studio
May 30, 2026
Merged

feat(stories): Pro Studio Phase 1 — real audiobook output, cast, persistence, reorder, i18n#177
debpalash merged 2 commits into
mainfrom
feat/stories-studio

Conversation

@debpalash

@debpalash debpalash commented May 30, 2026

Copy link
Copy Markdown
Owner

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

  • PersistencestoriesSlice (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.
  • Cast — editable CastMember[] (name, color, voice) + Cast panel. Each line picks a character and inherits its voice; per-line override still available.
  • Real GenerateexportStoryAudio() stitches every line + [pause] gaps into a single WAV (Web Audio API, job-less /generate per chunk) with a % progress indicator + download. Per-line preview shipped in fix(stories): preview 404 — route through job-less /generate #176.
  • Reorder — native HTML5 drag-and-drop (pure reorder()).
  • i18n — all Stories strings via t('stories.*') (en + zh-CN).

Tests / parity

  • 18 new unit tests (slice reducers, cast resolution, WAV encode/concat/silence, reorder). Full suite vitest 125/125, typecheck ✓, build ✓, CJK guard ✓, legacy ✓.
  • No DB/alembic (localStorage only); same-origin + PIN-safe synth; no new deps; cross-platform-identical default behavior.

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

    • Added multi-track audio story editor with cast panel for character management
    • Enabled drag-and-drop track reordering
    • Added per-character voice and emotion controls
    • Implemented WAV audio export functionality
    • Added text import with auto-splitting capability
  • Documentation

    • Added Stories Editor Pro Studio design specification
  • Tests

    • Added test coverage for cast management and audio export utilities

Review Change Stack

debpalash and others added 2 commits May 30, 2026 21:45
…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>
@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This 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.

Changes

Stories Editor — Pro Studio Foundation

Layer / File(s) Summary
Design specification and architecture
docs/superpowers/specs/2026-05-30-stories-editor-studio-design.md
Design doc specifies the interaction model (line cards, expandable studio drawer, cast panel), persisted data schema with inheritance rules, client/backend integration (reusing /generate, client-side WAV stitching, optional Phase 4 MP3/M4B), phased delivery (Phase 1–4), file structure, error handling, testing, and constraints.
Stories state model and slice
frontend/src/store/storiesSlice.ts, frontend/src/store/storiesSlice.test.ts
StoryTrack and CastMember types defined; StoriesSlice provides mutators (setStoryTracks, upsertCastMember, removeCastMember, setCharacterVoice). Slice initializes with empty tracks and a default narrator. Tests verify initialization, CRUD operations, and reference isolation.
Root store integration and persistence
frontend/src/store/index.ts
AppStore type extended with StoriesSlice. Root store wires in createStoriesSlice. Persistence partialize saves trimmed track shape excluding transient fields.
Cast color and resolution helpers
frontend/src/utils/storyCast.js, frontend/src/utils/storyCast.test.js
Exports color palette, nextCastColor (cyclic picker), effectiveProfile (resolution from per-line override → cast member → null), and castMember (lookup with fallback). Tests verify color cycling, profile priority, and member retrieval.
Audio export pipeline
frontend/src/utils/storyExport.js, frontend/src/utils/storyExport.test.js
Helpers for mono silence buffers, buffer concatenation, and 16-bit PCM WAV encoding. exportStoryAudio orchestrates: parse tracks to segments, fetch TTS chunks via /generate, decode with Web Audio API, concatenate, encode, report progress. Tests validate buffer operations and WAV header structure.
Track reordering utility
frontend/src/utils/storyReorder.js, frontend/src/utils/storyReorder.test.js
Pure reorder(list, fromId, toId) moves element before target id. Early returns for identity or missing ids. Tests verify move behavior and no-op cases.
Internationalization strings
frontend/src/i18n/locales/en.json, frontend/src/i18n/locales/zh-CN.json
New stories namespace with UI text for character/line editing, splitting, generation, voice controls, preview, removal, and export status in English and Chinese.
StoriesEditor component and styling
frontend/src/components/StoriesEditor.jsx, frontend/src/components/StoriesEditor.css
Component now derives tracks and cast from Zustand store. Implements cast-management panel (add/edit/remove with voice assignment), punctuation-aware text splitting, preview via generateSpeech, full export flow via exportStoryAudio with progress. Tracks support drag-and-drop reordering. UI uses i18n text. CSS adds cast panel layout and drag-over highlights.

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely summarizes the main deliverables of Phase 1: real audiobook output, cast management, persistence, reorder functionality, and internationalization support.
Description check ✅ Passed The PR description comprehensively covers the purpose, key features (persistence, cast, real generate, reorder, i18n), testing approach, and implementation notes. Most template sections are addressed with relevant details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/stories-studio

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

...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);

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
frontend/src/store/storiesSlice.test.ts (1)

4-10: 💤 Low value

Test harness passes unused arguments.

The harness() helper passes three arguments to createStoriesSlice, but the slice creator only uses the first (set). The extra get and api arguments 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 value

Add 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

📥 Commits

Reviewing files that changed from the base of the PR and between 762f5bf and 2a8db1f.

📒 Files selected for processing (14)
  • docs/superpowers/specs/2026-05-30-stories-editor-studio-design.md
  • frontend/src/components/StoriesEditor.css
  • frontend/src/components/StoriesEditor.jsx
  • frontend/src/i18n/locales/en.json
  • frontend/src/i18n/locales/zh-CN.json
  • frontend/src/store/index.ts
  • frontend/src/store/storiesSlice.test.ts
  • frontend/src/store/storiesSlice.ts
  • frontend/src/utils/storyCast.js
  • frontend/src/utils/storyCast.test.js
  • frontend/src/utils/storyExport.js
  • frontend/src/utils/storyExport.test.js
  • frontend/src/utils/storyReorder.js
  • frontend/src/utils/storyReorder.test.js

Comment on lines 179 to 190
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Suggested change
<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.

@debpalash
debpalash merged commit 06560e5 into main May 30, 2026
15 checks passed
@debpalash
debpalash deleted the feat/stories-studio branch May 30, 2026 16:30
@greptile-apps

greptile-apps Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR delivers Phase 1 of the pro-studio Stories Editor: zustand-persisted project state (tracks + cast), an editable Cast panel with per-character voice inheritance, real WAV audiobook export via Web Audio API stitching, native HTML5 drag-to-reorder, and full i18n coverage across en and zh-CN.

  • Export pipeline (storyExport.js): pure silenceBuffer / concatBuffers / encodeWav helpers stitch per-line /generate WAV chunks into a single PCM download; correctly closes AudioContext in a finally block and strips transient fields from localStorage on persist.
  • HTTP error check regression in fetchChunkBlob: the if (!res.ok) throw guard present in the original fetchChunkAudio was dropped during the refactor, so any non-2xx response from /generate reaches decodeAudioData() or new Audio() undetected, producing opaque downstream errors instead of a clear failure message.
  • Store version stays at 4 despite new persisted fields (storyTracks, cast), leaving no explicit migration seam for edge cases.

Confidence Score: 3/5

The core export path has a regression where HTTP errors from /generate are silently swallowed, yielding cryptic decode failures instead of actionable messages; the fix is a one-liner but affects both the preview and full-export flows.

The refactor that split fetchChunkAudio into fetchChunkBlob + fetchChunkAudio dropped the res.ok guard that was deliberately present in the original. Every export and every preview that hits a non-2xx backend response will now fail with an opaque error or silently produce no audio. The remaining findings (stereo truncation, version seam) are non-blocking quality concerns. The new slice, cast helpers, reorder logic, WAV encoder, and i18n additions are all clean and well-tested.

frontend/src/components/StoriesEditor.jsx (fetchChunkBlob missing res.ok check) and frontend/src/utils/storyExport.js (silent stereo-to-mono truncation in concatBuffers)

Important Files Changed

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
Loading

Comments Outside Diff (1)

  1. frontend/src/store/index.ts, line 97-114 (link)

    P2 Persist version not bumped for new persisted fields

    storyTracks and cast are now included in partialize, but the store version stays at 4. Zustand's migrate runs 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 persisted storyTracks/cast in a different shape (e.g., a prototype from an earlier branch), those users would silently rehydrate invalid data with no sanitisation step. Bumping to version: 5 with a pass-through migration gives a clean migration seam and ensures the partialize shape is always matched.

    Fix in Claude Code

Fix All in Claude Code

Reviews (1): Last reviewed commit: "feat(stories): Phase 1 — real audiobook ..." | Re-trigger Greptile

Comment on lines 456 to +467
@@ -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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 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.

Fix in Claude Code

Comment on lines +159 to 166
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();
}, []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 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.

Suggested change
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();
}, []);

Fix in Claude Code

Comment on lines +64 to +72
* @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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 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.

Fix in Claude Code

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.

2 participants