Skip to content
Open
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
2 changes: 1 addition & 1 deletion kits/speech-to-text/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
- fix: the transcoded `.wav` and the `.txt` transcript are written where the legacy extension wrote them, `tmp/<original path>.wav` and `<original path>.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 `<transcoded object>_transcription.txt` exactly ([#3026](https://github.com/firebase/extensions/issues/3026))
55 changes: 34 additions & 21 deletions kits/speech-to-text/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<original path>.wav`, and with `OUTPUT_STORAGE_PATH: transcriptions` as
`transcriptions/tmp/<original path>.wav`. The kit names it after the original
object instead: `<original path>.wav`, or
`transcriptions/<original path>.wav`.

The transcript itself is written to the same place as before
(`<original path>.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

Expand Down
42 changes: 33 additions & 9 deletions kits/speech-to-text/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand All @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions kits/speech-to-text/src/transcribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TranscribeAudioResult> {
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: {
Expand Down
115 changes: 109 additions & 6 deletions kits/speech-to-text/tests/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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/<name>.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/<name>.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",
})
);
});

Expand Down
5 changes: 3 additions & 2 deletions kits/speech-to-text/tests/transcribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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: {
Expand All @@ -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",
},
})
);
Expand Down
Loading