Echo

This example shows how to use PyGears to implement a hardware module that applies echo audio effect to a continuous audio stream. For a more detailed explanation of PyGears features used in this example, you can checkout a quick introduction to PyGears.

The hardware module is part of the pygears_dsp library and is defined in pygears_dsp/examples/echo/echo.py. Its block diagram is given below. You can checkout the functional description of the echo module. In-depth explanation of the PyGears description of the echo model given in hardware description chapter. PyGears takes the Python module description and compiles it to SystemVerilog which is listed below.

_images/echo.png

SystemVerilog Generation

Run the script pygears_dsp/examples/echo/echo_svgen.py in order to let PyGears generate SystemVerilog files.

cd <pygears_dsp_source_dir>/pygears_dsp/examples/echo
python echo_svgen.py

If you have Vivado installed (you can download a free WebPack version from Xilinx website), the script will automatically try to synthesize the design and display the resource utilization report (displayed also below).

Running Simulation

Run the script pygears_dsp/examples/echo/plop_test_wav_echo_sim.py to run a simulation of the echo module. The simulation is run with the help of the Verilator tool, so please checkout the installation instructions if you need help with installing the Verilator. Furthermore, if you would like to see plots of the audio waves, you need to install matplotlib. You can run the echo example like this:

cd <pygears_source_dir>/examples/echo
python plop_test_wav_echo_sim.py

Upon starting the script, the following info should be displayed:

Audio file "plop.wav":

    Channels     : 2
    Framerate    : 48000
    Sample width : 2 Bytes
    Sample num   : 165359

-  [INFO]: Running sim with seed: ...
0 /echo [INFO]: Verilating...
0 /echo [WARNING]: Verilator compiled with warnings. Please inspect "..."
0 /echo [INFO]: Verilator VCD dump to "..."
0 /echo [INFO]: Done
0  [INFO]: -------------- Simulation start --------------
165459  [INFO]: ----------- Simulation done ---------------
165459  [INFO]: Elapsed: 31.78
Result length: 165359

Upon completion, the resulting wave will be saved in the file build/plop_echo.wav. If you installed matplotlib, the plots of the original and the resulting audio waves should be displayed.

_images/echo_plot.png

Simulation log will display path to the simulation wave file in standard VCD. Wave can be viewed for an example with an open-source tool GTKWave.

_images/echo_vcd.png

You can now play with the parameters in plop_test_wav_echo_sim.py script. Try changing echo delay and gain settings and check the results.

Stereo Echo

PyGears lets you easily compose gears at any level. To create a stereo echo effect gear, we will instantiate one echo gear for each channel.

_images/stereo_echo.png

In PyGears this can be described as follows:

@alternative(echo)
@gear
def stereo_echo(
        din: Tuple[Fixp, Fixp],  # audio samples
        *,
        feedback_gain,  # feedback gain == echo gain
        sample_rate,  # sample_rate in samples per second
        delay  # delay in seconds
):

    mono_echo = echo(feedback_gain=feedback_gain,
                     sample_rate=sample_rate,
                     delay=delay)

    return din | tuplemap(f=(mono_echo, mono_echo))

The input interface din of the stereo_echo module, needs to carry two samples, one for each channel. This can be represented as a Tuple data type. If the samples are 16 bits wide, the stereo data should be of the type Tuple[Int[16], Int[16]], which can be displayed more succinctly as (i16, 116). Other parameters of the stereo_echo gear have the same meaning as the echo gear parameters.

First, a version of echo gear is created, with some of its parameters supplied/set. This is akin to the partial function application:

mono_echo = echo(feedback_gain=feedback_gain,
                 sample_rate=sample_rate,
                 delay=delay)

The mono_echo variable now points to the echo gear, but also carries the information about parameter settings for feedback_gain, sample_rate and delay. Even though the echo function seems to be called, it will not be instantiated at this moment. The reason is that the input interface din was not connected, i.e. it has not been supplied as a parameter. PyGears will instantiate a gear only when all of its input interfaces are supplied.

Next, the input interface din is connected to the two echo gears. For this we will rely on tuplemap to split the data from din into two components, feed each of the components to the individual echo gear, and then combine the result. In more functional terms, tuplemap applies the echo functions to each item of the din data tuple. Checkout a short presentation of useful functors used in PyGears.

return din | tuplemap(f=(mono_echo, mono_echo))

The output interface of the tuplemap gear will also be output interface of the stereo_echo gear.

You can run the cosimulation of the stereo design by setting stereo=True in examples/echo/plop_test_wav_echo_sim.py.

Functional description

The echo module operates as follows: audio samples arrive at the echo module input din, echo is added and the resulting samples are output to the module output dout. In PyGears terms this is a single-input, single-output gear (function), with the following declaration:

As you can see the echo gear has few more parameters besides din: feedback_gain, sample_rate and delay. These are declared after the ‘*’ symbol, which makes them keyword-only arguments in Python, which in turn makes them compile-time parameters in PyGears. These are akin to HDL parameters or generics.

Notice that the din argument has also a type associated with it, namely a template Int['W'] which represents signed integers of arbitrary width. Take a look at the short explanation on how types are used in PyGears. Int type is generic in the number of bits, hence echo gear can work on samples of arbitrary width. The actual width of the input will be set by echo gear parent, i.e the module that instantiates echo:

def wav_echo_sim(ifn,
                 ofn,
                 stereo=True,
                 cosim=True,
                 sample_rng=None,
                 feedback_gain=0.6,
                 delay=0.25):
    """Applies echo effect on a WAV file using Verilator cosimulation

    ifn - Input WAV file name
    ofn - Output WAV file name
    """

    samples_all, params = wav_utils.load_wav(ifn, stereo=stereo)
    samples = samples_all[:sample_rng]

    sample_bit_width = 8 * params.sampwidth
    sample_type = Fixp[1, sample_bit_width]

    if stereo:
        stream_type = Tuple[sample_type, sample_type]

        def decode_sample(seq):
            for s in seq:
                yield stream_type(
                    (sample_type.decode(s[0]), sample_type.decode(s[1])))
    else:
        stream_type = sample_type

        def decode_sample(seq):
            for s in seq:
                yield sample_type.decode(s)

    result = []
    drv(t=stream_type, seq=decode_sample(samples)) \
        | echo(feedback_gain=feedback_gain,
               sample_rate=params.framerate,
               delay=delay,
               sim_cls=SimVerilated if cosim else None) \
        | collect(result=result, samples_num=len(samples))

    sim(resdir='./build')

    wav_utils.dump_wav(ofn, result, params, stereo=stereo)

    try:
        wav_utils.plot_wavs(samples, result, stereo=stereo)
    except:
        pass

In wav_echo_sim() function, drv gear is used to drive audio samples to the echo gear, which in turn sends the result to the collect gear. In PyGears the connection from drv to echo can be described using pipe ‘|’ operator. The drv gear sends the sequence of audio samples (variable seq), by first converting them to the specified data type: Fixp[1, sample_bit_width], where sample_bit_width value is calculated based on the input wave file format.

_images/echo_sim.png

At compile time, PyGears will try to match output data type of drv gear: Fixp[1, sample_bit_width] to the input data type Fixp of the echo gear, and since that their base types (Fixp) match PyGears will allow the connection.

Conveniently, echo gear accepts also some floating point arguments, but these then need to be converted in order to be used for parametrizing hardware modules. This is done at the beginning of the function:

    sample_dly_len = round(sample_rate * delay)
    fifo_depth = ceil_pow2(sample_dly_len)
    feedback_gain_fixp = din.dtype(feedback_gain)

Since echo delay is given in seconds, it needs to be calculated in terms of the number of samples: variable sample_dly_len in the code. Then, feedback loop fifo needs to be deep enough to store the delayed samples. Current implementation of the fifo module in PyGears demands its depth to be a power of 2. Hence, function ceil_pow2 is used to calculate the smallest power of 2 that can accommodate selected delay: variable fifo_depth in the code.

Feedback loop gain is also given as a floating point number and needs to be converted to its fixed-point representation: variable feedback_gain_fixp. Width of the fixed-point gain is chosen to be equal to the width of the audio samples received at din, which will be available via the sample_width argument. Calculated fixed-point value is then cast to the type of the din interface: feedback_gain_fixp = din.dtype(...).

Hardware description

After the compile-time parameters calculation in the echo function, the description of the actual hardware is given.


    feedback = dout \
        | decouple(depth=fifo_depth) \
        | prefill(dtype=din.dtype, num=sample_dly_len)

    feedback_attenuated = trunc(feedback * feedback_gain_fixp, t=din.dtype)

    dout |= trunc(din + feedback_attenuated, t=dout.dtype)

    return dout
_images/echo.png

The feedback loop, present in the design, cannot be described as a plain gear composition since it forms a cycle. This cycle needs to be cut at one spot, described as the gear composition and then stitched together. In this example, we will cut the cycle after the adder and start by defining the interface dout:

dout = Intf(din.dtype)

At this moment, this interface has no source (producer), which has to be attended to later, when we stitch the cycle. We will now connect dout interface to the FIFO, which we will in turn connect to the Fill Void gear:

feedback = dout \
    | decouple(depth=fifo_depth) \
    | prefill(dtype=din.dtype, num=sample_dly_len)

The function of the prefill gear is to supply the feedback loop with zeros until there are enough samples (sample_dly_len of them) in the FIFO, at which moment the FIFO will start outputting the delayed samples. The definition of the prefill gear is given in the same file:

@gear
def prefill(din, *, num, dtype):
    fill = once(val=dtype(0)) \
        | replicate(num) \
        | flatten

    return priority_mux(fill, din) \
        | union_collapse

The priority_mux gives priority to the din interface over the fill interface. In other words, if the data is available at the din interface, it will be passed to the output of the priority_mux gear. Otherwise, the data from the fill interface is output. priority_mux also outputs some additional information (i.e. from which input interface the data was passed) which is not needed here, so union_collapse gear is used to filter it out.

Back to the echo function. Finally the output interface of the prefill gear is assigned to the variable feedback. This variable can now be used to connect this interface to the multiplier:

feedback_attenuated = trunc(feedback * feedback_gain_fixp, t=din.dtype)

The output of the multiplier is then connected to the SHR gear, whose output interface is in turn assigned to the variable feedback_attenuated. Adder is then instantiated and din and feedback_attenuated interfaces are connected to it. Multiplication, shifting and addition operations change the bit width of the data, so we need restore the width of the data to the input data width. This is done by the cast to the input data type din.dtype. Finally, we stitch the feedback cycle back, by connecting the output interface of the adder to the previously declared dout interface:

dout |= trunc(din + feedback_attenuated, t=dout.dtype)

At the end of the echo function implementation, we declare which of the interfaces will be output outside of the echo gear:

return dout

Given description of the echo gear is translated by the PyGears into the SystemVerilog module given below:

Generated SystemVerilog

module echo(
    input logic clk,
    input logic rst,

    dti.consumer din, // q1.15 (16)
    dti.producer dout // q1.15 (16)

);

/*verilator tracing_off*/

      dti #(.W_DATA(16)) trunc11_s(); // q1.15 (16)
      dti #(.W_DATA(16)) trunc11_s_bc[1:0](); // q1.15 (16)
    bc #(
        .SIZE(2'd2)
    )
    bc_trunc11_s (
        .clk(clk),
        .rst(rst),
        .din(trunc11_s),
        .dout(trunc11_s_bc)
    );

    assign dout.valid = trunc11_s_bc[1].valid;
    assign dout.data = trunc11_s_bc[1].data;
    assign trunc11_s_bc[1].ready = dout.ready;

      dti #(.W_DATA(16)) decouple1_s(); // q1.15 (16)

      dti #(.W_DATA(16)) prefill1_s(); // q1.15 (16)

      dti #(.W_DATA(16)) const1_s(); // q1.15 (16)

      dti #(.W_DATA(32)) ccat01_s(); // (q1.15, q1.15) (32)

      dti #(.W_DATA(32)) mul1_s(); // q2.30 (32)

      dti #(.W_DATA(16)) trunc01_s(); // q1.15 (16)

      dti #(.W_DATA(32)) ccat11_s(); // (q1.15, q1.15) (32)

      dti #(.W_DATA(17)) add1_s(); // q2.15 (17)

    echo_decouple decouple (
        .clk(clk),
        .rst(rst),
        .din(trunc11_s_bc[0]),
        .dout(decouple1_s)
    );


    echo_prefill prefill (
        .clk(clk),
        .rst(rst),
        .din(decouple1_s),
        .dout(prefill1_s)
    );


    echo_ccat0 ccat0 (
        .clk(clk),
        .rst(rst),
        .din0(prefill1_s),
        .din1(const1_s),
        .dout(ccat01_s)
    );


    sustain #(
        .VAL(15'd19661),
        .TOUT(5'd16)
    )
    const_i (
        .clk(clk),
        .rst(rst),
        .dout(const1_s)
    );


    echo_mul mul (
        .clk(clk),
        .rst(rst),
        .din(ccat01_s),
        .dout(mul1_s)
    );


    echo_trunc0 trunc0 (
        .clk(clk),
        .rst(rst),
        .din(mul1_s),
        .dout(trunc01_s)
    );


    echo_ccat1 ccat1 (
        .clk(clk),
        .rst(rst),
        .din0(din),
        .din1(trunc01_s),
        .dout(ccat11_s)
    );


    echo_add add (
        .clk(clk),
        .rst(rst),
        .din(ccat11_s),
        .dout(add1_s)
    );


    echo_trunc1 trunc1 (
        .clk(clk),
        .rst(rst),
        .din(add1_s),
        .dout(trunc11_s)
    );



endmodule

Resource Utilization

If you have Vivado tool on your path while running the pygears_dsp/examples/echo/echo_svgen.py script, the following report will be produced. Some of the echo submodules are missing from the list, since Vivado tried to merged the modules in order to reduce the resource utilization.

Instance

Total LUTs

Logic LUTs

LUTRAMs

SRLs

FFs

RAMB36

RAMB18

DSP48 Blocks

wrap_echo

88

88

0

0

72

8

0

1

  • echo_i

88

88

0

0

72

8

0

1

    • add_i

16

16

0

0

0

0

0

0

    • bc_dout_s

5

5

0

0

2

0

0

0

    • decouple_i

24

24

0

0

38

0

0

0

    • fifo_i

42

42

0

0

30

8

0

0

    • fill_void_i

1

1

0

0

2

0

0

0

      • priority_mux_i

1

1

0

0

2

0

0

0

    • mul_i

0

0

0

0

0

0

0

1