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
98 changes: 54 additions & 44 deletions plots/waveform-audio/implementations/python/letsplot.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
""" pyplots.ai
""" anyplot.ai
waveform-audio: Audio Waveform Plot
Library: letsplot 4.8.2 | Python 3.14.3
Quality: 90/100 | Created: 2026-03-07
Library: letsplot 4.10.1 | Python 3.13.13
Quality: 90/100 | Updated: 2026-06-03
"""

import os

import numpy as np
import pandas as pd
from lets_plot import * # noqa: F403, F401
from lets_plot.export import ggsave as export_ggsave
from lets_plot.export import ggsave


LetsPlot.setup_html() # noqa: F405

# Data — synthetic audio waveform: tone with harmonics and amplitude envelope
# Theme tokens — Imprint palette, theme-adaptive chrome
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"
GRID_COLOR = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)"

# Data — synthetic audio waveform: 220 Hz tone with harmonics and ASR amplitude envelope
np.random.seed(42)
sample_rate = 22050
duration = 1.5
n_samples = int(sample_rate * duration)
time = np.linspace(0, duration, n_samples)

# Primary tone (220 Hz) with harmonics
fundamental = 220
signal = (
0.6 * np.sin(2 * np.pi * fundamental * time)
Expand All @@ -28,7 +38,7 @@
+ 0.05 * np.sin(2 * np.pi * fundamental * 5 * time)
)

# Amplitude envelope: attack-sustain-release shape
# Attack-sustain-release amplitude envelope
envelope = np.ones(n_samples)
attack_samples = int(0.05 * sample_rate)
release_samples = int(0.3 * sample_rate)
Expand All @@ -39,20 +49,16 @@
signal = signal * envelope
signal = signal / np.max(np.abs(signal))

# Downsample using min/max envelope — vectorized binning
# Downsample via min/max envelope binning to avoid aliasing at display resolution
n_bins = 800
bin_edges = np.linspace(0, n_samples, n_bins + 1, dtype=int)
time_env = np.array([time[(bin_edges[i] + bin_edges[i + 1]) // 2] for i in range(n_bins)])
amp_max = np.array([signal[bin_edges[i] : bin_edges[i + 1]].max() for i in range(n_bins)])
amp_min = np.array([signal[bin_edges[i] : bin_edges[i + 1]].min() for i in range(n_bins)])

# Compute amplitude magnitude per bin for color intensity mapping
amp_range = amp_max - amp_min

# Segment dataframe: vertical bars from ymin to ymax at each time point
df = pd.DataFrame({"time": time_env, "ymin": amp_min, "ymax": amp_max, "intensity": amp_range})

# Annotation data for waveform sections
ann_data = pd.DataFrame(
{
"time": [0.025, 0.225, 0.55, 0.95, 1.35],
Expand All @@ -61,16 +67,32 @@
}
)

# Section boundaries
section_df = pd.DataFrame({"x": [0.05, 0.4, 0.7, 1.2]})

# Subtitle with signal description
subtitle = "220 Hz fundamental + harmonics \u00b7 ASR envelope with amplitude dip at 0.4\u20130.7 s"
subtitle = "220 Hz fundamental + harmonics · ASR envelope with amplitude dip at 0.4–0.7 s"

# Theme-adaptive chrome applied after theme_minimal()
anyplot_chrome = theme( # noqa: F405
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG), # noqa: F405
panel_background=element_rect(fill=PAGE_BG), # noqa: F405
panel_grid_major_y=element_line(color=GRID_COLOR, size=0.3), # noqa: F405
panel_grid_major_x=element_blank(), # noqa: F405
panel_grid_minor=element_blank(), # noqa: F405
axis_title=element_text(color=INK, size=12), # noqa: F405
axis_text=element_text(color=INK_SOFT, size=10), # noqa: F405
axis_line=element_line(color=INK_SOFT), # noqa: F405
plot_title=element_text(color=INK, size=16), # noqa: F405
plot_subtitle=element_text(color=INK_SOFT, size=10), # noqa: F405
legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT), # noqa: F405
legend_text=element_text(color=INK_SOFT, size=10), # noqa: F405
legend_title=element_text(color=INK, size=12), # noqa: F405
legend_position="right",
plot_margin=[40, 20, 20, 20],
)

# Plot — vertical segments for DAW-style waveform rendering
plot = (
ggplot(df) # noqa: F405
# Waveform bars: vertical segments colored by intensity
# DAW-style vertical bars coloured by local amplitude range (Imprint sequential cmap)
+ geom_segment( # noqa: F405
mapping=aes(x="time", y="ymin", xend="time", yend="ymax", color="intensity"), # noqa: F405
size=1.5,
Expand All @@ -83,49 +105,37 @@
.line("Max: @ymax")
.line("Min: @ymin"),
)
+ scale_color_gradient(low="#7bafd4", high="#1a3a5c", name="Amplitude\nRange") # noqa: F405
# Imprint sequential: brand green (#009E73) → blue (#4467A3) for single-polarity data
+ scale_color_gradient(low="#009E73", high="#4467A3", name="Amplitude\nRange") # noqa: F405
# Zero reference line
+ geom_hline(yintercept=0, color="#999999", size=0.5, linetype="dashed") # noqa: F405
# Section boundary markers
+ geom_hline(yintercept=0, color=INK_MUTED, size=0.5, linetype="dashed") # noqa: F405
# Envelope section boundary markers
+ geom_vline( # noqa: F405
data=section_df,
mapping=aes(xintercept="x"), # noqa: F405
color="#CCCCCC",
color=INK_MUTED,
size=0.4,
linetype="dotted",
)
# Section annotations for storytelling
# Section labels — geom_text size is in mm (~2.845 mm = 1 pt)
+ geom_text( # noqa: F405
data=ann_data,
mapping=aes(x="time", y="y", label="label"), # noqa: F405
size=11,
color="#1a3a5c",
size=4,
color=INK,
fontface="italic",
)
+ scale_x_continuous(name="Time (seconds)", limits=[0, duration]) # noqa: F405
+ scale_y_continuous( # noqa: F405
name="Amplitude", limits=[-1.15, 1.18], breaks=[-1.0, -0.5, 0.0, 0.5, 1.0]
)
+ labs( # noqa: F405
title="waveform-audio \u00b7 letsplot \u00b7 pyplots.ai", subtitle=subtitle
)
+ ggsize(1600, 900) # noqa: F405
+ labs(title="waveform-audio · python · letsplot · anyplot.ai", subtitle=subtitle) # noqa: F405
# Canvas: 800×450 × scale=4 → 3200×1800 px (landscape 16:9)
+ ggsize(800, 450) # noqa: F405
+ theme_minimal() # noqa: F405
+ theme( # noqa: F405
axis_text=element_text(size=16), # noqa: F405
axis_title=element_text(size=20), # noqa: F405
plot_title=element_text(size=24), # noqa: F405
plot_subtitle=element_text(size=16, color="#555555"), # noqa: F405
legend_text=element_text(size=14), # noqa: F405
legend_title=element_text(size=16), # noqa: F405
legend_position="right", # noqa: F405
panel_grid_major_y=element_line(color="#E8E8E8", size=0.3), # noqa: F405
panel_grid_major_x=element_blank(), # noqa: F405
panel_grid_minor=element_blank(), # noqa: F405
plot_margin=[40, 20, 20, 20], # noqa: F405
)
+ anyplot_chrome
)

# Save
export_ggsave(plot, filename="plot.png", path=".", scale=3)
export_ggsave(plot, filename="plot.html", path=".")
# Save PNG (3200×1800 px) and interactive HTML — path="." keeps files in the current dir
ggsave(plot, filename=f"plot-{THEME}.png", path=".", scale=4)
ggsave(plot, filename=f"plot-{THEME}.html", path=".")
Loading
Loading