A while ago I blogged about how to display audio waveforms in WinForms. Porting this code to WPF is not as simple as it may first appear. The rendering models are quite different. In Windows Forms, you can draw points or lines using GDI functions. You are responsible for drawing them all every time the window is invalidated and its Paint method is called. However, in WPF, you create objects to put on a Canvas, and WPF manages the rendering and invalidation.
My first attempt was to stay as close to the WinForms model as I could. I have a sample aggregator that looks for the highest sample value over a short period of time. It then uses that to calculate the height of the line it should draw. Every time we calculate a new one, we add a line to our Canvas at the next X position, wrapping round if necessary, and deleting any old lines.
As can be seen, this gives a reasonable waveform display. I made use of a LinearGradientBrush to try to improve the visual effect a little (although this requires we cheat and keep the waveform symmetrical). There is a big performance problem however - it is very inefficient to keep throwing new lines onto a Canvas and removing old ones. The solution was to start re-using lines once we had wrapped past the right-hand edge of the display.
private Line CreateLine(float value) { Line line; if (renderPosition >= lines.Count) { line = new Line(); lines.Add(line); mainCanvas.Children.Add(line); } else { line = lines[renderPosition]; } line.Stroke = this.Foreground; line.X1 = renderPosition; line.X2 = renderPosition; line.Y1 = yTranslate + -value * yScale; line.Y2 = yTranslate + value * yScale; renderPosition++; line.Visibility = Visibility.Visible; return line; }
This solves our performance issue, but I still wasn’t too happy with the visual effect – it is too obviously composed of vertical lines. I tried a second approach. This added two instances of PolyLine to the canvas. Now, we would add a point to each line when a new maximum sample was created. Again the same trick of re-using points when we had scrolled off the right-hand edge was used for performance reasons.
As nice as this approach is, there is an obvious problem that we are not able to render the bit in between the top and bottom lines. This requires a Polygon. However, we can’t just add new points to the end of the Polygon’s Points collection. We need all of the top line points first, followed by all of the bottom line points in reverse order if we are to create a shape.
The trick is that when we get a new sample maximum and minimum in, we have to insert those values into the middle of the existing Points collection, or calculate the position in the points array. Notice that I create a new Point object every time to make sure that the Polygon is invalidated correctly.
private int Points { get { return waveForm.Points.Count / 2; } } public void AddValue(float maxValue, float minValue) { int visiblePixels = (int)(ActualWidth / xScale); if (visiblePixels > 0) { CreatePoint(maxValue, minValue); if (renderPosition > visiblePixels) { renderPosition = 0; } int erasePosition = (renderPosition + blankZone) % visiblePixels; if (erasePosition < Points) { double yPos = SampleToYPosition(0); waveForm.Points[erasePosition] = new Point(erasePosition * xScale, yPos); waveForm.Points[BottomPointIndex(erasePosition)] = new Point(erasePosition * xScale, yPos); } } } private int BottomPointIndex(int position) { return waveForm.Points.Count - position - 1; } private double SampleToYPosition(float value) { return yTranslate + value * yScale; } private void CreatePoint(float topValue, float bottomValue) { double topYPos = SampleToYPosition(topValue); double bottomYPos = SampleToYPosition(bottomValue); double xPos = renderPosition * xScale; if (renderPosition >= Points) { int insertPos = Points; waveForm.Points.Insert(insertPos, new Point(xPos, topYPos)); waveForm.Points.Insert(insertPos + 1, new Point(xPos, bottomYPos)); } else { waveForm.Points[renderPosition] = new Point(xPos, topYPos); waveForm.Points[BottomPointIndex(renderPosition)] = new Point(xPos, bottomYPos); } renderPosition++; }
This means that our minimum and maximum lines join together to create a shape, and we can fill in the middle bit.
Now we are a lot closer to the visual effect I am looking for, but it is still looking a bit spiky. To smooth the edges, I decided to only add one point every two pixels instead of every one:
This tidies up the edges considerably. You can take this a step further and have a point every third pixel, but this highlights another problem – that our polygons have sharp corners as they draw straight lines between each point. The next step would be to try out using Bezier curves, although I am not sure what the performance implications of that would be. Maybe that is a subject for a future post.
The code for these waveforms will be made available in NAudio in the near future.