Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
03da6ac
macOS: hold the hotkey 200 ms before recording, so a quick fn tap ope…
andersryanc Oct 21, 2026
b892cbd
Rust: scaffold the cross-platform workspace and crate contracts
andersryanc Oct 21, 2026
247be91
Rust: voice activity and level meter
andersryanc Oct 21, 2026
7bc80f3
Rust: fix the level meter test's expectation
andersryanc Oct 21, 2026
e6a4313
Rust: port MurmurKit to murmur-core
andersryanc Oct 21, 2026
6219d4d
Rust: speech-to-text with Parakeet through sherpa-onnx
andersryanc Oct 21, 2026
6e5e410
Rust: mic capture and sound playback with cpal
andersryanc Oct 21, 2026
aec9def
Rust: macOS hotkey tap, paste, Accessibility and frontmost app
andersryanc Oct 21, 2026
6b28ca0
Rust: default speech-to-text threads to min(cores, 8)
andersryanc Oct 21, 2026
98f6a11
Rust: the tray app, dictation queue and macOS bundle script
andersryanc Oct 21, 2026
a5bef54
Rust: link sherpa-onnx as shared libraries on macOS for 2x faster speech
andersryanc Oct 21, 2026
2c42398
Rust: the recording pill
andersryanc Oct 21, 2026
cce6d4f
Rust: Linux platform layer, control socket and the Omarchy plugin
andersryanc Oct 21, 2026
8463bb9
Rust: Dictionary window and Launch at Login on macOS
andersryanc Oct 21, 2026
3cd53ff
Rust: shared dictation engine and the Linux daemon
andersryanc Oct 21, 2026
88fbd82
Rust: fix double-click editing in the Dictionary window; inset the se…
andersryanc Oct 21, 2026
a180c22
CI: format, clippy and test the Rust workspace on macOS and Linux
andersryanc Oct 22, 2026
83a5d1d
Rust: Murmur menu in the Omarchy bar
andersryanc Oct 22, 2026
a974d01
CI: keep sherpa-onnx's downloaded libraries in the Rust cache
andersryanc Oct 22, 2026
7e1e8d0
CI: cache sherpa-onnx's libraries in their own step
andersryanc Oct 22, 2026
e130397
CI: download sherpa-onnx outside target/ instead of caching it
andersryanc Oct 22, 2026
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
78 changes: 78 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Formatting, lints and tests for the Rust workspace in rust/, on the two platforms Murmur ships
# for: macOS (Apple Silicon) and Linux (x86_64, like the Omarchy machine). Clippy runs on both
# because each OS compiles its own platform code. No releases are built here.
name: Rust

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

# A newer push to the same branch or PR replaces a run still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
CARGO_TERM_COLOR: always
# Incremental builds only help locally; on CI they just make the cache bigger.
CARGO_INCREMENTAL: 0

defaults:
run:
working-directory: rust

jobs:
check:
name: ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: macOS
os: macos-latest
- name: Linux
os: ubuntu-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5

# cpal's ALSA backend needs the ALSA headers to build. sherpa-onnx downloads its own
# prebuilt libraries during the build, so it needs nothing installed.
- name: Install Linux packages
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev pkg-config

- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- uses: Swatinem/rust-cache@v2
with:
workspaces: rust

# sherpa-onnx's build script would download its prebuilt libraries into target/, which
# rust-cache prunes before saving: a run that restored the cache skipped the build script
# and the macOS link failed with "library 'sherpa-onnx-c-api' not found". Download them
# here instead, outside target/, and point the build script at them. The archive is the
# one the build script would pick: shared libraries on macOS, static on Linux.
- name: Download sherpa-onnx
run: |
version=$(grep -A1 '^name = "sherpa-onnx-sys"' Cargo.lock | sed -n 's/^version = "\(.*\)"/\1/p')
kind=$([[ $RUNNER_OS == macOS ]] && echo osx-arm64-shared-lib || echo linux-x64-static-lib)
archive=sherpa-onnx-v$version-$kind
mkdir -p "$RUNNER_TEMP/sherpa-onnx"
curl -fsSL --retry 3 "https://github.com/k2-fsa/sherpa-onnx/releases/download/v$version/$archive.tar.bz2" \
| tar -xj -C "$RUNNER_TEMP/sherpa-onnx"
echo "SHERPA_ONNX_LIB_DIR=$RUNNER_TEMP/sherpa-onnx/$archive/lib" >> "$GITHUB_ENV"

- name: Format
run: cargo fmt --all --check

- name: Clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings

# Tests that need the speech model, a Claude key or a desktop are #[ignore]d and skipped.
- name: Test
run: cargo test --workspace --locked
35 changes: 32 additions & 3 deletions App/Murmur/HotkeyMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ enum Hotkey: String, CaseIterable, Identifiable {
/// Creating the tap needs Accessibility permission, which Murmur needs anyway to paste. Call
/// `install()` again once permission is granted.
final class HotkeyMonitor {
/// How long the key has to stay down before it counts as push-to-talk. A quicker tap is left
/// to macOS, so tapping fn still opens the emoji picker without starting a recording.
static let holdDelay: TimeInterval = 0.2

var hotkey: Hotkey = .fn
var onPress: () -> Void = {}
var onRelease: () -> Void = {}
Expand All @@ -59,6 +63,8 @@ final class HotkeyMonitor {
private var tap: CFMachPort?
private var runLoopSource: CFRunLoopSource?
private var isDown = false
/// The hold delay is running: the key is down but `onPress` hasn't fired yet.
private var pendingPress: DispatchWorkItem?

/// Returns false when the tap couldn't be created, which means no Accessibility permission yet.
@discardableResult
Expand Down Expand Up @@ -88,6 +94,12 @@ final class HotkeyMonitor {
tap = nil
runLoopSource = nil
isDown = false
cancelPendingPress()
}

private func cancelPendingPress() {
pendingPress?.cancel()
pendingPress = nil
}

fileprivate func handle(type: CGEventType, keyCode: UInt16, flags: CGEventFlags) {
Expand All @@ -96,16 +108,33 @@ final class HotkeyMonitor {
// macOS switches a tap off if it stalls; turn it straight back on.
if let tap { CGEvent.tapEnable(tap: tap, enable: true) }
case .keyDown:
if isDown { onChord() }
guard isDown else { return }
if pendingPress != nil {
// A shortcut typed before the hold delay ran out: never start recording at all.
cancelPendingPress()
} else {
onChord()
}
case .flagsChanged:
guard keyCode == hotkey.keyCode else { return }
let pressed = flags.contains(hotkey.flag)
if pressed, !isDown {
isDown = true
onPress()
let press = DispatchWorkItem { [weak self] in
guard let self else { return }
pendingPress = nil
onPress()
}
pendingPress = press
DispatchQueue.main.asyncAfter(deadline: .now() + Self.holdDelay, execute: press)
} else if !pressed, isDown {
isDown = false
onRelease()
if pendingPress != nil {
// A quick tap: recording never started, so there's nothing to finish.
cancelPendingPress()
} else {
onRelease()
}
}
default:
break
Expand Down
1 change: 1 addition & 0 deletions rust/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/target
Loading
Loading