Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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 <key>`; set sends `<header>: <key>` 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.
Expand Down
56 changes: 46 additions & 10 deletions src/voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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<unknown> = async (specifier) => _require(specifier);
Expand Down Expand Up @@ -311,11 +337,15 @@ async function transcribeWithSherpaOnnx(
}

async function transcribeWithOpenAI(filePath: string): Promise<TranscriptionResult> {
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();
Expand All @@ -327,26 +357,28 @@ async function transcribeWithOpenAI(filePath: string): Promise<TranscriptionResu
const mimeType = mimeTypes[ext] ?? "audio/ogg";
const form = new FormData();
form.append("file", new Blob([audioBuffer], { type: mimeType }), path.basename(filePath) || "audio.ogg");
form.append("model", "whisper-1");
form.append("model", model);
const transcriptionPrompt = process.env[TRANSCRIPTION_PROMPT_ENV]?.trim();
if (transcriptionPrompt) {
form.append("prompt", transcriptionPrompt);
}

const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
},
headers: authHeader ? { [authHeader]: apiKey } : { Authorization: `Bearer ${apiKey}` },
body: form,
});

if (!response.ok) {
const errorText = (await response.text().catch(() => "")).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 {
Expand Down Expand Up @@ -504,8 +536,12 @@ function decodeAudioToSamples(filePath: string): Promise<Float32Array> {
});
}

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 {
Expand Down
126 changes: 126 additions & 0 deletions test/voice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
});
Expand All @@ -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 {
Expand Down Expand Up @@ -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") {
Expand Down