Electronics Tinkerer
Projects 'N Stuff

Lecture 4: Direct Digital Sound Synthesis

Last Update: 2023-11-25 So far, we have discussed counters, state machines, and SPI. Now we will take all of these topics and combine them into a rudimentary sound synthesizer. The completed demo is available here: dds_demp.zip

Waveforms

Direct digital synthesis (DDS) is a method of generating different waveforms with controllable frequency. Typical waveforms which are “easy” to generate include square, triangle, saw, sine, and noise waveforms. For this basic tutorial, we will discuss square, triangle, and saw waves since they can be easily created with bit manipulation of a counter. A question I have received previously is “does the shape of the waveform affect it’s sound?” And to similar effect, “is the sound just the frequency of the waveform?” The short answer is that, yes, the waveform’s shape affects the sound, but this is because a complex waveform can be broken down into many sine waveforms with a wide variety of amplitudes, phases, and frequencies. Without getting too math heavy, we will call a sine wave (Figure 1) a “pure” tone. Other sounds are just built by adding more sine waves to a sine wave that is at the “fundamental” frequency. I usually don’t link to other websites since links could die, but this page does a really nice job showing these basic waveforms and their frequency components: thewolfsound.com Basic Waveforms in Synthesis

The Phase Accumulator

The easiest way to divide down a frequency is with a counter. If you recall, the binary counters that we designed earlier are effectively frequency dividers. Each bit toggled at half the rate of the previous (e.g., bit 0 toggled at half the input clock rate, while bit 1 toggled at one quarter the input clock rate). While the binary counter only generates frequency divisions by powers of 2, we can use it in a different way to generate a much wider range of frequencies. Figure 1 shows the binary counter from earlier. It consists of a register to store the current count value and an adder to increment the value (by adding 1). There are of course more efficient circuits to create a count-by-1 binary counter, but for our purposes, this structure is rather useful. Take a moment to think about the figure. Is there anything that we could change to affect the output frequency of the counter? Binary counter block diagram. There is a register which feeds into an adder, which adds 1 to the vale and stores that back into the register. Figure 1: Binary counter Can the input frequency be changed? What about the number that is added each iteration? Instead of reading a single output from the counter, what if the entire value was taken as-is? It turns out that all three of these ideas can be used. First, let’s start by using the entire count value as the output. Suddenly, the output waveform is now very slow since it takes 2**n clock cycles for the counter to roll over and repeat. (n is the width of the counter). One solution to this is of course to increase the input clock rate. For the FPGAs that we are using, 50MHz for the input clock is very reasonable. Figure 2 shows the output of this. Binary counter output, showing the full count value plotted against time. Figure 2: Binary counter output value versus time The binary counter generates a sawtooth waveform as it counts. When the value rolls over to 0, there is a sharp falling edge. Now for that last question: modify the counter to count be some other constant than 1. If the amount to add each cycle is stored in a second register, the circuit looks like Figure 3. The count waveform looks like Figure 4. Binary counter with an extra register to store a tuning word. Figure 3: The Phase Accumulator Phase accumulator output, showing the full count value plotted against time. Figure 4: Phase accumulator output value versus time This is the phase accumulator; it is a circuit which allows for external control of the rate at which it increments. Figure 4 shows the phase accumulator with a tuning word (the amount to be added each cycle) of 2 compared to the default value of 1.

Bit Tricks

To generate other waveforms from this sawtooth count output, there are a few tricks that can be played to minimize the amount of hardware needed. First, let’s focus on the square wave. This is very easy; just replicate the MSb of the counter. Reversing the sawtooth horizontally (on the plot) is also trivial; just invert all the bits. This is a neat binary trick: if you have a counter which only counts up but you need it to count down, inverting the count’s value will cause it to count in reverse. 3-bit binary values showing up and down counting sequences. Figure 5: Inverting the count bits reverses the count direction! Finally, is the triangle wave. Making use of the two above tricks, we can also generate a triangle waveform easily. The first half of every waveform cycle is just an up counter while the second half is a down counter. To select between the count directions, just use the MSb. The remaining bits of the PA’s output are then used as inputs to a multiplexer with the raw count bits connected to one input, and the inverted bits connected to the other input. Conveniently the XOR gate preforms this mux-to-inverted operation. Input A is the raw value and input B is the “invert” signal. Schematic showing the different waveform generation circuits Figure 6: Combined waveform generation circuits

Verilog

The Verilog for the PA starts off similarly to the binary counter. The PA just adds the “tuning_word” value each clock cycle. Note that the tuning word’s width is much smaller than the width of the phase accumulator. This sets a lower limit on the frequency at which the PA can oscillate. The mux to select a waveform and the waveform bit manipulation are at the end in the combinational “always” block.
module dds
  #(
    parameter PA_WIDTH = 24,
    parameter TUNE_WIDTH = 16,
    parameter OUTPUT_WIDTH = 8
  )
  (
   input sys_clk,
   input [TUNE_WIDTH-1:0] tuning_word,
   input [1:0] wave_sel,
   output reg [OUTPUT_WIDTH-1:0] wave_out
  );

   localparam                WAVE_SQUARE = 2'b00,
                             WAVE_TRI    = 2'b01,
                             WAVE_SAWL   = 2'b10,
                             WAVE_SAWR   = 2'b11;

   reg [PA_WIDTH-1:0]        pa;

   always @(posedge sys_clk) begin
      pa <= pa + { {(PA_WIDTH - TUNE_WIDTH){1'b0}}, tuning_word };
   end

   // Generate waveforms from the PA value
   always @(*) begin
      case (wave_sel)
        WAVE_SQUARE : wave_out = { OUTPUT_WIDTH{pa[TUNE_WIDTH-1]} };
        WAVE_TRI    : wave_out = { OUTPUT_WIDTH{pa[TUNE_WIDTH-1]} } ^ pa[TUNE_WIDTH-2:TUNE_WIDTH-OUTPUT_WIDTH-1];
        WAVE_SAWL   : wave_out = ~pa[TUNE_WIDTH-1:TUNE_WIDTH-OUTPUT_WIDTH];
        WAVE_SAWR   : wave_out = pa[TUNE_WIDTH-1:TUNE_WIDTH-OUTPUT_WIDTH];
      endcase
   end
   
endmodule
Listing 1: Complete PA module SAWL and SAWR are the two sawtooth waveforms. SAWL has a fast-rising transition and the SAWR has a fast-falling transition. TRI is the triangle waveform. It uses the topmost bit and XORs it with the next lower bits of the PA’s value. Finally, SQUARE is simply a replication of the top bit of the PA’s value.

Analog Output

[TODO: PWM generation, RC lowpass filtering, Verilog model] Try out the demo code posted at the top of this page to see an example of how one might control the DDS design using a sensor for input.
That's all!
© 2021-2024 — Electronics Tinkerer — All rights reserved.
Top | Home | Privacy | Terms of Service