Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/capture-web/e2e/ambient-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
resolveLateAudio
} from "./ambient-browser-fixture.js";

const appUrl = "/phenometric/";
const appUrl = "/phenometrix/";

async function consentAndStart(page: Page): Promise<void> {
await page.locator("#consent-checkbox").check();
Expand Down
2 changes: 1 addition & 1 deletion apps/capture-web/e2e/static-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
const dist = resolve(
fileURLToPath(new URL("../dist", import.meta.url))
);
const mount = "/phenometric/";
const mount = "/phenometrix/";
const contentTypes: Record<string, string> = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
Expand Down
1 change: 1 addition & 0 deletions apps/capture-web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ <h1 id="report-title">Ambient session measurement report</h1>
<p id="report-boundary" class="report-boundary"></p>
<p id="report-source" class="report-source"></p>
</div>
<button id="copy-diagnostics" class="secondary-button" type="button" hidden>Copy capture diagnostics</button>
<button id="reset-button" class="secondary-button" type="button">Clear and start again</button>
</header>
<section id="report-sections" class="report-sections"></section>
Expand Down
4 changes: 2 additions & 2 deletions apps/capture-web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ export default defineConfig({
fullyParallel: false,
workers: 1,
use: {
baseURL: "http://127.0.0.1:4173/phenometric/",
baseURL: "http://127.0.0.1:4173/phenometrix/",
channel: "chrome",
headless: true,
trace: "retain-on-failure"
},
webServer: {
command: "pnpm exec tsx e2e/static-server.ts",
url: "http://127.0.0.1:4173/phenometric/",
url: "http://127.0.0.1:4173/phenometrix/",
reuseExistingServer: false,
timeout: 30_000
}
Expand Down
159 changes: 159 additions & 0 deletions apps/capture-web/src/ambient-core-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
finalizeAmbientMetrics,
type FaceScreeningDiagnostics,
type VoiceScreeningDiagnostics,
type AmbientFaceCalibration,
type AmbientFacialFrame,
type AmbientMetricEvidence,
Expand Down Expand Up @@ -130,6 +132,161 @@ function primaryTrack(outcome: AmbientMetricOutcome): string {
* Counting only the metric's own events keeps the number honest and keeps
* each gate answerable by its own evidence.
*/
/**
* Prints why the session measured what it measured.
*
* A report full of abstentions looks identical whether the camera saw nobody or
* saw a face that never held still long enough for a bin to qualify, and those
* call for opposite fixes. Console only: no storage, no export, no contract
* surface. Counts and pose percentiles, never a per-frame series.
*/
function reportCaptureDiagnostics(
extraction: { diagnostics?: unknown; events?: unknown },
faceFrameCount: number
): void {
const all = extraction.diagnostics as
| { face?: FaceScreeningDiagnostics; voice?: VoiceScreeningDiagnostics }
| undefined;
const diagnostics = all?.face;
const voice = all?.voice;
if (!diagnostics) return;
const events = extraction.events as
| { blinks?: readonly unknown[]; expressions?: readonly unknown[];
pauses?: readonly unknown[]; speechRuns?: readonly unknown[] }
| undefined;

const pct = (part: number, whole: number) =>
whole > 0 ? `${((part / whole) * 100).toFixed(1)}%` : "n/a";
const lines: string[] = [
`frames delivered to extractor: ${faceFrameCount}`,
`frames passing every gate: ${diagnostics.usableFrameCount} (${pct(diagnostics.usableFrameCount, diagnostics.frameCount)})`,
`bins accepted: ${diagnostics.binsAccepted} of ${diagnostics.binsConsidered}`
];
const pose = diagnostics.pose;
if (pose) {
lines.push(
"absolute pose in degrees (limits yaw 7 / pitch 10 / roll 5):",
` yaw p50 ${pose.yawP50.toFixed(1)} p95 ${pose.yawP95.toFixed(1)}`,
` pitch p50 ${pose.pitchP50.toFixed(1)} p95 ${pose.pitchP95.toFixed(1)}`,
` roll p50 ${pose.rollP50.toFixed(1)} p95 ${pose.rollP95.toFixed(1)}`
);
}
const gates = Object.entries(diagnostics.frameGateFailures)
.sort(([, a], [, b]) => b - a);
lines.push(
gates.length === 0
? "no frame failed any gate"
: "frames failing each gate (one frame can fail several):"
);
for (const [reason, count] of gates) {
lines.push(` ${reason.padEnd(20)} ${String(count).padStart(6)} ${pct(count, diagnostics.frameCount)}`);
}
const rejections = Object.entries(diagnostics.binRejections)
.sort(([, a], [, b]) => b - a);
lines.push(
rejections.length === 0
? "no bin was rejected"
: "bin rejections by first failing check:"
);
for (const [reason, count] of rejections) {
lines.push(` ${reason.padEnd(20)} ${String(count).padStart(6)}`);
}
const curve = diagnostics.acceptanceCurve ?? [];
if (curve.length > 0) {
lines.push(
"if the all-or-nothing frame gate became fractional:",
" threshold bins lost to gap lost to span lost to samples"
);
for (const point of curve) {
lines.push(
` ${point.threshold.toFixed(2).padStart(8)}` +
`${String(point.binsAccepted).padStart(7)}` +
`${String(point.lostToGap).padStart(14)}` +
`${String(point.lostToSpan ?? 0).padStart(15)}` +
`${String(point.lostToSampleCount).padStart(17)}`
);
}
}
const perBin = diagnostics.bins ?? [];
if (perBin.length > 0) {
lines.push("per bin: usable fraction / largest gap between usable frames:");
for (const bin of perBin) {
lines.push(
` bin ${String(bin.index).padStart(3)} ` +
`${(bin.usableFraction * 100).toFixed(1).padStart(5)}% ` +
`${String(bin.usableFrameCount).padStart(4)}/${String(bin.frameCount).padEnd(4)} ` +
`gap ${bin.maxUsableGapMs.toFixed(0).padStart(5)} ms ` +
`span ${(bin.usableSpanMs ?? 0).toFixed(0).padStart(5)} ms`
);
}
}
if (voice) {
const req = voice.requirements;
lines.push(
"voice lane:",
` frames ${voice.frameCount}, usable ${voice.usableFrameCount}, ` +
`speech-active ${voice.speechActiveFrameCount}, periodic ${voice.periodicFrameCount}`,
` segments ${voice.segmentsAccepted} (needs ${req.minimumSegments})`,
` eligible ${(voice.eligibleDurationMs / 1000).toFixed(1)}s ` +
`(needs ${(req.minimumEligibleSpanMs / 1000).toFixed(0)}s), ` +
`active speech ${(voice.activeSpeechMs / 1000).toFixed(1)}s ` +
`(needs ${(req.minimumActiveSpeechMs / 1000).toFixed(0)}s)`
);
const vg = Object.entries(voice.frameGateFailures).sort(([, a], [, b]) => b - a);
if (vg.length > 0) {
lines.push(" frames failing each voice gate:");
for (const [reason, count] of vg) {
lines.push(` ${reason.padEnd(22)} ${String(count).padStart(6)}`);
}
}
}
lines.push(
"tier-2 events:",
` blinks ${events?.blinks?.length ?? 0}`,
` expressions ${events?.expressions?.length ?? 0}`,
` pauses ${events?.pauses?.length ?? 0}`,
` speech runs ${events?.speechRuns?.length ?? 0}`
);
const report = `PhenoMetrix capture diagnostics\n${lines.join("\n")}`;
// eslint-disable-next-line no-console
console.log(report);
publishDiagnosticsReport(report);
}

/**
* Makes the diagnostics copyable from the finish screen.
*
* Calibration is an iterate-and-rerun loop, and console-only output made every
* round cost a hand-copy or the whole session. Clipboard on an explicit click
* is the same act as selecting the text by hand: no storage, no file, no
* network, and nothing retained after the page closes.
*/
function publishDiagnosticsReport(report: string): void {
// The adapter is exercised headlessly in unit tests, where there is no DOM
// and no clipboard. Diagnostics are a browser affordance, not part of what
// this function computes.
if (typeof document === "undefined") return;
const button = document.querySelector<HTMLButtonElement>(
"#copy-diagnostics"
);
if (!button || typeof navigator === "undefined" || !navigator.clipboard) {
return;
}
button.hidden = false;
button.onclick = () => {
void navigator.clipboard
.writeText(report)
.then(() => {
button.textContent = "Diagnostics copied";
})
.catch(() => {
// Clipboard permission can be refused; say so rather than appearing to
// have worked.
button.textContent = "Copy failed - select the console output";
});
};
}

function relevantEventCount(
code: MetricCode,
evidence: AmbientMetricEvidence
Expand Down Expand Up @@ -456,6 +613,8 @@ export function buildAmbientObservation(
calibration: input.faceCalibration
}
});

reportCaptureDiagnostics(extraction, input.faceFrames.length);
const artifacts = extraction.outcomes.map((outcome) =>
outcomeArtifacts(outcome, input)
);
Expand Down
4 changes: 2 additions & 2 deletions apps/capture-web/src/static-assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ describe("static asset manifest", () => {
expect(
resolveAssetUrl(
"models/face_landmarker.task",
"https://example.test/tools/phenometric/index.html"
"https://example.test/tools/phenometrix/index.html"
)
).toBe("https://example.test/tools/phenometric/models/face_landmarker.task");
).toBe("https://example.test/tools/phenometrix/models/face_landmarker.task");
});

it("rejects root-relative and escaping paths", () => {
Expand Down
11 changes: 11 additions & 0 deletions apps/capture-web/src/voice-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ export async function startVoiceCapturePipeline(
options.audioContext.createMediaStreamSource(options.stream);
const worklet = new AudioWorkletNode(
options.audioContext,
/*
* Deliberately NOT renamed with the rest of PhenoMetrix.
*
* This is a runtime registration identifier, the same category as the
* "phenometric.<name>.vN" schema strings that were left alone for the same
* reason. Worse, the worklet is served from public/ at a fixed unhashed
* URL, so browsers cache it hard: a stale copy registers the old name while
* a fresh bundle asks for the new one, AudioWorkletNode throws, and the
* voice lane dies silently -- live telemetry simply stops. Renaming it was
* cosmetic and cost exactly that.
*/
"phenometric-voice-capture",
{
numberOfInputs: 1,
Expand Down
Loading
Loading