feat: added record qso dvk
This commit is contained in:
@@ -12,12 +12,15 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/adif"
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/backup"
|
||||
"hamlog/internal/audio"
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/clublog"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/extsvc"
|
||||
@@ -32,6 +35,7 @@ import (
|
||||
"hamlog/internal/settings"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// Setting keys.
|
||||
@@ -68,6 +72,26 @@ const (
|
||||
keyCATDelayMs = "cat.delay_ms" // pause between commands
|
||||
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
||||
|
||||
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
|
||||
// global (not per-profile) like CAT/rotator. Device fields store the
|
||||
// WASAPI endpoint id; the UI resolves it to a friendly name.
|
||||
keyAudioFromRadio = "audio.from_radio" // capture: rig RX audio in
|
||||
keyAudioToRadio = "audio.to_radio" // render: DVK plays into rig
|
||||
keyAudioRecDevice = "audio.rec_device" // capture: your mic (record DVK msgs)
|
||||
keyAudioListenDevice = "audio.listen_device" // render: local preview speakers
|
||||
keyAudioQSORecord = "audio.qso_record" // "1" → auto-record every QSO
|
||||
keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings
|
||||
keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length
|
||||
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
|
||||
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
|
||||
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
|
||||
|
||||
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
|
||||
|
||||
// clublogAppAPIKey is OpsLog's ClubLog API key, also used for the country
|
||||
// file download. Visible in the binary but must not be exposed publicly.
|
||||
clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"
|
||||
|
||||
keyRotatorEnabled = "rotator.enabled"
|
||||
keyRotatorHost = "rotator.host"
|
||||
keyRotatorPort = "rotator.port"
|
||||
@@ -289,6 +313,14 @@ type App struct {
|
||||
udpRepo *udp.Repo
|
||||
extsvc *extsvc.Manager
|
||||
winkeyer *winkeyer.Manager
|
||||
clublog *clublog.Manager
|
||||
audioMgr *audio.Manager
|
||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||
pttMu sync.Mutex
|
||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
dbPath string // active database file (may be a user-chosen location)
|
||||
dataDir string // %APPDATA%/OpsLog — holds config.json, logs, cty.dat
|
||||
@@ -495,6 +527,16 @@ func (a *App) startup(ctx context.Context) {
|
||||
}
|
||||
fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities")
|
||||
}()
|
||||
// ClubLog Country File (cty.xml) — date-ranged callsign exceptions that
|
||||
// cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on
|
||||
// demand. Resolution applied only when the user enables it.
|
||||
a.clublog = clublog.NewManager(clublogAppAPIKey, dataDir)
|
||||
go func() {
|
||||
if err := a.clublog.EnsureLoaded(); err == nil {
|
||||
d, n := a.clublog.Info()
|
||||
fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d)
|
||||
}
|
||||
}()
|
||||
// CAT manager: emit pushes state to the frontend via Wails events.
|
||||
a.cat = cat.NewManager(func(s cat.RigState) {
|
||||
if a.ctx != nil {
|
||||
@@ -570,6 +612,21 @@ func (a *App) startup(ctx context.Context) {
|
||||
},
|
||||
)
|
||||
|
||||
// Digital Voice Keyer + QSO recorder (WASAPI). Idle until used.
|
||||
a.audioMgr = audio.NewManager(func() {
|
||||
st := a.dvkStatus()
|
||||
// When a voice message finishes (or is stopped), drop CAT PTT.
|
||||
if !st.Playing && a.dvkPttKeyed {
|
||||
a.dvkPttKeyed = false
|
||||
go a.dvkUnkeyPTT()
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "audio:status", st)
|
||||
}
|
||||
})
|
||||
a.qsoRec = audio.NewRecorder()
|
||||
a.startQSORecorderIfEnabled()
|
||||
|
||||
fmt.Println("OpsLog: db ready at", a.dbPath)
|
||||
}
|
||||
|
||||
@@ -721,6 +778,9 @@ func (a *App) shutdown(ctx context.Context) {
|
||||
if a.winkeyer != nil {
|
||||
a.winkeyer.Disconnect()
|
||||
}
|
||||
if a.qsoRec != nil {
|
||||
a.qsoRec.Stop()
|
||||
}
|
||||
if a.db != nil {
|
||||
_ = a.db.Close()
|
||||
}
|
||||
@@ -971,10 +1031,14 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) {
|
||||
}
|
||||
a.applyStationDefaults(&q)
|
||||
a.applyDXCCNumber(&q)
|
||||
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
|
||||
a.applyQSLDefaults(&q)
|
||||
id, err := a.qso.Add(a.ctx, q)
|
||||
if err == nil && a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
if err == nil {
|
||||
a.saveQSORecording(q.Callsign)
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
@@ -1309,8 +1373,17 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.Impor
|
||||
default: // "skip"
|
||||
im.SkipDuplicates = true
|
||||
}
|
||||
// When the user opts to fix countries on import, recompute from cty.dat and
|
||||
// then apply ClubLog's date-ranged exceptions (which take precedence) if
|
||||
// ClubLog is enabled + loaded. Unchecked = ADIF preserved verbatim.
|
||||
clEnabled := a.clublogCtyEnabled() && a.clublog != nil && a.clublog.Loaded()
|
||||
if applyCty {
|
||||
im.Enrich = func(q *qso.QSO) { a.enrichContactedFromCtyForce(q) }
|
||||
im.Enrich = func(q *qso.QSO) {
|
||||
a.enrichContactedFromCtyForce(q)
|
||||
if clEnabled {
|
||||
a.applyClublogException(q, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
im.OnProgress = func(processed, total int) {
|
||||
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
|
||||
@@ -1369,6 +1442,25 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||||
r.ImageURL = ""
|
||||
}
|
||||
}
|
||||
// ClubLog exception override (live entry → today's date): for an active
|
||||
// DXpedition the entered call gets the right entity/zones immediately.
|
||||
if a.clublogCtyEnabled() && a.clublog != nil {
|
||||
if e, ok := a.clublog.Resolve(callsign, time.Now().UTC()); ok {
|
||||
r.Country = titleEntity(e.Entity)
|
||||
if e.Cont != "" {
|
||||
r.Continent = e.Cont
|
||||
}
|
||||
if e.ADIF != 0 {
|
||||
r.DXCC = e.ADIF
|
||||
}
|
||||
if e.CQZ != 0 {
|
||||
r.CQZ = e.CQZ
|
||||
}
|
||||
if r.Callsign == "" {
|
||||
r.Callsign = strings.ToUpper(strings.TrimSpace(callsign))
|
||||
}
|
||||
}
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
@@ -1560,6 +1652,596 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Audio (Digital Voice Keyer + QSO recorder) ────────────────────────
|
||||
|
||||
// AudioSettings is the machine-local audio config for the voice keyer and
|
||||
// the QSO recorder.
|
||||
type AudioSettings struct {
|
||||
FromRadio string `json:"from_radio"` // capture id: rig RX audio
|
||||
ToRadio string `json:"to_radio"` // render id: into the rig
|
||||
RecordingDevice string `json:"recording_device"` // capture id: your mic
|
||||
ListeningDevice string `json:"listening_device"` // render id: preview
|
||||
QSORecord bool `json:"qso_record"` // auto-record every QSO
|
||||
QSODir string `json:"qso_dir"` // recordings folder
|
||||
PrerollSeconds int `json:"preroll_seconds"` // rolling pre-roll (default 8)
|
||||
PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr"
|
||||
PTTPort string `json:"ptt_port"` // COM port for serial PTT
|
||||
Format string `json:"format"` // "wav" | "mp3"
|
||||
}
|
||||
|
||||
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
|
||||
// for the device dropdowns.
|
||||
func (a *App) ListAudioInputDevices() ([]audio.Device, error) { return audio.ListInputDevices() }
|
||||
func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.ListOutputDevices() }
|
||||
|
||||
// GetAudioSettings returns the stored audio config (preroll defaults to 8s).
|
||||
func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav"}
|
||||
if a.settings == nil {
|
||||
return out, nil
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
|
||||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if v := m[keyAudioFormat]; v == "mp3" || v == "wav" {
|
||||
out.Format = v
|
||||
}
|
||||
if v := m[keyAudioPTTMethod]; v == "rts" || v == "dtr" || v == "cat" || v == "none" {
|
||||
out.PTTMethod = v
|
||||
}
|
||||
out.PTTPort = m[keyAudioPTTPort]
|
||||
out.FromRadio = m[keyAudioFromRadio]
|
||||
out.ToRadio = m[keyAudioToRadio]
|
||||
out.RecordingDevice = m[keyAudioRecDevice]
|
||||
out.ListeningDevice = m[keyAudioListenDevice]
|
||||
out.QSORecord = m[keyAudioQSORecord] == "1"
|
||||
out.QSODir = m[keyAudioQSODir]
|
||||
if n, _ := strconv.Atoi(m[keyAudioPreroll]); n >= 0 && n <= 60 {
|
||||
if n > 0 {
|
||||
out.PrerollSeconds = n
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveAudioSettings persists the audio config.
|
||||
func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if s.PrerollSeconds < 0 || s.PrerollSeconds > 60 {
|
||||
s.PrerollSeconds = 8
|
||||
}
|
||||
qr := "0"
|
||||
if s.QSORecord {
|
||||
qr = "1"
|
||||
}
|
||||
pttMethod := s.PTTMethod
|
||||
if pttMethod != "rts" && pttMethod != "dtr" && pttMethod != "cat" {
|
||||
pttMethod = "none"
|
||||
}
|
||||
format := s.Format
|
||||
if format != "mp3" {
|
||||
format = "wav"
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyAudioFromRadio: s.FromRadio,
|
||||
keyAudioToRadio: s.ToRadio,
|
||||
keyAudioRecDevice: s.RecordingDevice,
|
||||
keyAudioListenDevice: s.ListeningDevice,
|
||||
keyAudioQSORecord: qr,
|
||||
keyAudioQSODir: strings.TrimSpace(s.QSODir),
|
||||
keyAudioPreroll: strconv.Itoa(s.PrerollSeconds),
|
||||
keyAudioPTTMethod: pttMethod,
|
||||
keyAudioPTTPort: strings.TrimSpace(s.PTTPort),
|
||||
keyAudioFormat: format,
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Apply device/preroll/enable changes to the running recorder.
|
||||
a.startQSORecorderIfEnabled()
|
||||
return nil
|
||||
}
|
||||
|
||||
// PickAudioFolder opens a directory picker for the QSO-recordings folder.
|
||||
func (a *App) PickAudioFolder() (string, error) {
|
||||
if a.ctx == nil {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
cur, _ := a.GetAudioSettings()
|
||||
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Pick a folder for QSO recordings",
|
||||
DefaultDirectory: firstExistingAncestor(cur.QSODir),
|
||||
})
|
||||
}
|
||||
|
||||
// ── QSO recorder ──────────────────────────────────────────────────────
|
||||
|
||||
// startQSORecorderIfEnabled (re)starts the continuous recorder per the current
|
||||
// settings. Safe to call repeatedly — it stops any running instance first.
|
||||
func (a *App) startQSORecorderIfEnabled() {
|
||||
if a.qsoRec == nil {
|
||||
return
|
||||
}
|
||||
a.qsoRec.Stop()
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
if !cfg.QSORecord {
|
||||
return
|
||||
}
|
||||
if err := a.qsoRec.Start(cfg.FromRadio, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil {
|
||||
applog.Printf("qso-rec: start failed: %v", err)
|
||||
return
|
||||
}
|
||||
applog.Printf("qso-rec: running (preroll %ds, mix=%v)", cfg.PrerollSeconds, cfg.RecordingDevice != "" && cfg.RecordingDevice != cfg.FromRadio)
|
||||
}
|
||||
|
||||
// qsoRecDir returns the configured recordings folder, defaulting to
|
||||
// <dataDir>/Recordings, and ensures it exists.
|
||||
func (a *App) qsoRecDir() string {
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
d := strings.TrimSpace(cfg.QSODir)
|
||||
if d == "" {
|
||||
d = filepath.Join(a.dataDir, "Recordings")
|
||||
}
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
return d
|
||||
}
|
||||
|
||||
// saveQSORecording finalises the active recording (if any) into a WAV named
|
||||
// after the callsign. Called right after a QSO is inserted (manual + UDP).
|
||||
func (a *App) saveQSORecording(call string) {
|
||||
if a.qsoRec == nil || !a.qsoRec.Active() {
|
||||
return
|
||||
}
|
||||
ext := "wav"
|
||||
if cfg, _ := a.GetAudioSettings(); cfg.Format == "mp3" {
|
||||
ext = "mp3"
|
||||
}
|
||||
name := fmt.Sprintf("%s_%s.%s", sanitizeFilename(call), time.Now().UTC().Format("20060102_150405"), ext)
|
||||
path := filepath.Join(a.qsoRecDir(), name)
|
||||
if err := a.qsoRec.SaveQSO(path); err != nil {
|
||||
applog.Printf("qso-rec: save failed: %v", err)
|
||||
return
|
||||
}
|
||||
applog.Printf("qso-rec: saved %s", path)
|
||||
}
|
||||
|
||||
// sanitizeFilename makes a callsign safe for a filename (slashes etc.).
|
||||
func sanitizeFilename(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
s = "QSO"
|
||||
}
|
||||
repl := func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\', ':', '*', '?', '"', '<', '>', '|', ' ':
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}
|
||||
return strings.Map(repl, s)
|
||||
}
|
||||
|
||||
// QSOAudioBegin starts accumulating a QSO recording (seeded with the pre-roll).
|
||||
// Called by the entry strip when a callsign is first entered.
|
||||
func (a *App) QSOAudioBegin() {
|
||||
if a.qsoRec != nil {
|
||||
a.qsoRec.BeginQSO()
|
||||
}
|
||||
}
|
||||
|
||||
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
|
||||
// abandoned without logging).
|
||||
func (a *App) QSOAudioCancel() {
|
||||
if a.qsoRec != nil {
|
||||
a.qsoRec.DiscardQSO()
|
||||
}
|
||||
}
|
||||
|
||||
// RestartQSORecorder applies new audio settings to the running recorder.
|
||||
func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() }
|
||||
|
||||
// ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
|
||||
|
||||
// ClublogCtyInfo is the UI status of the ClubLog exception data.
|
||||
type ClublogCtyInfo struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Loaded bool `json:"loaded"`
|
||||
Date string `json:"date"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func (a *App) clublogCtyEnabled() bool {
|
||||
if a.settings == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := a.settings.Get(a.ctx, keyClublogCtyEnabled)
|
||||
return v == "1"
|
||||
}
|
||||
|
||||
// GetClublogCtyInfo returns the current ClubLog exception status.
|
||||
func (a *App) GetClublogCtyInfo() ClublogCtyInfo {
|
||||
info := ClublogCtyInfo{Enabled: a.clublogCtyEnabled()}
|
||||
if a.clublog != nil {
|
||||
info.Loaded = a.clublog.Loaded()
|
||||
info.Date, info.Count = a.clublog.Info()
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// SetClublogCtyEnabled toggles ClubLog exception resolution, loading the cached
|
||||
// file on first enable.
|
||||
func (a *App) SetClublogCtyEnabled(on bool) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
v := "0"
|
||||
if on {
|
||||
v = "1"
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyClublogCtyEnabled, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if on && a.clublog != nil && !a.clublog.Loaded() {
|
||||
_ = a.clublog.EnsureLoaded() // ok if file not downloaded yet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadClublogCty fetches a fresh ClubLog country file.
|
||||
func (a *App) DownloadClublogCty() (ClublogCtyInfo, error) {
|
||||
if a.clublog == nil {
|
||||
return ClublogCtyInfo{}, fmt.Errorf("clublog not initialized")
|
||||
}
|
||||
if err := a.clublog.Download(a.ctx); err != nil {
|
||||
return a.GetClublogCtyInfo(), err
|
||||
}
|
||||
return a.GetClublogCtyInfo(), nil
|
||||
}
|
||||
|
||||
// applyClublogException overrides a QSO's entity fields from a ClubLog
|
||||
// exception matching its callsign at its date. force=true ignores the
|
||||
// enable toggle (used by the explicit "Update from ClubLog" action).
|
||||
// Returns true if something changed.
|
||||
func (a *App) applyClublogException(q *qso.QSO, force bool) bool {
|
||||
if a.clublog == nil || q.Callsign == "" {
|
||||
return false
|
||||
}
|
||||
if !force && !a.clublogCtyEnabled() {
|
||||
return false
|
||||
}
|
||||
date := q.QSODate
|
||||
if date.IsZero() {
|
||||
date = time.Now().UTC()
|
||||
}
|
||||
e, ok := a.clublog.Resolve(q.Callsign, date)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
q.Country = titleEntity(e.Entity)
|
||||
if e.Cont != "" {
|
||||
q.Continent = e.Cont
|
||||
}
|
||||
if e.ADIF != 0 {
|
||||
n := e.ADIF
|
||||
q.DXCC = &n
|
||||
}
|
||||
if e.CQZ != 0 {
|
||||
v := e.CQZ
|
||||
q.CQZ = &v
|
||||
}
|
||||
if e.Lat != 0 || e.Lon != 0 {
|
||||
lat, lon := e.Lat, e.Lon
|
||||
q.Lat, q.Lon = &lat, &lon
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateQSOsFromClublog re-resolves the selected QSOs against ClubLog
|
||||
// exceptions (by their QSO date) and saves any that changed.
|
||||
func (a *App) UpdateQSOsFromClublog(ids []int64) (int, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if a.clublog == nil || !a.clublog.Loaded() {
|
||||
return 0, fmt.Errorf("ClubLog data not loaded — download it first")
|
||||
}
|
||||
changed := 0
|
||||
for _, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if a.applyClublogException(&q, true) {
|
||||
if err := a.qso.Update(a.ctx, q); err == nil {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// titleCaseIfUpper title-cases a string ONLY when it's entirely upper-case
|
||||
// (e.g. Log4OM/contest ADIF sends "SANTO DOMINGO"); mixed-case values are
|
||||
// left untouched. Codes like state "DN" stay as-is (no lower-case letters
|
||||
// to gain, but they're short — callers pick which fields to pass).
|
||||
func titleCaseIfUpper(s string) string {
|
||||
t := strings.TrimSpace(s)
|
||||
if t == "" || t != strings.ToUpper(t) {
|
||||
return s
|
||||
}
|
||||
return titleEntity(t)
|
||||
}
|
||||
|
||||
// titleEntity converts ClubLog's UPPERCASE entity names to title case
|
||||
// ("LORD HOWE ISLAND" → "Lord Howe Island") for display consistency.
|
||||
func titleEntity(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
words := strings.Fields(strings.ToLower(s))
|
||||
for i, w := range words {
|
||||
r := []rune(w)
|
||||
r[0] = []rune(strings.ToUpper(string(r[0])))[0]
|
||||
words[i] = string(r)
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
// ── Digital Voice Keyer (DVK) ─────────────────────────────────────────
|
||||
//
|
||||
// Six voice-message slots (F1–F6, like the WinKeyer macros). Each message is a
|
||||
// WAV file in <dataDir>/dvk/dvk<N>.wav; its label lives in settings. Record via
|
||||
// the configured "Recording mic", transmit via "To Radio", preview via
|
||||
// "Listening".
|
||||
|
||||
const dvkSlots = 6
|
||||
|
||||
// DVKMessage is one voice-keyer slot for the UI.
|
||||
type DVKMessage struct {
|
||||
Slot int `json:"slot"`
|
||||
Label string `json:"label"`
|
||||
HasAudio bool `json:"has_audio"`
|
||||
DurationSec float64 `json:"duration_sec"`
|
||||
}
|
||||
|
||||
// DVKStatus reflects the live record/playback state for the operating panel.
|
||||
type DVKStatus struct {
|
||||
Recording bool `json:"recording"`
|
||||
Playing bool `json:"playing"`
|
||||
RecSlot int `json:"rec_slot"`
|
||||
}
|
||||
|
||||
func (a *App) dvkDir() string {
|
||||
d := filepath.Join(a.dataDir, "dvk")
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
return d
|
||||
}
|
||||
|
||||
func (a *App) dvkPath(slot int) string {
|
||||
return filepath.Join(a.dvkDir(), fmt.Sprintf("dvk%d.wav", slot))
|
||||
}
|
||||
|
||||
func dvkLabelKey(slot int) string { return fmt.Sprintf("audio.dvk.label%d", slot) }
|
||||
|
||||
func (a *App) dvkStatus() DVKStatus {
|
||||
st := DVKStatus{RecSlot: a.dvkRecSlot}
|
||||
if a.audioMgr != nil {
|
||||
st.Recording = a.audioMgr.IsRecording()
|
||||
st.Playing = a.audioMgr.IsPlaying()
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// GetDVKStatus returns the current record/playback state.
|
||||
func (a *App) GetDVKStatus() DVKStatus { return a.dvkStatus() }
|
||||
|
||||
// GetDVKMessages returns the six voice-keyer slots with their labels, whether
|
||||
// a recording exists, and its duration.
|
||||
func (a *App) GetDVKMessages() []DVKMessage {
|
||||
out := make([]DVKMessage, 0, dvkSlots)
|
||||
for s := 1; s <= dvkSlots; s++ {
|
||||
m := DVKMessage{Slot: s}
|
||||
if a.settings != nil {
|
||||
if v, _ := a.settings.Get(a.ctx, dvkLabelKey(s)); v != "" {
|
||||
m.Label = v
|
||||
}
|
||||
}
|
||||
if fi, err := os.Stat(a.dvkPath(s)); err == nil && fi.Size() > 44 {
|
||||
m.HasAudio = true
|
||||
m.DurationSec = float64(fi.Size()-44) / 32000.0 // 16 kHz mono 16-bit
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetDVKLabel renames a voice-keyer slot.
|
||||
func (a *App) SetDVKLabel(slot int, label string) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if slot < 1 || slot > dvkSlots {
|
||||
return fmt.Errorf("bad slot")
|
||||
}
|
||||
return a.settings.Set(a.ctx, dvkLabelKey(slot), strings.TrimSpace(label))
|
||||
}
|
||||
|
||||
// DVKStartRecord begins recording a voice message into the given slot, using
|
||||
// the configured Recording mic.
|
||||
func (a *App) DVKStartRecord(slot int) error {
|
||||
if a.audioMgr == nil {
|
||||
return fmt.Errorf("audio not initialized")
|
||||
}
|
||||
if slot < 1 || slot > dvkSlots {
|
||||
return fmt.Errorf("bad slot")
|
||||
}
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
a.dvkRecSlot = slot
|
||||
return a.audioMgr.StartRecording(cfg.RecordingDevice)
|
||||
}
|
||||
|
||||
// DVKStopRecord ends the recording and writes it to the slot's WAV file.
|
||||
func (a *App) DVKStopRecord() error {
|
||||
if a.audioMgr == nil {
|
||||
return fmt.Errorf("audio not initialized")
|
||||
}
|
||||
return a.audioMgr.StopRecording(a.dvkPath(a.dvkRecSlot))
|
||||
}
|
||||
|
||||
// DVKCancelRecord aborts a recording without saving.
|
||||
func (a *App) DVKCancelRecord() { if a.audioMgr != nil { a.audioMgr.CancelRecording() } }
|
||||
|
||||
// DVKPlay transmits a slot's message to the rig ("To Radio"), asserting serial
|
||||
// PTT (RTS/DTR) first unless the operator uses VOX. PTT is released
|
||||
// automatically when playback ends (see the audio status callback).
|
||||
func (a *App) DVKPlay(slot int) error {
|
||||
if a.audioMgr == nil {
|
||||
return fmt.Errorf("audio not initialized")
|
||||
}
|
||||
path := a.dvkPath(slot)
|
||||
if fi, err := os.Stat(path); err != nil || fi.Size() <= 44 {
|
||||
return fmt.Errorf("no recording in slot %d", slot)
|
||||
}
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
if err := a.pttKey(cfg); err != nil {
|
||||
applog.Printf("dvk: PTT on failed: %v", err)
|
||||
// Keep going — the audio still reaches the rig; the user may use VOX.
|
||||
} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
|
||||
a.dvkPttKeyed = true
|
||||
}
|
||||
if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil {
|
||||
if a.dvkPttKeyed {
|
||||
a.dvkPttKeyed = false
|
||||
go a.dvkUnkeyPTT()
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dvkUnkeyPTT releases serial PTT after a short tail so the rig doesn't clip
|
||||
// the end of the message.
|
||||
func (a *App) dvkUnkeyPTT() {
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
a.pttUnkey()
|
||||
}
|
||||
|
||||
// pttKey keys the transmitter using the configured method:
|
||||
// - "cat" → OmniRig (sets the Tx parameter to PM_TX)
|
||||
// - "rts"/"dtr" → open the COM port and assert that line, held during TX
|
||||
// - "none" → VOX, nothing to do
|
||||
func (a *App) pttKey(cfg AudioSettings) error {
|
||||
switch cfg.PTTMethod {
|
||||
case "cat":
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
if err := a.cat.SetPTT(true); err != nil {
|
||||
return err
|
||||
}
|
||||
a.pttMu.Lock()
|
||||
a.pttKeyedMethod = "cat"
|
||||
a.pttMu.Unlock()
|
||||
applog.Printf("dvk: PTT keyed (CAT/OmniRig)")
|
||||
return nil
|
||||
case "rts", "dtr":
|
||||
if strings.TrimSpace(cfg.PTTPort) == "" {
|
||||
return fmt.Errorf("no PTT COM port configured")
|
||||
}
|
||||
a.pttMu.Lock()
|
||||
defer a.pttMu.Unlock()
|
||||
if a.pttPort != nil {
|
||||
return nil // already keyed
|
||||
}
|
||||
port, err := serial.Open(cfg.PTTPort, &serial.Mode{BaudRate: 9600})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", cfg.PTTPort, err)
|
||||
}
|
||||
var lerr error
|
||||
if cfg.PTTMethod == "rts" {
|
||||
lerr = port.SetRTS(true)
|
||||
_ = port.SetDTR(false)
|
||||
} else {
|
||||
lerr = port.SetDTR(true)
|
||||
_ = port.SetRTS(false)
|
||||
}
|
||||
if lerr != nil {
|
||||
_ = port.Close()
|
||||
return fmt.Errorf("assert %s on %s: %w", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort, lerr)
|
||||
}
|
||||
a.pttPort = port
|
||||
a.pttKeyedMethod = cfg.PTTMethod
|
||||
applog.Printf("dvk: PTT keyed (%s on %s)", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort)
|
||||
return nil
|
||||
}
|
||||
return nil // none / VOX
|
||||
}
|
||||
|
||||
// pttUnkey releases whichever PTT was keyed (CAT back to RX, or drop the
|
||||
// serial line + close the port).
|
||||
func (a *App) pttUnkey() {
|
||||
a.pttMu.Lock()
|
||||
method := a.pttKeyedMethod
|
||||
a.pttKeyedMethod = ""
|
||||
port := a.pttPort
|
||||
a.pttPort = nil
|
||||
a.pttMu.Unlock()
|
||||
|
||||
switch method {
|
||||
case "cat":
|
||||
if a.cat != nil {
|
||||
if err := a.cat.SetPTT(false); err != nil {
|
||||
applog.Printf("dvk: PTT off (CAT) failed: %v", err)
|
||||
}
|
||||
}
|
||||
case "rts", "dtr":
|
||||
if port != nil {
|
||||
_ = port.SetRTS(false)
|
||||
_ = port.SetDTR(false)
|
||||
_ = port.Close()
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
applog.Printf("dvk: PTT released")
|
||||
}
|
||||
|
||||
// TestPTT keys PTT for ~600ms so the user can confirm the rig transmits.
|
||||
func (a *App) TestPTT() error {
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
|
||||
return fmt.Errorf("PTT method is None (VOX) — nothing to test")
|
||||
}
|
||||
if err := a.pttKey(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() { time.Sleep(600 * time.Millisecond); a.pttUnkey() }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DVKPreview plays a slot's message locally on the "Listening" device.
|
||||
func (a *App) DVKPreview(slot int) error {
|
||||
if a.audioMgr == nil {
|
||||
return fmt.Errorf("audio not initialized")
|
||||
}
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
return a.audioMgr.Play(cfg.ListeningDevice, a.dvkPath(slot))
|
||||
}
|
||||
|
||||
// DVKStop halts any voice-keyer playback.
|
||||
func (a *App) DVKStop() {
|
||||
if a.audioMgr != nil {
|
||||
a.audioMgr.StopPlayback()
|
||||
}
|
||||
}
|
||||
|
||||
// GetLogFilePath returns where the diagnostic log file lives so the user
|
||||
// can open it from the Settings UI. Empty when applog hasn't initialised.
|
||||
func (a *App) GetLogFilePath() string {
|
||||
@@ -2650,6 +3332,15 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Name/city normalisation ──
|
||||
// Log4OM / contest loggers often send NAME and QTH in ALL CAPS. Title-case
|
||||
// them so UDP-logged QSOs match the manual + lookup paths ("SANTO DOMINGO"
|
||||
// → "Santo Domingo"). Only all-caps values are touched.
|
||||
q.Name = titleCaseIfUpper(q.Name)
|
||||
q.QTH = titleCaseIfUpper(q.QTH)
|
||||
q.Country = titleCaseIfUpper(q.Country)
|
||||
q.MyCity = titleCaseIfUpper(q.MyCity)
|
||||
|
||||
// ── Operating-conditions stamp ──
|
||||
// Pre-fill MY_RIG / MY_ANTENNA / TX_PWR from the default antenna for
|
||||
// this band (if the user has configured Operating conditions).
|
||||
@@ -2675,15 +3366,22 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
// entity-name table; QSL defaults are applied last so explicit ADIF
|
||||
// fields (or what the lookup gave us) always win.
|
||||
a.applyDXCCNumber(&q)
|
||||
a.applyClublogException(&q, false) // date-ranged DXpedition override
|
||||
a.applyQSLDefaults(&q)
|
||||
|
||||
// ── Dedup ──
|
||||
// Match by call + minute + band + mode (same key the importer uses).
|
||||
// Match by call + band + mode within a ±2-minute window: a QSO logged
|
||||
// manually in OpsLog and re-broadcast by Log4OM over UDP often differs by
|
||||
// a minute (the two apps stamp their own time), so a minute-exact key
|
||||
// missed it and the contact got duplicated.
|
||||
seen, err := a.qso.ExistingDedupeKeys(a.ctx)
|
||||
if err == nil {
|
||||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
||||
if _, dup := seen[key]; dup {
|
||||
return 0, fmt.Errorf("duplicate (already in log)")
|
||||
base := q.QSODate.UTC()
|
||||
for d := -2; d <= 2; d++ {
|
||||
min := base.Add(time.Duration(d) * time.Minute).Format("2006-01-02T15:04")
|
||||
if _, dup := seen[qso.DedupeKey(q.Callsign, min, q.Band, q.Mode)]; dup {
|
||||
return 0, fmt.Errorf("duplicate (already in log within ±2 min)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2691,6 +3389,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert qso: %w", err)
|
||||
}
|
||||
a.saveQSORecording(q.Callsign)
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user