Confirmed on a real SunSDR: the stream decodes and the test recording plays back clean. So it can do the job a virtual audio cable was doing — this wires it to the QSO recorder, which already accepts a pushed source (the Icom network audio uses the same door). The conversion lives here rather than in internal/cat: the radio's job is to hand over what it sent, not to know that the recorder works in 16 kHz mono. Three samples are AVERAGED rather than two of them dropped — decimating by picking every third folds everything above 8 kHz back into the voice band, and on a receiver that is hiss, which a QSO recording has plenty of already. Off by default, and applied the moment it is switched: it replaces a sound card the operator has already wired up, and an option that needs a restart to take effect reads as an option that does not work.
88 lines
2.3 KiB
Go
88 lines
2.3 KiB
Go
//go:build windows
|
|
|
|
package audio
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"os"
|
|
)
|
|
|
|
// The DVK/recorder pipeline uses a single fixed PCM format end-to-end: 16 kHz
|
|
// mono 16-bit. That's plenty for SSB voice (3 kHz audio bandwidth), keeps files
|
|
// tiny (~32 KB/s), and — fed through WASAPI's AUTOCONVERTPCM — plays/records on
|
|
// any device regardless of its native mix format.
|
|
const (
|
|
sampleRate = 16000
|
|
|
|
channels = 1
|
|
bitsPerSample = 16
|
|
blockAlign = channels * bitsPerSample / 8 // bytes per frame (=2)
|
|
bytesPerSec = sampleRate * blockAlign // =32000
|
|
)
|
|
|
|
// writeWAV writes 16-bit PCM as a canonical RIFF/WAVE file.
|
|
func writeWAV(path string, pcm []byte) error {
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
dataLen := len(pcm)
|
|
put := func(v any) { _ = binary.Write(f, binary.LittleEndian, v) }
|
|
f.WriteString("RIFF")
|
|
put(uint32(36 + dataLen))
|
|
f.WriteString("WAVE")
|
|
f.WriteString("fmt ")
|
|
put(uint32(16)) // PCM fmt chunk size
|
|
put(uint16(1)) // WAVE_FORMAT_PCM
|
|
put(uint16(channels)) //
|
|
put(uint32(sampleRate)) //
|
|
put(uint32(bytesPerSec)) // byte rate
|
|
put(uint16(blockAlign)) //
|
|
put(uint16(bitsPerSample)) //
|
|
f.WriteString("data")
|
|
put(uint32(dataLen))
|
|
_, err = f.Write(pcm)
|
|
return err
|
|
}
|
|
|
|
// readWAV reads a PCM WAV and returns the raw sample bytes plus its format.
|
|
// Handles arbitrary chunk ordering (walks the RIFF chunk list).
|
|
func readWAV(path string) (pcm []byte, rate, ch, bits int, err error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, 0, 0, 0, err
|
|
}
|
|
if len(b) < 12 || string(b[0:4]) != "RIFF" || string(b[8:12]) != "WAVE" {
|
|
return nil, 0, 0, 0, fmt.Errorf("not a WAVE file")
|
|
}
|
|
i := 12
|
|
for i+8 <= len(b) {
|
|
id := string(b[i : i+4])
|
|
size := int(binary.LittleEndian.Uint32(b[i+4 : i+8]))
|
|
body := i + 8
|
|
if body+size > len(b) {
|
|
size = len(b) - body
|
|
}
|
|
switch id {
|
|
case "fmt ":
|
|
if size >= 16 {
|
|
ch = int(binary.LittleEndian.Uint16(b[body+2 : body+4]))
|
|
rate = int(binary.LittleEndian.Uint32(b[body+4 : body+8]))
|
|
bits = int(binary.LittleEndian.Uint16(b[body+14 : body+16]))
|
|
}
|
|
case "data":
|
|
pcm = b[body : body+size]
|
|
}
|
|
i = body + size
|
|
if size%2 == 1 {
|
|
i++ // chunks are word-aligned
|
|
}
|
|
}
|
|
if pcm == nil || rate == 0 {
|
|
return nil, 0, 0, 0, fmt.Errorf("WAV missing fmt/data")
|
|
}
|
|
return pcm, rate, ch, bits, nil
|
|
}
|