diff --git a/CHANGELOG.md b/CHANGELOG.md index 48dbe66..7b12177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ repository's local calendar date. ## Unreleased +### Added + +- **A history row can transcribe its recording again, and hand you the recording.** Retry only ever + answered "the words never arrived". The more common complaint is that they arrived wrong — a name + misheard, a provider that was the wrong one for the accent — and that row is completed, which + `canRetry` excludes by definition, so the only recovery was to say the whole thing again. Both new + offers are keyed to the recording still being on disk rather than to the status, which for a + successful dictation means Keep audio was on when it was made; that setting now says so on all + four clients. A redo is not Retry renamed: Retry types the words it recovers, and nothing is owed + a cursor when you are reading your own history, so a redo updates the row and leaves Copy one + button away. It also drops the rewrite beside the transcript it replaced — the styled text is what + a row delivers, so keeping it would have replaced the words and still shown the old ones. Saved + recordings are named `donottype-.wav`, because on disk they are named for a + UUID. macOS, Windows, Android and iOS. + ## 0.3.0 - 2026-08-25 A model that quietly dropped most of a long dictation, and the three ways that is now caught. The diff --git a/Sources/DoNotTypeApp/SettingsModel.swift b/Sources/DoNotTypeApp/SettingsModel.swift index 07a237d..9336c69 100644 --- a/Sources/DoNotTypeApp/SettingsModel.swift +++ b/Sources/DoNotTypeApp/SettingsModel.swift @@ -1,6 +1,8 @@ +import AppKit import DoNotTypeCore import Foundation import Observation +import UniformTypeIdentifiers /// Observable façade over `Settings` and `HistoryStore` for the settings window. /// @@ -1014,6 +1016,57 @@ final class SettingsModel { await refresh() } + /// Transcribes a stored recording again, for a dictation that arrived and arrived wrong. + /// + /// The same request Retry makes, and deliberately not the same ending: a retry is recovering + /// words that never reached a cursor, so it types them. Nothing is owed a cursor here — the + /// user is reading the history — and typing into whatever is behind this window would be a + /// surprise. The row updates, and Copy is one button away. + func redo(_ record: DictationRecord) async { + guard let coordinator = makeCoordinator() else { + lastRetrySummary = "No API key set." + return + } + retryingIDs.insert(record.id) + defer { retryingIDs.remove(record.id) } + + switch await coordinator.retry(record) { + case .success(let text): + lastRetrySummary = "Transcribed again: \(text.prefix(60))" + case .failure(let error): + lastRetrySummary = error.localizedDescription + } + await refresh() + } + + /// Copies the recording out of the history to somewhere the user chose. + /// + /// A copy rather than a move: the history keeps its own file, so saving a recording is not a + /// way to lose the ability to redo it. + func saveAudio(_ record: DictationRecord) async { + guard let source = await store.audioURL(for: record) else { + lastRetrySummary = HistoryStore.StoreError.audioMissing(record.id).localizedDescription + return + } + + let panel = NSSavePanel() + panel.nameFieldStringValue = record.audioExportName + panel.allowedContentTypes = [.wav] + guard panel.runModal() == .OK, let target = panel.url else { return } + + do { + // Overwriting is the user's answer to the panel's own "replace?" prompt, so the + // existing file goes rather than the copy failing on it. + if FileManager.default.fileExists(atPath: target.path) { + try FileManager.default.removeItem(at: target) + } + try FileManager.default.copyItem(at: source, to: target) + lastRetrySummary = "Saved \(target.lastPathComponent)." + } catch { + lastRetrySummary = error.localizedDescription + } + } + func retryAll() async { guard let coordinator = makeCoordinator() else { lastRetrySummary = "No API key set." diff --git a/Sources/DoNotTypeApp/SettingsView.swift b/Sources/DoNotTypeApp/SettingsView.swift index e706926..60e0187 100644 --- a/Sources/DoNotTypeApp/SettingsView.swift +++ b/Sources/DoNotTypeApp/SettingsView.swift @@ -830,7 +830,9 @@ private struct HistoryTab: View { Toggle("Keep audio", isOn: $model.keepAudio) .help( - "Failed dictations always keep their audio until they succeed, so Retry works.") + "Failed dictations always keep their audio until they succeed, so Retry works." + + " Keeping it for the ones that succeeded is what lets you redo a" + + " transcription or save the recording.") Spacer() @@ -925,6 +927,10 @@ private struct HistoryRow: View { .buttonStyle(.borderless) .help("Show exactly what was sent") + // One circular arrow per row, doing the thing that row needs. On a failed dictation + // that is Retry — the words never reached a cursor, so they are typed. On a completed + // one it is a redo: the words arrived and arrived wrong, and re-running the + // transcription is the only fix that does not mean saying it all again. if model.retryingIDs.contains(record.id) { ProgressView().controlSize(.small) } else if record.canRetry { @@ -935,7 +941,17 @@ private struct HistoryRow: View { } .buttonStyle(.borderless) .help("Retry this dictation") - } else if record.status == .completed { + } else if record.canRedo { + Button { + Task { await model.redo(record) } + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Redo the transcription") + } + + if record.status == .completed { Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(record.text, forType: .string) @@ -946,6 +962,19 @@ private struct HistoryRow: View { .help("Copy transcript") } + // The recording is the evidence behind the row: it is what a wrong transcript should + // be judged against, and the one thing here that cannot be reconstructed. Offered + // wherever it still exists. + if record.canRedo { + Button { + Task { await model.saveAudio(record) } + } label: { + Image(systemName: "square.and.arrow.down") + } + .buttonStyle(.borderless) + .help("Save the original audio") + } + // A failed row's summary *is* its error, and it is the thing worth pasting into an // issue. Copying the truncated label off the screen is not an option, so give it a // button of its own. diff --git a/Sources/DoNotTypeCore/DictationRecord.swift b/Sources/DoNotTypeCore/DictationRecord.swift index 84d838c..127e1b9 100644 --- a/Sources/DoNotTypeCore/DictationRecord.swift +++ b/Sources/DoNotTypeCore/DictationRecord.swift @@ -156,6 +156,31 @@ public struct DictationRecord: Codable, Sendable, Identifiable, Equatable { /// Retryable only while the recording still exists. public var canRetry: Bool { status.isRetryable && audioFileName != nil } + /// Whether the transcription can be run again, and the recording saved out of the history. + /// + /// A superset of `canRetry`, and a different question. Retry is about words that never + /// arrived; redoing is about words that arrived wrong — a name misheard, a provider that was + /// the wrong one for the accent — and that case is a *completed* dictation, which keeps its + /// audio only when the keep-audio setting was on when it was made. + public var canRedo: Bool { audioFileName != nil } + + /// What to call the recording when it is saved somewhere the user chose. + /// + /// Named for when it was said. On disk it is the record's UUID, which is the right name for a + /// file the store owns and a useless one in a downloads folder next to twenty others. + public var audioExportName: String { + "donottype-\(Self.exportStamp.string(from: createdAt)).wav" + } + + private static let exportStamp: DateFormatter = { + let formatter = DateFormatter() + // Fixed format, so the name does not change shape with the user's region — a filename + // with a slash in it is not a filename. + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd-HHmmss" + return formatter + }() + /// What gets inserted: the styled version when one exists, otherwise the transcript. public var deliveredText: String { styledText ?? text } diff --git a/Sources/DoNotTypeCore/TranscriptionService.swift b/Sources/DoNotTypeCore/TranscriptionService.swift index 0e61594..3e42429 100644 --- a/Sources/DoNotTypeCore/TranscriptionService.swift +++ b/Sources/DoNotTypeCore/TranscriptionService.swift @@ -510,7 +510,11 @@ public struct RetryCoordinator: Sendable { self.store = store } - /// Retries a single record and writes the result back to the store. + /// Transcribes a stored recording again and writes the result back to the store. + /// + /// Both the retry of a dictation that failed and the redo of one the user thinks came back + /// wrong: the request is the same either way, and only the caller knows whether the text is + /// owed to a cursor somewhere. @discardableResult public func retry(_ record: DictationRecord) async -> Result { var updated = record @@ -525,6 +529,15 @@ public struct RetryCoordinator: Sendable { updated.status = .completed updated.text = text updated.errorMessage = nil + updated.errorDetail = nil + // The rewrite beside it was derived from the transcript that has just been replaced, + // so it goes with it. Keeping it would be worse than losing it: `deliveredText` + // prefers the styled version, so a redo of a rewritten dictation would replace the + // words and still show the old ones — a button that appears to do nothing. + updated.styledText = nil + updated.style = nil + updated.mode = .verbatim + updated.rewriteFailed = nil await store.update(updated) return .success(text) } catch { diff --git a/Tests/DoNotTypeCoreTests/DictationJourneyTests.swift b/Tests/DoNotTypeCoreTests/DictationJourneyTests.swift index afd6340..4531226 100644 --- a/Tests/DoNotTypeCoreTests/DictationJourneyTests.swift +++ b/Tests/DoNotTypeCoreTests/DictationJourneyTests.swift @@ -178,6 +178,87 @@ final class DictationJourneyTests: XCTestCase { ProviderError.missingAPIKey(envVar: "STUB_KEY"))) } + // MARK: - Redoing a transcript that arrived wrong + + /// The other reason to keep a recording: a dictation that *succeeded* and still came back + /// wrong. Retry cannot reach it — the row is completed — so the offer is keyed to the audio + /// being there rather than to the status. + func testACompletedDictationCanBeRedoneWhileItsAudioIsKept() async throws { + await store.configure(retention: .forever, keepAudioForCompleted: true) + var record = DictationRecord( + status: .completed, provider: "stub", model: "stub-model", fidelity: .light, + durationSeconds: 1) + record.text = "meet Bo Jelly at four" + let stored = await store.insert(record, audio: wav().data) + + XCTAssertFalse(stored.canRetry, "a completed dictation has nothing to retry") + XCTAssertTrue(stored.canRedo, "but its recording is still there to transcribe again") + + let outcome = await RetryCoordinator( + service: service(text: "meet Bojie at four"), store: store).retry(stored) + + guard case .success = outcome else { return XCTFail("expected the redo to succeed") } + let after = await store.record(id: stored.id) + XCTAssertEqual(after?.text, "meet Bojie at four") + XCTAssertTrue(after?.canRedo ?? false, "keep-audio is on, so it can be redone again") + } + + /// Without the audio there is nothing to send, so nothing is offered — the default for a + /// completed dictation, which discards its recording. + func testADiscardedRecordingCannotBeRedone() async { + var record = DictationRecord( + status: .completed, provider: "stub", model: "stub-model", fidelity: .light) + record.text = "already delivered" + let stored = await store.insert(record, audio: wav().data) + + XCTAssertNil(stored.audioFileName) + XCTAssertFalse(stored.canRedo) + } + + /// A redo replaces the transcript, so the rewrite derived from the *old* one cannot stay: + /// `deliveredText` prefers the styled version, and leaving it would show the old words under + /// a button that had just replaced them. + func testARedoDropsTheRewriteDerivedFromTheReplacedTranscript() async throws { + await store.configure(retention: .forever, keepAudioForCompleted: true) + var record = DictationRecord( + status: .completed, provider: "stub", model: "stub-model", fidelity: .light, + durationSeconds: 1) + record.text = "so basically we should just ship it" + record.styledText = "We should proceed with the release." + record.style = .formal + record.mode = .rewrite(.formal) + let stored = await store.insert(record, audio: wav().data) + + _ = await RetryCoordinator( + service: service(text: "so basically we should just ship it today"), store: store) + .retry(stored) + + let after = await store.record(id: stored.id) + XCTAssertEqual(after?.text, "so basically we should just ship it today") + XCTAssertNil(after?.styledText, "the rewrite described a transcript that is now gone") + XCTAssertEqual( + after?.deliveredText, "so basically we should just ship it today", + "the row must show the words the redo produced") + XCTAssertEqual(after?.resolvedMode, .verbatim) + } + + /// The recording is saved under the time it was said, not under the UUID it has on disk. + func testASavedRecordingIsNamedForWhenItWasSaid() { + var components = DateComponents() + components.year = 2026 + components.month = 8 + components.day = 28 + components.hour = 14 + components.minute = 32 + components.second = 5 + let created = Calendar.current.date(from: components)! + let record = DictationRecord( + id: UUID(), createdAt: created, status: .completed, provider: "stub", + model: "stub-model", fidelity: .light) + + XCTAssertEqual(record.audioExportName, "donottype-20260828-143205.wav") + } + // MARK: - Rewriting never costs the verbatim transcript /// The claim the whole product rests on: a rewrite is stored *beside* what was said, never diff --git a/android/app/src/main/kotlin/app/donottype/HistoryActivity.kt b/android/app/src/main/kotlin/app/donottype/HistoryActivity.kt index 600fd60..760bc25 100644 --- a/android/app/src/main/kotlin/app/donottype/HistoryActivity.kt +++ b/android/app/src/main/kotlin/app/donottype/HistoryActivity.kt @@ -21,6 +21,7 @@ import android.widget.ScrollView import android.widget.Spinner import android.widget.TextView import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope @@ -62,6 +63,40 @@ class HistoryActivity : AppCompatActivity() { private val service by lazy { DictationService(this) } private var query = HistoryQuery() + /** + * The row whose recording the document picker was opened for. + * + * The id rather than the record or its bytes: the picker is a trip through another app, and a + * megabyte of audio held across it is a megabyte held for as long as the user browses. The + * store is re-read when the answer comes back, which is also what makes a row deleted in the + * meantime say so instead of writing a file from a stale copy. + */ + private var pendingAudioRecordId: String? = null + + private val saveAudio = registerForActivityResult( + ActivityResultContracts.CreateDocument("audio/wav"), + ) { uri -> + val id = pendingAudioRecordId + pendingAudioRecordId = null + if (uri == null || id == null) return@registerForActivityResult + + val wav = service.history.all().firstOrNull { it.id == id } + ?.let { service.history.audioFor(it) } + if (wav == null) { + summary.text = "The recording for this dictation is no longer on disk." + return@registerForActivityResult + } + runCatching { + contentResolver.openOutputStream(uri, "wt")?.use { it.write(wav) } + ?: error("Could not open that file for writing.") + }.onSuccess { + Toast.makeText(this, "Recording saved.", Toast.LENGTH_SHORT).show() + }.onFailure { + // Uncut: the reason is what tells someone to pick a different folder. + summary.text = it.message ?: "Could not save the recording." + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Settings.initialise(this) @@ -337,9 +372,67 @@ class HistoryActivity : AppCompatActivity() { contentDescription = "Delete this transcript" }, ) - return row + + // Nothing more to offer unless the recording is still here, which for a dictation that + // succeeded means the keep-audio setting was on when it was made — so on most rows this + // second line does not exist at all rather than sitting there disabled. + if (!record.canRedo) return row + + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(row) + addView(audioActions(record)) + } } + /** + * What the kept recording makes possible, under the row it belongs to. + * + * A line of its own rather than more buttons beside the transcript: four actions on one line + * leave the transcript a column too narrow to read, which is the thing the row is for. + */ + private fun audioActions(record: DictationRecord): View = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + val padding = resources.getDimensionPixelSize(R.dimen.space_m) + setPadding(padding, 0, padding, resources.getDimensionPixelSize(R.dimen.space_s)) + + // Only where Retry is not already the same button. On a failed row the words never + // arrived and Retry above transcribes the very same recording; offering "redo" beside it + // would be two names for one action. + if (record.status == DictationRecord.Status.COMPLETED) { + addView( + textButton("Redo transcription") {}.apply { + layoutParams = shareWidth() + setOnClickListener { + isEnabled = false + summary.text = "Transcribing again…" + lifecycleScope.launch { + service.retry(record) + refresh() + } + } + }, + ) + } + + // The recording is the evidence behind the row: what a wrong transcript should be judged + // against, and the one thing here that cannot be reconstructed. A copy — the history keeps + // its own file, so saving it does not cost the ability to redo it. + addView( + textButton("Save audio") { + pendingAudioRecordId = record.id + saveAudio.launch(record.audioExportName) + }.apply { layoutParams = shareWidth() }, + ) + } + + /** Even shares of the row, so a long label ellipsizes rather than pushing the next one off. */ + private fun shareWidth() = LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f, + ) + private fun wrapContent() = LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, diff --git a/android/app/src/main/kotlin/app/donottype/SettingsActivity.kt b/android/app/src/main/kotlin/app/donottype/SettingsActivity.kt index 243c4a7..f24b0f4 100644 --- a/android/app/src/main/kotlin/app/donottype/SettingsActivity.kt +++ b/android/app/src/main/kotlin/app/donottype/SettingsActivity.kt @@ -406,7 +406,9 @@ class SettingsActivity : AppCompatActivity() { controlRow("Keep for", buildRetentionPicker()), switchRow( "Keep audio for successful dictations", - "Failed dictations always keep theirs until they succeed.", + "Failed dictations always keep theirs until they succeed. Keeping it for the " + + "ones that worked is what lets you redo a transcription or save the " + + "recording.", checked = Settings.keepAudio, ) { Settings.keepAudio = it diff --git a/android/app/src/main/kotlin/app/donottype/core/Dictation.kt b/android/app/src/main/kotlin/app/donottype/core/Dictation.kt index 724ef14..18ffd64 100644 --- a/android/app/src/main/kotlin/app/donottype/core/Dictation.kt +++ b/android/app/src/main/kotlin/app/donottype/core/Dictation.kt @@ -485,7 +485,12 @@ class DictationService(private val context: Context) { } } - /** Reissues a stored dictation. */ + /** + * Transcribes a stored recording again. + * + * Both the retry of a dictation that failed and the redo of one the user thinks came back + * wrong: the request is the same either way. + */ suspend fun retry(record: DictationRecord): Result { val key = Settings.apiKey if (key.isNullOrBlank()) { @@ -534,6 +539,14 @@ class DictationService(private val context: Context) { record.status = DictationRecord.Status.COMPLETED record.text = result.transcript.transcript.trim() record.errorMessage = null + record.errorDetail = null + // The rewrite beside it was derived from the transcript that has just been replaced, + // so it goes with it. Keeping it would be worse than losing it: deliveredText prefers + // the styled version, so a redo of a rewritten dictation would replace the words and + // still show the old ones — a button that appears to do nothing. + record.styledText = null + record.mode = TranscriptMode.Verbatim.id + record.rewriteFailed = false history.update(record) Result.success(record) } catch (error: Exception) { diff --git a/android/app/src/main/kotlin/app/donottype/core/History.kt b/android/app/src/main/kotlin/app/donottype/core/History.kt index 2620b3e..81d2303 100644 --- a/android/app/src/main/kotlin/app/donottype/core/History.kt +++ b/android/app/src/main/kotlin/app/donottype/core/History.kt @@ -10,6 +10,9 @@ import java.nio.charset.StandardCharsets import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import java.util.UUID /** @@ -119,6 +122,29 @@ data class DictationRecord( val canRetry: Boolean get() = status.isRetryable && audioFileName != null + /** + * Whether the transcription can be run again, and the recording saved out of the history. + * + * A superset of [canRetry], and a different question. Retry is about words that never arrived; + * redoing is about words that arrived wrong — a name misheard, a provider that was the wrong + * one for the accent — and that case is a *completed* dictation, which keeps its audio only + * when the keep-audio setting was on when it was made. + */ + val canRedo: Boolean get() = audioFileName != null + + /** + * What to call the recording when it is saved somewhere the user chose. + * + * Named for when it was said. On disk it is the record's UUID, which is the right name for a + * file the store owns and a useless one in a downloads folder next to twenty others. The + * format is fixed rather than locale-dependent: a filename with a slash in it is not a + * filename. + */ + val audioExportName: String + get() = "donottype-" + + SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date(createdAt)) + + ".wav" + /** True when this came from a recording on disk rather than the microphone. */ val isFromFile: Boolean get() = sourceFileName != null diff --git a/android/app/src/test/kotlin/app/donottype/core/DictationJourneyTest.kt b/android/app/src/test/kotlin/app/donottype/core/DictationJourneyTest.kt index e10c831..9ef53e3 100644 --- a/android/app/src/test/kotlin/app/donottype/core/DictationJourneyTest.kt +++ b/android/app/src/test/kotlin/app/donottype/core/DictationJourneyTest.kt @@ -130,6 +130,52 @@ class DictationJourneyTest { assertNull("completed and retention is off, so it goes", completed.audioFileName) } + // ---- Redoing a transcript that arrived wrong --------------------------------------------- + + /** + * The other reason to keep a recording: a dictation that *succeeded* and still came back + * wrong. Retry cannot reach it — the row is completed — so the offer is keyed to the audio + * being there rather than to the status. + */ + @Test + fun `a completed dictation can be redone while its audio is kept`() { + store.configure(RetentionPolicy.FOREVER, keepAudioForCompleted = true) + val entry = record(DictationRecord.Status.COMPLETED) + .apply { text = "meet Bo Jelly at four" } + + val stored = store.insert(entry, wav()) + + assertFalse("a completed dictation has nothing to retry", stored.canRetry) + assertTrue("but its recording is still there to transcribe again", stored.canRedo) + assertNotNull(store.audioFor(stored)) + } + + /** + * Without the audio there is nothing to send, so nothing is offered — the default for a + * completed dictation, which discards its recording. + */ + @Test + fun `a discarded recording cannot be redone`() { + val stored = store.insert(record(DictationRecord.Status.COMPLETED), wav()) + + assertNull(stored.audioFileName) + assertFalse(stored.canRedo) + } + + /** The recording is saved under the time it was said, not the UUID it has on disk. */ + @Test + fun `a saved recording is named for when it was said`() { + val calendar = java.util.Calendar.getInstance() + calendar.set(2026, java.util.Calendar.AUGUST, 28, 14, 32, 5) + val entry = DictationRecord( + createdAt = calendar.timeInMillis, + status = DictationRecord.Status.COMPLETED, + model = "stub-model", + ) + + assertEquals("donottype-20260828-143205.wav", entry.audioExportName) + } + // ---- Provider behaviour ------------------------------------------------------------------ @Test diff --git a/docs/PARITY.md b/docs/PARITY.md index 921d3ef..bfb9872 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -140,6 +140,8 @@ dictate into, so the fallback has not been needed; it is a gap rather than an im | History with search and filters | ✅ | ✅ | ✅ | ✅ | | Retry a failed dictation | ✅ | ✅ | ✅ | ✅ | | …with the context it originally had | ✅ | ✅ | ✅ | n/a ⁷ | +| Redo a transcription that came back wrong | ✅ | ✅ | ✅ | ✅ | +| Save the recording out of History | ✅ | ✅ | ✅ | ✅ | | Retention policy and keep-audio | ✅ | ✅ | ✅ | ✅ | | Edit the prompt | ✅ | ✅ | ✅ | ✅ | | Log viewer, level, content toggle | ✅ | ✅ | ✅ | ✅ | @@ -155,6 +157,14 @@ dictate into, so the fallback has not been needed; it is a gap rather than an im | Hedge and retry on their own connection | ✅ | ✅ | — ¹¹ | ✅ | | Only type where the dictation started | ✅ | ✅ | ✅ | n/a ¹² | +Both new rows depend on the recording still being on disk, which for a dictation that *succeeded* +means keep-audio was on when it was made — so on all four clients the two offers appear per row +rather than as controls that are always there and usually disabled. Redo is not a rename of Retry: +Retry recovers words that never reached a cursor and so types them, while a redo is read in the +history and delivers nothing. macOS, Windows and iOS give the two one control between them, since +a row can only ever want one of them; Android puts the redo on a line of its own beneath the row, +because a fourth control beside the transcript leaves the transcript too narrow to read. + ⁹ Windows has no permission prompt for the microphone at all — access is a Settings toggle — so what it does instead is open the privacy page when recording is refused. @@ -249,7 +259,9 @@ one screen further in on two of them. punctuation without rephrasing. - **Grounding.** On/off, screenshot fallback, and two blocklists evaluated before capture. - **History.** Search, filters, per-item retry and delete, retention policy, per-dictation - timings, and a Context Inspector showing exactly what was sent with any dictation. + timings, and a Context Inspector showing exactly what was sent with any dictation. While a + recording is kept, its row can transcribe it again — the fix for a transcript that arrived and + arrived wrong — and save the recording itself to a file. - **Stats.** Median and p95 wait, wait per second spoken, success rate, retries, and a per-model breakdown measured on the microphone and network in use rather than on a vendor's benchmark. - **Prompt.** The contract is editable in place on any platform, validated before saving, and diff --git a/ios/App/DictationModel.swift b/ios/App/DictationModel.swift index 667a6b3..14ab4b5 100644 --- a/ios/App/DictationModel.swift +++ b/ios/App/DictationModel.swift @@ -3,6 +3,7 @@ import DoNotTypeCore import Foundation import SwiftUI import UIKit +import UniformTypeIdentifiers /// Records, transcribes, and hands the result to the keyboard through the App Group. /// @@ -50,6 +51,8 @@ final class DictationModel { /// and a silent one should not look different. private(set) var levels = DictationModel.silentMeter private(set) var retryingIDs: Set = [] + /// The recording waiting for the user to say where to put it. Nil unless the picker is open. + var audioExport: AudioExport? private(set) var audioBytes: Int64 = 0 private(set) var connectionStatus: String? private(set) var isCheckingConnection = false @@ -1477,6 +1480,38 @@ final class DictationModel { await refresh() } + /// Transcribes a stored recording again, for a dictation that arrived and arrived wrong. + /// + /// The same request Retry makes, and deliberately not the same ending: a retry is recovering + /// words that never reached the keyboard, so it delivers them. Nothing is waiting on these — + /// the user is reading their own history — and putting them on the clipboard would quietly + /// replace whatever is there. The row updates; Copy is one button away. + func redo(_ record: DictationRecord) async { + guard let coordinator = makeCoordinator() else { + state = .failed("Add your API key in Settings.") + return + } + retryingIDs.insert(record.id) + defer { retryingIDs.remove(record.id) } + + _ = await coordinator.retry(record) + await refresh() + } + + /// Loads a recording for the export sheet, which is what asks the user where it goes. + /// + /// Read here rather than handed to the sheet as a file URL: the store's copy lives in the app + /// container under a UUID, and a share sheet offering `A1B2….wav` names the file after an + /// implementation detail. + func prepareAudioExport(_ record: DictationRecord) async { + do { + let audio = try await history.audioFile(for: record) + audioExport = AudioExport(data: audio.data, name: record.audioExportName) + } catch { + state = .failed(error.localizedDescription) + } + } + func retryAll() async { guard let coordinator = makeCoordinator() else { return } let pending = await history.retryable() @@ -1603,6 +1638,31 @@ final class DictationModel { }() } +/// A recording on its way out of the history, named for when it was said. +/// +/// Write-only. The history is written by dictating, not by importing a file, so there is no +/// direction in which this document is ever read back. +struct AudioExport: FileDocument, Identifiable { + static var readableContentTypes: [UTType] { [.wav] } + + let id = UUID() + let data: Data + let name: String + + init(data: Data, name: String) { + self.data = data + self.name = name + } + + init(configuration: ReadConfiguration) throws { + throw CocoaError(.fileReadUnsupportedScheme) + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} + /// Keychain wrapper. The key never goes in `UserDefaults` — this is a bring-your-own-key app, so /// the key is the whole privacy story. enum KeychainStore { diff --git a/ios/App/SettingsView.swift b/ios/App/SettingsView.swift index 4dc9910..73f99ea 100644 --- a/ios/App/SettingsView.swift +++ b/ios/App/SettingsView.swift @@ -315,7 +315,8 @@ struct SettingsView: View { } footer: { Text( "Failed dictations always keep their audio until they succeed, whatever this is " - + "set to — otherwise Retry could not work." + + "set to — otherwise Retry could not work. Keeping it for the ones that " + + "worked is what lets you redo a transcription or save the recording." ) } } @@ -775,6 +776,18 @@ struct HistoryView: View { .navigationTitle("History") // Searching is the point of keeping history at all; a log you cannot search is storage. .searchable(text: $model.query.text, prompt: "Transcripts, errors, apps") + // Files rather than a share sheet: the recording is evidence somebody wants to keep, and + // Save to Files is the ending that leaves it somewhere they can find again. + .fileExporter( + isPresented: Binding( + get: { model.audioExport != nil }, + set: { if !$0 { model.audioExport = nil } }), + document: model.audioExport, + contentType: .wav, + defaultFilename: model.audioExport?.name + ) { _ in + model.audioExport = nil + } .overlay { if model.records.isEmpty { ContentUnavailableView( @@ -816,6 +829,10 @@ private struct HistoryRow: View { Spacer() + // One circular arrow per row, doing the thing that row needs. On a failed dictation + // that is Retry — the words never reached the keyboard, so they are delivered. On a + // completed one it is a redo: the words arrived and arrived wrong, and re-running the + // transcription is the only fix that does not mean saying it all again. if model.retryingIDs.contains(record.id) { ProgressView() } else if record.canRetry { @@ -825,13 +842,38 @@ private struct HistoryRow: View { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) - } else if record.status == .completed { + .accessibilityLabel("Retry this dictation") + } else if record.canRedo { + Button { + Task { await model.redo(record) } + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .accessibilityLabel("Redo the transcription") + } + + if record.status == .completed { Button { UIPasteboard.general.string = record.text } label: { Image(systemName: "doc.on.doc") } .buttonStyle(.borderless) + .accessibilityLabel("Copy transcript") + } + + // The recording is the evidence behind the row: it is what a wrong transcript should + // be judged against, and the one thing here that cannot be reconstructed. Offered + // wherever it still exists. + if record.canRedo { + Button { + Task { await model.prepareAudioExport(record) } + } label: { + Image(systemName: "square.and.arrow.down") + } + .buttonStyle(.borderless) + .accessibilityLabel("Save the original audio") } } } diff --git a/windows/DoNotType.App/DictationController.cs b/windows/DoNotType.App/DictationController.cs index 110d466..d9836ee 100644 --- a/windows/DoNotType.App/DictationController.cs +++ b/windows/DoNotType.App/DictationController.cs @@ -1097,7 +1097,11 @@ private async Task TranscribeAsync( // ---- Retry ------------------------------------------------------------------------------- - /// Reissues one stored dictation. + /// Transcribes one stored recording again. + /// + /// Both the retry of a dictation that failed and the redo of one the user thinks came back + /// wrong: the request is the same either way. + /// public async Task RetryAsync(DictationRecord record) { var key = _settings.ResolvedApiKey(); @@ -1135,6 +1139,13 @@ public async Task RetryAsync(DictationRecord record) record.Status = DictationStatus.Completed; record.Text = result.Transcript.Text.Trim(); record.ErrorMessage = null; + record.ErrorDetail = null; + // The rewrite beside it was derived from the transcript that has just been replaced, + // so it goes with it. Keeping it would be worse than losing it: DeliveredText prefers + // the styled version, so a redo of a rewritten dictation would replace the words and + // still show the old ones -- a button that appears to do nothing. + record.StyledText = null; + record.Mode = TranscriptMode.Verbatim.Id; _history.Update(record); HistoryChanged?.Invoke(); return true; diff --git a/windows/DoNotType.App/SettingsForm.cs b/windows/DoNotType.App/SettingsForm.cs index 6ab2bd3..8b17ac7 100644 --- a/windows/DoNotType.App/SettingsForm.cs +++ b/windows/DoNotType.App/SettingsForm.cs @@ -491,7 +491,10 @@ private TabPage BuildHistoryTab() toolbar.Controls.Add(deleteAll); toolbar.Controls.Add(_historySummary); - // Per-item retry: double-clicking a failed row reissues just that dictation. + // Per-item retry: double-clicking a failed row reissues just that dictation. Deliberately + // still only a failed one, though a completed row can now be re-transcribed as well: that + // row is not broken, a double-click on it is as likely to be exploratory as intended, and + // every one of these spends a request. The redo asks for itself, from the menu below. _history.DoubleClick += async (_, _) => { if (SelectedRecord() is not { CanRetry: true } record) return; @@ -506,13 +509,22 @@ private TabPage BuildHistoryTab() var rowMenu = new ContextMenuStrip(); var deleteOne = new ToolStripMenuItem("Delete this transcript"); deleteOne.Click += (_, _) => DeleteSelected(); + // One item rather than two, renamed for the row it is opened on. The request is identical; + // what differs is what the user is asking for. On a failed row it is Retry — the words + // never arrived. On a completed one it is a redo — they arrived wrong, and re-running the + // transcription is the only fix that does not mean saying it all again. var retryOne = new ToolStripMenuItem("Retry this dictation"); retryOne.Click += async (_, _) => { - if (SelectedRecord() is not { CanRetry: true } record) return; + if (SelectedRecord() is not { CanRedo: true } record) return; await _controller.RetryAsync(record).ConfigureAwait(true); RefreshHistory(); }; + + // The recording is the evidence behind the row: what a wrong transcript should be judged + // against, and the one thing here that cannot be reconstructed. + var saveAudioOne = new ToolStripMenuItem("Save the original audio…"); + saveAudioOne.Click += (_, _) => SaveSelectedAudio(); // The point of the whole grounding argument: if the app reads your screen, you can read // what it read. One click from the row it belongs to, rather than a separate screen you // have to know exists. @@ -532,12 +544,16 @@ private TabPage BuildHistoryTab() rowMenu.Opening += (_, _) => { var record = SelectedRecord(); - retryOne.Enabled = record?.CanRetry == true; + retryOne.Text = record?.Status == DictationStatus.Completed + ? "Redo the transcription" + : "Retry this dictation"; + retryOne.Enabled = record?.CanRedo == true; + saveAudioOne.Enabled = record?.CanRedo == true; copyOne.Enabled = record?.Text.Length > 0; deleteOne.Enabled = record is not null; }; rowMenu.Items.AddRange( - [inspectOne, retryOne, copyOne, new ToolStripSeparator(), deleteOne]); + [inspectOne, retryOne, saveAudioOne, copyOne, new ToolStripSeparator(), deleteOne]); _history.ContextMenuStrip = rowMenu; _history.KeyDown += (_, e) => @@ -558,6 +574,42 @@ private TabPage BuildHistoryTab() ? null : _history.SelectedItems[0].Tag as DictationRecord; + /// + /// Writes the selected row's recording somewhere the user chose. A copy rather than a move: + /// the history keeps its own file, so saving a recording does not cost the ability to redo it. + /// + private void SaveSelectedAudio() + { + if (SelectedRecord() is not { CanRedo: true } record) return; + + var wav = _controller.History.AudioFor(record); + if (wav is null) + { + _historySummary.Text = "The recording for this dictation is no longer on disk."; + return; + } + + using var dialog = new SaveFileDialog + { + FileName = record.AudioExportName, + Filter = "WAV audio (*.wav)|*.wav", + DefaultExt = "wav", + AddExtension = true, + }; + if (dialog.ShowDialog(this) != DialogResult.OK) return; + + try + { + File.WriteAllBytes(dialog.FileName, wav); + _historySummary.Text = $"Saved {Path.GetFileName(dialog.FileName)}."; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + // Uncut: the path and the reason are what the user needs to pick a different folder. + _historySummary.Text = error.Message; + } + } + private void DeleteSelected() { // Multi-select is enabled, so a range delete is one action rather than N. @@ -613,7 +665,8 @@ private void RefreshHistory() _historySummary.Text = $"{shown} · {retryable} to retry · {bytes / 1024} KB audio" - + " (double-click to retry · Delete key or right-click to remove)" + + " (double-click to retry · right-click to redo one or save its audio" + + " · Delete key or right-click to remove)" + performance; } diff --git a/windows/DoNotType.Core.Tests/DictationJourneyTests.cs b/windows/DoNotType.Core.Tests/DictationJourneyTests.cs index 3082221..c6ad6af 100644 --- a/windows/DoNotType.Core.Tests/DictationJourneyTests.cs +++ b/windows/DoNotType.Core.Tests/DictationJourneyTests.cs @@ -126,6 +126,50 @@ public void RetentionOffStillKeepsAudioForAnythingRetryable() Assert.Null(completed.AudioFileName); } + // ---- Redoing a transcript that arrived wrong --------------------------------------------- + + /// + /// The other reason to keep a recording: a dictation that succeeded and still came + /// back wrong. Retry cannot reach it -- the row is completed -- so the offer is keyed to the + /// audio being there rather than to the status. + /// + [Fact] + public void ACompletedDictationCanBeRedoneWhileItsAudioIsKept() + { + _store.Configure(RetentionPolicy.Forever, keepAudioForCompleted: true); + var entry = Record(DictationStatus.Completed); + entry.Text = "meet Bo Jelly at four"; + + var stored = _store.Insert(entry, Wav()); + + Assert.False(stored.CanRetry, "a completed dictation has nothing to retry"); + Assert.True(stored.CanRedo, "but its recording is still there to transcribe again"); + Assert.NotNull(_store.AudioFor(stored)); + } + + /// + /// Without the audio there is nothing to send, so nothing is offered -- the default for a + /// completed dictation, which discards its recording. + /// + [Fact] + public void ADiscardedRecordingCannotBeRedone() + { + var stored = _store.Insert(Record(DictationStatus.Completed), Wav()); + + Assert.Null(stored.AudioFileName); + Assert.False(stored.CanRedo); + } + + /// The recording is saved under the time it was said, not the GUID it has on disk. + [Fact] + public void ASavedRecordingIsNamedForWhenItWasSaid() + { + var entry = Record(DictationStatus.Completed); + entry.CreatedAt = new DateTimeOffset(2026, 8, 28, 14, 32, 5, TimeSpan.Zero); + + Assert.Equal("donottype-20260828-143205.wav", entry.AudioExportName); + } + // ---- The fallback is recorded honestly --------------------------------------------------- /// A hedged dictation must name the backend that answered, not the one asked. diff --git a/windows/DoNotType.Core/History.cs b/windows/DoNotType.Core/History.cs index 5a664f3..82909fc 100644 --- a/windows/DoNotType.Core/History.cs +++ b/windows/DoNotType.Core/History.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -128,6 +129,28 @@ public sealed class DictationRecord [JsonIgnore] public bool CanRetry => IsRetryable && AudioFileName is not null; + /// + /// Whether the transcription can be run again, and the recording saved out of the history. + /// + /// + /// A superset of , and a different question. Retry is about words that + /// never arrived; redoing is about words that arrived wrong -- a name misheard, a provider + /// that was the wrong one for the accent -- and that case is a completed dictation, + /// which keeps its audio only when the keep-audio setting was on when it was made. + /// + [JsonIgnore] + public bool CanRedo => AudioFileName is not null; + + /// What to call the recording when it is saved somewhere the user chose. + /// + /// Named for when it was said. On disk it is the record's GUID, which is the right name for a + /// file the store owns and a useless one in a downloads folder next to twenty others. The + /// format is fixed rather than cultural: a filename with a slash in it is not a filename. + /// + [JsonIgnore] + public string AudioExportName => + $"donottype-{CreatedAt.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.wav"; + [JsonIgnore] public string Summary => Status switch {