Skip to content
Merged
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
49 changes: 49 additions & 0 deletions desktop/linux/run-ft8af.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/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 LD_PRELOADs that
# one library into FT8AF's own process: rig.rs's dlopen("libhamlib.so.4")
# then resolves to the already-loaded object, since the soname matches.
#
# LD_PRELOAD rather than prepending /usr/local/lib to LD_LIBRARY_PATH: the
# latter redirects *every* library FT8AF resolves, so a machine that also has
# a locally built libssl/libcurl/libstdc++ under /usr/local/lib would load
# those instead of the distro copies the binary was linked against -- version
# symbol errors or a crash at startup, from a script whose only job is to
# pick a Hamlib. If no newer build exists, this is a no-op and FT8AF falls
# back to whatever the system provides (same as running the binary directly).
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEV_BIN="$SCRIPT_DIR/../src-tauri/target/release/ft8af"

# Prefer the dev tree's build when this is run from a checkout, but fall back
# to an installed ft8af on PATH -- the .deb/AppImage CI produces puts it at
# /usr/bin/ft8af, and this script is meant to be copied out of the repo.
if [ -x "$DEV_BIN" ]; then
BIN="$DEV_BIN"
elif BIN="$(command -v ft8af)"; then
:
else
echo "error: no ft8af binary found -- looked at $DEV_BIN and on PATH." >&2
echo " Build it with 'npm run tauri build' or install the package." >&2
exit 1
fi

HAMLIB=/usr/local/lib/libhamlib.so.4
if [ -f "$HAMLIB" ]; then
export LD_PRELOAD="$HAMLIB${LD_PRELOAD:+:$LD_PRELOAD}"
fi

exec "$BIN" "$@"
37 changes: 37 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,43 @@ 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;
}

/* Select readability, fixed at the cause rather than per-id.
The original symptom is Linux-only: WebKitGTK draws <select> as a *native*
GTK widget, which ignores the author `background` from the shared
select/input rule above but still honors `color`, so the light --text sat
on a light GTK control and was unreadable. Forcing `color: #000` per id
papered over that, but WebView2 (Windows) and WKWebView (macOS) *do* honor
the dark --panel background, so the same rule turned every dropdown on
those platforms into black-on-#1a2129 -- and CI ships all three.
`appearance: none` opts the control out of native rendering everywhere, so
the panel background and --text color actually paint on every platform;
the arrow the native widget provided is redrawn as a background image. */
select {
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath fill='%238a99a8' d='M1 1.5 6 6.5l5-5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 10px 7px;
padding-right: 26px;
}

/* The open dropdown's popup is drawn by the platform, outside the page, so
`appearance` does not reach it. Pin both properties on the options so the
popup is self-consistent no matter which of the two an engine honors --
light ground, dark text, readable against every platform's default popup. */
select option {
background: #fff;
color: #000;
}

.topbar {
display: flex;
align-items: center;
Expand Down
158 changes: 133 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, 0.5 = -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,25 @@ 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) --
// hard-clipping mirrors what a real ADC does when overdriven, rather than
// passing arbitrarily large sample values into the resampler/decoder.
//
// Only *above* unity though. A gain of 1.0 or less cannot push a sample
// past full scale that was not already there, so clamping unconditionally
// would only ever change the samples a device delivered out of range in
// the first place -- and F32 devices (CoreAudio, JACK/PipeWire) do hand
// out the occasional sample a hair over 1.0. Clipping those on a default,
// unity-gain install adds harmonics to RX audio that the capture path
// never introduced before the gain control existed. clamp_rx_gain caps at
// 1.0 today, so this is dormant; it keeps the guard correct if the range
// is ever widened.
let clip = g > 1.0;
let limit = |x: f32| if clip { x.clamp(-1.0, 1.0) } else { x };
if channels == 1 {
for &s in data {
let _ = prod.try_push(f32::from_sample(s));
let _ = prod.try_push(limit(f32::from_sample(s) * g));
}
return;
}
Expand All @@ -130,6 +176,68 @@ where
for &s in frame {
acc += f32::from_sample(s);
}
let _ = prod.try_push(acc / channels as f32);
let _ = prod.try_push(limit((acc / channels as f32) * g));
}
}

#[cfg(test)]
mod tests {
use super::push_mono;
use ringbuf::traits::{Consumer, Split};
use ringbuf::HeapRb;
use std::sync::atomic::AtomicU32;

/// Run `push_mono` over `data` at the given gain and collect what landed.
fn pushed(data: &[f32], channels: usize, gain: f32) -> Vec<f32> {
let rb = HeapRb::<f32>::new(data.len().max(1) + 1);
let (mut prod, mut cons) = rb.split();
push_mono(data, channels, &mut prod, &AtomicU32::new(gain.to_bits()));
cons.pop_iter().collect()
}

#[test]
fn unity_gain_is_a_pure_pass_through() {
// The regression this guards: an unconditional clamp would hard-clip
// the over-full-scale samples F32 devices (CoreAudio, JACK/PipeWire)
// occasionally deliver, changing RX audio on a default install where
// the gain control was never touched.
let hot = [0.5, -0.5, 1.02, -1.04];
assert_eq!(pushed(&hot, 1, 1.0), hot.to_vec());
}

#[test]
fn gain_below_unity_scales_without_clipping() {
assert_eq!(pushed(&[0.5, -0.25], 1, 0.5), vec![0.25, -0.125]);
// Still no clamp: halving an over-scale sample leaves it over-scale,
// exactly as the pre-gain path delivered it.
assert_eq!(pushed(&[1.6], 1, 0.5), vec![0.8]);
assert_eq!(pushed(&[2.4], 1, 1.0), vec![2.4]);
}

#[test]
fn gain_above_unity_clips_to_full_scale() {
// Dormant while clamp_rx_gain caps at 1.0, but the guard has to be
// correct if that range is ever widened.
assert_eq!(pushed(&[0.8, -0.8, 0.1], 1, 2.0), vec![1.0, -1.0, 0.2]);
}

#[test]
fn multichannel_frames_are_averaged_then_gained() {
// Two stereo frames: (0.4, 0.8) -> 0.6, (-1.0, 0.0) -> -0.5.
assert_eq!(pushed(&[0.4, 0.8, -1.0, 0.0], 2, 1.0), vec![0.6, -0.5]);
assert_eq!(pushed(&[0.4, 0.8, -1.0, 0.0], 2, 0.5), vec![0.3, -0.25]);
}

#[test]
fn zero_channels_pushes_nothing() {
assert!(pushed(&[0.5, 0.5], 0, 1.0).is_empty());
}

#[test]
fn a_trailing_partial_frame_is_dropped() {
// chunks_exact: three samples on a stereo stream yield one frame.
let got = pushed(&[0.2, 0.4, 0.9], 2, 1.0);
assert_eq!(got.len(), 1);
assert!((got[0] - 0.3).abs() < 1e-6, "got {got:?}");
}
}
Loading
Loading