sc_client.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import argparse, socket, threading, time, random, shlex
  2. from pythonosc.osc_message import OscMessage
  3. from pythonosc.osc_message_builder import OscMessageBuilder
  4. from packet import Packet, CMD, PLF, stoi, OBLIGATE_POLYPHONE
  5. class CustomArgumentParser(argparse.ArgumentParser):
  6. def __init__(self):
  7. super(CustomArgumentParser, self).__init__(
  8. description = 'ITL Chorus SuperCollider Client',
  9. fromfile_prefix_chars = '@',
  10. epilog = 'Use at-sign (@) prefixing a file path in the argument list to include arguments from a file.',
  11. )
  12. def convert_arg_line_to_args(self, line):
  13. return shlex.split(line)
  14. class SetObligatePoly(argparse.Action):
  15. def __init__(self, *args, **kwargs):
  16. super().__init__(*args, **kwargs)
  17. self.default = argparse.SUPPRESS # Don't assign anything--we're effectively a special "store_true"
  18. self.nargs = 0
  19. def __call__(self, parser, ns, values, opt):
  20. ns.voices = 1
  21. ns.obpoly = True
  22. class SetVoice(argparse.Action):
  23. def __call__(self, parser, ns, values, opt):
  24. ns.voicen = values
  25. class SetVoiceAll(argparse.Action):
  26. def __init__(self, *args, **kwargs):
  27. super().__init__(*args, **kwargs)
  28. self.nargs = 0
  29. def __call__(self, parser, ns, values, opt):
  30. ns.voicen = None
  31. class SetVoiceOpt(argparse.Action):
  32. def __init__(self, *args, **kwargs):
  33. super().__init__(*args, **kwargs)
  34. self.default = argparse.SUPPRESS # We have custom init logic
  35. def ensure_existence(self, ns):
  36. if not hasattr(ns, '_voices'):
  37. ns._voices = ns.voices
  38. if ns.voices != ns._voices:
  39. raise ValueError(f'Cannot set voices (to {ns.voices}, was {ns._voices}) after configuring a voice option')
  40. if not hasattr(ns, self.dest):
  41. setattr(ns, self.dest, self.produce_default_seq(ns))
  42. def produce_default_seq(self, ns):
  43. return [self.const] * ns.voices
  44. def __call__(self, parser, ns, values, opt):
  45. self.ensure_existence(ns)
  46. for i in (range(ns._voices) if ns.voicen is None else ns.voicen):
  47. if i >= ns._voices:
  48. raise ValueError(f'Cannot set property {self.dest!r} on voice {i} as there are only {ns._voices} voices')
  49. self.call_on_voice(i, ns, values)
  50. def call_on_voice(self, voice, ns, values):
  51. getattr(ns, self.dest)[voice] = values
  52. class Copy(object):
  53. def __init__(self, name):
  54. self.name = name
  55. class Expr(object):
  56. def __init__(self, expr):
  57. self.expr = compile(expr, 'param', 'eval')
  58. class AddVoiceOpt(SetVoiceOpt):
  59. @staticmethod
  60. def interp_rand(s):
  61. a, _, b = s.partition(',')
  62. a, b = float(a), float(b)
  63. return a + random.random() * (b - a)
  64. @staticmethod
  65. def interp_randint(s):
  66. a, _, b = s.partition(',')
  67. a, b = int(a), int(b)
  68. return random.randint(a, b)
  69. TYPE_FUNCS = {
  70. 's': lambda x: x,
  71. 'str': lambda x: x,
  72. 'i': int,
  73. 'int': int,
  74. 'f': float,
  75. 'float': float,
  76. 'r': interp_rand,
  77. 'rand': interp_rand,
  78. 'ri': interp_randint,
  79. 'randint': interp_randint,
  80. 'c': Copy,
  81. 'copy': Copy,
  82. 'e': Expr,
  83. 'expr': Expr,
  84. }
  85. def produce_default_seq(self, ns):
  86. return [{} for _ in range(ns.voices)]
  87. def call_on_voice(self, voice, ns, values):
  88. k, v = values.split('=')
  89. k, sep, t = k.partition(':')
  90. if t:
  91. v = self.TYPE_FUNCS[t](v)
  92. getattr(ns, self.dest)[voice][k] = v
  93. class RemoveVoiceOpt(AddVoiceOpt):
  94. def call_on_voice(self, voice, ns, values):
  95. del getattr(ns, self.dest)[voice][values]
  96. TYPE=b'SUPC'
  97. parser = CustomArgumentParser()
  98. parser.add_argument('-p', '--port', type=int, default=13676, help='Sets the port to listen on')
  99. parser.add_argument('-B', '--bind', default='', help='Bind to this address')
  100. parser.add_argument('-S', '--server', default='127.0.0.1', help='Send OSC to SCSynth at this address')
  101. parser.add_argument('-P', '--server-port', type=int, default=57110, help='Send OSC to SCSynth on this port')
  102. parser.add_argument('--server-bind', default='127.0.0.1', help='Address to bind the OSC socket to')
  103. parser.add_argument('--server-bind-port', type=int, default=0, help='Port to bind the OSC socket to')
  104. parser.add_argument('-u', '--uid', default='', help='Set the UID (identifer) of this client for routing')
  105. #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.")
  106. parser.add_argument('--start-id', type=int, default=2, help='Starting node ID to allocate (further IDs are allocated sequentially)')
  107. parser.add_argument('-G', '--group', type=int, default=1, help='SC Group to add to (should exist before starting)')
  108. parser.add_argument('--attach', type=int, default=1, help='SC Target attachment method (head=0, tail, before, after, replace=4)')
  109. parser.add_argument('--stop-with-free', action='store_true', help='Kill a node with n_free rather than setting its gate to 0')
  110. parser.add_argument('--slack', type=float, default=0.002, help='Add this much to duration to allow late SAMEPHASE to work')
  111. group = parser.add_mutually_exclusive_group()
  112. group.add_argument('-n', '--voices', type=int, default=1, help='Number of voices to advertise (does not affect synth polyphony)')
  113. 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)
  114. parser.set_defaults(obpoly = False)
  115. group = parser.add_argument_group('Voice Selection', 'Options which select a voice to configure. Specify AFTER setting the number of voices!')
  116. group.add_argument('-v', '--voice', type=int, nargs='+', help='Following Voice Options apply to these voices', action=SetVoice)
  117. group.add_argument('-V', '--all-voices', help='Following Voice Options apply to all voices', action=SetVoiceAll)
  118. parser.set_defaults(voicen = None)
  119. group = parser.add_argument_group('Voice Options', 'Options which configure one or more voices.')
  120. group.add_argument('-s', '--synth', const='default', help='Set the SC synth name for these voices', action=SetVoiceOpt)
  121. group.add_argument('-A', '--amplitude', type=float, const=1.0, help='Set a custom amplitude for these voices', action=SetVoiceOpt)
  122. group.add_argument('-T', '--transpose', type=float, const=1.0, help='Set a frequency multiplier(!) for these voices', action=SetVoiceOpt)
  123. 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)
  124. group.add_argument('--param', help='Set an arbitrary parameter for the voice synth (see --help-oparam)', action=AddVoiceOpt)
  125. group.add_argument('--unset-param', dest='param', help='Unset an arbitrary parameter', action=RemoveVoiceOpt)
  126. group.add_argument('--help-param', action='store_true', help='Display the documentation for the --param option')
  127. help_param = '''
  128. Use --param <name>[:<type>]=<value> to send arbitrary parameters to the synth
  129. at play time--this includes whenever the ITL Chorus sends SAMEPHASE plays
  130. (usually as part of a pitchbend expression). The values in angle brackets (<>)
  131. are to be replaced, including the brackets themselves; the section in square
  132. brackets ([]) is optional, defaulting to :str, and the brackets must not be
  133. included.
  134. The entire second word (according to your shell) is consumed. Remember to use
  135. your shell's quoting facilities if, e.g., you need to include spaces or special
  136. characters.
  137. The name is sent as a symbol verbatim to SC; the value is interpreted based on
  138. the type given:
  139. - s, str: The default; the value is sent as a string.
  140. - i, int: The value is interpreted as as decimal integer.
  141. - f, float: The value is interpreted as a Python-syntax float.
  142. - r, rand: The value is of the form "<a>,<b>" where a and b are Python-syntax
  143. floats. On each play, the value is chosen uniformly randomly from this range.
  144. - ri, randint: The value is of the form "<a>,<b>" where a and b are decimal
  145. integers. On each play, the value is an integer chosen uniformly randomly
  146. from this range, inclusive.
  147. - c, copy: The value is copied from another parameter named. This is useful for
  148. making a value follow other named parameters provided by the implementation,
  149. such as freq or amp. Note that copy values are interpreted only after the
  150. other parameters are assigned, but the ordering within all copy params is
  151. undefined, so they should not depend on each other.
  152. - e, expr: The value is interpreted as a Python expression and evaluated, the
  153. type of the result being used verbatim. This is done after all copies are
  154. resolved, but the order of evaluation of expr values among themselves is
  155. undefined, so they should not depend on each other. The expression has access
  156. to the global scope, and its local scope consists of the parameters presently
  157. defined--so they can be named as regular python identifiers.
  158. '''
  159. def make_play_pkt(args, synth, nid, **ctrls):
  160. msg = OscMessageBuilder('/s_new')
  161. msg.add_arg(synth)
  162. msg.add_arg(nid)
  163. msg.add_arg(args.attach)
  164. msg.add_arg(args.group)
  165. print(ctrls)
  166. for name, value in ctrls.items():
  167. msg.add_arg(name)
  168. msg.add_arg(value)
  169. return msg.build().dgram
  170. def make_set_pkt(nid, **ctrls):
  171. msg = OscMessageBuilder('/n_set')
  172. msg.add_arg(nid)
  173. for name, value in ctrls.items():
  174. msg.add_arg(name)
  175. msg.add_arg(value)
  176. return msg.build().dgram
  177. def make_stop_pkt(nid):
  178. msg = OscMessageBuilder('/n_free')
  179. msg.add_arg(nid)
  180. return msg.build().dgram
  181. def make_version_pkt():
  182. msg = OscMessageBuilder('/version')
  183. return msg.build().dgram
  184. def _get_second(pair):
  185. return pair[1]
  186. def _not_none(pair):
  187. return pair[1] is not None
  188. def free_voice(args, idx, osc, srv, nodes, deadlines, lk):
  189. with lk:
  190. if nodes[idx] is None:
  191. return
  192. if args.stop_with_free:
  193. osc.sendto(make_stop_pkt(ndes[idx]), srv)
  194. else:
  195. osc.sendto(make_set_pkt(nodes[idx], gate=0.0), srv)
  196. nodes[idx] = None
  197. deadlines[idx] = None
  198. def check_deadlines(args, osc, srv, nodes, deadlines, lk):
  199. while True:
  200. with lk:
  201. dls = list(filter(_not_none, enumerate(deadlines)))
  202. if not dls:
  203. time.sleep(0.05)
  204. continue
  205. idx, cur = min(dls, key=_get_second)
  206. now = time.time()
  207. if cur > now:
  208. time.sleep(max(0.0001, cur - time.time())) # account for time since recording now
  209. else:
  210. free_voice(args, idx, osc, srv, nodes, deadlines, lk)
  211. def main():
  212. args = parser.parse_args()
  213. for act in parser._actions:
  214. if isinstance(act, SetVoiceOpt):
  215. act.ensure_existence(args)
  216. args.uid = args.uid.encode()
  217. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  218. sock.bind((args.bind, args.port))
  219. osc = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  220. osc.bind((args.server_bind, args.server_bind_port))
  221. osc_srv = (args.server, args.server_port)
  222. osc.sendto(make_version_pkt(), osc_srv)
  223. osc.settimeout(5)
  224. data, _ = osc.recvfrom(4096)
  225. msg = OscMessage(data)
  226. assert msg.address == "/version.reply"
  227. prog, major, minor, patch, branch, commit = list(msg)
  228. print(f'Connected to {prog} {major}.{minor}.{patch}, branch {branch}, commit {commit}')
  229. def ignore_input():
  230. osc.settimeout(None)
  231. while True:
  232. osc.recv(4096)
  233. ignore_thread = threading.Thread(target=ignore_input)
  234. ignore_thread.daemon = True
  235. ignore_thread.start()
  236. nodes = [None] * args.voices
  237. dls = [None] * args.voices
  238. lock = threading.RLock()
  239. dl_thread = threading.Thread(target=check_deadlines, args=(args, osc, osc_srv, nodes, dls, lock))
  240. dl_thread.daemon = True
  241. dl_thread.start()
  242. while True:
  243. data = b''
  244. while not data:
  245. try:
  246. data, cli = sock.recvfrom(4096)
  247. except socket.error:
  248. pass
  249. pkt = Packet.FromStr(data)
  250. print(f'{bytes(pkt)!r}')
  251. if pkt.cmd == CMD.KA:
  252. pass
  253. elif pkt.cmd == CMD.PING:
  254. sock.sendto(data, cli)
  255. elif pkt.cmd == CMD.QUIT:
  256. break
  257. elif pkt.cmd == CMD.PLAY:
  258. voice = pkt.data[4]
  259. dur = pkt.data[0] + pkt.data[1] / 1000000.0
  260. freq = pkt.data[2] * args.transpose[voice]
  261. if args.random[voice] != 0.0:
  262. freq *= 1.0 + args.random[voice] * (random.random() + 1) / 2
  263. amp = pkt.as_float(3) * args.amplitude[voice]
  264. flags = pkt.data[5]
  265. synth = args.synth[voice]
  266. nid = args.start_id
  267. args.start_id += 1
  268. params = dict(args.param[voice], freq=freq, amp=amp, dur=dur)
  269. for k in [p[0] for p in params.items() if isinstance(p[1], Copy)]:
  270. params[k] = params[params[k].name]
  271. for k in [p[0] for p in params.items() if isinstance(p[1], Expr)]:
  272. params[k] = eval(params[k].expr, None, params)
  273. if freq == 0: # STOP
  274. free_voice(args, voice, osc, osc_srv, nodes, dls, lock)
  275. elif flags & PLF.SAMEPHASE and nodes[voice] is not None:
  276. with lock:
  277. osc.sendto(
  278. make_set_pkt(nodes[voice], **params),
  279. osc_srv,
  280. )
  281. dls[voice] = time.time() + dur + args.slack
  282. else:
  283. with lock:
  284. if nodes[voice] is not None:
  285. free_voice(args, voice, osc, osc_srv, nodes, dls, lock)
  286. nodes[voice] = nid
  287. dls[voice] = time.time() + dur + args.slack
  288. osc.sendto(
  289. make_play_pkt(args, synth, nid, **params),
  290. osc_srv,
  291. )
  292. elif pkt.cmd == CMD.CAPS:
  293. data = [0] * 8
  294. data[0] = OBLIGATE_POLYPHONE if args.obpoly else args.voices
  295. data[1] = stoi(TYPE)
  296. for i in range(len(args.uid)//4 + 1):
  297. data[i+2] = stoi(args.uid[4*i:4*(i+1)])
  298. sock.sendto(bytes(Packet(CMD.CAPS, *data)), cli)
  299. else:
  300. pass # unrec
  301. if __name__ == '__main__':
  302. main()