diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ac5bcb..d300700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.14.16 + +- Home no longer suggests an unsafe terminal command to remove old Echo copies. Use the existing **Remove old copies** action. +- Home disables the recording control during transcription and text insertion, preventing accidental cancellation while speech is processed. +- Microphone discovery and selection now run on a worker so audio-device enumeration does not block the desktop UI. +- Speech benchmark memory measurements now include child processes started by worker threads. + ## v0.14.15 - Dictionary replacements now preserve shorter valid matches when a longer overlapping phrase takes priority. Adding unrelated dictionary entries no longer changes which replacements apply. diff --git a/Cargo.lock b/Cargo.lock index db000f3..864d5c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1166,7 +1166,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "echo" -version = "0.14.15" +version = "0.14.16" dependencies = [ "bzip2", "cpal", @@ -1190,7 +1190,7 @@ dependencies = [ [[package]] name = "echo-core" -version = "0.14.15" +version = "0.14.16" dependencies = [ "caseless", "fs2", @@ -1204,7 +1204,7 @@ dependencies = [ [[package]] name = "echo-desktop" -version = "0.14.15" +version = "0.14.16" dependencies = [ "ashpd", "clap", @@ -1223,7 +1223,7 @@ dependencies = [ [[package]] name = "echo-ipc" -version = "0.14.15" +version = "0.14.16" dependencies = [ "echo", "echo-core", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "echo-ipc-gen" -version = "0.14.15" +version = "0.14.16" dependencies = [ "echo-ipc", ] @@ -6087,7 +6087,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.14.15" +version = "0.14.16" dependencies = [ "png 0.18.1", "resvg", diff --git a/Cargo.toml b/Cargo.toml index d456ffc..dc19c81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" license = "MIT" repository = "https://github.com/ddv1982/echo" rust-version = "1.89" -version = "0.14.15" +version = "0.14.16" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/frontend/e2e/home-processing.spec.ts b/frontend/e2e/home-processing.spec.ts new file mode 100644 index 0000000..f283be5 --- /dev/null +++ b/frontend/e2e/home-processing.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test' + +test('the preview blocks a second toggle while transcribing, then accepts the next recording', async ({ page }) => { + await page.goto('/') + + const start = page.getByRole('button', { name: 'Start recording' }) + await expect(start).toBeEnabled() + await start.click() + + const stop = page.getByRole('button', { name: 'Stop and transcribe' }) + await expect(stop).toBeEnabled() + await stop.click() + + const processing = page.getByRole('button', { name: 'Processing recording' }) + await expect(processing).toBeDisabled() + await expect(page.getByRole('heading', { name: 'Transcribing locally…' })).toBeVisible() + const retry = page.getByRole('button', { name: 'Start recording' }) + await expect(retry).toBeEnabled({ timeout: 2_000 }) + await retry.click() + await expect(page.getByRole('button', { name: 'Stop and transcribe' })).toBeEnabled() +}) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1a1f1e5..2ad4395 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -30,6 +30,7 @@ import { stopRecording, testInputDevice, testMicrophoneFallback, + toggleRecording, } from './tauri' import type { AppStatus, @@ -79,6 +80,7 @@ vi.mock('./tauri', async (importOriginal) => { stopRecording: vi.fn((activation: string) => actual.stopRecording(activation)), testInputDevice: vi.fn((id: string | null) => actual.testInputDevice(id)), testMicrophoneFallback: vi.fn(() => actual.testMicrophoneFallback()), + toggleRecording: vi.fn(() => actual.toggleRecording()), } }) @@ -148,6 +150,8 @@ describe('Echo desktop shell', () => { vi.mocked(retryShortcut).mockImplementation(() => actual.retryShortcut()) vi.mocked(stopRecording).mockReset() vi.mocked(stopRecording).mockImplementation((activation) => actual.stopRecording(activation)) + vi.mocked(toggleRecording).mockReset() + vi.mocked(toggleRecording).mockImplementation(() => actual.toggleRecording()) vi.mocked(getSettings).mockReset() vi.mocked(getSettings).mockImplementation(() => actual.getSettings()) vi.mocked(getMicrophones).mockReset() @@ -299,6 +303,90 @@ describe('Echo desktop shell', () => { expect(screen.getByPlaceholderText('Search transcripts…')).toBeInTheDocument() }) + it.each([ + ['Transcribing', 'Transcribing locally…', 'Whisper · small · VAD on is turning your recording into text.'], + ['Injecting', 'Inserting transcript…', 'ydotool · Wayland is sending your transcript to the active app.'], + ] satisfies Array<[AppStatus['phase'], string, string]>)( + 'prevents a second recording toggle while %s', + async (phase, heading, description) => { + seedPreviewStatus({ phase, recordingInProcess: false }) + render() + + const orb = await screen.findByRole('button', { name: 'Processing recording' }) + expect(orb).toBeDisabled() + expect(screen.getByRole('heading', { name: heading })).toBeInTheDocument() + expect(screen.getByText(description)).toBeInTheDocument() + fireEvent.click(orb) + expect((await previewDesktopApi.getAppStatus()).phase).toBe(phase) + }, + ) + + it.each(['Idle', 'Failed'] as const)( + 'allows recording to start again from %s', + async (phase) => { + seedPreviewStatus({ + phase, + recordingInProcess: false, + }) + render() + + const orb = await screen.findByRole('button', { name: 'Start recording' }) + expect(orb).toBeEnabled() + fireEvent.click(orb) + fireEvent.click(orb) + expect(await screen.findByRole('button', { name: 'Stop and transcribe' })).toBeEnabled() + }, + ) + + it('keeps a successful stop pending through stale statuses and polling errors', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const recording = { ...richPreviewStatus(), phase: 'Recording' } satisfies AppStatus + const transcribing = { ...recording, phase: 'Transcribing', recordingInProcess: false } satisfies AppStatus + const idle = { ...recording, phase: 'Idle', recordingInProcess: false } satisfies AppStatus + vi.mocked(toggleRecording).mockResolvedValueOnce(undefined) + vi.mocked(getAppStatus) + .mockResolvedValueOnce(recording) + .mockResolvedValueOnce(recording) + .mockRejectedValueOnce(new Error('temporary status error')) + .mockResolvedValueOnce(transcribing) + .mockResolvedValueOnce(idle) + try { + render() + const stop = await screen.findByRole('button', { name: 'Stop and transcribe' }) + fireEvent.click(stop) + await act(async () => {}) + expect(toggleRecording).toHaveBeenCalledOnce() + + const stopping = screen.getByRole('button', { name: 'Stopping recording' }) + expect(stopping).toBeDisabled() + fireEvent.click(stopping) + expect(toggleRecording).toHaveBeenCalledOnce() + + await act(async () => vi.advanceTimersByTimeAsync(400)) + expect(screen.getByRole('button', { name: 'Stopping recording' })).toBeDisabled() + await act(async () => vi.advanceTimersByTimeAsync(400)) + expect(screen.getByRole('button', { name: 'Processing recording' })).toBeDisabled() + await act(async () => vi.advanceTimersByTimeAsync(400)) + expect(screen.getByRole('button', { name: 'Start recording' })).toBeEnabled() + } finally { + vi.useRealTimers() + } + }) + + it('releases a rejected stop request for retry', async () => { + const recording = { ...richPreviewStatus(), phase: 'Recording' } satisfies AppStatus + vi.mocked(getAppStatus).mockResolvedValue(recording) + vi.mocked(toggleRecording) + .mockRejectedValueOnce(new Error('stop was rejected')) + .mockResolvedValueOnce(undefined) + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Stop and transcribe' })) + expect(await screen.findByRole('alert')).toHaveTextContent('stop was rejected') + fireEvent.click(screen.getByRole('button', { name: 'Stop and transcribe' })) + await waitFor(() => expect(toggleRecording).toHaveBeenCalledTimes(2)) + }) + it('reports a rejected dictionary entry without clearing the form or leaving it busy', async () => { vi.mocked(addDictionaryEntry).mockRejectedValueOnce(new Error('could not add dictionary entry')) render() @@ -398,14 +486,15 @@ describe('Echo desktop shell', () => { it('warns when a stale install shadows the running binary', async () => { seedPreviewStatus({ currentExe: '/usr/bin/echo-desktop', - firstPathHit: '/home/user/.local/bin/echo-desktop', - staleInstalls: ['/home/user/.local/bin/echo-desktop'], + firstPathHit: '/home/user/.local/bin/echo desktop; keep-me', + staleInstalls: ['/home/user/.local/bin/echo desktop; keep-me'], }) render() await screen.findByRole('button', { name: 'Start recording' }) const warning = await screen.findByRole('alert') - expect(warning).toHaveTextContent('/home/user/.local/bin/echo-desktop') - expect(warning).toHaveTextContent('rm -f /home/user/.local/bin/echo-desktop') + expect(warning).toHaveTextContent('/home/user/.local/bin/echo desktop; keep-me') + expect(warning).not.toHaveTextContent('rm -f') + expect(within(warning).getByRole('button', { name: 'Remove old copies' })).toBeEnabled() }) it('shows no stale-install warning when PATH is clean', async () => { @@ -425,8 +514,7 @@ describe('Echo desktop shell', () => { await screen.findByRole('button', { name: 'Start recording' }) const warning = await screen.findByRole('alert') expect(warning).toHaveTextContent('/home/user/.local/bin/echo-desktop') - // The manual command stays visible as secondary text. - expect(warning).toHaveTextContent('rm -f /home/user/.local/bin/echo-desktop') + expect(warning).not.toHaveTextContent('rm -f') fireEvent.click(within(warning).getByRole('button', { name: 'Remove old copies' })) expect(vi.mocked(removeStaleInstalls)).toHaveBeenCalledTimes(1) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cfb6661..11a549e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,7 @@ function App() { error, setError, recordingSeconds, + stopPending, refreshStatus, toggleRecording, quitApp, @@ -114,6 +115,7 @@ function App() { status={status} history={history} recordingSeconds={recordingSeconds} + stopPending={stopPending} onToggleRecording={toggleRecording} onOpenSettings={() => setView('settings')} /> diff --git a/frontend/src/app/AppHistory.test.tsx b/frontend/src/app/AppHistory.test.tsx index 2c7af8b..9fca2e2 100644 --- a/frontend/src/app/AppHistory.test.tsx +++ b/frontend/src/app/AppHistory.test.tsx @@ -245,6 +245,7 @@ describe('Echo desktop shell', () => { status={richPreviewStatus()} history={history} recordingSeconds={0} + stopPending={false} onToggleRecording={async () => undefined} onOpenSettings={vi.fn()} />, diff --git a/frontend/src/app/useAppController.ts b/frontend/src/app/useAppController.ts index 2a0b0e8..4ad2764 100644 --- a/frontend/src/app/useAppController.ts +++ b/frontend/src/app/useAppController.ts @@ -38,6 +38,8 @@ const initialStatus: AppStatus = { staleInstalls: [], } +type StopState = 'none' | 'requesting' | 'awaiting-status' + export function useAppController() { const [view, setView] = useState('home') const [status, setStatus] = useState(initialStatus) @@ -47,8 +49,11 @@ export function useAppController() { }) const [error, setError] = useState(null) const [recordingStartedAt, setRecordingStartedAt] = useState(null) - const previousPhase = useRef('Idle') + const previousPhase = useRef('Idle') const previousHistoryId = useRef(null) + const toggleInFlight = useRef(false) + const stopStateRef = useRef('none') + const [stopState, setStopState] = useState('none') const recordingSeconds = useElapsedSeconds(recordingStartedAt) const reportError = useCallback((reason: unknown) => setError(messageFrom(reason)), []) const { @@ -67,6 +72,10 @@ export function useAppController() { const applyStatus = useCallback((next: AppStatus) => { setStatus(next) + if (stopStateRef.current === 'awaiting-status' && next.phase !== 'Recording') { + stopStateRef.current = 'none' + setStopState('none') + } const observedAt = Date.now() setRecordingStartedAt((current) => next.phase === 'Recording' ? (current ?? observedAt) : null) @@ -107,11 +116,30 @@ export function useAppController() { }, [view]) const toggle = useCallback(async () => { + const phase = previousPhase.current + const processing = phase === 'Transcribing' || phase === 'Injecting' + if (toggleInFlight.current || stopStateRef.current !== 'none' || processing) return + const stopping = phase === 'Recording' + if (stopping) { + stopStateRef.current = 'requesting' + setStopState('requesting') + } + toggleInFlight.current = true try { await toggleRecording() + if (stopping) { + stopStateRef.current = 'awaiting-status' + setStopState('awaiting-status') + } await refreshStatus() } catch (reason) { + if (stopping) { + stopStateRef.current = 'none' + setStopState('none') + } reportError(reason) + } finally { + toggleInFlight.current = false } }, [refreshStatus, reportError]) @@ -136,6 +164,7 @@ export function useAppController() { error, setError, recordingSeconds, + stopPending: stopState !== 'none', refreshStatus, toggleRecording: toggle, quitApp: quit, diff --git a/frontend/src/home/HomeView.tsx b/frontend/src/home/HomeView.tsx index 3e577be..b643ed9 100644 --- a/frontend/src/home/HomeView.tsx +++ b/frontend/src/home/HomeView.tsx @@ -14,27 +14,35 @@ export function HomeView({ status, history, recordingSeconds, + stopPending, onToggleRecording, onOpenSettings, }: { status: AppStatus history: HistoryItem[] recordingSeconds: number + stopPending: boolean onToggleRecording: () => Promise onOpenSettings: () => void }) { const shortcut = presentShortcut(status.shortcut) const recording = status.phase === 'Recording' - const heroState = recording + const processing = status.phase === 'Transcribing' || status.phase === 'Injecting' + const busy = processing || stopPending + const heroState = recording && !stopPending ? 'recording' - : status.phase === 'Transcribing' + : busy ? 'transcribing' : 'idle' - const stateCopy = recording - ? ['Listening…', 'Speak naturally, then press the shortcut again.'] - : status.phase === 'Transcribing' - ? ['Transcribing locally…', `${status.engineName} is turning your recording into text.`] - : ['Ready when you are', 'Your audio stays on this machine.'] + const [readout, title, description] = stopPending + ? ['Stopping', 'Finishing recording…', 'Waiting for Echo to finish recording.'] + : recording + ? ['Listening', 'Listening…', 'Speak naturally, then press the shortcut again.'] + : status.phase === 'Transcribing' + ? ['Processing', 'Transcribing locally…', `${status.engineName} is turning your recording into text.`] + : status.phase === 'Injecting' + ? ['Processing', 'Inserting transcript…', `${status.injectionName} is sending your transcript to the active app.`] + : ['Ready', 'Ready when you are', 'Your audio stays on this machine.'] return (
@@ -44,14 +52,16 @@ export function HomeView({ className="record-orb" type="button" onClick={() => void onToggleRecording()} - aria-label={recording ? 'Stop and transcribe' : 'Start recording'} + aria-label={stopPending ? 'Stopping recording' : recording ? 'Stop and transcribe' : processing ? 'Processing recording' : 'Start recording'} + aria-busy={busy || undefined} + disabled={busy} >