Roger B. Dannenberg
Constraints in Arco offer a style of programming where unit generator parameters and other values can be constrained to follow GUI controls and other input data.
Consider the problem of sending microphone input directly to the audio output, using a slider to control gain.
In "normal" event-driven object-oriented programming, we would use
callbacks from GUI controls to react to changes in the slider. In
wxSerpent, we use something like .add_target_method(obj, 'gain_change')
to get a callback to method gain_change in some object, and then
we define gain_change to update the gain in a mixer or multiplier.
In contrast, in a visual programming language like Max or Pd, we would probably just connect the slider to the multiply without all the "glue code" to receive and act on slider events.
A corresponding approach is provided by Arco constraints, which allow you to "declare" a connection from GUI control (slider) to unit generator. Of course, it is still text-based programming, but it seems to provide a principled way to extend the data-flow properties of signal processing at audio- and control-rates inside the Arco server to the realm of "constant-rate" parameter computation happening in the control program.
A Constraint is an abstract superclass that implements one-way constraints
from GUI controls to Arco const and smoothb unit generators. Specifically,
a constraint computes a single value from a set of input parameters, which
are represented as instance variables. The output is recomputed (normally)
when any input parameter changes, so that the output is always a function
of the current value of inputs.
Any change to an instance variable is made by calling set(name, value)
where name is the symbol representing the instance variable (e.g. 'x')
and value is the new value. This call triggers the recomputation
of the constraint and propagates the value.
For each constraint formula, a subclass of Constraint must be defined.
To make constraints general, we allow any number of input parameters, which
must be declared as instance variables. The constraint function must be
defined in the method compute() which has no explicit parameters, but
which ordinarily is a function of various instance variables. The
compute() method returns a computed value that is considered to be the
output value of the constraint.
In some cases, you may not want to recompute the constraint when a particular
instance variable changes. For example, you may know that instance variables
are set in sequence and it is better to wait for the last variable in the
sequence to be set. Or you might want to drive recomputation by a single
variable update that occurs periodically. To control constraint computation
and propagation, you can call passive(name) with the name of an instance
variable. Then, changes to the variable will not trigger a recomputation.
Details: Note that simply assigning the instance variable (e.g. obj.x = 5)
will not cause recomputation in any case. You must use obj.set('x', 5).
Also, computation will only occur if the value actually changes so
periodically calaling obj.set('x', t) will not cause the constraint to be
updated, but this will: obj.set('x', not obj.x) since it is guaranteed to
change x.
To "connect" a constraint to a GUI control, use the connect(control, name, control_number) method. control is a GUI control created by
Spinctrl, Slider, Labeled_slider, or Checkbox (a Checkbox is
considered to output 0 or 1 even though it's "native" output is
Boolean) or control can be a subclass of another Constraint. (Do
not create circular constraint dependencies!)
The name parameter is the symbol representing the instance variable to
be set to the value of the control. This parameter is optional and defaults
to 'x'. You can connect up to three controls to a single constraint,
and this is most sensible if there are multiple variables to
control. Each variable should be given a different number, e.g. for
two controls, call
constraint.connect(slider_a, 'x', 1)
constraint.connect(slider_b, 'y', 2)
Each variable must be associated with a different number.
If you want to use the output of the constraint in an audio computation,
you can create a const or smoothb unit generator by calling either
constraint.get_const() or constraint.get_smoothb(cutoff), where
'constraintis the constraint (object) andcutoffis the cutoff frequency.cutoffis optional and defaults to 10 Hz. These both provide block-rate signals, which are normally interpolated, so evenconst` gets
some smoothing as a by-product of up-sampling by linear interpolation
when it is used to control audio gain.
Once you have called get_const or get_smoothb, a second call will
return the same const ugen because ugens in Arco can fan out to any
number of inputs.
You can also "convert" constraint updates to callbacks by calling
constraint.add_target_method(target, method) where target is an
object and method is a method of the target (or if target is nil,
method names a global function). The method or function is called
with two parameters: the constraint (object) and its new value.
You can have any number of target/method pairs in combination with
either one const or one smoothb (but not both).
When can a constraint be garbage collected? When a constraint is
connected to a control, there is a reference from the control to
the constraint. Freeing the control will free the constraint. But
to remove the constraint while leaving the control intact requires
a call to the Constraint.finish() method. This removes the
constraint from the control's target objects and removes the
constraint's reference to a ugen. If the ugen is in use, it will
continue to provide the current constant values.
In short, call a constraint's finish method when you no longer
need it.
Getting back to our initial example, controlling input-to-output gain with a slider, the code in Serpent looks like the following:
You need the constraint implementation before you can use Constraint:
require "constraint" // you need the constraint implementation
Create a slider. Window position is (10, 5), size is (250, 'H') where
H means the default height, and the label width is 80. The range of
the slider is -60 to 20 using the 'db' mapping from slider position to
decibels. The name of the slider is 'monitor_gain_sl' which will
assign the slider to this global variable for convenience, and prefs
is a preferences object that is already created and open. This will
be used to automatically initialize the slider to a stored preference,
using preference name 'monitor_gain_sl', and if you save preferences,
the current value of the slider will be saved.
Labeled_slider(win, "Monitor Input", 10, 5, 250, 'H',
80, -60, 20, 0, 'db', 'monitor_gain_sl', prefs = prefs)
Next create a constraint. We're going to pan a mono signal to stereo
output, so the constraint is going to output an array of 2 gain values
that depend on the instance variable 'x', which already exists in
Simple_constraint so we'll subclass that. All we need to define is
the compute method, which converts x from dB to linear and calls
pan_45 to pan to center using the 4.5 dB pan law:
class Center_constraint (Simple_constraint):
def compute(): return pan_45(0.5, db_to_linear(x))
We need an instance of Center_constraint to manage our output gain.
Then, we connect it to the slider. The trace variable is entirely
optional: Setting it will cause constraint evaluations to be printed
so you can see the contraint at work. Normally, set this only for
debugging because it will generate a lot of output.
monitor_gain_constraint = Center_constraint()
monitor_gain_constraint.trace = "monitor_gain_constraint"
monitor_gain_constraint.connect(monitor_gain_sl)
Finally, we can implement the audio processing. We use get_const()
to obtain the pan and gain control. This will make a two-channel
const ugen. When we multiply that by a single channel audio signal,
we get a stereo output that we can send to the audio output. To
make sure we have a single channel audio signal (e.g. we might have
a stereo input with the mono input on the left channel), we'll use
a route ugen to select just the first channel (designated by the
second parameter, 0) regardless of how many input channels there are:
monitor_gain = mult(route(input_ugen, 0),
monitor_gain_constraint.get_const())
monitor_gain.play() // connect the mult ugen to audio output
If you want to delete this audio process, you should stop playing, "finish" the constraint, and remove references to the objects. The const ugen is referenced by both the mult ugen and the constraint, so when they are both garbage collected, the const will also be garbage collected:
monitor_gain.mute()
monitor_gain_constraint.finish()
monitor_gain = nil
monitor_gain_constraint = nil
I'm always thinking about visual vs. textual programminig systems and their relative merits. For Arco , that's 9 lines of code to send input to output with gain control, but the Center_constraint class is reusable, so maybe 7 lines. If you were controlling a lot of different sources in the same way, you could obviously write a function to create a slider, build a constraint and route the audio to simplify things further. As a one-time feature, maybe 9 lines is more work than the corresponding visual programming construction. With visual programming, though, adding the panning and channel assignments creates some extra work, and the ability of textual approaches like this to encapsulate, parameterize, and reuse little building blocks is a big advantage of textual programming languages.
There is still more to freeing unused objects. I think there is a clash between "push" and "pull" structures. Audio computation in Arco is "pull" because the audio output "pulls" data from the unit generators that it depends on. The recursively propagates up the tree toward signal generators. But we do not want the propagation to be accessing sliders and check boxes at audio rates, so we have "pull" structures such as Constraint objects that "push" changes from GUI callbacks through constraints and into Const unit generators, which can be read quickly as a "pull" access.
You can free subtrees of the "pull" structure when unit generators no longer depend on them. When we feed leaves of the tree with "push" structures, we get references "up" through the "pull" structure, but we also need references "down" through the "push" structure, leaving objects that are referenced from both directions.
Let's enumerate some cases of interest:
- One constraint controls one Const used by one Mult for gain control. If the sound finishes, we should remove the Mult, the Const and the constraint.
- One constraint controls one Const that is shared, e.g. by a succession of notes or sound events. When the sound event finishes, the Mult should be freed, but the Const survives to be shared by another note.
- Either of the cases above, except the slider is deleted. We want to free the constraint and its reference to the Const, but leave the Const in place with only a reference from the Mult.
- Like case 3, but with the option of recreating the slider. Here, we want the Const to be global and never freed, so it's a simpler case. In all cases, if we know the sound has finished, we can explicitly shut things down and delete them, so let's consider only cases where objects terminate and termination propagates to the Mult. Then what?
Case 1. When the Mult terminates, we can use the atend mechanism to
invoke finish on the constraint to run some clean-up code. The
method calls constraint.finish(). There were two references to the
Const: from Mult and from the constraint. Both references are removed,
so the Const is freed.
Case 2. We still want Mult to terminate so that it can be freed, but
we do not want the Const or constraint controlling it to go away. To
accomplish that, we simply do not set an atend action on the Mult,
and we do not need to use a Constrained_const. A Const will do.
Case 3. Freeing the control frees the constraint. Freeing the constraint frees the const_ugen. The Const (or Constrained_const) is now only referenced by the Mult, and will be freed when Mult is freed. If the Const is shared, reference counts will keep it alive until the last Mult is freed.
Case 4. If you kept a reference to the Const (Case 2), you can
reattach a new constraint when you have a new control (slider). If you
kept a reference to the constraint, you can connect a new slider
rather than creating a new constraint.