Montag, 14. März 2011

Wave Dateien zusammenmischen (überblenden)

In den vorigen beiden Posts wurde das Wave Format auseinander genommen, es wurde gezeigt, wie mit C# Wave Dateien eingelesen und geschrieben werden können.
In diesem Post möchte ich eine "coole" Anwendung zeigen, und zwar, wie man ein Programm erstellt, welches 2 Lieder ineinander übermischt.
Das Zusammenmischen entsteht hier durch Überblenden (Fading) der Lieder, das erste Lied wird gegen Ende hin immer leiser während das zweite Lied immer lauter wird.
Viele wünschen sich wahrscheinlich eine Anwendung, die das selbe mit MP3 - Dateien macht - allerdings ist das MP3 Format um Längen komplizierter als das Wave Format.
Es lassen sich aber beliebige MP3s mit dem in den vorigen Posts erwähnten Programm Audacity in Wave Dateien umwandeln und dann bearbeiten.
Der in diesem Post verwendete Code baut immer noch auf die Klasse WaveFile auf, die in den beiden vorigen Posts angefangen wurde.
Ein Beispielaufruf der benötigten Zeilen zum Mischen 2er Wave Dateien könnte so aussehen:

            WaveFile WF1 = new WaveFile();
            WF1.LoadWave(@"C:\Users\User\Desktop\101-die_atzen_-_disco_pogo-ysp.wav");

            WaveFile WF2 = new WaveFile();
            WF2.LoadWave(@"C:\Users\User\Desktop\StroboPopDieAtzenFeatNena.wav");

            WaveFile.StoreMixWave(@"C:\Users\User\Desktop\mixed.wav", WF1, WF2, 10);

Es werden also zuerst 2 Wave Dateien eingelesen und schließlich wird die statische Funktion StoreMixWave() aufgerufen, welche als Parameter den Pfad zur Ergebnisdatei, die zu mischenden WaveFiles sowie die Überblendezeit erwartet.
Der Funktionscode sieht so aus:

public static void StoreMixWave(string path, WaveFile wf1, WaveFile wf2, int fadeTime)
{
    WaveFile Mixed = MixWave(wf1, wf2, fadeTime); // Ergebnisdatei mischen
    Mixed.StoreWave(path); // Ergebnisdatei auf Festplatte speichern
}

Der Ergebniswavefile wird also in der Funktion MixWave() zusammengemischt, und dieser WaveFile dann mit der bekannten Funktion StoreWave() geschrieben.
Nun betrachten wir direkt einmal den Code der Funktion MixWave():

        private static WaveFile MixWave(WaveFile wf1, WaveFile wf2, int fadeTime)
        {
            int FadeSamples = fadeTime * wf1.ByteRate / wf1.NumChannels; // Anzahl an aus-/ einzublenden Samples
            int FadeBytes = fadeTime * wf1.ByteRate; // Anzahl an aus-/ einzublendenden Bytes

            WaveFile Result = new WaveFile(); // Ergebnis Wave Datei
            Result.FileSize = wf1.FileSize + wf2.DataSize - 2 * FadeBytes; // neue Dateigröße
            Result.Format = "WAVE";

            // Informationen aus dem fmt Chunk übernehmen
            Result.FmtChunkSize = wf1.FmtChunkSize;
            Result.AudioFormat = wf1.AudioFormat;
            Result.NumChannels = wf1.NumChannels;
            Result.SampleRate = wf1.SampleRate;
            Result.ByteRate = wf1.ByteRate;
            Result.BlockAlign = wf1.BlockAlign;
            Result.BitsPerSample = wf1.BitsPerSample;

            Result.DataSize = wf1.DataSize + wf2.DataSize - 2 * FadeBytes; // neue Größe des Data Chunks
            Result.Data = new int[wf1.NumChannels][]; // Anzahl an Kanälen übernehmen
            int NumSamples = Result.DataSize / (Result.NumChannels * ( Result.BitsPerSample / 8)); // Anzahl an Samples ausrechnen, die sich in de Ergebnisdatei ergeben

            // Die Data Arrays für alle Kanäle auf die Anzahl der Samples dimensionieren.
            for (int i = 0; i < Result.Data.Length; i++)
            {
                Result.Data[i] = new int[NumSamples];
            }

            int PosCounter = 0; // Position des aktuellen Samples in der Ergebnisdatei

            // die Samples aus der ersten Wave Datei in das Data Feld der Ergebnisdatei kopieren
            for (int i = 0; i < wf1.Data[0].Length; i++)
            {
                // das aktuelle Sample in allen Kanälen übernehmen
                for (int j = 0; j < wf1.NumChannels; j++)
                {
                    // fällt das aktuelle Sample in die Zeit, die überblendet werden soll, den Amplitudenwert der 1. Datei mit dem Amplitudenwert aus der 2. Datei mischen
                    if (i > wf1.Data[0].Length - FadeSamples)
                       Result.Data[j][PosCounter] = (int)(wf1.Data[j][i] * Factor(i - (wf1.Data[0].Length - FadeSamples), FadeSamples, 0) + wf2.Data[j][i - (wf1.Data[0].Length - FadeSamples)] * Factor(i - (wf1.Data[0].Length - FadeSamples), FadeSamples, 1));
                    else
                       Result.Data[j][PosCounter] = wf1.Data[j][i];
                }
                PosCounter++;
            }

            // die restlichen Samples in die Ergebnisdatei übernehmen
            for (int i = FadeSamples; i < wf2.Data[0].Length; i++)
            {
                for (int j = 0; j < wf1.NumChannels; j++)
                {
                    Result.Data[j][PosCounter] = wf2.Data[j][i];
                }
                PosCounter++;
            }
            return Result;
        }

Am Anfang dieser wird die Anzahl der Samples berechnet, die überblendet werden sollen.
Zur Erinnerung: Eine Wave Datei ist im Groben eine Sammlung von Luftdruckamplituden. Mit einer bestimmten Abtastrate werden in bestimmten Zeitabständen diese Amplituden gemessen und in der Datei gespeichert. Diese einzelnen Werte sind die Samples.
Um 2 Audiodateien übereinander zu legen, kann man z.B. einfach die Amplituden addieren.
Die Anzahl an zu überblenden Samples berechnet sich demnach als Produkt der Überblendzeit in Sekunden und Byterate (Anzahl der Bytes pro Sekunde, die beim Abspielen bearbeitet werden), dividiert durch die Anzahl der Kanäle (da die Byterate die Anzahl an Bytes von allen Kanälen angibt).
Dann werden die Dateien zusammengemischt:
Die ersten beiden Blöcke ("RIFF" und "fmt ") werden einfach aus der ersten übergebenen Wave Datei übernommen - allerdings wird natürlich die Dateigröße angepasst.
Die neue Dateigröße ergibt sich als Summe der alten Dateigrößen, abzüglich 2mal der Anzahl an Bytes, die überblendet werden.
Damit das Ergebnis auch nach etwas klingt, ist hier also Vorraussetzung, dass beide Wave Dateien einen ähnlichen fmt Block haben, sprich die selbe Abtastrate, Anzahl an Kanälen etc. Das ist aber eh der Fall, wenn man die Wave Dateien mit Audacity erstellt.
Das Mischen der Data Chunks gestaltet sich nun als etwas komplizierter.
Zuerst wird die Größe des resultierende Data Blocks berechnet und daraus dann die Anzahl an Samples, die in jedem Kanal gespeichert werden, um das Data Array für alle Kanäle auf diesen Wert zu dimensionieren.
Diese Anzahl berechnet sich laut Spezifikation des Wave Formats laut der Formel (Größe des Data Blocks) / (Anzahl Kanäle * BitsPerSample / 8).
Die Variable PosCounter zählt im folgenden die Position im Data Array der Ergebnisdatei.
Mit 2 Schleifen werden nun alle Samples der ersten Wave Datei durchgegangen und hierbei in jeder Iteration alle Kanäle dieser.
Die entsprechenden Daten aus dem Data Array werden an die Position PosCounter im Data Array der Ergebnisdatei geschrieben.
Hat die Schleife eine Position erreicht, die näher als die Anzahl der zu überblenden Bytes am Ende der ersten Datei liegt, werden nicht einfach die Daten der 1. Datei kopiert, sondern nun mit denen der 2. Datei gemischt.
Damit das 1. Lied langsam ausgeblendet während das 2. langsam eingeblendet wird, bestimmt die Funktion Factor() den Anteil der jeweiligen Lieder an der Gesamtamplitude.
Anschließend werden die noch nicht betrachteten Samples aus der 2. Datei in die Ergebnisdatei übernommen - fertig ist die Mischung!
Schlussendlich noch eine Übersicht über den Code der kompletten Klasse WaveFile, welchen alle 3 Posts zu diesem Thema benutzen:

    public class WaveFile  
    {
        int FileSize; // 1
        string Format; // 2
        int FmtChunkSize; // 3
        int AudioFormat; // 4
        int NumChannels; // 5
        int SampleRate; // 6
        int ByteRate; // 7
        int BlockAlign; // 8
        int BitsPerSample; // 9
        int DataSize; // 10

        int[][] Data; // 11

        #region Einlesen
        public void LoadWave(string path)
        {
            System.IO.FileStream fs = System.IO.File.OpenRead(path); // zu lesende Wave Datei öffnen
            LoadChunk(fs); // RIFF Chunk einlesen
            LoadChunk(fs); // fmt Chunk einlesen
            LoadChunk(fs); // data Chunk einlesen
        }

        private void LoadChunk(System.IO.FileStream fs)
        {
            System.Text.ASCIIEncoding Encoder = new ASCIIEncoding();

            byte[] bChunkID = new byte[4];
            /* Die ersten 4 Bytes einlesen.
            Diese ergeben bei jedem Chunk den jeweiligen Namen. */
            fs.Read(bChunkID, 0, 4);
            string sChunkID = Encoder.GetString(bChunkID); // den Namen aus den Bytes dekodieren

            byte[] ChunkSize = new byte[4];
            /* Die nächsten 4 Bytes ergeben bei jedem Chunk die Größenangabe. */
            fs.Read(ChunkSize, 0, 4);

            if (sChunkID.Equals("RIFF"))
            {
                // beim Riff Chunk ...
                // die Größe in FileSize speichern
                FileSize = System.BitConverter.ToInt32(ChunkSize, 0);
                // das Format einlesen
                byte[] Format = new byte[4];
                fs.Read(Format, 0, 4);
                // ergibt "WAVE" als String
                this.Format = Encoder.GetString(Format);
            }

            if (sChunkID.Equals("fmt "))
            {
                // beim fmt Chunk die Größe in FmtChunkSize speichern
                FmtChunkSize = System.BitConverter.ToInt32(ChunkSize, 0);
                // sowie die anderen Informationen auslesen und speichern
                byte[] AudioFormat = new byte[2];
                fs.Read(AudioFormat, 0, 2);
                this.AudioFormat = System.BitConverter.ToInt16(AudioFormat, 0);
                byte[] NumChannels = new byte[2];
                fs.Read(NumChannels, 0, 2);
                this.NumChannels = System.BitConverter.ToInt16(NumChannels, 0);
                byte[] SampleRate = new byte[4];
                fs.Read(SampleRate, 0, 4);
                this.SampleRate = System.BitConverter.ToInt32(SampleRate, 0);
                byte[] ByteRate = new byte[4];
                fs.Read(ByteRate, 0, 4);
                this.ByteRate = System.BitConverter.ToInt32(ByteRate, 0);
                byte[] BlockAlign = new byte[2];
                fs.Read(BlockAlign, 0, 2);
                this.BlockAlign = System.BitConverter.ToInt16(BlockAlign, 0);
                byte[] BitsPerSample = new byte[2];
                fs.Read(BitsPerSample, 0, 2);
                this.BitsPerSample = System.BitConverter.ToInt16(BitsPerSample, 0);
            }

            if (sChunkID == "data")
            {
                // beim data Chunk die Größenangabe in DataSize speichern
                DataSize = System.BitConverter.ToInt32(ChunkSize, 0);

                // der 1. Index von Data spezifiziert den Audiokanal, der 2. das Sample
                Data = new int[this.NumChannels][];
                // Temporäres Array zum Einlesen der jeweiligen Bytes eines Kanals pro Sample
                byte[] temp = new byte[BlockAlign / NumChannels];
                // für jeden Kanal das Data Array auf die Anzahl der Samples dimensionalisieren
                for (int i = 0; i < this.NumChannels; i++)
                {
                    Data[i] = new int[this.DataSize / (NumChannels * BitsPerSample / 8)];
                }

                // nacheinander alle Samples durchgehen
                for (int i = 0; i < Data[0].Length; i++)
                {
                    // alle Audiokanäle pro Sample durchgehen
                    for (int j = 0; j < NumChannels; j++)
                    {
                        // die jeweils genutze Anzahl an Bytes pro Sample und Kanal einlesen
                        if (fs.Read(temp, 0, BlockAlign / NumChannels) > 0)
                        {   // je nachdem, wie viele Bytes für einen Wert genutzt werden,
                            // die Amplitude als Int16 oder Int32 interpretieren
                            if (BlockAlign / NumChannels == 2)
                                Data[j][i] = System.BitConverter.ToInt16(temp, 0);
                            else
                                Data[j][i] = System.BitConverter.ToInt32(temp, 0);
                        }
                        /* else
                         * andere Werte als 2 oder 4 werden nicht behandelt, hier bei Bedarf ergänzen!
                        */
                    }
                }
            }
        }
        #endregion

        #region Schreiben
        public void StoreWave(string path)
        {
            System.IO.FileStream fs = System.IO.File.OpenWrite(path); // zu schreiben Wave Datei öffnen / erstellen
            StoreChunk(fs, "RIFF"); // RIFF Chunk schreiben
            StoreChunk(fs, "fmt "); // fmt Chunk schreiben
            StoreChunk(fs, "data"); // data Chunk schreiben
        }

        private void StoreChunk(System.IO.FileStream fs, string chunkID)
        {
            System.Text.ASCIIEncoding Decoder = new ASCIIEncoding();
            // den Namen in Bytes konvertieren und schreiben
            fs.Write(Decoder.GetBytes(chunkID), 0, 4);

            if (chunkID == "RIFF")
            {
                // im RIFF Chunk, FileSize als Größe und das Audioformat schreiben
                fs.Write(System.BitConverter.GetBytes(FileSize), 0, 4);
                fs.Write(Decoder.GetBytes(Format), 0, 4);
            }
            if (chunkID == "fmt ")
            {
                // beim fmt Chunk die Größe dieses sowie die weiteren kodierten Informationen schreiben
                fs.Write(System.BitConverter.GetBytes(FmtChunkSize), 0, 4);
                fs.Write(System.BitConverter.GetBytes(AudioFormat), 0, 2);
                fs.Write(System.BitConverter.GetBytes(NumChannels), 0, 2);
                fs.Write(System.BitConverter.GetBytes(SampleRate), 0, 4);
                fs.Write(System.BitConverter.GetBytes(ByteRate), 0, 4);
                fs.Write(System.BitConverter.GetBytes(BlockAlign), 0, 2);
                fs.Write(System.BitConverter.GetBytes(BitsPerSample), 0, 2);
            }
            if (chunkID == "data")
            {
                // beim data Chunk die Größe des Datenblocks als Größenangabe schreiben
                fs.Write(System.BitConverter.GetBytes(DataSize), 0, 4);
                // dann die einzelnen Amplituden, wie beschrieben Sample für Sample mit jeweils allen
                // Audiospuren, schreiben
                for (int i = 0; i < Data[0].Length; i++)
                {
                    for (int j = 0; j < NumChannels; j++)
                    {
                        fs.Write(System.BitConverter.GetBytes(Data[j][i]), 0, BlockAlign / NumChannels);
                    }
                }
            }
        }
        #endregion

        #region Mischen
        private static WaveFile MixWave(WaveFile wf1, WaveFile wf2, int fadeTime)
        {
            int FadeSamples = fadeTime * wf1.ByteRate / wf1.NumChannels; // Anzahl an aus-/ einzublenden Samples
            int FadeBytes = fadeTime * wf1.ByteRate; // Anzahl an aus-/ einzublendenden Bytes

            WaveFile Result = new WaveFile(); // Ergebnis Wave Datei
            Result.FileSize = wf1.FileSize + wf2.DataSize - 2 * FadeBytes; // neue Dateigröße
            Result.Format = "WAVE";

            // Informationen aus dem fmt Chunk übernehmen
            Result.FmtChunkSize = wf1.FmtChunkSize;
            Result.AudioFormat = wf1.AudioFormat;
            Result.NumChannels = wf1.NumChannels;
            Result.SampleRate = wf1.SampleRate;
            Result.ByteRate = wf1.ByteRate;
            Result.BlockAlign = wf1.BlockAlign;
            Result.BitsPerSample = wf1.BitsPerSample;

            Result.DataSize = wf1.DataSize + wf2.DataSize - 2 * FadeBytes; // neue Größe des Data Chunks
            Result.Data = new int[wf1.NumChannels][]; // Anzahl an Kanälen übernehmen
            int NumSamples = Result.DataSize / (Result.NumChannels * ( Result.BitsPerSample / 8)); // Anzahl an Samples ausrechnen, die sich in de Ergebnisdatei ergeben

            // Die Data Arrays für alle Kanäle auf die Anzahl der Samples dimensionieren.
            for (int i = 0; i < Result.Data.Length; i++)
            {
                Result.Data[i] = new int[NumSamples];
            }

            int PosCounter = 0; // Position des aktuellen Samples in der Ergebnisdatei

            // die Samples aus der ersten Wave Datei in das Data Feld der Ergebnisdatei kopieren
            for (int i = 0; i < wf1.Data[0].Length; i++)
            {
                // das aktuelle Sample in allen Kanälen übernehmen
                for (int j = 0; j < wf1.NumChannels; j++)
                {
                    // fällt das aktuelle Sample in die Zeit, die überblendet werden soll, den Amplitudenwert der 1. Datei mit dem Amplitudenwert aus der 2. Datei mischen
                    if (i > wf1.Data[0].Length - FadeSamples)
                       Result.Data[j][PosCounter] = (int)(wf1.Data[j][i] * Factor(i - (wf1.Data[0].Length - FadeSamples), FadeSamples, 0) + wf2.Data[j][i - (wf1.Data[0].Length - FadeSamples)] * Factor(i - (wf1.Data[0].Length - FadeSamples), FadeSamples, 1));
                    else
                       Result.Data[j][PosCounter] = wf1.Data[j][i];
                }
                PosCounter++;
            }

            // die restlichen Samples in die Ergebnisdatei übernehmen
            for (int i = FadeSamples; i < wf2.Data[0].Length; i++)
            {
                for (int j = 0; j < wf1.NumChannels; j++)
                {
                    Result.Data[j][PosCounter] = wf2.Data[j][i];
                }
                PosCounter++;
            }
            return Result;
        }
        
        /// <summary>
       /// Diese Funktion dient zur Berechnung der Gewichtung der Amplituden bei Übermischung.
        /// </summary>
        /// <param name="pos">Position in Datei relativ zum Anfang der Überblendezeit</param>
        /// <param name="max">Ende der Überblendung, relativ zu pos</param>
        /// <param name="song">Kann die Werte 0 (auszublendender Song) oder 1 (einzublender Song) annehmen</param>
        /// <returns></returns>
        private static double Factor(int pos, int max, int song)
        {
            if (song == 0)
                return 1 - Math.Pow((double)pos / (double)max, 2);
            else
                return Math.Pow((double)pos / (double)max, 2);
        }

        public static void StoreMixWave(string path, WaveFile wf1, WaveFile wf2, int fadeTime)
        {
            WaveFile Mixed = MixWave(wf1, wf2, fadeTime); // Ergebnisdatei mischen
            Mixed.StoreWave(path); // Ergebnisdatei auf Festplatte speichern
        }

        #endregion
    }

Keine Kommentare:

Kommentar veröffentlichen