How to Build Beat Grids

BeatGrid, FloorMap, RotationMap, and measure/beat queries

How to Build Beat Grids

This tutorial shows how to add beat and measure information to audio tracks when you know the tempo and first-beat offset.

The Common Problem:

“I have an audio file. I know the tempo and when the first beat occurs. I want to export the beat times for Audacity.”

The TimeToAlign! Solution:

grid = BeatGrid.from_tempo(tempo_bpm=120, length_seconds=180, start_seconds=0.5)
grid.export_to_csv("beats.txt", format="sonic_visualiser")  # Done!

Learning Objectives:

  1. Create a BeatGrid from tempo + first-beat offset
  2. Get all beat/measure times as numpy arrays
  3. Export to Audacity-compatible CSV
  4. Query individual time positions for measure/beat
  5. (Advanced) Create BeatGrids from score measure data

Prerequisites: - how01_coordinate_math.ipynb (Timelines, Coordinates) - No prerequisites for Part 1 (the simple case)

Setup

import os
from fractions import Fraction

import numpy as np
import pandas as pd

from timetoalign import AudioLoader, BeatGrid
from timetoalign.testdata import ensure_data

AUDIO_DIR = ensure_data("audio") / "hard_techno"

TL;DR: Three Lines to Beatgrid Your Audio

If you already know what you’re doing, here’s the pattern:


# Create beatgrid: 120 BPM, 4/4, 3-minute track, first beat at 0.5 seconds
grid = BeatGrid.from_tempo(tempo_bpm=120, length_seconds=180, start_seconds=0.5)

# Get all beat times in seconds (numpy array)
beat_times = grid.beat_seconds()

# Quick look at what we have
{
    "Total beats": len(beat_times),
    "Total measures": grid.n_measures,
    "First 4 beats (seconds)": list(beat_times[:4].round(3)),
    "First 4 measures (seconds)": list(grid.measure_seconds()[:4].round(3)),
}
{'Total beats': 359,
 'Total measures': 89,
 'First 4 beats (seconds)': [0.5, 1.0, 1.5, 2.0],
 'First 4 measures (seconds)': [0.5, 2.5, 4.5, 6.5]}

Part 1: Audio Beatgrids - The Simple Case

You have audio files. You know the tempo and when the first beat occurs. You want to generate a complete list of beat and measure times.

1.1 The Use Case

Let’s work with three techno tracks that all have: - Tempo: 160 BPM (constant throughout) - Time Signature: 4/4 - Known first-beat offset (measured by ear or beat detection)

# Our test tracks - tempo is constant 160 BPM
TEMPO_BPM = 160.0
BEATS_PER_MEASURE = 4

# Load audio files to get accurate durations
# First-beat offsets are measured by ear or beat detection
TRACK_FILES = {
    "Ao Céu": {"file": "Ao Céu.m4a", "first_beat": 0.092},
    "Bye Bye": {"file": "Bye Bye.m4a", "first_beat": 0.035},
    "Bass Kick": {"file": "Bass Kick.mp3", "first_beat": 0.061},
}

# Create loaders and extract durations from actual audio files
TRACKS = {}
for name, info in TRACK_FILES.items():
    loader = AudioLoader.from_file(AUDIO_DIR / info["file"])
    TRACKS[name] = {
        "loader": loader,
        "duration": loader.duration_seconds,
        "first_beat": info["first_beat"],
    }
    print(f"{name}: {loader.duration_seconds:.3f}s ({loader.format})")
Ao Céu: 279.382s (M4A)
Bye Bye: 294.266s (M4A)
Bass Kick: 231.079s (MP3)

1.2 Creating the BeatGrid

The BeatGrid.from_tempo() factory method does all the work:

# Create a beatgrid for "Ao Céu"
ao_ceu = TRACKS["Ao Céu"]
grid = BeatGrid.from_tempo(
    tempo_bpm=TEMPO_BPM,
    beats_per_measure=BEATS_PER_MEASURE,
    length_seconds=ao_ceu["duration"],
    start_seconds=ao_ceu["first_beat"],  # When the first beat occurs
)

{
    "Track": "Ao Céu",
    "Duration": f'{ao_ceu["duration"]:.3f} seconds',
    "First beat at": f'{ao_ceu["first_beat"]} seconds',
    "Total measures": grid.n_measures,
    "Total beats": grid.n_beats,
}
{'Track': 'Ao Céu',
 'Duration': '279.382 seconds',
 'First beat at': '0.092 seconds',
 'Total measures': 186,
 'Total beats': 744}

1.3 Getting Beat Times

The beat_seconds() method returns a numpy array of ALL beat times.

# Get all beat times
beats = grid.beat_seconds()

{
    "Array shape": beats.shape,
    "First 8 beats (seconds)": list(beats[:8].round(3)),
    "Last 4 beats (seconds)": list(beats[-4:].round(3)),
}
{'Array shape': (744,),
 'First 8 beats (seconds)': [0.092,
  0.467,
  0.842,
  1.217,
  1.592,
  1.967,
  2.342,
  2.717],
 'Last 4 beats (seconds)': [277.592, 277.967, 278.342, 278.717]}

1.4 Getting Measure (Downbeat) Times

Use measure_seconds() for downbeat times only:

# Get all measure start times (downbeats)
measures = grid.measure_seconds()

{
    "Total measures": len(measures),
    "First 4 measures (seconds)": list(measures[:4].round(3)),
    "Last 4 measures (seconds)": list(measures[-4:].round(3)),
}
{'Total measures': 186,
 'First 4 measures (seconds)': [0.092, 1.592, 3.092, 4.592],
 'Last 4 measures (seconds)': [273.092, 274.592, 276.092, 277.592]}

1.5 Verifying the Math

At 160 BPM: - One beat = 60/160 = 0.375 seconds - One measure (4 beats) = 1.5 seconds

Let’s verify the beat spacing is constant:

# Check beat intervals
intervals = np.diff(beats)
expected_interval = 60.0 / TEMPO_BPM  # 0.375 seconds

{
    "Expected beat interval": f"{expected_interval} seconds",
    "Actual intervals (first 8)": list(intervals[:8].round(4)),
    "All intervals equal?": np.allclose(intervals, expected_interval),
}
{'Expected beat interval': '0.375 seconds',
 'Actual intervals (first 8)': [0.375,
  0.375,
  0.375,
  0.375,
  0.375,
  0.375,
  0.375,
  0.375],
 'All intervals equal?': True}

Part 2: Exporting Beat Grids

BeatGrid supports multiple export formats for different annotation tools:

Format Tool Columns
sonic_visualiser Sonic Visualiser, Audacity TIME, LABEL
tilia Tilia time, measure, beat, is_first_in_measure

2.1 Export Formats

# Preview: Sonic Visualiser format (TIME, LABEL)
times = grid.beat_seconds()[:8]
measures = np.repeat(np.arange(1, 3), grid.beats_per_measure)
beats = np.tile(np.arange(1, grid.beats_per_measure + 1), 2)

pd.DataFrame(
    {
        "TIME": np.round(times, 6),
        "LABEL": [f"M{m}B{b}" for m, b in zip(measures, beats)],
    }
)
TIME LABEL
0 0.092 M1B1
1 0.467 M1B2
2 0.842 M1B3
3 1.217 M1B4
4 1.592 M2B1
5 1.967 M2B2
6 2.342 M2B3
7 2.717 M2B4
# Preview: Tilia format (time, measure, beat, is_first_in_measure)
pd.DataFrame(
    {
        "time": np.round(times, 6),
        "measure": measures,
        "beat": beats,
        "is_first_in_measure": beats == 1,
    }
)
time measure beat is_first_in_measure
0 0.092 1 1 True
1 0.467 1 2 False
2 0.842 1 3 False
3 1.217 1 4 False
4 1.592 2 1 True
5 1.967 2 2 False
6 2.342 2 3 False
7 2.717 2 4 False

2.2 Batch Export to Multiple Formats

# Create outputs directory
os.makedirs("outputs", exist_ok=True)

# Process all three tracks and export to both formats
results = []

for name, info in TRACKS.items():
    grid = BeatGrid.from_tempo(
        tempo_bpm=TEMPO_BPM,
        beats_per_measure=BEATS_PER_MEASURE,
        length_seconds=info["duration"],
        start_seconds=info["first_beat"],
    )

    # Export to both formats
    base_name = name.lower().replace(" ", "_")
    sv_file = f"outputs/{base_name}_beats.csv"
    tilia_file = f"outputs/{base_name}_beats_tilia.csv"

    n_sv = grid.export_to_csv(sv_file, format="sonic_visualiser")
    n_tilia = grid.export_to_csv(tilia_file, format="tilia")

    results.append(
        {
            "Track": name,
            "Measures": grid.n_measures,
            "Beats": n_sv,
            "Sonic Visualiser": sv_file,
            "Tilia": tilia_file,
        }
    )

pd.DataFrame(results)
Track Measures Beats Sonic Visualiser Tilia
0 Ao Céu 186 744 outputs/ao_céu_beats.csv outputs/ao_céu_beats_tilia.csv
1 Bye Bye 196 784 outputs/bye_bye_beats.csv outputs/bye_bye_beats_tilia.csv
2 Bass Kick 154 616 outputs/bass_kick_beats.csv outputs/bass_kick_beats_tilia.csv

Part 3: Point Queries

Sometimes you need to ask: “What measure/beat is this time position?”

3.1 Query by Time (Seconds)

# Create grid for "Ao Céu" using loaded audio duration
ao_ceu = TRACKS["Ao Céu"]
grid = BeatGrid.from_tempo(
    tempo_bpm=TEMPO_BPM,
    beats_per_measure=BEATS_PER_MEASURE,
    length_seconds=ao_ceu["duration"],
    start_seconds=ao_ceu["first_beat"],
)

# Query specific time positions
test_times = [0.092, 1.0, 60.0, 120.0, 200.0]

queries = []
for t in test_times:
    queries.append(
        {
            "Time (s)": t,
            "Measure": grid.measure_at_seconds(t),
            "Beat": grid.beat_at_seconds(t),
        }
    )

pd.DataFrame(queries)
Time (s) Measure Beat
0 0.092 1 1
1 1.000 1 3
2 60.000 40 4
3 120.000 80 4
4 200.000 134 2

3.2 Query by Quarter-Note Position

If you’re working with MIDI or score data, you may have coordinates in quarter notes. BeatGrid handles these directly:

# Query positions in quarters
test_quarters = [0, 4, 100, 400]

queries = []
for q in test_quarters:
    pos = grid.metrical_position(q)
    queries.append(
        {
            "Quarters": q,
            "Measure (MC)": pos["mc"],
            "Beat": str(pos["beat"]),
            "Label (MN)": pos["mn"],
        }
    )

pd.DataFrame(queries)
Quarters Measure (MC) Beat Label (MN)
0 0 1 1 1
1 4 2 1 2
2 100 26 1 26
3 400 101 1 101

Part 4: Different Time Signatures

BeatGrid supports any time signature via beats_per_measure and beat_unit.

4.1 Waltz (3/4 Time)

# A waltz at 90 BPM, 5 minutes long
waltz = BeatGrid.from_tempo(
    tempo_bpm=90.0,
    beats_per_measure=3,  # 3 beats per measure
    length_seconds=300.0,
    start_seconds=0.0,
)

{
    "Time signature": "3/4",
    "Tempo": "90 BPM",
    "Duration": "5 minutes",
    "Total measures": waltz.n_measures,
    "Total beats": waltz.n_beats,
    "Seconds per measure": f"{60/90 * 3:.2f}",
}
{'Time signature': '3/4',
 'Tempo': '90 BPM',
 'Duration': '5 minutes',
 'Total measures': 150,
 'Total beats': 450,
 'Seconds per measure': '2.00'}

4.2 Compound Meter (6/8 Time)

For 6/8, you have 6 eighth-note beats per measure. Use beat_unit=Fraction(1, 8) to indicate eighth-note beats:

# 6/8 time at 120 BPM (where the beat is an eighth note)
compound = BeatGrid.from_tempo(
    tempo_bpm=120.0,
    beats_per_measure=6,
    beat_unit=Fraction(1, 8),  # Eighth note = 1 beat
    length_seconds=60.0,
    start_seconds=0.0,
)

{
    "Time signature": "6/8",
    "Beat unit": "eighth note",
    "Quarters per measure": float(compound.quarters_per_measure),
    "Quarters per beat": float(compound.quarters_per_beat),
    "Total measures": compound.n_measures,
    "First 6 beats (seconds)": list(compound.beat_seconds()[:6].round(3)),
}
{'Time signature': '6/8',
 'Beat unit': 'eighth note',
 'Quarters per measure': 3.0,
 'Quarters per beat': 0.5,
 'Total measures': 20,
 'First 6 beats (seconds)': [0.0, 0.25, 0.5, 0.75, 1.0, 1.25]}

Part 5: Score-Based Beatgrids (Advanced)

When working with classical music that has complex meter changes, repeats, and pickup measures, you need to create BeatGrids from score data rather than simple tempo information.

5.1 The Use Case: Beethoven String Quartet

Consider Beethoven’s String Quartet Op. 18 No. 4, 4th movement: - Has repeat structures (needs unfolding for audio alignment) - Pickup measure (anacrusis) - 2/2 time signature throughout

We have a recording: StringQuartetEEP_I_Normal_mono.mp3

The score data is available in TSV format with: - Measure coordinates in quarterbeats - Flow control information (repeats, voltas) - Unfolded versions for performance alignment

# This is the score data structure we're working with
# (From: tests/data/score/beethoven_op18-4iv_multimodal/ABC/)

BEETHOVEN_MEASURES = """
mc  mn  quarterbeats    duration_qb timesig repeats
1   0   0   1.0 2/2 start
2   1   1   4.0 2/2
...
9   8   29  3.0 2/2 end
10  8   32  1.0 2/2 start
...
"""

# The unfolded version has ~291 measures (with repeats expanded)
UNFOLDED_TOTAL_QUARTERS = 1116
UNFOLDED_MEASURES = 291

5.2 Creating BeatGrid from Score (API Draft)

Note: This API is a draft for future implementation. The pattern shows how TimeToAlign! will support creating BeatGrids from rich score metadata.

# API DRAFT: Creating BeatGrid from score measures
#
# This functionality is planned for the Score Integration milestone.
# The code below shows the intended API design.

# --- FUTURE API ---
#
# from timetoalign.loader import MeasureMapLoader
#
# # Load measure map from score TSV
# loader = MeasureMapLoader.from_file(
#     "tests/data/score/beethoven_op18-4iv_multimodal/ABC/n04op18-4_04_flow_unfolded.measures.tsv"
# )
#
# # Create BeatGrid with the actual meter structure
# grid = loader.create_beatgrid(
#     tempo_bpm=120.0,  # Performance tempo (from audio analysis or metadata)
#     start_seconds=0.5,  # First beat offset
# )
#
# # The grid now has the exact measure structure from the score
# # Including partial measures, meter changes, etc.
# grid.n_measures  # -> 291 (unfolded)
# grid.n_beats     # -> based on actual time signatures
#
# # Export to Audacity with score measure labels
# beatgrid_to_audacity_csv(grid, "beethoven_op18-4iv_beats.txt")
# --- END FUTURE API ---

# For now, we can approximate with uniform measures
approx_grid = BeatGrid.from_tempo(
    tempo_bpm=120.0,  # Assumed tempo
    beats_per_measure=4,  # 2/2 = 4 quarter beats per measure
    length_seconds=540.0,  # ~9 minutes (estimated)
    start_seconds=0.5,
    name="Beethoven Op.18/4-iv (approximate)",
)

{
    "Track": "StringQuartetEEP_I_Normal_mono.mp3",
    "Approximate measures": approx_grid.n_measures,
    "Approximate beats": approx_grid.n_beats,
    "Note": "Use MeasureMapLoader for exact score structure",
}
{'Track': 'StringQuartetEEP_I_Normal_mono.mp3',
 'Approximate measures': 269,
 'Approximate beats': 1079,
 'Note': 'Use MeasureMapLoader for exact score structure'}

5.3 Why Score-Based BeatGrids Matter

For simple steady-tempo music, BeatGrid.from_tempo() works perfectly. But classical music often has:

Feature Simple BeatGrid Score-Based BeatGrid
Constant tempo Yes Yes
Tempo changes No Yes (via tempo track)
Repeats/Voltas No Yes (unfolded)
Anacrusis Limited Full support
Measure labels M1, M2… 0, 1a, 1b, 2…
Time sig changes No Yes

The Score-Based approach gives you ground truth measure structure from the score, ensuring your beat markers match what musicians see on the page.


Summary

“BeatGrid.from_tempo() generates beat and measure times for audio with known tempo and first-beat offset. Export to Audacity with one line.”

Key API:

Method Returns Use Case
BeatGrid.from_tempo() BeatGrid Create from tempo + offset
.beat_seconds() numpy array All beat times
.measure_seconds() numpy array All measure/downbeat times
.n_beats int Total beat count
.n_measures int Total measure count
.measure_at_seconds(t) int Measure number at time t
.beat_at_seconds(t) int Beat-in-measure at time t
.export_to_csv() int Export to Audacity/Sonic Visualiser

Common Patterns:

# Pattern 1: Simple audio beatgrid
grid = BeatGrid.from_tempo(tempo_bpm=120, length_seconds=180, start_seconds=0.5)
beats = grid.beat_seconds()

# Pattern 2: Export to Audacity (one line!)
grid.export_to_csv("beats.txt", format="sonic_visualiser")

# Pattern 3: Different time signatures
grid_3_4 = BeatGrid.from_tempo(tempo_bpm=90, beats_per_measure=3, ...)
grid_6_8 = BeatGrid.from_tempo(tempo_bpm=120, beats_per_measure=6,
                                beat_unit=Fraction(1, 8), ...)

Exercises

Exercise 1: Your Own Track

Pick an audio track you know the tempo of. Create a BeatGrid and export the first 32 beats to an Audacity-compatible format.

# Your solution here:
# my_grid = BeatGrid.from_tempo(
#     tempo_bpm=...,
#     length_seconds=...,
#     start_seconds=...,
# )
# ...

Exercise 2: Tempo Change Workaround

For a track that changes tempo at 60 seconds (from 120 to 140 BPM), create two BeatGrids and concatenate their beat arrays.

Hint: Use np.concatenate() on the beat_seconds() arrays.

# Your solution here:
# grid1 = BeatGrid.from_tempo(tempo_bpm=120, length_seconds=60, start_seconds=0)
# grid2 = BeatGrid.from_tempo(tempo_bpm=140, length_seconds=120, start_seconds=60)
# all_beats = np.concatenate([grid1.beat_seconds(), grid2.beat_seconds()])
# ...

Exercise 3: Custom Labels

Use grid.beat_seconds() and grid.measure_at_seconds() to create a custom export with time signature in the labels (e.g., “M1 (4/4)”).

# Your solution here:
# times = grid.measure_seconds()
# labels = [f"M{i} (4/4)" for i in range(1, len(times) + 1)]
# ...

Next Steps

  • Tutorial 07: TimelineGroups - Connect beatgrids to other timelines
  • Tutorial 08: SUPRA Piano Roll - See beatgrids in a complete alignment workflow
  • Score Integration: MeasureMapLoader for complex scores