diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 5d2556f..d85b474 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -74,5 +74,9 @@ jobs: 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. + # On macOS the test binaries load sherpa-onnx's dylibs at run time. The build script only + # copies them into target/ when it runs, which a restored cache skips, so point dyld at + # the downloaded folder. Set here rather than in GITHUB_ENV: macOS strips DYLD_* variables + # when it starts /bin/bash, but not when bash starts cargo. Linux links statically. - name: Test - run: cargo test --workspace --locked + run: DYLD_FALLBACK_LIBRARY_PATH="$SHERPA_ONNX_LIB_DIR" cargo test --workspace --locked diff --git a/App/Signing.xcconfig b/App/Signing.xcconfig index 1b611cb..36b634e 100644 --- a/App/Signing.xcconfig +++ b/App/Signing.xcconfig @@ -6,14 +6,13 @@ // // DEVELOPMENT_TEAM = ABCDE12345 // -// Or, with no Apple account, a self-signed certificate (see "Building without an Apple -// account" in the README): +// Or, with no Apple account, a self-signed certificate (see "Signing" in the README): // // MURMUR_SIGN_IDENTITY = Murmur Self-Signed // DEVELOPMENT_TEAM = // -// MURMUR_SIGN_IDENTITY rather than CODE_SIGN_IDENTITY, so scripts/release.sh can override it on -// the command line without also signing the Swift packages' targets. +// MURMUR_SIGN_IDENTITY rather than CODE_SIGN_IDENTITY, so it can be overridden on the +// xcodebuild command line without also signing the Swift packages' targets. MURMUR_SIGN_IDENTITY = Apple Development #include? "Signing.local.xcconfig" CODE_SIGN_IDENTITY = $(MURMUR_SIGN_IDENTITY) diff --git a/CLAUDE.md b/CLAUDE.md index b07752b..2945f49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Push-to-talk dictation: hold a key, speak, and cleaned-up text is pasted at the cursor. See [README.md](README.md) for the layout and [ROADMAP.md](ROADMAP.md) for what's next. -- **macOS:** the menu-bar app in `App/`, plus `MurmurCore` (macOS-only audio and speech engines) in `Core/`. -- **Linux (Hyprland/Omarchy):** `Linux/`. If you're running on Linux, start with [Linux/README.md](Linux/README.md): it has the setup steps (Swift, system packages, `Linux/scripts/install.sh`, the API key, Hyprland bindings), what has and hasn't been tested yet, and a testing checklist to work through. -- **Shared:** `Core/Sources/MurmurKit` (cleanup prompt, cleaners, Dictionary, sounds) must stay plain Foundation so it builds on both. `cd Core && swift test` runs its tests on either system. -- The cleanup prompt (`Core/Sources/MurmurKit/CleanupPrompt.swift`) drives most of the quality; check changes with the eval (`Eval/`, macOS only). +- **Murmur is the Rust workspace in `rust/`**, for macOS (the menu-bar app) and Linux (the Hyprland daemon). The crates and the interfaces between them are in [rust/CONTRACTS.md](rust/CONTRACTS.md); change it together with its callers. `cd rust && cargo test --workspace` runs the tests; CI (`.github/workflows/rust.yml`) also runs `cargo fmt --check` and `cargo clippy --workspace --all-targets -- -D warnings` on macOS and Linux. +- **Linux (Hyprland/Omarchy):** if you're running on Linux, start with [rust/linux/README.md](rust/linux/README.md): setup, system packages, `rust/linux/install.sh`, the API key, Hyprland bindings, and a testing checklist. +- **Swift, being retired:** the original macOS app (`App/`, `Core/`) and the eval (`Eval/`, macOS only) stay until the eval and the release scripts are ported. Don't add features there. +- The cleanup prompt drives most of the quality. It lives in `rust/crates/murmur-core/src/prompt.rs` and, byte for byte, in `Core/Sources/MurmurKit/CleanupPrompt.swift`; a Rust test fails if they differ, so edit both. Check changes with the eval. diff --git a/Linux/.gitignore b/Linux/.gitignore deleted file mode 100644 index e0e18d7..0000000 --- a/Linux/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.build/ -vendor/ diff --git a/Linux/Package.swift b/Linux/Package.swift deleted file mode 100644 index 5ac0452..0000000 --- a/Linux/Package.swift +++ /dev/null @@ -1,36 +0,0 @@ -// swift-tools-version: 6.2 -import Foundation -import PackageDescription - -// The Linux build of Murmur: a background daemon plus a small CLI that Hyprland key bindings call. -// Speech-to-text is Parakeet through sherpa-onnx's C API. `scripts/setup.sh` downloads the -// sherpa-onnx libraries into `vendor/sherpa-onnx`; set SHERPA_ONNX_DIR to use another copy. -let packageDir = Context.packageDirectory -let sherpa = Context.environment["SHERPA_ONNX_DIR"] ?? "\(packageDir)/vendor/sherpa-onnx" - -let package = Package( - name: "MurmurLinux", - dependencies: [ - .package(path: "../Core"), - ], - targets: [ - .systemLibrary(name: "CSherpaOnnx"), - .executableTarget( - name: "murmur", - dependencies: [ - "CSherpaOnnx", - .product(name: "MurmurKit", package: "Core"), - ], - swiftSettings: [ - .unsafeFlags(["-Xcc", "-I\(sherpa)/include"]), - ], - linkerSettings: [ - // Installed: libraries sit in ~/.local/lib/murmur next to ~/.local/bin/murmur. - // Development (`swift run`): straight from the vendor folder. - .unsafeFlags(["-L\(sherpa)/lib", - "-Xlinker", "-rpath", "-Xlinker", "$ORIGIN/../lib/murmur", - "-Xlinker", "-rpath", "-Xlinker", "\(sherpa)/lib"]), - ] - ), - ] -) diff --git a/Linux/README.md b/Linux/README.md deleted file mode 100644 index 4acdad2..0000000 --- a/Linux/README.md +++ /dev/null @@ -1,195 +0,0 @@ -# Murmur for Linux - -Push-to-talk dictation on Linux with Hyprland (built for [Omarchy](https://omarchy.org)). Hold a key, speak, and polished text is pasted wherever your cursor is. It uses the same speech model, cleanup prompt and Dictionary as the Mac app. - -``` -Hyprland key bind ─▶ murmur start/stop ─▶ daemon ─▶ pw-record ─▶ Parakeet (sherpa-onnx, CPU) - ─▶ Claude / Ollama cleanup ─▶ wl-copy + wtype -``` - -- **`murmur daemon`** runs in the background. It loads Parakeet v3 once and listens on a Unix socket (`$XDG_RUNTIME_DIR/murmur/murmur.sock`). -- **`murmur start`** and **`murmur stop`** are bound to a key's press and release in Hyprland. Wayland doesn't let apps watch keys globally, so the compositor does it. -- Cleanup, the Dictionary, the start/stop sounds and the per-app style all come from the shared [`MurmurKit`](../Core/Sources/MurmurKit). Your Mac Dictionary file works here unchanged. - -## Status - -> **For the agent picking this up on the Linux machine:** this was built on a Mac and checked in a `swift:6.2` arm64 Docker container. The container can't run a desktop, a microphone or Hyprland, so everything in the second list below still needs testing on the real machine. Work through [Setup](#setup), then [Testing checklist](#testing-checklist), and fix what breaks. - -Checked in Docker (Swift 6.2.4, Ubuntu, arm64): - -- `scripts/setup.sh` downloads sherpa-onnx 1.13.8 and the model, and the release build links against them. -- `murmur transcribe` gives a correct transcript (about 0.18 s for a 6 s clip after the model is loaded) and the Claude cleanup call works from Linux. -- The daemon's socket commands (start, stop, toggle, cancel, status), the "already running" check, `murmur doctor` and shutdown on SIGTERM. -- MurmurKit's tests pass on Linux. - -Not tested yet: - -- The x86_64 build (the Docker check was arm64; `setup.sh` picks the x64 sherpa-onnx build automatically). -- Recording with `pw-record`, the sounds with `pw-play`, pasting with `wl-copy` + `wtype`, `hyprctl activewindow`, `notify-send`. -- The Hyprland press/release bindings, and how they behave with modifier keys. -- Reading the API key with `secret-tool`. -- Real-world latency on this CPU, and whether `pw-record`'s start-up clips the first word. - -## Setup - -### 1. Swift - -Murmur needs Swift 6.2 or newer. On Arch/Omarchy, install the prebuilt toolchain from the AUR: - -```bash -yay -S swift-bin -``` - -```bash -swift --version -``` - -If `swift-bin` is out of date or broken, the alternatives are the official tarball from [swift.org/install/linux](https://www.swift.org/install/linux/) (pick the newest "Universal" or RHEL/Fedora build and add its `usr/bin` to `PATH`) or the `swift-language` AUR package (builds from source; slow). - -### 2. System packages - -```bash -sudo pacman -S --needed pipewire wl-clipboard wtype libnotify libsecret curl bzip2 -``` - -| Tool | Package | Used for | -|---|---|---| -| `pw-record`, `pw-play` | pipewire | recording the mic, playing the start/stop sounds | -| `wl-copy` | wl-clipboard | putting the text on the clipboard | -| `wtype` | wtype | sending Ctrl+V (Ctrl+Shift+V in terminals) | -| `hyprctl` | hyprland | finding the focused window, to pick the cleanup style | -| `notify-send` | libnotify | showing errors (Omarchy shows them with mako) | -| `secret-tool` | libsecret | reading the API key from the keyring | - -Omarchy already ships most of these. - -### 3. Build and install - -From the repository root: - -```bash -Linux/scripts/install.sh -``` - -This runs `scripts/setup.sh` first, which downloads sherpa-onnx's libraries into `Linux/vendor/` (not in git) and the Parakeet model (~480 MB) into `~/.local/share/murmur/models/`. Then it builds a release binary and installs `~/.local/bin/murmur`, with sherpa-onnx's libraries in `~/.local/lib/murmur/`. Run it again after pulling changes; it restarts the daemon if one is running. - -### 4. API key - -For Claude cleanup, store your Anthropic API key in the keyring (it prompts for the key, so it doesn't land in shell history): - -```bash -secret-tool store --label='Murmur' service com.plyhdrn.murmur account anthropic-api-key -``` - -This needs a Secret Service provider such as gnome-keyring, which Omarchy uses. Without one, set `ANTHROPIC_API_KEY` for the daemon instead, e.g. `env = ANTHROPIC_API_KEY,…` in Hyprland's config. To use a local model or no cleanup, see [Settings](#settings). - -### 5. Hyprland - -Start the daemon with your session. In `~/.config/hypr/autostart.conf` (Omarchy) or `hyprland.conf`: - -```ini -exec-once = ~/.local/bin/murmur daemon -``` - -Bind push-to-talk in `~/.config/hypr/bindings.conf`. A key with no modifiers is the safest choice: by the time text is pasted you've let go of it, so the paste can't turn into some other shortcut. Right Alt, for example: - -```ini -bind = , Alt_R, exec, ~/.local/bin/murmur start -bindr = ALT, Alt_R, exec, ~/.local/bin/murmur stop -``` - -When a modifier key is pressed on its own, Hyprland sees that modifier as held when it's released, hence `ALT` on the release line. If the release doesn't fire, try `bindr = , Alt_R, …`, or use toggle mode, where one press starts and the next press finishes: - -```ini -bind = SUPER CTRL, D, exec, ~/.local/bin/murmur toggle -``` - -Check what's free first with `hyprctl binds`. Reload with `hyprctl reload` and start the daemon once by hand (`hyprctl dispatch exec "~/.local/bin/murmur daemon"`) instead of logging out. - -### 6. Check - -```bash -murmur doctor -``` - -It checks each tool, the model, the API key (or the Ollama model) and whether the daemon is running, and prints the fix for anything missing. - -## Settings - -`~/.config/murmur/config.json` (optional; every field has a default, and it's re-read at the start of each dictation): - -```json -{ - "cleanup": "claude", - "ollamaModel": "qwen3.5:9b", - "sounds": "wood", - "microphone": "alsa_input.usb-Blue_Yeti-00.analog-stereo", - "threads": 4 -} -``` - -- **cleanup**: `claude` (default), `ollama` (runs `ollamaModel`; `ollama pull` it first) or `off` (paste Parakeet's transcript as-is). -- **sounds**: `wood` (default), `soft`, `chime`, `bubble` or `off`. -- **microphone**: a PipeWire node name from `pw-record --list-targets`; leave it out to use the default input. -- **threads**: CPU threads for speech-to-text. -- **model**: a different sherpa-onnx Parakeet folder (default `~/.local/share/murmur/models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8`). - -**Dictionary:** `~/.config/murmur/dictionary.json`, the same format as on the Mac (`~/Library/Application Support/Murmur/dictionary.json`), so you can copy that file over. Changes apply on the next dictation. - -**Cleanup style per app:** the focused window's class picks chat, email, notes or code style (see `TargetContext(windowClass:)` in [`TargetContext.swift`](../Core/Sources/MurmurKit/TargetContext.swift)). Find a window's class with `hyprctl activewindow`. Terminals get Ctrl+Shift+V instead of Ctrl+V (`Desktop.isTerminal`). - -## Testing checklist - -Work through these on the Linux machine, roughly in order. Each one isolates one piece. - -1. `murmur doctor` reports nothing missing. -2. **Speech model:** `murmur transcribe ~/.local/share/murmur/models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/test_wavs/en.wav --no-cleanup` prints a sensible sentence. Note the load and speech times. -3. **Microphone:** `pw-record --rate 16000 --channels 1 --format f32 /tmp/t.wav`, speak, Ctrl+C, then `murmur transcribe /tmp/t.wav`. This checks recording, the WAV reader and cleanup together. -4. **API key:** step 3 prints a `Cleanup [claude-haiku-5-5 …]` line rather than "Cleanup failed". -5. **Daemon by hand:** `murmur daemon` in one terminal; in another, run `murmur start`, speak, then `murmur stop`. The text should paste into that second terminal (with Ctrl+Shift+V). The daemon's terminal shows the log line. -6. **Sounds:** start and stop each play a short tone. -7. **Key binding:** hold the key, speak, release. Check that it pastes into a browser text field (Ctrl+V), a terminal (Ctrl+Shift+V), and a chat app (chat style). -8. **First word:** say a short sentence right as you press the key. If the first word is often missing, `pw-record` is starting too slowly (see Ideas). -9. **Taps:** tap the key without speaking. Nothing should paste (the log says "No speech"). -10. **Autostart:** log out and in; `murmur status` says `ready`. - -When something fails, check `~/.local/state/murmur/murmur.log` first. - -## Development - -```bash -cd Linux -scripts/setup.sh # once: sherpa-onnx into vendor/, model into ~/.local/share -swift build -swift run murmur doctor -``` - -The pieces, all in [`Sources/murmur`](Sources/murmur): - -| File | What | -|---|---| -| `main.swift` | The CLI: `daemon`, `start`/`stop`/`toggle`/`cancel`/`status`, `transcribe`, `doctor`. | -| `Daemon.swift` | The push-to-talk loop: record → transcribe → Dictionary → cleanup → paste. | -| `ControlSocket.swift` | The Unix socket between the CLI and the daemon. | -| `Transcriber.swift` | Parakeet through sherpa-onnx's C API (`Sources/CSherpaOnnx`). | -| `Audio.swift` | Recording with `pw-record`, reading WAV files, playing sounds with `pw-play`. | -| `Desktop.swift` | `hyprctl`, `wl-copy` + `wtype`, `notify-send`. | -| `Config.swift` | `config.json` and choosing the cleaner. | -| `Support.swift` | XDG paths, the log, running command-line tools. | - -`Package.swift` finds sherpa-onnx in `vendor/sherpa-onnx` (or `$SHERPA_ONNX_DIR`). The binary looks for the libraries next to the installed binary first (`~/.local/lib/murmur`), then in `vendor/`. Shared logic belongs in `Core/Sources/MurmurKit`, which must stay plain Foundation so it builds on both systems (`cd Core && swift test` runs its tests on either). - -**Checking the Linux build from a Mac,** with Docker: - -```bash -docker run --rm -v "$PWD":/src:ro swift:6.2 bash -c 'apt-get update -qq && apt-get install -y -qq curl bzip2 >/dev/null && cp -r /src/Core /src/Linux /tmp/ && cd /tmp/Linux && rm -rf .build vendor && scripts/setup.sh && swift build' -``` - -(This downloads the model too; mount an existing copy at `/root/.local/share/murmur/models` to skip that.) - -## Ideas - -- **Overlay:** a small "listening" indicator, e.g. a Waybar module that polls `murmur status`, or a GTK layer-shell window like the Mac HUD. -- **Faster start:** if `pw-record` clips the first word, keep one recording stream open and only keep the audio between start and stop. -- **Streaming:** transcribe while you're still talking, as on the Mac roadmap. -- **Packaging:** an AUR `PKGBUILD`, and a systemd user service as an alternative to `exec-once`. diff --git a/Linux/Sources/CSherpaOnnx/module.modulemap b/Linux/Sources/CSherpaOnnx/module.modulemap deleted file mode 100644 index d16f528..0000000 --- a/Linux/Sources/CSherpaOnnx/module.modulemap +++ /dev/null @@ -1,5 +0,0 @@ -module CSherpaOnnx [system] { - header "shim.h" - link "sherpa-onnx-c-api" - export * -} diff --git a/Linux/Sources/CSherpaOnnx/shim.h b/Linux/Sources/CSherpaOnnx/shim.h deleted file mode 100644 index 0ab5a09..0000000 --- a/Linux/Sources/CSherpaOnnx/shim.h +++ /dev/null @@ -1,3 +0,0 @@ -// sherpa-onnx's C API. The header comes from the sherpa-onnx download (see scripts/setup.sh), -// found through the -I flag in Package.swift. -#include diff --git a/Linux/Sources/murmur/Audio.swift b/Linux/Sources/murmur/Audio.swift deleted file mode 100644 index d42d32e..0000000 --- a/Linux/Sources/murmur/Audio.swift +++ /dev/null @@ -1,111 +0,0 @@ -import Foundation -import MurmurKit - -/// Records the microphone with PipeWire's `pw-record` into a 16 kHz mono float WAV file. -@MainActor -final class Recorder { - private var process: Process? - private var file: URL? - - func start(microphone: String?) throws { - guard Shell.has("pw-record") else { throw MurmurError("pw-record isn't installed") } - Paths.ensure(Paths.runtime) - let file = Paths.runtime.appending(path: "recording.wav") - try? FileManager.default.removeItem(at: file) - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - var arguments = ["pw-record", "--rate", "\(Int(sttSampleRate))", "--channels", "1", "--format", "f32"] - if let microphone { arguments += ["--target", microphone] } - process.arguments = arguments + [file.path] - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - try process.run() - self.process = process - self.file = file - } - - /// Stops recording and returns the samples. Empty if nothing was recorded. - func stop() async -> [Float] { - guard let process, let file else { return [] } - self.process = nil - self.file = nil - // SIGINT lets pw-record finish the WAV header. - process.interrupt() - for _ in 0..<200 where process.isRunning { try? await Task.sleep(for: .milliseconds(10)) } - if process.isRunning { process.terminate() } - defer { try? FileManager.default.removeItem(at: file) } - guard let data = try? Data(contentsOf: file) else { - Log.info("pw-record wrote nothing (exit \(process.isRunning ? -1 : process.terminationStatus))") - return [] - } - return (try? WAV.decode(data).samples) ?? [] - } -} - -/// Just enough WAV reading for pw-record's output and test clips: 16-bit or float PCM, any channel -/// count (mixed down to mono), with or without WAVE_FORMAT_EXTENSIBLE. -enum WAV { - static func decode(_ data: Data) throws -> (samples: [Float], sampleRate: Int) { - let bytes = [UInt8](data) - func u16(_ at: Int) -> Int { Int(bytes[at]) | Int(bytes[at + 1]) << 8 } - func u32(_ at: Int) -> Int { u16(at) | u16(at + 2) << 16 } - guard bytes.count >= 12, bytes[0..<4] == [0x52, 0x49, 0x46, 0x46], bytes[8..<12] == [0x57, 0x41, 0x56, 0x45] - else { throw MurmurError("Not a WAV file") } - - var format = 0, channels = 1, sampleRate = 0, bits = 0 - var offset = 12 - while offset + 8 <= bytes.count { - let id = String(decoding: bytes[offset..= 26 { format = u16(body + 24) } - } else if id == "data" { - // A recording that was cut off may have a missing or wrong size. - if size == 0 || body + size > bytes.count { size = bytes.count - body } - return (mixDown(bytes[body.., format: Int, bits: Int, channels: Int) -> [Float] { - let data = Data(raw) - let interleaved: [Float] - switch (format, bits) { - case (3, 32): - interleaved = data.withUnsafeBytes { Array($0.bindMemory(to: Float.self)) } - case (1, 16): - interleaved = data.withUnsafeBytes { $0.bindMemory(to: Int16.self).map { Float(Int16(littleEndian: $0)) / 32768 } } - default: - return [] - } - guard channels > 1 else { return interleaved } - return stride(from: 0, to: interleaved.count - channels + 1, by: channels).map { start in - interleaved[start.. Config { - guard let data = try? Data(contentsOf: url) else { return Config() } - do { - return try JSONDecoder().decode(Config.self, from: data) - } catch { - Log.info("Ignoring \(url.path): \(error)") - return Config() - } - } - - /// The cleaner for this config, or nil to paste the raw transcript. Throws when Claude is - /// chosen but there's no API key. - func makeCleaner(vocabulary: [String]) throws -> (any TextCleaner)? { - switch cleanup { - case .off: - return nil - case .ollama: - return OllamaCleaner(model: ollamaModel, vocabulary: vocabulary) - case .claude: - guard let key = APIKey.load() else { throw MurmurError("No Anthropic API key") } - return ClaudeCleaner(apiKey: key, vocabulary: vocabulary) - } - } -} diff --git a/Linux/Sources/murmur/ControlSocket.swift b/Linux/Sources/murmur/ControlSocket.swift deleted file mode 100644 index 97d6a87..0000000 --- a/Linux/Sources/murmur/ControlSocket.swift +++ /dev/null @@ -1,93 +0,0 @@ -import Dispatch -import Foundation -import Glibc - -/// The daemon listens on a Unix socket in `$XDG_RUNTIME_DIR/murmur/`. Each client connection -/// sends one command ("start", "stop", …) and reads back one line. -enum ControlSocket { - /// Sends `command` to the running daemon and returns its reply. - static func send(_ command: String) throws -> String { - let fd = try connected() - defer { close(fd) } - let bytes = Array((command + "\n").utf8) - guard write(fd, bytes, bytes.count) == bytes.count else { throw MurmurSocketError.io } - shutdown(fd, Int32(SHUT_WR)) - var reply = [UInt8]() - var buffer = [UInt8](repeating: 0, count: 512) - while true { - let count = read(fd, &buffer, buffer.count) - if count <= 0 { break } - reply += buffer[0.. String) throws -> any DispatchSourceRead { - let path = Paths.socket.path - Paths.ensure(Paths.runtime) - if (try? connected()).map({ close($0) }) != nil { - throw MurmurSocketError.alreadyRunning - } - unlink(path) - - let fd = socket(AF_UNIX, Int32(SOCK_STREAM.rawValue), 0) - guard fd >= 0 else { throw MurmurSocketError.io } - var address = try address(path) - let bound = withUnsafePointer(to: &address) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout.size)) } - } - guard bound == 0, Glibc.listen(fd, 8) == 0 else { close(fd); throw MurmurSocketError.io } - - let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: .main) - source.setEventHandler { - let client = accept(fd, nil, nil) - guard client >= 0 else { return } - defer { close(client) } - var buffer = [UInt8](repeating: 0, count: 256) - let count = read(client, &buffer, buffer.count) - guard count > 0 else { return } - let command = String(decoding: buffer[0.. Int32 { - let fd = socket(AF_UNIX, Int32(SOCK_STREAM.rawValue), 0) - guard fd >= 0 else { throw MurmurSocketError.io } - var address = try address(Paths.socket.path) - let result = withUnsafePointer(to: &address) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, socklen_t(MemoryLayout.size)) } - } - guard result == 0 else { close(fd); throw MurmurSocketError.notRunning } - return fd - } - - private static func address(_ path: String) throws -> sockaddr_un { - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - let bytes = Array(path.utf8) - try withUnsafeMutableBytes(of: &address.sun_path) { buffer in - guard bytes.count < buffer.count else { throw MurmurSocketError.io } - buffer.copyBytes(from: bytes) - } - return address - } -} - -enum MurmurSocketError: Error, CustomStringConvertible { - case notRunning, alreadyRunning, io - - var description: String { - switch self { - case .notRunning: "Murmur isn't running (or is still loading). Start it with `murmur daemon`." - case .alreadyRunning: "Murmur is already running." - case .io: "Couldn't talk to Murmur over \(Paths.socket.path)" - } - } -} diff --git a/Linux/Sources/murmur/Daemon.swift b/Linux/Sources/murmur/Daemon.swift deleted file mode 100644 index 9467b04..0000000 --- a/Linux/Sources/murmur/Daemon.swift +++ /dev/null @@ -1,131 +0,0 @@ -import Foundation -import MurmurKit - -/// Runs the push-to-talk loop: start → record → stop → transcribe → clean → paste. Commands -/// arrive over the control socket from Hyprland key bindings (`murmur start` on press, -/// `murmur stop` on release). -@MainActor -final class Daemon { - enum Phase: String { - case ready, recording, processing - } - - /// Holds shorter than this are treated as accidental taps. - private static let minimumHold = Duration.milliseconds(300) - - private var phase = Phase.ready - private var config = Config.load() - private let transcriber: Transcriber - private let recorder = Recorder() - private var recordingStarted = ContinuousClock.now - /// Looked up when recording starts, so the paste goes where you were when you began. - private var windowClass: Task? - - init(transcriber: Transcriber) { - self.transcriber = transcriber - } - - func handle(_ command: String) -> String { - switch command { - case "start": start() - case "stop": stop() - case "toggle": phase == .recording ? stop() : start() - case "cancel": cancel() - case "status": phase.rawValue - default: "unknown command: \(command)" - } - } - - private func start() -> String { - guard phase == .ready else { return "busy: \(phase.rawValue)" } - config = Config.load() - do { - try recorder.start(microphone: config.microphone) - } catch { - Log.info("Couldn't start pw-record: \(error)") - Desktop.notify("Couldn't start the microphone") - return "error: couldn't start pw-record" - } - phase = .recording - recordingStarted = .now - windowClass = Task { await Desktop.activeWindowClass() } - Sounds.play(.start, theme: config.soundTheme) - return "recording" - } - - private func stop() -> String { - guard phase == .recording else { return "not recording" } - phase = .processing - Sounds.play(.stop, theme: config.soundTheme) - let held = ContinuousClock.now - recordingStarted - Task { - await finish(held: held) - phase = .ready - } - return "processing" - } - - private func cancel() -> String { - guard phase == .recording else { return "not recording" } - phase = .processing - Sounds.play(.stop, theme: config.soundTheme) - Task { - _ = await recorder.stop() - phase = .ready - } - return "cancelled" - } - - private func finish(held: Duration) async { - let samples = await recorder.stop() - let target = await windowClass?.value - - // Too short, or no voice in it (a tap of the key): speech models invent words from - // silence, so don't transcribe at all. - guard held >= Self.minimumHold, VoiceActivity.containsSpeech(samples) else { - Log.info("No speech in \(held.formatted(.units(allowed: [.milliseconds]))) recording, skipped") - return - } - - let clock = ContinuousClock() - let speechStart = clock.now - let transcriber = transcriber - let raw = await Task.detached { transcriber.transcribe(samples) }.value - let speech = clock.now - speechStart - guard !raw.isEmpty else { return } - - // Replacements run first and always, so they work with cleanup off and the cleanup - // model sees the corrected terms. - let dictionary: PersonalDictionary - do { - dictionary = try PersonalDictionary.load() - } catch { - Log.info("Ignoring \(PersonalDictionary.defaultURL.path): \(error)") - dictionary = PersonalDictionary() - } - let corrected = dictionary.applyReplacements(to: raw) - let context = TargetContext(windowClass: target) - - var text = corrected - var timing = "speech \(Self.ms(speech))" - do { - if let cleaner = try config.makeCleaner(vocabulary: dictionary.vocabulary) { - let cleanupStart = clock.now - text = try await cleaner.clean(corrected, context: context) - timing += ", cleanup \(Self.ms(clock.now - cleanupStart)) (\(cleaner.id))" - } - } catch { - Log.info("Cleanup failed, pasting raw text: \(error)") - Desktop.notify("Cleanup failed, pasted raw text") - } - guard !text.isEmpty else { return } - - let pasted = await Desktop.paste(text, windowClass: target) - if !pasted { Desktop.notify("Copied. Paste with Ctrl+V") } - Log.info("Dictated into \(target ?? "nothing") [\(context.rawValue)], \(timing): \(text)") - } - - private static func ms(_ duration: Duration) -> String { - "\(Int(duration / .milliseconds(1))) ms" - } -} diff --git a/Linux/Sources/murmur/Desktop.swift b/Linux/Sources/murmur/Desktop.swift deleted file mode 100644 index 6459739..0000000 --- a/Linux/Sources/murmur/Desktop.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -/// Hyprland and Wayland: which window has focus, pasting into it, and notifications. -enum Desktop { - /// Class of the focused window ("Slack", "Alacritty", …), or nil if there is none or this - /// isn't Hyprland. - static func activeWindowClass() async -> String? { - let result = await Shell.run("hyprctl", ["activewindow", "-j"], captureOutput: true) - guard result.succeeded, - let json = try? JSONSerialization.jsonObject(with: Data(result.output.utf8)) as? [String: Any], - let windowClass = json["class"] as? String, !windowClass.isEmpty - else { return nil } - return windowClass - } - - /// Terminals paste with Ctrl+Shift+V; Ctrl+V would send a literal ^V. - static func isTerminal(_ windowClass: String?) -> Bool { - guard let name = windowClass?.lowercased() else { return false } - return terminals.contains { name.contains($0) } - } - - private static let terminals = [ - "alacritty", "kitty", "ghostty", "foot", "wezterm", "konsole", "gnome-terminal", - "terminator", "tilix", "xterm", "rio", "warp", - ] - - /// Puts `text` on the clipboard and pastes it into the focused window with wtype. The text - /// stays on the clipboard afterwards, like on the Mac. Returns false if the paste keystroke - /// couldn't be sent (the text is still on the clipboard if copying worked). - static func paste(_ text: String, windowClass: String?) async -> Bool { - guard await Shell.run("wl-copy", input: text).succeeded else { return false } - let modifiers = isTerminal(windowClass) ? ["ctrl", "shift"] : ["ctrl"] - let keys = modifiers.flatMap { ["-M", $0] } + ["-k", "v"] + modifiers.reversed().flatMap { ["-m", $0] } - return await Shell.run("wtype", keys).succeeded - } - - static func notify(_ message: String) { - Shell.spawn("notify-send", ["--app-name=Murmur", "--expire-time=3000", "Murmur", message]) - } -} diff --git a/Linux/Sources/murmur/Support.swift b/Linux/Sources/murmur/Support.swift deleted file mode 100644 index cecad80..0000000 --- a/Linux/Sources/murmur/Support.swift +++ /dev/null @@ -1,117 +0,0 @@ -import Foundation -import Glibc - -/// Where Murmur keeps things, following the XDG base directories. -enum Paths { - /// `~/.config/murmur`: config.json and dictionary.json. - static var config: URL { base("XDG_CONFIG_HOME", ".config") } - /// `~/.local/share/murmur`: the speech model. - static var data: URL { base("XDG_DATA_HOME", ".local/share") } - /// `~/.local/state/murmur`: murmur.log. - static var state: URL { base("XDG_STATE_HOME", ".local/state") } - /// `$XDG_RUNTIME_DIR/murmur`: the control socket and rendered sounds. Cleared at logout. - static var runtime: URL { - let dir = env("XDG_RUNTIME_DIR").map { URL(fileURLWithPath: $0) } - ?? URL(fileURLWithPath: "/tmp/murmur-\(getuid())") - return dir.appending(path: "murmur") - } - - static var socket: URL { runtime.appending(path: "murmur.sock") } - static var log: URL { state.appending(path: "murmur.log") } - - static func env(_ name: String) -> String? { - guard let value = ProcessInfo.processInfo.environment[name], !value.isEmpty else { return nil } - return value - } - - private static func base(_ variable: String, _ fallback: String) -> URL { - let dir = env(variable).map { URL(fileURLWithPath: $0) } - ?? FileManager.default.homeDirectoryForCurrentUser.appending(path: fallback) - return dir.appending(path: "murmur") - } - - static func ensure(_ dir: URL) { - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - } -} - -/// Prints to stderr and appends to `~/.local/state/murmur/murmur.log`, since the daemon usually -/// runs from Hyprland's autostart with nowhere to print. -enum Log { - static func info(_ message: String) { - let line = "\(Date().formatted(.iso8601)) \(message)\n" - FileHandle.standardError.write(Data(line.utf8)) - Paths.ensure(Paths.state) - let url = Paths.log - if !FileManager.default.fileExists(atPath: url.path) { - _ = FileManager.default.createFile(atPath: url.path, contents: nil) - } - guard let file = try? FileHandle(forWritingTo: url) else { return } - defer { try? file.close() } - _ = try? file.seekToEnd() - try? file.write(contentsOf: Data(line.utf8)) - } -} - -/// Runs the desktop's command-line tools. -enum Shell { - struct Result { - let status: Int32 - let output: String - var succeeded: Bool { status == 0 } - } - - /// Runs `program` from PATH to completion. `input` is written to its stdin. Output is only - /// captured when asked for, and must be small (it's read after the program exits); never - /// capture from a program that leaves a child running, like wl-copy. - /// - /// Waits with a termination handler rather than `waitUntilExit()`, which spins the run loop - /// and could run other daemon work in the middle of this call. - @discardableResult - static func run(_ program: String, _ arguments: [String] = [], input: String? = nil, - captureOutput: Bool = false) async -> Result { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [program] + arguments - let stdout = Pipe() - let stdin = Pipe() - process.standardOutput = captureOutput ? stdout : FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - process.standardInput = input == nil ? FileHandle.nullDevice : stdin - - let status: Int32 = await withCheckedContinuation { continuation in - process.terminationHandler = { continuation.resume(returning: $0.terminationStatus) } - do { - try process.run() - } catch { - process.terminationHandler = nil - continuation.resume(returning: -1) - return - } - if let input { - stdin.fileHandleForWriting.write(Data(input.utf8)) - try? stdin.fileHandleForWriting.close() - } - } - let output = captureOutput ? String(decoding: stdout.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) : "" - return Result(status: status, output: output) - } - - /// Starts `program` and doesn't wait for it. - static func spawn(_ program: String, _ arguments: [String] = []) { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [program] + arguments - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - process.standardInput = FileHandle.nullDevice - try? process.run() - } - - /// True if `program` is on PATH. - static func has(_ program: String) -> Bool { - (Paths.env("PATH") ?? "/usr/bin").split(separator: ":").contains { - FileManager.default.isExecutableFile(atPath: "\($0)/\(program)") - } - } -} diff --git a/Linux/Sources/murmur/Transcriber.swift b/Linux/Sources/murmur/Transcriber.swift deleted file mode 100644 index 2bb28ee..0000000 --- a/Linux/Sources/murmur/Transcriber.swift +++ /dev/null @@ -1,60 +0,0 @@ -import CSherpaOnnx -import Foundation -import MurmurKit - -/// Parakeet TDT through sherpa-onnx, on the CPU. The same model the Mac app runs on the Neural -/// Engine (v3, multilingual), as int8 ONNX. Loaded once; call `transcribe` from one task at a time. -final class Transcriber: @unchecked Sendable { - /// The model `scripts/setup.sh` downloads. - static let defaultModel = "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8" - - private let recognizer: OpaquePointer - - init(modelDirectory: URL, threads: Int) throws { - let file = { (name: String) in modelDirectory.appending(path: name).path } - let paths = ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"].map(file) - for path in paths where !FileManager.default.fileExists(atPath: path) { - throw MurmurError("Speech model not found: \(path). Run Linux/scripts/setup.sh to download it.") - } - - // sherpa-onnx copies the strings while creating the recognizer, so they only need to live - // until then. - let strings = (paths + ["nemo_transducer", "cpu", "greedy_search"]).map { strdup($0)! } - defer { strings.forEach { free($0) } } - - var config = SherpaOnnxOfflineRecognizerConfig() - config.feat_config.sample_rate = Int32(sttSampleRate) - config.feat_config.feature_dim = 80 - config.model_config.transducer.encoder = UnsafePointer(strings[0]) - config.model_config.transducer.decoder = UnsafePointer(strings[1]) - config.model_config.transducer.joiner = UnsafePointer(strings[2]) - config.model_config.tokens = UnsafePointer(strings[3]) - config.model_config.model_type = UnsafePointer(strings[4]) - config.model_config.provider = UnsafePointer(strings[5]) - config.model_config.num_threads = Int32(threads) - config.decoding_method = UnsafePointer(strings[6]) - - guard let recognizer = SherpaOnnxCreateOfflineRecognizer(&config) else { - throw MurmurError("sherpa-onnx couldn't load the speech model from \(modelDirectory.path)") - } - self.recognizer = recognizer - } - - deinit { - SherpaOnnxDestroyOfflineRecognizer(recognizer) - } - - /// Mono samples in, text out. sherpa-onnx resamples anything that isn't 16 kHz. - func transcribe(_ samples: [Float], sampleRate: Int = Int(sttSampleRate)) -> String { - guard let stream = SherpaOnnxCreateOfflineStream(recognizer) else { return "" } - defer { SherpaOnnxDestroyOfflineStream(stream) } - samples.withUnsafeBufferPointer { - SherpaOnnxAcceptWaveformOffline(stream, Int32(sampleRate), $0.baseAddress, Int32($0.count)) - } - SherpaOnnxDecodeOfflineStream(recognizer, stream) - guard let result = SherpaOnnxGetOfflineStreamResult(stream) else { return "" } - defer { SherpaOnnxDestroyOfflineRecognizerResult(result) } - guard let text = result.pointee.text else { return "" } - return String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines) - } -} diff --git a/Linux/Sources/murmur/main.swift b/Linux/Sources/murmur/main.swift deleted file mode 100644 index 1f58d9e..0000000 --- a/Linux/Sources/murmur/main.swift +++ /dev/null @@ -1,173 +0,0 @@ -import Dispatch -import Foundation -import Glibc -import MurmurKit - -let usage = """ -murmur: push-to-talk dictation for Linux (Hyprland) - - murmur daemon Load the speech model and wait for commands. Run it from - Hyprland's autostart (exec-once). - murmur start | stop Start or finish a dictation. Bind to a key's press and release. - murmur toggle Start, or finish if already recording. For a single key binding. - murmur cancel Throw away the current recording. - murmur status ready, recording or processing. - murmur transcribe [--context plain|chat|email|notes|code] [--no-cleanup] - Transcribe and clean up a WAV file, without the daemon. - murmur doctor Check that the tools, model and API key Murmur needs are present. - -Settings: \(Config.url.path) -Dictionary: \(PersonalDictionary.defaultURL.path) -Log: \(Paths.log.path) -""" - -func fail(_ message: String) -> Never { - FileHandle.standardError.write(Data((message + "\n").utf8)) - exit(1) -} - -func loadTranscriber(_ config: Config) -> Transcriber { - do { - return try Transcriber(modelDirectory: config.modelDirectory, threads: config.threads) - } catch { - fail("\(error)") - } -} - -@MainActor -func runDaemon() async -> Never { - signal(SIGPIPE, SIG_IGN) - if (try? ControlSocket.send("status")) != nil { fail("\(MurmurSocketError.alreadyRunning)") } - let config = Config.load() - Log.info("Loading speech model from \(config.modelDirectory.path)") - let transcriber = loadTranscriber(config) - // The first decode is slow while ONNX Runtime warms up; do it now, not on the first dictation. - _ = transcriber.transcribe([Float](repeating: 0, count: Int(sttSampleRate))) - - let daemon = Daemon(transcriber: transcriber) - let socket: any DispatchSourceRead - do { - socket = try ControlSocket.listen { daemon.handle($0) } - } catch { - fail("\(error)") - } - - // Remove the socket on the way out. - var signals: [any DispatchSourceSignal] = [] - for number in [SIGINT, SIGTERM] { - signal(number, SIG_IGN) - let source = DispatchSource.makeSignalSource(signal: number, queue: .main) - source.setEventHandler { - socket.cancel() - unlink(Paths.socket.path) - exit(0) - } - source.resume() - signals.append(source) - } - Log.info("Ready. Listening on \(Paths.socket.path)") - - while true { - try? await Task.sleep(for: .seconds(3600)) - withExtendedLifetime((socket, signals)) {} - } -} - -func transcribe(_ arguments: [String]) async { - guard let path = arguments.first(where: { !$0.hasPrefix("--") }) else { fail(usage) } - let config = Config.load() - let context = arguments.firstIndex(of: "--context").flatMap { arguments.indices.contains($0 + 1) ? arguments[$0 + 1] : nil } - .flatMap(TargetContext.init(rawValue:)) ?? .plain - - let audio: (samples: [Float], sampleRate: Int) - do { - audio = try WAV.decode(Data(contentsOf: URL(fileURLWithPath: path))) - } catch { - fail("Couldn't read \(path): \(error)") - } - - let clock = ContinuousClock() - var start = clock.now - let transcriber = loadTranscriber(config) - print("Model loaded in \(clock.now - start)") - _ = transcriber.transcribe([Float](repeating: 0, count: Int(sttSampleRate))) - - start = clock.now - let raw = transcriber.transcribe(audio.samples, sampleRate: audio.sampleRate) - print("Speech (\(clock.now - start)): \(raw)") - guard !arguments.contains("--no-cleanup") else { return } - - let dictionary = (try? PersonalDictionary.load()) ?? PersonalDictionary() - let corrected = dictionary.applyReplacements(to: raw) - do { - guard let cleaner = try config.makeCleaner(vocabulary: dictionary.vocabulary) else { - print("Cleanup is off in \(Config.url.path)") - return - } - start = clock.now - let cleaned = try await cleaner.clean(corrected, context: context) - print("Cleanup [\(cleaner.id), \(context.rawValue)] (\(clock.now - start)): \(cleaned)") - } catch { - fail("Cleanup failed: \(error)") - } -} - -func doctor() async { - let config = Config.load() - var problems = 0 - func check(_ ok: Bool, _ label: String, fix: String) { - print("\(ok ? "✓" : "✗") \(label)\(ok ? "" : "\n \(fix)")") - if !ok { problems += 1 } - } - - let tools: [(tool: String, purpose: String, package: String)] = [ - ("pw-record", "records the microphone", "pipewire"), - ("pw-play", "plays the start/stop sounds", "pipewire"), - ("wl-copy", "copies to the clipboard", "wl-clipboard"), - ("wtype", "sends the paste keystroke", "wtype"), - ("hyprctl", "finds the focused window", "hyprland"), - ("notify-send", "shows errors", "libnotify"), - ] - for (tool, purpose, package) in tools { - check(Shell.has(tool), "\(tool): \(purpose)", fix: "Install it: sudo pacman -S --needed \(package)") - } - - let model = config.modelDirectory - check(FileManager.default.fileExists(atPath: model.appending(path: "encoder.int8.onnx").path), - "Speech model at \(model.path)", fix: "Run Linux/scripts/setup.sh") - - switch config.cleanup { - case .claude: - check(APIKey.load() != nil, "Anthropic API key (cleanup: claude)", - fix: "secret-tool store --label='Murmur' service \(APIKey.keychainService) account \(APIKey.keychainAccount)") - case .ollama: - let models = await OllamaCleaner.installedModels() - check(models.contains(config.ollamaModel), "Ollama model \(config.ollamaModel) (cleanup: ollama)", - fix: "Start Ollama and run: ollama pull \(config.ollamaModel)") - case .off: - print("· Cleanup is off") - } - - let running = (try? ControlSocket.send("status")) != nil - check(running, "Daemon running", fix: "Run `murmur daemon`, or add `exec-once = murmur daemon` to Hyprland's autostart") - print(problems == 0 ? "\nAll good." : "\n\(problems) problem(s).") -} - -let arguments = Array(CommandLine.arguments.dropFirst()) -switch arguments.first { -case "daemon": - await runDaemon() -case "start", "stop", "toggle", "cancel", "status": - do { - print(try ControlSocket.send(arguments[0])) - } catch { - if arguments[0] == "start" || arguments[0] == "toggle" { Desktop.notify("\(error)") } - fail("\(error)") - } -case "transcribe": - await transcribe(Array(arguments.dropFirst())) -case "doctor": - await doctor() -default: - print(usage) -} diff --git a/Linux/scripts/install.sh b/Linux/scripts/install.sh deleted file mode 100755 index fbd1b8c..0000000 --- a/Linux/scripts/install.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# Builds murmur (release) and installs it to ~/.local/bin, with sherpa-onnx's libraries in -# ~/.local/lib/murmur. Restarts the daemon if it's running. -set -euo pipefail -cd "$(dirname "$0")/.." - -scripts/setup.sh - -flags=(-c release --static-swift-stdlib) -swift build "${flags[@]}" -bin="$(swift build "${flags[@]}" --show-bin-path)/murmur" - -mkdir -p ~/.local/bin ~/.local/lib/murmur -install -m 755 "$bin" ~/.local/bin/murmur -cp -P vendor/sherpa-onnx/lib/*.so* ~/.local/lib/murmur/ -echo "Installed ~/.local/bin/murmur" - -if pkill -x murmur; then - sleep 0.5 - if command -v hyprctl >/dev/null && [[ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then - hyprctl dispatch exec "~/.local/bin/murmur daemon" >/dev/null - echo "Restarted the daemon" - else - echo "Stopped the daemon; start it again with: murmur daemon" - fi -fi diff --git a/Linux/scripts/setup.sh b/Linux/scripts/setup.sh deleted file mode 100755 index 371dd23..0000000 --- a/Linux/scripts/setup.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# Downloads what the Linux build needs and doesn't keep in git: sherpa-onnx's libraries (into -# Linux/vendor/sherpa-onnx) and the Parakeet speech model (into ~/.local/share/murmur/models, -# ~480 MB). Safe to run again; it skips anything already there. -set -euo pipefail -cd "$(dirname "$0")/.." - -SHERPA_VERSION=1.13.8 -MODEL=sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8 - -case "$(uname -m)" in - x86_64) build=linux-x64-shared ;; - aarch64) build=linux-aarch64-shared-cpu ;; - *) echo "No sherpa-onnx build for $(uname -m)" >&2; exit 1 ;; -esac - -vendor=vendor/sherpa-onnx -if [[ "$(cat "$vendor/VERSION" 2>/dev/null)" != "$SHERPA_VERSION" ]]; then - echo "Downloading sherpa-onnx $SHERPA_VERSION ($build)…" - rm -rf "$vendor" && mkdir -p "$vendor" - curl -fL --progress-bar "https://github.com/k2-fsa/sherpa-onnx/releases/download/v$SHERPA_VERSION/sherpa-onnx-v$SHERPA_VERSION-$build.tar.bz2" \ - | tar xj -C "$vendor" --strip-components=1 - rm -rf "$vendor/bin" - # Some builds ship without headers; take the one from the same release. - header="$vendor/include/sherpa-onnx/c-api/c-api.h" - if [[ ! -f "$header" ]]; then - mkdir -p "$(dirname "$header")" - curl -fsSL "https://raw.githubusercontent.com/k2-fsa/sherpa-onnx/v$SHERPA_VERSION/sherpa-onnx/c-api/c-api.h" -o "$header" - fi - echo "$SHERPA_VERSION" > "$vendor/VERSION" -fi - -models="${XDG_DATA_HOME:-$HOME/.local/share}/murmur/models" -if [[ ! -f "$models/$MODEL/encoder.int8.onnx" ]]; then - echo "Downloading the Parakeet speech model (~480 MB)…" - mkdir -p "$models" - curl -fL --progress-bar "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/$MODEL.tar.bz2" \ - | tar xj -C "$models" -fi - -echo "sherpa-onnx: $PWD/$vendor" -echo "Model: $models/$MODEL" diff --git a/README.md b/README.md index f9cea19..82f66bc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Murmur -Push-to-talk dictation for macOS, with a Linux (Hyprland) version in [`Linux/`](Linux). Hold a key, speak, and polished text appears wherever your cursor is. +Push-to-talk dictation for macOS and Linux (Hyprland). Hold a key, speak, and polished text appears wherever your cursor is.

Dictating into a prompt box: the recording pill shows the live mic level, then "Murmuring…", then "Help me set up a new project." is pasted at the cursor @@ -10,7 +10,7 @@ The pipeline: ``` hotkey ─▶ mic capture ─▶ on-device STT ─▶ Claude cleanup ─▶ paste into frontmost app - (Parakeet / ANE) (Haiku 5.5) + (Parakeet, CPU) (Haiku 5.5) ``` Most of the quality comes from the cleanup pass. It fixes misheard words, drops filler and false starts, keeps only the final version of a self-correction, adds punctuation, and formats spoken lists as markdown when the target app allows it. @@ -19,20 +19,26 @@ Most of the quality comes from the cleanup pass. It fixes misheard words, drops | Folder | What | |---|---| -| [`App/`](App) | The menu-bar app. `App/project.yml` generates the Xcode project with XcodeGen. | -| [`Core/`](Core) | `MurmurKit`: the cleanup prompt, Claude and Ollama cleanup, the Dictionary, the start/stop sounds. Plain Foundation, so it also builds on Linux. `MurmurCore` (macOS only) adds audio capture and the speech engines, and re-exports `MurmurKit`. Shared by the app and the eval. | -| [`Eval/`](Eval) | CLI harness that scores engines and the cleanup prompt against recordings of your voice. | -| [`Linux/`](Linux) | The Linux version: a daemon driven by Hyprland key bindings, with Parakeet on the CPU via sherpa-onnx. Setup, status and a testing checklist are in [`Linux/README.md`](Linux/README.md). | +| [`rust/`](rust) | **Murmur.** One Rust workspace for both platforms: the macOS menu-bar app and the Linux daemon share the dictation engine, cleanup prompt, Dictionary and speech model. Crates are described in [`rust/README.md`](rust/README.md) and [`rust/CONTRACTS.md`](rust/CONTRACTS.md). | +| [`rust/linux/`](rust/linux) | Linux setup: the install script, Hyprland bindings and the Omarchy pill and bar menu. Start with [`rust/linux/README.md`](rust/linux/README.md). | +| [`App/`](App), [`Core/`](Core) | The original Swift macOS app and its `MurmurKit`/`MurmurCore` packages. Kept until the eval and the release scripts move to Rust; `Core/Sources/MurmurKit/CleanupPrompt.swift` is still the prompt's reference copy (see [The Swift app](#the-swift-app)). | +| [`Eval/`](Eval) | Swift CLI harness that scores speech engines and the cleanup prompt against recordings of your voice. macOS only. | -## The app +Murmur is open source and isn't distributed as a prebuilt app: you build it on the machine that runs it. + +## macOS + +You need [Rust](https://rustup.rs) (`rustup default stable`), the Xcode Command Line Tools (`xcode-select --install`), and a self-signed code-signing certificate, which takes a minute to make and needs no Apple account (see [Signing](#signing)). ```bash -brew install xcodegen # once -echo 'DEVELOPMENT_TEAM = ' > App/Signing.local.xcconfig # once; see App/Signing.xcconfig, or below for no Apple account -./scripts/install.sh # build Release, install to /Applications (~/Applications without admin rights), launch +rust/scripts/bundle-macos.sh # release build, signed rust/target/Murmur.app +open rust/target/Murmur.app +tail -f ~/Library/Logs/Murmur/murmur.log ``` -On first launch, allow **Microphone** and turn on Murmur under **System Settings › Privacy & Security › Accessibility**. Accessibility lets Murmur see the push-to-talk key and paste at your cursor. Both permissions persist across rebuilds because the app is always signed with the same certificate. +The first launch downloads the Parakeet model (~480 MB). Allow **Microphone** and turn on Murmur under **System Settings › Privacy & Security › Accessibility**. Accessibility lets Murmur see the push-to-talk key and paste at your cursor. Both permissions persist across rebuilds as long as you sign with the same certificate. Store your [API key](#api-key) for Claude cleanup. The menu's **Launch at Login** starts it with your Mac. + +If the Swift Murmur is installed, quit it first: both listen for fn. The Rust app's bundle ID is `com.plyhdrn.murmur-rs`, so it has its own permission grants; the Dictionary and API key are shared. Hold **fn** (changeable in the menu), speak, and release. The text is pasted wherever your cursor is. @@ -42,56 +48,52 @@ Hold **fn** (changeable in the menu), speak, and release. The text is pasted whe The Dictionary window's Replacements tab, mapping spoken phrases such as "cube control" to kubectl

-- **fn key:** set **System Settings › Keyboard › "Press 🌐 key to"** to **Do Nothing**, or macOS will open the emoji picker or its own dictation. You can pick Right ⌥ or Right ⌘ instead. +- **fn key:** set **System Settings › Keyboard › "Press 🌐 key to"** to **Do Nothing**, or macOS will open the emoji picker or its own dictation. You can pick Right ⌥ or Right ⌘ instead. A quick fn tap (under 200 ms) is left alone, so it still works as the Globe key. - **Chords are ignored:** typing another key while holding the hotkey (e.g. ⌥← to jump words) cancels the recording. - **Context:** the frontmost app sets the cleanup style. Slack and Messages get chat style, Mail gets email, Notes and Obsidian get markdown notes, editors and terminals get code. - **Cleanup** (menu): **Claude Haiku** (cloud, most reliable, ~1.3 s), **Local Model** via Ollama (default `qwen3.5:9b`, ~250 ms, occasional slips such as an invented heading or a dropped "yeah"; `ollama pull qwen3.5:9b` first), or **Off** (paste Parakeet's transcript as-is). The menu shows how long the last dictation's speech and cleanup steps took. - **Fallbacks:** if cleanup fails (no API key, Ollama not running), Murmur pastes the raw transcript and says so. Without Accessibility, it copies the text so you can ⌘V. - **Dictionary** (menu › Dictionary…): **Words** are spellings to prefer (names, jargon), used by the cleanup step to fix near-misses. **Replacements** are exact "when I say X, write Y" fixes, applied to every dictation even with cleanup off; they ignore case and match whole words only. Both live in `~/Library/Application Support/Murmur/dictionary.json`, which you can also edit by hand; changes apply on the next dictation. - **Microphone** (menu): System Default or any connected mic. Murmur remembers your choice without changing the system-wide default, and uses the default while that mic is unplugged. -- **Sound Effects** (menu): a start and stop sound in one of three styles, all synthesized in code: **Wood** (low, woody; default), **Soft** (pure round tones with a faint grace note), **Chime** (bright, bell-like) or **Bubble** (a single note that swoops up or down). Picking one plays a preview. +- **Sound Effects** (menu): a start and stop sound in one of four styles, all synthesized in code: **Wood** (low, woody; default), **Soft** (pure round tones with a faint grace note), **Chime** (bright, bell-like) or **Bubble** (a single note that swoops up or down). Picking one plays a preview. - **Clipboard:** the dictated text stays on the clipboard after pasting, so you can paste it again (or yourself, if no text field had focus). +- **Settings** live in `~/Library/Application Support/Murmur/settings.json`. -### Building without an Apple account +### Signing -You need Xcode 26 (free from the App Store) but no developer account: sign with a self-signed certificate instead. It's only trusted on the Mac that built it, which is all a local build needs, and macOS keeps the permissions across rebuilds as long as you keep the certificate. +macOS ties the Microphone and Accessibility permissions to the certificate an app is signed with, so Murmur has to be signed, and with the same certificate every time, or macOS asks again after each rebuild. A self-signed certificate does that: it's only trusted on the Mac that made it, which is all a build you run yourself needs. -1. Open Xcode once so it finishes installing its components, and install XcodeGen (`brew install xcodegen`). If `xcodebuild` says it needs Xcode rather than the Command Line Tools, run `sudo xcode-select -s /Applications/Xcode.app`, or without admin rights, `export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer` in the shell you build from. -2. In **Keychain Access**, choose **Keychain Access › Certificate Assistant › Create a Certificate…**. Name it `Murmur Self-Signed`, set **Identity Type** to **Self-Signed Root** and **Certificate Type** to **Code Signing**, and click **Create**. -3. Point the build at it, from the repo folder: - ```bash - printf 'MURMUR_SIGN_IDENTITY = Murmur Self-Signed\nDEVELOPMENT_TEAM =\n' > App/Signing.local.xcconfig - ``` -4. Run `./scripts/install.sh`. The first build downloads the Swift packages and may ask to use the certificate's key; choose **Always Allow**. Then grant Microphone and Accessibility as above. -5. For Claude cleanup, store your API key (the command is under [Eval harness](#eval-harness)). Without one, Murmur pastes the raw transcript, or use Local Model or Off in the menu. +1. In **Keychain Access**, choose **Keychain Access › Certificate Assistant › Create a Certificate…**. Name it `Murmur Self-Signed`, set **Identity Type** to **Self-Signed Root** and **Certificate Type** to **Code Signing**, and click **Create**. +2. Run `rust/scripts/bundle-macos.sh`, which signs with `Murmur Self-Signed` by default. The first signing may ask to use the certificate's key; choose **Always Allow**. -### Other Macs +Keep the certificate: a new one, even with the same name, makes macOS ask for the permissions again. To sign with a different certificate you already have, such as an Apple Development one, set `MURMUR_SIGN_IDENTITY` to its name; the script lists the ones in your Keychain if it can't find it. -To use Murmur on a Mac that doesn't build it, publish a build from this one and install it there with a script. Releases are public, so `release.sh` only publishes builds signed with a **Developer ID Application** certificate (from the paid Apple Developer Program). It refuses Apple Development and self-signed builds, which are for your own Macs. Set `DEVELOPMENT_TEAM` to the team that owns the certificate; the script shows who signed the build and asks before publishing. +Linux needs no signing. -```bash -./scripts/release.sh # here: build the current commit and publish it as a GitHub Release -``` +## Linux -On the other Mac (Apple Silicon, macOS 26), run this to install, and again to update. It needs no admin rights; without them it installs to `~/Applications`: +The same `murmur` binary is a daemon and command-line tool for Hyprland, driven by key bindings, with an Omarchy pill and bar menu. Setup, bindings and troubleshooting are in [`rust/linux/README.md`](rust/linux/README.md): ```bash -curl -fsSL https://raw.githubusercontent.com/andersryanc/murmur/main/scripts/get.sh | zsh +rust/linux/install.sh # release build to ~/.local/bin/murmur, plus the Omarchy plugin +murmur doctor ``` -Releases aren't notarized yet. That's fine for `get.sh`, because `curl` doesn't quarantine what it downloads, so Gatekeeper doesn't check it; a copy downloaded in a browser would be blocked (System Settings › Privacy & Security › **Open Anyway** lets it through). Grant Microphone and Accessibility on first launch as above; updates keep them as long as they're signed with the same certificate. A renewed certificate makes macOS ask again. Store the API key on that Mac too (see [Eval harness](#eval-harness) for the command; `get.sh` reminds you). A managed work Mac may still refuse to run the app or to turn on Accessibility, depending on its policies. +## API key -## Eval harness +Create a key at [platform.claude.com](https://platform.claude.com/settings/keys). `ANTHROPIC_API_KEY` in the environment overrides the stored key if set. -Requires macOS 26 and Xcode 26. The first run downloads the Parakeet models (~500 MB) and Apple's speech assets. - -**API key.** Create a key at [platform.claude.com](https://platform.claude.com/settings/keys), then store it in your login Keychain. The command prompts for the key, so it never lands in shell history: +On macOS, store it in your login Keychain. The command prompts for the key, so it never lands in shell history; add `-U` to replace a stored key: ```bash security add-generic-password -s com.plyhdrn.murmur -a anthropic-api-key -w ``` -`ANTHROPIC_API_KEY` in the environment overrides the Keychain if set. To replace a stored key, add `-U` to the command above. +On Linux, it's read from the Secret Service keyring; [`rust/linux/README.md`](rust/linux/README.md) has the command. + +## Eval harness + +Still Swift, so it runs the Swift engines (Parakeet on the Neural Engine through FluidAudio, and Apple's speech) rather than the Rust app's sherpa-onnx build; the cleanup prompt is byte-for-byte the same, so prompt results carry over. Requires macOS 26 and Xcode 26. The first run downloads the Parakeet models (~500 MB) and Apple's speech assets. It uses the same [API key](#api-key). ```bash cd Eval @@ -117,4 +119,16 @@ Run the binary directly rather than `swift run`, so macOS attributes the mic pro - **Clips** live in `Eval/clips/` as `.wav` plus a `.json` sidecar (`reference`, `intended`, `context`). Any `.wav`/`.m4a` you drop in works too. Audio is gitignored. - **Contexts**: `plain`, `chat`, `email`, `notes`, `code`. They set how the cleanup pass formats output. - **Dictionary**: every command uses the app's dictionary (`dictionary.json`) so results match real use. Pass `--dictionary ` to try another, or `--dictionary none` for none. -- **Cleanup prompt**: `Core/Sources/MurmurKit/CleanupPrompt.swift`, shared with the app. Try a draft first with `--prompt draft.md`. +- **Cleanup prompt**: edit `Core/Sources/MurmurKit/CleanupPrompt.swift` and `rust/crates/murmur-core/src/prompt.rs` together; a Rust test fails if they differ. Try a draft first with `--prompt draft.md`. + +## The Swift app + +The original macOS app, in `App/` and `Core/`, kept until the eval is ported. It needs Xcode 26 and XcodeGen, and signs with the same [self-signed certificate](#signing): + +```bash +brew install xcodegen # once +printf 'MURMUR_SIGN_IDENTITY = Murmur Self-Signed\nDEVELOPMENT_TEAM =\n' > App/Signing.local.xcconfig # once; see App/Signing.xcconfig +./scripts/install.sh # build Release, install to /Applications (~/Applications without admin rights), launch +``` + +If `xcodebuild` says it needs Xcode rather than the Command Line Tools, run `sudo xcode-select -s /Applications/Xcode.app`, or without admin rights, `export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`. diff --git a/ROADMAP.md b/ROADMAP.md index 96cead7..734b526 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,14 +18,27 @@ Ideas for what's next, roughly in priority order. Nothing here is committed to; - [ ] **Branding.** App icon, a proper menu-bar glyph, and a final palette for the overlay. - [ ] **First-run onboarding.** Walk through Microphone and Accessibility permissions, the fn key setting ("Press 🌐 key to: Do Nothing"), the API key, and a test dictation. - [ ] **Settings window.** Paste the Anthropic API key in the app (stored in the Keychain) instead of using the `security` command. -- [ ] **Distribution.** Sign with a personal Developer ID, notarize, and ship updates (e.g. Sparkle). + +Murmur is build-it-yourself: no prebuilt releases, Developer ID or notarization. + +## Retire the Swift app + +- [ ] **Port the eval to Rust.** `murmur eval record/run/bench/clean` against the sherpa-onnx Parakeet the app actually runs, then delete `Eval/`. +- [ ] **Make `prompt.rs` the only copy of the cleanup prompt.** Drop the test that compares it with `CleanupPrompt.swift`. +- [ ] **Install the Rust app on macOS.** A script that runs `rust/scripts/bundle-macos.sh` and copies the app to `~/Applications`, like `scripts/install.sh` does for the Swift app, so it runs from a stable path rather than `rust/target/`. +- [ ] **Take over the bundle ID.** Once the Swift app is gone, move from `com.plyhdrn.murmur-rs` to `com.plyhdrn.murmur`. +- [ ] **Delete `App/` and `Core/`**, trim the "port of the Swift …" comments, and consider moving `rust/` to the repo root. ## Linux -- [ ] **Test on the Omarchy machine.** Built and checked in Docker only; work through the testing checklist in [`Linux/README.md`](Linux/README.md) (mic, paste, key bindings, keyring, x86_64 build, latency). -- [ ] **A "listening" indicator.** A Waybar module that polls `murmur status`, or a layer-shell overlay like the Mac HUD. +- [ ] **Finish the Omarchy checklist.** Recording, sounds, pasting and the pill are confirmed on hardware; the rest is listed under "Status" in [`rust/linux/README.md`](rust/linux/README.md#status) (microphone choice, keyring, install and autostart, taps, notices). - [ ] **Packaging.** An AUR `PKGBUILD`, so installing doesn't need the repo. +## Windows + +- [ ] **The platform layer.** `murmur-platform` has a Windows stub: hotkey, paste, frontmost app. +- [ ] **Check whether signing matters.** A locally built `.exe` should need none (SmartScreen only checks downloaded files); confirm on a real machine. + ## Known issues - Local cleanup (`qwen3.5:9b`) occasionally drops a leading "yeah" or adds a heading in notes. Claude doesn't. diff --git a/rust/README.md b/rust/README.md index 8ebf693..506662e 100644 --- a/rust/README.md +++ b/rust/README.md @@ -1,15 +1,17 @@ -# Murmur in Rust +# Murmur -A cross-platform rewrite of Murmur, the push-to-talk dictation app. One codebase for macOS, -Windows and Linux. The Swift app in `../App` stays the daily driver until this matches it on the -eval. The crates and the interfaces between them are described in [CONTRACTS.md](CONTRACTS.md). +Murmur, the push-to-talk dictation app: one codebase for macOS and Linux, with Windows to come. +It replaces the Swift app in `../App`, which stays only until the eval and the release scripts +are ported (see the [roadmap](../ROADMAP.md#retire-the-swift-app)). The crates and the interfaces +between them are described in [CONTRACTS.md](CONTRACTS.md); user-facing setup is in the +[top-level README](../README.md). | Crate | What | |---|---| | `murmur-core` | Cleanup prompt (checked against the Swift copy by a test), Claude and Ollama cleaners, Dictionary, settings, sounds, voice activity, file locations, API key | | `murmur-stt` | Parakeet TDT 0.6B v3 (int8) on the CPU through sherpa-onnx; downloads the model on first use | | `murmur-audio` | Mic capture with cpal, resampled to 16 kHz; sound playback | -| `murmur-platform` | Hotkey, paste, permissions, frontmost app. macOS is done, Linux (Hyprland) is written but untested on hardware, Windows is a stub | +| `murmur-platform` | Hotkey, paste, permissions, frontmost app. macOS and Linux (Hyprland); Windows is a stub | | `murmur-control` | The Linux daemon's control socket and its line protocol (Unix only) | | `murmur-engine` | The dictation loop both front ends share: recording, the queue, transcribe → Dictionary → cleanup → paste, model loading, warm-ups, notices | | `murmur` | The binary. macOS: the tray app (menu, recording pill, Dictionary window, launch at login). Linux: the CLI and daemon | @@ -17,12 +19,12 @@ eval. The crates and the interfaces between them are described in [CONTRACTS.md] ## macOS ```bash -scripts/bundle-macos.sh # release build, signed target/Murmur.app +scripts/bundle-macos.sh # release build, signed target/Murmur.app ("Murmur Self-Signed", or MURMUR_SIGN_IDENTITY) open target/Murmur.app tail -f ~/Library/Logs/Murmur/murmur.log ``` -- **Quit the Swift Murmur first**: both listen for fn. +- **Quit the Swift Murmur first** if it's installed: both listen for fn. - The bundle ID is `com.plyhdrn.murmur-rs`, so it gets its own Accessibility and Microphone grants, separate from the Swift app's. Settings live in `~/Library/Application Support/Murmur/settings.json`; the Dictionary and the API key (Keychain) are shared with the Swift app. @@ -41,6 +43,16 @@ linux/install.sh # release build to ~/.local/bin/murmur, plus the Om murmur doctor ``` +## Checks + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace # tests needing the model, an API key or a desktop are #[ignore]d +``` + +CI runs all three on macOS and Linux. `scripts/linux-docker-test.sh` runs them in a Debian container from a Mac. + ## Not yet -Windows, and the eval. +Windows, and the eval (still the Swift one in `../Eval`). diff --git a/rust/linux/README.md b/rust/linux/README.md index fd30483..9c5c998 100644 --- a/rust/linux/README.md +++ b/rust/linux/README.md @@ -13,11 +13,23 @@ Hyprland key bind ─▶ murmur start/stop ─▶ daemon ─▶ ALSA (pipewire-a - **The on-screen pill** (like the Mac pill) is an [Omarchy shell](https://omarchy.org) plugin in [`omarchy-plugin/`](omarchy-plugin). It shows the mic level while you speak, "Murmuring…" while it works, and any problem for a moment afterwards. - **The bar menu** (like the Mac menu-bar menu) is in the same plugin: a mic icon in the Omarchy bar that turns red while recording and dims when the daemon isn't running. Click it for the cleanup mode and local model, the microphone, the sound effects, the last five dictations (click one to copy it), the Dictionary, and starting, restarting or quitting the daemon. -This replaces the Swift Linux build (`Linux/` on the `linux-omarchy-setup` branch). It reads that build's `~/.config/murmur/config.json` and model folder as they are, so switching needs no setup beyond installing. +This replaces the Swift Linux build (`Linux/`, removed from the repo; it's in git history before the Rust rewrite). It reads that build's `~/.config/murmur/config.json` and model folder as they are, so switching needs no setup beyond installing. ## Status -> **For the agent picking this up on the Linux machine:** this was built on a Mac and checked in Docker (Debian bookworm, x86_64 under emulation). Nothing here has run on Hyprland, PipeWire or real hardware yet. Work through [Setup](#setup), then the [Testing checklist](#testing-checklist), and fix what breaks. +Tested on an Omarchy machine (Hyprland, PipeWire). Confirmed there (October 2026): + +- Recording from the real microphone, with the pill's level meter following your voice. +- The start and stop sounds. +- Pasting with `wl-copy` + `wtype` into a browser text field, a terminal and a chat app. +- The Omarchy pill against this daemon, including two quick dictations showing two cards. + +Not confirmed on hardware yet (checklist step in brackets): + +- Picking a microphone by node name from the bar menu [5]. It works in Docker, below. +- The API key from `secret-tool` rather than `ANTHROPIC_API_KEY`. +- `install.sh`, the Hyprland bindings and autostart [13]. +- The pill reconnecting after a daemon restart [8], taps pasting nothing [11], the first word not being clipped [10], and `notify-send` notices without the pill [12]. Checked in Docker (Rust 1.99, Debian bookworm, x86_64): @@ -26,13 +38,6 @@ Checked in Docker (Rust 1.99, Debian bookworm, x86_64): - The daemon: `murmur status`, a second daemon refusing to start, `murmur start` with no microphone (a clean error reply, and a notice to watchers), a socket watcher seeing the state and notices, `murmur doctor`, `--help`, and shutdown on SIGTERM (the socket file is removed). - Picking a microphone by PipeWire node name (PipeWire 0.3.65 with WirePlumber and pipewire-alsa in the container, arm64): with two virtual sources, a tone and silence, the node name decides which one is recorded and `pw-link` shows the stream linked to it; no setting records the default source, and a name that doesn't exist falls back to the default. -Not tested yet (needs the Omarchy machine): - -- Recording from a real microphone through ALSA and pipewire-alsa, and the start/stop sounds. -- Pasting with `wl-copy` + `wtype`, `hyprctl` window classes, `notify-send`, and the API key from `secret-tool`. -- The Omarchy pill against this daemon (the wire format is unchanged from the Swift daemon, which it was tested with). -- `install.sh`, the Hyprland bindings and autostart. - **T2 MacBooks:** the built-in mic records about 30 dB too quietly and with a DC offset, so the level meter stays flat and every recording looks like speech (a tap gets transcribed). There's no hardware gain to raise; fix it system-wide with a PipeWire filter that removes the offset and boosts the mic. ## Setup diff --git a/rust/scripts/bundle-macos.sh b/rust/scripts/bundle-macos.sh index 0e81c93..7a8716d 100755 --- a/rust/scripts/bundle-macos.sh +++ b/rust/scripts/bundle-macos.sh @@ -6,16 +6,29 @@ # Signing uses the hardened runtime with the audio-input entitlement, which macOS requires # before it will show the microphone prompt to a hardened app. # +# Murmur isn't distributed prebuilt: you build and sign it on the Mac that runs it. A +# self-signed certificate is enough for that (see "Signing" in the README), and macOS keeps the +# app's permissions across rebuilds as long as the certificate stays the same. +# # Usage: rust/scripts/bundle-macos.sh -# Env: MURMUR_SIGN_IDENTITY codesign identity (default: the Apple Development certificate) +# Env: MURMUR_SIGN_IDENTITY codesign identity (default: "Murmur Self-Signed") set -euo pipefail cd "$(dirname "$0")/.." ROOT=$(pwd) -IDENTITY=${MURMUR_SIGN_IDENTITY:-"Apple Development: Ryan Anderson (N6KL49FYD2)"} +IDENTITY=${MURMUR_SIGN_IDENTITY:-"Murmur Self-Signed"} BUNDLE_ID=com.plyhdrn.murmur-rs APP="$ROOT/target/Murmur.app" +# Without -v, so a self-signed certificate counts even though no root trusts it. +if ! security find-identity -p codesigning | grep -qF "\"$IDENTITY"; then + echo "No code-signing certificate named \"$IDENTITY\" in your Keychain." >&2 + echo "Create one as described under \"Signing\" in the README, or set MURMUR_SIGN_IDENTITY" >&2 + echo "to one of these:" >&2 + security find-identity -p codesigning | sed -n 's/.*"\(.*\)".*/ \1/p' | sort -u >&2 + exit 1 +fi + VERSION=$(sed -n '/^\[workspace.package\]/,/^\[/s/^version *= *"\(.*\)"/\1/p' Cargo.toml) # Build number: commits on this branch, so every bundle from a new commit sorts after the last. BUILD=$(git rev-list --count HEAD 2>/dev/null || echo 1) @@ -67,6 +80,9 @@ cat > "$APP/Contents/Info.plist" < PLIST +# The hardened runtime only loads libraries signed by the app's own Team ID, and a self-signed +# certificate has none, so dyld would refuse the bundled sherpa-onnx dylibs ("different Team +# IDs"). disable-library-validation lifts that; the libraries are still signed and sealed in. ENTITLEMENTS="$ROOT/target/Murmur.entitlements" cat > "$ENTITLEMENTS" < @@ -75,6 +91,8 @@ cat > "$ENTITLEMENTS" < com.apple.security.device.audio-input + com.apple.security.cs.disable-library-validation + PLIST diff --git a/scripts/get.sh b/scripts/get.sh deleted file mode 100755 index 81e471a..0000000 --- a/scripts/get.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/zsh -# Installs or updates Murmur from the latest GitHub Release, for a Mac that doesn't build it -# (see scripts/release.sh). Needs no Xcode or admin rights; run it again to update: -# -# curl -fsSL https://raw.githubusercontent.com/andersryanc/murmur/main/scripts/get.sh | zsh -# -# Downloads with curl, which doesn't quarantine the app, so Gatekeeper doesn't block it for not -# being notarized. -set -euo pipefail - -url=https://github.com/andersryanc/murmur/releases/latest/download/Murmur.zip - -if [[ $(uname -m) != arm64 ]]; then - echo "Murmur needs an Apple Silicon Mac." >&2 - exit 1 -fi -if (( ${$(sw_vers -productVersion)%%.*} < 26 )); then - echo "Murmur needs macOS 26 or later." >&2 - exit 1 -fi - -# Update in place; otherwise /Applications if you can write to it, else ~/Applications. -if [[ -d ~/Applications/Murmur.app ]]; then - dest=~/Applications -elif [[ -d /Applications/Murmur.app || -w /Applications ]]; then - dest=/Applications -else - dest=~/Applications -fi - -tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT -echo "Downloading the latest release…" -curl -fL --progress-bar -o "$tmp/Murmur.zip" "$url" -ditto -x -k "$tmp/Murmur.zip" "$tmp" -codesign --verify --strict "$tmp/Murmur.app" -xattr -dr com.apple.quarantine "$tmp/Murmur.app" 2>/dev/null || true - -pkill -x Murmur 2>/dev/null && sleep 0.5 || true -mkdir -p "$dest" -rm -rf "$dest/Murmur.app" -ditto "$tmp/Murmur.app" "$dest/Murmur.app" -open "$dest/Murmur.app" - -info="$dest/Murmur.app/Contents/Info.plist" -version=$(defaults read "$info" CFBundleShortVersionString) -build=$(defaults read "$info" CFBundleVersion) -echo "Installed Murmur $version ($build) to $dest/Murmur.app" - -if ! security find-generic-password -s com.plyhdrn.murmur -a anthropic-api-key >/dev/null 2>&1; then - echo - echo "For Claude cleanup, store your Anthropic API key in the Keychain:" - echo " security add-generic-password -s com.plyhdrn.murmur -a anthropic-api-key -w" -fi diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100755 index 0779e25..0000000 --- a/scripts/release.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/zsh -# Builds Murmur (Release) from the current commit and publishes it as a GitHub Release, for -# installing on other Macs with scripts/get.sh. Needs a clean, pushed tree and `gh auth login`. -# -# Releases are public, so they must be signed with a Developer ID Application certificate, the -# one Apple issues for distributing outside the App Store. Apple Development and self-signed -# builds are for your own Macs only. Set DEVELOPMENT_TEAM in App/Signing.local.xcconfig to the -# team that owns the certificate; the script shows who signed the build and asks before -# publishing. -# -# The build number is the commit count, so each release on main is higher than the last. -# Other Macs keep the app's permissions across updates as long as every release is signed -# with the same certificate. -set -euo pipefail -cd "$(dirname "$0")/.." - -if ! security find-identity -v -p codesigning | grep -q '"Developer ID Application: '; then - echo "No Developer ID Application certificate in your Keychain. Public releases need one" >&2 - echo "(Apple Developer Program); Apple Development and self-signed builds aren't for publishing." >&2 - exit 1 -fi - -if [[ -n $(git status --porcelain) ]]; then - echo "Commit or stash your changes first, so the release matches a commit." >&2 - exit 1 -fi -commit=$(git rev-parse HEAD) -if ! git branch -r --contains "$commit" | grep -q .; then - echo "Push $(git rev-parse --abbrev-ref HEAD) first; the release is tagged at a pushed commit." >&2 - exit 1 -fi - -build=$(git rev-list --count HEAD) -version=$(sed -n 's/^ *MARKETING_VERSION: "\(.*\)"/\1/p' App/project.yml) -tag="build-$build" -if gh release view "$tag" >/dev/null 2>&1; then - echo "Release $tag already exists. Commit something new first." >&2 - exit 1 -fi - -APP=$(scripts/build.sh CURRENT_PROJECT_VERSION="$build" MURMUR_SIGN_IDENTITY="Developer ID Application") -codesign --verify --strict "$APP" - -# Anyone can read the signer of a download, so make sure it's the one you mean to publish under. -signature=$(codesign -dv --verbose=2 "$APP" 2>&1) -signer=$(sed -n 's/^Authority=//p' <<<"$signature" | head -1) -team=$(sed -n 's/^TeamIdentifier=//p' <<<"$signature") -if [[ $signer != "Developer ID Application: "* ]]; then - echo "The build is signed by \"$signer\", not a Developer ID Application certificate." >&2 - exit 1 -fi -read -r "reply?Publish $tag signed by \"$signer\" (team $team)? [y/N] " -[[ $reply == [yY]* ]] || { echo "Not published."; exit 1; } - -zip="build/Murmur.zip" -rm -f "$zip" -ditto -c -k --keepParent "$APP" "$zip" - -git fetch -q --tags -previous=$(gh release list --limit 1 --json tagName --jq '.[0].tagName // empty') -notes=$(git log --format='- %s' ${previous:+$previous..}"$commit") -gh release create "$tag" "$zip" --target "$commit" --title "Murmur $version ($build)" --notes "$notes" -echo "Published $tag. On another Mac, install or update with scripts/get.sh (see README)."