From bb3b0bcb94617f9db9da791514bbc394fd15febe Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 1 Aug 2026 18:58:57 +0700 Subject: [PATCH] Give every window a verb, and answer what the terminal asks The socket carried a bare word and said nothing back, so a script could press the buttons but never learn what was said. A request is JSON now, it can ask to be answered when the run ends rather than when it starts, and the reply carries the transcript, the agent's answer, or why nothing happened. The verbs that do not need the microphone run in the caller's own process, which is what makes transcribing a file or reading a setting work over ssh. --- README.md | 11 +- README.tr.md | 11 +- assistant.py | 10 + cli.py | 1046 ++++++++++++++++++++++++++++++++++++++++++++++++++ dikte.py | 260 +++++++++---- ipc.py | 81 ++++ worker.py | 16 +- 7 files changed, 1364 insertions(+), 71 deletions(-) create mode 100644 cli.py create mode 100644 ipc.py diff --git a/README.md b/README.md index de518bd..8169076 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,13 @@ A dictation and a command to the agent do wait on each other for the microphone, which is one device, but for nothing else: each has its own indicator, and the second one stacks above the first while both are up. +Everything the settings window holds has a verb of its own too, so a script or +an agent can work the whole thing: `dikte record --seconds 8` says back what was +said, `dikte transcribe talk.mp4 --srt` writes subtitles, and the settings, the +history and the meetings are there beside them. `dikte --help` lists them, they +all take `--json`, and only the ones needing the microphone need the application +running. + ## What it does - **Silence never reaches the API.** Handed near-silence, a transcription model @@ -124,7 +131,9 @@ needs your user in the `input` group: `sudo usermod -aG input $USER`. ## Layout ``` -dikte.py entry point, tray icon, state machine, IPC +dikte.py entry point, tray icon, state machine +cli.py the command line: every verb, and what it answers with +ipc.py one request and one reply over the local socket audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting meeting.py channel split, speaker labelling, cleanup, minutes assistant.py running a dictation through Claude Code, Codex or OpenRouter diff --git a/README.tr.md b/README.tr.md index 3e8a877..de751b0 100644 --- a/README.tr.md +++ b/README.tr.md @@ -59,6 +59,13 @@ verilen komut yalnızca mikrofon için birbirini bekler, o da tek aygıt olduğu için; başka hiçbir şeyde beklemezler. Her birinin kendi göstergesi var, ikisi birden ekrandayken ikincisi birincinin üstüne yerleşir. +Ayarlar penceresindeki her şeyin bir de komutu var; bir betik ya da bir ajan +yazılımın tamamını çalıştırabilsin diye: `dikte record --seconds 8` söyleneni +geri verir, `dikte transcribe konusma.mp4 --srt` altyazıyı yazar, ayarlar, +geçmiş ve toplantılar da yanlarında durur. Hepsini `dikte --help` sayar, hepsi +`--json` kabul eder, yalnızca mikrofona ihtiyacı olanlar uygulamanın açık +olmasını ister. + ## Neler yapıyor - **Sessizlik API'ye gitmez.** Sessize yakın bir ses verildiğinde model boş dize @@ -122,7 +129,9 @@ grubunda olmasını gerektirir: `sudo usermod -aG input $USER`. ## Dosyalar ``` -dikte.py giriş noktası, tepsi simgesi, durum makinesi, IPC +dikte.py giriş noktası, tepsi simgesi, durum makinesi +cli.py komut satırı: bütün fiiller ve verdikleri cevap +ipc.py yerel sokette bir istek, bir cevap audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg meeting.py kanal ayırma, konuşmacı etiketi, temizleme, tutanak assistant.py dikteyi Claude Code, Codex ya da OpenRouter'dan geçirme diff --git a/assistant.py b/assistant.py index fe808f2..20339bb 100644 --- a/assistant.py +++ b/assistant.py @@ -154,6 +154,16 @@ def clear_session(): pass +def stored_provider(): + """Whose conversation is on disk, whatever the setting says now.""" + try: + with open(SESSION_FILE, encoding="utf-8") as fh: + row = json.load(fh) + except (OSError, json.JSONDecodeError, ValueError): + return "" + return str(row.get("provider", "")) if isinstance(row, dict) else "" + + def session_age(): """Seconds since the stored conversation was last used, or None.""" try: diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..85a63dc --- /dev/null +++ b/cli.py @@ -0,0 +1,1046 @@ +"""Dikte from a terminal, with everything the windows can do. + +The verbs that need the microphone are handed to the running instance over its +socket: the microphone is one device, that process owns it, and it is also the +one drawing the indicator. Everything else runs right here and needs nothing +else running, which is what makes transcribing a file or reading a setting work +over ssh and inside a script. + +Output is meant for a program as much as for a person. --json turns any answer +into one object on stdout, progress lines go to stderr so they never end up in +a pipe, and the exit code says which of the three things happened: 0 done, +1 failed, 2 the command line was wrong, 3 nothing is running. +""" + +import argparse +import json +import os +import shutil +import signal +import sys +import time + +from PyQt6.QtCore import QCoreApplication, QTimer + +import api +import assistant +import audio +import config as cfg +import filetranscribe +import hotkey +import ipc +import meeting +import paste + +NOT_RUNNING = 3 + +# Verbs that start the application when none is running, which is what the KDE +# shortcut has always relied on: press the key on a fresh login and Dikte comes +# up recording. +GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"} + +# Asking a process that is not there to stop, cancel or quit is not a failure; +# it is already in the state that was asked for. +IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-cancel", + "ask-reset", "meeting-cancel"} + +# Which desktop entry, name and setting belong to each of the three shortcuts. +SHORTCUTS = { + "toggle": (hotkey.DESKTOP_ID, "Dikte: start/stop recording", "shortcut"), + "ask": (hotkey.ASK_DESKTOP_ID, "Dikte: ask Claude Code", "assistant_shortcut"), + "meeting": (hotkey.MEETING_DESKTOP_ID, "Dikte: start/end a meeting recording", + "meeting_shortcut"), +} + +_app = None + + +# --- talking to the terminal ---------------------------------------------- + +def out(opts, payload, text=""): + """The answer: one JSON object, or the plain thing a person wanted.""" + if opts.json: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + elif text: + print(text) + return 0 + + +def note(opts, message): + """A progress line. Never stdout: stdout is the answer.""" + if message and not opts.quiet: + print(message, file=sys.stderr, flush=True) + + +def fail(opts, message, code=1, **extra): + if opts.json: + payload = {"ok": False, "error": str(message)} + payload.update(extra) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + print(f"dikte: {message}", file=sys.stderr) + return code + + +def _pick(flag, fallback): + """A --thing/--no-thing pair that was not given falls back to the setting.""" + return fallback if flag is None else flag + + +def _headless(connect, on_interrupt=None): + """Run one of the signal-driven workers to the end without a window. + + `connect` is handed a callback, wires it to the worker's signals and starts + the worker; whatever it passes back comes out of here. The event loop is + only here to carry those signals across from the worker thread. + """ + app = QCoreApplication.instance() + box = {} + + def finish(result): + box["result"] = result + app.quit() + + def interrupt(*_): + box.setdefault("result", {"error": "interrupted"}) + if on_interrupt: + on_interrupt() + app.quit() + + signal.signal(signal.SIGINT, interrupt) + # Qt's loop does not run Python between events, so without something ticking + # a Ctrl+C would sit unnoticed until the job finished on its own. + ticker = QTimer() + ticker.timeout.connect(lambda: None) + ticker.start(200) + QTimer.singleShot(0, lambda: connect(finish)) + app.exec() + ticker.stop() + signal.signal(signal.SIGINT, signal.SIG_DFL) + return box.get("result") or {"error": "interrupted"} + + +# --- the running instance -------------------------------------------------- + +def _ask_instance(opts, cmd, wait=False, **args): + """Send a request, or explain why there is nobody to send it to.""" + reply = ipc.send(cmd, wait=wait, timeout=getattr(opts, "timeout", 0), **args) + if reply is not None: + return reply + verb = getattr(opts, "verb", cmd) + if verb in GUI_VERBS and not wait: + launch_gui(verb) # replaces this process; never comes back + if verb in IDEMPOTENT_VERBS: + return {"ok": True, "running": False} + return None + + +def launch_gui(verb=""): + """No instance running, so become the application itself.""" + args = [sys.executable, ipc.script_path()] + if verb: + args.append(verb) + args.append("--gui") + os.execv(sys.executable, args) + + +def _not_running(opts): + return fail(opts, "Dikte is not running. Start it with: dikte", NOT_RUNNING, + running=False) + + +# --- dictation ------------------------------------------------------------- + +def cmd_record(opts): + """Record, transcribe, clean up, and print what was said.""" + reply = _ask_instance( + opts, "record", wait=not opts.no_wait, + seconds=opts.seconds, paste=bool(opts.paste), + ) + if reply is None: + return _not_running(opts) + return _dictation_result(opts, reply) + + +def cmd_toggle(opts): + reply = _ask_instance(opts, opts.verb, wait=opts.wait, + paste=opts.paste if opts.paste else None) + if reply is None: + return _not_running(opts) + return _dictation_result(opts, reply) + + +def _dictation_result(opts, reply): + if not reply.get("ok"): + return fail(opts, reply.get("error") or "the recording did not go through") + if reply.get("warning"): + note(opts, reply["warning"]) + if "text" not in reply: + return out(opts, reply, "") + return out(opts, reply, reply.get("text", "")) + + +def cmd_cancel(opts): + reply = _ask_instance(opts, "cancel") + return 0 if reply is not None else _not_running(opts) + + +def cmd_plain(opts): + """The verbs with nothing to say: settings, restart, quit, ask-reset…""" + reply = _ask_instance(opts, opts.verb) + if reply is None: + return _not_running(opts) + if not reply.get("ok"): + return fail(opts, reply.get("error") or opts.verb) + return out(opts, reply, "") + + +# --- the agent ------------------------------------------------------------- + +def cmd_ask(opts): + """Put a command to the agent. With no text, record one first.""" + text = " ".join(opts.text).strip() if opts.text else "" + if not text and not sys.stdin.isatty(): + text = sys.stdin.read().strip() + if not text: + reply = _ask_instance(opts, "ask", wait=opts.wait, + paste=opts.paste if opts.paste else None) + if reply is None: + return _not_running(opts) + if not reply.get("ok"): + return fail(opts, reply.get("error") or "the command did not go through") + if reply.get("warning"): + note(opts, reply["warning"]) + return out(opts, reply, reply.get("answer", "")) + + conf = cfg.Config() + if opts.provider: + conf["assistant_provider"] = opts.provider + if opts.model: + key = {"claude": "assistant_model", "codex": "assistant_codex_model", + "openrouter": "assistant_openrouter_model"}[assistant.provider(conf)] + conf[key] = opts.model + if opts.dir: + conf["assistant_dir"] = opts.dir + if opts.new: + assistant.clear_session() + + stopped = [] + signal.signal(signal.SIGINT, lambda *_: stopped.append(True)) + started = time.monotonic() + try: + answer, warning = assistant.ask( + text, conf, on_stage=lambda stage: note(opts, stage), + should_stop=lambda: bool(stopped), + ) + except assistant.Cancelled: + return fail(opts, "stopped", 130) + except (assistant.AssistantError, api.ApiError) as exc: + return fail(opts, exc) + finally: + signal.signal(signal.SIGINT, signal.SIG_DFL) + + if opts.paste or opts.copy: + try: + paste.copy(answer) + if opts.paste: + paste.press(conf["paste_shortcut"]) + except paste.PasteError as exc: + note(opts, str(exc)) + + cfg.append_history({ + "ts": time.strftime("%Y-%m-%d %H:%M:%S"), + "duration": 0.0, + "elapsed": round(time.monotonic() - started, 1), + "model": "", + "cleanup_model": "", + "cleanup_error": warning, + "mode": "ask", + "question": text, + "assistant_model": conf["assistant_model"], + "raw": text, + "text": answer, + }) + if warning: + note(opts, warning) + return out(opts, {"ok": True, "question": text, "answer": answer, + "warning": warning, + "provider": assistant.provider(conf)}, answer) + + +def cmd_session(opts): + if opts.session == "reset": + assistant.clear_session() + return out(opts, {"ok": True, "conversation": None}, + "The conversation has been dropped.") + conf = cfg.Config() + age = assistant.session_age() + minutes = None if age is None else int(age // 60) + # The conversation on disk belongs to whoever was asked last, which is not + # always whoever would be asked next; none of them can pick up another's. + owner = assistant.stored_provider() + told = f"{assistant.display_name(conf)}: " + if minutes is None: + told += "no conversation going." + elif owner != assistant.provider(conf): + told += f"nothing going; the conversation on disk is {owner}'s." + else: + told += f"last used {minutes} min ago." + return out( + opts, + {"ok": True, "provider": assistant.provider(conf), + "agent": assistant.display_name(conf), "conversation": owner or None, + "idle_minutes": minutes, + "keeps_minutes": conf["assistant_session_minutes"]}, + told, + ) + + +# --- a file ---------------------------------------------------------------- + +def cmd_transcribe(opts): + path = os.path.expanduser(opts.file) + if not os.path.isfile(path): + return fail(opts, f"no such file: {path}") + + conf = cfg.Config() + timestamps = opts.srt or _pick(opts.timestamps, conf["file_timestamps"]) + worker = filetranscribe.FileTranscriber(conf) + + def begin(finish): + worker.progress.connect(lambda message: note(opts, message)) + worker.finished.connect( + lambda text, segments: finish({"text": text, "segments": segments}) + ) + worker.failed.connect(lambda error: finish({"error": error})) + worker.start(path, timestamps, _pick(opts.cleanup, conf["file_cleanup"])) + + result = _headless(begin, on_interrupt=worker.stop) + if result.get("error"): + return fail(opts, result["error"]) + + text, segments = result["text"], result["segments"] + srt = filetranscribe.to_srt(text, segments) if opts.srt else "" + if opts.srt and not srt: + return fail(opts, "no timestamped lines to turn into subtitles") + + body = srt or text + written = "" + if opts.out: + written = os.path.expanduser(opts.out) + try: + with open(written, "w", encoding="utf-8") as fh: + fh.write(body if body.endswith("\n") else body + "\n") + except OSError as exc: + return fail(opts, exc) + note(opts, f"Saved: {written}") + + return out(opts, {"ok": True, "text": text, "srt": srt or None, + "segments": [list(item) for item in segments], + "path": written or None}, + "" if written else body) + + +# --- meetings --------------------------------------------------------------- + +def cmd_meeting(opts): + """The tray verb: start a meeting, or end it and write it up.""" + reply = _ask_instance(opts, opts.verb, wait=getattr(opts, "wait", False)) + if reply is None: + return _not_running(opts) + if not reply.get("ok"): + return fail(opts, reply.get("error") or "the meeting did not go through") + title = reply.get("title", "") + return out(opts, reply, f"{title}\n{reply['path']}" if title else "") + + +def _find_meeting(which): + """A meeting by its base, by 1 for the newest, or 'last'.""" + rows = cfg.read_meetings() + if not rows: + return None + if which in ("", "last"): + return rows[-1] + if which.isdigit(): + index = int(which) + return rows[-index] if 0 < index <= len(rows) else None + exact = [row for row in rows if row["base"] == which] + if exact: + return exact[0] + near = [row for row in rows if row["base"].startswith(which)] + return near[-1] if near else None + + +def cmd_meetings_list(opts): + rows = cfg.read_meetings() + lines = [] + for index, row in enumerate(reversed(rows), start=1): + lines.append( + f"{index:>3} {row['base']} {row.get('ts', ''):16} " + f"{int(row.get('duration', 0)) // 60:>4} min " + f"{row.get('status', ''):11} {row.get('title') or ''}".rstrip() + ) + return out(opts, {"ok": True, "meetings": list(reversed(rows))}, + "\n".join(lines) or "No meetings recorded yet.") + + +def cmd_meetings_show(opts): + row = _find_meeting(opts.which) + if row is None: + return fail(opts, f"no such meeting: {opts.which}") + doc_path, wav_path = cfg.meeting_paths(row["base"]) + try: + document = doc_path.read_text(encoding="utf-8") + except OSError: + document = "" + body = meeting.read_transcript(document) if opts.transcript else document + if not body: + return fail(opts, row.get("error") or "nothing has been written yet", + base=row["base"], status=row.get("status", "")) + return out(opts, {"ok": True, "base": row["base"], "title": row.get("title", ""), + "status": row.get("status", ""), "path": str(doc_path), + "audio": str(wav_path) if wav_path.exists() else None, + "text": body}, + body) + + +def cmd_meetings_retry(opts): + row = _find_meeting(opts.which) + if row is None: + return fail(opts, f"no such meeting: {opts.which}") + status = ipc.send("status") or {} + if status.get("meeting_base") == row["base"]: + return fail(opts, "the application is already writing this one up") + + conf = cfg.Config() + pipeline = meeting.MeetingPipeline(conf) + + def start(finish): + pipeline.progress.connect(lambda _base, message: note(opts, message)) + pipeline.finished.connect( + lambda base, title: finish({"base": base, "title": title}) + ) + pipeline.failed.connect(lambda _base, error: finish({"error": error})) + pipeline.run(row) + + result = _headless(start, on_interrupt=pipeline.stop) + if result.get("error"): + return fail(opts, result["error"], base=row["base"]) + doc_path, _wav = cfg.meeting_paths(result["base"]) + return out(opts, {"ok": True, "base": result["base"], "title": result["title"], + "path": str(doc_path)}, + f"{result['title']}\n{doc_path}") + + +def cmd_meetings_delete(opts): + bases = [] + for which in opts.which: + row = _find_meeting(which) + if row is None: + return fail(opts, f"no such meeting: {which}") + bases.append(row["base"]) + status = ipc.send("status") or {} + if status.get("meeting_base") in bases: + return fail(opts, "that meeting is being written up right now") + cfg.delete_meetings(bases) + return out(opts, {"ok": True, "deleted": bases}, + f"Deleted {len(bases)} " + ("meeting." if len(bases) == 1 else "meetings.")) + + +# --- history ---------------------------------------------------------------- + +def _find_history(which): + """A row by 1 for the newest, counting back. 'last' is the same as 1.""" + rows = cfg.read_history() + if not rows: + return None + if which in ("", "last"): + return rows[-1] + if not which.isdigit(): + return None + index = int(which) + return rows[-index] if 0 < index <= len(rows) else None + + +def cmd_history_list(opts): + rows = cfg.read_history(opts.limit if opts.limit > 0 else None) + lines = [] + for index, row in enumerate(reversed(rows), start=1): + preview = (row.get("text") or "").replace("\n", " ") + mode = "ask " if row.get("mode") == "ask" else " " + lines.append( + f"{index:>3} {row.get('ts', ''):19} {row.get('duration', 0):>6.1f}s " + f"{mode}{preview[:60]}" + ) + return out(opts, {"ok": True, "history": list(reversed(rows))}, + "\n".join(lines) or "Nothing dictated yet.") + + +def cmd_history_show(opts): + row = _find_history(opts.which) + if row is None: + return fail(opts, f"no such entry: {opts.which}") + body = row.get("raw", "") if opts.raw else row.get("text", "") + return out(opts, {"ok": True, **row}, body) + + +def cmd_history_delete(opts): + rows = [] + for which in opts.which: + row = _find_history(which) + if row is None: + return fail(opts, f"no such entry: {which}") + rows.append(row) + cfg.delete_history(rows) + return out(opts, {"ok": True, "deleted": len(rows)}, + f"Deleted {len(rows)} " + ("entry." if len(rows) == 1 else "entries.")) + + +def cmd_history_clear(opts): + if not opts.yes: + return fail(opts, "this deletes the whole history; pass --yes to mean it", 2) + cfg.clear_history() + return out(opts, {"ok": True}, "History cleared.") + + +# --- settings --------------------------------------------------------------- + +SECRET_KEYS = ("openai_api_key", "openrouter_api_key") + + +def _mask(key, value): + if key in SECRET_KEYS and value: + return f"…{value[-4:]}" + return value + + +def _coerce(key, raw): + """A value off the command line, in the type the setting is stored as.""" + default = cfg.DEFAULTS[key] + if isinstance(default, bool): + lowered = raw.strip().lower() + if lowered in ("1", "true", "yes", "on"): + return True + if lowered in ("0", "false", "no", "off"): + return False + raise ValueError(f"{key} wants true or false, got: {raw}") + if isinstance(default, int): + return int(float(raw)) + if isinstance(default, float): + return float(raw) + return raw + + +def _tell_instance_to_reload(): + """A setting changed under a running instance means nothing until it reads + it back, and the window it was not changed in would otherwise overwrite it.""" + ipc.send("reload") + + +def cmd_config_list(opts): + conf = cfg.Config() + # A key belongs to whoever asked for it by name, not to everything that ever + # prints the whole list; a terminal scrollback is a poor place for one. + values = {key: conf[key] if opts.reveal else _mask(key, conf[key]) + for key in sorted(cfg.DEFAULTS)} + lines = [] + for key, value in values.items(): + shown = value + if isinstance(shown, str) and len(shown) > 60: + shown = shown[:57].replace("\n", " ") + "…" + lines.append(f"{key} = {shown}") + return out(opts, {"ok": True, "config": values, "path": str(cfg.CONFIG_FILE)}, + "\n".join(lines)) + + +def cmd_config_get(opts): + if opts.key not in cfg.DEFAULTS: + return fail(opts, f"unknown setting: {opts.key}", 2) + value = cfg.Config()[opts.key] + return out(opts, {"ok": True, "key": opts.key, "value": value}, + json.dumps(value, ensure_ascii=False) if not isinstance(value, str) + else value) + + +def cmd_config_set(opts): + if opts.key not in cfg.DEFAULTS: + return fail(opts, f"unknown setting: {opts.key}", 2) + raw = opts.value + if raw is None: + if sys.stdin.isatty(): + return fail(opts, "no value given, and nothing on stdin to read", 2) + raw = sys.stdin.read().rstrip("\n") + try: + value = _coerce(opts.key, raw) + except ValueError as exc: + return fail(opts, exc, 2) + + conf = cfg.Config() + conf[opts.key] = value + try: + conf.save() + except OSError as exc: + return fail(opts, exc) + _tell_instance_to_reload() + return out(opts, {"ok": True, "key": opts.key, "value": value}, + f"{opts.key} = {_mask(opts.key, value)}") + + +def cmd_config_reset(opts): + keys = opts.key or [] + if opts.all: + keys = list(cfg.DEFAULTS) + if not keys: + return fail(opts, "name a setting, or pass --all", 2) + unknown = [key for key in keys if key not in cfg.DEFAULTS] + if unknown: + return fail(opts, f"unknown setting: {unknown[0]}", 2) + conf = cfg.Config() + for key in keys: + conf[key] = cfg.DEFAULTS[key] + try: + conf.save() + except OSError as exc: + return fail(opts, exc) + _tell_instance_to_reload() + return out(opts, {"ok": True, "reset": keys}, + f"Reset {len(keys)} setting(s) to their defaults.") + + +def cmd_config_path(opts): + return out(opts, + {"ok": True, "config": str(cfg.CONFIG_FILE), + "data": str(cfg.DATA_DIR), "history": str(cfg.HISTORY_FILE), + "meetings": str(cfg.MEETINGS_DIR), + "recordings": str(cfg.RECORDINGS_DIR)}, + str(cfg.CONFIG_FILE)) + + +def cmd_prompt(opts): + """The prompt a run would really use, defaults and glossary folded in.""" + conf = cfg.Config() + prompts = { + "cleanup": conf.cleanup_prompt(), + "subtitles": conf.cleanup_prompt(subtitles=True), + "meeting": conf.meeting_prompt(), + "agent": conf.assistant_prompt(), + } + if opts.which: + return out(opts, {"ok": True, "prompt": opts.which, + "text": prompts[opts.which]}, prompts[opts.which]) + return out(opts, {"ok": True, "prompts": prompts}, + "\n\n".join(f"--- {name} ---\n{text}" + for name, text in prompts.items())) + + +# --- the machine ------------------------------------------------------------ + +def cmd_devices(opts): + conf = cfg.Config() + default = audio.default_monitor() + mics = [{"name": name, "description": desc, + "chosen": name == conf["mic_target"]} + for name, desc in audio.list_sources()] + monitors = [{"name": name, "description": desc, + "chosen": name == conf["meeting_system_target"], + "default": name == default} + for name, desc in audio.list_monitors()] + if not mics and not monitors: + return fail(opts, "pactl found nothing; is PipeWire running?") + + lines = ["Microphones:"] + lines += [f" {'*' if item['chosen'] else ' '} {item['name']}\n" + f" {item['description']}" for item in mics] + lines += ["", "Monitors (what a meeting records the other side from):"] + lines += [f" {'*' if item['chosen'] else '·' if item['default'] else ' '} " + f"{item['name']}\n {item['description']}" for item in monitors] + return out(opts, {"ok": True, "microphones": mics, "monitors": monitors}, + "\n".join(lines)) + + +def cmd_models(opts): + conf = cfg.Config() + try: + if opts.provider == "openai": + models = api.openai_models(conf.openai_key(), conf["openai_base_url"]) + else: + models = api.openrouter_models(conf.openrouter_key(), + transcription=opts.transcription) + except api.ApiError as exc: + return fail(opts, exc) + return out(opts, {"ok": True, "provider": opts.provider, "models": models}, + "\n".join(models)) + + +def cmd_test_key(opts): + conf = cfg.Config() + results = {} + if opts.which in ("openai", "all"): + try: + count = len(api.openai_models(conf.openai_key(), conf["openai_base_url"])) + results["openai"] = {"ok": True, + "message": f"connection works, {count} models visible"} + except api.ApiError as exc: + results["openai"] = {"ok": False, "message": str(exc)} + if opts.which in ("openrouter", "all"): + try: + results["openrouter"] = {"ok": True, + "message": api.openrouter_key_status(conf.openrouter_key())} + except api.ApiError as exc: + results["openrouter"] = {"ok": False, "message": str(exc)} + everything_ok = all(item["ok"] for item in results.values()) + lines = [f"{'✓' if item['ok'] else '✗'} {name}: {item['message']}" + for name, item in results.items()] + out(opts, {"ok": everything_ok, "keys": results}, "\n".join(lines)) + return 0 if everything_ok else 1 + + +def cmd_shortcut(opts): + conf = cfg.Config() + if opts.shortcut == "status": + rows = {} + for name, (desktop_id, _label, key) in SHORTCUTS.items(): + rows[name] = {"registered": hotkey.kde_shortcut_status(desktop_id), + "configured": conf[key]} + lines = [f"{name:8} {row['registered'] or '(not installed)':16} " + f"setting: {row['configured'] or '(none)'}" + for name, row in rows.items()] + lines.append(f"built-in listener: {'on' if conf['evdev_hotkey'] else 'off'}") + return out(opts, {"ok": True, "shortcuts": rows, + "listener": conf["evdev_hotkey"]}, "\n".join(lines)) + + desktop_id, label, key = SHORTCUTS[opts.which] + if opts.shortcut == "remove": + hotkey.remove_kde_shortcut(desktop_id) + return out(opts, {"ok": True, "removed": opts.which}, + f"Removed the {opts.which} shortcut.") + + combo = (opts.combo or conf[key] or ("Ctrl+Space" if opts.which == "toggle" else "")).strip() + if not combo: + return fail(opts, "no combination given and none stored; pass --combo", 2) + if hotkey.parse_shortcut(combo) == (None, None): + return fail(opts, f"cannot parse that combination: {combo}", 2) + clashes = hotkey.conflicting_shortcuts(combo, desktop_id) + if clashes and not opts.force: + return fail(opts, f"{combo} is also used by: {', '.join(clashes[:6])}. " + "Pass --force to install it anyway.", 1, conflicts=clashes) + + ok, message = hotkey.install_kde_shortcut( + combo, ipc.command_for(opts.which), name=label, desktop_id=desktop_id, + ) + if not ok: + return fail(opts, message) + conf[key] = combo + try: + conf.save() + except OSError as exc: + return fail(opts, exc) + _tell_instance_to_reload() + return out(opts, {"ok": True, "which": opts.which, "shortcut": combo, + "message": message, "conflicts": clashes}, + message) + + +def cmd_status(opts): + reply = ipc.send("status") + if reply is None: + conf = cfg.Config() + out(opts, {"ok": True, "running": False, + "agent": assistant.display_name(conf)}, + "Dikte is not running.") + return NOT_RUNNING + if reply.get("legacy"): + return fail(opts, "the running instance is from before it could answer " + "questions; reload it with: dikte restart", 1, running=True) + lines = [ + f"dictation: {reply.get('dictation', '?')}", + f"agent: {reply.get('ask', '?')} ({reply.get('agent', '?')})", + f"meeting: {reply.get('meeting', '?')}" + + (f" {reply['meeting_message']}" if reply.get("meeting_message") else ""), + f"listener: {'on' if reply.get('listener') else 'off'}", + ] + return out(opts, reply, "\n".join(lines)) + + +def cmd_doctor(opts): + """What the settings window checks behind its buttons, in one pass.""" + conf = cfg.Config() + programs = {name: shutil.which(name) or "" + for name in ("pw-record", "wl-copy", "ydotool", "ffmpeg", + "pactl", "kwriteconfig6", + assistant.executable(assistant.provider(conf)) or "claude")} + target = conf.transcribe_target() + checks = { + "programs": programs, + "transcription": {"provider": target.provider, "model": target.model, + "key": bool(target.api_key)}, + "cleanup": {"enabled": conf["cleanup_enabled"], "model": conf["cleanup_model"], + "key": bool(conf.openrouter_key())}, + "agent": {"provider": assistant.provider(conf), + "directory": assistant.working_dir(conf)}, + "running": ipc.send("status") is not None, + } + lines = [f"{'✓' if path else '✗'} {name:14} {path or 'not on your PATH'}" + for name, path in programs.items()] + lines += [ + f"{'✓' if target.api_key else '✗'} {target.service} key, transcribing on " + f"{target.model}", + f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on " + f"{conf['cleanup_model']}", + f"{'✓' if checks['running'] else '·'} application " + + ("running" if checks["running"] else "not running"), + ] + return out(opts, {"ok": True, **checks}, "\n".join(lines)) + + +# --- the command line ------------------------------------------------------- + +EPILOG = """\ +examples: + dikte record --seconds 8 --json + dikte transcribe talk.mp4 --srt -o talk.srt + dikte ask "put that in my calendar on Thursday at three" + dikte config set cleanup_model google/gemini-3.5-flash + dikte history show 1 + +Recording needs the application to be running, since that is what holds the +microphone; everything else works on its own. --json is accepted by every +command. Exit codes: 0 done, 1 failed, 2 wrong command line, 3 not running. +""" + + +def build_parser(): + # Two flags every command takes, before the verb or after it. The + # subcommands get the copies that keep quiet when they are not given, so + # that a flag typed before the verb survives; a plain default here would be + # copied down and overwrite it. + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--json", action="store_true", default=argparse.SUPPRESS, + help="print the answer as one JSON object") + common.add_argument("-q", "--quiet", action="store_true", default=argparse.SUPPRESS, + help="keep progress lines off stderr") + + parser = argparse.ArgumentParser( + prog="dikte", + description="Voice dictation: record, transcribe, clean up, paste.", + epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--json", action="store_true", + help="print the answer as one JSON object") + parser.add_argument("-q", "--quiet", action="store_true", + help="keep progress lines off stderr") + parser.set_defaults(verb="", timeout=0, func=cmd_plain) + subs = parser.add_subparsers(dest="verb", metavar="COMMAND") + + def leaf(group, name, help_text="", **kwargs): + """A subcommand. Without a line of help it stays out of the listing, + which is where the verbs kept for the old spelling belong.""" + if help_text: + kwargs["help"] = help_text + return group.add_parser(name, parents=[common], **kwargs) + + # --- dictation -------------------------------------------------------- + record = leaf(subs, "record", "record and print what was said") + record.add_argument("--seconds", type=float, default=0, + help="stop on its own after this many seconds") + record.add_argument("--paste", action="store_true", + help="paste into the focused window as well") + record.add_argument("--no-wait", action="store_true", + help="start it and return, without the transcript") + record.add_argument("--timeout", type=float, default=0, + help="give up waiting after this many seconds") + record.set_defaults(func=cmd_record) + + for name, help_text in (("toggle", "start or stop recording"), + ("start", "start recording"), + ("stop", "stop recording and transcribe")): + page = leaf(subs, name, help_text) + page.add_argument("--wait", action="store_true", + help="wait for the run and print the transcript") + page.add_argument("--paste", action="store_true", + help="paste into the focused window as well") + page.add_argument("--timeout", type=float, default=0) + page.set_defaults(func=cmd_toggle) + + leaf(subs, "cancel", "throw away the recording").set_defaults(func=cmd_cancel) + + # --- the agent -------------------------------------------------------- + ask = leaf(subs, "ask", "put a command to the agent") + ask.add_argument("text", nargs="*", help="the command; read from stdin, or " + "recorded when there is none") + ask.add_argument("--provider", choices=("claude", "codex", "openrouter"), + help="just for this run") + ask.add_argument("--model", help="just for this run") + ask.add_argument("--dir", help="working directory, just for this run") + ask.add_argument("--new", action="store_true", + help="start a fresh conversation") + ask.add_argument("--paste", action="store_true", + help="paste the answer into the focused window") + ask.add_argument("--copy", action="store_true", help="put the answer on the clipboard") + ask.add_argument("--wait", action="store_true", + help="when recording, wait for the answer") + ask.add_argument("--timeout", type=float, default=0) + ask.set_defaults(func=cmd_ask) + + session = leaf(subs, "session", "the agent's conversation") + session.add_argument("session", nargs="?", default="status", + choices=("status", "reset")) + session.set_defaults(func=cmd_session) + + for name in ("ask-cancel", "ask-reset"): + leaf(subs, name).set_defaults(func=cmd_plain) + + # --- a file ----------------------------------------------------------- + transcribe = leaf(subs, "transcribe", "transcribe an audio or video file") + transcribe.add_argument("file") + transcribe.add_argument("-o", "--out", help="write here instead of stdout") + transcribe.add_argument("--srt", action="store_true", + help="subtitles rather than plain text") + transcribe.add_argument("--timestamps", action=argparse.BooleanOptionalAction, + default=None, help="[mm:ss] in front of every line") + transcribe.add_argument("--cleanup", action=argparse.BooleanOptionalAction, + default=None, help="run the cleanup model over it") + transcribe.set_defaults(func=cmd_transcribe) + + # --- meetings --------------------------------------------------------- + for name, help_text in (("meeting", "start a meeting, or end it and write it up"), + ("meeting-cancel", "")): + page = leaf(subs, name, help_text) + page.add_argument("--wait", action="store_true", + help="wait for the minutes to be written") + page.add_argument("--timeout", type=float, default=0) + page.set_defaults(func=cmd_meeting) + + meetings = leaf(subs, "meetings", "recorded meetings and their minutes") + inner = meetings.add_subparsers(dest="meetings", metavar="") + meetings.set_defaults(func=_needs_subcommand(meetings)) + leaf(inner, "list", "every meeting, newest first").set_defaults(func=cmd_meetings_list) + show = leaf(inner, "show", "the minutes of one meeting") + show.add_argument("which", nargs="?", default="last", + help="its base, or 1 for the newest") + show.add_argument("--transcript", action="store_true", + help="the transcript rather than the whole document") + show.set_defaults(func=cmd_meetings_show) + retry = leaf(inner, "retry", "write up a meeting that failed") + retry.add_argument("which", nargs="?", default="last") + retry.set_defaults(func=cmd_meetings_retry) + delete = leaf(inner, "delete", "drop meetings and their files") + delete.add_argument("which", nargs="+") + delete.set_defaults(func=cmd_meetings_delete) + for name, verb, help_text in (("start", "meeting-start", "start recording one"), + ("stop", "meeting-stop", "end it and write it up"), + ("cancel", "meeting-cancel", "throw the recording away")): + page = leaf(inner, name, help_text) + page.add_argument("--wait", action="store_true") + page.add_argument("--timeout", type=float, default=0) + page.set_defaults(func=cmd_meeting, verb=verb) + + # --- history ---------------------------------------------------------- + history = leaf(subs, "history", "past dictations") + inner = history.add_subparsers(dest="history", metavar="") + history.set_defaults(func=_needs_subcommand(history)) + listing = leaf(inner, "list", "the last dictations, newest first") + listing.add_argument("--limit", type=int, default=20, help="0 for all of them") + listing.set_defaults(func=cmd_history_list) + show = leaf(inner, "show", "one dictation in full") + show.add_argument("which", nargs="?", default="last", + help="1 for the newest, counting back") + show.add_argument("--raw", action="store_true", + help="the transcript before cleanup") + show.set_defaults(func=cmd_history_show) + delete = leaf(inner, "delete", "drop entries") + delete.add_argument("which", nargs="+") + delete.set_defaults(func=cmd_history_delete) + clear = leaf(inner, "clear", "drop all of them") + clear.add_argument("--yes", action="store_true") + clear.set_defaults(func=cmd_history_clear) + + # --- settings --------------------------------------------------------- + config = leaf(subs, "config", "every setting the window holds") + inner = config.add_subparsers(dest="config", metavar="") + config.set_defaults(func=_needs_subcommand(config)) + listing = leaf(inner, "list", "all of them") + listing.add_argument("--reveal", action="store_true", + help="print the API keys in full") + listing.set_defaults(func=cmd_config_list) + getter = leaf(inner, "get", "one setting") + getter.add_argument("key") + getter.set_defaults(func=cmd_config_get) + setter = leaf(inner, "set", "change one setting") + setter.add_argument("key") + setter.add_argument("value", nargs="?", help="omit it to read stdin") + setter.set_defaults(func=cmd_config_set) + resetter = leaf(inner, "reset", "back to the default") + resetter.add_argument("key", nargs="*") + resetter.add_argument("--all", action="store_true") + resetter.set_defaults(func=cmd_config_reset) + leaf(inner, "path", "where things are stored").set_defaults(func=cmd_config_path) + + prompt = leaf(subs, "prompt", "the prompt a run would really send") + prompt.add_argument("which", nargs="?", + choices=("cleanup", "subtitles", "meeting", "agent")) + prompt.set_defaults(func=cmd_prompt) + + # --- the machine ------------------------------------------------------ + leaf(subs, "devices", "microphones and monitors").set_defaults(func=cmd_devices) + models = leaf(subs, "models", "model ids a provider offers") + models.add_argument("--provider", choices=("openrouter", "openai"), + default="openrouter") + models.add_argument("--transcription", action="store_true", + help="only the speech-to-text ones") + models.set_defaults(func=cmd_models) + test = leaf(subs, "test-key", "check the API keys") + test.add_argument("which", nargs="?", default="all", + choices=("all", "openai", "openrouter")) + test.set_defaults(func=cmd_test_key) + leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor) + + shortcut = leaf(subs, "shortcut", "the KDE global shortcuts") + inner = shortcut.add_subparsers(dest="shortcut", metavar="") + shortcut.set_defaults(func=_needs_subcommand(shortcut)) + leaf(inner, "status", "what is registered").set_defaults(func=cmd_shortcut) + install = leaf(inner, "install", "register one") + install.add_argument("which", nargs="?", default="toggle", + choices=tuple(SHORTCUTS)) + install.add_argument("--combo", help="e.g. Ctrl+Alt+Space") + install.add_argument("--force", action="store_true", + help="install it even if something else uses it") + install.set_defaults(func=cmd_shortcut) + remove = leaf(inner, "remove", "unregister one") + remove.add_argument("which", nargs="?", default="toggle", choices=tuple(SHORTCUTS)) + remove.set_defaults(func=cmd_shortcut) + + # --- the application -------------------------------------------------- + leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status) + for name, help_text in (("settings", "open the settings window"), + ("restart", "reload the running instance"), + ("quit", "shut it down")): + leaf(subs, name, help_text).set_defaults(func=cmd_plain) + + helper = leaf(subs, "help", "this text") + helper.set_defaults(func=lambda _opts: parser.print_help() or 0) + return parser + + +def _needs_subcommand(parser): + def show(_opts): + parser.print_help() + return 2 + return show + + +def run(argv): + global _app + parser = build_parser() + opts = parser.parse_args(argv) + # No verb at all is the plain `dikte`, which means the settings window. + opts.verb = opts.verb or "" + # Every path here either talks over the socket or drives one of the workers, + # and both want an event loop under them; a window is what none of them want. + _app = QCoreApplication.instance() or QCoreApplication(sys.argv[:1]) + try: + return opts.func(opts) + except KeyboardInterrupt: + return 130 + except BrokenPipeError: + return 0 diff --git a/dikte.py b/dikte.py index 08203ce..cc17c6c 100755 --- a/dikte.py +++ b/dikte.py @@ -1,20 +1,13 @@ #!/usr/bin/env python3 """Dikte: press Ctrl+Space, talk, press again to transcribe, clean up and paste. -Usage: - dikte.py run in the background (tray icon) - dikte.py toggle start / stop recording - dikte.py cancel discard the current recording - dikte.py ask start / stop recording a command for the agent - dikte.py ask-cancel call off the command the agent is working on - dikte.py ask-reset forget the conversation the agent has been following - dikte.py meeting start / end a meeting recording - dikte.py meeting-cancel discard the meeting being recorded - dikte.py settings open the settings window - dikte.py restart reload the running instance - dikte.py quit shut the application down +This is the application: the tray icon, the state machine, and the socket the +terminal talks to. Every verb it answers is in cli.py, which is also what runs +`dikte.py --help`; the only argument handled here is --gui, which is how the +command line says "there is no instance to talk to, so be one". """ +import json import os import sys @@ -30,9 +23,11 @@ from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402 import assistant # noqa: E402 import audio # noqa: E402 +import cli # noqa: E402 import config as cfg # noqa: E402 import hotkey # noqa: E402 import i18n # noqa: E402 +import ipc # noqa: E402 import meeting # noqa: E402 from i18n import t # noqa: E402 from meeting import MeetingPipeline # noqa: E402 @@ -40,7 +35,7 @@ from overlay import Overlay # noqa: E402 from settings_ui import SettingsWindow # noqa: E402 from worker import Pipeline # noqa: E402 -SERVER_NAME = "dikte-" + str(os.getuid()) +SERVER_NAME = ipc.SERVER_NAME IDLE, RECORDING, BUSY = "idle", "recording", "busy" # Dictation and a command for the agent are two runs of the same machinery, kept # apart so that neither waits on the other: an agent can spend a minute thinking, @@ -49,6 +44,7 @@ IDLE, RECORDING, BUSY = "idle", "recording", "busy" DICTATION, ASK = "dictation", "ask" # A meeting runs alongside dictation rather than through it: writing up an hour # of audio takes minutes, and dictation should not be held hostage to it. +MEETING = "meeting" M_IDLE, M_RECORDING, M_WORKING = "idle", "recording", "working" # The KDE shortcut answers a key press by launching a whole Python process, so @@ -75,6 +71,15 @@ class Dikte: self.meeting_message = "" self.settings_window = None self._quitting = False + # A request that asked to be told how its run ended waits in here until + # the run gets there, keyed by which of the three it was waiting on. + self._waiters = {} + # Whether the next run pastes, when the request said so instead of + # leaving it to the setting. + self.paste_override = {} + # Which recording is the current one, so a timer set for the run that + # started it cannot stop the one that came after. + self._run_id = 0 self.overlay = Overlay(self.conf["overlay_corner"]) # The agent's indicator sits on top of the dictation one when both are @@ -332,6 +337,122 @@ class Dikte: QSystemTrayIcon.MessageIcon.Information, 8000, ) + # ---- requests off the socket ------------------------------------------ + # + # Every request is answered, and a request can ask to be answered late: not + # when the recording starts but when the transcript is there. That is what + # makes a terminal, or something driving one, able to use this at all rather + # than only able to press its buttons. + + def handle(self, request, reply): + cmd = str(request.get("cmd") or "settings").strip() + if cmd in ("toggle", "start", "stop", "record"): + self._dictation_request(cmd, request, reply) + elif cmd == "ask": + self._ask_request(request, reply) + elif cmd in ("meeting", "meeting-start", "meeting-stop"): + self._meeting_request(cmd, request, reply) + elif cmd == "status": + reply(self.status()) + else: + handler = { + "cancel": self.cancel, + "ask-cancel": self.cancel_ask, + "ask-reset": self.reset_conversation, + "meeting-cancel": self.cancel_meeting, + "settings": self.open_settings, + "reload": self.reload_settings, + "restart": self.restart, + "quit": self.app.quit, + }.get(cmd) + if handler is None: + reply({"ok": False, "error": f"unknown command: {cmd}"}) + return + if cmd in ("restart", "quit"): + # Answer while there is still something to answer with. + reply({"ok": True}) + QTimer.singleShot(120, handler) + return + handler() + reply({"ok": True}) + + def _dictation_request(self, cmd, request, reply): + before = self.state + # Only a request that said something about pasting changes it, so that + # the stop half of a `start --paste` does not undo the start half. + if "paste" in request: + self.paste_override[DICTATION] = request["paste"] + if cmd == "toggle": + self.toggle() + elif cmd == "stop": + self.stop() + else: + self.start() + seconds = float(request.get("seconds") or 0) + if seconds > 0 and self.state == RECORDING: + run = self._run_id + QTimer.singleShot(int(seconds * 1000), lambda: self._auto_stop(run)) + self._answer(DICTATION, before, self.state, request, reply) + + def _ask_request(self, request, reply): + before = self.ask_state + if "paste" in request: + self.paste_override[ASK] = request["paste"] + self.toggle_ask() + self._answer(ASK, before, self.ask_state, request, reply) + + def _meeting_request(self, cmd, request, reply): + before = self.meeting_state + if cmd == "meeting": + self.toggle_meeting() + elif cmd == "meeting-start": + self.start_meeting() + else: + self.stop_meeting() + self._answer(MEETING, before, self.meeting_state, request, reply) + + def _answer(self, kind, before, after, request, reply): + """Reply now, or once the run this request set going is over.""" + if not request.get("wait"): + reply({"ok": True, "state": after}) + elif after == before: + # Nothing moved: the microphone is held by the other one, or this + # one is still working, or there was nothing to stop. Say so rather + # than wait for a run that was never started. + reply({"ok": False, "state": after, + "error": f"nothing was started; {kind} is {after}"}) + else: + self._waiters.setdefault(kind, []).append(reply) + + def _settle(self, kind, payload): + """Tell whoever was waiting on this run how it ended.""" + for reply in self._waiters.pop(kind, []): + reply(payload) + + def _auto_stop(self, run): + """The end of a `record --seconds`, if that recording is still the one.""" + if self._run_id == run and self.state == RECORDING: + self.stop() + + def status(self): + return { + "ok": True, + "running": True, + "dictation": self.state, + "ask": self.ask_state, + "meeting": self.meeting_state, + "meeting_base": self.meetings.running_base, + "meeting_message": self.meeting_message, + "agent": assistant.display_name(self.conf), + "provider": assistant.provider(self.conf), + "listener": self.evdev.running, + } + + def reload_settings(self): + """Read the config file back after something outside changed it.""" + self.conf.load() + self._apply_settings() + def _toggle(self): # Two /dev/input nodes can carry the same keyboard, and a menu click can # land on top of a key press; swallow the immediate repeat. @@ -374,6 +495,7 @@ class Dikte: def _begin_recording(self, owner): """One microphone, so one of the two holds it at a time.""" self.recorder_owner = owner + self._run_id += 1 self.elapsed.restart() self.ticker.start() self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"]) @@ -402,12 +524,18 @@ class Dikte: self.ticker.stop() self.recorder.cancel() self.recorder_owner = None + # What goes over the socket is read by a program as often as by a + # person, so it stays in one language; only what a run itself said + # travels through translated. + dropped = {"ok": False, "cancelled": True, "error": "cancelled"} if asking: self.ask_overlay.dismiss() self._set_ask_state(IDLE) + self._settle(ASK, dropped) else: self.overlay.dismiss() self._set_state(IDLE) + self._settle(DICTATION, dropped) def cancel_ask(self): """Call off the agent, whether it is still recording or already working.""" @@ -482,6 +610,7 @@ class Dikte: if self.overlay.state == "meeting": self.overlay.dismiss() self._set_meeting_state(M_IDLE) + self._settle(MEETING, {"ok": False, "cancelled": True, "error": "cancelled"}) def _conceal_meeting_overlay(self): if self.overlay.state == "meeting": @@ -514,6 +643,11 @@ class Dikte: return if not self.meetings.run(entry): self._set_meeting_state(M_IDLE) + self._settle(MEETING, { + "ok": False, "base": entry["base"], + "error": "recording saved, but the previous meeting is still " + "being written up", + }) self.tray.showMessage( "Dikte", t("Recording saved. The previous meeting is still being written " @@ -531,6 +665,8 @@ class Dikte: def _on_meeting_finished(self, base, title): self._set_meeting_state(M_IDLE) doc_path, _ = cfg.meeting_paths(base) + self._settle(MEETING, {"ok": True, "base": base, "title": title, + "path": str(doc_path)}) self.overlay.show_done(t("Meeting written up: {title}", title=title), 5000) self.tray.showMessage( t("Dikte: the meeting is written up"), f"{title}\n{doc_path}", @@ -539,6 +675,7 @@ class Dikte: def _on_meeting_failed(self, _base, error): self._set_meeting_state(M_IDLE) + self._settle(MEETING, {"ok": False, "base": _base, "error": error}) first_line = error.strip().splitlines()[0] self.overlay.show_error(t("Meeting failed: {error}", error=first_line)) self.tray.showMessage( @@ -554,6 +691,7 @@ class Dikte: if self.overlay.state == "meeting": self.overlay.dismiss() self._set_meeting_state(M_IDLE) + self._settle(MEETING, {"ok": False, "error": message}) self._on_error(message) def _on_meeting_died(self): @@ -569,10 +707,12 @@ class Dikte: def _on_recorded(self, wav_path, duration, rms_values): owner, self.recorder_owner = self.recorder_owner, None + wants_paste = self.paste_override.pop(owner, None) if owner == ASK: - self.ask_pipeline.run(wav_path, duration, rms_values, ask=True) + self.ask_pipeline.run(wav_path, duration, rms_values, ask=True, + paste=wants_paste) else: - self.pipeline.run(wav_path, duration, rms_values) + self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste) def _on_finished(self, _raw, text, warning): if warning: @@ -591,6 +731,8 @@ class Dikte: t("{action}: {preview}", action=action, preview=_preview(text)) ) self._set_state(IDLE) + self._settle(DICTATION, {"ok": True, "text": text, "raw": _raw, + "warning": warning}) def _on_ask_finished(self, _raw, text, warning): agent = assistant.display_name(self.conf) @@ -612,10 +754,13 @@ class Dikte: t("{name}: {preview}", name=agent, preview=_preview(text)), 6000 ) self._set_ask_state(IDLE) + self._settle(ASK, {"ok": True, "answer": text, "question": _raw, + "warning": warning, "agent": agent}) def _on_ask_cancelled(self): self.ask_overlay.show_done(t("Stopped."), 2000) self._set_ask_state(IDLE) + self._settle(ASK, {"ok": False, "cancelled": True, "error": "stopped"}) def _on_recorder_error(self, message): """The microphone itself could not run, so it belongs to whoever asked.""" @@ -626,10 +771,12 @@ class Dikte: def _on_error(self, message): self._report(message, self.overlay) self._set_state(IDLE) + self._settle(DICTATION, {"ok": False, "error": message}) def _on_ask_error(self, message): self._report(message, self.ask_overlay) self._set_ask_state(IDLE) + self._settle(ASK, {"ok": False, "error": message}) def _report(self, message, overlay): first_line = message.strip().splitlines()[0] @@ -673,8 +820,7 @@ class Dikte: self.settings_window.close() self.shutdown() QLocalServer.removeServer(SERVER_NAME) - script = os.path.realpath(__file__) - os.execv(sys.executable, [sys.executable, script]) + os.execv(sys.executable, [sys.executable, ipc.script_path(), "--gui"]) def shutdown(self): self._quitting = True @@ -705,53 +851,35 @@ def _clock(seconds): def launch_command(): """The command the KDE shortcut will run.""" - return f"{sys.executable} {os.path.realpath(__file__)} toggle" + return ipc.command_for("toggle") def meeting_command(): - return f"{sys.executable} {os.path.realpath(__file__)} meeting" + return ipc.command_for("meeting") def ask_command(): - return f"{sys.executable} {os.path.realpath(__file__)} ask" - - -def send_command(command, timeout=800): - """Hand a command to the running instance; False when there is none.""" - socket = QLocalSocket() - socket.connectToServer(SERVER_NAME) - if not socket.waitForConnected(timeout): - return False - socket.write(command.encode("utf-8")) - socket.flush() - socket.waitForBytesWritten(timeout) - socket.disconnectFromServer() - return True + return ipc.command_for("ask") def main(): - args = [a for a in sys.argv[1:] if not a.startswith("-")] - command = args[0] if args else "" + argv = sys.argv[1:] + # Anything typed at a terminal is the command line's business, including + # --help and the verbs that only need a message sent. It comes back here + # with --gui when it turns out there is no instance to send one to. + if "--gui" not in argv: + return cli.run(argv) + return run_app([arg for arg in argv if arg != "--gui"]) - if command and command not in ("toggle", "cancel", "settings", "restart", - "quit", "start", "stop", "ask", "ask-reset", - "ask-cancel", "meeting", "meeting-cancel"): - print(__doc__) - return 2 + +def run_app(args): + command = args[0] if args else "" app = QApplication(sys.argv) app.setApplicationName("Dikte") app.setDesktopFileName("dikte") app.setQuitOnLastWindowClosed(False) - # No command and an instance already running: bring its settings forward. - if send_command(command or "settings"): - return 0 - - if command in ("cancel", "quit", "stop", "restart", "meeting-cancel", - "ask-reset", "ask-cancel"): - return 0 - if not QSystemTrayIcon.isSystemTrayAvailable(): print("dikte: no system tray found, running anyway") @@ -770,25 +898,27 @@ def main(): if conn is None: return + def reply(payload): + """One JSON object back, and the connection is done. + + A request that waited for its run may find the terminal gone by the + time the answer is ready, which is a closed socket and not an error. + """ + if conn.state() != QLocalSocket.LocalSocketState.ConnectedState: + return + conn.write((json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")) + conn.flush() + conn.disconnectFromServer() + def read(): payload = bytes(conn.readAll()).decode("utf-8", "replace").strip() - handler = { - "toggle": dikte.toggle, - "start": dikte.start, - "stop": dikte.stop, - "cancel": dikte.cancel, - "ask": dikte.toggle_ask, - "ask-cancel": dikte.cancel_ask, - "ask-reset": dikte.reset_conversation, - "meeting": dikte.toggle_meeting, - "meeting-cancel": dikte.cancel_meeting, - "settings": dikte.open_settings, - "restart": dikte.restart, - "quit": app.quit, - }.get(payload) - if handler: - handler() - conn.disconnectFromServer() + try: + request = json.loads(payload) + if not isinstance(request, dict): + raise ValueError + except (json.JSONDecodeError, ValueError): + request = {"cmd": payload} # a bare verb, as older versions sent + dikte.handle(request, reply) conn.readyRead.connect(read) diff --git a/ipc.py b/ipc.py new file mode 100644 index 0000000..541e067 --- /dev/null +++ b/ipc.py @@ -0,0 +1,81 @@ +"""The socket the running instance listens on, and one request over it. + +A command typed at a terminal is answered rather than only obeyed: the reply +carries the transcript, the agent's answer, or the reason nothing happened, +which is what lets a script wait for a dictation instead of guessing when it is +done. One JSON object goes each way per connection. A bare verb is still +understood, because that is what earlier versions sent and what a stale KDE +shortcut may still send. +""" + +import json +import os +import sys + +from PyQt6.QtNetwork import QLocalSocket + +SERVER_NAME = "dikte-" + str(os.getuid()) + +# Long enough for a process that is already running to answer, short enough that +# "nothing is running" is not a noticeable pause in front of a key press. +CONNECT_MS = 800 + + +def script_path(): + return os.path.realpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "dikte.py") + ) + + +def command_for(verb): + """The command line a KDE shortcut runs for one of the verbs.""" + return f"{sys.executable} {script_path()} {verb}" + + +def send(cmd, wait=False, timeout=0, **args): + """Send one request; the reply, or None when no instance is running. + + `wait` asks the instance to hold its reply back until the job the request + started is over, which is how a terminal gets the transcript rather than + only the fact that recording began. `timeout` bounds that wait in seconds; + 0 waits for as long as the job takes. + """ + sock = QLocalSocket() + sock.connectToServer(SERVER_NAME) + if not sock.waitForConnected(CONNECT_MS): + return None + + request = {"cmd": cmd} + request.update({key: value for key, value in args.items() if value is not None}) + if wait: + request["wait"] = True + # A verb carrying nothing goes as the bare word it used to be, so that an + # instance still running the older code obeys it: that is the one request + # that has to work across an update, since it is how you install the update. + line = cmd if list(request) == ["cmd"] else json.dumps(request) + sock.write((line + "\n").encode("utf-8")) + sock.flush() + sock.waitForBytesWritten(CONNECT_MS) + + limit = (int(timeout * 1000) if timeout else -1) if wait else CONNECT_MS + buffer = b"" + while b"\n" not in buffer: + if not sock.waitForReadyRead(limit): + break + buffer += bytes(sock.readAll()) + sock.disconnectFromServer() + + line = buffer.decode("utf-8", "replace").strip() + if not line: + # An instance from before replies existed answers by staying silent, and + # for a fire-and-forget verb that silence means it went through. A wait + # that ends this way did not: the run never reported back. + return ({"ok": False, "legacy": True, + "error": "the running instance is too old to answer; " + "reload it with: dikte restart"} + if wait else {"ok": True, "legacy": True}) + try: + reply = json.loads(line) + except json.JSONDecodeError: + return {"ok": True, "legacy": True} + return reply if isinstance(reply, dict) else {"ok": True, "legacy": True} diff --git a/worker.py b/worker.py index 7ecb04f..ba27107 100644 --- a/worker.py +++ b/worker.py @@ -49,12 +49,16 @@ class Pipeline(QObject): def busy(self): return self._thread is not None and self._thread.is_alive() - def run(self, wav_path, duration, rms_values=(), ask=False): + def run(self, wav_path, duration, rms_values=(), ask=False, paste=None): + """`paste` overrides the setting for this one run, which is what a + dictation asked for from a terminal wants: the text comes back down the + socket, and pasting it into whatever had focus is nobody's intention.""" if self.busy: return self._stop.clear() self._thread = threading.Thread( - target=self._work, args=(wav_path, duration, list(rms_values), ask), + target=self._work, + args=(wav_path, duration, list(rms_values), ask, paste), daemon=True, ) self._thread.start() @@ -68,7 +72,7 @@ class Pipeline(QObject): """ self._stop.set() - def _work(self, wav_path, duration, rms_values, ask): + def _work(self, wav_path, duration, rms_values, ask, paste_override=None): conf = self.conf started = time.monotonic() raw = "" @@ -135,11 +139,15 @@ class Pipeline(QObject): ) warning = "\n".join(x for x in (warning, denied) if x) + wants_paste = (conf["assistant_paste"] if ask else conf["auto_paste"]) + if paste_override is not None: + wants_paste = paste_override + with _paste_lock: previous = paste.read_clipboard() if conf["restore_clipboard"] else None paste.copy(text) - if (conf["assistant_paste"] if ask else conf["auto_paste"]): + if wants_paste: self.stage.emit(t("Pasting…")) paste.press(conf["paste_shortcut"]) if previous is not None: