#!/usr/bin/env node
|
|
import fs from 'node:fs'
|
import path from 'node:path'
|
|
const file = process.argv[2] || 'fixtures/turn-stream-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([
|
'reply_playback_mode_selected',
|
'reply_state',
|
'reply_audio_chunk',
|
'device_output',
|
'turn_completed',
|
'turn_failed',
|
'turn_cancelled',
|
'activity',
|
])
|
|
let playbackMode = null
|
let sawPlaybackStarted = false
|
let audioChunkCount = 0
|
let sawCompleted = false
|
let sawFailed = false
|
let sawCancelled = false
|
let lastSeq = 0
|
let callId = null
|
let traceId = null
|
let turnId = null
|
const supportedAudioFormats = new Set(['pcm_s16le', 'mp3', 'mpeg', 'wav'])
|
|
function fail(message) {
|
console.error(`turn stream fixture invalid: ${message}`)
|
process.exit(1)
|
}
|
|
function requireSameIdentity(event) {
|
if (!event.callId || !event.traceId || !event.turnId) {
|
fail(`${event.event} missing callId/traceId/turnId`)
|
}
|
callId ??= event.callId
|
traceId ??= event.traceId
|
turnId ??= event.turnId
|
if (event.callId !== callId || event.traceId !== traceId || event.turnId !== turnId) {
|
fail(`${event.event} identity mismatch`)
|
}
|
}
|
|
for (const [index, line] of lines.entries()) {
|
let event
|
try {
|
event = JSON.parse(line)
|
} catch (error) {
|
fail(`line ${index + 1} is not JSON`)
|
}
|
|
if (!allowedEvents.has(event.event)) {
|
fail(`line ${index + 1} has unsupported event ${event.event}`)
|
}
|
requireSameIdentity(event)
|
if (!Number.isInteger(event.seq) || event.seq <= lastSeq) {
|
fail(`${event.event} seq must be strictly increasing`)
|
}
|
lastSeq = event.seq
|
|
if (event.event === 'reply_playback_mode_selected') {
|
if (!['streaming_tts', 'full_tts_fallback'].includes(event.replyPlaybackMode)) {
|
fail('reply_playback_mode_selected has invalid replyPlaybackMode')
|
}
|
playbackMode = event.replyPlaybackMode
|
}
|
|
if (event.event === 'reply_state') {
|
if (!event.state) {
|
fail('reply_state missing state')
|
}
|
if (event.state === 'reply_playback_started') {
|
sawPlaybackStarted = true
|
}
|
}
|
|
if (event.event === 'reply_audio_chunk') {
|
if (!playbackMode) {
|
fail('reply_audio_chunk arrived before reply_playback_mode_selected')
|
}
|
if (!sawPlaybackStarted) {
|
fail('reply_audio_chunk arrived before reply_playback_started')
|
}
|
const chunk = event.audioChunk
|
if (!chunk) {
|
fail('reply_audio_chunk missing audioChunk')
|
}
|
if (!supportedAudioFormats.has(chunk.format)) {
|
fail(`fixture fast path only accepts ${Array.from(supportedAudioFormats).join('/')}, got ${chunk.format}`)
|
}
|
const payload = Buffer.from(chunk.payloadBase64 || '', 'base64')
|
if (payload.length === 0) {
|
fail(`${chunk.format} payload must be non-empty`)
|
}
|
if (chunk.format === 'pcm_s16le') {
|
if (chunk.sampleRate !== 48000 || chunk.channels !== 1) {
|
fail('pcm_s16le chunk must be 48000Hz mono')
|
}
|
if (payload.length % 2 !== 0) {
|
fail('pcm_s16le payload must be 16-bit aligned')
|
}
|
} else {
|
if (chunk.sampleRate != null && !Number.isInteger(chunk.sampleRate)) {
|
fail(`${chunk.format} sampleRate must be an integer when present`)
|
}
|
if (chunk.channels != null && !Number.isInteger(chunk.channels)) {
|
fail(`${chunk.format} channels must be an integer when present`)
|
}
|
}
|
audioChunkCount += 1
|
}
|
|
if (event.event === 'turn_completed') {
|
sawCompleted = true
|
}
|
if (event.event === 'turn_failed') {
|
sawFailed = true
|
}
|
if (event.event === 'turn_cancelled') {
|
sawCancelled = true
|
}
|
}
|
|
if (lines.length === 0) {
|
fail('fixture is empty')
|
}
|
if (!sawCompleted && !sawFailed && !sawCancelled) {
|
fail('fixture must end with turn_completed, turn_failed or turn_cancelled')
|
}
|
if (sawCompleted && audioChunkCount === 0) {
|
fail('completed streaming fixture must contain at least one audio chunk')
|
}
|
|
console.log(JSON.stringify({
|
ok: true,
|
file: path.relative(process.cwd(), absolute),
|
eventCount: lines.length,
|
audioChunkCount,
|
terminal: sawCompleted ? 'turn_completed' : sawFailed ? 'turn_failed' : 'turn_cancelled',
|
}))
|