Voice button
A microphone button bound to a voice source: connect, mute, push-to-talk and end the session, with the view following along.
The voice button is the control that goes with a visual: it connects the voice source, mutes and unmutes it, and ends the session, and it shows where the session stands. The agent's own states (thinking, speaking) stay on the visual.
This demo runs on a simulated conversation: press the button to start it. No microphone is used.
import "@sinua/web/element"; // registers <sinua-view>
import type { SinuaViewElement } from "@sinua/web/element";
import { defineSinuaVoiceButtonElement, type SinuaVoiceButtonElement } from "@sinua/web/voice-button";
import { LocalMicVoiceSource } from "@sinua/voice/mic";
// <sinua-view pattern="glowing"></sinua-view>
// <sinua-voice-button mode="push-to-talk"></sinua-voice-button> (or mode="toggle", the default)
defineSinuaVoiceButtonElement();
const source = new LocalMicVoiceSource(); // any VoiceSource: a vendor source, the mic, a simulation
document.querySelector<SinuaViewElement>("sinua-view")!.voice = source;
const button = document.querySelector<SinuaVoiceButtonElement>("sinua-voice-button")!;
button.source = source; // the button connects, mutes and ends the session; the view shows it
button.addEventListener("voicebuttonchange", (e) => {
console.log("voice button:", e.detail.state); // ready, connecting, listening, muted, error
});
import { useState } from "react";
import { SinuaView, SinuaVoiceButton } from "@sinua/web/react";
import { LocalMicVoiceSource } from "@sinua/voice/mic";
export function Assistant() {
const [source] = useState(() => new LocalMicVoiceSource());
return (
<>
<SinuaView pattern="glowing" voice={source} style={{ width: 160, height: 160 }} />
{/* Press to connect; then press to mute and unmute. Long-press ends the session. */}
<SinuaVoiceButton source={source} mode="toggle" />
</>
);
}
States
| State | When | Looks like |
|---|---|---|
ready | not connected | a microphone |
connecting | a connection is starting | a spinning ring |
listening | connected, not muted | a ring on the edge that swells with the level |
muted | connected, muted | the microphone struck through, grey |
error | the connection failed | a warning; the accessible name says why |
Modes
toggle(the default): a press connects, then mutes and unmutes.- Push-to-talk (
mode="push-to-talk"on the web element,"pushToTalk"in code): pressing connects the first time and unmutes; releasing mutes. The session stays up between presses, so there's no reconnect delay. - In both modes, a long press (0.6 s), the small ✕, or Escape on the web ends the session.
- A source that can't mute ends the session on a press while listening, and push-to-talk behaves like toggle.
Mute
Muting sends silence and keeps the session up: no reconnect and no lost context. Every built-in source supports it, and a view bound to the source shows a muted cue. onConnectionChange says when a session starts and ends, which the agent's state can't: an agent can be idle while connected.
Sharing a source
A voice source holds one callback of each kind, so a second listener would silently replace the first. Views and the button share a source through SharedVoiceSource automatically. To listen yourself, subscribe through it too, never on the raw source:
import { SharedVoiceSource, type VoiceSource } from "@sinua/core";
// A source holds one callback of each kind. To listen yourself next to a view or a
// button, subscribe through the shared fan-out, never on the raw source.
export function watch(source: VoiceSource, pill: HTMLElement) {
const shared = SharedVoiceSource.of(source); // the same instance every time for this source
const offState = shared.onStateChange((state) => (pill.textContent = state));
const offLink = shared.onConnectionChange((connected) => pill.toggleAttribute("data-live", connected));
return () => {
offState();
offLink();
};
}
// Mute without ending the session: silence goes out, the agent keeps its context,
// and every view bound to the source shows the muted cue.
export const mute = (source: VoiceSource, muted: boolean) => SharedVoiceSource.of(source).setMuted(muted);
On iOS and Android the same fan-out has listenState, listenConnection, listenMute and friends, each returning a cancel.
Accessibility
On the web it's a real button: its name follows the state ("Start voice", "Microphone on", "Microphone muted"…), its hint says what a press does, and muted is aria-pressed. VoiceOver and TalkBack get a button role and an "End voice session" action. A screen reader can't hold a press, so in push-to-talk its activation toggles. Every word can be replaced with labels.