Module musx.score

This module implements a scheduling queue for generating musical scores. The Score runs 'part composers' (Python generators) that output musical events to sequences and score files.

Example

seq = Seq()
sco = Score(out=seq)
def bach(score, num):
    for i in range(num):
        n = Note(time=score.now, key=60+i)
        score.add(n)
        yield .125
sco.compose(bach(sco, 10))

See the demos directory for many examples of part composers!

Expand source code
###############################################################################
"""
This module implements a scheduling queue for generating musical scores.
The Score runs 'part composers' (Python generators) that output musical events
to sequences and score files.

Example
-------
```py
seq = Seq()
sco = Score(out=seq)
def bach(score, num):
    for i in range(num):
        n = Note(time=score.now, key=60+i)
        score.add(n)
        yield .125
sco.compose(bach(sco, 10))
```

See the demos directory for many examples of part composers!
"""

import types


class Score:
    """
    Runs part composers in time sorted order. A part composer is a Python generator
    that is evaluated by the Score. When a part composer executes it
    can add musical events or new part composers to the score. To continue
    running a part composer must yield back a delta time that specifies the next time
    point at which the composer should run again. A part composer stops 
    running when it stops yielding delta times or it yields a negative delta time.
    """

    _queue = []
    """
    A time sorted list of queue entries. A queue entry is a list: 
    [<runtime>, <starttime>, <composer>], where <runtime> is the next time 
    (in seconds) at which <composer> will be called, <starttime> is 
    the time at which <composer> was initially inserted into the scheduer,
    and <composer> is a generator.
    """

    _latest_time = 0
    """The run time of the last queue entry."""


    def __init__(self, out=None):
        self._clean()
        self.out = out


    def _clean(self):
        self._queue = []
        self.running = False
        self._latest_time = self.now = self.elapsed = 0


    def _insert(self, entry):
        """
        Insert entry after all entries <= to it. Optimized
        for the most common case of appending at the end.
        """

        if entry[0] < self._latest_time:
            for i, e in enumerate(self._queue):
                if e[0] > entry[0]:
                    #print("inserting", entry)
                    self._queue.insert(i, entry)
                    return
        #print("insert: appending", entry)
        self._queue.append(entry)
        self._latest_time = entry[0]


    def _insure_entries(self, expr):
        """
        Parses composer expression(s) into a list of one or
        more queue entries. A composer expression can be a 
        composer, a list [*ahead*, *composer*], or a sequence
        of the same: [[*ahead1*, *composer1*], ...]
        """

        def isgen(x): return isinstance(x, types.GeneratorType)
        def isnum(x): return isinstance(x, (int, float))
        def islist(x): return isinstance(x, list)
        def ispair(x):
            if len(x) == 0:
                raise ValueError("empty generator spec.")
            elif isnum(x[0]):
                if x[0] < 0:
                    raise ValueError(f"not a number >= 0: {x}.")
                if len(x) != 2:
                    raise ValueError(f"not a generator spec: {x}.")
                if not isgen(x[1]):
                    raise ValueError(f"not a generator spec: {x}.")
                return True
            return False      
        
        now = self.now
        if not islist(expr):
            if isgen(expr):
                return [[now + 0, now + 0, expr]]
            raise TypeError(f"not a generator: {expr}")
        elif ispair(expr):
            return [[now + expr[0], now + expr[0], expr[1]]]

        # If we reach here, expr is a list that does not start with
        # a number so every item has to be a gen or a list [num gen]
        entries = []
        for e in expr:
            #print("e:", e)
            if not islist(e):
                if isgen(e):
                    entries.append([now + 0, now + 0, e])
                else:
                    raise TypeError(f"not a generator: {e}.")
            elif ispair(e):
                entries.append([now + e[0], now + e[0], e[1]])
            else:
                raise ValueError(f"not a generator list [time, gen]: {e}.")
        return entries


    def _run(self):
        """
        The scheduling loop that processes queued composers until there are
        no more composers to run. To process the current composer in the
        queue the Score performs the following tasks:

        * the current (first) composer is popped off the queue.
        * the queue's self.now and self.elapsed times are updated.
        * the composer generator is called via next().
        * if the next() call yields a positive ahead value back the composer
          is reinserted  back into the queue at time now + ahead, otherwise
          the composer is not reinserted in the queue.
        """

        self.running = True
        while len(self._queue) > 0:
            # Pop earliest entry from the queue and get its composer
            entry = self._queue.pop(0)
            # Set the current score time to the entry's time.
            self.now = entry[0]
            # print(f"before composer, score time is {self.now}")
            # Set the composer's elapsed time since starting
            self.elapsed = self.now - entry[1]
            #print("***elapsed=", self.elapsed)
            composer = entry[2]
            try:
                # Call the entry's composer. if it yields a positive number
                # increment entry's current scoretime and add it back into the _queue.
                delta = next(composer)
                if isinstance(delta, (int, float)): # and delta >= 0:
                    if delta >= 0:
                        # Advance entry's time and insert back into queue
                        entry[0] += delta
                        # print(f"after composer, delta is {delta} and entry time is now {entry[0]}")
                        self._insert(entry)
                else:
                    raise ValueError(f"invalid yield value {delta} from composer {entry[2]}.")
            except StopIteration:
                # print("stopping", composer)
                pass

 
    out = None
    """
    An optional object a composer can add musical events to. The Score
    does not examine the contents this variable.
    """


    running = False
    """True if the Score is running."""


    now = 0
    """
    The time point in the score that the currently executing composer is at.
    """
 

    elapsed = 0
    """
    The amount of time the currently executing composer has been running since
    it was added to the Score.
    """
 

    def compose(self, composer):
        """
        Starts running one or more part composers. This function can be
        called at the top level and also by composers that are running.

        Parameters
        ----------
        composer : generator | list
            If composer is a generator it is added to the queue at the current
            time. Otherwise composer can be a list [*ahead*, *composer*]
            or a list of the same [[*ahead*, *composer*], ...]  where
            *ahead* is a future start time and *composer* is a part composer.

        Example
        -------
        ```py
        def simp(q, num, rate, key):
            for _ in range(num):
                print("simp at", q.now, "plays", key)
                yield rate
        q=Score()
        # running one composer
        q.compose(simp(q, 5, 1, 60))
        # running two at times 0 and 5
        q.compose([[0, simp(q, 5, 1, 60)], [5, simp(q, 10, .251, 72)]])
        # a composer that composes composers =:)
        def simp2(q, num)
            for i in range(num):
                print("simp2 at", q.now)
                q.compose([q.now+i*2, simp(num, 5, .25, 60+i)])
            yield 2
        q.compose(simp2(q, num))
        ```
        """

        #print("in compose")
        if self.running:
            # A composer added while running just gets inserted it at
            # the propper time in the queue which is already running.
            for e in self._insure_entries(composer):
                #print("r adding", e)
                self._insert(e)
        else:
            for e in self._insure_entries(composer):
                #print("x adding", e)
                self._insert(e)
            try:
                # This is a loop that processes the queue until it is empty. It
                # is in a try statement because we always want to clean up, even
                # if an error is thrown.
                self._run()
            finally:
                self._clean()
                #print("Done!")

    def add(self, event):
        """
        Adds an event to the score. 
        """
        self.out.add(event)
        # Since the score is a scheduler we can just append the event to the seq.
        #self.out.append(event)


if __name__ == '__main__':
    print('Score Tests...')

    def gen(): yield 0
    def foo(): return 0
    x = []
    q = Score()

    print("\ntesting input:", x)
    try: l = q._insure_entries(x)
    except ValueError as e: print("ERROR:",e)
    except TypeError as e: print("ERROR:", e)
    else: print(l)

    x = gen
    print("\ntesting input:", x)
    try: l = q._insure_entries(x)
    except ValueError as e: print("ERROR:", e)
    except TypeError as e: print("ERROR:", e)
    else: print(l)

    x = gen()
    print("\ntesting input:", x)
    try: l = q._insure_entries(x)
    except ValueError as e: print("ERROR:", e)
    except TypeError as e: print("ERROR:", e)
    else: print(l)

    x = [5, gen()]
    print("\ntesting input:", x)
    try: l = q._insure_entries(x)
    except ValueError as e: print("ERROR:", e)
    except TypeError as e: print("ERROR:", e)
    else: print(l)

    x = [gen(), 5]
    print("\ntesting input:", x)
    try: l = q._insure_entries(x)
    except ValueError as e: print("ERROR:", e)
    except TypeError as e: print("ERROR:", e)
    else: print(l)

    x = [gen()]
    print("\ntesting input:", x)
    try: l = q._insure_entries(x)
    except ValueError as e: print("ERROR:", e)
    except TypeError as e: print("ERROR:", e)
    else: print(l)

    x = [[gen()]]
    print("\ntesting input:", x)
    try: l = q._insure_entries(x)
    except ValueError as e: print("ERROR:", e)
    except TypeError as e: print("ERROR:", e)
    else: print(l)

    x = [gen(), gen(), gen(), [20, gen]]
    print("\ntesting input:", x)
    try: l = q._insure_entries(x)
    except ValueError as e: print("ERROR:", e)
    except TypeError as e: print("ERROR:", e)
    else: print(l)

    x = [gen(), gen(), gen(), [20, gen()]]
    print("\ntesting input:", x)
    try: l = q._insure_entries(x)
    except ValueError as e: print("ERROR:", e)
    except TypeError as e: print("ERROR:", e)
    else: print(l)

    x = [5]
    print("\ntesting input:", x)
    try: l = q._insure_entries(x)
    except ValueError as e: print("ERROR:", e)
    except TypeError as e: print("ERROR:", e)
    else: print(l)

    x = [0, gen(), 45]
    print("\ntesting input:", x)
    try: l = q._insure_entries(x)
    except ValueError as e: print("ERROR:", e)
    except TypeError as e: print("ERROR:", e)
    else: print(l)

    x = [gen(), gen(), [45, gen()]]
    print("\ntesting input:", x)
    try: l = q._insure_entries(x)
    except ValueError as e: print("ERROR:", e)
    except TypeError as e: print("ERROR:", e)
    else: print(l)

    x = [gen(), gen(), [-2, gen()]]
    print("\ntesting input:", x)
    try: l = q._insure_entries(x)
    except ValueError as e: print("ERROR:", e)
    except TypeError as e: print("ERROR:", e)
    else: print(l)

    print('...Done!')

Classes

class Score (out=None)

Runs part composers in time sorted order. A part composer is a Python generator that is evaluated by the Score. When a part composer executes it can add musical events or new part composers to the score. To continue running a part composer must yield back a delta time that specifies the next time point at which the composer should run again. A part composer stops running when it stops yielding delta times or it yields a negative delta time.

Expand source code
class Score:
    """
    Runs part composers in time sorted order. A part composer is a Python generator
    that is evaluated by the Score. When a part composer executes it
    can add musical events or new part composers to the score. To continue
    running a part composer must yield back a delta time that specifies the next time
    point at which the composer should run again. A part composer stops 
    running when it stops yielding delta times or it yields a negative delta time.
    """

    _queue = []
    """
    A time sorted list of queue entries. A queue entry is a list: 
    [<runtime>, <starttime>, <composer>], where <runtime> is the next time 
    (in seconds) at which <composer> will be called, <starttime> is 
    the time at which <composer> was initially inserted into the scheduer,
    and <composer> is a generator.
    """

    _latest_time = 0
    """The run time of the last queue entry."""


    def __init__(self, out=None):
        self._clean()
        self.out = out


    def _clean(self):
        self._queue = []
        self.running = False
        self._latest_time = self.now = self.elapsed = 0


    def _insert(self, entry):
        """
        Insert entry after all entries <= to it. Optimized
        for the most common case of appending at the end.
        """

        if entry[0] < self._latest_time:
            for i, e in enumerate(self._queue):
                if e[0] > entry[0]:
                    #print("inserting", entry)
                    self._queue.insert(i, entry)
                    return
        #print("insert: appending", entry)
        self._queue.append(entry)
        self._latest_time = entry[0]


    def _insure_entries(self, expr):
        """
        Parses composer expression(s) into a list of one or
        more queue entries. A composer expression can be a 
        composer, a list [*ahead*, *composer*], or a sequence
        of the same: [[*ahead1*, *composer1*], ...]
        """

        def isgen(x): return isinstance(x, types.GeneratorType)
        def isnum(x): return isinstance(x, (int, float))
        def islist(x): return isinstance(x, list)
        def ispair(x):
            if len(x) == 0:
                raise ValueError("empty generator spec.")
            elif isnum(x[0]):
                if x[0] < 0:
                    raise ValueError(f"not a number >= 0: {x}.")
                if len(x) != 2:
                    raise ValueError(f"not a generator spec: {x}.")
                if not isgen(x[1]):
                    raise ValueError(f"not a generator spec: {x}.")
                return True
            return False      
        
        now = self.now
        if not islist(expr):
            if isgen(expr):
                return [[now + 0, now + 0, expr]]
            raise TypeError(f"not a generator: {expr}")
        elif ispair(expr):
            return [[now + expr[0], now + expr[0], expr[1]]]

        # If we reach here, expr is a list that does not start with
        # a number so every item has to be a gen or a list [num gen]
        entries = []
        for e in expr:
            #print("e:", e)
            if not islist(e):
                if isgen(e):
                    entries.append([now + 0, now + 0, e])
                else:
                    raise TypeError(f"not a generator: {e}.")
            elif ispair(e):
                entries.append([now + e[0], now + e[0], e[1]])
            else:
                raise ValueError(f"not a generator list [time, gen]: {e}.")
        return entries


    def _run(self):
        """
        The scheduling loop that processes queued composers until there are
        no more composers to run. To process the current composer in the
        queue the Score performs the following tasks:

        * the current (first) composer is popped off the queue.
        * the queue's self.now and self.elapsed times are updated.
        * the composer generator is called via next().
        * if the next() call yields a positive ahead value back the composer
          is reinserted  back into the queue at time now + ahead, otherwise
          the composer is not reinserted in the queue.
        """

        self.running = True
        while len(self._queue) > 0:
            # Pop earliest entry from the queue and get its composer
            entry = self._queue.pop(0)
            # Set the current score time to the entry's time.
            self.now = entry[0]
            # print(f"before composer, score time is {self.now}")
            # Set the composer's elapsed time since starting
            self.elapsed = self.now - entry[1]
            #print("***elapsed=", self.elapsed)
            composer = entry[2]
            try:
                # Call the entry's composer. if it yields a positive number
                # increment entry's current scoretime and add it back into the _queue.
                delta = next(composer)
                if isinstance(delta, (int, float)): # and delta >= 0:
                    if delta >= 0:
                        # Advance entry's time and insert back into queue
                        entry[0] += delta
                        # print(f"after composer, delta is {delta} and entry time is now {entry[0]}")
                        self._insert(entry)
                else:
                    raise ValueError(f"invalid yield value {delta} from composer {entry[2]}.")
            except StopIteration:
                # print("stopping", composer)
                pass

 
    out = None
    """
    An optional object a composer can add musical events to. The Score
    does not examine the contents this variable.
    """


    running = False
    """True if the Score is running."""


    now = 0
    """
    The time point in the score that the currently executing composer is at.
    """
 

    elapsed = 0
    """
    The amount of time the currently executing composer has been running since
    it was added to the Score.
    """
 

    def compose(self, composer):
        """
        Starts running one or more part composers. This function can be
        called at the top level and also by composers that are running.

        Parameters
        ----------
        composer : generator | list
            If composer is a generator it is added to the queue at the current
            time. Otherwise composer can be a list [*ahead*, *composer*]
            or a list of the same [[*ahead*, *composer*], ...]  where
            *ahead* is a future start time and *composer* is a part composer.

        Example
        -------
        ```py
        def simp(q, num, rate, key):
            for _ in range(num):
                print("simp at", q.now, "plays", key)
                yield rate
        q=Score()
        # running one composer
        q.compose(simp(q, 5, 1, 60))
        # running two at times 0 and 5
        q.compose([[0, simp(q, 5, 1, 60)], [5, simp(q, 10, .251, 72)]])
        # a composer that composes composers =:)
        def simp2(q, num)
            for i in range(num):
                print("simp2 at", q.now)
                q.compose([q.now+i*2, simp(num, 5, .25, 60+i)])
            yield 2
        q.compose(simp2(q, num))
        ```
        """

        #print("in compose")
        if self.running:
            # A composer added while running just gets inserted it at
            # the propper time in the queue which is already running.
            for e in self._insure_entries(composer):
                #print("r adding", e)
                self._insert(e)
        else:
            for e in self._insure_entries(composer):
                #print("x adding", e)
                self._insert(e)
            try:
                # This is a loop that processes the queue until it is empty. It
                # is in a try statement because we always want to clean up, even
                # if an error is thrown.
                self._run()
            finally:
                self._clean()
                #print("Done!")

    def add(self, event):
        """
        Adds an event to the score. 
        """
        self.out.add(event)
        # Since the score is a scheduler we can just append the event to the seq.
        #self.out.append(event)

Class variables

var elapsed

The amount of time the currently executing composer has been running since it was added to the Score.

var now

The time point in the score that the currently executing composer is at.

var out

An optional object a composer can add musical events to. The Score does not examine the contents this variable.

var running

True if the Score is running.

Methods

def add(self, event)

Adds an event to the score.

Expand source code
def add(self, event):
    """
    Adds an event to the score. 
    """
    self.out.add(event)
    # Since the score is a scheduler we can just append the event to the seq.
    #self.out.append(event)
def compose(self, composer)

Starts running one or more part composers. This function can be called at the top level and also by composers that are running.

Parameters

composer : generator | list
If composer is a generator it is added to the queue at the current time. Otherwise composer can be a list [ahead, composer] or a list of the same [[ahead, composer], …] where ahead is a future start time and composer is a part composer.

Example

def simp(q, num, rate, key):
    for _ in range(num):
        print("simp at", q.now, "plays", key)
        yield rate
q=Score()
# running one composer
q.compose(simp(q, 5, 1, 60))
# running two at times 0 and 5
q.compose([[0, simp(q, 5, 1, 60)], [5, simp(q, 10, .251, 72)]])
# a composer that composes composers =:)
def simp2(q, num)
    for i in range(num):
        print("simp2 at", q.now)
        q.compose([q.now+i*2, simp(num, 5, .25, 60+i)])
    yield 2
q.compose(simp2(q, num))
Expand source code
def compose(self, composer):
    """
    Starts running one or more part composers. This function can be
    called at the top level and also by composers that are running.

    Parameters
    ----------
    composer : generator | list
        If composer is a generator it is added to the queue at the current
        time. Otherwise composer can be a list [*ahead*, *composer*]
        or a list of the same [[*ahead*, *composer*], ...]  where
        *ahead* is a future start time and *composer* is a part composer.

    Example
    -------
    ```py
    def simp(q, num, rate, key):
        for _ in range(num):
            print("simp at", q.now, "plays", key)
            yield rate
    q=Score()
    # running one composer
    q.compose(simp(q, 5, 1, 60))
    # running two at times 0 and 5
    q.compose([[0, simp(q, 5, 1, 60)], [5, simp(q, 10, .251, 72)]])
    # a composer that composes composers =:)
    def simp2(q, num)
        for i in range(num):
            print("simp2 at", q.now)
            q.compose([q.now+i*2, simp(num, 5, .25, 60+i)])
        yield 2
    q.compose(simp2(q, num))
    ```
    """

    #print("in compose")
    if self.running:
        # A composer added while running just gets inserted it at
        # the propper time in the queue which is already running.
        for e in self._insure_entries(composer):
            #print("r adding", e)
            self._insert(e)
    else:
        for e in self._insure_entries(composer):
            #print("x adding", e)
            self._insert(e)
        try:
            # This is a loop that processes the queue until it is empty. It
            # is in a try statement because we always want to clean up, even
            # if an error is thrown.
            self._run()
        finally:
            self._clean()
            #print("Done!")