Skip to content

Instantly share code, notes, and snippets.

@rccursach
Last active June 12, 2021 08:44
Show Gist options
  • Save rccursach/66bc4a052f154a84b72f803425faf257 to your computer and use it in GitHub Desktop.
Save rccursach/66bc4a052f154a84b72f803425faf257 to your computer and use it in GitHub Desktop.
Script to control blackstar ID Core/Series? Amps - Todd Hartmann - https://forum.cockos.com/showthread.php?t=183176 > https://pastebin.com/NPviGAdn
# -*- coding: utf-8 -*-
"""
Darkstar
"Talk to the amp. You have to talk to it, Doolittle. Teach it PHENOMENOLOGY."
Control a Blackstar ID guitar amplifier with MIDI Program Change and
Control Change messages.
Author: Todd Hartmann
License: Public Domain, Use At Your Own Risk
Version: 1
Darkstar is a command line app and only requires the excellent
Outsider and awesome rtMidi-Python
https://github.com/jonathanunderwood/outsider
https://github.com/superquadratic/rtmidi-python
Outsider needs PyQt5 for its UI and PyUSB to talk to the amp
https://wiki.python.org/moin/PyQt
https://pypi.python.org/pypi/pyusb/1.0.0
Outsider can run on Windows if you make some changes.
First, for some reason, Windows reports one extra byte transmitted. So
change in blackstarid.py, class BlackstarIDAmp, member _send_data() ~line 463,
bytes_written = self.device.write(self.interrupt_out, data)
to
bytes_written = self.device.write(self.interrupt_out, data) - 1 # HEY WINDOWS SAYS ONE MORE
Then you've got to keep it from doing the kernel driver deactivation
loop in blackstarid.py, class BlackstarIDAmp member connect(),
~line 370, change
for intf in cfg:
to
for intf in []: #cfg: HEY WINDOWS DON'T DO THIS KERNEL VOODOO
and do the same sorta thing in disconnect(), ~line 429, change
cfg = self.device.get_active_configuration()
to
cfg = [] #self.device.get_active_configuration() HEY WINDOWS NO KERNEL VOODOO
These are bad solutions but they work with a minimum of changing.
"""
import blackstarid
import rtmidi_python as rtmidi
import argparse, csv, textwrap
from functools import partial
def midiports(midi_in):
"""return a list of strings of Midi port names"""
# because midi_in.ports elements end with annoying space and bus number
return [ v[0 : v.rfind(b' ')].decode('UTF-8') for v in midi_in.ports ]
#
def cctocontrol(ccval, name):
"""scale CC value to named control's range"""
fcc = float(ccval) / 127.0
lo, hi = blackstarid.BlackstarIDAmp.control_limits[name]
answer = fcc * float(hi - lo) + lo
return round(answer)
# map from Midi CC number to a human-friendly mixed-case version of
# blackstarid.controls.keys() (becomes the key when .lower()ed)
# it's okay to map more than one CC to a given control
controlMap = dict( [
(7, 'Volume'),
(22, 'Volume'), (23, 'Bass'), (24, 'Middle'), (25, 'Treble'),
(26, 'Mod_Switch'), (27, 'Delay_Switch'), (28, 'Reverb_Switch'),
(14, 'Voice'), (15, 'Gain'), (16, 'ISF')
] )
def readmap(filename):
"""reads a CSV file of number,name pairs into the controlMap"""
global controlMap
try:
with open(filename, 'r') as cmf:
cm = dict( [ [ int(row[0]), row[1] ] for row in csv.reader(cmf) ] )
for k in cm.keys():
if k < 0 or k > 127:
raise ValueError('Invalid MIDI CC number {}'.format(k))
if cm[k].lower() not in blackstarid.BlackstarIDAmp.controls.keys():
raise ValueError('Invalid control name "{}"'.format(cm[k]))
# everything is valid
controlMap = cm
except Exception as e:
print(e)
print('Problem with --map {}, using default mapping'.format(filename))
def midicallback( message, delta_time, amp, chan, quiet ):
"""respond (or not) to midi message"""
mchan = (message[0] & 0x0F) + 1; # low nybble is channel-1
if mchan == chan or chan == 0:
kind = message[0] & 0xF0; # high nybble of Status is type
if kind == 0xC0: # 0xC0 is Program Change
preset = message[1] + 1 # presets are 1-128
if not quiet:
print('Preset Change to {:3} on channel {:2} at time {:.3}'.format( preset, mchan, delta_time ) )
amp.select_preset(preset)
elif kind == 0xB0: # 0xB0 is Control Change
ccnum = message[1]
ccval = message[2]
if ccnum in controlMap.keys():
name = controlMap[ccnum]
val = cctocontrol(ccval, name.lower())
if not quiet:
print('{} Change to {:3} on channel {:2} at time {:.3}'.format( name, val, mchan, delta_time ))
amp.set_control(name.lower(), val)
def midiloop(midi_in, bnum):
"""open midi, loop until ctrl-c etc. pressed, close midi."""
midi_in.open_port(bnum)
print('Press ctrl-C to exit')
try:
while True:
pass
except KeyboardInterrupt:
pass
print("Quitting")
midi_in.close_port()
def buscheck(sname, midi_in):
"""argparse checker meant to be used in a partial that supplies midi_in"""
try:
busnum = int(sname) # see if it's a number instead of a name
except ValueError: # okay it's a name try to find its number
try:
busnum = midiports(midi_in).index(sname)
except ValueError:
raise argparse.ArgumentTypeError('Midi bus "{}" not found'.format(sname))
if busnum not in range(0, len(midi_in.ports)):
raise argparse.ArgumentTypeError('Midi bus {} not found'.format(busnum))
return busnum
def intrangecheck(sval, ranje):
"""argparse check that argument is an integer within a range"""
try:
ival = int(sval)
except ValueError:
raise argparse.ArgumentTypeError('Invalid value {} should be an integer'.format(sval))
if ival not in ranje:
msg = 'Invalid value {} not in range {}-{}'.format(ival, ranje.start, ranje.stop - 1)
raise argparse.ArgumentTypeError(msg)
return ival
def presetcheck(sval): return intrangecheck(sval, range(1, 129))
def volumecheck(sval): return intrangecheck(sval, range(0, 128))
def channelcheck(sval): return intrangecheck(sval, range(0, 17))
class controlchecker:
"""first check if the control name is valid, then check if value is good for that control"""
def __init__(self):
self.name = None
def __call__(self, scon):
if(self.name == None): # first execution is control name
if scon in blackstarid.BlackstarIDAmp.controls.keys():
self.name = scon
else:
raise argparse.ArgumentTypeError('Invalid control name "{}"'.format(scon))
return scon
else:
lo, hi = blackstarid.BlackstarIDAmp.control_limits[self.name]
return intrangecheck(scon, range(lo, hi + 1) )
controlcheck = controlchecker()
def fillit(s): return textwrap.fill(' '.join(s.split()))
def main():
midi_in = rtmidi.MidiIn()
midibus = partial(buscheck, midi_in=midi_in)
parser = argparse.ArgumentParser(
description = fillit(""" Control a Blackstar ID guitar
amplifier with MIDI Program Change
and Control Change messages."""),
epilog = '\n\n'.join( [fillit(s) for s in [
"""Darkstar probably can't keep up with an LFO signal from
your DAW. It's for setting a value every now-and-then,
not continuously. Latency appears to be ~40ms YLMV.""",
"""--preset, --volume, and --control are conveniences to quickly
set a control and exit. They can be used together.""",
"""--listbus, --listmap, and --listcontrols provide useful
information and exit. They can be used together."""]] ),
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('--bus', type=midibus, default='blackstar', help='number or exact name including spaces of MIDI bus to listen on, default="blackstar"')
parser.add_argument('--channel', type=channelcheck, default=0, help='MIDI channel 1-16 to listen on, 0=all, default=all')
parser.add_argument('--map', type=str, metavar='FILENAME', help='name of file of (cc number, control name) pairs.')
parser.add_argument('--quiet', action='store_true', help='suppress operational messages')
parser.add_argument('--preset', type=presetcheck, help='send a preset select 1-128 and exit')
parser.add_argument('--volume', type=volumecheck, help="set the amp's volume and exit")
parser.add_argument('--control', type=controlcheck, nargs=2, metavar=('NAME', 'VALUE'), help='set the named control to the value and exit')
parser.add_argument('--listbus', action='store_true', help='list Midi input busses and exit')
parser.add_argument('--listmap', action='store_true', help='list the default control mapping and exit')
parser.add_argument('--listcontrols', action='store_true', help='list Blackstar controls and exit')
args = parser.parse_args()
if any([ args.listbus, args.listmap, args.listcontrols ]):
if args.listbus:
print('\n'.join([ '{} "{}"'.format(e[0], e[1]) for e in enumerate(midiports(midi_in)) ]))
if args.listmap:
for k in sorted(controlMap.keys()):
print('{:3} -> {}'.format(k, controlMap[k]))
if args.listcontrols:
s = ', '.join( sorted([k for k in blackstarid.BlackstarIDAmp.controls.keys()]) )
print(textwrap.fill(s))
else:
amp = blackstarid.BlackstarIDAmp()
amp.connect()
print('Connected to {}'.format(amp.model))
if args.preset != None or args.volume != None or args.control != None:
if args.preset != None:
print('Requesting preset {}'.format(args.preset))
amp.select_preset(args.preset)
if args.volume != None:
print('Setting volume {}'.format(args.volume))
amp.set_control('volume', args.volume)
if args.control != None:
print('Setting control {} to {}'.format(args.control[0], args.control[1]))
amp.set_control(args.control[0], args.control[1])
else:
if args.map != None:
readmap(args.map)
midi_in.callback = partial(midicallback, amp=amp, chan=args.channel, quiet=args.quiet)
busstr = midiports(midi_in)[args.bus]
chanstr = 'MIDI channel {}'.format(args.channel)
if args.channel == 0:
chanstr = 'all MIDI channels'
print('Listening to {} on bus "{}"'.format(chanstr, busstr))
midiloop(midi_in, args.bus) # exit main loop with KeyboardInterrupt
amp.disconnect()
#
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment