Files
OpsLog/internal/audio/mp3.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

130 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package audio
import (
"math"
"os"
"github.com/braheezy/shine-mp3/pkg/mp3"
)
// mp3Rate is the encode sample rate. The capture pipeline is 16 kHz, but the
// Shine encoder emits broken "free-format" frames at MPEG-2 rates (16/22/24
// kHz) that most players reject. Encoding at an MPEG-1 rate (we upsample ×2 to
// 32 kHz) produces standard, universally-playable MP3s.
const mp3Rate = sampleRate * 2 // 32000
// writeMP3 encodes 16 kHz mono 16-bit PCM to a standard MP3 file using the
// pure-Go Shine encoder (no CGO). Two quirks are worked around:
// - 16 kHz (MPEG-2) yields broken free-format frames → upsample ×2 to 32 kHz.
// - Shine's Write only encodes half the samples for MONO input (its loop
// advances by samples_per_pass*2). Feeding STEREO interleaved data (the
// encoder reads samples_per_pass*channels per pass) encodes everything, so
// we duplicate mono → L=R stereo.
func writeMP3(path string, pcm []byte) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
mono32 := upsample2(bytesToInt16(pcm)) // 16 kHz → 32 kHz mono
stereo := make([]int16, len(mono32)*2) // L=R interleaved
for i, v := range mono32 {
stereo[2*i], stereo[2*i+1] = v, v
}
// Shine's Write() reads a WHOLE frame (samplesPerPass × channels = 1152 × 2 =
// 2304 interleaved samples for MPEG-1) per pass via unsafe pointer arithmetic,
// regardless of how short the trailing chunk is. If the buffer isn't an exact
// multiple of a frame, the final pass reads past the slice and the process
// dies with an access violation (0xc0000005) inside windowFilterSubband.
// Pad with trailing silence to a whole number of frames so no partial pass
// exists. (~36 ms of silence at most — inaudible.)
const frameInterleaved = 1152 * 2 // samplesPerPass(MPEG-1) × 2 channels
if rem := len(stereo) % frameInterleaved; rem != 0 {
stereo = append(stereo, make([]int16, frameInterleaved-rem)...)
}
if len(stereo) == 0 {
return nil // nothing to encode (empty recording)
}
enc := mp3.NewEncoder(mp3Rate, 2)
return enc.Write(f, stereo)
}
// upsample2 doubles the sample rate, 16 kHz → 32 kHz, WITH the interpolation
// filter that makes the operation legitimate.
//
// It used to interpolate linearly — each new sample the average of its
// neighbours. That is an upsampler in name only: every component at f is
// mirrored to 16 kHz f, measured just 7 dB down (see the test). On speech it
// adds brightness; on receiver noise, which is broadband, it lays a second
// noise floor across the top of the band. Operators heard it as a hiss louder
// than the voice, present in the MP3 and absent from the WAV of the very same
// audio.
//
// Zero-stuffing followed by a windowed-sinc low-pass at the old Nyquist is the
// textbook answer, and it puts the image below 40 dB. 63 taps is a few hundred
// microseconds of work on an eight-second recording — nothing, next to the MP3
// encode that follows.
func upsample2(in []int16) []int16 {
if len(in) == 0 {
return in
}
out := make([]int16, len(in)*2)
half := len(upsampleFIR) / 2
for i := range out {
var acc float64
// The zero-stuffed signal is non-zero only at even indices, so only
// every other tap contributes — the loop skips the zeros rather than
// multiplying by them.
start := (i - half + 1) &^ 1 // first even index in the window
for j := start; j <= i+half; j += 2 {
k := i - j + half
if k < 0 || k >= len(upsampleFIR) {
continue
}
src := j / 2
if src < 0 || src >= len(in) {
continue
}
acc += float64(in[src]) * upsampleFIR[k]
}
// ×2 for the energy lost to zero-stuffing, then clamp.
v := acc * 2
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
out[i] = int16(v)
}
return out
}
// upsampleFIR is a 63-tap Hamming-windowed sinc, cut off at a quarter of the
// NEW rate (8 kHz at 32 kHz) — exactly the old Nyquist, which is where the
// images begin.
var upsampleFIR = func() []float64 {
const n = 63
const fc = 0.25 // cycles/sample at the new rate
h := make([]float64, n)
mid := (n - 1) / 2
var sum float64
for i := 0; i < n; i++ {
m := float64(i - mid)
var v float64
if m == 0 {
v = 2 * fc
} else {
v = math.Sin(2*math.Pi*fc*m) / (math.Pi * m)
}
// Hamming window: a rectangular one would ring and put the stopband
// only ~21 dB down, which is not enough to be worth the filter.
v *= 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(n-1))
h[i] = v
sum += v
}
for i := range h {
h[i] /= sum // unity gain at DC
}
return h
}()