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; }