Merge pull request #2651 from f4exb/feature-fscan-voicesquelch

Voice Activity detection for Frequency Scanner in SSB modes
This commit is contained in:
Edouard Griffiths
2026-02-22 13:50:45 +01:00
committed by GitHub
17 changed files with 1339 additions and 108 deletions
+1
View File
@@ -20,6 +20,7 @@ These instructions guide Copilot to generate code that aligns with modern Qt C++
- Use **auto** for type deduction when it improves readability
- Utilize **smart pointers** (`std::unique_ptr`, `std::shared_ptr`) over raw pointers
- Apply **range-based for loops** instead of traditional iterator loops
- When the loop index is only used to access container elements, prefer **range-based loops** to avoid index/type mismatch and off-by-one errors (common Sonar findings)
- Use **constexpr** for compile-time constants and functions
- Leverage **structured bindings** for tuple/pair unpacking
- Use **std::optional** for optional values instead of null pointers
Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.
+150 -58
View File
@@ -49,6 +49,7 @@ MESSAGE_CLASS_DEFINITION(FreqScanner::MsgReportChannels, Message)
MESSAGE_CLASS_DEFINITION(FreqScanner::MsgStartScan, Message)
MESSAGE_CLASS_DEFINITION(FreqScanner::MsgStopScan, Message)
MESSAGE_CLASS_DEFINITION(FreqScanner::MsgScanComplete, Message)
MESSAGE_CLASS_DEFINITION(FreqScanner::MsgContinueScan, Message)
MESSAGE_CLASS_DEFINITION(FreqScanner::MsgScanResult, Message)
MESSAGE_CLASS_DEFINITION(FreqScanner::MsgStatus, Message)
MESSAGE_CLASS_DEFINITION(FreqScanner::MsgReportActiveFrequency, Message)
@@ -261,6 +262,12 @@ bool FreqScanner::handleMessage(const Message& cmd)
return true;
}
else if (MsgContinueScan::match(cmd))
{
continueScan();
return true;
}
else
{
return false;
@@ -307,7 +314,7 @@ void FreqScanner::initScan()
// }
mute(m_scanDeviceSetIndex, m_scanChannelIndex);
if (m_centerFrequency != m_stepStartFrequency) {
if (!m_settings.m_lockDeviceFrequency && (m_centerFrequency != m_stepStartFrequency)) {
setDeviceCenterFrequency(m_stepStartFrequency);
}
@@ -320,6 +327,11 @@ void FreqScanner::initScan()
m_state = SCAN_FOR_MAX_POWER;
}
void FreqScanner::continueScan()
{
m_state = SCAN_FOR_MAX_POWER;
}
void FreqScanner::processScanResults(const QDateTime& fftStartTime, const QList<MsgScanResult::ScanResult>& results)
{
switch (m_state)
@@ -374,54 +386,77 @@ void FreqScanner::processScanResults(const QDateTime& fftStartTime, const QList<
}
// Calculate next center frequency
bool complete = false; // Have all frequencies been scanned?
bool freqInRange = false;
const bool lockDeviceFrequency = m_settings.m_lockDeviceFrequency;
const qint64 currentCenterFrequency = m_centerFrequency;
bool complete = lockDeviceFrequency; // Have all frequencies been scanned?
bool freqInRange = lockDeviceFrequency;
qint64 nextCenterFrequency = m_centerFrequency;
int usableBW = (m_scannerSampleRate * 3 / 4) & ~1;
int nextFrequencyIndex = 0;
do
int nextFrequencyIndex = -1;
const auto isInCurrentScanRange = [currentCenterFrequency, usableBW](qint64 frequency)
{
if (nextCenterFrequency + usableBW / 2 > m_stepStopFrequency)
{
nextCenterFrequency = m_stepStartFrequency;
complete = true;
}
else
{
nextCenterFrequency += usableBW;
complete = false;
}
return (frequency >= currentCenterFrequency - usableBW / 2)
&& (frequency < currentCenterFrequency + usableBW / 2);
};
// Are any frequencies in this new range?
if (!lockDeviceFrequency)
{
do
{
if (nextCenterFrequency + usableBW / 2 > m_stepStopFrequency)
{
nextCenterFrequency = m_stepStartFrequency;
complete = true;
}
else
{
nextCenterFrequency += usableBW;
complete = false;
}
// Are any frequencies in this new range?
for (int i = 0; i < m_settings.m_frequencySettings.size(); i++)
{
if (m_settings.m_frequencySettings[i].m_enabled
&& (m_settings.m_frequencySettings[i].m_frequency >= nextCenterFrequency - usableBW / 2)
&& (m_settings.m_frequencySettings[i].m_frequency < nextCenterFrequency + usableBW / 2))
{
freqInRange = true;
nextFrequencyIndex = i;
// Do we need to realign for frequencies with wider bandwidths than default
if (!m_settings.m_frequencySettings[i].m_channelBandwidth.isEmpty())
{
bool ok;
int channelBW = m_settings.m_frequencySettings[i].m_channelBandwidth.toInt(&ok);
if (ok)
{
if (channelBW >= usableBW) {
nextCenterFrequency = m_settings.m_frequencySettings[i].m_frequency;
} else if (m_settings.m_frequencySettings[i].m_frequency - channelBW / 2 < nextCenterFrequency - usableBW / 2) {
nextCenterFrequency = m_settings.m_frequencySettings[i].m_frequency - channelBW / 2;
}
}
}
break;
}
}
}
while (!complete && !freqInRange);
}
else
{
for (int i = 0; i < m_settings.m_frequencySettings.size(); i++)
{
if (m_settings.m_frequencySettings[i].m_enabled
&& (m_settings.m_frequencySettings[i].m_frequency >= nextCenterFrequency - usableBW / 2)
&& (m_settings.m_frequencySettings[i].m_frequency < nextCenterFrequency + usableBW / 2))
&& isInCurrentScanRange(m_settings.m_frequencySettings[i].m_frequency))
{
freqInRange = true;
nextFrequencyIndex = i;
// Do we need to realign for frequencies with wider bandwidths than default
if (!m_settings.m_frequencySettings[i].m_channelBandwidth.isEmpty())
{
bool ok;
int channelBW = m_settings.m_frequencySettings[i].m_channelBandwidth.toInt(&ok);
if (ok)
{
if (channelBW >= usableBW) {
nextCenterFrequency = m_settings.m_frequencySettings[i].m_frequency;
} else if (m_settings.m_frequencySettings[i].m_frequency - channelBW / 2 < nextCenterFrequency - usableBW / 2) {
nextCenterFrequency = m_settings.m_frequencySettings[i].m_frequency - channelBW / 2;
}
}
}
break;
}
}
}
while (!complete && !freqInRange);
if (complete || (m_settings.m_mode == FreqScannerSettings::MULTIPLEX))
{
@@ -442,39 +477,67 @@ void FreqScanner::processScanResults(const QDateTime& fftStartTime, const QList<
if (m_settings.m_mode == FreqScannerSettings::MULTIPLEX)
{
activeFrequencySettings = &m_settings.m_frequencySettings[nextFrequencyIndex];
frequency = activeFrequencySettings->m_frequency;
if (nextFrequencyIndex >= 0)
{
activeFrequencySettings = &m_settings.m_frequencySettings[nextFrequencyIndex];
frequency = activeFrequencySettings->m_frequency;
}
}
else if (m_settings.m_priority == FreqScannerSettings::MAX_POWER)
{
Real maxPower = -200.0f;
Real maxVoiceActivityLevel = 0.0f;
// Find frequency with max power that exceeds thresholds
for (int i = 0; i < m_scanResults.size(); i++)
{
if (lockDeviceFrequency && !isInCurrentScanRange(m_scanResults[i].m_frequency)) {
continue;
}
frequencySettings = m_settings.getFrequencySettings(m_scanResults[i].m_frequency);
Real threshold = m_settings.getThreshold(frequencySettings);
if (m_scanResults[i].m_power >= threshold)
if (m_settings.m_voiceSquelchType == FreqScannerSettings::VoiceSquelchType::None)
{
if (!activeFrequencySettings || (m_scanResults[i].m_power > maxPower))
if ((m_scanResults[i].m_power >= threshold)
&& (!activeFrequencySettings || (m_scanResults[i].m_power > maxPower)))
{
frequency = m_scanResults[i].m_frequency;
maxPower = m_scanResults[i].m_power;
activeFrequencySettings = frequencySettings;
}
}
else // VAD
{
if ((m_scanResults[i].m_voiceActivityLevel >= m_settings.m_voiceSquelchThreshold)
&& (!activeFrequencySettings || (m_scanResults[i].m_voiceActivityLevel > maxVoiceActivityLevel)))
{
frequency = m_scanResults[i].m_frequency;
maxVoiceActivityLevel = m_scanResults[i].m_voiceActivityLevel;
activeFrequencySettings = frequencySettings;
}
}
}
}
else
else // TABLE_ORDER
{
// Find first frequency in list above threshold
for (int i = 0; i < m_scanResults.size(); i++)
{
frequencySettings = m_settings.getFrequencySettings(m_scanResults[i].m_frequency);
int j = m_settings.m_voiceSquelchType == FreqScannerSettings::VoiceSquelchType::VoiceLsb ?
m_scanResults.size()-1 - i : i;
if (lockDeviceFrequency && !isInCurrentScanRange(m_scanResults[j].m_frequency)) {
continue;
}
frequencySettings = m_settings.getFrequencySettings(m_scanResults[j].m_frequency);
Real threshold = m_settings.getThreshold(frequencySettings);
if (m_scanResults[i].m_power >= threshold)
if (checkThresholds(m_settings.m_voiceSquelchType, m_scanResults[j], threshold, m_settings.m_voiceSquelchThreshold))
{
frequency = m_scanResults[i].m_frequency;
frequency = m_scanResults[j].m_frequency;
activeFrequencySettings = frequencySettings;
break;
}
@@ -501,20 +564,23 @@ void FreqScanner::processScanResults(const QDateTime& fftStartTime, const QList<
}
// Ensure we have minimum offset from DC
if (offset >= 0)
if (!lockDeviceFrequency)
{
while (offset < m_settings.m_channelFrequencyOffset)
if (offset >= 0)
{
nextCenterFrequency -= m_settings.m_channelBandwidth;
offset += m_settings.m_channelBandwidth;
while (offset < m_settings.m_channelFrequencyOffset)
{
nextCenterFrequency -= m_settings.m_channelBandwidth;
offset += m_settings.m_channelBandwidth;
}
}
}
else
{
while (abs(offset) < m_settings.m_channelFrequencyOffset)
else
{
nextCenterFrequency += m_settings.m_channelBandwidth;
offset -= m_settings.m_channelBandwidth;
while (abs(offset) < m_settings.m_channelFrequencyOffset)
{
nextCenterFrequency += m_settings.m_channelBandwidth;
offset -= m_settings.m_channelBandwidth;
}
}
}
@@ -580,7 +646,12 @@ void FreqScanner::processScanResults(const QDateTime& fftStartTime, const QList<
}
}
if (nextCenterFrequency != m_centerFrequency) {
if (m_settings.m_lockDeviceFrequency) {
nextCenterFrequency = m_centerFrequency;
}
if (nextCenterFrequency != m_centerFrequency)
{
setDeviceCenterFrequency(nextCenterFrequency);
}
@@ -604,7 +675,7 @@ void FreqScanner::processScanResults(const QDateTime& fftStartTime, const QList<
// Wait until power drops below threshold
FreqScannerSettings::FrequencySettings *frequencySettings = m_settings.getFrequencySettings(m_activeFrequency);
Real threshold = m_settings.getThreshold(frequencySettings);
if (results[i].m_power < threshold)
if (results[i].m_power < threshold)
{
m_timeoutTimer.setSingleShot(true);
m_timeoutTimer.start((int)(m_settings.m_retransmitTime * 1000.0));
@@ -627,8 +698,12 @@ void FreqScanner::processScanResults(const QDateTime& fftStartTime, const QList<
// Check if power has returned to being above threshold
FreqScannerSettings::FrequencySettings *frequencySettings = m_settings.getFrequencySettings(m_activeFrequency);
Real threshold = m_settings.getThreshold(frequencySettings);
if (results[i].m_power >= threshold)
if (checkThresholds(m_settings.m_voiceSquelchType, results[i], threshold, m_settings.m_voiceSquelchThreshold))
{
if (m_settings.m_voiceSquelchType != FreqScannerSettings::VoiceSquelchType::None) {
qDebug("FreqScanner::processScanResults: WAIT_FOR_RETRANSMISSION restart: frequency: %lld, voice score: %f", m_activeFrequency, results[i].m_voiceActivityLevel);
}
m_timeoutTimer.stop();
m_state = WAIT_FOR_END_TX;
}
@@ -668,7 +743,13 @@ void FreqScanner::timeout()
void FreqScanner::calcScannerSampleRate(int channelBW, int basebandSampleRate, int& scannerSampleRate, int& fftSize, int& binsPerChannel)
{
const int maxFFTSize = 16384;
const int minBinsPerChannel = 8;
int minBinsPerChannel = 8;
if (m_settings.m_voiceSquelchType == FreqScannerSettings::VoiceSquelchType::VoiceLsb
|| m_settings.m_voiceSquelchType == FreqScannerSettings::VoiceSquelchType::VoiceUsb)
{
minBinsPerChannel = channelBW / 20; // we want at most 20 Hz per bin
}
// Base FFT size on that used for main spectrum
std::vector<DeviceSet*>& deviceSets = MainCore::instance()->getDeviceSets();
@@ -787,6 +868,17 @@ void FreqScanner::unmuteAll()
m_autoMutedChannels.clear();
}
bool FreqScanner::checkThresholds(
FreqScannerSettings::VoiceSquelchType voiceSquelchType,
const FreqScanner::MsgScanResult::ScanResult& result,
Real powerThreshold,
Real voiceSquelchThreshold
)
{
return (((result.m_power >= powerThreshold) && (voiceSquelchType == FreqScannerSettings::VoiceSquelchType::None))
|| (result.m_voiceActivityLevel >= voiceSquelchThreshold));
}
void FreqScanner::applyChannelSetting(const QString& channel)
{
if (!MainCore::getDeviceAndChannelIndexFromId(channel, m_scanDeviceSetIndex, m_scanChannelIndex)) {
+26 -2
View File
@@ -146,6 +146,23 @@ public:
}
};
class MsgContinueScan : public Message {
MESSAGE_CLASS_DECLARATION
public:
static MsgContinueScan* create()
{
return new MsgContinueScan();
}
private:
MsgContinueScan() :
Message()
{
}
};
class MsgScanResult : public Message {
MESSAGE_CLASS_DECLARATION
@@ -154,9 +171,10 @@ public:
struct ScanResult {
qint64 m_frequency;
float m_power;
float m_voiceActivityLevel; // 0.0-1.0, voice likelihood for SSB modes
};
const QDateTime& getFFTStartTime() { return m_fftStartTime; }
const QDateTime& getFFTStartTime() const { return m_fftStartTime; }
QList<ScanResult>& getScanResults() { return m_scanResults; }
static MsgScanResult* create(const QDateTime& fftStartTime) {
@@ -421,6 +439,7 @@ private:
void startScan();
void stopScan();
void initScan();
void continueScan();
void processScanResults(const QDateTime& fftStartTime, const QList<MsgScanResult::ScanResult>& results);
void setDeviceCenterFrequency(qint64 frequency);
void applyChannelSetting(const QString& channel);
@@ -428,6 +447,12 @@ private:
void unmuteAll();
void mute(unsigned int deviceSetIndex, unsigned int channelIndex);
void unmute(unsigned int deviceSetIndex, unsigned int channelIndex);
static bool checkThresholds(
FreqScannerSettings::VoiceSquelchType voiceSquelchType,
const FreqScanner::MsgScanResult::ScanResult& result,
Real powerThreshold,
Real voiceSquelchThreshold
);
static QList<SWGSDRangel::SWGFreqScannerFrequency *> *createFrequencyList(const FreqScannerSettings& settings);
@@ -440,4 +465,3 @@ private slots:
};
#endif // INCLUDE_FREQSCANNER_H
@@ -168,6 +168,24 @@ void FreqScannerAddRangeDialog::on_preset_currentTextChanged(const QString& text
{
enableManAdjust = false;
}
else if (text == "HF 20m")
{
ui->start->setValue(14180500);
ui->stop->setValue(14250500);
ui->step->setCurrentText("1000");
}
else if (text == "HF 40m")
{
ui->start->setValue(7110500);
ui->stop->setValue(7190500);
ui->step->setCurrentText("1000");
}
else if (text == "HF 80m")
{
ui->start->setValue(3680500);
ui->stop->setValue(3780500);
ui->step->setCurrentText("1000");
}
ui->start->setEnabled(enableManAdjust);
ui->stop->setEnabled(enableManAdjust);
ui->step->setEnabled(enableManAdjust);
@@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>385</width>
<height>190</height>
<height>203</height>
</rect>
</property>
<property name="font">
@@ -130,6 +130,11 @@
<string>100000</string>
</property>
</item>
<item>
<property name="text">
<string>1000</string>
</property>
</item>
</widget>
</item>
<item row="3" column="1">
@@ -203,6 +208,21 @@
<string>HF ATC</string>
</property>
</item>
<item>
<property name="text">
<string>HF 20m</string>
</property>
</item>
<item>
<property name="text">
<string>HF 40m</string>
</property>
</item>
<item>
<property name="text">
<string>HF 80m</string>
</property>
</item>
</widget>
</item>
<item row="0" column="0">
@@ -29,7 +29,8 @@ MESSAGE_CLASS_DEFINITION(FreqScannerBaseband::MsgConfigureFreqScannerBaseband, M
FreqScannerBaseband::FreqScannerBaseband(FreqScanner *freqScanner) :
m_freqScanner(freqScanner),
m_messageQueueToGUI(nullptr)
m_messageQueueToGUI(nullptr),
m_currentBasebandSampleRate(0)
{
qDebug("FreqScannerBaseband::FreqScannerBaseband");
@@ -62,6 +63,7 @@ void FreqScannerBaseband::reset()
m_inputMessageQueue.clear();
m_sampleFifo.reset();
m_channelSampleRate = 0;
m_currentBasebandSampleRate = 0;
}
void FreqScannerBaseband::setChannel(ChannelAPI *channel)
@@ -130,11 +132,18 @@ bool FreqScannerBaseband::handleMessage(const Message& cmd)
QMutexLocker mutexLocker(&m_mutex);
DSPSignalNotification& notif = (DSPSignalNotification&) cmd;
qDebug() << "FreqScannerBaseband::handleMessage: DSPSignalNotification: basebandSampleRate: " << notif.getSampleRate();
setBasebandSampleRate(notif.getSampleRate());
m_sampleFifo.setSize(SampleSinkFifo::getSizePolicy(notif.getSampleRate()));
if (m_channelSampleRate != m_channelizer->getChannelSampleRate()) {
m_channelSampleRate = m_channelizer->getChannelSampleRate();
int basebandSampleRate = notif.getSampleRate();
if (basebandSampleRate != m_currentBasebandSampleRate)
{
setBasebandSampleRate(notif.getSampleRate());
m_sampleFifo.setSize(SampleSinkFifo::getSizePolicy(notif.getSampleRate()));
if (m_channelSampleRate != m_channelizer->getChannelSampleRate()) {
m_channelSampleRate = m_channelizer->getChannelSampleRate();
}
m_currentBasebandSampleRate = basebandSampleRate;
}
m_sink.setCenterFrequency(notif.getCenterFrequency());
return true;
@@ -86,6 +86,7 @@ private:
MessageQueue *m_messageQueueToGUI;
FreqScannerSettings m_settings;
QRecursiveMutex m_mutex;
int m_currentBasebandSampleRate;
bool handleMessage(const Message& cmd);
void applySettings(const FreqScannerSettings& settings, const QStringList& settingsKeys, bool force = false);
@@ -197,6 +197,8 @@ bool FreqScannerGUI::handleMessage(const Message& message)
int row = item->row();
QTableWidgetItem* powerItem = ui->table->item(row, COL_POWER);
powerItem->setData(Qt::DisplayRole, results[i].m_power);
QTableWidgetItem* vadItem = ui->table->item(row, COL_VAD);
vadItem->setData(Qt::DisplayRole, results[i].m_voiceActivityLevel);
FreqScannerSettings::FrequencySettings *frequencySettings = m_settings.getFrequencySettings(freq);
Real threshold = m_settings.getThreshold(frequencySettings);
bool active = results[i].m_power >= threshold;
@@ -361,6 +363,50 @@ void FreqScannerGUI::on_thresh_valueChanged(int value)
applySetting("threshold");
}
void FreqScannerGUI::on_voiceThreshold_valueChanged(int value)
{
ui->voiceThresholdText->setText(QString("%1").arg(value / 100.0, 0, 'f', 2));
m_settings.m_voiceSquelchThreshold = value / 100.0;
applySetting("voiceSquelchThreshold");
}
void FreqScannerGUI::on_voiceSquelchType_currentIndexChanged(int index)
{
m_settings.m_voiceSquelchType = (FreqScannerSettings::VoiceSquelchType)index;
QStringList settingsKeys({"voiceSquelchType"});
if (m_settings.m_voiceSquelchType == FreqScannerSettings::VoiceSquelchType::VoiceLsb)
{
blockApplySettings(true);
m_settings.m_channelBandwidth = 3000;
ui->channelBandwidth->setValue(m_settings.m_channelBandwidth);
m_settings.m_channelShift = 1500;
ui->channelShift->setValue(m_settings.m_channelShift);
settingsKeys.append("channelBandwidth");
settingsKeys.append("channelShift");
blockApplySettings(false);
}
else if (m_settings.m_voiceSquelchType == FreqScannerSettings::VoiceSquelchType::VoiceUsb)
{
blockApplySettings(true);
m_settings.m_channelBandwidth = 3000;
ui->channelBandwidth->setValue(m_settings.m_channelBandwidth);
m_settings.m_channelShift = -1500;
ui->channelShift->setValue(m_settings.m_channelShift);
settingsKeys.append("channelBandwidth");
settingsKeys.append("channelShift");
blockApplySettings(false);
}
applySettings(settingsKeys);
}
void FreqScannerGUI::on_lockDeviceFrequency_toggled(bool checked)
{
m_settings.m_lockDeviceFrequency = checked;
applySetting("lockDeviceFrequency");
}
void FreqScannerGUI::on_priority_currentIndexChanged(int index)
{
m_settings.m_priority = (FreqScannerSettings::Priority)index;
@@ -540,6 +586,7 @@ FreqScannerGUI::FreqScannerGUI(PluginAPI* pluginAPI, DeviceUISet *deviceUISet, B
ui->table->setItemDelegateForColumn(COL_FREQUENCY, new FrequencyDelegate("Auto", 3, true, ui->table));
ui->table->setItemDelegateForColumn(COL_POWER, new DecimalDelegate(1, ui->table));
ui->table->setItemDelegateForColumn(COL_VAD, new DecimalDelegate(2, ui->table));
ui->table->setItemDelegateForColumn(COL_CHANNEL_BW, new Int64Delegate(0, 10000000, ui->table));
ui->table->setItemDelegateForColumn(COL_TH, new DecimalDelegate(1, -120.0, 0.0, ui->table));
ui->table->setItemDelegateForColumn(COL_SQ, new DecimalDelegate(1, -120.0, 0.0, ui->table));
@@ -597,6 +644,7 @@ void FreqScannerGUI::displaySettings()
ui->channels->setCurrentIndex(channelIndex);
}
ui->deltaFrequency->setValue(m_settings.m_channelFrequencyOffset);
ui->deviceFreqLock->setChecked(m_settings.m_lockDeviceFrequency);
ui->channelBandwidth->setValue(m_settings.m_channelBandwidth);
ui->channelShift->setValue(m_settings.m_channelShift);
ui->scanTime->setValue(m_settings.m_scanTime * 10.0);
@@ -607,6 +655,9 @@ void FreqScannerGUI::displaySettings()
ui->tuneTimeText->setText(QString("%1 ms").arg(m_settings.m_tuneTime));
ui->thresh->setValue(m_settings.m_threshold * 10.0);
ui->threshText->setText(QString("%1 dB").arg(m_settings.m_threshold, 0, 'f', 1));
ui->voiceThreshold->setValue(m_settings.m_voiceSquelchThreshold * 100.0);
ui->voiceThresholdText->setText(QString("%1").arg(m_settings.m_voiceSquelchThreshold, 0, 'f', 2));
ui->voiceSquelch->setCurrentIndex((int)m_settings.m_voiceSquelchType);
ui->priority->setCurrentIndex((int)m_settings.m_priority);
ui->measurement->setCurrentIndex((int)m_settings.m_measurement);
ui->mode->setCurrentIndex((int)m_settings.m_mode);
@@ -666,6 +717,12 @@ void FreqScannerGUI::on_startStop_toggled(bool checked)
}
}
void FreqScannerGUI::on_continueScan_clicked()
{
FreqScanner::MsgContinueScan* message = FreqScanner::MsgContinueScan::create();
m_freqScanner->getInputMessageQueue()->push(message);
}
void FreqScannerGUI::addRow(const FreqScannerSettings::FrequencySettings& frequencySettings)
{
int row = ui->table->rowCount();
@@ -687,6 +744,10 @@ void FreqScannerGUI::addRow(const FreqScannerSettings::FrequencySettings& freque
powerItem->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
ui->table->setItem(row, COL_POWER, powerItem);
QTableWidgetItem* vadItem = new QTableWidgetItem();
vadItem->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
ui->table->setItem(row, COL_VAD, vadItem);
QTableWidgetItem *activeCountItem = new QTableWidgetItem();
activeCountItem->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
ui->table->setItem(row, COL_ACTIVE_COUNT, activeCountItem);
@@ -776,6 +837,13 @@ void FreqScannerGUI::on_removeInactive_clicked()
applySetting("frequencySettings");
}
void FreqScannerGUI::on_removeAll_clicked()
{
ui->table->setRowCount(0);
m_settings.m_frequencySettings.clear();
applySetting("frequencySettings");
}
static QList<QTableWidgetItem*> takeRow(QTableWidget* table, int row)
{
QList<QTableWidgetItem*> rowItems;
@@ -1236,6 +1304,7 @@ void FreqScannerGUI::resizeTable()
ui->table->setItem(row, COL_ANNOTATION, new QTableWidgetItem("London VOLMET"));
ui->table->setItem(row, COL_ENABLE, new QTableWidgetItem("Enable"));
ui->table->setItem(row, COL_POWER, new QTableWidgetItem("-100.0"));
ui->table->setItem(row, COL_VAD, new QTableWidgetItem("0.00"));
ui->table->setItem(row, COL_ACTIVE_COUNT, new QTableWidgetItem("10000"));
ui->table->setItem(row, COL_NOTES, new QTableWidgetItem("A channel name"));
ui->table->setItem(row, COL_CHANNEL, new QTableWidgetItem("Enter some notes"));
@@ -1256,15 +1325,20 @@ void FreqScannerGUI::makeUIConnections()
QObject::connect(ui->retransmitTime, &QDial::valueChanged, this, &FreqScannerGUI::on_retransmitTime_valueChanged);
QObject::connect(ui->tuneTime, &QDial::valueChanged, this, &FreqScannerGUI::on_tuneTime_valueChanged);
QObject::connect(ui->thresh, &QDial::valueChanged, this, &FreqScannerGUI::on_thresh_valueChanged);
QObject::connect(ui->voiceSquelch, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &FreqScannerGUI::on_voiceSquelchType_currentIndexChanged);
QObject::connect(ui->voiceThreshold, &QDial::valueChanged, this, &FreqScannerGUI::on_voiceThreshold_valueChanged);
QObject::connect(ui->deviceFreqLock, &QToolButton::toggled, this, &FreqScannerGUI::on_lockDeviceFrequency_toggled);
QObject::connect(ui->priority, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &FreqScannerGUI::on_priority_currentIndexChanged);
QObject::connect(ui->measurement, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &FreqScannerGUI::on_measurement_currentIndexChanged);
QObject::connect(ui->mode, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &FreqScannerGUI::on_mode_currentIndexChanged);
QObject::connect(ui->startStop, &ButtonSwitch::toggled, this, &FreqScannerGUI::on_startStop_toggled);
QObject::connect(ui->continueScan, &QPushButton::clicked, this, &FreqScannerGUI::on_continueScan_clicked);
QObject::connect(ui->table, &QTableWidget::cellChanged, this, &FreqScannerGUI::on_table_cellChanged);
QObject::connect(ui->addSingle, &QToolButton::clicked, this, &FreqScannerGUI::on_addSingle_clicked);
QObject::connect(ui->addRange, &QToolButton::clicked, this, &FreqScannerGUI::on_addRange_clicked);
QObject::connect(ui->remove, &QToolButton::clicked, this, &FreqScannerGUI::on_remove_clicked);
QObject::connect(ui->removeInactive, &QToolButton::clicked, this, &FreqScannerGUI::on_removeInactive_clicked);
QObject::connect(ui->removeAll, &QToolButton::clicked, this, &FreqScannerGUI::on_removeAll_clicked);
QObject::connect(ui->up, &QToolButton::clicked, this, &FreqScannerGUI::on_up_clicked);
QObject::connect(ui->down, &QToolButton::clicked, this, &FreqScannerGUI::on_down_clicked);
QObject::connect(ui->clearActiveCount, &QToolButton::clicked, this, &FreqScannerGUI::on_clearActiveCount_clicked);
@@ -115,6 +115,7 @@ private:
COL_ANNOTATION,
COL_ENABLE,
COL_POWER,
COL_VAD,
COL_ACTIVE_COUNT,
COL_NOTES,
COL_CHANNEL,
@@ -132,6 +133,9 @@ private slots:
void on_retransmitTime_valueChanged(int value);
void on_tuneTime_valueChanged(int value);
void on_thresh_valueChanged(int value);
void on_voiceThreshold_valueChanged(int value);
void on_voiceSquelchType_currentIndexChanged(int index);
void on_lockDeviceFrequency_toggled(bool checked);
void on_priority_currentIndexChanged(int index);
void on_measurement_currentIndexChanged(int index);
void on_mode_currentIndexChanged(int index);
@@ -143,10 +147,12 @@ private slots:
void columnSelectMenu(QPoint pos);
void columnSelectMenuChecked(bool checked = false);
void on_startStop_toggled(bool checked = false);
void on_continueScan_clicked();
void on_addSingle_clicked();
void on_addRange_clicked();
void on_remove_clicked();
void on_removeInactive_clicked();
void on_removeAll_clicked();
void on_up_clicked();
void on_down_clicked();
void on_clearActiveCount_clicked();
+138 -9
View File
@@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>516</width>
<height>423</height>
<width>676</width>
<height>410</height>
</rect>
</property>
<property name="sizePolicy">
@@ -39,7 +39,7 @@
<rect>
<x>0</x>
<y>0</y>
<width>511</width>
<width>671</width>
<height>411</height>
</rect>
</property>
@@ -157,6 +157,24 @@
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="deviceFreqLock">
<property name="toolTip">
<string>Lock device frequency to current value</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset resource="../../../sdrgui/resources/res.qrc">
<normaloff>:/unlocked.png</normaloff>
<normalon>:/locked.png</normalon>:/unlocked.png</iconset>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="Line" name="line">
<property name="orientation">
@@ -166,6 +184,9 @@
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
@@ -259,6 +280,9 @@
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
@@ -526,6 +550,9 @@
</item>
<item>
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
@@ -650,6 +677,26 @@
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="continueScan">
<property name="maximumSize">
<size>
<width>32</width>
<height>16777215</height>
</size>
</property>
<property name="toolTip">
<string>Force scanner to continue on next frequency</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset resource="../../../sdrgui/resources/res.qrc">
<normaloff>:/arrow_left.png</normaloff>:/arrow_left.png</iconset>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="status">
<property name="sizePolicy">
@@ -663,6 +710,67 @@
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="voiceSquelchLabel">
<property name="text">
<string>VAD</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="voiceSquelch">
<property name="toolTip">
<string>Voice Activity Detection</string>
</property>
<item>
<property name="text">
<string>None</string>
</property>
</item>
<item>
<property name="text">
<string>LSB</string>
</property>
</item>
<item>
<property name="text">
<string>USB</string>
</property>
</item>
</widget>
</item>
<item>
<widget class="QDial" name="voiceThreshold">
<property name="maximumSize">
<size>
<width>24</width>
<height>24</height>
</size>
</property>
<property name="toolTip">
<string>VAD score minimum level</string>
</property>
<property name="minimum">
<number>0</number>
</property>
<property name="maximum">
<number>100</number>
</property>
<property name="pageStep">
<number>1</number>
</property>
<property name="value">
<number>50</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="voiceThresholdText">
<property name="text">
<string>0.01</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
@@ -705,6 +813,14 @@
<string>Channel power in decibels during the previous scan</string>
</property>
</column>
<column>
<property name="text">
<string>VAD</string>
</property>
<property name="toolTip">
<string>Voice level</string>
</property>
</column>
<column>
<property name="text">
<string>Active Count</string>
@@ -805,6 +921,16 @@ Leave blank for no adjustment</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="removeAll">
<property name="toolTip">
<string>Remove selected items</string>
</property>
<property name="text">
<string>Remove All</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="up">
<property name="toolTip">
@@ -863,6 +989,9 @@ Leave blank for no adjustment</string>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
@@ -898,18 +1027,18 @@ Leave blank for no adjustment</string>
<extends>QToolButton</extends>
<header>gui/buttonswitch.h</header>
</customwidget>
<customwidget>
<class>ValueDialZ</class>
<extends>QWidget</extends>
<header>gui/valuedialz.h</header>
<container>1</container>
</customwidget>
<customwidget>
<class>RollupContents</class>
<extends>QWidget</extends>
<header>gui/rollupcontents.h</header>
<container>1</container>
</customwidget>
<customwidget>
<class>ValueDialZ</class>
<extends>QWidget</extends>
<header>gui/valuedialz.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>deltaFrequency</tabstop>
@@ -46,6 +46,9 @@ void FreqScannerSettings::resetToDefaults()
m_scanTime = 0.1f;
m_retransmitTime = 2.0f;
m_tuneTime = 100;
m_voiceSquelchThreshold = 0.5f;
m_voiceSquelchType = None;
m_lockDeviceFrequency = false;
m_priority = MAX_POWER;
m_measurement = PEAK;
m_mode = CONTINUOUS;
@@ -76,6 +79,8 @@ QByteArray FreqScannerSettings::serialize() const
s.writeS32(2, m_channelBandwidth);
s.writeS32(3, m_channelFrequencyOffset);
s.writeFloat(4, m_threshold);
s.writeS32(5, (int)m_voiceSquelchType);
s.writeFloat(6, m_voiceSquelchThreshold);
s.writeString(8, m_channel);
s.writeFloat(9, m_scanTime);
s.writeFloat(10, m_retransmitTime);
@@ -85,6 +90,7 @@ QByteArray FreqScannerSettings::serialize() const
s.writeS32(14, (int)m_mode);
s.writeList(15, m_frequencySettings);
s.writeS32(16, m_channelShift);
s.writeBool(17, m_lockDeviceFrequency);
s.writeList(20, m_columnIndexes);
s.writeList(21, m_columnSizes);
@@ -130,6 +136,8 @@ bool FreqScannerSettings::deserialize(const QByteArray& data)
d.readS32(2, &m_channelBandwidth, 25000);
d.readS32(3, &m_channelFrequencyOffset, 25000);
d.readFloat(4, &m_threshold, -60.0f);
d.readS32(5, (int*)&m_voiceSquelchType, (int)None);
d.readFloat(6, &m_voiceSquelchThreshold, 0.5f);
d.readString(8, &m_channel);
d.readFloat(9, &m_scanTime, 0.1f);
d.readFloat(10, &m_retransmitTime, 2.0f);
@@ -139,6 +147,7 @@ bool FreqScannerSettings::deserialize(const QByteArray& data)
d.readS32(14, (int*)&m_mode, (int)CONTINUOUS);
d.readList(15, &m_frequencySettings);
d.readS32(16, &m_channelShift, 0);
d.readBool(17, &m_lockDeviceFrequency, false);
if (m_frequencySettings.size() == 0)
{
@@ -222,6 +231,15 @@ void FreqScannerSettings::applySettings(const QStringList& settingsKeys, const F
if (settingsKeys.contains("threshold")) {
m_threshold = settings.m_threshold;
}
if (settingsKeys.contains("voiceSquelchThreshold")) {
m_voiceSquelchThreshold = settings.m_voiceSquelchThreshold;
}
if (settingsKeys.contains("voiceSquelchType")) {
m_voiceSquelchType = settings.m_voiceSquelchType;
}
if (settingsKeys.contains("lockDeviceFrequency")) {
m_lockDeviceFrequency = settings.m_lockDeviceFrequency;
}
if (settingsKeys.contains("frequencySettings")) {
m_frequencySettings = settings.m_frequencySettings;
}
@@ -303,6 +321,15 @@ QString FreqScannerSettings::getDebugString(const QStringList& settingsKeys, boo
if (settingsKeys.contains("threshold") || force) {
ostr << " m_threshold: " << m_threshold;
}
if (settingsKeys.contains("voiceSquelchThreshold") || force) {
ostr << " m_voiceSquelchThreshold: " << m_voiceSquelchThreshold;
}
if (settingsKeys.contains("voiceSquelchType") || force) {
ostr << " m_voiceSquelchType: " << m_voiceSquelchType;
}
if (settingsKeys.contains("lockDeviceFrequency") || force) {
ostr << " m_lockDeviceFrequency: " << m_lockDeviceFrequency;
}
if (settingsKeys.contains("frequencySettings") || force)
{
QStringList s;
@@ -49,11 +49,17 @@ struct FreqScannerSettings
qint32 m_channelFrequencyOffset;//!< Minimum DC offset of tuned channel
qint32 m_channelShift; //!< Channel frequency shift
Real m_threshold; //!< Power threshold in dB
Real m_voiceSquelchThreshold; //!< Voice squelch threshold in the range [0.0, 1.0]. Only relevant if voice squelch is enabled.
QString m_channel; //!< Channel (E.g: R1:4) to tune to active frequency
QList<FrequencySettings> m_frequencySettings; //!< Frequencies to scan and corresponding settings
float m_scanTime; //!< In seconds
float m_retransmitTime; //!< In seconds
int m_tuneTime; //!< In milliseconds
enum VoiceSquelchType {
None,
VoiceLsb,
VoiceUsb,
} m_voiceSquelchType; //!< Voice squelch type for SSB modes. None means no voice squelch, VoiceLsb means voice squelch on lower sideband frequencies, VoiceUsb means voice squelch on upper sideband frequencies.
enum Priority {
MAX_POWER,
TABLE_ORDER
@@ -68,6 +74,8 @@ struct FreqScannerSettings
SCAN_ONLY,
MULTIPLEX
} m_mode; //!< Whether to run a single or many scans
bool m_lockDeviceFrequency; //!< Whether to lock device frequency to the initial center frequency of the first scan,
//!< or allow it to be shifted by the channel shift setting
QList<int> m_columnIndexes;//!< How the columns are ordered in the table
QList<int> m_columnSizes; //!< Size of the coumns in the table
@@ -18,6 +18,11 @@
#include <QDebug>
#include <complex.h>
#include <cmath>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#include "dsp/dspengine.h"
#include "dsp/fftfactory.h"
@@ -38,7 +43,12 @@ FreqScannerSink::FreqScannerSink() :
m_fftCounter(0),
m_fftSize(1024),
m_binsPerChannel(16),
m_averageCount(0)
m_averageCount(0),
m_cepstrumSequenceInverse(-1),
m_cepstrumSequenceForward(-1),
m_cepstrumFFTInverse(nullptr),
m_cepstrumFFTForward(nullptr),
m_cepstrumSize(0)
{
applySettings(m_settings, QStringList(), true);
applyChannelSettings(m_channelSampleRate, m_channelFrequencyOffset, 16, 4, true);
@@ -46,11 +56,19 @@ FreqScannerSink::FreqScannerSink() :
FreqScannerSink::~FreqScannerSink()
{
if (m_fftSequence >= 0)
{
FFTFactory* fftFactory = DSPEngine::instance()->getFFTFactory();
FFTFactory* fftFactory = DSPEngine::instance()->getFFTFactory();
if (m_fftSequence >= 0) {
fftFactory->releaseEngine(m_fftSize, false, m_fftSequence);
}
if (m_cepstrumSequenceInverse >= 0) {
fftFactory->releaseEngine(m_cepstrumSize, true, m_cepstrumSequenceInverse);
}
if (m_cepstrumSequenceForward >= 0) {
fftFactory->releaseEngine(m_cepstrumSize, false, m_cepstrumSequenceForward);
}
}
void FreqScannerSink::feed(const SampleVector::const_iterator& begin, const SampleVector::const_iterator& end)
@@ -95,6 +113,52 @@ void FreqScannerSink::processOneSample(Complex &ci)
// Perform FFT
m_fft->transform();
// Accumulate voice activity levels on individual FFT (before averaging)
// This captures sharp formant structure better than averaged spectrum
int freqCount = m_settings.m_frequencySettings.size();
if (m_voiceLevelSum.size() != freqCount) {
m_voiceLevelSum.resize(freqCount);
m_voiceLevelCount.resize(freqCount);
m_voiceLevelSum.fill(0.0);
m_voiceLevelCount.fill(0);
}
for (int i = 0; i < freqCount; i++)
{
if (m_settings.m_frequencySettings[i].m_enabled)
{
qint64 frequency = m_settings.m_frequencySettings[i].m_frequency;
qint64 startFrequency = m_centerFrequency - m_scannerSampleRate / 2;
qint64 diff = frequency - startFrequency;
float binBW = m_scannerSampleRate / (float)m_fftSize;
// avoid spectrum edges where there may be aliasing from half-band filters
if ((diff >= m_scannerSampleRate / 8) && (diff < m_scannerSampleRate * 7 / 8))
{
int bin = std::round(diff / binBW);
int channelBins;
if (m_settings.m_frequencySettings[i].m_channelBandwidth.isEmpty()) {
channelBins = m_binsPerChannel;
} else {
int channelBW = m_settings.getChannelBandwidth(&m_settings.m_frequencySettings[i]);
channelBins = m_fftSize / (m_scannerSampleRate / (float)channelBW);
}
Real voiceLevel = 0.0;
if (m_settings.m_voiceSquelchType == FreqScannerSettings::VoiceLsb) {
voiceLevel = voiceActivityLevel(bin, channelBins, true);
} else if (m_settings.m_voiceSquelchType == FreqScannerSettings::VoiceUsb) {
voiceLevel = voiceActivityLevel(bin, channelBins, false);
}
if (voiceLevel > 0.0) {
m_voiceLevelSum[i] += voiceLevel;
m_voiceLevelCount[i]++;
}
}
}
}
// Reorder (so negative frequencies are first) and average
int halfSize = m_fftSize / 2;
for (int i = 0; i < halfSize; i++) {
@@ -124,8 +188,10 @@ void FreqScannerSink::processOneSample(Complex &ci)
// Ignore results in upper and lower 12.5%, as there may be aliasing here from half-band filters
if ((diff >= m_scannerSampleRate / 8) && (diff < m_scannerSampleRate * 7 / 8))
{
int bin = std::round(diff / binBW);
int channelBins;
int bin = std::round(diff / binBW); // Bin corresponding to the frequency
int channelBins; // Number of bins in the channel containing the frequency.
// This is either the default (m_binsPerChannel)
// or calculated based on the channel bandwidth if specified in settings for this frequency
if (m_settings.m_frequencySettings[i].m_channelBandwidth.isEmpty())
{
@@ -144,8 +210,22 @@ void FreqScannerSink::processOneSample(Complex &ci)
} else {
power = totalPower(bin, channelBins);
}
//qDebug() << "startFrequency:" << startFrequency << "m_scannerSampleRate:" << m_scannerSampleRate << "m_centerFrequency:" << m_centerFrequency << "frequency" << frequency << "bin" << bin << "power" << power;
FreqScanner::MsgScanResult::ScanResult result = {frequency, power};
// Use averaged voice activity level from individual FFTs
Real voiceLevel = 0.0;
if ((m_settings.m_voiceSquelchType == FreqScannerSettings::VoiceLsb ||
m_settings.m_voiceSquelchType == FreqScannerSettings::VoiceUsb) &&
m_voiceLevelCount[i] > 0)
{
voiceLevel = m_voiceLevelSum[i] / m_voiceLevelCount[i];
if (voiceLevel > m_settings.m_voiceSquelchThreshold) {
qDebug() << "FreqScannerSink::processOneSample: freq"
<< frequency + (m_settings.m_voiceSquelchType == FreqScannerSettings::VoiceLsb ? 1500 : -1500)
<< "voiceLevel" << voiceLevel << "count" << m_voiceLevelCount[i];
}
}
FreqScanner::MsgScanResult::ScanResult result = {frequency, power, voiceLevel};
results.append(result);
}
}
@@ -154,6 +234,10 @@ void FreqScannerSink::processOneSample(Complex &ci)
}
m_averageCount = 0;
m_fftStartTime = QDateTime::currentDateTime();
// Reset voice level accumulators for next averaging period
m_voiceLevelSum.fill(0.0);
m_voiceLevelCount.fill(0);
}
m_fftCounter = 0;
}
@@ -205,6 +289,24 @@ Real FreqScannerSink::magSq(int bin) const
return magsq;
}
// Compute magSq from raw FFT output with reordering (negative frequencies first)
Real FreqScannerSink::magSqFromRawFFT(int bin) const
{
// m_magSq is reordered: negative freqs first, then positive
// m_fft->out() is in standard FFT order: DC, positive freqs, negative freqs
int halfSize = m_fftSize / 2;
int fftBin;
if (bin < halfSize) {
// Negative frequencies: map to second half of FFT output
fftBin = bin + halfSize;
} else {
// Positive frequencies: map to first half of FFT output
fftBin = bin - halfSize;
}
return magSq(fftBin);
}
void FreqScannerSink::applyChannelSettings(int channelSampleRate, int channelFrequencyOffset, int scannerSampleRate, int fftSize, int binsPerChannel, bool force)
{
qDebug() << "FreqScannerSink::applyChannelSettings:"
@@ -231,7 +333,7 @@ void FreqScannerSink::applyChannelSettings(int channelSampleRate, int channelFre
{
FFTFactory* fftFactory = DSPEngine::instance()->getFFTFactory();
if (m_fftSequence >= 0) {
fftFactory->releaseEngine(fftSize, false, m_fftSequence);
fftFactory->releaseEngine(m_fftSize, false, m_fftSequence);
}
m_fftSequence = fftFactory->getEngine(fftSize, false, &m_fft);
m_fftCounter = 0;
@@ -241,6 +343,39 @@ void FreqScannerSink::applyChannelSettings(int channelSampleRate, int channelFre
int averages = m_settings.m_scanTime * scannerSampleRate / 2 / fftSize;
m_fftAverage.resize(fftSize, averages);
m_magSq.resize(fftSize);
// Resize voice level accumulators to match frequency count
int freqCount = m_settings.m_frequencySettings.size();
m_voiceLevelSum.resize(freqCount);
m_voiceLevelCount.resize(freqCount);
m_voiceLevelSum.fill(0.0);
m_voiceLevelCount.fill(0);
// Allocate cepstral FFT engines for formant detection
// Size needs to be power-of-2 and >= channel bins (typically ~100-200 bins)
// Use conservative size to handle most SSB channels
int maxChannelBins = std::max(binsPerChannel * 2, 256);
int cepstrumSize = 1;
while (cepstrumSize < maxChannelBins) {
cepstrumSize <<= 1;
}
if (m_cepstrumSize != cepstrumSize) {
// Release old engines if size changed
if (m_cepstrumSequenceInverse >= 0) {
fftFactory->releaseEngine(m_cepstrumSize, true, m_cepstrumSequenceInverse);
}
if (m_cepstrumSequenceForward >= 0) {
fftFactory->releaseEngine(m_cepstrumSize, false, m_cepstrumSequenceForward);
}
// Allocate new engines
m_cepstrumSequenceInverse = fftFactory->getEngine(cepstrumSize, true, &m_cepstrumFFTInverse);
m_cepstrumSequenceForward = fftFactory->getEngine(cepstrumSize, false, &m_cepstrumFFTForward);
m_cepstrumSize = cepstrumSize;
qDebug() << "FreqScannerSink::applyChannelSettings: Allocated cepstral FFT engines, size:" << cepstrumSize;
}
}
m_channelSampleRate = channelSampleRate;
@@ -268,3 +403,649 @@ void FreqScannerSink::applySettings(const FreqScannerSettings& settings, const Q
m_settings.applySettings(settingsKeys, settings);
}
}
// Voice activity detection for SSB signals
// Detects voice by looking for formant-like structure using spectral smoothing
// Returns a value from 0.0 (no voice) to 1.0 (strong voice signature)
Real FreqScannerSink::voiceActivityLevel(int bin, int channelBins, bool isLSB)
{
// Voice band in SSB is typically 100-3000 Hz from carrier
// We look for 2-4 formant peaks in the smoothed spectral envelope
int startBin = bin - channelBins / 2 + 1;
int endBin = startBin + channelBins - 1;
if (startBin < 0 || endBin >= m_fftSize) {
return 0.0;
}
// Calculate bin bandwidth in Hz
float binBW = m_scannerSampleRate / (float)m_fftSize;
// For LSB, spectrum is reversed - flip the search direction
int carrierBin = isLSB ? endBin : startBin;
// Get formant envelope using spectral smoothing
// This separates vocal tract resonances from pitch harmonics
QVector<Real> formantEnvelope;
Real pitchHz = 0.0;
getFormantEnvelope(startBin, endBin, formantEnvelope, &pitchHz);
if (formantEnvelope.isEmpty()) {
qDebug() << "FreqScannerSink::voiceActivityLevel formantEnvelope is empty!";
return 0.0;
}
// Calculate noise floor from formant envelope
Real noiseFloor = 0.0;
Real maxEnv = 0.0;
for (const Real env : formantEnvelope) {
noiseFloor += env;
maxEnv = std::max(maxEnv, env);
}
noiseFloor = formantEnvelope.size() > 0 ? noiseFloor / formantEnvelope.size() : 1e-12;
// For voice, we need reasonably high peaks relative to noise
// Use 4 dB (2.5x) above average as threshold for peak detection
// Additional validation: peak-to-noise ratio
// If max is not sufficiently higher than average, signal quality is poor
float peakToNoiseRatio = maxEnv / noiseFloor;
// Lower threshold: require at least 1.2x peak-to-noise ratio (only ~1.6dB)
// After heavy smoothing, formants appear as gentle bumps, not sharp peaks
if (peakToNoiseRatio < 1.2) {
// Signal is essentially all noise
return 0.0;
}
// For peak detection, use much lower threshold since smoothed spectral peaks are gentle
// Use 1.1x noise floor to catch formant peaks in the smoothed envelope
Real threshold = noiseFloor * 1.1;
// Find formant peaks in the smoothed envelope
QVector<int> formantBins;
QVector<Real> formantMags;
// Simple peak detection in formant envelope
const qsizetype formantEnvelopeSize = formantEnvelope.size();
for (qsizetype i = 1; i + 1 < formantEnvelopeSize; ++i)
{
Real prev = formantEnvelope[i - 1];
Real curr = formantEnvelope[i];
Real next = formantEnvelope[i + 1];
// Local maximum above threshold
if (curr > prev && curr > next && curr > threshold)
{
int absBin = startBin + static_cast<int>(i);
// Use SIGNED offset to distinguish USB from LSB and reject mistuned signals
// USB: formants at positive offset (100-3000 Hz above carrier)
// LSB: formants at negative offset (-3000 to -100 Hz below carrier)
float freqOffset = (absBin - carrierBin) * binBW;
// Check appropriate sideband for voice: USB uses positive, LSB uses negative
// This rejects signals tuned to wrong sideband (e.g., 1kHz offset)
bool inVoiceBand = isLSB ? (freqOffset <= -100.0 && freqOffset >= -3000.0)
: (freqOffset >= 100.0 && freqOffset <= 3000.0);
if (inVoiceBand)
{
formantBins.append(absBin);
formantMags.append(curr);
}
}
}
// Voice requires 2-4 formants
if (formantBins.size() < 2) {
return 0.0;
}
// Sort formants by frequency offset (not bin order) to handle LSB reversal
// Convert to absolute frequencies since F1/F2 validation expects positive values
QVector<float> formantFreqs;
QVector<int> formantIndices;
const qsizetype formantBinsSize = formantBins.size();
for (qsizetype i = 0; i < formantBinsSize; ++i)
{
// Use absolute value for formant frequency analysis (F1, F2 ranges are defined as positive)
float freqOffset = std::abs(formantBins[i] - carrierBin) * binBW;
formantFreqs.append(freqOffset);
formantIndices.append(static_cast<int>(i));
}
// Simple insertion sort by frequency
const qsizetype formantFreqsSize = formantFreqs.size();
for (qsizetype i = 1; i < formantFreqsSize; ++i)
{
for (qsizetype j = i; j > 0 && formantFreqs[j] < formantFreqs[j - 1]; --j)
{
std::swap(formantFreqs[j], formantFreqs[j - 1]);
std::swap(formantIndices[j], formantIndices[j - 1]);
}
}
// Merge peaks that are too close together (within 400 Hz)
// Real formants have minimum separation of 400-500 Hz in SSB voice
// So anything closer is ripple within a single formant
// We keep the peak with highest magnitude and remove others
QVector<float> mergedFormantFreqs;
QVector<int> mergedFormantIndices;
const float minFormantSpacing = 400.0; // Hz - minimum spacing between real formants
const qsizetype sortedFormantFreqsSize = formantFreqs.size();
for (qsizetype i = 0; i < sortedFormantFreqsSize; ++i)
{
if (i == 0 || formantFreqs[i] - mergedFormantFreqs.back() >= minFormantSpacing)
{
// This is a new formant (far enough from previous)
mergedFormantFreqs.append(formantFreqs[i]);
mergedFormantIndices.append(formantIndices[i]);
}
else
{
// This peak is too close to the previous one - merge by keeping highest magnitude
int prevIdx = mergedFormantIndices.back();
int currIdx = formantIndices[i];
if (formantMags[currIdx] > formantMags[prevIdx])
{
// Replace with higher magnitude peak
mergedFormantFreqs.back() = formantFreqs[i];
mergedFormantIndices.back() = currIdx;
}
}
}
formantFreqs = mergedFormantFreqs;
formantIndices = mergedFormantIndices;
// After merging, we should still have at least 2 formants for voice
if (formantFreqs.size() < 2) {
return 0.0;
}
// Check formant spacing (voice formants should be 400-1500 Hz apart)
// F1-F2 spacing is typically 600-1200 Hz
bool goodSpacing = false;
const qsizetype mergedFormantFreqsSize = formantFreqs.size();
for (qsizetype i = 0; i + 1 < mergedFormantFreqsSize; ++i)
{
float spacing = formantFreqs[i + 1] - formantFreqs[i];
if (spacing >= 400.0 && spacing <= 1500.0) {
goodSpacing = true;
break;
}
}
if (!goodSpacing) {
return 0.0; // Formants too close or too far apart
}
// Check for F1 formant in expected range (300-1000 Hz from carrier)
// This is critical for voice detection
// F1 should be the lowest frequency formant
bool hasF1 = false;
Real f1Mag = 0.0;
int f1Idx = -1;
if (formantFreqs.size() > 0 && formantFreqs[0] >= 300.0 && formantFreqs[0] <= 1000.0)
{
hasF1 = true;
f1Idx = formantIndices[0];
f1Mag = formantMags[f1Idx];
}
if (!hasF1) {
return 0.0; // No F1 formant - not voice or mistuned
}
// Check for F2 formant in expected range (900-2500 Hz from carrier)
// F2 should be higher frequency than F1 AND 400-1500 Hz away from F1
bool hasF2 = false;
Real f2Mag = 0.0;
int f2Idx = -1;
float f2Freq = 0.0;
const qsizetype validatedFormantFreqsSize = formantFreqs.size();
for (qsizetype i = 1; i < validatedFormantFreqsSize; ++i)
{
float freqOffset = formantFreqs[i];
float f1ToF2Spacing = freqOffset - formantFreqs[0];
// F2 must be: in range, higher than F1, and properly spaced from F1
if (freqOffset >= 900.0 && freqOffset <= 2500.0 &&
f1ToF2Spacing >= 400.0 && f1ToF2Spacing <= 1500.0)
{
hasF2 = true;
f2Idx = formantIndices[i];
f2Freq = freqOffset;
f2Mag = formantMags[f2Idx];
break; // Take the first valid F2
}
}
if (!hasF2) {
return 0.0; // No F2 formant - not voice or mistuned
}
// Reject strongly tonal spectra (typical CW / single-tone signals).
// Voice in SSB should have broader spectral spread, lower peak dominance,
// and non-negligible spectral flatness across the 100-3000 Hz voice band.
Real voiceBandTotalEnergy = 0.0;
Real voiceBandPeakEnergy = 0.0;
Real voiceBandSecondPeakEnergy = 0.0;
Real voiceBandLogEnergySum = 0.0;
Real voiceBandWeightedFreqSum = 0.0;
Real voiceBandWeightedFreqSqSum = 0.0;
QVector<Real> voiceBandEnergies;
voiceBandEnergies.reserve(std::max(0, endBin - startBin + 1));
int voiceBandBins = 0;
for (int absBin = startBin; absBin <= endBin; absBin++)
{
float signedOffset = (absBin - carrierBin) * binBW;
float voiceOffset = isLSB ? -signedOffset : signedOffset;
if (voiceOffset < 100.0f || voiceOffset > 3000.0f) {
continue;
}
Real binEnergy = magSqFromRawFFT(absBin);
Real safeEnergy = std::max(binEnergy, (Real) 1e-20);
voiceBandEnergies.append(safeEnergy);
voiceBandTotalEnergy += safeEnergy;
if (safeEnergy > voiceBandPeakEnergy)
{
voiceBandSecondPeakEnergy = voiceBandPeakEnergy;
voiceBandPeakEnergy = safeEnergy;
}
else if (safeEnergy > voiceBandSecondPeakEnergy)
{
voiceBandSecondPeakEnergy = safeEnergy;
}
voiceBandLogEnergySum += std::log(safeEnergy);
voiceBandWeightedFreqSum += safeEnergy * voiceOffset;
voiceBandWeightedFreqSqSum += safeEnergy * voiceOffset * voiceOffset;
voiceBandBins++;
}
if (voiceBandBins <= 0 || voiceBandTotalEnergy <= 0.0) {
return 0.0;
}
Real voiceBandMeanEnergy = voiceBandTotalEnergy / voiceBandBins;
Real voiceBandGeometricMean = std::exp(voiceBandLogEnergySum / voiceBandBins);
Real voiceBandMeanFreq = voiceBandWeightedFreqSum / voiceBandTotalEnergy;
Real voiceBandMeanFreqSq = voiceBandWeightedFreqSqSum / voiceBandTotalEnergy;
Real voiceBandVariance = std::max((Real) 0.0, voiceBandMeanFreqSq - voiceBandMeanFreq * voiceBandMeanFreq);
float voiceBandRmsSpreadHz = std::sqrt(voiceBandVariance);
float spectralFlatness = voiceBandMeanEnergy > 0.0 ? (voiceBandGeometricMean / voiceBandMeanEnergy) : 0.0f;
float peakFraction = voiceBandTotalEnergy > 0.0 ? (voiceBandPeakEnergy / voiceBandTotalEnergy) : 1.0f;
float peak12Ratio = voiceBandSecondPeakEnergy > 0.0 ? (voiceBandPeakEnergy / voiceBandSecondPeakEnergy) : 1000.0f;
int significantBins = 0;
Real significantThreshold = voiceBandMeanEnergy * 1.8;
for (const Real& binEnergy : voiceBandEnergies)
{
if (binEnergy > significantThreshold) {
significantBins++;
}
}
if ((peakFraction > 0.45f) || (spectralFlatness < 0.025f) || (significantBins < 5)) {
return 0.0;
}
// Additional strong-CW rejection:
// - very narrow energy spread in voice band
// - one dominant spectral line overwhelmingly larger than the second line
if ((voiceBandRmsSpreadHz < 220.0f) || ((peak12Ratio > 8.0f) && (significantBins < 9))) {
return 0.0;
}
float tonalPenalty = 1.0f;
if (peakFraction > 0.30f) {
tonalPenalty *= 0.6f;
}
if (spectralFlatness < 0.06f) {
tonalPenalty *= 0.7f;
}
if (voiceBandRmsSpreadHz < 350.0f) {
tonalPenalty *= 0.55f;
}
if (peak12Ratio > 4.0f) {
tonalPenalty *= 0.60f;
}
// CW rejection end
// Additional F1 plausibility checks.
// Prevent a tiny low-frequency ripple from being accepted as F1 when
// dominant formant energy is shifted high (e.g. around 2 kHz).
Real strongestFormantMag = 0.0;
float strongestFormantFreq = 0.0f;
for (int i = 0; i < formantFreqs.size(); i++)
{
int idx = formantIndices[i];
if (formantMags[idx] > strongestFormantMag)
{
strongestFormantMag = formantMags[idx];
strongestFormantFreq = formantFreqs[i];
}
}
const float minF1ToStrongestRatio = 0.35f;
const float dominantHighFormantHz = 1200.0f;
bool weakF1 = f1Mag < strongestFormantMag * minF1ToStrongestRatio;
bool dominantIsHigh = strongestFormantFreq >= dominantHighFormantHz;
if (weakF1 && dominantIsHigh) {
return 0.0;
}
// F1 must have a minimum contrast above noise floor.
if (f1Mag < noiseFloor * 1.25f) {
return 0.0;
}
// Additional F1 plausibility checks - end
// Calculate voice activity score based on formant characteristics
// Voice is indicated by presence of F1 and F2 formants - this is the primary voice signature
// Harmonics are less reliable in SSB due to spectral properties and noise
float score = 0.0;
// Base score from number of formants (2-4 formants typical for voice)
// Formants are the most reliable voice indicator
// Use merged formant count, not raw peak count
float formantScore = std::min(formantFreqs.size() / 3.0f, 1.0f);
score += formantScore * 0.6; // 60% weight
// Score from formant magnitude (strong formants = strong voice)
// Higher magnitudes indicate clearer voice detection
// Make this threshold appropriately high to prefer strong signals
float magnitudeScore = std::min((f1Mag + f2Mag) / (noiseFloor * 6.0f), 1.0f);
score += magnitudeScore * 0.4; // 40% weight
// Apply a soft pitch-based weighting. Pitch helps detect detuning, but should not gate voice.
const float minPitchHz = 70.0f;
const float maxPitchHz = 300.0f;
const float softMinPitchHz = 50.0f;
const float softMaxPitchHz = 400.0f;
float pitchScore = 0.7f;
float harmonicAlignmentScore = 0.75f;
float formantIndexScore = 0.8f;
if (pitchHz > 0.0f) {
if (pitchHz >= minPitchHz && pitchHz <= maxPitchHz) {
pitchScore = 1.0f;
} else if (pitchHz >= softMinPitchHz && pitchHz <= softMaxPitchHz) {
if (pitchHz < minPitchHz) {
pitchScore = 0.7f + 0.3f * (pitchHz - softMinPitchHz) / (minPitchHz - softMinPitchHz);
} else {
pitchScore = 0.7f + 0.3f * (softMaxPitchHz - pitchHz) / (softMaxPitchHz - maxPitchHz);
}
} else {
pitchScore = 0.6f;
}
// Check harmonic-comb alignment against the estimated pitch.
// A wrong carrier offset shifts all harmonics by a constant frequency,
// so they no longer align with integer multiples of pitch.
const float harmonicToleranceHz = std::max(2.0f * binBW, 0.18f * pitchHz);
Real rawNoiseFloor = 0.0;
int rawBinCount = 0;
for (int absBin = startBin; absBin <= endBin; absBin++)
{
float signedOffset = (absBin - carrierBin) * binBW;
float voiceOffset = isLSB ? -signedOffset : signedOffset;
if (voiceOffset >= 100.0f && voiceOffset <= 3000.0f)
{
rawNoiseFloor += magSqFromRawFFT(absBin);
rawBinCount++;
}
}
rawNoiseFloor = rawBinCount > 0 ? rawNoiseFloor / rawBinCount : 0.0;
Real alignedEnergy = 0.0;
Real totalEnergy = 0.0;
for (int absBin = startBin; absBin <= endBin; absBin++)
{
float signedOffset = (absBin - carrierBin) * binBW;
float voiceOffset = isLSB ? -signedOffset : signedOffset;
if (voiceOffset < 100.0f || voiceOffset > 3000.0f) {
continue;
}
Real binEnergy = magSqFromRawFFT(absBin);
if (binEnergy <= rawNoiseFloor * 1.2f) {
continue;
}
float residue = std::fmod(voiceOffset, pitchHz);
if (residue < 0.0f) {
residue += pitchHz;
}
float harmonicDistance = std::min(residue, pitchHz - residue);
Real weightedEnergy = std::max(binEnergy - rawNoiseFloor, (Real) 0.0);
totalEnergy += weightedEnergy;
if (harmonicDistance <= harmonicToleranceHz) {
alignedEnergy += weightedEnergy;
}
}
if (totalEnergy > 0.0)
{
float harmonicAlignment = alignedEnergy / totalEnergy;
float normalizedAlignment = (harmonicAlignment - 0.20f) / 0.45f;
normalizedAlignment = std::max(0.0f, std::min(normalizedAlignment, 1.0f));
harmonicAlignmentScore = 0.5f + 0.5f * normalizedAlignment;
}
// Check formant harmonic-index plausibility.
// Wrong carrier tuning that shifts spectrum down can make F2/F3 appear as F1/F2.
// In that case the implied harmonic indices become unusually high.
float f1HarmonicIndex = formantFreqs[0] / pitchHz;
float f2HarmonicIndex = f2Freq / pitchHz;
float harmonicGap = f2HarmonicIndex - f1HarmonicIndex;
float f1IndexScore = 1.0f;
if (f1HarmonicIndex < 2.0f) {
f1IndexScore = std::max(0.0f, (f1HarmonicIndex - 1.0f) / 1.0f);
} else if (f1HarmonicIndex > 18.0f) {
f1IndexScore = std::max(0.0f, (26.0f - f1HarmonicIndex) / 8.0f);
}
float f2IndexScore = 1.0f;
if (f2HarmonicIndex < 5.0f) {
f2IndexScore = std::max(0.0f, (f2HarmonicIndex - 3.0f) / 2.0f);
} else if (f2HarmonicIndex > 36.0f) {
f2IndexScore = std::max(0.0f, (44.0f - f2HarmonicIndex) / 8.0f);
}
float gapScore = 1.0f;
if (harmonicGap < 3.0f) {
gapScore = std::max(0.0f, (harmonicGap - 1.0f) / 2.0f);
} else if (harmonicGap > 22.0f) {
gapScore = std::max(0.0f, (28.0f - harmonicGap) / 6.0f);
}
formantIndexScore = std::max(0.4f, 0.25f + 0.75f * (0.40f * f1IndexScore + 0.40f * f2IndexScore + 0.20f * gapScore));
}
score *= tonalPenalty * pitchScore * harmonicAlignmentScore * formantIndexScore;
// Clamp to [0, 1]
score = std::max(0.0f, std::min(score, 1.0f));
return score;
}
// Compute formant envelope using cepstral liftering
// This separates vocal tract resonances (formants) from pitch harmonics
// Method: log spectrum → IFFT → lifter → FFT → exp
void FreqScannerSink::getFormantEnvelope(int startBin, int endBin, QVector<Real>& envelope, Real *pitchHz)
{
if (pitchHz) {
*pitchHz = 0.0;
}
if (startBin < 0 || endBin >= m_fftSize || startBin > endBin) {
envelope.clear();
return;
}
int numBins = endBin - startBin + 1;
envelope.resize(numBins);
// Check if cepstral FFT engines are available and large enough
if (!m_cepstrumFFTInverse || !m_cepstrumFFTForward || numBins > m_cepstrumSize) {
// Fallback: return simple log/exp without cepstral processing
for (int i = 0; i < numBins; i++) {
Real magSq = magSqFromRawFFT(startBin + i);
envelope[i] = std::sqrt(std::max(magSq, (Real)1e-12));
}
return;
}
// Step 1: Compute log magnitude spectrum
QVector<Real> logMag(numBins);
Real minLog = -10.0; // Floor to avoid log(0)
Real sumMagSq = 0.0;
for (int i = 0; i < numBins; i++)
{
Real magSq = magSqFromRawFFT(startBin + i);
sumMagSq += magSq;
Real mag = std::sqrt(std::max(magSq, (Real)1e-12));
logMag[i] = std::log(mag);
if (logMag[i] < minLog) {
logMag[i] = minLog;
}
}
// Step 2: Apply cepstral liftering for better source-filter separation
// Cepstral analysis separates:
// - Voice pitch (high quefrency peak)
// - Formants (low quefrency components)
// Liftering = low-pass filtering in quefrency domain
// Copy log magnitude to inverse FFT input (real data, symmetric spectrum)
// For real cepstrum, we need a symmetric spectrum:
// [DC, positive freqs, Nyquist, negative freqs (mirror)]
// DC component
m_cepstrumFFTInverse->in()[0] = Complex(logMag[0], 0.0);
// Positive frequencies
int halfBins = std::min(numBins, m_cepstrumSize / 2);
for (int i = 1; i < halfBins; i++) {
m_cepstrumFFTInverse->in()[i] = Complex(logMag[i], 0.0);
}
// Nyquist (if we have space)
if (m_cepstrumSize > halfBins) {
m_cepstrumFFTInverse->in()[halfBins] = Complex(numBins > halfBins ? logMag[halfBins] : logMag[halfBins-1], 0.0);
}
// Negative frequencies (mirror of positive)
for (int i = 1; i < halfBins; i++) {
m_cepstrumFFTInverse->in()[m_cepstrumSize - i] = Complex(logMag[i], 0.0);
}
// Zero-pad the middle if needed
for (int i = halfBins + 1; i < m_cepstrumSize - halfBins; i++) {
m_cepstrumFFTInverse->in()[i] = Complex(0.0, 0.0);
}
// Step 3: IFFT to get cepstrum (quefrency domain)
m_cepstrumFFTInverse->transform();
// Step 4: Estimate pitch from the cepstrum before liftering.
// Pitch appears as a peak at higher quefrencies (around 3-14 ms).
float binBW = m_scannerSampleRate / (float)m_fftSize;
float quefrencyResolution = 1.0f / (m_cepstrumSize * binBW); // seconds per bin in quefrency
if (pitchHz) {
const float minPitchHz = 70.0f;
const float maxPitchHz = 300.0f;
const float minQuefrency = 1.0f / maxPitchHz;
const float maxQuefrency = 1.0f / minPitchHz;
const int minBin = std::max(1, (int)std::ceil(minQuefrency / quefrencyResolution));
const int maxBin = std::min(m_cepstrumSize / 2, (int)std::floor(maxQuefrency / quefrencyResolution));
Real maxVal = 0.0;
int maxIdx = -1;
for (int i = minBin; i <= maxBin; i++) {
Real val = std::abs(m_cepstrumFFTInverse->out()[i].real());
if (val > maxVal) {
maxVal = val;
maxIdx = i;
}
}
if (maxIdx > 0) {
*pitchHz = 1.0f / (maxIdx * quefrencyResolution);
}
}
// Step 5: Apply lifter (low-pass filter in quefrency domain)
// Lifter cutoff: keep low quefrencies (formant envelope), remove high quefrencies (pitch harmonics)
// Typical pitch periods: 3-10 ms (100-330 Hz F0)
// We want to remove quefrencies corresponding to pitch harmonics
// Lifter cutoff in seconds (quefrency): keep components below this
// Use much lower cutoff - we only need to keep very low quefrencies for formant envelope
// Most formant information is in the first few quefrency bins
float lifterCutoffQuefrency = 0.002f; // 2 ms (reduced from 8 ms)
int lifterCutoffBin = std::max(1, (int)(lifterCutoffQuefrency / quefrencyResolution));
// Cap the lifter cutoff to reasonable maximum (1/4 of cepstrum size)
lifterCutoffBin = std::min(lifterCutoffBin, m_cepstrumSize / 4);
// Apply lifter: keep low quefrencies, zero out high quefrencies
// Use smooth transition (raised cosine) to reduce artifacts
int transitionBins = std::max(1, lifterCutoffBin / 4);
for (int i = 0; i < m_cepstrumSize; i++) {
Real lifterWeight = 1.0;
if (i > lifterCutoffBin + transitionBins) {
lifterWeight = 0.0; // Zero out high quefrencies
} else if (i > lifterCutoffBin) {
// Smooth transition using raised cosine
float t = (float)(i - lifterCutoffBin) / transitionBins;
lifterWeight = 0.5 * (1.0 + std::cos(M_PI * t));
}
// else: lifterWeight = 1.0 (keep low quefrencies)
m_cepstrumFFTForward->in()[i] = m_cepstrumFFTInverse->out()[i] * lifterWeight;
}
// Step 6: FFT back to frequency domain (smoothed log spectrum)
m_cepstrumFFTForward->transform();
// Step 7: Convert back to linear magnitude
// Take real part of FFT output and exponentiate
// IMPORTANT: Normalize by FFT size since FFT engines don't auto-normalize
Real normalization = 1.0 / m_cepstrumSize;
for (int i = 0; i < numBins; i++)
{
Real smoothedLog = m_cepstrumFFTForward->out()[i].real() * normalization;
envelope[i] = std::exp(smoothedLog);
}
}
@@ -74,12 +74,24 @@ private:
FixedAverage2D<Real> m_fftAverage; // magSq average
QVector<Real> m_magSq;
int m_averageCount;
QVector<Real> m_voiceLevelSum; // Sum of voice levels for averaging
QVector<int> m_voiceLevelCount; // Count of voice level samples
// Cepstral analysis FFT engines for formant detection
int m_cepstrumSequenceInverse;
int m_cepstrumSequenceForward;
FFTEngine *m_cepstrumFFTInverse;
FFTEngine *m_cepstrumFFTForward;
int m_cepstrumSize;
void processOneSample(Complex &ci);
MessageQueue *getMessageQueueToChannel() { return m_messageQueueToChannel; }
Real totalPower(int bin, int channelBins) const;
Real peakPower(int bin, int channelBins) const;
Real magSq(int bin) const;
Real magSqFromRawFFT(int bin) const;
Real voiceActivityLevel(int bin, int channelBins, bool isLSB);
void getFormantEnvelope(int startBin, int endBin, QVector<Real>& envelope, Real *pitchHz = nullptr);
};
#endif // INCLUDE_FREQSCANNERSINK_H
+53 -24
View File
@@ -30,47 +30,51 @@ Use the wheels of keyboard to adjust the minimum frequency shift in Hz from the
This setting is typically used to avoid having the channel (1) centered at DC, which can be problematic for some demodulators used with SDRs with a DC spike.
<h3>3: Active frequency power</h3>
<h3>3: Lock device frequency</h3>
Locks device frequency to its current value. This is useful for network devices like the TCP input that has a very long latency for setting the frequency. It makes impractical to move the device frequency. In this kind of setup you have to make sure that the scanned channels all fit in the same baseband.
<h3>4: Active frequency power</h3>
Average power in dB relative to a +/- 1.0 amplitude signal received for the active frequency. This is set to '-' while scanning.
<h3>4: TH - Threshold</h3>
<h3>5: TH - Threshold</h3>
Power threshold in dB that determines whether a frequency is active or not.
<h3>5: t_delta_f - Tune time</h3>
<h3>6: t_delta_f - Tune time</h3>
Specifies the time in milliseconds that the Frequency Scanner should wait after adjusting the device center frequency, before starting a measurement.
This time should take in to account PLL settle time and the device to host transfer latency, so that the measurement only starts when IQ data
that corresponds to the set frequency is being received.
<h3>6: t_s - Scan time</h3>
<h3>7: t_s - Scan time</h3>
Specifies the time in seconds that the Frequency Scanner will average its power measurement over.
<h3>7: t_rtx - Retransmission Time / t_rx Receive Time</h3>
<h3>8: t_rtx - Retransmission Time / t_rx Receive Time</h3>
t_rtx: When Run Mode (11) is not Multiplex, specifies the time in seconds that the Frequency Scanner will wait after the power on the active frequency falls below the threshold, before restarting
scanning. This enables the channel to remain tuned to a single frequency while there is a temporary break in transmission.
t_rx: When Run Mode (11) is Multiplex, specifies the time in seconds the channel will be tuned to each frequency.
<h3>8: Ch BW - Channel Bandwidth</h3>
<h3>9: Ch BW - Channel Bandwidth</h3>
This specifies the bandwidth of the channels to be scanned.
<h3>9: Channel shift</h3>
<h3>10: Channel shift</h3>
This shift is applied to the controlled channel from the scanned center frequency. This is useful for SSB or CW or generally whenever the signal of interest is only on one side of the controlled channel center frequency.
<h3>10: Pri - Priority</h3>
<h3>11: Pri - Priority</h3>
Specifies which frequency will be chosen as the active frequency, when multiple frequencies exceed the threshold (4):
- Max power: The frequency with the highest power will be chosen
- Max power: The frequency with the highest power will be chosen. If VAD is active this is the highest voice score/
- Table order: The frequency first in the frequency table (14) will be chosen.
<h3>11: Meas - Power Measurement</h3>
<h3>12: Meas - Power Measurement</h3>
Specifies how power is measured. In both cases, a FFT is used.
FFT size is typically the same as used for the Main Spectrum, but may be increased to ensure at least 8 bins cover the channel bandwidth (8).
@@ -82,7 +86,7 @@ The first and last bins are excluded from the measurement (to reduce spectral le
Peak can be used when you wish to set the threshold roughly according to the level displayed in the Main Spectrum.
Total is potentially more useful for wideband signals, that are close to the noise floor.
<h3>12: Run Mode</h3>
<h3>13: Run Mode</h3>
Specifies the run mode:
@@ -91,18 +95,38 @@ Specifies the run mode:
- Scan only: All frequencies are scanned repeatedly. The channel will not be tuned. This mode is just for counting how often frequencies are active, which can be seen in the Active Count column in the frequency table (14).
- Multiplex: Frequencies will be stepped through sequentially and repeatedly, with the channel (1) being tuned for the time specified by t_rx (7).
<h3>13: Start/Stop Scanning</h3>
<h3>14: Start/Stop Scanning</h3>
Press this button to start or stop scanning.
<h3>14: Status Text</h3>
<h3>15: Restart scanning</h3>
Forces resuming scanning.
<h3>16: Status Text</h3>
Displays the current status of the Frequency Scanner.
- "Scanning": When scanning for active frequencies.
- Frequency and annotation for active frequency.
<h3>15: Frequency Table</h3>
<h3>17: Voice Activity Detection (VAD) mode</h3>
- **None**: No VAD is active. Scanning is based on channel power only
- **LSB**: For SSB LSB, scanning is based on the likelihood of the audio to be a human voice. This yields a score between 0.0 and 1.0 that can be used to control the scanner (18)
- **USB**: Same thing but for SSB USB mode
SSB voice detection makes the following assumptions:
- the channel bandwidth (9) is set to 3000 Hz and the shift (10) to 1500 Hz for LSB and -1500 Hz for USB so that the audio spectrum from 0 to 3000 Hz fits in the passband.
- as most of the time transmissions occur on integer multiples of the kHz you must set your scanning frequencies as a comb of frequencies 1 kHz apart on 500 Hz points (e.g 7110.5, 7111.5, ... kHz)
- the VAD threshold value (18) works best for values in the 0.75~0.85 range.
- of course you must make sure that the controlled channel supports SSB and is set to the correct sideband mode.
<h3>18: Voice Activity threshold</h3>
In LSB and USB VAD modes, voice likelihood score above which a channel is declared active.
<h3> Frequency Table</h3>
The frequency table contains the list of frequencies to be scanned, along with results of a scan. The columns are:
@@ -110,6 +134,7 @@ The frequency table contains the list of frequencies to be scanned, along with r
- Annotation: An annotation (description) for the frequency, that is obtained from the closest matching [annotation marker](../../../sdrgui/gui/spectrummarkers.md) in the Main Spectrum.
- Enable: Determines whether the frequency will be scanned. This can be used to temporarily disable frequencies you aren't interested in.
- Power (dB): Displays the measured power in decibels from the last scan. The cell will have a green background if the power was above the threshold (4).
- VAD: Voice activity likelihood score in the [0.0, 1.0] range
- Active Count: Displays the number of scans in which the power for this frequency was above the threshold (4). This allows you to see which frequencies are commonly in use.
- Notes: Available for user-entry of notes/information about this frequency.
- Channel: Specifies the channel that should be tuned when this frequency is active. If blank, the common Channel setting (1) is used.
@@ -127,32 +152,36 @@ Right clicking on a cell will display a popup menu:
- Remove selected rows.
- Tune selected channel (1) to the frequency in the row clicked on.
<h3>16: Add</h3>
<h3>20: Add</h3>
Press to add a single row to the frequency table (14).
<h3>17: Add Range</h3>
<h3>21: Add Range</h3>
Press to add a range of frequencies to the frequency table (14). A dialog is displayed with start and stop frequencies, as well as a step value.
Press to add a range of frequencies to the frequency table. A dialog is displayed with start and stop frequencies, as well as a step value.
The step value should typically be an integer multiple of the channel bandwidth (8).
<h3>18: Remove</h3>
<h3>22: Remove</h3>
Removes the selected rows from the frequency table (14). Press Ctrl-A to select all rows.
<h3>19: Remove Inactive</h3>
<h3>23: Remove Inactive</h3>
Removes all rows with Active Count of 0.
<h3>20: Up</h3>
<h3>24: Remove all</h3>
Remove all rows unconditionnally
<h3>25: Up</h3>
Moves the selected rows up the frequency table (14).
<h3>21: Down</h3>
<h3>26: Down</h3>
Moves the selected rows the the frequency table (14).
<h3>22: Import Frequencies from .csv</h3>
<h3>27: Import Frequencies from .csv</h3>
Imports frequencies from a .csv file.
@@ -160,11 +189,11 @@ The expected column names are "Freq (Hz)", "Enable", "Notes", "Channel", "Ch BW
Annotations are not included. These should be imported via the Spectrum Markers dialog.
<h3>23: Export Frequencies to .csv</h3>
<h3>28: Export Frequencies to .csv</h3>
Exports frequencies to a .csv file. Note that annotations are not included. These should be exported via the Spectrum Markers dialog.
<h3>24: Clear Active Count</h3>
<h3>29: Clear Active Count</h3>
Press to reset the value in the Active Count column to 0 for all rows.