mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Transcribe and clean up on this machine, without installing anything first
whisper-server is started on --inference-path /v1/audio/transcriptions, which is exactly the path api.py already builds for the hosted providers, and llama-server answers /chat/completions the way OpenRouter does. So the local half is one more base URL rather than a second code path: worker.py, filetranscribe.py and meeting.py are untouched, and dictation, subtitles and meetings all work here on the first try. Three findings worth naming, none of them in the new code: whisper.cpp cuts segments on tokens, which in Turkish lands inside a word about as often as between two. Pasted raw that gives "akraba değ\niller."; in a subtitle it gives a cue reading "değ". Whisper marks the start of a word with a leading space, so a piece that does not begin with one continues the word above it. A small model will repeat the transcript until the context is full, and every one of those tokens is a second of somebody waiting: measured at 206 seconds, and 25 with a ceiling on the reply. Hosted models are left alone, where the same runaway is rare and a ceiling would cut the minutes short. A server outlives SIGTERM and SIGKILL holding its model in memory. Signals are now turned into an event Qt delivers, since Qt blocks in C where a Python handler never runs, and a pid file lets the next start sweep up what a SIGKILL left behind. The minutes keep their own provider rather than following cleanup's. The two jobs are not the same size: a 4B model here will strip the filler words out of a dictation and will not write up an hour long meeting. The suite runs offline now: a test that reaches the network says so instead of quietly going there.
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
"""OpenAI and OpenRouter calls, stdlib only.
|
||||
"""OpenAI, OpenRouter and this machine, stdlib only.
|
||||
|
||||
Transcription runs on either provider: OpenRouter mirrors OpenAI's
|
||||
/audio/transcriptions endpoint field for field, so one multipart request serves
|
||||
both and only the key, the base URL and the model id change. Cleanup is always
|
||||
OpenRouter.
|
||||
Transcription runs on any of three providers and cleanup on two, and none of
|
||||
them needs code of its own. OpenRouter mirrors OpenAI's /audio/transcriptions
|
||||
endpoint field for field, and ggml.py starts whisper.cpp on that same path, so
|
||||
one multipart request serves all three; llama.cpp answers /chat/completions the
|
||||
way OpenRouter does, so one JSON request serves both. What changes between them
|
||||
is the key, the base URL and the model id.
|
||||
|
||||
The local ones have no key, and their base URL is not known until a server is
|
||||
up, which is the one thing this module has to fill in for them.
|
||||
"""
|
||||
|
||||
import collections
|
||||
@@ -14,6 +19,7 @@ import secrets
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import ggml
|
||||
from i18n import t
|
||||
|
||||
APP_URL = "https://github.com/yusufipk/dikte"
|
||||
@@ -21,14 +27,29 @@ USER_AGENT = f"dikte/1.0 (+{APP_URL})"
|
||||
OPENAI_URL = "https://api.openai.com/v1"
|
||||
OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
||||
|
||||
# Where a transcription request goes; built by config.Config.transcribe_target().
|
||||
# `service` is the name the user sees in an error, `provider` the one the code
|
||||
# branches on.
|
||||
Target = collections.namedtuple("Target", "provider service api_key base_url model")
|
||||
# The floor for a local request. The timeouts elsewhere are sized for a hosted
|
||||
# API, where a slow answer is a bill running; here the only thing being spent is
|
||||
# time, and a long recording on a machine without a graphics card takes a good
|
||||
# deal of it. Cutting that off would throw the work away for nothing.
|
||||
LOCAL_TIMEOUT = 3600
|
||||
|
||||
# Where a request goes; built by config.Config's *_target() methods. `service`
|
||||
# is the name the user sees in an error, `provider` the one the code branches
|
||||
# on. `reasoning` is only read by cleanup, which is the only job with a model
|
||||
# that might think about anything.
|
||||
Target = collections.namedtuple(
|
||||
"Target", "provider service api_key base_url model reasoning", defaults=("",))
|
||||
|
||||
|
||||
def timestamp_model(provider):
|
||||
"""Only whisper-1 returns segment times, and OpenRouter namespaces the id."""
|
||||
def timestamp_model(provider, model):
|
||||
"""Only whisper-1 returns segment times, and OpenRouter namespaces the id.
|
||||
|
||||
Whisper is what the local server runs whatever the file is called, so there
|
||||
it stays on the model that is already loaded; asking for another one would
|
||||
name a model that server has never heard of.
|
||||
"""
|
||||
if provider == "local":
|
||||
return model
|
||||
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
|
||||
|
||||
|
||||
@@ -106,7 +127,11 @@ def _multipart(fields, file_field, file_path):
|
||||
|
||||
|
||||
def _headers(provider, api_key, content_type=None):
|
||||
headers = {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT}
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
# A server on this machine has nothing to authorise, and sending it a
|
||||
# bearer token would only be a made-up one.
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
if provider == "openrouter":
|
||||
@@ -116,17 +141,44 @@ def _headers(provider, api_key, content_type=None):
|
||||
return headers
|
||||
|
||||
|
||||
def _serving(target, server, timeout):
|
||||
"""A local target with the address of a running server in it.
|
||||
|
||||
The server is started on demand and picks its own port, so this is the first
|
||||
moment its address exists. serve() is idempotent: once it is up this costs
|
||||
nothing.
|
||||
"""
|
||||
try:
|
||||
return target._replace(base_url=server.serve()), max(timeout, LOCAL_TIMEOUT)
|
||||
except ggml.LocalError as exc:
|
||||
raise ApiError(str(exc)) from None
|
||||
|
||||
|
||||
def _local_failure(target, server, exc):
|
||||
"""A server that died mid-request, explained by its own output.
|
||||
|
||||
Without this the message is that the connection dropped, when the reason for
|
||||
it was printed by the process at the other end.
|
||||
"""
|
||||
detail = server.error()
|
||||
return ApiError(f"{target.service}: {exc}" + (f" ({detail})" if detail else ""),
|
||||
exc.status)
|
||||
|
||||
|
||||
def _transcribe_request(target, wav_path, language, prompt, response_format,
|
||||
granularity=None, timeout=300):
|
||||
if not target.api_key:
|
||||
if target.provider == "local":
|
||||
target, timeout = _serving(target, ggml.whisper, timeout)
|
||||
elif not target.api_key:
|
||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||
service=target.service))
|
||||
fields = [("model", target.model), ("response_format", response_format)]
|
||||
if language and language != "auto":
|
||||
fields.append(("language", language))
|
||||
# OpenRouter takes the hint field and throws it away, so spare it the bytes.
|
||||
# The same words still reach the cleanup model as a glossary.
|
||||
if prompt and target.provider == "openai":
|
||||
# The same words still reach the cleanup model as a glossary. whisper.cpp
|
||||
# takes it as the initial prompt, the way OpenAI does.
|
||||
if prompt and target.provider in ("openai", "local"):
|
||||
fields.append(("prompt", prompt))
|
||||
if granularity:
|
||||
fields.append(("timestamp_granularities[]", granularity))
|
||||
@@ -137,14 +189,58 @@ def _transcribe_request(target, wav_path, language, prompt, response_format,
|
||||
_headers(target.provider, target.api_key, ctype), timeout=timeout,
|
||||
)
|
||||
except ApiError as exc:
|
||||
if target.provider == "local":
|
||||
raise _local_failure(target, ggml.whisper, exc) from None
|
||||
raise explain(exc, target.service) from None
|
||||
|
||||
|
||||
# Whisper marks the start of a word with a leading space, so a piece of text
|
||||
# that does not begin with one continues the word before it rather than starting
|
||||
# a new one. Both helpers below turn on that.
|
||||
def _continues_a_word(previous, following):
|
||||
return bool(previous) and not previous[-1:].isspace() and not following[:1].isspace()
|
||||
|
||||
|
||||
def _local_text(text):
|
||||
"""whisper.cpp's segments, joined back into the flowing line OpenAI returns.
|
||||
|
||||
Its plain text puts one segment per line, and a segment boundary falls
|
||||
wherever the tokens fell, which in Turkish lands inside a word about as
|
||||
often as between two. Nothing takes the line break's place: whisper's own
|
||||
leading spaces are what separate the words, and a break inside "değ|iller"
|
||||
has nothing on either side of it worth keeping.
|
||||
"""
|
||||
return "".join(text.split("\n"))
|
||||
|
||||
|
||||
def _merge_word_splits(segments):
|
||||
"""Fold a segment that begins mid-word into the one it continues.
|
||||
|
||||
The hosted whisper-1 hands back segments cut on sentences; whisper.cpp cuts
|
||||
them on tokens, and a subtitle cue reading "değ" is not a cue. The times are
|
||||
joined along with the text, so the merged segment still covers the whole
|
||||
word.
|
||||
"""
|
||||
merged = []
|
||||
for seg in segments:
|
||||
text = seg.get("text") or ""
|
||||
if merged and _continues_a_word(merged[-1]["text"], text):
|
||||
merged[-1]["text"] += text
|
||||
merged[-1]["end"] = seg.get("end") or merged[-1]["end"]
|
||||
continue
|
||||
merged.append({"text": text, "start": seg.get("start") or 0.0,
|
||||
"end": seg.get("end") or 0.0})
|
||||
return merged
|
||||
|
||||
|
||||
def transcribe(target, wav_path, language="", prompt="", timeout=300):
|
||||
data = _transcribe_request(
|
||||
target, wav_path, language, prompt, "json", timeout=timeout
|
||||
)
|
||||
text = (data.get("text") or "").strip()
|
||||
text = data.get("text") or ""
|
||||
if target.provider == "local":
|
||||
text = _local_text(text)
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ApiError(t("Transcript came back empty."))
|
||||
return text
|
||||
@@ -153,11 +249,13 @@ def transcribe(target, wav_path, language="", prompt="", timeout=300):
|
||||
def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
|
||||
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
|
||||
data = _transcribe_request(
|
||||
target._replace(model=timestamp_model(target.provider)),
|
||||
target._replace(model=timestamp_model(target.provider, target.model)),
|
||||
wav_path, language, prompt, "verbose_json",
|
||||
granularity="segment", timeout=timeout,
|
||||
)
|
||||
segments = data.get("segments") or []
|
||||
if target.provider == "local":
|
||||
segments = _merge_word_splits(segments)
|
||||
out = []
|
||||
for seg in segments:
|
||||
text = (seg.get("text") or "").strip()
|
||||
@@ -166,45 +264,91 @@ def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
|
||||
end = float(seg.get("end") or 0.0)
|
||||
out.append((start, max(end, start), text))
|
||||
if not out:
|
||||
text = (data.get("text") or "").strip()
|
||||
text = data.get("text") or ""
|
||||
if target.provider == "local":
|
||||
text = _local_text(text)
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ApiError(t("Transcript came back empty."))
|
||||
out = [(0.0, 0.0, text)]
|
||||
return out
|
||||
|
||||
|
||||
def cleanup(text, api_key, model, system_prompt, reasoning="",
|
||||
base_url=OPENROUTER_URL, timeout=180):
|
||||
if not api_key:
|
||||
def _thinking(target, payload):
|
||||
"""Ask for as much thinking as this provider understands, or for none.
|
||||
|
||||
An empty level means "whatever the model does on its own", so nothing is
|
||||
sent. The two providers mean opposite things by that, which is why the
|
||||
setting is kept per provider: OpenRouter's cleanup models answer straight
|
||||
away, while a local model that was trained to think will think, and cleanup
|
||||
is punctuation rather than a job worth thinking about.
|
||||
"""
|
||||
if not target.reasoning:
|
||||
return
|
||||
if target.provider == "local-llm":
|
||||
# What llama.cpp passes to the chat template. The models that think
|
||||
# read it; the ones that do not ignore it.
|
||||
payload["chat_template_kwargs"] = {
|
||||
"enable_thinking": target.reasoning != "none"}
|
||||
return
|
||||
if target.reasoning != "none":
|
||||
# The thinking itself is never shown, so ask for it to be left out.
|
||||
payload["reasoning"] = {"effort": target.reasoning, "exclude": True}
|
||||
|
||||
|
||||
def _local_ceiling(text):
|
||||
"""How much of a reply is worth waiting for from a model on this machine.
|
||||
|
||||
Cleanup gives back what it was given, near enough, so a reply several times
|
||||
the length of the transcript is a model that has lost the thread rather than
|
||||
one doing the job. A small one will happily repeat the transcript until the
|
||||
context is full, and every one of those tokens is a second of somebody
|
||||
waiting. A hosted model is left alone: there the same runaway is rare, and a
|
||||
ceiling would cut the minutes short instead.
|
||||
"""
|
||||
return max(512, len(text))
|
||||
|
||||
|
||||
def cleanup(target, text, system_prompt, timeout=180):
|
||||
if target.provider == "local-llm":
|
||||
target, timeout = _serving(target, ggml.llm, timeout)
|
||||
elif not target.api_key:
|
||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||
service="OpenRouter"))
|
||||
service=target.service))
|
||||
payload = {
|
||||
"model": model,
|
||||
"model": target.model,
|
||||
"temperature": 0,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"<transcript>\n{text}\n</transcript>"},
|
||||
],
|
||||
}
|
||||
# An empty level means "whatever the model does on its own"; anything else is
|
||||
# one of OpenRouter's efforts. The thinking itself is never shown, so ask for
|
||||
# it to be left out of the reply.
|
||||
if reasoning:
|
||||
payload["reasoning"] = {"effort": reasoning, "exclude": True}
|
||||
if target.provider == "local-llm":
|
||||
payload["max_tokens"] = _local_ceiling(text)
|
||||
_thinking(target, payload)
|
||||
try:
|
||||
data = _request(
|
||||
f"{base_url.rstrip('/')}/chat/completions",
|
||||
f"{target.base_url.rstrip('/')}/chat/completions",
|
||||
json.dumps(payload).encode("utf-8"),
|
||||
_headers("openrouter", api_key, "application/json"),
|
||||
_headers(target.provider, target.api_key, "application/json"),
|
||||
timeout=timeout,
|
||||
)
|
||||
except ApiError as exc:
|
||||
raise explain(exc, "OpenRouter") from None
|
||||
if target.provider == "local-llm":
|
||||
raise _local_failure(target, ggml.llm, exc) from None
|
||||
raise explain(exc, target.service) from None
|
||||
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()
|
||||
message = choices[0].get("message") or {}
|
||||
content = (message.get("content") or "").strip()
|
||||
if not content:
|
||||
# A thinking model can spend the whole reply on the thinking and leave
|
||||
# nothing to paste. Worth naming, because the fix is a setting rather
|
||||
# than a retry: cleanup is not a job that wants thinking.
|
||||
if message.get("reasoning_content") or message.get("reasoning"):
|
||||
raise ApiError(t("The cleanup model spent its whole reply on "
|
||||
"thinking. Set Thinking to “Off”."))
|
||||
raise ApiError(t("The cleanup model returned an empty reply."))
|
||||
return content
|
||||
|
||||
|
||||
Reference in New Issue
Block a user