diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5086f728..3c271653 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,6 +55,7 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
+- Voice synthesis progress no longer races to a fabricated 95%; it stays indeterminate until the active generation path reports real progress (#1907) — thanks @psiberfunk!
- Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang!
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index e26eab75..fde9a0f2 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -399,6 +399,7 @@ function App() {
setPendingTrimFile,
isGenerating,
generationTime,
+ generationProgress,
textAreaRef,
ingestRefAudio,
insertTag,
@@ -1748,6 +1749,7 @@ function App() {
setVdStates={setVdStates}
isGenerating={isGenerating}
generationTime={generationTime}
+ generationProgress={generationProgress}
applyPreset={applyPreset}
insertTag={insertTag}
handleSelectProfile={handleSelectProfile}
diff --git a/frontend/src/components/clone/ActionBar.jsx b/frontend/src/components/clone/ActionBar.jsx
index 4a20853d..fd423dcc 100644
--- a/frontend/src/components/clone/ActionBar.jsx
+++ b/frontend/src/components/clone/ActionBar.jsx
@@ -62,6 +62,7 @@ export default function ActionBar({
isGenerating,
handleGenerate,
generationTime,
+ generationProgress,
wasGeneratingRef,
}) {
return (
@@ -288,12 +289,7 @@ export default function ActionBar({
)}
{isGenerating && (
-
+
)}
{/* 10x P4 a11y (spec §3): persistent polite live region — screen
readers hear generation start AND finish in-workspace, without
diff --git a/frontend/src/components/clone/ActionBar.test.jsx b/frontend/src/components/clone/ActionBar.test.jsx
index 1541ce2c..411a5b29 100644
--- a/frontend/src/components/clone/ActionBar.test.jsx
+++ b/frontend/src/components/clone/ActionBar.test.jsx
@@ -35,6 +35,7 @@ const baseProps = {
isGenerating: false,
handleGenerate: setter,
generationTime: 0,
+ generationProgress: null,
wasGeneratingRef: { current: false },
};
@@ -97,4 +98,40 @@ describe('ActionBar', () => {
fireEvent.click(screen.getByRole('button', { name: /clone.production_overrides/ }));
expect(screen.getByRole('slider', { name: 'clone.steps' })).toBeInTheDocument();
});
+
+ it('shows indeterminate progress instead of inventing a percentage from elapsed time', () => {
+ render(
+ ,
+ );
+
+ const progress = screen.getByRole('progressbar');
+ expect(progress).not.toHaveAttribute('aria-valuenow');
+ expect(progress).toHaveAttribute('data-state', 'indeterminate');
+ });
+
+ it('shows determinate progress only when the generation path reports it', () => {
+ render(
+ ,
+ );
+
+ const progress = screen.getByRole('progressbar');
+ expect(progress).toHaveAttribute('aria-valuenow', '42');
+ expect(progress.querySelector('[data-slot="progress-indicator"]')).toHaveStyle({
+ width: '42%',
+ });
+ });
});
diff --git a/frontend/src/hooks/useTTS.js b/frontend/src/hooks/useTTS.js
index 6dc22030..b67ea123 100644
--- a/frontend/src/hooks/useTTS.js
+++ b/frontend/src/hooks/useTTS.js
@@ -70,6 +70,9 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
const [pendingTrimFile, setPendingTrimFile] = useState(null);
const [isGenerating, setIsGenerating] = useState(false);
const [generationTime, setGenerationTime] = useState(0);
+ // Real 0–100 progress when the active delivery path can measure it.
+ // null means the backend has not supplied a meaningful fraction yet.
+ const [generationProgress, setGenerationProgress] = useState(null);
const timerRef = useRef(null);
const textAreaRef = useRef(null);
@@ -126,13 +129,11 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
addBreadcrumb(`generate:start (${defineMethod})`);
setIsGenerating(true);
setGenerationTime(0);
+ setGenerationProgress(null);
const st = Date.now();
timerRef.current = setInterval(() => {
const elapsed = ((Date.now() - st) / 1000).toFixed(1);
- setGenerationTime((prev) => {
- const suffix = /\(\d+%\)$/.exec(String(prev))?.[0];
- return suffix ? `${elapsed} ${suffix}` : elapsed;
- });
+ setGenerationTime(elapsed);
}, 100);
let abortTimer = null;
try {
@@ -292,8 +293,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
}
}
};
- const setProgressPct = (pct) =>
- setGenerationTime((prev) => `${prev.toString().split(' ')[0]} (${pct}%)`);
+ const setProgressPct = (pct) => setGenerationProgress(pct);
// Streaming preview (feat: streaming-tts-preview): playback starts from
// the FIRST synthesized chunk while the rest is still rendering, via
@@ -355,6 +355,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
'Streaming preview failed mid-stream; falling back to the classic generate:',
err?.message || err,
);
+ setGenerationProgress(null);
addBreadcrumb('generate:stream-fallback');
}
}
@@ -410,6 +411,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
if (abortTimer) clearTimeout(abortTimer);
clearInterval(timerRef.current);
setIsGenerating(false);
+ setGenerationProgress(null);
}
}, [
text,
@@ -444,6 +446,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
setPendingTrimFile,
isGenerating,
generationTime,
+ generationProgress,
textAreaRef,
ingestRefAudio,
insertTag,
diff --git a/frontend/src/pages/CloneDesignTab.jsx b/frontend/src/pages/CloneDesignTab.jsx
index 816011c7..19c7254b 100644
--- a/frontend/src/pages/CloneDesignTab.jsx
+++ b/frontend/src/pages/CloneDesignTab.jsx
@@ -73,6 +73,7 @@ export default function CloneDesignTab(props) {
setVdStates,
isGenerating,
generationTime,
+ generationProgress,
applyPreset,
insertTag,
handleSaveProfile,
@@ -595,6 +596,7 @@ export default function CloneDesignTab(props) {
isGenerating={isGenerating}
handleGenerate={handleGenerate}
generationTime={generationTime}
+ generationProgress={generationProgress}
wasGeneratingRef={wasGeneratingRef}
/>
)}
diff --git a/frontend/src/pages/CloneDesignTab.test.jsx b/frontend/src/pages/CloneDesignTab.test.jsx
index 7311d4b9..a8ce1a18 100644
--- a/frontend/src/pages/CloneDesignTab.test.jsx
+++ b/frontend/src/pages/CloneDesignTab.test.jsx
@@ -96,6 +96,7 @@ function baseProps(overrides = {}) {
setVdStates: NOOP,
isGenerating: false,
generationTime: 0,
+ generationProgress: null,
applyPreset: NOOP,
insertTag: NOOP,
handleSaveProfile: NOOP,
@@ -121,6 +122,16 @@ function renderDesignTab(overrides = {}) {
}
describe('CloneDesignTab — Voice Design panel redesign regressions', () => {
+ it('forwards measurable generation progress to the action bar', () => {
+ renderDesignTab({ isGenerating: true, generationTime: '10.6', generationProgress: 42 });
+
+ const progress = screen.getByRole('progressbar');
+ expect(progress).toHaveAttribute('aria-valuenow', '42');
+ expect(progress.querySelector('[data-slot="progress-indicator"]')).toHaveStyle({
+ width: '42%',
+ });
+ });
+
it.each([
['recording is active', { isRecording: true }],
['microphone startup is pending', { isStartingRecording: true }],
diff --git a/frontend/src/test/useTTSAutoplayPref.test.jsx b/frontend/src/test/useTTSAutoplayPref.test.jsx
index 4e2dcd6c..4d34ee8a 100644
--- a/frontend/src/test/useTTSAutoplayPref.test.jsx
+++ b/frontend/src/test/useTTSAutoplayPref.test.jsx
@@ -1,9 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { renderHook, act } from '@testing-library/react';
+import { renderHook, act, waitFor } from '@testing-library/react';
import useTTS from '../hooks/useTTS';
import { useAppStore } from '../store';
import { playBlobAudio } from '../utils/media';
import {
+ StreamingPreviewError,
resolveRemoteTtsTarget,
streamGenerateSpeech,
supportsStreamingPreview,
@@ -129,6 +130,69 @@ describe('useTTS delivery path vs the chosen GPU', () => {
expect(generateSpeech).not.toHaveBeenCalled();
});
+ it('keeps real stream progress separate from the elapsed timer', async () => {
+ let release;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ vi.mocked(streamGenerateSpeech).mockImplementationOnce(async (_formData, { onProgress }) => {
+ onProgress(42);
+ await gate;
+ return { id: 'x', audio_path: 'x.wav' };
+ });
+
+ const { result } = renderHook(() => useTTS(hookProps()));
+ let generation;
+ act(() => {
+ generation = result.current.handleGenerate();
+ });
+
+ await waitFor(() => expect(result.current.generationProgress).toBe(42));
+ expect(String(result.current.generationTime)).not.toContain('%');
+
+ await act(async () => {
+ release();
+ await generation;
+ });
+ expect(result.current.generationProgress).toBeNull();
+ });
+
+ it('clears stale stream progress before a classic fallback', async () => {
+ let releaseClassic;
+ const classicGate = new Promise((resolve) => {
+ releaseClassic = resolve;
+ });
+ vi.mocked(streamGenerateSpeech).mockImplementationOnce(async (_formData, { onProgress }) => {
+ onProgress(42);
+ throw new StreamingPreviewError('stream transport dropped');
+ });
+ vi.mocked(generateSpeech).mockImplementationOnce(async () => ({
+ body: {
+ getReader: () => ({
+ read: async () => {
+ await classicGate;
+ return { done: true, value: undefined };
+ },
+ }),
+ },
+ headers: { get: () => null },
+ }));
+
+ const { result } = renderHook(() => useTTS(hookProps()));
+ let generation;
+ act(() => {
+ generation = result.current.handleGenerate();
+ });
+
+ await waitFor(() => expect(generateSpeech).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(result.current.generationProgress).toBeNull());
+
+ await act(async () => {
+ releaseClassic();
+ await generation;
+ });
+ });
+
it('takes the classic path when the resolved target is a worker', async () => {
// Streaming would have rendered here — a local job wearing the badge of
// the 4090 the user picked. The classic path is the one that goes remote.