> For the complete documentation index, see [llms.txt](https://docs.anthriq.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.anthriq.com/bxi-studio/developer/overview/backend-services/recording.md).

# Record and play back

`RecordingClient` writes sample data to disk, plays it back, annotates it with markers, and exports it. Reach it as `services.recording`, or construct it standalone.

## Method reference

### Recording

| Method               | Payload                                                   | Response data                                                 |
| -------------------- | --------------------------------------------------------- | ------------------------------------------------------------- |
| `startRecording`     | `{ recordingId, deviceId, sampleRate, numChannels, ... }` | `{ recordingId, batchSize, recordingDir }`                    |
| `writeBatch`         | `{ recordingId, values, timestamps }`                     | Empty                                                         |
| `stopRecording`      | `{ recordingId }`                                         | `{ recordingId, totalSamples, totalDurationMs, numSegments }` |
| `getRecordingStatus` | `{ recordingId? }`                                        | `{ recordingId, active, totalSamples }`                       |

### Playback

| Method              | Payload                       | Response data                                                           |
| ------------------- | ----------------------------- | ----------------------------------------------------------------------- |
| `startPlayback`     | `{ recordingId, channels? }`  | `{ playbackId, recordingId, totalDurationUs, sampleRate, numChannels }` |
| `getNextBatch`      | `{ playbackId }`              | `{ values?, timestamps?, numSamples?, positionUs, speed?, state? }`     |
| `pausePlayback`     | `{ playbackId }`              | Empty                                                                   |
| `stopPlayback`      | `{ playbackId }`              | Empty                                                                   |
| `seek`              | `{ playbackId, timestampUs }` | `{ positionUs }`                                                        |
| `setPlaybackSpeed`  | `{ playbackId, speed }`       | `{ speed }`                                                             |
| `getPlaybackStatus` | `{ playbackId }`              | `{ playbackId, state, positionUs, totalDurationUs, speed }`             |

### Management and markers

| Method             | Payload                                                               | Response data                                 |
| ------------------ | --------------------------------------------------------------------- | --------------------------------------------- |
| `listRecordings`   | `{ deviceId?, kind?, search?, limit?, offset?, sortBy?, sortOrder? }` | `{ recordings, total }`                       |
| `getRecordingInfo` | `{ recordingId }`                                                     | Full metadata and storage layout              |
| `updateMetadata`   | `{ recordingId, updates }`                                            | Empty                                         |
| `deleteRecording`  | `{ recordingId }`                                                     | Empty                                         |
| `exportRecording`  | `{ recordingId, format, location, includeFilters? }`                  | `{ format, folder, filePath, metaPath? }`     |
| `addMarkers`       | `{ recordingId, defs?, markers }`                                     | `{ recordingId, written }`                    |
| `getMarkers`       | `{ recordingId, fromUs?, toUs?, defIds?, source?, limit?, offset? }`  | `{ defs, markers, total, returned, hasMore }` |
| `getMarkerDefs`    | `{ recordingId }`                                                     | `{ defs }`                                    |
| `deleteMarkers`    | `{ recordingId, seqs? }`                                              | `{ recordingId, removed }`                    |

> **Note:** Every playback position and marker time is **microseconds since the start of the recording**, deliberately not wall-clock, so nothing needs re-anchoring when a recording is replayed later.

## Record a session

{% tabs %}
{% tab title="Node.js" %}

```typescript
import { RecordingClient } from "@anthriq_dev/services";

const recording = new RecordingClient({
  endpoint: "ipc:///tmp/bxi-recording.sock",
  storagePath: "/data/recordings",
});
await recording.connect();

const started = await recording.startRecording({
  recordingId: "session-001",
  deviceId: "instinct-a1",
  deviceName: "Instinct E13",
  sampleRate: 1000,
  numChannels: 8,
  channels: [
    { id: 0, label: "Fp1", unit: "uV" },
    { id: 1, label: "Fp2", unit: "uV" },
  ],
  metadata: { subject: "S07", protocol: "resting-state" },
  kind: "experiment",
});

const batchSize = started.data?.batchSize;
```

{% endtab %}

{% tab title="Python" %}

```python
from anthriq_services import RecordingClient

recording = RecordingClient(
    "ipc:///tmp/bxi-recording.sock",
    storage_path="/data/recordings",
)
await recording.connect()

started = await recording.start_recording(
    recording_id="session-001",
    device_id="instinct-a1",
    device_name="Instinct E13",
    sample_rate=1000,
    num_channels=8,
    channels=[
        {"id": 0, "label": "Fp1", "unit": "uV"},
        {"id": 1, "label": "Fp2", "unit": "uV"},
    ],
    metadata={"subject": "S07", "protocol": "resting-state"},
    kind="experiment",
)

batch_size = started.data["batchSize"]
```

{% endtab %}
{% endtabs %}

Write batches of `batchSize` samples per channel to match the segment layout.

`kind` tags what produced the recording and decides which screen lists it. The service defaults it to `"stream"`. The type is open-ended, so a new producer can tag its recordings without an SDK change.

### Write sample batches

`values` is channels-first, so `values[channel][sample]`. `timestamps` has one entry per sample.

{% tabs %}
{% tab title="Node.js" %}

```typescript
await recording.writeBatch({
  recordingId: "session-001",
  values: [
    [1.2, 1.3, 1.1],   // channel 0
    [0.8, 0.9, 0.7],   // channel 1
  ],
  timestamps: [0, 1000, 2000],
});
```

{% endtab %}

{% tab title="Python" %}

```python
await recording.write_batch(
    recording_id="session-001",
    values=[
        [1.2, 1.3, 1.1],    # channel 0
        [0.8, 0.9, 0.7],    # channel 1
    ],
    timestamps=[0, 1000, 2000],
)
```

{% endtab %}
{% endtabs %}

Match the batch length to `batchSize` from the start response. The service sizes it against the segment layout, and matching it avoids partial row groups.

### Stop

{% tabs %}
{% tab title="Node.js" %}

```typescript
const status = await recording.getRecordingStatus({ recordingId: "session-001" });
console.log(status.data?.active, status.data?.totalSamples);

const stopped = await recording.stopRecording({ recordingId: "session-001" });
console.log(stopped.data?.totalSamples, stopped.data?.numSegments);
```

{% endtab %}

{% tab title="Python" %}

```python
status = await recording.get_recording_status("session-001")
print(status.data["active"], status.data["totalSamples"])

stopped = await recording.stop_recording("session-001")
print(stopped.data["totalSamples"], stopped.data["numSegments"])
```

{% endtab %}
{% endtabs %}

300,000 samples at 4 kHz is 75 seconds, split across five segment files.

Omit `recordingId` from `getRecordingStatus` to query the active recording.

> **Warning:** A recording that is never stopped leaves its final segment unclosed. The samples are on disk, but the duration and segment index are incomplete, and playback of that segment fails.

## Play back

Playback is pull-based: start a session, then call `getNextBatch` on your own cadence.

{% tabs %}
{% tab title="Node.js" %}

```typescript
const playback = await recording.startPlayback({
  recordingId: "session-001",
  channels: [0, 1],
});

const playbackId = playback.data!.playbackId;

while (true) {
  const batch = await recording.getNextBatch({ playbackId });
  if (batch.data?.state === "completed") break;
  render(batch.data?.values, batch.data?.timestamps);
}

await recording.stopPlayback({ playbackId });
```

{% endtab %}

{% tab title="Python" %}

```python
playback = await recording.start_playback("session-001", channels=[0, 1])
playback_id = playback.data["playbackId"]

while True:
    batch = await recording.get_next_batch(playback_id)
    if batch.data.get("state") == "completed":
        break
    render(batch.data.get("values"), batch.data.get("timestamps"))

await recording.stop_playback(playback_id)
```

{% endtab %}
{% endtabs %}

Pass `channels` to read a subset; omitting it reads every channel. Playback state is `playing`, `paused`, `stopped`, or `completed`.

### Seek and change speed

{% tabs %}
{% tab title="Node.js" %}

```typescript
await recording.seek({ playbackId, timestampUs: 5_000_000 });   // 5 s in
await recording.setPlaybackSpeed({ playbackId, speed: 2.0 });
await recording.pausePlayback({ playbackId });

const status = await recording.getPlaybackStatus({ playbackId });
console.log(status.data?.state, status.data?.positionUs);
```

{% endtab %}

{% tab title="Python" %}

```python
await recording.seek(playback_id=playback_id, timestamp_us=5_000_000)   # 5 s in
await recording.set_playback_speed(playback_id=playback_id, speed=2.0)
await recording.pause_playback(playback_id)

status = await recording.get_playback_status(playback_id)
print(status.data["state"], status.data["positionUs"])
```

{% endtab %}
{% endtabs %}

`seek` returns the position actually reached, which is the nearest sample boundary rather than the exact request. Read `positionUs` from the response rather than assuming.

## Browse recordings

{% tabs %}
{% tab title="Node.js" %}

```typescript
const list = await recording.listRecordings({
  kind: ["experiment", "stream"],
  deviceId: "instinct-a1",
  search: "resting",
  limit: 50,
  sortBy: "started_at",
  sortOrder: "desc",
});

for (const item of list.data?.recordings ?? []) {
  console.log(item.recordingId, item.kind, item.durationMs);
}
```

{% endtab %}

{% tab title="Python" %}

```python
listing = await recording.list_recordings(
    kind=["experiment", "stream"],
    device_id="instinct-a1",
    search="resting",
    limit=50,
    sort_by="started_at",
    sort_order="desc",
)

for item in listing.data.get("recordings", []):
    print(item["recordingId"], item["kind"], item["durationMs"])
```

{% endtab %}
{% endtabs %}

`kind` accepts one tag or an array. `sortBy` accepts `started_at`, `duration_ms`, or `total_samples`.

`getRecordingInfo` adds the storage layout that `listRecordings` omits: the channel table, `segmentDurationSec`, `rowGroupDurationSec`, `batchSize`, and a `segments` array with each file's time span.

> **Warning:** `deleteRecording` removes the files from disk. There is no undo and no trash folder.

## Annotate with markers

Markers annotate the recorded timeline: a key press, a hardware trigger, an experiment epoch. The service stores when each fired and a label, and never interprets what they mean, which is why one API serves all three.

Markers use a dictionary and occurrence split. A definition describes a *kind* of marker once; each occurrence references it by `defId`. A 1500-epoch run carries a handful of definitions rather than 1500.

| Definition field | Description                                                      |
| ---------------- | ---------------------------------------------------------------- |
| `id`             | Referenced by an occurrence's `defId`; stable within a recording |
| `name`           | Display label                                                    |
| `color`          | Display colour                                                   |
| `kind`           | `"event"` for a point in time, `"epoch"` for a span              |
| `source`         | Origin, uninterpreted by the service                             |

| Occurrence field | Description                                   |
| ---------------- | --------------------------------------------- |
| `seq`            | Ordinal; assigned by the service when omitted |
| `defId`          | Definition this references                    |
| `tUs`            | Microseconds from the start of the recording  |
| `durUs`          | Duration; `0` or omitted means a point event  |
| `data`           | Free-form payload, never interpreted          |

{% tabs %}
{% tab title="Node.js" %}

```typescript
await recording.addMarkers({
  recordingId: "session-001",
  defs: [
    { id: 1, name: "Stimulus", color: "#FF5C00", kind: "event", source: "hardware" },
    { id: 2, name: "Trial", color: "#634391", kind: "epoch", source: "experiment" },
  ],
  markers: [
    { defId: 1, tUs: 1_000_000 },
    { defId: 2, tUs: 1_000_000, durUs: 2_000_000, data: { rep: 7 } },
  ],
});
```

{% endtab %}

{% tab title="Python" %}

```python
await recording.add_markers(
    recording_id="session-001",
    defs=[
        {"id": 1, "name": "Stimulus", "color": "#FF5C00", "kind": "event", "source": "hardware"},
        {"id": 2, "name": "Trial", "color": "#634391", "kind": "epoch", "source": "experiment"},
    ],
    markers=[
        {"defId": 1, "tUs": 1_000_000},
        {"defId": 2, "tUs": 1_000_000, "durUs": 2_000_000, "data": {"rep": 7}},
    ],
)
```

{% endtab %}
{% endtabs %}

Always batch. Re-sending `defs` on every batch is safe, because definitions upsert, which keeps each batch self-contained. `addMarkers` is callable while the recording is still active.

Supply `seq` explicitly to replace an existing occurrence, which makes a retry idempotent. Without it, retrying a failed batch appends duplicates.

### Read markers

{% tabs %}
{% tab title="Node.js" %}

```typescript
const result = await recording.getMarkers({
  recordingId: "session-001",
  fromUs: 0,
  toUs: 10_000_000,
  defIds: [1],
  limit: 500,
});

console.log(result.data?.defs);      // Returned only on the first page
console.log(result.data?.total, result.data?.hasMore);
```

{% endtab %}

{% tab title="Python" %}

```python
result = await recording.get_markers(
    recording_id="session-001",
    from_us=0,
    to_us=10_000_000,
    def_ids=[1],
    limit=500,
)

print(result.data["defs"])      # Returned only on the first page
print(result.data["total"], result.data["hasMore"])
```

{% endtab %}
{% endtabs %}

`fromUs` and `toUs` are inclusive. `limit` defaults to 500. The `defs` legend comes back only when `offset` is 0 or absent, since it is constant for a recording.

> **Warning:** `deleteMarkers` with no `seqs`, or an empty array, deletes every marker for the recording along with its definitions.

## Export

{% tabs %}
{% tab title="Node.js" %}

```typescript
const result = await recording.exportRecording({
  recordingId: "session-001",
  format: "edf+",
  location: "/data/exports",
  includeFilters: true,
});

console.log(result.data?.filePath);
```

{% endtab %}

{% tab title="Python" %}

```python
result = await recording.export_recording(
    recording_id="session-001",
    format="edf+",
    location="/data/exports",
    include_filters=True,
)

print(result.data["filePath"])
```

{% endtab %}
{% endtabs %}

| Format | Output                                   |
| ------ | ---------------------------------------- |
| `csv`  | A `.csv` file plus a `meta.json` sidecar |
| `edf`  | An `.edf` file                           |
| `edf+` | An `.edf` file with annotations          |

The export lands in `{location}/{recordingId}/`. Export a stopped recording: exporting an active one captures only the segments closed so far.

## Next steps

* [Read recording files](/bxi-studio/developer/overview/backend-services/recording-files.md)
* [Run pipelines](/bxi-studio/developer/overview/backend-services/pipelines.md)
* [Open a recording in BXI Studio](/bxi-studio/streams/open-recording.md)
