diff --git a/README.md b/README.md index 543b21b..78c7019 100644 --- a/README.md +++ b/README.md @@ -26,24 +26,29 @@ run fluidsynth in a terminal. For example, `fluidsynth -v -o midi.portname="flui Notochord includes several [iipyper](https://github.com/Intelligent-Instruments-Lab/iipyper.git) apps which can be run in a terminal. They have a clickable text-mode user interface and connect directly to MIDI ports, so you can wire them up to your controllers, DAW, etc. -The Notochord harmonizer adds extra concurrent notes for each MIDI note you play in. In a terminal, make sure your notochord Python environment is active and run: -``` -notochord harmonizer -``` -try `notochord harmonizer --help` -to see more options. - -the "homunculus" gives you a UI to manage multiple input, harmonizing or autonomous notochord channels: +The `homunculus` provides a text-based graphical interface to manage multiple input, harmonizing or autonomous notochord channels: ``` notochord homunculus ``` You can set the MIDI in and out ports with `--midi-in` and `--midi-out`. If you use a General MIDI synthesizer like fluidsynth, you can add `--send-pc` to also send program change messages. -If you are using fluidsynth, try: +If you are using fluidsynth as above, try: ``` notochord homunculus --send-pc --midi-out fluidsynth --thru ``` +Note: on windows, there are no virtual MIDI ports and no system MIDI loopback, so you may need to attach some MIDI devices or run a loopback driver like [loopMIDI](https://www.tobias-erichsen.de/software/loopmidi.html) before starting the app. + +There are also two simpler notochord apps: `improviser` and `harmonizer`. The harmonizer adds extra concurrent notes for each MIDI note you play in. In a terminal, make sure your notochord Python environment is active and run: +``` +notochord harmonizer +``` +try `notochord harmonizer --help` +to see more options. + +Development is now focused on `homunculus`, which is intended to subsume all features of `improviser` and `harmonizer`. + + ## Python API See the docs for `Notochord.feed` and `Notochord.query` for the low-level Notochord inference API which can be used from Python code. `notochord/app/simple_harmonizer.py` provides a minimal example of how to build an interactive app. diff --git a/pyproject.toml b/pyproject.toml index 11a111e..3b54498 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "notochord" -version = "0.5.2b0" +version = "0.5.3b0" description = "Notochord is a real-time neural network model for MIDI performances." authors = ["Victor Shepardson "] license = "MIT" @@ -15,6 +15,7 @@ tqdm = "^4.64" sf2utils = "^0.9" appdirs = "^1.4.4" iipyper = {path = "../iipyper", develop = true} +toml-file = "^1.0.5" [tool.poetry.group.dev.dependencies] mkdocs = "^1.5.3" diff --git a/src/notochord/__main__.py b/src/notochord/__main__.py index ad0c33b..7a4bd9a 100644 --- a/src/notochord/__main__.py +++ b/src/notochord/__main__.py @@ -1,7 +1,8 @@ -import sys +import sys, subprocess from iipyper import run +from notochord import Notochord from notochord.app import * def help(): @@ -11,6 +12,7 @@ def help(): homunculus: run the Notochord homunculus TUI harmonizer: run the Notochord harmonizer TUI improviser: run the Notochord improviser TUI + files: show the location of Notochord models and config files on disk """) def _main(): @@ -28,6 +30,11 @@ def _main(): if sys.argv[1] == 'improviser': sys.argv = sys.argv[1:] run(improviser) + if sys.argv[1] == 'files': + d = Notochord.user_data_dir() + print(d) + # os.system(f"open '{d}'") + subprocess.run(('open', d)) else: help() except IndexError: diff --git a/src/notochord/app/homunculus.css b/src/notochord/app/homunculus.css index b307aae..24fda34 100644 --- a/src/notochord/app/homunculus.css +++ b/src/notochord/app/homunculus.css @@ -64,15 +64,40 @@ MixerButtons Button { height:3; } -InstrumentSelect Grid { - grid-size: 8 16; +ModalScreen { + align: center middle; } -InstrumentSelect Button { + +ModalScreen Grid { + grid-size: 4 7; + grid-gutter: 1 1; + # grid-rows: 1fr 3; + padding: 0 1; + width: 35; + height: 22; + border: thick $background 80%; + background: $surface; +} +InstrumentGroup { border: none; min-width: 5; height: 2; - } +InstrumentSelect Grid { + grid-size: 4 5; +} +Instrument { + border: none; + min-width: 5; + min-height: 3; +} + +# InstrumentSelect Button { +# border: none; +# min-width: 5; +# height: 2; + +# } /* MixerButtons Button { height: 5; diff --git a/src/notochord/app/homunculus.py b/src/notochord/app/homunculus.py index a57a98b..9425eef 100644 --- a/src/notochord/app/homunculus.py +++ b/src/notochord/app/homunculus.py @@ -1,12 +1,30 @@ """ Notochord MIDI co-improviser server. Notochord plays different instruments along with the player. - -Authors: - Victor Shepardson - Intelligent Instruments Lab 2023 """ +# Authors: +# Victor Shepardson +# Intelligent Instruments Lab 2023 + +tui_doc = """Welcome to notochord homunculus. +You have 16 channels of MIDI, laid out in two rows of eight. +Each can be in one of three modes: + input (-->02) + follow (01->02) + auto (02) +Input channels take MIDI input from the corresponding channel, +and send it to notochord. When using --thru, input is also copied +to the output. + Follow channels act like a harmonizer; they query notochord for +a NoteOn whenever the followed channel has a NoteOn, and have the +corresponding NoteOff when the followed channel does. + Auto channels are played autonomously by notochord. +Each channel can be muted independently of the others. + Below the channel strips, there is a row of presets. +These can be edited in homunculus.toml; run `notochord files` to find +it in your file explorer.""" + # TODO: move soundfont / general MIDI stuff out of script # TODO: make key bindings visibly click corresponding buttons @@ -25,17 +43,17 @@ # TODO: MIDI learn import time - +import shutil from typing import Optional, Dict, Any from numbers import Number import math import functools as ft -import json from pathlib import Path from collections import defaultdict from datetime import datetime import numpy as np +import toml_file import torch torch.set_num_threads(1) @@ -48,7 +66,7 @@ from rich.pretty import Pretty from textual.reactive import reactive from textual.widgets import Header, Footer, Static, Button, Log, RichLog, Label -from textual.screen import Screen +from textual.screen import Screen, ModalScreen from textual.containers import Grid def main( @@ -89,34 +107,49 @@ def main( profiler=0, ): """ - This a terminal app for using Notochord interactively with MIDI controllers and synthesizers. Arguments to main can be given on the command line as flags, for example: + This a terminal app for using Notochord interactively with MIDI controllers and synthesizers. It allows combining both 'harmonizer' and 'improviser' features. + + Arguments to main can be given on the command line as flags, for example: + + `notochord homunculus --config "{1:{mode:auto, inst:1, mute:False}}" --initial-query --max-time 1` + + This says: play the grand piano autonomously on channel 1, start automatically, allow no more than 1 second between MIDI events. A different example: + + `notochord homunculus --config "{1:{mode:input, inst:1, mute:False}, 2:{mode:follow, inst:12, mute:False}}" --thru --send-pc` - `python -m notochord homunculus --initial-query --config '{1:{mode:auto, inst:1}}'` + This says, take grand piano input on channel 1, and harmonize it with vibraphone on channel 2. Pass input through to the output, and also send program change messages to set the instruments on the synthesizer. + + You may also need to use the --midi-in or --midi-out flags to get MIDI to the right place. - 16 voices correspond to the 16 MIDI channels. Each voice can be in one of three modes: + # MIDI Channels + + In homunculus, you have 16 MIDI channels. Each channel can be in one of three modes: + + * input (appearing like "-->01"), channel 1 comes from MIDI input channel 1. + * follow (appearing like "01->02"), channel 2 plays whenever channel 1 plays. + * auto (appearing like just "03"), channel 3 plays autonomously. - * input (appearing like "-->01"), voice 1 comes from MIDI input channel 1. - * follow (appearing like "01->02"), voice 2 plays whenever voice 1 plays. - * auto (appearing like just "03"), voice 3 plays autonomously. + Click the top section of each channel strip to cycle the mode. - Click the top section of each voice to cycle the mode. + Each channel is also assigned a [General MIDI instrument](https://en.wikipedia.org/wiki/General_MIDI#Program_change_events). Each 'input' and 'auto' channel should have a unique General MIDI instrument, but 'follow' channels can be duplicates of others. If you try to assign duplicate instruments, they will automatically change to "anonymous" melodic or drum instruments, but still send the right program change messages when using --send-pc. - Each voice is also assigned a [General MIDI instrument](https://en.wikipedia.org/wiki/General_MIDI#Program_change_events). Each 'input' and 'auto' voice should have a unique General MIDI instrument, but 'follow' voices can be duplicates of other voices. + Click the middle section of each channel strip to choose a new instrument. - Click the middle section of each voice to choose a new instrument. + The bottom section of each channel strip allows muting individual voices. - The bottom section of each voice allows muting individual voices. + # Global controls Along the bottom, there are global query, sustain, mute and reset buttons. Query manually replaces the next pending note. Sustain stops all auto voices from playing without ending any open notes. Mute ends all open notes and stops auto voices. Reset ends all open notes, forgets all context and sets the Notochord model to its initial state. Args: checkpoint: path to notochord model checkpoint. - config: mapping from MIDI channels to voice specs. + config: + mapping from MIDI channels to voice specs. MIDI channels and General MIDI instruments are indexed from 1. - see https://en.wikipedia.org/wiki/General_MIDI for instrument numbers. - There are 3 modes of voice: 'auto', 'follow' and 'input'. - Example: + see wikipedia.org/wiki/General_MIDI for values. + There are 3 modes of voice, 'auto', 'follow' and 'input'. For example, + ``` { 1:{ 'mode':'input', 'inst':1 @@ -135,52 +168,74 @@ def main( 'mode':'follow', 'source':3, 'inst':10, 'range':(72,96) }, # harmonize channel 3 within upper registers of the glockenspiel } + ``` Notes: - no 'input' or 'auto' channels should use the same instrument, - but 'follow' channels may have the same as an 'input' or 'auto' - preset: preset name (in preset file) to load config from - preset_file: path to JSON file containing dict of name:config (as above) - - initial_mute: start 'auto' voices muted so it won't play with input. - initial_query: query Notochord immediately, + when two channels use the same instrument, one will be converted + to an 'anonymous' instrument number (but it will still respect the + chosen instrument when using --send-pc) + + preset: + preset name (in preset file) to load config from + preset_file: + path to a TOML file containing channel presets + the default config file is `homunculus.toml`; + running `notochord files` will show its location. + + initial_mute: + start 'auto' voices muted so it won't play with input. + initial_query: + query Notochord immediately, so 'auto' voices begin playing without input. - midi_in: MIDI ports for input. + midi_in: + MIDI ports for input. default is to use all input ports. can be comma-separated list of ports. - midi_out: MIDI ports for output. + midi_out: + MIDI ports for output. default is to use only virtual 'From iipyper' port. can be comma-separated list of ports. - thru: if True, copy input MIDI to output ports. + thru: + if True, copy input MIDI to output ports. only makes sense if input and output ports are different. - send_pc: if True, send MIDI program change messages to set the General MIDI + send_pc: + if True, send MIDI program change messages to set the General MIDI instrument on each channel according to player_config and noto_config. useful when using a General MIDI synthesizer like fluidsynth. - dump_midi: if True, print all incoming MIDI for debugging purposes + dump_midi: + if True, print all incoming MIDI for debugging purposes - balance_sample choose 'auto' voices which have played less recently, + balance_sample: + choose 'auto' voices which have played less recently, ensures that all configured instruments will play. - n_recent: number of recent note-on events to consider for above - n_margin: amount of 'slack' in the balance_sample calculation - - max_note_len: time in seconds after which to force-release sustained - 'auto' notes. - max_time: maximum seconds between predicted events for 'auto' voices. + n_recent: + number of recent note-on events to consider for above + n_margin: + controls the amount of slack in the balance_sample calculation + + max_note_len: + time in seconds after which to force-release sustained 'auto' notes. + max_time: + maximum seconds between predicted events for 'auto' voices. default is the Notochord model's maximum (usually 10 seconds). - nominal_time: if True, feed Notochord with its own predicted times + nominal_time: + if True, feed Notochord with its own predicted times instead of the actual elapsed time. May make Notochord more likely to play chords. - osc_port: optional. if supplied, listen for OSC to set controls - osc_host: hostname or IP of OSC sender. + osc_port: + optional. if supplied, listen for OSC to set controls + osc_host: + hostname or IP of OSC sender. leave this as empty string to get all traffic on the port - use_tui: run textual UI. - predict_input: forecasted next events can be for 'input' voices. + use_tui: + run textual UI. + predict_input: + forecasted next events can be for 'input' voices. generally should be True for manual input; use balance_sample to force 'auto' voices to play. you might want it False if you have a very busy input. - debug_query=False, # don't query notochord when there is no pending event. """ if osc_port is not None: osc = OSC(osc_host, osc_port) @@ -226,33 +281,59 @@ def get_range(i): print = notochord.print = iipyper.print = tui.print ### + # make preset file if it doesn't exist + cfg_dir = Notochord.user_data_dir() + default_preset_file = cfg_dir / 'homunculus.toml' + src_preset_file = Path(__file__).parent / 'homunculus.toml' + if not default_preset_file.exists(): + shutil.copy(src_preset_file, default_preset_file) + ### presets and config try: if preset_file is None: - with open(Path(__file__).parent / 'preset.json') as f: - presets = json.load(f) + presets = toml_file.Config(str(default_preset_file))['preset'] else: - with open(preset_file) as f: - presets = json.load(f) + presets = toml_file.Config(str(preset_file))['preset'] except Exception: print('WARNING: failed to load presets file') presets = {} + # defaults + def default_config_channel(i): + return {'mode':'auto', 'inst':1, 'mute':True, 'mono':False, 'source':max(1,i-1), 'note_shift':0} + # convert MIDI channels to int - presets = {p:{int(k):v for k,v in d.items()} for p,d in presets.items()} - - if preset is None and len(presets): - preset = list(presets)[0] + # print(presets) + for p in presets: + p['channel'] = {int(k):{**default_config_channel(i), **v} for i,(k,v) in enumerate(p['channel'].items(),1)} + # presets = {p:{int(k):v for k,v in d.items()} for p,d in presets.items()} + + # TODO: config from CLI > config from preset file > channel defaults + # warn if preset is overridden by config + # TODO: what is sensible default behavior? + # for quickstart, it's good if it does something as soon as possible + # but for general use, it's good if it does nothing until you ask... + + config_file = {} if isinstance(preset, str): - config = presets[preset] - - # defaults - config_in = config - def default_config_channel(): - return {'mode':'auto', 'inst':1, 'mute':False, 'mono':False, 'source':1} - config = {i:default_config_channel() for i in range(1,17)} - for k,v in config_in.items(): + for p in presets: + if preset == p['name']: + config_file = p['channel'] + if config is not None: + print('WARNING: `--config` overrides `--preset`') + break + elif len(presets): + config_file = presets[0]['channel'] + # preset = list(presets)[0] + + config_cli = {} if config is None else config + + config = {i:default_config_channel(i) for i in range(1,17)} + for k,v in config_file.items(): + config[k].update(v) + for k,v in config_cli.items(): config[k].update(v) + def validate_config(): assert all( @@ -306,14 +387,12 @@ def channel_followers(chan): if v['mode']=='follow' and v.get('source', None)==chan] - if len(mode_insts('input') & mode_insts('auto')): - print("WARNING: auto and input instruments shouldn't overlap") - print('setting to an anonymous instrument') - # TODO: set to anon insts without changing mel/drum - # respecting anon insts selected for player - raise NotImplementedError - # TODO: - # check for repeated insts/channels + # if len(mode_insts('input') & mode_insts('auto')): + # print("WARNING: auto and input instruments shouldn't overlap") + # print('setting to an anonymous instrument') + # # TODO: set to anon insts without changing mel/drum + # # respecting anon insts selected for player + # raise NotImplementedError # load notochord model try: @@ -326,18 +405,20 @@ def channel_followers(chan): print("""error loading notochord model""") raise - def warn_inst(i): - if i > 128: - if i < 257: - print(f"WARNING: drum instrument {i} selected, be sure to select a drum bank in your synthesizer") - else: - print(f"WARNING: instrument {i} is not General MIDI") + # def warn_inst(i): + # if i > 128: + # if i < 257: + # print(f"WARNING: drum instrument {i} selected, be sure to select a drum bank in your synthesizer") + # else: + # print(f"WARNING: instrument {i} is not General MIDI") def dedup_inst(c, i): # change to anon if already in use def in_use(): return any( - c_other!=c and config[c_other]['inst']==i + c_other!=c + and config[c_other]['inst']==i + and config[c_other]['mode']!='follow' for c_other in config) if in_use(): i = noto.first_anon_like(i) @@ -346,7 +427,15 @@ def in_use(): return i def do_send_pc(c, i): - warn_inst(i) + # warn_inst(i) + # assuming fluidsynth -o synth.midi-bank-select=mma + if noto.is_drum(i): + midi.control_change(channel=c-1, control=0, value=1) + else: + midi.control_change(channel=c-1, control=32, value=0) + midi.control_change(channel=c-1, control=0, value=0) + if noto.is_anon(i): + i = 0 # convert to 0-index midi.program_change(channel=c-1, program=(i-1)%128) @@ -384,6 +473,21 @@ def display_event(tag, memo, inst, pitch, vel, channel, **kw): s += f' ({memo})' tui.defer(note=s) + def send_midi(note, velocity, channel): + kind = 'note_on' if velocity > 0 else 'note_off' + cfg = config[channel] + # get channel note map + if 'note_map' in cfg: + note_map = cfg['note_map'] + else: + note_map = {} + if note in note_map: + note = note_map[note] + elif 'note_shift' in cfg: + note = note + cfg['note_shift'] + + midi.send(kind, note=note, velocity=velocity, channel=channel-1) + def play_event( event, channel, parent=None, # parent note as (channel, inst, pitch) @@ -400,9 +504,10 @@ def play_event( # send out as MIDI if send: with profile('\tmidi.send', print=print, enable=profiler>1): - midi.send( - 'note_on' if vel > 0 else 'note_off', - note=event['pitch'], velocity=vel, channel=channel-1) + send_midi(event['pitch'], vel, channel) + # midi.send( + # 'note_on' if vel > 0 else 'note_off', + # note=event['pitch'], velocity=vel, channel=channel-1) # print with profile('\tdisplay_event', print=print, enable=profiler>1): @@ -846,7 +951,8 @@ def _(): for note in history.notes: # for (inst,pitch) in notes: if note.inst in mode_insts(('auto', 'follow')): - midi.note_on(note=note.pitch, velocity=0, channel=note.chan-1) + midi.note_off(note=note.pitch, velocity=0, channel=note.chan-1) + # midi.note_on(note=note.pitch, velocity=0, channel=note.chan-1) ### update_* keeps the UI in sync with the state @@ -856,19 +962,23 @@ def update_config(): tui.set_channel(c, v) def update_presets(): - for p,k in enumerate(presets): - tui.set_preset(p, k) + for k,p in enumerate(presets): + tui.set_preset(k, p.get('name')) @tui.on def mount(): update_config() update_presets() - print('welcome to notochord homunculus') print('MIDI handling:') print(midi.get_docs()) if osc_port is not None: print('OSC handling:') print(osc.get_docs()) + print(tui_doc) + print('For more detailed documentation, see:') + print('https://intelligent-instruments-lab.github.io/notochord/reference/notochord/app/homunculus/') + print('or run `notochord homunculus --help`') + print('to exit, use CTRL+C') ### set_* does whatever necessary to change channel properties ### calls update_config() to keep the UI in sync @@ -909,8 +1019,45 @@ def set_mode(c, m, update=True): if update: update_config() - class InstrumentSelect(Screen): - """Screen with an instrument select dialog.""" + class Instrument(Button): + """button which picks an instrument""" + def __init__(self, c, i): + super().__init__(inst_label(i)) + self.channel = c + self.inst = i + def on_button_pressed(self, event: Button.Pressed): + self.app.pop_screen() + self.app.pop_screen() + set_inst(self.channel, self.inst) + + class InstrumentGroup(Button): + """button which picks an instrument group""" + def __init__(self, text, c, g): + super().__init__(text) + self.channel = c + self.group = g + def on_button_pressed(self, event: Button.Pressed): + # show inst buttons + tui.push_screen(InstrumentSelect(self.channel, self.group)) + + class InstrumentSelect(ModalScreen): + """Screen with instruments""" + def __init__(self, c, g): + super().__init__() + self.channel = c + self.group = g + + def compose(self): + yield Grid( + *( + Instrument(self.channel, i) + for i in gm_groups[self.group][1] + ), id="dialog", + ) + + class InstrumentGroupSelect(ModalScreen): + """Screen with instrument groups""" + # TODO: add other features to this screen -- transpose, range, etc? def __init__(self, c): super().__init__() self.channel = c @@ -918,17 +1065,10 @@ def __init__(self, c): def compose(self): yield Grid( *( - Button(s, id='select_'+inst_id(i)) - for i,s in enumerate(gm_names, 1) + InstrumentGroup(s, self.channel, g) + for g,(s,_) in enumerate(gm_groups) ), id="dialog", ) - def on_button_pressed(self, event: Button.Pressed) -> None: - # print(event.button.id) - # i = 1 - i = int(event.button.id.split('_')[-1]) - self.app.pop_screen() - set_inst(self.channel, i) - # TODO: on key pressed esc, q: cancel def set_inst(c, i, update=True): print(f'SET INSTRUMENT {i}') @@ -995,39 +1135,34 @@ def action_mode(c): def action_inst(c): print(f'inst channel {c}') - # TODO: instrument picker - tui.push_screen(InstrumentSelect(c)) - # inst_select.channel = c - # tui.push_screen(inst_select) - # i = 1 - # set_inst(c, i) + tui.push_screen(InstrumentGroupSelect(c)) def action_mute(c): if i not in config: return set_mute(c, not config[c].get('mute', False)) def action_preset(p): - ks = list(presets.keys()) - if p >= len(ks): + if p >= len(presets): return - k = ks[p] - preset = presets[k] - print(f'load preset: {k}') + preset = presets[p]['channel'] + print(f'load preset: {p}') for c in range(1,17): if c not in config: - config[c] = default_config_channel() + config[c] = default_config_channel(c) if c not in preset: set_mute(c, True, update=False) else: - v = preset[c] - set_mode(c, v.get('mode', 'auto'), update=False) - set_inst(c, v.get('inst', 1), update=False) - set_mute(c, v.get('mute', False), update=False) - # ugly, this sets config repeatedly... + # note config should *not* be updated before calling set_* + v = {**preset[c]} + # v = {**config[c]} + # v.update(preset[c]) + set_mode(c, v.pop('mode'), update=False) + set_inst(c, v.pop('inst'), update=False) + set_mute(c, v.pop('mute'), update=False) config[c].update(v) update_config() - ### set actions which have an with index argument + ### set actions which have an index argument ### TODO move this logic into @tui.set_action for i in range(1,17): @@ -1078,11 +1213,6 @@ def watch_value(self, time: float) -> None: s = f"\tinstrument: {evt['inst']:3d} pitch: {evt['pitch']:3d} time: {int(evt['time']*1000):4d} ms velocity:{int(evt['vel']):3d}" self.update(Panel(s, title='prediction')) -# class NotoToggle(Static): -# def compose(self): -# yield Button("Mute", id="mute", variant="error") -# yield Switch() - class Mixer(Static): def compose(self): for i in range(1,17): @@ -1165,6 +1295,8 @@ class NotoTUI(TUI): ("s", "sustain", "Sustain"), ("q", "query", "Re-query Notochord"), ("r", "reset", "Reset Notochord")] + + def compose(self): """Create child widgets for the app.""" @@ -1179,10 +1311,12 @@ def compose(self): def on_mount(self) -> None: self.query_one(NotoPrediction).tooltip = "displays the next predicted event" + print(self.screen) def set_preset(self, idx, name): node = self.query_one('#'+preset_id(idx)) - node.label = name + node.label = str(idx) if name is None else name + # node.label = name def set_channel(self, chan, cfg): # print(f'set_channel {cfg}') @@ -1221,6 +1355,7 @@ def set_channel(self, chan, cfg): mute_node.variant = 'default' gm_names = [ + '_SEQ_\nSTART', 'GRAND\nPIANO', 'BRGHT\nPIANO', 'EGRND\nPIANO', 'HONKY\n-TONK', 'RHODE\nPIANO', 'FM \nPIANO', 'HRPSI\nCHORD', 'CLAV \n INET', 'CEL \n ESTA', 'GLOCN\nSPIEL', 'MUSIC\n BOX ', 'VIBRA\nPHONE', @@ -1253,12 +1388,50 @@ def set_channel(self, chan, cfg): 'TAIKO\nDRUM ', 'MELO \n TOM', 'SYNTH\nDRUM ', ' REV \nCYMBL', 'GTR \n FRET', 'BRE \n ATH', ' SEA \nSHORE', 'BIRD \nTWEET', 'TELE \nPHONE', 'HELI \nCOPTR', 'APP \nLAUSE', 'GUN \n SHOT', - ' STD \nDRUMS' -] + ['DRUM \n KIT']*127 + ['ANON \n MEL ']*32 + ['ANON \nDRUMS']*32 +] + ( + ['STD \n KIT']*8 + + ['ROOM \n KIT']*8 + + ['ROCK \n KIT']*8 + + ['ELCTR\n KIT']*8 + + ['JAZZ \n KIT']*8 + + ['BRUSH\n KIT']*8 + + ['ORCHS\n KIT']*8 + + ['SFX \n KIT']*8 + + ['DRUM \n KIT?']*64 +) + ['ANON \n MEL ']*32 + ['ANON \nDRUMS']*32 +gm_groups = [ + ('PIANO\n ', range(1,9)), + ('CHROM\nPERC ', range(9,17)), + ('ORGAN\n ', range(17,25)), + ('GUI \n TARS', range(25,33)), + ('BASS \n GTRS', range(33,41)), + ('STRIN\n GS', range(41,49)), + ('ENSEM\n BLES', range(49,57)), + ('BRASS\n ', range(57,65)), + ('REEDS\n ', range(65,73)), + ('PIPES\n ', range(73,81)), + ('SYNTH\nLEADS', range(81,89)), + ('SYNTH\nPADS ', range(89,97)), + ('SYNTH\nFX ', range(97,105)), + ('MISC \n MEL', range(105,113)), + ('MISC \n PERC', range(113,121)), + ('SOUND\nFX ', range(121,129)), + ('DRUM \n KITS', [128+i for i in (1,9,17,25,33,41,49,57)]), + # (' STD \nDRUMS', range(129,137)), + # ('ROOM \nDRUMS', range(137,145)), + # ('ROCK \nDRUMS', range(145,153)), + # ('ELCTR\nDRUMS', range(153,161)), + # ('JAZZ \nDRUMS', range(161,169)), + # ('BRUSH\nDRUMS', range(169,177)), + # ('ORCH \nDRUMS', range(177,185)), + # (' SFX \nDRUMS', range(185,193)), + ('ANON\n MEL', range(257,273)), + ('ANON\n DRUM', range(289,305)), +] def inst_label(i): if i is None: return f"--- \n-----\n-----" - return f'{i:03d} \n{gm_names[i-1]}' + return f'{i:03d} \n{gm_names[i]}' ### end def TUI components### diff --git a/src/notochord/app/homunculus.toml b/src/notochord/app/homunculus.toml new file mode 100644 index 0000000..11d4a59 --- /dev/null +++ b/src/notochord/app/homunculus.toml @@ -0,0 +1,90 @@ +[[preset]] +name = "ens1" +channel.1 = {mode="auto", inst=29, mute=false} +channel.2 = {mode="follow", inst=7, source=1, transpose=[3,15]} +channel.3 = {mode="follow", inst=12, source=2, transpose=[12,24]} +channel.4 = {mode="follow", inst=13, source=3, transpose=[-15,-3]} +channel.5 = {mode="follow", inst=33, source=4, transpose=[-36,-12]} +channel.6 = {mode="follow", inst=10, source=5} +channel.7 = {mode="follow", inst=60, source=6, transpose=[12,36]} +channel.8 = {mode="follow", inst=74, source=7, transpose=[24,36]} +channel.9 = {mode="follow", inst=109, source=8, transpose=[3,7]} +channel.10 = {mode="follow", inst=129, source=9} +channel.11 = {mode="follow", inst=122, source=10} +channel.12 = {mode="follow", inst=48, source=11} +channel.13 = {mode="follow", inst=78, source=12, transpose=[7,12]} +channel.14 = {mode="follow", inst=93, source=13, transpose=[12,24]} +channel.15 = {mode="follow", inst=21, source=14, transpose=[0,4]} +channel.16 = {mode="follow", inst=16, source=15} + +[[preset]] +name = "strings" +channel.1 = {mode="input", inst=7, mute=false} +channel.2 = {mode="auto", inst=41, range=[55,103]} +channel.3 = {mode="auto", inst=42, range=[48,91]} +channel.4 = {mode="auto", inst=43, range=[36,76]} +channel.5 = {mode="auto", inst=44, range=[28,67]} + +[[preset]] +name = "brass" +channel.1 = {mode="input", inst=5, mute=false} +channel.2 = {mode="auto", inst=57, range=[55,82]} +channel.3 = {mode="auto", inst=58, range=[40,72]} +channel.4 = {mode="auto", inst=59, range=[28,58]} +channel.5 = {mode="auto", inst=61, range=[34,77]} + +[[preset]] +name = "fx" +channel.1 = {mode="input", inst=122, mute=false} +channel.2 = {mode="auto", inst=114} +channel.3 = {mode="auto", inst=119} +channel.4 = {mode="auto", inst=100} +channel.5 = {mode="auto", inst=56} +channel.6 = {mode="auto", inst=102} +channel.7 = {mode="auto", inst=103} +channel.8 = {mode="auto", inst=104} +channel.9 = {mode="auto", inst=121} +channel.10 = {mode="auto", inst=97} +channel.11 = {mode="auto", inst=123} +channel.12 = {mode="auto", inst=124} +channel.13 = {mode="auto", inst=125} +channel.14 = {mode="auto", inst=126} +channel.15 = {mode="auto", inst=127} +channel.16 = {mode="auto", inst=128} + +[[preset]] +name = "drums" +channel.1 = {mode="auto", inst=129, mute=false} +channel.2 = {mode="auto", inst=137} +channel.3 = {mode="auto", inst=145} +channel.4 = {mode="auto", inst=153} +channel.5 = {mode="auto", inst=161} +channel.6 = {mode="auto", inst=169} +channel.7 = {mode="auto", inst=177} +channel.8 = {mode="auto", inst=185} +channel.9 = {mode="auto", inst=113} +channel.10 = {mode="auto", inst=114} +channel.11 = {mode="auto", inst=116} +channel.12 = {mode="auto", inst=117} +channel.13 = {mode="auto", inst=118} +channel.14 = {mode="auto", inst=119} +channel.15 = {mode="auto", inst=120} +channel.16 = {mode="auto", inst=48} + + +# "rock": { +# "1":{"mode":"input", "inst":29}, +# "3":{"mode":"auto", "inst":30, "mute":true}, +# "4":{"mode":"auto", "inst":35}, +# "5":{"mode":"auto", "inst":28, "mute":true}, +# "6":{"mode":"auto", "inst":31, "mute":true}, +# "7":{"mode":"auto", "inst":32, "mute":true}, +# "10":{"mode":"auto", "inst":129} +# }, +# "synths": { +# "1":{"mode":"input", "inst":85}, +# "2":{"mode":"auto", "inst":84}, +# "3":{"mode":"auto", "inst":91}, +# "4":{"mode":"auto", "inst":119}, +# "5":{"mode":"auto", "inst":99} +# }, \ No newline at end of file diff --git a/src/notochord/app/preset.json b/src/notochord/app/preset.json deleted file mode 100644 index a07e1cf..0000000 --- a/src/notochord/app/preset.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "ens1":{ - "1":{"mode":"auto", "inst":29}, - "2":{"mode":"follow", "inst":7, "source":1, - "transpose":[3,15], "mute":true}, - "3":{"mode":"follow", "inst":12, "source":2, - "transpose":[12,24], "mute":true}, - "4":{"mode":"follow", "inst":13, "source":3, - "transpose":[-15,-3], "mute":true}, - "5":{"mode":"follow", "inst":33, "source":4, - "transpose":[-36,-12], "mute":true}, - "6":{"mode":"follow", "inst":10, "source":5, - "range":[72,96], "mute":true}, - "7":{"mode":"follow", "inst":60, "source":6, - "range":[12,36], "mute":true}, - "8":{"mode":"follow", "inst":74, "source":7, - "transpose":[24,36], "mute":true}, - "9":{"mode":"follow", "inst":109, "source":8, - "transpose":[3,7], "mute":true}, - "10":{"mode":"follow", "inst":129, "source":9, - "mute":true}, - "11":{"mode":"follow", "inst":122, "source":10, - "range":[60,72], "mute":true}, - "12":{"mode":"follow", "inst":48, "source":11, - "range":[0,81], "mute":true}, - "13":{"mode":"follow", "inst":78, "source":12, - "transpose":[7,12], "mute":true}, - "14":{"mode":"follow", "inst":93, "source":13, - "transpose":[12,24], "mute":true}, - "15":{"mode":"follow", "inst":21, "source":14, - "transpose":[0,4], "mute":true}, - "16":{"mode":"follow", "inst":16, "source":15, - "range":[84,108], "mute":true} - }, - "ens2":{ - "1":{"mode":"input", "inst":25}, - "2":{"mode":"follow", "inst":7, "source":1, - "transpose":[3,15], "mute":true}, - "3":{"mode":"follow", "inst":12, "source":1, - "transpose":[12,24], "mute":true}, - "4":{"mode":"follow", "inst":13, "source":1, - "transpose":[-15,-3], "mute":true}, - "5":{"mode":"follow", "inst":33, "source":1, - "transpose":[-36,-12], "mute":true}, - "6":{"mode":"follow", "inst":10, "source":1, - "range":[72,96], "mute":true}, - "7":{"mode":"follow", "inst":60, "source":1, - "range":[12,36], "mute":true}, - "8":{"mode":"follow", "inst":74, "source":1, - "transpose":[24,36], "mute":true}, - "9":{"mode":"follow", "inst":109, "source":1, - "transpose":[3,7], "mute":true}, - "11":{"mode":"follow", "inst":14, "source":1, - "mute":true}, - "12":{"mode":"follow", "inst":48, "source":1, - "range":[0,81], "mute":true}, - "13":{"mode":"follow", "inst":78, "source":1, - "transpose":[7,12], "mute":true}, - "14":{"mode":"follow", "inst":93, "source":1, - "transpose":[12,24], "mute":true}, - "15":{"mode":"follow", "inst":21, "source":1, - "transpose":[0,4], "mute":true}, - "16":{"mode":"follow", "inst":16, "source":1, - "range":[84,108], "mute":true} - }, - "strings": { - "1":{"mode":"input", "inst":7}, - "2":{"mode":"auto", "inst":41, "range":[55,103]}, - "3":{"mode":"auto", "inst":42, "range":[48,91]}, - "4":{"mode":"auto", "inst":43, "range":[36,76]}, - "5":{"mode":"auto", "inst":44, "range":[28,67]} - }, - "brass": { - "1":{"mode":"input", "inst":5}, - "2":{"mode":"auto", "inst":57, "range":[55,82]}, - "3":{"mode":"auto", "inst":58, "range":[40,72]}, - "4":{"mode":"auto", "inst":59, "range":[28,58]}, - "5":{"mode":"auto", "inst":61, "range":[34,77]} - }, - "rock": { - "1":{"mode":"input", "inst":29}, - "3":{"mode":"auto", "inst":30, "mute":true}, - "4":{"mode":"auto", "inst":35}, - "5":{"mode":"auto", "inst":28, "mute":true}, - "6":{"mode":"auto", "inst":31, "mute":true}, - "7":{"mode":"auto", "inst":32, "mute":true}, - "10":{"mode":"auto", "inst":129} - }, - "synths": { - "1":{"mode":"input", "inst":85}, - "2":{"mode":"auto", "inst":84}, - "3":{"mode":"auto", "inst":91}, - "4":{"mode":"auto", "inst":119}, - "5":{"mode":"auto", "inst":99} - }, - "fx": { - "1":{"mode":"input", "inst":122}, - "2":{"mode":"auto", "inst":114}, - "3":{"mode":"auto", "inst":119}, - "4":{"mode":"auto", "inst":100}, - "5":{"mode":"auto", "inst":56}, - "6":{"mode":"auto", "inst":102}, - "7":{"mode":"auto", "inst":103}, - "8":{"mode":"auto", "inst":104}, - "9":{"mode":"auto", "inst":121}, - "10":{"mode":"auto", "inst":97}, - "11":{"mode":"auto", "inst":123}, - "12":{"mode":"auto", "inst":124}, - "13":{"mode":"auto", "inst":125}, - "14":{"mode":"auto", "inst":126}, - "15":{"mode":"auto", "inst":127}, - "16":{"mode":"auto", "inst":128} - } -} \ No newline at end of file diff --git a/src/notochord/model.py b/src/notochord/model.py index 9ad8148..483d601 100644 --- a/src/notochord/model.py +++ b/src/notochord/model.py @@ -322,15 +322,21 @@ def forward(self, instruments, pitches, times, velocities, ends, return r + # 0 - start token + # 1-128 - melodic + # 129-256 - drums + # 257-288 - anon melodic + # 289-320 - anon drums def is_drum(self, inst): # TODO: add a constructor argument to specify which are drums # hardcoded for now return inst > 128 and inst < 257 or inst > 288 + def is_anon(self, inst): + return inst > 256 def first_anon_like(self, inst): # TODO: add a constructor argument to specify how many anon # hardcoded for now - return 288 if self.is_drum(inst) else 257 - + return 289 if self.is_drum(inst) else 257 def feed(self, inst, pitch, time, vel, **kw): """consume an event and advance hidden state @@ -1184,6 +1190,13 @@ def reset(self, start=True): # self.feed( # self.instrument_start_token, self.pitch_start_token, 0., 0.) + @classmethod + def user_data_dir(cls): + import appdirs + d = Path(appdirs.user_data_dir('Notochord', 'IIL')) + d.mkdir(exist_ok=True, parents=True) + return d + @classmethod def from_checkpoint(cls, path): """ @@ -1194,9 +1207,7 @@ def from_checkpoint(cls, path): path: file path to Notochord model """ if path=="notochord-latest.ckpt": - import appdirs - d = Path(appdirs.user_data_dir('Notochord', 'IIL')) - d.mkdir(exist_ok=True, parents=True) + d = Notochord.user_data_dir() path = d / path # maybe download if not path.is_file():