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:
+1
-1
Submodule nobilis updated: 9690905970...e0f6169073
+15
-1
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)))
|
||||
}
|
||||
Reference in New Issue
Block a user