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

233 lines (232 loc) • 9.66 kB
/** * Native audio delivery to providers that can listen. * * ## The gap this closes * * Until this module existed, attaching an audio file produced a message * containing only a metadata block: * * ## Audio File: "recording.mp3" * Duration: 19s | Codec: MPEG 2 Layer 3 | Bitrate: 32 kbps | * Sample Rate: 22050 Hz | Channels: 1 (Mono) * * No audio bytes were ever handed to the provider. Every question about what * the recording *says* — transcribe this, who is speaking, what was agreed — * was answered from a description of the file, and Gemini has accepted inline * audio the whole time. * * The failure was invisible for an instructive reason: that metadata block * answers precisely the questions a test is most tempted to ask. "How long is * this audio?" and "what sample rate is it?" both succeed with no audio * attached, so a suite built on them reports working audio support. It took an * end-to-end test asking for a spoken word to expose it. * * ## Provider scope * * Deliberately a capability map rather than "send audio to everyone". A * provider that cannot accept an audio part responds with an opaque HTTP 400, * which is worse than the metadata summary it would otherwise have received — * so an unlisted provider keeps the existing text-only behaviour and loses * nothing. * * @module adapters/audioFormatSupport */ import { withTimeout } from "../utils/errorHandling.js"; import { logger } from "../utils/logger.js"; import { getFfmpegPath, runFfmpeg } from "./video/ffmpegAdapter.js"; /** * Ceiling for one audio conversion. * * Longer than the image equivalent because a lossless hour-long WAV is a * legitimate input and re-encoding it is not instant, but still bounded so a * wedged decoder cannot hold a generation request open indefinitely. */ const AUDIO_TRANSCODE_TIMEOUT_MS = 120_000; /** * Providers that accept inline audio parts. * * Google's Gemini models (both Vertex and AI Studio) take audio as `inlineData` * alongside text. Other providers are omitted rather than assumed: OpenAI's * audio models use a different request shape than the chat-completions path * NeuroLink builds here, and sending an audio part to a provider that does not * expect one converts a working (if limited) response into a hard failure. */ const NATIVE_AUDIO_PROVIDERS = new Set([ "vertex", "google-vertex", "googlevertex", "google-ai-studio", "googleaistudio", "google-ai", "googleai", "gemini", ]); /** * The Vertex-family aliases within {@link NATIVE_AUDIO_PROVIDERS}. * * Vertex is a front end for two unrelated model families: Gemini, which * accepts inline audio, and Claude (routed to `@anthropic-ai/vertex-sdk`), * which does not. The provider name alone cannot tell them apart, so these * aliases get an extra model check in {@link supportsNativeAudio}; the * AI-Studio/Gemini aliases never serve Claude and skip it. */ const VERTEX_PROVIDER_NAMES = new Set([ "vertex", "google-vertex", "googlevertex", ]); /** * Audio MIME types the native providers accept as-is. * * Gemini's documented set. Anything outside it is transcoded rather than * rejected, because the container a user happens to have — a voice memo in * CAF, a Windows recording in WMA — says nothing about whether the audio * inside is useful. */ const NATIVE_AUDIO_MIME_TYPES = new Set([ "audio/wav", "audio/x-wav", "audio/mpeg", "audio/mp3", "audio/aiff", "audio/x-aiff", "audio/aac", "audio/ogg", "audio/flac", "audio/x-flac", ]); /** MIME type every transcode targets. Universally accepted and compact. */ const TRANSCODE_TARGET_MIME = "audio/mpeg"; /** * Whether `provider` can be handed raw audio bytes. * * `model` is optional and only consulted for the Vertex aliases, which serve * both Gemini (native audio) and Claude (no native audio — routed to * `@anthropic-ai/vertex-sdk`, whose message builder never reads an audio * part). The Claude test is the one `GoogleVertexProvider` routes by * (`isAnthropicModel` in providers/googleVertex/client.ts: case-insensitive, * anywhere in the id). A stricter test here would call a model Gemini that * Vertex actually sends to Claude. A caller that omits `model` on Vertex keeps * today's behaviour. AI Studio / bare "gemini" aliases never serve Claude. */ export function supportsNativeAudio(provider, model) { const normalizedProvider = provider.toLowerCase().trim(); if (!NATIVE_AUDIO_PROVIDERS.has(normalizedProvider)) { return false; } if (VERTEX_PROVIDER_NAMES.has(normalizedProvider)) { return !(model ?? "").toLowerCase().includes("claude"); } return true; } /** Whether `mimeType` must be re-encoded before a native provider will read it. */ export function needsAudioTranscode(mimeType) { return !NATIVE_AUDIO_MIME_TYPES.has(normalizeAudioMime(mimeType)); } function normalizeAudioMime(mimeType) { return mimeType.split(";")[0].trim().toLowerCase(); } /** * Re-encode audio to MP3 with ffmpeg. * * Temp files rather than stdin: several of the containers that need converting * (CAF, WavPack, AU) carry their metadata in a trailer or require seeking, and * a piped stream leaves ffmpeg unable to find it. The directory is removed in * `finally` whether or not the conversion succeeded. * * Node builtins are imported dynamically because the browser bundle stubs * `node:fs/promises` without `mkdtemp`; nothing in a browser spawns ffmpeg, so * the import belongs at the point of use. */ async function transcodeToMp3(buffer, extension) { const [{ randomUUID }, { mkdtemp, readFile, rm, writeFile }, { tmpdir }, { join },] = await Promise.all([ import("node:crypto"), import("node:fs/promises"), import("node:os"), import("node:path"), ]); const workDir = await mkdtemp(join(tmpdir(), "neurolink-audio-")); const inputPath = join(workDir, `${randomUUID()}${extension}`); const outputPath = join(workDir, `${randomUUID()}.mp3`); try { await writeFile(inputPath, buffer); await runFfmpeg([ "-y", "-v", "error", "-i", inputPath, // Downmix and cap the rate: speech is the point, and a 48 kHz stereo // re-encode of a mono voice memo triples the payload for nothing. "-ac", "1", "-ar", "16000", "-c:a", "libmp3lame", "-q:a", "4", outputPath, ], // Without this the call inherits runFfmpeg's frame-extraction default of // 30s, which is sized for pulling a single video frame — so the 120s // ceiling above, chosen precisely because re-encoding a lossless // hour-long WAV is not instant, could never be reached. ffmpeg killed the // transcode at 30s and the outer race never got to run. { timeoutMs: AUDIO_TRANSCODE_TIMEOUT_MS }); return await readFile(outputPath); } finally { await rm(workDir, { recursive: true, force: true }).catch(() => undefined); } } /** * Return audio bytes a native provider can read, transcoding when the source * container is one it does not accept. * * Never throws for audio reasons. When conversion is impossible — no ffmpeg, an * unreadable stream — the original bytes and MIME type come back with * `converted: false`, and the caller falls back to the metadata summary. That * keeps this from turning a previously-working (if limited) request into a * failure. * * @param buffer - Raw audio bytes. * @param mimeType - Detected MIME type of `buffer`. * @param extension - Source extension, used so ffmpeg picks the right demuxer. */ export async function toProviderCompatibleAudio(buffer, mimeType, extension) { const normalized = normalizeAudioMime(mimeType); if (!needsAudioTranscode(normalized)) { return { buffer, mimeType: normalized, converted: false }; } // Resolving the binary first turns "ffmpeg is not installed" into one clear // warning rather than a spawn error surfacing from inside the conversion. const ffmpegAvailable = await getFfmpegPath() .then(() => true) .catch(() => false); if (!ffmpegAvailable) { logger.warn(`[audioFormatSupport] ${normalized} needs conversion before a provider can ` + `read it, but ffmpeg is unavailable — falling back to a metadata-only ` + `summary. Install ffmpeg (or set FFMPEG_PATH) to enable this format.`); return { buffer, mimeType: normalized, converted: false }; } try { const converted = await withTimeout(transcodeToMp3(buffer, extension), AUDIO_TRANSCODE_TIMEOUT_MS, new Error(`audio transcode exceeded ${AUDIO_TRANSCODE_TIMEOUT_MS}ms`)); if (converted.length === 0) { throw new Error("produced an empty audio stream"); } logger.debug(`[audioFormatSupport] Transcoded ${normalized} → ${TRANSCODE_TARGET_MIME} ` + `(${buffer.length} → ${converted.length} bytes) for native delivery`); return { buffer: converted, mimeType: TRANSCODE_TARGET_MIME, converted: true, }; } catch (error) { logger.warn(`[audioFormatSupport] Could not convert ${normalized} for native delivery ` + `— falling back to a metadata-only summary: ` + `${error instanceof Error ? error.message.split("\n")[0] : String(error)}`); return { buffer, mimeType: normalized, converted: false }; } }