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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,14 @@ endif
test: $(TEST_RUNNER)
@$(TEST_RUNNER)

$(TEST_RUNNER): Sources/AppContextService.swift Sources/AppleFoundationModelsPostProcessor.swift Sources/DictionaryStore.swift Sources/TranscriptTidier.swift Sources/TransformStore.swift Sources/WakePhraseMatcher.swift Tests/AppContextServiceTests.swift Tests/DictionaryStoreTests.swift Tests/TranscriptTidierTests.swift Tests/TransformStoreTests.swift Tests/WakePhraseMatcherTests.swift
$(TEST_RUNNER): Sources/AppContextService.swift Sources/AppleFoundationModelsPostProcessor.swift Sources/DictionaryStore.swift Sources/TranscriptTidier.swift Sources/TransformStore.swift Sources/WakePhraseMatcher.swift Tests/AppContextServiceTests.swift Tests/DictionaryStoreTests.swift Tests/TranscriptTidierTests.swift Tests/TransformStoreTests.swift Tests/WakePhraseMatcherTests.swift Sources/ScratchCommandMatcher.swift Tests/ScratchCommandMatcherTests.swift
@mkdir -p "$(BUILD_DIR)"
swiftc \
-parse-as-library \
-o "$(TEST_RUNNER)" \
-sdk $(shell xcrun --show-sdk-path) \
-target $(ARCH)-apple-macosx26.0 \
Sources/AppContextService.swift Sources/AppleFoundationModelsPostProcessor.swift Sources/DictionaryStore.swift Sources/TranscriptTidier.swift Sources/TransformStore.swift Sources/WakePhraseMatcher.swift Tests/AppContextServiceTests.swift Tests/DictionaryStoreTests.swift Tests/TranscriptTidierTests.swift Tests/TransformStoreTests.swift Tests/WakePhraseMatcherTests.swift
Sources/AppContextService.swift Sources/AppleFoundationModelsPostProcessor.swift Sources/DictionaryStore.swift Sources/TranscriptTidier.swift Sources/TransformStore.swift Sources/WakePhraseMatcher.swift Tests/AppContextServiceTests.swift Tests/DictionaryStoreTests.swift Tests/TranscriptTidierTests.swift Tests/TransformStoreTests.swift Tests/WakePhraseMatcherTests.swift Sources/ScratchCommandMatcher.swift Tests/ScratchCommandMatcherTests.swift

icon: $(ICON_ICNS)

Expand Down
144 changes: 143 additions & 1 deletion Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ final class AppState: ObservableObject, @unchecked Sendable {
private let preserveExactWordingStorageKey = "preserve_exact_wording"
private let keepDictationInClipboardHistoryStorageKey = "keep_dictation_in_clipboard_history"
private let pressEnterVoiceCommandStorageKey = "press_enter_voice_command_enabled"
private let scratchThatCommandStorageKey = "scratch_that_command_enabled"
private let alertSoundsEnabledStorageKey = "alert_sounds_enabled"
private let soundVolumeStorageKey = "sound_volume"
private let startSoundNameStorageKey = "start_sound_name"
Expand Down Expand Up @@ -483,6 +484,12 @@ final class AppState: ObservableObject, @unchecked Sendable {
}
}

@Published var isScratchThatCommandEnabled: Bool {
didSet {
UserDefaults.standard.set(isScratchThatCommandEnabled, forKey: scratchThatCommandStorageKey)
}
}

@Published var alertSoundsEnabled: Bool {
didSet {
UserDefaults.standard.set(alertSoundsEnabled, forKey: alertSoundsEnabledStorageKey)
Expand Down Expand Up @@ -633,6 +640,11 @@ final class AppState: ObservableObject, @unchecked Sendable {
private var pendingMicrophonePermissionManualCommandRequested: Bool?
private let postTranscriptionUpdateReminderDuration: TimeInterval = 7
private let wakeCommandPreviousTextWindow: TimeInterval = 120
/// History entries recorded at or before this instant are ignored when
/// looking up the previously inserted dictation. Set after "scratch that"
/// deletes text so a repeated scratch (or a wake command) cannot act on
/// the already-deleted dictation again.
private var previousDictationInvalidatedAt: Date?

init() {
UserDefaults.standard.removeObject(forKey: "force_http2_transcription")
Expand Down Expand Up @@ -695,6 +707,9 @@ final class AppState: ObservableObject, @unchecked Sendable {
let isPressEnterVoiceCommandEnabled = UserDefaults.standard.object(forKey: pressEnterVoiceCommandStorageKey) == nil
? true
: UserDefaults.standard.bool(forKey: pressEnterVoiceCommandStorageKey)
let isScratchThatCommandEnabled = UserDefaults.standard.object(forKey: scratchThatCommandStorageKey) == nil
? true
: UserDefaults.standard.bool(forKey: scratchThatCommandStorageKey)
let soundVolume: Float = UserDefaults.standard.object(forKey: soundVolumeStorageKey) != nil
? UserDefaults.standard.float(forKey: soundVolumeStorageKey) : 1.0
let alertSoundsEnabled = UserDefaults.standard.object(forKey: alertSoundsEnabledStorageKey) != nil
Expand Down Expand Up @@ -770,6 +785,7 @@ final class AppState: ObservableObject, @unchecked Sendable {
self.keepDictationInClipboardHistory = keepDictationInClipboardHistory
self.dictationAudioInterruptionEnabled = dictationAudioInterruptionEnabled
self.isPressEnterVoiceCommandEnabled = isPressEnterVoiceCommandEnabled
self.isScratchThatCommandEnabled = isScratchThatCommandEnabled
self.alertSoundsEnabled = alertSoundsEnabled
self.soundVolume = soundVolume
self.startSoundName = startSoundName
Expand Down Expand Up @@ -2392,6 +2408,9 @@ final class AppState: ObservableObject, @unchecked Sendable {
pipelineHistory.first { item in
let text = item.postProcessedTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return false }
if let invalidatedAt = previousDictationInvalidatedAt, item.timestamp <= invalidatedAt {
return false
}
let age = now.timeIntervalSince(item.timestamp)
guard age >= 0, age <= wakeCommandPreviousTextWindow else { return false }

Expand Down Expand Up @@ -2724,12 +2743,21 @@ final class AppState: ObservableObject, @unchecked Sendable {
pressEnterCommandEnabled: self.isPressEnterVoiceCommandEnabled
)
try Task.checkCancellation()
let isScratchCommand: Bool
if case .dictation = sessionIntent {
isScratchCommand = self.isScratchThatCommandEnabled
&& ScratchCommandMatcher.matches(parsedTranscript.transcript)
} else {
isScratchCommand = false
}
// Capture the parsed raw transcript as lastTranscript before
// post-processing runs. If anything after this throws or focus
// shifts mid-paste, the Paste Again shortcut still has the raw
// text instead of the previous dictation's stale value.
// Scratch commands never paste, so they keep the previous
// transcript available for Paste Again instead.
let bootstrapTranscript = parsedTranscript.transcript.trimmingCharacters(in: .whitespacesAndNewlines)
if !bootstrapTranscript.isEmpty {
if !isScratchCommand, !bootstrapTranscript.isEmpty {
await MainActor.run { [weak self] in
self?.lastTranscript = bootstrapTranscript
}
Expand All @@ -2746,6 +2774,18 @@ final class AppState: ObservableObject, @unchecked Sendable {
self.recentTextForWakeCommand(in: appContext, now: Date())
}
try Task.checkCancellation()
if isScratchCommand {
await MainActor.run {
guard self.isTranscribing else { return }
self.completeScratchCommand(
previousText: previousText,
rawTranscript: parsedTranscript.transcript,
context: appContext,
audioFileName: savedAudioFile?.fileName
)
}
return
}
await MainActor.run { [weak self] in
self?.debugStatusMessage = "Running post-processing"
}
Expand Down Expand Up @@ -2968,6 +3008,95 @@ final class AppState: ObservableObject, @unchecked Sendable {
}
}

/// Terminal handling for a whole-utterance "scratch that" dictation: the
/// utterance is never pasted; instead the previously inserted dictation
/// is deleted when it still sits immediately before the caret.
@MainActor
private func completeScratchCommand(
previousText: String?,
rawTranscript: String,
context: AppContext,
audioFileName: String?
) {
lastContextSummary = context.contextSummary
lastContextScreenshotDataURL = nil
lastContextScreenshotStatus = "Not captured (local-only)"
lastContextAppName = context.appName ?? ""
lastContextBundleIdentifier = context.bundleIdentifier ?? ""
lastContextWindowTitle = context.windowTitle ?? ""
lastContextSelectedText = context.selectedText ?? ""
lastContextLLMPrompt = ""
lastPostProcessingPrompt = ""
lastRawTranscript = rawTranscript
lastPostProcessedTranscript = ""
transcriptionTask = nil
transcribingAudioFileName = nil
isTranscribing = false
endCriticalDictationActivity()
debugStatusMessage = "Done"
clearPendingOverlayDismissToken()
audioRecorder.cleanup()
refreshAvailableMicrophonesIfNeeded()

guard let previousText else {
finishScratchCommand(
scratched: false,
rawTranscript: rawTranscript,
context: context,
audioFileName: audioFileName
)
return
}

// Selecting and deleting synthesizes key events, so wait for the
// dictation shortcut to be fully released — the same discipline the
// paste path follows.
performAfterShortcutReleased { [weak self] in
guard let self else { return }
let scratched = self.contextService.selectTextImmediatelyBeforeCaret(matching: previousText)
if scratched {
self.pressDelete()
// Forget the deleted dictation so a second "scratch that"
// cannot select-and-delete unrelated text that happens to
// match it.
self.previousDictationInvalidatedAt = Date()
}
self.finishScratchCommand(
scratched: scratched,
rawTranscript: rawTranscript,
context: context,
audioFileName: audioFileName
)
}
}

private func finishScratchCommand(
scratched: Bool,
rawTranscript: String,
context: AppContext,
audioFileName: String?
) {
let status = scratched ? "Scratched last dictation" : "Nothing to scratch"
lastPostProcessingStatus = status
recordPipelineHistoryEntry(
rawTranscript: rawTranscript,
postProcessedTranscript: "",
postProcessingPrompt: "",
systemPrompt: Self.resolvedSystemPrompt(customSystemPrompt),
context: context,
processingStatus: status,
intent: .dictation,
audioFileName: audioFileName
)
statusText = status
if scratched {
overlayManager.dismiss()
} else {
overlayManager.showError("Nothing to scratch")
}
scheduleReadyStatusReset(after: 3, matching: [status])
}

/// Start streaming microphone audio into the on-device SpeechAnalyzer.
/// PCM16 samples (24 kHz mono) are analyzed while the user is still
/// speaking, so the transcript is essentially ready at stop time. Setup
Expand Down Expand Up @@ -3239,6 +3368,19 @@ final class AppState: ObservableObject, @unchecked Sendable {
keyUp?.post(tap: .cgSessionEventTap)
}

/// Synthesizes a Delete (backspace, kVK_Delete = 51) key press. With the
/// previous dictation selected via `selectTextImmediatelyBeforeCaret`, a
/// single Delete removes the whole selection.
private func pressDelete() {
let source = CGEventSource(stateID: .hidSystemState)

let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 51, keyDown: true)
keyDown?.post(tap: .cgSessionEventTap)

let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 51, keyDown: false)
keyUp?.post(tap: .cgSessionEventTap)
}

/// Writes the final transcript to the system pasteboard.
/// Also handles appending necessary trailing spaces, declaring transient
/// types for clipboard managers, and saving the clipboard state for later restoration.
Expand Down
37 changes: 37 additions & 0 deletions Sources/ScratchCommandMatcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation

/// Recognizes whole-utterance "scratch that" commands that ask Megaphone to
/// delete the previously inserted dictation instead of pasting new text.
///
/// Matching is deliberately strict: after trimming, casing, and punctuation
/// tolerance, the entire utterance must be one of a small set of phrases.
/// "Scratch that itch, please" is regular dictation, not a command.
enum ScratchCommandMatcher {
static func matches(_ transcript: String) -> Bool {
let normalized = normalize(transcript)
guard !normalized.isEmpty else { return false }
return phrases.contains(normalized)
}

/// Lowercases and collapses every run of whitespace, punctuation, or
/// symbols into a single separator so "Scratch that." and "scratch, that!"
/// normalize to the same key.
private static func normalize(_ transcript: String) -> String {
transcript
.lowercased()
.components(separatedBy: separators)
.filter { !$0.isEmpty }
.joined(separator: " ")
}

private static let phrases: Set<String> = [
"scratch that",
"scratch this",
"delete that",
"delete this"
]

private static let separators = CharacterSet.whitespacesAndNewlines
.union(.punctuationCharacters)
.union(.symbols)
}
9 changes: 9 additions & 0 deletions Sources/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,15 @@ struct GeneralSettingsView: View {
Text("When the transcription ends with \"press enter\", \(AppName.displayName) removes those words before cleanup, pastes the remaining transcript, then presses Return.")
.font(.caption)
.foregroundStyle(.secondary)

Divider()
.padding(.vertical, 2)

Toggle("“Scratch that” deletes the last dictation", isOn: $appState.isScratchThatCommandEnabled)

Text("When an entire dictation is \"scratch that\" or \"delete that\", \(AppName.displayName) deletes the dictation it just pasted instead of typing the words — as long as that text still sits right before your cursor.")
.font(.caption)
.foregroundStyle(.secondary)
}
}

Expand Down
1 change: 1 addition & 0 deletions Tests/AppContextServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct AppContextServiceTests {
DictionaryStoreTests.run()
WakePhraseMatcherTests.run()
TransformStoreTests.run()
ScratchCommandMatcherTests.run()
print("MegaphoneTests passed")
}

Expand Down
76 changes: 76 additions & 0 deletions Tests/ScratchCommandMatcherTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation

enum ScratchCommandMatcherTests {
static func run() {
testWholeUtterancePhrases()
testPunctuationAndCasingVariants()
testNonCommandsAreRejected()
}

private static func testWholeUtterancePhrases() {
let commands = [
"scratch that",
"scratch this",
"delete that",
"delete this"
]
for transcript in commands {
expect(
ScratchCommandMatcher.matches(transcript),
"Did not recognize \(transcript.debugDescription)"
)
}
}

private static func testPunctuationAndCasingVariants() {
let variants = [
"Scratch that.",
"SCRATCH THAT!",
" scratch that… ",
"Scratch, that",
"scratch — that",
"Delete that?",
"“Delete this.”",
"Scratch that\n"
]
for transcript in variants {
expect(
ScratchCommandMatcher.matches(transcript),
"Did not tolerate \(transcript.debugDescription)"
)
}
}

private static func testNonCommandsAreRejected() {
let rejected = [
"",
" ",
"scratch",
"that",
"scratch that itch please",
"please scratch that",
"scratch that and start over",
"you can scratch that",
"delete that file from the repo",
"we should scratch that idea",
"scratched that",
"scratch thats"
]
for transcript in rejected {
expect(
!ScratchCommandMatcher.matches(transcript),
"False positive for \(transcript.debugDescription)"
)
}
}

private static func expect(
_ condition: Bool,
_ message: String,
file: StaticString = #file,
line: UInt = #line
) {
guard !condition else { return }
fatalError("\(file):\(line): \(message)")
}
}