diff --git a/core/src/utils/optionlist.h b/core/src/utils/optionlist.h index 424649e6..e65261ef 100644 --- a/core/src/utils/optionlist.h +++ b/core/src/utils/optionlist.h @@ -52,6 +52,10 @@ public: return keys.size(); } + bool empty() { + return keys.empty(); + } + bool keyExists(K key) { if (std::find(keys.begin(), keys.end(), key) != keys.end()) { return true; } return false; diff --git a/misc_modules/recorder/src/main.cpp b/misc_modules/recorder/src/main.cpp index 50201c77..62cf656d 100644 --- a/misc_modules/recorder/src/main.cpp +++ b/misc_modules/recorder/src/main.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -19,125 +20,117 @@ #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, 2, 0, + /* Version: */ 0, 3, 0, /* Max instances */ -1 }; ConfigManager config; -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; -} - class RecorderModule : public ModuleManager::Instance { public: RecorderModule(std::string name) : folderSelect("%ROOT%/recordings") { this->name = name; - root = (std::string)core::args["root"]; + strcpy(nameTemplate, "$t_$f_$h-$m-$s_$d-$M-$y"); + + // Define option lists + containers.define("WAV", wav::FORMAT_WAV); + // containers.define("RF64", wav::FORMAT_RF64); // Disabled for now + sampleTypes.define(wav::SAMP_TYPE_UINT8, "Uint8", wav::SAMP_TYPE_UINT8); + sampleTypes.define(wav::SAMP_TYPE_INT16, "Int16", wav::SAMP_TYPE_INT16); + sampleTypes.define(wav::SAMP_TYPE_INT32, "Int32", wav::SAMP_TYPE_INT32); + sampleTypes.define(wav::SAMP_TYPE_FLOAT32, "Float32", wav::SAMP_TYPE_FLOAT32); + + // Load default config for option lists + containerId = containers.valueId(wav::FORMAT_WAV); + sampleTypeId = sampleTypes.valueId(wav::SAMP_TYPE_INT16); // Load config config.acquire(); - bool created = false; - - // Create config if it doesn't exist - if (!config.conf.contains(name)) { - config.conf[name]["mode"] = RECORDER_MODE_AUDIO; - config.conf[name]["recPath"] = "%ROOT%/recordings"; - config.conf[name]["audioStream"] = "Radio"; - config.conf[name]["audioVolume"] = 1.0; - created = true; + if (config.conf[name].contains("mode")) { + recMode = config.conf[name]["mode"]; } - - if (!config.conf[name].contains("audioVolume")) { - config.conf[name]["audioVolume"] = 1.0; + if (config.conf[name].contains("recPath")) { + folderSelect.setPath(config.conf[name]["recPath"]); } - if (!config.conf[name].contains("ignoreSilence")) { - config.conf[name]["ignoreSilence"] = false; + if (config.conf[name].contains("container") && containers.keyExists(config.conf[name]["container"])) { + containerId = containers.keyId(config.conf[name]["container"]); } - - recMode = config.conf[name]["mode"]; - folderSelect.setPath(config.conf[name]["recPath"]); - selectedStreamName = config.conf[name]["audioStream"]; - audioVolume = config.conf[name]["audioVolume"]; - ignoreSilence = config.conf[name]["ignoreSilence"]; - config.release(created); + if (config.conf[name].contains("sampleType") && sampleTypes.keyExists(config.conf[name]["sampleType"])) { + sampleTypeId = sampleTypes.keyId(config.conf[name]["sampleType"]); + } + 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"]; + } + if (config.conf[name].contains("nameTemplate")) { + std::string _nameTemplate = config.conf[name]["nameTemplate"]; + if (_nameTemplate.length() > sizeof(nameTemplate)-1) { + _nameTemplate = _nameTemplate.substr(0, sizeof(nameTemplate)-1); + } + strcpy(nameTemplate, _nameTemplate.c_str()); + } + config.release(); // Init audio path - vol.init(&dummyStream, audioVolume, false); - audioSplit.init(&vol.out); - audioSplit.bindStream(&meterStream); + volume.init(NULL, audioVolume, false); + splitter.init(&volume.out); + splitter.bindStream(&meterStream); meter.init(&meterStream); - audioHandler.init(&audioHandlerStream, _audioHandler, this); + s2m.init(NULL); - vol.start(); - audioSplit.start(); - meter.start(); - - // Init baseband path - basebandHandler.init(&basebandStream, _basebandHandler, this); - - wavSampleBuf = new int16_t[2 * STREAM_BUFFER_SIZE]; + // Init sinks + basebandSink.init(NULL, complexHandler, this); + stereoSink.init(NULL, stereoHandler, this); + monoSink.init(&s2m.out, monoHandler, this); gui::menu.registerEntry(name, menuHandler, this); core::modComManager.registerInterface("recorder", name, moduleInterfaceHandler, this); - - streamRegisteredHandler.handler = onStreamRegistered; - streamRegisteredHandler.ctx = this; - streamUnregisterHandler.handler = onStreamUnregister; - streamUnregisterHandler.ctx = this; - streamUnregisteredHandler.handler = onStreamUnregistered; - streamUnregisteredHandler.ctx = this; - sigpath::sinkManager.onStreamRegistered.bindHandler(&streamRegisteredHandler); - sigpath::sinkManager.onStreamUnregister.bindHandler(&streamUnregisterHandler); - sigpath::sinkManager.onStreamUnregistered.bindHandler(&streamUnregisteredHandler); } ~RecorderModule() { - std::lock_guard lck(recMtx); - gui::menu.removeEntry(name); + std::lock_guard lck(recMtx); core::modComManager.unregisterInterface(name); - - // Stop recording - if (recording) { stopRecording(); } - - vol.setInput(&dummyStream); - if (audioInput != NULL) { sigpath::sinkManager.unbindStream(selectedStreamName, audioInput); } - - sigpath::sinkManager.onStreamRegistered.unbindHandler(&streamRegisteredHandler); - sigpath::sinkManager.onStreamUnregister.unbindHandler(&streamUnregisterHandler); - sigpath::sinkManager.onStreamUnregistered.unbindHandler(&streamUnregisteredHandler); - - vol.stop(); - audioSplit.stop(); + gui::menu.removeEntry(name); + stop(); + deselectStream(); + sigpath::sinkManager.onStreamRegistered.unbindHandler(&onStreamRegisteredHandler); + sigpath::sinkManager.onStreamUnregister.unbindHandler(&onStreamUnregisterHandler); meter.stop(); - - delete[] wavSampleBuf; } void postInit() { - refreshStreams(); - if (selectedStreamName == "") { - selectStream(streamNames[0]); - } - else { - selectStream(selectedStreamName); + // Enumerate streams + audioStreams.clear(); + auto names = sigpath::sinkManager.getStreamNames(); + for (const auto& name : names) { + audioStreams.define(name, name, name); } + + // Bind stream register/unregister handlers + onStreamRegisteredHandler.ctx = this; + onStreamRegisteredHandler.handler = streamRegisteredHandler; + sigpath::sinkManager.onStreamRegistered.bindHandler(&onStreamRegisteredHandler); + onStreamUnregisterHandler.ctx = this; + onStreamUnregisterHandler.handler = streamUnregisterHandler; + sigpath::sinkManager.onStreamUnregister.bindHandler(&onStreamUnregisterHandler); + + // Select the stream + selectStream(selectedStreamName); } void enable() { @@ -152,70 +145,106 @@ public: return enabled; } + void start() { + std::lock_guard lck(recMtx); + if (recording) { return; } + + // Configure the wav writer + if (recMode == RECORDER_MODE_AUDIO) { + if (selectedStreamName.empty()) { return; } + samplerate = sigpath::sinkManager.getStreamSampleRate(selectedStreamName); + } + else { + samplerate = sigpath::iqFrontEnd.getSampleRate(); + } + writer.setFormat(containers[containerId]); + writer.setChannels((recMode == RECORDER_MODE_AUDIO && !stereo) ? 1 : 2); + writer.setSampleType(sampleTypes[sampleTypeId]); + writer.setSamplerate(samplerate); + + // Open file + std::string type = (recMode == RECORDER_MODE_AUDIO) ? "audio" : "baseband"; + std::string vfoName = (recMode == RECORDER_MODE_AUDIO) ? gui::waterfall.selectedVFO : ""; + std::string extension = ".wav"; + std::string expandedPath = expandString(folderSelect.path + "/" + genFileName(nameTemplate, type, vfoName) + extension); + if (!writer.open(expandedPath)) { + spdlog::error("Failed to open file for recording: {0}", expandedPath); + return; + } + + // Open audio stream or baseband + if (recMode == RECORDER_MODE_AUDIO) { + // TODO: Select the stereo to mono converter if needed + stereoStream = sigpath::sinkManager.bindStream(selectedStreamName); + if (stereo) { + stereoSink.setInput(stereoStream); + stereoSink.start(); + } + else { + s2m.setInput(stereoStream); + s2m.start(); + monoSink.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) { + // NOTE: Has to be done before the unbind since the stream is deleted... + monoSink.stop(); + stereoSink.stop(); + s2m.stop(); + sigpath::sinkManager.unbindStream(selectedStreamName, stereoStream); + } + else { + // Unbind and destroy IQ stream + sigpath::iqFrontEnd.unbindIQStream(basebandStream); + basebandSink.stop(); + delete basebandStream; + } + + // Close file + writer.close(); + + recording = false; + } + private: - void refreshStreams() { - std::vector names = sigpath::sinkManager.getStreamNames(); - - streamNames.clear(); - streamNamesTxt = ""; - - // If there are no stream, cancel - if (names.size() == 0) { return; } - - // List streams - for (auto const& name : names) { - streamNames.push_back(name); - streamNamesTxt += name; - streamNamesTxt += '\0'; - } - } - - void selectStream(std::string name) { - if (streamNames.empty()) { - selectedStreamName = ""; - return; - } - auto it = std::find(streamNames.begin(), streamNames.end(), name); - if (it == streamNames.end()) { - selectStream(streamNames[0]); - return; - } - streamId = std::distance(streamNames.begin(), it); - - vol.stop(); - if (audioInput != NULL) { sigpath::sinkManager.unbindStream(selectedStreamName, audioInput); } - audioInput = sigpath::sinkManager.bindStream(name); - if (audioInput == NULL) { - selectedStreamName = ""; - return; - } - selectedStreamName = name; - vol.setInput(audioInput); - vol.start(); - } - static void menuHandler(void* ctx) { RecorderModule* _this = (RecorderModule*)ctx; - float menuColumnWidth = ImGui::GetContentRegionAvail().x; + float menuWidth = ImGui::GetContentRegionAvail().x; // Recording mode if (_this->recording) { style::beginDisabled(); } ImGui::BeginGroup(); - ImGui::Columns(2, CONCAT("AirspyGainModeColumns##_", _this->name), false); - if (ImGui::RadioButton(CONCAT("Baseband##_recmode_", _this->name), _this->recMode == RECORDER_MODE_BASEBAND)) { + 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##_recmode_", _this->name), _this->recMode == RECORDER_MODE_AUDIO)) { + 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("EndAirspyGainModeColumns##_", _this->name), false); + ImGui::Columns(1, CONCAT("EndRecorderModeColumns##_", _this->name), false); ImGui::EndGroup(); if (_this->recording) { style::endDisabled(); } @@ -228,116 +257,232 @@ private: } } - // Mode specific menu - if (_this->recMode == RECORDER_MODE_AUDIO) { - _this->audioMenu(menuColumnWidth); + ImGui::LeftLabel("Name template"); + ImGui::FillWidth(); + if (ImGui::InputText(CONCAT("##_recorder_name_template_", _this->name), _this->nameTemplate, 1023)) { + config.acquire(); + config.conf[_this->name]["nameTemplate"] = _this->nameTemplate; + config.release(true); } - else { - _this->basebandMenu(menuColumnWidth); - } - } - void basebandMenu(float menuColumnWidth) { - if (!folderSelect.pathIsValid()) { style::beginDisabled(); } - if (!recording) { - if (ImGui::Button(CONCAT("Record##_recorder_rec_", name), ImVec2(menuColumnWidth, 0))) { - std::lock_guard lck(recMtx); - startRecording(); + ImGui::LeftLabel("Container"); + ImGui::FillWidth(); + if (ImGui::Combo(CONCAT("##_recorder_container_", _this->name), &_this->containerId, _this->containers.txt)) { + config.acquire(); + config.conf[_this->name]["container"] = _this->containers.key(_this->containerId); + config.release(true); + } + + ImGui::LeftLabel("Sample type"); + ImGui::FillWidth(); + if (ImGui::Combo(CONCAT("##_recorder_st_", _this->name), &_this->sampleTypeId, _this->sampleTypes.txt)) { + config.acquire(); + config.conf[_this->name]["sampleType"] = _this->sampleTypes.key(_this->sampleTypeId); + config.release(true); + } + + // Show additional audio options + if (_this->recMode == RECORDER_MODE_AUDIO) { + ImGui::LeftLabel("Stream"); + ImGui::FillWidth(); + if (ImGui::Combo(CONCAT("##_recorder_stream_", _this->name), &_this->streamId, _this->audioStreams.txt)) { + _this->selectStream(_this->audioStreams.value(_this->streamId)); + config.acquire(); + config.conf[_this->name]["audioStream"] = _this->audioStreams.key(_this->streamId); + config.release(true); + } + + _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, "")) { + _this->volume.setVolume(_this->audioVolume); + config.acquire(); + config.conf[_this->name]["audioVolume"] = _this->audioVolume; + config.release(true); + } + + 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_", name), ImVec2(menuColumnWidth, 0))) { - std::lock_guard lck(recMtx); - stopRecording(); + if (ImGui::Button(CONCAT("Stop##_recorder_rec_", _this->name), ImVec2(menuWidth, 0))) { + _this->stop(); } - uint64_t seconds = samplesWritten / (uint64_t)sampleRate; + 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); } - if (!folderSelect.pathIsValid()) { style::endDisabled(); } } - void audioMenu(float menuColumnWidth) { - ImGui::PushItemWidth(menuColumnWidth); + void selectStream(std::string name) { + std::lock_guard lck(recMtx); + deselectStream(); - if (streamNames.size() == 0) { + if (audioStreams.empty()) { + selectedStreamName.clear(); + return; + } + else if (!audioStreams.keyExists(name)) { + selectStream(audioStreams.key(0)); return; } - if (recording) { style::beginDisabled(); } - if (ImGui::Combo(CONCAT("##_recorder_strm_", name), &streamId, streamNamesTxt.c_str())) { - selectStream(streamNames[streamId]); - config.acquire(); - config.conf[name]["audioStream"] = streamNames[streamId]; - config.release(true); + audioStream = sigpath::sinkManager.bindStream(name); + if (!audioStream) { return; } + selectedStreamName = name; + streamId = audioStreams.keyId(name); + volume.setInput(audioStream); + startAudioPath(); + } + + void deselectStream() { + std::lock_guard lck(recMtx); + if (selectedStreamName.empty() || !audioStream) { + selectedStreamName.clear(); + return; } - if (recording) { style::endDisabled(); } + if (recording && recMode == RECORDER_MODE_AUDIO) { stop(); } + stopAudioPath(); + sigpath::sinkManager.unbindStream(selectedStreamName, audioStream); + selectedStreamName.clear(); + audioStream = NULL; + } - double frameTime = 1.0 / ImGui::GetIO().Framerate; - lvlL = std::clamp(lvlL - (frameTime * 50.0), -90.0f, 10.0f); - lvlR = std::clamp(lvlR - (frameTime * 50.0), -90.0f, 10.0f); + void startAudioPath() { + volume.start(); + splitter.start(); + meter.start(); + } + void stopAudioPath() { + volume.stop(); + splitter.stop(); + meter.stop(); + } + + static void streamRegisteredHandler(std::string name, void* ctx) { + RecorderModule* _this = (RecorderModule*)ctx; + + // Add new stream to the list + _this->audioStreams.define(name, name, name); + + // If no stream is selected, select new stream. If not, update the menu ID. + if (_this->selectedStreamName.empty()) { + _this->selectStream(name); + } + else { + _this->streamId = _this->audioStreams.keyId(_this->selectedStreamName); + } + } + + static void streamUnregisterHandler(std::string name, void* ctx) { + RecorderModule* _this = (RecorderModule*)ctx; + + // Remove stream from list + _this->audioStreams.undefineKey(name); + + // If the stream is in used, deselect it and reselect default. Otherwise, update ID. + if (_this->selectedStreamName == name) { + _this->selectStream(""); + } + else { + _this->streamId = _this->audioStreams.keyId(_this->selectedStreamName); + } + } + + 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); 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 > lvlL) { lvlL = dbLvl.l; } - if (dbLvl.r > lvlR) { lvlR = dbLvl.r; } - ImGui::VolumeMeter(lvlL, lvlL, -60, 10); - ImGui::VolumeMeter(lvlR, lvlR, -60, 10); - - if (ImGui::SliderFloat(CONCAT("##_recorder_vol_", name), &audioVolume, 0, 1, "")) { - vol.setVolume(audioVolume); - config.acquire(); - config.conf[name]["audioVolume"] = audioVolume; - config.release(true); - } - ImGui::PopItemWidth(); - - if (ImGui::Checkbox(CONCAT("Ignore silence##_recorder_ing_silence_", name), &ignoreSilence)) { - config.acquire(); - config.conf[name]["ignoreSilence"] = ignoreSilence; - config.release(true); - } - - if (!folderSelect.pathIsValid() || selectedStreamName == "") { style::beginDisabled(); } - if (!recording) { - if (ImGui::Button(CONCAT("Record##_recorder_rec_", name), ImVec2(menuColumnWidth, 0))) { - std::lock_guard lck(recMtx); - startRecording(); - } - ImGui::TextColored(ImGui::GetStyleColorVec4(ImGuiCol_Text), "Idle --:--:--"); - } - else { - if (ImGui::Button(CONCAT("Stop##_recorder_rec_", name), ImVec2(menuColumnWidth, 0))) { - std::lock_guard lck(recMtx); - stopRecording(); - } - uint64_t seconds = samplesWritten / (uint64_t)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); - } - if (!folderSelect.pathIsValid() || selectedStreamName == "") { style::endDisabled(); } + if (dbLvl.l > lvl.l) { lvl.l = dbLvl.l; } + if (dbLvl.r > lvl.r) { lvl.r = dbLvl.r; } } - static void _audioHandler(dsp::stereo_t* data, int count, void* ctx) { - RecorderModule* _this = (RecorderModule*)ctx; - if (_this->ignoreSilence && data[0].l == 0.0f && data[0].r == 0.0f) { - return; + std::string genFileName(std::string templ, std::string type, std::string name) { + // Get data + time_t now = time(0); + tm* ltm = localtime(&now); + char buf[1024]; + double freq = gui::waterfall.getCenterFrequency(); + if (gui::waterfall.vfos.find(name) != gui::waterfall.vfos.end()) { + freq += gui::waterfall.vfos[name]->generalOffset; } - volk_32f_s32f_convert_16i(_this->wavSampleBuf, (float*)data, 32767.0f, count * 2); - _this->audioWriter->writeSamples(_this->wavSampleBuf, count * 2 * sizeof(int16_t)); - _this->samplesWritten += count; + + // Format to string + char freqStr[128]; + char hourStr[128]; + char minStr[128]; + char secStr[128]; + char dayStr[128]; + char monStr[128]; + char yearStr[128]; + sprintf(freqStr, "%.0lfHz", freq); + sprintf(hourStr, "%02d", ltm->tm_hour); + sprintf(minStr, "%02d", ltm->tm_min); + sprintf(secStr, "%02d", ltm->tm_sec); + sprintf(dayStr, "%02d", ltm->tm_mday); + sprintf(monStr, "%02d", ltm->tm_mon + 1); + sprintf(yearStr, "%02d", ltm->tm_year + 1900); + + // Replace in template + templ = std::regex_replace(templ, std::regex("\\$t"), type); + templ = std::regex_replace(templ, std::regex("\\$f"), freqStr); + templ = std::regex_replace(templ, std::regex("\\$h"), hourStr); + templ = std::regex_replace(templ, std::regex("\\$m"), minStr); + templ = std::regex_replace(templ, std::regex("\\$s"), secStr); + templ = std::regex_replace(templ, std::regex("\\$d"), dayStr); + templ = std::regex_replace(templ, std::regex("\\$M"), monStr); + templ = std::regex_replace(templ, std::regex("\\$y"), yearStr); + return templ; } - static void _basebandHandler(dsp::complex_t* data, int count, void* ctx) { + 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; - volk_32f_s32f_convert_16i(_this->wavSampleBuf, (float*)data, 32767.0f, count * 2); - _this->basebandWriter->writeSamples(_this->wavSampleBuf, count * 2 * sizeof(int16_t)); - _this->samplesWritten += count; + _this->writer.write(data, count); } static void moduleInterfaceHandler(int code, void* in, void* out, void* ctx) { @@ -353,187 +498,54 @@ private: _this->recMode = std::clamp(*_in, 0, 1); } else if (code == RECORDER_IFACE_CMD_START) { - if (!_this->recording) { _this->startRecording(); } + if (!_this->recording) { _this->start(); } } else if (code == RECORDER_IFACE_CMD_STOP) { - if (_this->recording) { _this->stopRecording(); } + if (_this->recording) { _this->stop(); } } } - void startRecording() { - if (recMode == RECORDER_MODE_BASEBAND) { - samplesWritten = 0; - std::string expandedPath = expandString(folderSelect.path + genFileName("/baseband_", false)); - sampleRate = sigpath::iqFrontEnd.getSampleRate(); - basebandWriter = new WavWriter(expandedPath, 16, 2, sigpath::iqFrontEnd.getSampleRate()); - if (basebandWriter->isOpen()) { - basebandHandler.start(); - sigpath::iqFrontEnd.bindIQStream(&basebandStream); - recording = true; - spdlog::info("Recording to '{0}'", expandedPath); - } - else { - spdlog::error("Could not create '{0}'", expandedPath); - } - } - else if (recMode == RECORDER_MODE_AUDIO) { - if (selectedStreamName.empty()) { - spdlog::error("Cannot record with no selected stream"); - } - samplesWritten = 0; - std::string expandedPath = expandString(folderSelect.path + genFileName("/audio_", true, selectedStreamName)); - sampleRate = sigpath::sinkManager.getStreamSampleRate(selectedStreamName); - audioWriter = new WavWriter(expandedPath, 16, 2, sigpath::sinkManager.getStreamSampleRate(selectedStreamName)); - if (audioWriter->isOpen()) { - recording = true; - audioHandler.start(); - audioSplit.bindStream(&audioHandlerStream); - spdlog::info("Recording to '{0}'", expandedPath); - } - else { - spdlog::error("Could not create '{0}'", expandedPath); - } - } - } - - void stopRecording() { - if (recMode == 0) { - recording = false; - sigpath::iqFrontEnd.unbindIQStream(&basebandStream); - basebandHandler.stop(); - basebandWriter->close(); - delete basebandWriter; - } - else if (recMode == 1) { - recording = false; - audioSplit.unbindStream(&audioHandlerStream); - audioHandler.stop(); - audioWriter->close(); - delete audioWriter; - } - } - - static void onStreamRegistered(std::string name, void* ctx) { - RecorderModule* _this = (RecorderModule*)ctx; - _this->refreshStreams(); - - if (_this->streamNames.empty()) { - _this->selectedStreamName = ""; - return; - } - - if (_this->selectedStreamName.empty()) { - _this->selectStream(_this->streamNames[0]); - return; - } - - // Reselect stream in UI to make sure the ID is correct - int id = 0; - for (auto& str : _this->streamNames) { - if (str == _this->selectedStreamName) { - _this->streamId = id; - break; - } - id++; - } - } - - static void onStreamUnregister(std::string name, void* ctx) { - RecorderModule* _this = (RecorderModule*)ctx; - if (name != _this->selectedStreamName) { return; } - if (_this->recording) { _this->stopRecording(); } - if (_this->audioInput != NULL) { - _this->vol.setInput(&_this->dummyStream); - sigpath::sinkManager.unbindStream(_this->selectedStreamName, _this->audioInput); - _this->audioInput = NULL; - } - } - - static void onStreamUnregistered(std::string name, void* ctx) { - RecorderModule* _this = (RecorderModule*)ctx; - _this->refreshStreams(); - - if (_this->streamNames.empty()) { - _this->selectedStreamName = ""; - return; - } - - // If current stream was deleted, reselect steam completely - if (name == _this->selectedStreamName) { - _this->streamId = std::clamp(_this->streamId, 0, _this->streamNames.size() - 1); - _this->selectStream(_this->streamNames[_this->streamId]); - return; - } - - // Reselect stream in UI to make sure the ID is correct - int id = 0; - for (auto& str : _this->streamNames) { - if (str == _this->selectedStreamName) { - _this->streamId = id; - break; - } - id++; - } - } - - std::string expandString(std::string input) { - input = std::regex_replace(input, std::regex("%ROOT%"), root); - return std::regex_replace(input, std::regex("//"), "/"); - } - - std::string name; bool enabled = true; + std::string root; + char nameTemplate[1024]; - int recMode = 1; - bool recording = false; - - float audioVolume = 1.0f; - - double sampleRate = 48000; - - float lvlL = -90.0f; - float lvlR = -90.0f; - - dsp::stream dummyStream; - - std::mutex recMtx; - + OptionList containers; + OptionList sampleTypes; FolderSelect folderSelect; - // Audio path - dsp::stream* audioInput = NULL; - dsp::audio::Volume vol; - dsp::routing::Splitter audioSplit; + int recMode = RECORDER_MODE_AUDIO; + int containerId; + int sampleTypeId; + 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; + + OptionList audioStreams; + int streamId = 0; + dsp::stream* audioStream = NULL; + dsp::audio::Volume volume; + dsp::routing::Splitter splitter; dsp::stream meterStream; dsp::bench::PeakLevelMeter meter; - dsp::stream audioHandlerStream; - dsp::sink::Handler audioHandler; - WavWriter* audioWriter; + dsp::convert::StereoToMono s2m; - std::vector streamNames; - std::string streamNamesTxt; - int streamId = 0; - std::string selectedStreamName = ""; - std::string root; + uint64_t samplerate = 48000; - // Baseband path - dsp::stream basebandStream; - dsp::sink::Handler basebandHandler; - WavWriter* basebandWriter; + EventHandler onStreamRegisteredHandler; + EventHandler onStreamUnregisterHandler; - uint64_t samplesWritten; - int16_t* wavSampleBuf; - - EventHandler streamRegisteredHandler; - EventHandler streamUnregisterHandler; - EventHandler streamUnregisteredHandler; - - bool ignoreSilence = false; -}; - -struct RecorderContext_t { - std::string name; }; MOD_EXPORT void _INIT_() { @@ -559,7 +571,7 @@ MOD_EXPORT void _DELETE_INSTANCE_(ModuleManager::Instance* inst) { delete (RecorderModule*)inst; } -MOD_EXPORT void _END_(RecorderContext_t* ctx) { +MOD_EXPORT void _END_() { config.disableAutoSave(); config.save(); } \ No newline at end of file diff --git a/misc_modules/recorder/src/riff.cpp b/misc_modules/recorder/src/riff.cpp new file mode 100644 index 00000000..9074badc --- /dev/null +++ b/misc_modules/recorder/src/riff.cpp @@ -0,0 +1,134 @@ +#include "riff.h" +#include +#include + +namespace riff { + const char* RIFF_SIGNATURE = "RIFF"; + const char* LIST_SIGNATURE = "LIST"; + const size_t RIFF_LABEL_SIZE = 4; + + bool Writer::open(std::string path, const char form[4]) { + std::lock_guard lck(mtx); + + // Open file + file = std::ofstream(path, std::ios::out | std::ios::binary); + if (!file.is_open()) { return false; } + + // Begin RIFF chunk + beginRIFF(form); + + return true; + } + + bool Writer::isOpen() { + std::lock_guard lck(mtx); + return file.is_open(); + } + + void Writer::close() { + std::lock_guard lck(mtx); + + if (!isOpen()) { return; } + + // Finalize RIFF chunk + endRIFF(); + + // Close file + file.close(); + } + + void Writer::beginList(const char id[4]) { + std::lock_guard lck(mtx); + + // Create chunk with the LIST ID and write id + beginChunk(LIST_SIGNATURE); + write((uint8_t*)id, RIFF_LABEL_SIZE); + } + + void Writer::endList() { + std::lock_guard lck(mtx); + + if (chunks.empty()) { + throw std::runtime_error("No chunk to end"); + } + if (memcmp(chunks.top().hdr.id, LIST_SIGNATURE, RIFF_LABEL_SIZE)) { + throw std::runtime_error("Top chunk not LIST chunk"); + } + + endChunk(); + } + + void Writer::beginChunk(const char id[4]) { + std::lock_guard lck(mtx); + + // Create and write header + ChunkDesc desc; + desc.pos = file.tellp(); + memcpy(desc.hdr.id, id, sizeof(desc.hdr.id)); + desc.hdr.size = 0; + file.write((char*)&desc.hdr, sizeof(ChunkHeader)); + + // Save descriptor + chunks.push(desc); + } + + void Writer::endChunk() { + std::lock_guard lck(mtx); + + if (chunks.empty()) { + throw std::runtime_error("No chunk to end"); + } + + // Get descriptor + ChunkDesc desc = chunks.top(); + chunks.pop(); + + // Write size + auto pos = file.tellp(); + auto npos = desc.pos; + npos += 4; + file.seekp(npos); + file.write((char*)&desc.hdr.size, sizeof(desc.hdr.size)); + file.seekp(pos); + + // If parent chunk, increment its size + if (!chunks.empty()) { + chunks.top().hdr.size += desc.hdr.size; + } + } + + void Writer::write(const uint8_t* data, size_t len) { + std::lock_guard lck(mtx); + + if (chunks.empty()) { + throw std::runtime_error("No chunk to write into"); + } + file.write((char*)data, len); + chunks.top().hdr.size += len; + } + + void Writer::beginRIFF(const char form[4]) { + std::lock_guard lck(mtx); + + if (!chunks.empty()) { + throw std::runtime_error("Can't create RIFF chunk on an existing RIFF file"); + } + + // Create chunk with RIFF ID and write form + beginChunk(RIFF_SIGNATURE); + write((uint8_t*)form, RIFF_LABEL_SIZE); + } + + void Writer::endRIFF() { + std::lock_guard lck(mtx); + + if (chunks.empty()) { + throw std::runtime_error("No chunk to end"); + } + if (memcmp(chunks.top().hdr.id, RIFF_SIGNATURE, RIFF_LABEL_SIZE)) { + throw std::runtime_error("Top chunk not RIFF chunk"); + } + + endChunk(); + } +} \ No newline at end of file diff --git a/misc_modules/recorder/src/riff.h b/misc_modules/recorder/src/riff.h new file mode 100644 index 00000000..e47ccf03 --- /dev/null +++ b/misc_modules/recorder/src/riff.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace riff { +#pragma pack(push, 1) + struct ChunkHeader { + char id[4]; + uint32_t size; + }; +#pragma pack(pop) + + struct ChunkDesc { + ChunkHeader hdr; + std::streampos pos; + }; + + class Writer { + public: + bool open(std::string path, const char form[4]); + bool isOpen(); + void close(); + + void beginList(const char id[4]); + void endList(); + + void beginChunk(const char id[4]); + void endChunk(); + + void write(const uint8_t* data, size_t len); + + private: + void beginRIFF(const char form[4]); + void endRIFF(); + + std::recursive_mutex mtx; + std::ofstream file; + std::stack chunks; + }; +} \ No newline at end of file diff --git a/misc_modules/recorder/src/wav.cpp b/misc_modules/recorder/src/wav.cpp new file mode 100644 index 00000000..bd545755 --- /dev/null +++ b/misc_modules/recorder/src/wav.cpp @@ -0,0 +1,182 @@ +#include "wav.h" +#include +#include +#include +#include +#include + +namespace wav { + const char* WAVE_FILE_TYPE = "WAVE"; + const char* FORMAT_MARKER = "fmt "; + const char* DATA_MARKER = "data"; + const uint32_t FORMAT_HEADER_LEN = 16; + const uint16_t SAMPLE_TYPE_PCM = 1; + + std::map SAMP_BITS = { + { SAMP_TYPE_UINT8, 8 }, + { SAMP_TYPE_INT16, 16 }, + { SAMP_TYPE_INT32, 32 }, + { SAMP_TYPE_FLOAT32, 32 } + }; + + Writer::Writer(int channels, uint64_t samplerate, Format format, SampleType type) { + // Validate channels and samplerate + if (channels < 1) { throw std::runtime_error("Channel count must be greater or equal to 1"); } + if (!samplerate) { throw std::runtime_error("Samplerate must be non-zero"); } + + // Initialize variables + _channels = channels; + _samplerate = samplerate; + _format = format; + _type = type; + } + + Writer::~Writer() { close(); } + + bool Writer::open(std::string path) { + std::lock_guard lck(mtx); + // Close previous file + if (rw.isOpen()) { close(); } + + // Reset work values + samplesWritten = 0; + + // Fill header + bytesPerSamp = (SAMP_BITS[_type] / 8) * _channels; + hdr.codec = (_type == SAMP_TYPE_FLOAT32) ? CODEC_FLOAT : CODEC_PCM; + hdr.channelCount = _channels; + hdr.sampleRate = _samplerate; + hdr.bitDepth = SAMP_BITS[_type]; + hdr.bytesPerSample = bytesPerSamp; + hdr.bytesPerSecond = bytesPerSamp * _samplerate; + + // Precompute sizes and allocate buffers + switch (_type) { + case SAMP_TYPE_UINT8: + bufU8 = dsp::buffer::alloc(STREAM_BUFFER_SIZE * _channels); + break; + case SAMP_TYPE_INT16: + bufI16 = dsp::buffer::alloc(STREAM_BUFFER_SIZE * _channels); + break; + case SAMP_TYPE_INT32: + bufI32 = dsp::buffer::alloc(STREAM_BUFFER_SIZE * _channels); + break; + case SAMP_TYPE_FLOAT32: + break; + default: + return false; + break; + } + + // Open file + if (!rw.open(path, WAVE_FILE_TYPE)) { return false; } + + // Write format chunk + rw.beginChunk(FORMAT_MARKER); + rw.write((uint8_t*)&hdr, sizeof(FormatHeader)); + rw.endChunk(); + + // Begin data chunk + rw.beginChunk(DATA_MARKER); + + return true; + } + + bool Writer::isOpen() { + std::lock_guard lck(mtx); + return rw.isOpen(); + } + + void Writer::close() { + std::lock_guard lck(mtx); + // Do nothing if the file is not open + if (!rw.isOpen()) { return; } + + // Finish data chunk + rw.endChunk(); + + // Close the file + rw.close(); + + // Free buffers + if (bufU8) { + dsp::buffer::free(bufU8); + bufU8 = NULL; + } + if (bufI16) { + dsp::buffer::free(bufI16); + bufI16 = NULL; + } + if (bufI32) { + dsp::buffer::free(bufI32); + bufI32 = NULL; + } + } + + void Writer::setChannels(int channels) { + std::lock_guard lck(mtx); + // Do not allow settings to change while open + if (rw.isOpen()) { throw std::runtime_error("Cannot change parameters while file is open"); } + + // Validate channel count + if (channels < 1) { throw std::runtime_error("Channel count must be greater or equal to 1"); } + _channels = channels; + } + + void Writer::setSamplerate(uint64_t samplerate) { + std::lock_guard lck(mtx); + // Do not allow settings to change while open + if (rw.isOpen()) { throw std::runtime_error("Cannot change parameters while file is open"); } + + // Validate samplerate + if (!samplerate) { throw std::runtime_error("Samplerate must be non-zero"); } + } + + void Writer::setFormat(Format format) { + std::lock_guard lck(mtx); + // Do not allow settings to change while open + if (rw.isOpen()) { throw std::runtime_error("Cannot change parameters while file is open"); } + _format = format; + } + + void Writer::setSampleType(SampleType type) { + std::lock_guard lck(mtx); + // Do not allow settings to change while open + if (rw.isOpen()) { throw std::runtime_error("Cannot change parameters while file is open"); } + _type = type; + } + + void Writer::write(float* samples, int count) { + std::lock_guard lck(mtx); + if (!rw.isOpen()) { return; } + + // Select different writer function depending on the chose depth + int tcount = count * _channels; + int tbytes = count * bytesPerSamp; + switch (_type) { + case SAMP_TYPE_UINT8: + // Volk doesn't support unsigned ints yet :/ + for (int i = 0; i < tcount; i++) { + bufU8[i] = (samples[i] * 127.0f) + 128.0f; + } + rw.write(bufU8, tbytes); + break; + case SAMP_TYPE_INT16: + volk_32f_s32f_convert_16i(bufI16, samples, 32767.0f, tcount); + rw.write((uint8_t*)bufI16, tbytes); + break; + case SAMP_TYPE_INT32: + volk_32f_s32f_convert_32i(bufI32, samples, 2147483647.0f, tcount); + rw.write((uint8_t*)bufI32, tbytes); + break; + case SAMP_TYPE_FLOAT32: + rw.write((uint8_t*)samples, tbytes); + break; + default: + break; + } + + // Increment sample counter + samplesWritten += count; + } +} \ No newline at end of file diff --git a/misc_modules/recorder/src/wav.h b/misc_modules/recorder/src/wav.h index f17ed312..c9a95bdb 100644 --- a/misc_modules/recorder/src/wav.h +++ b/misc_modules/recorder/src/wav.h @@ -1,66 +1,71 @@ #pragma once -#include +#include #include +#include +#include +#include "riff.h" -#define WAV_SIGNATURE "RIFF" -#define WAV_TYPE "WAVE" -#define WAV_FORMAT_MARK "fmt " -#define WAV_DATA_MARK "data" -#define WAV_SAMPLE_TYPE_PCM 1 - -class WavWriter { -public: - WavWriter(std::string path, uint16_t bitDepth, uint16_t channelCount, uint32_t sampleRate) { - file = std::ofstream(path.c_str(), std::ios::binary); - memcpy(hdr.signature, WAV_SIGNATURE, 4); - memcpy(hdr.fileType, WAV_TYPE, 4); - memcpy(hdr.formatMarker, WAV_FORMAT_MARK, 4); - memcpy(hdr.dataMarker, WAV_DATA_MARK, 4); - hdr.formatHeaderLength = 16; - hdr.sampleType = WAV_SAMPLE_TYPE_PCM; - hdr.channelCount = channelCount; - hdr.sampleRate = sampleRate; - hdr.bytesPerSecond = (bitDepth / 8) * channelCount * sampleRate; - hdr.bytesPerSample = (bitDepth / 8) * channelCount; - hdr.bitDepth = bitDepth; - file.write((char*)&hdr, sizeof(WavHeader_t)); - } - - bool isOpen() { - return file.is_open(); - } - - void writeSamples(void* data, size_t size) { - file.write((char*)data, size); - bytesWritten += size; - } - - void close() { - hdr.fileSize = bytesWritten + sizeof(WavHeader_t) - 8; - hdr.dataSize = bytesWritten; - file.seekp(0); - file.write((char*)&hdr, sizeof(WavHeader_t)); - file.close(); - } - -private: - struct WavHeader_t { - char signature[4]; // "RIFF" - uint32_t fileSize; // data bytes + sizeof(WavHeader_t) - 8 - char fileType[4]; // "WAVE" - char formatMarker[4]; // "fmt " - uint32_t formatHeaderLength; // Always 16 - uint16_t sampleType; // PCM (1) +namespace wav { + #pragma pack(push, 1) + struct FormatHeader { + uint16_t codec; uint16_t channelCount; uint32_t sampleRate; uint32_t bytesPerSecond; uint16_t bytesPerSample; uint16_t bitDepth; - char dataMarker[4]; // "data" - uint32_t dataSize; + }; + #pragma pack(pop) + + enum Format { + FORMAT_WAV, + FORMAT_RF64 }; - std::ofstream file; - size_t bytesWritten = 0; - WavHeader_t hdr; -}; \ No newline at end of file + enum SampleType { + SAMP_TYPE_UINT8, + SAMP_TYPE_INT16, + SAMP_TYPE_INT32, + SAMP_TYPE_FLOAT32 + }; + + enum Codec { + CODEC_PCM = 1, + CODEC_FLOAT = 3 + }; + + class Writer { + public: + Writer(int channels = 2, uint64_t samplerate = 48000, Format format = FORMAT_WAV, SampleType type = SAMP_TYPE_INT16); + ~Writer(); + + bool open(std::string path); + bool isOpen(); + void close(); + + void setChannels(int channels); + void setSamplerate(uint64_t samplerate); + void setFormat(Format format); + void setSampleType(SampleType type); + + size_t getSamplesWritten() { return samplesWritten; } + + void write(float* samples, int count); + + private: + std::recursive_mutex mtx; + FormatHeader hdr; + riff::Writer rw; + + int _channels; + uint64_t _samplerate; + Format _format; + SampleType _type; + size_t bytesPerSamp; + + uint8_t* bufU8 = NULL; + int16_t* bufI16 = NULL; + int32_t* bufI32 = NULL; + size_t samplesWritten = 0; + }; +}