From 5cb4274907d32fb8946558988461224196c2be59 Mon Sep 17 00:00:00 2001 From: "Steinar H. Gunderson" Date: Fri, 16 Sep 2016 20:20:45 +0200 Subject: [PATCH] Make it possible to load/save input mappings. Settings these up can be a bit of a hassle if you have a lot of them, so we allow them to be saved to disk and then loaded back (complete with machinery for fuzzy-matching devices if the state has changed, e.g. cards moved around). This introduces a dependency on protocol buffers, because it's a convenient way to store simple data to disk (including forward compatibility). We choose to use the text format for the user's convenience; we don't need the speed or compactness of the binary format. --- .gitignore | 2 + Makefile | 7 +- README | 3 +- alsa_input.cpp | 39 ++++++++ alsa_input.h | 10 +++ audio_mixer.cpp | 22 +++++ audio_mixer.h | 10 +++ input_mapping.cpp | 189 +++++++++++++++++++++++++++++++++++++++ input_mapping.h | 8 ++ input_mapping_dialog.cpp | 35 ++++++++ input_mapping_dialog.h | 5 +- state.proto | 35 ++++++++ ui_input_mapping.ui | 42 ++++++--- 13 files changed, 388 insertions(+), 19 deletions(-) create mode 100644 input_mapping.cpp create mode 100644 state.proto diff --git a/.gitignore b/.gitignore index ea80de2..8724f1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ *.d *.o +*.pb.h +*.pb.cc *.moc.cpp ui_aboutdialog.h ui_audio_expanded_view.h diff --git a/Makefile b/Makefile index 055f811..1b1f038 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ CXX=g++ +PROTOC=protoc INSTALL=install EMBEDDED_BMUSB=no -PKG_MODULES := Qt5Core Qt5Gui Qt5Widgets Qt5OpenGLExtensions Qt5OpenGL libusb-1.0 movit lua52 libmicrohttpd epoxy x264 +PKG_MODULES := Qt5Core Qt5Gui Qt5Widgets Qt5OpenGLExtensions Qt5OpenGL libusb-1.0 movit lua52 libmicrohttpd epoxy x264 protobuf CXXFLAGS ?= -O2 -g -Wall # Will be overridden by environment. CXXFLAGS += -std=gnu++11 -fPIC $(shell pkg-config --cflags $(PKG_MODULES)) -pthread -DMOVIT_SHADER_DIR=\"$(shell pkg-config --variable=shaderdir movit)\" -Idecklink/ @@ -17,7 +18,7 @@ OBJS=glwidget.o main.o mainwindow.o vumeter.o lrameter.o vu_common.o correlation OBJS += glwidget.moc.o mainwindow.moc.o vumeter.moc.o lrameter.moc.o correlation_meter.moc.o aboutdialog.moc.o ellipsis_label.moc.o input_mapping_dialog.moc.o nonlinear_fader.moc.o clickable_label.moc.o # Mixer objects -AUDIO_MIXER_OBJS = audio_mixer.o alsa_input.o ebu_r128_proc.o stereocompressor.o resampling_queue.o flags.o correlation_measurer.o filter.o disk_space_estimator.o +AUDIO_MIXER_OBJS = audio_mixer.o alsa_input.o ebu_r128_proc.o stereocompressor.o resampling_queue.o flags.o correlation_measurer.o filter.o input_mapping.o state.pb.o OBJS += mixer.o pbo_frame_allocator.o context.o ref_counted_frame.o theme.o httpd.o flags.o image_input.o alsa_output.o disk_space_estimator.o $(AUDIO_MIXER_OBJS) # Streaming and encoding objects @@ -38,6 +39,8 @@ BM_OBJS = benchmark_audio_mixer.o $(AUDIO_MIXER_OBJS) flags.o $(CXX) -MMD -MP $(CPPFLAGS) $(CXXFLAGS) -o $@ -c $< %.o: %.cc $(CXX) -MMD -MP $(CPPFLAGS) $(CXXFLAGS) -o $@ -c $< +%.pb.cc %.pb.h : %.proto + $(PROTOC) --cpp_out=. $< %.h: %.ui uic $< -o $@ diff --git a/README b/README index b61f712..ca7d8f7 100644 --- a/README +++ b/README @@ -70,7 +70,8 @@ with: apt install qtbase5-dev libqt5opengl5-dev qt5-default pkg-config libmicrohttpd-dev \ libusb-1.0-0-dev liblua5.2-dev libzita-resampler-dev libva-dev \ libavcodec-dev libavformat-dev libswscale-dev libavresample-dev \ - libmovit-dev libegl1-mesa-dev libasound2-dev libx264-dev libbmusb-dev + libmovit-dev libegl1-mesa-dev libasound2-dev libx264-dev libbmusb-dev \ + protobuf-compiler libprotobuf-dev Exceptions as of October 2016: diff --git a/alsa_input.cpp b/alsa_input.cpp index c53b241..0f8e99c 100644 --- a/alsa_input.cpp +++ b/alsa_input.cpp @@ -1,9 +1,11 @@ #include "alsa_input.h" #include "audio_mixer.h" #include "defs.h" +#include "state.pb.h" #include +#include #include #include @@ -645,6 +647,43 @@ unsigned ALSAPool::find_free_device_index(const string &name, const string &info return devices.size() - 1; } +unsigned ALSAPool::create_dead_card(const string &name, const string &info, unsigned num_channels) +{ + lock_guard lock(mu); + + // See if there are any empty slots. If not, insert one at the end. + vector::iterator free_device = + find_if(devices.begin(), devices.end(), + [](const Device &device) { return device.state == Device::State::EMPTY; }); + if (free_device == devices.end()) { + devices.push_back(Device()); + inputs.emplace_back(nullptr); + free_device = devices.end() - 1; + } + + free_device->state = Device::State::DEAD; + free_device->name = name; + free_device->info = info; + free_device->num_channels = num_channels; + free_device->held = true; + + return distance(devices.begin(), free_device); +} + +void ALSAPool::serialize_device(unsigned index, DeviceSpecProto *serialized) +{ + lock_guard lock(mu); + assert(index < devices.size()); + assert(devices[index].held); + serialized->set_type(DeviceSpecProto::ALSA_INPUT); + serialized->set_index(index); + serialized->set_display_name(devices[index].display_name()); + serialized->set_alsa_name(devices[index].name); + serialized->set_alsa_info(devices[index].info); + serialized->set_num_channels(devices[index].num_channels); + serialized->set_address(devices[index].address); +} + void ALSAPool::free_card(unsigned index) { DeviceSpec spec{InputSourceType::ALSA_INPUT, index}; diff --git a/alsa_input.h b/alsa_input.h index bac13be..3b98885 100644 --- a/alsa_input.h +++ b/alsa_input.h @@ -22,6 +22,7 @@ #include "timebase.h" class ALSAPool; +class DeviceSpecProto; class ALSAInput { public: @@ -152,6 +153,15 @@ public: // EMPTY or DEAD state. Only for ALSAInput and for internal use. void free_card(unsigned index); + // Create a new card, mark it immediately as DEAD and hold it. + // Returns the new index. + unsigned create_dead_card(const std::string &name, const std::string &info, unsigned num_channels); + + // Make a protobuf representation of the given card, so that it can be + // matched against at a later stage. For AudioMixer only. + // The given card must be held. + void serialize_device(unsigned index, DeviceSpecProto *serialized); + private: mutable std::mutex mu; std::vector devices; // Under mu. diff --git a/audio_mixer.cpp b/audio_mixer.cpp index 3b40955..962877e 100644 --- a/audio_mixer.cpp +++ b/audio_mixer.cpp @@ -13,6 +13,7 @@ #include "db.h" #include "flags.h" #include "mixer.h" +#include "state.pb.h" #include "timebase.h" using namespace bmusb; @@ -754,6 +755,9 @@ map AudioMixer::get_devices() DeviceInfo info; info.display_name = device.display_name(); info.num_channels = device.num_channels; + info.alsa_name = device.name; + info.alsa_info = device.info; + info.alsa_address = device.address; devices.insert(make_pair(spec, info)); } return devices; @@ -767,6 +771,24 @@ void AudioMixer::set_display_name(DeviceSpec device_spec, const string &name) device->display_name = name; } +void AudioMixer::serialize_device(DeviceSpec device_spec, DeviceSpecProto *device_spec_proto) +{ + lock_guard lock(audio_mutex); + switch (device_spec.type) { + case InputSourceType::SILENCE: + device_spec_proto->set_type(DeviceSpecProto::SILENCE); + break; + case InputSourceType::CAPTURE_CARD: + device_spec_proto->set_type(DeviceSpecProto::CAPTURE_CARD); + device_spec_proto->set_index(device_spec.index); + device_spec_proto->set_display_name(video_cards[device_spec.index].display_name); + break; + case InputSourceType::ALSA_INPUT: + alsa_pool.serialize_device(device_spec.index, device_spec_proto); + break; + } +} + void AudioMixer::set_input_mapping(const InputMapping &new_input_mapping) { lock_guard lock(audio_mutex); diff --git a/audio_mixer.h b/audio_mixer.h index 7ae5464..3a1334a 100644 --- a/audio_mixer.h +++ b/audio_mixer.h @@ -76,8 +76,18 @@ public: return alsa_pool.get_card_state(index); } + // See comments on ALSAPool::create_dead_card(). + DeviceSpec create_dead_card(const std::string &name, const std::string &info, unsigned num_channels) + { + unsigned dead_card_index = alsa_pool.create_dead_card(name, info, num_channels); + return DeviceSpec{InputSourceType::ALSA_INPUT, dead_card_index}; + } + void set_display_name(DeviceSpec device_spec, const std::string &name); + // Note: The card should be held (currently this isn't enforced, though). + void serialize_device(DeviceSpec device_spec, DeviceSpecProto *device_spec_proto); + void set_input_mapping(const InputMapping &input_mapping); InputMapping get_input_mapping() const; diff --git a/input_mapping.cpp b/input_mapping.cpp new file mode 100644 index 0000000..4b28a59 --- /dev/null +++ b/input_mapping.cpp @@ -0,0 +1,189 @@ +#include +#include +#include + +#include +#include +#include + +#include "audio_mixer.h" +#include "input_mapping.h" +#include "state.pb.h" + +using namespace std; +using namespace google::protobuf; + +bool save_input_mapping_to_file(const map &devices, const InputMapping &input_mapping, const string &filename) +{ + InputMappingProto mapping_proto; + { + map used_devices; + for (const InputMapping::Bus &bus : input_mapping.buses) { + if (!used_devices.count(bus.device)) { + used_devices.emplace(bus.device, used_devices.size()); + global_audio_mixer->serialize_device(bus.device, mapping_proto.add_device()); + } + + BusProto *bus_proto = mapping_proto.add_bus(); + bus_proto->set_name(bus.name); + bus_proto->set_device_index(used_devices[bus.device]); + bus_proto->set_source_channel_left(bus.source_channel[0]); + bus_proto->set_source_channel_right(bus.source_channel[1]); + } + } + + // Save to disk. We use the text format because it's friendlier + // for a user to look at and edit. + int fd = open(filename.c_str(), O_WRONLY | O_TRUNC | O_CREAT, 0666); + if (fd == -1) { + perror(filename.c_str()); + return false; + } + io::FileOutputStream output(fd); // Takes ownership of fd. + if (!TextFormat::Print(mapping_proto, &output)) { + // TODO: Don't overwrite the old file (if any) on error. + output.Close(); + return false; + } + + output.Close(); + return true; +} + +bool load_input_mapping_from_file(const map &devices, const string &filename, InputMapping *new_mapping) +{ + // Read and parse the protobuf from disk. + int fd = open(filename.c_str(), O_RDONLY); + if (fd == -1) { + perror(filename.c_str()); + return false; + } + io::FileInputStream input(fd); // Takes ownership of fd. + InputMappingProto mapping_proto; + if (!TextFormat::Parse(&input, &mapping_proto)) { + input.Close(); + return false; + } + input.Close(); + + // Map devices in the proto to our current ones: + + // Get a list of all active devices. + set remaining_devices; + for (const auto &device_spec_and_info : devices) { + remaining_devices.insert(device_spec_and_info.first); + } + + // Now look at every device in the serialized protobuf and try to map + // it to one device we haven't taken yet. This isn't a full maximal matching, + // but it's good enough for our uses. + vector device_mapping; + for (unsigned device_index = 0; device_index < unsigned(mapping_proto.device_size()); ++device_index) { + const DeviceSpecProto &device_proto = mapping_proto.device(device_index); + switch (device_proto.type()) { + case DeviceSpecProto::SILENCE: + device_mapping.push_back(DeviceSpec{InputSourceType::SILENCE, 0}); + break; + case DeviceSpecProto::CAPTURE_CARD: { + // First see if there's a card that matches on both index and name. + DeviceSpec spec{InputSourceType::CAPTURE_CARD, unsigned(device_proto.index())}; + assert(devices.count(spec)); + const DeviceInfo &dev = devices.find(spec)->second; + if (remaining_devices.count(spec) && + dev.display_name == device_proto.display_name()) { + device_mapping.push_back(spec); + remaining_devices.erase(spec); + goto found_capture_card; + } + + // Scan and see if there's a match on name alone. + for (const DeviceSpec &spec : remaining_devices) { + if (spec.type == InputSourceType::CAPTURE_CARD && + dev.display_name == device_proto.display_name()) { + device_mapping.push_back(spec); + remaining_devices.erase(spec); + goto found_capture_card; + } + } + + // OK, see if at least the index is free. + if (remaining_devices.count(spec)) { + device_mapping.push_back(spec); + remaining_devices.erase(spec); + goto found_capture_card; + } + + // Give up. + device_mapping.push_back(DeviceSpec{InputSourceType::SILENCE, 0}); +found_capture_card: + break; + } + case DeviceSpecProto::ALSA_INPUT: { + // For ALSA, we don't really care about index, but we can use address + // in its place. + + // First see if there's a card that matches on name, num_channels and address. + for (const DeviceSpec &spec : remaining_devices) { + assert(devices.count(spec)); + const DeviceInfo &dev = devices.find(spec)->second; + if (spec.type == InputSourceType::ALSA_INPUT && + dev.alsa_name == device_proto.alsa_name() && + dev.alsa_info == device_proto.alsa_info() && + int(dev.num_channels) == device_proto.num_channels() && + dev.alsa_address == device_proto.address()) { + device_mapping.push_back(spec); + remaining_devices.erase(spec); + goto found_alsa_input; + } + } + + // Looser check: Ignore the address. + for (const DeviceSpec &spec : remaining_devices) { + assert(devices.count(spec)); + const DeviceInfo &dev = devices.find(spec)->second; + if (spec.type == InputSourceType::ALSA_INPUT && + dev.alsa_name == device_proto.alsa_name() && + dev.alsa_info == device_proto.alsa_info() && + int(dev.num_channels) == device_proto.num_channels()) { + device_mapping.push_back(spec); + remaining_devices.erase(spec); + goto found_alsa_input; + } + } + + // OK, so we couldn't map this to a device, but perhaps one is added + // at some point in the future through hotplug. Create a dead card + // matching this one; right now, it will give only silence, + // but it could be replaced with something later. + // + // NOTE: There's a potential race condition here, if the card + // gets inserted while we're doing the device remapping + // (or perhaps more realistically, while we're reading the + // input mapping from disk). + DeviceSpec dead_card_spec; + dead_card_spec = global_audio_mixer->create_dead_card( + device_proto.alsa_name(), device_proto.alsa_info(), device_proto.num_channels()); + device_mapping.push_back(dead_card_spec); + +found_alsa_input: + break; + } + default: + assert(false); + } + } + + for (const BusProto &bus_proto : mapping_proto.bus()) { + if (bus_proto.device_index() < 0 || unsigned(bus_proto.device_index()) >= device_mapping.size()) { + return false; + } + InputMapping::Bus bus; + bus.name = bus_proto.name(); + bus.device = device_mapping[bus_proto.device_index()]; + bus.source_channel[0] = bus_proto.source_channel_left(); + bus.source_channel[1] = bus_proto.source_channel_right(); + new_mapping->buses.push_back(bus); + } + + return true; +} diff --git a/input_mapping.h b/input_mapping.h index a1e90eb..10c3ca2 100644 --- a/input_mapping.h +++ b/input_mapping.h @@ -2,6 +2,7 @@ #define _INPUT_MAPPING_H 1 #include +#include #include #include @@ -46,4 +47,11 @@ struct InputMapping { std::vector buses; }; +bool save_input_mapping_to_file(const std::map &devices, + const InputMapping &mapping, + const std::string &filename); +bool load_input_mapping_from_file(const std::map &devices, + const std::string &filename, + InputMapping *mapping); + #endif // !defined(_INPUT_MAPPING_H) diff --git a/input_mapping_dialog.cpp b/input_mapping_dialog.cpp index 6fcf7a8..232a88d 100644 --- a/input_mapping_dialog.cpp +++ b/input_mapping_dialog.cpp @@ -4,6 +4,8 @@ #include "ui_input_mapping.h" #include +#include +#include using namespace std; using namespace std::placeholders; @@ -26,6 +28,8 @@ InputMappingDialog::InputMappingDialog() connect(ui->remove_button, &QPushButton::clicked, this, &InputMappingDialog::remove_clicked); connect(ui->up_button, &QPushButton::clicked, bind(&InputMappingDialog::updown_clicked, this, -1)); connect(ui->down_button, &QPushButton::clicked, bind(&InputMappingDialog::updown_clicked, this, 1)); + connect(ui->save_button, &QPushButton::clicked, this, &InputMappingDialog::save_clicked); + connect(ui->load_button, &QPushButton::clicked, this, &InputMappingDialog::load_clicked); update_button_state(); connect(ui->table, &QTableWidget::itemSelectionChanged, this, &InputMappingDialog::update_button_state); @@ -231,6 +235,37 @@ void InputMappingDialog::updown_clicked(int direction) ui->table->setRangeSelected(b_sel, true); } +void InputMappingDialog::save_clicked() +{ + QString filename = QFileDialog::getSaveFileName(this, + "Save input mapping", QString(), tr("Mapping files (*.mapping)")); + if (!filename.endsWith(".mapping")) { + filename += ".mapping"; + } + if (!save_input_mapping_to_file(devices, mapping, filename.toStdString())) { + QMessageBox box; + box.setText("Could not save mapping to '" + filename + "'. Check that you have the right permissions and try again."); + box.exec(); + } +} + +void InputMappingDialog::load_clicked() +{ + QString filename = QFileDialog::getOpenFileName(this, + "Load input mapping", QString(), tr("Mapping files (*.mapping)")); + InputMapping new_mapping; + if (!load_input_mapping_from_file(devices, filename.toStdString(), &new_mapping)) { + QMessageBox box; + box.setText("Could not load mapping from '" + filename + "'. Check that the file exists, has the right permissions and is valid."); + box.exec(); + return; + } + + mapping = new_mapping; + devices = global_audio_mixer->get_devices(); // New dead cards may have been made. + fill_ui_from_mapping(mapping); +} + void InputMappingDialog::update_button_state() { ui->add_button->setDisabled(mapping.buses.size() >= MAX_BUSES); diff --git a/input_mapping_dialog.h b/input_mapping_dialog.h index 0ca81d9..e24a801 100644 --- a/input_mapping_dialog.h +++ b/input_mapping_dialog.h @@ -35,6 +35,8 @@ private: void add_clicked(); void remove_clicked(); void updown_clicked(int direction); + void save_clicked(); + void load_clicked(); void update_button_state(); Ui::InputMappingDialog *ui; @@ -45,8 +47,7 @@ private: // held forever). InputMapping old_mapping; - std::map devices; - + std::map devices; // Needs no lock, accessed only on the UI thread. AudioMixer::state_changed_callback_t saved_callback; }; diff --git a/state.proto b/state.proto new file mode 100644 index 0000000..8ea6b97 --- /dev/null +++ b/state.proto @@ -0,0 +1,35 @@ +// Used to serialize state between runs. Currently only audio input mappings, +// but in theory we could do the entire mix, video inputs, etc. + +syntax = "proto2"; + +// Similar to DeviceSpec, but only devices that are used are stored, +// and contains additional information that will help us try to map +// to the right device even if the devices have moved around. +message DeviceSpecProto { + // Members from DeviceSpec itself. + enum InputSourceType { SILENCE = 0; CAPTURE_CARD = 1; ALSA_INPUT = 2; }; + optional InputSourceType type = 1; + optional int32 index = 2; + + // Additional information. + optional string display_name = 3; + optional string alsa_name = 4; // Only for ALSA devices. + optional string alsa_info = 5; // Only for ALSA devices. + optional int32 num_channels = 6; // Only for ALSA devices. + optional string address = 7; // Only for ALSA devices. +} + +// Corresponds to InputMapping::Bus. +message BusProto { + optional string name = 1; + optional int32 device_index = 2; // Index into the "devices" array. + optional int32 source_channel_left = 3; + optional int32 source_channel_right = 4; +} + +// Corresponds to InputMapping. +message InputMappingProto { + repeated DeviceSpecProto device = 1; + repeated BusProto bus = 2; +} diff --git a/ui_input_mapping.ui b/ui_input_mapping.ui index 7b45338..4487b94 100644 --- a/ui_input_mapping.ui +++ b/ui_input_mapping.ui @@ -39,7 +39,7 @@ - + @@ -86,19 +86,6 @@ - - - - Qt::Horizontal - - - - 40 - 20 - - - - @@ -133,6 +120,33 @@ + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + &Save… + + + + + + + &Load… + + + -- 2.39.2