Tally follows MMO Stream (darkice connected) automatically
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f5c5c593f9
commit
19e5b3d898
4 files changed
+103
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)';
|
||||
|
||||
Reference in new issue
Block a user