Jazz insights
24 July 2025 - Written by ML
This report looks at 100 jazz tracks of various ensembles, instruments and players over a span of 40 years from 1930s.
Selection of music tracks was made by a veteran appreciator of jazz and saxophone especially.
Using various tools such as python and R we aim to make visual some of the features of these 100 tracks singly and in aggregate. And in so doing hope to become better informed into the music but also into the person who chose the music.
For fun we hope to establish a set of objective benchmarks from the analysis that can be used to compare with contemporary jazz.
The code has been generated with considerable support from various ai including Chatgpt, Grok, Deep Seek, and Mistral. And generally working with unpaid versions of latest models.
The start point is a collection of 100 mp3 tracks and their labels. We then performed the following steps:
These labels were anonymized using a script to reduce the label to their bare initials and retain the mp3 suffix.
#!/bin/bash
# anonymization script
# Navigate to the target directory
cd "directory that contains file"
# Process each MP3 file
for file in *.mp3; do
# Extract the original filename without path
original="$file"
# Split into artist and song parts
if [[ "$original" =~ ^(.+)\ -\ (.+)\.mp3$ ]]; then
artist="${BASH_REMATCH[1]}"
song="${BASH_REMATCH[2]}"
# Convert artist name to lowercase initials with underscore
artist_initials=$(echo "$artist" | awk '{
# Replace special characters with spaces
gsub(/[^a-zA-Z]/, " ");
# Convert to lowercase initials with underscores
for(i=1; i<=NF; i++) {
printf "%s", tolower(substr($i,1,1));
if(i < NF) printf "_";
}
}')
# Convert song title to lowercase initials with underscore
song_initials=$(echo "$song" | awk '{
# Replace apostrophes and special chars with spaces (keep words)
gsub(/[^a-zA-Z]/, " ");
# Convert to lowercase initials with underscores
for(i=1; i<=NF; i++) {
printf "%s", tolower(substr($i,1,1));
if(i < NF) printf "_";
}
}')
# Construct new filename
newname="${artist_initials}_${song_initials}.mp3"
# Rename the file (use -n to avoid overwriting)
mv -n -- "$original" "$newname"
echo "Renamed: $original -> $newname"
else
echo "Skipping: $original (doesn't match pattern)"
fi
done
The following script sought to load each track, resample it to 22050 Hz1, then estimate f0, and aggregate the frequencies to reach a median frequency for the track. Once the frequency was derived it was mapped to a note name.
#!/usr/bin/env python3
import os
import csv
# 0. Patch numpy for compatibility if needed
import numpy as np
np.complex = complex
np.float = float
import librosa
# 1. Configuration
INPUT_DIR = "where anonymized mp3 are"
OUTPUT_CSV = "fundamentals.csv" # will be written in the current working directory
# 2. Helper to estimate fundamental for one file
def estimate_f0_for_file(path):
"""
Loads the audio file, runs PYIN to estimate f0 per frame,
takes the median voiced f0, and maps to a MIDI note name.
"""
try:
y, sr = librosa.load(path, sr=22050)
f0, voiced_flag, _ = librosa.pyin(
y,
fmin=librosa.note_to_hz("C2"),
fmax=librosa.note_to_hz("C7"),
frame_length=2048,
hop_length=512,
sr=sr
)
# keep only voiced (non-NaN) frames
f0_voiced = f0[~np.isnan(f0)]
if len(f0_voiced) == 0:
return None, None
median_f0 = float(np.median(f0_voiced))
midi = int(round(librosa.hz_to_midi(median_f0)))
note = librosa.midi_to_note(midi)
return median_f0, note
except Exception as e:
print(f"Error processing {path}: {e}")
return None, None
# 3. Walk the directory and process each mp3
results = []
for fname in os.listdir(INPUT_DIR):
if not fname.lower().endswith(".mp3"):
continue
fullpath = os.path.join(INPUT_DIR, fname)
f0_hz, note = estimate_f0_for_file(fullpath)
results.append({
"filename": fname,
"f0_hz": f0_hz if f0_hz is not None else "",
"note": note if note is not None else ""
})
print(f"Processed {fname}: {f0_hz:.2f} Hz → {note}")
# 4. Write out CSV
with open(OUTPUT_CSV, "w", newline="") as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=["filename","f0_hz","note"])
writer.writeheader()
for row in results:
writer.writerow(row)
print(f"\nDone! Wrote {len(results)} entries to {OUTPUT_CSV}")
The python script takes the inferred note for each track aggregates and counts the number of tracks for each note. It then maps the location of the notes with their accompanying count onto a piano keyboard.
import pandas as pd
import matplotlib.pyplot as plt
# 1. Read fundamentals CSV into a DataFrame
fund_path = r'/Users/ml/beats/data/outputs/fundamentals.csv'
fund = pd.read_csv(fund_path)
# 2. Create a summary table by 'note'
note_summary = fund.groupby('note').size().reset_index(name='count')
print("Note Summary:")
print(note_summary)
# 3. Function to convert note name to MIDI number, normalizing unicode sharps
def note_to_midi(note):
# Normalize unicode sharp to ASCII '#'
note = note.replace('♯', '#').replace('𝄪', '##')
# Match the longest note names first (e.g. 'A#' before 'A')
note_names = sorted(['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'], key=lambda x: -len(x))
for name in note_names:
if note.startswith(name):
octave = int(note[len(name):])
idx = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'].index(name)
return (octave + 1) * 12 + idx
raise ValueError(f"Invalid note format: {note}")
# 4. Prepare MIDI and counts for plotting
highlight_notes = [note_to_midi(n) for n in note_summary['note']]
note_counts = dict(zip(highlight_notes, note_summary['count']))
# 5. Setup piano keyboard figure
fig, ax = plt.subplots(figsize=(30, 4))
ax.set_xlim(20.5, 108.5)
ax.set_ylim(-0.3, 2.0)
ax.axis('off')
plt.title("Full Piano Keyboard: A0 to C8 with Highlighted Notes and Counts", fontsize=14, pad=20)
all_keys = list(range(21, 109))
white_keys = [m for m in all_keys if m % 12 in [0,2,4,5,7,9,11]]
black_keys = [m for m in all_keys if m % 12 in [1,3,6,8,10]]
# Draw white and black keys
for key in white_keys:
ax.add_patch(plt.Rectangle((key - 0.5, 0.2), 1, 0.8,
facecolor='yellow' if key in highlight_notes else 'white',
edgecolor='black'))
for key in black_keys:
ax.add_patch(plt.Rectangle((key - 0.45, 0.8), 0.9, 0.6,
facecolor='yellow' if key in highlight_notes else 'black',
edgecolor='black'))
# Label all keys
note_names = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
for m in all_keys:
octave = (m - 12) // 12
label = note_names[m % 12] + str(octave)
y = 1.1 if '#' in label else 0.1
ax.text(m, y, label, color='red', ha='center', va='center', fontsize=8)
# Annotate counts
for i, m in enumerate(highlight_notes):
cnt = note_counts[m]
y = 1.6 if m % 12 in [1,3,6,8,10] else -0.2
x_offset = 0.2 if i % 2 == 0 else -0.2
ax.text(m + x_offset, y, f'({cnt})', color='blue', ha='center', va='center', fontsize=6)
# Highlight Middle C
ax.text(60, 1.8, "Middle C", color='darkred', ha='center', va='center', weight='bold')
plt.tight_layout()
plt.savefig('piano_keyboard.svg', format='svg', bbox_inches='tight')
plt.show()
For what reason is it worth resampling the tracks to
22050 hz for the purpose identified? Chatgpt response: “When you’re
using a pitch‑tracking algorithm like Librosa’s PYIN to find the
fundamental frequency (f₀) of each track the full 44.1 kHz (or higher)
resolution that most commercial recordings use is not necessary.
Down‑sampling every file to 22 050 Hz before running PYIN cuts data
volume and CPU work in half. At 22 050 Hz instead of 44 100 Hz, each
second of audio has 22 050 samples rather than 44 100, so the median‑f₀
estimation runs roughly twice as fast and uses half the memory — without
losing any information you care about. With a sampling rate of 22 050 Hz
the Nyquist limit is 11 025 Hz that is well above the upper bound of the
pitches being measured (C2→C7 spans roughly 65 Hz to 2 093 Hz). In other
words all the energy required for f₀ estimation is preserved, and only
inaudible ultra‑high frequencies are discarded. Moreover, Librosa’s
load(..., sr=22050) is the library’s default resampling
rate, so there is uniform behavior across files and is sufficient for
pitch tracking”. A full size 88-key piano spans seven full octaves plus
a minor third from the lowest note A₀ (the lowest note) at 27.5 Hz,
through middle C (C₄) at 261.63 Hz, to C₈ (the highest key) at
4 186.01 Hz. In MIDI terms that’s notes 21 (A₀) through 108 (C₈). That
range covers virtually all tonal material in Western music, from deep
bass up to very bright treble (Chatgpt).↩︎