Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
abafe64
feat: accept Voice Memos drags and batch file transcription (#219)
shreeraman96 Jul 26, 2026
30a04be
fix: deliver every item of a multi-memo drag; add batch UI screenshot
shreeraman96 Jul 26, 2026
41951be
fix: crash on multi-item promise drags — serialize pasteboard access
shreeraman96 Jul 26, 2026
e6137ea
fix: address xhigh code-review findings
shreeraman96 Jul 27, 2026
61f65b2
fix: hold dictation intent across every start, stop, and cancel path
shreeraman96 Aug 1, 2026
8457208
fix: sanitize promised file names and bound fallback deliveries
shreeraman96 Aug 1, 2026
e659501
fix: give every delivered promise file its own staging dir
shreeraman96 Aug 2, 2026
a7e75d8
fix: drop a cancelled transcription instead of writing it to history
shreeraman96 Aug 2, 2026
ebeec17
style: compress Voice Memos drop and batch comments
shreeraman96 Aug 2, 2026
a358531
fix: expect promised file names, not receivers, when bounding delivery
shreeraman96 Aug 2, 2026
b455632
style: cut branch comment density below 10% per file
shreeraman96 Aug 2, 2026
809f0f5
fix: survive an undecodable promise and stop stalling a lone drop
shreeraman96 Aug 2, 2026
c67a687
fix: keep partial receiver output and count legacy promise names
shreeraman96 Aug 2, 2026
5c07b86
fix: check the batch guard before flipping recording mode
shreeraman96 Aug 2, 2026
8057bd6
style: compress this branch's comments
shreeraman96 Aug 2, 2026
5cb1d04
fix: keep polling a receiver that is still writing past the soft timeout
shreeraman96 Aug 2, 2026
c44affd
fix: shorten the delivery wait once a receiver has completed
shreeraman96 Aug 3, 2026
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
8 changes: 8 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000001 /* AudioBufferConverterTests.swift */; };
C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */; };
DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */; };
A62300000000000000000022 /* BatchTranscriptionCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000021 /* BatchTranscriptionCoordinatorTests.swift */; };
A62300000000000000000032 /* PromiseDropSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000031 /* PromiseDropSupportTests.swift */; };
7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; };
7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; };
7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; };
Expand Down Expand Up @@ -56,6 +58,8 @@
A62300000000000000000001 /* AudioBufferConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferConverterTests.swift; sourceTree = "<group>"; };
C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngineRetirementDrainTests.swift; sourceTree = "<group>"; };
DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectAudioReliabilityTests.swift; sourceTree = "<group>"; };
A62300000000000000000021 /* BatchTranscriptionCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchTranscriptionCoordinatorTests.swift; sourceTree = "<group>"; };
A62300000000000000000031 /* PromiseDropSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromiseDropSupportTests.swift; sourceTree = "<group>"; };
7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; };
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = "<group>"; };
7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -135,6 +139,8 @@
A62300000000000000000001 /* AudioBufferConverterTests.swift */,
C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */,
DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */,
A62300000000000000000021 /* BatchTranscriptionCoordinatorTests.swift */,
A62300000000000000000031 /* PromiseDropSupportTests.swift */,
);
path = FluidDictationIntegrationTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -295,6 +301,8 @@
A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */,
C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */,
DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */,
A62300000000000000000022 /* BatchTranscriptionCoordinatorTests.swift in Sources */,
A62300000000000000000032 /* PromiseDropSupportTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
53 changes: 46 additions & 7 deletions Sources/Fluid/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2012,6 +2012,8 @@ struct ContentView: View {
// MARK: - Stop and Process Transcription

private func stopAndProcessTranscription(route: DictationOutputRoute = .normal) async {
defer { FileTranscriptionSession.shared.endDictationIntent() } // released after the final transcription pass

DebugLogger.shared.debug("stopAndProcessTranscription called", source: "ContentView")
DebugLogger.shared.info("Output route selected: \(route.rawValue)", source: "ContentView")
self.appBench("stop_path_enter route=\(route.rawValue)")
Expand Down Expand Up @@ -3074,6 +3076,12 @@ struct ContentView: View {

/// Capture app context at start to avoid mismatches if the user switches apps mid-session
private func startRecording() {
// avoid concurrent inference on the shared ASR model
guard !FileTranscriptionSession.isBatchTranscribing else {
self.notifyDictationBlockedByBatch()
return
Comment thread
shreeraman96 marked this conversation as resolved.
}

let model = SettingsStore.shared.selectedSpeechModel
DebugLogger.shared.info(
"ContentView: startRecording() for model=\(model.displayName), supportsStreaming=\(model.supportsStreaming)",
Expand All @@ -3084,6 +3092,8 @@ struct ContentView: View {
return
}

FileTranscriptionSession.shared.beginDictationIntent() // synchronous, before any await, so an ignored start can't latch it

self.advanceOverlayLifecycle()
self.setActiveRecordingMode(.dictate)
let shouldShowDictationOverlay = !self.isRecordingForCommand
Expand Down Expand Up @@ -3119,6 +3129,7 @@ struct ContentView: View {
})
if startOutcome == .failed {
self.menuBarManager.hideRecordingOverlayImmediately(reason: "asr_start_failed")
FileTranscriptionSession.shared.endDictationIntent()
}
}

Expand Down Expand Up @@ -3344,15 +3355,19 @@ struct ContentView: View {
},
commandModeCallback: {
DebugLogger.shared.info("Command mode triggered", source: "ContentView")
self.captureRecordingContext()

// Set flag so stopAndProcessTranscription knows to process as command
self.setActiveRecordingMode(.command)
// Bypasses beginDictationRecording, so it needs its own batch guard — and before the mode
// flip, which would otherwise leave the overlay in command mode with nothing recording.
guard !FileTranscriptionSession.isBatchTranscribing else {
self.notifyDictationBlockedByBatch()
return
Comment thread
shreeraman96 marked this conversation as resolved.
}

// Set overlay mode to command
self.captureRecordingContext()
self.setActiveRecordingMode(.command) // tells stopAndProcessTranscription to process as command
self.menuBarManager.setOverlayMode(.command)

guard !self.asr.isRunningOrStarting else { return }
guard !self.asr.isRunningOrStarting else { return } // mid-recording mode switch ends here: no restart
FileTranscriptionSession.shared.beginDictationIntent()

self.advanceOverlayLifecycle()

Expand All @@ -3370,12 +3385,19 @@ struct ContentView: View {
self.menuBarManager.hideRecordingOverlayImmediately(
reason: "command_asr_start_failed"
)
FileTranscriptionSession.shared.endDictationIntent()
}
}
},
rewriteModeCallback: {
guard !self.showPrivateAIEditModeUnavailableIfNeeded() else { return }

// Ahead of the capture and mode flip, which would otherwise strand the overlay in edit mode.
guard !FileTranscriptionSession.isBatchTranscribing else {
self.notifyDictationBlockedByBatch()
return
}

self.captureRecordingContext()

// Try to capture text first while still in the other app
Expand All @@ -3401,7 +3423,8 @@ struct ContentView: View {
// Set flag so stopAndProcessTranscription knows to process as rewrite
self.setActiveRecordingMode(.edit)

guard !self.asr.isRunningOrStarting else { return }
guard !self.asr.isRunningOrStarting else { return } // mid-recording mode switch ends here: no restart
FileTranscriptionSession.shared.beginDictationIntent()

self.advanceOverlayLifecycle()

Expand All @@ -3416,6 +3439,7 @@ struct ContentView: View {
self.menuBarManager.hideRecordingOverlayImmediately(
reason: "edit_asr_start_failed"
)
FileTranscriptionSession.shared.endDictationIntent()
}
}
},
Expand Down Expand Up @@ -3727,6 +3751,10 @@ extension ContentView {
}

private func beginDictationRecording(for slot: SettingsStore.DictationShortcutSlot, mode: ActiveRecordingMode) {
guard !FileTranscriptionSession.isBatchTranscribing else {
self.notifyDictationBlockedByBatch()
return
}
Comment thread
shreeraman96 marked this conversation as resolved.
DebugLogger.shared.debug("Begin dictation recording for slot \(slot.rawValue)", source: "ContentView")
self.appBench("begin_recording slot=\(slot.rawValue) mode=\(mode.rawValue)")
if self.isOnboardingVoicePlaygroundStepActive {
Expand All @@ -3744,6 +3772,7 @@ extension ContentView {
self.appBench("asr_start_skipped reason=already_running_or_starting")
return
}
FileTranscriptionSession.shared.beginDictationIntent() // claimed before the async start, since isRunning hasn't flipped yet
self.advanceOverlayLifecycle()
if self.asr.micStatus == .authorized {
self.appBench("overlay_mode_request mode=Dictation")
Expand All @@ -3765,6 +3794,7 @@ extension ContentView {
})
if startOutcome == .failed {
self.menuBarManager.hideRecordingOverlayImmediately(reason: "asr_start_failed")
FileTranscriptionSession.shared.endDictationIntent()
}
DebugLogger.shared.benchmark(
"APP_BENCH",
Expand All @@ -3780,6 +3810,15 @@ extension ContentView {
self.beginDictationRecording(for: .secondary, mode: mode)
}

/// reuses the stop cue as feedback when blocked by a batch
private func notifyDictationBlockedByBatch() {
DebugLogger.shared.warning(
"Dictation blocked: batch file transcription in progress",
source: "ContentView"
)
TranscriptionSoundPlayer.shared.playStopSound()
}

private func appBench(_ message: String) {
DebugLogger.shared.benchmark("APP_BENCH", message: message, source: "AppBenchmark")
}
Expand Down
1 change: 1 addition & 0 deletions Sources/Fluid/Fluid-Bridging-Header.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
#define FLUID_BRIDGING_HEADER_H

#include "CoreAudioCaptureSupportBridge.h"
#include "ObjCExceptionCatcher.h"

#endif
15 changes: 15 additions & 0 deletions Sources/Fluid/ObjCExceptionCatcher.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#ifndef OBJC_EXCEPTION_CATCHER_H
#define OBJC_EXCEPTION_CATCHER_H

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

/// Runs the block, returning a raised exception's description or nil if it completed.
/// Pasteboard and file-promise calls raise, and a C++ terminate handler in-process
/// turns any uncaught NSException into abort().
NSString *_Nullable FluidCatchObjCException(void (NS_NOESCAPE ^block)(void));

NS_ASSUME_NONNULL_END

#endif
10 changes: 10 additions & 0 deletions Sources/Fluid/ObjCExceptionCatcher.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#import "ObjCExceptionCatcher.h"

NSString *FluidCatchObjCException(void (NS_NOESCAPE ^block)(void)) {
@try {
block();
return nil;
} @catch (NSException *exception) {
return exception.description ?: @"unknown Objective-C exception";
}
}
3 changes: 3 additions & 0 deletions Sources/Fluid/Services/ASRService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2161,6 +2161,9 @@ final class ASRService: ObservableObject {
}

func stopWithoutTranscription() async {
// Cancel paths skip stop-and-process; above the guard to cover the starting phase.
defer { FileTranscriptionSession.endDictationIntentIfCreated() }

if self.isStarting, self.isRunning == false {
await self.cancelPendingAudioCaptureStart(reason: "stop_without_transcription")
}
Expand Down
Loading
Loading