diff --git a/noctwhspr/README.md b/noctwhspr/README.md new file mode 100644 index 0000000..fef78f3 --- /dev/null +++ b/noctwhspr/README.md @@ -0,0 +1,72 @@ +# noctwhspr + +Noctalia companion for [hyprwhspr](https://github.com/goodroot/hyprwhspr) — +native speech-to-text for Linux. hyprwhspr is fast, accurate, private, +system-wide dictation: press a hotkey, talk, and your words land in whatever +you were typing — transcribed by local in-memory models (Whisper, Parakeet) +that never leave your machine, or by top cloud APIs if you point it there. +Its visuals even theme themselves to match your Noctalia theme. + +This plugin puts hyprwhspr on your bar: the widget shows the dictation state +at a glance, left-click starts or stops a recording, and right-click restarts +the hyprwhspr service if it ever gets stuck. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `goodroot/noctwhspr` | +| Entries | Bar widget: `status` | + +## Requirements + +Install [`hyprwhspr`](https://github.com/goodroot/hyprwhspr) and set it up so +its `hyprwhspr.service` systemd user service exists. The widget drives +hyprwhspr's own tray script (`hyprwhspr-tray.sh`), which it looks up under the +install root — `/usr/lib/hyprwhspr` for the Arch/AUR package. Nothing else is +needed; the widget has no network access of its own. + +## Usage + +Enable the plugin in Settings → Plugins, then add the `status` widget to your +bar in Settings → Bar. + +The glyph tracks the dictation state reported by hyprwhspr: + +| Glyph | State | +| --- | --- | +| Filled record dot (error color) | Recording — dictation is capturing audio | +| Microphone (primary color) | Ready — service is up, waiting for a hotkey or click | +| Zzz | Model unloaded — first recording will load it | +| Microphone off | Service stopped | +| Warning triangle | Error — the tooltip explains what is wrong | + +Interactions: + +- **Left-click** — toggle recording (same as the hyprwhspr hotkey). If the + service is stopped, this starts it first. +- **Right-click** — restart the `hyprwhspr.service` systemd user service. +- **Hover** — the tooltip shows the detailed status line, including error + reasons when something is wrong. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `root` | `string` | `/usr/lib/hyprwhspr` | Directory hyprwhspr is installed under. Leave at the default unless hyprwhspr lives at a non-standard prefix; `HYPRWHSPR_ROOT` in Noctalia's environment is also honored. | + +## Notes + +- The widget is a thin shim over hyprwhspr's tray script — the same script + that backs its Waybar module — so state detection stays in one place. It + polls `/config/hyprland/hyprwhspr-tray.sh status` every 2 seconds and + renders the returned JSON. +- Spawned processes: the tray script on every poll and click; the script in + turn queries `systemctl --user` and hyprwhspr's runtime state files. + Right-click runs `systemctl --user restart hyprwhspr.service` through the + script. No network calls and no filesystem writes are made by the plugin + itself. +- If the widget shows "hyprwhspr not found", hyprwhspr is not installed at + the configured root — point the `root` setting at your install prefix. +- Works on any compositor Noctalia runs on; the `hyprland` in the script path + is historical, hyprwhspr itself supports Hyprland, Niri and friends. diff --git a/noctwhspr/plugin.toml b/noctwhspr/plugin.toml new file mode 100644 index 0000000..ebfe1f6 --- /dev/null +++ b/noctwhspr/plugin.toml @@ -0,0 +1,21 @@ +id = "goodroot/noctwhspr" +name = "noctwhspr" +version = "1.0.0" +plugin_api = 3 +author = "goodroot" +license = "MIT" +icon = "microphone" +description = "Noctalia companion for hyprwhspr: shows dictation state, click to record, right-click to restart." +dependencies = ["hyprwhspr"] +tags = ["audio", "bar", "recording", "utility"] + +[[widget]] +id = "status" +entry = "widget.luau" + + [[widget.setting]] + key = "root" + type = "string" + label_key = "settings.root.label" + description_key = "settings.root.description" + default = "/usr/lib/hyprwhspr" diff --git a/noctwhspr/thumbnail.webp b/noctwhspr/thumbnail.webp new file mode 100644 index 0000000..1f109e0 Binary files /dev/null and b/noctwhspr/thumbnail.webp differ diff --git a/noctwhspr/translations/en.json b/noctwhspr/translations/en.json new file mode 100644 index 0000000..a141050 --- /dev/null +++ b/noctwhspr/translations/en.json @@ -0,0 +1,5 @@ +{ + "title": "noctwhspr", + "settings.root.label": "hyprwhspr install root", + "settings.root.description": "Directory hyprwhspr is installed under. Leave at the default unless hyprwhspr lives at a non-standard prefix." +} diff --git a/noctwhspr/widget.luau b/noctwhspr/widget.luau new file mode 100644 index 0000000..f0c326e --- /dev/null +++ b/noctwhspr/widget.luau @@ -0,0 +1,125 @@ +--!nonstrict +-- hyprwhspr bar widget for Noctalia. +-- +-- Thin shim over the existing tray script (single source of truth for state +-- detection, shared with the Waybar module): polls `hyprwhspr-tray.sh status` +-- and renders its JSON (class + tooltip) as a glyph. Clicks invoke the same +-- script actions the Waybar module binds. + +local TRAY_REL = "/config/hyprland/hyprwhspr-tray.sh" + +-- state class (from the tray script's JSON) -> glyph + palette color +local STATES = { + recording = { glyph = "player-record-filled", color = "error" }, + ready = { glyph = "microphone", color = "primary" }, + unloaded = { glyph = "zzz", color = "outline" }, + stopped = { glyph = "microphone-off", color = "outline" }, + error = { glyph = "alert-triangle", color = "error" }, +} + +local function applyState(state, tooltip) + barWidget.setGlyph(state.glyph) + barWidget.setGlyphColor(state.color) + if tooltip ~= nil and tooltip ~= "" then + barWidget.setTooltip(tooltip) + end +end + +local function applyError(tooltip) + applyState(STATES.error, tooltip) +end + +-- Resolve the tray script lazily and cache the hit. Noctalia's process is +-- spawned by the compositor, so HYPRWHSPR_ROOT is usually absent from its +-- environment — the widget "root" setting is the override for non-standard +-- install prefixes. +local trayCached = nil +local function trayPath() + if trayCached ~= nil and noctalia.fileExists(trayCached) then + return trayCached + end + trayCached = nil + local candidates = {} + local cfg = noctalia.getConfig("root") + if type(cfg) == "string" and cfg ~= "" then + table.insert(candidates, cfg) + end + local env = noctalia.getenv("HYPRWHSPR_ROOT") + if env ~= nil and env ~= "" then + table.insert(candidates, env) + end + table.insert(candidates, "/usr/lib/hyprwhspr") + for _, root in ipairs(candidates) do + local path = root .. TRAY_REL + if noctalia.fileExists(path) then + trayCached = path + return path + end + end + return nil +end + +local function trayCommand(action) + local path = trayPath() + if path == nil then + return nil + end + -- Single-quote the path: install roots may contain spaces. + return "'" .. path .. "' " .. action +end + +local function render(json) + if type(json) ~= "string" or json == "" then + applyError("hyprwhspr: tray script returned no status") + return + end + local class = json:match('"class":"([^"]*)"') or "error" + local tooltip = json:match('"tooltip":"([^"]*)"') or "" + + -- Strip the Waybar cache-buster line, unescape \n + tooltip = tooltip:gsub("\\n_ts:%d+", "") + tooltip = tooltip:gsub("\\n", "\n") + + applyState(STATES[class] or STATES.error, tooltip) +end + +local function refresh() + local cmd = trayCommand("status") + if cmd == nil then + applyError("hyprwhspr not found\nInstall hyprwhspr or set this widget's root setting") + return + end + noctalia.runAsync(cmd, function(result) + if type(result) ~= "table" then + applyError("hyprwhspr: status query failed") + return + end + render(result.stdout) + end) +end + +function update() + -- The tray script re-derives state via many subprocesses per call; 2s is + -- responsive enough (the recording overlay gives immediate feedback). + noctalia.setUpdateInterval(2000) + refresh() +end + +function onClick() + local cmd = trayCommand("record") + if cmd ~= nil then + noctalia.runAsync(cmd, function() refresh() end) + end +end + +function onRightClick() + local cmd = trayCommand("restart") + if cmd ~= nil then + noctalia.runAsync(cmd, function() refresh() end) + end +end + +-- Honest placeholder until the first poll lands. +barWidget.setGlyph("microphone") +barWidget.setGlyphColor("outline") +barWidget.setTooltip("hyprwhspr: waiting for status…")