diff --git a/README.md b/README.md index bdc2dfb..b5f73f4 100644 --- a/README.md +++ b/README.md @@ -64,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/pulse-transit-time/pulse-transit-time-core.mjs b/docs/pulse-transit-time/pulse-transit-time-core.mjs new file mode 100644 index 0000000..c0fd7cd --- /dev/null +++ b/docs/pulse-transit-time/pulse-transit-time-core.mjs @@ -0,0 +1,134 @@ +import { BrowserSignalCoach } from '../signal-coach/signal-coach-core.mjs?v=20260819-ptt-r1'; + +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 PulseTransitTimeCoach { + 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/pulse-transit-time/pulse-transit-time-core.test.mjs b/docs/pulse-transit-time/pulse-transit-time-core.test.mjs new file mode 100644 index 0000000..0e107b3 --- /dev/null +++ b/docs/pulse-transit-time/pulse-transit-time-core.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +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; + 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 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 = 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 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('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/index.html b/docs/signal-coach/index.html index b892951..9586c84 100644 --- a/docs/signal-coach/index.html +++ b/docs/signal-coach/index.html @@ -7,7 +7,7 @@ PulseSensor Signal Coach - +
@@ -128,6 +128,6 @@

Live PulseSensor display

setTimeout(announceReady, 500); } - + 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.mjs b/docs/signal-coach/signal-coach.mjs index 581fd35..a21f9c6 100644 --- a/docs/signal-coach/signal-coach.mjs +++ b/docs/signal-coach/signal-coach.mjs @@ -1,6 +1,6 @@ // 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=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') { @@ -42,6 +42,7 @@ const rawSerial = document.querySelector('#rawSerial'); let port = null; let reader = null; +let readTask = null; let reading = false; let history = new Array(HISTORY_LENGTH).fill(null); let frameCount = 0; @@ -56,16 +57,22 @@ const browserCoach = new BrowserSignalCoach(); function resizeCanvas() { const rectangle = canvas.getBoundingClientRect(); + if (!rectangle.width || !rectangle.height) return; 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(); } 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); @@ -236,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/); @@ -257,23 +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'; sourceMeta.textContent = 'Pulse-wave input · 115200 baud'; resetDisconnectedDisplay(); + return true; } async function connect() { @@ -281,9 +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(); - await port.open({ baudRate: 115200, bufferSize: 8192 }); + 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; @@ -296,22 +315,17 @@ async function connect() { statusText.textContent = 'Waiting for samples'; 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; - 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)); }); @@ -331,11 +345,10 @@ resyncBtn.addEventListener('click', () => { }); navigator.serial?.addEventListener('disconnect', (event) => { - if (event.target === port) disconnect().catch(() => {}); + finalizeConnection(event.target).catch(() => {}); }); window.addEventListener('resize', resizeCanvas); -new ResizeObserver(resizeCanvas).observe(canvas); resizeCanvas(); function startReplay() { @@ -383,5 +396,16 @@ 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, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + connected: Boolean(port), + button: connectBtn.textContent, + status: statusText.textContent, + source: sourceMeta.textContent, + }), }; diff --git a/docs/signal-coach/source-pack.test.mjs b/docs/signal-coach/source-pack.test.mjs index 402cddd..4de8f33 100644 --- a/docs/signal-coach/source-pack.test.mjs +++ b/docs/signal-coach/source-pack.test.mjs @@ -39,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/);
@@ -54,6 +78,15 @@ 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.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(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');
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/PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino b/examples/PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino
new file mode 100644
index 0000000..1f69e2c
--- /dev/null
+++ b/examples/PulseTransitTimeWebSerial/PulseTransitTimeWebSerial.ino
@@ -0,0 +1,53 @@
+/*
+  PulseSensor Pulse Transit Time — dual-channel Web Serial sender
+
+  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.
+
+  Wiring:
+    Proximal PulseSensor purple -> A0 (earlobe recommended)
+    Distal PulseSensor purple   -> A1 (fingertip recommended)
+    Both red wires              -> 5V
+    Both black wires            -> GND
+
+  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
+  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() {
+#if defined(ARDUINO_UNOR4_WIFI)
+  // The PTT Lab thresholds use the familiar 0-1023 Arduino ADC scale.
+  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);
+}
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 71eee8c..6db1f7b 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 |
+| [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/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..b5f88e9 100644
--- a/resources/signal-coach/shopify/page-signal-coach.html
+++ b/resources/signal-coach/shopify/page-signal-coach.html
@@ -17,7 +17,7 @@
 

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.

+

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.

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

Arduino / ESP32 setup

void setup() { Serial.begin(115200); delay(1000); + nextSampleAt = millis(); } void loop() { 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 };