empowered.house ↗

media/audio-waveform.ex

Audio waveform

An audio player with a waveform of bars, the played part in lime, for recordings and calls.

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

<div class="h-96 rounded-xl border border-border">
  <.media_audio_waveform animated />
</div>
default
isometric
playing · custom copy
own levels · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
title string "Weekly operations review" Up to 34 characters.
meta string "Meeting recording · 14 May · 6 people"
elapsed string "12:48"
duration string "42:10"
progress float 0.3 The played share of the recording, 0.0 to 1.0. Bars up to it are lime.
levels list [0.45, 0.57, 0.93, 0.72, 0.9, 0.38, ...] The bar heights, 0.0 to 1.0, oldest first. 16 to 72 values read well.
playing boolean false Shows the pause icon and, when animated, lets the bar at the playhead pulse.
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/media/audio-waveform.ex
audio-waveform.ex
defmodule EHVisuals.Media.AudioWaveform do
  @moduledoc """
  An audio player: a play button, the waveform as bars with the played part
  in lime, and the time.
  """

  use EHVisuals.Composition,
    name: "media/audio-waveform",
    title: "Audio waveform",
    description:
      "An audio player with a waveform of bars, the played part in lime, for recordings and calls."

  # A fixed, uneven wave: two sines of different period, so it reads as speech.
  @levels for index <- 0..53,
              do:
                Float.round(
                  min(
                    0.16 + 0.5 * abs(:math.sin(index * 0.55)) +
                      0.34 * abs(:math.sin(index * 1.7 + 1)),
                    1.0
                  ),
                  2
                )

  attr(:title, :string, default: "Weekly operations review", doc: "Up to 34 characters.")
  attr(:meta, :string, default: "Meeting recording · 14 May · 6 people")
  attr(:elapsed, :string, default: "12:48")
  attr(:duration, :string, default: "42:10")

  attr(:progress, :float,
    default: 0.3,
    doc: "The played share of the recording, 0.0 to 1.0. Bars up to it are lime."
  )

  attr(:levels, :list,
    default: @levels,
    doc: "The bar heights, 0.0 to 1.0, oldest first. 16 to 72 values read well."
  )

  attr(:playing, :boolean,
    default: false,
    doc: "Shows the pause icon and, when animated, lets the bar at the playhead pulse."
  )

  attr(:label, :string,
    default: "An audio player with a waveform, about a third of the recording played"
  )

  frame_attrs()

  @spec media_audio_waveform(map()) :: Phoenix.LiveView.Rendered.t()
  def media_audio_waveform(assigns) do
    levels = Enum.take(assigns.levels, 72)
    count = max(length(levels), 1)
    pitch = 270 / count
    w = Float.round(pitch * 0.56, 2)
    played = round(min(max(assigns.progress, 0.0), 1.0) * count)

    bars =
      for {level, index} <- Enum.with_index(levels) do
        h = Float.round(max(min(level, 1.0) * 52, 3.0), 1)

        %{
          x: Float.round(70 + index * pitch + (pitch - w) / 2, 2),
          y: Float.round(98 - h / 2, 1),
          h: h,
          played: index < played,
          head: index == played - 1,
          stagger: min(div(index * 20, count) + 2, 24)
        }
      end

    assigns =
      assigns
      |> assign(:bars, bars)
      |> assign(:bar_w, w)
      |> assign(:bar_rx, Float.round(w / 2, 2))

    ~H"""
    <.frame name="media/audio-waveform" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 360 156">
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="359" height="155" rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <text class="ehv-t-strong" x="20" y="33" font-size="13.5">{@title}</text>
            <text class="ehv-mono" x="20" y="49" font-size="8.5">{String.upcase(@meta)}</text>
          </g>
          <g class="ehv-a-pop ehv-i-2">
            <circle class="ehv-card-3 ehv-media-audio-waveform__play" cx="38" cy="98" r="18" />
            <path :if={!@playing} class="ehv-fill-ink" d="M33.5 90.5 l12 7.5 l-12 7.5 z" />
            <g :if={@playing}>
              <rect class="ehv-fill-ink" x="32" y="91" width="4" height="14" rx="1.2" />
              <rect class="ehv-fill-ink" x="40" y="91" width="4" height="14" rx="1.2" />
            </g>
          </g>
          <g :for={bar <- @bars} class={[bar.head && @playing && "ehv-a-pulse ehv-i-24"]}>
            <rect
              class={[
                "ehv-media-audio-waveform__bar ehv-a-grow-y",
                "ehv-i-#{bar.stagger}",
                bar.played && "ehv-media-audio-waveform__bar--played"
              ]}
              x={bar.x}
              y={bar.y}
              width={@bar_w}
              height={bar.h}
              rx={@bar_rx}
            />
          </g>
          <g class="ehv-a-fade ehv-i-6">
            <text class="ehv-mono ehv-t-lime" x="70" y="141" font-size="9">{@elapsed}</text>
            <text class="ehv-mono" x="340" y="141" font-size="9" text-anchor="end">{@duration}</text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end
end
assets/css/media/audio-waveform.css
audio-waveform.css
/* media/audio-waveform */
.ehv-media-audio-waveform { --ehv-max-w: 400px; --ehv-step: 45ms; }

/* Bars grow out of the middle line. The played ones take the line lime, which
   stays readable on the cream surface. */
.ehv-media-audio-waveform__bar { fill: var(--ehv-ink-4); }
.ehv-media-audio-waveform__bar--played { fill: var(--ehv-lime); }
.ehv .ehv-media-audio-waveform__bar.ehv-a-grow-y { transform-origin: center; }

.ehv--hover .ehv-media-audio-waveform__play { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-media-audio-waveform__play { stroke: var(--ehv-lime); }