Lifting instrument definitions from the MSX SFG-01 audio module (i.e., patches for the YM2151)

Motivation: test a probably broken YM2151 on a breadboard. We need to play certain instruments in a certain way to reproduce the problem. I won’t bother you with the details in this blog post, but I’d say it worked well.

Introduction

The MSX SFG-01 extension module contains some software on a 16 KB ROM, some MIDI hardware, and an FM audio generator chip. The software can be started by typing “CALL MUSIC” in the BASIC prompt. The software looks like this:

In this state, you can press left and right to select a different voice. There’s a lot of them!

If you have an external (piano) keyboard connected to the SFG-01 module (not via MIDI, but via a proprietary connector), you can now hit keys and hear them played using the settings displayed above. The above software makes it look like you can only select between a couple different instruments (currently, BRASS 1 is selected), but the YM2151 is a full-fledged synthesizer chip; you can define any instrument you want just by poking into a couple registers. And you have 8 independent voices!

Programming the YM2151

The Commander X16 project’s documentation provides a very good overview of the registers and some programming information: https://github.com/commanderx16/x16-docs/blob/master/X16%20Reference%20-%2009%20-%20Sound%20Programming.md

Quick summary: first you poke into a set of registers to define what your instrument is supposed to sound like, then you poke into a different set of registers to make the YM2151 play notes using that instrument definition. The above link has BASIC code for both. Here’s the code for the instrument definition (from https://github.com/commanderx16/x16-docs/blob/master/X16%20Reference%20-%2009%20-%20Sound%20Programming.md#loading-a-patch), this produces marimba-like sounds:

5 YA=$9F40 : YD=$9F41 : V=0
10 REM: MARIMBA PATCH FOR YM VOICE 0 (SET V=0..7 FOR OTHER VOICES)
20 DATA $DC,$00,$1B,$67,$61,$31,$21,$17,$1F,$0A,$DF,$5F,$DE
30 DATA $DE,$0E,$10,$09,$07,$00,$05,$07,$04,$FF,$A0,$16,$17
40 READ D
50 POKE YA,$20+V : POKE YD,D
60 FOR A=$38 TO $F8 STEP 8
70 READ D : POKE YA,A+V : POKE YD,D
80 NEXT A

What’s going on over here? First, we poke $DC into register $20. Then $00 into $38. Then $1B into $40, $67 into $48, $61 into $50, etc. (We increase the address register by 8 on every loop execution because we only want to load the marimba sound into the registers for voice 0. For voice 1, the registers would be $39, $41, $49, $51, $59, $61, $69, etc. For voice 2, $3A, $42, $4A, etc.)

Getting the patches off the SFG-01’s firmware using openMSX and a TCL script

So what are these magic values? Well, I’m not a synthesizer expert, but they control stuff like ADSR (attack, decay, sustain, release), among other things. See https://en.wikipedia.org/wiki/Envelope_(music) for more information.

Well, if you’re like me you probably won’t immediately come up with the correct values that imitate a specific sound. So let’s extract these values! That’s the main part of this blog post. Unfortunately they aren’t in an easy-to-guess data structure on the software ROM. So instead we’ll be using openMSX’ VGM recording script! This script is in TCL and I’m in the process of getting some additions merged to make it work with the SFG-01’s YM2151. In the meantime, you can grab it from here: https://github.com/qiqitori/openMSX/blob/vgm_rec_ym2151/share/scripts/_vgmrecorder.tcl. Replace your openMSX’ installation’s version of this script (probably /usr/share/openmsx/scripts/_vgmrecorder.tcl) and restart openMSX. Then add the SFG-01 extension (Menu -> Hardware -> Extensions -> Add… -> Yamaha SFG-01 FM Sound Synthesizer Unit) and restart your emulated MSX. Then type “CALL MUSIC” and you should get something like the above screenshot.

Next, press F10 to open the console and type “vgm_rec start SFG-01”. Close the console by pressing F10 again. Then select a different instrument using the cursor keys. Then open the console again and type “vgm_rec stop”. If everything went well, you should now have a playable VGM file containing the instrument patch. (If you play it with vgmplay, nothing will happen because no note is being pressed.) Let’s look at the generated VGM file in a hex editor:

We have a header and a bunch of byte triplets, each starting with “54”, which indicates that a YM2151 command follows. We also have “61”s, which indicate that the YM2151 is to play a certain number of samples with the current settings. (These two numbers are defined in the VGM specification.) The next byte is the register address, the next byte the data to put into that register. We see a bunch of writes to registers $12 and $14. Remembering the BASIC script, we should actually only be interested in writes to $38 to $FF. We can easily extract these like this:

$ xxd -p music0005.vgm | perl -pe 's/(..)/"$1 "/eg; chomp' | grep -o -P '54 [3-9a-f][0-9a-f] .. ..'
54 e1 4f 61
54 e2 4f 61
54 e3 4f 61
54 e4 4f 61
54 e5 4f 61
54 e6 4f 61
54 e7 4f 61
54 e9 1f 61
54 ea 1f 61
54 eb 1f 61
54 ec 1f 61
54 ed 1f 61
54 ee 1f 61
54 ef 1f 61
54 f1 0f 61
54 f2 0f 61
54 f3 0f 61
54 f4 0f 61
54 f5 0f 61
54 f6 0f 61
54 f7 0f 61
54 f9 0f 61
54 fa 0f 61
54 fb 0f 61
54 fc 0f 61
54 fd 0f 61
54 fe 0f 61
54 ff 0f 61
54 31 00 61
54 32 00 61
54 33 00 61
54 34 00 61
54 35 00 61
54 36 00 61
54 37 00 61
54 39 00 61
54 3a 00 61
54 3b 00 61
54 3c 00 61
54 3d 00 61
54 3e 00 61
54 3f 00 61
54 41 14 61
54 42 14 61
54 43 14 61
54 44 14 61
54 45 14 61
54 46 14 61
54 47 14 61
54 49 0c 61
54 4a 0c 61
54 4b 0c 61
54 4c 0c 61
54 4d 0c 61
54 4e 0c 61
54 4f 0c 61
54 51 12 61
54 52 12 61
54 53 12 61
54 54 12 61
54 55 12 61
54 56 12 61
54 57 12 61
54 59 06 61
54 5a 06 61
54 5b 06 61
54 5c 06 61
54 5d 06 61
54 5e 06 61
54 5f 06 61
54 81 92 61
54 82 92 61
54 83 92 61
54 84 92 61
54 85 92 61
54 86 92 61
54 87 92 61
54 89 5e 61
54 8a 5e 61
54 8b 5e 61
54 8c 5e 61
54 8d 5e 61
54 8e 5e 61
54 8f 5e 61
54 91 12 61
54 92 12 61
54 93 12 61
54 94 12 61
54 95 12 61
54 96 12 61
54 97 12 61
54 99 59 61
54 9a 59 61
54 9b 59 61
54 9c 59 61
54 9d 59 61
54 9e 59 61
54 9f 59 61
54 a1 0a 61
54 a2 0a 61
54 a3 0a 61
54 a4 0a 61
54 a5 0a 61
54 a6 0a 61
54 a7 0a 61
54 a9 06 61
54 aa 06 61
54 ab 06 61
54 ac 06 61
54 ad 06 61
54 ae 06 61
54 af 06 61
54 b1 84 61
54 b2 84 61
54 b3 84 61
54 b4 84 61
54 b5 84 61
54 b6 84 61
54 b7 84 61
54 b9 85 61
54 ba 85 61
54 bb 85 61
54 bc 85 61
54 bd 85 61
54 be 85 61
54 bf 85 61
54 c1 02 61
54 c2 02 61
54 c3 02 61
54 c4 02 61
54 c5 02 61
54 c6 02 61
54 c7 02 61
54 c9 02 61
54 ca 02 61
54 cb 02 61
54 cc 02 61
54 cd 02 61
54 ce 02 61
54 cf 02 61
54 d1 00 61
54 d2 00 61
54 d3 00 61
54 d4 00 61
54 d5 00 61
54 d6 00 61
54 d7 00 61
54 d9 00 61
54 da 00 61
54 db 00 61
54 dc 00 61
54 dd 00 61
54 de 00 61
54 df 00 61
...

The firmware starts writing the patch at register address $E0. Note that this program doesn’t appear to be setting channel 0, just channels 1-7. Also note that we don’t have anything for registers $60-$7F. We don’t need those; they just control the loudness.

The X16 page also had a BASIC program that hits some keys so we can actually figure out if the patch sounds right. This is the code:

10 YA=$9F40      : REM YM_ADDRESS
20 YD=$9F41      : REM YM_DATA
30 POKE YA,$29   : REM CHANNEL 1 NOTE SELECT
40 POKE YD,$4A   : REM SET NOTE = CONCERT A
50 POKE YA,$08   : REM SELECT THE KEY ON/OFF REGISTER
60 POKE YD,$00+1 : REM RELEASE ANY NOTE ALREADY PLAYING ON CHANNEL 1
70 POKE YD,$78+1 : REM KEY-ON VOICE 1 TO PLAY THE NOTE
80 FOR I=1 TO 100 : NEXT I : REM DELAY WHILE NOTE PLAYS
90 POKE YD,$00+1 : REM RELEASE THE NOTE

Note that this program is operating on voice 1, not voice 0. What’s going on here? We write $4A into register $29. This selects the “A” note in octave 4. It’s just a coincidence that hex A is note A. ($0 is C#, $1 is D, $2 is D#, …, $A is A.) Next we write 1 into register $08. That ends any note already playing on voice 1. (We could have done that first.) Then we put $79 into the same register to play our select note. (“A” in octave 4). Then we wait a bit, and release the note by putting 1 into the same register again.

Playing notes with the extracted patches

Now let’s see if these patches produce the expected sound. To do that, we could manually add a couple note commands to the generated VGM files using our hex editor. It would just be a couple byte triplets. However, I decided to quickly hack together a short Perl program that generates a hex dump that can be converted back to a binary file that then happens to be a VGM file. So you would put this in, say, brass1_patch_with_body_polyphonic.pl and concatenate its output to a header (which I’ll show below). The result can be played with vgmplay. (Note: there is a cleaned up version of this code at the bottom of this post.)

#!/usr/bin/perl

@data = (0xDC, 0x21, 0x01, 0x01, 0x23, 0x11, 0x21, 0x17, 0x1F, 0x0A, 0x8d, 0x15, 0x4f, 0x52, 0x06, 0x0e, 0x08, 0x83, 0x02, 0x00, 0x00, 0x00, 0x18, 0x28, 0x18, 0x28); # brass 1 from sfg-01
#        ----  ----  ----  ----  ----  ----  ----  ----  ----  ----  ----  ----  ----  ----  DC1   DC1   DC1   DC1   DC2   DC2   DC2   DC2   ----  ----  ----  ----
# DC1: decay rate 1, DC2: decay rate 2

# init voice 0
$v = 0;
$i = 0;
printf("%02x %02x %02x ", 0x54, 0x20+$v, $data[$i]);
$i++;
for ($a=0x38; $a <= 0xf8; $a += 8, $i++) {
    printf("%02x %02x %02x ", 0x54, $a+$v, $data[$i])
}
# init voice 1
$v = 1;
$i = 0;
printf("%02x %02x %02x ", 0x54, 0x20+$v, $data[$i]);
$i++;
for ($a=0x38; $a <= 0xf8; $a += 8, $i++) {
    printf("%02x %02x %02x ", 0x54, $a+$v, $data[$i])
}
# init voice 2
$v = 2;
$i = 0;
printf("%02x %02x %02x ", 0x54, 0x20+$v, $data[$i]);
$i++;
for ($a=0x38; $a <= 0xf8; $a += 8, $i++) {
    printf("%02x %02x %02x ", 0x54, $a+$v, $data[$i])
}

# body
for (1..10) {
    $v = 0;
    printf("%02x %02x %02x ", 0x54, 0x28+$v, 0x4a); # 4 means octave 4, a means A (coincidence)
    printf("%02x %02x %02x ", 0x54, 0x08, 0x0+$v); # release voice $v
    printf("%02x %02x %02x ", 0x54, 0x08, 0x78+$v); # key down for voice $v
    for ($i = 0; $i < 256; $i++) { # wait
        printf("%02x ", 0x63);
    }
    $v = 1;
    printf("%02x %02x %02x ", 0x54, 0x28+$v, 0x50); # 5 means octave 5, 0 means C#
    printf("%02x %02x %02x ", 0x54, 0x08, 0x0+$v); # release voice $v
    printf("%02x %02x %02x ", 0x54, 0x08, 0x78+$v); # key down for voice $v
    for ($i = 0; $i < 256; $i++) { # wait
        printf("%02x ", 0x63);
    }
    $v = 2;
    printf("%02x %02x %02x ", 0x54, 0x28+$v, 0x54); # 5 means octave 5, 4 means E
    printf("%02x %02x %02x ", 0x54, 0x08, 0x0+$v); # release voice $v
    printf("%02x %02x %02x ", 0x54, 0x08, 0x78+$v); # key down for voice $v
    for ($i = 0; $i < 256; $i++) { # wait
        printf("%02x ", 0x63);
    }
    printf("%02x %02x %02x ", 0x54, 0x08, 0x0+$v); # release voice $v
    $v = 1;
    printf("%02x %02x %02x ", 0x54, 0x08, 0x0+$v); # release voice $v
    $v = 0;
    printf("%02x %02x %02x ", 0x54, 0x08, 0x0+$v); # release voice $v
    for ($i = 0; $i < 128; $i++) { # wait
        printf("%02x ", 0x63);
    }
}
printf("\n");

The header is as follows. Note that you should normally specify an end offset in the header, but vgmplay is nice and doesn’t care if that offset is set correctly or not. Name it simple_vgm_header.hex or something.

56676d20f0f0f0f05001000000000000000000003609010000dd00002400000000dd000000000000000000000000000000093d000c0000000000000000000000

Then you execute:

$ perl brass1_patch_with_body_polyphonic.pl > body.hex
$ cat simple_vgm_header.hex body.hex | xxd -r -p > simple_vgm.vgm
$ vgmplay simple_vgm.vgm
VGM Player
----------

File Name:      foo.vgm
Warning! Invalid EOF Offset 0xF0F0F0F4! (should be: 0x2592)

Track Title:
Game Name:
System:
Composer:
Release:
Version:        1.50      Gain: 1.00    Loop: Yes (00:01.28)
VGM by:
Notes:

Used chips:     YM2151  

Playing 8.92%   00:16.06 / 00:01.28 seconds
Playing finished.

You can replace the contents of the @data array with the extracted patch. Remember that the @data array starts with $DC followed by the value to be put into $38, the value to be put into $40, $48, etc., while the extracted patch starts with register address $E0. ($E1 actually because it doesn’t set voice 0, just voices 1-7.) In addition, the extracted patch contains register writes to set all voices (except 0) to the selected instrument. So let’s have another look at portions of the extracted patch:

54 39 00 61
54 3a 00 61
54 3b 00 61
...
54 41 14 61
54 42 14 61
54 43 14 61
...
54 49 0c 61
54 4a 0c 61
54 4b 0c 61
...
54 51 12 61
54 52 12 61
54 53 12 61
...
54 59 06 61
54 5a 06 61
54 5b 06 61
54 5c 06 61
54 5d 06 61
54 5e 06 61
54 5f 06 61
54 81 92 61
54 82 92 61
54 83 92 61
...

(Oh, never mind the 61 at the end, we didn’t actually want that.) This writes $00 to $39-$3F, $14 to $41-$47, $0C to $49-$4F, $12 to $51-$57, $06 to $59-$5F, $92 to $81-$87, etc.

So we modify the @data array like this:

@data = (0xDC, 0x00, 0x14, 0x0C, 0x12, 0x06, # above numbers
0x21, 0x17, 0x1F, 0x0A, # volume, can stay as-is
0x92, 0x5E, 0x12, 0x59, 0x0A, 0x06, 0x84, 0x85, 0x02, 0x02, 0x00, 0x00, 0x47, 0x19, 0x08, 0x09); # unfortunately I'm a bit silly and don't quite remember which patch this was, but it might have been PORGAN2 :p

Let’s also extract KOTO, just out of interest. We can make our command line a bit more intelligent so we can read off the values for @data a little easier:

$ xxd -p music0006.vgm | perl -pe 's/(..)/"$1 "/eg; chomp' | \grep -o -P '54 [3-9a-f][0-9a-f] ..' | perl -ne 'if (!/^(54 .)[2-7a-f](.*)/) { print }'
54 e1 2f
54 e9 3f
54 f1 1f
54 f9 1f
54 31 00
54 39 01
54 41 33
54 49 31
54 51 34
54 59 31
54 81 da
54 89 dc
54 91 dd
54 99 df
54 a1 08
54 a9 04
54 b1 05
54 b9 8a
54 c1 05
54 c9 02
54 d1 04
54 d9 03
54 e1 27
54 e9 36
54 f1 14
54 f9 15
54 e1 27
54 e9 36
54 f1 14
54 f9 15

Thus, @data becomes:

@data = (0xDC, 0x01, 0x33, 0x31, 0x34, 0x31, # above numbers
0x21, 0x17, 0x1F, 0x0A, # volume, can stay as-is
0xda, 0xdc, 0xdd, 0xdf, 0x08, 0x04, 0x05, 0x8a, 0x05, 0x02, 0x04, 0x03, 0x27, 0x36, 0x14, 0x15); # koto?

As the koto has fast decay, we may want to reduce the amount of time to wait between notes by changing the “256” in “for ($i = 0; $i < 256; $i++) { # wait” to something much smaller, such as 8. Here’s what it sounds like:

As promised, here’s the cleaned up version of the VGM generator code:

#!/usr/bin/perl

my @data = (0xDC, 0x00, 0x1B, 0x67, 0x61, 0x31, 0x21, 0x17, 0x1F, 0x0A, 0xDF, 0x5F, 0xDE, 0xDE, 0x0E, 0x10, 0x09, 0x07, 0x00, 0x05, 0x07, 0x04, 0xFF, 0xA0, 0x16, 0x17); # marimba
# my @data = (0xDC, 0x21, 0x01, 0x01, 0x23, 0x11, 0x21, 0x17, 0x1F, 0x0A, 0x8d, 0x15, 0x4f, 0x52, 0x06, 0x0e, 0x08, 0x83, 0x02, 0x00, 0x00, 0x00, 0x18, 0x28, 0x18, 0x28); # brass 1
# my @data = (0xDC, 0x00, 0x14, 0x0C, 0x12, 0x06, 0x21, 0x17, 0x1F, 0x0A, 0x92, 0x5E, 0x12, 0x59, 0x0A, 0x06, 0x84, 0x85, 0x02, 0x02, 0x00, 0x00, 0x47, 0x19, 0x08, 0x09); # porgan2?
# my @data = (0xDC, 0x01, 0x33, 0x31, 0x34, 0x31, 0x21, 0x17, 0x1F, 0x0A, 0xda, 0xdc, 0xdd, 0xdf, 0x08, 0x04, 0x05, 0x8a, 0x05, 0x02, 0x04, 0x03, 0x27, 0x36, 0x14, 0x15); # koto

sub voice_init {
    my ($v) = @_;
    my $i = 0;
    printf("%02x %02x %02x ", 0x54, 0x20+$v, $data[$i]);
    $i++;
    for ($a=0x38; $a <= 0xf8; $a += 8, $i++) {
        printf("%02x %02x %02x ", 0x54, $a+$v, $data[$i])
    }
}

sub play_note {
    my ($v, $note, $length) = @_;
    printf("%02x %02x %02x ", 0x54, 0x28+$v, $note);
    printf("%02x %02x %02x ", 0x54, 0x08, 0x0+$v); # release voice $v
    printf("%02x %02x %02x ", 0x54, 0x08, 0x78+$v); # key down for voice $v
    for ($i = 0; $i < $length; $i++) { # wait
        printf("%02x ", 0x63);
    }
}

sub release_voice {
    my ($v) = @_;
    printf("%02x %02x %02x ", 0x54, 0x08, 0x0+$v); # release voice $v
}

voice_init(0);
voice_init(1);
voice_init(2);

for (1..3) {
    play_note(0, 0x4a, 8); # 4 means octave 4, A means A (concidence)
    play_note(1, 0x50, 8); # 5 means octave 5, 0 means C#
    play_note(2, 0x54, 32); # 5 means octave 5, 4 means E (But it's just three semitones from C# to E? Shouldn't it be 0x53? No, for some reason, 3, 7, B, and F are skipped)
    release_voice(0);
    release_voice(1);
    release_voice(2);
}
printf("\n");

Now that we have a koto, let’s play a tune that sounds good on the koto!

#!/usr/bin/perl

my @data = (0xDC, 0x01, 0x33, 0x31, 0x34, 0x31, 0x21, 0x17, 0x1F, 0x0A, 0xda, 0xdc, 0xdd, 0xdf, 0x08, 0x04, 0x05, 0x8a, 0x05, 0x02, 0x04, 0x03, 0x27, 0x36, 0x14, 0x15); # koto

sub voice_init {
    my ($v) = @_;
    my $i = 0;
    printf("%02x %02x %02x ", 0x54, 0x20+$v, $data[$i]);
    $i++;
    for ($a=0x38; $a <= 0xf8; $a += 8, $i++) {
        printf("%02x %02x %02x ", 0x54, $a+$v, $data[$i])
    }
}

sub play_note {
    my ($v, $note, $length) = @_;
    printf("%02x %02x %02x ", 0x54, 0x28+$v, $note);
    printf("%02x %02x %02x ", 0x54, 0x08, 0x0+$v); # release voice $v
    printf("%02x %02x %02x ", 0x54, 0x08, 0x78+$v); # key down for voice $v
    for ($i = 0; $i < $length; $i++) { # wait
        printf("%02x ", 0x63);
    }
}

sub release_voice {
    my ($v) = @_;
    printf("%02x %02x %02x ", 0x54, 0x08, 0x0+$v); # release voice $v
}

voice_init(0);

$QUA_NOTE_LEN = 32;
for (1..2) {
    play_note(0, 0x4a, $QUA_NOTE_LEN);
    play_note(0, 0x4a, $QUA_NOTE_LEN);
    play_note(0, 0x4d, $QUA_NOTE_LEN*2);
}

play_note(0, 0x4a, $QUA_NOTE_LEN);
play_note(0, 0x4d, $QUA_NOTE_LEN);
play_note(0, 0x4e, $QUA_NOTE_LEN);
play_note(0, 0x4d, $QUA_NOTE_LEN);

play_note(0, 0x4a, $QUA_NOTE_LEN);
play_note(0, 0x4d, $QUA_NOTE_LEN/2);
play_note(0, 0x4a, $QUA_NOTE_LEN/2);
play_note(0, 0x45, $QUA_NOTE_LEN*2);

play_note(0, 0x44, $QUA_NOTE_LEN);
play_note(0, 0x3e, $QUA_NOTE_LEN);
play_note(0, 0x44, $QUA_NOTE_LEN);
play_note(0, 0x45, $QUA_NOTE_LEN);

play_note(0, 0x44, $QUA_NOTE_LEN);
play_note(0, 0x44, $QUA_NOTE_LEN/2);
play_note(0, 0x3e, $QUA_NOTE_LEN/2);
play_note(0, 0x3d, $QUA_NOTE_LEN*2);

for (1..2) {
    play_note(0, 0x4a, $QUA_NOTE_LEN);
    play_note(0, 0x4a, $QUA_NOTE_LEN);
    play_note(0, 0x4d, $QUA_NOTE_LEN*2);
}

play_note(0, 0x44, $QUA_NOTE_LEN);
play_note(0, 0x45, $QUA_NOTE_LEN);
play_note(0, 0x4d, $QUA_NOTE_LEN/2);
play_note(0, 0x4a, $QUA_NOTE_LEN/2);
play_note(0, 0x45, $QUA_NOTE_LEN);

play_note(0, 0x44, $QUA_NOTE_LEN*3);

release_voice(0);

printf("\n");
sakura.vgm

Leave a Reply

Your email address will not be published.