From a07082f17b27335de476f220ba669873cc8c8548 Mon Sep 17 00:00:00 2001 From: Alan Silva Date: Mon, 7 Sep 2026 00:08:05 +0100 Subject: [PATCH] History retention, simpler history actions, microphone picker, install offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings: "Keep history" (a day … forever, default a month); older recordings and their audio are pruned at startup, after each recording and when the setting changes. - History rows keep play, copy and delete only. - Advanced: the microphone is picked from PipeWire's sources instead of a free-text field, with a note about Bluetooth headsets switching profile. - A stock machine without voxtype gets a banner with an Install button that runs omarchy-voxtype-install in a floating terminal; the daemon refuses to record until the tools are there and says why. --- README.md | 8 ++--- daemon/sttd.py | 87 +++++++++++++++++++++++++++++++++++++++++----- plugin/Panel.qml | 86 +++++++++++++++++++++++++++++++++++---------- plugin/Service.qml | 4 +++ 4 files changed, 154 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index a7b05e3..58042b5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Dictation for Omarchy: press a key, talk, press it again, and the words are past - **Ask your agent**: a second key per language hands the text to Omarchy's default coding agent (`omarchy default agent`) instead of pasting it. - **Live waveform and live text** in the bar: yellow while the microphone connects, green while it listens; the bar goes back to the icon the moment the text is pasted. - **Stop is instant**: the recording is transcribed at every pause while you talk, so only the last phrase is left when you stop. -- **History** of every recording (text + audio) with play, copy, paste, paste-and-send and delete, searchable, in the bar popup. +- **History** of every recording (text + audio) with play, copy and delete, searchable, in the bar popup; kept for a month by default (Settings). - **Languages** picked from Whisper's list; the model a language needs is downloaded by itself the first time (about 150 MB for the default model; the bar shows the progress). English-only models are swapped for the multilingual one. - **Engine**: Omarchy's own dictation engine, [voxtype](https://github.com/peteonrails/voxtype) (local Whisper), by default, so there is nothing new to install. whisper.cpp (`whisper-cli`) and any custom command are also supported (Settings → Advanced). - **Safe key bindings**: a key that anything else already uses is refused, never taken over. @@ -23,12 +23,12 @@ Dictation for Omarchy: press a key, talk, press it again, and the words are past omarchy plugin add https://github.com/alanfortlink/speech-to-text.git --enable ``` -That is all: the microphone icon appears in the bar, the daemon starts with the shell and applies the key bindings itself (nothing in `~/.config/hypr` is touched). +That is all. The microphone icon appears in the bar, the daemon starts with the shell and applies the key bindings itself (nothing in `~/.config/hypr` is touched). + +If Omarchy's dictation engine (voxtype) is not installed yet, the popup says so and offers an **Install** button, which runs `omarchy-voxtype-install` in a floating terminal (about 150 MB, asks for your password). Everything else the plugin needs is part of a stock Omarchy: PipeWire, `wl-clipboard`, `curl`, Python 3; `wtype` comes with voxtype. Optional: `~/.config/omarchy/plugins/alanfortlink.speech-to-text/install.sh` puts the `stt` command on your PATH. From a checkout anywhere else, `./install.sh` also links the checkout into the plugins directory (handy for development). -Requires `voxtype` (Omarchy ships it: `omarchy-voxtype-install`), `pipewire`, `wtype`, `wl-clipboard`, `curl`. All present on a stock Omarchy. - ## Use - Press the language's key, talk, press it again. `Esc` discards while recording. The first press for a new language downloads its model; the bar shows "Getting ready…" until it is there. diff --git a/daemon/sttd.py b/daemon/sttd.py index a2deb22..13cafc2 100755 --- a/daemon/sttd.py +++ b/daemon/sttd.py @@ -72,7 +72,8 @@ DEFAULT_CONFIG = { "liveText": True, # transcribe while recording and show it in the bar "liveIntervalMs": 1500, "liveWindowSecs": 30, - "keepAudio": True, # keep the wav of every take next to its text + "keepAudio": True, # keep the wav of every recording next to its text + "historyDays": 30, # delete recordings older than this (0 = keep forever) "outputMode": "paste", # paste | type | clipboard "pasteKeys": "auto", # auto | ctrl+v | ctrl+shift+v | shift+insert "restoreClipboard": True, @@ -760,6 +761,39 @@ def deliver(cfg, text, enter): wl_copy(saved) +def audio_sources(): + """Microphones PipeWire offers: [{name, label}] (a virtual one like Microphone Effects included).""" + try: + r = subprocess.run(["pw-dump"], capture_output=True, text=True, timeout=5) + nodes = json.loads(r.stdout) if r.returncode == 0 else [] + except (OSError, subprocess.TimeoutExpired, ValueError): + return [] + out = [] + for n in nodes: + props = (n.get("info") or {}).get("props") or {} + if props.get("media.class") != "Audio/Source": + continue + name = str(props.get("node.name") or "") + if not name or name.startswith("alsa_output") or "monitor" in name: + continue + out.append({"name": name, "label": str(props.get("node.description") or props.get("node.nick") or name)}) + return out + + +def missing_tools(cfg): + """What a stock machine may still lack; the panel offers to install it.""" + out = [] + if not which("pw-record"): + out.append("pipewire") + if not which("wtype"): + out.append("wtype") + if cfg.get("engine", "voxtype") == "voxtype" and not which("voxtype"): + out.append("voxtype") + if not which("wl-copy"): + out.append("wl-clipboard") + return out + + def deliver_agent(cfg, text): """Blocking. Hands the text to the default coding agent (a new terminal) instead of pasting it.""" tmpl = cfg.get("agentCommand") or "omarchy-agent-prompt {text}" @@ -835,6 +869,22 @@ class History: self.db.execute("UPDATE takes SET text=? WHERE id=?", (text, id)) self.db.commit() + def prune(self, days): + """Delete recordings older than `days` (0: keep everything). Returns how many went.""" + if not days or days <= 0: + return 0 + cutoff = time.time() - days * 86400 + rows = self.db.execute("SELECT id, audio FROM takes WHERE created_at < ?", (cutoff,)).fetchall() + for id, audio in rows: + if audio: + try: + os.remove(audio) + except OSError: + pass + self.db.execute("DELETE FROM takes WHERE created_at < ?", (cutoff,)) + self.db.commit() + return len(rows) + # --------------------------------------------------------------------------- # daemon @@ -867,6 +917,8 @@ class Daemon: self.wanted_model = "" # model a refused recording was waiting for self.engines = available_engines() # cached: state is pushed 20×/s while recording self.agent = self.agent_name() + self.sources = audio_sources() + self.missing = missing_tools(self.cfg) self.error_clear = None # timer handle: errors fade by themselves self.loop = None self.stopping = False @@ -898,11 +950,20 @@ class Daemon: "download": self.download, "agentName": self.agent, "agentMode": self.agent_mode if self.state != "idle" else False, + "missing": self.missing, } if full: msg["languageNames"] = LANGUAGES + msg["sources"] = self.sources return msg + def refresh_environment(self): + """Things that change rarely and cost a subprocess: only on demand.""" + self.engines = available_engines() + self.agent = self.agent_name() + self.sources = audio_sources() + self.missing = missing_tools(self.cfg) + def broadcast(self, msg=None): line = (json.dumps(msg or self.state_msg()) + "\n").encode() for w in list(self.clients): @@ -1025,16 +1086,13 @@ class Daemon: if self.state != "idle": self.broadcast() return - if not which("pw-record"): - self.fail("pw-record (PipeWire) is not installed") + self.missing = missing_tools(self.cfg) + if self.missing: + self.fail("Dictation needs " + ", ".join(self.missing) + " — open the microphone icon to install it") self.broadcast() return self.agent_mode = bool(agent) self.lang = self.find_lang(code) - if self.cfg.get("engine", "voxtype") == "voxtype" and not which("voxtype"): - self.fail("voxtype is not installed — run omarchy-voxtype-install") - self.broadcast() - return model = model_for(self.cfg, self.lang) if model and not os.path.exists(model_path(model)): # Not an error: the bar shows "Getting ready for · N%" while it downloads. @@ -1227,6 +1285,8 @@ class Daemon: await self.loop.run_in_executor(None, deliver_agent, self.cfg, text) else: await self.loop.run_in_executor(None, deliver, self.cfg, text, self.enter_pending) + if self.history.prune(self.cfg.get("historyDays", 30)): + log("history pruned") self.broadcast({"type": "history-changed"}) except asyncio.CancelledError: self._discard(path) @@ -1345,8 +1405,9 @@ class Daemon: except OSError as e: self.fail(f"cannot save config: {e}") self.binds.apply(self.cfg) - self.engines = available_engines() - self.agent = self.agent_name() + self.refresh_environment() + if "historyDays" in (patch or {}) and self.history.prune(self.cfg.get("historyDays", 30)): + self.broadcast({"type": "history-changed"}) self.ensure_models() # ---- socket ---- @@ -1403,7 +1464,13 @@ class Daemon: async def dispatch(self, msg, writer): cmd = msg.get("cmd") if cmd == "get": + self.refresh_environment() writer.write((json.dumps(self.state_msg(full=True)) + "\n").encode()) + elif cmd == "install": + # Omarchy's own installer (asks for confirmation and the password in a floating terminal). + self.spawn(self.loop.run_in_executor(None, lambda: subprocess.Popen( + ["omarchy-launch-floating-terminal-with-presentation", "omarchy-voxtype-install"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True))) elif cmd == "toggle": self.spawn(self.toggle(self._lang(msg), bool(msg.get("enter")), bool(msg.get("agent")))) elif cmd == "start": @@ -1523,6 +1590,8 @@ class Daemon: server = await asyncio.start_unix_server(self.handle, path=SOCK, limit=1 << 20) self.binds.sweep() self.binds.apply(self.cfg) + if self.history.prune(self.cfg.get("historyDays", 30)): + log("history pruned") self.ensure_models() self.loop.create_task(self.hypr_events()) for s in (signal.SIGTERM, signal.SIGINT): diff --git a/plugin/Panel.qml b/plugin/Panel.qml index 14c870c..cdc2bbd 100644 --- a/plugin/Panel.qml +++ b/plugin/Panel.qml @@ -151,7 +151,7 @@ Panel { active: root.busy || root.showError useActiveColor: true activeColor: root.busy ? root.takeColor : Color.urgent - tooltipText: (root.connected ? "Speech to text" : "Speech to text · starting") + tooltipText: (root.connected ? (root.svc.missing.length ? "Speech to text · click to install " + root.svc.missing.join(", ") : "Speech to text") : "Speech to text · starting") + (root.svc && root.svc.error !== "" ? " · " + root.svc.error : "") + (root.keyFor(root.defaultLang) !== "" ? " · " + root.keyFor(root.defaultLang) + ": dictate " + root.langName(root.defaultLang) : "") + " · right-click: dictate" @@ -272,6 +272,14 @@ Panel { return { value: e, label: ({ voxtype: "Omarchy built-in (voxtype)", "whisper-cpp": "whisper.cpp", command: "Custom command" })[e] || e } }) readonly property var langOpts: langs.map(function(l) { return { value: l.code, label: l.label || l.code } }) + readonly property string sourcesJson: JSON.stringify(svc ? svc.sources : []) + readonly property var micOpts: { + var list = JSON.parse(sourcesJson), cur = String(cfg.device || "default") + var opts = [ { value: "default", label: "System default" } ] + for (var i = 0; i < list.length; i++) opts.push({ value: String(list[i].name), label: String(list[i].label) }) + if (cur !== "default" && !list.some(function(m) { return String(m.name) === cur })) opts.push({ value: cur, label: cur + " (not connected)" }) + return opts + } readonly property string conflictsJson: JSON.stringify(svc ? svc.conflicts : []) readonly property var conflicts: JSON.parse(conflictsJson) function conflictText() { @@ -474,6 +482,39 @@ Panel { } } + // A stock machine may not have the dictation engine yet: say so, and install it from here. + Rectangle { + width: parent.width + visible: root.connected && root.svc.missing.length > 0 + height: missingCol.implicitHeight + Style.space(16) + radius: Style.cornerRadius + color: Style.normalFillFor(root.fg, Color.accent) + Column { + id: missingCol + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Style.space(8) + spacing: Style.space(6) + Text { + width: parent.width + text: "Dictation needs " + (root.svc ? root.svc.missing.join(", ") : "") + " to be installed. Omarchy's installer takes care of it (about 150 MB, asks for your password)." + color: root.fg + font.family: root.fontFamily + font.pixelSize: Style.font.body + wrapMode: Text.Wrap + } + Button { + text: "Install" + iconText: "󰇚" + foreground: root.fg + fontFamily: root.fontFamily + bordered: true + onClicked: { if (root.svc) root.svc.install(); root.close() } + } + } + } + Text { width: parent.width visible: root.busy @@ -810,20 +851,6 @@ Panel { fontFamily: root.fontFamily onClicked: { if (root.svc) root.svc.copyTake(row.modelData.id); row.copied = true; copiedTimer.restart() } } - PanelActionButton { - iconText: "󰆒" - tooltipText: "Paste into the focused window" - foreground: root.fg - fontFamily: root.fontFamily - onClicked: { var id = row.modelData.id; root.closeThen(function() { if (root.svc) root.svc.pasteTake(id, false) }) } - } - PanelActionButton { - iconText: "󰌑" - tooltipText: "Paste and press Return" - foreground: root.fg - fontFamily: root.fontFamily - onClicked: { var id = row.modelData.id; root.closeThen(function() { if (root.svc) root.svc.pasteTake(id, true) }) } - } PanelActionButton { iconText: "󰆴" tooltipText: "Delete" @@ -1077,6 +1104,23 @@ Panel { checked: root.cfg.keepAudio !== false onToggled: if (root.svc) root.svc.setSetting("keepAudio", root.cfg.keepAudio === false) } + Row { + width: parent.width + spacing: Style.space(8) + RowLabel { text: "Keep history" } + Dropdown { + width: parent.width - root.labelW - parent.spacing - root.trailInset + showLabel: false + enabled: root.connected + value: String(root.cfg.historyDays === undefined ? 30 : root.cfg.historyDays) + options: [ { value: "1", label: "For a day" }, { value: "7", label: "For a week" }, { value: "30", label: "For a month" }, + { value: "90", label: "For 3 months" }, { value: "365", label: "For a year" }, { value: "0", label: "Forever" } ] + foreground: root.fg + fontFamily: root.fontFamily + onChanged: function(v) { if (root.svc) root.svc.setSetting("historyDays", parseInt(v)); value = Qt.binding(function() { return String(root.cfg.historyDays === undefined ? 30 : root.cfg.historyDays) }) } + } + } + Note { text: "Older recordings and their audio are deleted automatically." } } // ---------- Advanced (collapsed) ---------- @@ -1209,12 +1253,18 @@ Panel { width: parent.width spacing: Style.space(8) RowLabel { text: "Microphone" } - ConfigField { + Dropdown { width: parent.width - root.labelW - parent.spacing - root.trailInset - placeholderText: "default" - key: "device" + showLabel: false + enabled: root.connected + value: String(root.cfg.device || "default") + options: root.micOpts + foreground: root.fg + fontFamily: root.fontFamily + onChanged: function(v) { if (root.svc) root.svc.setSetting("device", v); value = Qt.binding(function() { return String(root.cfg.device || "default") }) } } } + Note { text: "A Bluetooth headset switches to its low-quality headset profile while its microphone is open, which pauses or degrades whatever it is playing. Pick another microphone here to avoid that." } Row { width: parent.width spacing: Style.space(8) diff --git a/plugin/Service.qml b/plugin/Service.qml index 953b2e4..bd626c9 100644 --- a/plugin/Service.qml +++ b/plugin/Service.qml @@ -38,6 +38,8 @@ Item { readonly property var download: state.download || null // {model, pct} while a model is fetched readonly property string agentName: state.agentName || "" // omarchy's default coding agent readonly property bool agentMode: !!state.agentMode // this recording goes to the agent + readonly property var missing: state.missing || [] // tools a stock machine still lacks (voxtype, wtype…) + property var sources: [] // microphones (sent with the greeting and on `get`) // ---- history (fetched on demand; the daemon says when it changed) ---- property var historyItems: [] @@ -72,6 +74,7 @@ Item { function rebind() { return send({ cmd: "rebind" }) } function setLang(code) { return send({ cmd: "setLang", lang: code }) } // While the panel captures a key, our own binds must not fire on it. + function install() { return send({ cmd: "install" }) } // omarchy-voxtype-install in a floating terminal function suspendBinds() { return send({ cmd: "suspendBinds" }) } function resumeBinds() { return send({ cmd: "resumeBinds" }) } function loadHistory(query, limit) { @@ -140,6 +143,7 @@ Item { if (!msg) return if (msg.type === "state") { if (msg.languageNames) root.languageNames = msg.languageNames + if (msg.sources) root.sources = msg.sources root.state = msg if (root.daemonError !== "") root.daemonError = "" } else if (msg.type === "history") {