Discord calls: call volume and soundboard on the stage, voice processing switches

The call's volume moves from Settings onto the stage, as a button with its
slider above it, where it can be changed without leaving the call. A
soundboard button in a guild's voice channel opens the board: this
server's sounds, Discord's defaults, then other servers' (needing Nitro),
searchable. Settings > Voice gains Echo cancellation and Noise
suppression.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-01 15:19:48 -04:00
co-authored by Claude Opus 5.5
parent 11c4def803
commit ccd9936757
6 changed files with 280 additions and 22 deletions
+1 -1
Submodule nobilis updated: 00d4f50ce9...72ef1b51aa
+83
View File
@@ -7604,3 +7604,86 @@ video.lightbox-media {
min-width: 0;
font-weight: 600;
}
/* A small panel above a call button: the call's volume, the soundboard. */
.stage-panel {
padding: var(--spacing-m);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius);
background: var(--surface-container-high);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
}
.call-volume-panel {
display: flex;
align-items: center;
gap: 10px;
font-variant-numeric: tabular-nums;
}
.call-volume-panel input {
width: 180px;
accent-color: var(--primary);
}
.call-volume-value {
min-width: 3.5em;
text-align: right;
}
.soundboard {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
width: min(380px, 90vw);
}
.soundboard .emoji-search {
margin: 0;
}
.soundboard-list {
max-height: 320px;
overflow-y: auto;
}
.soundboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 6px;
}
.soundboard-sound {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 8px 10px;
border: 0;
border-radius: var(--corner-radius-small);
background: var(--surface-container-highest);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.soundboard-sound:hover:not(:disabled) {
background: var(--primary-hover-light, var(--surface-container-highest));
outline: 1px solid var(--primary);
}
.soundboard-sound:disabled {
opacity: 0.4;
cursor: default;
}
.soundboard-sound img {
width: 18px;
height: 18px;
}
.soundboard-emoji {
font-size: 16px;
line-height: 1;
}
+156
View File
@@ -82,6 +82,8 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where:
// Only this call's account: one window can be in two accounts' calls.
const ownCamera = myCamera?.accountId === session.accountId ? myCamera.stream : null
const [choosing, setChoosing] = useState(false)
/** Which of the stage's two small panels is open: the call's volume, or the soundboard. */
const [panel, setPanel] = useState<'volume' | 'soundboard' | null>(null)
const shareStream = useChat((s) => s.discordShareStream)
const watching = useChat((s) => s.discordWatching)
const voicePrefs = useChat((s) => s.voicePrefs)
@@ -263,6 +265,51 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where:
/>
) : null
},
// The whole call's loudness, on the call rather than in Settings: it is
// changed while listening, by somebody who should not have to leave the
// call to do it.
{
icon: (voicePrefs.outputVolume ?? 1) === 0 ? 'volume_off' : (voicePrefs.outputVolume ?? 1) < 1 ? 'volume_down' : 'volume_up',
label: 'Call volume',
active: panel === 'volume',
onClick: () => setPanel(panel === 'volume' ? null : 'volume'),
popover:
panel === 'volume' ? (
<StagePanel onClose={() => setPanel(null)} label="Call volume">
<div className="call-volume-panel">
<span className="small muted">Call volume</span>
<input
type="range"
min={0}
max={200}
step={5}
value={Math.round((voicePrefs.outputVolume ?? 1) * 100)}
aria-label="Call volume"
onChange={(e) => store.setVoiceVolume(Number(e.target.value) / 100)}
/>
<span className="call-volume-value">{Math.round((voicePrefs.outputVolume ?? 1) * 100)}%</span>
</div>
</StagePanel>
) : null
},
// Sounds anybody in a guild's voice channel can set off. Not in a one-to-
// one call, which Discord gives no soundboard.
...(session.guildId
? [
{
icon: 'graphic_eq',
label: 'Soundboard',
active: panel === 'soundboard',
onClick: () => setPanel(panel === 'soundboard' ? null : 'soundboard'),
popover:
panel === 'soundboard' ? (
<StagePanel onClose={() => setPanel(null)} label="Soundboard">
<Soundboard accountId={accountId} />
</StagePanel>
) : null
}
]
: []),
// A stage's own two controls. In the audience: put a hand up, and once
// it is up - yours, or a moderator inviting you - step onto the stage.
// On the stage: step back down. Discord refuses a step up nobody allowed,
@@ -443,3 +490,112 @@ function volumeBadge(volume: number | undefined): string | undefined {
if (volume === undefined || Math.abs(volume - 1) < 0.005) return undefined
return `${Math.round(volume * 100)}%`
}
/**
* A small panel above a stage button, closed by Escape or a press anywhere
* else - except on the stage's buttons, which open and close it themselves.
*/
function StagePanel({ onClose, label, children }: { onClose: () => void; label: string; children: React.ReactNode }): JSX.Element {
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
const onDown = (e: MouseEvent): void => {
const target = e.target as HTMLElement
if (box.current?.contains(target) || target.closest('.stage-button')) return
onClose()
}
window.addEventListener('keydown', onKey)
window.addEventListener('mousedown', onDown)
return () => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('mousedown', onDown)
}
}, [onClose])
return (
<div className="stage-panel" ref={box} role="dialog" aria-label={label}>
{children}
</div>
)
}
interface SoundboardSound {
id: string
name: string
group: string
guildId?: string
emojiName?: string
emojiId?: string
locked: boolean
}
/**
* The soundboard: everything this account can play here, grouped as Discord
* groups it - this server's own, Discord's defaults, then the others, which
* need Nitro. Pressing one plays it to the whole channel, this end included.
*/
function Soundboard({ accountId }: { accountId: string }): JSX.Element {
const store = useStore()
const [sounds, setSounds] = useState<SoundboardSound[] | null>(null)
const [query, setQuery] = useState('')
useEffect(() => {
void window.moho
.rpc<SoundboardSound[]>('listSoundboard', { accountId })
.then(setSounds)
.catch(() => setSounds([]))
}, [accountId])
const q = query.trim().toLowerCase()
const groups = new Map<string, SoundboardSound[]>()
for (const s of sounds ?? []) {
if (q && !s.name.toLowerCase().includes(q)) continue
groups.set(s.group, [...(groups.get(s.group) ?? []), s])
}
const play = (s: SoundboardSound): void => {
void window.moho
.rpc('playSoundboard', { accountId, soundId: s.id, guildId: s.guildId })
.catch((e: Error) => store.toast('error', `Couldn't play ${s.name}: ${e.message}`))
}
return (
<div className="soundboard">
<div className="emoji-search">
<Icon name="search" size={16} />
<input autoFocus placeholder="Find a sound" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
<div className="soundboard-list">
{sounds === null && <p className="small muted">Loading…</p>}
{sounds !== null && groups.size === 0 && <p className="small muted">{q ? 'No matches.' : 'No sounds here.'}</p>}
{[...groups.entries()].map(([group, list]) => (
<div key={group}>
<div className="emoji-section small muted">{group}</div>
<div className="soundboard-grid">
{list.map((s) => (
<button
key={`${group}:${s.id}`}
type="button"
className="soundboard-sound"
disabled={s.locked}
title={s.locked ? `${s.name} - needs Nitro outside its own server` : s.name}
onClick={() => play(s)}
>
{s.emojiId ? (
<img src={`https://cdn.discordapp.com/emojis/${s.emojiId}.webp?size=32`} alt="" />
) : s.emojiName ? (
<span className="soundboard-emoji">{s.emojiName}</span>
) : (
<Icon name="music_note" size={16} />
)}
<span className="ellipsis">{s.name}</span>
</button>
))}
</div>
</div>
))}
</div>
</div>
)
}
@@ -283,7 +283,7 @@ function VoiceSettings(): JSX.Element {
description={
none
? "No sound devices were found. Voice needs a running sound server; everything else works without one."
: 'Used for voice calls. A change takes effect on a call already in progress. Muting is on the status plaque at the foot of the channel list, where it stays reachable during one.'
: 'Used for voice calls. A change takes effect on a call already in progress. Muting is on the status plaque at the foot of the channel list, and the call\'s volume on the call itself, where both stay reachable during one.'
}
>
<ChoiceSetting
@@ -301,30 +301,36 @@ function VoiceSettings(): JSX.Element {
onChange={(id) => void store.setVoiceDevice('output', id)}
disabled={none}
/>
{/* The whole call at once, for one that is simply too loud. One
person is turned up or down on their own tile in the call. */}
<div className="setting-row">
{/* What a Discord call's microphone goes through before it is sent -
the processing a Matrix call gets from the browser. The call's
volume is on the call itself, not here. */}
<label className="setting-row">
<div className="setting-text">
<div>Call volume</div>
<div>Echo cancellation</div>
<div className="small muted">
Everybody in a Discord call together. One person is set on their own tile, from the
slider that appears over it.
Takes what your speakers play back out of your microphone, so a call on speakers is not
sent back into itself. Off only makes sense with headphones.
</div>
</div>
<span className="call-volume">
<input
type="range"
min={0}
max={200}
step={5}
value={Math.round((voice.outputVolume ?? 1) * 100)}
aria-label="Call volume"
onChange={(e) => store.setVoiceVolume(Number(e.target.value) / 100)}
disabled={none}
/>
<span className="small muted">{Math.round((voice.outputVolume ?? 1) * 100)}%</span>
</span>
</div>
<input
type="checkbox"
className="setting-toggle"
checked={voice.echoCancellation ?? true}
onChange={(e) => void store.setVoiceProcessing({ echoCancellation: e.target.checked })}
/>
</label>
<label className="setting-row">
<div className="setting-text">
<div>Noise suppression</div>
<div className="small muted">Takes steady background noise - a fan, a hum - out of your microphone.</div>
</div>
<input
type="checkbox"
className="setting-toggle"
checked={voice.noiseSuppression ?? true}
onChange={(e) => void store.setVoiceProcessing({ noiseSuppression: e.target.checked })}
/>
</label>
</SettingsSection>
)
}
+9
View File
@@ -1382,6 +1382,15 @@ export class ChatStore {
}, 150)
}
/** Echo cancellation and noise suppression on a Discord call's microphone. */
async setVoiceProcessing(change: { echoCancellation?: boolean; noiseSuppression?: boolean }): Promise<void> {
try {
this.set({ voicePrefs: await window.moho.rpc<VoicePrefs>('setVoiceProcessing', change) })
} catch (e) {
this.toast('error', `Couldn't change that: ${(e as Error).message}`)
}
}
/** On a stage: asks to speak, or takes the request back. */
async setStageHand(accountId: string, guildId: string, channelId: string, on: boolean): Promise<void> {
try {
+4
View File
@@ -198,6 +198,10 @@ export interface VoicePrefs {
outputVolume?: number
/** People turned up or down, by Discord user id. Absent is 1. */
userVolumes?: Record<string, number>
/** Echo cancellation on a Discord call's microphone. Absent is on. */
echoCancellation?: boolean
/** Noise suppression on it. Absent is on. */
noiseSuppression?: boolean
}
/** Someone sitting in a voice channel. */