Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 90 additions & 41 deletions plots/waveform-audio/implementations/python/altair.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,65 @@
""" pyplots.ai
""" anyplot.ai
waveform-audio: Audio Waveform Plot
Library: altair 6.0.0 | Python 3.14.3
Quality: 92/100 | Created: 2026-03-07
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 waveform: sine tone with harmonics and amplitude envelope
np.random.seed(42)
# 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)

# Primary tone (440 Hz) with harmonics
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)
)

# Amplitude envelope: attack-sustain(with dip)-release with brief clipping section
# 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)
envelope[int(0.4 * sample_rate) : int(0.7 * sample_rate)] *= 0.5
# Boost a short section to demonstrate clipping behavior

# 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)

# Vectorized min/max envelope binning for efficient rendering
num_bins = 2000
# 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)
Expand All @@ -53,25 +74,25 @@
)
df["clipped"] = (df["amp_max"] >= 0.99) | (df["amp_min"] <= -0.99)

# Shared x/y encodings
# 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 fill with vertical gradient
# Main waveform: Imprint brand green (#009E73) vertical gradient
waveform_gradient = (
alt.Chart(df)
.mark_area(
interpolate="monotone",
interpolate="linear",
color=alt.Gradient(
gradient="linear",
stops=[
alt.GradientStop(color="rgba(48, 105, 152, 0.10)", offset=0),
alt.GradientStop(color="rgba(48, 105, 152, 0.60)", offset=0.45),
alt.GradientStop(color="rgba(48, 105, 152, 0.60)", offset=0.55),
alt.GradientStop(color="rgba(48, 105, 152, 0.10)", offset=1),
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,
Expand All @@ -83,34 +104,37 @@
.encode(x=x_enc, y=y_enc, y2="amp_max:Q")
)

# Clipped regions overlay (filtered layer with red tint)
# Clipped regions overlay — Imprint matte red (#AE3030) semantic anchor
clipped_overlay = (
alt.Chart(df)
.mark_area(interpolate="monotone", color="rgba(180, 50, 50, 0.50)", line=False)
.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) # noqa: E712
)

# Zero baseline reference line
# Zero baseline reference line (theme-adaptive)
zero_line = (
alt.Chart(pd.DataFrame({"y": [0]}))
.mark_rule(color="#306998", strokeWidth=1.5, opacity=0.35, strokeDash=[6, 4])
.encode(y="y:Q")
.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
# Clipping threshold lines at ±1.0 (semantic matte red)
clip_lines = (
alt.Chart(pd.DataFrame({"y": [-1.0, 1.0]}))
.mark_rule(color="#b43232", strokeWidth=0.8, opacity=0.3, strokeDash=[3, 5])
.encode(y="y:Q")
.mark_rule(strokeWidth=0.8, opacity=0.3, strokeDash=[3, 5])
.encode(y="y:Q", color=alt.value(CLIP_COLOR))
)

# Interactive vertical crosshair following the pointer
# Interactive crosshair following pointer
crosshair_rule = (
alt.Chart(df).mark_rule(color="#306998", strokeWidth=1, opacity=0.5).encode(x="time:Q").transform_filter(nearest)
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 layer with tooltips
# Invisible selection trigger with tooltips
selection_layer = (
alt.Chart(df)
.mark_point(opacity=0)
Expand All @@ -126,24 +150,49 @@
.add_params(nearest)
)

# Compose layers and style
# Compose layers and apply theme-adaptive configuration
chart = (
alt.layer(waveform_gradient, clipped_overlay, zero_line, clip_lines, crosshair_rule, selection_layer)
.properties(
width=1600,
height=900,
width=620,
height=320,
background=PAGE_BG,
title=alt.Title(
"waveform-audio · altair · pyplots.ai",
fontSize=28,
"waveform-audio · python · altair · anyplot.ai",
fontSize=16,
subtitle="440 Hz tone with harmonics · attack–sustain–release envelope · clipped region highlighted",
subtitleFontSize=16,
subtitleColor="#666666",
subtitleFontSize=10,
),
)
.configure_axis(labelFontSize=18, titleFontSize=22, gridOpacity=0.12, domainColor="#aaaaaa", tickColor="#aaaaaa")
.configure_view(strokeWidth=0)
.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
chart.save("plot.png", scale_factor=3.0)
chart.interactive().save("plot.html")
# 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")
Loading
Loading