From 19e5b3d89891d15be3ee78006d67ccc5b8ec22ae Mon Sep 17 00:00:00 2001 From: nam Date: Fri, 6 Nov 2026 11:40:56 -0600 Subject: [PATCH] Tally follows MMO Stream (darkice connected) automatically Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +++- main.go | 113 ++++++++++++++++++++++++++++++++++++--------- web/host.html | 2 +- web/static/host.js | 3 ++ 4 files changed, 103 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 1067cfc..39e1190 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,13 @@ live meters for both directions and the selected network route. The page never p **Tally (On air / Off air)**, added 2026-11-06: a button on the console sets a big ON AIR / Off air bar on the guest's page. It is **only a sign**: it changes no audio, and the guest is heard -whenever their fader is up. It is set by hand and reset to Off air by New link / Revoke. +whenever their fader is up. **Automatic since 2026-11-06 (`-tally-auto`, on by default):** it goes ON AIR when the MMO +Stream app goes LIVE and Off air when it stops. Detection: any `darkice` process holding an +established TCP connection (`ss -tnp`), which happens when a stream server accepts it. +Read-only; mmo-stream itself is unchanged. ON needs two 2 s polls; OFF waits 15 s, which rides +out mmo-stream's 5 s reconnect. It acts only when the stream state *changes*, so a manual +click on the console sticks until the stream next starts or stops. New link / Revoke set it +to match the stream. `-tally-auto=false` makes it manual only. **Connection warning**, added 2026-11-06: a banner on the console when the guest -> show audio is audibly struggling over the last 10 s. "Wobbling" means 3 or more patched gaps (concealed diff --git a/main.go b/main.go index 222e1f6..93b1193 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,7 @@ import ( "net" "net/http" "os" + "os/exec" "path/filepath" "strings" "sync" @@ -55,6 +56,7 @@ var ( sourceName = flag.String("source", "motu_mixminus.monitor", "PipeWire source sent to the guest") hostLabel = flag.String("host-label", "Host", "name the guest sees on host chat messages") testPlayoutFile = flag.String("test-playout-file", "", "TEST ONLY: write guest audio to this raw file instead of the sink") + tallyAuto = flag.Bool("tally-auto", true, "set the guest's tally from the MMO Stream app: ON AIR while any darkice is connected to its server") stateDir = flag.String("state", filepath.Join(os.Getenv("HOME"), ".local/state/guestlink"), "state directory (invite token)") ) @@ -82,16 +84,17 @@ func (w *wsConn) send(v any) { } type Hub struct { - mu sync.Mutex - token string - guest *wsConn - guestName string - call *Call - hosts map[*wsConn]bool - chat []chatMsg - onAir bool // tally shown on the guest page; set by the host, never automatic - udp *net.UDPConn - mux ice.UDPMux + mu sync.Mutex + token string + guest *wsConn + guestName string + call *Call + hosts map[*wsConn]bool + chat []chatMsg + onAir bool // tally shown on the guest page (sign only, no audio effect) + streamLive bool // MMO Stream (darkice) currently connected, per watchStream + udp *net.UDPConn + mux ice.UDPMux } var hub = &Hub{hosts: map[*wsConn]bool{}} @@ -131,6 +134,8 @@ func (h *Hub) stateLocked() map[string]any { "guestOnline": h.guest != nil, "guestName": h.guestName, "onAir": h.onAir, + "tallyAuto": *tallyAuto, + "streamLive": h.streamLive, "call": nil, } if h.call != nil { @@ -466,7 +471,7 @@ func hostWS(w http.ResponseWriter, r *http.Request) { hub.token = randToken() hub.chat = nil hub.guestName = "" - hub.onAir = false + hub.onAir = *tallyAuto && hub.streamLive // a new session starts matching the stream hub.saveToken() hub.mu.Unlock() log.Printf("new invite link created") @@ -476,7 +481,7 @@ func hostWS(w http.ResponseWriter, r *http.Request) { hub.kickGuest("The host closed this session.") hub.mu.Lock() hub.token = "" - hub.onAir = false + hub.onAir = *tallyAuto && hub.streamLive // a new session starts matching the stream hub.saveToken() hub.mu.Unlock() log.Printf("invite link revoked") @@ -492,15 +497,7 @@ func hostWS(w http.ResponseWriter, r *http.Request) { g.send(map[string]any{"t": "callended"}) } case "onair": - hub.mu.Lock() - hub.onAir = m.On - g := hub.guest - hub.mu.Unlock() - log.Printf("tally: on air %v", m.On) - if g != nil { - g.send(map[string]any{"t": "onair", "on": m.On}) - } - hub.pushState() + hub.setTally(m.On, "set by host") case "clearchat": hub.mu.Lock() hub.chat = nil @@ -515,12 +512,86 @@ func hostWS(w http.ResponseWriter, r *http.Request) { } } +// ---------------------------------------------------------------- automatic tally + +// streamLive reports whether any darkice process (launched by mmo-stream) holds an +// established TCP connection, i.e. a stream server accepted it. That is the same moment +// mmo-stream shows LIVE. Read-only: it never touches mmo-stream or darkice. +func streamLive() (bool, error) { + out, err := exec.Command("ss", "-Htnp", "state", "established").Output() + if err != nil { + return false, err + } + return strings.Contains(string(out), `(("darkice",`), nil +} + +// setTally sets the tally and tells the guest and the consoles. +func (h *Hub) setTally(on bool, why string) { + h.mu.Lock() + changed := h.onAir != on + h.onAir = on + g := h.guest + h.mu.Unlock() + if !changed { + return + } + log.Printf("tally: on air %v (%s)", on, why) + if g != nil { + g.send(map[string]any{"t": "onair", "on": on}) + } + h.pushState() +} + +// watchStream follows the stream state. It acts only on a CHANGE of stream state, so a +// manual click on the console sticks until the stream next connects or stops. ON after two +// polls (a refused connection is open for a moment), OFF after 15 s down, which rides out +// mmo-stream's 5 s reconnect. +func watchStream() { + const poll = 2 * time.Second + live, upPolls, downSince := false, 0, time.Time{} + for range time.Tick(poll) { + up, err := streamLive() + if err != nil { + continue + } + if up { + upPolls++ + downSince = time.Time{} + if !live && upPolls >= 2 { + live = true + hub.setStreamLive(true) + hub.setTally(true, "MMO Stream connected") + } + } else { + upPolls = 0 + if downSince.IsZero() { + downSince = time.Now() + } + if live && time.Since(downSince) >= 15*time.Second { + live = false + hub.setStreamLive(false) + hub.setTally(false, "MMO Stream stopped") + } + } + } +} + +func (h *Hub) setStreamLive(v bool) { + h.mu.Lock() + h.streamLive = v + h.mu.Unlock() + h.pushState() +} + // ---------------------------------------------------------------- main func main() { flag.Parse() log.SetFlags(log.LstdFlags) hub.loadToken() + if *tallyAuto { + go watchStream() + } udp, err := net.ListenUDP("udp4", &net.UDPAddr{Port: *udpPort}) if err != nil { diff --git a/web/host.html b/web/host.html index 9ebe3cc..c3ac5bd 100644 --- a/web/host.html +++ b/web/host.html @@ -14,7 +14,7 @@

A new link ends the current session and clears chat. Old links stop working.

Guest

- Guest's tally light - sign only, no audio change
+ Guest's tally light - sign only, no audio change
No guest connected
diff --git a/web/static/host.js b/web/static/host.js index ee8b7e6..c35dc8c 100644 --- a/web/static/host.js +++ b/web/static/host.js @@ -56,6 +56,9 @@ function render(s){ $('#tally').textContent = onAir ? '● ON AIR' : 'Off air'; $('#tally').classList.toggle('on', onAir); $('#tally').disabled = !s.link; + $('#tallyhint').textContent = s.tallyAuto + ? 'Auto: follows MMO Stream (' + (s.streamLive ? 'streaming now' : 'not streaming') + '). Click to override until the stream next starts or stops. Sign only, no audio change.' + : 'Guest\'s tally light - sign only, no audio change'; checkTrouble(s.call); $('#copy').disabled = $('#revoke').disabled = !s.link; const name = s.guestName || 'Guest (anonymous)';