MIDI-Dateien

C# Quellcode - 19.5 Kb

Worum geht es?

Die meisten MIDI Nachrichten sind hörbar, aber manche steuern nur Einstellungen des MIDI Gerätes. Dieser Artikel beschäftigt sich damit, die Nachricht "Program Change", die Klangfarbe des Instruments festlegt, zum Verstecken kurzer Texte zu missbrauchen.

Kurzer MIDI Überblick

Eine MIDI Datei enthält Ereignisse. Jedes Ereignis besteht aus seiner Zeit, einem Nachrichtentyp, und einer bestimmten Menge von Parametern (Datenbytes). Acht Typen sind möglich:

TypNameDatenBedeutung
80Note Off2 Bytes (Note, Velocity)Ein Ton wird losgelassen
90Note On2 Bytes (Note, Velocity)Ein Ton wird gespielt
A0After Touch2 Bytes (Note, Pressure)Der Druck auf eine Taste ändert sich zwischen 90 and 80
B0Control Change2 Bytes (Control, Value)Eine Geräteabhängige Einstellung wird geändert
C0Program change1 Byte (Program Number)Eine andere Klangfarbe wird gewählt
D0Channel Pressure1 Byte (Pressure)After Touch für einen ganzen Kanal; für Geräte ohne einzelne Sensoren an jeder Taste
E0Pitch Wheel2 Bytes (combined to a 14-bit value)Das Pitch Wheel wird verstellt
F0System Exclusiveall Bytes to next 0xF7Geräteabhängige Nachricht

Die unteren vier Bits sind für die Kanalnummer reserviert. Wenn man ein mittleres C auf Kanal 5 spielt, sendet der Sequencer (z.B. MIDI Keyboard) so eine Nachricht: 02 94 3C B7
Zwei Einheiten vom Anfang, Taste auf Kanal 4 (von 0 an gezählt), Note 60 (mittleres C), Anschlagsgeschwindigkeit 92.

Wenn man die Klangfarbe auf "Piano" stellt, bevor man anfängt zu spielen, sendet er so eine Nachricht: 00 C4 00
Vor dem Anfang, Programmwechsel auf Kanal 5, neues Program ist Nummer 0.

Wenn der Sequencer die aufgezeichneten Nachrichten speichert, setzt er einen Header an den Anfang der Datei, und einen an den Anfang jedes Tracks. Jeder Header enthält zwei Felder für Typ und Länge.

struct ChunkHeader {
        char[] type; //char[4], MThd ot MTrk
        Int32 length;
}

Der Typ kann "MThd" für einen Datei-Header sein, oder "MTrk" für einen Track-Header. Ein Datei-Header sieht so aus:

Nach dem Datei-Header muss der Header des ersten Tracks folgen. Ein typischer Track-Header sieht so aus:

Die Länge des Track-Headers gibt die Anzahl der Bytes bis zum nächsten Track-Header an. Diese Bytes sind System- und MIDI-Nachrichten. System-Nachrichten haben den Typ 0xFF, ein Subtyp-Byte, und ein Length-Byte. Die Länge gibt die Anzahl der Datenbytes an:

Normalerweise beginnt eine Datei mit einer Reihe Non-MIDI Nachrichten, gefolgt von Control Change Nachrichten, und den Program Change und Note On/Off Nachrichten:

Das Ende jedes Tracks wird von einem End Of Track Ereignis markiert:

Wenn Du alles über die MIDI Spezifikation wissen mächtest, empfehle ich das MIDI Technical Fanatic´s Brainwashing Center.

Stille Verstecke

Was passiert, wenn ein paar Program Change Nachrichten aufeinander folgen, ohne eine Note On/Off Nachricht dazwischen? Das MIDI-Gerät wechselt von einer Einstellung zur nächsten, erreicht die Letzte und spielt erst dann den nächsten Ton. Das Program Change selbst hört man nicht, man hört nur die Töne, die in der aktuellen Klangfarbe gespielt werden. Das heisst, wir können ein Program Change VOR einem anderen Program Change verstecken, und niemand wird es hören.

Das Datenbyte, das die Programmnummer enthält, kann ein Wert zwischen 0 und 127 sein. Bit #7 von jedem Oktett ist als a Start Of Message Flag reserviert. Bei allen Typen ist Bit #7 auf 1 gesetzt, alle anderen Bytes verwenden nur die Bits #0 bis #6. Felder mit variabler Länge (Zeitfelder und Parameter von SysEx Nachrichten) brauchen kein eigenes Längnefeld, weil sie mit dem ersten Byte >127 aufhören (dieses muss der Anfang der nächsten Nachricht sein). Die Program Change Nachrichten sind also geeignet, um eine kurze Nachricht zu verstecken, aber die Bytes eines Unicode Textes können >127 sein. Also müssen wir die Bytes teilen. Uns stehen mehr als genug Bits zu Verfügung, um ein halbes Byte in einem Program Change zu verstecken. Bytes zu spalten ist einfach:

private byte[] SplitByte(byte b){
        byte[] parts = new byte[2];
        parts[0] = (byte)(b >> 4); //höhere Hälfte in die Niedrigere schieben
        parts[1] = (byte)((byte)(b << 4) >> 4); //Höhere Hälfte rausschieben, zurück schieben
        return parts;
}

Wir müssen nur die MIDI Datei durchs´uchen, bis wir ein Program Change Ereignis erreichen, eine Kopie dieses Ereignisses einfügen, bei der die Programmnummer unser Halbbyte enthält, und dann das nächste Program Change Ereignis suchen, um das nächste Halbbyte zu verstecken. Eine durchschnittliche MIDI Datei enthält weniger Program Change Ereignisse als ein durchschnittlicher Satz Buchstaben, darum müssen wir mehrere falsche Ereignisse vor dem Original einfügen.

Bevor wir anfangen, definieren wir erstmal ein paar Strukturen, die einiges erleichtern.

/// <summary>Header einer MIDI Datei (MThd)</summary>
public struct MidiFileHeader {
        /// <summary>char[4] - muss "MThd" (Dateianfang) sein</summary>
        public char[] HeaderType;
        ///<summary>Länge der Header-Daten - muss 6 sein.
        ///Dieser Wert in ein Int32 in Big Endian Format (umgekehrte Byte-Reihenfolge)</summary>
        public byte[] DataLength;
        /// <summary>Format der Datei
        /// 0 (ein Track)
        /// 1 (mehrere simultane Tracks)
        /// 2 (mehrere unabhängige Tracks)</summary>
        public Int16 FileType;
        /// <summary>Anzahl der Tracks</summary>
        public Int16 CountTracks;
        /// <summary>Einheiten pro Viertelnote</summary>
        public Int16 Division;
}

/// <summary>Header eines MIDI Tracks (MTrk)</summary>
public struct MidiTrackHeader {
        /// <summary>char[4] - muss "MTrk" (beginning of track) sein</summary>
        public char[] HeaderType;
        ///<summary>Länge in Bytes aller Nachrichten im Track
        ///Dieser Wert wird in Big Endian Format gespeichert</summary>
        public Int32 DataLength;
}

/// <summary>Zeit, Typ und Parameter eines Ereignisses</summary>
public struct MidiMessage {
        /// <summary>Delta Zeit - Feld varaibler Länge</summary>
        public byte[] Time;
        /// <summary>//Höhere 4 Bits: Type, Niedrigere 4 Bits: Channel</summary>
        public byte MessageType;
        /// <summary>Ein oder zwei Datenbytes
        /// SysEx (F0) Nachrichten können mehr Datenbytes haben,  aber wir brauchen sie nicht</summary>
        public byte[] MessageData;

        /// <summary>ERstellt eine neue Message aus einer Vorlage</summary>
        /// <param name="template">Vorlage für Zeit und Typ</param>
        /// <param name="messageData">Wert für die Datenbytes</param>
        public MidiMessage(MidiMessage template, byte[] messageData){
                Time = template.Time;
                MessageType = template.MessageType;
                MessageData = messageData;
        }
}

Jetzt können wir anfangen, die MIDI Datei zu lesen. Alle Sicherheits-Checks über Dateigröße etc. sind ausgelassen, Du kannst sie im vollständigen Quellcode nachlesen.

/// <summary>MIDI Datei lesen und Nachricht verstekcken bzw. auslesen</summary>
/// <param name="srcFileName">Name der "sauberen" MIDI Datei</param>
/// <param name="dstFileName">Name der Zieldatei</param>
/// <param name="secretMessage">Die geheime Nachricht,
///                oder ein leerer Stream für die extrahierte Nachricht</param>
/// <param name="key">Das Schlüsselmuster legt fest, welche ProgChg Ereignisse ausgelassen werden</param>
/// <param name="extract">true: Eine Nachricht aus [srcFileName] extrahieren;
///                false: Eine Nachricht in [srcFileName] verstecken</param>
public void HideOrExtract(String srcFileName, String dstFileName,
                          Stream secretMessage, Stream key, bool extract){

        //Quelldatei öffnen
        FileStream srcFile = new FileStream(srcFileName, FileMode.Open);
        srcReader = new BinaryReader(srcFile);
        //Stream für die resultierende MIDI Datei initialisieren
        dstWriter = null;
        if(dstFileName != null){
                FileStream dstFile = new FileStream(dstFileName, FileMode.Create);
                dstWriter = new BinaryWriter(dstFile);
        }

        //Wenn das Flag true ist, wird der Rest der Queldatei unverändert kopiert
        bool isMessageComplete = false;
        //Enthält die gerade bearbeitete Nachricht
        MidiMessage midiMessage = new MidiMessage();

        //Date-Header lesen

        MidiFileHeader header = new MidiFileHeader();

        //Typ lesen
        header.HeaderType = CopyChars(4);
        header.DataLength = new byte[4];
        header.DataLength = CopyBytes(4);

        //Typ prüfen
        if((new String(header.HeaderType) != "MThd")
                ||(header.DataLength[3] != 6)){
                MessageBox.Show("Keine Standard-MIDI Datei!");
                srcReader.Close();
                dstWriter.Close();
                return;
        }

        //Es ist eine Standard-MIDI Datei - Rest des Headers lesen

        //Diese Werte sind Int16, in umgekehrter Byte-Reihenfolge
        header.FileType = (Int16)(CopyByte()*16 + CopyByte());
        header.CountTracks = (Int16)(CopyByte()*16 + CopyByte());
        header.Division = (Int16)(CopyByte()*16 + CopyByte());

Damit haben wir den Datei-Header überwunden und erwarten den ersten Track-Header. Es ist an der Zeit, das erste Paar von Halbbytes zu lesen, und dann in den Track einzutauchen.

//Erstes geheimes Byte lesen, oder das Byte zum Extrahieren zurücksetzen
byte[] currentMessageByte = extract
        ? new byte[2]{0,0}
        : SplitByte((byte)secretMessage.ReadByte());
//Index für das currentMessageByte Array initialisieren
byte currentMessageByteIndex = 0;

//Zähler für die zu Track hinzugefügten Bytes initialisieren
Int32 countBytesAdded = 0;

//Erster Byte aus dem Schlüssel lesen (0 wenn kein Schlüssel verwendet wird)
int countIgnoreMessages = GetKeyByte(key);

//Für alle Tracks
for(int track=0; track<header.CountTracks; track++){

        if(srcReader.BaseStream.Position == srcReader.BaseStream.Length){
                break; //keine weiteren Tracks vorhanden
        }

        //Track-Header lesen

        MidiTrackHeader th = new MidiTrackHeader();
        th.HeaderType = CopyChars(4);
        if(new String(th.HeaderType) != "MTrk"){
                //Kein Standard-Track - nächsten Track suchen
                while(srcReader.BaseStream.Position+4 < srcReader.BaseStream.Length){
                        th.HeaderType = CopyChars(4);
                        if(new String(th.HeaderType) == "MTrk"){
                                break; //Standard-Track gefunden
                        }
                }
        }

        //Position des Längenfeldes merken
        //Später muss hier der Wert geändert werden,
        //weil die Länge des Tracks sich ändern wird
        int trackLengthPosition = (dstWriter == null) ? 0
                : (int)dstWriter.BaseStream.Position;

        //Längenfeld lesen und zu Int32 konvertieren
        //srcReader.ReadInt32() gibt wegen der Big Endian Reihenfolge
        //einen falschen Wert zurück,

        byte[] trackLength = new byte[4];
        trackLength = CopyBytes(4);

        th.DataLength = trackLength[0] << 24;
        th.DataLength += trackLength[1] << 16;
        th.DataLength += trackLength[2] << 8;
        th.DataLength += trackLength[3];

Der Header ist geschafft, weiter gehts mit den Nachrichten. Normalerweise enthalten die ersten Nachrichten Non-MIDI Information wie Songname und -Text. Wir können sie in die Zeildatei kopieren, ohne uns mit dem Inhalt aufzuhalten.

bool isEndOfTrack = false; //Track fängt erst an
countBytesAdded = 0; //noch keine Bytes hinzugefügt
while( ! isEndOfTrack){

        /* Nachrichten lesen
         * 1. Feld: Zeit - variable Länge
         * 2. feld: Typ und Kanal - 1 Byte
         *    Untere vier Bits enthalten den Kanal (0-15),
         *    obere vier Bits en Typ (8-F)
         * 3. und 4. Feld: Parameter - je 1 Byte */

        ReadMidiMessageHeader(ref midiMessage);

        if(midiMessage.MessageType == 0xFF){ //Non-MIDI Ereignis
                if(dstWriter != null){
                        dstWriter.Write(midiMessage.Time);
                        dstWriter.Write(midiMessage.MessageType);
                }
                byte name = CopyByte();
                int length = (int)CopyVariableLengthValue();
                CopyBytes(length);

                if((name == 0x2F)&&(length == 0)){ // End Of Track
                        isEndOfTrack = true;
                }
        }

Die MIDI Nachrichten sind interessanter. Wir müssen die Kanalnummer (untere vier Bits) entfernen, um den Nachrichtentyp zu erhalten. Dann können wir prüfen, ob wir ein Program Change gefunden haben.

else{
        //Untere vier Bits zurücksetzen, um die Kanalnummer zu entfernen
        byte cleanMessageType = (byte)(((byte)(midiMessage.MessageType >> 4)) << 4);

        if((cleanMessageType != 0xC0)&&(dstWriter != null)){
                //Kein "program change" - Kopieren
                dstWriter.Write(midiMessage.Time);
                dstWriter.Write(midiMessage.MessageType);
        }

        switch(cleanMessageType){
                case 0x80: //Note Off - Note und Velocity folgen
                case 0x90: //Note On - Note und Velocity folgen
                case 0xA0: //After Touch - Note und Pressure folgen
                case 0xB0: //Control Change - Control und Value folgen
                case 0xD0: //Channel Pressure - Value folgt
                case 0xE0:{ //Pitch Wheel - 14-Bit-Wert folgt
                        CopyBytes(2); //Datenbytes kopieren
                        break;
                }
                case 0xF0: { //SysEx - keine Länge, bis zum End-Tag 0xF7 lesen
                        byte b=0;
                        while(b != 0xF7){
                                b = CopyByte();
                        }
                        break;
                }
                case 0xC0:{ //Program Change - Programmnummer folgt

Wir haben eine Program Change Nachricht gefunden. Anhängig von der Anzahl aller Program Change Nachrichten müssen wir ein oder mehrere 4-Bit-Pakete hier verstecken ("Block Grösse"). Um die Nachricht später zu extrahieren, müssen wir diese Block Grösse kennen, darum werden wir sie als Erstes verstecken, und entsprechend als Erstes extrahieren.

                //Programmnummer lesen
                midiMessage.MessageData = srcReader.ReadBytes(1);

                if( ! isHalfBytesPerMidiMessageFinshed){
                        //Die Anzahl von Halbbytes pro MIDI Nachricht wurde
                        //noch nicht geschrieben/gelesen - Jetzt erledigen
                        if(extract){
                                //Block Grösse lesen
                                halfBytesPerMidiMessage = midiMessage.MessageData[0];
                                countBytesAdded -= midiMessage.Time.Length + 2;

                                //Nächste Nachricht lesen
                                ReadMidiMessageHeader(ref midiMessage);
                                //Get program number
                                midiMessage.MessageData = srcReader.ReadBytes(1);

                        }else{
                                //Block Grösse schreiben
                                MidiMessage msg = new MidiMessage(midiMessage,
                                       new byte[1]{halfBytesPerMidiMessage});
                                WriteMidiMessage(msg);
                                countBytesAdded += midiMessage.Time.Length + 2;
                        }
                        isHalfBytesPerMidiMessageFinshed = true;
                }

                //Einen Block von 4-Bit-Paketen verstecken
                //und dahinter das originale Program Change
                ProcessMidiMessage(midiMessage, secretMessage, key, extract,
                        ref isMessageComplete, ref countIgnoreMessages,
                        ref currentMessageByte, ref currentMessageByteIndex,
                        ref countBytesAdded);

                break;
        } //Ende "case"
}}} //Ende "switch", "else", "while"

Haben wir etwas vergessen? Ja, wir haben Nachrichten zum Track hinzugefügt, also stimmt die im Header angegebene Länge nicht mehr. Wir müssen zum Header zurückkehren und das alte Längenfeld überschreiben.

                if(dstWriter != null){
                        //Längenfeld im Track-Header ändern
                        th.DataLength += countBytesAdded;
                        trackLength = IntToArray(th.DataLength);
                        dstWriter.Seek(trackLengthPosition, SeekOrigin.Begin);
                        dstWriter.Write(trackLength);
                        dstWriter.Seek(0, SeekOrigin.End);
                }

        }//Ende "for" über die Tracks
} //Ende der Methode

Jetzt ist es aber wirklich Zeit, auf den Punkt zu kommen und die geheime Nachricht zu verstecken. Die Methode ProcessMidiMessage entscheidet nur, ob versteckt oder extrahiert wird, und ruft ProcessMidiMessageH oder ProcessMidiMessageE auf. ProcessMidiMessageH versteckt mehrere Blöcke und kopiert dann das Original MIDI Ereignis:

...

//So viele 4-Bit-Pakete wie angegeben verstecken
for(int n=0; n<halfBytesPerMidiMessage; n++){
        //Neue Nachricht mit gleichem Inhalt aber leerem Datenbyte erstellen
        MidiMessage msg = new MidiMessage(midiMessage,
                new byte[midiMessage.MessageData.Length]);

        //Neue Nachricht füllen und in Zieldatei schreiben
        isMessageComplete = HideHalfByte(msg, secretMessage,
                ref currentMessageByte, ref currentMessageByteIndex, ref countBytesAdded);

        if(isMessageComplete){ break; }
}

...

//Original Nachricht kopieren
WriteMidiMessage(midiMessage);

...

private bool HideHalfByte(MidiMessage midiMessage, Stream secretMessage,
                ref byte[] currentMessageByte, ref byte currentMessageByteIndex,
                ref int countBytesAdded){

        bool returnValue = false;
        //Aktuelles Nachrichten-Byte ins Datenbyte der MIDI Nachricht setzen
        midiMessage.MessageData[0] = currentMessageByte[currentMessageByteIndex];
        //Nachricht in Zieldatei schreiben
        WriteMidiMessage(midiMessage);
        //Hinzugefügte Bytes zählen
        countBytesAdded += midiMessage.Time.Length + 1 + midiMessage.MessageData.Length;

        //Weiter mit dem  nächsten Halbbyte

        currentMessageByteIndex++;

        if(currentMessageByteIndex == 2){
                int nextValue = secretMessage.ReadByte();
                if(nextValue < 0){
                        returnValue = true;
                }else{
                        currentMessageByte = SplitByte( (byte)nextValue );
                        currentMessageByteIndex = 0;
                }
        }

        return returnValue; //true wenn die Nachricht vollständig versteckt ist
}

Mehr braucht man nicht, um Informationen in einer MIDI Datei zu verstecken. Gar nicht so schwer, oder? ProcessMidiMessageE dreht den Prozess um:

...

for(int n=0; n<halfBytesPerMidiMessage; n++){

        ExtractHalfByte(midiMessage, secretMessage,
                        ref currentMessageByte, ref currentMessageByteIndex,
                        ref countBytesAdded);

        if((secretMessage.Length==8)&&(secretMessageLength==0)){
                //er geheime Nachrichten-Stream enthielt dieLänge er Nachricht
                //in den ersten 8 Bytes - Entfernen.
                secretMessage.Seek(0, SeekOrigin.Begin);
                byte[] bytes = new byte[8];
                secretMessage.Read(bytes, 0, 8);
                secretMessageLength = ArrayToInt(bytes);
                secretMessage.SetLength(0);
        }
        else if((secretMessageLength > 0)&&(secretMessage.Length==secretMessageLength)){
                //Ale Bytes ausgelesen - weitere Program Change Nachrichten ignorieren
                isMessageComplete = true;
                break;
        }

        if((n+1)<halfBytesPerMidiMessage){
                //Weitere versteckte Pakete folgen - nächsten Header lesen
                ReadMidiMessageHeader(ref midiMessage);
                midiMessage.MessageData = srcReader.ReadBytes(1);
        }
}

...

private void ExtractHalfByte(MidiMessage midiMessage, Stream secretMessage,
                ref byte[] currentMessageByte, ref byte currentMessageByteIndex,
                ref int countBytesAdded){

        //Gefundenes Halbbyte kopieren
        currentMessageByte[currentMessageByteIndex] = midiMessage.MessageData[0];

        //Entfernte ("negativ hinzugefügte") Bytes zählen: Zeit, Typ, Parameter
        countBytesAdded -= midiMessage.Time.Length + 1 + midiMessage.MessageData.Length;


        //Weiter zum nächsten Halbbyte
        currentMessageByteIndex++;
        if(currentMessageByteIndex == 2){
                //Extrahierts Bytes schreiben
                byte completeMessageByte = (byte)((currentMessageByte[0]<<4) + currentMessageByte[1]);
                secretMessage.WriteByte(completeMessageByte);

                currentMessageByte[0]=0;
                currentMessageByte[1]=0;
                currentMessageByteIndex = 0;
        }
}

Konvertierung zwischen Big-Endian und Little-Endian

Wahrscheinlich hast Du die Methoden IntToArray und ArrayToInt bemerkt. Diese Beiden konvertieren Integers zwischen dem von C# verwendeten Little-Endian Format und den Big-Endian Byte Arrays, die wir brauchen um MIDI Dateien zu lesen/schreiben. Zum Beispiel ist in einer MIDI Datei der Int16-Wert 12345 als "0x30 0x39" abgelegt. Das höhere Byte steht links vom niedrigeren Byte! C# erwartet das höhere Byte rechts vom Niedrigeren, es speichert Integers von niedrig nach hoch. Darum kann man hier keine Funktionen wie BinaryReader.ReadInt16 verwenden. Verwendbar sind ReadChars und ReadBytes, aber alles andere würde die Byte-Reihenfolge umdrehen. Kein Problem, wir können Integer-Werte Byte für Byte lesen, und dann alle Bytes in eine Integer-Variable schieben:

public static byte[] IntToArray(Int64 val){
        //64 bits für den Int64 initialisieren
        byte[] bytes = new byte[8];
        for(int n=0; n<8; n++){
                //Den Int64 nach rechts schieben und das niedrigste Byte abschneiden
                bytes[n] = (byte)(val >> (n*8));
        }
        return bytes;
}

public Int64 ArrayToInt(byte[] bytes){
        //Ein Little-Endian Int64 initialisieren
        Int64 result = 0;
        for(int n=0; n<bytes.Length; n++){
                //Die Bytes in die Int64-Variable schieben
                result += (bytes[n] << (n*8));
        }
        return result;
}
Follow me on Mastodon