diff --git a/services/bstein-dev-home/av-observer/activity-worklet.js b/services/bstein-dev-home/av-observer/activity-worklet.js
new file mode 100644
index 00000000..27b4b8a0
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/activity-worklet.js
@@ -0,0 +1,28 @@
+// A cumulative, one-credit metadata reporter: never copies or sends audio.
+class Activity extends AudioWorkletProcessor {
+ constructor() {
+ super(); this.frames=0; this.inputFrames=0; this.zeroFrames=0;
+ this.energy=0; this.peak=0; this.credit=true; this.next=0; this.stopped=false;
+ this.port.onmessage=({data})=>{ if(data==='ack')this.credit=true; if(data==='stop')this.stopped=true; };
+ }
+ process(inputs,outputs) {
+ if(this.stopped || this.frames >= sampleRate*125) return false;
+ const block=outputs[0]?.[0]?.length ?? 128;
+ const samples=inputs[0]?.[0];
+ this.frames+=block;
+ if(samples) for(const value of samples) {
+ this.inputFrames++; this.energy+=value*value;
+ this.peak=Math.max(this.peak,Math.abs(value));
+ if(value===0)this.zeroFrames++;
+ }
+ // Output is left at zero: no monitoring/playback or acoustic feedback.
+ if(this.credit && this.frames>=this.next) {
+ this.port.postMessage({inputFrames:this.inputFrames,processedFrames:this.frames,
+ exactZeroFrames:this.zeroFrames,rms:this.inputFrames ? Math.sqrt(this.energy/this.inputFrames) : 0,
+ peak:this.peak,sampleRate});
+ this.credit=false; this.next=this.frames+sampleRate;
+ }
+ return true;
+ }
+}
+registerProcessor('av-activity',Activity);
diff --git a/services/bstein-dev-home/av-observer/deployment.yaml b/services/bstein-dev-home/av-observer/deployment.yaml
new file mode 100644
index 00000000..d0bf66fb
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/deployment.yaml
@@ -0,0 +1,53 @@
+# Static assets only; no API, uploads, credentials or outbound connections.
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: lesavka-av-observer
+spec:
+ replicas: 1
+ revisionHistoryLimit: 2
+ selector:
+ matchLabels: {app: lesavka-av-observer}
+ template:
+ metadata:
+ labels: {app: lesavka-av-observer}
+ spec:
+ automountServiceAccountToken: false
+ nodeSelector:
+ kubernetes.io/arch: arm64
+ node-role.kubernetes.io/worker: "true"
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 101
+ runAsGroup: 101
+ fsGroup: 101
+ seccompProfile: {type: RuntimeDefault}
+ containers:
+ - name: static
+ image: nginx:1.27.5-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10
+ command: [/usr/sbin/nginx]
+ args: [-c, /etc/nginx/observer/nginx.conf, -g, "daemon off;"]
+ ports: [{name: http, containerPort: 8080}]
+ securityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities: {drop: [ALL]}
+ resources:
+ requests: {cpu: 10m, memory: 16Mi}
+ limits: {cpu: 100m, memory: 64Mi}
+ readinessProbe:
+ httpGet: {path: /lesavka-av-test/, port: http}
+ livenessProbe:
+ httpGet: {path: /lesavka-av-test/, port: http}
+ initialDelaySeconds: 10
+ volumeMounts:
+ - {name: config, mountPath: /etc/nginx/observer, readOnly: true}
+ - {name: assets, mountPath: /usr/share/nginx/html/lesavka-av-test, readOnly: true}
+ - {name: tmp, mountPath: /tmp}
+ volumes:
+ - name: config
+ configMap: {name: lesavka-av-observer-nginx}
+ - name: assets
+ configMap: {name: lesavka-av-observer-assets}
+ - name: tmp
+ emptyDir: {medium: Memory, sizeLimit: 8Mi}
diff --git a/services/bstein-dev-home/av-observer/index.html b/services/bstein-dev-home/av-observer/index.html
new file mode 100644
index 00000000..cdb2d8ff
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/index.html
@@ -0,0 +1,18 @@
+
+
+
+
Lesavka — bundled A/V observer
+
+Bundled A/V observer
+No recording, uploads or saved camera/microphone content. Select the RCT's Lesavka video and microphone devices.
+Choose devices…
+Video
+Microphone
+Start 2-minute check Stop Save metrics
+Stopped
+
+No observations.
+Sync and capture-to-RCT freshness: not measured by this observer .
+Video callback gaps can include browser scheduling. Silence is not proof of audio loss.
+Use the qualified paired-marker/clock-bounded probe for release acceptance.
+
diff --git a/services/bstein-dev-home/av-observer/ingress.yaml b/services/bstein-dev-home/av-observer/ingress.yaml
new file mode 100644
index 00000000..1f8fba01
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/ingress.yaml
@@ -0,0 +1,23 @@
+# Reuse the existing domain/certificate, without changing its homepage route.
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: lesavka-av-observer
+ annotations:
+ traefik.ingress.kubernetes.io/router.entrypoints: websecure
+ traefik.ingress.kubernetes.io/router.tls: "true"
+spec:
+ ingressClassName: traefik
+ tls:
+ - hosts: [bstein.dev]
+ secretName: bstein-dev-home-tls
+ rules:
+ - host: bstein.dev
+ http:
+ paths:
+ - path: /lesavka-av-test
+ pathType: Prefix
+ backend:
+ service:
+ name: lesavka-av-observer
+ port: {number: 80}
diff --git a/services/bstein-dev-home/av-observer/kustomization.yaml b/services/bstein-dev-home/av-observer/kustomization.yaml
new file mode 100644
index 00000000..d633d081
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/kustomization.yaml
@@ -0,0 +1,17 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - deployment.yaml
+ - service.yaml
+ - ingress.yaml
+ - network-policy.yaml
+configMapGenerator:
+ - name: lesavka-av-observer-nginx
+ files: [nginx.conf]
+ - name: lesavka-av-observer-assets
+ files:
+ - index.html
+ - observer.js
+ - metrics.js
+ - activity-worklet.js
+ - style.css
diff --git a/services/bstein-dev-home/av-observer/metrics.js b/services/bstein-dev-home/av-observer/metrics.js
new file mode 100644
index 00000000..9598b9d9
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/metrics.js
@@ -0,0 +1,30 @@
+// Metadata only. No frames or PCM are retained. These are OBSERVER metrics,
+// never an automatically passing sync/freshness/artifact report.
+export class VideoMetrics {
+ constructor() { this.last = null; this.intervals = []; this.callbacks = 0; this.unobserved = 0; this.resets = 0; }
+ observe(timeMs, presentedFrames) {
+ if (!Number.isFinite(timeMs) || !Number.isSafeInteger(presentedFrames) || presentedFrames < 0) return;
+ if (this.last) {
+ if (timeMs <= this.last.timeMs || presentedFrames <= this.last.presentedFrames) this.resets++;
+ else {
+ if (this.intervals.length < 15000) this.intervals.push(timeMs-this.last.timeMs);
+ this.unobserved += Math.max(0,presentedFrames-this.last.presentedFrames-1);
+ }
+ }
+ this.last = {timeMs,presentedFrames}; this.callbacks++;
+ }
+ summary() {
+ const sorted = [...this.intervals].sort((a,b)=>a-b);
+ return {callbacks:this.callbacks,unobservedPresentedFrames:this.unobserved,clockOrCounterResets:this.resets,
+ callbackIntervalP95Ms:sorted.length ? sorted[Math.ceil(sorted.length*.95)-1] : null,
+ callbackGapMaxMs:sorted.at(-1) ?? null,
+ note:"Browser presentation callbacks; gaps are not proof of transport loss or corrupted video."};
+ }
+}
+
+export function receipt(video,audio,settings,elapsedMs,reason) {
+ return {schema:"lesavka.rct-av-observer.v1",kind:"diagnostic-only",reason,elapsedMs,
+ settings,video:video.summary(),audio,
+ syncMeasured:false,freshnessMeasured:false,artifactsMeasured:false,
+ releaseEligible:false,recordedMedia:false};
+}
diff --git a/services/bstein-dev-home/av-observer/network-policy.yaml b/services/bstein-dev-home/av-observer/network-policy.yaml
new file mode 100644
index 00000000..15827659
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/network-policy.yaml
@@ -0,0 +1,12 @@
+# The observer only serves static files; it has no reason to initiate traffic.
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: lesavka-av-observer
+spec:
+ podSelector:
+ matchLabels: {app: lesavka-av-observer}
+ policyTypes: [Ingress, Egress]
+ ingress:
+ - ports: [{protocol: TCP, port: 8080}]
+ egress: []
diff --git a/services/bstein-dev-home/av-observer/nginx.conf b/services/bstein-dev-home/av-observer/nginx.conf
new file mode 100644
index 00000000..04b1345f
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/nginx.conf
@@ -0,0 +1,35 @@
+worker_processes 1;
+pid /tmp/nginx.pid;
+error_log /dev/stderr warn;
+events { worker_connections 128; }
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+ access_log off;
+ server_tokens off;
+ client_max_body_size 1k;
+ client_body_temp_path /tmp/client;
+ proxy_temp_path /tmp/proxy;
+ fastcgi_temp_path /tmp/fastcgi;
+ uwsgi_temp_path /tmp/uwsgi;
+ scgi_temp_path /tmp/scgi;
+ server {
+ listen 8080;
+ absolute_redirect off;
+ root /usr/share/nginx/html;
+ if ($request_method !~ ^(GET|HEAD)$) { return 405; }
+ add_header Cache-Control "no-store" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "DENY" always;
+ add_header Referrer-Policy "no-referrer" always;
+ add_header Permissions-Policy "camera=(self), microphone=(self), geolocation=(), display-capture=()" always;
+ add_header Cross-Origin-Opener-Policy "same-origin" always;
+ add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; media-src 'self' blob:; connect-src 'none'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
+ location = /lesavka-av-test { return 308 /lesavka-av-test/; }
+ location = /lesavka-av-test/ { try_files /lesavka-av-test/index.html =404; }
+ location ~ ^/lesavka-av-test/(index\.html|observer\.js|metrics\.js|activity-worklet\.js|style\.css)$ {
+ try_files $uri =404;
+ }
+ location / { return 404; }
+ }
+}
diff --git a/services/bstein-dev-home/av-observer/observer.js b/services/bstein-dev-home/av-observer/observer.js
new file mode 100644
index 00000000..5805bda1
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/observer.js
@@ -0,0 +1,117 @@
+import {VideoMetrics,receipt} from './metrics.js';
+const el=id=>document.getElementById(id);
+let generation=0, current=null, last=null;
+const stopTracks=stream=>stream?.getTracks().forEach(track=>track.stop());
+function release(run) {
+ if(run.released)return;
+ run.released=true;
+ clearTimeout(run.timer); clearTimeout(run.setupTimer);
+ if(run.callback)el('preview').cancelVideoFrameCallback(run.callback);
+ run.node?.port.postMessage('stop'); run.node?.disconnect(); run.source?.disconnect();
+ stopTracks(run.stream);
+ // Do not wait on a browser AudioContext close before fencing a stopped run.
+ run.context?.close().catch(()=>{});
+}
+function stop(reason='operator') {
+ generation++;
+ const run=current; current=null;
+ if(run) {
+ release(run);
+ last=receipt(run.video,run.audio,run.settings,performance.now()-run.started,reason);
+ el('metrics').textContent=JSON.stringify(last,null,2);
+ el('export').disabled=false;
+ }
+ el('preview').srcObject=null;
+ el('start').disabled=!el('audio-device').value || !el('video-device').value;
+ el('stop').disabled=true; el('devices').disabled=false;
+ el('status').textContent=`Stopped · ${reason}`;
+}
+el('devices').onclick=async()=>{
+ stop('selecting devices');
+ const ticket=++generation;
+ el('devices').disabled=true; el('start').disabled=true; el('stop').disabled=false;
+ el('status').textContent='Requesting device permission…';
+ const timer=setTimeout(()=>{if(generation===ticket)stop('permission timeout');},20000);
+ try {
+ const stream=await navigator.mediaDevices.getUserMedia({audio:true,video:true});
+ stopTracks(stream);
+ if(generation!==ticket)return;
+ const devices=await navigator.mediaDevices.enumerateDevices();
+ if(generation!==ticket)return;
+ for(const [id,kind] of [['video-device','videoinput'],['audio-device','audioinput']]) {
+ const select=el(id), saved=select.value;
+ select.replaceChildren(new Option('Select Lesavka device',''));
+ for(const d of devices.filter(d=>d.kind===kind)) select.add(new Option(d.label||'Unnamed device',d.deviceId));
+ select.value=saved;
+ }
+ el('status').textContent='Select both Lesavka devices, then Start.';
+ el('devices').disabled=false; el('stop').disabled=true;
+ } catch { if(generation===ticket)stop('device permission unavailable'); }
+ finally {clearTimeout(timer);}
+};
+for(const id of ['audio-device','video-device']) el(id).onchange=()=>{
+ if(current)stop('device selection changed');
+ el('start').disabled=!el('audio-device').value||!el('video-device').value;
+};
+el('start').onclick=async()=>{
+ if(current || !el('audio-device').value || !el('video-device').value)return;
+ if(!el('preview').requestVideoFrameCallback || !window.AudioWorkletNode) {
+ el('status').textContent='This browser lacks the required observation APIs.';return;
+ }
+ const ticket=++generation;
+ const run={started:performance.now(),video:new VideoMetrics(),audio:null,settings:{}};
+ current=run; last=null;
+ el('start').disabled=true; el('devices').disabled=true; el('stop').disabled=false; el('export').disabled=true;
+ el('status').textContent='Starting…';
+ run.setupTimer=setTimeout(()=>{if(current===run)stop('setup timeout');},20000);
+ try {
+ const stream=await navigator.mediaDevices.getUserMedia({
+ video:{deviceId:{exact:el('video-device').value}},
+ audio:{deviceId:{exact:el('audio-device').value},echoCancellation:false,noiseSuppression:false,autoGainControl:false},
+ });
+ if(generation!==ticket){stopTracks(stream);return;}
+ run.stream=stream;
+ for(const track of stream.getTracks())track.onended=()=>{if(current===run)stop('device ended');};
+ const vs=stream.getVideoTracks()[0].getSettings(),as=stream.getAudioTracks()[0].getSettings();
+ run.settings={video:{width:vs.width,height:vs.height,frameRate:vs.frameRate},
+ audio:{sampleRate:as.sampleRate,channelCount:as.channelCount,echoCancellation:as.echoCancellation,
+ noiseSuppression:as.noiseSuppression,autoGainControl:as.autoGainControl}};
+ run.context=new AudioContext();
+ await run.context.audioWorklet.addModule('activity-worklet.js');
+ if(generation!==ticket)return;
+ run.node=new AudioWorkletNode(run.context,'av-activity');
+ run.node.onprocessorerror=()=>{if(current===run)stop('audio observer error');};
+ run.node.port.onmessage=({data})=>{
+ if(current!==run)return;
+ run.audio=data; run.node.port.postMessage('ack');
+ el('metrics').textContent=JSON.stringify(receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing'),null,2);
+ };
+ run.source=run.context.createMediaStreamSource(new MediaStream(stream.getAudioTracks()));
+ run.source.connect(run.node);run.node.connect(run.context.destination);
+ await run.context.resume();
+ if(generation!==ticket)return;
+ el('preview').srcObject=stream;
+ await el('preview').play();
+ if(generation!==ticket)return;
+ clearTimeout(run.setupTimer);
+ run.started=performance.now();
+ function frame(now,metadata) {
+ if(current!==run)return;
+ run.video.observe(metadata.expectedDisplayTime,metadata.presentedFrames);
+ run.callback=el('preview').requestVideoFrameCallback(frame);
+ }
+ run.callback=el('preview').requestVideoFrameCallback(frame);
+ run.timer=setTimeout(()=>{if(current===run)stop('120 seconds complete');},120000);
+ el('status').textContent='Observing both devices · no recording';
+ } catch { if(current===run)stop('capture/setup failed'); }
+ finally { if(current!==run)release(run); }
+};
+el('stop').onclick=()=>stop();
+el('export').onclick=()=>{
+ if(!last)return;
+ const url=URL.createObjectURL(new Blob([JSON.stringify(last,null,2)],{type:'application/json'}));
+ const link=document.createElement('a');link.href=url;link.download='lesavka-av-observer.json';link.click();
+ setTimeout(()=>URL.revokeObjectURL(url),1000);
+};
+window.addEventListener('pagehide',()=>stop('page closed'));
+document.addEventListener('visibilitychange',()=>{if(document.hidden&¤t)stop('page hidden; timing invalid');});
diff --git a/services/bstein-dev-home/av-observer/service.yaml b/services/bstein-dev-home/av-observer/service.yaml
new file mode 100644
index 00000000..d2e21b35
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/service.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: lesavka-av-observer
+spec:
+ selector: {app: lesavka-av-observer}
+ ports: [{name: http, port: 80, targetPort: http}]
diff --git a/services/bstein-dev-home/av-observer/style.css b/services/bstein-dev-home/av-observer/style.css
new file mode 100644
index 00000000..4f36b546
--- /dev/null
+++ b/services/bstein-dev-home/av-observer/style.css
@@ -0,0 +1,7 @@
+:root { color-scheme: dark; font: 15px system-ui,sans-serif; }
+body { margin: 24px; background: #171b22; color: #e7edf6; }
+main { max-width: 1000px; margin: auto; }
+button,select { padding: 8px; margin: 4px; max-width: 100%; }
+label { display: inline-block; }
+video { width: 100%; max-height: 55vh; background: #000; }
+pre { white-space: pre-wrap; overflow-wrap: anywhere; }
diff --git a/services/bstein-dev-home/kustomization.yaml b/services/bstein-dev-home/kustomization.yaml
index 44600cab..b03d71e5 100644
--- a/services/bstein-dev-home/kustomization.yaml
+++ b/services/bstein-dev-home/kustomization.yaml
@@ -18,6 +18,7 @@ resources:
- vaultwarden-cred-sync-cronjob.yaml
- validation-jobs/portal-onboarding-e2e-test-job.yaml
- ingress.yaml
+ - av-observer
images:
- name: registry.bstein.dev/bstein/bstein-dev-home-frontend
newTag: 0.1.1-541 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"}