256-fall-2009: HW2
Roy Fejgin
This compressed tar file contains all the files needed to build and run this assignment.
To build just type 'make'.
To clean all temporary files and executables type 'make clean'.
To execute, type './poly'.
The project builds for JACK/Linux.
A main concept in the way I implemented this assignment is the 'AudioChainElement'. This is an abstract class representing any entity that can be connected into an audio chain. That means it accepts audio inputs on one end, and generates audio outputs (samples) on the other. The number of inputs connected to the element and the number of outputs (other elements to which this one is connected) can be arbitrarily large.
A good example of an audio chain element is a filter. Another is a plucked string.
The to use an audio chain is:
Patch up any number of audio chain elements using their connectTo() API. A graph of any shape may be created.
Then, for each sample:
Call the tick() function on the element at the start
of the chain.
This function causes the whole chain to
generate its output by each element recursively calling tick() on
the elements it is connected to.
Read the audio output from the element at the end of the chain.
Call endOfTick() on the start of the chain to let all elements prepare for the next sample.
Many of the objects in this assignment are descendant classes of AudioChainElement. The plucked string is one example. But the delays and filters used inside the plucked string object are themselves AudioChainElements. That means that they can be independently be connected into audio chains, inside or outside the plucked string. In this exercise filters and delays are used only inside the plucked string (which contains its own little audio chain), but their code can be reused so that they are placed in any audio chain.
Another example of code reuse in this hierarchy is the graph traversal code: all classes share it since it implemented in the base class (AudioChainElement).
Note: maybe “Unit Generator” would have been a better name for the Audio Chain Element, but I implemented it before I'd knew that term.
(the README continues below)
AudioChainElement Class Hierarchy
The implementation attempts to take care of loops in the chain (i.e., cycles in the directed graph represents the chain). When we hit a node that has already been processed in the current tick (a tick is a traversal of the graph for the purpose generating a single output), the recursive call stops. The output generated by the last element in the loop is accumulated in the next element and used as part of that element's input for the next tick.
There may be a problem with this implementation, which wasn't sure how to solve. Consider a graph containing the following paths: X->Y->Z->DAC, and also X->T->Z->DAC. That is, there are two paths from A to C (but not an actual loop). My implementation will send a sample to the DAC along the first path, and when the second path is traversed and C is encountered for a second time, the traversal will stop (even though it's not an actual loop), and the output of T will be accumulated in Z until the next tick. I'm not sure if this one-sample delay is valid.
Last year, after finishing this homework I took a look at the ChucK implementation and realized that it traverses the UANA graph in reverse order (starting from the DAC). Would implementing it this way this have solved my problem?
Anyway – this problem doesn't affect the audio in this exercise since this it doesn't contain any graphs of the above form (proper loops, such as that one in the plucked string object, are handled correctly).
AudioChainElement.cpp.h – The abstract audio chain element described above.
FIRFilter.cpp, h – An abstract filter. It has a circular buffer containing the last n samples. Derived classes should be able to implement a filter using the samples in the buffer.
DelayFilter.cpp, h – Implements a delay. The delay can be changed on the fly.
MovingAvgFilter.cpp, h – A moving average filter. The number of samples being averaged is configurable.
PluckedString.cpp, h – A Karplus-Strong string simulation.
Poly.cpp – Contains the main(), the audio callback and the MIDI callback.
SimpleList.cpp,h – A very very simple list allowing insertion only. I wrote it before I knew we could use STL...
CircularBuffer.h – A template based implementation of the circular buffer described in class. The data type in the buffer is controlled by the template parameter.
Polyphony is arbitrary. The PluckedString objects are created dynamically.
For storing the PluckedString objects (actually pointers to them) I used an STL map. The mapping is from MIDI note number to the pointer to the PluckedString object.
In selecting the data structure to strings pointers, I considered two options:
The STL map containing only active strings (the strings are destroyed once they are finished).
An array of 128 pointers to strings, indexed by MIDI note.
I chose the STL map since it is more efficient in the most common
operation: iterating over all active strings in order to get sound
from them. That operation is carried out for each sample of each
audio callback. With a map implementation, we only need to iterate
over the active strings, and the complexity is O(number of active
strings), which is assumed to be far less than 128 most of the time.
Using the array we would always have to iterate over 128 cells,
whether or not there are active strings in them.
The other
operation to consider is note addition and removal. That is an O(lg
n) operation using the STL map and an O(1) operation with the array.
Still, the map was preferred since the former operation occurs far
more frequently.
Another data structure used is a STL list containing note numbers of strings that have completed their release phase and are waiting to be destroyed. During the audio callback we iterate over this list, remove the associated objects from the map, and destroy the objects. See more about the release mechanism in the following section.
When the MIDI callback receives a 'note off' MIDI message, release() is called on the associated PluckedString object.
In response, the PluckedString changes the gain of its feedback loop so that the audio level starts to decay rapidly.
Once the PluckedString has determined its release stage is finished, it calls a callback function that was given to it when it was constructed. This callback lets Poly.cpp know that the string is ready to be destroyed.
This implementation has the following advantages:
The PluckedString object is responsible for determining when its release phase is over. I think the string is the right object to make that decision since it is the entity which knows the most about a string's sound characteristics.
Using a callback means that the string is decoupled from Poly.cpp – it doesn't need to know anything about the object that created it.
I've noticed that if I hit the same key a few times, each time sounds slightly different, slightly detuned from the others. I'm not sure if this is due to a problem in my implementation or the result of the fact that a different random noise pattern is generated for each run of the Karplus-Strong algorithm.
A very meaningful statement by the name of plickpluck.
(it's not performed very well since it was recorded on a Windows setup with very high latency...).