feat: expression blink function
A 'simple' appearing expression function which can be used in feedbacks or elsewhere, which returns a boolean
All the blink usages join a shared bus to react to the blinking in sync (I have been warned by someone with much more experience than me that for good UX blinking should be in sync).
All timers are run from the same starting reference point in time, so that multiples will be in sync, and stopping and starting one will keep its alignment (at the cost of the first blink may not be perfectly even)
Which allows for easily producing fun patterns like:
https://github.com/user-attachments/assets/8889f3e0-99da-4c94-9819-6de93280cee6
This is piping these updates through the global variables system, so I am a bit worried about the performance impact. I think we might want to look into ways to reduce the sprawl of the variable change events before considering merging this, but I was inspired to look at this.
I see that this gives a lot of freedom. I wouldn't have thought of using an expression function but I can see potential benefits over the solution I suggested somewhere earlier (expanding the invert switch to a dropdown with several blink options). While I see that this is built now mainly for blinking, I wonder if it would be better to name the function more generic. Other products call this a modulator, oscillator or LFO. Maybe "lfoSquare" would be the correct technical name. Or just "lfo" defaulting to a square wave and in the future we can add different waveforms with a second parameter.
Also it might be good to return 0 and 1 instead of a boolean. This should easily cast to a boolean but makes it easier to use in other mathematical expressions (123 + lfo(1000) * 42).
I guess when the time parameter is 0 or some invalid type the output is constantly off? What happens with negative time parameter, inverted output?
that is quite elegant.
although it would require a Conditionalise to combine it with a module provided feedback,
perhaps also making it difficult to create as part of a preset?
or is there a better way?
over the solution I suggested somewhere earlier (expanding the invert switch to a dropdown with several blink options).
honestly I forgot about that discussion, and didnt really read that issue.
I'm not that attached to this approach though, I did this more as a small fun experiment
I do see that this approach has the 'risk'/complexity of specifying the interval everywhere, which will make it riskier that users will make confusing non-aligned flashing.
But then it also has some value as a more general purpose tool. This should work in expression variables and other places too, for any kind of expression (I know it works in button text, didnt try anywhere else)
While I see that this is built now mainly for blinking, I wonder if it would be better to name the function more generic.
I wouldnt be opposed to calling this oscillate or something like that. lfo means nothing to me, so I would struggle to discover it named that.
I was considering expanding it to be blink(onTime, offTime), with offTime having a default of matching onTime when not defined.
Also it might be good to return 0 and 1 instead of a boolean. This should easily cast to a boolean but makes it easier to use in other mathematical expressions (123 + lfo(1000) * 42).
Makes sense to me, and I agree it will probably just work
I guess when the time parameter is 0 or some invalid type the output is constantly off? What happens with negative time parameter, inverted output?
Yes a non numeric input or <= 0 is fixed false.
I have imposed a minimum interval of 50ms to minimise risks of floods. That is a pretty arbitrary value, so could be higher or lower.
I did wonder about negative, but wasnt sure what it should do so left it as false.
@alex-Arc although it would require a Conditionalise to combine it with a module provided feedback, perhaps also making it difficult to create as part of a preset?
Instead of conditionalise, I would use the logic and feedback
Yes, this wont be usable in presets currently, until that general problem of not being able to use internal actions/feedbacks is resolved.
A blink function of some description could be an interesting approach, and more easily allow other modules to do what I wrote for the vMix module with the Shift button and blinking feedback through 'shift layers' (you could have a row of buttons for preview bus on inputs 1 to 8, then hold a 'shift' action and they now do 9 to 16, and the feedback I wrote in the vMix module does solid feedback if the current input is active, but blinks if the input on the other 'shift layer' is active), all with this sort of style of doing blinking as opposed to specific stuff written in to modules.
As for performance, a max blink frequency of 50ms is reasonable, slower systems such as a Pi may get a bit sluggish but it should still be responsive enough to adjust the blink to something slower rather than the system just locking up. In general I've found for blinking 333ms works quite nicely, as I used a timer to see how fast an ATEM panel blinks and went off of that as it seemed to look good :D Once you start getting blinking around 1000ms and slower, you start to run in to issues of the feedback staying on/off for such a long period that a user may glance down at a streamdeck, or when they're browsing through pages, and think the feedback is solid on, or solid off, rather thank blinking (which is one of the issues with the Generic Blink module IIRC, it doesn't do sub 1s blinking).
First of all, @Julusian, this is brilliant!
-
Question: does
blink(1000)stay on for 500ms or 1000ms (i.e. is the period 1s or the on-state 1s)? -
Related suggestion: when I was doing some timing-related stuff in my (unpublished) animation module, it became apparent that specifying the period in ms is awkward and nonlinear. What we are generally interested in is the frequency, i.e. 1/period. So for example, I may want something to blink 1, 2, 3, 4, 5, or 6 times per second (etc.), but in ms that's 1000, 500, 333, 250, 200, 167.... (And quick: what's the period for 7 Hz? or 13 Hz?)
So it might be better to have the argument be Hz rather than ms. As Jeff (@thedist) pointed out, rates slower than 1 Hz (longer than 1 ms) are probably not desirable anyway -- though of course one could specify 1/x for those.
And, sure, one could specify 1000/7 for 7 Hz, but why not design it to be direct to begin with? Using Hz may also make it clearer that the function will be "on" once per second and not every other second (my question, above).
To allow unequal periods, a second argument could specify the fraction "on", which defaults to 0.5, of course. So:
~~blink(frequency (Hz), fractionOn (0 - 1) or~~
blink(frequency (Hz), onOffRatio=1)~~the second arg could be of the form 1:1, 2:1, etc., which would be even simpler - either specified as a string or an array~~[edit: specify it as a fractiononOffRatio = 2means it's on twice as long as it's off, so the default would then be 1.]. -
I like Dorian's (@dnmeid) idea of a generalized wave function -- it would be cool to have a waxing/waning background. Since this adds complexity in the arguments (including needing to specify a sampling interval?), I would suggest that "blink" should still be provided as a convenience for the, presumably more common, use-case.
I'm fine with oscillate, LFO is the abbreviation for "low frequency oscillator" and actually it is a well known technical term but you are right, it wouldn't be the first term one was searching for and many of our users (especially the blinky guys) may not be the most technical ones.
I would assume that the time parameter is for the full period. For a square wave that is the on time plus the off time, for a (co-)sine it is both halfs and for a triangle/sawtooth/ramp it is one tooth.
_____|‾‾‾‾‾|_____|‾‾‾‾‾|______
+---time---+
I would still prefer a time (= wavelength) over frequency. I see the appeal of a frequency in many situations, but we are using ms everywhere else and I would find it confusing to have in one button e.g. wait(10) and oscillate(10) but in one place it is ms and in the other place it is Hz. And if you want to have a frequency I find oscillate(1000/5) to be more easy to interpret as: this results in a frequency. Also I wouldn't focus too much on the usable time periods for blinking a background. With expressions everywhere and the graphics overhaul the usages of such a function are endless. It could be used in any action where completely different times are useful. It could be used to animate the rotation of a button element where again other times are useful. So I think we should limit the times only as much as necessary to handle the workload.
For the on/off fraction parameter, I strongly discourage using 1 for 1:1 or 2 for 1:2. The simple reason is that this is not linear. With such a parameter a square wave is turned into a PWM (pulse width modulation). With a fraction it is not possible to reach full off or full on as it would be 1:infinite and 1:0. I think the best solution is 0.0 -1.0 with a default of 0.5.
My suggestion is to have an optional waveform parameter at second position and optional divison parameter at third position. We may have only 'square' as a waveform now, but at least to me it seams more useful in that order. I guess a potential sine would almost never be used with uneven halfs.
As I said my alternative would be to have a dedicated function name for each wave-form. This would make it more complex for the users to switch wave-forms programatically but maybe is easier to write. I can't oversee how performant all of this is going to be, but transfer functions can really open pandora's box. Think of filters or analyzers... So if we are possibly implementing more of this in the future, we should now get the parameters right and flexible enough for future additions.
Another possible addition would be a phase parameter, also 0.0. - 1.0. I can imagine people will ask for a double blink. It could be done with two OR-ed single short blinks (e.g. 0.1) that are are phase offset by 0.2
I would assume that the time parameter is for the full period. For a square wave that is the on time plus the off time, for a (co-)sine it is both halfs and for a triangle/sawtooth/ramp it is one tooth.
The issue I have with this is that it differs from existing blink functionality in modules, where the time value is the time between changes in state, rather than a full cycle and back to its original state. If we also want to consider the potential for more than 2 states. For example, I think it'll be easier to cycle through 0 to 7 every 500ms with something like oscillate(500, $(custom:max)) than something like oscillate(500 * $(custom:max), $(custom:max)).
I would still prefer a time (= wavelength) over frequency. I see the appeal of a frequency in many situations, but we are using ms everywhere else and I would find it confusing to have in one button e.g. wait(10) and oscillate(10) but in one place it is ms and in the other place it is Hz.
100% agree here, users are already more familiar with using time, such as with the existing generic blink module as well as delays/waits and the like.
I think it'll be easier to cycle through 0 to 7 every 500ms with something like
oscillate(500, $(custom:max))
I don't think that cycling through discrete values should be done in a oscillate function. Oscillate for me is a good name for the classical waveforms: sine, square, triangle... usually a waveform that oscillates between two values. The function should be normalized to 0-1 like e.g. mathematical sin() and the two maxima should be applied externally, e.g. -2 + oscillate(500) * 4. This is easier to understand than oscillate(500, 0.5, -2, 2). When there are too many parameters it is hard to know which one is doing what.
So for oscillate I stand with full wavelength in ms, waveform as string and fraction in 0-1. This seems to be the best combination of parameters to tweak the waveform, be independent and ensure that the oscillators can appear in sync when they are meant to be.
I wouldn't take the existing blink module as the base to continue, I think it is more an example of what not to do.
The approach "the time value is the time between changes" can only be used for a square wave with even on/off times. When you want e.g. a 200ms on time and 800ms off time you'd need to express it as oscillate(500, 0.2). If the first parameter would be on time and the second off time (oscillate(200, 800)) it would be harder to keep the oscillators in sync, because the frequency now depends on two paramterers. Also this would align poorly with a sine wave or your suggestion of a "cycle" function.
The cycle thing I would probably call count() and in a count function I'd say a step time is more useful than the wavelength. count(0, 7, 500) = count from 0 to 7 with 500ms steps and repeat. A count(1, 500) would count from 0 to 1 and repeat, making it effectively a toggle or blink, but I'm not sure if there should be that common timebase for count.
Talking about generating a sine wave I think is a different topic. I see it as being a completely separate implementation, as instead of being an occasional change like this, we would want to be re-evaluating the expression at some regular framerate. While that could be done with this implementation, I worry that the cost of doing that through global variables will be high.
That doesn't mean it has to be a different function in the expressions though, so it may still be relevant to figure out.
But if we do want to have sine wave generation in the future, then I feel like that would make more sense to be the default behaviour of an oscillate function?
In which case, having a simplified blink function could be beneficial? It would allow for the assymetric pattern, which otherwise would probably need to be done with a oscillate(500, 'triangle') > 0.6, which is not intuitive to figure out the numbers.
Then everything dorian is saying about that oscillate function sounds reasonable.
But perhaps that is not the solution for easy blinking
A thought which I am certain will be shot down and I am not at all attached to;
If we want to support hz as a parameter, there is nothing stopping us from supporting a token such as 4hz in the expressions. Which internally we would translate to ms.
I suppose that these in a way would be some special global variables (within normal js syntax, not our variable syntax)
I'm not convinced this is a good idea though
Perhaps this topic should be kept specifically to 'blink' type functionality that, and the functions to achieve that, and both cycling between different values and generating wave forms can be entirely separate PR's.
If we want to support hz as a parameter, there is nothing stopping us from supporting a token such as 4hz in the expressions.
Would that result in much benefit though? It seems like if a user is writing "4hz", 1000 / 4, or $(custom:freq)hz, for example then it's all roughly the same amount of effort, and the benefit of some users being more familiar with frequency could be outweighed by the potential confusion to end users being unsure what to use or why it differs to existing solutions, so this may be a case of while we can do something it might be best to keep it simple and stick with what people are already using (ie, ms).
- Agree with Julian and Jeff that we should limit this PR to blink aka square wave (in case my opinion counts!) -- both for Julian's reason, (need to specify/deal with sampling interval otherwise) and the increased complexity vs. most common use...
- While it's hard to argue against precedent, I still think that Hz would be better, whether implicitly or explicitly. In addition to my main reason, it is because I agree with Dorian that it would be better to specify the whole cycle: this is more naturally/clearly done when expressed as Hz. (And these are called low frequency oscillators, as Dorian pointed out, not "long period oscillators". 😀 ). To avoid confusion, one could name the function
cycle()ofblinksPerSecond()or somesuch... (bps()like fps? ) - Expressing
fractionOnas [0..1] makes sense if you're want the on period to be a particular duration, as Dorian illustrated. Expressing it asratioOnOffmakes more sense when you want to specify that on is twice as long as offratioOnOff = 2. I'm pretty sure that most people would have trouble getting something even as simple as "on twice as long as off" right (the answer isfractionOn = 0.66666...). I'm not worried about Infinity or 0 since (a) why would you specify blink() if it's always on or off? and (b) zero is covered, and infinity is simply having "off" shorter than than shortest duration. For example: if we limit to 50 ms intervals (20Hz),ratioOnfOff = 25is effectively infinity -- the next cycle begins before the algorithm can determine that it should have been turned off. As with frequency vs. period, this comes down to a question of what (most) people want to do: specify exactly how long the "blink" should be on or specify how long it is on relative to the off? For the most part I suspect it won't be a huge deal either way... - That fact that
waitis specified in ms also doesn't bother me since (a) I record at 30fps for 60minutes and never confuse the rate with the period (but would have to scratch my head if asked what the frame period is!) and (b)Waitis an action, and as long as you keep expression as pure functions -- i.e. no side-effects, including temporal delays), -- you'd never see await()function in an expression. In fact, you'd never see a wait action nearby either, since blink() would be in feedback or button text. ... This got me to thinking if there is ever a case in whichblink()would make sense in an action. The problem being that actions generally do not self-trigger. More on that later... - Phase is an interesting question. I would say that at least for Dorian's example, you could do the same thing with one or two blink functions. (a) blink twice as fast: easy. (b) blink twice in the first half-period, off in the second half-period. Let's make the frequency 1s for simplicity:
blink(1Hz) && blink(4Hz)does it. And of course you could make it triple-blink by changing 4Hz to 6Hz, whereas phase would require a thirdblink()... Not arguing against phase, just suggesting it may not be immediately necessary. Probably more useful in a general oscillate function. - Ok, so back to the question of where would
blink()in an expression make sense in an action? Well (a) use it to set button text -- this probably doesn't count (can you even set the button expression?) (b) have a button that changes what it does based on the state ofblink(). While at first-glance this seems a bit insane (i.e., a "random-action" button), it may actually work since the "blink bus" is synchronized: you could add a feedback of the same frequency that shows what state the button is in. Still seems a bit far-fetched to me. (c) But this got me thinking: the problem is that actions in buttons aren't self-triggering (well, OK, you could add a while loop or button-press loop, so maybe there is a use-case but if the goal is to toggle, you would just specify the interval explicitly and use a toggle -- no need forblink) but what if the action is in a Trigger? or a Trigger condition? This would, however, require Triggers to monitor at sub-second intervals, which is not currently supported... I'm not sure this is a novel use-case though. for the same reason as in my parenthetic note on while loops. Just a bit of brainstorming...
A footnote to my last comment regarding blink(msOn, msOff) vs. blink(<full-cycle>, <fraction or ratio on>) (my 2nd and 3rd points): I would rather see the latter with fractionOn than blink(msOn, msOff) for the same reason's that Dorian said: using full-period & fraction makes harder to accidentally misalign flashing intervals across buttons.
In addition it would be difficult-to-impossible to allow the user to choose between ms and Hz with msOn, msOff since frequencies don't add in intuitive ways (2 Hz + 3Hz is not 5 Hz, it's 1.2 Hz). And likewise specifying the period as 1000/rate becomes much more difficult for non-default msOff. So...if we did want to give the user the option of using Hz or ms (or 1000/rate), the first argument would have to be the full period and not msOn.
Incidentally, if we did want to give the user the option of using Hz or ms, I would suggest requiring the unit to be specified explicitly, for example define: blink(periodOrRate, units: ('Hz' | 'ms'), assymetryOption). (BTW, there's a small edit in my comment at the end of point 2.)