From 202a0a8a0a4e60074653a7ca74b73354f81ad468 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sat, 25 Apr 2020 20:53:49 +0200 Subject: [PATCH] Imported existing sources --- .gitmodules | 3 + DrumMachine.pro | 53 ++++++ filesmodel.cpp | 129 ++++++++++++++ filesmodel.h | 31 ++++ jsonconverters.cpp | 396 +++++++++++++++++++++++++++++++++++++++++ jsonconverters.h | 33 ++++ main.cpp | 85 +++++++++ mainwindow.cpp | 123 +++++++++++++ mainwindow.h | 45 +++++ mainwindow.ui | 228 ++++++++++++++++++++++++ midicontainers.cpp | 23 +++ midicontainers.h | 31 ++++ midiinwrapper.cpp | 73 ++++++++ midiinwrapper.h | 34 ++++ presetdetailwidget.cpp | 16 ++ presetdetailwidget.h | 22 +++ presetdetailwidget.ui | 306 +++++++++++++++++++++++++++++++ presets.cpp | 15 ++ presets.h | 84 +++++++++ presetsmodel.cpp | 208 ++++++++++++++++++++++ presetsmodel.h | 27 +++ resources.qrc | 5 + rtmidi | 1 + sampleswidget.cpp | 172 ++++++++++++++++++ sampleswidget.h | 45 +++++ sampleswidget.ui | 253 ++++++++++++++++++++++++++ samplewidget.cpp | 192 ++++++++++++++++++++ samplewidget.h | 54 ++++++ samplewidget.ui | 108 +++++++++++ sequencerwidget.cpp | 148 +++++++++++++++ sequencerwidget.h | 44 +++++ sequencerwidget.ui | 78 ++++++++ splashscreen.png | Bin 0 -> 17884 bytes 33 files changed, 3065 insertions(+) create mode 100644 .gitmodules create mode 100755 DrumMachine.pro create mode 100755 filesmodel.cpp create mode 100755 filesmodel.h create mode 100755 jsonconverters.cpp create mode 100755 jsonconverters.h create mode 100755 main.cpp create mode 100755 mainwindow.cpp create mode 100755 mainwindow.h create mode 100755 mainwindow.ui create mode 100755 midicontainers.cpp create mode 100755 midicontainers.h create mode 100755 midiinwrapper.cpp create mode 100755 midiinwrapper.h create mode 100755 presetdetailwidget.cpp create mode 100755 presetdetailwidget.h create mode 100755 presetdetailwidget.ui create mode 100755 presets.cpp create mode 100755 presets.h create mode 100755 presetsmodel.cpp create mode 100755 presetsmodel.h create mode 100644 resources.qrc create mode 160000 rtmidi create mode 100755 sampleswidget.cpp create mode 100755 sampleswidget.h create mode 100755 sampleswidget.ui create mode 100755 samplewidget.cpp create mode 100755 samplewidget.h create mode 100755 samplewidget.ui create mode 100755 sequencerwidget.cpp create mode 100755 sequencerwidget.h create mode 100755 sequencerwidget.ui create mode 100644 splashscreen.png diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ae3ece6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "rtmidi"] + path = rtmidi + url = git@github.com:thestk/rtmidi.git diff --git a/DrumMachine.pro b/DrumMachine.pro new file mode 100755 index 0000000..6156b5c --- /dev/null +++ b/DrumMachine.pro @@ -0,0 +1,53 @@ +QT = core multimedia gui widgets network + +CONFIG += c++17 + +release: QMAKE_CXXFLAGS_RELEASE -= -O1 +release: QMAKE_CXXFLAGS_RELEASE -= -O2 +release: QMAKE_CXXFLAGS_RELEASE += -O3 -ffast-math -march=native -mtune=native + +win32: { + DEFINES += __WINDOWS_MM__ + LIBS += -lwinmm +} + +DEFINES += QT_DEPRECATED_WARNINGS QT_DISABLE_DEPRECATED_BEFORE=0x060000 + +SOURCES += \ + filesmodel.cpp \ + jsonconverters.cpp \ + main.cpp \ + mainwindow.cpp \ + midicontainers.cpp \ + midiinwrapper.cpp \ + presetdetailwidget.cpp \ + presets.cpp \ + presetsmodel.cpp \ + rtmidi/RtMidi.cpp \ + sampleswidget.cpp \ + samplewidget.cpp \ + sequencerwidget.cpp + +HEADERS += \ + filesmodel.h \ + jsonconverters.h \ + mainwindow.h \ + midicontainers.h \ + midiinwrapper.h \ + presetdetailwidget.h \ + presets.h \ + presetsmodel.h \ + rtmidi/RtMidi.h \ + sampleswidget.h \ + samplewidget.h \ + sequencerwidget.h + +FORMS += \ + mainwindow.ui \ + presetdetailwidget.ui \ + sampleswidget.ui \ + samplewidget.ui \ + sequencerwidget.ui + +RESOURCES += \ + resources.qrc diff --git a/filesmodel.cpp b/filesmodel.cpp new file mode 100755 index 0000000..5fc8a4a --- /dev/null +++ b/filesmodel.cpp @@ -0,0 +1,129 @@ +#include "filesmodel.h" + +#include + +#include +#include + +enum { + ColumnFilename, + ColumnColor, + ColumnStopOnRelease, + ColumnLooped, + ColumnChoke, + NumberOfColumns +}; + +FilesModel::~FilesModel() = default; + +const presets::File &FilesModel::getFile(const QModelIndex &index) const +{ + return getFile(index.row()); +} + +const presets::File &FilesModel::getFile(int row) const +{ + return m_files->at(row); +} + +void FilesModel::setPreset(const presets::Preset &preset) +{ + beginResetModel(); + m_files = preset.files; + endResetModel(); +} + +int FilesModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + + if (!m_files) + return 0; + + return std::size(*m_files); +} + +int FilesModel::columnCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + + return NumberOfColumns; +} + +QVariant FilesModel::data(const QModelIndex &index, int role) const +{ + if (role != Qt::DisplayRole && role != Qt::EditRole && role != Qt::FontRole && role != Qt::ForegroundRole) + return {}; + + if (!m_files) + return {}; + if (index.column() < 0) + return {}; + if (index.column() >= NumberOfColumns) + return {}; + if (index.row() < 0) + return {}; + if (index.row() >= std::size(*m_files)) + return {}; + + const auto &file = getFile(index); + + const auto handleData = [&](const auto &val) -> QVariant + { + if (!val) + { + if (role == Qt::DisplayRole) + return this->tr("(null)"); + else if (role == Qt::FontRole) + { + QFont font; + font.setItalic(true); + return font; + } + else if (role == Qt::ForegroundRole) + return QColor{Qt::gray}; + return {}; + } + + if (role == Qt::DisplayRole || role == Qt::EditRole) + return *val; + + return {}; + }; + + switch (index.column()) + { + case ColumnFilename: return handleData(file.filename); + case ColumnColor: return handleData(file.color); + case ColumnStopOnRelease: return handleData(file.stopOnRelease); + case ColumnLooped: return handleData(file.looped); + case ColumnChoke: return handleData(file.choke); + } + + Q_UNREACHABLE(); +} + +QVariant FilesModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (role != Qt::DisplayRole && role != Qt::EditRole) + return {}; + + if (orientation != Qt::Horizontal) + return {}; + + if (section < 0) + return {}; + if (section >= NumberOfColumns) + return {}; + + switch (section) + { + case ColumnFilename: return tr("filename"); + case ColumnColor: return tr("color"); + case ColumnStopOnRelease: return tr("stopOnRelease"); + case ColumnLooped: return tr("looped"); + case ColumnChoke: return tr("choke"); + } + + Q_UNREACHABLE(); +} diff --git a/filesmodel.h b/filesmodel.h new file mode 100755 index 0000000..677022d --- /dev/null +++ b/filesmodel.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include + +#include "presets.h" + +namespace presets { class Preset; class File; } + +class FilesModel : public QAbstractTableModel +{ + Q_OBJECT + +public: + using QAbstractTableModel::QAbstractTableModel; + ~FilesModel() override; + + const presets::File &getFile(const QModelIndex &index) const; + const presets::File &getFile(int row) const; + + void setPreset(const presets::Preset &preset); + + int rowCount(const QModelIndex &parent) const override; + int columnCount(const QModelIndex &parent) const override; + QVariant data(const QModelIndex &index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + +private: + std::optional> m_files; +}; diff --git a/jsonconverters.cpp b/jsonconverters.cpp new file mode 100755 index 0000000..31ea86f --- /dev/null +++ b/jsonconverters.cpp @@ -0,0 +1,396 @@ +#include "jsonconverters.h" + +#include + +#include + +namespace json_converters +{ +QJsonObject loadJson(const QByteArray &buffer) +{ + QJsonParseError error; + QJsonDocument document{QJsonDocument::fromJson(buffer, &error)}; + if (error.error != QJsonParseError::NoError) + throw std::runtime_error{QString{"Could not parse JSON because %0"}.arg(error.errorString()).toStdString()}; + + if (!document.isObject()) + throw std::runtime_error{"JSON is not an object"}; + + return document.object(); +} + +QString parseString(const QJsonValue &jsonValue) +{ + if (!jsonValue.isString()) + throw std::runtime_error{"json value for string is not a string"}; + + return jsonValue.toString(); +} + +std::vector parseStringVector(const QJsonValue &jsonValue) +{ + if (!jsonValue.isArray()) + throw std::runtime_error{"json value for string vector is not an array"}; + + std::vector vector; + + for (const auto &jsonValue : jsonValue.toArray()) + vector.emplace_back(parseString(jsonValue)); + + return vector; +} + +int parseInt(const QJsonValue &jsonValue) +{ + if (!jsonValue.isDouble()) + throw std::runtime_error{"json value for int is not a double"}; + + return jsonValue.toDouble(); +} + +bool parseBool(const QJsonValue &jsonValue) +{ + if (!jsonValue.isBool()) + throw std::runtime_error{"json value for bool is not a bool"}; + + return jsonValue.toBool(); +} + +std::vector parseIntVector(const QJsonValue &jsonValue) +{ + if (!jsonValue.isArray()) + throw std::runtime_error{"json value for int vector is not an array"}; + + std::vector vector; + + for (const auto &jsonValue : jsonValue.toArray()) + vector.emplace_back(parseInt(jsonValue)); + + return vector; +} + +presets::PresetsConfig parsePresetsConfig(const QJsonObject &jsonObj) +{ + presets::PresetsConfig presetConfig; + + for (auto iter = std::cbegin(jsonObj); iter != std::cend(jsonObj); iter++) + { + if (iter.key() == "categories") + presetConfig.categories = parseCategoryVector(iter.value()); + else if (iter.key() == "presets") + presetConfig.presets = parsePresetMap(iter.value()); + else + throw std::runtime_error{QString{"unknown key %0 for PresetConfig"}.arg(iter.key()).toStdString()}; + } + + return presetConfig; +} + +std::vector parseCategoryVector(const QJsonValue &jsonValue) +{ + if (!jsonValue.isArray()) + throw std::runtime_error{"json value for vector of Category is not an array"}; + + std::vector vector; + + for (const auto &jsonValue : jsonValue.toArray()) + vector.emplace_back(parseCategory(jsonValue)); + + return vector; +} + +std::map parsePresetMap(const QJsonValue &jsonValue) +{ + if (!jsonValue.isObject()) + throw std::runtime_error{"json value for Preset map is not an object"}; + + const auto jsonObj = jsonValue.toObject(); + + std::map map; + + for (auto iter = std::cbegin(jsonObj); iter != std::cend(jsonObj); iter++) + map[iter.key()] = parsePreset(iter.value()); + + return map; +} + +presets::Category parseCategory(const QJsonValue &jsonValue) +{ + if (!jsonValue.isObject()) + throw std::runtime_error{"json value for Category is not an object"}; + + const auto jsonObj = jsonValue.toObject(); + + presets::Category category; + + for (auto iter = std::cbegin(jsonObj); iter != std::cend(jsonObj); iter++) + { + if (iter.key() == "title") + category.title = parseString(iter.value()); + else if (iter.key() == "filter") + category.filter = parseFilter(iter.value()); + else + throw std::runtime_error{QString{"unknown key %0 for Category"}.arg(iter.key()).toStdString()}; + } + + return category; +} + +presets::Filter parseFilter(const QJsonValue &jsonValue) +{ + if (!jsonValue.isObject()) + throw std::runtime_error{"json value for Filters is not an object"}; + + const auto jsonObj = jsonValue.toObject(); + + presets::Filter filters; + + for (auto iter = std::cbegin(jsonObj); iter != std::cend(jsonObj); iter++) + { + if (iter.key() == "tags") + filters.tags = parseStringVector(iter.value()); + else + throw std::runtime_error{QString{"unknown key %0 for Filters"}.arg(iter.key()).toStdString()}; + } + + return filters; +} + +presets::Preset parsePreset(const QJsonValue &jsonValue) +{ + if (!jsonValue.isObject()) + throw std::runtime_error{"json value for Preset is not an object"}; + + const auto jsonObj = jsonValue.toObject(); + + presets::Preset preset; + + for (auto iter = std::cbegin(jsonObj); iter != std::cend(jsonObj); iter++) + { + const auto key = iter.key(); + if (key == "id") + preset.id = parseString(iter.value()); + else if (key == "name") + preset.name = parseString(iter.value()); + else if (key == "author") + preset.author = parseString(iter.value()); + else if (key == "orderBy") + preset.orderBy = parseString(iter.value()); + else if (key == "version") + preset.version = parseString(iter.value()); + else if (key == "tempo") + preset.tempo = parseInt(iter.value()); + else if (key == "icon") + preset.icon = parseString(iter.value()); + else if (key == "price") + preset.price = parseInt(iter.value()); + else if (key == "priceForSession") + preset.priceForSession = parseInt(iter.value()); + else if (key == "hasInfo") + preset.hasInfo = parseBool(iter.value()); + else if (key == "tags") + preset.tags = parseStringVector(iter.value()); + else if (key == "DELETED") + preset.DELETED = parseBool(iter.value()); + else if (key == "difficulty") + preset.difficulty = parseInt(iter.value()); + else if (key == "sample") + preset.sample = parseInt(iter.value()); + else if (key == "audioPreview1Name") + preset.audioPreview1Name = parseString(iter.value()); + else if (key == "audioPreview1URL") + preset.audioPreview1URL = parseString(iter.value()); + else if (key == "audioPreview2Name") + preset.audioPreview2Name = parseString(iter.value()); + else if (key == "audioPreview2URL") + preset.audioPreview2URL = parseString(iter.value()); + else if (key == "imagePreview1") + preset.imagePreview1 = parseString(iter.value()); + else if (key == "videoPreview") + preset.videoPreview = parseString(iter.value()); + else if (key == "videoTutorial") + preset.videoTutorial = parseString(iter.value()); + else if (key == "files") + preset.files = parseFileArray(iter.value()); + else if (key == "beatSchool") + preset.beatSchool = parseSequenceVectorMap(iter.value()); + else if (key == "easyPlay") + preset.easyPlay = parseSequenceVectorMap(iter.value()); + else if (key == "middleDescription") + {} + else + throw std::runtime_error{QString{"unknown key %0 for Preset"}.arg(key).toStdString()}; + } + + return preset; +} + +std::array parseFileArray(const QJsonValue &jsonValue) +{ + if (!jsonValue.isObject()) + throw std::runtime_error{"json value for File array is not an object"}; + + const auto jsonObj = jsonValue.toObject(); + + if (jsonObj.size() != 24) + throw std::runtime_error{"json value for File array doesn't have exactly 24 entries"}; + + std::array array; + + for (auto iter = std::cbegin(jsonObj); iter != std::cend(jsonObj); iter++) + { + bool ok; + const auto index = iter.key().toInt(&ok); + + if (!ok || index < 1 || index >= 25) + throw std::runtime_error{QString{"unknown key %0 for File"}.arg(iter.key()).toStdString()}; + + array[index - 1] = parseFile(iter.value()); + } + + return array; +} + +presets::File parseFile(const QJsonValue &jsonValue) +{ + if (!jsonValue.isObject()) + throw std::runtime_error{"json value for File is not an object"}; + + const auto jsonObj = jsonValue.toObject(); + + presets::File file; + + for (auto iter = std::cbegin(jsonObj); iter != std::cend(jsonObj); iter++) + { + const auto key = iter.key(); + if (key == "filename") + file.filename = parseString(iter.value()); + else if (key == "color") + file.color = parseString(iter.value()); + else if (key == "stopOnRelease") + file.stopOnRelease = parseString(iter.value()); + else if (key == "looped") + file.looped = parseBool(iter.value()); + else if (key == "choke") + file.choke = parseInt(iter.value()); + else + throw std::runtime_error{QString{"unknown key %0 for File"}.arg(key).toStdString()}; + } + + return file; +} + +std::vector parseSequenceVector(const QJsonValue &jsonValue) +{ + if (!jsonValue.isArray()) + throw std::runtime_error{"json value for vector of Sequence is not an array"}; + + std::vector vector; + + for (const auto &jsonValue : jsonValue.toArray()) + vector.emplace_back(parseSequence(jsonValue)); + + return vector; +} + +presets::Sequence parseSequence(const QJsonValue &jsonValue) +{ + if (!jsonValue.isObject()) + throw std::runtime_error{"json value for File is not an object"}; + + const auto jsonObj = jsonValue.toObject(); + + presets::Sequence sequence; + + for (auto iter = std::cbegin(jsonObj); iter != std::cend(jsonObj); iter++) + { + const auto key = iter.key(); + if (key == "name") + sequence.name = parseString(iter.value()); + else if (key == "id") + sequence.id = parseInt(iter.value()); + else if (key == "version") + sequence.version = parseInt(iter.value()); + else if (key == "orderBy") + sequence.orderBy = parseInt(iter.value()); + else if (key == "sequencerSize") + sequence.sequencerSize = parseInt(iter.value()); + else if (key == "pads") + sequence.pads = parseSequencePadVectorMap(iter.value()); + else if (key == "embientPads") + sequence.embientPads = parseIntVector(iter.value()); + else + throw std::runtime_error{QString{"unknown key %0 for Sequence"}.arg(key).toStdString()}; + } + + return sequence; +} + +std::map> parseSequenceVectorMap(const QJsonValue &jsonValue) +{ + if (!jsonValue.isObject()) + throw std::runtime_error{"json value for Sequence vector map is not an object"}; + + const auto jsonObj = jsonValue.toObject(); + + std::map> map; + + for (auto iter = std::cbegin(jsonObj); iter != std::cend(jsonObj); iter++) + map[iter.key()] = parseSequenceVector(iter.value()); + + return map; +} + +presets::SequencePad parseSequencePad(const QJsonValue &jsonValue) +{ + if (!jsonValue.isObject()) + throw std::runtime_error{"json value for File is not an object"}; + + const auto jsonObj = jsonValue.toObject(); + + presets::SequencePad sequencePad; + + for (auto iter = std::cbegin(jsonObj); iter != std::cend(jsonObj); iter++) + { + if (iter.key() == "start") + sequencePad.start = parseInt(iter.value()); + else if (iter.key() == "duration") + sequencePad.duration = parseInt(iter.value()); + else if (iter.key() == "embient") + sequencePad.embient = parseBool(iter.value()); + else + throw std::runtime_error{QString{"unknown key %0 for Sequence"}.arg(iter.key()).toStdString()}; + } + + return sequencePad; +} + +std::vector parseSequencePadVector(const QJsonValue &jsonValue) +{ + if (!jsonValue.isArray()) + throw std::runtime_error{"json value for vector of SequencePad is not an array"}; + + std::vector vector; + + for (const auto &jsonValue : jsonValue.toArray()) + vector.emplace_back(parseSequencePad(jsonValue)); + + return vector; +} + +std::map> parseSequencePadVectorMap(const QJsonValue &jsonValue) +{ + if (!jsonValue.isObject()) + throw std::runtime_error{"json value for SequencePad vector map is not an object"}; + + const auto jsonObj = jsonValue.toObject(); + + std::map> map; + + for (auto iter = std::cbegin(jsonObj); iter != std::cend(jsonObj); iter++) + map[iter.key()] = parseSequencePadVector(iter.value()); + + return map; +} + +} diff --git a/jsonconverters.h b/jsonconverters.h new file mode 100755 index 0000000..9470b94 --- /dev/null +++ b/jsonconverters.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +#include "presets.h" + +namespace json_converters +{ +QJsonObject loadJson(const QByteArray &buffer); + +QString parseString(const QJsonValue &jsonValue); +std::vector parseStringVector(const QJsonValue &jsonValue); +int parseInt(const QJsonValue &jsonValue); +bool parseBool(const QJsonValue &jsonValue); +std::vector parseIntVector(const QJsonValue &jsonValue); + +presets::PresetsConfig parsePresetsConfig(const QJsonObject &jsonObj); +std::vector parseCategoryVector(const QJsonValue &jsonValue); +std::map parsePresetMap(const QJsonValue &jsonValue); +presets::Category parseCategory(const QJsonValue &jsonValue); +presets::Filter parseFilter(const QJsonValue &jsonValue); +presets::Preset parsePreset(const QJsonValue &jsonValue); +std::array parseFileArray(const QJsonValue &jsonValue); +presets::File parseFile(const QJsonValue &jsonValue); +std::vector parseSequenceVector(const QJsonValue &jsonValue); +presets::Sequence parseSequence(const QJsonValue &jsonValue); +std::map> parseSequenceVectorMap(const QJsonValue &jsonValue); +presets::SequencePad parseSequencePad(const QJsonValue &jsonValue); +std::vector parseSequencePadVector(const QJsonValue &jsonValue); +std::map> parseSequencePadVectorMap(const QJsonValue &jsonValue); +} diff --git a/main.cpp b/main.cpp new file mode 100755 index 0000000..8dfa0ac --- /dev/null +++ b/main.cpp @@ -0,0 +1,85 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "jsonconverters.h" +#include "mainwindow.h" + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + QCoreApplication::setOrganizationDomain("brunner.ninja"); + QCoreApplication::setOrganizationName("brunner.ninja"); + QCoreApplication::setApplicationName("miditest"); + QCoreApplication::setApplicationVersion("1.0"); + + qDebug() << "supportsSsl" << QSslSocket::supportsSsl(); + qDebug() << "sslLibraryVersionString" << QSslSocket::sslLibraryVersionString(); + qDebug() << "sslLibraryBuildVersionString" << QSslSocket::sslLibraryBuildVersionString(); + + qSetMessagePattern("%{time dd.MM.yyyy HH:mm:ss.zzz} " + "[" + "%{if-debug}D%{endif}" + "%{if-info}I%{endif}" + "%{if-warning}W%{endif}" + "%{if-critical}C%{endif}" + "%{if-fatal}F%{endif}" + "] " + "%{function}(): " + "%{message}"); + + presets::PresetsConfig presetsConfig; + + { + QSplashScreen splashScreen{QPixmap{":/drummachine/splashscreen.png"}}; + splashScreen.showMessage(QCoreApplication::translate("main", "Loading list of presets...")); + splashScreen.show(); + + QEventLoop eventLoop; + + QNetworkAccessManager manager; + const auto reply = std::unique_ptr(manager.get(QNetworkRequest{QUrl{"https://brunner.ninja/komposthaufen/dpm/presets_config_v12.json"}})); + QObject::connect(reply.get(), &QNetworkReply::finished, &eventLoop, &QEventLoop::quit); + eventLoop.exec(); + + if (reply->error() != QNetworkReply::NoError) + { + QMessageBox::warning(nullptr, QCoreApplication::translate("main", "Could not load presets!"), QCoreApplication::translate("main", "Could not load presets!") + "\n\n" + reply->errorString()); + return 1; + } + + presetsConfig = json_converters::parsePresetsConfig(json_converters::loadJson(reply->readAll())); + } + +#if !defined(Q_OS_WIN) + QPalette darkPalette; + darkPalette.setColor(QPalette::Window, QColor(53,53,53)); + darkPalette.setColor(QPalette::WindowText, Qt::white); + darkPalette.setColor(QPalette::Base, QColor(25,25,25)); + darkPalette.setColor(QPalette::AlternateBase, QColor(53,53,53)); + darkPalette.setColor(QPalette::ToolTipBase, Qt::white); + darkPalette.setColor(QPalette::ToolTipText, Qt::white); + darkPalette.setColor(QPalette::Text, Qt::white); + darkPalette.setColor(QPalette::Button, QColor(53,53,53)); + darkPalette.setColor(QPalette::ButtonText, Qt::white); + darkPalette.setColor(QPalette::BrightText, Qt::red); + darkPalette.setColor(QPalette::Link, QColor(42, 130, 218)); + darkPalette.setColor(QPalette::Highlight, QColor(42, 130, 218)); + darkPalette.setColor(QPalette::HighlightedText, Qt::black); + app.setPalette(darkPalette); + + app.setStyleSheet("QToolTip { color: #ffffff; background-color: #2a82da; border: 1px solid white; }"); +#endif + + MainWindow mainWindow{presetsConfig}; + mainWindow.showMaximized(); + mainWindow.selectFirstPreset(); + + return app.exec(); +} diff --git a/mainwindow.cpp b/mainwindow.cpp new file mode 100755 index 0000000..5602e99 --- /dev/null +++ b/mainwindow.cpp @@ -0,0 +1,123 @@ +#include "mainwindow.h" +#include "ui_mainwindow.h" + +#include +#include +#include +#include +#include + +#include "presets.h" +#include "midiinwrapper.h" +#include "midicontainers.h" + +MainWindow::MainWindow(const presets::PresetsConfig &presetsConfig, QWidget *parent) : + QMainWindow{parent}, + m_ui{std::make_unique()}, + m_presetsModel{*presetsConfig.presets} +{ + m_ui->setupUi(this); + + connect(&m_midiIn, &MidiInWrapper::messageReceived, this, &MainWindow::messageReceived); + + updateMidiDevices(); + + connect(m_ui->pushButtonMidiController, &QAbstractButton::pressed, this, [this](){ + if (m_midiIn.isPortOpen()) + m_midiIn.closePort(); + else + { + const auto index = m_ui->comboBoxMidiController->currentIndex(); + if (index != -1) + m_midiIn.openPort(index); + } + + m_ui->pushButtonMidiController->setText(m_midiIn.isPortOpen() ? tr("Close") : tr("Open")); + }); + + updateAudioDevices(); + + { + const auto index = m_devices.indexOf(QAudioDeviceInfo::defaultOutputDevice()); + if (index != -1) + m_ui->comboBoxAudioDevice->setCurrentIndex(index); + } + + { + const auto callback = [this](int index){ + m_ui->samplesWidget->setAudioDevice(m_devices.at(index)); + }; + connect(m_ui->comboBoxAudioDevice, qOverload(&QComboBox::currentIndexChanged), m_ui->samplesWidget, callback); + callback(m_ui->comboBoxAudioDevice->currentIndex()); + } + + m_presetsProxyModel.setFilterCaseSensitivity(Qt::CaseInsensitive); + m_presetsProxyModel.setSourceModel(&m_presetsModel); + m_ui->presetsView->setModel(&m_presetsProxyModel); + + m_presetsProxyModel.setFilterKeyColumn(1); + + connect(m_ui->lineEdit, &QLineEdit::textChanged, this, [=](){ + m_presetsProxyModel.setFilterFixedString(m_ui->lineEdit->text()); + }); + + m_ui->filesView->setModel(&m_filesModel); + + connect(m_ui->presetsView->selectionModel(), &QItemSelectionModel::currentRowChanged, this, &MainWindow::currentRowChanged); +} + +MainWindow::~MainWindow() = default; + +void MainWindow::selectFirstPreset() +{ + if (m_presetsProxyModel.rowCount()) + { + const auto index = m_presetsProxyModel.index(0, 0); + if (index.isValid()) + { + m_ui->presetsView->selectionModel()->select(index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + currentRowChanged(index); + } + } +} + +void MainWindow::messageReceived(const midi::MidiMessage &message) +{ + m_ui->statusbar->showMessage(tr("Received midi message: flag: %0 cmd: %1 channel: %2 note: %3 velocity: %4") + .arg(message.flag?"true":"false", QMetaEnum::fromType().valueToKey(int(message.cmd))) + .arg(message.channel).arg(message.note).arg(message.velocity), 1000); + + m_ui->samplesWidget->messageReceived(message); +} + +void MainWindow::currentRowChanged(const QModelIndex ¤t) +{ + if (!current.isValid()) + return; + + const auto &preset = m_presetsModel.getPreset(m_presetsProxyModel.mapToSource(current)); + + m_ui->presetDetailWidget->setPreset(preset); + m_filesModel.setPreset(preset); + m_ui->samplesWidget->setPreset(preset); +} + +void MainWindow::updateMidiDevices() +{ + m_ui->comboBoxMidiController->clear(); + + for (const auto &name : m_midiIn.portNames()) + m_ui->comboBoxMidiController->addItem(name); + + m_ui->pushButtonMidiController->setEnabled(m_ui->comboBoxMidiController->count() > 0); +} + +void MainWindow::updateAudioDevices() +{ + m_ui->comboBoxAudioDevice->clear(); + + m_devices = QAudioDeviceInfo::availableDevices(QAudio::AudioOutput); + + for (const auto &device : m_devices) + m_ui->comboBoxAudioDevice->addItem(device.deviceName()); +} diff --git a/mainwindow.h b/mainwindow.h new file mode 100755 index 0000000..ed71c92 --- /dev/null +++ b/mainwindow.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +#include +#include + +#include "presetsmodel.h" +#include "filesmodel.h" +#include "midiinwrapper.h" + +namespace Ui { class MainWindow; } +namespace presets { struct PresetsConfig; } +namespace midi { struct MidiMessage; } +class QAudioDeviceInfo; + +class MainWindow : public QMainWindow +{ + Q_OBJECT + +public: + explicit MainWindow(const presets::PresetsConfig &presetsConfig, QWidget *parent = nullptr); + ~MainWindow() override; + + void selectFirstPreset(); + +private slots: + void messageReceived(const midi::MidiMessage &message); + void currentRowChanged(const QModelIndex ¤t); + +private: + void updateMidiDevices(); + void updateAudioDevices(); + + const std::unique_ptr m_ui; + + MidiInWrapper m_midiIn; + + QList m_devices; + + PresetsModel m_presetsModel; + QSortFilterProxyModel m_presetsProxyModel; + + FilesModel m_filesModel; +}; diff --git a/mainwindow.ui b/mainwindow.ui new file mode 100755 index 0000000..ca925ae --- /dev/null +++ b/mainwindow.ui @@ -0,0 +1,228 @@ + + + MainWindow + + + + 0 + 0 + 1017 + 712 + + + + MainWindow + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + <b>UltraDj</b> + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Master: + + + horizontalSliderMaster + + + + + + + + 100 + 16777215 + + + + 100 + + + 100 + + + Qt::Horizontal + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Midi in: + + + + + + + + + + Open + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + Qt::Horizontal + + + + Qt::Vertical + + + + + + + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Select random + + + + + + + + + false + + + true + + + + + + + + Qt::Horizontal + + + + + false + + + + + + + + + + + + + 0 + 0 + 1017 + 20 + + + + + + + + SamplesWidget + QWidget +
sampleswidget.h
+ 1 +
+ + PresetDetailWidget + QScrollArea +
presetdetailwidget.h
+ 1 +
+
+ + +
diff --git a/midicontainers.cpp b/midicontainers.cpp new file mode 100755 index 0000000..025eebd --- /dev/null +++ b/midicontainers.cpp @@ -0,0 +1,23 @@ +#include "midicontainers.h" + +#include + +namespace midi { +bool MidiMessage::operator==(const MidiMessage &other) const +{ + return channel == other.channel && + cmd == other.cmd && + flag == other.flag && + note == other.note && + velocity == other.velocity; +} +} + +namespace { +void registerMidiMessageMetatype() +{ + qRegisterMetaType(); +} + +Q_COREAPP_STARTUP_FUNCTION(registerMidiMessageMetatype) +} diff --git a/midicontainers.h b/midicontainers.h new file mode 100755 index 0000000..f6bf8a7 --- /dev/null +++ b/midicontainers.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace midi { +Q_NAMESPACE + +enum class Command : uint8_t { + NoteOff, + NoteOn, + PolyphonicKeyPressure, + ControlChange, + ProgramChange, + ChannelPressure, + PitchBendChange +}; +Q_ENUM_NS(Command) + +struct MidiMessage +{ + uint8_t channel:4; + Command cmd:3; + bool flag:1; + uint8_t note; + uint8_t velocity; + + bool operator==(const MidiMessage &other) const; +}; +} + +Q_DECLARE_METATYPE(midi::MidiMessage) diff --git a/midiinwrapper.cpp b/midiinwrapper.cpp new file mode 100755 index 0000000..969f98a --- /dev/null +++ b/midiinwrapper.cpp @@ -0,0 +1,73 @@ +#include "midiinwrapper.h" + +#include +#include +#include + +MidiInWrapper::MidiInWrapper(RtMidi::Api api, const QString& clientName, unsigned int queueSizeLimit, QObject *parent) : + QObject{parent}, + midiIn{api, clientName.toStdString(), queueSizeLimit} +{ + midiIn.ignoreTypes(false, false, false); + midiIn.setCallback(&mycallback, this); +} + +void MidiInWrapper::openPort(unsigned int portNumber) +{ + midiIn.openPort(portNumber); +} + +void MidiInWrapper::openVirtualPort(const QString &portName) +{ + midiIn.openVirtualPort(portName.toStdString()); +} + +void MidiInWrapper::closePort() +{ + midiIn.closePort(); +} + +bool MidiInWrapper::isPortOpen() const +{ + return midiIn.isPortOpen(); +} + +QStringList MidiInWrapper::portNames() +{ + QStringList names; + + const auto count = midiIn.getPortCount(); + + for (unsigned int i = 0; i < count; i++) + names.append(QString::fromStdString(midiIn.getPortName(i))); + + return names; +} + +void MidiInWrapper::mycallback(double deltatime, std::vector *message, void *userData) +{ + Q_UNUSED(deltatime) + + if (!userData) + { + qCritical() << "called without userData pointer to wrapper"; + return; + } + + auto wrapper = static_cast(userData); + + if (!message) + { + qCritical() << "called without message pointer"; + return; + } + + if (message->size() < sizeof(midi::MidiMessage)) + { + qCritical() << "called with message that is shorter than 3 bytes"; + return; + } + + const midi::MidiMessage &midiMessage = reinterpret_cast(message->at(0)); + wrapper->messageReceived(midiMessage); +} diff --git a/midiinwrapper.h b/midiinwrapper.h new file mode 100755 index 0000000..d7423bc --- /dev/null +++ b/midiinwrapper.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +#include "rtmidi/RtMidi.h" + +#include "midicontainers.h" + +class MidiInWrapper : public QObject +{ + Q_OBJECT + +public: + MidiInWrapper(RtMidi::Api api = RtMidi::UNSPECIFIED, + const QString &clientName = "RtMidi Input Client", + unsigned int queueSizeLimit = 100, + QObject *parent = nullptr); + + void openPort(unsigned int portNumber); + void openVirtualPort(const QString &portName); + void closePort(); + bool isPortOpen() const; + + QStringList portNames(); + +signals: + void messageReceived(const midi::MidiMessage &message); + +private: + static void mycallback(double deltatime, std::vector *message, void *userData); + + RtMidiIn midiIn; +}; diff --git a/presetdetailwidget.cpp b/presetdetailwidget.cpp new file mode 100755 index 0000000..1813739 --- /dev/null +++ b/presetdetailwidget.cpp @@ -0,0 +1,16 @@ +#include "presetdetailwidget.h" +#include "ui_presetdetailwidget.h" + +PresetDetailWidget::PresetDetailWidget(QWidget *parent) : + QScrollArea{parent}, + m_ui{std::make_unique()} +{ + m_ui->setupUi(this); +} + +PresetDetailWidget::~PresetDetailWidget() = default; + +void PresetDetailWidget::setPreset(const presets::Preset &preset) +{ + +} diff --git a/presetdetailwidget.h b/presetdetailwidget.h new file mode 100755 index 0000000..c0ee94e --- /dev/null +++ b/presetdetailwidget.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include + +namespace Ui { class PresetDetailWidget; } +namespace presets { class Preset; } + +class PresetDetailWidget : public QScrollArea +{ + Q_OBJECT + +public: + explicit PresetDetailWidget(QWidget *parent = nullptr); + ~PresetDetailWidget() override; + + void setPreset(const presets::Preset &preset); + +private: + const std::unique_ptr m_ui; +}; diff --git a/presetdetailwidget.ui b/presetdetailwidget.ui new file mode 100755 index 0000000..8648f75 --- /dev/null +++ b/presetdetailwidget.ui @@ -0,0 +1,306 @@ + + + PresetDetailWidget + + + + 0 + 0 + 320 + 633 + + + + true + + + + + 0 + 0 + 318 + 631 + + + + + + + id: + + + lineEditId + + + + + + + name: + + + lineEditName + + + + + + + author: + + + lineEditAuthor + + + + + + + orderBy: + + + lineEditOrderBy + + + + + + + version: + + + lineEditVersion + + + + + + + tempo: + + + spinBoxTempo + + + + + + + icon: + + + lineEditIcon + + + + + + + price: + + + spinBoxPrice + + + + + + + priceForSession: + + + spinBoxPriceForSession + + + + + + + hasInfo: + + + checkBoxHasInfo + + + + + + + tags: + + + + + + + DELETED: + + + checkBoxDeleted + + + + + + + difficulty: + + + spinBoxDifficulty + + + + + + + sample: + + + spinBoxSample + + + + + + + audioPreview1Name: + + + lineEditAudioPreview1Name + + + + + + + audioPreview1URL: + + + lineEditAudioPreview1URL + + + + + + + audioPreview2Name: + + + lineEditAudioPreview2Name + + + + + + + audioPreview2URL: + + + lineEditAudioPreview2URL + + + + + + + imagePreview1: + + + lineEditImagePreview1 + + + + + + + videoPreview: + + + lineEditVideoPreview + + + + + + + videoTutorial: + + + lineEditVideoTutorial + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CheckBox + + + + + + + CheckBox + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presets.cpp b/presets.cpp new file mode 100755 index 0000000..4cdf931 --- /dev/null +++ b/presets.cpp @@ -0,0 +1,15 @@ +#include "presets.h" + +namespace presets +{ + +bool File::operator==(const File &other) const +{ + return filename == other.filename && + color == other.color && + stopOnRelease == other.stopOnRelease && + looped == other.looped && + choke == other.choke; +} + +} diff --git a/presets.h b/presets.h new file mode 100755 index 0000000..a53244a --- /dev/null +++ b/presets.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include + +#include + +namespace presets +{ +struct Filter +{ + std::optional> tags; +}; + +struct Category +{ + std::optional title; + std::optional filter; +}; + +struct File +{ + std::optional filename; + std::optional color; // purple, red, yellow, green, blue + std::optional stopOnRelease; + std::optional looped; + std::optional choke; + + bool operator==(const File &other) const; +}; + +struct SequencePad +{ + std::optional start; + std::optional duration; + std::optional embient; +}; + +struct Sequence +{ + std::optional name; + std::optional id; + std::optional version; + std::optional orderBy; + std::optional sequencerSize; + std::optional>> pads; + std::optional> embientPads; +}; + +struct Preset +{ + std::optional id; + std::optional name; + std::optional author; + std::optional orderBy; + std::optional version; + std::optional tempo; + std::optional icon; + std::optional price; + std::optional priceForSession; + std::optional hasInfo; + std::optional> tags; + std::optional DELETED; + std::optional difficulty; + std::optional sample; + std::optional audioPreview1Name; + std::optional audioPreview1URL; + std::optional audioPreview2Name; + std::optional audioPreview2URL; + std::optional imagePreview1; + std::optional videoPreview; + std::optional videoTutorial; + std::optional> files; + std::optional>> beatSchool; + std::optional>> easyPlay; +}; + +struct PresetsConfig +{ + std::optional> categories; + std::optional> presets; +}; +} diff --git a/presetsmodel.cpp b/presetsmodel.cpp new file mode 100755 index 0000000..3e89fab --- /dev/null +++ b/presetsmodel.cpp @@ -0,0 +1,208 @@ +#include "presetsmodel.h" + +#include + +#include +#include + +#include "presets.h" + +enum { + ColumnId, + ColumnName, + ColumnAuthor, + ColumnOrderBy, + ColumnVersion, + ColumnTempo, + ColumnIcon, + ColumnPrice, + ColumnPriceForSession, + ColumnHasInfo, + ColumnTags, + ColumnDELETED, + ColumnDifficulty, + ColumnSample, + ColumnAudioPreview1Name, + ColumnAudioPreview1URL, + ColumnAudioPreview2Name, + ColumnAudioPreview2URL, + ColumnImagePreview1, + ColumnVideoPreview, + ColumnVideoTutorial, + NumberOfColumns +}; + +PresetsModel::PresetsModel(const std::map &presets, QObject *parent) : + QAbstractTableModel{parent} +{ + m_presets.reserve(std::size(presets)); + for (const auto &pair : presets) + m_presets.emplace_back(pair.second); +} + +PresetsModel::~PresetsModel() = default; + +const presets::Preset &PresetsModel::getPreset(const QModelIndex &index) const +{ + return getPreset(index.row()); +} + +const presets::Preset &PresetsModel::getPreset(int row) const +{ + Q_ASSERT(row >= 0 && row < std::size(m_presets)); + return m_presets.at(row); +} + +int PresetsModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + + return std::size(m_presets); +} + +int PresetsModel::columnCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + + return NumberOfColumns; +} + +QVariant PresetsModel::data(const QModelIndex &index, int role) const +{ + if (role != Qt::DisplayRole && role != Qt::EditRole && role != Qt::FontRole && role != Qt::ForegroundRole) + return {}; + + if (index.column() < 0) + return {}; + if (index.column() >= NumberOfColumns) + return {}; + if (index.row() < 0) + return {}; + if (index.row() >= std::size(m_presets)) + return {}; + + const auto &preset = getPreset(index); + + const auto handleData = [&](const auto &val) -> QVariant + { + if (!val) + { + if (role == Qt::DisplayRole) + return this->tr("(null)"); + else if (role == Qt::FontRole) + { + QFont font; + font.setItalic(true); + return font; + } + else if (role == Qt::ForegroundRole) + return QColor{Qt::gray}; + return {}; + } + + if (role == Qt::DisplayRole || role == Qt::EditRole) + return *val; + + return {}; + }; + + const auto handleStringVectorData = [&](const auto &val) -> QVariant + { + if (!val) + { + if (role == Qt::DisplayRole) + return this->tr("(null)"); + else if (role == Qt::FontRole) + { + QFont font; + font.setItalic(true); + return font; + } + else if (role == Qt::ForegroundRole) + return QColor{Qt::gray}; + return {}; + } + + if (role == Qt::DisplayRole || role == Qt::EditRole) + { + QString text; + for (auto iter = std::cbegin(*val); iter != std::cend(*val); iter++) + { + if (iter != std::cbegin(*val)) + text += ", "; + text += *iter; + } + return text; + } + + return {}; + }; + + switch (index.column()) + { + case ColumnId: return handleData(preset.id); + case ColumnName: return handleData(preset.name); + case ColumnAuthor: return handleData(preset.author); + case ColumnOrderBy: return handleData(preset.orderBy); + case ColumnVersion: return handleData(preset.version); + case ColumnTempo: return handleData(preset.tempo); + case ColumnIcon: return handleData(preset.icon); + case ColumnPrice: return handleData(preset.price); + case ColumnPriceForSession: return handleData(preset.priceForSession); + case ColumnHasInfo: return handleData(preset.hasInfo); + case ColumnTags: return handleStringVectorData(preset.tags); + case ColumnDELETED: return handleData(preset.DELETED); + case ColumnDifficulty: return handleData(preset.difficulty); + case ColumnSample: return handleData(preset.sample); + case ColumnAudioPreview1Name: return handleData(preset.audioPreview1Name); + case ColumnAudioPreview1URL: return handleData(preset.audioPreview1URL); + case ColumnAudioPreview2Name: return handleData(preset.audioPreview2Name); + case ColumnAudioPreview2URL: return handleData(preset.audioPreview2URL); + case ColumnImagePreview1: return handleData(preset.imagePreview1); + case ColumnVideoPreview: return handleData(preset.videoPreview); + case ColumnVideoTutorial: return handleData(preset.videoTutorial); + } + + Q_UNREACHABLE(); +} + +QVariant PresetsModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (role != Qt::DisplayRole && role != Qt::EditRole) + return {}; + + if (orientation != Qt::Horizontal) + return {}; + + if (section < 0) + return {}; + if (section >= NumberOfColumns) + return {}; + + switch (section) + { + case ColumnId: return tr("id"); + case ColumnName: return tr("name"); + case ColumnAuthor: return tr("author"); + case ColumnOrderBy: return tr("orderBy"); + case ColumnVersion: return tr("version"); + case ColumnTempo: return tr("tempo"); + case ColumnIcon: return tr("icon"); + case ColumnPrice: return tr("price"); + case ColumnPriceForSession: return tr("priceForSession"); + case ColumnHasInfo: return tr("hasInfo"); + case ColumnTags: return tr("tags"); + case ColumnDELETED: return tr("DELETED"); + case ColumnDifficulty: return tr("difficulty"); + case ColumnSample: return tr("sample"); + case ColumnAudioPreview1Name: return tr("audioPreview1Name"); + case ColumnAudioPreview1URL: return tr("audioPreview1URL"); + case ColumnAudioPreview2Name: return tr("audioPreview2Name"); + case ColumnAudioPreview2URL: return tr("audioPreview2URL"); + case ColumnImagePreview1: return tr("imagePreview1"); + case ColumnVideoPreview: return tr("videoPreview"); + case ColumnVideoTutorial: return tr("videoTutorial"); + } + + Q_UNREACHABLE(); +} diff --git a/presetsmodel.h b/presetsmodel.h new file mode 100755 index 0000000..ecbd711 --- /dev/null +++ b/presetsmodel.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include + +namespace presets { class Preset; } + +class PresetsModel : public QAbstractTableModel +{ + Q_OBJECT + +public: + PresetsModel(const std::map &presets, QObject *parent = nullptr); + ~PresetsModel() override; + + const presets::Preset &getPreset(const QModelIndex &index) const; + const presets::Preset &getPreset(int row) const; + + int rowCount(const QModelIndex &parent) const override; + int columnCount(const QModelIndex &parent) const override; + QVariant data(const QModelIndex &index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + +private: + std::vector m_presets; +}; diff --git a/resources.qrc b/resources.qrc new file mode 100644 index 0000000..3dff046 --- /dev/null +++ b/resources.qrc @@ -0,0 +1,5 @@ + + + splashscreen.png + + diff --git a/rtmidi b/rtmidi new file mode 160000 index 0000000..7ab18ef --- /dev/null +++ b/rtmidi @@ -0,0 +1 @@ +Subproject commit 7ab18ef06b549e7cdd247c492e055093263dd4f3 diff --git a/sampleswidget.cpp b/sampleswidget.cpp new file mode 100755 index 0000000..63a394e --- /dev/null +++ b/sampleswidget.cpp @@ -0,0 +1,172 @@ +#include "sampleswidget.h" +#include "ui_sampleswidget.h" + +#include + +#include +#include + +#include "midicontainers.h" + +SamplesWidget::SamplesWidget(QWidget *parent) : + QWidget{parent}, + m_ui{std::make_unique()} +{ + m_ui->setupUi(this); + + { + QEventLoop eventLoop; + connect(&m_audioThread, &QThread::started, &eventLoop, &QEventLoop::quit); + m_audioThread.start(QThread::HighestPriority); + eventLoop.exec(); + } + + connect(m_ui->checkBox, &QCheckBox::toggled, this, &SamplesWidget::updateWidgets); + + connect(m_ui->pushButtonStopAll, &QAbstractButton::pressed, this, &SamplesWidget::stopAll); + + for (const auto &ref : getWidgets()) + connect(&ref.get(), &SampleWidget::chokeTriggered, this, &SamplesWidget::chokeTriggered); + + connect(m_ui->sequencerWidget, &SequencerWidget::triggerSample, this, &SamplesWidget::sequencerTriggerSample); + + m_ui->sampleWidget_1->setNote(48); + m_ui->sampleWidget_2->setNote(50); + m_ui->sampleWidget_3->setNote(52); + m_ui->sampleWidget_4->setNote(53); + m_ui->sampleWidget_5->setNote(55); + m_ui->sampleWidget_6->setNote(57); + m_ui->sampleWidget_7->setNote(59); + m_ui->sampleWidget_8->setNote(60); + m_ui->sampleWidget_9->setNote(62); + m_ui->sampleWidget_10->setNote(64); + m_ui->sampleWidget_11->setNote(65); + m_ui->sampleWidget_12->setNote(67); + + m_ui->sampleWidget_22->setNote(69); + m_ui->sampleWidget_23->setNote(71); + m_ui->sampleWidget_24->setNote(72); +} + +SamplesWidget::~SamplesWidget() +{ + m_audioThread.exit(); + m_audioThread.wait(); +} + +void SamplesWidget::setPreset(const presets::Preset &preset) +{ + m_preset = preset; + + m_ui->sequencerWidget->setPreset(preset); + + updateWidgets(); +} + +void SamplesWidget::messageReceived(const midi::MidiMessage &message) +{ + if (message == midi::MidiMessage{.channel=0,.cmd=midi::Command::ControlChange,.flag=true,.note=64,.velocity=127}) + { + m_ui->checkBox->toggle(); + return; + } + + if (message.cmd != midi::Command::NoteOn && message.cmd != midi::Command::NoteOff) + return; + + for (const auto &ref : getWidgets()) + { + if (ref.get().channel() == message.channel && ref.get().note() == message.note) + { + if (message.cmd == midi::Command::NoteOn) + ref.get().pressed(message.velocity); + else if (message.cmd == midi::Command::NoteOff) + ref.get().released(); + } + } +} + +void SamplesWidget::setAudioDevice(const QAudioDeviceInfo &device) +{ + for (const auto &ref : getWidgets()) + { + connect(&ref.get(), &SampleWidget::chokeTriggered, this, &SamplesWidget::chokeTriggered); + ref.get().setupAudioThread(device, m_audioThread); + } +} + +void SamplesWidget::chokeTriggered(int choke) +{ + for (const auto &ref : getWidgets()) + { + if (&ref.get() == sender()) + continue; + + if (ref.get().choke() && *ref.get().choke() && *ref.get().choke() == choke) + ref.get().forceStop(); + } +} + +void SamplesWidget::updateWidgets() +{ + const auto widgets = getWidgets(); + + auto files = *m_preset.files; + + if (m_ui->checkBox->isChecked()) + for (int i = 0; i < 12; i++) + std::swap(files[i], files[i+12]); + + auto filesIter = std::cbegin(files); + auto widgetsIter = std::cbegin(widgets); + + for (; filesIter != std::cend(files) && widgetsIter != std::cend(widgets); std::advance(filesIter, 1), std::advance(widgetsIter, 1)) + widgetsIter->get().setFile(*m_preset.id, *filesIter); +} + +void SamplesWidget::sequencerTriggerSample(int index) +{ + const auto widgets = getWidgets(); + if (index < 0 || index >= std::size(widgets)) + { + qDebug() << "index out of range" << index; + return; + } + widgets[index].get().pressed(127); +} + +void SamplesWidget::stopAll() +{ + for (const auto &ref : getWidgets()) + ref.get().forceStop(); +} + +std::array, 24> SamplesWidget::getWidgets() +{ + return { + std::ref(*m_ui->sampleWidget_1), + std::ref(*m_ui->sampleWidget_2), + std::ref(*m_ui->sampleWidget_3), + std::ref(*m_ui->sampleWidget_4), + std::ref(*m_ui->sampleWidget_5), + std::ref(*m_ui->sampleWidget_6), + std::ref(*m_ui->sampleWidget_7), + std::ref(*m_ui->sampleWidget_8), + std::ref(*m_ui->sampleWidget_9), + std::ref(*m_ui->sampleWidget_10), + std::ref(*m_ui->sampleWidget_11), + std::ref(*m_ui->sampleWidget_12), + std::ref(*m_ui->sampleWidget_13), + std::ref(*m_ui->sampleWidget_14), + std::ref(*m_ui->sampleWidget_15), + std::ref(*m_ui->sampleWidget_16), + std::ref(*m_ui->sampleWidget_17), + std::ref(*m_ui->sampleWidget_18), + std::ref(*m_ui->sampleWidget_19), + std::ref(*m_ui->sampleWidget_20), + std::ref(*m_ui->sampleWidget_21), + std::ref(*m_ui->sampleWidget_22), + std::ref(*m_ui->sampleWidget_23), + std::ref(*m_ui->sampleWidget_24) + }; +} diff --git a/sampleswidget.h b/sampleswidget.h new file mode 100755 index 0000000..293ad9d --- /dev/null +++ b/sampleswidget.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "presets.h" + +namespace Ui { class SamplesWidget; } +namespace midi { class MidiMessage; } +class SampleWidget; +class QAudioDeviceInfo; + +class SamplesWidget : public QWidget +{ + Q_OBJECT + +public: + explicit SamplesWidget(QWidget *parent = nullptr); + ~SamplesWidget() override; + + void setPreset(const presets::Preset &preset); + + void messageReceived(const midi::MidiMessage &message); + + void setAudioDevice(const QAudioDeviceInfo &device); + +private slots: + void chokeTriggered(int choke); + void updateWidgets(); + void sequencerTriggerSample(int index); + void stopAll(); + +private: + std::array, 24> getWidgets(); + + const std::unique_ptr m_ui; + + presets::Preset m_preset; + + QThread m_audioThread; +}; diff --git a/sampleswidget.ui b/sampleswidget.ui new file mode 100755 index 0000000..26e2c96 --- /dev/null +++ b/sampleswidget.ui @@ -0,0 +1,253 @@ + + + SamplesWidget + + + + 0 + 0 + 660 + 421 + + + + Form + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Sequencer + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + Swap left/right + + + + + + + Stop all + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + SampleWidget + QWidget +
samplewidget.h
+ 1 +
+ + SequencerWidget + QWidget +
sequencerwidget.h
+ 1 +
+
+ + +
diff --git a/samplewidget.cpp b/samplewidget.cpp new file mode 100755 index 0000000..85dccae --- /dev/null +++ b/samplewidget.cpp @@ -0,0 +1,192 @@ +#include "samplewidget.h" +#include "ui_samplewidget.h" + +#include +#include +#include +#include + +namespace { +QString toString(QString value) { return value; } +QString toString(int value) { return QString::number(value); } +QString toString(bool value) { return value?"true":"false"; } +QString toString(QSoundEffect::Status value) +{ + switch (value) + { + case QSoundEffect::Null: return "Null"; + case QSoundEffect::Loading: return "Loading"; + case QSoundEffect::Ready: return "Ready"; + case QSoundEffect::Error: return "Error"; + } + + return QString{"Unknown (%0)"}.arg(value); +} +} + +SampleWidget::SampleWidget(QWidget *parent) : + QFrame{parent}, + m_ui{std::make_unique()} +{ + m_ui->setupUi(this); + + connect(m_ui->pushButton, &QAbstractButton::pressed, this, [this](){ pressed(127); }); + connect(m_ui->pushButton, &QAbstractButton::released, this, &SampleWidget::released); + + updateStatus(); +} + +SampleWidget::~SampleWidget() +{ + if (m_effect) + QMetaObject::invokeMethod(m_effect.get(), [effect=m_effect.release()](){ delete effect; }); +} + +void SampleWidget::setFile(const QString &presetId, const presets::File &file) +{ + m_presetId = presetId; + m_file = file; + + if (m_effect) + { + auto sampleUrl = this->sampleUrl(); + if (!sampleUrl.isEmpty()) + QMetaObject::invokeMethod(m_effect.get(), [&effect=*m_effect,sampleUrl=std::move(sampleUrl)](){ + effect.setSource(sampleUrl); + }); + } + + const auto setupLabel = [&](const auto &value, QLabel *label){ + QString text; + QFont font; + QPalette pal; + + if (value) + text = toString(*value); + else + { + text = tr("(null)"); + font.setItalic(true); + pal.setColor(label->foregroundRole(), Qt::gray); + } + + label->setText(text); + label->setFont(font); + label->setPalette(pal); + }; + + setupLabel(file.stopOnRelease, m_ui->stopOnReleaseLabel); + setupLabel(file.looped, m_ui->loopedLabel); + setupLabel(file.choke, m_ui->chokeLabel); +} + +quint8 SampleWidget::channel() const +{ + return m_ui->channelSpinBox->value(); +} + +void SampleWidget::setChannel(quint8 channel) +{ + m_ui->channelSpinBox->setValue(channel); +} + +quint8 SampleWidget::note() const +{ + return m_ui->noteSpinBox->value(); +} + +void SampleWidget::setNote(quint8 note) +{ + m_ui->noteSpinBox->setValue(note); +} + +std::optional SampleWidget::choke() const +{ + if (!m_file) + return {}; + return m_file->choke; +} + +void SampleWidget::pressed(quint8 velocity) +{ + Q_UNUSED(velocity) + + if (m_effect) + QMetaObject::invokeMethod(m_effect.get(), &QSoundEffect::play); + + if (m_file && m_file->choke && *m_file->choke) + emit chokeTriggered(*m_file->choke); +} + +void SampleWidget::released() +{ +} + +void SampleWidget::forceStop() +{ + if (m_effect) + QMetaObject::invokeMethod(m_effect.get(), &QSoundEffect::stop); +} + +void SampleWidget::setupAudioThread(const QAudioDeviceInfo &device, QThread &thread) +{ + const auto setupEffect = [this,device](){ + m_effect = std::make_unique(device); + + connect(m_effect.get(), &QSoundEffect::playingChanged, this, &SampleWidget::updateStatus); + connect(m_effect.get(), &QSoundEffect::statusChanged, this, &SampleWidget::updateStatus); + + const auto sampleUrl = this->sampleUrl(); + if (!sampleUrl.isEmpty()) + m_effect->setSource(sampleUrl); + + QMetaObject::invokeMethod(this, &SampleWidget::updateStatus); + }; + + QMetaObject::invokeMethod(QAbstractEventDispatcher::instance(&thread), setupEffect); + //setupEffect(); +} + +void SampleWidget::updateStatus() +{ + QPalette pal; + if (m_effect && m_file && m_file->color) + { + const auto bright = m_effect->isPlaying() ? 255 : 155; + const auto dark = m_effect->isPlaying() ? +#if !defined(Q_OS_WIN) + 80 : 0 +#else + 180 : 80 +#endif +; + + const auto &color = *m_file->color; + if (color == "purple") + pal.setColor(QPalette::Window, QColor{bright, dark, bright}); + else if (color == "red") + pal.setColor(QPalette::Window, QColor{bright, dark, dark}); + else if (color == "yellow") + pal.setColor(QPalette::Window, QColor{bright, bright, dark}); + else if (color == "green") + pal.setColor(QPalette::Window, QColor{dark, bright, dark}); + else if (color == "blue") + pal.setColor(QPalette::Window, QColor{dark, dark, bright}); + else + qWarning() << "unknown color:" << color; + } + setPalette(pal); + + if (!m_effect) + m_ui->statusLabel->setText(tr("No player")); + else + m_ui->statusLabel->setText(toString(m_effect->status())); +} + +QUrl SampleWidget::sampleUrl() const +{ + if (!m_file || !m_file->filename) + return {}; + + return QUrl{QString{"https://brunner.ninja/komposthaufen/dpm/presets/extracted/%0/%1"}.arg(m_presetId, *m_file->filename)}; +} diff --git a/samplewidget.h b/samplewidget.h new file mode 100755 index 0000000..55084ed --- /dev/null +++ b/samplewidget.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +#include + +#include "presets.h" + +namespace Ui { class SampleWidget; } +class QThread; +class QAudioDeviceInfo; +class QSoundEffect; + +class SampleWidget : public QFrame +{ + Q_OBJECT + +public: + explicit SampleWidget(QWidget *parent = nullptr); + ~SampleWidget() override; + + void setFile(const QString &presetId, const presets::File &file); + + quint8 channel() const; + void setChannel(quint8 channel); + + quint8 note() const; + void setNote(quint8 note); + + std::optional choke() const; + + void pressed(quint8 velocity); + void released(); + + void forceStop(); + + void setupAudioThread(const QAudioDeviceInfo &device, QThread &thread); + +signals: + void chokeTriggered(int choke); + +private slots: + void updateStatus(); + +private: + QUrl sampleUrl() const; + + const std::unique_ptr m_ui; + + QString m_presetId; + std::optional m_file; + + std::unique_ptr m_effect; +}; diff --git a/samplewidget.ui b/samplewidget.ui new file mode 100755 index 0000000..8fb911d --- /dev/null +++ b/samplewidget.ui @@ -0,0 +1,108 @@ + + + SampleWidget + + + + 0 + 0 + 400 + 300 + + + + Form + + + true + + + QFrame::Panel + + + QFrame::Sunken + + + + + + + + TextLabel + + + + + + + + + + + + + + + + PushButton + + + + + + + + + + + + + + + + + + + QFrame::Panel + + + QFrame::Sunken + + + TextLabel + + + + + + + QFrame::Panel + + + QFrame::Sunken + + + TextLabel + + + + + + + QFrame::Panel + + + QFrame::Sunken + + + TextLabel + + + + + + + + + + diff --git a/sequencerwidget.cpp b/sequencerwidget.cpp new file mode 100755 index 0000000..91c82d5 --- /dev/null +++ b/sequencerwidget.cpp @@ -0,0 +1,148 @@ +#include "sequencerwidget.h" +#include "ui_sequencerwidget.h" + +#include + +#include "presets.h" + +SequencerWidget::SequencerWidget(QWidget *parent) : + QWidget{parent}, + m_ui{std::make_unique()} +{ + m_ui->setupUi(this); + + connect(m_ui->spinBoxTempo, qOverload(&QSpinBox::valueChanged), this, &SequencerWidget::tempoChanged); + connect(m_ui->comboBoxSequence, qOverload(&QComboBox::currentIndexChanged), this, &SequencerWidget::sequenceSelected); + connect(m_ui->horizontalSlider, &QSlider::valueChanged, this, [=](int value){ m_pos = value; updateStatusLabel(); }); + + connect(m_ui->pushButtonPlayPause, &QAbstractButton::pressed, this, &SequencerWidget::playPause); + connect(m_ui->pushButtonStop, &QAbstractButton::pressed, this, &SequencerWidget::stop); + + connect(&m_timer, &QTimer::timeout, this, &SequencerWidget::timeout); + + updateStatusLabel(); +} + +SequencerWidget::~SequencerWidget() = default; + +void SequencerWidget::setPreset(const presets::Preset &preset) +{ + if (preset.tempo) + m_ui->spinBoxTempo->setValue(*preset.tempo); + + m_selectedSequence = nullptr; + m_ui->horizontalSlider->setMaximum(0); + + m_ui->comboBoxSequence->clear(); + m_sequences.clear(); + m_selectedSequence = nullptr; + + const auto doit = [&](const QString &prefix, const std::optional>> &value) + { + if (!value) + return; + + for (const auto &pair : *value) + { + for (const auto &sequence : pair.second) + { + m_ui->comboBoxSequence->addItem(QString{"%0/%1/%2"}.arg(prefix, pair.first, sequence.name?*sequence.name:"(null)")); + m_sequences.emplace_back(sequence); + } + } + }; + + { + QSignalBlocker blocker{m_ui->comboBoxSequence}; + doit("beatSchool", preset.beatSchool); + doit("easyPlay", preset.easyPlay); + } + + sequenceSelected(); +} + +void SequencerWidget::playPause() +{ + if (m_timer.isActive()) + { + m_timer.stop(); + m_ui->pushButtonPlayPause->setText(tr("▶")); + } + else + { + m_timer.start(); + m_ui->pushButtonPlayPause->setText(tr("▮▮")); + } +} + +void SequencerWidget::stop() +{ + m_timer.stop(); + m_ui->pushButtonPlayPause->setText(tr("▶")); + m_pos = 0; + m_ui->horizontalSlider->setValue(0); + updateStatusLabel(); +} + +void SequencerWidget::tempoChanged(int tempo) +{ + m_timer.setInterval(1000. * 60. / tempo / 4.); +} + +void SequencerWidget::sequenceSelected() +{ + const auto index = m_ui->comboBoxSequence->currentIndex(); + + if (index == -1) + m_selectedSequence = nullptr; + else + m_selectedSequence = &m_sequences[index]; + + m_ui->horizontalSlider->setMaximum(m_selectedSequence && m_selectedSequence->sequencerSize ? *m_selectedSequence->sequencerSize-2 : 0); + + m_ui->pushButtonPlayPause->setEnabled(m_selectedSequence != nullptr); + m_ui->pushButtonStop->setEnabled(m_selectedSequence != nullptr); + + m_pos = 0; + m_ui->horizontalSlider->setValue(0); + updateStatusLabel(); +} + +void SequencerWidget::timeout() +{ + if (m_selectedSequence && m_selectedSequence->pads) + { + for (const auto &pair : *m_selectedSequence->pads) + { + const auto iter = std::find_if(std::cbegin(pair.second), std::cend(pair.second), [&](const presets::SequencePad &sequencePad){ + return sequencePad.start && *sequencePad.start == m_pos; + }); + + if (iter == std::cend(pair.second)) + continue; + + //TODO: iter->duration; + + bool ok; + const auto index = pair.first.toInt(&ok); + if (!ok) + continue; + + emit triggerSample(index); + } + } + + m_pos++; + + if (m_pos >= (m_selectedSequence && m_selectedSequence->sequencerSize ? *m_selectedSequence->sequencerSize-1 : -1)) + m_pos = 0; + + m_ui->horizontalSlider->setValue(m_pos); + + updateStatusLabel(); +} + +void SequencerWidget::updateStatusLabel() +{ + m_ui->labelStatus->setText(QString{"%0 / %1"}.arg(m_pos+1).arg(m_selectedSequence && m_selectedSequence->sequencerSize ? *m_selectedSequence->sequencerSize-1 : -1)); +} diff --git a/sequencerwidget.h b/sequencerwidget.h new file mode 100755 index 0000000..4d59ed8 --- /dev/null +++ b/sequencerwidget.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +#include +#include + +namespace Ui { class SequencerWidget; } +namespace presets { class Preset; class Sequence; } + +class SequencerWidget : public QWidget +{ + Q_OBJECT + +public: + explicit SequencerWidget(QWidget *parent = nullptr); + ~SequencerWidget() override; + + void setPreset(const presets::Preset &preset); + +signals: + void triggerSample(int index); + +private slots: + void playPause(); + void stop(); + + void tempoChanged(int tempo); + void sequenceSelected(); + void timeout(); + + void updateStatusLabel(); + +private: + const std::unique_ptr m_ui; + + std::vector m_sequences; + const presets::Sequence *m_selectedSequence{}; + + QTimer m_timer; + + int m_pos; +}; diff --git a/sequencerwidget.ui b/sequencerwidget.ui new file mode 100755 index 0000000..8c89b0c --- /dev/null +++ b/sequencerwidget.ui @@ -0,0 +1,78 @@ + + + SequencerWidget + + + + 0 + 0 + 412 + 65 + + + + Form + + + + + + + + BPM + + + 999 + + + + + + + + + + + 32 + 16777215 + + + + + + + + + + + + 32 + 16777215 + + + + + + + + + + + TextLabel + + + + + + + + + Qt::Horizontal + + + + + + + + diff --git a/splashscreen.png b/splashscreen.png new file mode 100644 index 0000000000000000000000000000000000000000..73072cccf7f0677223f2ee30773728689f2ad3d7 GIT binary patch literal 17884 zcmeAS@N?(olHy`uVBq!ia0y~yVB%w7VASSdV_;y|X2h+?z`(##?Bp53!NI{%!;#X# zz@Wh3>EaktG3V{w%8HPuf4_e$H=7`#?AqaCrpoEUk)nFgX*NgO(WP3U*`XYlb~SJ( zh<;tpzio@you#4yTT+AdyVu^zYHUbY5f-_Lk>^}R~>8M^qDl% za&t}G^{1w~Gv|EfRh)eO>*pjCl?_xVX3+;rkaRuAro(G|%~jwbRbc z@eBQuV={H_cM)&iRr|-bJwP{c69u(VQmkq z`SIR zvvcQ86?Jvy4^OAZ3yF%BUb7b$6#S?vRcLYe&CSi3IXNnJcK@~%OqeiX!B##crAMDd zWX>5>d`Mt$>y`Stx8L^T5n&cq){Qwgjoxk$5EPuh6#W>CW2YhQCQ7jv2aa`EeO_R%U2spUIuO2ym_sjyNk;aRS}t6D=W=Uw#(Pu zcva-$;u7O@@F7oWY3Xv?d1vQX7Bgt*>ACS*xVjt>*RYtQXm2myAZuMV=kv~&FJFFn zeZ9Z-_qUCepVQK(t0^fZS+cepe|dLTxWs+FKu|D#(Sw4vQ>VNR{CmIv7P!Cg!3Ce>WM%h08ASz! zhP=DGZvL&*)YSa)@^bs9Pes?(Mn6BonRU6VK|#?3nQ2K|5) zbMW&&UphUGDdE+Xm2YlsJ*^10qN%x=amT|pX{(aCo|DxKGcG7(WoPevKCfCP5bnq| zYj_%#ELp-A|JuL)mvCKO-IM3fgF{2FK70*z=>N>DtVK(fur#b#v4Sr?|Hp@iOO`DQ z3JyLzJAdCzges7OOG`_?yt=yDxm$Vm?)UqCGgLgC8h)iK%Ee_*Q`6!9Z|?1tZfIm? zw|XC?qv}{!H*?e zeE%Q!JYXpJez#m$MC8ct_xtZJ%$=vCr1Z|=;KO<@Zf?eozP_>-rH2n6-udyEbY@1z zgI@D{1uwR`y12yn9(-6o$Mg|{LGCRRhPdjtrmtSVmaVH56ch{(EMQo*diBZ`D;9W9 z*ZcDJ_VtI?{e68?PfSn@2?_adkX_#5&o3ndSJ$EY_buyHpDhgM z7S}5|^%Z1+<3Yv~=gzS$sQ>@ZZ};&fo|AVxXyQI`>QvIB{ne^dGUxL;phKK}AsH-6uo>i2u6@7iV6 z#wUAfTkh>Sb^d~ag6fj*czU&-;fydGdsz&(dqR?xRPKDxS?uXQ+74$i8Cb%EFg_U0qymIUhW@ z=mA5)|G(djYkm~$`}InDUeznj>G$JZT$Ze@v9p*FZ73`(99x%PUtbRj>87Tp8TR#d zJ9qBXTkq)NvP8GW&f>|_r;OKF*c%o)w|_do%zxp=jT5%t?>HwXFIFuD)hUOUi@Qf{ zHTdMSb?eq6M~-}$|Nl?>&(F_4KWOHU3hVCZIC1mGmyYJCucc>k^YNt}Y+_|NGt0EQ zsfo$^wV2zrFR# zt=Cmjx)cWzjgzQcHc{E#sOCq(s#U8%<;~Tn9UUDt{pZ=*ZL7Z(e0bovtNi`DNoJS7 z?fU(0H)F(}ij7C7f>g{u&(_{6ZO(MVZ%v8#_Ip*@vAfHjcI)rka8*M|>5*QInZ=&J z-)=Mf`1vz3=H$y=B`=+Pe0UiA?S3vfdbOjYW6rts=EJVyu`CBlug9hrs@^v*e;4D> zt>Wr(@-p|g@_jhn$xOwxYh`4xkY+px5M?PEo zr%y!({x&;1IWb8}N}8CN8Rg%zS$$eiQ1Ij12M^Y9E6=We)Tz$F#g%k(Q>s*{fS}-M zO^Y2n|NZ@KT=>YP*9ufJemMEyfo8Uh&$AVWFD>=XRj=ym=!iI|-rTI-YjI#@VEd0B z6|-i|s(7>U_!>|ZR5Qb$jlDeo-<3)2@^umot5#`sYYPer#y^|PyWq~=pTXW)Sy?Zx zt(9&|Eco#uQE$fsrq$j;f`al-ik121UHG$7RZ&q4g8&&IcQ+zGgA-$=PHSs^*Fd2|4BD=tw*PB>EAi~(fRs+o586_|I^L~1rH7|GU)C9Q`D`UIe%mF zai$Ba!}TE^eNucd@%_EM44@L~k=L!`Yj!VNcI=Uti^~!5pCJVXJck#ThccABzbD(^ z+|Cyn)7jDSLuo!2-|z45pa1yy_{)on%?}!0mphsxQs3X(yCz~|lXU)`iI*+~fg-h=B`UT-yYkmBwtxbG>zW!G4<5G5 zFI%{<@$K#H^S8C`T(qdkceYt%#N3XEqw4N5HkKAF?Ch79dW*k$^=if1wOe<|d~{Ox zpOv|IY$s#)}IJb>jEUv910V5E*&$Sg*9FhsT1ID?8)=|GFMF zU0X@)!^#H_c$N7UJa~}nCojM47lFA}rA3vxN{{}TJbKV*^5}u>H2wH<>tc5^oH%!G*^V7I zQopZTz54OK-|rs(dcA)6vSn>AUSt#%6@k+AIVq`axwp%1E$;XsHlLGk>(;F@RwW(h z=2{njJSrX?>(}1W(!y|`Rq>+_D=VwdTr1HA`TswTLv!2oJJrw6&E=7|o72iIeraW} z`m?jMkDr@s-Pze``TY5@W5?!w(@@%XG`;EYy}i|jJk9%Re?NNuJiXLXHi@mRSK2%( zrgKYpYO3nXmoKkeyJl4JAz_+Mq*FkEfcJF0(pPz`hYdi5c4ud2&i#FJPwVfWqrdM* zle>JaNPYe9IKEvaFN+}V=Hh$s^Rfs-VPPQykEGF&M_x;8wY0S*ZL3Uve}AuTV>5@} z?nlGFzrTg0rC(pU%M}?J8B_Q3>6saZ&6CyrjdE|96crV9^!Jya`s;GU`sbNPC-nFK z5jyaAxu(PWA2yMJfr6QtnIRz|0>Z+Fe|>$;$<58|_crXrxpQSN@>)-w^14^`T6daW z?5S^WZ#M=9$L8<XkFTBj;>C-y`1o#R*P7YqS=zbv z_Xt>Q{px#!tNd=M`2U~h>p>;X{F+ZEGZ!8d^z!!pc~rm7(b2IncXeJsfB+}~&9JF7 zx*k*9>%1-E*s)`iX3kuBbfS{osr07i$~oJvv37`pqOD6*n}d@x@o*b2kBr5Ih1bn_ znzPsMop$Ju69cF&e{yp2&cEMo%gP0qgsMHdU880(W$IMM3;XNqyR`-5H)mbdnpgA5 z^UvSEm7mX=N7pXDH*qVo{{BBkt*xysEiE7JRllD&Z{EFyv(CF^%ed6c_Gf9IHA_lM zTif#ep3gg;O!8i%=U#JSUF_~3KhM|uB_}HvKR@^M*X#B9Uw6KZwGIdfIPm}P{r`*~ zu0`itnwm6Rv*`F?HUCCmWLQ{RqC`yD&D65@q2=jedmn4->OOt7di}FktJkZUn_pl0 z{hN!ctLvW^?)HmTt?KF&R{wM(xqt50pKmkQl>GTon78|_8K?oWCG&F3rZ#up1uiwS zpZ^!z+PvL2IO1#H`){}N)4#sDx+ZS#tS3)Wmif#~`n`MV``HGGOzr&g&n|iEKmB?= z-v0UL+ndWCENqucI^HMCBW2R@d|vgsyIZ%lg4}7w%Fds1)Oz2YJ29I!Z~plGd-{h5 z2Y>wjy*cHi(4xhQCGBcz;b%Je){usb6MEgKR;}jzqGSBz5Raj*H>5f{Cc&z zvNc#Ps9t zJUc(vELY0CPv+sR?DeUSj&zpQ$3@>edB5bc?>1rGvlAX^fxRXo^JLArn(sCl7ZzN$ zn>S&?fo-|BC5%!ySXo&??ft;Oi_5A(x%d11`uk7!Gn{{RVxsbL+gsT;H>K8mILIDT z@vv1VYRd}W`PuKSe?D`3mtX&T_G$jy%Tnxu_n&<|xNzml&j0_u?{E0-6S`xP?fZGv zUtS#i_4T!+Q3^+EYio~`=_=oc2YE`~iM+XGEVDoRy52J1*=z^o|9xPu`u^_jvg%u( z{r<%`o)g`2%<;(f%G!b-zh1Amtovgj9$#aqsHhk={c+%}Pw#fR_siA%`FQ-uks|>S z5gtiNN~)@=Wp~rRzq`BKc31N^p5y*AjaqNrielrHIU$@*mrEU)2s7AV zxu%kmlFqs#g)txd*4w_Hclgkui2Zf8w$%oWZ`~UJn zT0Mm_>H8mN@B8uYZu$M(&(Gs)zg}fwWt}*Ex_fEqR$;9YyUzkz)qIP!w6&9uc8RjE zu*}(P{p9AHT$Po39sOL6T-;-4G2=M*{o?bsV&RW0&ea^>o_$^Kw#eZ{uHCn`{?JoV zX}NJDLP|<1VsqMA%lYs7y5HW~^zM*ME~p=(rM2qr_nh1*ix)edyLjj0Z&jsrkFOql zcwwRQws*CUkN0oiCDVW5B5U2Ub+NmdK0IjVpSO8?v$fu~&E+BL=H}B=PfshY*s^eI zwUyEd%URZS`(2Kd?y<5+NlUwQ<%&yI)~evBe^o-&vve$r8t*T1?dIU;Pyhe#?~$WN z=a#m&LdsB>y=USIDefar& z{_THj_U)59P?oc;qvPrxD~mPp`{h3DdvIZ%Wm<8^?yU>++;u->>*?t9{PCC&j{&Or2PScHkadowLzs;u(=XW#b&GVaYS9@c7zWnQJ zYm?8sd1AZS3DKFE7vCbzpt<_jf+? z?e2EC|dHVIG)8n$fKi_ig!ish4 z&YAW9J7av_Vu@Y;g-aY~Z*9%i)Ym_L{ye|Ey?tEW&(uGEWq&JgNjS*lLzdfUjS_ z*8Kf?orQ&EOXj`ZTie{;@A=H<-Y@sGOM6|xYSxOd(kD{&Z+&6D>l$4ca>*n_vmEW~kymwp24)gC)%%u~q$Jfg) zxVo^=|J&8@_=`7gc--Gt%gM#H>G$WP)HC{cR~YWVt5?9yu7;Qzkj_bS^j5UXGeBLYJtIT zR*U3?^RJ$<{qpeP!`xqcrp3Le|9$&DlR?S}f!lMoir&c$KiVm*uA!~1Y+&$Vi>YF7 zZ!ZH2E9=gy-#oOpJQXky{HPZvDRU;4J@@a^SDg1M9`hdfr@Y!%ckhMSv7GPseve}h zy>9nz+v-IdHcV)1V^dO6^2-j*%iI0t=H}(0OLy`Xzjgm?s-W~pdXJICo7>y@8@%;) z9(m*?^ZIeWecJDDZ%bZX>8$+xZ0pP7=&FU~$Je#IOE^7Em*IZ&l#Fi$pka(kUnQk? zhh8^1J3BM8^UEE%fB*jKvXD78m6x`yuRk}(QdaI-`So=z?;e~rzt6I7|J4gJf`uW6 z)%};niYo0p{krM!zrVjTv$7sN>eesYeauq!MkdP~p{HwCg&eZ=RZJx(+ zCpos!`t2$Mp5{%Pj2P;^u8yDk&9GS9JGyLhM@NP9dTzdZJN7MHu%H3dcVYPRkiWj* zgy6%p;Drqh4Seyd7oTCBKie#K(~e2+XW4vxb@g@i*~5Cf-)y=pS9{a`-$(ujyWh_R zl^$SY9;Vy>mQ?qfqhMw>t?==&t*@odmWNK6I(6dw`ThI%+xy%9-JpC2DV4S+Ydws!BW z{vH)}{J4DmnQghZUtC!!{Q3F$|M!k7D=AgH-}}9zqhrGYtM41~@7vuDxw*)-JIZ!h ztjnJ1*IC*zT3juaN9yIgCU;gu3Lqnrr^+mSJKR!PG`1$kW z$Nl!804aHOl{LNJ$|C9G%8rip0R;uh&petXr9OTBENPI? z;Johc9`~l5^A6md5uE|bHd@-+u|@vwx^|+1A4R$N@)m8{H0k1E_uEd+VPVspt>ew? z@0^NVUI-eJJ9PN)&HeTEg@uJvrcAN;TGtm-qowpnTSP`DO@eLa%$Y}y9SaHwaPaZz zadwyTIa|)T!@2#~G#59wN%QCXpPy&Da>a@V>vq2@y0!P!>(@K~{dz6C?MH~qk#G%* zhyw;Z*Ve_Jo*EwKd4FH6q*8O^>ypNJ-yEs^S@Km(dkk3p2o2JTD3&m!zsVd z%(0xjX_L{evbU$I-|u}srMjb|LYb9a{M6aAsaIA6etCO)`o`qrlcr8}t*o@%TmAjn zDed(skB{~Gar2%%+_d|!O4)Yuwzp+gFXEGo=x_*+t}K=Ws8ZCvGL8_<@(ZQIR&r$*f$q1QUCDy z^XAg@J)h56%kBy=GB=-I{cb0_MQNOSQPHNu>vWYKaqrQyFtML}Fe@#gqpvUR&XZ@e^V6Q6o11fM z%gNNlg*&ImRdr67AaKCAuhmT4>)4Sa0RaIHF)=aieo1TAtVy}DA`mp(_wVoT&zHRQ zEjL673f^CMZeni9iwlZgUS228pHIKKD)h+lB%`<>!x*OJz6*kEvNeSCg)$J9A<&VU+? zdzcrkSn*=ZKZ(`h>%Vn;P*Hl+w@1%ni>t3MZ}zn{iATFcj~qRElc{0lwr$hS&Ndeo z5;`=`wpv3|^Wlq&i+9ne7Z)Ena-`s1<#Wb@KR*h+yu4-@r}xF~zjDMb zFfdR=L}WwF&!T01b5EU{YwhXhr>3dd*~%@x?N|BRH_M`wl=hu?-E?^7Zmmt5Hi0Jg zK%=acm6h8TFbGIUoY;_fn1hc`O;7Kf7=zF8etA$=Bc}fE*CR)d3Q9_Pet&m2_WRKj znQFeXT!Mmxf|vOyT3gRPJKKEnoH;&8NsHe0bne+>vuN?+oBL|5D=RBQ!oq}vg&$A) zC!wUY@4)$juy)z*nDV=&HD4~egL=6K4mfPfzn}MehNMVPaImMhck+=A!ELO~k0OhT zi)WhUO5MD9)5Oed+biu7`vq7Cp?z_86AA|ZC8v=rZAJ16N0vZ*ZtnUBp+wJ`8yL|)&1=TZ(3w}JCoe%1* zii(QzX)36wwCt<>eQ9@j{;u-(eyORd&FuXD?l~R3CSzYW=h34ixymPkmo8nB37Xu| z(XnMsY`1cEcQ?ol6DLkwp6hyNXL0)K{a?rQn|z>waodK1hppmee^!HL#;1SS z6)OsweYS}!4+D)GTl7;nAMOzE{}HwpfI2hr{ee+Yr&>6LHFR~i?h+9coG+SD%Bb{h z*Y|tXKYu*#|M_fo{*4`li$TNJKU8`xI*wZuJYZm8Wn~o$S5kVkTSR70&vw4&)2B=c z2n%C#@b&ElO;`NTV`bMjuKiVVul|2+-rlcatGx?XXa+C)@OJzCN6()hKRH?b=I-+K zS5-SZeyFgr>kA7CGCKJA@f|&SR4n|lL}_X1n;RRM8+PolU|3ryAh@xksd+kRD2l;= zr@8X;v&fjO}X81Q_HXz7{s`L)NE`OfCx<$XFW zIxq3hkB^Z4^YRZ0N=b~Y?E0XwYlcaaCNXgEe0dWl%Wwb3fWf()Pu0R=#)Jt1Lc+qJ zxy8@t?YHl+5){0@_`!pvpgz!?TgP6kJay{SiL+;q-oHP8dR*1XZ_hI`GaJM_x;i2p z4<0;yPEkM@PoLzrQbExUli=?(*oE(A!U+J=>IX(};3(g$> z^{a{@Y+X!dq;LA>%*$$NXJ@s}pD*t{P3Iw~6TD01qa&!8e82y{opJiPExSafTb8^K z`276*^A8UXhfPS{*<~;_k2dh@EV^Yx9?_ah| z?ewdxlUp(`gBC?hoH+5!TJql{}mqtEH6p^WExT$k;u9%0MRY}KO>vAC(nKM5=K3=QmUK3GU zYa3tx*OZZw@ypB0$6poAS+-=JDTN@2(t6oj*WDhF%z^JLI$&hn%)6qv> z`zFtv3CfczSFS91cjqR^Gqqo@hMSm~U0NNk-_+E!M(=p$yIrsKw6wHd+}y1G^XE@4 z-b;C0e0B$T_}-a3+K_kGYQf*`i|6jxDqV`(XW*MC!KSLJdSK<}*h7a7>8$gxnc39L z@8RR4qM^|tYh4B^oT6g+7+zmr&wfi@2BPg*q~61a55>YC*<@c^vvALzIdOZd4Aag? ztUfKszx=@iX6QsMFE8(tr%xkek%noO++BU+JlJaa+Ao3(?tL;FcL+USv1U!msVSNV zR<;$?)zyiGtIYE|c<}TVvzGUMmqnkM9Gzj9eCw~$GUs(i0_#3B%QF$g!*RLlGfTgVW%inXF940GGk(nzI38T^!N}6ml0ig7WXq3_>hmg^ zy7l+XsQ>#q{?Fg{_2rSV&oZ3d+}HvG17-Kki}LGMt|;bje?Gr{9%%m4{9eW3S3yTk zYk*2&*~7f%cP>0SuD{~}6KKALA?@re*V@{>m$QqGWi-RIc64yutA1~*q@=V)&vg$d zCu{2IvF&?(`@$s9>I}zbw!n~(BiZZsKKn9D#`YFNmZfgr!?WGWu5(-u9_$7UYi-(O z#Kg=jX;F}{`aCxe&y(-_|JyR`|9LjwqU_C#%UPg4(alY%eDSvzOuKw3aNniFjz=zv z$n5b*OjP9M<#lm!xv;bN`DVNCzh19T|MKD@Xi9Ns@pG%S%U)hye)+}?kD#DQhux%I zWDef0w&<|vZfX`cyzP1WcK^Sf&*vS!e}BGse9cA8Y}Qw=UgdO~T;1+?r+1mA%PseV z2fIPz`wTMnb$8Z1KYskUu(-H;ZSCIRZ65nRpRp2Cw8zIUQQot&y}83!)1qSH zj2RLPLc+pt|ILa2_h~xAoce!1`PQd@d~}rIPVM)*eEYX~xX-*jv0u(Mt58*G9w-#H zq#sO(iIHKb{{F5sLl<>)>gdAjVudWlDSFDvhaY*l*my6v%G$B@%vXVDW`6D&IXR&G z$G{_NH3c+X6B~U*P)bVb#e?MUew=y2^y%&;B_^(}u4m@i&TeU8>E6%uec^)#kE_1CILKf3fw@n{ z^3X!(cA28Ar}_22qr1AhIeB?Ovr**^QRRO9FPC1M=jq95|LbDE zMcp5Z_`08}o}Qjho;Tpb1nVG7psw^L0WMt%@cjfy*akc;7*Yz&$?$#^31dsoG)UD63 z<58FPxu18e-|c9Ae7yhi%3yUSCZ-juR)H2Ark|ZFJ^-_Ug>9DMoVScc%@Pp+?M;z?fLShL|I+^c;fYQCr|#o zm+hF5v0`KO_q_JIhlMka9PHlm{lRkke;@n*{5WpEY|)~oi;LYi*Zr+Jbm-8nl3vT= zXQ2A<(4j*Wuh(wp;NZx({CxN7MT?Z$c%|J!Lq!=G8IK%0c4=#N`0czYbFItgnQgJG z_>geU`n^q`yuF{FAK(AK@9ROSaL=AOYu4y6F*A4e_p6(jOc7LeyCK$h?b@|Xxwp;Y zDjv3q@vE*{vqr_#^lF*LWKc@l{eItU(9q7${6p0tVPQd0QKx=BpPzsG(49%o7W1y( z^NEY$@$r6j4ULXHdu&#%UcK`{6L(DLwi}sGPD}*#3VV8ZZci(4_w?{cxV+5w$+Ksm zN#c)3#p44)LtUeyq<;SVnOk@FOGKEuc;TFHeHIdJi82-i2Rb@C4H=YeZ0>02>23R6 zZ2$9#u(E=}fe+umn_pWOd;3}Sc6axEVb!u01qV3z`M2*~opIiJ`m$we%Erc*cf4A) zx+n2)n`C2SW4i2I(8@@^-+v}e5=#E_pUmZfi`^s} zKxt$7{p|1W?ryu@@!>vYWn)@f^Qpdxw^V8S-KS3s*8+_qX^`zIvk6_qe0-|C={Cpn&J#;4tXw>N@x94l_HSgl+XV69#21ty3>w zOU){N*5O=OxKU6_Dyv%j;=NLN*{O5q=7L&%3_Tk*YzW&g9~l|BO>q9&=n5P)9-dxf6u#}?Us>|G21Q2cA}Mg)x=%9c3qact!!vGv8RVeN?LlR zd4AmL^Q+gaN%`{P;+y;X=YRN65Vkhz>7`QMb2eeW*X@1>TC{NCLI9{QHhcRVlT0CX zzc~wvpPvH_HRY8)Ez8Q%^7QmHF*W`8;V}R19bV5*oIU&W)#~+WUtU~PQB(W$@woiw zr_9xOnd}$-YF1DO08}Y$$r_<=rmh-Y3I3hfCo3uitZx)At5x*4_Cd8M!TIrt|J~Rn2eSdGHUN3!N_4oI8bw$O-Wy{n+F$`KZ3c|kGx2zz?2#i! zHWWT~tNQllWyhzxhaeMo%fr{lZ7byr1FbGjxNoWS^5x4fFE6))mX+t-wMw7CO)Zj6ZL{s_?)LQee|}njKg)(cUk}gRvieZ>jFa~SFSAXYI58zX-MILf&+V_E z20%uJf`!ElR&KG1*bD~HXdq}g)X#_f^%u+8lapybt2)Ty=lvObL3z%yJgW6j@aF0r6(Li83F?XL4%=uJEvL97wtauX5#GG zrw{Yn`xF*_{C>zOB}Jv*{@;$%w~C%Weag6&o&B)EkK6bEu^l*Te!u42>%AY3NrToR zPM>}}eSU3P^z=vF%Y0|MB_}IuYHI%ZvfTdZrqg=LmXFJ>6+r<6<|GwYWKezth&oA%p&Njbab2lWdyl(`FLthVUdPPNr($e88MF$1v(9c4`;?5^+j6%BbFSs!R;w0s zTNk@~+TQB#psw)kN|khBVd2Bc{kF&ESQgK)tF?;Wo_F;A{Yv8lOP4OS`E)`V)G^EZ zy7^wv^s_B3ER!Zp0_B14_v`no+PZyvclY#;!p9z-o{amfwQk$k-L?sTmXm(YpI*Fg-@FA26dpZ#l#{q|$&wb(u+`dq`hLj|!vYEyv~r6}HGI50|KF8YK{1CS z(?fS`dVObSkF51IhMtYd$E}|JJa=giKigTW=(MynDPLdTX;-)A+%#e~aO;=5`>Oa% z-o%wFSC++Z-?r`Ai`(1RGfJGEu3zr3bnWhkZPIr(g&&xiUNz&)Jn?`3{^?nVKb@7m z&XD2o{rmf4(@)QNy}zM>;qf`^_aYXCl<{uY zd-ebSmc?)Po~{RKMJHcc;%W2m$79e?)93T{`;(7ud$jZUyyU;XzJ`Q`8WunEvHSDE zdHJ2?x=~WO2Bz|*KYl#!-}!#u?>TicJ9kdFZNTH!C*zrru;6X_2^*B~jqvp3EPi&T zu)F?zSWv+SrnW@TAnE~Ue%q8n(|Kp-UgO+STDauq{>6)HZLC&p+a_kQweLXA^tXI^ zyIv?Q`1gvwSx0koKw|{nS?&~eaAq=-?x4gZ*-TJLuZsI;^dDE zj)bqTu7dW?hdan6UKgj>{$f#qsrjO&wNSgC=|$HqTEi)GK~|j&;uMU)As- z317K%GwPG~aZQVgM(EJM_S`>@dCtuW zGHCy~J@2kpVBkdO^>@R*^{fI71STEf$jZ)^w5c$dtma!3TDi99_{({V-TQ-LW6!dR z$6R>yQ`pqh6tuHVLgL24XYrtV5VF1Mrd6U<6l70{iHXUCnKNHbslJi@^V3riF)`3! z-RARl)z_Zys=A_RWHjl|pPEC553gLYLc!8<=EKA7uX6<10}2HCpRB3-!Y!twU}7?5 z)-0(@moDkZKDx6pnf*iTcW%?f_KS<%nFZwJ{_X95dt+nsjvW?ST3Q_Z{Gf$O zd6!q7i@fY1n^?9cN7q6pY70lkUz0Q246Oau#q7M4d08~ASgSa3k@l2n(~J@iu|#M8 zW|yy-09ovp^4(*)sQV;$SJ#c1m({lA+)T3mc1xx>j zmE5oS-23*t-#nX>dn!MBczJDVZTs`5#=HN&-DGRO&>63FtG>Lr_~`gq!Ooe+>199S zKwHROU+@3)sc5-xctj!7lO9RqivNGV&ooSC`w)A+dt09E)>j1^T3T2(ZQkrJU2DL6 zc`>!U*K*cP|3ypfn3gVGI%Ud~3u~jdn|;sE=v=)_JLc@1E(w#2fcC05 zllvbZbZzB3YbsxxA*XPxPuBbXzS^CiPHF#q9smE;rTNAFOV|RG)*86WozZNJ&fD2~ z;DE!U$B$p$+PeC(wbHC$k?Qa7Kv84%#lGNy!@nQL?I+Ef_ijsddCSgS|G;x0liu@4 z8ns+q9e(?qOvX8zB`;pBUSAf$-!nUZ-%RoNnu$}j!$1A`e7^dwS!S`XM|D-)ft6eN zPM$ouO;&836~k#zJ7bye?0w}7(Up~!pn#bF`ND+&At9lh^ymZS&tpNeMu!bR1Cp(+ ztuF5Fpj91jZf@SYfAf;5f7iL*IvtrFx@F0V6(>CW{mt8WrPEm0*mkW+|2s`Ln#bPW z{`Oz1(pM>mUIg>2`v^!?nJl~6VZvNZ5Wa(1LhL7j0-&?$?^Y!(0 zn{QXkbKu08GbJ}leb@c@o}X1G)>XOe_|0>FEv7G7qQY#jZs#*8_OP3FAjN5E%L=cC zhS$D-zGv-!Ru&eClP6CeQ|`A(`twBH-VoFzTD)u5tylUN7n(d$DB=qY4BWQx9kc`Y z`MkYEOP4Rdyu6%0zV@pqD1DrnVHh_3%Btk!eIMV~|DU~JfkL}a@Qgoy{=B%q z-yWoBbNczRANLzYc`wYGKX+5&VK#<$d%xc+i+|s4|3~rW&6{}}d+(e--*1?F?8ZL- zXXk%TQuTgud3pca9PS_Q_H-XQ#=eYg9 zhnFULg$EP}WHwKjAdt7~rCQ#um&-2K9W<|BXkc1@kb{>u^+<=H!|lGew(s|R2Cb;8 ze7p6!jgrZMm-|iX|Np+fyR;x;YnJHOuU~tlOuN>_?zZ}JOMl-FB~Ut9+umoBchja{ zMIAIwvgP{8$?CWF?ptVCT;66qH$5rqU-7-LojZ4e2JPnbh4sfDu95pBkl7!(IZd*`F#DR$YH!1$qN1McbvuvE zu&dqm@x|hPsmsg#&4Yr2Z`(e(S;KY2M(M}<`v1GH#;48Ral6Dd+xhqQyJfQ(CG_`x znH2oaI`xzYv%xZ-nU5~K){EP7ta@H_P9B$)%ec@}_yE~aI?CjDFpzXTs zp)cpHTD`ir{C@3q4^K}+CMKqHzdpZTQ{UaUM3DWX$Ga=q3W|!2{dT`r9PKxi|73E% zaNE-xai3k>{a=?o=e#W1+uOVCTsLTcLGZheM}+-BD_^GR#h$vdGFWyS$AveK1A>Ac zO?Z9t@SIf!w`0n`y!hbQ%(gM(qLPu3QBJ*i*mm#oY0s1;&bq6do5SSk=~>wQZvFE` z4;189{;XMT{rbcN^_uOq1!rfOf+nwuQZoIn*6d1pc4p>to8t#=Mnws4$hxYv@8>gV zHXexw7cyI(omtyH+dLn%B)8_*%jJAK^GZrg8XFs@Oq*8p=m@7(`MWn0Uf()=$JMap z#Rb0j>`O~LL2aWVgIDx7SEy)9J&1Vt?RGxgSd}P|9_qzSQ%fnbLY+Z*TQlz2#@sd}keb zaIksh+O?{-ws$uC~cMl8a3m)?UpI`=-REsyhn8+GA?)SOi*+N z?`JzYb<25n`5Mr|;oslitLy2Vd&_?&x2TH!U~we#&0IH?2_h zt+1qI<*kK@Cr_UAnQ3%1vG~dJq<@Rd#4pWS%quA==@%_hxlAv1*NwJE^0i+A+hdC1 z?WpIQK0M$NkFWWd+5hMt*bD!E*Z=3YD4nuxvDo#UQ4tXm(&l+8s;aD@b@n=uP9-HK zot>b9u`hCSnql3aietUf$3cVfeDZd0HdOEbdM%pa$M4_KMz<1X+1J}KGBR#!3y>8R z5^_pPQsU+1%`4UOi2ixXDLo&Q+)s8*+Z|ST?U7ZuvA>#*j?R;F*6)|BTnSnQV`OYx z`EKX)h|OudGiS~`Gtc(+!rcaE-Zl%X`D{qpa&XFo2@Y;FJ-Z@BcRylpak?O+P-J9>2@R;=szg zG8ZpiG%+*VRQdTCpZ=Mc&1t-kkN1CnQ7;bKV{)`h)Mt*x#lO+I+zTEk$i2GgF8}mq z`ux;uYa&72#jxp*OHj9l-hRhmxjpahuEHlL1VKAEy_cgOQnmRf>7FPpSF4kS_?BvwZ+q-n(!h`C&^t!483Isk1JX1RP z=uy%weQ)*si#C1EnYm+FuCpN+xfAY zpyLTZ3%o(|%i5?dlp|mTC8c&Q8A&V zg{A8IyR-9bt8_}>ETgOx-#!zjLq#US6o6urg-i(HZv?&eWv8;{&Z{a21a|a->sOwrFoh2gjz( zn>95x0}BL}2ZMSV-gA}DPnj{}#JRcFYxNE*r=+Lf{#Wko+}t{K&bsLBd7v!TPj}2d<8YQDCmw9_z?sD6%Ek|q=bW~JYKx_6NWzBhhV`H+z>C0hCeajvw z$epx#d7(xm^W>pc?wucxNuNA>wsiGME_dVW8)o<(a+%{9Ec$3-&D!E6Tl`&ISl;jb sKJRL(#oRi%BU4QtDJUr^mHqy&QgrNLirT@O3=9kmp00i_>zopr0D}~^s{jB1 literal 0 HcmV?d00001