From 96186666fbae07c2afce7e9a4dfb0c750b17517a Mon Sep 17 00:00:00 2001 From: Yury Gitman Date: Sun, 16 Aug 2026 19:33:55 -0400 Subject: [PATCH 1/6] Add dual-sensor PTT mode to Signal Coach --- README.md | 9 +- docs/signal-coach/README.md | 40 +++- docs/signal-coach/index.html | 80 ++++++- docs/signal-coach/protocol.test.mjs | 9 + docs/signal-coach/ptt-coach-core.mjs | 134 +++++++++++ docs/signal-coach/ptt-coach-core.test.mjs | 50 +++++ .../signal-coach/pulse-webserial-protocol.mjs | 13 ++ docs/signal-coach/signal-coach-core.mjs | 29 ++- docs/signal-coach/signal-coach.css | 36 ++- docs/signal-coach/signal-coach.mjs | 212 +++++++++++++++++- docs/signal-coach/source-pack.test.mjs | 13 +- .../PulseSensor_Pulse_Transit_Time.ino | 25 ++- .../SignalCoachDualWebSerial.ino | 47 ++++ resources/CodeDescriptions.md | 1 + resources/PulseSensor Playground Tools.md | 12 +- .../shopify/page-signal-coach.html | 17 +- src/PulseSensorPlayground.cpp | 14 ++ src/PulseSensorPlayground.h | 15 ++ src/utility/PulseSensor.cpp | 26 ++- src/utility/PulseSensor.h | 9 +- 20 files changed, 748 insertions(+), 43 deletions(-) create mode 100644 docs/signal-coach/ptt-coach-core.mjs create mode 100644 docs/signal-coach/ptt-coach-core.test.mjs create mode 100644 examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino diff --git a/README.md b/README.md index bdc2dfb..ef52da8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This Playground is a collection of code for the most popular uses of PulseSensor --- -## Web Serial Signal Coach +## Web Serial Signal Coach and two-sensor PTT lab The browser dashboard in [`docs/signal-coach/`](docs/signal-coach/) connects @@ -41,6 +41,12 @@ The complete no-build, no-minification home-hacking guide is Arduino/ESP32 sender lives at [`examples/SignalCoachWebSerial/SignalCoachWebSerial.ino`](examples/SignalCoachWebSerial/SignalCoachWebSerial.ino). +The same dashboard also has a **Two sensors / PTT** mode with synchronized A0 +and A1 waveforms, fixed or adaptive thresholds per sensor, clipping and weak +signal gates, and one-to-one same-cycle beat pairing. Its 500 Hz sender is +[`examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino`](examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino). +The PTT lab is educational and does not estimate blood pressure. + Run it locally from the repository root: ```bash @@ -54,6 +60,7 @@ own the port. Protocol tests run with: ```bash node docs/signal-coach/protocol.test.mjs node docs/signal-coach/signal-coach-core.test.mjs +node docs/signal-coach/ptt-coach-core.test.mjs node docs/signal-coach/source-pack.test.mjs ``` diff --git a/docs/signal-coach/README.md b/docs/signal-coach/README.md index 00191fb..2588497 100644 --- a/docs/signal-coach/README.md +++ b/docs/signal-coach/README.md @@ -2,8 +2,9 @@ This folder is the complete source for the public PulseSensor Signal Coach. There is no framework, package install, bundler, minified file, server-side -detector, or private runtime. Chrome receives a pulse-wave sample and runs beat -detection, IBI, BPM, confidence, coaching states, and Re-sync locally. +detector, or private runtime. Chrome receives one or two pulse-wave samples and +runs beat detection, IBI, BPM, confidence, coaching states, and same-cycle PTT +pairing locally. Public app: Standalone dashboard: @@ -30,6 +31,23 @@ Close Arduino Serial Monitor before connecting because only one application can own the serial port at a time. Web Serial works on HTTPS pages and on `localhost`; opening `index.html` directly as a file is not supported. +## Two-sensor PTT build + +1. Connect the proximal sensor to A0 and the distal sensor to A1. An earlobe + and fingertip are easier to stabilize than a wrist and fingertip. +2. Upload + [`examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino`](../../examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino). +3. In Signal Coach choose **Two sensors / PTT**, click **Connect**, and select + the board. This mode uses 500 synchronized samples/s at 250000 baud. +4. Start with fixed thresholds. Move each threshold above its idle noise but + through the rising pulse wave. Use adaptive mode only when you deliberately + want the threshold to follow recent pulse amplitude. + +The lab accepts a distal beat only after a proximal beat in the same 5–300 ms +window, consumes each beat once, and rejects weak or clipped channels. It never +converts PTT to blood pressure. This is an educational timing experiment, not +a medical measurement. + ## Complete source map | File | What to hack | @@ -39,11 +57,14 @@ own the serial port at a time. Web Serial works on HTTPS pages and on | [`signal-coach.css`](signal-coach.css) | Large waveform display, coaching colors, layout, and responsive styles. | | [`signal-coach.mjs`](signal-coach.mjs) | Web Serial connection, sample clock, canvas rendering, UI, and blue Re-sync action. | | [`signal-coach-core.mjs`](signal-coach-core.mjs) | Transport-neutral beat detector, BPM/IBI, confidence, state order, copy, and v1.1-resync recovery. | +| [`ptt-coach-core.mjs`](ptt-coach-core.mjs) | Dual-channel signal quality, fixed/adaptive detector control, and one-to-one PTT pairing. | | [`pulse-webserial-protocol.mjs`](pulse-webserial-protocol.mjs) | Text/CSV/JSON/PSWS input adapters. | | [`signal-coach-core.test.mjs`](signal-coach-core.test.mjs) | 10-bit, 12-bit, multi-rate, flat-wave, false-sender, and Re-sync tests. | +| [`ptt-coach-core.test.mjs`](ptt-coach-core.test.mjs) | Threshold-mode, missed-beat, timing-window, clipping, and synthetic PTT tests. | | [`protocol.test.mjs`](protocol.test.mjs) | Accepted and rejected serial-line examples. | | [`source-pack.test.mjs`](source-pack.test.mjs) | Verifies that the public home-build source pack and links stay complete. | | [`SignalCoachWebSerial.ino`](../../examples/SignalCoachWebSerial/SignalCoachWebSerial.ino) | Minimal Arduino/ESP32 raw pulse-wave sender. | +| [`SignalCoachDualWebSerial.ino`](../../examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino) | Synchronized A0/A1 sender for the two-sensor PTT lab. | | [`page-signal-coach.html`](../../resources/signal-coach/shopify/page-signal-coach.html) | Complete source for the surrounding PulseSensor.com tutorial page. | The repository root [`LICENSE`](../../LICENSE) is the MIT License. You may use, @@ -69,6 +90,13 @@ Signal Coach also accepts: - Arduino `signal,bpm,ibi,beat` CSV; and - PulseLink PSWS v1/v2. +Two-sensor mode uses one versioned CSV record per synchronized sample: + +```text +PTT1,timestamp_us,proximal,distal +PTT1,123456,532,601 +``` + The browser uses sender timestamps when present. Otherwise it advances time using the **Samples/s** choice. Sender BPM, IBI, beat, quality, and coach state are diagnostics only; raw waveform data always controls the browser coach. @@ -78,6 +106,9 @@ are diagnostics only; raw waveform data always controls the browser coach. ```text PulseSensor -> analogRead() -> USB serial -> input adapter -> BrowserSignalCoach -> waveform + guidance + qualified BPM + +Two PulseSensors -> synchronized timestamp + A0/A1 -> DualSignalCoach + -> quality gates -> one-to-one pairing -> educational PTT ``` The board is deliberately simple. This makes the same browser coach usable @@ -109,6 +140,7 @@ root: ```sh node docs/signal-coach/protocol.test.mjs node docs/signal-coach/signal-coach-core.test.mjs +node docs/signal-coach/ptt-coach-core.test.mjs node docs/signal-coach/source-pack.test.mjs ``` @@ -116,6 +148,10 @@ For visual testing, open this explicitly simulated URL: +Two-sensor replay: + + + The page must say **SIMULATED BENCH SIGNAL — NOT A PERSON**. The normal URL never invents fallback samples. diff --git a/docs/signal-coach/index.html b/docs/signal-coach/index.html index b892951..a74e596 100644 --- a/docs/signal-coach/index.html +++ b/docs/signal-coach/index.html @@ -6,24 +6,30 @@ PulseSensor Signal Coach - - + +

PulseSensor · Web Serial

-

BETA

+

BETA

Signal Coach

-

Connect Arduino, ESP32, or any supported pulse-wave serial stream. Chrome finds the beats, coaches the signal, and shows BPM only after it settles.

+

Coach one pulse signal for BPM, or compare two synchronized sensors for educational Pulse Transit Time. Detection and pairing stay in your browser.

Disconnected - Pulse-wave input · 115200 baud + Single pulse-wave input · 115200 baud
+ + + +
+ A1 · Distal + + +
+
+ Pairing window + + +
+
+ +
+
+
A0 PROXIMALWARMING
+ +

Signal -- · threshold 550 fixed

+
+
+
A1 DISTALWARMING
+ +

Signal -- · threshold 550 fixed

+
+
+ +
+
Latest PTT--milliseconds
+
Rolling median--last 20 accepted pairs
+
Accepted0quality + timing pass
+
Rejected / unmatched0 / 0never carried into next cycle
+
+

Upload the dual-channel sketch, then connect both sensors.

+
@@ -109,13 +169,13 @@

Live PulseSensor display

Supported pulse streams -

Signal Coach needs a numeric pulse-wave sample. It accepts one number per line, S512, Signal 512, signal=512, timestamp,signal, JSON samples, Arduino signal,bpm,ibi,beat, and PulseLink PSWS. Sender BPM and IBI never control the coach.

+

Single mode accepts one number per line, S512, labeled or timestamped samples, JSON, Arduino signal,bpm,ibi,beat, and PulseLink PSWS. Dual mode accepts PTT1,timestamp_us,proximal,distal. Sender BPM and IBI never control the coach.

Choose the rate your sketch prints. The default tutorial sends 50 samples each second.

@@ -128,6 +188,6 @@

Live PulseSensor display

setTimeout(announceReady, 500); } - + diff --git a/docs/signal-coach/protocol.test.mjs b/docs/signal-coach/protocol.test.mjs index 9e356e0..95a0b48 100644 --- a/docs/signal-coach/protocol.test.mjs +++ b/docs/signal-coach/protocol.test.mjs @@ -24,6 +24,13 @@ assert.equal(parsePulseLine('{"timestampMs":1200,"raw":2048,"bpm":70}').sourceBp assert.equal(parsePulseLine('B72'), null); assert.equal(parsePulseLine('Q833'), null); +const ptt = parsePulseLine('PTT1,123456,520,618'); +assert.equal(ptt.source, 'ptt1'); +assert.equal(ptt.timestampUs, 123456); +assert.equal(ptt.timestampMs, 123.456); +assert.equal(ptt.signal, 520); +assert.equal(ptt.secondarySignal, 618); + const v1 = parsePulseLine('PSWS,1,SIM,42,12345,2840,72,833,1,12,QUALIFIED'); assert.equal(v1.source, 'pulselink'); assert.equal(v1.format, 'PulseLink PSWS v1'); @@ -43,5 +50,7 @@ assert.throws(() => parsePulseLine('70000'), /invalid signal/); assert.throws(() => parsePulseLine('PSWS,1,SIM,42,12345,70000,72,833,1,12,QUALIFIED'), /signal/); assert.throws(() => parsePulseLine('PSWS,1,UNKNOWN,42,12345,2048,72,833,1,12,QUALIFIED'), /mode/); assert.throws(() => parsePulseLine('PSWS,3,LIVE,42,12345,2048,72,833,1,12,QUALIFIED'), /unsupported/); +assert.throws(() => parsePulseLine('PTT1,123,500'), /field count/); +assert.throws(() => parsePulseLine('PTT1,123,500,70000'), /secondary signal/); console.log('PulseSensor pulse-stream adapter tests passed'); diff --git a/docs/signal-coach/ptt-coach-core.mjs b/docs/signal-coach/ptt-coach-core.mjs new file mode 100644 index 0000000..4cc14c5 --- /dev/null +++ b/docs/signal-coach/ptt-coach-core.mjs @@ -0,0 +1,134 @@ +import { BrowserSignalCoach } from './signal-coach-core.mjs'; + +function median(values) { + if (!values.length) return null; + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +} + +export class SignalQualityWindow { + constructor({ windowSize = 500, warmupSize = 100, minRange = 25, clipLow = 5, clipHigh = 1018 } = {}) { + this.options = { windowSize, warmupSize, minRange, clipLow, clipHigh }; + this.samples = []; + } + + update(signal) { + this.samples.push(signal); + if (this.samples.length > this.options.windowSize) this.samples.shift(); + const minimum = Math.min(...this.samples); + const maximum = Math.max(...this.samples); + const clipped = this.samples.filter((value) => value <= this.options.clipLow || value >= this.options.clipHigh).length; + let state = 'GOOD'; + if (this.samples.length < this.options.warmupSize) state = 'WARMING'; + else if (clipped / this.samples.length >= 0.01) state = 'CLIPPED'; + else if (maximum - minimum < this.options.minRange) state = 'WEAK'; + return { state, minimum, maximum, range: maximum - minimum, clipped }; + } +} + +export class PttPairer { + constructor({ minimumMs = 5, maximumMs = 300, historyLength = 20 } = {}) { + this.minimumMs = minimumMs; + this.maximumMs = maximumMs; + this.historyLength = historyLength; + this.reset(); + } + + reset() { + this.proximal = null; + this.values = []; + this.accepted = 0; + this.rejected = 0; + this.unmatched = 0; + } + + setWindow(minimumMs, maximumMs) { + if (!Number.isFinite(minimumMs) || !Number.isFinite(maximumMs) || minimumMs < 0 || maximumMs <= minimumMs) { + throw new Error('PTT window must have a non-negative minimum below the maximum'); + } + this.minimumMs = minimumMs; + this.maximumMs = maximumMs; + } + + pushProximal(timestamp) { + if (this.proximal !== null) this.unmatched += 1; + this.proximal = timestamp; + } + + pushDistal(timestamp, qualityOkay = true) { + if (this.proximal === null) { + this.unmatched += 1; + return null; + } + const delay = timestamp - this.proximal; + if (delay < this.minimumMs) return null; + this.proximal = null; + if (delay > this.maximumMs || !qualityOkay) { + this.rejected += 1; + return null; + } + this.accepted += 1; + this.values.push(delay); + if (this.values.length > this.historyLength) this.values.shift(); + return delay; + } + + expire(timestamp) { + if (this.proximal !== null && timestamp - this.proximal > this.maximumMs) { + this.proximal = null; + this.unmatched += 1; + } + } + + snapshot(latest = null) { + return { + latest, + median: median(this.values), + accepted: this.accepted, + rejected: this.rejected, + unmatched: this.unmatched, + pending: this.proximal !== null, + }; + } +} + +export class DualSignalCoach { + constructor(options = {}) { + const channelDefaults = { pulseThreshold: 550, thresholdMode: 'fixed' }; + this.proximal = new BrowserSignalCoach({ ...channelDefaults, ...options.proximal }); + this.distal = new BrowserSignalCoach({ ...channelDefaults, ...options.distal }); + this.proximalQuality = new SignalQualityWindow(options.quality); + this.distalQuality = new SignalQualityWindow(options.quality); + this.pairer = new PttPairer(options.pairing); + } + + configureChannel(channel, mode, threshold) { + const detector = channel === 'proximal' ? this.proximal : this.distal; + detector.setThresholdMode(mode, threshold); + } + + setPairingWindow(minimumMs, maximumMs) { + this.pairer.setWindow(minimumMs, maximumMs); + } + + update(proximalSignal, distalSignal, timestamp) { + const proximal = this.proximal.update(proximalSignal, timestamp); + const distal = this.distal.update(distalSignal, timestamp); + const proximalQuality = this.proximalQuality.update(proximal.signal10); + const distalQuality = this.distalQuality.update(distal.signal10); + this.pairer.expire(timestamp); + if (proximal.qualifiedBeat) this.pairer.pushProximal(timestamp); + let latest = null; + if (distal.qualifiedBeat) { + latest = this.pairer.pushDistal(timestamp, proximalQuality.state === 'GOOD' && distalQuality.state === 'GOOD'); + } + return { + proximal, + distal, + proximalQuality, + distalQuality, + ptt: this.pairer.snapshot(latest), + }; + } +} diff --git a/docs/signal-coach/ptt-coach-core.test.mjs b/docs/signal-coach/ptt-coach-core.test.mjs new file mode 100644 index 0000000..b450118 --- /dev/null +++ b/docs/signal-coach/ptt-coach-core.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { BrowserSignalCoach } from './signal-coach-core.mjs'; +import { DualSignalCoach, PttPairer } from './ptt-coach-core.mjs'; + +function pulseSample(timestamp, delay = 0) { + const phase = ((timestamp - delay) % 833 + 833) % 833; + if (phase < 50) return 500 + Math.round(phase * 3); + if (phase < 140) return 650 - Math.round((phase - 50) * 1.5); + return 500; +} + +const fixed = new BrowserSignalCoach({ pulseThreshold: 550, thresholdMode: 'fixed' }); +for (let timestamp = 0; timestamp < 5000; timestamp += 2) fixed.update(pulseSample(timestamp), timestamp); +assert.equal(fixed.lastResult.threshold, 550, 'fixed threshold must not drift'); + +const adaptive = new BrowserSignalCoach({ pulseThreshold: 550, thresholdMode: 'adaptive' }); +for (let timestamp = 0; timestamp < 5000; timestamp += 2) adaptive.update(pulseSample(timestamp), timestamp); +assert.notEqual(adaptive.lastResult.threshold, 550, 'adaptive threshold should follow the waveform'); + +const pairer = new PttPairer({ minimumMs: 5, maximumMs: 300 }); +pairer.pushProximal(1000); +assert.equal(pairer.pushDistal(1040), 40); +pairer.pushProximal(2000); +pairer.pushProximal(2833); +assert.equal(pairer.pushDistal(2873), 40, 'a new proximal beat must replace a missed old cycle'); +assert.equal(pairer.snapshot().unmatched, 1); +pairer.pushProximal(4000); +assert.equal(pairer.pushDistal(4400), null, 'out-of-window pair must be rejected'); +assert.equal(pairer.snapshot().rejected, 1); + +const dual = new DualSignalCoach({ + proximal: { pulseThreshold: 550, thresholdMode: 'fixed' }, + distal: { pulseThreshold: 550, thresholdMode: 'fixed' }, + quality: { windowSize: 500, warmupSize: 100 }, +}); +let result; +for (let timestamp = 0; timestamp < 12000; timestamp += 2) { + result = dual.update(pulseSample(timestamp), pulseSample(timestamp, 40), timestamp); +} +assert.ok(result.ptt.accepted >= 5, 'clean dual wave should produce paired beats'); +assert.ok(result.ptt.median >= 36 && result.ptt.median <= 44, `expected about 40 ms, got ${result.ptt.median}`); + +const clipped = new DualSignalCoach({ quality: { windowSize: 100, warmupSize: 20 } }); +for (let timestamp = 0; timestamp < 12000; timestamp += 2) { + result = clipped.update(1023, pulseSample(timestamp, 40), timestamp); +} +assert.equal(result.proximalQuality.state, 'CLIPPED'); +assert.equal(result.ptt.accepted, 0, 'clipped channels must not produce accepted PTT'); + +console.log('Signal Coach dual-channel PTT tests passed'); diff --git a/docs/signal-coach/pulse-webserial-protocol.mjs b/docs/signal-coach/pulse-webserial-protocol.mjs index 7aebb69..17982c7 100644 --- a/docs/signal-coach/pulse-webserial-protocol.mjs +++ b/docs/signal-coach/pulse-webserial-protocol.mjs @@ -63,6 +63,19 @@ export function parsePulseLine(line) { if (json) return json; const fields = normalized.split(',').map((field) => field.trim()); + if (fields[0] === 'PTT1') { + if (fields.length !== 4) throw new Error('invalid PTT1 field count'); + const timestampUs = integer(Number(fields[1]), 'timestamp', 0, 0xffffffff); + const frame = pulseFrame({ + source: 'ptt1', + format: 'PulseSensor dual-channel PTT1', + signal: fields[2], + timestampMs: timestampUs / 1000, + }); + frame.timestampUs = timestampUs; + frame.secondarySignal = integer(Number(fields[3]), 'secondary signal', 0, 65535); + return frame; + } if (fields[0] === PSWS_PREFIX) { const version = integer(Number(fields[1]), 'protocol version', 1, 255); if (!PSWS_VERSIONS.includes(version)) throw new Error(`unsupported PSWS version ${version}`); diff --git a/docs/signal-coach/signal-coach-core.mjs b/docs/signal-coach/signal-coach-core.mjs index f4b4601..d8fd148 100644 --- a/docs/signal-coach/signal-coach-core.mjs +++ b/docs/signal-coach/signal-coach-core.mjs @@ -6,6 +6,7 @@ const DEFAULTS = Object.freeze({ pulseThreshold: 550, + thresholdMode: 'adaptive', refractoryMs: 250, noBeatTimeoutMs: 3000, staleMs: 1500, @@ -54,9 +55,24 @@ function nowMs() { export class BrowserSignalCoach { constructor(options = {}) { this.options = { ...DEFAULTS, ...options }; + if (!['adaptive', 'fixed'].includes(this.options.thresholdMode)) { + throw new Error('threshold mode must be adaptive or fixed'); + } this.reset(); } + setThresholdMode(mode, threshold = this.options.pulseThreshold) { + if (!['adaptive', 'fixed'].includes(mode)) throw new Error('threshold mode must be adaptive or fixed'); + if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1023) throw new Error('threshold must be 0..1023'); + this.options.thresholdMode = mode; + this.options.pulseThreshold = Math.round(threshold); + this.threshold = this.options.pulseThreshold; + this.peak = this.threshold; + this.trough = this.threshold; + this.pulsing = false; + return this.lastResult; + } + reset(timestamp = null) { const start = timestamp ?? 0; const initial = this.options.pulseThreshold; @@ -123,7 +139,9 @@ export class BrowserSignalCoach { } rearm(timestamp) { - const midpoint = this.maximum > this.minimum + const midpoint = this.options.thresholdMode === 'fixed' + ? this.options.pulseThreshold + : this.maximum > this.minimum ? Math.floor((this.minimum + this.maximum) / 2) : this.options.pulseThreshold; this.threshold = midpoint; @@ -140,7 +158,9 @@ export class BrowserSignalCoach { } resync(timestamp = nowMs()) { - const midpoint = this.maximum > this.minimum + const midpoint = this.options.thresholdMode === 'fixed' + ? this.options.pulseThreshold + : this.maximum > this.minimum ? Math.floor((this.minimum + this.maximum) / 2) : this.options.pulseThreshold; this.threshold = midpoint; @@ -213,7 +233,9 @@ export class BrowserSignalCoach { if (this.signal < this.threshold && this.pulsing) { this.pulsing = false; this.amplitude = this.peak - this.trough; - this.threshold = this.trough + Math.floor(this.amplitude / 2); + this.threshold = this.options.thresholdMode === 'adaptive' + ? this.trough + Math.floor(this.amplitude / 2) + : this.options.pulseThreshold; this.peak = this.threshold; this.trough = this.threshold; } @@ -262,6 +284,7 @@ export class BrowserSignalCoach { qualifiedBeat, amplitude: Math.round(this.amplitude), threshold: Math.round(this.threshold), + thresholdMode: this.options.thresholdMode, range: Math.round(this.maximum - this.minimum), minimum: Math.round(this.minimum), maximum: Math.round(this.maximum), diff --git a/docs/signal-coach/signal-coach.css b/docs/signal-coach/signal-coach.css index 281bd9c..ab4d2c1 100644 --- a/docs/signal-coach/signal-coach.css +++ b/docs/signal-coach/signal-coach.css @@ -54,9 +54,9 @@ h1 { margin: 0; font-size: clamp(30px, 4vw, 46px); line-height: .95; letter-spac .subhead { max-width: 570px; margin: 8px 0 0; color: var(--muted); font-size: clamp(13px, 1.3vw, 15px); line-height: 1.35; } .connection { - min-width: 560px; + min-width: 690px; display: grid; - grid-template-columns: 10px minmax(150px, 1fr) auto auto auto; + grid-template-columns: 10px minmax(150px, 1fr) auto auto auto auto; align-items: center; gap: 11px; border: 1px solid var(--grid); @@ -88,6 +88,7 @@ h1 { margin: 0; font-size: clamp(30px, 4vw, 46px); line-height: .95; letter-spac button:disabled { cursor: not-allowed; opacity: .38; } .rate-control { display: grid; gap: 2px; color: #7d8b83; font: 650 9px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; text-transform: uppercase; } .rate-control select { min-width: 62px; border: 1px solid #9fb6a8; border-radius: 0; background: #fff; color: var(--white); padding: 5px 7px; font: 700 12px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; } +#coachMode { min-width: 145px; } .resync-help { margin: 0; padding: 9px 10px; border-left: 3px solid var(--blue); background: #eff9fa; color: var(--muted); font-size: 11px; line-height: 1.35; } .resync-help strong, .resync-help b { color: var(--blue); } @@ -117,6 +118,7 @@ button:disabled { cursor: not-allowed; opacity: .38; } .screen-source { color: var(--muted); font-size: .58em; letter-spacing: .09em; } .wave-coach-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(245px, 310px); border: 2px solid var(--grid); border-top: 0; } +.wave-coach-layout[hidden], .readout-grid[hidden] { display: none; } .wave-wrap { position: relative; border: 2px solid var(--grid); border-top: 0; background: var(--screen); } .wave-coach-layout .wave-wrap { border: 0; border-right: 1px solid var(--grid); } #waveformCanvas { display: block; width: 100%; height: clamp(270px, 30vw, 360px); } @@ -165,6 +167,31 @@ button:disabled { cursor: not-allowed; opacity: .38; } .bpm-card.invert { background: var(--green); } .bpm-card.invert .card-label, .bpm-card.invert strong, .bpm-card.invert .card-note { color: var(--screen); } +.ptt-workspace { display: grid; gap: 14px; margin-top: 12px; } +.ptt-workspace[hidden] { display: none; } +.ptt-heading { display: flex; justify-content: space-between; gap: 20px; align-items: start; border: 1px solid var(--grid); background: var(--panel); padding: 15px; } +.ptt-heading h2 { margin: 5px 0; font-size: clamp(24px, 3vw, 36px); letter-spacing: -.03em; } +.ptt-heading p { margin: 0; max-width: 720px; color: var(--muted); font-size: 13px; } +.experimental-badge { border: 1px solid var(--yellow); color: var(--yellow); padding: 7px 9px; white-space: nowrap; font: 750 10px/1.2 ui-monospace, "SFMono-Regular", Menlo, monospace; } +.ptt-controls { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; } +.ptt-controls fieldset { min-width: 0; margin: 0; border: 1px solid var(--grid); padding: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 9px; } +.ptt-controls legend { padding: 0 5px; color: var(--blue); font: 800 11px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: .08em; } +.ptt-controls label { display: grid; gap: 4px; color: var(--muted); font-size: 10px; text-transform: uppercase; } +.ptt-controls select, .ptt-controls input { width: 100%; min-width: 0; border: 1px solid #9fb6a8; background: #fff; color: var(--white); padding: 7px; font: 700 12px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; } +.dual-wave-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.channel-panel { min-width: 0; border: 1px solid var(--grid); background: var(--panel); } +.channel-header { display: flex; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--grid); color: var(--blue); font: 800 11px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; } +.channel-header span { color: var(--yellow); } +.channel-header span.good { color: var(--green); } +.channel-header span.clipped { color: #a12626; } +.channel-panel canvas { display: block; width: 100%; height: 210px; background: #fff; } +.channel-panel p { margin: 0; padding: 8px 12px; color: var(--muted); font: 650 10px/1.3 ui-monospace, "SFMono-Regular", Menlo, monospace; } +.ptt-readouts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } +.ptt-readouts article { border: 1px solid var(--grid); background: var(--panel); padding: 13px; display: grid; gap: 5px; } +.ptt-readouts span, .ptt-readouts small { color: var(--muted); font-size: 10px; } +.ptt-readouts strong { color: var(--blue); font: 850 clamp(28px, 4vw, 48px)/1 ui-monospace, "SFMono-Regular", Menlo, monospace; } +.ptt-guidance { margin: 0; padding: 12px; border-left: 4px solid var(--blue); background: #eff9fa; color: var(--muted); font-size: 13px; } + .connection-summary { display: flex; justify-content: space-between; gap: 16px; color: #6f7e75; font: 650 10px/1.4 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: .06em; text-transform: uppercase; } #modeChip { color: var(--blue); } #modeChip.live { color: var(--green); } @@ -200,6 +227,7 @@ footer p { margin: 0; } @media (max-width: 1000px) { .topbar, footer { flex-direction: column; align-items: stretch; } .connection { min-width: 0; } + .dual-wave-grid { grid-template-columns: 1fr; } } @media (max-width: 760px) { @@ -212,9 +240,13 @@ footer p { margin: 0; } .connection { grid-template-columns: 9px minmax(0, 1fr) auto; } .connection-copy { grid-column: 2/-1; } .rate-control { grid-column: 2; } + .rate-control:has(#coachMode) { grid-column: 2/-1; } #resyncBtn { grid-column: 3; } #connectBtn { grid-column: 1/-1; width: 100%; } #waveformCanvas { height: 220px; } + .ptt-heading { flex-direction: column; } + .ptt-controls { grid-template-columns: 1fr; } + .ptt-readouts { grid-template-columns: 1fr 1fr; } } @media (max-width: 430px) { diff --git a/docs/signal-coach/signal-coach.mjs b/docs/signal-coach/signal-coach.mjs index 581fd35..edd783e 100644 --- a/docs/signal-coach/signal-coach.mjs +++ b/docs/signal-coach/signal-coach.mjs @@ -1,6 +1,7 @@ // Browser transport, browser-native coaching, and rendering for Signal Coach. -import { parsePulseLine } from './pulse-webserial-protocol.mjs?v=20260812-action-first-r5'; -import { BrowserSignalCoach, adviceFor, stateColor } from './signal-coach-core.mjs?v=20260812-action-first-r5'; +import { parsePulseLine } from './pulse-webserial-protocol.mjs?v=20260816-dual-ptt-r1'; +import { BrowserSignalCoach, adviceFor, stateColor } from './signal-coach-core.mjs?v=20260816-dual-ptt-r1'; +import { DualSignalCoach } from './ptt-coach-core.mjs?v=20260816-dual-ptt-r1'; const pageOptions = new URLSearchParams(location.search); if (window.self !== window.top || pageOptions.get('embedded') === '1') { @@ -13,6 +14,7 @@ const context = canvas.getContext('2d'); const display = document.querySelector('.display'); const connectBtn = document.querySelector('#connectBtn'); const resyncBtn = document.querySelector('#resyncBtn'); +const coachMode = document.querySelector('#coachMode'); const sampleRate = document.querySelector('#sampleRate'); const statusDot = document.querySelector('#statusDot'); const statusText = document.querySelector('#statusText'); @@ -39,6 +41,26 @@ const liveQuality = document.querySelector('#liveQuality'); const liveInputFormat = document.querySelector('#liveInputFormat'); const sourceMetrics = document.querySelector('#sourceMetrics'); const rawSerial = document.querySelector('#rawSerial'); +const singleWavePanel = document.querySelector('#singleWavePanel'); +const singleReadoutPanel = document.querySelector('#singleReadoutPanel'); +const pttWorkspace = document.querySelector('#pttWorkspace'); +const proximalCanvas = document.querySelector('#proximalCanvas'); +const distalCanvas = document.querySelector('#distalCanvas'); +const proximalThresholdMode = document.querySelector('#proximalThresholdMode'); +const distalThresholdMode = document.querySelector('#distalThresholdMode'); +const proximalThreshold = document.querySelector('#proximalThreshold'); +const distalThreshold = document.querySelector('#distalThreshold'); +const minimumPtt = document.querySelector('#minimumPtt'); +const maximumPtt = document.querySelector('#maximumPtt'); +const proximalQuality = document.querySelector('#proximalQuality'); +const distalQuality = document.querySelector('#distalQuality'); +const proximalMeta = document.querySelector('#proximalMeta'); +const distalMeta = document.querySelector('#distalMeta'); +const latestPtt = document.querySelector('#latestPtt'); +const medianPtt = document.querySelector('#medianPtt'); +const acceptedPairs = document.querySelector('#acceptedPairs'); +const rejectedPairs = document.querySelector('#rejectedPairs'); +const pttGuidance = document.querySelector('#pttGuidance'); let port = null; let reader = null; @@ -53,6 +75,11 @@ let streamTimestamp = null; let lastDeviceTimestamp = null; let resyncUiTimer = null; const browserCoach = new BrowserSignalCoach(); +let dualCoach = new DualSignalCoach(); +let proximalHistory = new Array(HISTORY_LENGTH).fill(null); +let distalHistory = new Array(HISTORY_LENGTH).fill(null); +let lastPtt = null; +let lastPttRenderAt = -Infinity; function resizeCanvas() { const rectangle = canvas.getBoundingClientRect(); @@ -61,6 +88,54 @@ function resizeCanvas() { canvas.height = Math.max(1, Math.round(rectangle.height * dpr)); context.setTransform(dpr, 0, 0, dpr, 0, 0); drawWaveform(); + resizePttCanvas(proximalCanvas, proximalHistory, lastPtt?.proximal?.threshold ?? 550, '#087e91'); + resizePttCanvas(distalCanvas, distalHistory, lastPtt?.distal?.threshold ?? 550, '#997800'); +} + +function resizePttCanvas(target, values, threshold, color) { + const rectangle = target.getBoundingClientRect(); + if (!rectangle.width || !rectangle.height) return; + const dpr = window.devicePixelRatio || 1; + target.width = Math.max(1, Math.round(rectangle.width * dpr)); + target.height = Math.max(1, Math.round(rectangle.height * dpr)); + const drawing = target.getContext('2d'); + drawing.setTransform(dpr, 0, 0, dpr, 0, 0); + drawPttWaveform(target, drawing, values, threshold, color); +} + +function drawPttWaveform(target, drawing, values, threshold, color) { + const width = target.getBoundingClientRect().width; + const height = target.getBoundingClientRect().height; + drawing.fillStyle = '#fff'; + drawing.fillRect(0, 0, width, height); + drawing.strokeStyle = '#dce7df'; + drawing.lineWidth = 1; + for (let index = 1; index < 4; index += 1) { + drawing.beginPath(); drawing.moveTo(0, (height * index) / 4); drawing.lineTo(width, (height * index) / 4); drawing.stroke(); + } + const present = values.filter((value) => value !== null); + if (present.length < 2) return; + const low = Math.max(0, Math.min(...present, threshold) - 35); + const high = Math.min(1023, Math.max(...present, threshold) + 35); + const range = Math.max(1, high - low); + const thresholdY = height - ((threshold - low) / range) * height; + drawing.save(); + drawing.setLineDash([6, 5]); + drawing.strokeStyle = '#a12626'; + drawing.beginPath(); drawing.moveTo(0, thresholdY); drawing.lineTo(width, thresholdY); drawing.stroke(); + drawing.restore(); + drawing.strokeStyle = color; + drawing.lineWidth = 2.5; + drawing.lineJoin = 'round'; + drawing.beginPath(); + let started = false; + values.forEach((value, index) => { + if (value === null) return; + const x = (index / (HISTORY_LENGTH - 1)) * width; + const y = height - ((value - low) / range) * height; + if (!started) { drawing.moveTo(x, y); started = true; } else drawing.lineTo(x, y); + }); + drawing.stroke(); } function drawWaveform() { @@ -143,6 +218,10 @@ function updateCoach(coach) { } function updateFrame(frame, line, explicitTimestamp = null) { + if (frame.secondarySignal !== undefined) { + updatePttFrame(frame, line, explicitTimestamp); + return; + } const timestamp = nextSampleTimestamp(frame, explicitTimestamp); frameCount += 1; if (frame.sequence !== null && lastSequence !== null && frame.sequence !== ((lastSequence + 1) & 0x7fffffff)) gaps += 1; @@ -182,6 +261,98 @@ function updateFrame(frame, line, explicitTimestamp = null) { } } +function qualityLabel(target, quality) { + target.textContent = quality.state; + target.className = quality.state === 'GOOD' ? 'good' : quality.state === 'CLIPPED' ? 'clipped' : ''; +} + +function updatePttFrame(frame, line, explicitTimestamp = null) { + if (coachMode.value !== 'ptt') { + coachMode.value = 'ptt'; + setCoachMode('ptt'); + } + const timestamp = nextSampleTimestamp(frame, explicitTimestamp); + frameCount += 1; + lastFrame = frame; + lastPtt = dualCoach.update(frame.signal, frame.secondarySignal, timestamp); + proximalHistory.shift(); proximalHistory.push(lastPtt.proximal.signal10); + distalHistory.shift(); distalHistory.push(lastPtt.distal.signal10); + if (timestamp - lastPttRenderAt < 33 && lastPtt.ptt.latest === null) return; + lastPttRenderAt = timestamp; + resizePttCanvas(proximalCanvas, proximalHistory, lastPtt.proximal.threshold, '#087e91'); + resizePttCanvas(distalCanvas, distalHistory, lastPtt.distal.threshold, '#997800'); + qualityLabel(proximalQuality, lastPtt.proximalQuality); + qualityLabel(distalQuality, lastPtt.distalQuality); + proximalMeta.textContent = `Signal ${lastPtt.proximal.signal10} · threshold ${lastPtt.proximal.threshold} ${lastPtt.proximal.thresholdMode}`; + distalMeta.textContent = `Signal ${lastPtt.distal.signal10} · threshold ${lastPtt.distal.threshold} ${lastPtt.distal.thresholdMode}`; + if (lastPtt.ptt.latest !== null) latestPtt.textContent = lastPtt.ptt.latest.toFixed(1); + medianPtt.textContent = lastPtt.ptt.median === null ? '--' : lastPtt.ptt.median.toFixed(1); + acceptedPairs.textContent = String(lastPtt.ptt.accepted); + rejectedPairs.textContent = `${lastPtt.ptt.rejected} / ${lastPtt.ptt.unmatched}`; + if (lastPtt.proximalQuality.state === 'CLIPPED' || lastPtt.distalQuality.state === 'CLIPPED') { + pttGuidance.textContent = 'A channel is clipping at the ADC rail. Reduce pressure, check power, and reposition before trusting PTT.'; + } else if (lastPtt.proximalQuality.state !== 'GOOD' || lastPtt.distalQuality.state !== 'GOOD') { + pttGuidance.textContent = 'Hold both sensors lightly and still until both channels report GOOD.'; + } else if (lastPtt.ptt.accepted === 0) { + pttGuidance.textContent = 'Both waves look usable. Waiting for repeatable same-cycle beat pairs.'; + } else { + pttGuidance.textContent = 'Both channels pass signal quality. PTT is an educational timing measurement, not blood pressure.'; + } + const simulated = frame.mode === 'SIM'; + waitingOverlay.hidden = true; + resyncBtn.disabled = true; + statusDot.className = `status-dot ${simulated ? 'simulation' : 'connected'}`; + statusText.textContent = simulated ? 'Simulated two-sensor replay' : 'Two PulseSensors connected'; + sourceMeta.textContent = `${frame.format} · 250000 baud`; + sourceName.textContent = simulated ? 'SIM DUAL PULSE' : 'Dual PulseSensor'; + simulationWarning.hidden = !simulated; + modeChip.textContent = simulated ? 'SIM PTT' : 'LIVE PTT'; + modeChip.className = simulated ? 'sim' : 'live'; + frameMeta.textContent = `${frameCount.toLocaleString()} synchronized dual samples`; + signalMeta.textContent = `A0 ${lastPtt.proximal.signal10} · A1 ${lastPtt.distal.signal10}`; + liveSignal.textContent = `${lastPtt.proximal.signal10} / ${lastPtt.distal.signal10}`; + liveBeat.textContent = `${Number(lastPtt.proximal.qualifiedBeat)} / ${Number(lastPtt.distal.qualifiedBeat)}`; + liveIbi.textContent = lastPtt.ptt.latest === null ? '--' : lastPtt.ptt.latest.toFixed(1); + liveQuality.textContent = `${lastPtt.proximalQuality.state} / ${lastPtt.distalQuality.state}`; + liveInputFormat.textContent = frame.format; + sourceMetrics.textContent = 'Browser detector · one-to-one pairing'; + rawSerial.textContent = line; +} + +function resetPttState() { + dualCoach = new DualSignalCoach(); + proximalHistory = new Array(HISTORY_LENGTH).fill(null); + distalHistory = new Array(HISTORY_LENGTH).fill(null); + lastPtt = null; + lastPttRenderAt = -Infinity; + latestPtt.textContent = '--'; + medianPtt.textContent = '--'; + acceptedPairs.textContent = '0'; + rejectedPairs.textContent = '0 / 0'; + applyPttSettings(); +} + +function applyPttSettings() { + dualCoach.configureChannel('proximal', proximalThresholdMode.value, Number(proximalThreshold.value)); + dualCoach.configureChannel('distal', distalThresholdMode.value, Number(distalThreshold.value)); + dualCoach.setPairingWindow(Number(minimumPtt.value), Number(maximumPtt.value)); +} + +function setCoachMode(mode) { + const ptt = mode === 'ptt'; + singleWavePanel.hidden = ptt; + singleReadoutPanel.hidden = ptt; + pttWorkspace.hidden = !ptt; + sampleRate.value = ptt ? '500' : '50'; + sourceMeta.textContent = ptt ? 'Dual PTT1 input · 250000 baud' : 'Single pulse-wave input · 115200 baud'; + resyncBtn.hidden = ptt; + waitingOverlay.textContent = ptt ? 'CONNECT A PTT1 DUAL STREAM' : 'CONNECT A PULSE STREAM'; + if (ptt) { + resetPttState(); + requestAnimationFrame(resizeCanvas); + } +} + function showError(message) { errorMessage.textContent = message; errorMessage.hidden = false; @@ -272,7 +443,8 @@ async function disconnect() { connectBtn.textContent = 'Connect'; statusDot.className = 'status-dot'; statusText.textContent = 'Disconnected'; - sourceMeta.textContent = 'Pulse-wave input · 115200 baud'; + coachMode.disabled = false; + sourceMeta.textContent = coachMode.value === 'ptt' ? 'Dual PTT1 input · 250000 baud' : 'Single pulse-wave input · 115200 baud'; resetDisconnectedDisplay(); } @@ -283,7 +455,8 @@ async function connect() { } try { port = await navigator.serial.requestPort(); - await port.open({ baudRate: 115200, bufferSize: 8192 }); + const baudRate = coachMode.value === 'ptt' ? 250000 : 115200; + await port.open({ baudRate, bufferSize: 16384 }); history = new Array(HISTORY_LENGTH).fill(null); frameCount = 0; gaps = 0; @@ -292,9 +465,10 @@ async function connect() { reading = true; errorMessage.hidden = true; sampleRate.disabled = true; + coachMode.disabled = true; connectBtn.textContent = 'Disconnect'; statusText.textContent = 'Waiting for samples'; - sourceMeta.textContent = 'Connected · USB serial · 115200 baud'; + sourceMeta.textContent = `Connected · USB serial · ${baudRate} baud`; waitingOverlay.textContent = 'WAITING FOR PULSE DATA'; await readLoop(); } catch (error) { @@ -305,6 +479,7 @@ async function connect() { try { await failedPort.close(); } catch { /* Port never opened or already closed. */ } } sampleRate.disabled = false; + coachMode.disabled = false; connectBtn.textContent = 'Connect'; if (error.name !== 'NotFoundError') showError(`Connection failed: ${error.message}`); } @@ -315,6 +490,13 @@ connectBtn.addEventListener('click', () => { else connect().catch((error) => showError(error.message)); }); +coachMode.addEventListener('change', () => setCoachMode(coachMode.value)); +[proximalThresholdMode, distalThresholdMode, proximalThreshold, distalThreshold, minimumPtt, maximumPtt].forEach((control) => { + control.addEventListener('change', () => { + try { applyPttSettings(); errorMessage.hidden = true; } catch (error) { showError(error.message); } + }); +}); + resyncBtn.addEventListener('click', () => { if (streamTimestamp === null) return; lastCoach = browserCoach.resync(streamTimestamp); @@ -336,11 +518,16 @@ navigator.serial?.addEventListener('disconnect', (event) => { window.addEventListener('resize', resizeCanvas); new ResizeObserver(resizeCanvas).observe(canvas); +new ResizeObserver(resizeCanvas).observe(pttWorkspace); resizeCanvas(); function startReplay() { connectBtn.hidden = true; - sampleRate.value = '50'; + const pttReplay = pageOptions.get('mode') === 'ptt'; + coachMode.value = pttReplay ? 'ptt' : 'single'; + setCoachMode(coachMode.value); + coachMode.disabled = true; + sampleRate.value = pttReplay ? '500' : '50'; sampleRate.disabled = true; resetCoachState(); let sequence = 0; @@ -360,6 +547,15 @@ function startReplay() { timestampMs: elapsed, signal, sourceBpm: null, sourceIbi: null, sourceBeat: null, sourceQuality: null, sourceState: null, }; + if (pttReplay) { + const distalPhase = ((elapsed - 40) % 833 + 833) % 833; + let distalPulse = 0; + if (distalPhase < 55) distalPulse = distalPhase * 21; + else if (distalPhase < 145) distalPulse = 1155 - (distalPhase - 55) * 10; + else if (distalPhase < 205) distalPulse = 255 - (distalPhase - 145) * 4; + frame.secondarySignal = Math.max(0, Math.min(4095, 1880 + Math.max(0, distalPulse))); + frame.format = 'Browser bench PTT1'; + } updateFrame(frame, String(signal)); sequence = (sequence + 1) & 0x7fffffff; }, 20); @@ -383,5 +579,7 @@ window.__PULSE_COACH_QA__ = { streamTimestamp = null; lastDeviceTimestamp = null; }, - getState: () => ({ frameCount, gaps, lastFrame, coach: lastCoach, canvasWidth: canvas.width, canvasHeight: canvas.height }), + getState: () => ({ frameCount, gaps, lastFrame, coach: lastCoach, ptt: lastPtt, mode: coachMode.value, canvasWidth: canvas.width, canvasHeight: canvas.height }), }; + +setCoachMode(coachMode.value); diff --git a/docs/signal-coach/source-pack.test.mjs b/docs/signal-coach/source-pack.test.mjs index 402cddd..65d3132 100644 --- a/docs/signal-coach/source-pack.test.mjs +++ b/docs/signal-coach/source-pack.test.mjs @@ -11,10 +11,13 @@ const required = [ 'docs/signal-coach/signal-coach.css', 'docs/signal-coach/signal-coach.mjs', 'docs/signal-coach/signal-coach-core.mjs', + 'docs/signal-coach/ptt-coach-core.mjs', 'docs/signal-coach/pulse-webserial-protocol.mjs', 'docs/signal-coach/protocol.test.mjs', 'docs/signal-coach/signal-coach-core.test.mjs', + 'docs/signal-coach/ptt-coach-core.test.mjs', 'examples/SignalCoachWebSerial/SignalCoachWebSerial.ino', + 'examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino', 'resources/signal-coach/shopify/page-signal-coach.html', 'LICENSE', ]; @@ -46,19 +49,25 @@ assert.match(tutorial, /another finger or person/); assert.doesNotMatch(tutorial, /
/); assert.doesNotMatch(tutorial, /Signal Coach tester preview/); assert.doesNotMatch(tutorial, /See the wave\. Improve it\./); -assert.match(tutorial, /1 Upload the sketch/); -assert.match(tutorial, /3 Click Connect/); +assert.match(tutorial, /1 Upload the one- or two-sensor sketch/); +assert.match(tutorial, /3 Choose the mode and Connect/); assert.match(tutorial, /Loading Signal Coach/); assert.match(tutorial, /Taking longer than expected/); assert.match(tutorial, /Open Signal Coach/); assert.match(tutorial, /Coach not loading\? Open directly\./); assert.match(tutorial, /pulsesensor-signal-coach-ready/); assert.match(dashboard, /pulsesensor-signal-coach-ready/); +assert.match(dashboard, /Two sensors \/ PTT/); +assert.match(dashboard, /id="proximalThresholdMode"/); +assert.match(dashboard, /id="distalThresholdMode"/); +assert.match(tutorial, /SignalCoachDualWebSerial\.ino/); +assert.match(tutorial, /timing experiment, not a blood-pressure reading/i); const releaseVersion = dashboard.match(/signal-coach\.mjs\?v=([a-z0-9-]+)/i)?.[1]; assert.ok(releaseVersion, 'dashboard should version its browser module'); assert.ok(dashboard.includes(`signal-coach.css?v=${releaseVersion}`)); assert.ok(browserApp.includes(`pulse-webserial-protocol.mjs?v=${releaseVersion}`)); assert.ok(browserApp.includes(`signal-coach-core.mjs?v=${releaseVersion}`)); +assert.ok(browserApp.includes(`ptt-coach-core.mjs?v=${releaseVersion}`)); console.log('Signal Coach public source-pack tests passed'); diff --git a/examples/PulseSensor_Pulse_Transit_Time/PulseSensor_Pulse_Transit_Time.ino b/examples/PulseSensor_Pulse_Transit_Time/PulseSensor_Pulse_Transit_Time.ino index 7900caa..d0d74e5 100644 --- a/examples/PulseSensor_Pulse_Transit_Time/PulseSensor_Pulse_Transit_Time.ino +++ b/examples/PulseSensor_Pulse_Transit_Time/PulseSensor_Pulse_Transit_Time.ino @@ -96,8 +96,10 @@ PulseSensorPlayground pulseSensor(PULSE_SENSOR_COUNT); Variables used to determine PTT. NOTE: This code assumes the Pulse Sensor on analog pin 0 is closer to he heart. */ -unsigned long lastBeatSampleNumber[PULSE_SENSOR_COUNT]; -int PTT; +unsigned long proximalBeatTime = 0; +bool proximalBeatPending = false; +const unsigned long MIN_PTT_MS = 5; +const unsigned long MAX_PTT_MS = 300; void setup() { /* @@ -122,11 +124,13 @@ void setup() { pulseSensor.blinkOnPulse(PULSE_BLINK0, 0); pulseSensor.fadeOnPulse(PULSE_FADE0, 0); pulseSensor.setThreshold(THRESHOLD0, 0); + pulseSensor.setAdaptiveThreshold(false, 0); pulseSensor.analogInput(PULSE_INPUT1, 1); pulseSensor.blinkOnPulse(PULSE_BLINK1, 1); pulseSensor.fadeOnPulse(PULSE_FADE1, 1); pulseSensor.setThreshold(THRESHOLD1, 1); + pulseSensor.setAdaptiveThreshold(false, 1); pulseSensor.setSerial(Serial); pulseSensor.setOutputType(OUTPUT_TYPE); @@ -205,10 +209,19 @@ void loop() { if (pulseSensor.sawStartOfBeat(i)) { pulseSensor.outputBeat(i); - lastBeatSampleNumber[i] = pulseSensor.getLastBeatTime(i); - if (i == 1) { - PTT = lastBeatSampleNumber[1] - lastBeatSampleNumber[0]; - pulseSensor.outputToSerial('|', PTT); + const unsigned long beatTime = pulseSensor.getLastBeatTime(i); + if (i == 0) { + // A new proximal beat replaces any unpaired older beat. This prevents + // a missed distal beat from contaminating the next cardiac cycle. + proximalBeatTime = beatTime; + proximalBeatPending = true; + } else if (proximalBeatPending) { + const unsigned long ptt = beatTime - proximalBeatTime; + if (beatTime > proximalBeatTime && ptt >= MIN_PTT_MS && ptt <= MAX_PTT_MS) { + pulseSensor.outputToSerial('|', (int)ptt); + } + // One distal beat can consume at most one proximal beat. + proximalBeatPending = false; } } } diff --git a/examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino b/examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino new file mode 100644 index 0000000..2039485 --- /dev/null +++ b/examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino @@ -0,0 +1,47 @@ +/* + PulseSensor Signal Coach — dual-channel PTT sender + + Sends two raw PulseSensor readings plus the board sample timestamp. Signal + Coach performs threshold crossing, signal-quality checks, beat pairing, and + Pulse Transit Time (PTT) measurement in the browser. + + Wiring on Arduino Uno/Nano/Mega: + Proximal PulseSensor purple -> A0 (earlobe recommended) + Distal PulseSensor purple -> A1 (fingertip recommended) + Both red wires -> 5V + Both black wires -> GND + + Open Signal Coach, choose "Two sensors / PTT", then connect at 250000 baud. + Educational experiment only. Not a medical device or blood-pressure tool. + + World Famous Electronics LLC + MIT License — see ../../LICENSE +*/ + +const int PROXIMAL_PIN = A0; +const int DISTAL_PIN = A1; +const unsigned long SAMPLE_PERIOD_US = 2000; // 500 samples/second. + +unsigned long nextSampleAt = 0; + +void setup() { + Serial.begin(250000); + delay(1000); + nextSampleAt = micros(); +} + +void loop() { + const unsigned long now = micros(); + if ((long)(now - nextSampleAt) < 0) return; + nextSampleAt += SAMPLE_PERIOD_US; + + const int proximal = analogRead(PROXIMAL_PIN); + const int distal = analogRead(DISTAL_PIN); + + Serial.print("PTT1,"); + Serial.print(now); + Serial.print(','); + Serial.print(proximal); + Serial.print(','); + Serial.println(distal); +} diff --git a/resources/CodeDescriptions.md b/resources/CodeDescriptions.md index 71eee8c..37032c5 100644 --- a/resources/CodeDescriptions.md +++ b/resources/CodeDescriptions.md @@ -7,6 +7,7 @@ This index lists the example sketches that ship with the current library. Start | [GettingStartedProject](../examples/GettingStartedProject/GettingStartedProject.ino) | Raw pulse signal in the Serial Plotter and heartbeat LED | First setup and signal troubleshooting | | [Getting_BPM_to_Monitor](../examples/Getting_BPM_to_Monitor/Getting_BPM_to_Monitor.ino) | BPM and beat messages in the Serial Monitor | A short beginner BPM sketch | | [SignalCoachWebSerial](../examples/SignalCoachWebSerial/SignalCoachWebSerial.ino) | Raw samples over Web Serial for Signal Coach | Browser-based signal coaching without library-side beat detection | +| [SignalCoachDualWebSerial](../examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino) | Synchronized A0/A1 samples for Signal Coach | Two-sensor signal comparison and educational PTT experiments | | [PulseSensor_BPM](../examples/PulseSensor_BPM/PulseSensor_BPM.ino) | Full library BPM setup and serial output | Standard BPM projects | | [PulseSensor_ATtiny85_Serial](../examples/PulseSensor_ATtiny85_Serial/PulseSensor_ATtiny85_Serial.ino) | Beat detection with serial output on ATtiny85 | ATtiny85 projects with serial output | | [PulseSensor_ATtiny85_noSerial](../examples/PulseSensor_ATtiny85_noSerial/PulseSensor_ATtiny85_noSerial.ino) | Beat detection without serial output on ATtiny85 | Small standalone ATtiny85 projects | diff --git a/resources/PulseSensor Playground Tools.md b/resources/PulseSensor Playground Tools.md index e1b246a..fa1cb3a 100644 --- a/resources/PulseSensor Playground Tools.md +++ b/resources/PulseSensor Playground Tools.md @@ -57,7 +57,15 @@ By default, Playground will output Serial data in `SERIAL_PLOTTER` format. Chang --- ### setThreshold(int) -Set a value that the PulseSensor signal has to cross when going up. Adjusting this can be useful to combat noise. We set the default value at 550 +Set the starting value that the PulseSensor signal must cross while rising. The default is 550. By default the detector adapts this threshold after each pulse; call `setAdaptiveThreshold(false)` to keep your chosen value fixed. + +--- +### setAdaptiveThreshold(bool) +Choose how the beat detector uses the threshold. `true` (the default) moves the threshold to the midpoint of each detected pulse. `false` keeps the value supplied to `setThreshold()` fixed. Fixed mode is useful when comparing two channels or reproducing a detection setup. + +--- +### getCurrentThreshold() +Returns the threshold currently used by the beat detector. In adaptive mode this can differ from the value passed to `setThreshold()`. Type = int. --- ### getLatestSample() @@ -85,7 +93,7 @@ Returns `true` if a new heartbeat pulse has been detected. Type = bool. --- ### isInsideBeat() -Returns `true` for the time when a measured heartbeat wave is above the value set by the setThreshold() function, and `false` when it's not. +Returns `true` while a measured pulse wave is above the detector's current threshold, and `false` when it is not. --- ### outputSample() diff --git a/resources/signal-coach/shopify/page-signal-coach.html b/resources/signal-coach/shopify/page-signal-coach.html index c6b2770..c138882 100644 --- a/resources/signal-coach/shopify/page-signal-coach.html +++ b/resources/signal-coach/shopify/page-signal-coach.html @@ -16,8 +16,8 @@
-

1 Upload the sketch2 Close Serial Monitor3 Click Connect4 Place the sensor gently

-

Already sending pulse data? Start at Connect. Desktop Chrome, Edge, or Brave. Coach not loading? Open directly.

+

1 Upload the one- or two-sensor sketch2 Close Serial Monitor3 Choose the mode and Connect4 Place the sensor gently

+

Already sending pulse data? Start at Connect. Desktop Chrome, Edge, or Brave. Coach not loading? Open directly.

@@ -32,12 +32,12 @@

Loading Signal Coach

Taking longer than expected

Open Signal Coach directly and keep going. Your Arduino setup stays the same.

- +
@@ -80,9 +80,9 @@

Taking longer than expected

-

BETA

+

BETA

-

What the coach does: It reads your live pulse wave and tells you when to adjust the sensor, hold still, or trust the BPM.

+

What the coach does: It reads one live pulse wave for BPM, or compares two synchronized sensors for an educational Pulse Transit Time experiment.

Pulse samples stay in this browser tab. This beta runs alongside the current WebSerial page. Wave visible but not locking? Use Re-sync Signal.

@@ -133,6 +133,7 @@

Hack Signal Coach at home

Read the home-build guide View all Chrome coach source Open the Arduino sketch + Open the two-sensor sketch Download everything
python3 -m http.server 8000
@@ -142,6 +143,10 @@ 

Hack Signal Coach at home

Arduino / ESP32 setup

No Arduino library is required for the simplest stream. Choose the signal pin for your board, upload this sketch, select 50 samples/s above, and let Chrome do the beat detection and coaching. The same sketch is saved in the repo as SignalCoachWebSerial.ino.

+

Two sensors / Pulse Transit Time

+

For a two-sensor experiment, connect the proximal signal to A0 and the distal signal to A1, upload SignalCoachDualWebSerial.ino, and choose Two sensors / PTT in Signal Coach. Start with an earlobe at A0 and fingertip at A1. The dashboard checks both waves, exposes fixed or adaptive thresholds, rejects clipped channels, and pairs each proximal beat with at most one distal beat in the same cardiac cycle.

+

PTT is a timing experiment, not a blood-pressure reading. Do not use it for diagnosis, treatment, or health decisions.

+ diff --git a/src/PulseSensorPlayground.cpp b/src/PulseSensorPlayground.cpp index ce971c6..247cb63 100644 --- a/src/PulseSensorPlayground.cpp +++ b/src/PulseSensorPlayground.cpp @@ -211,6 +211,20 @@ void PulseSensorPlayground::setThreshold(int threshold, int sensorIndex) { Sensors[sensorIndex].setThreshold(threshold); } +void PulseSensorPlayground::setAdaptiveThreshold(bool enabled, int sensorIndex) { + if (sensorIndex < 0 || sensorIndex >= SensorCount) { + return; + } + Sensors[sensorIndex].setAdaptiveThreshold(enabled); +} + +int PulseSensorPlayground::getCurrentThreshold(int sensorIndex) { + if (sensorIndex < 0 || sensorIndex >= SensorCount) { + return -1; + } + return Sensors[sensorIndex].getCurrentThreshold(); +} + #if USE_SERIAL void PulseSensorPlayground::setSerial(Stream &output) { diff --git a/src/PulseSensorPlayground.h b/src/PulseSensorPlayground.h index 8a8821a..e10d498 100644 --- a/src/PulseSensorPlayground.h +++ b/src/PulseSensorPlayground.h @@ -303,6 +303,21 @@ vvvvvvvv THIS NEEDS MODIFICATION FOR V2 vvvvvvvv */ void setThreshold(int threshold, int sensorIndex = 0); + /* + By default, the detector adapts its threshold after each pulse. + Pass false to keep the value supplied to setThreshold() fixed. + + sensorIndex = optional, index (0..numberOfSensors - 1). + */ + void setAdaptiveThreshold(bool enabled, int sensorIndex = 0); + + /* + Returns the threshold currently used by the detector. + + sensorIndex = optional, index (0..numberOfSensors - 1). + */ + int getCurrentThreshold(int sensorIndex = 0); + //---------- Serial Output functions #if USE_SERIAL diff --git a/src/utility/PulseSensor.cpp b/src/utility/PulseSensor.cpp index 84e6f56..6a4ebc2 100644 --- a/src/utility/PulseSensor.cpp +++ b/src/utility/PulseSensor.cpp @@ -38,6 +38,8 @@ PulseSensor::PulseSensor() { InputPin = A0; BlinkPin = -1; FadePin = -1; + threshSetting = 550; + AdaptiveThreshold = true; // Initialize (seed) the pulse detector sampleIntervalMs = PulseSensorPlayground::MICROS_PER_READ / 1000; @@ -82,6 +84,24 @@ void PulseSensor::setThreshold(int threshold) { ENABLE_PULSE_SENSOR_INTERRUPTS; } +void PulseSensor::setAdaptiveThreshold(bool enabled) { + DISABLE_PULSE_SENSOR_INTERRUPTS; + AdaptiveThreshold = enabled; + if (!AdaptiveThreshold) { + thresh = threshSetting; + P = thresh; + T = thresh; + } + ENABLE_PULSE_SENSOR_INTERRUPTS; +} + +int PulseSensor::getCurrentThreshold() { + DISABLE_PULSE_SENSOR_INTERRUPTS; + int currentThreshold = thresh; + ENABLE_PULSE_SENSOR_INTERRUPTS; + return currentThreshold; +} + int PulseSensor::getLatestSample() { return Signal; } @@ -182,7 +202,11 @@ void PulseSensor::processLatestSample() { if (Signal < thresh && Pulse == true) { // when the values are going down, the beat is over Pulse = false; // reset the Pulse flag so we can do it again amp = P - T; // get amplitude of the pulse wave - thresh = amp / 2 + T; // set thresh at 50% of the amplitude + if (AdaptiveThreshold) { + thresh = amp / 2 + T; // set thresh at 50% of the amplitude + } else { + thresh = threshSetting; // keep the threshold selected by the user + } P = thresh; // reset these for next time T = thresh; } diff --git a/src/utility/PulseSensor.h b/src/utility/PulseSensor.h index 06d3123..ab7b470 100644 --- a/src/utility/PulseSensor.h +++ b/src/utility/PulseSensor.h @@ -71,6 +71,12 @@ class PulseSensor { // (internal to the library) Updtate the thresh variables. void setThreshold(int threshold); + // Select whether the threshold follows each detected pulse amplitude. + void setAdaptiveThreshold(bool enabled); + + // Returns the threshold currently used by the beat detector. + int getCurrentThreshold(); + private: // Configuration @@ -98,7 +104,8 @@ class PulseSensor { int N; // used to monitor duration between beats int P; // used to find peak in pulse wave, seeded (sample value) int T; // used to find trough in pulse wave, seeded (sample value) - int thresh; // used to find instant moment of heart beat, seeded (sample value) + volatile int thresh; // used to find instant moment of heart beat, seeded (sample value) + bool AdaptiveThreshold; // true: retune after each pulse; false: keep the user setting bool firstBeat; // used to seed rate array so we startup with reasonable BPM bool secondBeat; // used to seed rate array so we startup with reasonable BPM }; From 7b6feacc7bcd78d65bd5bb5f06351992e7694fce Mon Sep 17 00:00:00 2001 From: Yury Gitman Date: Sun, 16 Aug 2026 19:36:06 -0400 Subject: [PATCH 2/6] Open Signal Coach directly in PTT mode --- docs/signal-coach/signal-coach.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/signal-coach/signal-coach.mjs b/docs/signal-coach/signal-coach.mjs index edd783e..c168d94 100644 --- a/docs/signal-coach/signal-coach.mjs +++ b/docs/signal-coach/signal-coach.mjs @@ -562,6 +562,7 @@ function startReplay() { return timer; } +if (pageOptions.get('mode') === 'ptt') coachMode.value = 'ptt'; if (pageOptions.get('bench') === '1') startReplay(); window.__PULSE_COACH_QA__ = { From 04e4b90df42fed106ba9a40631ab1fde8d96f56b Mon Sep 17 00:00:00 2001 From: Yury Gitman Date: Mon, 17 Aug 2026 07:55:43 -0400 Subject: [PATCH 3/6] Add UNO R4 WiFi setup to Signal Coach --- docs/signal-coach/README.md | 17 ++++++ docs/signal-coach/index.html | 52 ++++++++++++++++++- docs/signal-coach/signal-coach.css | 9 ++++ docs/signal-coach/signal-coach.mjs | 28 ++++++++-- docs/signal-coach/source-pack.test.mjs | 8 +++ .../SignalCoachDualWebSerial.ino | 8 ++- .../shopify/page-signal-coach.html | 28 ++++++++++ 7 files changed, 144 insertions(+), 6 deletions(-) diff --git a/docs/signal-coach/README.md b/docs/signal-coach/README.md index 2588497..54bbbfb 100644 --- a/docs/signal-coach/README.md +++ b/docs/signal-coach/README.md @@ -37,6 +37,8 @@ own the serial port at a time. Web Serial works on HTTPS pages and on and fingertip are easier to stabilize than a wrist and fingertip. 2. Upload [`examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino`](../../examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino). + The complete UNO R4 WiFi sketch is also shown directly on the Signal Coach + page, with a **Copy sketch** button. 3. In Signal Coach choose **Two sensors / PTT**, click **Connect**, and select the board. This mode uses 500 synchronized samples/s at 250000 baud. 4. Start with fixed thresholds. Move each threshold above its idle noise but @@ -48,6 +50,21 @@ window, consumes each beat once, and rejects weak or clipped channels. It never converts PTT to blood pressure. This is an educational timing experiment, not a medical measurement. +### UNO R4 WiFi upload options + +The full Arduino IDE is optional, but the first USB upload still needs local +uploader tooling. Use one of these paths: + +- Arduino IDE 2 with the Arduino UNO R4 Boards package; +- Arduino CLI with the Renesas core and its BOSSA uploader; or +- Arduino Cloud Editor with Arduino Cloud Agent installed on the Mac. + +Signal Coach's **Connect** button uses Web Serial only after the sketch is on +the board. It does not currently compile or flash the UNO R4 WiFi. The UNO R4 +upload sequence changes the port into bootloader mode and uses the SAM-BA/BOSSA +protocol, so a trustworthy one-click browser flasher is separate work from the +serial dashboard. + ## Complete source map | File | What to hack | diff --git a/docs/signal-coach/index.html b/docs/signal-coach/index.html index a74e596..377b219 100644 --- a/docs/signal-coach/index.html +++ b/docs/signal-coach/index.html @@ -7,7 +7,7 @@ PulseSensor Signal Coach - +
@@ -154,6 +154,54 @@

Pulse Transit Time Lab

+ +
Technical details
@@ -188,6 +236,6 @@

Pulse Transit Time Lab

setTimeout(announceReady, 500); } - + diff --git a/docs/signal-coach/signal-coach.css b/docs/signal-coach/signal-coach.css index ab4d2c1..eb0619d 100644 --- a/docs/signal-coach/signal-coach.css +++ b/docs/signal-coach/signal-coach.css @@ -208,6 +208,15 @@ button:disabled { cursor: not-allowed; opacity: .38; } .supported-inputs { color: #9dacA3; font-size: 13px; line-height: 1.55; } .supported-inputs p { margin: 12px 0 0; } .supported-inputs code { color: var(--blue); } +.board-setup { color: #9daca3; font-size: 13px; line-height: 1.55; } +.setup-callout { margin: 14px 0; padding: 12px 14px; border-left: 4px solid var(--yellow); background: #fff8d5; color: #4f4a2f; } +.setup-callout p { margin: 0 0 8px; } +.setup-callout p:last-child { margin-bottom: 0; } +.sketch-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-bottom: 8px; color: #7c8b82; font: 700 11px/1.4 ui-monospace, "SFMono-Regular", Menlo, monospace; } +.copy-sketch-button { margin-left: auto; padding: 8px 12px; border: 1px solid var(--blue); border-radius: 3px; background: transparent; color: var(--blue); font-weight: 800; cursor: pointer; } +.copy-sketch-button:hover, .copy-sketch-button:focus-visible { background: var(--blue); color: #fff; } +.copy-status { min-width: 190px; color: var(--green); } +.sketch-code { max-height: 430px; margin: 0; overflow: auto; border: 1px solid var(--grid); background: #040804; padding: 16px; color: #b9d9c5; font: 12px/1.55 ui-monospace, "SFMono-Regular", Menlo, monospace; tab-size: 2; } footer { display: flex; justify-content: space-between; gap: 24px; border-top: 1px solid var(--grid-soft); padding-top: 14px; color: #6f7d74; font-size: 12px; } footer p { margin: 0; } diff --git a/docs/signal-coach/signal-coach.mjs b/docs/signal-coach/signal-coach.mjs index c168d94..d468b23 100644 --- a/docs/signal-coach/signal-coach.mjs +++ b/docs/signal-coach/signal-coach.mjs @@ -1,7 +1,7 @@ // Browser transport, browser-native coaching, and rendering for Signal Coach. -import { parsePulseLine } from './pulse-webserial-protocol.mjs?v=20260816-dual-ptt-r1'; -import { BrowserSignalCoach, adviceFor, stateColor } from './signal-coach-core.mjs?v=20260816-dual-ptt-r1'; -import { DualSignalCoach } from './ptt-coach-core.mjs?v=20260816-dual-ptt-r1'; +import { parsePulseLine } from './pulse-webserial-protocol.mjs?v=20260817-unor4-r2'; +import { BrowserSignalCoach, adviceFor, stateColor } from './signal-coach-core.mjs?v=20260817-unor4-r2'; +import { DualSignalCoach } from './ptt-coach-core.mjs?v=20260817-unor4-r2'; const pageOptions = new URLSearchParams(location.search); if (window.self !== window.top || pageOptions.get('embedded') === '1') { @@ -61,6 +61,10 @@ const medianPtt = document.querySelector('#medianPtt'); const acceptedPairs = document.querySelector('#acceptedPairs'); const rejectedPairs = document.querySelector('#rejectedPairs'); const pttGuidance = document.querySelector('#pttGuidance'); +const unoR4Setup = document.querySelector('#unoR4Setup'); +const copySketchBtn = document.querySelector('#copySketchBtn'); +const copySketchStatus = document.querySelector('#copySketchStatus'); +const unoR4Sketch = document.querySelector('#unoR4Sketch'); let port = null; let reader = null; @@ -343,6 +347,7 @@ function setCoachMode(mode) { singleWavePanel.hidden = ptt; singleReadoutPanel.hidden = ptt; pttWorkspace.hidden = !ptt; + unoR4Setup.hidden = !ptt; sampleRate.value = ptt ? '500' : '50'; sourceMeta.textContent = ptt ? 'Dual PTT1 input · 250000 baud' : 'Single pulse-wave input · 115200 baud'; resyncBtn.hidden = ptt; @@ -353,6 +358,23 @@ function setCoachMode(mode) { } } +copySketchBtn.addEventListener('click', async () => { + const sketch = unoR4Sketch.textContent.trim(); + try { + await navigator.clipboard.writeText(sketch); + copySketchBtn.textContent = 'Copied'; + copySketchStatus.textContent = 'Paste into a new Arduino sketch.'; + } catch { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(unoR4Sketch); + selection.removeAllRanges(); + selection.addRange(range); + copySketchStatus.textContent = 'Sketch selected. Press Command-C to copy.'; + } + setTimeout(() => { copySketchBtn.textContent = 'Copy sketch'; }, 1800); +}); + function showError(message) { errorMessage.textContent = message; errorMessage.hidden = false; diff --git a/docs/signal-coach/source-pack.test.mjs b/docs/signal-coach/source-pack.test.mjs index 65d3132..f2856ed 100644 --- a/docs/signal-coach/source-pack.test.mjs +++ b/docs/signal-coach/source-pack.test.mjs @@ -28,6 +28,7 @@ const guide = await readFile(path.join(folder, 'README.md'), 'utf8'); const dashboard = await readFile(path.join(folder, 'index.html'), 'utf8'); const browserApp = await readFile(path.join(folder, 'signal-coach.mjs'), 'utf8'); const sketch = await readFile(path.join(root, 'examples/SignalCoachWebSerial/SignalCoachWebSerial.ino'), 'utf8'); +const dualSketch = await readFile(path.join(root, 'examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino'), 'utf8'); const tutorial = await readFile(path.join(root, 'resources/signal-coach/shopify/page-signal-coach.html'), 'utf8'); for (const relativePath of required) { @@ -60,6 +61,13 @@ assert.match(dashboard, /pulsesensor-signal-coach-ready/); assert.match(dashboard, /Two sensors \/ PTT/); assert.match(dashboard, /id="proximalThresholdMode"/); assert.match(dashboard, /id="distalThresholdMode"/); +assert.match(dashboard, /UNO R4 WiFi setup/); +assert.match(dashboard, /Flash from this page: not yet/); +assert.match(dashboard, /id="copySketchBtn"/); +assert.match(dashboard, /Serial\.begin\(250000\)/); +assert.match(dashboard, /Serial\.print\("PTT1,"\)/); +assert.match(dashboard, /ARDUINO_UNOR4_WIFI/); +assert.match(dualSketch, /analogReadResolution\(10\)/); assert.match(tutorial, /SignalCoachDualWebSerial\.ino/); assert.match(tutorial, /timing experiment, not a blood-pressure reading/i); diff --git a/examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino b/examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino index 2039485..1a815f8 100644 --- a/examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino +++ b/examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino @@ -5,7 +5,9 @@ Coach performs threshold crossing, signal-quality checks, beat pairing, and Pulse Transit Time (PTT) measurement in the browser. - Wiring on Arduino Uno/Nano/Mega: + Test target: Arduino UNO R4 WiFi. Also works on classic Uno/Nano/Mega boards. + + Wiring: Proximal PulseSensor purple -> A0 (earlobe recommended) Distal PulseSensor purple -> A1 (fingertip recommended) Both red wires -> 5V @@ -25,6 +27,10 @@ const unsigned long SAMPLE_PERIOD_US = 2000; // 500 samples/second. unsigned long nextSampleAt = 0; void setup() { +#if defined(ARDUINO_UNOR4_WIFI) + // Signal Coach thresholds use the familiar 0-1023 Arduino ADC scale. + analogReadResolution(10); +#endif Serial.begin(250000); delay(1000); nextSampleAt = micros(); diff --git a/resources/signal-coach/shopify/page-signal-coach.html b/resources/signal-coach/shopify/page-signal-coach.html index c138882..3eb6446 100644 --- a/resources/signal-coach/shopify/page-signal-coach.html +++ b/resources/signal-coach/shopify/page-signal-coach.html @@ -145,6 +145,34 @@

Arduino / ESP32 setup

Two sensors / Pulse Transit Time

For a two-sensor experiment, connect the proximal signal to A0 and the distal signal to A1, upload SignalCoachDualWebSerial.ino, and choose Two sensors / PTT in Signal Coach. Start with an earlobe at A0 and fingertip at A1. The dashboard checks both waves, exposes fixed or adaptive thresholds, rejects clipped channels, and pairs each proximal beat with at most one distal beat in the same cardiac cycle.

+

UNO R4 WiFi: the complete sketch is included inside Signal Coach with a Copy button. The first upload still needs Arduino IDE 2, Arduino CLI, or Arduino Cloud Editor plus its local Cloud Agent. The dashboard Connect button reads Web Serial after upload; it does not currently flash the board.

+
const int PROXIMAL_PIN = A0;
+const int DISTAL_PIN = A1;
+const unsigned long SAMPLE_PERIOD_US = 2000;
+unsigned long nextSampleAt = 0;
+
+void setup() {
+#if defined(ARDUINO_UNOR4_WIFI)
+  analogReadResolution(10);
+#endif
+  Serial.begin(250000);
+  delay(1000);
+  nextSampleAt = micros();
+}
+
+void loop() {
+  const unsigned long now = micros();
+  if ((long)(now - nextSampleAt) < 0) return;
+  nextSampleAt += SAMPLE_PERIOD_US;
+  const int proximal = analogRead(PROXIMAL_PIN);
+  const int distal = analogRead(DISTAL_PIN);
+  Serial.print("PTT1,");
+  Serial.print(now);
+  Serial.print(',');
+  Serial.print(proximal);
+  Serial.print(',');
+  Serial.println(distal);
+}

PTT is a timing experiment, not a blood-pressure reading. Do not use it for diagnosis, treatment, or health decisions.

BoardPULSE_PINSensor power
From 96f11ad374cd57125eec3d1e5f200faef1a2c379 Mon Sep 17 00:00:00 2001 From: Yury Gitman Date: Mon, 17 Aug 2026 08:14:52 -0400 Subject: [PATCH 4/6] Stop Signal Coach canvas resize loop --- docs/signal-coach/signal-coach.mjs | 24 ++++++++++++++++-------- docs/signal-coach/source-pack.test.mjs | 2 ++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/signal-coach/signal-coach.mjs b/docs/signal-coach/signal-coach.mjs index d468b23..cb15fea 100644 --- a/docs/signal-coach/signal-coach.mjs +++ b/docs/signal-coach/signal-coach.mjs @@ -12,6 +12,7 @@ const HISTORY_LENGTH = 600; const canvas = document.querySelector('#waveformCanvas'); const context = canvas.getContext('2d'); const display = document.querySelector('.display'); +const deviceScreen = document.querySelector('.device-screen'); const connectBtn = document.querySelector('#connectBtn'); const resyncBtn = document.querySelector('#resyncBtn'); const coachMode = document.querySelector('#coachMode'); @@ -88,9 +89,13 @@ let lastPttRenderAt = -Infinity; function resizeCanvas() { const rectangle = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; - canvas.width = Math.max(1, Math.round(rectangle.width * dpr)); - canvas.height = Math.max(1, Math.round(rectangle.height * dpr)); - context.setTransform(dpr, 0, 0, dpr, 0, 0); + const width = Math.max(1, Math.round(rectangle.width * dpr)); + const height = Math.max(1, Math.round(rectangle.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + context.setTransform(dpr, 0, 0, dpr, 0, 0); + } drawWaveform(); resizePttCanvas(proximalCanvas, proximalHistory, lastPtt?.proximal?.threshold ?? 550, '#087e91'); resizePttCanvas(distalCanvas, distalHistory, lastPtt?.distal?.threshold ?? 550, '#997800'); @@ -100,10 +105,14 @@ function resizePttCanvas(target, values, threshold, color) { const rectangle = target.getBoundingClientRect(); if (!rectangle.width || !rectangle.height) return; const dpr = window.devicePixelRatio || 1; - target.width = Math.max(1, Math.round(rectangle.width * dpr)); - target.height = Math.max(1, Math.round(rectangle.height * dpr)); + const width = Math.max(1, Math.round(rectangle.width * dpr)); + const height = Math.max(1, Math.round(rectangle.height * dpr)); const drawing = target.getContext('2d'); - drawing.setTransform(dpr, 0, 0, dpr, 0, 0); + if (target.width !== width || target.height !== height) { + target.width = width; + target.height = height; + drawing.setTransform(dpr, 0, 0, dpr, 0, 0); + } drawPttWaveform(target, drawing, values, threshold, color); } @@ -539,8 +548,7 @@ navigator.serial?.addEventListener('disconnect', (event) => { }); window.addEventListener('resize', resizeCanvas); -new ResizeObserver(resizeCanvas).observe(canvas); -new ResizeObserver(resizeCanvas).observe(pttWorkspace); +new ResizeObserver(resizeCanvas).observe(deviceScreen); resizeCanvas(); function startReplay() { diff --git a/docs/signal-coach/source-pack.test.mjs b/docs/signal-coach/source-pack.test.mjs index f2856ed..4d5e007 100644 --- a/docs/signal-coach/source-pack.test.mjs +++ b/docs/signal-coach/source-pack.test.mjs @@ -68,6 +68,8 @@ assert.match(dashboard, /Serial\.begin\(250000\)/); assert.match(dashboard, /Serial\.print\("PTT1,"\)/); assert.match(dashboard, /ARDUINO_UNOR4_WIFI/); assert.match(dualSketch, /analogReadResolution\(10\)/); +assert.match(browserApp, /ResizeObserver\(resizeCanvas\)\.observe\(deviceScreen\)/); +assert.doesNotMatch(browserApp, /ResizeObserver\(resizeCanvas\)\.observe\((canvas|pttWorkspace)\)/); assert.match(tutorial, /SignalCoachDualWebSerial\.ino/); assert.match(tutorial, /timing experiment, not a blood-pressure reading/i); From d8996490500365401e1b404977de7a27f179f2a2 Mon Sep 17 00:00:00 2001 From: Yury Gitman Date: Mon, 17 Aug 2026 08:25:54 -0400 Subject: [PATCH 5/6] Skip hidden Signal Coach canvases --- docs/signal-coach/signal-coach.mjs | 21 +++++++++++---------- docs/signal-coach/source-pack.test.mjs | 4 ++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/signal-coach/signal-coach.mjs b/docs/signal-coach/signal-coach.mjs index cb15fea..e926860 100644 --- a/docs/signal-coach/signal-coach.mjs +++ b/docs/signal-coach/signal-coach.mjs @@ -12,7 +12,6 @@ const HISTORY_LENGTH = 600; const canvas = document.querySelector('#waveformCanvas'); const context = canvas.getContext('2d'); const display = document.querySelector('.display'); -const deviceScreen = document.querySelector('.device-screen'); const connectBtn = document.querySelector('#connectBtn'); const resyncBtn = document.querySelector('#resyncBtn'); const coachMode = document.querySelector('#coachMode'); @@ -88,15 +87,17 @@ let lastPttRenderAt = -Infinity; function resizeCanvas() { const rectangle = canvas.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - const width = Math.max(1, Math.round(rectangle.width * dpr)); - const height = Math.max(1, Math.round(rectangle.height * dpr)); - if (canvas.width !== width || canvas.height !== height) { - canvas.width = width; - canvas.height = height; - context.setTransform(dpr, 0, 0, dpr, 0, 0); + if (rectangle.width && rectangle.height) { + const dpr = window.devicePixelRatio || 1; + const width = Math.max(1, Math.round(rectangle.width * dpr)); + const height = Math.max(1, Math.round(rectangle.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + context.setTransform(dpr, 0, 0, dpr, 0, 0); + } + drawWaveform(); } - drawWaveform(); resizePttCanvas(proximalCanvas, proximalHistory, lastPtt?.proximal?.threshold ?? 550, '#087e91'); resizePttCanvas(distalCanvas, distalHistory, lastPtt?.distal?.threshold ?? 550, '#997800'); } @@ -154,6 +155,7 @@ function drawPttWaveform(target, drawing, values, threshold, color) { function drawWaveform() { const width = canvas.getBoundingClientRect().width; const height = canvas.getBoundingClientRect().height; + if (!width || !height) return; context.fillStyle = '#f4f8f5'; context.fillRect(0, 0, width, height); @@ -548,7 +550,6 @@ navigator.serial?.addEventListener('disconnect', (event) => { }); window.addEventListener('resize', resizeCanvas); -new ResizeObserver(resizeCanvas).observe(deviceScreen); resizeCanvas(); function startReplay() { diff --git a/docs/signal-coach/source-pack.test.mjs b/docs/signal-coach/source-pack.test.mjs index 4d5e007..b629b6c 100644 --- a/docs/signal-coach/source-pack.test.mjs +++ b/docs/signal-coach/source-pack.test.mjs @@ -68,8 +68,8 @@ assert.match(dashboard, /Serial\.begin\(250000\)/); assert.match(dashboard, /Serial\.print\("PTT1,"\)/); assert.match(dashboard, /ARDUINO_UNOR4_WIFI/); assert.match(dualSketch, /analogReadResolution\(10\)/); -assert.match(browserApp, /ResizeObserver\(resizeCanvas\)\.observe\(deviceScreen\)/); -assert.doesNotMatch(browserApp, /ResizeObserver\(resizeCanvas\)\.observe\((canvas|pttWorkspace)\)/); +assert.doesNotMatch(browserApp, /ResizeObserver\(resizeCanvas\)/); +assert.match(browserApp, /if \(!width \|\| !height\) return;/); assert.match(tutorial, /SignalCoachDualWebSerial\.ino/); assert.match(tutorial, /timing experiment, not a blood-pressure reading/i); From 682071ea6aff179af34751c721b9635d60c0c2e3 Mon Sep 17 00:00:00 2001 From: Yury Gitman Date: Wed, 19 Aug 2026 14:50:05 -0400 Subject: [PATCH 6/6] Split PTT into a standalone web lab --- README.md | 32 +- docs/pulse-transit-time/README.md | 113 +++++ docs/pulse-transit-time/index.html | 166 ++++++++ .../pulse-transit-time-core.mjs} | 4 +- .../pulse-transit-time-core.test.mjs} | 12 +- .../pulse-transit-time-protocol.mjs | 27 ++ .../pulse-transit-time-protocol.test.mjs | 34 ++ .../pulse-transit-time/pulse-transit-time.css | 128 ++++++ .../pulse-transit-time/pulse-transit-time.mjs | 391 ++++++++++++++++++ docs/pulse-transit-time/source-pack.test.mjs | 80 ++++ docs/signal-coach/README.md | 57 +-- docs/signal-coach/index.html | 128 +----- docs/signal-coach/protocol.test.mjs | 9 - .../signal-coach/pulse-webserial-protocol.mjs | 13 - docs/signal-coach/signal-coach.css | 45 +- docs/signal-coach/signal-coach.mjs | 308 +++----------- docs/signal-coach/source-pack.test.mjs | 52 ++- .../PulseTransitTimeWebSerial.ino} | 12 +- .../SignalCoachWebSerial.ino | 1 + resources/CodeDescriptions.md | 2 +- .../shopify/page-signal-coach.html | 46 +-- 21 files changed, 1084 insertions(+), 576 deletions(-) create mode 100644 docs/pulse-transit-time/README.md create mode 100644 docs/pulse-transit-time/index.html rename docs/{signal-coach/ptt-coach-core.mjs => pulse-transit-time/pulse-transit-time-core.mjs} (97%) rename docs/{signal-coach/ptt-coach-core.test.mjs => pulse-transit-time/pulse-transit-time-core.test.mjs} (82%) create mode 100644 docs/pulse-transit-time/pulse-transit-time-protocol.mjs create mode 100644 docs/pulse-transit-time/pulse-transit-time-protocol.test.mjs create mode 100644 docs/pulse-transit-time/pulse-transit-time.css create mode 100644 docs/pulse-transit-time/pulse-transit-time.mjs create mode 100644 docs/pulse-transit-time/source-pack.test.mjs rename examples/{SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino => PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino} (77%) diff --git a/README.md b/README.md index ef52da8..b5f73f4 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This Playground is a collection of code for the most popular uses of PulseSensor --- -## Web Serial Signal Coach and two-sensor PTT lab +## Web Serial Signal Coach The browser dashboard in [`docs/signal-coach/`](docs/signal-coach/) connects @@ -41,12 +41,6 @@ The complete no-build, no-minification home-hacking guide is Arduino/ESP32 sender lives at [`examples/SignalCoachWebSerial/SignalCoachWebSerial.ino`](examples/SignalCoachWebSerial/SignalCoachWebSerial.ino). -The same dashboard also has a **Two sensors / PTT** mode with synchronized A0 -and A1 waveforms, fixed or adaptive thresholds per sensor, clipping and weak -signal gates, and one-to-one same-cycle beat pairing. Its 500 Hz sender is -[`examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino`](examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino). -The PTT lab is educational and does not estimate blood pressure. - Run it locally from the repository root: ```bash @@ -60,7 +54,6 @@ own the port. Protocol tests run with: ```bash node docs/signal-coach/protocol.test.mjs node docs/signal-coach/signal-coach-core.test.mjs -node docs/signal-coach/ptt-coach-core.test.mjs node docs/signal-coach/source-pack.test.mjs ``` @@ -71,6 +64,29 @@ The normal customer URL never generates fallback data. An explicit `?bench=1` developer URL exposes a manually started, prominently labeled browser replay for UI QA; it is not physiology or sensor evidence. +## Pulse Transit Time Lab + +[`docs/pulse-transit-time/`](docs/pulse-transit-time/) is the separate +two-sensor research project. It has its own page, strict `PTT1` protocol, +250000-baud firmware, documentation, tests, and physical validation gate. It +deliberately shares only the transport-neutral beat-detection algorithm in +`signal-coach-core.mjs`; it is not a Signal Coach mode. + +The dedicated 500 Hz A0/A1 sender is +[`examples/PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino`](examples/PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino). +After starting the same local server, open +. Its checks run with: + +```bash +node docs/pulse-transit-time/pulse-transit-time-protocol.test.mjs +node docs/pulse-transit-time/pulse-transit-time-core.test.mjs +node docs/pulse-transit-time/source-pack.test.mjs +``` + +Pulse Transit Time Lab is an educational timing experiment and does not +estimate blood pressure. Browser transport success does not clear its real +two-sensor physiological HOLD gate. + --- ## Connecting the Harware 😎 diff --git a/docs/pulse-transit-time/README.md b/docs/pulse-transit-time/README.md new file mode 100644 index 0000000..5ada83e --- /dev/null +++ b/docs/pulse-transit-time/README.md @@ -0,0 +1,113 @@ +# Build and test Pulse Transit Time Lab + +Pulse Transit Time Lab is the dedicated two-sensor PulseSensor project. It is +not a mode of Signal Coach. The projects have separate pages, firmware, +protocol adapters, documentation, and validation gates. The PTT detector +deliberately reuses only Signal Coach's transport-neutral beat-detection core. + +Candidate standalone dashboard path after merge and Pages deployment: + + +## Two-sensor build + +1. Connect the proximal sensor's purple wire to A0 and the distal sensor's + purple wire to A1. Both sensors share 5V and GND. +2. Upload + [`PulseTransitTimeWebSerial.ino`](../../examples/PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino). +3. Close Arduino Serial Monitor and any other application using the serial + port. +4. From the repository root, start a local web server: + + ```sh + python3 -m http.server 8000 + ``` + +5. Open in desktop Chrome, + click **Connect two-sensor stream**, and select the board. + +The dedicated sender emits synchronized A0/A1 samples at 500 samples/s and +250000 baud. The page does not compile or flash the board. + +## Source map + +| File | Responsibility | +|---|---| +| [`README.md`](README.md) | This build, architecture, test, and physical-validation guide. | +| [`index.html`](index.html) | Dedicated two-sensor page structure and controls. | +| [`pulse-transit-time.css`](pulse-transit-time.css) | PTT page layout and responsive waveform presentation. | +| [`pulse-transit-time.mjs`](pulse-transit-time.mjs) | PTT Web Serial ownership, rendering, settings, reset, and bench replay. | +| [`pulse-transit-time-protocol.mjs`](pulse-transit-time-protocol.mjs) | Strict PTT1-only serial adapter. | +| [`pulse-transit-time-protocol.test.mjs`](pulse-transit-time-protocol.test.mjs) | PTT1 acceptance and one-sensor-format rejection tests. | +| [`pulse-transit-time-core.mjs`](pulse-transit-time-core.mjs) | Dual-channel quality gates and one-to-one beat pairing. | +| [`pulse-transit-time-core.test.mjs`](pulse-transit-time-core.test.mjs) | Threshold, missed-beat, timing-window, clipping, and synthetic PTT tests. | +| [`source-pack.test.mjs`](source-pack.test.mjs) | Architecture and source-completeness checks. | +| [`signal-coach-core.mjs`](../signal-coach/signal-coach-core.mjs) | Deliberately shared transport-neutral beat-detection algorithm. | +| [`PulseTransitTimeWebSerial.ino`](../../examples/PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino) | Dedicated synchronized A0/A1 sender. | + +No Signal Coach browser UI, serial adapter, firmware, product copy, or +validation result is shared with this project. + +The repository root [`LICENSE`](../../LICENSE) is the MIT License. + +## PTT1 input contract + +Each line is one synchronized sample: + +```text +PTT1,timestamp_us,proximal,distal +PTT1,123456,532,601 +``` + +The timestamp is the sender's unsigned microsecond clock. All three numeric +fields use canonical unsigned decimal digits only: no empty values, signs, +hexadecimal notation, exponents, or leading zeroes. Both samples belong to +that same acquisition instant. One-sensor numeric, labeled, JSON, and PSWS +formats are intentionally ignored here. + +## Detector and pairing behavior + +- A0 and A1 each have an explicit fixed or adaptive threshold. +- Each channel must clear warmup, range, and clipping checks. +- A distal beat is accepted only after a proximal beat in the configured + 5–300 ms default window. +- Each proximal beat is consumed at most once; stale cycles are unmatched, + never carried forward. +- Changing thresholds or the pairing window resets the analysis counters so + results from different configurations are not mixed, while the live serial + connection stays open for tuning during placement. +- Disconnect, reconnect, and QA reset clear the prior median and pair counts. + +The result is an educational timing measurement. It is not blood pressure and +must not be used for diagnosis, treatment, or health decisions. + +## Automated checks + +No npm install is required. From the repository root run: + +```sh +node docs/pulse-transit-time/pulse-transit-time-protocol.test.mjs +node docs/pulse-transit-time/pulse-transit-time-core.test.mjs +node docs/pulse-transit-time/source-pack.test.mjs +``` + +Browser-only simulated replay: + + + +The page must say **SIMULATED BENCH SIGNAL — NOT A PERSON**. The normal URL +never invents fallback samples. + +## Physical HOLD gate + +The browser transport smoke test is not physiological validation. Keep the +project in HOLD until a real two-sensor run records all of the following: + +- both sensors share 5V and GND, with A0 proximal and A1 distal; +- both channel quality states remain GOOD without ADC clipping; +- accepted same-cycle pairs increase repeatedly rather than once; +- median PTT is reasonably stable across a sustained placement; +- disconnect/reconnect begins a visibly fresh session; +- raw placement, threshold, and timing-window notes are captured with the run. + +Do not merge the release candidate, publish a customer page, close issue #202, +or notify customers until that physical gate passes. diff --git a/docs/pulse-transit-time/index.html b/docs/pulse-transit-time/index.html new file mode 100644 index 0000000..934aa56 --- /dev/null +++ b/docs/pulse-transit-time/index.html @@ -0,0 +1,166 @@ + + + + + + + PulseSensor Pulse Transit Time Lab + + + + +
+
+
+

PulseSensor · Two-channel Web Serial

+

RESEARCH BETA

+

Pulse Transit Time Lab

+

Compare synchronized proximal and distal pulse waves. Pairing and quality checks run locally in this browser.

+
+
+ +
+ Disconnected + PTT1 dual stream · 250000 baud +
+ +
+
+ + + + + +
+
+
+ Two synchronized sensors +

A0 proximal · A1 distal

+

Start with an earlobe at A0 and a fingertip at A1. The lab accepts only one-to-one beats inside the selected timing window.

+
+ PTT1 · 500 samples/s +
+ +
+
+ A0 · Proximal + + +
+
+ A1 · Distal + + +
+
+ Pairing window + + +
+
+ +
+
+
A0 PROXIMALWARMING
+ +

Signal -- · threshold 550 fixed

+
+
+
A1 DISTALWARMING
+ +

Signal -- · threshold 550 fixed

+
+
+ +
+
Latest PTT--milliseconds
+
Rolling median--last 20 accepted pairs
+
Accepted0quality + timing pass
+
Rejected / unmatched0 / 0never carried into next cycle
+
+

Upload the Pulse Transit Time sender, then connect both sensors.

+ +
+ WAITING + No synchronized samples received + A0 -- · A1 -- +
+
+ +
+ UNO R4 WiFi setup · upload this dedicated sketch first +
+

Wire A0 to the proximal sensor and A1 to the distal sensor. Both sensors share 5V and GND. Upload the sketch, close Arduino Serial Monitor, then return here and connect.

+

The browser reads Web Serial after upload; it does not flash the board.

+
+
+ PulseTransitTimeWebSerial.ino · 500 samples/s · 250000 baud + + +
+
/* PulseSensor Pulse Transit Time — dual-channel Web Serial sender
+ * Test target: Arduino UNO R4 WiFi
+ * Proximal purple wire -> A0; distal purple wire -> A1
+ * Both red wires -> 5V; both black wires -> GND
+ */
+const int PROXIMAL_PIN = A0;
+const int DISTAL_PIN = A1;
+const unsigned long SAMPLE_PERIOD_US = 2000;
+unsigned long nextSampleAt = 0;
+
+void setup() {
+#if defined(ARDUINO_UNOR4_WIFI)
+  analogReadResolution(10);
+#endif
+  Serial.begin(250000);
+  delay(1000);
+  nextSampleAt = micros();
+}
+
+void loop() {
+  const unsigned long now = micros();
+  if ((long)(now - nextSampleAt) < 0) return;
+  nextSampleAt += SAMPLE_PERIOD_US;
+  const int proximal = analogRead(PROXIMAL_PIN);
+  const int distal = analogRead(DISTAL_PIN);
+  Serial.print("PTT1,");
+  Serial.print(now);
+  Serial.print(',');
+  Serial.print(proximal);
+  Serial.print(',');
+  Serial.println(distal);
+}
+
+ +
+ Protocol and latest frame +

The dedicated sender emits PTT1,timestamp_us,proximal,distal. Other serial formats are ignored.

+

Latest framewaiting for PTT1 data...

+
+ + +
+ + + + + diff --git a/docs/signal-coach/ptt-coach-core.mjs b/docs/pulse-transit-time/pulse-transit-time-core.mjs similarity index 97% rename from docs/signal-coach/ptt-coach-core.mjs rename to docs/pulse-transit-time/pulse-transit-time-core.mjs index 4cc14c5..c0fd7cd 100644 --- a/docs/signal-coach/ptt-coach-core.mjs +++ b/docs/pulse-transit-time/pulse-transit-time-core.mjs @@ -1,4 +1,4 @@ -import { BrowserSignalCoach } from './signal-coach-core.mjs'; +import { BrowserSignalCoach } from '../signal-coach/signal-coach-core.mjs?v=20260819-ptt-r1'; function median(values) { if (!values.length) return null; @@ -93,7 +93,7 @@ export class PttPairer { } } -export class DualSignalCoach { +export class PulseTransitTimeCoach { constructor(options = {}) { const channelDefaults = { pulseThreshold: 550, thresholdMode: 'fixed' }; this.proximal = new BrowserSignalCoach({ ...channelDefaults, ...options.proximal }); diff --git a/docs/signal-coach/ptt-coach-core.test.mjs b/docs/pulse-transit-time/pulse-transit-time-core.test.mjs similarity index 82% rename from docs/signal-coach/ptt-coach-core.test.mjs rename to docs/pulse-transit-time/pulse-transit-time-core.test.mjs index b450118..0e107b3 100644 --- a/docs/signal-coach/ptt-coach-core.test.mjs +++ b/docs/pulse-transit-time/pulse-transit-time-core.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; -import { BrowserSignalCoach } from './signal-coach-core.mjs'; -import { DualSignalCoach, PttPairer } from './ptt-coach-core.mjs'; +import { BrowserSignalCoach } from '../signal-coach/signal-coach-core.mjs'; +import { PulseTransitTimeCoach, PttPairer } from './pulse-transit-time-core.mjs'; function pulseSample(timestamp, delay = 0) { const phase = ((timestamp - delay) % 833 + 833) % 833; @@ -28,23 +28,23 @@ pairer.pushProximal(4000); assert.equal(pairer.pushDistal(4400), null, 'out-of-window pair must be rejected'); assert.equal(pairer.snapshot().rejected, 1); -const dual = new DualSignalCoach({ +const coach = new PulseTransitTimeCoach({ proximal: { pulseThreshold: 550, thresholdMode: 'fixed' }, distal: { pulseThreshold: 550, thresholdMode: 'fixed' }, quality: { windowSize: 500, warmupSize: 100 }, }); let result; for (let timestamp = 0; timestamp < 12000; timestamp += 2) { - result = dual.update(pulseSample(timestamp), pulseSample(timestamp, 40), timestamp); + result = coach.update(pulseSample(timestamp), pulseSample(timestamp, 40), timestamp); } assert.ok(result.ptt.accepted >= 5, 'clean dual wave should produce paired beats'); assert.ok(result.ptt.median >= 36 && result.ptt.median <= 44, `expected about 40 ms, got ${result.ptt.median}`); -const clipped = new DualSignalCoach({ quality: { windowSize: 100, warmupSize: 20 } }); +const clipped = new PulseTransitTimeCoach({ quality: { windowSize: 100, warmupSize: 20 } }); for (let timestamp = 0; timestamp < 12000; timestamp += 2) { result = clipped.update(1023, pulseSample(timestamp, 40), timestamp); } assert.equal(result.proximalQuality.state, 'CLIPPED'); assert.equal(result.ptt.accepted, 0, 'clipped channels must not produce accepted PTT'); -console.log('Signal Coach dual-channel PTT tests passed'); +console.log('Pulse Transit Time core tests passed'); diff --git a/docs/pulse-transit-time/pulse-transit-time-protocol.mjs b/docs/pulse-transit-time/pulse-transit-time-protocol.mjs new file mode 100644 index 0000000..929d404 --- /dev/null +++ b/docs/pulse-transit-time/pulse-transit-time-protocol.mjs @@ -0,0 +1,27 @@ +function integer(value, name, minimum, maximum) { + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new Error(`invalid ${name}`); + } + return value; +} + +function unsignedDecimal(field, name, maximum) { + if (!/^(?:0|[1-9]\d*)$/.test(field)) throw new Error(`invalid ${name}`); + return integer(Number(field), name, 0, maximum); +} + +export function parsePulseTransitTimeLine(line) { + const fields = line.trim().split(','); + if (fields[0] !== 'PTT1') return null; + if (fields.length !== 4) throw new Error('invalid PTT1 field count'); + const timestampUs = unsignedDecimal(fields[1], 'timestamp', 0xffffffff); + return { + source: 'pulse-transit-time', + format: 'Pulse Transit Time PTT1', + mode: 'LIVE', + timestampUs, + timestampMs: timestampUs / 1000, + proximalSignal: unsignedDecimal(fields[2], 'proximal signal', 65535), + distalSignal: unsignedDecimal(fields[3], 'distal signal', 65535), + }; +} diff --git a/docs/pulse-transit-time/pulse-transit-time-protocol.test.mjs b/docs/pulse-transit-time/pulse-transit-time-protocol.test.mjs new file mode 100644 index 0000000..777f98b --- /dev/null +++ b/docs/pulse-transit-time/pulse-transit-time-protocol.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { parsePulseTransitTimeLine } from './pulse-transit-time-protocol.mjs'; + +assert.deepEqual(parsePulseTransitTimeLine('PTT1,123456,520,618'), { + source: 'pulse-transit-time', + format: 'Pulse Transit Time PTT1', + mode: 'LIVE', + timestampUs: 123456, + timestampMs: 123.456, + proximalSignal: 520, + distalSignal: 618, +}); +assert.equal(parsePulseTransitTimeLine('512'), null, 'one-sensor streams belong to Signal Coach'); +assert.equal(parsePulseTransitTimeLine('S512'), null, 'Signal Coach prefixes must not enter the PTT lane'); +assert.throws(() => parsePulseTransitTimeLine('PTT1,123,500'), /field count/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,12.5,500,600'), /timestamp/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,,500,600'), /timestamp/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,0x10,500,600'), /timestamp/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,1e3,500,600'), /timestamp/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,+123,500,600'), /timestamp/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,-1,500,600'), /timestamp/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,001,500,600'), /timestamp/); +assert.throws(() => parsePulseTransitTimeLine('PTT1, 123,500,600'), /timestamp/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,123,,600'), /proximal signal/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,123,0x10,600'), /proximal signal/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,123,+500,600'), /proximal signal/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,123,0500,600'), /proximal signal/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,123,500,1e3'), /distal signal/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,123,500,-1'), /distal signal/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,123,500, 600'), /distal signal/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,123,70000,600'), /proximal signal/); +assert.throws(() => parsePulseTransitTimeLine('PTT1,123,500,70000'), /distal signal/); + +console.log('Pulse Transit Time protocol tests passed'); diff --git a/docs/pulse-transit-time/pulse-transit-time.css b/docs/pulse-transit-time/pulse-transit-time.css new file mode 100644 index 0000000..7b82ec0 --- /dev/null +++ b/docs/pulse-transit-time/pulse-transit-time.css @@ -0,0 +1,128 @@ +:root { + color-scheme: light; + --page: #f2f4f2; + --screen: #fff; + --panel: #f4f8f5; + --grid: #b9d5c2; + --grid-soft: #dce7df; + --ink: #17211b; + --muted: #5d6962; + --blue: #087e91; + --yellow: #997800; + --green: #16784b; + --red: #a12626; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } +html, body { min-height: 100%; } +body { margin: 0; background: var(--page); color: var(--ink); } +button, summary, select, input { font: inherit; } + +.ptt-app { + width: min(1180px, 100%); + margin: 0 auto; + padding: clamp(14px, 2vw, 26px); + display: grid; + gap: clamp(12px, 2vw, 20px); +} + +.topbar { display: flex; justify-content: space-between; align-items: center; gap: 18px; } +.eyebrow { margin: 0 0 7px; color: var(--blue); font: 750 11px/1.2 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: .16em; text-transform: uppercase; } +.release-line { margin: 0 0 7px; display: flex; align-items: center; gap: 9px; color: #819087; font: 700 10px/1.2 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: .05em; text-transform: uppercase; } +.beta-badge { display: inline-block; border: 1px solid var(--yellow); color: var(--yellow); padding: 4px 7px; letter-spacing: .12em; } +h1 { margin: 0; font-size: clamp(30px, 4vw, 46px); line-height: .95; letter-spacing: -.045em; } +.subhead { max-width: 620px; margin: 8px 0 0; color: var(--muted); font-size: clamp(13px, 1.3vw, 15px); line-height: 1.35; } + +.connection { min-width: 480px; display: grid; grid-template-columns: 10px minmax(160px, 1fr) auto; align-items: center; gap: 11px; border: 1px solid var(--grid); background: var(--panel); padding: 10px 11px 10px 14px; } +.status-dot { width: 9px; height: 9px; border-radius: 50%; background: #8ba096; } +.status-dot.connected { background: var(--green); box-shadow: 0 0 0 4px rgba(22,120,75,.13); } +.status-dot.simulation { background: var(--yellow); box-shadow: 0 0 0 4px rgba(153,120,0,.13); } +.connection-copy { display: grid; gap: 2px; } +.connection-copy strong { font-size: 13px; } +.connection-copy span { color: #839189; font-size: 11px; } +#connectBtn, .copy-sketch-button { border: 1px solid var(--blue); border-radius: 0; background: var(--blue); color: #fff; padding: 9px 14px; cursor: pointer; font-weight: 800; } +#connectBtn:hover, #connectBtn:focus-visible, .copy-sketch-button:hover, .copy-sketch-button:focus-visible { background: #076f80; outline: 2px solid rgba(8,126,145,.25); outline-offset: 2px; } +button:disabled { cursor: not-allowed; opacity: .38; } + +.error-message, .simulation-warning, .experiment-warning { padding: 12px 15px; border: 1px solid var(--yellow); color: var(--yellow); background: #fff9e8; font: 700 12px/1.45 ui-monospace, "SFMono-Regular", Menlo, monospace; } +.error-message { border-color: #c54b4b; color: #8c2222; background: #fff0f0; } +.experiment-warning { color: #4e3d00; background: #fff3b0; } + +.workspace { display: grid; gap: 14px; border: 1px solid var(--grid); background: var(--screen); padding: clamp(12px, 2vw, 18px); } +.workspace-heading { display: flex; justify-content: space-between; gap: 20px; align-items: start; border: 1px solid var(--grid); background: var(--panel); padding: 15px; } +.workspace-heading h2 { margin: 5px 0; font-size: clamp(24px, 3vw, 36px); letter-spacing: -.03em; } +.workspace-heading p { margin: 0; max-width: 720px; color: var(--muted); font-size: 13px; } +.card-label { color: var(--blue); font: 800 13px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: .12em; text-transform: uppercase; } +.protocol-badge { border: 1px solid var(--yellow); color: var(--yellow); padding: 7px 9px; white-space: nowrap; font: 750 10px/1.2 ui-monospace, "SFMono-Regular", Menlo, monospace; } + +.ptt-controls { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; } +.ptt-controls fieldset { min-width: 0; margin: 0; border: 1px solid var(--grid); padding: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 9px; } +.ptt-controls legend { padding: 0 5px; color: var(--blue); font: 800 11px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: .08em; } +.ptt-controls label { display: grid; gap: 4px; color: var(--muted); font-size: 10px; text-transform: uppercase; } +.ptt-controls select, .ptt-controls input { width: 100%; min-width: 0; border: 1px solid #9fb6a8; background: #fff; color: var(--ink); padding: 7px; font: 700 12px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; } + +.dual-wave-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.channel-panel { min-width: 0; border: 1px solid var(--grid); background: var(--panel); } +.channel-header { display: flex; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--grid); color: var(--blue); font: 800 11px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; } +.channel-header span { color: var(--yellow); } +.channel-header span.good { color: var(--green); } +.channel-header span.clipped { color: var(--red); } +.channel-panel canvas { display: block; width: 100%; height: 240px; background: #fff; } +.channel-panel p { margin: 0; padding: 8px 12px; color: var(--muted); font: 650 10px/1.3 ui-monospace, "SFMono-Regular", Menlo, monospace; } + +.ptt-readouts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } +.ptt-readouts article { border: 1px solid var(--grid); background: var(--panel); padding: 13px; display: grid; gap: 5px; } +.ptt-readouts span, .ptt-readouts small { color: var(--muted); font-size: 10px; } +.ptt-readouts strong { color: var(--blue); font: 850 clamp(28px, 4vw, 48px)/1 ui-monospace, "SFMono-Regular", Menlo, monospace; } +.ptt-guidance { margin: 0; padding: 12px; border-left: 4px solid var(--blue); background: #eff9fa; color: var(--muted); font-size: 13px; } +.connection-summary { display: flex; justify-content: space-between; gap: 16px; color: #6f7e75; font: 650 10px/1.4 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: .06em; text-transform: uppercase; } +#modeChip { color: var(--blue); } +#modeChip.live { color: var(--green); } +#modeChip.sim { color: var(--yellow); } + +.technical-details { border-top: 1px solid var(--grid-soft); border-bottom: 1px solid var(--grid-soft); padding: 14px 0; color: #76857c; font-size: 13px; line-height: 1.55; } +.technical-details summary { cursor: pointer; color: #829087; font-weight: 750; } +.technical-details code { color: var(--blue); } +.setup-callout { margin: 14px 0; padding: 12px 14px; border-left: 4px solid var(--yellow); background: #fff8d5; color: #4f4a2f; } +.setup-callout p { margin: 0 0 8px; } +.setup-callout p:last-child { margin-bottom: 0; } +.sketch-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-bottom: 8px; color: #7c8b82; font: 700 11px/1.4 ui-monospace, "SFMono-Regular", Menlo, monospace; } +.copy-sketch-button { margin-left: auto; } +.copy-status { min-width: 190px; color: var(--green); } +.sketch-code { max-height: 430px; margin: 0; overflow: auto; border: 1px solid var(--grid); background: #040804; padding: 16px; color: #b9d9c5; font: 12px/1.55 ui-monospace, "SFMono-Regular", Menlo, monospace; tab-size: 2; } +.raw-line { display: grid; gap: 5px; margin: 12px 0 0; } +.raw-line span { color: #748279; font-size: 11px; } +.raw-line code { overflow-wrap: anywhere; color: #688275; font-size: 10px; } + +footer { display: flex; justify-content: space-between; gap: 24px; border-top: 1px solid var(--grid-soft); padding-top: 14px; color: #6f7d74; font-size: 12px; } +footer p { margin: 0; } +.source-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px 14px; } +.source-links a { color: var(--blue); font-weight: 750; text-decoration-thickness: 1px; text-underline-offset: 3px; } +.is-embedded .ptt-app { padding: 10px 12px 14px; gap: 10px; } +.is-embedded .topbar > div:first-child { display: none; } +.is-embedded .topbar { justify-content: flex-end; } +.is-embedded .connection { width: 100%; min-width: 0; } + +@media (max-width: 1000px) { + .topbar, footer { flex-direction: column; align-items: stretch; } + .connection { min-width: 0; } + .dual-wave-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 760px) { + .workspace-heading { flex-direction: column; } + .ptt-controls { grid-template-columns: 1fr; } + .ptt-readouts { grid-template-columns: 1fr 1fr; } + .connection-summary { flex-direction: column; gap: 4px; } + .connection { grid-template-columns: 9px minmax(0, 1fr); } + #connectBtn { grid-column: 1/-1; width: 100%; } + .channel-panel canvas { height: 220px; } +} + +@media (max-width: 430px) { + .ptt-app { padding: 12px; } + .ptt-readouts { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { * { transition: none !important; } } diff --git a/docs/pulse-transit-time/pulse-transit-time.mjs b/docs/pulse-transit-time/pulse-transit-time.mjs new file mode 100644 index 0000000..109ba6c --- /dev/null +++ b/docs/pulse-transit-time/pulse-transit-time.mjs @@ -0,0 +1,391 @@ +import { parsePulseTransitTimeLine } from './pulse-transit-time-protocol.mjs?v=20260819-ptt-r1'; +import { PulseTransitTimeCoach } from './pulse-transit-time-core.mjs?v=20260819-ptt-r1'; + +const pageOptions = new URLSearchParams(location.search); +if (window.self !== window.top || pageOptions.get('embedded') === '1') { + document.documentElement.classList.add('is-embedded'); +} + +const HISTORY_LENGTH = 600; +const connectBtn = document.querySelector('#connectBtn'); +const statusDot = document.querySelector('#statusDot'); +const statusText = document.querySelector('#statusText'); +const sourceMeta = document.querySelector('#sourceMeta'); +const errorMessage = document.querySelector('#errorMessage'); +const simulationWarning = document.querySelector('#simulationWarning'); +const proximalCanvas = document.querySelector('#proximalCanvas'); +const distalCanvas = document.querySelector('#distalCanvas'); +const proximalThresholdMode = document.querySelector('#proximalThresholdMode'); +const distalThresholdMode = document.querySelector('#distalThresholdMode'); +const proximalThreshold = document.querySelector('#proximalThreshold'); +const distalThreshold = document.querySelector('#distalThreshold'); +const minimumPtt = document.querySelector('#minimumPtt'); +const maximumPtt = document.querySelector('#maximumPtt'); +const proximalQuality = document.querySelector('#proximalQuality'); +const distalQuality = document.querySelector('#distalQuality'); +const proximalMeta = document.querySelector('#proximalMeta'); +const distalMeta = document.querySelector('#distalMeta'); +const latestPtt = document.querySelector('#latestPtt'); +const medianPtt = document.querySelector('#medianPtt'); +const acceptedPairs = document.querySelector('#acceptedPairs'); +const rejectedPairs = document.querySelector('#rejectedPairs'); +const pttGuidance = document.querySelector('#pttGuidance'); +const modeChip = document.querySelector('#modeChip'); +const frameMeta = document.querySelector('#frameMeta'); +const signalMeta = document.querySelector('#signalMeta'); +const rawSerial = document.querySelector('#rawSerial'); +const copySketchBtn = document.querySelector('#copySketchBtn'); +const copySketchStatus = document.querySelector('#copySketchStatus'); +const unoR4Sketch = document.querySelector('#unoR4Sketch'); +const settingsControls = [proximalThresholdMode, distalThresholdMode, proximalThreshold, distalThreshold, minimumPtt, maximumPtt]; + +let port = null; +let reader = null; +let readTask = null; +let reading = false; +let coach = new PulseTransitTimeCoach(); +let proximalHistory = new Array(HISTORY_LENGTH).fill(null); +let distalHistory = new Array(HISTORY_LENGTH).fill(null); +let lastResult = null; +let lastFrame = null; +let frameCount = 0; +let streamTimestamp = null; +let lastDeviceTimestamp = null; +let lastRenderAt = -Infinity; + +function qualityLabel(target, quality) { + target.textContent = quality.state; + target.className = quality.state === 'GOOD' ? 'good' : quality.state === 'CLIPPED' ? 'clipped' : ''; +} + +function drawWaveform(target, values, threshold, color) { + const rectangle = target.getBoundingClientRect(); + if (!rectangle.width || !rectangle.height) return; + const dpr = window.devicePixelRatio || 1; + const width = Math.max(1, Math.round(rectangle.width * dpr)); + const height = Math.max(1, Math.round(rectangle.height * dpr)); + const drawing = target.getContext('2d'); + if (target.width !== width || target.height !== height) { + target.width = width; + target.height = height; + drawing.setTransform(dpr, 0, 0, dpr, 0, 0); + } + const cssWidth = rectangle.width; + const cssHeight = rectangle.height; + drawing.fillStyle = '#fff'; + drawing.fillRect(0, 0, cssWidth, cssHeight); + drawing.strokeStyle = '#dce7df'; + drawing.lineWidth = 1; + for (let index = 1; index < 4; index += 1) { + drawing.beginPath(); + drawing.moveTo(0, (cssHeight * index) / 4); + drawing.lineTo(cssWidth, (cssHeight * index) / 4); + drawing.stroke(); + } + const present = values.filter((value) => value !== null); + if (present.length < 2) return; + const low = Math.max(0, Math.min(...present, threshold) - 35); + const high = Math.min(1023, Math.max(...present, threshold) + 35); + const range = Math.max(1, high - low); + const thresholdY = cssHeight - ((threshold - low) / range) * cssHeight; + drawing.save(); + drawing.setLineDash([6, 5]); + drawing.strokeStyle = '#a12626'; + drawing.beginPath(); + drawing.moveTo(0, thresholdY); + drawing.lineTo(cssWidth, thresholdY); + drawing.stroke(); + drawing.restore(); + drawing.strokeStyle = color; + drawing.lineWidth = 2.5; + drawing.lineJoin = 'round'; + drawing.beginPath(); + let started = false; + values.forEach((value, index) => { + if (value === null) return; + const x = (index / (HISTORY_LENGTH - 1)) * cssWidth; + const y = cssHeight - ((value - low) / range) * cssHeight; + if (!started) { + drawing.moveTo(x, y); + started = true; + } else { + drawing.lineTo(x, y); + } + }); + drawing.stroke(); +} + +function resizeCanvases() { + drawWaveform(proximalCanvas, proximalHistory, lastResult?.proximal?.threshold ?? Number(proximalThreshold.value), '#087e91'); + drawWaveform(distalCanvas, distalHistory, lastResult?.distal?.threshold ?? Number(distalThreshold.value), '#997800'); +} + +function applySettings() { + coach.configureChannel('proximal', proximalThresholdMode.value, Number(proximalThreshold.value)); + coach.configureChannel('distal', distalThresholdMode.value, Number(distalThreshold.value)); + coach.setPairingWindow(Number(minimumPtt.value), Number(maximumPtt.value)); +} + +function clearSession() { + coach = new PulseTransitTimeCoach(); + proximalHistory = new Array(HISTORY_LENGTH).fill(null); + distalHistory = new Array(HISTORY_LENGTH).fill(null); + lastResult = null; + lastFrame = null; + frameCount = 0; + streamTimestamp = null; + lastDeviceTimestamp = null; + lastRenderAt = -Infinity; + qualityLabel(proximalQuality, { state: 'WARMING' }); + qualityLabel(distalQuality, { state: 'WARMING' }); + proximalMeta.textContent = `Signal -- · threshold ${proximalThreshold.value} ${proximalThresholdMode.value}`; + distalMeta.textContent = `Signal -- · threshold ${distalThreshold.value} ${distalThresholdMode.value}`; + latestPtt.textContent = '--'; + medianPtt.textContent = '--'; + acceptedPairs.textContent = '0'; + rejectedPairs.textContent = '0 / 0'; + pttGuidance.textContent = 'Upload the Pulse Transit Time sender, then connect both sensors.'; + modeChip.textContent = 'WAITING'; + modeChip.className = ''; + frameMeta.textContent = 'No synchronized samples received'; + signalMeta.textContent = 'A0 -- · A1 --'; + rawSerial.textContent = 'waiting for PTT1 data...'; + resizeCanvases(); +} + +function resetSession() { + clearSession(); + applySettings(); +} + +function nextTimestamp(frame, explicitTimestamp = null) { + if (explicitTimestamp !== null) { + streamTimestamp = explicitTimestamp; + return streamTimestamp; + } + const deviceTimestamp = frame.timestampMs; + if (lastDeviceTimestamp === null) streamTimestamp = deviceTimestamp; + else if (deviceTimestamp > lastDeviceTimestamp) streamTimestamp += deviceTimestamp - lastDeviceTimestamp; + else streamTimestamp += 2; + lastDeviceTimestamp = deviceTimestamp; + return streamTimestamp; +} + +function updateFrame(frame, line, explicitTimestamp = null) { + const timestamp = nextTimestamp(frame, explicitTimestamp); + frameCount += 1; + lastFrame = frame; + lastResult = coach.update(frame.proximalSignal, frame.distalSignal, timestamp); + proximalHistory.shift(); + proximalHistory.push(lastResult.proximal.signal10); + distalHistory.shift(); + distalHistory.push(lastResult.distal.signal10); + if (timestamp - lastRenderAt < 33 && lastResult.ptt.latest === null) return; + lastRenderAt = timestamp; + resizeCanvases(); + qualityLabel(proximalQuality, lastResult.proximalQuality); + qualityLabel(distalQuality, lastResult.distalQuality); + proximalMeta.textContent = `Signal ${lastResult.proximal.signal10} · threshold ${lastResult.proximal.threshold} ${lastResult.proximal.thresholdMode}`; + distalMeta.textContent = `Signal ${lastResult.distal.signal10} · threshold ${lastResult.distal.threshold} ${lastResult.distal.thresholdMode}`; + if (lastResult.ptt.latest !== null) latestPtt.textContent = lastResult.ptt.latest.toFixed(1); + medianPtt.textContent = lastResult.ptt.median === null ? '--' : lastResult.ptt.median.toFixed(1); + acceptedPairs.textContent = String(lastResult.ptt.accepted); + rejectedPairs.textContent = `${lastResult.ptt.rejected} / ${lastResult.ptt.unmatched}`; + if (lastResult.proximalQuality.state === 'CLIPPED' || lastResult.distalQuality.state === 'CLIPPED') { + pttGuidance.textContent = 'A channel is clipping at the ADC rail. Reduce pressure, check power, and reposition before trusting PTT.'; + } else if (lastResult.proximalQuality.state !== 'GOOD' || lastResult.distalQuality.state !== 'GOOD') { + pttGuidance.textContent = 'Hold both sensors lightly and still until both channels report GOOD.'; + } else if (lastResult.ptt.accepted === 0) { + pttGuidance.textContent = 'Both waves look usable. Waiting for repeatable same-cycle beat pairs.'; + } else { + pttGuidance.textContent = 'Both channels pass signal quality. PTT is an educational timing measurement, not blood pressure.'; + } + const simulated = frame.mode === 'SIM'; + statusDot.className = `status-dot ${simulated ? 'simulation' : 'connected'}`; + statusText.textContent = simulated ? 'Simulated two-sensor replay' : 'Two PulseSensors connected'; + sourceMeta.textContent = `${frame.format} · 250000 baud`; + simulationWarning.hidden = !simulated; + modeChip.textContent = simulated ? 'SIM PTT' : 'LIVE PTT'; + modeChip.className = simulated ? 'sim' : 'live'; + frameMeta.textContent = `${frameCount.toLocaleString()} synchronized dual samples`; + signalMeta.textContent = `A0 ${lastResult.proximal.signal10} · A1 ${lastResult.distal.signal10}`; + rawSerial.textContent = line; +} + +function showError(message) { + errorMessage.textContent = message; + errorMessage.hidden = false; +} + +async function readLoop() { + const decoder = new TextDecoder(); + let buffer = ''; + while (reading && port?.readable) { + const activeReader = port.readable.getReader(); + reader = activeReader; + try { + while (reading) { + const { value, done } = await activeReader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + rawSerial.textContent = trimmed; + try { + const frame = parsePulseTransitTimeLine(trimmed); + if (frame) updateFrame(frame, trimmed); + } catch (error) { + console.warn('Ignored invalid Pulse Transit Time frame:', error.message); + } + } + } + } finally { + activeReader.releaseLock(); + if (reader === activeReader) reader = null; + } + } +} + +async function finalizeConnection(targetPort) { + if (!targetPort || targetPort !== port) return false; + reading = false; + port = null; + const activeReader = reader; + const activeReadTask = readTask; + if (activeReader) { + try { await activeReader.cancel(); } catch { /* The stream already failed or closed. */ } + } + if (activeReadTask) { + try { await activeReadTask; } catch { /* The caller reports read failures after cleanup. */ } + } + if (readTask === activeReadTask) readTask = null; + try { await targetPort.close(); } catch { /* The device may already be gone or never opened. */ } + connectBtn.textContent = 'Connect two-sensor stream'; + settingsControls.forEach((control) => { control.disabled = false; }); + statusDot.className = 'status-dot'; + statusText.textContent = 'Disconnected'; + sourceMeta.textContent = 'PTT1 dual stream · 250000 baud'; + simulationWarning.hidden = true; + clearSession(); + return true; +} + +async function connect() { + if (!('serial' in navigator)) { + showError('Web Serial is unavailable. Use desktop Chrome, Edge, or Brave over HTTPS.'); + return; + } + let requestedPort = null; + try { + requestedPort = await navigator.serial.requestPort(); + port = requestedPort; + await requestedPort.open({ baudRate: 250000, bufferSize: 16384 }); + resetSession(); + reading = true; + errorMessage.hidden = true; + connectBtn.textContent = 'Disconnect'; + statusText.textContent = 'Waiting for synchronized samples'; + sourceMeta.textContent = 'Connected · USB serial · 250000 baud'; + readTask = readLoop(); + await readTask; + if (port === requestedPort) await finalizeConnection(requestedPort); + } catch (error) { + if (requestedPort && port === requestedPort) await finalizeConnection(requestedPort); + if (error.name !== 'NotFoundError') showError(`Connection failed: ${error.message}`); + } +} + +connectBtn.addEventListener('click', () => { + if (port) finalizeConnection(port).catch((error) => showError(error.message)); + else connect().catch((error) => showError(error.message)); +}); + +settingsControls.forEach((control) => { + control.addEventListener('change', () => { + try { + resetSession(); + errorMessage.hidden = true; + } catch (error) { + showError(error.message); + } + }); +}); + +copySketchBtn.addEventListener('click', async () => { + const sketch = unoR4Sketch.textContent.trim(); + try { + await navigator.clipboard.writeText(sketch); + copySketchBtn.textContent = 'Copied'; + copySketchStatus.textContent = 'Paste into a new Arduino sketch.'; + } catch { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(unoR4Sketch); + selection.removeAllRanges(); + selection.addRange(range); + copySketchStatus.textContent = 'Sketch selected. Press Command-C to copy.'; + } + setTimeout(() => { copySketchBtn.textContent = 'Copy sketch'; }, 1800); +}); + +navigator.serial?.addEventListener('disconnect', (event) => { + finalizeConnection(event.target).catch(() => {}); +}); + +window.addEventListener('resize', resizeCanvases); + +function pulseSample(timestamp, delay = 0) { + const phase = ((timestamp - delay) % 833 + 833) % 833; + if (phase < 50) return 500 + Math.round(phase * 3); + if (phase < 140) return 650 - Math.round((phase - 50) * 1.5); + return 500; +} + +function startReplay() { + connectBtn.hidden = true; + settingsControls.forEach((control) => { control.disabled = true; }); + resetSession(); + let timestamp = 0; + const timer = setInterval(() => { + for (let sample = 0; sample < 10; sample += 1) { + const frame = { + source: 'bench', + format: 'Browser bench PTT1', + mode: 'SIM', + timestampUs: timestamp * 1000, + timestampMs: timestamp, + proximalSignal: pulseSample(timestamp), + distalSignal: pulseSample(timestamp, 40), + }; + updateFrame(frame, `PTT1,${frame.timestampUs},${frame.proximalSignal},${frame.distalSignal}`, timestamp); + timestamp += 2; + } + }, 20); + return timer; +} + +window.__PULSE_TRANSIT_TIME_QA__ = { + inject: (line, timestamp = null) => { + const frame = parsePulseTransitTimeLine(line); + if (frame) updateFrame(frame, line, timestamp); + return { frame, result: lastResult }; + }, + reset: resetSession, + getState: () => ({ + frameCount, + lastFrame, + result: lastResult, + canvasWidths: [proximalCanvas.width, distalCanvas.width], + connected: Boolean(port), + button: connectBtn.textContent, + status: statusText.textContent, + source: sourceMeta.textContent, + controlsDisabled: settingsControls.some((control) => control.disabled), + }), +}; + +resetSession(); +if (pageOptions.get('bench') === '1') startReplay(); diff --git a/docs/pulse-transit-time/source-pack.test.mjs b/docs/pulse-transit-time/source-pack.test.mjs new file mode 100644 index 0000000..1884ed5 --- /dev/null +++ b/docs/pulse-transit-time/source-pack.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { access, readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const folder = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(folder, '../..'); +const required = [ + 'docs/pulse-transit-time/README.md', + 'docs/pulse-transit-time/index.html', + 'docs/pulse-transit-time/pulse-transit-time.css', + 'docs/pulse-transit-time/pulse-transit-time.mjs', + 'docs/pulse-transit-time/pulse-transit-time-core.mjs', + 'docs/pulse-transit-time/pulse-transit-time-core.test.mjs', + 'docs/pulse-transit-time/pulse-transit-time-protocol.mjs', + 'docs/pulse-transit-time/pulse-transit-time-protocol.test.mjs', + 'docs/pulse-transit-time/source-pack.test.mjs', + 'docs/signal-coach/signal-coach-core.mjs', + 'examples/PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino', + 'LICENSE', +]; + +await Promise.all(required.map((relativePath) => access(path.join(root, relativePath)))); + +const guide = await readFile(path.join(folder, 'README.md'), 'utf8'); +const dashboard = await readFile(path.join(folder, 'index.html'), 'utf8'); +const browserApp = await readFile(path.join(folder, 'pulse-transit-time.mjs'), 'utf8'); +const protocol = await readFile(path.join(folder, 'pulse-transit-time-protocol.mjs'), 'utf8'); +const core = await readFile(path.join(folder, 'pulse-transit-time-core.mjs'), 'utf8'); +const sketch = await readFile(path.join(root, 'examples/PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino'), 'utf8'); + +for (const relativePath of required) { + assert.ok(guide.includes(path.basename(relativePath)), `source map should name ${relativePath}`); +} + +assert.match(dashboard, /Pulse Transit Time Lab/); +assert.match(dashboard, /A0 proximal · A1 distal/); +assert.match(dashboard, /EDUCATIONAL TIMING EXPERIMENT/); +assert.match(dashboard, /id="proximalThresholdMode"/); +assert.match(dashboard, /id="distalThresholdMode"/); +assert.match(dashboard, /id="acceptedPairs"/); +assert.match(dashboard, /PulseTransitTimeWebSerial\.ino/); +assert.match(dashboard, /Serial\.begin\(250000\)/); +assert.match(dashboard, /Serial\.print\("PTT1,"\)/); +assert.match(dashboard, /pulsesensor-pulse-transit-time-ready/); +assert.doesNotMatch(dashboard, /Signal Coach/); +assert.doesNotMatch(browserApp, /coachMode|pulse-webserial-protocol|SignalCoachWebSerial/); +assert.match(browserApp, /pulse-transit-time-protocol\.mjs\?v=/); +assert.match(browserApp, /pulse-transit-time-core\.mjs\?v=/); +assert.match(browserApp, /function clearSession\(\)/); +assert.match(browserApp, /resetSession\(\)/); +assert.match(browserApp, /async function finalizeConnection\(targetPort\)/); +assert.match(browserApp, /if \(!targetPort \|\| targetPort !== port\) return false;/); +assert.match(browserApp, /finalizeConnection\(event\.target\)/); +const connectBody = browserApp.match(/async function connect\(\) \{([\s\S]*?)\n\}\n\nconnectBtn/)?.[1]; +assert.ok(connectBody, 'PTT connect function should be inspectable'); +assert.doesNotMatch(connectBody, /control\.disabled = true/, 'live PTT controls must remain enabled'); +assert.match(protocol, /fields\[0\] !== 'PTT1'/); +assert.match(protocol, /\^\(\?:0\|\[1-9\]\\d\*\)\$/); +assert.doesNotMatch(protocol, /PSWS|raw-number|arduino-csv/); +assert.match(core, /\.\.\/signal-coach\/signal-coach-core\.mjs/); +assert.doesNotMatch(core, /signal-coach\.mjs|pulse-webserial-protocol/); +assert.match(sketch, /PulseSensor Pulse Transit Time/); +assert.doesNotMatch(sketch, /Signal Coach/); +assert.match(sketch, /analogReadResolution\(10\)/); +assert.match(sketch, /SAMPLE_PERIOD_US = 2000/); +assert.match(sketch, /Serial\.begin\(250000\)/); +assert.match(sketch, /Serial\.print\("PTT1,"\)/); + +const releaseVersion = dashboard.match(/pulse-transit-time\.mjs\?v=([a-z0-9-]+)/i)?.[1]; +assert.ok(releaseVersion, 'dashboard should version its browser module'); +assert.ok(dashboard.includes(`pulse-transit-time.css?v=${releaseVersion}`)); +assert.ok(browserApp.includes(`pulse-transit-time-protocol.mjs?v=${releaseVersion}`)); +assert.ok(browserApp.includes(`pulse-transit-time-core.mjs?v=${releaseVersion}`)); +assert.ok(core.includes(`signal-coach-core.mjs?v=${releaseVersion}`)); + +await assert.rejects(access(path.join(root, 'examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino'))); +await assert.rejects(access(path.join(root, 'docs/signal-coach/ptt-coach-core.mjs'))); + +console.log('Pulse Transit Time source-pack tests passed'); diff --git a/docs/signal-coach/README.md b/docs/signal-coach/README.md index 54bbbfb..00191fb 100644 --- a/docs/signal-coach/README.md +++ b/docs/signal-coach/README.md @@ -2,9 +2,8 @@ This folder is the complete source for the public PulseSensor Signal Coach. There is no framework, package install, bundler, minified file, server-side -detector, or private runtime. Chrome receives one or two pulse-wave samples and -runs beat detection, IBI, BPM, confidence, coaching states, and same-cycle PTT -pairing locally. +detector, or private runtime. Chrome receives a pulse-wave sample and runs beat +detection, IBI, BPM, confidence, coaching states, and Re-sync locally. Public app: Standalone dashboard: @@ -31,40 +30,6 @@ Close Arduino Serial Monitor before connecting because only one application can own the serial port at a time. Web Serial works on HTTPS pages and on `localhost`; opening `index.html` directly as a file is not supported. -## Two-sensor PTT build - -1. Connect the proximal sensor to A0 and the distal sensor to A1. An earlobe - and fingertip are easier to stabilize than a wrist and fingertip. -2. Upload - [`examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino`](../../examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino). - The complete UNO R4 WiFi sketch is also shown directly on the Signal Coach - page, with a **Copy sketch** button. -3. In Signal Coach choose **Two sensors / PTT**, click **Connect**, and select - the board. This mode uses 500 synchronized samples/s at 250000 baud. -4. Start with fixed thresholds. Move each threshold above its idle noise but - through the rising pulse wave. Use adaptive mode only when you deliberately - want the threshold to follow recent pulse amplitude. - -The lab accepts a distal beat only after a proximal beat in the same 5–300 ms -window, consumes each beat once, and rejects weak or clipped channels. It never -converts PTT to blood pressure. This is an educational timing experiment, not -a medical measurement. - -### UNO R4 WiFi upload options - -The full Arduino IDE is optional, but the first USB upload still needs local -uploader tooling. Use one of these paths: - -- Arduino IDE 2 with the Arduino UNO R4 Boards package; -- Arduino CLI with the Renesas core and its BOSSA uploader; or -- Arduino Cloud Editor with Arduino Cloud Agent installed on the Mac. - -Signal Coach's **Connect** button uses Web Serial only after the sketch is on -the board. It does not currently compile or flash the UNO R4 WiFi. The UNO R4 -upload sequence changes the port into bootloader mode and uses the SAM-BA/BOSSA -protocol, so a trustworthy one-click browser flasher is separate work from the -serial dashboard. - ## Complete source map | File | What to hack | @@ -74,14 +39,11 @@ serial dashboard. | [`signal-coach.css`](signal-coach.css) | Large waveform display, coaching colors, layout, and responsive styles. | | [`signal-coach.mjs`](signal-coach.mjs) | Web Serial connection, sample clock, canvas rendering, UI, and blue Re-sync action. | | [`signal-coach-core.mjs`](signal-coach-core.mjs) | Transport-neutral beat detector, BPM/IBI, confidence, state order, copy, and v1.1-resync recovery. | -| [`ptt-coach-core.mjs`](ptt-coach-core.mjs) | Dual-channel signal quality, fixed/adaptive detector control, and one-to-one PTT pairing. | | [`pulse-webserial-protocol.mjs`](pulse-webserial-protocol.mjs) | Text/CSV/JSON/PSWS input adapters. | | [`signal-coach-core.test.mjs`](signal-coach-core.test.mjs) | 10-bit, 12-bit, multi-rate, flat-wave, false-sender, and Re-sync tests. | -| [`ptt-coach-core.test.mjs`](ptt-coach-core.test.mjs) | Threshold-mode, missed-beat, timing-window, clipping, and synthetic PTT tests. | | [`protocol.test.mjs`](protocol.test.mjs) | Accepted and rejected serial-line examples. | | [`source-pack.test.mjs`](source-pack.test.mjs) | Verifies that the public home-build source pack and links stay complete. | | [`SignalCoachWebSerial.ino`](../../examples/SignalCoachWebSerial/SignalCoachWebSerial.ino) | Minimal Arduino/ESP32 raw pulse-wave sender. | -| [`SignalCoachDualWebSerial.ino`](../../examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino) | Synchronized A0/A1 sender for the two-sensor PTT lab. | | [`page-signal-coach.html`](../../resources/signal-coach/shopify/page-signal-coach.html) | Complete source for the surrounding PulseSensor.com tutorial page. | The repository root [`LICENSE`](../../LICENSE) is the MIT License. You may use, @@ -107,13 +69,6 @@ Signal Coach also accepts: - Arduino `signal,bpm,ibi,beat` CSV; and - PulseLink PSWS v1/v2. -Two-sensor mode uses one versioned CSV record per synchronized sample: - -```text -PTT1,timestamp_us,proximal,distal -PTT1,123456,532,601 -``` - The browser uses sender timestamps when present. Otherwise it advances time using the **Samples/s** choice. Sender BPM, IBI, beat, quality, and coach state are diagnostics only; raw waveform data always controls the browser coach. @@ -123,9 +78,6 @@ are diagnostics only; raw waveform data always controls the browser coach. ```text PulseSensor -> analogRead() -> USB serial -> input adapter -> BrowserSignalCoach -> waveform + guidance + qualified BPM - -Two PulseSensors -> synchronized timestamp + A0/A1 -> DualSignalCoach - -> quality gates -> one-to-one pairing -> educational PTT ``` The board is deliberately simple. This makes the same browser coach usable @@ -157,7 +109,6 @@ root: ```sh node docs/signal-coach/protocol.test.mjs node docs/signal-coach/signal-coach-core.test.mjs -node docs/signal-coach/ptt-coach-core.test.mjs node docs/signal-coach/source-pack.test.mjs ``` @@ -165,10 +116,6 @@ For visual testing, open this explicitly simulated URL: -Two-sensor replay: - - - The page must say **SIMULATED BENCH SIGNAL — NOT A PERSON**. The normal URL never invents fallback samples. diff --git a/docs/signal-coach/index.html b/docs/signal-coach/index.html index 377b219..9586c84 100644 --- a/docs/signal-coach/index.html +++ b/docs/signal-coach/index.html @@ -6,30 +6,24 @@ PulseSensor Signal Coach - - + +

PulseSensor · Web Serial

-

BETA

+

BETA

Signal Coach

-

Coach one pulse signal for BPM, or compare two synchronized sensors for educational Pulse Transit Time. Detection and pairing stay in your browser.

+

Connect Arduino, ESP32, or any supported pulse-wave serial stream. Chrome finds the beats, coaches the signal, and shows BPM only after it settles.

Disconnected - Single pulse-wave input · 115200 baud + Pulse-wave input · 115200 baud
- - - -
- A1 · Distal - - -
-
- Pairing window - - -
-
- -
-
-
A0 PROXIMALWARMING
- -

Signal -- · threshold 550 fixed

-
-
-
A1 DISTALWARMING
- -

Signal -- · threshold 550 fixed

-
-
- -
-
Latest PTT--milliseconds
-
Rolling median--last 20 accepted pairs
-
Accepted0quality + timing pass
-
Rejected / unmatched0 / 0never carried into next cycle
-
-

Upload the dual-channel sketch, then connect both sensors.

-
@@ -154,54 +94,6 @@

Pulse Transit Time Lab

- -
Technical details
@@ -217,13 +109,13 @@

Pulse Transit Time Lab

Supported pulse streams -

Single mode accepts one number per line, S512, labeled or timestamped samples, JSON, Arduino signal,bpm,ibi,beat, and PulseLink PSWS. Dual mode accepts PTT1,timestamp_us,proximal,distal. Sender BPM and IBI never control the coach.

+

Signal Coach needs a numeric pulse-wave sample. It accepts one number per line, S512, Signal 512, signal=512, timestamp,signal, JSON samples, Arduino signal,bpm,ibi,beat, and PulseLink PSWS. Sender BPM and IBI never control the coach.

Choose the rate your sketch prints. The default tutorial sends 50 samples each second.

@@ -236,6 +128,6 @@

Pulse Transit Time Lab

setTimeout(announceReady, 500); } - + diff --git a/docs/signal-coach/protocol.test.mjs b/docs/signal-coach/protocol.test.mjs index 95a0b48..9e356e0 100644 --- a/docs/signal-coach/protocol.test.mjs +++ b/docs/signal-coach/protocol.test.mjs @@ -24,13 +24,6 @@ assert.equal(parsePulseLine('{"timestampMs":1200,"raw":2048,"bpm":70}').sourceBp assert.equal(parsePulseLine('B72'), null); assert.equal(parsePulseLine('Q833'), null); -const ptt = parsePulseLine('PTT1,123456,520,618'); -assert.equal(ptt.source, 'ptt1'); -assert.equal(ptt.timestampUs, 123456); -assert.equal(ptt.timestampMs, 123.456); -assert.equal(ptt.signal, 520); -assert.equal(ptt.secondarySignal, 618); - const v1 = parsePulseLine('PSWS,1,SIM,42,12345,2840,72,833,1,12,QUALIFIED'); assert.equal(v1.source, 'pulselink'); assert.equal(v1.format, 'PulseLink PSWS v1'); @@ -50,7 +43,5 @@ assert.throws(() => parsePulseLine('70000'), /invalid signal/); assert.throws(() => parsePulseLine('PSWS,1,SIM,42,12345,70000,72,833,1,12,QUALIFIED'), /signal/); assert.throws(() => parsePulseLine('PSWS,1,UNKNOWN,42,12345,2048,72,833,1,12,QUALIFIED'), /mode/); assert.throws(() => parsePulseLine('PSWS,3,LIVE,42,12345,2048,72,833,1,12,QUALIFIED'), /unsupported/); -assert.throws(() => parsePulseLine('PTT1,123,500'), /field count/); -assert.throws(() => parsePulseLine('PTT1,123,500,70000'), /secondary signal/); console.log('PulseSensor pulse-stream adapter tests passed'); diff --git a/docs/signal-coach/pulse-webserial-protocol.mjs b/docs/signal-coach/pulse-webserial-protocol.mjs index 17982c7..7aebb69 100644 --- a/docs/signal-coach/pulse-webserial-protocol.mjs +++ b/docs/signal-coach/pulse-webserial-protocol.mjs @@ -63,19 +63,6 @@ export function parsePulseLine(line) { if (json) return json; const fields = normalized.split(',').map((field) => field.trim()); - if (fields[0] === 'PTT1') { - if (fields.length !== 4) throw new Error('invalid PTT1 field count'); - const timestampUs = integer(Number(fields[1]), 'timestamp', 0, 0xffffffff); - const frame = pulseFrame({ - source: 'ptt1', - format: 'PulseSensor dual-channel PTT1', - signal: fields[2], - timestampMs: timestampUs / 1000, - }); - frame.timestampUs = timestampUs; - frame.secondarySignal = integer(Number(fields[3]), 'secondary signal', 0, 65535); - return frame; - } if (fields[0] === PSWS_PREFIX) { const version = integer(Number(fields[1]), 'protocol version', 1, 255); if (!PSWS_VERSIONS.includes(version)) throw new Error(`unsupported PSWS version ${version}`); diff --git a/docs/signal-coach/signal-coach.css b/docs/signal-coach/signal-coach.css index eb0619d..281bd9c 100644 --- a/docs/signal-coach/signal-coach.css +++ b/docs/signal-coach/signal-coach.css @@ -54,9 +54,9 @@ h1 { margin: 0; font-size: clamp(30px, 4vw, 46px); line-height: .95; letter-spac .subhead { max-width: 570px; margin: 8px 0 0; color: var(--muted); font-size: clamp(13px, 1.3vw, 15px); line-height: 1.35; } .connection { - min-width: 690px; + min-width: 560px; display: grid; - grid-template-columns: 10px minmax(150px, 1fr) auto auto auto auto; + grid-template-columns: 10px minmax(150px, 1fr) auto auto auto; align-items: center; gap: 11px; border: 1px solid var(--grid); @@ -88,7 +88,6 @@ h1 { margin: 0; font-size: clamp(30px, 4vw, 46px); line-height: .95; letter-spac button:disabled { cursor: not-allowed; opacity: .38; } .rate-control { display: grid; gap: 2px; color: #7d8b83; font: 650 9px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; text-transform: uppercase; } .rate-control select { min-width: 62px; border: 1px solid #9fb6a8; border-radius: 0; background: #fff; color: var(--white); padding: 5px 7px; font: 700 12px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; } -#coachMode { min-width: 145px; } .resync-help { margin: 0; padding: 9px 10px; border-left: 3px solid var(--blue); background: #eff9fa; color: var(--muted); font-size: 11px; line-height: 1.35; } .resync-help strong, .resync-help b { color: var(--blue); } @@ -118,7 +117,6 @@ button:disabled { cursor: not-allowed; opacity: .38; } .screen-source { color: var(--muted); font-size: .58em; letter-spacing: .09em; } .wave-coach-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(245px, 310px); border: 2px solid var(--grid); border-top: 0; } -.wave-coach-layout[hidden], .readout-grid[hidden] { display: none; } .wave-wrap { position: relative; border: 2px solid var(--grid); border-top: 0; background: var(--screen); } .wave-coach-layout .wave-wrap { border: 0; border-right: 1px solid var(--grid); } #waveformCanvas { display: block; width: 100%; height: clamp(270px, 30vw, 360px); } @@ -167,31 +165,6 @@ button:disabled { cursor: not-allowed; opacity: .38; } .bpm-card.invert { background: var(--green); } .bpm-card.invert .card-label, .bpm-card.invert strong, .bpm-card.invert .card-note { color: var(--screen); } -.ptt-workspace { display: grid; gap: 14px; margin-top: 12px; } -.ptt-workspace[hidden] { display: none; } -.ptt-heading { display: flex; justify-content: space-between; gap: 20px; align-items: start; border: 1px solid var(--grid); background: var(--panel); padding: 15px; } -.ptt-heading h2 { margin: 5px 0; font-size: clamp(24px, 3vw, 36px); letter-spacing: -.03em; } -.ptt-heading p { margin: 0; max-width: 720px; color: var(--muted); font-size: 13px; } -.experimental-badge { border: 1px solid var(--yellow); color: var(--yellow); padding: 7px 9px; white-space: nowrap; font: 750 10px/1.2 ui-monospace, "SFMono-Regular", Menlo, monospace; } -.ptt-controls { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; } -.ptt-controls fieldset { min-width: 0; margin: 0; border: 1px solid var(--grid); padding: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 9px; } -.ptt-controls legend { padding: 0 5px; color: var(--blue); font: 800 11px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: .08em; } -.ptt-controls label { display: grid; gap: 4px; color: var(--muted); font-size: 10px; text-transform: uppercase; } -.ptt-controls select, .ptt-controls input { width: 100%; min-width: 0; border: 1px solid #9fb6a8; background: #fff; color: var(--white); padding: 7px; font: 700 12px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; } -.dual-wave-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } -.channel-panel { min-width: 0; border: 1px solid var(--grid); background: var(--panel); } -.channel-header { display: flex; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--grid); color: var(--blue); font: 800 11px/1 ui-monospace, "SFMono-Regular", Menlo, monospace; } -.channel-header span { color: var(--yellow); } -.channel-header span.good { color: var(--green); } -.channel-header span.clipped { color: #a12626; } -.channel-panel canvas { display: block; width: 100%; height: 210px; background: #fff; } -.channel-panel p { margin: 0; padding: 8px 12px; color: var(--muted); font: 650 10px/1.3 ui-monospace, "SFMono-Regular", Menlo, monospace; } -.ptt-readouts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } -.ptt-readouts article { border: 1px solid var(--grid); background: var(--panel); padding: 13px; display: grid; gap: 5px; } -.ptt-readouts span, .ptt-readouts small { color: var(--muted); font-size: 10px; } -.ptt-readouts strong { color: var(--blue); font: 850 clamp(28px, 4vw, 48px)/1 ui-monospace, "SFMono-Regular", Menlo, monospace; } -.ptt-guidance { margin: 0; padding: 12px; border-left: 4px solid var(--blue); background: #eff9fa; color: var(--muted); font-size: 13px; } - .connection-summary { display: flex; justify-content: space-between; gap: 16px; color: #6f7e75; font: 650 10px/1.4 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: .06em; text-transform: uppercase; } #modeChip { color: var(--blue); } #modeChip.live { color: var(--green); } @@ -208,15 +181,6 @@ button:disabled { cursor: not-allowed; opacity: .38; } .supported-inputs { color: #9dacA3; font-size: 13px; line-height: 1.55; } .supported-inputs p { margin: 12px 0 0; } .supported-inputs code { color: var(--blue); } -.board-setup { color: #9daca3; font-size: 13px; line-height: 1.55; } -.setup-callout { margin: 14px 0; padding: 12px 14px; border-left: 4px solid var(--yellow); background: #fff8d5; color: #4f4a2f; } -.setup-callout p { margin: 0 0 8px; } -.setup-callout p:last-child { margin-bottom: 0; } -.sketch-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-bottom: 8px; color: #7c8b82; font: 700 11px/1.4 ui-monospace, "SFMono-Regular", Menlo, monospace; } -.copy-sketch-button { margin-left: auto; padding: 8px 12px; border: 1px solid var(--blue); border-radius: 3px; background: transparent; color: var(--blue); font-weight: 800; cursor: pointer; } -.copy-sketch-button:hover, .copy-sketch-button:focus-visible { background: var(--blue); color: #fff; } -.copy-status { min-width: 190px; color: var(--green); } -.sketch-code { max-height: 430px; margin: 0; overflow: auto; border: 1px solid var(--grid); background: #040804; padding: 16px; color: #b9d9c5; font: 12px/1.55 ui-monospace, "SFMono-Regular", Menlo, monospace; tab-size: 2; } footer { display: flex; justify-content: space-between; gap: 24px; border-top: 1px solid var(--grid-soft); padding-top: 14px; color: #6f7d74; font-size: 12px; } footer p { margin: 0; } @@ -236,7 +200,6 @@ footer p { margin: 0; } @media (max-width: 1000px) { .topbar, footer { flex-direction: column; align-items: stretch; } .connection { min-width: 0; } - .dual-wave-grid { grid-template-columns: 1fr; } } @media (max-width: 760px) { @@ -249,13 +212,9 @@ footer p { margin: 0; } .connection { grid-template-columns: 9px minmax(0, 1fr) auto; } .connection-copy { grid-column: 2/-1; } .rate-control { grid-column: 2; } - .rate-control:has(#coachMode) { grid-column: 2/-1; } #resyncBtn { grid-column: 3; } #connectBtn { grid-column: 1/-1; width: 100%; } #waveformCanvas { height: 220px; } - .ptt-heading { flex-direction: column; } - .ptt-controls { grid-template-columns: 1fr; } - .ptt-readouts { grid-template-columns: 1fr 1fr; } } @media (max-width: 430px) { diff --git a/docs/signal-coach/signal-coach.mjs b/docs/signal-coach/signal-coach.mjs index e926860..a21f9c6 100644 --- a/docs/signal-coach/signal-coach.mjs +++ b/docs/signal-coach/signal-coach.mjs @@ -1,7 +1,6 @@ // Browser transport, browser-native coaching, and rendering for Signal Coach. -import { parsePulseLine } from './pulse-webserial-protocol.mjs?v=20260817-unor4-r2'; -import { BrowserSignalCoach, adviceFor, stateColor } from './signal-coach-core.mjs?v=20260817-unor4-r2'; -import { DualSignalCoach } from './ptt-coach-core.mjs?v=20260817-unor4-r2'; +import { parsePulseLine } from './pulse-webserial-protocol.mjs?v=20260819-single-r6'; +import { BrowserSignalCoach, adviceFor, stateColor } from './signal-coach-core.mjs?v=20260819-single-r6'; const pageOptions = new URLSearchParams(location.search); if (window.self !== window.top || pageOptions.get('embedded') === '1') { @@ -14,7 +13,6 @@ const context = canvas.getContext('2d'); const display = document.querySelector('.display'); const connectBtn = document.querySelector('#connectBtn'); const resyncBtn = document.querySelector('#resyncBtn'); -const coachMode = document.querySelector('#coachMode'); const sampleRate = document.querySelector('#sampleRate'); const statusDot = document.querySelector('#statusDot'); const statusText = document.querySelector('#statusText'); @@ -41,33 +39,10 @@ const liveQuality = document.querySelector('#liveQuality'); const liveInputFormat = document.querySelector('#liveInputFormat'); const sourceMetrics = document.querySelector('#sourceMetrics'); const rawSerial = document.querySelector('#rawSerial'); -const singleWavePanel = document.querySelector('#singleWavePanel'); -const singleReadoutPanel = document.querySelector('#singleReadoutPanel'); -const pttWorkspace = document.querySelector('#pttWorkspace'); -const proximalCanvas = document.querySelector('#proximalCanvas'); -const distalCanvas = document.querySelector('#distalCanvas'); -const proximalThresholdMode = document.querySelector('#proximalThresholdMode'); -const distalThresholdMode = document.querySelector('#distalThresholdMode'); -const proximalThreshold = document.querySelector('#proximalThreshold'); -const distalThreshold = document.querySelector('#distalThreshold'); -const minimumPtt = document.querySelector('#minimumPtt'); -const maximumPtt = document.querySelector('#maximumPtt'); -const proximalQuality = document.querySelector('#proximalQuality'); -const distalQuality = document.querySelector('#distalQuality'); -const proximalMeta = document.querySelector('#proximalMeta'); -const distalMeta = document.querySelector('#distalMeta'); -const latestPtt = document.querySelector('#latestPtt'); -const medianPtt = document.querySelector('#medianPtt'); -const acceptedPairs = document.querySelector('#acceptedPairs'); -const rejectedPairs = document.querySelector('#rejectedPairs'); -const pttGuidance = document.querySelector('#pttGuidance'); -const unoR4Setup = document.querySelector('#unoR4Setup'); -const copySketchBtn = document.querySelector('#copySketchBtn'); -const copySketchStatus = document.querySelector('#copySketchStatus'); -const unoR4Sketch = document.querySelector('#unoR4Sketch'); let port = null; let reader = null; +let readTask = null; let reading = false; let history = new Array(HISTORY_LENGTH).fill(null); let frameCount = 0; @@ -79,77 +54,19 @@ let streamTimestamp = null; let lastDeviceTimestamp = null; let resyncUiTimer = null; const browserCoach = new BrowserSignalCoach(); -let dualCoach = new DualSignalCoach(); -let proximalHistory = new Array(HISTORY_LENGTH).fill(null); -let distalHistory = new Array(HISTORY_LENGTH).fill(null); -let lastPtt = null; -let lastPttRenderAt = -Infinity; function resizeCanvas() { const rectangle = canvas.getBoundingClientRect(); - if (rectangle.width && rectangle.height) { - const dpr = window.devicePixelRatio || 1; - const width = Math.max(1, Math.round(rectangle.width * dpr)); - const height = Math.max(1, Math.round(rectangle.height * dpr)); - if (canvas.width !== width || canvas.height !== height) { - canvas.width = width; - canvas.height = height; - context.setTransform(dpr, 0, 0, dpr, 0, 0); - } - drawWaveform(); - } - resizePttCanvas(proximalCanvas, proximalHistory, lastPtt?.proximal?.threshold ?? 550, '#087e91'); - resizePttCanvas(distalCanvas, distalHistory, lastPtt?.distal?.threshold ?? 550, '#997800'); -} - -function resizePttCanvas(target, values, threshold, color) { - const rectangle = target.getBoundingClientRect(); if (!rectangle.width || !rectangle.height) return; const dpr = window.devicePixelRatio || 1; const width = Math.max(1, Math.round(rectangle.width * dpr)); const height = Math.max(1, Math.round(rectangle.height * dpr)); - const drawing = target.getContext('2d'); - if (target.width !== width || target.height !== height) { - target.width = width; - target.height = height; - drawing.setTransform(dpr, 0, 0, dpr, 0, 0); - } - drawPttWaveform(target, drawing, values, threshold, color); -} - -function drawPttWaveform(target, drawing, values, threshold, color) { - const width = target.getBoundingClientRect().width; - const height = target.getBoundingClientRect().height; - drawing.fillStyle = '#fff'; - drawing.fillRect(0, 0, width, height); - drawing.strokeStyle = '#dce7df'; - drawing.lineWidth = 1; - for (let index = 1; index < 4; index += 1) { - drawing.beginPath(); drawing.moveTo(0, (height * index) / 4); drawing.lineTo(width, (height * index) / 4); drawing.stroke(); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + context.setTransform(dpr, 0, 0, dpr, 0, 0); } - const present = values.filter((value) => value !== null); - if (present.length < 2) return; - const low = Math.max(0, Math.min(...present, threshold) - 35); - const high = Math.min(1023, Math.max(...present, threshold) + 35); - const range = Math.max(1, high - low); - const thresholdY = height - ((threshold - low) / range) * height; - drawing.save(); - drawing.setLineDash([6, 5]); - drawing.strokeStyle = '#a12626'; - drawing.beginPath(); drawing.moveTo(0, thresholdY); drawing.lineTo(width, thresholdY); drawing.stroke(); - drawing.restore(); - drawing.strokeStyle = color; - drawing.lineWidth = 2.5; - drawing.lineJoin = 'round'; - drawing.beginPath(); - let started = false; - values.forEach((value, index) => { - if (value === null) return; - const x = (index / (HISTORY_LENGTH - 1)) * width; - const y = height - ((value - low) / range) * height; - if (!started) { drawing.moveTo(x, y); started = true; } else drawing.lineTo(x, y); - }); - drawing.stroke(); + drawWaveform(); } function drawWaveform() { @@ -233,10 +150,6 @@ function updateCoach(coach) { } function updateFrame(frame, line, explicitTimestamp = null) { - if (frame.secondarySignal !== undefined) { - updatePttFrame(frame, line, explicitTimestamp); - return; - } const timestamp = nextSampleTimestamp(frame, explicitTimestamp); frameCount += 1; if (frame.sequence !== null && lastSequence !== null && frame.sequence !== ((lastSequence + 1) & 0x7fffffff)) gaps += 1; @@ -276,116 +189,6 @@ function updateFrame(frame, line, explicitTimestamp = null) { } } -function qualityLabel(target, quality) { - target.textContent = quality.state; - target.className = quality.state === 'GOOD' ? 'good' : quality.state === 'CLIPPED' ? 'clipped' : ''; -} - -function updatePttFrame(frame, line, explicitTimestamp = null) { - if (coachMode.value !== 'ptt') { - coachMode.value = 'ptt'; - setCoachMode('ptt'); - } - const timestamp = nextSampleTimestamp(frame, explicitTimestamp); - frameCount += 1; - lastFrame = frame; - lastPtt = dualCoach.update(frame.signal, frame.secondarySignal, timestamp); - proximalHistory.shift(); proximalHistory.push(lastPtt.proximal.signal10); - distalHistory.shift(); distalHistory.push(lastPtt.distal.signal10); - if (timestamp - lastPttRenderAt < 33 && lastPtt.ptt.latest === null) return; - lastPttRenderAt = timestamp; - resizePttCanvas(proximalCanvas, proximalHistory, lastPtt.proximal.threshold, '#087e91'); - resizePttCanvas(distalCanvas, distalHistory, lastPtt.distal.threshold, '#997800'); - qualityLabel(proximalQuality, lastPtt.proximalQuality); - qualityLabel(distalQuality, lastPtt.distalQuality); - proximalMeta.textContent = `Signal ${lastPtt.proximal.signal10} · threshold ${lastPtt.proximal.threshold} ${lastPtt.proximal.thresholdMode}`; - distalMeta.textContent = `Signal ${lastPtt.distal.signal10} · threshold ${lastPtt.distal.threshold} ${lastPtt.distal.thresholdMode}`; - if (lastPtt.ptt.latest !== null) latestPtt.textContent = lastPtt.ptt.latest.toFixed(1); - medianPtt.textContent = lastPtt.ptt.median === null ? '--' : lastPtt.ptt.median.toFixed(1); - acceptedPairs.textContent = String(lastPtt.ptt.accepted); - rejectedPairs.textContent = `${lastPtt.ptt.rejected} / ${lastPtt.ptt.unmatched}`; - if (lastPtt.proximalQuality.state === 'CLIPPED' || lastPtt.distalQuality.state === 'CLIPPED') { - pttGuidance.textContent = 'A channel is clipping at the ADC rail. Reduce pressure, check power, and reposition before trusting PTT.'; - } else if (lastPtt.proximalQuality.state !== 'GOOD' || lastPtt.distalQuality.state !== 'GOOD') { - pttGuidance.textContent = 'Hold both sensors lightly and still until both channels report GOOD.'; - } else if (lastPtt.ptt.accepted === 0) { - pttGuidance.textContent = 'Both waves look usable. Waiting for repeatable same-cycle beat pairs.'; - } else { - pttGuidance.textContent = 'Both channels pass signal quality. PTT is an educational timing measurement, not blood pressure.'; - } - const simulated = frame.mode === 'SIM'; - waitingOverlay.hidden = true; - resyncBtn.disabled = true; - statusDot.className = `status-dot ${simulated ? 'simulation' : 'connected'}`; - statusText.textContent = simulated ? 'Simulated two-sensor replay' : 'Two PulseSensors connected'; - sourceMeta.textContent = `${frame.format} · 250000 baud`; - sourceName.textContent = simulated ? 'SIM DUAL PULSE' : 'Dual PulseSensor'; - simulationWarning.hidden = !simulated; - modeChip.textContent = simulated ? 'SIM PTT' : 'LIVE PTT'; - modeChip.className = simulated ? 'sim' : 'live'; - frameMeta.textContent = `${frameCount.toLocaleString()} synchronized dual samples`; - signalMeta.textContent = `A0 ${lastPtt.proximal.signal10} · A1 ${lastPtt.distal.signal10}`; - liveSignal.textContent = `${lastPtt.proximal.signal10} / ${lastPtt.distal.signal10}`; - liveBeat.textContent = `${Number(lastPtt.proximal.qualifiedBeat)} / ${Number(lastPtt.distal.qualifiedBeat)}`; - liveIbi.textContent = lastPtt.ptt.latest === null ? '--' : lastPtt.ptt.latest.toFixed(1); - liveQuality.textContent = `${lastPtt.proximalQuality.state} / ${lastPtt.distalQuality.state}`; - liveInputFormat.textContent = frame.format; - sourceMetrics.textContent = 'Browser detector · one-to-one pairing'; - rawSerial.textContent = line; -} - -function resetPttState() { - dualCoach = new DualSignalCoach(); - proximalHistory = new Array(HISTORY_LENGTH).fill(null); - distalHistory = new Array(HISTORY_LENGTH).fill(null); - lastPtt = null; - lastPttRenderAt = -Infinity; - latestPtt.textContent = '--'; - medianPtt.textContent = '--'; - acceptedPairs.textContent = '0'; - rejectedPairs.textContent = '0 / 0'; - applyPttSettings(); -} - -function applyPttSettings() { - dualCoach.configureChannel('proximal', proximalThresholdMode.value, Number(proximalThreshold.value)); - dualCoach.configureChannel('distal', distalThresholdMode.value, Number(distalThreshold.value)); - dualCoach.setPairingWindow(Number(minimumPtt.value), Number(maximumPtt.value)); -} - -function setCoachMode(mode) { - const ptt = mode === 'ptt'; - singleWavePanel.hidden = ptt; - singleReadoutPanel.hidden = ptt; - pttWorkspace.hidden = !ptt; - unoR4Setup.hidden = !ptt; - sampleRate.value = ptt ? '500' : '50'; - sourceMeta.textContent = ptt ? 'Dual PTT1 input · 250000 baud' : 'Single pulse-wave input · 115200 baud'; - resyncBtn.hidden = ptt; - waitingOverlay.textContent = ptt ? 'CONNECT A PTT1 DUAL STREAM' : 'CONNECT A PULSE STREAM'; - if (ptt) { - resetPttState(); - requestAnimationFrame(resizeCanvas); - } -} - -copySketchBtn.addEventListener('click', async () => { - const sketch = unoR4Sketch.textContent.trim(); - try { - await navigator.clipboard.writeText(sketch); - copySketchBtn.textContent = 'Copied'; - copySketchStatus.textContent = 'Paste into a new Arduino sketch.'; - } catch { - const selection = window.getSelection(); - const range = document.createRange(); - range.selectNodeContents(unoR4Sketch); - selection.removeAllRanges(); - selection.addRange(range); - copySketchStatus.textContent = 'Sketch selected. Press Command-C to copy.'; - } - setTimeout(() => { copySketchBtn.textContent = 'Copy sketch'; }, 1800); -}); - function showError(message) { errorMessage.textContent = message; errorMessage.hidden = false; @@ -440,10 +243,11 @@ async function readLoop() { const decoder = new TextDecoder(); let buffer = ''; while (reading && port?.readable) { - reader = port.readable.getReader(); + const activeReader = port.readable.getReader(); + reader = activeReader; try { while (reading) { - const { value, done } = await reader.read(); + const { value, done } = await activeReader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split(/\r?\n/); @@ -461,24 +265,32 @@ async function readLoop() { } } } finally { - reader.releaseLock(); - reader = null; + activeReader.releaseLock(); + if (reader === activeReader) reader = null; } } } -async function disconnect() { +async function finalizeConnection(targetPort) { + if (!targetPort || targetPort !== port) return false; reading = false; - if (reader) await reader.cancel(); - const activePort = port; port = null; - if (activePort) await activePort.close(); + const activeReader = reader; + const activeReadTask = readTask; + if (activeReader) { + try { await activeReader.cancel(); } catch { /* The stream already failed or closed. */ } + } + if (activeReadTask) { + try { await activeReadTask; } catch { /* The caller reports read failures after cleanup. */ } + } + if (readTask === activeReadTask) readTask = null; + try { await targetPort.close(); } catch { /* The device may already be gone or never opened. */ } connectBtn.textContent = 'Connect'; statusDot.className = 'status-dot'; statusText.textContent = 'Disconnected'; - coachMode.disabled = false; - sourceMeta.textContent = coachMode.value === 'ptt' ? 'Dual PTT1 input · 250000 baud' : 'Single pulse-wave input · 115200 baud'; + sourceMeta.textContent = 'Pulse-wave input · 115200 baud'; resetDisconnectedDisplay(); + return true; } async function connect() { @@ -486,10 +298,11 @@ async function connect() { showError('Web Serial is unavailable. Use desktop Chrome, Edge, or Brave over HTTPS.'); return; } + let requestedPort = null; try { - port = await navigator.serial.requestPort(); - const baudRate = coachMode.value === 'ptt' ? 250000 : 115200; - await port.open({ baudRate, bufferSize: 16384 }); + requestedPort = await navigator.serial.requestPort(); + port = requestedPort; + await requestedPort.open({ baudRate: 115200, bufferSize: 8192 }); history = new Array(HISTORY_LENGTH).fill(null); frameCount = 0; gaps = 0; @@ -498,38 +311,24 @@ async function connect() { reading = true; errorMessage.hidden = true; sampleRate.disabled = true; - coachMode.disabled = true; connectBtn.textContent = 'Disconnect'; statusText.textContent = 'Waiting for samples'; - sourceMeta.textContent = `Connected · USB serial · ${baudRate} baud`; + sourceMeta.textContent = 'Connected · USB serial · 115200 baud'; waitingOverlay.textContent = 'WAITING FOR PULSE DATA'; - await readLoop(); + readTask = readLoop(); + await readTask; + if (port === requestedPort) await finalizeConnection(requestedPort); } catch (error) { - reading = false; - const failedPort = port; - port = null; - if (failedPort) { - try { await failedPort.close(); } catch { /* Port never opened or already closed. */ } - } - sampleRate.disabled = false; - coachMode.disabled = false; - connectBtn.textContent = 'Connect'; + if (requestedPort && port === requestedPort) await finalizeConnection(requestedPort); if (error.name !== 'NotFoundError') showError(`Connection failed: ${error.message}`); } } connectBtn.addEventListener('click', () => { - if (port) disconnect().catch((error) => showError(error.message)); + if (port) finalizeConnection(port).catch((error) => showError(error.message)); else connect().catch((error) => showError(error.message)); }); -coachMode.addEventListener('change', () => setCoachMode(coachMode.value)); -[proximalThresholdMode, distalThresholdMode, proximalThreshold, distalThreshold, minimumPtt, maximumPtt].forEach((control) => { - control.addEventListener('change', () => { - try { applyPttSettings(); errorMessage.hidden = true; } catch (error) { showError(error.message); } - }); -}); - resyncBtn.addEventListener('click', () => { if (streamTimestamp === null) return; lastCoach = browserCoach.resync(streamTimestamp); @@ -546,7 +345,7 @@ resyncBtn.addEventListener('click', () => { }); navigator.serial?.addEventListener('disconnect', (event) => { - if (event.target === port) disconnect().catch(() => {}); + finalizeConnection(event.target).catch(() => {}); }); window.addEventListener('resize', resizeCanvas); @@ -554,11 +353,7 @@ resizeCanvas(); function startReplay() { connectBtn.hidden = true; - const pttReplay = pageOptions.get('mode') === 'ptt'; - coachMode.value = pttReplay ? 'ptt' : 'single'; - setCoachMode(coachMode.value); - coachMode.disabled = true; - sampleRate.value = pttReplay ? '500' : '50'; + sampleRate.value = '50'; sampleRate.disabled = true; resetCoachState(); let sequence = 0; @@ -578,22 +373,12 @@ function startReplay() { timestampMs: elapsed, signal, sourceBpm: null, sourceIbi: null, sourceBeat: null, sourceQuality: null, sourceState: null, }; - if (pttReplay) { - const distalPhase = ((elapsed - 40) % 833 + 833) % 833; - let distalPulse = 0; - if (distalPhase < 55) distalPulse = distalPhase * 21; - else if (distalPhase < 145) distalPulse = 1155 - (distalPhase - 55) * 10; - else if (distalPhase < 205) distalPulse = 255 - (distalPhase - 145) * 4; - frame.secondarySignal = Math.max(0, Math.min(4095, 1880 + Math.max(0, distalPulse))); - frame.format = 'Browser bench PTT1'; - } updateFrame(frame, String(signal)); sequence = (sequence + 1) & 0x7fffffff; }, 20); return timer; } -if (pageOptions.get('mode') === 'ptt') coachMode.value = 'ptt'; if (pageOptions.get('bench') === '1') startReplay(); window.__PULSE_COACH_QA__ = { @@ -611,7 +396,16 @@ window.__PULSE_COACH_QA__ = { streamTimestamp = null; lastDeviceTimestamp = null; }, - getState: () => ({ frameCount, gaps, lastFrame, coach: lastCoach, ptt: lastPtt, mode: coachMode.value, canvasWidth: canvas.width, canvasHeight: canvas.height }), + getState: () => ({ + frameCount, + gaps, + lastFrame, + coach: lastCoach, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + connected: Boolean(port), + button: connectBtn.textContent, + status: statusText.textContent, + source: sourceMeta.textContent, + }), }; - -setCoachMode(coachMode.value); diff --git a/docs/signal-coach/source-pack.test.mjs b/docs/signal-coach/source-pack.test.mjs index b629b6c..4de8f33 100644 --- a/docs/signal-coach/source-pack.test.mjs +++ b/docs/signal-coach/source-pack.test.mjs @@ -11,13 +11,10 @@ const required = [ 'docs/signal-coach/signal-coach.css', 'docs/signal-coach/signal-coach.mjs', 'docs/signal-coach/signal-coach-core.mjs', - 'docs/signal-coach/ptt-coach-core.mjs', 'docs/signal-coach/pulse-webserial-protocol.mjs', 'docs/signal-coach/protocol.test.mjs', 'docs/signal-coach/signal-coach-core.test.mjs', - 'docs/signal-coach/ptt-coach-core.test.mjs', 'examples/SignalCoachWebSerial/SignalCoachWebSerial.ino', - 'examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino', 'resources/signal-coach/shopify/page-signal-coach.html', 'LICENSE', ]; @@ -28,7 +25,6 @@ const guide = await readFile(path.join(folder, 'README.md'), 'utf8'); const dashboard = await readFile(path.join(folder, 'index.html'), 'utf8'); const browserApp = await readFile(path.join(folder, 'signal-coach.mjs'), 'utf8'); const sketch = await readFile(path.join(root, 'examples/SignalCoachWebSerial/SignalCoachWebSerial.ino'), 'utf8'); -const dualSketch = await readFile(path.join(root, 'examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino'), 'utf8'); const tutorial = await readFile(path.join(root, 'resources/signal-coach/shopify/page-signal-coach.html'), 'utf8'); for (const relativePath of required) { @@ -43,6 +39,30 @@ assert.match(dashboard, /Blue Adjust<\/b>/); assert.match(dashboard, /Light touch/); assert.match(sketch, /Serial\.println\(analogRead\(PULSE_PIN\)\)/); assert.match(sketch, /SAMPLE_PERIOD_MS = 20/); +assert.match(sketch, /nextSampleAt = millis\(\)/); +const normalizedTutorial = tutorial.replaceAll('<', '<').replaceAll('>', '>'); +const inlineSketch = normalizedTutorial.match(/
(\/\* PulseSensor Signal Coach[\s\S]*?)<\/code><\/pre>/)?.[1];
+assert.ok(inlineSketch, 'Shopify source pack should include the inline Signal Coach sender');
+const functionalSketch = (source) => source
+  .replace(/\/\*[\s\S]*?\*\//g, '')
+  .replace(/\/\/.*$/gm, '')
+  .replace(/\s+/g, '');
+assert.equal(
+  functionalSketch(inlineSketch),
+  functionalSketch(sketch),
+  'Shopify inline and checked-in Signal Coach senders must remain functionally identical',
+);
+for (const schedulingLine of [
+  'const unsigned long SAMPLE_PERIOD_MS = 20;',
+  'unsigned long nextSampleAt = 0;',
+  'nextSampleAt = millis();',
+  'if ((long)(millis() - nextSampleAt) < 0) return;',
+  'nextSampleAt += SAMPLE_PERIOD_MS;',
+  'Serial.println(analogRead(PULSE_PIN));',
+]) {
+  assert.ok(sketch.includes(schedulingLine), `checked-in sender should include ${schedulingLine}`);
+  assert.ok(normalizedTutorial.includes(schedulingLine), `Shopify sender should include ${schedulingLine}`);
+}
 assert.match(tutorial, /Hack Signal Coach at home/i);
 assert.match(tutorial, /SignalCoachWebSerial\.ino/);
 assert.match(tutorial, /docs\/signal-coach\/README\.md/);
@@ -50,34 +70,28 @@ assert.match(tutorial, /another finger or person/);
 assert.doesNotMatch(tutorial, /
/); assert.doesNotMatch(tutorial, /Signal Coach tester preview/); assert.doesNotMatch(tutorial, /See the wave\. Improve it\./); -assert.match(tutorial, /1 Upload the one- or two-sensor sketch/); -assert.match(tutorial, /3 Choose the mode and Connect/); +assert.match(tutorial, /1 Upload the sketch/); +assert.match(tutorial, /3 Click Connect/); assert.match(tutorial, /Loading Signal Coach/); assert.match(tutorial, /Taking longer than expected/); assert.match(tutorial, /Open Signal Coach/); assert.match(tutorial, /Coach not loading\? Open directly\./); assert.match(tutorial, /pulsesensor-signal-coach-ready/); assert.match(dashboard, /pulsesensor-signal-coach-ready/); -assert.match(dashboard, /Two sensors \/ PTT/); -assert.match(dashboard, /id="proximalThresholdMode"/); -assert.match(dashboard, /id="distalThresholdMode"/); -assert.match(dashboard, /UNO R4 WiFi setup/); -assert.match(dashboard, /Flash from this page: not yet/); -assert.match(dashboard, /id="copySketchBtn"/); -assert.match(dashboard, /Serial\.begin\(250000\)/); -assert.match(dashboard, /Serial\.print\("PTT1,"\)/); -assert.match(dashboard, /ARDUINO_UNOR4_WIFI/); -assert.match(dualSketch, /analogReadResolution\(10\)/); +assert.doesNotMatch(dashboard, /Pulse Transit Time|Two sensors \/ PTT|PTT1/i); +assert.doesNotMatch(guide, /Pulse Transit Time|Two sensors \/ PTT|PTT1/i); +assert.doesNotMatch(browserApp, /DualSignalCoach|secondarySignal|coachMode/); +assert.doesNotMatch(tutorial, /Pulse Transit Time|Two sensors \/ PTT|PTT1/i); assert.doesNotMatch(browserApp, /ResizeObserver\(resizeCanvas\)/); assert.match(browserApp, /if \(!width \|\| !height\) return;/); -assert.match(tutorial, /SignalCoachDualWebSerial\.ino/); -assert.match(tutorial, /timing experiment, not a blood-pressure reading/i); +assert.match(browserApp, /async function finalizeConnection\(targetPort\)/); +assert.match(browserApp, /if \(!targetPort \|\| targetPort !== port\) return false;/); +assert.match(browserApp, /finalizeConnection\(event\.target\)/); const releaseVersion = dashboard.match(/signal-coach\.mjs\?v=([a-z0-9-]+)/i)?.[1]; assert.ok(releaseVersion, 'dashboard should version its browser module'); assert.ok(dashboard.includes(`signal-coach.css?v=${releaseVersion}`)); assert.ok(browserApp.includes(`pulse-webserial-protocol.mjs?v=${releaseVersion}`)); assert.ok(browserApp.includes(`signal-coach-core.mjs?v=${releaseVersion}`)); -assert.ok(browserApp.includes(`ptt-coach-core.mjs?v=${releaseVersion}`)); console.log('Signal Coach public source-pack tests passed'); diff --git a/examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino b/examples/PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino similarity index 77% rename from examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino rename to examples/PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino index 1a815f8..1f69e2c 100644 --- a/examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino +++ b/examples/PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino @@ -1,9 +1,9 @@ /* - PulseSensor Signal Coach — dual-channel PTT sender + PulseSensor Pulse Transit Time — dual-channel Web Serial sender - Sends two raw PulseSensor readings plus the board sample timestamp. Signal - Coach performs threshold crossing, signal-quality checks, beat pairing, and - Pulse Transit Time (PTT) measurement in the browser. + Sends two raw PulseSensor readings plus the board sample timestamp. The + Pulse Transit Time Lab performs threshold crossing, signal-quality checks, + beat pairing, and educational PTT measurement in the browser. Test target: Arduino UNO R4 WiFi. Also works on classic Uno/Nano/Mega boards. @@ -13,7 +13,7 @@ Both red wires -> 5V Both black wires -> GND - Open Signal Coach, choose "Two sensors / PTT", then connect at 250000 baud. + Open Pulse Transit Time Lab, then connect at 250000 baud. Educational experiment only. Not a medical device or blood-pressure tool. World Famous Electronics LLC @@ -28,7 +28,7 @@ unsigned long nextSampleAt = 0; void setup() { #if defined(ARDUINO_UNOR4_WIFI) - // Signal Coach thresholds use the familiar 0-1023 Arduino ADC scale. + // The PTT Lab thresholds use the familiar 0-1023 Arduino ADC scale. analogReadResolution(10); #endif Serial.begin(250000); diff --git a/examples/SignalCoachWebSerial/SignalCoachWebSerial.ino b/examples/SignalCoachWebSerial/SignalCoachWebSerial.ino index 163a5de..196e416 100644 --- a/examples/SignalCoachWebSerial/SignalCoachWebSerial.ino +++ b/examples/SignalCoachWebSerial/SignalCoachWebSerial.ino @@ -28,6 +28,7 @@ unsigned long nextSampleAt = 0; void setup() { Serial.begin(115200); delay(1000); + nextSampleAt = millis(); } void loop() { diff --git a/resources/CodeDescriptions.md b/resources/CodeDescriptions.md index 37032c5..6db1f7b 100644 --- a/resources/CodeDescriptions.md +++ b/resources/CodeDescriptions.md @@ -7,7 +7,7 @@ This index lists the example sketches that ship with the current library. Start | [GettingStartedProject](../examples/GettingStartedProject/GettingStartedProject.ino) | Raw pulse signal in the Serial Plotter and heartbeat LED | First setup and signal troubleshooting | | [Getting_BPM_to_Monitor](../examples/Getting_BPM_to_Monitor/Getting_BPM_to_Monitor.ino) | BPM and beat messages in the Serial Monitor | A short beginner BPM sketch | | [SignalCoachWebSerial](../examples/SignalCoachWebSerial/SignalCoachWebSerial.ino) | Raw samples over Web Serial for Signal Coach | Browser-based signal coaching without library-side beat detection | -| [SignalCoachDualWebSerial](../examples/SignalCoachDualWebSerial/SignalCoachDualWebSerial.ino) | Synchronized A0/A1 samples for Signal Coach | Two-sensor signal comparison and educational PTT experiments | +| [PulseTransitTimeWebSerial](../examples/PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino) | Synchronized A0/A1 samples for Pulse Transit Time Lab | Two-sensor educational PTT experiments | | [PulseSensor_BPM](../examples/PulseSensor_BPM/PulseSensor_BPM.ino) | Full library BPM setup and serial output | Standard BPM projects | | [PulseSensor_ATtiny85_Serial](../examples/PulseSensor_ATtiny85_Serial/PulseSensor_ATtiny85_Serial.ino) | Beat detection with serial output on ATtiny85 | ATtiny85 projects with serial output | | [PulseSensor_ATtiny85_noSerial](../examples/PulseSensor_ATtiny85_noSerial/PulseSensor_ATtiny85_noSerial.ino) | Beat detection without serial output on ATtiny85 | Small standalone ATtiny85 projects | diff --git a/resources/signal-coach/shopify/page-signal-coach.html b/resources/signal-coach/shopify/page-signal-coach.html index 3eb6446..b5f88e9 100644 --- a/resources/signal-coach/shopify/page-signal-coach.html +++ b/resources/signal-coach/shopify/page-signal-coach.html @@ -16,8 +16,8 @@
-

1 Upload the one- or two-sensor sketch2 Close Serial Monitor3 Choose the mode and Connect4 Place the sensor gently

-

Already sending pulse data? Start at Connect. Desktop Chrome, Edge, or Brave. Coach not loading? Open directly.

+

1 Upload the sketch2 Close Serial Monitor3 Click Connect4 Place the sensor gently

+

Already sending pulse data? Start at Connect. Desktop Chrome, Edge, or Brave. Coach not loading? Open directly.

@@ -32,12 +32,12 @@

Loading Signal Coach

Taking longer than expected

Open Signal Coach directly and keep going. Your Arduino setup stays the same.

- +
@@ -80,9 +80,9 @@

Taking longer than expected

-

BETA

+

BETA

-

What the coach does: It reads one live pulse wave for BPM, or compares two synchronized sensors for an educational Pulse Transit Time experiment.

+

What the coach does: It reads your live pulse wave and tells you when to adjust the sensor, hold still, or trust the BPM.

Pulse samples stay in this browser tab. This beta runs alongside the current WebSerial page. Wave visible but not locking? Use Re-sync Signal.

@@ -133,7 +133,6 @@

Hack Signal Coach at home

Read the home-build guide View all Chrome coach source Open the Arduino sketch - Open the two-sensor sketch Download everything
python3 -m http.server 8000
@@ -143,38 +142,6 @@ 

Hack Signal Coach at home

Arduino / ESP32 setup

No Arduino library is required for the simplest stream. Choose the signal pin for your board, upload this sketch, select 50 samples/s above, and let Chrome do the beat detection and coaching. The same sketch is saved in the repo as SignalCoachWebSerial.ino.

-

Two sensors / Pulse Transit Time

-

For a two-sensor experiment, connect the proximal signal to A0 and the distal signal to A1, upload SignalCoachDualWebSerial.ino, and choose Two sensors / PTT in Signal Coach. Start with an earlobe at A0 and fingertip at A1. The dashboard checks both waves, exposes fixed or adaptive thresholds, rejects clipped channels, and pairs each proximal beat with at most one distal beat in the same cardiac cycle.

-

UNO R4 WiFi: the complete sketch is included inside Signal Coach with a Copy button. The first upload still needs Arduino IDE 2, Arduino CLI, or Arduino Cloud Editor plus its local Cloud Agent. The dashboard Connect button reads Web Serial after upload; it does not currently flash the board.

-
const int PROXIMAL_PIN = A0;
-const int DISTAL_PIN = A1;
-const unsigned long SAMPLE_PERIOD_US = 2000;
-unsigned long nextSampleAt = 0;
-
-void setup() {
-#if defined(ARDUINO_UNOR4_WIFI)
-  analogReadResolution(10);
-#endif
-  Serial.begin(250000);
-  delay(1000);
-  nextSampleAt = micros();
-}
-
-void loop() {
-  const unsigned long now = micros();
-  if ((long)(now - nextSampleAt) < 0) return;
-  nextSampleAt += SAMPLE_PERIOD_US;
-  const int proximal = analogRead(PROXIMAL_PIN);
-  const int distal = analogRead(DISTAL_PIN);
-  Serial.print("PTT1,");
-  Serial.print(now);
-  Serial.print(',');
-  Serial.print(proximal);
-  Serial.print(',');
-  Serial.println(distal);
-}
-

PTT is a timing experiment, not a blood-pressure reading. Do not use it for diagnosis, treatment, or health decisions.

-
@@ -196,6 +163,7 @@

Two sensors / Pulse Transit Time

void setup() { Serial.begin(115200); delay(1000); + nextSampleAt = millis(); } void loop() {
BoardPULSE_PINSensor power