+
+
+
PulseSensor · Two-channel Web Serial
+
RESEARCH BETA August 19, 2026
+
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
+
+
Connect two-sensor stream
+
+
+
+
+
+ SIMULATED BENCH SIGNAL — NOT A PERSON. This mode checks the interface only.
+
+
+ EDUCATIONAL TIMING EXPERIMENT — NOT BLOOD PRESSURE AND NOT A MEDICAL DEVICE.
+
+
+
+
+
+
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
+ Threshold mode
+ Fixed Adaptive
+
+ Threshold
+
+
+ A1 · Distal
+ Threshold mode
+ Fixed Adaptive
+
+ Threshold
+
+
+ Pairing window
+ Minimum ms
+ Maximum ms
+
+
+
+
+
+
+
+ Signal -- · threshold 550 fixed
+
+
+
+
+ Signal -- · threshold 550 fixed
+
+
+
+
+
Latest PTT -- milliseconds
+
Rolling median -- last 20 accepted pairs
+
Accepted 0 quality + timing pass
+
Rejected / unmatched 0 / 0 never 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
+ Copy sketch
+
+
+ /* 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 frame waiting 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 @@