Merge pull request #1908 from psiberfunk/codex/fix-synthesis-progress-indeterminate
fix(studio): show honest synthesis progress
This commit is contained in:
@@ -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!
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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%',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user