344 lines
12 KiB
C++
344 lines
12 KiB
C++
#include "fileops.h"
|
|
|
|
#include "uisocket.h"
|
|
#include "data/project.h"
|
|
#include "data/graph.h"
|
|
|
|
#include <QDebug>
|
|
|
|
#include <QFile>
|
|
#include <QCborMap>
|
|
#include <QCborArray>
|
|
#include <QCborStreamReader>
|
|
#include <QCborStreamWriter>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QJsonValue>
|
|
|
|
#include <QUndoStack>
|
|
#include <QFileDialog>
|
|
|
|
#define qs QStringLiteral
|
|
|
|
using Xybrid::Data::Project;
|
|
using Xybrid::Data::Pattern;
|
|
using Xybrid::Data::Sample;
|
|
using Xybrid::Data::Graph;
|
|
using Xybrid::Data::Node;
|
|
|
|
namespace FileOps = Xybrid::FileOps;
|
|
|
|
namespace {
|
|
constexpr uint32_t packedVersion(uint8_t major = 0, uint8_t minor = 0, uint8_t revision = 0, uint8_t wip = 0) {
|
|
return + (static_cast<uint32_t>(wip))
|
|
+ (static_cast<uint32_t>(revision)<<8)
|
|
+ (static_cast<uint32_t>(minor)<<16)
|
|
+ (static_cast<uint32_t>(major)<<24);
|
|
}
|
|
constexpr const uint32_t XYBRID_VERSION = packedVersion(0,0,0,2);
|
|
|
|
constexpr const QSize dlgSize(700, 500);
|
|
}
|
|
|
|
const QString FileOps::Filter::project = qs("Xybrid project (*.xyp);;All files (*)");
|
|
const QString FileOps::Filter::node = qs("Xybrid node (*.xyn);;All files (*)");
|
|
|
|
const QString FileOps::Filter::audioIn = qs("Audio files (*.mp3 *.ogg *.flac *.wav);;MPEG Layer 3 (*.mp3);;All files (*)");
|
|
const QString FileOps::Filter::audioOut = qs("Audio files (*.mp3 *.flac);;MPEG Layer 3 (*.mp3);;FLAC (*.flac)"); // only supported formats
|
|
|
|
QString FileOps::showOpenDialog(QWidget* parent, const QString& caption, const QString& directory, const QString& filter) {
|
|
QFileDialog dlg(parent, caption, directory, filter);
|
|
dlg.resize(dlgSize);
|
|
dlg.setFileMode(QFileDialog::ExistingFile);
|
|
dlg.setAcceptMode(QFileDialog::AcceptOpen);
|
|
if (!dlg.exec()) return QString(); // canceled
|
|
auto sf = dlg.selectedFiles().at(0);
|
|
return sf;
|
|
}
|
|
|
|
QString FileOps::showSaveAsDialog(QWidget* parent, const QString& caption, const QString& directory, const QString& filter, const QString& suffix) {
|
|
QFileDialog dlg(parent, caption, directory, filter);
|
|
dlg.resize(dlgSize);
|
|
dlg.setDefaultSuffix(suffix);
|
|
dlg.setFileMode(QFileDialog::AnyFile);
|
|
dlg.setAcceptMode(QFileDialog::AcceptSave);
|
|
if (!dlg.exec()) return QString(); // canceled
|
|
auto sf = dlg.selectedFiles().at(0);
|
|
return sf;
|
|
}
|
|
|
|
bool FileOps::saveProject(std::shared_ptr<Project> project, QString fileName) {
|
|
if (!fileName.isEmpty()) project->fileName = fileName;
|
|
else fileName = project->fileName;
|
|
if (fileName.isEmpty()) return false; // fail
|
|
|
|
QFile file(fileName);
|
|
if (!file.open({QFile::WriteOnly})) return false;
|
|
|
|
// header
|
|
QCborArray root;
|
|
root << "xybrid:project" << XYBRID_VERSION;
|
|
{ /* Main body */ } {
|
|
QCborMap main;
|
|
|
|
{ /* Project metadata */ } {
|
|
QCborMap meta;
|
|
|
|
meta[qs("artist")] = project->artist;
|
|
meta[qs("title")] = project->title;
|
|
meta[qs("comment")] = project->comment;
|
|
meta[qs("tempo")] = project->tempo;
|
|
|
|
main[qs("meta")] = meta;
|
|
}
|
|
|
|
{ /* Sequence */ } {
|
|
QCborArray seq;
|
|
for (auto& s : project->sequence) seq << s;
|
|
main[qs("sequence")] = seq;
|
|
}
|
|
|
|
{ /* Patterns */ } {
|
|
QCborArray ptns;
|
|
for (auto& p : project->patterns) {
|
|
QCborMap pm;
|
|
pm[qs("name")] = p->name;
|
|
pm[qs("fold")] = p->fold;
|
|
pm[qs("tempo")] = p->tempo;
|
|
|
|
{
|
|
QCborArray ts;
|
|
ts << p->time.beatsPerMeasure << p->time.rowsPerBeat << p->time.ticksPerRow;
|
|
pm[qs("time")] = ts;
|
|
}
|
|
|
|
QCborArray chns;
|
|
bool needsCount = true;
|
|
for (auto& ch : p->channels) {
|
|
QCborMap chm;
|
|
chm[qs("name")] = ch.name;
|
|
QCborArray rows;
|
|
int skipped = 0;
|
|
for (auto& r : ch.rows) {
|
|
if (r.isEmpty()) {
|
|
skipped++; // compact empty rows into a simple number
|
|
continue;
|
|
} else if (skipped > 0) { // ...and insert said number only if there's data after
|
|
rows << skipped;
|
|
skipped = 0;
|
|
}
|
|
QCborArray row;
|
|
row << r.port << r.note;
|
|
if (r.params) for (auto p : *r.params) row << p[0] << p[1];
|
|
rows << row;
|
|
}
|
|
if (!ch.rows.back().isEmpty()) needsCount = false; // omit extra count if any channel has data on the last row
|
|
chm[qs("rows")] = rows;
|
|
chns << chm;
|
|
}
|
|
if (needsCount) chns << p->rows; // if no pattern data reaches the last row, store a manual count
|
|
if (chns.size() != 0) pm[qs("channels")] = chns;
|
|
ptns << pm;
|
|
}
|
|
main[qs("patterns")] = ptns;
|
|
}
|
|
|
|
{ /* Samples */ } {
|
|
QCborMap smp;
|
|
for (auto& s : qAsConst(project->samples)) smp[QCborValue(s->uuid)] = s->toCbor();
|
|
main[qs("samples")] = smp;
|
|
}
|
|
|
|
{ /* Graph */ } {
|
|
QCborMap g;
|
|
project->rootGraph->saveData(g);
|
|
main[qs("graph")] = g;
|
|
}
|
|
|
|
root << main;
|
|
}
|
|
|
|
// write out
|
|
QCborStreamWriter w(&file);
|
|
root.toCborValue().toCbor(w);
|
|
file.close();
|
|
|
|
if (project->socket && project->socket->undoStack) project->socket->undoStack->setClean();
|
|
|
|
return true;
|
|
}
|
|
|
|
std::shared_ptr<Project> FileOps::loadProject(QString fileName, bool asTemplate) {
|
|
QCborArray root;
|
|
{
|
|
QFile file(fileName);
|
|
if (!file.open(QFile::ReadOnly)) return nullptr;
|
|
QCborStreamReader read(&file);
|
|
|
|
// if it's not an array it'll just return an empty which will fail the header check anyway
|
|
root = QCborValue::fromCbor(read).toArray();
|
|
file.close(); // don't want to leave this hanging any longer than necessary
|
|
}
|
|
|
|
// header and sanity checks
|
|
if (root.at(0) != qs("xybrid:project")) return nullptr; // not a project
|
|
if (auto v = root.at(1); !v.isInteger() || v.toInteger() > XYBRID_VERSION) return nullptr; // invalid version or too new
|
|
if (!root.at(2).isMap()) return nullptr; // so close, but... nope
|
|
|
|
auto project = std::make_shared<Project>();
|
|
if (!asTemplate) project->fileName = fileName;
|
|
QCborMap main = root.at(2).toMap();
|
|
|
|
{ /* Project metadata */ } {
|
|
QCborMap meta = main.value("meta").toMap();
|
|
project->artist = meta.value("artist").toString();
|
|
project->title = meta.value("title").toString();
|
|
project->comment = meta.value("comment").toString();
|
|
project->tempo = meta.value("tempo").toDouble(project->tempo);
|
|
}
|
|
|
|
{ /* Patterns */ } {
|
|
QCborArray ptns = main.value("patterns").toArray();
|
|
project->patterns.reserve(static_cast<size_t>(ptns.size()));
|
|
for (auto pm_ : ptns) {
|
|
auto pm = pm_.toMap();
|
|
auto p = std::make_shared<Pattern>(pm.value("rows").toInteger(1));
|
|
p->project = project.get();
|
|
project->patterns.push_back(p);
|
|
p->name = pm.value("name").toString();
|
|
p->fold = static_cast<int>(pm.value("fold").toInteger(0));
|
|
p->tempo = pm.value("tempo").toDouble(p->tempo);
|
|
|
|
if (auto ts = pm.value("time").toArray(); !ts.empty())
|
|
p->time = Data::TimeSignature(static_cast<int>(ts[0].toInteger()), static_cast<int>(ts[1].toInteger()), static_cast<int>(ts[2].toInteger()));
|
|
|
|
auto chns = pm.value("channels").toArray();
|
|
for (auto chm_ : chns) {
|
|
if (chm_.isInteger()) { // can have a number of rows encoded as part of the channels map
|
|
p->rows = std::max(p->rows, static_cast<int>(chm_.toInteger()));
|
|
continue;
|
|
}
|
|
auto chm = chm_.toMap();
|
|
auto& ch = p->channels.emplace_back();
|
|
ch.name = chm.value("name").toString();
|
|
|
|
auto rows = chm.value("rows").toArray();
|
|
ch.rows.reserve(static_cast<size_t>(rows.size()));
|
|
for (auto row_ : rows) {
|
|
if (row_.isInteger()) { // empty space encoded as a number of rows to skip
|
|
for (auto i = row_.toInteger(); i > 0; i--) ch.rows.emplace_back();
|
|
continue;
|
|
}
|
|
auto& r = ch.rows.emplace_back();
|
|
if (row_.isNull()) continue;
|
|
auto row = row_.toArray();
|
|
r.port = static_cast<int16_t>(row.at(0).toInteger(-1));
|
|
r.note = static_cast<int16_t>(row.at(1).toInteger(-1));
|
|
for (int i = 2; i < row.size(); i += 2) r.addParam(static_cast<char>(row.at(i).toInteger(' ')), static_cast<unsigned char>(row.at(i+1).toInteger()));
|
|
}
|
|
p->rows = std::max(p->rows, static_cast<int>(ch.rows.size()));
|
|
}
|
|
|
|
p->setLength(p->rows);
|
|
}
|
|
project->updatePatternIndices();
|
|
}
|
|
|
|
{ /* Sequence */ } {
|
|
QCborArray seq = main.value("sequence").toArray();
|
|
project->sequence.reserve(static_cast<size_t>(seq.size()));
|
|
for (auto s : seq) project->sequence.emplace_back(project.get(), QCborValue(s));
|
|
}
|
|
|
|
{ /* Samples */ } {
|
|
QCborMap smp = main.value("samples").toMap();
|
|
for (auto it = smp.constBegin(), end = smp.constEnd(); it != end; ++it) {
|
|
auto s = Sample::fromCbor(it.value(), it.key().toUuid());
|
|
s->project = project.get();
|
|
project->samples.insert(s->uuid, s);
|
|
//qDebug() << "loaded sample " << s->name << "of length" << s->length() << "with" << s->numChannels() << "channels";
|
|
}
|
|
}
|
|
|
|
{ /* Graph */ } {
|
|
QCborMap g = main.value("graph").toMap();
|
|
if (!g.isEmpty()) project->rootGraph->loadData(g);
|
|
}
|
|
|
|
return project;
|
|
}
|
|
|
|
std::shared_ptr<Project> FileOps::newProject(bool useTemplate) {
|
|
std::shared_ptr<Project> project;
|
|
if (useTemplate) {
|
|
project = loadProject(Config::Directories::userDefaultTemplate, true);
|
|
if (!project) project = loadProject(":/template/default.xyp", true);
|
|
}
|
|
if (!project) {
|
|
project = std::make_shared<Project>();
|
|
project->sequence.emplace_back(project->newPattern());
|
|
}
|
|
|
|
return project;
|
|
}
|
|
|
|
bool FileOps::saveNode(std::shared_ptr<Node> node, QString fileName) {
|
|
QFile file(fileName);
|
|
if (!file.open({QFile::WriteOnly})) return false;
|
|
|
|
Sample::startExport();
|
|
|
|
// we handle header and let the node handle the rest of its conversion
|
|
QCborArray root;
|
|
root << "xybrid:node" << XYBRID_VERSION << node->toCbor();
|
|
|
|
// and write in any exported samples
|
|
if (auto v = Sample::finishExport(); !v.empty()) {
|
|
QCborMap smp;
|
|
for (auto& s : v) smp[QCborValue(s->uuid)] = s->toCbor();
|
|
root << smp;
|
|
}
|
|
|
|
// write out
|
|
QCborStreamWriter w(&file);
|
|
root.toCborValue().toCbor(w);
|
|
file.close();
|
|
|
|
return true;
|
|
}
|
|
|
|
std::shared_ptr<Node> FileOps::loadNode(QString fileName, std::shared_ptr<Graph> parent) {
|
|
QCborArray root;
|
|
{
|
|
QFile file(fileName);
|
|
if (!file.open(QFile::ReadOnly)) return nullptr;
|
|
QCborStreamReader read(&file);
|
|
|
|
// if it's not an array it'll just return an empty which will fail the header check anyway
|
|
root = QCborValue::fromCbor(read).toArray();
|
|
file.close(); // don't want to leave this hanging any longer than necessary
|
|
}
|
|
|
|
// header and sanity checks
|
|
if (root.at(0) != QString("xybrid:node")) return nullptr; // not a node
|
|
if (auto v = root.at(1); !v.isInteger() || v.toInteger() > XYBRID_VERSION) return nullptr; // invalid version or too new
|
|
if (!root.at(2).isMap()) return nullptr; // so close, but... nope
|
|
|
|
if (root.at(3).isMap()) { // node file has samples, load in any we don't already have
|
|
auto smp = root.at(3).toMap();
|
|
auto project = parent->project;
|
|
for (auto it = smp.constBegin(), end = smp.constEnd(); it != end; ++it) {
|
|
auto uuid = it.key().toUuid();
|
|
if (project->samples.find(uuid) != project->samples.end()) continue; // we already have this; next
|
|
auto s = Sample::fromCbor(it.value(), uuid);
|
|
s->project = project;
|
|
project->samples.insert(s->uuid, s);
|
|
}
|
|
emit project->socket->updatePatternLists();
|
|
}
|
|
|
|
return Node::fromCbor(root.at(2), parent); // let Node handle the rest
|
|
}
|
|
|
|
|