Drive machines from the flex-i2s analysers over MQTT

4261038 · Gabriel Morell · 2026-10-02 23:43

47 files +2520 -146
Message
{commit_body(@commit)}

Files changed

modified lib/lambent_ex/machine.ex
+75 −58
@@ -8,6 +8,13 @@ defmodule LambentEx.Machine do
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,6 +49,9 @@ defmodule LambentEx.Machine do
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,10 +111,11 @@ defmodule LambentEx.Machine do
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,10 +176,25 @@ defmodule LambentEx.Machine do
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,6 +299,13 @@ defmodule LambentEx.Machine do
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,17 +330,28 @@ defmodule LambentEx.Machine do
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,7 +382,7 @@ defmodule LambentEx.Machine do
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,7 +394,7 @@ defmodule LambentEx.Machine do
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,7 +407,7 @@ defmodule LambentEx.Machine do
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,49 +485,28 @@ defmodule LambentEx.Machine do
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,12 +547,6 @@ defmodule LambentEx.Machine do
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,7 +23,9 @@ defmodule LambentEx.Machines.Preview do
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
@@ -0,0 +1,271 @@
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
@@ -0,0 +1,247 @@
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,6 +17,7 @@ defmodule LambentEx.Meta do
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,6 +253,18 @@ defmodule LambentEx.Meta do
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,6 +327,17 @@ defmodule LambentEx.Meta do
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