DEV Community

memeshe
memeshe

Posted on Edited on

HalfBeat — the interview coach that scores how fast you come back down

Presage SmartSpectra SDK Challenge Submission

This is a submission for the Presage SmartSpectra SDK Challenge

What I Built

You have an interview on Thursday. You practise the answers, and you practise them well — you know
the STAR structure, you have the salary number ready, you have done the "tell me about yourself" so
many times it comes out in one breath.

Then somebody asks the salary question, and your ears go hot. You cannot see it. Nobody in the room
tells you. That is the thing interview prep cannot fix: not what you said, but what your body did while
you were saying it.

HalfBeat points a webcam at you, runs the Presage SmartSpectra
SDK
, and scores exactly one thing:

how many seconds it took your pulse to come back inside your own calm band after each hard
question, and what the half-life of that decay was.

Not "your composure was 71/100". Not a percentile against strangers. The return. Because the return is
the thing that changes with practice — run the same question set tomorrow and the number moves in a
direction you can see.

Around that number the app does the parts that make it a product rather than a demo: an explicit
consent gate before the camera opens, a framing check that speaks the SDK's own validation codes
("too dark — add light on your face"), a settle-in that learns your baseline in this room and
refuses to end until it has five clean readings to learn it from, rounds of question → answer out loud
→ stop talking, and a report that ranks your questions by how slowly you came down and compares the run
to your last one.

Everything is local. There is no account, no server, no upload. The repo is
memeshee/halfbeat, npm install && npm start, and there is a
replay page that renders any saved session with no camera and no SDK at all.

Demo

▶ Same cut as MP4, 4 MB
— the report alone, in a browser, no install: memeshee.github.io/halfbeat

What you are watching, plainly. The video is two runs.

The first is the live camera path: the app is pointed at a real webcam on this machine, the real SDK
runs, and the framing check reports what it actually sees. Nobody is in frame, so it says so — that is
the honest version of "the camera is on".

The second is a recorded clip pushed through the SDK's own recorded-input path
(sdk.useFile(path, {interframeDelayMs})), played at 2× in the video. I chose not to put my own face in
a public video, and a stock clip cannot contain a nervous interviewee — so the demo never shows a
human producing a real recovery, and I am not going to pretend it does.
What it shows is the pipeline
running on real signals, and then the app refusing to score them because nothing rose. The one-flag path
back to a live human is npm start; the synthetic mock source used for UI development is labelled
MOCK SIGNALS in the header, in the report metadata and in the artefact itself.

The data, and what's not real

The clip is public domain: Capt. Jonathan Hopkins, Commander, Fleet Activities Yokosuka, sits down for
a radio interview… (U.S. Navy video by Mass Communication Specialist 2nd Class Nicholas Rodriguez)
,
via DVIDS 997782. 17 U.S.C. §105, no attribution required, credited anyway.

Two things follow, and both are load-bearing. First, nothing in this post is a statement about the
person in that footage
— the app is measuring pipeline behaviour there, and the evaluation section
below says so in its first line, because analysing a stranger's physiology is exactly what this
challenge's rules rule out. Second, a clip is 60 seconds of one take; a real session is six rounds, so
the demo runs a shortened three-phase configuration whose lengths are printed in the report metadata
rather than hidden. The path to real data is one flag: npm start swaps useFile() for
useCamera() and the rest of the pipeline is the same code, which is what the repo is for.

Code

github.com/memeshee/halfbeat — public, MIT, and the README
quickstart is the sequence I actually ran (I re-ran it before publishing; the key file, the port, the
flags and the output paths all match the source).

src/integrity.mjs  what counts as a believable pulse reading, and what a refusal must say
src/recovery.mjs   baseline band, peak, time-to-band, exponential half-life, ranking — pure functions
src/source.mjs     camera / recorded file / synthetic mock behind one interface
src/session.mjs    consent → check → settle-in → rounds → report; phase boundaries on the sample clock
src/signals.mjs    protobuf metrics → one flat sample per tick
web/               live instrument panel, report, run-over-run comparison, server-free replay page
scripts/verify.mjs the check that has to pass: fixture analysis + adversarial series + a real session
scripts/eval.mjs   the recorded-input harness behind the numbers below
scripts/egress.mjs measures what the app talks to, so the privacy section is an observation
Enter fullscreen mode Exit fullscreen mode

How It Responds to Human Signals

Three live Presage signals decide what the app does, and each of them has a different time constant,
which is the whole engineering problem.

Pulse (cardio.pulseRate, a 12-second average with a confidence score) is the spine. During the
settle-in window the app collects readings whose confidence clears the SDK's own stability threshold
(≥40, which the model card maps to ±3 bpm) and takes the median as the baseline, with a band derived
from the median absolute deviation. Every later number is pulse − that baseline.

Talking (face.talking.detected) is what makes a round a round: the answer window ends when the
countdown ends or when you press "done answering", and then the recovery window starts with the
instruction stop talking and sit still. Talking detection is visual — from your face, not your
voice — which is why the app never opens a microphone.

Expression (face.expression, per-class probabilities) rides the same timeline as context. It is
deliberately never the headline: "your dominant class was neutral at 0.88" is a thing a model says, not
a thing you can act on.

The integration is one Node process that owns the SDK and a browser page that owns the pixels:

[camera] ─┐
[file]   ─┼─► Source ─► SmartSpectraSDK ─► metrics protobuf ─► signals.mjs ─► one sample per tick
[mock]   ─┘        useCamera / useFile                     │
                                                           ▼
                       session.mjs (phases, rounds) ─► recovery.mjs ─► session JSON
                                                           │
                                            server.mjs (127.0.0.1 + ws) ─► web/
Enter fullscreen mode Exit fullscreen mode

No Electron build chain, no electron-rebuild, no native addon — @smartspectra/node-sdk loads the
platform runtime through koffi, so the whole app is node src/index.mjs.

The bug that mattered: the app lied about a recovery

My first real run reported "peak +36 bpm, never came back down". It was measuring noise.

The footage was a talking, gesturing subject; the SDK's pulse series went 55 → 73 → 91 bpm in about
three seconds and kept climbing. A human pulse cannot move that fast. Compressed video, motion and a
face at 30% of frame height make camera pulse easy to corrupt, and my analysis layer had no idea: it
found a peak, fitted a decay, and printed a confident physiological sentence assembled out of camera
artefacts. That is the failure that makes this whole product category untrustworthy, and I had built it.

src/integrity.mjs is the fix and is now the most load-bearing file in the repo:

  • A rate, not a flat step limit. Readings are rejected when the change between consecutive accepted readings exceeds max(8 bpm, 3.5 bpm/s × Δt) — the artefacts I measured ran at ~12 bpm/s, while a real startle stays under 4. My first attempt used a flat 8 bpm step limit, which rejected a genuine 10 bpm decay step across a 12-second window: the gate has to know about time.
  • Minimum counts. No baseline from fewer than five clean readings, no round scored from fewer than four. My first run built a baseline out of two noisy readings and then compared everything to it.
  • A verdict with reasons. Every window returns stable, unstable or insufficient, and a refusal is printed on the report row instead of a number.
  • "No rise" is not "never came back down." A window where nothing rose has no recovery to measure.
  • Sub-window precision is not claimed. If pulse is back inside the band within one reading, the report says it returned faster than a 12-second window can time, rather than printing "0 s".

The settle-in phase also stopped pretending: it now extends past its window until it has five clean
readings, and the screen says "learning your calm baseline — 3/5 readings" instead of counting down to a
deadline it was not going to meet.

Two more that bit me, in one line each. One clock: phase boundaries are now stamped with the same
monotonic clock that stamps each sample, after a version that lined every timeline up wrong by exactly
the process start offset — obvious in a chart, invisible in a log. Dedupe before you fit: the SDK
re-emits its rolling measurement array on every tick, so a naive ln(excess) fit is dominated by
repeated points and reports a half-life that is really the tick rate; deduping on the SDK's own
measurement timestamp moved a reported half-life from 3.4 s to 12.0 s on a fixture whose true value is
exactly 12 s, and that fixture is now a unit check.

And rather than write "nothing leaves your machine", the app publishes what it talks to — see the
measured egress in the section below.

Written inside the entry period. The first commit is 2026-11-05, one day after this challenge
opened, and the repository's history is the receipt — the rules require the project to be developed
after the entry period begins, so there is nothing here from before it.

What I measured, and where it loses

Everything below came out of scripts/ on this machine and lives in the repo.

The pipeline, on recorded input (docs/EVAL.md):

clip pulse readings mean sd mean confidence verdict
close-up talking head (Pexels licence) 41 60.6 bpm 1.9 69 analysed — the gate passes, nothing rises above the band, so nothing is scored
edited interview (public domain, DVIDS) 50 78.9 bpm 9.0 73 refused — the series spans too wide a range to be a body

The second row is the result I care about. Without the integrity layer that clip becomes a +36 bpm
spike
; with it, the app refuses and says why. On good footage the honest spread is about ±2 bpm, and
on bad footage it is unbounded — which is exactly why a tolerance is not enough and a gate is required.

The network, measured (docs/EGRESS.md): two outbound TLS connections per session, both to the
vendor's anycast authorisation edge, first seen at second 6–7. Nothing else, from a machine that was
otherwise busy.

The suite (node scripts/verify.mjs): 29 assertions, including a constructed series whose true
recovery half-life is exactly 12 s — the fit recovers 12.0 s — a two-reading baseline that must be
refused, a series that leaps 36 bpm in three seconds that must be refused, a flat window that must be
called "no rise" rather than "never came back down", and an end-to-end session that must label itself
synthetic.

Four honest limits that no amount of polish hides:

  • Pulse is a 12-second average. A "time to come back down" of 24 seconds is two pulse windows, not 24.0 s of precision. The report prints the resolution beside the number instead of implying otherwise, and a recovery half-life shorter than the noise floor (sd in the table above) is a rounding artefact, not a finding.
  • HRV is mostly absent in a 60-second round. It needs a full 60-second clean window, so the live panel shows — (needs 60s) rather than a stale number. If I had printed the last known value the panel would look richer and mean less.
  • Breathing rate cannot be measured while you talk. The SDK's own model card lists talking under "does not work when". My first build displayed a breathing number during the answer window, which was a number the documentation says not to trust there. It now prints — (invalid while talking) during the answer and only shows breathing during the silent settle-in and recovery windows. This is why the round has a recovery window at all: the silence is the measurement.

Built with the Presage SmartSpectra SDK. Vitals come from
Presage; the recovery analysis, the interface and the evaluation harness are this repository's work.

Prize Categories

Best Thoughtful Real-World Use. The real-world need is specific: people rehearse for high-stakes
conversations alone, and the feedback they get is either a friend's encouragement or a recording of
their own voice. Neither can see arousal. The people this is for are job candidates, viva and thesis
defence students, people prepping a residency MMI, and anyone rehearsing a conversation they are
dreading — all of whom already sit in front of a laptop to practise.

Who it is not for. Hiring teams. A tool that reads a candidate's physiology is the exact shape of
what made facial analysis in interviews reputationally radioactive; HalfBeat is for the person on the
receiving end. A pulse rising during a hard question is a normal thing bodies do, not a verdict.

Consent and Data Handling

  • Consent is a screen, not a checkbox. The camera is not opened until you press "Turn on the camera and continue"; session.mjs refuses samples while the phase is consent, so the press is the gate, not a nicety.
  • Camera only. No microphone, no audio, no screen capture. "Talking" is inferred from the face on video. The consent screen says this in its first line, because "does this listen to me" is the first question anyone asks.
  • Storage. Default is one JSON file per session in sessions/, written at the end of a run. It contains numbers and timestamps: pulse values with confidence, breathing rate, HRV, expression labels with probabilities, talking yes/no, phase boundaries, and the derived recovery metrics. No images, no video, no audio, and no facial landmarks — the SDK provides 478 landmarks and this app never stores or transmits them.
  • Sharing: measured. There is no server, no analytics, no CDN and no font fetch in this app; the UI is served from 127.0.0.1 and the replay page loads nothing from the network at all. The app contains no requestInsight() call. Rather than assert that the closed native SDK is silent, scripts/egress.mjs samples ss -tnp every 500 ms during a session and publishes every outbound endpoint the process tree opens (docs/EGRESS.md): two TLS connections, both to the vendor's authorisation edge, no video, no vital values.
  • Retention and deletion. Sessions stay until you delete them; rm -r sessions/ is the whole deletion mechanism, and there is no second copy. Nothing persists between runs — the baseline is recomputed each session, so no profile accumulates inside the app.
  • SDK telemetry: off in this build. The SDK can send an aggregate, non-identifying session summary (source kind, session shape — no video, no vitals, no identifiers); this app constructs it with enableTelemetry: false, the header says SDK telemetry off throughout, and --telemetry turns it back on. Disclosed rather than hidden, because "the vendor sees my sessions" should never be a surprise in a consent section.
  • Wellness only, no diagnosis. Every figure is a general-wellness estimate. The SDK is not an FDA-cleared device and nothing here is a diagnosis, a treatment, a screening test or a fitness-for-work assessment. The app never says "stress", "anxiety" or "nervous" as a measurement — it says pulse went up 16 bpm and took 24 seconds to come back, which is what it actually knows.

docs/PRIVACY.md in the repo is this section in longer form, written against the code rather than as
a policy statement.

Agent Session

This build was done with an AI coding agent driving the terminal end to end. The transcript is not in
one of the formats the Agent Sessions uploader parses (the supported set is Claude Code, Gemini CLI,
Codex, GitHub Copilot CLI and Pi), so there is no embed here rather than a broken one — happy to paste
excerpts in the comments if anyone wants to see how a particular decision was reached.

Top comments (0)