cyberchef/src/core/operations/GCloudSpeechToText.mjs
2026-02-28 16:01:49 +00:00

259 lines
9.4 KiB
JavaScript

/**
* @author CyberChefCloud
* @copyright Crown Copyright 2026
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
import { GCP_AUTH_ARGS, applyGCPAuth, pollLongRunningOperation, writeGCSFile } from "../lib/GoogleCloud.mjs";
/**
* GCloud Speech to Text operation
*/
class GCloudSpeechToText extends Operation {
/**
* GCloudSpeechToText constructor
*/
constructor() {
super();
this.name = "GCloud Speech to Text";
this.module = "Cloud";
this.description = [
"Transcribes audio using the Google Cloud Speech-to-Text API.",
"<br><br>",
"<b>GCS URI mode (recommended for large files):</b> Input a <code>gs://</code> URI ",
"(e.g. <code>gs://my-bucket/audio/file.mp3</code>). The audio is processed entirely within ",
"Google Cloud — the raw audio never passes through the browser. Suitable for files of any size. ",
"Uses the asynchronous <code>longrunningrecognize</code> API with internal polling.",
"<br><br>",
"<b>Raw Audio mode:</b> Provide Base64-encoded audio bytes directly. Only suitable for short ",
"clips (under ~1 minute). Uses the synchronous <code>recognize</code> API.",
"<br><br>",
"<b>Write to GCS mode:</b> Instead of returning the transcript to CyberChef, writes it to a ",
"structured path in a GCS bucket and returns the destination <code>gs://</code> URI. This is ",
"ideal for batch processing with Fork — each fork branch writes its transcript and returns a URI, ",
"which can be saved and used as input to a later recipe.",
"<br><br>",
"Output path convention: <code>output/audio/{filename}/speech-to-text/text.txt</code>",
].join("\n");
this.infoURL = "https://cloud.google.com/speech-to-text/docs/reference/rest";
this.inputType = "string";
this.outputType = "string";
this.manualBake = true;
this.args = [
{
"name": "Input Mode",
"type": "option",
"value": ["GCS URI (gs://...)", "Raw Audio Bytes (Base64)"]
},
{
"name": "Language Code",
"type": "string",
"value": "en-US"
},
{
"name": "Model",
"type": "option",
"value": ["latest_long", "latest_short", "telephony", "medical_dictation", "default"]
},
{
"name": "Output Destination",
"type": "option",
"value": ["Return to CyberChef", "Write to GCS"]
},
{
"name": "Output GCS Bucket",
"type": "string",
"value": "cyber-chef-cloud-examples"
},
{
"name": "Max Poll Minutes",
"type": "number",
"value": 30
},
...GCP_AUTH_ARGS
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
async run(input, args) {
const [
inputMode, languageCode, model, outputDest, outputBucket, maxPollMinutes,
authType, authStringObj, quotaProject
] = args;
const uri = input.trim();
if (!uri) throw new OperationError("Please provide a GCS URI or Base64 audio input.");
const authString = typeof authStringObj === "string" ? authStringObj : (authStringObj.string || "");
if (!authString) throw new OperationError("Please provide a valid GCP Auth String (API Key or OAuth Token).");
const maxMs = maxPollMinutes * 60 * 1000;
let transcript;
if (inputMode === "GCS URI (gs://...)") {
if (!uri.startsWith("gs://")) {
throw new OperationError("Input Mode is set to GCS URI but input does not start with gs://");
}
transcript = await this._transcribeGcsUri(uri, languageCode, model, authType, authStringObj, quotaProject, maxMs);
} else {
// Raw audio bytes (Base64)
transcript = await this._transcribeRawAudio(uri, languageCode, model, authType, authStringObj, quotaProject);
}
if (outputDest === "Write to GCS") {
// Derive source filename from GCS URI (or use a default for raw audio)
const sourceFilename = inputMode === "GCS URI (gs://...)"
? uri.split("/").pop()
: "raw_audio";
const objectPath = `output/audio/${sourceFilename}/speech-to-text/text.txt`;
const destUri = await writeGCSFile(outputBucket, objectPath, transcript, authType, authStringObj, quotaProject);
return destUri;
}
return transcript;
}
/**
* Transcribes audio from a GCS URI using the longrunningrecognize API.
*
* @param {string} gcsUri
* @param {string} languageCode
* @param {string} model
* @param {string} authType
* @param {Object|string} authStringObj
* @param {string} quotaProject
* @param {number} maxMs
* @returns {Promise<string>}
*/
async _transcribeGcsUri(gcsUri, languageCode, model, authType, authStringObj, quotaProject, maxMs) {
let url = "https://speech.googleapis.com/v1/speech:longrunningrecognize";
const headers = new Headers();
headers.set("Content-Type", "application/json; charset=utf-8");
const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject);
const body = JSON.stringify({
config: {
languageCode,
model,
enableAutomaticPunctuation: true,
},
audio: { uri: gcsUri }
});
const response = await fetch(authed.url, {
method: "POST",
headers: authed.headers,
body,
mode: "cors",
cache: "no-cache"
});
let responseData;
try { responseData = await response.json(); } catch (e) {
throw new OperationError("GCloud Speech to Text: Failed to parse API response.");
}
if (!response.ok) {
const msg = responseData?.error?.message || response.statusText;
throw new OperationError(`GCloud Speech to Text: API Error (${response.status}): ${msg}`);
}
const operationName = responseData.name;
if (!operationName) throw new OperationError("GCloud Speech to Text: No operation name returned from API.");
// Poll for completion
const POLL_URL = "https://speech.googleapis.com/v1/operations/";
const completed = await pollLongRunningOperation(
operationName,
POLL_URL,
authType,
authStringObj,
quotaProject,
maxMs,
10000,
(elapsedSec) => {
// onProgress — not easily surfaced in CyberChef output mid-bake,
// but available for future UI integration
void elapsedSec;
}
);
return this._extractTranscript(completed);
}
/**
* Transcribes audio from raw Base64 bytes using the synchronous recognize API.
*
* @param {string} base64Audio
* @param {string} languageCode
* @param {string} model
* @param {string} authType
* @param {Object|string} authStringObj
* @param {string} quotaProject
* @returns {Promise<string>}
*/
async _transcribeRawAudio(base64Audio, languageCode, model, authType, authStringObj, quotaProject) {
let url = "https://speech.googleapis.com/v1/speech:recognize";
const headers = new Headers();
headers.set("Content-Type", "application/json; charset=utf-8");
const authed = applyGCPAuth(url, headers, authType, authStringObj, quotaProject);
const body = JSON.stringify({
config: {
languageCode,
model,
enableAutomaticPunctuation: true,
},
audio: { content: base64Audio }
});
const response = await fetch(authed.url, {
method: "POST",
headers: authed.headers,
body,
mode: "cors",
cache: "no-cache"
});
let responseData;
try { responseData = await response.json(); } catch (e) {
throw new OperationError("GCloud Speech to Text: Failed to parse API response.");
}
if (!response.ok) {
const msg = responseData?.error?.message || response.statusText;
throw new OperationError(`GCloud Speech to Text: API Error (${response.status}): ${msg}`);
}
return this._extractTranscript({ response: responseData });
}
/**
* Extracts a joined transcript string from a completed LRO response or synchronous response.
*
* @param {Object} completed - The operation response object.
* @returns {string} Joined transcript text.
*/
_extractTranscript(completed) {
const results = completed?.response?.results;
if (!results || results.length === 0) {
return "(No speech detected)";
}
return results
.map(r => r.alternatives?.[0]?.transcript || "")
.filter(t => t.length > 0)
.join(" ")
.trim();
}
}
export default GCloudSpeechToText;