From 6233292931d67cf1c58567f21c3a806966288500 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Sat, 3 Oct 2026 16:32:01 +0200 Subject: [PATCH] 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 --- app.go | 2 +- build/darwin/Info.plist | 2 ++ frontend/dist/app.js | 10 ---------- internal/opus/opus.go | 1 + main.go | 1 + main_bindings.go | 1 + station/behavior_test.go | 26 ++++++++++++++++++++++++++ station/session.go | 11 +++++++---- station/station.go | 5 +++++ 9 files changed, 44 insertions(+), 15 deletions(-) diff --git a/app.go b/app.go index 9a51c74..036413f 100644 --- a/app.go +++ b/app.go @@ -23,7 +23,7 @@ func (a *App) startup(ctx context.Context) { a.station.Fail("could not open the audio device") 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) { diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist index d17a747..b742140 100644 --- a/build/darwin/Info.plist +++ b/build/darwin/Info.plist @@ -23,6 +23,8 @@ true NSHumanReadableCopyright {{.Info.Copyright}} + NSMicrophoneUsageDescription + funknetz uses the microphone to answer the talk group. {{if .Info.FileAssociations}} CFBundleDocumentTypes diff --git a/frontend/dist/app.js b/frontend/dist/app.js index bf1bc26..daff955 100644 --- a/frontend/dist/app.js +++ b/frontend/dist/app.js @@ -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); refresh(); diff --git a/internal/opus/opus.go b/internal/opus/opus.go index cbb33fb..038f1eb 100644 --- a/internal/opus/opus.go +++ b/internal/opus/opus.go @@ -26,6 +26,7 @@ import ( "unsafe" ) +// FrameSamples is the 20 ms frame at 16 kHz, which is what the reflector carries. const FrameSamples = 320 // Encoder produces one raw Opus packet per 20 ms frame. diff --git a/main.go b/main.go index 48a6256..0532571 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,6 @@ //go:build !bindings +// Command funknetz is the headset station window. package main import ( diff --git a/main_bindings.go b/main_bindings.go index 0720908..bad281d 100644 --- a/main_bindings.go +++ b/main_bindings.go @@ -1,5 +1,6 @@ //go:build bindings +// Command funknetz is the headset station window. package main import ( diff --git a/station/behavior_test.go b/station/behavior_test.go index ff0ff3b..92c2f06 100644 --- a/station/behavior_test.go +++ b/station/behavior_test.go @@ -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) { in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) in.st.HoldButton() diff --git a/station/session.go b/station/session.go index dc8e457..763d2ac 100644 --- a/station/session.go +++ b/station/session.go @@ -163,12 +163,12 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error { case <-ctx.Done(): timer.Stop() return ctx.Err() - case <-tcpErr: + case err := <-tcpErr: timer.Stop() - return errors.New("tcp closed") - case <-udpFail: + return err + case err := <-udpFail: timer.Stop() - return errors.New("udp closed") + return err case tcpf = <-tcpCh: timer.Stop() reason = "tcp" @@ -465,12 +465,14 @@ func (s *Station) endTransmit() { copy(frame, s.mic) s.mic = 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)) } } else { s.mic = nil } if s.udp != nil { + // A lost datagram ends the session when the receive timer expires. _ = s.sendUDP(udpFlush, nil) } } @@ -511,6 +513,7 @@ func (s *Station) publish() { s.state.Transmitting = tx if tx { s.state.Talker = s.cfg.callsign + s.play = nil } else { s.state.Talker = s.remoteTalker } diff --git a/station/station.go b/station/station.go index 64f2bcf..c9db2b5 100644 --- a/station/station.go +++ b/station/station.go @@ -151,6 +151,7 @@ func (s *Station) WriteMicrophone(pcm []int16) { if err != nil { continue } + // A lost datagram ends the session when the receive timer expires. _ = s.sendUDP(udpAudio, appendBlob(nil, pkt)) } } @@ -159,6 +160,10 @@ func (s *Station) WriteMicrophone(pcm []int16) { func (s *Station) TakePlayback() []int16 { s.mu.Lock() defer s.mu.Unlock() + if s.transmitting() { + s.play = nil + return nil + } out := s.play s.play = nil return out