[refactor](trx-backend-soapysdr): auto-calculate FIR taps from bandwidth
Replace the static fir_taps parameter with auto_taps(cutoff_norm) which computes ceil(3.32 / cutoff_norm).clamp(63, 16383). This ensures the filter transition band equals one passband width regardless of SDR sample rate, giving correct image rejection when the user sets audio_bandwidth_hz. At 912 ksps with 3 kHz audio bandwidth this yields ~2018 taps instead of the previous hardcoded 64, eliminating the 114 kHz stopband gap that caused adjacent-band signals to alias into the audio output. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -147,7 +147,7 @@ impl SdrPipeline {
|
|||||||
wfm_deemphasis_us: u32,
|
wfm_deemphasis_us: u32,
|
||||||
wfm_stereo: bool,
|
wfm_stereo: bool,
|
||||||
squelch_cfg: VirtualSquelchConfig,
|
squelch_cfg: VirtualSquelchConfig,
|
||||||
channels: &[(f64, RigMode, u32, usize)],
|
channels: &[(f64, RigMode, u32)],
|
||||||
) -> Self {
|
) -> Self {
|
||||||
const IQ_BROADCAST_CAPACITY: usize = 64;
|
const IQ_BROADCAST_CAPACITY: usize = 64;
|
||||||
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
|
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
|
||||||
@@ -158,7 +158,7 @@ impl SdrPipeline {
|
|||||||
let mut iq_senders = Vec::with_capacity(channels.len());
|
let mut iq_senders = Vec::with_capacity(channels.len());
|
||||||
let mut channel_dsps_vec: Vec<Arc<Mutex<ChannelDsp>>> = Vec::with_capacity(channels.len());
|
let mut channel_dsps_vec: Vec<Arc<Mutex<ChannelDsp>>> = Vec::with_capacity(channels.len());
|
||||||
|
|
||||||
for (channel_idx, &(channel_if_hz, ref mode, audio_bandwidth_hz, fir_taps)) in
|
for (channel_idx, &(channel_if_hz, ref mode, audio_bandwidth_hz)) in
|
||||||
channels.iter().enumerate()
|
channels.iter().enumerate()
|
||||||
{
|
{
|
||||||
let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(PCM_BROADCAST_CAPACITY);
|
let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(PCM_BROADCAST_CAPACITY);
|
||||||
@@ -178,7 +178,6 @@ impl SdrPipeline {
|
|||||||
audio_bandwidth_hz,
|
audio_bandwidth_hz,
|
||||||
wfm_deemphasis_us,
|
wfm_deemphasis_us,
|
||||||
wfm_stereo,
|
wfm_stereo,
|
||||||
fir_taps,
|
|
||||||
false,
|
false,
|
||||||
channel_squelch_cfg,
|
channel_squelch_cfg,
|
||||||
pcm_tx.clone(),
|
pcm_tx.clone(),
|
||||||
@@ -250,7 +249,6 @@ impl SdrPipeline {
|
|||||||
channel_if_hz: f64,
|
channel_if_hz: f64,
|
||||||
mode: &RigMode,
|
mode: &RigMode,
|
||||||
bandwidth_hz: u32,
|
bandwidth_hz: u32,
|
||||||
fir_taps: usize,
|
|
||||||
) -> (broadcast::Sender<Vec<f32>>, broadcast::Sender<Vec<Complex<f32>>>) {
|
) -> (broadcast::Sender<Vec<f32>>, broadcast::Sender<Vec<Complex<f32>>>) {
|
||||||
const PCM_BROADCAST_CAPACITY: usize = 32;
|
const PCM_BROADCAST_CAPACITY: usize = 32;
|
||||||
const IQ_BROADCAST_CAPACITY: usize = 64;
|
const IQ_BROADCAST_CAPACITY: usize = 64;
|
||||||
@@ -266,7 +264,6 @@ impl SdrPipeline {
|
|||||||
bandwidth_hz,
|
bandwidth_hz,
|
||||||
self.wfm_deemphasis_us,
|
self.wfm_deemphasis_us,
|
||||||
self.wfm_stereo,
|
self.wfm_stereo,
|
||||||
fir_taps.max(1),
|
|
||||||
false,
|
false,
|
||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
pcm_tx.clone(),
|
pcm_tx.clone(),
|
||||||
@@ -558,7 +555,7 @@ mod tests {
|
|||||||
75,
|
75,
|
||||||
true,
|
true,
|
||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
&[(200_000.0, RigMode::USB, 3000, 64)],
|
&[(200_000.0, RigMode::USB, 3000)],
|
||||||
);
|
);
|
||||||
assert_eq!(pipeline.pcm_senders.len(), 1);
|
assert_eq!(pipeline.pcm_senders.len(), 1);
|
||||||
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1);
|
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1);
|
||||||
|
|||||||
@@ -160,6 +160,18 @@ fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculate the FIR tap count automatically from the normalised cutoff frequency.
|
||||||
|
///
|
||||||
|
/// Uses the Hann-windowed sinc rule-of-thumb: taps = ceil(3.32 / cutoff_norm),
|
||||||
|
/// clamped to [63, 16383]. This gives enough taps so the filter transition band
|
||||||
|
/// equals one passband width (image rejection starts at audio_bandwidth_hz).
|
||||||
|
fn auto_taps(cutoff_norm: f32) -> usize {
|
||||||
|
if cutoff_norm <= 0.0 {
|
||||||
|
return 63;
|
||||||
|
}
|
||||||
|
((3.32 / cutoff_norm).ceil() as usize).clamp(63, 16383)
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-channel DSP state: mixer, FFT-FIR, decimator, demodulator, frame accumulator.
|
/// Per-channel DSP state: mixer, FFT-FIR, decimator, demodulator, frame accumulator.
|
||||||
pub struct ChannelDsp {
|
pub struct ChannelDsp {
|
||||||
pub channel_if_hz: f64,
|
pub channel_if_hz: f64,
|
||||||
@@ -169,7 +181,6 @@ pub struct ChannelDsp {
|
|||||||
sdr_sample_rate: u32,
|
sdr_sample_rate: u32,
|
||||||
audio_sample_rate: u32,
|
audio_sample_rate: u32,
|
||||||
audio_bandwidth_hz: u32,
|
audio_bandwidth_hz: u32,
|
||||||
fir_taps: usize,
|
|
||||||
wfm_deemphasis_us: u32,
|
wfm_deemphasis_us: u32,
|
||||||
wfm_stereo: bool,
|
wfm_stereo: bool,
|
||||||
wfm_denoise: WfmDenoiseLevel,
|
wfm_denoise: WfmDenoiseLevel,
|
||||||
@@ -254,7 +265,7 @@ impl ChannelDsp {
|
|||||||
} else {
|
} else {
|
||||||
(cutoff_hz / self.sdr_sample_rate as f32).min(0.499)
|
(cutoff_hz / self.sdr_sample_rate as f32).min(0.499)
|
||||||
};
|
};
|
||||||
self.lpf_iq = BlockFirFilterPair::new(cutoff_norm, ssb_shift_norm(&self.mode, cutoff_norm), self.fir_taps, IQ_BLOCK_SIZE);
|
self.lpf_iq = BlockFirFilterPair::new(cutoff_norm, ssb_shift_norm(&self.mode, cutoff_norm), auto_taps(cutoff_norm), IQ_BLOCK_SIZE);
|
||||||
let rate_changed = self.decim_factor != next_decim_factor;
|
let rate_changed = self.decim_factor != next_decim_factor;
|
||||||
self.decim_factor = next_decim_factor;
|
self.decim_factor = next_decim_factor;
|
||||||
self.decim_counter = 0;
|
self.decim_counter = 0;
|
||||||
@@ -297,7 +308,6 @@ impl ChannelDsp {
|
|||||||
audio_bandwidth_hz: u32,
|
audio_bandwidth_hz: u32,
|
||||||
wfm_deemphasis_us: u32,
|
wfm_deemphasis_us: u32,
|
||||||
wfm_stereo: bool,
|
wfm_stereo: bool,
|
||||||
fir_taps: usize,
|
|
||||||
force_mono_pcm: bool,
|
force_mono_pcm: bool,
|
||||||
squelch_cfg: VirtualSquelchConfig,
|
squelch_cfg: VirtualSquelchConfig,
|
||||||
pcm_tx: broadcast::Sender<Vec<f32>>,
|
pcm_tx: broadcast::Sender<Vec<f32>>,
|
||||||
@@ -311,7 +321,6 @@ impl ChannelDsp {
|
|||||||
(audio_sample_rate as usize * frame_duration_ms as usize * output_channels) / 1000
|
(audio_sample_rate as usize * frame_duration_ms as usize * output_channels) / 1000
|
||||||
};
|
};
|
||||||
|
|
||||||
let taps = fir_taps.max(1);
|
|
||||||
let (decim_factor, channel_sample_rate) =
|
let (decim_factor, channel_sample_rate) =
|
||||||
Self::pipeline_rates(mode, sdr_sample_rate, audio_sample_rate, audio_bandwidth_hz);
|
Self::pipeline_rates(mode, sdr_sample_rate, audio_sample_rate, audio_bandwidth_hz);
|
||||||
let cutoff_hz = audio_bandwidth_hz.min(channel_sample_rate.saturating_sub(1)) as f32 / 2.0;
|
let cutoff_hz = audio_bandwidth_hz.min(channel_sample_rate.saturating_sub(1)) as f32 / 2.0;
|
||||||
@@ -331,11 +340,10 @@ impl ChannelDsp {
|
|||||||
channel_if_hz,
|
channel_if_hz,
|
||||||
demodulator: Demodulator::for_mode(mode),
|
demodulator: Demodulator::for_mode(mode),
|
||||||
mode: mode.clone(),
|
mode: mode.clone(),
|
||||||
lpf_iq: BlockFirFilterPair::new(cutoff_norm, ssb_shift_norm(mode, cutoff_norm), taps, IQ_BLOCK_SIZE),
|
lpf_iq: BlockFirFilterPair::new(cutoff_norm, ssb_shift_norm(mode, cutoff_norm), auto_taps(cutoff_norm), IQ_BLOCK_SIZE),
|
||||||
sdr_sample_rate,
|
sdr_sample_rate,
|
||||||
audio_sample_rate,
|
audio_sample_rate,
|
||||||
audio_bandwidth_hz,
|
audio_bandwidth_hz,
|
||||||
fir_taps: taps,
|
|
||||||
wfm_deemphasis_us,
|
wfm_deemphasis_us,
|
||||||
wfm_stereo,
|
wfm_stereo,
|
||||||
wfm_denoise: WfmDenoiseLevel::Auto,
|
wfm_denoise: WfmDenoiseLevel::Auto,
|
||||||
@@ -406,9 +414,8 @@ impl ChannelDsp {
|
|||||||
self.rebuild_filters(true);
|
self.rebuild_filters(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_filter(&mut self, bandwidth_hz: u32, taps: usize) {
|
pub fn set_filter(&mut self, bandwidth_hz: u32) {
|
||||||
self.audio_bandwidth_hz = Self::clamp_bandwidth_for_mode(&self.mode, bandwidth_hz);
|
self.audio_bandwidth_hz = Self::clamp_bandwidth_for_mode(&self.mode, bandwidth_hz);
|
||||||
self.fir_taps = taps.max(1);
|
|
||||||
self.rebuild_filters(false);
|
self.rebuild_filters(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,7 +640,6 @@ mod tests {
|
|||||||
3000,
|
3000,
|
||||||
75,
|
75,
|
||||||
true,
|
true,
|
||||||
31,
|
|
||||||
false,
|
false,
|
||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
pcm_tx,
|
pcm_tx,
|
||||||
@@ -657,7 +663,6 @@ mod tests {
|
|||||||
3000,
|
3000,
|
||||||
75,
|
75,
|
||||||
true,
|
true,
|
||||||
31,
|
|
||||||
false,
|
false,
|
||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
pcm_tx,
|
pcm_tx,
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ pub struct SoapySdrRig {
|
|||||||
primary_channel_idx: usize,
|
primary_channel_idx: usize,
|
||||||
/// Current filter state of the primary channel (for filter_controls support).
|
/// Current filter state of the primary channel (for filter_controls support).
|
||||||
bandwidth_hz: u32,
|
bandwidth_hz: u32,
|
||||||
fir_taps: u32,
|
|
||||||
/// Shared spectrum magnitude buffer populated by the IQ read loop.
|
/// Shared spectrum magnitude buffer populated by the IQ read loop.
|
||||||
spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>,
|
spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>,
|
||||||
/// How many Hz below the dial frequency the SDR hardware is actually tuned.
|
/// How many Hz below the dial frequency the SDR hardware is actually tuned.
|
||||||
@@ -89,7 +88,7 @@ impl SoapySdrRig {
|
|||||||
/// - `args`: SoapySDR device args string (e.g. `"driver=rtlsdr"`).
|
/// - `args`: SoapySDR device args string (e.g. `"driver=rtlsdr"`).
|
||||||
/// Opens a real hardware device via SoapySDR.
|
/// Opens a real hardware device via SoapySDR.
|
||||||
/// - `channels`: per-channel tuples of
|
/// - `channels`: per-channel tuples of
|
||||||
/// `(channel_if_hz, initial_mode, audio_bandwidth_hz, fir_taps)`.
|
/// `(channel_if_hz, initial_mode, audio_bandwidth_hz)`.
|
||||||
/// - `gain_mode`: `"auto"` or `"manual"`.
|
/// - `gain_mode`: `"auto"` or `"manual"`.
|
||||||
/// - `gain_db`: gain in dB; used when `gain_mode == "manual"`.
|
/// - `gain_db`: gain in dB; used when `gain_mode == "manual"`.
|
||||||
/// - `max_gain_db`: optional hard ceiling for the applied hardware gain.
|
/// - `max_gain_db`: optional hard ceiling for the applied hardware gain.
|
||||||
@@ -111,7 +110,7 @@ impl SoapySdrRig {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new_with_config(
|
pub fn new_with_config(
|
||||||
args: &str,
|
args: &str,
|
||||||
channels: &[(f64, RigMode, u32, usize)],
|
channels: &[(f64, RigMode, u32)],
|
||||||
gain_mode: &str,
|
gain_mode: &str,
|
||||||
gain_db: f64,
|
gain_db: f64,
|
||||||
max_gain_db: Option<f64>,
|
max_gain_db: Option<f64>,
|
||||||
@@ -178,13 +177,11 @@ impl SoapySdrRig {
|
|||||||
(initial_freq.hz as i64 - hardware_center_hz) as f64,
|
(initial_freq.hz as i64 - hardware_center_hz) as f64,
|
||||||
RigMode::FM,
|
RigMode::FM,
|
||||||
25_000,
|
25_000,
|
||||||
96,
|
|
||||||
));
|
));
|
||||||
all_channels.push((
|
all_channels.push((
|
||||||
(initial_freq.hz as i64 + AIS_CHANNEL_SPACING_HZ - hardware_center_hz) as f64,
|
(initial_freq.hz as i64 + AIS_CHANNEL_SPACING_HZ - hardware_center_hz) as f64,
|
||||||
RigMode::FM,
|
RigMode::FM,
|
||||||
25_000,
|
25_000,
|
||||||
96,
|
|
||||||
));
|
));
|
||||||
let block_ms = if sdr_sample_rate == 0 {
|
let block_ms = if sdr_sample_rate == 0 {
|
||||||
0.0
|
0.0
|
||||||
@@ -259,10 +256,10 @@ impl SoapySdrRig {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Initialise filter state from primary channel config (index 0), or defaults.
|
// Initialise filter state from primary channel config (index 0), or defaults.
|
||||||
let (bandwidth_hz, fir_taps) = channels
|
let bandwidth_hz = channels
|
||||||
.first()
|
.first()
|
||||||
.map(|&(_, _, bw, taps)| (bw, taps as u32))
|
.map(|&(_, _, bw)| bw)
|
||||||
.unwrap_or((3000, 64));
|
.unwrap_or(3000);
|
||||||
|
|
||||||
let spectrum_buf = pipeline.spectrum_buf.clone();
|
let spectrum_buf = pipeline.spectrum_buf.clone();
|
||||||
let retune_cmd = pipeline.retune_cmd.clone();
|
let retune_cmd = pipeline.retune_cmd.clone();
|
||||||
@@ -286,7 +283,6 @@ impl SoapySdrRig {
|
|||||||
pipeline,
|
pipeline,
|
||||||
primary_channel_idx: 0,
|
primary_channel_idx: 0,
|
||||||
bandwidth_hz,
|
bandwidth_hz,
|
||||||
fir_taps,
|
|
||||||
spectrum_buf,
|
spectrum_buf,
|
||||||
center_offset_hz,
|
center_offset_hz,
|
||||||
center_hz: hardware_center_hz,
|
center_hz: hardware_center_hz,
|
||||||
@@ -365,7 +361,7 @@ impl SoapySdrRig {
|
|||||||
dsp_arc
|
dsp_arc
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.set_filter(self.bandwidth_hz, self.fir_taps as usize);
|
.set_filter(self.bandwidth_hz);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -537,7 +533,7 @@ impl RigCat for SoapySdrRig {
|
|||||||
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
||||||
let mut dsp = dsp_arc.lock().unwrap();
|
let mut dsp = dsp_arc.lock().unwrap();
|
||||||
dsp.set_mode(&mode);
|
dsp.set_mode(&mode);
|
||||||
dsp.set_filter(self.bandwidth_hz, self.fir_taps as usize);
|
dsp.set_filter(self.bandwidth_hz);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.apply_ais_channel_activity();
|
self.apply_ais_channel_activity();
|
||||||
@@ -755,28 +751,7 @@ impl RigCat for SoapySdrRig {
|
|||||||
dsp_arc
|
dsp_arc
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.set_filter(bandwidth_hz, self.fir_taps as usize);
|
.set_filter(bandwidth_hz);
|
||||||
}
|
|
||||||
}
|
|
||||||
self.apply_ais_channel_filters();
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_fir_taps<'a>(
|
|
||||||
&'a mut self,
|
|
||||||
taps: u32,
|
|
||||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
tracing::debug!("SoapySdrRig: set_fir_taps -> {}", taps);
|
|
||||||
self.fir_taps = taps;
|
|
||||||
{
|
|
||||||
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
|
||||||
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
|
||||||
dsp_arc
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.set_filter(self.bandwidth_hz, taps as usize);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.apply_ais_channel_filters();
|
self.apply_ais_channel_filters();
|
||||||
@@ -827,7 +802,6 @@ impl RigCat for SoapySdrRig {
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
Some(RigFilterState {
|
Some(RigFilterState {
|
||||||
bandwidth_hz: self.bandwidth_hz,
|
bandwidth_hz: self.bandwidth_hz,
|
||||||
fir_taps: self.fir_taps,
|
|
||||||
cw_center_hz: 700,
|
cw_center_hz: 700,
|
||||||
sdr_gain_db: Some(
|
sdr_gain_db: Some(
|
||||||
self.max_gain_db
|
self.max_gain_db
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ use trx_core::vchan::{VChanError, VChannelInfo, VirtualChannelManager};
|
|||||||
// Default DSP parameters for virtual channels
|
// Default DSP parameters for virtual channels
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const DEFAULT_FIR_TAPS: usize = 64;
|
|
||||||
|
|
||||||
fn default_bandwidth_hz(mode: &RigMode) -> u32 {
|
fn default_bandwidth_hz(mode: &RigMode) -> u32 {
|
||||||
match mode {
|
match mode {
|
||||||
RigMode::CW | RigMode::CWR => 500,
|
RigMode::CW | RigMode::CWR => 500,
|
||||||
@@ -181,7 +179,7 @@ impl SdrVirtualChannelManager {
|
|||||||
let bandwidth_hz = default_bandwidth_hz(mode);
|
let bandwidth_hz = default_bandwidth_hz(mode);
|
||||||
let (pcm_tx, iq_tx) =
|
let (pcm_tx, iq_tx) =
|
||||||
self.pipeline
|
self.pipeline
|
||||||
.add_virtual_channel(if_hz as f64, mode, bandwidth_hz, DEFAULT_FIR_TAPS);
|
.add_virtual_channel(if_hz as f64, mode, bandwidth_hz);
|
||||||
|
|
||||||
let pipeline_slot = self
|
let pipeline_slot = self
|
||||||
.pipeline
|
.pipeline
|
||||||
@@ -344,7 +342,7 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
|
|||||||
|
|
||||||
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
||||||
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
|
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
|
||||||
dsp_arc.lock().unwrap().set_filter(bandwidth_hz, DEFAULT_FIR_TAPS);
|
dsp_arc.lock().unwrap().set_filter(bandwidth_hz);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -436,7 +434,7 @@ mod tests {
|
|||||||
75,
|
75,
|
||||||
true,
|
true,
|
||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
&[(0.0, RigMode::USB, 3_000, 64)],
|
&[(0.0, RigMode::USB, 3_000)],
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user