This document highlights features and advantages unique to the Nim wrapper that go beyond a simple C++ binding.
All critical data structures are designed for stack allocation with guaranteed zero heap usage in audio callbacks and other realtime paths.
C++ libDaisy:
// Risk of heap allocation with STL std::vector<float> buffer; // May allocate std::string name; // Allocates // Safe but verbose float buffer[256]; // Stack allocated
Nimphea:
# All containers are stack-allocated by default var buffer: array[256, float] # Stack allocated var fifo: FIFO[float, 128] # Stack allocated var str: FixedStr[32] # Stack allocated - no heap! # Compile-time enforcement when defined(danger): # --gc:arc ensures deterministic behavior # No GC pauses in embedded targets
Benefits:
Nim's Automatic Reference Counting (ARC) provides deterministic memory management without garbage collection.
Key Advantages:
Example:
type AudioBuffer = object data: seq[float32] proc createBuffer(size: int): AudioBuffer = result.data = newSeq[float32](size) # Automatically freed when result goes out of scope # In embedded code, use stack allocation instead: proc audioCallback(input, output: AudioBuffer, size: int) {.cdecl.} = # No heap allocation in callback - compile-time guaranteed var temp: array[256, float32] # Stack only # Process audio...
Compile-Time Safety:
# Nim can detect some heap allocations at compile time: proc badAudioCallback(input, output: ptr ptr cfloat, size: int) {.cdecl.} = var list = @[1, 2, 3] # Can cause compile warnings in analysis mode # The seq type is marked as potentially allocating # Best practice: Explicitly use stack-allocated types proc goodAudioCallback(input, output: ptr ptr cfloat, size: int) {.cdecl.} = var buffer: array[256, float32] # Stack allocated - guaranteed safe # Array types are explicitly stack-based
Note: Nim's type system makes it easy to choose safe types, but detecting all heap allocations requires careful use of stack-allocated containers and type restrictions. This is similar to using alloca() or stack containers in C++.
Instead of wrapping C++ STL templates, Nimphea provides pure Nim implementations designed specifically for embedded use.
| Aspect | C++ Wrappers | Nim-Native |
|---|---|---|
| Type Safety | Limited (C++ templates are opaque) | Full Nim type checking |
| Error Messages | Cryptic template errors | Clear, helpful messages |
| Overhead | FFI boundary crossings | Zero-cost abstractions |
| Control | Limited to C++ API | Full control over memory layout |
| Integration | Foreign types | Native Nim semantics |
C++ libDaisy:
#include "util/fifo.h" daisy::FIFO<float, 128> fifo; // Template instantiation fifo.PushBack(42.0f); // C++ naming float val; fifo.PopFront(&val); // Pointer-based API
Nimphea:
import nimphea var fifo: FIFO[float, 128] # Generic, type-safe fifo.write(42.0) # Nim naming conventions let val = fifo.read() # Value-based API (safer) # Check before operations if not fifo.isFull(): fifo.write(3.14) if not fifo.isEmpty(): echo "Got: ", fifo.read()
Benefits:
A zero-allocation string type for embedded systems.
The Problem:
# Standard Nim strings allocate on heap: var name = "Daisy" # Heap allocated - NOT safe in audio callbacks!
The Solution:
import nimphea var name: FixedStr[32] # Stack allocated, max 32 chars name = "Daisy Seed" # No heap allocation! name.add(" Audio") # Still no heap! echo name # "Daisy Seed Audio" echo name.len # 16 echo name.capacity # 32
Features:
Use Cases:
# Display text without heap allocation var display: OledDisplay var msg: FixedStr[20] msg = "Volume: " msg.add($volumeLevel) # String conversion, still no heap! display.writeString(0, 0, msg.cstring, Font_7x10, true) # Debug logging in realtime code var log: FixedStr[64] log = "Sample rate: " log.add($sampleRate) usbLogger.print(log)
Optimized for audio applications:
import nimphea # Lock-free, single-producer single-consumer var rb: RingBuffer[AudioSample, 1024] # Producer (audio callback) proc audioCallback(input, output: ptr ptr cfloat, size: int) {.cdecl.} = for i in 0..<size: if not rb.isFull(): rb.write(input[0][i]) # Consumer (main thread) while not rb.isEmpty(): let sample = rb.read() processAudioData(sample)
Features:
Type-safe, bounded stack:
import nimphea var history: Stack[MidiNote, 16] # Max 16 notes # Push note if not history.isFull(): history.push(MidiNote(note: 60, velocity: 100)) # Pop note if not history.isEmpty(): let lastNote = history.pop() echo "Last note: ", lastNote.note
Nim's powerful macro system enables code generation at compile time, reducing boilerplate and improving safety.
Problem: C++ uses namespaces, Nim uses modules.
Solution: The useDaisyNamespace() macro imports and re-exports all common types automatically.
import nimphea useDaisyNamespace() # Macro expands to import all needed types # Now you can use Daisy types directly: var hw: DaisySeed var led: Led var sw: Switch # No need for: libdaisy.DaisySeed, libdaisy.Led, etc.
Generated code (what the macro expands to):
# Auto-imports and re-exports: import nimphea export DaisyGPIO, Pin, PinMode, Pull import hid/ctrl export Switch, Encoder, Led, RgbLed # ... and many more modules
Benefits:
Selective imports for advanced users:
import nimphea_macros useDaisyModules(gpio, controls, audio) # Import only what you need # Now have: DaisyGPIO, Switch, Encoder, AudioCallback # But not: OLED types, MIDI types, etc.
Benefits:
Declarative menu construction:
import src/ui/menu_builder var volume = linear(0.0, 100.0, 50.0, "%") var mute = false # Define menu using DSL defineMenu mainMenu: value "Volume", volume checkbox "Mute", mute action "Save", saveSettings close "Exit" # Generated at compile-time: # - Menu structure # - Item array # - Type-safe bindings
Expanded code (what macro generates):
var mainMenu: BuiltMenu[4] mainMenu.items[0] = valueItem("Volume", addr volume) mainMenu.items[1] = checkboxItem("Mute", addr mute) mainMenu.items[2] = actionItem("Save", saveSettings) mainMenu.items[3] = closeItem("Exit") mainMenu.menu = initFullScreenItemMenu(mainMenu.items)
Nim templates are hygienic macros that expand inline at compile time - zero runtime overhead.
template withBounds[T](buffer: openArray[T], index: int, body: untyped) = if index >= 0 and index < buffer.len: body else: # Compile-time error or runtime assertion {.error: "Index out of bounds".} # Usage var samples: array[256, float] withBounds(samples, idx): samples[idx] = 0.5 # Checked access, zero overhead
template stereoProcess(input, output: AudioBuffer, size: int, processBody: untyped) = for i in 0..<size: let left {.inject.} = input[0][i] let right {.inject.} = input[1][i] processBody # User code injected here output[0][i] = left output[1][i] = right # Use the template proc audioCallback(input, output: ptr ptr cfloat, size: int) {.cdecl.} = stereoProcess(input, output, size): # 'left' and 'right' variables are automatically available left = left * 0.5 # Apply gain right = right * 0.5
Benefits:
Nim generics are fully type-checked at definition time. C++ templates traditionally use duck typing, but C++20 added concepts for similar checking.
C++ Templates (traditional, duck typing):
template<typename T> void process(T& value) { value.start(); // Error only when instantiated value.unknown(); // Cryptic template instantiation error }
C++ with Concepts (C++20, type-checked):
template<typename T> concept Startable = requires(T t) { t.start(); // Explicit requirements }; template<Startable T> void process(T& value) { value.start(); // Type-checked at definition value.unknown(); // ERROR: Clear error at definition time }
Nim Generics (type-checked):
type Startable = concept c c.start() # Explicit constraints proc process[T: Startable](value: var T) = value.start() # Type-checked at definition value.unknown() # ERROR: Clear message at definition time
Comparison:
| Aspect | C++ Templates | C++20 Concepts | Nim Generics |
|---|---|---|---|
| Type checking | Duck typing (at instantiation) | Explicit (at definition) | Explicit (at definition) |
| Error messages | Cryptic, instantiation-related | Clear, requirement-focused | Clear, requirement-focused |
| Availability | All versions | C++20+ only | All Nim versions |
| Constraint syntax | Implicit (code attempts) | Explicit requires | Explicit concept |
Benefits:
C++ libDaisy:
#include "daisy_seed.h" daisy::DaisySeed hw; void AudioCallback(AudioHandle::InputBuffer in, AudioHandle::OutputBuffer out, size_t size) { for(size_t i = 0; i < size; i++) { out[0][i] = in[0][i]; out[1][i] = in[1][i]; } } int main() { hw.Init(); hw.StartAudio(AudioCallback); while(1) {} }
Nimphea:
import nimphea useDaisyNamespace() proc audioCallback(input, output: ptr ptr cfloat, size: csize_t) {.cdecl.} = for i in 0..<size: output[0][i] = input[0][i] output[1][i] = input[1][i] proc main() = var hw = initDaisy() hw.startAudio(audioCallback) while true: discard when isMainModule: main()
Improvements:
C++ Template Error (can be 100+ lines):
error: no matching function for call to 'FIFO<float, 128>::PushBack(int)'
fifo.PushBack(42);
^~~~~~~~
note: candidate: void daisy::FIFO<T, capacity>::PushBack(const T&) [with T = float; long unsigned int capacity = 128]
... 50 more lines of template instantiation context ...
Nim Error (clear and concise):
Error: type mismatch: got <int> but expected 'float'
fifo.write(42)
^
Hint: convert with 42.0 or 42.cfloat
Method-like calls on any type:
# Traditional function call echo repeat("*", 10) # UFCS - call as method "*".repeat(10).echo() # Chain operations let result = "hello" .toUpper() .repeat(3) .split("E") # Works with Daisy types too: var hw = initDaisy() hw.setLed(true) # Method syntax setLed(hw, true) # Function syntax - both work!
Run code at compile time to generate lookup tables, validate configurations, etc.
Nim:
import math # Generate sine table at compile time const WaveTableSize = 256 proc generateSineTable(): array[WaveTableSize, float] = for i in 0..<WaveTableSize: result[i] = sin(2.0 * PI * i.float / WaveTableSize.float) # Executed at compile time - zero runtime cost! const SineTable = generateSineTable() # Use in audio callback - just a memory lookup proc audioCallback(input, output: ptr ptr cfloat, size: csize_t) {.cdecl.} = var phase = 0 for i in 0..<size: output[0][i] = SineTable[phase] phase = (phase + 1) mod WaveTableSize
C++ Equivalent (constexpr):
#include <cmath> #include <array> constexpr std::array<float, 256> generateSineTable() { std::array<float, 256> result{}; for (int i = 0; i < 256; i++) { result[i] = std::sin(2.0f * M_PI * i / 256.0f); } return result; } // Executed at compile time - equivalent to Nim's approach constexpr auto SineTable = generateSineTable(); void audioCallback(float** input, float** output, size_t size) { static int phase = 0; for (size_t i = 0; i < size; i++) { output[0][i] = SineTable[phase]; phase = (phase + 1) % 256; } }
Comparison:
| Aspect | Nim CTFE | C++ constexpr |
|---|---|---|
| Syntax | Implicit with const | Explicit constexpr keyword |
| Flexibility | Works with any function | Limited to certain operations |
| Runtime cost | Zero | Zero |
| Compile time impact | Can be slow for large tables | Generally faster |
Benefits (Both Languages):
Note: Modern C++ (C++20+) supports most compile-time code generation via constexpr. Nim's CTFE is more general and flexible, but both achieve the goal of zero-cost initialization.
Create incompatible types from the same base type to prevent mixing values:
type Hertz = distinct float Seconds = distinct float Decibels = distinct float proc setFrequency(freq: Hertz) = discard proc setDelay(time: Seconds) = discard let freq: Hertz = 440.0.Hertz let time: Seconds = 0.5.Seconds setFrequency(freq) # OK setFrequency(time) # ERROR: Type mismatch - prevented at compile time!
Safe enum handling without undefined values:
type SampleRate = enum SR_8KHZ = 8000 SR_16KHZ = 16000 SR_44_1KHZ = 44100 SR_48KHZ = 48000 SR_96KHZ = 96000 proc configureSampleRate(sr: SampleRate) = # Compiler ensures all cases are handled case sr of SR_8KHZ: echo "8kHz" of SR_16KHZ: echo "16kHz" of SR_44_1KHZ: echo "44.1kHz" of SR_48KHZ: echo "48kHz" # Missing SR_96KHZ - COMPILER ERROR!
Safe handling of optional values without null pointers:
import std/options type MidiMessage = object note: int velocity: int proc getNextMidiEvent(): Option[MidiMessage] = if hasEvent(): some(MidiMessage(note: 60, velocity: 100)) else: none(MidiMessage) # Safe usage let maybeEvent = getNextMidiEvent() if maybeEvent.isSome: let event = maybeEvent.get echo "Note: ", event.note # No null pointer crashes!
Explicit control over inlining:
proc readGPIO(pin: DaisyGPIO): bool {.inline.} = # Always inlined - zero function call overhead pin.read() proc complexCalculation(): float {.noinline.} = # Never inlined - saves code size # ... complex code ...
When you need maximum performance, directly emit C++ code:
proc fastCopy(dest, src: pointer, size: int) {.importc: "memcpy", header: "<string.h>".} # Or emit inline C++: proc atomicIncrement(p: ptr int): int = {.emit: """ return __atomic_fetch_add(`p`, 1, __ATOMIC_SEQ_CST); """.}
Nim uses static dispatch by default - no vtable lookups:
# Static dispatch - resolved at compile time proc process(sw: var Switch) = discard proc process(enc: var Encoder) = discard # Method syntax, but static dispatch: sw.process() # No virtual call overhead enc.process() # Direct function call
Generic specialization at compile time:
proc process[T](control: var T) = when T is Switch: # Specialized code for Switch control.debounce() elif T is Encoder: # Specialized code for Encoder control.update() # Code specialized for each type - no runtime checks!
Nimphea leverages Nim's unique features to provide:
These features combine to create a safer, more expressive, and equally performant alternative to C++ for embedded audio development on the Daisy platform.
This document highlights Nim's strengths. Modern C++ (especially C++20+) has evolved significantly and now provides many similar features (concepts, constexpr, structured bindings, etc.). The advantage of Nimphea is consistent availability across all versions and simpler syntax for these features, not necessarily unique capabilities. Both languages are capable for embedded audio work—Nim simply prioritizes these patterns as first-class features.
Next Steps: