diff options
| -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() | 
