feat(stories): Pro Studio Phase 3 — per-line tone tags + speed - #179
Conversation
Click the tune button on any line to reveal a drawer:
- Tone chips insert OmniVoice's native inline emotion/sound tags ([laughter],
[sigh], [question-en], [surprise-wa], [confirmation-en], [dissatisfaction-hnn])
at the cursor — the model-native way to direct tone (not the instruct param,
which only whitelists gender/age/pitch/style/accent and rejects free emotion).
- Per-line speed slider (0.5–2.0x) → threaded into /generate for both preview
and the audiobook export; reset-to-default.
- insertToken extracted to storyTokens (pure + tested); insertPauseInto + tone
chips share it. exportStoryAudio now resolves per-track {profileId, speed}.
- i18n (en + zh-CN); 4 new unit tests. Full suite 143/143, typecheck/build/CJK ✓.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR extends the StoriesEditor with per-track tone tag insertion and playback speed control. It introduces a reusable ChangesPer-Line Tone and Speed Controls
🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
🚥 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 |
|
| Filename | Overview |
|---|---|
| frontend/src/components/StoriesEditor.jsx | Adds expandedLine state, tune drawer with tone chips and speed slider; refactors insertPauseInto into a shared insertTokenInto. Reset button has a minor no-op UX issue at speed=1.0. |
| frontend/src/utils/storyExport.js | resolveProfile renamed to resolveOpts returning {profileId, speed}; per-chunk speed correctly threaded to fetchChunkBlob. Logic is sound and backwards-compatible via the |
| frontend/src/utils/storyTokens.js | Adds pure insertToken helper extracted from the old inline insertPauseInto. Logic is correct; tone tags intentionally pass through TOKEN_RE unmodified. |
| frontend/src/utils/storyTokens.test.js | Four new tests cover the main insertToken paths; the out-of-bounds caret branch (caret > text.length) is untested. |
| frontend/src/i18n/locales/en.json | Adds tune, speed, reset, and tones sub-keys. Keys align with usage in StoriesEditor. |
| frontend/src/i18n/locales/zh-CN.json | Mirrors en.json additions with appropriate Chinese translations. No issues found. |
| frontend/src/components/StoriesEditor.css | Adds drawer, tone chip, speed slider, and reset button styles; adds flex-wrap to .stories-track to support the full-width drawer row. Looks correct. |
Sequence Diagram
sequenceDiagram
participant User
participant StoriesEditor
participant storyTokens
participant storyExport
participant TTS as /generate (TTS API)
User->>StoriesEditor: Click tune button on a line
StoriesEditor->>StoriesEditor: setExpandedLine(track.id)
User->>StoriesEditor: Click tone chip (e.g. [laughter])
StoriesEditor->>storyTokens: insertToken(text, caret, "[laughter]")
storyTokens-->>StoriesEditor: updated text
StoriesEditor->>StoriesEditor: updateTrack(id, 'text', ...)
User->>StoriesEditor: Drag speed slider (e.g. 1.5x)
StoriesEditor->>StoriesEditor: updateTrack(id, 'speed', 1.5)
User->>StoriesEditor: Click Preview
StoriesEditor->>TTS: "fetchChunkAudio(text, profileId, speed=1.5)"
TTS-->>StoriesEditor: audio blob
StoriesEditor->>User: plays audio
User->>StoriesEditor: Click Generate All
StoriesEditor->>storyExport: exportStoryAudio(tracks, resolveOpts, fetchChunkBlob, onProgress)
loop each track segment
storyExport->>TTS: fetchChunkBlob(text, profileId, speed)
TTS-->>storyExport: WAV blob
storyExport->>storyExport: decodeAudioData + concat
end
storyExport-->>StoriesEditor: WAV Blob
StoriesEditor->>User: download story.wav
Reviews (1): Last reviewed commit: "feat(stories): Phase 3 — per-line studio..." | Re-trigger Greptile
| {track.speed != null && ( | ||
| <button type="button" className="stories-track__reset" onClick={() => updateTrack(track.id, 'speed', null)}>{t('stories.reset')}</button> | ||
| )} |
There was a problem hiding this comment.
Reset button appears at speed=1.0 (no-op reset)
track.speed != null will show the Reset button even when the slider is sitting at exactly 1.0 — the same value null || 1 produces. Clicking Reset changes the stored state from 1.0 → null, but the slider display, the toFixed readout, and the audio output are all unchanged. A user who carefully drags back to 1.0 sees a Reset button that does nothing perceivable. Checking track.speed != null && track.speed !== 1 would hide the button when it's a no-op.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| it('handles empty text', () => { | ||
| expect(insertToken('', null, '[laughter]')).toBe('[laughter]'); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
The
insertToken test suite exercises the null caret and caret=0 branches but never the caret > t.length branch. Per the implementation this falls through to the append path — worth a covering case so that branch is explicitly verified.
| it('handles empty text', () => { | |
| expect(insertToken('', null, '[laughter]')).toBe('[laughter]'); | |
| }); | |
| }); | |
| it('handles empty text', () => { | |
| expect(insertToken('', null, '[laughter]')).toBe('[laughter]'); | |
| }); | |
| it('appends when caret is beyond text length', () => { | |
| expect(insertToken('hi', 99, '[sigh]')).toBe('hi [sigh]'); | |
| }); | |
| }); |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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.css`:
- Around line 382-396: The .stories-track is a grid container so
.stories-track__drawer won’t span the full row using flex properties; update the
drawer to explicitly span the grid by setting grid-column: 1 / -1 (or
grid-column: span <number> as appropriate) on .stories-track__drawer and
remove/replace ineffective flex-basis/flex-wrap assumptions; keep
.stories-track--dragover unchanged except for any needed visual adjustments.
🪄 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: 5718a20b-2161-401e-8b3a-3b8ab6fe46c9
📒 Files selected for processing (7)
frontend/src/components/StoriesEditor.cssfrontend/src/components/StoriesEditor.jsxfrontend/src/i18n/locales/en.jsonfrontend/src/i18n/locales/zh-CN.jsonfrontend/src/utils/storyExport.jsfrontend/src/utils/storyTokens.jsfrontend/src/utils/storyTokens.test.js
| .stories-track { cursor: grab; flex-wrap: wrap; } | ||
| .stories-track--dragover { box-shadow: inset 0 2px 0 0 var(--color-accent, #b8bb26); } | ||
|
|
||
| /* ── Per-line tone/speed drawer ───────────────────────────────────── */ | ||
| .stories-track__btn--on { color: var(--color-accent); background: rgba(255, 255, 255, 0.06); } | ||
| .stories-track__drawer { | ||
| flex-basis: 100%; | ||
| display: flex; | ||
| flex-wrap: wrap; | ||
| align-items: center; | ||
| gap: 12px; | ||
| margin-top: 8px; | ||
| padding-top: 8px; | ||
| border-top: 1px solid var(--color-border); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Does flex-wrapapply to CSS Grid containers, and isgrid-column: 1 / -1 the correct way to make a grid item span the full row?
💡 Result:
The flex-wrap property does not apply to CSS Grid containers [1][2]. The flex-wrap property is specific to the Flexbox layout model and controls whether flex items wrap onto multiple lines within a flex container [1][2]. CSS Grid and Flexbox are distinct layout systems; Grid handles two-dimensional layouts, while Flexbox handles one-dimensional layouts [3][1]. Regarding your second question, yes, grid-column: 1 / -1 is the standard and correct way to make a grid item span the full width of the grid container [4]. The value 1 refers to the first grid line, and -1 refers to the last grid line of the explicit grid, effectively spanning the item across all available columns [4][5].
Citations:
- 1: https://dev.to/armorbreak/css-layout-in-2026-flexbox-grid-and-when-to-use-each-2026-155c
- 2: https://tailwindcss.com/docs/flex-wrap
- 3: https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout/Relationship_with_other_layout_methods
- 4: https://stackoverflow.com/questions/42239778/items-that-span-all-columns-rows-using-css-grid-layout
- 5: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/grid-column
🌐 Web query:
Does flex-basis apply to items in CSS Grid layout (i.e., grid items), or is it only relevant for flex items?
💡 Result:
No—flex-basis is for flex layout only, not CSS Grid. flex-basis sets the “initial main size of a flex item” and “applies to flex items” [1]. The CSS Flexible Box spec defines flex-basis as part of Flexbox’s layout algorithm for flex items [2]. Therefore, if an element is a Grid item (i.e., it is laid out by a parent with display: grid), flex-basis does not participate in Grid track sizing; Grid uses grid-specific sizing properties (e.g., grid-template-columns/minmax(), fr units) rather than flex-basis. What you should use in Grid instead: put your constraints in the track definitions, e.g. grid-template-columns: repeat(auto-fill, minmax(<min-track>, <max-track>)) and use functions like min()/clamp() (or viewport-relative units) to achieve responsive minimums—rather than trying to apply flex-basis to Grid items [3].
Citations:
- 1: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/flex-basis
- 2: https://stackoverflow.com/questions/67113156/css-grid-with-flex-basis
- 3: https://stackoverflow.com/questions/34352140/what-are-the-differences-between-flex-basis-and-width
🏁 Script executed:
#!/bin/bash
set -euo pipefail
file="frontend/src/components/StoriesEditor.css"
echo "== Matching selectors around .stories-track =="
rg -n "\.stories-track(\s|\{)" "$file" || true
echo
echo "== Lines near the mentioned snippet (350-420) =="
nl -ba "$file" | sed -n '350,430p'Repository: debpalash/OmniVoice-Studio
Length of output: 303
🏁 Script executed:
#!/bin/bash
set -euo pipefail
file="frontend/src/components/StoriesEditor.css"
echo "== Lines around first .stories-track definition =="
sed -n '40,90p' "$file"
echo
echo "== Lines near the mentioned snippet (370-410) =="
cat -n "$file" | sed -n '370,420p'Repository: debpalash/OmniVoice-Studio
Length of output: 3385
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n "stories-track__drawer" -S frontend/src || true
rg -n "\bstories-track\b" -S frontend/src/components | head -n 200 || trueRepository: debpalash/OmniVoice-Studio
Length of output: 1234
🏁 Script executed:
#!/bin/bash
set -euo pipefail
file="frontend/src/components/StoriesEditor.jsx"
# Show context around the drawer render and the track row container
rg -n "stories-track__drawer" "$file"
echo "---- context ----"
sed -n '470,560p' "$file"Repository: debpalash/OmniVoice-Studio
Length of output: 5588
Fix grid layout so the tone/speed drawer spans the full track row (frontend/src/components/StoriesEditor.css, lines ~382-396)
.stories-track is display: grid, so flex-wrap on the grid container has no effect, and .stories-track__drawer (a grid item) won’t span the row just via flex-basis: 100%—it will land in a single grid cell. Set grid spanning explicitly.
💡 Suggested fix
-.stories-track { cursor: grab; flex-wrap: wrap; }
+.stories-track { cursor: grab; }
.stories-track__drawer {
- flex-basis: 100%;
+ grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--color-border);
}🤖 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.css` around lines 382 - 396, The
.stories-track is a grid container so .stories-track__drawer won’t span the full
row using flex properties; update the drawer to explicitly span the grid by
setting grid-column: 1 / -1 (or grid-column: span <number> as appropriate) on
.stories-track__drawer and remove/replace ineffective flex-basis/flex-wrap
assumptions; keep .stories-track--dragover unchanged except for any needed
visual adjustments.
Phase 3: studio depth per line, revealed only when you click the tune button (progressive disclosure).
[laughter],[sigh],[question-en],[surprise-wa],[confirmation-en],[dissatisfaction-hnn]) at the cursor. (Theinstructparam only whitelists gender/age/pitch/style/accent and rejects free emotion words — issues [Bug] Bad request - conflicting instruct items within the same category #114/Voice Design generates unsupported instructions on macOS Apple Silicon #115 — so tags are the correct mechanism.)/generatefor both preview and the audiobook export, with reset-to-default.insertTokenin storyTokens (shared by pause + tones);exportStoryAudioresolves per-track{profileId, speed}.Next: Phase 4 (pro output — per-character stems, chapters, MP3 — + named projects).
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Style
Internationalization
Tests