media/audio-waveform.ex
Audio waveform
An audio player with a waveform of bars, the played part in lime, for recordings and calls.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.media_audio_waveform animated />
</div>
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
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
/* 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); }