From b8799c9a39f876f28de167c21fb6153984c20812 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Wed, 24 Jun 2026 10:13:56 -0700 Subject: [PATCH] makelab: fix plotting bugs, prune dead code, add docs + audio tests Review/cleanup of the makelab helper library (scoped to the .py files; notebooks untouched). Correctness: - plot_audio now passes its computed bit-depth title to plot_signal (previously discarded, so the title never appeared) - plot_spectrogram default title: duration is len(s)/sampling_rate, not len(s)*sampling_rate - == None -> is None in plot_signal/plot_spectrogram - set_xticks() before set_xticklabels() everywhere to avoid the matplotlib FixedFormatter warning students would otherwise see - convert_to_mono uses mean(axis=1) (correct for any channel count, not just stereo) Cleanup: - drop unused scipy/librosa/distance imports; the scipy.signal import was shadowed by local `signal` vars - de-duplicate map/remap (map is now a thin alias of remap) - remove dead last_zero_crossing_idx var; simplify the min_gap guard - hoist matplotlib.ticker import to the top Pedagogy: - standardize docstrings with Parameters/Returns; fix stale comments - add pointers to library equivalents (numpy.interp, scipy.ndimage.shift, librosa.zero_crossings, librosa.to_mono) noting behavioral differences - add Tutorials/makelab/README.md documenting the package + public API Tests: - add tests/test_makelab_audio.py (incl. a >2-channel case that guards the convert_to_mono fix) Verified: pytest tests/ (29 passed); Agg render smoke of all plot helpers with warnings-as-errors (clean); nbmake on the three signals notebooks (3 passed). Notebook-side follow-up tracked in #10. Co-Authored-By: Claude Opus 4.8 --- Tutorials/makelab/README.md | 87 +++++++++ Tutorials/makelab/audio.py | 33 ++-- Tutorials/makelab/signal.py | 359 +++++++++++++++++++++++++----------- tests/test_makelab_audio.py | 27 +++ 4 files changed, 384 insertions(+), 122 deletions(-) create mode 100644 Tutorials/makelab/README.md create mode 100644 tests/test_makelab_audio.py diff --git a/Tutorials/makelab/README.md b/Tutorials/makelab/README.md new file mode 100644 index 0000000..2d54f2d --- /dev/null +++ b/Tutorials/makelab/README.md @@ -0,0 +1,87 @@ +# makelab + +Shared signal-processing and audio helpers for the Makeability Lab **signals tutorial +notebooks** (`Tutorials/`). This package keeps the notebooks focused on the *concepts* by +handling the repetitive bits — generating test signals, finding zero crossings, and building +the multi-axis time/frequency plots used throughout the tutorials. + +> **Teaching code, on purpose.** This package is meant to be *read* by students. The +> implementations favor clarity over cleverness, cite their sources, and frequently show the +> alternative way a result could be computed. Where a function duplicates something a production +> library already does (e.g. `numpy.interp`, `scipy.ndimage.shift`, `librosa.to_mono`), a comment +> in the source points to that equivalent and notes any behavioral difference. Keep that style if +> you edit it. + +## Modules + +- **`makelab.signal`** — signal generation, analysis, and plotting. +- **`makelab.audio`** — audio post-processing (currently just stereo→mono). Audio *loading* + (librosa/soundfile) happens in the notebooks; this module only works on the arrays they produce. + +## Importing + +The repo is installed editable (`pip install -e .`) and `pyproject.toml` maps this folder to the +top-level `makelab` import name, so it imports **from any working directory** — no `sys.path` +hacks: + +```python +import makelab.signal +import makelab.audio +``` + +Note that **data/audio loading in the notebooks is still CWD-relative** (paths like +`data/audio/...`), so a notebook's kernel must run from its own folder. That's a notebook +concern, not a `makelab` one — these helpers take in-memory arrays. + +## Public API (quick reference) + +### Signal generation (`makelab.signal`) +| Function | Purpose | +| --- | --- | +| `create_sine_wave(freq, sampling_rate, total_time_in_secs=None, return_time=False)` | One sine wave (one period if no length given). | +| `create_cos_wave(...)` | Same, but cosine (starts at amplitude 1). | +| `create_sine_waves(freqs, ...)` | A list of `(freq, wave)` tuples, one per frequency. | +| `create_composite_sine_wave(freqs, ..., amplitudes=None)` | Sum of sine waves (i.e. a chord / complex tone). | +| `create_sine_wave_sequence(freqs, ..., starting/ending_amplitudes)` | Notes played back-to-back with amplitude ramps. | + +### Analysis (`makelab.signal`) +| Function | Purpose | +| --- | --- | +| `shift_array(arr, shift_amount, fill_value=np.nan)` | Shift left/right, filling the vacated end. | +| `calc_zero_crossings(s, min_gap=None)` | Sample indices where `s` crosses zero (`min_gap` thins them). | +| `get_top_n_frequency_indices_sorted(n, freqs, amplitudes)` | Indices of the `n` largest amplitudes, high→low. | +| `remap(val, start1, stop1, start2, stop2)` / `map(...)` | Linear range remap (Arduino-style; `map` is an alias). | +| `get_random_xzoom(signal_length, fraction_of_length)` | A random `(start, end)` sample window for zoom plots. | + +### Plotting (`makelab.signal`) +All plotting helpers return their matplotlib `(fig, axes, …)` objects and add a secondary +time-based x-axis on top of the sample-based axis. + +| Function | Purpose | +| --- | --- | +| `plot_signal(s, sampling_rate, title=None, xlim_zoom=None)` | Waveform; adds a zoomed panel if `xlim_zoom` given. | +| `plot_audio(s, sampling_rate, quantization_bits=16, ...)` | `plot_signal` with a bit-depth-aware default title. | +| `plot_signal_to_axes(ax, s, sampling_rate, ...)` | Plot onto an existing axes (building block). | +| `plot_sampling_demonstration(total_time_in_secs, real_world_freqs, ...)` | Stem-plot demo of sampling/aliasing. | +| `plot_signal_and_magnitude_spectrum(t, s, sampling_rate, ...)` | Time domain beside its magnitude spectrum. | +| `plot_spectrogram(s, sampling_rate, ...)` / `plot_spectrogram_to_axes(...)` | Spectrogram (full + zoom). | +| `plot_signal_and_spectrogram(s, sampling_rate, quantization_bits, xlim_zoom, ...)` | Waveform + spectrogram, both with zoom. | + +### Audio (`makelab.audio`) +| Function | Purpose | +| --- | --- | +| `convert_to_mono(audio_data)` | Average a `(samples, channels)` array to mono; 1-D input passes through. | + +## Tests + +Pure (non-plotting) helpers are unit-tested under the repo's top-level `tests/` +(`test_makelab_signal.py`, `test_makelab_audio.py`); plotting helpers are exercised by the +`nbmake` notebook smoke tests. Run the unit tests with: + +```bash +pip install -e ".[test]" +pytest tests/ +``` + +If you add or change a helper here, add/adjust a unit test to match (see the repo's **Testing** +section in `CLAUDE.md`). diff --git a/Tutorials/makelab/audio.py b/Tutorials/makelab/audio.py index e0c8788..0df4459 100644 --- a/Tutorials/makelab/audio.py +++ b/Tutorials/makelab/audio.py @@ -1,18 +1,27 @@ -"""Audio helpers (mono conversion and analysis) built on librosa/scipy for the -Tutorials notebooks. +"""Audio helpers for the Tutorials notebooks. + +Currently just stereo->mono conversion. Audio *loading* (librosa/soundfile) happens +in the notebooks themselves; this module only post-processes the arrays they produce. """ -import matplotlib.pyplot as plt # matplotlib: https://matplotlib.org/ import numpy as np # numpy: https://numpy.org/ -import scipy as sp # for signal processing -from scipy import signal -from scipy.spatial import distance -import librosa -import random def convert_to_mono(audio_data): - '''Converts stereo audio (a 2-D array) to mono by averaging the two channels; mono input is returned unchanged.''' + '''Converts stereo audio to mono by averaging across channels. + + Parameters: + audio_data (np.ndarray): audio samples. A 2-D array is treated as + (num_samples, num_channels); a 1-D array is assumed already-mono. + + Returns: + np.ndarray: a 1-D mono signal. Mono input is returned unchanged. + + Note: librosa offers `librosa.to_mono(y)` for the same job, but it expects a + *channels-first* array of shape (num_channels, num_samples) -- the transpose of + the channels-last layout the notebooks load -- so we average ourselves here. + ''' if len(audio_data.shape) == 2: print("Converting stereo audio file to mono") - audio_data_mono = audio_data.sum(axis=1) / 2 - return audio_data_mono - return audio_data \ No newline at end of file + # mean(axis=1) averages across channels and works for any channel count + # (the old `sum(axis=1) / 2` silently assumed exactly two channels). + return audio_data.mean(axis=1) + return audio_data diff --git a/Tutorials/makelab/signal.py b/Tutorials/makelab/signal.py index e14bc2e..a2cbebd 100644 --- a/Tutorials/makelab/signal.py +++ b/Tutorials/makelab/signal.py @@ -1,21 +1,30 @@ -"""Signal generation and analysis helpers (sine/cosine generators, FFT, distance -metrics, and plotting) for the Tutorials notebooks. +"""Signal generation, analysis, and plotting helpers for the Tutorials notebooks. + +This module is written to be *read by students*: the implementations favor clarity +over cleverness, cite their sources, and often show the alternative ways a result +could be computed. Where a function duplicates something a production library already +does, a comment points to that library equivalent. """ -import matplotlib.pyplot as plt # matplot lib is the premiere plotting lib for Python: https://matplotlib.org/ -import numpy as np # numpy is the premiere signal handling library for Python: http://www.numpy.org/ -import scipy as sp # for signal processing -from scipy import signal -from scipy.spatial import distance -import librosa import random +import matplotlib.pyplot as plt # the premier plotting library for Python: https://matplotlib.org/ +import matplotlib.ticker as ticker # for FixedLocator/FixedFormatter when setting custom ticks +import numpy as np # the premier numerical/signal-handling library for Python: https://numpy.org/ + ### SINE AND COSINE GENERATOR FUNCTIONS ### def create_sine_waves(freqs, sampling_rate, total_time_in_secs = None, return_time = False): - '''Creates multiple sine waves corresponding to the freq array, sampling rate, and length - - Returns a tuple list of (freq, sine_wave) or (freq, (time, sine_wave)) - depending on whether return_time is True or False + '''Creates one sine wave per frequency in freqs (see create_sine_wave for the details). + + Parameters: + freqs (array): the frequencies (in Hz) to generate, one wave each + sampling_rate (num): samples per second + total_time_in_secs (float): length of each wave in secs (defaults to one period) + return_time (bool): if True, include each wave's time array + + Returns: + list: a list of (freq, sine_wave) tuples, or (freq, (time, sine_wave)) tuples + when return_time is True. ''' sine_waves = [] for freq in freqs: @@ -25,17 +34,21 @@ def create_sine_waves(freqs, sampling_rate, total_time_in_secs = None, return_ti def create_sine_wave_sequence(freqs, sampling_rate, time_per_freq = None, starting_amplitudes = None, ending_amplitudes = None): - ''' - Creates a sine wave sequence at the given frequencies and sampling rate. You can control - the time per frequency via time_per_freq and the starting and ending amplitudes of each signal - + '''Creates a sequence of sine waves played back-to-back (concatenated), one per frequency. + + Each wave's amplitude is linearly ramped from its starting to its ending value, which lets + you fade notes in/out so the joins between them don't "click". + Parameters: - freqs (array): array of frequencies - sampling_rate (num): sampling rate - time_per_freq (float or list): If a float, creates all sine waves of that length (in secs). - If an array, takes the time per frequency (in secs). If None, sets all sine waves to length 1 sec. - starting_amplitudes (array): List of starting amplitudes for each freq (one by default) - ending_amplitudes (array): list of ending amplitudes for each freq (zero by default) + freqs (array): array of frequencies (in Hz) + sampling_rate (num): samples per second + time_per_freq (float or list): If a float, every wave gets that length (in secs). + If a list/array, the per-frequency length (in secs). If None, every wave is 1 sec. + starting_amplitudes (array): starting amplitude for each freq (one by default) + ending_amplitudes (array): ending amplitude for each freq (zero by default) + + Returns: + np.ndarray: the concatenated 1-D signal. ''' if starting_amplitudes is None: starting_amplitudes = np.ones(len(freqs)) @@ -62,17 +75,32 @@ def create_sine_wave_sequence(freqs, sampling_rate, time_per_freq = None, starti def create_composite_sine_wave(freqs, sampling_rate, total_time_in_secs, amplitudes = None, use_random_amplitudes = False, return_time = False): - '''Creates a composite sine wave with the given frequencies and amplitudes''' - + '''Creates a single composite signal by summing sine waves at the given frequencies. + + This is how a chord (or any complex tone) is built: add together pure sine waves. The FFT + notebooks then pull those component frequencies back out. + + Parameters: + freqs (array): the component frequencies (in Hz) to sum + sampling_rate (num): samples per second + total_time_in_secs (float): length of the signal in secs + amplitudes (array): amplitude per freq. Defaults to all ones, unless use_random_amplitudes. + use_random_amplitudes (bool): if True (and amplitudes is None), pick random amplitudes in [0.1, 1) + return_time (bool): if True, also return the time array + + Returns: + np.ndarray, or (time, np.ndarray) if return_time is True. + ''' + if amplitudes is None and use_random_amplitudes is False: amplitudes = np.ones(len(freqs)) elif amplitudes is None and use_random_amplitudes is True: amplitudes = np.random.uniform(low = 0.1, high = 1, size=(len(freqs))) time = np.arange(total_time_in_secs * sampling_rate) / sampling_rate - signal_composite = np.zeros(len(time)) # start with empty array + signal_composite = np.zeros(len(time)) # start with a flat (all-zeros) signal for i, freq in enumerate(freqs): - # set random amplitude for each freq (you can change this, of course) + # scale this component by its amplitude, then add it into the running sum signal = amplitudes[i] * create_sine_wave(freq, sampling_rate, total_time_in_secs) signal_composite += signal @@ -82,8 +110,18 @@ def create_composite_sine_wave(freqs, sampling_rate, total_time_in_secs, amplitu return (time, signal_composite) def create_sine_wave(freq, sampling_rate, total_time_in_secs = None, return_time = False): - '''Creates a sine wave with the given frequency, sampling rate, and length''' - + '''Creates a sine wave with the given frequency, sampling rate, and length. + + Parameters: + freq (num): frequency in Hz + sampling_rate (num): samples per second + total_time_in_secs (float): length in secs. If None, returns exactly one period (1/freq secs). + return_time (bool): if True, also return the time array + + Returns: + np.ndarray, or (time, np.ndarray) if return_time is True. + ''' + # if the total time in secs is None, then return one period of the wave if total_time_in_secs is None: total_time_in_secs = 1 / freq @@ -106,8 +144,14 @@ def create_sine_wave(freq, sampling_rate, total_time_in_secs = None, return_time return (time, sine_wave) def create_cos_wave(freq, sampling_rate, total_time_in_secs = None, return_time = False): - '''Creates a cos wave with the given frequency, sampling rate, and length''' - + '''Creates a cosine wave with the given frequency, sampling rate, and length. + + Same contract as create_sine_wave, but cosine (so it starts at amplitude 1, not 0). + + Returns: + np.ndarray, or (time, np.ndarray) if return_time is True. + ''' + # if the total time in secs is None, then return one period of the wave if total_time_in_secs is None: total_time_in_secs = 1 / freq @@ -123,20 +167,28 @@ def create_cos_wave(freq, sampling_rate, total_time_in_secs = None, return_time return (time, cos_wave) def get_random_xzoom(signal_length, fraction_of_length): - '''Returns a tuple of (start, end) for a random xzoom amount''' + '''Returns a random (start, end) sample range covering fraction_of_length of the signal.''' zoom_length = int(signal_length * fraction_of_length) random_start = random.randint(0, signal_length - zoom_length) xlim_zoom = (random_start, random_start + zoom_length) return xlim_zoom -def map(val, start1, stop1, start2, stop2): - '''Similar to Processing and Arduino's map function''' - return ((val-start1)/(stop1-start1)) * (stop2 - start2) + start2 - def remap(val, start1, stop1, start2, stop2): - '''Similar to Processing and Arduino's map function''' + '''Linearly re-maps val from the range [start1, stop1] into the range [start2, stop2]. + + Like Processing's and Arduino's map() function. numpy offers a close equivalent in + np.interp(val, [start1, stop1], [start2, stop2]) -- but np.interp *clamps* values that fall + outside the input range, whereas this version extrapolates (the Arduino behavior students expect). + ''' return ((val-start1)/(stop1-start1)) * (stop2 - start2) + start2 +# `map` is kept as an alias of `remap` for the Processing/Arduino name students know. Note it +# shadows Python's built-in map() *within this module* -- harmless here since we never use the +# built-in, but it's why notebooks call it as makelab.signal.map(...) rather than bare map(...). +def map(val, start1, stop1, start2, stop2): + '''Alias of remap() -- see remap for details.''' + return remap(val, start1, stop1, start2, stop2) + ### SIGNAL MANIPULATION FUNCTIONS ### # While numpy provides a roll function, it does not appear to provide a shift @@ -144,9 +196,22 @@ def remap(val, start1, stop1, start2, stop2): # So, lots of people have implemented their own, including some nice benchmarks here: # https://stackoverflow.com/a/42642326 def shift_array(arr, shift_amount, fill_value = np.nan): - '''Shifts the array either left or right by the shift_amount (which can be negative or positive) - - From: https://stackoverflow.com/a/42642326 + '''Shifts arr left or right by shift_amount samples, filling the vacated end with fill_value. + + A positive shift_amount moves elements to the right (fills the front); a negative shift_amount + moves them left (fills the back); zero returns a copy unchanged. + + Parameters: + arr (np.ndarray): the array to shift + shift_amount (int): samples to shift (sign sets direction) + fill_value: value placed in the vacated positions (np.nan by default) + + Returns: + np.ndarray: a new, shifted array (arr is not modified). + + From: https://stackoverflow.com/a/42642326. (scipy offers scipy.ndimage.shift(arr, n, order=0, + cval=fill_value) for a similar effect, but its default spline interpolation and edge handling + differ -- this plain index-copy version is clearer for teaching.) ''' result = np.empty_like(arr) if shift_amount > 0: @@ -164,29 +229,47 @@ def shift_array(arr, shift_amount, fill_value = np.nan): # TODO: update get_top_n_frequency_indices_sorted so that you can specify a min_gap # between top freqs (so if two top freqs are close together, one can be skipped) def get_top_n_frequency_indices_sorted(n, freqs, amplitudes): - '''Gets the top N frequency indices (sorted)''' - ind = np.argpartition(amplitudes, -n)[-n:] # from https://stackoverflow.com/a/23734295 - ind_sorted_by_coef = ind[np.argsort(-amplitudes[ind])] # reverse sort indices + '''Returns the indices of the n largest amplitudes, ordered largest-first. + + Parameters: + n (int): how many indices to return + freqs (array): the frequency for each bin (unused here, kept for a symmetric call signature) + amplitudes (array): the amplitude/magnitude of each bin + + Returns: + np.ndarray: n indices into `amplitudes`, sorted by descending amplitude. + ''' + # argpartition finds the n largest in O(len) without fully sorting (from + # https://stackoverflow.com/a/23734295); we then sort just those n by descending amplitude. + ind = np.argpartition(amplitudes, -n)[-n:] + ind_sorted_by_coef = ind[np.argsort(-amplitudes[ind])] # negate to sort high -> low return ind_sorted_by_coef def calc_zero_crossings(s, min_gap = None): - '''Returns the number of zero crossings in the signal s - + '''Returns the sample indices where the signal s crosses zero. + This method is based on https://stackoverflow.com/q/3843017 - + Parameters: - s: the signal - min_gap: the minimum gap (in samples) between zero crossings - TODO: - - could have a mininum height after the zero crossing (within some window) to eliminate noise + s: the signal + min_gap: if set, the minimum gap (in samples) required between reported crossings; closer + crossings are skipped (useful for thinning out noise-driven crossings) + + Returns: + list: the sample indices of the zero crossings. + + Note: librosa.zero_crossings(s) and np.where(np.diff(np.signbit(s)))[0] both find crossings + fast, but neither offers the min_gap thinning or the exact-zero "walk back" handling below, + which is why this is hand-written. + + TODO: could also require a minimum height after the crossing (within some window) to ignore noise. ''' - # I could not get the speedier Pythonista solutions to work reliably so here's a - # custom non-Pythony solution + # I could not get the speedier Pythonista solutions to work reliably so here's a + # custom, step-by-step solution that's easy to follow. cur_pt = s[0] zero_crossings = [] - last_zero_crossing_idx = None - last_zero_cross_idx_saved = None + last_zero_cross_idx_saved = None # index of the most recently *saved* crossing (for min_gap) for i in range(1, len(s)): next_pt = s[i] zero_crossing_idx = None @@ -226,31 +309,29 @@ def calc_zero_crossings(s, min_gap = None): if tmp_pt > 0: zero_crossing_idx = i - # now potentially add zero_crossing_idx to our list + # now potentially add zero_crossing_idx to our list. We save it if this is the first + # crossing, if no min_gap was requested, or if it's far enough from the last saved one. if zero_crossing_idx is not None: - # potentially have a new zero crossing, check for other conditions - if last_zero_cross_idx_saved is None or \ - last_zero_cross_idx_saved is not None and min_gap is None or \ - (min_gap is not None and (i - last_zero_cross_idx_saved) > min_gap): - + if last_zero_cross_idx_saved is None or min_gap is None or \ + (i - last_zero_cross_idx_saved) > min_gap: + zero_crossings.append(zero_crossing_idx) # save the zero crossing point last_zero_cross_idx_saved = zero_crossing_idx - - last_zero_crossing_idx = zero_crossing_idx - + cur_pt = s[i] return zero_crossings ##### VISUALIZATION CODE ###### def plot_signal_to_axes(ax, s, sampling_rate, title=None, signal_label=None, marker=None): - '''Plots a sine wave s with the given sampling rate - + '''Plots time-series signal s onto the given axes, with a second time-based x-axis on top. + Parameters: - ax: matplot axis to do the plotting - s: numpy array - sampling_rate: sampling rate of s - title: chart title - signal_label: the label of the signal + ax: the matplotlib axes to plot onto + s: the signal (numpy array) + sampling_rate: sampling rate of s, used to label the top axis in seconds/ms + title: chart title (optional) + signal_label: legend label for the signal (optional) + marker: matplotlib marker for the data points (optional) ''' ax.plot(s, label=signal_label, marker=marker, alpha=0.9) ax.set(xlabel="Samples") @@ -258,7 +339,7 @@ def plot_signal_to_axes(ax, s, sampling_rate, title=None, signal_label=None, mar if signal_label is not None: ax.legend() - # we use y=1.14 to make room for the secondary x-axis + # nudge the title up (y=1.1) to make room for the secondary x-axis added below # see: https://stackoverflow.com/questions/12750355/python-matplotlib-figure-title-overlaps-axes-label-when-using-twiny if title is not None: ax.set_title(title, y=1.1) @@ -287,21 +368,37 @@ def plot_signal_to_axes(ax, s, sampling_rate, title=None, signal_label=None, mar ax2.set_xticklabels(ax2_tick_labels) def plot_audio(s, sampling_rate, quantization_bits = 16, title = None, xlim_zoom = None, highlight_zoom_area = True): - ''' Calls plot_Signal but accepts quantization_bits ''' + '''Like plot_signal, but builds a default title that includes the audio's bit depth. + + Returns: + (fig, axes) from plot_signal. + ''' plot_title = title if plot_title is None: plot_title = f"{quantization_bits}-bit, {sampling_rate} Hz audio" - - return plot_signal(s, sampling_rate, title = title, xlim_zoom = xlim_zoom, highlight_zoom_area = highlight_zoom_area) + + # pass plot_title (not the raw title) so the bit-depth default actually reaches the plot + return plot_signal(s, sampling_rate, title = plot_title, xlim_zoom = xlim_zoom, highlight_zoom_area = highlight_zoom_area) def plot_signal(s, sampling_rate, title = None, xlim_zoom = None, highlight_zoom_area = True): - '''Plots time-series data with the given sampling_rate and xlim_zoom''' - + '''Plots time-series data; if xlim_zoom is given, adds a second zoomed-in panel. + + Parameters: + s: the signal (numpy array) + sampling_rate: samples per second + title: chart title (a sampling-rate default is used if None) + xlim_zoom: optional (start_sample, end_sample) range to show zoomed alongside the full view + highlight_zoom_area: if True, shade the zoomed range on the full-view panel + + Returns: + (fig, axes) -- axes is a single Axes when xlim_zoom is None, else a 2-element array. + ''' + plot_title = title if plot_title is None: plot_title = f"Sampling rate: {sampling_rate} Hz" - if xlim_zoom == None: + if xlim_zoom is None: fig, axes = plt.subplots(1, 1, figsize=(15,6)) plot_signal_to_axes(axes, s, sampling_rate, plot_title) @@ -325,22 +422,30 @@ def plot_signal(s, sampling_rate, title = None, xlim_zoom = None, highlight_zoom return (fig, axes) def plot_sampling_demonstration(total_time_in_secs, real_world_freqs, real_world_continuous_speed = 10000, resample_factor = 200): - '''Used to demonstrate digital sampling and uses stem plots to show where samples taken''' + '''Demonstrates digital sampling: one chart per frequency, with stem markers at the sample points. + + A near-"continuous" signal is generated at real_world_continuous_speed, then sampled every + resample_factor-th point to show what a real ADC captures. The effective sampling rate is + real_world_continuous_speed / resample_factor. + + Parameters: + total_time_in_secs (float): length of each signal in secs + real_world_freqs (array): one underlying frequency (in Hz) per chart + real_world_continuous_speed (num): high sampling rate used to approximate the continuous signal + resample_factor (int): keep every resample_factor-th sample as the "digital" sample + ''' num_charts = len(real_world_freqs) fig_height = num_charts * 3.25 fig, axes = plt.subplots(num_charts, 1, figsize=(15, fig_height)) - - time = None - - i = 0 + sampling_rate = real_world_continuous_speed / resample_factor print(f"Sampling rate: {sampling_rate} Hz") - for real_world_freq in real_world_freqs: - time, real_world_signal = create_sine_wave(real_world_freq, real_world_continuous_speed, + for i, real_world_freq in enumerate(real_world_freqs): + time, real_world_signal = create_sine_wave(real_world_freq, real_world_continuous_speed, total_time_in_secs, return_time = True) sampled_time = time[::resample_factor] sampled_signal = real_world_signal[::resample_factor] - + axes[i].plot(time, real_world_signal) axes[i].axhline(0, color="gray", linestyle="-", linewidth=0.5) axes[i].plot(sampled_time, sampled_signal, linestyle='None', alpha=0.8, marker='s', color='black') @@ -348,13 +453,22 @@ def plot_sampling_demonstration(total_time_in_secs, real_world_freqs, real_world axes[i].set_ylabel("Amplitude") axes[i].set_xlabel("Time (secs)") axes[i].set_title(f"{real_world_freq}Hz signal sampled at {sampling_rate}Hz") - - i += 1 fig.tight_layout(pad = 3.0) #### FREQUENCY VISUALIZATIONS #### def plot_signal_and_magnitude_spectrum(t, s, sampling_rate, title = None, xlim_zoom_in_secs = None): - '''Plots a signal in the time domain alongside its magnitude (frequency) spectrum.''' + '''Plots a signal in the time domain alongside its magnitude (frequency) spectrum. + + Parameters: + t: the time array for s + s: the signal + sampling_rate: samples per second (passed to magnitude_spectrum as Fs) + title: title for the time-domain plot(s) + xlim_zoom_in_secs: optional (start_sec, end_sec) range; if given, adds a zoomed time panel + + Returns: + (fig, axes) -- axes is [main_time, spectrum] without zoom, else [main_time, zoom_time, spectrum]. + ''' # Plot the time domain ax_main_time = None ax_zoom_time = None @@ -402,22 +516,25 @@ def plot_signal_and_magnitude_spectrum(t, s, sampling_rate, title = None, xlim_z return (fig, axes) -import matplotlib.ticker as ticker -def plot_spectrogram_to_axes(ax, s, sampling_rate, title=None, +def plot_spectrogram_to_axes(ax, s, sampling_rate, title=None, marker=None, custom_axes = True): - '''Plots a spectrogram wave s with the given sampling rate - + '''Plots a spectrogram of signal s onto the given axes. + Parameters: - ax: matplot axis to do the plotting - s: numpy array - sampling_rate: sampling rate of s - title: chart title + ax: the matplotlib axes to plot onto + s: the signal (numpy array) + sampling_rate: samples per second (passed to specgram as Fs) + title: chart title (optional) + marker: unused, kept for signature symmetry with plot_signal_to_axes + custom_axes: if True, relabel the x-axis in samples and add a top time-axis + + Returns: + the tuple returned by matplotlib's specgram (spectrum, freqs, t, image). ''' specgram_return_data = ax.specgram(s, Fs=sampling_rate) - # we use y=1.14 to make room for the secondary x-axis - # see: https://stackoverflow.com/questions/12750355/python-matplotlib-figure-title-overlaps-axes-label-when-using-twiny + # nudge the title up (y=1.2) to make room for the secondary x-axis added below if title is not None: ax.set_title(title, y=1.2) @@ -428,6 +545,9 @@ def plot_spectrogram_to_axes(ax, s, sampling_rate, title=None, ax.set(xlabel="Samples") ax_xtick_labels = np.array(ax.get_xticks()) * sampling_rate ax_xtick_labels_strs = [f"{int(xtick_label)}" for xtick_label in ax_xtick_labels] + # pin the tick locations before setting labels, else matplotlib warns that the labels + # may not line up with the ticks (FixedFormatter without a FixedLocator) + ax.set_xticks(ax.get_xticks()) ax.set_xticklabels(ax_xtick_labels_strs) ax2 = ax.twiny() @@ -440,15 +560,28 @@ def plot_spectrogram_to_axes(ax, s, sampling_rate, title=None, return specgram_return_data def plot_spectrogram(s, sampling_rate, title = None, xlim_zoom = None, highlight_zoom_area = True): - '''Plots signal with the given sampling_Rate, quantization level, and xlim_zoom''' + '''Plots a spectrogram of s (full view) next to a zoomed-in view. + + Parameters: + s: the signal + sampling_rate: samples per second + title: chart title (a duration/rate default is used if None) + xlim_zoom: optional (start_sample, end_sample) range; a random 10% window is used if None + highlight_zoom_area: if True, mark the zoomed range on the full-view panel + + Returns: + (fig, axes, specgram_return_data_full, specgram_return_data_zoom). + ''' fig, axes = plt.subplots(1, 2, figsize=(15,4), gridspec_kw={'width_ratios': [2, 1]}) - + if title is None: - title = f"{len(s) * sampling_rate} sec Signal with {sampling_rate} Hz" - + # duration in secs = number of samples / samples-per-second + length_in_secs = len(s) / sampling_rate + title = f"{length_in_secs:.2f} sec Signal at {sampling_rate} Hz" + specgram_return_data0 = plot_spectrogram_to_axes(axes[0], s, sampling_rate, title) - - if(xlim_zoom == None): + + if xlim_zoom is None: max_length = len(s) length = int(max_length * 0.1) random_start = random.randint(0, max_length - length) @@ -472,8 +605,10 @@ def plot_spectrogram(s, sampling_rate, title = None, xlim_zoom = None, highlight ax_xtick_labels = np.array(axes[1].get_xticks()) * sampling_rate ax2_tick_labels_strs = [f"{int(xtick_label)}" for xtick_label in ax_xtick_labels] axes[1].set(xlabel="Samples") + # pin tick locations before relabeling so matplotlib doesn't warn about mismatched ticks/labels + axes[1].set_xticks(axes[1].get_xticks()) axes[1].set_xticklabels(ax2_tick_labels_strs) - + if highlight_zoom_area: # yellow highlight color: color='#FFFBCC' axes[0].axvline(x = zoom_x1, linewidth=2, color='r', alpha=0.8, linestyle='-.') @@ -483,7 +618,16 @@ def plot_spectrogram(s, sampling_rate, title = None, xlim_zoom = None, highlight return (fig, axes, specgram_return_data0, specgram_return_data1) def plot_signal_and_spectrogram(s, sampling_rate, quantization_bits, xlim_zoom = None, highlight_zoom_area = True): - '''Plot waveforms and spectrograms together''' + '''Plots the waveform (top) and spectrogram (bottom) together, each with a zoomed-in panel. + + Parameters: + s: the signal + sampling_rate: samples per second + quantization_bits: bit depth, used only to build the title + xlim_zoom: (start_sample, end_sample) range to show zoomed -- required (the right-hand + panels zoom to this range) + highlight_zoom_area: if True, mark the zoomed range on the full-view panels + ''' fig = plt.figure(figsize=(15, 9)) spec = fig.add_gridspec(ncols = 2, nrows = 2, width_ratios = [2, 1], height_ratios = [1, 1]) plot_title = f"{quantization_bits}-bit, {sampling_rate} Hz audio" @@ -497,11 +641,6 @@ def plot_signal_and_spectrogram(s, sampling_rate, quantization_bits, xlim_zoom = plot_signal_to_axes(ax_waveform1, s, sampling_rate, plot_title) specgram_return_data = plot_spectrogram_to_axes(ax_spectrogram1, s, sampling_rate, plot_title) - #print(len(specgram_return_data[2])) - - #print(ax_waveform1.get_xlim()) - #print(ax_spectrogram1.get_xlim()) - waveform_xrange = ax_waveform1.get_xlim()[1] - ax_waveform1.get_xlim()[0] ax_waveform2.set_xlim(xlim_zoom) plot_signal_to_axes(ax_waveform2, s, sampling_rate, plot_title + ' zoomed') diff --git a/tests/test_makelab_audio.py b/tests/test_makelab_audio.py new file mode 100644 index 0000000..74f6eca --- /dev/null +++ b/tests/test_makelab_audio.py @@ -0,0 +1,27 @@ +"""Unit tests for makelab.audio. + +Only convert_to_mono lives here so far. The key contract: a 2-D (samples, channels) +array is averaged across channels; a 1-D array is already mono and passes through. +""" +import numpy as np + +import makelab.audio as ma + + +def test_convert_to_mono_averages_two_channels(): + # shape (num_samples, num_channels): each row is one sample's [left, right]. + stereo = np.array([[2.0, 4.0], [6.0, 8.0]]) + np.testing.assert_allclose(ma.convert_to_mono(stereo), np.array([3.0, 7.0])) + + +def test_convert_to_mono_passes_through_mono(): + mono = np.array([1.0, 2.0, 3.0]) + # 1-D input is already mono and must be returned unchanged. + np.testing.assert_array_equal(ma.convert_to_mono(mono), mono) + + +def test_convert_to_mono_averages_more_than_two_channels(): + # Guards the bug fix: averaging (not the old `sum / 2`) must be correct for any + # channel count. Three channels of [3,6,9] average to 6, not (3+6+9)/2 = 9. + three_channel = np.array([[3.0, 6.0, 9.0], [0.0, 0.0, 3.0]]) + np.testing.assert_allclose(ma.convert_to_mono(three_channel), np.array([6.0, 1.0]))