diff --git a/README.md b/README.md
index f3ca8a2..1067cfc 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,16 @@ New link (ends the session, clears chat), Revoke, Copy, End audio, Kick, Clear c
live meters for both directions and the selected network route. The page never plays sound
(it is on the rig): a new guest message flashes the tab title.
+**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.
+
+**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
+frames) or one buffer dropout. "Bad" (red) means 25 or more gaps, 3 or more dropouts, or the
+connection dropping. FEC recoveries are inaudible and do not count. When it turns red and the
+tab is in the background, the tab title flashes (no sound).
+
## Chat
Anonymous: no accounts, and the guest's name is optional (default "Guest"). The guest sees
the host as `-host-label` (default "Host"). Messages are kept **in memory only** (last 300),
diff --git a/main.go b/main.go
index 82cb247..222e1f6 100644
--- a/main.go
+++ b/main.go
@@ -89,6 +89,7 @@ type Hub struct {
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
}
@@ -129,6 +130,7 @@ func (h *Hub) stateLocked() map[string]any {
"link": h.link(),
"guestOnline": h.guest != nil,
"guestName": h.guestName,
+ "onAir": h.onAir,
"call": nil,
}
if h.call != nil {
@@ -322,13 +324,14 @@ func guestWS(w http.ResponseWriter, r *http.Request) {
old := hub.guest
hub.guest = me
hist := append([]chatMsg(nil), hub.chat...)
+ onAir := hub.onAir
hub.mu.Unlock()
if old != nil {
old.send(map[string]any{"t": "ended", "reason": "This link was opened in another window."})
old.c.Close()
}
log.Printf("guest connected from %s", r.Header.Get("X-Real-IP"))
- me.send(map[string]any{"t": "welcome", "hostLabel": *hostLabel, "history": hist})
+ me.send(map[string]any{"t": "welcome", "hostLabel": *hostLabel, "history": hist, "onAir": onAir})
hub.pushState()
chatRL := &rateLimiter{n: 8, per: 10 * time.Second}
@@ -447,6 +450,7 @@ func hostWS(w http.ResponseWriter, r *http.Request) {
var m struct {
T string `json:"t"`
Text string `json:"text"`
+ On bool `json:"on"`
}
if err := c.ReadJSON(&m); err != nil {
return
@@ -462,6 +466,7 @@ func hostWS(w http.ResponseWriter, r *http.Request) {
hub.token = randToken()
hub.chat = nil
hub.guestName = ""
+ hub.onAir = false
hub.saveToken()
hub.mu.Unlock()
log.Printf("new invite link created")
@@ -471,6 +476,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.saveToken()
hub.mu.Unlock()
log.Printf("invite link revoked")
@@ -485,6 +491,16 @@ func hostWS(w http.ResponseWriter, r *http.Request) {
if g != nil {
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()
case "clearchat":
hub.mu.Lock()
hub.chat = nil
diff --git a/web/guest.html b/web/guest.html
index 30a6b60..4652ffc 100644
--- a/web/guest.html
+++ b/web/guest.html
@@ -5,6 +5,7 @@