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:
@@ -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.
|
||||
|
||||
+78
-9
@@ -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 <language> · 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):
|
||||
|
||||
+68
-18
@@ -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)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user