fix(ui): dub editor play button no longer sticks disabled; remove donate heart from nav rail (#1019)

WaveformTimeline's play button (disabled={!ready}) stayed permanently
disabled whenever the initial WaveSurfer decode failed and the
component fell back to loading pre-computed peaks. The waveform still
rendered fine from those peaks (nothing looked visibly broken), but
`ready` was only ever flipped by the 'ready' event re-firing on that
recovery load — which this component's own error-handling never
actually confirmed, just assumed. Each of the three fallback ws.load()
calls now explicitly confirms readiness once it settles (via .then()/
.catch(), or the existing synchronous-throw catch), instead of hoping
the event fires again.

Regression test: WaveformTimeline.readyFallback.test.js — a
source-level contract guard (driving a real decode-failure/recovery
sequence through jsdom is brittle, same house pattern as the sibling
WaveformTimeline.unlock.test.js) asserting every fallback load in the
error handler is followed by an explicit setReady(true).

Also removes the "Support OmniVoice" heart button from NavRail — the
donate page stays reachable from Settings' footer and the Contact page.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-09 02:09:54 +05:30
committed by GitHub
co-authored by mergetest Claude Fable 5
parent 33379890ad
commit 93a3cb260a
4 changed files with 65 additions and 30 deletions
+5
View File
@@ -20,6 +20,11 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
- **Voice Gallery errors now say what actually went wrong.** "Use voice", "Preview", search, upload, save, delete, and trim in the Gallery all showed the same hardcoded guess ("the engine may be loading") on ANY failure — a 500, a validation error, a genuinely unrelated bug — discarding the real, already-clean backend error message in the process. Every one of those now shows the actual error.
- **Voice cloning on mlx-audio's CSM model no longer crashes with an opaque "list index out of range".** `MLXAudioBackend.generate()` read `voice`/`ref_audio`/`language`/`speed` from its kwargs but silently dropped `ref_text` — CSM only builds its cloning context when both `ref_audio` and `ref_text` are present, so cloning on this engine could never have worked as shipped. Reported with the exact root cause and a working fix. (#1012, #1013)
- **A dub segment's free-text style tags no longer 400 the segment preview.** A validator-safe instruct builder already keeps Studio and Clone generation from round-tripping a 400 on unsupported free-text (a preset's raw attrs, an old profile's stray descriptive phrase) — but the Dub tab's segment preview, and saving a profile from a clone or from history, built their instruct strings directly and skipped it. Same guard now applies everywhere an instruct string is sent. (#1010)
- **The dub editor's play button no longer sticks permanently disabled after an audio-decode hiccup.** When the initial WaveSurfer decode fails, the timeline falls back to loading pre-computed peaks — the waveform draws fine, but the button's enabled state only relied on the `ready` event firing again for that recovery load, which it didn't reliably do. Each fallback path now confirms readiness explicitly once it settles.
### Changed
- **Removed the donate heart from the nav rail.** Support OmniVoice is still one click away from Settings and the Contact page.
## [0.3.12] — 2026-07-08
-27
View File
@@ -73,9 +73,6 @@ export default function NavRail({ mode, setMode, side = 'left', onFlipSide }) {
[t],
);
const donateLabel = t('donate.pill', { defaultValue: 'Support OmniVoice' });
const donateActive = mode === 'donate';
// `nav-rail` is retained purely as the layout hook the (out-of-scope)
// `.app-container > .nav-rail` grid rules position by; all visual styling now
// lives in the utilities below. Border flips to the inner edge when on the right.
@@ -84,17 +81,6 @@ export default function NavRail({ mode, setMode, side = 'left', onFlipSide }) {
? '[border-left:1px_solid_var(--chrome-border)]'
: '[border-right:1px_solid_var(--chrome-border)]';
// Quiet "Support" pill (was `.rail-btn.donate-pill`): neutral at rest, warms to
// the accent on hover/active.
const donateState = donateActive
? 'text-[var(--chrome-accent)] bg-[var(--chrome-accent-bg)] [border:1px_solid_var(--chrome-accent-border)]'
: 'bg-transparent text-[var(--chrome-fg-dim)] [border:1px_solid_transparent] hover:bg-[color-mix(in_srgb,var(--chrome-accent)_10%,transparent)] hover:text-[var(--chrome-accent)]';
const heartBase =
'text-[16px] leading-none [transition:filter_0.16s,opacity_0.16s,transform_0.16s] group-hover:[transform:scale(1.1)] motion-reduce:[transition:none] motion-reduce:group-hover:[transform:none]';
const heartState = donateActive
? 'opacity-100 [filter:grayscale(0)]'
: 'opacity-75 [filter:grayscale(0.55)] group-hover:opacity-100 group-hover:[filter:grayscale(0)]';
return (
<aside
className={`nav-rail z-50 flex select-none flex-col items-center gap-[6px] bg-[var(--chrome-bg)] py-[8px] ${asideBorder}`}
@@ -111,19 +97,6 @@ export default function NavRail({ mode, setMode, side = 'left', onFlipSide }) {
))}
</div>
<div className="flex flex-col items-center gap-[4px]">
{/* Quiet "Support" pill — warms to the accent on hover, opens the
donate page. Sits with the footer nav (Settings / flip). (#007) */}
<button
onClick={() => setMode('donate')}
title={donateLabel}
aria-label={donateLabel}
className={`${RAIL_BTN_BASE} ${donateState}`}
>
<span className={`${heartBase} ${heartState}`} aria-hidden="true">
🩷
</span>
<span className={railLabelCls(side)}>{donateLabel}</span>
</button>
{footerItems.map((it) => (
<RailBtn
key={it.id}
+17 -3
View File
@@ -351,7 +351,14 @@ function WaveformTimeline(
console.warn('WebKit audio decode not supported, using media element directly');
try {
const emptyPeaks = new Float32Array(1000).fill(0);
ws.load(undefined, [emptyPeaks], mediaEl.duration || 60);
// Don't rely solely on the 'ready' event firing again for this
// recovery load — the play button stayed permanently disabled
// when it didn't (the waveform still rendered from the peaks, so
// there was no visible sign anything was wrong). Confirm
// readiness explicitly once this load settles either way.
Promise.resolve(ws.load(undefined, [emptyPeaks], mediaEl.duration || 60))
.then(() => setReady(true))
.catch(() => setReady(true));
} catch (_) {
setReady(true);
}
@@ -372,7 +379,12 @@ function WaveformTimeline(
})
.then((audioBuffer) => {
const channelData = audioBuffer.getChannelData(0);
ws.load(undefined, [channelData], audioBuffer.duration);
// Same explicit-readiness guard as the NotSupportedError branch
// above — don't depend on the 'ready' event re-firing for this
// manually-decoded recovery load.
Promise.resolve(ws.load(undefined, [channelData], audioBuffer.duration))
.then(() => setReady(true))
.catch(() => setReady(true));
})
.catch((decodeErr) => {
// HTTP 404 on the companion audio means the source file is
@@ -391,7 +403,9 @@ function WaveformTimeline(
console.warn('Audio decode fallback failed, loading with empty peaks:', decodeErr);
try {
const emptyPeaks = new Float32Array(1000).fill(0);
ws.load(undefined, [emptyPeaks], mediaEl.duration || 60);
Promise.resolve(ws.load(undefined, [emptyPeaks], mediaEl.duration || 60))
.then(() => setReady(true))
.catch(() => setReady(true));
} catch (_) {
setLoadError(true);
}
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
// Regression guard: the dub editor's play button stayed permanently disabled
// (disabled={!ready}) whenever the initial WaveSurfer decode failed and the
// component fell back to a peaks-only ws.load(undefined, [peaks], duration)
// call — the waveform still rendered from those peaks (so nothing looked
// visibly broken), but `ready` was only ever set from the 'ready' event
// re-firing on that recovery load, which this component's own error-handling
// code never actually confirmed. Each fallback load must now explicitly
// confirm readiness once it settles, instead of assuming the event fires.
//
// Driving WaveSurfer + a real decode-failure/recovery sequence through jsdom
// is brittle (see WaveformTimeline.unlock.test.js), so this is a
// source-level contract guard, same house pattern: every `ws.load(undefined,
// ...)` recovery call inside the `ws.on('error', ...)` handler must be
// followed by an explicit setReady(true) confirmation.
const src = readFileSync(
path.resolve(process.cwd(), 'src/components/WaveformTimeline.jsx'),
'utf8',
);
describe('WaveformTimeline error-recovery ready confirmation', () => {
it("confirms readiness explicitly after every fallback ws.load() call, not just via the 'ready' event", () => {
const errorHandler = /ws\.on\('error', \(err\) => \{([\s\S]*?)\n \}\);/.exec(src)?.[1];
expect(errorHandler, "ws.on('error', ...) handler not found").toBeTruthy();
// Every recovery load in this handler passes peaks explicitly
// (`ws.load(undefined, [...], ...)`) — each occurrence must be
// immediately confirmed ready via a .then()/.catch() pair (or an
// unconditional setReady in a synchronous catch), not left to hope the
// 'ready' event re-fires on its own.
const loadCalls = [...errorHandler.matchAll(/ws\.load\(undefined, \[[^\]]*\][^)]*\)/g)];
expect(loadCalls.length).toBeGreaterThanOrEqual(3);
for (const match of loadCalls) {
const tail = errorHandler.slice(match.index, match.index + 220);
expect(tail, `no readiness confirmation after: ${match[0]}`).toMatch(/setReady\(true\)/);
}
});
});