feat: added record qso dvk
This commit is contained in:
@@ -12,12 +12,15 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/adif"
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/backup"
|
||||
"hamlog/internal/audio"
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/clublog"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/extsvc"
|
||||
@@ -32,6 +35,7 @@ import (
|
||||
"hamlog/internal/settings"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// Setting keys.
|
||||
@@ -68,6 +72,26 @@ const (
|
||||
keyCATDelayMs = "cat.delay_ms" // pause between commands
|
||||
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
||||
|
||||
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
|
||||
// global (not per-profile) like CAT/rotator. Device fields store the
|
||||
// WASAPI endpoint id; the UI resolves it to a friendly name.
|
||||
keyAudioFromRadio = "audio.from_radio" // capture: rig RX audio in
|
||||
keyAudioToRadio = "audio.to_radio" // render: DVK plays into rig
|
||||
keyAudioRecDevice = "audio.rec_device" // capture: your mic (record DVK msgs)
|
||||
keyAudioListenDevice = "audio.listen_device" // render: local preview speakers
|
||||
keyAudioQSORecord = "audio.qso_record" // "1" → auto-record every QSO
|
||||
keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings
|
||||
keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length
|
||||
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
|
||||
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
|
||||
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
|
||||
|
||||
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
|
||||
|
||||
// clublogAppAPIKey is OpsLog's ClubLog API key, also used for the country
|
||||
// file download. Visible in the binary but must not be exposed publicly.
|
||||
clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"
|
||||
|
||||
keyRotatorEnabled = "rotator.enabled"
|
||||
keyRotatorHost = "rotator.host"
|
||||
keyRotatorPort = "rotator.port"
|
||||
@@ -289,6 +313,14 @@ type App struct {
|
||||
udpRepo *udp.Repo
|
||||
extsvc *extsvc.Manager
|
||||
winkeyer *winkeyer.Manager
|
||||
clublog *clublog.Manager
|
||||
audioMgr *audio.Manager
|
||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||
pttMu sync.Mutex
|
||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
dbPath string // active database file (may be a user-chosen location)
|
||||
dataDir string // %APPDATA%/OpsLog — holds config.json, logs, cty.dat
|
||||
@@ -495,6 +527,16 @@ func (a *App) startup(ctx context.Context) {
|
||||
}
|
||||
fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities")
|
||||
}()
|
||||
// ClubLog Country File (cty.xml) — date-ranged callsign exceptions that
|
||||
// cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on
|
||||
// demand. Resolution applied only when the user enables it.
|
||||
a.clublog = clublog.NewManager(clublogAppAPIKey, dataDir)
|
||||
go func() {
|
||||
if err := a.clublog.EnsureLoaded(); err == nil {
|
||||
d, n := a.clublog.Info()
|
||||
fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d)
|
||||
}
|
||||
}()
|
||||
// CAT manager: emit pushes state to the frontend via Wails events.
|
||||
a.cat = cat.NewManager(func(s cat.RigState) {
|
||||
if a.ctx != nil {
|
||||
@@ -570,6 +612,21 @@ func (a *App) startup(ctx context.Context) {
|
||||
},
|
||||
)
|
||||
|
||||
// Digital Voice Keyer + QSO recorder (WASAPI). Idle until used.
|
||||
a.audioMgr = audio.NewManager(func() {
|
||||
st := a.dvkStatus()
|
||||
// When a voice message finishes (or is stopped), drop CAT PTT.
|
||||
if !st.Playing && a.dvkPttKeyed {
|
||||
a.dvkPttKeyed = false
|
||||
go a.dvkUnkeyPTT()
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "audio:status", st)
|
||||
}
|
||||
})
|
||||
a.qsoRec = audio.NewRecorder()
|
||||
a.startQSORecorderIfEnabled()
|
||||
|
||||
fmt.Println("OpsLog: db ready at", a.dbPath)
|
||||
}
|
||||
|
||||
@@ -721,6 +778,9 @@ func (a *App) shutdown(ctx context.Context) {
|
||||
if a.winkeyer != nil {
|
||||
a.winkeyer.Disconnect()
|
||||
}
|
||||
if a.qsoRec != nil {
|
||||
a.qsoRec.Stop()
|
||||
}
|
||||
if a.db != nil {
|
||||
_ = a.db.Close()
|
||||
}
|
||||
@@ -971,10 +1031,14 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) {
|
||||
}
|
||||
a.applyStationDefaults(&q)
|
||||
a.applyDXCCNumber(&q)
|
||||
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
|
||||
a.applyQSLDefaults(&q)
|
||||
id, err := a.qso.Add(a.ctx, q)
|
||||
if err == nil && a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
if err == nil {
|
||||
a.saveQSORecording(q.Callsign)
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
@@ -1309,8 +1373,17 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.Impor
|
||||
default: // "skip"
|
||||
im.SkipDuplicates = true
|
||||
}
|
||||
// When the user opts to fix countries on import, recompute from cty.dat and
|
||||
// then apply ClubLog's date-ranged exceptions (which take precedence) if
|
||||
// ClubLog is enabled + loaded. Unchecked = ADIF preserved verbatim.
|
||||
clEnabled := a.clublogCtyEnabled() && a.clublog != nil && a.clublog.Loaded()
|
||||
if applyCty {
|
||||
im.Enrich = func(q *qso.QSO) { a.enrichContactedFromCtyForce(q) }
|
||||
im.Enrich = func(q *qso.QSO) {
|
||||
a.enrichContactedFromCtyForce(q)
|
||||
if clEnabled {
|
||||
a.applyClublogException(q, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
im.OnProgress = func(processed, total int) {
|
||||
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
|
||||
@@ -1369,6 +1442,25 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||||
r.ImageURL = ""
|
||||
}
|
||||
}
|
||||
// ClubLog exception override (live entry → today's date): for an active
|
||||
// DXpedition the entered call gets the right entity/zones immediately.
|
||||
if a.clublogCtyEnabled() && a.clublog != nil {
|
||||
if e, ok := a.clublog.Resolve(callsign, time.Now().UTC()); ok {
|
||||
r.Country = titleEntity(e.Entity)
|
||||
if e.Cont != "" {
|
||||
r.Continent = e.Cont
|
||||
}
|
||||
if e.ADIF != 0 {
|
||||
r.DXCC = e.ADIF
|
||||
}
|
||||
if e.CQZ != 0 {
|
||||
r.CQZ = e.CQZ
|
||||
}
|
||||
if r.Callsign == "" {
|
||||
r.Callsign = strings.ToUpper(strings.TrimSpace(callsign))
|
||||
}
|
||||
}
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
@@ -1560,6 +1652,596 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Audio (Digital Voice Keyer + QSO recorder) ────────────────────────
|
||||
|
||||
// AudioSettings is the machine-local audio config for the voice keyer and
|
||||
// the QSO recorder.
|
||||
type AudioSettings struct {
|
||||
FromRadio string `json:"from_radio"` // capture id: rig RX audio
|
||||
ToRadio string `json:"to_radio"` // render id: into the rig
|
||||
RecordingDevice string `json:"recording_device"` // capture id: your mic
|
||||
ListeningDevice string `json:"listening_device"` // render id: preview
|
||||
QSORecord bool `json:"qso_record"` // auto-record every QSO
|
||||
QSODir string `json:"qso_dir"` // recordings folder
|
||||
PrerollSeconds int `json:"preroll_seconds"` // rolling pre-roll (default 8)
|
||||
PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr"
|
||||
PTTPort string `json:"ptt_port"` // COM port for serial PTT
|
||||
Format string `json:"format"` // "wav" | "mp3"
|
||||
}
|
||||
|
||||
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
|
||||
// for the device dropdowns.
|
||||
func (a *App) ListAudioInputDevices() ([]audio.Device, error) { return audio.ListInputDevices() }
|
||||
func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.ListOutputDevices() }
|
||||
|
||||
// GetAudioSettings returns the stored audio config (preroll defaults to 8s).
|
||||
func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav"}
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
|
||||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if v := m[keyAudioFormat]; v == "mp3" || v == "wav" {
|
||||
out.Format = v
|
||||
}
|
||||
if v := m[keyAudioPTTMethod]; v == "rts" || v == "dtr" || v == "cat" || v == "none" {
|
||||
out.PTTMethod = v
|
||||
}
|
||||
out.PTTPort = m[keyAudioPTTPort]
|
||||
out.FromRadio = m[keyAudioFromRadio]
|
||||
out.ToRadio = m[keyAudioToRadio]
|
||||
out.RecordingDevice = m[keyAudioRecDevice]
|
||||
out.ListeningDevice = m[keyAudioListenDevice]
|
||||
out.QSORecord = m[keyAudioQSORecord] == "1"
|
||||
out.QSODir = m[keyAudioQSODir]
|
||||
if n, _ := strconv.Atoi(m[keyAudioPreroll]); n >= 0 && n <= 60 {
|
||||
if n > 0 {
|
||||
out.PrerollSeconds = n
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveAudioSettings persists the audio config.
|
||||
func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if s.PrerollSeconds < 0 || s.PrerollSeconds > 60 {
|
||||
s.PrerollSeconds = 8
|
||||
}
|
||||
qr := "0"
|
||||
if s.QSORecord {
|
||||
qr = "1"
|
||||
}
|
||||
pttMethod := s.PTTMethod
|
||||
if pttMethod != "rts" && pttMethod != "dtr" && pttMethod != "cat" {
|
||||
pttMethod = "none"
|
||||
}
|
||||
format := s.Format
|
||||
if format != "mp3" {
|
||||
format = "wav"
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyAudioFromRadio: s.FromRadio,
|
||||
keyAudioToRadio: s.ToRadio,
|
||||
keyAudioRecDevice: s.RecordingDevice,
|
||||
keyAudioListenDevice: s.ListeningDevice,
|
||||
keyAudioQSORecord: qr,
|
||||
keyAudioQSODir: strings.TrimSpace(s.QSODir),
|
||||
keyAudioPreroll: strconv.Itoa(s.PrerollSeconds),
|
||||
keyAudioPTTMethod: pttMethod,
|
||||
keyAudioPTTPort: strings.TrimSpace(s.PTTPort),
|
||||
keyAudioFormat: format,
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Apply device/preroll/enable changes to the running recorder.
|
||||
a.startQSORecorderIfEnabled()
|
||||
return nil
|
||||
}
|
||||
|
||||
// PickAudioFolder opens a directory picker for the QSO-recordings folder.
|
||||
func (a *App) PickAudioFolder() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
cur, _ := a.GetAudioSettings()
|
||||
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Pick a folder for QSO recordings",
|
||||
DefaultDirectory: firstExistingAncestor(cur.QSODir),
|
||||
})
|
||||
}
|
||||
|
||||
// ── QSO recorder ──────────────────────────────────────────────────────
|
||||
|
||||
// startQSORecorderIfEnabled (re)starts the continuous recorder per the current
|
||||
// settings. Safe to call repeatedly — it stops any running instance first.
|
||||
func (a *App) startQSORecorderIfEnabled() {
|
||||
if a.qsoRec == nil {
|
||||
return
|
||||
}
|
||||
a.qsoRec.Stop()
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
if !cfg.QSORecord {
|
||||
return
|
||||
}
|
||||
if err := a.qsoRec.Start(cfg.FromRadio, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil {
|
||||
applog.Printf("qso-rec: start failed: %v", err)
|
||||
return
|
||||
}
|
||||
applog.Printf("qso-rec: running (preroll %ds, mix=%v)", cfg.PrerollSeconds, cfg.RecordingDevice != "" && cfg.RecordingDevice != cfg.FromRadio)
|
||||
}
|
||||
|
||||
// qsoRecDir returns the configured recordings folder, defaulting to
|
||||
// <dataDir>/Recordings, and ensures it exists.
|
||||
func (a *App) qsoRecDir() string {
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
d := strings.TrimSpace(cfg.QSODir)
|
||||
if d == "" {
|
||||
d = filepath.Join(a.dataDir, "Recordings")
|
||||
}
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
return d
|
||||
}
|
||||
|
||||
// saveQSORecording finalises the active recording (if any) into a WAV named
|
||||
// after the callsign. Called right after a QSO is inserted (manual + UDP).
|
||||
func (a *App) saveQSORecording(call string) {
|
||||
if a.qsoRec == nil || !a.qsoRec.Active() {
|
||||
return
|
||||
}
|
||||
ext := "wav"
|
||||
if cfg, _ := a.GetAudioSettings(); cfg.Format == "mp3" {
|
||||
ext = "mp3"
|
||||
}
|
||||
name := fmt.Sprintf("%s_%s.%s", sanitizeFilename(call), time.Now().UTC().Format("20060102_150405"), ext)
|
||||
path := filepath.Join(a.qsoRecDir(), name)
|
||||
if err := a.qsoRec.SaveQSO(path); err != nil {
|
||||
applog.Printf("qso-rec: save failed: %v", err)
|
||||
return
|
||||
}
|
||||
applog.Printf("qso-rec: saved %s", path)
|
||||
}
|
||||
|
||||
// sanitizeFilename makes a callsign safe for a filename (slashes etc.).
|
||||
func sanitizeFilename(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
s = "QSO"
|
||||
}
|
||||
repl := func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\', ':', '*', '?', '"', '<', '>', '|', ' ':
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}
|
||||
return strings.Map(repl, s)
|
||||
}
|
||||
|
||||
// QSOAudioBegin starts accumulating a QSO recording (seeded with the pre-roll).
|
||||
// Called by the entry strip when a callsign is first entered.
|
||||
func (a *App) QSOAudioBegin() {
|
||||
if a.qsoRec != nil {
|
||||
a.qsoRec.BeginQSO()
|
||||
}
|
||||
}
|
||||
|
||||
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
|
||||
// abandoned without logging).
|
||||
func (a *App) QSOAudioCancel() {
|
||||
if a.qsoRec != nil {
|
||||
a.qsoRec.DiscardQSO()
|
||||
}
|
||||
}
|
||||
|
||||
// RestartQSORecorder applies new audio settings to the running recorder.
|
||||
func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() }
|
||||
|
||||
// ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
|
||||
|
||||
// ClublogCtyInfo is the UI status of the ClubLog exception data.
|
||||
type ClublogCtyInfo struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Loaded bool `json:"loaded"`
|
||||
Date string `json:"date"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func (a *App) clublogCtyEnabled() bool {
|
||||
if a.settings == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := a.settings.Get(a.ctx, keyClublogCtyEnabled)
|
||||
return v == "1"
|
||||
}
|
||||
|
||||
// GetClublogCtyInfo returns the current ClubLog exception status.
|
||||
func (a *App) GetClublogCtyInfo() ClublogCtyInfo {
|
||||
info := ClublogCtyInfo{Enabled: a.clublogCtyEnabled()}
|
||||
if a.clublog != nil {
|
||||
info.Loaded = a.clublog.Loaded()
|
||||
info.Date, info.Count = a.clublog.Info()
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// SetClublogCtyEnabled toggles ClubLog exception resolution, loading the cached
|
||||
// file on first enable.
|
||||
func (a *App) SetClublogCtyEnabled(on bool) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
v := "0"
|
||||
if on {
|
||||
v = "1"
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyClublogCtyEnabled, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if on && a.clublog != nil && !a.clublog.Loaded() {
|
||||
_ = a.clublog.EnsureLoaded() // ok if file not downloaded yet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadClublogCty fetches a fresh ClubLog country file.
|
||||
func (a *App) DownloadClublogCty() (ClublogCtyInfo, error) {
|
||||
if a.clublog == nil {
|
||||
return ClublogCtyInfo{}, fmt.Errorf("clublog not initialized")
|
||||
}
|
||||
if err := a.clublog.Download(a.ctx); err != nil {
|
||||
return a.GetClublogCtyInfo(), err
|
||||
}
|
||||
return a.GetClublogCtyInfo(), nil
|
||||
}
|
||||
|
||||
// applyClublogException overrides a QSO's entity fields from a ClubLog
|
||||
// exception matching its callsign at its date. force=true ignores the
|
||||
// enable toggle (used by the explicit "Update from ClubLog" action).
|
||||
// Returns true if something changed.
|
||||
func (a *App) applyClublogException(q *qso.QSO, force bool) bool {
|
||||
if a.clublog == nil || q.Callsign == "" {
|
||||
return false
|
||||
}
|
||||
if !force && !a.clublogCtyEnabled() {
|
||||
return false
|
||||
}
|
||||
date := q.QSODate
|
||||
if date.IsZero() {
|
||||
date = time.Now().UTC()
|
||||
}
|
||||
e, ok := a.clublog.Resolve(q.Callsign, date)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
q.Country = titleEntity(e.Entity)
|
||||
if e.Cont != "" {
|
||||
q.Continent = e.Cont
|
||||
}
|
||||
if e.ADIF != 0 {
|
||||
n := e.ADIF
|
||||
q.DXCC = &n
|
||||
}
|
||||
if e.CQZ != 0 {
|
||||
v := e.CQZ
|
||||
q.CQZ = &v
|
||||
}
|
||||
if e.Lat != 0 || e.Lon != 0 {
|
||||
lat, lon := e.Lat, e.Lon
|
||||
q.Lat, q.Lon = &lat, &lon
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateQSOsFromClublog re-resolves the selected QSOs against ClubLog
|
||||
// exceptions (by their QSO date) and saves any that changed.
|
||||
func (a *App) UpdateQSOsFromClublog(ids []int64) (int, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if a.clublog == nil || !a.clublog.Loaded() {
|
||||
return 0, fmt.Errorf("ClubLog data not loaded — download it first")
|
||||
}
|
||||
changed := 0
|
||||
for _, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if a.applyClublogException(&q, true) {
|
||||
if err := a.qso.Update(a.ctx, q); err == nil {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// titleCaseIfUpper title-cases a string ONLY when it's entirely upper-case
|
||||
// (e.g. Log4OM/contest ADIF sends "SANTO DOMINGO"); mixed-case values are
|
||||
// left untouched. Codes like state "DN" stay as-is (no lower-case letters
|
||||
// to gain, but they're short — callers pick which fields to pass).
|
||||
func titleCaseIfUpper(s string) string {
|
||||
t := strings.TrimSpace(s)
|
||||
if t == "" || t != strings.ToUpper(t) {
|
||||
return s
|
||||
}
|
||||
return titleEntity(t)
|
||||
}
|
||||
|
||||
// titleEntity converts ClubLog's UPPERCASE entity names to title case
|
||||
// ("LORD HOWE ISLAND" → "Lord Howe Island") for display consistency.
|
||||
func titleEntity(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
words := strings.Fields(strings.ToLower(s))
|
||||
for i, w := range words {
|
||||
r := []rune(w)
|
||||
r[0] = []rune(strings.ToUpper(string(r[0])))[0]
|
||||
words[i] = string(r)
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
// ── Digital Voice Keyer (DVK) ─────────────────────────────────────────
|
||||
//
|
||||
// Six voice-message slots (F1–F6, like the WinKeyer macros). Each message is a
|
||||
// WAV file in <dataDir>/dvk/dvk<N>.wav; its label lives in settings. Record via
|
||||
// the configured "Recording mic", transmit via "To Radio", preview via
|
||||
// "Listening".
|
||||
|
||||
const dvkSlots = 6
|
||||
|
||||
// DVKMessage is one voice-keyer slot for the UI.
|
||||
type DVKMessage struct {
|
||||
Slot int `json:"slot"`
|
||||
Label string `json:"label"`
|
||||
HasAudio bool `json:"has_audio"`
|
||||
DurationSec float64 `json:"duration_sec"`
|
||||
}
|
||||
|
||||
// DVKStatus reflects the live record/playback state for the operating panel.
|
||||
type DVKStatus struct {
|
||||
Recording bool `json:"recording"`
|
||||
Playing bool `json:"playing"`
|
||||
RecSlot int `json:"rec_slot"`
|
||||
}
|
||||
|
||||
func (a *App) dvkDir() string {
|
||||
d := filepath.Join(a.dataDir, "dvk")
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
return d
|
||||
}
|
||||
|
||||
func (a *App) dvkPath(slot int) string {
|
||||
return filepath.Join(a.dvkDir(), fmt.Sprintf("dvk%d.wav", slot))
|
||||
}
|
||||
|
||||
func dvkLabelKey(slot int) string { return fmt.Sprintf("audio.dvk.label%d", slot) }
|
||||
|
||||
func (a *App) dvkStatus() DVKStatus {
|
||||
st := DVKStatus{RecSlot: a.dvkRecSlot}
|
||||
if a.audioMgr != nil {
|
||||
st.Recording = a.audioMgr.IsRecording()
|
||||
st.Playing = a.audioMgr.IsPlaying()
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// GetDVKStatus returns the current record/playback state.
|
||||
func (a *App) GetDVKStatus() DVKStatus { return a.dvkStatus() }
|
||||
|
||||
// GetDVKMessages returns the six voice-keyer slots with their labels, whether
|
||||
// a recording exists, and its duration.
|
||||
func (a *App) GetDVKMessages() []DVKMessage {
|
||||
out := make([]DVKMessage, 0, dvkSlots)
|
||||
for s := 1; s <= dvkSlots; s++ {
|
||||
m := DVKMessage{Slot: s}
|
||||
if a.settings != nil {
|
||||
if v, _ := a.settings.Get(a.ctx, dvkLabelKey(s)); v != "" {
|
||||
m.Label = v
|
||||
}
|
||||
}
|
||||
if fi, err := os.Stat(a.dvkPath(s)); err == nil && fi.Size() > 44 {
|
||||
m.HasAudio = true
|
||||
m.DurationSec = float64(fi.Size()-44) / 32000.0 // 16 kHz mono 16-bit
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetDVKLabel renames a voice-keyer slot.
|
||||
func (a *App) SetDVKLabel(slot int, label string) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if slot < 1 || slot > dvkSlots {
|
||||
return fmt.Errorf("bad slot")
|
||||
}
|
||||
return a.settings.Set(a.ctx, dvkLabelKey(slot), strings.TrimSpace(label))
|
||||
}
|
||||
|
||||
// DVKStartRecord begins recording a voice message into the given slot, using
|
||||
// the configured Recording mic.
|
||||
func (a *App) DVKStartRecord(slot int) error {
|
||||
if a.audioMgr == nil {
|
||||
return fmt.Errorf("audio not initialized")
|
||||
}
|
||||
if slot < 1 || slot > dvkSlots {
|
||||
return fmt.Errorf("bad slot")
|
||||
}
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
a.dvkRecSlot = slot
|
||||
return a.audioMgr.StartRecording(cfg.RecordingDevice)
|
||||
}
|
||||
|
||||
// DVKStopRecord ends the recording and writes it to the slot's WAV file.
|
||||
func (a *App) DVKStopRecord() error {
|
||||
if a.audioMgr == nil {
|
||||
return fmt.Errorf("audio not initialized")
|
||||
}
|
||||
return a.audioMgr.StopRecording(a.dvkPath(a.dvkRecSlot))
|
||||
}
|
||||
|
||||
// DVKCancelRecord aborts a recording without saving.
|
||||
func (a *App) DVKCancelRecord() { if a.audioMgr != nil { a.audioMgr.CancelRecording() } }
|
||||
|
||||
// DVKPlay transmits a slot's message to the rig ("To Radio"), asserting serial
|
||||
// PTT (RTS/DTR) first unless the operator uses VOX. PTT is released
|
||||
// automatically when playback ends (see the audio status callback).
|
||||
func (a *App) DVKPlay(slot int) error {
|
||||
if a.audioMgr == nil {
|
||||
return fmt.Errorf("audio not initialized")
|
||||
}
|
||||
path := a.dvkPath(slot)
|
||||
if fi, err := os.Stat(path); err != nil || fi.Size() <= 44 {
|
||||
return fmt.Errorf("no recording in slot %d", slot)
|
||||
}
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
if err := a.pttKey(cfg); err != nil {
|
||||
applog.Printf("dvk: PTT on failed: %v", err)
|
||||
// Keep going — the audio still reaches the rig; the user may use VOX.
|
||||
} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
|
||||
a.dvkPttKeyed = true
|
||||
}
|
||||
if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil {
|
||||
if a.dvkPttKeyed {
|
||||
a.dvkPttKeyed = false
|
||||
go a.dvkUnkeyPTT()
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dvkUnkeyPTT releases serial PTT after a short tail so the rig doesn't clip
|
||||
// the end of the message.
|
||||
func (a *App) dvkUnkeyPTT() {
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
a.pttUnkey()
|
||||
}
|
||||
|
||||
// pttKey keys the transmitter using the configured method:
|
||||
// - "cat" → OmniRig (sets the Tx parameter to PM_TX)
|
||||
// - "rts"/"dtr" → open the COM port and assert that line, held during TX
|
||||
// - "none" → VOX, nothing to do
|
||||
func (a *App) pttKey(cfg AudioSettings) error {
|
||||
switch cfg.PTTMethod {
|
||||
case "cat":
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
if err := a.cat.SetPTT(true); err != nil {
|
||||
return err
|
||||
}
|
||||
a.pttMu.Lock()
|
||||
a.pttKeyedMethod = "cat"
|
||||
a.pttMu.Unlock()
|
||||
applog.Printf("dvk: PTT keyed (CAT/OmniRig)")
|
||||
return nil
|
||||
case "rts", "dtr":
|
||||
if strings.TrimSpace(cfg.PTTPort) == "" {
|
||||
return fmt.Errorf("no PTT COM port configured")
|
||||
}
|
||||
a.pttMu.Lock()
|
||||
defer a.pttMu.Unlock()
|
||||
if a.pttPort != nil {
|
||||
return nil // already keyed
|
||||
}
|
||||
port, err := serial.Open(cfg.PTTPort, &serial.Mode{BaudRate: 9600})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", cfg.PTTPort, err)
|
||||
}
|
||||
var lerr error
|
||||
if cfg.PTTMethod == "rts" {
|
||||
lerr = port.SetRTS(true)
|
||||
_ = port.SetDTR(false)
|
||||
} else {
|
||||
lerr = port.SetDTR(true)
|
||||
_ = port.SetRTS(false)
|
||||
}
|
||||
if lerr != nil {
|
||||
_ = port.Close()
|
||||
return fmt.Errorf("assert %s on %s: %w", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort, lerr)
|
||||
}
|
||||
a.pttPort = port
|
||||
a.pttKeyedMethod = cfg.PTTMethod
|
||||
applog.Printf("dvk: PTT keyed (%s on %s)", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort)
|
||||
return nil
|
||||
}
|
||||
return nil // none / VOX
|
||||
}
|
||||
|
||||
// pttUnkey releases whichever PTT was keyed (CAT back to RX, or drop the
|
||||
// serial line + close the port).
|
||||
func (a *App) pttUnkey() {
|
||||
a.pttMu.Lock()
|
||||
method := a.pttKeyedMethod
|
||||
a.pttKeyedMethod = ""
|
||||
port := a.pttPort
|
||||
a.pttPort = nil
|
||||
a.pttMu.Unlock()
|
||||
|
||||
switch method {
|
||||
case "cat":
|
||||
if a.cat != nil {
|
||||
if err := a.cat.SetPTT(false); err != nil {
|
||||
applog.Printf("dvk: PTT off (CAT) failed: %v", err)
|
||||
}
|
||||
}
|
||||
case "rts", "dtr":
|
||||
if port != nil {
|
||||
_ = port.SetRTS(false)
|
||||
_ = port.SetDTR(false)
|
||||
_ = port.Close()
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
applog.Printf("dvk: PTT released")
|
||||
}
|
||||
|
||||
// TestPTT keys PTT for ~600ms so the user can confirm the rig transmits.
|
||||
func (a *App) TestPTT() error {
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
|
||||
return fmt.Errorf("PTT method is None (VOX) — nothing to test")
|
||||
}
|
||||
if err := a.pttKey(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() { time.Sleep(600 * time.Millisecond); a.pttUnkey() }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DVKPreview plays a slot's message locally on the "Listening" device.
|
||||
func (a *App) DVKPreview(slot int) error {
|
||||
if a.audioMgr == nil {
|
||||
return fmt.Errorf("audio not initialized")
|
||||
}
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
return a.audioMgr.Play(cfg.ListeningDevice, a.dvkPath(slot))
|
||||
}
|
||||
|
||||
// DVKStop halts any voice-keyer playback.
|
||||
func (a *App) DVKStop() {
|
||||
if a.audioMgr != nil {
|
||||
a.audioMgr.StopPlayback()
|
||||
}
|
||||
}
|
||||
|
||||
// GetLogFilePath returns where the diagnostic log file lives so the user
|
||||
// can open it from the Settings UI. Empty when applog hasn't initialised.
|
||||
func (a *App) GetLogFilePath() string {
|
||||
@@ -2650,6 +3332,15 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Name/city normalisation ──
|
||||
// Log4OM / contest loggers often send NAME and QTH in ALL CAPS. Title-case
|
||||
// them so UDP-logged QSOs match the manual + lookup paths ("SANTO DOMINGO"
|
||||
// → "Santo Domingo"). Only all-caps values are touched.
|
||||
q.Name = titleCaseIfUpper(q.Name)
|
||||
q.QTH = titleCaseIfUpper(q.QTH)
|
||||
q.Country = titleCaseIfUpper(q.Country)
|
||||
q.MyCity = titleCaseIfUpper(q.MyCity)
|
||||
|
||||
// ── Operating-conditions stamp ──
|
||||
// Pre-fill MY_RIG / MY_ANTENNA / TX_PWR from the default antenna for
|
||||
// this band (if the user has configured Operating conditions).
|
||||
@@ -2675,15 +3366,22 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
// entity-name table; QSL defaults are applied last so explicit ADIF
|
||||
// fields (or what the lookup gave us) always win.
|
||||
a.applyDXCCNumber(&q)
|
||||
a.applyClublogException(&q, false) // date-ranged DXpedition override
|
||||
a.applyQSLDefaults(&q)
|
||||
|
||||
// ── Dedup ──
|
||||
// Match by call + minute + band + mode (same key the importer uses).
|
||||
// Match by call + band + mode within a ±2-minute window: a QSO logged
|
||||
// manually in OpsLog and re-broadcast by Log4OM over UDP often differs by
|
||||
// a minute (the two apps stamp their own time), so a minute-exact key
|
||||
// missed it and the contact got duplicated.
|
||||
seen, err := a.qso.ExistingDedupeKeys(a.ctx)
|
||||
if err == nil {
|
||||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
||||
if _, dup := seen[key]; dup {
|
||||
return 0, fmt.Errorf("duplicate (already in log)")
|
||||
base := q.QSODate.UTC()
|
||||
for d := -2; d <= 2; d++ {
|
||||
min := base.Add(time.Duration(d) * time.Minute).Format("2006-01-02T15:04")
|
||||
if _, dup := seen[qso.DedupeKey(q.Callsign, min, q.Band, q.Mode)]; dup {
|
||||
return 0, fmt.Errorf("duplicate (already in log within ±2 min)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2691,6 +3389,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert qso: %w", err)
|
||||
}
|
||||
a.saveQSORecording(q.Callsign)
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
|
||||
+80
-13
@@ -8,7 +8,7 @@ import {
|
||||
AddQSO, ListQSO, CountQSO,
|
||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF,
|
||||
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UploadQSOsManual,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual,
|
||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||
GetStartupStatus,
|
||||
WorkedBefore,
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
ListCountries,
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
QSOAudioBegin, QSOAudioCancel,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { EventsOn } from '../wailsjs/runtime/runtime';
|
||||
@@ -45,6 +47,7 @@ import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -495,6 +498,29 @@ export default function App() {
|
||||
const wkEscClearsRef = useRef(true);
|
||||
useEffect(() => { wkActiveRef.current = wkEnabled && wkStatus.connected; }, [wkEnabled, wkStatus.connected]);
|
||||
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
|
||||
|
||||
// === Digital Voice Keyer (DVK) ===
|
||||
const [dvkEnabled, setDvkEnabled] = useState(false);
|
||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||
const dvkActiveRef = useRef(false);
|
||||
const dvkPlayingRef = useRef(false);
|
||||
const dvkPlayRef = useRef<(slot: number) => void>(() => {});
|
||||
useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]);
|
||||
useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]);
|
||||
useEffect(() => {
|
||||
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
|
||||
return () => { off?.(); };
|
||||
}, []);
|
||||
const reloadDvk = useCallback(() => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); }, []);
|
||||
// Load messages + status whenever the keyer is switched on.
|
||||
useEffect(() => {
|
||||
if (!dvkEnabled) return;
|
||||
reloadDvk();
|
||||
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
|
||||
}, [dvkEnabled, reloadDvk]);
|
||||
const dvkPlay = useCallback((slot: number) => { DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e))); }, []);
|
||||
useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]);
|
||||
// Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch
|
||||
// it from the keyboard without clashing with the F1-F12 keyer macros).
|
||||
type DetailTab = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||
@@ -586,6 +612,9 @@ export default function App() {
|
||||
// Worked-before tab so the history is front-and-centre. Only once per call,
|
||||
// and we don't yank the user out of the Cluster / QSL-manager tabs.
|
||||
useEffect(() => {
|
||||
// Opt-out: General settings can disable this auto-jump (read live so the
|
||||
// toggle takes effect without a reload).
|
||||
if (localStorage.getItem('opslog.autofocusWB') === '0') return;
|
||||
const c = callsign.trim().toUpperCase();
|
||||
if (!c || !wb || (wb.count ?? 0) <= 0 || (wb.callsign ?? '').toUpperCase() !== c) return;
|
||||
if (lastWbFocusRef.current === c) return;
|
||||
@@ -1017,6 +1046,9 @@ export default function App() {
|
||||
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
|
||||
// are preserved so backdated batches stay productive.
|
||||
function resetEntry() {
|
||||
// Discard any in-progress QSO recording (no-op if it was already saved on
|
||||
// log, or if the recorder is off).
|
||||
QSOAudioCancel();
|
||||
setCallsign(''); setComment(''); setNote('');
|
||||
if (!locks.start) setQsoStartedAt(null);
|
||||
if (!locks.end) setQsoEndedAt(null);
|
||||
@@ -1095,6 +1127,11 @@ export default function App() {
|
||||
try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function bulkUpdateFromClublog(ids: number[]) {
|
||||
if (ids.length === 0) return;
|
||||
try { await afterBulkUpdate(await UpdateQSOsFromClublog(ids as any), 'from ClubLog'); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
// Right-click "Send to QRZ.com / Club Log / LoTW": uploads the selected QSOs
|
||||
// on demand (regardless of their current upload status). Runs in the
|
||||
// background; qslmgr:done refreshes the grid when finished.
|
||||
@@ -1199,6 +1236,10 @@ export default function App() {
|
||||
// reload worked-before + the band matrix, making them flicker. Compared
|
||||
// via the ref so it's correct even from the stale UDP closure.
|
||||
if (v.trim().toUpperCase() === callsignValRef.current.trim().toUpperCase()) return;
|
||||
// QSO recorder: a non-empty callsign marks the QSO start (the recorder
|
||||
// keeps the pre-roll from before this); clearing it discards the take.
|
||||
// Both are no-ops when the recorder is off.
|
||||
if (v.trim() === '') QSOAudioCancel(); else QSOAudioBegin();
|
||||
const wasEmpty = callsign.trim() === '';
|
||||
const isEmpty = v.trim() === '';
|
||||
if (wasEmpty && !isEmpty && !locks.start) {
|
||||
@@ -1296,6 +1337,7 @@ export default function App() {
|
||||
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
|
||||
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
|
||||
{ type: 'separator' },
|
||||
// Maintenance — bumped here while we only have one entry. Will move
|
||||
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
||||
@@ -1304,7 +1346,7 @@ export default function App() {
|
||||
{ name: 'help', label: 'Help', items: [
|
||||
{ type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true },
|
||||
]},
|
||||
], [total, selectedId, ctyRefreshing, exporting, wkEnabled]);
|
||||
], [total, selectedId, ctyRefreshing, exporting, wkEnabled, dvkEnabled]);
|
||||
|
||||
function handleMenu(action: string) {
|
||||
switch (action) {
|
||||
@@ -1318,6 +1360,7 @@ export default function App() {
|
||||
case 'edit.prefs': setShowSettings(true); break;
|
||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
}
|
||||
}
|
||||
@@ -1348,6 +1391,12 @@ export default function App() {
|
||||
// callsign depends on the "ESC clears callsign" option; with the keyer
|
||||
// off it always resets the entry (the classic behaviour).
|
||||
if (e.key === 'Escape') {
|
||||
// If a voice message is transmitting, ESC just stops it (keeps entry).
|
||||
if (dvkActiveRef.current && dvkPlayingRef.current) {
|
||||
DVKStop();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const keyerLive = wkActiveRef.current;
|
||||
if (keyerLive) WinkeyerStop().catch(() => {});
|
||||
if (!keyerLive || wkEscClearsRef.current) {
|
||||
@@ -1366,13 +1415,19 @@ export default function App() {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const plain = !mod && !e.altKey;
|
||||
if (wkActiveRef.current) {
|
||||
// Keyer live: plain F1..F12 fire macros; Ctrl+F1..F5 switch the
|
||||
// CW keyer live: plain F1..F12 fire macros; Ctrl+F1..F5 switch the
|
||||
// detail tab (so the two don't clash). Labels read "Ctrl+F1…".
|
||||
if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
|
||||
if (plain) { e.preventDefault(); wkSendMacroRef.current(n - 1); return; }
|
||||
return;
|
||||
}
|
||||
// Keyer off: plain F1..F5 switch the detail tab (labels read "F1…").
|
||||
if (dvkActiveRef.current) {
|
||||
// Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs.
|
||||
if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
|
||||
if (plain && n <= 6) { e.preventDefault(); dvkPlayRef.current(n); return; }
|
||||
return;
|
||||
}
|
||||
// No keyer: plain F1..F5 switch the detail tab (labels read "F1…").
|
||||
if (plain && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
|
||||
return;
|
||||
}
|
||||
@@ -1970,15 +2025,26 @@ export default function App() {
|
||||
mode={mode}
|
||||
tab={detailTab}
|
||||
onTab={setDetailTab}
|
||||
keyerActive={wkEnabled && wkStatus.connected}
|
||||
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Reserved free space to the right. When the WinKeyer CW keyer is
|
||||
enabled it takes this slot (Log4OM-style); otherwise it shows the
|
||||
QRZ profile photo. */}
|
||||
{!compact && (wkEnabled || lookupResult?.image_url) && (
|
||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||
otherwise it shows the QRZ profile photo. */}
|
||||
{!compact && (wkEnabled || dvkEnabled || lookupResult?.image_url) && (
|
||||
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
|
||||
{dvkEnabled && (
|
||||
<div className="flex-1 min-w-0 min-h-0">
|
||||
<DvkPanel
|
||||
messages={dvkMsgs}
|
||||
status={dvkStat}
|
||||
onPlay={dvkPlay}
|
||||
onStop={() => DVKStop()}
|
||||
onClose={() => setDvkEnabled(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{wkEnabled && (
|
||||
<div className="flex-1 min-w-0 min-h-0">
|
||||
<WinkeyerPanel
|
||||
@@ -2007,7 +2073,7 @@ export default function App() {
|
||||
{/* QRZ photo: when the keyer is open it sits to its right at natural
|
||||
(capped) width, shrinking the keyer panel rather than hiding it. */}
|
||||
{lookupResult?.image_url && (
|
||||
<div className={cn('min-w-0 flex items-center', wkEnabled ? 'shrink-0' : 'flex-1')}>
|
||||
<div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lookupResult.image_url && setPhotoModal(lookupResult.image_url)}
|
||||
@@ -2145,6 +2211,7 @@ export default function App() {
|
||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty}
|
||||
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
||||
onUpdateFromClublog={bulkUpdateFromClublog}
|
||||
onSendTo={bulkSendTo}
|
||||
onRowSelected={(id) => setSelectedId(id)}
|
||||
/>
|
||||
@@ -2477,7 +2544,7 @@ export default function App() {
|
||||
|
||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onSendTo={bulkSendTo} />
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Opened on demand from Tools → QSL Manager; closable via the
|
||||
@@ -2718,9 +2785,9 @@ export default function App() {
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Fix country & zones from cty.dat
|
||||
Fix country & zones (cty.dat + ClubLog)
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Recompute Country, DXCC, CQ & ITU zones from cty.dat, overriding the file. Corrects wrong countries that contest software exports (e.g. RG2Y as Asiatic instead of European Russia). Everything else in the ADIF is kept as-is.
|
||||
Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). If ClubLog exceptions are enabled, its date-ranged DXpedition overrides are applied on top (per QSO date). Everything else in the ADIF is kept as-is.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Mic, Square, X, Radio } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||||
export type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
||||
|
||||
type Props = {
|
||||
messages: DVKMsg[];
|
||||
status: DVKStat;
|
||||
onPlay: (slot: number) => void;
|
||||
onStop: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
||||
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
||||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
||||
const anyAudio = messages.some((m) => m.has_audio);
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
||||
<Mic className="size-3.5 text-primary" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span>
|
||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
|
||||
{status.playing && <span className="text-[10px] text-amber-600 font-medium">transmitting…</span>}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
||||
<Square className="size-3" /> Stop
|
||||
</Button>
|
||||
<button className="text-muted-foreground hover:text-foreground" title="Disable voice keyer" onClick={onClose}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto p-2">
|
||||
{!anyAudio ? (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-1 text-center text-[11px] text-muted-foreground px-3">
|
||||
<Radio className="size-5 opacity-50" />
|
||||
No messages recorded yet. Open <strong>Settings → Audio devices & voice keyer</strong> to record F1–F6.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{messages.map((m) => (
|
||||
<button
|
||||
key={m.slot}
|
||||
type="button"
|
||||
disabled={!m.has_audio}
|
||||
onClick={() => onPlay(m.slot)}
|
||||
title={m.has_audio ? `Transmit F${m.slot}${m.label ? ' — ' + m.label : ''} (${m.duration_sec.toFixed(1)}s)` : `F${m.slot} — empty`}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border px-2 py-1.5 text-left transition-colors',
|
||||
m.has_audio
|
||||
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
|
||||
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
|
||||
<span className="text-xs truncate flex-1">{m.label || (m.has_audio ? 'message' : '—')}</span>
|
||||
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Globe2, RefreshCw, Upload } from 'lucide-react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck } from 'lucide-react';
|
||||
|
||||
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||
|
||||
@@ -8,6 +8,7 @@ type Props = {
|
||||
onClose: () => void;
|
||||
onUpdateFromCty: (ids: number[]) => void;
|
||||
onUpdateFromQRZ: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
};
|
||||
|
||||
@@ -20,7 +21,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
||||
// onCellContextMenu. Closes on any outside click, scroll or Escape.
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onSendTo }: Props) {
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo }: Props) {
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => onClose();
|
||||
@@ -66,6 +67,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
<RefreshCw className="size-4 text-sky-600" />
|
||||
<span>Update from QRZ.com</span>
|
||||
</button>
|
||||
{onUpdateFromClublog && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
|
||||
>
|
||||
<BadgeCheck className="size-4 text-violet-600" />
|
||||
<span>Update from ClubLog (exceptions)</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onSendTo && (
|
||||
<>
|
||||
|
||||
@@ -49,6 +49,7 @@ type Props = {
|
||||
onRowSelected?: (id: number | null) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
};
|
||||
|
||||
@@ -207,7 +208,7 @@ export const GROUP_ORDER = [
|
||||
'Contest', 'Propagation', 'My station', 'Misc',
|
||||
];
|
||||
|
||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onSendTo }: Props) {
|
||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo }: Props) {
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
@@ -350,6 +351,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
onClose={() => setMenu(null)}
|
||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||
onUpdateFromClublog={onUpdateFromClublog}
|
||||
onSendTo={onSendTo}
|
||||
/>
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
|
||||
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||||
GetClusterAutoConnect, SetClusterAutoConnect,
|
||||
ConnectClusterServer, DisconnectClusterServer,
|
||||
@@ -132,6 +135,7 @@ interface Props {
|
||||
Section IDs are stable strings — adding new ones means adding a panel below.
|
||||
`disabled: true` greys them out and shows the "coming soon" placeholder. */
|
||||
type SectionId =
|
||||
| 'general'
|
||||
| 'station'
|
||||
| 'profiles'
|
||||
| 'operating'
|
||||
@@ -167,6 +171,7 @@ const TREE: TreeNode[] = [
|
||||
},
|
||||
{
|
||||
kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [
|
||||
{ kind: 'item', label: 'General', id: 'general' },
|
||||
{ kind: 'item', label: 'Callsign Lookup', id: 'lookup' },
|
||||
{ kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [
|
||||
{ kind: 'item', label: 'Bands', id: 'lists-bands' },
|
||||
@@ -184,7 +189,7 @@ const TREE: TreeNode[] = [
|
||||
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
|
||||
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
|
||||
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
||||
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true },
|
||||
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -376,6 +381,46 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||
|
||||
// ── Audio (DVK + QSO recorder) ──
|
||||
type AudioSettings = {
|
||||
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
||||
};
|
||||
type AudioDev = { id: string; name: string; default: boolean };
|
||||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
||||
});
|
||||
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
||||
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
|
||||
const setAudioField = (patch: Partial<AudioSettings>) => setAudioCfg((s) => ({ ...s, ...patch }));
|
||||
const reloadAudioDevices = () => {
|
||||
ListAudioInputDevices().then((d) => setAudioInputs((d ?? []) as AudioDev[])).catch(() => {});
|
||||
ListAudioOutputDevices().then((d) => setAudioOutputs((d ?? []) as AudioDev[])).catch(() => {});
|
||||
};
|
||||
// DVK voice-keyer messages (F1–F6).
|
||||
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||||
type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||
const [dvkErr, setDvkErr] = useState('');
|
||||
|
||||
// General behaviour prefs (machine-local, applied live via localStorage).
|
||||
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
||||
// ClubLog Country File (cty.xml) exception status.
|
||||
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
|
||||
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
|
||||
const [clubBusy, setClubBusy] = useState(false);
|
||||
const [clubErr, setClubErr] = useState('');
|
||||
useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []);
|
||||
const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); };
|
||||
useEffect(() => {
|
||||
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
|
||||
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
|
||||
return () => { off?.(); };
|
||||
}, []);
|
||||
|
||||
type QSLDefaults = {
|
||||
qsl_sent: string; qsl_rcvd: string;
|
||||
lotw_sent: string; lotw_rcvd: string;
|
||||
@@ -523,6 +568,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
||||
try { setWk(await GetWinkeyerSettings() as any); } catch {}
|
||||
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
||||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||
reloadAudioDevices();
|
||||
reloadDvk();
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -638,7 +686,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
async function save(close = true) {
|
||||
setSaving(true); setErr(''); setMsg('');
|
||||
try {
|
||||
// Bands: dedup, lowercase, trim. Order = user's drag order.
|
||||
@@ -677,6 +725,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
await SaveCATSettings(catCfg as any);
|
||||
await SaveRotatorSettings(rotator as any);
|
||||
await SaveWinkeyerSettings(wk as any);
|
||||
await SaveAudioSettings(audioCfg as any);
|
||||
await SaveBackupSettings(backupCfg as any);
|
||||
await SaveQSLDefaults(qslDefaults as any);
|
||||
await SaveExternalServices(extSvc as any);
|
||||
@@ -684,7 +733,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
|
||||
setMsg('Settings saved.');
|
||||
onSaved();
|
||||
setTimeout(onClose, 500);
|
||||
if (close) setTimeout(onClose, 500);
|
||||
else setTimeout(() => setMsg(''), 2000);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -2465,8 +2515,266 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function AudioPanel() {
|
||||
const deviceSelect = (
|
||||
field: keyof AudioSettings,
|
||||
devices: AudioDev[],
|
||||
placeholder: string,
|
||||
) => (
|
||||
<Select
|
||||
value={(audioCfg[field] as string) || '_'}
|
||||
onValueChange={(v) => setAudioField({ [field]: v === '_' ? '' : v } as any)}
|
||||
>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder={placeholder} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">— none / system default —</SelectItem>
|
||||
{devices.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' (default)' : ''}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between">
|
||||
<SectionHeader
|
||||
title="Audio devices & voice keyer"
|
||||
hint="Machine-local audio routing for the Digital Voice Keyer and the QSO recorder. Pick the soundcard endpoints wired to your rig. (Pure-Go WASAPI — no extra driver.)"
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="h-7 text-[11px] shrink-0" onClick={reloadAudioDevices}>
|
||||
Refresh devices
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">From Radio (RX in)</Label>
|
||||
{deviceSelect('from_radio', audioInputs, 'Rig audio output → soundcard input')}
|
||||
<Label className="text-sm">To Radio (TX out)</Label>
|
||||
{deviceSelect('to_radio', audioOutputs, 'Soundcard output → rig mic/data in')}
|
||||
<Label className="text-sm">Recording mic</Label>
|
||||
{deviceSelect('recording_device', audioInputs, 'Your microphone (record DVK messages)')}
|
||||
<Label className="text-sm">Listening (preview)</Label>
|
||||
{deviceSelect('listening_device', audioOutputs, 'Local speakers for preview')}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
|
||||
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
|
||||
<h4 className="text-sm font-semibold text-foreground">QSO recorder</h4>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} className="mt-0.5" />
|
||||
<span>
|
||||
Record every QSO to an audio file
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Captures <strong>From Radio + your mic</strong> continuously into a rolling buffer; on <em>Log QSO</em> the
|
||||
file is saved from a few seconds <em>before</em> you entered the callsign through the end of the contact.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">Recordings folder</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={audioCfg.qso_dir} onChange={(e) => setAudioField({ qso_dir: e.target.value })}
|
||||
placeholder="C:\…\OpsLog\Recordings" className="h-8 font-mono text-xs" />
|
||||
<Button variant="outline" size="sm" className="h-8 shrink-0"
|
||||
onClick={() => PickAudioFolder().then((d) => { if (d) setAudioField({ qso_dir: d }); }).catch(() => {})}>
|
||||
Browse…
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-sm">Pre-roll (seconds)</Label>
|
||||
<Input type="number" min={0} max={60} value={audioCfg.preroll_seconds}
|
||||
onChange={(e) => setAudioField({ preroll_seconds: Math.max(0, Math.min(60, parseInt(e.target.value, 10) || 0)) })}
|
||||
className="h-8 w-24 font-mono" />
|
||||
<Label className="text-sm">File format</Label>
|
||||
<Select value={audioCfg.format} onValueChange={(v) => setAudioField({ format: v as any })}>
|
||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="wav">WAV (lossless, larger)</SelectItem>
|
||||
<SelectItem value="mp3">MP3 (compressed, small)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Files are named <span className="font-mono">CALL_YYYYMMDD_HHMMSS.{audioCfg.format}</span>.
|
||||
{audioCfg.format === 'mp3' ? ' MP3 ≈ 7× smaller — handy to send to correspondents.' : ' WAV is lossless (~115 KB/min).'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-2 max-w-2xl">
|
||||
<h4 className="text-sm font-semibold text-foreground">Voice keyer messages (F1–F6)</h4>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<strong>Press and hold</strong> Rec while you speak (release to save). Preview on <strong>Listening</strong>;
|
||||
during operation they transmit via <strong>To Radio</strong>.
|
||||
</p>
|
||||
<div className="rounded-md border border-border/60 p-2.5 space-y-2">
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2 items-center">
|
||||
<Label className="text-sm">PTT method</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={audioCfg.ptt_method} onValueChange={(v) => setAudioField({ ptt_method: v as any })}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None (VOX)</SelectItem>
|
||||
<SelectItem value="cat">CAT (OmniRig)</SelectItem>
|
||||
<SelectItem value="rts">Serial RTS</SelectItem>
|
||||
<SelectItem value="dtr">Serial DTR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{audioCfg.ptt_method !== 'none' && (
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT().catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
|
||||
Test PTT
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
|
||||
<>
|
||||
<Label className="text-sm">PTT COM port</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={audioCfg.ptt_port || '_'} onValueChange={(v) => setAudioField({ ptt_port: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue placeholder="Pick a COM port" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_">— select —</SelectItem>
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-[11px]"
|
||||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<strong>CAT (OmniRig)</strong> keys TX through the rig control (sets OmniRig's Tx parameter) — needs CAT
|
||||
connected. <strong>Serial RTS/DTR</strong> asserts a COM line (e.g. a SmartSDR CAT port set to PTT-on-RTS).
|
||||
<strong> None (VOX)</strong> lets the rig key on audio. Use <strong>Test PTT</strong> to confirm.
|
||||
</p>
|
||||
</div>
|
||||
{dvkErr && <p className="text-[11px] text-destructive">{dvkErr}</p>}
|
||||
<div className="space-y-1.5">
|
||||
{dvkMsgs.map((m) => {
|
||||
const recHere = dvkStat.recording && dvkStat.rec_slot === m.slot;
|
||||
const recBusy = dvkStat.recording && !recHere;
|
||||
return (
|
||||
<div key={m.slot} className="flex items-center gap-2">
|
||||
<span className="w-7 font-mono text-xs font-bold text-muted-foreground">F{m.slot}</span>
|
||||
<Input
|
||||
className="h-8 flex-1"
|
||||
placeholder={`Message ${m.slot} label (CQ, report, 73…)`}
|
||||
value={m.label}
|
||||
onChange={(e) => setDvkMsgs((ms) => ms.map((x) => x.slot === m.slot ? { ...x, label: e.target.value } : x))}
|
||||
onBlur={(e) => SetDVKLabel(m.slot, e.target.value).catch(() => {})}
|
||||
/>
|
||||
<span className="w-16 text-[11px] text-muted-foreground text-right">
|
||||
{m.has_audio ? `✓ ${m.duration_sec.toFixed(1)}s` : '—'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant={recHere ? 'destructive' : 'outline'} size="sm" className="h-8 w-28 shrink-0 select-none touch-none"
|
||||
disabled={recBusy}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
setDvkErr('');
|
||||
DVKStartRecord(m.slot).catch((err) => setDvkErr('Record: ' + String(err?.message ?? err)));
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr('Save: ' + String(err?.message ?? err)));
|
||||
}}
|
||||
>
|
||||
{recHere ? '● Recording…' : '● Hold to rec'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline" size="sm" className="h-8 w-20 shrink-0"
|
||||
disabled={!m.has_audio || dvkStat.recording}
|
||||
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr('Play: ' + String(err?.message ?? err))))}
|
||||
>
|
||||
{dvkStat.playing ? '■ Stop' : '▶ Play'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneralPanel() {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="General" hint="App behaviour preferences (saved instantly, machine-local)." />
|
||||
<div className="space-y-4 max-w-lg">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={autofocusWB}
|
||||
onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); localStorage.setItem('opslog.autofocusWB', v ? '1' : '0'); }}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Auto-focus "Worked before" for stations already worked
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
When you type a callsign you've contacted before, OpsLog jumps to the Worked before tab. Turn off to stay on your current tab.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">ClubLog exceptions (DXpedition overrides)</h4>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={clubInfo.enabled}
|
||||
onCheckedChange={async (c) => {
|
||||
const v = !!c; setClubInfo((s) => ({ ...s, enabled: v })); setClubErr('');
|
||||
try {
|
||||
await SetClublogCtyEnabled(v);
|
||||
let info = (await GetClublogCtyInfo()) as ClubInfo;
|
||||
// First enable with no cached data → download it now.
|
||||
if (v && !info.loaded) {
|
||||
setClubBusy(true);
|
||||
try { info = (await DownloadClublogCty()) as ClubInfo; }
|
||||
finally { setClubBusy(false); }
|
||||
}
|
||||
setClubInfo(info);
|
||||
} catch (e: any) { setClubErr(String(e?.message ?? e)); }
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Use ClubLog Country File for callsign resolution
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Applies ClubLog's date-ranged full-callsign <strong>exceptions</strong> that cty.dat lacks — e.g. VK2/SP9FIH
|
||||
resolves to Lord Howe Island (not Australia) for the DXpedition dates. Used on entry, import, UDP, and the
|
||||
right-click <em>Update from ClubLog</em>.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3 pl-6">
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
|
||||
onClick={() => { setClubBusy(true); setClubErr(''); DownloadClublogCty().then((i) => setClubInfo(i as ClubInfo)).catch((e: any) => setClubErr(String(e?.message ?? e))).finally(() => setClubBusy(false)); }}>
|
||||
{clubBusy ? 'Downloading…' : (clubInfo.loaded ? 'Update ClubLog data' : 'Download ClubLog data')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{clubInfo.loaded
|
||||
? `${clubInfo.count.toLocaleString()} exceptions${clubInfo.date ? ' · ' + clubInfo.date.slice(0, 10) : ''}`
|
||||
: 'not downloaded'}
|
||||
</span>
|
||||
</div>
|
||||
{clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Map sections to their content + icon (for placeholder).
|
||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||
general: GeneralPanel,
|
||||
station: StationPanel,
|
||||
profiles: ProfilesPanel,
|
||||
operating: OperatingPanelWrapper,
|
||||
@@ -2484,7 +2792,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
rotator: RotatorPanel,
|
||||
winkeyer: WinkeyerPanel,
|
||||
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
||||
audio: () => <ComingSoon id="audio" icon={Server} />,
|
||||
audio: AudioPanel,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -2527,7 +2835,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={save} disabled={saving || loading}>{saving ? 'Saving…' : 'Save all'}</Button>
|
||||
<Button variant="outline" onClick={() => save(false)} disabled={saving || loading}>{saving ? 'Saving…' : 'Save'}</Button>
|
||||
<Button onClick={() => save(true)} disabled={saving || loading}>{saving ? 'Saving…' : 'Save and close'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -49,6 +49,7 @@ type Props = {
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
};
|
||||
|
||||
@@ -62,7 +63,7 @@ function fmtDate(s: any): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onSendTo }: Props) {
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo }: Props) {
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
@@ -233,6 +234,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
onClose={() => setMenu(null)}
|
||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||
onUpdateFromClublog={onUpdateFromClublog}
|
||||
onSendTo={onSendTo}
|
||||
/>
|
||||
|
||||
|
||||
Vendored
+45
@@ -8,6 +8,7 @@ import {cat} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
import {winkeyer} from '../models';
|
||||
import {audio} from '../models';
|
||||
import {operating} from '../models';
|
||||
import {udp} from '../models';
|
||||
import {lookup} from '../models';
|
||||
@@ -30,6 +31,18 @@ export function CountQSO():Promise<number>;
|
||||
|
||||
export function CreateDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function DVKCancelRecord():Promise<void>;
|
||||
|
||||
export function DVKPlay(arg1:number):Promise<void>;
|
||||
|
||||
export function DVKPreview(arg1:number):Promise<void>;
|
||||
|
||||
export function DVKStartRecord(arg1:number):Promise<void>;
|
||||
|
||||
export function DVKStop():Promise<void>;
|
||||
|
||||
export function DVKStopRecord():Promise<void>;
|
||||
|
||||
export function DXCCForCountry(arg1:string):Promise<number>;
|
||||
|
||||
export function DeleteAllQSO():Promise<number>;
|
||||
@@ -50,6 +63,8 @@ export function DisconnectAllClusters():Promise<void>;
|
||||
|
||||
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
||||
|
||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function DownloadConfirmations(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||
@@ -60,18 +75,26 @@ export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.Upl
|
||||
|
||||
export function GetActiveProfile():Promise<profile.Profile>;
|
||||
|
||||
export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||
|
||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||
|
||||
export function GetCATSettings():Promise<main.CATSettings>;
|
||||
|
||||
export function GetCATState():Promise<cat.RigState>;
|
||||
|
||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function GetClusterAutoConnect():Promise<boolean>;
|
||||
|
||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||
|
||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
|
||||
|
||||
export function GetDVKStatus():Promise<main.DVKStatus>;
|
||||
|
||||
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
||||
|
||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
@@ -102,6 +125,10 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.ImportResult>;
|
||||
|
||||
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
||||
|
||||
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
|
||||
|
||||
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
|
||||
|
||||
export function ListCountries():Promise<Array<string>>;
|
||||
@@ -132,12 +159,18 @@ export function OpenExternalURL(arg1:string):Promise<void>;
|
||||
|
||||
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
|
||||
|
||||
export function PickAudioFolder():Promise<string>;
|
||||
|
||||
export function PickBackupFolder():Promise<string>;
|
||||
|
||||
export function PickOpenDatabase():Promise<string>;
|
||||
|
||||
export function PickSaveDatabase():Promise<string>;
|
||||
|
||||
export function QSOAudioBegin():Promise<void>;
|
||||
|
||||
export function QSOAudioCancel():Promise<void>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||
@@ -146,6 +179,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||
|
||||
export function ResetDatabaseToDefault():Promise<void>;
|
||||
|
||||
export function RestartQSORecorder():Promise<void>;
|
||||
|
||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function RotatorPark():Promise<void>;
|
||||
@@ -156,6 +191,8 @@ export function RunBackupNow():Promise<string>;
|
||||
|
||||
export function SaveADIFFile():Promise<string>;
|
||||
|
||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||
|
||||
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
|
||||
|
||||
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||
@@ -192,10 +229,14 @@ export function SetCATFrequency(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
@@ -206,12 +247,16 @@ export function TestLoTWUpload():Promise<string>;
|
||||
|
||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||
|
||||
export function TestPTT():Promise<void>;
|
||||
|
||||
export function TestQRZUpload():Promise<string>;
|
||||
|
||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||
|
||||
export function UpdateQSOsFromClublog(arg1:Array<number>):Promise<number>;
|
||||
|
||||
export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
|
||||
|
||||
export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
|
||||
|
||||
@@ -38,6 +38,30 @@ export function CreateDatabase(arg1) {
|
||||
return window['go']['main']['App']['CreateDatabase'](arg1);
|
||||
}
|
||||
|
||||
export function DVKCancelRecord() {
|
||||
return window['go']['main']['App']['DVKCancelRecord']();
|
||||
}
|
||||
|
||||
export function DVKPlay(arg1) {
|
||||
return window['go']['main']['App']['DVKPlay'](arg1);
|
||||
}
|
||||
|
||||
export function DVKPreview(arg1) {
|
||||
return window['go']['main']['App']['DVKPreview'](arg1);
|
||||
}
|
||||
|
||||
export function DVKStartRecord(arg1) {
|
||||
return window['go']['main']['App']['DVKStartRecord'](arg1);
|
||||
}
|
||||
|
||||
export function DVKStop() {
|
||||
return window['go']['main']['App']['DVKStop']();
|
||||
}
|
||||
|
||||
export function DVKStopRecord() {
|
||||
return window['go']['main']['App']['DVKStopRecord']();
|
||||
}
|
||||
|
||||
export function DXCCForCountry(arg1) {
|
||||
return window['go']['main']['App']['DXCCForCountry'](arg1);
|
||||
}
|
||||
@@ -78,6 +102,10 @@ export function DisconnectClusterServer(arg1) {
|
||||
return window['go']['main']['App']['DisconnectClusterServer'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadClublogCty() {
|
||||
return window['go']['main']['App']['DownloadClublogCty']();
|
||||
}
|
||||
|
||||
export function DownloadConfirmations(arg1, arg2) {
|
||||
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2);
|
||||
}
|
||||
@@ -98,6 +126,10 @@ export function GetActiveProfile() {
|
||||
return window['go']['main']['App']['GetActiveProfile']();
|
||||
}
|
||||
|
||||
export function GetAudioSettings() {
|
||||
return window['go']['main']['App']['GetAudioSettings']();
|
||||
}
|
||||
|
||||
export function GetBackupSettings() {
|
||||
return window['go']['main']['App']['GetBackupSettings']();
|
||||
}
|
||||
@@ -110,6 +142,10 @@ export function GetCATState() {
|
||||
return window['go']['main']['App']['GetCATState']();
|
||||
}
|
||||
|
||||
export function GetClublogCtyInfo() {
|
||||
return window['go']['main']['App']['GetClublogCtyInfo']();
|
||||
}
|
||||
|
||||
export function GetClusterAutoConnect() {
|
||||
return window['go']['main']['App']['GetClusterAutoConnect']();
|
||||
}
|
||||
@@ -122,6 +158,14 @@ export function GetCtyDatInfo() {
|
||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||
}
|
||||
|
||||
export function GetDVKMessages() {
|
||||
return window['go']['main']['App']['GetDVKMessages']();
|
||||
}
|
||||
|
||||
export function GetDVKStatus() {
|
||||
return window['go']['main']['App']['GetDVKStatus']();
|
||||
}
|
||||
|
||||
export function GetDatabaseSettings() {
|
||||
return window['go']['main']['App']['GetDatabaseSettings']();
|
||||
}
|
||||
@@ -182,6 +226,14 @@ export function ImportADIF(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function ListAudioInputDevices() {
|
||||
return window['go']['main']['App']['ListAudioInputDevices']();
|
||||
}
|
||||
|
||||
export function ListAudioOutputDevices() {
|
||||
return window['go']['main']['App']['ListAudioOutputDevices']();
|
||||
}
|
||||
|
||||
export function ListClusterServers() {
|
||||
return window['go']['main']['App']['ListClusterServers']();
|
||||
}
|
||||
@@ -242,6 +294,10 @@ export function OperatingDefaultForBand(arg1) {
|
||||
return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
|
||||
}
|
||||
|
||||
export function PickAudioFolder() {
|
||||
return window['go']['main']['App']['PickAudioFolder']();
|
||||
}
|
||||
|
||||
export function PickBackupFolder() {
|
||||
return window['go']['main']['App']['PickBackupFolder']();
|
||||
}
|
||||
@@ -254,6 +310,14 @@ export function PickSaveDatabase() {
|
||||
return window['go']['main']['App']['PickSaveDatabase']();
|
||||
}
|
||||
|
||||
export function QSOAudioBegin() {
|
||||
return window['go']['main']['App']['QSOAudioBegin']();
|
||||
}
|
||||
|
||||
export function QSOAudioCancel() {
|
||||
return window['go']['main']['App']['QSOAudioCancel']();
|
||||
}
|
||||
|
||||
export function QuitApp() {
|
||||
return window['go']['main']['App']['QuitApp']();
|
||||
}
|
||||
@@ -270,6 +334,10 @@ export function ResetDatabaseToDefault() {
|
||||
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
||||
}
|
||||
|
||||
export function RestartQSORecorder() {
|
||||
return window['go']['main']['App']['RestartQSORecorder']();
|
||||
}
|
||||
|
||||
export function RotatorGoTo(arg1, arg2) {
|
||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||
}
|
||||
@@ -290,6 +358,10 @@ export function SaveADIFFile() {
|
||||
return window['go']['main']['App']['SaveADIFFile']();
|
||||
}
|
||||
|
||||
export function SaveAudioSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveBackupSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveBackupSettings'](arg1);
|
||||
}
|
||||
@@ -362,6 +434,10 @@ export function SetCATMode(arg1) {
|
||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||
}
|
||||
|
||||
export function SetClublogCtyEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetClusterAutoConnect(arg1) {
|
||||
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
|
||||
}
|
||||
@@ -370,6 +446,10 @@ export function SetCompactMode(arg1) {
|
||||
return window['go']['main']['App']['SetCompactMode'](arg1);
|
||||
}
|
||||
|
||||
export function SetDVKLabel(arg1, arg2) {
|
||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SetUIPref(arg1, arg2) {
|
||||
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
|
||||
}
|
||||
@@ -390,6 +470,10 @@ export function TestLookupProvider(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function TestPTT() {
|
||||
return window['go']['main']['App']['TestPTT']();
|
||||
}
|
||||
|
||||
export function TestQRZUpload() {
|
||||
return window['go']['main']['App']['TestQRZUpload']();
|
||||
}
|
||||
@@ -402,6 +486,10 @@ export function UpdateQSO(arg1) {
|
||||
return window['go']['main']['App']['UpdateQSO'](arg1);
|
||||
}
|
||||
|
||||
export function UpdateQSOsFromClublog(arg1) {
|
||||
return window['go']['main']['App']['UpdateQSOsFromClublog'](arg1);
|
||||
}
|
||||
|
||||
export function UpdateQSOsFromCty(arg1) {
|
||||
return window['go']['main']['App']['UpdateQSOsFromCty'](arg1);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,27 @@ export namespace adif {
|
||||
|
||||
}
|
||||
|
||||
export namespace audio {
|
||||
|
||||
export class Device {
|
||||
id: string;
|
||||
name: string;
|
||||
default: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Device(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.default = source["default"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace cat {
|
||||
|
||||
export class RigState {
|
||||
@@ -353,6 +374,36 @@ export namespace lookup {
|
||||
|
||||
export namespace main {
|
||||
|
||||
export class AudioSettings {
|
||||
from_radio: string;
|
||||
to_radio: string;
|
||||
recording_device: string;
|
||||
listening_device: string;
|
||||
qso_record: boolean;
|
||||
qso_dir: string;
|
||||
preroll_seconds: number;
|
||||
ptt_method: string;
|
||||
ptt_port: string;
|
||||
format: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AudioSettings(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.from_radio = source["from_radio"];
|
||||
this.to_radio = source["to_radio"];
|
||||
this.recording_device = source["recording_device"];
|
||||
this.listening_device = source["listening_device"];
|
||||
this.qso_record = source["qso_record"];
|
||||
this.qso_dir = source["qso_dir"];
|
||||
this.preroll_seconds = source["preroll_seconds"];
|
||||
this.ptt_method = source["ptt_method"];
|
||||
this.ptt_port = source["ptt_port"];
|
||||
this.format = source["format"];
|
||||
}
|
||||
}
|
||||
export class BackupSettings {
|
||||
enabled: boolean;
|
||||
folder: string;
|
||||
@@ -397,6 +448,24 @@ export namespace main {
|
||||
this.digital_default = source["digital_default"];
|
||||
}
|
||||
}
|
||||
export class ClublogCtyInfo {
|
||||
enabled: boolean;
|
||||
loaded: boolean;
|
||||
date: string;
|
||||
count: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ClublogCtyInfo(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.loaded = source["loaded"];
|
||||
this.date = source["date"];
|
||||
this.count = source["count"];
|
||||
}
|
||||
}
|
||||
export class CtyDatInfo {
|
||||
path: string;
|
||||
entities: number;
|
||||
@@ -415,6 +484,40 @@ export namespace main {
|
||||
this.file_mod_time = source["file_mod_time"];
|
||||
}
|
||||
}
|
||||
export class DVKMessage {
|
||||
slot: number;
|
||||
label: string;
|
||||
has_audio: boolean;
|
||||
duration_sec: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new DVKMessage(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.slot = source["slot"];
|
||||
this.label = source["label"];
|
||||
this.has_audio = source["has_audio"];
|
||||
this.duration_sec = source["duration_sec"];
|
||||
}
|
||||
}
|
||||
export class DVKStatus {
|
||||
recording: boolean;
|
||||
playing: boolean;
|
||||
rec_slot: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new DVKStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.recording = source["recording"];
|
||||
this.playing = source["playing"];
|
||||
this.rec_slot = source["rec_slot"];
|
||||
}
|
||||
}
|
||||
export class DatabaseSettings {
|
||||
path: string;
|
||||
default_path: string;
|
||||
|
||||
@@ -3,7 +3,9 @@ module hamlog
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/braheezy/shine-mp3 v0.1.0
|
||||
github.com/go-ole/go-ole v1.3.0
|
||||
github.com/moutend/go-wca v0.3.0
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
go.bug.st/serial v1.7.1
|
||||
golang.org/x/net v0.35.0
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
|
||||
github.com/braheezy/shine-mp3 v0.1.0/go.mod h1:0H/pmcpFAd+Fnrj6Pc7du7wL36U/HqtfcgPJuCgc1L4=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
@@ -40,6 +43,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/moutend/go-wca v0.3.0 h1:IzhsQ44zBzMdT42xlBjiLSVya9cPYOoKx9E+yXVhFo8=
|
||||
github.com/moutend/go-wca v0.3.0/go.mod h1:7VrPO512jnjFGJ6rr+zOoCfiYjOHRPNfbttJuxAurcw=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
@@ -80,6 +85,7 @@ golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user