cai
2026-08-11 dc98e7dbb2e909beab577f287da8dccac374c714
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
#!/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,
}))