diff --git a/app.go b/app.go index deaba23..595e8fd 100644 --- a/app.go +++ b/app.go @@ -23,6 +23,7 @@ import ( "hamlog/internal/clublog" "hamlog/internal/cluster" "hamlog/internal/db" + "hamlog/internal/email" "hamlog/internal/extsvc" "hamlog/internal/integrations/udp" "hamlog/internal/operating" @@ -85,9 +86,24 @@ const ( keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr" keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT keyAudioFormat = "audio.qso_format" // "wav" | "mp3" + keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent + keyAudioMicGain = "audio.mic_gain" // mic mix level, percent keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions + // E-mail / SMTP — send QSO recordings to the correspondent. + keyEmailEnabled = "email.enabled" + keyEmailHost = "email.smtp_host" + keyEmailPort = "email.smtp_port" + keyEmailUser = "email.smtp_user" + keyEmailPassword = "email.smtp_password" + keyEmailFrom = "email.from" + keyEmailEncryption = "email.encryption" // "ssl" | "starttls" | "none" + keyEmailAuth = "email.auth" // "1" → SMTP requires authorization (send user/password) + keyEmailAutoSend = "email.auto_send" // "1" → auto-send recording on log when an e-mail is known + keyEmailSubject = "email.subject" + keyEmailBody = "email.body" + // 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" @@ -1033,9 +1049,17 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) { a.applyDXCCNumber(&q) a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions a.applyQSLDefaults(&q) + // Fill the contacted operator's e-mail from the (cached) lookup so the + // recording can be auto-sent. Cheap: the entry already looked the call up. + if strings.TrimSpace(q.Email) == "" && a.lookup != nil { + if lr, e := a.lookup.Lookup(a.ctx, q.Callsign); e == nil && lr.Email != "" { + q.Email = lr.Email + } + } id, err := a.qso.Add(a.ctx, q) if err == nil { - a.saveQSORecording(q.Callsign) + q.ID = id + a.saveQSORecording(&q) if a.extsvc != nil { a.extsvc.OnQSOLogged(id) } @@ -1667,6 +1691,8 @@ type AudioSettings struct { 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" + FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100 + MicGain int `json:"mic_gain"` // mic mix level %, default 100 } // ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints @@ -1676,13 +1702,14 @@ func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.Li // GetAudioSettings returns the stored audio config (preroll defaults to 8s). func (a *App) GetAudioSettings() (AudioSettings, error) { - out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav"} + out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100} if a.settings == nil { return out, nil } m, err := a.settings.GetMany(a.ctx, keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice, - keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat) + keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat, + keyAudioFromGain, keyAudioMicGain) if err != nil { return out, err } @@ -1704,6 +1731,12 @@ func (a *App) GetAudioSettings() (AudioSettings, error) { out.PrerollSeconds = n } } + if n, _ := strconv.Atoi(m[keyAudioFromGain]); n > 0 && n <= 400 { + out.FromGain = n + } + if n, _ := strconv.Atoi(m[keyAudioMicGain]); n > 0 && n <= 400 { + out.MicGain = n + } return out, nil } @@ -1727,6 +1760,12 @@ func (a *App) SaveAudioSettings(s AudioSettings) error { if format != "mp3" { format = "wav" } + if s.FromGain <= 0 || s.FromGain > 400 { + s.FromGain = 100 + } + if s.MicGain <= 0 || s.MicGain > 400 { + s.MicGain = 100 + } for k, v := range map[string]string{ keyAudioFromRadio: s.FromRadio, keyAudioToRadio: s.ToRadio, @@ -1738,6 +1777,8 @@ func (a *App) SaveAudioSettings(s AudioSettings) error { keyAudioPTTMethod: pttMethod, keyAudioPTTPort: strings.TrimSpace(s.PTTPort), keyAudioFormat: format, + keyAudioFromGain: strconv.Itoa(s.FromGain), + keyAudioMicGain: strconv.Itoa(s.MicGain), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err @@ -1777,7 +1818,15 @@ func (a *App) startQSORecorderIfEnabled() { 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) + fromGain, micGain := float64(cfg.FromGain)/100, float64(cfg.MicGain)/100 + if cfg.FromGain == 0 { + fromGain = 1 + } + if cfg.MicGain == 0 { + micGain = 1 + } + a.qsoRec.SetGains(fromGain, micGain) + applog.Printf("qso-rec: running (preroll %ds, mix=%v, gains rx=%.2f mic=%.2f)", cfg.PrerollSeconds, cfg.RecordingDevice != "" && cfg.RecordingDevice != cfg.FromRadio, fromGain, micGain) } // qsoRecDir returns the configured recordings folder, defaulting to @@ -1792,23 +1841,66 @@ func (a *App) qsoRecDir() string { 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) { +// saveQSORecording finalises the active recording (if any) into a file named +// CALL_BAND_MODE_YYYYMMDD_HHMMSS.ext, stores the filename on the QSO (so it can +// be e-mailed later), and auto-sends it to the contacted operator when enabled +// and an e-mail is known. Called right after a QSO is inserted (manual + UDP); +// q must have its ID set. +// recordableMode reports whether a QSO mode is worth an audio recording — +// only voice (SSB/AM/FM) and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry no +// useful audio, so they are never recorded. +func recordableMode(mode string) bool { + switch strings.ToUpper(strings.TrimSpace(mode)) { + case "SSB", "USB", "LSB", "AM", "FM", "DV", "CW": + return true + } + return false +} + +func (a *App) saveQSORecording(q *qso.QSO) { if a.qsoRec == nil || !a.qsoRec.Active() { return } + if !recordableMode(q.Mode) { + a.qsoRec.DiscardQSO() // digital mode — drop the buffered audio + 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) + parts := []string{sanitizeFilename(q.Callsign)} + if b := strings.TrimSpace(q.Band); b != "" { + parts = append(parts, sanitizeFilename(b)) + } + if m := strings.TrimSpace(q.Mode); m != "" { + parts = append(parts, sanitizeFilename(m)) + } + parts = append(parts, time.Now().UTC().Format("20060102_150405")) + name := strings.Join(parts, "_") + "." + 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) + + // Remember the recording on the QSO so it can be e-mailed later. + if q.ID != 0 { + if q.Extras == nil { + q.Extras = map[string]string{} + } + q.Extras["APP_OPSLOG_RECORDING"] = name + if err := a.qso.Update(a.ctx, *q); err != nil { + applog.Printf("qso-rec: store recording path: %v", err) + } + } + + // Auto-send to the correspondent when enabled and an e-mail is known. + if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(q.Email) != "" { + qc := *q + go func() { _ = a.sendRecordingEmail(qc, path) }() + } } // sanitizeFilename makes a callsign safe for a filename (slashes etc.). @@ -1829,10 +1921,15 @@ func sanitizeFilename(s string) string { // 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() +// QSOAudioBegin starts accumulating a recording for the current QSO. It +// returns true when a recording is actually running (recorder enabled and +// capturing), so the UI can show a "REC" indicator. +func (a *App) QSOAudioBegin() bool { + if a.qsoRec == nil { + return false } + a.qsoRec.BeginQSO() + return a.qsoRec.Active() } // QSOAudioCancel drops the in-progress recording (callsign cleared, QSO @@ -1846,6 +1943,184 @@ func (a *App) QSOAudioCancel() { // RestartQSORecorder applies new audio settings to the running recorder. func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() } +// ── E-mail / SMTP (send QSO recordings) ─────────────────────────────── + +const ( + defaultEmailSubject = "Our QSO recording — {CALL}" + defaultEmailBody = "Hi,\n\nGreat to work you! Please find attached the audio recording of our QSO.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}" +) + +// EmailSettings is the user's SMTP config + auto-send + message templates. +type EmailSettings struct { + Enabled bool `json:"enabled"` + Host string `json:"smtp_host"` + Port int `json:"smtp_port"` + User string `json:"smtp_user"` + Password string `json:"smtp_password"` + From string `json:"from"` + Encryption string `json:"encryption"` // "ssl" | "starttls" | "none" + Auth bool `json:"auth"` // SMTP requires authorization + AutoSend bool `json:"auto_send"` + Subject string `json:"subject"` + Body string `json:"body"` +} + +// GetEmailSettings returns the stored SMTP config (with sensible defaults). +func (a *App) GetEmailSettings() (EmailSettings, error) { + out := EmailSettings{Port: 587, Encryption: "starttls", Auth: true, Subject: defaultEmailSubject, Body: defaultEmailBody} + if a.settings == nil { + return out, nil + } + m, err := a.settings.GetMany(a.ctx, + keyEmailEnabled, keyEmailHost, keyEmailPort, keyEmailUser, keyEmailPassword, + keyEmailFrom, keyEmailEncryption, keyEmailAuth, keyEmailAutoSend, keyEmailSubject, keyEmailBody) + if err != nil { + return out, err + } + out.Enabled = m[keyEmailEnabled] == "1" + out.Host = m[keyEmailHost] + if p, _ := strconv.Atoi(m[keyEmailPort]); p > 0 { + out.Port = p + } + out.User = m[keyEmailUser] + out.Password = m[keyEmailPassword] + out.From = m[keyEmailFrom] + if e := m[keyEmailEncryption]; e == "ssl" || e == "starttls" || e == "none" { + out.Encryption = e + } + out.Auth = m[keyEmailAuth] != "0" // default true (unset → required) + out.AutoSend = m[keyEmailAutoSend] == "1" + if s := m[keyEmailSubject]; s != "" { + out.Subject = s + } + if b := m[keyEmailBody]; b != "" { + out.Body = b + } + return out, nil +} + +// SaveEmailSettings persists the SMTP config. +func (a *App) SaveEmailSettings(s EmailSettings) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + enc := s.Encryption + if enc != "ssl" && enc != "none" { + enc = "starttls" + } + if s.Port <= 0 { + s.Port = 587 + } + b2s := func(b bool) string { + if b { + return "1" + } + return "0" + } + for k, v := range map[string]string{ + keyEmailEnabled: b2s(s.Enabled), + keyEmailHost: strings.TrimSpace(s.Host), + keyEmailPort: strconv.Itoa(s.Port), + keyEmailUser: strings.TrimSpace(s.User), + keyEmailPassword: s.Password, + keyEmailFrom: strings.TrimSpace(s.From), + keyEmailEncryption: enc, + keyEmailAuth: b2s(s.Auth), + keyEmailAutoSend: b2s(s.AutoSend), + keyEmailSubject: s.Subject, + keyEmailBody: s.Body, + } { + if err := a.settings.Set(a.ctx, k, v); err != nil { + return err + } + } + return nil +} + +func (a *App) emailConfig(s EmailSettings) email.Config { + return email.Config{Host: s.Host, Port: s.Port, User: s.User, Password: s.Password, From: s.From, Encryption: s.Encryption, Auth: s.Auth} +} + +// TestEmail sends a test message to `to` (defaults to the From address) to +// validate the SMTP configuration. +func (a *App) TestEmail(to string) error { + s, _ := a.GetEmailSettings() + if to == "" { + to = s.From + } + if to == "" { + to = s.User + } + return email.Send(a.emailConfig(s), to, + "OpsLog SMTP test", "This is a test message from OpsLog — your SMTP settings work. 73", "") +} + +// fillTemplate substitutes {CALL} {DATE} {BAND} {MODE} {MYCALL} in a string. +func (a *App) fillTemplate(tmpl string, q qso.QSO) string { + myCall := "" + if a.profiles != nil { + if p, err := a.profiles.Active(a.ctx); err == nil { + myCall = p.Callsign + } + } + r := strings.NewReplacer( + "{CALL}", q.Callsign, + "{DATE}", q.QSODate.UTC().Format("2006-01-02 15:04 UTC"), + "{BAND}", q.Band, + "{MODE}", q.Mode, + "{MYCALL}", myCall, + ) + return r.Replace(tmpl) +} + +// sendRecordingEmail e-mails a QSO recording to the contacted operator. +func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error { + s, _ := a.GetEmailSettings() + to := strings.TrimSpace(q.Email) + if to == "" { + return fmt.Errorf("no e-mail address for %s", q.Callsign) + } + subject := s.Subject + if subject == "" { + subject = defaultEmailSubject + } + body := s.Body + if body == "" { + body = defaultEmailBody + } + err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), attachPath) + if err != nil { + applog.Printf("email: send recording to %s failed: %v", to, err) + } else { + applog.Printf("email: recording sent to %s (%s)", to, q.Callsign) + } + return err +} + +// SendQSORecordingEmail e-mails the stored recording for a QSO id (right-click +// "Send recording by e-mail"). Errors if the QSO has no recording or e-mail. +func (a *App) SendQSORecordingEmail(id int64) error { + if a.qso == nil { + return fmt.Errorf("db not initialized") + } + q, err := a.qso.GetByID(a.ctx, id) + if err != nil { + return err + } + name := "" + if q.Extras != nil { + name = q.Extras["APP_OPSLOG_RECORDING"] + } + if name == "" { + return fmt.Errorf("no recording stored for this QSO") + } + path := filepath.Join(a.qsoRecDir(), name) + if _, e := os.Stat(path); e != nil { + return fmt.Errorf("recording file missing: %s", name) + } + return a.sendRecordingEmail(q, path) +} + // ── ClubLog Country File (cty.xml) exceptions ───────────────────────── // ClublogCtyInfo is the UI status of the ClubLog exception data. @@ -3389,7 +3664,8 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { if err != nil { return 0, fmt.Errorf("insert qso: %w", err) } - a.saveQSORecording(q.Callsign) + q.ID = id + a.saveQSORecording(&q) if a.extsvc != nil { a.extsvc.OnQSOLogged(id) } @@ -4567,6 +4843,7 @@ func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) erro if c := strings.TrimSpace(comment); c != "" { cmd += " " + c } + applog.Printf("cluster: send spot — freqKHz=%v → command %q", freqKHz, cmd) return a.SendClusterCommand(cmd) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b47a618..e58b5db 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, ExternalLink, Hash, Loader2, Lock, + AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, Hash, Loader2, Lock, Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, Square, Trash2, Unlock, X, } from 'lucide-react'; @@ -8,7 +8,7 @@ import { AddQSO, ListQSO, CountQSO, OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO, - UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, + UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, LookupCallsign, GetStationSettings, GetListsSettings, GetStartupStatus, WorkedBefore, @@ -76,6 +76,9 @@ type CATState = Omit; const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm']; const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE']; +// Modes the QSO recorder captures (voice + CW). Mirrors recordableMode() in +// app.go — digital modes carry no useful audio and are never recorded. +const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV','CW']); const emptyDetails: DetailsState = { state: '', cnty: '', address: '', @@ -419,6 +422,16 @@ export default function App() { setToast(msg); window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500); }, []); + // Error banners auto-dismiss after a few seconds (longer than toasts since + // they may be multi-line). The X button still closes them immediately. + useEffect(() => { + if (!error) return; + const t = window.setTimeout(() => setError(''), 6000); + return () => window.clearTimeout(t); + }, [error]); + // True while the QSO recorder is capturing the current contact (set when we + // leave the callsign field, cleared on log/cancel). Drives the "REC" badge. + const [recording, setRecording] = useState(false); const [saving, setSaving] = useState(false); const [filterCallsign, setFilterCallsign] = useState(''); const [filterBand, setFilterBand] = useState(''); @@ -843,7 +856,6 @@ export default function App() { const mine = myCallRef.current; if (mine && (sp.dx_call ?? '').toUpperCase() === mine) { setSelfSpot({ spotter: cleanSpotter(sp.spotter ?? ''), freqKHz: sp.freq_khz, band: sp.band, comment: sp.comment, at: Date.now() }); - showToast(`You've been spotted by ${cleanSpotter(sp.spotter ?? '') || '?'} on ${sp.freq_khz?.toFixed(1)} kHz`); // Auto-hide 3 s after the last self-spot; a new one resets the timer. if (selfSpotTimerRef.current) window.clearTimeout(selfSpotTimerRef.current); selfSpotTimerRef.current = window.setTimeout(() => setSelfSpot(null), 3000); @@ -878,7 +890,11 @@ export default function App() { await LogUDPLoggedADIF(text); await refresh(); } catch (e: any) { - setError('UDP auto-log: ' + String(e?.message ?? e)); + const msg = String(e?.message ?? e); + // A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign — + // show a quiet toast, not a red error. + if (/duplicate/i.test(msg)) showToast('UDP QSO already logged — skipped'); + else setError('UDP auto-log: ' + msg); } }); return () => { unsubDX?.(); unsubRC?.(); unsubProg?.(); unsubLog?.(); }; @@ -1048,7 +1064,7 @@ export default function App() { function resetEntry() { // Discard any in-progress QSO recording (no-op if it was already saved on // log, or if the recorder is off). - QSOAudioCancel(); + QSOAudioCancel(); setRecording(false); setCallsign(''); setComment(''); setNote(''); if (!locks.start) setQsoStartedAt(null); if (!locks.end) setQsoEndedAt(null); @@ -1132,6 +1148,17 @@ export default function App() { try { await afterBulkUpdate(await UpdateQSOsFromClublog(ids as any), 'from ClubLog'); } catch (e: any) { setError(String(e?.message ?? e)); } } + async function bulkSendRecording(ids: number[]) { + if (ids.length === 0) return; + showToast(`Sending ${ids.length} recording${ids.length > 1 ? 's' : ''} by e-mail…`); + let ok = 0; const errs: string[] = []; + for (const id of ids) { + try { await SendQSORecordingEmail(id as any); ok++; } + catch (e: any) { errs.push(String(e?.message ?? e)); } + } + if (errs.length) setError(`Recording e-mail: ${ok} sent, ${errs.length} failed — ${errs[0]}`); + else showToast(`${ok} recording${ok > 1 ? 's' : ''} sent`); + } // 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. @@ -1238,8 +1265,9 @@ export default function App() { 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(); + // Recording START happens on blur (leaving the callsign field), NOT here — + // you may type a call and work it minutes later. Clearing it cancels. + if (v.trim() === '') { QSOAudioCancel(); setRecording(false); } const wasEmpty = callsign.trim() === ''; const isEmpty = v.trim() === ''; if (wasEmpty && !isEmpty && !locks.start) { @@ -1454,24 +1482,6 @@ export default function App() {
+
+ {recording && RECORDABLE_MODES.has(mode.toUpperCase()) && ( + + REC + + )} onCallsignInput(e.target.value)} + // Start the QSO recording when leaving the callsign field (the pre-roll + // covers the seconds before). No-op when the recorder is off. + onBlur={() => { if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }} /> +
); const rstTxBlock = ( @@ -1866,14 +1886,6 @@ export default function App() { )} - {error && ( -
- -
{error}
- -
- )} - {/* QRZ profile photo lightbox — full size, in-app. Click anywhere or press Esc to close; click the image itself doesn't close. */} {photoModal && ( @@ -1899,12 +1911,24 @@ export default function App() { )} - {/* Transient success toast (bottom-right). */} - {toast && ( -
- - {toast} - + {/* Transient toasts (bottom-right). Errors stack on top of the green + success toast; both auto-dismiss. */} + {(error || toast) && ( +
+ {error && ( +
+ +
{error}
+ +
+ )} + {toast && ( +
+ + {toast} + +
+ )}
)} @@ -1913,7 +1937,7 @@ export default function App() { so it never shifts the layout (push-down / spring-back) and never covers the entry fields; auto-hides 3s after the last self-spot. */} {!compact && selfSpot && ( -
+
You've been spotted by {selfSpot.spotter || '?'} @@ -2213,6 +2237,7 @@ export default function App() { onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} + onSendRecording={bulkSendRecording} onRowSelected={(id) => setSelectedId(id)} />
@@ -2544,7 +2569,7 @@ export default function App() { openEdit(q.id as number)} - onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} /> + onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} /> {/* Opened on demand from Tools → QSL Manager; closable via the @@ -2735,8 +2760,8 @@ export default function App() {
- - + + diff --git a/frontend/src/components/QSOContextMenu.tsx b/frontend/src/components/QSOContextMenu.tsx index a186aa7..78147d0 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, BadgeCheck } from 'lucide-react'; +import { Globe2, RefreshCw, Upload, BadgeCheck, Mail } from 'lucide-react'; export type QSOMenuState = { x: number; y: number; ids: number[] } | null; @@ -10,6 +10,7 @@ type Props = { onUpdateFromQRZ: (ids: number[]) => void; onUpdateFromClublog?: (ids: number[]) => void; onSendTo?: (service: string, ids: number[]) => void; + onSendRecording?: (ids: number[]) => void; }; const UPLOAD_TARGETS: { service: string; label: string }[] = [ @@ -21,7 +22,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, onUpdateFromClublog, onSendTo }: Props) { +export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) { useEffect(() => { if (!menu) return; const close = () => onClose(); @@ -77,6 +78,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ )} + {onSendRecording && ( + <> +
+ + + )} + {onSendTo && ( <>
diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index cb34366..eb30ec3 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -51,6 +51,7 @@ type Props = { onUpdateFromQRZ?: (ids: number[]) => void; onUpdateFromClublog?: (ids: number[]) => void; onSendTo?: (service: string, ids: number[]) => void; + onSendRecording?: (ids: number[]) => void; }; const COL_STATE_KEY = 'hamlog.qsoColState.v2'; @@ -208,7 +209,7 @@ export const GROUP_ORDER = [ 'Contest', 'Propagation', 'My station', 'Misc', ]; -export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo }: Props) { +export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) { const gridRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); const [menu, setMenu] = useState(null); @@ -353,6 +354,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)} onUpdateFromClublog={onUpdateFromClublog} onSendTo={onSendTo} + onSendRecording={onSendRecording} /> diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index dd12588..7330399 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -14,6 +14,7 @@ import { GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, + GetEmailSettings, SaveEmailSettings, TestEmail, GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus, ListClusterServers, SaveClusterServer, DeleteClusterServer, GetClusterAutoConnect, SetClusterAutoConnect, @@ -136,6 +137,7 @@ interface Props { `disabled: true` greys them out and shows the "coming soon" placeholder. */ type SectionId = | 'general' + | 'email' | 'station' | 'profiles' | 'operating' @@ -172,6 +174,7 @@ const TREE: TreeNode[] = [ { kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [ { kind: 'item', label: 'General', id: 'general' }, + { kind: 'item', label: 'E-mail (SMTP)', id: 'email' }, { kind: 'item', label: 'Callsign Lookup', id: 'lookup' }, { kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [ { kind: 'item', label: 'Bands', id: 'lists-bands' }, @@ -386,11 +389,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { 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'; + from_gain: number; mic_gain: number; }; 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', + from_gain: 100, mic_gain: 100, }); const [audioInputs, setAudioInputs] = useState([]); const [audioOutputs, setAudioOutputs] = useState([]); @@ -408,6 +413,18 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { // General behaviour prefs (machine-local, applied live via localStorage). const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0'); + + // E-mail / SMTP (send QSO recordings). + type EmailCfg = { + enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string; + from: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string; + }; + const [emailCfg, setEmailCfg] = useState({ + enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '', + from: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '', + }); + const [emailMsg, setEmailMsg] = useState(''); + const setEmailField = (patch: Partial) => setEmailCfg((s) => ({ ...s, ...patch })); // 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 }); @@ -569,6 +586,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { try { setWk(await GetWinkeyerSettings() as any); } catch {} try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {} try { setAudioCfg(await GetAudioSettings() as any); } catch {} + try { setEmailCfg(await GetEmailSettings() as any); } catch {} reloadAudioDevices(); reloadDvk(); } catch (e: any) { @@ -726,6 +744,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { await SaveRotatorSettings(rotator as any); await SaveWinkeyerSettings(wk as any); await SaveAudioSettings(audioCfg as any); + await SaveEmailSettings(emailCfg as any); await SaveBackupSettings(backupCfg as any); await SaveQSLDefaults(qslDefaults as any); await SaveExternalServices(extSvc as any); @@ -2538,9 +2557,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { <>
+ title="Audio devices & voice keyer"/> @@ -2565,15 +2582,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {

QSO recorder

-

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. -

@@ -2648,11 +2668,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { )}
-

- 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}

}
@@ -2772,9 +2787,58 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { ); } + function EmailPanel() { + return ( + <> + +
+ +
+ + setEmailField({ smtp_host: e.target.value })} /> + +
+ setEmailField({ smtp_port: parseInt(e.target.value, 10) || 0 })} /> + +
+
+ + + setEmailField({ smtp_user: e.target.value })} /> + + setEmailField({ smtp_password: e.target.value })} /> + + setEmailField({ from: e.target.value })} /> +
+
+ + {emailMsg} +
+
+ + ); + } + // Map sections to their content + icon (for placeholder). const PANELS: Record JSX.Element> = { general: GeneralPanel, + email: EmailPanel, station: StationPanel, profiles: ProfilesPanel, operating: OperatingPanelWrapper, diff --git a/frontend/src/components/WorkedBeforeGrid.tsx b/frontend/src/components/WorkedBeforeGrid.tsx index 2d639ef..bda45a8 100644 --- a/frontend/src/components/WorkedBeforeGrid.tsx +++ b/frontend/src/components/WorkedBeforeGrid.tsx @@ -51,6 +51,7 @@ type Props = { onUpdateFromQRZ?: (ids: number[]) => void; onUpdateFromClublog?: (ids: number[]) => void; onSendTo?: (service: string, ids: number[]) => void; + onSendRecording?: (ids: number[]) => void; }; const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2'; @@ -63,7 +64,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, onUpdateFromClublog, onSendTo }: Props) { +export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) { const gridRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); const [menu, setMenu] = useState(null); @@ -236,6 +237,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)} onUpdateFromClublog={onUpdateFromClublog} onSendTo={onSendTo} + onSendRecording={onSendRecording} /> {count > entries.length && ( diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 3709a70..31ec576 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -97,6 +97,8 @@ export function GetDVKStatus():Promise; export function GetDatabaseSettings():Promise; +export function GetEmailSettings():Promise; + export function GetExternalServices():Promise; export function GetListsSettings():Promise; @@ -167,7 +169,7 @@ export function PickOpenDatabase():Promise; export function PickSaveDatabase():Promise; -export function QSOAudioBegin():Promise; +export function QSOAudioBegin():Promise; export function QSOAudioCancel():Promise; @@ -199,6 +201,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise; export function SaveClusterServer(arg1:cluster.ServerConfig):Promise; +export function SaveEmailSettings(arg1:main.EmailSettings):Promise; + export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise; export function SaveListsSettings(arg1:main.ListsSettings):Promise; @@ -225,6 +229,8 @@ export function SendClusterCommand(arg1:string):Promise; export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise; +export function SendQSORecordingEmail(arg1:number):Promise; + export function SetCATFrequency(arg1:number):Promise; export function SetCATMode(arg1:string):Promise; @@ -243,6 +249,8 @@ export function SwitchCATRig(arg1:number):Promise; export function TestClublogUpload():Promise; +export function TestEmail(arg1:string):Promise; + export function TestLoTWUpload():Promise; export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index b6f8ff5..dcd8192 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -170,6 +170,10 @@ export function GetDatabaseSettings() { return window['go']['main']['App']['GetDatabaseSettings'](); } +export function GetEmailSettings() { + return window['go']['main']['App']['GetEmailSettings'](); +} + export function GetExternalServices() { return window['go']['main']['App']['GetExternalServices'](); } @@ -374,6 +378,10 @@ export function SaveClusterServer(arg1) { return window['go']['main']['App']['SaveClusterServer'](arg1); } +export function SaveEmailSettings(arg1) { + return window['go']['main']['App']['SaveEmailSettings'](arg1); +} + export function SaveExternalServices(arg1) { return window['go']['main']['App']['SaveExternalServices'](arg1); } @@ -426,6 +434,10 @@ export function SendClusterSpot(arg1, arg2, arg3) { return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3); } +export function SendQSORecordingEmail(arg1) { + return window['go']['main']['App']['SendQSORecordingEmail'](arg1); +} + export function SetCATFrequency(arg1) { return window['go']['main']['App']['SetCATFrequency'](arg1); } @@ -462,6 +474,10 @@ export function TestClublogUpload() { return window['go']['main']['App']['TestClublogUpload'](); } +export function TestEmail(arg1) { + return window['go']['main']['App']['TestEmail'](arg1); +} + export function TestLoTWUpload() { return window['go']['main']['App']['TestLoTWUpload'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 48f4a9c..64a06fa 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -385,6 +385,8 @@ export namespace main { ptt_method: string; ptt_port: string; format: string; + from_gain: number; + mic_gain: number; static createFrom(source: any = {}) { return new AudioSettings(source); @@ -402,6 +404,8 @@ export namespace main { this.ptt_method = source["ptt_method"]; this.ptt_port = source["ptt_port"]; this.format = source["format"]; + this.from_gain = source["from_gain"]; + this.mic_gain = source["mic_gain"]; } } export class BackupSettings { @@ -534,6 +538,38 @@ export namespace main { this.is_custom = source["is_custom"]; } } + export class EmailSettings { + enabled: boolean; + smtp_host: string; + smtp_port: number; + smtp_user: string; + smtp_password: string; + from: string; + encryption: string; + auth: boolean; + auto_send: boolean; + subject: string; + body: string; + + static createFrom(source: any = {}) { + return new EmailSettings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.smtp_host = source["smtp_host"]; + this.smtp_port = source["smtp_port"]; + this.smtp_user = source["smtp_user"]; + this.smtp_password = source["smtp_password"]; + this.from = source["from"]; + this.encryption = source["encryption"]; + this.auth = source["auth"]; + this.auto_send = source["auto_send"]; + this.subject = source["subject"]; + this.body = source["body"]; + } + } export class ModePreset { name: string; default_rst_sent?: string; diff --git a/go.mod b/go.mod index 21a2b35..e256bdf 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,11 @@ require ( 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 + github.com/wneessen/go-mail v0.7.3 go.bug.st/serial v1.7.1 golang.org/x/net v0.35.0 golang.org/x/sys v0.45.0 - golang.org/x/text v0.22.0 + golang.org/x/text v0.37.0 modernc.org/sqlite v1.50.1 ) diff --git a/go.sum b/go.sum index 9b5a553..4bd292a 100644 --- a/go.sum +++ b/go.sum @@ -74,12 +74,14 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= +github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0= +github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk= go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc= go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= @@ -96,11 +98,11 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/internal/audio/recorder.go b/internal/audio/recorder.go index 16d8dc8..1aa2e98 100644 --- a/internal/audio/recorder.go +++ b/internal/audio/recorder.go @@ -31,6 +31,8 @@ type Recorder struct { bufA []int16 // From Radio bufB []int16 // mic twoSrc bool + gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu + gainB float64 // mic gain // Mixed output state (guarded by mu). ring []int16 // last prerollSamples of mixed audio @@ -38,7 +40,36 @@ type Recorder struct { acc []int16 // active QSO accumulation (seeded from ring on BeginQSO) } -func NewRecorder() *Recorder { return &Recorder{} } +func NewRecorder() *Recorder { return &Recorder{gainA: 1, gainB: 1} } + +// SetGains sets the per-source mix levels (1.0 = unity). Use this to balance a +// hot mic against quieter rig RX audio. Values ≤0 fall back to unity. +func (r *Recorder) SetGains(fromGain, micGain float64) { + if fromGain <= 0 { + fromGain = 1 + } + if micGain <= 0 { + micGain = 1 + } + r.srcMu.Lock() + r.gainA, r.gainB = fromGain, micGain + r.srcMu.Unlock() +} + +// scaleSample applies gain to a sample with clamping. +func scaleSample(s int16, g float64) int16 { + if g == 1 { + return s + } + v := float64(s) * g + if v > 32767 { + return 32767 + } + if v < -32768 { + return -32768 + } + return int16(v) +} func (r *Recorder) Running() bool { r.mu.Lock() @@ -128,7 +159,7 @@ func (r *Recorder) mixTick() { if n > 0 { mixed = make([]int16, n) for i := 0; i < n; i++ { - mixed[i] = clampSum(r.bufA[i], r.bufB[i]) + mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB)) } r.bufA = append(r.bufA[:0], r.bufA[n:]...) r.bufB = append(r.bufB[:0], r.bufB[n:]...) @@ -142,7 +173,9 @@ func (r *Recorder) mixTick() { } } else if len(r.bufA) > 0 { mixed = make([]int16, len(r.bufA)) - copy(mixed, r.bufA) + for i, s := range r.bufA { + mixed[i] = scaleSample(s, r.gainA) + } r.bufA = r.bufA[:0] } r.srcMu.Unlock() diff --git a/internal/cat/omnirig.go b/internal/cat/omnirig.go index 655e5a3..75b55bb 100644 --- a/internal/cat/omnirig.go +++ b/internal/cat/omnirig.go @@ -202,39 +202,33 @@ func (o *OmniRig) SetFrequency(hz int64) error { debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz): rig=%q status=%d(%s) vfo=%q(raw=%d) split=%d writeableParams=0x%X", hz, float64(hz)/1e6, rigType, status, statusStr, vfo, rawVfo, split, writeable) - // Pick the active VFO's specific property. Many rig .ini files only define - // a WRITE command for FreqA/FreqB but not the generic Freq. - prop := "FreqA" - switch vfo { - case "B", "BB", "BA": - prop = "FreqB" - case "A", "AA", "AB": - prop = "FreqA" - } - - wroteOK := false - if _, err := oleutil.PutProperty(o.rig, prop, hz32); err != nil { - debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", prop, err) + // Primary path: OmniRig's SetSimplexMode is the rig-agnostic "QSY here" + // method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) — + // where direct FreqA/FreqB writes are accepted but never move the radio. + // Clearing split is the right thing when tuning to a spot anyway. + if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil { + debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32) } else { - debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s, %d) OK", prop, hz32) - wroteOK = true - } - - // Belt-and-suspenders: when NOT in split, also write the generic Freq. - // Icom .ini files commonly honour Freq (CI-V "set operating frequency") - // but ignore FreqA/FreqB, so the rig changed mode but never moved — this - // is exactly the IC-9100 "mode changes, freq doesn't" symptom. - if split == 0 { - if _, err := oleutil.PutProperty(o.rig, "Freq", hz32); err != nil { - debugLog.Printf("OmniRig.SetFrequency: PutProperty(Freq) error: %v", err) - if !wroteOK { - return err - } - } else { - debugLog.Printf("OmniRig.SetFrequency: PutProperty(Freq, %d) OK", hz32) + debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v) — using property writes", err) + // Fallback: write the active VFO's property AND the generic Freq + // (always — some .ini honour only one, and split here is often misread). + prop := "FreqA" + switch vfo { + case "B", "BB", "BA": + prop = "FreqB" + } + okAny := false + for _, p := range []string{prop, "Freq"} { + if _, e := oleutil.PutProperty(o.rig, p, hz32); e != nil { + debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", p, e) + } else { + debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s, %d) OK", p, hz32) + okAny = true + } + } + if !okAny { + return fmt.Errorf("OmniRig: no writable frequency property for this rig") } - } else if !wroteOK { - return fmt.Errorf("OmniRig: could not write %s and split is on (won't touch generic Freq)", prop) } // Read back all three immediately. OmniRig is async (the CAT command is diff --git a/internal/email/email.go b/internal/email/email.go new file mode 100644 index 0000000..0291616 --- /dev/null +++ b/internal/email/email.go @@ -0,0 +1,73 @@ +// Package email sends QSO recordings to correspondents via SMTP. Pure Go (no +// CGO) using go-mail; supports implicit SSL (465), STARTTLS (587) or none. +package email + +import ( + "fmt" + "time" + + "github.com/wneessen/go-mail" +) + +// Config is the user's SMTP configuration. +type Config struct { + Host string + Port int + User string + Password string + From string + Encryption string // "ssl" | "starttls" | "none" + Auth bool // SMTP requires authorization (send username/password) +} + +func (c Config) opts() []mail.Option { + o := []mail.Option{mail.WithPort(c.Port), mail.WithTimeout(30 * time.Second)} + if c.Auth && c.User != "" { + // AutoDiscover negotiates whatever mechanism the server advertises + // (LOGIN, PLAIN, CRAM-MD5, …). OVH, for instance, rejects forced PLAIN. + o = append(o, mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithUsername(c.User), mail.WithPassword(c.Password)) + } + switch c.Encryption { + case "ssl": + o = append(o, mail.WithSSL()) + case "none": + o = append(o, mail.WithTLSPolicy(mail.NoTLS)) + default: // starttls + o = append(o, mail.WithTLSPolicy(mail.TLSMandatory)) + } + return o +} + +// Send delivers a plain-text email to `to`, optionally attaching a file. +func Send(cfg Config, to, subject, body, attachPath string) error { + if cfg.Host == "" { + return fmt.Errorf("SMTP server not configured") + } + if to == "" { + return fmt.Errorf("no recipient e-mail") + } + from := cfg.From + if from == "" { + from = cfg.User + } + m := mail.NewMsg() + if err := m.From(from); err != nil { + return fmt.Errorf("bad sender %q: %w", from, err) + } + if err := m.To(to); err != nil { + return fmt.Errorf("bad recipient %q: %w", to, err) + } + m.Subject(subject) + m.SetBodyString(mail.TypeTextPlain, body) + if attachPath != "" { + m.AttachFile(attachPath) + } + client, err := mail.NewClient(cfg.Host, cfg.opts()...) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + if err := client.DialAndSend(m); err != nil { + return fmt.Errorf("send: %w", err) + } + return nil +}