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: