Przeglądaj źródła

Merge branch 'beta'

Graham Northup 7 lat temu
rodzic
commit
6cba85974d
10 zmienionych plików z 1252 dodań i 215 usunięć
  1. 252 101
      broadcast.py
  2. 175 64
      client.py
  3. 117 0
      downsamp.py
  4. 215 0
      drums.py
  5. BIN
      drums.tar.bz2
  6. 29 0
      make_patfile.sh
  7. 308 39
      mkiv.py
  8. 63 0
      mktune.py
  9. 9 4
      packet.py
  10. 84 7
      shiv.py

+ 252 - 101
broadcast.py

@@ -8,14 +8,16 @@ import thread
 import optparse
 import random
 import itertools
+import re
+import os
 
-from packet import Packet, CMD, itos
+from packet import Packet, CMD, itos, OBLIGATE_POLYPHONE
 
 parser = optparse.OptionParser()
 parser.add_option('-t', '--test', dest='test', action='store_true', help='Play a test tone (440, 880) on all clients in sequence (the last overlaps with the first of the next)')
-parser.add_option('--test-delay', dest='test_delay', type='float', help='Time for which to play a test tone')
 parser.add_option('-T', '--transpose', dest='transpose', type='int', help='Transpose by a set amount of semitones (positive or negative)')
 parser.add_option('--sync-test', dest='sync_test', action='store_true', help='Don\'t wait for clients to play tones properly--have them all test tone at the same time')
+parser.add_option('--wait-test', dest='wait_test', action='store_true', help='Wait for user input before moving to the next client tested')
 parser.add_option('-R', '--random', dest='random', type='float', help='Generate random notes at approximately this period')
 parser.add_option('--rand-low', dest='rand_low', type='int', help='Low frequency to randomly sample')
 parser.add_option('--rand-high', dest='rand_high', type='int', help='High frequency to randomly sample')
@@ -26,22 +28,30 @@ parser.add_option('-q', '--quit', dest='quit', action='store_true', help='Instru
 parser.add_option('-p', '--play', dest='play', action='append', help='Play a single tone or chord (specified multiple times) on all listening clients (either "midi pitch" or "@frequency")')
 parser.add_option('-P', '--play-async', dest='play_async', action='store_true', help='Don\'t wait for the tone to finish using the local clock')
 parser.add_option('-D', '--duration', dest='duration', type='float', help='How long to play this note for')
-parser.add_option('-V', '--volume', dest='volume', type='int', help='Master volume (0-255)')
+parser.add_option('-V', '--volume', dest='volume', type='float', help='Master volume [0.0, 1.0]')
 parser.add_option('-s', '--silence', dest='silence', action='store_true', help='Instruct all clients to stop playing any active tones')
 parser.add_option('-S', '--seek', dest='seek', type='float', help='Start time in seconds (scaled by --factor)')
 parser.add_option('-f', '--factor', dest='factor', type='float', help='Rescale time by this factor (0<f<1 are faster; 0.5 is twice the speed, 2 is half)')
 parser.add_option('-r', '--route', dest='routes', action='append', help='Add a routing directive (see --route-help)')
+parser.add_option('--clear-routes', dest='routes', action='store_const', const=[], help='Clear routes previously specified (including the default)')
 parser.add_option('-v', '--verbose', dest='verbose', action='store_true', help='Be verbose; dump events and actual time (can slow down performance!)')
-parser.add_option('-W', '--wait-time', dest='wait_time', type='float', help='How long to wait for clients to initially respond (delays all broadcasts)')
+parser.add_option('-W', '--wait-time', dest='wait_time', type='float', help='How long to wait between pings for clients to initially respond (delays all broadcasts)')
+parser.add_option('--tries', dest='tries', type='int', help='Number of ping packets to send')
 parser.add_option('-B', '--bind-addr', dest='bind_addr', help='The IP address (or IP:port) to bind to (influences the network to send to)')
+parser.add_option('--port', dest='ports', action='append', type='int', help='Add a port to find clients on')
+parser.add_option('--clear-ports', dest='ports', action='store_const', const=[], help='Clear ports previously specified (including the default)')
 parser.add_option('--repeat', dest='repeat', action='store_true', help='Repeat the file playlist indefinitely')
 parser.add_option('-n', '--number', dest='number', type='int', help='Number of clients to use; if negative (default -1), use the product of stream count and the absolute value of this parameter')
+parser.add_option('--dry', dest='dry', action='store_true', help='Dry run--don\'t actually search for or play to clients, but pretend they exist (useful with -G)')
+parser.add_option('--pcm', dest='pcm', action='store_true', help='Use experimental PCM rendering')
+parser.add_option('--pcm-lead', dest='pcmlead', type='float', help='Seconds of leading PCM data to send')
+parser.add_option('--spin', dest='spin', action='store_true', help='Ignore delta times in the queue (busy loop the CPU) for higher accuracy')
 parser.add_option('-G', '--gui', dest='gui', default='', help='set a GUI to use')
 parser.add_option('--pg-fullscreen', dest='fullscreen', action='store_true', help='Use a full-screen video mode')
 parser.add_option('--pg-width', dest='pg_width', type='int', help='Width of the pygame window')
 parser.add_option('--pg-height', dest='pg_height', type='int', help='Width of the pygame window')
 parser.add_option('--help-routes', dest='help_routes', action='store_true', help='Show help about routing directives')
-parser.set_defaults(routes=[], test_delay=0.25, random=0.0, rand_low=80, rand_high=2000, live=None, factor=1.0, duration=1.0, volume=255, wait_time=0.25, play=[], transpose=0, seek=0.0, bind_addr='', pg_width = 0, pg_height = 0, number=-1)
+parser.set_defaults(routes=['T:DRUM=!perc,0'], random=0.0, rand_low=80, rand_high=2000, live=None, factor=1.0, duration=0.25, volume=1.0, wait_time=0.1, tries=5, play=[], transpose=0, seek=0.0, bind_addr='', ports=[13676, 13677],  pg_width = 0, pg_height = 0, number=-1, pcmlead=0.1)
 options, args = parser.parse_args()
 
 if options.help_routes:
@@ -50,8 +60,13 @@ if options.help_routes:
 Routes are fully specified by:
 -The attribute to be routed on (either type "T", or UID "U")
 -The value of that attribute
--The exclusivity of that route ("+" for inclusive, "-" for exclusive)
--The stream group to be routed there.
+-The exclusivity of that route ("+" for inclusive, "-" for exclusive, "!" for complete)
+-The stream group to be routed there, or 0 to null route.
+The first two may be replaced by a single '0' to null route a stream--effective only when used with an exclusive route.
+
+"Complete" exclusivity is valid only for obligate polyphones, and indicates that *all* matches are to receive the stream. In other cases, this will have the undesirable effect of routing only one stream.
+
+The special group ALL matches all streams. Regular expressions may be used to specify groups. Note that the first character is *not* part of the regular expression.
 
 The syntax for that specification resembles the following:
 
@@ -61,6 +76,7 @@ The specifier consists of a comma-separated list of attribute-colon-value pairs,
     exit()
 
 GUIS = {}
+BASETIME = time.time()  # XXX fixes a race with the GUI
 
 def gui_pygame():
     print 'Starting pygame GUI...'
@@ -93,6 +109,7 @@ def gui_pygame():
     PFAC = HEIGHT / 128.0
 
     clock = pygame.time.Clock()
+    font = pygame.font.SysFont(pygame.font.get_default_font(), 24)
 
     print 'Pygame GUI initialized, running...'
 
@@ -103,10 +120,13 @@ def gui_pygame():
         idx = 0
         for cli, note in sorted(playing_notes.items(), key = lambda pair: pair[0]):
             pitch = note[0]
-            col = colorsys.hls_to_rgb(float(idx) / len(clients), note[1]/512.0, 1.0)
+            col = colorsys.hls_to_rgb(float(idx) / len(targets), note[1]/2.0, 1.0)
             col = [int(i*255) for i in col]
             disp.fill(col, (WIDTH - 1, HEIGHT - pitch * PFAC - PFAC, 1, PFAC))
             idx += 1
+        tsurf = font.render('%0.3f' % ((time.time() - BASETIME) / factor,), True, (255, 255, 255), (0, 0, 0))
+        disp.fill((0, 0, 0), tsurf.get_rect())
+        disp.blit(tsurf, (0, 0))
         pygame.display.flip()
 
         for ev in pygame.event.get():
@@ -120,11 +140,17 @@ def gui_pygame():
 
 GUIS['pygame'] = gui_pygame
 
-PORT = 13676
 factor = options.factor
 
 print 'Factor:', factor
 
+try:
+    rows, columns = map(int, os.popen('stty size', 'r').read().split())
+except Exception:
+    import traceback
+    traceback.print_exc()
+    rows, columns = 25, 80
+
 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
 s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
 if options.bind_addr:
@@ -133,51 +159,67 @@ if options.bind_addr:
         port = '12074'
     s.bind((addr, int(port)))
 
-clients = []
+clients = set()
+targets = set()
 uid_groups = {}
 type_groups = {}
-
-s.sendto(str(Packet(CMD.PING)), ('255.255.255.255', PORT))
-s.settimeout(options.wait_time)
-
-try:
-	while True:
-		data, src = s.recvfrom(4096)
-		clients.append(src)
-except socket.timeout:
-	pass
-
-playing_notes = {}
-for cli in clients:
-    playing_notes[cli] = (0, 0)
+ports = {}
+
+if not options.dry:
+    s.settimeout(options.wait_time)
+    for PORT in options.ports:
+        for num in xrange(options.tries):
+            s.sendto(str(Packet(CMD.PING)), ('255.255.255.255', PORT))
+            try:
+                while True:
+                    data, src = s.recvfrom(4096)
+                    clients.add(src)
+            except socket.timeout:
+                pass
 
 print len(clients), 'detected clients'
 
-print 'Clients:'
-for cl in clients:
+for num in xrange(options.tries):
+    print 'Try', num
+    for cl in clients:
 	print cl,
         s.sendto(str(Packet(CMD.CAPS)), cl)
         data, _ = s.recvfrom(4096)
         pkt = Packet.FromStr(data)
         print 'ports', pkt.data[0],
+        ports[cl] = pkt.data[0]
         tp = itos(pkt.data[1])
         print 'type', tp,
         uid = ''.join([itos(i) for i in pkt.data[2:]]).rstrip('\x00')
         print 'uid', uid
         if uid == '':
             uid = None
-        uid_groups.setdefault(uid, []).append(cl)
-        type_groups.setdefault(tp, []).append(cl)
+        uid_groups.setdefault(uid, set()).add(cl)
+        type_groups.setdefault(tp, set()).add(cl)
 	if options.test:
-                ts, tms = int(options.test_delay), int(options.test_delay * 1000000) % 1000000
-		s.sendto(str(Packet(CMD.PLAY, ts, tms, 440, options.volume)), cl)
+            ts, tms = int(options.duration), int(options.duration * 1000000) % 1000000
+            if options.wait_test:
+                s.sendto(str(Packet(CMD.PLAY, 65535, 0, 440, options.volume)), cl)
+                raw_input('%r: Press enter to test next client...' %(cl,))
+                s.sendto(str(Packet(CMD.PLAY, ts, tms, 880, options.volume)), cl)
+            else:
+                s.sendto(str(Packet(CMD.PLAY, ts, tms, 440, options.volume)), cl)
                 if not options.sync_test:
-                    time.sleep(options.test_delay)
+                    time.sleep(options.duration)
                     s.sendto(str(Packet(CMD.PLAY, ts, tms, 880, options.volume)), cl)
 	if options.quit:
 		s.sendto(str(Packet(CMD.QUIT)), cl)
         if options.silence:
-                s.sendto(str(Packet(CMD.PLAY, 0, 1, 1, 0)), cl)
+            for i in xrange(pkt.data[0]):
+                s.sendto(str(Packet(CMD.PLAY, 0, 1, 1, 0.0, i)), cl)
+        if pkt.data[0] == OBLIGATE_POLYPHONE:
+            pkt.data[0] = 1
+        for i in xrange(pkt.data[0]):
+            targets.add(cl+(i,))
+
+playing_notes = {}
+for tg in targets:
+    playing_notes[tg] = (0, 0)
 
 if options.gui:
     gui_thr = threading.Thread(target=GUIS[options.gui], args=())
@@ -190,16 +232,16 @@ if options.play:
             options.play[i] = int(val[1:])
         else:
             options.play[i] = int(440.0 * 2**((int(val) - 69)/12.0))
-    for i, cl in enumerate(clients):
-        s.sendto(str(Packet(CMD.PLAY, int(options.duration), int(1000000*(options.duration-int(options.duration))), options.play[i%len(options.play)], options.volume)), cl)
+    for i, cl in enumerate(targets):
+        s.sendto(str(Packet(CMD.PLAY, int(options.duration), int(1000000*(options.duration-int(options.duration))), options.play[i%len(options.play)], options.volume, cl[2])), cl[:2])
     if not options.play_async:
         time.sleep(options.duration)
     exit()
 
 if options.test and options.sync_test:
     time.sleep(0.25)
-    for cl in clients:
-        s.sendto(str(Packet(CMD.PLAY, 0, 250000, 880, 255)), cl)
+    for cl in targets:
+        s.sendto(str(Packet(CMD.PLAY, 0, 250000, 880, options.volume, cl[2])), cl[:2])
 
 if options.test or options.quit or options.silence:
     print uid_groups
@@ -208,8 +250,8 @@ if options.test or options.quit or options.silence:
 
 if options.random > 0:
     while True:
-        for cl in clients:
-            s.sendto(str(Packet(CMD.PLAY, int(options.random), int(1000000*(options.random-int(options.random))), random.randint(options.rand_low, options.rand_high), options.volume)), cl)
+        for cl in targets:
+            s.sendto(str(Packet(CMD.PLAY, int(options.random), int(1000000*(options.random-int(options.random))), random.randint(options.rand_low, options.rand_high), options.volume, cl[2])), cl[:2])
         time.sleep(options.random)
 
 if options.live or options.list_live:
@@ -223,7 +265,7 @@ if options.live or options.list_live:
         print sequencer.SequencerHardware()
         exit()
     seq = sequencer.SequencerRead(sequencer_resolution=120)
-    client_set = set(clients)
+    client_set = set(targets)
     active_set = {} # note (pitch) -> [client]
     deferred_set = set() # pitches held due to sustain
     sustain_status = False
@@ -270,9 +312,9 @@ if options.live or options.list_live:
                     print 'WARNING: Out of clients to do note %r; dropped'%(event.pitch,)
                     continue
                 cli = sorted(inactive_set)[0]
-                s.sendto(str(Packet(CMD.PLAY, 65535, 0, int(440.0 * 2**((event.pitch-69)/12.0)), 2*event.velocity)), cli)
+                s.sendto(str(Packet(CMD.PLAY, 65535, 0, int(440.0 * 2**((event.pitch-69)/12.0)), event.velocity / 127.0, cli[2])), cli[:2])
                 active_set.setdefault(event.pitch, []).append(cli)
-                playing_notes[cli] = (event.pitch, 2*event.velocity)
+                playing_notes[cli] = (event.pitch, event.velocity / 127.0)
                 if options.verbose:
                     print 'LIVE:', event.pitch, '+ =>', active_set[event.pitch]
             elif isinstance(event, midi.NoteOffEvent):
@@ -283,7 +325,7 @@ if options.live or options.list_live:
                     deferred_set.add(event.pitch)
                     continue
                 cli = active_set[event.pitch].pop()
-                s.sendto(str(Packet(CMD.PLAY, 0, 1, 1, 0)), cli)
+                s.sendto(str(Packet(CMD.PLAY, 0, 1, 1, 0, cli[2])), cli[:2])
                 playing_notes[cli] = (0, 0)
                 if options.verbose:
                     print 'LIVE:', event.pitch, '- =>', active_set[event.pitch]
@@ -300,7 +342,7 @@ if options.live or options.list_live:
                                 print 'WARNING: Attempted deferred removal of inactive note %r'%(pitch,)
                                 continue
                             for cli in active_set[pitch]:
-                                s.sendto(str(Packet(CMD.PLAY, 0, 1, 1, 0)), cli)
+                                s.sendto(str(Packet(CMD.PLAY, 0, 1, 1, 0, cli[2])), cli[:2])
                                 playing_notes[cli] = (0, 0)
                             del active_set[pitch]
                         deferred_set.clear()
@@ -309,6 +351,53 @@ if options.repeat:
     args = itertools.cycle(args)
 
 for fname in args:
+    if options.pcm and not fname.endswith('.iv'):
+        print 'PCM: play', fname
+        if fname == '-':
+            import wave
+            pcr = wave.open(sys.stdin)
+            samprate = pcr.getframerate()
+            pcr.read = pcr.readframes
+        else:
+            try:
+                import audiotools
+                pcr = audiotools.open(fname).to_pcm()
+                assert pcr.channels == 1 and pcr.bits_per_sample == 16 and pcr.sample_rate == 44100
+                samprate = pcr.sample_rate
+            except ImportError:
+                import wave
+                pcr = wave.open(fname, 'r')
+                assert pcr.getnchannels() == 1 and pcr.getsampwidth() == 2 and pcr.getframerate() == 44100
+                samprate = pcr.getframerate()
+                pcr.read = pcr.readframes
+
+        def read_all(fn, n):
+            buf = ''
+            while len(buf) < n:
+                nbuf = fn.read(n - len(buf))
+                if not isinstance(nbuf, str):
+                    nbuf = nbuf.to_bytes(False, True)
+                buf += nbuf
+            return buf
+
+        BASETIME = time.time() - options.pcmlead
+        sampcnt = 0
+        buf = read_all(pcr, 32)
+        print 'PCM: pcr', pcr, 'BASETIME', BASETIME, 'buf', len(buf)
+        while len(buf) >= 32:
+            frag = buf[:32]
+            buf = buf[32:]
+            for cl in clients:
+                s.sendto(struct.pack('>L', CMD.PCM) + frag, cl)
+            sampcnt += len(frag) / 2
+            delay = max(0, BASETIME + (sampcnt / float(samprate)) - time.time())
+            #print sampcnt, delay
+            if delay > 0:
+                time.sleep(delay)
+            if len(buf) < 32:
+                buf += read_all(pcr, 32 - len(buf))
+        print 'PCM: exit'
+        continue
     try:
         iv = ET.parse(fname).getroot()
     except IOError:
@@ -322,11 +411,12 @@ for fname in args:
     number = (len(notestreams) * abs(options.number) if options.number < 0 else options.number)
     print len(notestreams), 'notestreams'
     print len(clients), 'clients'
+    print len(targets), 'targets'
     print len(groups), 'groups'
     print number, 'clients used (number)'
 
     class Route(object):
-        def __init__(self, fattr, fvalue, group, excl=False):
+        def __init__(self, fattr, fvalue, group, excl=False, complete=False):
             if fattr == 'U':
                 self.map = uid_groups
             elif fattr == 'T':
@@ -336,10 +426,9 @@ for fname in args:
             else:
                 raise ValueError('Not a valid attribute specifier: %r'%(fattr,))
             self.value = fvalue
-            if group is not None and group not in groups:
-                raise ValueError('Not a present group: %r'%(group,))
             self.group = group
             self.excl = excl
+            self.complete = complete
         @classmethod
         def Parse(cls, s):
             fspecs, _, grpspecs = map(lambda x: x.strip(), s.partition('='))
@@ -354,39 +443,51 @@ for fname in args:
                         ret.append(Route(fattr, fvalue, part[1:], False))
                     elif part[0] == '-':
                         ret.append(Route(fattr, fvalue, part[1:], True))
+                    elif part[0] == '!':
+                        ret.append(Route(fattr, fvalue, part[1:], True, True))
                     elif part[0] == '0':
                         ret.append(Route(fattr, fvalue, None, True))
                     else:
                         raise ValueError('Not an exclusivity: %r'%(part[0],))
             return ret
         def Apply(self, cli):
-            return cli in self.map.get(self.value, [])
+            return cli[:2] in self.map.get(self.value, [])
         def __repr__(self):
             return '<Route of %r to %s:%s>'%(self.group, ('U' if self.map is uid_groups else 'T'), self.value)
 
     class RouteSet(object):
         def __init__(self, clis=None):
             if clis is None:
-                clis = clients[:]
-            self.clients = clis
+                clis = set(targets)
+            self.clients = list(clis)
             self.routes = []
         def Route(self, stream):
-            testset = self.clients[:]
+            testset = self.clients
             grp = stream.get('group', 'ALL')
             if options.verbose:
                 print 'Routing', grp, '...'
             excl = False
             for route in self.routes:
-                if route.group == grp:
+                if route.group is not None and re.match(route.group, grp) is not None:
                     if options.verbose:
                         print '\tMatches route', route
                     excl = excl or route.excl
                     matches = filter(lambda x, route=route: route.Apply(x), testset)
                     if matches:
+                        if route.complete:
+                            if options.verbose:
+                                print '\tUsing ALL clients:', matches
+                            for cl in matches:
+                                self.clients.remove(matches[0])
+                                if ports.get(matches[0][:2]) == OBLIGATE_POLYPHONE:
+                                    self.clients.append(matches[0])
+                            return matches
                         if options.verbose:
                             print '\tUsing client', matches[0]
                         self.clients.remove(matches[0])
-                        return matches[0]
+                        if ports.get(matches[0][:2]) == OBLIGATE_POLYPHONE:
+                            self.clients.append(matches[0])
+                        return [matches[0]]
                     if options.verbose:
                         print '\tNo matches, moving on...'
                 if route.group is None:
@@ -403,16 +504,18 @@ for fname in args:
             if excl:
                 if options.verbose:
                     print '\tExclusively routed, no route matched.'
-                return None
+                return []
             if not testset:
                 if options.verbose:
                     print '\tOut of clients, no route matched.'
-                return None
-            cli = testset[0]
+                return []
+            cli = list(testset)[0]
             self.clients.remove(cli)
+            if ports.get(cli[:2]) == OBLIGATE_POLYPHONE:
+                self.clients.append(cli)
             if options.verbose:
                 print '\tDefault route to', cli
-            return cli
+            return [cli]
 
     routeset = RouteSet()
     for rspec in options.routes:
@@ -428,33 +531,50 @@ for fname in args:
             print route
 
     class NSThread(threading.Thread):
-        def drop_missed(self):
-            nsq, cl = self._Thread__args
-            cnt = 0
-            while nsq and float(nsq[0].get('time'))*factor < time.time() - BASETIME:
-                nsq.pop(0)
-                cnt += 1
-            if options.verbose:
-                print self, 'dropped', cnt, 'notes due to miss'
-            self._Thread__args = (nsq, cl)
-        def wait_for(self, t):
-            if t <= 0:
-                return
-            time.sleep(t)
-	def run(self):
-		nsq, cl = self._Thread__args
-		for note in nsq:
-			ttime = float(note.get('time'))
-			pitch = int(note.get('pitch')) + options.transpose
-			vel = int(note.get('vel'))
-			dur = factor*float(note.get('dur'))
-			while time.time() - BASETIME < factor*ttime:
-				self.wait_for(factor*ttime - (time.time() - BASETIME))
-			s.sendto(str(Packet(CMD.PLAY, int(dur), int((dur*1000000)%1000000), int(440.0 * 2**((pitch-69)/12.0)), int(vel*2 * options.volume/255.0))), cl)
-                        if options.verbose:
-                            print (time.time() - BASETIME), cl, ': PLAY', pitch, dur, vel
-			self.wait_for(dur - ((time.time() - BASETIME) - factor*ttime))
-    class NSThread(threading.Thread):
+            def __init__(self, *args, **kwargs):
+                threading.Thread.__init__(self, *args, **kwargs)
+                self.done = False
+                self.cur_offt = None
+                self.next_t = None
+            def actuate_missed(self):
+                nsq, cls = self._Thread__args
+                dur = None
+                i = 0
+                while nsq and float(nsq[0].get('time'))*factor <= time.time() - BASETIME:
+                    i += 1
+                    note = nsq.pop(0)
+                    ttime = float(note.get('time'))
+                    pitch = float(note.get('pitch')) + options.transpose
+                    ampl = float(note.get('ampl', float(note.get('vel', 127.0)) / 127.0))
+                    dur = factor*float(note.get('dur'))
+                    if options.verbose:
+                        print (time.time() - BASETIME) / options.factor, ': PLAY', pitch, dur, ampl
+                    if options.dry:
+                        playing_notes[self.nsid] = (pitch, ampl)
+                    else:
+                        for cl in cls:
+                            s.sendto(str(Packet(CMD.PLAY, int(dur), int((dur*1000000)%1000000), int(440.0 * 2**((pitch-69)/12.0)), ampl * options.volume, cl[2])), cl[:2])
+                            playing_notes[cl] = (pitch, ampl)
+                if i > 0 and dur is not None:
+                    self.cur_offt = ttime + dur / options.factor
+                else:
+                    if self.cur_offt:
+                        if factor * self.cur_offt <= time.time() - BASETIME:
+                            if options.verbose:
+                                print '% 6.5f'%((time.time() - BASETIME) / factor,), ': DONE'
+                            self.cur_offt = None
+                            if options.dry:
+                                playing_notes[self.nsid] = (0, 0)
+                            else:
+                                for cl in cls:
+                                    playing_notes[cl] = (0, 0)
+                next_act = None
+                if nsq:
+                    next_act = float(nsq[0].get('time'))
+                if options.verbose:
+                    print 'NEXT_ACT:', next_act, 'CUR_OFFT:', self.cur_offt
+                self.next_t = min((next_act or float('inf'), self.cur_offt or float('inf')))
+                self.done = not (nsq or self.cur_offt)
             def drop_missed(self):
                 nsq, cl = self._Thread__args
                 cnt = 0
@@ -463,7 +583,6 @@ for fname in args:
                     cnt += 1
                 if options.verbose:
                     print self, 'dropped', cnt, 'notes due to miss'
-                self._Thread__args = (nsq, cl)
             def wait_for(self, t):
                 if t <= 0:
                     return
@@ -473,30 +592,42 @@ for fname in args:
                     for note in nsq:
                             ttime = float(note.get('time'))
                             pitch = float(note.get('pitch')) + options.transpose
-                            vel = int(note.get('vel'))
+                            ampl = float(note.get('ampl', float(note.get('vel', 127.0)) / 127.0))
                             dur = factor*float(note.get('dur'))
                             while time.time() - BASETIME < factor*ttime:
-                                    self.wait_for(factor*ttime - (time.time() - BASETIME))
-                            for cl in cls:
-                                    s.sendto(str(Packet(CMD.PLAY, int(dur), int((dur*1000000)%1000000), int(440.0 * 2**((pitch-69)/12.0)), int(vel*2 * options.volume/255.0))), cl)
+                                self.wait_for(factor*ttime - (time.time() - BASETIME))
+                            if options.dry:
+                                cl = self.nsid  # XXX hack
+                            else:
+                                for cl in cls:
+                                    s.sendto(str(Packet(CMD.PLAY, int(dur), int((dur*1000000)%1000000), int(440.0 * 2**((pitch-69)/12.0)), ampl * options.volume, cl[2])), cl[:2])
                             if options.verbose:
                                 print (time.time() - BASETIME), cl, ': PLAY', pitch, dur, vel
-                            playing_notes[cl] = (pitch, vel*2)
+                            playing_notes[cl] = (pitch, ampl)
                             self.wait_for(dur - ((time.time() - BASETIME) - factor*ttime))
                             playing_notes[cl] = (0, 0)
                     if options.verbose:
                         print '% 6.5f'%(time.time() - BASETIME,), cl, ': DONE'
 
     threads = {}
-    nscycle = itertools.cycle(notestreams)
-    for idx, ns in zip(xrange(number), nscycle):
-        cli = routeset.Route(ns)
-        if cli:
+    if options.dry:
+        for nsid, ns in enumerate(notestreams):
             nsq = ns.findall('note')
-            if ns in threads:
-                threads[ns]._Thread__args[1].add(cli)
-            else:
-                threads[ns] = NSThread(args=(nsq, set([cli])))
+            nsq.sort(key=lambda x: float(x.get('time')))
+            threads[ns] = NSThread(args=(nsq, set()))
+            threads[ns].nsid = nsid
+        targets = threads.values()  # XXX hack
+    else:
+        nscycle = itertools.cycle(notestreams)
+        for idx, ns in zip(xrange(number), nscycle):
+            clis = routeset.Route(ns)
+            for cli in clis:
+                nsq = ns.findall('note')
+                nsq.sort(key=lambda x: float(x.get('time')))
+                if ns in threads:
+                    threads[ns]._Thread__args[1].add(cli)
+                else:
+                    threads[ns] = NSThread(args=(nsq, set([cli])))
 
     if options.verbose:
         print 'Playback threads:'
@@ -504,11 +635,31 @@ for fname in args:
             print thr._Thread__args[1]
 
     BASETIME = time.time() - (options.seek*factor)
+    ENDTIME = max(max(float(n.get('time')) + float(n.get('dur')) for n in thr._Thread__args[0]) for thr in threads.values())
+    print 'Playtime is', ENDTIME
     if options.seek > 0:
         for thr in threads.values():
             thr.drop_missed()
-    for thr in threads.values():
-            thr.start()
-    for thr in threads.values():
-            thr.join()
+    spin_phase = 0
+    SPINNERS = ['-', '\\', '|', '/']
+    while not all(thr.done for thr in threads.values()):
+        for thr in threads.values():
+            if thr.next_t is None or factor * thr.next_t <= time.time() - BASETIME:
+                thr.actuate_missed()
+        delta = factor * min(thr.next_t for thr in threads.values() if thr.next_t is not None) + BASETIME - time.time()
+        if delta == float('inf'):
+            print 'WARNING: Infinite postponement detected! Did all notestreams finish?'
+            break
+        if options.verbose:
+            print 'TICK DELTA:', delta
+        else:
+            sys.stdout.write('\x1b[G\x1b[K[%s]' % (
+                ('#' * int((time.time() - BASETIME) * (columns - 2) / (ENDTIME * factor)) + SPINNERS[spin_phase]).ljust(columns - 2),
+            ))
+            sys.stdout.flush()
+            spin_phase += 1
+            if spin_phase >= len(SPINNERS):
+                spin_phase = 0
+        if delta >= 0 and not options.spin:
+            time.sleep(delta)
     print fname, ': Done!'

+ 175 - 64
client.py

@@ -13,6 +13,7 @@ import array
 import random
 import threading
 import thread
+import colorsys
 
 from packet import Packet, CMD, stoi
 
@@ -24,34 +25,58 @@ parser.add_option('-u', '--uid', dest='uid', default='', help='Set the UID (iden
 parser.add_option('-p', '--port', dest='port', type='int', default=13676, help='Set the port to listen on')
 parser.add_option('-r', '--rate', dest='rate', type='int', default=44100, help='Set the sample rate of the audio device')
 parser.add_option('-V', '--volume', dest='volume', type='float', default=1.0, help='Set the volume factor (>1 distorts, <1 attenuates)')
+parser.add_option('-n', '--streams', dest='streams', type='int', default=1, help='Set the number of streams this client will play back')
+parser.add_option('-N', '--numpy', dest='numpy', action='store_true', help='Use numpy acceleration')
 parser.add_option('-G', '--gui', dest='gui', default='', help='set a GUI to use')
 parser.add_option('--pg-fullscreen', dest='fullscreen', action='store_true', help='Use a full-screen video mode')
 parser.add_option('--pg-samp-width', dest='samp_width', type='int', help='Set the width of the sample pane (by default display width / 2)')
 parser.add_option('--pg-bgr-width', dest='bgr_width', type='int', help='Set the width of the bargraph pane (by default display width / 2)')
 parser.add_option('--pg-height', dest='height', type='int', help='Set the height of the window or full-screen video mode')
+parser.add_option('--pg-no-colback', dest='no_colback', action='store_true', help='Don\'t render a colored background')
+parser.add_option('--pg-low-freq', dest='low_freq', type='int', default=40, help='Low frequency for colored background')
+parser.add_option('--pg-high-freq', dest='high_freq', type='int', default=1500, help='High frequency for colored background')
+parser.add_option('--pg-log-base', dest='log_base', type='int', default=2, help='Logarithmic base for coloring (0 to make linear)')
+parser.add_option('--counter-modulus', dest='counter_modulus', type='int', default=16, help='Number of packet events in period of the terminal color scroll on the left margin')
 
 options, args = parser.parse_args()
 
+if options.numpy:
+    import numpy
+
 PORT = options.port
-STREAMS = 1
+STREAMS = options.streams
 IDENT = 'TONE'
 UID = options.uid
 
-LAST_SAMP = 0
+LAST_SAMPS = [0] * STREAMS
 LAST_SAMPLES = []
-FREQ = 0
-PHASE = 0
+FREQS = [0] * STREAMS
+PHASES = [0] * STREAMS
 RATE = options.rate
 FPB = 64
 
 Z_SAMP = '\x00\x00\x00\x00'
 MAX = 0x7fffffff
-AMP = MAX
+AMPS = [MAX] * STREAMS
 MIN = -0x80000000
 
+EXPIRATIONS = [0] * STREAMS
+QUEUED_PCM = ''
+
 def lin_interp(frm, to, p):
     return p*to + (1-p)*frm
 
+def rgb_for_freq_amp(f, a):
+    a = max((min((a, 1.0)), 0.0))
+    pitchval = float(f - options.low_freq) / (options.high_freq - options.low_freq)
+    if options.log_base == 0:
+        try:
+            pitchval = math.log(pitchval) / math.log(options.log_base)
+        except ValueError:
+            pass
+    bgcol = colorsys.hls_to_rgb(min((1.0, max((0.0, pitchval)))), 0.5 * (a ** 2), 1.0)
+    return [int(i*255) for i in bgcol]
+
 # GUIs
 
 GUIs = {}
@@ -95,23 +120,42 @@ def pygame_notes():
     PFAC = HEIGHT / 128.0
 
     sampwin = pygame.Surface((SAMP_WIDTH, HEIGHT))
+    sampwin.set_colorkey((0, 0, 0))
     lastsy = HEIGHT / 2
+    bgrwin = pygame.Surface((BGR_WIDTH, HEIGHT))
+    bgrwin.set_colorkey((0, 0, 0))
 
     clock = pygame.time.Clock()
 
     while True:
-        if FREQ > 0:
-            try:
-                pitch = 12 * math.log(FREQ / 440.0, 2) + 69
-            except ValueError:
-                pitch = 0
+        if options.no_colback:
+            disp.fill((0, 0, 0), (0, 0, WIDTH, HEIGHT))
         else:
-            pitch = 0
-        col = [int((AMP / MAX) * 255)] * 3
-
-        disp.fill((0, 0, 0), (BGR_WIDTH, 0, SAMP_WIDTH, HEIGHT))
-        disp.scroll(-1, 0)
-        disp.fill(col, (BGR_WIDTH - 1, HEIGHT - pitch * PFAC - PFAC, 1, PFAC))
+            gap = WIDTH / STREAMS
+            for i in xrange(STREAMS):
+                FREQ = FREQS[i]
+                AMP = AMPS[i]
+                if FREQ > 0:
+                    bgcol = rgb_for_freq_amp(FREQ, float(AMP) / MAX)
+                else:
+                    bgcol = (0, 0, 0)
+                #print i, ':', pitchval
+                disp.fill(bgcol, (i*gap, 0, gap, HEIGHT))
+
+        bgrwin.scroll(-1, 0)
+        bgrwin.fill((0, 0, 0), (BGR_WIDTH - 1, 0, 1, HEIGHT))
+        for i in xrange(STREAMS):
+            FREQ = FREQS[i]
+            AMP = AMPS[i]
+            if FREQ > 0:
+                try:
+                    pitch = 12 * math.log(FREQ / 440.0, 2) + 69
+                except ValueError:
+                    pitch = 0
+            else:
+                pitch = 0
+            col = [int((AMP / MAX) * 255)] * 3
+            bgrwin.fill(col, (BGR_WIDTH - 1, HEIGHT - pitch * PFAC - PFAC, 1, PFAC))
 
         sampwin.scroll(-len(LAST_SAMPLES), 0)
         x = max(0, SAMP_WIDTH - len(LAST_SAMPLES))
@@ -132,6 +176,7 @@ def pygame_notes():
         #        break
         #if len(pts) > 2:
         #    pygame.gfxdraw.aapolygon(disp, pts, [0, 255, 0])
+        disp.blit(bgrwin, (0, 0))
         disp.blit(sampwin, (BGR_WIDTH, 0))
         pygame.display.flip()
 
@@ -272,45 +317,85 @@ if options.generators:
 #generator = square_wave
 generator = eval(options.generator)
 
-def sigalrm(sig, frm):
-    global FREQ
-    FREQ = 0
-
-def lin_seq(frm, to, cnt):
-    step = (to-frm)/float(cnt)
-    samps = [0]*cnt
-    for i in xrange(cnt):
-        p = i / float(cnt-1)
-        samps[i] = int(lin_interp(frm, to, p))
-    return samps
-
-def samps(freq, phase, cnt):
-    global RATE, AMP
-    samps = [0]*cnt
-    for i in xrange(cnt):
-        samps[i] = int(AMP * max(-1, min(1, options.volume*generator((phase + 2 * math.pi * freq * i / RATE) % (2*math.pi)))))
-    return samps, (phase + 2 * math.pi * freq * cnt / RATE) % (2*math.pi)
-
-def to_data(samps):
-    return struct.pack('i'*len(samps), *samps)
-
-def gen_data(data, frames, time, status):
-    global FREQ, PHASE, Z_SAMP, LAST_SAMP, LAST_SAMPLES
-    if FREQ == 0:
-        PHASE = 0
-        if LAST_SAMP == 0:
-            if options.gui:
-                LAST_SAMPLES.extend([0]*frames)
-            return (Z_SAMP*frames, pyaudio.paContinue)
-        fdata = lin_seq(LAST_SAMP, 0, frames)
-        if options.gui:
-            LAST_SAMPLES.extend(fdata)
-        LAST_SAMP = fdata[-1]
-        return (to_data(fdata), pyaudio.paContinue)
-    fdata, PHASE = samps(FREQ, PHASE, frames)
+#def sigalrm(sig, frm):
+#    global FREQ
+#    FREQ = 0
+
+if options.numpy:
+    def lin_seq(frm, to, cnt):
+        return numpy.linspace(frm, to, cnt, dtype=numpy.int32)
+
+    def samps(freq, amp, phase, cnt):
+        samps = numpy.ndarray((cnt,), numpy.int32)
+        pvel = 2 * math.pi * freq / RATE
+        fac = options.volume * amp / float(STREAMS)
+        for i in xrange(cnt):
+            samps[i] = fac * max(-1, min(1, generator(phase)))
+            phase = (phase + pvel) % (2 * math.pi)
+        return samps, phase
+
+    def to_data(samps):
+        return samps.tobytes()
+
+    def mix(a, b):
+        return a + b
+
+else:
+    def lin_seq(frm, to, cnt):
+        step = (to-frm)/float(cnt)
+        samps = [0]*cnt
+        for i in xrange(cnt):
+            p = i / float(cnt-1)
+            samps[i] = int(lin_interp(frm, to, p))
+        return samps
+
+    def samps(freq, amp, phase, cnt):
+        global RATE
+        samps = [0]*cnt
+        for i in xrange(cnt):
+            samps[i] = int(2*amp / float(STREAMS) * max(-1, min(1, options.volume*generator((phase + 2 * math.pi * freq * i / RATE) % (2*math.pi)))))
+        return samps, (phase + 2 * math.pi * freq * cnt / RATE) % (2*math.pi)
+
+    def to_data(samps):
+        return struct.pack('i'*len(samps), *samps)
+
+    def mix(a, b):
+        return [min(MAX, max(MIN, i + j)) for i, j in zip(a, b)]
+
+def gen_data(data, frames, tm, status):
+    global FREQS, PHASE, Z_SAMP, LAST_SAMP, LAST_SAMPLES, QUEUED_PCM
+    if len(QUEUED_PCM) >= frames*4:
+        fdata = QUEUED_PCM[:frames*4]
+        QUEUED_PCM = QUEUED_PCM[frames*4:]
+        LAST_SAMPLES.extend(struct.unpack(str(frames)+'i', fdata))
+        return fdata, pyaudio.paContinue
+    if options.numpy:
+        fdata = numpy.zeros((frames,), numpy.int32)
+    else:
+        fdata = [0] * frames
+    for i in range(STREAMS):
+        FREQ = FREQS[i]
+        LAST_SAMP = LAST_SAMPS[i]
+        AMP = AMPS[i]
+        EXPIRATION = EXPIRATIONS[i]
+        PHASE = PHASES[i]
+        if FREQ != 0:
+            if time.time() > EXPIRATION:
+                FREQ = 0
+                FREQS[i] = 0
+        if FREQ == 0:
+            PHASES[i] = 0
+            if LAST_SAMP != 0:
+                vdata = lin_seq(LAST_SAMP, 0, frames)
+                fdata = mix(fdata, vdata)
+                LAST_SAMPS[i] = vdata[-1]
+        else:
+            vdata, PHASE = samps(FREQ, AMP, PHASE, frames)
+            fdata = mix(fdata, vdata)
+            PHASES[i] = PHASE
+            LAST_SAMPS[i] = vdata[-1]
     if options.gui:
         LAST_SAMPLES.extend(fdata)
-    LAST_SAMP = fdata[-1]
     return (to_data(fdata), pyaudio.paContinue)
 
 pa = pyaudio.PyAudio()
@@ -322,21 +407,25 @@ if options.gui:
     guithread.start()
 
 if options.test:
-    FREQ = 440
+    FREQS[0] = 440
+    EXPIRATIONS[0] = time.time() + 1
     time.sleep(1)
-    FREQ = 0
+    FREQS[0] = 0
     time.sleep(1)
-    FREQ = 880
+    FREQS[0] = 880
+    EXPIRATIONS[0] = time.time() + 1
     time.sleep(1)
-    FREQ = 440
+    FREQS[0] = 440
+    EXPIRATIONS[0] = time.time() + 2
     time.sleep(2)
     exit()
 
 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
 sock.bind(('', PORT))
 
-signal.signal(signal.SIGALRM, sigalrm)
+#signal.signal(signal.SIGALRM, sigalrm)
 
+counter = 0
 while True:
     data = ''
     while not data:
@@ -345,24 +434,46 @@ while True:
         except socket.error:
             pass
     pkt = Packet.FromStr(data)
-    print 'From', cli, 'command', pkt.cmd
+    crgb = [int(i*255) for i in colorsys.hls_to_rgb((float(counter) / options.counter_modulus) % 1.0, 0.5, 1.0)]
+    print '\x1b[38;2;{};{};{}m#'.format(*crgb),
+    counter += 1
+    print '\x1b[mFrom', cli, 'command', pkt.cmd,
     if pkt.cmd == CMD.KA:
-        pass
+        print '\x1b[37mKA'
     elif pkt.cmd == CMD.PING:
         sock.sendto(data, cli)
+        print '\x1b[1;33mPING'
     elif pkt.cmd == CMD.QUIT:
+        print '\x1b[1;31mQUIT'
         break
     elif pkt.cmd == CMD.PLAY:
+        voice = pkt.data[4]
         dur = pkt.data[0]+pkt.data[1]/1000000.0
-        FREQ = pkt.data[2]
-        AMP = MAX * (pkt.data[3]/255.0)
-        signal.setitimer(signal.ITIMER_REAL, dur)
+        FREQS[voice] = pkt.data[2]
+        AMPS[voice] = MAX * max(min(pkt.as_float(3), 1.0), 0.0)
+        EXPIRATIONS[voice] = time.time() + dur
+        vrgb = [int(i*255) for i in colorsys.hls_to_rgb(float(voice) / STREAMS * 2.0 / 3.0, 0.5, 1.0)]
+        frgb = rgb_for_freq_amp(pkt.data[2], pkt.as_float(3))
+        print '\x1b[1;32mPLAY',
+        print '\x1b[1;38;2;{};{};{}mVOICE'.format(*vrgb), '{:03}'.format(voice),
+        print '\x1b[1;38;2;{};{};{}mFREQ'.format(*frgb), '{:04}'.format(pkt.data[2]), 'AMP', '%08.6f'%pkt.as_float(3),
+        if pkt.data[0] == 0 and pkt.data[1] == 0:
+            print '\x1b[1;35mSTOP!!!'
+        else:
+            print '\x1b[1;36mDUR', '%08.6f'%dur
+        #signal.setitimer(signal.ITIMER_REAL, dur)
     elif pkt.cmd == CMD.CAPS:
         data = [0] * 8
         data[0] = STREAMS
         data[1] = stoi(IDENT)
-        for i in xrange(len(UID)/4):
+        for i in xrange(len(UID)/4 + 1):
             data[i+2] = stoi(UID[4*i:4*(i+1)])
         sock.sendto(str(Packet(CMD.CAPS, *data)), cli)
+        print '\x1b[1;34mCAPS'
+    elif pkt.cmd == CMD.PCM:
+        fdata = data[4:]
+        fdata = struct.pack('16i', *[i<<16 for i in struct.unpack('16h', fdata)])
+        QUEUED_PCM += fdata
+        print 'Now', len(QUEUED_PCM) / 4.0, 'frames queued'
     else:
         print 'Unknown cmd', pkt.cmd

+ 117 - 0
downsamp.py

@@ -0,0 +1,117 @@
+from xml.etree import ElementTree as ET
+import optparse
+import os
+
+parser = optparse.OptionParser()
+parser.add_option('-f', '--frequency', dest='frequency', type='float', help='How often to switch between active streams')
+parser.set_defaults(frequency=0.016)
+options, args = parser.parse_args()
+
+class Note(object):
+    def __init__(self, time, dur, pitch, ampl):
+        self.time = time
+        self.dur = dur
+        self.pitch = pitch
+        self.ampl = ampl
+
+for fname in args:
+    try:
+        iv = ET.parse(fname).getroot()
+    except IOError:
+        import traceback
+        traceback.print_exc()
+        print fname, ': Bad file'
+        continue
+
+    print '----', fname, '----'
+
+    notestreams = iv.findall("./streams/stream[@type='ns']")
+    print len(notestreams), 'notestreams'
+
+    print 'Loading all events...'
+
+    evs = []
+
+    dur = 0.0
+
+    for ns in notestreams:
+        for note in ns.findall('note'):
+            n = Note(
+                float(note.get('time')),
+                float(note.get('dur')),
+                float(note.get('pitch')),
+                float(note.get('ampl', float(note.get('vel', 127.0)) / 127.0)),
+            )
+            evs.append(n)
+            if n.time + n.dur > dur:
+                dur = n.time + n.dur
+
+    print len(evs), 'events'
+    print dur, 'duration'
+
+    print 'Scheduling events...'
+
+    sched = {}
+
+    t = 0.0
+    i = 0
+    while t <= dur:
+        nextt = t + options.frequency
+        #print '-t', t, 'nextt', nextt
+
+        evs_now = [n for n in evs if n.time <= t and t < n.time + n.dur]
+        if evs_now:
+            holding = False
+            count = 0
+            while count < len(evs_now):
+                selidx = (count + i) % len(evs_now)
+                sel = evs_now[selidx]
+                sched[t] = (sel.pitch, sel.ampl)
+                if sel.time + sel.dur >= nextt:
+                    holding = True
+                    break
+                t = sel.time + sel.dur
+                count += 1
+            if not holding:
+                sched[t] = (0, 0)
+        else:
+            sched[t] = (0, 0)
+
+        t = nextt
+        i += 1
+
+    print len(sched), 'events scheduled'
+
+    print 'Writing out schedule...'
+
+    newiv = ET.Element('iv')
+    newiv.append(iv.find('meta'))
+    newivstreams = ET.SubElement(newiv, 'streams')
+    newivstream = ET.SubElement(newivstreams, 'stream', type='ns')
+
+    prevt = None
+    prevev = None
+    for t, ev in sorted(sched.items(), key=lambda pair: pair[0]):
+        if prevt is not None:
+            if prevev[0] != 0:
+                ET.SubElement(newivstream, 'note',
+                        pitch = str(prevev[0]),
+                        ampl = str(prevev[1]),
+                        time = str(prevt),
+                        dur = str(t - prevt),
+                )
+        prevev = ev
+        prevt = t
+
+    t = dur
+    if prevev[0] != 0:
+        ET.SubElement(newivstream, 'note',
+                pitch = str(prevev[0]),
+                ampl = str(prevev[1]),
+                time = str(prevt),
+                dur = str(t - prevt),
+        )
+
+    print 'Done.'
+    txt = ET.tostring(newiv, 'UTF-8')
+    open(os.path.splitext(os.path.basename(fname))[0]+'.downsampled.iv', 'wb').write(txt)

+ 215 - 0
drums.py

@@ -0,0 +1,215 @@
+import pyaudio
+import socket
+import optparse
+import tarfile
+import wave
+import cStringIO as StringIO
+import array
+import time
+import colorsys
+
+from packet import Packet, CMD, stoi, OBLIGATE_POLYPHONE
+
+parser = optparse.OptionParser()
+parser.add_option('-t', '--test', dest='test', action='store_true', help='As a test, play all samples then exit')
+parser.add_option('-v', '--verbose', dest='verbose', action='store_true', help='Be verbose')
+parser.add_option('-V', '--volume', dest='volume', type='float', default=1.0, help='Set the volume factor (nominally [0.0, 1.0], but >1.0 can be used to amplify with possible distortion)')
+parser.add_option('-r', '--rate', dest='rate', type='int', default=44100, help='Audio sample rate for output and of input files')
+parser.add_option('-u', '--uid', dest='uid', default='', help='User identifier of this client')
+parser.add_option('-p', '--port', dest='port', default=13676, type='int', help='UDP port to listen on')
+parser.add_option('--repeat', dest='repeat', action='store_true', help='If a note plays longer than a sample length, keep playing the sample')
+parser.add_option('--cut', dest='cut', action='store_true', help='If a note ends within a sample, stop playing that sample immediately')
+parser.add_option('-n', '--max-voices', dest='max_voices', default=-1, type='int', help='Only support this many notes playing simultaneously (earlier ones get dropped)')
+parser.add_option('--pg-low-freq', dest='low_freq', type='int', default=40, help='Low frequency for colored background')
+parser.add_option('--pg-high-freq', dest='high_freq', type='int', default=1500, help='High frequency for colored background')
+parser.add_option('--pg-log-base', dest='log_base', type='int', default=2, help='Logarithmic base for coloring (0 to make linear)')
+parser.add_option('--counter-modulus', dest='counter_modulus', type='int', default=16, help='Number of packet events in period of the terminal color scroll on the left margin')
+
+options, args = parser.parse_args()
+
+MAX = 0x7fffffff
+MIN = -0x80000000
+IDENT = 'DRUM'
+
+if not args:
+    print 'Need at least one drumpack (.tar.bz2) as an argument!'
+    parser.print_usage()
+    exit(1)
+
+def rgb_for_freq_amp(f, a):
+    pitchval = float(f - options.low_freq) / (options.high_freq - options.low_freq)
+    a = max((min((a, 1.0)), 0.0))
+    if options.log_base == 0:
+        try:
+            pitchval = math.log(pitchval) / math.log(options.log_base)
+        except ValueError:
+            pass
+    bgcol = colorsys.hls_to_rgb(min((1.0, max((0.0, pitchval)))), 0.5 * (a ** 2), 1.0)
+    return [int(i*255) for i in bgcol]
+
+DRUMS = {}
+
+for fname in args:
+    print 'Reading', fname, '...'
+    tf = tarfile.open(fname, 'r')
+    names = tf.getnames()
+    for nm in names:
+        if not (nm.endswith('.wav') or nm.endswith('.raw')) or len(nm) < 5:
+            continue
+        frq = int(nm[:-4])
+        if options.verbose:
+            print '\tLoading frq', frq, '...'
+        fo = tf.extractfile(nm)
+        if nm.endswith('.wav'):
+            wf = wave.open(fo)
+            if wf.getnchannels() != 1:
+                print '\t\tWARNING: Channel count wrong: got', wf.getnchannels(), 'expecting 1'
+            if wf.getsampwidth() != 4:
+                print '\t\tWARNING: Sample width wrong: got', wf.getsampwidth(), 'expecting 4'
+            if wf.getframerate() != options.rate:
+                print '\t\tWARNING: Rate wrong: got', wf.getframerate(), 'expecting', options.rate, '(maybe try setting -r?)'
+            frames = wf.getnframes()
+            data = ''
+            while len(data) < wf.getsampwidth() * frames:
+                data += wf.readframes(frames - len(data) / wf.getsampwidth())
+        elif nm.endswith('.raw'):
+            data = fo.read()
+            frames = len(data) / 4
+        if options.verbose:
+            print '\t\tData:', frames, 'samples,', len(data), 'bytes'
+        if frq in DRUMS:
+            print '\t\tWARNING: frequency', frq, 'already in map, overwriting...'
+        DRUMS[frq] = data
+
+if options.verbose:
+    print len(DRUMS), 'sounds loaded'
+
+PLAYING = []
+
+class SampleReader(object):
+    def __init__(self, buf, total, amp):
+        self.buf = buf
+        self.total = total
+        self.cur = 0
+        self.amp = amp
+
+    def read(self, bytes):
+        if self.cur >= self.total:
+            return ''
+        res = ''
+        while self.cur < self.total and len(res) < bytes:
+            data = self.buf[self.cur % len(self.buf):self.cur % len(self.buf) + bytes - len(res)]
+            self.cur += len(data)
+            res += data
+        arr = array.array('i')
+        arr.fromstring(res)
+        for i in range(len(arr)):
+            arr[i] = int(arr[i] * self.amp)
+        return arr.tostring()
+
+    def __repr__(self):
+        return '<SR (%d) @%d / %d A:%f>'%(len(self.buf), self.cur, self.total, self.amp)
+
+def gen_data(data, frames, tm, status):
+    fdata = array.array('l', [0] * frames)
+    torem = set()
+    for src in set(PLAYING):
+        buf = src.read(frames * 4)
+        if not buf:
+            torem.add(src)
+            continue
+        samps = array.array('i')
+        samps.fromstring(buf)
+        if len(samps) < frames:
+            samps.extend([0] * (frames - len(samps)))
+        for i in range(frames):
+            fdata[i] += samps[i]
+    for src in torem:
+        PLAYING.remove(src)
+    for i in range(frames):
+        fdata[i] = max(MIN, min(MAX, fdata[i]))
+    fdata = array.array('i', fdata)
+    return (fdata.tostring(), pyaudio.paContinue)
+
+pa = pyaudio.PyAudio()
+stream = pa.open(rate=options.rate, channels=1, format=pyaudio.paInt32, output=True, frames_per_buffer=64, stream_callback=gen_data)
+
+if options.test:
+    for frq in sorted(DRUMS.keys()):
+        print 'Current playing:', PLAYING
+        print 'Playing:', frq
+        data = DRUMS[frq]
+        PLAYING.append(SampleReader(data, len(data), options.volume))
+        time.sleep(len(data) / (4.0 * options.rate))
+    print 'Done'
+    exit()
+
+
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.bind(('', options.port))
+
+#signal.signal(signal.SIGALRM, sigalrm)
+
+counter = 0
+while True:
+    data = ''
+    while not data:
+        try:
+            data, cli = sock.recvfrom(4096)
+        except socket.error:
+            pass
+    pkt = Packet.FromStr(data)
+    crgb = [int(i*255) for i in colorsys.hls_to_rgb((float(counter) / options.counter_modulus) % 1.0, 0.5, 1.0)]
+    print '\x1b[38;2;{};{};{}m#'.format(*crgb),
+    counter += 1
+    print '\x1b[mFrom', cli, 'command', pkt.cmd,
+    if pkt.cmd == CMD.KA:
+        print '\x1b[37mKA'
+    elif pkt.cmd == CMD.PING:
+        sock.sendto(data, cli)
+        print '\x1b[1;33mPING'
+    elif pkt.cmd == CMD.QUIT:
+        print '\x1b[1;31mQUIT'
+        break
+    elif pkt.cmd == CMD.PLAY:
+        frq = pkt.data[2]
+        if frq not in DRUMS:
+            print 'WARNING: No such instrument', frq, ', ignoring...'
+            continue
+        rdata = DRUMS[frq]
+        rframes = len(rdata) / 4
+        dur = pkt.data[0]+pkt.data[1]/1000000.0
+        dframes = int(dur * options.rate)
+        if not options.repeat:
+            dframes = max(dframes, rframes)
+        if not options.cut:
+            dframes = rframes * ((dframes + rframes - 1) / rframes)
+        amp = max(min(options.volume * pkt.as_float(3), 1.0), 0.0)
+        PLAYING.append(SampleReader(rdata, dframes * 4, amp))
+        if options.max_voices >= 0:
+            while len(PLAYING) > options.max_voices:
+                PLAYING.pop(0)
+        frgb = rgb_for_freq_amp(pkt.data[2], pkt.as_float(3))
+        print '\x1b[1;32mPLAY',
+        print '\x1b[1;34mVOICE', '{:03}'.format(pkt.data[4]),
+        print '\x1b[1;38;2;{};{};{}mFREQ'.format(*frgb), '{:04}'.format(pkt.data[2]), 'AMP', '%08.6f'%pkt.as_float(3),
+        if pkt.data[0] == 0 and pkt.data[1] == 0:
+            print '\x1b[1;35mSTOP!!!'
+        else:
+            print '\x1b[1;36mDUR', '%08.6f'%dur
+        #signal.setitimer(signal.ITIMER_REAL, dur)
+    elif pkt.cmd == CMD.CAPS:
+        data = [0] * 8
+        data[0] = OBLIGATE_POLYPHONE
+        data[1] = stoi(IDENT)
+        for i in xrange(len(options.uid)/4 + 1):
+            data[i+2] = stoi(options.uid[4*i:4*(i+1)])
+        sock.sendto(str(Packet(CMD.CAPS, *data)), cli)
+        print '\x1b[1;34mCAPS'
+#    elif pkt.cmd == CMD.PCM:
+#        fdata = data[4:]
+#        fdata = struct.pack('16i', *[i<<16 for i in struct.unpack('16h', fdata)])
+#        QUEUED_PCM += fdata
+#        print 'Now', len(QUEUED_PCM) / 4.0, 'frames queued'
+    else:
+        print 'Unknown cmd', pkt.cmd

BIN
drums.tar.bz2


+ 29 - 0
make_patfile.sh

@@ -0,0 +1,29 @@
+# Convert the FreePats Drums_000 directory to ITL Chorus drums.tar.bz2
+# Note: technically, this must be run in a directory with subdirectories
+# starting with a MIDI pitch, and containing a text file with a
+# "convert_to_wav:" command that produces a .wav in that working directory.
+# sox is required to convert the audio. This handles the dirt-old options
+# for sox in the text files explicitly to support the FreePats standard.
+# The current version was checked in from the Drums_000 directory to be
+# found in the TAR at this URL:
+# http://freepats.zenvoid.org/samples/freepats/freepats-raw-samples.tar.bz2
+# Thank you again, FreePats!
+
+rm *.wav .wav *.raw .raw
+
+for i in *; do
+	if [ -d $i ]; then
+		pushd $i
+		eval `grep 'convert_to_wav' *.txt | sed -e 's/convert_to_wav: //' -e 's/-w/-b 16/' -e 's/-s/-e signed/' -e 's/-u/-e unsigned/'`
+		PITCH=`echo "$i" | sed -e 's/^\([0-9]\+\).*$/\1/g'`
+		# From broadcast.py, eval'd in Python for consistent results
+		FRQ=`echo $PITCH | python2 -c "print(int(440.0 * 2**((int(raw_input())-69)/12.0)))"`
+		echo "WRITING $FRQ.wav"
+		[ -z "$FRQ" ] && echo "!!! EMPTY FILENAME?"
+		sox *.wav -r 44100 -c 1 -e signed -b 32 -t raw ../$FRQ.raw
+		popd
+	fi
+done
+
+rm drums.tar.bz2
+tar cjf drums.tar.bz2 *.raw

+ 308 - 39
mkiv.py

@@ -4,10 +4,6 @@ mkiv -- Make Intervals
 
 This simple script (using python-midi) reads a MIDI file and makes an interval
 (.iv) file (actually XML) that contains non-overlapping notes.
-
-TODO:
--MIDI Control events
--Percussion
 '''
 
 import xml.etree.ElementTree as ET
@@ -15,6 +11,7 @@ import midi
 import sys
 import os
 import optparse
+import math
 
 TRACKS = object()
 PROGRAMS = object()
@@ -32,8 +29,24 @@ parser.add_option('-f', '--fuckit', dest='fuckit', action='store_true', help='Us
 parser.add_option('-v', '--verbose', dest='verbose', action='store_true', help='Be verbose; show important parts about the MIDI scheduling process')
 parser.add_option('-d', '--debug', dest='debug', action='store_true', help='Debugging output; show excessive output about the MIDI scheduling process (please use less or write to a file)')
 parser.add_option('-D', '--deviation', dest='deviation', type='int', help='Amount (in semitones/MIDI pitch units) by which a fully deflected pitchbend modifies the base pitch (0 disables pitchbend processing)')
+parser.add_option('-M', '--modwheel-freq-dev', dest='modfdev', type='float', help='Amount (in semitones/MIDI pitch unites) by which a fully-activated modwheel modifies the base pitch')
+parser.add_option('--modwheel-freq-freq', dest='modffreq', type='float', help='Frequency of modulation periods (sinusoids) of the modwheel acting on the base pitch')
+parser.add_option('--modwheel-amp-dev', dest='modadev', type='float', help='Deviation [0, 1] by which a fully-activated modwheel affects the amplitude as a factor of that amplitude')
+parser.add_option('--modwheel-amp-freq', dest='modafreq', type='float', help='Frequency of modulation periods (sinusoids) of the modwheel acting on amplitude')
+parser.add_option('--modwheel-res', dest='modres', type='float', help='(Fractional) seconds by which to resolve modwheel events (0 to disable)')
+parser.add_option('--modwheel-continuous', dest='modcont', action='store_true', help='Keep phase continuous in global time (don\'t reset to 0 for each note)')
+parser.add_option('--string-res', dest='stringres', type='float', help='(Fractional) seconds by which to resolve string models (0 to disable)')
+parser.add_option('--string-max', dest='stringmax', type='int', help='Maximum number of events to generate per single input event')
+parser.add_option('--string-rate-on', dest='stringonrate', type='float', help='Rate (amplitude / sec) by which to exponentially decay in the string model while a note is active')
+parser.add_option('--string-rate-off', dest='stringoffrate', type='float', help='Rate (amplitude / sec) by which to exponentially decay in the string model after a note ends')
+parser.add_option('--string-threshold', dest='stringthres', type='float', help='Amplitude (as fraction of original) at which point the string model event is terminated')
 parser.add_option('--tempo', dest='tempo', help='Adjust interpretation of tempo (try "f1"/"global", "f2"/"track")')
-parser.set_defaults(tracks=[], perc='GM', deviation=2, tempo='global')
+parser.add_option('--epsilon', dest='epsilon', type='float', help='Don\'t consider overlaps smaller than this number of seconds (which regularly happen due to precision loss)')
+parser.add_option('--slack', dest='slack', type='float', help='Inflate the duration of events by this much when scheduling them--this is for clients which need time to release their streams')
+parser.add_option('--vol-pow', dest='vol_pow', type='float', help='Exponent to raise volume changes (adjusts energy per delta volume)')
+parser.add_option('-0', '--keep-empty', dest='keepempty', action='store_true', help='Keep (do not cull) events with 0 duration in the output file')
+parser.add_option('--no-text', dest='no_text', action='store_true', help='Disable text streams (useful for unusual text encodings)')
+parser.set_defaults(tracks=[], perc='GM', deviation=2, tempo='global', modres=0.005, modfdev=2.0, modffreq=8.0, modadev=0.5, modafreq=8.0, stringres=0, stringmax=1024, stringrateon=0.7, stringrateoff=0.4, stringthres=0.02, epsilon=1e-12, slack=0.0, vol_pow=2)
 options, args = parser.parse_args()
 if options.tempo == 'f1':
     options.tempo == 'global'
@@ -49,6 +62,7 @@ The "ev" object will be a MergeEvent with the following properties:
 -ev.abstime: the real time in seconds of this event relative to the beginning of playback
 -ev.bank: the selected bank (all bits)
 -ev.prog: the selected program
+-ev.mw: the modwheel value
 -ev.ev: a midi.NoteOnEvent:
     -ev.ev.pitch: the MIDI pitch
     -ev.ev.velocity: the MIDI velocity
@@ -214,25 +228,31 @@ for fname in args:
         return rt
 
     class MergeEvent(object):
-        __slots__ = ['ev', 'tidx', 'abstime', 'bank', 'prog']
-        def __init__(self, ev, tidx, abstime, bank, prog):
+        __slots__ = ['ev', 'tidx', 'abstime', 'bank', 'prog', 'mw']
+        def __init__(self, ev, tidx, abstime, bank=0, prog=0, mw=0):
             self.ev = ev
             self.tidx = tidx
             self.abstime = abstime
             self.bank = bank
             self.prog = prog
+            self.mw = mw
         def copy(self, **kwargs):
-            args = {'ev': self.ev, 'tidx': self.tidx, 'abstime': self.abstime, 'bank': self.bank, 'prog': self.prog}
+            args = {'ev': self.ev, 'tidx': self.tidx, 'abstime': self.abstime, 'bank': self.bank, 'prog': self.prog, 'mw': self.mw}
             args.update(kwargs)
             return MergeEvent(**args)
         def __repr__(self):
-            return '<ME %r in %d on (%d:%d) @%f>'%(self.ev, self.tidx, self.bank, self.prog, self.abstime)
+            return '<ME %r in %d on (%d:%d) MW:%d @%f>'%(self.ev, self.tidx, self.bank, self.prog, self.mw, self.abstime)
+
+    vol_at = [[{0: 0x3FFF} for i in range(16)] for j in range(len(pat))]
 
     events = []
+    cur_mw = [[0 for i in range(16)] for j in range(len(pat))]
     cur_bank = [[0 for i in range(16)] for j in range(len(pat))]
     cur_prog = [[0 for i in range(16)] for j in range(len(pat))]
+    chg_mw = [[0 for i in range(16)] for j in range(len(pat))]
     chg_bank = [[0 for i in range(16)] for j in range(len(pat))]
     chg_prog = [[0 for i in range(16)] for j in range(len(pat))]
+    chg_vol = [[0 for i in range(16)] for j in range(len(pat))]
     ev_cnts = [[0 for i in range(16)] for j in range(len(pat))]
     tnames = [''] * len(pat)
     progs = set([0])
@@ -253,25 +273,40 @@ for fname in args:
                 progs.add(ev.value)
                 chg_prog[tidx][ev.channel] += 1
             elif isinstance(ev, midi.ControlChangeEvent):
-                if ev.control == 0:
-                    cur_bank[tidx][ev.channel] = (0x3F80 & cur_bank[tidx][ev.channel]) | ev.value
-                    chg_bank[tidx][ev.channel] += 1
-                elif ev.control == 32:
+                if ev.control == 0:  # Bank -- MSB
                     cur_bank[tidx][ev.channel] = (0x3F & cur_bank[tidx][ev.channel]) | (ev.value << 7)
                     chg_bank[tidx][ev.channel] += 1
+                elif ev.control == 32:  # Bank -- LSB
+                    cur_bank[tidx][ev.channel] = (0x3F80 & cur_bank[tidx][ev.channel]) | ev.value
+                    chg_bank[tidx][ev.channel] += 1
+                elif ev.control == 1:  # ModWheel -- MSB
+                    cur_mw[tidx][ev.channel] = (0x3F & cur_mw[tidx][ev.channel]) | (ev.value << 7)
+                    chg_mw[tidx][ev.channel] += 1
+                elif ev.control == 33:  # ModWheel -- LSB
+                    cur_mw[tidx][ev.channel] = (0x3F80 & cur_mw[tidx][ev.channel]) | ev.value
+                    chg_mw[tidx][ev.channel] += 1
+                elif ev.control == 7:  # Volume -- MSB
+                    lvtime, lvol = sorted(vol_at[tidx][ev.channel].items(), key = lambda pair: pair[0])[-1]
+                    vol_at[tidx][ev.channel][abstime] = (0x3F & lvol) | (ev.value << 7)
+                    chg_vol[tidx][ev.channel] += 1
+                elif ev.control == 39:  # Volume -- LSB
+                    lvtime, lvol = sorted(vol_at[tidx][ev.channel].items(), key = lambda pair: pair[0])[-1]
+                    vol_at[tidx][ev.channel][abstime] = (0x3F80 & lvol) | ev.value
+                    chg_vol[tidx][ev.channel] += 1
+                events.append(MergeEvent(ev, tidx, abstime, cur_bank[tidx][ev.channel], cur_prog[tidx][ev.channel], cur_mw[tidx][ev.channel]))
+                ev_cnts[tidx][ev.channel] += 1
             elif isinstance(ev, midi.MetaEventWithText):
-                events.append(MergeEvent(ev, tidx, abstime, 0, 0))
+                events.append(MergeEvent(ev, tidx, abstime))
             elif isinstance(ev, midi.Event):
                 if isinstance(ev, midi.NoteOnEvent) and ev.velocity == 0:
                     ev.__class__ = midi.NoteOffEvent #XXX Oww
-                events.append(MergeEvent(ev, tidx, abstime, cur_bank[tidx][ev.channel], cur_prog[tidx][ev.channel]))
+                events.append(MergeEvent(ev, tidx, abstime, cur_bank[tidx][ev.channel], cur_prog[tidx][ev.channel], cur_mw[tidx][ev.channel]))
                 ev_cnts[tidx][ev.channel] += 1
 
-    if options.verbose:
-        print 'Track name, event count, final banks, bank changes, final programs, program changes:'
-        for tidx, tname in enumerate(tnames):
-            print tidx, ':', tname, ',', ','.join(map(str, ev_cnts[tidx])), ',', ','.join(map(str, cur_bank[tidx])), ',', ','.join(map(str, chg_bank[tidx])), ',', ','.join(map(str, cur_prog[tidx])), ',', ','.join(map(str, chg_prog[tidx]))
-        print 'All programs observed:', progs
+    print 'Track name, event count, final banks, bank changes, final programs, program changes, final modwheel, modwheel changes, volume changes:'
+    for tidx, tname in enumerate(tnames):
+        print tidx, ':', tname, ',', ','.join(map(str, ev_cnts[tidx])), ',', ','.join(map(str, cur_bank[tidx])), ',', ','.join(map(str, chg_bank[tidx])), ',', ','.join(map(str, cur_prog[tidx])), ',', ','.join(map(str, chg_prog[tidx])), ',', ','.join(map(str, cur_mw[tidx])), ',', ','.join(map(str, chg_mw[tidx])), ',', ','.join(map(str, chg_vol[tidx]))
+    print 'All programs observed:', progs
 
     print 'Sorting events...'
 
@@ -281,29 +316,39 @@ for fname in args:
     print 'Generating streams...'
 
     class DurationEvent(MergeEvent):
-        __slots__ = ['duration', 'pitch']
-        def __init__(self, me, pitch, dur):
-            MergeEvent.__init__(self, me.ev, me.tidx, me.abstime, me.bank, me.prog)
+        __slots__ = ['duration', 'real_duration', 'pitch', 'modwheel', 'ampl']
+        def __init__(self, me, pitch, ampl, dur, modwheel=0):
+            MergeEvent.__init__(self, me.ev, me.tidx, me.abstime, me.bank, me.prog, me.mw)
             self.pitch = pitch
+            self.ampl = ampl
             self.duration = dur
+            self.real_duration = dur
+            self.modwheel = modwheel
+
+        def __repr__(self):
+            return '<NE %s P:%f A:%f D:%f W:%f>'%(MergeEvent.__repr__(self), self.pitch, self.ampl, self.duration, self.modwheel)
 
     class NoteStream(object):
-        __slots__ = ['history', 'active', 'realpitch']
+        __slots__ = ['history', 'active', 'bentpitch', 'modwheel']
         def __init__(self):
             self.history = []
             self.active = None
-            self.realpitch = None
+            self.bentpitch = None
+            self.modwheel = 0
         def IsActive(self):
             return self.active is not None
-        def Activate(self, mev, realpitch = None):
-            if realpitch is None:
-                realpitch = mev.ev.pitch
+        def Activate(self, mev, bentpitch=None, modwheel=None):
+            if bentpitch is None:
+                bentpitch = mev.ev.pitch
             self.active = mev
-            self.realpitch = realpitch
+            self.bentpitch = bentpitch
+            if modwheel is not None:
+                self.modwheel = modwheel
         def Deactivate(self, mev):
-            self.history.append(DurationEvent(self.active, self.realpitch, mev.abstime - self.active.abstime))
+            self.history.append(DurationEvent(self.active, self.bentpitch, self.active.ev.velocity / 127.0, mev.abstime - self.active.abstime, self.modwheel))
             self.active = None
-            self.realpitch = None
+            self.bentpitch = None
+            self.modwheel = 0
         def WouldDeactivate(self, mev):
             if not self.IsActive():
                 return False
@@ -311,6 +356,8 @@ for fname in args:
                 return mev.ev.pitch == self.active.ev.pitch and mev.tidx == self.active.tidx and mev.ev.channel == self.active.ev.channel
             if isinstance(mev.ev, midi.PitchWheelEvent):
                 return mev.tidx == self.active.tidx and mev.ev.channel == self.active.ev.channel
+            if isinstance(mev.ev, midi.ControlChangeEvent):
+                return mev.tidx == self.active.tidx and mev.ev.channel == self.active.ev.channel
             raise TypeError('Tried to deactivate with bad type %r'%(type(mev.ev),))
 
     class NSGroup(object):
@@ -409,6 +456,23 @@ for fname in args:
                         print '    Group %r:'%(group.name,)
                         for stream in group.streams:
                             print '      Stream: %r'%(stream.active,)
+        elif options.modres > 0 and isinstance(mev.ev, midi.ControlChangeEvent):
+            found = False
+            for group in notegroups:
+                for stream in group.streams:
+                    if stream.WouldDeactivate(mev):
+                        base = stream.active.copy(abstime=mev.abstime)
+                        stream.Deactivate(mev)
+                        stream.Activate(base, stream.bentpitch, mev.mw)
+                        found = True
+            if not found:
+                print 'WARNING: Did not find any matching active streams for %r'%(mev,)
+                if options.verbose:
+                    print '  Current state:'
+                    for group in notegroups:
+                        print '    Group %r:'%(group.name,)
+                        for stream in group.streams:
+                            print '      Stream: %r'%(stream.active,)
         else:
             auxstream.append(mev)
 
@@ -418,13 +482,207 @@ for fname in args:
         for ns in group.streams:
             if ns.IsActive():
                 print 'WARNING: Active notes at end of playback.'
-                ns.Deactivate(MergeEvent(ns.active, ns.active.tidx, lastabstime, 0, 0))
+                ns.Deactivate(MergeEvent(ns.active, ns.active.tidx, lastabstime))
+
+    if options.slack > 0:
+        print 'Adding slack time...'
+
+        slack_evs = []
+        for group in notegroups:
+            for ns in group.streams:
+                for dev in ns.history:
+                    dev.duration += options.slack
+                    slack_evs.append(dev)
+
+        print 'Resorting all streams...'
+        for group in notegroups:
+            group.streams = []
+
+        for dev in slack_evs:
+            for group in notegroups:
+                if not group.filter(dev):
+                    continue
+                for ns in group.streams:
+                    if dev.abstime >= ns.history[-1].abstime + ns.history[-1].duration:
+                        ns.history.append(dev)
+                        break
+                else:
+                    group.streams.append(NoteStream())
+                    group.streams[-1].history.append(dev)
+                break
+            else:
+                print 'WARNING: No stream accepts event', dev
+
+    if options.modres > 0:
+        print 'Resolving modwheel events...'
+        ev_cnt = 0
+        for group in notegroups:
+            for ns in group.streams:
+                i = 0
+                while i < len(ns.history):
+                    dev = ns.history[i]
+                    if dev.modwheel > 0:
+                        realpitch = dev.pitch
+                        realamp = dev.ampl
+                        mwamp = float(dev.modwheel) / 0x3FFF
+                        dt = 0.0
+                        origtime = dev.abstime
+                        events = []
+                        while dt < dev.duration:
+                            dev.abstime = origtime + dt
+                            if options.modcont:
+                                t = origtime
+                            else:
+                                t = dt
+                            events.append(DurationEvent(dev, realpitch + mwamp * options.modfdev * math.sin(2 * math.pi * options.modffreq * t), realamp + mwamp * options.modadev * (math.sin(2 * math.pi * options.modafreq * t) - 1.0) / 2.0, min(options.modres, dev.duration - dt), dev.modwheel))
+                            dt += options.modres
+                        ns.history[i:i+1] = events
+                        i += len(events)
+                        ev_cnt += len(events)
+                        if options.verbose:
+                            print 'Event', i, 'note', dev, 'in group', group.name, 'resolved to', len(events), 'events'
+                            if options.debug:
+                                for ev in events:
+                                    print '\t', ev
+                    else:
+                        i += 1
+        print '...resolved', ev_cnt, 'events'
+
+    if options.stringres:
+        print 'Resolving string models...'
+        st_cnt = sum(sum(len(ns.history) for ns in group.streams) for group in notegroups)
+        in_cnt = 0
+        ex_cnt = 0
+        ev_cnt = 0
+        dev_grps = []
+        for group in notegroups:
+            for ns in group.streams:
+                i = 0
+                while i < len(ns.history):
+                    dev = ns.history[i]
+                    ntime = float('inf')
+                    if i + 1 < len(ns.history):
+                        ntime = ns.history[i+1].abstime
+                    dt = 0.0
+                    ampf = 1.0
+                    origtime = dev.abstime
+                    events = []
+                    while dt < dev.duration and ampf * dev.ampl >= options.stringthres:
+                        dev.abstime = origtime + dt
+                        events.append(DurationEvent(dev, dev.pitch, ampf * dev.ampl, min(options.stringres, dev.duration - dt), dev.modwheel))
+                        if len(events) > options.stringmax:
+                            print 'WARNING: Exceeded maximum string model events for event', i
+                            if options.verbose:
+                                print 'Final ampf', ampf, 'dt', dt
+                            break
+                        ampf *= options.stringrateon ** options.stringres
+                        dt += options.stringres
+                        in_cnt += 1
+                    dt = dev.duration
+                    while ampf * dev.ampl >= options.stringthres:
+                        dev.abstime = origtime + dt
+                        events.append(DurationEvent(dev, dev.pitch, ampf * dev.ampl, options.stringres, dev.modwheel))
+                        if len(events) > options.stringmax:
+                            print 'WARNING: Exceeded maximum string model events for event', i
+                            if options.verbose:
+                                print 'Final ampf', ampf, 'dt', dt
+                            break
+                        ampf *= options.stringrateoff ** options.stringres
+                        dt += options.stringres
+                        ex_cnt += 1
+                    if events:
+                        for j in xrange(len(events) - 1):
+                            cur, next = events[j], events[j + 1]
+                            if abs(cur.abstime + cur.duration - next.abstime) > options.epsilon:
+                                print 'WARNING: String model events cur: ', cur, 'next:', next, 'have gap/overrun of', next.abstime - (cur.abstime + cur.duration)
+                        dev_grps.append(events)
+                    else:
+                        print 'WARNING: Event', i, 'note', dev, ': No events?'
+                    if options.verbose:
+                        print 'Event', i, 'note', dev, 'in group', group.name, 'resolved to', len(events), 'events'
+                        if options.debug:
+                            for ev in events:
+                                print '\t', ev
+                    i += 1
+                    ev_cnt += len(events)
+        print '...resolved', ev_cnt, 'events (+', ev_cnt - st_cnt, ',', in_cnt, 'inside', ex_cnt, 'extra), resorting streams...'
+        for group in notegroups:
+            group.streams = []
+
+        dev_grps.sort(key = lambda evg: evg[0].abstime)
+        for devgr in dev_grps:
+            dev = devgr[0]
+            for group in notegroups:
+                if group.filter(dev):
+                    grp = group
+                    break
+            else:
+                grp = NSGroup()
+                notegroups.append(grp)
+            for ns in grp.streams:
+                if not ns.history:
+                    ns.history.extend(devgr)
+                    break
+                last = ns.history[-1]
+                if dev.abstime >= last.abstime + last.duration - 1e-3:
+                    ns.history.extend(devgr)
+                    break
+            else:
+                ns = NoteStream()
+                grp.streams.append(ns)
+                ns.history.extend(devgr)
+        scnt = 0
+        for group in notegroups:
+            for ns in group.streams:
+                scnt += 1
+        print 'Final sort:', len(notegroups), 'groups with', scnt, 'streams'
+
+    if not options.keepempty:
+        print 'Culling empty events...'
+        ev_cnt = 0
+        for group in notegroups:
+            for ns in group.streams:
+                i = 0
+                while i < len(ns.history):
+                    if ns.history[i].duration == 0.0:
+                        del ns.history[i]
+                        ev_cnt += 1
+                    else:
+                        i += 1
+        print '...culled', ev_cnt, 'events'
 
     if options.verbose:
         print 'Final group mappings:'
         for group in notegroups:
             print ('<anonymous>' if group.name is None else group.name), '<=', '(', len(group.streams), 'streams)'
 
+    print 'Final volume resolution...'
+    for group in notegroups:
+        for ns in group.streams:
+            for ev in ns.history:
+                t, vol = sorted(filter(lambda pair: pair[0] <= ev.abstime, vol_at[ev.tidx][ev.ev.channel].items()), key=lambda pair: pair[0])[-1]
+                ev.ampl *= (float(vol) / 0x3FFF) ** options.vol_pow
+
+    print 'Checking consistency...'
+    for group in notegroups:
+        if options.verbose:
+            print 'Group', '<None>' if group.name is None else group.name, 'with', len(group.streams), 'streams...',
+        ecnt = 0
+        for ns in group.streams:
+            for i in xrange(len(ns.history) - 1):
+                cur, next = ns.history[i], ns.history[i + 1]
+                if cur.abstime + cur.duration > next.abstime + options.epsilon:
+                    print 'WARNING: event', i, 'collides with next event (@', cur.abstime, '+', cur.duration, 'next @', next.abstime, ';', next.abstime - (cur.abstime + cur.duration), 'overlap)'
+                    ecnt += 1
+                if cur.abstime > next.abstime:
+                    print 'WARNING: event', i + 1, 'out of sort order (@', cur.abstime, 'next @', next.abstime, ';', cur.abstime - next.abstime, 'underlap)'
+                    ecnt += 1
+        if options.verbose:
+            if ecnt > 0:
+                print '...', ecnt, 'errors occured'
+            else:
+                print 'ok'
+
     print 'Generated %d streams in %d groups'%(sum(map(lambda x: len(x.streams), notegroups)), len(notegroups))
     print 'Playtime:', lastabstime, 'seconds'
 
@@ -455,13 +713,20 @@ for fname in args:
                     for note in ns.history:
                             ivnote = ET.SubElement(ivns, 'note')
                             ivnote.set('pitch', str(note.pitch))
-                            ivnote.set('vel', str(note.ev.velocity))
+                            ivnote.set('vel', str(int(note.ampl * 127.0)))
+                            ivnote.set('ampl', str(note.ampl))
                             ivnote.set('time', str(note.abstime))
-                            ivnote.set('dur', str(note.duration))
-
-    ivtext = ET.SubElement(ivstreams, 'stream', type='text')
-    for tev in textstream:
-        ivev = ET.SubElement(ivtext, 'text', time=str(tev.abstime), type=type(tev.ev).__name__, text=tev.ev.text)
+                            ivnote.set('dur', str(note.real_duration))
+
+    if not options.no_text:
+        ivtext = ET.SubElement(ivstreams, 'stream', type='text')
+        for tev in textstream:
+            text = tev.ev.text
+            try:
+                text = text.decode('utf8')
+            except UnicodeDecodeError:
+                text = 'base64:' + text.encode('base64')
+            ivev = ET.SubElement(ivtext, 'text', time=str(tev.abstime), type=type(tev.ev).__name__, text=text)
 
     ivaux = ET.SubElement(ivstreams, 'stream')
     ivaux.set('type', 'aux')
@@ -474,5 +739,9 @@ for fname in args:
         ivev.set('time', str(mev.abstime))
         ivev.set('data', repr(fw.encode_midi_event(mev.ev)))
 
+    ivargs = ET.SubElement(ivmeta, 'args')
+    ivargs.text = ' '.join('%r' % (i,) for i in sys.argv[1:])
+
     print 'Done.'
-    open(os.path.splitext(os.path.basename(fname))[0]+'.iv', 'w').write(ET.tostring(iv))
+    txt = ET.tostring(iv, 'UTF-8')
+    open(os.path.splitext(os.path.basename(fname))[0]+'.iv', 'wb').write(txt)

+ 63 - 0
mktune.py

@@ -0,0 +1,63 @@
+from xml.etree import ElementTree as ET
+import optparse
+
+parser = optparse.OptionParser()
+parser.add_option('-t', '--tempo', dest='tempo', type='float', help='Tempo (in BPM)')
+parser.add_option('-r', '--resolution', dest='resolution', type='float', help='Approximate resolution in seconds (overrides tempo)')
+parser.add_option('-f', '--float', dest='float', action='store_true', help='Allow floating point representations on output')
+parser.add_option('-T', '--transpose', dest='transpose', type='float', help='Transpose by this many semitones')
+parser.set_defaults(tempo=60000, resolution=None, transpose=0)
+options, args = parser.parse_args()
+
+maybe_int = int
+if options.float:
+    maybe_int = float
+
+class Note(object):
+    def __init__(self, time, dur, pitch, ampl):
+        self.time = time
+        self.dur = dur
+        self.pitch = pitch
+        self.ampl = ampl
+
+if options.resolution is not None:
+    options.tempo = 60.0 / options.resolution
+
+options.tempo = maybe_int(options.tempo)
+
+def to_beats(tm):
+    return options.tempo * tm / 60.0
+
+for fname in args:
+    try:
+        iv = ET.parse(fname).getroot()
+    except IOError:
+        import traceback
+        traceback.print_exc()
+        print fname, ': Bad file'
+        continue
+
+    print options.tempo,
+
+    ns = iv.find('./streams/stream[@type="ns"]')
+    prevn = None
+    for note in ns.findall('note'):
+        n = Note(
+            float(note.get('time')),
+            float(note.get('dur')),
+            float(note.get('pitch')) + options.transpose,
+            float(note.get('ampl', float(note.get('vel', 127.0)) / 127.0)),
+        )
+        if prevn is not None:
+            rtime = to_beats(n.time - (prevn.time + prevn.dur))
+            if rtime >= 1:
+                print 0, maybe_int(rtime),
+            ntime = to_beats(prevn.dur)
+            if ntime < 1 and not options.float:
+                ntime = 1
+            print maybe_int(440.0 * 2**((prevn.pitch-69)/12.0)), maybe_int(ntime),
+        prevn = n
+    ntime = to_beats(n.dur)
+    if ntime < 1 and not options.float:
+        ntime = 1
+    print int(440.0 * 2**((n.pitch-69)/12.0)), int(ntime),

+ 9 - 4
packet.py

@@ -13,18 +13,23 @@ class Packet(object):
         def FromStr(cls, s):
             parts = struct.unpack('>9L', s)
             return cls(parts[0], *parts[1:])
+        def as_float(self, i):
+            return struct.unpack('>f', struct.pack('>L', self.data[i]))[0]
 	def __str__(self):
-		return struct.pack('>L'+('L'*len(self.data)), self.cmd, *self.data)
+		return struct.pack('>L'+(''.join('f' if isinstance(i, float) else 'L' for i in self.data)), self.cmd, *self.data)
 
 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-255), port
+	PLAY = 3 # seconds, microseconds, frequency (Hz), amplitude (0.0 - 1.0), port
         CAPS = 4 # ports, client type (1), user ident (2-7)
+        PCM = 5 # 16 samples, encoded S16_LE
 
 def itos(i):
-    return struct.pack('>L', i)
+    return struct.pack('>L', i).rstrip('\0')
 
 def stoi(s):
-    return struct.unpack('>L', s)[0]
+    return struct.unpack('>L', s.ljust(4, '\0'))[0]
+
+OBLIGATE_POLYPHONE = 0xffffffff

+ 84 - 7
shiv.py

@@ -8,6 +8,7 @@ import math
 parser = optparse.OptionParser()
 parser.add_option('-n', '--number', dest='number', action='store_true', help='Show number of tracks')
 parser.add_option('-g', '--groups', dest='groups', action='store_true', help='Show group names')
+parser.add_option('-G', '--group', dest='group', action='append', help='Only compute for this group (may be specified multiple times)')
 parser.add_option('-N', '--notes', dest='notes', action='store_true', help='Show number of notes')
 parser.add_option('-M', '--notes-stream', dest='notes_stream', action='store_true', help='Show notes per stream')
 parser.add_option('-m', '--meta', dest='meta', action='store_true', help='Show meta track information')
@@ -23,11 +24,29 @@ parser.add_option('-x', '--aux', dest='aux', action='store_true', help='Show inf
 
 parser.add_option('-a', '--almost-all', dest='almost_all', action='store_true', help='Show useful information')
 parser.add_option('-A', '--all', dest='all', action='store_true', help='Show everything')
+parser.add_option('-t', '--total', dest='total', action='store_true', help='Make cross-file totals')
 
-parser.set_defaults(height=20)
+parser.set_defaults(height=20, group=[])
 
 options, args = parser.parse_args()
 
+if not any((
+    options.number,
+    options.groups,
+    options.notes,
+    options.notes_stream,
+    options.histogram,
+    options.vel_hist,
+    options.duration,
+    options.duty_cycle,
+    options.aux,
+    options.meta,
+    options.histogram_tracks,
+    options.vel_hist_tracks,
+)):
+    print 'No computations specified! Assuming you meant --almost-all...'
+    options.almost_all = True
+
 if options.almost_all or options.all:
     options.number = True
     options.groups = True
@@ -65,6 +84,7 @@ else:
 def show_hist(values, height=None):
     if not values:
         print '{empty histogram}'
+        return
     if height is None:
         height = options.height
     xs, ys = values.keys(), values.values()
@@ -85,16 +105,29 @@ def show_hist(values, height=None):
         print COL.YELLOW + '\t   ' + ''.join([s[i] if len(s) > i else ' ' for s in xcs]) + COL.NONE
     print
 
+if options.total:
+    tot_note_cnt = 0
+    max_note_cnt = 0
+    tot_pitches = {}
+    tot_velocities = {}
+    tot_dur = 0
+    max_dur = 0
+    tot_streams = 0
+    max_streams = 0
+    tot_notestreams = 0
+    max_notestreams = 0
+    tot_groups = {}
+
 for fname in args:
+    print
+    print 'File :', fname
     try:
         iv = ET.parse(fname).getroot()
-    except IOError:
+    except Exception:
         import traceback
         traceback.print_exc()
         print 'Bad file :', fname, ', skipping...'
         continue
-    print
-    print 'File :', fname
     print '\t<computing...>'
 
     if options.meta:
@@ -115,10 +148,19 @@ for fname in args:
     streams = iv.findall('./streams/stream')
     notestreams = [s for s in streams if s.get('type') == 'ns']
     auxstreams = [s for s in streams if s.get('type') == 'aux']
+    if options.group:
+        print 'NOTE: Restricting results to groups', options.group, 'as requested'
+        notestreams = [ns for ns in notestreams if ns.get('group', '<anonymous>') in options.group]
+
     if options.number:
         print 'Stream count:'
         print '\tNotestreams:', len(notestreams)
         print '\tTotal:', len(streams)
+        if options.total:
+            tot_streams += len(streams)
+            max_streams = max(max_streams, len(streams))
+            tot_notestreams += len(notestreams)
+            max_notestreams = max(max_notestreams, len(notestreams))
 
     if not (options.groups or options.notes or options.histogram or options.histogram_tracks or options.vel_hist or options.vel_hist_tracks or options.duration or options.duty_cycle or options.aux):
         continue
@@ -128,6 +170,8 @@ for fname in args:
         for s in notestreams:
             group = s.get('group', '<anonymous>')
             groups[group] = groups.get(group, 0) + 1
+            if options.total:
+                tot_groups[group] = tot_groups.get(group, 0) + 1
         print 'Groups:'
         for name, cnt in groups.iteritems():
             print '\t{} ({} streams)'.format(name, cnt)
@@ -175,26 +219,35 @@ for fname in args:
         notes = stream.findall('note')
         for note in notes:
             pitch = float(note.get('pitch'))
-            vel = int(note.get('vel'))
+            ampl = int(127 * float(note.get('ampl', float(note.get('vel', 127.0)) / 127.0)))
             time = float(note.get('time'))
             dur = float(note.get('dur'))
             if options.notes:
                 note_cnt += 1
+                if options.total:
+                    tot_note_cnt += 1
             if options.notes_stream:
                 notes_stream[sidx] += 1
             if options.histogram:
                 pitches[pitch] = pitches.get(pitch, 0) + 1
+                if options.total:
+                    tot_pitches[pitch] = tot_pitches.get(pitch, 0) + 1
             if options.histogram_tracks:
                 pitch_tracks[sidx][pitch] = pitch_tracks[sidx].get(pitch, 0) + 1
             if options.vel_hist:
-                velocities[vel] = velocities.get(vel, 0) + 1
+                velocities[ampl] = velocities.get(ampl, 0) + 1
+                if options.total:
+                    tot_velocities[ampl] = tot_velocities.get(ampl, 0) + 1
             if options.vel_hist_tracks:
-                velocities_tracks[sidx][vel] = velocities_tracks[sidx].get(vel, 0) + 1
+                velocities_tracks[sidx][ampl] = velocities_tracks[sidx].get(ampl, 0) + 1
             if (options.duration or options.duty_cycle) and time + dur > max_dur:
                 max_dur = time + dur
             if options.duty_cycle:
                 cum_dur[sidx] += dur
 
+    if options.notes and options.total:
+        max_note_cnt = max(max_note_cnt, note_cnt)
+
     if options.histogram_tracks:
         for sidx, hist in enumerate(pitch_tracks):
             print 'Stream {} (group {}) pitch histogram:'.format(sidx, notestreams[sidx].get('group', '<anonymous>'))
@@ -219,3 +272,27 @@ for fname in args:
         show_hist(velocities)
     if options.duration:
         print 'Playing duration: {}'.format(max_dur)
+
+if options.total:
+    print 'Totals:'
+    if options.number:
+        print '\tTotal streams:', tot_streams
+        print '\tMax streams:', max_streams
+        print '\tTotal notestreams:', tot_notestreams
+        print '\tMax notestreams:', max_notestreams
+        print
+    if options.notes:
+        print '\tTotal notes:', tot_note_cnt
+        print '\tMax notes:', max_note_cnt
+        print
+    if options.groups:
+        print '\tGroups:'
+        for grp, cnt in tot_groups.iteritems():
+            print '\t\t', grp, ':', cnt
+        print
+    if options.histogram:
+        print 'Overall pitch histogram:'
+        show_hist(tot_pitches)
+    if options.vel_hist:
+        print 'Overall velocity histogram:'
+        show_hist(tot_velocities)