#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "wav.h" #define CONCAT(a, b) ((std::string(a) + b).c_str()) SDRPP_MOD_INFO{ /* Name: */ "recorder", /* Description: */ "Recorder module for SDR++", /* Author: */ "Ryzerth", /* Version: */ 0, 3, 0, /* Max instances */ -1 }; ConfigManager config; class RecorderModule : public ModuleManager::Instance { public: RecorderModule(std::string name) : folderSelect("%ROOT%/recordings") { this->name = name; root = (std::string)core::args["root"]; // Define option lists formats.define("WAV", wav::FORMAT_WAV); formats.define("RF64", wav::FORMAT_RF64); sampleDepths.define(wav::SAMP_DEPTH_8BIT, "8-Bit", wav::SAMP_DEPTH_8BIT); sampleDepths.define(wav::SAMP_DEPTH_16BIT, "16-Bit", wav::SAMP_DEPTH_16BIT); sampleDepths.define(wav::SAMP_DEPTH_32BIT, "32-Bit", wav::SAMP_DEPTH_32BIT); // Load default config for option lists formatId = formats.valueId(wav::FORMAT_WAV); sampleDepthId = sampleDepths.valueId(wav::SAMP_DEPTH_16BIT); // Load config config.acquire(); if (config.conf[name].contains("mode")) { recMode = config.conf[name]["mode"]; } if (config.conf[name].contains("recPath")) { folderSelect.setPath(config.conf[name]["recPath"]); } if (config.conf[name].contains("format") && formats.keyExists(config.conf[name]["format"])) { formatId = formats.keyId(config.conf[name]["format"]); } if (config.conf[name].contains("sampleDepth") && sampleDepths.keyExists(config.conf[name]["sampleDepth"])) { sampleDepthId = sampleDepths.keyId(config.conf[name]["sampleDepth"]); } if (config.conf[name].contains("audioStream")) { selectedStreamName = config.conf[name]["audioStream"]; } if (config.conf[name].contains("audioVolume")) { audioVolume = config.conf[name]["audioVolume"]; } if (config.conf[name].contains("ignoreSilence")) { ignoreSilence = config.conf[name]["ignoreSilence"]; } config.release(); // Init audio path volume.init(NULL, audioVolume, false); splitter.init(&volume.out); splitter.bindStream(&meterStream); meter.init(&meterStream); // Init sinks basebandSink.init(NULL, complexHandler, this); stereoSink.init(NULL, stereoHandler, this); monoSink.init(NULL, monoHandler, this); gui::menu.registerEntry(name, menuHandler, this); } ~RecorderModule() { stop(); deselectStream(); meter.stop(); gui::menu.removeEntry(name); } void postInit() { selectStream(selectedStreamName); } void enable() { enabled = true; } void disable() { enabled = false; } bool isEnabled() { return enabled; } void start() { std::lock_guard lck(recMtx); if (recording) { return; } // Configure the wav writer if (recMode == RECORDER_MODE_AUDIO) { samplerate = sigpath::sinkManager.getStreamSampleRate("Radio"); } else { samplerate = sigpath::iqFrontEnd.getSampleRate(); } writer.setFormat(formats[formatId]); writer.setChannels((recMode == RECORDER_MODE_AUDIO && !stereo) ? 1 : 2); writer.setSampleDepth(sampleDepths[sampleDepthId]); writer.setSamplerate(samplerate); // Open file std::string prefix = (recMode == RECORDER_MODE_AUDIO) ? "/audio_" : "/baseband_"; std::string expandedPath = expandString(folderSelect.path + genFileName(prefix, false)); if (!writer.open(expandedPath)) { spdlog::error("Failed to open file for recording: {0}", expandedPath); return; } // Open audio stream or baseband // TODO: DO NOT HARDCODE THE STREAM NAME if (recMode == RECORDER_MODE_AUDIO) { // TODO: HAS TO BE DONE PROPERLY stereoStream = sigpath::sinkManager.bindStream("Radio"); stereoSink.setInput(stereoStream); stereoSink.start(); } else { // Create and bind IQ stream basebandStream = new dsp::stream(); basebandSink.setInput(basebandStream); basebandSink.start(); sigpath::iqFrontEnd.bindIQStream(basebandStream); } recording = true; } void stop() { std::lock_guard lck(recMtx); if (!recording) { return; } // Close audio stream or baseband if (recMode == RECORDER_MODE_AUDIO) { // TODO: HAS TO BE DONE PROPERLY stereoSink.stop(); sigpath::sinkManager.unbindStream("Radio", stereoStream); } else { // Unbind and destroy IQ stream sigpath::iqFrontEnd.unbindIQStream(basebandStream); basebandSink.stop(); delete basebandStream; } // Close file writer.close(); recording = false; } private: static void menuHandler(void* ctx) { RecorderModule* _this = (RecorderModule*)ctx; float menuWidth = ImGui::GetContentRegionAvail().x; // Recording mode if (_this->recording) { style::beginDisabled(); } ImGui::BeginGroup(); ImGui::Columns(2, CONCAT("RecorderModeColumns##_", _this->name), false); if (ImGui::RadioButton(CONCAT("Baseband##_recorder_mode_", _this->name), _this->recMode == RECORDER_MODE_BASEBAND)) { _this->recMode = RECORDER_MODE_BASEBAND; config.acquire(); config.conf[_this->name]["mode"] = _this->recMode; config.release(true); } ImGui::NextColumn(); if (ImGui::RadioButton(CONCAT("Audio##_recorder_mode_", _this->name), _this->recMode == RECORDER_MODE_AUDIO)) { _this->recMode = RECORDER_MODE_AUDIO; config.acquire(); config.conf[_this->name]["mode"] = _this->recMode; config.release(true); } ImGui::Columns(1, CONCAT("EndRecorderModeColumns##_", _this->name), false); ImGui::EndGroup(); if (_this->recording) { style::endDisabled(); } // Recording path if (_this->folderSelect.render("##_recorder_fold_" + _this->name)) { if (_this->folderSelect.pathIsValid()) { config.acquire(); config.conf[_this->name]["recPath"] = _this->folderSelect.path; config.release(true); } } ImGui::LeftLabel("WAV Format"); ImGui::FillWidth(); if (ImGui::Combo(CONCAT("##_recorder_wav_fmt_", _this->name), &_this->formatId, _this->formats.txt)) { config.acquire(); config.conf[_this->name]["format"] = _this->formats.key(_this->formatId); config.release(true); } ImGui::LeftLabel("Sample depth"); ImGui::FillWidth(); if (ImGui::Combo(CONCAT("##_recorder_bits_", _this->name), &_this->sampleDepthId, _this->sampleDepths.txt)) { config.acquire(); config.conf[_this->name]["sampleDepth"] = _this->sampleDepths.key(_this->sampleDepthId); config.release(true); } // Show additional audio options if (_this->recMode == RECORDER_MODE_AUDIO) { _this->updateAudioMeter(_this->audioLvl); ImGui::FillWidth(); ImGui::VolumeMeter(_this->audioLvl.l, _this->audioLvl.l, -60, 10); ImGui::FillWidth(); ImGui::VolumeMeter(_this->audioLvl.r, _this->audioLvl.r, -60, 10); ImGui::FillWidth(); if (ImGui::SliderFloat(CONCAT("##_recorder_vol_", _this->name), &_this->audioVolume, 0, 1, "")) { // TODO: ADD VOLUME CONTROL _this->volume.setVolume(_this->audioVolume); config.acquire(); config.conf[_this->name]["audioVolume"] = _this->audioVolume; config.release(true); } //ImGui::PopItemWidth(); if (_this->recording) { style::beginDisabled(); } if (ImGui::Checkbox(CONCAT("Stereo##_recorder_stereo_", _this->name), &_this->stereo)) { config.acquire(); config.conf[_this->name]["stereo"] = _this->stereo; config.release(true); } if (_this->recording) { style::endDisabled(); } if (ImGui::Checkbox(CONCAT("Ignore silence##_recorder_ignore_silence_", _this->name), &_this->ignoreSilence)) { config.acquire(); config.conf[_this->name]["ignoreSilence"] = _this->ignoreSilence; config.release(true); } } // Record button bool canRecord = _this->folderSelect.pathIsValid(); if (_this->recMode == RECORDER_MODE_AUDIO) { canRecord &= !_this->selectedStreamName.empty(); } if (!_this->recording) { if (ImGui::Button(CONCAT("Record##_recorder_rec_", _this->name), ImVec2(menuWidth, 0))) { _this->start(); } ImGui::TextColored(ImGui::GetStyleColorVec4(ImGuiCol_Text), "Idle --:--:--"); } else { if (ImGui::Button(CONCAT("Stop##_recorder_rec_", _this->name), ImVec2(menuWidth, 0))) { _this->stop(); } uint64_t seconds = _this->writer.getSamplesWritten() / _this->samplerate; time_t diff = seconds; tm* dtm = gmtime(&diff); ImGui::TextColored(ImVec4(1.0f, 0.0f, 0.0f, 1.0f), "Recording %02d:%02d:%02d", dtm->tm_hour, dtm->tm_min, dtm->tm_sec); } } void selectStream(std::string name) { std::lock_guard lck(recMtx); deselectStream(); audioStream = sigpath::sinkManager.bindStream(name); if (!audioStream) { return; } selectedStreamName = name; volume.setInput(audioStream); startAudioPath(); } void deselectStream() { std::lock_guard lck(recMtx); if (selectedStreamName.empty() || !audioStream) { return; } if (recording && recMode == RECORDER_MODE_AUDIO) { stop(); } stopAudioPath(); sigpath::sinkManager.unbindStream(selectedStreamName, audioStream); selectedStreamName = ""; audioStream = NULL; } void startAudioPath() { volume.start(); splitter.start(); meter.start(); } void stopAudioPath() { volume.stop(); splitter.stop(); meter.stop(); } void updateAudioMeter(dsp::stereo_t& lvl) { // Note: Yes, using the natural log is on purpose, it just gives a more beautiful result. double frameTime = 1.0 / ImGui::GetIO().Framerate; lvl.l = std::clamp(lvl.l - (frameTime * 50.0), -90.0f, 10.0f); lvl.r = std::clamp(lvl.r - (frameTime * 50.0), -90.0f, 10.0f); // TODO: FINISH METER dsp::stereo_t rawLvl = meter.getLevel(); meter.resetLevel(); dsp::stereo_t dbLvl = { 10.0f * logf(rawLvl.l), 10.0f * logf(rawLvl.r) }; if (dbLvl.l > lvl.l) { lvl.l = dbLvl.l; } if (dbLvl.r > lvl.r) { lvl.r = dbLvl.r; } } // TODO: REPLACE WITH SOMETHING CLEAN std::string genFileName(std::string prefix, bool isVfo, std::string name = "") { time_t now = time(0); tm* ltm = localtime(&now); char buf[1024]; double freq = gui::waterfall.getCenterFrequency(); ; if (isVfo && gui::waterfall.vfos.find(name) != gui::waterfall.vfos.end()) { freq += gui::waterfall.vfos[name]->generalOffset; } sprintf(buf, "%.0lfHz_%02d-%02d-%02d_%02d-%02d-%02d.wav", freq, ltm->tm_hour, ltm->tm_min, ltm->tm_sec, ltm->tm_mday, ltm->tm_mon + 1, ltm->tm_year + 1900); return prefix + buf; } std::string expandString(std::string input) { input = std::regex_replace(input, std::regex("%ROOT%"), root); return std::regex_replace(input, std::regex("//"), "/"); } static void complexHandler(dsp::complex_t* data, int count, void* ctx) { monoHandler((float*)data, count, ctx); } static void stereoHandler(dsp::stereo_t* data, int count, void* ctx) { monoHandler((float*)data, count, ctx); } static void monoHandler(float* data, int count, void* ctx) { RecorderModule* _this = (RecorderModule*)ctx; _this->writer.write(data, count); } std::string name; bool enabled = true; std::string root; OptionList formats; OptionList sampleDepths; FolderSelect folderSelect; int recMode = RECORDER_MODE_AUDIO; int formatId; int sampleDepthId; bool stereo = true; std::string selectedStreamName = ""; float audioVolume = 1.0f; bool ignoreSilence = false; dsp::stereo_t audioLvl = { -100.0f, -100.0f }; bool recording = false; wav::Writer writer; std::recursive_mutex recMtx; dsp::stream* basebandStream; dsp::stream* stereoStream; dsp::sink::Handler basebandSink; dsp::sink::Handler stereoSink; dsp::sink::Handler monoSink; dsp::stream* audioStream = NULL; dsp::audio::Volume volume; dsp::routing::Splitter splitter; dsp::stream meterStream; dsp::bench::PeakLevelMeter meter; uint64_t samplerate = 48000; }; MOD_EXPORT void _INIT_() { // Create default recording directory std::string root = (std::string)core::args["root"]; if (!std::filesystem::exists(root + "/recordings")) { spdlog::warn("Recordings directory does not exist, creating it"); if (!std::filesystem::create_directory(root + "/recordings")) { spdlog::error("Could not create recordings directory"); } } json def = json({}); config.setPath(root + "/recorder_config.json"); config.load(def); config.enableAutoSave(); } MOD_EXPORT ModuleManager::Instance* _CREATE_INSTANCE_(std::string name) { return new RecorderModule(name); } MOD_EXPORT void _DELETE_INSTANCE_(ModuleManager::Instance* inst) { delete (RecorderModule*)inst; } MOD_EXPORT void _END_() { config.disableAutoSave(); config.save(); }