Skip to content

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.

Terminal window
npm install @kuralle-syrinx/recorder
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.

From a real 11-second turn:

FileContents
conversation.wav2ch 16 kHz — caller left, assistant right, one timeline
user_audio.pcmcaller stem, mono, at the negotiated uplink rate
assistant_audio.pcmassistant stem, mono, at the synthesis rate
events.jsonlevery bus packet in order (993 on that turn)
manifest.jsonpaths, 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.

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.

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.

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.

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.

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.

The ./wav subpath exports interleaveStereoPcm16 and pcm16ToWav if you would rather assemble the stereo file from the stems on your own terms.