Tally follows MMO Stream (darkice connected) automatically

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
namandClaude Opus 5.5 committed 2026-11-06 11:40:56 -06:00
1 parent f5c5c593f9
commit 19e5b3d898
4 files changed
+103 -23

No files matched your search

+7 -1
View File
@@ -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
+92 -21
View File
@@ -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 {
+1 -1
View File
@@ -14,7 +14,7 @@
<p class="muted small">A new link ends the current session and clears chat. Old links stop working.</p>
<h2>Guest</h2>
<div class="row"><button id="tally" class="tally" title="Shows ON AIR / Off air on the guest's page. Does not change any audio.">Off air</button>
<span class="muted small">Guest's tally light - sign only, no audio change</span></div>
<span id="tallyhint" class="muted small">Guest's tally light - sign only, no audio change</span></div>
<div id="warn" class="warnbox hidden" role="status"></div>
<div id="guest" class="big">No guest connected</div>
<div id="callinfo" class="muted small"></div>
+3
View File
@@ -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)';