Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
87e3af0
time_value: make unary minus a pure operation
jcelerier Jul 30, 2026
5f3523a
token_request: fix the physical-time mapping and loop() when rewinding
jcelerier Jul 30, 2026
5b9efbe
quantification: report the right dates when the timeline runs backwards
jcelerier Jul 30, 2026
704da91
scenario: put a backward overtick at the right buffer offset
jcelerier Jul 30, 2026
58662b0
sound: write only the samples the tick covers
jcelerier Jul 30, 2026
d5f91bd
sound: ignore the live tempo in raw playback mode
jcelerier Jul 30, 2026
06d4b86
faust: process when the timeline runs backwards
jcelerier Jul 30, 2026
4c035e9
tests: cover backwards playback
jcelerier Jul 30, 2026
93eb792
metronome: click when the timeline runs backwards
jcelerier Aug 2, 2026
85bf531
scenario: cascade backwards from an interval sitting at date 0
jcelerier Aug 2, 2026
2f77829
tests: sweep the timing invariants
jcelerier Aug 2, 2026
895430b
scenario: keep the tick offset in the interval's own frame
jcelerier Aug 2, 2026
131b625
scenario: clamp a tempo-locked interval with the speed it actually ad…
jcelerier Aug 2, 2026
a33b649
token_request: derive every span from one monotone model->sample map
jcelerier Aug 2, 2026
8dfd342
time_interval: advance the date by flooring and carrying the fraction
jcelerier Aug 2, 2026
2ea13ac
time_value: make the infinite cases agree with each other
jcelerier Aug 2, 2026
4fb01dd
metronome: report every grid point a tick crosses, not just the last
jcelerier Aug 2, 2026
fa10119
quantification: restart the sub-bar grid at every bar line
jcelerier Aug 2, 2026
3f9d969
sound: do not skip a span just because it reads no new sample
jcelerier Aug 2, 2026
4fa0b30
quantification: one grid, walked once, for every consumer
jcelerier Aug 2, 2026
2bd865d
graph_node: track the transport position alongside the frame counter
jcelerier Aug 3, 2026
44c0707
token_request: carry the buffer span instead of reconstructing it
jcelerier Aug 3, 2026
c0776da
scenario: decide interval cuts in samples, not in reconstructed model…
jcelerier Aug 3, 2026
cf33d0f
token_request: place events inside the span the tick was handed
jcelerier Aug 3, 2026
4c7e351
sound: seek a dropped-in file to where a running copy actually is
jcelerier Aug 3, 2026
eca3c08
rubberband: trim the start delay so stretched sounds are not late
jcelerier Aug 3, 2026
c36da5a
tests: measure sound sync against the grid with click tracks
jcelerier Aug 3, 2026
4d2fd86
tests: probe sound sync at extreme ratios, 48 kHz and through tempo c…
jcelerier Aug 3, 2026
f73e94b
quantification: map a grid point to a sample once, from its musical p…
jcelerier Aug 3, 2026
13b231b
tests: do not assert RubberBand's transient rendering as if it were sync
jcelerier Aug 4, 2026
86a393c
build: pin the Windows target for every translation unit
jcelerier Aug 5, 2026
e1c65ad
3rdparty: update libremidi
jcelerier Aug 5, 2026
b167d39
build: version Boost.Asio's global symbols on Windows
jcelerier Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion 3rdparty/libremidi
52 changes: 52 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,58 @@ find_package(${QT_VERSION} COMPONENTS Core)
## at least with CMake 15.2
set(OSSIA_SOURCE_FOLDER "${CMAKE_CURRENT_SOURCE_DIR}" CACHE INTERNAL "")
set(OSSIA_3RDPARTY_FOLDER "${CMAKE_CURRENT_SOURCE_DIR}/3rdparty" CACHE INTERNAL "")

if(WIN32)
# Pin the Windows target for every translation unit, whatever the compiler.
#
# <windows.h> defines _WIN32_WINNT itself, through <sdkddkver.h>, whenever it
# is not already set, so leaving it alone does not mean "no minimum" - it
# means each translation unit gets one depending on whether it reached
# <windows.h> at all. Headers that branch on it, Boost.Asio among them, then
# configure themselves differently from one file to the next: Asio reads it
# to decide BOOST_ASIO_HAS_STD_ATOMIC_WAIT, which changes the wait primitive
# it uses. That is an ODR violation, and nothing reports it because both
# spellings mangle the same.
#
# The defaults also disagree between toolchains: the Windows Kits header
# picks 0x0A00 while mingw-w64 picks _WIN32_WINNT_WS03, so a mingw build has
# been configuring itself for Server 2003 wherever this was left alone.
#
# Here rather than on the ossia target, and before any add_subdirectory: the
# dependencies we add as subdirectories compile Asio too - libremidi builds
# its own translation units on MSVC - and they are siblings of ossia rather
# than consumers of it, so a PUBLIC definition never reaches them. It is
# repeated in src/ossia_setup.cmake as PUBLIC so that projects consuming an
# installed ossia get it too.
#
# WINVER and NTDDI_VERSION are deliberately not set: sdkddkver.h derives
# WINVER from _WIN32_WINNT and NTDDI_VERSION from the SDK, in both
# toolchains, so setting them by hand only creates a way for them to
# disagree.
# And give Boost.Asio's global symbols a version-tagged name.
#
# Since 1.91 they are named through BOOST_ASIO_VERSIONED_NAME, which with no
# version namespace expands to the bare asio_ prefix - the names standalone
# Asio uses. Where Asio is compiled separately we emit strong definitions of
# them, so anything else in the link carrying its own standalone Asio
# collides: score's LSL addon compiles asio/impl/src.hpp, and the two meet as
# a duplicate asio_signal_handler.
#
# The tag encodes the Asio configuration, which is why this only holds
# together with the pin above; without it the namespace varies per TU and the
# duplicate symbol merely becomes an undefined one. It also requires that
# nothing forward-declares an Asio type in plain boost::asio, which would be
# ambiguous against the real one - see libremidi's backends/net/config.hpp,
# which declares io_context inside the same inline namespace we do.
#
# Standalone Asio has no equivalent knob, still hardcoding the bare names as
# of 1.36, so the versioning has to come from our side. A no-op before 1.91.
add_definitions(
-D_WIN32_WINNT=0x0A00
-DBOOST_ASIO_ENABLE_VERSION_NAMESPACE=1
)
endif()

include(OssiaOptions)

# Dependencies
Expand Down
42 changes: 30 additions & 12 deletions src/ossia/dataflow/execution_state.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -525,23 +525,41 @@ auto exec_state_facade::timings(const token_request& t) const noexcept -> sample
{
sample_timings tm;
static constexpr double speed_epsilon = 0.01;
if(t.speed > speed_epsilon)

if(t.start_sample >= 0 && t.length_sample >= 0)
{
[[likely]];
tm.start_sample = t.physical_start(impl->modelToSamplesRatio);

const auto tick_dur = t.physical_write_duration(impl->modelToSamplesRatio);
auto max_dur = int64_t(impl->bufferSize - tm.start_sample);
if(max_dur < 0)
max_dur = 0;

tm.length = std::min(tick_dur, max_dur);
// The producer of the token knew which samples of the buffer it stands
// for, and that knowledge cannot be reconstructed from the flick-quantised
// model dates - carrying it is the point. Clamp defensively: a span must
// never reach outside the buffer.
tm.start_sample = t.start_sample;
tm.length = t.length_sample;
if(tm.start_sample > impl->bufferSize)
{
[[unlikely]];
ossia::logger().error(
"token start_sample > bufferSize: {} > {}", tm.start_sample,
impl->bufferSize);
return {};
}
if(tm.start_sample + tm.length > impl->bufferSize)
{
[[unlikely]];
ossia::logger().error(
"token start_sample + length_sample > bufferSize: {} + {} > {}",
tm.start_sample, tm.length, impl->bufferSize);
tm.length = impl->bufferSize - tm.start_sample;
}
return tm;
}
else if(t.speed < -speed_epsilon)

if(t.speed > speed_epsilon || t.speed < -speed_epsilon)
{
tm.start_sample = -t.physical_start(impl->modelToSamplesRatio);
[[likely]];
tm.start_sample = t.physical_start(impl->modelToSamplesRatio);

const auto tick_dur = -t.physical_write_duration(impl->modelToSamplesRatio);
const auto tick_dur = t.physical_write_duration(impl->modelToSamplesRatio);
auto max_dur = int64_t(impl->bufferSize - tm.start_sample);
if(max_dur < 0)
max_dur = 0;
Expand Down
14 changes: 11 additions & 3 deletions src/ossia/dataflow/graph/tick_methods.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,11 @@ struct tick_all_nodes
const time_value new_date{e.samples_since_start};

// TODO tempo / sig ?
token_request tok{old_date, new_date, 0_tv, 0_tv, 1.0, {}, ossia::root_tempo};
tok.start_sample = 0;
tok.length_sample = int32_t(samples);
for(auto& node : g.get_nodes())
node->request(
token_request{old_date, new_date, 0_tv, 0_tv, 1.0, {}, ossia::root_tempo});
node->request(tok);

g.state(e);
std::atomic_thread_fence(std::memory_order_seq_cst);
Expand Down Expand Up @@ -143,6 +145,12 @@ struct buffer_tick

tok.date = tok.prev_date + flicks;

// This is the one thing the audio callback knows for certain: the buffer
// is frameCount samples. Everything downstream carries cuts of this span
// rather than reconstructing them from the flick-quantised model dates.
tok.start_sample = 0;
tok.length_sample = int32_t(frameCount);

// Notify the current transport state
if(transport.allocated())
{
Expand Down Expand Up @@ -208,7 +216,7 @@ struct precise_score_tick
st.begin_tick();
st.samples_since_start++;
const ossia::token_request tok{};
itv.tick_offset(ossia::time_value{1}, 0_tv, tok);
itv.tick_offset(ossia::time_value{1}, 0_tv, tok, 0, 1);
g.state(st);
std::atomic_thread_fence(std::memory_order_seq_cst);
st.commit();
Expand Down
6 changes: 6 additions & 0 deletions src/ossia/dataflow/graph_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,12 @@ void graph_node::process_time(
{
auto [s, d] = exec_state_facade{&st}.timings(req);
this->m_processed_frames += d;

// A span length is never negative, so the counter above cannot follow a
// rewind. The playhead can: map the date of the tick's first sample through
// the same model -> sample map the spans are taken from.
if(req.speed != 0.)
this->m_transport_frames = req.start_date_to_physical(st.modelToSamplesRatio);
}

void graph_node::all_notes_off() noexcept { }
Expand Down
16 changes: 16 additions & 0 deletions src/ossia/dataflow/graph_node.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,29 @@ class OSSIA_EXPORT graph_node
return m_processed_frames;
}

/**
* Where the playhead sits on the timeline, in frames, at the first sample of
* the current tick.
*
* Distinct from processed_frames(): that counts audio pushed through the node
* and only ever rises, which is what a steady counter should do. This one
* follows the transport and goes back down when the timeline runs backwards.
* A plug-in wants the transport position, not the counter.
*/
[[nodiscard]]
int64_t transport_frames() const noexcept
{
return m_transport_frames;
}

virtual void all_notes_off() noexcept;
token_request_vec requested_tokens;

protected:
inlets m_inlets;
outlets m_outlets;
int64_t m_processed_frames{};
int64_t m_transport_frames{};

bool m_executed{};
bool m_not_threadable{};
Expand Down
6 changes: 3 additions & 3 deletions src/ossia/dataflow/nodes/faust/faust_utils.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ struct faust_node_utils
Node& self, Dsp& dsp, const ossia::token_request& tk,
const ossia::exec_state_facade& e)
{
if(tk.forward())
if(!tk.paused())
{
const auto [st, d] = e.timings(tk);
copy_controls(self);
Expand Down Expand Up @@ -504,7 +504,7 @@ struct faust_node_utils
Node& self, Dsp& dsp, const ossia::token_request& tk,
const ossia::exec_state_facade& e)
{
if(tk.forward())
if(!tk.paused())
{
const auto [st, d] = e.timings(tk);

Expand All @@ -521,7 +521,7 @@ struct faust_node_utils
Node& self, DspPoly& dsp, const ossia::token_request& tk,
const ossia::exec_state_facade& e)
{
if(tk.forward())
if(!tk.paused())
{
const auto [st, d] = e.timings(tk);

Expand Down
50 changes: 32 additions & 18 deletions src/ossia/dataflow/nodes/sound.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
#include <ossia/detail/pod_vector.hpp>
#include <ossia/detail/variant.hpp>

#include <algorithm>

namespace ossia
{
namespace snd
Expand All @@ -32,7 +34,10 @@ sample_info(int64_t bufferSize, double durationRatio, const ossia::token_request
return _;

_.samples_to_read = t.physical_read_duration(durationRatio);
_.samples_to_write = t.safe_physical_write_duration(durationRatio, bufferSize);

const auto room = t.safe_physical_write_duration(durationRatio, bufferSize);
const auto tick_dur = t.physical_write_duration(durationRatio);
_.samples_to_write = std::min(tick_dur, std::max<int64_t>(0, room));

return _;
}
Expand Down Expand Up @@ -260,37 +265,46 @@ struct sound_processing_info

void set_native_tempo(double v) { tempo = v; }

// File sample at which a dropped sound must seek to align with an
// identical sound already playing at the same model time. Scales by
// |timeline_tempo| / file_tempo when stretching; falls back to to_sample().
// File sample an already-playing sound has reached at a given model date,
// i.e. where a sound dropped in mid-playback must seek to align with it.
//
// When stretching, both the model clock and the file consumption scale
// with the live tempo: the interval advances its date by
// tempo / root_tempo model samples per physical sample, and the stretcher
// consumes tempo / file_tempo file samples per physical sample. Their
// ratio is root_tempo / file_tempo whatever the transport tempo, tempo
// curve or speed are doing, so the file position is a pure function of
// the model date and the file's own tempo. Scaling by the *live* tempo
// here - as this used to do - lands wrong by a factor of
// live_tempo / root_tempo, i.e. it is only right at 120 BPM.
//
// In raw mode the file advances at one sample per physical sample while
// the model still runs at tempo / root_tempo, so there the live tempo is
// exactly what is needed to unscale the date; when it is not known
// (timeline_tempo == 0), the date is used as-is.
[[nodiscard]] int64_t file_sample_for_model_time(
time_value date, double timeline_tempo,
int file_sample_rate) const noexcept
{
const int64_t base = to_sample(date, file_sample_rate);
const double abs_tempo = std::abs(timeline_tempo);
if(!m_resampler.stretch() || tempo <= 0.0 || abs_tempo <= 0.0)
return base;
if(m_resampler.stretch() && tempo > 0.0)
return int64_t(std::llround(double(base) * ossia::root_tempo / tempo));

return int64_t(double(base) * abs_tempo / tempo);
const double abs_tempo = std::abs(timeline_tempo);
if(abs_tempo > 0.0)
return int64_t(std::llround(double(base) * ossia::root_tempo / abs_tempo));
return base;
}

double update_stretch(
const ossia::token_request& t, const ossia::exec_state_facade& e) noexcept
{
double stretch_ratio = 1.;
double model_ratio = 1.;
if(tempo != 0.)
if(tempo != 0. && m_resampler.stretch())
{
if(m_resampler.stretch())
{
model_ratio = ossia::root_tempo / this->tempo;
stretch_ratio = this->tempo / t.tempo;
}
else
{
model_ratio = ossia::root_tempo / t.tempo;
}
model_ratio = ossia::root_tempo / this->tempo;
stretch_ratio = this->tempo / t.tempo;
}

m_loop_duration_samples = m_loop_duration.impl * e.modelToSamples() * model_ratio;
Expand Down
26 changes: 18 additions & 8 deletions src/ossia/dataflow/nodes/sound_libav.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,31 @@ class sound_libav final : public ossia::sound_node

void transport(time_value flicks) override
{
m_channel_q.clear();
ossia::seek_to_flick(
m_handle.format, m_handle.codec, m_handle.stream, flicks.impl, AVSEEK_FLAG_ANY);
transport_scaled(flicks, 0.);
}

void transport(time_value flicks, const ossia::tick_transport_info& tinfo) override
{
transport_scaled(flicks, tinfo.current_tempo);
}

// Same mapping as file_sample_for_model_time, in flicks: when stretching
// the file position is model_time * root_tempo / file_tempo whatever the
// transport is doing; in raw mode it is the model time unscaled by the
// live tempo when that is known.
void transport_scaled(time_value flicks, double timeline_tempo)
{
m_channel_q.clear();
// Scale flicks by |timeline_tempo| / file_tempo when stretching; otherwise
// seek at the raw model time. See file_sample_for_model_time.
int64_t target_flicks = flicks.impl;
const double abs_tempo = std::abs(tinfo.current_tempo);
if(m_resampler.stretch() && tempo > 0.0 && abs_tempo > 0.0)
if(m_resampler.stretch() && tempo > 0.0)
{
target_flicks
= int64_t(std::llround(double(flicks.impl) * ossia::root_tempo / tempo));
}
else if(const double abs_tempo = std::abs(timeline_tempo); abs_tempo > 0.0)
{
target_flicks = int64_t(double(flicks.impl) * abs_tempo / tempo);
target_flicks
= int64_t(std::llround(double(flicks.impl) * ossia::root_tempo / abs_tempo));
}
ossia::seek_to_flick(
m_handle.format, m_handle.codec, m_handle.stream, target_flicks,
Expand Down
5 changes: 4 additions & 1 deletion src/ossia/dataflow/nodes/sound_mmap.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,11 @@ class sound_mmap final : public ossia::sound_node

void transport(time_value date) override
{
// No transport info: the live tempo is unknown, but the stretching seek
// only needs the file's own tempo (see file_sample_for_model_time).
if(m_handle)
m_resampler.transport(to_sample(date, m_handle.sampleRate()));
m_resampler.transport(
file_sample_for_model_time(date, 0., m_handle.sampleRate()));
}

void transport(time_value date, const ossia::tick_transport_info& tinfo) override
Expand Down
12 changes: 9 additions & 3 deletions src/ossia/dataflow/nodes/sound_sampler.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ struct sound_sampler

void transport(time_value date)
{
info->m_resampler.transport(to_sample(date, m_dataSampleRate));
// No transport info: the live tempo is unknown, but the stretching seek
// only needs the file's own tempo (see file_sample_for_model_time).
info->m_resampler.transport(
info->file_sample_for_model_time(date, 0., m_dataSampleRate));
}

void transport(time_value date, const ossia::tick_transport_info& tinfo)
Expand Down Expand Up @@ -87,8 +90,11 @@ struct sound_sampler

const auto [samples_to_read, samples_to_write]
= snd::sample_info(e.bufferSize(), e.modelToSamples(), t);
if(samples_to_read == 0)
return;
// Only the write count decides whether there is anything to do, as in
// sound_mmap and sound_libav. The read count is a floor over absolute model
// time while the write count is a floor over the offset into the buffer, so
// a tick can legitimately cover a sample without consuming a whole new one;
// bailing here left a hole and a stale m_prev_date.
if(samples_to_write <= 0)
return;

Expand Down
Loading
Loading