Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions frontend/e2e/home-processing.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
100 changes: 94 additions & 6 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
stopRecording,
testInputDevice,
testMicrophoneFallback,
toggleRecording,
} from './tauri'
import type {
AppStatus,
Expand Down Expand Up @@ -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()),
}
})

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(<App />)

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(<App />)

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(<App />)
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(<App />)

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(<App />)
Expand Down Expand Up @@ -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(<App />)
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 () => {
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function App() {
error,
setError,
recordingSeconds,
stopPending,
refreshStatus,
toggleRecording,
quitApp,
Expand Down Expand Up @@ -114,6 +115,7 @@ function App() {
status={status}
history={history}
recordingSeconds={recordingSeconds}
stopPending={stopPending}
onToggleRecording={toggleRecording}
onOpenSettings={() => setView('settings')}
/>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/AppHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ describe('Echo desktop shell', () => {
status={richPreviewStatus()}
history={history}
recordingSeconds={0}
stopPending={false}
onToggleRecording={async () => undefined}
onOpenSettings={vi.fn()}
/>,
Expand Down
31 changes: 30 additions & 1 deletion frontend/src/app/useAppController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const initialStatus: AppStatus = {
staleInstalls: [],
}

type StopState = 'none' | 'requesting' | 'awaiting-status'

export function useAppController() {
const [view, setView] = useState<View>('home')
const [status, setStatus] = useState<AppStatus>(initialStatus)
Expand All @@ -47,8 +49,11 @@ export function useAppController() {
})
const [error, setError] = useState<string | null>(null)
const [recordingStartedAt, setRecordingStartedAt] = useState<number | null>(null)
const previousPhase = useRef('Idle')
const previousPhase = useRef<AppStatus['phase']>('Idle')
const previousHistoryId = useRef<string | null>(null)
const toggleInFlight = useRef(false)
const stopStateRef = useRef<StopState>('none')
const [stopState, setStopState] = useState<StopState>('none')
const recordingSeconds = useElapsedSeconds(recordingStartedAt)
const reportError = useCallback((reason: unknown) => setError(messageFrom(reason)), [])
const {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Comment thread
vriesd marked this conversation as resolved.
}
}, [refreshStatus, reportError])

Expand All @@ -136,6 +164,7 @@ export function useAppController() {
error,
setError,
recordingSeconds,
stopPending: stopState !== 'none',
refreshStatus,
toggleRecording: toggle,
quitApp: quit,
Expand Down
Loading