Quickstart Guide

A circuit design running on the Zrna platform is composed of two kinds of entities: modules and nets.

A module is an abstract building block that implements a particular analog signal processing function. A net defines a connection between modules that allows a signal to flow through the system.

Zrna's module library provides implementations of various functions including gain stages, filters and oscillators, etc.

At a high-level, the workflow consists of picking some modules from the library, configuring their parameters and options and connecting them in a useful network.

When you have a design you like, you can store it to internal flash for later retrieval or designate that it be loaded on startup.

The Python Client

The recommended way to get started with Zrna is to use the Python API client. Alternative methods will be covered in a future advanced topics article.

First, ensure that Python is available on your system. The client is written such that recent versions of either Python 2 or 3 will work, but 3 is recommended as 2 is approaching retirement.

The package is available on PyPI and can be installed with pip like so:

pip install zrna

Next, open a Python REPL and verify that the package is visible:

>>> import zrna

If you prefer to work with the source directly, grab it from our Github repository.

Connect the board to your host system with a micro USB cable. As it powers up, you'll see the LED blink several times and then go solid. Your operating system will see the device as both a standard virtual COM port and as a class compliant MIDI device. From a new script or REPL session run the following:

import zrna
z = zrna.api.Client()

Here we create a client instance and open a connection with the board. connect() will attempt to find the connected device automatically. If this process fails, check your connections and verify that your operating system sees the device. The USB product string is zrna midi/cdc. A virtual serial device will appear in /dev on Linux and MacOS and as a COM port on Windows.

If connect() fails but the serial device is visible, take note of its path on your machine and specify it as an argument:

# on Linux and MacOS, specify the /dev path

# on Windows, specify the COM port

When connect() completes without error, startup has succeeded and we are ready to proceed with our design.

Client Structure

Interaction with the API all happens through the main client object that we've defined here as z. The first thing we might want to do is check what modules are available:

>>> z.modules()
['Comparator', 'DelayLine', ... , 'GainInv']

We'll start our design with GainInv, a simple inverting gain stage. Each of the listed modules is available as a class on the client object:

g = z.GainInv()

Each module has parameters, options, inputs and outputs:

>>> g.parameters
>>> g.options
>>> g.inputs
>>> g.outputs

These fields are accessible on the module object itself:

>>> g.gain
>>> g.gain = 10
>>> g.gain

Let's clear the active circuit, add our gain module and set it in motion:

>>> z.clear()
>>> z.add(g)
>>> z.run()

At this point we have the following circuit running on the device:

where the gain \(G = 10.0\). We can continue to adjust parameters while the circuit is running:

>>> g.gain = 5.3

Simply assigning a different value causes the device to update itself accordingly.

There's one obvious problem: we haven't connected anything to the outside world! We'll cover that next.

Basic IO

Let's get a signal from the audio input, run it through our gain module and send it back out of the audio output.

To do this, we'll use the AudioIn and AudioOut modules. First, let's pause the active circuit:

>>> z.pause()

Then declare the IO modules:

>>> audio_in = z.AudioIn()
>>> audio_out = z.AudioOut()
>>> z.add(audio_in)
>>> z.add(audio_out)

At this point, we'd like to wire the modules together. Let's double check what inputs and outputs they have:

>>> audio_in.outputs
>>> audio_out.inputs
>>> g.inputs
>>> g.outputs

First we connect the output of the AudioIn to the input of the GainInv:

>>> audio_in.output.connect(g.input)

Then we connect the output of the GainInv to the input of the AudioOut and start the circuit running:

>>> g.output.connect(audio_out.input)
>>> z.run()

Now our running circuit looks like this:

The AudioIn and AudioOut modules are connected conceptually to the physical 3.5mm jacks and corresponding test points on the board. With the circuit running, if you send a signal into the physical input and observe the signal on the physical output, you'll see the gain being applied. As before, we can adjust the gain of the live circuit as needed:

>>> g.gain = 10.0
>>> g.gain = 1.0
>>> g.gain = 0.5

Or do something more complicated like a sweep:

>>> g.gain = 1.0
>>> while g.gain < 10.0:
...     g.gain += delta_gain
...     time.sleep(delta_t)

At this point, we've sketched out the basics. There are few more concepts to keep in mind that we'll cover next and then you'll be ready to start experimenting.


In contrast to parameters which are numerical values, options are enumerated types, they consist of several named values. For example, the FilterVoltageControlled module has an option filter_type which can have one of following values: \( \{ \) LOWPASS\(,\) HIGHPASS\(,\) BANDPASS\(,\) ALLPASS\( \} \). In this case, the option controls what type of filter response the module has.

>>> vcf = z.FilterVoltageControlled()
>>> vcf.options
['opamp_mode', 'filter_order', 'filter_type', 'input_phase']
>>> vcf.filter_type
>>> vcf.filter_type.valid_values
>>> vcf.filter_type = z.BANDPASS

Parameter Range and Clocks

Parameters each have an associated range of values they can take on. When a parameter value is requested, the hardware attempts to realize the nearest possible value it can. The requested and realized values of a parameter can be inspected:

>>> lpf = z.FilterLowpass()
>>> lpf.corner_frequency = 10.0
>>> lpf.corner_frequency.requested
>>> lpf.corner_frequency.realized

How much error there is depends on the module in question, clock configuration (see below) and where the in the parameter range the requested value resides.

Hardware Resources

Unsurprisingly, the Zrna board has finite hardware resources. The analog array section consists of four comparators, eight opamps, switched-capacitors and some auxiliary hardware. Each analog module in a design consumes resources while it is running. A specific module's resource usage is summarized on its documentation page. It's also possible to keep track of resource usage through the API, i.e. what resources are in use, what resources a particular module requires and whether or not the headroom exists for it to be added to the current design:

>>> lpf = z.FilterLowpass()
>>> lpf.analog()
... resource usage ...
>>> lpf.can_add()

The client will notify you if a module can't be added because of resource limits.

Configuration Errors and Safety

The analog array device that Zrna uses is designed to protect itself from errors. Whenever you make an API request that changes the state of the array, configuration data is sent from the microcontroller via SPI. Logic hardware inside the array itself checks the incoming data stream for correctness. If an error is detected, the array will assert its ERRb error flag and go into a safe idle state. The firmware monitors this flag for the error condition. An API request that is rejected by the hardware in this way will return the status code INVALID_CONFIG_BYTESTREAM. If this happens, a client can simply restart and try again with a new, hopefully valid, configuration. For more about hardware safety and limits, see the hardware quick reference.

Clock Phase

Zrna relies on switched-capacitor technology that operates on a non-overlapping clock with two phases (usually referred to as \(\phi_1\) and \(\phi_2\) in the literature). A signal in the system is either continuous or what is called half-cycle, valid on only one of the two clock phases.

If you're coming from a software, music or non-engineering background and this sounds confusing, don't worry. There are a couple of simple rules to keep in mind.

If you take a look at the documentation page for a module, you'll notice that its inputs and outputs are labeled either Continuous or Half-Cycle. A continuous output can be connected to any input. A half-cycle output should only be connected to a half-cycle input with matching phase, i.e. both the input and output should be valid on PHASE1 or on PHASE2. By default, the client will warn you if you make a mistake. Let's take a look at an example. The GainHalf module has Half-Cycle input and Half-Cycle output:

>>> g = z.GainHalf()
>>> z.add(g)
>>> g.input.phase
>>> g.output.phase

The GainInv module has Continuous input and Continuous output:

>>> g2 = z.GainInv()
>>> z.add(g2)
>>> g2.input.phase
>>> g2.output.phase

If we try to connect the GainHalf output to the GainInv input, an exception will be thrown because a continuous input shouldn't be driven by a half-cycle signal.

Clock Configuration

Zrna's switched-capacitor circuitry is driven by a primary 16Mhz clock that is divided down into several secondary clocks. Modules are each bound to one or more of these secondary clocks, and a module's parameter range is generally a function of secondary clock frequency. Both primary and secondary clocks can be scaled with integer divisors.

Zrna has a reasonable default clock configuration that works for most modules. Changing the primary clock or module clock configurations is useful for adjusting a module's operating range.

Adjusting the primary clock divisor is done like so:

>>> z.set_divisor(z.CLOCK_SYS1, 8) 

This has the effect of dividing every secondary clock and thus clock dependent parameters by that factor. The client reported maximum, minimum and realized parameter values adjust themselves accordingly so you can inspect the new operating range.

A module's clock binding can be adjusted like so:

>>> wave = z.ArbitraryWaveGen()
>>> wave.set_clock(z.CLOCK3)

This binds the module to a different secondary clock which adjusts its operating range.

You can inspect and change clock divisor settings like this:

>>> print(z.clocks())
  sys_clock {
    id: CLOCK2
    divisor: 8
>>> z.set_divisor(z.CLOCK2, 16)

The Storage System

Zrna's firmware implements a basic filesystem on a wear-leveled region of flash memory. Circuit designs can be stored to and loaded from named files. Being stored in flash, the data persists even when the board isn't powered. The API client provides several methods for working with the storage system. First we can store the active to circuit to a named file or replace the active circuit with the data from a file:

>>> z.store('cool_circuit')
>>> z.load('really_cool_circuit')

Next we can query storage to see what circuits we've stored in the past:

>>> print(z.stored_circuits())
storage_response {
  file_info {
    name: "cool_circuit"
    byte_count: 31
  file_info {
    name: "really_cool_circuit"
    byte_count: 228

Finally we can specify a circuit to be loaded on startup:


# And then disable later if needed

This is enough to get started with storage. There are other convenience and debug methods available if you need finer grained control; take a look at the API client source.

API Endpoints

The API uses HTTP-like semantics. Resources are exposed through endpoints at URLs that are each defined to be valid for certain HTTP-like verbs (i.e. GET, POST, etc). You can take a look at the endpoint definitions like this:

>>> print(z.endpoints())
endpoint {
  method: GET
  method: POST
  url {
    path_components {
      resource_id: STORAGE
    path_components {
      resource_id: DEBUG
  docstring: "Issue debug commands to the flash filesystem."

This is slightly more advanced topic that is good to be aware of but isn't necessary when getting started. If you're interested in using the low-level API directly, start by looking at the Python client source code and the .proto spec in the Github repository.


© 2022 Zrna Research