# Record and Analyze a Call

> Record both legs of a call as a stereo WAV. Fetch the recording URL from the CDR. Split the channels for per-speaker analysis.

Recording captures **both legs** of a call as a single stereo file, keyed on the
call's `sid`. The caller is on the **left** channel. The agent (AI or human) is
on the **right** channel. Because the two speakers land on separate channels,
you can run each side through its own transcript or scorer without a speaker-ID
model. After a call ends, the pipeline uploads the recording to your workspace's
object storage. It stamps the URL onto the call's CDR. The control plane can
then give you a time-limited download link.

> **WARNING:**
> Recording is a **workspace-level** setting today. There is **no per-call
> `record: true` flag** on `calls.originate(...)`, and `CallData` carries no
> recording field. Turn recording on once for the workspace, in the Contact Center
> or Voice AI console, or through your operator. The system then records
> **every** call on that workspace.
> You cannot opt a single call in or out from the SDK. A per-call record toggle is
> not shipped yet.

## How it works

  1. **Capture (automatic once enabled)**
The media plane taps both RTP legs. It mixes them into a stereo stream
    (caller = L, agent = R). It streams the result to the recording pipeline.
    No SDK call is involved.
  2. **Finalize**
On hang-up, the pipeline writes a stereo WAV to your workspace's object
    storage. It stamps `recording_url` and the byte size onto the call's CDR.
  3. **Fetch**
Read the CDR to find the recording. Then mint a short-lived presigned
    download URL keyed on the `sid`.

## Find recordings and get a download URL

The recording URL is stored on the CDR, not on the live `Call` object. For this
reason, you fetch it through the control-plane API, not through the audio SDK.
Two routes do the job. `cdr.recordings` lists calls that produced a recording.
`storage.signedUrl` returns a one-hour presigned download link for a given
`sid`. Both routes authenticate with your API key.

  
```ts
const API = "https://engine.firstbyte.fast";
const KEY = process.env.CLUTCHCALL_CREDENTIALS!;
const ORG = "org_abc";

// Small tRPC caller — the audio SDK talks to these same endpoints under the hood.
async function trpc<T>(path: string, input: unknown): Promise<T> {
  const url = new URL(`/api/trpc/${path}`, API);
  url.searchParams.set("input", JSON.stringify(input));
  const res = await fetch(url, {
    headers: { authorization: `Bearer ${KEY}` },
  });
  if (!res.ok) throw new Error(`${path} ${res.status}`);
  const body = await res.json();
  return body.result.data as T;
}

// 1. list the most recent calls that have a recording
const { rows } = await trpc<{ rows: Array<{ call_id: string; duration: number }> }>(
  "cdr.recordings",
  { orgId: ORG, limit: 20 },
);

// 2. mint a 1-hour download URL for one of them
const { url, expiresIn } = await trpc<{ url: string; expiresIn: number }>(
  "storage.signedUrl",
  { orgId: ORG, callSid: rows[0].call_id },
);

console.log("download for", expiresIn, "s:", url);
```
  
  
```python
import os, json, urllib.parse, urllib.request

API = "https://engine.firstbyte.fast"
KEY = os.environ["CLUTCHCALL_CREDENTIALS"]
ORG = "org_abc"

def trpc(path: str, params: dict):
    q = urllib.parse.urlencode({"input": json.dumps(params)})
    req = urllib.request.Request(
        f"{API}/api/trpc/{path}?{q}",
        headers={"authorization": f"Bearer {KEY}"},
    )
    with urllib.request.urlopen(req) as r:
        return json.load(r)["result"]["data"]

# 1. list recent calls that produced a recording
rows = trpc("cdr.recordings", {"orgId": ORG, "limit": 20})["rows"]

# 2. mint a 1-hour download URL for one of them
signed = trpc("storage.signedUrl", {"orgId": ORG, "callSid": rows[0]["call_id"]})
print("download for", signed["expiresIn"], "s:", signed["url"])
```
  

> **NOTE:**
> The presigned URL expires in one hour. Mint it when you are about to download,
> not when you list. Requests are tenant-scoped. You can only presign recordings
> that belong to your `orgId`, even if you guess another workspace's `sid`.

### React to a finished recording (native SDK cores)

The native SDK cores (Python, Go, Rust, and the other FFI wrappers) send a
**`RECORDING_READY`** call event when the file is finalized. The event carries
`recording_url` and `duration_seconds`. With it, you can process recordings as
they complete instead of polling the CDR.

```python
from clutchcall-agents import parse_call_event

def on_event(evt_bytes):
    evt = parse_call_event(evt_bytes)
    if evt.status == "RECORDING_READY":
        print(evt.call_sid, evt.recording_url, evt.duration_seconds, "s")
        # evt.estimated_mos / evt.jitter_ms / evt.packets_lost are also populated
```

> **NOTE:**
> The **native** cores expose this event stream. The browser/TypeScript voice SDK
> has no recording-event surface. From a browser or Node app, use the CDR +
> `storage.signedUrl` path above.

## Analyze: split the channels

The download is a **stereo WAV**. The caller is on the left channel and the
agent is on the right channel. Split the two channels to get a clean
per-speaker signal. This channel split **is** your speaker separation. The
recording path has no built-in diarization or voiceprint. Run each channel
through your own ASR, or any scorer, independently.

  
```ts
// Deinterleave a 16-bit PCM stereo buffer into caller (L) and agent (R).
function splitStereoPcm16(interleaved: Int16Array) {
  const n = interleaved.length / 2;
  const caller = new Int16Array(n);
  const agent = new Int16Array(n);
  for (let i = 0; i < n; i++) {
    caller[i] = interleaved[2 * i];       // L
    agent[i] = interleaved[2 * i + 1];    // R
  }
  return { caller, agent };
}

// const wav = await (await fetch(url)).arrayBuffer();
// const pcm = new Int16Array(wav, 44);   // skip the 44-byte WAV header
// const { caller, agent } = splitStereoPcm16(pcm);
// await myAsr.transcribe(caller);   // what the caller said
// await myAsr.transcribe(agent);    // what the agent said
```
  
  
```python
import wave, urllib.request

urllib.request.urlretrieve(signed["url"], "call.wav")

with wave.open("call.wav", "rb") as w:
    assert w.getnchannels() == 2 and w.getsampwidth() == 2
    frames = w.readframes(w.getnframes())

# de-interleave 16-bit stereo: even samples = caller (L), odd = agent (R)
import array
pcm = array.array("h", frames)
caller = pcm[0::2]   # left  — the caller
agent  = pcm[1::2]   # right — the AI or human agent

# my_asr.transcribe(caller.tobytes())
# my_asr.transcribe(agent.tobytes())
```
  

> **NOTE:**
> Speaker separation here comes entirely from the **stereo channel split**. The
> voice path has no speaker-ID, diarization, or voiceprint model. If both speakers
> were mixed to mono, you could not tell them apart. The two-channel recording is
> what makes per-speaker analysis reliable.

## Pair it with call quality

Every recorded call also has objective quality data on its CDR: an ITU-T
E-model **MOS** estimate, plus jitter and packet loss. The media plane computes
these values. Join them to the recording when you score a call. Then you can
attribute a low transcript confidence to a bad line rather than a bad agent.
See [MOS, jitter & loss](/modalities/voice/observability/mos-jitter-loss) for
the fields and how the system derives them.

## Related

  - **[MOS, jitter & loss](/modalities/voice/observability/mos-jitter-loss)** — The per-call quality metrics that live alongside the recording URL.
  - **[Call traces](/modalities/voice/observability/call-traces)** — Follow a single `sid` across signalling, media, and agent turns.
  - **[Stream audio to ASR](/modalities/voice/cookbooks/stream-to-asr)** — Transcribe live instead of after the fact. Feed uplink into your recognizer.
  - **[Observability overview](/modalities/voice/observability/overview)** — Recordings, CDRs, dashboards, and how they fit together.
