Files
OpsLog/internal/audio/engine_linux.go
T
rouggyandClaude Opus 5 8b1dff581b feat(linux): the Go half of OpsLog builds for Linux
Measured rather than guessed: the whole repository was cross-compiled for
linux/amd64 and the gaps closed one by one. There were fewer than expected.

Flex and TCI were never Windows-specific — they carried //go:build windows by
inheritance and import nothing but net and gorilla/websocket. Untagged, no code
change. The two backends a Linux operator is most likely to own were already
portable.

Audio was 560 lines, not 2287: only devices.go and engine.go touch WASAPI, while
manager.go, recorder.go, wav.go and mp3.go were pure Go wearing the tag by
association. The whole platform surface is seven functions, now implemented a
second time on PulseAudio through github.com/jfreymuth/pulse — pure Go over the
server socket, so the no-cgo rule survives, and PipeWire answers the same
protocol. The fixed 16 kHz mono format and the server-side resampling mirror
what AUTOCONVERTPCM does on Windows, for the same reason.

OmniRig is the only real loss, and its backend still EXISTS off Windows rather
than being compiled out of app.go: a settings database is portable, so an
operator moving a profile across keeps "omnirig" saved and must be told to pick
a native backend instead of meeting a nil one.

The parts where Linux is not Windows, and where a compile-only stub would have
been a silent bug:

  - data dir: still beside the binary, but ~/.local/share/OpsLog/data when that
    folder belongs to the system — decided by trying the write, because /opt and
    /usr/local are writable on some stations and not others.
  - single instance: an flock, not a pid file. The kernel drops it however the
    process dies, so a crash leaves nothing to delete by hand. This is the guard
    that stops two instances fighting over the rig frequency.
  - update: simpler here. Unix renames over a running binary, so the deferred
    swap the Windows path needs a detached helper for is unreachable.
  - tasklist/taskkill become /proc and SIGTERM; the boot log moves out of /tmp,
    which is wiped exactly when the evidence is wanted.
  - serial ports sorted naturally: /dev/ttyUSB10 was landing between USB1 and
    USB2, the same trap COM10 fell into.

release.ps1 now cross-builds and vets for linux before it builds the exe, and
refuses the release if that fails — a port rots one unguarded x/sys/windows call
at a time.

Nothing has been executed on Linux yet: Wails needs webkit2gtk and cgo there, so
the binary must be built on Linux. scripts/linux-setup.sh checks the machine and
does it; BUILDING-LINUX.md is the manual version.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 10:21:27 +02:00

276 lines
8.3 KiB
Go

//go:build linux
package audio
// engine_linux.go — the four calls the rest of the package makes into the sound
// card, implemented on PulseAudio. The Windows half of this pair is engine.go
// (WASAPI); nothing above these functions knows which one it is talking to.
//
// Capture is fixed at 16 kHz mono 16-bit, the format the DVK, the recorder and
// the CW tap all share (see wav.go). We ask the server for it and let the
// server resample from whatever the device really runs at — the same division
// of labour as WASAPI's AUTOCONVERTPCM, and for the same reason: a rig codec
// that only does 48 kHz must still feed a 16 kHz pipeline, and the sound
// server's converter filters before it decimates, where a naive one folds the
// receiver hiss above 8 kHz straight back on top of the voice.
import (
"fmt"
"io"
"time"
"github.com/jfreymuth/pulse"
"github.com/jfreymuth/pulse/proto"
)
// chunkFrames is how much audio a playback reader hands over at once (20 ms).
// It bounds how much silence a padded underrun can queue ahead of real audio,
// which is what keeps live monitoring from drifting seconds behind the rig.
const chunkFrames = sampleRate / 50
// channelMap describes n channels to the server. Only mono and stereo occur
// here — capture is always mono, and playback follows the WAV being played.
func channelMap(n int) proto.ChannelMap {
if n >= 2 {
return proto.ChannelMap{proto.ChannelLeft, proto.ChannelRight}
}
return proto.ChannelMap{proto.ChannelMono}
}
// chunkWriter turns the record stream's byte deliveries into onChunk calls.
// The server reuses its buffer between deliveries, so every chunk is copied
// before it leaves: the recorder keeps the slices it is given.
type chunkWriter struct{ onChunk func([]byte) }
func (w chunkWriter) Write(p []byte) (int, error) {
if len(p) > 0 && w.onChunk != nil {
cp := make([]byte, len(p))
copy(cp, p)
w.onChunk(cp)
}
return len(p), nil
}
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
// stop channel is closed.
func recordPCM(deviceID string, stop <-chan struct{}) ([]byte, error) {
out := make([]byte, 0, bytesPerSec*4)
err := captureStream(deviceID, stop, func(chunk []byte) { out = append(out, chunk...) })
return out, err
}
// captureStream opens a device and calls onChunk with freshly-captured 16 kHz
// mono 16-bit PCM as it arrives, until stop closes. onChunk receives a private
// copy it may retain.
func captureStream(deviceID string, stop <-chan struct{}, onChunk func([]byte)) error {
c, err := pulseClient()
if err != nil {
return err
}
defer c.Close()
// Rate and channels first, then latency — the latency option sizes its
// buffer from both, so setting it earlier would size it from the defaults.
//
// 50 ms of fragment: the CW decoder is downstream of this and works on the
// chunks as they arrive, so a server-chosen fragment of a quarter of a
// second would make it decide about a dit long after the dit was over.
opts := []pulse.RecordOption{
pulse.RecordSampleRate(sampleRate),
pulse.RecordChannels(channelMap(channels)),
pulse.RecordLatency(0.05),
pulse.RecordMediaName("OpsLog capture"),
}
// An empty id means "whatever the desktop calls the default", which is also
// what an operator who has never opened the audio settings expects.
if deviceID != "" {
src, err := c.SourceByID(deviceID)
if err != nil {
return fmt.Errorf("no audio input %q: %w", deviceID, err)
}
opts = append(opts, pulse.RecordSource(src))
}
st, err := c.NewRecord(pulse.NewWriter(chunkWriter{onChunk}, proto.FormatInt16LE), opts...)
if err != nil {
return fmt.Errorf("open capture: %w", err)
}
defer st.Close()
st.Start()
<-stop
st.Stop()
return st.Error()
}
// playPCM plays a fixed buffer to a device and returns when it has been heard
// (or when stop closes, which cuts it short).
func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
if len(pcm) == 0 {
return nil
}
format, err := pulseFormat(bits)
if err != nil {
return err
}
frameBytes := ch * bits / 8
if frameBytes <= 0 || rate <= 0 {
return fmt.Errorf("bad audio format")
}
c, err := pulseClient()
if err != nil {
return err
}
defer c.Close()
// The reader hands out the buffer a slice at a time and ends the stream
// with EndOfData — the library's own sentinel. io.EOF would work as an end
// too, but it is recorded as the stream's error, and a message finishing
// normally must not look like a fault in the log.
pos := 0
read := func(buf []byte) (int, error) {
select {
case <-stop:
return 0, pulse.EndOfData
default:
}
if pos >= len(pcm) {
return 0, pulse.EndOfData
}
n := copy(buf, pcm[pos:])
n -= n % frameBytes // never hand the server a partial frame
if n == 0 {
return 0, pulse.EndOfData
}
pos += n
return n, nil
}
opts := []pulse.PlaybackOption{
pulse.PlaybackSampleRate(rate),
pulse.PlaybackChannels(channelMap(ch)),
pulse.PlaybackLatency(0.1),
pulse.PlaybackMediaName("OpsLog playback"),
}
if deviceID != "" {
sink, err := c.SinkByID(deviceID)
if err != nil {
return fmt.Errorf("no audio output %q: %w", deviceID, err)
}
opts = append(opts, pulse.PlaybackSink(sink))
}
st, err := c.NewPlayback(pulse.NewReader(readerFunc(read), format), opts...)
if err != nil {
return fmt.Errorf("open playback: %w", err)
}
defer st.Close()
st.Start()
// Drain blocks until the server has played everything queued. Waiting on it
// in a goroutine keeps stop responsive: a voice message must cut off the
// instant the operator unkeys, not at the end of the buffer.
drained := make(chan struct{})
go func() { st.Drain(); close(drained) }()
select {
case <-drained:
case <-stop:
st.Stop()
}
return st.Error()
}
// renderStream continuously renders PCM pulled from src to a device until stop
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
// writes silence rather than glitching, keeping the server's clock steady so
// live monitor audio flows smoothly even when the source stalls briefly.
func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error {
format, err := pulseFormat(bits)
if err != nil {
return err
}
frameBytes := ch * bits / 8
if frameBytes <= 0 || rate <= 0 || src == nil {
return fmt.Errorf("bad audio format")
}
c, err := pulseClient()
if err != nil {
return err
}
defer c.Close()
// Never return 0 bytes without an error: the library's playback loop would
// spin on it. A stalled source therefore yields silence, which is also the
// behaviour that keeps the clock running.
chunk := chunkFrames * frameBytes
read := func(buf []byte) (int, error) {
select {
case <-stop:
return 0, pulse.EndOfData
default:
}
n := len(buf)
if n > chunk {
n = chunk
}
n -= n % frameBytes
if n == 0 {
n = frameBytes
}
got := copy(buf[:n], src.pull(n))
for i := got; i < n; i++ {
buf[i] = 0
}
return n, nil
}
opts := []pulse.PlaybackOption{
pulse.PlaybackSampleRate(rate),
pulse.PlaybackChannels(channelMap(ch)),
pulse.PlaybackLatency(0.1),
pulse.PlaybackMediaName("OpsLog monitor"),
}
if deviceID != "" {
sink, err := c.SinkByID(deviceID)
if err != nil {
return fmt.Errorf("no audio output %q: %w", deviceID, err)
}
opts = append(opts, pulse.PlaybackSink(sink))
}
st, err := c.NewPlayback(pulse.NewReader(readerFunc(read), format), opts...)
if err != nil {
return fmt.Errorf("open monitor: %w", err)
}
defer st.Close()
st.Start()
<-stop
st.Stop()
// Give the server a moment to notice the stream stopped before the client
// socket goes away, so the last fragment is heard instead of clipped.
time.Sleep(20 * time.Millisecond)
return st.Error()
}
// pulseFormat maps a WAV bit depth onto the server's sample formats. 8 and 16
// bit cover everything OpsLog produces or reads; anything else is refused by
// name rather than played as noise.
func pulseFormat(bits int) (byte, error) {
switch bits {
case 8:
return proto.FormatUint8, nil
case 16:
return proto.FormatInt16LE, nil
default:
return 0, fmt.Errorf("unsupported sample size %d-bit (8 or 16 expected)", bits)
}
}
// readerFunc adapts a read closure to io.Reader.
type readerFunc func([]byte) (int, error)
func (f readerFunc) Read(p []byte) (int, error) { return f(p) }
var _ io.Reader = readerFunc(nil)