monadgroup / axiom

A powerful realtime node-based audio synthesizer.
Other
678 stars 31 forks source link

Conditional statement #148

Open remaininlight opened 6 years ago

remaininlight commented 6 years ago

So this isn't actually a pull request yet, it's a rough sketch of what a conditional statement might look like (I thought Maxim could use one?). However, this is my first time writing Rust and I've only dabbled lightly in compilers before.. so apologies for the sharp edges and lack of it compiling atm.

I thought I'd ask your opinions @cpdt before I go too much further/ make any structural changes to what already exists. Any pointers are useful, I suspect some change might be necessary to codegen but I don't want to be too brutal - I imagine you probably have some reasons for the current architecture!

Also, how are you debugging the parser at the moment? I thought some tests (like https://github.com/TheDan64/inkwell/tree/master/tests/all) might be useful but haven't set any up yet.

Parsing

AST

Mir

Codegen

remaininlight commented 6 years ago

Amazing, thanks for the detailed feedback! Interesting learning how it's all put together.

I completely agree with you on disabling/enabling functionality on the node level being a good way to go most of the time and look forward to checking out Extractors. My desire for a conditional is motivated by wanting to be able to create midi events for generative and interactive music ie.

if sensor_value == 1:
    // Send midi note

There shouldn't be anything too heavy on their branches and tbh I don't think I'd even use the else that much, so for now I think changing the MIR structure shouldn't be necessary atm - given that evaluating both then and else should be cheap.

I'd wondered how inputs and outputs were determined, that sounds neat - I'll check it out when I have time. I love the flexibility of being able to modulate all the controls with each other but I think it might be a bit confusing for your average musician/ casual user. If they form part of your intended audience I wonder if, at some point in the future, some way of hiding internal state from the control surface and overriding each control's status as an input or output could be a good idea - perhaps in the right-click menu for that control? Otherwise, if people are making nodes themselves imo they should know better than to plug signals into outputs etc.

Re: the difference in updates for controls and functions, I may be missing some subtlety here but it sounds like the functions would behave correctly and there would be some extra processing for a control which isn't active on that branch? I think that a constant cost for each control is not too unexpected, painting them on the screen probably gives you a comparable overhead. I admire your eye for detail in elegance/efficiency (I guess this is from demoscening?) but if Axiom is being used on a PC I don't think anyone would ever notice ;) + If they do then they could do their switching at the node level.

I look forward to progressing through your comments over the next few days.

cpdt commented 5 years ago

@remaininlight Hey, sorry for taking a while to get back to you... had a busy weekend :)

That makes sense with the branching. However, I am concerned that using that kind of "traditional" if/else structure will mean people will assume only one branch is evaluated instead of both... That's fine in a lot of cases, but it becomes a bit of a problem when the branches have side-effects (e.g. setting a control's value or calling a function like delay that has some internal state). It would probably also make the result of something like this quite confusing:

if some_cond:
    my_var = 2
else:
    my_var = 3

Since both branches are executed, my_var would end up always being 3 which doesn't make much sense!

Something that would be a bit easier to implement (wouldn't require changes in the parser or MIR) is to define a new if function, that looks like this:

my_var = if(some_cond, 2, 3)

I think that makes the semantics a bit more clear. What do you think?

Regarding the MIDI use-case specifically: yeah, that's been something I've been wanting to implement for a while, but I'm not 100% sure on the best way to do it yet. I wonder if something like:

out:midi = midi_note(gate, note, velocity)

would work better than having an explicit if? The way I'm imagining that is that internally the function would emit a note on event when gate rises, then a note off when it falls. Might be a bit more error-proof than providing functions to just emit MIDI events :)

As for inputs/outputs, yeah I have some plans around that to make it easier to understand. I think what you're talking about with hiding internal state is something that group nodes already handle nicely (if I'm understanding your point correctly) - they basically allow you to have a whole node area inside of them, and you can 'expose' the controls you want from inside that to show on the group node's control surface. I do think we can do a lot more around showing which controls are inputs and outputs, and which ways data is flowing through connections. Currently input number controls default to knobs while outputs default to "plugs" (which look kinda like read-only knobs), although I have considered adding arrows to knobs and connections to make that more clear. I think that added clarity will also allow us to add more behaviour around how connections interact, too. For example, if you currently connect two outputs into one input, one of the outputs will override the other (and it's not defined which). But a more useful behaviour here would probably be to add together the values of those two inputs, which could get quite confusing without some way to easily see the direction of connections.

As for control/function updates, it's less about performance (which is still very important when you're running the functions 44100 times per second :D) and more about side-effects. For example, the graph control has a bar moving across that shows the current time in the graph. The position of this bar (and the state of the graph) is updated in the control's update function. I was imagining a case where someone was only using the control inside a branch, and was possibly expecting the graph to become 'disabled' (i.e stop moving) when that branch isn't active, which wouldn't happen because control updates are always run. But I'm not too sure if that actually would be counter-intuitive, and it doesn't really matter with the "branchless" design anyway.

remaininlight commented 5 years ago

No worries, I can relate! :) I think I'm slowly starting to get my head more into Maxim, now I see that in the case where both branches are executed it would not be possible to conditionally execute only one of them (by definition..).

I had actually started making an if function when I first came to this but abandoned it in favour of chasing the if statement. I agree the function is clearer for the case when both branches are executed, it looks like it might be used in the place some languages use a ternary statement:

my_var = some_cond ? 2 : 3

Though I guess that's still a little ambiguous as to what is executed. I'm not sure if Maxim can return different types from the same function (ie. can a function return a midi message or something which casts to no midi message?) - would it be possible to conditionally send a midi note with this? Perhaps something like:

out:midi = if(some_cond, midi_note(channel, note, velocity), 0)

A midi_note function could be a good way to go for many people's use cases, though if a note on/off is triggered each time the gate changes I guess you could end up with 44100 notes per second from a continuously changing signal? Maybe having gate non-zero being a note and zero being no output could work.

Note ons with zero velocity vs note offs are pragmatically the same thing in my experience (in fact I think it says something along those lines in the MIDI standard somewhere) but some people might also want to be able to send note offs with velocities. Most libraries I've seen have two functions, meaning the note_on function (which is used most of the time) doesn't have to have too many parameters and people can still use a note_off function when they want it.

Yes, now you mention it, groups do that well. Arrows sound good, as does adding the values of the connections together on one input. Re: controls and function updates I am guided by your knowledge of how these pieces fit together :)

For now I have implemented an if function which is on this pull request. It doesn't quite work.. it doesn't switch when the conditional input changes but I thought I'd send it over anyway - I have to be up early and can't quite finish it tonight.

cpdt commented 5 years ago

Hey! Just wanted to say, thanks for the continued interest/work on this :)

Unfortunately I don't think we'll be able to get away with just an Any type. Since we're compiling code to bare-metal it's pretty important that we do actually know what the types are - bad stuff would happen if a user tried to do this for example:

# 'something' is typed as Any but contains a Midi value
something = if(cond, note_on(), note_off())
# out:num expects a Num, since 'something' is Any it passes the type checker, but it'll crash when emitting the LLVM IR
out:num = something

What we need is a way for a function declaration to define generic parameters, and then the AST -> MIR lowering pass can check this and change the call to be to a concrete function (i.e one with actual types defined). Then we just need a way to instantiate the concrete function, which I don't think should be complicated. I can have a look at getting this infrastructure in place sometime soon if you want, since it'll probably get quite far down into the nitty-gritty of how all the pieces work together :)

This does raise a question though, of if we want to allow the function to have different behaviour for different types. For example, numeric values contain two floats for left and right... if you have an expression like:

if(cond, a, b)

and cond is equal to {0, 1}, should the result be {b.left, a.right}? Or should we just take the left channel value like we'd do for other types (since they don't have two channels) and return b? I kinda prefer the first one since it better mirrors how other numeric functions work (i.e they operate independently for each channel), but it might be counterintuitive that it doesn't work that way for other types?