stochastic music generated from a grammar of melodies (in Python)

Kragen Javier Sitaker kragen at canonical.org
Sat Jul 6 03:37:01 EDT 2013


#!/usr/bin/python
"""A grammar of melodies.

Some results of various versions of this program are in
<http://canonical.org/~kragen/synthgramelodia>.  Run it with the names
of one or more sample .wav files (8-bit, mono) on the command line,
all of the same pitch, ideally 110Hz.  It will take an absurdly long
time to generate an output .wav file that is probably almost
listenable.

This is an effort to randomly generate listenable scores.  The idea is
to use an encoding in which listenable music has lower entropy than
unlistenable music.

One of the appealing things about bytebeat (see
<http://canonical.org/~kragen/bytebeat>) is how easy it is to
accidentally stumble upon listenable things.  This is because there
are a lot of listenable things, which in part is because of the
similarity in the underlying mechanisms of binary arithmetic and
traditional Western music.  The idea of this grammar is to encode a
little bit more music theory into the 'language', making listenable
music more frequent.

A 'score' lasts some power-of-2 number of beats and reduces ultimately
to a sequence of notes, each of which consists of:

- an instrument identifier,
- a pitch at which to play a note on that instrument, measured in
  semitones above or below the base pitch, A2 (110 Hz);
- a time at which to start playing it (duration is omitted), and
- a volume at which to play it;

coupled with a sound font that defines the instruments, this produces
a waveform you can listen to.

A 'score' is really a directed acyclic graph of scores.  The primitive
scores, the leaf nodes in the DAG, are:

- a Rest, which represents one beat of silence, and
- a NoteScore, which represents a sampled sound playing at full volume
  and the default pitch, for one beat.  There is one possible
  NoteScore for each instrument.

The other kinds of scores are different ways of combining or modifying
scores:

- Transpose: playing a score lowered by a perfect fifth;
- Louder: playing a score slightly louder (3dB);
- Sequence: playing one score followed by another at slightly
  diminished volume (2dB) and slightly altered timing, with one or the
  other repeated so that they each take equal time, if necessary;
- Parallel: playing two scores concurrently, one raised by an octave,
  with one or the other repeated so that they take equal time, if
  necessary;
- and I may add others, like time-reversal, inversion, simple
  repetition, slowing, speeding, and so on.

"""
from __future__ import division

import cgitb
import math
import os
import random
import sys
import wave

### Melody generation

class Note:
    def __init__(self, instrument, start_time, pitch, volume):
        self.instrument = instrument
        self.start_time = start_time
        self.pitch = pitch
        self.volume = volume

    def __str__(self):
        return "%s %s %s %s" % (self.instrument,
                                self.start_time,
                                self.pitch,
                                self.volume)

    def __repr__(self):
        return 'Note(%r, %r, %r, %r)' % (self.instrument,
                                         self.start_time,
                                         self.pitch,
                                         self.volume)

    def __cmp__(self, other):
        return cmp((float(str(self.start_time)), self.instrument, self.pitch, self.volume),
                   (float(str(other.start_time)), other.instrument, other.pitch, other.volume))

    def copy(self):
        return Note(self.instrument,
                    self.start_time,
                    self.pitch,
                    self.volume)

class Rest:
    def render2(self):
        return ()

    def beats(self):
        return 1

    def __str__(self):
        return '.'

    def peak_volume(self):
        return -200

    def tracks(self):
        return 1

    def depth(self):
        return 1

class NoteScore:
    def __init__(self, instrument):
        self.instrument = instrument

    def render2(self):
        yield Note(self.instrument, 0, 0, 0)

    def beats(self):
        return 1

    def __str__(self):
        return str(self.instrument)

    def peak_volume(self):
        return 0

    def tracks(self):
        return 1

    def depth(self):
        return 1
    
class Transpose:
    def __init__(self, score):
        self.score = score

    def render2(self):
        for note in self.score.render2():
            note.pitch -= 7
            yield note

    def beats(self):
        return self.score.beats()

    def __str__(self):
        return '_%s' % self.score

    def peak_volume(self):
        return self.score.peak_volume()

    def tracks(self):
        return self.score.tracks()

    def depth(self):
        return 1 + self.score.depth()

class Louder:
    def __init__(self, score):
        self.score = score

    def render2(self):
        for note in self.score.render2():
            note.volume += 3
            yield note

    def beats(self):
        return self.score.beats()

    def __str__(self):
        return '+%s' % self.score

    def peak_volume(self):
        return self.score.peak_volume() + 3

    def tracks(self):
        return self.score.tracks()

    def depth(self):
        return 1 + self.score.depth()

class RepeatFor:
    def __init__(self, score, beats):
        self.score = score
        self._beats = beats

    def render2(self):
        for note in self.score.render2():
            while note.start_time < self._beats:
                new_note = note.copy()
                yield note
                note = new_note
                note.start_time += self.score.beats()

class Sequence:
    def __init__(self, left, right):
        self.left = left
        self.right = right
        self._beats = 2 * max(left.beats(), right.beats())
        self._pv = max(self.left.peak_volume(), self.right.peak_volume() - 2)
        self._tracks = max(self.left.tracks(), self.right.tracks())
        self._depth = 1 + max(self.left.depth(), self.right.depth())

    def render2(self):
        b2 = self.beats()/2

        for note in RepeatFor(self.left, b2).render2():
            yield note

        right_start = b2 + 0.1

        for note in RepeatFor(self.right, b2).render2():
            note.start_time += right_start
            note.volume -= 2
            yield note
        
    def beats(self):
        return self._beats

    def __str__(self):
        return '(%s %s)' % (self.left, self.right)

    def peak_volume(self):
        return self._pv

    def tracks(self):
        return self._tracks

    def depth(self):
        return self._depth


class Parallel:
    def __init__(self, low, high):
        self.low = low
        self.high = high
        self._beats = max(low.beats(), high.beats())
        self._pv = max(low.peak_volume(), high.peak_volume())
        self._tracks = low.tracks() + high.tracks()
        self._depth = 1 + max(self.low.depth(), self.high.depth())

    def render2(self):
        for note in RepeatFor(self.low, self.beats()).render2():
            yield note

        for note in RepeatFor(self.high, self.beats()).render2():
            note.pitch += 12
            yield note

    def beats(self):
        return self._beats

    def __str__(self):
        return '(%s ^ %s)' % (self.low, self.high)

    def peak_volume(self):
        return self._pv

    def tracks(self):
        return self._tracks

    def depth(self):
        return self._depth


def _choose(lst):
    "Choose an item from lst with a bias toward later items."
    bias = 1.5
    return lst[int(random.randrange(int(len(lst)**bias))**(1/bias))]

def double(score):
    return Sequence(score, score)

def random_score(instruments, complexity):
    nodes = ([NoteScore(instrument) for instrument in instruments] +
             [Rest()] * len(instruments))
    while len(nodes) < complexity + len(instruments) + 1:
        node_type = random.choice([Transpose, Louder, Sequence, Parallel,
                                   Parallel, Sequence, Sequence, Sequence])
        if node_type in [Transpose, Louder, double]:
            new_node = node_type(_choose(nodes))
        else:
            new_node = node_type(_choose(nodes), _choose(nodes))

        nodes.append(new_node)

    return nodes[-1]


### Sound mixing

def resample_input(filename, rate):
    sample = wave.open(filename, 'r')
    assert sample.getnchannels() == 1
    assert sample.getcomptype() == 'NONE'
    freq = sample.getframerate()
    assert sample.getsampwidth() == 1   # until I implement more
    contents = sample.readframes(sample.getnframes())

    # A sum table.
    rv = []
    total = 0

    # Very crude resampling.
    for ii, sample_byte in enumerate(contents):
        while len(rv) < (ii+1) * rate / freq:
            total += ord(sample_byte)
            rv.append(total)

    return rv

class DitherClipper:
    def __init__(self): self.err = 0
    def dither(self, val):
        val += 128
        val_int = int(val + 0.5)
        self.err += val_int - val
        if self.err < -0.55:
            self.err += 1
            val_int += 1
        elif self.err > 0.55:
            self.err -= 1
            val_int -= 1

        if val_int < 0:
            self.err += -val_int
            val_int = 0
        elif val_int > 255:
            self.err -= val_int - 255
            val_int = 255

        return val_int

class Sample:
    def __init__(self, sample, speed, volume):
        self.sample = sample
        self.speed = speed
        self.volume = volume
        self.fp = 0
        
    def __iter__(self): return self
    
    def next(self):
        samp = self.sample
        fp = self.fp

        if fp == len(samp)-1:
            raise StopIteration
        
        nfp = min(fp + self.speed, len(samp)-1)
        infp, ifp = int(nfp), int(fp)
        assert infp != ifp, (nfp, fp, len(samp), self.speed)
        avg = (samp[infp] - samp[ifp]) / (infp - ifp)
        self.fp = nfp
        return (avg - 128) * self.volume

class Mixer:
    def __init__(self):
        self.notes = []

    def add(self, note):
        self.notes.append(note)

    def __iter__(self): return self

    def next(self):
        samples = []
        ii = 0
        while ii < len(self.notes):
            note = self.notes[ii]
            try:
                samples.append(note.next())
            except StopIteration:
                self.notes.pop(ii)

            ii += 1

        return sum(samples)

    def __len__(self):
        return len(self.notes)


### Main program

def choose_outfile_name():
    ii = 0
    while True:
        filename = 'synthgramelodia-%d.wav' % ii
        if not os.path.exists(filename): return filename
        ii += 1

def main(argv):
    cgitb.enable(format='text')
    output_rate = 8000
    oversampling = 16
    instruments = [resample_input(filename, output_rate * oversampling)
                   for filename in argv[1:]]
    bpm = 140 * 2 # * 32               # maximum notes per second on a track
    seconds_per_beat = 60 / bpm
    samples_per_beat = seconds_per_beat * output_rate

    instrument_names = 'abcdefghijklmnopqrstuvwxyz'
    score = random_score(instrument_names[:len(instruments)], 160)
    print score

    notes = list(score.render2())
    notes.sort(reverse=True)
    for note in notes:
        print note

    outfile = wave.open(choose_outfile_name(), 'w')
    outfile.setnchannels(1)
    outfile.setsampwidth(1)
    outfile.setframerate(output_rate)

    # A sample I'm working with happens to be like 326Hz, and I want
    # to resample it to 220Hz.
    base_pitch_speed = oversampling * 220 / 326

    mixer = Mixer()
    clipper = DitherClipper()
    sample_number = 0
    peak_volume = score.peak_volume()

    print "total beats %s, peak volume %s, %d tracks, %d nodes deep" % (score.beats(),
                                                                        peak_volume,
                                                                        score.tracks(),
                                                                        score.depth())
    
    print "planning %d samples" % (samples_per_beat * score.beats())

    output_buffer = []

    while sample_number < samples_per_beat * score.beats() or len(mixer):
        while notes and sample_number >= notes[-1].start_time * samples_per_beat:
            note = notes.pop()
            instrument = instruments[instrument_names.index(note.instrument)]
            pitch = max(note.pitch, -28)
            mixer.add(Sample(instrument,
                             speed = base_pitch_speed * 2 ** (pitch/12),
                             volume = 10 ** ((note.volume - peak_volume)/20)))
            print "now playing %d notes at once; latest: %s" % (len(mixer), note)

        output_buffer.append(chr(clipper.dither(mixer.next())))
        if len(output_buffer) > 128:
            outfile.writeframesraw(''.join(output_buffer))
            output_buffer[:] = []
            
        sample_number += 1

    print "%d samples in all; %d leftover notes!" % (sample_number, len(notes))

    outfile.writeframes(''.join(output_buffer))
    outfile.close()

if __name__ == '__main__':
    main(sys.argv)




More information about the Kragen-hacks mailing list