DocsMultimodal & Media
BCILattice Documentation

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.

v1.0BCINexus Platform · 2026-09-10[email protected]

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 fieldWhat it holds
item_id, sha256, bytesIdentity and size
kindimage · audio · video · text
source_name, source_relDisplay name; path relative to the media root for linked sets
width, height, duration_s, sample_rate, channels, fpsWhatever the kind has
textInline, for text items
tagsFree 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.

ModeWhere the bytes liveDefault forPortableCounts against cloud quota
embeddedInside the .bltdText, images, audio up to 25 MB per item and 1 GB per setYes — the study carries its own stimuliYes
linkedOn disk, relative to a media rootVideo; audio over 25 MB per item; any set over 1 GBNo — the recipient supplies the filesNo

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

Missing items are listed by name, together with the media root that was searched. There is no silent trial drop: a set that half-resolves is a wrong result waiting to be published, and a network share that is not mounted is the single most common cause.

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.

RoleMeaningScope
stimulusPresented to the participantExperiment — the same for every subject on that paradigm
responseProduced by the participantSession, per subject, and identifiable
targetWhat a model must recoverDeclared separately from the stimulus, always as (binding, transform)
generatedModel outputNever stored with targets; carries its verdict
RegimeUnitShape it yieldsTypical study
eventOne item per trial[trials, …], index-parallel to X and YImage decoding, P300, oddball
continuousOne stream per span[samples, k] at the recording rateSpeech tracking, naturalistic viewing, attention decoding

Four ways in

#RouteInputWhat you do
1designedA NeuralFlow paradigmNothing. Compiling the paradigm emits the binding, because the app already knows the timeline it is about to present.
2declaredevents.tsv, .csv or .xlsxOne column-mapping dialog — and zero for a BIDS events.tsv, whose column names are the standard’s own.
3derivedA marker channel plus an ordered item listPick the marker value; confirm the histogram. Marker N of value V binds item N.
4streamedOne media file plus one anchorPick the anchor kind. Continuous regime only.

Route 2 auto-detects these column names before it asks you anything:

FieldAccepted names
onsetonset, onset_s, time, latency, sample (with a rate)
durationduration, dur, offset
itemstim_file, stimulus, file, filename, image, audio
texttext, word, sentence, caption, trial_type
trialtrial, trial_index, epoch, n

Route 3 shows you the histogram first

41 markers for 40 trials is an ordinary thing to find in a real recording. It is much cheaper to see it in a histogram before the mapping than to discover it later as an off-by-one confusion matrix that still looks plausible.

Route 4 anchors, in order of how much they can be trusted:

AnchorAccuracyWhat it is
markerBest available“The first trigger of value V is media t = 0”
sampleExactAn explicit sample index
channelBest, slowestCross-correlate a photodiode or click channel against the media’s onset envelope
manualUnknownA 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

Routes 1–3 are. Route 4 needs a channel picker and a cross-correlation preview to be worth using, and the marker route’s channel read needs you to say which channel is the trigger — an app that guesses that will guess wrong on somebody’s montage. Both are refused by name, with the reason, rather than approximated.

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.

CoverageBehaviour
1.0Normal.
Between 0 and 1The 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.8Refused 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 overlapRefused 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.

SettingOptions
Ordersequence · random · random_no_repeat · balanced
Per occurrenceA loop repeating one block 40 times draws 40 different items
SeedResolution is a pure function of items, order, seed and occurrence index — the same paradigm draws the same sequence next year
SubsetTag filter
Presentationfit, 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

Because resolution happens per occurrence at compile time, 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

BlockWhat it is for
Stimulus SetA 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 PlaybackThe 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_repeat with fewer items than occurrences — raises, naming both counts.
  • Media Playback media 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 sessionWhat it is
Median and p95 |requested − measured|The band this machine actually hit
Dropped frames, audio underrunsWhere it did not
Marker-sent timestampsPer segment, alongside the frame timestamp
Adequate forNot adequate for
P300, motor imagery, tolerant SSVEP, naturalistic viewing, all fNIRS workEarly 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

A session emits BIDS event rows built from the measured onsets, not the requested ones. So the file a session produces re-imports through Route 2 with no column mapping, and describes what happened rather than what was planned.

Export to PsychoPy or BIDS

TargetOutputPlan
BIDSevents.tsv + events.json + stimuli/Free on every plan
PsychoPyA runnable .py, the media, and a marker mapResearcher and up
ManifestTimeline and binding as JSONFree 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 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:

RuleHow it is enforced
Per-session consent recordSigned 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-onlyNever syncs.
Excluded by defaultFrom 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 deletableThe 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:

PortYields
StimulusThe binding — Media Loader does the decoding
Stimulus Textlist[str], feeds a Captions port directly
Stimulus IndexThe item index per trial
Media PathsResolved paths, for blocks that read their own files
ResponseThe response binding, when ticked

Why the port yields the binding rather than the decoded media

Decoding inside the input block would put a possibly-gigabyte tensor read on the path of every graph that ticks the box — including a graph that only wants 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

BlockIn → outPlan
Media LoaderBinding or paths → media. Handles target_rate resampling, and names the anti-alias filter it used rather than silently decimating an envelope.Free
Text LoaderBinding → text and tokens. One caption per trial.Free
Media WindowerContinuous media + epochs → event media. Cuts the stream with the epoch boundaries, so windows cannot drift away from trials.Researcher+
Audio FeaturesMedia → features: envelope (Hilbert or half-wave rectified), log-mel, MFCC, onset envelope, RMS, spectral flux. All CPU-viable.Researcher+
Media EmbedderMedia → embeddings, from a frozen pretrained encoder.Researcher+
Encoding Model (TRF)Features + neural → predictions, kernel, metrics.Researcher+

The embedder is never fine-tuned

A tuned stimulus encoder learns your stimulus set, and every downstream number quietly becomes a number about that set. Provenance records the exact revision — 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

DirectionPredictsWhat you keep
forward (encoding)Neural response from the stimulus featureThe TRF kernel — a time × channel map
backward (decoding)The stimulus feature from the neural responseA 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

A speech envelope is strongly autocorrelated. Permuting it destroys that structure, so the permutation null sits far below chance and everything clears it — including nothing. A circular shift preserves the autocorrelation and breaks only the alignment, which is the thing being tested.

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

Raw decoder weights are not interpretable as neural sources: a weight map can point straight at a noise channel the model is using to subtract noise, while the actual source sits elsewhere. Drawn as a topomap that looks exactly like a result. For linear models the tab draws Haufe-corrected patterns, labels them as patterns, and returns a stated refusal rather than an unlabelled weight map — the same rule the time-resolved analysis already follows.

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.

GateBehaviour
Feature parityBlocks. 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 mappingBlocks. A neural head emits one score per position; position 1 is class 1 only when the labels happened to be 0…K−1.
MontageBlocks. Different or reordered channels are refused, or remapped explicitly with the mapping stamped into provenance. Never a positional match by luck.
Sampling geometryBlocks. Rate and epoch length must match, or be resampled with the filter named.
Domain shiftStates, 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.

CapabilityFreeResearcher and up
Text and image sets, both regimes2 sets per studyUnlimited
Audio and video sets
All four binding routes
Media Loader, Text Loader
Media Windower, Audio Features, Media Embedder, TRF
Paradigm RunnerSingle sessionUnlimited
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.

SituationWhat the app does
No media anywhereIdentical to before any of this existed. An untouched paradigm is byte-identical on save.
Media, but no onsetsRoutes 3 and 4 are offered. If neither builds, the set is browsable and refuses to be a target, with the reason.
Onsets, but missing mediaMissing items listed by name. Text-only conditions are legitimate and are not an error.
More items than trialsThe unused tail is reported. Not an error.
Fewer items than trialsrandom_no_repeat raises at compile. Otherwise allowed with an explicit opt-in, and the repeat count goes into provenance.
Media longer than the recordingOverhang reported; out-of-range events dropped with a count.
Recording longer than the mediaTrials go unbound, coverage drops, and the coverage rules apply.
Corrupt or truncated fileNamed, with item id and path, at bind time — not at epoch 3 of training.
Unsupported codecRefused with the exact install line for the media-video extension. Never an ffmpeg traceback.
Media on a network driveLinked 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 orderPer-subject bindings. The set is shared; the order is not.
Rate mismatchMedia Loader.target_rate, with the filter named. Never a naive stride.
Media added after the recordingBind it later. Bindings are additive metadata.
No GPUEvery read, feature and encoding block is CPU-viable. Media Embedder says so before it runs.

Which formats need the extension

Text needs nothing. Images read through Pillow, and WAV, FLAC and OGG through soundfile — both bundled. Only video and compressed audio (MP4, MOV, WebM, MP3, M4A) need the curated 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 builtWhy, and what reopens it
Audio and video generationGated 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 pageThe gates, the feature spec and the run path are built and tested; the page that drives them is not written yet.
The Steps trial stepperWalking 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 editingNo crop, trim or resample-to-taste. Sets import as-is, because the hash of what you showed is the point.
Face or voice de-identificationOffering a blur button implies an anonymity guarantee we cannot make.
Psychophysics-grade timingWe measure and report, or export to a tool built for it.
Fine-tuning stimulus encodersFrozen only. See above.
Cloud media streamingSets are local or linked-local.
Workspace-wide shared setsSets are study-scoped. Reopens when people ask for it.
Video motion features, text surprisal, CCADeferred with a named trigger each: a naturalistic-viewing study, a reading or N400 study, and a real request that TRF proves insufficient for.
Multimodal & Media v1.0 · BCINexus Platform · 2026-09-10