Skip to content

synthio: a freed track buffer, a ring modulation sign flip, reads pas… - #11370

Open
peterbay wants to merge 1 commit into
adafruit:mainfrom
peterbay:synthio-freed-track-and-ring-clamp
Open

synthio: a freed track buffer, a ring modulation sign flip, reads pas…#11370
peterbay wants to merge 1 commit into
adafruit:mainfrom
peterbay:synthio-freed-track-and-ring-clamp

Conversation

@peterbay

@peterbay peterbay commented Sep 13, 2026

Copy link
Copy Markdown

Code written by Claude Code, guided and corrected by @peterbay.

The problem

Six defects in synthio: a buffer freed while it is still being read, a full scale sign flip in ring modulation, two reads past the end of a buffer, and two divisors that were never checked.

The changes

  • from_file freed the track it had just handed over. common_hal_synthio_miditrack_construct does not copy the buffer — it keeps the pointer and parses it as the track plays, so everything after the first pause was decoded from memory that had been returned to the allocator. The m_free is removed; the buffer is owned by the MidiTrack from that point on.

  • Ring modulation of two troughs came out as a full scale spike of the wrong sign. -32768 * -32768 / 32768 is +32768, which was stored into an int16_t and read back as -32768. Widened to int32_t and clamped at 32767.

  • The nyquist test used the whole waveform's length, not the loop's. dds_rate is worked out from waveform_length - waveform_start, so the limit has to be (lim - offset) / 2. With a 256 sample loop inside a 512 sample waveform at 8 kHz the threshold came out at 8 kHz rather than the real 4 kHz, and everything between the two was let through and aliased.

  • The accumulator could be left outside the loop. lim is the loop end shifted, so accum == lim indexes waveform[waveform_length], one int16_t past the end; the comparison has to be >=. The wrap alongside it, accum % lim + offset, does not land in [offset, lim) either — it is offset + (accum - offset) % (lim - offset). Both are reached when a note's waveform or loop points are changed while it sounds.

  • A MIDI message that runs off the end of the track was parsed anyway. parse_note recorded the stream error and then fell through into its two reads with pos already at len, because record_midi_stream_error sets pos = len. It returns after recording now. The event loop had no guard of its own either, so synthio.MidiTrack(b"\x00", ...) reached the read; it breaks when pos is at the end.

  • MidiTrack(tempo=0) and Synthesizer(sample_rate=0) were accepted. Both are divisors further in, and in MidiTrack's case only the setter checked it, not the constructor.

Testing

Seeed XIAO nRF52840 Sense, on two builds differing only by these changes. Notes were rendered into a WAV in RAM with audiofilewriter.AudioFileWriter, so the samples could be compared rather than listened to.

before after
Synthesizer(sample_rate=0) accepted ValueError: sample_rate must be >= 1
MidiTrack(tempo=0) accepted ValueError: tempo must be >= 1
a note whose waveform and ring waveform are both a constant -32768 min -16382, max 0 min 0, max 16382
a note at 6 kHz with a 256 sample loop, at an 8 kHz sample rate output energy 11696256 0, the note is refused
the same at 7 kHz 12063744 0
the same at 2 kHz and 3 kHz, both below nyquist plays plays

The ring modulation row is the sign flip in full: the whole rendered note is negative before the change and positive after it.

Two of the six are not covered by that table. Reading past the end of a MIDI track returns whatever lies next in the heap, and on this board that did not change what was played. The freed track buffer is the same kind of thing: the allocator did not hand that block back during the window the test left it, so the output was the same either way. Both are clear enough in the code — a pointer kept after the buffer is freed, and a read at pos when pos == len.

…t the end

from_file freed the track buffer it had just handed to MidiTrack, which does
not copy it: it keeps the pointer and parses it as the track plays, so
everything after the first pause was decoded from memory that had been
returned to the allocator.

Ring modulation of two troughs gave -32768 * -32768 / 32768, which is +32768
and was stored into an int16_t as -32768, a full scale sign flip. It is
computed in an int32_t and clamped now.

dds_rate is worked out from the waveform loop's length, but the nyquist test
compared it with the whole waveform's, so a frequency past nyquist for the
loop was let through and aliased. lim is the loop end shifted, so accum == lim
indexes one int16_t past the end and the test has to be >=; the wrap beside
it did not land inside the loop either.

parse_note recorded a stream error and then fell through into two reads with
pos already at len, because recording the error sets pos = len. The event loop
had no guard of its own, so a one byte track reached the read.

MidiTrack's tempo and Synthesizer's sample_rate are divisors and were not
checked in the constructors.
@peterbay

Copy link
Copy Markdown
Author

Testing and diagnostic script.
synthio_freed_track_and_ring_clamp.py

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