Keep 8KB CHUNK_SIZE (proven stable) but replace 10ms setTimeout delay
with a microtask break every 16 chunks. This avoids the AWS SDK
"Deserialization error: inspect {error}.\$response" while still
eliminating the ~1.25s/MB artificial delay from the old 10ms sleep.
211 lines
8.5 KiB
JavaScript
211 lines
8.5 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 = 8192; // 8 KB per audio chunk (AWS limit ~32KB per event frame)
|
|
|
|
// 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) {
|
|
// 8KB chunks — larger sizes cause AWS SDK deserialization errors
|
|
// ("to see the raw response, inspect the hidden field {error}.$response").
|
|
// No artificial delay between chunks; yield with a microtask break
|
|
// to avoid blocking the event loop without adding real latency.
|
|
for (var i = 0; i < buffer.length; i += CHUNK_SIZE) {
|
|
yield { AudioEvent: { AudioChunk: buffer.slice(i, i + CHUNK_SIZE) } };
|
|
// Microtask break every 16 chunks (~128KB) to keep event loop responsive
|
|
if ((i / CHUNK_SIZE) % 16 === 15) {
|
|
await new Promise(function(r) { setImmediate(r); });
|
|
}
|
|
}
|
|
}
|
|
|
|
function logTranscribeError(label, err) {
|
|
console.error('[Transcribe] ' + label + ':', err.message);
|
|
if (err.name) console.error('[Transcribe] name:', err.name);
|
|
if (err.$metadata) console.error('[Transcribe] metadata:', JSON.stringify(err.$metadata));
|
|
if (err.cause) console.error('[Transcribe] cause:', err.cause.message || JSON.stringify(err.cause));
|
|
if (err.$response) {
|
|
try {
|
|
var resp = err.$response;
|
|
console.error('[Transcribe] status:', resp.statusCode);
|
|
if (resp.headers) console.error('[Transcribe] headers:', JSON.stringify(resp.headers));
|
|
if (resp.body) console.error('[Transcribe] body:', typeof resp.body === 'string' ? resp.body.slice(0, 500) : '(stream)');
|
|
} catch (e) { /* ignore logging errors */ }
|
|
}
|
|
}
|
|
|
|
async function transcribeWithAWS(audioBuffer, mimeType) {
|
|
var startTime = Date.now();
|
|
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;
|
|
|
|
// 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 {
|
|
var ffStart = Date.now();
|
|
audioToSend = await convertToPCM(audioBuffer);
|
|
mediaEncoding = 'pcm';
|
|
sampleRate = 16000;
|
|
console.log('[Transcribe] ffmpeg: ' + (Date.now() - ffStart) + 'ms — ' + audioBuffer.length + ' → ' + audioToSend.length + ' bytes');
|
|
} catch (ffErr) {
|
|
console.warn('[Transcribe] ffmpeg unavailable, sending ogg-opus directly:', ffErr.message);
|
|
if (mimeType && mimeType.indexOf('wav') !== -1) {
|
|
mediaEncoding = 'pcm';
|
|
sampleRate = 16000;
|
|
}
|
|
}
|
|
|
|
// Minimum ~0.5s of PCM audio at 16kHz mono 16-bit = 16000 bytes
|
|
if (mediaEncoding === 'pcm' && audioToSend.length < 16000) {
|
|
console.warn('[Transcribe] Audio too short (' + audioToSend.length + ' bytes), skipping');
|
|
return '';
|
|
}
|
|
|
|
var useMedical = process.env.AWS_TRANSCRIBE_MEDICAL === 'true';
|
|
var specialty = process.env.AWS_TRANSCRIBE_SPECIALTY || 'PRIMARYCARE';
|
|
var transcript = '';
|
|
|
|
// Helper to collect transcript from a result stream
|
|
async function collectTranscript(resultStream) {
|
|
var text = '';
|
|
for await (var event of resultStream) {
|
|
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]) {
|
|
text += r.Alternatives[0].Transcript + ' ';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
|
|
// Try Medical first if enabled, with fallback to Standard
|
|
var streamStart = Date.now();
|
|
if (useMedical) {
|
|
try {
|
|
var client = new TranscribeStreamingClient({ region: region, credentials: credentials });
|
|
var medCmd = new TranscribeModule.StartMedicalStreamTranscriptionCommand({
|
|
LanguageCode: 'en-US',
|
|
MediaSampleRateHertz: sampleRate,
|
|
MediaEncoding: mediaEncoding,
|
|
Specialty: specialty,
|
|
Type: 'DICTATION',
|
|
AudioStream: makeAudioStream(audioToSend)
|
|
});
|
|
var medResp = await client.send(medCmd);
|
|
transcript = await collectTranscript(medResp.TranscriptResultStream);
|
|
console.log('[Transcribe] Medical OK — ' + (Date.now() - streamStart) + 'ms, ' + transcript.length + ' chars');
|
|
} catch (medErr) {
|
|
logTranscribeError('Medical failed', medErr);
|
|
console.warn('[Transcribe] Falling back to standard...');
|
|
streamStart = Date.now();
|
|
try {
|
|
var client2 = new TranscribeStreamingClient({ region: region, credentials: credentials });
|
|
var stdCmd = new TranscribeModule.StartStreamTranscriptionCommand({
|
|
LanguageCode: 'en-US',
|
|
MediaSampleRateHertz: sampleRate,
|
|
MediaEncoding: mediaEncoding,
|
|
AudioStream: makeAudioStream(audioToSend)
|
|
});
|
|
var stdResp = await client2.send(stdCmd);
|
|
transcript = await collectTranscript(stdResp.TranscriptResultStream);
|
|
console.log('[Transcribe] Standard fallback OK — ' + (Date.now() - streamStart) + 'ms, ' + transcript.length + ' chars');
|
|
} catch (stdErr) {
|
|
logTranscribeError('Standard also failed', stdErr);
|
|
throw stdErr;
|
|
}
|
|
}
|
|
} else {
|
|
var client = new TranscribeStreamingClient({ region: region, credentials: credentials });
|
|
var cmd = new TranscribeModule.StartStreamTranscriptionCommand({
|
|
LanguageCode: 'en-US',
|
|
MediaSampleRateHertz: sampleRate,
|
|
MediaEncoding: mediaEncoding,
|
|
AudioStream: makeAudioStream(audioToSend)
|
|
});
|
|
var resp = await client.send(cmd);
|
|
transcript = await collectTranscript(resp.TranscriptResultStream);
|
|
console.log('[Transcribe] Standard OK — ' + (Date.now() - streamStart) + 'ms, ' + transcript.length + ' chars');
|
|
}
|
|
|
|
console.log('[Transcribe] Total: ' + (Date.now() - startTime) + 'ms');
|
|
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 };
|