Browse Source

Merge branch 'beta' into stable

Grissess 9 years ago
parent
commit
fd4e8f344b
8 changed files with 1527 additions and 206 deletions
  1. 1 0
      .gitignore
  2. 283 137
      broadcast.py
  3. 130 2
      client.py
  4. 29 0
      mkarduino.py
  5. 233 67
      mkiv.py
  6. 529 0
      piano.py
  7. 221 0
      shiv.py
  8. 101 0
      voice.py

+ 1 - 0
.gitignore

@@ -4,4 +4,5 @@ client
 *.swp
 *.swp
 *.swo
 *.swo
 *.pyc
 *.pyc
+*.mid
 *~
 *~

+ 283 - 137
broadcast.py

@@ -4,13 +4,16 @@ import struct
 import time
 import time
 import xml.etree.ElementTree as ET
 import xml.etree.ElementTree as ET
 import threading
 import threading
+import thread
 import optparse
 import optparse
 import random
 import random
+import itertools
 
 
 from packet import Packet, CMD, itos
 from packet import Packet, CMD, itos
 
 
 parser = optparse.OptionParser()
 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('-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('-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('--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('-R', '--random', dest='random', type='float', help='Generate random notes at approximately this period')
 parser.add_option('-R', '--random', dest='random', type='float', help='Generate random notes at approximately this period')
@@ -18,19 +21,27 @@ parser.add_option('--rand-low', dest='rand_low', type='int', help='Low frequency
 parser.add_option('--rand-high', dest='rand_high', type='int', help='High frequency to randomly sample')
 parser.add_option('--rand-high', dest='rand_high', type='int', help='High frequency to randomly sample')
 parser.add_option('-l', '--live', dest='live', help='Enter live mode (play from a controller in real time), specifying the port to connect to as "client,port"; use just "," to manually subscribe later')
 parser.add_option('-l', '--live', dest='live', help='Enter live mode (play from a controller in real time), specifying the port to connect to as "client,port"; use just "," to manually subscribe later')
 parser.add_option('-L', '--list-live', dest='list_live', action='store_true', help='List all the clients and ports that can be connected to for live performance')
 parser.add_option('-L', '--list-live', dest='list_live', action='store_true', help='List all the clients and ports that can be connected to for live performance')
+parser.add_option('--no-sustain', dest='no_sustain', action='store_true', help='Don\'t use sustain hacks in live mode')
 parser.add_option('-q', '--quit', dest='quit', action='store_true', help='Instruct all clients to quit')
 parser.add_option('-q', '--quit', dest='quit', action='store_true', help='Instruct all clients to quit')
 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', 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('-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('-D', '--duration', dest='duration', type='float', help='How long to play this note for')
-parser.add_option('-V', '--volume', dest='volume', type='int', help='How loud to play this note (0-255)')
+parser.add_option('-V', '--volume', dest='volume', type='int', help='Master volume (0-255)')
 parser.add_option('-s', '--silence', dest='silence', action='store_true', help='Instruct all clients to stop playing any active tones')
 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('-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('-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('-r', '--route', dest='routes', action='append', help='Add a routing directive (see --route-help)')
 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('-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 for clients to initially respond (delays all broadcasts)')
+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('--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('-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.add_option('--help-routes', dest='help_routes', action='store_true', help='Show help about routing directives')
-parser.set_defaults(routes=[], 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)
+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)
 options, args = parser.parse_args()
 options, args = parser.parse_args()
 
 
 if options.help_routes:
 if options.help_routes:
@@ -49,6 +60,66 @@ The syntax for that specification resembles the following:
 The specifier consists of a comma-separated list of attribute-colon-value pairs, followed by an equal sign. After this is a comma-separated list of exclusivities paired with the name of a stream group as specified in the file. The above example shows that stream groups "bass", "treble", and "beeps" will be routed to clients with UID "bass", "treble", and TYPE "BEEP" respectively. Additionally, TYPE "BEEP" will receive tracks 4 and 6 (indices 3 and 5) of the MIDI file (presumably split with -T), and that these three groups are exclusively to be routed to TYPE "BEEP" clients only (the broadcaster will drop the stream if no more are available), as opposed to the preference of the bass and treble groups, which may be routed onto other stream clients if they are available. Finally, the last route says that all "noise" UID clients should not proceed any further (receiving "null" streams) instead. Order is important; if a "noise" client already received a stream (such as "+beeps"), then it would receive that route with priority.'''
 The specifier consists of a comma-separated list of attribute-colon-value pairs, followed by an equal sign. After this is a comma-separated list of exclusivities paired with the name of a stream group as specified in the file. The above example shows that stream groups "bass", "treble", and "beeps" will be routed to clients with UID "bass", "treble", and TYPE "BEEP" respectively. Additionally, TYPE "BEEP" will receive tracks 4 and 6 (indices 3 and 5) of the MIDI file (presumably split with -T), and that these three groups are exclusively to be routed to TYPE "BEEP" clients only (the broadcaster will drop the stream if no more are available), as opposed to the preference of the bass and treble groups, which may be routed onto other stream clients if they are available. Finally, the last route says that all "noise" UID clients should not proceed any further (receiving "null" streams) instead. Order is important; if a "noise" client already received a stream (such as "+beeps"), then it would receive that route with priority.'''
     exit()
     exit()
 
 
+GUIS = {}
+
+def gui_pygame():
+    print 'Starting pygame GUI...'
+    import pygame, colorsys
+    pygame.init()
+    print 'Pygame init'
+
+    dispinfo = pygame.display.Info()
+    DISP_WIDTH = 640
+    DISP_HEIGHT = 480
+    if dispinfo.current_h > 0 and dispinfo.current_w > 0:
+        DISP_WIDTH = dispinfo.current_w
+        DISP_HEIGHT = dispinfo.current_h
+    print 'Pygame info'
+
+    WIDTH = DISP_WIDTH
+    if options.pg_width > 0:
+        WIDTH = options.pg_width
+    HEIGHT = DISP_HEIGHT
+    if options.pg_height > 0:
+        HEIGHT = options.pg_height
+
+    flags = 0
+    if options.fullscreen:
+        flags |= pygame.FULLSCREEN
+
+    disp = pygame.display.set_mode((WIDTH, HEIGHT), flags)
+    print 'Disp acquire'
+
+    PFAC = HEIGHT / 128.0
+
+    clock = pygame.time.Clock()
+
+    print 'Pygame GUI initialized, running...'
+
+    while True:
+
+        disp.scroll(-1, 0)
+        disp.fill((0, 0, 0), (WIDTH - 1, 0, 1, HEIGHT))
+        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 = [int(i*255) for i in col]
+            disp.fill(col, (WIDTH - 1, HEIGHT - pitch * PFAC - PFAC, 1, PFAC))
+            idx += 1
+        pygame.display.flip()
+
+        for ev in pygame.event.get():
+            if ev.type == pygame.KEYDOWN:
+                if ev.key == pygame.K_ESCAPE:
+                    thread.interrupt_main()
+                    pygame.quit()
+                    exit()
+
+        clock.tick(60)
+
+GUIS['pygame'] = gui_pygame
+
 PORT = 13676
 PORT = 13676
 factor = options.factor
 factor = options.factor
 
 
@@ -56,6 +127,11 @@ print 'Factor:', factor
 
 
 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
 s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
 s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+if options.bind_addr:
+    addr, _, port = options.bind_addr.partition(':')
+    if not port:
+        port = '12074'
+    s.bind((addr, int(port)))
 
 
 clients = []
 clients = []
 uid_groups = {}
 uid_groups = {}
@@ -71,6 +147,10 @@ try:
 except socket.timeout:
 except socket.timeout:
 	pass
 	pass
 
 
+playing_notes = {}
+for cli in clients:
+    playing_notes[cli] = (0, 0)
+
 print len(clients), 'detected clients'
 print len(clients), 'detected clients'
 
 
 print 'Clients:'
 print 'Clients:'
@@ -89,15 +169,21 @@ for cl in clients:
         uid_groups.setdefault(uid, []).append(cl)
         uid_groups.setdefault(uid, []).append(cl)
         type_groups.setdefault(tp, []).append(cl)
         type_groups.setdefault(tp, []).append(cl)
 	if options.test:
 	if options.test:
-		s.sendto(str(Packet(CMD.PLAY, 0, 250000, 440, options.volume)), cl)
+                ts, tms = int(options.test_delay), int(options.test_delay * 1000000) % 1000000
+		s.sendto(str(Packet(CMD.PLAY, ts, tms, 440, options.volume)), cl)
                 if not options.sync_test:
                 if not options.sync_test:
-                    time.sleep(0.25)
-                    s.sendto(str(Packet(CMD.PLAY, 0, 250000, 880, options.volume)), cl)
+                    time.sleep(options.test_delay)
+                    s.sendto(str(Packet(CMD.PLAY, ts, tms, 880, options.volume)), cl)
 	if options.quit:
 	if options.quit:
 		s.sendto(str(Packet(CMD.QUIT)), cl)
 		s.sendto(str(Packet(CMD.QUIT)), cl)
         if options.silence:
         if options.silence:
                 s.sendto(str(Packet(CMD.PLAY, 0, 1, 1, 0)), cl)
                 s.sendto(str(Packet(CMD.PLAY, 0, 1, 1, 0)), cl)
 
 
+if options.gui:
+    gui_thr = threading.Thread(target=GUIS[options.gui], args=())
+    gui_thr.setDaemon(True)
+    gui_thr.start()
+
 if options.play:
 if options.play:
     for i, val in enumerate(options.play):
     for i, val in enumerate(options.play):
         if val.startswith('@'):
         if val.startswith('@'):
@@ -123,10 +209,13 @@ if options.test or options.quit or options.silence:
 if options.random > 0:
 if options.random > 0:
     while True:
     while True:
         for cl in clients:
         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), 255)), cl)
+            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)
         time.sleep(options.random)
         time.sleep(options.random)
 
 
 if options.live or options.list_live:
 if options.live or options.list_live:
+    if options.gui:
+        print 'Waiting a second for GUI init...'
+        time.sleep(3.0)
     import midi
     import midi
     from midi import sequencer
     from midi import sequencer
     S = sequencer.S
     S = sequencer.S
@@ -142,10 +231,16 @@ if options.live or options.list_live:
     if client or port:
     if client or port:
         seq.subscribe_port(client, port)
         seq.subscribe_port(client, port)
     seq.start_sequencer()
     seq.start_sequencer()
+    if not options.gui:  # FIXME
+        seq.set_nonblock(False)
     while True:
     while True:
         ev = S.event_input(seq.client)
         ev = S.event_input(seq.client)
+        if ev is None:
+            time.sleep(0)
         event = None
         event = None
         if ev:
         if ev:
+            if options.verbose:
+                print 'SEQ:', ev
             if ev < 0:
             if ev < 0:
                 seq._error(ev)
                 seq._error(ev)
             if ev.type == S.SND_SEQ_EVENT_NOTEON:
             if ev.type == S.SND_SEQ_EVENT_NOTEON:
@@ -163,7 +258,7 @@ if options.live or options.list_live:
                 continue
                 continue
         if event is not None:
         if event is not None:
             if isinstance(event, midi.NoteOnEvent) and event.velocity == 0:
             if isinstance(event, midi.NoteOnEvent) and event.velocity == 0:
-                ev.__class__ = midi.NoteOffEvent
+                event.__class__ = midi.NoteOffEvent
             if options.verbose:
             if options.verbose:
                 print 'EVENT:', event
                 print 'EVENT:', event
             if isinstance(event, midi.NoteOnEvent):
             if isinstance(event, midi.NoteOnEvent):
@@ -177,6 +272,7 @@ if options.live or options.list_live:
                 cli = sorted(inactive_set)[0]
                 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)), 2*event.velocity)), cli)
                 active_set.setdefault(event.pitch, []).append(cli)
                 active_set.setdefault(event.pitch, []).append(cli)
+                playing_notes[cli] = (event.pitch, 2*event.velocity)
                 if options.verbose:
                 if options.verbose:
                     print 'LIVE:', event.pitch, '+ =>', active_set[event.pitch]
                     print 'LIVE:', event.pitch, '+ =>', active_set[event.pitch]
             elif isinstance(event, midi.NoteOffEvent):
             elif isinstance(event, midi.NoteOffEvent):
@@ -188,11 +284,16 @@ if options.live or options.list_live:
                     continue
                     continue
                 cli = active_set[event.pitch].pop()
                 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)
+                playing_notes[cli] = (0, 0)
                 if options.verbose:
                 if options.verbose:
                     print 'LIVE:', event.pitch, '- =>', active_set[event.pitch]
                     print 'LIVE:', event.pitch, '- =>', active_set[event.pitch]
+                    if sustain_status:
+                        print '...ignored (sustain on)'
             elif isinstance(event, midi.ControlChangeEvent):
             elif isinstance(event, midi.ControlChangeEvent):
-                if event.control == 64:
+                if event.control == 64 and not options.no_sustain:
                     sustain_status = (event.value >= 64)
                     sustain_status = (event.value >= 64)
+                    if options.verbose:
+                        print 'LIVE: SUSTAIN', ('+' if sustain_status else '-')
                     if not sustain_status:
                     if not sustain_status:
                         for pitch in deferred_set:
                         for pitch in deferred_set:
                             if pitch not in active_set or not active_set[pitch]:
                             if pitch not in active_set or not active_set[pitch]:
@@ -200,123 +301,133 @@ if options.live or options.list_live:
                                 continue
                                 continue
                             for cli in active_set[pitch]:
                             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)
+                                playing_notes[cli] = (0, 0)
                             del active_set[pitch]
                             del active_set[pitch]
                         deferred_set.clear()
                         deferred_set.clear()
 
 
-try:
-	iv = ET.parse(args[0]).getroot()
-except IOError:
+if options.repeat:
+    args = itertools.cycle(args)
+
+for fname in args:
+    try:
+        iv = ET.parse(fname).getroot()
+    except IOError:
         import traceback
         import traceback
         traceback.print_exc()
         traceback.print_exc()
-	print 'Bad file'
-	exit()
-
-notestreams = iv.findall("./streams/stream[@type='ns']")
-groups = set([ns.get('group') for ns in notestreams if 'group' in ns.keys()])
-print len(notestreams), 'notestreams'
-print len(groups), 'groups'
-
-class Route(object):
-    def __init__(self, fattr, fvalue, group, excl=False):
-        if fattr == 'U':
-            self.map = uid_groups
-        elif fattr == 'T':
-            self.map = type_groups
-        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
-    @classmethod
-    def Parse(cls, s):
-        fspecs, _, grpspecs = map(lambda x: x.strip(), s.partition('='))
-        fpairs = []
-        ret = []
-        for fspec in [i.strip() for i in fspecs.split(',')]:
-            fattr, _, fvalue = map(lambda x: x.strip(), fspec.partition(':'))
-            fpairs.append((fattr, fvalue))
-        for part in [i.strip() for i in grpspecs.split(',')]:
-            for fattr, fvalue in fpairs:
-                if part[0] == '+':
-                    ret.append(Route(fattr, fvalue, part[1:], False))
-                elif part[0] == '-':
-                    ret.append(Route(fattr, fvalue, part[1:], 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, [])
-    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
-        self.routes = []
-    def Route(self, stream):
-        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 options.verbose:
-                    print '\tMatches route', route
-                excl = excl or route.excl
-                matches = filter(lambda x, route=route: route.Apply(x), testset)
-                if matches:
+        print fname, ': Bad file'
+        continue
+
+    notestreams = iv.findall("./streams/stream[@type='ns']")
+    groups = set([ns.get('group') for ns in notestreams if 'group' in ns.keys()])
+    number = (len(notestreams) * abs(options.number) if options.number < 0 else options.number)
+    print len(notestreams), 'notestreams'
+    print len(clients), 'clients'
+    print len(groups), 'groups'
+    print number, 'clients used (number)'
+
+    class Route(object):
+        def __init__(self, fattr, fvalue, group, excl=False):
+            if fattr == 'U':
+                self.map = uid_groups
+            elif fattr == 'T':
+                self.map = type_groups
+            elif fattr == '0':
+                self.map = {}
+            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
+        @classmethod
+        def Parse(cls, s):
+            fspecs, _, grpspecs = map(lambda x: x.strip(), s.partition('='))
+            fpairs = []
+            ret = []
+            for fspec in [i.strip() for i in fspecs.split(',')]:
+                fattr, _, fvalue = map(lambda x: x.strip(), fspec.partition(':'))
+                fpairs.append((fattr, fvalue))
+            for part in [i.strip() for i in grpspecs.split(',')]:
+                for fattr, fvalue in fpairs:
+                    if part[0] == '+':
+                        ret.append(Route(fattr, fvalue, part[1:], False))
+                    elif part[0] == '-':
+                        ret.append(Route(fattr, fvalue, part[1:], 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, [])
+        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
+            self.routes = []
+        def Route(self, stream):
+            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 options.verbose:
+                        print '\tMatches route', route
+                    excl = excl or route.excl
+                    matches = filter(lambda x, route=route: route.Apply(x), testset)
+                    if matches:
+                        if options.verbose:
+                            print '\tUsing client', matches[0]
+                        self.clients.remove(matches[0])
+                        return matches[0]
                     if options.verbose:
                     if options.verbose:
-                        print '\tUsing client', matches[0]
-                    self.clients.remove(matches[0])
-                    return matches[0]
+                        print '\tNo matches, moving on...'
+                if route.group is None:
+                    if options.verbose:
+                        print 'Encountered NULL route, removing from search space...'
+                    toremove = []
+                    for cli in testset:
+                        if route.Apply(cli):
+                            toremove.append(cli)
+                    for cli in toremove:
+                        if options.verbose:
+                            print '\tRemoving', cli, '...'
+                        testset.remove(cli)
+            if excl:
                 if options.verbose:
                 if options.verbose:
-                    print '\tNo matches, moving on...'
-            if route.group is None:
+                    print '\tExclusively routed, no route matched.'
+                return None
+            if not testset:
                 if options.verbose:
                 if options.verbose:
-                    print 'Encountered NULL route, removing from search space...'
-                toremove = []
-                for cli in testset:
-                    if route.Apply(cli):
-                        toremove.append(cli)
-                for cli in toremove:
-                    if options.verbose:
-                        print '\tRemoving', cli, '...'
-                    testset.remove(cli)
-        if excl:
-            if options.verbose:
-                print '\tExclusively routed, no route matched.'
-            return None
-        if not testset:
+                    print '\tOut of clients, no route matched.'
+                return None
+            cli = testset[0]
+            self.clients.remove(cli)
             if options.verbose:
             if options.verbose:
-                print '\tOut of clients, no route matched.'
-            return None
-        cli = testset[0]
-        self.clients.remove(cli)
-        if options.verbose:
-            print '\tDefault route to', cli
-        return cli
-
-routeset = RouteSet()
-for rspec in options.routes:
-    try:
-        routeset.routes.extend(Route.Parse(rspec))
-    except Exception:
-        import traceback
-        traceback.print_exc()
+                print '\tDefault route to', cli
+            return cli
 
 
-if options.verbose:
-    print 'All routes:'
-    for route in routeset.routes:
-        print route
+    routeset = RouteSet()
+    for rspec in options.routes:
+        try:
+            routeset.routes.extend(Route.Parse(rspec))
+        except Exception:
+            import traceback
+            traceback.print_exc()
 
 
-class NSThread(threading.Thread):
+    if options.verbose:
+        print 'All routes:'
+        for route in routeset.routes:
+            print route
+
+    class NSThread(threading.Thread):
         def drop_missed(self):
         def drop_missed(self):
             nsq, cl = self._Thread__args
             nsq, cl = self._Thread__args
             cnt = 0
             cnt = 0
@@ -339,30 +450,65 @@ class NSThread(threading.Thread):
 			dur = factor*float(note.get('dur'))
 			dur = factor*float(note.get('dur'))
 			while time.time() - BASETIME < factor*ttime:
 			while time.time() - BASETIME < factor*ttime:
 				self.wait_for(factor*ttime - (time.time() - BASETIME))
 				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)), vel * 2 * (options.volume / 255.0))), cl)
+			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:
                         if options.verbose:
                             print (time.time() - BASETIME), cl, ': PLAY', pitch, dur, vel
                             print (time.time() - BASETIME), cl, ': PLAY', pitch, dur, vel
 			self.wait_for(dur - ((time.time() - BASETIME) - factor*ttime))
 			self.wait_for(dur - ((time.time() - BASETIME) - factor*ttime))
+    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:
                 if options.verbose:
-                    print '% 6.5f'%(time.time() - BASETIME,), cl, ': DONE'
-
-threads = []
-for ns in notestreams:
-    cli = routeset.Route(ns)
-    if cli:
-        nsq = ns.findall('note')
-        threads.append(NSThread(args=(nsq, cli)))
-
-if options.verbose:
-    print 'Playback threads:'
-    for thr in threads:
-        print thr._Thread__args[1]
-
-BASETIME = time.time() - (options.seek*factor)
-if options.seek > 0:
-    for thr in threads:
-        thr.drop_missed()
-for thr in threads:
-	thr.start()
-for thr in threads:
-	thr.join()
+                    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, cls = self._Thread__args
+                    for note in nsq:
+                            ttime = float(note.get('time'))
+                            pitch = float(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))
+                            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)
+                            if options.verbose:
+                                print (time.time() - BASETIME), cl, ': PLAY', pitch, dur, vel
+                            playing_notes[cl] = (pitch, vel*2)
+                            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:
+            nsq = ns.findall('note')
+            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:'
+        for thr in threads.values():
+            print thr._Thread__args[1]
+
+    BASETIME = time.time() - (options.seek*factor)
+    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()
+    print fname, ': Done!'

+ 130 - 2
client.py

@@ -11,6 +11,8 @@ import socket
 import optparse
 import optparse
 import array
 import array
 import random
 import random
+import threading
+import thread
 
 
 from packet import Packet, CMD, stoi
 from packet import Packet, CMD, stoi
 
 
@@ -21,6 +23,12 @@ parser.add_option('--generators', dest='generators', action='store_true', help='
 parser.add_option('-u', '--uid', dest='uid', default='', help='Set the UID (identifier) of this client in the network')
 parser.add_option('-u', '--uid', dest='uid', default='', help='Set the UID (identifier) of this client in the network')
 parser.add_option('-p', '--port', dest='port', type='int', default=13676, help='Set the port to listen on')
 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('-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('-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')
 
 
 options, args = parser.parse_args()
 options, args = parser.parse_args()
 
 
@@ -30,6 +38,7 @@ IDENT = 'TONE'
 UID = options.uid
 UID = options.uid
 
 
 LAST_SAMP = 0
 LAST_SAMP = 0
+LAST_SAMPLES = []
 FREQ = 0
 FREQ = 0
 PHASE = 0
 PHASE = 0
 RATE = options.rate
 RATE = options.rate
@@ -43,6 +52,102 @@ MIN = -0x80000000
 def lin_interp(frm, to, p):
 def lin_interp(frm, to, p):
     return p*to + (1-p)*frm
     return p*to + (1-p)*frm
 
 
+# GUIs
+
+GUIs = {}
+
+def GUI(f):
+    GUIs[f.__name__] = f
+    return f
+
+@GUI
+def pygame_notes():
+    import pygame
+    import pygame.gfxdraw
+    pygame.init()
+
+    dispinfo = pygame.display.Info()
+    DISP_WIDTH = 640
+    DISP_HEIGHT = 480
+    if dispinfo.current_h > 0 and dispinfo.current_w > 0:
+        DISP_WIDTH = dispinfo.current_w
+        DISP_HEIGHT = dispinfo.current_h
+
+    SAMP_WIDTH = DISP_WIDTH / 2
+    if options.samp_width > 0:
+        SAMP_WIDTH = options.samp_width
+    BGR_WIDTH = DISP_WIDTH / 2
+    if options.bgr_width > 0:
+        BGR_WIDTH = options.bgr_width
+    HEIGHT = DISP_HEIGHT
+    if options.height > 0:
+        HEIGHT = options.height
+
+    flags = 0
+    if options.fullscreen:
+        flags |= pygame.FULLSCREEN
+
+    disp = pygame.display.set_mode((SAMP_WIDTH + BGR_WIDTH, HEIGHT), flags)
+
+    WIDTH, HEIGHT = disp.get_size()
+    SAMP_WIDTH = WIDTH / 2
+    BGR_WIDTH = WIDTH - SAMP_WIDTH
+    PFAC = HEIGHT / 128.0
+
+    sampwin = pygame.Surface((SAMP_WIDTH, HEIGHT))
+    lastsy = HEIGHT / 2
+
+    clock = pygame.time.Clock()
+
+    while True:
+        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
+
+        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))
+
+        sampwin.scroll(-len(LAST_SAMPLES), 0)
+        x = max(0, SAMP_WIDTH - len(LAST_SAMPLES))
+        sampwin.fill((0, 0, 0), (x, 0, SAMP_WIDTH - x, HEIGHT))
+        for i in LAST_SAMPLES:
+            sy = int((float(i) / MAX) * (HEIGHT / 2) + (HEIGHT / 2))
+            pygame.gfxdraw.line(sampwin, x - 1, lastsy, x, sy, (0, 255, 0))
+            x += 1
+            lastsy = sy
+        del LAST_SAMPLES[:]
+        #w, h = SAMP_WIDTH, HEIGHT
+        #pts = [(BGR_WIDTH, HEIGHT / 2), (w + BGR_WIDTH, HEIGHT / 2)]
+        #x = w + BGR_WIDTH
+        #for i in reversed(LAST_SAMPLES):
+        #    pts.insert(1, (x, int((h / 2) + (float(i) / MAX) * (h / 2))))
+        #    x -= 1
+        #    if x < BGR_WIDTH:
+        #        break
+        #if len(pts) > 2:
+        #    pygame.gfxdraw.aapolygon(disp, pts, [0, 255, 0])
+        disp.blit(sampwin, (BGR_WIDTH, 0))
+        pygame.display.flip()
+
+        for ev in pygame.event.get():
+            if ev.type == pygame.KEYDOWN:
+                if ev.key == pygame.K_ESCAPE:
+                    thread.interrupt_main()
+                    pygame.quit()
+                    exit()
+            elif ev.type == pygame.QUIT:
+                thread.interrupt_main()
+                pygame.quit()
+                exit()
+
+        clock.tick(60)
+
 # Generator functions--should be cyclic within [0, 2*math.pi) and return [-1, 1]
 # Generator functions--should be cyclic within [0, 2*math.pi) and return [-1, 1]
 
 
 GENERATORS = [{'name': 'math.sin', 'args': None, 'desc': 'Sine function'},
 GENERATORS = [{'name': 'math.sin', 'args': None, 'desc': 'Sine function'},
@@ -65,6 +170,10 @@ def tri_wave(theta):
     else:
     else:
         return lin_interp(-1, 0, (theta-3*math.pi/2)/(math.pi/2))
         return lin_interp(-1, 0, (theta-3*math.pi/2)/(math.pi/2))
 
 
+@generator('Saw wave (line from (0, 1) to (2pi, -1))')
+def saw_wave(theta):
+    return lin_interp(1, -1, theta/(math.pi * 2))
+
 @generator('Simple square wave (piecewise 1 at x<pi, 0 else)')
 @generator('Simple square wave (piecewise 1 at x<pi, 0 else)')
 def square_wave(theta):
 def square_wave(theta):
     if theta < math.pi:
     if theta < math.pi:
@@ -113,6 +222,14 @@ class harmonic(object):
     def __call__(self, theta):
     def __call__(self, theta):
         return max(-1, min(1, sum([amp*self.gen((i+1)*theta % (2*math.pi)) for i, amp in enumerate(self.spectrum)])))
         return max(-1, min(1, sum([amp*self.gen((i+1)*theta % (2*math.pi)) for i, amp in enumerate(self.spectrum)])))
 
 
+@generator('General harmonics generator (adds arbitrary overtones)', '(<generator>, <factor of f>, <amplitude>, <factor>, <amplitude>, ...)')
+class genharmonic(object):
+    def __init__(self, gen, *harmonics):
+        self.gen = gen
+        self.harmonics = zip(harmonics[::2], harmonics[1::2])
+    def __call__(self, theta):
+        return max(-1, min(1, sum([amp * self.gen(i * theta % (2*math.pi)) for i, amp in self.harmonics])))
+
 @generator('Mix generator', '(<generator>[, <amp>], [<generator>[, <amp>], [...]])')
 @generator('Mix generator', '(<generator>[, <amp>], [<generator>[, <amp>], [...]])')
 class mixer(object):
 class mixer(object):
     def __init__(self, *specs):
     def __init__(self, *specs):
@@ -171,28 +288,39 @@ def samps(freq, phase, cnt):
     global RATE, AMP
     global RATE, AMP
     samps = [0]*cnt
     samps = [0]*cnt
     for i in xrange(cnt):
     for i in xrange(cnt):
-        samps[i] = int(AMP * generator((phase + 2 * math.pi * freq * i / RATE) % (2*math.pi)))
+        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)
     return samps, (phase + 2 * math.pi * freq * cnt / RATE) % (2*math.pi)
 
 
 def to_data(samps):
 def to_data(samps):
     return struct.pack('i'*len(samps), *samps)
     return struct.pack('i'*len(samps), *samps)
 
 
 def gen_data(data, frames, time, status):
 def gen_data(data, frames, time, status):
-    global FREQ, PHASE, Z_SAMP, LAST_SAMP
+    global FREQ, PHASE, Z_SAMP, LAST_SAMP, LAST_SAMPLES
     if FREQ == 0:
     if FREQ == 0:
         PHASE = 0
         PHASE = 0
         if LAST_SAMP == 0:
         if LAST_SAMP == 0:
+            if options.gui:
+                LAST_SAMPLES.extend([0]*frames)
             return (Z_SAMP*frames, pyaudio.paContinue)
             return (Z_SAMP*frames, pyaudio.paContinue)
         fdata = lin_seq(LAST_SAMP, 0, frames)
         fdata = lin_seq(LAST_SAMP, 0, frames)
+        if options.gui:
+            LAST_SAMPLES.extend(fdata)
         LAST_SAMP = fdata[-1]
         LAST_SAMP = fdata[-1]
         return (to_data(fdata), pyaudio.paContinue)
         return (to_data(fdata), pyaudio.paContinue)
     fdata, PHASE = samps(FREQ, PHASE, frames)
     fdata, PHASE = samps(FREQ, PHASE, frames)
+    if options.gui:
+        LAST_SAMPLES.extend(fdata)
     LAST_SAMP = fdata[-1]
     LAST_SAMP = fdata[-1]
     return (to_data(fdata), pyaudio.paContinue)
     return (to_data(fdata), pyaudio.paContinue)
 
 
 pa = pyaudio.PyAudio()
 pa = pyaudio.PyAudio()
 stream = pa.open(rate=RATE, channels=1, format=pyaudio.paInt32, output=True, frames_per_buffer=FPB, stream_callback=gen_data)
 stream = pa.open(rate=RATE, channels=1, format=pyaudio.paInt32, output=True, frames_per_buffer=FPB, stream_callback=gen_data)
 
 
+if options.gui:
+    guithread = threading.Thread(target=GUIs[options.gui])
+    guithread.setDaemon(True)
+    guithread.start()
+
 if options.test:
 if options.test:
     FREQ = 440
     FREQ = 440
     time.sleep(1)
     time.sleep(1)

+ 29 - 0
mkarduino.py

@@ -0,0 +1,29 @@
+#IV to arduino array computer
+
+import xml.etree.ElementTree as ET
+import sys
+
+iv = ET.parse(sys.argv[1]).getroot()
+
+streams = iv.findall('./streams/stream[@type="ns"]')
+if len(streams) > 3:
+    print 'WARNING: Too many streams'
+
+for i in xrange(min(3, len(streams))):
+    stream = streams[i]
+    notes = stream.findall('note')
+
+# First, the header
+    sys.stdout.write('const uint16_t track%d[] PROGMEM = {\n'%(i,))
+
+# For the first note, write out the delay needed to get there
+    if notes[0].get('time') > 0:
+        sys.stdout.write('%d, 0,\n'%(int(float(notes[0].get('time'))*1000),))
+
+    for idx, note in enumerate(notes):
+        sys.stdout.write('%d, FREQ(%d),\n'%(int(float(note.get('dur'))*1000), int(440.0 * 2**((int(note.get('pitch'))-69)/12.0))))
+        if idx < len(notes)-1 and float(note.get('time'))+float(note.get('dur')) < float(notes[idx+1].get('time')):
+            sys.stdout.write('%d, 0,\n'%(int(1000*(float(notes[idx+1].get('time')) - (float(note.get('time')) + float(note.get('dur'))))),))
+
+# Finish up the stream
+    sys.stdout.write('};\n\n')

+ 233 - 67
mkiv.py

@@ -6,10 +6,8 @@ This simple script (using python-midi) reads a MIDI file and makes an interval
 (.iv) file (actually XML) that contains non-overlapping notes.
 (.iv) file (actually XML) that contains non-overlapping notes.
 
 
 TODO:
 TODO:
--Reserve channels by track
--Reserve channels by MIDI channel
--Pitch limits for channels
 -MIDI Control events
 -MIDI Control events
+-Percussion
 '''
 '''
 
 
 import xml.etree.ElementTree as ET
 import xml.etree.ElementTree as ET
@@ -19,6 +17,7 @@ import os
 import optparse
 import optparse
 
 
 TRACKS = object()
 TRACKS = object()
+PROGRAMS = object()
 
 
 parser = optparse.OptionParser()
 parser = optparse.OptionParser()
 parser.add_option('-s', '--channel-split', dest='chansplit', action='store_true', help='Split MIDI channels into independent tracks (as far as -T is concerned)')
 parser.add_option('-s', '--channel-split', dest='chansplit', action='store_true', help='Split MIDI channels into independent tracks (as far as -T is concerned)')
@@ -27,11 +26,19 @@ parser.add_option('-c', '--preserve-channels', dest='chanskeep', action='store_t
 parser.add_option('-T', '--track-split', dest='tracks', action='append_const', const=TRACKS, help='Ensure all tracks are on non-mutual streams')
 parser.add_option('-T', '--track-split', dest='tracks', action='append_const', const=TRACKS, help='Ensure all tracks are on non-mutual streams')
 parser.add_option('-t', '--track', dest='tracks', action='append', help='Reserve an exclusive set of streams for certain conditions (try --help-conds)')
 parser.add_option('-t', '--track', dest='tracks', action='append', help='Reserve an exclusive set of streams for certain conditions (try --help-conds)')
 parser.add_option('--help-conds', dest='help_conds', action='store_true', help='Print help on filter conditions for streams')
 parser.add_option('--help-conds', dest='help_conds', action='store_true', help='Print help on filter conditions for streams')
-parser.add_option('-P', '--no-percussion', dest='no_perc', action='store_true', help='Don\'t try to filter percussion events out')
+parser.add_option('-p', '--program-split', dest='tracks', action='append_const', const=PROGRAMS, help='Ensure all programs are on non-mutual streams (overrides -T presently)')
+parser.add_option('-P', '--percussion', dest='perc', help='Which percussion standard to use to automatically filter to "perc" (GM, GM2, or none)')
 parser.add_option('-f', '--fuckit', dest='fuckit', action='store_true', help='Use the Python Error Steamroller when importing MIDIs (useful for extended formats)')
 parser.add_option('-f', '--fuckit', dest='fuckit', action='store_true', help='Use the Python Error Steamroller when importing MIDIs (useful for extended formats)')
-parser.add_option('-n', '--target-num', dest='repeaterNumber', type='int', help='Target count of devices')
-parser.set_defaults(tracks=[], repeaterNumber=1)
+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('--tempo', dest='tempo', help='Adjust interpretation of tempo (try "f1"/"global", "f2"/"track")')
+parser.set_defaults(tracks=[], perc='GM', deviation=2, tempo='global')
 options, args = parser.parse_args()
 options, args = parser.parse_args()
+if options.tempo == 'f1':
+    options.tempo == 'global'
+elif options.tempo == 'f2':
+    options.tempo == 'track'
 
 
 if options.help_conds:
 if options.help_conds:
     print '''Filter conditions are used to route events to groups of streams.
     print '''Filter conditions are used to route events to groups of streams.
@@ -40,9 +47,14 @@ Every filter is an expression; internally, this expression is evaluated as the b
 The "ev" object will be a MergeEvent with the following properties:
 The "ev" object will be a MergeEvent with the following properties:
 -ev.tidx: the originating track index (starting at 0)
 -ev.tidx: the originating track index (starting at 0)
 -ev.abstime: the real time in seconds of this event relative to the beginning of playback
 -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.ev: a midi.NoteOnEvent:
 -ev.ev: a midi.NoteOnEvent:
     -ev.ev.pitch: the MIDI pitch
     -ev.ev.pitch: the MIDI pitch
     -ev.ev.velocity: the MIDI velocity
     -ev.ev.velocity: the MIDI velocity
+    -ev.ev.channel: the MIDI channel
+
+All valid Python expressions are accepted. Take care to observe proper shell escaping.
 
 
 Specifying a -t <group>=<filter> will group all streams under a filter; if the <group> part is omitted, no group will be added.
 Specifying a -t <group>=<filter> will group all streams under a filter; if the <group> part is omitted, no group will be added.
 For example:
 For example:
@@ -57,11 +69,19 @@ will cause these groups to be made:
 
 
 As can be seen, order of specification is important. Equally important is the location of -T, which should be at the end.
 As can be seen, order of specification is important. Equally important is the location of -T, which should be at the end.
 
 
-NoteOffEvents are always matched to the stream which has their corresponding NoteOnEvent (in track and pitch), and so are
+NoteOffEvents are always matched to the stream which has their corresponding NoteOnEvent (in track, pitch, and channel), and so are
 not affected or observed by filters.
 not affected or observed by filters.
 
 
 If the filters specified are not a complete cover, an anonymous group will be created with no filter to contain the rest. If
 If the filters specified are not a complete cover, an anonymous group will be created with no filter to contain the rest. If
-it is desired to force this group to have a name, use -t <group>=True.'''
+it is desired to force this group to have a name, use -t <group>=True. This should be placed at the end.
+
+-T behaves exactly as if:
+    -t trk0=ev.tidx==0 -t trk1=ev.tidx==1 -t trk2=ev.tidx==2 [...]
+had been specified in its place, though it is automatically sized to the number of tracks. Similarly, -P operates as if
+    -t prg31=ev.prog==31 -t prg81=ev.prog==81 [...]
+had been specified, again containing only the programs that were observed in the piece.
+
+Groups for which no streams are generated are not written to the resulting file.'''
     exit()
     exit()
 
 
 if not args:
 if not args:
@@ -87,6 +107,8 @@ for fname in args:
     iv.set('version', '1')
     iv.set('version', '1')
     iv.set('src', os.path.basename(fname))
     iv.set('src', os.path.basename(fname))
     print fname, ': MIDI format,', len(pat), 'tracks'
     print fname, ': MIDI format,', len(pat), 'tracks'
+    if options.verbose:
+        print fname, ': MIDI Parameters:', pat.resolution, 'PPQN,', pat.format, 'format'
 
 
     if options.chansplit:
     if options.chansplit:
         print 'Splitting channels...'
         print 'Splitting channels...'
@@ -121,32 +143,135 @@ for fname in args:
 ##### Merge events from all tracks into one master list, annotated with track and absolute times #####
 ##### Merge events from all tracks into one master list, annotated with track and absolute times #####
     print 'Merging events...'
     print 'Merging events...'
 
 
+    class SortEvent(object):
+        __slots__ = ['ev', 'tidx', 'abstick']
+        def __init__(self, ev, tidx, abstick):
+            self.ev = ev
+            self.tidx = tidx
+            self.abstick = abstick
+
+    sorted_events = []
+    for tidx, track in enumerate(pat):
+        absticks = 0
+        for ev in track:
+            absticks += ev.tick
+            sorted_events.append(SortEvent(ev, tidx, absticks))
+
+    sorted_events.sort(key=lambda x: x.abstick)
+    if options.tempo == 'global':
+        bpm_at = [{0: 120}]
+    else:
+        bpm_at = [{0: 120} for i in pat]
+
+    print 'Computing tempos...'
+
+    for sev in sorted_events:
+        if isinstance(sev.ev, midi.SetTempoEvent):
+            if options.debug:
+                print fname, ': SetTempo at', sev.abstick, 'to', sev.ev.bpm, ':', sev.ev
+            bpm_at[sev.tidx if options.tempo == 'track' else 0][sev.abstick] = sev.ev.bpm
+
+    if options.verbose:
+        print fname, ': Events:', len(sorted_events)
+        print fname, ': Resolved global BPM:', bpm_at
+        if options.debug:
+            if options.tempo == 'track':
+                for tidx, bpms in enumerate(bpm_at):
+                    print fname, ': Tempos in track', tidx
+                    btimes = bpms.keys()
+                    for i in range(len(btimes) - 1):
+                        fev = filter(lambda sev: sev.tidx == tidx and sev.abstick >= btimes[i] and sev.abstick < btimes[i+1], sorted_events)
+                        print fname, ': BPM partition', i, 'contains', len(fev), 'events'
+            else:
+                btimes = bpm_at[0].keys()
+                for i in range(len(btimes) - 1):
+                    fev = filter(lambda sev: sev.abstick >= btimes[i] and sev.abstick < btimes[i+1], sorted_events)
+                    print fname, ': BPM partition', i, 'contains', len(fev), 'events'
+
+    def at2rt(abstick, bpms):
+        bpm_segs = bpms.items()
+        bpm_segs.sort(key=lambda pair: pair[0])
+        bpm_segs = filter(lambda pair: pair[0] <= abstick, bpm_segs)
+        rt = 0
+        atick = 0
+        if not bpm_segs:
+            rt = 0
+        else:
+            ctick, bpm = bpm_segs[0]
+            rt = (60.0 * ctick) / (bpm * pat.resolution)
+        for idx in range(1, len(bpm_segs)):
+            dt = bpm_segs[idx][0] - bpm_segs[idx-1][0]
+            bpm = bpm_segs[idx-1][1]
+            rt += (60.0 * dt) / (bpm * pat.resolution)
+        if not bpm_segs:
+            bpm = 120
+            ctick = 0
+        else:
+            ctick, bpm = bpm_segs[-1]
+        if options.debug:
+            print 'seg through', bpm_segs, 'final seg', (abstick - ctick, bpm)
+        rt += (60.0 * (abstick - ctick)) / (bpm * pat.resolution)
+        return rt
+
     class MergeEvent(object):
     class MergeEvent(object):
-        __slots__ = ['ev', 'tidx', 'abstime']
-        def __init__(self, ev, tidx, abstime):
+        __slots__ = ['ev', 'tidx', 'abstime', 'bank', 'prog']
+        def __init__(self, ev, tidx, abstime, bank, prog):
             self.ev = ev
             self.ev = ev
             self.tidx = tidx
             self.tidx = tidx
             self.abstime = abstime
             self.abstime = abstime
+            self.bank = bank
+            self.prog = prog
+        def copy(self, **kwargs):
+            args = {'ev': self.ev, 'tidx': self.tidx, 'abstime': self.abstime, 'bank': self.bank, 'prog': self.prog}
+            args.update(kwargs)
+            return MergeEvent(**args)
         def __repr__(self):
         def __repr__(self):
-            return '<ME %r in %d @%f>'%(self.ev, self.tidx, self.abstime)
+            return '<ME %r in %d on (%d:%d) @%f>'%(self.ev, self.tidx, self.bank, self.prog, self.abstime)
 
 
     events = []
     events = []
-    bpm_at = {0: 120}
+    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_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))]
+    ev_cnts = [[0 for i in range(16)] for j in range(len(pat))]
+    tnames = [''] * len(pat)
+    progs = set([0])
 
 
     for tidx, track in enumerate(pat):
     for tidx, track in enumerate(pat):
         abstime = 0
         abstime = 0
         absticks = 0
         absticks = 0
+        lastbpm = 120
         for ev in track:
         for ev in track:
-            if isinstance(ev, midi.SetTempoEvent):
-                absticks += ev.tick
-                bpm_at[absticks] = ev.bpm
-            else:
+            absticks += ev.tick
+            abstime = at2rt(absticks, bpm_at[tidx if options.tempo == 'track' else 0])
+            if options.debug:
+                print 'tick', absticks, 'realtime', abstime
+            if isinstance(ev, midi.TrackNameEvent):
+                tnames[tidx] = ev.text
+            if isinstance(ev, midi.ProgramChangeEvent):
+                cur_prog[tidx][ev.channel] = ev.value
+                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:
+                    cur_bank[tidx][ev.channel] = (0x3F & cur_bank[tidx][ev.channel]) | (ev.value << 7)
+                    chg_bank[tidx][ev.channel] += 1
+            elif isinstance(ev, midi.MetaEventWithText):
+                events.append(MergeEvent(ev, tidx, abstime, 0, 0))
+            elif isinstance(ev, midi.Event):
                 if isinstance(ev, midi.NoteOnEvent) and ev.velocity == 0:
                 if isinstance(ev, midi.NoteOnEvent) and ev.velocity == 0:
                     ev.__class__ = midi.NoteOffEvent #XXX Oww
                     ev.__class__ = midi.NoteOffEvent #XXX Oww
-                bpm = filter(lambda pair: pair[0] <= absticks, sorted(bpm_at.items(), key=lambda pair: pair[0]))[-1][1]
-                abstime += (60.0 * ev.tick) / (bpm * pat.resolution)
-                absticks += ev.tick
-                events.append(MergeEvent(ev, tidx, abstime))
+                events.append(MergeEvent(ev, tidx, abstime, cur_bank[tidx][ev.channel], cur_prog[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 'Sorting events...'
     print 'Sorting events...'
 
 
@@ -156,27 +281,37 @@ for fname in args:
     print 'Generating streams...'
     print 'Generating streams...'
 
 
     class DurationEvent(MergeEvent):
     class DurationEvent(MergeEvent):
-        __slots__ = ['duration']
-        def __init__(self, me, dur):
-            MergeEvent.__init__(self, me.ev, me.tidx, me.abstime)
+        __slots__ = ['duration', 'pitch']
+        def __init__(self, me, pitch, dur):
+            MergeEvent.__init__(self, me.ev, me.tidx, me.abstime, me.bank, me.prog)
+            self.pitch = pitch
             self.duration = dur
             self.duration = dur
 
 
     class NoteStream(object):
     class NoteStream(object):
-        __slots__ = ['history', 'active']
+        __slots__ = ['history', 'active', 'realpitch']
         def __init__(self):
         def __init__(self):
             self.history = []
             self.history = []
             self.active = None
             self.active = None
+            self.realpitch = None
         def IsActive(self):
         def IsActive(self):
             return self.active is not None
             return self.active is not None
-        def Activate(self, mev):
+        def Activate(self, mev, realpitch = None):
+            if realpitch is None:
+                realpitch = mev.ev.pitch
             self.active = mev
             self.active = mev
+            self.realpitch = realpitch
         def Deactivate(self, mev):
         def Deactivate(self, mev):
-            self.history.append(DurationEvent(self.active, mev.abstime - self.active.abstime))
+            self.history.append(DurationEvent(self.active, self.realpitch, mev.abstime - self.active.abstime))
             self.active = None
             self.active = None
+            self.realpitch = None
         def WouldDeactivate(self, mev):
         def WouldDeactivate(self, mev):
             if not self.IsActive():
             if not self.IsActive():
                 return False
                 return False
-            return mev.ev.pitch == self.active.ev.pitch and mev.tidx == self.active.tidx
+            if isinstance(mev.ev, midi.NoteOffEvent):
+                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
+            raise TypeError('Tried to deactivate with bad type %r'%(type(mev.ev),))
 
 
     class NSGroup(object):
     class NSGroup(object):
         __slots__ = ['streams', 'filter', 'name']
         __slots__ = ['streams', 'filter', 'name']
@@ -199,14 +334,23 @@ for fname in args:
 
 
     notegroups = []
     notegroups = []
     auxstream = []
     auxstream = []
+    textstream = []
 
 
-    if not options.no_perc:
-        notegroups.append(NSGroup(filter = lambda mev: mev.ev.channel == 10, name='perc'))
+    if options.perc and options.perc != 'none':
+        if options.perc == 'GM':
+            notegroups.append(NSGroup(filter = lambda mev: mev.ev.channel == 9, name='perc'))
+        elif options.perc == 'GM2':
+            notegroups.append(NSGroup(filter = lambda mev: mev.bank == 15360, name='perc'))
+        else:
+            print 'Unrecognized --percussion option %r; should be GM, GM2, or none'%(options.perc,)
 
 
     for spec in options.tracks:
     for spec in options.tracks:
         if spec is TRACKS:
         if spec is TRACKS:
             for tidx in xrange(len(pat)):
             for tidx in xrange(len(pat)):
                 notegroups.append(NSGroup(filter = lambda mev, tidx=tidx: mev.tidx == tidx, name = 'trk%d'%(tidx,)))
                 notegroups.append(NSGroup(filter = lambda mev, tidx=tidx: mev.tidx == tidx, name = 'trk%d'%(tidx,)))
+        elif spec is PROGRAMS:
+            for prog in progs:
+                notegroups.append(NSGroup(filter = lambda mev, prog=prog: mev.prog == prog, name = 'prg%d'%(prog,)))
         else:
         else:
             if '=' in spec:
             if '=' in spec:
                 name, _, spec = spec.partition('=')
                 name, _, spec = spec.partition('=')
@@ -214,12 +358,15 @@ for fname in args:
                 name = None
                 name = None
             notegroups.append(NSGroup(filter = eval("lambda ev: "+spec), name = name))
             notegroups.append(NSGroup(filter = eval("lambda ev: "+spec), name = name))
 
 
-    print 'Initial group mappings:'
-    for group in notegroups:
-        print ('<anonymous>' if group.name is None else group.name), '<=', group.filter
+    if options.verbose:
+        print 'Initial group mappings:'
+        for group in notegroups:
+            print ('<anonymous>' if group.name is None else group.name)
 
 
     for mev in events:
     for mev in events:
-        if isinstance(mev.ev, midi.NoteOnEvent):
+        if isinstance(mev.ev, midi.MetaEventWithText):
+            textstream.append(mev)
+        elif isinstance(mev.ev, midi.NoteOnEvent):
             for group in notegroups:
             for group in notegroups:
                 if group.Accept(mev):
                 if group.Accept(mev):
                     break
                     break
@@ -239,6 +386,29 @@ for fname in args:
                     break
                     break
             else:
             else:
                 print 'WARNING: Did not match %r with any stream deactivation.'%(mev,)
                 print 'WARNING: Did not match %r with any stream deactivation.'%(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,)
+        elif options.deviation > 0 and isinstance(mev.ev, midi.PitchWheelEvent):
+            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, base.ev.pitch + options.deviation * (mev.ev.pitch / float(0x2000)))
+                        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:
         else:
             auxstream.append(mev)
             auxstream.append(mev)
 
 
@@ -248,11 +418,12 @@ for fname in args:
         for ns in group.streams:
         for ns in group.streams:
             if ns.IsActive():
             if ns.IsActive():
                 print 'WARNING: Active notes at end of playback.'
                 print 'WARNING: Active notes at end of playback.'
-                ns.Deactivate(MergeEvent(ns.active, ns.active.tidx, lastabstime))
+                ns.Deactivate(MergeEvent(ns.active, ns.active.tidx, lastabstime, 0, 0))
 
 
-    print 'Final group mappings:'
-    for group in notegroups:
-        print ('<anonymous>' if group.name is None else group.name), '<=', group.filter, '(', len(group.streams), 'streams)'
+    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 'Generated %d streams in %d groups'%(sum(map(lambda x: len(x.streams), notegroups)), len(notegroups))
     print 'Generated %d streams in %d groups'%(sum(map(lambda x: len(x.streams), notegroups)), len(notegroups))
     print 'Playtime:', lastabstime, 'seconds'
     print 'Playtime:', lastabstime, 'seconds'
@@ -260,42 +431,37 @@ for fname in args:
 ##### Write to XML and exit #####
 ##### Write to XML and exit #####
 
 
     ivmeta = ET.SubElement(iv, 'meta')
     ivmeta = ET.SubElement(iv, 'meta')
-    ivbpms = ET.SubElement(ivmeta, 'bpms')
     abstime = 0
     abstime = 0
     prevticks = 0
     prevticks = 0
     prev_bpm = 120
     prev_bpm = 120
-    for absticks, bpm in sorted(bpm_at.items(), key = lambda pair: pair[0]):
-        abstime += ((absticks - prevticks) * 60.0) / (prev_bpm * pat.resolution)
-        prevticks = absticks
-        ivbpm = ET.SubElement(ivbpms, 'bpm')
-        ivbpm.set('bpm', str(bpm))
-        ivbpm.set('ticks', str(absticks))
-        ivbpm.set('time', str(abstime))
+    for tidx, bpms in enumerate(bpm_at):
+        ivbpms = ET.SubElement(ivmeta, 'bpms', track=str(tidx))
+        for absticks, bpm in sorted(bpms.items(), key = lambda pair: pair[0]):
+            abstime += ((absticks - prevticks) * 60.0) / (prev_bpm * pat.resolution)
+            prevticks = absticks
+            ivbpm = ET.SubElement(ivbpms, 'bpm')
+            ivbpm.set('bpm', str(bpm))
+            ivbpm.set('ticks', str(absticks))
+            ivbpm.set('time', str(abstime))
 
 
     ivstreams = ET.SubElement(iv, 'streams')
     ivstreams = ET.SubElement(iv, 'streams')
 
 
-    x = 0 
-    while(x<options.repeaterNumber):
-    	for group in notegroups:
-        	for ns in group.streams:
-            		ivns = ET.SubElement(ivstreams, 'stream')
-            		ivns.set('type', 'ns')
-           		if group.name is not None:
-                		ivns.set('group', group.name)
-            		for note in ns.history:
-                		ivnote = ET.SubElement(ivns, 'note')
-                		ivnote.set('pitch', str(note.ev.pitch))
-              			ivnote.set('vel', str(note.ev.velocity))
-        	       		ivnote.set('time', str(note.abstime))
- 	               		ivnote.set('dur', str(note.duration))
-			x+=1
-			print x
-			if(x>=options.repeaterNumber and options.repeaterNumber!=1):
-				break
-		if(x>=options.repeaterNumber and options.repeaterNumber!=1):
-			break
-	if(x>=options.repeaterNumber and options.repeaterNumber!=1):
-		break
+    for group in notegroups:
+            for ns in group.streams:
+                    ivns = ET.SubElement(ivstreams, 'stream')
+                    ivns.set('type', 'ns')
+                    if group.name is not None:
+                            ivns.set('group', group.name)
+                    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('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)
 
 
     ivaux = ET.SubElement(ivstreams, 'stream')
     ivaux = ET.SubElement(ivstreams, 'stream')
     ivaux.set('type', 'aux')
     ivaux.set('type', 'aux')

+ 529 - 0
piano.py

@@ -0,0 +1,529 @@
+import socket
+import sys
+import struct
+import time
+import xml.etree.ElementTree as ET
+import threading
+import optparse
+import random
+
+from packet import Packet, CMD, itos
+
+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('-T', '--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('-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')
+parser.add_option('-l', '--live', dest='live', help='Enter live mode (play from a controller in real time), specifying the port to connect to as "client,port"; use just "," to manually subscribe later')
+parser.add_option('-L', '--list-live', dest='list_live', action='store_true', help='List all the clients and ports that can be connected to for live performance')
+parser.add_option('-q', '--quit', dest='quit', action='store_true', help='Instruct all clients to quit')
+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='How loud to play this note (0-255)')
+parser.add_option('-s', '--silence', dest='silence', action='store_true', help='Instruct all clients to stop playing any active tones')
+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('-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('-k', '--keyboard', dest='keyboard', action='store_true', help='Play using the keyboard')
+parser.add_option('--help-routes', dest='help_routes', action='store_true', help='Show help about routing directives')
+parser.set_defaults(routes=[], random=0.0, rand_low=80, rand_high=2000, live=None, factor=1.0, duration=1.0, volume=255, wait_time=0.25, play=[])
+options, args = parser.parse_args()
+
+if options.help_routes:
+    print '''Routes are a way of either exclusively or mutually binding certain streams to certain playback clients. They are especially fitting in heterogenous environments where some clients will outperform others in certain pitches or with certain parts.
+
+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 syntax for that specification resembles the following:
+
+    broadcast.py -r U:bass=+bass -r U:treble1,U:treble2=+treble -r T:BEEP=-beeps,-trk3,-trk5 -r U:noise=0
+
+The specifier consists of a comma-separated list of attribute-colon-value pairs, followed by an equal sign. After this is a comma-separated list of exclusivities paired with the name of a stream group as specified in the file. The above example shows that stream groups "bass", "treble", and "beeps" will be routed to clients with UID "bass", "treble", and TYPE "BEEP" respectively. Additionally, TYPE "BEEP" will receive tracks 4 and 6 (indices 3 and 5) of the MIDI file (presumably split with -T), and that these three groups are exclusively to be routed to TYPE "BEEP" clients only (the broadcaster will drop the stream if no more are available), as opposed to the preference of the bass and treble groups, which may be routed onto other stream clients if they are available. Finally, the last route says that all "noise" UID clients should not proceed any further (receiving "null" streams) instead. Order is important; if a "noise" client already received a stream (such as "+beeps"), then it would receive that route with priority.'''
+    exit()
+
+PORT = 13676
+factor = options.factor
+
+print 'Factor:', factor
+
+s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+
+clients = []
+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
+
+print 'Clients:'
+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],
+        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)
+	if options.test:
+		s.sendto(str(Packet(CMD.PLAY, 0, 250000, 440, 255)), cl)
+                if not options.sync_test:
+                    time.sleep(0.25)
+                    s.sendto(str(Packet(CMD.PLAY, 0, 250000, 880, 255)), 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)
+
+if options.play:
+    for i, val in enumerate(options.play):
+        if val.startswith('@'):
+            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)
+    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)
+
+if options.test or options.quit or options.silence:
+    print uid_groups
+    print type_groups
+    exit()
+
+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), 255)), cl)
+        time.sleep(options.random)
+
+if options.live or options.list_live:
+    import midi
+    from midi import sequencer
+    S = sequencer.S
+    if options.list_live:
+        print sequencer.SequencerHardware()
+        exit()
+    seq = sequencer.SequencerRead(sequencer_resolution=120)
+    client_set = set(clients)
+    active_set = {} # note (pitch) -> client
+    deferred_set = set() # pitches held due to sustain
+    sustain_status = False
+    client, _, port = options.live.partition(',')
+    if client or port:
+        seq.subscribe_port(client, port)
+    seq.start_sequencer()
+    while True:
+        ev = S.event_input(seq.client)
+        event = None
+        if ev:
+            if options.verbose:
+                print 'SEQ:', ev
+            if ev < 0:
+                seq._error(ev)
+            if ev.type == S.SND_SEQ_EVENT_NOTEON:
+                event = midi.NoteOnEvent(channel = ev.data.note.channel, pitch = ev.data.note.note, velocity = ev.data.note.velocity)
+            elif ev.type == S.SND_SEQ_EVENT_NOTEOFF:
+                event = midi.NoteOffEvent(channel = ev.data.note.channel, pitch = ev.data.note.note, velocity = ev.data.note.velocity)
+            elif ev.type == S.SND_SEQ_EVENT_CONTROLLER:
+                event = midi.ControlChangeEvent(channel = ev.data.control.channel, control = ev.data.control.param, value = ev.data.control.value)
+            elif ev.type == S.SND_SEQ_EVENT_PGMCHANGE:
+                event = midi.ProgramChangeEvent(channel = ev.data.control.channel, pitch = ev.data.control.value)
+            elif ev.type == S.SND_SEQ_EVENT_PITCHBEND:
+                event = midi.PitchWheelEvent(channel = ev.data.control.channel, pitch = ev.data.control.value)
+            elif options.verbose:
+                print 'WARNING: Unparsed event, type %r'%(ev.type,)
+                continue
+        if event is not None:
+            if isinstance(event, midi.NoteOnEvent) and event.velocity == 0:
+                event.__class__ = midi.NoteOffEvent
+            if options.verbose:
+                print 'EVENT:', event
+            if isinstance(event, midi.NoteOnEvent):
+                if event.pitch in active_set:
+                    if sustain_status:
+                        deferred_set.discard(event.pitch)
+                    else:
+                        print 'WARNING: Note already activated: %r'%(event.pitch,),
+                    continue
+                inactive_set = client_set - set(active_set.values())
+                if not inactive_set:
+                    print 'WARNING: Out of clients to do note %r; dropped'%(event.pitch,)
+                    continue
+                cli = random.choice(list(inactive_set))
+                s.sendto(str(Packet(CMD.PLAY, 65535, 0, int(440.0 * 2**((event.pitch-69)/12.0)), 2*event.velocity)), cli)
+                active_set[event.pitch] = cli
+            elif isinstance(event, midi.NoteOffEvent):
+                if event.pitch not in active_set:
+                    print 'WARNING: Deactivating inactive note %r'%(event.pitch,)
+                    continue
+                if sustain_status:
+                    deferred_set.add(event.pitch)
+                    continue
+                s.sendto(str(Packet(CMD.PLAY, 0, 1, 1, 0)), active_set[event.pitch])
+                del active_set[event.pitch]
+            elif isinstance(event, midi.ControlChangeEvent):
+                if event.control == 64:
+                    sustain_status = (event.value >= 64)
+                    if not sustain_status:
+                        for pitch in deferred_set:
+                            if pitch not in active_set:
+                                print 'WARNING: Attempted deferred removal of inactive note %r'%(pitch,)
+                                continue
+                            s.sendto(str(Packet(CMD.PLAY, 0, 1, 1, 0)), active_set[pitch])
+                            del active_set[pitch]
+                        deferred_set.clear()
+
+
+
+
+
+if options.keyboard:
+    import pygame
+    import midi
+    from pygame import *
+    pygame.init()
+    size = width , height = 640,360 
+    screen = pygame.display.set_mode(size)
+    #picture = pygame.image.load("aaaa.png")
+    #surface = pygame.display.get_surface()
+    pitch = 60
+    velocity = 127
+    client_set = set(clients)
+    active_set = {} # note (pitch) -> client
+    sustain_status = False
+    sharp = 0
+    while True:
+         #surface.blit(picture,(0,0))
+         #pygame.display.update()
+         for event in pygame.event.get():
+              if event.type == pygame.QUIT:
+                 pygame.quit()
+                 sys.exit()
+              elif event.type == pygame.KEYDOWN:
+                 if event.key == K_a:
+                         pitch = 60
+                 if event.key == K_s:
+                         pitch = 62
+                 if event.key == K_d:
+                         pitch = 64
+                 if event.key == K_f:
+                         pitch = 65
+                 if event.key == K_g:
+                         pitch = 67
+                 if event.key == K_h:
+                         pitch = 69
+                 if event.key == K_j:
+                         pitch = 71
+                 if event.key == K_k:
+                         pitch = 72
+                 if event.key == K_l:
+                         pitch = 74
+                 if event.key == K_z:
+                         pitch = 48
+                 if event.key == K_x:
+                         pitch = 50
+                 if event.key == K_c:
+                         pitch = 52
+                 if event.key == K_v:
+                         pitch = 53
+                 if event.key == K_b:
+                         pitch = 55
+                 if event.key == K_n:
+                         pitch = 57
+                 if event.key == K_m:
+                         pitch = 59
+                 if event.key == K_q:
+                         pitch = 76
+                 if event.key == K_w:
+                         pitch = 77
+                 if event.key == K_e:
+                         pitch = 79
+                 if event.key == K_r:
+                         pitch = 81
+                 if event.key == K_t:
+                         pitch = 83
+                 if event.key == K_y:
+                         pitch = 84
+                 if event.key == K_u:
+                         pitch = 86
+                 if event.key == K_i:
+                         pitch = 88
+                 if event.key == K_o:
+                         pitch = 89
+                 if event.key == K_p:
+                         pitch = 91
+                 if event.key == K_LSHIFT:
+                         sharp = 1
+                         continue
+                 pitch = pitch + sharp
+                 mevent = midi.NoteOnEvent(channel = 0, pitch = pitch, velocity = velocity)
+                 if mevent.pitch in active_set:
+                    if sustain_status:
+                        deferred_set.discard(mevent.pitch)
+                    else:
+                        print 'WARNING: Note already activated: %r \n'%(mevent.pitch,),
+                    continue
+                 inactive_set = client_set - set(active_set.values())
+                 if not inactive_set:
+                    print 'WARNING: Out of clients to do note %r; dropped'%(mevent.pitch,)
+                    continue
+                 cli = random.choice(list(inactive_set))
+                 s.sendto(str(Packet(CMD.PLAY, 65535, 0, int(440.0 * 2**((mevent.pitch-69)/12.0)), 2*mevent.velocity)), cli)
+                 active_set[mevent.pitch] = cli
+              elif event.type == pygame.KEYUP:
+                 if event.key == K_a:
+                         pitch = 60
+                 if event.key == K_s:
+                         pitch = 62
+                 if event.key == K_d:
+                         pitch = 64
+                 if event.key == K_f:
+                         pitch = 65
+                 if event.key == K_g:
+                         pitch = 67
+                 if event.key == K_h:
+                         pitch = 69
+                 if event.key == K_j:
+                         pitch = 71
+                 if event.key == K_k:
+                         pitch = 72
+                 if event.key == K_l:
+                         pitch = 74
+                 if event.key == K_z:
+                         pitch = 48
+                 if event.key == K_x:
+                         pitch = 50
+                 if event.key == K_c:
+                         pitch = 52
+                 if event.key == K_v:
+                         pitch = 53
+                 if event.key == K_b:
+                         pitch = 55
+                 if event.key == K_n:
+                         pitch = 57
+                 if event.key == K_m:
+                         pitch = 59
+                 if event.key == K_q:
+                         pitch = 76
+                 if event.key == K_w:
+                         pitch = 77
+                 if event.key == K_e:
+                         pitch = 79
+                 if event.key == K_r:
+                         pitch = 81
+                 if event.key == K_t:
+                         pitch = 83
+                 if event.key == K_y:
+                         pitch = 84
+                 if event.key == K_u:
+                         pitch = 86
+                 if event.key == K_i:
+                         pitch = 88
+                 if event.key == K_o:
+                         pitch = 89
+                 if event.key == K_p:
+                         pitch = 91
+                 if event.key == K_LSHIFT:
+                         sharp = 0
+                         continue
+                 mevent = midi.NoteOffEvent(channel = 0, pitch = pitch, velocity = velocity)
+                 if mevent.pitch not in active_set:
+                    print 'WARNING: Deactivating inactive note %r'%(mevent.pitch,)
+                    continue
+                 if sustain_status:
+                    deferred_set.add(mevent.pitch)
+                    continue
+		 s.sendto(str(Packet(CMD.PLAY, 0, 1, 1, 0)), active_set[mevent.pitch])
+                 del active_set[mevent.pitch]
+                 mevent = midi.NoteOffEvent(channel = 0, pitch = pitch + 1, velocity = velocity)
+                 if mevent.pitch not in active_set:
+                    print 'WARNING: Deactivating inactive note %r'%(mevent.pitch,)
+                    continue
+                 if sustain_status:
+                    deferred_set.add(mevent.pitch)
+                    continue
+		 s.sendto(str(Packet(CMD.PLAY, 0, 1, 1, 0)), active_set[mevent.pitch])
+                 del active_set[mevent.pitch]
+                 
+for fname in args:
+    try:
+        iv = ET.parse(fname).getroot()
+    except IOError:
+        import traceback
+        traceback.print_exc()
+        print fname, ': Bad file'
+        continue
+
+    notestreams = iv.findall("./streams/stream[@type='ns']")
+    groups = set([ns.get('group') for ns in notestreams if 'group' in ns.keys()])
+    print len(notestreams), 'notestreams'
+    print len(clients), 'clients'
+    print len(groups), 'groups'
+
+    class Route(object):
+        def __init__(self, fattr, fvalue, group, excl=False):
+            if fattr == 'U':
+                self.map = uid_groups
+            elif fattr == 'T':
+                self.map = type_groups
+            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
+        @classmethod
+        def Parse(cls, s):
+            fspecs, _, grpspecs = map(lambda x: x.strip(), s.partition('='))
+            fpairs = []
+            ret = []
+            for fspec in [i.strip() for i in fspecs.split(',')]:
+                fattr, _, fvalue = map(lambda x: x.strip(), fspec.partition(':'))
+                fpairs.append((fattr, fvalue))
+            for part in [i.strip() for i in grpspecs.split(',')]:
+                for fattr, fvalue in fpairs:
+                    if part[0] == '+':
+                        ret.append(Route(fattr, fvalue, part[1:], False))
+                    elif part[0] == '-':
+                        ret.append(Route(fattr, fvalue, part[1:], 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, [])
+        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
+            self.routes = []
+        def Route(self, stream):
+            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 options.verbose:
+                        print '\tMatches route', route
+                    excl = excl or route.excl
+                    matches = filter(lambda x, route=route: route.Apply(x), testset)
+                    if matches:
+                        if options.verbose:
+                            print '\tUsing client', matches[0]
+                        self.clients.remove(matches[0])
+                        return matches[0]
+                    if options.verbose:
+                        print '\tNo matches, moving on...'
+                if route.group is None:
+                    if options.verbose:
+                        print 'Encountered NULL route, removing from search space...'
+                    toremove = []
+                    for cli in testset:
+                        if route.Apply(cli):
+                            toremove.append(cli)
+                    for cli in toremove:
+                        if options.verbose:
+                            print '\tRemoving', cli, '...'
+                        testset.remove(cli)
+            if excl:
+                if options.verbose:
+                    print '\tExclusively routed, no route matched.'
+                return None
+            if not testset:
+                if options.verbose:
+                    print '\tOut of clients, no route matched.'
+                return None
+            cli = testset[0]
+            self.clients.remove(cli)
+            if options.verbose:
+                print '\tDefault route to', cli
+            return cli
+
+    routeset = RouteSet()
+    for rspec in options.routes:
+        try:
+            routeset.routes.extend(Route.Parse(rspec))
+        except Exception:
+            import traceback
+            traceback.print_exc()
+
+    if options.verbose:
+        print 'All routes:'
+        for route in routeset.routes:
+            print route
+
+    class NSThread(threading.Thread):
+        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'))
+                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)), vel*2)), cl)
+                if options.verbose:
+                    print (time.time() - BASETIME), cl, ': PLAY', pitch, dur, vel
+                self.wait_for(dur - ((time.time() - BASETIME) - factor*ttime))
+            if options.verbose:
+                print '% 6.5f'%(time.time() - BASETIME,), cl, ': DONE'
+
+    threads = []
+    for ns in notestreams:
+        cli = routeset.Route(ns)
+        if cli:
+            nsq = ns.findall('note')
+            threads.append(NSThread(args=(nsq, cli)))
+
+    if options.verbose:
+        print 'Playback threads:'
+        for thr in threads:
+            print thr._Thread__args[1]
+
+    BASETIME = time.time()
+    for thr in threads:
+        thr.start()
+    for thr in threads:
+        thr.join()
+
+    print fname, ': Done!'

+ 221 - 0
shiv.py

@@ -0,0 +1,221 @@
+# IV file viewer
+
+import xml.etree.ElementTree as ET
+import optparse
+import sys
+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('-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')
+parser.add_option('--histogram', dest='histogram', action='store_true', help='Show a histogram distribution of pitches')
+parser.add_option('--histogram-tracks', dest='histogram_tracks', action='store_true', help='Show a histogram distribution of pitches per track')
+parser.add_option('--vel-hist', dest='vel_hist', action='store_true', help='Show a histogram distribution of velocities')
+parser.add_option('--vel-hist-tracks', dest='vel_hist_tracks', action='store_true', help='Show a histogram distributions of velocities per track')
+parser.add_option('-d', '--duration', dest='duration', action='store_true', help='Show the duration of the piece')
+parser.add_option('-D', '--duty-cycle', dest='duty_cycle', action='store_true', help='Show the duration of the notes within tracks, and as a percentage of the piece duration')
+parser.add_option('-H', '--height', dest='height', type='int', help='Height of histograms')
+parser.add_option('-C', '--no-color', dest='no_color', action='store_true', help='Don\'t use ANSI color escapes')
+parser.add_option('-x', '--aux', dest='aux', action='store_true', help='Show information about the auxiliary streams')
+
+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.set_defaults(height=20)
+
+options, args = parser.parse_args()
+
+if options.almost_all or options.all:
+    options.number = True
+    options.groups = True
+    options.notes = True
+    options.notes_stream = True
+    options.histogram = True
+    options.vel_hist = True
+    options.duration = True
+    options.duty_cycle = True
+    if options.all:
+        options.aux = True
+        options.meta = True
+        options.histogram_tracks= True
+        options.vel_hist_tracks = True
+
+if options.no_color:
+    class COL:
+        NONE=''
+        RED=''
+        GREEN=''
+        BLUE=''
+        YELLOW=''
+        MAGENTA=''
+        CYAN=''
+else:
+    class COL:
+        NONE='\x1b[0m'
+        RED='\x1b[31m'
+        GREEN='\x1b[32m'
+        BLUE='\x1b[34m'
+        YELLOW='\x1b[33m'
+        MAGENTA='\x1b[35m'
+        CYAN='\x1b[36m'
+
+def show_hist(values, height=None):
+    if not values:
+        print '{empty histogram}'
+    if height is None:
+        height = options.height
+    xs, ys = values.keys(), values.values()
+    minx, maxx = min(xs), max(xs)
+    miny, maxy = min(ys), max(ys)
+    xv = range(int(math.floor(minx)), int(math.ceil(maxx + 1)))
+    incs = max((maxy - miny) / height, 1)
+    print COL.CYAN + '\t --' + '-' * len(xv) + COL.NONE
+    for ub in range(maxy + incs, miny, -incs):
+        print '{}{}\t | {}{}{}'.format(COL.CYAN, ub, COL.YELLOW, ''.join(['#' if values.get(x) > (ub - incs) else ' ' for x in xv]), COL.NONE)
+    print COL.CYAN + '\t |-' + '-' * len(xv) + COL.NONE
+    xvs = map(str, xv)
+    for i in range(max(map(len, xvs))):
+        print COL.CYAN + '\t   ' + ''.join([s[i] if len(s) > i else ' ' for s in xvs]) + COL.NONE
+    print
+    xcs = map(str, [values.get(x, 0) for x in xv])
+    for i in range(max(map(len, xcs))):
+        print COL.YELLOW + '\t   ' + ''.join([s[i] if len(s) > i else ' ' for s in xcs]) + COL.NONE
+    print
+
+for fname in args:
+    try:
+        iv = ET.parse(fname).getroot()
+    except IOError:
+        import traceback
+        traceback.print_exc()
+        print 'Bad file :', fname, ', skipping...'
+        continue
+    print
+    print 'File :', fname
+    print '\t<computing...>'
+
+    if options.meta:
+        print 'Metatrack:',
+        meta = iv.find('./meta')
+        if len(meta):
+            print 'exists'
+            print '\tBPM track:',
+            bpms = meta.find('./bpms')
+            if len(bpms):
+                print 'exists'
+                for elem in bpms.iterfind('./bpm'):
+                    print '\t\tAt ticks {}, time {}: {} bpm'.format(elem.get('ticks'), elem.get('time'), elem.get('bpm'))
+
+    if not (options.number or 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
+
+    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.number:
+        print 'Stream count:'
+        print '\tNotestreams:', len(notestreams)
+        print '\tTotal:', len(streams)
+
+    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
+
+    if options.groups:
+        groups = {}
+        for s in notestreams:
+            group = s.get('group', '<anonymous>')
+            groups[group] = groups.get(group, 0) + 1
+        print 'Groups:'
+        for name, cnt in groups.iteritems():
+            print '\t{} ({} streams)'.format(name, cnt)
+
+    if options.aux:
+        import midi
+        fr = midi.FileReader()
+        fr.RunningStatus = None  # XXX Hack
+        print 'Aux stream data:'
+        for aidx, astream in enumerate(auxstreams):
+            evs = astream.findall('ev')
+            failed = 0
+            print '\tFrom stream {}, {} events:'.format(aidx, len(evs))
+            for ev in evs:
+                try:
+                    data = eval(ev.get('data'))
+                    mev = fr.parse_midi_event(iter(data))
+                except AssertionError:
+                    failed += 1
+                else:
+                    print '\t\tAt time {}: {}'.format(ev.get('time'), mev)
+            print '\t\t(...and {} others which failed to parse)'.format(failed)
+
+    if not (options.notes or options.notes_stream or options.histogram or options.histogram_tracks or options.vel_hist or options.vel_hist_tracks or options.duration or options.duty_cycle):
+        continue
+
+    if options.notes:
+        note_cnt = 0
+    if options.notes_stream:
+        notes_stream = [0] * len(notestreams)
+    if options.histogram:
+        pitches = {}
+    if options.histogram_tracks:
+        pitch_tracks = [{} for i in notestreams]
+    if options.vel_hist:
+        velocities = {}
+    if options.vel_hist_tracks:
+        velocities_tracks = [{} for i in notestreams]
+    if options.duration or options.duty_cycle:
+        max_dur = 0
+    if options.duty_cycle:
+        cum_dur = [0.0] * len(notestreams)
+
+    for sidx, stream in enumerate(notestreams):
+        notes = stream.findall('note')
+        for note in notes:
+            pitch = float(note.get('pitch'))
+            vel = int(note.get('vel'))
+            time = float(note.get('time'))
+            dur = float(note.get('dur'))
+            if options.notes:
+                note_cnt += 1
+            if options.notes_stream:
+                notes_stream[sidx] += 1
+            if options.histogram:
+                pitches[pitch] = 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
+            if options.vel_hist_tracks:
+                velocities_tracks[sidx][vel] = velocities_tracks[sidx].get(vel, 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.histogram_tracks:
+        for sidx, hist in enumerate(pitch_tracks):
+            print 'Stream {} (group {}) pitch histogram:'.format(sidx, notestreams[sidx].get('group', '<anonymous>'))
+            show_hist(hist)
+    if options.vel_hist_tracks:
+        for sidx, hist in enumerate(velocities_tracks):
+            print 'Stream {} (group {}) velocity histogram:'.format(sidx, notestreams[sidx].get('group', '<anonymous>'))
+            show_hist(hist)
+    if options.notes_stream:
+        for sidx, value in enumerate(notes_stream):
+            print 'Stream {} (group {}) note count: {}'.format(sidx, notestreams[sidx].get('group', '<anonymous>'), value)
+    if options.duty_cycle:
+        for sidx, value in enumerate(cum_dur):
+            print 'Stream {} (group {}) duty cycle: {}'.format(sidx, notestreams[sidx].get('group', '<anonymous>'), value / max_dur)
+    if options.notes:
+        print 'Total notes: {}'.format(note_cnt)
+    if options.histogram:
+        print 'Pitch histogram:'
+        show_hist(pitches)
+    if options.vel_hist:
+        print 'Velocity histogram:'
+        show_hist(velocities)
+    if options.duration:
+        print 'Playing duration: {}'.format(max_dur)

+ 101 - 0
voice.py

@@ -0,0 +1,101 @@
+'''
+voice -- Voices
+
+A voice is a simple, singular unit of sound generation that encompasses the following
+properties:
+-A *generator*: some function that generates a waveform. As an input, it receives theta,
+ the phase of the signal it is to generate (in [0, 2pi)) and, as an output, it produces
+ the sample at that point, a normalized amplitude value in [-1, 1].
+-An *envelope*: a function that receives a boolean (the status of whether or not a note
+ is playing now) and the change in time, and outputs a factor in [0, 1] that represents
+ a modification to the volume of the generator (pre-output mix).
+All of these functions may internally store state or other data, usually by being 
+implemented as a class with a __call__ method.
+
+Voices are meant to generate audio data. This can be done in a number of ways, least to
+most abstracted:
+-A sample at a certain phase (theta) may be gotten from the generator; this can be done
+ by calling the voice outright;
+-A set of samples can be generated via the .samples() method, which receives the number
+ of samples to generate and the phase velocity (a function of the sample rate and the
+ desired frequency of the waveform's period; this can be calculated using the static
+ method .phase_vel());
+-Audio data with enveloping can be generated using the .data() method, which calls the
+ envelope function as if the note is depressed at the given phase velocity; if the
+ freq is specified as None, then the note is treated as released. Note that
+ this will often be necessary for envelopes, as many of them are stateful (as they
+ depend on the first derivative of time). Also, at this level, the Voice will maintain
+ some state (namely, the phase at the end of generation) which will ensure (C0) smooth
+ transitions between already smooth generator functions, even if the frequency changes.
+-Finally, a pyaudio-compatible stream callback can be provided with .pyaudio_scb(), a
+ method that returns a function that arranges to call .data() with the appropriate values.
+ The freq input to .data() will be taken from the .freq member of the voice in a possibly
+ non-atomic manner.
+'''
+
+import math
+import pyaudio
+import struct
+import time
+
+def norm_theta(theta):
+    return theta % (2*math.pi)
+
+def norm_amp(amp):
+    return min(1.0, max(-1.0, amp))
+
+def theta2lin(theta):
+    return theta / (2*math.pi)
+
+def lin2theta(lin):
+    return lin * 2*math.pi
+
+class Voice(object):
+    @classmethod
+    def register_gen(cls, name, params):
+    def __init__(self, generator=None, envelope=None):
+        self.generator = generator or self.DEFAULT_GENERATOR
+        self.envelope = envelope or self.DEFAULT_ENVELOPE
+        self.phase = 0
+        self.freq = None
+    def __call__(self, theta):
+        return norm_amp(self.generator(norm_theta(theta)))
+    @staticmethod
+    def phase_vel(freq, samp_rate):
+        return 2 * math.pi * freq / samp_rate
+    def samples(self, frames, pvel):
+        for i in xrange(frames):
+            yield self(self.phase)
+            self.phase = norm_theta(self.phase + pvel)
+    def data(self, frames, freq, samp_rate):
+        period = 1.0/samp_rate
+        status = freq is not None
+        for samp in self.samples(frames, self.phase_vel(freq, samp_rate)):
+            yield samp * self.envelope(status, period)
+    def pyaudio_scb(self, rate, fmt=pyaudio.paInt16):
+        samp_size = pyaudio.get_sample_size(fmt)
+        maxint = (1 << (8*samp_size)) - 1
+        dtype = ['!', 'h', 'i', '!', 'l', '!', '!', '!', 'q'][samp_size]
+        def __callback(data, frames, time, status, self=self, rate=rate, maxint=maxint, dtype=dtype):
+            return struct.pack(dtype*frames, *[maxint*int(i) for i in self.data(frames, self.freq, rate)])
+        return __callback
+
+class VMeanMixer(Voice):
+    def __init__(self, *voices):
+        self.voices = list(voices)
+    def __call__(self, theta):
+        return norm_amp(sum([i(theta)/len(self.voices) for i in self.voices]))
+
+class VSumMixer(Voice):
+    def __init__(self, *voices):
+        self.voices = list(voices)
+    def __call__(self, theta):
+        return norm_amp(sum([i(theta) for i in self.voices]))
+
+class VLFOMixer(Voice):
+    def __init__(self, lfo, *voices):
+        self.lfo = lfo
+        self.voices = list(voices)
+    def __call__(self, theta):
+        i = int(len(self.voices) * self.lfo * (time.time() % (1.0 / self.lfo)))
+        return self.voices[i](theta)