You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
New SmfFile::active_notes_at(tick) -> Vec<Note>: every Note span
sounding at the absolute tick — the piano-roll / seek companion to notes(), and the note-level analogue of the channel-state channel_snapshot_at primitive. Where the snapshot answers "what
controller / program / bend state does a channel carry at tick T?",
this answers "which keys are held down at tick T?" — exactly the set a
DAW must re-trigger (or a renderer must prime into the voice pool) when
seeking into the middle of a file rather than playing from the top.
A note is sounding when start_tick <= tick && end_tick > tick — the
half-open interval [start_tick, end_tick). The onset tick is
inclusive (the snapshot reflects state immediately after that tick's
events fire, the same convention as channel_snapshot_at); the release
tick is exclusive (the key has come up). A zero-duration note
(start_tick == end_tick) is sounding at no tick. Hanging onsets and
unmatched releases — already dropped by notes() — cannot be reported.
The result preserves the notes()(start_tick, track) order so chord
notes stay grouped and track 0 precedes track 1.
Seven new unit tests: empty-when-silent, half-open boundary
(onset-inclusive / release-exclusive / mid-span / after-release),
before-onset silence, zero-duration never sounds, chord returns all
held keys in onset order, staggered overlap window (only-n1 / both /
only-n2), and hanging-note never sounds. Full lib suite 550 → 557
tests, zero ignored.
New SmfFile::poly_aftertouches() -> Vec<PolyAftertouchEvent>: every An kk pp Polyphonic Key Pressure (per-key aftertouch) channel-voice
event as a PolyAftertouchEvent { tick, track, channel, key, pressure } pinned to the absolute tick on its parent track, stably merged
across tracks (track 0 before track 1 at the same tick) under the same
convention every existing iteration helper and the scheduler use.
An was the only channel-voice status nibble without a dedicated
typed extraction helper. Distinct from Channel Pressure (Dn,
surfaced by channel_pressures()) — An carries a per-key kk byte,
so per-voice aftertouch automation can be rebuilt. Accessors channel() / key() / pressure() return the decoded fields.
Nine new unit tests: tick-zero decode, low-nibble channel index,
running-status (key, pressure) pair chaining, late-position absolute
tick, two-track stable sort, cross-track tick merge, filter exclusion
of every other channel-voice kind, to_bytes()/parse() round trip,
and empty-when-none. Full lib suite 541 → 550 tests, zero ignored.
Round 292 — SmfFile::notes() Note On / Note Off pairing into sounding-note spans
New SmfFile::notes() -> Vec<Note>: pairs every Note On
(9n key vel, vel > 0) with the Note Off that releases it and
returns one Note { start_tick, end_tick, track, channel, key, velocity, off_velocity } span per sounding note, ordered by onset.
Where the channel-voice helpers (program_changes / control_changes / pitch_bends / channel_pressures) surface one
value per wire event, this helper joins the two wire events that
bracket a note into a single span carrying its duration — the
primitive a piano-roll / DAW note-lane view consumes directly.
Honours the MIDI 1.0 Summary of MIDI Messages Table 1 velocity-0
convention: a 9n key 0 is treated as a Note Off and closes the
earliest open note of that pitch (FIFO), with off_velocity == 0.
An explicit 8n key off_vel carries its release velocity through to Note::off_velocity.
Matches over the globally merged event stream sorted by (absolute tick, track, in-track position) — the same stable-merge
convention every other iteration helper and the scheduler use — so a
Note Off on a different track from its Note On still pairs correctly.
Overlapping notes of the same (channel, key) are matched FIFO; an
unmatched release or a hanging onset is dropped from the span list.
New benches/synth_render.rs (harness = false) — repeatable
SMF→PCM wall-clock harness: dense 8-channel / 32-voice score with
pitch-bend sweeps + volume/pan CCs rendered through an in-memory
looping SF2 bank. --corpus hashes (FNV-1a-64) the PCM for every
in-tree fixture SMF through both the SF2 bank and the tone
fallback; --spin SECS loops the render as a sampling-profiler
target; default mode prints per-iteration wall time + output hash.
Profiling ranked Sf2Voice::render at ~89 % of the synthesis wall
clock; within it the per-sample DAHDSR volume-envelope stage walk
(an Option test + up to four stage comparisons + an f32 divide,
serialised behind the phase walk) at ~31 % of total, ahead of
sample fetch + linear interpolation (~15 %).
Sf2Voice::render now evaluates the volume envelope in
stage-segmented runs (envelope_run) into a 256-entry stack
buffer: delay / hold / sustain become slice fills, attack / decay /
release become element-wise loops with no loop-carried dependency
that the compiler vectorises. Every per-sample expression is kept
verbatim from envelope_at, so the rendered PCM is bit-identical —
corpus hashes are unchanged and the new envelope_run_matches_envelope_at_per_sample test pins to_bits() equality across stage boundaries, the release tail,
and the elapsed-wrap fallback. Dense-score render: 80.2 ms →
64.2 ms (-20 %) on an Apple-silicon dev box.
New SmfFile::channel_pressures(&self) -> Vec<ChannelPressureEvent>
surfaces every Dn pp Channel Pressure (mono aftertouch)
channel-voice event on every track, pinned to the absolute tick at
which it fires, in time order. Each entry is a ChannelPressureEvent { tick, track, channel, pressure } with the
status nibble's low four bits decoded into the spec's 0..=15
channel index and the single data byte pp (0..=127) carrying
"the single greatest pressure value (of all the current depressed
keys)" per the MIDI 1.0 Summary of MIDI Messages Table 1.
The new ChannelPressureEvent struct exposes channel() / pressure() accessors. The helper stays routing-agnostic — the
pressure value's musical effect (volume / vibrato depth / filter
cutoff) is left to the receiving instrument.
Only Dn is selected; polyphonic key pressure (An, per-key) keeps
its own surface, and the neighbouring CC (Bn) / program (Cn) /
pitch-bend (En) / note (8n / 9n) channel-voice events stay
isolated. Per-track sequences are stably merged by absolute tick
(track 0 before track 1 at the same tick), the same convention as
every meta-event, SysEx, and channel-voice helper.
8 new unit tests cover tick-zero decode, channel-index nibble,
running-status chains (single-data-byte status), late-position
absolute tick, stable same-tick cross-track sort, cross-track tick
merge, cross-kind filtering, and a to_bytes() / parse round trip.
New SmfFile::pitch_bends(&self) -> Vec<PitchBendEvent> surfaces
every En lsb msb Pitch Bend channel-voice event on every track,
pinned to the absolute tick at which it fires, in time order. Each
entry is a PitchBendEvent { tick, track, channel, value } with the
status nibble's low four bits decoded into the spec's 0..=15
channel index and the two data bytes combined into the 14-bit code (msb << 7) | lsb, 0..=0x3FFF, no-bend centre 0x2000 (the parser
assembles the value at decode time).
The new PitchBendEvent struct carries the same tick / track
pair the existing channel-voice iteration helpers use plus the
decoded channel and the assembled 14-bit value. The channel()
/ value() accessors return the fields with mnemonic names; a signed_value() -> i16 accessor returns the displacement from centre
in -8192..=8191 (value as i32 - 0x2000, so 0x2000 → 0, 0x0000 → -8192, 0x3FFF → 8191); an is_centre() predicate
flags the no-bend position for bend-lane collapse / wheel-release
detection.
Resolving the 14-bit code to an actual pitch displacement requires
the channel's Pitch Bend Sensitivity (RPN 0, default ±2 semitones),
which is intentionally left to the receiving application: the helper
stays sensitivity-agnostic and surfaces the raw code so callers pick
their own bend-range policy.
Per-track sequences are stably merged by absolute tick — track 0's
events fire before track 1's at the same tick — the same convention
used by every existing iteration helper (control_changes(), program_changes(), the SysEx and meta-event families, …) and the
scheduler's merged event list.
Companion to the wire-state primitive SmfFile::channel_snapshot_at,
which folds the last pitch bend per channel into SmfChannelSnapshot::pitch_bend at the seek point. Where the
snapshot answers "what is the wheel position on channel N at tick
T?", pitch_bends() answers "give me every bend in song order" —
the typed accessor a DAW bend-lane editor or a glissando / vibrato
curve renderer reads.
10 new tests cover the empty-input case, the centre value at tick
zero, the lsb / msb 14-bit combine at both extremes (0x0000
signed -8192, 0x3FFF signed +8191), channel-index decode across
the 0..=15 range, running-status reuse on the En two-data-byte
status (three chained bends sharing one status byte), late-position
absolute-tick tracking, stable-sort merge of same-tick events across
tracks, time-ordered merge of different-tick events across tracks,
filtering against the other six channel-voice status kinds (8n, 9n, An, Bn, Cn, Dn), a cross-check against channel_snapshot_at confirming the snapshot's pitch_bend field
tracks the iterator at each change point and on the silence between
changes, and a to_bytes() / parse() round trip confirming the
bend stream survives the mux-side writer.
New SmfFile::control_changes(&self) -> Vec<ControlChangeEvent>
surfaces every Bn cc vv Control Change channel-voice event on
every track, pinned to the absolute tick at which it fires, in time
order. Each entry is a ControlChangeEvent { tick, track, channel, controller, value } with the status nibble's low four bits decoded
into the spec's 0..=15 channel index and both data bytes cc / vv surfaced as raw 0..=127 payloads.
The new ControlChangeEvent struct carries the same tick / track pair the existing iteration helpers use plus the decoded channel, controller, and value bytes. The channel() / controller() / value() accessor methods return the
same fields with mnemonic names so call sites driving a
controller-automation view read cc.controller() / cc.value()
rather than the bare field access. A is_channel_mode() predicate
flags the channel-mode family (controller in 120..=127) — All
Sound Off (120), Reset All Controllers (121), Local Control
(122), All Notes Off (123), Omni Mode Off / On (124 / 125),
Mono / Poly Mode On (126 / 127) — so a player reset-detector
can route on the predicate without re-checking the controller
range manually.
Resolution against a controller vocabulary (the MIDI 1.0 Control Change Messages — Data Bytes table's 14-bit MSB / LSB
pairing for controllers 0..=31 plus 32..=63, the on-off
threshold value >= 64 for switch controllers 64..=69, the
CC-6 / CC-38 Data Entry pump that drives RPN / NRPN parameter writes
selected through CC-100 / CC-101 RPN and CC-98 / CC-99 NRPN pairs)
is intentionally left to the receiving application: the helper
stays controller-agnostic and surfaces the raw value byte so
callers pick their own controller-vocabulary policy.
Per-track sequences are stably merged by absolute tick — track 0's
events fire before track 1's at the same tick — the same convention
used by every existing iteration helper (program_changes(), sysex_events(), universal_sysex_events(), the meta-event
family, …) and the scheduler's merged event list.
Companion to the wire-state primitive SmfFile::channel_snapshot_at,
which folds the last value of the six snapshot-tracked
controllers (Bank MSB / LSB, Modulation, Volume, Pan, Expression,
Sustain) into the snapshot at the seek point. Where the snapshot
answers "what value is CC-7 / CC-10 / … at tick T?", control_changes() answers "give me every controller change in
song order, including the controllers the snapshot doesn't track"
— the typed accessor a DAW lane-editor or a CC-1 modulation-curve
renderer reads.
12 new tests cover the empty-input case, single-controller decode,
the full 0..=127 value range, channel-index decode across the 0..=15 range, running-status reuse on the Bn two-data-byte
status (three chained CCs sharing one status byte), late-position
absolute-tick tracking, stable-sort merge of same-tick events
across tracks, time-ordered merge of different-tick events across
tracks, filtering against the other six channel-voice status kinds
(8n, 9n, An, Cn, Dn, En), the channel-mode family
(120..=127) flagged by is_channel_mode() alongside a continuous
CC-7 surface that doesn't, a cross-check against channel_snapshot_at confirming the snapshot's volume field
tracks the iterator at each change point and on the silence
between changes, and a to_bytes() / parse() round trip
confirming the CC stream survives the mux-side writer. Total
lib-test count: 508 (up from 496).
New SmfFile::program_changes(&self) -> Vec<ProgramChangeEvent>
surfaces every Cn pp Program Change channel-voice event on every
track, pinned to the absolute tick at which it fires, in time order.
Each entry is a ProgramChangeEvent { tick, track, channel, program }
with the status nibble's low four bits decoded into the spec's 0..=15 channel index and the single data byte pp surfaced as the 0..=127 patch number.
The new ProgramChangeEvent struct carries the same tick / track
pair the existing iteration helpers use plus the decoded channel and
raw program bytes. channel() and program() accessor methods
return the same fields with mnemonic names so call sites driving an
instrument-list view read pc.channel() / pc.program() rather than
the bare field access.
Resolution against a patch list (General MIDI 1 / 2, GS, XG, …) is
intentionally left to the receiving application: the helper stays
bank-agnostic and surfaces the raw program byte so callers can pick
their own bank-select (CC 0 / CC 32) policy.
Per-track sequences are stably merged by absolute tick — track 0's
events fire before track 1's at the same tick — the same convention
used by every existing iteration helper
(sysex_events(), universal_sysex_events(), the meta-event family,
…) and the scheduler's merged event list.
Companion to the wire-state primitive SmfFile::channel_snapshot_at,
which folds the last Program Change per channel into SmfChannelSnapshot::program for seek initialisation. Where the
snapshot answers "what patch is this channel on at tick T?", program_changes() answers "give me every patch change in song
order" — the typed accessor a DAW track-inspector view (highlighting
the bar each instrument enters) reads.
10 new tests cover the empty-input case, single-patch decode, the
full 0..=127 program range, channel-index decode across the 0..=15 range, running-status reuse on the Cn single-data-byte
status, late-position absolute-tick tracking, stable-sort merge of
same-tick events across tracks, time-ordered merge of different-tick
events across tracks, filtering against the other six channel-voice
status kinds (8n, 9n, An, Bn, Dn, En), and a cross-check
against channel_snapshot_at confirming the snapshot's program
field tracks the iterator at each change point and on the silence
between changes. Total lib-test count: 496 (up from 486).
New SmfFile::universal_sysex_events(&self) -> Vec<UniversalSysExEvent>
surfaces every Universal System Exclusive packet on every track — F0 7E … (Non-Real-Time) and F0 7F … (Real-Time) only — pinned
to the absolute tick at which it fires, in time order, with the
Table-4 classification already resolved. Each entry is a UniversalSysExEvent { tick, track, classification, data }.
The new UniversalSysExEvent struct carries the same tick / track pair the existing iteration helpers use, the parsed UniversalSysEx { realm, device_id, sub_id1 } classification
the round-246 per-event classifier returns, and the verbatim
payload bytes from the wire (leading <realm> byte through
trailing 0xF7 when present) so callers reading Sub-ID #2-
derived arguments — Master Volume's 14-bit value, MTC Full
Message's hr / mn / se / fr quartet, MTS Single Note Tuning's
note + tuning triple, … — don't have to re-walk SmfFile::sysex_events() alongside the typed list.
Manufacturer-prefixed F0 packets (Roland 0x41, Yamaha 0x43,
any leading byte other than 0x7E / 0x7F) and F7
continuation / escape packets are filtered out — callers
interested in those route through SmfFile::sysex_events() and SysExEvent::manufacturer_id() directly. F0 packets truncated
before the Sub-ID #1 byte (payload shorter than 3 bytes) are also
filtered, matching the contract of the underlying SysExEvent::universal_classification() classifier so the two
views agree on which packets are universal.
Per-track sequences are stably merged by absolute tick — track 0's
universal packets fire before track 1's at the same tick — the
same convention used by SmfFile::sysex_events() / tempo_map() / time_signatures() / key_signatures() / markers() / lyrics() / cue_points() / track_names() / instrument_names() / texts() / copyrights() / smpte_offsets() / sequencer_specifics() / sequence_numbers() / midi_ports() / channel_prefixes()
and scheduler.rs §"merged event list, sorted by absolute tick".
The helper re-uses SysExEvent::universal_classification() for the
per-packet decode so any future tweak to the Table 4 vocabulary
lands in exactly one place; a regression test walks sysex_events()
in parallel and confirms the typed view stays byte-for-byte aligned
with the per-event classifier across a mixed packet sample.
7 new unit tests cover the empty-file case, the manufacturer-
prefixed + escape filter, both-realm classification, cross-track
stable merging, same-tick stable-sort ordering (track 0 wins),
truncated-universal-packet drop, and the per-event classifier
parity check.
New SysExEvent::universal_classification(&self) -> Option<UniversalSysEx>
classifies a Universal System Exclusive packet against Table 4 of the
MIDI 1.0 Universal System Exclusive Messages document, returning a UniversalSysEx { realm, device_id, sub_id1 } for every F0 packet
whose leading byte is 0x7E (Universal Non-Real-Time) or 0x7F
(Universal Real-Time). The Sub-ID #1 byte is parsed against the
Table 4 vocabulary into a UniversalSubId1 enum that names every
category in the document; categories that branch on Sub-ID #2 carry
a UniversalSubId2 payload that names every Sub-ID #2 value the
document defines for the category-realm pair. Sub-ID #1 / Sub-ID #2
bytes outside the Table 4 vocabulary the classifier knows about
surface through UniversalSubId1::Other(raw) / UniversalSubId2::Other(raw) so callers with deeper, more recent
vocabulary can still route the packet.
The classifier is realm-aware: the same (sub_id1, sub_id2) byte
pair names different messages in the two realms (e.g. 0x09 0x01
is GeneralMidi1SystemOn in Non-Real-Time and ControllerDestinationChannelPressure in Real-Time, per Table 4);
the classifier decodes against the parsed realm before matching.
Singleton Sub-ID #1 categories (0x7B End of File through 0x7F
ACK) are singleton-shaped and report as unit variants of UniversalSubId1. F7 continuation / escape packets, manufacturer-
prefixed F0 packets (any leading byte other than 0x7E / 0x7F),
and F0 packets truncated before Sub-ID #1 (fewer than 3 payload
bytes) return None so callers route them through SysExEvent::manufacturer_id or the raw SysExEvent::data instead.
The UniversalSubId1 enum surfaces every Sub-ID #1 byte the
document defines: Sample Dump Header / Data Packet / Request (the
three Non-Real-Time singletons at 0x01..=0x03), MIDI Time Code
(Non-RT Setup at 0x04 / RT Quarter-Frame at 0x01), Sample Dump
Extensions, General Information, File Dump, MIDI Tuning Standard,
General MIDI / Controller Destination Setting, Downloadable Sounds /
Key-Based Instrument Control, File Reference / Scalable Polyphony
MIP, MIDI Visual Control / Mobile Phone Control, MIDI Capability
Inquiry, MIDI Show Control, Notation Information, Device Control,
Real-Time MTC Cueing — plus the five Non-Real-Time singletons (End
of File, Wait, Cancel, NAK, ACK).
The UniversalSubId2 enum surfaces every Sub-ID #2 byte the
document defines for those categories: 15 Non-Real-Time MTC Setup
sub-categories (Special, Punch In / Out Points, Delete Punch In /
Out Point, Event Start / Stop Point, the four "with additional
info" variants, Cue Points, Delete Cue Point, Event Name with
additional info), 7 Sample Dump Extension sub-categories (Loop
Points / Sample Name transmission and request, Extended Dump
Header, Extended Loop Points transmission and request), the
Identity Request / Reply pair, File Dump Header / Data Packet /
Request, 10 MTS sub-categories (Bulk Dump Request / Reply, Tuning
Dump Request, Key-Based Tuning Dump, Scale/Octave Tuning Dump in 1-
and 2-byte formats, Single Note Tuning Change with Bank Select,
Scale/Octave Tuning in 1- and 2-byte formats, RT Single Note
Tuning Change), GM 1 / GM Off / GM 2 System On, the DLS quartet
(Turn DLS On / Off / Voice Allocation Off / On), the File
Reference quartet (Open File, Select or Reselect Contents, Open
File and Select Contents, Close File), MTC Full Message / User
Bits, MSC Extensions, Notation Bar Number / Time Signature
Immediate / Time Signature Delayed, the five Device Control
variants (Master Volume / Balance / Fine Tuning / Coarse Tuning /
Global Parameter Control), 10 RT MTC Cueing sub-categories (the
Real-Time mirror of the Non-Real-Time MTC Setup family), the
Controller Destination triple (Channel Pressure, Polyphonic Key
Pressure, Control Change), Key-Based Instrument Control, Scalable
Polyphony MIP Message, Mobile Phone Control Message.
30 new unit tests cover: every Sub-ID #1 vocabulary slot, every
Sub-ID #2 vocabulary slot across both realms, realm disambiguation
for the shared 0x09 0x01 / 0x02 / 0x07 / 0x08 / 0x09
byte pairs, the four "this is not a Universal packet" return-None
paths (F7 continuation, manufacturer-prefixed F0, expanded
three-byte manufacturer ID, payload shorter than 3 bytes), the device_id byte preservation across the 0x00..=0x7F range
(specific-device vs broadcast 0x7F target), the Other(raw)
fallback paths for unknown Sub-ID #1 and Sub-ID #2 values, and an
end-to-end pass that parses an SMF carrying GM 1 System On +
Master Volume + Master Fine Tuning, walks SmfFile::sysex_events(),
classifies each entry, and confirms the realm split + Sub-ID #1 /
Sub-ID #2 decoding all the way through the parser → iteration
helper → classifier pipeline.
Builds on the existing SysExEvent surface (tick, track, is_escape, data, ends_with_eox, is_complete_message, manufacturer_id) — universal_classification is the realm- and
category-decoded view of the same payload manufacturer_id returns
the leading byte of, so a caller routing by either accessor stays
in sync with the verbatim SysExEvent::data buffer the SMF
parser produces.
New SmfFile::sysex_events(&self) -> Vec<SysExEvent> surfaces
every System Exclusive event — both the F0 start and the F7
continuation / escape flavours — as a SysExEvent { tick, track, is_escape, data } with the absolute tick on the parent track,
stably merged across tracks (track 0 before track 1 at the same
tick) under the same merge rule as every existing iteration
helper and the scheduler. Standard MIDI File Specification 1.0
§"System Exclusive Events" defines F0 <varlen> <payload> (a
complete-or-starting SysEx message; the trailing F7 end marker,
when present, is included as the final byte of <payload>) and F7 <varlen> <payload> (a continuation packet for a previously-
started F0 message or an arbitrary escape sequence whose
payload is shipped verbatim to the wire). Both forms surface
through the helper; the is_escape flag distinguishes them and
the data payload is reproduced verbatim so a writer can
round-trip the helper output through SmfFile::to_bytes without
re-synthesising the SysEx framing.
SysExEvent::ends_with_eox() returns true when the payload
terminates with the 0xF7 end marker; SysExEvent::is_complete_message()
is sugar for !is_escape && ends_with_eox(), the common universal-
SysEx case (GM-on F0 7E 7F 09 01 F7, Master Volume, Master
Tuning) where a caller routes the whole packet in one step. SysExEvent::manufacturer_id() returns the leading byte of an F0 payload (the manufacturer ID, or 0x7E non-real-time / 0x7F real-time for universal SysEx) and None for an F7
packet or an empty payload; expanded three-byte IDs (0x00-
prefixed) are surfaced as Some(0x00) and the caller inspects data[0..=2] for the full ID.
The FF 7FSequencerSpecific channel — surfaced through SmfFile::sequencer_specifics() — is not selected here; the
two channels carry different semantics (SysEx travels to the
MIDI wire; FF 7F is file-private metadata that does not) and a
file may carry both an F0 7E 7F 09 01 F7 Universal Non-Real-Time
GM-On packet on the conductor track and a private FF 7F
plugin-state blob alongside it. The helper surfaces empty
payloads (F0 00 / F7 00) as data.is_empty() rather than
filtering them out — the spec permits a zero-length packet.
9 new unit tests cover: empty case (no F0 / F7 in any track);
universal GM-on at tick zero (F0 7E 7F 09 01 F7, complete
message, manufacturer 0x7E); F0 without trailing F7 marking
a multi-packet opener (Roland 0x41); F7 continuation pairing
after an F0 opener (with EOX terminator on the continuation
packet); empty payload F0 00 surfacing as data.is_empty();
multi-track merge by absolute tick; stable sort keeping (track, in-track) order at the same tick; filter-purity against F0 + F7 alongside FF 03 / FF 01 / FF 21 / FF 20 / FF 51 / FF 7F / B0 / 90 / FF 2F plus a cross-check that sequencer_specifics() surfaces its single FF 7F entry
untouched; and a parser → to_bytes → parser round-trip
exercising the writer's F0 + F7 paths so the helper can't
drift out of sync with the mux.
Surfaces the SysEx channel alongside the 15-helper meta-event
family; the meta-event helper count itself stays at 15
(SmfFile::{tempo_map, time_signatures, key_signatures, markers, lyrics, cue_points, track_names, instrument_names, texts, copyrights, smpte_offsets, sequencer_specifics, sequence_numbers, midi_ports, channel_prefixes}), since SysEx is a wire-event
family rather than a meta-event family.
New SmfFile::channel_prefixes(&self) -> Vec<ChannelPrefixEvent>
surfaces every FF 20 01 cc Channel Prefix meta event as a ChannelPrefixEvent { tick, track, channel } with the absolute
tick on the parent track, stably merged across tracks (track 0
before track 1 at the same tick) under the same merge rule as
every existing iteration helper and the scheduler. FF 20 carries
the channel-binding hint for non-channel events that follow on the
same track: the single payload byte names the MIDI channel
(0..=15) the following meta / sysex events should be associated
with — text, lyric, marker, cue point, sysex — until another FF 20 arrives, the next channel-voice event arrives and
supersedes the binding, or the track ends. The Standard MIDI File
Specification 1.0 lists the event as part of the meta-event
vocabulary; modern authoring tools prefer explicit per-track
channel-voice streams plus FF 21 port hints, but older files
still emit it and a round-trip workflow must preserve it.
ChannelPrefixEvent::channel() returns the spec-clamped channel
index as Option<u8>: Some(c) when c < 16, None otherwise.
The raw byte stays available on the channel field so files with
out-of-spec values (a single bit set in the high nibble, etc.)
still round-trip; the helper returns None rather than mask the
byte because masking would silently route to an unintended channel.
Only FF 20 is selected — the neighbouring FF 21 port-hint
sibling (different routing semantics: per-track physical port
assignment versus per-message channel override) stays on its own SmfFile::midi_ports() helper so callers reconstructing the
channel association of surrounding non-channel events get a clean
time-ordered list independent of the other meta streams.
8 new unit tests cover: empty case (no FF 20 in any track);
single binding at tick zero; full spec 0..=15 round trip (one
case per cc); out-of-spec cc = 0x20 surfaces raw with channel() == None; multi-track merge by absolute tick; stable
sort keeping (track, in-track) order at the same tick;
filter-purity against FF 21 / FF 00 / FF 01 / FF 03 / FF 05 / FF 51 / FF 54 / FF 58 / FF 59 / FF 7F; and a parser → to_bytes
→ parser round-trip that exercises the writer's FF 20 path so
the helper can't drift out of sync with the mux.
Lifts the SMF meta-event iterator family from 14 to 15 total: SmfFile::{tempo_map, time_signatures, key_signatures, markers, lyrics, cue_points, track_names, instrument_names, texts, copyrights, smpte_offsets, sequencer_specifics, sequence_numbers, midi_ports, channel_prefixes}.
New SmfFile::to_bytes(&self) -> Result<Vec<u8>> serialises a
parsed SMF back to a complete byte stream (MThd header + one MTrk chunk per Track), suitable to hand back to parse for
a structural round-trip. Companion Track::to_bytes_chunk(&self) -> Result<Vec<u8>> emits one self-contained MTrk chunk so
callers building a multi-track file from independent track
sources can splice the chunks under a single MThd header.
Every channel-voice variant (NoteOn / NoteOff / PolyAftertouch / ControlChange / ProgramChange / ChannelAftertouch / PitchBend), every concrete meta variant
(SequenceNumber / Text / ChannelPrefix / Port / EndOfTrack / Tempo / SmpteOffset / TimeSignature / KeySignature / SequencerSpecific / passthrough Unknown),
and both sysex forms (F0 start, F7 continuation/escape) emit
on the wire format the parser already accepts. Unknown with type_byte: 0x2F is rejected so the End-of-Track marker cannot
be smuggled in past the placement check.
Output uses explicit status bytes throughout — the spec
permits but does not require running-status compression, and
the explicit form keeps the writer deterministic regardless of
internal track ordering. A reader that does not honour running
status can still consume the output unchanged.
New top-level MAX_VLQ_VALUE = 0x0FFF_FFFF constant — the
largest VLQ-encodable value per the SMF spec's 4-byte cap (the
same cap the parser's MAX_VLQ_BYTES = 4 enforces on the read
side). Delta-times, meta payload lengths, and sysex payload
lengths must all fit; the writer surfaces Error::InvalidData
for anything larger.
Strict validation at encode-time, with descriptive Error::InvalidData messages that name the offending field /
track / event index so caller-side debugging stays local: header.ntrks must match tracks.len(); each track must end
with exactly one MetaEvent::EndOfTrack as its final event;
channel-voice data bytes must have the high bit clear; pitch-bend
values must fit 14 bits; KeySignature.mode must be 0 or 1; Tempo must fit 24 bits; SMPTE frames_per_second must be one
of {24, 25, 29, 30}; TicksPerQuarter must be 1..=0x7FFF; Text.kind must be 0x01..=0x0F.
Lifts the SMF surface from read-only to read + write: the
full event vocabulary the parser materialises (channel-voice,
meta, sysex) now round-trips through to_bytes -> parse
byte-for-byte. The 17 new writer tests cover spec-VLQ encoding
(10 worked examples), every meta variant, every channel-voice
variant, both sysex forms, a multi-track Format-1 file, the
long-VLQ (4-byte) delta branch, all five validation rejections,
and the per-track chunk helper.
Round 230 — SmfFile::midi_ports() (FF 21)
New smf::MidiPortEvent { tick, track, port } value type pinned to
the absolute tick at which an FF 21 01 pp MIDI Port Meta-Event
fires on its parent track. MidiPortEvent::port() returns the
physical port byte (0..=127).
New SmfFile::midi_ports() -> Vec<MidiPortEvent> iterator helper
that walks every track, sums the per-track cumulative deltas, and
stably merges the matching FF 21 events across tracks (track 0
before track 1 at the same tick) under the same merge rule the
other 13 meta-event helpers and scheduler.rs §"merged event
list, sorted by absolute tick" already use.
The FF 21 Meta-Event carries an unofficial routing hint: the
single payload byte names the physical output port the
surrounding channel-voice messages on this track should be
dispatched through. The Standard MIDI File Specification 1.0 caps
a single channel-voice stream at 16 channels (the four status
nibbles 8x..Ex combined with the four channel bits x0..xF);
the port hint lets a multi-port DAW back-end multiply that ceiling
by however many physical outputs it wires up, by labelling each
track with the port it routes to. The convention is one FF 21
near the start of a track (delta zero, before the first
channel-voice event), but the helper surfaces every occurrence
rather than enforcing the placement rule so files that re-route
mid-track still round-trip the hint.
Only FF 21 is selected — the neighbouring FF 20 channel-prefix
hint (different routing semantics: per-message channel override
versus per-track physical port assignment) stays on its own so the
port-routing layer gets a clean time-ordered list independent of
the surrounding meta streams.
Lifts the SMF meta-event iterator family from 13 to 14 total
(SmfFile::{tempo_map,time_signatures,key_signatures,markers, lyrics,cue_points,track_names,instrument_names,texts,copyrights, smpte_offsets,sequencer_specifics,sequence_numbers,midi_ports}).
8 new unit tests covering: empty case, single hint at tick 0, full
7-bit range (0x7F) round-trip, per-track routing in a format-1
multi-port file, same-tick stable sort (track 0 before track 1),
cross-track merge sorted by tick, late-position absolute-tick
tracking after a channel-voice event, and filter exclusion against
the surrounding FF 00 / FF 01 / FF 03 / FF 05 / FF 20 / FF 51 / FF 54 / FF 58 / FF 59 / FF 7F events (the FF 20 sibling test pins the channel-prefix-vs-port distinction).
Round 224 — SmfFile::sequence_numbers() (FF 00)
New smf::SequenceNumberEvent { tick, track, number } value type
pinned to the absolute tick at which an FF 00 02 ssss Sequence
Number Meta-Event fires on its parent track. SequenceNumberEvent::number() returns the 16-bit identifier (the ssss payload is decoded big-endian).
New SmfFile::sequence_numbers() -> Vec<SequenceNumberEvent>
iterator helper that walks every track, sums the per-track
cumulative deltas, and stably merges the matching FF 00 events
across tracks (track 0 before track 1 at the same tick) under the
same merge rule the other 12 meta-event helpers and scheduler.rs
§"merged event list, sorted by absolute tick" already use.
The Standard MIDI File Specification 1.0 reserves the event for
delta-time zero (the first event of a track) and uses it to label
a format-2 pattern so it can be cued from a Song Select, but the
helper surfaces every occurrence rather than enforcing the
placement rule so files that carry the label later in a track
still round-trip. Format-1 / format-0 files conventionally place
the event on track 0 to label the file as a whole.
Lifts the SMF meta-event iterator family from 12 to 13 total
(SmfFile::{tempo_map,time_signatures,key_signatures,markers, lyrics,cue_points,track_names,instrument_names,texts,copyrights, smpte_offsets,sequencer_specifics,sequence_numbers}).
8 new unit tests covering: empty case, single label at tick 0,
big-endian decode (0x1234), full 0xFFFF round-trip, format-2
per-pattern labels, same-tick stable sort (track 0 before track
1), late-position absolute-tick tracking, and filter exclusion
against the surrounding FF 01 / FF 03 / FF 05 / FF 51 / FF 54 / FF 58 / FF 59 / FF 7F events.
New smf::SequencerSpecificEvent { tick, track, data } value type
pinned to the absolute tick at which an FF 7F len data
Sequencer-Specific Meta-Event fires on its parent track. SequencerSpecificEvent::data_bytes() borrows the raw payload.
New SmfFile::sequencer_specifics() -> Vec<SequencerSpecificEvent>
iterator helper that walks every track, sums the per-track
cumulative deltas, and stably merges the matching FF 7F events
across tracks (track 0 before track 1 at the same tick) under the
same merge rule the other 11 meta-event helpers and scheduler.rs
§"merged event list, sorted by absolute tick" already use. Only FF 7F is selected — channel-message F0 / F7 SysEx events
(which travel through the scheduler's SysEx pump rather than the
meta-event family) stay out of the list.
FF 7F payloads are surfaced verbatim: the parser does not
interpret the SysEx-style manufacturer-ID convention (where data[0], or data[0..=2] when data[0] == 0x00, holds the ID),
so a caller routing by manufacturer can inspect SequencerSpecificEvent::data directly while a generic player can
ignore per the SMF spec's "unknown meta events SHOULD be ignored"
rule. Empty payloads (FF 7F 00) are surfaced as data.is_empty() rather than filtered out — the spec permits a
zero-length blob.
Lifts the SMF meta-event iterator family from 11 to 12 total
(SmfFile::{tempo_map,time_signatures,key_signatures,markers, lyrics,cue_points,track_names,instrument_names,texts,copyrights, smpte_offsets,sequencer_specifics}).
8 new unit tests covering: empty case, single event at tick 0,
zero-length payload, multiple within one track, cross-track merge
sorted by tick, same-tick stable sort (track 0 before track 1),
filter excluding text / tempo / SMPTE / time / key meta kinds,
and absolute-tick tracking after channel-voice events.
New smf::SmfChannelSnapshot { program, bank_msb, bank_lsb, volume, pan, expression, modulation, sustain, pitch_bend } capturing the
on-the-wire channel state of one MIDI channel at one absolute SMF
tick. Fields that no event has touched stay at the SMF + GM 1
(RP-003) recommended defaults: volume = 100, pan = 64, expression = 127, modulation = 0, sustain = false, pitch_bend = 0x2000; program / bank_msb / bank_lsb stay None so a seek-time initialiser can skip emitting CCs the file
never wrote.
New SmfFile::channel_snapshot_at(channel, tick) -> SmfChannelSnapshot and SmfFile::channel_snapshots_at(tick) -> [SmfChannelSnapshot; 16]. Each replays every channel-voice event
from every track up to and including the requested tick, in
scheduler order — a stable merge by (tick, track, in-track),
track 0 winning over track 1 at the same tick, the same
convention tempo_map / time_signatures / key_signatures /
the eight text-meta helpers / smpte_offsets and scheduler.rs
§"merged event list, sorted by absolute tick" use.
The fold updates the snapshot for Program Change, Pitch Bend,
and Control Change on the seven channel-state CCs (CC 0 Bank
Select MSB, CC 1 Modulation Wheel, CC 7 Channel Volume, CC 10
Pan, CC 11 Expression, CC 32 Bank Select LSB, CC 64 Sustain
Pedal). Notes / poly + channel aftertouch are ignored — they
affect voice state, not channel state — so the snapshot is
cheap to compute for any tick without enumerating sounding
notes. Sustain pedal decodes the CC 64 value with the spec
threshold (value >= 64 = on, < 64 = off).
Events at exactly tick are included in the replay — the
snapshot reflects state immediately after that tick fires.
Channels >= 16 (not a legal MIDI channel) fall through to the
default snapshot rather than panic / index-out.
Bulk accessor channel_snapshots_at pools every track's events
into a single merge + replay pass so initialising all 16
channels at a seek target is single-pass rather than 16-pass —
the natural primitive for a DAW or player seeking into the
middle of a file.
SmfChannelSnapshot::apply(&ChannelBody) is also exposed
publicly so callers running custom replay (e.g. against a
custom track ordering or a filtered event set) can reuse the
same wire semantics.
This is the first SMF-file accessor that moves beyond the
"iterate every meta event" lens introduced by tempo_map / time_signatures / key_signatures / the eight text-meta
helpers / smpte_offsets. The iteration helpers answer "when
does X fire?"; the snapshot answers "what is the wire state at
T?" — the two are complementary primitives for SMF seeking and
inspection.
15 new dedicated tests: default snapshot when no events,
Program Change at tick 0, the four CC dimensions
(volume / pan / expression / modulation), CC 64 threshold at 63
vs. 64, Bank Select MSB + LSB independence, 14-bit Pitch Bend
decode, tick-filter inclusion of events at exact tick (90 →
100 → 199 → 200 → ∞ walk), running-status Program Change
series replayed, multi-channel independence, notes /
aftertouch leave the snapshot at defaults, unknown CCs
(CC 99 / CC 100) ignored, invalid channel returns default,
bulk channel_snapshots_at returns 16 independent states
consistent with per-channel calls, multi-track merge at same
tick honours track 0 → track 1 stable order (last-writer-wins
on the wire), public SmfChannelSnapshot::apply round-trips
every event-kind.
New smf::SmpteOffsetEvent { tick, track, hours_raw, minutes, seconds, frames, subframes } plus SmfFile::smpte_offsets() -> Vec<SmpteOffsetEvent>. Collects every SMPTE Offset meta event
(FF 54 05 hr mn se fr ff, the wall-clock cue declaring when a
track's first event is meant to fire) from every track, pins each
one to the absolute tick of its parent track via cumulative TrackEvent::delta sums, then merges the per-track sequences
with a stable sort by tick — track 0 wins over track 1 at the
same tick, matching the same merge rule used by the ten existing
text + rhythmic helpers (tempo_map / time_signatures / key_signatures / markers / lyrics / cue_points / track_names / instrument_names / texts / copyrights) and
by scheduler.rs §"merged event list, sorted by absolute tick".
New smf::FrameRate enum (Fps24 / Fps25 / Fps30DropFrame
/ Fps30NonDrop) decoded from the packed hr byte per the
MIDI Time Code spec, RP-004/008 §"HOURS COUNT": bits 5-6 hold
the rate type (00=24fps, 01=25fps, 10=30fps drop-frame, 11=30fps non-drop); bits 0-4 hold the hours count; bit 7 is
reserved. FrameRate::from_hours_byte(hr) exposes the raw
decode; frames_per_second() returns the nominal counter rate
(drop-frame still numbers 30 frames per wall-second); is_drop_frame() distinguishes the two 30-Hz variants.
SmpteOffsetEvent::frame_rate() / hours_count() / seconds_total() surface the SMPTE-cueing semantics without
forcing callers to re-mask the hr byte. seconds_total()
returns the wall-clock offset as h*3600 + m*60 + s + (frames + subframes/100) / fps — drop-frame uses the same 30 Hz divisor
since the counter compensates for the 29.97 Hz playback, so the
helper stays rate-independent across the four MTC types (callers
needing strict NTSC-accurate timing should re-derive from the
tempo map).
Out-of-range values (hours > 23, minutes > 59, seconds > 59,
frames above the rate's nominal count, subframes > 99) are
preserved as-is rather than clamped — the helper surfaces the
raw counter so callers can inspect malformed files.
Only FF 54 is selected; the rhythmic / text meta events stay
uncontaminated (asserted by a dedicated cross-kind filter test).
Lifts the SMF meta-event iterator family from 10 to 11 total
(SmfFile::{tempo_map,time_signatures,key_signatures,markers, lyrics,cue_points,track_names,instrument_names,texts,copyrights, smpte_offsets}), covering every rhythmic + text + SMPTE-cueing
meta event the spec defines a per-event "when it fires" lens for.
10 new dedicated tests: empty, single event with 24 fps decode,
all-four-frame-rates decode at hours=1, multi-track merge sorted
by tick, stable sort track-0-before-track-1 at same tick,
filter excludes other rhythmic + text meta kinds (with
sibling-helper uncontamination check), seconds_total() at 24
fps with non-zero sub-frames, seconds_total() at 30-non-drop
origin, absolute-tick accounting through running-status channel
events, and FrameRate::from_hours_byte() bit-mask coverage
(bit 7 reserved, bits 5-6 select the rate, bits 0-4 don't leak
into the rate decode).
New smf::TextEvent { tick, track, text } plus SmfFile::texts() -> Vec<TextEvent>. Collects every free-form /
general text meta event (FF 01 len text, the catch-all annotation
kind used for production notes, "do not edit", version stamps,
mix-engineer comments, …) from every track, pins each one to the
absolute tick of its parent track via cumulative TrackEvent::delta sums, then merges the per-track sequences with
a stable sort by tick — track 0 wins over track 1 at the same
tick, matching the same merge rule used by the seven existing
text-meta helpers (markers / lyrics / cue_points / track_names / instrument_names) and the rhythmic helpers
(tempo_map / time_signatures / key_signatures) and by scheduler.rs §"merged event list, sorted by absolute tick".
New smf::CopyrightEvent { tick, track, text } plus SmfFile::copyrights() -> Vec<CopyrightEvent>. Collects every
copyright-notice meta event (FF 02 len text) from every track
under the same merge rule. The SMF specification recommends
placing the notice on the first track at tick 0 so players can
surface authorship without scanning the whole file; this helper
surfaces every occurrence in time order regardless, so callers
that only want the first notice can take .next() on the
iterator while callers that want the full history can read the
whole Vec.
Only FF 01 / FF 02 are selected by their respective helpers.
The five sibling text-kind meta events (FF 03 track name, FF 04 instrument name, FF 05 lyric, FF 06 marker, FF 07
cue point) are filtered out so callers populating the relevant
metadata get a clean per-track stream without having to
discriminate themselves. A dedicated round-trip test asserts
that a single track may legally carry both FF 01 and FF 02
and that the two helpers surface them independently — and the
cross-kind filter tests now assert that all seven text-meta
helpers stay uncontaminated when every text-kind event is
present.
Lifts the SMF text-meta iterator family from 8 to 10
helpers (SmfFile::{tempo_map,time_signatures,key_signatures, markers,lyrics,cue_points,track_names,instrument_names,texts, copyrights}), covering every FF 01..=07 text-flavour meta
event the spec defines.
Same accessor shape as the other eight text-meta helpers: TextEvent::text_bytes() / CopyrightEvent::text_bytes() for
the raw payload (encoding is spec-unspecified — historically
Latin-1, modern files emit UTF-8), text_lossy() for a Cow<str> UTF-8 decode with U+FFFD substitutes for invalid
sequences.
19 new dedicated tests (10 for texts(), 9 for copyrights()):
empty, single event, multiple within one track, multi-track
merge sorted by tick, stable sort track-0-before-track-1 at
same tick, filter excludes other text kinds (with sibling-
helper uncontamination check), absolute-tick accounting
through running-status channel events, text_lossy()
invalid-UTF-8 substitution, and a cross-kind coexistence test
proving FF 01 and FF 02 on the same track surface
independently.
New smf::InstrumentNameEvent { tick, track, text } plus SmfFile::instrument_names() -> Vec<InstrumentNameEvent>. Collects
every instrument-name meta event (FF 04 len text, the per-track
voice / patch label distinct from the FF 03 track-list label) from
every track, pins each one to the absolute tick of its parent track
via cumulative TrackEvent::delta sums, then merges the per-track
sequences with a stable sort by tick — track 0 wins over track 1
at the same tick, matching the same merge rule used by SmfFile::track_names() / cue_points() / markers() / lyrics()
/ tempo_map() / time_signatures() / key_signatures() and by scheduler.rs §"merged event list, sorted by absolute tick".
Only FF 04 is selected. The other text-kind meta events
(FF 01 general text, FF 02 copyright, FF 03 track name, FF 05 lyric, FF 06 marker, FF 07 cue point) are filtered out
so callers populating per-track instrument metadata get a clean
per-track stream without having to discriminate themselves. A
dedicated round-trip test asserts that a single track may legally
carry both FF 03 and FF 04 and that the two helpers surface
them independently.
Lifts the SMF text-meta iterator family from 7 to 8 helpers
(SmfFile::{tempo_map,time_signatures,key_signatures,markers, lyrics,cue_points,track_names,instrument_names}).
Same accessor shape as the other six text-meta helpers: InstrumentNameEvent::text_bytes() for the raw payload (encoding
is spec-unspecified — historically Latin-1, modern files emit
UTF-8), text_lossy() for a Cow<str> UTF-8 decode with U+FFFD
substitutes for invalid sequences.
10 dedicated tests: empty, single event, per-track in format-1,
multiple within one track, multi-track merge sorted by tick, stable
sort track-0-before-track-1 at same tick, filter excludes other
text kinds (including a sanity check that the sibling helpers stay
uncontaminated), absolute-tick accounting through running-status
channel events, coexistence with FF 03 on the same track, and text_lossy() invalid-UTF-8 substitution.