Open In Colab Binder

Music scales widget

Imports and utility classes

import warnings
import numpy as np
from scipy import signal
from ipywidgets import Button, IntSlider, Output, Layout, HBox, VBox, Dropdown
from ipywidgets import AppLayout, IntProgress, BoundedIntText, GridspecLayout
from IPython.display import Audio, display

%matplotlib inline
%config InlineBackend.figure_format = 'svg'
import matplotlib.pyplot as plt

import sys
in_colab = 'google.colab' in sys.modules

from collections import namedtuple, OrderedDict

continuous_update = False

import asyncio

class Timer:
    def __init__(self, timeout, callback):
        self._timeout = timeout
        self._callback = callback
        self._task = asyncio.ensure_future(self._job())

    async def _job(self):
        await asyncio.sleep(self._timeout)
        self._callback()

    def cancel(self):
        self._task.cancel()

def debounce(wait):
    """ Decorator that will postpone a function's
        execution until after `wait` seconds
        have elapsed since the last time it was invoked. """
    def decorator(fn):
        if continuous_update:
            timer = None
            def debounced(*args, **kwargs):
                nonlocal timer
                def call_it():
                    fn(*args, **kwargs)
                if timer is not None:
                    timer.cancel()
                timer = Timer(wait, call_it)
            return debounced
        else:
            return fn
    return decorator

Music arithmetic

octave_cents = 1200

fifth_circle_octaves = 7

note_names = ['C', 'C#', 'D', 'D#', 'E',
              'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']


# Function to convert decimal to Roman Numerals 
def roman(number): 
    num = [1, 4, 5, 9, 10, 40, 50, 90,  
           100, 400, 500, 900, 1000] 
    sym = ["I", "IV", "V", "IX", "X", "XL",  
           "L", "XC", "C", "CD", "D", "CM", "M"] 
    i = 12
    rom = ''
    while number: 
        div = number // num[i] 
        number %= num[i] 
  
        while div: 
            rom += sym[i]
            div -= 1
        i -= 1
        
    return rom

# roman_nums = ['I', 'II', 'III', 'IV', 'V', 'VI',
#               'VII', 'VIII', 'IX', 'X', 'XI', 'XII']

num_halftones = len(note_names)
halftone_cents = round(octave_cents / num_halftones)

a4_hz = 440
a4_index = note_names.index('A')


def ratio2cents(ratio):
  return np.log2(ratio) * octave_cents


def cents2ratio(cents):
  return np.power(2, np.divide(cents, octave_cents))


def octave_fit(cents):
  return cents % octave_cents


def tet_fit(cents):
  return round(cents / halftone_cents) * halftone_cents


def stacked_fifths(num_notes, fifth_val, root):
    
    note_cents = [octave_fit(n * fifth_val) for n in range(num_notes)]

    ref_cents = note_cents[root]
    note_cents = [round(octave_fit(cents - ref_cents)) for cents in note_cents]

    note_cents.sort()
    
    return note_cents
    

fifth_cents = ratio2cents(3 / 2)    # Pure fifth
majthird_cents = ratio2cents(5 / 4) # Pure major third

note_freq_hz = [a4_hz * cents2ratio((n - a4_index) * halftone_cents) for n in range(num_halftones)]

syntcomm_cents = octave_fit(fifth_cents * 4) - majthird_cents # Syntonic comma


# Equal division of the octave
def edo(num_notes):
  tone_step = octave_cents / num_notes
  note_cents = [round(n * tone_step) for n in range(num_notes) ]
  return note_cents



def pythagoras(num_notes=num_halftones):
    
    return stacked_fifths(num_notes, fifth_cents, root=5)
    

def five_limit_just():
    ratios = [1, 16/15, 9/8, 6/5, 5/4, 4/3,
              45/32, 3/2, 8/5, 5/3, 9/5, 15/8]
    note_cents = np.round(ratio2cents(ratios))
    
    return note_cents


def seven_limit_just():
    ratios = [1, 16/15, 9/8, 6/5, 5/4, 4/3,
              7/5, 3/2, 8/5, 5/3, 7/4, 15/8]
    note_cents = np.round(ratio2cents(ratios))
    
    return note_cents


def meantone(num_notes=num_halftones, comma_split=4):
    meantone_fifth = fifth_cents - syntcomm_cents / comma_split
    return stacked_fifths(num_notes, meantone_fifth, root=5)


def twelve_tet():
    note_cents = [round(n * halftone_cents) for n in range(num_halftones)]
    return note_cents


def fifth_order(num_notes, first=0):
    fifths = octave_fit(np.arange(num_notes) * fifth_cents)
    ordered = np.argsort(np.argsort(fifths))
    ordered = (np.append(ordered, 0) + first) % num_notes
    return ordered


def get_note_name(cents, ref):
    
    c_cents = octave_fit(cents + ref)
    c_index = np.round(c_cents / halftone_cents).astype(np.int)
    
    return note_names[c_index % num_halftones]


def get_cents(freq):
    return ratio2cents(freq / note_freq_hz[0])


def get_error(cents, round_digits=None):
    
    error = (cents2ratio(cents) - 1) * 100
    if round_digits is not None:
        e_sgn = np.sign(error)
        round_power = 10 ** round_digits
        e_ceil = np.ceil(np.abs(error) * round_power) / round_power
        error = e_sgn * e_ceil
        
    return error

Scale and temperament options

pyth_name = "Pythagorean (pure fifths)"
edo_name = "Equally divided octave"
mtone_name = "Meantone (1/4-comma)"


Temperament = namedtuple('Temperament', ('name', 'get'))
Scale = namedtuple('Scale', ('name', 'notes', 'fifth_origin'))
Note = namedtuple('Note', ('name', 'pitch'))


root_notes = tuple(Note(nn, nf) for nn, nf in zip(note_names, note_freq_hz))

temp_chromatic = (Temperament("Equal temperament", twelve_tet),
                  Temperament("Just intonation (5-limit)", five_limit_just),
                  Temperament(pyth_name, pythagoras),
                  Temperament(mtone_name, meantone),
                  Temperament(edo_name, edo))

temp_microtonal = (Temperament(edo_name, edo),
                   Temperament(pyth_name, pythagoras),
                   Temperament(mtone_name, meantone))

scales = OrderedDict()

scales[1] = (Scale("Octave", (0,), 0),)

scales[2] = (Scale("Fifth", (0, 7), 0),
             Scale("Fourth", (0, 5), 0),
             Scale("Major third", (0, 4), 0),
             Scale("Minor third", (0, 3), 0),
             Scale("Major sixth", (0, 9), 0),
             Scale("Minor sixth", (0, 8), 0),
             Scale("Major seventh", (0, 11), 0),
             Scale("Minor seventh", (0, 10), 0),
             Scale("Major second", (0, 2), 0),
             Scale("Minor second", (0, 1), 0),
             Scale("Tritone", (0, 6), 0))

scales[3] = (Scale("Structural", (0, 5, 7), 0),
             Scale("Major triad", (0, 4, 7), 0),
             Scale("Minor triad", (0, 3, 7), 0),
             Scale("Diminished triad", (0, 3, 6), 0),
             Scale("Augmented triad", (0, 4, 8), 0))

scales[4] = (Scale("Major tetratonic", (0, 2, 5, 7), 2),
             Scale("Minor tetratonic", (0, 3, 5, 10), 1),
             Scale("Major seventh", (0, 4, 7, 11), 0),
             Scale("Minor seventh", (0, 3, 7, 10), 0),
             Scale("Dominant seventh", (0, 4, 7, 10), 0),
             Scale("Diminished seventh", (0, 3, 6, 9), 0),
             Scale("Half-diminished seventh", (0, 3, 6, 10), 0),
             Scale("Minor-major seventh", (0, 3, 7, 11), 0))

scales[5] = (Scale("Major pentatonic", (0, 2, 4, 7, 9), 0),
             Scale("Minor pentatonic", (0, 3, 5, 7, 10), 0))

scales[6] = (Scale("Major hexatonic", (0, 2, 4, 5, 7, 9), 3),
             Scale("Minor hexatonic", (0, 2, 3, 5, 7, 10), 2),
             Scale("Whole-tone", (0, 2, 4, 6, 8, 10), 0),
             Scale("Blues scale", (0, 3, 5, 6, 7, 10), 4))

scales[7] = (Scale("Major scale", (0, 2, 4, 5, 7, 9, 11), 0),
             Scale("Minor scale", (0, 2, 3, 5, 7, 8, 10), 0),
             Scale("Harmonic minor", (0, 2, 3, 5, 7, 8, 11), 0),
             Scale("Lydian mode", (0, 2, 4, 6, 7, 9, 11), 0),
             Scale("Mixolyidian mode", (0, 2, 4, 5, 7, 9, 10), 0),
             Scale("Dorian mode", (0, 2, 3, 5, 7, 9, 10), 0),
             Scale("Phryigian mode", (0, 1, 3, 5, 7, 8, 10), 0),             
             Scale("Locrian mode", (0, 1, 3, 5, 6, 8, 10), 0))

scales[12] = (Scale("Chromatic", tuple(range(num_halftones)), 0),)

scales[17] = (Scale("-", 17, 0),)
scales[24] = (Scale("Quarter-tone", 24, 0),)
scales[31] = (Scale("-", 31, 0),)
scales[53] = (Scale("-", 53, 0),)

scale_cols= {1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 
             7: 1, 12: 1, 17: 3,
             24: 4, 31: 4, 53: 6}

valid_num_notes = tuple(scales.keys())


default_num_notes = 7


def get_notes(temperament, notes, root=0):
    
    if type(notes) is int:
        tempered_notes = temperament.get(notes)
        
        f_ref = note_freq_hz[root]
        note_tuple = tuple(Note('', tempered_notes[n]) for n in range(notes))
      
    elif type(notes) is tuple:
        tempered_notes = temperament.get()
        
        shifted_notes = [(n + root) % num_halftones for n in notes]

        f_ref = note_freq_hz[root]
        cent_ref = tempered_notes[root]
        
        note_tuple = tuple(Note(note_names[n],
                                octave_fit(tempered_notes[n] - cent_ref)) for n in shifted_notes)

    else:
        raise ValueError("notes must be an integer or a tuple of integers.")
        
    return note_tuple, f_ref
    

Signal processing

My very own minimalist synth implementation in less than 200 lines of code!!

sampling_rate = 44100      # Sample rate (Hz)

def time2samples(t, rate):
    """
    Convert time to number of samples at a certain rate.
    :param t: Time in seconds
    :param rate: Sampling rate.
    :return: Number of samples for the given time at the given rate.
    """
    return round(t * rate)


def db2mag(db):
    return 10 ** (db / 20)


def piecewiselin(x, y):
    """
    Create piecewise linear array
    :param x: Lengths of the segments in number of samples.
    :param y: Point values. Its length must be 1 greater than the lenght of x.
    :return: Piecewise linear array.
    """
    xlen = len(x)
    ylen = len(y)

    if (ylen - xlen) != 1:
        raise ValueError(f"Invalid dimensions len(x)={xlen}, len(y)={ylen}. len(x) must equal len(y) - 1.")

    y_start = y[:-1]
    y_stop = y[1:]

    pwl = np.array([])
    for start, stop, num in zip(y_start, y_stop, x):
        pwl = np.append(pwl, np.linspace(start, stop, num))

    return pwl


def adsr(adsr_durations, adsr_levels, total_duration=None, sustain_fade=1, smooth_factor=1):

    n_attack, n_decay, n_sustain, n_release = adsr_durations

    keystroke_duration = n_attack + n_decay + n_sustain + n_release
    
    db_floor, db_peak, db_sustain, db_release = adsr_levels

    if total_duration is not None:
      if total_duration < keystroke_duration:
        raise ValueError("Total duration must be greater or equal than the overall keystroke duration")
        
      fadeout = total_duration - keystroke_duration

      db_slope = (db_floor - db_sustain) / n_release
      db_release += db_slope * fadeout

      n_release += fadeout

    if max(adsr_levels) > -6.0:
        raise warnings.warn(f"Your dB levels are {adsr_levels}. Levels greater than -6 dB may saturate.", UserWarning)
    

    #################################################################
    #                                                               #
    #                       ADSR Curve                              #
    # dB Level ^                                                    #
    #          |                                                    #
    #    Peak  |        X                                           #
    #          |       X XX                                         #
    #          |      X    XX                                       #
    # Sustain  |     X       XXXXXXXXXXXXX                          #
    #          |    X                     XX                        #
    #          |   X                        XX                      #
    #          |  X                           XX                    #
    #          | X                              XX                  #
    #   Floor  |X                                 X                 #
    #          +--------------------------------------------> Time  #
    #          |-----|------------|---------|-------|-------|       #
    #           Attack  Decay      Sustain   Release Fadeout        #
    #################################################################
    
    if smooth_factor > 1:
        
        n_attack //= smooth_factor
        n_decay //= smooth_factor
        n_sustain //= smooth_factor
        n_release //= smooth_factor

    adsr_curve = piecewiselin((n_attack, n_decay, n_sustain, n_release),
                              (db_floor, db_peak, db_sustain,
                               db_sustain - sustain_fade, db_release))
    
    if smooth_factor > 1:
        adsr_curve = signal.resample(adsr_curve, total_duration, window="hamming")
    
    magnitudes = db2mag(adsr_curve)

    return magnitudes


class Filter:
    """
        Apply Butterworth lowpass-filter to signal
        :param x: Input signal.
        :param fc: Cutoff frequency for the filtering. Default is 8 kHz.
        :param fs: Sampling frequency. Default is 44.1 kHz
        :param order: Order of the butterworth lowpass filter. Default is 4
        :return: Filtered signal
    """
    def __init__(self, fc, order, fs):
        b, a = signal.butter(order, fc * 2 / fs)
        self.b = b
        self.a = a
        self._initial = signal.lfilter_zi(b, a)
        self.z = self._initial
        
    def lfilter(self, x):
        y, zf = signal.lfilter(self.b, self.a, x, zi=self.z)
        self.z = zf
        return y
    
    def reset(self):
        self.z *= 0
    

def sawtooth_harmonics(f0, t, n_harmonics=10, fundamental=1.0,
                       power=1, random_phase=True):
    
    wave = np.zeros_like(t)
    
    for n in np.arange(1, n_harmonics + 1):
        if n == 1:
            a = fundamental
        else:
            a = 1 / (n ** power)
            
        phase = 2 * np.pi * f0 * n * t
        if random_phase:
            phase += 2 * np.pi * np.random.rand()
        wave += a * np.sin(phase)

    return wave


lpf = Filter(fc=4000, order=6, fs=sampling_rate)


def synthesize(note_frequencies, fref=261.626, fs=sampling_rate, fadeout=1,
               attack=.1, decay=.1, sustain=-15, release=.7, sus_time=.4,
               floor=-90, peak=-12, rel_level=-90, spacing=.6, chord_notes=4,
               progress=None):
  

  n_freqs = len(note_frequencies)
  
  octave_chord = n_freqs == 2

  chord = n_freqs <= chord_notes + 1

  attack_samples = time2samples(attack, fs)
  decay_samples = time2samples(decay, fs)
  sustain_samples = time2samples(sus_time, fs)
  release_samples = time2samples(release, fs)
  fadeout_samples = time2samples(fadeout, fs)

  sample_spacing = time2samples(spacing, fs)
    
  chord_length = sample_spacing + fadeout_samples * 2 if chord else 0  

  total_samples = sample_spacing * n_freqs  + fadeout_samples + chord_length

  adsr_samples = (attack_samples, decay_samples,
                  sustain_samples, release_samples)
  adsr_levels = (floor, peak, sustain, rel_level)
    
  chord_adsr_ivals = (attack_samples, decay_samples,
                      2 * sustain_samples, release_samples)
    
  adsr_curve = adsr(adsr_samples, adsr_levels)
  adsr_chord = adsr(chord_adsr_ivals, adsr_levels, total_duration=chord_length) if chord else 0
    
  data = np.zeros(total_samples)

  t_length = adsr_curve.size
    
  max_length = np.max((chord_length, t_length))  

  t = np.arange(max_length) / fs
    
  lpf.reset()

  for n, f_cent in enumerate(note_frequencies):
    
      last_note = n == n_freqs - 1

      t_offset = n * sample_spacing
      t_wave = t_offset + t_length
      t_filter = total_samples if last_note else (n + 1) * sample_spacing

      f_hz = fref * np.power(2, f_cent / octave_cents)
      
      waveform = sawtooth_harmonics(f_hz, t, power=1.7)
      wave = waveform[:t_length] * adsr_curve

      data[t_offset:t_wave] += wave
    
      if chord and ((not last_note) or octave_chord):
         wave = waveform[:chord_length] * adsr_chord
         data[-chord_length:] += wave
            
      data[t_offset:t_filter] = lpf.lfilter(data[t_offset:t_filter])
            
      if progress is not None:
        progress(n + 1)

  return data


def compress(x):
    
    peak = np.abs(x).max()
    
    if peak > 1:
        return x / peak
    else:
        return x
    

Widget

def parse_options(options):
    return [(o.name, n) for n, o in enumerate(options)]


def react(*args):
    for a in args:
        a.react = True
        
        
def unreact(*args):
    for a in args:
        a.react = False
        
        
audio_height = "auto"
btn_width = 55

head_layout = {'height': 'auto', 'width': 'auto'}
btn_layout = {'height': 'auto', 'width': f'{btn_width}%'}
output_layout = {'align_items': 'center', 'align_content': 'center'}
style = {'description_width': f'{100 - btn_width}%'}

tuner_box = VBox([], layout=Layout(align_content='stretch', align_items='stretch',
                                   justify_content='flex-start', height="100%",
                                   width="100%", overflow="hidden"))

cent_margin = 5
            
            
def clip_neighbours(cell):
    
    num_notes = btn_notes.value
        
    note_widgets = cell.pool[:num_notes]

    i_note = cell.index

    low_note = note_widgets[(i_note - 1) % num_notes]
    high_note = note_widgets[(i_note + 1) % num_notes]

    if low_note is not note_widgets[-1]:
        low_note.max = cell.value - cent_margin
    if high_note is not note_widgets[0]:
        high_note.min = cell.value + cent_margin
        
        
def clip_value(change):
    owner = change["owner"]
    if owner.react:
        pool = owner.pool
        if pool is sliders_pool:
            
            num_notes = btn_notes.value
        
            note_widgets = owner.pool[:num_notes]

            i_note = owner.index

            low_note = note_widgets[(i_note - 1) % num_notes]
            high_note = note_widgets[(i_note + 1) % num_notes]
            
            low_value = low_note.value + cent_margin
            high_value = high_note.value - cent_margin

            if owner is note_widgets[0]:
                low_value -= octave_cents
            elif owner is note_widgets[-1]:
                high_value += octave_cents
            
            unreact(owner)

            if owner.value < low_value:
                owner.value = low_value
            elif owner.value > high_value:
                owner.value = high_value

            change["new"] = owner.value
            update_name(change)
            plot_notes(change)

            react(owner)
        
        elif pool is cell_pool:
            clip_neighbours(owner)
            
        else:
            raise RuntimeError("Unrecognized pool")
            

def notes2theta(note_list):
    theta = np.array(note_list) * 2 * np.pi / octave_cents
    
    return theta


plot_out = Output(layout=Layout(align_content='center', align_items='center',
                                justify_content='center', min_height="0px",
                                justify_items='center'))


def init_plot():
    with plot_out:
        
        plt.close(2)

        fig = plt.figure(2)
        fig.canvas.header_visible = False

        axis = fig.add_subplot(1, 1, 1, frameon=False, projection='polar')

        axis.set_theta_direction(-1)
        axis.set_theta_zero_location('N')

        axis.spines['polar'].set_visible(False)

        axis.set_yticks([1])
        axis.set_yticklabels([])
        axis.set_ylim(0, 1.1)
        plt.grid(False, axis='x')

        pline, = axis.plot(1, 1, 'tab:purple', marker="o", mfc="k", mec="k")

        pline.r = [1]
        plt.tight_layout(pad=0.0)
    
    return pline, axis


def set_notes(notes):
    pline, axis = init_plot()
    theta = notes2theta(notes)
    pline.set_data(theta[btn_scale.fifth_order], btn_notes.r)
    axis.set_xticks(theta)
    axis.set_xticklabels(btn_scale.xticklabels)

    
audio_layout = Layout(width="100%", align_items='flex-end')

audio_bar = IntProgress(description="Generating notes",
                        min=0, max=default_num_notes,
                        value=0, style=style, layout=Layout(**head_layout))
audio_out = Output(layout=audio_layout)

audio_out.f_root = a4_hz
audio_out.fs = sampling_rate
audio_out.player = None


def plot_notes(change):
    if not hasattr(change, 'owner') or change["owner"].react:
        with plot_out:
            
            if type(change) is tuple:
                notes = [nt.pitch for nt in change]
            else:
                notes = [ns.value for ns in change["owner"].pool[:btn_notes.value]]
            
            set_notes(notes)
            plot_out.clear_output(wait=True)
            plt.tight_layout(pad=0.0)
            plt.show()


@debounce(.2)
def generate_notes(change):
    if not hasattr(change, 'owner') or change["owner"].react:
        loading_audio()
        def audio_progress(p): audio_bar.value = p
        if type(change) is tuple:
            freqs = [nt.pitch for nt in change]
        else:
            freqs = [ns.value for ns in change["owner"].pool[:btn_notes.value]]
            
        freqs.append(freqs[0] + octave_cents)
        audio_data = synthesize(freqs, fref=audio_out.f_root,
                                fs=audio_out.fs, progress=audio_progress)
        audio_data = compress(audio_data)
        
        audio_kwargs = {"rate": audio_out.fs}
        if not in_colab:
            audio_kwargs["normalize"] = False

        if audio_out.player is None:
            audio = Audio(audio_data, autoplay=False, **audio_kwargs)  
            audio_out.player = audio
        else:
            audio_bytes = audio_out.player._make_wav(audio_data, **audio_kwargs)
            audio_out.player.data = audio_bytes
            audio_out.data = audio_data
        with audio_out:
            audio_out.clear_output(wait=True)
            display(audio_out.player)
        audio_ready()
        
    
def tune(change=None):
    
    if not hasattr(change, 'owner') or change["owner"].react:
        
        loading_audio()
        
        n_notes = btn_notes.value
        root = btn_root.value
        temperament = btn_temperament.temperament[btn_temperament.value]
        scale = scales[n_notes][btn_scale.value]

        if temperament.name == edo_name or n_notes > num_halftones:
            scale_notes = n_notes
        else:
            scale_notes = scale.notes


        default_notes, f_root = get_notes(temperament, scale_notes, root)
        audio_out.f_root = f_root

        btn_scale.cents_root = get_cents(f_root)

        btn_scale.fifth_order = fifth_order(n_notes, first=scale.fifth_origin)


        if n_notes > num_halftones:
            btn_scale.xticklabels = []
        else:
            btn_scale.xticklabels = [dn.name for dn in default_notes]

        
        cols = scale_cols[n_notes]
        
        if cols <= 1:
            pool = sliders_pool
            unreact(*pool)
            for tune_widget, nt in zip(pool, default_notes):
                tune_widget.description = nt.name
                tune_widget.value = nt.pitch
                react(tune_widget)
            show_tuner(n_notes)
        else:
            pool = cell_pool
            unreact(*pool)
            for tune_widget, nt in zip(pool, default_notes):
                tune_widget.description = nt.name
                tune_widget.min = 0
                tune_widget.max = octave_cents
                tune_widget.value = nt.pitch
            for tune_widget in pool[:n_notes]:
                clip_neighbours(tune_widget)
                react(tune_widget)
            show_tuner(n_notes, cols=cols)

        plot_notes(default_notes)
        generate_notes(default_notes)
    
    
def update_name(change):
    n_notes = btn_notes.value
    widget = change["owner"]
    if widget.react and not btn_temperament.edo and n_notes <= num_halftones:
        cents = change["new"]
        name = get_note_name(cents, btn_scale.cents_root)

        if name != widget.description:
            widget.description = name
            btn_scale.xticklabels[widget.index] = name

    
sliders_pool = ()
for n in range(num_halftones):
    new_slider = IntSlider(min=0, max=octave_cents, step=1, disabled=False,
                           continuous_update=continuous_update,
                           layout=Layout(width='auto', height="auto",
                                         min_height="0px"), style={'description_width': "20px"})
    
    new_slider.index = n
    
    new_slider.observe(clip_value, 'value')
    new_slider.observe(update_name, 'value')
    new_slider.observe(plot_notes, 'value')
    new_slider.observe(generate_notes, 'value')
    sliders_pool += new_slider,

for slider in sliders_pool:
    slider.pool = sliders_pool

unreact(*sliders_pool)


cell_pool = ()

for n in range(max(valid_num_notes)):
    new_cell = BoundedIntText(min=0, max=octave_cents, step=1, disabled=False,
                              continuous_update=continuous_update,
                              layout=Layout(width='auto', min_width="5%"),
                              style={'description_width': "15%"})
    
    new_cell.index = n
    
    new_cell.observe(clip_value, 'value')
    new_cell.observe(update_name, 'value')
    new_cell.observe(plot_notes, 'value')
    new_cell.observe(generate_notes, 'value')
    cell_pool += new_cell,

unreact(*cell_pool)    

for c in cell_pool:
    c.pool = cell_pool

    
def select_n_notes(change):
    
    n_notes = change["new"]

    audio_bar.max = n_notes
    btn_notes.r = np.ones(n_notes + 1)
    
    loading_audio()
    
    unreact(btn_temperament, btn_root, btn_scale)
    
    if n_notes > num_halftones:
        if btn_temperament.temperament is not temp_microtonal:
            btn_temperament.temperament = temp_microtonal
            btn_temperament.value  = 0
        btn_temperament.options = parse_options(temp_microtonal)
        btn_scale.disabled = True
    else:
        if btn_temperament.temperament is not temp_chromatic:
            btn_temperament.temperament = temp_chromatic
            btn_temperament.value  = 0      
        btn_temperament.options = parse_options(temp_chromatic)
        btn_scale.disabled = False
    hide_tuner()
    
    
    btn_scale.options = parse_options(scales[n_notes])
    
    btn_scale.value = 0
    
    tune()
    
    react(btn_temperament, btn_root, btn_scale)
    
    
def select_temperament(change):
    new_temp = btn_temperament.temperament[change["new"]]
    edo_selected = new_temp.name == edo_name
    btn_scale.disabled = edo_selected
    btn_temperament.edo = edo_selected
    tune(change)


btn_notes = Dropdown(options=valid_num_notes, value=default_num_notes,
                     description="Number of Notes", disabled=False,
                     style=style,
                     layout=Layout(**head_layout))

btn_root = Dropdown(options=parse_options(root_notes), value=0,
                    description="Tonic/Root", disabled=False,
                    style=style,
                    layout=Layout(**head_layout))

btn_temperament = Dropdown(options=parse_options(temp_chromatic),
                           value=0, description="Tuning",
                           style=style,
                           disabled=False, layout=Layout(**head_layout))

btn_temperament.edo = False

btn_temperament.temperament = temp_chromatic

btn_scale = Dropdown(options=parse_options(scales[default_num_notes]),
                     value=0, description="Scale/Chord",
                     style=style,
                     disabled=False, layout=Layout(**head_layout))

btn_reset = Button(description="Reset", layout=Layout(**btn_layout))

react(btn_temperament, btn_root, btn_scale)

btn_notes.observe(select_n_notes, 'value')
btn_temperament.observe(select_temperament, 'value')
btn_root.observe(tune, 'value')
btn_scale.observe(tune, 'value')
btn_reset.on_click(tune)

layout_right = Layout(width="auto",
                      display='flex',
                      flex_flow='column',
                      align_items='flex-end')

right_box = HBox(children=[btn_reset],
               layout=layout_right)

buttons = (btn_notes, btn_root, btn_temperament,btn_scale, right_box)

ui = AppLayout(pane_widths=[4, 0, 5], pane_heights=[0, 6, 4],
               grid_gap="0px",
               layout=Layout(justify_items="center", height="290px",
                             align_content='flex-start', align_items='flex-start'))
ui.right_sidebar = tuner_box
ui.left_sidebar = VBox(buttons + (audio_out,),
                       layout=Layout(width="100%", justify_content='flex-start'))

ui.loading = False
ui.visible_tuner = True


def loading_audio():
    if not ui.loading:
        audio_bar.value = 0
        ui.left_sidebar.children = buttons + (audio_bar,)
        ui.loading = True
        
        
def audio_ready():
    if ui.loading:
        ui.left_sidebar.children = buttons + (audio_out,)
        ui.loading = False
        
def hide_tuner():
    if ui.visible_tuner:
        tuner_box.children = ()
        ui.visible_tuner = False
        
        
def show_tuner(n_notes, cols=None):
    if cols is None:
        tuner_box.children = sliders_pool[:n_notes]
        ui.right_sidebar= tuner_box
    else:
        rows = int(np.ceil(n_notes / cols))
        
        grid = GridspecLayout(rows, cols, layout=Layout(align_content='stretch', align_items='stretch',
                                                        justify_content='flex-start', height="auto"))
        
        for n in range(n_notes):
            grid[n // cols, n % cols] = cell_pool[n]
        
        ui.right_sidebar= grid
    ui.visible_tuner = True
        
plot_box = HBox([plot_out], layout=Layout(align_content='center', align_items='center',
                                          justify_content='center', justify_items='center',
                                          padding="0.0"))

select_n_notes({"old": 0, "new": default_num_notes})


display(plot_box, ui)