Skip to content

Music: ear training

Music: ear training

Run it here, in your browser

The sketch plays two notes. The first is your starting note. The second is a mystery, higher than the first. Say how far up it is: click a button, press a key, or sing the second note. In "swara" mode the starting note is Sa and you name the swara. In "interval" mode the starting note changes, and you name the gap, like a minor 3rd. Space plays the notes again, and Enter or N asks the next question.

How it works

  • Sa is the home note, and a swara is a note named from it. SWARAS lists them by steps above Sa: S r R g G m M P d D n N. A small letter is komal (flat), and a capital M is tivra (sharp).
  • new_question() uses f.random_choice() to pick a step, never the same one twice in a row, and builds the question. play_question() plays it with f.melody(), such as "S:2 G:3 -:1", and sound.reverb(0.15).
  • mouse_pressed() checks the button rectangles. key_pressed() reads the swara letters, the arrow keys and Enter. Each calls answer(), which scores it.
  • Singing is read with mic.pitch(). sung_steps() turns the pitch into steps above the start and folds it into one octave with % 12. listen() takes the middle of the last five readings, and HOLD steady frames count as an answer.
  • The microphone waits until the notes have finished (f.millis() and quiet_until), so it does not hear the question. The small bar at the bottom left shows how loud the microphone hears you, in decibels (loudness()), so you can see it hears you even if it is quiet.
  • f.create_slider() picks the mode. The ladder on the right shows the right answer.

Make it yours

  • Change HOLD to 10 to make singing an answer quicker.
  • Change SA to "C4". Sa moves, and so do both notes.
  • Add notes to STARTS, such as "A3" and "B3", for more starting notes in interval mode.
  • Make it easier: in new_question(), change range(low, low + 12) to range(low, low + 8).
  • Change tempo=90 in play_question() to slow the notes down. Change the -70 and 50 in loudness() if the bar moves too much or too little.

The code

Copy it into a file and run it.

"""Music: ear training

The sketch plays two notes. The first is your starting note. The second is a mystery, higher than
the first. Say how far up it is: click a button, press a key, or sing the second note. In "swara"
mode the starting note is Sa and you name the swara. In "interval" mode the starting note changes,
and you name the gap, like a minor 3rd. Space plays the notes again, and Enter or N asks the next
question.

How it works:
- Sa is the home note, and a swara is a note named from it. SWARAS lists them by steps above Sa:
  S r R g G m M P d D n N. A small letter is komal (flat), and a capital M is tivra (sharp).
- new_question() uses f.random_choice() to pick a step, never the same one twice in a row, and
  builds the question. play_question() plays it with f.melody(), such as "S:2 G:3 -:1", and
  sound.reverb(0.15).
- mouse_pressed() checks the button rectangles. key_pressed() reads the swara letters, the arrow
  keys and Enter. Each calls answer(), which scores it.
- Singing is read with mic.pitch(). sung_steps() turns the pitch into steps above the start and
  folds it into one octave with % 12. listen() takes the middle of the last five readings, and
  HOLD steady frames count as an answer.
- The microphone waits until the notes have finished (f.millis() and quiet_until), so it does not
  hear the question. The small bar at the bottom left shows how loud the microphone hears you, in
  decibels (loudness()), so you can see it hears you even if it is quiet.
- f.create_slider() picks the mode. The ladder on the right shows the right answer.

Make it yours:
- Change HOLD to 10 to make singing an answer quicker.
- Change SA to "C4". Sa moves, and so do both notes.
- Add notes to STARTS, such as "A3" and "B3", for more starting notes in interval mode.
- Make it easier: in new_question(), change range(low, low + 12) to range(low, low + 8).
- Change tempo=90 in play_question() to slow the notes down. Change the -70 and 50 in loudness()
  if the bar moves too much or too little.
"""
import math

import funground as f

SA = "D4"
SWARAS = "S r R g G m M P d D n N".split()        # steps 0 to 11 above Sa
INTERVALS = ["minor 2nd", "major 2nd", "minor 3rd", "major 3rd", "4th", "tritone",
             "5th", "minor 6th", "major 6th", "minor 7th", "major 7th", "octave"]    # steps 1 to 12
STARTS = ["C4", "D4", "E4", "F4", "G4"]           # the starting notes in interval mode
HOLD = 20                                         # frames a sung note must stay the same

try:
    mic = f.microphone()
    mic.start()
except RuntimeError:
    mic = None                                    # no microphone: clicks and keys still work

mode = 1                  # 1 swara, 2 interval
question = {}             # start (a note name), start_hz and steps (the right answer)
answered = False
picked = None             # the steps the learner chose
score = 0
asked = 0
streak = 0
best = 0
cursor_at = 0             # which button the arrow keys are on
recent = []               # the last five sung readings, in steps above the start
held = 0                  # how many frames in a row the sung note has been the same
held_note = None
quiet_until = 0           # the microphone waits until the notes have finished playing
playing = None
message = ""
buttons = []              # (x, y, w, h, steps) for each answer button, set while drawing


def steps_of(button):
    """The number of steps above the start that a button stands for."""
    return button if mode == 1 else button + 1


def name_of(steps):
    return SWARAS[steps] if mode == 1 else INTERVALS[steps - 1]


def new_question():
    global question, answered, picked, recent, held, held_note, message
    low = 0 if mode == 1 else 1
    before = question.get("steps")
    steps = f.random_choice([n for n in range(low, low + 12) if n != before])
    start = SA if mode == 1 else f.random_choice(STARTS)
    question = {"start": start, "start_hz": f.note_to_frequency(start), "steps": steps}
    answered, picked, recent, held, held_note = False, None, [], 0, None
    message = "which swara is the second note?" if mode == 1 else "how far up is the second note?"
    play_question()


def play_question():
    """Both notes, in a small room. The microphone then waits for the sound to end."""
    global playing, quiet_until
    steps = question["steps"]
    if mode == 1:
        words = f"S:2 {SWARAS[steps]}:3 -:1"
        sound = f.melody(words, tempo=90, sa=SA, tuning="just").reverb(0.15)
    else:
        second = f.frequency_to_note(question["start_hz"] * 2 ** (steps / 12))
        sound = f.melody(f"{question['start']}:2 {second}:3 -:1", tempo=90).reverb(0.15)
    if playing:
        playing.stop()
    playing = sound
    playing.set_volume(0.8)
    playing.play()
    quiet_until = f.millis() + int(playing.duration() * 1000) + 300


def answer(steps):
    """Check an answer: the score and the streak, then play the notes again."""
    global answered, picked, score, asked, streak, best, message
    if answered:
        return
    answered, picked = True, steps
    asked += 1
    if steps == question["steps"]:
        score += 1
        streak += 1
        best = max(best, streak)
        message = "right! it was " + name_of(steps)
    else:
        streak = 0
        message = "not quite. it was " + name_of(question["steps"])
    play_question()


def loudness(level):
    """A number from 0 to 1 for a bar. It uses decibels, so a quiet laptop microphone still moves it:
    -70 dB (a very quiet room) is 0 and -20 dB (loud) is 1."""
    return f.constrain((20 * math.log10(level + 1e-9) + 70) / 50, 0, 1)


def sung_steps():
    """The note being sung, in steps above the start and in one octave (-0.5 to 11.5), or None."""
    if mic is None or answered or f.millis() < quiet_until:
        return None
    hz = mic.pitch()
    if hz is None:
        return None
    steps = 12 * math.log2(hz / question["start_hz"])
    return (steps + 0.5) % 12 - 0.5               # a note in another octave lands on the same rung


def listen():
    """A sung note held steady (the middle of the last five readings) for a moment is an answer."""
    global held, held_note
    here = sung_steps()
    if here is None:
        held, held_note = 0, None
        del recent[:]
        return
    recent.append(here)
    del recent[:-5]
    note = round(sorted(recent)[len(recent) // 2]) % 12
    if mode == 2 and note == 0:
        note = 12                                 # singing the start note again is the octave
    if note == held_note:
        held += 1
    else:
        held_note, held = note, 1
    if held >= HOLD:
        answer(note)


def mouse_pressed():
    for x, y, w, h, steps in buttons:
        if x <= f.mouse_x <= x + w and y <= f.mouse_y <= y + h:
            answer(steps)
            return


def key_pressed():
    global cursor_at
    key = f.key
    if key == " ":
        play_question()
    elif key in ("n", "N") or (key == "enter" and answered):
        new_question()
    elif key == "left":
        cursor_at = (cursor_at - 1) % 12
    elif key == "right":
        cursor_at = (cursor_at + 1) % 12
    elif key == "enter":
        answer(steps_of(cursor_at))
    elif mode == 1 and key in SWARAS:
        answer(SWARAS.index(key))


def setup():
    global mode_slider, again_button, next_button
    f.size(640, 420)
    mode_slider = f.create_slider(1, 2, 1, step=1, label="mode: 1 swara, 2 interval")
    again_button = f.create_button("hear it again")
    next_button = f.create_button("next question")
    new_question()


def draw():
    global mode, score, asked, streak
    if mode_slider.value() != mode:
        mode = mode_slider.value()
        score = asked = streak = 0
        new_question()
    if again_button.clicked():
        play_question()
    if next_button.clicked():
        new_question()
    listen()

    f.background("#14192b")
    f.no_stroke()
    f.text_align("left", "top")
    f.fill("white")
    f.text_size(22)
    f.text("Ear training: " + ("swaras above Sa" if mode == 1 else "intervals"), 20, 14)
    f.text_size(15)
    f.fill(("#58d68d" if picked == question["steps"] else "#ff7b72") if answered else "gold")
    f.text(message, 20, 46)
    f.fill("#9fb4d8")
    f.text_size(14)
    f.text(f"score {score} of {asked}     streak {streak}     best {best}", 20, 72)
    if mode == 1:
        f.text(f"Sa is {SA}.  A small letter is flat; M is sharp.", 20, 96)
    else:
        f.text(f"the starting note is {question['start']}", 20, 96)

    # the answer buttons, 4 across
    del buttons[:]
    f.text_align("center", "center")
    for i in range(12):
        x, y = 20 + (i % 4) * 100, 128 + (i // 4) * 52
        steps = steps_of(i)
        buttons.append((x, y, 92, 44, steps))
        if answered and steps == question["steps"]:
            f.fill("#2f9e6a")
        elif answered and steps == picked:
            f.fill("#b84a4a")
        else:
            f.fill("#46558a" if i == cursor_at else "#27304d")
        f.rect(x, y, 92, 44, 8)
        f.fill("white")
        f.text(name_of(steps), x + 46, y + 22)

    # what the microphone hears
    f.text_align("left", "top")
    f.text_size(13)
    f.fill("#9fb4d8")
    if mic is None:
        f.text("no microphone found: click or press keys to answer", 20, 300)
    else:
        hearing = f"   (hearing {name_of(held_note)})" if held_note is not None and not answered else ""
        f.text("or sing the second note and hold it" + hearing, 20, 300)
    f.text("space: hear again     enter or n: next question", 20, 322)
    f.text("arrow keys and enter: answer", 20, 342)
    if mic is not None:                            # the microphone light: it moves for any sound
        f.no_stroke()
        f.fill("#27304d")
        f.rect(100, 366, 120, 10, 5)
        f.fill("limegreen" if held_note is not None else "deepskyblue")
        f.rect(100, 366, 120 * loudness(mic.level()) + 0.001, 10, 5)
        f.fill("#9fb4d8")
        f.text("hearing you", 20, 363)

    # the ladder: one rung for each step above the start
    f.text_align("left", "center")
    for step in range(13):
        y = 380 - step * 25
        shown = answered and step in (0, question["steps"])
        if shown:
            f.fill("deepskyblue" if step == 0 else "gold")
        else:
            f.fill("#27304d")
        f.rect(450, y - 11, 170, 22, 5)
        f.fill("black" if shown else "#6f7fa8")
        if shown:
            note = f.frequency_to_note(question["start_hz"] * 2 ** (step / 12))
            f.text(f"{'start' if step == 0 else 'answer'}: {note}", 458, y)
        else:
            f.text(str(step), 458, y)
    f.fill("#9fb4d8")
    f.text_align("left", "bottom")
    f.text_size(12)
    f.text("steps above the start", 450, 410)


f.run()

View on GitHub ยท Back to the gallery