uttrflow / uttrflow/uttrflow-swift
A recording refused after a device change offers a Retry that opens the microphone again, while the refused audio sits unoffered on disk
- Dominant language
- Swift
- Stars
- 4
- Forks
- 17
- Avg merge
- 3h 32m
- Merged PRs (30d)
- 277
Description
## What happens
When the input hardware changes mid-recording (a Bluetooth headset connecting, headphones with a microphone going in, the input device changed in System Settings), `AVAudioCaptureEngine.stop()` refuses the recording on purpose, because `AudioSamples` cannot represent the gap (`Sources/UttrflowAudio/AVAudioCaptureEngine.swift:81-91`, `Docs/microphone.md`). Before it throws, it has already finished the recording's WAV (line 75-76), so the whole take is on disk.
The refusal then takes a different path from every other lost dictation:
- `DictationPipeline.finishRecording` catches the capture error and calls `transition(to: .failed(...))` directly (`Sources/UttrflowPipeline/DictationPipeline.swift:238-242`). It never looks up `recordings.current()` (that happens at line 247, after a successful stop) and never goes through `fail(_:)` (lines 744-756), which is what turns a lost dictation's recovery into `.retryFromRecording`.
- The notice therefore carries `AudioCaptureError.engineFailed`'s own recovery, `.retry` (`Sources/UttrflowCore/Errors/AudioCaptureError.swift:34`), with "Recording stopped unexpectedly. Try again."
- `.retry` is `controller.toggleFromControl()` (`Sources/Uttrflow/AppDelegate.swift:1810-1812`): pressing it **starts a brand-new recording** with the microphone open, instead of opening the kept audio.
- The kept audio only appears on the Dictation page after the next window refresh, and nothing points the user there.
`Tests/UttrflowPipelineTests/DictationPipelineRecordingTests.swift:111-126` pins this with a comment that "there was no recording to reason about", which is not true of the real capture engine. `Docs/microphone.md` says the opposite of what happens: "It is `.engineFailed`, so `DictationPipeline` keeps the audio and offers the retry", and #170's fix direction asked for the same ("a gapped recording is offered for retry, never inserted").
## Why it matters
`Docs/microphone.md` calls a headset connecting mid-sentence "a Tuesday". The user loses the dictation, and the one button on offer turns the microphone on again without asking, which a user who has just watched a failure does not expect and may not notice (VoiceOver users hear nothing, #551). The audio they wanted back is kept for 24 hours and then pruned without ever being offered.
## How to reproduce (headless, no microphone)
A throwaway test in `Tests/UttrflowAudioTests/AVAudioCaptureEngineRecordingTests.swift`, with the existing `FakeMicrophoneSource` and a `RecordingStore` in a temporary directory:
```swift
try await engine.start()
source.emit(Array(repeating: 0.2, count: 16_000))
source.skip() // the device went and came back
// wait for engine.interruptionsHandled >= 1
source.emit(Array(repeating: 0.2, count: 16_000))
await #expect(throws: AudioCaptureError.self) { _ = try await engine.stop() }
// store.current() -> a 2.0 s KeptRecording; store.waiting(now:) -> 1 recording
```
On main (26d7bc1) the store answers a 2.0-second current recording and one waiting recording, while the pipeline test above shows the failure offering `.retry`. Whether an output-only change (headphones without a microphone) also posts `AVAudioEngineConfigurationChange` and so triggers the refusal needs real hardware, hence help wanted.
## Acceptance criteria
- A capture that ends in a refusal after the recording was written goes through the same rule as every other lost dictation: the recording is claimed, and the notice offers `.retryFromRecording`.
- No failure notice offers a control that silently opens the microphone for a new dictation in place of a kept one.
- The pipeline test is changed to use a keeper that has a current recording for the refused stop, and asserts `.retryFromRecording`.
- `Docs/microphone.md` and the code agree.
- Someone with a Bluetooth headset confirms the notice and its Retry on a real device change.
Note for whoever picks this up: the kept file holds both sides of the gap joined together, which is exactly what the refusal exists to avoid inserting silently. Offering it through the Dictation page's Retry, which delivers to the clipboard and is the user's explicit choice, is what #170 proposed; if that is not wanted, the recording should be discarded instead of left to be pruned unoffered.
Contributor guide
Research direction
Start with AVAudioCaptureEngine.swift:81-91 and DictationPipeline.swift:238-242, then read fail(_:) at lines 744-756 and the recovery definitions in AudioCaptureError.swift. Update DictationPipelineRecordingTests.swift:111-126 to use a keeper with a current recording and assert .retryFromRecording, then reconcile Docs/microphone.md and verify the notice on a real device change if hardware is available.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- macos, swift
- Domain
- audio-video-rtc, desktop, documentation, testing-qa
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100