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.

+
+ +
+
+

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"}