Files
funknetz/station/station.go
T
Konrad NeitzelandCursor 9c4f6621fc Join one FM-Funknetz talk group from a headset window.
The station reads ~/.funknetz.json, speaks protocol 2.0, and builds an unsigned funknetz.app.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 16:21:41 +02:00

185 lines
4.2 KiB
Go

// Package station is the headset station. It loads the config, keeps one
// reflector session, and exposes the state the window shows.
package station
import (
"context"
"net"
"sync"
"time"
"gitea.neitzel.de/konrad/funknetz/internal/opus"
)
// Options selects the config file and the clock used for heartbeats and retries.
type Options struct {
ConfigPath string
Clock Clock
}
// State is what the window shows.
type State struct {
Connection string `json:"connection"`
Detail string `json:"detail"`
Callsign string `json:"callsign"`
TalkGroup int `json:"talkGroup"`
Talker string `json:"talker"`
Transmitting bool `json:"transmitting"`
}
// Station is the seam tests and the window use.
type Station struct {
cfg loaded
clock Clock
mu sync.Mutex
state State
wake chan struct{}
buttonHeld bool
spaceHeld bool
fatal string
remoteTalker string
roomBusy bool
mic []int16
play []int16
clientID uint16
seq uint16
udp *net.UDPConn
lastUDPSend time.Time
enc *opus.Encoder
dec *opus.Decoder
}
// New loads the config at opt.ConfigPath. A missing file is created mode 0600
// and New returns an error so the caller can exit before opening a window.
func New(opt Options) (*Station, error) {
cfg, err := loadConfig(opt.ConfigPath)
if err != nil {
return nil, err
}
clock := opt.Clock
if clock == nil {
clock = wallClock{}
}
s := &Station{
cfg: cfg,
clock: clock,
wake: make(chan struct{}, 1),
state: State{
Connection: "Disconnected",
Detail: "Idle",
Callsign: cfg.callsign,
TalkGroup: cfg.talkGroup,
},
}
return s, nil
}
// State returns the latest window state.
func (s *Station) State() State {
s.mu.Lock()
defer s.mu.Unlock()
return s.state
}
// Wait blocks until pred is true or ctx ends.
func (s *Station) Wait(ctx context.Context, pred func(State) bool) (State, error) {
s.mu.Lock()
defer s.mu.Unlock()
for !pred(s.state) {
s.mu.Unlock()
select {
case <-ctx.Done():
s.mu.Lock()
return State{}, ctx.Err()
case <-s.wake:
s.mu.Lock()
}
}
return s.state, nil
}
// HoldButton reports that the on-screen button is held.
func (s *Station) HoldButton() { s.setButton(true) }
// ReleaseButton reports that the on-screen button was released.
func (s *Station) ReleaseButton() { s.setButton(false) }
// HoldSpace reports that Space is held.
func (s *Station) HoldSpace() { s.setSpace(true) }
// ReleaseSpace reports that Space was released.
func (s *Station) ReleaseSpace() { s.setSpace(false) }
func (s *Station) setButton(held bool) {
s.mu.Lock()
defer s.mu.Unlock()
was := s.transmitting()
s.buttonHeld = held
s.afterHold(was)
}
func (s *Station) setSpace(held bool) {
s.mu.Lock()
defer s.mu.Unlock()
was := s.transmitting()
s.spaceHeld = held
s.afterHold(was)
}
func (s *Station) afterHold(was bool) {
if was && !s.transmitting() {
s.endTransmit()
}
s.publish()
}
// WriteMicrophone accepts 16 kHz mono samples. Samples are sent only while
// this station is allowed to talk; otherwise they are discarded.
func (s *Station) WriteMicrophone(pcm []int16) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.transmitting() || s.enc == nil || s.udp == nil {
s.mic = nil
return
}
s.mic = append(s.mic, pcm...)
for len(s.mic) >= opus.FrameSamples {
frame := append([]int16(nil), s.mic[:opus.FrameSamples]...)
s.mic = append([]int16(nil), s.mic[opus.FrameSamples:]...)
pkt, err := s.enc.Encode(frame)
if err != nil {
continue
}
_ = s.sendUDP(udpAudio, appendBlob(nil, pkt))
}
}
// TakePlayback returns PCM queued for the speaker since the last take.
func (s *Station) TakePlayback() []int16 {
s.mu.Lock()
defer s.mu.Unlock()
out := s.play
s.play = nil
return out
}
// WaitPlayback waits until at least n samples are queued, then returns those samples.
func (s *Station) WaitPlayback(ctx context.Context, n int) ([]int16, error) {
s.mu.Lock()
defer s.mu.Unlock()
for len(s.play) < n {
s.mu.Unlock()
select {
case <-ctx.Done():
s.mu.Lock()
return nil, ctx.Err()
case <-s.wake:
s.mu.Lock()
}
}
out := append([]int16(nil), s.play[:n]...)
s.play = append([]int16(nil), s.play[n:]...)
return out, nil
}