1

I am attempting to extract the audio content of a wav file and export the resultant waveform as an image (bmp/jpg/png).

So I have found the following code which draws a sine wave and works as expected:

    string filename = @"C:\0\test.bmp";
    int width = 640;
    int height = 480;
    Bitmap b = new Bitmap(width, height);

    for (int i = 0; i < width; i++)
    {
        int y = (int)((Math.Sin((double)i * 2.0 * Math.PI / width) + 1.0) * (height - 1) / 2.0);
        b.SetPixel(i, y, Color.Black);
    }
    b.Save(filename);

This works completely as expected, what I would like to do is replace

int y = (int)((Math.Sin((double)i * 2.0 * Math.PI / width) + 1.0) * (height - 1) / 2.0);

with something like

int y = converted and scaled float from monoWaveFileFloatValues

So how would I best go about doing this in the simplest manner possible?

I have 2 basic issues I need to deal with (i think)

  1. convert float to int in a way which does not loose information, this is due to SetPixel(i, y, Color.Black); where x & y are both int
  2. sample skipping on the x axis so the waveform fits into the defined space audio length / image width give the number of samples to average out intensity over which would be represented by a single pixel

The other options is find another method of plotting the waveform which does not rely on the method noted above. Using a chart might be a good method, but I would like to be able to render the image directly if possible

This is all to be run from a console application and I have the audio data (minus the header) already in a float array.


UPDATE 1

The following code enabled me to draw the required output using System.Windows.Forms.DataVisualization.Charting but it took about 30 seconds to process 27776 samples and whilst it does do what I need, it is far too slow to be useful. So I am still looking towards a solution which will draw the bitmap directly.

    System.Windows.Forms.DataVisualization.Charting.Chart chart = new System.Windows.Forms.DataVisualization.Charting.Chart();
    chart.Size = new System.Drawing.Size(640, 320);
    chart.ChartAreas.Add("ChartArea1");
    chart.Legends.Add("legend1");

    // Plot {sin(x), 0, 2pi} 
    chart.Series.Add("sin");
    chart.Series["sin"].LegendText = args[0];
    chart.Series["sin"].ChartType = System.Windows.Forms.DataVisualization.Charting.SeriesChartType.Spline;

    //for (double x = 0; x < 2 * Math.PI; x += 0.01)
    for (int x = 0; x < audioDataLength; x ++)
    {
        //chart.Series["sin"].Points.AddXY(x, Math.Sin(x));
        chart.Series["sin"].Points.AddXY(x, leftChannel[x]);
    }

    // Save sin_0_2pi.png image file
    chart.SaveImage(@"c:\tmp\example.png", System.Drawing.Imaging.ImageFormat.Png);

Output shown below: enter image description here

Community
  • 1
  • 1
Majickal
  • 176
  • 2
  • 16
  • So do you have any code to read the audio file? Evalauate the headers and then go over the data? This should be your start; the drawing comes only after.. And, no, for the number of points to plot a chart is not really such a good idea, imo – TaW Sep 20 '16 at 23:16
  • @TaW - "I have the audio data (minus the header) already in a float array." So I am looking for the next step. – Majickal Sep 20 '16 at 23:45

2 Answers2

2

So I managed to figure it out using a code sample found here, though I made some minor changes to the way I interact with it.

public static Bitmap DrawNormalizedAudio(List<float> data, Color foreColor, Color backColor, Size imageSize, string imageFilename)
{
    Bitmap bmp = new Bitmap(imageSize.Width, imageSize.Height);

    int BORDER_WIDTH = 0;
    float width = bmp.Width - (2 * BORDER_WIDTH);
    float height = bmp.Height - (2 * BORDER_WIDTH);

    using (Graphics g = Graphics.FromImage(bmp))
    {
        g.Clear(backColor);
        Pen pen = new Pen(foreColor);
        float size = data.Count;
        for (float iPixel = 0; iPixel < width; iPixel += 1)
        {
            // determine start and end points within WAV
            int start = (int)(iPixel * (size / width));
            int end = (int)((iPixel + 1) * (size / width));
            if (end > data.Count)
                end = data.Count;

            float posAvg, negAvg;
            averages(data, start, end, out posAvg, out negAvg);

            float yMax = BORDER_WIDTH + height - ((posAvg + 1) * .5f * height);
            float yMin = BORDER_WIDTH + height - ((negAvg + 1) * .5f * height);

            g.DrawLine(pen, iPixel + BORDER_WIDTH, yMax, iPixel + BORDER_WIDTH, yMin);
        }
    }
    bmp.Save(imageFilename);
    bmp.Dispose();
    return null;
}


private static void averages(List<float> data, int startIndex, int endIndex, out float posAvg, out float negAvg)
{
    posAvg = 0.0f;
    negAvg = 0.0f;

    int posCount = 0, negCount = 0;

    for (int i = startIndex; i < endIndex; i++)
    {
        if (data[i] > 0)
        {
            posCount++;
            posAvg += data[i];
        }
        else
        {
            negCount++;
            negAvg += data[i];
        }
    }

    if (posCount > 0)
       posAvg /= posCount;
    if (negCount > 0)
       negAvg /= negCount;
}

In order to get it working I had to do a couple of things prior to calling the method DrawNormalizedAudio you can see below what I needed to do:

    Size imageSize = new Size();
    imageSize.Width = 1000;
    imageSize.Height = 500;
    List<float> lst = leftChannel.OfType<float>().ToList(); //change float array to float list - see link below
    DrawNormalizedAudio(lst, Color.Red, Color.White, imageSize, @"c:\tmp\example2.png");

* change float array to float list

The result of this is as follows, a waveform representation of a hand clap wav sample: enter image description here

I am quite sure there needs to be some updates/revisions to the code, but it's a start and hopefully this will assist someone else who is trying to do the same thing I was.

If you can see any improvements that can be made, let me know.


UPDATES

  1. NaN issue mentioned in the comments now resolved and code above updated.
  2. Waveform Image updated to represent output fixed by removal of NaN values as noted in point 1.

UPDATE 1

Average level (not RMS) was determined by summing the max level for each sample point and dividing by the total number of samples. Examples of this can be seen below:

Silent Wav File: enter image description here

Hand Clap Wav File: enter image description here

Brownian, Pink & White Noise Wav File: enter image description here

Community
  • 1
  • 1
Majickal
  • 176
  • 2
  • 16
  • 1
    Glad you got this far! You can use the DrawLines method to draw the lines in one go (recommended for speed & quality) if you first create a List: `var points = data.ToList().Select((y, x) => new { x, y }).Select(p => new PointF(p.x, p.y)).ToList();`. You also may want to play with using `Graphics.ScaleTranform` to do all the scaling instead of scaling all the coordinates. Something like `g.ScaleTransform(0.1f, 0.1f);`(or less) would be a start, but you should calculate using `var xScale = (data.Max() - data.Min()) / imageSize.Width;`etc. Also: You need to `Dispose()` of the Bitmap! – TaW Sep 21 '16 at 04:29
  • @TaW The first part of what you mentioned I will have to look into along with the scaling, in more detail to ensure I understand the process. I'll update my code with the Dispose() now. I am getting NaN values which I need to deal with before I do anything further though. Not sure why yet, but will also update code once I figure that out. – Majickal Sep 21 '16 at 10:03
  • Not sure if I understand the average level lines. Nor if removing NaN (i.r. overflow) data points is the correct way to treat those values. Could it be that they miss in the summing but count in the divider thus shifting the average ? Why does anything overflow in the first place? – TaW Sep 23 '16 at 05:21
1

Here is a variation you may want to study. It scales the Graphics object so it can use the float data directly.

Note how I translate (i.e. move) the drawing area twice so I can do the drawing more conveniently!

It also uses the DrawLines method for drawing. The benefit in addition to speed is that the lines may be semi-transparent or thicker than one pixel without getting artifacts at the joints. You can see the center line shine through.

To do this I convert the float data to a List<PointF> using a little Linq magick.

I also make sure to put all GDI+ objects I create in using clause so they will get disposed of properly.

enter image description here

...
using System.Windows.Forms;
using System.IO;
using System.Drawing;
using System.Drawing.Imaging;
using System.Drawing.Drawing2D;
..
..
class Program
{
    static void Main(string[] args)
    {
        float[] data = initData(10000);
        Size imgSize = new Size(1000, 400);
        Bitmap bmp = drawGraph(data, imgSize , Color.Green, Color.Black);
        bmp.Save("D:\\wave.png", ImageFormat.Png);
    }

    static float[] initData(int count)
    {
        float[] data = new float[count];

        for (int i = 0; i < count; i++ )
        {
            data[i] = (float) ((Math.Sin(i / 12f) * 880 + Math.Sin(i / 15f) * 440
                              + Math.Sin(i / 66) * 110) / Math.Pow( (i+1), 0.33f));
        }
        return data;
    }

    static Bitmap drawGraph(float[] data, Size size, Color ForeColor, Color BackColor)
    {
        Bitmap bmp = new System.Drawing.Bitmap(size.Width, size.Height, 
                                PixelFormat.Format32bppArgb);
        Padding borders = new Padding(20, 20, 10, 50);
        Rectangle plotArea = new Rectangle(borders.Left, borders.Top,
                       size.Width - borders.Left - borders.Right, 
                       size.Height - borders.Top - borders.Bottom);
        using (Graphics g = Graphics.FromImage(bmp))
        using (Pen pen = new Pen(Color.FromArgb(224, ForeColor),1.75f))
        {
            g.SmoothingMode = SmoothingMode.AntiAlias;
            g.Clear(Color.Silver);
            using (SolidBrush brush = new SolidBrush(BackColor))
                g.FillRectangle(brush, plotArea);
            g.DrawRectangle(Pens.LightGoldenrodYellow, plotArea);

            g.TranslateTransform(plotArea.Left, plotArea.Top);

            g.DrawLine(Pens.White, 0, plotArea.Height / 2,
                   plotArea.Width,  plotArea.Height / 2);


            float dataHeight = Math.Max( data.Max(), - data.Min()) * 2;
            float yScale = 1f * plotArea.Height / dataHeight;
            float xScale = 1f * plotArea.Width / data.Length;


            g.ScaleTransform(xScale, yScale);
            g.TranslateTransform(0, dataHeight / 2);

            var points = data.ToList().Select((y, x) => new { x, y })
                             .Select(p => new PointF(p.x, p.y)).ToList();

            g.DrawLines(pen, points.ToArray());

            g.ResetTransform();
            g.DrawString(data.Length.ToString("###,###,###,##0") + " points plotted.", 
                new Font("Consolas", 14f), Brushes.Black, 
                plotArea.Left, plotArea.Bottom + 2f);
        }
        return bmp;
    }
}
TaW
  • 53,122
  • 8
  • 69
  • 111
  • I like what you have done here @Taw! I will definitely have a look at this in more detail, thank you! Plotting floats directly is a great idea and one I was able to do only when using the charting method, which was really slow, so this will be good to work through. – Majickal Sep 22 '16 at 23:31
  • This looks great and I was excited to try it but.. It looks like these examples are on small pieces of audio. I tried it on a 78 Minute wave file and it never finished. It chokes on g.DrawLines(pen, points.ToArray()); My wave data is 83 million points; – Tim Davis May 08 '20 at 07:35
  • I am not surpised. 80M+ points is way beyond what you could visualize; in fact it may run into precision barriers of the gdi+ math. – TaW May 08 '20 at 08:23
  • I did get it to work using a variation of majikais code above. It does not cause any error because the file is being read from a stream and the draw lines call is happening sequentially. It takes about 3 seconds on a core i7 and 80 minute wave file. Also for bsckground you can use Color.Transparent – Tim Davis Jun 19 '20 at 03:35