diff --git a/App/Murmur/AppModel.swift b/App/Murmur/AppModel.swift index b406167..5a5610f 100644 --- a/App/Murmur/AppModel.swift +++ b/App/Murmur/AppModel.swift @@ -51,6 +51,9 @@ struct Timing { } /// Runs the whole push-to-talk loop: key down → record → key up → transcribe → clean → paste. +/// +/// A new recording can start while earlier ones are still being processed. Finished recordings +/// wait in a queue and are processed one at a time, so they paste in the order they were spoken. @Observable final class AppModel { enum Phase: Equatable { @@ -61,7 +64,11 @@ final class AppModel { case failed(String) } + /// `.recording` while the key is held, even with dictations still queued; `.processing` + /// while any are queued otherwise. private(set) var phase: Phase = .loading("Starting…") + /// One entry per dictation waiting to paste, oldest first. The first is being processed. + private(set) var queue: [UUID] = [] /// Recent loudness values for the HUD's level meter, newest last. private(set) var levels: [Float] = Array(repeating: 0, count: AppModel.levelCount) private(set) var history: [Dictation] = [] @@ -136,6 +143,8 @@ final class AppModel { private var recordingStarted: Date? private var targetApp: NSRunningApplication? private var chordCancelled = false + /// The last dictation in the queue; the next one waits for it. + private var lastInQueue: Task? private let sounds = SoundEffects() /// Your words and replacements, edited in the Dictionary window. let dictionary = DictionaryStore() @@ -179,7 +188,7 @@ final class AppModel { case .loading(let message): message case .ready: "Hold \(hotkey.shortLabel) to dictate" case .recording: "Listening…" - case .processing: "Transcribing…" + case .processing: queue.count > 1 ? "Transcribing… (\(queue.count - 1) queued)" : "Transcribing…" case .failed(let message): message } } @@ -255,7 +264,7 @@ final class AppModel { // MARK: - Push to talk private func startRecording() { - guard phase == .ready else { return } + guard phase == .ready || phase == .processing else { return } guard microphoneGranted else { flash("Murmur needs microphone access") return @@ -286,8 +295,7 @@ final class AppModel { chordCancelled = true endChime() _ = try? capture.stop() - phase = .ready - hud?.hide() + settle() } private func finishRecording() { @@ -299,23 +307,35 @@ final class AppModel { // 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 { - phase = .ready - hud?.hide() + settle() return } + let id = UUID() + queue.append(id) phase = .processing let context = TargetContext(bundleID: targetApp?.bundleIdentifier) let appName = targetApp?.localizedName - Task { await process(samples, context: context, appName: appName) } + let previous = lastInQueue + lastInQueue = Task { + await previous?.value + await process(samples, context: context, appName: appName) + queue.removeAll { $0 == id } + if phase == .processing { settle() } + } } - private func process(_ samples: [Float], context: TargetContext, appName: String?) async { - defer { - if phase == .processing { phase = .ready } - hud?.hide(after: notice == nil ? 0 : 2.5) + /// After a recording ends or a dictation pastes: back to ready once nothing is left. + private func settle() { + guard queue.isEmpty else { + phase = .processing + return } + phase = .ready + hud?.hide(after: notice == nil ? 0 : 2.5) + } + private func process(_ samples: [Float], context: TargetContext, appName: String?) async { let clock = ContinuousClock() let raw: String let speechStart = clock.now @@ -386,7 +406,8 @@ final class AppModel { private func flash(_ message: String) { notice = message hud?.show() - hud?.hide(after: 2.5) + // While dictations are queued, the HUD hides once they're done instead. + if queue.isEmpty && phase != .recording { hud?.hide(after: 2.5) } } func copy(_ dictation: Dictation) { diff --git a/App/Murmur/HUD.swift b/App/Murmur/HUD.swift index 976ae3c..3dda01b 100644 --- a/App/Murmur/HUD.swift +++ b/App/Murmur/HUD.swift @@ -3,6 +3,9 @@ import SwiftUI /// The small pill near the bottom of the screen that shows Murmur listening and working. /// +/// Each dictation waiting to paste gets its own pill, oldest on the left: the first is being +/// processed, the rest are queued. A new recording's pill sits to their right. +/// /// It's a non-activating panel that ignores the mouse, so it never takes focus away from the /// app you're dictating into. If it did, the paste would land in the wrong place. final class HUDController { @@ -11,7 +14,7 @@ final class HUDController { init(model: AppModel) { panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 72), + contentRect: NSRect(x: 0, y: 0, width: 900, height: 72), styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: true @@ -62,6 +65,50 @@ final class HUDController { struct HUDView: View { let model: AppModel + var body: some View { + HStack(spacing: 10) { + ForEach(model.queue, id: \.self) { id in + Pill { + if id == model.queue.first { + ThinkingDots() + HUDText("Murmuring…", opacity: 0.9) + } else { + ThinkingDots(animating: false) + HUDText("Queued", opacity: 0.6) + } + } + .transition(.opacity.combined(with: .scale(scale: 0.9))) + } + if model.phase == .recording { + Pill { + Circle() + .fill(Murmur.recording) + .frame(width: 8, height: 8) + LevelBars(levels: model.levels) + } + .transition(.opacity) + } else if let notice = model.notice { + Pill { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + Text(notice) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + .lineLimit(1) + } + .transition(.opacity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .animation(.spring(duration: 0.25), value: model.phase) + .animation(.spring(duration: 0.25), value: model.queue) + } +} + +/// A black capsule around one item in the HUD. +private struct Pill: View { + @ViewBuilder let content: Content + var body: some View { HStack(spacing: 10) { content @@ -74,30 +121,22 @@ struct HUDView: View { .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5)) .shadow(color: .black.opacity(0.35), radius: 12, y: 4) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .animation(.spring(duration: 0.25), value: model.phase) + } +} + +private struct HUDText: View { + let text: String + let opacity: Double + + init(_ text: String, opacity: Double) { + self.text = text + self.opacity = opacity } - @ViewBuilder - private var content: some View { - if let notice = model.notice, model.phase != .recording { - Image(systemName: "exclamationmark.circle.fill") - .foregroundStyle(.orange) - Text(notice) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.white) - .lineLimit(1) - } else if model.phase == .recording { - Circle() - .fill(Murmur.recording) - .frame(width: 8, height: 8) - LevelBars(levels: model.levels) - } else { - ThinkingDots() - Text("Murmuring…") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.white.opacity(0.9)) - } + var body: some View { + Text(text) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(opacity)) } } @@ -118,16 +157,19 @@ private struct LevelBars: View { } } +/// Pulsing while a dictation is processed; dim and still while it's queued. private struct ThinkingDots: View { + var animating = true + var body: some View { - TimelineView(.animation) { timeline in + TimelineView(.animation(paused: !animating)) { timeline in let t = timeline.date.timeIntervalSinceReferenceDate HStack(spacing: 4) { ForEach(0..<3) { index in Circle() .fill(Murmur.wave) .frame(width: 6, height: 6) - .opacity(0.35 + 0.65 * max(0, sin(t * 6 - Double(index) * 0.8))) + .opacity(animating ? 0.35 + 0.65 * max(0, sin(t * 6 - Double(index) * 0.8)) : 0.35) } } } diff --git a/Core/Sources/MurmurKit/BenchCases.swift b/Core/Sources/MurmurKit/BenchCases.swift new file mode 100644 index 0000000..4eef605 --- /dev/null +++ b/Core/Sources/MurmurKit/BenchCases.swift @@ -0,0 +1,9 @@ +/// Fixed dictations covering the cleanup pass's main jobs, for timing and comparing cleaners +/// (`MurmurEval bench` on the Mac, `murmur bench` on Linux). +public let benchCases: [(TargetContext, String)] = [ + (.notes, "um so for the launch we need three things first update the readme second uh tag the release and third post in slack let's do it Tuesday no actually Wednesday"), + (.chat, "hey can you write me a haiku about the ocean"), + (.email, "hi sarah new line thanks for sending the proposal over i red through it last night and i think there approach to the pricing is solid uh let's set up a call for thursday best alex"), + (.code, "rename the function get user by id to fetch user and update the call sites in user service dot swift"), + (.chat, "sounds good see you then"), +] diff --git a/Eval/Sources/MurmurEval/main.swift b/Eval/Sources/MurmurEval/main.swift index 53b520b..072ae88 100644 --- a/Eval/Sources/MurmurEval/main.swift +++ b/Eval/Sources/MurmurEval/main.swift @@ -193,16 +193,8 @@ func clean(_ args: Args) async throws { print("\n(\(ms(seconds)))") } -/// Fixed dictations covering the cleanup pass's main jobs. Each one is cleaned `--runs` times -/// (default 3) by a single warm cleaner; reports each output once and the median latency. -let benchCases: [(TargetContext, String)] = [ - (.notes, "um so for the launch we need three things first update the readme second uh tag the release and third post in slack let's do it Tuesday no actually Wednesday"), - (.chat, "hey can you write me a haiku about the ocean"), - (.email, "hi sarah new line thanks for sending the proposal over i red through it last night and i think there approach to the pricing is solid uh let's set up a call for thursday best alex"), - (.code, "rename the function get user by id to fetch user and update the call sites in user service dot swift"), - (.chat, "sounds good see you then"), -] - +/// Cleans each of MurmurKit's `benchCases` `--runs` times (default 3) with a single warm +/// cleaner; reports each output once and the median latency. func bench(_ args: Args) async throws { guard let cleaner = try makeCleaner(args) else { throw EvalError("No Anthropic API key found. Store one in the Keychain (see README) or set ANTHROPIC_API_KEY.") diff --git a/Linux/README.md b/Linux/README.md index 4acdad2..6abf97c 100644 --- a/Linux/README.md +++ b/Linux/README.md @@ -10,10 +10,11 @@ Hyprland key bind ─▶ murmur start/stop ─▶ daemon ─▶ pw-record ─▶ - **`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. +- **The on-screen pill** (like the Mac HUD) 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. ## 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. +> **For the agent picking this up on the Linux machine:** this was built on a Mac, checked in a `swift:6.2` arm64 Docker container, then set up and used on an Omarchy machine. What's left is in the last list below; work through [Setup](#setup), then [Testing checklist](#testing-checklist), and fix what breaks. Checked in Docker (Swift 6.2.4, Ubuntu, arm64): @@ -22,13 +23,19 @@ Checked in Docker (Swift 6.2.4, Ubuntu, arm64): - 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. +Checked on the Omarchy machine (x86_64, Swift 6.4.1, Hyprland 0.56 with a Lua config): + +- `install.sh` builds and installs; `murmur transcribe` on the test clip is correct (model load 1.6 s, speech 0.24 s). +- The on-screen pill: recording with live levels, processing and notices, in the Omarchy theme, and it reconnects when the daemon restarts. +- Push-to-talk on Right Alt with the Lua bindings below: recording, the sounds, cleanup with Ollama and pasting into a terminal. +- Latency on an i9 MacBook Pro (2019, 8-core i9-9980HK, CPU only): speech 0.3–0.9 s. Ollama cleanup with `qwen3.5:4b` takes ~0.5 s when the previous dictation used the same style, but ~10 s when the style changes (each style's prompt is processed again), and ~25 s when the model has to load; `qwen3.5:9b` is about twice as slow. + 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. +- Pasting into a browser and a chat app, `notify-send`, reading the API key with `secret-tool`. +- Whether `pw-record`'s start-up clips the first word. + +**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 @@ -58,7 +65,7 @@ sudo pacman -S --needed pipewire wl-clipboard wtype libnotify libsecret curl bzi | `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) | +| `notify-send` | libnotify | showing errors when the on-screen pill isn't running | | `secret-tool` | libsecret | reading the API key from the keyring | Omarchy already ships most of these. @@ -71,7 +78,7 @@ From the repository root: 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. +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/`. On Omarchy it also installs the on-screen pill plugin to `~/.config/omarchy/plugins/plyhdrn.murmur/` and enables it the first time (`omarchy plugin disable plyhdrn.murmur` turns it off). Run it again after pulling changes; it restarts the daemon if one is running. ### 4. API key @@ -85,26 +92,32 @@ This needs a Secret Service provider such as gnome-keyring, which Omarchy uses. ### 5. Hyprland -Start the daemon with your session. In `~/.config/hypr/autostart.conf` (Omarchy) or `hyprland.conf`: +Start the daemon with your session, and bind push-to-talk. 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. When a modifier key is pressed on its own, Hyprland sees that modifier as held when it's released, hence `ALT` on the release binding. -```ini -exec-once = ~/.local/bin/murmur daemon +Current Omarchy configures Hyprland in Lua. In `~/.config/hypr/autostart.lua`: + +```lua +o.launch_on_start(os.getenv("HOME") .. "/.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: +and in `~/.config/hypr/bindings.lua`: -```ini -bind = , Alt_R, exec, ~/.local/bin/murmur start -bindr = ALT, Alt_R, exec, ~/.local/bin/murmur stop +```lua +local murmur = os.getenv("HOME") .. "/.local/bin/murmur" +o.bind("Alt_R", "Start dictation (Murmur)", murmur .. " start") +o.bind("ALT + Alt_R", "Stop dictation (Murmur)", murmur .. " stop", { release = true }) ``` -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: +With an older `.conf` config, use `exec-once = ~/.local/bin/murmur daemon` in `autostart.conf`, and in `bindings.conf`: ```ini -bind = SUPER CTRL, D, exec, ~/.local/bin/murmur toggle +bind = , Alt_R, exec, ~/.local/bin/murmur start +bindr = ALT, Alt_R, exec, ~/.local/bin/murmur stop ``` -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. +If the release doesn't fire, drop the `ALT` from the release binding, or use toggle mode, where one press starts and the next press finishes (`murmur toggle` on one key). + +Check what's free first with `hyprctl binds`. Reload with `hyprctl reload`, then check `hyprctl configerrors`. Start the daemon once by hand instead of logging out: `hyprctl dispatch 'hl.dsp.exec_cmd("~/.local/bin/murmur daemon")'` (Lua) or `hyprctl dispatch exec "~/.local/bin/murmur daemon"` (`.conf`). ### 6. Check @@ -177,6 +190,15 @@ The pieces, all in [`Sources/murmur`](Sources/murmur): | `Config.swift` | `config.json` and choosing the cleaner. | | `Support.swift` | XDG paths, the log, running command-line tools. | +The on-screen pill is [`omarchy-plugin/Murmur.qml`](omarchy-plugin/Murmur.qml), a `panel` plugin for the Omarchy shell (Quickshell). It connects to the daemon's socket and sends `watch`; the daemon keeps that connection open and writes one line per event: `state ready|recording|processing`, `queue 2` (dictations waiting to paste, including the one being processed), `level 0.42` (about every 50 ms while recording, measured from the WAV file `pw-record` is writing), and `notice `. While anything is watching, notices go to the pill instead of `notify-send`. To work on the plugin, link it instead of copying it, and the shell reloads it on save (`install.sh` leaves a link alone): + +```bash +rm -rf ~/.config/omarchy/plugins/plyhdrn.murmur +ln -s "$PWD/omarchy-plugin" ~/.config/omarchy/plugins/plyhdrn.murmur +``` + +`quickshell log -p /usr/share/omarchy/shell` shows QML errors. + `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: @@ -189,7 +211,6 @@ docker run --rm -v "$PWD":/src:ro swift:6.2 bash -c 'apt-get update -qq && apt-g ## 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/murmur/Audio.swift b/Linux/Sources/murmur/Audio.swift index d42d32e..0346b80 100644 --- a/Linux/Sources/murmur/Audio.swift +++ b/Linux/Sources/murmur/Audio.swift @@ -6,12 +6,17 @@ import MurmurKit final class Recorder { private var process: Process? private var file: URL? + private var meter: Task? + + /// Called about every 50 ms while recording with a 0...1 loudness value, for the pill's + /// level meter. + var onLevel: (@MainActor (Float) -> Void)? 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) + // A file per recording, so the next one can start while the last is still being read. + let file = Paths.runtime.appending(path: "recording-\(UUID().uuidString).wav") let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") @@ -23,23 +28,81 @@ final class Recorder { try process.run() self.process = process self.file = file + + if let onLevel { + meter = Task { + let reader = LevelReader(file: file) + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(50)) + if let level = reader.next() { onLevel(level) } + } + } + } } - /// Stops recording and returns the samples. Empty if nothing was recorded. - func stop() async -> [Float] { - guard let process, let file else { return [] } + /// Stops recording at once and returns the samples once pw-record has finished the file. + /// Empty if nothing was recorded. A new recording can start before the samples arrive. + func stop() -> Task<[Float], Never> { + meter?.cancel() + meter = nil + guard let process, let file else { return Task { [] } } 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 Task { + 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) ?? [] } - return (try? WAV.decode(data).samples) ?? [] + } +} + +/// Follows the WAV file pw-record is writing and measures the audio added since the last call. +final class LevelReader { + private let file: URL + private var handle: FileHandle? + private var pending: [UInt8] = [] + private var inData = false + + init(file: URL) { + self.file = file + } + + deinit { try? handle?.close() } + + /// Loudness of the new audio, or nil if none has arrived yet. + func next() -> Float? { + if handle == nil { handle = try? FileHandle(forReadingFrom: file) } + guard let handle, let data = try? handle.readToEnd(), !data.isEmpty else { return nil } + pending += data + + // Skip the header: the samples start after the "data" chunk's tag and size. + if !inData { + guard pending.count >= 20, + let tag = (12...(pending.count - 8)).first(where: { pending[$0..<$0 + 4] == [0x64, 0x61, 0x74, 0x61] }) + else { return nil } + pending.removeFirst(tag + 8) + inData = true + } + + let usable = pending.count / 4 * 4 + guard usable > 0 else { return nil } + let samples = pending[0.. Float { + let rms = sqrt(samples.reduce(0) { $0 + $1 * $1 } / Float(max(samples.count, 1))) + let db = 20 * log10(max(rms, 1e-6)) + return min(max((db + 50) / 50, 0), 1) } } diff --git a/Linux/Sources/murmur/ControlSocket.swift b/Linux/Sources/murmur/ControlSocket.swift index 97d6a87..b69c64b 100644 --- a/Linux/Sources/murmur/ControlSocket.swift +++ b/Linux/Sources/murmur/ControlSocket.swift @@ -22,9 +22,13 @@ enum ControlSocket { return String(decoding: reply, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) } - /// Starts listening and calls `handler` on the main actor for each command. Keep the returned - /// source alive for as long as the daemon runs. - static func listen(_ handler: @escaping @MainActor (String) -> String) throws -> any DispatchSourceRead { + /// Starts listening and calls `handler` on the main actor for each command. A client that + /// sends "watch" stays connected and is handed to `watch` instead. Keep the returned source + /// alive for as long as the daemon runs. + static func listen( + _ handler: @escaping @MainActor (String) -> String, + watch: @escaping @MainActor (Int32) -> Void + ) throws -> any DispatchSourceRead { let path = Paths.socket.path Paths.ensure(Paths.runtime) if (try? connected()).map({ close($0) }) != nil { @@ -44,11 +48,15 @@ enum ControlSocket { 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 } + guard count > 0 else { close(client); return } let command = String(decoding: buffer[0..? + private var phase: Phase { + recording ? .recording : queued > 0 ? .processing : .ready + } private var config = Config.load() private let transcriber: Transcriber private let recorder = Recorder() + private let watchers = Watchers() 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 + recorder.onLevel = { [weak self] level in + guard let self, recording else { return } + watchers.send("level \((level * 100).rounded() / 100)") + } + } + + /// Sends events to a client that asked to watch, starting with the current state. + func watch(_ fd: Int32) { + watchers.add(fd, greeting: ["queue \(queued)", "state \(phase.rawValue)"]) + } + + /// The queue length goes first, so a watcher already knows it when the state changes. + private func publish() { + watchers.send("queue \(queued)") + watchers.send("state \(phase.rawValue)") + } + + /// Shows `message` in the pill when the Omarchy plugin is watching, or as a notification. + private func notice(_ message: String) { + // Sending first drops watchers that have gone away (e.g. the shell restarted). + watchers.send("notice \(message)") + if watchers.isEmpty { Desktop.notify(message) } } func handle(_ command: String) -> String { switch command { case "start": start() case "stop": stop() - case "toggle": phase == .recording ? stop() : start() + case "toggle": recording ? stop() : start() case "cancel": cancel() case "status": phase.rawValue default: "unknown command: \(command)" @@ -37,16 +74,16 @@ final class Daemon { } private func start() -> String { - guard phase == .ready else { return "busy: \(phase.rawValue)" } + guard !recording else { return "already recording" } 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") + notice("Couldn't start the microphone") return "error: couldn't start pw-record" } - phase = .recording + recording = true recordingStarted = .now windowClass = Task { await Desktop.activeWindowClass() } Sounds.play(.start, theme: config.soundTheme) @@ -54,32 +91,33 @@ final class Daemon { } private func stop() -> String { - guard phase == .recording else { return "not recording" } - phase = .processing + guard recording else { return "not recording" } Sounds.play(.stop, theme: config.soundTheme) + let samples = recorder.stop() let held = ContinuousClock.now - recordingStarted - Task { - await finish(held: held) - phase = .ready + let target = windowClass + let config = config + let previous = lastInQueue + queued += 1 + recording = false + lastInQueue = Task { + let samples = await samples.value + await previous?.value + await finish(samples, held: held, target: await target?.value, config: config) + queued -= 1 } - return "processing" + return queued > 1 ? "queued" : "processing" } private func cancel() -> String { - guard phase == .recording else { return "not recording" } - phase = .processing + guard recording else { return "not recording" } Sounds.play(.stop, theme: config.soundTheme) - Task { - _ = await recorder.stop() - phase = .ready - } + _ = recorder.stop() + recording = false return "cancelled" } - private func finish(held: Duration) async { - let samples = await recorder.stop() - let target = await windowClass?.value - + private func finish(_ samples: [Float], held: Duration, target: String?, config: Config) async { // 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 { @@ -116,12 +154,12 @@ final class Daemon { } } catch { Log.info("Cleanup failed, pasting raw text: \(error)") - Desktop.notify("Cleanup failed, pasted raw text") + notice("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") } + if !pasted { notice("Copied. Paste with Ctrl+V") } Log.info("Dictated into \(target ?? "nothing") [\(context.rawValue)], \(timing): \(text)") } diff --git a/Linux/Sources/murmur/Support.swift b/Linux/Sources/murmur/Support.swift index cecad80..f3661f2 100644 --- a/Linux/Sources/murmur/Support.swift +++ b/Linux/Sources/murmur/Support.swift @@ -62,39 +62,90 @@ enum Shell { } /// 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. + /// captured when asked for; never capture from a program that leaves a child running, like + /// wl-copy, since its output never ends. /// - /// 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. + /// Uses posix_spawn rather than Foundation's `Process`, which on Linux learns that a program + /// exited when a socket it passes to the program closes. wl-copy forks a child that keeps + /// serving the clipboard and inherits that socket, so `Process` never saw wl-copy finish. + /// Here the program gets only stdin, stdout and stderr, and is waited for with waitpid. @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 + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + continuation.resume(returning: runBlocking(program, arguments, input: input, captureOutput: captureOutput)) + } + } + } + + private static func runBlocking(_ program: String, _ arguments: [String], input: String?, + captureOutput: Bool) -> Result { + var stdinPipe: [Int32] = [-1, -1] + var stdoutPipe: [Int32] = [-1, -1] + if input != nil, pipe(&stdinPipe) != 0 { return Result(status: -1, output: "") } + if captureOutput, pipe(&stdoutPipe) != 0 { + for fd in stdinPipe { close(fd) } + return Result(status: -1, output: "") + } + // Close-on-exec, so a program started elsewhere at the same moment (pw-record through + // `Process`) can't inherit them and hold wl-copy's stdin open. dup2 clears the flag on + // the copies this program gets. (Swift's Glibc doesn't import pipe2.) + for fd in stdinPipe + stdoutPipe where fd >= 0 { _ = fcntl(fd, F_SETFD, FD_CLOEXEC) } + + var actions = posix_spawn_file_actions_t() + posix_spawn_file_actions_init(&actions) + defer { posix_spawn_file_actions_destroy(&actions) } + if input != nil { + posix_spawn_file_actions_adddup2(&actions, stdinPipe[0], 0) + } else { + posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0) + } + if captureOutput { + posix_spawn_file_actions_adddup2(&actions, stdoutPipe[1], 1) + } else { + posix_spawn_file_actions_addopen(&actions, 1, "/dev/null", O_WRONLY, 0) + } + posix_spawn_file_actions_addopen(&actions, 2, "/dev/null", O_WRONLY, 0) + posix_spawn_file_actions_addclosefrom_np(&actions, 3) + + let argv = ([program] + arguments).map { strdup($0) } + [nil] + defer { argv.forEach { free($0) } } + var pid: pid_t = 0 + let spawned = posix_spawnp(&pid, program, &actions, nil, argv, environ) + for fd in [stdinPipe[0], stdoutPipe[1]] where fd >= 0 { close(fd) } + guard spawned == 0 else { + for fd in [stdinPipe[1], stdoutPipe[0]] where fd >= 0 { close(fd) } + return Result(status: -1, output: "") + } + + if let input { + let bytes = Array(input.utf8) + var written = 0 + while written < bytes.count { + let count = bytes[written...].withUnsafeBytes { write(stdinPipe[1], $0.baseAddress, $0.count) } + if count <= 0 { break } + written += count } - if let input { - stdin.fileHandleForWriting.write(Data(input.utf8)) - try? stdin.fileHandleForWriting.close() + close(stdinPipe[1]) + } + + var output = [UInt8]() + if captureOutput { + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = read(stdoutPipe[0], &buffer, buffer.count) + if count <= 0 { break } + output += buffer[0..> 8) & 0xff : -1 + return Result(status: exitStatus, output: String(decoding: output, as: UTF8.self)) } /// Starts `program` and doesn't wait for it. diff --git a/Linux/Sources/murmur/main.swift b/Linux/Sources/murmur/main.swift index 1f58d9e..e450b6c 100644 --- a/Linux/Sources/murmur/main.swift +++ b/Linux/Sources/murmur/main.swift @@ -14,6 +14,8 @@ murmur: push-to-talk dictation for Linux (Hyprland) 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 bench [--model ] [--runs 3] + Time the cleanup on fixed test dictations and print the results. murmur doctor Check that the tools, model and API key Murmur needs are present. Settings: \(Config.url.path) @@ -47,7 +49,7 @@ func runDaemon() async -> Never { let daemon = Daemon(transcriber: transcriber) let socket: any DispatchSourceRead do { - socket = try ControlSocket.listen { daemon.handle($0) } + socket = try ControlSocket.listen({ daemon.handle($0) }, watch: { daemon.watch($0) }) } catch { fail("\(error)") } @@ -60,7 +62,9 @@ func runDaemon() async -> Never { source.setEventHandler { socket.cancel() unlink(Paths.socket.path) - exit(0) + // _exit skips the libraries' exit handlers, which can crash while onnxruntime's + // worker threads are still running. + _exit(0) } source.resume() signals.append(source) @@ -112,6 +116,68 @@ func transcribe(_ arguments: [String]) async { } } +/// Cleans each of MurmurKit's `benchCases` `--runs` times. Consecutive cases use different +/// styles, so with Ollama the first run of each pays for reading that style's prompt and the +/// rest show the cached speed; both are reported. +func bench(_ arguments: [String]) async { + func option(_ name: String) -> String? { + arguments.firstIndex(of: name).flatMap { arguments.indices.contains($0 + 1) ? arguments[$0 + 1] : nil } + } + let config = Config.load() + let runs = max(Int(option("--runs") ?? "3") ?? 3, 1) + let dictionary = (try? PersonalDictionary.load()) ?? PersonalDictionary() + let cleaner: any TextCleaner + do { + if let model = option("--model") { + cleaner = OllamaCleaner(model: model, vocabulary: dictionary.vocabulary) + } else if let configured = try config.makeCleaner(vocabulary: dictionary.vocabulary) { + cleaner = configured + } else { + fail("Cleanup is off in \(Config.url.path); pass --model to bench an Ollama model") + } + } catch { + fail("\(error)") + } + + let clock = ContinuousClock() + func seconds(_ duration: Duration) -> Double { Double(duration / .milliseconds(1)) / 1000 } + print("Cleaner: \(cleaner.id)") + var start = clock.now + do { + try await cleaner.prepare() + _ = try await cleaner.clean("warm up", context: .plain) + } catch { + fail("Cleanup failed: \(error)") + } + print(String(format: "Load and warm-up: %.1f s\n", seconds(clock.now - start))) + + var firsts: [Double] = [], repeats: [Double] = [] + for (context, text) in benchCases { + var times: [Double] = [] + var output = "" + for _ in 0.. Double { values.sorted()[values.count / 2] } + var summary = String(format: "Median: %.2f s after a style change", median(firsts)) + if !repeats.isEmpty { summary += String(format: ", %.2f s repeated", median(repeats)) } + print(summary) +} + func doctor() async { let config = Config.load() var problems = 0 @@ -166,6 +232,8 @@ case "start", "stop", "toggle", "cancel", "status": } case "transcribe": await transcribe(Array(arguments.dropFirst())) +case "bench": + await bench(Array(arguments.dropFirst())) case "doctor": await doctor() default: diff --git a/Linux/omarchy-plugin/Murmur.qml b/Linux/omarchy-plugin/Murmur.qml new file mode 100644 index 0000000..39d79de --- /dev/null +++ b/Linux/omarchy-plugin/Murmur.qml @@ -0,0 +1,310 @@ +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import Quickshell.Wayland +import qs.Commons +import qs.Commons as Commons +import qs.Ui + +// The pill near the bottom of the screen that shows Murmur listening and working, like the Mac +// app's HUD. It follows the daemon over its control socket: after sending "watch", the daemon +// writes one line per event (`state recording`, `queue 2`, `level 0.42`, `notice …`). +// +// Each dictation waiting to paste gets its own card, oldest on the left: the first is being +// processed, the rest are queued. A new recording's card sits to their right. +// +// The surface never takes keyboard focus and ignores the mouse. If it took focus, the paste +// would land in the wrong place. +Item { + id: root + + readonly property int levelCount: 14 + // Matches the daemon's Paths.socket. UID usually isn't exported, so the fallback asks `id -u`. + property string runtimeDir: Quickshell.env("XDG_RUNTIME_DIR") || "" + readonly property string socketPath: runtimeDir === "" ? "" : runtimeDir + "/murmur/murmur.sock" + + Process { + running: root.runtimeDir === "" + command: ["id", "-u"] + stdout: StdioCollector { + onStreamFinished: root.runtimeDir = "/tmp/murmur-" + text.trim() + } + } + + property string phase: "ready" + property string notice: "" + // One entry per dictation waiting to paste. When the oldest pastes, the first entry goes and + // the others slide left. + ListModel { id: queue } + property var levels: [] + property bool shown: false + // Picked when the pill appears, so it doesn't jump monitors mid-dictation. + property var targetScreen: null + + function resetLevels() { + var zeros = [] + for (var i = 0; i < root.levelCount; i++) zeros.push(0) + root.levels = zeros + } + + function show() { + hideTimer.stop() + if (!root.shown) { + var name = Hyprland.focusedMonitor ? String(Hyprland.focusedMonitor.name || "") : "" + root.targetScreen = Quickshell.screens.find(s => s.name === name) || null + } + root.shown = true + } + + // A notice stays up for a moment after the dictation ends; otherwise the pill goes at once. + function settle() { + if (root.notice !== "") hideTimer.restart() + else root.shown = false + } + + function setQueue(count) { + while (queue.count > count) queue.remove(0) + while (queue.count < count) queue.append({ waiting: true }) + } + + function handle(line) { + var space = line.indexOf(" ") + var kind = space < 0 ? line : line.slice(0, space) + var rest = space < 0 ? "" : line.slice(space + 1) + if (kind === "queue") { + root.setQueue(Math.max(0, Number(rest) || 0)) + } else if (kind === "state") { + var wasRecording = root.phase === "recording" + root.phase = rest + if (rest === "recording") { + if (!wasRecording) { + root.notice = "" + root.resetLevels() + } + root.show() + } else if (rest === "processing") { + // An older daemon doesn't send `queue`. + if (queue.count === 0) queue.append({ waiting: true }) + root.show() + } else { + root.setQueue(0) + root.settle() + } + } else if (kind === "level" && root.phase === "recording") { + var level = Math.max(0, Math.min(1, Number(rest) || 0)) + root.levels = root.levels.slice(1).concat([level]) + } else if (kind === "notice") { + root.notice = rest + root.show() + if (root.phase !== "recording" && root.phase !== "processing") hideTimer.restart() + } + } + + Component.onCompleted: resetLevels() + + Timer { + id: hideTimer + interval: 2500 + onTriggered: root.shown = false + } + + Socket { + id: socket + path: root.socketPath + connected: root.socketPath !== "" + + onConnectionStateChanged: { + if (connected) { + write("watch\n") + flush() + } else { + root.phase = "ready" + root.notice = "" + root.setQueue(0) + root.shown = false + } + } + + parser: SplitParser { + onRead: line => root.handle(line) + } + } + + // The daemon may start after the shell, or restart; keep trying until it's there. + Timer { + interval: 2000 + repeat: true + running: !socket.connected && root.socketPath !== "" + onTriggered: socket.connected = true + } + + // A rounded card in the theme's popup style, sized to what's inside it. + component Card: BorderSurface { + id: card + + readonly property int pad: Style.space(16) + default property alias body: inner.data + + width: card.borderLeft + card.pad + inner.width + card.pad + card.borderRight + height: card.borderTop + Style.space(40) + card.borderBottom + color: Util.alpha(Commons.Color.background, 0.97) + borderSpec: Border.surfaceSpec("popups", "border", Commons.Color.popups.border, Math.max(1, Style.space(2))) + radius: Style.cornerRadius + + Behavior on width { + enabled: root.shown + NumberAnimation { duration: 200; easing.type: Easing.OutCubic } + } + + Item { + id: inner + anchors.centerIn: parent + width: childrenRect.width + height: Style.space(22) + } + } + + component Label: Text { + textFormat: Text.PlainText + font.family: Style.font.family + font.pixelSize: Style.font.subtitle + font.weight: Font.Medium + color: Commons.Color.popups.text + anchors.verticalCenter: parent.verticalCenter + } + + PanelWindow { + id: panel + screen: root.targetScreen + visible: root.shown || cards.opacity > 0 + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + WlrLayershell.namespace: "murmur" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore + mask: Region {} + + Row { + id: cards + spacing: Style.space(10) + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Style.space(28) + opacity: root.shown ? 1 : 0 + + Behavior on opacity { NumberAnimation { duration: 150 } } + add: Transition { + NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 150 } + } + move: Transition { + NumberAnimation { properties: "x"; duration: 250; easing.type: Easing.OutCubic } + } + + // Waiting to paste: pulsing dots on the one being processed, "Queued" on the rest. + Repeater { + model: queue + + Card { + id: waiting + required property int index + readonly property bool active: index === 0 + + Row { + spacing: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + + Row { + id: dots + property real t: 0 + spacing: Style.space(4) + anchors.verticalCenter: parent.verticalCenter + NumberAnimation on t { + running: waiting.active && root.shown + from: 0 + to: 2 * Math.PI / 6 + duration: 1047 + loops: Animation.Infinite + } + Repeater { + model: 3 + Rectangle { + required property int index + width: Style.space(6) + height: width + radius: width / 2 + color: Commons.Color.popups.text + opacity: waiting.active ? 0.35 + 0.65 * Math.max(0, Math.sin(dots.t * 6 - index * 0.8)) : 0.35 + } + } + } + + Label { + text: waiting.active ? "Murmuring…" : "Queued" + color: Util.alpha(Commons.Color.popups.text, waiting.active ? 0.9 : 0.6) + } + } + } + } + + // Recording: a dot and the live mic level, newest bar on the right. + Card { + visible: root.phase === "recording" + + Row { + spacing: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + width: Style.space(8) + height: width + radius: width / 2 + color: Commons.Color.urgent + anchors.verticalCenter: parent.verticalCenter + } + + Row { + spacing: Style.space(3) + anchors.verticalCenter: parent.verticalCenter + Repeater { + model: root.levelCount + Rectangle { + required property int index + width: Style.space(3) + height: Style.space(4) + (root.levels[index] || 0) * Style.space(18) + radius: width / 2 + color: Commons.Color.popups.text + anchors.verticalCenter: parent.verticalCenter + Behavior on height { NumberAnimation { duration: 80 } } + } + } + } + } + } + + // Notice: something went wrong (the text was still pasted or copied). + Card { + visible: root.notice !== "" + + Row { + spacing: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + + Label { + text: "" + font.pixelSize: Style.font.heading + font.weight: Font.Normal + color: Commons.Color.accent + } + + Label { + text: root.notice + elide: Text.ElideRight + width: Math.min(implicitWidth, Style.space(320)) + } + } + } + } + } +} diff --git a/Linux/omarchy-plugin/manifest.json b/Linux/omarchy-plugin/manifest.json new file mode 100644 index 0000000..d2b7aa7 --- /dev/null +++ b/Linux/omarchy-plugin/manifest.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "id": "plyhdrn.murmur", + "name": "Murmur", + "version": "1.0.0", + "author": "Ryan Anderson", + "description": "Shows Murmur listening and working: a pill at the bottom of the screen with the mic level while you dictate.", + "kinds": ["panel"], + "keepLoaded": true, + "entryPoints": { + "panel": "Murmur.qml" + } +} diff --git a/Linux/scripts/install.sh b/Linux/scripts/install.sh index fbd1b8c..cd41d46 100755 --- a/Linux/scripts/install.sh +++ b/Linux/scripts/install.sh @@ -15,10 +15,28 @@ install -m 755 "$bin" ~/.local/bin/murmur cp -P vendor/sherpa-onnx/lib/*.so* ~/.local/lib/murmur/ echo "Installed ~/.local/bin/murmur" +# The on-screen pill is an Omarchy shell plugin. A symlink there (for working on the plugin) +# is left alone. It's enabled on the first install only, so disabling it sticks. +plugin=~/.config/omarchy/plugins/plyhdrn.murmur +if command -v omarchy-shell >/dev/null && [[ ! -L $plugin ]]; then + first=$([[ -d $plugin ]] || echo yes) + rm -rf "$plugin" && mkdir -p "$plugin" + cp omarchy-plugin/* "$plugin/" + omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true + if [[ -n $first ]]; then + sleep 1 + omarchy plugin enable plyhdrn.murmur >/dev/null 2>&1 \ + || echo "Enable the on-screen pill with: omarchy plugin enable plyhdrn.murmur" + fi + echo "Installed the Omarchy plugin to $plugin" +fi + 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 + # A Lua Hyprland config takes a Lua expression here; older configs take the plain form. + hyprctl dispatch 'hl.dsp.exec_cmd("~/.local/bin/murmur daemon")' 2>/dev/null | grep -qx ok \ + || hyprctl dispatch exec "~/.local/bin/murmur daemon" >/dev/null echo "Restarted the daemon" else echo "Stopped the daemon; start it again with: murmur daemon" diff --git a/ROADMAP.md b/ROADMAP.md index 96cead7..d512992 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,8 +22,7 @@ Ideas for what's next, roughly in priority order. Nothing here is committed to; ## 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. +- [ ] **Test on the Omarchy machine.** Builds and transcribes on x86_64; work through the testing checklist in [`Linux/README.md`](Linux/README.md) (mic, paste, key bindings, keyring, latency). - [ ] **Packaging.** An AUR `PKGBUILD`, so installing doesn't need the repo. ## Known issues