Jazz insights

24 July 2025 - Written by ML

Highlights

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.

Aim

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.

Method

The start point is a collection of 100 mp3 tracks and their labels. We then performed the following steps:

1. Change file name

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

2. frequency and note inference

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}")

3. Statistics and display

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()

Conclusion

Piano keyboard with highlighted notes and counts

References


  1. 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).↩︎