Skip to content

Utilities, value delay, time chooser tapers and tempo-syncable examples - #214

Merged
jcelerier merged 23 commits into
mainfrom
score/utilities-and-time-choosers
Sep 30, 2026
Merged

jcelerier merged 23 commits into
mainfrom
score/utilities-and-time-choosers

Conversation

@jcelerier

@jcelerier jcelerier commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Changes to the avendish utilities, examples and bindings that ossia score's integration branch (ossia/score#2312) needs.

Bindings and halp

  • ossia binding: an optional ossia::value port is an event port. It read the bound address's value at every tick instead of receiving its messages (Flip Flop toggled on every tick; Repetition Filter, Repeat and Spigot saw the value again each tick).
  • ossia binding: an impulse on a maintained button is a one-tick press; a bool control takes numbers (non-zero is true); a plain number on a time control is a time in seconds (it was read as a musical ratio).
  • init: impulse buttons are no longer pressed when an object is created (a Counter, Accumulator or Buffer queue in manual mode sent once at the start of execution).
  • A static_assert refuses an operator()(halp::tick) that the bindings cannot call (Enumerator and Value delay silently did nothing).
  • halp: file_path and save_file_path; the file objects use them.
  • halp: time_chooser_mapper, the taper of time choosers (cube of the knob travel: short times get most of it, 0 stays reachable).
  • halp: musical_to_frame maps musical positions to frames with one function, exact at sample boundaries, identical to ossia::musical_to_frame so the engine and the objects agree.
  • halp: the layout example shows enabled_when / visible_when returned from a static constexpr auto condition() rather than inherited. Boost.PFR (used without P1061: clang < 21, gcc < 16, MSVC) rejects layouts with a base class.
  • Pulse view: a 6 px accent dot that lights when a message arrives and fades over 300 ms of wall-clock time, matching score's value display.

Utilities

  • Shell command: an Interpreter combo box (system shell by default, bash, zsh, fish, sh, python3, PowerShell, cmd) or a Custom command line where %s stands for the script.
  • Spigot: outputs only what passes this tick (it re-sent the last value at every tick).
  • Counter: Reset, an Output bang shaped by the Mode, and a Send choice (every tick, the default and previous behaviour; on each message; only on the Output bang).
  • Accumulator: the same Send choice and an Output bang.
  • Enumerator: walks a list one value at a time.
  • Value delay: any value type; Ticks / Messages / Time modes on one delay line; Feedback, a Mix outlet, Freeze, Clear, and Smooth in Time mode.
  • Array recombiner groups vec2f / vec3f / vec4f inputs; Array best sends only when an array arrives or its settings change.
  • MIDI humanize always outputs well-formed MIDI and has a Pitch control.
  • Lightness sampler shows the image at its aspect ratio.
  • New ports are appended after the existing ones, so saved documents keep their port indices.

Audio examples

  • ADSR, Flanger, Compressor and Limiter v2 with tempo-syncable times (seconds or a note value). The old versions stay as "(old)", deprecated and hidden from score's library, so existing documents are unchanged.
  • Shared fixes for old and new: a ratio of 0 no longer divides by zero, the lookahead is clamped to the delay line, and the delay lines are only reallocated for a new sample rate, not from the audio thread.
  • Both limiters are now a standard lookahead brickwall peak limiter (behaviour change): Makeup is input gain, a peak detector linked over all channels and the sidechain, a hard-knee gain computer with the threshold as ceiling, a running minimum over the attack, a one-pole release and a moving average, with the audio delayed by the lookahead. Below the threshold the signal comes out unchanged, only delayed; above it the output never exceeds the threshold. Before, a static waveshaper did the limiting and bent the signal below the threshold (Limiter v1 at its default threshold squashed everything to about ±0.02 around a DC offset). Attack defaults to the lookahead, so the gain ramp spans the whole lookahead. The compressors are unchanged.
  • Audio particles: a synced interval is converted to the grid's rate (at 120 BPM a synced quarter triggered on half notes); no allocation on the audio thread for new playheads; 64-bit frame positions.

Testing

The objects are tested through ossia score's suite in ossia/score#2312 (utilities, value delay, counter / accumulator send modes, limiter ceilings and transparency below the threshold for v1 and v2, envelopes, audio particles grid rate). The local non-GPU suite passes (450/450). Windows/MSVC, FreeBSD (the Boost.PFR path) and the Qt 6.4 / 6.8 builds are only confirmed by score's CI.

🤖 Generated with Claude Code

https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX

setup() for an optional value delegates to the concrete type's overload; the
typed ones (int, float, string...) set is_event, the ossia::value one does
not. So a halp::val_port<..., std::optional<ossia::value>> bound to an
address read the address's value at every tick instead of receiving its
messages: Flip Flop toggled on every tick, and Repetition Filter, Repeat and
Spigot saw the value again at each tick.

Set is_event from the optional port itself after the concrete setup, still
honouring a Field::event override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
jcelerier and others added 22 commits September 28, 2026 02:14
The script always went to ::system, i.e. /bin/sh. An "Interpreter" combobox
now picks the system shell (the default, as before), bash, zsh, fish, sh,
python3, PowerShell or cmd, each given the script with its -c / -Command /
/C flag; "Custom" runs the "Custom command" line, where %s stands for the
script (appended when absent), e.g. "/opt/foo/bash -c %s".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
The output was a plain value, so the last message that passed was sent
again at every tick, disabled or not. Make it optional and clear it each
tick; also a lowercase c_name like the other utilities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
- The Output bang sent the raw count: it is now clipped / wrapped / folded
  by the Mode like the counting itself.
- A Reset bang starts the count over.
- "Send" chooses when the count goes out: every tick (the behaviour so far
  and the default), on each message, or only on the Output bang. The Count
  outlet is optional, so nothing is sent on ticks where nothing asked.

The new ports come after the existing ones: saved documents keep theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
"Send" chooses between every tick (the behaviour so far and the default),
on each input, or only on the new Output bang; the outlets are optional so
nothing is sent otherwise. Reset now also forgets the consecutive
difference, and is sent once as zeros in the "on input" mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
The delay was clocked by the ticks: one step per tick of a nominal 500 Hz
line with fixed tap times, so how long it delayed depended on the tick
rate, and Length did nothing. A Mode combobox keeps that as the default
(Ticks) and adds:
- Messages: tap i gives the value received (i+1) * Length changes of In ago;
- Time: tap i gives the value In had (i+1) * Length milliseconds ago,
  measured with the tick's frames and the sample rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
- widget_type::file and halp::file_path<"Name", "filters">: a path picked
  with a file dialog, given to the object as a string (file_port gives the
  contents instead).
- halp::save_file_path<"Name">: a typed path (a dialog could only pick
  existing files) whose placeholders the host may expand (%t, %n).
- Read File and Read File Line take a file_path; Write File a
  save_file_path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
Only lists were handled: a vector value fell through and gave an empty
output. They are taken as the list of their components.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
Ghost notes: every note gets its own timing and length deviation, so notes
of the same pitch could overlap or swap after scheduling -- two note-ons in
a row, a note-off cutting the next note, stray note-offs. A new note of a
pitch also replaced the previous one in the tracking table, so the first
note's note-off ended the second.

The output now pairs notes: each note-on and its note-off carry the note's
id; per channel and pitch the output knows which note sounds, ends it before
another note-on of that pitch, and drops a note-off that is not the sounding
note's. Note-offs match the oldest instance of their pitch. Flushing ends
exactly the notes sounding at the output.

Pitch (appended after the existing controls): standard deviation, in
semitones, of a random transposition of each note; its note-off follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
The preview stretched the texture to the square item, and the sample
markers with it. The image is drawn as large as fits, centered
(letterboxed), and the markers are placed in that rectangle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
New object. Trigger: a bang outputs the next value of the list (an
internal counter bounded by Clip, Wrap or Fold), a number the value at that
index. List: a list, or a vec2f/3f/4f. Mode: Manual (triggers only), Every
tick, or Timed (the next value at each Interval, counted in frames). Value
out, and Index out when the index changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
A maintained button (bool control, button/pushbutton widget) is true while
held. An impulse arriving on it -- a cable, an OSC message -- went through
ossia::convert<bool>, which makes an impulse false, so it did nothing: a
Clear or a Hold could not be triggered by a message.

Such an impulse now sets the button for the tick (with its update()), and
finish_run releases it (update() again). A button already held stays held;
true and false keep their meaning. Other bool controls and ossia::convert
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
- Enumerator and Value delay took operator()(halp::tick) without declaring
  `using tick = halp::tick;`: the bindings never called it, and the objects
  did nothing (Value delay since its Messages / Time modes). A static_assert
  now refuses an operator() that cannot be called.
- Enumerator keeps the last list it received: the List port was a plain
  ossia::value, which the ossia binding resets after each tick.
- Value delay: a Time control for the Time mode's tap spacing, instead of
  reading Length as milliseconds.
- Counter: the count goes out from operator(). The binding clears optional
  outputs after the controls' update() and before operator(), so the Output
  bang wrote a value that was then dropped.
- ossia binding: a plain number on a time control (a cable, OSC) is a time in
  seconds; it converted to {v, v} and was read as a musical ratio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
- avnd::init_controls called every control's update(), and an impulse
  button's update() is its press: every object's Output, Bang, Reset... was
  pressed once when it was created, e.g. a Counter, Accumulator or Buffer
  queue in Manually mode sent once at the start of the execution. A port
  whose optional value holds nothing is not updated; which ports can be
  empty is decided at compile time (avnd::optional_value_field).
- ossia binding: a bool control takes numbers (non-zero is true), as
  ossia::convert<bool> does; a toggle driven by a cable or an OSC
  controller ignored 0 / 1.
- Array best sends when an array arrives or Mode / Softmax scale change,
  instead of the same results at every tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
In takes any value (was a float slider); taps and the new Mix outlet carry
it. One delay line for every mode, at positions counted in ticks, messages
or milliseconds; Ticks mode taps are Length ticks apart like the others
(was a Gamma multitap at fixed 0.1 + 0.11 i s with a hidden 0.1 feedback).

- Feedback: each echo is mixed back into the line, (1 - fb) In + fb echo,
  so a movement repeats and fades while a steady value stays.
- Mix outlet: (1 - mix) In + mix first echo.
- Freeze: the line repeats its last Length / Time and ignores In.
- Clear: empties the line.
- Smooth (Time mode): glides between recorded values.

Numbers, vec2/3/4 and lists of numbers mix; other values are delayed as
they are. Time already follows the tempo: the binding converts a musical
value to seconds at the current tempo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
A 6 px dot in the runtime-value colour, lit when a message arrives and
fading out over 300 ms of wall-clock time (was a light 10 px square
fading per repaint, so at the repaint rate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
The cube of the knob's travel: short times (envelope attacks, a few
milliseconds) get most of it, the whole range stays reachable, 0 included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
…imes

ADSR's attack, decay and release, the flanger's sweep (a period instead of a
frequency), the compressor's and limiter's attack and release are time
choosers: seconds, or a note value -- a release synced to the beat is what
makes a compressor pump in time. Stage times reach 0. The "Relase" typo is
gone from the new ones.

The old objects stay, as "(old)" and deprecated (hidden from score's
library), so that documents using them are unchanged.

The old and new versions share their implementation: the ADSR envelopes,
and the dynamics setup. For both, a ratio of 0 no longer divides by zero,
the lookahead is clamped to the delay line (at 0 it read the oldest sample
instead of the newest, beyond 100 ms the read position wrapped around), and
the delay lines are only reallocated for a new sample rate, not every time
prepare() re-runs from the audio thread for a larger buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
The binding hands a synced time chooser over in seconds at the current tempo,
but the particles used 1 / seconds as the quantification rate, i.e. took the
seconds for a fraction of a whole note: at 120 BPM a synced quarter (0.5 s)
triggered on half notes. The grid's rate is 240 / (seconds * tempo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
A new playhead was pushed into a vector that grew on the audio thread. The
number of sounds playing at once is now bounded and reserved in prepare().
frame_in_interval takes 64-bit positions: the transport's frame count
overflows an int after about 12 hours at 48 kHz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
The limiters ran the compressor with an infinite ratio followed by a static
waveshaper. The one-pole gain envelope does not reach its target before the
peak, so the waveshaper did the limiting, and it bent the signal below the
threshold: at the default threshold of Limiter v1 its linear region was
empty and everything was squashed to about +-0.02 around a DC offset; Limiter
v2's knee attenuated signal under the threshold, and its makeup saturated
instead of adding level.

Both now share PeakLimiter: input gain (Makeup) first, a peak detector linked
over all channels and the sidechain (which can only add gain reduction), a
hard-knee gain computer min(1, ceiling / peak) with the threshold as the
ceiling, a running minimum over the attack, a one-pole release keeping its
t60 meaning, and a moving average over the attack, with the audio delayed by
the lookahead. Below the threshold the signal comes out unchanged, only
delayed; above it, the output never exceeds the threshold. Attack defaults
to the default lookahead, so that the gain ramp spans the whole lookahead.
The compressors are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
…ndaries

tick_musical converted musical positions to frames in three places, each
with a plain floor of the ratio. The positions come out of floating-point
arithmetic exact only to a few ulps of their magnitude, so a point meant to
fall on a sample boundary could land one sample early, and one in the last
ulps of a tick was dropped by both that tick and the next. musical_to_frame
treats a point within that tolerance below a boundary as on it, and "past
the end of the tick" is decided on the musical position rather than on the
rounded frame. It is kept identical to ossia::musical_to_frame so that the
engine and the objects agree on where a grid point falls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
Layouts are walked as aggregates, and Boost.PFR used without P1061 rejects
any base class: a layout inheriting from enabled_when, as the example
suggested, does not build there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
@jcelerier
jcelerier force-pushed the score/utilities-and-time-choosers branch from 0c88975 to 529e8a3 Compare September 29, 2026 02:51
@jcelerier
jcelerier merged commit 57675ee into main Sep 30, 2026
1 of 23 checks passed
jcelerier added a commit to ossia/score that referenced this pull request Sep 30, 2026
celtera/avendish#214 was rebase-merged; same tree as 529e8a3fbc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tPT7bc2XWmFyQUtv9B4MX
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.

1 participant