Pause playback while transmitting and declare microphone use.

macOS refuses capture without a usage string, and queued remote audio was still handed to the speaker after push-to-talk began.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Konrad Neitzel
2026-10-03 16:32:01 +02:00
co-authored by Cursor
parent 9c4f6621fc
commit 6233292931
9 changed files with 44 additions and 15 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ func (a *App) startup(ctx context.Context) {
a.station.Fail("could not open the audio device") a.station.Fail("could not open the audio device")
return return
} }
go func() { _ = a.station.Run(runCtx) }() go func() { _ = a.station.Run(runCtx) }() // Run returns when the window closes.
} }
func (a *App) shutdown(context.Context) { func (a *App) shutdown(context.Context) {
+2
View File
@@ -23,6 +23,8 @@
<string>true</string> <string>true</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string> <string>{{.Info.Copyright}}</string>
<key>NSMicrophoneUsageDescription</key>
<string>funknetz uses the microphone to answer the talk group.</string>
{{if .Info.FileAssociations}} {{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key> <key>CFBundleDocumentTypes</key>
<array> <array>
-10
View File
@@ -84,15 +84,5 @@ window.addEventListener("keyup", function (event) {
} }
}); });
window.addEventListener("blur", function () {
spaceHeld = false;
const app = api();
if (!app) {
return;
}
app.ReleaseButton();
app.ReleaseSpace();
});
setInterval(refresh, 100); setInterval(refresh, 100);
refresh(); refresh();
+1
View File
@@ -26,6 +26,7 @@ import (
"unsafe" "unsafe"
) )
// FrameSamples is the 20 ms frame at 16 kHz, which is what the reflector carries.
const FrameSamples = 320 const FrameSamples = 320
// Encoder produces one raw Opus packet per 20 ms frame. // Encoder produces one raw Opus packet per 20 ms frame.
+1
View File
@@ -1,5 +1,6 @@
//go:build !bindings //go:build !bindings
// Command funknetz is the headset station window.
package main package main
import ( import (
+1
View File
@@ -1,5 +1,6 @@
//go:build bindings //go:build bindings
// Command funknetz is the headset station window.
package main package main
import ( import (
+26
View File
@@ -360,6 +360,32 @@ func TestRemoteAudioAndTalker(t *testing.T) {
} }
} }
func TestPlaybackPausesWhenTransmitting(t *testing.T) {
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
enc, err := opus.NewEncoder()
if err != nil {
t.Fatal(err)
}
defer enc.Close()
pkt, err := enc.Encode(make([]int16, opus.FrameSamples))
if err != nil {
t.Fatal(err)
}
in.refl.writeUDP(t, 101, 7, 1, appendBytes(nil, pkt))
in.refl.writeUDP(t, 101, 7, 2, appendBytes(nil, pkt))
if _, err := in.st.WaitPlayback(t.Context(), 2*opus.FrameSamples-1); err != nil {
t.Fatal(err)
}
in.st.HoldButton()
in.refl.writeFrame(t, 105, appendStr(appendU32(nil, 20), "DB0XYZ"))
if _, err := in.st.Wait(t.Context(), func(s station.State) bool { return s.Transmitting }); err != nil {
t.Fatal(err)
}
if pcm := in.st.TakePlayback(); len(pcm) != 0 {
t.Fatalf("playback %d samples, want none while transmitting", len(pcm))
}
}
func TestMicrophoneIsNotPlayed(t *testing.T) { func TestMicrophoneIsNotPlayed(t *testing.T) {
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
in.st.HoldButton() in.st.HoldButton()
+7 -4
View File
@@ -163,12 +163,12 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error {
case <-ctx.Done(): case <-ctx.Done():
timer.Stop() timer.Stop()
return ctx.Err() return ctx.Err()
case <-tcpErr: case err := <-tcpErr:
timer.Stop() timer.Stop()
return errors.New("tcp closed") return err
case <-udpFail: case err := <-udpFail:
timer.Stop() timer.Stop()
return errors.New("udp closed") return err
case tcpf = <-tcpCh: case tcpf = <-tcpCh:
timer.Stop() timer.Stop()
reason = "tcp" reason = "tcp"
@@ -465,12 +465,14 @@ func (s *Station) endTransmit() {
copy(frame, s.mic) copy(frame, s.mic)
s.mic = nil s.mic = nil
if pkt, err := s.enc.Encode(frame); err == nil { if pkt, err := s.enc.Encode(frame); err == nil {
// A lost datagram ends the session when the receive timer expires.
_ = s.sendUDP(udpAudio, appendBlob(nil, pkt)) _ = s.sendUDP(udpAudio, appendBlob(nil, pkt))
} }
} else { } else {
s.mic = nil s.mic = nil
} }
if s.udp != nil { if s.udp != nil {
// A lost datagram ends the session when the receive timer expires.
_ = s.sendUDP(udpFlush, nil) _ = s.sendUDP(udpFlush, nil)
} }
} }
@@ -511,6 +513,7 @@ func (s *Station) publish() {
s.state.Transmitting = tx s.state.Transmitting = tx
if tx { if tx {
s.state.Talker = s.cfg.callsign s.state.Talker = s.cfg.callsign
s.play = nil
} else { } else {
s.state.Talker = s.remoteTalker s.state.Talker = s.remoteTalker
} }
+5
View File
@@ -151,6 +151,7 @@ func (s *Station) WriteMicrophone(pcm []int16) {
if err != nil { if err != nil {
continue continue
} }
// A lost datagram ends the session when the receive timer expires.
_ = s.sendUDP(udpAudio, appendBlob(nil, pkt)) _ = s.sendUDP(udpAudio, appendBlob(nil, pkt))
} }
} }
@@ -159,6 +160,10 @@ func (s *Station) WriteMicrophone(pcm []int16) {
func (s *Station) TakePlayback() []int16 { func (s *Station) TakePlayback() []int16 {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.transmitting() {
s.play = nil
return nil
}
out := s.play out := s.play
s.play = nil s.play = nil
return out return out