Nixie Tube Audio Meter

February 2020





















Nixie Tubes are an antiquated neon-based display technology dating back to the mid-1950s. They predate both LEDs and LCDs, which are both cheaper and simpler to operate. However, LEDs and LCDs both struggle to match the otherworldly aesthetic of these glowing glass tubes.

Back in high school I built a clock using Nixie Tubes. It was of a very primitive construction: plenty of hand-wiring, protoboard, and self-etched PCBs. Remarkably, it worked great! However, the end result was so ugly that I never used it - the negative aesthetics of the poorly cut and glued prefab plastic container more than cancelled out the positive aesthetics of the Nixie Tubes.

Something that I also wanted to do in high school, but lacked the funds (and probably the patience) to pull off, was a spectrometer-style audio meter using old Russian bargraph tubes. These are high-voltage gas-discharge tubes that display a vertical bar, which varies in length depending on how much current you supply it.

I figured it was time to revisit this old project idea.

All the code and schematics are on GitHub.

This post is broken down into a few sections:

Hardware: Details about circuit design. Details on KiCad, PCBs from PCBWay, cabling, etc.

Software: Details about the code that runs this system. Information on (embedded) Rust, the DSP algorithms used, etc.

Case: Details on how the case was designed and constructed.

I’ve had a few requests from people to buy one of these things, so if you’re interested, please fill out this survey. Depending on the level of interest, I’ll run a Vickrey Auction for a limited run or something.

Hardware

Circuit Design





The electronic hardware for this project was designed using KiCad. I remember trying KiCad several times in the past and finding it pretty frustrating and moving back to Eagle. However, I’m glad that I tried using it again. It does the job quite well with few bugs, and has tons of great features like the shove-based router (a huge time saver) and even a built-in 3D preview and export engine. Here’s a view from KiCad’s built-in raytracer:

More on 3D modeling (and rendering) in the Case section.

Couple this with a pretty solid included part library and an incredibly smooth experience for adding and editing custom parts and you have a major winner.

As for the circuits themselves, here’s a quick rundown:

Tube board (schematics) A modular, daisy-chainable board that powers and controls 4 tubes The board is designed primarily to operate with IN-13 nixie tubes, but will work with the cheaper IN-9 tube as well. ATSAMD11 microcontroller. All pins used - 4x PWM, 2x bidirectional UART, programming interface exposed via SWD header, 1x power control output. Programmed entirely in Rust! See Software for more info. High Voltage power can be turned on/off using TPS2281 PMIC. It’s turned off after a few seconds of inactivity, so as to extend tube lifetime. I use the lovely NCH8200HV board to generate 170 Volt power. The tube PCB can operate with either 1 or 2 of these boards, depending on whether you’re using a more or less efficient Nixie Tube. (IN-13 can run with just one HV board, but IN-9 might require 2.) The circuit’s operating principle is: PWM -> Low-pass RC filter -> Op-amp stabilized low-side BJT current source -> Nixie Tube cathode Each Nixie Tube can be individually calibrated by the use of a trimpot in its current source Boards are connected together using DIP jumpers

Riser boards (schematics, schematics) Sits on top of tube board, holding Nixie Tubes in place and keeping them upright The topmost riser board uses a rubber grommet to hold the tube in place

Control Board (schematics) Operates separately from the tube boards. Responsible for doing audio processing to determine what levels to show on each tube Auto-detects how many tubes are connected and automatically uses the correct number of bands Can, in principle, be as simple as a 5-12V power supply and a 3.3V UART. If you wanted, you could just use a simple USB-serial adapter The one I’ve built reads Toslink (optical) S/PDIF audio signals Programmed in Rust and C



Here’s a preview of the schematic for the tube board:





PCBs

I’d like to give a big shout-out to PCBWay, who sponsored this project with free PCBs. They saw one of my other projects and thought it was cool, so they offered to sponsor my next hardware project if I gave them a quick review. I’d been meaning to try them out anyway, so I was happy to accept.

Uploading to their website was pretty smooth - just export Gerber files from KiCad (with settings specified on their website), archive, and upload. They give you a ton of options - board color, surface finish, thickness, copper density, etc. etc.

As long as you don’t stray too far from the default/popular options, getting boards from PCBWay is very affordable; the board you saw rendered above, with 1.6mm PCBs, 1Oz/sqft copper, and leaded HASL, was only $8 for 10 boards! I usually get the boards in the mail in less than 10 days after ordering (shipped to Hong Kong).

The quality of the boards seems quite good as well. I haven’t noticed any shorts or significant masking defects. Here are some samples from my second batch of boards:







Pretty nice! Here’s a video from when I was testing rev.1 PCBs:

I ended up having a number of crazy and hard-to-debug problems with the rev.1 board PCBs that ended up being caused by the cheap flux in my solder and the high humidity of my apartment. However, it worked great as long as the AC was on! This highlights the importance of proper board cleaning, especially in extreme environments.

Cable

To connect the control board to the tube boards, I decided to use beautiful (if somewhat pricey) LEMO latching push-pull connectors. I took a nice shielded USB cable, removed the ends, and reattached them to 0B series LEMO male connectors. These cables carry power and bidirectional serial.









Software

Architecture

For embedded software (and software in general), I’m a big advocate of an event-driven architecture. In embedded land, this basically means you have a master loop that awaits input events (pin state changed, timer went off, byte received over UART, etc.), and when an event is detected, it updates its internal state and issues output events (set pin state, send serial data, etc.)

If you need to do timing, don’t use a blocking delay or sleep function; just have a timer running in the background at a consistent interval, and count how many times the timer fired since the last time you did whatever task you need to do periodically. This lets you keep doing other stuff in the mean time.

For power efficiency’s sake, we try not to just sit spinning in this loop at 100% CPU constantly waiting for something to happen. Thankfully, most embedded platforms have some sort of mechanism to precisely “wait for something to happen” - that is, power down unnecessary components (go into “sleep mode”) until some possibly-relevant hardware event goes off. On ARM, this is the wfi instruction (“wait for interrupt”). As long as we make sure every hardware event we care about also triggers an interrupt (or we have enough incidental interrupts to ensure that our loop runs frequently enough), our polling loop won’t waste (much) energy just spinning around waiting for something to happen.

My master loop in embedded applications often ends up looking something like the following:

enum PinEvent { PinEvent PinTurnedOn,PinTurnedOff } enum TimerEvent { TimerEvent { times: usize } TimerFiredtimes: } enum SerialEvent { SerialEvent u8 ) ByteReceived( } // We have a single master event type which wraps all other events enum Event { Event Pin(PinEvent),Timer(TimerEvent),Serial(SerialEvent) } impl Pins { Pins fn update(& mut self ) -> Option <PinEvent> { /* ... */ } update(&) -> } impl Timer { Timer fn update(& mut self ) -> Option <TimerEvent> { /* ... */ } update(&) -> } impl Serial { Serial fn update(& mut self ) -> Option <SerialEvent> { /* ... */ } update(&) -> } loop { // Check each piece of hardware for a change. // Instead of using this polling based model, you can also // use the interrupt handlers to e.g. put events into // some event buffers and read from those buffers. let pin_event = input_pins.update().map( Event:: Pin); pin_event = input_pins.update().map(Pin); let timer_event = timer.update().map( Event:: Timer); timer_event = timer.update().map(Timer); // In many cases, it's better to process multiple inputs // per loop, usually by passing around a buffer. See my // S/PDIF code for an example - it will process up to 128 // samples per cycle. let serial_event = serial.update().map( Event:: Serial) serial_event = serial.update().map(Serial) // We have a master state object which, internally, // keeps mutable reference(s) to the state of our logic. // We pass in all hardware events as well as mutable handle // objects that the state object can use to control hardware. // // Hardware handles should not have a concrete type in the state definition. There // should be a trait that describes the capabilities of the hardware and a type // parameter constrained to satisfy that trait. This allows you to mock out // hardware during testing. // // Sometimes you'll want to pass in the hardware handle at every update() invocation, // and sometimes you'll want to have the state object own the handle. Either way, // use a trait to define the capability of the hardware. // // Fixed-sized output events (e.g. LED on/off) can optionally be returned from the // state update function, but for variable-sized output events it's usually easier // to pass in some object which consumes the events (e.g. tx_buffer). for event in [ pin_event,timer_event,serial_event ] .iter() { eventpin_event,timer_event,serial_event.iter() mut tx_buffer, & mut led); state.update(event, &tx_buffer, &led); } // Iterating like this may or may not work for your Event type. // You may want to use pin_event.into_iter().chain(...) instead of putting // the events in an array and iterating over that. // Flush as much buffered data (e.g. outgoing serial bytes) // as possible (without blocking) to hardware. // It's important to keep in mind that the design principles here // strongly suggest that you shouldn't ever block waiting for the // UART to flush. Instead, you should intentionally decide when (not) // to send based on available UART bandwidth. In this project, // I just drop packets if I'm low on bandwidth (which I try to avoid). tx_buffer.flush(); // If we're not too busy, execute wfi (or equivalent). // Processor will go into a low-power state // until an interrupt fires. // We should be confident that an interrupt will fire // every time something we care about changes, or at least // that some arbitrary interrupt will happen frequently // enough that our loop runs sufficiently often. let not_busy = [ pin_event,timer_event,serial_event ] .iter().all(|evt| evt.is_none()); not_busy =pin_event,timer_event,serial_event.iter().all(|evt| evt.is_none()); if not_busy { wfi() } not_busywfi() }

A few pieces of advice for embedded programs like this:

Don’t block. It makes adding more functionality too annoying, especially if you want to do anything concurrently. As a corollary, use timers and counters instead of delays.

Use interrupt-based sleeping if you need to manage power usage. It’s easy and basically free.

Don’t use dynamic allocation. Embedded code that uses dynamic allocation is almost always bad and/or broken. You almost certainly don’t need it. Thankfully embedded Rust developers seem to understand this well and the embedded libraries have good memory management.

Make sure your state representations are correct and convenient to work with. Use sum types liberally. (Unfortunately Rust can get in the way here sometimes - more on that later)

To the extent possible, try to separate your state update logic from hardware. Try to use pure functions (i.e. that don’t use mutability). This makes reasoning about application behavior easier, especially during refactors. This can be a bit harder without an allocator and garbage collector, but is still often worth doing. Try to separate out the modification of hardware from the processing of events. Try to model events as data. Instead of having to check a bunch of hardware flags in the middle of your application logic to figure out what changed in hardware, try to abstract this into a convenient event type with a different constructor for each possible event. E.g. in my Rust code for S/PDIF handling, I have an Event type that describes possible changes to the S/PDIF hardware: pub enum Event<T> { LockLost, LockAcquired(f32), Samples(T)} . In my application logic, I just have to pattern match on this convenient Event type rather than having to screw around with all the registers in the S/PDIF hardware. In Rust-land, the lifetime system and impl return values are very helpful for wrangling this sort of stuff.



A lot of these are very similar to advice I gave in my article Distributed Systems in Haskell; many of these ideas are applicable beyond an embedded context.

Embedded Rust

For this project, I made heavy use of the Rust programming language. I never really found a use for Rust in my normal software projects - my thought is that if I need to deal with the overhead of a non-realtime operating system anyway, I might as well use a garbage collected language most of the time. However, I think Rust is great for embedded usage. It has tons of features perfectly suited for embedded programming, including:

The ability to write programs free of dynamic allocation and all its concomitant downsides

The ability to safely do things with pointers that you couldn’t do safely in C(++), thanks to the lifetime system

The ability to represent exclusive hardware access using the linear type system

Compared to C(++), it has a number of features that I want from my languages, such as:

Memory safety

Algebraic data types

Parametric polymorphism (a bit limited, but still useful)

Rust’s support for ARM is pretty solid, and many popular boards have pretty decent libraries (at varying levels of completeness/feature support) for interacting with the board in pure Rust. If an available library falls short, the C FFI is pretty easy to use for calling out to C code.

There are a few pain points in Rust that I ran into during this project, including:

No higher-kinded polymorphism

No rank-2 type variables

No size polymorphism

The inability to temporarily move out of a mutable reference (it sounds like people couldn’t agree on how this should work with the panic! macro). This really diminishes one’s ability to use sum types to represent states.

macro). This really diminishes one’s ability to use sum types to represent states. No impl return values in trait implementations

return values in trait implementations Async/await doesn’t work without std unless you use some nightly-only wrapper crates

unless you use some nightly-only wrapper crates Async functions can’t be used in trait definitions, which especially sucks given the lack of impl return values in traits

return values in traits No equality constraints in where clauses

but for the most part it was relatively pleasant, especially compared to C(++). Most of the pain points listed above could be fixed with a more generalized type system, so hopefully they will improve with time. Some of them seem to be under active development.

Audio Processing

The audio processing pipeline works as follows:

L --> Bandpasses ==> Energies ==> EWMA \\ + ==> Rescaler ==> Tubes R --> Bandpasses ==> Energies ==> EWMA //

Bandpasses consists of a set of N exponentially-spaced biquad bandpass filters, where N is the number of tubes. We calculate the energy of each band and feed it through a bank of EWMA filters, to smooth out the response. We then add up the left and right EWMA banks and feed them into a moderately complicated algorithm which attempts to find an aesthetically pleasing and useful mapping from energy levels to tube heights. Simplified, it does this by trying to find a “representative” energy level based on the distribution of energy levels across channels over time and using that as a reference point in a log-scale representation with a given dynamic range.

The output of this system is an array of N values in [0, 1] once every M input samples (where M can be any positive integer).

Packet Protocol

The boards communicate using a simple packet-based protocol. Every packet is 6 bytes: a 1-byte header, 1-byte TTL (to select which tube is being controlled), 2-byte brightness level, and 2-byte checksum.

Whenever a board receives a packet, it checks if the TTL t is less than 4. If it is, it sets its tth tube to the specified brightness. If t is greater than 4, it subtracts 4 from t and re-forwards the packet.

The control board can also use this protocol to detect how many tubes there are. We use a jumper to connect the last board in the chain to itself, and this causes messages to “reflect” back to the control board, with a TTL decremented by two times the number of tubes.

You can see the protocol code in full detail here.

Tube Board

The tube board sits in a loop, waiting for one of a few things to happen:

If it receives a message on one UART, it either: Turns on the tubes (if they’re off) and applies the brightness specified in the message (using one of the PWM pins)

Forwards it out the other UART (if the message is for a different board) If it doesn’t receive any messages for 3 seconds, it turns off the tubes. The assumption is that it’s been disconnected from the control board. If all of the tubes are set to zero for 10 seconds, it turns off the tubes. The assumption is that the music (or whatever) has been paused.

The main advantages of turning off the tubes during quiescence are lower power consumption and less wear on the auxiliary cathodes. The tube board also has logic to give the auxiliary cathodes on IN-13 tubes time to activate before activating the primary cathodes, which increases tube reliability.

Audio Board

For the audio board (which does DSP on the audio signal and sends values to the tube boards), I was trying out a couple possible designs in parallel.

The first design I was trying was based on a Raspberry Pi with a “hat” (extension board) that added support for digital and analog audio input. Unfortunately, this worked poorly for a number of reasons:

The Pi is relatively large

The Pi plus its “hat” and various paraphernalia is way more expensive than the other option

The only appropriate “hat” I found had basically no manufacturer support and required the use of a horrible proprietary Windows IDE to configure properly

The Pi has very high power consumption, and at an inconvenient voltage

The Pi takes a very long time to boot up

The Pi requires special care to make it acceptably reliable (e.g. enabling the OverlayFS to prevent disk writes)

The Pi, having a non-realtime OS with extensive buffering, introduces non-negligible latency

So that design was a dud. The second option ended up working out a lot better - a “Teensy” board, version 4. It’s a simple and inexpensive board with a 600MHz 32-bit ARM processor. It had approximately the same level of manufacturer support for my use case (i.e. none) but beats the Pi at basically all the other metrics. It even seems to beat the Pi in terms of processor usage while doing DSP. That doesn’t seem right to me, so maybe the Pi’s audio drivers are introducing a bunch of overhead or something.

One downside of the Teensy is that its Rust support is pretty limited. There’s a package that lets you boot the teensy, attach to interrupt handlers, log messages over USB, etc, but it doesn’t really support any peripherals. I had to do basically all the peripheral stuff in C and call out to C from Rust via the FFI. I tried the other way around (compile my Rust DSP/protocol code to a statically linked library and call out to it from the well-supported Arduino Teensy setup), but the Arduino IDE really punishes you if you try to do anything complex like that, and I couldn’t get it linking correctly.

When the Audio Board starts up, it auto-detects how many tubes are connected. The number of tubes is used to configure the audio meter parameters (number of bandpass filters, refresh rate, etc.).

The Audio Board then sits in a loop, (roughly) waiting for one of the following to happen:

If the S/PDIF PLL clock settles on a stable clock frequency (i.e. an S/PDIF connection was established), the Audio board initializes an audio meter in software. If the S/PDIF PLL clock loses its frequency lock, the Audio board resets the audio meter and waits for a new connection. If audio samples are received over S/PDIF, the samples are fed into the audio meter pipeline. Once every M input samples, the audio board sends out fresh values to the tube boards. M is calculated to update the tubes at 240Hz (or as fast as possible, with a very large number of tubes).

The Audio Board is pretty fast:

16 tubes at 24bit/96kHz is no problem

16 tubes can update at >400Hz, with about 3ms end-to-end latency. Default is to run at 240Hz

Case

One of the biggest reasons I never used my old Nixie Tube clock was that the case was ugly. I wanted to make extra sure I didn’t make that mistake again, so I spent a fair amount of time designing a nice laser-cut acrylic case.

I read a number of articles online about challenges associated with making a usable acrylic product case and ended up going for a design based on two principles: interlocked crenellations to hold the top, back, front, and bottom in alignment, and sprung hooks on the side panels to keep everything together. The tube boards are attached to the case via standard M2.5 spacers.

The case schematics are available here.

A few takeaways from this:

Parametric designs are extremely valuable. It’s tempting to just do a one-off design by hand, but the reality is that you’ll almost certainly need to tweak design parameters a number of times. Doing a parametric CAD design (whether by programming or with some constraint-based tool) is worth it.

It’s hard to find a good acrylic cutter service. I tried a number of popular online acrylic cutting services and almost all of them had some serious problems (software issues, absurd shipping rates/times, etc.). I ended up finding a small local shop that does a decent job, although I have to use Google Translate to email them.

You should include tolerances in your design parameters. If you want to get a snug fit with complex parts, you’ll need to winnow down the acceptable error range in manufacturing. My final case design just barely squeezes together (which is exactly what I want - the alternative is slop in the case).

CAD Software

I tried out a number of different tools here. My favorite approaches ended up being parametric (i.e. you can edit the parameters of the design easily without having to re-do your design from scratch).

My favorite CAD tool I tried is called SolveSpace, which is a very minimalist 3D CAD tool based around a constraint solver engine. However, I was unable to quickly implement my design with this tool due to some apparent limitations the constraint solver has around repetitive groups.

I ended up using a Python script in FreeCAD. FreeCAD also has a constraint solver engine, but I found it complicated to use (with similar problems on repetitive groups), so I just used the Python API to generate lines, surfaces, and extrusions. FreeCAD’s python API felt somewhat clunky, but it got the job done.

Previewing

I exported 3D models of my PCBs from KiCad to FreeCAD to make sure that everything fit together nicely. When I was first getting a feel for how I wanted the case to look, I would export from FreeCAD to Blender so I could render preview videos for how the whole thing fit together. Here’s one of those videos, from when I was considering putting the control board in the same case as the tubes:

Render

