Audio control
The vessel's audio in one place, the output with its level and mute and the microphone with its gain, live level and capture figures.
On this page
Code
import {
AudioControl,
type AudioInput,
type AudioOutput,
type CaptureFigures,
} from "@bosunkit/components/audio-control";
import { Card } from "@bosunkit/components/card";
import { useEffect, useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"The audio panel of a settings page, beside the status strip's speaker button that opens the same controls: " +
"the saloon and cockpit zones and a Bluetooth speaker, the ReSpeaker array with a live level and its capture " +
"details open, and the saloon muted, so the note says alarms still sound.";
export const readsClock: boolean = true;
const OUTPUTS: readonly AudioOutput[] = [
{ id: "main", name: "Saloon", kind: "speaker" },
{ id: "intercom-cockpit", name: "Cockpit", kind: "intercom" },
{ id: "bt-F8_DF_15_2A_0C_91", name: "JBL Flip 6", kind: "bluetooth" },
];
const INPUTS: readonly AudioInput[] = [
{ id: "respeaker", name: "ReSpeaker array", kind: "array" },
{ id: "usb", name: "USB microphone", kind: "usb" },
{ id: "phone", name: "Skipper's phone", kind: "phone", state: "unavailable" },
];
/**
* The array's capture stats at anchor in a quiet saloon, measured `now`: the denoise chain's last 30 s window, the
* input overruns since the array was plugged back in, and the bearing of the last voice it heard.
*/
function saloonStats(now: number): CaptureFigures {
const replugged: number = now - (3 * 60 + 41) * 60_000;
return {
sampleRate: 16_000,
channels: 1,
chunkMs: 32,
measuredAt: now - 12_000,
windowSeconds: 30,
noiseFloor: -51.4,
speechPercent: 8.6,
clipPercent: 0.2,
dropouts: 2,
dropoutsSince: replugged,
reconnectedAt: replugged,
direction: 312,
directionAt: now - 47_000,
};
}
/** How often the simulated microphone reports a level, in milliseconds. */
const LEVEL_MS: number = 60;
const STRIP: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "var(--space-md)",
padding: "0 var(--space-sm) 0 var(--space-md)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-md)",
background: "var(--surface2)",
fontFamily: "var(--font-mono)",
fontSize: "0.85em",
};
/**
* A speech-like level in dBFS: the saloon's background noise around -50 dBFS, and now and then a crew member
* speaking, peaking between -20 and -6 dBFS.
*/
function useSimulatedLevel(): number {
const [level, setLevel]: [number, Dispatch<SetStateAction<number>>] = useState<number>(-50);
useEffect((): (() => void) => {
let speaking: number = 0;
const timer: ReturnType<typeof setInterval> = setInterval((): void => {
if (speaking <= 0 && Math.random() < 0.04) {
speaking = 20 + Math.floor(Math.random() * 30);
}
speaking -= 1;
setLevel(speaking > 0 ? -20 + Math.random() * 14 : -52 + Math.random() * 5);
}, LEVEL_MS);
return (): void => {
clearInterval(timer);
};
}, []);
return level;
}
export default function Showcase(): ReactElement {
const [output, setOutput]: [string, Dispatch<SetStateAction<string>>] = useState<string>("main");
const [level, setLevel]: [number, Dispatch<SetStateAction<number>>] = useState<number>(60);
const [muted, setMuted]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
const inputLevel: number = useSimulatedLevel();
const [stats]: [CaptureFigures, Dispatch<SetStateAction<CaptureFigures>>] = useState<CaptureFigures>(
(): CaptureFigures => saloonStats(Date.now()),
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%", maxWidth: "52em" }}>
<div style={STRIP}>
<span>SV Althea · 14:32 UTC</span>
<AudioControl
layout="compact"
outputs={OUTPUTS}
output={output}
onOutputChange={setOutput}
outputLevel={level}
onOutputLevelChange={setLevel}
muted={muted}
onMutedChange={setMuted}
/>
</div>
<Card title="Audio">
<AudioControl
headingLevel={4}
outputs={OUTPUTS}
output={output}
onOutputChange={setOutput}
outputLevel={level}
onOutputLevelChange={setLevel}
muted={muted}
onMutedChange={setMuted}
inputs={INPUTS}
defaultInput="respeaker"
defaultInputGain={70}
inputLevel={inputLevel}
defaultInputMuted={false}
captureFigures={stats}
defaultCaptureDetailsOpen
/>
</Card>
</div>
);
}
The audio control holds the output the crew listen on and the microphone bosun listens with. The output half is a
list of outputs, each with its kind and whether it is connected, a level from 0 to 100% and a mute button. The
input half is a list of microphones, the input gain, a mute button, a meter of the live input level and what bosund
measures of the capture: format, noise floor, speech, clipping, dropouts and the voice's direction. As a panel
it is a section of a settings page, the output and the microphone side by side where the panel itself is wider than
40em and one under the other where it is narrower; as compact it is a speaker button for a status strip or header
that opens the same controls in a popover. It takes every value as a prop and fetches nothing, and each value can be held by the
caller or by the control.
Behavior
- Outputs and inputs are kit selects. Each device shows an icon for its kind and, on the line under its name, its kind and state in words. An unavailable device is listed and cannot be chosen; a chosen device that drops its connection is named in a note with what to do, and its level is disabled.
- The level is a range input with a lower and a raise button on its sides, each a whole touch target, and its
value after it in tabular figures. The buttons move it by
step(5%), the arrow keys by 1%, Page Up and Page Down by 10%, Home and End to 0% and 100%. - Mute is a toggle button that keeps its name and says its state with
aria-pressed. The slider keeps the level, dimmed, and reads it as "60%, muted". - The alarm note shows while the output is muted or at or below
lowLevel(20%), and the mute button points at it witharia-describedby. In bosund an alert is spoken on the voice output at the alert's own gain, 1.15 for an alarm or a warning and 1.4 for an emergency, and an emergency also mutes every zone's music. The zone and music volume this control sets is the music player's, which alert speech does not pass through. - The meter runs from -60 to 0 dBFS in 20 segments of 3 dB, the last two in the hot color. It takes dBFS or a
linear peak from 0 to 1. The held peak is outlined, stays for
peakHoldMsand then falls at 20 dB a second; a level at 0 dBFS fills the clip mark for two seconds and shows a note to lower the gain. It is ameterwith its value in dBFS, not a live region, and its clock runs only while a peak or a clip mark is held. - Capture figures come in
captureFigures, each field optional and each shown only when set. As a panel they sit in a "Capture details" disclosure under the input: the format (sample rate, channels, chunk length), the noise floor in dBFS, the share of speech, the share of clipping, the dropouts, when the microphone last reconnected and the bearing of the last voice from the array's front, with an arrow turned to it. Times come in as epoch milliseconds and are written as ages from the control's own clock, which runs only while they are shown; a line under the list says how long a stretch the noise, speech and clipping figures cover and how long ago they were measured.compactshows one summary line instead: format, noise floor, clipping and age. - Clipping over
clipLimit(1%) carries a caution mark and the words "too loud" beside its value, never the color alone, and a note under the input that is in the page while the details are closed says to lower the gain. The limit is the kit's: bosund's denoise chain reportsclip_pct, the frames in which its soft clipper above -3 dBFS acted, and sets no threshold on it. - The input half shows only with
inputs, the gain slider only with a gain, the meter only withinputLeveland the input mute only with a mute state or handler.
Examples
Compact
Code
import { AudioControl, type AudioOutput } from "@bosunkit/components/audio-control";
import type { CSSProperties, ReactElement } from "react";
export const description: string =
'`layout="compact"` draws a speaker button for a status strip or header; it opens the output controls in a ' +
"popover, and its icon and name follow the level and the mute.";
const OUTPUTS: readonly AudioOutput[] = [
{ id: "main", name: "Saloon", kind: "speaker" },
{ id: "intercom-cockpit", name: "Cockpit", kind: "intercom" },
];
const STRIP: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "var(--space-md)",
width: "100%",
maxWidth: "30em",
padding: "0 var(--space-sm) 0 var(--space-md)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-md)",
background: "var(--surface2)",
fontFamily: "var(--font-mono)",
fontSize: "0.85em",
};
export default function Compact(): ReactElement {
return (
<div style={STRIP}>
<span>Anchored · Cala Portals</span>
<AudioControl layout="compact" outputs={OUTPUTS} defaultOutput="main" defaultOutputLevel={45} />
</div>
);
}
Muted
Output
Alarms still sound at their own volume.
Code
import { AudioControl, type AudioOutput } from "@bosunkit/components/audio-control";
import type { ReactElement } from "react";
export const description: string =
"The output muted at 60%: the slider keeps the level, dimmed, the readout says Muted, the mute button is " +
"pressed, and the note under them says alarms still sound.";
const OUTPUTS: readonly AudioOutput[] = [
{ id: "main", name: "Saloon", kind: "speaker" },
{ id: "intercom-cockpit", name: "Cockpit", kind: "intercom" },
];
export default function Muted(): ReactElement {
return (
<div style={{ width: "100%", maxWidth: "30em" }}>
<AudioControl outputs={OUTPUTS} defaultOutput="main" defaultOutputLevel={60} defaultMuted />
</div>
);
}
Low level
Output
Alarms still sound at their own volume.
Code
import { AudioControl, type AudioOutput } from "@bosunkit/components/audio-control";
import type { ReactElement } from "react";
export const description: string =
"At 15%, under the `lowLevel` of 20%, the alarm note shows as it does when muted. Raise the level past 20% and " +
"the note goes.";
const OUTPUTS: readonly AudioOutput[] = [{ id: "main", name: "Saloon", kind: "speaker" }];
export default function LowLevel(): ReactElement {
return (
<div style={{ width: "100%", maxWidth: "30em" }}>
<AudioControl outputs={OUTPUTS} defaultOutputLevel={15} />
</div>
);
}
No output connected
Output
No audio output is connected. Connect a Bluetooth speaker or check the speaker cable.
Code
import { AudioControl } from "@bosunkit/components/audio-control";
import type { ReactElement } from "react";
export const description: string =
"With no output connected, the panel says so and what to check, and draws no level or mute to change.";
export default function NoOutputs(): ReactElement {
return (
<div style={{ width: "100%", maxWidth: "30em" }}>
<AudioControl outputs={[]} />
</div>
);
}
Bluetooth speaker disconnected
Output
JBL Flip 6 is not connected. Turn it on, or choose another output.
Code
import { AudioControl, type AudioOutput } from "@bosunkit/components/audio-control";
import type { ReactElement } from "react";
export const description: string =
"The chosen Bluetooth speaker has dropped its connection: the panel names it, says what to do, and disables its " +
"level. In the list it is marked unavailable and cannot be chosen.";
const OUTPUTS: readonly AudioOutput[] = [
{ id: "main", name: "Saloon", kind: "speaker" },
{ id: "bt-F8_DF_15_2A_0C_91", name: "JBL Flip 6", kind: "bluetooth", state: "unavailable" },
];
export default function BluetoothDisconnected(): ReactElement {
return (
<div style={{ width: "100%", maxWidth: "30em" }}>
<AudioControl outputs={OUTPUTS} defaultOutput="bt-F8_DF_15_2A_0C_91" defaultOutputLevel={40} />
</div>
);
}
Input clipping
Output
Input
Code
import { AudioControl, type AudioInput, type AudioOutput } from "@bosunkit/components/audio-control";
import type { ReactElement } from "react";
export const description: string =
"The microphone reaching 0 dBFS: every segment is lit, the last two in the hot color, the clip mark is filled, " +
"and the note says to lower the input gain. The mark stays for two seconds after the last level at full scale.";
const OUTPUTS: readonly AudioOutput[] = [{ id: "main", name: "Saloon", kind: "speaker" }];
const INPUTS: readonly AudioInput[] = [{ id: "respeaker", name: "ReSpeaker array", kind: "array" }];
export default function InputClipping(): ReactElement {
return (
<div style={{ width: "100%", maxWidth: "30em" }}>
<AudioControl outputs={OUTPUTS} inputs={INPUTS} defaultInputGain={95} inputLevel={0} />
</div>
);
}
Microphone muted
Output
Input
The microphone is muted, so Bosun can't hear voice commands.
Code
import { AudioControl, type AudioInput, type AudioOutput } from "@bosunkit/components/audio-control";
import type { ReactElement } from "react";
export const description: string =
"The microphone muted: its mute button is pressed, the meter dims, and the note says Bosun can't hear voice " +
"commands until it is unmuted.";
const OUTPUTS: readonly AudioOutput[] = [{ id: "main", name: "Saloon", kind: "speaker" }];
const INPUTS: readonly AudioInput[] = [
{ id: "respeaker", name: "ReSpeaker array", kind: "array" },
{ id: "usb", name: "USB microphone", kind: "usb" },
];
export default function InputMuted(): ReactElement {
return (
<div style={{ width: "100%", maxWidth: "30em" }}>
<AudioControl outputs={OUTPUTS} inputs={INPUTS} inputLevel={-34} defaultInputMuted />
</div>
);
}
Linear level
Output
Input
Code
import { AudioControl, type AudioInput, type AudioOutput } from "@bosunkit/components/audio-control";
import type { ReactElement } from "react";
export const description: string =
'`inputLevelScale="linear"` takes a peak amplitude from 0 to 1, as the Web Audio API\'s analyser gives it: 0.25 ' +
"draws at -12 dBFS.";
const OUTPUTS: readonly AudioOutput[] = [{ id: "main", name: "Saloon", kind: "speaker" }];
const INPUTS: readonly AudioInput[] = [{ id: "phone", name: "Skipper's phone", kind: "phone" }];
export default function LinearLevel(): ReactElement {
return (
<div style={{ width: "100%", maxWidth: "30em" }}>
<AudioControl outputs={OUTPUTS} inputs={INPUTS} inputLevel={0.25} inputLevelScale="linear" />
</div>
);
}
Capture figures
Code
import {
AudioControl,
type AudioInput,
type AudioOutput,
type CaptureFigures,
} from "@bosunkit/components/audio-control";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"The capture details of the ReSpeaker array in a noisy engine room: clipping in 2.3% of the last 30 s is over the " +
"1% limit, so its value carries a caution mark and the words too loud, and a note outside the disclosure says to " +
"lower the gain. Each figure has its unit, and the line under them says how long a stretch they cover and when " +
"they were measured.";
export const readsClock: boolean = true;
const OUTPUTS: readonly AudioOutput[] = [{ id: "main", name: "Saloon", kind: "speaker" }];
const INPUTS: readonly AudioInput[] = [{ id: "respeaker", name: "ReSpeaker array", kind: "array" }];
/** Figures as bosund reports them for the array, measured `now`: the denoise chain's last 30 s window. */
function engineRoomStats(now: number): CaptureFigures {
return {
sampleRate: 16_000,
channels: 1,
chunkMs: 32,
measuredAt: now - 9_000,
windowSeconds: 30,
noiseFloor: -38.6,
speechPercent: 21,
clipPercent: 2.3,
dropouts: 0,
dropoutsSince: now - 47 * 60_000,
direction: 168,
directionAt: now - 14_000,
};
}
export default function CaptureStatsExample(): ReactElement {
const [stats]: [CaptureFigures, Dispatch<SetStateAction<CaptureFigures>>] = useState<CaptureFigures>(
(): CaptureFigures => engineRoomStats(Date.now()),
);
return (
<div style={{ width: "100%", maxWidth: "30em" }}>
<AudioControl
outputs={OUTPUTS}
inputs={INPUTS}
defaultInputGain={85}
inputLevel={-9}
captureFigures={stats}
defaultCaptureDetailsOpen
/>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| layout | AudioControlLayout | undefined | "panel" | panel for a settings page section; compact for a speaker button in a status strip or header. |
| outputsrequired | readonly AudioOutput[] | – | The outputs the crew can send sound to. An empty list shows that none is connected. |
| output | string | undefined | – | The chosen output's id, for a caller that holds it; pair it with onOutputChange. |
| defaultOutput | string | undefined | – | The output chosen at first, when the caller does not hold it. |
| onOutputChange | ((output: string) => void) | undefined | – | Called with the id of the output the crew choose. |
| outputLevel | number | undefined | – | The output level, 0 to 100 percent, for a caller that holds it; pair it with onOutputLevelChange. |
| defaultOutputLevel | number | undefined | 50 | The output level at first, 0 to 100 percent, when the caller does not hold it. |
| onOutputLevelChange | ((level: number) => void) | undefined | – | Called with the level, 0 to 100 percent, the slider, its step buttons or the keyboard asks for. |
| muted | boolean | undefined | – | Whether the output is muted, for a caller that holds it; the slider keeps the level. |
| defaultMuted | boolean | undefined | false | Whether the output starts muted, when the caller does not hold it. |
| onMutedChange | ((muted: boolean) => void) | undefined | – | Called with the mute state the mute button asks for. |
| inputs | readonly AudioInput[] | undefined | – | The microphones the crew can choose from. Unset hides the input half. bosund has no endpoint that lists its microphones, so a caller passes what it knows (the mic array while the voice pipeline runs). |
| input | string | undefined | – | The chosen input's id, for a caller that holds it; pair it with onInputChange. |
| defaultInput | string | undefined | – | The input chosen at first, when the caller does not hold it. |
| onInputChange | ((input: string) => void) | undefined | – | Called with the id of the input the crew choose. |
| inputGain | number | undefined | – | The input gain, 0 to 100 percent, for a caller that holds it. The gain slider shows only when this or
defaultInputGain is set: bosund does not expose the mic array's gain over HTTP. |
| defaultInputGain | number | undefined | – | The input gain at first, 0 to 100 percent, when the caller does not hold it. |
| onInputGainChange | ((gain: number) => void) | undefined | – | Called with the gain, 0 to 100 percent, the slider, its step buttons or the keyboard asks for. |
| inputLevel | number | undefined | – | The live input level, in the scale inputLevelScale names; the level meter shows only when it is set. The
meter draws each value as it arrives, without easing, holds the peak and marks a clip at 0 dBFS. bosund
reports the denoise chain's input level every 30 seconds, not a live level. |
| inputLevelScale | AudioInputLevelScale | undefined | "dbfs" | How inputLevel is given: dbfs (the default) or linear, a peak amplitude from 0 to 1. |
| peakHoldMs | number | undefined | 1500 | How long the meter holds a peak before it falls, in milliseconds. |
| inputMuted | boolean | undefined | – | Whether the microphone is muted, for a caller that holds it. The input mute button shows only when this,
defaultInputMuted or onInputMutedChange is set: bosund has no input mute endpoint. |
| defaultInputMuted | boolean | undefined | – | Whether the microphone starts muted, when the caller does not hold it. |
| onInputMutedChange | ((muted: boolean) => void) | undefined | – | Called with the mute state the input mute button asks for. |
| captureFigures | CaptureFigures | undefined | – | What bosund measures of the microphone. With panel the stats sit in a "Capture details" disclosure under the
input; with compact they are one summary line. Unset, nothing is drawn. Needs inputs. |
| clipLimit | number | undefined | CLIP_LIMIT | Above this clipping percentage the clipping stat carries a caution mark and a note shows under the input. |
| captureDetailsOpen | boolean | undefined | – | Whether the capture details are open, for a caller that holds it; pair it with onCaptureDetailsOpenChange. |
| defaultCaptureDetailsOpen | boolean | undefined | – | Whether the capture details start open, when the caller does not hold it. |
| onCaptureDetailsOpenChange | ((open: boolean) => void) | undefined | – | Called with the open state the capture details' disclosure asks for. |
| step | number | undefined | 5 | How far a step button moves a level, in percent. The arrow keys move it 1, Page Up and Page Down 10. |
| lowLevel | number | undefined | 20 | At or below this output level, in percent, the note that alarms still sound shows, as it does when muted. |
| alarmNote | string | undefined | – | The note shown while the output is muted or low, translated by the caller. By default it says that alarms still sound at their own volume, which is what bosund does: an alert is spoken at the alert's own gain, and the music and zone volume this control sets does not reach it. |
| headingLevel | NestedHeadingLevel | undefined | 3 | The level of the output and input headings in the page's outline. |
| open | boolean | undefined | – | With compact: whether the popover is open, for a caller that holds it; pair it with onOpenChange. |
| defaultOpen | boolean | undefined | – | With compact: whether the popover starts open. |
| onOpenChange | ((open: boolean) => void) | undefined | – | With compact: called with the open state the speaker button, Escape or a press outside asks for. |
| motion | boolean | undefined | true | Whether the popover (with compact) and the device lists fade as they open; on by default. |
| className | string | undefined | – | Classes added to the root. |
| testId | string | undefined | – | data-testid on the root; parts are ${testId}--meter, ${testId}--peak, ${testId}--clip,
${testId}--clipping-caution and, with
compact, ${testId}--trigger and ${testId}--panel. |
| ref | Ref<HTMLElement> | undefined | – | The root element: the panel, or with compact the speaker button. |
Output
Alarms still sound at their own volume.
Output
Alerts are silenced.
Related
- Select: the list each device choice opens.
- Popover: the panel the compact layout opens from its speaker button.
- Status strip: the row the compact speaker button sits in.
- Setting control: the other rows of the settings page the panel sits on.