Speech

The speech module turns what the user says into text. SpeechState owns a dictation session: it captures audio from an AudioInput, feeds it to a SpeechRecognizer, keeps the transcript, and emits SpeechEvents. SpeechButton starts and stops the session and SpeechWaveform shows the input level while it captures.

Install

The application owns where the text goes. The state never edits an input on its own; subscribe to its events and insert the final transcript where it belongs.

Recognition and capture are both replaceable. Without further setup, the state uses the recognizer built into macOS or Windows and the default microphone. Implement SpeechRecognizer to use a cloud service or a local model, and AudioInput to feed audio from elsewhere.

#In a Slint application

speech.slint holds the part of this component a Slint file can be: the button and the waveform. Slint cannot capture audio or recognize speech, so the application owns the session — it captures, it recognizes, and it puts the transcript where the text belongs — and drives the component with three properties and one callback:

  • status — where the session is: idle, connecting, recording, or stopping.
  • has-recognizer — whether this build has a recognizer and an input at all, the question GPUI’s has_recognizer() answers. While it is false, SpeechButton draws nothing, unless show-when-unsupported: true.
  • available — whether the recognizer works right now, the question is_available() answers: false for a language the machine cannot recognize. The button stays drawn, and is disabled with the unavailable label.
  • levels — the recent input levels, oldest first, in 0.0..=1.0, one per level the application pushes. SpeechWaveform draws them, one bar each, the newest against the trailing edge, and glides from one level to the next over interval: set that to the rate the levels are pushed at (80 ms is the rate GPUI’s meter reports at) so the trail never stands still between them.
  • toggled(active) — the reader asked to start (true) or stop (false) the session. The application starts or stops its own session and moves status.

SpeechButton carries its label as its accessible name, and shows it on hover the way the GPUI button does. Speech.is-active(status) and Speech.is-capturing(status) are the two questions a layout asks, and SpeechStatus carries the same four values as the GPUI enum. There is no SpeechState here: the state a GPUI application gets from the crate is the state a Slint application owns itself, which is where the recognizer and the microphone live in both frameworks. Linux and the web have no recognizer built in, so has-recognizer: false is how an application that has none hides the button.

GPUINo GPUI version of this example yet.
import { ControlSize } from "theme.slint";
import { Speech, SpeechButton, SpeechStatus, SpeechWaveform } from "speech.slint";

Input {
    placeholder: "Type or dictate";

    // One bar per level, newest against the trailing edge. An empty history
    // draws nothing, so no condition is needed to keep it out of the way.
    SpeechWaveform {
        levels: root.levels;
        capturing: Speech.is-capturing(root.status);
        interval: root.level-interval;
        size: ControlSize.xsmall;
        width: 48px;
    }

    SpeechButton {
        status: root.status;
        has-recognizer: root.has-recognizer;
        available: root.available;
        size: ControlSize.xsmall;
        // The application starts or stops its own session here.
        toggled(active) => { root.request-dictation(active); }
    }
}

#Enable the feature

The microphone and the system recognizer are behind the speech feature:

[dependencies]
gpui-kit = { version = "{{gpui_kit_version}}", features = ["speech"] }
SlintNo Slint version of this example yet.

The feature adds cpal for audio capture. On Linux, building it needs the ALSA development package (libasound2-dev on Debian and Ubuntu).

Without the feature, and on the web, the types are still available but there is no default input and no system recognizer. A state then works only with both an application recognizer and an application input.

#Import

use gpui_kit::component::speech::{
    SpeechButton, SpeechEvent, SpeechState, SpeechStatus, SpeechWaveform,
};
// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { SpeechStatus, Speech, SpeechButton, SpeechWaveform } from "speech.slint";
use crate::components::button::Button;
use crate::components::group_box::{self, GroupBox};
use crate::components::input::Input;
use crate::components::speech::{SpeechButton, SpeechStatus, SpeechWaveform};
use crate::components::theme::{self, ControlSize, Tokens};
use iced::time::Instant;
use iced::widget::{column, container, mouse_area, row, scrollable, svg, text};
use iced::{Alignment as Axis, Color, Element, Length, Subscription, Task, Theme};
use std::time::Duration;

#Usage

#Dictate into an input

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The waveform and the microphone are the field's suffix. The waveform is
// always there — an empty history draws nothing, so no condition is needed
// to keep it out of the way — and the application starts and stops its own
// session on the button's callback.
let dictate = column![
    Input::new("speech-field")
        .value(&app.field)
        .placeholder("Type or dictate")
        .on_input(Message::FieldChanged)
        .suffix(
            row![
                SpeechWaveform::new()
                    .levels(app.levels.iter().copied())
                    .capturing(app.status.is_capturing())
                    .size(ControlSize::XSmall)
                    .width(48.0),
                SpeechButton::new()
                    .status(app.status)
                    .size(ControlSize::XSmall)
                    .on_toggle(Message::Toggle),
            ]
            .spacing(theme::SPACING_SM)
            .align_y(Axis::Center),
        ),
    line(&app.transcript, theme::FONT_SIZE_SM, |tokens| tokens
        .muted_foreground,),
]
.spacing(theme::SPACING_SM);

Create the state beside the input it fills, subscribe to it, and put the button and waveform in the input’s suffix:

use gpui_kit::component::{
    WindowExt as _, h_flex,
    input::{Input, InputState},
    notification::Notification,
    speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform},
};

let input = cx.new(|cx| InputState::new(window, cx));
let speech = cx.new(SpeechState::new);

let subscription = cx.subscribe_in(&speech, window, {
    let input = input.clone();
    move |_, _, event, window, cx| match event {
        SpeechEvent::Final(text) if !text.is_empty() => {
            input.update(cx, |input, cx| input.insert(text.clone(), window, cx));
        }
        SpeechEvent::Error(error) => {
            window.push_notification(Notification::error(error.to_string()), cx);
        }
        _ => {}
    }
});
VerticalLayout {
    spacing: Theme.spacing-sm;
    field := Input {
        placeholder: "Type or dictate";
        min-width: 280px;
        // No `if` around it: an empty history draws nothing, and a
        // conditional here would move both controls off the trailing
        // edge.
        SpeechWaveform {
            levels: root.levels;
            capturing: Speech.is-capturing(root.status);
            interval: root.level-interval;
            size: ControlSize.xsmall;
            width: 48px;
        }
        SpeechButton {
            status: root.status;
            size: ControlSize.xsmall;
            toggled(active) => {
                if active {
                    root.start-session();
                }
                else {
                    root.stop-session();
                }
            }
        }
    }
    if Speech.is-active(root.status): Text {
        text: root.transcript;
        color: Theme.muted-foreground;
        font-size: Theme.font-size-sm;
        wrap: word-wrap;
    }
}
let capturing = self.speech.read(cx).status().is_capturing();

Input::new(&self.input).suffix(
    h_flex()
        .gap_2()
        .when(capturing, |this| {
            this.child(SpeechWaveform::new(&self.speech).w(px(48.)).xsmall())
        })
        .child(SpeechButton::new(&self.speech).xsmall()),
)
SlintNo Slint version of this example yet.

Keep the subscription on the view that owns the input, for example in its _subscriptions list.

#Events

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// Every event carries the whole transcript, so the latest one is the preview.
// The events themselves are the application's: the component reports nothing,
// and this demo shows what the host would have carried.
let events = column![
    line(
        "Every event carries the whole transcript, so the latest one is the preview.",
        theme::FONT_SIZE_SM,
        |tokens| tokens.muted_foreground,
    ),
    row![
        SpeechWaveform::new()
            .levels(app.levels.iter().copied())
            .capturing(app.status.is_capturing())
            .size(ControlSize::Small),
        if app.last_event.is_empty() {
            line("Nothing yet", theme::FONT_SIZE_SM, |tokens| {
                tokens.muted_foreground
            })
        } else {
            line(&app.last_event, theme::FONT_SIZE_SM, |tokens| {
                tokens.foreground
            })
        },
    ]
    .spacing(theme::SPACING_MD)
    .align_y(Axis::Center),
]
.spacing(theme::SPACING_SM);
EventWhen
StartedA session started and audio is being captured.
Partial(text)The transcript changed while the user speaks.
Final(text)The session ended normally. The text may be empty.
CancelledThe session was cancelled and its transcript discarded.
Error(error)The session failed and ended.

Partial and Final carry the whole transcript of the session so far: every committed phrase followed by the current hypothesis. A later event replaces an earlier one, so show the latest Partial text as a preview and commit only the Final text. SpeechState::transcript() returns the same text during render:

let speech = self.speech.read(cx);

v_flex()
    .gap_2()
    .child(Input::new(&self.input))
    .when(speech.status().is_active(), |this| {
        this.child(
            div()
                .text_sm()
                .text_color(cx.theme().muted_foreground)
                .child(speech.transcript()),
        )
    })
VerticalLayout {
    spacing: Theme.spacing-sm;
    Text {
        text: "Every event carries the whole transcript, so the latest one is the preview.";
        color: Theme.muted-foreground;
        font-size: Theme.font-size-sm;
        wrap: word-wrap;
    }
    HorizontalLayout {
        spacing: Theme.spacing-md;
        alignment: start;
        cross-axis-alignment: center;
        SpeechWaveform {
            levels: root.levels;
            capturing: Speech.is-capturing(root.status);
            interval: root.level-interval;
            size: ControlSize.small;
            width: 96px;
        }
        Text {
            text: root.transcript == "" ? "Nothing yet" : root.transcript;
            color: root.transcript == "" ? Theme.muted-foreground : Theme.foreground;
            font-size: Theme.font-size-sm;
            wrap: word-wrap;
            vertical-alignment: center;
        }
    }
}

#Session control

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The state the component reads, and the three calls an application makes to
// it: start, stop, cancel. iced's element cannot own the session, so the
// buttons move the one this demo holds.
let session_status = format!("status: {}", status_text(app.status));
let session = column![
    row![
        control("Start", !app.status.is_active(), Message::Start),
        control("Stop", app.status.is_capturing(), Message::Stop),
        control("Cancel", app.status.is_active(), Message::Cancel),
    ]
    .spacing(theme::SPACING_SM)
    .align_y(Axis::Center),
    owned_muted(session_status, theme::FONT_SIZE_SM),
]
.spacing(theme::SPACING_SM);

SpeechButton toggles the session. To drive it from an action, a key binding, or another control, call the state directly:

speech.update(cx, |speech, cx| speech.start(cx));  // Does nothing while running.
speech.update(cx, |speech, cx| speech.stop(cx));   // Emits `Final` once the result is in.
speech.update(cx, |speech, cx| speech.cancel(cx)); // Emits `Cancelled` at once.
speech.update(cx, |speech, cx| speech.toggle(cx)); // `start` when idle, otherwise `stop`.
VerticalLayout {
    spacing: Theme.spacing-sm;
    HorizontalLayout {
        spacing: Theme.spacing-sm;
        alignment: start;
        Button {
            text: "Start";
            size: ControlSize.xsmall;
            enabled: !Speech.is-active(root.status);
            clicked => {
                root.start-session();
            }
        }
        Button {
            text: "Stop";
            size: ControlSize.xsmall;
            enabled: Speech.is-capturing(root.status);
            clicked => {
                root.stop-session();
            }
        }
        Button {
            text: "Cancel";
            size: ControlSize.xsmall;
            enabled: Speech.is-active(root.status);
            clicked => {
                root.cancel-session();
            }
        }
    }
    Text {
        text: "status: " + root.status-text();
        color: Theme.muted-foreground;
        font-size: Theme.font-size-sm;
    }
}

status() reports where the session is:

SpeechStatusMeaning
IdleNo session is running.
ConnectingAudio is captured while the recognizer connects.
RecordingAudio is captured and recognized.
StoppingCapture stopped; waiting for the final result.

is_active() is true for every status but Idle, and is_capturing() for Connecting and Recording. While Stopping, the button shows a spinner and ignores clicks. If the recognizer does not deliver its final result within the stop timeout, the session ends with the transcript so far. The timeout is 3 seconds by default:

let speech = cx.new(|cx| SpeechState::new(cx).stop_timeout(Duration::from_secs(5)));
SlintNo Slint version of this example yet.

#Button states

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The same control in its six states: idle, capturing, stopping, disabled,
// unavailable, and hidden when the host has no recognizer at all.
let states = row![
    state_cell(
        SpeechButton::new()
            .status(SpeechStatus::Idle)
            .size(ControlSize::XSmall),
        "Idle",
    ),
    state_cell(
        SpeechButton::new()
            .status(SpeechStatus::Recording)
            .size(ControlSize::XSmall),
        "Capturing",
    ),
    state_cell(
        SpeechButton::new()
            .status(SpeechStatus::Stopping)
            .size(ControlSize::XSmall),
        "Stopping",
    ),
    state_cell(
        SpeechButton::new().disabled(true).size(ControlSize::XSmall),
        "Disabled",
    ),
    state_cell(
        SpeechButton::new()
            .available(false)
            .size(ControlSize::XSmall),
        "Unavailable",
    ),
    state_cell(
        SpeechButton::new()
            .has_recognizer(false)
            .size(ControlSize::XSmall),
        "Hidden",
    ),
]
.spacing(theme::SPACING_LG)
.align_y(Axis::Start);

SpeechButton is a ghost icon Button with a localized tooltip and accessible name: a microphone at rest, and a pressed stop glyph while capturing.

  • When the state has no recognizer or no input on this platform (has_recognizer() is false), the button renders nothing, so an application can place it unconditionally. Use .show_when_unsupported(true) to render it disabled instead.
  • When the recognizer reports itself unavailable (is_available(cx) is false), for example because the language is not installed, the button is disabled with an “unavailable” tooltip.
  • .disabled(true) disables it for application reasons, such as a readonly field.
SpeechButton::new(&speech)
    .small()
    .show_when_unsupported(true)
    .disabled(readonly)
GridLayout {
    horizontal-stretch: 1;
    spacing: 16px;
    VerticalLayout {
        spacing: Theme.spacing-xs;
        alignment: center;
        // The cell is as wide as its caption, so the button needs
        // centring across it.
        cross-axis-alignment: center;
        SpeechButton {
            status: SpeechStatus.idle;
            size: ControlSize.xsmall;
        }
        Text {
            text: "Idle";
            font-size: Theme.font-size-xs;
            color: Theme.muted-foreground;
            horizontal-alignment: center;
        }
    }
    VerticalLayout {
        spacing: Theme.spacing-xs;
        alignment: center;
        // The cell is as wide as its caption, so the button needs
        // centring across it.
        cross-axis-alignment: center;
        SpeechButton {
            status: SpeechStatus.recording;
            size: ControlSize.xsmall;
        }
        Text {
            text: "Capturing";
            font-size: Theme.font-size-xs;
            color: Theme.muted-foreground;
            horizontal-alignment: center;
        }
    }
    VerticalLayout {
        spacing: Theme.spacing-xs;
        alignment: center;
        // The cell is as wide as its caption, so the button needs
        // centring across it.
        cross-axis-alignment: center;
        SpeechButton {
            status: SpeechStatus.stopping;
            size: ControlSize.xsmall;
        }
        Text {
            text: "Stopping";
            font-size: Theme.font-size-xs;
            color: Theme.muted-foreground;
            horizontal-alignment: center;
        }
    }
    VerticalLayout {
        spacing: Theme.spacing-xs;
        alignment: center;
        // The cell is as wide as its caption, so the button needs
        // centring across it.
        cross-axis-alignment: center;
        SpeechButton {
            status: SpeechStatus.idle;
            size: ControlSize.xsmall;
            enabled: false;
        }
        Text {
            text: "Disabled";
            font-size: Theme.font-size-xs;
            color: Theme.muted-foreground;
            horizontal-alignment: center;
        }
    }
    VerticalLayout {
        spacing: Theme.spacing-xs;
        alignment: center;
        // The cell is as wide as its caption, so the button needs
        // centring across it.
        cross-axis-alignment: center;
        SpeechButton {
            available: false;
            size: ControlSize.xsmall;
        }
        Text {
            text: "Unavailable";
            font-size: Theme.font-size-xs;
            color: Theme.muted-foreground;
            horizontal-alignment: center;
        }
    }
    VerticalLayout {
        spacing: Theme.spacing-xs;
        alignment: center;
        // The cell is as wide as its caption, so the button needs
        // centring across it.
        cross-axis-alignment: center;
        SpeechButton {
            has-recognizer: false;
            size: ControlSize.xsmall;
        }
        Text {
            text: "Hidden";
            font-size: Theme.font-size-xs;
            color: Theme.muted-foreground;
            horizontal-alignment: center;
        }
    }
}

#Choose a recognizer

The state picks its recognizer in this order:

  1. the one passed to .recognizer(...);
  2. otherwise the platform’s SystemRecognizer, unless .system_fallback(false) turned it off;
  3. otherwise none, and has_recognizer() is false.
use gpui_kit::component::speech::SystemRecognizer;

// Dictate Chinese regardless of the system language.
let speech = cx.new(|cx| {
    SpeechState::new(cx).recognizer(SystemRecognizer::new().locale("zh-CN"))
});

// Use the application's own recognizer instead of the system's.
let speech = cx.new(|cx| SpeechState::new(cx).recognizer(CloudRecognizer::new(client)));

// Never fall back to the system recognizer. Without a recognizer of its own,
// `has_recognizer()` is false and the button renders nothing.
let speech = cx.new(|cx| SpeechState::new(cx).system_fallback(false));
SlintNo Slint version of this example yet.

CloudRecognizer stands for an application type that implements SpeechRecognizer; see Implement a recognizer.

SystemRecognizer::new() recognizes the system’s current language; .locale(...) takes a BCP 47 tag such as en-US or zh-CN. A language the platform cannot recognize makes the recognizer unavailable.

#Implement a recognizer

Implement SpeechRecognizer to use any speech service. start opens a RecognitionSession and reports results through the SpeechSink it receives:

use gpui_kit::App;
use gpui_kit::component::speech::{
    RecognitionSession, SpeechError, SpeechRecognizer, SpeechSink,
};

/// Reports how much audio it has heard, as a stand-in for a real service.
struct DurationRecognizer;

impl SpeechRecognizer for DurationRecognizer {
    fn start(
        &self,
        sink: SpeechSink,
        cx: &mut App,
    ) -> Result<Box<dyn RecognitionSession>, SpeechError> {
        // A service would connect here and call `ready` once connected.
        sink.ready(cx);
        Ok(Box::new(DurationSession { sink, samples: 0 }))
    }
}

struct DurationSession {
    sink: SpeechSink,
    samples: usize,
}

impl RecognitionSession for DurationSession {
    fn push_audio(&mut self, samples: &[i16], cx: &mut App) {
        // 16 kHz mono, the default `audio_format`.
        self.samples += samples.len();
        let seconds = self.samples / 16_000;
        self.sink.hypothesis(format!("{seconds} s of audio"), cx);
    }

    fn finish(&mut self, cx: &mut App) {
        let seconds = self.samples / 16_000;
        self.sink.phrase(format!("{seconds} s of audio"), cx);
        self.sink.finish(cx);
    }
}
SlintNo Slint version of this example yet.

A session runs as follows:

  1. start opens the session. Connecting may take a while, so return at once, buffer the audio pushed in the meantime, and call sink.ready(cx) when the service accepts audio. The status moves from Connecting to Recording.
  2. push_audio delivers interleaved 16-bit PCM in the recognizer’s audio_format(), 16 kHz mono by default. Report the phrase being spoken with sink.hypothesis(...), which replaces the previous hypothesis, and a recognized phrase with sink.phrase(...), which commits it and clears the hypothesis.
  3. finish means the user stopped talking: send the remaining audio, wait for the last result, then call sink.finish(cx). The state then emits Final.
  4. Dropping the session cancels it. Close the connection there and report nothing more.

Report a failure with sink.error(SpeechError::recognizer(error), cx); the session ends with SpeechEvent::Error.

A few rules keep a recognizer simple:

  • The sink is cheap to clone, so a task that reads the service’s responses can own a copy. Once its session is stopped, cancelled, or replaced, its calls are ignored, so a late response cannot leak into the next session.
  • Sink calls are applied after the current update. They may be made from anywhere on the main thread, including from inside start or push_audio.
  • Phrases are joined verbatim. Include any separator the language needs, such as a leading space between English sentences and none between Chinese ones.
  • Override is_available to return false while the recognizer cannot work, for example before the user signs in. It is called on every render, so keep it cheap.

#Provide audio

AudioInput is the audio seam. The built-in Microphone captures the default input device and converts it to the recognizer’s format. Replace it to feed audio from elsewhere, such as a file in tests:

use std::time::Duration;

use gpui_kit::{App, Subscription};
use gpui_kit::component::speech::{AudioFormat, AudioInput, AudioSink, SpeechError};

/// Pushes 100 ms of silence at a time.
struct Silence;

impl AudioInput for Silence {
    fn start(
        &self,
        format: AudioFormat,
        sink: AudioSink,
        cx: &mut App,
    ) -> Result<Subscription, SpeechError> {
        let len = format.sample_rate() as usize / 10 * format.channels() as usize;
        let task = cx.spawn(async move |cx| {
            loop {
                cx.background_executor().timer(Duration::from_millis(100)).await;
                cx.update(|cx| sink.push(vec![0; len], cx));
            }
        });
        // Capture runs until the state drops this subscription.
        Ok(Subscription::new(move || drop(task)))
    }
}

let speech = cx.new(|cx| SpeechState::new(cx).recognizer(recognizer).input(Silence));
SlintNo Slint version of this example yet.

levels() exposes the recent input levels that SpeechWaveform draws, in 0.0..=1.0 with the oldest first, one per 80 ms of audio, for an application that renders its own meter. Peaks rise at once and fall back smoothly, and background noise reads as 0.0.

#Platform support

PlatformSystemRecognizerMicrophone
macOSSFSpeechRecognizer, on the device onlyCore Audio
WindowsWindows.Media.SpeechRecognition, through Microsoft’s online serviceWASAPI
LinuxNone; provide a recognizerALSA
WebNoneNone; provide an input

#macOS

The application’s Info.plist must describe why it uses the microphone and speech recognition:

<key>NSMicrophoneUsageDescription</key>
<string>Dictate text into messages.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Turn your speech into text.</string>
SlintNo Slint version of this example yet.

Without NSMicrophoneUsageDescription, the system refuses microphone access without asking. Without NSSpeechRecognitionUsageDescription, the system recognizer reports itself unavailable instead of asking, because asking without it would terminate the process. A binary run outside an app bundle, such as from cargo run, has neither key.

Recognition runs on the device only, so audio never leaves the machine. A language the Mac cannot recognize offline is unavailable.

#Windows

Dictation needs the language’s speech pack and the Online speech recognition setting in Settings > Privacy & security > Speech. Audio is sent to Microsoft’s online speech service. An application that must keep audio on the device should use .system_fallback(false) with its own recognizer.

The Windows recognizer listens to the default microphone itself. The state still captures through its input, but only to drive the waveform, so a custom AudioInput does not change what the system recognizer hears.

#Linux

Linux has no system recognizer. Speech input works there only with an application recognizer; without one, SpeechButton renders nothing. The Microphone captures through ALSA, and building it needs libasound2-dev.

#API Reference

#SpeechStatus

Where a session is. Mirrors GPUI's `SpeechStatus`.

idle, connecting, recording, stopping

#SpeechButton

Starts and stops a session: a microphone at rest, a stop glyph while it captures, and a spinner while the final result is pending. Nothing is drawn while the host reports no recognizer, unless `show-when-unsupported` is set, so an application can place the button unconditionally. The button is a ghost icon button with a localized tooltip and accessible name, like GPUI's.

NameKindTypeDefaultDescription
statusinSpeechStatusSpeechStatus.idle
has-recognizerinbooltrueWhether this build has a recognizer and an input at all, the question GPUI's `has_recognizer()` answers. While it is false nothing is drawn, unless `show-when-unsupported` is set, so an application can place the button unconditionally.
availableinbooltrueWhether the recognizer works right now, the question GPUI's `is_available()` answers: false for a language the machine cannot recognize. The button stays drawn, and is disabled with the unavailable label.
show-when-unsupportedinboolfalseDraw a disabled button, instead of nothing, when there is no recognizer.
enabledinbooltrueDisable it for application reasons, such as a readonly field.
sizeinControlSizeControlSize.medium
start-labelinstring"Start dictation"
stop-labelinstring"Stop dictation"
unavailable-labelinstring"Speech input is not available"
toggledcallback(active: bool)The host starts a session when `active` is true, and stops the running one when it is false.
activeoutboolSpeech.is-active(root.status)
capturingoutboolSpeech.is-capturing(root.status)

#SpeechWaveform

A live waveform of the input levels the host pushes. Each level is a bar that grows up and down from the midline; silence is a dot. The newest level enters at the trailing edge and older ones trail toward the leading edge, so a short history grows from the right. Before any audio, nothing is drawn. Set `interval` to the rate the host pushes levels at. Each bar takes that long to reach its next level, so the trail glides from one to the next instead of stepping once per push and standing still in between. It is 96 px wide by default and its height follows `size`; size it like any element, for example `width: 48px` beside the button.

NameKindTypeDefaultDescription
levelsin[float]Oldest first, newest last: the order a session pushes levels in.
capturinginboolfalseColoured primary while the host captures, muted otherwise.
sizeinControlSizeControlSize.medium
intervalinduration80msThe time one level covers: the host's push rate, or the bars wait out the rest of it before moving again. GPUI's meter pushes every 80 ms.