From 29d5c39e45c1e926fefdad83af8482f826e81f75 Mon Sep 17 00:00:00 2001 From: Bret Curtis Date: Mon, 27 Jul 2026 10:04:45 +0200 Subject: [PATCH 1/7] initial work on ma7 --- docs/formats/SmafFileFormat.txt | 251 +++++++++++++++++++------------- src/patches.c | 8 + src/smaf2mid.c | 42 ++++-- test/CMakeLists.txt | 4 + 4 files changed, 193 insertions(+), 112 deletions(-) diff --git a/docs/formats/SmafFileFormat.txt b/docs/formats/SmafFileFormat.txt index a42c3f64..a122fe7a 100644 --- a/docs/formats/SmafFileFormat.txt +++ b/docs/formats/SmafFileFormat.txt @@ -44,10 +44,14 @@ to the GM conversion. The Mobile Standard layout below was verified by decoding a corpus of real .mmf files from the MA-2/MA-3/MA-5/MA-6 generations, cross-checked against the vavi-sound reference decoder (identical note output). The MA-7 "SEQU" format -(format_type 0x03) is a different, compressed encoding that is NOT decoded - -WildMIDI declines it; see its section below for reverse-engineering notes. The -HandyPhone Standard encoding (format_type 0x00) IS decoded (verified against -real MA-1/MA-2 .mmf files); see its section below. +(format_type 0x03) turned out to be the same encoding with a 32-channel status +byte, and is decoded too; see its section below. The HandyPhone Standard +encoding (format_type 0x00) IS decoded (verified against real MA-1/MA-2 .mmf +files); see its section below. + +Note that no other open-source SMAF implementation reads MA-7 correctly, so +there is nothing to cross-check the SEQU section against - see the "Prior art" +note at the end of that section. NOTE: All multi-byte integers are BIG-ENDIAN. All chunks have the form { uint8_t id[4]; uint32_t size; uint8_t data[size]; }. Unknown chunks @@ -118,14 +122,14 @@ The score-track format_type byte selects the sequence encoding: 0x00 HandyPhone Standard (MA-1/MA-2, 4-channel; SUPPORTED, see below) 0x01 Mobile Standard (Huffman-compressed; see note below) 0x02 Mobile Standard (uncompressed; the common MA-2/3/5/6 case) - 0x03 SEQU (the MA-7 format; NOT supported - declined) + 0x03 SEQU (the MA-7 format; SUPPORTED, see below) -WildMIDI converts 0x00 and 0x02. 0x01 is the Huffman-compressed variant of -Mobile Standard; the converter treats it like 0x02 and will only work if the +WildMIDI converts 0x00, 0x02 and 0x03. 0x01 is the Huffman-compressed variant +of Mobile Standard; the converter treats it like 0x02 and will only work if the body is not actually compressed (decompression is not implemented). 0x03 -(SEQU / MA-7) is declined - see the SEQU section below for the (incomplete) -reverse-engineering notes. 0x00 (HandyPhone) uses a different, simpler encoding -(see its section below) and is decoded by decode_handyphone(). +(SEQU / MA-7) is Mobile Standard with a 32-channel status byte and shares the +same decoder. 0x00 (HandyPhone) uses a different, simpler encoding (see its +section below) and is decoded by decode_handyphone(). MA-chip support summary @@ -138,20 +142,46 @@ optional FM engine, the VOICE/instrument form. MA-1 / MA-2 HandyPhone (0x00) decoded, tested on real files MA-2 / 3 / 5 Mobile uncompressed (0x02) decoded, tested on real files (any) Mobile compressed (0x01) only if not actually Huffman'd - MA-7 SEQU (0x03) NOT supported (see SEQU section) + MA-7 SEQU (0x03) decoded, tested on real files FM voice-exclusives (Mtsu; decoded by src/mafm/smaf_voice.c, played by the FM engine when WANT_MAFM is on): - MA-1 / MA-2 VMA form "43 03 ..." decoded, tested + MA-1 / MA-2 VMA form "43 03 ..." decoded, tested MA-3 VM35 packed "43 79 06 7F 01 ..." decoded, UNTESTED MA-5 VM35 direct "43 79 07 ..." / "43 05 01 ..." decoded, UNTESTED MA-3 / MA-5 PCM (sampled) "43 79 .. type!=0" parsed, not yet played + MA-7 ? "43 79 08 7F 21 ..." NOT decoded (see below) Net: MA-1 and MA-2 are fully supported and verified (score + FM voices + ATR ADPCM drums). MA-3 / MA-5 score plays; their FM voice forms are implemented - but not yet exercised (need MA-3/5 sample files). MA-7 is unsupported (its - SEQU score format was never reverse-engineered). + but not yet exercised (need MA-3/5 sample files). MA-7 score plays; its FM + voice form is not decoded, so MA-7 falls back to the GM wavetable. + + MA-7 voice-exclusive (unsolved). MA-7 Mtsu voices look like this: + + 43 79 08 7F 21 | 7c 02 0a 00 | 00 | <24-byte body> | F7 + \-- id ------/ \- key ---/ type + bankMSB bankLSB pc drumNote + + i.e. the same maker id / bank / program / drum-note / voice-type key layout + as the MA-3 and MA-5 forms (which use "43 79 06|07 7F 01"), only with model + byte 0x08 and sub-id 0x21, and a 24-byte body where MA-5's VM35 body is 17 + (3 global + 7 per operator, 2 operators). The extra 7 bytes were not placed: + the four voices in the corpus differ in only 7 byte positions, which is not + enough to separate "4 global + 8 per op + 4 trailing" from "3 global + 8 per + op + 5 trailing", and the two readings disagree about which bytes are MULTI + and DT. Guessing produces a plausible-looking but wrong patch, so + _WM_MAFM_ParseVoiceExclusive() does not accept the form and MA-7 files get no + mafm_synth. + + Also seen in MA-7 Mtsu, undecoded: "43 79 08 7F 22 00" (the end-of-setup + marker, matching MA-5's "43 79 07 7F 07 00") and two 38-byte per-channel + parameter tables "43 79 08 7F 26 ..." and "43 79 08 7F 27 ...". + + To finish this, the reference is the MCP-MA7 player's DLLs (an InstallShield + package; the bundled PDF is only a user manual and carries no format detail), + or an MA-7 file set large enough to vary more of the body. Description - Sequence Data (Mtsq), Mobile Standard (format_type 0x01 / 0x02) @@ -208,99 +238,114 @@ expected is the trailing NOP/End-Of-Sequence padding after the last event (e.g. "ff 00 00 ff 2f 00") and marks the end of useful data. -Description - Sequence Data (Mtsq), SEQU (format_type 0x03, "MA-7") NOT SUPPORTED +Description - Sequence Data (Mtsq), SEQU (format_type 0x03, "MA-7") *********************************************************************** - * STATUS: NOT DECODED. WildMIDI DECLINES format 0x03. * - * This section records what reverse-engineering DID and did NOT * - * establish, so a future attempt can start ahead. Do not trust it * - * as a working spec - it is deliberately incomplete and partly * - * wrong (the parts known to be wrong are marked). * + * STATUS: SUPPORTED. Decoded by decode_mobile(..., sequ=1) in * + * src/smaf2mid.c. Verified byte-exact (parse consumes the whole * + * Mtsq and lands on its End-Of-Sequence) on the MA-7 files in the * + * libsmaf corpus, AB00221GM7.MMF and AB03957GM7.MMF. * *********************************************************************** -MA-7 (YMU786, 2005) is the last SMAF generation. Its score track uses a -distinct sequence encoding that vavi-sound calls "SEQU" (the 4th value of its -FormatType enum: 0=HandyPhone, 1=Mobile-compressed, 2=Mobile-uncompressed, -3=SEQU). The score-track header carries a 32-byte channel-status field (vs 16 -for Mobile), which is the reliable way to recognise it alongside format_type -0x03 and the MTR id trailing byte 0x07. - -Why it is undocumented: SEQU is a COMPRESSED / packed format and no public -byte-level specification exists. Checked and found lacking: the LPC wiki -(Yamaha_SMAF and Yamaha_SMAF/MA-7 pages - chip/phone info only), the ltva1 -MA-7 hardware research page (FM operator internals, not the file format), the -KDDI au ezfactory archive (device catalog), and Yamaha's own references. The -real encoder lives in the proprietary "MA-7 author tool" / "MCP MA-7" DLLs. - -Reference decoders do NOT handle these files correctly either: - - vavi-sound's readSEQU emits hundreds of "gateTime == 0" warnings and, on a - known 914-note song, produced only 94 notes over 46 s (the true song is - ~37 s with 914 notes). Its readSEQU note formula is - note = (e1 & 15) + ((e1>>4 & 3) + 3) * 12 with NO velocity byte - which does - NOT reproduce the reference output. - - akustikrausch's C++ player routes 0x03 through its Mobile decoder, whose - "if (status < 0x80) continue;" silently drops the SEQU note bytes. - -What reverse-engineering (against a matched .mmf / .mid / .mp3 triplet where the -.mid is the authoring tool's own output - the oracle) DID establish: - - Container / framing (solid): - - Records are still [duration VLQ][event], duration-first, same VLQ as - Mobile Standard. Timebase math is identical (durationTimeBase and - gateTimeBase from the header; 0x02 = 4 ms/tick). - - Leading 0xff 0x00 (NOP) and 0xf0 ... 0xf7 (exclusive) parse normally. - - The song's whole SETUP block (first ~105 events: per-channel control - changes, then a per-channel setup byte, then the first melody notes) - parses with clean byte boundaries under the tentative status scheme below, - and the first melody NOTE PITCHES AND VELOCITIES matched the reference - exactly (e.g. ch0 note 29 vel 105; the ch2/ch3 four-note chords; the ch1 - line 69 -> 72 -> 74). So the note-event field order below is close for - the "sparse" sections. - - Tentative status scheme (works for the sparse intro, WRONG in dense sections): - status byte = (type_nibble << 4) | channel_nibble, channels 0-9 observed - 0x0n note, reuse running velocity : [note][gate VLQ] - 0x1n note, set velocity : [note][vel][gate VLQ] - 0x3n control change : [cc][val] (cc = MIDI controller: - 00 bankMSB, 20 bankLSB, 07 vol, 0a pan, 5a/5b/5d fx, ...) - 0x4n per-channel setup : [value] (see "programs" below) - - KNOWN-WRONG / UNSOLVED (why 0x03 is declined): - 1. Program numbers do NOT match. Decoding 0x4n as MIDI program change gives - ch0=1 where the reference has ch0=38 (no constant offset; e.g. ref - {0:38,1:54,2:5,3:48,4:55,5:81,6:11,7:11,8:81}). Notably our decoded - values pair up the same way the reference does (ch6==ch7, ch5==ch8), - suggesting 0x4n carries a SMAF voice/bank index that must be resolved - through the Mtsu setup (voice) chunk, not a GM program - i.e. not - directly convertible without modelling the voice bank. - 2. The dominant dense-section record is a 4-byte group led by 0x61 - (0x61 appears ~520 times in one 7940-byte Mtsq; 0x02 ~507 times): - 61 XX YY 0Z with 0Z small (00-04) - Its field layout was NOT determined. Splitting it as - [status][d1][d2][dur] and treating d2 as the note yields a smooth-looking - line (e.g. 65,67,69,70,69,66,65,61,52,...) that nevertheless matches NO - reference channel by absolute pitch OR by interval - so that split is - wrong. Whatever 0x61 encodes, it is the main melodic/rhythmic payload - of the busy sections and must be decoded for a usable result. - 3. Drum channel (ch9) notes decode to wrong pitches (e.g. 20, 17 where the - reference has 38, 39) - likely a drum note-map and/or a different record - layout on the rhythm channel. - - Net effect if force-decoded with the tentative scheme: ~1000+ notes over - ~375 s for a song that is really ~914 notes over ~37 s - i.e. far too many - notes stretched ~8x too long, which sounds like large gaps between notes. - Hence: declined, not shipped. - - Short expression / modulation tables that appear in vavi's readSEQU control - path (kept here for reference; part of the 0x00-led control events vavi - documents but which did not reproduce this file): - expr: 00 00 1f 27 2f 37 3f 47 4f 57 5f 67 6f 77 7f 7f - mod: 00 00 08 10 18 20 28 30 38 40 48 50 60 70 7f 7f - - For a future attempt: the matched .mmf/.mid oracle approach is the right one - - align the byte stream to the authoring tool's .mid note list and solve the - 0x61 record and the 0x4n->voice mapping first, ideally with more than one - MA-7 sample to cross-check. +SEQU is NOT a compressed or otherwise novel encoding - earlier notes here were +wrong about that. It is Mobile Standard with ONE change: the status byte is +repacked so it can address 32 channels instead of 16. + +MA-7 (YMU786, 2005) doubles the channel count, so the status byte can no longer +spend its top bit on the MIDI "this is a status byte" flag. Instead: + + bit 7 : channel bank 0 -> channels 0-15, 1 -> channels 16-31 + bits 6-4 : event type the low 3 bits of the MIDI status nibble, + i.e. type n means MIDI status 0x8+n + bits 3-0 : channel, low nibble + +Everything else - the [duration VLQ][event] record framing, the VLQ, the +timebase math, the note gate-times, the payload of each event type - is exactly +Mobile Standard. So the status byte maps straight onto MIDI: + + SEQU MIDI event + ------ ------ ----------------------------------------------------- + 0x0n 0x8n Note, reuse running velocity : [note][gate VLQ] + 0x1n 0x9n Note, set velocity : [note][vel][gate VLQ] + 0x2n 0xAn reserved - 2 data bytes, ignore + 0x3n 0xBn Control Change : [cc][val] + 0x4n 0xCn Program Change : [pc] + 0x5n 0xDn reserved - 1 data byte, ignore + 0x6n 0xEn Pitch Bend : [lsb][msb] + 0x7n - not used + 0xF0 Exclusive - literal, as in Mobile Standard + 0xFF Meta - literal, as in Mobile Standard + +0xF0 and 0xFF keep their literal Mobile meaning and are recognised before the +bit-7 channel-bank rule (a "type 7" status would otherwise collide with them). + +There is NO running status in SEQU. It cannot exist: ordinary status bytes are +below 0x80, so a status byte and a data byte are not distinguishable by their +high bit. Every event carries its own status byte, and the parse only closes +if that is true - which it is, on every sample file. + +The bit-7 rule is what the old notes had missed, and it is directly visible in +any MA-7 file: the opening per-channel control-change sweep runs +0x30,0x31,...,0x3f and then continues 0xb0,0xb1,...,0xbf - one CC for each of +the 32 channels, the second sixteen carrying bit 7. + +It also explains the two records the old notes could not crack: + - the "mystery 4-byte 0x61 XX YY 0Z group", ~520 of them in one file, is just + pitch bend on channel 1 ([status][lsb][msb]) followed by the next record's + duration byte. It is dense because MA-7 content uses pitch bend for + vibrato and for detuning doubled voices. + - the frequent "0x02" is a note-with-running-velocity on channel 2. + +Cross-checks that the decode is right, beyond the byte-exact parse: + - AB03957GM7's decoded notes use exactly 8 pitch classes, {C#,D,D#,E,F#,G#, + A,B} - E major plus one passing tone. A misaligned field or a wrong + bit-packing smears the pitch-class histogram; a clean diatonic set does not + happen by accident. + - Both files decode to 8 active channels (0-3 and 10-13), with ch10-13 a + unison/octave doubling of ch0-3, and the only pitch bends in the corpus sit + on exactly those doubled channels at slightly sharp/flat values (msb 0x41 / + 0x3e). That is a detuned chorus layer, which is what MA-7's extra channels + were for. + - Program changes land on Mtsu voice numbers: the score selects programs + 10-13 and the Mtsu setup chunk defines voices 0x0a-0x0d. (The old note + that "program numbers do not match" was comparing them to GM programs; + they are voice-bank indices, exactly as in MA-3/MA-5.) + - Lengths come out at 44.5 s and 41.4 s for 601 and 568 notes. + +Channels 16-31: MIDI has 16, so the converter folds them (ch & 0x0f). Every +MA-7 file seen so far leaves 16-31 at their setup defaults and plays only on +0-15, so the fold does not collide in practice. Carrying the upper bank +faithfully would need a second MIDI port, which the converter cannot express. + +OPEN QUESTION - octave offset. The decoded note values are raw linear semitone +numbers (confirmed: note bytes appear with low nibbles 0xC-0xF, which rules out +the HandyPhone-style octave|semitone nibble packing, and the pitch-class +histogram is diatonic). What is NOT confirmed is whether MA-7 applies a +constant transpose on top. The two sample songs sit at notes 64-99, roughly an +octave above the note distribution of the Mobile-format corpus (which centres on +60-83), but part of that is the deliberate +12 doubling layer, and two songs is +not a sample. WildMIDI passes the note through unchanged, as Mobile Standard +does. If more MA-7 files turn up and they too sit an octave high, revisit this. + +PRIOR ART - none of it reads MA-7. Checked while writing this section: + - umjammer/vavi-sound (Java) has a readSEQU(), and but80/smaf825 (Go) has a + CreateEventSEQU(). They are the same routine, and it is NOT a SEQU + decoder: it is the HandyPhone reader (4 channels via e1>>6, octave|semitone + note packing, 0x00-led 6-bit control events, no velocity byte). Run on a + real format_type 0x03 file it emits garbage. smaf825 at least knows this - + it defines ScoreTrackFormatType_SEQU = -1 and its IsSupported() returns + false, so a real MA-7 file never reaches the routine at all. + - akustikrausch/yamaha-smaf-player (C++) sets "mobile = (formatType != 0x00)" + and sends 0x03 through its Mobile decoder, whose "if (status < 0x80) + continue" drops every SEQU event, since SEQU status bytes are below 0x80. + - antanas-vasiliauskas/smaf-converter wraps vavi-sound and inherits its + behaviour. + - ltva1's MA-7 hardware page is chip research, not a file-format spec, and is + explicitly unfinished on the data layout. + - Yamaha's own MCP-MA7 player is a Windows binary; the PDF that ships with it + is a user manual with no format detail. +So the decode above was derived from the files themselves, not from a +reference, and the corroboration for it is the evidence listed above. Description - Sequence Data (Mtsq), HandyPhone Standard (format_type 0x00) diff --git a/src/patches.c b/src/patches.c index cb30caac..90aeabd5 100644 --- a/src/patches.c +++ b/src/patches.c @@ -81,6 +81,14 @@ _WM_get_patch_data(struct _mdi *mdi, uint16_t patchid) { _WM_Lock(&_WM_patch_lock); search_patch = _find_nearest_patch(patchid); + if (search_patch == NULL && (patchid & 0xff00) != 0) { + /* Nothing at all in the requested bank: fall back to bank 0 rather + * than play silence, as a hardware synth does for an unknown bank. + * SMAF needs this - its scores select Yamaha's own voice banks (0x7c + * and friends), which no GUS/SF2 patch set defines, so without the + * fallback every SMAF file that has no custom FM voices is mute. */ + search_patch = _find_nearest_patch(patchid & 0x00ff); + } _WM_Unlock(&_WM_patch_lock); return (search_patch); } diff --git a/src/smaf2mid.c b/src/smaf2mid.c index 397218ad..d34a3bb4 100644 --- a/src/smaf2mid.c +++ b/src/smaf2mid.c @@ -316,9 +316,25 @@ static int find_sequence(const uint8_t *in, uint32_t insize, /* ------------------------------------------------------------------------- */ /* Decode a Mobile Standard (format 0x01/0x02) sequence into MIDI events. + * + * With sequ != 0, decode the MA-7 "SEQU" variant (format 0x03) instead. SEQU + * is the same record stream; only the status byte is packed differently, to + * address 32 channels instead of 16: + * + * bit 7 : channel bank (0 -> channels 0-15, 1 -> channels 16-31) + * bits 6-4 : event type (the low 3 bits of the MIDI status nibble, + * i.e. type n means MIDI status 0x8+n) + * bits 3-0 : channel, low nibble + * + * so 0x1n is a note-with-velocity (MIDI 0x9n), 0x3n a control change (0xBn), + * 0x4n a program change (0xCn), 0x6n a pitch bend (0xEn), and so on. 0xF0 and + * 0xFF keep their literal Mobile meaning. Because ordinary status bytes are + * below 0x80 there is no running status in SEQU: every event carries one. + * * Returns 0 on success, -1 on allocation failure. */ static int decode_mobile(struct smaf_ctx *ctx, const uint8_t *seq, - uint32_t seqlen, uint32_t ms_dur, uint32_t ms_gate) { + uint32_t seqlen, uint32_t ms_dur, uint32_t ms_gate, + int sequ) { uint8_t run_vel[MIDI_MAXCHANNELS]; uint8_t run_status = 0; uint32_t p = 0, cur_ms = 0; @@ -347,7 +363,17 @@ static int decode_mobile(struct smaf_ctx *ctx, const uint8_t *seq, flush_offs(ctx, cur_ms); s = seq[p]; - if (s & 0x80) { + if (sequ) { + p++; + if (s != 0xf0 && s != 0xff) { + /* ponytail: fold the 32 SEQU channels onto MIDI's 16. Every + * MA-7 file seen so far plays only on channels 0-15 and leaves + * 16-31 at their setup defaults, so the fold never collides; + * carrying the upper bank properly would need a second MIDI + * port, which the converter has no way to express. */ + s = (uint8_t)(0x80 | (((s >> 4) & 7) << 4) | (s & 0x0f)); + } + } else if (s & 0x80) { p++; run_status = s; } else if (run_status) { @@ -811,12 +837,9 @@ int _WM_smaf2midi(const uint8_t *in, uint32_t insize, return -1; } - /* Supported score-track formats: Mobile Standard (0x01/0x02) and HandyPhone - * Standard (0x00). The MA-7 "SEQU" variant (0x03) is a different, - * compressed encoding that has not been decoded (see - * docs/formats/SmafFileFormat.txt); decline it rather than emit a mangled - * conversion. */ - if (fmt != 0x00 && fmt != 0x01 && fmt != 0x02) { + /* Supported score-track formats: Mobile Standard (0x01/0x02), HandyPhone + * Standard (0x00) and MA-7 SEQU (0x03). */ + if (fmt > 0x03) { _WM_GLOBAL_ERROR(WM_ERR_NOT_SMAF, "(unsupported SMAF track format)", 0); return -1; } @@ -873,7 +896,8 @@ int _WM_smaf2midi(const uint8_t *in, uint32_t insize, hp_emit(&ctx, &hev); free(hev.ev); } else { - if (decode_mobile(&ctx, seq, seqlen, ms_dur, ms_gate) < 0) { + if (decode_mobile(&ctx, seq, seqlen, ms_dur, ms_gate, + fmt == 0x03) < 0) { _WM_GLOBAL_ERROR(WM_ERR_MEM, NULL, 0); goto _end; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index f128a4c9..07b4a0b2 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1,3 +1,7 @@ ADD_EXECUTABLE(test_tokenize test_tokenize.c) TARGET_LINK_LIBRARIES(test_tokenize libwildmidi-static ${M_LIBRARY}) ADD_TEST(NAME tokenize COMMAND test_tokenize) + +ADD_EXECUTABLE(test_smaf_sequ test_smaf_sequ.c) +TARGET_LINK_LIBRARIES(test_smaf_sequ libwildmidi-static ${M_LIBRARY}) +ADD_TEST(NAME smaf_sequ COMMAND test_smaf_sequ) From 62609efd119e94cbca66dd7f788f0cbda7e88e2f Mon Sep 17 00:00:00 2001 From: Bret Curtis Date: Mon, 27 Jul 2026 10:04:57 +0200 Subject: [PATCH 2/7] add test --- test/test_smaf_sequ.c | 103 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 test/test_smaf_sequ.c diff --git a/test/test_smaf_sequ.c b/test/test_smaf_sequ.c new file mode 100644 index 00000000..4b7e7247 --- /dev/null +++ b/test/test_smaf_sequ.c @@ -0,0 +1,103 @@ +/* assert-based smoke test for the MA-7 "SEQU" (score format_type 0x03) decode + * in smaf2mid.c. SEQU is Mobile Standard with the status byte repacked to + * address 32 channels: bit 7 is the channel bank, bits 6-4 are the event type + * (MIDI status nibble 0x8+n), bits 3-0 are the low channel nibble. + * See docs/formats/SmafFileFormat.txt. */ +#include +#include +#include +#include + +extern int _WM_smaf2midi(const uint8_t *in, uint32_t insize, + uint8_t **out, uint32_t *outsize); + +/* The Mtsq event stream under test. Each record is [duration VLQ][event]. */ +static const uint8_t seq[] = { + 0x00, 0x30, 0x07, 0x64, /* ch0 CC volume 100 -> b0 07 64 */ + 0x00, 0xb1, 0x07, 0x50, /* ch17 CC volume 80 (bit 7) -> b1 07 50 */ + 0x00, 0x40, 0x05, /* ch0 program 5 -> c0 05 */ + 0x00, 0x60, 0x00, 0x50, /* ch0 pitch bend 0,0x50 -> e0 00 50 */ + 0x00, 0x11, 0x3c, 0x64, 0x0a, /* ch1 note 60 vel 100 gate 10 */ + 0x14, 0x01, 0x3e, 0x0a, /* ch1 note 62, running velocity 100 */ + 0x00, 0xff, 0x2f, 0x00 /* end of sequence */ +}; + +/* Build the smallest MMMD container that carries one MA-7 score track. */ +static uint8_t *build_mmf(uint32_t *size_out) { + uint32_t mtsq = 8 + (uint32_t)sizeof(seq); /* "Mtsq" + size + body */ + uint32_t mtr = 36 + mtsq; /* 4 fixed + 32 chstat + sub */ + uint32_t total = 8 + 8 + mtr; /* MMMD hdr + MTR hdr + body */ + uint8_t *b = (uint8_t *) calloc(total, 1); + uint32_t p = 0; + assert(b != NULL); + + memcpy(b + p, "MMMD", 4); p += 4; + b[p++] = 0; b[p++] = 0; + b[p++] = (uint8_t)((total - 8) >> 8); b[p++] = (uint8_t)(total - 8); + + memcpy(b + p, "MTR", 3); p += 3; + b[p++] = 0x07; /* MA-7 score track id byte */ + b[p++] = 0; b[p++] = 0; + b[p++] = (uint8_t)(mtr >> 8); b[p++] = (uint8_t)mtr; + + b[p++] = 0x03; /* format_type: SEQU */ + b[p++] = 0x00; /* sequence_type */ + b[p++] = 0x02; /* timebase_dur: 4 ms/tick */ + b[p++] = 0x02; /* timebase_gate: 4 ms/tick */ + p += 32; /* channel_status, all zero */ + + memcpy(b + p, "Mtsq", 4); p += 4; + b[p++] = 0; b[p++] = 0; + b[p++] = (uint8_t)(sizeof(seq) >> 8); b[p++] = (uint8_t)sizeof(seq); + memcpy(b + p, seq, sizeof(seq)); p += (uint32_t)sizeof(seq); + + assert(p == total); + *size_out = total; + return b; +} + +/* Does the emitted MIDI contain this exact byte run anywhere? */ +static int contains(const uint8_t *hay, uint32_t n, + const uint8_t *needle, uint32_t m) { + uint32_t i; + if (m > n) return 0; + for (i = 0; i + m <= n; i++) + if (memcmp(hay + i, needle, m) == 0) return 1; + return 0; +} + +#define HAS(...) do { \ + static const uint8_t want[] = { __VA_ARGS__ }; \ + assert(contains(mid, midsize, want, (uint32_t)sizeof(want))); \ +} while (0) + +int main(void) { + uint8_t *mmf, *mid = NULL; + uint32_t mmfsize = 0, midsize = 0; + + mmf = build_mmf(&mmfsize); + assert(_WM_smaf2midi(mmf, mmfsize, &mid, &midsize) == 0); + assert(mid != NULL && midsize > 22); + assert(memcmp(mid, "MThd", 4) == 0); + + /* 0x3n -> control change, and 0xb1 proves bit 7 is the channel bank and + * not a MIDI status flag: channel 17 folds onto MIDI channel 1. */ + HAS(0xb0, 0x07, 0x64); + HAS(0xb1, 0x07, 0x50); + + HAS(0xc0, 0x05); /* 0x4n -> program change */ + HAS(0xe0, 0x00, 0x50); /* 0x6n -> pitch bend */ + + /* 0x1n carries an explicit velocity; 0x0n reuses the channel's running + * velocity, so note 62 must come out at 100 as well. */ + HAS(0x91, 0x3c, 0x64); + HAS(0x91, 0x3e, 0x64); + + /* Notes carry their own gate time, so each one must get a note-off. */ + HAS(0x81, 0x3c, 0x40); + HAS(0x81, 0x3e, 0x40); + + free(mid); + free(mmf); + return 0; +} From baa24bb9d3c0681849f7096469af7346d66ee347 Mon Sep 17 00:00:00 2001 From: Bret Curtis Date: Mon, 27 Jul 2026 10:39:12 +0200 Subject: [PATCH 3/7] update smaf file format --- docs/formats/SmafFileFormat.txt | 96 ++++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 7 deletions(-) diff --git a/docs/formats/SmafFileFormat.txt b/docs/formats/SmafFileFormat.txt index a122fe7a..4bd719af 100644 --- a/docs/formats/SmafFileFormat.txt +++ b/docs/formats/SmafFileFormat.txt @@ -73,6 +73,17 @@ Description - Container // "ATR?" AudioTrack (embedded PCM/ADPCM; e.g. "AspI"/"Atsq"/"Awa\x01") // "MSTR" master track (seen in some MA-3 files) + // Yamaha's own MCP-MA7 middleware (M7_EmuSmw7.dll) maps chunk ids to + // these internal type codes; this is the authoritative list, and it names + // three chunks the sample corpus does not contain (Mthv/Mhvs/Mhsc, the + // "HV" speech-synthesis track): + // MMMD 0x00 CNTI 0x01 OPDA 0x02 + // MspI 0x30 Mtsu 0x31 Mtsq 0x32 Mtsp 0x33 Mthv 0x35 + // Mhvs 0x36 Mhsc 0x38 + // AspI 0x40 Atsu 0x41 Atsq 0x42 + // Its score-track walker keeps MspI/Mtsu/Mtsq/Mtsp/Mthv and treats only + // Mtsq as mandatory - a track without one is rejected. + uint16_t crc; // last 2 bytes of file, CRC-16 over the file } @@ -147,16 +158,53 @@ optional FM engine, the VOICE/instrument form. FM voice-exclusives (Mtsu; decoded by src/mafm/smaf_voice.c, played by the FM engine when WANT_MAFM is on): MA-1 / MA-2 VMA form "43 03 ..." decoded, tested - MA-3 VM35 packed "43 79 06 7F 01 ..." decoded, UNTESTED + MA-3 VM35 packed "43 79 06 7F 01 ..." decoded, layout + confirmed vs Yamaha MA-5 VM35 direct "43 79 07 ..." / - "43 05 01 ..." decoded, UNTESTED + "43 05 01 ..." decoded, layout + confirmed vs Yamaha MA-3 / MA-5 PCM (sampled) "43 79 .. type!=0" parsed, not yet played MA-7 ? "43 79 08 7F 21 ..." NOT decoded (see below) Net: MA-1 and MA-2 are fully supported and verified (score + FM voices + ATR ADPCM drums). MA-3 / MA-5 score plays; their FM voice forms are implemented - but not yet exercised (need MA-3/5 sample files). MA-7 score plays; its FM - voice form is not decoded, so MA-7 falls back to the GM wavetable. + and the byte layout now matches Yamaha's own decoder (see below), though no + MA-3/5 file has been listened to end to end. MA-7 score plays; its FM voice + form is not decoded, so MA-7 falls back to the GM wavetable. + + VM35 layout confirmed against Yamaha's MCP-MA7 player + ---------------------------------------------------- + The MA-7 player ships a middleware DLL (M7_EmuSmw7.dll) whose voice-format + converter takes a source voice and emits a chip register block. Read with + Ghidra, it confirms - independently of the sample corpus - the VM35 body + layout that src/mafm/smaf_voice.c implements: + + - 3 global bytes, then 7 bytes per operator. The converter reads operator + i's last byte at offsets 0x0b, 0x12, 0x19, 0x20 from its record base: + a stride of exactly 7, four operators maximum. + - The algorithm is the low 3 bits of global byte 2 (it tests + "in_EAX[4] & 7", where in_EAX[4] is that byte). + - Operator count comes from the algorithm: alg <= 1 means 2 operators, + otherwise 4. The 2-operator path explicitly bails out if the algorithm + is > 1, which is the same rule as op_count_from_alg(). + - Operator byte 6 is the wave selector via ">> 3" (it indexes a wave table + with "op[6] >> 3"), matching o->wave = (op[6] >> 3) & 0x1f and + o->fb = op[6] & 0x07. + - Its two record sizes differ by exactly 14 bytes (20 vs 34), i.e. two + more 7-byte operators over a common 6-byte prefix. + + Two other details in the same DLL confirm parts of this document that had + been derived from sample files alone: + + - The HandyPhone Mtsu voice scan looks for "FF F0 43 03", which is + the "43 03" VMA form decoded by _WM_MAFM_ParseVoiceExclusive(). + - The HandyPhone duration VLQ really is the odd 1-or-2 byte form + documented below: the DLL computes ((b & 0x7F) + 1) * 0x80 + next for + the 2-byte case, byte for byte what decode_handyphone() does. + + The same DLL also yields the authoritative chunk-id table (see the container + section); it recognises Mthv / Mhvs / Mhsc, which the sample corpus does not + contain. MA-7 voice-exclusive (unsolved). MA-7 Mtsu voices look like this: @@ -179,9 +227,43 @@ optional FM engine, the VOICE/instrument form. marker, matching MA-5's "43 79 07 7F 07 00") and two 38-byte per-channel parameter tables "43 79 08 7F 26 ..." and "43 79 08 7F 27 ...". - To finish this, the reference is the MCP-MA7 player's DLLs (an InstallShield - package; the bundled PDF is only a user manual and carries no format detail), - or an MA-7 file set large enough to vary more of the body. + Reverse-engineering Yamaha's MCP-MA7 player (Ver.1.6.2) did NOT close this. + What was tried, so the next attempt does not repeat it: + + - The InstallShield package unpacks with "unshield x data1.cab" and yields + M7_EmuSmw7.dll (the SMAF middleware + synth, 9.8 MB), M7_EmuHw.dll (the + YMU786 register-level emulator) and SMAFMMS7EMU.dll (the SmafMms_* API + the GUI calls). All are stripped 32-bit PE, no debug strings. + - M7_EmuSmw7.dll contains SEVEN near-identical copies of the middleware + (functions repeat at roughly 0x6f000 intervals). They are the same + build, not one per MA generation - every copy checks the same model + byte - so only one copy needs reading. + - Its voice-format converter (FUN_100345f0 in the first copy) confirmed + the VM35 layout above, but the byte layout it consumes is reached with + an internal format tag, not the file's sub-id: its "0x21" tag means + "VM35, 4 operators" and is unrelated to the 0x21 in "43 79 08 7F 21". + - The MA-7 Mtsu exclusive dispatch itself was NOT found. The DLL contains + no "43 79" maker-id template in .rdata, no 16-bit 0x4379/0x7943 compare, + and no function holding both 0x43 and 0x79 as immediates on a voice + path (the 0x43/0x79 immediates that do exist are MIDI CC 67/121 + handlers, plus one Standard-MIDI-File path that recognises the MA-3 + exclusive "43 79 06 7C 02"). Nothing statically imports M7_EmuHw.dll or + M7_EmuSmw7.dll either - they are LoadLibrary'd - so the call graph + dead-ends at indirect dispatch and cannot be walked from the exports. + + The two approaches most likely to finish it: + 1. Dynamic, and probably decisive: the chip emulator exports + Hw_WriteReg(addr, data). Run the player (Wine, or emulate the DLL with + Unicorn) and log the register writes while it loads AB00221GM7.MMF, + then diff those against the writes for an MA-5 file whose voice layout + is already known. That maps body bytes to register fields directly. + 2. Static, cheaper to start: SMAFMMS7EMU.dll is only 614 KB and is the + layer that reads the .mmf and hands setup data to the middleware. The + Mtsu -> "write exclusive" hand-off most likely lives there rather than + in the 9.8 MB DLL. + + Failing that, an MA-7 file set large enough to vary more of the body would + also settle it: the whole corpus has only four distinct voices. Description - Sequence Data (Mtsq), Mobile Standard (format_type 0x01 / 0x02) From a8d1cf225bf91072c29a7971598264d2cc628f73 Mon Sep 17 00:00:00 2001 From: Bret Curtis Date: Mon, 27 Jul 2026 11:23:53 +0200 Subject: [PATCH 4/7] Decode MA-7 FM voices (43 79 08 7F 21) MA-7 stores operators in the chip's native 10-byte layout rather than the packed 7-byte VM35 one: MULTI/DT moves to byte 9, WS/FB to byte 6, and bytes 5/7/8 are reserved. Undoing that shuffle reuses the existing VM35 decoder, so MA-7 files now play on the FM engine instead of falling back to the GM wavetable. --- docs/formats/SmafFileFormat.txt | 163 +++++++++++++++++++++----------- src/mafm/smaf_voice.c | 49 ++++++++++ test/CMakeLists.txt | 7 ++ test/test_ma7_voice.c | 66 +++++++++++++ 4 files changed, 228 insertions(+), 57 deletions(-) create mode 100644 test/test_ma7_voice.c diff --git a/docs/formats/SmafFileFormat.txt b/docs/formats/SmafFileFormat.txt index 4bd719af..370d7cf6 100644 --- a/docs/formats/SmafFileFormat.txt +++ b/docs/formats/SmafFileFormat.txt @@ -164,13 +164,15 @@ optional FM engine, the VOICE/instrument form. "43 05 01 ..." decoded, layout confirmed vs Yamaha MA-3 / MA-5 PCM (sampled) "43 79 .. type!=0" parsed, not yet played - MA-7 ? "43 79 08 7F 21 ..." NOT decoded (see below) + MA-7 native ops "43 79 08 7F 21 ..." decoded, layout + confirmed vs Yamaha Net: MA-1 and MA-2 are fully supported and verified (score + FM voices + ATR ADPCM drums). MA-3 / MA-5 score plays; their FM voice forms are implemented and the byte layout now matches Yamaha's own decoder (see below), though no - MA-3/5 file has been listened to end to end. MA-7 score plays; its FM voice - form is not decoded, so MA-7 falls back to the GM wavetable. + MA-3/5 file has been listened to end to end. MA-7 score plays and its FM + voices are decoded too, so MA-7 files now play on the FM engine rather than + falling back to the GM wavetable. VM35 layout confirmed against Yamaha's MCP-MA7 player ---------------------------------------------------- @@ -193,6 +195,29 @@ optional FM engine, the VOICE/instrument form. - Its two record sizes differ by exactly 14 bytes (20 vs 34), i.e. two more 7-byte operators over a common 6-byte prefix. + The layout was then confirmed a second way, by EMULATING the converter. + Yamaha's DLL is 32-bit x86, so it runs under Unicorn on any host (no Wine + needed). Map the PE at its image base, point EAX at a voice record, push + (ctx, format_class, 0, record_len), and let it run; the converter fills a + chip register block which can then be read back. Feeding it the MA-5 voice + from ab02283gm5.mmf reproduces that voice's own bytes in the output block, + and the input offsets that make it do so are exactly the ones + smaf_voice.c uses: + + record base = p+7 (p = the "43 79 07 7F 01 ..." exclusive) + in_EAX[0] program change (= p[7]) + in_EAX[1] drum note (= p[8]) + in_EAX[2] voice type (= p[9]) + in_EAX[3..5] the 3 global bytes (= p[10..12], alg = p[12] & 7) + in_EAX[6..] operators, 7 bytes each (= p[13..]) + + record length 0x13 = 19 = 6 header + 7*2, i.e. a 2-operator voice. + + That is byte-for-byte what apply_vm35_body() does with body = p + 10. The + converter's output is not a copy of the body but a 22-byte register block + with the operator bytes permuted into chip order, which is why the FM engine + has to interpret the fields rather than blit them. + Two other details in the same DLL confirm parts of this document that had been derived from sample files alone: @@ -206,64 +231,88 @@ optional FM engine, the VOICE/instrument form. section); it recognises Mthv / Mhvs / Mhsc, which the sample corpus does not contain. - MA-7 voice-exclusive (unsolved). MA-7 Mtsu voices look like this: + MA-7 voice-exclusive (SOLVED). MA-7 Mtsu voices look like this: - 43 79 08 7F 21 | 7c 02 0a 00 | 00 | <24-byte body> | F7 - \-- id ------/ \- key ---/ type + 43 79 08 7F 21 | 7c 02 0a 00 | 00 | 00 | <23-byte body> | F7 + \-- id ------/ \- key ---/ type rsv bankMSB bankLSB pc drumNote - i.e. the same maker id / bank / program / drum-note / voice-type key layout - as the MA-3 and MA-5 forms (which use "43 79 06|07 7F 01"), only with model - byte 0x08 and sub-id 0x21, and a 24-byte body where MA-5's VM35 body is 17 - (3 global + 7 per operator, 2 operators). The extra 7 bytes were not placed: - the four voices in the corpus differ in only 7 byte positions, which is not - enough to separate "4 global + 8 per op + 4 trailing" from "3 global + 8 per - op + 5 trailing", and the two readings disagree about which bytes are MULTI - and DT. Guessing produces a plausible-looking but wrong patch, so - _WM_MAFM_ParseVoiceExclusive() does not accept the form and MA-7 files get no - mafm_synth. - - Also seen in MA-7 Mtsu, undecoded: "43 79 08 7F 22 00" (the end-of-setup - marker, matching MA-5's "43 79 07 7F 07 00") and two 38-byte per-channel - parameter tables "43 79 08 7F 26 ..." and "43 79 08 7F 27 ...". - - Reverse-engineering Yamaha's MCP-MA7 player (Ver.1.6.2) did NOT close this. - What was tried, so the next attempt does not repeat it: - + Same maker id / bank / program / drum-note / voice-type key as the MA-3 and + MA-5 forms (which use "43 79 06|07 7F 01"), with model byte 0x08, sub-id + 0x21, and one reserved byte (0 in every corpus voice) before the body. + + The body is 3 global bytes followed by TEN bytes per operator - not the + seven that MA-3/MA-5 use. The globals are unchanged, so the algorithm is + still global[2] & 7 and still picks 2 or 4 operators; a 2-operator MA-7 + voice is therefore 3 + 20 = 23 body bytes, which is exactly what the corpus + files carry. + + The reason for ten is that MA-7 stores the operator block in the CHIP's own + layout instead of the packed VM35 one. Yamaha's middleware converts VM35 -> + native with a fixed shuffle (M7_EmuSmw7.dll, routine at 0x10183980): + + native[0..4] = vm35[0..4] (SR/KSR, RR/DR, AR/SL, TL/KSL, EAM..) + native[5] = 0 + native[6] = vm35[6] (WS / FB) + native[7] = 0 + native[8] = 0 + native[9] = vm35[5] (MULTI / DT) + + Undo that and the existing VM35 decoder handles the rest, which is what + _WM_MAFM_ParseVoiceExclusive() does. + + How it was verified. The mapping is not inferred from the sample bytes - it + was read out of Yamaha's converter and then round-tripped through it under + Unicorn: take each corpus voice's native operator block, apply the inverse + above to get a VM35 operator, feed that back into Yamaha's own routine, and + compare. All 20 operator bytes reproduce exactly, for all four voices. + Two independent checks agree: + - the three bytes the routine zeroes (native 5, 7 and 8) are zero in all + eight operators in the corpus, which will not happen by chance; + - the decoded parameters are textbook 2-op FM. Every carrier comes out + TL=0, MULTI=1, AR=10-13, and the modulators carry the per-instrument + difference (TL 28/36/52/58, MULTI 9/7/9/2, FB=1). Earlier guesses at + this layout produced MULTI=0 on both operators, which no real patch has. + + Still unknown, and harmless: the reserved byte at p[10], and whether MA-7 + ever ships 4-operator or PCM voices - the corpus has only four voices, all + 2-operator FM. The 4-operator path follows from the same 3 + 10*N rule and + is implemented, but is untested for want of a sample. + + Also in MA-7 Mtsu, still undecoded because nothing needs them: + "43 79 08 7F 22 00" (end-of-setup, matching MA-5's "43 79 07 7F 07 00") and + two 38-byte per-channel parameter tables "43 79 08 7F 26 ..." and + "43 79 08 7F 27 ...". + + Notes from the reverse-engineering, for anyone extending this + ---------------------------------------------------------- - The InstallShield package unpacks with "unshield x data1.cab" and yields - M7_EmuSmw7.dll (the SMAF middleware + synth, 9.8 MB), M7_EmuHw.dll (the - YMU786 register-level emulator) and SMAFMMS7EMU.dll (the SmafMms_* API - the GUI calls). All are stripped 32-bit PE, no debug strings. - - M7_EmuSmw7.dll contains SEVEN near-identical copies of the middleware - (functions repeat at roughly 0x6f000 intervals). They are the same - build, not one per MA generation - every copy checks the same model - byte - so only one copy needs reading. - - Its voice-format converter (FUN_100345f0 in the first copy) confirmed - the VM35 layout above, but the byte layout it consumes is reached with - an internal format tag, not the file's sub-id: its "0x21" tag means - "VM35, 4 operators" and is unrelated to the 0x21 in "43 79 08 7F 21". - - The MA-7 Mtsu exclusive dispatch itself was NOT found. The DLL contains - no "43 79" maker-id template in .rdata, no 16-bit 0x4379/0x7943 compare, - and no function holding both 0x43 and 0x79 as immediates on a voice - path (the 0x43/0x79 immediates that do exist are MIDI CC 67/121 - handlers, plus one Standard-MIDI-File path that recognises the MA-3 - exclusive "43 79 06 7C 02"). Nothing statically imports M7_EmuHw.dll or - M7_EmuSmw7.dll either - they are LoadLibrary'd - so the call graph - dead-ends at indirect dispatch and cannot be walked from the exports. - - The two approaches most likely to finish it: - 1. Dynamic, and probably decisive: the chip emulator exports - Hw_WriteReg(addr, data). Run the player (Wine, or emulate the DLL with - Unicorn) and log the register writes while it loads AB00221GM7.MMF, - then diff those against the writes for an MA-5 file whose voice layout - is already known. That maps body bytes to register fields directly. - 2. Static, cheaper to start: SMAFMMS7EMU.dll is only 614 KB and is the - layer that reads the .mmf and hands setup data to the middleware. The - Mtsu -> "write exclusive" hand-off most likely lives there rather than - in the 9.8 MB DLL. - - Failing that, an MA-7 file set large enough to vary more of the body would - also settle it: the whole corpus has only four distinct voices. + M7_EmuSmw7.dll (SMAF middleware + synth, 9.8 MB), M7_EmuHw.dll (the + YMU786 register-level emulator) and SMAFMMS7EMU.dll. All are stripped + 32-bit PE with no debug strings. SMAFMMS7EMU.dll is a dead end: a thin + shim whose SmafMms_* exports forward to MaSound_* in M7_EmuSmw7.dll. + - M7_EmuSmw7.dll holds SEVEN near-identical copies of the middleware + (functions repeat at roughly 0x6f000 intervals) - the same build, not + one per MA generation, so only one copy needs reading. + - Nothing statically imports the emu DLLs; they are LoadLibrary'd, so the + call graph dead-ends at indirect dispatch and cannot be walked from the + exports. Working backwards from the leaf helpers is what paid off: + every voice path ends up calling the same two routines (the wire-block + packer at 0x10019d70 and the voice registrar at 0x10018f70), so + enumerating THEIR callers enumerates the voice loaders. + - Being 32-bit x86, the DLL runs happily under Unicorn on any host - no + Wine needed. Map the PE at its image base, point EAX at a record, push + the stack arguments and call the routine directly. This is the quickest + way to check any claim about a SMAF voice layout against Yamaha's own + code, and it is how both the MA-5 and MA-7 layouts here were confirmed. + - Two coincidences that mislead: the general voice converter at 0x100345f0 + has an internal format tag 0x21 that means "VM35, 4 operators" and has + nothing to do with the 0x21 in "43 79 08 7F 21"; and its class-1 input + form (6-byte header + 5 bytes/op, 26 bytes for 4 operators) is the same + size as an MA-7 record measured from the drum-note byte. Emulation + rules both out - that converter never accepts an MA-7 record at all. + MA-7 voices reach the chip through the loader at 0x1004c250 instead, + which keys on bank MSB 0x7c and dispatches on record length. Description - Sequence Data (Mtsq), Mobile Standard (format_type 0x01 / 0x02) diff --git a/src/mafm/smaf_voice.c b/src/mafm/smaf_voice.c index 636f8e5d..4ad0bccc 100644 --- a/src/mafm/smaf_voice.c +++ b/src/mafm/smaf_voice.c @@ -152,6 +152,55 @@ void _WM_MAFM_ParseVoiceExclusive(const uint8_t *p, uint32_t n, if (!p || n < 5) return; if (p[0] != 0x43) return; /* not a Yamaha maker id */ + /* MA-7 form: 43 79 08 7F 21 [bank msb][bank lsb][pc][drum note][type][rsv] + * then 3 global bytes and TEN bytes per operator. + * + * MA-7 stores the operator block in the chip's own layout instead of the + * packed VM35 one. Yamaha's middleware converts VM35 -> native with a + * fixed shuffle (M7_EmuSmw7.dll, the routine at 0x10183980): + * + * native[0..4] = vm35[0..4] native[5] = 0 + * native[6] = vm35[6] native[7] = native[8] = 0 + * native[9] = vm35[5] + * + * so undoing it recovers a VM35 operator and the existing decoder can do + * the rest. Confirmed by round-tripping all four voices in the corpus + * back through that routine: every operator byte reproduces exactly, and + * the three bytes it zeroes are zero in all of them. */ + if (n >= 14 && p[1] == 0x79 && p[2] == 0x08 && p[3] == 0x7f && p[4] == 0x21) { + const uint8_t *body = p + 11; + uint32_t bn = n - 11; + uint8_t vm35[3 + 7 * 4]; + int alg, ops, i; + + out->key.bank_msb = p[5]; + out->key.bank_lsb = p[6]; + out->key.pc = p[7]; + out->key.drum_note = p[8]; + if (p[9] != 0) { /* PCM (sampled) voice, not FM */ + out->is_pcm = 1; + return; + } + if (bn < 3) return; + alg = body[2] & 0x07; + ops = op_count_from_alg(alg); + if (bn < (uint32_t)(3 + 10 * ops)) return; + + vm35[0] = body[0]; + vm35[1] = body[1]; + vm35[2] = body[2]; + for (i = 0; i < ops; i++) { + const uint8_t *o = body + 3 + 10 * i; + uint8_t *v = vm35 + 3 + 7 * i; + v[0] = o[0]; v[1] = o[1]; v[2] = o[2]; v[3] = o[3]; v[4] = o[4]; + v[5] = o[9]; /* MULTI / DT */ + v[6] = o[6]; /* WS / FB */ + } + apply_vm35_body(vm35, (uint32_t)(3 + 7 * ops), ops, &out->patch); + out->valid = 1; + return; + } + /* MA-3 / MA-5 long form: 43 79 06|07 7F 01 [bank...] body */ if (n >= 11 && p[1] == 0x79 && (p[2] == 0x06 || p[2] == 0x07) && p[3] == 0x7f && p[4] == 0x01) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 07b4a0b2..d82f8d0a 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -5,3 +5,10 @@ ADD_TEST(NAME tokenize COMMAND test_tokenize) ADD_EXECUTABLE(test_smaf_sequ test_smaf_sequ.c) TARGET_LINK_LIBRARIES(test_smaf_sequ libwildmidi-static ${M_LIBRARY}) ADD_TEST(NAME smaf_sequ COMMAND test_smaf_sequ) + +IF (WANT_MAFM) + ADD_EXECUTABLE(test_ma7_voice test_ma7_voice.c) + TARGET_INCLUDE_DIRECTORIES(test_ma7_voice PRIVATE ${CMAKE_SOURCE_DIR}/src) + TARGET_LINK_LIBRARIES(test_ma7_voice libwildmidi-static ${M_LIBRARY}) + ADD_TEST(NAME ma7_voice COMMAND test_ma7_voice) +ENDIF (WANT_MAFM) diff --git a/test/test_ma7_voice.c b/test/test_ma7_voice.c new file mode 100644 index 00000000..3dc420e5 --- /dev/null +++ b/test/test_ma7_voice.c @@ -0,0 +1,66 @@ +/* assert-based smoke test for the MA-7 Mtsu voice-exclusive decode + * (_WM_MAFM_ParseVoiceExclusive, src/mafm/smaf_voice.c). + * + * MA-7 stores its operators in the chip's native 10-byte layout rather than + * the 7-byte packed VM35 one the older chips use; the decoder undoes the + * shuffle Yamaha's middleware applies. See docs/formats/SmafFileFormat.txt. + * The bytes below are the pc=0x0a voice out of AB00221GM7.MMF. */ +#include +#include +#include +#include + +#include "mafm/smaf_voice.h" + +/* the exclusive payload, i.e. what sits between the F0 length and the F7 */ +static const uint8_t ma7_voice[] = { + 0x43, 0x79, 0x08, 0x7f, 0x21, /* Yamaha, MA-7, voice-set sub-id */ + 0x7c, 0x02, 0x0a, 0x00, /* bank MSB/LSB, program, drum note */ + 0x00, /* voice type: 0 = FM */ + 0x00, /* reserved */ + 0x00, 0x79, 0x40, /* 3 global bytes; alg = 0x40 & 7 = 0 */ + /* operator 0, native layout: bytes 5,7,8 are the reserved zeroes */ + 0x33, 0x3b, 0x94, 0x70, 0x44, 0x00, 0x41, 0x00, 0x00, 0x90, + /* operator 1 */ + 0x22, 0x44, 0xdf, 0x02, 0x41, 0x00, 0x00, 0x00, 0x00, 0x10 +}; + +int main(void) { + struct mafm_parsed_voice v; + const struct mafm_op_patch *m, *c; + + _WM_MAFM_ParseVoiceExclusive(ma7_voice, (uint32_t)sizeof(ma7_voice), &v); + + assert(v.valid); + assert(!v.is_pcm); + assert(v.key.bank_msb == 0x7c); + assert(v.key.bank_lsb == 0x02); + assert(v.key.pc == 0x0a); + assert(v.key.drum_note == 0x00); + + m = &v.patch.ops[0]; /* modulator */ + c = &v.patch.ops[1]; /* carrier */ + + /* The fields that live at the SAME offset in both layouts. */ + assert(m->ar == 9 && m->sl == 4); + assert(m->rr == 3 && m->dr == 11); + assert(m->tl == 28 && m->ksl == 0); + assert(c->ar == 13 && c->sl == 15); + assert(c->tl == 0); + + /* The two that MA-7 moves, and the whole point of this test: MULTI/DT come + * from native byte 9 and WS/FB from native byte 6. Reading them at the + * VM35 offsets instead yields MULTI 0 on both operators - which is what + * every wrong guess at this layout produced, and no real patch has. */ + assert(m->multi == 9 && m->dt == 0); + assert(c->multi == 1 && c->dt == 0); + assert(m->wave == 8 && m->fb == 1); + assert(c->wave == 0 && c->fb == 0); + + /* A carrier at full level against an attenuated modulator: the sanity + * check that the operator order came out right. */ + assert(c->tl < m->tl); + + printf("ma7_voice ok\n"); + return 0; +} From 3159568339643fb2cc845f44e4be192af04a7eb3 Mon Sep 17 00:00:00 2001 From: Bret Curtis Date: Mon, 27 Jul 2026 11:48:14 +0200 Subject: [PATCH 5/7] Gate MA-7 voice decode on the body-form selector p[10] selects the MA-7 voice body layout; only form 0x00 (3 global + 10 bytes/operator) is established, so require it and an exact body length instead of assuming every 7F 21 record uses that shape. Also drop the bogus voice-type reading of p[9], which holds split points. --- docs/formats/SmafFileFormat.txt | 123 +++++++++++++++++++++----------- src/mafm/smaf_voice.c | 42 ++++++----- 2 files changed, 106 insertions(+), 59 deletions(-) diff --git a/docs/formats/SmafFileFormat.txt b/docs/formats/SmafFileFormat.txt index 370d7cf6..e89b7a8b 100644 --- a/docs/formats/SmafFileFormat.txt +++ b/docs/formats/SmafFileFormat.txt @@ -231,21 +231,33 @@ optional FM engine, the VOICE/instrument form. section); it recognises Mthv / Mhvs / Mhsc, which the sample corpus does not contain. - MA-7 voice-exclusive (SOLVED). MA-7 Mtsu voices look like this: - - 43 79 08 7F 21 | 7c 02 0a 00 | 00 | 00 | <23-byte body> | F7 - \-- id ------/ \- key ---/ type rsv - bankMSB bankLSB pc drumNote - - Same maker id / bank / program / drum-note / voice-type key as the MA-3 and - MA-5 forms (which use "43 79 06|07 7F 01"), with model byte 0x08, sub-id - 0x21, and one reserved byte (0 in every corpus voice) before the body. - - The body is 3 global bytes followed by TEN bytes per operator - not the - seven that MA-3/MA-5 use. The globals are unchanged, so the algorithm is - still global[2] & 7 and still picks 2 or 4 operators; a 2-operator MA-7 - voice is therefore 3 + 20 = 23 body bytes, which is exactly what the corpus - files carry. + MA-7 voice-exclusive (form 0x00 solved; other forms open). + + 43 79 08 7F 21 | 7c 02 0a 00 | 00 | 00 | | F7 + \-- id ------/ \- key ---/ ? form + bankMSB bankLSB pc p[8] + + p[5..7] are bank MSB / bank LSB / program as in the MA-3 and MA-5 forms. + Bank 0x7D marks a drum voice, where p[7] is the drum bank and p[8] the drum + note. p[8] and p[9] otherwise hold split points - a group of records can + share one program and differ only in those bytes, running up to 0x7F (e.g. + 52, 57, 77, 93, 127 for one instrument), so they look like velocity or key + split limits. They are NOT a voice-type byte; an earlier reading of this + document said so and was wrong. + + p[10] selects the BODY LAYOUT, and several forms exist: + + p[10] body len layout + 0x00 23 or 43 3 global + 10 bytes/operator <- decoded + 0x01 17 3 global + 7 bytes/operator (plain VM35) + 0x02 59 not established + 0x03 33 not established + 0x07 42 not established + + Form 0x00 is the one WildMIDI decodes. Its body is 3 global bytes then TEN + bytes per operator: the globals are unchanged from VM35, so the algorithm is + still global[2] & 7 and still picks 2 or 4 operators, giving 3 + 20 = 23 or + 3 + 40 = 43 body bytes. Both sizes occur in the corpus and both fit exactly. The reason for ten is that MA-7 stores the operator block in the CHIP's own layout instead of the packed VM35 one. Yamaha's middleware converts VM35 -> @@ -259,30 +271,29 @@ optional FM engine, the VOICE/instrument form. native[9] = vm35[5] (MULTI / DT) Undo that and the existing VM35 decoder handles the rest, which is what - _WM_MAFM_ParseVoiceExclusive() does. - - How it was verified. The mapping is not inferred from the sample bytes - it - was read out of Yamaha's converter and then round-tripped through it under - Unicorn: take each corpus voice's native operator block, apply the inverse - above to get a VM35 operator, feed that back into Yamaha's own routine, and - compare. All 20 operator bytes reproduce exactly, for all four voices. - Two independent checks agree: - - the three bytes the routine zeroes (native 5, 7 and 8) are zero in all - eight operators in the corpus, which will not happen by chance; - - the decoded parameters are textbook 2-op FM. Every carrier comes out - TL=0, MULTI=1, AR=10-13, and the modulators carry the per-instrument - difference (TL 28/36/52/58, MULTI 9/7/9/2, FB=1). Earlier guesses at - this layout produced MULTI=0 on both operators, which no real patch has. - - Still unknown, and harmless: the reserved byte at p[10], and whether MA-7 - ever ships 4-operator or PCM voices - the corpus has only four voices, all - 2-operator FM. The 4-operator path follows from the same 3 + 10*N rule and - is implemented, but is untested for want of a sample. - - Also in MA-7 Mtsu, still undecoded because nothing needs them: - "43 79 08 7F 22 00" (end-of-setup, matching MA-5's "43 79 07 7F 07 00") and - two 38-byte per-channel parameter tables "43 79 08 7F 26 ..." and - "43 79 08 7F 27 ...". + _WM_MAFM_ParseVoiceExclusive() does. Native bytes 5, 7 and 8 are MA-7-only + parameters with no VM35 equivalent: they are zero in voices that were + authored as VM35 and converted, and carry real data otherwise. WildMIDI + ignores them either way, so those voices lose whatever those fields do. + + How it was verified. The mapping was read out of Yamaha's converter and then + round-tripped through it under Unicorn: take a corpus voice's native operator + block, apply the inverse above to get a VM35 operator, feed that back into + Yamaha's own routine, and compare. All 20 operator bytes reproduce exactly, + for all four voices in AB00221GM7.MMF / AB03957GM7.MMF. The decoded + parameters are also textbook 2-op FM - every carrier comes out TL=0, MULTI=1, + AR=10-13, with the modulators carrying the per-instrument difference (TL + 28/36/52/58, MULTI 9/7/9/2, FB=1). Earlier guesses at this layout produced + MULTI=0 on both operators, which no real patch has. + + The decoder requires p[10] == 0 AND an exact 3 + 10*ops body, so the other + forms are declined rather than guessed at; a file mixing forms simply loses + the voices it cannot decode and falls back to GM for those. Of the 54 + "7F 21" voice records across the five known MA-7 files, 6 are form 0x00. + + Also in MA-7 Mtsu, still undecoded because nothing needs them: sub-ids 0x22 + (end-of-setup, matching MA-5's "43 79 07 7F 07 00"), 0x23, 0x26, 0x27 and + 0x28 - the last two being 38-byte per-channel parameter tables. Notes from the reverse-engineering, for anyone extending this ---------------------------------------------------------- @@ -315,6 +326,35 @@ optional FM engine, the VOICE/instrument form. which keys on bank MSB 0x7c and dispatches on record length. + Where to find MA-7 files + ------------------------ + MA-7 content is rare - it was the last generation (2005) and shipped in few + phones. Two sources were checked: + + - github.com/denjhang/libsmaf carries ~1180 .mmf files. Exactly FIVE are + MA-7 (score format_type 0x03): + mmfs/AB00221GM7.MMF + mmfs/AB03957GM7.MMF + ringtones/Demo YAMAHA SMAF/AccidentCafe7-Q-UTF8EN.mmf + ringtones/Demo YAMAHA SMAF/Blossom-Q-UTF8EN.mmf + ringtones/MA-3 MMF files/Utada Hikaru - First Love.mmf + The last three are the useful ones: they carry the 0x01/0x02/0x03/0x07 + body forms and the 4-operator form 0x00 records that the first two do + not. They are also much denser scores (Mtsq of 11 KB and 25 KB) and + exercise SEQU event types the first two never use - notably type 0 (note + with running velocity, 1366 of them in Blossom) and type 6 (pitch bend). + All five parse byte-exact. + - archive.org/details/RingtoneBangers21 has 68 .mmf files but NO MA-7: + 58 are Mobile Standard and 10 HandyPhone. Still useful as regression + material - between them they carry 736 MA-5 and 388 MA-3 voice + exclusives, which is what finally exercised those two voice paths in + bulk (all 68 render, none silent). + + Anything that turns up more MA-7 content would help most with the undecoded + body forms above; a file using form 0x02, 0x03 or 0x07 together with a + known-good reference render would settle them. + + Description - Sequence Data (Mtsq), Mobile Standard (format_type 0x01 / 0x02) The sequence is a series of records: @@ -374,8 +414,9 @@ Description - Sequence Data (Mtsq), SEQU (format_type 0x03, "MA-7") *********************************************************************** * STATUS: SUPPORTED. Decoded by decode_mobile(..., sequ=1) in * * src/smaf2mid.c. Verified byte-exact (parse consumes the whole * - * Mtsq and lands on its End-Of-Sequence) on the MA-7 files in the * - * libsmaf corpus, AB00221GM7.MMF and AB03957GM7.MMF. * + * Mtsq and lands on its End-Of-Sequence) on all five known MA-7 files * + * (listed under "Where to find MA-7 files" in the chip-support * + * summary above), including the two dense ones. * *********************************************************************** SEQU is NOT a compressed or otherwise novel encoding - earlier notes here were diff --git a/src/mafm/smaf_voice.c b/src/mafm/smaf_voice.c index 4ad0bccc..c0313692 100644 --- a/src/mafm/smaf_voice.c +++ b/src/mafm/smaf_voice.c @@ -152,39 +152,45 @@ void _WM_MAFM_ParseVoiceExclusive(const uint8_t *p, uint32_t n, if (!p || n < 5) return; if (p[0] != 0x43) return; /* not a Yamaha maker id */ - /* MA-7 form: 43 79 08 7F 21 [bank msb][bank lsb][pc][drum note][type][rsv] - * then 3 global bytes and TEN bytes per operator. + /* MA-7 form: 43 79 08 7F 21 [bank msb][bank lsb][pc][?][?][form] body * - * MA-7 stores the operator block in the chip's own layout instead of the - * packed VM35 one. Yamaha's middleware converts VM35 -> native with a - * fixed shuffle (M7_EmuSmw7.dll, the routine at 0x10183980): + * p[10] selects the body layout. Only form 0x00 is decoded here: 3 global + * bytes then TEN bytes per operator, the chip's own layout rather than the + * packed VM35 one the older chips use. Yamaha's middleware converts + * VM35 -> native with a fixed shuffle (M7_EmuSmw7.dll, routine 0x10183980): * * native[0..4] = vm35[0..4] native[5] = 0 * native[6] = vm35[6] native[7] = native[8] = 0 * native[9] = vm35[5] * - * so undoing it recovers a VM35 operator and the existing decoder can do - * the rest. Confirmed by round-tripping all four voices in the corpus - * back through that routine: every operator byte reproduces exactly, and - * the three bytes it zeroes are zero in all of them. */ - if (n >= 14 && p[1] == 0x79 && p[2] == 0x08 && p[3] == 0x7f && p[4] == 0x21) { + * so undoing it recovers a VM35 operator and the existing decoder does the + * rest. Confirmed by round-tripping corpus voices back through that + * routine: every operator byte reproduces exactly. (Bytes 5/7/8 carry + * MA-7-only parameters that VM35 cannot express - zero in voices that were + * authored as VM35, non-zero otherwise - and are ignored either way.) + * + * Forms 0x01 (3 + 7/op, i.e. plain VM35), 0x02, 0x03 and 0x07 also occur + * in the wild; their layouts are not established, so they are declined + * rather than guessed at. See docs/formats/SmafFileFormat.txt. */ + if (n >= 14 && p[1] == 0x79 && p[2] == 0x08 && p[3] == 0x7f && p[4] == 0x21 && + p[10] == 0x00) { const uint8_t *body = p + 11; uint32_t bn = n - 11; uint8_t vm35[3 + 7 * 4]; int alg, ops, i; + if (bn > 0 && p[n - 1] == 0xf7) + bn--; /* drop the terminator if it is included */ + if (bn < 3) return; + alg = body[2] & 0x07; + ops = op_count_from_alg(alg); + /* exact fit only: a body that is not 3 + 10*ops is some other form */ + if (bn != (uint32_t)(3 + 10 * ops)) return; + out->key.bank_msb = p[5]; out->key.bank_lsb = p[6]; out->key.pc = p[7]; out->key.drum_note = p[8]; - if (p[9] != 0) { /* PCM (sampled) voice, not FM */ - out->is_pcm = 1; - return; - } - if (bn < 3) return; - alg = body[2] & 0x07; - ops = op_count_from_alg(alg); - if (bn < (uint32_t)(3 + 10 * ops)) return; vm35[0] = body[0]; vm35[1] = body[1]; From 474b54958fb5b5c6390590329d9f51f8944a3a0e Mon Sep 17 00:00:00 2001 From: Bret Curtis Date: Mon, 27 Jul 2026 12:34:01 +0200 Subject: [PATCH 6/7] Decode MA-7 FM voice form 0x02 p[10] is effectively the voice type: forms 0x00 and 0x02 are FM and share the 3-global + 10-byte-operator layout (0x02 adds a 16-byte trailer), while 0x01/0x03/0x07 are sampled voices whose bodies start with a PCM sample rate. Decoding 0x02 covers every FM voice in every known MA-7 file. Also document the MA-1..MA-6 gaps in SMAF_TODO.md. --- docs/formats/SmafFileFormat.txt | 98 ++++++++++++++++++++------------- src/mafm/smaf_voice.c | 17 ++++-- test/test_ma7_voice.c | 39 +++++++++++++ 3 files changed, 109 insertions(+), 45 deletions(-) diff --git a/docs/formats/SmafFileFormat.txt b/docs/formats/SmafFileFormat.txt index e89b7a8b..9b30f6bc 100644 --- a/docs/formats/SmafFileFormat.txt +++ b/docs/formats/SmafFileFormat.txt @@ -164,15 +164,18 @@ optional FM engine, the VOICE/instrument form. "43 05 01 ..." decoded, layout confirmed vs Yamaha MA-3 / MA-5 PCM (sampled) "43 79 .. type!=0" parsed, not yet played - MA-7 native ops "43 79 08 7F 21 ..." decoded, layout - confirmed vs Yamaha + MA-7 native ops "43 79 08 7F 21 ..." FM forms 0x00/0x02 + (p[10] = 0x00, 0x02) decoded, layout + confirmed vs Yamaha + MA-7 sampled (p[10] = 0x01/03/07) parsed as sampled, + not yet played Net: MA-1 and MA-2 are fully supported and verified (score + FM voices + ATR ADPCM drums). MA-3 / MA-5 score plays; their FM voice forms are implemented and the byte layout now matches Yamaha's own decoder (see below), though no - MA-3/5 file has been listened to end to end. MA-7 score plays and its FM - voices are decoded too, so MA-7 files now play on the FM engine rather than - falling back to the GM wavetable. + MA-3/5 file has been listened to end to end. MA-7 score plays and all its FM + voices are decoded, so MA-7 files play on the FM engine. What no generation + has is SAMPLED (PCM) voice playback - see docs/formats/SMAF_TODO.md. VM35 layout confirmed against Yamaha's MCP-MA7 player ---------------------------------------------------- @@ -245,36 +248,41 @@ optional FM engine, the VOICE/instrument form. split limits. They are NOT a voice-type byte; an earlier reading of this document said so and was wrong. - p[10] selects the BODY LAYOUT, and several forms exist: - - p[10] body len layout - 0x00 23 or 43 3 global + 10 bytes/operator <- decoded - 0x01 17 3 global + 7 bytes/operator (plain VM35) - 0x02 59 not established - 0x03 33 not established - 0x07 42 not established - - Form 0x00 is the one WildMIDI decodes. Its body is 3 global bytes then TEN - bytes per operator: the globals are unchanged from VM35, so the algorithm is - still global[2] & 7 and still picks 2 or 4 operators, giving 3 + 20 = 23 or - 3 + 40 = 43 body bytes. Both sizes occur in the corpus and both fit exactly. - - The reason for ten is that MA-7 stores the operator block in the CHIP's own - layout instead of the packed VM35 one. Yamaha's middleware converts VM35 -> - native with a fixed shuffle (M7_EmuSmw7.dll, routine at 0x10183980): - - native[0..4] = vm35[0..4] (SR/KSR, RR/DR, AR/SL, TL/KSL, EAM..) - native[5] = 0 - native[6] = vm35[6] (WS / FB) - native[7] = 0 - native[8] = 0 - native[9] = vm35[5] (MULTI / DT) - - Undo that and the existing VM35 decoder handles the rest, which is what - _WM_MAFM_ParseVoiceExclusive() does. Native bytes 5, 7 and 8 are MA-7-only - parameters with no VM35 equivalent: they are zero in voices that were - authored as VM35 and converted, and carry real data otherwise. WildMIDI - ignores them either way, so those voices lose whatever those fields do. + p[10] selects the BODY LAYOUT. It is effectively the voice type: two of the + forms are FM, the rest are SAMPLED (PCM) voices. + + p[10] body kind layout + 0x00 23 or 43 FM 3 global + 10 bytes/operator + 0x02 59 FM 3 global + 10 bytes/operator + 16-byte tail + 0x01 17 sampled - + 0x03 33 sampled - + 0x07 42 sampled - + + WildMIDI decodes the two FM forms and declines the sampled ones. + + Both FM forms use the SAME body rule: 3 global bytes, the algorithm is still + global[2] & 7, it still picks 2 or 4 operators, and each operator is TEN + bytes. Form 0x02 simply appends a 16-byte trailer of rate/level pairs that + this engine does not model; it is skipped. Every form 0x00 body in the + corpus is exactly 3 + 10*ops (23 for 2 operators, 43 for 4) and every form + 0x02 body is exactly 3 + 10*ops + 16 = 59, with the algorithm always giving + 4 operators. + + That the other three forms are sampled rather than FM is clear from the data: + their bodies open with a 16-bit PCM sample rate, and most of them sit in the + drum bank (0x7D), which is where sampled percussion lives. + + form bodies whose first u16 is a plausible rate round hundreds + 0x01 4/4 (10500, 13000, 13500, 16000) 4/4 + 0x03 36/36 (2618 .. 9320, incl. 6000/8000/9000) 26/36 + 0x07 1/1 (5250) 0/1 + 0x00 1/10 - 0/10 <- not rates + 0x02 0/7 (all start 0x0079, an FM global) 0/7 <- not rates + + 24 of the 36 form 0x03 records and 2 of the 4 form 0x01 records are drum-bank + voices. Their exact layouts are not established, and playing them needs the + sampled-voice support that WildMIDI lacks for every MA generation - see + docs/formats/SMAF_TODO.md, gaps 1 and 2. How it was verified. The mapping was read out of Yamaha's converter and then round-tripped through it under Unicorn: take a corpus voice's native operator @@ -286,10 +294,22 @@ optional FM engine, the VOICE/instrument form. 28/36/52/58, MULTI 9/7/9/2, FB=1). Earlier guesses at this layout produced MULTI=0 on both operators, which no real patch has. - The decoder requires p[10] == 0 AND an exact 3 + 10*ops body, so the other - forms are declined rather than guessed at; a file mixing forms simply loses - the voices it cannot decode and falls back to GM for those. Of the 54 - "7F 21" voice records across the five known MA-7 files, 6 are form 0x00. + Coverage. Every FM voice in every known MA-7 file is now decoded. Across + the 58 "7F 21" voice records in the five files: + + form 0x00 10 FM decoded + form 0x02 7 FM decoded + form 0x01 4 sampled declined + form 0x03 36 sampled declined + form 0x07 1 sampled declined + + i.e. 17 FM records decoded and 41 sampled ones declined. Per file: + AB00221GM7 4/4, AB03957GM7 4/4, AccidentCafe7 4 FM of 25, Blossom 5 of 25, + First Love has no voice records at all (it is a streaming Mtsp file). + + The remaining 41 are not an MA-7 gap as such - WildMIDI plays no sampled SMAF + voice on any chip generation. Fixing that fixes MA-3, MA-5 and MA-7 at once; + see docs/formats/SMAF_TODO.md. Also in MA-7 Mtsu, still undecoded because nothing needs them: sub-ids 0x22 (end-of-setup, matching MA-5's "43 79 07 7F 07 00"), 0x23, 0x26, 0x27 and diff --git a/src/mafm/smaf_voice.c b/src/mafm/smaf_voice.c index c0313692..cb774ed2 100644 --- a/src/mafm/smaf_voice.c +++ b/src/mafm/smaf_voice.c @@ -169,14 +169,18 @@ void _WM_MAFM_ParseVoiceExclusive(const uint8_t *p, uint32_t n, * MA-7-only parameters that VM35 cannot express - zero in voices that were * authored as VM35, non-zero otherwise - and are ignored either way.) * - * Forms 0x01 (3 + 7/op, i.e. plain VM35), 0x02, 0x03 and 0x07 also occur - * in the wild; their layouts are not established, so they are declined - * rather than guessed at. See docs/formats/SmafFileFormat.txt. */ + * Form 0x02 is the same FM voice with a 16-byte trailer after the + * operators (rate/level pairs that this engine does not model), so it + * shares the code. Forms 0x01, 0x03 and 0x07 are SAMPLED voices, not FM - + * their bodies start with a PCM sample rate and most of them sit in the + * drum bank - so they are declined here; playing them needs the sampled + * voice support tracked in docs/formats/SMAF_TODO.md. */ if (n >= 14 && p[1] == 0x79 && p[2] == 0x08 && p[3] == 0x7f && p[4] == 0x21 && - p[10] == 0x00) { + (p[10] == 0x00 || p[10] == 0x02)) { const uint8_t *body = p + 11; uint32_t bn = n - 11; uint8_t vm35[3 + 7 * 4]; + uint32_t want; int alg, ops, i; if (bn > 0 && p[n - 1] == 0xf7) @@ -184,8 +188,9 @@ void _WM_MAFM_ParseVoiceExclusive(const uint8_t *p, uint32_t n, if (bn < 3) return; alg = body[2] & 0x07; ops = op_count_from_alg(alg); - /* exact fit only: a body that is not 3 + 10*ops is some other form */ - if (bn != (uint32_t)(3 + 10 * ops)) return; + /* exact fit only: anything else is a form we have not established */ + want = (uint32_t)(3 + 10 * ops) + (p[10] == 0x02 ? 16u : 0u); + if (bn != want) return; out->key.bank_msb = p[5]; out->key.bank_lsb = p[6]; diff --git a/test/test_ma7_voice.c b/test/test_ma7_voice.c index 3dc420e5..726cde14 100644 --- a/test/test_ma7_voice.c +++ b/test/test_ma7_voice.c @@ -25,6 +25,31 @@ static const uint8_t ma7_voice[] = { 0x22, 0x44, 0xdf, 0x02, 0x41, 0x00, 0x00, 0x00, 0x00, 0x10 }; +/* form 0x02: 4 operators (alg = 0x4d & 7 = 5) plus a 16-byte trailer */ +static const uint8_t ma7_form02[] = { + 0x43, 0x79, 0x08, 0x7f, 0x21, + 0x7c, 0x01, 0x19, 0x00, 0x00, + 0x02, /* form 0x02 */ + 0x00, 0x79, 0x4d, + 0x42, 0x65, 0xf6, 0x60, 0x03, 0x08, 0x20, 0x1a, 0x27, 0xa4, + 0x52, 0x6d, 0xf2, 0x1e, 0x03, 0x08, 0x00, 0x1e, 0x36, 0x14, + 0x13, 0x44, 0xf8, 0x5a, 0x03, 0x0a, 0x05, 0x0e, 0xca, 0x54, + 0x13, 0x51, 0xe6, 0x18, 0x03, 0x02, 0x20, 0x1e, 0x36, 0x14, + 0x06, 0x08, 0x1f, 0xf8, 0x1f, 0xf8, 0x1d, 0x3c, + 0x1b, 0x45, 0x1c, 0xfd, 0x1f, 0x95, 0x10, 0x13 +}; + +/* form 0x03: a sampled voice - body starts with the PCM rate 0x3e94 */ +static const uint8_t ma7_form03[] = { + 0x43, 0x79, 0x08, 0x7f, 0x21, + 0x7c, 0x01, 0x00, 0x80, 0x34, + 0x03, /* form 0x03 */ + 0x3e, 0x94, 0x78, 0x8b, 0x13, 0x53, 0xf2, 0x2c, 0x01, 0x0b, + 0x00, 0x00, 0x08, 0x2a, 0x0d, 0x6e, 0x0c, 0x08, 0x1e, 0x78, + 0x1e, 0x78, 0x1e, 0x78, 0x1d, 0x4c, 0x1d, 0x4c, 0x1f, 0x9d, + 0x1f, 0x00, 0x96 +}; + int main(void) { struct mafm_parsed_voice v; const struct mafm_op_patch *m, *c; @@ -61,6 +86,20 @@ int main(void) { * check that the operator order came out right. */ assert(c->tl < m->tl); + /* Form 0x02: the same 10-byte operators, four of them, with a 16-byte + * trailer the engine ignores. From AccidentCafe7-Q-UTF8EN.mmf. */ + _WM_MAFM_ParseVoiceExclusive(ma7_form02, (uint32_t)sizeof(ma7_form02), &v); + assert(v.valid); + assert(v.patch.ops[0].multi == 10 && v.patch.ops[0].tl == 24); + assert(v.patch.ops[1].multi == 1 && v.patch.ops[1].tl == 7); + assert(v.patch.ops[2].multi == 5 && v.patch.ops[2].tl == 22); + assert(v.patch.ops[3].multi == 1 && v.patch.ops[3].tl == 6); + + /* Forms 0x01/0x03/0x07 are sampled voices, not FM: decline them rather + * than decode the bytes as operators. */ + _WM_MAFM_ParseVoiceExclusive(ma7_form03, (uint32_t)sizeof(ma7_form03), &v); + assert(!v.valid); + printf("ma7_voice ok\n"); return 0; } From 01d2ef47f78287b2e449633375ef6e8fffea7531 Mon Sep 17 00:00:00 2001 From: Bret Curtis Date: Mon, 27 Jul 2026 12:50:53 +0200 Subject: [PATCH 7/7] no octave offset note --- docs/formats/SmafFileFormat.txt | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/formats/SmafFileFormat.txt b/docs/formats/SmafFileFormat.txt index 9b30f6bc..c04cdb93 100644 --- a/docs/formats/SmafFileFormat.txt +++ b/docs/formats/SmafFileFormat.txt @@ -509,15 +509,19 @@ MA-7 file seen so far leaves 16-31 at their setup defaults and plays only on 0-15, so the fold does not collide in practice. Carrying the upper bank faithfully would need a second MIDI port, which the converter cannot express. -OPEN QUESTION - octave offset. The decoded note values are raw linear semitone -numbers (confirmed: note bytes appear with low nibbles 0xC-0xF, which rules out -the HandyPhone-style octave|semitone nibble packing, and the pitch-class -histogram is diatonic). What is NOT confirmed is whether MA-7 applies a -constant transpose on top. The two sample songs sit at notes 64-99, roughly an -octave above the note distribution of the Mobile-format corpus (which centres on -60-83), but part of that is the deliberate +12 doubling layer, and two songs is -not a sample. WildMIDI passes the note through unchanged, as Mobile Standard -does. If more MA-7 files turn up and they too sit an octave high, revisit this. +RESOLVED - there is NO octave offset. The decoded note values are raw linear +semitone numbers and WildMIDI passes them through unchanged, as Mobile Standard +does. This was open while only two MA-7 files were known, both of which sit +about an octave high (means 85.9 and 83.7) and suggested a constant transpose. +With five files the picture is unambiguous: + + MA-7, all files n=5003 median 63 mean 63.3 + Mobile MA-2/3/5/6 n=101929 median 63 mean 59.9 + +Identical medians, and the per-octave histograms overlap. The two original +files were simply high-register arrangements with a deliberate +12 doubling +layer; AccidentCafe7 (mean 57.9) and Blossom (mean 56.2) sit squarely in the +normal range. Do not add a transpose. PRIOR ART - none of it reads MA-7. Checked while writing this section: - umjammer/vavi-sound (Java) has a readSEQU(), and but80/smaf825 (Go) has a