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