Skip to content

Commit bdf58bb

Browse files
authored
Merge pull request chigwell#252 from arnoutvw/feat/transcription-engines
Add OpenAI-compatible and local faster-whisper transcription engines
2 parents 5960872 + 479be72 commit bdf58bb

8 files changed

Lines changed: 1198 additions & 71 deletions

File tree

‎.env.example‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,34 @@ TELEGRAM_SESSION_NAME=telegram_session
131131
# leaves Telegram, but empirically drops the last speech segment in
132132
# roughly 2 of 3 recordings. Requires Telegram Premium on the account.
133133
# Use for chats that should never be sent to a third party.
134+
# - openai: any OpenAI-compatible /audio/transcriptions endpoint (OpenAI,
135+
# a self-hosted Parakeet or speaches server, LocalAI, ...), with an
136+
# optional API key.
137+
# - whisper: local faster-whisper model inside this process. Audio never
138+
# leaves the machine. Needs: pip install 'telegram-mcp[whisper]'.
134139
# TELEGRAM_TRANSCRIBE_ENGINE=groq
135140
# GROQ_API_KEY=<groq api key, required when engine=groq is ever used>
136141
#
142+
# Language hint (ISO-639-1) for groq/openai/whisper. Unset = auto-detect,
143+
# except on Parakeet servers, which assume English when no language is sent.
144+
# TELEGRAM_TRANSCRIBE_LANGUAGE=nl
145+
# Timeout in seconds for the HTTP engines (groq/openai).
146+
# TELEGRAM_TRANSCRIBE_TIMEOUT=120
147+
#
148+
# engine=openai (base URL, like the OpenAI SDK's base_url; a full
149+
# .../audio/transcriptions URL works too). Parakeet (achetronic/parakeet):
150+
# http://localhost:5092/v1
151+
# TELEGRAM_TRANSCRIBE_OPENAI_URL=https://api.openai.com/v1
152+
# TELEGRAM_TRANSCRIBE_OPENAI_API_KEY=<api key, optional for keyless local servers>
153+
# TELEGRAM_TRANSCRIBE_OPENAI_MODEL=whisper-1
154+
# TELEGRAM_TRANSCRIBE_OPENAI_MAX_MB=25
155+
#
156+
# engine=whisper (faster-whisper model name or local path; loaded once per process)
157+
# TELEGRAM_TRANSCRIBE_WHISPER_MODEL=small
158+
# TELEGRAM_TRANSCRIBE_WHISPER_DEVICE=auto # auto / cpu / cuda
159+
# TELEGRAM_TRANSCRIBE_WHISPER_COMPUTE_TYPE=default # e.g. int8 on CPU, float16 on GPU
160+
# TELEGRAM_TRANSCRIBE_WHISPER_MODEL_DIR=<where downloaded models are stored>
161+
#
137162
# Per-call budget for TELEGRAM_TRANSCRIBE=auto prefetch (ignored otherwise;
138163
# transcribe_voice itself is never budget-limited since it's an explicit call).
139164
# TELEGRAM_TRANSCRIBE_MAX_VOICES=5

‎README.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,16 @@ Aliases live in `${XDG_STATE_HOME:-~/.local/state}/telegram-mcp/aliases.json` (o
7474

7575
### Voice transcription
7676

77-
`transcribe_voice(chat_id, message_id, engine=None)` turns a voice message or video note into text. Two engines are available:
77+
`transcribe_voice(chat_id, message_id, engine=None)` turns a voice message or video note into text. Four engines are available:
7878

7979
- `groq` (default): uploads the recording to Groq's hosted `whisper-large-v3-turbo`. Leaves the server and costs a download+upload per call, but doesn't drop the recording's last few words the way native transcription does. Requires `GROQ_API_KEY`. Groq caps the size of a single upload, so a recording above `TELEGRAM_TRANSCRIBE_GROQ_MAX_MB` (default 25, the free-tier limit) is refused locally with a `too_large` error naming its size instead of being downloaded and rejected by the API. Raise the limit if your Groq tier allows bigger files, or transcribe that message with `engine='telegram'`, which has no such cap.
8080
- `telegram`: native Telegram Premium transcription (`messages.TranscribeAudioRequest`). Free and never leaves Telegram, but empirically drops the last speech segment in roughly 2 of 3 recordings and requires Telegram Premium on the account. Long recordings come back `pending` and are polled automatically.
81+
- `openai`: any OpenAI-compatible `/audio/transcriptions` endpoint — OpenAI itself, a self-hosted [Parakeet](https://github.com/achetronic/parakeet) or [speaches](https://github.com/speaches-ai/speaches) server, LocalAI, a vLLM Whisper deployment, and so on. Set `TELEGRAM_TRANSCRIBE_OPENAI_URL` to the API base URL (e.g. `https://api.openai.com/v1`; a full `.../audio/transcriptions` URL also works), `TELEGRAM_TRANSCRIBE_OPENAI_API_KEY` for the bearer token (optional for keyless local servers), and `TELEGRAM_TRANSCRIBE_OPENAI_MODEL` (default `whisper-1`). Size cap: `TELEGRAM_TRANSCRIBE_OPENAI_MAX_MB` (default 25). For Parakeet use `TELEGRAM_TRANSCRIBE_OPENAI_URL=http://localhost:5092/v1`, the API key only if the server sets `PARAKEET_API_KEY`, and set `TELEGRAM_TRANSCRIBE_LANGUAGE` for anything that isn't English — Parakeet assumes `en` when no language is sent.
82+
- `whisper`: a local [faster-whisper](https://github.com/SYSTRAN/faster-whisper) model loaded inside the MCP server process. The audio never leaves the machine. Install the extra with `pip install 'telegram-mcp[whisper]'` (or `uv sync --extra whisper`). `TELEGRAM_TRANSCRIBE_WHISPER_MODEL` picks the model (default `small`; e.g. `large-v3-turbo` for better quality), `TELEGRAM_TRANSCRIBE_WHISPER_DEVICE` (`auto`/`cpu`/`cuda`), `TELEGRAM_TRANSCRIBE_WHISPER_COMPUTE_TYPE` (e.g. `int8` on CPU) and `TELEGRAM_TRANSCRIBE_WHISPER_MODEL_DIR` (where models are downloaded) tune it. The model is loaded once on first use and recordings are transcribed one at a time. Not available in the Alpine Docker image; use `openai` against a Parakeet or other OpenAI-compatible server next to the container instead.
8183

82-
The engine is chosen per call via the `engine` argument, or otherwise defaults to `TELEGRAM_TRANSCRIBE_ENGINE` (`groq` or `telegram`). Results are cached by `(chat_id, message_id, engine)` in a local SQLite file so repeat reads and repeat listings never re-transcribe the same message. Concurrent requests for the same uncached recording are collapsed too: the second one waits for the first and returns its transcript, so a burst of callers costs one paid call, not one per caller. Every transcript is returned with a `note` marking it as a machine transcript, not a verbatim quote — treat it as a paraphrase, not exact wording.
84+
`TELEGRAM_TRANSCRIBE_LANGUAGE` (ISO-639-1, e.g. `nl`) is passed as a language hint to every engine except `telegram`; unset, the engines auto-detect. `TELEGRAM_TRANSCRIBE_TIMEOUT` (default 120 seconds) bounds a single request to the HTTP engines (`groq`, `openai`).
85+
86+
The engine is chosen per call via the `engine` argument, or otherwise defaults to `TELEGRAM_TRANSCRIBE_ENGINE` (`groq`, `telegram`, `openai` or `whisper`). Results are cached by `(chat_id, message_id, engine)` in a local SQLite file so repeat reads and repeat listings never re-transcribe the same message. Concurrent requests for the same uncached recording are collapsed too: the second one waits for the first and returns its transcript, so a burst of callers costs one paid call, not one per caller. Every transcript is returned with a `note` marking it as a machine transcript, not a verbatim quote — treat it as a paraphrase, not exact wording.
8387

8488
`get_history`, `get_messages`, and `list_messages` fill in already-cached transcripts for voice messages instead of leaving the text empty, controlled by `TELEGRAM_TRANSCRIBE`:
8589

@@ -304,12 +308,13 @@ already-cached transcripts. Enable prefetching or pick an engine explicitly:
304308

305309
```env
306310
TELEGRAM_TRANSCRIBE=on-demand # off / on-demand (default) / auto
307-
TELEGRAM_TRANSCRIBE_ENGINE=groq # groq (default) or telegram
311+
TELEGRAM_TRANSCRIBE_ENGINE=groq # groq (default), telegram, openai or whisper
308312
GROQ_API_KEY=your_groq_api_key_here # required whenever engine=groq is used
309313
```
310314

311315
`engine=groq` requires `GROQ_API_KEY`; `engine=telegram` requires Telegram
312-
Premium on the account. `TELEGRAM_TRANSCRIBE_MAX_VOICES` (default 5) and
316+
Premium on the account; `engine=openai` requires `TELEGRAM_TRANSCRIBE_OPENAI_URL`;
317+
`engine=whisper` requires the `whisper` extra. `TELEGRAM_TRANSCRIBE_MAX_VOICES` (default 5) and
313318
`TELEGRAM_TRANSCRIBE_MAX_SECONDS` (default 300) bound how much `auto` mode
314319
prefetches per listing call; `TELEGRAM_TRANSCRIPT_CACHE_DIR` (default
315320
`data/transcripts`) sets where the SQLite cache is written; `TELEGRAM_TRANSCRIBE_GROQ_MAX_MB`

‎pyproject.toml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ dependencies = [
3838
proxy = [
3939
"python-socks>=2.4.3",
4040
]
41+
# Required only for the local transcription engine (TELEGRAM_TRANSCRIBE_ENGINE=whisper).
42+
whisper = [
43+
"faster-whisper>=1.1.0",
44+
# faster-whisper 1.2.1 passes av.open(metadata_errors=...), which PyAV 19
45+
# removed; 18.1.0 is the newest that works. Drop once a faster-whisper
46+
# release includes SYSTRAN/faster-whisper#1495.
47+
"av<19",
48+
]
4149

4250
[project.urls]
4351
"Homepage" = "https://github.com/chigwell/telegram-mcp"

‎telegram_mcp/tools/messages.py‎

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,31 +1194,37 @@ async def transcribe_voice(
11941194
"""
11951195
Transcribe a voice message or video note (video circle) to text.
11961196
1197-
Two engines behind one interface:
1198-
- "groq" (default, override with TELEGRAM_TRANSCRIBE_ENGINE): Groq-hosted
1199-
whisper-large-v3-turbo. Downloads the audio and sends it to Groq - not
1200-
free, and leaves the server. Does not drop the recording's last words.
1197+
Engines (default TELEGRAM_TRANSCRIBE_ENGINE, otherwise "groq"):
1198+
- "groq": Groq-hosted whisper-large-v3-turbo. Downloads the audio and
1199+
sends it to Groq - not free, and leaves the server. Does not drop the
1200+
recording's last words.
12011201
- "telegram": native Telegram Premium transcription. Free, audio never
12021202
leaves Telegram, but empirically drops the last speech segment in
12031203
roughly 2 of 3 recordings (proven with per-segment timestamps). Use for
12041204
chats you don't want sent to a third party, or when Groq is unavailable.
12051205
Requires Telegram Premium on this account; polls briefly (up to ~20s)
12061206
while Telegram finishes a long recording.
1207+
- "openai": any OpenAI-compatible transcription endpoint
1208+
(TELEGRAM_TRANSCRIBE_OPENAI_URL, optional API key), e.g. OpenAI or a
1209+
self-hosted Parakeet/speaches server.
1210+
- "whisper": a local faster-whisper model on this server. Audio never
1211+
leaves the machine; slower on CPU.
12071212
12081213
Results are cached per engine, by (chat_id, message_id, engine) - a
12091214
repeat call with the same engine returns the cached text without
1210-
hitting either API again. Asking for an engine that has no cached
1211-
result transcribes with it, even when the other engine's text is
1215+
hitting any engine again. Asking for an engine that has no cached
1216+
result transcribes with it, even when another engine's text is
12121217
already cached.
12131218
12141219
The returned text is a machine transcript, not a verbatim quote: proper
1215-
names, punctuation and occasional words drift under both engines.
1220+
names, punctuation and occasional words drift under every engine.
12161221
12171222
Args:
12181223
chat_id: The chat ID or username.
12191224
message_id: The message ID containing the voice/video-note media.
1220-
engine: "groq" or "telegram". Defaults to TELEGRAM_TRANSCRIBE_ENGINE
1221-
(groq unless configured otherwise).
1225+
engine: "groq", "telegram", "openai" or "whisper".
1226+
Defaults to TELEGRAM_TRANSCRIBE_ENGINE (groq unless configured
1227+
otherwise).
12221228
"""
12231229
try:
12241230
mode = transcription.transcribe_mode()
@@ -1233,10 +1239,11 @@ async def transcribe_voice(
12331239

12341240
chosen_engine = (engine or transcription.default_engine()).strip().lower()
12351241
if chosen_engine not in transcription.ENGINES:
1236-
return f"Invalid engine '{engine}'. Use 'telegram' or 'groq'."
1242+
accepted = ", ".join(f"'{name}'" for name in sorted(transcription.ENGINES))
1243+
return f"Invalid engine '{engine}'. Use one of: {accepted}."
12371244

12381245
# Pinned to the chosen engine on purpose: a cached telegram transcript
1239-
# must not answer a groq request. The native engine drops the last
1246+
# must not answer a request for any other engine. The native engine drops the last
12401247
# speech segment and the loss cannot be seen in the text.
12411248
cached = transcription.get_cached_transcript(
12421249
numeric_chat_id, message_id, source=chosen_engine
@@ -1261,11 +1268,9 @@ async def transcribe_voice(
12611268
if not transcription.is_transcribable(msg):
12621269
return f"Message {message_id} has no voice message or video note to transcribe."
12631270

1264-
if chosen_engine == "groq" and not os.getenv("GROQ_API_KEY"):
1265-
return (
1266-
"GROQ_API_KEY is not configured on this server. "
1267-
"Use engine='telegram' or set GROQ_API_KEY."
1268-
)
1271+
config_error = transcription.engine_config_error(chosen_engine)
1272+
if config_error:
1273+
return config_error
12691274

12701275
duration = transcription.voice_duration(msg)
12711276
# Cache-first and locked by (chat, message, engine): two concurrent

0 commit comments

Comments
 (0)