Browse Source

Merge branch 'beta'

Graham Northup 8 năm trước cách đây
mục cha
commit
6cba85974d
10 tập tin đã thay đổi với 1252 bổ sung và 215 xóa
  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)