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.