diff options
author | Grissess <grissess@nexusg.org> | 2021-06-12 16:28:10 -0400 |
---|---|---|
committer | Grissess <grissess@nexusg.org> | 2021-06-12 16:28:10 -0400 |
commit | ebd8f4b242c1a83a222297aa80b2e5c64580e36d (patch) | |
tree | 68e3b95b21c43245951bb7f4aa8f5af7da8d5471 | |
parent | f02119a40bdeb56d18c323993d7a78e1a8440b17 (diff) |
Add the SC client, refactor for Py3 a little
-rw-r--r-- | drums.py | 2 | ||||
-rw-r--r-- | packet.py | 37 | ||||
-rw-r--r-- | sc_client.py | 354 |
3 files changed, 376 insertions, 17 deletions
@@ -248,7 +248,7 @@ while True: dur = pkt.data[0]+pkt.data[1]/1000000.0 dframes = int(dur * options.rate) if not options.repeat: - dframes = max(dframes, rframes) + dframes = min(dframes, rframes) if not options.cut: dframes = rframes * ((dframes + rframes - 1) / rframes) amp = options.volume * pkt.as_float(3) @@ -3,26 +3,31 @@ import struct class Packet(object): - def __init__(self, cmd, *data): - self.cmd = cmd - self.data = data - if len(data) > 8: - raise ValueError('Too many data') - self.data = list(self.data) + [0] * (8-len(self.data)) + def __init__(self, cmd, *data): + self.cmd = cmd + self.data = data + if len(data) > 8: + raise ValueError('Too many data') + self.data = list(self.data) + [0] * (8-len(self.data)) @classmethod def FromStr(cls, s): - parts = struct.unpack('>9L', s) - return cls(parts[0], *parts[1:]) + try: + parts = struct.unpack('>9L', s) + return cls(parts[0], *parts[1:]) + except Exception: + raise ValueError('Failed to unpack %r' % s) def as_float(self, i): return struct.unpack('>f', struct.pack('>L', self.data[i]))[0] - def __str__(self): - return struct.pack('>L'+(''.join('f' if isinstance(i, float) else 'L' for i in self.data)), self.cmd, *self.data) + def __str__(self): + return struct.pack('>L'+(''.join('f' if isinstance(i, float) else 'L' for i in self.data)), self.cmd, *self.data) + def __bytes__(self): + return self.__str__() class CMD: - KA = 0 # No important data - PING = 1 # Data are echoed exactly - QUIT = 2 # No important data - PLAY = 3 # seconds, microseconds, frequency (Hz), amplitude (0.0 - 1.0), port, flags + KA = 0 # No important data + PING = 1 # Data are echoed exactly + QUIT = 2 # No important data + PLAY = 3 # seconds, microseconds, frequency (Hz), amplitude (0.0 - 1.0), port, flags CAPS = 4 # ports, client type (1), user ident (2-7) PCM = 5 # 16 samples, encoded S16_LE PCMSYN = 6 # number of samples which should be buffered right now @@ -32,9 +37,9 @@ class PLF: SAMEPHASE = 0x1 def itos(i): - return struct.pack('>L', i).rstrip('\0') + return struct.pack('>L', i).rstrip(b'\0') def stoi(s): - return struct.unpack('>L', s.ljust(4, '\0'))[0] + return struct.unpack('>L', s.ljust(4, b'\0'))[0] OBLIGATE_POLYPHONE = 0xffffffff diff --git a/sc_client.py b/sc_client.py new file mode 100644 index 0000000..b487ef2 --- /dev/null +++ b/sc_client.py @@ -0,0 +1,354 @@ +import argparse, socket, threading, time, random, shlex + +from pythonosc.osc_message import OscMessage +from pythonosc.osc_message_builder import OscMessageBuilder + +from packet import Packet, CMD, PLF, stoi, OBLIGATE_POLYPHONE + +class CustomArgumentParser(argparse.ArgumentParser): + def __init__(self): + super(CustomArgumentParser, self).__init__( + description = 'ITL Chorus SuperCollider Client', + fromfile_prefix_chars = '@', + epilog = 'Use at-sign (@) prefixing a file path in the argument list to include arguments from a file.', + ) + + def convert_arg_line_to_args(self, line): + return shlex.split(line) + +class SetObligatePoly(argparse.Action): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.default = argparse.SUPPRESS # Don't assign anything--we're effectively a special "store_true" + self.nargs = 0 + + def __call__(self, parser, ns, values, opt): + ns.voices = 1 + ns.obpoly = True + +class SetVoice(argparse.Action): + def __call__(self, parser, ns, values, opt): + ns.voicen = values + +class SetVoiceAll(argparse.Action): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.nargs = 0 + + def __call__(self, parser, ns, values, opt): + ns.voicen = None + +class SetVoiceOpt(argparse.Action): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.default = argparse.SUPPRESS # We have custom init logic + + def ensure_existence(self, ns): + if not hasattr(ns, '_voices'): + ns._voices = ns.voices + if ns.voices != ns._voices: + raise ValueError(f'Cannot set voices (to {ns.voices}, was {ns._voices}) after configuring a voice option') + if not hasattr(ns, self.dest): + setattr(ns, self.dest, self.produce_default_seq(ns)) + + def produce_default_seq(self, ns): + return [self.const] * ns.voices + + def __call__(self, parser, ns, values, opt): + self.ensure_existence(ns) + for i in (range(ns._voices) if ns.voicen is None else ns.voicen): + if i >= ns._voices: + raise ValueError(f'Cannot set property {self.dest!r} on voice {i} as there are only {ns._voices} voices') + self.call_on_voice(i, ns, values) + + def call_on_voice(self, voice, ns, values): + getattr(ns, self.dest)[voice] = values + +class Copy(object): + def __init__(self, name): + self.name = name + +class Expr(object): + def __init__(self, expr): + self.expr = compile(expr, 'param', 'eval') + +class AddVoiceOpt(SetVoiceOpt): + @staticmethod + def interp_rand(s): + a, _, b = s.partition(',') + a, b = float(a), float(b) + return a + random.random() * (b - a) + + @staticmethod + def interp_randint(s): + a, _, b = s.partition(',') + a, b = int(a), int(b) + return random.randint(a, b) + + TYPE_FUNCS = { + 's': lambda x: x, + 'str': lambda x: x, + 'i': int, + 'int': int, + 'f': float, + 'float': float, + 'r': interp_rand, + 'rand': interp_rand, + 'ri': interp_randint, + 'randint': interp_randint, + 'c': Copy, + 'copy': Copy, + 'e': Expr, + 'expr': Expr, + } + + def produce_default_seq(self, ns): + return [{} for _ in range(ns.voices)] + + def call_on_voice(self, voice, ns, values): + k, v = values.split('=') + k, sep, t = k.partition(':') + if t: + v = self.TYPE_FUNCS[t](v) + getattr(ns, self.dest)[voice][k] = v + +class RemoveVoiceOpt(AddVoiceOpt): + def call_on_voice(self, voice, ns, values): + del getattr(ns, self.dest)[voice][values] + +TYPE=b'SUPC' + +parser = CustomArgumentParser() +parser.add_argument('-p', '--port', type=int, default=13676, help='Sets the port to listen on') +parser.add_argument('-B', '--bind', default='', help='Bind to this address') +parser.add_argument('-S', '--server', default='127.0.0.1', help='Send OSC to SCSynth at this address') +parser.add_argument('-P', '--server-port', type=int, default=57110, help='Send OSC to SCSynth on this port') +parser.add_argument('--server-bind', default='127.0.0.1', help='Address to bind the OSC socket to') +parser.add_argument('--server-bind-port', type=int, default=0, help='Port to bind the OSC socket to') +parser.add_argument('-u', '--uid', default='', help='Set the UID (identifer) of this client for routing') +#parser.add_argument('--exclusive', action='store_true', help="Don't query the server for a new node ID--assume they're incremental. Boosts performance, but only sound if we're the only client.") +parser.add_argument('--start-id', type=int, default=2, help='Starting node ID to allocate (further IDs are allocated sequentially)') +parser.add_argument('-G', '--group', type=int, default=1, help='SC Group to add to (should exist before starting)') +parser.add_argument('--attach', type=int, default=1, help='SC Target attachment method (head=0, tail, before, after, replace=4)') +parser.add_argument('--stop-with-free', action='store_true', help='Kill a node with n_free rather than setting its gate to 0') +parser.add_argument('--slack', type=float, default=0.002, help='Add this much to duration to allow late SAMEPHASE to work') + +group = parser.add_mutually_exclusive_group() +group.add_argument('-n', '--voices', type=int, default=1, help='Number of voices to advertise (does not affect synth polyphony)') +group.add_argument('-N', '--obligate-polyphone', help='Set this instance as an Obligate Polyphone (arbitrary voice count)--incompatible with -n/--voices, and there is effectively only one voice to configure', action=SetObligatePoly) +parser.set_defaults(obpoly = False) + +group = parser.add_argument_group('Voice Selection', 'Options which select a voice to configure. Specify AFTER setting the number of voices!') +group.add_argument('-v', '--voice', type=int, nargs='+', help='Following Voice Options apply to these voices', action=SetVoice) +group.add_argument('-V', '--all-voices', help='Following Voice Options apply to all voices', action=SetVoiceAll) +parser.set_defaults(voicen = None) + +group = parser.add_argument_group('Voice Options', 'Options which configure one or more voices.') +group.add_argument('-s', '--synth', const='default', help='Set the SC synth name for these voices', action=SetVoiceOpt) +group.add_argument('-A', '--amplitude', type=float, const=1.0, help='Set a custom amplitude for these voices', action=SetVoiceOpt) +group.add_argument('-T', '--transpose', type=float, const=1.0, help='Set a frequency multiplier(!) for these voices', action=SetVoiceOpt) +group.add_argument('-R', '--random', type=float, const=0.0, help='Uniformly vary (in frequency space!) by up to +/- this value (as a fraction of the sent frequency)', action=SetVoiceOpt) +group.add_argument('--param', help='Set an arbitrary parameter for the voice synth (see --help-oparam)', action=AddVoiceOpt) +group.add_argument('--unset-param', dest='param', help='Unset an arbitrary parameter', action=RemoveVoiceOpt) +group.add_argument('--help-param', action='store_true', help='Display the documentation for the --param option') + +help_param = ''' +Use --param <name>[:<type>]=<value> to send arbitrary parameters to the synth +at play time--this includes whenever the ITL Chorus sends SAMEPHASE plays +(usually as part of a pitchbend expression). The values in angle brackets (<>) +are to be replaced, including the brackets themselves; the section in square +brackets ([]) is optional, defaulting to :str, and the brackets must not be +included. + +The entire second word (according to your shell) is consumed. Remember to use +your shell's quoting facilities if, e.g., you need to include spaces or special +characters. + +The name is sent as a symbol verbatim to SC; the value is interpreted based on +the type given: + +- s, str: The default; the value is sent as a string. +- i, int: The value is interpreted as as decimal integer. +- f, float: The value is interpreted as a Python-syntax float. +- r, rand: The value is of the form "<a>,<b>" where a and b are Python-syntax + floats. On each play, the value is chosen uniformly randomly from this range. +- ri, randint: The value is of the form "<a>,<b>" where a and b are decimal + integers. On each play, the value is an integer chosen uniformly randomly + from this range, inclusive. +- c, copy: The value is copied from another parameter named. This is useful for + making a value follow other named parameters provided by the implementation, + such as freq or amp. Note that copy values are interpreted only after the + other parameters are assigned, but the ordering within all copy params is + undefined, so they should not depend on each other. +- e, expr: The value is interpreted as a Python expression and evaluated, the + type of the result being used verbatim. This is done after all copies are + resolved, but the order of evaluation of expr values among themselves is + undefined, so they should not depend on each other. The expression has access + to the global scope, and its local scope consists of the parameters presently + defined--so they can be named as regular python identifiers. +''' + +def make_play_pkt(args, synth, nid, **ctrls): + msg = OscMessageBuilder('/s_new') + msg.add_arg(synth) + msg.add_arg(nid) + msg.add_arg(args.attach) + msg.add_arg(args.group) + print(ctrls) + for name, value in ctrls.items(): + msg.add_arg(name) + msg.add_arg(value) + return msg.build().dgram + +def make_set_pkt(nid, **ctrls): + msg = OscMessageBuilder('/n_set') + msg.add_arg(nid) + for name, value in ctrls.items(): + msg.add_arg(name) + msg.add_arg(value) + return msg.build().dgram + +def make_stop_pkt(nid): + msg = OscMessageBuilder('/n_free') + msg.add_arg(nid) + return msg.build().dgram + +def make_version_pkt(): + msg = OscMessageBuilder('/version') + return msg.build().dgram + +def _get_second(pair): + return pair[1] + +def _not_none(pair): + return pair[1] is not None + +def free_voice(args, idx, osc, srv, nodes, deadlines, lk): + with lk: + if nodes[idx] is None: + return + if args.stop_with_free: + osc.sendto(make_stop_pkt(ndes[idx]), srv) + else: + osc.sendto(make_set_pkt(nodes[idx], gate=0.0), srv) + nodes[idx] = None + deadlines[idx] = None + +def check_deadlines(args, osc, srv, nodes, deadlines, lk): + while True: + with lk: + dls = list(filter(_not_none, enumerate(deadlines))) + if not dls: + time.sleep(0.05) + continue + idx, cur = min(dls, key=_get_second) + now = time.time() + if cur > now: + time.sleep(max(0.0001, cur - time.time())) # account for time since recording now + else: + free_voice(args, idx, osc, srv, nodes, deadlines, lk) + +def main(): + args = parser.parse_args() + for act in parser._actions: + if isinstance(act, SetVoiceOpt): + act.ensure_existence(args) + args.uid = args.uid.encode() + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind((args.bind, args.port)) + + osc = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + osc.bind((args.server_bind, args.server_bind_port)) + + osc_srv = (args.server, args.server_port) + + osc.sendto(make_version_pkt(), osc_srv) + osc.settimeout(5) + data, _ = osc.recvfrom(4096) + msg = OscMessage(data) + assert msg.address == "/version.reply" + prog, major, minor, patch, branch, commit = list(msg) + print(f'Connected to {prog} {major}.{minor}.{patch}, branch {branch}, commit {commit}') + + def ignore_input(): + osc.settimeout(None) + while True: + osc.recv(4096) + + ignore_thread = threading.Thread(target=ignore_input) + ignore_thread.daemon = True + ignore_thread.start() + + nodes = [None] * args.voices + dls = [None] * args.voices + lock = threading.RLock() + + dl_thread = threading.Thread(target=check_deadlines, args=(args, osc, osc_srv, nodes, dls, lock)) + dl_thread.daemon = True + dl_thread.start() + + while True: + data = b'' + while not data: + try: + data, cli = sock.recvfrom(4096) + except socket.error: + pass + + pkt = Packet.FromStr(data) + print(f'{bytes(pkt)!r}') + if pkt.cmd == CMD.KA: + pass + elif pkt.cmd == CMD.PING: + sock.sendto(data, cli) + elif pkt.cmd == CMD.QUIT: + break + elif pkt.cmd == CMD.PLAY: + voice = pkt.data[4] + dur = pkt.data[0] + pkt.data[1] / 1000000.0 + freq = pkt.data[2] * args.transpose[voice] + if args.random[voice] != 0.0: + freq *= 1.0 + args.random[voice] * (random.random() + 1) / 2 + amp = pkt.as_float(3) * args.amplitude[voice] + flags = pkt.data[5] + synth = args.synth[voice] + nid = args.start_id + args.start_id += 1 + params = dict(args.param[voice], freq=freq, amp=amp, dur=dur) + for k in [p[0] for p in params.items() if isinstance(p[1], Copy)]: + params[k] = params[params[k].name] + for k in [p[0] for p in params.items() if isinstance(p[1], Expr)]: + params[k] = eval(params[k].expr, None, params) + + if freq == 0: # STOP + free_voice(args, voice, osc, osc_srv, nodes, dls, lock) + elif flags & PLF.SAMEPHASE and nodes[voice] is not None: + with lock: + osc.sendto( + make_set_pkt(nodes[voice], **params), + osc_srv, + ) + dls[voice] = time.time() + dur + args.slack + else: + with lock: + if nodes[voice] is not None: + free_voice(args, voice, osc, osc_srv, nodes, dls, lock) + nodes[voice] = nid + dls[voice] = time.time() + dur + args.slack + osc.sendto( + make_play_pkt(args, synth, nid, **params), + osc_srv, + ) + elif pkt.cmd == CMD.CAPS: + data = [0] * 8 + data[0] = OBLIGATE_POLYPHONE if args.obpoly else args.voices + data[1] = stoi(TYPE) + for i in range(len(args.uid)//4 + 1): + data[i+2] = stoi(args.uid[4*i:4*(i+1)]) + sock.sendto(bytes(Packet(CMD.CAPS, *data)), cli) + else: + pass # unrec + +if __name__ == '__main__': + main() |