1

I am making a musical instrument (theremin simulator). I want there to be a "record" capability which stores the volume and note the user is playing, into a MIDI file. Note this doesn't actually record audio, just data for the frequency and volume at a certain point in time.

So far I've done some research and have found how to play MIDI files (which is useful and I plan to take this on as well) and read/write to SD cards. However I haven't found a source which allows me to create and produce MIDI files.

How should I accomplish this feature?

Specs:

  • I am using an Arduino Nano, I have an Uno too if needed
  • The SD card is a 64GB micro SD, inserted into an SD adapter, with header pins soldered onto it. (I am too cheap to buy an actual shield)
  • It was previously XCSD, I formatted it to FAT32 to be compatible with Arduino

Also: I am recording from a self built replica of a theremin, which is basically two ultrasonic sensor readings for volume and pitch. Is this "too basic" for a MIDI file type? (Edit: I guess this is too advanced)

EDIT: This project has been abandoned because of no good way to play between semitones (i.e. at specific frequencies), which is what I need for my theremin. Although there is the pitch bend functionality, the amount of pitch bend translates to different frequency changes based on the receiver.

Instead, I will create my own custom file to achieve the record/playback, because using the pitch bend + note method using MIDI is just too much work and it won't be compatible with other devices anyway.

Nick Gammon's answer is really thorough for those who only need to record/play specific tones and semitones in MIDI format.

Thanks

  • Learn the syntax of a properly constructed MIDI file and write the Arduino sketch to create the file on an SDCard. This may be easier said than done as I suspect an artist seldom plays a musical phrase the exact same way twice. – st2000 May 8 '18 at 3:02
  • 1
    @st2000 Why would it matter if they play it differently another time? Record what they actually played. What they might play another time is irrelevant. – Mark Smith May 8 '18 at 5:46
  • Which Arduino do you have in mind? There are quite a few these days. – Nick Gammon May 8 '18 at 8:01
  • You are correct @MarkSmith. WRT MIDI recordings, I always want to commit the recording to sheet music. As such, I'm constantly thinking in terms of "what is a quarter note". Similar to your auto's GPS snapping you to the nearest road despite your actual location. If that is not needed here - they you are correct - just record what is played and play it back. – st2000 May 8 '18 at 12:58
  • Updated the question with specifications. – NotAMonkey24 May 9 '18 at 11:22
4

The short answer would be "yes, it is possible". There are lots of gadgets around that record MIDI. They would have microprocessors in them, and they would have something like an SD card, which itself is like EEPROM in concept.

You may possibly have problems with storing the notes in RAM, and then writing them to the SD card fast enough not to lose some. Knowing the rate at which notes would arrive, the speed of the SD card, and the type of Arduino would help to answer if your specific case was likely to work.

I wrote a sketch that reads MIDI which shows that it is certainly possible to receive MIDI data and display it. Video of it running is here.

I don't personally know the format of MIDI files, but it is well documented.

(Edit) I do now - see further down.

Writing to SD cards is well documented. Making your idea work would be a case of taking stuff (like my code to decode MIDI), code to write to SD cards, and knowing the correct format.


(Edited to add)

See Standard MIDI-File Format Spec. 1.1 for an example of producing MIDI data. Also see The MIDI File Format.

Your basic technique would need to be (as suggested by gabonator):

  • Write a header chunk to the file, for example:

    4D 54 68 64   // MThd
    00 00 00 06   // chunk length
    00 00         // format 0
    00 01         // one track
    00 60         // 96 pulses per quarter-note
    
  • Write a track chunk to the file (the length will be updated later)

    4D 54 72 6B   // MTrk
    00 00 00 00   // <-- length to be updated later
    
  • Write instrument configurations, as an example from that page:

    00 FF 58  04 04 02 18 08  // 4 bytes; 4/4 time; 24 MIDI clocks/click, 
                              // 8 32nd notes/ 24 MIDI clocks 
                              // (24 MIDI clocks = 1 crotchet = 1 beat)
    00 FF 51  03 07 A1 20     // 3 bytes: 500,000 µsec/ quarter note 
                              // = 120 beats/minute (500000 in hex is 0x07A120)
    00 C0 05                  // Ch.1 Program Change 5 = GM 
                              // Patch 6 = Electric Piano 2
    
  • When the player plays something, subtract the time that s/he last did something from the time now. This will give you the delta time.

  • Convert the time difference to ticks. From the header line above we have 96 pulses per quarter note (PPQ), and 500,000 µs (500 ms) per quarter note. Thus one second would be two quarter notes (2 beats per second, and therefore 120 beats per minute) and one second is therefore 192 ticks). Therefore the number of ticks, based on the time difference in milliseconds, would be:

    unsigned long ticks = (time_difference * 192) / 1000
    
  • Output the delta time as a variable length number, where the high order bit is set for each byte, as necessary, until what is left is <= 0x7F. The page above gives sample code for that. For example, for a delta time of 199 ticks:

    Decimal time of 199 ticks -> 0xC7
    0xC7 -> 0b11000111
    Since that exceeds 127, split into two bytes:
    
    10000001  (the high order bits)
     ^^^^^^^  <- high order bits (the MSB must be one)
    01000111  (the low-order 7 bits)
     ^^^^^^^  <- low order bits (the MSB must be zero)
    
    Result is 0x81 0x47
    

    Longer times may require even more bytes.

    Something like this should do it (adapted from the linked page). Just change the Serial.write line to output to your disk file.

    void WriteVarLen (unsigned long value)
      {
      unsigned long buffer;
    
      buffer = value & 0x7f;
      while ((value >>= 7) > 0)
        {
        buffer <<= 8;
        buffer |= 0x80;
        buffer |= value & 0x7f;
        }
    
      while (true)
        {
        Serial.write (buffer & 0xFF);
        if (buffer & 0x80)
          buffer >>= 8;
        else
          break;
        }
      }   // end of WriteVarLen
    
  • Follow the delta time immediately by the "note on" or "note off" byte (depending on whether they started or stopped playing a note). For example, "note on" for channel 1 would be 0x90 because 0x9n is a note on command and n is the channel number, zero relative. A "note off" for channel 1 would be 0x80.

  • Follow the "note on" or "note off" by the note number (in other words, which note was just played or released) - one byte.

    Octave #  Note Numbers in decimal
              C   C#  D   D#  E   F   F#  G   G#  A   A#  B
    -1         0   1   2   3   4   5   6   7   8   9  10  11
    0         12  13  14  15  16  17  18  19  20  21  22  23
    1         24  25  26  27  28  29  30  31  32  33  34  35
    2         36  37  38  39  40  41  42  43  44  45  46  47
    3         48  49  50  51  52  53  54  55  56  57  58  59
    4         60  61  62  63  64  65  66  67  68  69  70  71
    5         72  73  74  75  76  77  78  79  80  81  82  83
    6         84  85  86  87  88  89  90  91  92  93  94  95
    7         96  97  98  99 100 101 102 103 104 105 106 107
    8        108 109 110 111 112 113 114 115 116 117 118 119
    9        120 121 122 123 124 125 126 127
    

    Typically middle C is considered to be C4 (C in the 4th octave), however apparently that can vary between devices.

  • Then output the velocity (one byte). A velocity of (decimal) 32 would be soft, 64 would be medium, 96 would be loud. The range for velocities is 0 to 127.

  • Repeat the above (except for writing the chunks and the configuration) until the player decides to stop playing.

  • Write an "end of track" marker:

    00  FF 2F 00    // Delta time zero, end of track
    
  • Then seek back to the start of the file + where the length bytes are (18 bytes into the file by the looks of it) and update the length bytes (4 bytes).

Example of producing a MIDI file

This example code writes out a MIDI file. As an input device I used a 16-key keypad like this:

Keypad matrix

The columns were connected to pins 0, 1, 2, 3 of the Arduino Uno, and the rows to pins 4, 5, 6, 7. This makes the ribbon cable from the keypad keep the pins in a natural order. If you use different pins just change the keys array in the sketch.

The library for keypads that I used is available at https://github.com/nickgammon/Keypad_Matrix

This particular library supports n-key rollover and key-down and key-up events. That is particularly useful for music, because it lets you hold multiple keys down at once, and the appropriate events will be generated. Obviously you need a key-down event to generate "note on" and a key-up event to generate "note-off".

I laid out the keys like this:

C     C#    D     D#
E     F     F#    G
G#    A     A#    B
Down  Soft  Up    Record
  • There is a "recording" LED connected to A0 which lights up when you start recording (by pressing the Record button).

  • Press the Record button again to stop recording and finalize the file. It is important to stop recording properly and not just power off the device, because the length of the file needs to be written near the start.

  • Press (and hold) Down alongside with another note to play an octave lower. Press (and hold) Up alongside with another note to play an octave higher.

  • Press (and hold) Soft alongside with another note to play at a softer velocity.

Using a keypad matrix is logical, because most keyboard devices would use a matrix, to reduce the number of wires you have to run.

Note that the matrix needs diodes wired for each switch as described here because otherwise if you press 3 or more keys at once you will get "ghost" keys.

In my case I opened up the commercial keypad and soldered in the diodes.


I also used a Micro-SD breakout adapter, connected to the SPI pins of the Arduino, like this:

Micro SD adapter


The SDFat library was an archived one that I keep here: http://gammon.com.au/Arduino/SdFat-master.zip (2.2 Mb)

Unzip that file, and from within the folders inside it, copy the SdFat folder into your "libraries" folder (which is under your Arduino sketchbook folder - not inside the Arduino application folder). Then restart the Arduino IDE.

The code definitely compiles with that (with a handful of warnings). The later library from https://github.com/greiman/SdFat may be an improvement but I haven't tested that.


To keep track of file numbers I used the EEPROMAnything library, a copy of which can be found at http://gammon.com.au/Arduino/EEPROMAnything.zip

Each time you start a new recording it adds one to the file number, which wraps around to 0000 once it reaches 9999.


If there is an error initializing the file system (eg. no SD card) then the LED blinks slowly. If the file can't be created the LED blinks rapidly.


You can choose a MIDI instrument by changing the line:

const byte PATCH_NUMBER = 4;   // electric piano 1

Code

/*
 Sketch to write to a MIDI file from an instrument.
 Author: Nick Gammon
 Date:   10th May 2018

 Copyright 2018 Nick Gammon.

 PERMISSION TO DISTRIBUTE

 Permission is hereby granted, free of charge, to any person obtaining a copy of this software
 and associated documentation files (the "Software"), to deal in the Software without restriction,
 including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
 and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
 subject to the following conditions:

 The above copyright notice and this permission notice shall be included in
 all copies or substantial portions of the Software.


 LIMITATION OF LIABILITY

 The software is provided "as is", without warranty of any kind, express or implied,
 including but not limited to the warranties of merchantability, fitness for a particular
 purpose and noninfringement. In no event shall the authors or copyright holders be liable
 for any claim, damages or other liability, whether in an action of contract,
 tort or otherwise, arising from, out of or in connection with the software
 or the use or other dealings in the software.

*/


#include <Keypad_Matrix.h>
#include <SdFat.h>
#include <EEPROM.h>
#include <EEPROMAnything.h>

// For MIDI numbers see: http://www.pjb.com.au/muscript/gm.html
const byte PATCH_NUMBER = 4;   // electric piano 1

const byte RECORDING_LED = A0;

// bottom row changes things
const char DOWN_OCTAVE  = '-';
const char SOFT         = 's';
const char UP_OCTAVE    = '+';
const char RECORD       = 'r';


// how the keypad has its keys laid out
// capital letters are for sharps
const byte ROWS = 4;
const byte COLS = 4;
const char keys[ROWS][COLS] = {
  {'c', 'C', 'd', 'D'},
  {'e', 'f', 'F', 'g'},
  {'G', 'a', 'A', 'b'},
  {DOWN_OCTAVE, SOFT, UP_OCTAVE, RECORD},  // - = down octave, s = soft, + = up octave, r = start/stop record
};

const byte colPins[COLS] = {0, 1, 2, 3}; //connect to the column pinouts of the keypad
const byte rowPins[ROWS] = {4, 5, 6, 7}; //connect to the row pinouts of the keypad

// Create the Keypad
Keypad_Matrix kpd = Keypad_Matrix( makeKeymap (keys), rowPins, colPins, ROWS, COLS );

// file system object
SdFat sd;
const byte chipSelect = SS;

// the current file we are recording to
SdFile myFile;
// are we recording right now?
bool recording = false;

const byte MIDI_META_EVENT = 0xFF;
const byte MIDI_SET_TEMPO  = 0x51;
const byte MIDI_TIME_SIGNATURE = 0x58;
const byte MIDI_END_OF_TRACK = 0x2F;
const byte MIDI_PROGRAM_CHANGE = 0xC0;
const byte MIDI_NOTE_ON = 0x90;   // channel 1
const byte MIDI_NOTE_OFF = 0x80;  // channel 1

struct
  {
  char magic [4]; // MThd
  uint32_t length;
  uint16_t format;
  uint16_t tracks;
  uint16_t PPQ;    // pulses per quarter note  
  } chunkHeader;

struct
  {
  char magic [4]; // MTrk
  uint32_t length;
  } trackHeader;

unsigned long trackHeaderPosition;
unsigned long timeLastAction;
int           nextFileNumber;

uint32_t changeEndianness32(uint32_t val)
{
  return (val << 24) |
        ((val <<  8) & 0x00ff0000) |
        ((val >>  8) & 0x0000ff00) |
        ((val >> 24) & 0x000000ff);
} // end of changeEndianness32

uint16_t changeEndianness16(uint16_t val)
{
  return (val << 8) | ((val >> 8) & 0x00ff);
} // end of changeEndianness16

void showError (const int delayTime)
  {
  while (true)
    {
    digitalWrite (RECORDING_LED, HIGH);
    delay (delayTime);
    digitalWrite (RECORDING_LED, LOW);
    delay (delayTime);
    }

  } // end of showError

void startRecording ()
  {
  EEPROM_readAnything (0, nextFileNumber);
  if (nextFileNumber > 9999 || nextFileNumber < 0)
    nextFileNumber = 0;

  char name[15];
  sprintf (name, "SONG%04d.MID", nextFileNumber++);

  // open the file for writing
  if (!myFile.open(name, O_WRITE | O_CREAT | O_TRUNC))
    showError (100);  // fast flash - never returns

  // update EEPROM for next time
  EEPROM_writeAnything (0, nextFileNumber);

  recording = true;
  digitalWrite (RECORDING_LED, HIGH);
  memcpy (chunkHeader.magic, "MThd", sizeof (chunkHeader.magic));
  chunkHeader.length = changeEndianness32 (
                       sizeof (chunkHeader.format) + 
                       sizeof (chunkHeader.tracks) +
                       sizeof (chunkHeader.PPQ));

  chunkHeader.format = changeEndianness16 (0);
  chunkHeader.tracks = changeEndianness16 (1);
  chunkHeader.PPQ = changeEndianness16 (96); 
  myFile.write (&chunkHeader, sizeof (chunkHeader));
  memcpy (trackHeader.magic, "MTrk", sizeof (trackHeader.magic));
  trackHeader.length = 0;   // to be filled in later
  trackHeaderPosition = myFile.curPosition ();
  myFile.write (&trackHeader, sizeof (trackHeader));

  struct
    {
    byte deltaTime = 0;
    byte metaCode [2] = { MIDI_META_EVENT, MIDI_TIME_SIGNATURE };
    byte length = 4;
    byte sig [2] = { 4, 2 };  // 4/4 time (denominator of 2 is 2^2)
    byte clocksPerClick = 24;  // MIDI clocks/click
    byte clocksPerBeat  = 8;
    }  timeSignature;

 struct
    {
    byte deltaTime = 0;
    byte metaCode [2] = { MIDI_META_EVENT, MIDI_SET_TEMPO };
    byte length = 3;
    byte usPerQuarterNote [3] = { 0x07, 0xA1, 0x20 };  // that is: 500000 µs per beat
    }  tempo;

 struct
    {
    byte deltaTime = 0;
    byte message =   MIDI_PROGRAM_CHANGE;
    byte patchNumber = PATCH_NUMBER;
    }  programChange;

  myFile.write (&timeSignature, sizeof timeSignature);
  myFile.write (&tempo, sizeof tempo);
  myFile.write (&programChange, sizeof programChange);
  timeLastAction = millis ();
  } // end of startRecording

void stopRecording ()
  {
  struct
    {
    byte deltaTime = 0;
    byte metaCode [2] = { MIDI_META_EVENT, MIDI_END_OF_TRACK };
    byte length = 0;
    }  endOfTrack;

  // write out "end of track" message
  myFile.write (&endOfTrack, sizeof endOfTrack);
  // find where we are
  unsigned long finalPosition = myFile.curPosition ();
  // go back to where the track header is
  myFile.seekSet (trackHeaderPosition);
  // work out how long the data was (excluding the headers)
  trackHeader.length = changeEndianness32 (finalPosition - trackHeaderPosition - sizeof (trackHeader));
  // update the file length
  myFile.write (&trackHeader, sizeof (trackHeader));

  myFile.close ();
  // ensure flushed to disk
  myFile.SdBaseFile::sync();

  // no longer recording, turn off recording LED
  recording = false;
  digitalWrite (RECORDING_LED, LOW);
  } // end of stopRecording

void writeVarLen(unsigned long value)
{
  unsigned long buffer;

  buffer = value & 0x7f;
  while ((value >>= 7) > 0)
  {
    buffer <<= 8;
    buffer |= 0x80;
    buffer |= value & 0x7f;
  } // end of while

  while (true)
  {
    myFile.write ((byte) (buffer & 0xFF));
    if (buffer & 0x80)
      buffer >>= 8;
    else
      break;
  } // end of while

}   // end of writeVarLen

// turn the keypad code into a note number
byte codeToNote (const char which)
{
  char notes [] = "cCdDefFgGaAb";
  char * pos = strchr (notes, which);
  if (pos == NULL)
    return 0;
  return pos - notes + 60;  // start at middle C (C4)
} // end of codeToNote

// do a note on or note off
void doNoteAction (const char which, const byte action)
{
  // we need to work out how many milliseconds have elapsed
  unsigned long now = millis ();
  unsigned long deltaTime = now - timeLastAction;
  // remember when we did this
  timeLastAction = now;
  // write out the delta time
  writeVarLen ((deltaTime * 192) / 1000);

  // note information
  struct
    {
    byte noteOn;
    byte whichNote;
    byte velocity;
    } playNote;

  playNote.noteOn = action;
  playNote.whichNote = codeToNote (which);
  if (kpd.isKeyDown (DOWN_OCTAVE))
    playNote.whichNote -= 12;  // one octave down
  else if (kpd.isKeyDown (UP_OCTAVE))
    playNote.whichNote += 12;  // one octave up

  playNote.velocity = 64;      // medium velocity
  if (kpd.isKeyDown (SOFT))
    playNote.velocity = 32;    // softer

  myFile.write (&playNote, sizeof (playNote));
} // end of doNoteAction

void startPlaying (const char which)
  {
  doNoteAction (which, MIDI_NOTE_ON);
  } // end of startPlaying

void stopPlaying (const char which)
  {
  doNoteAction (which, MIDI_NOTE_OFF);
  } // end of stopPlaying

void keyDown (const char which)
  {
  switch (which)
    {
    case 'c':
    case 'C':
    case 'd':
    case 'D':
    case 'e':
    case 'f':
    case 'F':
    case 'g':
    case 'G':
    case 'a':
    case 'A':
    case 'b':
      startPlaying (which);
      break;

    default:
      break;
    } // end of switch on which key    
  } // end of keyDown

void keyUp (const char which)
  {
  switch (which)
    {
    case RECORD:  
      if (recording)
        stopRecording ();
      else       
        startRecording ();
      break;

    case 'c':
    case 'C':
    case 'd':
    case 'D':
    case 'e':
    case 'f':
    case 'F':
    case 'g':
    case 'G':
    case 'a':
    case 'A':
    case 'b':
      stopPlaying (which);
      break;

    default:
      break;
    } // end of switch on which key
  } // end of keyUp

void setup() 
{
  kpd.begin ();
  kpd.setKeyDownHandler (keyDown);
  kpd.setKeyUpHandler   (keyUp);
  pinMode (RECORDING_LED, OUTPUT);

  if (!sd.begin (chipSelect, SPI_HALF_SPEED))
     showError (500);  // never returns

} // end of setup

void loop() 
{
  kpd.scan ();
} // end of loop

I am recording from a self built replica of a theremin, which is basically two ultrasonic sensor readings for volume and pitch. Is this "too basic" for a MIDI file type?

It looks like this could be possible by using the MIDI "pitch bend" message. You would need to determine where your note value was (presumably most of the time between the natural notes like C and C#) and then send a bend message or "Pitch Wheel Change" (0xE0 for channel 1) to modify the main note. The main note would need to be updated if you moved more than a couple of semitones away.

See:

  • My project doesn't actually have anything connected to a MIDI port; its just reading from/writing to files. – NotAMonkey24 May 9 '18 at 11:30
  • Although what you've done is really cool – NotAMonkey24 May 9 '18 at 11:31
  • Ah, OK. So my answer doesn't really address your question. I got the idea into my head that your device outputs MIDI codes and you wanted to turn them into a disk file. – Nick Gammon May 9 '18 at 21:58
  • I've updated my answer to give a lot more detail about how you might do it. – Nick Gammon May 10 '18 at 0:17
  • 1
    Yes, well for recording and playing back on your own device, a custom file format would be much simpler. You could basically just record the raw data from the sensors (and a time difference). Then when they go to play it back, make the code think this raw data has just been received and it should reproduce it perfectly. – Nick Gammon May 13 '18 at 21:20
3

Yes, read the MIDI file format specification. Create a single track midi file and append just note press / release commands / pitch or volume changes and delta times (reverse conversion of delta times to milliseconds is explained here). When you finish recording, just seek to the beginning of the file and update the track length value (total size of the appended buffer of midi commands)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.