Advertisement
ToddHartmann

Darkstar

Oct 27th, 2016
495
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 10.56 KB | None | 0 0
  1. # -*- coding: utf-8 -*-
  2. """
  3. Darkstar
  4.  
  5. "Talk to the amp. You have to talk to it, Doolittle. Teach it PHENOMENOLOGY."
  6.  
  7. Control a Blackstar ID guitar amplifier with MIDI Program Change and
  8. Control Change messages.
  9.  
  10. Author: Todd Hartmann
  11. License:  Public Domain, Use At Your Own Risk
  12. Version: 1
  13.  
  14. Darkstar is a command line app and only requires the excellent
  15. Outsider and awesome rtMidi-Python
  16.  
  17. https://github.com/jonathanunderwood/outsider
  18. https://github.com/superquadratic/rtmidi-python
  19.  
  20. Outsider needs PyQt5 for its UI and PyUSB to talk to the amp
  21.  
  22. https://wiki.python.org/moin/PyQt
  23. https://pypi.python.org/pypi/pyusb/1.0.0
  24.  
  25. Outsider can run on Windows if you make some changes.
  26.  
  27. First, for some reason, Windows reports one extra byte transmitted.  So
  28. change in blackstarid.py, class BlackstarIDAmp, member _send_data() ~line 463,
  29.  
  30.        bytes_written = self.device.write(self.interrupt_out, data)
  31. to
  32.        bytes_written = self.device.write(self.interrupt_out, data) - 1 # HEY WINDOWS SAYS ONE MORE
  33.  
  34. Then you've got to keep it from doing the kernel driver deactivation
  35. loop in blackstarid.py, class BlackstarIDAmp member connect(),
  36. ~line 370, change
  37.  
  38.        for intf in cfg:
  39. to
  40.        for intf in []: #cfg:  HEY WINDOWS DON'T DO THIS KERNEL VOODOO
  41.  
  42. and do the same sorta thing in disconnect(), ~line 429, change
  43.  
  44.        cfg = self.device.get_active_configuration()
  45. to
  46.        cfg = [] #self.device.get_active_configuration() HEY WINDOWS NO KERNEL VOODOO
  47.  
  48. These are bad solutions but they work with a minimum of changing.
  49. """
  50.  
  51. import blackstarid
  52. import rtmidi_python as rtmidi
  53.  
  54. import argparse, csv, textwrap
  55. from functools import partial
  56.  
  57. def midiports(midi_in):
  58.     """return a list of strings of Midi port names"""
  59.     # because midi_in.ports elements end with annoying space and bus number
  60.     return [ v[0 : v.rfind(b' ')].decode('UTF-8') for v in midi_in.ports ]
  61. #
  62. def cctocontrol(ccval, name):
  63.     """scale CC value to named control's range"""
  64.     fcc = float(ccval) / 127.0
  65.     lo, hi = blackstarid.BlackstarIDAmp.control_limits[name]
  66.     answer = fcc * float(hi - lo) + lo
  67.     return round(answer)
  68.  
  69. # map from Midi CC number to a human-friendly mixed-case version of
  70. # blackstarid.controls.keys() (becomes the key when .lower()ed)
  71. # it's okay to map more than one CC to a given control
  72.  
  73. controlMap = dict( [
  74.     (7, 'Volume'),    
  75.     (22, 'Volume'), (23, 'Bass'), (24, 'Middle'), (25, 'Treble'),
  76.     (26, 'Mod_Switch'), (27, 'Delay_Switch'), (28, 'Reverb_Switch'),
  77.     (14, 'Voice'), (15, 'Gain'), (16, 'ISF')
  78. ] )
  79.  
  80. def readmap(filename):
  81.     """reads a CSV file of number,name pairs into the controlMap"""
  82.     global controlMap
  83.     try:
  84.         with open(filename, 'r') as cmf:
  85.             cm = dict( [ [ int(row[0]), row[1] ] for row in csv.reader(cmf) ] )
  86.             for k in cm.keys():
  87.                 if k < 0 or k > 127:
  88.                     raise ValueError('Invalid MIDI CC number {}'.format(k))
  89.                 if cm[k].lower() not in blackstarid.BlackstarIDAmp.controls.keys():
  90.                     raise ValueError('Invalid control name "{}"'.format(cm[k]))
  91.             # everything is valid
  92.             controlMap = cm
  93.     except Exception as e:
  94.         print(e)
  95.         print('Problem with --map {}, using default mapping'.format(filename))
  96.  
  97. def midicallback( message, delta_time, amp, chan, quiet ):
  98.     """respond (or not) to midi message"""
  99.     mchan = (message[0] & 0x0F) + 1;    # low nybble is channel-1
  100.     if mchan == chan or chan == 0:
  101.         kind  = message[0] & 0xF0;              # high nybble of Status is type
  102.         if kind == 0xC0:                        # 0xC0 is Program Change
  103.             preset = message[1] + 1         # presets are 1-128
  104.             if not quiet:
  105.                 print('Preset Change to {:3} on channel {:2} at time {:.3}'.format( preset, mchan, delta_time ) )
  106.             amp.select_preset(preset)
  107.         elif kind == 0xB0:                      # 0xB0 is Control Change
  108.             ccnum = message[1]
  109.             ccval = message[2]
  110.             if ccnum in controlMap.keys():
  111.                 name = controlMap[ccnum]
  112.                 val = cctocontrol(ccval, name.lower())
  113.                 if not quiet:
  114.                     print('{} Change to {:3} on channel {:2} at time {:.3}'.format( name, val, mchan, delta_time ))
  115.                 amp.set_control(name.lower(), val)
  116.            
  117. def midiloop(midi_in, bnum):
  118.     """open midi, loop until ctrl-c etc. pressed, close midi."""
  119.     midi_in.open_port(bnum)
  120.  
  121.     print('Press ctrl-C to exit')
  122.     try:
  123.         while True:
  124.             pass
  125.     except KeyboardInterrupt:
  126.         pass
  127.    
  128.     print("Quitting")
  129.     midi_in.close_port()
  130.  
  131. def buscheck(sname, midi_in):
  132.     """argparse checker meant to be used in a partial that supplies midi_in"""
  133.     try:
  134.         busnum = int(sname) # see if it's a number instead of a name
  135.     except ValueError:      # okay it's a name try to find its number
  136.         try:
  137.             busnum = midiports(midi_in).index(sname)
  138.         except ValueError:
  139.             raise argparse.ArgumentTypeError('Midi bus "{}" not found'.format(sname))
  140.  
  141.     if busnum not in range(0, len(midi_in.ports)):
  142.         raise argparse.ArgumentTypeError('Midi bus {} not found'.format(busnum))
  143.  
  144.     return busnum
  145.  
  146. def intrangecheck(sval, ranje):
  147.     """argparse check that argument is an integer within a range"""
  148.     try:
  149.         ival = int(sval)
  150.     except ValueError:
  151.         raise argparse.ArgumentTypeError('Invalid value {} should be an integer'.format(sval))
  152.        
  153.     if ival not in ranje:
  154.         msg = 'Invalid value {} not in range {}-{}'.format(ival, ranje.start, ranje.stop - 1)
  155.         raise argparse.ArgumentTypeError(msg)
  156.     return ival
  157.  
  158. def presetcheck(sval):  return intrangecheck(sval, range(1, 129))
  159. def volumecheck(sval):  return intrangecheck(sval, range(0, 128))
  160. def channelcheck(sval): return intrangecheck(sval, range(0, 17))
  161.  
  162. class controlchecker:
  163.     """first check if the control name is valid, then check if value is good for that control"""
  164.     def __init__(self):
  165.         self.name = None
  166.  
  167.     def __call__(self, scon):
  168.         if(self.name == None):  # first execution is control name
  169.             if scon in blackstarid.BlackstarIDAmp.controls.keys():
  170.                 self.name = scon
  171.             else:
  172.                 raise argparse.ArgumentTypeError('Invalid control name "{}"'.format(scon))
  173.             return scon
  174.         else:
  175.             lo, hi = blackstarid.BlackstarIDAmp.control_limits[self.name]
  176.             return intrangecheck(scon, range(lo, hi + 1) )
  177.  
  178. controlcheck = controlchecker()
  179.  
  180. def fillit(s): return textwrap.fill(' '.join(s.split()))
  181.  
  182. def main():
  183.     midi_in = rtmidi.MidiIn()
  184.     midibus = partial(buscheck, midi_in=midi_in)
  185.  
  186.     parser = argparse.ArgumentParser(
  187.         description =   fillit(""" Control a Blackstar ID guitar
  188.                                   amplifier with MIDI Program Change
  189.                                   and Control Change messages."""),
  190.         epilog = '\n\n'.join( [fillit(s) for s in [
  191.             """Darkstar probably can't keep up with an LFO signal from
  192.               your DAW. It's for setting a value every now-and-then,
  193.               not continuously.  Latency appears to be ~40ms YLMV.""",
  194.             """--preset, --volume, and --control are conveniences to quickly
  195.               set a control and exit. They can be used together.""",
  196.             """--listbus, --listmap, and --listcontrols provide useful
  197.               information and exit. They can be used together."""]] ),
  198.         formatter_class=argparse.RawDescriptionHelpFormatter
  199.     )
  200.    
  201.     parser.add_argument('--bus', type=midibus, default='blackstar', help='number or exact name including spaces of MIDI bus to listen on, default="blackstar"')
  202.     parser.add_argument('--channel', type=channelcheck, default=0, help='MIDI channel 1-16 to listen on, 0=all, default=all')
  203.     parser.add_argument('--map', type=str, metavar='FILENAME', help='name of file of (cc number, control name) pairs.')
  204.     parser.add_argument('--quiet', action='store_true', help='suppress operational messages')
  205.     parser.add_argument('--preset', type=presetcheck, help='send a preset select 1-128 and exit')
  206.     parser.add_argument('--volume', type=volumecheck, help="set the amp's volume and exit")
  207.     parser.add_argument('--control', type=controlcheck, nargs=2, metavar=('NAME', 'VALUE'), help='set the named control to the value and exit')
  208.     parser.add_argument('--listbus', action='store_true', help='list Midi input busses and exit')
  209.     parser.add_argument('--listmap', action='store_true', help='list the default control mapping and exit')
  210.     parser.add_argument('--listcontrols', action='store_true', help='list Blackstar controls and exit')
  211.     args = parser.parse_args()
  212.  
  213.     if any([ args.listbus, args.listmap, args.listcontrols ]):
  214.         if args.listbus:
  215.             print('\n'.join([ '{} "{}"'.format(e[0], e[1]) for e in enumerate(midiports(midi_in)) ]))
  216.         if args.listmap:
  217.             for k in sorted(controlMap.keys()):
  218.                 print('{:3} -> {}'.format(k, controlMap[k]))
  219.         if args.listcontrols:
  220.             s = ', '.join( sorted([k for k in blackstarid.BlackstarIDAmp.controls.keys()]) )
  221.             print(textwrap.fill(s))
  222.     else:
  223.         amp = blackstarid.BlackstarIDAmp()
  224.         amp.connect()
  225.         print('Connected to {}'.format(amp.model))
  226.  
  227.         if args.preset != None or args.volume != None or args.control != None:
  228.             if args.preset != None:
  229.                 print('Requesting preset {}'.format(args.preset))
  230.                 amp.select_preset(args.preset)
  231.             if args.volume != None:
  232.                 print('Setting volume {}'.format(args.volume))
  233.                 amp.set_control('volume', args.volume)
  234.             if args.control != None:
  235.                 print('Setting control {} to {}'.format(args.control[0], args.control[1]))
  236.                 amp.set_control(args.control[0], args.control[1])
  237.         else:
  238.             if args.map != None:
  239.                 readmap(args.map)
  240.  
  241.             midi_in.callback = partial(midicallback, amp=amp, chan=args.channel, quiet=args.quiet)
  242.            
  243.             busstr = midiports(midi_in)[args.bus]
  244.             chanstr = 'MIDI channel {}'.format(args.channel)
  245.             if args.channel == 0:
  246.                 chanstr = 'all MIDI channels'
  247.             print('Listening to {} on bus "{}"'.format(chanstr, busstr))
  248.            
  249.             midiloop(midi_in, args.bus)   # exit main loop with KeyboardInterrupt
  250.  
  251.         amp.disconnect()
  252. #
  253. if __name__ == '__main__':
  254.     main()
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement