feat: added record qso dvk

This commit is contained in:
2026-06-04 00:46:35 +02:00
parent 1a425a1b0d
commit a2a29c66d2
24 changed files with 3098 additions and 346 deletions
+103
View File
@@ -0,0 +1,103 @@
//go:build windows
// Package audio drives Windows audio endpoints via WASAPI (through go-ole /
// go-wca) — pure Go, no CGO, the same COM stack OmniRig already uses. It
// powers the Digital Voice Keyer (record/play voice messages to the rig) and
// the QSO recorder (rolling-buffer capture saved as WAV).
package audio
import (
"fmt"
"runtime"
"sort"
"github.com/go-ole/go-ole"
"github.com/moutend/go-wca/pkg/wca"
)
// Device is one audio endpoint (a capture input or a render output).
type Device struct {
ID string `json:"id"` // stable WASAPI endpoint id (persisted)
Name string `json:"name"` // friendly name shown in dropdowns
Default bool `json:"default"` // is this the system default endpoint
}
// ListInputDevices returns the active capture endpoints — microphones,
// line-in, and the soundcard input wired to the rig's audio out ("From Radio").
func ListInputDevices() ([]Device, error) { return listEndpoints(wca.ECapture) }
// ListOutputDevices returns the active render endpoints — speakers and the
// soundcard output wired to the rig's mic/data input ("To Radio").
func ListOutputDevices() ([]Device, error) { return listEndpoints(wca.ERender) }
// listEndpoints enumerates active endpoints for a data-flow direction. COM is
// thread-affine, so we lock the OS thread and Co(Un)Initialize around the work
// — this is a one-shot call from a Wails binding, not a long-lived session.
func listEndpoints(flow uint32) (out []Device, err error) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if e := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); e != nil {
// 0x1 = S_FALSE → already initialised on this thread, fine.
if oe, ok := e.(*ole.OleError); !ok || oe.Code() != 0x00000001 {
return nil, fmt.Errorf("CoInitializeEx: %w", e)
}
}
defer ole.CoUninitialize()
var mmde *wca.IMMDeviceEnumerator
if e := wca.CoCreateInstance(wca.CLSID_MMDeviceEnumerator, 0, wca.CLSCTX_ALL,
wca.IID_IMMDeviceEnumerator, &mmde); e != nil {
return nil, fmt.Errorf("create MMDeviceEnumerator: %w", e)
}
defer mmde.Release()
// Record the default endpoint id so the UI can flag it.
var defID string
var defDev *wca.IMMDevice
if e := mmde.GetDefaultAudioEndpoint(flow, wca.EConsole, &defDev); e == nil && defDev != nil {
_ = defDev.GetId(&defID)
defDev.Release()
}
var coll *wca.IMMDeviceCollection
if e := mmde.EnumAudioEndpoints(flow, wca.DEVICE_STATE_ACTIVE, &coll); e != nil {
return nil, fmt.Errorf("enum endpoints: %w", e)
}
defer coll.Release()
var count uint32
if e := coll.GetCount(&count); e != nil {
return nil, fmt.Errorf("count endpoints: %w", e)
}
for i := uint32(0); i < count; i++ {
var dev *wca.IMMDevice
if coll.Item(i, &dev) != nil || dev == nil {
continue
}
var id string
_ = dev.GetId(&id)
name := endpointName(dev, id)
dev.Release()
out = append(out, Device{ID: id, Name: name, Default: id != "" && id == defID})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
// endpointName reads PKEY_Device_FriendlyName, falling back to the raw id.
func endpointName(dev *wca.IMMDevice, fallback string) string {
var ps *wca.IPropertyStore
if dev.OpenPropertyStore(wca.STGM_READ, &ps) != nil || ps == nil {
return fallback
}
defer ps.Release()
var pv wca.PROPVARIANT
if ps.GetValue(&wca.PKEY_Device_FriendlyName, &pv) != nil {
return fallback
}
if s := pv.String(); s != "" {
return s
}
return fallback
}
+271
View File
@@ -0,0 +1,271 @@
//go:build windows
package audio
import (
"fmt"
"runtime"
"time"
"unsafe"
"github.com/go-ole/go-ole"
"github.com/moutend/go-wca/pkg/wca"
)
const (
// AUDCLNT_BUFFERFLAGS_SILENT — the capture packet is silent; emit zeros.
bufferFlagSilent uint32 = 0x1
// 1-second WASAPI buffer (REFERENCE_TIME is in 100-ns units).
bufferDuration100ns = 10_000_000
)
func coInit() error {
if e := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); e != nil {
if oe, ok := e.(*ole.OleError); !ok || oe.Code() != 0x00000001 { // S_FALSE ok
return e
}
}
return nil
}
// openDevice resolves an IMMDevice by endpoint id, falling back to the default
// endpoint for the flow when id is empty or not found. Caller must Release().
func openDevice(flow uint32, id string) (*wca.IMMDevice, error) {
var mmde *wca.IMMDeviceEnumerator
if err := wca.CoCreateInstance(wca.CLSID_MMDeviceEnumerator, 0, wca.CLSCTX_ALL,
wca.IID_IMMDeviceEnumerator, &mmde); err != nil {
return nil, fmt.Errorf("create enumerator: %w", err)
}
defer mmde.Release()
if id != "" {
var coll *wca.IMMDeviceCollection
if err := mmde.EnumAudioEndpoints(flow, wca.DEVICE_STATE_ACTIVE, &coll); err == nil && coll != nil {
defer coll.Release()
var count uint32
coll.GetCount(&count)
for i := uint32(0); i < count; i++ {
var dev *wca.IMMDevice
if coll.Item(i, &dev) != nil || dev == nil {
continue
}
var did string
dev.GetId(&did)
if did == id {
return dev, nil // caller owns it
}
dev.Release()
}
}
}
var dev *wca.IMMDevice
if err := mmde.GetDefaultAudioEndpoint(flow, wca.EConsole, &dev); err != nil {
return nil, fmt.Errorf("no audio endpoint (id %q): %w", id, err)
}
return dev, nil
}
// pcmFormat is the fixed capture format (16 kHz mono 16-bit PCM). WASAPI's
// AUTOCONVERTPCM resamples from the device's native mix format for us.
func pcmFormat() *wca.WAVEFORMATEX {
return &wca.WAVEFORMATEX{
WFormatTag: 1, // WAVE_FORMAT_PCM
NChannels: channels,
NSamplesPerSec: sampleRate,
NAvgBytesPerSec: bytesPerSec,
NBlockAlign: blockAlign,
WBitsPerSample: bitsPerSample,
CbSize: 0,
}
}
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY
// 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. Runs on a COM-initialised, OS-locked thread.
func captureStream(deviceID string, stop <-chan struct{}, onChunk func([]byte)) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := coInit(); err != nil {
return fmt.Errorf("CoInitialize: %w", err)
}
defer ole.CoUninitialize()
dev, err := openDevice(wca.ECapture, deviceID)
if err != nil {
return err
}
defer dev.Release()
var ac *wca.IAudioClient
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
return fmt.Errorf("activate capture: %w", err)
}
defer ac.Release()
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
wca.REFERENCE_TIME(bufferDuration100ns), 0, pcmFormat(), nil); err != nil {
return fmt.Errorf("initialize capture: %w", err)
}
var acc *wca.IAudioCaptureClient
if err := ac.GetService(wca.IID_IAudioCaptureClient, &acc); err != nil {
return fmt.Errorf("get capture service: %w", err)
}
defer acc.Release()
if err := ac.Start(); err != nil {
return fmt.Errorf("start capture: %w", err)
}
defer ac.Stop()
for {
select {
case <-stop:
return nil
default:
}
var packet uint32
if err := acc.GetNextPacketSize(&packet); err != nil {
return err
}
if packet == 0 {
time.Sleep(10 * time.Millisecond)
continue
}
for packet > 0 {
var data *byte
var frames, flags uint32
var devpos, qpcpos uint64
if err := acc.GetBuffer(&data, &frames, &flags, &devpos, &qpcpos); err != nil {
return err
}
n := int(frames) * blockAlign
if n > 0 {
chunk := make([]byte, n)
if flags&bufferFlagSilent == 0 && data != nil {
copy(chunk, unsafe.Slice(data, n))
}
onChunk(chunk)
}
acc.ReleaseBuffer(frames)
if err := acc.GetNextPacketSize(&packet); err != nil {
return err
}
}
}
}
// playPCM renders raw PCM (with the given format) to a device, stopping early
// if the stop channel closes. Runs on a COM-initialised, OS-locked thread.
func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
if len(pcm) == 0 {
return nil
}
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := coInit(); err != nil {
return fmt.Errorf("CoInitialize: %w", err)
}
defer ole.CoUninitialize()
dev, err := openDevice(wca.ERender, deviceID)
if err != nil {
return err
}
defer dev.Release()
var ac *wca.IAudioClient
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
return fmt.Errorf("activate render: %w", err)
}
defer ac.Release()
frameBytes := ch * bits / 8
if frameBytes <= 0 {
return fmt.Errorf("bad audio format")
}
wfx := &wca.WAVEFORMATEX{
WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate),
NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes),
WBitsPerSample: uint16(bits), CbSize: 0,
}
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil {
return fmt.Errorf("initialize render: %w", err)
}
var bufFrames uint32
if err := ac.GetBufferSize(&bufFrames); err != nil {
return err
}
var arc *wca.IAudioRenderClient
if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil {
return fmt.Errorf("get render service: %w", err)
}
defer arc.Release()
totalFrames := len(pcm) / frameBytes
written := 0
feed := func(maxFrames int) error {
if maxFrames <= 0 || written >= totalFrames {
return nil
}
n := totalFrames - written
if n > maxFrames {
n = maxFrames
}
var data *byte
if err := arc.GetBuffer(uint32(n), &data); err != nil {
return err
}
dst := unsafe.Slice(data, n*frameBytes)
copy(dst, pcm[written*frameBytes:(written+n)*frameBytes])
arc.ReleaseBuffer(uint32(n), 0)
written += n
return nil
}
// Pre-fill before starting to avoid an initial glitch.
if err := feed(int(bufFrames)); err != nil {
return err
}
if err := ac.Start(); err != nil {
return fmt.Errorf("start render: %w", err)
}
defer ac.Stop()
for written < totalFrames {
select {
case <-stop:
return nil
default:
}
var padding uint32
ac.GetCurrentPadding(&padding)
if err := feed(int(bufFrames - padding)); err != nil {
return err
}
time.Sleep(8 * time.Millisecond)
}
// Drain the remaining buffered audio.
for {
select {
case <-stop:
return nil
default:
}
var padding uint32
if ac.GetCurrentPadding(&padding) != nil || padding == 0 {
return nil
}
time.Sleep(10 * time.Millisecond)
}
}
+137
View File
@@ -0,0 +1,137 @@
//go:build windows
package audio
import (
"fmt"
"sync"
)
// Manager owns the DVK record/playback lifecycle: at most one recording and
// one playback at a time. Device ids are passed per call so the host can route
// recording to the mic and playback to the rig (or the preview speakers).
type Manager struct {
mu sync.Mutex
recStop chan struct{}
recDone chan recResult
playStop chan struct{}
onChange func() // fired on any record/playback state transition
}
type recResult struct {
pcm []byte
err error
}
// NewManager creates a DVK manager. onChange (optional) is called whenever the
// recording/playback state changes, so the host can push an audio:status event.
func NewManager(onChange func()) *Manager { return &Manager{onChange: onChange} }
func (m *Manager) notify() {
if m.onChange != nil {
m.onChange()
}
}
// StartRecording begins capturing from deviceID into memory. Finish with
// StopRecording (which writes the WAV) or CancelRecording (which discards it).
func (m *Manager) StartRecording(deviceID string) error {
m.mu.Lock()
if m.recStop != nil {
m.mu.Unlock()
return fmt.Errorf("already recording")
}
stop := make(chan struct{})
done := make(chan recResult, 1)
m.recStop, m.recDone = stop, done
m.mu.Unlock() // release BEFORE notify — onChange re-enters via IsRecording()
go func() {
pcm, err := recordPCM(deviceID, stop)
done <- recResult{pcm, err}
}()
m.notify()
return nil
}
// StopRecording ends the capture and writes it to path as a WAV file.
func (m *Manager) StopRecording(path string) error {
m.mu.Lock()
stop, done := m.recStop, m.recDone
m.recStop, m.recDone = nil, nil
m.mu.Unlock()
if stop == nil {
return fmt.Errorf("not recording")
}
close(stop)
res := <-done
m.notify()
if res.err != nil {
return res.err
}
if len(res.pcm) == 0 {
return fmt.Errorf("captured no audio (check the recording device)")
}
return writeWAV(path, res.pcm)
}
// CancelRecording aborts a recording without saving.
func (m *Manager) CancelRecording() {
m.mu.Lock()
stop, done := m.recStop, m.recDone
m.recStop, m.recDone = nil, nil
m.mu.Unlock()
if stop != nil {
close(stop)
<-done
m.notify()
}
}
func (m *Manager) IsRecording() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.recStop != nil
}
func (m *Manager) IsPlaying() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.playStop != nil
}
// Play renders a WAV file to deviceID. Any current playback is stopped first.
// Returns immediately; playback runs in the background.
func (m *Manager) Play(deviceID, path string) error {
pcm, rate, ch, bits, err := readWAV(path)
if err != nil {
return err
}
m.StopPlayback()
stop := make(chan struct{})
m.mu.Lock()
m.playStop = stop
m.mu.Unlock()
go func() {
_ = playPCM(deviceID, pcm, rate, ch, bits, stop)
m.mu.Lock()
if m.playStop == stop {
m.playStop = nil
}
m.mu.Unlock()
m.notify()
}()
m.notify()
return nil
}
// StopPlayback halts any in-progress playback.
func (m *Manager) StopPlayback() {
m.mu.Lock()
stop := m.playStop
m.playStop = nil
m.mu.Unlock()
if stop != nil {
close(stop)
m.notify()
}
}
+54
View File
@@ -0,0 +1,54 @@
//go:build windows
package audio
import (
"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
}
enc := mp3.NewEncoder(mp3Rate, 2)
return enc.Write(f, stereo)
}
// upsample2 doubles the sample rate with linear interpolation (16 kHz → 32 kHz).
func upsample2(in []int16) []int16 {
if len(in) == 0 {
return in
}
out := make([]int16, len(in)*2)
for i := range in {
out[2*i] = in[i]
if i+1 < len(in) {
out[2*i+1] = int16((int32(in[i]) + int32(in[i+1])) / 2)
} else {
out[2*i+1] = in[i]
}
}
return out
}
+250
View File
@@ -0,0 +1,250 @@
//go:build windows
package audio
import (
"encoding/binary"
"fmt"
"strings"
"sync"
"time"
)
// Recorder continuously captures audio into a rolling pre-roll buffer so a QSO
// recording can begin a few seconds BEFORE the operator entered the callsign.
// It optionally mixes two sources (the rig RX "From Radio" + your mic) into a
// single mono track, so both sides of the contact are captured.
//
// Lifecycle: Start() runs capture+mix in the background. BeginQSO() snapshots
// the pre-roll and starts accumulating; SaveQSO() writes the WAV; DiscardQSO()
// drops it. Stop() tears down capture.
type Recorder struct {
mu sync.Mutex
stopCh chan struct{}
wg sync.WaitGroup
running bool
prerollSamples int
// Per-source sample queues (guarded by srcMu), drained by the mixer.
srcMu sync.Mutex
bufA []int16 // From Radio
bufB []int16 // mic
twoSrc bool
// Mixed output state (guarded by mu).
ring []int16 // last prerollSamples of mixed audio
active bool
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
}
func NewRecorder() *Recorder { return &Recorder{} }
func (r *Recorder) Running() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.running
}
func (r *Recorder) Active() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.active
}
// Start begins continuous capture from fromDev (required) mixed with micDev
// (optional — "" or same as fromDev → single source). prerollSec is how much
// audio to retain ahead of BeginQSO.
func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.mu.Lock()
if r.running {
r.mu.Unlock()
return nil
}
if prerollSec < 0 {
prerollSec = 0
}
r.prerollSamples = prerollSec * sampleRate
r.twoSrc = micDev != "" && micDev != fromDev
r.stopCh = make(chan struct{})
r.running = true
r.ring, r.acc, r.active, r.bufA, r.bufB = nil, nil, false, nil, nil
stop := r.stopCh
twoSrc := r.twoSrc
r.mu.Unlock()
// Capture goroutine(s) feed the per-source queues.
r.wg.Add(1)
go func() {
defer r.wg.Done()
_ = captureStream(fromDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
r.bufA = append(r.bufA, s...)
r.srcMu.Unlock()
})
}()
if twoSrc {
r.wg.Add(1)
go func() {
defer r.wg.Done()
_ = captureStream(micDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
r.bufB = append(r.bufB, s...)
r.srcMu.Unlock()
})
}()
}
// Mixer goroutine.
r.wg.Add(1)
go func() {
defer r.wg.Done()
t := time.NewTicker(40 * time.Millisecond)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
r.mixTick()
}
}
}()
return nil
}
// mixTick drains the source queues, mixes what's available, and appends to the
// ring + active accumulation.
func (r *Recorder) mixTick() {
r.srcMu.Lock()
var mixed []int16
if r.twoSrc {
n := len(r.bufA)
if len(r.bufB) < n {
n = len(r.bufB)
}
if n > 0 {
mixed = make([]int16, n)
for i := 0; i < n; i++ {
mixed[i] = clampSum(r.bufA[i], r.bufB[i])
}
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
}
// Drift guard: if the clocks diverge, drop the excess so the two
// sources stay roughly aligned (≤1 s skew).
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
} else if d < -sampleRate {
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
}
} else if len(r.bufA) > 0 {
mixed = make([]int16, len(r.bufA))
copy(mixed, r.bufA)
r.bufA = r.bufA[:0]
}
r.srcMu.Unlock()
if len(mixed) == 0 {
return
}
r.mu.Lock()
r.ring = append(r.ring, mixed...)
if len(r.ring) > r.prerollSamples {
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
}
if r.active {
r.acc = append(r.acc, mixed...)
}
r.mu.Unlock()
}
// BeginQSO starts accumulating a recording, seeded with the current pre-roll.
// No-op if already accumulating or not running.
func (r *Recorder) BeginQSO() {
r.mu.Lock()
defer r.mu.Unlock()
if !r.running || r.active {
return
}
r.acc = append([]int16(nil), r.ring...)
r.active = true
}
// SaveQSO writes the accumulated recording to path as a WAV and stops
// accumulating. Returns an error if no recording was active.
func (r *Recorder) SaveQSO(path string) error {
r.mu.Lock()
if !r.active {
r.mu.Unlock()
return fmt.Errorf("no active recording")
}
samples := r.acc
r.acc, r.active = nil, false
r.mu.Unlock()
if len(samples) == 0 {
return fmt.Errorf("recording was empty")
}
data := int16sToBytes(samples)
if strings.HasSuffix(strings.ToLower(path), ".mp3") {
return writeMP3(path, data)
}
return writeWAV(path, data)
}
// DiscardQSO drops the active accumulation without saving (callsign cleared).
func (r *Recorder) DiscardQSO() {
r.mu.Lock()
r.acc, r.active = nil, false
r.mu.Unlock()
}
// Stop tears down capture+mix.
func (r *Recorder) Stop() {
r.mu.Lock()
if !r.running {
r.mu.Unlock()
return
}
r.running = false
stop := r.stopCh
r.stopCh = nil
r.mu.Unlock()
close(stop)
r.wg.Wait()
r.mu.Lock()
r.ring, r.acc, r.active = nil, nil, false
r.mu.Unlock()
r.srcMu.Lock()
r.bufA, r.bufB = nil, nil
r.srcMu.Unlock()
}
func clampSum(a, b int16) int16 {
v := int32(a) + int32(b)
if v > 32767 {
return 32767
}
if v < -32768 {
return -32768
}
return int16(v)
}
func bytesToInt16(b []byte) []int16 {
out := make([]int16, len(b)/2)
for i := range out {
out[i] = int16(binary.LittleEndian.Uint16(b[i*2:]))
}
return out
}
func int16sToBytes(s []int16) []byte {
b := make([]byte, len(s)*2)
for i, v := range s {
binary.LittleEndian.PutUint16(b[i*2:], uint16(v))
}
return b
}
+86
View File
@@ -0,0 +1,86 @@
//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
}
+8
View File
@@ -27,6 +27,9 @@ type Backend interface {
// Implementations decide USB vs LSB (typically by current freq) and
// generic vs specific digital modes (most rigs just have DATA).
SetMode(mode string) error
// SetPTT keys (on=true) or unkeys the transmitter. Used by the Digital
// Voice Keyer to put the rig into TX while a message plays.
SetPTT(on bool) error
}
// RigState is the snapshot exchanged with the frontend.
@@ -161,6 +164,11 @@ func (m *Manager) SetMode(mode string) error {
return m.exec(func(b Backend) error { return b.SetMode(mode) })
}
// SetPTT dispatches a transmit on/off request to the CAT goroutine.
func (m *Manager) SetPTT(on bool) error {
return m.exec(func(b Backend) error { return b.SetPTT(on) })
}
// exec marshals a backend operation onto the CAT goroutine. Returns the
// operation's error or a "busy"/"not running" error if dispatch failed.
func (m *Manager) exec(fn func(Backend) error) error {
+39
View File
@@ -319,6 +319,43 @@ func (o *OmniRig) SetMode(mode string) error {
return nil
}
// SetPTT keys or unkeys the rig via OmniRig's SetTx(PM_RX|PM_TX). Used by the
// Digital Voice Keyer to put the rig into TX while a voice message plays.
func (o *OmniRig) SetPTT(on bool) error {
if o.rig == nil {
debugLog.Printf("OmniRig.SetPTT(%v): NOT CONNECTED", on)
return fmt.Errorf("not connected")
}
status, statusStr, writeable := int64(-1), "", int64(-1)
if v, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
status = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
statusStr = v.ToString()
}
if v, err := oleutil.GetProperty(o.rig, "WriteableParams"); err == nil {
writeable = v.Val
}
txWriteable := writeable != -1 && writeable&pmTX != 0
param, name := pmRX, "PM_RX"
if on {
param, name = pmTX, "PM_TX"
}
debugLog.Printf("OmniRig.SetPTT(%v): status=%d(%s) writeableParams=0x%X PM_TX-writeable=%v → Tx=%s",
on, status, statusStr, writeable, txWriteable, name)
if on && !txWriteable {
debugLog.Printf("OmniRig.SetPTT: ⚠ this rig's OmniRig .ini does NOT expose TX keying (PM_TX not writeable). " +
"Use VOX or serial RTS/DTR PTT instead.")
}
// OmniRig has NO SetTx method (that returns "unknown name"); the Tx
// parameter is set via the writeable Tx PROPERTY (PM_TX / PM_RX).
if _, err := oleutil.PutProperty(o.rig, "Tx", int32(param)); err != nil {
debugLog.Printf("OmniRig.SetPTT error: %v", err)
return fmt.Errorf("set Tx=%s: %w", name, err)
}
return nil
}
// ===== OmniRig enum decoders =====
// Bit flags from OmniRig type library (RigParamX enum in OmniRig_TLB.pas).
@@ -328,6 +365,8 @@ func (o *OmniRig) SetMode(mode string) error {
// too low which causes every mode to map to the slot below it (AM → DIG_L,
// FT8 → SSB_L, etc.).
const (
pmRX int64 = 1 << 20 // 0x00100000 — PM_RX (receive)
pmTX int64 = 1 << 21 // 0x00200000 — PM_TX (transmit / PTT on)
pmCWU int64 = 1 << 23 // 0x00800000
pmCWL int64 = 1 << 24 // 0x01000000
pmSSBU int64 = 1 << 25 // 0x02000000
+173
View File
@@ -0,0 +1,173 @@
// Package clublog uses the ClubLog Country File (cty.xml) to resolve callsign
// EXCEPTIONS — date-ranged full-callsign overrides for DXpeditions and special
// operations that prefix-based files (cty.dat) get wrong. e.g. VK2/SP9FIH was
// Lord Howe Island (not Australia) between specific 2025 dates.
package clublog
import (
"compress/gzip"
"encoding/xml"
"fmt"
"io"
"strings"
"time"
)
// Exception is one date-ranged full-callsign override.
type Exception struct {
Call string
Entity string
ADIF int
CQZ int
Cont string
Lat float64
Lon float64
Start time.Time // zero = no lower bound
End time.Time // zero = no upper bound
}
func (e Exception) covers(t time.Time) bool {
if !e.Start.IsZero() && t.Before(e.Start) {
return false
}
if !e.End.IsZero() && t.After(e.End) {
return false
}
return true
}
// DB holds the parsed exception list, keyed by upper-cased callsign.
type DB struct {
exceptions map[string][]Exception
date string // cty.xml generation date (for the UI)
count int
}
// Count returns how many exceptions were loaded.
func (db *DB) Count() int { return db.count }
// Date returns the cty.xml generation timestamp.
func (db *DB) Date() string { return db.date }
// xml decode shapes.
type xlException struct {
Call string `xml:"call"`
Entity string `xml:"entity"`
ADIF int `xml:"adif"`
CQZ int `xml:"cqz"`
Cont string `xml:"cont"`
Long string `xml:"long"`
Lat string `xml:"lat"`
Start string `xml:"start"`
End string `xml:"end"`
}
// LoadGzip parses a gzipped ClubLog cty.xml stream.
func LoadGzip(r io.Reader) (*DB, error) {
zr, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("gunzip: %w", err)
}
defer zr.Close()
return Load(zr)
}
// Load parses a (plain) ClubLog cty.xml stream, extracting only the
// <exceptions> section via a streaming decoder (the file is ~10 MB).
func Load(r io.Reader) (*DB, error) {
db := &DB{exceptions: map[string][]Exception{}}
dec := xml.NewDecoder(r)
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("xml: %w", err)
}
se, ok := tok.(xml.StartElement)
if !ok {
continue
}
switch se.Name.Local {
case "clublog":
for _, a := range se.Attr {
if a.Name.Local == "date" {
db.date = a.Value
}
}
case "exception":
var x xlException
if err := dec.DecodeElement(&x, &se); err != nil {
continue
}
call := strings.ToUpper(strings.TrimSpace(x.Call))
if call == "" || x.ADIF == 0 {
continue
}
e := Exception{
Call: call, Entity: x.Entity, ADIF: x.ADIF, CQZ: x.CQZ,
Cont: strings.ToUpper(strings.TrimSpace(x.Cont)),
Lat: parseFloat(x.Lat), Lon: parseFloat(x.Long),
Start: parseTime(x.Start), End: parseTime(x.End),
}
db.exceptions[call] = append(db.exceptions[call], e)
db.count++
}
}
return db, nil
}
// Resolve returns the exception for a callsign valid at the given date, if any.
// It tries the call as-is, then with a trailing "/x" affix stripped (so
// VK2/SP9FIH/P still matches the VK2/SP9FIH exception).
func (db *DB) Resolve(call string, date time.Time) (Exception, bool) {
if db == nil {
return Exception{}, false
}
c := strings.ToUpper(strings.TrimSpace(call))
for _, key := range candidates(c) {
for _, e := range db.exceptions[key] {
if e.covers(date) {
return e, true
}
}
}
return Exception{}, false
}
// candidates yields the call and a version with one trailing affix removed.
func candidates(c string) []string {
out := []string{c}
if i := strings.LastIndex(c, "/"); i > 0 {
suffix := c[i+1:]
// Only strip short operational affixes, not a real prefix override
// (e.g. keep "VK2/SP9FIH" intact; strip "VK2/SP9FIH/P").
switch suffix {
case "P", "M", "MM", "AM", "QRP", "A":
out = append(out, c[:i])
}
}
return out
}
func parseTime(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
return time.Time{}
}
func parseFloat(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
var f float64
fmt.Sscanf(s, "%g", &f)
return f
}
+127
View File
@@ -0,0 +1,127 @@
package clublog
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)
// ctyURL is the ClubLog Country File endpoint. It returns a gzipped cty.xml.
const ctyURL = "https://cdn.clublog.org/cty.php?api="
// Manager owns the on-disk cty.xml.gz cache and the parsed exception DB.
type Manager struct {
apiKey string
cacheDir string
mu sync.RWMutex
db *DB
}
func NewManager(apiKey, cacheDir string) *Manager {
return &Manager{apiKey: apiKey, cacheDir: cacheDir}
}
// Path is where the cached gzipped country file lives.
func (m *Manager) Path() string {
return filepath.Join(m.cacheDir, "clublog_cty.xml.gz")
}
// Loaded reports whether an exception DB is in memory.
func (m *Manager) Loaded() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.db != nil
}
// Info returns the loaded file's generation date + exception count (zeros when
// not loaded).
func (m *Manager) Info() (date string, count int) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.db == nil {
return "", 0
}
return m.db.Date(), m.db.Count()
}
// EnsureLoaded loads the cached file into memory if present. Does NOT download.
func (m *Manager) EnsureLoaded() error {
if m.Loaded() {
return nil
}
return m.LoadFromDisk()
}
// LoadFromDisk parses the cached cty.xml.gz and swaps it in.
func (m *Manager) LoadFromDisk() error {
f, err := os.Open(m.Path())
if err != nil {
return err
}
defer f.Close()
db, err := LoadGzip(f)
if err != nil {
return err
}
m.mu.Lock()
m.db = db
m.mu.Unlock()
return nil
}
// Download fetches a fresh cty.xml.gz from ClubLog and writes it atomically,
// then loads it.
func (m *Manager) Download(ctx context.Context) error {
if m.apiKey == "" {
return fmt.Errorf("clublog api key not set")
}
if err := os.MkdirAll(m.cacheDir, 0o755); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "GET", ctyURL+m.apiKey, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("clublog HTTP %d", resp.StatusCode)
}
tmp, err := os.CreateTemp(m.cacheDir, "clublog-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := io.Copy(tmp, resp.Body); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
tmp.Close()
if err := os.Rename(tmpName, m.Path()); err != nil {
os.Remove(tmpName)
return err
}
return m.LoadFromDisk()
}
// Resolve returns the matching exception for a callsign at a date, if loaded.
func (m *Manager) Resolve(call string, date time.Time) (Exception, bool) {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
if db == nil {
return Exception{}, false
}
return db.Resolve(call, date)
}
+68 -317
View File
@@ -2,332 +2,83 @@ package dxcc
import "strings"
// dxccByName maps cty.dat entity names to ADIF DXCC entity numbers
// (ARRL DXCC List). cty.dat itself doesn't carry this number — it's a
// separate ARRL-maintained list. We embed the current entities here so
// QSO records can be stamped with MY_DXCC / DXCC at log time without a
// network round-trip.
//
// Coverage: every current ARRL DXCC entity (340+). Deleted entities are
// included for legacy compatibility. The lookup is case-insensitive and
// space-tolerant on the caller side.
var dxccByName = map[string]int{
// 0xx
"sovereign military order of malta": 246,
"spratly is.": 247,
"sable i.": 211,
"st. paul i.": 252,
"hawaii": 110,
"agalega & st. brandon is.": 4,
"alaska": 6,
"american samoa": 9,
"amsterdam & st. paul is.": 10,
"andaman & nicobar is.": 11,
"anguilla": 12,
"antarctica": 13,
"armenia": 14,
"asiatic russia": 15,
"aves i.": 17,
"azerbaijan": 18,
"baker & howland is.": 20,
"balearic is.": 21,
"palmyra & jarvis is.": 22,
"central kiribati": 31,
"central african republic": 27,
"cape verde": 32,
"chagos is.": 33,
"chatham is.": 34,
"christmas i.": 35,
"clipperton i.": 36,
"cocos i.": 37,
"cocos (keeling) is.": 38,
"comoros": 39,
"crete": 40,
"crozet i.": 41,
"falkland is.": 141,
"chesterfield is.": 512,
"easter i.": 47,
"sint eustatius & saba": 519,
"ducie i.": 513,
"european russia": 54,
"farquhar": 55,
"fernando de noronha": 56,
"french equatorial africa": 57,
"french indo-china": 58,
"french polynesia": 175,
"djibouti": 382,
"gabon": 420,
"galapagos is.": 71,
"guantanamo bay": 105,
"guatemala": 76,
"guernsey": 106,
"guinea": 107,
"guyana": 129,
"hong kong": 321,
"howland & baker is.": 20,
"isle of man": 114,
"itu hq": 117,
"iran": 330,
"iraq": 333,
"juan de nova & europa": 124,
"juan fernandez is.": 125,
"kaliningrad": 126,
"kerguelen is.": 131,
"kermadec is.": 133,
"kingman reef": 134,
"kuwait": 348,
"kyrgyzstan": 135,
"jersey": 122,
"laccadive is.": 142,
"laos": 143,
"lord howe i.": 147,
"market reef": 151,
"marquesas is.": 509,
"marshall is.": 168,
"mauritania": 444,
"mayotte": 169,
"mexico": 50,
"midway i.": 174,
"minami torishima": 177,
"monaco": 260,
"mongolia": 363,
"mount athos": 180,
"navassa i.": 182,
"new caledonia": 162,
"new zealand": 170,
"niue": 188,
"norfolk i.": 189,
"north cook is.": 191,
"north korea": 344,
"ogasawara": 192,
"oman": 370,
"palestine": 510,
"pratas i.": 505,
"qatar": 376,
"rotuma i.": 460,
"rwanda": 454,
"san andres & providencia": 216,
"south georgia i.": 235,
"south orkney is.": 238,
"south sandwich is.": 240,
"south shetland is.": 241,
"swains i.": 515,
"swaziland": 468,
"taiwan": 386,
"tajikistan": 262,
"thailand": 387,
"timor-leste": 511,
"tokelau is.": 270,
"tonga": 160,
"trindade & martim vaz is.": 273,
"tristan da cunha & gough is.": 274,
"tromelin i.": 276,
"tunisia": 474,
"turkmenistan": 280,
"turks & caicos is.": 89,
"tuvalu": 282,
"uk sov. base areas on cyprus": 283,
"united nations hq": 289,
"vatican city": 295,
"venezuela": 148,
"viet nam": 293,
"wake i.": 297,
"wallis & futuna is.": 298,
"western kiribati": 301,
"yemen": 492,
// The dxccByName table itself lives in dxcc_names_gen.go (generated by joining
// cty.dat to the authoritative ARRL/ADIF entity list). cty.dat doesn't carry
// the ADIF DXCC number, so we map its entity names → numbers here to stamp
// MY_DXCC / DXCC at log time without a network round-trip.
// Major populous entities
"france": 227,
"germany": 230,
"belgium": 209,
"netherlands": 263,
"luxembourg": 254,
"switzerland": 287,
"liechtenstein": 251,
"austria": 206,
"italy": 248,
// Sicily (IT9) and African Italy (IG9/IH9) are cty.dat WAE splits, not
// DXCC entities — they count as Italy (248). Sardinia (IS0) IS its own
// DXCC entity (225) and keeps its number.
"sicily": 248,
"african italy": 248,
"sardinia": 225,
"spain": 281,
"portugal": 272,
"andorra": 203,
"san marino": 278,
"corsica": 214,
"vatican": 295,
"england": 223,
"scotland": 279,
"wales": 294,
"northern ireland": 265,
"ireland": 245,
"shetland is.": 279,
"poland": 269,
"czech republic": 503,
"slovak republic": 504,
"hungary": 239,
"romania": 275,
"bulgaria": 212,
"greece": 236,
"dodecanese": 45,
"turkey": 390,
"european turkey": 390,
"asiatic turkey": 390,
"cyprus": 215,
"malta": 257,
"denmark": 221,
"faroe is.": 222,
"greenland": 237,
"sweden": 284,
"norway": 266,
"finland": 224,
"aland is.": 5,
"iceland": 242,
"estonia": 52,
"latvia": 145,
"lithuania": 146,
"belarus": 27,
"ukraine": 288,
"moldova": 179,
"georgia": 75,
"serbia": 296,
"montenegro": 514,
"slovenia": 499,
"croatia": 497,
"bosnia-herzegovina": 501,
"macedonia": 502,
"kosovo": 522,
"albania": 7,
"israel": 336,
"jordan": 342,
"lebanon": 354,
"syria": 384,
"saudi arabia": 378,
"united arab emirates": 391,
"bahrain": 304,
"egypt": 478,
"libya": 436,
"algeria": 400,
"morocco": 446,
"western sahara": 302,
"south africa": 462,
"namibia": 464,
"botswana": 402,
"zimbabwe": 452,
"zambia": 482,
"mozambique": 181,
"madagascar": 438,
"mauritius": 165,
"reunion i.": 453,
"seychelles": 379,
"kenya": 430,
"tanzania": 470,
"uganda": 286,
"ethiopia": 53,
"eritrea": 51,
"sudan": 466,
"south sudan republic of": 521,
"nigeria": 450,
"ghana": 424,
"cameroon": 406,
"senegal": 456,
"liberia": 434,
"sierra leone": 458,
"benin": 416,
"togo": 483,
"ivory coast": 428,
"mali": 442,
"niger": 187,
"chad": 410,
"japan": 339,
"south korea": 137,
"china": 318,
"india": 324,
"pakistan": 372,
"sri lanka": 315,
"nepal": 369,
"bangladesh": 305,
"bhutan": 306,
"myanmar": 309,
"west malaysia": 299,
"east malaysia": 46,
"singapore": 381,
"indonesia": 327,
"philippines": 375,
"brunei darussalam": 345,
"cambodia": 312,
"kazakhstan": 130,
"uzbekistan": 292,
"afghanistan": 3,
"maldives": 159,
"australia": 150,
"tasmania": 150,
"papua new guinea": 163,
"solomon is.": 185,
"vanuatu": 158,
"fiji": 176,
"samoa": 190,
"canada": 1,
"united states": 291,
"united states of america": 291,
"puerto rico": 202,
"us virgin is.": 285,
"british virgin is.": 91,
"cayman is.": 69,
"jamaica": 82,
"bahamas": 60,
"bermuda": 64,
"haiti": 78,
"dominican republic": 72,
"cuba": 70,
"barbados": 62,
"trinidad & tobago": 90,
"grenada": 77,
"st. lucia": 97,
"st. vincent": 98,
"dominica": 95,
"montserrat": 96,
"st. kitts & nevis": 249,
"antigua & barbuda": 94,
"guadeloupe": 79,
"martinique": 84,
"french guiana": 63,
"suriname": 140,
"colombia": 116,
"ecuador": 120,
"peru": 136,
"bolivia": 104,
"chile": 112,
"argentina": 100,
"uruguay": 144,
"paraguay": 132,
"brazil": 108,
"belize": 66,
"honduras": 80,
"el salvador": 74,
"nicaragua": 86,
"costa rica": 308,
"panama": 88,
}
// EntityDXCC returns the ADIF DXCC entity number for the given cty.dat
// entity name. Returns 0 when the name isn't in our table — callers
// should leave the field empty in that case rather than guess. The match
// is case-insensitive and tolerant of leading/trailing whitespace.
// EntityDXCC returns the ADIF DXCC entity number for the given cty.dat entity
// name, or 0 when unknown (callers should then leave the field empty rather
// than guess). Case-insensitive and whitespace-tolerant.
func EntityDXCC(name string) int {
if name == "" {
return 0
}
return dxccByName[strings.ToLower(strings.TrimSpace(name))]
// Fast path: exact (lower-cased) match against the cty.dat names.
if n := dxccByName[strings.ToLower(strings.TrimSpace(name))]; n != 0 {
return n
}
// Fallback: canonicalise so abbreviation/spelling differences still match
// (e.g. an ADIF import that wrote "Lord Howe I." instead of cty.dat's
// "Lord Howe Island").
if n := dxccByCanon[canonEntity(name)]; n != 0 {
return n
}
// Last resort: cty.dat pseudo-entities (Sicily, African Italy) report a
// parent DXCC entity for the number.
if c := CanonicalEntityName(name); !strings.EqualFold(c, name) {
return dxccByName[strings.ToLower(strings.TrimSpace(c))]
}
return 0
}
// dxccByCanon is dxccByName re-keyed by the canonical entity form, built once.
var dxccByCanon = func() map[string]int {
m := make(map[string]int, len(dxccByName))
for name, num := range dxccByName {
m[canonEntity(name)] = num
}
return m
}()
// canonEntity reduces an entity name to a canonical token stream, expanding the
// common abbreviations that differ between naming conventions and normalising
// punctuation / "&".
func canonEntity(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "&", " and ")
fields := strings.FieldsFunc(s, func(r rune) bool {
switch r {
case ' ', '.', ',', '-', '\'', '(', ')', '/':
return true
}
return false
})
for i, w := range fields {
switch w {
case "i":
fields[i] = "island"
case "is":
fields[i] = "islands"
case "st":
fields[i] = "saint"
case "mt":
fields[i] = "mount"
case "rep":
fields[i] = "republic"
case "dem":
fields[i] = "democratic"
case "fed":
fields[i] = "federal"
}
}
return strings.Join(fields, " ")
}
// ctyEntityAliases maps cty.dat's non-DXCC pseudo-entities — the CQ-zone /
// WAE splits AD1C lists separately — onto the ARRL DXCC entity they belong
// to. cty.dat reports e.g. "Sicily" or "Sardinia" so contesters get the
// right zones, but for DXCC (and the COUNTRY field) they are Italy. Add an
// entry here for any other split that should report its parent entity.
// to. cty.dat reports e.g. "Sicily" so contesters get the right zones, but
// for DXCC (and the COUNTRY field) they are Italy.
var ctyEntityAliases = map[string]string{
"sicily": "Italy",
"african italy": "Italy",
+351
View File
@@ -0,0 +1,351 @@
package dxcc
// dxccByName maps cty.dat entity names (lower-cased) to ADIF DXCC entity
// numbers. Generated by joining cty.dat to the authoritative ARRL/ADIF entity
// list (k0swe/dxcc-json) by primary prefix + canonical name. 344 entities.
var dxccByName = map[string]int{
"afghanistan": 3,
"agalega & st. brandon": 4,
"aland islands": 5,
"alaska": 6,
"albania": 7,
"algeria": 400,
"american samoa": 9,
"amsterdam & st. paul is.": 10,
"andaman & nicobar is.": 11,
"andorra": 203,
"angola": 401,
"anguilla": 12,
"annobon island": 195,
"antarctica": 13,
"antigua & barbuda": 94,
"argentina": 100,
"armenia": 14,
"aruba": 91,
"ascension island": 205,
"asiatic russia": 15,
"asiatic turkey": 390,
"austral islands": 508,
"australia": 150,
"austria": 206,
"aves island": 17,
"azerbaijan": 18,
"azores": 149,
"bahamas": 60,
"bahrain": 304,
"baker & howland islands": 20,
"balearic islands": 21,
"banaba island": 490,
"bangladesh": 305,
"barbados": 62,
"bear island": 259,
"belarus": 27,
"belgium": 209,
"belize": 66,
"benin": 416,
"bermuda": 64,
"bhutan": 306,
"bolivia": 104,
"bonaire": 520,
"bosnia-herzegovina": 501,
"botswana": 402,
"bouvet": 24,
"brazil": 108,
"british virgin islands": 65,
"brunei darussalam": 345,
"bulgaria": 212,
"burkina faso": 480,
"burundi": 404,
"cambodia": 312,
"cameroon": 406,
"canada": 1,
"canary islands": 29,
"cape verde": 409,
"cayman islands": 69,
"central african republic": 408,
"central kiribati": 31,
"ceuta & melilla": 32,
"chad": 410,
"chagos islands": 33,
"chatham islands": 34,
"chesterfield islands": 512,
"chile": 112,
"china": 318,
"christmas island": 35,
"clipperton island": 36,
"cocos (keeling) islands": 38,
"cocos island": 37,
"colombia": 116,
"comoros": 411,
"conway reef": 489,
"corsica": 214,
"costa rica": 308,
"cote d'ivoire": 428,
"crete": 40,
"croatia": 497,
"crozet island": 41,
"cuba": 70,
"curacao": 517,
"cyprus": 215,
"czech republic": 503,
"dem. rep. of the congo": 414,
"denmark": 221,
"desecheo island": 43,
"djibouti": 382,
"dodecanese": 45,
"dominica": 95,
"dominican republic": 72,
"dpr of korea": 344,
"ducie island": 513,
"east malaysia": 46,
"easter island": 47,
"eastern kiribati": 48,
"ecuador": 120,
"egypt": 478,
"el salvador": 74,
"england": 223,
"equatorial guinea": 49,
"eritrea": 51,
"estonia": 52,
"ethiopia": 53,
"european russia": 54,
"european turkey": 390,
"falkland islands": 141,
"faroe islands": 222,
"fed. rep. of germany": 230,
"fernando de noronha": 56,
"fiji": 176,
"finland": 224,
"france": 227,
"franz josef land": 61,
"french guiana": 63,
"french polynesia": 175,
"gabon": 420,
"galapagos islands": 71,
"georgia": 75,
"ghana": 424,
"gibraltar": 233,
"glorioso islands": 99,
"greece": 236,
"greenland": 237,
"grenada": 77,
"guadeloupe": 79,
"guam": 103,
"guantanamo bay": 105,
"guatemala": 76,
"guernsey": 106,
"guinea": 107,
"guinea-bissau": 109,
"guyana": 129,
"haiti": 78,
"hawaii": 110,
"heard island": 111,
"honduras": 80,
"hong kong": 321,
"hungary": 239,
"iceland": 242,
"india": 324,
"indonesia": 327,
"iran": 330,
"iraq": 333,
"ireland": 245,
"isle of man": 114,
"israel": 336,
"italy": 248,
"itu hq": 117,
"jamaica": 82,
"jan mayen": 118,
"japan": 339,
"jersey": 122,
"johnston island": 123,
"jordan": 342,
"juan de nova, europa": 124,
"juan fernandez islands": 125,
"kaliningrad": 126,
"kazakhstan": 130,
"kenya": 430,
"kerguelen islands": 131,
"kermadec islands": 133,
"kingdom of eswatini": 468,
"kure island": 138,
"kuwait": 348,
"kyrgyzstan": 135,
"lakshadweep islands": 142,
"laos": 143,
"latvia": 145,
"lebanon": 354,
"lesotho": 432,
"liberia": 434,
"libya": 436,
"liechtenstein": 251,
"lithuania": 146,
"lord howe island": 147,
"luxembourg": 254,
"macao": 152,
"macquarie island": 153,
"madagascar": 438,
"madeira islands": 256,
"malawi": 440,
"maldives": 159,
"mali": 442,
"malpelo island": 161,
"malta": 257,
"mariana islands": 166,
"market reef": 167,
"marquesas islands": 509,
"marshall islands": 168,
"martinique": 84,
"mauritania": 444,
"mauritius": 165,
"mayotte": 169,
"mellish reef": 171,
"mexico": 50,
"micronesia": 173,
"midway island": 174,
"minami torishima": 177,
"moldova": 179,
"monaco": 260,
"mongolia": 363,
"montenegro": 514,
"montserrat": 96,
"morocco": 446,
"mount athos": 180,
"mozambique": 181,
"myanmar": 309,
"n.z. subantarctic is.": 16,
"namibia": 464,
"nauru": 157,
"navassa island": 182,
"nepal": 369,
"netherlands": 263,
"new caledonia": 162,
"new zealand": 170,
"nicaragua": 86,
"niger": 187,
"nigeria": 450,
"niue": 188,
"norfolk island": 189,
"north cook islands": 191,
"north macedonia": 502,
"northern ireland": 265,
"norway": 266,
"ogasawara": 192,
"oman": 370,
"pakistan": 372,
"palau": 22,
"palestine": 510,
"palmyra & jarvis islands": 197,
"panama": 88,
"papua new guinea": 163,
"paraguay": 132,
"peru": 136,
"peter 1 island": 199,
"philippines": 375,
"pitcairn island": 172,
"poland": 269,
"portugal": 272,
"pr. edward & marion is.": 201,
"pratas island": 505,
"puerto rico": 202,
"qatar": 376,
"republic of korea": 137,
"republic of kosovo": 522,
"republic of south sudan": 521,
"republic of the congo": 412,
"reunion island": 453,
"revillagigedo": 204,
"rodriguez island": 207,
"romania": 275,
"rotuma island": 460,
"rwanda": 454,
"saba & st. eustatius": 519,
"sable island": 211,
"samoa": 190,
"san andres & providencia": 216,
"san felix & san ambrosio": 217,
"san marino": 278,
"sao tome & principe": 219,
"sardinia": 225,
"saudi arabia": 378,
"scarborough reef": 506,
"scotland": 279,
"senegal": 456,
"serbia": 296,
"seychelles": 379,
"shetland islands": 279,
"sierra leone": 458,
"singapore": 381,
"sint maarten": 518,
"slovak republic": 504,
"slovenia": 499,
"solomon islands": 185,
"somalia": 232,
"south africa": 462,
"south cook islands": 234,
"south georgia island": 235,
"south orkney islands": 238,
"south sandwich islands": 240,
"south shetland islands": 241,
"sov mil order of malta": 246,
"spain": 281,
"spratly islands": 247,
"sri lanka": 315,
"st. barthelemy": 516,
"st. helena": 250,
"st. kitts & nevis": 249,
"st. lucia": 97,
"st. martin": 213,
"st. paul island": 252,
"st. peter & st. paul": 253,
"st. pierre & miquelon": 277,
"st. vincent": 98,
"sudan": 466,
"suriname": 140,
"svalbard": 259,
"swains island": 515,
"sweden": 284,
"switzerland": 287,
"syria": 384,
"taiwan": 386,
"tajikistan": 262,
"tanzania": 470,
"temotu province": 507,
"thailand": 387,
"the gambia": 422,
"timor - leste": 511,
"togo": 483,
"tokelau islands": 270,
"tonga": 160,
"trindade & martim vaz": 273,
"trinidad & tobago": 90,
"tristan da cunha & gough": 274,
"tromelin island": 276,
"tunisia": 474,
"turkmenistan": 280,
"turks & caicos islands": 89,
"tuvalu": 282,
"uganda": 286,
"uk base areas on cyprus": 283,
"ukraine": 288,
"united arab emirates": 391,
"united nations hq": 289,
"united states": 291,
"uruguay": 144,
"us virgin islands": 285,
"uzbekistan": 292,
"vanuatu": 158,
"vatican city": 295,
"venezuela": 148,
"vienna intl ctr": 206,
"vietnam": 293,
"wake island": 297,
"wales": 294,
"wallis & futuna islands": 298,
"west malaysia": 155,
"western kiribati": 301,
"western sahara": 302,
"willis island": 303,
"yemen": 492,
"zambia": 482,
"zimbabwe": 452,
}