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.
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:
| Typ | Name | Daten | Bedeutung |
| 80 | Note Off | 2 Bytes (Note, Velocity) | Ein Ton wird losgelassen |
| 90 | Note On | 2 Bytes (Note, Velocity) | Ein Ton wird gespielt |
| A0 | After Touch | 2 Bytes (Note, Pressure) | Der Druck auf eine Taste ändert sich zwischen 90 and 80 |
| B0 | Control Change | 2 Bytes (Control, Value) | Eine Geräteabhängige Einstellung wird geändert |
| C0 | Program change | 1 Byte (Program Number) | Eine andere Klangfarbe wird gewählt |
| D0 | Channel Pressure | 1 Byte (Pressure) | After Touch für einen ganzen Kanal; für Geräte ohne einzelne Sensoren an jeder Taste |
| E0 | Pitch Wheel | 2 Bytes (combined to a 14-bit value) | Das Pitch Wheel wird verstellt |
| F0 | System Exclusive | all Bytes to next 0xF7 | Gerä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.
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;
}
}
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;
}