History retention, simpler history actions, microphone picker, install offer

- 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.
This commit is contained in:
2026-09-07 00:08:05 +01:00
parent f2ed66ed17
commit a07082f17b
4 changed files with 154 additions and 31 deletions
+4 -4
View File
@@ -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. - **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. - **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. - **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. - **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). - **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. - **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 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). 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 ## 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. - 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.
+78 -9
View File
@@ -72,7 +72,8 @@ DEFAULT_CONFIG = {
"liveText": True, # transcribe while recording and show it in the bar "liveText": True, # transcribe while recording and show it in the bar
"liveIntervalMs": 1500, "liveIntervalMs": 1500,
"liveWindowSecs": 30, "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 "outputMode": "paste", # paste | type | clipboard
"pasteKeys": "auto", # auto | ctrl+v | ctrl+shift+v | shift+insert "pasteKeys": "auto", # auto | ctrl+v | ctrl+shift+v | shift+insert
"restoreClipboard": True, "restoreClipboard": True,
@@ -760,6 +761,39 @@ def deliver(cfg, text, enter):
wl_copy(saved) 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): def deliver_agent(cfg, text):
"""Blocking. Hands the text to the default coding agent (a new terminal) instead of pasting it.""" """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}" 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.execute("UPDATE takes SET text=? WHERE id=?", (text, id))
self.db.commit() 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 # daemon
@@ -867,6 +917,8 @@ class Daemon:
self.wanted_model = "" # model a refused recording was waiting for self.wanted_model = "" # model a refused recording was waiting for
self.engines = available_engines() # cached: state is pushed 20×/s while recording self.engines = available_engines() # cached: state is pushed 20×/s while recording
self.agent = self.agent_name() 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.error_clear = None # timer handle: errors fade by themselves
self.loop = None self.loop = None
self.stopping = False self.stopping = False
@@ -898,11 +950,20 @@ class Daemon:
"download": self.download, "download": self.download,
"agentName": self.agent, "agentName": self.agent,
"agentMode": self.agent_mode if self.state != "idle" else False, "agentMode": self.agent_mode if self.state != "idle" else False,
"missing": self.missing,
} }
if full: if full:
msg["languageNames"] = LANGUAGES msg["languageNames"] = LANGUAGES
msg["sources"] = self.sources
return msg 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): def broadcast(self, msg=None):
line = (json.dumps(msg or self.state_msg()) + "\n").encode() line = (json.dumps(msg or self.state_msg()) + "\n").encode()
for w in list(self.clients): for w in list(self.clients):
@@ -1025,16 +1086,13 @@ class Daemon:
if self.state != "idle": if self.state != "idle":
self.broadcast() self.broadcast()
return return
if not which("pw-record"): self.missing = missing_tools(self.cfg)
self.fail("pw-record (PipeWire) is not installed") if self.missing:
self.fail("Dictation needs " + ", ".join(self.missing) + " — open the microphone icon to install it")
self.broadcast() self.broadcast()
return return
self.agent_mode = bool(agent) self.agent_mode = bool(agent)
self.lang = self.find_lang(code) 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) model = model_for(self.cfg, self.lang)
if model and not os.path.exists(model_path(model)): if model and not os.path.exists(model_path(model)):
# Not an error: the bar shows "Getting ready for <language> · N%" while it downloads. # Not an error: the bar shows "Getting ready for <language> · N%" while it downloads.
@@ -1227,6 +1285,8 @@ class Daemon:
await self.loop.run_in_executor(None, deliver_agent, self.cfg, text) await self.loop.run_in_executor(None, deliver_agent, self.cfg, text)
else: else:
await self.loop.run_in_executor(None, deliver, self.cfg, text, self.enter_pending) 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"}) self.broadcast({"type": "history-changed"})
except asyncio.CancelledError: except asyncio.CancelledError:
self._discard(path) self._discard(path)
@@ -1345,8 +1405,9 @@ class Daemon:
except OSError as e: except OSError as e:
self.fail(f"cannot save config: {e}") self.fail(f"cannot save config: {e}")
self.binds.apply(self.cfg) self.binds.apply(self.cfg)
self.engines = available_engines() self.refresh_environment()
self.agent = self.agent_name() if "historyDays" in (patch or {}) and self.history.prune(self.cfg.get("historyDays", 30)):
self.broadcast({"type": "history-changed"})
self.ensure_models() self.ensure_models()
# ---- socket ---- # ---- socket ----
@@ -1403,7 +1464,13 @@ class Daemon:
async def dispatch(self, msg, writer): async def dispatch(self, msg, writer):
cmd = msg.get("cmd") cmd = msg.get("cmd")
if cmd == "get": if cmd == "get":
self.refresh_environment()
writer.write((json.dumps(self.state_msg(full=True)) + "\n").encode()) 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": elif cmd == "toggle":
self.spawn(self.toggle(self._lang(msg), bool(msg.get("enter")), bool(msg.get("agent")))) self.spawn(self.toggle(self._lang(msg), bool(msg.get("enter")), bool(msg.get("agent"))))
elif cmd == "start": elif cmd == "start":
@@ -1523,6 +1590,8 @@ class Daemon:
server = await asyncio.start_unix_server(self.handle, path=SOCK, limit=1 << 20) server = await asyncio.start_unix_server(self.handle, path=SOCK, limit=1 << 20)
self.binds.sweep() self.binds.sweep()
self.binds.apply(self.cfg) self.binds.apply(self.cfg)
if self.history.prune(self.cfg.get("historyDays", 30)):
log("history pruned")
self.ensure_models() self.ensure_models()
self.loop.create_task(self.hypr_events()) self.loop.create_task(self.hypr_events())
for s in (signal.SIGTERM, signal.SIGINT): for s in (signal.SIGTERM, signal.SIGINT):
+68 -18
View File
@@ -151,7 +151,7 @@ Panel {
active: root.busy || root.showError active: root.busy || root.showError
useActiveColor: true useActiveColor: true
activeColor: root.busy ? root.takeColor : Color.urgent 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.svc && root.svc.error !== "" ? " · " + root.svc.error : "")
+ (root.keyFor(root.defaultLang) !== "" ? " · " + root.keyFor(root.defaultLang) + ": dictate " + root.langName(root.defaultLang) : "") + (root.keyFor(root.defaultLang) !== "" ? " · " + root.keyFor(root.defaultLang) + ": dictate " + root.langName(root.defaultLang) : "")
+ " · right-click: dictate" + " · 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 } 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 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 string conflictsJson: JSON.stringify(svc ? svc.conflicts : [])
readonly property var conflicts: JSON.parse(conflictsJson) readonly property var conflicts: JSON.parse(conflictsJson)
function conflictText() { 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 { Text {
width: parent.width width: parent.width
visible: root.busy visible: root.busy
@@ -810,20 +851,6 @@ Panel {
fontFamily: root.fontFamily fontFamily: root.fontFamily
onClicked: { if (root.svc) root.svc.copyTake(row.modelData.id); row.copied = true; copiedTimer.restart() } 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 { PanelActionButton {
iconText: "󰆴" iconText: "󰆴"
tooltipText: "Delete" tooltipText: "Delete"
@@ -1077,6 +1104,23 @@ Panel {
checked: root.cfg.keepAudio !== false checked: root.cfg.keepAudio !== false
onToggled: if (root.svc) root.svc.setSetting("keepAudio", 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) ---------- // ---------- Advanced (collapsed) ----------
@@ -1209,12 +1253,18 @@ Panel {
width: parent.width width: parent.width
spacing: Style.space(8) spacing: Style.space(8)
RowLabel { text: "Microphone" } RowLabel { text: "Microphone" }
ConfigField { Dropdown {
width: parent.width - root.labelW - parent.spacing - root.trailInset width: parent.width - root.labelW - parent.spacing - root.trailInset
placeholderText: "default" showLabel: false
key: "device" 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 { Row {
width: parent.width width: parent.width
spacing: Style.space(8) spacing: Style.space(8)
+4
View File
@@ -38,6 +38,8 @@ Item {
readonly property var download: state.download || null // {model, pct} while a model is fetched 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 string agentName: state.agentName || "" // omarchy's default coding agent
readonly property bool agentMode: !!state.agentMode // this recording goes to the 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) ---- // ---- history (fetched on demand; the daemon says when it changed) ----
property var historyItems: [] property var historyItems: []
@@ -72,6 +74,7 @@ Item {
function rebind() { return send({ cmd: "rebind" }) } function rebind() { return send({ cmd: "rebind" }) }
function setLang(code) { return send({ cmd: "setLang", lang: code }) } function setLang(code) { return send({ cmd: "setLang", lang: code }) }
// While the panel captures a key, our own binds must not fire on it. // 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 suspendBinds() { return send({ cmd: "suspendBinds" }) }
function resumeBinds() { return send({ cmd: "resumeBinds" }) } function resumeBinds() { return send({ cmd: "resumeBinds" }) }
function loadHistory(query, limit) { function loadHistory(query, limit) {
@@ -140,6 +143,7 @@ Item {
if (!msg) return if (!msg) return
if (msg.type === "state") { if (msg.type === "state") {
if (msg.languageNames) root.languageNames = msg.languageNames if (msg.languageNames) root.languageNames = msg.languageNames
if (msg.sources) root.sources = msg.sources
root.state = msg root.state = msg
if (root.daemonError !== "") root.daemonError = "" if (root.daemonError !== "") root.daemonError = ""
} else if (msg.type === "history") { } else if (msg.type === "history") {