empowered.house ↗

ai/voice.ex

Voice

A voice assistant as an orb in a ring of waveform bars, with its state and one line of transcript.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.ai_voice animated />
</div>
default · listening
isometric
speaking
thinking · hover · no glow
light surface
isometric · fade out · still

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
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
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); }