ai/voice.ex
Voice
A voice assistant as an orb in a ring of waveform bars, with its state and one line of transcript.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.ai_voice animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
caption |
string | "Operations assistant" |
|
timer |
string | "00:14" |
|
state |
"listening" | "thinking" | "speaking" | "listening" |
`listening` draws a moderate wave, `speaking` a tall one, `thinking` a flat ring with an arc going round. |
status |
any | nil |
The text of the state pill. Left empty (`nil`), it is the state name. |
transcript |
string | "Move the Northwind delivery to Friday" |
One line of up to 42 characters. |
level |
float | 0.7 |
How loud the wave is, from 0 to 1. |
Frame props, shared by every composition
| Prop | Type | Default | What it does |
|---|---|---|---|
animated |
boolean | false |
Turns motion on. Without it the composition renders in its final, still state. |
trigger |
"mount" | "in-view" | "in-view-repeat" | "in-view" |
When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view. |
hover |
boolean | false |
Enables the hover micro-interactions the composition supports. |
glow |
boolean | true |
The warm incandescent halo behind the composition. |
fade_out |
boolean | false |
Fades the bottom edge into the surface underneath. |
isometric |
boolean | false |
Tilts the composition into the isometric plane of the platform stack. |
theme |
"dark" | "light" | "dark" |
`dark` for the site canvas, `light` for the cream reversed surface. |
class |
string | nil |
Extra classes for the frame. |
rest |
global | — |
Any other HTML attribute, set on the frame. |
Source
Two files. Copy them into your app if you would rather own the code than depend on the library.
lib/eh_visuals/ai/voice.ex
defmodule EHVisuals.Ai.Voice do
@moduledoc """
A voice assistant: an orb inside a ring of waveform bars, the state it is
in, and the line it hears or says.
"""
use EHVisuals.Composition,
name: "ai/voice",
title: "Voice",
description:
"A voice assistant as an orb in a ring of waveform bars, with its state and one line of transcript."
@bars 44
attr(:caption, :string, default: "Operations assistant")
attr(:timer, :string, default: "00:14")
attr(:state, :string,
default: "listening",
values: ~w(listening thinking speaking),
doc:
"`listening` draws a moderate wave, `speaking` a tall one, `thinking` a flat ring with an arc going round."
)
attr(:status, :any,
default: nil,
doc: "The text of the state pill. Left empty (`nil`), it is the state name."
)
attr(:transcript, :string,
default: "Move the Northwind delivery to Friday",
doc: "One line of up to 42 characters."
)
attr(:level, :float, default: 0.7, doc: "How loud the wave is, from 0 to 1.")
attr(:label, :string,
default: "A voice assistant listening, with a ring of waveform bars around its orb"
)
frame_attrs()
@spec ai_voice(map()) :: Phoenix.LiveView.Rendered.t()
def ai_voice(assigns) do
status = assigns.status || String.capitalize(assigns.state)
assigns =
assigns
|> assign(:bars, bars(assigns.state, assigns.level))
|> assign(:live, assigns.state != "thinking")
|> assign(:status_text, status)
|> assign(:pill_w, Float.round(30 + String.length(status) * 5.8, 1))
~H"""
<.frame name="ai/voice" label={@label} {frame_opts(assigns)}>
<svg viewBox="0 0 320 276">
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="319" height="275" rx="12" />
<g class="ehv-a-fade ehv-i-1">
<text class="ehv-mono" x="20" y="29" font-size="8.5">{String.upcase(@caption)}</text>
<text class="ehv-mono ehv-t-2" x="300" y="29" font-size="8.5" text-anchor="end">
{@timer}
</text>
</g>
<g class="ehv-a-fade ehv-i-3">
<line
:for={bar <- @bars}
class={[
"ehv-ai-voice__bar ehv-i-#{bar.phase}",
bar.loud && "ehv-ai-voice__bar--loud",
@live && "ehv-ai-voice__bar--live"
]}
x1={bar.x1}
y1={bar.y1}
x2={bar.x2}
y2={bar.y2}
/>
</g>
<g :if={!@live} class="ehv-a-fade ehv-i-4">
<g class="ehv-ai-voice__spin">
<circle class="ehv-fill-none" cx="160" cy="120" r="71" />
<path class="ehv-ai-voice__arc ehv-round" d="M160 49 a71 71 0 0 1 71 71" />
</g>
</g>
<g class="ehv-a-pop ehv-i-2">
<circle class="ehv-card-3" cx="160" cy="120" r="34" />
<circle class="ehv-fill-lime ehv-a-ping ehv-i-6" cx="160" cy="120" r="11" />
<circle class="ehv-fill-lime" cx="160" cy="120" r="11" />
</g>
<g class="ehv-a-rise ehv-i-5">
<rect
class="ehv-fill-lime-soft"
x={160 - @pill_w / 2}
y="204"
width={@pill_w}
height="22"
rx="7"
/>
<circle
class="ehv-ai-voice__dot ehv-a-pulse"
cx={160 - @pill_w / 2 + 12}
cy="215"
r="2.5"
/>
<text class="ehv-mono ehv-t-lime" x={160 - @pill_w / 2 + 21} y="218.5" font-size="8.5">
{String.upcase(@status_text)}
</text>
</g>
<text class="ehv-a-fade ehv-i-7" x="160" y="252" font-size="13" text-anchor="middle">
{@transcript}
</text>
</g>
</svg>
</.frame>
"""
end
# The bars stand on a circle of radius 60 around the orb, each centred on
# it, so a bar that grows reaches inwards and outwards alike.
defp bars(state, level) do
level = level |> max(0) |> min(1)
for index <- 0..(@bars - 1) do
wave = abs(:math.sin(index * 0.7) * 0.6 + :math.sin(index * 1.9 + 1.3) * 0.4)
length =
case state do
"thinking" -> 4.0
"speaking" -> 8 + 26 * level * wave
_listening -> 5 + 17 * level * wave
end
angle = index * 2 * :math.pi() / @bars - :math.pi() / 2
{cos, sin} = {:math.cos(angle), :math.sin(angle)}
%{
phase: rem(index * 5, 24),
loud: state != "thinking" and wave > 0.55,
x1: Float.round(160 + (60 - length / 2) * cos, 1),
y1: Float.round(120 + (60 - length / 2) * sin, 1),
x2: Float.round(160 + (60 + length / 2) * cos, 1),
y2: Float.round(120 + (60 + length / 2) * sin, 1)
}
end
end
end
assets/css/ai/voice.css
/* ai/voice */
.ehv-ai-voice { --ehv-max-w: 350px; }
.ehv-ai-voice__bar {
stroke: var(--ehv-ink-3);
stroke-width: 2;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
transform-box: fill-box;
transform-origin: center;
}
.ehv-ai-voice__bar--loud { stroke: var(--ehv-ink); }
/* Each bar breathes around its own middle, on a phase taken from ehv-i-N. */
@keyframes ehv-ai-voice-wave { 50% { transform: scale(0.4); } }
.ehv--animated .ehv-ai-voice__bar--live {
animation: ehv-ai-voice-wave 1.3s ease-in-out infinite;
animation-delay: calc(var(--ehv-i, 0) * -110ms);
}
/* Thinking: an arc goes round the ring. The hidden circle keeps the group's box centred on the orb. */
.ehv-ai-voice__arc { fill: none; stroke: var(--ehv-lime); stroke-width: 2; }
.ehv-ai-voice__spin { transform-box: fill-box; transform-origin: center; }
@keyframes ehv-ai-voice-spin { to { transform: rotate(360deg); } }
.ehv--animated .ehv-ai-voice__spin { animation: ehv-ai-voice-spin 1.6s linear infinite; }
.ehv-ai-voice__dot { fill: var(--ehv-lime); }
.ehv--hover .ehv-ai-voice__bar { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-ai-voice__bar--loud { stroke: var(--ehv-lime); }