#!/usr/bin/env node
|
|
import fs from 'node:fs'
|
import path from 'node:path'
|
|
const file = process.argv[2] || 'fixtures/asr-realtime-happy.ndjson'
|
const absolute = path.resolve(file)
|
const raw = fs.readFileSync(absolute, 'utf8')
|
const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
const allowedEvents = new Set(['session_start', 'audio_chunk', 'vad_speech_end', 'finish', 'cancel'])
|
const forbiddenActivityEvents = new Set([
|
'asr_realtime_session_started',
|
'asr_realtime_audio_chunk',
|
'asr_realtime_speech_end',
|
'asr_realtime_finish_received',
|
'asr_realtime_cancelled',
|
])
|
|
let state = 'new'
|
let lastChunkSeq = 0
|
let audioChunkCount = 0
|
let audioBytes = 0
|
|
function fail(message) {
|
console.error(`asr realtime fixture invalid: ${message}`)
|
process.exit(1)
|
}
|
|
for (const [index, line] of lines.entries()) {
|
if (Buffer.byteLength(`${line}\n`, 'utf8') > 24 * 1024) {
|
fail(`line ${index + 1} exceeds 24KiB`)
|
}
|
let event
|
try {
|
event = JSON.parse(line)
|
} catch {
|
fail(`line ${index + 1} is not JSON`)
|
}
|
if (forbiddenActivityEvents.has(event.event)) {
|
fail(`line ${index + 1} uses activity event ${event.event} as a wire event`)
|
}
|
if (!allowedEvents.has(event.event)) {
|
fail(`line ${index + 1} has unsupported event ${event.event}`)
|
}
|
|
if (state === 'new') {
|
if (event.event !== 'session_start') {
|
fail('first event must be session_start')
|
}
|
if (!event.callId || !event.traceId || !event.turnId || !event.runtimeSessionNonceHash) {
|
fail('session_start missing binding fields')
|
}
|
if (event.audio?.format !== 'pcm_s16le' || event.audio?.sampleRate !== 16000 || event.audio?.channels !== 1) {
|
fail('session_start audio must be pcm_s16le/16000Hz/mono')
|
}
|
state = 'streaming'
|
continue
|
}
|
|
if (state === 'terminal') {
|
fail(`event ${event.event} arrived after terminal event`)
|
}
|
if (event.event === 'audio_chunk') {
|
if (state !== 'streaming') {
|
fail('audio_chunk arrived after vad_speech_end')
|
}
|
if (!Number.isInteger(event.chunkSeq) || event.chunkSeq !== lastChunkSeq + 1) {
|
fail('audio_chunk chunkSeq must start at 1 and be contiguous')
|
}
|
if (!Number.isInteger(event.durationMs) || event.durationMs < 1 || event.durationMs > 1000) {
|
fail('audio_chunk durationMs must be 1..1000')
|
}
|
const payload = Buffer.from(event.audioBase64 || '', 'base64')
|
if (payload.length === 0 || payload.length > 16000 || payload.length % 2 !== 0) {
|
fail('audio_chunk payload must be non-empty, <=16000 bytes and 16-bit aligned')
|
}
|
lastChunkSeq = event.chunkSeq
|
audioChunkCount += 1
|
audioBytes += payload.length
|
continue
|
}
|
if (event.event === 'vad_speech_end') {
|
if (state !== 'streaming' || audioChunkCount === 0) {
|
fail('vad_speech_end requires at least one audio_chunk')
|
}
|
state = 'speech_ended'
|
continue
|
}
|
if (event.event === 'finish') {
|
if (state !== 'speech_ended') {
|
fail('finish must follow vad_speech_end')
|
}
|
state = 'terminal'
|
continue
|
}
|
if (event.event === 'cancel') {
|
if (!event.reason) {
|
fail('cancel missing reason')
|
}
|
state = 'terminal'
|
continue
|
}
|
fail(`duplicate session_start at line ${index + 1}`)
|
}
|
|
if (state !== 'terminal') {
|
fail('fixture must end with finish or cancel')
|
}
|
|
console.log(JSON.stringify({
|
ok: true,
|
file: path.relative(process.cwd(), absolute),
|
eventCount: lines.length,
|
audioChunkCount,
|
audioBytes,
|
}))
|