Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions desktop/linux/run-ft8af.sh
Original file line number Diff line number Diff line change
@@ -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" "$@"
23 changes: 23 additions & 0 deletions desktop/src/styles.css → desktop/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
85 changes: 60 additions & 25 deletions desktop/src-tauri/src/audio/input.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -22,12 +22,23 @@ pub struct AudioInput {
worker: Option<JoinHandle<()>>,
pub device_name: String,
pub device_rate: u32,
gain: Arc<AtomicU32>, // 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<SlotAccumulator>,
initial_gain: f32,
) -> anyhow::Result<AudioInput> {
let device = find_input_device(device_name)
.ok_or_else(|| anyhow::anyhow!("no input audio device available"))?;
Expand All @@ -42,29 +53,43 @@ impl AudioInput {
let rb = HeapRb::<f32>::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()?;
Expand Down Expand Up @@ -96,6 +121,7 @@ impl AudioInput {
worker: Some(worker),
device_name: dev_name,
device_rate,
gain,
})
}
}
Expand All @@ -109,8 +135,12 @@ impl Drop for AudioInput {
}
}

/// Downmix interleaved `T` frames to mono f32 and push into the ring producer.
fn push_mono<T, P>(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<T, P>(data: &[T], channels: usize, prod: &mut P, gain: &AtomicU32)
where
T: Sample,
f32: FromSample<T>,
Expand All @@ -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;
}
Expand All @@ -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));
}
}
36 changes: 35 additions & 1 deletion desktop/src-tauri/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String>),
SetOutputDevice(Option<String>),
SelectRig(RigConfig),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -310,6 +328,11 @@ impl Engine {
.and_then(|s| s.parse::<f32>().ok())
.map(clamp_tx_gain)
.unwrap_or(DEFAULT_TX_GAIN);
let rx_gain = db
.get_config("rx_gain")
.and_then(|s| s.parse::<f32>().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<i64> = db.get_config("clock_offset_ms").and_then(|s| s.parse().ok());
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading