mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Ctrl+Space starts and stops a recording. The audio goes to OpenAI for transcription, a model on OpenRouter strips the fillers and restores punctuation, and the result is copied and pasted into the focused window. Only the Python standard library and PyQt6 — HTTP, multipart uploads and WAV writing are all hand-rolled. - pw-record captures raw 16 kHz mono PCM with a live level meter - the corner indicator is drawn through XWayland, since a Wayland client cannot position its own window - silence is caught before it costs an API call, relative to each recording's own noise floor, plus a filter for the stock phrases models invent when handed silence - audio and video files can be transcribed too, optionally with [mm:ss] timestamps, chunked through ffmpeg for long inputs - global shortcut installs as a KDE custom shortcut, with an evdev listener as a fallback until the session is restarted - Turkish and English interface, following the system locale by default
195 lines
6.9 KiB
Python
195 lines
6.9 KiB
Python
"""OpenAI (transcription) and OpenRouter (cleanup) calls — stdlib only."""
|
|
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import secrets
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from i18n import t
|
|
|
|
USER_AGENT = "dikte/1.0 (+https://github.com/yusufipk/dikte)"
|
|
OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
|
|
|
# Only whisper-1 returns segment-level timestamps.
|
|
TIMESTAMP_MODEL = "whisper-1"
|
|
|
|
|
|
class ApiError(Exception):
|
|
pass
|
|
|
|
|
|
def _request(url, data, headers, timeout=120):
|
|
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as exc:
|
|
body = exc.read().decode("utf-8", "replace")
|
|
raise ApiError(f"HTTP {exc.code}: {_extract_error(body)}") from exc
|
|
except urllib.error.URLError as exc:
|
|
raise ApiError(t("Could not connect: {reason}", reason=exc.reason)) from exc
|
|
except json.JSONDecodeError as exc:
|
|
raise ApiError(t("Could not parse the response: {error}", error=exc)) from exc
|
|
|
|
|
|
def _extract_error(body):
|
|
try:
|
|
payload = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
return body[:300]
|
|
err = payload.get("error")
|
|
if isinstance(err, dict):
|
|
return err.get("message") or json.dumps(err)[:300]
|
|
if isinstance(err, str):
|
|
return err
|
|
return body[:300]
|
|
|
|
|
|
def _multipart(fields, file_field, file_path):
|
|
"""Build a multipart/form-data body; returns (body, content-type)."""
|
|
boundary = "----dikte" + secrets.token_hex(16)
|
|
out = bytearray()
|
|
for name, value in fields:
|
|
if value is None or value == "":
|
|
continue
|
|
out += f"--{boundary}\r\n".encode()
|
|
out += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
|
|
out += str(value).encode("utf-8") + b"\r\n"
|
|
|
|
filename = os.path.basename(file_path)
|
|
ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
with open(file_path, "rb") as fh:
|
|
payload = fh.read()
|
|
out += f"--{boundary}\r\n".encode()
|
|
out += (
|
|
f'Content-Disposition: form-data; name="{file_field}"; filename="{filename}"\r\n'
|
|
f"Content-Type: {ctype}\r\n\r\n"
|
|
).encode()
|
|
out += payload + b"\r\n"
|
|
out += f"--{boundary}--\r\n".encode()
|
|
return bytes(out), f"multipart/form-data; boundary={boundary}"
|
|
|
|
|
|
def _transcribe_request(wav_path, api_key, model, language, prompt, base_url,
|
|
response_format, granularity=None, timeout=300):
|
|
if not api_key:
|
|
raise ApiError(t("OpenAI API key is empty. Add it in Settings."))
|
|
fields = [("model", model), ("response_format", response_format)]
|
|
if language and language != "auto":
|
|
fields.append(("language", language))
|
|
if prompt:
|
|
fields.append(("prompt", prompt))
|
|
if granularity:
|
|
fields.append(("timestamp_granularities[]", granularity))
|
|
body, ctype = _multipart(fields, "file", wav_path)
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": ctype,
|
|
"User-Agent": USER_AGENT,
|
|
}
|
|
return _request(
|
|
f"{base_url.rstrip('/')}/audio/transcriptions", body, headers, timeout=timeout
|
|
)
|
|
|
|
|
|
def transcribe(wav_path, api_key, model="gpt-4o-transcribe", language="", prompt="",
|
|
base_url="https://api.openai.com/v1", timeout=300):
|
|
data = _transcribe_request(
|
|
wav_path, api_key, model, language, prompt, base_url, "json", timeout=timeout
|
|
)
|
|
text = (data.get("text") or "").strip()
|
|
if not text:
|
|
raise ApiError(t("Transcript came back empty."))
|
|
return text
|
|
|
|
|
|
def transcribe_segments(wav_path, api_key, language="", prompt="",
|
|
base_url="https://api.openai.com/v1", timeout=300):
|
|
"""[(start_seconds, text)] using whisper-1's verbose response."""
|
|
data = _transcribe_request(
|
|
wav_path, api_key, TIMESTAMP_MODEL, language, prompt, base_url,
|
|
"verbose_json", granularity="segment", timeout=timeout,
|
|
)
|
|
segments = data.get("segments") or []
|
|
out = []
|
|
for seg in segments:
|
|
text = (seg.get("text") or "").strip()
|
|
if text:
|
|
out.append((float(seg.get("start") or 0.0), text))
|
|
if not out:
|
|
text = (data.get("text") or "").strip()
|
|
if not text:
|
|
raise ApiError(t("Transcript came back empty."))
|
|
out = [(0.0, text)]
|
|
return out
|
|
|
|
|
|
def cleanup(text, api_key, model, system_prompt, base_url=OPENROUTER_URL, timeout=180):
|
|
if not api_key:
|
|
raise ApiError(t("OpenRouter API key is empty. Add it in Settings."))
|
|
payload = {
|
|
"model": model,
|
|
"temperature": 0,
|
|
"messages": [
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": f"<transcript>\n{text}\n</transcript>"},
|
|
],
|
|
}
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
"User-Agent": USER_AGENT,
|
|
"HTTP-Referer": "https://github.com/yusufipk/dikte",
|
|
"X-Title": "Dikte",
|
|
}
|
|
data = _request(
|
|
f"{base_url.rstrip('/')}/chat/completions",
|
|
json.dumps(payload).encode("utf-8"),
|
|
headers,
|
|
timeout=timeout,
|
|
)
|
|
choices = data.get("choices") or []
|
|
if not choices:
|
|
raise ApiError(_extract_error(json.dumps(data)))
|
|
content = ((choices[0].get("message") or {}).get("content") or "").strip()
|
|
if not content:
|
|
raise ApiError(t("The cleanup model returned an empty reply."))
|
|
return content
|
|
|
|
|
|
def _get_json(url, headers, timeout=20):
|
|
req = urllib.request.Request(url, headers=headers)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as exc:
|
|
body = exc.read().decode("utf-8", "replace")
|
|
raise ApiError(f"HTTP {exc.code}: {_extract_error(body)}") from exc
|
|
except urllib.error.URLError as exc:
|
|
raise ApiError(t("Could not connect: {reason}", reason=exc.reason)) from exc
|
|
except json.JSONDecodeError as exc:
|
|
raise ApiError(t("Could not parse the response: {error}", error=exc)) from exc
|
|
|
|
|
|
def openrouter_models(api_key=""):
|
|
"""Model ids available on OpenRouter (no key required)."""
|
|
headers = {"User-Agent": USER_AGENT}
|
|
if api_key:
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
data = _get_json(f"{OPENROUTER_URL}/models", headers)
|
|
return sorted(m["id"] for m in data.get("data", []) if m.get("id"))
|
|
|
|
|
|
def openai_models(api_key, base_url="https://api.openai.com/v1"):
|
|
if not api_key:
|
|
raise ApiError(t("OpenAI API key is empty. Add it in Settings."))
|
|
data = _get_json(
|
|
f"{base_url.rstrip('/')}/models",
|
|
{"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT},
|
|
)
|
|
ids = [m["id"] for m in data.get("data", []) if m.get("id")]
|
|
audio = [i for i in ids if "transcribe" in i or "whisper" in i]
|
|
return sorted(audio or ids)
|