Code
import matplotlib.pyplot as plt
import numpy as np
import sonore as so
plt.rcParams.update({"font.size": 9, "axes.titlesize": 10, "figure.dpi": 100})
FS = 44100
F0 = so.note_to_freq("Eb4")
DUR = 1.0 # every tone [s]
GAP = 0.5 # silence before and between tones [s]
RELEASE = 0.1 # fade at the end of every tone [s]
HARMONICS = np.arange(1, 21)
def envelope(t, attack, duration=DUR):
"""A linear rise over ``attack`` seconds and a raised-cosine fade over the last RELEASE."""
fade = 0.5 - 0.5 * np.cos(np.pi * np.clip((duration - t) / RELEASE, 0, 1))
return np.minimum(1, t / attack) * fade
def tone(slope=1.0, attack=20e-3, duration=DUR):
"""E-flat 4 with 20 harmonics of amplitude n^-slope. ``slope`` may be a function of time
and ``attack`` (the linear rise of each partial, in seconds) a function of the harmonic
number."""
def gain(t, f, n):
s = slope(t) if callable(slope) else slope
rise = attack(n) if callable(attack) else attack
power = (HARMONICS[:, None] ** (-2.0 * np.asarray(s))).sum(0) # keeps the level steady
return float(n) ** -s / np.sqrt(power) * envelope(t, rise, duration)
return so.harmonic_complex(duration, FS, F0, harmonics=HARMONICS, amplitudes=gain)
def finish(snd):
"""How every sound in the gallery is played: 5 ms ramps, RMS 0.1, peak at most 0.95."""
snd = snd.ramp(5e-3).normalize(rms=0.1)
return snd.normalize(peak=0.95) if snd.peak > 0.95 else snd
def describe(snd):
"""Log attack time, median centroid [Hz] and median flux of one tone."""
return (
so.log_attack_time(snd),
so.spectral_centroid(snd).median[0],
so.spectral_flux(snd).median[0],
)
def sequence(tones):
"""The tones one after another, each at the same RMS, with GAP seconds before each one.
Returns the sound and each tone's start time."""
parts, starts, t = [], [], 0.0
for snd in tones:
parts += [so.silence(GAP, FS), snd.normalize(rms=0.1)]
starts.append(t + GAP)
t += GAP + snd.duration
return finish(so.concat(parts + [so.silence(0.2, FS)])), starts
def show(snd, tones, starts, labels, fmin=150, fmax=8000, lower="envelope"):
"""Spectrogram with the centroid track, and under it the waveform with each tone's
measured attack shaded (or, with ``lower="flux"``, the flux track). Returns the figure and the panels
the playhead follows."""
fig, (ax_s, ax_l) = plt.subplots(
2, 1, figsize=(10, 5.4), sharex=True, height_ratios=[1.7, 1], layout="constrained"
)
stft = so.STFT(snd, 30e-3, 5e-3)
keep = (stft.f >= fmin) & (stft.f <= fmax)
level = stft.db[0][keep]
ax_s.pcolormesh(stft.t, stft.f[keep], level, vmin=level.max() - 70, vmax=level.max(), cmap="magma")
centroid = so.spectral_centroid(snd)
ax_s.plot(centroid.t, centroid.values[0], color="c", lw=1.2, label="spectral centroid")
ax_s.legend(loc="upper right", fontsize=8)
ax_s.set(
yscale="log",
ylim=(fmin, fmax),
ylabel="Frequency [Hz]",
title="Spectrogram (Hann 30 ms) and spectral centroid (power)",
)
for start, label in zip(starts, labels, strict=True):
ax_s.text(start + 0.02, 0.8 * fmax, label, color="w", fontsize=8, va="top")
if lower == "flux":
flux = so.spectral_flux(snd)
ax_l.plot(flux.t, flux.values[0], color="C0")
ax_l.set(yscale="log", ylim=(1e-5, 1), ylabel="Flux", title="Spectral flux, spectra 100 ms apart")
else:
t = np.arange(snd.n_samples) / FS
ax_l.plot(t, np.abs(snd.data[:, 0]), color="0.75", lw=0.3)
for start, snd_tone in zip(starts, tones, strict=True):
begin, end = so.attack_segment(snd_tone)
ax_l.axvspan(start + begin, start + end, color="C1", alpha=0.35, lw=0)
ax_l.set(ylabel="Amplitude", title="Waveform magnitude, each measured attack shaded")
ax_l.set(xlim=(0, snd.duration), xlabel="Time [s]")
ax_l.grid(ls=":")
return fig, [ax_s, ax_l]