diff --git a/.gitignore b/.gitignore index 6ed4a6b8..b58fdc7a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ msix-root-*/ *.msixupload *.pfx packaging/windows/certs/ +Resumen para empezar de nuevo.txt diff --git a/CMakeLists.txt b/CMakeLists.txt index d236b01e..4ba34390 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -577,6 +577,8 @@ set(CORE_SOURCES src/core/SleepInhibitor.cpp src/core/MemoryCsvCompat.cpp src/core/MemoryRecallPolicy.cpp + src/core/WfmDemodulator.cpp + src/core/WaveOutWriter.cpp src/core/tnc/AetherAx25LibmodemShim.cpp src/core/tnc/Ax25FrameFormatter.cpp src/core/tnc/Ax25.cpp @@ -634,6 +636,7 @@ set(GUI_SOURCES src/gui/MainWindow.cpp src/gui/AgcCalibrationDialog.cpp src/gui/AudioDeviceChangeDialog.cpp + src/gui/WfmDeviceDialog.cpp src/gui/ConnectionPanel.cpp src/gui/ClientDisconnectDialog.cpp src/gui/ConnectedStationsDialog.cpp diff --git a/src/core/WaveOutWriter.cpp b/src/core/WaveOutWriter.cpp new file mode 100644 index 00000000..ce1e90a6 --- /dev/null +++ b/src/core/WaveOutWriter.cpp @@ -0,0 +1,86 @@ +#include "core/WaveOutWriter.h" +#include "core/LogManager.h" +#include + +namespace AetherSDR { + +WaveOutWriter::WaveOutWriter(QObject* parent) + : QObject(parent) +{} + +WaveOutWriter::~WaveOutWriter() +{ + close(); +} + +bool WaveOutWriter::open(const QString& deviceId, int sampleRate, int channelCount) +{ + close(); + + // Find the requested device by its persistent ID string. + QAudioDevice found; + const auto outputs = QMediaDevices::audioOutputs(); + qCDebug(lcAudio) << "WaveOutWriter::open looking for" << deviceId + << "among" << outputs.size() << "devices"; + for (const QAudioDevice& dev : outputs) { + if (dev.id() == deviceId.toUtf8()) { + found = dev; + break; + } + } + if (found.isNull()) { + qCDebug(lcAudio) << "WaveOutWriter::open: device not found —" << deviceId; + return false; + } + + QAudioFormat fmt; + fmt.setSampleRate(sampleRate); + fmt.setChannelCount(channelCount); + fmt.setSampleFormat(QAudioFormat::Int16); + + if (!found.isFormatSupported(fmt)) { + qCDebug(lcAudio) << "WaveOutWriter::open: Int16 format not supported by" + << found.description() << "— trying default format"; + fmt = found.preferredFormat(); + } + + m_sink = new QAudioSink(found, fmt, this); + m_io = m_sink->start(); + + if (!m_io || m_sink->error() != QAudio::NoError) { + qCDebug(lcAudio) << "WaveOutWriter::open: QAudioSink::start() failed, error=" + << m_sink->error(); + delete m_sink; + m_sink = nullptr; + m_io = nullptr; + return false; + } + + m_deviceName = found.description(); + qCDebug(lcAudio) << "WaveOutWriter opened:" << m_deviceName + << "rate=" << fmt.sampleRate() + << "ch=" << fmt.channelCount() + << "fmt=" << fmt.sampleFormat(); + return true; +} + +void WaveOutWriter::close() +{ + if (m_sink) { + m_sink->stop(); + delete m_sink; + m_sink = nullptr; + m_io = nullptr; + qCDebug(lcAudio) << "WaveOutWriter closed"; + } + m_deviceName.clear(); +} + +void WaveOutWriter::write(const QByteArray& pcm) +{ + if (!m_io || pcm.isEmpty()) + return; + m_io->write(pcm); +} + +} // namespace AetherSDR diff --git a/src/core/WaveOutWriter.h b/src/core/WaveOutWriter.h new file mode 100644 index 00000000..4b9f2e47 --- /dev/null +++ b/src/core/WaveOutWriter.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace AetherSDR { + +// Cross-platform audio output wrapper backed by QAudioSink (Qt6 Multimedia). +// Accepts Int16 stereo PCM at a fixed sample rate and writes it to the +// selected output device — works on Windows (WASAPI), macOS (CoreAudio), +// and Linux (PipeWire / PulseAudio / ALSA). +class WaveOutWriter : public QObject +{ + Q_OBJECT +public: + explicit WaveOutWriter(QObject* parent = nullptr); + ~WaveOutWriter() override; + + // Open the device whose description contains |deviceId| (the + // QAudioDevice::id() string stored in WfmSettings). Returns true on + // success. |sampleRate| is the output rate in Hz (typically 48000). + bool open(const QString& deviceId, int sampleRate, int channelCount = 2); + + void close(); + + // Write Int16 interleaved PCM samples. Thread-safe: may be called from + // any thread; internally posts to the Qt event loop. + void write(const QByteArray& pcm); + + bool isOpen() const { return m_sink != nullptr; } + QString deviceName() const { return m_deviceName; } + +private: + QAudioSink* m_sink{nullptr}; + QIODevice* m_io{nullptr}; + QString m_deviceName; +}; + +} // namespace AetherSDR diff --git a/src/core/WfmDemodulator.cpp b/src/core/WfmDemodulator.cpp new file mode 100644 index 00000000..5c8946b9 --- /dev/null +++ b/src/core/WfmDemodulator.cpp @@ -0,0 +1,174 @@ +#include "core/WfmDemodulator.h" +#include "core/WaveOutWriter.h" +#include "core/LogManager.h" +#include "models/DaxIqModel.h" + +#include +#include + +namespace AetherSDR { + +WfmDemodulator::WfmDemodulator(QObject* parent) + : QObject(parent) +{} + +WfmDemodulator::~WfmDemodulator() +{ + stop(); +} + +void WfmDemodulator::start(DaxIqModel* daxIq, const QString& deviceId, + const QString& panId, float freqOffsetHz) +{ + if (m_active) stop(); + + m_daxIq = daxIq; + m_prevI = 0.0f; + m_prevQ = 0.0f; + m_corrCos = 1.0f; + m_corrSin = 0.0f; + m_panId = panId; + m_panSent = false; + + // Pre-compute the per-sample rotation for frequency correction. + const float step = -2.0f * static_cast(M_PI) * freqOffsetHz / IQ_RATE; + m_corrCosStep = std::cos(step); + m_corrSinStep = std::sin(step); + + qCDebug(lcAudio) << "WfmDemodulator::start deviceId=" << deviceId + << "freqOffsetHz=" << freqOffsetHz; + + m_waveOut = new WaveOutWriter(this); + if (!m_waveOut->open(deviceId, AUDIO_RATE, 2)) { + qCDebug(lcAudio) << "WfmDemodulator: failed to open audio device:" << deviceId; + delete m_waveOut; + m_waveOut = nullptr; + return; + } + + // Wire VITA-49 DaxIqModel path (cross-platform). + connect(m_daxIq, &DaxIqModel::iqSamplesReady, + this, &WfmDemodulator::onIqSamples); + connect(m_daxIq, &DaxIqModel::streamChanged, + this, &WfmDemodulator::onStreamChanged); + m_daxIq->createStream(DAX_CHANNEL); + + m_active = true; +} + +void WfmDemodulator::stop() +{ + if (!m_active) return; + m_active = false; + + if (m_daxIq) { + m_daxIq->removeStream(DAX_CHANNEL); + disconnect(m_daxIq, nullptr, this, nullptr); + m_daxIq = nullptr; + } + if (m_waveOut) { + m_waveOut->close(); + delete m_waveOut; + m_waveOut = nullptr; + } +} + +void WfmDemodulator::onStreamChanged(int channel) +{ + const auto& s = m_daxIq->stream(DAX_CHANNEL); + qCDebug(lcAudio) << "WfmDemodulator::onStreamChanged ch=" << channel + << "panSent=" << m_panSent << "panId=" << m_panId + << "exists=" << s.exists << "active=" << s.active + << "streamId=0x" + QString::number(s.streamId, 16); + if (channel != DAX_CHANNEL || m_panSent || m_panId.isEmpty()) return; + if (!s.exists || s.streamId == 0) return; + const QString cmd = QString("stream set 0x%1 pan=%2") + .arg(s.streamId, 0, 16).arg(m_panId); + qCDebug(lcAudio) << "WfmDemodulator: sending" << cmd; + emit commandReady(cmd); + m_panSent = true; +} + +void WfmDemodulator::onIqSamples(int channel, QVector iqInterleaved, int /*sampleRate*/) +{ + if (channel != DAX_CHANNEL || !m_active || !m_waveOut) return; + processSamples(iqInterleaved); +} + +void WfmDemodulator::processSamples(const QVector& iqInterleaved) +{ + const int numSamples = iqInterleaved.size() / 2; + if (numSamples <= 0) return; + + QByteArray pcm(numSamples * 2 * sizeof(qint16), Qt::Uninitialized); + auto* out = reinterpret_cast(pcm.data()); + + float prevI = m_prevI; + float prevQ = m_prevQ; + + for (int i = 0; i < numSamples; ++i) { + float I = iqInterleaved[2 * i]; + float Q = iqInterleaved[2 * i + 1]; + + // Frequency correction: rotate IQ by the running phasor. + { + const float Ic = I * m_corrCos - Q * m_corrSin; + const float Qc = I * m_corrSin + Q * m_corrCos; + I = Ic; Q = Qc; + const float newCos = m_corrCos * m_corrCosStep - m_corrSin * m_corrSinStep; + const float newSin = m_corrCos * m_corrSinStep + m_corrSin * m_corrCosStep; + m_corrCos = newCos; + m_corrSin = newSin; + } + + // Normalize to unit circle (carrier lock) + const float amp = std::sqrt(I * I + Q * Q); + if (amp > 1e-9f) { I /= amp; Q /= amp; } + else { I = prevI; Q = prevQ; } + + // Phase-difference FM discriminator + const float cross = I * prevQ - Q * prevI; + const float dot = I * prevI + Q * prevQ; + float audio = std::atan2(cross, dot) * (GAIN / static_cast(M_PI)); + audio = std::max(-1.0f, std::min(1.0f, audio)); + + prevI = I; + prevQ = Q; + + const qint16 s16 = static_cast(audio * 32767.0f * m_volume); + out[i * 2] = s16; + out[i * 2 + 1] = s16; + } + + m_prevI = prevI; + m_prevQ = prevQ; + + // Renormalize frequency-correction phasor every block to prevent drift. + { + const float norm = std::sqrt(m_corrCos * m_corrCos + m_corrSin * m_corrSin); + if (norm > 1e-9f) { m_corrCos /= norm; m_corrSin /= norm; } + } + + // Periodic signal-level diagnostic (every 100 blocks ≈ every ~2 s at 48 kHz/512) + static int s_blk = 0; + if (++s_blk % 100 == 0) { + float iqRms = 0, audioRms = 0, audioMax = 0; + for (int i = 0; i < numSamples; ++i) { + const float rI = iqInterleaved[2*i], rQ = iqInterleaved[2*i+1]; + iqRms += rI*rI + rQ*rQ; + const float a = std::abs(out[i*2] / 32767.0f); + audioRms += a*a; + if (a > audioMax) audioMax = a; + } + iqRms = std::sqrt(iqRms / numSamples); + audioRms = std::sqrt(audioRms / numSamples); + qCDebug(lcAudio) << "WfmDemodulator blk#" << s_blk + << "IQ_rms=" << iqRms + << "audio_rms=" << audioRms + << "audio_max=" << audioMax; + } + + m_waveOut->write(pcm); +} + +} // namespace AetherSDR diff --git a/src/core/WfmDemodulator.h b/src/core/WfmDemodulator.h new file mode 100644 index 00000000..f2734388 --- /dev/null +++ b/src/core/WfmDemodulator.h @@ -0,0 +1,76 @@ +#pragma once +#include +#include +#include +#include + +namespace AetherSDR { + +class DaxIqModel; +class WaveOutWriter; + +// Software FM demodulator for satellite work (G3RUH 9600 baud, hs-soundmodem). +// Uses a phase-difference (atan2) discriminator. +// +// IQ source : VITA-49 DaxIqModel::iqSamplesReady — cross-platform, +// no Win32 dependency. +// IF bandwidth : ±20 kHz slice filter +// Audio output : QAudioSink → any system output device (VAC, BlackHole, +// PipeWire null-sink, etc.) at 48 kHz stereo Int16. +// Gain : G3RUH ±4.8 kHz deviation → ~60 % full scale +class WfmDemodulator : public QObject +{ + Q_OBJECT +public: + static constexpr int DAX_CHANNEL = 1; + static constexpr int IQ_RATE = 48000; + static constexpr int AUDIO_RATE = 48000; + static constexpr int FILTER_HZ = 20000; + static constexpr float GAIN = 1.0f; + + explicit WfmDemodulator(QObject* parent = nullptr); + ~WfmDemodulator() override; + + // deviceId : QAudioDevice::id() string stored in WfmSettings. + // panId : panadapter ID string ("0x40000000") + // freqOffsetHz: slice_frequency_hz − panadapter_center_hz + // Applied as a complex frequency shift so the IQ is + // centered at the satellite frequency before FM discrimination. + void start(DaxIqModel* daxIq, const QString& deviceId, + const QString& panId = QString(), float freqOffsetHz = 0.0f); + void stop(); + + bool isActive() const { return m_active; } + + // Volume 0–100 (maps linearly to 0.0–1.0 amplitude scale). + void setVolume(int pct) { m_volume = std::clamp(pct / 100.0f, 0.0f, 1.0f); } + +signals: + void commandReady(const QString& cmd); + +public slots: + // Called from DaxIqModel VITA-49 path + void onIqSamples(int channel, QVector iqInterleaved, int sampleRate); + void onStreamChanged(int channel); + +private: + void processSamples(const QVector& iqInterleaved); + + DaxIqModel* m_daxIq{nullptr}; + WaveOutWriter* m_waveOut{nullptr}; + bool m_active{false}; + float m_volume{1.0f}; + + // Frequency-correction oscillator (centers IQ at slice frequency) + float m_corrCos{1.0f}; + float m_corrSin{0.0f}; + float m_corrCosStep{1.0f}; + float m_corrSinStep{0.0f}; + float m_prevI{0.0f}; + float m_prevQ{0.0f}; + + QString m_panId; + bool m_panSent{false}; +}; + +} // namespace AetherSDR diff --git a/src/core/WfmSettings.h b/src/core/WfmSettings.h new file mode 100644 index 00000000..55fc9132 --- /dev/null +++ b/src/core/WfmSettings.h @@ -0,0 +1,81 @@ +#pragma once +#include "core/AppSettings.h" +#include +#include +#include +#include +#include + +namespace AetherSDR { + +// WFM demodulator settings live in one nested AppSettings JSON blob per +// constitution Principle V. The temporary flat key "WfmAudioDevice" from +// the initial PR is migrated on first access so existing testers keep their +// selected device. +class WfmSettings +{ +public: + static QString audioDeviceId() + { + return readObj().value(QStringLiteral("AudioDeviceId")).toString(); + } + + static void setAudioDeviceId(const QString& id) + { + QJsonObject obj = readObj(); + obj[QStringLiteral("AudioDeviceId")] = id; + write(obj); + } + + static void clearAudioDeviceId() + { + QJsonObject obj = readObj(); + obj.remove(QStringLiteral("AudioDeviceId")); + write(obj); + } + + static void migrateLegacy() + { + auto& settings = AppSettings::instance(); + constexpr const char* kLegacyKey = "WfmAudioDevice"; + if (!settings.contains(kLegacyKey)) + return; + if (!settings.contains(kRootKey)) { + const QString legacyId = settings.value(kLegacyKey).toString().trimmed(); + if (!legacyId.isEmpty()) { + QJsonObject obj; + obj[QStringLiteral("AudioDeviceId")] = legacyId; + settings.setValue(kRootKey, + QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact))); + } + } + settings.remove(kLegacyKey); + settings.save(); + } + +private: + static constexpr const char* kRootKey = "WFM"; + + static QJsonObject readObj() + { + migrateLegacy(); + const QString json = + AppSettings::instance().value(kRootKey, QString{}).toString(); + if (json.isEmpty()) + return {}; + QJsonParseError error{}; + const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8(), &error); + if (error.error != QJsonParseError::NoError || !doc.isObject()) + return {}; + return doc.object(); + } + + static void write(const QJsonObject& obj) + { + AppSettings::instance().setValue(kRootKey, + QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact))); + AppSettings::instance().save(); + } +}; + +} // namespace AetherSDR diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 8b2264f7..8df15309 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -201,6 +201,10 @@ #include "core/RADEEngine.h" #include "RadeApplet.h" #endif +#include "core/WfmDemodulator.h" +#include "core/PanadapterStream.h" +#include "gui/WfmDeviceDialog.h" +#include "core/WfmSettings.h" #if defined(Q_OS_MAC) #include "core/VirtualAudioBridge.h" #include @@ -3404,6 +3408,12 @@ MainWindow::MainWindow(QWidget* parent) }); #endif + connect(m_appletPanel->rxApplet(), &RxApplet::wfmActivated, + this, [this](bool on, int sliceId) { + if (on) activateWFM(sliceId); + else deactivateWFM(); + }); + // ── Tuning step size ─────────────────────────────────────────────────── // Two connections, split by source. stepSizeChanged fires for ANY step // change, including radio-driven syncs (syncStepFromSlice) after a memory @@ -14740,6 +14750,11 @@ void MainWindow::wireVfoWidget(VfoWidget* w, SliceModel* s) }); #endif + connect(w, &VfoWidget::wfmActivated, this, [this](bool on, int sliceId) { + if (on) activateWFM(sliceId); + else deactivateWFM(); + }); + // AetherDSP button on the per-slice DSP tab — same entry point as the // Settings menu action and the RX chain double-click; reuses the // existing modeless m_dspDialog when one is already open. @@ -19059,4 +19074,95 @@ void MainWindow::onSpectrumReadyForSHistory(quint32 streamId, const QVector QString { + while (true) { + QString deviceId = WfmSettings::audioDeviceId().trimmed(); + if (deviceId.isEmpty()) { + WfmDeviceDialog dlg(this); + if (dlg.exec() != QDialog::Accepted || dlg.selectedDeviceId().isEmpty()) + return {}; // user cancelled + deviceId = dlg.selectedDeviceId(); + if (dlg.rememberChoice()) + WfmSettings::setAudioDeviceId(deviceId); + } + return deviceId; + } + }; + + const QString audioDeviceId = resolveAudioDevice(); + if (audioDeviceId.isEmpty()) { + m_wfmSliceId = -1; + return; + } + + m_wfmPrevFilterLo = s->filterLow(); + m_wfmPrevFilterHi = s->filterHigh(); + s->setFilterWidth(-WfmDemodulator::FILTER_HZ, WfmDemodulator::FILTER_HZ); + m_wfmSliceId = sliceId; + + auto centerPanAtSlice = [this, s]() { + const QString panId = s->panId(); + if (panId.isEmpty()) return; + const double freq = s->frequency(); + auto* pan = m_radioModel.panadapter(panId); + if (pan && qFuzzyCompare(pan->centerMhz(), freq)) return; + const QString freqStr = QString::number(freq, 'f', 6); + if (pan) pan->applyPanStatus({{"center", freqStr}}); + m_radioModel.sendCommand( + QString("display pan set %1 center=%2").arg(panId, freqStr)); + }; + centerPanAtSlice(); + m_wfmFreqConn = connect(s, &SliceModel::frequencyChanged, + this, [centerPanAtSlice](double) { centerPanAtSlice(); }); + + m_wfmDemod = new WfmDemodulator(this); + connect(m_wfmDemod, &WfmDemodulator::commandReady, + &m_radioModel, &RadioModel::sendCommand); + m_wfmDemod->setVolume(static_cast(s->audioGain())); + connect(s, &SliceModel::audioGainChanged, + m_wfmDemod, [demod = m_wfmDemod](float g) { demod->setVolume(static_cast(g)); }); + m_wfmDemod->start(&m_radioModel.daxIqModel(), audioDeviceId, s->panId(), 0.0f); + if (!m_wfmDemod->isActive()) { + WfmSettings::clearAudioDeviceId(); + delete m_wfmDemod; + m_wfmDemod = nullptr; + m_wfmSliceId = -1; + } +} + +void MainWindow::deactivateWFM() +{ + if (m_wfmSliceId < 0) return; + if (m_wfmCooldown) return; + + disconnect(m_wfmFreqConn); + + if (m_wfmDemod) { + delete m_wfmDemod; + m_wfmDemod = nullptr; + } + + if (auto* s = m_radioModel.slice(m_wfmSliceId)) { + if (m_wfmPrevFilterLo != 0 || m_wfmPrevFilterHi != 0) + s->setFilterWidth(m_wfmPrevFilterLo, m_wfmPrevFilterHi); + } + + m_wfmSliceId = -1; + m_wfmPrevFilterLo = 0; + m_wfmPrevFilterHi = 0; +} + } // namespace AetherSDR diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 6f24446c..a9f585bf 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -924,6 +924,15 @@ private slots: void onFdvMetersChanged(); #endif + WfmDemodulator* m_wfmDemod{nullptr}; + int m_wfmSliceId{-1}; + bool m_wfmCooldown{false}; + int m_wfmPrevFilterLo{0}; + int m_wfmPrevFilterHi{0}; + QMetaObject::Connection m_wfmFreqConn; + void activateWFM(int sliceId); + void deactivateWFM(); + #if defined(Q_OS_MAC) || defined(HAVE_PIPEWIRE) DaxBridge* m_daxBridge{nullptr}; QString m_savedMicSelection; // restore on stopDax diff --git a/src/gui/RxApplet.cpp b/src/gui/RxApplet.cpp index 5b574d9e..d8637301 100644 --- a/src/gui/RxApplet.cpp +++ b/src/gui/RxApplet.cpp @@ -496,6 +496,11 @@ void RxApplet::buildUI() this, [this](int) { if (m_modeCombo->signalsBlocked()) return; QString mode = m_modeCombo->currentText(); + if (mode == "WFM") { + emit wfmActivated(true, m_slice ? m_slice->sliceId() : -1); + return; + } + emit wfmActivated(false, m_slice ? m_slice->sliceId() : -1); #ifdef HAVE_RADE if (mode == "RADE") { emit radeActivated(true, m_slice ? m_slice->sliceId() : -1); @@ -512,6 +517,24 @@ void RxApplet::buildUI() }); m_freqRow->addWidget(m_modeCombo); + m_wfmButton = new QPushButton("WFM"); + m_wfmButton->setCheckable(true); + m_wfmButton->setFixedSize(36, 20); + m_wfmButton->setToolTip("Software FM demodulator via DAX IQ → Hi-Fi Cable"); + m_wfmButton->setStyleSheet( + "QPushButton { background: #444; color: #ccc; border: 1px solid #666;" + " border-radius: 3px; font-size: 10px; font-weight: bold; padding: 0 2px; }" + "QPushButton:checked { background: #2a7; color: #fff; border-color: #2a7; }" + "QPushButton:hover { background: #555; }"); + connect(m_wfmButton, &QPushButton::toggled, this, [this](bool on) { + if (on) { + emit wfmActivated(true, m_slice ? m_slice->sliceId() : -1); + } else { + emit wfmActivated(false, m_slice ? m_slice->sliceId() : -1); + } + }); + m_freqRow->addWidget(m_wfmButton); + m_freqStack = new QStackedWidget; m_freqStack->setFixedHeight(34); diff --git a/src/gui/RxApplet.h b/src/gui/RxApplet.h index d4d65f31..8eecc5db 100644 --- a/src/gui/RxApplet.h +++ b/src/gui/RxApplet.h @@ -133,6 +133,7 @@ class RxApplet : public QWidget { // Emitted when user selects/deselects RADE digital voice mode void radeActivated(bool on, int sliceId); #endif + void wfmActivated(bool on, int sliceId); public: void setInitialStepSize(int hz); @@ -209,6 +210,7 @@ class RxApplet : public QWidget { QHBoxLayout* m_freqRow{nullptr}; // frequency display row QPushButton* m_txBadge{nullptr}; // TX slice indicator (click to set as TX slice) QComboBox* m_modeCombo{nullptr}; // mode selector (USB, LSB, CW, etc.) + QPushButton* m_wfmButton{nullptr}; // WFM software demodulator toggle QLabel* m_freqLabel{nullptr}; // frequency readout e.g. "14.289.510" QLineEdit* m_freqEdit{nullptr}; QStackedWidget* m_freqStack{nullptr}; diff --git a/src/gui/VfoWidget.cpp b/src/gui/VfoWidget.cpp index ae06f193..9cd80d96 100644 --- a/src/gui/VfoWidget.cpp +++ b/src/gui/VfoWidget.cpp @@ -11,7 +11,6 @@ #include "models/TransmitModel.h" #include "Theme.h" #include "core/AppSettings.h" -#include "InteractionSettings.h" #include #include @@ -364,7 +363,6 @@ void VfoWidget::wheelEvent(QWheelEvent* ev) } if (steps != 0) { - if (reverseMouseWheel()) steps = -steps; // #3302 double newMhz = m_slice->frequency() + steps * stepHz / 1e6; emit stepTuneRequested(newMhz); } @@ -997,13 +995,6 @@ void VfoWidget::buildTabContent() m_escPhaseLbl->setFixedWidth(28); m_escPhaseLbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter); escTopRow->addWidget(m_escPhaseLbl); - m_escPlus180Btn = new QPushButton("+180"); - m_escPlus180Btn->setAccessibleName("Add 180 degrees to ESC phase"); - m_escPlus180Btn->setToolTip("Shift ESC phase by 180\u00B0 to check the out-of-phase null. Click again to return."); - m_escPlus180Btn->setFixedHeight(20); - m_escPlus180Btn->setFixedWidth(40); - m_escPlus180Btn->setStyleSheet(kDspToggle); - escTopRow->addWidget(m_escPlus180Btn); escVbox->addLayout(escTopRow); // Gain vertical slider + polar plot row @@ -1128,9 +1119,7 @@ void VfoWidget::buildTabContent() m_slice->setDiversity(on); // ESC panel only on diversity parent, not child m_escPanel->setVisible(on && m_slice && !m_slice->isDiversityChild()); - // setVisible() only posts a LayoutRequest; adjustSize() activates the - // layout first so the panel collapses immediately (#3383) - adjustSize(); + resize(sizeHint()); }); connect(m_escBtn, &QPushButton::toggled, this, [this](bool on) { if (!m_updatingFromModel && m_slice) @@ -1144,14 +1133,6 @@ void VfoWidget::buildTabContent() if (!m_updatingFromModel && m_slice) m_slice->setEscPhaseShift(rad); }); - // +180 momentary: integer-domain mod keeps two-press round-trip exact. - connect(m_escPlus180Btn, &QPushButton::clicked, this, [this]() { - if (m_updatingFromModel || !m_slice) return; - constexpr int kStepsPer180 = 36; // 180° / 5° - constexpr int kStepsPerFull = 72; // 360° / 5° - const int v = (m_escPhaseSlider->value() + kStepsPer180) % kStepsPerFull; - m_escPhaseSlider->setValue(v); - }); connect(m_escGainSlider, &QSlider::valueChanged, this, [this](int v) { float gain = v / 100.0f; m_escGainLbl->setText(QString::number(gain, 'f', 2)); @@ -1848,6 +1829,19 @@ void VfoWidget::buildTabContent() modeRow->addWidget(btn, 1); } + + // WFM software demodulator toggle (DAX IQ → Hi-Fi Cable) + m_wfmBtn = new QPushButton("WFM"); + m_wfmBtn->setCheckable(true); + m_wfmBtn->setFixedHeight(26); + m_wfmBtn->setVisible(false); + m_wfmBtn->setToolTip("Software FM demodulator: DAX IQ → Hi-Fi Cable Input"); + m_wfmBtn->setStyleSheet(kModeBtn); + connect(m_wfmBtn, &QPushButton::toggled, this, [this](bool on) { + emit wfmActivated(on, m_slice ? m_slice->sliceId() : -1); + }); + modeRow->addWidget(m_wfmBtn, 1); + vb->addLayout(modeRow); // Filter preset grid (4 columns, rebuilt on mode change) @@ -2224,7 +2218,7 @@ void VfoWidget::setDiversityAllowed(bool allowed) // ESC panel only visible when DIV is active on a dual-SCU radio if (m_escPanel && !allowed) { m_escPanel->setVisible(false); - adjustSize(); // flush pending layout before sizing (#3383) + resize(sizeHint()); } } @@ -2745,6 +2739,12 @@ void VfoWidget::setSlice(SliceModel* slice) bool isFdv = mode.startsWith("FDV"); // FDVU, FDVM, etc. // Swap DSP tab label to OPT for FM modes m_tabBtns[1]->setText(isFm ? "OPT" : "DSP"); + if (!isFm && m_wfmBtn->isChecked()) { + QSignalBlocker sb(m_wfmBtn); + m_wfmBtn->setChecked(false); + emit wfmActivated(false, m_slice ? m_slice->sliceId() : -1); + } + m_wfmBtn->setVisible(isFm); m_rttyContainer->setVisible(isRtty); m_apfContainer->setVisible(isCw); m_digContainer->setVisible(isDig && !isFdv && mode != "NT"); @@ -2840,7 +2840,7 @@ void VfoWidget::setSlice(SliceModel* slice) QSignalBlocker sb(m_divBtn); m_divBtn->setChecked(on); m_escPanel->setVisible(on && !m_slice->isDiversityChild()); - adjustSize(); // flush pending layout before sizing (#3383) + resize(sizeHint()); }); // ESC sync — phase is in radians, display as degrees { @@ -3285,6 +3285,12 @@ void VfoWidget::syncFromSlice() bool isDig = (m_slice->mode() == "DIGL" || m_slice->mode() == "DIGU" || m_slice->mode() == "NT"); bool isFm = (m_slice->mode() == "FM" || m_slice->mode() == "NFM"); m_tabBtns[1]->setText(isFm ? "OPT" : "DSP"); + if (!isFm && m_wfmBtn->isChecked()) { + QSignalBlocker sb(m_wfmBtn); + m_wfmBtn->setChecked(false); + emit wfmActivated(false, m_slice->sliceId()); + } + m_wfmBtn->setVisible(isFm); m_apfBtn->setVisible(isCw); m_anfBtn->setVisible(!isRtty && !isCw && !isDig && !isFm); m_anflBtn->setVisible(!isRtty && !isCw && !isDig && !isFm); diff --git a/src/gui/VfoWidget.h b/src/gui/VfoWidget.h index 6a05d12b..bf6ea667 100644 --- a/src/gui/VfoWidget.h +++ b/src/gui/VfoWidget.h @@ -113,6 +113,7 @@ class VfoWidget : public QWidget { #ifdef HAVE_RADE void radeActivated(bool on, int sliceId); #endif + void wfmActivated(bool on, int sliceId); void recordToggled(bool on); void playToggled(bool on); void aetherDspRequested(); // user clicked the ADSP button on the DSP tab @@ -232,7 +233,6 @@ class VfoWidget : public QWidget { QPushButton* m_escBtn{nullptr}; PhaseKnob* m_phaseKnob{nullptr}; QSlider* m_escPhaseSlider{nullptr}; - QPushButton* m_escPlus180Btn{nullptr}; QSlider* m_escGainSlider{nullptr}; QLabel* m_escPhaseLbl{nullptr}; QLabel* m_escGainLbl{nullptr}; @@ -337,6 +337,7 @@ class VfoWidget : public QWidget { QComboBox* m_modeCombo{nullptr}; QPushButton* m_quickModeBtns[3]{}; QString m_quickModeAssign[3]; // e.g. "USB", "CW", "SSB", "DIG" + QPushButton* m_wfmBtn{nullptr}; void updateQuickModeButtons(); QGridLayout* m_filterGrid{nullptr}; QVector m_filterBtns; diff --git a/src/gui/WfmDeviceDialog.cpp b/src/gui/WfmDeviceDialog.cpp new file mode 100644 index 00000000..3ead9f47 --- /dev/null +++ b/src/gui/WfmDeviceDialog.cpp @@ -0,0 +1,85 @@ +#include "gui/WfmDeviceDialog.h" +#include +#include +#include +#include +#include +#include +#include + +namespace AetherSDR { + +WfmDeviceDialog::WfmDeviceDialog(QWidget* parent) + : QDialog(parent) +{ + setWindowTitle(tr("Select WFM Audio Output Device")); + setMinimumWidth(420); + + auto* layout = new QVBoxLayout(this); + + auto* label = new QLabel( + tr("Choose the audio output device for the WFM demodulator.\n" + "Select a Virtual Audio Cable (e.g. Hi-Fi Cable Input, BlackHole,\n" + "PipeWire null-sink) to feed another application."), this); + label->setWordWrap(true); + layout->addWidget(label); + + m_list = new QListWidget(this); + m_list->setAlternatingRowColors(true); + layout->addWidget(m_list); + + m_rememberCheck = new QCheckBox(tr("Remember this choice"), this); + m_rememberCheck->setChecked(true); + layout->addWidget(m_rememberCheck); + + m_buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + m_buttons->button(QDialogButtonBox::Ok)->setEnabled(false); + layout->addWidget(m_buttons); + + connect(m_list, &QListWidget::itemSelectionChanged, this, [this]() { + m_buttons->button(QDialogButtonBox::Ok) + ->setEnabled(!m_list->selectedItems().isEmpty()); + }); + connect(m_list, &QListWidget::itemDoubleClicked, + this, &QDialog::accept); + connect(m_buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(m_buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + populate(); +} + +void WfmDeviceDialog::populate() +{ + m_list->clear(); + m_devices = QMediaDevices::audioOutputs(); + for (const QAudioDevice& dev : m_devices) { + auto* item = new QListWidgetItem(dev.description(), m_list); + item->setData(Qt::UserRole, QString::fromUtf8(dev.id())); + } + if (m_list->count() > 0) + m_list->setCurrentRow(0); +} + +QString WfmDeviceDialog::selectedDeviceId() const +{ + const auto items = m_list->selectedItems(); + if (items.isEmpty()) + return {}; + return items.first()->data(Qt::UserRole).toString(); +} + +QString WfmDeviceDialog::selectedDeviceName() const +{ + const auto items = m_list->selectedItems(); + if (items.isEmpty()) + return {}; + return items.first()->text(); +} + +bool WfmDeviceDialog::rememberChoice() const +{ + return m_rememberCheck->isChecked(); +} + +} // namespace AetherSDR diff --git a/src/gui/WfmDeviceDialog.h b/src/gui/WfmDeviceDialog.h new file mode 100644 index 00000000..65fc27b3 --- /dev/null +++ b/src/gui/WfmDeviceDialog.h @@ -0,0 +1,42 @@ +#pragma once +#include +#include + +class QCheckBox; +class QDialogButtonBox; +class QListWidget; + +namespace AetherSDR { + +// Audio output device picker for the WFM demodulator. +// Lists all available output devices via QMediaDevices::audioOutputs() — +// cross-platform: works on Windows, macOS, and Linux. +// The selected device is identified by QAudioDevice::id() (a persistent +// opaque byte string) rather than a human-readable name, so it survives +// device renames and reordering. +class WfmDeviceDialog : public QDialog +{ + Q_OBJECT +public: + explicit WfmDeviceDialog(QWidget* parent = nullptr); + + // Returns the QAudioDevice::id() of the selected device, or empty if + // none was selected / the dialog was cancelled. + QString selectedDeviceId() const; + + // Returns the human-readable description of the selected device. + QString selectedDeviceName() const; + + bool rememberChoice() const; + +private: + void populate(); + + QListWidget* m_list{nullptr}; + QCheckBox* m_rememberCheck{nullptr}; + QDialogButtonBox* m_buttons{nullptr}; + + QList m_devices; +}; + +} // namespace AetherSDR diff --git a/src/models/DaxIqModel.cpp b/src/models/DaxIqModel.cpp index 1057ac44..a82c3672 100644 --- a/src/models/DaxIqModel.cpp +++ b/src/models/DaxIqModel.cpp @@ -24,7 +24,8 @@ DaxIqModel::DaxIqModel(QObject* parent) m_worker = new DaxIqWorker; m_worker->moveToThread(&m_workerThread); connect(&m_workerThread, &QThread::finished, m_worker, &QObject::deleteLater); - connect(m_worker, &DaxIqWorker::levelReady, this, &DaxIqModel::iqLevelReady); + connect(m_worker, &DaxIqWorker::levelReady, this, &DaxIqModel::iqLevelReady); + connect(m_worker, &DaxIqWorker::samplesReady, this, &DaxIqModel::iqSamplesReady); m_workerThread.start(); } @@ -132,6 +133,10 @@ void DaxIqModel::handleStreamRemoved(quint32 streamId) void DaxIqModel::feedRawIqPacket(int channel, const QByteArray& rawPayload, int sampleRate) { + static int s_feedCount = 0; + if (++s_feedCount <= 3) + qDebug() << "DaxIqModel::feedRawIqPacket ch=" << channel + << "size=" << rawPayload.size() << "rate=" << sampleRate; QMetaObject::invokeMethod(m_worker, [this, channel, rawPayload, sampleRate] { m_worker->processIqPacket(channel, rawPayload, sampleRate); @@ -250,6 +255,15 @@ void DaxIqWorker::processIqPacket(int channel, const QByteArray& rawPayload, int m_sumSq[idx] = 0.0; } + // Emit byte-swapped samples for software demodulators (all platforms) + QVector samples(numFloats); + std::memcpy(samples.data(), swapped.constData(), swapped.size()); + static int s_emitCount = 0; + if (++s_emitCount <= 3) + qDebug() << "DaxIqWorker: emitting samplesReady ch=" << channel + << "samples=" << numSamples; + emit samplesReady(channel, std::move(samples), sampleRate); + // Write to pipe (non-blocking, Linux/macOS only) #ifndef Q_OS_WIN if (m_pipeFds[idx] >= 0) { diff --git a/src/models/DaxIqModel.h b/src/models/DaxIqModel.h index e25766be..75fb4a3e 100644 --- a/src/models/DaxIqModel.h +++ b/src/models/DaxIqModel.h @@ -5,6 +5,7 @@ #include #include #include +#include namespace AetherSDR { @@ -59,6 +60,7 @@ class DaxIqModel : public QObject { void streamChanged(int channel); void commandReady(const QString& cmd); void iqLevelReady(int channel, float rms); + void iqSamplesReady(int channel, QVector iqInterleaved, int sampleRate); private: IqStream m_streams[NUM_CHANNELS]; // index 0-3 for channels 1-4 @@ -89,6 +91,7 @@ public slots: signals: void levelReady(int channel, float rms); + void samplesReady(int channel, QVector iqInterleaved, int sampleRate); private: int m_pipeFds[DaxIqModel::NUM_CHANNELS]{-1, -1, -1, -1};