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} > {recording ? : } - {recording ? 'Listening' : status.phase === 'Transcribing' ? 'Transcribing' : 'Ready'} + {readout} {recording ? ( {status.recordingLimitSeconds == null @@ -60,8 +70,8 @@ export function HomeView({ ) : null} - {stateCopy[0]} - {stateCopy[1]} + {title} + {description} {recording ? : null} @@ -189,10 +199,7 @@ function StaleInstallWarning({ status }: { status: AppStatus }) { {path} ))} - .{' '} - - Or from a terminal: rm -f {paths.join(' ')}, then relaunch. - + . {error ? {error} : null} void remove()}> diff --git a/frontend/src/tauri.test.ts b/frontend/src/tauri.test.ts index 8c0b36a..ae7cd6a 100644 --- a/frontend/src/tauri.test.ts +++ b/frontend/src/tauri.test.ts @@ -1,5 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createPreviewDesktopApi } from './api/previewDesktopApi' +import type { MicrophoneSnapshot } from './generated/ipc' +import { + configureDesktopApi, + getMicrophones as getConfiguredMicrophones, + setMicrophone as setConfiguredMicrophone, +} from './tauri' const { getAppStatus, @@ -11,6 +17,28 @@ const { toggleRecording, } = createPreviewDesktopApi() +function deferred() { + const state: { + resolve: ((value: T | PromiseLike) => void) | null + reject: ((reason?: unknown) => void) | null + } = { resolve: null, reject: null } + const promise = new Promise((resolvePromise, rejectPromise) => { + state.resolve = resolvePromise + state.reject = rejectPromise + }) + return { + promise, + resolve(value: T | PromiseLike) { + if (!state.resolve) throw new Error('deferred promise is not initialized') + state.resolve(value) + }, + reject(reason?: unknown) { + if (!state.reject) throw new Error('deferred promise is not initialized') + state.reject(reason) + }, + } +} + describe('settings preview wrappers', () => { beforeEach(() => resetPreviewSettings()) @@ -75,4 +103,83 @@ describe('settings preview wrappers', () => { vi.useRealTimers() } }) + + it('serializes microphone selections so the final choice wins', async () => { + const preview = createPreviewDesktopApi() + const initial = await preview.getMicrophones() + const first = deferred() + const set = preview.setMicrophone.bind(preview) + const select = vi.spyOn(preview, 'setMicrophone') + .mockImplementationOnce(() => first.promise) + .mockImplementation((id) => set(id)) + configureDesktopApi(preview) + + const firstChoice = initial.devices[0] + const finalChoice = initial.devices[1] + if (!firstChoice || !finalChoice) throw new Error('preview needs two microphones') + const firstRequest = setConfiguredMicrophone(firstChoice.id) + const finalRequest = setConfiguredMicrophone(finalChoice.id) + await Promise.resolve() + expect(select).toHaveBeenCalledOnce() + + first.resolve(initial) + await firstRequest + await finalRequest + expect(select).toHaveBeenNthCalledWith(1, firstChoice.id) + expect(select).toHaveBeenNthCalledWith(2, finalChoice.id) + expect((await getConfiguredMicrophones()).selection).toMatchObject({ + kind: 'selected', + device: { id: finalChoice.id }, + }) + }) + + it('continues microphone operations after a rejected selection and keeps reads ordered', async () => { + const preview = createPreviewDesktopApi() + const initial = await preview.getMicrophones() + const reject = deferred() + const set = preview.setMicrophone.bind(preview) + const select = vi.spyOn(preview, 'setMicrophone') + .mockImplementationOnce(() => reject.promise) + .mockImplementation((id) => set(id)) + const read = vi.spyOn(preview, 'getMicrophones') + configureDesktopApi(preview) + + const firstChoice = initial.devices[0] + const finalChoice = initial.devices[1] + if (!firstChoice || !finalChoice) throw new Error('preview needs two microphones') + const rejected = setConfiguredMicrophone(firstChoice.id) + const finalRequest = setConfiguredMicrophone(finalChoice.id) + const readRequest = getConfiguredMicrophones() + await Promise.resolve() + expect(select).toHaveBeenCalledOnce() + expect(read).not.toHaveBeenCalled() + + reject.reject(new Error('device disconnected')) + await expect(rejected).rejects.toThrow('device disconnected') + await finalRequest + await readRequest + expect(select).toHaveBeenCalledTimes(2) + expect(read).toHaveBeenCalledOnce() + }) + + it('keeps queued microphone work with the adapter that accepted it', async () => { + const firstAdapter = createPreviewDesktopApi() + const secondAdapter = createPreviewDesktopApi() + const initial = await firstAdapter.getMicrophones() + const pending = deferred() + const firstSet = vi.spyOn(firstAdapter, 'setMicrophone').mockImplementation(() => pending.promise) + const secondRead = vi.spyOn(secondAdapter, 'getMicrophones') + const choice = initial.devices[0] + if (!choice) throw new Error('preview needs a microphone') + + configureDesktopApi(firstAdapter) + const firstRequest = setConfiguredMicrophone(choice.id) + configureDesktopApi(secondAdapter) + await getConfiguredMicrophones() + expect(firstSet).toHaveBeenCalledOnce() + expect(secondRead).toHaveBeenCalledOnce() + + pending.resolve(initial) + await firstRequest + }) }) diff --git a/frontend/src/tauri.ts b/frontend/src/tauri.ts index 4533ea3..b01fa8e 100644 --- a/frontend/src/tauri.ts +++ b/frontend/src/tauri.ts @@ -1,9 +1,12 @@ import type { DesktopApi } from './api/DesktopApi' +import type { MicrophoneSnapshot } from './generated/ipc' let desktopApi: DesktopApi | undefined +let microphoneQueue: Promise = Promise.resolve() export function configureDesktopApi(api: DesktopApi): void { desktopApi = api + microphoneQueue = Promise.resolve() } function api(): DesktopApi { @@ -11,6 +14,15 @@ function api(): DesktopApi { return desktopApi } +function queueMicrophoneOperation(operation: () => Promise): Promise { + const request = microphoneQueue.then(operation) + microphoneQueue = request.then( + () => undefined, + () => undefined, + ) + return request +} + export const getAppStatus: DesktopApi['getAppStatus'] = () => api().getAppStatus() export const getShortcutStatus: DesktopApi['getShortcutStatus'] = () => api().getShortcutStatus() export const retryShortcut: DesktopApi['retryShortcut'] = () => api().retryShortcut() @@ -42,9 +54,14 @@ export const listModels: DesktopApi['listModels'] = () => api().listModels() export const listLanguages: DesktopApi['listLanguages'] = () => api().listLanguages() export const setSettings: DesktopApi['setSettings'] = (settings) => api().setSettings(settings) export const listGpuDevices: DesktopApi['listGpuDevices'] = (refresh = false) => api().listGpuDevices(refresh) -export const getMicrophones: DesktopApi['getMicrophones'] = () => api().getMicrophones() -export const setMicrophone: DesktopApi['setMicrophone'] = (id) => - api().setMicrophone(id) +export const getMicrophones: DesktopApi['getMicrophones'] = () => { + const configuredApi = api() + return queueMicrophoneOperation(() => configuredApi.getMicrophones()) +} +export const setMicrophone: DesktopApi['setMicrophone'] = (id) => { + const configuredApi = api() + return queueMicrophoneOperation(() => configuredApi.setMicrophone(id)) +} export const testInputDevice: DesktopApi['testInputDevice'] = (id) => api().testInputDevice(id) export const testMicrophoneFallback: DesktopApi['testMicrophoneFallback'] = () => diff --git a/scripts/process_observation.py b/scripts/process_observation.py index 8e7a3df..7f58abb 100644 --- a/scripts/process_observation.py +++ b/scripts/process_observation.py @@ -88,14 +88,18 @@ def _read_status(root: Path, pid: int) -> dict[str, int] | None: def _read_children(root: Path, pid: int) -> list[int] | None: try: - return [ - int(value) - for value in (root / str(pid) / "task" / str(pid) / "children") - .read_text() - .split() - ] - except (FileNotFoundError, PermissionError, ValueError, OSError): + tasks = list((root / str(pid) / "task").iterdir()) + except (FileNotFoundError, PermissionError, OSError): return None + children: set[int] = set() + for task in tasks: + if not task.name.isdigit(): + continue + try: + children.update(int(value) for value in (task / "children").read_text().split()) + except (ValueError, OSError): + return None + return sorted(children) def _parent_snapshot(root: Path) -> tuple[dict[int, list[int]], set[int]] | None: diff --git a/scripts/test_process_observation.py b/scripts/test_process_observation.py new file mode 100644 index 0000000..402d132 --- /dev/null +++ b/scripts/test_process_observation.py @@ -0,0 +1,86 @@ +"""Focused regressions for process-tree sampling.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path + +from process_observation import _read_children, _tree + + +class ProcessObservationTests(unittest.TestCase): + def test_tree_collects_children_from_every_thread(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "meminfo").write_text("MemAvailable: 1 kB\nSwapTotal: 1 kB\nSwapFree: 1 kB\n") + for pid, children in ((1, "2"), (2, ""), (3, "")): + (root / str(pid) / "task" / str(pid)).mkdir(parents=True) + (root / str(pid) / "status").write_text("State:\tS (sleeping)\n") + (root / str(pid) / "task" / str(pid) / "children").write_text(children) + (root / "1" / "task" / "10").mkdir() + (root / "1" / "task" / "10" / "children").write_text("3 2") + + self.assertEqual(_read_children(root, 1), [2, 3]) + self.assertEqual(sorted(_tree(root, 1)[0]), [1, 2, 3]) + self.assertFalse(_tree(root, 1)[1]) + + def test_unreadable_children_falls_back_or_marks_partial(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for pid, parent in ((1, 0), (2, 1)): + (root / str(pid) / "task" / str(pid)).mkdir(parents=True) + (root / str(pid) / "status").write_text(f"PPid:\t{parent}\n") + + self.assertEqual(_tree(root, 1), ([1, 2], False)) + + (root / "2" / "status").write_text("PPid:\tnot-a-pid\n") + self.assertEqual(_tree(root, 1), ([1], True)) + + def test_tree_finds_child_spawned_by_worker_thread(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + child_pid_file = Path(temporary) / "child.pid" + program = f"""import pathlib +import subprocess +import sys +import threading +import time + +ready = threading.Event() +release = threading.Event() +def worker(): + child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(10)"]) + target = pathlib.Path({str(child_pid_file)!r}) + temporary = target.with_name(target.name + ".tmp") + temporary.write_text(str(child.pid)) + temporary.replace(target) + ready.set() + release.wait() + +threading.Thread(target=worker).start() +ready.wait() +time.sleep(10) +""" + parent = subprocess.Popen([sys.executable, "-c", program], start_new_session=True) + try: + deadline = time.monotonic() + 2 + while not child_pid_file.exists() and time.monotonic() < deadline: + time.sleep(0.01) + self.assertTrue(child_pid_file.exists(), "worker did not spawn its child") + child_pid = int(child_pid_file.read_text()) + self.assertIn(child_pid, _tree(Path("/proc"), parent.pid)[0]) + finally: + try: + os.killpg(parent.pid, signal.SIGKILL) + except ProcessLookupError: + pass + parent.wait() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-stt-benchmark.sh b/scripts/verify-stt-benchmark.sh index 2c86936..fa06b19 100755 --- a/scripts/verify-stt-benchmark.sh +++ b/scripts/verify-stt-benchmark.sh @@ -6,6 +6,7 @@ verify_root=$(mktemp -d "${TMPDIR:-/tmp}/echo-stt-benchmark.XXXXXX") trap 'rm -rf "$verify_root"' EXIT python3 "$repo_root/scripts/process_observation.py" --self-test +python3 "$repo_root/scripts/test_process_observation.py" python3 "$repo_root/scripts/benchmark-stt.py" --self-test python3 "$repo_root/scripts/probe-whisper-resident.py" --self-test if [[ ${ECHO_STT_BENCHMARK_SKIP_BUILD:-0} == 1 ]]; then diff --git a/src-tauri/src/commands/devices.rs b/src-tauri/src/commands/devices.rs index b4a79a6..a252ed3 100644 --- a/src-tauri/src/commands/devices.rs +++ b/src-tauri/src/commands/devices.rs @@ -51,40 +51,47 @@ pub(crate) async fn list_models() -> Result { } #[tauri::command] -pub(crate) fn get_microphones() -> echo_desktop::ipc::MicrophoneSnapshot { - echo::audio::microphone_snapshot().into() +pub(crate) async fn get_microphones() -> Result { + crate::blocking::run_blocking("microphone enumeration", || { + echo::audio::microphone_snapshot().into() + }) + .await } #[tauri::command] -pub(crate) fn set_microphone( +pub(crate) async fn set_microphone( id: Option, ) -> Result { - if env::var("ECHO_MICROPHONE") - .ok() - .is_some_and(|value| !value.trim().is_empty()) - { - return Err("ECHO_MICROPHONE controls the microphone in this process".to_string()); - } - let snapshot = echo::audio::microphone_snapshot(); - let selection = match id { - None => None, - Some(raw) => { - let id = echo::microphone::MicrophoneId::parse(raw)?; - let device = snapshot - .devices - .iter() - .find(|device| device.id == id) - .ok_or_else(|| { - "that microphone is no longer connected; refresh and choose again".to_string() - })?; - Some((id, device.label.clone())) + crate::blocking::run_blocking("microphone selection", move || { + if env::var("ECHO_MICROPHONE") + .ok() + .is_some_and(|value| !value.trim().is_empty()) + { + return Err("ECHO_MICROPHONE controls the microphone in this process".to_string()); } - }; - crate::settings::update_file_config(|config| { - update_microphone_config(config, selection); - Ok(()) - })?; - Ok(echo::audio::microphone_snapshot().into()) + let snapshot = echo::audio::microphone_snapshot(); + let selection = match id { + None => None, + Some(raw) => { + let id = echo::microphone::MicrophoneId::parse(raw)?; + let device = snapshot + .devices + .iter() + .find(|device| device.id == id) + .ok_or_else(|| { + "that microphone is no longer connected; refresh and choose again" + .to_string() + })?; + Some((id, device.label.clone())) + } + }; + crate::settings::update_file_config(|config| { + update_microphone_config(config, selection); + Ok(()) + })?; + Ok(echo::audio::microphone_snapshot().into()) + }) + .await? } fn update_microphone_config( @@ -181,11 +188,26 @@ mod tests { ) { } + fn assert_async_microphones( + _: impl Future>, + ) { + } + #[test] fn gpu_device_listing_yields_before_detection() { assert_async_gpu_devices(list_gpu_devices(false)); } + #[test] + fn microphone_listing_yields_before_detection() { + assert_async_microphones(get_microphones()); + } + + #[test] + fn microphone_selection_yields_before_detection_and_config_write() { + assert_async_microphones(set_microphone(None)); + } + #[test] fn language_command_projection_covers_every_support_mode() { let multilingual =
{stateCopy[1]}
{description}
{path}
rm -f {paths.join(' ')}