Voice panel: a horizontal microphone bar, the connection's latency, and a share button (#292)

The three rising strokes looked like phone signal but were the microphone level. It is now a bar along the panel that fills as you speak (polled ten times a second, on a square-root scale between a quiet room and loud speech), with the voice server's round trip beside the title in green, amber or red. Discord calls get a screen-share button.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
2026-10-08 14:11:20 -04:00
co-authored by Claude Sonnet 5.5
parent 1eef37ee76
commit ad0f0c08cf
6 changed files with 171 additions and 41 deletions
+1 -1
Submodule nobilis updated: 9690905970...e0f6169073
+15 -1
View File
@@ -334,7 +334,12 @@ const SCENES = [
{ name: 'discord-guild', setup: discord(false) },
{
name: 'discord-voice-panel',
setup: discord(true),
setup: async (p) => {
await discord(true)(p)
// Somebody speaking, and a healthy connection.
await p.evaluate(`window.__shotsLevels = { micPeak: 0.05, rttMs: 42 }, true`)
await sleep(500)
},
clip: { x: 0, y: 480, width: 300, height: 320 }
},
{
@@ -385,6 +390,15 @@ const SCENES = [
await sleep(400)
}
},
{
name: 'discord-voice-panel-poor',
setup: async (p) => {
await discord(true)(p)
await p.evaluate(`window.__shotsLevels = { micPeak: 0.13, rttMs: 210 }, true`)
await sleep(500)
},
clip: { x: 0, y: 480, width: 300, height: 320 }
},
// Motion, recorded rather than photographed.
{
name: 'motion-hover-rows',
+47 -26
View File
@@ -5189,7 +5189,7 @@ video.lightbox-media {
.voice-panel-head {
display: flex;
align-items: center;
gap: var(--spacing-s);
gap: var(--spacing-xxs);
}
.voice-panel-state {
@@ -5198,39 +5198,54 @@ video.lightbox-media {
gap: var(--spacing-s);
flex: 1;
min-width: 0;
white-space: nowrap;
color: var(--success);
font-weight: var(--weight-semibold);
font-size: var(--font-size-small);
}
/* Rising bars, so a glance says whether the microphone is hearing anything
rather than only that a connection exists. */
.voice-level {
display: flex;
align-items: flex-end;
gap: 2px;
height: 12px;
flex: none;
}
.voice-level-bar {
width: 3px;
background: var(--outline);
border-radius: 1px;
}
.voice-level-bar:nth-child(1) {
/* What the microphone is hearing, as a bar along the panel: full width so it
cannot be mistaken for a connection indicator, which is what three little
rising strokes beside the title looked like. It fills as somebody speaks. */
.voice-panel .voice-level {
height: 4px;
}
.voice-level-bar:nth-child(2) {
height: 8px;
}
.voice-level-bar:nth-child(3) {
height: 12px;
margin: var(--spacing-xs) 0 var(--spacing-xs);
border-radius: var(--radius-pill);
background: var(--surface-container-highest);
overflow: hidden;
}
.voice-level-bar.lit {
background: var(--success);
.voice-panel .voice-level-fill {
display: block;
height: 100%;
border-radius: inherit;
background: var(--speaking);
transition: width 140ms linear;
}
.voice-panel .voice-level.hot .voice-level-fill {
background: var(--warning);
}
/* The round trip to the voice server, next to the title it qualifies. */
.voice-ping {
margin-left: auto;
padding-right: var(--spacing-xs);
font-size: var(--font-size-xs);
font-weight: var(--weight-regular);
font-variant-numeric: tabular-nums;
}
.voice-ping.good {
color: var(--success);
}
.voice-ping.fair {
color: var(--warning);
}
.voice-ping.poor {
color: var(--danger-text);
}
.voice-panel-where {
@@ -8663,3 +8678,9 @@ video.lightbox-media {
display: none;
}
}
/* A toggle that is on: sharing a screen, for one. */
.icon-button.active {
background: var(--primary-hover);
color: var(--primary);
}
+62 -13
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { useChat, useStore } from '../state/hooks'
import { DM_GROUP_ID } from '../lib/groups'
import { levelFill } from '../lib/voiceLevel'
/**
* The call you are in, shown above the account plaque.
@@ -19,7 +20,9 @@ export function VoicePanel(): JSX.Element | null {
const store = useStore()
const sessions = useChat((s) => s.voiceSessions)
const groups = useChat((s) => s.groups)
const sharing = useChat((s) => s.discordSharing)
const [level, setLevel] = useState(0)
const [rtt, setRtt] = useState<number | null>(null)
const session = sessions[0]
@@ -29,15 +32,33 @@ export function VoicePanel(): JSX.Element | null {
// whether this machine is actually hearing anything, which no amount of
// connection state answers.
const tick = async (): Promise<void> => {
// The screenshot harness has no call to listen to, and says what the
// panel should show (scripts/ui-shots.mjs). Only in a window it started.
const staged = window.moho.uiShots
? (window as unknown as { __shotsLevels?: { micPeak: number; rttMs: number | null } }).__shotsLevels
: undefined
if (staged) {
setLevel(staged.micPeak)
setRtt(staged.rttMs)
return
}
try {
const levels = await window.moho.rpc<{ accountId: string; micPeak: number }[]>('getVoiceLevels')
setLevel(levels.find((l) => l.accountId === session.accountId)?.micPeak ?? 0)
const levels = await window.moho.rpc<{ accountId: string; micPeak: number; rttMs?: number | null }[]>(
'getVoiceLevels'
)
const mine = levels.find((l) => l.accountId === session.accountId)
setLevel(mine?.micPeak ?? 0)
setRtt(mine?.rttMs ?? null)
} catch {
// A daemon that cannot answer is not worth a toast every second.
}
}
void tick()
const timer = setInterval(() => void tick(), 1000)
// Often enough that the bar follows speech rather than lagging it; not
// while the window is hidden, where nobody is looking at it.
const timer = setInterval(() => {
if (document.visibilityState === 'visible') void tick()
}, 150)
return () => clearInterval(timer)
}, [session])
@@ -47,23 +68,37 @@ export function VoicePanel(): JSX.Element | null {
// conversation itself, which the DM page holds.
const groupId = session.guildId ? `${session.accountId}|guild:${session.guildId}` : DM_GROUP_ID
const guildName = session.isDirect ? '' : (groups.find((g) => g.id === groupId)?.name ?? '')
// Thresholds, not a linear scale of full scale. Measured on this hardware,
// a quiet room sits around 0.008 and ordinary speech peaks between 0.02 and
// 0.05 - so anything scaled against 1.0 stays dark while somebody is
// talking, which is exactly the failure a meter exists to rule out.
const bars = level > 0.05 ? 3 : level > 0.03 ? 2 : level > 0.015 ? 1 : 0
const fill = levelFill(level)
const quality = rtt === null ? null : rtt < 80 ? 'good' : rtt < 160 ? 'fair' : 'poor'
return (
<div className="voice-panel">
<div className="voice-panel-head">
<span className="voice-panel-state">
<span className="voice-level" aria-hidden>
{[0, 1, 2].map((i) => (
<span key={i} className={`voice-level-bar${i < bars ? ' lit' : ''}`} />
))}
</span>
Voice Connected
{/* How the connection to the voice server is, in the one unit that
means anything: how long it takes to answer. Absent until the
first answer has come back, and where the service has no such
number. */}
{rtt !== null && quality && (
<span className={`voice-ping ${quality}`} title={`Round trip to the voice server: ${rtt} ms`}>
{rtt} ms
</span>
)}
</span>
{/* Sharing a screen into the call you are in, from where the call is
shown rather than only from inside it. Discord's calls only: those
are the ones that live in the daemon and have no stage of their
own to hold the control. */}
{session.accountId.startsWith('discord:') && (
<IconButton
name={sharing === session.accountId ? 'stop_screen_share' : 'screen_share'}
size={18}
title={sharing === session.accountId ? 'Stop sharing your screen' : 'Share your screen'}
className={sharing === session.accountId ? 'active' : undefined}
onClick={() => void store.toggleScreenShare()}
/>
)}
<IconButton
name="call_end"
size={18}
@@ -72,6 +107,20 @@ export function VoicePanel(): JSX.Element | null {
/>
</div>
{/* What the microphone is hearing, which is how somebody knows it is
the one they think it is before they say something into silence. */}
<div
className={`voice-level${fill > 0.85 ? ' hot' : ''}`}
role="meter"
aria-label="Microphone level"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(fill * 100)}
title="What your microphone is hearing"
>
<span className="voice-level-fill" style={{ width: `${Math.round(fill * 100)}%` }} />
</div>
<button
type="button"
className="voice-panel-where ellipsis"
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { levelFill } from './voiceLevel'
describe('the microphone level bar', () => {
it('is empty in a quiet room and full when loud', () => {
expect(levelFill(0)).toBe(0)
expect(levelFill(0.008)).toBe(0)
expect(levelFill(0.5)).toBe(1)
})
it('moves visibly for ordinary speech, which a linear scale would not', () => {
// Measured speech peaks of 0.02-0.05 against full scale 1.0.
expect(levelFill(0.02)).toBeGreaterThan(0.2)
expect(levelFill(0.05)).toBeGreaterThan(0.45)
expect(levelFill(0.05)).toBeLessThan(0.7)
})
it('only ever rises with the level', () => {
let last = -1
for (let p = 0; p <= 0.2; p += 0.005) {
const f = levelFill(p)
expect(f).toBeGreaterThanOrEqual(last)
last = f
}
})
it('stays inside the bar for nonsense', () => {
expect(levelFill(-1)).toBe(0)
expect(levelFill(10)).toBe(1)
})
})
+15
View File
@@ -0,0 +1,15 @@
/**
* How full the level bar is for a microphone peak (0-1).
*
* On a square-root scale between a quiet room and loud speech, not a linear
* scale of full scale. Measured on this hardware a quiet room sits around
* 0.008 and ordinary speech peaks between 0.02 and 0.05 - so anything scaled
* against 1.0 stays dark while somebody is talking, which is exactly the
* failure a meter exists to rule out. The root spreads that quiet end out the
* way hearing does.
*/
export function levelFill(peak: number): number {
const floor = Math.sqrt(0.008)
const top = Math.sqrt(0.12)
return Math.min(1, Math.max(0, (Math.sqrt(Math.max(0, peak)) - floor) / (top - floor)))
}