Brain Products actiCHamp — direct ctypes acquisition¶
ssvep.runtime.actichamp is a clean-room ctypes driver for the classic actiCHamp amplifier
(the ActiChamp_x64.dll SDK that PyCorder uses). It streams the amp directly over USB and
publishes an LSL EEG stream that the toolbox records — no BrainVision Recorder, no RDA, no
external streaming app. BrainFlow doesn't support Brain Products amps, so this is a hand-written
binding rather than a board id.
Why clean-room (GPL avoidance)¶
The toolbox must stay free for commercial reuse (no strong copyleft — see CLAUDE.md). PyCorder
(actichamp_w.py) and the eego bindings are GPLv3, so they can't be vendored. The C API this
module binds — function signatures, struct layouts, enum values, the data-block format, the µV
scaling — is the amplifier's documented SDK interface (facts about the hardware, not
copyrightable expression); PyCorder was consulted only as an API reference. No GPL code,
structure, or comments are reproduced, so this file keeps the toolbox's permissive licence. The
proprietary DLL is not redistributed here — the operator supplies it.
The vendor DLL's own licence (Brain Products Amplifier SDK)¶
Separate from the GPL/PyCorder concern above, ActiChamp_x64.dll itself is licensed under Brain
Products' BrainVision Amplifier SDK "Licensing Terms" (Dec 2019, read in full 2026-07-13) — it is
research-only, non-redistributable, and non-commercial. Consequences for this module:
- Never ship or commit the DLL (or
.bit/.lib/headers) —find_dll()loads a copy the operator installs from Brain Products ($ACTICHAMP_DLL). This module is therefore an optional, user-supplied plugin, not part of the free-for-commercial core. - Distributing the toolbox with this module is non-commercial only, under our own licence that forbids commercial/medical/high-risk use, carries Brain Products' copyright notice, and names the BP parts used (an obligation of BP's §4 "work result" sublicensing grant).
- Commercial release requires a written agreement from Brain Products — their terms bar commercial use of both the SDK and any app that processes BP-hardware data.
- BP supplies the interface documentation on request, free of charge (their §3) — request it to
ground this clean-room binding rather than relying on behavioural inference. The V-Amp/FirstAmp SDK,
when obtained for a
vampdriver, carries its own BP licence to review separately.
Full analysis in docs/REQUIREMENTS.md → "Third-party hardware SDKs".
Status — first on-hardware run 2026-07-13 (64-ch actiCHamp): connects; two fixes applied + verified¶
Everything except the actual DLL call is pure and unit-tested (tests/test_actichamp.py drives
open → configure → start → read → µV, decimation, impedance, and DLL discovery against a fake DLL).
First live run on a real 64-ch actiCHamp (2026-07-13, C:\Vision\Amplifier SDK\Bin\x64\ActiChamp_x64.dll,
all champ* symbols present): the driver opens, configures, and streams end-to-end.
Confirmed correct on hardware:
- Struct/data-block ABI:
CountEeg=64,ResolutionEeg=0.0488 µV/bit(documented actiCHamp LSB),RangeEeg=0.819 V ≈ 2²⁴ × LSB(24-bit) — soCHAMP_PROPERTIESlayout + µV scaling are right. Frame = 74 int32 words (64 EEG + 8 AUX + trigger + counter); the counter increments by 1/frame, so the[EEG…, AUX…, TRIGGER, COUNTER]layout is correct and no samples dropped.
Two driver bugs found on hardware, now fixed (and re-verified on the amp):
- Rate enum was wrong → fixed + software decimation added. The classic actiCHamp hardware
supports only 3 native rates: code 0 → 10 kHz, 1 → 50 kHz, 2 → 100 kHz; codes 3–15 silently
fell back to 10 kHz (verified by sweeping all 16 codes).
RATE_CODESis now{10000,50000,100000}only;configure(output_rate_hz=…)sets a native base rate and an anti-aliased_Decimatordownsamples to the requested output (e.g. 10 kHz → 500 Hz, factor 20).sfreq= effective output,hw_sfreq= native rate. Re-verified: 500 Hz requested → 500.0 Hz effective. - µV coding = signed (two's-complement), verified 2026-07-14 with an actiCAP connected.
read()returnscount × ResolutionEeg × 1e6(no offset). ⚠ A 2026-07-13 floating-input test wrongly suggested offset-binary and a2²³re-centering was briefly added — that was reverted: open inputs rail to +full-scale ≈ +2²³, which is indistinguishable from an offset-binary midscale. On a real cap the raw counts sit near 0 (means ~a few mV electrode offsets; std ~20 µV = real EEG), so the signed reading is correct and the offset one gave a spurious −0.4 V. Do not re-add a 2²³ offset.
GUI-path fixes (2026-07-14, actiCAP connected):
- DLL discovery —
find_dll()now findsActiChamp_x64.dllin the BrainVision Amplifier SDK (C:\Vision\Amplifier SDK\Bin\x64) + a bounded scan of the install roots, and is architecture-aware (64-bit Python skips the 32-bit Recorderx86DLL). Previously the GUI couldn't load the DLL at all unlessACTICHAMP_DLLwas set — the root cause of "streaming doesn't work in the toolbox". - Open retry (cold-start + busy-restart) —
stream_to_lsl/stream_eeg_previewgo through_open_streaming, which (a) retries a nullchampOpenwith backoff — a previous bridge that was hard-killed (the pythonw GUI can't deliver CTRL-BREAK, so_stop_bridgefalls back to a kill that skipschampClose) briefly leaves the amp claimed, wedging a quick restart — and (b) confirms data is actually flowing afterchampStart, else stops/reopens (a cold open can come up with a dead stream). Streaming then holds a steady ~508 Hz. (A cleaner graceful-release for the pythonw bridge — stdin/stop-file instead of CTRL-BREAK — is a possible follow-up.)
Impedance read verified 2026-07-14 (no cap). The impedance-mode calls all return 0 and
champImpedanceGetData fills n_eeg + 2 uint32 Ohm words (64 EEG, then GND + REF which are dropped);
an unconnected channel reads 0x7FFFFFFF (INT_MAX), now mapped to NaN → 'n/a' so it doesn't peg
the colour scale. Two Setup-tab robustness bugs that made an impedance check "do nothing" (seen on
sub-904) are fixed: the worker now surfaces stream errors instead of dying silently, and
stream_impedance retries a busy champOpen so a check right after a recording rides out the
amp's release. Still to confirm with a cap on a head: that connected electrodes read sensible kΩ
(the no-cap test only proved the unconnected sentinel path).
⚠️ That caveat came true (2026-07-15). The first run with a cap on a head produced nothing usable. The no-cap test could not have caught it: with no cap, every channel correctly reads the INT_MAX 'not connected' sentinel — which is exactly what an unsettled amp returns too. So the test passed while proving nothing about a settled read.
Root cause:
stream_impedancepolledimpedances_kohm(settle_s=0.0)in a loop, reading immediately afterchampStartand round-tripping the amp's mode on every poll. The amp needs time in impedance mode before its first reading is real.Fixed:
enter_impedance_mode()→ settleIMPEDANCE_SETTLE_S(1.5 s) →read_impedances_kohm()in place →exit_impedance_mode(). Mode switches are now constant (3) instead of1 + 2N. A fake that models the settle makes the regression catchable headlessly (test_actichamp.py).✅ Resolved 2026-07-17 (saline). With the cap's electrodes in a saline bath (GND in the bath), all 64 channels read a stable ~2 kΩ (min 1.6 / median 2.0 / max 2.6, steady across polled reads) — plausible connected values, not the INT_MAX sentinel and not 0. The
enter → settle → read×N → exitpath is validated on hardware; the acceptance criterion (connected electrodes reading plausible kΩ) is met.
Acquisition mode: use normal, NOT active shielding¶
NCIL runs actiCAP active electrodes, so mode="active_shield" looked like the accurate choice — but
keep the default normal. Evidence (2026-07-17):
- Saline A/B (GND in the bath), interleaved, 4 s each:
normal= ~2.3 µV raw / ~1.2 µV CAR std, ~0.7 mV DC — clean.active_shield(gain 100) = ~310 µV raw / ~133 µV CAR std (≈111× noisier), ~211 mV DC. All prior good recordings werenormal(the orchestrator never passes--mode, and the bridge defaults tonormal). - The actiCHamp Plus manual explains why (Appendix B — Active shielding): shielding assumes the noise
is common-mode and uniformly distributed; "for channels that are less affected by common-mode noise,
shielding mode can cause an over-compensation, which results in more noisy signals." A saline short
presents no such clean common-mode, so it over-compensates — exactly the 111× result. The manual also
warns active shielding irreversibly modifies the data (vs reversible referencing) and uses
channel 1 as the shield source (FCz/Fp1 in our montages). The toolbox already does reference-free
acquisition + software CAR (reversible, per-channel) — better aligned with
normal. - If ever reconsidered, apply the manual's own method: record a real-head pilot in both modes and compare in time + frequency before switching. Do not flip the default off a bench/saline test.
stream_to_lsl / check_connection / stream_eeg_preview all default mode="normal"; --mode still
accepts active_shield for a deliberate pilot comparison.
Use¶
# supply the DLL (installed with the actiCHamp control software), then:
set ACTICHAMP_DLL=C:\Program Files\Brain Products\actiCHamp Control\ActiChamp_x64.dll
python -m ssvep.runtime.actichamp # short bench check: per-channel mean/std/range (µV)
python -m ssvep.runtime.actichamp --stream --rate 500 # stream to LSL (normal mode)
ssvep-run) — RunRecorder resolves the EEG
stream by type and records it, exactly like the OpenBCI LSL path.
find_dll()looks at$ACTICHAMP_DLL, then PATH, then common Brain Products install dirs.- Native hardware rates are only 10 000 / 50 000 / 100 000 Hz (confirmed on hardware 2026-07-13 —
see Status above; the
--rate 500shown above currently runs at 10 kHz until decimation lands). Any lower effective rate for SSVEP must come from software decimation of the 10 kHz stream. The amp's real rate + channel count + µV scale are read back fromCHAMP_PROPERTIESafter configuration. - Channel labels come from the manifest/cap, not the SDK — the driver just delivers N EEG channels.
⚠️ The amp reports channels its modules could carry, not the ones connected¶
Verified on hardware 2026-07-15. With a single 32-channel electrode bundle in module 1, a 64-ch
actiCHamp still reports CountEeg=64 and streams 64 columns. Reading 3 s of data splits exactly at
the module boundary: channels 1–32 carry the cap (std ~1.1–1.6 mV on the reference-free common-mode
offset), channels 33–64 all sit at ~409 600 µV — that is 2**23 × ResolutionEeg, i.e. the
+full-scale rail that open inputs go to (the same effect that caused the 2026-07-13 offset-binary
misdiagnosis; see the coding note in actichamp.py).
So the amp cannot tell you how many electrodes are on the participant — the manifest's montage does. Those railed channels must never reach the recording. Two ways they bite, both silent:
- They misalign the montage's labels — a 32-entry montage against a 64-column stream mislabels everything from channel 33 on.
- They poison the CAR. The amp is reference-free, so any montage over
spatial.BIG_MONTAGEgets a common-average reference (below) — and CAR runs over all channels before the occipital ROI is picked. Averaging in 32 channels at +409 600 µV subtracts ~205 mV from every channel and drops the decode to chance with no error.
actichamp.used_channels(n_eeg, n_used) implements the trim, and every surface that reads the amp takes
an n_used: stream_to_lsl (via --n-eeg, which orchestrator.record_run passes from the
montage), stream_eeg_preview, and stream_impedance. n_used=None keeps all channels, so a
fully-populated 64-ch cap is unchanged. orchestrator.pump_once independently slices recorded rows to
the montage width, so the XDF is doubly protected; the bridge cap additionally fixes the LSL header, the
Setup-tab preview, and the impedance read, none of which go through the recorder.
GUI integration¶
The actiCHamp is a selectable device on the Build run tab — "Brain Products actiCHamp (64-ch, USB)"
(board="actichamp", rates 250/500/1000). Its carrier list is restricted to the two actiCAP layouts
but stays selectable (unlike the Unicorn's single moulded-in montage, which auto-locks) — the operator
must say which cap is actually on the participant, because that choice sets the recorded channel count:
| Carrier | Ch | Layout |
|---|---|---|
actiCAP 64-ch (10-20) |
64 | Brain Products actiCAP 64Ch Standard-2 (default) |
actiCAP 32-ch posterior (NCIL) |
32 | NCIL's posterior-only build — see below |
The runtime dispatches board=="actichamp" to this driver, not BrainFlow: recording
launches python -m ssvep.runtime.actichamp --stream (orchestrator), and the QA-check-tab impedance +
signal-preview use actichamp.stream_impedance / stream_eeg_preview. The Set up Session tab no longer picks
hardware — it takes the designed board and only auto-resolves the connection (for actiCHamp: USB
index 0 + a DLL-present check). Impedance is wired but inherits the unverified impedances_kohm read
layout (above) — treat with caution until confirmed on hardware.
⚠️ Never hard-kill the bridge — it can become an unkillable process holding the amp¶
The worst failure on this rig, root-caused 2026-07-15. TerminateProcess cannot complete while a
thread is blocked inside the actiCHamp's USB driver. Since the bridge polls champGetData every
50 ms, a hard kill is a race: land inside the driver call and the process becomes an unkillable
zombie that still owns the amp. Observed directly — a bridge alive 216 s after Stop-Process -Force
returned successfully, its thread in Wait/Executive, champOpen NULL the whole time.
It survives force-kill. It survives power-cycling the amplifier (the stuck wait is in the host driver, not the amp). It locks out PyCorder. Only unplugging the USB cable clears it — that aborts the pending I/O, the thread returns, and the queued termination finally lands. Then it's gone.
And it was reached every time, because the graceful path never worked from the GUI:
- The Run GUI is
pythonw.exe(no console) and spawns bridges withsys.executable, so they're console-less too.CTRL_BREAK_EVENTis accepted by the OS but never delivered — measured:_stop_bridgeburned its full 5 s timeout, every time, then hard-killed. TheSIGBREAKhandler in the bridges is only a fallback for a hand-started bridge; it never fired under the GUI.
Fix: stdin is the shutdown channel. record_run launches the bridge with stdin=PIPE; the
bridge watches it (lsl_io.stdin_stop_event) and unwinds on EOF, so stream_to_lsl's finally runs
champClose. _stop_bridge closes stdin first, then falls back to the console signal, and only kills
as a genuine last resort. It also fails safe: if the GUI dies, the pipe closes and the bridge
releases the amp on its own — which the signal path never did. Verified on hardware, 5/5 runs:
_stop_bridge returns in ~0.6 s with exit code 0 and the amp free immediately (was: 5.01 s →
hard kill → zombie → amp locked until USB unplug). Same fix covers the OpenBCI bridge, whose WiFi-shield
"wedged until power-cycled" symptom is this same bug.
⚠️ The amp is single-client — and a null handle does NOT mean "not plugged in"¶
champOpen admits one client at a time, across the whole machine. When something already holds the
amp, every other client gets a null handle, which this driver reports as
champOpen(0) returned a null handle (device not present / in use?). That message reads like unplugged
hardware, and it is the single most misleading failure on this rig — it usually means the toolbox
itself is holding the amp, in this very process.
Diagnosed on hardware 2026-07-15 after a live run failed to start. What was measured:
- A hard-killed bridge does NOT wedge the amp. Killing a streaming bridge with
TerminateProcess(skippingchampClose) and polling immediately:champOpensucceeded at t+0.00 s. Windows reclaims the claim with the process. So a crashed session does not strand the next one — and the_open_configuredretry is not the thing to reach for here. - A running Setup-tab signal preview DOES lock the recording out.
SignalPreviewstreams viastream_eeg_previewon an in-process worker thread, while recording drives the amp from a separate bridge subprocess (orchestrator.record_run). Reproduced: with the preview streaming, a bridge-stylechampOpenreturns NULL; stop the preview and it returns OK.
That is why the symptom was so confusing: it locked out PyCorder too, and survived a power-cycle of the amplifier — because the holder was the GUI, not the amp. Power-cycling can't fix it; closing (or restarting) the toolbox can.
_start_session now calls _release_preview() before launching the bridge, and disables the preview's
Start button for the duration so it can't be restarted mid-recording (regression tests in
tests/test_runmode.py). Note the asymmetry with impedance, which refuses to start a run instead:
lead-off is a mode the operator deliberately entered and silently ending it would discard their reading,
whereas the preview is passive QC with nothing to lose.
If you hit a null handle: close anything else that talks to the amp — the toolbox's own preview first, then PyCorder / BrainVision Recorder / actiCHamp Control — before suspecting the hardware. A power-cycle is not the fix.
The V-Amp and ANT Refa8 have no usable Python driver here (see docs/ and the memory notes on the
V-Amp) — use OpenViBE / Recorder → LSL for those.
Decoding: the amp is reference-free — re-reference + occipital ROI before CCA¶
The actiCHamp streams ground-only (no true reference) by default, so the raw data rides on a large common-mode offset (sub-904, 2026-07-14: median |amplitude| ≈ 7.8 mV, ~24 mV p99). Two consequences for the calibration-free decoder, both handled automatically now but worth understanding:
- Re-reference first. A common-average reference (CAR) removes the common mode and maximised the occipital SSVEP SNR (CAR ≈ Fz ≈ linked-mastoid; CAR best). Without it the live plot looks noisy and CCA sees mostly common-mode drift.
- Decode from an occipital ROI, not all 64 channels. Calibration-free CCA/FBCCA overfits the sinusoid templates when handed 64 channels; sub-904 decoded at near chance across all 64 but ~68% @4s on ~8 occipital channels + CAR. TRCA does not help (phase jitter at 40 Hz).
The live Run view, scripts/analyze_recording.py, and the BIDS runner BIDS/code/02_run_offline_pipeline.py
all apply this via ssvep.spatial.occipital_decode_plan — a montage larger than spatial.BIG_MONTAGE
recorded reference-free gets CAR + the occipital ROI automatically. The ROI is the canonical
occipital-8 Oz, O1, O2, POz, PO3, PO4, PO7, PO8 (spatial.occipital_roi), not the naive
most-posterior-by-y set — that swaps in the far-lateral PO9/PO10 and decoded ~4 pts worse on sub-904
(63.9% vs 68.1% @4s). The offline report still computes SNR/topography over all 64 channels (only the
decode is restricted). To override and compare policies by hand, use the developer script
scripts/analyze_recording.py --reref … --roi … — those knobs are exactly why it still exists (#69);
they are not on the operator path, which applies the manifest's policy and nothing else. See
DESIGN_PRINCIPLES #8.
The 32-ch posterior actiCAP (NCIL)¶
builder.acticap_32ch_posterior_montage() — 32 electrodes in the posterior holders of the 64-holder
cap, concentrating coverage over visual cortex. Not a stock actiCAP layout. Wiring order confirmed with
Aaron 2026-07-15: ch1 FCz, ch2 Cz as midline anchors, then complete rows sweeping left→right,
rows running anterior→posterior. PO9/PO10 sit at occipital height, so they belong to the O row:
| Row | Channels | Sites |
|---|---|---|
| anchors | 1–2 | FCz, Cz |
| TP/CP | 3–13 | TP9 TP7 CP5 CP3 CP1 CPz CP2 CP4 CP6 TP8 TP10 |
| P | 14–22 | P7 P5 P3 P1 Pz P2 P4 P6 P8 |
| PO | 23–27 | PO7 PO3 POz PO4 PO8 |
| O | 28–32 | PO9 O1 Oz O2 PO10 |
This fills 1..32 exactly: the 64Ch Standard-2 cap has precisely 30 holders from the TP/CP row back,
and 30 + FCz + Cz = 32. Note FCz is the 64-ch cap's reference holder (which is why it's absent from
acticap_64ch_montage) — it carries a normal electrode here, and the amp records reference-free.
Nothing special is needed to decode it: at 32 channels it is above spatial.BIG_MONTAGE, and
reference-free, so occipital_decode_plan returns CAR + the occ-8 automatically — and the cap
carries the whole validated occ-8 (O1 Oz O2 POz PO3 PO4 PO7 PO8), so no position-based fallback runs.
Positions reuse the 64-ch montage's coordinates for every shared site (FCz is the only new one), so the
two montages plot identically. This cap is exactly the partly-populated case above — the amp reports
64 channels for it, and the bridge trims to 32 (verified end-to-end on hardware 2026-07-15: the outlet
advertises 32 channels and no railed sample reaches the stream).