Seeking Guidance: SMF format, Single Pattern, MIDI Sequencer/Looper

Using a Teensy 4.1 & Rev D2 Audio Shield, I'm in the process of building a "Basic" standalone (no computer) midi sequencer which receives force sensitive resistor data and transmits ouput to a microSD card in SMF format. Working features are summarized here:

- RECEIVES
1) Analog pin data from force sensitive resistors (midi notes C5-Eb5 ((or 0x48, 0x49, 0x4A, 0x4B in hex)) ).​
- TRANSMITS
1) Audio feedback via Rev D2 Audio Shield's DAC output. This is only to provide user with audible feedback for their analog input.​
2) SMF format data written as .mid files to Teensy's microSD card. This is the main type of data I'm trying to make the sequencer work with. This point is paramount because the idea is to be able to record midi patterns away from a computer, and later open them up within a DAW for auditioning various sounds, mainly percussion.​

StandaloneMidiSequencerGlove_Prototype_WIP-min (1).jpg

As stated elsewhere in PJRC's forum, I'm quickly realizing why adding a "Sequencer" component to a project can be such a large task, and this is what I've been mulling over for the past week. With my limited programming & C++ knowledge, I've reviewed the Standard MIDI specifications from Mcgill and the MIDI Association, as well as the reference projects listed toward the bottom of this post, but I've yet to see a clear path toward implementing the following playback/looping/overdub features into my current sketch:

- Add "Queue Recording" button, which prompts device to listen for user input. The recording would need to begin in synchrony with the first input event.​
- Add "End/Save Recording" button, which does exactly this. Terminates recording and writes the new data to a .mid file.​
- Add "Loop Playback" button, which recalls/parses the most recent .mid file (along with its corresponding event type, channel, velocity, and delta time information) repeatedly until the "End/Save Recording" button is pressed. While playback is looping, the device will need to be listening for user input as well, in order to combine the looping midi events from the file with new analog input from the user.​

Code:
// File and MIDI handling
#include "AudioSampleKickkhronos.h"
#include "AudioSampleHh1khronos.h"
#include "AudioSampleSnarekhronos.h"
#include "AudioSampleHh2khronos.h"
#include <Bounce.h> 
#include <Audio.h>
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
//#include <MIDI.h> 
#include <SerialFlash.h>

// INDEX # -->  0     1     2    3
int FSRpin[] = {A17, A16, A15, A14};
const int FSRs = 4;  
int Note [] = {60, 61, 62, 63};  
int counter [FSRs];         
int VELMASK = 0;
int ATMASK = 0;
int AFTERTHRESH = 50; 
int THRESH = 45; 
int VELTIME = 500;   
int AFTERTIME = 2500;  
int MIDIMIN = 20;  
void NoteOnSend (int);   
void PolyTouchSend (int);

int channel [] = {0, 1, 2, 3};    
const int SAMPLEs = 4;
int counter2 [SAMPLEs]; 
void SampleOnSend (int);    
void gain (unsigned int channel, float gain);

// Our Real Time Clock
#include <RTClib.h>
RTC_DS3231 RTC;
bool HAS_RTC = false;

// Audio pins and values
#define AUDIO 8
#define AUDIO_DEBUG_PIN 3
int lastPlayState = 0;
bool play = false;

// Marker pins and values
#define PLACE_MARKER_PIN 5
int lastMarkState = 0;
int nextMarker = 1;

const int chipSelect = 10;
#define CHIP_SELECT 10
#define HAS_MORE_BYTES 0x80

#define NOTE_OFF_EVENT 0x80
#define NOTE_ON_EVENT 0x90
#define CONTROL_CHANGE_EVENT 0xB0
#define PITCH_BEND_EVENT 0xE0
#define AFTER_TOUCH_POLY_EVENT 0xA0

// we use a 2 minute idling timeout (in millis)
#define RECORDING_TIMEOUT 120000
unsigned long lastLoopCounter = 0;
unsigned long loopCounter = 0;

unsigned long startTime = 0;
unsigned long lastTime = 0;

#define FILE_FLUSH_INTERVAL 400
String filename;
File file;

// GUItool: begin automatically generated code
AudioPlayMemory          playMem3;       //xy=248.75,260.0000057220459
AudioPlayMemory          playMem4;       //xy=248.75,325.0000057220459
AudioPlayMemory          playMem2;       //xy=251.25,193.7500057220459
AudioPlayMemory          playMem1;       //xy=256.25,135.0000057220459
AudioMixer4              mixer1;         //xy=498.75000762939453,230.00000381469727
AudioOutputI2S           i2s1;           //xy=735.0000114440918,230.00000190734863
AudioConnection          patchCord1(playMem3, 0, mixer1, 2);
AudioConnection          patchCord2(playMem4, 0, mixer1, 3);
AudioConnection          patchCord3(playMem2, 0, mixer1, 1);
AudioConnection          patchCord4(playMem1, 0, mixer1, 0);
AudioConnection          patchCord5(mixer1, 0, i2s1, 0);
AudioConnection          patchCord6(mixer1, 0, i2s1, 1);
AudioControlSGTL5000     sgtl5000_1;     //xy=433.75000762939453,493.7500057220459
// GUItool: end automatically generated code

// ======================================================================================
/**
   Set up inline MIDI recorder
*/

void setup() {
  usbMIDI.begin();
  Serial.begin (32500);
  AudioMemory(10);
  sgtl5000_1.enable();
  sgtl5000_1.volume(0.5);
  mixer1.gain(0, 0.4);
  mixer1.gain(1, 0.4);
  mixer1.gain(2, 0.4);
  mixer1.gain(3, 0.4);

  //usbMIDI.begin();

  usbMIDI.setHandleNoteOff(myNoteOff);
  usbMIDI.setHandleNoteOn(myNoteOn);
  usbMIDI.setHandleControlChange(myControlChange);
  usbMIDI.setHandlePitchChange(myPitchChange);
  usbMIDI.setHandleAfterTouchPoly(myAfterTouchPoly);

  // set up the tone playing button
  pinMode(AUDIO_DEBUG_PIN, INPUT);
  pinMode(AUDIO, OUTPUT);
  tone(AUDIO, 440, 200);

  // set up the MIDI marker button
  pinMode(PLACE_MARKER_PIN, INPUT);

  // set up RTC interfacing
  if (RTC.begin()) {
    // uncomment this line to set the current date/time on the RTC
    // RTC.adjust(DateTime(F(__DATE__), F(__TIME__)));

    // if the RTC works, we can tell the SD library
    // how it can check for the current time when it
    // needs timestamping for file creation/writing.
    SdFile::dateTimeCallback(dateTime);
    HAS_RTC = true;
    tone(AUDIO, 880, 100);
  }

  // set up SD card functionality and allocate a file
  pinMode(CHIP_SELECT, OUTPUT);
  if (SD.begin(CHIP_SELECT)) {
    creatNextFile();
    if (file) {
      writeMidiPreamble();
      tone(AUDIO, 1760, 100);
    }
  }
}

// ======================================================================================
void dateTime(uint16_t* date, uint16_t* time) {
  DateTime d = RTC.now();
  *date = FAT_DATE(d.year(), d.month(), d.day());
  *time = FAT_TIME(d.hour(), d.minute(), d.second());
}

// ======================================================================================
/**
    We could use the EEPROM to store this number,
    but since we're not going to get timestamped
    files anyway, just looping is also fine.
*/
void creatNextFile() {
  for (int i = 1; i < 1000; i++) {
    filename = "file-";
    if (i < 10) filename += "0";
    if (i < 100) filename += "0";
    filename += String(i);
    filename += String(".mid");

    if (!SD.exists(filename.c_str())) {
      file = SD.open(filename.c_str(), FILE_WRITE);
      return;
    }
  }
}

// ======================================================================================
/**
   Set up a new MIDI file with some boiler plate byte code
*/
void writeMidiPreamble() {
  byte header[] = {
    0x4D, 0x54, 0x68, 0x64,   // "MThd" chunk
    0x00, 0x00, 0x00, 0x06,   // chunk length (from this point on)
    0x00, 0x00,               // format 0
    0x00, 0x01,               // one track
    0x01, 0xD4                // data rate = 458 ticks per quarter note 
  };
  file.write(header, 14);

  byte track[] = {
    0x4D, 0x54, 0x72, 0x6B,   // "MTrk" chunk
    0x00, 0x00, 0x00, 0x00    // chunk length placeholder (MSB)
  };
  file.write(track, 8);

  byte tempo[] = {
    0x00,                     // time delta (of zero)
    0xFF, 0x51, 0x03,         // tempo op code
    0x06, 0xFD, 0x1F          // real rate = 458,015μs per quarter note (= 134.681 BPM)
  };
  file.write(tempo, 7);
}

// ======================================================================================
/**
   The program loop consists of flushing our file to disk,
   checking our buttons to see if they just got pressed,
   and then handling MIDI input, if there is any.
*/
void loop() {
    for (int i = 0; i < FSRs; i++) {
    int FSRRead = analogRead(FSRpin[i]);
    if (FSRRead > THRESH) {
      counter[i] ++;
      if (!(VELMASK & (1 << i)) && (counter[i] == VELTIME)) {
        VELMASK |= (1 << i);                   
        counter [i] = 0;
        NoteOnSend (i);
        SampleOnSend (i);                                      
        }
      if (counter [i] == AFTERTIME) {
        counter [i] = 0;
        PolyTouchSend(i);
      }
    }
    else {                                                    
      if (VELMASK & (1 << i)) {           
        usbMIDI.sendNoteOff (Note[i], 0, 1);
        myNoteOff(1, Note[i], 0);
        VELMASK &= ~ (1 << i);                 
        counter [i] = 0;
      }
    }
  }
  checkForMarker();
  setPlayState();
  updateFile();
  usbMIDI.read();
}

// ======================================================================================
/**
   We flush the file's in-memory content to disk
   every 400ms, allowing. That way if we take the
   SD card out, it's basically impossible for any
   data to have been lost.
*/
void updateFile() {
  loopCounter = millis();
  if (loopCounter - lastLoopCounter > FILE_FLUSH_INTERVAL) {
    checkReset();
    lastLoopCounter = loopCounter;
    file.flush();
  }
}

// ======================================================================================
/**
   This "function" would normally crash any kernel that tries
   to run it by violating memory access. Instead, the Arduino's
   watchdog will auto-reboot, giving us a software "reset".
*/
void(* resetArduino) (void) = 0;

/**
  if we've not received any data for 2 minutes, and we were
  previously recording, we reset the arduino so that when
  we start playing again, we'll be doing so in a new file,
  rather than having multiple sessions with huge silence
  between them in the same file.
*/
void checkReset() {
  if (startTime == 0) return;
  if (!file) return;
  if (millis() - lastTime > RECORDING_TIMEOUT) {
    file.close();
    resetArduino();
  }
}

// ======================================================================================
/**
   A little audio-debugging: pressing the button tied to the
   audio debug pin will cause the program to play notes for
   every MIDI note-on event that comes flying by.
*/
void setPlayState() {
  int playState = digitalRead(AUDIO_DEBUG_PIN);
  if (playState != lastPlayState) {
    lastPlayState = playState;
    if (playState == 1) {
      play = !play;
    }
  }
}

// ======================================================================================
/**
   This checks whether the MIDI marker button got pressed,
   and if so, writes a MIDI marker message into the track.
*/
void checkForMarker() {
  int markState = digitalRead(PLACE_MARKER_PIN);
  if (markState  != lastMarkState) {
    lastMarkState = markState;
    if (markState == 1) {
      writeMidiMarker();
    }
  }
}

// ======================================================================================
/**
  Write a MIDI marker to file, by writing a delta, then
  the op code for "midi marker", the number of letters
  the marker label has, and then the label (using ASCII).

  For simplicity, the marker labels will just be a
  sequence number starting at "1".
*/
void writeMidiMarker() {
  if (!file) return;

  // delta + event code
  writeVarLen(file, getDelta());
  file.write(0xFF);
  file.write(0x06);                             // https://www.recordingblogs.com/wiki/midi-marker-meta-message#:~:text=The%20second%20byte%20is%20the,marker%20comment%20in%20ASCII%20text.
  
  // If we have an RTC available, we can write the clock time
  // Otherwise,  write a sequence number.
  if (HAS_RTC) {
    DateTime d = RTC.now();
    byte len = 20;
    writeVarLen(file, len);

    char marker[len]; // will hold strings like "2021/01/23, 10:53:31"
    sprintf(marker, "%04d/%02d/%02d, %02d:%02d:%02d", d.year(), d.month(), d.day(), d.hour(), d.minute(), d.second());
    file.write(marker, len);
  }

  else {
    // how many letters are we writing?
    byte len = 1;
    if (nextMarker > 9) len++;
    if (nextMarker > 99) len++;
    if (nextMarker > 999) len++;
    writeVarLen(file, len);

    // our label:
    byte marker[len];
    String(nextMarker++).getBytes(marker, len);
    file.write(marker, len);
  }
}

// ======================================================================================
void NoteOnSend (int j) {
  int FSRRead = analogRead(FSRpin [j]);                       
  int velocity = map (FSRRead, 0, 800, MIDIMIN, 127);     
  usbMIDI.sendNoteOn (Note[j], velocity, 1);
  myNoteOn(1, Note[j], velocity);
}

// ======================================================================================

void PolyTouchSend (int j) {                                 
  int FSRRead = analogRead(FSRpin [j]);
  if (FSRRead > AFTERTHRESH) {
    int pressure = map (FSRRead, 0, 800, MIDIMIN, 127);     
    usbMIDI.sendPolyPressure (Note[j], pressure, 1);
    myAfterTouchPoly(1, Note[j], pressure);
  }
}

// ======================================================================================
/*
  Reference to callbacks in void setup():
    usbMIDI.setHandleNoteOff(myNoteOff);
    usbMIDI.setHandleNoteOn(myNoteOn);
    usbMIDI.setHandleControlChange(myControlChange);
    usbMIDI.setHandlePitchChange(myPitchChange);
    //usbMIDI.setHandleAfterTouchPoly(myAfterTouchPoly);

    Order in which parameters are stored to writeToFile(); function:
      writeToFile(byte eventType, byte b1, byte b2, int delta) -> void
*/

// usbMIDI.h callbacks / writeToFile hand-offs ////
void myNoteOff(byte channel, byte note, byte velocity) {
  writeToFile(NOTE_OFF_EVENT, note, velocity, getDelta());
}
void myNoteOn(byte channel, byte note, byte velocity) {
  writeToFile(NOTE_ON_EVENT, note, velocity, getDelta());
  if (play) tone(AUDIO, 440 * pow(2, (note - 69.0) / 12.0), 100);
}
void myControlChange(byte channel, byte control, byte value) {
  writeToFile(CONTROL_CHANGE_EVENT, control, value, getDelta());
}
void myPitchChange(byte channel, int bend) {
  bend += 0x2000; // MIDI bend uses the range 0x0000-0x3FFF, with 0x2000 as center. Double the size of other data byte channel messages. A 14-bit thing.
  byte lsb = bend & 0x7F;
  byte msb = bend >> 7;
  writeToFile(PITCH_BEND_EVENT, lsb, msb, getDelta());
}
void myAfterTouchPoly(byte channel, byte note, byte pressure)  {
  writeToFile(AFTER_TOUCH_POLY_EVENT, note, pressure, getDelta());
}

// ======================================================================================
void SampleOnSend (int k) {
  analogReadResolution(7);
  int FSRRead2 = analogRead(FSRpin [k]);
  float gain = map (FSRRead2, 0, 127, .2, 1.0);
  mixer1.gain(channel[k], gain);
  if (analogRead(A17) >= THRESH) {
    playMem1.play(AudioSampleHh2khronos); }
  if (analogRead(A16) >= THRESH) {
    playMem2.play(AudioSampleSnarekhronos); }
  if (analogRead(A15) >= THRESH) {
    playMem3.play(AudioSampleHh1khronos); }
  if (analogRead(A14) >= THRESH) {
    playMem4.play(AudioSampleKickkhronos); }
}

// ======================================================================================
/**
   This calculates the number of ticks since the last MIDI event
*/
int getDelta() {
  if (startTime == 0) {
    // if this is the first event, even if the Arduino's been
    // powered on for hours, this should be delta zero.
    startTime = millis();
    lastTime = startTime;
    return 0;
  }
  unsigned long now = millis();
  unsigned int delta = (now - lastTime);
  lastTime = now;
  return delta;
}

// ======================================================================================
/**
   Write "common" MIDI events to file, where common MIDI events
   all use the following data format:

     delta     eventType    b1     b2
     <delta> <event code> <byte> <byte>

   See the "Standard MIDI-File Format" for more information -- 
   http://www.music.mcgill.ca/~ich/classes/mumt306/StandardMIDIfileformat.html
*/
void writeToFile(byte eventType, byte b1, byte b2, int delta) {
  if (!file) return;
  writeVarLen(file, delta);
  file.write(eventType);
  file.write(b1);
  file.write(b2);
}

/**
   Encode a unsigned 32 bit integer as variable-length byte sequence
   of, at most, 4 7-bit-with-has-more bytes. This function is supplied
   as part of the MIDI file format specification.
*/
void writeVarLen(File file, unsigned long value) {
  // capture the first 7 bit block
  unsigned long buffer = value & 0x7f;

  // shift in 7 bit blocks with "has-more" bit from the
  // right for as long as `value` has more bits to encode.
  while ((value >>= 7) > 0) {
    buffer <<= 8;
    buffer |= HAS_MORE_BYTES;
    buffer |= value & 0x7f;
  }

  // Then unshift bytes one at a time for as long as the has-more bit is high.
  while (true) {
    file.write((byte)(buffer & 0xff));
    if (buffer & HAS_MORE_BYTES) {
      buffer >>= 8;
    } else {
      break;
    }
  }
}
View attachment MidiGloveSequencer_WIP_Clean.ino

I do see how the "void(* resetArduino) (void) = 0;" portion of my code will need to be changed, and perhaps it would be more straightforward to overhaul this code with a new approach altogether?

There are some excellent reference projects out there for Teensy Sequencers capable of handling midi (as opposed to audio). One thing I haven't seen in any of these projects is some form of a "void writeVarLen" function used to read/write variable-length numbers from/to MIDI files (provided toward the bottom of the Mcgill Standard MIDI Specification) which is critical for saving .mid files in SMF format. These projects come close to what I'm trying to accomplish, but either side-step my goal, or overshoot it in ways that make it difficult to parse only the functionality I'm looking for:

1) positionhigh's "MicroDexed",​
2) Nic Newdigate's "Teensy-quencer",​
3) Uija's "Midi Step Sequencer",​
4) Mark J Culross's "TeensyMIDIPolySynth",​
5) mortonkopf's "Teensy-Euclidian-Sequencer", and​
6) canvasus's "CreatorMaster2k"...​

To clarify, I'm not looking to handle many audio files, create song structures, or apply any sort of effects (i.e. reverb/delay/etc). Strictly speaking, my goal is to create an SMF, single-pattern sequencer that can "overdub" user input onto an existing .mid file played back in a looping fashion.

I wanted to reach out here on the off chance someone knows of a specific path of learning that will enable me to create the aforementioned "Queue Recording, End/Save Recording, and Loop Playback" features without entangling myself in the proverbial weeds -- at least where it won't be necessary?

I understand that this challenge is likely much larger than my current understanding allows me to perceive, but I look forward to passing on what I learn as this project progresses.

Thank you for taking the time to read this & best wishes,
Colby
 
Last edited:
Back
Top