Skip to content

Music: a piano roll of a tune you wrote

Music: a piano roll of a tune you wrote

Run it here, in your browser

The tune is a string of notes, shown at the top. The sketch reads the string itself and draws each note as a bar. Time goes across, and higher notes sit higher up. The roll scrolls past a fixed line as the tune plays, and each note lights up while it sounds. Tick the box to draw the pitch that the sound itself gives, on top. (This one uses real time, so the picture changes from run to run.)

How it works

  • MELODY is text. parse() reads it with a regular expression into notes: the token, the start beat, how many beats, and the pitches. A - is a rest, [ ] is a chord, and :2 is two beats.
  • pitch_number() uses f.note_to_frequency() to turn a note name into a semitone number, where 69 is A4. The roll's top and bottom come from the lowest and highest note.
  • The same MELODY goes to f.melody() to make the sound, so the roll and the sound always agree. That works because the notes were written in code. A roll from a recording would need machine learning.
  • now = tune.current_time() is the clock. Each bar's x is HEAD + (start * BEAT - now) * PPS, so the bars slide left past the line at HEAD. The bars are drawn twice, so the start of the next time round is in view.
  • f.create_checkbox() makes the box. When it is ticked, f.draw_pitch_line(tune, ...) draws the one note that tune.pitch() hears. A chord has no single pitch, so it leaves a gap.

Make it yours

  • Change the notes in MELODY. The roll resizes to fit them.
  • Change TEMPO to play it faster.
  • Change PPS to 120 to zoom in, or to 50 to see more of the tune.
  • Change the colours "gold" and "#6c8cff" for the bars.
  • Start with the pitch line off: change True to False in f.create_checkbox().

The code

Copy it into a file and run it.

"""Music: a piano roll of a tune you wrote

The tune is a string of notes, shown at the top. The sketch reads the string itself and draws each
note as a bar. Time goes across, and higher notes sit higher up. The roll scrolls past a fixed
line as the tune plays, and each note lights up while it sounds. Tick the box to draw the pitch
that the sound itself gives, on top.

How it works:
- MELODY is text. parse() reads it with a regular expression into notes: the token, the start
  beat, how many beats, and the pitches. A - is a rest, [ ] is a chord, and :2 is two beats.
- pitch_number() uses f.note_to_frequency() to turn a note name into a semitone number, where 69 is
  A4. The roll's top and bottom come from the lowest and highest note.
- The same MELODY goes to f.melody() to make the sound, so the roll and the sound always agree.
  That works because the notes were written in code. A roll from a recording would need machine
  learning.
- now = tune.current_time() is the clock. Each bar's x is HEAD + (start * BEAT - now) * PPS, so the
  bars slide left past the line at HEAD. The bars are drawn twice, so the start of the next time
  round is in view.
- f.create_checkbox() makes the box. When it is ticked, f.draw_pitch_line(tune, ...) draws the one
  note that tune.pitch() hears. A chord has no single pitch, so it leaves a gap.

Make it yours:
- Change the notes in MELODY. The roll resizes to fit them.
- Change TEMPO to play it faster.
- Change PPS to 120 to zoom in, or to 50 to see more of the tune.
- Change the colours "gold" and "#6c8cff" for the bars.
- Start with the pitch line off: change True to False in f.create_checkbox().
"""
# gallery: time-dependent
import math
import re

import funground as f

MELODY = ("E4 G4 C5:2 B4 G4 E4:2 - F4 A4 D5:2 C5 A4 F4:2 - "
          "G4:0.5 A4:0.5 B4 C5 D5 E5:2 D5:0.5 C5:0.5 B4 G4 [C4 E4 G4 C5]:4 -:2")
TEMPO = 108
BEAT = 60 / TEMPO
ROLL_X, ROLL_Y, ROLL_W = 60, 112, 560
HEAD = 200                       # the playhead: notes are here when they sound
PPS = 80                         # pixels for each second
NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]


def parse(text):
    """Read a melody string into notes: (token, start beat, beats, [pitch numbers]).
    A rest is - and a chord is [ notes ]. :beats after a token says how long (1 if left off)."""
    notes = []
    beat = 0.0
    for token in re.findall(r"\[[^\[\]]*\]\S*|\S+", text):
        names, _, length = token.partition(":")
        beats = float(length or 1)
        if names == "-":
            pitches = []
        else:
            pitches = [pitch_number(n) for n in names.strip("[]").split()]
        notes.append((token, beat, beats, pitches))
        beat += beats
    return notes, beat


def pitch_number(name):
    """A note name as a number of semitones, from f.note_to_frequency(): 69 is A4 (440 Hz)."""
    return round(69 + 12 * math.log2(f.note_to_frequency(name) / 440))


notes, total_beats = parse(MELODY)
ALL = [p for _, _, _, pitches in notes for p in pitches]
LOW, HIGH = min(ALL), max(ALL)
ROWS = HIGH - LOW + 1
ROW = 238 / ROWS                 # height of one row
LENGTH = total_beats * BEAT      # seconds in one time round
tune = f.melody(MELODY, tempo=TEMPO).reverb(0.2)
tune.set_volume(0.8)


def note_name(number):
    return NAMES[number % 12] + str(number // 12 - 1)


def row_top(number):
    return ROLL_Y + (HIGH - number) * ROW


def setup():
    global heard_box
    f.size(640, 440)
    heard_box = f.create_checkbox("draw the pitch that is heard on top", True)
    tune.loop()


def draw():
    now = tune.current_time() % LENGTH if tune.is_playing() else 0.0
    f.background("#12182b")
    f.no_stroke()
    f.text_align("left", "top")
    f.fill("white")
    f.text_size(22)
    f.text("A piano roll of a tune written in code", 20, 10)

    # the melody string, with the token that is sounding now lit
    f.text_size(14)
    x, y = 20, 46
    for token, start, beats, _ in notes:
        width = f.text_width(token + "  ")
        if x + width > 620:
            x, y = 20, y + 20
        lit = start * BEAT <= now < (start + beats) * BEAT
        f.fill("gold" if lit else "#8fa3cc")
        f.text(token, x, y)
        x += width

    # the keys on the left, then the rows
    for number in range(LOW, HIGH + 1):
        top = row_top(number)
        black = NAMES[number % 12].endswith("#")
        sounding = any(s * BEAT <= now < (s + b) * BEAT and number in p for _, s, b, p in notes)
        f.fill("gold" if sounding else "#1c2440" if black else "#d8deef")
        f.rect(ROLL_X - 52, top + 1, 48, ROW - 1.5, 3)
        f.fill("#d8deef" if black and not sounding else "#222a44")
        f.text_size(10)
        if NAMES[number % 12] in ("C", "E", "G") or sounding:
            f.text(note_name(number), ROLL_X - 48, top + ROW / 2 - 5)
        f.fill("#1a2340" if black else "#1f2a4a")
        f.rect(ROLL_X, top + 1, ROLL_W, ROW - 1.5)

    # the bars: the tune, and the start of the next time round if it is in view
    for shift in (0, LENGTH):
        for _, start, beats, pitches in notes:
            left = HEAD + (start * BEAT + shift - now) * PPS
            right = left + beats * BEAT * PPS - 2
            if right < ROLL_X or left > ROLL_X + ROLL_W:
                continue
            left = max(left, ROLL_X)
            on = shift == 0 and start * BEAT <= now < (start + beats) * BEAT
            f.fill("gold" if on else "#6c8cff")
            for number in pitches:
                f.rect(left, row_top(number) + 1.5, right - left, ROW - 3.5, 3)

    # the beat lines
    f.stroke(255, 255, 255, 25)
    f.stroke_width(1)
    for beat in range(-8, int(total_beats * 2) + 12):
        gx = HEAD + (beat * BEAT - now) * PPS
        if ROLL_X <= gx <= ROLL_X + ROLL_W and beat % 4 == 0:
            f.line(gx, ROLL_Y, gx, ROLL_Y + ROWS * ROW)

    # the pitch that sound.pitch() hears, over the past, which sits left of the playhead
    if heard_box.checked():
        f.no_fill()
        f.stroke("#ff6ec7")
        f.stroke_width(3)
        f.draw_pitch_line(tune, ROLL_X, ROLL_Y + ROW / 2, HEAD - ROLL_X, (ROWS - 1) * ROW,
                          seconds=(HEAD - ROLL_X) / PPS, low=note_name(LOW), high=note_name(HIGH))

    # the playhead
    f.stroke("white")
    f.stroke_width(2)
    f.line(HEAD, ROLL_Y - 6, HEAD, ROLL_Y + ROWS * ROW + 6)
    f.no_stroke()
    f.text_size(13)
    f.fill("#9fb4d8")
    f.text("The sketch reads the string above, so it knows every note before it plays.", 20, 366)
    f.text("This works for tunes made in code. A roll from a recording would need machine learning.", 20, 386)
    f.text("The pink line is the one note heard now. A chord has no single pitch, so it leaves a gap.", 20, 406)


f.run()

View on GitHub ยท Back to the gallery