Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions src/ota_channel_core/models.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@
#include "pocketfft/pocketfft_hdronly.h"

#include <algorithm>
#include <cctype>
#include <cstring>
#include <cmath>
#include <fstream>
#include <limits>
#include <stdexcept>
#include <string>
#include <utility>

namespace ultra::ota_channel_core {
Expand All @@ -18,6 +23,9 @@ namespace {
using Complex = std::complex<float>;

constexpr float kPi = 3.14159265358979323846f;
constexpr uint16_t kWavFormatPcm = 1;
constexpr uint16_t kRealHfLoopChannels = 1;
constexpr uint16_t kRealHfLoopBitsPerSample = 16;

void analyticFrequencyShift(std::vector<float>& samples,
float cfo_hz,
Expand Down Expand Up @@ -74,6 +82,40 @@ void analyticFrequencyShift(std::vector<float>& samples,
phase_acc = phase;
}

uint16_t readLe16(const uint8_t* p) {
return static_cast<uint16_t>(p[0]) |
static_cast<uint16_t>(p[1] << 8);
}

uint32_t readLe32(const uint8_t* p) {
return static_cast<uint32_t>(p[0]) |
(static_cast<uint32_t>(p[1]) << 8) |
(static_cast<uint32_t>(p[2]) << 16) |
(static_cast<uint32_t>(p[3]) << 24);
}

bool readExact(std::istream& in, void* dst, size_t len) {
in.read(static_cast<char*>(dst), static_cast<std::streamsize>(len));
return static_cast<size_t>(in.gcount()) == len;
}

std::string normalizedChannelToken(std::string_view value) {
std::string out;
out.reserve(value.size());
for (const char c : value) {
out.push_back(c == '-'
? '_'
: static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
}
return out;
}

std::runtime_error realHfLoopWavError(std::string_view path,
const std::string& detail) {
return std::runtime_error("real_hf_loop noise-bed WAV " +
std::string(path) + ": " + detail);
}

} // namespace

float modemReferenceNoiseStddev(float snr_db) {
Expand All @@ -88,10 +130,123 @@ const char* channelTypeName(ChannelType type) {
case ChannelType::MODERATE: return "moderate";
case ChannelType::POOR: return "poor";
case ChannelType::FLUTTER: return "flutter";
case ChannelType::REAL_HF_LOOP:return "real_hf_loop";
default: return "unknown";
}
}

std::optional<ChannelType> parseChannelType(std::string_view value) {
const std::string v = normalizedChannelToken(value);
if (v.empty() || v == "passthrough" || v == "null") {
return ChannelType::PASSTHROUGH;
}
if (v == "awgn") {
return ChannelType::AWGN;
}
if (v == "good" || v == "watterson_good") {
return ChannelType::GOOD;
}
if (v == "moderate" || v == "watterson_moderate") {
return ChannelType::MODERATE;
}
if (v == "poor" || v == "watterson_poor") {
return ChannelType::POOR;
}
if (v == "flutter" || v == "watterson_flutter") {
return ChannelType::FLUTTER;
}
if (v == "real_hf_loop") {
return ChannelType::REAL_HF_LOOP;
}
return std::nullopt;
}

std::vector<float> loadRealHfLoopNoiseBedWav(std::string_view path_view) {
const std::string path(path_view);
std::ifstream in(path, std::ios::binary);
if (!in) {
throw realHfLoopWavError(path_view, "failed to open");
}

uint8_t riff_header[12] = {};
if (!readExact(in, riff_header, sizeof(riff_header)) ||
std::memcmp(riff_header, "RIFF", 4) != 0 ||
std::memcmp(riff_header + 8, "WAVE", 4) != 0) {
throw realHfLoopWavError(path_view, "not a RIFF/WAVE file");
}

std::vector<uint8_t> fmt_chunk;
std::vector<uint8_t> data_chunk;
while (in) {
uint8_t chunk_header[8] = {};
if (!readExact(in, chunk_header, sizeof(chunk_header))) {
break;
}
const uint32_t chunk_size = readLe32(chunk_header + 4);
std::vector<uint8_t> chunk(chunk_size);
if (chunk_size > 0 && !readExact(in, chunk.data(), chunk_size)) {
throw realHfLoopWavError(path_view, "truncated chunk");
}
if (chunk_size & 1u) {
in.seekg(1, std::ios::cur);
}

if (std::memcmp(chunk_header, "fmt ", 4) == 0) {
fmt_chunk = std::move(chunk);
} else if (std::memcmp(chunk_header, "data", 4) == 0) {
data_chunk = std::move(chunk);
}
}

if (fmt_chunk.size() < 16) {
throw realHfLoopWavError(path_view, "missing fmt chunk");
}
if (data_chunk.empty()) {
throw realHfLoopWavError(path_view, "missing data chunk");
}

const uint16_t format = readLe16(fmt_chunk.data());
const uint16_t channels = readLe16(fmt_chunk.data() + 2);
const uint32_t sample_rate = readLe32(fmt_chunk.data() + 4);
const uint16_t block_align = readLe16(fmt_chunk.data() + 12);
const uint16_t bits = readLe16(fmt_chunk.data() + 14);
if (format != kWavFormatPcm ||
channels != kRealHfLoopChannels ||
sample_rate != kDefaultSampleRate ||
bits != kRealHfLoopBitsPerSample ||
block_align != sizeof(int16_t)) {
throw realHfLoopWavError(
path_view,
"must be 16-bit PCM mono 48000 Hz; no resampling is applied");
}
if ((data_chunk.size() % sizeof(int16_t)) != 0) {
throw realHfLoopWavError(path_view, "data chunk is not aligned to 16-bit samples");
}

std::vector<float> samples;
samples.reserve(data_chunk.size() / sizeof(int16_t));
double sum_squares = 0.0;
for (size_t i = 0; i < data_chunk.size(); i += sizeof(int16_t)) {
const int16_t raw = static_cast<int16_t>(readLe16(data_chunk.data() + i));
const float sample = static_cast<float>(raw) / 32768.0f;
samples.push_back(sample);
sum_squares += static_cast<double>(sample) * static_cast<double>(sample);
}
if (samples.empty()) {
throw realHfLoopWavError(path_view, "contains no samples");
}

const double rms = std::sqrt(sum_squares / static_cast<double>(samples.size()));
if (!(rms > std::numeric_limits<double>::min())) {
throw realHfLoopWavError(path_view, "RMS is zero");
}
const float inv_rms = static_cast<float>(1.0 / rms);
for (float& sample : samples) {
sample *= inv_rms;
}
return samples;
}

void PassthroughChannelModel::process(std::span<const float> input,
std::vector<float>& output) {
output.assign(input.begin(), input.end());
Expand All @@ -114,6 +269,50 @@ void AWGNChannelModel::process(std::span<const float> input,
}
}

RealHfLoopChannelModel::RealHfLoopChannelModel(float snr_db,
std::vector<float> normalized_loop,
uint64_t seed_for_phase)
: RealHfLoopChannelModel(
snr_db,
std::make_shared<const std::vector<float>>(std::move(normalized_loop)),
seed_for_phase) {}

RealHfLoopChannelModel::RealHfLoopChannelModel(
float snr_db,
std::shared_ptr<const std::vector<float>> normalized_loop,
uint64_t seed_for_phase)
: loop_(std::move(normalized_loop)) {
if (loop_ && !loop_->empty() && seed_for_phase != 0) {
start_position_ = static_cast<size_t>(
seed_for_phase % static_cast<uint64_t>(loop_->size()));
position_ = start_position_;
}
setSNR(snr_db);
}

void RealHfLoopChannelModel::reset() {
position_ = start_position_;
}

void RealHfLoopChannelModel::setSNR(float snr_db) {
scale_ = modemReferenceNoiseStddev(snr_db);
}

void RealHfLoopChannelModel::process(std::span<const float> input,
std::vector<float>& output) {
output.resize(input.size());
if (!loop_ || loop_->empty()) {
std::copy(input.begin(), input.end(), output.begin());
return;
}

const auto& loop = *loop_;
for (size_t i = 0; i < input.size(); ++i) {
output[i] = input[i] + scale_ * loop[position_];
position_ = (position_ + 1) % loop.size();
}
}

WattersonChannel::WattersonChannel(const Config& config, uint64_t seed)
: config_(config),
rng_(static_cast<uint32_t>(seed)) {
Expand Down Expand Up @@ -385,6 +584,18 @@ std::unique_ptr<IChannelModel> createChannelModel(const ChannelConfig& config,
case ChannelType::AWGN:
return std::make_unique<AWGNChannelModel>(
config.snr_db, rng_root.stream(stream_name));
case ChannelType::REAL_HF_LOOP:
if (!config.real_hf_loop_noise || config.real_hf_loop_noise->empty()) {
throw std::invalid_argument(
"real_hf_loop requires a normalized noise-bed loop");
}
if (config.sample_rate != kDefaultSampleRate) {
throw std::invalid_argument("real_hf_loop requires 48000 Hz sample rate");
}
return std::make_unique<RealHfLoopChannelModel>(
config.snr_db,
config.real_hf_loop_noise,
rng_root.childSeed(stream_name));
case ChannelType::GOOD:
case ChannelType::MODERATE:
case ChannelType::POOR:
Expand Down
30 changes: 29 additions & 1 deletion src/ota_channel_core/ota_channel_core/models.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
#include <cstdint>
#include <deque>
#include <memory>
#include <optional>
#include <random>
#include <span>
#include <string_view>
#include <vector>

namespace ultra::ota_channel_core {
Expand All @@ -25,18 +27,22 @@ enum class ChannelType {
GOOD,
MODERATE,
POOR,
FLUTTER
FLUTTER,
REAL_HF_LOOP
};

struct ChannelConfig {
ChannelType type = ChannelType::PASSTHROUGH;
float snr_db = 20.0f;
uint64_t seed = 42;
uint32_t sample_rate = kDefaultSampleRate;
std::shared_ptr<const std::vector<float>> real_hf_loop_noise;
};

float modemReferenceNoiseStddev(float snr_db);
const char* channelTypeName(ChannelType type);
std::optional<ChannelType> parseChannelType(std::string_view value);
std::vector<float> loadRealHfLoopNoiseBedWav(std::string_view path);

class IChannelModel {
public:
Expand Down Expand Up @@ -74,6 +80,28 @@ class AWGNChannelModel final : public IChannelModel {
RngStream rng_;
};

class RealHfLoopChannelModel final : public IChannelModel {
public:
using IChannelModel::process;

RealHfLoopChannelModel(float snr_db,
std::vector<float> normalized_loop,
uint64_t seed_for_phase = 0);
RealHfLoopChannelModel(float snr_db,
std::shared_ptr<const std::vector<float>> normalized_loop,
uint64_t seed_for_phase = 0);

void reset() override;
void setSNR(float snr_db);
void process(std::span<const float> input, std::vector<float>& output) override;

private:
float scale_ = 0.0f;
std::shared_ptr<const std::vector<float>> loop_;
size_t start_position_ = 0;
size_t position_ = 0;
};

class WattersonChannel {
public:
struct Config {
Expand Down
6 changes: 5 additions & 1 deletion src/ota_channel_core/ota_channel_core/session_config.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>

namespace ultra::ota_channel_core {

Expand All @@ -17,12 +19,14 @@ struct SessionConfig {
size_t station_cap = 16;
bool is_lobby = false;
uint32_t sample_rate = kDefaultSampleRate;
std::shared_ptr<const std::vector<float>> real_hf_loop_noise;

ChannelConfig channelConfig() const {
return {.type = default_channel_model,
.snr_db = default_snr_db,
.seed = seed,
.sample_rate = sample_rate};
.sample_rate = sample_rate,
.real_hf_loop_noise = real_hf_loop_noise};
}
};

Expand Down
1 change: 1 addition & 0 deletions src/ota_channel_core/session_context.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ void SessionContext::setChannel(ChannelConfig config) {
config_.default_snr_db = config.snr_db;
config_.seed = config.seed;
config_.sample_rate = config.sample_rate;
config_.real_hf_loop_noise = std::move(config.real_hf_loop_noise);
tick_samples_ = samplesForMs(config_.sample_rate, kDefaultTickIntervalMs);
max_tx_queue_samples_ = samplesForMs(config_.sample_rate, kMaxTxQueuedAudioMs);
max_rx_queue_samples_ = samplesForMs(config_.sample_rate, kMaxRxQueuedAudioMs);
Expand Down
Loading
Loading