电子产业一站式赋能平台

PCB联盟网

搜索
查看: 14|回复: 0
收起左侧

复古又现代的辉光管音量指示器(Nixie Tube Audio Meter)

[复制链接]

764

主题

764

帖子

4133

积分

四级会员

Rank: 4

积分
4133
发表于 2025-5-16 12:14:00 | 显示全部楼层 |阅读模式
虽然有点复古,但好玩的项目永不过时。Nixie Tube Audio Meter(辉光管音频电平表)是一种结合复古辉光管显示技术与现代音频处理功能的电子设备,以蒸汽朋克美学的形式可视化音频信号的动态变化

b0ediau0xfv64060622202.png

b0ediau0xfv64060622202.png

先来一组视频+图片:

hueoo5ydvmc64060622302.jpg

hueoo5ydvmc64060622302.jpg

burzr5rk1oo64060622402.jpg

burzr5rk1oo64060622402.jpg

02uqrueskhp64060622502.jpg

02uqrueskhp64060622502.jpg

2bq0srf3d3t64060622602.jpg

2bq0srf3d3t64060622602.jpg

霓虹数码管(Nixie Tubes)是一种基于霓虹灯的古老显示技术,其历史可追溯至20世纪50年代中期。它比LED和LCD显示屏更早问世,而后两者在成本和操作便捷性上更具优势。然而无论是LED还是LCD,都难以企及这些发光玻璃管所呈现的超凡脱俗的美感。高中时期我曾用霓虹数码管制作过一个时钟。那是个非常原始的结构:布满手工接线、实验板和自制蚀刻PCB。令人惊讶的是它居然运行良好!但最终成品实在不够美观,导致我从未实际使用过它——粗糙切割和胶合的预制塑料外壳所带来的负面美学效应,完全抵消了霓虹数码管本身的视觉魅力。
另一个高中时期就想实现但受限于资金而未能完成的项目,是使用前苏联的柱状显示管制作光谱仪风格的音频表。这些高压气体放电管能根据输入电流大小显示不同高度的垂直光柱。
我认为现在是时候重新审视这个搁置多年的创意了。所有代码与原理图均已开源至GitHub。
本文内容分为以下几个部分:
  • 硬件篇:详解电路设计。包含KiCad设计软件使用、线缆连接等内容。
  • 软件篇:解析系统运行代码。涵盖(嵌入式)Rust编程语言、数字信号处理(DSP)算法等实现细节。
  • 外壳篇:介绍设备外壳的设计与制作过程。
    硬件部分电路设计

    0ac2mgag5yf64060622702.jpg

    0ac2mgag5yf64060622702.jpg

    本项目的电子硬件采用KiCad进行设计。记得过去多次尝试KiCad时都因其操作繁琐而转回Eagle,但很高兴这次重新启用。它表现出色且稳定,更具备推挤布线器、内置3D预览与导出引擎等强大功能。下图展示KiCad光线追踪渲染效果:

    ltdpi4msl2p64060622802.png

    ltdpi4msl2p64060622802.png

    关于3D建模与渲染的更多细节详见外壳篇
    结合其丰富的元件库资源、以及自定义元件添加/编辑的流畅体验,堪称EDA领域的重要赢家。
    电路系统概览:
  • 霓虹管驱动板
  • 模块化菊花链式设计,每板支持4根数码管
  • 核心适配IN-13霓虹数码管,同时兼容更经济的IN-9管
  • 搭载ATSAMD11微控制器:全引脚利用(4路PWM/2路双向UART/SWD编程接口/1路电源控制输出),全程Rust编程(详见软件篇)
  • 采用TPS2281电源管理IC控制高压电源启停,无操作数秒后自动断电以延长数码管寿命
  • 170V高压由NCH8200HV模块生成。根据数码管能效差异(IN-13单模块即可驱动,IN-9需双模块)支持1-2模块灵活配置
  • 工作原理链:PWM→低通RC滤波器→运放稳定型低边BJT恒流源→数码管阴极
  • 每根数码管可通过恒流源微调电位器单独校准
  • 板间通过DIP跳线连接
  • 支撑架板(原理图1,原理图2)
  • 安装在驱动板上方,负责数码管定位与垂直固定
  • 顶层支撑板采用橡胶垫圈固定数码管
  • 控制主板
  • 独立于数码管驱动板运行,负责音频信号处理与电平显示决策
  • 自动检测连接数码管数量并匹配显示频段
  • 基础配置仅需5-12V电源+3.3V UART,理论上可用USB转串口模块简化实现
  • 本方案实现版本支持Toslink光纤S/PDIF音频输入
  • 编程语言采用Rust与C混合开发
    数码管驱动板原理图预览:

    0pbterrji3w64060622902.png

    0pbterrji3w64060622902.png

    PCB

    tipxlvafvom64060623002.jpg

    tipxlvafvom64060623002.jpg

    dyexdzznlek64060623102.jpg

    dyexdzznlek64060623102.jpg

    线缆
    为了将控制板与电子管板子连接起来,我决定使用漂亮的 LEMO 锁存推挽连接器(虽然有点贵)。我使用了一条漂亮的屏蔽 USB 电缆,将其两端拆下,重新连接到 0B 系列 LEMO 公连接器上。这些电缆提供电源和双向串行接口。软件部分构架2024 年补记(本文最早是2020年发布的):。如今Rust异步生态与裸机调度器(如RTIC与Embassy框架)已大幅成熟,下文所述方案可能不再具有实用价值。建议探索基于调度器的新体系。

    在嵌入式开发(乃至广义软件开发)中,我坚定推崇事件驱动架构。对于嵌入式场景,这意味着构建一个主循环(master loop),持续监听输入事件(引脚状态变化、定时器触发、串口数据接收等),并在事件触发时更新内部状态、执行输出动作(设置引脚电平、发送串口数据等)。

    避免使用阻塞式延时函数。推荐在后台运行一个固定间隔的定时器,通过统计定时器触发次数来管理周期性任务。这种方式能确保主循环在执行任务间隙仍可处理其他事件。

    主循环不应以100% CPU占用率空转轮询。多数嵌入式平台提供「事件等待」机制——即进入低功耗睡眠模式,直至相关硬件事件触发中断唤醒。ARM架构中的wfi指令("等待中断")正是此类机制的体现。只要确保所有关键硬件事件均关联中断(或存在足够多的随机中断保证主循环及时响应),轮询过程就能最大限度减少无效能耗。

    在嵌入式应用中,我的主循环通常会如下所示:
  • enum PinEvent {    PinTurnedOn,PinTurnedOff}enum TimerEvent {    TimerFired{times:usize}}enum SerialEvent {    ByteReceived(u8)}// We have a single master event type which wraps all other eventsenum Event {    Pin(PinEvent),Timer(TimerEvent),Serial(SerialEvent)}impl Pins {    fn update(&mut self) -> OptionPinEvent> {/* ... */}}impl Timer {    fn update(&mut self) -> OptionTimerEvent> {/* ... */}}impl Serial {    fn update(&mut self) -> OptionSerialEvent> {/* ... */}}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);    let timer_event = timer.update().map(Event::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)    // 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() {        state.update(event, &mut tx_buffer, &mut 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());    if not_busy {wfi()}
    }对于这样的嵌入式程序,有几点建议:
  • 规避阻塞操作。阻塞式代码会严重阻碍功能扩展,尤其在需要并发处理时
  • 替代方案:采用定时器/计数器机制替代延时函数
  • 如果需要管理功耗,启用基于中断的休眠机制,实现零功耗空转等待
  • 禁止动态内存分配:嵌入式场景中动态内存分配易引发致命错误。Rust嵌入式生态对此有深刻认知,标准库提供优秀静态内存管理方案
  • 确保状态表示正确且方便使用。采用联合类型(sum types)精确描述系统状态(Rust的enum类型在此大显身手)
  • 设计时注意状态模型的易操作性,避免与硬件直接耦合
  • 尽量使用纯函数(即不使用可变性)。这样可以更容易地推理应用程序的行为,尤其是在重构过程中。在没有分配器和垃圾回收器的情况下,这可能会比较困难,但通常还是值得一做。
  • 尽量将硬件修改与事件处理分开。
  • 尝试将事件建模为数据。与其在应用程序逻辑中间检查一堆硬件标志来弄清硬件发生了什么变化,不如尝试将其抽象为一种方便的事件类型,并为每种可能的事件提供不同的构造函数。例如,在我处理 S/PDIF 的 Rust 代码中,我有一个事件类型来描述 S/PDIF 硬件可能发生的变化:pub enum Event { LockLost, LockAcquired(f32), Samples(T)}。在我的应用逻辑中,我只需在这个方便的事件类型上进行模式匹配,而不必去处理 S/PDIF 硬件中的所有寄存器。
  • 在 Rust 环境中,lifetime 系统和 impl 返回值对处理这类问题很有帮助。
    其中很多建议与我在文章《Haskell 中的分布式系统》(Distributed Systems in Haskell)中给出的建议非常相似;其中很多想法也适用于嵌入式环境之外。
    Rust 实战体验在本项目中,我深度运用了Rust编程语言。尽管在日常软件开发中我鲜少使用Rust(毕竟在非实时操作系统场景下,垃圾回收语言通常更高效),但Rust在嵌入式领域展现出非凡魅力。其特性与嵌入式开发需求高度契合:
  • 零动态内存分配:规避动态内存管理的所有潜在风险
  • 安全指针操作:借助生命周期系统实现C/C++难以企及的内存安全
  • 硬件独占访问建模:通过线性类型系统(Linear Types)精确控制硬件资
    和C(++)相比,Rust具有以下现代化语言特性:
  • 内存安全保证
  • 代数数据类型(Algebraic Data Types)
  • 参数多态(Parametric Polymorphism,虽有限但实用)
    ARM生态支持
    Rust对ARM架构的支持相当完善,主流开发板大多拥有成熟的纯Rust驱动库。若现有库无法满足需求,Rust的C FFI接口可便捷调用C代码。
    但在项目过程中我也遇到以下痛点:
  • 类型系统局限:
    ? 缺乏高阶种类多态(Higher-Kinded Polymorphism)
    ? 不支持二阶类型变量(Rank-2 Type Variables)
    ? 缺失尺寸多态(Size Polymorphism)
    ? 无法在where子句中添加等式约束(Equality Constraints)
  • 状态建模挑战:
    ? 不可临时移出可变引用(因与panic!宏的交互未达成共识)
    ? Trait 中无法返回impl类型
  • 异步生态短板:
    ? 无标准库时需依赖Nightly版封装包实现async/await
    ? 特质定义中禁用异步函数(与impl返回值缺失形成双重打击)
    [/ol]上述痛点多源于类型系统泛化不足,有望随语言演进逐步改善。部分特性已进入活跃开发阶段。总体而言,相较于C/C++,Rust的嵌入式开发体验堪称愉悦。毕竟,谁不想在写驱动代码时享受模式匹配与内存安全的双重福利呢?
    音频处理音频处理流程如下:

    bq5zpshrerr64060623203.png

    bq5zpshrerr64060623203.png

    带通滤波器由N个指数间距分布的双二阶带通滤波器构成,其中 N 是电子管的数量。各频段能量计算后输入指数加权移动平均滤波器(EWMA)实现响应平滑。然后,我们将左右两组 EWMA 滤波器相加,并将其送入一个中等复杂的算法,该算法试图找到一个从能量水平到辉光管高度的既美观又实用的映射关系。简单来说,它是根据各通道随时间变化的能级分布,试图找到一个 “有代表性 ”的能级,并将其作为给定动态范围对数标度表示法的参考点。
    该系统的输出是一个由 [0, 1] 中的 N 个值组成的数组,每 M 个输入样本出现一次(M 可以是任意正整数)。
    包协议(Packet Protocol)电路板使用简单的基于数据包的协议进行通信。每个数据包由 6 个字节组成:1 字节报头、1 字节 TTL(用于选择受控的灯管)、2 字节亮度级别和 2 字节校验和。
    每当电路板接收到一个数据包,它就会检查 TTL t 是否小于 4。如果 t 大于 4,则从 t 中减去 4,然后重新转发数据包。
    控制板也可以使用此协议来检测灯管的数量。我们使用一个跳线将链条中的最后一块电路板与自己连接起来,这样就可以将信息 “反射” 回控制板,并将 TTL 减去灯管数量的 2 倍。
    辉光管板子辉光管板子处于轮询状态,等待几种情况中的一种发生:
  • 如果 UART 收到信息,它就会
  • 打开辉光管(如果它们处于关闭状态)并应用信息中指定的亮度(使用 PWM 引脚之一)
  • 将信息转发到另一个 UART(如果信息是针对不同电路板的)
  • 如果 3 秒内没有收到任何信息,则关闭灯管。假定它已与控制板断开连接。
  • 如果所有电子管在 10 秒内都设置为零,则会关闭电子管。假定音乐(或其他)已经暂停。
    在静止期间关闭电子管的主要好处是降低功耗和减少对辅助阴极的磨损。电子管电路板还具有逻辑功能,可以让 IN-13 电子管上的辅助阴极在激活主阴极之前有足够的时间激活,从而提高电子管的可靠性。
    音频板子
    对于音频板(对音频信号进行 DSP 处理,并将数值发送到辉光管板子),可以尝试以下两种方案。方案一:树莓派+扩展板第一种设计基于 Raspberry Pi 和 Hat,增加了对数字和模拟音频输入的支持。不幸的是,由于多种原因,这种设计效果不佳:
  • Pi 尺寸较大
  • Pi 加上 Hat 和各种配件比其他方案昂贵得多
  • 我找到的唯一合适的 Hat 基本上没有制造商支持,需要使用专有 Windows IDE 才能正确配置
  • Pi 的功耗非常高,而且电压不方便
  • Pi 需要很长时间才能启动
  • Pi 需要特殊配置才能保障可靠性(例如启用 OverlayFS 以防止磁盘写入)
  • Pi 采用非实时操作系统,具有大量缓冲区,会带来不可忽略的延迟
    所以这个设计是失败的。
    方案二:Teensy 4 开发板第二种方案的结果要好得多,使用 “Teensy”开发板(第 4 版)。这是一块简单而廉价的电路板,配备一个 600MHz 的 32 位 ARM 处理器。在我的使用案例中,制造商支持水平大致也相当于没有,但基本上在所有其他指标上都优于 Pi。在进行 DSP 处理时,它甚至在处理器使用率方面击败了 Pi。这在我看来不太对劲,也许是 Pi 的音频驱动程序带来了大量开销或其他什么。
    Teensy 的一个缺点是对 Rust 的支持非常有限。有一个软件包可以让你启动 Teensy、连接到中断处理程序、通过 USB 记录信息等,但它并不真正支持任何外设。我不得不用 C 语言完成所有外设,并通过 FFI 从 Rust 调用 C 语言。我尝试过另一种方法(将 Rust DSP/协议代码编译成静态链接库,然后从支持良好的 Arduino Teensy 设置中调用),但如果你试图做任何类似的复杂事情,Arduino IDE 就会惩罚你,而且我无法让它正确链接。
    音频板启动时,它会自动检测连接了多少个电子管。电子管数量用于配置音频表参数(带通滤波数量、刷新率等)。
    然后,音频板将处于轮询状态,等待下列情况之一发生:
  • 如果 S/PDIF PLL 时钟稳定在一个时钟频率上(即建立了 S/PDIF 连接),音频板将在软件中初始化音频表(音量计)。
  • 如果 S/PDIF PLL 时钟失去频率锁定,音频板将重置音频表并等待新的连接。
  • 如果通过 S/PDIF 接收到音频采样,采样将被送入音频表管道。
  • 每输入 M 个采样点,音频电路板就会向电子管电路板发送新值。M 的计算是为了以 240Hz 的频率更新电子管(或者在电子管数量非常多的情况下尽可能快)。
    音频板的速度非常快:
  • 16 个电子管在 24bit/96kHz 的频率下没有问题
  • 16 个电子管的更新频率大于 400Hz,端到端延迟约为 3ms。默认运行频率为 240Hz
    外壳
    旧款霓虹管时钟遭弃用的核心败笔在于丑陋的外壳。为避免重蹈覆辙,本次耗时月余打造激光切割亚克力外壳。
    我在网上阅读了许多文章,了解制作一个可用的亚克力产品外壳所面临的挑战,最终我选择了基于以下原则的设计:顶板/背板/前面板/底板通过精密卡槽实现自校准装配,侧板内置弹簧卡扣确保整体结构稳固,驱动板通过M2.5标准螺柱固定于壳体内部。
    从设计中可以得到一些启示:
  • 参数化设计非常有价值。手工进行一次性设计很有诱惑力,但实际情况是,你几乎肯定需要多次调整设计参数。进行参数化 CAD 设计(无论是通过编程还是使用某些基于约束的工具)是值得的。
  • 很难找到好的亚克力切割服务。我尝试过很多流行的在线亚克力切割服务,几乎所有的服务都存在一些严重的问题(软件问题、荒唐的运费/时间等)。最后我找到了一家当地的小店,虽然我必须用谷歌翻译给他们发邮件,但他们的工作还不错。
  • 应该在设计参数中加入公差。如果你想让复杂的零件紧密配合,就需要在制造过程中缩小可接受的误差范围。我的最终外壳设计只是勉强挤在一起(这正是我想要的,否则外壳就会松动)。
    CAD 工具在壳体设计阶段,我尝试了多款CAD工具,最终参数化设计(parametric design)方案脱颖而出——这种设计方法允许快速调整参数而无需重构整体结构。
    我试用过的最喜欢的 CAD 工具名为 SolveSpace,它是一款非常简约的 3D CAD 工具,以约束求解引擎为基础。然而,由于约束求解器在重复组方面存在一些明显的限制,我无法使用该工具快速实现我的设计。
    最后,我在 FreeCAD 中使用了 Python 脚本。FreeCAD 也有一个约束求解引擎,但我发现它使用起来很复杂(在重复群组上也有类似的问题),因此我只使用 Python API 来生成线、曲面和挤压。FreeCAD 的 Python API 感觉有些笨拙,但还是完成了工作。
    效果预览
    将电路板的 3D 模型从 KiCad 导出到 FreeCAD,以确保所有部件都能很好地组合在一起。当我刚开始了解我想要的外壳外观时,我会将 FreeCAD 导出到 Blender,这样我就可以渲染预览视频,了解整个外壳是如何组合在一起的。这就是其中一段视频,当时我正在考虑将控制板和辉光管放在同一个盒子里:
    我使用 STEP 文件从 KiCAD 导出到 FreeCAD,并使用 Collada (.dae) 从 FreeCAD 导出到 blender。为了渲染如上图所示的光线追踪预览效果,我要么让 Blender 在我的电脑上运行 5 个小时,要么启动几个 EC2 GPU 实例,然后将工作量分摊给它们。Blender 有一个功能齐全的 Python API(虽然很笨拙),很适合做这样的事情。


    本文转载自 https://yager.io/vumeter/vu.html,以经过翻译及校对。



    注意:如果想第一时间收到 KiCad 内容推送,请点击下方的名片,按关注,再设为星标。
    常用合集汇总:
  • 和 Dr Peter 一起学 KiCad
    KiCad 8 探秘合集
    KiCad 使用经验分享KiCad 设计项目(Made with KiCad)常见问题与解决方法KiCad 开发笔记插件应用
    发布记录
  • 回复

    使用道具 举报

    发表回复

    您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则

    关闭

    站长推荐上一条 /1 下一条


    联系客服 关注微信 下载APP 返回顶部 返回列表