pediatric-ai-scribe-v3/src/utils/transcribeAWS.js
Daniel Onyejesi 3da61c455e Add ffmpeg audio conversion fallback for AWS Transcribe
- transcribeAWS.js: convert browser WebM/Opus → PCM 16kHz mono via
  ffmpeg before sending to AWS Transcribe — PCM is unambiguous and
  most reliable; gracefully falls back to ogg-opus if ffmpeg absent
- Dockerfile: install ffmpeg (apk add ffmpeg) so Docker image works
  out of the box with AWS Transcribe
- README: document Amazon Transcribe setup, ffmpeg requirement,
  Transcribe Medical specialty options, and env vars reference
2026-03-25 20:35:24 +00:00

161 lines
5.9 KiB
JavaScript

// ============================================================
// TRANSCRIBE-AWS.JS — Amazon Transcribe Streaming
// No S3 required. Audio streams directly to AWS.
//
// Audio conversion: ffmpeg converts browser WebM/Opus → PCM 16kHz
// which is the most reliable format for AWS Transcribe. If ffmpeg
// is not installed, falls back to sending ogg-opus directly.
//
// Env vars:
// TRANSCRIBE_PROVIDER=aws — use this instead of Whisper
// AWS_TRANSCRIBE_MEDICAL=true — use Transcribe Medical (better
// accuracy for clinical dictation)
// AWS_TRANSCRIBE_SPECIALTY=PRIMARYCARE — medical specialty
// Options: PRIMARYCARE, CARDIOLOGY, NEUROLOGY, ONCOLOGY,
// RADIOLOGY, UROLOGY
// AWS_BEDROCK_REGION — reused for Transcribe region
// AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY — reused credentials
// ============================================================
const { spawn } = require('child_process');
const CHUNK_SIZE = 32768; // 32 KB per audio chunk
// Convert any browser audio (WebM, OGG, etc.) to raw PCM s16le 16kHz mono
// using ffmpeg. Returns a Buffer of raw PCM bytes.
// Throws if ffmpeg is not installed.
function convertToPCM(inputBuffer) {
return new Promise(function(resolve, reject) {
var ff = spawn('ffmpeg', [
'-i', 'pipe:0', // read from stdin
'-f', 's16le', // raw signed 16-bit little-endian PCM
'-ar', '16000', // 16 kHz — ideal for speech recognition
'-ac', '1', // mono
'-acodec', 'pcm_s16le',
'pipe:1' // write to stdout
]);
var out = [];
var errBuf = [];
ff.stdout.on('data', function(d) { out.push(d); });
ff.stderr.on('data', function(d) { errBuf.push(d); }); // ffmpeg logs to stderr, not an error
ff.on('close', function(code) {
if (code !== 0) {
reject(new Error('ffmpeg conversion failed (code ' + code + '): ' + Buffer.concat(errBuf).toString().slice(-200)));
return;
}
resolve(Buffer.concat(out));
});
ff.on('error', function(err) {
reject(new Error('ffmpeg not found. Install ffmpeg on the server: ' + err.message));
});
ff.stdin.write(inputBuffer);
ff.stdin.end();
});
}
async function* makeAudioStream(buffer) {
for (var i = 0; i < buffer.length; i += CHUNK_SIZE) {
yield { AudioEvent: { AudioChunk: buffer.slice(i, i + CHUNK_SIZE) } };
await new Promise(function(r) { setTimeout(r, 0); });
}
}
async function transcribeWithAWS(audioBuffer, mimeType) {
var TranscribeModule = require('@aws-sdk/client-transcribe-streaming');
var TranscribeStreamingClient = TranscribeModule.TranscribeStreamingClient;
var region = process.env.AWS_BEDROCK_REGION || process.env.AWS_REGION || 'us-east-1';
var credentials = process.env.AWS_ACCESS_KEY_ID ? {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
} : undefined;
var client = new TranscribeStreamingClient({ region: region, credentials: credentials });
// Try ffmpeg conversion to PCM (most reliable with AWS Transcribe).
// Falls back to ogg-opus passthrough if ffmpeg is not installed.
var audioToSend = audioBuffer;
var mediaEncoding = 'ogg-opus';
var sampleRate = 48000;
try {
audioToSend = await convertToPCM(audioBuffer);
mediaEncoding = 'pcm';
sampleRate = 16000;
console.log('[Transcribe] ffmpeg conversion OK — PCM 16kHz, ' + audioToSend.length + ' bytes');
} catch (ffErr) {
console.warn('[Transcribe] ffmpeg unavailable, sending ogg-opus directly:', ffErr.message);
// For WAV input, try pcm anyway without ffmpeg
if (mimeType && mimeType.indexOf('wav') !== -1) {
mediaEncoding = 'pcm';
sampleRate = 16000;
}
}
var useMedical = process.env.AWS_TRANSCRIBE_MEDICAL === 'true';
var specialty = process.env.AWS_TRANSCRIBE_SPECIALTY || 'PRIMARYCARE';
var transcript = '';
if (useMedical) {
var StartMedicalStreamTranscriptionCommand = TranscribeModule.StartMedicalStreamTranscriptionCommand;
var medCmd = new StartMedicalStreamTranscriptionCommand({
LanguageCode: 'en-US',
MediaSampleRateHertz: sampleRate,
MediaEncoding: mediaEncoding,
Specialty: specialty,
Type: 'DICTATION',
AudioStream: makeAudioStream(audioToSend)
});
var medResp = await client.send(medCmd);
for await (var event of medResp.TranscriptResultStream) {
if (event.TranscriptEvent && event.TranscriptEvent.Transcript) {
var results = event.TranscriptEvent.Transcript.Results || [];
for (var i = 0; i < results.length; i++) {
var r = results[i];
if (!r.IsPartial && r.Alternatives && r.Alternatives[0]) {
transcript += r.Alternatives[0].Transcript + ' ';
}
}
}
}
} else {
var StartStreamTranscriptionCommand = TranscribeModule.StartStreamTranscriptionCommand;
var cmd = new StartStreamTranscriptionCommand({
LanguageCode: 'en-US',
MediaSampleRateHertz: sampleRate,
MediaEncoding: mediaEncoding,
AudioStream: makeAudioStream(audioToSend)
});
var resp = await client.send(cmd);
for await (var ev of resp.TranscriptResultStream) {
if (ev.TranscriptEvent && ev.TranscriptEvent.Transcript) {
var res = ev.TranscriptEvent.Transcript.Results || [];
for (var j = 0; j < res.length; j++) {
var item = res[j];
if (!item.IsPartial && item.Alternatives && item.Alternatives[0]) {
transcript += item.Alternatives[0].Transcript + ' ';
}
}
}
}
}
return transcript.trim();
}
// Check if AWS Transcribe is available and configured
function isAWSTranscribeConfigured() {
try {
require('@aws-sdk/client-transcribe-streaming');
return !!(process.env.AWS_BEDROCK_REGION || process.env.AWS_REGION);
} catch (e) {
return false;
}
}
module.exports = { transcribeWithAWS, isAWSTranscribeConfigured };