diff --git a/.env.example b/.env.example index cab16af..ee767a2 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,17 @@ TELEGRAM_ALLOWED_USER_IDS=123456789 # Optional: enable cloud-based voice transcription via OpenAI Whisper (~$0.006/min) # OPENAI_API_KEY=sk-... +# Optional: point cloud transcription at any OpenAI-compatible provider instead. +# Each variable falls back to the OpenAI default when unset, so an existing +# OPENAI_API_KEY setup keeps working untouched. +# TELEPI_TRANSCRIPTION_API_KEY=... # falls back to OPENAI_API_KEY +# TELEPI_TRANSCRIPTION_URL=https://api.openai.com/v1/audio/transcriptions +# TELEPI_TRANSCRIPTION_MODEL=whisper-1 +# Only needed when the provider does not authenticate with `Authorization: Bearer`. +# TELEPI_TRANSCRIPTION_AUTH_HEADER=api-key +# Context that biases transcription toward names and jargon you say often. +# TELEPI_TRANSCRIPTION_PROMPT=Conversation about software. Terms: Kubernetes, Grafana, ... + # Optional: enable local/offline transcription via Sherpa-ONNX Parakeet # (primarily for Intel-based Macs, where parakeet-coreml is not supported) # SHERPA_ONNX_MODEL_DIR=/absolute/path/to/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8 diff --git a/README.md b/README.md index c37116b..8320d17 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,7 @@ TelePi supports three transcription backends and picks the best one automaticall | **Parakeet CoreML** (local) | `npm install parakeet-coreml` + `brew install ffmpeg` | Free | On-device | | **Sherpa-ONNX Parakeet** (local, Intel Mac path) | `npm install sherpa-onnx-node` + download model + set `SHERPA_ONNX_MODEL_DIR` | Free | On-device | | **OpenAI Whisper** (cloud) | `OPENAI_API_KEY=sk-...` in your TelePi config file | ~$0.006/min | Cloud | +| **Any OpenAI-compatible provider** (cloud) | `TELEPI_TRANSCRIPTION_URL` + `TELEPI_TRANSCRIPTION_MODEL` + `TELEPI_TRANSCRIPTION_API_KEY` | Provider's | Cloud | TelePi tries backends in this order: @@ -303,6 +304,44 @@ OPENAI_API_KEY=sk-... No additional packages are required. Supports the same audio formats Telegram delivers (Ogg Opus, MP3, M4A, WAV, etc.). +#### Using another OpenAI-compatible provider + +The cloud backend sends a plain multipart request to an OpenAI-shaped +`/audio/transcriptions` endpoint, so any provider implementing that shape works. +Every variable below is optional and falls back to the OpenAI default, so an +existing `OPENAI_API_KEY` setup needs no changes. + +| Variable | Default | Purpose | +|----------|---------|---------| +| `TELEPI_TRANSCRIPTION_API_KEY` | `OPENAI_API_KEY` | Credential for the endpoint | +| `TELEPI_TRANSCRIPTION_URL` | `https://api.openai.com/v1/audio/transcriptions` | Full endpoint URL | +| `TELEPI_TRANSCRIPTION_MODEL` | `whisper-1` | Value sent in the `model` field | +| `TELEPI_TRANSCRIPTION_AUTH_HEADER` | unset | Header name for the key. Unset sends `Authorization: Bearer `; set sends `
: ` raw | +| `TELEPI_TRANSCRIPTION_PROMPT` | unset | Sent as the API's `prompt` field. Free-form context that biases decoding toward names and jargon. Omitted entirely when unset | + +A prompt is worth setting if you dictate proper nouns the model does not know. +Write it as a **context sentence**, not a bare word list - the same wording as a +list performed noticeably worse in testing: + +``` +TELEPI_TRANSCRIPTION_PROMPT=Conversation about software development. Terms: Kubernetes, Grafana, worktree, ... +``` + +Support varies by provider and even by model within one provider, and a model +that ignores the field does so silently. Transcribe the same clip with and +without it before trusting it. + +Example, using SipPulse AI: + +``` +TELEPI_TRANSCRIPTION_API_KEY=... +TELEPI_TRANSCRIPTION_URL=https://api.sippulse.ai/openai/audio/transcriptions +TELEPI_TRANSCRIPTION_MODEL=pulse-precision-pro +``` + +`TELEPI_TRANSCRIPTION_AUTH_HEADER` exists for providers that do not accept the +Bearer form and expect their own header instead, such as `api-key`. + ## Session Tree Navigation Every prompt and response in Pi is stored as a tree of entries. TelePi exposes this tree so you can review history and jump back to any point to create a new branch. diff --git a/src/voice.ts b/src/voice.ts index de2c0e8..64ed5f8 100644 --- a/src/voice.ts +++ b/src/voice.ts @@ -60,6 +60,25 @@ const SHERPA_ONNX_MODEL_DIR_ENV = "SHERPA_ONNX_MODEL_DIR"; const SHERPA_ONNX_NUM_THREADS_ENV = "SHERPA_ONNX_NUM_THREADS"; const SHERPA_MODEL_DOCS_URL = "https://k2-fsa.github.io/sherpa/onnx/pretrained_models/offline-transducer/nemo-transducer-models.html"; + +// The cloud backend talks plain multipart to an OpenAI-shaped endpoint, so any +// provider that implements /audio/transcriptions works once the URL, the model +// name and the credential are not hard-coded. Every default below reproduces +// the previous behaviour exactly, so an existing OPENAI_API_KEY setup keeps +// working with no new configuration. +const TRANSCRIPTION_URL_ENV = "TELEPI_TRANSCRIPTION_URL"; +const TRANSCRIPTION_MODEL_ENV = "TELEPI_TRANSCRIPTION_MODEL"; +const TRANSCRIPTION_API_KEY_ENV = "TELEPI_TRANSCRIPTION_API_KEY"; +// Some OpenAI-compatible providers authenticate with their own header instead +// of `Authorization: Bearer` (SipPulse uses `api-key`). When this is set the +// key is sent raw under that header; when it is unset the Bearer form is used. +const TRANSCRIPTION_AUTH_HEADER_ENV = "TELEPI_TRANSCRIPTION_AUTH_HEADER"; +// Optional `prompt` field of the OpenAI transcription API: free-form context +// that biases decoding toward names and jargon the model would otherwise miss. +// Sent only when set, so providers that reject the field are unaffected. +const TRANSCRIPTION_PROMPT_ENV = "TELEPI_TRANSCRIPTION_PROMPT"; +const DEFAULT_TRANSCRIPTION_URL = "https://api.openai.com/v1/audio/transcriptions"; +const DEFAULT_TRANSCRIPTION_MODEL = "whisper-1"; const FFMPEG_INSTALL_MESSAGE = `ffmpeg not found. Install it with: ${getPlatformInstallHint("ffmpeg")}`; const NO_BACKEND_ERROR = `Voice messages require a transcription backend. @@ -75,7 +94,14 @@ Option 2: Install Sherpa-ONNX for local/offline Parakeet transcription on Intel- Set ${SHERPA_ONNX_MODEL_DIR_ENV}=/path/to/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8 Option 3: Set OPENAI_API_KEY for cloud transcription (~$0.006/min): - Add OPENAI_API_KEY=sk-... to your .env file`; + Add OPENAI_API_KEY=sk-... to your .env file + + Any OpenAI-compatible transcription endpoint works instead: + ${TRANSCRIPTION_API_KEY_ENV}=... (falls back to OPENAI_API_KEY) + ${TRANSCRIPTION_URL_ENV}=https://provider.example/v1/audio/transcriptions + ${TRANSCRIPTION_MODEL_ENV}=provider-model-name + ${TRANSCRIPTION_AUTH_HEADER_ENV}=api-key (only if the provider does not use Bearer) + ${TRANSCRIPTION_PROMPT_ENV}=... (context to bias names and jargon)`; const _require = createRequire(import.meta.url); let _importModule: (specifier: string) => Promise = async (specifier) => _require(specifier); @@ -311,11 +337,15 @@ async function transcribeWithSherpaOnnx( } async function transcribeWithOpenAI(filePath: string): Promise { - const apiKey = process.env.OPENAI_API_KEY?.trim(); + const apiKey = resolveTranscriptionApiKey(); if (!apiKey) { throw new Error(NO_BACKEND_ERROR); } + const endpoint = process.env[TRANSCRIPTION_URL_ENV]?.trim() || DEFAULT_TRANSCRIPTION_URL; + const model = process.env[TRANSCRIPTION_MODEL_ENV]?.trim() || DEFAULT_TRANSCRIPTION_MODEL; + const authHeader = process.env[TRANSCRIPTION_AUTH_HEADER_ENV]?.trim(); + const startedAt = Date.now(); const audioBuffer = await readFile(filePath); const ext = (path.extname(filePath) || ".ogg").slice(1).toLowerCase(); @@ -327,26 +357,28 @@ async function transcribeWithOpenAI(filePath: string): Promise "")).trim(); throw new Error( - `OpenAI transcription failed (${response.status}): ${errorText || response.statusText || "Unknown error"}`, + `OpenAI transcription failed (${response.status}): ${errorText || response.statusText || "Unknown error"} [endpoint: ${endpoint}]`, ); } const payload = (await response.json()) as { text?: unknown }; if (typeof payload.text !== "string") { - throw new Error("OpenAI transcription response did not include a text field"); + throw new Error(`OpenAI transcription response did not include a text field [endpoint: ${endpoint}]`); } return { @@ -504,8 +536,12 @@ function decodeAudioToSamples(filePath: string): Promise { }); } +function resolveTranscriptionApiKey(): string | undefined { + return process.env[TRANSCRIPTION_API_KEY_ENV]?.trim() || process.env.OPENAI_API_KEY?.trim() || undefined; +} + function hasOpenAIApiKey(): boolean { - return Boolean(process.env.OPENAI_API_KEY?.trim()); + return Boolean(resolveTranscriptionApiKey()); } function extractTranscribedText(result: unknown): string | undefined { diff --git a/test/voice.test.ts b/test/voice.test.ts index 7705a93..72c1e86 100644 --- a/test/voice.test.ts +++ b/test/voice.test.ts @@ -13,8 +13,19 @@ import { transcribeAudio, } from "../src/voice.js"; +const TRANSCRIPTION_ENV_KEYS = [ + "TELEPI_TRANSCRIPTION_API_KEY", + "TELEPI_TRANSCRIPTION_URL", + "TELEPI_TRANSCRIPTION_MODEL", + "TELEPI_TRANSCRIPTION_AUTH_HEADER", + "TELEPI_TRANSCRIPTION_PROMPT", +] as const; + describe("voice transcription", () => { const originalOpenAIKey = process.env.OPENAI_API_KEY; + const originalTranscriptionEnv = Object.fromEntries( + TRANSCRIPTION_ENV_KEYS.map((key) => [key, process.env[key]]), + ); const originalSherpaModelDir = process.env.SHERPA_ONNX_MODEL_DIR; const originalSherpaNumThreads = process.env.SHERPA_ONNX_NUM_THREADS; @@ -55,6 +66,9 @@ describe("voice transcription", () => { delete process.env.OPENAI_API_KEY; delete process.env.SHERPA_ONNX_MODEL_DIR; delete process.env.SHERPA_ONNX_NUM_THREADS; + for (const key of TRANSCRIPTION_ENV_KEYS) { + delete process.env[key]; + } _resetImportHook(); vi.unstubAllGlobals(); }); @@ -70,6 +84,15 @@ describe("voice transcription", () => { process.env.OPENAI_API_KEY = originalOpenAIKey; } + for (const key of TRANSCRIPTION_ENV_KEYS) { + const original = originalTranscriptionEnv[key]; + if (original === undefined) { + delete process.env[key]; + } else { + process.env[key] = original; + } + } + if (originalSherpaModelDir === undefined) { delete process.env.SHERPA_ONNX_MODEL_DIR; } else { @@ -324,6 +347,109 @@ describe("voice transcription", () => { ); }); + it("honours a custom transcription endpoint, model and auth header", async () => { + _setImportHook(async (specifier) => { + if (specifier === "parakeet-coreml") { + throw moduleNotFound("parakeet-coreml"); + } + throw new Error(`unexpected import: ${specifier}`); + }); + process.env.TELEPI_TRANSCRIPTION_API_KEY = "sp-test"; + process.env.TELEPI_TRANSCRIPTION_URL = "https://api.sippulse.ai/openai/audio/transcriptions"; + process.env.TELEPI_TRANSCRIPTION_MODEL = "pulse-precision-pro"; + process.env.TELEPI_TRANSCRIPTION_AUTH_HEADER = "api-key"; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ text: "custom transcript" }), + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await transcribeAudio(audioPath); + + expect(result).toMatchObject({ text: "custom transcript", backend: "openai" }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.sippulse.ai/openai/audio/transcriptions", + expect.objectContaining({ + method: "POST", + headers: { "api-key": "sp-test" }, + body: expect.any(FormData), + }), + ); + const [, init] = fetchMock.mock.calls[0] as [string, { body: FormData }]; + expect(init.body.get("model")).toBe("pulse-precision-pro"); + }); + + it("keeps the Bearer form when only the endpoint and model are overridden", async () => { + _setImportHook(async (specifier) => { + if (specifier === "parakeet-coreml") { + throw moduleNotFound("parakeet-coreml"); + } + throw new Error(`unexpected import: ${specifier}`); + }); + process.env.TELEPI_TRANSCRIPTION_API_KEY = "sp-test"; + process.env.TELEPI_TRANSCRIPTION_URL = "https://api.sippulse.ai/openai/audio/transcriptions"; + process.env.TELEPI_TRANSCRIPTION_MODEL = "pulse-precision-pro"; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ text: "bearer transcript" }), + }); + vi.stubGlobal("fetch", fetchMock); + + await transcribeAudio(audioPath); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.sippulse.ai/openai/audio/transcriptions", + expect.objectContaining({ headers: { Authorization: "Bearer sp-test" } }), + ); + }); + + it("prefers TELEPI_TRANSCRIPTION_API_KEY over OPENAI_API_KEY", async () => { + _setImportHook(async (specifier) => { + if (specifier === "parakeet-coreml") { + throw moduleNotFound("parakeet-coreml"); + } + throw new Error(`unexpected import: ${specifier}`); + }); + process.env.OPENAI_API_KEY = "sk-test"; + process.env.TELEPI_TRANSCRIPTION_API_KEY = "sp-test"; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ text: "precedence transcript" }), + }); + vi.stubGlobal("fetch", fetchMock); + + await transcribeAudio(audioPath); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.openai.com/v1/audio/transcriptions", + expect.objectContaining({ headers: { Authorization: "Bearer sp-test" } }), + ); + }); + + it("sends the transcription prompt only when it is set", async () => { + _setImportHook(async (specifier) => { + if (specifier === "parakeet-coreml") { + throw moduleNotFound("parakeet-coreml"); + } + throw new Error(`unexpected import: ${specifier}`); + }); + process.env.OPENAI_API_KEY = "sk-test"; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ text: "t" }), + }); + vi.stubGlobal("fetch", fetchMock); + + await transcribeAudio(audioPath); + const [, withoutPrompt] = fetchMock.mock.calls[0] as [string, { body: FormData }]; + expect(withoutPrompt.body.get("prompt")).toBeNull(); + + process.env.TELEPI_TRANSCRIPTION_PROMPT = "Hipatia, worktree, herdr"; + await transcribeAudio(audioPath); + const [, withPrompt] = fetchMock.mock.calls[1] as [string, { body: FormData }]; + expect(withPrompt.body.get("prompt")).toBe("Hipatia, worktree, herdr"); + }); + it("throws a helpful error when no backend is available", async () => { _setImportHook(async (specifier) => { if (specifier === "parakeet-coreml") {