From 40814d1f8836a1e78a6a520034321bc6d497b344 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 8 Sep 2026 17:15:35 +0100 Subject: [PATCH 1/3] fix(speech-to-text): write the transcoded .wav under tmp/ like the extension The extension named the transcoded WAV after its local temp file, so the object landed at tmp/.wav, prefixed with OUTPUT_STORAGE_PATH verbatim when set (no separator normalisation, so a trailing slash gave a double slash). The kit derived the name from the input object and normalised the prefix, which moved the file for migrated users. Issue #3140 decided on parity, so the kit now reproduces the extension's path exactly. The .txt transcript path is unchanged. --- kits/speech-to-text/CHANGELOG.md | 1 + kits/speech-to-text/README.md | 42 +++++++++++----------- kits/speech-to-text/src/handlers.ts | 11 ++---- kits/speech-to-text/tests/handlers.test.ts | 38 ++++++++++++++++---- 4 files changed, 56 insertions(+), 36 deletions(-) diff --git a/kits/speech-to-text/CHANGELOG.md b/kits/speech-to-text/CHANGELOG.md index 628c00529e..14f8705baf 100644 --- a/kits/speech-to-text/CHANGELOG.md +++ b/kits/speech-to-text/CHANGELOG.md @@ -1,3 +1,4 @@ +- fix: the transcoded `.wav` is written to `tmp/.wav` again (under `OUTPUT_STORAGE_PATH` when set, joined with a single `/` and no trailing-slash normalisation), matching the legacy extension so existing consumers keep finding it ([#3140](https://github.com/firebase/extensions/issues/3140)) - fix: restore the extension's `Enabled` / `Disabled` option labels on the `ENABLE_AUTOMATIC_PUNCTUATION` deploy-time prompt. The stored values are unchanged (`true`/`false`), so this is a label-only fix and no `.env` from an earlier deploy needs editing. - Initial release of kit, see README for differences between the legacy extension and this kit - The `.txt` transcription output no longer has `tmp/` stripped from its path, a remnant of the legacy extension's temp-file handling; it lands at `_transcription.txt` exactly ([#3026](https://github.com/firebase/extensions/issues/3026)) diff --git a/kits/speech-to-text/README.md b/kits/speech-to-text/README.md index 0e2a92ec62..9a7bc7f310 100644 --- a/kits/speech-to-text/README.md +++ b/kits/speech-to-text/README.md @@ -134,27 +134,27 @@ ffmpeg transcode to LINEAR16, the same long-running recognition request, the sam per-channel transcript map, the same Firestore progress document and the same two Eventarc events. Every setting keeps its extension environment variable name and default, so a `.env` copied from your installed instance needs no value changes. -What changes is where the intermediate audio file is written, how long the -function may run, and what is no longer checked for you. - -### The transcoded copy no longer lands under `tmp/` - -The extension named the transcoded WAV after the local temporary file it had just -written, so with no `OUTPUT_STORAGE_PATH` the copy appeared in your bucket as -`tmp/.wav`, and with `OUTPUT_STORAGE_PATH: transcriptions` as -`transcriptions/tmp/.wav`. The kit names it after the original -object instead: `.wav`, or -`transcriptions/.wav`. - -The transcript itself is written to the same place as before -(`.wav_transcription.txt`, under `OUTPUT_STORAGE_PATH` when set), -so only the intermediate audio moves. If you have lifecycle rules, cleanup jobs -or client code that expect the WAV under a `tmp/` prefix, point them at the new -path. The transcoded `.wav` still carries the `isTranscodeOutput` metadata flag -that stops the function from processing its own output. The transcript `.txt` is -written directly by the Speech-to-Text API and carries no metadata, so its -finalize event runs the function again; that run creates a transcript document -for the `.txt` object and marks it `FAILED` with "Invalid content type.". +What changes is how long the function may run and what is no longer checked +for you. + +### Where the transcoded copy is written + +The transcoded WAV is written to `tmp/.wav`, or +`/tmp/.wav` when `OUTPUT_STORAGE_PATH` is +set. The `tmp/` segment is an artefact of the extension naming the copy after +its local temporary file; the kit keeps it so lifecycle rules, cleanup jobs and +client code written against the extension keep finding the file. The prefix is +joined with a single `/` and is not normalised, so a trailing slash on +`OUTPUT_STORAGE_PATH` produces a double slash (`transcriptions//tmp/a.mp3.wav`), +exactly as the extension did. + +The transcript is written next to the WAV as +`_transcription.txt`. The transcoded `.wav` carries the +`isTranscodeOutput` metadata flag that stops the function from processing its +own output. The transcript `.txt` is written directly by the Speech-to-Text API +and carries no metadata, so its finalize event runs the function again; that run +creates a transcript document for the `.txt` object and marks it `FAILED` with +"Invalid content type.". ### The function may now run for nine minutes diff --git a/kits/speech-to-text/src/handlers.ts b/kits/speech-to-text/src/handlers.ts index 7208389269..a36dc1af41 100644 --- a/kits/speech-to-text/src/handlers.ts +++ b/kits/speech-to-text/src/handlers.ts @@ -148,18 +148,11 @@ export async function handleObjectFinalized( message: "Transcoding audio file.", }); - /** - * Bucket-relative object name for the transcoded file, derived from the - * input object's path/name (not the local `/tmp` path). - */ - const transcodedObjectName = `${filePath}.wav`; + const transcodedObjectName = `tmp/${filePath}.wav`; const transcodedUploadResult = await ctx.fns.uploadTranscodedFile({ localPath: localTranscodedPath, storagePath: config.outputStoragePath - ? `${config.outputStoragePath.replace( - /\/$/, - "" - )}/${transcodedObjectName}` + ? `${config.outputStoragePath}/${transcodedObjectName}` : transcodedObjectName, bucket, }); diff --git a/kits/speech-to-text/tests/handlers.test.ts b/kits/speech-to-text/tests/handlers.test.ts index a94d5c80e1..32246bf97b 100644 --- a/kits/speech-to-text/tests/handlers.test.ts +++ b/kits/speech-to-text/tests/handlers.test.ts @@ -277,7 +277,7 @@ describe("handleObjectFinalized", () => { expect(ctx.events.recordCompleteEvent).toHaveBeenCalledTimes(1); }); - test("uploads the transcoded file to a bucket-relative path, not the /tmp path", async () => { + test("uploads the transcoded file under tmp/ with the full input object path", async () => { const ctx = makeCtx(); await handleObjectFinalized( @@ -288,21 +288,47 @@ describe("handleObjectFinalized", () => { expect(ctx.fns.uploadTranscodedFile).toHaveBeenCalledWith( expect.objectContaining({ localPath: normalize("/tmp/nested/clip.mp3.wav"), - storagePath: "nested/clip.mp3.wav", + storagePath: "tmp/nested/clip.mp3.wav", }) ); }); - test("prefixes the transcoded object with outputStoragePath without leaking /tmp", async () => { - const ctx = makeCtx({ config: { outputStoragePath: "transcoded/" } }); + test("writes the transcoded file to tmp/.wav when outputStoragePath is unset", async () => { + const ctx = makeCtx(); + + await handleObjectFinalized( + storageEvent({ ...audioObject, name: "a.mp3" }), + ctx + ); + + expect(ctx.fns.uploadTranscodedFile).toHaveBeenCalledWith( + expect.objectContaining({ storagePath: "tmp/a.mp3.wav" }) + ); + }); + + test("joins outputStoragePath and tmp/.wav with a single slash", async () => { + const ctx = makeCtx({ config: { outputStoragePath: "transcriptions" } }); + + await handleObjectFinalized( + storageEvent({ ...audioObject, name: "a.mp3" }), + ctx + ); + + expect(ctx.fns.uploadTranscodedFile).toHaveBeenCalledWith( + expect.objectContaining({ storagePath: "transcriptions/tmp/a.mp3.wav" }) + ); + }); + + test("keeps a trailing slash on outputStoragePath, producing a double slash", async () => { + const ctx = makeCtx({ config: { outputStoragePath: "transcriptions/" } }); await handleObjectFinalized( - storageEvent({ ...audioObject, name: "clip.mp3" }), + storageEvent({ ...audioObject, name: "a.mp3" }), ctx ); expect(ctx.fns.uploadTranscodedFile).toHaveBeenCalledWith( - expect.objectContaining({ storagePath: "transcoded/clip.mp3.wav" }) + expect.objectContaining({ storagePath: "transcriptions//tmp/a.mp3.wav" }) ); }); From 52229244299ed8533c382d55a64a05274142e54f Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Wed, 9 Sep 2026 14:06:01 +0100 Subject: [PATCH 2/3] fix(speech-to-text): keep the transcript at the extension's path Restoring the `tmp/` segment on the transcoded `.wav` moved the transcript too, because the Speech API output was named after the uploaded object. Strip the segment back off when naming the transcript, as the extension did, and build the object path with `path.posix.join` so it gets the normalisation the extension got from `path.join(os.tmpdir(), filePath)`. --- kits/speech-to-text/CHANGELOG.md | 3 +- kits/speech-to-text/README.md | 33 +++++---- kits/speech-to-text/src/handlers.ts | 36 ++++++++- kits/speech-to-text/src/transcribe.ts | 12 ++- kits/speech-to-text/tests/handlers.test.ts | 77 ++++++++++++++++++++ kits/speech-to-text/tests/transcribe.test.ts | 5 +- 6 files changed, 142 insertions(+), 24 deletions(-) diff --git a/kits/speech-to-text/CHANGELOG.md b/kits/speech-to-text/CHANGELOG.md index 14f8705baf..5e29123765 100644 --- a/kits/speech-to-text/CHANGELOG.md +++ b/kits/speech-to-text/CHANGELOG.md @@ -1,4 +1,3 @@ -- fix: the transcoded `.wav` is written to `tmp/.wav` again (under `OUTPUT_STORAGE_PATH` when set, joined with a single `/` and no trailing-slash normalisation), matching the legacy extension so existing consumers keep finding it ([#3140](https://github.com/firebase/extensions/issues/3140)) +- fix: the transcoded `.wav` and the `.txt` transcript are written where the legacy extension wrote them, `tmp/.wav` and `.wav_transcription.txt`, both under `OUTPUT_STORAGE_PATH` when set and with no trailing-slash normalisation, so existing consumers keep finding them ([#3140](https://github.com/firebase/extensions/issues/3140), [#3026](https://github.com/firebase/extensions/issues/3026)) - fix: restore the extension's `Enabled` / `Disabled` option labels on the `ENABLE_AUTOMATIC_PUNCTUATION` deploy-time prompt. The stored values are unchanged (`true`/`false`), so this is a label-only fix and no `.env` from an earlier deploy needs editing. - Initial release of kit, see README for differences between the legacy extension and this kit -- The `.txt` transcription output no longer has `tmp/` stripped from its path, a remnant of the legacy extension's temp-file handling; it lands at `_transcription.txt` exactly ([#3026](https://github.com/firebase/extensions/issues/3026)) diff --git a/kits/speech-to-text/README.md b/kits/speech-to-text/README.md index 9a7bc7f310..ccc8845d89 100644 --- a/kits/speech-to-text/README.md +++ b/kits/speech-to-text/README.md @@ -137,19 +137,26 @@ default, so a `.env` copied from your installed instance needs no value changes. What changes is how long the function may run and what is no longer checked for you. -### Where the transcoded copy is written - -The transcoded WAV is written to `tmp/.wav`, or -`/tmp/.wav` when `OUTPUT_STORAGE_PATH` is -set. The `tmp/` segment is an artefact of the extension naming the copy after -its local temporary file; the kit keeps it so lifecycle rules, cleanup jobs and -client code written against the extension keep finding the file. The prefix is -joined with a single `/` and is not normalised, so a trailing slash on -`OUTPUT_STORAGE_PATH` produces a double slash (`transcriptions//tmp/a.mp3.wav`), -exactly as the extension did. - -The transcript is written next to the WAV as -`_transcription.txt`. The transcoded `.wav` carries the +### Where the outputs land + +Both outputs keep the paths the extension used, so migrated consumers find them +unchanged. For an input object `a.mp3`: + +| `OUTPUT_STORAGE_PATH` | Transcoded audio | Transcript | +| --- | --- | --- | +| unset | `tmp/a.mp3.wav` | `a.mp3.wav_transcription.txt` | +| `transcriptions` | `transcriptions/tmp/a.mp3.wav` | `transcriptions/a.mp3.wav_transcription.txt` | +| `transcriptions/` | `transcriptions//tmp/a.mp3.wav` | `transcriptions//a.mp3.wav_transcription.txt` | + +The `tmp/` segment on the audio is an artefact of the extension naming the copy +after its local temporary file, and is kept so lifecycle rules, cleanup jobs and +client code written against the extension keep finding it. The transcript is +named after the same object with that segment removed, again as the extension +did, so it sits beside your input rather than under `tmp/`. +`OUTPUT_STORAGE_PATH` is not normalised, so a trailing slash produces a double +slash in both paths. + +The transcoded `.wav` carries the `isTranscodeOutput` metadata flag that stops the function from processing its own output. The transcript `.txt` is written directly by the Speech-to-Text API and carries no metadata, so its finalize event runs the function again; that run diff --git a/kits/speech-to-text/src/handlers.ts b/kits/speech-to-text/src/handlers.ts index a36dc1af41..85d00ee942 100644 --- a/kits/speech-to-text/src/handlers.ts +++ b/kits/speech-to-text/src/handlers.ts @@ -148,12 +148,27 @@ export async function handleObjectFinalized( message: "Transcoding audio file.", }); - const transcodedObjectName = `tmp/${filePath}.wav`; + /** + * The extension named the transcoded upload after its local temp file, so + * the object carries a leading `tmp/` segment. The segment is deliberate + * parity: lifecycle rules and client code written against the extension + * look for the file there. `path.posix.join` supplies the normalisation the + * extension got for free from `path.join(os.tmpdir(), filePath)`. + */ + const transcodedObjectName = path.posix.join("tmp", `${filePath}.wav`); + + /** + * `OUTPUT_STORAGE_PATH` is concatenated without normalising, so a trailing + * slash still yields the extension's double slash. + */ + const withOutputPrefix = (objectName: string) => + config.outputStoragePath + ? `${config.outputStoragePath}/${objectName}` + : objectName; + const transcodedUploadResult = await ctx.fns.uploadTranscodedFile({ localPath: localTranscodedPath, - storagePath: config.outputStoragePath - ? `${config.outputStoragePath}/${transcodedObjectName}` - : transcodedObjectName, + storagePath: withOutputPrefix(transcodedObjectName), bucket, }); @@ -167,9 +182,22 @@ export async function handleObjectFinalized( const { sampleRateHertz, audioChannelCount } = transcodeResult; const [file] = transcodedUploadResult.uploadResponse; + /** + * The extension stripped the `tmp/` segment back off before naming the + * Speech API's output, so the transcript sits beside the input object + * rather than under `tmp/`. Only the segment added above is removed, so a + * `tmp/` inside the user's own object name survives; the extension used a + * first-substring replace and would also have stripped one occurring in + * `OUTPUT_STORAGE_PATH`. + */ + const transcriptObjectName = `${withOutputPrefix( + transcodedObjectName.replace(/^tmp\//, "") + )}_transcription.txt`; + const transcriptionResult = await ctx.fns.transcribeAndUpload({ client, file, + transcriptObjectName, sampleRateHertz, audioChannelCount, options: speechOptions, diff --git a/kits/speech-to-text/src/transcribe.ts b/kits/speech-to-text/src/transcribe.ts index 9bbd2faa18..ebf813e752 100644 --- a/kits/speech-to-text/src/transcribe.ts +++ b/kits/speech-to-text/src/transcribe.ts @@ -47,25 +47,31 @@ export interface SpeechOptions { * The operation is polled to completion in-process, so the host function must * allow a long timeout for lengthy audio. * - * @param args - The Speech client, the uploaded file, probed audio params and - * recognition options. + * @param args - The Speech client, the uploaded file, the object name to write + * the transcript to, probed audio params and recognition options. * @returns The transcription result, success or failure. */ export async function transcribeAndUpload({ client, file: { bucket, name }, + transcriptObjectName, sampleRateHertz, audioChannelCount, options, }: { client: SpeechClient; file: { bucket: Bucket; name: string }; + /** + * Complete bucket-relative object name for the transcript. The caller owns + * this path so the extension's naming rules live in one place. + */ + transcriptObjectName: string; sampleRateHertz: number; audioChannelCount: number; options: SpeechOptions; }): Promise { const inputUri = `gs://${bucket.name}/${name}`; - const outputUri = `gs://${bucket.name}/${name}_transcription.txt`; + const outputUri = `gs://${bucket.name}/${transcriptObjectName}`; const warnings: WarningType[] = []; const request: google.cloud.speech.v1.ILongRunningRecognizeRequest = { config: { diff --git a/kits/speech-to-text/tests/handlers.test.ts b/kits/speech-to-text/tests/handlers.test.ts index 32246bf97b..6a2f82c782 100644 --- a/kits/speech-to-text/tests/handlers.test.ts +++ b/kits/speech-to-text/tests/handlers.test.ts @@ -332,6 +332,83 @@ describe("handleObjectFinalized", () => { ); }); + test("normalises redundant separators in the input object path", async () => { + const ctx = makeCtx(); + + await handleObjectFinalized( + storageEvent({ ...audioObject, name: "audio//clip.mp3" }), + ctx + ); + + expect(ctx.fns.uploadTranscodedFile).toHaveBeenCalledWith( + expect.objectContaining({ storagePath: "tmp/audio/clip.mp3.wav" }) + ); + }); + + test("names the transcript without the tmp/ segment when outputStoragePath is unset", async () => { + const ctx = makeCtx(); + + await handleObjectFinalized( + storageEvent({ ...audioObject, name: "a.mp3" }), + ctx + ); + + expect(ctx.fns.transcribeAndUpload).toHaveBeenCalledWith( + expect.objectContaining({ + transcriptObjectName: "a.mp3.wav_transcription.txt", + }) + ); + }); + + test("names the transcript under outputStoragePath, outside tmp/", async () => { + const ctx = makeCtx({ config: { outputStoragePath: "transcriptions" } }); + + await handleObjectFinalized( + storageEvent({ ...audioObject, name: "nested/clip.mp3" }), + ctx + ); + + expect(ctx.fns.transcribeAndUpload).toHaveBeenCalledWith( + expect.objectContaining({ + transcriptObjectName: + "transcriptions/nested/clip.mp3.wav_transcription.txt", + }) + ); + }); + + test("keeps a trailing slash on outputStoragePath for the transcript too", async () => { + const ctx = makeCtx({ config: { outputStoragePath: "transcriptions/" } }); + + await handleObjectFinalized( + storageEvent({ ...audioObject, name: "a.mp3" }), + ctx + ); + + expect(ctx.fns.transcribeAndUpload).toHaveBeenCalledWith( + expect.objectContaining({ + transcriptObjectName: "transcriptions//a.mp3.wav_transcription.txt", + }) + ); + }); + + test("strips only the tmp/ segment it added, not one in the input object name", async () => { + const ctx = makeCtx(); + + await handleObjectFinalized( + storageEvent({ ...audioObject, name: "tmp/a.mp3" }), + ctx + ); + + expect(ctx.fns.uploadTranscodedFile).toHaveBeenCalledWith( + expect.objectContaining({ storagePath: "tmp/tmp/a.mp3.wav" }) + ); + expect(ctx.fns.transcribeAndUpload).toHaveBeenCalledWith( + expect.objectContaining({ + transcriptObjectName: "tmp/a.mp3.wav_transcription.txt", + }) + ); + }); + test("cleans up both temp files after a successful run", async () => { const ctx = makeCtx(); diff --git a/kits/speech-to-text/tests/transcribe.test.ts b/kits/speech-to-text/tests/transcribe.test.ts index 1ee65fa1b1..3bab0593de 100644 --- a/kits/speech-to-text/tests/transcribe.test.ts +++ b/kits/speech-to-text/tests/transcribe.test.ts @@ -78,7 +78,7 @@ describe("transcribeAndUpload", () => { vi.clearAllMocks(); }); - test("writes the .txt next to the uploaded object, even when its path contains tmp/", async () => { + test("writes the .txt to the transcript object name it is given", async () => { const longRunningRecognize = vi.fn().mockResolvedValue([ { promise: vi.fn().mockResolvedValue([ @@ -98,6 +98,7 @@ describe("transcribeAndUpload", () => { bucket: { name: "my-bucket" } as Bucket, name: "audio/tmp/clip.mp3.wav", }, + transcriptObjectName: "audio/clip.mp3.wav_transcription.txt", sampleRateHertz: 44100, audioChannelCount: 1, options: { @@ -112,7 +113,7 @@ describe("transcribeAndUpload", () => { expect.objectContaining({ audio: { uri: "gs://my-bucket/audio/tmp/clip.mp3.wav" }, outputConfig: { - gcsUri: "gs://my-bucket/audio/tmp/clip.mp3.wav_transcription.txt", + gcsUri: "gs://my-bucket/audio/clip.mp3.wav_transcription.txt", }, }) ); From 103cf4ef3b0e0325808051fb02e95fe1ef345d35 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Wed, 9 Sep 2026 15:09:59 +0100 Subject: [PATCH 3/3] fix(speech-to-text): strip a trailing slash on OUTPUT_STORAGE_PATH The extension concatenated the prefix raw, so `transcriptions/` produced `transcriptions//tmp/a.mp3.wav`. Cloud Storage accepts that object name but Speech-to-Text rejects the URI ("is an invalid GCS path"), so the audio was uploaded and no transcript was ever written. Verified on a live deploy: the transcript is missing and the Firestore document is left on "Transcoding audio file.". Matching the extension there would only reproduce the failure, so the slash is stripped and `transcriptions/` behaves like `transcriptions`. --- kits/speech-to-text/CHANGELOG.md | 2 +- kits/speech-to-text/README.md | 14 ++++++++++---- kits/speech-to-text/src/handlers.ts | 9 ++++++--- kits/speech-to-text/tests/handlers.test.ts | 8 ++++---- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/kits/speech-to-text/CHANGELOG.md b/kits/speech-to-text/CHANGELOG.md index 5e29123765..fc7736e5f3 100644 --- a/kits/speech-to-text/CHANGELOG.md +++ b/kits/speech-to-text/CHANGELOG.md @@ -1,3 +1,3 @@ -- fix: the transcoded `.wav` and the `.txt` transcript are written where the legacy extension wrote them, `tmp/.wav` and `.wav_transcription.txt`, both under `OUTPUT_STORAGE_PATH` when set and with no trailing-slash normalisation, so existing consumers keep finding them ([#3140](https://github.com/firebase/extensions/issues/3140), [#3026](https://github.com/firebase/extensions/issues/3026)) +- fix: the transcoded `.wav` and the `.txt` transcript are written where the legacy extension wrote them, `tmp/.wav` and `.wav_transcription.txt`, both under `OUTPUT_STORAGE_PATH` when set, so existing consumers keep finding them. A trailing slash on `OUTPUT_STORAGE_PATH` is stripped rather than doubled: the extension's double slash made the Speech-to-Text API reject the audio URI, so that configuration never produced a transcript ([#3140](https://github.com/firebase/extensions/issues/3140), [#3026](https://github.com/firebase/extensions/issues/3026)) - fix: restore the extension's `Enabled` / `Disabled` option labels on the `ENABLE_AUTOMATIC_PUNCTUATION` deploy-time prompt. The stored values are unchanged (`true`/`false`), so this is a label-only fix and no `.env` from an earlier deploy needs editing. - Initial release of kit, see README for differences between the legacy extension and this kit diff --git a/kits/speech-to-text/README.md b/kits/speech-to-text/README.md index ccc8845d89..0192492403 100644 --- a/kits/speech-to-text/README.md +++ b/kits/speech-to-text/README.md @@ -140,21 +140,27 @@ for you. ### Where the outputs land Both outputs keep the paths the extension used, so migrated consumers find them -unchanged. For an input object `a.mp3`: +unchanged, with one deliberate exception noted below. For an input object +`a.mp3`: | `OUTPUT_STORAGE_PATH` | Transcoded audio | Transcript | | --- | --- | --- | | unset | `tmp/a.mp3.wav` | `a.mp3.wav_transcription.txt` | | `transcriptions` | `transcriptions/tmp/a.mp3.wav` | `transcriptions/a.mp3.wav_transcription.txt` | -| `transcriptions/` | `transcriptions//tmp/a.mp3.wav` | `transcriptions//a.mp3.wav_transcription.txt` | +| `transcriptions/` | `transcriptions/tmp/a.mp3.wav` | `transcriptions/a.mp3.wav_transcription.txt` | The `tmp/` segment on the audio is an artefact of the extension naming the copy after its local temporary file, and is kept so lifecycle rules, cleanup jobs and client code written against the extension keep finding it. The transcript is named after the same object with that segment removed, again as the extension did, so it sits beside your input rather than under `tmp/`. -`OUTPUT_STORAGE_PATH` is not normalised, so a trailing slash produces a double -slash in both paths. +A trailing slash on `OUTPUT_STORAGE_PATH` is stripped. The extension +concatenated the prefix raw, so `transcriptions/` gave +`transcriptions//tmp/a.mp3.wav`, but the Speech-to-Text API rejects a `gs://` +URI containing a double slash, so that configuration uploaded the audio and +then failed without ever writing a transcript. The kit strips the slash instead, +which is the only difference from the extension's paths and only affects a +configuration that never worked. The transcoded `.wav` carries the `isTranscodeOutput` metadata flag that stops the function from processing its diff --git a/kits/speech-to-text/src/handlers.ts b/kits/speech-to-text/src/handlers.ts index 85d00ee942..263921d43e 100644 --- a/kits/speech-to-text/src/handlers.ts +++ b/kits/speech-to-text/src/handlers.ts @@ -158,12 +158,15 @@ export async function handleObjectFinalized( const transcodedObjectName = path.posix.join("tmp", `${filePath}.wav`); /** - * `OUTPUT_STORAGE_PATH` is concatenated without normalising, so a trailing - * slash still yields the extension's double slash. + * A trailing slash on `OUTPUT_STORAGE_PATH` is stripped. The extension + * concatenated the prefix raw, producing a double slash, but the Speech API + * rejects a `gs://` URI containing one ("is an invalid GCS path"), so that + * configuration never produced a transcript. Parity here would only + * reproduce the failure. */ const withOutputPrefix = (objectName: string) => config.outputStoragePath - ? `${config.outputStoragePath}/${objectName}` + ? `${config.outputStoragePath.replace(/\/$/, "")}/${objectName}` : objectName; const transcodedUploadResult = await ctx.fns.uploadTranscodedFile({ diff --git a/kits/speech-to-text/tests/handlers.test.ts b/kits/speech-to-text/tests/handlers.test.ts index 6a2f82c782..b6c123cdad 100644 --- a/kits/speech-to-text/tests/handlers.test.ts +++ b/kits/speech-to-text/tests/handlers.test.ts @@ -319,7 +319,7 @@ describe("handleObjectFinalized", () => { ); }); - test("keeps a trailing slash on outputStoragePath, producing a double slash", async () => { + test("strips a trailing slash on outputStoragePath rather than doubling it", async () => { const ctx = makeCtx({ config: { outputStoragePath: "transcriptions/" } }); await handleObjectFinalized( @@ -328,7 +328,7 @@ describe("handleObjectFinalized", () => { ); expect(ctx.fns.uploadTranscodedFile).toHaveBeenCalledWith( - expect.objectContaining({ storagePath: "transcriptions//tmp/a.mp3.wav" }) + expect.objectContaining({ storagePath: "transcriptions/tmp/a.mp3.wav" }) ); }); @@ -376,7 +376,7 @@ describe("handleObjectFinalized", () => { ); }); - test("keeps a trailing slash on outputStoragePath for the transcript too", async () => { + test("strips a trailing slash on outputStoragePath for the transcript too", async () => { const ctx = makeCtx({ config: { outputStoragePath: "transcriptions/" } }); await handleObjectFinalized( @@ -386,7 +386,7 @@ describe("handleObjectFinalized", () => { expect(ctx.fns.transcribeAndUpload).toHaveBeenCalledWith( expect.objectContaining({ - transcriptObjectName: "transcriptions//a.mp3.wav_transcription.txt", + transcriptObjectName: "transcriptions/a.mp3.wav_transcription.txt", }) ); });