diff --git a/kits/speech-to-text/CHANGELOG.md b/kits/speech-to-text/CHANGELOG.md index 628c00529..fc7736e5f 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, 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 -- 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 0e2a92ec6..019249240 100644 --- a/kits/speech-to-text/README.md +++ b/kits/speech-to-text/README.md @@ -134,27 +134,40 @@ 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 outputs land + +Both outputs keep the paths the extension used, so migrated consumers find them +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` | + +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/`. +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 +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 720838926..263921d43 100644 --- a/kits/speech-to-text/src/handlers.ts +++ b/kits/speech-to-text/src/handlers.ts @@ -149,18 +149,29 @@ export async function handleObjectFinalized( }); /** - * Bucket-relative object name for the transcoded file, derived from the - * input object's path/name (not the local `/tmp` path). + * 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 = `${filePath}.wav`; + const transcodedObjectName = path.posix.join("tmp", `${filePath}.wav`); + + /** + * 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.replace(/\/$/, "")}/${objectName}` + : objectName; + const transcodedUploadResult = await ctx.fns.uploadTranscodedFile({ localPath: localTranscodedPath, - storagePath: config.outputStoragePath - ? `${config.outputStoragePath.replace( - /\/$/, - "" - )}/${transcodedObjectName}` - : transcodedObjectName, + storagePath: withOutputPrefix(transcodedObjectName), bucket, }); @@ -174,9 +185,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 9bbd2faa1..ebf813e75 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 a94d5c80e..b6c123cda 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,124 @@ 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("strips a trailing slash on outputStoragePath rather than doubling it", 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("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: "clip.mp3" }), + 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("strips 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: "transcoded/clip.mp3.wav" }) + expect.objectContaining({ storagePath: "tmp/tmp/a.mp3.wav" }) + ); + expect(ctx.fns.transcribeAndUpload).toHaveBeenCalledWith( + expect.objectContaining({ + transcriptObjectName: "tmp/a.mp3.wav_transcription.txt", + }) ); }); diff --git a/kits/speech-to-text/tests/transcribe.test.ts b/kits/speech-to-text/tests/transcribe.test.ts index 1ee65fa1b..3bab0593d 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", }, }) );