> 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-files.md).

# Read recording files

A recording is a directory of files, not a single document. This page describes what each file holds, how the sample data is laid out, and how to decode the marker format — including the `meta.json` an export produces.

[Record and play back](/bxi-studio/developer/overview/backend-services/recording.md) covers making recordings. This page covers reading what they leave on disk.

## Find the directory

Each recording lives in its own folder under the recording service's storage path:

```
{storagePath}/recordings/{recordingId}/
├── metadata.json          Identity, channels, timing
├── segments.json          The segment list
├── markers.json           Annotations on the timeline
├── segment_00000.parquet  Sample data
├── segment_00001.parquet
└── ...
```

`start_recording` returns this path as `recordingDir`. Segment files are numbered from zero and padded to five digits.

A recording is split into segments so that a long session is not one unbounded file. Each segment targets roughly 250 MB of raw samples, held between one minute and one hour of wall-clock time.

## Read metadata.json

| Field                    | Type     | Meaning                                                            |
| ------------------------ | -------- | ------------------------------------------------------------------ |
| `recordingId`            | `string` | Identifier, and the folder name                                    |
| `deviceId`, `deviceName` | `string` | Which device produced it                                           |
| `startedAt`, `endedAt`   | `string` | ISO timestamps                                                     |
| `sampleRate`             | `number` | Samples per second per channel                                     |
| `numChannels`            | `number` | Channel count                                                      |
| `channels`               | `array`  | `{ id, label, unit }` per channel; `unit` defaults to `uV`         |
| `totalSamples`           | `number` | Samples per channel across every segment                           |
| `totalDurationMs`        | `number` | Recording length                                                   |
| `numSegments`            | `number` | Segment file count                                                 |
| `segmentDurationSec`     | `number` | Target seconds per segment                                         |
| `rowGroupDurationSec`    | `number` | Target seconds per Parquet row group                               |
| `batchSize`              | `number` | Samples per batch, which is one Parquet row                        |
| `metadata`               | `object` | Whatever you passed to `start_recording` — tags, notes, subject id |
| `kind`                   | `string` | `"stream"` or `"experiment"`                                       |

`kind` is recorded but not interpreted. Both kinds have identical file layout, playback, and export; the field exists so an application can list them separately.

> **Note:** `channels` can be empty even when `numChannels` is set. Fall back to `ch1`…`chN` for labels and `uV` for units, which is what the exporter does.

The segment list is written to `segments.json` rather than inlined, so read that file for the per-segment entries:

| Field                      | Meaning                             |
| -------------------------- | ----------------------------------- |
| `file`                     | Segment filename                    |
| `startTimeUs`, `endTimeUs` | Segment bounds, microseconds        |
| `samples`                  | Samples per channel in this segment |

## Read a Parquet segment

Segments are Parquet, Snappy-compressed. The schema is **batch-oriented**: one row is one batch of samples, not one sample.

| Column                                      | Arrow type     | Holds                                                       |
| ------------------------------------------- | -------------- | ----------------------------------------------------------- |
| `batch_time_us`                             | `int64`        | Batch start, microseconds                                   |
| `num_samples`                               | `int32`        | Samples in this batch                                       |
| `channel_0_values` … `channel_{N-1}_values` | `list<double>` | One list per channel                                        |
| `timestamps`                                | `list<double>` | Per-sample timestamps, integer microseconds held as doubles |

Each list in a row has `num_samples` entries. To get a flat per-channel series, concatenate the lists across rows, and across segment files in order.

{% tabs %}
{% tab title="Python" %}

```python
import glob
import pyarrow.parquet as pq

series = {}

for path in sorted(glob.glob(f"{recording_dir}/segment_*.parquet")):
    table = pq.read_table(path)
    for column in table.column_names:
        if not column.startswith("channel_"):
            continue
        flat = [value for batch in table[column].to_pylist() for value in batch]
        series.setdefault(column, []).extend(flat)

print({name: len(values) for name, values in series.items()})
```

{% endtab %}
{% endtabs %}

> **Warning:** Sort segment files by name before concatenating. Directory order is not guaranteed, and an out-of-order concatenation produces a series that looks continuous but has time running backwards at each segment boundary.

## Decode markers.json

Markers are annotations on the recorded timeline — a key press, a hardware trigger, an experiment epoch. The service stores when each one happened and a label identifying it. It does not interpret what any of them mean.

The file uses a **dictionary plus delta** encoding:

```json
{
  "v": 1,
  "defs": [
    { "id": 0, "name": "stimulus", "color": "#e11", "kind": "event", "source": "experiment" },
    { "id": 1, "name": "rest", "color": "#1a1", "kind": "epoch", "source": "experiment" }
  ],
  "seq": {
    "def": [0, 0, 1],
    "dt":  [500000, 250000, 250000],
    "dur": [0, 0, 2000000],
    "data": { "2": { "repetition": 3 } }
  }
}
```

### Understand the two halves

`defs` is the legend: one entry per distinct **kind** of marker, written once.

| Field    | Meaning                                                                                                   |
| -------- | --------------------------------------------------------------------------------------------------------- |
| `id`     | Referenced by entries in `seq.def`                                                                        |
| `name`   | Label                                                                                                     |
| `color`  | Display colour, carried through verbatim                                                                  |
| `kind`   | `"event"` for a point in time, `"epoch"` for something with a duration                                    |
| `source` | Where it came from — `"keyboard"`, `"hardware"`, `"manual"`, `"experiment"`, or anything a caller invents |

`seq` holds the occurrences as parallel arrays, one entry per occurrence:

| Array  | Meaning                                                                                                     |
| ------ | ----------------------------------------------------------------------------------------------------------- |
| `def`  | Index into `defs`, by `id`                                                                                  |
| `dt`   | Gap in microseconds from the **previous** occurrence, not an absolute time                                  |
| `dur`  | Duration in microseconds; `0` for a point event                                                             |
| `data` | Sparse object, keyed by occurrence index as a **string**; present only for occurrences that carry a payload |

The split exists because a 300-trial run over five marker kinds produces 1500 occurrences against five definitions. Repeating the label and colour on each occurrence would be far larger than referencing an index.

### Restore absolute times

`dt` is delta-encoded, so recover absolute times with a running sum. Times are **microseconds since the start of the recording**, not wall-clock — the same base as `segments.json` and the playback seek API, so nothing needs re-anchoring at playback time.

{% tabs %}
{% tab title="Python" %}

```python
import json

with open(f"{recording_dir}/markers.json") as handle:
    doc = json.load(handle)

defs = {d["id"]: d for d in doc["defs"]}
seq = doc["seq"]
payloads = seq.get("data", {})

markers = []
t_us = 0
for index, def_id in enumerate(seq["def"]):
    t_us += seq["dt"][index]
    markers.append({
        "seq": index,
        "name": defs[def_id]["name"],
        "kind": defs[def_id]["kind"],
        "t_us": t_us,
        "dur_us": seq["dur"][index],
        "data": payloads.get(str(index)),
    })

for marker in markers:
    print(marker["t_us"] / 1e6, marker["name"], marker["data"])
```

{% endtab %}

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

```javascript
import { readFileSync } from "fs";

const doc = JSON.parse(readFileSync(`${recordingDir}/markers.json`, "utf8"));
const defs = new Map(doc.defs.map((d) => [d.id, d]));
const { def, dt, dur, data = {} } = doc.seq;

let tUs = 0;
const markers = def.map((defId, index) => {
  tUs += dt[index];
  return {
    seq: index,
    name: defs.get(defId).name,
    kind: defs.get(defId).kind,
    tUs,
    durUs: dur[index],
    data: data[String(index)],
  };
});

for (const marker of markers) {
  console.log(marker.tUs / 1e6, marker.name, marker.data);
}
```

{% endtab %}
{% endtabs %}

> **Warning:** Index `data` with the occurrence index converted to a string. It is a sparse JSON object keyed by position, not an array, so a numeric lookup misses every payload.

Occurrences are stored sorted ascending by time. A running sum only produces correct absolutes on that ordering, so do not re-sort `seq` before decoding it.

An absent `markers.json` means no markers were recorded. The service writes the file only when there is something to write, so treat a missing file as an empty set rather than as a damaged recording.

## Read an export

Exporting writes a self-contained folder — the data file plus a `meta.json` sidecar — so a recording can be reconstructed on another machine without the service or its index:

```
{location}/{recordingId}/
├── meta.json
└── {recordingId}.csv     or  {recordingId}.edf
```

`meta.json` is `metadata.json` with markers folded in. It is a different file from the `metadata.json` in the recording directory: same fields, plus a `markers` block when the recording has any.

| Field      | Meaning                                              |
| ---------- | ---------------------------------------------------- |
| `v`        | Marker format version                                |
| `defs`     | The legend, as in `markers.json`                     |
| `seq`      | The occurrences, same delta encoding                 |
| `timeBase` | `"us"` — stated so an importer never infers the unit |
| `origin`   | `"recordingStart"` — states what `t = 0` means       |
| `count`    | Total occurrences                                    |

`timeBase` and `origin` are written explicitly for exactly the reason they look redundant: an export leaves the system that produced it, and a reader on the other side has no other way to learn what the numbers mean. Decode `defs` and `seq` here with the same running sum shown above.

Passing `includeFilters: false` strips `metadata.filters` from the sidecar and leaves everything else intact.

### CSV

One row per sample. The header is the channel labels in order, then `timestamp`:

```
Fp1,Fp2,Cz,timestamp
12.5,-3.25,0.75,1718000000123456
```

Timestamps are integer microseconds. Values are written with enough significant digits to round-trip the stored doubles.

Markers are **not** in the CSV. They are in `meta.json` alongside it.

### EDF and EDF+

EDF+ additionally writes markers as standard annotations, which EDFbrowser, MNE and similar tools read without knowing anything about BXI:

* Onset and duration are in **100-microsecond ticks**, not microseconds.
* A point event carries a duration of `-1`, per the EDF+ API.
* Labels are truncated to 40 characters.

The annotation channel has a bounded number of slots, so a recording with more markers than slots keeps the excess in `meta.json` only. `meta.json` always holds the full set at full fidelity — colour, source, and per-occurrence data — which annotations cannot represent.

Plain `.edf` carries no annotations at all. Its markers live only in `meta.json`.

## Next steps

* [Record and play back](/bxi-studio/developer/overview/backend-services/recording.md)
* [Run pipelines](/bxi-studio/developer/overview/backend-services/pipelines.md)
* [Connect to the services](/bxi-studio/developer/overview/backend-services/connect.md)
