diff --git a/open-sse/handlers/audioTranscription.ts b/open-sse/handlers/audioTranscription.ts index 9fe9d2277e..da4e1ccfd4 100644 --- a/open-sse/handlers/audioTranscription.ts +++ b/open-sse/handlers/audioTranscription.ts @@ -69,8 +69,24 @@ function isValidPathSegment(segment: string): boolean { return !segment.includes("..") && !segment.includes("//"); } +/** + * A `.opus` file is Opus audio in an Ogg container (RFC 7845) — the same bytes + * a client would otherwise name `.ogg`. Whisper-compatible upstreams pick the + * decoder from the *filename* and their allow-list + * (`flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav, webm`) has no `opus`, so + * `note.opus` 400s while byte-identical `note.ogg` succeeds. Since + * `/v1/audio/speech` emits `audio/opus` for `response_format=opus`, clients + * round-tripping their own voice notes hit this constantly. Relabel to the + * container that actually describes the bytes. + */ +function normalizeUploadExtension(name: string): string { + return name.replace(/\.opus$/i, ".ogg"); +} + function getUploadedFileName(file: Blob & { name?: unknown }): string { - return typeof file.name === "string" && file.name.length > 0 ? file.name : "audio.wav"; + return typeof file.name === "string" && file.name.length > 0 + ? normalizeUploadExtension(file.name) + : "audio.wav"; } /** diff --git a/open-sse/handlers/openrouterTranscription.ts b/open-sse/handlers/openrouterTranscription.ts index 3c2f7796cb..474fd41321 100644 --- a/open-sse/handlers/openrouterTranscription.ts +++ b/open-sse/handlers/openrouterTranscription.ts @@ -21,6 +21,10 @@ import { upstreamErrorResponse } from "./audioTranscription.ts"; export function resolveOpenRouterAudioFormat(file: Blob & { name?: unknown }): string { const fileName = typeof file.name === "string" ? file.name.toLowerCase() : ""; const extension = fileName.includes(".") ? fileName.split(".").pop() || "" : ""; + // `.opus` is Ogg-encapsulated Opus (RFC 7845). Without this it matched + // neither the extension list nor the MIME map below and fell through to the + // "wav" default, so Opus bytes were announced to the upstream as WAV. + if (extension === "opus") return "ogg"; if (["wav", "mp3", "flac", "m4a", "ogg", "webm", "aac"].includes(extension)) { return extension; } @@ -33,6 +37,7 @@ export function resolveOpenRouterAudioFormat(file: Blob & { name?: unknown }): s "audio/x-flac": "flac", "audio/mp4": "m4a", "audio/ogg": "ogg", + "audio/opus": "ogg", "audio/webm": "webm", "audio/aac": "aac", }; diff --git a/tests/unit/audio-transcription-opus-filename.test.ts b/tests/unit/audio-transcription-opus-filename.test.ts new file mode 100644 index 0000000000..f54af8df68 --- /dev/null +++ b/tests/unit/audio-transcription-opus-filename.test.ts @@ -0,0 +1,88 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildMultipartBody } = await import("../../open-sse/handlers/audioTranscription.ts"); +const { resolveOpenRouterAudioFormat } = await import( + "../../open-sse/handlers/openrouterTranscription.ts" +); + +/** + * `.opus` is Opus audio in an Ogg container (RFC 7845) — byte-identical to what + * a client would name `.ogg`. Whisper-compatible upstreams select the decoder + * from the multipart *filename* against an allow-list + * (`flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav, webm`) that has no `opus`, + * so `note.opus` used to 400 while the same bytes as `note.ogg` transcribed + * fine. `/v1/audio/speech` emits `audio/opus` for `response_format=opus`, so + * clients re-uploading their own voice notes hit it constantly (#10588). + */ + +function audioFile(name: string, type = "") { + return Object.assign(new Blob([new Uint8Array([1, 2, 3, 4])], { type }), { name }); +} + +function filenameIn(body: Uint8Array): string { + const header = new TextDecoder().decode(body); + return /filename="([^"]*)"/.exec(header)?.[1] ?? ""; +} + +test("a .opus upload is announced to the upstream as .ogg", async () => { + const { body } = await buildMultipartBody(audioFile("note.opus", "audio/opus"), { + model: "whisper-1", + }); + + assert.equal(filenameIn(body), "note.ogg"); +}); + +test("the rewrite is case-insensitive and keeps the rest of the name intact", async () => { + const { body } = await buildMultipartBody(audioFile("Voice Note 2026.OPUS"), { + model: "whisper-1", + }); + + assert.equal(filenameIn(body), "Voice Note 2026.ogg"); +}); + +test("only a trailing .opus is rewritten — not one mid-name", async () => { + // `opus` appearing anywhere else is part of the name, not the container. + const { body } = await buildMultipartBody(audioFile("opus-demo.wav"), { model: "whisper-1" }); + assert.equal(filenameIn(body), "opus-demo.wav"); + + const nested = await buildMultipartBody(audioFile("take.opus.mp3"), { model: "whisper-1" }); + assert.equal(filenameIn(nested.body), "take.opus.mp3"); +}); + +test("other extensions are forwarded unchanged", async () => { + for (const name of ["note.ogg", "note.mp3", "note.wav", "note.webm"]) { + const { body } = await buildMultipartBody(audioFile(name), { model: "whisper-1" }); + assert.equal(filenameIn(body), name); + } +}); + +test("a nameless blob still falls back to audio.wav", async () => { + const blob = new Blob([new Uint8Array([1, 2, 3, 4])], { type: "audio/wav" }); + const { body } = await buildMultipartBody(blob as Blob & { name?: unknown }, { + model: "whisper-1", + }); + + assert.equal(filenameIn(body), "audio.wav"); +}); + +/** + * The OpenRouter STT endpoint takes the container as a JSON field rather than a + * filename. `.opus` matched neither its extension list nor its MIME map, so it + * fell through to the `"wav"` default — announcing Opus bytes as WAV. + */ +test("OpenRouter STT resolves .opus to its ogg container, not the wav default", () => { + assert.equal(resolveOpenRouterAudioFormat(audioFile("note.opus")), "ogg"); + assert.equal(resolveOpenRouterAudioFormat(audioFile("note.OPUS")), "ogg"); +}); + +test("OpenRouter STT resolves an audio/opus MIME to ogg", () => { + assert.equal(resolveOpenRouterAudioFormat(audioFile("blob", "audio/opus")), "ogg"); +}); + +test("OpenRouter STT still resolves the formats it already supported", () => { + assert.equal(resolveOpenRouterAudioFormat(audioFile("a.ogg")), "ogg"); + assert.equal(resolveOpenRouterAudioFormat(audioFile("a.mp3")), "mp3"); + assert.equal(resolveOpenRouterAudioFormat(audioFile("blob", "audio/webm;codecs=opus")), "webm"); + assert.equal(resolveOpenRouterAudioFormat(audioFile("mystery.xyz")), "wav"); +});