“雖然有點復古,但好玩的項目永不過時。Nixie Tube Audio Meter(輝光管音頻電平表)是一種結合復古輝光管顯示技術與現代音頻處理功能的電子設備,以蒸汽朋克美學的形式可視化音頻信號的動態變化”





高中時期我曾用霓虹數碼管制作過一個時鐘。那是個非常原始的結構:布滿手工接線、實驗板和自制蝕刻PCB。令人驚訝的是它居然運行良好!但最終成品實在不夠美觀,導致我從未實際使用過它——粗糙切割和膠合的預制塑料外殼所帶來的負面美學效應,完全抵消了霓虹數碼管本身的視覺魅力。
另一個高中時期就想實現但受限于資金而未能完成的項目,是使用前蘇聯的柱狀顯示管制作光譜儀風格的音頻表。這些高壓氣體放電管能根據輸入電流大小顯示不同高度的垂直光柱。
我認為現在是時候重新審視這個擱置多年的創意了。所有代碼與原理圖均已開源至GitHub。
本文內容分為以下幾個部分:
-
外殼篇:介紹設備外殼的設計與制作過程。


關于3D建模與渲染的更多細節詳見外殼篇
結合其豐富的元件庫資源、以及自定義元件添加/編輯的流暢體驗,堪稱EDA領域的重要贏家。
電路系統概覽:
-
霓虹管驅動板
-
模塊化菊花鏈式設計,每板支持4根數碼管
-
核心適配IN-13霓虹數碼管,同時兼容更經濟的IN-9管
-
采用TPS2281電源管理IC控制高壓電源啟停,無操作數秒后自動斷電以延長數碼管壽命
-
170V高壓由NCH8200HV模塊生成。根據數碼管能效差異(IN-13單模塊即可驅動,IN-9需雙模塊)支持1-2模塊靈活配置
-
每根數碼管可通過恒流源微調電位器單獨校準
-
板間通過DIP跳線連接
-
-
支撐架板(原理圖1,原理圖2)
-
安裝在驅動板上方,負責數碼管定位與垂直固定
-
頂層支撐板采用橡膠墊圈固定數碼管
-
-
控制主板
-
-
獨立于數碼管驅動板運行,負責音頻信號處理與電平顯示決策
-
自動檢測連接數碼管數量并匹配顯示頻段
-
基礎配置僅需5-12V電源+3.3V UART,理論上可用USB轉串口模塊簡化實現
-
本方案實現版本支持Toslink光纖S/PDIF音頻輸入
-
編程語言采用Rust與C混合開發
-
數碼管驅動板原理圖預覽:



2024 年補記(本文最早是2020年發布的):。如今Rust異步生態與裸機調度器(如RTIC與Embassy框架)已大幅成熟,下文所述方案可能不再具有實用價值。建議探索基于調度器的新體系。
在嵌入式開發(乃至廣義軟件開發)中,我堅定推崇事件驅動架構。對于嵌入式場景,這意味著構建一個主循環(master loop),持續監聽輸入事件(引腳狀態變化、定時器觸發、串口數據接收等),并在事件觸發時更新內部狀態、執行輸出動作(設置引腳電平、發送串口數據等)。
避免使用阻塞式延時函數。推薦在后臺運行一個固定間隔的定時器,通過統計定時器觸發次數來管理周期性任務。這種方式能確保主循環在執行任務間隙仍可處理其他事件。
主循環不應以100% CPU占用率空轉輪詢。多數嵌入式平臺提供「事件等待」機制——即進入低功耗睡眠模式,直至相關硬件事件觸發中斷喚醒。ARM架構中的wfi
指令("等待中斷")正是此類機制的體現。只要確保所有關鍵硬件事件均關聯中斷(或存在足夠多的隨機中斷保證主循環及時響應),輪詢過程就能最大限度減少無效能耗。
在嵌入式應用中,我的主循環通常會如下所示:
enumPinEvent{
PinTurnedOn,PinTurnedOff
}
enumTimerEvent{
TimerFired{times:usize}
}
enumSerialEvent{
ByteReceived(u8)
}
// We have a single master event type which wraps all other events
enumEvent{
Pin(PinEvent),Timer(TimerEvent),Serial(SerialEvent)
}
impl Pins {
fnupdate(&mutself) ->Option<PinEvent>{/* ... */}
}
impl Timer {
fnupdate(&mutself) ->Option<TimerEvent>{/* ... */}
}
impl Serial {
fnupdate(&mutself) ->Option<SerialEvent>{/* ... */}
}
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).
forevent 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());
ifnot_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(++)相比,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
返回值缺失形成雙重打擊)
上述痛點多源于類型系統泛化不足,有望隨語言演進逐步改善。部分特性已進入活躍開發階段。總體而言,相較于C/C++,Rust的嵌入式開發體驗堪稱愉悅。畢竟,誰不想在寫驅動代碼時享受模式匹配與內存安全的雙重福利呢?
音頻處理音頻處理流程如下:

帶通濾波器由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工具,最終參數化設計(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,以經過翻譯及校對。
-
指示器
+關注
關注
0文章
254瀏覽量
38667 -
輝光管
+關注
關注
3文章
13瀏覽量
5400
發布評論請先 登錄
超級電容在故障指示器中的作用有哪些?

Linux simple-audio-card缺少音量控制怎么解決?
淺談架空暫態特征型遠傳故障指示器
線路故障指示器為什么變成紅色
線路故障指示器如何復位
線路故障指示器工作原理是什么
線路故障指示器怎么判斷故障點
7月全志芯片開源項目分享合輯
L60系列0.230英寸(5.9毫米)防水面板安裝指示器
用舊世代的顯像管技術做一款開源全志H616安卓智能手機
利用Arduino的數字水位指示器電路設計

VL53L8CX TOF開發(4)----運動指示器

內置超級電容模塊的故障指示器有哪些特性?

評論