Skip to content

Commit

Permalink
Fix composite determinism (#360)
Browse files Browse the repository at this point in the history
  • Loading branch information
SanderVocke authored Apr 30, 2024
1 parent 5c37026 commit 3859d24
Show file tree
Hide file tree
Showing 7 changed files with 60 additions and 22 deletions.
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ dependencies = [
"soundfile==0.12.1",
"mido==1.3.2",
"jsonschema==4.21.1",
"numpy==1.24.4",
"numpy==1.24.4; python_version < \"3.12\"",
"numpy==1.26.4; python_version >= \"3.12\"",
"pyjacklib==0.1.1",
"pyside6-essentials==6.6.2",
"lupa==2.1",
Expand Down
53 changes: 41 additions & 12 deletions src/shoopdaloop/lib/q_objects/CompositeLoop.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@ def __init__(self, parent=None):
self._n_cycles = 0
self._kind = 'regular'
self._pending_transitions = []
self._pending_cycles = 0
self._pending_cycles = []
self._backend = None
self._initialized = False
self._play_after_record = False
self._sync_mode_active = False
self._cycle_nr = 0
self._last_handled_source_cycle_nr = -1

self.scheduleChangedUnsafe.connect(self.scheduleChanged, Qt.QueuedConnection)
self.nCyclesChangedUnsafe.connect(self.nCyclesChanged, Qt.QueuedConnection)
Expand All @@ -70,8 +72,8 @@ def __init__(self, parent=None):
self.backendChanged.connect(lambda: self.maybe_initialize())
self.backendInitializedChanged.connect(lambda: self.maybe_initialize())

cycled = ShoopSignal()
cycledUnsafe = ShoopSignal()
cycled = ShoopSignal(int)
cycledUnsafe = ShoopSignal(int)

######################
## PROPERTIES
Expand Down Expand Up @@ -445,10 +447,10 @@ def transition_with_immediate_sync_impl(self, mode, sync_cycle):
# Perform the trigger(s) for the next loop cycle
self.do_triggers(self.iteration + 1, mode)

@ShoopSlot(thread_protection=ThreadProtectionType.AnyThread)
def handle_sync_loop_trigger(self):
@ShoopSlot(int, thread_protection=ThreadProtectionType.AnyThread)
def handle_sync_loop_trigger(self, cycle_nr):
self.logger.trace(lambda: f'queue sync loop trigger')
self._pending_cycles += 1
self._pending_cycles.append(cycle_nr)

@ShoopSlot('QVariant', 'QVariant', 'QVariant', int)
def adopt_ringbuffers(self, reverse_start_cycle, cycles_length, go_to_cycle, go_to_mode):
Expand Down Expand Up @@ -504,8 +506,27 @@ def adopt_ringbuffers(self, reverse_start_cycle, cycles_length, go_to_cycle, go_
if go_to_mode != LoopMode.Unknown.value:
self.transition(go_to_mode, DontWaitForSync, go_to_cycle)

def handle_sync_loop_trigger_impl(self):
self.logger.debug(lambda: 'handle sync cycle')
def all_loops(self):
loops = set()
for elem in self._schedule.values():
for e in elem['loops_start']:
loops.add(e[0])
for e in elem['loops_end']:
loops.add(e)
for e in elem['loops_ignored']:
loops.add(e)
return loops

def handle_sync_loop_trigger_impl(self, cycle_nr):
if cycle_nr == self._last_handled_source_cycle_nr:
self.logger.trace(lambda: f'already handled sync cycle {cycle_nr}, skipping')
return
self.logger.debug(lambda: f'handle sync cycle {cycle_nr}')

# Before we start, give any of the loops in our schedule a chance to handle the cycle
# first. This ensures a deterministic ordering of execution.
for l in self.all_loops():
l.dependent_will_handle_sync_loop_cycle(cycle_nr)

if self._next_transition_delay == 0:
self.handle_transition(self.next_mode)
Expand All @@ -525,8 +546,10 @@ def handle_sync_loop_trigger_impl(self):
self.do_triggers(self.iteration+1, self.mode)

if cycled:
self.cycledUnsafe.emit()
self._cycle_nr += 1
self.cycledUnsafe.emit(self._cycle_nr)

self._last_handled_source_cycle_nr = cycle_nr
self.logger.trace(lambda: 'handle sync cycle done')

def cancel_all(self):
Expand Down Expand Up @@ -631,10 +654,16 @@ def maybe_initialize(self):
@ShoopSlot(thread_protection = ThreadProtectionType.OtherThread)
def updateOnOtherThread(self):
if self._backend:
for _ in range(self._pending_cycles):
self.handle_sync_loop_trigger_impl()
for cycle_nr in self._pending_cycles:
self.handle_sync_loop_trigger_impl(cycle_nr)
for transition in self._pending_transitions:
self.transition_impl(*transition)
self._pending_transitions = []
self._pending_cycles = 0
self._pending_cycles = []

# Another loop which references this loop (composite) can notify this loop that it is
# about to handle a sync loop cycle in advance, to ensure a deterministic ordering.
@ShoopSlot(int, thread_protection = ThreadProtectionType.OtherThread)
def dependent_will_handle_sync_loop_cycle(self, cycle_nr):
self.handle_sync_loop_trigger_impl(cycle_nr)

2 changes: 1 addition & 1 deletion src/shoopdaloop/lib/q_objects/FileIO.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def make_tarfile(self, filename, source_dir, compress):
def extract_tarfile(self, filename, target_dir):
flags = "r:*"
with tarfile.open(filename, flags) as tar:
tar.extractall(target_dir)
tar.extractall(target_dir, filter='fully_trusted')

def save_data_to_soundfile_impl(self, filename, sample_rate, data):
self.startSavingFile.emit()
Expand Down
16 changes: 12 additions & 4 deletions src/shoopdaloop/lib/q_objects/Loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
# Wraps a back-end loop.
class Loop(FindParentBackend):
# Other signals
cycled = ShoopSignal()
cycledUnsafe = ShoopSignal(thread_protection=ThreadProtectionType.OtherThread)
cycled = ShoopSignal(int)
cycledUnsafe = ShoopSignal(int, thread_protection=ThreadProtectionType.OtherThread)

def __init__(self, parent=None):
super(Loop, self).__init__(parent)
Expand All @@ -44,6 +44,7 @@ def __init__(self, parent=None):
self.logger.name = "Frontend.Loop"
self._pending_transitions = []
self._pending_cycles = 0
self._cycle_nr = 0

self.modeChangedUnsafe.connect(self.modeChanged, Qt.QueuedConnection)
self.nextModeChangedUnsafe.connect(self.nextModeChanged, Qt.QueuedConnection)
Expand Down Expand Up @@ -276,8 +277,9 @@ def updateOnOtherThread(self):
self._pending_transitions = []

if (self.position < prev_position and is_playing_mode(prev_mode) and is_playing_mode(self.mode)):
self.logger.debug(lambda: 'cycled')
self.cycledUnsafe.emit()
self._cycle_nr += 1
self.logger.debug(lambda: f'cycled -> nr {self._cycle_nr}')
self.cycledUnsafe.emit(self._cycle_nr)

# Update on GUI thread.
@ShoopSlot()
Expand Down Expand Up @@ -379,6 +381,12 @@ def get_backend_loop(self):
@ShoopSlot(result="QVariant")
def py_loop(self):
return self

# Another loop which references this loop (composite) can notify this loop that it is
# about to handle a sync loop cycle in advance, to ensure a deterministic ordering.
@ShoopSlot(int, thread_protection = ThreadProtectionType.OtherThread)
def dependent_will_handle_sync_loop_cycle(self, cycle_nr):
pass

def maybe_initialize(self):
if self._backend and self._backend.initialized and not self._backend_loop:
Expand Down
4 changes: 2 additions & 2 deletions src/shoopdaloop/lib/qml/CompositeLoop.qml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Item {
play_after_record: registries.state_registry.play_after_record_active
sync_mode_active: registries.state_registry.sync_active

onCycled: root.cycled()
onCycled: n => root.cycled(n)
Component.onCompleted: root.recalculate_schedule()
}

Expand Down Expand Up @@ -127,7 +127,7 @@ Item {
instanceIdentifier: obj_id
}

signal cycled()
signal cycled(int cycle_nr)

function add_loop(loop, delay, n_cycles=undefined, playlist_idx=undefined) {
root.logger.debug(`Adding loop ${loop.obj_id} to playlist ${playlist_idx} with delay ${delay}, n_cycles override ${n_cycles}`)
Expand Down
2 changes: 1 addition & 1 deletion src/shoopdaloop/lib/qml/LoopWidget.qml
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ Item {
}

// Signals
signal cycled
signal cycled(int cycle_nr)

// Methods
function transition_loops(loops, mode, maybe_delay, maybe_align_to_sync_at) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ ShoopTestFile {

property var maybe_loop: this

signal cycled()
signal cycled(int cycle_nr)

RegisterInRegistry {
object: fakeloop
Expand Down

0 comments on commit 3859d24

Please sign in to comment.