Airband VOR receiver and trasmitter (#3244)

* First test

* Reorder includes for consistency and fix string dereference in VorCdiIndicator

* Add memory section and linker script entry for vor_rx application

* Testing VOR TX

* Moving VOR TX to SD

* Code refactoring to align with guidelines

* Add TODO comment to verify radial sign convention in vor_tx_config

* Remove unnecessary set_dirty() calls in VorRxView methods

* Update Course Deviation Indicator label and adjust UI element positions

* Update VorTxView to run prepared image from memory map

* Update build condition in nightly release workflow to include workflow_dispatch event

* Reverting action changes

* Adjust Course Deviation Indicator layout and refine painting logic

* Refactor VorRxView UI elements for improved layout and clarity

* Add calibration field and update radial calculation logic in VorRxView

* Enhance VOR processing: add renormalization to phase oscillator, improve signal handling, and update configuration parameters

* Implement radial smoothing and TO/FROM state handling in VorRxView; update VOR processing logic

* Setting comments inline

* Refactor VorCdiIndicator and VorRxView: change normalize_signed_degrees to use int32_t, update radial smoothing to use fixed point arithmetic to reduce flash usage

* Update external app address end to 0xAE0B0000

* Update set_vor_tx_config to include enabled parameter for consistency

* Refactor VorRx to omit decim_2 stage and update comments for clarity on VOR processing

* Add RSSI, Channel, and Audio fields to VorRxView to mimic existing apps

* Refactor VorRxView labels and status messages

* Update VorRxView calibration field range to allow full circle input

* Completing Ident transmission logic

* Fixing TX gain and moving log label

* Refactor VOR phase calculations and update radial offset handling for accurate bearing representation

* Add low-pass filters to strip 9960 Hz subcarrier from audio output
This commit is contained in:
Luca
2026-07-04 08:49:21 +02:00
committed by GitHub
parent 7481aefe9f
commit b5d84fcc4d
20 changed files with 1765 additions and 1 deletions
+14
View File
@@ -516,6 +516,20 @@ DeclareTargets(PFUT flash_utility)
set(add_to_firmware FALSE)
set(MODE_FLAGS "-O3")
### VOR TX
set(MODE_CPPSRC
proc_vor_tx.cpp
)
DeclareTargets(PVTX vor_tx)
### VOR RX
set(MODE_CPPSRC
proc_vor_rx.cpp
)
DeclareTargets(PVRX vor_rx)
### FLEX RX
set(MODE_CPPSRC
+272
View File
@@ -0,0 +1,272 @@
/*
* Copyright (C) 2014 Jared Boone, ShareBrained Technology, Inc.
* Copyright (C) 2026 PortaPack Mayhem
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#include "proc_vor_rx.hpp"
#include "audio_output.hpp"
#include "audio_dma.hpp"
#include "dsp_iir_config.hpp"
#include "portapack_shared_memory.hpp"
#include "event_m4.hpp"
#include <algorithm>
namespace {
constexpr float kPi = 3.14159265358979323846f;
}
VorRx::VorRx() {
channel_spectrum.set_decimation_factor(1);
baseband_thread.start();
rssi_thread.start();
}
void VorRx::execute(const buffer_c8_t& buffer) {
if (!configured) {
return;
}
const auto decim_0_out = decim_0.execute(buffer, dst_buffer);
const auto decim_1_out = decim_1.execute(decim_0_out, dst_buffer);
channel_spectrum.feed(decim_1_out, channel_filter_low_f, channel_filter_high_f, channel_filter_transition);
// No decim_2 stage: the standard AM decim_2 filter is a ~4.5 kHz low-pass
// that would attenuate the 9960 Hz VOR subcarrier by ~60 dB and destroy the
// reference phase. The channel stays at 48 kHz so the subcarrier survives.
const auto channel_out = channel_filter.execute(decim_1_out, dst_buffer);
feed_channel_stats(channel_out);
auto audio = demod_am.execute(channel_out, audio_buffer);
if (vor_enabled) {
process_vor_metrics(audio);
// Strip the 9960 Hz subcarrier from the audible signal (the metrics
// above still ran on the unfiltered audio so the decode is unaffected).
audio_lpf_1.execute_in_place(audio);
audio_lpf_2.execute_in_place(audio);
}
audio_compressor.execute_in_place(audio);
audio_output.write(audio);
}
void VorRx::on_message(const Message* const message) {
switch (message->id) {
case Message::ID::UpdateSpectrum:
case Message::ID::SpectrumStreamingConfig:
channel_spectrum.on_message(message);
break;
case Message::ID::VorRxConfigure:
configure_vor(*reinterpret_cast<const VorRxConfigureMessage*>(message));
break;
case Message::ID::AMConfigure:
configure(*reinterpret_cast<const AMConfigureMessage*>(message));
break;
default:
break;
}
}
void VorRx::configure(const AMConfigureMessage& message) {
decim_0.configure(message.decim_0_filter.taps);
decim_1.configure(message.decim_1_filter.taps);
channel_filter.configure(message.channel_filter.taps, channel_filter_decimation_factor);
constexpr size_t decim_0_output_fs = baseband_fs / decim_0.decimation_factor;
constexpr size_t decim_1_output_fs = decim_0_output_fs / decim_1.decimation_factor;
constexpr size_t channel_filter_input_fs = decim_1_output_fs;
channel_filter_low_f = message.channel_filter.low_frequency_normalized * channel_filter_input_fs;
channel_filter_high_f = message.channel_filter.high_frequency_normalized * channel_filter_input_fs;
channel_filter_transition = message.channel_filter.transition_normalized * channel_filter_input_fs;
channel_spectrum.set_decimation_factor(message.channel_spectrum_decimation_factor);
audio_output.configure(message.audio_hpf_lpf_config);
configured = true;
}
void VorRx::PhaseOscillator::configure(float frequency_hz, float sample_rate_hz) {
const float step = 2.0f * kPi * frequency_hz / sample_rate_hz;
step_sin = sinf(step);
step_cos = cosf(step);
sin_v = 0.0f;
cos_v = 1.0f;
}
void VorRx::PhaseOscillator::advance() {
const float next_cos = (cos_v * step_cos) - (sin_v * step_sin);
const float next_sin = (sin_v * step_cos) + (cos_v * step_sin);
cos_v = next_cos;
sin_v = next_sin;
}
void VorRx::PhaseOscillator::renormalize() {
// The recursive rotator slowly drifts in amplitude over long runs;
// rescale to the unit circle so accumulated tone levels stay meaningful.
const float mag = sqrtf((cos_v * cos_v) + (sin_v * sin_v));
if (mag > 0.0f) {
const float inv = 1.0f / mag;
cos_v *= inv;
sin_v *= inv;
}
}
void VorRx::configure_vor(const VorRxConfigureMessage& message) {
vor_enabled = message.enabled;
vor_reference_osc.configure(vor_reference_hz, 48000.0f);
vor_subcarrier_osc.configure(vor_subcarrier_hz, 48000.0f);
audio_lpf_1.configure(audio_48k_lpf_3200hz_config);
audio_lpf_2.configure(audio_48k_lpf_3200hz_config);
vor_ref_i = 0.0f;
vor_ref_q = 0.0f;
vor_var_i = 0.0f;
vor_var_q = 0.0f;
vor_dc_sum = 0.0f;
vor_sub_lp_i = 0.0f;
vor_sub_lp_q = 0.0f;
vor_sub_prev_i = 0.0f;
vor_sub_prev_q = 0.0f;
vor_sample_count = 0;
}
uint16_t VorRx::normalize_degrees(float degrees) {
while (degrees < 0.0f) {
degrees += 360.0f;
}
while (degrees >= 360.0f) {
degrees -= 360.0f;
}
return static_cast<uint16_t>(degrees + 0.5f);
}
void VorRx::send_vor_status(uint16_t phase_deg, uint16_t radial_deg, uint16_t ref_level, uint16_t var_level, uint8_t quality, bool valid, bool to_from) {
const VorRxStatusDataMessage message{phase_deg, radial_deg, ref_level, var_level, quality, valid, to_from};
shared_memory.application_queue.push(message);
}
void VorRx::process_vor_metrics(const buffer_f32_t& audio) {
for (size_t i = 0; i < audio.count; ++i) {
const float sample = audio.p[i];
const float ref_cos = vor_reference_osc.cosine();
const float ref_sin = vor_reference_osc.sine();
const float sub_cos = vor_subcarrier_osc.cosine();
const float sub_sin = vor_subcarrier_osc.sine();
// Variable signal: 30 Hz amplitude modulation of the carrier envelope.
// Correlate the envelope against a local 30 Hz tone (DC rejected because
// the window spans an exact integer number of 30 Hz cycles).
vor_dc_sum += sample;
vor_var_i += sample * ref_cos;
vor_var_q -= sample * ref_sin;
// Reference signal: 30 Hz FM on the 9960 Hz subcarrier. Quadrature
// down-convert the subcarrier, low-pass to its +/-480 Hz baseband, then
// FM-demodulate (phase difference between successive samples).
const float sc_i = sample * sub_cos;
const float sc_q = -sample * sub_sin;
vor_sub_lp_i += (1.0f - vor_sub_lp_alpha) * (sc_i - vor_sub_lp_i);
vor_sub_lp_q += (1.0f - vor_sub_lp_alpha) * (sc_q - vor_sub_lp_q);
const float num = (vor_sub_lp_q * vor_sub_prev_i) - (vor_sub_lp_i * vor_sub_prev_q);
const float den = (vor_sub_lp_i * vor_sub_prev_i) + (vor_sub_lp_q * vor_sub_prev_q);
const float fm = atan2f(num, den);
vor_sub_prev_i = vor_sub_lp_i;
vor_sub_prev_q = vor_sub_lp_q;
vor_ref_i += fm * ref_cos;
vor_ref_q -= fm * ref_sin;
vor_reference_osc.advance();
vor_subcarrier_osc.advance();
++vor_sample_count;
if (vor_sample_count < vor_window_samples) {
continue;
}
const float inv_n = 1.0f / static_cast<float>(vor_window_samples);
const float variable_phase = atan2f(vor_var_q, vor_var_i);
const float reference_phase = atan2f(vor_ref_q, vor_ref_i);
// Real VOR radials increase clockwise, i.e. the variable tone lags the
// reference by the bearing. The decoder therefore reports
// (reference - variable); the opposite order mirrors every bearing to
// 360 - true. Add back the receive-chain phase lag (see
// vor_ref_phase_lag_deg): with this subtraction order the delayed
// reference makes the raw radial read low, so the correction is added.
float phase_diff = ((reference_phase - variable_phase) * 180.0f / kPi) + vor_ref_phase_lag_deg;
while (phase_diff < 0.0f) {
phase_diff += 360.0f;
}
while (phase_diff >= 360.0f) {
phase_diff -= 360.0f;
}
// Scale-invariant lock metrics (independent of RF/AGC level):
// - var_ratio: 30 Hz AM depth vs. carrier DC (~0.30 for a real VOR).
// - ref_amp: FM-demodulated 30 Hz amplitude (phase-only, so it does
// not depend on signal amplitude, ~0.065 when locked).
const float dc_level = vor_dc_sum * inv_n;
const float var_amp = 2.0f * sqrtf((vor_var_i * vor_var_i) + (vor_var_q * vor_var_q)) * inv_n;
const float ref_amp = 2.0f * sqrtf((vor_ref_i * vor_ref_i) + (vor_ref_q * vor_ref_q)) * inv_n;
const float var_ratio = (dc_level > 0.0f) ? (var_amp / dc_level) : 0.0f;
const auto phase_deg = normalize_degrees(phase_diff);
const auto radial_deg = phase_deg;
const auto ref_level = static_cast<uint16_t>(std::min(65535.0f, ref_amp * 10000.0f));
const auto var_level = static_cast<uint16_t>(std::min(65535.0f, var_ratio * 1000.0f));
const bool valid = (ref_amp > 0.03f) && (var_ratio > 0.15f);
// TO/FROM depends on the operator-selected OBS course, which only the
// application knows, so it is derived there (see VorRxView). Emit a
// stable placeholder here rather than a radial-only guess.
const bool to_from = false;
uint8_t quality = 0;
if (valid) {
const float q = (ref_amp / 0.065f) * 100.0f;
quality = static_cast<uint8_t>(std::min(100.0f, std::max(0.0f, q)));
}
send_vor_status(phase_deg, radial_deg, ref_level, var_level, quality, valid, to_from);
vor_reference_osc.renormalize();
vor_subcarrier_osc.renormalize();
vor_sample_count = 0;
vor_ref_i = 0.0f;
vor_ref_q = 0.0f;
vor_var_i = 0.0f;
vor_var_q = 0.0f;
vor_dc_sum = 0.0f;
}
}
int main() {
audio::dma::init_audio_out();
EventDispatcher event_dispatcher{std::make_unique<VorRx>()};
event_dispatcher.run();
return 0;
}
+150
View File
@@ -0,0 +1,150 @@
/*
* Copyright (C) 2014 Jared Boone, ShareBrained Technology, Inc.
* Copyright (C) 2026 PortaPack Mayhem
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#ifndef __PROC_VOR_RX_H__
#define __PROC_VOR_RX_H__
#include "baseband_processor.hpp"
#include "baseband_thread.hpp"
#include "rssi_thread.hpp"
#include "dsp_decimate.hpp"
#include "dsp_demodulate.hpp"
#include "dsp_iir.hpp"
#include "audio_compressor.hpp"
#include "audio_output.hpp"
#include "spectrum_collector.hpp"
#include <array>
#include <cstdint>
#include <cmath>
// Standalone VOR receiver baseband.
// Demodulates the AM channel at 48 kHz (so the 9960 Hz subcarrier stays
// representable) and decodes the 30 Hz reference vs. variable phase to derive
// the radial.
class VorRx : public BasebandProcessor {
public:
VorRx();
void execute(const buffer_c8_t& buffer) override;
void on_message(const Message* const message) override;
private:
static constexpr size_t baseband_fs = 3072000;
// VOR keeps the channel at 48 kHz instead of the AM audio 12 kHz, so the
// 9960 Hz subcarrier stays below Nyquist. The standard AM decim_2 stage is
// therefore omitted (its ~4.5 kHz low-pass would remove the subcarrier).
static constexpr size_t channel_filter_decimation_factor = 1;
std::array<complex16_t, 512> dst{};
const buffer_c16_t dst_buffer{
dst.data(),
dst.size()};
std::array<float, 32> audio{};
const buffer_f32_t audio_buffer{
audio.data(),
audio.size()};
dsp::decimate::FIRC8xR16x24FS4Decim8 decim_0{};
dsp::decimate::FIRC16xR16x32Decim8 decim_1{};
dsp::decimate::FIRAndDecimateComplex channel_filter{};
int32_t channel_filter_low_f = 0;
int32_t channel_filter_high_f = 0;
int32_t channel_filter_transition = 0;
bool configured{false};
bool vor_enabled{false};
static constexpr float vor_reference_hz{30.0f};
static constexpr float vor_subcarrier_hz{9960.0f};
// 100 ms at 48 kHz (exactly 3 cycles of 30 Hz)
static constexpr uint32_t vor_window_samples{4800};
// One-pole low-pass to isolate the +/-480 Hz FM subcarrier baseband
// after quadrature down-conversion (corner ~600 Hz at 48 kHz).
static constexpr float vor_sub_lp_alpha{0.9245f};
// Fixed phase lag the receive chain adds to the recovered 30 Hz reference
// tone, expressed in degrees at 30 Hz. It comes almost entirely from the
// one-pole subcarrier low-pass above (~2.75 deg of lag at 30 Hz for
// alpha = 0.9245) plus a ~0.1 deg half-sample delay in the FM
// differentiator. Because the reference tone is delayed, the raw radial
// (reference - variable) reads low by this amount, so we add it back.
// Value verified by a TX->RX loopback (proc_vor_tx fed through this
// decoder): every transmitted radial decoded ~3.2 deg low before this
// correction, and on-bearing afterwards.
static constexpr float vor_ref_phase_lag_deg{3.2f};
struct PhaseOscillator {
float sin_v{0.0f};
float cos_v{1.0f};
float step_sin{0.0f};
float step_cos{1.0f};
void configure(float frequency_hz, float sample_rate_hz);
float cosine() const { return cos_v; }
float sine() const { return sin_v; }
void advance();
void renormalize();
} vor_reference_osc{}, vor_subcarrier_osc{};
// 30 Hz DFT accumulators for the variable (AM) and reference (FM) tones.
float vor_ref_i{0.0f};
float vor_ref_q{0.0f};
float vor_var_i{0.0f};
float vor_var_q{0.0f};
float vor_dc_sum{0.0f};
// Subcarrier quadrature down-converter / FM-demodulator state.
float vor_sub_lp_i{0.0f};
float vor_sub_lp_q{0.0f};
float vor_sub_prev_i{0.0f};
float vor_sub_prev_q{0.0f};
uint32_t vor_sample_count{0};
dsp::demodulate::AM demod_am{};
// Two cascaded 2nd-order Butterworth low-passes (4th-order total) that strip
// the 9960 Hz FM subcarrier out of the audio before it reaches the speaker.
// The channel is kept wide (48 kHz) so the subcarrier survives for the
// radial decode, so it must be filtered out of the audible path here.
IIRBiquadFilter audio_lpf_1{};
IIRBiquadFilter audio_lpf_2{};
FeedForwardCompressor audio_compressor{};
AudioOutput audio_output{};
SpectrumCollector channel_spectrum{};
/* NB: Threads should be the last members in the class definition. */
#ifdef PRALINE
BasebandThread baseband_thread{baseband_fs, this, baseband::Direction::Receive,
/*auto_start*/ false};
RSSIThread rssi_thread{/*auto_start*/ false};
#else
BasebandThread baseband_thread{baseband_fs, this, baseband::Direction::Receive};
RSSIThread rssi_thread{};
#endif
void configure(const AMConfigureMessage& message);
void configure_vor(const VorRxConfigureMessage& message);
void process_vor_metrics(const buffer_f32_t& audio);
void send_vor_status(uint16_t phase_deg, uint16_t radial_deg, uint16_t ref_level, uint16_t var_level, uint8_t quality, bool valid, bool to_from);
static uint16_t normalize_degrees(float degrees);
};
#endif /*__PROC_VOR_RX_H__*/
+169
View File
@@ -0,0 +1,169 @@
/*
* Copyright (C) 2026 PortaPack Mayhem
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#include "proc_vor_tx.hpp"
#include "morse.hpp"
#include "sine_table_int8.hpp"
#include "event_m4.hpp"
void VorTxProcessor::execute(const buffer_c8_t& buffer) {
for (size_t i = 0; i < buffer.count; i++) {
if (!configured) {
buffer.p[i] = {0, 0};
continue;
}
// 30 Hz reference and variable tones (same frequency, offset by the radial).
const int32_t ref_sine = sine_table_i8[(phase_30 >> 24) & 0xFF];
const int32_t var_sine = sine_table_i8[((phase_30 - radial_offset) >> 24) & 0xFF];
phase_30 += delta_30;
// 9960 Hz subcarrier, FM-modulated +/- 480 Hz by the 30 Hz reference tone.
const int32_t sub_inc = static_cast<int32_t>(delta_sub) + ((ref_sine * static_cast<int32_t>(delta_480)) >> 7);
phase_sub += static_cast<uint32_t>(sub_inc);
const int32_t sub_sine = sine_table_i8[(phase_sub >> 24) & 0xFF];
// Optional 1020 Hz identification tone.
int32_t id_sine = 0;
if (ident_enabled && ident_tone_active()) {
id_sine = sine_table_i8[(phase_id >> 24) & 0xFF];
phase_id += delta_1020;
}
// DSB-AM envelope (carrier kept at LO -> Q = 0).
int32_t env = carrier_level + (((var_sine * var_depth) + (sub_sine * sub_depth) + (id_sine * id_depth)) >> 7);
if (env < 0) env = 0;
if (env > 127) env = 127;
buffer.p[i] = {static_cast<int8_t>(env), 0};
}
}
void VorTxProcessor::vor_tx_config(const VorTxConfigureMessage& message) {
// Real VOR radials increase clockwise: the variable tone lags the 30 Hz
// reference by the bearing, so the offset is subtracted (see the matching
// (reference - variable) decode in proc_vor_rx). A TX->RX loopback with this
// convention still decodes a transmitted radial of N degrees as N degrees,
// and it now matches real ground-station VOR bearings.
radial_offset = static_cast<uint32_t>(static_cast<uint64_t>(message.radial_deg % 360) * phase_period / 360);
ident_enabled = message.ident_enabled;
for (size_t i = 0; i < sizeof(ident_text); ++i)
ident_text[i] = message.ident_text[i];
ident_text[sizeof(ident_text) - 1] = '\0';
build_ident_schedule();
configured = message.enabled;
}
// Render the ident text into a repeating sequence of keyed-on / keyed-off
// sample runs using the shared ITU Morse table. Timing is derived from the
// standard "dot = 1200 / wpm" relation; letters are separated by a 3-unit gap,
// words by 7 units, and a trailing gap pads each cycle to a fixed 10 s period.
void VorTxProcessor::build_ident_schedule() {
ident_segment_count = 0;
ident_index = 0;
ident_sample_counter = 0;
const uint32_t dot_samples = static_cast<uint32_t>(1200ULL * baseband_fs / (ident_wpm * 1000ULL));
uint32_t total_samples = 0;
auto push_samples = [&](bool on, uint32_t samples) {
if (ident_segment_count >= max_ident_segments) return;
ident_segments[ident_segment_count++] = {on, samples};
total_samples += samples;
};
auto push = [&](bool on, uint32_t units) {
push_samples(on, units * dot_samples);
};
bool any = false;
for (size_t n = 0; n < sizeof(ident_text) && ident_text[n]; ++n) {
char ch = ident_text[n];
if (ch >= 'a' && ch <= 'z') ch -= 32;
uint16_t code = 0;
if (ch >= '!' && ch <= '_') code = morse::morse_ITU[ch - '!'];
if (!code) {
// Space or unsupported character: word gap between symbols.
if (any) push(false, MORSE_WORD_SPACE);
continue;
}
const uint16_t code_size = code & 7;
for (uint16_t c = 0; c < code_size; ++c) {
const bool dash = ((code << c) & 0x8000) != 0;
push(true, dash ? MORSE_DASH : MORSE_DOT);
if (c + 1 < code_size)
push(false, MORSE_SYMBOL_SPACE);
}
push(false, MORSE_LETTER_SPACE);
any = true;
}
if (!any) {
// No sendable characters: disable keying entirely.
ident_segment_count = 0;
return;
}
// Pad the trailing silence so ident-start to ident-start is a full 10 s.
// If the keyed sequence is somehow longer than the period, fall back to a
// one word-space gap so repeats never run back-to-back.
const uint32_t min_gap = MORSE_WORD_SPACE * dot_samples;
const uint32_t gap = (ident_period_samples > total_samples + min_gap)
? ident_period_samples - total_samples
: min_gap;
push_samples(false, gap);
}
// Advances the keying schedule by one sample and returns whether the ident
// tone should be sounding right now. Returns false when there is nothing to
// send so the caller leaves the ident tone off.
bool VorTxProcessor::ident_tone_active() {
if (ident_segment_count == 0) return false;
if (ident_sample_counter == 0)
ident_sample_counter = ident_segments[ident_index].length;
const bool on = ident_segments[ident_index].on;
if (ident_sample_counter == 0 || --ident_sample_counter == 0) {
++ident_index;
if (ident_index >= ident_segment_count) ident_index = 0;
}
return on;
}
void VorTxProcessor::on_message(const Message* const msg) {
if (msg->id == Message::ID::VorTxConfigure) {
vor_tx_config(*reinterpret_cast<const VorTxConfigureMessage*>(msg));
}
}
int main() {
EventDispatcher event_dispatcher{std::make_unique<VorTxProcessor>()};
event_dispatcher.run();
return 0;
}
+90
View File
@@ -0,0 +1,90 @@
/*
* Copyright (C) 2026 PortaPack Mayhem
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#ifndef __PROC_VOR_TX_H__
#define __PROC_VOR_TX_H__
#include "baseband_processor.hpp"
#include "baseband_thread.hpp"
// Generates a conventional VOR composite signal as DSB-AM (carrier at LO, Q = 0):
// - 30 Hz "variable" tone, amplitude-modulated directly (phase = transmitted radial)
// - 9960 Hz subcarrier, FM-modulated (+/- 480 Hz) by a 30 Hz "reference" tone
// - optional 1020 Hz identification tone
// The radial encoded is the phase difference between the variable and reference 30 Hz tones.
class VorTxProcessor : public BasebandProcessor {
public:
void execute(const buffer_c8_t& buffer) override;
void on_message(const Message* const msg) override;
private:
static constexpr size_t baseband_fs = 1536000;
static constexpr uint64_t phase_period = 1ULL << 32;
// Per-sample phase increments (top byte indexes the 256-entry sine table).
static constexpr uint32_t delta_30 = static_cast<uint32_t>(30ULL * phase_period / baseband_fs);
static constexpr uint32_t delta_sub = static_cast<uint32_t>(9960ULL * phase_period / baseband_fs);
static constexpr uint32_t delta_1020 = static_cast<uint32_t>(1020ULL * phase_period / baseband_fs);
static constexpr uint32_t delta_480 = static_cast<uint32_t>(480ULL * phase_period / baseband_fs);
// AM levels (int8 scale). carrier_level + sum of contributions must stay <= 127.
static constexpr int32_t carrier_level = 60;
static constexpr int32_t var_depth = 18; // ~30% of carrier
static constexpr int32_t sub_depth = 18; // ~30% of carrier
static constexpr int32_t id_depth = 6; // ~10% of carrier
// CW identifier keying. Real VOR stations key the 1020 Hz ident tone in
// Morse at ~7 words per minute, so the schedule below is expressed in
// "dot" time units and rendered to on/off sample runs at config time.
static constexpr uint32_t ident_wpm = 7;
static constexpr size_t max_ident_segments = 128;
// VOR ident is transmitted at least every 10 s; repeat the keyed identifier
// on a fixed 10 s cycle (ident start to next ident start).
static constexpr uint32_t ident_period_samples = 10 * baseband_fs;
struct IdentSegment {
bool on;
uint32_t length; // duration in samples
};
uint32_t phase_30{0};
uint32_t phase_sub{0};
uint32_t phase_id{0};
uint32_t radial_offset{0};
bool ident_enabled{true};
bool configured{false};
char ident_text[8]{};
IdentSegment ident_segments[max_ident_segments]{};
size_t ident_segment_count{0};
size_t ident_index{0};
uint32_t ident_sample_counter{0};
void vor_tx_config(const VorTxConfigureMessage& message);
void build_ident_schedule();
bool ident_tone_active();
/* NB: Threads should be the last members in the class definition. */
BasebandThread baseband_thread{baseband_fs, this, baseband::Direction::Transmit};
};
#endif /* __PROC_VOR_TX_H__ */