Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
.vscode/
docs/superpowers/
.worktrees/
REBUILD
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ swift build -c release
.build/release/open-wispr start
```

## Troubleshooting

**App won't open on macOS 26 (Tahoe) or later**

macOS 26 blocks ad-hoc signed apps launched from Finder, Raycast, or Spotlight with no error shown. open-wispr is designed to run as a background service — use `brew services` instead of double-clicking the app:

```bash
brew services start open-wispr # start now and on every login
```

## Support

open-wispr is free and always will be. If you find it useful, you can [leave a tip](https://buy.stripe.com/4gM5kC2AU0Ssd4l6Hqd7q00).
Expand Down
50 changes: 47 additions & 3 deletions Sources/OpenWisprLib/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AppKit

public class AppDelegate: NSObject, NSApplicationDelegate {
var statusBar: StatusBarController!
var pill: PillOverlay!
var hotkeyManagers: [HotkeyManager] = []
var recorder: AudioRecorder!
var transcriber: Transcriber!
Expand All @@ -10,9 +11,11 @@ public class AppDelegate: NSObject, NSApplicationDelegate {
var isPressed = false
var isReady = false
public var lastTranscription: String?
private var recordingStartTime: Date?

public func applicationDidFinishLaunching(_ notification: Notification) {
statusBar = StatusBarController()
pill = PillOverlay()
recorder = AudioRecorder()

DispatchQueue.global(qos: .userInitiated).async { [weak self] in
Expand Down Expand Up @@ -258,7 +261,16 @@ public class AppDelegate: NSObject, NSApplicationDelegate {
private func handleRecordingStart() {
guard !isPressed else { return }
isPressed = true
statusBar.liveLevel = 0
pill.level = 0
recorder.onLevelUpdate = { [weak self] level in
self?.statusBar.liveLevel = level
self?.pill.level = level
}
recordingStartTime = Date()
statusBar.state = .recording
pill.state = .recording
if config.showOverlay?.value ?? false { pill.show() }
do {
let outputURL: URL
if Config.effectiveMaxRecordings(config.maxRecordings) == 0 {
Expand All @@ -270,20 +282,42 @@ public class AppDelegate: NSObject, NSApplicationDelegate {
} catch {
print("Error: \(error.localizedDescription)")
isPressed = false
pill.hide()
statusBar.state = .idle
}
}

// Whisper needs at least ~0.5 s of audio to produce meaningful output.
// Taps shorter than this are treated as accidental presses: the pill is
// dismissed immediately and no transcription job is started.
private static let minimumRecordingSeconds: TimeInterval = 0.5

private func handleRecordingStop() {
guard isPressed else { return }
isPressed = false
recorder.onLevelUpdate = nil

let elapsed = recordingStartTime.map { Date().timeIntervalSince($0) } ?? 1
recordingStartTime = nil

guard let audioURL = recorder.stopRecording() else {
pill.hide()
statusBar.state = .idle
return
}

// Tap too short to contain real speech — skip Whisper entirely.
if elapsed < AppDelegate.minimumRecordingSeconds {
pill.hide()
statusBar.state = .idle
if Config.effectiveMaxRecordings(config.maxRecordings) == 0 {
try? FileManager.default.removeItem(at: audioURL)
}
return
}

statusBar.state = .transcribing
pill.state = .transcribing

DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
Expand All @@ -295,15 +329,24 @@ public class AppDelegate: NSObject, NSApplicationDelegate {
}
do {
let raw = try self.transcriber.transcribe(audioURL: audioURL)
let text = (self.config.spokenPunctuation?.value ?? false) ? TextPostProcessor.process(raw) : raw
let spokenPunctuation = self.config.spokenPunctuation?.value ?? false
let text = spokenPunctuation ? TextPostProcessor.process(raw) : raw
if maxRecordings > 0 {
RecordingStore.prune(maxCount: maxRecordings)
}
DispatchQueue.main.async {
if !text.isEmpty {
self.lastTranscription = text
self.inserter.insert(text: text)
let polished = TextPolisher.polish(
text,
voiceCommands: spokenPunctuation,
removeFillers: self.config.removeFillers?.value ?? false
)
self.lastTranscription = polished
self.inserter.insert(text: polished)
}
// Only hide the pill if the user hasn't already started a new
// recording — otherwise we'd close the pill mid-dictation.
if !self.isPressed { self.pill.hide() }
self.statusBar.state = .idle
self.statusBar.buildMenu()
}
Expand All @@ -313,6 +356,7 @@ public class AppDelegate: NSObject, NSApplicationDelegate {
}
DispatchQueue.main.async {
print("Error: \(error.localizedDescription)")
if !self.isPressed { self.pill.hide() }
self.statusBar.state = .error(error.localizedDescription)
self.statusBar.buildMenu()
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
Expand Down
32 changes: 31 additions & 1 deletion Sources/OpenWisprLib/AudioRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ class AudioRecorder {
private var currentOutputURL: URL?
var preferredDeviceID: AudioDeviceID?

/// Called on the audio thread with a smoothed 0–1 RMS level each buffer.
var onLevelUpdate: ((Float) -> Void)?
// smoothedLevel is written on the audio thread and read nowhere outside this
// class — safe. Callers receive values only via the onLevelUpdate callback.
private var smoothedLevel: Float = 0

func prewarm() {
guard audioEngine == nil else { return }

Expand Down Expand Up @@ -78,7 +84,7 @@ class AudioRecorder {
let file = try AVAudioFile(forWriting: outputURL, settings: settings)
let converter = AVAudioConverter(from: inputFmt, to: recordingFormat)

engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFmt) { buffer, _ in
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFmt) { [weak self] buffer, _ in
guard let converter = converter else { return }

let convertedBuffer = AVAudioPCMBuffer(
Expand All @@ -97,6 +103,30 @@ class AudioRecorder {
if error == nil && convertedBuffer.frameLength > 0 {
try? file.write(from: convertedBuffer)
}

// Compute RMS from the raw input buffer and report a smoothed level.
if let self, let cb = self.onLevelUpdate,
let ch = buffer.floatChannelData {
let n = Int(buffer.frameLength)
var sum: Float = 0
let ptr = ch[0]
let step = max(1, n / 256)
var count = 0
var i = 0
while i < n { let v = ptr[i]; sum += v * v; count += 1; i += step }
let rms = count > 0 ? sqrtf(sum / Float(count)) : 0
// Scale factor: typical conversational speech produces RMS ~0.02–0.06
// on a 0–1 float PCM scale. Multiplying by 28 maps the mid-range of
// normal speech (RMS ≈ 0.035) to roughly 1.0 so the bars read full
// during active dictation and drop to near-zero in silence.
let norm = min(1.0, rms * 28)
// Asymmetric smoothing: fast attack (0.75), slow release (0.18).
// Bars jump up instantly when you speak and decay gradually —
// the same envelope shape used in broadcast audio meters.
let alpha: Float = norm > self.smoothedLevel ? 0.75 : 0.18
self.smoothedLevel = self.smoothedLevel * (1 - alpha) + norm * alpha
cb(self.smoothedLevel)
}
}

currentOutputURL = outputURL
Expand Down
21 changes: 20 additions & 1 deletion Sources/OpenWisprLib/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@ public struct Config: Codable {
public var modelSize: String
public var language: String
public var spokenPunctuation: FlexBool?
/// When true, TextPolisher strips disfluencies/fillers (um, uh, like, …).
/// Off by default: the filler list includes ordinary English words, so
/// removal can only be opted into, never applied silently.
public var removeFillers: FlexBool?
public var maxRecordings: Int?
public var toggleMode: FlexBool?
/// When true, a floating pill near the cursor shows "Listening"/"Transcribing".
/// Off by default; toggle via the "Show Overlay" menu item.
public var showOverlay: FlexBool?
public var audioInputDeviceID: UInt32?
public var audioInputDeviceUID: String?

Expand Down Expand Up @@ -42,8 +49,10 @@ public struct Config: Codable {
case modelSize
case language
case spokenPunctuation
case removeFillers
case maxRecordings
case toggleMode
case showOverlay
case audioInputDeviceID
case audioInputDeviceUID
}
Expand All @@ -63,8 +72,10 @@ public struct Config: Codable {
self.modelSize = try c.decode(String.self, forKey: .modelSize)
self.language = try c.decode(String.self, forKey: .language)
self.spokenPunctuation = try c.decodeIfPresent(FlexBool.self, forKey: .spokenPunctuation)
self.removeFillers = try c.decodeIfPresent(FlexBool.self, forKey: .removeFillers)
self.maxRecordings = try c.decodeIfPresent(Int.self, forKey: .maxRecordings)
self.toggleMode = try c.decodeIfPresent(FlexBool.self, forKey: .toggleMode)
self.showOverlay = try c.decodeIfPresent(FlexBool.self, forKey: .showOverlay)
self.audioInputDeviceID = try c.decodeIfPresent(UInt32.self, forKey: .audioInputDeviceID)
self.audioInputDeviceUID = try c.decodeIfPresent(String.self, forKey: .audioInputDeviceUID)
}
Expand All @@ -77,8 +88,10 @@ public struct Config: Codable {
try c.encode(modelSize, forKey: .modelSize)
try c.encode(language, forKey: .language)
try c.encodeIfPresent(spokenPunctuation, forKey: .spokenPunctuation)
try c.encodeIfPresent(removeFillers, forKey: .removeFillers)
try c.encodeIfPresent(maxRecordings, forKey: .maxRecordings)
try c.encodeIfPresent(toggleMode, forKey: .toggleMode)
try c.encodeIfPresent(showOverlay, forKey: .showOverlay)
try c.encodeIfPresent(audioInputDeviceID, forKey: .audioInputDeviceID)
try c.encodeIfPresent(audioInputDeviceUID, forKey: .audioInputDeviceUID)
}
Expand All @@ -91,6 +104,8 @@ public struct Config: Codable {
spokenPunctuation: FlexBool?,
maxRecordings: Int?,
toggleMode: FlexBool?,
removeFillers: FlexBool? = nil,
showOverlay: FlexBool? = nil,
audioInputDeviceID: UInt32? = nil,
audioInputDeviceUID: String? = nil
) {
Expand All @@ -101,8 +116,10 @@ public struct Config: Codable {
self.modelSize = modelSize
self.language = language
self.spokenPunctuation = spokenPunctuation
self.removeFillers = removeFillers
self.maxRecordings = maxRecordings
self.toggleMode = toggleMode
self.showOverlay = showOverlay
self.audioInputDeviceID = audioInputDeviceID
self.audioInputDeviceUID = audioInputDeviceUID
}
Expand Down Expand Up @@ -250,7 +267,9 @@ public struct Config: Codable {
language: "en",
spokenPunctuation: FlexBool(false),
maxRecordings: nil,
toggleMode: FlexBool(false)
toggleMode: FlexBool(false),
removeFillers: FlexBool(false),
showOverlay: FlexBool(false)
)

public static var configDir: URL {
Expand Down
Loading