Skip to content
Brok UI

Loading…

No results

Voice Dictator

Open source

A voice-dictation widget: a circular mic toggle centred inside a halftone dot-cloud radial audio visualiser, with an aria-live transcript region below. Clicking the mic toggles listening; in demo mode (default, no permission needed) the dot-cloud pulses outward and a faux transcript types in word-by-word. With the live prop it progressively enhances to real SpeechRecognition when available. Token-derived colours, reduced-motion static ring.

Version
v1.0.3
Stability
stable
License
MIT
Related
Orb
Live Waveform
Audio Player

Preview

previews.components.voice-dictator.default.blade.php Blade
<div class="bg-card flex min-h-72 w-full items-center justify-center rounded-xl border p-8 dark">
    <x-ui.voice-dictator class="max-w-sm" />
</div>

Installation

terminal
php artisan ui:add voice-dictator

Note

This component ships an Alpine behavior module at resources/js/ui/voice-dictator.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './voice-dictator.js';

Registry contract

php artisan ui:add voice-dictator writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/voice-dictator.blade.php
  • js resources/js/ui/voice-dictator.js
Registry dependencies
button
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

voice-dictator.md
# Brok UI: Voice Dictator (`voice-dictator`)

A voice-dictation widget: a circular mic toggle centred inside a halftone dot-cloud radial audio visualiser, with an aria-live transcript region below. Clicking the mic toggles listening; in demo mode (default, no permission needed) the dot-cloud pulses outward and a faux transcript types in word-by-word. With the live prop it progressively enhances to real SpeechRecognition when available. Token-derived colours, reduced-motion static ring.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add voice-dictator
```

## Usage

```blade
<div class="bg-card flex min-h-72 w-full items-center justify-center rounded-xl border p-8 dark">
    <x-ui.voice-dictator class="max-w-sm" />
</div>
```

## Props

- `placeholder` (mixed|null, default `null`)
- `transcript` (mixed|null, default `null`)
- `live` (bool, default `false`)

## Use when

- Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
- Letting someone dictate text with a single mic toggle and a visible transcript.
- Demonstrating a voice input without asking for microphone permission.

## Avoid when

- Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
- Request microphone access only after explicit user intent and treat permission denial as a normal recoverable state.
- Production dictation on every browser: live mode depends on SpeechRecognition and degrades to demo where it is missing.
- Recording audio to store or play back; pair with audio-player for the resulting file, since this produces a transcript, not a recording.

## Anti-patterns

- Autoplay with sound
- Audio as the only access to essential information

## Rules

- Use the `<brok:voice-dictator>` tag (or `<x-ui.voice-dictator>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/voice-dictator
- Registry JSON (files, props, contract): https://brokui.dev/r/open/voice-dictator.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
placeholder mixed | null null
transcript mixed | null null
live bool false

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

voice-dictator voice-dictator-mic voice-dictator-transcript

Behavior

  • Clicking the mic toggles listening; the dot-cloud pulses while listening and settles when stopped.
  • In demo mode a faux transcript types in word by word; with live set, real SpeechRecognition fills the transcript where available.
  • The ring is static under prefers-reduced-motion.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.

Guidance

Audio and voice interaction

Play, inspect, record, or dictate temporal audio.

Use when

  • Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
  • Letting someone dictate text with a single mic toggle and a visible transcript.
  • Demonstrating a voice input without asking for microphone permission.

Avoid when

  • Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
  • Request microphone access only after explicit user intent and treat permission denial as a normal recoverable state.
  • Production dictation on every browser: live mode depends on SpeechRecognition and degrades to demo where it is missing.
  • Recording audio to store or play back; pair with audio-player for the resulting file, since this produces a transcript, not a recording.

Use instead

  • Silent scannable controls
  • Transcript or text input

Anti-patterns

  • Autoplay with sound
  • Audio as the only access to essential information
Anatomy
root voice-dictator-mic voice-dictator-transcript
Theming hooks
voice-dictator voice-dictator-mic voice-dictator-transcript

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The mic is a button whose aria-pressed and label change with the listening state.
  • The transcript is a polite live region, so dictated text is announced as it arrives.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<div wire:ignore>
    <div class="bg-card flex min-h-72 w-full items-center justify-center rounded-xl border p-8 dark">
        <x-ui.voice-dictator class="max-w-sm" />
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/voice-dictator.blade.php Blade
@props([
    // Helper text shown in the transcript region before any speech is captured.
    'placeholder' => null,
    // The faux transcript spoken in demo mode. Pass an array of words/tokens or a
    // string (split on whitespace). A sensible default is supplied.
    'transcript' => null,
    // Progressive enhancement: when true AND the browser exposes SpeechRecognition,
    // listening drives the transcript from the real microphone. Falls back to the
    // synthetic demo otherwise. DEFAULT false → always the synthetic demo so it
    // animates anywhere with no permission prompt.
    'live' => false,
])

@php
    // A voice-dictation widget: a circular mic toggle centred inside a halftone
    // dot-cloud radial visualiser, with an aria-live transcript region below.
    // Clicking the mic toggles listening; in demo mode (default) the dot-cloud
    // pulses outward and a faux transcript types in word-by-word. Colour is
    // token-only — the canvas resolves --primary/--accent/--foreground at runtime
    // (see voice-dictator.js), never hard-coded. Reduced motion paints a calm
    // static dot ring and reveals the transcript instantly instead of typing.
    $defaultTranscript = [
        __('Generate'), __('a'), __('warm,'), __('cinematic'), __('portrait'),
        __('of'), __('a'), __('lighthouse'), __('keeper'), __('at'), __('dawn,'),
        __('soft'), __('volumetric'), __('light'), __('and'), __('gentle'), __('mist.'),
    ];

    if (is_string($transcript)) {
        $words = array_values(array_filter(preg_split('/\s+/', trim($transcript)) ?: [], fn ($w) => $w !== ''));
    } elseif (is_array($transcript)) {
        $words = array_values(array_filter(array_map('strval', $transcript), fn ($w) => trim($w) !== ''));
    } else {
        $words = [];
    }
    if (count($words) === 0) {
        $words = $defaultTranscript;
    }

    $placeholderText = $placeholder ?? __('Generate expressive prompts by speaking naturally.');

    $config = [
        'words' => array_values($words),
        'live' => (bool) $live,
        'placeholder' => $placeholderText,
        'startLabel' => __('Start dictation'),
        'stopLabel' => __('Stop dictation'),
    ];
@endphp

<div
    data-slot="voice-dictator"
    x-data="uiVoiceDictator(@js($config))"
    :data-listening="listening"
    {{ $attributes->merge(['class' => 'flex w-full flex-col items-center gap-6']) }}
>
    {{-- Radial dot-cloud visualiser. The canvas is decorative (aria-hidden); the
         mic button sits centred above it and remains the only focusable control. --}}
    <div class="relative grid aspect-square w-full max-w-[18rem] place-items-center">
        <canvas
            x-ref="canvas"
            aria-hidden="true"
            class="pointer-events-none absolute inset-0 block size-full text-primary"
        ></canvas>

        <x-ui.button
            type="button"
            @click="toggle()"
            ::aria-pressed="listening ? 'true' : 'false'"
            ::aria-label="listening ? micStopLabel : micStartLabel"
            data-slot="voice-dictator-mic"
            class="relative size-20 rounded-full p-0 shadow-lg transition-transform duration-200 ease-out hover:scale-105 motion-reduce:transition-none [&_svg]:size-7"
            ::class="listening ? 'motion-safe:animate-pulse motion-reduce:animate-none' : ''"
        >
            {{-- Microphone glyph (idle) / stop glyph (listening). Decorative; the
                 accessible name is carried by the bound aria-label above. --}}
            <svg
                x-show="!listening"
                viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
                stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
            >
                <path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
                <path d="M19 10v1a7 7 0 0 1-14 0v-1" />
                <path d="M12 18v4M8 22h8" />
            </svg>
            <svg
                x-show="listening" x-cloak
                viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"
            >
                <rect x="6" y="6" width="12" height="12" rx="2" />
            </svg>
        </x-ui.button>
    </div>

    {{-- Transcript region: live so assistive tech announces words as they arrive.
         Shows helper text until listening produces output, then the running
         transcript with a soft caret while active. --}}
    <div
        data-slot="voice-dictator-transcript"
        role="status"
        aria-live="polite"
        class="min-h-[3.5rem] w-full max-w-prose text-center text-base leading-relaxed text-balance"
        :class="text ? 'text-foreground' : 'text-muted-foreground'"
    >
        <span x-show="!text" x-text="placeholder"></span>
        <span x-show="text" x-cloak><span x-text="text"></span><span
            x-show="listening"
            aria-hidden="true"
            class="ms-1 inline-block h-[1.1em] w-0.5 motion-safe:animate-pulse rounded-full bg-primary align-text-bottom motion-reduce:animate-none"
        ></span></span>
    </div>
</div>
resources/js/ui/voice-dictator.js JS
/**
 * Voice Dictator behaviour (radial dot-cloud visualiser + faux transcript).
 *
 * A voice-dictation widget. Clicking the mic toggles `listening`. While
 * listening:
 *   • the canvas paints a halftone DISC of small dots arranged on concentric
 *     rings; each dot's radius and opacity pulse outward from the centre on a
 *     synthetic audio envelope, so the cloud "breathes" like a live mic level;
 *   • a faux transcript is typed into the transcript region word-by-word on a
 *     timer. Clicking again stops, freezes the dots to a calm resting ring, and
 *     leaves whatever was transcribed in place.
 *
 * Source: `demo` is the DEFAULT (synthetic) — it animates anywhere with no mic
 * permission. When `live` is set AND the browser exposes SpeechRecognition, the
 * transcript is driven from the real microphone instead (with the same synthetic
 * dot-cloud for the visual); any failure quietly falls back to the demo words.
 *
 * Colour is SEMANTIC: dots are drawn in the root's tokens, resolved at runtime —
 * `--primary` (active rings) blended toward `--foreground` (outer rings), with
 * `--accent` as a mid stop — so the cloud follows the active theme rather than
 * any hard-coded value. Bare HSL channel tokens are wrapped in `hsl(...)`.
 *
 * Sizing: the canvas backing store is scaled for devicePixelRatio and a
 * ResizeObserver rebuilds the dot layout when the box changes, keeping dots crisp.
 *
 * Reduced motion: a single calm static dot ring is painted with no rAF loop, and
 * the transcript appears instantly (no word-by-word typing).
 *
 * Teardown: destroy() cancels the rAF, clears the typing timer, disconnects the
 * ResizeObserver, stops any SpeechRecognition, and stops any MediaStream tracks,
 * so nothing leaks when the component unmounts mid-listen.
 *
 * Self-registers on `alpine:init`. Plain JS — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiVoiceDictator', (config = {}) => ({
        words: Array.isArray(config.words) && config.words.length > 0
            ? config.words.slice()
            : ['Generate', 'expressive', 'prompts', 'by', 'speaking', 'naturally.'],
        live: !!config.live,
        // Localized copy is seeded from Blade (wrapped in __()) so JS stays free
        // of user-facing strings.
        placeholder: config.placeholder || '',
        micStartLabel: config.startLabel || 'Start dictation',
        micStopLabel: config.stopLabel || 'Stop dictation',

        // UI state read by the Blade.
        listening: false,
        text: '',

        _ctx: null,
        _dpr: 1,
        _dots: [],
        _rings: 0,
        _cx: 0,
        _cy: 0,
        _maxR: 0,
        _stops: [[0, 0, 0]],
        _raf: null,
        _ro: null,
        _start: null,
        _level: 0,
        _reduced: false,

        // Faux-transcript typing.
        _typeTimer: null,
        _wordIndex: 0,

        // Live SpeechRecognition (opt-in).
        _recognition: null,
        _stream: null,

        init() {
            const canvas = this.$refs.canvas;
            this._ctx = canvas.getContext('2d');
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

            this.build();
            this.drawStatic();

            let pending = false;
            this._ro = new ResizeObserver(() => {
                if (pending) return;
                pending = true;
                requestAnimationFrame(() => {
                    pending = false;
                    this.build();
                    if (!this.listening || this._reduced) this.drawStatic();
                });
            });
            this._ro.observe(canvas);
        },

        // ---- token colour resolution (mirrors pixel-canvas) -------------------

        resolveToken(name, root) {
            const cs = getComputedStyle(root);
            const raw = cs.getPropertyValue(name).trim();
            if (raw === '') return null;
            const probe = document.createElement('span');
            probe.style.color = raw;
            // Tokens here are stored as bare HSL channels; wrap before other formats.
            if (probe.style.color === '') {
                probe.style.color = `hsl(${raw})`;
                if (probe.style.color === '') probe.style.color = `oklch(${raw})`;
                if (probe.style.color === '') probe.style.color = `rgb(${raw})`;
            }
            if (probe.style.color === '') return null;
            document.body.appendChild(probe);
            const resolved = getComputedStyle(probe).color;
            document.body.removeChild(probe);
            return resolved && resolved !== '' ? resolved : null;
        },

        toRgb(color) {
            const c = this._ctx;
            c.save();
            c.fillStyle = 'CanvasText';
            c.fillStyle = color;
            const computed = c.fillStyle;
            c.restore();
            if (computed.startsWith('#')) {
                const hex = computed.slice(1);
                const full = hex.length === 3 ? hex.split('').map((h) => h + h).join('') : hex;
                return [
                    parseInt(full.slice(0, 2), 16),
                    parseInt(full.slice(2, 4), 16),
                    parseInt(full.slice(4, 6), 16),
                ];
            }
            const m = computed.match(/(\d+(?:\.\d+)?)/g);
            return m ? [Number(m[0]), Number(m[1]), Number(m[2])] : [0, 0, 0];
        },

        resolveStops() {
            const root = this.$refs.canvas;
            const current = getComputedStyle(root).color || 'currentColor';
            const primary = this.resolveToken('--primary', root) || current;
            const accent = this.resolveToken('--accent', root) || primary;
            const foreground = this.resolveToken('--foreground', root) || current;
            this._stops = [this.toRgb(primary), this.toRgb(accent), this.toRgb(foreground)];
        },

        colorAt(t) {
            const stops = this._stops;
            const clamped = t < 0 ? 0 : t > 1 ? 1 : t;
            const scaled = clamped * (stops.length - 1);
            const i = Math.min(stops.length - 2, Math.floor(scaled));
            const f = scaled - i;
            const a = stops[i];
            const b = stops[i + 1];
            return [
                Math.round(a[0] + (b[0] - a[0]) * f),
                Math.round(a[1] + (b[1] - a[1]) * f),
                Math.round(a[2] + (b[2] - a[2]) * f),
            ];
        },

        // ---- dot-cloud layout & rendering -------------------------------------

        // Lay out a disc of dots on concentric rings (denser rings outward), sized
        // to the box and scaled for DPR.
        build() {
            const canvas = this.$refs.canvas;
            this._dpr = Math.max(1, window.devicePixelRatio || 1);
            const cssW = Math.max(1, canvas.clientWidth);
            const cssH = Math.max(1, canvas.clientHeight);
            canvas.width = Math.round(cssW * this._dpr);
            canvas.height = Math.round(cssH * this._dpr);
            this._ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);

            this._cx = cssW / 2;
            this._cy = cssH / 2;
            // Leave room for the centred mic button: dots start beyond an inner gap.
            this._maxR = Math.min(cssW, cssH) / 2 - 4;

            this.resolveStops();

            const dots = [];
            const rings = 7;
            this._rings = rings;
            const innerR = this._maxR * 0.42; // clear zone around the mic button
            for (let ring = 0; ring < rings; ring++) {
                const rt = ring / (rings - 1);
                const radius = innerR + (this._maxR - innerR) * rt;
                // More dots on outer rings keeps an even halftone density.
                const dotCount = Math.max(6, Math.round(8 + ring * 6));
                const phase = ring * 0.5;
                for (let k = 0; k < dotCount; k++) {
                    const angle = (k / dotCount) * Math.PI * 2 + phase;
                    dots.push({
                        x: this._cx + Math.cos(angle) * radius,
                        y: this._cy + Math.sin(angle) * radius,
                        rt, // 0 (inner) .. 1 (outer): drives colour stop + delay
                    });
                }
            }
            this._dots = dots;
        },

        // Synthetic mic-level envelope (0..1) — a smooth, breathing pulse with a
        // little deterministic ripple so it reads as live audio, no Math.random.
        synthLevel(time) {
            const base = Math.sin(time * 2.4) * 0.35 + 0.5;
            const ripple = Math.sin(time * 6.1 + 1.3) * 0.12;
            return Math.max(0.05, Math.min(1, base + ripple));
        },

        loop(ts) {
            if (this._start == null) this._start = ts;
            const time = (ts - this._start) / 1000;
            this._level = this.synthLevel(time);
            this.draw(time);
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        // Render the dot cloud. `time` (sec) drives an outward-travelling pulse;
        // `level` (0..1) scales how strongly dots swell. When idle (level≈0) it
        // settles to the calm resting ring.
        draw(time) {
            const ctx = this._ctx;
            if (!ctx) return;
            const w = this.$refs.canvas.width / this._dpr;
            const h = this.$refs.canvas.height / this._dpr;
            ctx.clearRect(0, 0, w, h);

            const level = this.listening ? this._level : 0;
            for (const d of this._dots) {
                // A wave travelling outward from the centre: each ring peaks a beat
                // after the one inside it, so the pulse visibly radiates.
                const wave = Math.sin(time * 4 - d.rt * 4) * 0.5 + 0.5;
                const excite = level * wave;
                // Dot size: a calm base radius that swells with excitement.
                const radius = 1.4 + d.rt * 1.1 + excite * 3.2;
                const alpha = 0.18 + d.rt * 0.12 + excite * 0.7;
                const [r, g, b] = this.colorAt(d.rt * 0.5 + excite * 0.5);
                ctx.globalAlpha = Math.min(1, alpha);
                ctx.fillStyle = `rgb(${r},${g},${b})`;
                ctx.beginPath();
                ctx.arc(d.x, d.y, radius, 0, Math.PI * 2);
                ctx.fill();
            }
            ctx.globalAlpha = 1;
        },

        // Calm resting frame (idle / reduced-motion): no pulse.
        drawStatic() {
            this.draw(0);
        },

        // ---- toggle + transcript ---------------------------------------------

        toggle() {
            if (this.listening) {
                this.stop();
            } else {
                this.start();
            }
        },

        start() {
            this.listening = true;
            this.text = '';
            this._wordIndex = 0;

            if (!this._reduced) {
                this._start = null;
                this._raf = requestAnimationFrame((t) => this.loop(t));
            }

            // Live mode: try real recognition; on any failure, run the demo.
            if (this.live && this.startLive()) return;
            this.runDemoTranscript();
        },

        stop() {
            this.listening = false;
            if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
            if (this._typeTimer) { clearTimeout(this._typeTimer); this._typeTimer = null; }
            this.stopLive();
            this.drawStatic();
        },

        // Type the faux transcript word-by-word. Reduced motion drops the whole
        // line in at once instead of animating it.
        runDemoTranscript() {
            if (this._reduced) {
                this.text = this.words.join(' ');
                return;
            }
            const tick = () => {
                if (!this.listening || this._wordIndex >= this.words.length) {
                    this._typeTimer = null;
                    return;
                }
                this.text = this.text
                    ? `${this.text} ${this.words[this._wordIndex]}`
                    : this.words[this._wordIndex];
                this._wordIndex += 1;
                this._typeTimer = setTimeout(tick, 220);
            };
            tick();
        },

        // Progressive enhancement: drive `text` from real SpeechRecognition.
        // Returns true if recognition was started; false to fall back to demo.
        startLive() {
            const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
            if (!SR || !navigator.mediaDevices?.getUserMedia) return false;
            try {
                const rec = new SR();
                rec.continuous = true;
                rec.interimResults = true;
                rec.onresult = (event) => {
                    let out = '';
                    for (let i = 0; i < event.results.length; i++) {
                        out += event.results[i][0].transcript;
                    }
                    this.text = out.trim();
                };
                rec.onerror = () => { this.fallbackToDemo(); };
                rec.start();
                this._recognition = rec;
                return true;
            } catch {
                return false;
            }
        },

        fallbackToDemo() {
            this.stopLive();
            if (this.listening) this.runDemoTranscript();
        },

        stopLive() {
            if (this._recognition) {
                try { this._recognition.stop(); } catch { /* already stopped */ }
                this._recognition = null;
            }
            if (this._stream) {
                this._stream.getTracks().forEach((t) => t.stop());
                this._stream = null;
            }
        },

        destroy() {
            if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
            if (this._typeTimer) { clearTimeout(this._typeTimer); this._typeTimer = null; }
            if (this._ro) { this._ro.disconnect(); this._ro = null; }
            this.stopLive();
            this._dots = [];
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.0.3
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0