Audio Waveform Plot — Altair

A time-domain visualization of audio amplitude that displays the raw waveform shape as seen in digital audio workstations (DAWs). The plot shows positive and negative amplitude symmetrically around a zero baseline, with time on the x-axis and normalized amplitude (-1 to +1) on the y-axis. This is the fundamental representation for inspecting audio signals, revealing dynamics, clipping, silence, and transient characteristics at a glance.

Audio Waveform Plot rendered with Altair

Python source (Altair)

""" anyplot.ai
waveform-audio: Audio Waveform Plot
Library: altair 6.1.0 | Python 3.13.13
Quality: 92/100 | Updated: 2026-06-03
"""

import os

import altair as alt
import numpy as np
import pandas as pd
from PIL import Image


THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint palette — semantic anchor for clipping regions
CLIP_COLOR = "#AE3030"  # matte red: error/clipping role

# Data - synthetic audio: 440 Hz tone with harmonics and amplitude envelope
sample_rate = 22050
duration = 1.5
num_samples = int(sample_rate * duration)
time = np.linspace(0, duration, num_samples)

fundamental = 440
signal = (
    0.6 * np.sin(2 * np.pi * fundamental * time)
    + 0.25 * np.sin(2 * np.pi * 2 * fundamental * time)
    + 0.15 * np.sin(2 * np.pi * 3 * fundamental * time)
)

# Attack-sustain-release envelope with amplitude dip and brief clipping boost
envelope = np.ones_like(time)
attack = int(0.05 * sample_rate)
release = int(0.3 * sample_rate)
envelope[:attack] = np.linspace(0, 1, attack)
envelope[-release:] = np.linspace(1, 0, release)

# Smooth amplitude dip (0.4-0.7 s) via cosine taper — avoids abrupt rectangular step
dip_start = int(0.4 * sample_rate)
dip_end = int(0.7 * sample_rate)
transition_len = int(0.035 * sample_rate)
t_fade = np.linspace(0, 1, transition_len)
dip_mult = np.ones_like(time)
dip_mult[dip_start : dip_start + transition_len] = 1.0 - 0.25 * (1 - np.cos(np.pi * t_fade))
dip_mult[dip_start + transition_len : dip_end - transition_len] = 0.5
dip_mult[dip_end - transition_len : dip_end] = 0.5 + 0.25 * (1 - np.cos(np.pi * t_fade))
envelope *= dip_mult

envelope[int(0.15 * sample_rate) : int(0.25 * sample_rate)] *= 1.35

signal = signal * envelope
signal = np.clip(signal, -1.0, 1.0)

# Min/max envelope binning — 600 bins avoids sub-pixel vertical striping artifacts
num_bins = 600
bin_size = num_samples // num_bins
usable = num_bins * bin_size
signal_trimmed = signal[:usable].reshape(num_bins, bin_size)
time_trimmed = time[:usable].reshape(num_bins, bin_size)

df = pd.DataFrame(
    {
        "time": time_trimmed[:, bin_size // 2],
        "amp_min": signal_trimmed.min(axis=1),
        "amp_max": signal_trimmed.max(axis=1),
    }
)
df["clipped"] = (df["amp_max"] >= 0.99) | (df["amp_min"] <= -0.99)

# Shared encodings
x_enc = alt.X("time:Q", title="Time (seconds)", axis=alt.Axis(format=".2f", tickCount=8))
y_enc = alt.Y("amp_min:Q", title="Amplitude", scale=alt.Scale(domain=[-1.0, 1.0]))

# Nearest-point selection for interactive crosshair
nearest = alt.selection_point(nearest=True, on="pointerover", fields=["time"], empty=False)

# Main waveform: Imprint brand green (#009E73) vertical gradient
waveform_gradient = (
    alt.Chart(df)
    .mark_area(
        interpolate="linear",
        color=alt.Gradient(
            gradient="linear",
            stops=[
                alt.GradientStop(color="rgba(0, 158, 115, 0.28)", offset=0),
                alt.GradientStop(color="rgba(0, 158, 115, 0.60)", offset=0.45),
                alt.GradientStop(color="rgba(0, 158, 115, 0.60)", offset=0.55),
                alt.GradientStop(color="rgba(0, 158, 115, 0.28)", offset=1),
            ],
            x1=0,
            x2=0,
            y1=0,
            y2=1,
        ),
        line=False,
    )
    .encode(x=x_enc, y=y_enc, y2="amp_max:Q")
)

# Clipped regions overlay — Imprint matte red (#AE3030) semantic anchor
clipped_overlay = (
    alt.Chart(df)
    .mark_area(interpolate="linear", color="rgba(174, 48, 48, 0.50)", line=False)
    .encode(x="time:Q", y=y_enc, y2="amp_max:Q")
    .transform_filter(alt.datum.clipped == True)
)

# Zero baseline reference line (theme-adaptive)
zero_line = (
    alt.Chart(pd.DataFrame({"y": [0]}))
    .mark_rule(strokeWidth=1.5, opacity=0.35, strokeDash=[6, 4])
    .encode(y="y:Q", color=alt.value(INK_SOFT))
)

# Clipping threshold lines at ±1.0 (semantic matte red)
clip_lines = (
    alt.Chart(pd.DataFrame({"y": [-1.0, 1.0]}))
    .mark_rule(strokeWidth=0.8, opacity=0.3, strokeDash=[3, 5])
    .encode(y="y:Q", color=alt.value(CLIP_COLOR))
)

# Interactive crosshair following pointer
crosshair_rule = (
    alt.Chart(df)
    .mark_rule(strokeWidth=1, opacity=0.5)
    .encode(x="time:Q", color=alt.value(INK_SOFT))
    .transform_filter(nearest)
)

# Invisible selection trigger with tooltips
selection_layer = (
    alt.Chart(df)
    .mark_point(opacity=0)
    .encode(
        x="time:Q",
        y="amp_max:Q",
        tooltip=[
            alt.Tooltip("time:Q", title="Time (s)", format=".3f"),
            alt.Tooltip("amp_max:Q", title="Peak", format=".3f"),
            alt.Tooltip("amp_min:Q", title="Trough", format=".3f"),
        ],
    )
    .add_params(nearest)
)

# Compose layers and apply theme-adaptive configuration
chart = (
    alt.layer(waveform_gradient, clipped_overlay, zero_line, clip_lines, crosshair_rule, selection_layer)
    .properties(
        width=620,
        height=320,
        background=PAGE_BG,
        title=alt.Title(
            "waveform-audio · python · altair · anyplot.ai",
            fontSize=16,
            subtitle="440 Hz tone with harmonics · attack–sustain–release envelope · clipped region highlighted",
            subtitleFontSize=10,
        ),
    )
    .configure_view(fill=PAGE_BG, strokeWidth=0, continuousWidth=620, continuousHeight=320)
    .configure_axis(
        labelFontSize=10,
        titleFontSize=12,
        gridColor=INK,
        gridOpacity=0.15,
        domainColor=INK_SOFT,
        tickColor=INK_SOFT,
        labelColor=INK_SOFT,
        titleColor=INK,
    )
    .configure_title(color=INK, subtitleColor=INK_MUTED)
)

# Save PNG and pad to exact 3200×1800 (landscape target)
chart.save(f"plot-{THEME}.png", scale_factor=4.0)

TW, TH = 3200, 1800
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(
        f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
        "Shrink chart .properties(width=, height=) values and re-render."
    )
if _w < TW or _h < TH:
    _canvas = Image.new("RGB", (TW, TH), PAGE_BG)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

# Save interactive HTML
chart.interactive().save(f"plot-{THEME}.html")

Part of Audio Waveform Plot on anyplot.ai.

Other implementations