For my next Rust project I want to try something a bit more challenging than the Sudoku solver. I want to write a mod player in Rust
I learned my programming skills by writing demos for the Amiga 500 in 68000 assembler in the early nineties. I knew all about cycle counts, register optimizations and cost of memory access before I knew what a structure was.
Practically All the demos had music which were provided by mod players. At the time I didn't pay much attention to how the mod players were written but given Rust's focus on low level programming I think this would be a good little project for getting deeper into Rust.
Mod format
One of the challenges with the mod format is that there is not one definite mod file format but many different variants. Different programmers developed their own enhanced versions of the format that were backward compatible to some degree. As the hardware improved the format evolved to account for more hardware channels etc.
I am going to focus on the original format with the view of possibly extending it to cover the latter variants.
I have not found any fully authoritative source for the mod file formats but I have used the descriptions at the following links to understand the structure
- http://lclevy.free.fr/mo3/mod.txt
- https://www.aes.id.au/modformat.html
- http://sox.sourceforge.net/AudioFormats-11.html
Because the original format was developed for the Amiga computer it can be useful to have a look at the old hardware reference manual.
I am also using milky tracker to load and play different mod files to check that my interpretations match.
Reading files and using match
Before we can parse the file we need to read it from disk. The Rust standard library
std::fs
has a helper function fs:::open
that reads the given file and returns a vector of u8
.
The code for reading the file is below
let file_data: Vec<u8> = match fs::read(file_name) { Err(why) => panic!("Cant open file {}: {}", file_name, why.description()), Ok(data) => data };
This contains a lot of Rust goodness that is worth spending some time on. The first thing to note is the
match
syntax used for handling the return value. This match
syntax is effectively like C++ switch...case
on steroids.
The value following the keyword
match
is the value being matched. In our case it is the return value from fs:::read
that is being used. The curly braces enclose all the arms of the match. An arm consists of the pattern matcher on the left and handler on the right of the =>
.
The match must be exhaustive so the arms must cover every possible value that the match could receive. In this case the return value from
fs:::read
is a Result
enum which can have two variants; Err
and Ok
.
In
Rust
different instances of enum variants can have data attached to them. The variant Result::Ok
has the actual data inside it and the variant Result::Err
has the error. The enums
with data is very nice concept that can help make the code more concise.
The
match
itself can be an expression. So the return value of the chosen arm can be assigned directly into a variable. In this case it gets assigned into file_data
The code above represents a fairly common sequence of operations so there is a helper method
Result::expect
that will return the value in Ok
if that is the result and otherwise will panic and print an error message. Using the helper the above code condenses into;fn read_mod_file(file_name: &str){ let file_data: Vec<u8> = fs::read(file_name).expect( &format!( "Cant open file {}", &file_name ) ); }
This is the code I will use but now I understand what it does can can use
enum
and match
in other places.
Extracting data from Vec
With the data loaded into
file_data
we can start pulling out information from it. I will start with the easiest part; the name of the mod song. The first 20 bytes in all mod files store the song name. The following line copies that into a Rust stringlet song_name = String::from_utf8_lossy(&file_data[0..20]);
The code
file_data[0..20]
creates a slice of u8
data which is then passed into String::from_utf8_lossy
which returns a String. The slice syntax uses the range operator a..b
to specify which parts of the vector should be used for the slice.
Strings in Rust are utf8 which is why the conversion is required.
Next, I want to read and prepare the audio samples. Different versions of the mod format can have different numbers of audio samples but this number is not explicitly stated anywhere so the number of samples needs to be worked out indirectly. Many format variants have a format tag at file offset
[1080...1084]
The code will inspect this tag and use it to determine the number of samples in the file.
This kind of messy deduction needs to be put in its own function. So far the function only differentiates between files with the tag M.K. and those that dont but this is likely to grow as I encounter more files.
fn identify_num_samples(file_data: &Vec<u8>) -> u32 { let format_tag = String::from_utf8_lossy(&file_data[1080..1084]); match format_tag.as_ref() { "M.K." => 31, _ => 15 } }
Reading audio samples
Now that I know how many audio samples there are I can extract them. I have set up a structure that contain all the information about each sample ( the contents mirror the specs).
pub struct Sample { name: String, size: u32, volume: u8, fine_tune: u8, repeat_offset: u32, repeat_size: u32, samples: Vec<i8>, }
I have created a matching sample constructor that takes a
u8
array and uses it to create the sample structure.impl Sample{ fn new( sample_info : &[u8] ) -> Sample { let sample_name = String::from_utf8_lossy(&sample_info[0..22]); let sample_size: u32 = ((sample_info[23] as u32) + (sample_info[22] as u32) * 256) * 2; let fine_tune = sample_info[24]; let volume = sample_info[25]; let repeat_offset: u32 = (sample_info[27] as u32) + (sample_info[26] as u32) * 256; let repeat_size: u32 = (sample_info[29] as u32) + (sample_info[28] as u32) * 256; Sample { name: String::from(sample_name), size: sample_size, volume: volume, fine_tune: fine_tune, repeat_offset: repeat_offset, repeat_size: repeat_size, samples: Vec::new(), } }
The only special bits about this is the extraction of
sample_size
, repeat_offset
and repeat_size
. They are 16 bit values in the file that are stored in a big endian format so we need to convert them into whatever endiannes we happen to be running on.
It is also worth noting that the argument to the constructor is of
[u8]
and not the entire file_data
vector. This stops the code from accidentally reading from regions that it is not meant to.
To create all the samples I iterate over the relevant part over the file and call a sample constructor on each part.
let mut samples: Vec<Sample> = Vec::new(); let mut offset : usize = 20=; for _sample_num in 0..num_samples { samples.push(Sample::new( &file_data[ offset .. ( offset + 30 ) as usize ])); offset += 30; }
The 'magic' numbers in the above code are the length of each sample info block ( 30 bytes ) and the initial offset into the mod file ( 20 bytes ). I fully intend to convert them into consts/variables once I have a better understanding of the format.
Patterns and pattern tables
Mod files store the actual note data in patterns. Each pattern has 64 lines of note data that control sample playing and effects.
Mod files use pattern tables to control the order in which the patterns are played in. So a pattern table [ 0,1,2,1,2,] would mean the first play patterns 0,1,2 and the play 1 and 2 again.
The pattern table info is stored right after the sample data in the mode file. The first byte is the how many patterns long the song is and the second is the restart position. The following 128 bytes are the actual pattern data.
The code for reading the pattern table is
let num_patterns: u8 = file_data[offset]; let end_position: u8 = file_data[offset + 1]; offset += 2; let pattern_table: Vec<u8> = file_data[offset..(offset + 128)].to_vec(); offset += 128;
the above uses
[u8].to_vec()
to convert the pattern_table slice into a vector.
I still need to read the patterns and the actual sample data which will be the topic for my next post
I have put all the code on https://github.com/janiorca/articles/tree/master/mod_player
No comments:
Post a Comment