supriya-project/supriya

Introduce NodeProxies

Opened this issue ยท 8 comments

Is your feature request related to a problem? Please describe.

I enjoy the JITlib approach to sclang with Ndefs, Pdefs etc, so something like this is possible

Ndef(\lfo, {SinOsc.ar(2.0)});
Ndef(\saw, {LFSaw.ar(200.0 * Ndef.ar(\lfo)) *0.2 }).play;

// update/replace lfo
Ndef(\lfo, {LFDNoise3.ar(5.0)});

I checked out supriya really quickly and I did not find anything like this immediately.

The basic idea is that you can not loose a handle to a running synth by overwriting a variable - instead by overwriting a variable you also overwrite/replace it on the server.

Describe the solution you'd like

I still don't have a concise idea how this could be best implemented, I see this issue more as design process or discussion.
I think we can change the idea of NodeProxies to be more pythonic, having the best of both worlds.

A quick Idea that came to my mind is by using a subscriptable class (of which one could have multiple or in a recursive/nested manner?), attaching each node as element of a class instead of having independent objects (like in the SC implementation).

from supriya.ugens import LFSaw, SinOsc, LFDNoise3

Ndef["lfo"] = SinOsc.ar(2.0)
Ndef["saw"] = LFSaw.ar(200.0 * Ndef["lfo"]) * 0.2

# not so nice - can we declare and play at the same time?
Ndef["saw"].play()

# update
Ndef["lfo"] = LFDNoise.ar(5.0)

I think all the necessary lifting could be done in the __getitem__ and __setitem__ methods, albeit the (internal) wiring of Ndefs can be really tricky sometimes.

An alternative could be looking at ProxySpace in SuperCollider.

p = ProxySpace.push(s);

~lfo = {SinOsc.ar(2.0)};
~saw = {LFSaw.ar(~lfo * 200.0) * 0.2};
~saw.play;

~lfo = {LFDNoise3.ar(5.0)}

The problem of a ProxySpace is that every function will be treated as a NodeProxy, so defining a function like ~myFunc = {"foo".postln} results in an error as a String is not something used on the synth.
Using Python I don't see a problem in this case.

One could use e.g. a context manager for proxyspace

with ProxySpace("foo") as p:
  lfo = SinOsc.ar(2.0)
  saw = LFSaw.ar(lfo)
  saw.play()
  # replaces old lfo value instead of overwriting it
  # so we don't have a dangling LFO running on our server
  lfo = LFDNoise.ar(2.0)

# or
p = ProxySpace("foo")

p.push()
lfo = SinOsc.ar(2.0)
saw = LFSaw.ar(lfo)
p.pop()

Or maybe make a tabula rasa and create something pythonic from the ground up?

I would really enjoy contributing to this project - maybe we can have some discussion here how to implement this and then I can do a first draft? :)

Additional context

Kudos to the programming style!

Currently deep in reimplementing Supriya's internals, so a bit of a vague response...

I'm not opposed to implementing something in the spirit JITlib, but I need to understand much more concretely what it's actually doing.

Some of the behavior concerns treating various types of entities interchangeably (synths, patterns, functions) and providing means of seamlessly connecting and crossfading between them. Other parts of the behavior seem to be UX affordances for live coding: how can we allow users to type as little as possible in a live-coding situation to create maximum impact?

An implementation in Supriya starts by teasing those two categories apart through a close reading of the code, with a couple principles in mind:

  • no global state
  • avoid implicit behavior (i'd prefer to not implement another kind of behavior like UGens magically populating SynthDefs)
  • keep class interfaces as small as possible
  • everything needs to be easily testable (testability should drive design)
  • everything needs to be typed, preferably clearly so (testability again)
  • low-level implementation has to take priority over syntactic sugar

Basically, we have to implement what's under the hood, as verbose as it needs to be in order to be clear, before we think about whether we want user-level code to use context managers, or subscripting, or magically converting name bindings to JITlib operations.

FWIW, my gut sense is that subscripting is the winner because it's the least implicit, assumes no global state, doesn't add magic or additional methods to SynthDefs or UGens, and will play the best with Python's typing tools. Supriya's never going to be as "pithy" as sclang; that's just not one of my guiding principles ๐Ÿ˜…

Some of the behavior concerns treating various types of entities interchangeably (synths, patterns, functions) and providing means of seamlessly connecting and crossfading between them. Other parts of the behavior seem to be UX affordances for live coding: how can we allow users to type as little as possible in a live-coding situation to create maximum impact?

Yes, this is indeed just some of the behavior, which happened to be useful. It is a consequence of not having to decide in advance.

One of the main things that may not be apparent immediately is being able to refactor temporal co-dependencies on the fly. It removes the classification of inputs and outputs, and before and after.

Here is a paper that describes the core ideas: http://wertlos.org/~rohrhuber/articles/Algorithms_Today.pdf

Thanks @telephon, that's very helpful ๐Ÿค”

@capital-G Are you still interested in pursuing this? The big refactor just wrapped up in #323 (although documentation is still in progress), so new features are unblocked.

Hey @josiah-wolf-oberholtzer, thanks for the reminder. Yes, I think NodeProxies would be a great addition to supriya as they are me my preferred "dialect" and it would be interesting what Python enables in regards of live coding (e.g. having dynamic classes, language server with inspection, network access and the whole ecosystem etc.).

I mainly have 2 questions:

  • Should this be developed within supriya (then I would fork it and work on a branch) or outside as an additional package? In sclang it is included in the core library, but as the dependency management of Python is better than in sclang, it would be also nice to keep supriya as small as possible? On the other hand it would not make sense to mix versions of both packages and I found monorepos easier to maintain. It is also possible to use extras within setup.py to have different versions of supriya available.
  • How close should it be to the sclang implementation? I would start by closely inspecting the source code of the sclang NodeProxy (as this have been well-proven over time) and then try to implement it in a really close manner through supriya. After this there could be a refactoring to make it more Pythonic and maybe even going beyond it and looking what could be improved?
  • I can imagine something like this going into Supriya eventually. Unlike GUI stuff, there's no equivalent to JITlib in the Python ecosystem, so might make sense to keep it close. Try forking, open a PR, and we can take the conversation from there.

  • I know I don't want to recapitulate the same interfaces, names, or (often) design patterns I see in sclang. A lot of code started in Supriya mirroring sclang's interfaces and semantics, but has changed considerably over the last ~decade. If I were doing this myself I would study the original code, but start designing in Python with a tabula rasa. There are some general principles over here that are going to force API decisions for anything new coming into the project: no global state (no default servers etc.), avoid implicit behavior, use context managers if implicit behavior is unavoidable, provide verbose explicit methods for anything that also uses fancy syntax tricks, etc.

Cheers!

It I were you, I'd try to implement the core ideas:

  • placeholder can be used in several places and its source can be exchanged
  • transition happens without clicks
  • atomicity (execution order should not matter (too much))
  • mapping/setting environment is kept between sources
  • number of channels and rates are recursively adjusted (this is maybe more advanced, but very good)

Let's talk through API design / implementation a little bit.

Here's the example at the top of the original ProxySpace
documentation
:

s.boot;
p = ProxySpace.new;
p.fadeTime = 2; // fadeTime specifies crossfade
p[\out].play; // monitor an empty placeholder through hardware output
// set its source
p[\out] = { SinOsc.ar([350, 351.3], 0, 0.2) };
p[\out] = { Pulse.ar([350, 351.3] / 4, 0.4) * 0.2 };
p[\out] = Pbind(\dur, 0.03, \freq, Pbrown(0, 1, 0.1, inf).linexp(0, 1, 200, 350));
// route one proxy through another:
p[\out] = { Ringz.ar(p[\in].ar, [350, 351.3] * 8, 0.2) * 4 };
p[\in] = { Impulse.ar([5, 7]/2, [0, 0.5]) };
p.clear(3); // clear after 3 seconds

And here's what I want this to look like in Python:

s = Server().boot()  # There's no default server in Supriya, by design
p = ProxySpace(s)  # 1) ProxySpace needs an explicit reference to a server
p.fade_time = 2
p["out"].play()
with p["out"]:  # 2) SynthDefs should be built inside a context manager
    SinOsc.ar(frequency=[350, 351.3]) * 0.2
with p["out"]:
    Pulse.ar(frequency=[350 / 4, 351.3 / 4], width=0.4) * 0.2
# 3) Let's ignore patterns for the initial feature
p["out"] = EventPattern(
    duration=0.03,
    frequency=RandomPattern(distribution="BROWN"),
).scale(0, 1, 200, 350, exponential=True)
# 4) Referencing proxies as SynthDef parameter should use a parameter factory,
#    not the NodeProxies directly
with p["out"] as psp:
    Ringz.ar(source=psp["in"].ar(), frequency=[350, 351.3] * 8, decay_time=0.2) * 4
with p["in"]:
    Impulse.ar(frequency=[5 / 2, 7 / 2], phase=[0, 0.5])
p.clear(3)

Yes, this is a little more verbose, but we're never going to be as concise as sclang. That's not one of my goals.

I'll expand on the numbered points in the comments for my Python version.

  1. This one's easy. There's no global / default anything in Supriya -
    everything needs to be referenced explicitly - so the ProxySpace needs an
    explicit reference to the server you're intending to use. There used to be a
    "default" server, but I backed that out a few years ago.

  2. SynthDefs need to be built inside a context manager's runtime context.
    Python doesn't have curly braces or multi-line lambdas, so I use a
    SynthDefBuilder context manager for collecting UGens into a Synthdef. This is
    true even with Supriya's @synthdef function decorator, just hidden from sight.
    That means NodeProxies need to implement __enter__ and __exit__ methods
    for entering and exiting a runtime context. Internally those methods can setup
    a SynthDefBuilder object, enter its context and then exit it to collect and
    construct the SynthDef we'll use for the NodeProxy.

class NodeProxy:

    def __enter__(self) -> ProxySpaceParameterFactory:
        # create and save a reference to a synthdef builder
        self._synthdef_builder = SynthDefBuilder() 
        # enter the synthdef builder's context
        self.-synthdef_builder.__enter__()
        # create and return a parameter factory
        return ProxySpaceParameterFactory(self.proxy_space)

    def __exit__(self, *args) -> None:
        # exit the synthdef builder's runtime context
        self._synthdef_builder.__exit__()
        # build the synthdef with the collected ugens
        synthdef = self._synthdef_builder.build()
        # do whatever it is you do
        ... do stuff with the synthdef

We can talk about relaxing this restriction once the initial feature is done -
I'm sure the draw of saving a few keystrokes by just assigning a UGen to the
ProxySpace key is powerful - but I'd prefer to begin strict and relax later
rather than the reverse.

  1. Let's just ignore patterns for the MVP. While Supriya does have a pattern
    system, it only implements a subset of what sclang currently has, and that
    doesn't yet include Pbrown or .linexp. There's also the problem of
    setting up clocks. As in point 1) above, there's no default clock and there's
    not going to be, so any clock will either be created with the ProxySpace or
    passed in alongside the server argument. We'll cross this bridge later.

  2. NodeProxies should not be used directly to build UGen graphs. There's a few
    reasons for this.

    One, treating a NodeProxy as a UGen-compatible object means giving
    NodeProxy the entire interface exposed on Supriya's
    UGenMethodMixin.
    That interface includes nearly every operator override already, making those
    unusable for NodeProxies outside the context of SynthDef building. While sclang
    can invent additional operators (<<>, <>> etc.), we can't do that in
    Python, so best to reserve them.

    Two, even if operator overrides weren't an issue, I still wouldn't want to
    conflate NodeProxy and UGen. I want to keep my class interfaces as small as
    possible whenever I can. Supriya already has a Parameter class for modeling
    SynthDef parameters, let's just hijack that / subclass it for use with
    NodeProxies. Note in 2) above that NodeProxy's __enter__ returns something
    I'm calling a ProxySpaceParameterFactory. Whatever that class is, it knows
    about the ProxySpace, it can be subscripted with the same keys used on the
    ProxySpace, but its __getitem__ returns a Parameter (or subclass thereof)
    instead of a NodeProxy.

Lemme know if you have any questions!