Merge pull request #1908 from psiberfunk/codex/fix-synthesis-progress-indeterminate

fix(studio): show honest synthesis progress
This commit is contained in:
Palash Debnath
2026-09-09 12:22:07 -07:00
committed by GitHub
8 changed files with 129 additions and 13 deletions
+1
View File
@@ -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!
+2
View File
@@ -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}
+2 -6
View File
@@ -62,6 +62,7 @@ export default function ActionBar({
isGenerating,
handleGenerate,
generationTime,
generationProgress,
wasGeneratingRef,
}) {
return (
@@ -288,12 +289,7 @@ export default function ActionBar({
</Button>
)}
{isGenerating && (
<Progress
value={Math.min((generationTime / 8) * 100, 95)}
tone="brand"
size="sm"
className="mt-[6px]"
/>
<Progress value={generationProgress} tone="brand" size="sm" className="mt-[6px]" />
)}
{/* 10x P4 a11y (spec §3): persistent polite live region — screen
readers hear generation start AND finish in-workspace, without
@@ -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(
<ActionBar
{...baseProps}
showOverrides={false}
setShowOverrides={setter}
isGenerating
generationTime="10.6"
generationProgress={null}
/>,
);
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(
<ActionBar
{...baseProps}
showOverrides={false}
setShowOverrides={setter}
isGenerating
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%',
});
});
});
+9 -6
View File
@@ -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 0100 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,
+2
View File
@@ -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}
/>
)}
@@ -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 }],
+65 -1
View File
@@ -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.