UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

400 lines (399 loc) • 15.7 kB
/** * Google Cloud Speech-to-Text Handler * * Implementation of STT using Google Cloud Speech-to-Text API. * * @module voice/providers/GoogleSTT */ import { logger } from "../../utils/logger.js"; import { STTError } from "../errors.js"; /** * Google Cloud Speech-to-Text Handler * * Supports transcription with speaker diarization, word timestamps, and punctuation. * * @see https://cloud.google.com/speech-to-text/docs */ export class GoogleSTT { apiKey; credentialsPath; baseUrl = "https://speech.googleapis.com/v1"; /** * Maximum audio duration in seconds for the synchronous recognize endpoint. * For longer audio, use the async longrunningrecognize endpoint (not yet implemented). */ maxAudioDuration = 60; /** * True streaming requires gRPC (not implemented). `transcribeStream()` on * the SDK streams this handler through the generic chunked adapter. */ supportsStreaming = false; /** * @param apiKeyOrCredentials - The `credentials.stt.google` slice * (`apiKey`, `credentialsPath`), or an API key (the original positional * form). The environment fills whatever is left out. * @param credentialsPath - Service-account file, positional form only. */ constructor(apiKeyOrCredentials, credentialsPath) { // `typeof x === "string"` narrows the same way with and without // strictNullChecks (the react-hooks build runs tsc without it); an // `=== undefined` test does not. const slice = typeof apiKeyOrCredentials === "string" ? { apiKey: apiKeyOrCredentials, credentialsPath } : { credentialsPath, ...(apiKeyOrCredentials ?? {}) }; // Accept GOOGLE_AI_API_KEY / GEMINI_API_KEY as aliases since `.env.example` // documents those as the canonical Google credentials and forcing users to // also set GOOGLE_API_KEY just for STT was a footgun (Copilot review). const resolvedKey = (slice.apiKey ?? process.env.GOOGLE_API_KEY ?? process.env.GOOGLE_AI_API_KEY ?? process.env.GEMINI_API_KEY ?? "").trim(); this.apiKey = resolvedKey.length > 0 ? resolvedKey : null; const resolvedCreds = (slice.credentialsPath ?? process.env.GOOGLE_APPLICATION_CREDENTIALS ?? "").trim(); this.credentialsPath = resolvedCreds.length > 0 ? resolvedCreds : null; } isConfigured() { return this.apiKey !== null || this.credentialsPath !== null; } getSupportedFormats() { return ["mp3", "wav", "ogg", "opus"]; } async getSupportedLanguages() { // Return common languages supported by Google STT return [ { code: "en-US", name: "English (US)", supportsDiarization: true, supportsPunctuation: true, }, { code: "en-GB", name: "English (UK)", supportsDiarization: true, supportsPunctuation: true, }, { code: "es-ES", name: "Spanish (Spain)", supportsDiarization: true, supportsPunctuation: true, }, { code: "es-US", name: "Spanish (US)", supportsDiarization: true, supportsPunctuation: true, }, { code: "fr-FR", name: "French", supportsDiarization: true, supportsPunctuation: true, }, { code: "de-DE", name: "German", supportsDiarization: true, supportsPunctuation: true, }, { code: "it-IT", name: "Italian", supportsDiarization: true, supportsPunctuation: true, }, { code: "pt-BR", name: "Portuguese (Brazil)", supportsDiarization: true, supportsPunctuation: true, }, { code: "ja-JP", name: "Japanese", supportsDiarization: true, supportsPunctuation: true, }, { code: "ko-KR", name: "Korean", supportsDiarization: true, supportsPunctuation: true, }, { code: "zh-CN", name: "Chinese (Simplified)", supportsDiarization: true, supportsPunctuation: true, }, { code: "zh-TW", name: "Chinese (Traditional)", supportsDiarization: true, supportsPunctuation: true, }, { code: "ar-SA", name: "Arabic", supportsDiarization: true, supportsPunctuation: true, }, { code: "hi-IN", name: "Hindi", supportsDiarization: true, supportsPunctuation: true, }, { code: "ru-RU", name: "Russian", supportsDiarization: true, supportsPunctuation: true, }, ]; } async transcribe(audio, options = {}) { if (!this.isConfigured()) { throw STTError.providerNotConfigured("google-stt"); } const audioBuffer = Buffer.isBuffer(audio) ? audio : Buffer.from(audio); if (audioBuffer.length === 0) { throw STTError.audioEmpty("google-stt"); } const googleOptions = options; const startTime = Date.now(); try { // Build recognition config const detectedFormat = options.format ?? "wav"; const config = { encoding: this.getEncoding(detectedFormat), // Omit sampleRateHertz for WAV/FLAC — the API reads it from the header. // Hardcoding a wrong value causes "sample_rate_hertz must match WAV header" errors. ...(detectedFormat !== "wav" && detectedFormat !== "flac" ? { sampleRateHertz: options.sampleRate ?? 16000 } : options.sampleRate ? { sampleRateHertz: options.sampleRate } : {}), // Google needs a language; "auto" (no pin) falls back to the default. languageCode: options.language && options.language.toLowerCase() !== "auto" ? options.language : "en-US", enableAutomaticPunctuation: options.punctuation ?? true, enableWordTimeOffsets: options.wordTimestamps ?? false, enableWordConfidence: true, profanityFilter: options.profanityFilter ?? false, }; // Add model if specified if (googleOptions.model) { config.model = googleOptions.model; } // Add enhanced model option if (googleOptions.useEnhanced) { config.useEnhanced = true; } // Add diarization if requested if (options.speakerDiarization) { config.enableSpeakerDiarization = true; if (options.speakerCount) { config.diarizationSpeakerCount = options.speakerCount; } } // Add max alternatives if (googleOptions.maxAlternatives) { config.maxAlternatives = googleOptions.maxAlternatives; } // Build request const requestBody = { config, audio: { content: audioBuffer.toString("base64"), }, }; // Build URL with API key const url = this.apiKey ? `${this.baseUrl}/speech:recognize?key=${this.apiKey}` : `${this.baseUrl}/speech:recognize`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); let response; try { response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...(this.credentialsPath && !this.apiKey ? { Authorization: `Bearer ${await this.getAccessToken()}` } : {}), }, body: JSON.stringify(requestBody), signal: controller.signal, }); } catch (fetchErr) { if (fetchErr instanceof Error && fetchErr.name === "AbortError") { throw STTError.transcriptionFailed("Google STT request timed out after 30 seconds", "google-stt", fetchErr); } throw fetchErr; } finally { clearTimeout(timeoutId); } if (!response.ok) { const errorData = await response .json() .catch(() => Object.create(null)); const errorMessage = errorData.error?.message || `HTTP ${response.status}`; throw STTError.transcriptionFailed(errorMessage, "google-stt"); } const data = (await response.json()); const latency = Date.now() - startTime; // Handle empty results if (!data.results || data.results.length === 0) { return { text: "", confidence: 0, language: options.language, metadata: { latency, provider: "google-stt", }, }; } // Build result from all alternatives const result = { text: data.results .map((r) => r.alternatives[0]?.transcript ?? "") .join(" ") .trim(), confidence: this.calculateAverageConfidence(data.results), language: data.results[0]?.languageCode ?? options.language, metadata: { latency, provider: "google-stt", billedTime: data.totalBilledTime, }, }; // Add word timings const words = []; const speakers = new Set(); for (const resultItem of data.results) { const alternative = resultItem.alternatives[0]; if (alternative?.words) { for (const wordInfo of alternative.words) { const word = { word: wordInfo.word, startTime: this.parseDuration(wordInfo.startTime), endTime: this.parseDuration(wordInfo.endTime), confidence: wordInfo.confidence, }; if (wordInfo.speakerTag !== undefined) { word.speaker = `Speaker ${wordInfo.speakerTag}`; speakers.add(word.speaker); } words.push(word); } } } if (words.length > 0) { result.words = words; } if (speakers.size > 0) { result.speakers = Array.from(speakers); } // Add segments result.segments = data.results.map((resultItem, index) => { const alt = resultItem.alternatives[0]; return { index, text: alt?.transcript ?? "", isFinal: true, confidence: alt?.confidence ?? 0, language: resultItem.languageCode, }; }); logger.info(`[GoogleSTTHandler] Transcribed audio in ${latency}ms`); return result; } catch (err) { if (err instanceof STTError) { throw err; } const errorMessage = err instanceof Error ? err.message : String(err || "Unknown error"); logger.error(`[GoogleSTTHandler] Transcription failed: ${errorMessage}`); throw STTError.transcriptionFailed(errorMessage, "google-stt", err instanceof Error ? err : undefined); } } /** * Get encoding string for audio format */ getEncoding(format) { const encodings = { mp3: "MP3", wav: "LINEAR16", ogg: "OGG_OPUS", opus: "OGG_OPUS", }; return encodings[format] ?? "LINEAR16"; } /** * Parse duration string (e.g., "1.5s") to seconds */ parseDuration(duration) { if (!duration) { return 0; } const match = duration.match(/^([\d.]+)s$/); return match ? parseFloat(match[1]) : 0; } /** * Calculate average confidence from results */ calculateAverageConfidence(results) { const confidences = results .map((r) => r.alternatives[0]?.confidence) .filter((c) => typeof c === "number"); if (confidences.length === 0) { return 0; } return confidences.reduce((sum, c) => sum + c, 0) / confidences.length; } /** * Get access token from service account credentials. * * M3: previously caught all errors and returned `""`, which then caused * a silent 401 from the Google API and a confusing downstream HTTP error * with no trace of the original auth failure. Now rethrows as STTError so * the caller sees the auth root cause. */ async getAccessToken() { try { const { GoogleAuth } = await import("google-auth-library"); const auth = new GoogleAuth({ ...(this.credentialsPath ? { keyFilename: this.credentialsPath } : {}), scopes: ["https://www.googleapis.com/auth/cloud-platform"], }); const client = await auth.getClient(); const tokenResponse = await client.getAccessToken(); const token = tokenResponse.token; if (!token) { throw STTError.transcriptionFailed("Google access token returned empty — check GOOGLE_APPLICATION_CREDENTIALS path and service account permissions", "google-stt"); } return token; } catch (err) { logger.error(`[GoogleSTTHandler] Failed to acquire access token: ${err instanceof Error ? err.message : String(err)}`); // Use instanceof — refactor-resilient and matches the pattern in // transcribe(). The earlier `err.name === "STTError"` check would // double-wrap if the base class ever overwrote `name`. if (err instanceof STTError) { throw err; } throw STTError.transcriptionFailed(`Google access token acquisition failed: ${err instanceof Error ? err.message : String(err)}`, "google-stt"); } } }