| author | Clayton Gentry <clayton@podstock.io> | 2026-08-20 12:29:37 -0400 |
|---|---|---|
| committer | Clayton Gentry <clayton@podstock.io> | 2026-08-20 12:29:37 -0400 |
| commit | 9c0b731c9ce08de0a94aab5ccfaf94a68a9c4edd (patch) | |
| tree | 620c9df38e5d232a6bc99267d01b0e1fed5a12ba | |
| parent | 3903e4d06351637151889b9ee64844e7f54d7b44 (diff) | |
| download | cribd-9c0b731c9c.tar.gz zip | |
Add audio activity meter
Diffstat
| CHANGELOG.md | +1 | -0 |
| changelog/2026-08-18-audio-activity-meter.md (new) | +11 | -0 |
| web/crib/app.css | +335 | -75 |
| web/crib/app.js | +68 | -42 |
| web/crib/index.html | +95 | -11 |
| web/crib/sound-meter.js (new) | +289 | -0 |
6 files changed, 799 insertions(+), 128 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026 +- **AUG.18** — [Audio Activity Meter](changelog/2026-08-18-audio-activity-meter.md) - **AUG.13** — [Camera Adapters](changelog/2026-08-13-camera-adapters.md) - **AUG.13** — [Stream Status](changelog/2026-08-13-stream-status.md) - **AUG.13** — [Local Network Viewing](changelog/2026-08-13-local-network-viewing.md) diff --git a/changelog/2026-08-18-audio-activity-meter.md b/changelog/2026-08-18-audio-activity-meter.md @@ -0,0 +1,11 @@ +# Audio Activity Meter + +Date: **AUG.18.2026** + +`cribd` shows a compact five-segment sound activity meter below a live camera stream when the stream includes audio. + +The viewer measures the incoming WebRTC audio track locally in the browser and maps it across five visual activity levels. In Chrome it reads decoded audio samples directly because Chrome reports zero receiver energy while muted; other browsers use the receiver’s inbound audio statistics or synchronization-source level. Neither path starts a Web Audio context, so the meter begins automatically without a click or keyboard interaction. It remains independent of playback volume, disappears when the stream is offline or has no audio track, and does not send audio or measurements to a server. + +The borderless, softly toned surface is labeled “Room sound” to keep the feature human and contextual. Activity appears immediately, holds its peak for 280ms, then releases one capsule at a time so short sounds remain readable without turning the display into an audio visualizer. Active capsules use a softer green while the current peak stays fully illuminated; warning colors remain reserved for future sustained-sound alerts with meaningful thresholds. + +The meter reports relative digital signal activity. It is not a calibrated decibel meter, cry detector, medical monitor, or alerting system. diff --git a/web/crib/app.css b/web/crib/app.css @@ -1,31 +1,80 @@ :root { - color-scheme: dark; - font-family: ui-rounded, "SF Pro Rounded", "Avenir Next", sans-serif; - --night-deep: #080c0d; + color-scheme: light; + font-family: + "Avenir Next Rounded", "SF Pro Rounded", ui-rounded, "Avenir Next", + sans-serif; + --canvas: #f8f6f2; + --frame: #161817; --ink: #f2ece1; --muted: #aaa79d; - --panel: rgb(25 31 30 / 78%); - --line: rgb(242 236 225 / 14%); + --detail-ink: #c3beb5; --connecting: #d2b779; - --live: #91b69d; + --live: #51aa71; --offline: #d27f70; + --header-ink: #303431; + --status-ink: #66726c; + --meter-bg: rgb(255 255 255 / 40%); + --meter-track: rgb(48 52 49 / 11%); + --toggle-bg: rgb(65 70 66 / 8%); + --toggle-border: rgb(48 52 49 / 12%); + --toggle-thumb: #fffefa; + --toggle-idle: #8a918c; + --toggle-active: #424844; + --toggle-shadow: 0 4px 13px rgb(65 55 44 / 10%); + + --content-width: 1120px; + --page-padding: clamp(18px, 4vw, 56px); + --content-stack-gap: clamp(24px, 3vmin, 32px); + --header-offset: clamp(12px, 2.5vh, 24px); + --monitor-aspect: 16 / 9; + --title-size: clamp(30px, 4.25vw, 44px); + + --theme-transition: 280ms ease; + --toggle-transition: 300ms cubic-bezier(0.22, 1, 0.36, 1); } +:root[data-theme="dark"] { + color-scheme: dark; + --canvas: #151715; + --frame: #090b0a; + --ink: #f2eee6; + --muted: #9d9f98; + --detail-ink: #b8b9b2; + --header-ink: #e8e5de; + --status-ink: #a0a8a2; + --meter-bg: rgb(255 255 255 / 3%); + --meter-track: rgb(242 238 230 / 10%); + --toggle-bg: rgb(255 255 255 / 7%); + --toggle-border: rgb(255 255 255 / 10%); + --toggle-thumb: #383c38; + --toggle-idle: #8f9891; + --toggle-active: #f1eade; + --toggle-shadow: 0 5px 18px rgb(0 0 0 / 26%); +} + * { box-sizing: border-box; } -html, -body { - margin: 0; - overflow: hidden; - background: var(--night-deep); +html { + background: var(--canvas); + transition: background-color var(--theme-transition); } body { + display: flex; + min-height: 100vh; + min-height: 100dvh; + margin: 0; + padding: var(--page-padding); + flex-direction: column; + align-items: center; + gap: var(--content-stack-gap); + overflow-x: hidden; color: var(--ink); -webkit-font-smoothing: antialiased; --state-color: var(--connecting); + transition: color var(--theme-transition); } body[data-state="live"] { @@ -36,26 +85,140 @@ --state-color: var(--offline); } +.viewer-header, .monitor { + width: min(var(--content-width), 100%); +} + +.viewer-header { position: relative; - height: 100vh; - height: 100dvh; - background: radial-gradient( - circle at 50% 44%, - #26302d 0, - #151d1c 42%, - #0a0f10 78% - ); + display: flex; + margin-top: var(--header-offset); + flex-direction: column; + align-items: center; + gap: clamp(12px, 1.7vw, 18px); + color: var(--header-ink); + text-align: center; } +.theme-toggle { + position: absolute; + display: grid; + top: -2px; + right: 0; + width: 74px; + height: 38px; + padding: 0; + grid-template-columns: repeat(2, 1fr); + place-items: center; + border: 1px solid var(--toggle-border); + border-radius: 999px; + background: var(--toggle-bg); + box-shadow: inset 0 1px rgb(255 255 255 / 6%); + color: var(--toggle-idle); + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: + background-color var(--theme-transition), + border-color var(--theme-transition), + box-shadow 180ms ease, + color var(--theme-transition); +} + +.theme-toggle::before { + position: absolute; + top: 3px; + left: 3px; + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--toggle-thumb); + box-shadow: var(--toggle-shadow); + content: ""; + transition: + transform var(--toggle-transition), + background-color var(--theme-transition), + box-shadow var(--theme-transition); +} + +.theme-toggle:hover { + box-shadow: + inset 0 1px rgb(255 255 255 / 6%), + 0 5px 18px rgb(54 48 41 / 9%); +} + +.theme-toggle:active { + transform: scale(0.97); +} + +.theme-toggle:focus-visible { + outline: 2px solid var(--header-ink); + outline-offset: 3px; +} + +.line-icon { + --line-icon-size: 17px; + --line-icon-stroke: 1.7; + + width: var(--line-icon-size); + height: var(--line-icon-size); + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: var(--line-icon-stroke); +} + +:root[data-theme="dark"] .theme-toggle::before { + transform: translateX(36px); +} + +.theme-icon { + z-index: 1; + transition: + color var(--theme-transition), + transform var(--toggle-transition); +} + +.theme-icon-sun { + color: var(--toggle-active); +} + +:root[data-theme="dark"] .theme-icon-sun { + color: var(--toggle-idle); + transform: rotate(40deg); +} + +:root[data-theme="dark"] .theme-icon-moon { + color: var(--toggle-active); + transform: rotate(-8deg); +} + +.room-title { + margin: 0; + font-size: var(--title-size); + font-weight: 600; + letter-spacing: -0.052em; + line-height: 1; +} + +.monitor { + position: relative; + aspect-ratio: var(--monitor-aspect); + overflow: hidden; + border-radius: clamp(20px, 2vw, 24px); + background: var(--frame); + isolation: isolate; + transition: background-color var(--theme-transition); +} + .monitor::after { position: absolute; z-index: 2; inset: 0; - border: 1px solid rgb(242 236 225 / 6%); - box-shadow: - inset 0 0 120px rgb(0 0 0 / 58%), - inset 0 -48px 90px rgb(0 0 0 / 18%); + border: 1px solid rgb(255 255 255 / 12%); + border-radius: inherit; + box-shadow: inset 0 0 80px rgb(0 0 0 / 22%); content: ""; pointer-events: none; } @@ -67,46 +230,108 @@ width: 100%; height: 100%; object-fit: contain; - background: var(--night-deep); + background: var(--frame); } -.status { - position: absolute; - z-index: 4; - right: max(16px, env(safe-area-inset-right)); - bottom: max(16px, env(safe-area-inset-bottom)); +.sound-meter { + display: grid; + width: min(400px, 100%); + min-height: clamp(56px, 7.6vw, 58px); + padding: 12px 16px; + grid-template-columns: auto 1fr; + align-items: center; + gap: clamp(14px, 2vw, 16px); + border-radius: 16px; + background: var(--meter-bg); + color: var(--header-ink); + transition: + background-color var(--theme-transition), + color var(--theme-transition); +} + +.sound-meter[hidden] { + display: none; +} + +.sound-meter-heading { display: flex; align-items: center; - gap: 10px; - min-height: 38px; - padding: 0 15px; - border: 1px solid var(--line); + gap: 12px; +} + +.sound-meter-icon { + display: grid; + width: 32px; + height: 32px; + flex: 0 0 auto; + border-radius: 50%; + background: color-mix(in srgb, var(--live) 11%, transparent); + color: var(--live); + place-items: center; +} + +.sound-meter-label { + color: var(--status-ink); + font-size: 13px; + line-height: 1; + font-weight: 500; + letter-spacing: -0.01em; +} + +.sound-bars { + display: flex; + height: 18px; + align-items: center; + justify-self: end; + gap: 5px; +} + +.sound-bar { + width: 5px; + height: 18px; border-radius: 999px; - background: - linear-gradient(145deg, rgb(46 54 51 / 38%), transparent 52%), - var(--panel); - box-shadow: - 0 12px 36px rgb(0 0 0 / 30%), - inset 0 1px rgb(255 255 255 / 5%); - -webkit-backdrop-filter: blur(18px) saturate(115%); - backdrop-filter: blur(18px) saturate(115%); - font-size: 12px; - font-weight: 600; - letter-spacing: 0.015em; - text-transform: capitalize; + background: var(--meter-track); + transition: background-color 120ms linear; +} + +.sound-meter[data-level="1"] .sound-bar:nth-child(1), +.sound-meter[data-level="2"] .sound-bar:nth-child(-n + 2), +.sound-meter[data-level="3"] .sound-bar:nth-child(-n + 3), +.sound-meter[data-level="4"] .sound-bar:nth-child(-n + 4), +.sound-meter[data-level="5"] .sound-bar:nth-child(-n + 5) { + background: color-mix(in srgb, var(--live) 80%, transparent); +} + +.sound-meter[data-level="1"] .sound-bar:nth-child(1), +.sound-meter[data-level="2"] .sound-bar:nth-child(2), +.sound-meter[data-level="3"] .sound-bar:nth-child(3), +.sound-meter[data-level="4"] .sound-bar:nth-child(4), +.sound-meter[data-level="5"] .sound-bar:nth-child(5) { + background: var(--live); +} + +.status { + display: flex; + align-items: center; + justify-content: center; + gap: clamp(8px, 1vw, 9px); + color: var(--status-ink); + font-size: clamp(14px, 1.5vw, 17px); + font-weight: 500; + letter-spacing: -0.005em; + line-height: 1.2; pointer-events: none; } -.status-dot { - width: 8px; - height: 8px; +.status::before { + width: clamp(7px, 0.9vw, 9px); + aspect-ratio: 1; border-radius: 50%; background: var(--state-color); - box-shadow: 0 0 0 4px - color-mix(in srgb, var(--state-color) 11%, transparent); + content: ""; } -body[data-state="connecting"] .status-dot { +body[data-state="connecting"] .status::before { animation: breathe 2.8s ease-in-out infinite; } @@ -117,13 +342,6 @@ display: grid; place-content: center; padding: 24px; - background: - radial-gradient( - circle at 50% 48%, - color-mix(in srgb, var(--state-color) 8%, transparent) 0, - transparent 34% - ), - linear-gradient(180deg, rgb(255 255 255 / 1.5%), transparent 30%); text-align: center; pointer-events: none; transition: opacity 260ms ease; @@ -133,31 +351,50 @@ opacity: 0; } +.offline-icon { + --line-icon-size: clamp(44px, 5vw, 56px); + --line-icon-stroke: 1.45; + + display: none; + color: var(--muted); + opacity: 0.62; +} + +body[data-state="offline"] .offline-icon { + display: block; +} + +body[data-state="offline"] .connection-copy { + display: none; +} + .eyebrow { - margin: 0 0 17px; + margin: 0 0 20px; color: var(--muted); - font-size: 12px; + font-size: 11px; font-weight: 600; - letter-spacing: 0.075em; + letter-spacing: 0.14em; + line-height: 1.2; + text-transform: uppercase; } -h1 { +.state-title { margin: 0; - font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif; - font-size: clamp(44px, 8vw, 76px); - font-weight: 400; - letter-spacing: -0.042em; - line-height: 0.96; + font-size: clamp(42px, 7vw, 70px); + font-weight: 500; + letter-spacing: -0.05em; + line-height: 1; text-shadow: 0 12px 38px rgb(0 0 0 / 25%); + text-wrap: balance; } .detail { - margin: 20px 0 0; - color: var(--muted); - font-size: 13px; - font-weight: 500; - letter-spacing: 0.01em; - line-height: 1.5; + margin: 18px 0 0; + color: var(--detail-ink); + font-size: 15px; + font-weight: 400; + letter-spacing: 0; + line-height: 1.45; } @keyframes breathe { @@ -168,11 +405,34 @@ } @media (prefers-reduced-motion: reduce) { - body[data-state="connecting"] .status-dot { - animation: none; + :root { + --theme-transition: 0s; + --toggle-transition: 0s; } + .theme-toggle, + .sound-bar, .empty-state { transition: none; } + + body[data-state="connecting"] .status::before { + animation: none; + } } + +@media (orientation: portrait) { + :root { + --page-padding: clamp(10px, 2vw, 20px); + --header-offset: clamp(20px, 2.5vw, 32px); + --title-size: clamp(27px, 3vw, 32px); + } +} + +@media (max-width: 760px) { + .theme-toggle { + top: -6px; + scale: 0.89; + transform-origin: right center; + } +} diff --git a/web/crib/app.js b/web/crib/app.js @@ -1,22 +1,27 @@ -"use strict"; +import { SoundMeter } from "./sound-meter.js?v=20260818"; -const mediaPort = "8890"; -const retryDelay = 1000; -const freezeAfter = 5000; -const watchdogEvery = 1000; +const MEDIA_PORT = "8890"; +const RETRY_DELAY_MS = 1000; +const FREEZE_TIMEOUT_MS = 5000; +const WATCHDOG_INTERVAL_MS = 1000; +const THEME_COOKIE = "cribd_theme"; +const root = document.documentElement; +const body = document.body; const video = document.getElementById("video"); -const statusLabel = document.getElementById("status-label"); -const stateTitle = document.getElementById("state-title"); -const stateDetail = document.getElementById("state-detail"); +const statusElement = document.getElementById("status-label"); +const themeToggle = document.getElementById("theme-toggle"); +const themeColor = document.getElementById("theme-color"); +const soundMeter = new SoundMeter(document.getElementById("sound-meter")); -const stateCopy = { - connecting: ["Connecting", "Waiting for the camera stream"], - offline: ["Camera offline", "Retrying automatically…"], +const STATUS_LABELS = { + connecting: "Connecting", + live: "Live", + offline: "Offline", }; const mediaBase = new URL(window.location.href); -mediaBase.port = mediaPort; +mediaBase.port = MEDIA_PORT; mediaBase.pathname = "/crib/"; mediaBase.search = ""; mediaBase.hash = ""; @@ -27,17 +32,34 @@ let lastFrameAt = 0; let stopped = false; -function setState(next) { - const copy = stateCopy[next]; - document.body.dataset.state = next; - video.controls = next === "live"; - statusLabel.textContent = next; - if (copy) { - [stateTitle.textContent, stateDetail.textContent] = copy; - } +function applyTheme(theme) { + root.dataset.theme = theme; + themeToggle.setAttribute("aria-pressed", String(theme === "dark")); + themeColor.content = getComputedStyle(root) + .getPropertyValue("--canvas") + .trim(); } -function clearVideo() { +function toggleTheme() { + const theme = root.dataset.theme === "dark" ? "light" : "dark"; + applyTheme(theme); + document.cookie = `${THEME_COOKIE}=${theme}; Max-Age=31536000; Path=/; SameSite=Lax`; +} + +function setViewerState(state) { + const live = state === "live"; + + body.dataset.state = state; + video.controls = live; + soundMeter.setLive(live); + statusElement.textContent = STATUS_LABELS[state]; +} + +function disconnect() { + const activeReader = reader; + reader = null; + activeReader?.close(); + soundMeter.stop(); video.pause(); video.srcObject = null; } @@ -48,12 +70,10 @@ } console.warn(`[cribd] ${reason}`); - reader?.close(); - reader = null; - clearVideo(); - setState("offline"); + disconnect(); + setViewerState("offline"); clearTimeout(reconnectTimer); - reconnectTimer = window.setTimeout(connect, retryDelay); + reconnectTimer = window.setTimeout(connect, RETRY_DELAY_MS); } function loadReader() { @@ -88,19 +108,27 @@ return; } - reader = new window.MediaMTXWebRTCReader({ + let nextReader; + nextReader = new window.MediaMTXWebRTCReader({ url: new URL("whep", mediaBase).toString(), - onError: scheduleReconnect, + onError: (reason) => { + if (reader === nextReader) { + scheduleReconnect(reason); + } + }, onTrack: (event) => { - if (stopped || event.streams.length === 0) { + if (stopped || reader !== nextReader || event.streams.length === 0) { return; } - video.srcObject = event.streams[0]; + const [stream] = event.streams; + video.srcObject = stream; + soundMeter.start(event.receiver, event.track); lastFrameAt = performance.now(); video.play().catch(() => undefined); }, }); + reader = nextReader; } catch (error) { scheduleReconnect(error); } @@ -111,8 +139,8 @@ return; } lastFrameAt = performance.now(); - if (document.body.dataset.state !== "live") { - setState("live"); + if (body.dataset.state !== "live") { + setViewerState("live"); } } @@ -132,21 +160,17 @@ } }); -const watchdog = window.setInterval(() => { - if ( - !document.hidden && - video.srcObject !== null && - !video.paused && - performance.now() - lastFrameAt >= freezeAfter - ) { +const freezeWatchdog = window.setInterval(() => { + const frozen = performance.now() - lastFrameAt >= FREEZE_TIMEOUT_MS; + if (!document.hidden && video.srcObject !== null && !video.paused && frozen) { scheduleReconnect("video stopped producing frames"); } -}, watchdogEvery); +}, WATCHDOG_INTERVAL_MS); window.addEventListener("beforeunload", () => { stopped = true; clearTimeout(reconnectTimer); - clearInterval(watchdog); + clearInterval(freezeWatchdog); if ( frameCallback !== null && typeof video.cancelVideoFrameCallback === "function" @@ -153,7 +177,9 @@ ) { video.cancelVideoFrameCallback(frameCallback); } - reader?.close(); + disconnect(); }); +applyTheme(root.dataset.theme); +themeToggle.addEventListener("click", toggleTheme); connect(); diff --git a/web/crib/index.html b/web/crib/index.html @@ -3,11 +3,60 @@ <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> - <meta name="theme-color" content="#101617" /> + <meta id="theme-color" name="theme-color" content="#f8f6f2" /> <title>cribd</title> - <link rel="stylesheet" href="app.css" /> + <link rel="stylesheet" href="app.css?v=20260818" /> + <script> + const savedTheme = document.cookie.match( + /(?:^|;\s*)cribd_theme=(dark|light)(?:;|$)/, + )?.[1]; + document.documentElement.dataset.theme = savedTheme ?? "light"; + document.getElementById("theme-color").content = getComputedStyle( + document.documentElement, + ) + .getPropertyValue("--canvas") + .trim(); + </script> </head> <body data-state="connecting"> + <header class="viewer-header"> + <h1 class="room-title">Baby TV</h1> + + <button + class="theme-toggle" + id="theme-toggle" + type="button" + aria-label="Dark appearance" + aria-pressed="false" + > + <svg + class="line-icon theme-icon theme-icon-sun" + aria-hidden="true" + viewBox="0 0 24 24" + > + <circle cx="12" cy="12" r="3.25"></circle> + <path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4"></path> + </svg> + <svg + class="line-icon theme-icon theme-icon-moon" + aria-hidden="true" + viewBox="0 0 24 24" + > + <path d="M20 15.1A8.25 8.25 0 0 1 8.9 4a8.25 8.25 0 1 0 11.1 11.1Z"></path> + </svg> + </button> + + <div + class="status" + id="status-label" + role="status" + aria-live="polite" + aria-atomic="true" + > + Connecting + </div> + </header> + <main class="monitor"> <video id="video" @@ -17,18 +66,53 @@ playsinline ></video> - <div class="status" role="status" aria-live="polite" aria-atomic="true"> - <span class="status-dot" aria-hidden="true"></span> - <span id="status-label">Connecting</span> - </div> - <section class="empty-state" aria-hidden="true"> - <p class="eyebrow">Crib camera</p> - <h1 id="state-title">Connecting</h1> - <p class="detail" id="state-detail">Waiting for the camera stream</p> + <svg + class="line-icon offline-icon" + aria-hidden="true" + viewBox="0 0 24 24" + > + <path d="M15.75 9.5 20.5 7v10l-4.75-2.5"></path> + <path d="M5.75 6.5h7.5a2.5 2.5 0 0 1 2.5 2.5v6a2.5 2.5 0 0 1-2.5 2.5h-7.5a2.5 2.5 0 0 1-2.5-2.5V9a2.5 2.5 0 0 1 2.5-2.5Z"></path> + <path d="m3 3 18 18"></path> + </svg> + <div class="connection-copy"> + <p class="eyebrow">Crib camera</p> + <h2 class="state-title">Connecting</h2> + <p class="detail">Waiting for the camera stream</p> + </div> </section> </main> - <script src="app.js"></script> + <section + class="sound-meter" + id="sound-meter" + data-level="0" + role="meter" + aria-label="Room sound" + aria-valuemin="0" + aria-valuemax="5" + aria-valuenow="0" + aria-valuetext="Quiet" + hidden + > + <span class="sound-meter-heading"> + <span class="sound-meter-icon" aria-hidden="true"> + <svg class="line-icon" viewBox="0 0 24 24"> + <path d="M4 10v4M8 8v8M12 5v14M16 8v8M20 10v4"></path> + </svg> + </span> + <span class="sound-meter-label">Room sound</span> + </span> + <span class="sound-bars" aria-hidden="true"> + <span class="sound-bar"></span> + <span class="sound-bar"></span> + <span class="sound-bar"></span> + <span class="sound-bar"></span> + <span class="sound-bar"></span> + </span> + </section> + + <script type="module" src="app.js?v=20260818"></script> </body> </html> diff --git a/web/crib/sound-meter.js b/web/crib/sound-meter.js @@ -0,0 +1,289 @@ +const LEVEL_THRESHOLDS_DB = [-55, -45, -36, -27, -18]; +const LEVEL_LABELS = ["Quiet", "Quiet", "Sound", "Sound", "Loud", "Loud"]; +const READ_INTERVAL_MS = 100; +const PEAK_HOLD_MS = 280; +const RELEASE_INTERVAL_MS = 120; +const MIN_SIGNAL = 0.0001; +const SAMPLE_FORMAT = "f32-planar"; + +function levelForSignal(signal) { + const decibels = 20 * Math.log10(Math.max(signal, MIN_SIGNAL)); + let level = 0; + + while ( + level < LEVEL_THRESHOLDS_DB.length && + decibels >= LEVEL_THRESHOLDS_DB[level] + ) { + level += 1; + } + + return level; +} + +function readAudioEnergy(audioData) { + let energy = 0; + let sampleCount = 0; + + try { + for (let channel = 0; channel < audioData.numberOfChannels; channel += 1) { + const copyOptions = { format: SAMPLE_FORMAT, planeIndex: channel }; + const samples = new Float32Array( + audioData.allocationSize(copyOptions) / Float32Array.BYTES_PER_ELEMENT, + ); + audioData.copyTo(samples, copyOptions); + + for (const sample of samples) { + energy += sample * sample; + } + sampleCount += samples.length; + } + } finally { + audioData.close(); + } + + return [energy, sampleCount]; +} + +function readReceiverSignal(receiver, report) { + let signal = 0; + + for (const stat of report.values()) { + const isAudio = stat.kind === "audio" || stat.mediaType === "audio"; + if ( + stat.type === "inbound-rtp" && + isAudio && + Number.isFinite(stat.audioLevel) + ) { + signal = Math.max(signal, stat.audioLevel); + } + } + + for (const source of receiver.getSynchronizationSources?.() ?? []) { + if (Number.isFinite(source.audioLevel)) { + signal = Math.max(signal, source.audioLevel); + } + } + + return signal; +} + +function warnSampleFallback(error) { + console.warn( + "[cribd] raw audio activity is unavailable; using receiver levels", + error, + ); +} + +export class SoundMeter { + #element; + #source = null; + #sample = null; + #pollTimer = null; + #pendingRead = null; + #live = false; + #level = 0; + #peakUntil = 0; + #lastReleaseAt = 0; + #lastSampleAt = 0; + + constructor(element) { + this.#element = element; + } + + setLive(live) { + this.#live = live; + this.#syncVisibility(); + } + + start(receiver, track) { + if (track.kind !== "audio" || this.#source?.track === track) { + return; + } + + this.stop(); + this.#source = { receiver, track }; + this.#syncVisibility(); + + track.addEventListener( + "ended", + () => { + if (this.#source?.track === track) { + this.stop(); + } + }, + { once: true }, + ); + + if (!this.#startSampleReader(track)) { + this.#startReceiverPolling(); + } + } + + stop() { + const sample = this.#sample; + + if (this.#pollTimer !== null) { + window.clearInterval(this.#pollTimer); + } + + this.#source = null; + this.#sample = null; + this.#pollTimer = null; + this.#pendingRead = null; + this.#peakUntil = 0; + this.#lastReleaseAt = 0; + this.#lastSampleAt = 0; + this.#syncVisibility(); + if (this.#level !== 0) { + this.#level = 0; + this.#render(); + } + + sample?.reader.cancel().catch(() => undefined); + sample?.track.stop(); + } + + #syncVisibility() { + this.#element.hidden = !this.#live || this.#source === null; + } + + #startSampleReader(track) { + if (typeof window.MediaStreamTrackProcessor !== "function") { + return false; + } + + let sampleTrack = null; + + try { + sampleTrack = track.clone(); + const processor = new window.MediaStreamTrackProcessor({ + track: sampleTrack, + }); + const reader = processor.readable.getReader(); + this.#sample = { reader, track: sampleTrack }; + void this.#readSamples(reader, track); + return true; + } catch (error) { + sampleTrack?.stop(); + warnSampleFallback(error); + return false; + } + } + + async #readSamples(reader, track) { + let energy = 0; + let sampleCount = 0; + + try { + while (this.#isCurrentSample(reader, track)) { + const { done, value: audioData } = await reader.read(); + if (done) { + break; + } + + const [frameEnergy, frameSampleCount] = readAudioEnergy(audioData); + energy += frameEnergy; + sampleCount += frameSampleCount; + + const now = performance.now(); + if (now - this.#lastSampleAt < READ_INTERVAL_MS) { + continue; + } + + const rms = sampleCount === 0 ? 0 : Math.sqrt(energy / sampleCount); + this.#updateSignal(rms, now); + energy = 0; + sampleCount = 0; + this.#lastSampleAt = now; + } + } catch (error) { + if (this.#isCurrentSample(reader, track)) { + warnSampleFallback(error); + } + } + + if (this.#isCurrentSample(reader, track)) { + this.#sample.track.stop(); + this.#sample = null; + this.#startReceiverPolling(); + } + } + + #isCurrentSample(reader, track) { + return this.#sample?.reader === reader && this.#source?.track === track; + } + + #startReceiverPolling() { + if (this.#pollTimer !== null || this.#source === null) { + return; + } + + void this.#readReceiver(); + this.#pollTimer = window.setInterval( + () => void this.#readReceiver(), + READ_INTERVAL_MS, + ); + } + + async #readReceiver() { + const receiver = this.#source?.receiver; + if (receiver === undefined || this.#pendingRead !== null) { + return; + } + + const request = {}; + this.#pendingRead = request; + + try { + const report = await receiver.getStats(); + if (this.#source?.receiver === receiver) { + this.#updateSignal( + readReceiverSignal(receiver, report), + performance.now(), + ); + } + } catch { + if (this.#source?.receiver === receiver) { + this.#updateLevel(0, performance.now()); + } + } finally { + if (this.#pendingRead === request) { + this.#pendingRead = null; + } + } + } + + #updateSignal(signal, now) { + this.#updateLevel(levelForSignal(signal), now); + } + + #updateLevel(targetLevel, now) { + const previousLevel = this.#level; + + if (targetLevel > this.#level) { + this.#level = targetLevel; + this.#peakUntil = now + PEAK_HOLD_MS; + this.#lastReleaseAt = now; + } else if (targetLevel === this.#level && targetLevel > 0) { + this.#peakUntil = now + PEAK_HOLD_MS; + } else if ( + targetLevel < this.#level && + now >= this.#peakUntil && + now - this.#lastReleaseAt >= RELEASE_INTERVAL_MS + ) { + this.#level = Math.max(targetLevel, this.#level - 1); + this.#lastReleaseAt = now; + } + + if (this.#level !== previousLevel) { + this.#render(); + } + } + + #render() { + const level = String(this.#level); + this.#element.dataset.level = level; + this.#element.setAttribute("aria-valuenow", level); + this.#element.setAttribute("aria-valuetext", LEVEL_LABELS[this.#level]); + } +} |