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>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user