Multimodal & Media
Text, image, audio and video as data the app can read, align to a recording, train on and display. Media sets, the four binding routes, coverage rules, the six Media blocks, the paradigm runner and its measured onset band, and consent-gated response capture.
Media is data with a time base
A picture in a folder is a file. The same picture with an onset — 3.00 s into the recording, 1.00 s long, on trial 7 — is data, because now it can be epoched with the signal, split with the signal, and scored against what the signal predicted. Everything on this page exists to turn the first thing into the second, and then to keep them together for the rest of the analysis.
Two objects do that work. A media set is the content: the items themselves, stored once, hashed. A binding is the time base: which item was shown when, to whom, and how confidently we know that. Sets are large and belong to the study; bindings are small and belong to the experiment. Branching an experiment does not fork your stimuli.
study (.bltd) experiment (.blts)
/media/sets/<set_id> media_bindings
manifest ← the item table [ { set_id, role, regime,
/blobs/… ← embedded items method, events[], coverage } ]The manager lives as a Media tab inside Load Dataset, next to the rest of the study’s data. It lists manifest rows; it does not decode. Decoding happens on an explicit Run, never on a page or tab switch.
Media sets
Every item is its own hash
An item’s id is the first 16 hex characters of the SHA-256 of its bytes. Two identical files are one item. An edited file is a different item, with a different id, so a year later the provenance record can still prove which bytes were on the screen. The filename is display only and is never used as an identity or as a caption.
| Manifest field | What it holds |
|---|---|
item_id, sha256, bytes | Identity and size |
kind | image · audio · video · text |
source_name, source_rel | Display name; path relative to the media root for linked sets |
width, height, duration_s, sample_rate, channels, fps | Whatever the kind has |
text | Inline, for text items |
tags | Free labels; a block can take a tagged subset |
Embedded or linked
A set is stored one of two ways, and the app picks the default from the size rather than from the kind alone. The thresholds are one definition in src/media/limits.py.
| Mode | Where the bytes live | Default for | Portable | Counts against cloud quota |
|---|---|---|---|---|
embedded | Inside the .bltd | Text, images, audio up to 25 MB per item and 1 GB per set | Yes — the study carries its own stimuli | Yes |
linked | On disk, relative to a media root | Video; audio over 25 MB per item; any set over 1 GB | No — the recipient supplies the files | No |
Crossing a threshold flips the default and the import dialog says which threshold and by how much — “linked because this set is 2.1 GB” reads as a fact, where “linked” alone reads as an arbitrary app decision. You can override either way.
A linked set that cannot resolve fails loudly
Licence, and what publishing sends
Every set carries license and license_url. A set left at unknown blocks publish, with a picker offering own-work, CC0, CC-BY, CC-BY-NC, research-only, proprietary, or a custom string.
research-only and proprietary sets are never uploaded. What the published study carries instead is the manifest — hashes, names, counts, licence — so a reader can obtain the originals from wherever they are licensed and verify byte-for-byte that they had the same ones. The stimulus licence is displayed on the study page beside the code and data licences.
Bindings: media on the recording clock
Four roles, two regimes
Roles are never merged, and a wire between mismatched roles is refused by the graph IR.
| Role | Meaning | Scope |
|---|---|---|
stimulus | Presented to the participant | Experiment — the same for every subject on that paradigm |
response | Produced by the participant | Session, per subject, and identifiable |
target | What a model must recover | Declared separately from the stimulus, always as (binding, transform) |
generated | Model output | Never stored with targets; carries its verdict |
| Regime | Unit | Shape it yields | Typical study |
|---|---|---|---|
event | One item per trial | [trials, …], index-parallel to X and Y | Image decoding, P300, oddball |
continuous | One stream per span | [samples, k] at the recording rate | Speech tracking, naturalistic viewing, attention decoding |
Four ways in
| # | Route | Input | What you do |
|---|---|---|---|
| 1 | designed | A NeuralFlow paradigm | Nothing. Compiling the paradigm emits the binding, because the app already knows the timeline it is about to present. |
| 2 | declared | events.tsv, .csv or .xlsx | One column-mapping dialog — and zero for a BIDS events.tsv, whose column names are the standard’s own. |
| 3 | derived | A marker channel plus an ordered item list | Pick the marker value; confirm the histogram. Marker N of value V binds item N. |
| 4 | streamed | One media file plus one anchor | Pick the anchor kind. Continuous regime only. |
Route 2 auto-detects these column names before it asks you anything:
| Field | Accepted names |
|---|---|
| onset | onset, onset_s, time, latency, sample (with a rate) |
| duration | duration, dur, offset |
| item | stim_file, stimulus, file, filename, image, audio |
| text | text, word, sentence, caption, trial_type |
| trial | trial, trial_index, epoch, n |
Route 3 shows you the histogram first
Route 4 anchors, in order of how much they can be trusted:
| Anchor | Accuracy | What it is |
|---|---|---|
marker | Best available | “The first trigger of value V is media t = 0” |
sample | Exact | An explicit sample index |
channel | Best, slowest | Cross-correlate a photodiode or click channel against the media’s onset envelope |
manual | Unknown | A typed offset. Stamped publication_grade: false. |
Clock drift is estimated when two anchors exist and reported as "unmeasured" when only one does. A 12-minute media file played from a separate clock is typically 10–200 ms out by the end, which is enough to smear a response into nothing.
Route 4 and the marker-channel read are not yet drivable from the UI
Coverage, and what a partial binding may be used for
Every binding carries a coverage record: trials total, trials bound, the unbound indices, overlaps and out-of-range events. What happens next depends only on that record.
| Coverage | Behaviour |
|---|---|
1.0 | Normal. |
| Between 0 and 1 | The uncovered trials are dropped from X, Y and media together, from one index vector. The count and the indices are reported and stamped into provenance. |
| Below 0.8 | Refused as a training target, with the reason. Still usable as a covariate and for inspection — a binding you cannot train on is not a binding you cannot look at. |
| Any overlap | Refused outright. Two items claiming one trial is a wrong binding, not a partial one, and averaging them would hide it. |
One index vector
This is the invariant the whole layer is built around. For a trial-locked source, Stimulus[i] is the media of the trial that produced X[i] and Y[i] — after every filter, every rejection and every reorder.
media X (epochs) Y (labels) i=0 face ↔ [ 62 × 250 ] ↔ face i=1 tone ↔ [ 62 × 250 ] ↔ tone i=2 "cat" ↔ [ 62 × 250 ] ↔ word i=3 ✗ unbound ✗ dropped ✗ dropped ← leaves all three i=4 house ↔ [ 62 × 250 ] ↔ house
The alignment layer returns media with the X and Y it belongs to; there is deliberately no API that returns media alone. The three-separate-lookups version agrees with this one right up until a trial drops or the order changes, and then disagrees silently, because every array is still exactly the right length.
Designing with media
Modality is a property, not a block type
A Visual Cue that shows a picture is still a Visual Cue. Every NeuralFlow block gains one optional Stimulus card in the inspector, below Duration: a set picker, the modality (read-only, from the set), presentation order, a tag subset, a seed, a thumbnail strip, and a live occurrences × items line.
| Setting | Options |
|---|---|
| Order | sequence · random · random_no_repeat · balanced |
| Per occurrence | A loop repeating one block 40 times draws 40 different items |
| Seed | Resolution is a pure function of items, order, seed and occurrence index — the same paradigm draws the same sequence next year |
| Subset | Tag filter |
| Presentation | fit, volume, loop, onset_offset_ms |
All defaults means the group is omitted on save, so a paradigm written before any of this existed still loads and re-saves byte-identically.
A stimulus code per segment falls out for free
Visual Cue → face and Visual Cue → house become different conditions without becoming different block types. The occurrences × items line in the inspector is where the most common paradigm error gets caught — before the recording rather than after it.Stimulus Set and Media Playback
| Block | What it is for |
|---|---|
Stimulus Set | A source marker. Declares a set once so many blocks can reference it, and makes set usage visible on the canvas instead of buried in five inspectors. |
Media Playback | The continuous regime. The media’s own duration is the block’s duration, and it emits a stream binding. |
What refuses at compile
All of these fail closed, before a participant is ever in the chair:
random_no_repeatwith fewer items than occurrences — raises, naming both counts.Media Playbackmedia longer than the block duration — raises unless you asked for truncation.- A set id that is not in this study — raises, with the id and every block label that references it.
- A modality that does not match the block type — warns and stamps, but does not raise.
Presenting it
Without a runner, media on a block is metadata about data collected somewhere else. That is genuinely useful — it covers every public dataset — but it is not “run my experiment”. The Paradigm Runner is a separate process with its own timing loop, full-screen on the presentation display, emitting a marker per onset over LSL or a serial/parallel TTL line, abortable at any point and leaving a valid partial log.
The onset band is measured, not claimed
Realistic onset accuracy for a stimulus drawn in a desktop window is 5–20 ms, and worse for audio. The runner states that band in the UI before your first session, then measures its own performance and reports what it actually achieved.
| Reported per session | What it is |
|---|---|
| Median and p95 |requested − measured| | The band this machine actually hit |
| Dropped frames, audio underruns | Where it did not |
| Marker-sent timestamps | Per segment, alongside the frame timestamp |
| Adequate for | Not adequate for |
|---|---|
| P300, motor imagery, tolerant SSVEP, naturalistic viewing, all fNIRS work | Early visual ERP components; precise SSVEP phase |
For the cases in the right column, use a photodiode patch and the Route 4 channel anchor to verify against a real channel — or export the paradigm and run it on hardware built for that.
The events file describes the recording, not the intention
Export to PsychoPy or BIDS
| Target | Output | Plan |
|---|---|---|
| BIDS | events.tsv + events.json + stimuli/ | Free on every plan |
| PsychoPy | A runnable .py, the media, and a marker map | Researcher and up |
| Manifest | Timeline and binding as JSON | Free on every plan |
Run it on your own rig, bring the events back, and import them through Route 2. BIDS export is free deliberately: putting the standard interchange format behind a paywall makes a tool untrusted in a teaching lab.
Response Capture and consent
Response Capture records microphone, webcam, keystrokes or typed text, timestamped on the recording clock — the basis for overt-speech decoding and behavioural annotation. It is the most sensitive data this app will ever hold: a webcam clip is identifiable in a way an EEG trace is not, and voice is biometric under regimes the compliance layer already tracks. Four rules are enforced in code, not documented as guidance:
| Rule | How it is enforced |
|---|---|
| Per-session consent record | Signed statement, timestamp and operator identity, in the session document. Consent is required before the output directory is created — a folder named after a participant is itself a small disclosure. |
| Linked and local-only | Never syncs. |
| Excluded by default | From publish, export, .bltm, community submission, team sync, cloud and backup — even when you selected the whole study. Sharing needs a separate confirmation that says the study’s own sharing settings do not cover this, and it defaults to no. |
| Independently deletable | The deletion path has no access to the study at all, which is the strongest available form of “delete this without touching my results”. |
Training on it
Media ports on Select Files
Media is an orthogonal selector, not another entry in the Data Source dropdown. That dropdown means “which container stage does this node read”, one entry to one stage; a media entry there would force you to choose between Epochs and Stimulus — Images when the graph needs both.
Data Source [ Epochs ▾ ] unchanged Media [ ☑ Stimulus ☐ Response ] new Binding [ faces · designed · 40/40 ▾ ]
The ports append after File Paths, so no existing port index moves:
| Port | Yields |
|---|---|
Stimulus | The binding — Media Loader does the decoding |
Stimulus Text | list[str], feeds a Captions port directly |
Stimulus Index | The item index per trial |
Media Paths | Resolved paths, for blocks that read their own files |
Response | The response binding, when ticked |
Why the port yields the binding rather than the decoded media
Stimulus Text. The alignment invariant is unaffected: X and Y are re-emitted from the same index vector whenever coverage dropped a trial.The six Media blocks
| Block | In → out | Plan |
|---|---|---|
Media Loader | Binding or paths → media. Handles target_rate resampling, and names the anti-alias filter it used rather than silently decimating an envelope. | Free |
Text Loader | Binding → text and tokens. One caption per trial. | Free |
Media Windower | Continuous media + epochs → event media. Cuts the stream with the epoch boundaries, so windows cannot drift away from trials. | Researcher+ |
Audio Features | Media → features: envelope (Hilbert or half-wave rectified), log-mel, MFCC, onset envelope, RMS, spectral flux. All CPU-viable. | Researcher+ |
Media Embedder | Media → embeddings, from a frozen pretrained encoder. | Researcher+ |
Encoding Model (TRF) | Features + neural → predictions, kernel, metrics. | Researcher+ |
The embedder is never fine-tuned
openai/clip-vit-base-patch32@3d74acf, never clip — because “CLIP embeddings” is not a reproducible description of anything.Three things you might expect as separate blocks are folded in on purpose: resampling is a Media Loader parameter; raw pixels, patch grids and colour histograms are Media Loader output options, because they are decode choices rather than a feature family; and both TRF directions are one parameter on one block.
Encoding Model (TRF), both directions
| Direction | Predicts | What you keep |
|---|---|---|
forward (encoding) | Neural response from the stimulus feature | The TRF kernel — a time × channel map |
backward (decoding) | The stimulus feature from the neural response | A reconstructed stream and a decision. This is the direction that becomes a deployable BCI. |
One block rather than two, because both share the lagged design matrix, the ridge solve and the null. Parameters are the lag window (default −100 to 400 ms), the ridge alphas and the CV folds; it returns per-channel prediction correlation, the kernel and the selected alpha. Auditory attention decoding is this block in backward with two bindings — attended and unattended — scored by which one reconstructs better.
The null is a circular shift, not a permutation
Stimulus leakage is reported, never enforced
The same image appearing in train and test is a new class of leakage, and it does not fit the existing one. For a reconstruction or generation model it is real leakage even when the trials differ: the model can memorise the picture and then be scored on recalling it. For a classifier it is not leakage at all — and grouping by item would be actively wrong, because it would put every “cat” trial in one group and destroy the split.
The splitter cannot see which of those you are doing. So it computes item-level overlap and reports it as its own field, and leaves the existing group_leakage and publication_grade verdicts completely untouched. A reader of a reconstruction result must look at it; a reader of a classification result may ignore it.
After training
Depth: patterns, never raw weights
The Depth tab of Model Analysis reads artifacts that already exist, so it is cheap to open. It shows learned spatial filters as topomaps (channel positions are already in the study), learned temporal filters as frequency responses, per-layer activation statistics on a chosen batch — mean, spread, saturation, dead-unit fraction — and per-layer output distributions.
It refuses to draw a weight topomap
Breadth: the same statistics as everywhere else
Breadth renders the group statistics the rest of the app computes; it does not reimplement them, and a test greps its own source to prove it computes none. It shows a subject × class accuracy heatmap, fold-to-fold spread beside every mean, a ranked leave-one-subject-out delta, session and run drift over time, and confusion matrices side by side. See Results Validation for the statistics themselves.
Inference gates
Running a saved model on new data is the most likely place in the whole app for a silent mismatch, because the model and the data now come from different places. Five gates run before it; four block, and the fifth deliberately does not.
| Gate | Behaviour |
|---|---|
| Feature parity | Blocks. The feature chain is replayed from the model’s own record. A model with no recorded feature spec cannot be run on new data, and says so. |
| Class mapping | Blocks. A neural head emits one score per position; position 1 is class 1 only when the labels happened to be 0…K−1. |
| Montage | Blocks. Different or reordered channels are refused, or remapped explicitly with the mapping stamped into provenance. Never a positional match by luck. |
| Sampling geometry | Blocks. Rate and epoch length must match, or be resampled with the filter named. |
| Domain shift | States, never blocks. A new subject or a new device is usually the whole reason you are running inference, so the drift metric is displayed rather than treated as a mistake. |
Ground truth is optional. With labels, metrics are wrapped exactly as a training run’s are. Without labels you get predictions, confidence and the prediction distribution against training — and no accuracy number is invented: there is no key in the output a reader could mistake for a score, plus a sentence saying why.
Storage and plans
The rule is the same one that governs the rest of the app: charge for management burden, not for capability. Reading your own data is never paid, and all four binding routes are free.
| Capability | Free | Researcher and up |
|---|---|---|
| Text and image sets, both regimes | 2 sets per study | Unlimited |
| Audio and video sets | — | ✅ |
| All four binding routes | ✅ | ✅ |
Media Loader, Text Loader | ✅ | ✅ |
Media Windower, Audio Features, Media Embedder, TRF | — | ✅ |
| Paradigm Runner | Single session | Unlimited |
| Paradigm Export — BIDS | ✅ | ✅ |
| Paradigm Export — PsychoPy | — | ✅ |
| Response Capture | — | ✅ |
| Inference — batch, one subject | ✅ | ✅ |
| Inference — cohort | — | ✅ |
Two sets per study is the same “two of everything, free” shape used across the app, and the refusal message says per study so the limit cannot be misread as per account. On storage: embedded media counts against your cloud quota and linked media does not, which is exactly why the size thresholds default the expensive kinds to linked. One 40-image set is around 2 MB; one movie clip is around 400 MB.
What happens when something is wrong
Each row here is a test, not an intention.
| Situation | What the app does |
|---|---|
| No media anywhere | Identical to before any of this existed. An untouched paradigm is byte-identical on save. |
| Media, but no onsets | Routes 3 and 4 are offered. If neither builds, the set is browsable and refuses to be a target, with the reason. |
| Onsets, but missing media | Missing items listed by name. Text-only conditions are legitimate and are not an error. |
| More items than trials | The unused tail is reported. Not an error. |
| Fewer items than trials | random_no_repeat raises at compile. Otherwise allowed with an explicit opt-in, and the repeat count goes into provenance. |
| Media longer than the recording | Overhang reported; out-of-range events dropped with a count. |
| Recording longer than the media | Trials go unbound, coverage drops, and the coverage rules apply. |
| Corrupt or truncated file | Named, with item id and path, at bind time — not at epoch 3 of training. |
| Unsupported codec | Refused with the exact install line for the media-video extension. Never an ffmpeg traceback. |
| Media on a network drive | Linked mode with the media root at the share. Unresolvable at run time is a loud failure that names the path it resolved to. |
| Two subjects, different order | Per-subject bindings. The set is shared; the order is not. |
| Rate mismatch | Media Loader.target_rate, with the filter named. Never a naive stride. |
| Media added after the recording | Bind it later. Bindings are additive metadata. |
| No GPU | Every read, feature and encoding block is CPU-viable. Media Embedder says so before it runs. |
Which formats need the extension
media-video extension, which is installable on a free plan because the decoder is not the thing being sold. The gate is per format on purpose: reading a PNG must not require the video extension. See Extensions.What this does not do
| Not built | Why, and what reopens it |
|---|---|
| Audio and video generation | Gated on an identity check that beats chance on a real cohort across three seeds — a capability that appears at one seed in three is a capability that does not exist. That measurement has not been run, so the blocks do not exist: not as an unfinished feature, not as a hidden one. They ship when the measurement says they should. |
| A dedicated Inference page | The gates, the feature spec and the run path are built and tested; the page that drives them is not written yet. |
| The Steps trial stepper | Walking one trial through raw → filtered → epoched → features → activations → prediction needs intermediate tensors captured during the run. The capture policy is built; the training path does not call it yet, so the stepper would have nothing to step through. |
| Media editing | No crop, trim or resample-to-taste. Sets import as-is, because the hash of what you showed is the point. |
| Face or voice de-identification | Offering a blur button implies an anonymity guarantee we cannot make. |
| Psychophysics-grade timing | We measure and report, or export to a tool built for it. |
| Fine-tuning stimulus encoders | Frozen only. See above. |
| Cloud media streaming | Sets are local or linked-local. |
| Workspace-wide shared sets | Sets are study-scoped. Reopens when people ask for it. |
| Video motion features, text surprisal, CCA | Deferred with a named trigger each: a naturalistic-viewing study, a reading or N400 study, and a real request that TRF proves insufficient for. |