From 1d28f298416c0df251efb951697d334c852c061c Mon Sep 17 00:00:00 2001 From: tyeth Date: Fri, 23 Oct 2026 01:35:55 +0100 Subject: [PATCH] synthio: follow the MIDI file's tempo map in MidiTrack MidiTrack played at one fixed rate for the whole track, so a file that changes tempo, or that simply does not use the 120 BPM default, played at the wrong speed throughout. The Set Tempo meta event was skipped. Act on Set Tempo. Its value is in microseconds per quarter note, which can only be converted to the ticks per second MidiTrack works in if the ticks per quarter note is known, so take that as a new optional ticks_per_beat argument. from_file() reads it from the file header; it is left at 0 for SMPTE timing, where the rate is already absolute and Set Tempo does not apply, and for callers that pass raw track bytes without it. The tempo is rewound along with the track when the sample restarts, so a looping track does not inherit the rate its last loop ended on. Co-Authored-By: Claude Opus 5 --- shared-bindings/synthio/MidiTrack.c | 19 +++++++++++++++---- shared-bindings/synthio/MidiTrack.h | 2 +- shared-bindings/synthio/__init__.c | 16 +++++++++++++--- shared-module/synthio/MidiTrack.c | 27 +++++++++++++++++++++------ shared-module/synthio/MidiTrack.h | 5 +++++ 5 files changed, 55 insertions(+), 14 deletions(-) diff --git a/shared-bindings/synthio/MidiTrack.c b/shared-bindings/synthio/MidiTrack.c index 89c50282bab..83b2fd94100 100644 --- a/shared-bindings/synthio/MidiTrack.c +++ b/shared-bindings/synthio/MidiTrack.c @@ -23,16 +23,25 @@ //| buffer: ReadableBuffer, //| tempo: int, //| *, +//| ticks_per_beat: int = 0, //| sample_rate: int = 11025, //| waveform: Optional[ReadableBuffer] = None, //| envelope: Optional[Envelope] = None, //| ) -> None: -//| """Create a MidiTrack from the given stream of MIDI events. Only "Note On" and "Note Off" events -//| are supported; channel numbers and key velocities are ignored. Up to two notes may be on at the -//| same time. +//| """Create a MidiTrack from the given stream of MIDI events. +//| +//| "Note On" and "Note Off" events are played; a "Note On" with a velocity of zero is +//| treated as a "Note Off", as the MIDI specification allows. Other channel events are +//| skipped, as are System Exclusive and meta events, except that "End of Track" stops +//| playback and "Set Tempo" adjusts it when ``ticks_per_beat`` is known. Events that omit +//| a repeated status byte ("running status") are understood. Channel numbers and key +//| velocities are otherwise ignored. //| //| :param ~circuitpython_typing.ReadableBuffer buffer: Stream of MIDI events, as stored in a MIDI file track chunk //| :param int tempo: Tempo of the streamed events, in MIDI ticks per second +//| :param int ticks_per_beat: Ticks per quarter note, from the MIDI file header. When this +//| is 0 (the default) any "Set Tempo" event in the stream is ignored, because its +//| microseconds-per-quarter-note value cannot be converted without it. //| :param int sample_rate: The desired playback sample rate; higher sample rate requires more memory //| :param ReadableBuffer waveform: A single-cycle waveform. Default is a 50% duty cycle square wave. If specified, must be a ReadableBuffer of type 'h' (signed 16 bit) //| :param Envelope envelope: An object that defines the loudness of a note over time. The default envelope provides no ramping, voices turn instantly on and off. @@ -55,10 +64,11 @@ //| ... //| static mp_obj_t synthio_miditrack_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { - enum { ARG_buffer, ARG_tempo, ARG_sample_rate, ARG_waveform, ARG_envelope }; + enum { ARG_buffer, ARG_tempo, ARG_ticks_per_beat, ARG_sample_rate, ARG_waveform, ARG_envelope }; static const mp_arg_t allowed_args[] = { { MP_QSTR_buffer, MP_ARG_OBJ | MP_ARG_REQUIRED, {} }, { MP_QSTR_tempo, MP_ARG_INT | MP_ARG_REQUIRED, {} }, + { MP_QSTR_ticks_per_beat, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, { MP_QSTR_sample_rate, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 11025} }, { MP_QSTR_waveform, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = mp_const_none } }, { MP_QSTR_envelope, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = mp_const_none } }, @@ -73,6 +83,7 @@ static mp_obj_t synthio_miditrack_make_new(const mp_obj_type_t *type, size_t n_a common_hal_synthio_miditrack_construct(self, (uint8_t *)bufinfo.buf, bufinfo.len, mp_arg_validate_int_min(args[ARG_tempo].u_int, 1, MP_QSTR_tempo), + args[ARG_ticks_per_beat].u_int, args[ARG_sample_rate].u_int, args[ARG_waveform].u_obj, mp_const_none, diff --git a/shared-bindings/synthio/MidiTrack.h b/shared-bindings/synthio/MidiTrack.h index cb154d3996b..48e430156d5 100644 --- a/shared-bindings/synthio/MidiTrack.h +++ b/shared-bindings/synthio/MidiTrack.h @@ -11,7 +11,7 @@ extern const mp_obj_type_t synthio_miditrack_type; -void common_hal_synthio_miditrack_construct(synthio_miditrack_obj_t *self, const uint8_t *buffer, uint32_t len, uint32_t tempo, uint32_t sample_rate, mp_obj_t waveform_obj, mp_obj_t filter_obj, mp_obj_t envelope_obj); +void common_hal_synthio_miditrack_construct(synthio_miditrack_obj_t *self, const uint8_t *buffer, uint32_t len, uint32_t tempo, uint32_t ticks_per_beat, uint32_t sample_rate, mp_obj_t waveform_obj, mp_obj_t filter_obj, mp_obj_t envelope_obj); void common_hal_synthio_miditrack_deinit(synthio_miditrack_obj_t *self); mp_int_t common_hal_synthio_miditrack_get_error_location(synthio_miditrack_obj_t *self); diff --git a/shared-bindings/synthio/__init__.c b/shared-bindings/synthio/__init__.c index 3f9bc3f9274..b77e312974b 100644 --- a/shared-bindings/synthio/__init__.c +++ b/shared-bindings/synthio/__init__.c @@ -177,6 +177,9 @@ const mp_obj_namedtuple_type_t synthio_envelope_type_obj = { //| """Create an AudioSample from an already opened MIDI file. //| Currently, only single-track MIDI (type 0) is supported. //| +//| The file's tempo map is followed: the track starts at the MIDI default of 120 BPM and +//| each "Set Tempo" event changes the rate from that point on. +//| //| :param typing.BinaryIO file: Already opened MIDI file //| :param int sample_rate: The desired playback sample rate; higher sample rate requires more memory //| :param ReadableBuffer waveform: A single-cycle waveform. Default is a 50% duty cycle square wave. If specified, must be a ReadableBuffer of type 'h' (signed 16 bit) @@ -233,11 +236,18 @@ static mp_obj_t synthio_from_file(size_t n_args, const mp_obj_t *pos_args, mp_ma // TODO: for a multi-track MIDI (type 1), return an AudioMixer } - uint16_t tempo; + uint32_t tempo; + uint32_t ticks_per_beat; if (chunk_header[12] & 0x80) { + // SMPTE: frames per second times ticks per frame is already an + // absolute rate, so Set Tempo events do not apply to it tempo = -(int8_t)chunk_header[12] * chunk_header[13]; + ticks_per_beat = 0; } else { - tempo = 2 * ((chunk_header[12] << 8) | chunk_header[13]); + ticks_per_beat = (chunk_header[12] << 8) | chunk_header[13]; + // until a Set Tempo event says otherwise, MIDI defaults to 120 BPM, + // i.e. two quarter notes per second + tempo = 2 * ticks_per_beat; } bytes_read = mp_stream_rw(file_obj, chunk_header, 8, &errcode, MP_STREAM_RW_READ); @@ -260,7 +270,7 @@ static mp_obj_t synthio_from_file(size_t n_args, const mp_obj_t *pos_args, mp_ma synthio_miditrack_obj_t *result = mp_obj_malloc_with_finaliser(synthio_miditrack_obj_t, &synthio_miditrack_type); common_hal_synthio_miditrack_construct(result, buffer, track_size, - tempo, args[ARG_sample_rate].u_int, args[ARG_waveform].u_obj, + tempo, ticks_per_beat, args[ARG_sample_rate].u_int, args[ARG_waveform].u_obj, mp_const_none, args[ARG_envelope].u_obj ); diff --git a/shared-module/synthio/MidiTrack.c b/shared-module/synthio/MidiTrack.c index 2d090be2696..23641c12a2c 100644 --- a/shared-module/synthio/MidiTrack.c +++ b/shared-module/synthio/MidiTrack.c @@ -92,6 +92,16 @@ static void decode_system_event(synthio_miditrack_obj_t *self, uint8_t status) { self->pos = self->track.len; return; } + if (type == 0x51 && length == 3 && self->ticks_per_beat != 0) { // Set Tempo + uint32_t us_per_beat = (buffer[self->pos] << 16) | + (buffer[self->pos + 1] << 8) | buffer[self->pos + 2]; + if (us_per_beat != 0) { + uint32_t ticks = (uint32_t)(((uint64_t)self->ticks_per_beat * 1000000) / us_per_beat); + if (ticks != 0) { + self->tempo = ticks; + } + } + } self->pos += length; } else if (status == 0xF0 || status == 0xF7) { uint32_t length = decode_varint(self); @@ -165,8 +175,10 @@ static void decode_until_pause(synthio_miditrack_obj_t *self) { static void start_parse(synthio_miditrack_obj_t *self) { self->pos = 0; self->error_location = -1; - self->running_status = 0; self->pending = 0; + self->running_status = 0; + // a tempo map applies from the top of the track, so rewind it too + self->tempo = self->base_tempo; decode_duration(self); if (self->synth.span.dur == 0 && self->pending == 0) { // the usual case: the file starts with some MIDI event, not a delay @@ -175,10 +187,12 @@ static void start_parse(synthio_miditrack_obj_t *self) { } void common_hal_synthio_miditrack_construct(synthio_miditrack_obj_t *self, - const uint8_t *buffer, uint32_t len, uint32_t tempo, uint32_t sample_rate, - mp_obj_t waveform_obj, mp_obj_t filter_obj, mp_obj_t envelope_obj) { + const uint8_t *buffer, uint32_t len, uint32_t tempo, uint32_t ticks_per_beat, + uint32_t sample_rate, mp_obj_t waveform_obj, mp_obj_t filter_obj, + mp_obj_t envelope_obj) { - self->tempo = tempo; + self->tempo = self->base_tempo = tempo; + self->ticks_per_beat = ticks_per_beat; self->track.buf = (void *)buffer; self->track.len = len; @@ -204,7 +218,8 @@ void common_hal_synthio_miditrack_set_tempo(synthio_miditrack_obj_t *self, mp_in uint64_t remaining = (uint64_t)self->synth.span.dur + self->pending; remaining = remaining * self->tempo / val; set_remaining(self, remaining > UINT32_MAX ? UINT32_MAX : (uint32_t)remaining); - self->tempo = val; + // becomes the new baseline, so that looping does not undo it + self->tempo = self->base_tempo = val; } void synthio_miditrack_reset_buffer(synthio_miditrack_obj_t *self, @@ -223,7 +238,7 @@ audioio_get_buffer_result_t synthio_miditrack_get_buffer(synthio_miditrack_obj_t synthio_synth_synthesize(&self->synth, buffer, buffer_length, single_channel_output ? 0 : channel); if (self->synth.span.dur == 0) { if (self->pending != 0) { - // still inside a delay too long for a single span + // still inside a gap too long for a single span set_remaining(self, self->pending); } else if (self->pos == self->track.len) { return GET_BUFFER_DONE; diff --git a/shared-module/synthio/MidiTrack.h b/shared-module/synthio/MidiTrack.h index 674e73c6d4e..e8a6a9bf864 100644 --- a/shared-module/synthio/MidiTrack.h +++ b/shared-module/synthio/MidiTrack.h @@ -16,9 +16,14 @@ typedef struct { // invariant: after initial startup, pos always points just after an encoded duration, i.e., at a midi message (or at EOF) size_t pos; mp_int_t error_location; + // current tempo in ticks per second, and the value to rewind it to uint32_t tempo; + uint32_t base_tempo; // samples of the current delay that did not fit in span.dur uint32_t pending; + // ticks per quarter note (15 bits in the file header), or 0 when the + // timing is absolute (SMPTE) and Set Tempo events do not apply + uint16_t ticks_per_beat; // last channel status byte, for events that omit it (running status) uint8_t running_status; } synthio_miditrack_obj_t;