diff --git a/desktop/linux/run-ft8af.sh b/desktop/linux/run-ft8af.sh new file mode 100755 index 000000000..87ffe6b1f --- /dev/null +++ b/desktop/linux/run-ft8af.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Launcher for FT8AF on Linux, working around a real Hamlib version conflict +# (see rig.rs's load_hamlib(): a bare dlopen("libhamlib.so.4"), resolved +# against whatever the dynamic linker finds first). +# +# QMX (and other newer rigs) need a newer Hamlib than the distro package +# usually ships -- confirmed on this machine: the system's +# libhamlib.so.4 is Hamlib 4.5.4 (no QMX support at all), while +# /usr/local/lib/libhamlib.so.4 is a separately-built Hamlib 4.7.1 that does +# have it. Both share the same soname, so whichever the linker resolves +# first wins for any process that doesn't override the search path. +# +# We deliberately do NOT upgrade or replace the system Hamlib package -- +# other software on this machine (CQRLOG) depends on it, and installing a +# newer one over it would break that. Instead this script sets +# LD_LIBRARY_PATH just for FT8AF's own process, so it preferentially finds +# the newer build at /usr/local/lib without touching anything system-wide. +# If no such build exists, this is a no-op and FT8AF falls back to whatever +# the system provides (same behavior as running the binary directly). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BIN="$SCRIPT_DIR/../src-tauri/target/release/ft8af" + +if [ ! -x "$BIN" ]; then + echo "error: $BIN not found or not executable -- build it first (npm run tauri build)" >&2 + exit 1 +fi + +if [ -f /usr/local/lib/libhamlib.so.4 ]; then + export LD_LIBRARY_PATH="/usr/local/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +fi + +exec "$BIN" "$@" diff --git a/desktop/src/styles.css b/desktop/public/styles.css similarity index 87% rename from desktop/src/styles.css rename to desktop/public/styles.css index 230eb2cce..7189b9b3f 100644 --- a/desktop/src/styles.css +++ b/desktop/public/styles.css @@ -48,6 +48,29 @@ select, input { font-size: 13px; } +/* Scoped test: only the Rig control "Display name" field, targeted by its + placeholder text (no JSX/rebuild needed) -- not touching the global + select/input rule, which broke real keyboard input when modified. */ +input[placeholder="e.g. Flex 6400"] { + color: #fff; +} + +/* Scoped: Radio/Backend/Connection/Serial port/Baud/Band selects, each + targeted by its own id -- not the shared select/input rule. */ +#band-select, +#rig-backend-select, +#rig-radio-select, +#rig-connection-select, +#rig-serial-port-select, +#rig-baud-select, +#audio-input-select, +#audio-output-select, +#wf-window-select, +#wf-fft-size-select, +#wf-avg-select { + color: #000; +} + .topbar { display: flex; align-items: center; diff --git a/desktop/src-tauri/src/audio/input.rs b/desktop/src-tauri/src/audio/input.rs index 550e600c8..dd2c6f780 100644 --- a/desktop/src-tauri/src/audio/input.rs +++ b/desktop/src-tauri/src/audio/input.rs @@ -1,7 +1,7 @@ //! Continuous audio capture: open an input device, downmix to mono, resample to //! 12 kHz on a worker thread, and feed a `SlotAccumulator`. -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; use std::thread::JoinHandle; @@ -22,12 +22,23 @@ pub struct AudioInput { worker: Option>, pub device_name: String, pub device_rate: u32, + gain: Arc, // f32 bits, read/written lock-free from the realtime callback } impl AudioInput { + /// Live-adjustable RX gain (a linear multiplier applied to each downmixed + /// sample, e.g. 1.0 = unity, 2.0 = +6 dB) -- some bands are noisier than + /// others, so this is expected to change often while decoding, not just + /// at startup. Lock-free: the realtime audio callback only ever loads + /// this, never blocks on it. + pub fn set_gain(&self, g: f32) { + self.gain.store(g.to_bits(), Ordering::Relaxed); + } + pub fn start( device_name: Option<&str>, accum: Arc, + initial_gain: f32, ) -> anyhow::Result { let device = find_input_device(device_name) .ok_or_else(|| anyhow::anyhow!("no input audio device available"))?; @@ -42,29 +53,43 @@ impl AudioInput { let rb = HeapRb::::new(device_rate as usize * 2); // ~2 s headroom let (mut prod, mut cons) = rb.split(); + let gain = Arc::new(AtomicU32::new(initial_gain.to_bits())); + let err_fn = |e| log::error!("audio input stream error: {e}"); - // Build a callback that downmixes interleaved frames to mono f32 and - // pushes into the ring. One arm per supported sample format. + // Build a callback that downmixes interleaved frames to mono f32, + // applies the live RX gain, and pushes into the ring. One arm per + // supported sample format. Each arm clones `gain` independently -- + // disjoint match arms may each move their own capture of a variable + // without conflicting (only one arm's closure is ever actually built). let stream = match sample_format { - SampleFormat::F32 => device.build_input_stream( - &config, - move |data: &[f32], _| push_mono(data, channels, &mut prod), - err_fn, - None, - )?, - SampleFormat::I16 => device.build_input_stream( - &config, - move |data: &[i16], _| push_mono(data, channels, &mut prod), - err_fn, - None, - )?, - SampleFormat::U16 => device.build_input_stream( - &config, - move |data: &[u16], _| push_mono(data, channels, &mut prod), - err_fn, - None, - )?, + SampleFormat::F32 => { + let gain = gain.clone(); + device.build_input_stream( + &config, + move |data: &[f32], _| push_mono(data, channels, &mut prod, &gain), + err_fn, + None, + )? + } + SampleFormat::I16 => { + let gain = gain.clone(); + device.build_input_stream( + &config, + move |data: &[i16], _| push_mono(data, channels, &mut prod, &gain), + err_fn, + None, + )? + } + SampleFormat::U16 => { + let gain = gain.clone(); + device.build_input_stream( + &config, + move |data: &[u16], _| push_mono(data, channels, &mut prod, &gain), + err_fn, + None, + )? + } other => anyhow::bail!("unsupported input sample format: {other:?}"), }; stream.play()?; @@ -96,6 +121,7 @@ impl AudioInput { worker: Some(worker), device_name: dev_name, device_rate, + gain, }) } } @@ -109,8 +135,12 @@ impl Drop for AudioInput { } } -/// Downmix interleaved `T` frames to mono f32 and push into the ring producer. -fn push_mono(data: &[T], channels: usize, prod: &mut P) +/// Downmix interleaved `T` frames to mono f32, apply the live RX gain, and +/// push into the ring producer. `gain` is read fresh per sample (a relaxed +/// atomic load is cheap, and this runs on the realtime audio thread, so no +/// locking) -- lets the gain slider feel immediate rather than only taking +/// effect on the next buffer. +fn push_mono(data: &[T], channels: usize, prod: &mut P, gain: &AtomicU32) where T: Sample, f32: FromSample, @@ -119,9 +149,14 @@ where if channels == 0 { return; } + let g = f32::from_bits(gain.load(Ordering::Relaxed)); + // Clamp to full scale, same as the TX gain path (audio/output.rs) -- gain + // can go well past unity (see clamp_rx_gain), and hard-clipping here + // mirrors what a real ADC does when overdriven, rather than passing + // arbitrarily large sample values into the resampler/decoder. if channels == 1 { for &s in data { - let _ = prod.try_push(f32::from_sample(s)); + let _ = prod.try_push((f32::from_sample(s) * g).clamp(-1.0, 1.0)); } return; } @@ -130,6 +165,6 @@ where for &s in frame { acc += f32::from_sample(s); } - let _ = prod.try_push(acc / channels as f32); + let _ = prod.try_push(((acc / channels as f32) * g).clamp(-1.0, 1.0)); } } diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 756358db6..7e7387397 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -43,6 +43,19 @@ const DEFAULT_TX_GAIN: f32 = 0.9; fn clamp_tx_gain(g: f32) -> f32 { g.clamp(0.0, 1.0) } + +// Default RX input gain: unity (no change) -- a fresh install shouldn't +// alter whatever level the operator's soundcard/interface already provides. +const DEFAULT_RX_GAIN: f32 = 1.0; + +/// Clamp a requested RX gain into 0.0–1.0, same range as TX gain. Widened to +/// 0.0-8.0 during testing to check the control had real effect (confirmed: +/// a clip warning at 800%, silence at 0%) -- but adjustment past 100% wasn't +/// doing anything practically useful, so finalized at 0-100% for finer +/// control resolution across the range that actually matters. +fn clamp_rx_gain(g: f32) -> f32 { + g.clamp(0.0, 1.0) +} // Live-waterfall FFT parameters (window/size/averaging + display constants) // live in `crate::wf` and are runtime-configurable via SetWaterfallConfig. // Input RMS at/below this (dBFS) counts as silence — no audio reaching the app. @@ -97,6 +110,9 @@ pub enum EngineCommand { SetBaseFreq(i32), /// TX output level, 0.0–1.0 (drive into the soundcard/USB audio path). SetTxGain(f32), + /// RX input gain, 0.0–2.0 (post-ADC software trim, applied live without + /// restarting capture -- some bands are noisier than others). + SetRxGain(f32), SetInputDevice(Option), SetOutputDevice(Option), SelectRig(RigConfig), @@ -265,6 +281,8 @@ struct Engine { tx_audio_hz: i32, /// TX output level (0.0–1.0) applied to the waveform before playback. tx_gain: f32, + /// RX input gain (0.0–2.0), applied live inside the capture callback. + rx_gain: f32, /// Slot id (rx-corrected clock) most recently handed to the decode worker. /// Guards the once-per-slot early decode trigger in the run loop. last_decoded_slot: i64, @@ -310,6 +328,11 @@ impl Engine { .and_then(|s| s.parse::().ok()) .map(clamp_tx_gain) .unwrap_or(DEFAULT_TX_GAIN); + let rx_gain = db + .get_config("rx_gain") + .and_then(|s| s.parse::().ok()) + .map(clamp_rx_gain) + .unwrap_or(DEFAULT_RX_GAIN); // Restore the last NTP offset so DT is roughly right immediately, before // the first fresh sync of this session lands. Treated as already-synced. let saved_offset: Option = db.get_config("clock_offset_ms").and_then(|s| s.parse().ok()); @@ -375,6 +398,7 @@ impl Engine { dial_hz, tx_audio_hz, tx_gain, + rx_gain, last_decoded_slot: -1, rx_offset_ms, last_tick_ms: 0, @@ -753,6 +777,16 @@ impl Engine { self.tx_gain = clamp_tx_gain(g); let _ = self.db.set_config("tx_gain", &self.tx_gain.to_string()); } + EngineCommand::SetRxGain(g) => { + self.rx_gain = clamp_rx_gain(g); + let _ = self.db.set_config("rx_gain", &self.rx_gain.to_string()); + // Applied live -- no capture restart, unlike changing the + // device itself. Some bands are noisier than others, so this + // is expected to be adjusted often while decoding. + if let Some(input) = &self.input { + input.set_gain(self.rx_gain); + } + } EngineCommand::SetWaterfallConfig(cfg) => { let cfg = cfg.sanitize(); let _ = self.db.set_config("wf_window", cfg.window.as_str()); @@ -866,7 +900,7 @@ impl Engine { } fn start_decode(&mut self) { - match AudioInput::start(self.input_device.as_deref(), self.accum.clone()) { + match AudioInput::start(self.input_device.as_deref(), self.accum.clone(), self.rx_gain) { Ok(input) => { self.emit(EngineEvent::Info(format!( "capturing from '{}' @ {} Hz", diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 118b7e372..5ccaa2a07 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -2,6 +2,7 @@ // exposes commands to the web UI, and forwards engine events to the webview. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::path::PathBuf; use std::sync::Arc; use serde::Serialize; @@ -87,6 +88,11 @@ fn set_tx_gain(state: State, gain: f32) { state.engine.send(EngineCommand::SetTxGain(gain)); } +#[tauri::command] +fn set_rx_gain(state: State, gain: f32) { + state.engine.send(EngineCommand::SetRxGain(gain)); +} + #[tauri::command] fn set_input_device(state: State, name: Option) { state.engine.send(EngineCommand::SetInputDevice(name)); @@ -191,6 +197,41 @@ fn set_waterfall_config(state: State, config: WfConfig) { state.engine.send(EngineCommand::SetWaterfallConfig(config)); } +fn app_data_dir() -> PathBuf { + dirs::data_dir() + .unwrap_or_else(std::env::temp_dir) + .join("FT8AF") +} + +// The compiled-in baseline -- only this constant needs a rebuild to change; +// everything a user actually edits lives at styles_path() on disk instead. +const DEFAULT_STYLES_CSS: &str = include_str!("../../public/styles.css"); + +fn styles_path() -> PathBuf { + app_data_dir().join("styles.css") +} + +#[tauri::command] +fn get_custom_css() -> String { + // Read from disk on every call, not from the Vite/Tauri-bundled frontend + // -- Tauri embeds frontendDist into the compiled binary at build time + // (confirmed directly: editing the bundled dist/styles.css after a build + // and relaunching the same binary had zero effect), so anything served + // from there needs a full rebuild for every change. This file lives + // outside that embed entirely, so editing it just needs an app relaunch + // -- the whole point, since this stylesheet is expected to change often. + let path = styles_path(); + match std::fs::read_to_string(&path) { + Ok(css) => css, + Err(_) => { + // First run: seed the file so there's something to open and edit. + let _ = std::fs::create_dir_all(app_data_dir()); + let _ = std::fs::write(&path, DEFAULT_STYLES_CSS); + DEFAULT_STYLES_CSS.to_string() + } + } +} + fn main() { // Debug helper: `ft8af --list-rigs` prints the Hamlib-enumerated rig count // and exits — verifies the bundled Hamlib library loads without the GUI. @@ -203,9 +244,38 @@ fn main() { return; } - let data_dir = dirs::data_dir() - .unwrap_or_else(std::env::temp_dir) - .join("FT8AF"); + // Debug helper: `ft8af --list-audio` prints cpal's enumerated input/output + // devices and exits -- same idea as --list-rigs, verifies device + // enumeration without needing to click through the GUI (native onBand(parseInt(e.target.value, 10))}> + onInputGain(parseInt(e.target.value, 10))} + style={{ width: 90 }} + /> + + {inputGain}% + +
@@ -772,6 +829,7 @@ function SettingsScreen(props: {
{ setOutput(e.target.value); @@ -843,6 +902,7 @@ function SettingsScreen(props: {
setRigCfg({ ...rigCfg, hamlib_model: parseInt(e.target.value, 10) }) @@ -884,6 +945,7 @@ function SettingsScreen(props: {
setRigCfg({ ...rigCfg, port: e.target.value })}> + setRigCfg({ ...rigCfg, baud: parseInt(e.target.value, 10) })} > @@ -988,7 +1051,7 @@ function SettingsScreen(props: {
- setRigCfg({ ...rigCfg, port: e.target.value })}> {ports.map((p) => (