Skip to content

Commit df50d1b

Browse files
committed
Implement sample-accurate timing for MIDI, sequencer, and arpeggiator
Replace wall clock timing with audio sample-based scheduling to eliminate drift and synchronization issues. All timed events (MIDI playback, step sequencer, arpeggiator) now use a unified event scheduler that runs on the audio callback clock instead of QTimer/perf_counter. Key changes: - Add event_scheduler module with sample-accurate event queue - Integrate scheduler into audio callback for precise timing - Update MIDI playback to pre-schedule all events - Refactor step sequencer to use 2-bar buffer scheduling - Update arpeggiator to schedule notes in batches - Add event source tagging for selective clearing - Sync UI updates to audio clock via Qt signals - Fix sustain_base by only releasing scheduled notes
1 parent 3d67350 commit df50d1b

5 files changed

Lines changed: 388 additions & 109 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# QWERTY Synth
22

3-
> **Note:** This is a toy project I created to get better at AI assisted coding and was not intended for public use.
3+
> **Note:** This is a toy project I created to get better at AI assisted coding and is not intended for public use.
44
55
A minimalist real-time synthesizer built in Python using the keyboard as a piano.
66

qwerty_synth/arpeggiator.py

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
)
1111

1212
from qwerty_synth import config
13-
from qwerty_synth.controller import play_midi_note_direct
13+
from qwerty_synth.event_scheduler import global_scheduler
1414

1515

1616
class Arpeggiator(QWidget):
@@ -420,7 +420,7 @@ def _start_arpeggio_impl(self):
420420
if self._test_mode:
421421
return
422422

423-
# Calculate timer interval
423+
# Calculate timer interval for UI updates
424424
if self.sync_to_bpm:
425425
bpm = config.bpm
426426
else:
@@ -430,8 +430,14 @@ def _start_arpeggio_impl(self):
430430
self.step_timer.setInterval(interval_ms)
431431
self.step_timer.start()
432432

433+
# Start scheduling notes using sample-accurate timing
434+
self._schedule_next_notes()
435+
433436
def stop_arpeggio(self):
434437
"""Stop the arpeggio playback (thread-safe)."""
438+
# Clear scheduled arpeggiator events
439+
global_scheduler.clear_events_by_source('arpeggiator')
440+
435441
# In test mode, call directly; otherwise use Qt threading
436442
if self._test_mode:
437443
self._stop_arpeggio_impl()
@@ -460,9 +466,39 @@ def _stop_arpeggio_impl(self):
460466
def advance_arpeggio(self):
461467
"""Advance to the next note in the arpeggio."""
462468
if not self.current_sequence or not self.enabled:
463-
self.stop_arpeggio()
464469
return
465470

471+
# In test mode, use legacy direct playback for compatibility
472+
if self._test_mode:
473+
self._advance_arpeggio_legacy()
474+
return
475+
476+
# In normal mode, this method is only used for UI updates
477+
# Actual note scheduling is done via _schedule_next_notes
478+
479+
# Update the current note label
480+
if self.pattern == 'chord':
481+
notes_to_play = self.current_sequence[0]
482+
self.current_note = f"Chord: {', '.join([self.midi_to_note_name(n) for n in notes_to_play])}"
483+
elif self.pattern == 'random':
484+
if self.current_sequence:
485+
# Display a representative note (first in sequence)
486+
self.current_note = self.midi_to_note_name(self.current_sequence[0])
487+
else:
488+
if self.current_sequence:
489+
note = self.current_sequence[self.sequence_position]
490+
self.current_note = self.midi_to_note_name(note)
491+
self.sequence_position = (self.sequence_position + 1) % len(self.current_sequence)
492+
493+
# Update display
494+
if not self._test_mode:
495+
QMetaObject.invokeMethod(self.current_note_label, "setText", Qt.QueuedConnection, Q_ARG(str, self.current_note))
496+
self.last_played_time = time.time()
497+
498+
def _advance_arpeggio_legacy(self):
499+
"""Legacy advance method for test mode compatibility."""
500+
from qwerty_synth.controller import play_midi_note_direct
501+
466502
# Calculate note duration based on gate
467503
if self.sync_to_bpm:
468504
bpm = config.bpm
@@ -475,7 +511,7 @@ def advance_arpeggio(self):
475511
# Get current note(s)
476512
if self.pattern == 'chord':
477513
# Play all notes in the chord
478-
notes_to_play = self.current_sequence[0] # Chord is stored as a list
514+
notes_to_play = self.current_sequence[0]
479515
for note in notes_to_play:
480516
play_midi_note_direct(note, note_duration, 0.8)
481517
self.current_note = f"Chord: {', '.join([self.midi_to_note_name(n) for n in notes_to_play])}"
@@ -494,14 +530,69 @@ def advance_arpeggio(self):
494530
# Advance position
495531
self.sequence_position = (self.sequence_position + 1) % len(self.current_sequence)
496532

497-
# Update display using appropriate method for test mode
498-
if self._test_mode:
499-
# In test mode, just store the current note without updating UI
500-
pass
501-
else:
502-
QMetaObject.invokeMethod(self.current_note_label, "setText", Qt.QueuedConnection, Q_ARG(str, self.current_note))
503533
self.last_played_time = time.time()
504534

535+
def _schedule_next_notes(self):
536+
"""Schedule the next batch of arpeggio notes using sample-accurate timing."""
537+
if not self.enabled or not self.current_sequence:
538+
return
539+
540+
# Calculate timing parameters
541+
if self.sync_to_bpm:
542+
bpm = config.bpm
543+
else:
544+
bpm = self.rate
545+
546+
step_duration = 60.0 / bpm / 4 # Duration of one 16th note
547+
note_duration = step_duration * self.gate
548+
549+
# Schedule notes for the next few steps (e.g., 8 steps ahead)
550+
num_steps_to_schedule = 8
551+
552+
for i in range(num_steps_to_schedule):
553+
delay = i * step_duration
554+
555+
if self.pattern == 'chord':
556+
# Schedule all notes in the chord
557+
notes_to_play = self.current_sequence[0]
558+
for note in notes_to_play:
559+
global_scheduler.schedule_note_on(
560+
midi_note=note,
561+
velocity=0.8,
562+
delay_seconds=delay,
563+
duration_seconds=note_duration
564+
)
565+
elif self.pattern == 'random':
566+
# Schedule a random note
567+
import random
568+
note = random.choice(self.current_sequence)
569+
global_scheduler.schedule_note_on(
570+
midi_note=note,
571+
velocity=0.8,
572+
delay_seconds=delay,
573+
duration_seconds=note_duration,
574+
source='arpeggiator'
575+
)
576+
else:
577+
# Regular sequential patterns
578+
note = self.current_sequence[self.sequence_position]
579+
global_scheduler.schedule_note_on(
580+
midi_note=note,
581+
velocity=0.8,
582+
delay_seconds=delay,
583+
duration_seconds=note_duration,
584+
source='arpeggiator'
585+
)
586+
self.sequence_position = (self.sequence_position + 1) % len(self.current_sequence)
587+
588+
# Schedule callback to schedule the next batch
589+
schedule_ahead_time = num_steps_to_schedule * step_duration * 0.8
590+
global_scheduler.schedule_callback(
591+
callback=self._schedule_next_notes,
592+
delay_seconds=schedule_ahead_time,
593+
source='arpeggiator'
594+
)
595+
505596
def midi_to_note_name(self, midi_note):
506597
"""Convert MIDI note number to note name."""
507598
note_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']

qwerty_synth/controller.py

Lines changed: 40 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""Programmatic API for controlling the synthesizer."""
22

33
import threading
4-
import time
54
import mido
65

76
from qwerty_synth import config
87
from qwerty_synth.keyboard_midi import MidiEvent
98
from qwerty_synth.synth import Oscillator
9+
from qwerty_synth.event_scheduler import global_scheduler
1010

1111

1212
# Counter to generate unique keys for notes in polyphonic mode
@@ -363,7 +363,7 @@ def _handle_keyboard_note_off(midi_note: int) -> None:
363363

364364
def play_midi_file(midi_file_path, tempo_scale=1.0):
365365
"""
366-
Play a MIDI file using the synthesizer.
366+
Play a MIDI file using the synthesizer with sample-accurate timing.
367367
368368
Args:
369369
midi_file_path: Path to the MIDI file
@@ -383,101 +383,64 @@ def play_midi_file(midi_file_path, tempo_scale=1.0):
383383
# Reset playback state
384384
config.midi_playback_active = True
385385

386-
# Track active notes for each channel to handle note_off events
387-
active_notes = {}
388-
389-
def play_midi_events():
390-
"""Play MIDI events in a separate thread with improved timing."""
391-
# Use absolute timing instead of sleep-based timing
392-
start_time = time.perf_counter()
393-
current_time = 0
394-
pause_start_time = 0
395-
total_pause_time = 0
386+
# Clear any existing scheduled events
387+
global_scheduler.clear_all_events()
396388

389+
def schedule_midi_events():
390+
"""Schedule all MIDI events using the sample-accurate scheduler."""
397391
try:
392+
# Track current time in seconds
393+
current_time_seconds = 0.0
394+
398395
for msg in midi_file:
399396
# Check if playback has been stopped
400397
if not config.midi_playback_active:
401-
# Release any active notes
402-
for channel_key in list(active_notes.keys()):
403-
with config.notes_lock:
404-
osc = active_notes[channel_key]
405-
if osc.key in config.active_notes:
406-
config.active_notes[osc.key].released = True
398+
global_scheduler.clear_all_events()
407399
break
408400

409-
# Handle paused state
410-
while config.midi_paused and config.midi_playback_active:
411-
if pause_start_time == 0:
412-
pause_start_time = time.perf_counter()
413-
time.sleep(0.01) # Reduced sleep time for better responsiveness
414-
415-
# If we were paused and resumed
416-
if pause_start_time > 0:
417-
pause_end_time = time.perf_counter()
418-
total_pause_time += (pause_end_time - pause_start_time)
419-
pause_start_time = 0
420-
421-
# Check if tempo scale has changed
422-
current_time_scale = 1.0 / config.midi_tempo_scale
423-
424-
# Calculate when this event should happen
425-
current_time += msg.time * current_time_scale
426-
# Adjust target time by subtracting pause duration
427-
target_time = start_time + current_time - total_pause_time
428-
429-
# Wait until it's time to process this event - with improved precision
430-
wait_time = target_time - time.perf_counter()
431-
if wait_time > 0:
432-
# For short waits, use busy waiting for high precision
433-
if wait_time < 0.01:
434-
while time.perf_counter() < target_time:
435-
pass
436-
else:
437-
# For longer waits, sleep most of the time then busy wait
438-
time.sleep(wait_time - 0.005) # Wake up 5ms early
439-
# Then busy wait for the remaining time for precision
440-
while time.perf_counter() < target_time:
441-
pass
401+
# Update time based on message delta
402+
time_scale = 1.0 / config.midi_tempo_scale
403+
current_time_seconds += msg.time * time_scale
442404

443405
# Handle note on events
444406
if msg.type == 'note_on' and msg.velocity > 0:
445407
# Convert velocity from 0-127 to 0.0-1.0
446408
velocity = msg.velocity / 127.0
447409

448-
# Start the note (duration will be handled by note_off)
449-
osc = play_midi_note(msg.note, 0, velocity)
450-
451-
# Store oscillator reference for this channel and note
452-
channel_key = (msg.channel, msg.note)
453-
active_notes[channel_key] = osc
410+
# Schedule the note_on event with sample-accurate timing
411+
global_scheduler.schedule_note_on(
412+
midi_note=msg.note + config.octave_offset + config.semitone_offset,
413+
velocity=velocity,
414+
delay_seconds=current_time_seconds,
415+
duration_seconds=0, # Duration handled by note_off
416+
source='midi'
417+
)
454418

455419
# Handle note off events (or note_on with velocity 0)
456420
elif (msg.type == 'note_off') or (msg.type == 'note_on' and msg.velocity == 0):
457-
channel_key = (msg.channel, msg.note)
458-
459-
# If we have a reference to this note's oscillator, release it
460-
if channel_key in active_notes:
461-
with config.notes_lock:
462-
# Mark the oscillator as released to start its release envelope
463-
osc = active_notes[channel_key]
464-
if osc.key in config.active_notes:
465-
config.active_notes[osc.key].released = True
466-
config.active_notes[osc.key].env_time = 0.0
467-
config.active_notes[osc.key].lfo_env_time = 0.0
468-
469-
# Remove from active notes
470-
del active_notes[channel_key]
471-
472-
# Mark playback as complete
473-
config.midi_playback_active = False
421+
# Schedule the note_off event
422+
global_scheduler.schedule_note_off(
423+
midi_note=msg.note + config.octave_offset + config.semitone_offset,
424+
delay_seconds=current_time_seconds,
425+
source='midi'
426+
)
427+
428+
# Schedule a callback to mark playback as complete
429+
def mark_playback_complete():
430+
config.midi_playback_active = False
431+
432+
global_scheduler.schedule_callback(
433+
callback=mark_playback_complete,
434+
delay_seconds=current_time_seconds + 1.0, # Add 1 second buffer
435+
source='midi'
436+
)
474437

475438
except Exception as e:
476-
print(f"Error during MIDI playback: {e}")
439+
print(f"Error scheduling MIDI events: {e}")
477440
config.midi_playback_active = False
478441

479-
# Start playback in a separate thread
480-
threading.Thread(target=play_midi_events, daemon=True).start()
442+
# Schedule all events in a separate thread
443+
threading.Thread(target=schedule_midi_events, daemon=True).start()
481444

482445
except Exception as e:
483446
print(f"Error playing MIDI file: {e}")

0 commit comments

Comments
 (0)