Recording a call
When someone says “it talked over me” or “it cut me off”, a transcript cannot settle
it and a memory of the call is not evidence. @kuralle-syrinx/recorder writes the two
speakers onto one timeline so overlap is visible.
npm install @kuralle-syrinx/recorderAttach it
Section titled “Attach it”import { attachRecorder } from '@kuralle-syrinx/recorder';
const session = new VoiceAgentSession({ plugins: { /* … */ } });const recording = attachRecorder(session, { outputDir: './recordings' });
// … run the conversation …
await session.close();console.log(recording.files?.conversationAudioPath);files is null until the session initializes the plugin, and final once it closes —
so read it after close(), not before.
What you get
Section titled “What you get”From a real 11-second turn:
| File | Contents |
|---|---|
conversation.wav | 2ch 16 kHz — caller left, assistant right, one timeline |
user_audio.pcm | caller stem, mono, at the negotiated uplink rate |
assistant_audio.pcm | assistant stem, mono, at the synthesis rate |
events.jsonl | every bus packet in order (993 on that turn) |
manifest.json | paths, sample rates, durations, byte counts |
The stems keep each side’s native rate; the conversation WAV resamples to a common one so the channels line up.
The alignment is the point
Section titled “The alignment is the point”Assistant audio is re-anchored onto the playout clock from tts.playout_progress,
not the moment TTS generated it. Those are not the same instant — synthesis finishes
well before the caller has heard the sentence. Positioning by generation time would
draw the agent replying earlier than it actually spoke, which is exactly the error you
are trying to detect when investigating an interruption.
Reading a recording
Section titled “Reading a recording”A clean, non-overlapping turn looks like this when you measure the two channels:
LEFT (caller) peak 32767 non-silent 23.5%RIGHT (assistant) peak 27686 non-silent 25.9%overlap 0.0%Non-zero overlap means the two spoke at once. That is not automatically a bug — barge-in should produce overlap — but unexplained overlap on a turn where nobody interrupted is a real finding.
Transcribe each channel
Section titled “Transcribe each channel”Levels prove a file is not empty; they cannot catch a channel swap or TTS speaking text the reasoner never produced. Transcribing each side with an independent STT can. On the recording above, whisper — which never saw the engine’s output — returned:
LEFT (caller) "What's the application deadline for the computer science masters?"RIGHT (assistant) "Please specify the University for the application deadline."matching the engine’s reported transcript and reply exactly. That is the check worth automating: it proves the caller landed left, the assistant landed right, and the voice spoke what the reasoner generated.
validateVoiceSessionRecorderManifest(manifest) returns a list of problems, empty when
the manifest is internally consistent. Worth running in CI: a recording that wrote zero
bytes should fail a build rather than pass because the file exists.
Recording on Cloudflare
Section titled “Recording on Cloudflare”On Workers the recorder is R2EdgeRecorder, which implements the same
EdgeRecorder interface the edge host injects:
import { R2EdgeRecorder } from '@kuralle-syrinx/cf-agents/r2-recorder';
const recorder = new R2EdgeRecorder({ bucket: env.RECORDINGS, // R2 binding sessionId, startedAtMs: Date.now(), storageClass: 'InfrequentAccess', // recordings are write-once, rarely read});Objects land at recordings/<sessionId>/<startedAtMs>/ — user.wav,
assistant.wav, conversation.wav, manifest.json.
Any S3-compatible bucket
Section titled “Any S3-compatible bucket”S3ObjectStore speaks the S3 REST API, so recordings can land in AWS S3, R2’s S3
endpoint, MinIO, Backblaze B2 or Wasabi:
import { S3ObjectStore } from '@kuralle-syrinx/cf-agents/s3-store';
const store = new S3ObjectStore({ bucket: 'recordings', endpoint: 'https://<account-id>.r2.cloudflarestorage.com', // or s3.<region>.amazonaws.com accessKeyId: env.S3_ACCESS_KEY_ID, secretAccessKey: env.S3_SECRET_ACCESS_KEY, region: 'auto', // R2 ignores it, SigV4 still signs one forcePathStyle: true, // required by R2 and MinIO});It signs SigV4 over fetch rather than bundling an AWS SDK, because this runs inside
a Worker where bundle size is the constraint.
Both stores implement ObjectStore — five methods, no storage assumptions — so the
timeline logic has exactly one implementation regardless of where bytes land.
Building the mix yourself
Section titled “Building the mix yourself”The ./wav subpath exports interleaveStereoPcm16 and pcm16ToWav if you would rather
assemble the stereo file from the stems on your own terms.
- Background observer — the other thing you attach to a live session, for guardrails rather than evidence.
- Observability — metrics and the packet stream.