LED output, colour and timing¶
Output safety and maths¶
Palettes/settings use 8-bit RGB; effect interpolation, fades, rainbow generation and
logical buffers use 16-bit RGB, followed by float PWM duties. Cached gamma gains
are interpolated using the full render peak (no truncation back to 8-bit).
The pipeline is:
stored sRGB colour → decoded linear-light colour ratios → 16-bit effect interpolation/fades →
optional main enforcer remap → configurable gamma (default 2.2) on peak intensity, using a common RGB gain → global PWM
brightness → background underlay blend (also remapped in enforcer mode) → palette motion accents → Raised tip replacement → forward-pulse replacement → gesture colour blend → RGB output trims → shared estimated-current limit → near-black pixel cutoff → wire quantisation → per-ear direction/colour
order → SPI DMA (S3) / RMT (ESP32). UI/palette colours are sRGB-encoded: their raw byte ratios are not linear
LED duties. A 512-byte lookup decodes their colour ratios while retaining the encoded peak
for the existing brightness curve; pastel flag colours therefore carry less unwanted white.
The common fade gain preserves these decoded ratios instead of crushing weaker channels
(e.g. turning orange red). At 20% control (20% PWM), stock orange #ff8c00 emits (51,13,0) without dithering before current
limiting; stock violet is #6000ff. These are visual starting points, not measured colour
calibration. Untouched legacy stock palettes refresh on load; custom RGB values remain intact.
Flashlight is a temporary warm-white replacement, available through the catalogue
controls in both phone apps and the browser. Its saved brightness is independent
of normal brightness; activation is volatile. Global off ends it. Enabling it from
off temporarily powers the LEDs and dismissal returns to the underlying power
choice. Physical trims and current limiting still apply. The current warm RGB
tuple is provisional, pending mounted white calibration. See the
state and output contract for the detailed semantics.
Fade gamma is runtime configurable from 1.0–4.0, default 2.2. It changes peak
intensity/fades with one common RGB gain, preserving colour ratios; it does not adjust
white balance. The inverse sRGB decoding curve remains fixed. Gamma is applied before
brightness so a saturated primary can still reach 20% PWM.
Global brightness isn't raised to gamma;
main/background controls each run from 0–100% of the 100% hardware PWM ceiling.
20% control therefore gives 20% PWM; 100% gives 100%. Scaling retains fractions. Floats preserve fractional contributions
until the final conversion; sub-one-byte channels necessarily quantise to black, with
temporal dithering recovers fractional wire levels (see below).
The optional background glow is a constant colour beneath dark animation areas. Its
independent 0–100% brightness uses the same hardware ceiling for a full-intensity colour;
0 disables it (default), with lilac #b482ff preselected. Try 3% for a dim lilac glow (3% PWM).
Foreground opacity follows its gamma-adjusted peak, so bright colours hide the underlay
and dark pixels reveal it. Background RGB receives the same hue-preserving gamma, then
both layers share the current limiter and wire cap. Diagnostic black/walk/primary stages
ignore the background; off mode and external flash pauses clear both strips. OTA uses
a latched progress bar without the background or gesture overlays.
Change background colour/brightness through the phone's confirmed controls. The
persisted JSON uses "background":{"brightness":3,"colour":[180,130,255]};
this is an NVS record format, not the browser's catalogue patch API.
LED output tuning in the app's Colours tab provides gamma plus
independent red/green/blue trims (0–100%, default 100/100/100). Trims attenuate the
complete composition, including glow, gestures and diagnostics, before current
limiting; they never boost channels or modify saved palette RGB. For a green cast,
leave gamma at 2.2 and start with green trim around 90–95%, then compare white and
pastel colours on the actual ears. That is a tuning starting point, not calibration.
Reset output tuning restores 2.2 / 100/100/100; it leaves palettes and brightness alone.
Native controls use confirmed application and coalesced saves. The NVS JSON encodes
"output":{"gamma":2.2,"rgb_trim":[100,95,100]}; the browser omits output tuning.
Schema 10 seeds missing values from Kconfig (ESPURR_GAMMA_X100 and the three trim
defaults); existing settings/palettes survive migration. The app disables tuning on
older firmware without BLE capability bit 9. Curves remain cached, rebuilt only on
brightness/gamma changes; trim edits do not add per-frame powf() work.
The output layer independently bounds PWM duty to 100% and final channel bytes to
255/255 (100%). There is no debug bypass. All modes share this pipeline. The limiter
uses configurable idle/full-channel estimates, scales both
ears together to preserve colours, and estimates current again from emitted bytes.
Its default 250 mA budget covers the ear strips only. The provisional model is
configurable in menuconfig: existing profiles retain 5 mA/channel and the legacy
1 mA idle/pixel default. Set ESPURR_LED_IDLE_UA=200 for the confirmed v2 WS2812C
0.2 mA idle/pixel coefficient. The default zero value selects the existing
ESPURR_LED_IDLE_MA setting in its original mA units; it does not remove idle draw.
Fractional idle consumption is retained through limiting and output estimation,
including inactive powered tails. Select coefficients for the actual LED part,
for the selected WS2812 parts. This is not a measured electrical limit or a substitute
for appropriate wiring/power. The owner reported eight hours of wearing the ears
using less than 20% of a 10,000 mAh USB
power bank on the previous firmware. That supports low average consumption but
does not establish full-duty channel current. Actual strip current needs measuring independently
of the controller, with the limiter nonbinding. At 36 pixels, full white and 40% control
(40% requested PWM) model about 252 mA with the legacy idle coefficient before
limiting, so the default budget still reduces that output. A whole-device power reading while limiting cannot calibrate the
full-duty channel coefficient.
leds_off() clears both buffers and attempts black output on both ears even if one
fails. Orderly esp_restart() shutdown requests a render-owned clear; shutdown callbacks
do not race the LED transport directly. Output errors trigger a
best-effort clear and a serial error; firmware doesn't deliberately leave an active frame
after an error. A crash, brownout, external reset, or broken data wire cannot guarantee
black output while strip power persists. True power removal is a hardware concern.
Experimental smooth fades¶
Smooth low-brightness fades is enabled by default (CONFIG_ESPURR_TEMPORAL_DITHER=y).
Toggle it in phone Setup for an immediate comparison; both modes keep
16-bit effect rendering. The NVS record uses "dither":false or true; Kconfig seeds
the first-boot default. The minimal browser has no dither editor. Diagnostic mode
always disables dithering and the background.
Normal firmware separates a 50 Hz animation/composition worker from the output/control owner. The worker owns effect/overlay state and cached gamma, background and current-limiting maths. It receives copied settings, motion and audio snapshots and publishes immutable fractional PWM duties. Stored network credentials and the saved palette library are excluded from its settings copy. The owner quantises and transmits at up to 450 Hz on S3 (nine phases per 20 ms) or 500 Hz on ESP32 (ten phases), adapting in 50 Hz steps. Diagnostics and update progress retain synchronous composition in the output owner; recovery has no animation worker.
The owner runs audio/session polling, brightness ramps, save policy, effect demand, timing-policy configuration and ordinary status publication on the same anchored 50 Hz cadence. Queued commands also run that work on their next output slot; BLE control and web-patch results retain publication before completion. Power allowances, reduced-limit blacking, generation checks, DMA handoff and adaptive deadline accounting remain per-slot. Frame configuration is copied only when requesting a new animation generation; ordinary subframes inspect its generation without copying the full configuration. Five-second task reports are polled on the 50 Hz cadence after output drains.
Requests and results each use three fixed slots with atomic index exchange. A producer replaces pending work with its latest frame, while the consumer's slot remains immutable. There is no frame FIFO, render mutex or wait for animation in the output path. A late worker leaves the last complete frame dithering normally. Generation checks reject frames predating settings, topology, flashlight, off or flash transitions. A reduced supply or saved user-current allowance forces black before waiting for fresh composition; an old worker result cannot restore it.
A one-shot esp_timer only notifies the output owner, with no busy loop or
FreeRTOS tick-rate increase. Normal S3 builds default to interrupt dispatch
(ESPURR_OUTPUT_ISR_TIMER): the IRAM callback uses a FromISR task notification
and IDF's deferred ISR-yield request. Encoding and transport remain task-owned.
Disable that option to compare task dispatch; original ESP32 defaults to task
dispatch and recovery has no normal output clock. Existing timer/core affinity
is retained. Its anchored 50 Hz deadlines publish worker requests;
the worker sleeps on notifications between jobs. Palette colours are decoded once
per animation pass, then reused by all pixels. Gamma curves are rebuilt only when
their inputs change. IMU filtering runs outside its short snapshot-publication
lock. On multicore builds output and animation are pinned to core 1, at
priorities 6 and 4. Wi-Fi, the Bluetooth controller, NimBLE, system events,
lwIP TCP/IP, HTTP server, radio/config supervisors, AP provisioning, BLE OTA/objects
and the normal OTA flash worker run on core 0. The FreeRTOS software timer
service also stays on core 0; it is separate from the output's esp_timer.
Recovery's OTA worker retains core 1 because it owns optional RMT progress output.
Existing SDK configurations must select CPU0 for LWIP_TCPIP_TASK_AFFINITY and
FREERTOS_TIMER_SERVICE_TASK_CORE_AFFINITY in menuconfig if they explicitly override
the project defaults. Output preempts animation at
its deadline. The owner creates the ear SPI/RMT transport there,
so its interrupts are allocated on core 1 as well; reconfiguration and cleanup stay in
the owner. Startup waits for LED initialisation and propagates errors before networking
starts. Single-core builds select core 0. The timer callback task defaults to core 0, so
its wakeups can still be delayed by radio work. Boot logs report the actual render/transport
initialisation core; timing/flicker under Wi-Fi association still need hardware verification.
The local AtomS3R investigation uses timer task/ISR affinity on core 1 through
menuconfig. The s3-atoms3r-task-stats matrix case exercises that placement;
fresh profile defaults retain IDF's core-0 timer placement.
Core affinity does not isolate flash/cache stalls: ordinary SPI1 flash writes can still suspend work on both cores. PSRAM XiP and flash auto-suspend remain disabled; see the affinity and flash investigation.
The optional CONFIG_ESPURR_PROFILE debug build separates control/request work,
quantisation, SPI packing/submission/completion wait, publication and scheduling.
It also profiles IMU stages and enables native esp_timer callback statistics.
Counters are bounded, use internal RAM and print only during the existing drained
report pause. They measure elapsed time, including interrupts and blocking;
completion wait is not a CPU utilisation measurement. The normal configuration
compiles this instrumentation out. See the
profiling and BMI270 power-mode record.
The quantiser floors requested duty to 1/N counts at N output phases per 20 ms
(ninths on S3 or tenths on ESP32 at full speed), then
distributes upper-count pulses evenly with staggered pixel phases. For example,
S3 emits seven 2s and two 3s for a duty of 2 + 2/9 counts over its nine passes;
ESP32 emits seven 2s and three 3s for 2.3 counts over ten passes. This is time-averaged
wire precision; the LED's internal PWM may
limit optical results. A completed output advances the phase, and ordinary animation
or brightness updates retain it. Off/clear and changes to dither policy restart it.
The owner caches each channel's integer base/remainder when adopting new duties, with one shared pulse mask per remainder and phase count. Subframes use integer lookup/add/shift operations instead of repeating float conversion and division. Phase-count/configuration changes invalidate that cache; hard channel caps are applied during preparation. This retains the existing phase bytes and current bounds without allocating one full frame per phase. The nominal output clock uses a quotient/remainder accumulator on ordinary slots; whole-period recovery maths is reserved for missed deadlines, retaining exact 20 ms phase intervals.
To suppress visible near-black shimmer, the final composite pixel goes dark when
its brightest channel is below 0.5/255 wire duty, after current limiting. At or
above that threshold, weak channels in brighter pixels retain fractional resolution.
The threshold is LED_BLACK_FLOOR_COUNTS, independent of the selected phase count.
The limiter reserves one count per nonzero channel before selecting the common gain, so each individual phase fits the estimated current budget, including rounded-up channels. This is conservative and can reduce average brightness near the current limit. The 100% per-channel hardware ceiling remains enforced in every phase.
Parallel WS2812 output budgets for the longer ear, not the sum of both lengths. Ears with
at most 57 pixels per side on S3 fit the 2.222 ms estimate; 47 on ESP32 fit 2 ms.
Longer chains select the highest 50 Hz step that fits the same wire/software
budget. All supported 64-pixel v1 chains fit the 400 Hz estimate on both targets.
These are scheduling estimates subject to loaded measurements and the overrun guard.
Normal S3 output uses SPI2 DMA at 3.2 MHz, with each LED bit encoded as four
SPI clocks: 1000 for zero (312.5/937.5 ns high/low), 1100 for one
(625/625 ns), and a common 300 µs all-low reset tail. Fifty pixels take 1800 µs including reset, leaving
about 422 µs before the next S3 slot for encoding, driver overhead and scheduling.
This is a target budget pending
loaded hardware measurement. The original ESP32 retains its existing 10 MHz waveform and
300 µs software margin. Legacy S3 v1 profiles use the same WS2812C waveform as v2.
Dual output maps v1 ears to SPI2 D0/D1; quad output maps v2 outer/inner chains
to D0/D1/D2/D3, retaining their existing GPIOs through the GPIO matrix.
Shorter chains receive encoded black padding up to the longest chain before reset.
A single enabled v1 ear uses one lane; two disabled ears allocate no SPI resources.
V2 retains all four powered physical chains even when a logical ear is disabled.
Single/dual-lane nibble tables and a quad-lane bit-pair table update packed words
instead of rebuilding bit addresses for each colour bit. The 112 bytes of constant
tables change only each lane's data tick, preserving pulse timing, neighbouring
lanes and black padding. They require no runtime allocation or PSRAM.
Each internal DMA buffer uses 12 bytes per padded pixel per lane plus 120 bytes
per lane for reset. Two buffers total 1344 bytes for 18/18 pixels, 2880 bytes
for 50/50 pixels, or 5568 bytes for v2, excluding driver allocations.
No ISR refills the waveform; completion and render scheduling can still be delayed.
SPI3 remains available for the AtomS3R display. SPI initialisation rejects a clock
other than exactly 3.2 MHz. Buffers and transaction descriptors remain immutable
until completion, including failed drains. A successful launch swaps writable
buffers; the output owner then adopts any newly composed duties and prepares the
following phase while DMA runs. Only one transaction is in flight. An adopted
frame appears in the following output buffer, adding approximately one output
slot of latency (2.22 ms on S3) after adoption; worker completion may wait another
slot before adoption. Settings/power/topology changes invalidate prepared output.
Phase advancement and current telemetry commit only after completion. Flash holds,
off/reconfiguration and task reporting still drain output. Only the owner touches
the SPI/RMT transport; the animation worker performs CPU work without I/O.
The ESP32 waveform and RMT backend are unchanged. Optional S3 recovery LED progress
retains its 20 MHz RMT waveform and bounded timeout/stop path.
The attached AtomS3R/v1 serial check is recorded in the
animation-worker acceptance record.
Active radio interaction, v2 hardware and physical waveform acceptance remain pending. AtomS3R
keeps BMI270 sampling on core 0 and holds the ears black for two seconds after
render initialisation, allowing sensor/display/radio startup to settle before
trying its selected output rate. Four consecutive late passes reduce the output
rate by 50 Hz, keeping animation at 50 Hz. The rate can step down to 50 Hz
without dithering; every higher step uses the corresponding phase count. After
ten clean seconds of completed output, one 50 Hz step is retried, up to the
topology's ceiling. A real late pass restarts clean-time observation. Deliberate
NVS/flash/report/command pauses contribute no recovery time and break the late
streak, but do not discard clean time accumulated before a pause. Periodic debug
reports therefore cannot prevent recovery indefinitely.
Ordinary settings edits retain the learned rate. Explicit dither off/on or a changed topology ceiling starts a new full-speed trial. Adaptive state lives only in RAM; changing rate does not save settings or write flash. Rate changes drain the active transaction, discard packed bytes and reset the phase before reuse. The current limiter retains its conservative upper-count headroom at every dither rate. Each completed pass arms a one-shot timer for the next absolute deadline. Deadlines stay anchored to the output clock rather than actual wake times. Each selected phase count divides exactly 20 ms across its integer intervals; nine S3 intervals use 2222/2223 µs at full speed. A fresh buffer less than one period late gets one alarm 50 µs in the future; the following deadline returns to the nominal clock. Whole missed periods are discarded, and deliberate pauses reset the clock. There is no backlog of historical frames or periodic notifications during DMA. Off and external flash holds clear the LEDs; a settings save briefly holds the last latched frame. Clears/topology changes restart the dither phase safely.
/api/status and the UI report measured output_hz, output_frames, output_late,
dither_active and dither_fallback; frames counts adopted animation frames
(including disabled-output ticks) and synchronous diagnostic passes.
The serial log reports one timing summary per smoothing trial and detailed timings on
fallback: wake is dispatch/scheduling delay, work is the complete owner pass (including
mutex waits), render covers request publication/frame adoption, and output
includes quantisation, wire encoding, launch and completion waits. On pipelined SPI
its span includes next-phase preparation. animation is the worker's wall time,
including preemption. early/fresh count prepared-buffer launches and uncached
launch preparation in that timing trial. Peak values are independent maxima.
Rollback health counts delivered fresh animation frames, or disabled/diagnostic
50 Hz passes, rather than repeated dither phases.
The owner reports no bad visible flickering after overlapping output and the softened
0.5-count cutoff at the previous 400 Hz target, and later reported the initial SPI
backend working well. Serial checks of the separated worker are recorded above.
Visual acceptance of the new image, active radio tests and waveform measurement
remain separate checks.
The adaptive-rate and LCD record covers
the later recovery policy, task affinity and cached CPU/rate display.
Hardware comparison: use breathing or Motion twinkle at low main brightness, initially with background at 0. Check orange/violet as they fade, then enable lilac background and compare smoothing on/off. Check peripheral vision, quick head movement and phone video for flicker/colour trails. Confirm measured output is close to the selected 450/500 Hz target with Wi-Fi and BLE active; check off/on, settings saves, config entry/exit and OTA pause/resume. Verify shrink/disable/reverse still clears old tail pixels. Disable smoothing if the visual tradeoff is worse; record any timing fallback or recurring late slots.