neiam
/lambent_ex
Lambent Aether 5
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Drive machines from the flex-i2s analysers over MQTT
4261038 · Gabriel Morell · 2026-10-02 23:43
Message
{commit_body(@commit)}
Files changed
modified
lib/lambent_ex/machine.ex
+75
−58
| 8 | 8 | @pubsub_topic "machine-" |
| 9 | 9 | @pubsub_topic_fh "machine:" |
| 10 | 10 | @pubsub_topic_idx "machines_idx" |
| 11 | + # its own topic: most of what listens on machines_idx has no clause for | |
| 12 | + # anything but a publish, Meta included | |
| 13 | + @pubsub_topic_gone "machines_gone" | |
| 14 | + | |
| 15 | + # how fast brightness moves toward a new target: the whole range in a second, | |
| 16 | + # whatever speed the machine steps at | |
| 17 | + @bright_per_sec 255 | |
| 11 | 18 | |
| 12 | 19 | alias LambentEx.Utils.Brightness, as: B |
| 13 | 20 |
| 42 | 49 | :step_opts, |
| 43 | 50 | :name, |
| 44 | 51 | single: false, |
| 52 | + # a single step's read is repeated to fill the strand; with this off it | |
| 53 | + # is drawn once and the rest of the strand is left dark | |
| 54 | + repeat: true, | |
| 45 | 55 | persist: false, |
| 46 | 56 | count: 300, |
| 47 | 57 | speed: 1000 |
| 101 | 111 | bright_curr: 255, |
| 102 | 112 | # the global clamp, re-read once per step rather than per pixel |
| 103 | 113 | bright_global: 255, |
| 104 | − bright_delay: 0, | |
| 105 | − bright_mvmul: 2, | |
| 114 | + # when brightness last moved, for the ramp | |
| 115 | + bright_at: System.monotonic_time(:millisecond), | |
| 106 | 116 | cnt: cnt, |
| 107 | 117 | single: opts[:single], |
| 118 | + repeat: opts[:repeat], | |
| 108 | 119 | started: DateTime.utc_now(), |
| 109 | 120 | persist: opts[:persist], |
| 110 | 121 | # modulating publishes |
| 165 | 176 | via_tuple("machine-#{id}") |> GenServer.cast({:set_persist, val}) |
| 166 | 177 | end |
| 167 | 178 | |
| 179 | + # stopped from the caller, not by casting the machine and having it ask its | |
| 180 | + # own supervisor: a machine is a parent with an infinite shutdown, so the | |
| 181 | + # supervisor would wait on it forever while it waited on the supervisor, and | |
| 182 | + # no machine could be started or stopped after that | |
| 168 | 183 | def quit(id) do |
| 169 | − via_tuple("machine-#{id}") |> GenServer.cast(:quit) | |
| 184 | + case Registry.lookup(@registry, "machine-#{id}") do | |
| 185 | + [{pid, _value}] -> LambentEx.MachineSupervisor.abort_child(pid) | |
| 186 | + [] -> :ok | |
| 187 | + end | |
| 188 | + | |
| 189 | + # a machine that is gone just stops publishing, which nothing listening can | |
| 190 | + # tell from a slow one, so say so | |
| 191 | + Phoenix.PubSub.broadcast(@pubsub_name, @pubsub_topic_gone, {:machines_gone, id}) | |
| 192 | + :ok | |
| 170 | 193 | end |
| 171 | 194 | |
| 195 | + @doc "The topic `{:machines_gone, name}` goes out on when a machine is quit." | |
| 196 | + def gone_topic, do: @pubsub_topic_gone | |
| 197 | + | |
| 172 | 198 | @doc "What a machine publishes about itself, or an empty map if it is gone." |
| 173 | 199 | def info(name) do |
| 174 | 200 | via_tuple("machine-#{name}") |> GenServer.call(:info) |
| 273 | 299 | } |
| 274 | 300 | end |
| 275 | 301 | |
| 302 | + # the firehose carries the first 32 pixels as [r, g, b]. a step that draws a | |
| 303 | + # whole strand reads as a list of pixels rather than one, and the per-pixel | |
| 304 | + # steps read as one each, so flatten either and cut it back into pixels | |
| 305 | + defp preview_pixels(data) do | |
| 306 | + data |> List.flatten() |> Enum.chunk_every(3, 3, :discard) |> Enum.take(32) | |
| 307 | + end | |
| 308 | + | |
| 276 | 309 | @impl true |
| 277 | 310 | def handle_info(:step, state) do |
| 278 | 311 | Process.send_after(self(), :step, state.speed) |
| 297 | 330 | data |
| 298 | 331 | end |
| 299 | 332 | |
| 300 | − case state[:single] do | |
| 301 | − # expand single to fill cnt | |
| 302 | − true -> | |
| 303 | − data = | |
| 304 | − data | |
| 305 | − |> List.flatten() | |
| 306 | − |> Stream.cycle() | |
| 333 | + # expand single to fill cnt. kept out here rather than rebound inside the | |
| 334 | + # case below, so the firehose shows the strand that was sent and not one | |
| 335 | + # read of the step | |
| 336 | + frame = | |
| 337 | + case state[:single] do | |
| 338 | + true -> | |
| 339 | + flat = data |> List.flatten() | |
| 340 | + | |
| 341 | + flat | |
| 342 | + |> Stream.concat( | |
| 343 | + if(state[:repeat], do: Stream.cycle(flat), else: Stream.repeatedly(fn -> 0 end)) | |
| 344 | + ) | |
| 307 | 345 | |> Enum.take(state[:cnt] * 3) |
| 308 | 346 | |> Enum.chunk_every(3) |
| 309 | 347 | |
| 310 | − Phoenix.PubSub.broadcast(@pubsub_name, @pubsub_topic <> state[:name], {:publish, data}) | |
| 348 | + _otherwise -> | |
| 349 | + data | |
| 350 | + end | |
| 351 | + | |
| 352 | + case state[:single] do | |
| 353 | + true -> | |
| 354 | + Phoenix.PubSub.broadcast(@pubsub_name, @pubsub_topic <> state[:name], {:publish, frame}) | |
| 311 | 355 | |
| 312 | 356 | # do nothing |
| 313 | 357 | false -> |
| 338 | 382 | Phoenix.PubSub.broadcast( |
| 339 | 383 | @pubsub_name, |
| 340 | 384 | @pubsub_topic_fh, |
| 341 | − {:firehose, {state[:name] <> Atom.to_string(k), datum |> Enum.slice(0..31)}} | |
| 385 | + {:firehose, {state[:name] <> Atom.to_string(k), datum |> preview_pixels()}} | |
| 342 | 386 | ) |
| 343 | 387 | end) |
| 344 | 388 |
| 350 | 394 | Phoenix.PubSub.broadcast( |
| 351 | 395 | @pubsub_name, |
| 352 | 396 | @pubsub_topic_fh, |
| 353 | − {:firehose, {state[:name], data |> Enum.slice(0..31)}} | |
| 397 | + {:firehose, {state[:name], frame |> preview_pixels()}} | |
| 354 | 398 | ) |
| 355 | 399 | |
| 356 | 400 | state |> Map.put(:modpub, 0) |
| 363 | 407 | Phoenix.PubSub.broadcast( |
| 364 | 408 | @pubsub_name, |
| 365 | 409 | @pubsub_topic_fh, |
| 366 | − {:firehose, {state[:name], data |> Enum.slice(0..31)}} | |
| 410 | + {:firehose, {state[:name], frame |> preview_pixels()}} | |
| 367 | 411 | ) |
| 368 | 412 | |
| 369 | 413 | state |
| 441 | 485 | end |
| 442 | 486 | end |
| 443 | 487 | |
| 444 | − defp do_bright_step(tgt, curr) do | |
| 445 | − if curr < tgt do | |
| 446 | − curr + 1 | |
| 447 | − else | |
| 448 | − curr - 1 | |
| 449 | − end | |
| 450 | − end | |
| 451 | − | |
| 488 | + # brightness walks toward its target by the time since it last moved, so a | |
| 489 | + # dimmer press lands in about the same time on a 50 ms machine as a 5 s one. | |
| 490 | + # it used to move a single step per tick, with a delay on top for quick | |
| 491 | + # machines, which took minutes to get anywhere on most of them | |
| 452 | 492 | defp bright_step(state) do |
| 493 | + now = System.monotonic_time(:millisecond) | |
| 494 | + # a machine started before this ramp existed, still running, has no clock | |
| 495 | + last = Map.get(state, :bright_at, now) | |
| 496 | + | |
| 453 | 497 | case state.bright_tgt == state.bright_curr do |
| 454 | 498 | true -> |
| 455 | − state | |
| 499 | + Map.put(state, :bright_at, now) | |
| 456 | 500 | |
| 457 | 501 | false -> |
| 458 | − cond do | |
| 459 | − state.speed < 100 -> | |
| 460 | − case state.bright_delay do | |
| 461 | − d when d in 1..(5 * state.bright_mvmul) -> | |
| 462 | − state | |
| 463 | − |> Map.put(:bright_delay, state.bright_delay + 1) | |
| 464 | − | |
| 465 | − _ -> | |
| 466 | − state | |
| 467 | − |> Map.put(:bright_curr, do_bright_step(state.bright_tgt, state.bright_curr)) | |
| 468 | − |> Map.put(:bright_delay, 0) | |
| 469 | − end | |
| 470 | − | |
| 471 | − state.speed < 5000 -> | |
| 472 | − case state.bright_delay do | |
| 473 | − d when d in 1..(2 * state.bright_mvmul) -> | |
| 474 | − state | |
| 475 | − |> Map.put(:bright_delay, state.bright_delay + 1) | |
| 476 | − | |
| 477 | − _ -> | |
| 478 | − state | |
| 479 | − |> Map.put(:bright_curr, do_bright_step(state.bright_tgt, state.bright_curr)) | |
| 480 | − |> Map.put(:bright_delay, 0) | |
| 481 | − end | |
| 482 | − | |
| 483 | − true -> | |
| 484 | − state | |
| 485 | − |> Map.put(:bright_curr, do_bright_step(state.bright_tgt, state.bright_curr)) | |
| 486 | − end | |
| 502 | + by = max(round(@bright_per_sec * (now - last) / 1000), 1) | |
| 503 | + | |
| 504 | + curr = | |
| 505 | + if state.bright_curr < state.bright_tgt, | |
| 506 | + do: min(state.bright_curr + by, state.bright_tgt), | |
| 507 | + else: max(state.bright_curr - by, state.bright_tgt) | |
| 508 | + | |
| 509 | + state |> Map.put(:bright_curr, curr) |> Map.put(:bright_at, now) | |
| 487 | 510 | end |
| 488 | 511 | end |
| 489 | 512 |
| 524 | 547 | {:noreply, state |> Map.put(:persist, val)} |
| 525 | 548 | end |
| 526 | 549 | |
| 527 | − @impl | |
| 528 | − def handle_cast(:quit, state) do | |
| 529 | − LambentEx.MachineSupervisor.abort_child(self()) | |
| 530 | − {:noreply, state} | |
| 531 | − end | |
| 532 | − | |
| 533 | 550 | @impl true |
| 534 | 551 | def handle_call(:info, _from, state) do |
| 535 | 552 | {:reply, publish(state), state} |
modified
lib/lambent_ex/machine/preview.ex
+3
−1
| 23 | 23 | def strip(nil, _args, _count), do: [] |
| 24 | 24 | |
| 25 | 25 | def strip(step, args, count) do |
| 26 | − case GenServer.start(step, args |> Map.put(:id, 0)) do | |
| 26 | + # :preview tells a step that keeps time by the clock to step a fixed slice | |
| 27 | + # per read instead; the rest ignore it | |
| 28 | + case GenServer.start(step, args |> Map.put(:id, 0) |> Map.put(:preview, true)) do | |
| 27 | 29 | {:ok, pid} -> read(pid, count) |
| 28 | 30 | _otherwise -> [] |
| 29 | 31 | end |
added
lib/lambent_ex/machine/steps/breathe.ex
+271
−0
| 1 | +defmodule LambentEx.Machine.Steps.Breathe.Cfg do | |
| 2 | + defstruct h: 0, | |
| 3 | + s: 255, | |
| 4 | + v: 255, | |
| 5 | + v_min: 0, | |
| 6 | + bpm: 60, | |
| 7 | + beats: 1, | |
| 8 | + hue_step: 20, | |
| 9 | + # follow the kick on a lambent-flex-i2s analyser instead of the | |
| 10 | + # fixed bpm. sensitivity is how far over its running average the | |
| 11 | + # 63 Hz band has to jump to count as a beat | |
| 12 | + broker: nil, | |
| 13 | + device: nil, | |
| 14 | + sensitivity: 6, | |
| 15 | + id: 0, | |
| 16 | + name: nil, | |
| 17 | + # the preview steps a fixed slice of a breath per read rather than | |
| 18 | + # following the clock, so 64 quick reads still show a few breaths | |
| 19 | + preview: false, | |
| 20 | + # breaths so far; the fraction is where in this one | |
| 21 | + breaths: 0.0, | |
| 22 | + last_step: nil, | |
| 23 | + ticks: 0, | |
| 24 | + # beat tracking | |
| 25 | + baseline: nil, | |
| 26 | + above: false, | |
| 27 | + onsets: [], | |
| 28 | + detected: nil, | |
| 29 | + # set once the analyser sends its own beats, after which its | |
| 30 | + # 250 ms readings are no longer second-guessed | |
| 31 | + device_beats: false | |
| 32 | +end | |
| 33 | + | |
| 34 | +defmodule LambentEx.Machine.Steps.Breathe do | |
| 35 | + @moduledoc """ | |
| 36 | + The whole strand breathes in time: dark, up to full, and back to dark once | |
| 37 | + per breath, on a cosine so it eases at both ends. | |
| 38 | + | |
| 39 | + A breath lasts `beats` beats. The hue moves on by `hue_step` with every | |
| 40 | + breath, at the dark point where the change cannot be seen; a step of 0 keeps | |
| 41 | + it a solid hue. `v_min` keeps it from going all the way out. | |
| 42 | + | |
| 43 | + The tempo is `bpm`, unless a `broker` and `device` are given: then it is | |
| 44 | + worked out from the analyser's 63 Hz band, with `bpm` as the guess until | |
| 45 | + there is enough to go on. | |
| 46 | + | |
| 47 | + ## Following the kick | |
| 48 | + | |
| 49 | + Firmware that finds the kicks itself sends a `beat` message for each one, | |
| 50 | + timed to its ~33 ms frames and carrying its tempo. Once one arrives, those | |
| 51 | + are what the breath follows: each one is a beat as it lands, and its `bpm` | |
| 52 | + is the tempo. The rest of this is the fallback for firmware that does not, | |
| 53 | + worked out from the 250 ms `peaks`. | |
| 54 | + | |
| 55 | + A beat is the 63 Hz band rising `sensitivity` dB over its running average. | |
| 56 | + The tempo is the beats counted over the time they span, across the last | |
| 57 | + ~12 s, which averages out where between two readings each kick landed; | |
| 58 | + gaps that skip a beat are counted as two. | |
| 59 | + | |
| 60 | + How well that works is down to how often the analyser publishes. A beat is | |
| 61 | + a rise, so it needs a quieter reading before it. At the analyser's default | |
| 62 | + 250 ms, tempos mirror about 120 BPM: 100 and 140 make the same rises, as do | |
| 63 | + 110 and 130, and nothing on this side can tell them apart. At 100 ms it is | |
| 64 | + within a beat a minute from 70 to 175 BPM. | |
| 65 | + | |
| 66 | + Each beat also nudges the breath toward the nearest beat boundary, so the | |
| 67 | + breathing settles in step with the music rather than drifting past it. | |
| 68 | + """ | |
| 69 | + use GenServer | |
| 70 | + | |
| 71 | + @registry :lambent_steps | |
| 72 | + @cls :breathe | |
| 73 | + # a preview read is this much of a breath | |
| 74 | + @preview_slice 1 / 16 | |
| 75 | + @kick_band 0 | |
| 76 | + @window_ms 12_000 | |
| 77 | + # the quickest two beats can come, 240 bpm | |
| 78 | + @min_gap_ms 250 | |
| 79 | + @max_gap_ms 2_000 | |
| 80 | + # how hard each beat pulls the breath into step, and the tempo toward a | |
| 81 | + # new estimate | |
| 82 | + @phase_pull 0.25 | |
| 83 | + @tempo_pull 0.3 | |
| 84 | + # how quickly the running average follows the band | |
| 85 | + @baseline_follow 0.1 | |
| 86 | + | |
| 87 | + alias LambentEx.Machine.Steps.Breathe.Cfg | |
| 88 | + alias LambentEx.Utils.Color | |
| 89 | + | |
| 90 | + defp n(opts) do | |
| 91 | + "#{@cls}-#{opts[:name]}-#{opts[:id]}" | |
| 92 | + end | |
| 93 | + | |
| 94 | + def start_link(opts) do | |
| 95 | + GenServer.start_link(__MODULE__, opts, name: via_tuple(n(opts))) | |
| 96 | + end | |
| 97 | + | |
| 98 | + @impl true | |
| 99 | + def init(opts) do | |
| 100 | + cfg = struct(%Cfg{}, opts) | |
| 101 | + | |
| 102 | + if listening?(cfg) do | |
| 103 | + Phoenix.PubSub.subscribe( | |
| 104 | + LambentEx.PubSub, | |
| 105 | + LambentEx.MQTT.audio_topic(cfg.broker, cfg.device) | |
| 106 | + ) | |
| 107 | + end | |
| 108 | + | |
| 109 | + {:ok, %{cfg | last_step: now(), bpm: max(cfg.bpm, 1), beats: max(cfg.beats, 1)}} | |
| 110 | + end | |
| 111 | + | |
| 112 | + defp listening?(cfg), do: cfg.broker not in [nil, ""] and cfg.device not in [nil, ""] | |
| 113 | + | |
| 114 | + defp via_tuple(name) do | |
| 115 | + {:via, Registry, {@registry, name}} | |
| 116 | + end | |
| 117 | + | |
| 118 | + defp now, do: System.monotonic_time(:millisecond) | |
| 119 | + | |
| 120 | + @doc "The tempo being breathed to: what was heard, or failing that the bpm set." | |
| 121 | + def tempo(state), do: state.detected || state.bpm | |
| 122 | + | |
| 123 | + @doc "The colour `breaths` into the breathing, as [h, s, v]." | |
| 124 | + def hsv(state, breaths) do | |
| 125 | + count = floor(breaths) | |
| 126 | + phase = breaths - count | |
| 127 | + # 0 at the start and end of a breath, 1 half way through | |
| 128 | + swell = (1 - :math.cos(2 * :math.pi() * phase)) / 2 | |
| 129 | + hue = Integer.mod(state.h + count * state.hue_step, 256) | |
| 130 | + | |
| 131 | + [hue, state.s, state.v_min + (state.v - state.v_min) * swell] | |
| 132 | + end | |
| 133 | + | |
| 134 | + defp read(state), do: state |> hsv(state.breaths) |> Color.hsv2rgb() | |
| 135 | + | |
| 136 | + # breaths per millisecond at the current tempo | |
| 137 | + defp rate(state), do: tempo(state) / 60_000 / state.beats | |
| 138 | + | |
| 139 | + @impl true | |
| 140 | + def handle_cast(:step, %{preview: true} = state) do | |
| 141 | + {:noreply, %{state | ticks: state.ticks + 1, breaths: (state.ticks + 1) * @preview_slice}} | |
| 142 | + end | |
| 143 | + | |
| 144 | + def handle_cast(:step, state) do | |
| 145 | + t = now() | |
| 146 | + breaths = state.breaths + (t - state.last_step) * rate(state) | |
| 147 | + {:noreply, %{state | breaths: breaths, last_step: t}} | |
| 148 | + end | |
| 149 | + | |
| 150 | + @impl true | |
| 151 | + def handle_call(:read, _from, state) do | |
| 152 | + {:reply, read(state), state} | |
| 153 | + end | |
| 154 | + | |
| 155 | + @impl true | |
| 156 | + def handle_info({:mqtt_audio, "beat", payload}, state) when is_map(payload) do | |
| 157 | + {:noreply, beat(%{state | device_beats: true}, payload["bpm"], now())} | |
| 158 | + end | |
| 159 | + | |
| 160 | + def handle_info({:mqtt_audio, "peaks", _payload}, %{device_beats: true} = state) do | |
| 161 | + {:noreply, state} | |
| 162 | + end | |
| 163 | + | |
| 164 | + def handle_info({:mqtt_audio, "peaks", %{"bands_db" => bands}}, state) | |
| 165 | + when is_list(bands) and length(bands) > @kick_band do | |
| 166 | + case Enum.at(bands, @kick_band) do | |
| 167 | + db when is_number(db) -> {:noreply, hear(state, db * 1.0, now())} | |
| 168 | + _otherwise -> {:noreply, state} | |
| 169 | + end | |
| 170 | + end | |
| 171 | + | |
| 172 | + def handle_info(_msg, state), do: {:noreply, state} | |
| 173 | + | |
| 174 | + @doc """ | |
| 175 | + Take one 63 Hz reading at `t`. Public so the beat tracking can be driven | |
| 176 | + with readings and times of a test's choosing. | |
| 177 | + """ | |
| 178 | + def hear(state, db, t) do | |
| 179 | + baseline = state.baseline || db | |
| 180 | + over = db - baseline >= state.sensitivity | |
| 181 | + # a beat is the rise, not every reading that stays up | |
| 182 | + rising = over and not state.above | |
| 183 | + | |
| 184 | + state = %{ | |
| 185 | + state | |
| 186 | + | baseline: baseline + (db - baseline) * @baseline_follow, | |
| 187 | + above: over | |
| 188 | + } | |
| 189 | + | |
| 190 | + if rising, do: onset(state, t), else: state | |
| 191 | + end | |
| 192 | + | |
| 193 | + @doc """ | |
| 194 | + A beat the analyser found itself, at `t`. Its tempo, when it has one, is | |
| 195 | + trusted over anything worked out here; before it has one, the beats are | |
| 196 | + counted the same way as the fallback's. | |
| 197 | + """ | |
| 198 | + def beat(state, bpm, t) when is_number(bpm) and bpm > 0 do | |
| 199 | + %{state | onsets: [t | state.onsets] |> Enum.take_while(fn o -> t - o <= @window_ms end)} | |
| 200 | + |> Map.put(:detected, blend(state.detected, bpm * 1.0)) | |
| 201 | + |> lock_phase() | |
| 202 | + end | |
| 203 | + | |
| 204 | + def beat(state, _no_tempo_yet, t), do: onset(state, t) | |
| 205 | + | |
| 206 | + defp onset(state, t) do | |
| 207 | + onsets = | |
| 208 | + [t | state.onsets] | |
| 209 | + |> Enum.take_while(fn o -> t - o <= @window_ms end) | |
| 210 | + | |
| 211 | + state = %{state | onsets: onsets} | |
| 212 | + | |
| 213 | + state = | |
| 214 | + case estimate(onsets) do | |
| 215 | + nil -> state | |
| 216 | + bpm -> %{state | detected: blend(state.detected, bpm)} | |
| 217 | + end | |
| 218 | + | |
| 219 | + lock_phase(state) | |
| 220 | + end | |
| 221 | + | |
| 222 | + # the tempo moves toward each new estimate rather than jumping to it | |
| 223 | + defp blend(nil, bpm), do: bpm | |
| 224 | + defp blend(current, bpm), do: current + (bpm - current) * @tempo_pull | |
| 225 | + | |
| 226 | + @doc """ | |
| 227 | + The tempo the onset times (newest first, in ms) imply, or nil until there | |
| 228 | + are enough. Gaps are folded against the typical one, so a missed beat | |
| 229 | + counts as two beats rather than halving the tempo. | |
| 230 | + """ | |
| 231 | + def estimate(onsets) when length(onsets) < 4, do: nil | |
| 232 | + | |
| 233 | + def estimate(onsets) do | |
| 234 | + gaps = | |
| 235 | + onsets | |
| 236 | + |> Enum.chunk_every(2, 1, :discard) | |
| 237 | + |> Enum.map(fn [later, earlier] -> later - earlier end) | |
| 238 | + |> Enum.filter(fn gap -> gap >= @min_gap_ms and gap <= @max_gap_ms end) | |
| 239 | + | |
| 240 | + case gaps do | |
| 241 | + [] -> | |
| 242 | + nil | |
| 243 | + | |
| 244 | + gaps -> | |
| 245 | + typical = median(gaps) | |
| 246 | + | |
| 247 | + {beats, time} = | |
| 248 | + gaps | |
| 249 | + |> Enum.map(fn gap -> {round(gap / typical), gap} end) | |
| 250 | + |> Enum.filter(fn {beats, _gap} -> beats in 1..4 end) | |
| 251 | + |> Enum.reduce({0, 0}, fn {beats, gap}, {b, t} -> {b + beats, t + gap} end) | |
| 252 | + | |
| 253 | + if beats > 0, do: 60_000 * beats / time, else: nil | |
| 254 | + end | |
| 255 | + end | |
| 256 | + | |
| 257 | + defp median(list) do | |
| 258 | + sorted = Enum.sort(list) | |
| 259 | + Enum.at(sorted, div(length(sorted), 2)) | |
| 260 | + end | |
| 261 | + | |
| 262 | + # pull the breath toward the nearest beat boundary. never back past the | |
| 263 | + # start of the breath it is in, or the hue would step backward | |
| 264 | + defp lock_phase(state) do | |
| 265 | + in_beats = state.breaths * state.beats | |
| 266 | + error = in_beats - round(in_beats) | |
| 267 | + pulled = state.breaths - error * @phase_pull / state.beats | |
| 268 | + | |
| 269 | + %{state | breaths: max(pulled, floor(state.breaths) * 1.0)} | |
| 270 | + end | |
| 271 | +end |
added
lib/lambent_ex/machine/steps/fft.ex
+247
−0
| 1 | +defmodule LambentEx.Machine.Steps.FFT.Cfg do | |
| 2 | + defstruct broker: nil, | |
| 3 | + device: nil, | |
| 4 | + h_l: 0, | |
| 5 | + h_h: 170, | |
| 6 | + s: 255, | |
| 7 | + v: 255, | |
| 8 | + span: 150, | |
| 9 | + floor: -90, | |
| 10 | + ceil: -30, | |
| 11 | + bars: false, | |
| 12 | + # which of the 7 bands to draw, inclusive. 0..1 is the bass, | |
| 13 | + # 4..6 the treble | |
| 14 | + band_lo: 0, | |
| 15 | + band_hi: 6, | |
| 16 | + # one bar along the whole strip instead of a segment per band | |
| 17 | + meter: false, | |
| 18 | + id: 0, | |
| 19 | + name: nil, | |
| 20 | + # what is drawn, per band, in dBFS | |
| 21 | + shown: [], | |
| 22 | + # dB per second the shown levels fall at, as the analyser reports it | |
| 23 | + fall: 56.0, | |
| 24 | + hues: [], | |
| 25 | + # the meter: a hue per pixel, and where the peak marker sits (0..1) | |
| 26 | + # and when it last went up | |
| 27 | + meter_hues: [], | |
| 28 | + peak: 0.0, | |
| 29 | + peak_at: 0, | |
| 30 | + last_step: nil | |
| 31 | +end | |
| 32 | + | |
| 33 | +defmodule LambentEx.Machine.Steps.FFT do | |
| 34 | + @moduledoc """ | |
| 35 | + Draws the 7-band spectrum a lambent-flex-i2s analyser publishes over MQTT. | |
| 36 | + | |
| 37 | + The strand is cut into one segment per band, low to high, each a hue picked | |
| 38 | + evenly from `h_l` round to `h_h`. A band's level either sets its segment's | |
| 39 | + brightness, or with `bars` how much of the segment is lit. `band_lo` and | |
| 40 | + `band_hi` narrow it to a run of bands, say just the bass, which then share | |
| 41 | + the strand and the hue range between them. | |
| 42 | + | |
| 43 | + With `meter` the strip is one bar instead: the loudest of the chosen bands | |
| 44 | + fills it from the start, coloured along the way from `h_l` to `h_h`, and a | |
| 45 | + peak marker holds at the top for a moment before falling, the way a | |
| 46 | + hardware spectrum analyser shows it. | |
| 47 | + | |
| 48 | + `span` is the strip's length (150 unless told otherwise). The spectrum or | |
| 49 | + meter is drawn across it once; the machine runs with `repeat: false`, so | |
| 50 | + anything past it is left dark rather than given another copy. | |
| 51 | + | |
| 52 | + Levels follow the analyser's `peaks`, falling at its `fall_db_per_s` between | |
| 53 | + readings the same way its own screen does, so a strand and the display agree. | |
| 54 | + """ | |
| 55 | + use GenServer | |
| 56 | + | |
| 57 | + @registry :lambent_steps | |
| 58 | + @cls :fft | |
| 59 | + @bands 7 | |
| 60 | + @bands_hz [63, 160, 400, 1000, 2500, 6250, 16000] | |
| 61 | + @silence -120.0 | |
| 62 | + # how long the meter's peak marker holds before it falls, and how much | |
| 63 | + # slower than the bar it falls | |
| 64 | + @peak_hold_ms 500 | |
| 65 | + @peak_fall_ratio 0.5 | |
| 66 | + | |
| 67 | + alias LambentEx.Machine.Steps.FFT.Cfg | |
| 68 | + alias LambentEx.Machine.Steps.Scape | |
| 69 | + alias LambentEx.Utils.Color | |
| 70 | + | |
| 71 | + defp n(opts) do | |
| 72 | + "#{@cls}-#{opts[:name]}-#{opts[:id]}" | |
| 73 | + end | |
| 74 | + | |
| 75 | + def start_link(opts) do | |
| 76 | + GenServer.start_link(__MODULE__, opts, name: via_tuple(n(opts))) | |
| 77 | + end | |
| 78 | + | |
| 79 | + @impl true | |
| 80 | + def init(opts) do | |
| 81 | + cfg = struct(%Cfg{}, opts) | |
| 82 | + | |
| 83 | + if cfg.broker && cfg.device do | |
| 84 | + Phoenix.PubSub.subscribe( | |
| 85 | + LambentEx.PubSub, | |
| 86 | + LambentEx.MQTT.audio_topic(cfg.broker, cfg.device) | |
| 87 | + ) | |
| 88 | + end | |
| 89 | + | |
| 90 | + {lo, hi} = band_range(cfg.band_lo, cfg.band_hi) | |
| 91 | + drawn = hi - lo + 1 | |
| 92 | + | |
| 93 | + {:ok, | |
| 94 | + %{ | |
| 95 | + cfg | |
| 96 | + | shown: List.duplicate(@silence, @bands), | |
| 97 | + band_lo: lo, | |
| 98 | + band_hi: hi, | |
| 99 | + hues: band_hues(cfg.h_l, cfg.h_h, drawn), | |
| 100 | + span: max(cfg.span, drawn), | |
| 101 | + meter_hues: meter_hues(cfg.h_l, cfg.h_h, max(cfg.span, drawn)), | |
| 102 | + last_step: now() | |
| 103 | + }} | |
| 104 | + end | |
| 105 | + | |
| 106 | + defp via_tuple(name) do | |
| 107 | + {:via, Registry, {@registry, name}} | |
| 108 | + end | |
| 109 | + | |
| 110 | + defp now, do: System.monotonic_time(:millisecond) | |
| 111 | + | |
| 112 | + @doc "The hue of each band, spread evenly over the range, wrapping like a scape." | |
| 113 | + def band_hues(low, high, count \\ @bands) | |
| 114 | + | |
| 115 | + def band_hues(low, _high, 1), do: [low] | |
| 116 | + | |
| 117 | + def band_hues(low, high, count) do | |
| 118 | + range = Scape.generate_hue_range(low, high) | |
| 119 | + last = length(range) - 1 | |
| 120 | + | |
| 121 | + 0..(count - 1) | |
| 122 | + |> Enum.map(fn band -> Enum.at(range, round(band * last / (count - 1))) end) | |
| 123 | + end | |
| 124 | + | |
| 125 | + @doc "A hue for each pixel of the meter, running from low to high along it." | |
| 126 | + def meter_hues(low, high, span) do | |
| 127 | + range = Scape.generate_hue_range(low, high) | |
| 128 | + last = length(range) - 1 | |
| 129 | + | |
| 130 | + 0..(span - 1) | |
| 131 | + |> Enum.map(fn pixel -> Enum.at(range, round(pixel * last / max(span - 1, 1))) end) | |
| 132 | + end | |
| 133 | + | |
| 134 | + @doc "The band centres, for labelling a band by what it hears." | |
| 135 | + def bands_hz, do: @bands_hz | |
| 136 | + | |
| 137 | + # in range and the right way up, whatever was asked for | |
| 138 | + defp band_range(lo, hi) do | |
| 139 | + [lo, hi] = [lo, hi] |> Enum.map(fn b -> b |> max(0) |> min(@bands - 1) end) | |
| 140 | + {min(lo, hi), max(lo, hi)} | |
| 141 | + end | |
| 142 | + | |
| 143 | + @doc "A dBFS reading as 0..1 between the floor and the ceiling." | |
| 144 | + def level(db, floor, ceil) do | |
| 145 | + ((db - floor) / (ceil - floor)) |> max(0.0) |> min(1.0) | |
| 146 | + end | |
| 147 | + | |
| 148 | + # the meter's level: the loudest of the chosen bands | |
| 149 | + defp meter_level(state) do | |
| 150 | + state.shown | |
| 151 | + |> Enum.slice(state.band_lo..state.band_hi) | |
| 152 | + |> Enum.map(&level(&1, state.floor, state.ceil)) | |
| 153 | + |> Enum.max() | |
| 154 | + end | |
| 155 | + | |
| 156 | + # the marker jumps to the bar whenever the bar is above it, holds, then falls | |
| 157 | + defp track_peak(state, t, elapsed_ms) do | |
| 158 | + lvl = meter_level(state) | |
| 159 | + | |
| 160 | + cond do | |
| 161 | + lvl >= state.peak -> | |
| 162 | + %{state | peak: lvl, peak_at: t} | |
| 163 | + | |
| 164 | + t - state.peak_at < @peak_hold_ms -> | |
| 165 | + state | |
| 166 | + | |
| 167 | + true -> | |
| 168 | + drop = state.fall * @peak_fall_ratio / (state.ceil - state.floor) * elapsed_ms / 1000 | |
| 169 | + %{state | peak: max(state.peak - drop, lvl)} | |
| 170 | + end | |
| 171 | + end | |
| 172 | + | |
| 173 | + defp read(%{meter: true} = state) do | |
| 174 | + lvl = meter_level(state) | |
| 175 | + peak = max(state.peak, lvl) | |
| 176 | + # the highest pixel the bar would light at the peak, by the same rule the | |
| 177 | + # bar uses, so a marker resting on the bar sits on its top pixel | |
| 178 | + marker = if peak > 0, do: max(trunc(peak * state.span) - 1, 0), else: nil | |
| 179 | + | |
| 180 | + state.meter_hues | |
| 181 | + |> Enum.with_index() | |
| 182 | + |> Enum.map(fn {hue, pixel} -> | |
| 183 | + lit = (pixel + 1) / state.span <= lvl or pixel == marker | |
| 184 | + Color.hsv2rgb([hue, state.s, if(lit, do: state.v, else: 0)]) | |
| 185 | + end) | |
| 186 | + end | |
| 187 | + | |
| 188 | + defp read(state) do | |
| 189 | + drawn = state.band_hi - state.band_lo + 1 | |
| 190 | + | |
| 191 | + levels = | |
| 192 | + state.shown | |
| 193 | + |> Enum.slice(state.band_lo..state.band_hi) | |
| 194 | + |> Enum.map(&level(&1, state.floor, state.ceil)) | |
| 195 | + | |
| 196 | + 0..(state.span - 1) | |
| 197 | + |> Enum.map(fn pixel -> | |
| 198 | + band = div(pixel * drawn, state.span) | |
| 199 | + lvl = Enum.at(levels, band) | |
| 200 | + hue = Enum.at(state.hues, band) | |
| 201 | + | |
| 202 | + v = | |
| 203 | + case state.bars do | |
| 204 | + false -> | |
| 205 | + lvl * state.v | |
| 206 | + | |
| 207 | + true -> | |
| 208 | + # where this pixel sits in its band's segment, 0 at the bottom | |
| 209 | + first = div(band * state.span + drawn - 1, drawn) | |
| 210 | + last = div((band + 1) * state.span + drawn - 1, drawn) | |
| 211 | + pos = (pixel - first + 1) / max(last - first, 1) | |
| 212 | + if pos <= lvl, do: state.v, else: 0 | |
| 213 | + end | |
| 214 | + | |
| 215 | + Color.hsv2rgb([hue, state.s, v]) | |
| 216 | + end) | |
| 217 | + end | |
| 218 | + | |
| 219 | + @impl true | |
| 220 | + def handle_info({:mqtt_audio, _kind, %{"bands_db" => bands} = payload}, state) | |
| 221 | + when length(bands) == @bands do | |
| 222 | + shown = Enum.zip_with(state.shown, bands, fn shown, peak -> max(shown, number(peak)) end) | |
| 223 | + fall = payload |> Map.get("fall_db_per_s", state.fall) |> number() | |
| 224 | + | |
| 225 | + {:noreply, %{state | shown: shown, fall: fall} |> track_peak(now(), 0)} | |
| 226 | + end | |
| 227 | + | |
| 228 | + def handle_info(_msg, state), do: {:noreply, state} | |
| 229 | + | |
| 230 | + defp number(n) when is_number(n), do: n * 1.0 | |
| 231 | + defp number(_otherwise), do: @silence | |
| 232 | + | |
| 233 | + @impl true | |
| 234 | + def handle_cast(:step, state) do | |
| 235 | + t = now() | |
| 236 | + drop = state.fall * (t - state.last_step) / 1000 | |
| 237 | + | |
| 238 | + shown = state.shown |> Enum.map(fn db -> max(db - drop, @silence) end) | |
| 239 | + | |
| 240 | + {:noreply, %{state | shown: shown, last_step: t} |> track_peak(t, t - state.last_step)} | |
| 241 | + end | |
| 242 | + | |
| 243 | + @impl true | |
| 244 | + def handle_call(:read, _from, state) do | |
| 245 | + {:reply, read(state), state} | |
| 246 | + end | |
| 247 | +end |
modified
lib/lambent_ex/meta.ex
+24
−0
| 17 | 17 | ) |
| 18 | 18 | |
| 19 | 19 | Phoenix.PubSub.subscribe(@pubsub_name, "machines_idx") |
| 20 | + Phoenix.PubSub.subscribe(@pubsub_name, "machines_gone") | |
| 20 | 21 | Phoenix.PubSub.subscribe(@pubsub_name, "links_idx") |
| 21 | 22 | Phoenix.PubSub.subscribe(@pubsub_name, "mqtt_idx") |
| 22 | 23 | Process.send_after(self(), :machine_flush, 5000) |
| 252 | 253 | :dets.delete(@table, "machs") |
| 253 | 254 | end |
| 254 | 255 | |
| 256 | + # stored as %{opts: keyword}, which is what the mqtt supervisor replays. | |
| 257 | + # replaces any entry already saved under the same name | |
| 258 | + def put_cfg_mqtt(opts) do | |
| 259 | + rest = get_saved_cfg_mqtt() |> Enum.reject(fn m -> m[:opts][:name] == opts[:name] end) | |
| 260 | + :dets.insert(@table, {"cfg_mqtt", rest ++ [%{opts: opts}]}) | |
| 261 | + end | |
| 262 | + | |
| 263 | + def del_cfg_mqtt(name) do | |
| 264 | + rest = get_saved_cfg_mqtt() |> Enum.reject(fn m -> m[:opts][:name] == name end) | |
| 265 | + :dets.insert(@table, {"cfg_mqtt", rest}) | |
| 266 | + end | |
| 267 | + | |
| 255 | 268 | def clear_cfg_mqtt() do |
| 256 | 269 | :dets.delete(@table, "cfg_mqtt") |
| 257 | 270 | end |
| 314 | 327 | end |
| 315 | 328 | end |
| 316 | 329 | |
| 330 | + # a quit machine is not to come back on the next start. the flush skips an | |
| 331 | + # empty set, so the saved row is rewritten here rather than left to it | |
| 332 | + def handle_info({:machines_gone, name}, state) do | |
| 333 | + gone? = fn m -> m[:name] == name or m[:opts][:name] == name end | |
| 334 | + | |
| 335 | + saved = get_saved_machines() |> Enum.reject(gone?) |> MapSet.new() | |
| 336 | + :dets.insert(@table, {"machs", saved}) | |
| 337 | + | |
| 338 | + {:noreply, %{state | machines: state.machines |> Enum.reject(gone?) |> MapSet.new()}} | |
| 339 | + end | |
| 340 | + | |
| 317 | 341 | def handle_info({:links_pub, link}, state) do |
| 318 | 342 | case link[:persist] == true do |
| 319 | 343 | true -> |
modified
lib/lambent_ex/mqtt.ex
+126
−33
Click to load diff…
added
lib/lambent_ex/mqtt_probe.ex
+84
−0
Click to load diff…
modified
lib/lambent_ex/schema/machines.ex
+59
−5
Click to load diff…
added
lib/lambent_ex/schema/mqtt.ex
+101
−0
Click to load diff…
added
lib/lambent_ex/schema/steps/breathe.ex
+57
−0
Click to load diff…
added
lib/lambent_ex/schema/steps/fft.ex
+62
−0
Click to load diff…
modified
lib/lambent_ex/supervision/mqtt.ex
+66
−3
Click to load diff…
modified
lib/lambent_ex_web/channels/machines_channel.ex
+16
−1
Click to load diff…
modified
lib/lambent_ex_web/channels/services_channel.ex
+2
−2
Click to load diff…
modified
lib/lambent_ex_web/live/devices/index.ex
+9
−3
Click to load diff…
modified
lib/lambent_ex_web/live/machines/form_component.ex
+135
−4
Click to load diff…
modified
lib/lambent_ex_web/live/machines/form_component.html.heex
+26
−5
Click to load diff…
modified
lib/lambent_ex_web/live/machines/index.ex
+27
−5
Click to load diff…
modified
lib/lambent_ex_web/live/services/index.ex
+5
−1
Click to load diff…
modified
lib/lambent_ex_web/live/services/index.html.heex
+5
−1
Click to load diff…
modified
lib/lambent_ex_web/live/services/mqtt_form_component.ex
+57
−22
Click to load diff…
modified
lib/lambent_ex_web/live/services/mqtt_form_component.html.heex
+60
−2
Click to load diff…
added
test/lambent_ex/breathe_test.exs
+228
−0
Click to load diff…
added
test/lambent_ex/fft_test.exs
+364
−0
Click to load diff…
modified
test/lambent_ex_web/channels/channels_test.exs
+7
−1
Click to load diff…
added
test/lambent_ex_web/live/devices_test.exs
+32
−0
Click to load diff…
modified
test/lambent_ex_web/live/machine_form_test.exs
+22
−1
Click to load diff…
added
test/lambent_ex_web/live/machines_test.exs
+96
−0
Click to load diff…
added
test/lambent_ex_web/live/mqtt_test.exs
+256
−0
Click to load diff…
Parents: 5f2ce48