Skip to content
Draft
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
8 changes: 8 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */; };
A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62300000000000000000001 /* AudioBufferConverterTests.swift */; };
C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.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 @@ -54,6 +56,8 @@
980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = "<group>"; };
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>"; };
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 @@ -132,6 +136,8 @@
980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */,
A62300000000000000000001 /* AudioBufferConverterTests.swift */,
C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */,
A62300000000000000000021 /* BatchTranscriptionCoordinatorTests.swift */,
A62300000000000000000031 /* PromiseDropSupportTests.swift */,
);
path = FluidDictationIntegrationTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -291,6 +297,8 @@
272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */,
A62300000000000000000002 /* AudioBufferConverterTests.swift in Sources */,
C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */,
A62300000000000000000022 /* BatchTranscriptionCoordinatorTests.swift in Sources */,
A62300000000000000000032 /* PromiseDropSupportTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
49 changes: 49 additions & 0 deletions Sources/Fluid/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2012,6 +2012,10 @@ struct ContentView: View {
// MARK: - Stop and Process Transcription

private func stopAndProcessTranscription(route: DictationOutputRoute = .normal) async {
// Recording session is ending: release dictation intent so the batch arbiter
// (and any dictation restart) no longer sees dictation as active.
FileTranscriptionSession.shared.endDictationIntent()

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 @@ -3036,6 +3040,19 @@ struct ContentView: View {

/// Capture app context at start to avoid mismatches if the user switches apps mid-session
private func startRecording() {
// File-transcription batches drive the same shared ASR model; starting
// dictation mid-batch would run concurrent inference through one model.
guard !FileTranscriptionSession.isBatchTranscribing else {
self.notifyDictationBlockedByBatch()
return
}

// Signal dictation intent synchronously, before any `await` below, so the
// batch transcribe closure's arbitration check (which also reads this flag)
// cannot race a window where neither isBatchTranscribing nor asr.isRunning
// is true yet.
FileTranscriptionSession.shared.beginDictationIntent()

let model = SettingsStore.shared.selectedSpeechModel
DebugLogger.shared.info(
"ContentView: startRecording() for model=\(model.displayName), supportsStreaming=\(model.supportsStreaming)",
Expand Down Expand Up @@ -3067,6 +3084,7 @@ struct ContentView: View {
})
if !self.asr.isRunning {
self.menuBarManager.hideRecordingOverlayImmediately(reason: "asr_start_failed")
FileTranscriptionSession.shared.endDictationIntent()
}
}

Expand Down Expand Up @@ -3301,6 +3319,12 @@ struct ContentView: View {
self.menuBarManager.setOverlayMode(.command)

guard !self.asr.isRunning else { return }
// Command mode starts recording directly (it does not route through
// beginDictationRecording), so it needs its own batch guard.
guard !FileTranscriptionSession.isBatchTranscribing else {
self.notifyDictationBlockedByBatch()
return
}

self.advanceOverlayLifecycle()

Expand Down Expand Up @@ -3341,6 +3365,12 @@ struct ContentView: View {
self.setActiveRecordingMode(.edit)

guard !self.asr.isRunning else { return }
// Rewrite/edit mode starts recording directly (it does not route through
// beginDictationRecording), so it needs its own batch guard.
guard !FileTranscriptionSession.isBatchTranscribing else {
self.notifyDictationBlockedByBatch()
return
}

self.advanceOverlayLifecycle()

Expand Down Expand Up @@ -3659,6 +3689,13 @@ extension ContentView {
}

private func beginDictationRecording(for slot: SettingsStore.DictationShortcutSlot, mode: ActiveRecordingMode) {
// Common chokepoint for dictate/prompt/command/rewrite hotkeys: file-transcription
// batches drive the same shared ASR model, so none of these modes may start
// while a batch is running.
guard !FileTranscriptionSession.isBatchTranscribing else {
self.notifyDictationBlockedByBatch()
return
}
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 Down Expand Up @@ -3708,6 +3745,18 @@ extension ContentView {
self.beginDictationRecording(for: .secondary, mode: mode)
}

/// Feedback when a dictation-family hotkey (dictate/prompt/command/rewrite) is
/// rejected because a file-transcription batch is running. Reuses the existing
/// stop cue rather than building new UI, so the user gets an audible signal that
/// the hotkey press had no effect instead of silence.
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
18 changes: 18 additions & 0 deletions Sources/Fluid/ObjCExceptionCatcher.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#ifndef OBJC_EXCEPTION_CATCHER_H
#define OBJC_EXCEPTION_CATCHER_H

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

/// Runs the block, catching any Objective-C exception it raises.
/// Returns the exception's description, or nil if the block completed cleanly.
/// Needed because NSPasteboard access from background threads can raise
/// NSInternalInconsistencyException on internal races, and a C++ library in the
/// process installs a terminate handler that 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";
}
}
Loading
Loading