Skip to content

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 vamp driver, 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) — so CHAMP_PROPERTIES layout + µ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):

  1. 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_CODES is now {10000,50000,100000} only; configure(output_rate_hz=…) sets a native base rate and an anti-aliased _Decimator downsamples 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.
  2. µV coding = signed (two's-complement), verified 2026-07-14 with an actiCAP connected. read() returns count × ResolutionEeg × 1e6 (no offset). ⚠ A 2026-07-13 floating-input test wrongly suggested offset-binary and a 2²³ 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):

  1. DLL discoveryfind_dll() now finds ActiChamp_x64.dll in 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 Recorder x86 DLL). Previously the GUI couldn't load the DLL at all unless ACTICHAMP_DLL was set — the root cause of "streaming doesn't work in the toolbox".
  2. Open retry (cold-start + busy-restart)stream_to_lsl / stream_eeg_preview go through _open_streaming, which (a) retries a null champOpen with backoff — a previous bridge that was hard-killed (the pythonw GUI can't deliver CTRL-BREAK, so _stop_bridge falls back to a kill that skips champClose) briefly leaves the amp claimed, wedging a quick restart — and (b) confirms data is actually flowing after champStart, 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_impedance polled impedances_kohm(settle_s=0.0) in a loop, reading immediately after champStart and 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() → settle IMPEDANCE_SETTLE_S (1.5 s) → read_impedances_kohm() in place → exit_impedance_mode(). Mode switches are now constant (3) instead of 1 + 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 → exit path 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 were normal (the orchestrator never passes --mode, and the bridge defaults to normal).
  • 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)
With the LSL stream up, run the toolbox as usual (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 500 shown 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 from CHAMP_PROPERTIES after 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_MONTAGE gets 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 with sys.executable, so they're console-less too. CTRL_BREAK_EVENT is accepted by the OS but never delivered — measured: _stop_bridge burned its full 5 s timeout, every time, then hard-killed. The SIGBREAK handler 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 (skipping champClose) and polling immediately: champOpen succeeded 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_configured retry is not the thing to reach for here.
  • A running Setup-tab signal preview DOES lock the recording out. SignalPreview streams via stream_eeg_preview on 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-style champOpen returns 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).