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
13 changes: 11 additions & 2 deletions FreeWispr/Sources/FreeWispr/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ class AppState: ObservableObject {
}

func startRecording() {
guard !isRecording else { return }
guard !isRecording, !isTranscribing else { return }

// Capture frontmost app on @MainActor for thread-safe use in LLM context and focus check
let frontmostApp = NSWorkspace.shared.frontmostApplication
Expand Down Expand Up @@ -148,6 +148,15 @@ class AppState: ObservableObject {
private func transcribeAndInject(_ samples: [Float]) async {
isRecording = false
recordingIndicator.hide()

// If a transcription is already running (e.g. hardware disconnect fired
// while whisper is mid-inference), drop this request rather than setting
// isTranscribing = false and breaking the in-flight session's state.
guard !isTranscribing else {
logger.warning("transcribeAndInject called while already transcribing — dropping")
return
}

guard !samples.isEmpty else {
statusMessage = "Ready"
return
Expand Down Expand Up @@ -205,7 +214,7 @@ class AppState: ObservableObject {
}

func switchModel(to model: ModelSize) async {
guard !isSwitchingModel else { return }
guard !isSwitchingModel, !isRecording, !isTranscribing else { return }
isSwitchingModel = true
defer { isSwitchingModel = false }

Expand Down
33 changes: 30 additions & 3 deletions FreeWispr/Sources/FreeWispr/AudioRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,17 @@ class AudioRecorder: ObservableObject {
var onRecordingComplete: (([Float]) -> Void)?

private lazy var whisperFormat: AVAudioFormat = {
AVAudioFormat(
guard let format = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 16000,
channels: 1,
interleaved: false
)!
) else {
// 16 kHz mono float32 is universally supported; failing here indicates a
// serious system-level audio misconfiguration that warrants a hard stop.
fatalError("FreeWispr: cannot create 16 kHz mono PCM format — audio subsystem unavailable")
}
return format
}()

/// Install the audio tap once during setup. This avoids recreating
Expand Down Expand Up @@ -150,6 +155,28 @@ class AudioRecorder: ObservableObject {
audioBuffer.removeAll(keepingCapacity: audioBuffer.capacity <= maxRetainedCapacity)
return copy
}
onRecordingComplete?(finalBuffer)
// Always deliver the completion callback on the main thread. stopRecording()
// may be called from handleConfigurationChange (already main-dispatched) or
// directly from AppState (@MainActor), so this is typically a no-op hop, but
// it guards against any future call-site that runs off main.
// Capture the closure value now (before any potential dealloc) rather than
// capturing self weakly to avoid an unnecessary retain cycle.
let completion = onRecordingComplete
if Thread.isMainThread {
completion?(finalBuffer)
} else {
DispatchQueue.main.async { completion?(finalBuffer) }
}
}

deinit {
// Remove the AVAudioEngineConfigurationChange observer so that a hardware-change
// notification arriving after this object is released does not invoke the
// @objc selector on a dangling pointer (EXC_BAD_ACCESS).
NotificationCenter.default.removeObserver(self)
audioEngine.stop()
if isTapInstalled {
audioEngine.inputNode.removeTap(onBus: 0)
}
}
}
10 changes: 10 additions & 0 deletions FreeWispr/Sources/FreeWispr/HotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ final class HotkeyManager: ObservableObject {
runLoopSource = nil
isListening = false
}

deinit {
// The C callback holds an unretained pointer to this object. Disable and
// invalidate the tap before the object is released so any in-flight or
// subsequent CGEvent callbacks do not dereference a dangling pointer.
if let tap = eventTap {
CGEvent.tapEnable(tap: tap, enable: false)
CFMachPortInvalidate(tap)
}
}
}

private func hotkeyCallback(
Expand Down
37 changes: 29 additions & 8 deletions FreeWispr/Sources/FreeWispr/ModelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,27 +41,48 @@ class ModelManager: ObservableObject {

nonisolated let baseURL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main"

/// Returns the Application Support directory, creating it if needed.
/// Marked nonisolated so it can be used from both @MainActor and nonisolated contexts.
nonisolated private static func appSupportDirectory() -> URL {
let urls = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
guard let url = urls.first else {
// Application Support is always available on macOS; this path is unreachable
// in practice. Use the temporary directory as a last resort so the app can
// still run rather than crashing.
return FileManager.default.temporaryDirectory
}
return url
}

private var modelsDirectory: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return appSupport.appendingPathComponent("FreeWispr/models")
Self.appSupportDirectory().appendingPathComponent("FreeWispr/models")
}

nonisolated func downloadURL(for model: ModelSize) -> URL {
URL(string: "\(baseURL)/ggml-\(model.rawValue).bin")!
// The base URL and model name are compile-time constants; this initializer
// cannot fail. The guard makes this explicit and surfaces any regression
// as a clear programmer error rather than a silent /dev/null fallback.
guard let url = URL(string: "\(baseURL)/ggml-\(model.rawValue).bin") else {
fatalError("FreeWispr: malformed model download URL — this is a programming error")
}
return url
}

nonisolated func coreMLDownloadURL(for model: ModelSize) -> URL {
URL(string: "\(baseURL)/ggml-\(model.rawValue)-encoder.mlmodelc.zip")!
guard let url = URL(string: "\(baseURL)/ggml-\(model.rawValue)-encoder.mlmodelc.zip") else {
fatalError("FreeWispr: malformed Core ML download URL — this is a programming error")
}
return url
}

nonisolated func localModelPath(for model: ModelSize) -> URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return appSupport.appendingPathComponent("FreeWispr/models/ggml-\(model.rawValue).bin")
Self.appSupportDirectory()
.appendingPathComponent("FreeWispr/models/ggml-\(model.rawValue).bin")
}

nonisolated func localCoreMLPath(for model: ModelSize) -> URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return appSupport.appendingPathComponent("FreeWispr/models/ggml-\(model.rawValue)-encoder.mlmodelc")
Self.appSupportDirectory()
.appendingPathComponent("FreeWispr/models/ggml-\(model.rawValue)-encoder.mlmodelc")
}

func isModelDownloaded(_ model: ModelSize) -> Bool {
Expand Down
48 changes: 41 additions & 7 deletions FreeWispr/Sources/FreeWispr/RecordingIndicator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AppKit

/// A small floating red dot that appears at the top-center of the screen while recording.
/// Uses NSPanel at status-window level so it floats above all apps and appears on all Spaces.
@MainActor
final class RecordingIndicator {
private var panel: NSPanel?
private var pulseTimer: Timer?
Expand All @@ -10,6 +11,18 @@ final class RecordingIndicator {
private static let panelPadding: CGFloat = 4
private static let panelSize = dotSize + panelPadding * 2

init() {
// Reposition the indicator whenever the screen configuration changes
// (e.g. external monitor disconnects). Without this, the panel can end
// up at coordinates that are off-screen on the new layout.
NotificationCenter.default.addObserver(
self,
selector: #selector(handleScreenChange),
name: NSApplication.didChangeScreenParametersNotification,
object: nil
)
}

func show() {
guard panel == nil else { return }

Expand All @@ -34,13 +47,7 @@ final class RecordingIndicator {

panel.contentView?.addSubview(dot)

// Position at 1/3 from left, vertically centered on screen
if let screen = NSScreen.main {
let visibleFrame = screen.visibleFrame
let x = visibleFrame.minX + visibleFrame.width / 3 - Self.panelSize / 2
let y = visibleFrame.minY + visibleFrame.height / 2 - Self.panelSize / 2
panel.setFrameOrigin(NSPoint(x: x, y: y))
}
positionPanel(panel)

panel.orderFrontRegardless()
self.panel = panel
Expand All @@ -58,6 +65,33 @@ final class RecordingIndicator {
panel = nil
}

deinit {
NotificationCenter.default.removeObserver(self)
// Ensure the panel is dismissed if the owner is released while recording.
// NSPanel is an AppKit object and must be dismissed from the main thread;
// @MainActor deinit guarantees this on Swift 5.9+.
panel?.orderOut(nil)
}

// MARK: - Private

/// Place the panel at 1/3 from the left edge, vertically centred, on the main screen.
/// Falls back gracefully if no screen is available.
private func positionPanel(_ panel: NSPanel) {
guard let screen = NSScreen.main ?? NSScreen.screens.first else { return }
let visibleFrame = screen.visibleFrame
let x = visibleFrame.minX + visibleFrame.width / 3 - Self.panelSize / 2
let y = visibleFrame.minY + visibleFrame.height / 2 - Self.panelSize / 2
panel.setFrameOrigin(NSPoint(x: x, y: y))
}

/// Called when monitors are connected or disconnected. Reposition the panel
/// so it stays visible on whatever screen is now primary.
@objc private func handleScreenChange(_ notification: Notification) {
guard let panel else { return }
positionPanel(panel)
}

private func startPulsing(dot: NSView) {
guard let layer = dot.layer else { return }
let pulse = CABasicAnimation(keyPath: "opacity")
Expand Down
6 changes: 5 additions & 1 deletion FreeWispr/Sources/FreeWispr/UpdateChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class UpdateChecker: ObservableObject {

let currentVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"

private let apiURL = URL(string: "https://api.github.com/repos/ygivenx/freeWispr/releases/latest")!
private let apiURL: URL? = URL(string: "https://api.github.com/repos/ygivenx/freeWispr/releases/latest")
private var dmgAssetURL: URL?

/// Expected Team ID for code signature verification.
Expand All @@ -35,6 +35,10 @@ class UpdateChecker: ObservableObject {
}()

func checkForUpdate() async {
guard let apiURL else {
logger.error("Update check skipped: could not construct API URL")
return
}
var request = URLRequest(url: apiURL)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")

Expand Down
28 changes: 24 additions & 4 deletions FreeWispr/Sources/FreeWispr/WhisperTranscriber.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ enum TranscriberError: Error {
case modelNotLoaded
case transcriptionFailed(String)
case timeout
case alreadyTranscribing
}

class WhisperTranscriber: ObservableObject {
Expand All @@ -30,24 +31,36 @@ class WhisperTranscriber: ObservableObject {

whisper = Whisper(fromFileURL: path, withParams: params)
modelPath = path
isModelLoaded = true
transcriptionCount = 0
// @Published mutations must happen on the main thread to avoid data races
// with SwiftUI's attribute graph. loadModel() is synchronous and may be
// called from a background thread (periodic model refresh inside transcribe()),
// so use async dispatch rather than a blocking hop.
DispatchQueue.main.async { [weak self] in self?.isModelLoaded = true }
}

func unloadModel() {
whisper = nil
modelPath = nil
isModelLoaded = false
transcriptionCount = 0
DispatchQueue.main.async { [weak self] in self?.isModelLoaded = false }
}

func transcribe(audioSamples: [Float]) async throws -> String {
guard let whisper = whisper else {
throw TranscriberError.modelNotLoaded
}

isTranscribing = true
defer { isTranscribing = false }
// Prevent re-entrant calls: two concurrent whisper.cpp inference sessions
// on the same context would corrupt its internal state.
guard !isTranscribing else {
throw TranscriberError.alreadyTranscribing
}

// @Published mutations must happen on the main thread. Set the flag
// synchronously before the first suspension point so that any concurrent
// main-actor check (e.g. AppState.startRecording) sees the correct state.
await MainActor.run { isTranscribing = true }

// Start a timeout task that cancels inference after 30s.
// SwiftWhisper's cancel() uses whisper.cpp's abort callback for clean cancellation.
Expand Down Expand Up @@ -75,13 +88,20 @@ class WhisperTranscriber: ObservableObject {
}
}

// Clear the flag on the main thread before returning so that the
// caller (AppState.transcribeAndInject, @MainActor) sees isTranscribing
// as false the moment this await returns — no scheduling gap.
await MainActor.run { isTranscribing = false }
return text
} catch is CancellationError {
await MainActor.run { isTranscribing = false }
throw TranscriberError.timeout
} catch WhisperError.cancelled {
await MainActor.run { isTranscribing = false }
throw TranscriberError.timeout
} catch {
timeoutTask.cancel()
await MainActor.run { isTranscribing = false }
throw error
}
}
Expand Down