supriya-project / supriya

A Python API for SuperCollider
http://supriya-project.github.io/supriya
MIT License
250 stars 28 forks source link

Introduce NodeProxies #313

Open capital-G opened 1 year ago

capital-G commented 1 year ago

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!

josiah-wolf-oberholtzer commented 1 year ago

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:

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 😅

telephon commented 1 year ago

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

josiah-wolf-oberholtzer commented 1 year ago

Thanks @telephon, that's very helpful 🤔

josiah-wolf-oberholtzer commented 1 year ago

@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.

capital-G commented 1 year ago

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:

josiah-wolf-oberholtzer commented 1 year ago

Cheers!

telephon commented 1 year ago

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

josiah-wolf-oberholtzer commented 1 year ago

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!