Skip to content

variables in cycles#107

Merged
emuell merged 21 commits intodevfrom
cycle-vars
Mar 3, 2026
Merged

variables in cycles#107
emuell merged 21 commits intodevfrom
cycle-vars

Conversation

@unlessgames
Copy link
Copy Markdown
Collaborator

A draft implementation of #95

  • Made the Replicate and Weight similar to other expressions in that they are being applied at runtime instead of while parsing. By relying on existing functionality, this also extended them so that now floats can be used on the right side (instead of the previous integer restriction) like a@1.5 b.
    Unfortunately, since we have to support the case without parameter like a! and a ! ! ! as a "repeat last" shorthand, it isn't possible to parse patterns on the right side of this without rewriting a huge part of parser to be a lot more stateful.
  • Introduced an enum around the previous Value, it can now can be either Constant (the struct previously called Value) or a Variable or VariableTarget, these latter cases hold an identifier string that the cycle resolves based on an HashMap<Rc<str>, Constant> into a Constant when outputting events. This works for essentially any single value inside the cycle including targets using the $identifier notation.
  • If the identifier is not found in the map, it falls back to Constant::Name(identifier). Not sure whether it should throw an undefined variable error here, but it seems nicer to let you have another chance of remapping this after the cycle has been generated, although things where the cycle itself would want to use the resolved value will fail somewhat silently like a * $undefined_id

Note, tests for cycles were moved to a separate file because rust-analyzer started to struggle a bit while editing.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Feb 8, 2026

Benchmark for b41e98e

Click to view benchmark
Test Base PR %
Cycle/Generate 48.0±0.52µs 47.0±0.52µs -2.08%
Cycle/Parse 298.1±5.69µs 333.3±5.52µs +11.81%
Rust Phrase/Clone 433.2±12.31ns 419.1±5.96ns -3.25%
Rust Phrase/Create 63.1±0.61µs 68.1±0.84µs +7.92%
Rust Phrase/Run 654.3±5.48µs 640.9±3.64µs -2.05%
Rust Phrase/Seek 180.8±331.09µs 136.5±253.37µs -24.50%
Scripted Phrase/Clone 645.8±6.61ns 645.2±13.92ns -0.09%
Scripted Phrase/Create 999.7±26.46µs 999.0±17.30µs -0.07%
Scripted Phrase/Run 1725.4±27.28µs 1671.0±12.48µs -3.15%
Scripted Phrase/Seek 223.1±450.39µs 219.9±445.14µs -1.43%

@emuell
Copy link
Copy Markdown
Member

emuell commented Feb 9, 2026

Looks great so far. Only have read through the diff - haven't tried it out yet, but will soon. Should I take care of injecting the vars or do you want to? We need to take care here and avoid wasting useless CPU cycles in case parameters don't change and in case there are no variables present in the cycle. Else this should be straight forward.

but it seems nicer to let you have another chance of remapping this after the cycle has been generated, although things where the cycle itself would want to use the resolved value will fail somewhat silently like a * $undefined_id

I'd vote for throwing errors here, as I can't think of a use case where remapping parameter string values would make sense or add a great new feature. Especially because you can't remap a parameter value to complex expressions but just note values or numbers.

@unlessgames
Copy link
Copy Markdown
Collaborator Author

Note, this could be useful for not just straight parameter injection, but being able to do something programmatic with or without parameters. Like what you suggested with [a b c]*${div / 2}, but without including scripting inside the cycle.

I imagine a similar thing as map might be useful where you could either inject the parameters as vars or supply a function that generates the table of vars from the context to transform parameters or do other procedural stuff: maybe you want to use one parameter but have more vars being derived differently from it or not use parameters at all and instead generate values procedurally.

I suppose injecting the parameters should be the default behaviour without the need to write anything but a function could be specified to override this.

Especially because you can't remap a parameter value to complex expressions but just note values or numbers.

You can map to any Constant from the cycles perspective, which means you could also inject other types like Rest or Name for example, I think this might be interesting from a control standpoint, like you could essentially toggle notes this way, or have output remaps paired with names and switch between those via a parameter.

@emuell
Copy link
Copy Markdown
Member

emuell commented Feb 10, 2026

Note, this could be useful for not just straight parameter injection, but being able to do something programmatic with or without parameters. Like what you suggested with [a b c]*${div / 2}, but without including scripting inside the cycle.

Wouldn't this be possible with a regular identifier (FOO), rather than a variable ($FOO) too?

Either way, we can simply assert unresolved identifiers after mappings have been applied, at:

fn generate(&mut self) -> Vec<EmitterEvent> {
.

But I'd design this for what we have now, rather than what we might have later. If we add other things later, we can adjust the assertions then as well.

@unlessgames
Copy link
Copy Markdown
Collaborator Author

Wouldn't this be possible with a regular identifier (FOO), rather than a variable ($FOO) too?

The difference is that variables are resolved any time the cycle generates so you can inject values that affect the structure of the output, whereas by remapping identifiers, you can only change the events after they have been generated.

The things on the right side of all operators are affected by this:

  • a*$FOO lets you output a or a a a etc just by changing the parameter/variable that sets $FOO (ie you turn a knob from 1 to 3 in this case)
  • whereas a*FOO will output nothing since the value of FOO isn't known at output time and the right side has to be resolved to a float to calculate the multiplication (and a Name currently resolves to zero here).

If the variable is on the left side and both variable and remap uses the same value then the results are the essentially the same, $FOO*2 is the same as FOO*2 because the cycle can output FOO FOO without knowing what FOO is and you can still map the events afterwards.

@unlessgames
Copy link
Copy Markdown
Collaborator Author

unlessgames commented Feb 11, 2026

Because of the overlap in functionality, I wonder if simply merging these two things would be nicer here.

We could reserve $ for automatic assignment of variable names (parameters or context variables) and expressions (using ${..}), while regular identifiers could still be assigned manually both before or after Cycle::generate and they would be placed in the same variables table.

So Cycle::from would automatically create this table upon parsing, and the outer context could resolve any expression inside before calling generate.

For the table, instead of HashMap<Rc<str>, Constant> we could have something like

enum VarKey {
    Ident(Rc<str>),
    Exp(Rc<str>),
}

type VarTable = HashMap<VarKey, Option<Constant>>;

Where Exp would simply unwrap the expression from $.

For example

kick*$repeat bass*mult kick bass*mult:v=${(step % 100) / 100}

would result in

Cycle {
  vars: HashMap {
    VarKey::Ident("kick") : None,
    VarKey::Exp("repeat") : None,
    VarKey::Ident("bass") : None,
    VarKey::Ident("mult") : None,
    VarKey::Exp("(step % 100) / 100") : None,
  }
  ..
}

So any string inside an Exp is just something that should be executed in lua with a set of variables expected to be in scope (like repeat and step in the above example) and result in something that can be converted to a Constant type for the cycle. Whereas any Ident is an identifier that the user has to define explicitly, either via a function or a table and remap before or after generation.

For example you could use the above as

return pattern {
  unit = "1/1",
  parameters = {
    parameter.integer("repeat", 1, {1, 8})
  },
  event = cycle("kick*$repeat bass*mult kick bass*mult:v=${(step % 100) / 100}")
    :vars(function(ctx) 
      return {
        kick = "c4 #0",
        bass = "c3 #1",
        mult = ctx.parameter["repeat"] * 2,
      } 
    end)
}

(note, some helper functions for creating note constants in lua would be needed here and currently a variable in cycles cannot be compound like a note and targets like instrument)


Additionally, the outer context could choose to try to assign Idents automatically as well, for example in the context of Renoise, assigning kick and bass above to a corresponding sample name's note/instrument would be quite ergonomic.

So any invalid Exp would result in a scripting error before generate is called and any Ident still unmapped after output would result in a runtime error just like how it is right now.

@emuell
Copy link
Copy Markdown
Member

emuell commented Feb 13, 2026

That would be pretty powerful, but I find it confusing when identifiers and expressions are mixed up in functionality. Then it's unclear what each one actually is and when to use it.

In the above example, the same could be done with the regular map and expressions, but it's then more obvious that a name (identifier) resolves to some note (instrument) and that the expressions things are doing something fancy:

return pattern {
  unit = "1/1",
  parameters = {
    parameter.integer("repeat", 1, {1, 8})
  },
  event = cycle("kick*$repeat bass*${repeat * 2} kick bass*${repeat * 2}:v=${(step % 100) / 100}")
    :map{
      kick = "c4 #0",
      bass = "c3 #1",
    } 
}

Implementation wise, it also will be tough to apply mappings before generating the cycle. Also, it's unclear to me which kind of mappings would work and which ones not.

Would this work?

return pattern {
  unit = "1/1",
  event = cycle("a <b c>")
    :with{
      a = "[kick]*2",
      b = "[kick g4 <a4 a5>]",
      c = "[a4 g4]",
    }
    :map{ 
      kick = "c4" 
    } 
}

Note with vs map here. with is applied before and map after generate.

Then one could split up complex cycles via identifiers, which is pretty cool and then definitely would be worth the extra complexity :)


Apropos. How exactly would we resolve identifiers within ${} expressions?

In the example above step is from the runtime context. But what if there's a step defined as a parameter, as well?

${(step % 100) / 100}

Could scope the context variables here,

${(context.step % 100) / 100}

then only context would be a reserved name and can't be used as parameter. This is quite verbose though.

Or we enforce using $ within ${} to address a parameter value:

${($some_parameter % 100) / 100}

Which feels a bit ugly.

@emuell
Copy link
Copy Markdown
Member

emuell commented Feb 17, 2026

While more flexible mappings of expressions and identifiers (or just parameters) would be great, let's finish the PR as it is, with simple evaluation of $parameter ,and discuss improvements later in a new issue?

Should I take care of injecting the variables/parameters, or would you like to give this a try?

@unlessgames
Copy link
Copy Markdown
Collaborator Author

unlessgames commented Feb 17, 2026

In the above example, the same could be done with the regular map and expressions

Sure, but it would need you to repeat the same expression like in your example. This isn't a big deal in such a small example but if you want to reuse the same value in more places or define deeper relationships between different variables, pure repeated expressions become unwieldy and hard to experiment with.

Also, it's unclear to me which kind of mappings would work and which ones not.

Currently a variable can be any value from a Constant

pub enum Constant {
    #[default]
    Rest,
    Hold,
    Float(f64),
    Integer(i32),
    Pitch(Pitch),
    Chord(Pitch, Rc<str>),
    Target(Target),
    Name(Rc<str>),
}

This excludes Steps like your example. The issue here is that if variables could be Steps then cycle creation would be recursive, either we'd need a "pure cycle" that cannot contain inner variables (or at least sub-cycles) and a "compound cycle", or we and the user would have to deal with possible cyclic reference issues across cycles and setting variables for sub-cycles, and continuously parsing at runtime if subcycles change etc.

Also, it would be very natural to use "a, b, c..." for this like your example, but these are reserved identifiers for notes.


For simple substitution there could be a separate helper in Cycle that would return a string for you with the subcycles replaced but this wouldn't be dynamic, it would act like a macro. This could be easy to implement and doesn't have any of the caveats of variable subcycles, but it's also fairly rigid.

cycle(replace("a a b <c d>", {
  a = "bd bd*2",
  b = "<[~ hh] [hh hh]>?0.2",
  ..
}))

Apropos. How exactly would we resolve identifiers within ${} expressions?

In the example above step is from the runtime context. But what if there's a step defined as a parameter, as well?

True. I suppose the most flexible would be to have a sort of let block where you can specify the identifiers that you want which would be executed before resolving expressions and could decide what identifiers exists. This would make it so that you can use your own variables inside expression which I think is fairly important on the long run.

So, the default could be for example bringing all context variables into the scope, then overriding with parameter names. Then you could further override this table yourself with something like

cycle("...")
  :let(function(context, vars)
    -- vars is already a table with the defaults applied
    -- override as you wish
    vars["step"] = context.parameter["something_else"] * 4 * context.step
    return vars
  end)

Personally I am all for reducing the stuff you need to write here, so useful defaults with the ability to bend it to your will feels right to me even if there are some subtle things that can happen like a parameter overriding a context variable. If you made a parameter "step" it is safe to assume you want to use that instead of "context.step" if you find yourself wanting to use both then you could simply rename the parameter, I think this is better price to pay in these cases than learning a new syntax or having to write context.something in every expression ever.

@unlessgames
Copy link
Copy Markdown
Collaborator Author

While more flexible mappings of expressions and identifiers (or just parameters) would be great, let's finish the PR as it is, with simple evaluation of $parameter ,and discuss improvements later in a new issue?

Makes sense, guess it might be safe to decide that writing $paramname should give you the parameter injected as-is, although I am not sure about whether it would be better if we'd just treat any name inside the cycle as a potential variable instead, so just writing paramname would get you the same thing with one less extra symbol.

Should I take care of injecting the variables/parameters, or would you like to give this a try?

That would be nice!

@emuell
Copy link
Copy Markdown
Member

emuell commented Feb 18, 2026

This could be easy to implement and doesn't have any of the caveats of variable subcycles, but it's also fairly rigid.

Yes. Actually we can do that with simple Lua string manipulation hacks right now as well.

So, the default could be for example bringing all context variables into the scope, then overriding with parameter names. Then you could further override this table yourself with something like

Make sense! I'd vote to call that variables, vars or context instead though.

[...] although I am not sure about whether it would be better if we'd just treat any name inside the cycle as a potential variable instead, so just writing paramname would get you the same thing with one less extra symbol.

I vote for a clean separation between variables and names, to make them easier for users to understand, and easier for us to implement.

What about:

  • Names (e.g., "[kick snare]") are applied after the cycle-generated events. This allows using custom names for events. Names do not affect the cycle step generation.

  • Variables (e.g., "[c4*$step]") are applied before the cycle generates events. This allows to inject parameter values or other custom behavior, as it may change affect step generation. Later on, we may also use bash-like expressions to compute things here as well.

I'll have a look at the parameter injection stuff tomorrow.

@emuell
Copy link
Copy Markdown
Member

emuell commented Feb 19, 2026

As expected, injecting the vars was pretty straightforward. You may want to check that the parameter value and cycle value look OK (impl From<&Parameter> for CycleValue).

@github-actions
Copy link
Copy Markdown

Benchmark for beeb1b2

Click to view benchmark
Test Base PR %
Cycle/Generate 48.3±0.43µs 47.4±0.61µs -1.86%
Cycle/Parse 304.7±5.30µs 310.3±7.58µs +1.84%
Rust Phrase/Clone 430.3±5.18ns 445.6±6.47ns +3.56%
Rust Phrase/Create 64.2±2.86µs 66.0±1.76µs +2.80%
Rust Phrase/Run 643.6±6.17µs 641.0±8.50µs -0.40%
Rust Phrase/Seek 148.7±260.09µs 136.6±254.43µs -8.14%
Scripted Phrase/Clone 640.3±10.25ns 651.7±9.80ns +1.78%
Scripted Phrase/Create 992.3±11.04µs 1010.7±28.80µs +1.85%
Scripted Phrase/Run 1695.3±16.77µs 1727.1±15.90µs +1.88%
Scripted Phrase/Seek 229.4±466.22µs 220.9±447.04µs -3.71%

@github-actions
Copy link
Copy Markdown

Benchmark for d9f0d38

Click to view benchmark
Test Base PR %
Cycle/Generate 48.5±0.65µs 46.2±0.53µs -4.74%
Cycle/Parse 299.9±2.53µs 379.7±6.39µs +26.61%
Rust Phrase/Clone 426.4±5.85ns 473.9±9.05ns +11.14%
Rust Phrase/Create 64.1±1.42µs 73.1±1.45µs +14.04%
Rust Phrase/Run 648.1±6.03µs 651.5±4.13µs +0.52%
Rust Phrase/Seek 136.2±252.59µs 136.0±251.72µs -0.15%
Scripted Phrase/Clone 646.5±8.46ns 659.0±8.37ns +1.93%
Scripted Phrase/Create 990.1±9.15µs 1023.8±18.67µs +3.40%
Scripted Phrase/Run 1673.9±9.49µs 1702.0±32.96µs +1.68%
Scripted Phrase/Seek 219.0±443.36µs 219.5±444.05µs +0.23%

@unlessgames
Copy link
Copy Markdown
Collaborator Author

unlessgames commented Feb 22, 2026

Enum parameters just becoming a Name introduces inconsistency here because you can define an parameter like {"1", "2", "4", "8"} (which could be a common multiplier variant I guess) but this would turn into Names to be mapped in the output and the cycle wouldn't parse or use them as numbers anymore, whereas if this was written verbatim in the cycle, it could never be parsed as a name at all.

So here you could both create a kind of Name that is otherwise not possible and also have errors from unmapped events you might expect to work. Having non-continuous sets of values here might be one of the most handy feature in practice.


I made the Cycle expose functionality for parsing constants to be used with this and used this parsing when setting the parameters on cycle emitter and scripted_cycle to cache the results.

Since parsing a string as a constant can fail (when calling constant_from) we probably want to handle that error somewhere, but the tricky part is that as long as you don't use the parameter as a variable in the cycle, the enum can have any value.

@github-actions
Copy link
Copy Markdown

Benchmark for 788efb9

Click to view benchmark
Test Base PR %
Cycle/Generate 47.5±0.41µs 46.7±0.45µs -1.68%
Cycle/Parse 300.4±4.57µs 379.2±5.50µs +26.23%
Rust Phrase/Clone 431.2±3.67ns 431.5±11.94ns +0.07%
Rust Phrase/Create 63.8±1.35µs 72.1±1.10µs +13.01%
Rust Phrase/Run 637.6±15.66µs 641.8±8.99µs +0.66%
Rust Phrase/Seek 136.8±248.07µs 134.1±247.68µs -1.97%
Scripted Phrase/Clone 663.3±12.45ns 660.4±7.48ns -0.44%
Scripted Phrase/Create 1008.8±42.26µs 1007.5±28.85µs -0.13%
Scripted Phrase/Run 1689.0±12.58µs 1686.1±22.28µs -0.17%
Scripted Phrase/Seek 230.8±469.93µs 218.4±441.28µs -5.37%

@emuell
Copy link
Copy Markdown
Member

emuell commented Feb 22, 2026

Enum parameters just becoming a Name introduces inconsistency here because you can define an parameter like {"1", "2", "4", "8"} (which could be a common multiplier variant I guess) but this would turn into Names to be mapped in the output and the cycle wouldn't parse or use them as numbers anymore, whereas if this was written verbatim in the cycle, it could never be parsed as a name at all.

I find that too vague and magic, and I would simply have kept those values as strings. It's not clear what for example, {"1/8", "1", "2"} evaluates into. Also {"a", "b", "c"} also not be magically be converted to note values?

More complex parameter value conversions should IMHO be mapped manually later in those custom bash like expressions, e.g. ${toNumber(value)} or the suggested var functions, so errors handling also happens there.

@unlessgames
Copy link
Copy Markdown
Collaborator Author

unlessgames commented Feb 23, 2026

I find that too vague and magic, and I would simply have kept those values as strings. It's not clear what for example, {"1/8", "1", "2"} evaluates into.

"1/8" throws an error (currently discarded) since it cannot be parsed a single constant (we could add support for 1%8 notation for such fractions). If the parse error would make it to the user, it'd be transparent what is going wrong.

Other values will behave pretty much as if you wrote them into the cycle string. I think this is a really powerful and straightforward behaviour that lets you select from a list of values without any additional boilerplate or extra syntax. Even if it was possible via var or expressions to do the same, such code could quickly become error-prone and verbose, whereas this keeps the script declarative and concise and covers a lot of common usecases.

When needed, you can always use unique names to be able to remap them later (or just other variables where you convert the enum any way you want), but one of the key designs we agreed on is that parameter notation is for values to be used before generation happens to be able to affect the structure of the output.

If we just cast to string here, then the usage becomes "parameters can affect the structure, except when they are enums, you'll have to revar/remap those, parse strings by hand, hit the right indices, implement conversion functions etc". I think we'd lose out on a very low hanging fruit for ergonomics.

Also {"a", "b", "c"} also not be magically be converted to note values?

What do you mean here? Such an enum would output notes in both types of enum conversion, wouldn't it?


Unrelated but do the bindings for this dev branch need something special or they are just broken at the moment? Currently in Renoise using the libpattrns.so, any pattrns output is panned hard right and with -1.0 vol? Would be cool to be able to test this branch there.

@emuell
Copy link
Copy Markdown
Member

emuell commented Feb 23, 2026

As I mentioned before, I find that too clever - or not clever enough. For me, an enum parameter is a string and thus should be treated like one, or it should be a full cycle sub expression, but not something in-between. I don't think it's clear to users what can be magically auto-mapped and what not.

For example:

pattern {
  parameter = {
    parameter.enum("enum", "c4", { 
      "c4", -- works 
      "c5 c6", -- does not work and is not causing an error
      "c5:v0.2", -- does not work and is not causing an error
      "48", -- works
      "bla", -- does not work and is causing an error as expected
      "~", -- works
      "!", -- does not work
    } ),
  },
  event = cycle "g5 $enum"
}

Also this has significantly slowed down the cycle parser. Not sure if that's worth it?

Comparing f5a9cfa to latest locally here:

cargo bench -- .*Cycle
   Compiling pattrns v0.9.3 (C:\Users\emuell\Development\Crates\pattrns)
    Finished `bench` profile [optimized + debuginfo] target(s) in 15.11s
     Running benches\benches.rs (target\release\deps\benches-ebd18df90f2e9965.exe)
Cycle/Parse             time:   [725.37 µs 728.04 µs 730.96 µs]
                        change: [+40.303% +42.118% +43.963%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 3 outliers among 100 measurements (3.00%)
  3 (3.00%) high severe

Cycle/Generate          time:   [83.902 µs 84.506 µs 85.144 µs]
                        change: [+0.2817% +2.2197% +4.5173%] (p = 0.03 < 0.05)
                        Change within noise threshold.
Found 6 outliers among 100 measurements (6.00%)
  2 (2.00%) high mild
  4 (4.00%) high severe

I'd love to have sub cycle expressions here, but don't think it's worth the trouble to solve that problem partially now. What we have now already is a great addition as it is. Keeping things simple and solving problems step by step.

As a compromise, number strings, as only exception, could be coerced to numbers though. Because that's what Lua does everywhere else too and is simple to define and explain.

@unlessgames
Copy link
Copy Markdown
Collaborator Author

Note, the "not causing an error" cases above do cause an error, it's just discarded here currently when turning the Err into a None. Ideally, this would bubble up to the user.

.map(|s| Cycle::constant_from(s).ok())

Implementation-wise the only exception for single values is the !, having those individual repeats be evaluated at generate-time requires a lot more on the cycle-side, but it could be done.

As a compromise, number strings, as only exception, could be coerced to numbers though. Because that's what Lua does everywhere else too and is simple to define and explain.

That would already be good as numbers are a big part of making this useful, although it does feel like a similar "solving the value injection problem halfway".


Also this has significantly slowed down the cycle parser. Not sure if that's worth it?

This is true for this entire PR, right? As we are introducing a new type of single value with $.., it adds an extra check at the lowest level of parsing. I'll look into what could be done here though or if messed something else up.

@unlessgames
Copy link
Copy Markdown
Collaborator Author

It seems most of the performance drop was my silly mistake switching to eagerly evaluated error formatting.

@github-actions
Copy link
Copy Markdown

Benchmark for c092fe9

Click to view benchmark
Test Base PR %
Cycle/Generate 47.8±0.50µs 48.5±0.83µs +1.46%
Cycle/Parse 303.9±6.13µs 323.0±4.68µs +6.28%
Rust Phrase/Clone 434.3±10.63ns 435.5±6.88ns +0.28%
Rust Phrase/Create 63.9±0.70µs 68.9±1.52µs +7.82%
Rust Phrase/Run 647.1±10.82µs 644.0±4.70µs -0.48%
Rust Phrase/Seek 134.9±249.43µs 134.7±247.67µs -0.15%
Scripted Phrase/Clone 642.5±7.54ns 654.7±8.64ns +1.90%
Scripted Phrase/Create 1007.9±42.18µs 997.4±15.12µs -1.04%
Scripted Phrase/Run 1678.7±9.37µs 1678.1±11.55µs -0.04%
Scripted Phrase/Seek 227.5±460.55µs 221.3±446.78µs -2.73%

@github-actions
Copy link
Copy Markdown

Benchmark for 441241b

Click to view benchmark
Test Base PR %
Cycle/Generate 47.7±0.33µs 48.4±0.58µs +1.47%
Cycle/Parse 305.0±4.12µs 315.9±5.05µs +3.57%
Rust Phrase/Clone 432.5±7.23ns 431.1±2.82ns -0.32%
Rust Phrase/Create 65.5±1.69µs 67.4±1.44µs +2.90%
Rust Phrase/Run 642.6±5.24µs 642.2±5.16µs -0.06%
Rust Phrase/Seek 136.6±253.00µs 142.8±265.72µs +4.54%
Scripted Phrase/Clone 642.1±27.24ns 654.7±14.40ns +1.96%
Scripted Phrase/Create 1003.8±45.72µs 1023.4±43.63µs +1.95%
Scripted Phrase/Run 1673.6±9.83µs 1702.0±15.05µs +1.70%
Scripted Phrase/Seek 218.7±441.70µs 228.7±462.61µs +4.57%

@github-actions
Copy link
Copy Markdown

Benchmark for 5a6dae0

Click to view benchmark
Test Base PR %
Cycle/Generate 47.9±0.45µs 49.2±0.50µs +2.71%
Cycle/Parse 299.1±3.86µs 319.6±4.58µs +6.85%
Rust Phrase/Clone 422.1±3.75ns 455.5±25.85ns +7.91%
Rust Phrase/Create 64.1±0.99µs 66.9±1.06µs +4.37%
Rust Phrase/Run 630.9±4.72µs 651.3±4.33µs +3.23%
Rust Phrase/Seek 134.2±248.16µs 141.5±261.87µs +5.44%
Scripted Phrase/Clone 646.1±7.12ns 660.5±8.74ns +2.23%
Scripted Phrase/Create 995.3±26.21µs 1011.1±26.21µs +1.59%
Scripted Phrase/Run 1668.7±16.30µs 1697.3±11.25µs +1.71%
Scripted Phrase/Seek 222.2±464.36µs 219.3±443.94µs -1.31%

@github-actions
Copy link
Copy Markdown

Benchmark for 06a564f

Click to view benchmark
Test Base PR %
Cycle/Generate 48.4±0.76µs 49.7±0.64µs +2.69%
Cycle/Parse 303.2±18.67µs 318.1±3.35µs +4.91%
Rust Phrase/Clone 430.1±41.88ns 434.2±6.61ns +0.95%
Rust Phrase/Create 64.2±1.21µs 67.9±1.32µs +5.76%
Rust Phrase/Run 650.7±4.21µs 647.3±4.77µs -0.52%
Rust Phrase/Seek 135.9±252.55µs 140.8±262.34µs +3.61%
Scripted Phrase/Clone 641.2±9.13ns 662.4±9.11ns +3.31%
Scripted Phrase/Create 1008.7±18.25µs 1011.2±18.16µs +0.25%
Scripted Phrase/Run 1674.7±12.61µs 1690.2±11.13µs +0.93%
Scripted Phrase/Seek 220.4±446.26µs 217.1±438.17µs -1.50%

@unlessgames
Copy link
Copy Markdown
Collaborator Author

I've made vars into subcycles with the caveat that a subcycle cannot itself contain variables. This restriction makes it easy to not have to deal with cyclic references between subcycles (for now), to make this explicit, the SubCycle struct wraps a Step in a way that obtaining an impure SubCycle is not possible.

The exception around injecting individual repeats remain as this is a harder problem to solve.

If this is enough to be considered for the automatic conversions of enum parameters then I'll write some more tests.


Made it so that errors for parsing the enum aren't discarded, but not sure how to properly handle the errors in the lua context and the compiled cycle emitter. Could you take a look at these?

fn set_parameters(&mut self, parameters: ParameterSet) {
match Parameter::parse_subcycles(&parameters) {
Ok(parameters) => self.parameters = parameters,
Err(err) => {
// FIX handle this more gracefully?
panic!("{err}")

fn set_parameters(&mut self, parameters: ParameterSet) {
match Parameter::parse_subcycles(&parameters) {
Ok(with_subcycles) => {
// store parameters, so we can inject them in generate()
self.parameters = with_subcycles;
// and pass them to the mapping callback context
if let Some(timeout_hook) = &mut self.timeout_hook {
timeout_hook.reset();
}
if let Some(callback) = &mut self.mapping_callback {
if let Err(err) = callback.set_context_parameters(parameters) {
callback.handle_error(&err);
}
}
}
Err(err) => {
// FIX make error point to the enum def?
add_lua_callback_error(None, None, "enum".to_string(), LuaError::RuntimeError(err));
}
}
}

@emuell
Copy link
Copy Markdown
Member

emuell commented Feb 24, 2026

If this is enough to be considered for the automatic conversions of enum parameters then I'll write some more tests.

I wanted to make our life's easier, but this of course is super great to have and works great from what I've tested!

This will especially be cool with the planed vars function. But let's add this one in a new PR though and just finalize what we have now?


I think the panic in the non scripted cycle emitter is fine, as sub cycles are evaluated immediately when setting parameters. Alternatively I'll check how hard it is to return a result in set_parameters and also fix/adapt the scripted emitter error handling.


Unrelated but do the bindings for this dev branch need something special or they are just broken at the moment? Currently in Renoise using the libpattrns.so, any pattrns output is panned hard right and with -1.0 vol? Would be cool to be able to test this branch there.

The note event struct layout changed in the dev branch. There's a new float glide; parameter. If you remove that here:

pub glide: f32,

It should be compatible with the master version, but I haven't tested this.

Alternatively you can build and run the playground locally:

just pg-run

Just noticed that a combination of newer rustcs with newer emscripten emccs no longer work here. Seems that in newer versions -fwasm-exceptions is enabled by default, which conflicts with -fexceptions from the Lua rust builds. -fwasm-exceptions should in theory be a lot more lightweight and thus faster, so this is a good fix either way...

@emuell
Copy link
Copy Markdown
Member

emuell commented Feb 24, 2026

Fixed the playground builds in dev: 6e73330

So you may want to rebase, if needed...

@github-actions
Copy link
Copy Markdown

Benchmark for d0e1a9b

Click to view benchmark
Test Base PR %
Cycle/Generate 48.7±0.79µs 49.7±0.73µs +2.05%
Cycle/Parse 306.0±16.94µs 320.1±7.46µs +4.61%
Rust Phrase/Clone 424.3±10.19ns 435.1±6.45ns +2.55%
Rust Phrase/Create 62.4±1.11µs 67.0±1.48µs +7.37%
Rust Phrase/Run 645.0±4.37µs 660.6±5.29µs +2.42%
Rust Phrase/Seek 141.2±266.49µs 135.5±249.92µs -4.04%
Scripted Phrase/Clone 650.7±9.68ns 656.9±14.83ns +0.95%
Scripted Phrase/Create 1024.0±35.73µs 1008.7±15.59µs -1.49%
Scripted Phrase/Run 1680.7±14.66µs 1720.4±12.17µs +2.36%
Scripted Phrase/Seek 219.2±448.31µs 227.9±461.59µs +3.97%

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 1, 2026

Benchmark for 64566ba

Click to view benchmark
Test Base PR %
Cycle/Generate 48.5±0.54µs 58.7±0.63µs +21.03%
Cycle/Parse 301.5±5.43µs 309.6±4.25µs +2.69%
Rust Phrase/Clone 422.9±6.95ns 443.1±6.40ns +4.78%
Rust Phrase/Create 62.1±1.11µs 65.7±0.84µs +5.80%
Rust Phrase/Run 644.5±3.85µs 651.6±5.15µs +1.10%
Rust Phrase/Seek 135.8±250.66µs 144.1±269.98µs +6.11%
Scripted Phrase/Clone 657.0±8.31ns 670.1±11.21ns +1.99%
Scripted Phrase/Create 997.6±14.30µs 1002.3±23.50µs +0.47%
Scripted Phrase/Run 1686.5±15.81µs 1718.3±14.56µs +1.89%
Scripted Phrase/Seek 220.5±447.24µs 221.3±448.42µs +0.36%

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 1, 2026

Benchmark for 424bc74

Click to view benchmark
Test Base PR %
Cycle/Generate 47.7±1.00µs 57.5±0.93µs +20.55%
Cycle/Parse 274.0±14.03µs 283.0±3.81µs +3.28%
Rust Phrase/Clone 432.2±3.57ns 440.5±10.55ns +1.92%
Rust Phrase/Create 56.7±1.27µs 58.9±0.63µs +3.88%
Rust Phrase/Run 600.6±8.96µs 607.8±6.23µs +1.20%
Rust Phrase/Seek 132.3±243.08µs 131.6±242.28µs -0.53%
Scripted Phrase/Clone 593.8±9.36ns 615.2±10.74ns +3.60%
Scripted Phrase/Create 931.1±15.56µs 930.5±9.36µs -0.06%
Scripted Phrase/Run 1604.3±12.36µs 1641.2±14.16µs +2.30%
Scripted Phrase/Seek 199.2±397.52µs 207.6±417.92µs +4.22%

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 1, 2026

Benchmark for 111eb9a

Click to view benchmark
Test Base PR %
Cycle/Generate 48.3±0.63µs 58.2±0.83µs +20.50%
Cycle/Parse 300.0±7.57µs 320.0±13.85µs +6.67%
Rust Phrase/Clone 428.1±4.71ns 442.2±5.09ns +3.29%
Rust Phrase/Create 62.7±1.46µs 67.4±1.37µs +7.50%
Rust Phrase/Run 639.0±6.82µs 651.2±5.67µs +1.91%
Rust Phrase/Seek 136.4±252.49µs 140.7±261.62µs +3.15%
Scripted Phrase/Clone 644.6±5.58ns 675.3±12.03ns +4.76%
Scripted Phrase/Create 1004.9±30.71µs 1025.0±41.65µs +2.00%
Scripted Phrase/Run 1679.1±17.26µs 1726.4±12.24µs +2.82%
Scripted Phrase/Seek 228.2±461.13µs 229.1±463.80µs +0.39%

@unlessgames
Copy link
Copy Markdown
Collaborator Author

I've made repeats also dynamic so they can be injected via variables as well.

I also missed that dynamic weights and replicate somewhat broke polymeter expressions so these needed a refactor.

Unfortunately all of this does come with some performance cost as the length of a section is no longer known at parse time so it has to be calculated while generating to correctly output polymeters. I suppose there is some opportunity to optimize here by caching but I'd do this in another go. If that's alright we could merge this now.

- mention enum parameter name and value in sub cycle parameter error strings

- forward parse errors as runtime error, but return a "rest" value on errors and continue parsing,
  so parse errors don't get masked by other errors

- add some basic tests
@emuell
Copy link
Copy Markdown
Member

emuell commented Mar 2, 2026

I've tweaked the error handling a bit so it's a bit more clear where exactly the error happened.

Else this looks and works great. Would be great to update the docs and tutorial as well, but we can add those later, when adding the 'var" function or as a separate PR as well.

So let me know when you're done. I'll then squash this into dev.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 2, 2026

Benchmark for 8c9089b

Click to view benchmark
Test Base PR %
Cycle/Generate 48.4±0.46µs 59.0±0.77µs +21.90%
Cycle/Parse 301.1±5.84µs 312.6±3.77µs +3.82%
Rust Phrase/Clone 421.3±6.79ns 437.5±7.10ns +3.85%
Rust Phrase/Create 62.7±1.70µs 65.9±1.84µs +5.10%
Rust Phrase/Run 640.5±8.63µs 655.4±6.29µs +2.33%
Rust Phrase/Seek 141.6±256.48µs 142.2±262.27µs +0.42%
Scripted Phrase/Clone 653.6±10.71ns 657.2±12.85ns +0.55%
Scripted Phrase/Create 1000.1±40.84µs 1010.3±34.08µs +1.02%
Scripted Phrase/Run 1667.6±12.51µs 1719.4±19.75µs +3.11%
Scripted Phrase/Seek 219.4±444.50µs 222.7±449.72µs +1.50%

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 3, 2026

Benchmark for 57972a9

Click to view benchmark
Test Base PR %
Cycle/Generate 48.6±0.54µs 53.1±0.81µs +9.26%
Cycle/Parse 298.9±3.83µs 319.4±19.64µs +6.86%
Rust Phrase/Clone 440.6±6.85ns 433.6±5.27ns -1.59%
Rust Phrase/Create 61.8±0.88µs 67.8±4.42µs +9.71%
Rust Phrase/Run 639.7±3.85µs 649.3±6.18µs +1.50%
Rust Phrase/Seek 137.6±255.18µs 137.5±254.99µs -0.07%
Scripted Phrase/Clone 669.1±5.91ns 672.1±14.04ns +0.45%
Scripted Phrase/Create 989.9±9.82µs 1004.1±18.13µs +1.43%
Scripted Phrase/Run 1684.3±13.49µs 1710.6±14.96µs +1.56%
Scripted Phrase/Seek 221.3±447.68µs 230.4±466.67µs +4.11%

@unlessgames
Copy link
Copy Markdown
Collaborator Author

Tried caching some stuff but it didn't matter much, on the other hand, I've tweaked some things unrelated to this PR that improved the performance a bit, this should be a more acceptable cost to pay here overall.

With that, I am fine to merge this. I agree that the docs might be better updated when vars is implemented so that we don't have to redo the explanation for the same topic.

@unlessgames unlessgames marked this pull request as ready for review March 3, 2026 05:40
@emuell emuell merged commit a4f38b8 into dev Mar 3, 2026
2 checks passed
@emuell emuell deleted the cycle-vars branch March 3, 2026 07:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants