Skip to content
design
Components

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
The audio panel of a settings page beside the status strip's speaker button: the saloon, the cockpit intercom and a Bluetooth speaker, the ReSpeaker array with a live level and its capture details open, and the saloon muted with the alarm note under it.
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.

01Alarms are not part of itMuting or lowering an output changes music and zone audio. Alert speech plays at its own level, and a note under the output says so whenever the output is muted or low.
02Muting keeps the levelA muted output keeps its place on the slider, dimmed, so unmuting brings back the level the crew set.
03A meter, not a light showThe input meter draws each level as it arrives, holds the peak and marks a clip. It never eases between values and never announces itself on every update.

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 with aria-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 peakHoldMs and 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 a meter with 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. compact shows 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 reports clip_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 with inputLevel and the input mute only with a mute state or handler.

Examples

Compact

Anchored · Cala Portals
`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.
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

Output level

Alarms still sound at their own volume.

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.
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

Output level

Alarms still sound at their own volume.

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.
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.

With no output connected, the panel says so and what to check, and draws no level or mute to change.
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.

Output level
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.
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

Output level

Input

Input gain
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.
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

Output level

Input

The microphone is muted, so Bosun can't hear voice commands.

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.
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

Output level

Input

`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.
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

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.
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

PropTypeDefaultDescription
layoutAudioControlLayout | undefined"panel"panel for a settings page section; compact for a speaker button in a status strip or header.
outputsrequiredreadonly AudioOutput[]–The outputs the crew can send sound to. An empty list shows that none is connected.
outputstring | undefined–The chosen output's id, for a caller that holds it; pair it with onOutputChange.
defaultOutputstring | 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.
outputLevelnumber | undefined–The output level, 0 to 100 percent, for a caller that holds it; pair it with onOutputLevelChange.
defaultOutputLevelnumber | undefined50The 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.
mutedboolean | undefined–Whether the output is muted, for a caller that holds it; the slider keeps the level.
defaultMutedboolean | undefinedfalseWhether 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.
inputsreadonly 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).
inputstring | undefined–The chosen input's id, for a caller that holds it; pair it with onInputChange.
defaultInputstring | 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.
inputGainnumber | 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.
defaultInputGainnumber | 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.
inputLevelnumber | 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.
inputLevelScaleAudioInputLevelScale | undefined"dbfs"How inputLevel is given: dbfs (the default) or linear, a peak amplitude from 0 to 1.
peakHoldMsnumber | undefined1500How long the meter holds a peak before it falls, in milliseconds.
inputMutedboolean | 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.
defaultInputMutedboolean | 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.
captureFiguresCaptureFigures | 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.
clipLimitnumber | undefinedCLIP_LIMITAbove this clipping percentage the clipping stat carries a caution mark and a note shows under the input.
captureDetailsOpenboolean | undefined–Whether the capture details are open, for a caller that holds it; pair it with onCaptureDetailsOpenChange.
defaultCaptureDetailsOpenboolean | 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.
stepnumber | undefined5How far a step button moves a level, in percent. The arrow keys move it 1, Page Up and Page Down 10.
lowLevelnumber | undefined20At or below this output level, in percent, the note that alarms still sound shows, as it does when muted.
alarmNotestring | 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.
headingLevelNestedHeadingLevel | undefined3The level of the output and input headings in the page's outline.
openboolean | undefined–With compact: whether the popover is open, for a caller that holds it; pair it with onOpenChange.
defaultOpenboolean | 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.
motionboolean | undefinedtrueWhether the popover (with compact) and the device lists fade as they open; on by default.
classNamestring | undefined–Classes added to the root.
testIdstring | 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.
refRef<HTMLElement> | undefined–The root element: the panel, or with compact the speaker button.
Do

Output

Output level

Alarms still sound at their own volume.

Keep the alarm note under a muted or low output, so the crew know the alerts are heard.
Don't

Output

Output level

Alerts are silenced.

Don't let an audio control mute or turn down alert speech. A crew that silenced the music would not hear the anchor alarm.
  • 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.