Skip to content

Commit b96d60a

Browse files
feat(altair): implement spectrogram-basic (#2964)
## Implementation: `spectrogram-basic` - altair Implements the **altair** version of `spectrogram-basic`. **File:** `plots/spectrogram-basic/implementations/altair.py` **Parent Issue:** #2927 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20612806176)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent df465f4 commit b96d60a

2 files changed

Lines changed: 131 additions & 0 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
""" pyplots.ai
2+
spectrogram-basic: Spectrogram Time-Frequency Heatmap
3+
Library: altair 6.0.0 | Python 3.13.11
4+
Quality: 91/100 | Created: 2025-12-31
5+
"""
6+
7+
import altair as alt
8+
import numpy as np
9+
import pandas as pd
10+
11+
12+
# Data: Generate a chirp signal with increasing frequency
13+
np.random.seed(42)
14+
sample_rate = 4000 # Hz
15+
duration = 2.0 # seconds
16+
n_samples = int(sample_rate * duration)
17+
t = np.linspace(0, duration, n_samples)
18+
19+
# Chirp signal: frequency sweeps from 100 Hz to 800 Hz (linear chirp)
20+
f0, f1 = 100, 800
21+
phase = 2 * np.pi * (f0 * t + (f1 - f0) / (2 * duration) * t**2)
22+
chirp_signal = np.sin(phase)
23+
24+
# Add some noise for realism
25+
chirp_signal += np.random.randn(len(chirp_signal)) * 0.1
26+
27+
# Compute spectrogram using numpy FFT (Short-Time Fourier Transform)
28+
nperseg = 256 # Window size
29+
hop_length = 32 # Step between windows (higher overlap for smoother result)
30+
window = np.hanning(nperseg)
31+
32+
# Calculate number of frames
33+
n_frames = (n_samples - nperseg) // hop_length + 1
34+
35+
# Initialize spectrogram matrix
36+
n_freq = nperseg // 2 + 1
37+
Sxx = np.zeros((n_freq, n_frames))
38+
39+
# Compute STFT
40+
for i in range(n_frames):
41+
start = i * hop_length
42+
segment = chirp_signal[start : start + nperseg] * window
43+
fft_result = np.fft.rfft(segment)
44+
Sxx[:, i] = np.abs(fft_result) ** 2
45+
46+
# Frequency and time arrays
47+
frequencies = np.fft.rfftfreq(nperseg, 1 / sample_rate)
48+
times = (np.arange(n_frames) * hop_length + nperseg / 2) / sample_rate
49+
50+
# Convert power to dB scale
51+
Sxx_db = 10 * np.log10(Sxx + 1e-10)
52+
53+
# Limit frequency range for better visualization (0-1000 Hz)
54+
freq_mask = frequencies <= 1000
55+
frequencies_subset = frequencies[freq_mask]
56+
Sxx_db_subset = Sxx_db[freq_mask, :]
57+
58+
# Create meshgrid and flatten for DataFrame
59+
time_grid, freq_grid = np.meshgrid(times, frequencies_subset)
60+
df = pd.DataFrame(
61+
{"Time (s)": time_grid.flatten(), "Frequency (Hz)": freq_grid.flatten(), "Power (dB)": Sxx_db_subset.flatten()}
62+
)
63+
64+
# Calculate bin sizes for proper rectangle rendering
65+
time_step = times[1] - times[0] if len(times) > 1 else 0.01
66+
freq_step = frequencies_subset[1] - frequencies_subset[0] if len(frequencies_subset) > 1 else 10
67+
68+
# Add bin edges for proper rectangle sizing
69+
df["time_start"] = df["Time (s)"] - time_step / 2
70+
df["time_end"] = df["Time (s)"] + time_step / 2
71+
df["freq_start"] = df["Frequency (Hz)"] - freq_step / 2
72+
df["freq_end"] = df["Frequency (Hz)"] + freq_step / 2
73+
74+
# Create spectrogram heatmap with Altair using x2/y2 for proper rectangles
75+
chart = (
76+
alt.Chart(df)
77+
.mark_rect()
78+
.encode(
79+
x=alt.X("time_start:Q", title="Time (s)", scale=alt.Scale(nice=False)),
80+
x2=alt.X2("time_end:Q"),
81+
y=alt.Y("freq_start:Q", title="Frequency (Hz)", scale=alt.Scale(nice=False)),
82+
y2=alt.Y2("freq_end:Q"),
83+
color=alt.Color(
84+
"Power (dB):Q",
85+
scale=alt.Scale(scheme="viridis"),
86+
legend=alt.Legend(
87+
title="Power (dB)", titleFontSize=18, labelFontSize=16, gradientLength=400, gradientThickness=20
88+
),
89+
),
90+
tooltip=[
91+
alt.Tooltip("Time (s):Q", format=".3f"),
92+
alt.Tooltip("Frequency (Hz):Q", format=".1f"),
93+
alt.Tooltip("Power (dB):Q", format=".1f"),
94+
],
95+
)
96+
.properties(width=1400, height=800, title="spectrogram-basic · altair · pyplots.ai")
97+
.configure_title(fontSize=28, anchor="middle")
98+
.configure_axis(labelFontSize=18, titleFontSize=22, tickSize=10)
99+
.configure_view(strokeWidth=0)
100+
)
101+
102+
# Save outputs
103+
chart.save("plot.png", scale_factor=3.0)
104+
chart.save("plot.html")
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
library: altair
2+
specification_id: spectrogram-basic
3+
created: '2025-12-31T05:35:22Z'
4+
updated: '2025-12-31T05:40:33Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20612806176
7+
issue: 2927
8+
python_version: 3.13.11
9+
library_version: 6.0.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/spectrogram-basic/altair/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/spectrogram-basic/altair/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/spectrogram-basic/altair/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent STFT implementation from scratch using numpy FFT, demonstrating understanding
17+
of signal processing
18+
- Proper use of x/x2/y/y2 encoding for precise rectangle positioning without gaps
19+
- Good use of viridis colormap as recommended in the specification for perceptually
20+
uniform display
21+
- Clean dB scale conversion with appropriate epsilon to avoid log(0)
22+
- Interactive tooltips showing Time, Frequency, and Power values
23+
- Correctly limits frequency range to 0-1000 Hz for better visualization
24+
weaknesses:
25+
- Missing .interactive() call which would enable zoom/pan for detailed exploration
26+
of the spectrogram
27+
- Colorbar font sizes could be slightly larger for better visibility

0 commit comments

Comments
 (0)