hans / hans/dot-prediction-app
Teensy real-time communication
@PauloParramon is already working on this.
Since Jul 6, 2026.
- Dominant language
- JavaScript
- Stars
- 0
- Forks
- 0
- PR merge metrics
- No merged PRs in 30d
Description
Dot Prediction → Teensy Trigger Rig (direct WebSocket)
Sending reveal events (predicted point, actual point, error, reaction time) from a
JavaScript dot-prediction task running in Chrome on an iPad to a Teensy 4.1,
which strobes a digital trigger to the DAQ for neural time-stamping.
Task page is served over plain HTTP, so ws:// is clean (no mixed-content block).
No CORS work needed — the WebSocket handshake doesn't use CORS.
Core design principle
Separate the timing trigger from the metadata. They have different requirements
and serving both with one mechanism is where these rigs go wrong.
- Trigger — low-bandwidth, timing-critical digital event the DAQ timestamps in neural
time. Answers only "a reveal happened, here's its code." Fire it as fast as possible. - Metadata (pred, actual, error, RT) — high-bandwidth, not timing-critical. Logged,
keyed by a shared counter, merged offline.
So on each packet the Teensy: fires the trigger first, then deals with metadata.
Packet layout makes the code readable without parsing:
[uint8 code][JSON metadata...]
Teensy reads byte 0 → strobes → only then parses the JSON tail.
Topology
iPad Chrome (WS client, WiFi) ─┐
├─ same LAN / subnet ─→ Teensy 4.1 (WS server) ─→ DAQ trigger
your HTTP page server ──────────┘
Physical requirement for direct-to-Teensy: the iPad must be able to route to the
Teensy's IP. iPad is on WiFi, Teensy is on wired Ethernet, so a router/AP must bridge both
onto one subnet — Teensy in a LAN port of the same router the iPad's WiFi hangs off, both on
192.168.1.x.
Lower-jitter option: a USB-C-to-Ethernet adapter puts the iPad on the same wired switch.
Same code, less WiFi jitter.
Message format
{
"seq": 1423, // monotonic counter — offline merge key + dropped-packet detection
"code": 4, // event type -> the digital trigger value
"t_js": 128374.51, // performance.now(), browser clock (ms) — never touches neural time
"trial": 87,
"pred": [x, y],
"actual": [x, y],
"error": 12.3,
"rt": 843.2
}
seqis the glue for offline merging and the drop detector.t_jsis browser-clock only; it reaches neural time only through the hardware trigger.
Library choice (the one thing to verify)
Teensy needs a WebSocket server layer on top of QNEthernet's TCP. Pragmatic pick:
Links2004/arduinoWebSockets (WebSocketsServer) — bundles handshake, framing, masking,
ping/pong.
Compile risk to check against current docs: arduinoWebSockets selects its network backend
at compile time (WEBSOCKETS_NETWORK_TYPE); binding it to QNEthernet's
EthernetServer/EthernetClient (in the qindesign::network namespace) is the fiddly bit.
Two routes:
- Point it at the generic-Ethernet backend and let QNEthernet's Arduino-compatible types satisfy it, or
- Fall back to NativeEthernet, whose API matches what the library expects out of the box.
If the library fights QNEthernet, NativeEthernet is the path of least resistance for a
first working version. Verify before committing wiring — this is the only spot where "it just
compiles" isn't guaranteed.
Teensy firmware sketch
#include <QNEthernet.h>
#include <WebSocketsServer.h> // Links2004/arduinoWebSockets
using namespace qindesign::network;
// trigger out — 8-bit parallel + strobe (match to your DAQ digital port)
const uint8_t CODE_PINS[8] = {2,3,4,5,6,7,8,9};
const uint8_t STROBE = 10;
const uint16_t STROBE_US = 100;
IPAddress ip(192,168,1,50), subnet(255,255,255,0), gw(192,168,1,1);
WebSocketsServer ws(8080);
inline void setCode(uint8_t c){
for (uint8_t i = 0; i < 8; i++) digitalWriteFast(CODE_PINS[i], (c >> i) & 1);
}
inline void fireTrigger(uint8_t code){
setCode(code);
digitalWriteFast(STROBE, HIGH);
delayMicroseconds(STROBE_US);
digitalWriteFast(STROBE, LOW);
}
void onWsEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t len){
if (type == WStype_BIN && len >= 1){
fireTrigger(payload[0]); // FIRE FIRST — byte 0 is the code
uint32_t t = micros(); // receipt time, validation only
Serial.write(payload + 1, len - 1); // JSON tail -> log seq/pred/actual/error/rt
Serial.printf(" t_us=%lu\n", t);
} else if (type == WStype_CONNECTED){
Serial.printf("client %u connected\n", num);
} else if (type == WStype_DISCONNECTED){
Serial.printf("client %u disconnected\n", num);
}
}
void setup(){
Serial.begin(115200);
for (uint8_t i = 0; i < 8; i++){ pinMode(CODE_PINS[i], OUTPUT); digitalWriteFast(CODE_PINS[i], LOW); }
pinMode(STROBE, OUTPUT); digitalWriteFast(STROBE, LOW);
Ethernet.begin(ip, subnet, gw); // static — no DHCP wait
ws.begin();
ws.onEvent(onWsEvent);
ws.enableHeartbeat(15000, 3000, 2); // ping every 15s, 3s timeout, drop after 2 misses
}
void loop(){ ws.loop(); }
Verify the exact
Ethernet.beginoverload andenableHeartbeatsignature against your
installed library versions — those APIs drift.
iPad client JS
Send a binary frame — [code byte][JSON] — so the Teensy reads the code without parsing.
let ws;
function connect(){
ws = new WebSocket("ws://192.168.1.50:8080");
ws.binaryType = "arraybuffer";
ws.onclose = () => setTimeout(connect, 500); // auto-reconnect
ws.onerror = () => ws.close();
}
connect();
function sendReveal({ seq, code, trial, pred, actual, error, rt }){
// always write your local behavioral log here, regardless of socket state
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const body = new TextEncoder().encode(
JSON.stringify({ seq, t_js: performance.now(), trial, pred, actual, error, rt })
);
const pkt = new Uint8Array(1 + body.length);
pkt[0] = code & 0xFF;
pkt.set(body, 1);
ws.send(pkt.buffer);
}
iOS-specific gotchas
- Connection dies on sleep / backgrounding. WebKit suspends timers and closes sockets when
the tab isn't foregrounded. Per session: disable auto-lock, keep the task tab foreground, rely
ononclose → reconnect. The Teensy heartbeat catches half-open connections the iPad doesn't
cleanly close. - Keep writing the browser log unconditionally.
sendReveallogs locally before checking
socket state — a dropped connection costs a timing trigger for those trials (recoverable-ish
via photodiode), never the metadata. Browser log stays ground truth;seqdrives the merge.
Timing & offline merge (the methodological crux)
Three clocks never agree: browser performance.now(), Teensy micros(), DAQ clock. The
hardware trigger is the only thing tying browser events to neural time.
- DAQ records
(code, neural-timestamp). - Browser records
(seq, code, full metadata, t_js). - Match on
seqoffline to hang metadata on each neural-time trigger. - Teensy also logs receipt
micros()+ seq — not for analysis, but to characterize
latency/jitter and catch drops.
Caveat to state loudly: the network trigger fires when JS emits the reveal event, which is
one frame + panel latency (~16.7 ms @ 60 Hz, plus display lag) away from photons changing on
screen, and WiFi + event-loop jitter adds several ms on top. If you need ms-accurate
visual-onset-locked responses, put a photodiode on the reveal region into a DAQ analog
channel and treat that as onset truth. The network trigger is then a coarse marker + metadata
key. If you only need event identity + behavioral RT, the network trigger alone is fine.
DAQ output format (pin plan depends on this)
What the trigger physically is depends on your DAQ's digital input:
- Single TTL pulse on a BNC — marks "an event," no content. Simplest.
- N-bit parallel + strobe — 8 GPIO carry the code, one strobe latches it. Classic pattern
for Blackrock / Ripple / Neuralynx digital ports; almost certainly what you want for ECoG
since it distinguishes event types in the neural stream. (This is what the sketch assumes.) - Serial to a trigger box, if your system takes that.
Confirm which your acquisition system expects before wiring — it determines the pin plan.
Bring-up sequence
- Reachability first. From the iPad, load
http://192.168.1.50:8080/in the browser — even
a failed/blank response confirms the iPad routes to the Teensy. If it times out, it's a
network/subnet problem, not code, and nothing else works until fixed. - Handshake. Open the WS from a desktop browser console on the same LAN; confirm
WStype_CONNECTEDprints on Teensy Serial. Desktop-first isolates iOS quirks from library quirks. - Trigger. Scope the strobe + code pins while firing test frames; confirm width and code bits.
- iPad end-to-end. Run the task, watch Serial for the JSON tail +
seqincrementing with no gaps. - Timing truth. Photodiode on the reveal region into a DAQ analog channel; compare to the
strobe across a few hundred trials to characterize WiFi lag + jitter. Keep that distribution —
network trigger stays a coarse marker + metadata key, photodiode is onset truth.
Open questions to close the spec
- Does the DAQ digital input want a single TTL or 8-bit parallel + strobe?
- Does the bridging router/AP already exist in the rig, or does it need adding?
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.