From d4f38120c63f01e94f9373f6f233b8deb3e95b1c Mon Sep 17 00:00:00 2001 From: Vynull App Date: Thu, 23 Jul 2026 17:16:23 +0000 Subject: [PATCH 1/4] device: Logs tab in the TUI The TUI owns the terminal, so logs go to a temp file the user has to find and tail in a second terminal to see what the server is doing. Add a Logs tab that shows the live stream in-place. LogRing is an io.Writer keeping the last 2000 lines in a fixed ring; main tees the stdlib log output through it alongside the existing file writer (the file remains the complete record - the ring only feeds the view). Partial writes buffer until their newline so split lines land whole, and writes never fail so the tee cannot break file logging. The tab follows the tail by default; scrolling up pauses it (with a PAUSED indicator), G re-follows, g jumps to the oldest buffered line, and scrolling back to the bottom re-attaches. Timestamps render without the date column and lines clip to the terminal width. Verified live under tmux: lines stream in follow mode, pause/resume and oldest-jump behave, tab cycling wraps through the new tab, and the ring picked up real peer traffic while browsing. --- device/logring.go | 75 +++++++++++++++++++++++++ device/logring_test.go | 58 +++++++++++++++++++ device/tui.go | 125 ++++++++++++++++++++++++++++++++++++++--- main.go | 13 ++++- 4 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 device/logring.go create mode 100644 device/logring_test.go diff --git a/device/logring.go b/device/logring.go new file mode 100644 index 0000000..de28d37 --- /dev/null +++ b/device/logring.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package device + +import ( + "bytes" + "sync" +) + +// LogRing is an io.Writer holding the most recent log lines in a fixed-size +// ring, so the TUI's Logs tab can show live output while the full stream +// still goes to the log file (tee both via io.MultiWriter). Safe for +// concurrent writers — the stdlib log package serializes its own writes, but +// nothing stops other code from writing to the same tee. +type LogRing struct { + mu sync.Mutex + lines []string + head int // next slot to overwrite + full bool + partial bytes.Buffer // trailing bytes of an unterminated line +} + +// NewLogRing returns a ring holding up to capacity lines. +func NewLogRing(capacity int) *LogRing { + if capacity < 1 { + capacity = 1 + } + return &LogRing{lines: make([]string, capacity)} +} + +// Write splits p into lines and appends each to the ring. A write that does +// not end in a newline leaves its tail buffered until the newline arrives, +// so interleaved fmt-style writes still land as whole lines. Always returns +// len(p), nil — a log tee must never fail the other writer. +func (r *LogRing) Write(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + n := len(p) + for len(p) > 0 { + i := bytes.IndexByte(p, '\n') + if i < 0 { + r.partial.Write(p) + break + } + r.partial.Write(p[:i]) + r.push(r.partial.String()) + r.partial.Reset() + p = p[i+1:] + } + return n, nil +} + +func (r *LogRing) push(line string) { + r.lines[r.head] = line + r.head++ + if r.head == len(r.lines) { + r.head = 0 + r.full = true + } +} + +// Lines returns the buffered lines, oldest first. +func (r *LogRing) Lines() []string { + r.mu.Lock() + defer r.mu.Unlock() + if !r.full { + out := make([]string, r.head) + copy(out, r.lines[:r.head]) + return out + } + out := make([]string, 0, len(r.lines)) + out = append(out, r.lines[r.head:]...) + out = append(out, r.lines[:r.head]...) + return out +} diff --git a/device/logring_test.go b/device/logring_test.go new file mode 100644 index 0000000..ea45b61 --- /dev/null +++ b/device/logring_test.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package device + +import ( + "fmt" + "log" + "reflect" + "testing" +) + +func TestLogRingBasic(t *testing.T) { + r := NewLogRing(3) + if got := r.Lines(); len(got) != 0 { + t.Fatalf("empty ring returned %v", got) + } + r.Write([]byte("a\nb\n")) + if got := r.Lines(); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("got %v", got) + } + // Overflow: oldest lines drop, order stays oldest-first. + r.Write([]byte("c\nd\n")) + if got := r.Lines(); !reflect.DeepEqual(got, []string{"b", "c", "d"}) { + t.Fatalf("after wrap got %v", got) + } +} + +func TestLogRingPartialWrites(t *testing.T) { + r := NewLogRing(8) + // A line split across writes must land as one line once terminated. + r.Write([]byte("hel")) + r.Write([]byte("lo wor")) + if got := r.Lines(); len(got) != 0 { + t.Fatalf("unterminated line surfaced early: %v", got) + } + r.Write([]byte("ld\nnext\n")) + if got := r.Lines(); !reflect.DeepEqual(got, []string{"hello world", "next"}) { + t.Fatalf("got %v", got) + } +} + +func TestLogRingAsStdlibLogSink(t *testing.T) { + r := NewLogRing(16) + l := log.New(r, "", log.LstdFlags) + for i := 0; i < 3; i++ { + l.Printf("line %d", i) + } + got := r.Lines() + if len(got) != 3 { + t.Fatalf("want 3 lines, got %v", got) + } + for i, line := range got { + want := fmt.Sprintf("line %d", i) + if len(line) < len(want) || line[len(line)-len(want):] != want { + t.Errorf("line %d = %q, want suffix %q", i, line, want) + } + } +} diff --git a/device/tui.go b/device/tui.go index 1e80e05..3ba41cc 100644 --- a/device/tui.go +++ b/device/tui.go @@ -55,9 +55,10 @@ const ( tabPlayers tuiTab = iota tabLibrary tabSettings + tabLogs ) -var tabNames = []string{"Players", "Library", "Settings"} +var tabNames = []string{"Players", "Library", "Settings", "Logs"} type tuiModel struct { monitor *PlayerMonitor @@ -66,6 +67,7 @@ type tuiModel struct { peers *PeerTracker mixersFn func() map[uint8]proto.MixerStatus apiAddr string + logs *LogRing // nil → Logs tab shows a hint instead active tuiTab width int @@ -79,6 +81,12 @@ type tuiModel struct { // Settings tab state settingsCursor int settingsOffset int + + // Logs tab state. logFollow pins the view to the tail (the default); + // scrolling up detaches it, G/end re-attach. logOffset is the index of + // the first visible line while detached. + logOffset int + logFollow bool } // NewTUI returns a bubbletea program rendering the player monitor + @@ -86,14 +94,18 @@ type tuiModel struct { // the user presses 'q' or sends SIGINT/SIGTERM. // mixersFn (optional) returns the latest per-device mixer status snapshot, // used to fill in channel/master state on the PLAYERS-view mixer strip. -func NewTUI(monitor *PlayerMonitor, lib *library.Library, settings *CDJSettings, peers *PeerTracker, mixersFn func() map[uint8]proto.MixerStatus, apiAddr string) *tea.Program { +// logs (optional) is the in-memory tail of the log stream for the Logs tab; +// pass the LogRing that tees the log file writer. +func NewTUI(monitor *PlayerMonitor, lib *library.Library, settings *CDJSettings, peers *PeerTracker, mixersFn func() map[uint8]proto.MixerStatus, apiAddr string, logs *LogRing) *tea.Program { m := tuiModel{ - monitor: monitor, - lib: lib, - settings: settings, - peers: peers, - mixersFn: mixersFn, - apiAddr: apiAddr, + monitor: monitor, + lib: lib, + settings: settings, + peers: peers, + mixersFn: mixersFn, + apiAddr: apiAddr, + logs: logs, + logFollow: true, } return tea.NewProgram(m, tea.WithAltScreen()) } @@ -168,6 +180,30 @@ func (m *tuiModel) moveCursor(delta int) { if m.settingsCursor < 0 { m.settingsCursor = 0 } + case tabLogs: + if m.logs == nil { + return + } + total := len(m.logs.Lines()) + rows := m.logViewRows() + maxOffset := total - rows + if maxOffset < 0 { + maxOffset = 0 + } + if m.logFollow { + // Detach from the tail at the current position. + m.logOffset = maxOffset + } + m.logOffset += delta + if m.logOffset < 0 { + m.logOffset = 0 + } + if m.logOffset >= maxOffset { + m.logOffset = maxOffset + m.logFollow = true // scrolled back to the tail → re-follow + } else { + m.logFollow = false + } } } @@ -187,6 +223,13 @@ func (m *tuiModel) setCursor(pos int) { pos = 0 } m.settingsCursor = pos + case tabLogs: + if pos == 0 { // g → jump to the oldest buffered line + m.logOffset = 0 + m.logFollow = false + } else { // G → back to following the tail + m.logFollow = true + } } } @@ -213,6 +256,8 @@ func (m tuiModel) View() string { top.WriteString(m.renderLibrary()) case tabSettings: top.WriteString(m.renderSettings()) + case tabLogs: + top.WriteString(m.renderLogs()) } top.WriteString("\n") top.WriteString(divider) @@ -288,6 +333,8 @@ func (m tuiModel) renderHints() string { switch m.active { case tabLibrary: return hintStyle.Render(common + " • g/G: top/bottom") + case tabLogs: + return hintStyle.Render(common + " • ↑↓/pgup/pgdn: scroll • g: oldest • G: follow") default: return hintStyle.Render(common) } @@ -527,6 +574,68 @@ func (m tuiModel) renderSettings() string { return b.String() } +// ---------- Logs tab ---------- + +// logViewRows is the line budget for the Logs tab: the viewport minus the +// tab's own header line. +func (m tuiModel) logViewRows() int { + rows := m.viewportRows() - 2 + if rows < 1 { + rows = 1 + } + return rows +} + +func (m tuiModel) renderLogs() string { + if m.logs == nil { + return dimStyle.Render("\n Log buffer not available (--log-file only captures to disk).") + } + lines := m.logs.Lines() + rows := m.logViewRows() + offset := m.logOffset + if m.logFollow || offset > len(lines)-rows { + offset = len(lines) - rows + } + if offset < 0 { + offset = 0 + } + end := offset + rows + if end > len(lines) { + end = len(lines) + } + + var b strings.Builder + b.WriteByte('\n') + b.WriteString(" ") + mode := accentStyle.Render("● FOLLOWING") + if !m.logFollow { + mode = dimStyle.Render("⏸ PAUSED (G to follow)") + } + b.WriteString(titleStyle.Render(fmt.Sprintf("%d lines buffered", len(lines)))) + b.WriteString(" ") + b.WriteString(mode) + b.WriteByte('\n') + if len(lines) == 0 { + b.WriteString(dimStyle.Render(" (no log output yet)")) + return b.String() + } + // Trim the stdlib log timestamp to the clock (drop the date — the date + // column is dead width on a live view) and clip to the terminal. + width := m.width - 4 + if width < 10 { + width = 10 + } + for _, line := range lines[offset:end] { + if len(line) > 20 && line[4] == '/' && line[7] == '/' && line[10] == ' ' { + line = line[11:] // "2026/07/23 12:04:05 msg" → "12:04:05 msg" + } + b.WriteString(" ") + b.WriteString(truncate(line, width)) + b.WriteByte('\n') + } + return strings.TrimRight(b.String(), "\n") +} + // ---------- helpers ---------- func (m tuiModel) viewportRows() int { diff --git a/main.go b/main.go index 1acff38..24954bc 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ package main import ( "context" "fmt" + "io" "log" "net" "net/http" @@ -106,6 +107,9 @@ func main() { // --log-file PATH → append to that file (TUI or headless) // TUI (default) → an auto temp file, since the TUI owns the terminal // headless → stdout (no redirect) + // In TUI mode the last few thousand log lines are also kept in memory + // for the Logs tab, teed alongside the file writer. + var logRing *device.LogRing if cfg.GenerateDir == "" { var logFile *os.File var err error @@ -118,7 +122,12 @@ func main() { if err != nil { fmt.Fprintf(os.Stderr, "warning: could not open log file: %v (logging to stdout)\n", err) } else if logFile != nil { - log.SetOutput(logFile) + if cfg.TUI { + logRing = device.NewLogRing(2000) + log.SetOutput(io.MultiWriter(logFile, logRing)) + } else { + log.SetOutput(logFile) + } defer logFile.Close() fmt.Printf("Logging to %s\n", logFile.Name()) // Mirror runtime crash output (panics from any goroutine @@ -643,7 +652,7 @@ func main() { // Launch the TUI on the main goroutine. It owns the terminal until // the user presses 'q' or ctx is cancelled from elsewhere (SIGINT, // service error). Bridge ctx → program.Quit so SIGINT unwinds cleanly. - tuiProgram := device.NewTUI(monitor, lib, cdjSettings, dev.Peers, dev.MixerSnapshot, displayAddr(cfg.Listen)) + tuiProgram := device.NewTUI(monitor, lib, cdjSettings, dev.Peers, dev.MixerSnapshot, displayAddr(cfg.Listen), logRing) go func() { <-ctx.Done() tuiProgram.Quit() From 7f42381c19a1ba7083d2cd3b18fb679a61d39b95 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 25 Jul 2026 18:48:43 +0000 Subject: [PATCH 2/4] device: scroll the TUI Settings tab to the viewport The Settings tab rendered all 25 rows unconditionally, so on any terminal shorter than the list the whole top of the screen - tab bar included - scrolled away, which read as the tab being broken. The cursor state existed and the arrow keys moved it, but the renderer never used it. Render a viewport window that follows the cursor (same pattern as the Library tab), with a position indicator in the header when the list is clipped, a highlighted cursor row, and the cursor clamped to the row count. g/G jump to the first/last row as elsewhere. --- device/tui.go | 69 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/device/tui.go b/device/tui.go index 3ba41cc..4bfa5be 100644 --- a/device/tui.go +++ b/device/tui.go @@ -180,6 +180,9 @@ func (m *tuiModel) moveCursor(delta int) { if m.settingsCursor < 0 { m.settingsCursor = 0 } + if m.settingsCursor > settingsRowCount-1 { + m.settingsCursor = settingsRowCount - 1 + } case tabLogs: if m.logs == nil { return @@ -219,6 +222,9 @@ func (m *tuiModel) setCursor(pos int) { } m.libCursor = pos case tabSettings: + if pos > settingsRowCount-1 { + pos = settingsRowCount - 1 + } if pos < 0 { pos = 0 } @@ -526,6 +532,10 @@ func (m tuiModel) renderLibrary() string { // ---------- Settings tab ---------- +// settingsRowCount mirrors the row list in renderSettings so cursor +// clamping can know the bound without building the rows. +const settingsRowCount = 25 + func (m tuiModel) renderSettings() string { if m.settings == nil { return dimStyle.Render("\n Settings not available.") @@ -560,18 +570,61 @@ func (m tuiModel) renderSettings() string { {"wave_position", cfg.DevSetting.WavePosition}, } + // Scroll to the viewport like the Library tab: the full list is taller + // than a typical terminal, and rendering every row pushed the tab bar + // off the top of the screen. The cursor row stays in view; section + // headers scroll with their rows. + view := m.viewportRows() - 3 // hint line + 2 padding + if view < 1 { + view = 1 + } + cursor := m.settingsCursor + if cursor >= len(rows) { + cursor = len(rows) - 1 + } + offset := m.settingsOffset + if cursor < offset { + offset = cursor + } + if cursor >= offset+view { + offset = cursor - view + 1 + } + if offset > len(rows)-view { + offset = len(rows) - view + } + if offset < 0 { + offset = 0 + } + end := offset + view + if end > len(rows) { + end = len(rows) + } + var b strings.Builder - b.WriteString("\n " + dimStyle.Render("(read-only view — edit settings.json to change)") + "\n\n") - for i, r := range rows { + b.WriteByte('\n') + b.WriteString(" ") + b.WriteString(dimStyle.Render("(read-only view — edit settings.json to change)")) + if len(rows) > view { + b.WriteString(dimStyle.Render(fmt.Sprintf(" · %d-%d of %d", offset+1, end, len(rows)))) + } + b.WriteByte('\n') + for i := offset; i < end; i++ { + r := rows[i] + var line string if r.value == "" { - b.WriteString(" " + titleStyle.Render(r.label) + "\n") - continue + line = " " + titleStyle.Render(r.label) + } else { + line = fmt.Sprintf(" %-26s %s", r.label, r.value) + } + if i == cursor { + // Rebuild unstyled so the cursor background spans the row. + plain := fmt.Sprintf(" %-26s %s", r.label, r.value) + line = cursorStyle.Render(plain) } - line := fmt.Sprintf(" %-26s %s", r.label, r.value) - _ = i - b.WriteString(line + "\n") + b.WriteString(line) + b.WriteByte('\n') } - return b.String() + return strings.TrimRight(b.String(), "\n") } // ---------- Logs tab ---------- From 17461589b80284d04428e77eb1a38e5fe9509df0 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 25 Jul 2026 19:00:13 +0000 Subject: [PATCH 3/4] device: fix the TUI mixer strip races that hid the DJM The mixer strip in the Players tab was a per-session coin flip: the PeerTracker is created inside VirtualDevice.Start, which runs in a goroutine racing TUI construction, and main passed dev.Peers to NewTUI by value. Whichever goroutine won decided the whole run - the TUI either had the tracker or held nil forever while /api/peers (which dereferences at request time) showed the mixer fine. The db wiring a few lines up even documents this exact trap and uses a closure; the TUI call did not. NewTUI now takes a peersFn resolved at render time. Also widen peerTimeout from 5s to 10s. Keep-alives arrive every 2s (measured on a DJM-A9), so the 5s window meant two consecutive lost broadcasts - easy on a busy link-local segment - dropped the device from the players/mixer views until the next packet landed. 10s tolerates four lost packets while still clearing departed devices quickly. Verified against the real DJM-A9: three consecutive TUI sessions all show the MIXER line with channel state immediately; before the fix the strip rendered only on lucky startups. --- device/peers.go | 8 +++++++- device/tui.go | 25 ++++++++++++++++++++----- main.go | 4 +++- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/device/peers.go b/device/peers.go index a30a829..3214aa2 100644 --- a/device/peers.go +++ b/device/peers.go @@ -12,7 +12,13 @@ import ( "github.com/vynulldev/vynull/proto" ) -const peerTimeout = 5 * time.Second +// peerTimeout is how long a peer stays "active" after its last keep-alive. +// Devices broadcast keep-alives every 2s (measured on a DJM-A9; CDJs are +// similar), so the old 5s window meant two consecutive lost broadcasts — +// easy on a busy link-local segment — made the device vanish from the +// players/mixer views until the next packet. 10s tolerates four lost +// packets while still clearing genuinely departed devices quickly. +const peerTimeout = 10 * time.Second // Peer represents a device seen on the Pro DJ Link network. type Peer struct { diff --git a/device/tui.go b/device/tui.go index 4bfa5be..0216c4a 100644 --- a/device/tui.go +++ b/device/tui.go @@ -64,7 +64,7 @@ type tuiModel struct { monitor *PlayerMonitor lib *library.Library settings *CDJSettings - peers *PeerTracker + peersFn func() *PeerTracker // resolved at render time — see NewTUI mixersFn func() map[uint8]proto.MixerStatus apiAddr string logs *LogRing // nil → Logs tab shows a hint instead @@ -96,12 +96,18 @@ type tuiModel struct { // used to fill in channel/master state on the PLAYERS-view mixer strip. // logs (optional) is the in-memory tail of the log stream for the Logs tab; // pass the LogRing that tees the log file writer. -func NewTUI(monitor *PlayerMonitor, lib *library.Library, settings *CDJSettings, peers *PeerTracker, mixersFn func() map[uint8]proto.MixerStatus, apiAddr string, logs *LogRing) *tea.Program { +// +// peersFn returns the live PeerTracker and is called at render time, NOT +// captured once here: the tracker is created inside VirtualDevice.Start, +// which races TUI construction at startup. Passing dev.Peers by value made +// the mixer strip a per-session coin flip — whichever goroutine won, the TUI +// either had the tracker or held nil for the whole run. +func NewTUI(monitor *PlayerMonitor, lib *library.Library, settings *CDJSettings, peersFn func() *PeerTracker, mixersFn func() map[uint8]proto.MixerStatus, apiAddr string, logs *LogRing) *tea.Program { m := tuiModel{ monitor: monitor, lib: lib, settings: settings, - peers: peers, + peersFn: peersFn, mixersFn: mixersFn, apiAddr: apiAddr, logs: logs, @@ -356,12 +362,12 @@ func (m tuiModel) renderPlayers() string { // When we've parsed a status broadcast for it (0x29 rich, or the // 0x03 channel packet), show master tempo and which channels are // on-air; otherwise fall back to a "detected" hint. - if m.peers != nil { + if peers := m.resolvePeers(); peers != nil { var mixers map[uint8]proto.MixerStatus if m.mixersFn != nil { mixers = m.mixersFn() } - for _, p := range m.peers.Peers() { + for _, p := range peers.Peers() { if p.DeviceType != proto.DeviceMixer { continue } @@ -691,6 +697,15 @@ func (m tuiModel) renderLogs() string { // ---------- helpers ---------- +// resolvePeers fetches the live PeerTracker (nil until the device loop has +// created it). +func (m tuiModel) resolvePeers() *PeerTracker { + if m.peersFn == nil { + return nil + } + return m.peersFn() +} + func (m tuiModel) viewportRows() int { // reserve lines for header (2), divider (2), status (1), hints (2) return m.height - 7 diff --git a/main.go b/main.go index 24954bc..bc3f33e 100644 --- a/main.go +++ b/main.go @@ -652,7 +652,9 @@ func main() { // Launch the TUI on the main goroutine. It owns the terminal until // the user presses 'q' or ctx is cancelled from elsewhere (SIGINT, // service error). Bridge ctx → program.Quit so SIGINT unwinds cleanly. - tuiProgram := device.NewTUI(monitor, lib, cdjSettings, dev.Peers, dev.MixerSnapshot, displayAddr(cfg.Listen), logRing) + tuiProgram := device.NewTUI(monitor, lib, cdjSettings, + func() *device.PeerTracker { return dev.Peers }, // created inside dev.Start — resolve at render time + dev.MixerSnapshot, displayAddr(cfg.Listen), logRing) go func() { <-ctx.Done() tuiProgram.Quit() From 8066fa47c701c07041cb0dd04f842a785f920a57 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 25 Jul 2026 22:12:03 +0000 Subject: [PATCH 4/4] device: stop 0x30 mixer status clobbering learned channel state Newer DJMs split their broadcast state across two packet types: a stripped 0x30 on the status port carrying only name + device number, and 0x03 channel packets on the on-air port carrying the channel state. The two listeners shared one mixerStatuses entry, but only the 0x03 path merged - the status path overwrote the entry wholesale, so every 0x30 wiped ChannelStateKnown (and master fields) until the next 0x03 restored them. Mixer views flipped between the rich channel-state line and the bare "detected" fallback at packet cadence, in the TUI and /api/peers alike. The status path now merges a state-less 0x30 over the learned fields instead of clobbering; a rich legacy 0x29 (which carries everything inline) still replaces the entry wholesale. The DJM-A9 on the bench emits 0x30 only in certain states (none seen while idle - confirmed by counting packet types on the status port), so the flip could not be reproduced live today; the merged path was verified stable against the 0x03-only stream, 20/20 samples rich. --- device/device.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/device/device.go b/device/device.go index c81dfa4..bb16815 100644 --- a/device/device.go +++ b/device/device.go @@ -699,7 +699,27 @@ func (d *VirtualDevice) listenStatus(ctx context.Context) { if d.mixerStatuses == nil { d.mixerStatuses = make(map[uint8]*proto.MixerStatus) } - _, alreadyLogged := d.mixerStatuses[mx.DeviceNumber] + prev, alreadyLogged := d.mixerStatuses[mx.DeviceNumber] + // A stripped 0x30 (newer DJMs) carries only presence — + // no channel or master state. Merge it over what the + // 0x03 channel listener has learned instead of + // clobbering, or the mixer views flip between "channel + // state" and "detected" at packet cadence as the two + // writers fight. A rich 0x29 carries everything and + // may replace the entry wholesale. + if prev != nil && !mx.ChannelStateKnown && prev.ChannelStateKnown { + mx.ChannelOnAir = prev.ChannelOnAir + mx.ChannelStateKnown = true + if mx.MasterBPM == 0 { + mx.MasterBPM = prev.MasterBPM + } + if mx.MasterDevice == 0 { + mx.MasterDevice = prev.MasterDevice + } + if mx.BeatInBar == 0 { + mx.BeatInBar = prev.BeatInBar + } + } d.mixerStatuses[mx.DeviceNumber] = mx d.mixerStatusesMu.Unlock() if !alreadyLogged {