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
27 changes: 27 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
description = "BobrWhisper - local-first voice-to-text";

inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";

outputs =
{ nixpkgs, ... }:
let
# arm64-only: Config.zig hardcodes Apple M1 for iOS targets and the
# Makefile pins `ARCHS=arm64` for xcodebuild.
pkgs = import nixpkgs { system = "aarch64-darwin"; };
in
{
# mkShellNoCC: the build resolves clang/clang++/metal/xcodebuild via the
# system Xcode (see src/build/AppleSdk.zig). A nix stdenv would inject a
# different libc++ and SDK root and break the link against whisper.cpp /
# llama.cpp.
devShells.aarch64-darwin.default = pkgs.mkShellNoCC {
packages = [
pkgs.zig_0_16
pkgs.zls
];
};
};
}
9 changes: 9 additions & 0 deletions include/bobrwhisper.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,23 @@ typedef enum {
typedef void (*bobrwhisper_status_cb)(void* userdata, bobrwhisper_status_e status);
typedef void (*bobrwhisper_transcript_cb)(void* userdata, bobrwhisper_string_s text, bool is_final);
typedef void (*bobrwhisper_error_cb)(void* userdata, bobrwhisper_string_s error);
/// Non-fatal warning channel. Runtime issues that the user should know about
/// but that do not stop work in progress (stuck microphone, suboptimal input
/// device, ...) flow through this callback instead of `on_error`. The status
/// is NOT changed to ERROR when a warning fires.
typedef void (*bobrwhisper_warning_cb)(void* userdata, bobrwhisper_string_s warning);

typedef struct {
void* userdata;
bobrwhisper_status_cb on_status_change;
bobrwhisper_transcript_cb on_transcript;
bobrwhisper_error_cb on_error;
bobrwhisper_warning_cb on_warning;
const char* models_dir;
const char* config_path;
const char* llm_model_path;
const char* vad_model_path;
bool whisper_mode;
} bobrwhisper_runtime_config_s;

typedef struct {
Expand All @@ -85,6 +92,7 @@ typedef struct {
bool remove_filler_words;
bool auto_punctuate;
bool use_llm_formatting;
bool whisper_mode;
} bobrwhisper_transcribe_options_s;

typedef struct {
Expand All @@ -93,6 +101,7 @@ typedef struct {
bool auto_punctuate;
bool use_llm_formatting;
const char* custom_prompt;
bool whisper_mode;
} bobrwhisper_settings_s;

int bobrwhisper_init(void);
Expand Down
8 changes: 8 additions & 0 deletions macos/BobrWhisper.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
A100000000000005 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A200000000000005 /* SettingsView.swift */; };
A100000000000006 /* TranscriptOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A200000000000006 /* TranscriptOverlayView.swift */; };
A100000000000007 /* MainWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A200000000000007 /* MainWindowView.swift */; };
A100000000000008 /* PermissionsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A200000000000008 /* PermissionsCoordinator.swift */; };
A100000000000009 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A200000000000009 /* OnboardingView.swift */; };
A100000000000010 /* BobrWhisperKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A200000000000010 /* BobrWhisperKit.xcframework */; };
A100000000000011 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A200000000000011 /* Assets.xcassets */; };
A100000000000012 /* silero-v6.2.0.bin in Resources */ = {isa = PBXBuildFile; fileRef = A200000000000012 /* silero-v6.2.0.bin */; };
Expand All @@ -27,6 +29,8 @@
A200000000000005 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A200000000000006 /* TranscriptOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptOverlayView.swift; sourceTree = "<group>"; };
A200000000000007 /* MainWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowView.swift; sourceTree = "<group>"; };
A200000000000008 /* PermissionsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsCoordinator.swift; sourceTree = "<group>"; };
A200000000000009 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
A200000000000010 /* BobrWhisperKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = BobrWhisperKit.xcframework; sourceTree = "<group>"; };
A200000000000011 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A200000000000012 /* silero-v6.2.0.bin */ = {isa = PBXFileReference; lastKnownFileType = archive.macbinary; name = "silero-v6.2.0.bin"; path = "../../resources/silero-v6.2.0.bin"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -61,6 +65,7 @@
A200000000000001 /* App.swift */,
A200000000000002 /* AppDelegate.swift */,
A200000000000003 /* AppState.swift */,
A200000000000008 /* PermissionsCoordinator.swift */,
A400000000000005 /* Views */,
A200000000000011 /* Assets.xcassets */,
A200000000000021 /* BobrWhisper.entitlements */,
Expand Down Expand Up @@ -90,6 +95,7 @@
children = (
A200000000000007 /* MainWindowView.swift */,
A200000000000004 /* MenuBarView.swift */,
A200000000000009 /* OnboardingView.swift */,
A200000000000005 /* SettingsView.swift */,
A200000000000006 /* TranscriptOverlayView.swift */,
);
Expand Down Expand Up @@ -169,8 +175,10 @@
A100000000000001 /* App.swift in Sources */,
A100000000000002 /* AppDelegate.swift in Sources */,
A100000000000003 /* AppState.swift in Sources */,
A100000000000008 /* PermissionsCoordinator.swift in Sources */,
A100000000000007 /* MainWindowView.swift in Sources */,
A100000000000004 /* MenuBarView.swift in Sources */,
A100000000000009 /* OnboardingView.swift in Sources */,
A100000000000005 /* SettingsView.swift in Sources */,
A100000000000006 /* TranscriptOverlayView.swift in Sources */,
);
Expand Down
27 changes: 27 additions & 0 deletions macos/BobrWhisper/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,45 @@ struct BobrWhisperApp: App {
WindowGroup("BobrWhisper", id: "dashboard") {
MainWindowView()
.environmentObject(appDelegate.appState)
.environmentObject(appDelegate.permissions)
}

Settings {
SettingsView()
.environmentObject(appDelegate.appState)
.environmentObject(appDelegate.permissions)
}

MenuBarExtra {
MenuBarView()
.environmentObject(appDelegate.appState)
.environmentObject(appDelegate.permissions)
} label: {
// The label is the always-alive part of the MenuBarExtra scene —
// it's instantiated on launch and kept alive for the app lifetime.
// Mounting `DashboardOpener` as an overlay here gives us a SwiftUI
// surface with `\.openWindow` that AppDelegate can drive via
// NotificationCenter, without depending on the menu actually
// being opened by the user.
Image(systemName: appDelegate.appState.statusIcon)
.overlay(DashboardOpener().allowsHitTesting(false))
}
}
}

/// SwiftUI bridge that AppKit code reaches via NotificationCenter to programmatically
/// open the dashboard `WindowGroup`. Mounted as a `.background` modifier in any
/// always-alive view (the menubar content) — its sole job is to translate the
/// `.bobrWhisperOpenDashboard` notification into a call on `\.openWindow`,
/// which is the only supported way to open a SwiftUI window scene.
struct DashboardOpener: View {
@Environment(\.openWindow) private var openWindow

var body: some View {
Color.clear
.frame(width: 0, height: 0)
.onReceive(NotificationCenter.default.publisher(for: .bobrWhisperOpenDashboard)) { _ in
openWindow(id: "dashboard")
}
}
}
58 changes: 49 additions & 9 deletions macos/BobrWhisper/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import BobrWhisperKit

class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
let appState = AppState()
let permissions = PermissionsCoordinator()
private var hotkeyMonitor: Any?

func applicationDidFinishLaunching(_ notification: Notification) {
Expand All @@ -20,9 +21,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {

// Register global hotkey (Fn key or custom)
setupHotkey()

// Request accessibility permissions if needed
requestAccessibilityPermissions()

// Onboarding lives inside the dashboard window now. If permissions
// need attention on launch, flip the coordinator into onboarding mode
// and ask SwiftUI to open the dashboard so the user actually sees it.
if permissions.shouldAutoShowOnLaunch {
permissions.beginOnboarding()
DispatchQueue.main.async { [weak self] in
self?.openDashboardWindow()
}
}
}

func applicationWillTerminate(_ notification: Notification) {
Expand Down Expand Up @@ -99,12 +107,44 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
}
}

private func requestAccessibilityPermissions() {
let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
let trusted = AXIsProcessTrustedWithOptions(options)

if !trusted {
print("Accessibility permissions required for global hotkey")
/// Open (or focus) the dashboard `WindowGroup`. Used on launch when
/// onboarding needs to run, and from the menubar's "Open Dashboard" entry.
/// Walks the existing window list first to focus an already-open dashboard
/// instead of leaning on SwiftUI to create a duplicate.
func openDashboardWindow() {
permissions.refresh()
NSApp.activate(ignoringOtherApps: true)

for window in NSApplication.shared.windows
where window.identifier?.rawValue == "dashboard" {
window.makeKeyAndOrderFront(nil)
return
}

// SwiftUI registers the WindowGroup with id "dashboard" via a
// discoverable URL handler under the hood. The standard
// `NSWorkspace.OpenConfiguration` route does not work for in-process
// scenes, so post a notification that the SwiftUI commands listener
// (in `App.swift`) acts on.
NotificationCenter.default.post(
name: .bobrWhisperOpenDashboard,
object: nil
)
}

/// Reset the "I've completed onboarding" flag and re-show the dashboard
/// in onboarding mode. Bound to the menubar / settings re-run actions.
func relaunchOnboarding() {
permissions.resetOnboarding()
permissions.beginOnboarding()
openDashboardWindow()
}
}

extension Notification.Name {
/// Posted by `AppDelegate.openDashboardWindow` when a SwiftUI surface needs
/// to call `openWindow(id: "dashboard")` on its behalf. Subscribed to from
/// the App scene, which is the only place with access to the
/// `\.openWindow` environment.
static let bobrWhisperOpenDashboard = Notification.Name("bobrWhisperOpenDashboard")
}
56 changes: 55 additions & 1 deletion macos/BobrWhisper/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ class AppState: ObservableObject {
@Published private(set) var isRecording: Bool = false
@Published private(set) var lastTranscript: String = ""
@Published private(set) var errorMessage: String?
/// Non-fatal advisory text (stuck mic, Bluetooth mic, etc.). Cleared
/// automatically a few seconds after it is set so the UI doesn't have to
/// own dismissal logic. Lives separately from `errorMessage` so it does
/// not flip status to `.error`.
@Published private(set) var warningMessage: String?
private var warningClearWorkItem: DispatchWorkItem?
@Published private(set) var isDownloading: Bool = false
@Published var downloadProgress: Double = 0
@Published private(set) var transcriptLog: [TranscriptLogEntry] = []
Expand Down Expand Up @@ -156,6 +162,21 @@ class AppState: ObservableObject {
appState.status = .error
}
}

config.on_warning = { userdata, warning in
guard let userdata = userdata else { return }
let warningMsg: String?
if let ptr = warning.ptr, warning.len > 0 {
let data = Data(bytes: ptr, count: warning.len)
warningMsg = String(data: data, encoding: .utf8)
} else {
warningMsg = nil
}
let appState = Unmanaged<AppState>.fromOpaque(userdata).takeUnretainedValue()
DispatchQueue.main.async {
appState.presentWarning(warningMsg)
}
}

let vadModelPath = Bundle.main.path(forResource: "silero-v6.2.0", ofType: "bin")

Expand Down Expand Up @@ -203,6 +224,32 @@ class AppState: ObservableObject {
if let ptr = vadModelPathCString { free(ptr); vadModelPathCString = nil }
}

/// Display a non-fatal advisory and auto-clear it after a short delay so
/// the UI doesn't have to track dismissal. Re-firing while a warning is
/// already on screen restarts the timer with the new text.
func presentWarning(_ message: String?) {
warningClearWorkItem?.cancel()
warningMessage = message
guard message != nil else { return }
let work = DispatchWorkItem { [weak self] in
guard let self = self else { return }
self.warningMessage = nil
self.warningClearWorkItem = nil
}
warningClearWorkItem = work
// 4.5 s sits between "long enough to read" and "out of the way before
// the next utterance lands". Matches the auto-dismiss feel of a
// short-lived toast.
DispatchQueue.main.asyncAfter(deadline: .now() + 4.5, execute: work)
}

/// Manually dismiss the warning (used when the user taps the toast).
func dismissWarning() {
warningClearWorkItem?.cancel()
warningClearWorkItem = nil
warningMessage = nil
}

private func persistSettings() {
guard let app = app else { return }

Expand Down Expand Up @@ -415,7 +462,14 @@ class AppState: ObservableObject {

func pasteToActiveApp() {
copyToClipboard()


// Auto-paste is opt-out via Settings, but ALSO requires Accessibility
// permission. If either is missing we still copy (above) so the user
// can paste manually — silent no-op otherwise would surprise users who
// skipped Accessibility during onboarding.
let autoPasteEnabled = (UserDefaults.standard.object(forKey: "autoPaste") as? Bool) ?? true
guard autoPasteEnabled, AXIsProcessTrusted() else { return }

// Simulate Cmd+V
let source = CGEventSource(stateID: .hidSystemState)
let vKeyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true)
Expand Down
Loading
Loading