chore: adiciona .gitignore e commit.command
This commit is contained in:
@@ -0,0 +1,673 @@
|
||||
//
|
||||
// TranscriptionFeature.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/24/25.
|
||||
//
|
||||
|
||||
import ComposableArchitecture
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
import WhisperKit
|
||||
|
||||
private let transcriptionFeatureLogger = HexLog.transcription
|
||||
|
||||
@Reducer
|
||||
struct TranscriptionFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var isRecording: Bool = false
|
||||
var isTranscribing: Bool = false
|
||||
var isPrewarming: Bool = false
|
||||
var error: String?
|
||||
var recordingStartTime: Date?
|
||||
var meter: Meter = .init(averagePower: 0, peakPower: 0)
|
||||
var sourceAppBundleID: String?
|
||||
var sourceAppName: String?
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.isRemappingScratchpadFocused) var isRemappingScratchpadFocused: Bool = false
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
}
|
||||
|
||||
enum Action {
|
||||
case task
|
||||
case audioLevelUpdated(Meter)
|
||||
|
||||
// Hotkey actions
|
||||
case hotKeyPressed
|
||||
case hotKeyReleased
|
||||
|
||||
// Recording flow
|
||||
case startRecording
|
||||
case stopRecording
|
||||
|
||||
// Cancel/discard flow
|
||||
case cancel // Explicit cancellation with sound
|
||||
case discard // Silent discard (too short/accidental)
|
||||
|
||||
// Transcription result flow
|
||||
case transcriptionResult(String, URL, TimeInterval)
|
||||
case transcriptionError(Error, URL?)
|
||||
|
||||
// Model availability
|
||||
case modelMissing
|
||||
}
|
||||
|
||||
enum CancelID {
|
||||
case metering
|
||||
case recordingStart
|
||||
case recordingCleanup
|
||||
case transcription
|
||||
}
|
||||
|
||||
@Dependency(\.transcription) var transcription
|
||||
@Dependency(\.recording) var recording
|
||||
@Dependency(\.pasteboard) var pasteboard
|
||||
@Dependency(\.keyEventMonitor) var keyEventMonitor
|
||||
@Dependency(\.soundEffects) var soundEffect
|
||||
@Dependency(\.sleepManagement) var sleepManagement
|
||||
@Dependency(\.date.now) var now
|
||||
@Dependency(\.transcriptPersistence) var transcriptPersistence
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
// MARK: - Lifecycle / Setup
|
||||
|
||||
case .task:
|
||||
// Starts two concurrent effects:
|
||||
// 1) Observing audio meter
|
||||
// 2) Monitoring hot key events
|
||||
// 3) Priming the recorder for instant startup
|
||||
return .merge(
|
||||
startMeteringEffect(),
|
||||
startHotKeyMonitoringEffect(),
|
||||
warmUpRecorderEffect()
|
||||
)
|
||||
|
||||
// MARK: - Metering
|
||||
|
||||
case let .audioLevelUpdated(meter):
|
||||
state.meter = meter
|
||||
return .none
|
||||
|
||||
// MARK: - HotKey Flow
|
||||
|
||||
case .hotKeyPressed:
|
||||
// If we're transcribing, send a cancel first. Otherwise start recording immediately.
|
||||
// We'll decide later (on release) whether to keep or discard the recording.
|
||||
return handleHotKeyPressed(isTranscribing: state.isTranscribing)
|
||||
|
||||
case .hotKeyReleased:
|
||||
// If we're currently recording, then stop. Otherwise, just cancel
|
||||
// the delayed "startRecording" effect if we never actually started.
|
||||
return handleHotKeyReleased(isRecording: state.isRecording)
|
||||
|
||||
// MARK: - Recording Flow
|
||||
|
||||
case .startRecording:
|
||||
return handleStartRecording(&state)
|
||||
|
||||
case .stopRecording:
|
||||
return handleStopRecording(&state)
|
||||
|
||||
// MARK: - Transcription Results
|
||||
|
||||
case let .transcriptionResult(result, audioURL, duration):
|
||||
return handleTranscriptionResult(&state, result: result, audioURL: audioURL, duration: duration)
|
||||
|
||||
case let .transcriptionError(error, audioURL):
|
||||
return handleTranscriptionError(&state, error: error, audioURL: audioURL)
|
||||
|
||||
case .modelMissing:
|
||||
return .none
|
||||
|
||||
// MARK: - Cancel/Discard Flow
|
||||
|
||||
case .cancel:
|
||||
// Only cancel if we're in the middle of recording, transcribing, or post-processing
|
||||
guard state.isRecording || state.isTranscribing else {
|
||||
return .none
|
||||
}
|
||||
return handleCancel(&state)
|
||||
|
||||
case .discard:
|
||||
// Silent discard for quick/accidental recordings
|
||||
guard state.isRecording else {
|
||||
return .none
|
||||
}
|
||||
return handleDiscard(&state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Effects: Metering & HotKey
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
/// Effect to begin observing the audio meter.
|
||||
func startMeteringEffect() -> Effect<Action> {
|
||||
.run { send in
|
||||
for await meter in await recording.observeAudioLevel() {
|
||||
await send(.audioLevelUpdated(meter))
|
||||
}
|
||||
}
|
||||
.cancellable(id: CancelID.metering, cancelInFlight: true)
|
||||
}
|
||||
|
||||
/// Effect to start monitoring hotkey events through the `keyEventMonitor`.
|
||||
func startHotKeyMonitoringEffect() -> Effect<Action> {
|
||||
.run { send in
|
||||
var hotKeyProcessor: HotKeyProcessor = .init(hotkey: HotKey(key: nil, modifiers: [.option]))
|
||||
@Shared(.isSettingHotKey) var isSettingHotKey: Bool
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
// Handle incoming input events (keyboard and mouse)
|
||||
let token = keyEventMonitor.handleInputEvent { inputEvent in
|
||||
// Skip if the user is currently setting a hotkey
|
||||
if isSettingHotKey {
|
||||
return false
|
||||
}
|
||||
|
||||
// Always keep hotKeyProcessor in sync with current user hotkey preference
|
||||
hotKeyProcessor.hotkey = hexSettings.hotkey
|
||||
let useDoubleTapOnly = hexSettings.doubleTapLockEnabled && hexSettings.useDoubleTapOnly
|
||||
hotKeyProcessor.doubleTapLockEnabled = hexSettings.doubleTapLockEnabled
|
||||
hotKeyProcessor.useDoubleTapOnly = useDoubleTapOnly
|
||||
hotKeyProcessor.minimumKeyTime = hexSettings.minimumKeyTime
|
||||
|
||||
switch inputEvent {
|
||||
case .keyboard(let keyEvent):
|
||||
// If Escape is pressed with no modifiers while idle, let's treat that as `cancel`.
|
||||
if keyEvent.key == .escape, keyEvent.modifiers.isEmpty,
|
||||
hotKeyProcessor.state == .idle
|
||||
{
|
||||
Task { await send(.cancel) }
|
||||
return false
|
||||
}
|
||||
|
||||
// Process the key event
|
||||
switch hotKeyProcessor.process(keyEvent: keyEvent) {
|
||||
case .startRecording:
|
||||
Task { await send(.hotKeyPressed) }
|
||||
// If the hotkey is purely modifiers, return false to keep it from interfering with normal usage
|
||||
// But if useDoubleTapOnly is true, always intercept the key
|
||||
return useDoubleTapOnly || keyEvent.key != nil
|
||||
|
||||
case .stopRecording:
|
||||
Task { await send(.hotKeyReleased) }
|
||||
return false // or `true` if you want to intercept
|
||||
|
||||
case .cancel:
|
||||
Task { await send(.cancel) }
|
||||
return true
|
||||
|
||||
case .discard:
|
||||
Task { await send(.discard) }
|
||||
return false // Don't intercept - let the key chord reach other apps
|
||||
|
||||
case .none:
|
||||
// If we detect repeated same chord, maybe intercept.
|
||||
if let pressedKey = keyEvent.key,
|
||||
pressedKey == hotKeyProcessor.hotkey.key,
|
||||
keyEvent.modifiers == hotKeyProcessor.hotkey.modifiers
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
case .mouseClick:
|
||||
// Process mouse click - for modifier-only hotkeys, this may cancel/discard
|
||||
switch hotKeyProcessor.processMouseClick() {
|
||||
case .cancel:
|
||||
Task { await send(.cancel) }
|
||||
return false // Don't intercept the click itself
|
||||
case .discard:
|
||||
Task { await send(.discard) }
|
||||
return false // Don't intercept the click itself
|
||||
case .startRecording, .stopRecording, .none:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defer { token.cancel() }
|
||||
|
||||
await withTaskCancellationHandler {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
}
|
||||
} onCancel: {
|
||||
token.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func warmUpRecorderEffect() -> Effect<Action> {
|
||||
.run { _ in
|
||||
await recording.warmUpRecorder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HotKey Press/Release Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleHotKeyPressed(isTranscribing: Bool) -> Effect<Action> {
|
||||
// If already transcribing, cancel first. Otherwise start recording immediately.
|
||||
guard isTranscribing else { return .send(.startRecording) }
|
||||
return .concatenate(
|
||||
.send(.cancel),
|
||||
.send(.startRecording)
|
||||
)
|
||||
}
|
||||
|
||||
func handleHotKeyReleased(isRecording: Bool) -> Effect<Action> {
|
||||
// Always stop recording when hotkey is released
|
||||
return isRecording ? .send(.stopRecording) : .none
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recording Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleStartRecording(_ state: inout State) -> Effect<Action> {
|
||||
guard state.modelBootstrapState.isModelReady else {
|
||||
return .merge(
|
||||
.send(.modelMissing),
|
||||
.run { _ in soundEffect.play(.cancel) }
|
||||
)
|
||||
}
|
||||
state.isRecording = true
|
||||
let startTime = now
|
||||
state.recordingStartTime = startTime
|
||||
|
||||
// Capture the active application
|
||||
if let activeApp = NSWorkspace.shared.frontmostApplication {
|
||||
state.sourceAppBundleID = activeApp.bundleIdentifier
|
||||
state.sourceAppName = activeApp.localizedName
|
||||
}
|
||||
transcriptionFeatureLogger.notice("Recording started at \(startTime.ISO8601Format())")
|
||||
|
||||
// Prevent system sleep during recording
|
||||
return .merge(
|
||||
.cancel(id: CancelID.recordingCleanup),
|
||||
.run { [sleepManagement, preventSleep = state.hexSettings.preventSystemSleep] _ in
|
||||
// Play sound immediately for instant feedback
|
||||
soundEffect.play(.startRecording)
|
||||
|
||||
if preventSleep {
|
||||
await sleepManagement.preventSleep(reason: "Hex Voice Recording")
|
||||
}
|
||||
guard !Task.isCancelled else {
|
||||
if preventSleep {
|
||||
await sleepManagement.allowSleep()
|
||||
}
|
||||
return
|
||||
}
|
||||
await recording.startRecording()
|
||||
}
|
||||
.cancellable(id: CancelID.recordingStart, cancelInFlight: true)
|
||||
)
|
||||
}
|
||||
|
||||
func handleStopRecording(_ state: inout State) -> Effect<Action> {
|
||||
state.isRecording = false
|
||||
|
||||
let stopTime = now
|
||||
let startTime = state.recordingStartTime
|
||||
let duration = startTime.map { stopTime.timeIntervalSince($0) } ?? 0
|
||||
|
||||
let decision = RecordingDecisionEngine.decide(
|
||||
.init(
|
||||
hotkey: state.hexSettings.hotkey,
|
||||
minimumKeyTime: state.hexSettings.minimumKeyTime,
|
||||
recordingStartTime: state.recordingStartTime,
|
||||
currentTime: stopTime
|
||||
)
|
||||
)
|
||||
|
||||
let startStamp = startTime?.ISO8601Format() ?? "nil"
|
||||
let stopStamp = stopTime.ISO8601Format()
|
||||
let minimumKeyTime = state.hexSettings.minimumKeyTime
|
||||
let hotkeyHasKey = state.hexSettings.hotkey.key != nil
|
||||
transcriptionFeatureLogger.notice(
|
||||
"Recording stopped duration=\(String(format: "%.3f", duration))s start=\(startStamp) stop=\(stopStamp) decision=\(String(describing: decision)) minimumKeyTime=\(String(format: "%.2f", minimumKeyTime)) hotkeyHasKey=\(hotkeyHasKey)"
|
||||
)
|
||||
|
||||
guard decision == .proceedToTranscription else {
|
||||
// If the user recorded for less than minimumKeyTime and the hotkey is modifier-only,
|
||||
// discard the audio to avoid accidental triggers.
|
||||
transcriptionFeatureLogger.notice("Discarding short recording per decision \(String(describing: decision))")
|
||||
return handleDiscard(&state)
|
||||
}
|
||||
|
||||
let model = state.hexSettings.selectedModel
|
||||
guard !model.isEmpty else {
|
||||
// Defense-in-depth: handleStartRecording already blocks recording when the
|
||||
// bootstrap state says no model is ready, but settings can change while a
|
||||
// recording is in flight (or the in-memory bootstrap default can race a
|
||||
// cold launch). Never hand an empty model name to the transcriber: it
|
||||
// silently produces nothing (or junk like "[BLANK_AUDIO]").
|
||||
transcriptionFeatureLogger.error("Recording stopped with no transcription model selected; discarding audio")
|
||||
return .merge(
|
||||
handleDiscard(&state),
|
||||
.send(.modelMissing)
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise, proceed to transcription
|
||||
state.isTranscribing = true
|
||||
state.error = nil
|
||||
let language = state.hexSettings.outputLanguage
|
||||
|
||||
state.isPrewarming = true
|
||||
|
||||
return .merge(
|
||||
.cancel(id: CancelID.recordingStart),
|
||||
.run { [sleepManagement] send in
|
||||
// Allow system to sleep again
|
||||
await sleepManagement.allowSleep()
|
||||
|
||||
var audioURL: URL?
|
||||
defer {
|
||||
if let audioURL {
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
}
|
||||
do {
|
||||
let stopResult = await recording.stopRecording()
|
||||
let capturedURL: URL
|
||||
switch stopResult {
|
||||
case let .captured(url):
|
||||
capturedURL = url
|
||||
case .ignored(.staleSession):
|
||||
transcriptionFeatureLogger.notice("Ignoring transcription stop superseded by a newer recording session")
|
||||
return
|
||||
case .ignored(.noActiveRecording):
|
||||
transcriptionFeatureLogger.error("Recording stopped without captured audio")
|
||||
await send(.transcriptionError(RecordingFailure.noCapturedAudio, nil))
|
||||
return
|
||||
case let .failed(error):
|
||||
transcriptionFeatureLogger.error("Recording stop failed: \(error.localizedDescription)")
|
||||
await send(.transcriptionError(error, nil))
|
||||
return
|
||||
}
|
||||
audioURL = capturedURL
|
||||
guard !Task.isCancelled else { return }
|
||||
soundEffect.play(.stopRecording)
|
||||
|
||||
// Create transcription options with the selected language
|
||||
// Note: cap concurrency to avoid audio I/O overloads on some Macs
|
||||
let decodeOptions = DecodingOptions(
|
||||
language: language,
|
||||
detectLanguage: language == nil, // Only auto-detect if no language specified
|
||||
chunkingStrategy: .vad,
|
||||
)
|
||||
|
||||
let result = try await transcription.transcribe(capturedURL, model, decodeOptions) { _ in }
|
||||
|
||||
transcriptionFeatureLogger.notice("Transcribed audio from \(capturedURL.lastPathComponent) to text length \(result.count)")
|
||||
audioURL = nil
|
||||
await send(.transcriptionResult(result, capturedURL, duration))
|
||||
} catch {
|
||||
transcriptionFeatureLogger.error("Transcription failed: \(error.localizedDescription)")
|
||||
await send(.transcriptionError(error, nil))
|
||||
}
|
||||
}
|
||||
.cancellable(id: CancelID.transcription)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transcription Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleTranscriptionResult(
|
||||
_ state: inout State,
|
||||
result: String,
|
||||
audioURL: URL,
|
||||
duration: TimeInterval
|
||||
) -> Effect<Action> {
|
||||
state.isTranscribing = false
|
||||
state.isPrewarming = false
|
||||
|
||||
// Check for force quit command (emergency escape hatch)
|
||||
if ForceQuitCommandDetector.matches(result) {
|
||||
transcriptionFeatureLogger.fault("Force quit voice command recognized; terminating Hex.")
|
||||
return .run { _ in
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
await MainActor.run {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If empty text, nothing else to do
|
||||
guard !result.isEmpty else {
|
||||
return .run { _ in
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
}
|
||||
|
||||
transcriptionFeatureLogger.info("Raw transcription: '\(result, privacy: .private)'")
|
||||
let remappings = state.hexSettings.wordRemappings
|
||||
let removalsEnabled = state.hexSettings.wordRemovalsEnabled
|
||||
let removals = state.hexSettings.wordRemovals
|
||||
let modifiedResult: String
|
||||
if state.isRemappingScratchpadFocused {
|
||||
modifiedResult = result
|
||||
transcriptionFeatureLogger.info("Scratchpad focused; skipping word modifications")
|
||||
} else {
|
||||
var output = result
|
||||
if removalsEnabled {
|
||||
let removedResult = WordRemovalApplier.apply(output, removals: removals)
|
||||
if removedResult != output {
|
||||
let enabledRemovalCount = removals.filter(\.isEnabled).count
|
||||
transcriptionFeatureLogger.info("Applied \(enabledRemovalCount) word removal(s)")
|
||||
}
|
||||
output = removedResult
|
||||
}
|
||||
let remappedResult = WordRemappingApplier.apply(output, remappings: remappings)
|
||||
if remappedResult != output {
|
||||
transcriptionFeatureLogger.info("Applied \(remappings.count) word remapping(s)")
|
||||
}
|
||||
let formattedResult = TranscriptFormattingApplier.apply(
|
||||
remappedResult,
|
||||
lowercase: state.hexSettings.lowercaseTranscripts,
|
||||
removePunctuation: state.hexSettings.removePunctuation
|
||||
)
|
||||
if formattedResult != remappedResult {
|
||||
transcriptionFeatureLogger.info("Applied paste formatting")
|
||||
}
|
||||
modifiedResult = formattedResult
|
||||
}
|
||||
|
||||
guard !modifiedResult.isEmpty else {
|
||||
return .run { _ in
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
}
|
||||
|
||||
let sourceAppBundleID = state.sourceAppBundleID
|
||||
let sourceAppName = state.sourceAppName
|
||||
let transcriptionHistory = state.$transcriptionHistory
|
||||
|
||||
return .run { send in
|
||||
do {
|
||||
try await finalizeRecordingAndStoreTranscript(
|
||||
result: modifiedResult,
|
||||
duration: duration,
|
||||
sourceAppBundleID: sourceAppBundleID,
|
||||
sourceAppName: sourceAppName,
|
||||
audioURL: audioURL,
|
||||
transcriptionHistory: transcriptionHistory
|
||||
)
|
||||
} catch {
|
||||
await send(.transcriptionError(error, audioURL))
|
||||
}
|
||||
}
|
||||
.cancellable(id: CancelID.transcription)
|
||||
}
|
||||
|
||||
func handleTranscriptionError(
|
||||
_ state: inout State,
|
||||
error: Error,
|
||||
audioURL: URL?
|
||||
) -> Effect<Action> {
|
||||
state.isTranscribing = false
|
||||
state.isPrewarming = false
|
||||
state.error = error.localizedDescription
|
||||
|
||||
if let audioURL {
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
|
||||
return .none
|
||||
}
|
||||
|
||||
/// Move file to permanent location, create a transcript record, paste text, and play sound.
|
||||
func finalizeRecordingAndStoreTranscript(
|
||||
result: String,
|
||||
duration: TimeInterval,
|
||||
sourceAppBundleID: String?,
|
||||
sourceAppName: String?,
|
||||
audioURL: URL,
|
||||
transcriptionHistory: Shared<TranscriptionHistory>
|
||||
) async throws {
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
if hexSettings.saveTranscriptionHistory {
|
||||
let transcript = try await transcriptPersistence.save(
|
||||
result,
|
||||
audioURL,
|
||||
duration,
|
||||
sourceAppBundleID,
|
||||
sourceAppName
|
||||
)
|
||||
|
||||
transcriptionHistory.withLock { history in
|
||||
history.history.insert(transcript, at: 0)
|
||||
|
||||
if let maxEntries = hexSettings.maxHistoryEntries, maxEntries > 0 {
|
||||
while history.history.count > maxEntries {
|
||||
if let removedTranscript = history.history.popLast() {
|
||||
Task {
|
||||
try? await transcriptPersistence.deleteAudio(removedTranscript)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
|
||||
await pasteboard.paste(result)
|
||||
soundEffect.play(.pasteTranscript)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cancel/Discard Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleCancel(_ state: inout State) -> Effect<Action> {
|
||||
let wasRecording = state.isRecording
|
||||
state.isTranscribing = false
|
||||
state.isRecording = false
|
||||
state.isPrewarming = false
|
||||
|
||||
return .merge(
|
||||
.cancel(id: CancelID.transcription),
|
||||
.cancel(id: CancelID.recordingStart),
|
||||
.run { [sleepManagement] _ in
|
||||
// Allow system to sleep again
|
||||
await sleepManagement.allowSleep()
|
||||
guard wasRecording else {
|
||||
soundEffect.play(.cancel)
|
||||
return
|
||||
}
|
||||
// Stop the recording to release microphone access
|
||||
let result = await recording.stopRecording()
|
||||
if case let .captured(url) = result {
|
||||
FileManager.default.removeItemIfExists(at: url)
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
soundEffect.play(.cancel)
|
||||
}
|
||||
.cancellable(id: CancelID.recordingCleanup, cancelInFlight: true)
|
||||
)
|
||||
}
|
||||
|
||||
func handleDiscard(_ state: inout State) -> Effect<Action> {
|
||||
state.isRecording = false
|
||||
state.isPrewarming = false
|
||||
|
||||
// Silently discard - no sound effect
|
||||
return .merge(
|
||||
.cancel(id: CancelID.recordingStart),
|
||||
.run { [sleepManagement] _ in
|
||||
// Allow system to sleep again
|
||||
await sleepManagement.allowSleep()
|
||||
let result = await recording.stopRecording()
|
||||
if case let .captured(url) = result {
|
||||
FileManager.default.removeItemIfExists(at: url)
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
.cancellable(id: CancelID.recordingCleanup, cancelInFlight: true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
|
||||
struct TranscriptionView: View {
|
||||
@Bindable var store: StoreOf<TranscriptionFeature>
|
||||
@ObserveInjection var inject
|
||||
|
||||
var status: TranscriptionIndicatorView.Status {
|
||||
if store.isTranscribing {
|
||||
return .transcribing
|
||||
} else if store.isRecording {
|
||||
return .recording
|
||||
} else if store.isPrewarming {
|
||||
return .prewarming
|
||||
} else {
|
||||
return .hidden
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TranscriptionIndicatorView(
|
||||
status: status,
|
||||
meter: store.meter
|
||||
)
|
||||
.task {
|
||||
await store.send(.task).finish()
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Force Quit Command
|
||||
|
||||
private enum ForceQuitCommandDetector {
|
||||
static func matches(_ text: String) -> Bool {
|
||||
let normalized = normalize(text)
|
||||
return normalized == "force quit hex now" || normalized == "force quit hex"
|
||||
}
|
||||
|
||||
private static func normalize(_ text: String) -> String {
|
||||
text
|
||||
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user