# Turn call transcripts into turns

> Recipe: split call transcripts that name each speaker into one row per turn, with the right direction and real timing.

## When you need it

Your call recording or meeting tool exports one row per call with the transcript in a cell, and each line starts with the speaker's name, sometimes after a time offset:

```text
[00:00:09] Priya Shah: Mostly good. The last two deliveries were late.
```

Unlike a `BOT:`/`USER:` transcript, a name doesn't say which side the speaker is on. A common shortcut, "the first person to speak is the customer", is wrong whenever your rep opens the call, which is most calls.

## Input

Example input, `calls.csv`. Sam Rivera and Dev Okafor work for Lumen Coffee; the other speakers are customers. The second call has no time offsets.

```csv title="calls.csv"
call_id,started_at,account_id,outcome,transcript
call-9001,2026-02-10T15:00:00Z,ACCT-204,renewed,"[00:00:02] Sam Rivera: Thanks for joining. How is the wholesale order going?
[00:00:09] Priya Shah: Mostly good. The last two deliveries were late.
[00:00:21] Sam Rivera: Sorry about that. We moved your route to a morning slot.
[00:00:30] Priya Shah: Great. Then we will renew for another year.
[00:00:31] Dev Okafor: I'll send the paperwork today."
call-9002,2026-02-12T18:30:00Z,ACCT-311,churn risk,"Jo Park: We're comparing prices with another roaster.
Sam Rivera: What would it take to stay?
Jo Park: A lower price on the espresso blend."
```

## Output

```csv title="call-turns.csv"
sessionId,userId,incoming,text,dimensionlabs_timestamp,conversationId,speaker_role,account_id,outcome,channel
call-9001,call-9001-s1,false,Thanks for joining. How is the wholesale order going?,1770735602000,call-9001,company,ACCT-204,renewed,phone
call-9001,call-9001-s2,true,Mostly good. The last two deliveries were late.,1770735609000,call-9001,customer,ACCT-204,renewed,phone
call-9001,call-9001-s1,false,Sorry about that. We moved your route to a morning slot.,1770735621000,call-9001,company,ACCT-204,renewed,phone
call-9001,call-9001-s2,true,Great. Then we will renew for another year.,1770735630000,call-9001,customer,ACCT-204,renewed,phone
call-9001,call-9001-s3,false,I'll send the paperwork today.,1770735631000,call-9001,company,ACCT-204,renewed,phone
call-9002,call-9002-s1,true,We're comparing prices with another roaster.,1770921000000,call-9002,customer,ACCT-311,churn risk,phone
call-9002,call-9002-s2,false,What would it take to stay?,1770921001000,call-9002,company,ACCT-311,churn risk,phone
call-9002,call-9002-s1,true,A lower price on the espresso blend.,1770921002000,call-9002,customer,ACCT-311,churn risk,phone
```

## Rules

- **Direction comes from a list of your people.** Put everyone who speaks for your company in `COMPANY`. Everyone else is the customer (`incoming: true`). The script prints the names it treated as customers so you can catch a colleague who is missing from the list.
- **Each participant keeps their own `userId`.** In a call between people, [each participant has their own id](/data/sessions-and-conversations#the-userid-rule). The script numbers speakers in order of appearance (`call-9001-s1`, `-s2`), so ids are stable across reruns and no names go into the file. A `speaker_role` column says `company` or `customer`.
- **Real timing when you have it.** A `[hh:mm:ss]` offset is added to the call's start time. Without offsets, each turn is one second after the one before. Either way, turns stay [at least a second apart](/data/conversation-format#small-rules-that-keep-results-clean) and in order.
- **One call is one session.** Use the call id as `sessionId` and `conversationId`, as [call transcripts](/data/reviews-surveys-tickets#call-transcripts) need.
- **Call-level fields ride along.** The account id and outcome become extra columns, which lets the agent join calls to [business data](/data/join-business-data) by account.
- **Summaries are not turns.** If your tool also exports an AI summary or "next steps" text, keep it out of the turns; it was not said by anyone on the call. Keep it as a column if you want it.

## Script

Node 18 or later, with `npm install papaparse`.

```js title="call-turns.mjs"
// Turn call transcripts with named speakers into one row per turn.
// Usage: node call-turns.mjs calls.csv call-turns.csv



const [input, output] = process.argv.slice(2);

// Everyone who speaks for your company. Anyone else is the customer (incoming).
const COMPANY = new Set(['sam rivera', 'dev okafor']);
// "[00:01:15] Name: text" or "Name: text". The name can't contain a colon.
const TURN = /^\s*(?:\[(\d{1,2}):(\d{2}):(\d{2})\]\s*)?([^:\[\]]{1,60}):\s+(.*)$/;

const { data } = Papa.parse(fs.readFileSync(input, 'utf8'), { header: true, skipEmptyLines: 'greedy' });
const rows = [];
const customerNames = new Set();

for (const call of data) {
  const start = Date.parse(call.started_at);
  const speakers = []; // order of first appearance, for stable ids without names
  let last = start - 1000;
  for (const line of call.transcript.split(/\r?\n/)) {
    const m = line.match(TURN);
    if (!m) {
      if (rows.length && line.trim() && rows.at(-1).sessionId === call.call_id) rows.at(-1).text += ' ' + line.trim();
      continue;
    }
    const [, h, mi, s, rawName, said] = m;
    const name = rawName.trim();
    if (!speakers.includes(name)) speakers.push(name);
    const fromCompany = COMPANY.has(name.toLowerCase());
    if (!fromCompany) customerNames.add(name);
    // Offset from the start of the call when the transcript has one, otherwise one second per turn.
    let ts = h !== undefined ? start + ((+h * 60 + +mi) * 60 + +s) * 1000 : last + 1000;
    if (ts < last + 1000) ts = last + 1000; // keep turns at least a second apart, in order
    last = ts;
    rows.push({
      sessionId: call.call_id,
      userId: `${call.call_id}-s${speakers.indexOf(name) + 1}`, // each participant keeps their own id
      incoming: fromCompany ? 'false' : 'true',
      text: said.trim(),
      dimensionlabs_timestamp: ts,
      conversationId: call.call_id,
      speaker_role: fromCompany ? 'company' : 'customer',
      account_id: call.account_id,
      outcome: call.outcome,
      channel: 'phone',
    });
  }
}

fs.writeFileSync(output, Papa.unparse(rows.filter((r) => r.text), { newline: '\n' }) + '\n');
console.log(`${data.length} calls -> ${rows.length} turns`);
console.log(`Treated as customers (check this list): ${[...customerNames].join(', ')}`);
```

```bash
node call-turns.mjs calls.csv call-turns.csv
# 2 calls -> 8 turns
# Treated as customers (check this list): Priya Shah, Jo Park
```

## Variations

- **A participants column with emails**: build `COMPANY` from it instead of typing names, by treating every participant whose email ends in your company's domain as company.
- **Speaker labels like `Speaker 1`**: you need a mapping per call (often in another column of the export) from speaker number to person. Without it, direction is a guess; [ask support](/transform#get-help-from-dimension-labs) before uploading.
- **Times like `01:15` (minutes and seconds)**: change the pattern to `\[(\d{1,2}):(\d{2})\]` and the offset to `(+mm * 60 + +ss) * 1000`.
