diff --git a/app.go b/app.go index 4831c79..deaba23 100644 --- a/app.go +++ b/app.go @@ -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 +// /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 /dvk/dvk.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) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c706309..b47a618 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]); + const [dvkStat, setDvkStat] = useState({ 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} /> )} - {/* 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) && (
+ {dvkEnabled && ( +
+ DVKStop()} + onClose={() => setDvkEnabled(false)} + /> +
+ )} {wkEnabled && (
+
+ +
+ +
+ {!anyAudio ? ( +
+ + No messages recorded yet. Open Settings → Audio devices & voice keyer to record F1–F6. +
+ ) : ( +
+ {messages.map((m) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/QSOContextMenu.tsx b/frontend/src/components/QSOContextMenu.tsx index 891e518..a186aa7 100644 --- a/frontend/src/components/QSOContextMenu.tsx +++ b/frontend/src/components/QSOContextMenu.tsx @@ -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 Update from QRZ.com + {onUpdateFromClublog && ( + + )} {onSendTo && ( <> diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index ee439b8..cb34366 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -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(null); const [pickerOpen, setPickerOpen] = useState(false); const [menu, setMenu] = useState(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} /> diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e280aa7..dd12588 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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([]); const setWkField = (patch: Partial) => 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({ + 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([]); + const [audioOutputs, setAudioOutputs] = useState([]); + const setAudioField = (patch: Partial) => 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([]); + const [dvkStat, setDvkStat] = useState({ 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({ 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, + ) => ( + + ); + return ( + <> +
+ + +
+ +
+
+ + {deviceSelect('from_radio', audioInputs, 'Rig audio output → soundcard input')} + + {deviceSelect('to_radio', audioOutputs, 'Soundcard output → rig mic/data in')} + + {deviceSelect('recording_device', audioInputs, 'Your microphone (record DVK messages)')} + + {deviceSelect('listening_device', audioOutputs, 'Local speakers for preview')} +
+

+ From Radio = what you receive (used by the QSO recorder).{' '} + To Radio = where voice-keyer messages are transmitted. +

+
+ +
+

QSO recorder

+ +
+ +
+ setAudioField({ qso_dir: e.target.value })} + placeholder="C:\…\OpsLog\Recordings" className="h-8 font-mono text-xs" /> + +
+ + setAudioField({ preroll_seconds: Math.max(0, Math.min(60, parseInt(e.target.value, 10) || 0)) })} + className="h-8 w-24 font-mono" /> + + +
+

+ Files are named CALL_YYYYMMDD_HHMMSS.{audioCfg.format}. + {audioCfg.format === 'mp3' ? ' MP3 ≈ 7× smaller — handy to send to correspondents.' : ' WAV is lossless (~115 KB/min).'} +

+
+ +
+

Voice keyer messages (F1–F6)

+

+ Press and hold Rec while you speak (release to save). Preview on Listening; + during operation they transmit via To Radio. +

+
+
+ +
+ + {audioCfg.ptt_method !== 'none' && ( + + )} +
+ {(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && ( + <> + +
+ + +
+ + )} +
+

+ CAT (OmniRig) keys TX through the rig control (sets OmniRig's Tx parameter) — needs CAT + connected. Serial RTS/DTR asserts a COM line (e.g. a SmartSDR CAT port set to PTT-on-RTS). + None (VOX) lets the rig key on audio. Use Test PTT to confirm. +

+
+ {dvkErr &&

{dvkErr}

} +
+ {dvkMsgs.map((m) => { + const recHere = dvkStat.recording && dvkStat.rec_slot === m.slot; + const recBusy = dvkStat.recording && !recHere; + return ( +
+ F{m.slot} + setDvkMsgs((ms) => ms.map((x) => x.slot === m.slot ? { ...x, label: e.target.value } : x))} + onBlur={(e) => SetDVKLabel(m.slot, e.target.value).catch(() => {})} + /> + + {m.has_audio ? `✓ ${m.duration_sec.toFixed(1)}s` : '—'} + + + +
+ ); + })} +
+
+ + ); + } + + function GeneralPanel() { + return ( + <> + +
+ + +
+

ClubLog exceptions (DXpedition overrides)

+ +
+ + + {clubInfo.loaded + ? `${clubInfo.count.toLocaleString()} exceptions${clubInfo.date ? ' · ' + clubInfo.date.slice(0, 10) : ''}` + : 'not downloaded'} + +
+ {clubErr &&

{clubErr}

} +
+
+ + ); + } + // Map sections to their content + icon (for placeholder). const PANELS: Record 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: () => , - audio: () => , + audio: AudioPanel, }; return ( @@ -2527,7 +2835,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { - + + diff --git a/frontend/src/components/WorkedBeforeGrid.tsx b/frontend/src/components/WorkedBeforeGrid.tsx index 5f5bc7c..2d639ef 100644 --- a/frontend/src/components/WorkedBeforeGrid.tsx +++ b/frontend/src/components/WorkedBeforeGrid.tsx @@ -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(null); const [pickerOpen, setPickerOpen] = useState(false); const [menu, setMenu] = useState(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} /> diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index a2ccdfc..3709a70 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -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; export function CreateDatabase(arg1:string):Promise; +export function DVKCancelRecord():Promise; + +export function DVKPlay(arg1:number):Promise; + +export function DVKPreview(arg1:number):Promise; + +export function DVKStartRecord(arg1:number):Promise; + +export function DVKStop():Promise; + +export function DVKStopRecord():Promise; + export function DXCCForCountry(arg1:string):Promise; export function DeleteAllQSO():Promise; @@ -50,6 +63,8 @@ export function DisconnectAllClusters():Promise; export function DisconnectClusterServer(arg1:number):Promise; +export function DownloadClublogCty():Promise; + export function DownloadConfirmations(arg1:string,arg2:boolean):Promise; export function DuplicateProfile(arg1:number,arg2:string):Promise; @@ -60,18 +75,26 @@ export function FindQSOsForUpload(arg1:string,arg2:string):Promise; +export function GetAudioSettings():Promise; + export function GetBackupSettings():Promise; export function GetCATSettings():Promise; export function GetCATState():Promise; +export function GetClublogCtyInfo():Promise; + export function GetClusterAutoConnect():Promise; export function GetClusterStatus():Promise>; export function GetCtyDatInfo():Promise; +export function GetDVKMessages():Promise>; + +export function GetDVKStatus():Promise; + export function GetDatabaseSettings():Promise; export function GetExternalServices():Promise; @@ -102,6 +125,10 @@ export function GetWinkeyerStatus():Promise; export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise; +export function ListAudioInputDevices():Promise>; + +export function ListAudioOutputDevices():Promise>; + export function ListClusterServers():Promise>; export function ListCountries():Promise>; @@ -132,12 +159,18 @@ export function OpenExternalURL(arg1:string):Promise; export function OperatingDefaultForBand(arg1:string):Promise; +export function PickAudioFolder():Promise; + export function PickBackupFolder():Promise; export function PickOpenDatabase():Promise; export function PickSaveDatabase():Promise; +export function QSOAudioBegin():Promise; + +export function QSOAudioCancel():Promise; + export function QuitApp():Promise; export function RefreshCtyDat():Promise; @@ -146,6 +179,8 @@ export function ReloadUDPIntegrations():Promise>; export function ResetDatabaseToDefault():Promise; +export function RestartQSORecorder():Promise; + export function RotatorGoTo(arg1:number,arg2:number):Promise; export function RotatorPark():Promise; @@ -156,6 +191,8 @@ export function RunBackupNow():Promise; export function SaveADIFFile():Promise; +export function SaveAudioSettings(arg1:main.AudioSettings):Promise; + export function SaveBackupSettings(arg1:main.BackupSettings):Promise; export function SaveCATSettings(arg1:main.CATSettings):Promise; @@ -192,10 +229,14 @@ export function SetCATFrequency(arg1:number):Promise; export function SetCATMode(arg1:string):Promise; +export function SetClublogCtyEnabled(arg1:boolean):Promise; + export function SetClusterAutoConnect(arg1:boolean):Promise; export function SetCompactMode(arg1:boolean):Promise; +export function SetDVKLabel(arg1:number,arg2:string):Promise; + export function SetUIPref(arg1:string,arg2:string):Promise; export function SwitchCATRig(arg1:number):Promise; @@ -206,12 +247,16 @@ export function TestLoTWUpload():Promise; export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise; +export function TestPTT():Promise; + export function TestQRZUpload():Promise; export function TestRotator(arg1:main.RotatorSettings):Promise; export function UpdateQSO(arg1:qso.QSO):Promise; +export function UpdateQSOsFromClublog(arg1:Array):Promise; + export function UpdateQSOsFromCty(arg1:Array):Promise; export function UpdateQSOsFromQRZ(arg1:Array):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 5b4cdb7..b6f8ff5 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -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); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 64aeed8..48f4a9c 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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; diff --git a/go.mod b/go.mod index 0ef058b..21a2b35 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index b6469b9..9b5a553 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/audio/devices.go b/internal/audio/devices.go new file mode 100644 index 0000000..611ffee --- /dev/null +++ b/internal/audio/devices.go @@ -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 +} diff --git a/internal/audio/engine.go b/internal/audio/engine.go new file mode 100644 index 0000000..d6d0a9f --- /dev/null +++ b/internal/audio/engine.go @@ -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) + } +} diff --git a/internal/audio/manager.go b/internal/audio/manager.go new file mode 100644 index 0000000..f1a0790 --- /dev/null +++ b/internal/audio/manager.go @@ -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() + } +} diff --git a/internal/audio/mp3.go b/internal/audio/mp3.go new file mode 100644 index 0000000..922bf1e --- /dev/null +++ b/internal/audio/mp3.go @@ -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 +} diff --git a/internal/audio/recorder.go b/internal/audio/recorder.go new file mode 100644 index 0000000..16d8dc8 --- /dev/null +++ b/internal/audio/recorder.go @@ -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 +} diff --git a/internal/audio/wav.go b/internal/audio/wav.go new file mode 100644 index 0000000..ee318cf --- /dev/null +++ b/internal/audio/wav.go @@ -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 +} diff --git a/internal/cat/cat.go b/internal/cat/cat.go index 74d2649..8ddcb8b 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -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 { diff --git a/internal/cat/omnirig.go b/internal/cat/omnirig.go index 94005fc..655e5a3 100644 --- a/internal/cat/omnirig.go +++ b/internal/cat/omnirig.go @@ -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 diff --git a/internal/clublog/clublog.go b/internal/clublog/clublog.go new file mode 100644 index 0000000..5ec38fa --- /dev/null +++ b/internal/clublog/clublog.go @@ -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 +// 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 +} diff --git a/internal/clublog/manager.go b/internal/clublog/manager.go new file mode 100644 index 0000000..30636fd --- /dev/null +++ b/internal/clublog/manager.go @@ -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) +} diff --git a/internal/dxcc/adif_numbers.go b/internal/dxcc/adif_numbers.go index 3f553b0..4b287a3 100644 --- a/internal/dxcc/adif_numbers.go +++ b/internal/dxcc/adif_numbers.go @@ -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", diff --git a/internal/dxcc/dxcc_names_gen.go b/internal/dxcc/dxcc_names_gen.go new file mode 100644 index 0000000..7555b68 --- /dev/null +++ b/internal/dxcc/dxcc_names_gen.go @@ -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, +}