Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Don't use garbage Cues from Serato Tags or library. #11466

Merged
merged 17 commits into from
May 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/library/dao/cuedao.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ inline QString labelFromQVariant(const QVariant& value) {
CuePointer cueFromRow(const QSqlRecord& row) {
const auto id = DbId(row.value(row.indexOf("id")));
TrackId trackId(row.value(row.indexOf("track_id")));
int type = row.value(row.indexOf("type")).toInt();
auto type = static_cast<mixxx::CueType>(row.value(row.indexOf("type")).toInt());
double position = row.value(row.indexOf("position")).toDouble();
int length = row.value(row.indexOf("length")).toInt();
int hotcue = row.value(row.indexOf("hotcue")).toInt();
Expand All @@ -48,9 +48,19 @@ CuePointer cueFromRow(const QSqlRecord& row) {
VERIFY_OR_DEBUG_ASSERT(color) {
return CuePointer();
}
if (type == mixxx::CueType::Loop && length == 0) {
// These entries are likely added via issue #11283
qWarning() << "Discard loop cue" << hotcue << "found in database with length of 0";
return CuePointer();
}
if (type == mixxx::CueType::HotCue && position == 0 && *color == mixxx::RgbColor(0)) {
// These entries are likely added via issue #11283
qWarning() << "Discard black hot cue" << hotcue << "found in database at position 0";
return CuePointer();
}
Comment on lines +51 to +60
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there no better way to remove these invalid cuepoints from the db? maybe some sort of sanity check at startup or db migration?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was currently not able to re-add this issue. Maybe it is a pending 2.4 bug (keep investigating).
If this is a case a library migration is not possible.

A startup code will involve a startup penalty, because we need to check the whole library. So I think this on-the-fly removal is already a good option.

It could be an issue if we keep removing real cues that fit to the pattern.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A startup code will involve a startup penalty, because we need to check the whole library. So I think this on-the-fly removal is already a good option.

My issue is that this will only apply to tracks once they're loaded, in huge libraries, tracks with the deficiency could linger around for years until they've been loaded again. So this cleanup workaround will also have to stay forever. With a DB migration or something similar, the workaround could be removed after a couple versions when we can be certain that people that introduced this issue into their DB while testing the beta have cleaned it up by running a proper 2.4 release version once.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this concern is overengineering, just sharing my thoughts. I'm not suggesting another approach would be better.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, in understand, let's look at it case by case.

The fist condition makes always sense and is IMHO no candidate for removal. A loop Cue without a length will cause some side effects. So rejecting it from the library is kind of reasonable interdependent from the issue that causes such an entry. Downgrade it to a Hotcue would be the alternative, but this will increase the complexity.

The second condition is more special. Does a black hotcue at position 0 make sense in future? I cannot think of it.
Do you?

The black hotcues at 0 do no harm other than populating the cue buttons and the file metadata. So we may consider to leave them alone.

If we have discovered the reason for these garbage cues, we may reconsider a one time action, that we can provide for a fix.

Ideas?

Copy link
Member

@Swiftb0y Swiftb0y Apr 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well I think both types of cues are reasonable to be removed, my complaint is just that it happens lazily on track load while I would prefer a one-time cleanup action.

In any case, we should definitely try to find the source of the garbage data. I'm not sure how to do that. A sanity check after the export could be an option, but that has some overhead associated with it. Do you have a better idea?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cannot tell yet. I need to investigate, maybe we should hold this PR back until we know more.

The lazy clean up is simple and works without any other conditions. The only drawback I see is that we may have an issue if a user uses a version without this clean up code or a third party tool reading the Mixxx database. Not sure if that rectifies a more complex solution.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be in favor of finding the cause before trying to fix its symptoms.

CuePointer pCue(new Cue(id,
trackId,
static_cast<mixxx::CueType>(type),
type,
position,
length,
hotcue,
Expand Down Expand Up @@ -81,7 +91,7 @@ QList<CuePointer> CueDAO::getCuesForTrack(TrackId trackId) const {
QMap<int, CuePointer> hotCuesByNumber;
while (query.next()) {
CuePointer pCue = cueFromRow(query.record());
VERIFY_OR_DEBUG_ASSERT(pCue) {
if (!pCue) {
continue;
}
int hotCueNumber = pCue->getHotCue();
Expand Down
2 changes: 1 addition & 1 deletion src/test/cue_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ TEST(CueTest, ConvertCueInfoToCueRoundtrip) {
const auto cueInfo1 = CueInfo(
CueType::HotCue,
std::make_optional(1.0 * 44100 * mixxx::kEngineChannelCount),
std::make_optional(2.0 * 44100 * mixxx::kEngineChannelCount),
std::nullopt,
std::make_optional(3),
QStringLiteral("label"),
RgbColor::optional(0xABCDEF));
Expand Down
Binary file modified src/test/serato/data/mp3/markers_/very-long-names.octet-stream
Binary file not shown.
32 changes: 30 additions & 2 deletions src/track/cueinfo.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@

namespace mixxx {

namespace {

void assertEndPosition(
CueType type,
std::optional<double> endPositionMillis) {
switch (type) {
case CueType::HotCue:
case CueType::MainCue:
DEBUG_ASSERT(!endPositionMillis);
break;
case CueType::Loop:
case CueType::Jump:
case CueType::AudibleSound:
DEBUG_ASSERT(endPositionMillis);
break;
case CueType::Intro:
case CueType::Outro:
case CueType::Invalid:
break;
case CueType::Beat: // unused
default:
DEBUG_ASSERT(!"Unknown Loop Type");
}
}

} // namespace

CueInfo::CueInfo()
: m_type(CueType::Invalid),
m_startPositionMillis(std::nullopt),
Expand All @@ -28,6 +55,7 @@ CueInfo::CueInfo(
m_label(label),
m_color(color),
m_flags(flags) {
assertEndPosition(type, endPositionMillis);
}

CueType CueInfo::getType() const {
Expand All @@ -48,6 +76,7 @@ std::optional<double> CueInfo::getStartPositionMillis() const {

void CueInfo::setEndPositionMillis(std::optional<double> positionMillis) {
m_endPositionMillis = positionMillis;
assertEndPosition(m_type, m_endPositionMillis);
}

std::optional<double> CueInfo::getEndPositionMillis() const {
Expand All @@ -58,8 +87,7 @@ std::optional<int> CueInfo::getHotCueIndex() const {
return m_hotCueIndex;
}

void CueInfo::setHotCueIndex(const std::optional<int> hotCueIndex) {
DEBUG_ASSERT(!hotCueIndex || *hotCueIndex >= kFirstHotCueIndex);
void CueInfo::setHotCueIndex(int hotCueIndex) {
m_hotCueIndex = hotCueIndex;
}

Expand Down
3 changes: 1 addition & 2 deletions src/track/cueinfo.h
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,7 @@ class CueInfo {
std::optional<double> positionMillis = std::nullopt);

std::optional<int> getHotCueIndex() const;
void setHotCueIndex(
const std::optional<int> hotCueIndex = std::nullopt);
void setHotCueIndex(int hotCueIndex);

QString getLabel() const;
void setLabel(
Expand Down
174 changes: 95 additions & 79 deletions src/track/serato/tags.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,35 +40,26 @@ QString getPrimaryDecoderNameForFilePath(const QString& filePath) {
constexpr int kFirstLoopIndex = mixxx::kFirstHotCueIndex + 8;
constexpr int kNumCuesInMarkersTag = 5;

std::optional<int> findIndexForCueInfo(const mixxx::CueInfo& cueInfo) {
VERIFY_OR_DEBUG_ASSERT(cueInfo.getHotCueIndex()) {
qWarning() << "SeratoTags::getCues: Cue without number found!";
return std::nullopt;
bool isCueInfoValid(const mixxx::CueInfo& cueInfo) {
if (cueInfo.getType() == mixxx::CueType::Loop &&
cueInfo.getEndPositionMillis().value_or(0) == 0) {
// These entries are likely added via issue #11283
qWarning() << "Discard loop cue" << cueInfo.getHotCueIndex()
<< "with length of 0";
return false;
}

int index = *cueInfo.getHotCueIndex();
VERIFY_OR_DEBUG_ASSERT(index >= mixxx::kFirstHotCueIndex) {
qWarning() << "SeratoTags::getCues: Cue with number < 0 found!";
return std::nullopt;
if (cueInfo.getType() == mixxx::CueType::HotCue &&
cueInfo.getStartPositionMillis().value_or(0) == 0 &&
cueInfo.getColor().value_or(mixxx::RgbColor(0)) == mixxx::RgbColor(0)) {
// These entries are likely added via issue #11283
qWarning() << "Discard black hot cue" << cueInfo.getHotCueIndex()
<< "at position 0";
return false;
}

switch (cueInfo.getType()) {
case mixxx::CueType::HotCue:
if (index >= kFirstLoopIndex) {
qWarning()
<< "SeratoTags::getCues: Non-loop Cue with number >="
<< kFirstLoopIndex << "found!";
return std::nullopt;
}
break;
case mixxx::CueType::Loop:
index += kFirstLoopIndex;
break;
default:
return std::nullopt;
VERIFY_OR_DEBUG_ASSERT(cueInfo.getHotCueIndex()) {
return false;
}

return index;
return true;
}

} // namespace
Expand Down Expand Up @@ -200,70 +191,90 @@ QList<CueInfo> SeratoTags::getCueInfos() const {
// "Serato Markers_" and "Serato Markers2" contradict each other,
// Serato will use the values from "Serato Markers_").

QMap<int, CueInfo> cueMap;
QMap<int, CueInfo> hotCueMap;
QMap<int, CueInfo> loopCueMap;
const QList<CueInfo> cuesMarkers2 = m_seratoMarkers2.getCues();
for (const CueInfo& cueInfo : cuesMarkers2) {
std::optional<int> index = findIndexForCueInfo(cueInfo);
if (!index) {
if (!isCueInfoValid(cueInfo)) {
continue;
}

CueInfo newCueInfo(cueInfo);
newCueInfo.setHotCueIndex(index);
cueMap.insert(*index, newCueInfo);
if (cueInfo.getType() == CueType::Loop) {
loopCueMap.insert(*cueInfo.getHotCueIndex(), cueInfo);
} else {
hotCueMap.insert(*cueInfo.getHotCueIndex(), cueInfo);
}
};

// If the "Serato Markers_" tag does not exist at all, Serato DJ Pro just
// takes data from the "Serato Markers2" tag, so we can exit early
// here. If the "Serato Markers_" exists, its data will take precedence.
if (m_seratoMarkers.isEmpty()) {
return cueMap.values();
}

// The "Serato Markers_" tag always contains entries for the first five
// cues. If a cue is not set, that entry is present but empty.
// If a cue is set in "Serato Markers2" but not in "Serato Markers_",
// Serato DJ Pro considers it as "not set" and ignores it.
// To mirror the behaviour of Serato, we need to remove from the output of
// this function.
QSet<int> unsetCuesInMarkersTag;
for (int i = 0; i < kNumCuesInMarkersTag; i++) {
unsetCuesInMarkersTag.insert(i);
}

const QList<CueInfo> cuesMarkers = m_seratoMarkers.getCues();
for (const CueInfo& cueInfo : cuesMarkers) {
std::optional<int> index = findIndexForCueInfo(cueInfo);
if (!index) {
continue;
if (cuesMarkers.size() > 0) {
// The "Serato Markers_" tag always contains entries for the first five
// cues. If a cue is not set, that entry is present but empty.
// If a cue is set in "Serato Markers2" but not in "Serato Markers_",
// Serato DJ Pro considers it as "not set" and ignores it.
// To mirror the behaviour of Serato, we need to remove from the output of
// this function.
QSet<int> unsetCuesInMarkersTag;
unsetCuesInMarkersTag.reserve(kNumCuesInMarkersTag);
for (int i = 0; i < kNumCuesInMarkersTag; i++) {
unsetCuesInMarkersTag.insert(i);
}

// Take a pre-existing CueInfo object that was read from
// "SeratoMarkers2" from the CueMap (or a default constructed CueInfo
// object if none exists) and use it as template for the new CueInfo
// object. Then overwrite all object values that are present in the
// "SeratoMarkers_"tag.
CueInfo newCueInfo = cueMap.value(*index);
newCueInfo.setType(cueInfo.getType());
newCueInfo.setStartPositionMillis(cueInfo.getStartPositionMillis());
newCueInfo.setEndPositionMillis(cueInfo.getEndPositionMillis());
newCueInfo.setHotCueIndex(index);
newCueInfo.setFlags(cueInfo.flags());
newCueInfo.setColor(cueInfo.getColor());
cueMap.insert(*index, newCueInfo);
for (const CueInfo& cueInfo : cuesMarkers) {
if (!isCueInfoValid(cueInfo)) {
continue;
}
// Take a pre-existing CueInfo object that was read from
// "SeratoMarkers2" from the CueMap (or a default constructed CueInfo
// object if none exists) and use it as template for the new CueInfo
// object. Then overwrite all object values that are present in the
// "SeratoMarkers_"tag.
CueInfo& newCueInfo = cueInfo.getType() == CueType::Loop
? loopCueMap[*cueInfo.getHotCueIndex()]
: hotCueMap[*cueInfo.getHotCueIndex()];
newCueInfo.setType(cueInfo.getType());
newCueInfo.setStartPositionMillis(cueInfo.getStartPositionMillis());
newCueInfo.setEndPositionMillis(cueInfo.getEndPositionMillis());
newCueInfo.setHotCueIndex(*cueInfo.getHotCueIndex());
newCueInfo.setFlags(cueInfo.flags());
newCueInfo.setColor(cueInfo.getColor());

// This cue is set in the "Serato Markers_" tag, so remove it from the
// set of unset cues
unsetCuesInMarkersTag.remove(*cueInfo.getHotCueIndex());
};

// Now that we know which cues should be present in the "Serato Markers_"
// tag but aren't, remove them from the set.
for (const int index : unsetCuesInMarkersTag) {
hotCueMap.remove(index);
}
}

// This cue is set in the "Serato Markers_" tag, so remove it from the
// set of unset cues
unsetCuesInMarkersTag.remove(*index);
};
// Serato has a separate indexing for loop and hot cues.
// We sort the Loops Cues into the HotCue map if the given index is free,
// add an offset of 8 otherwise or loop until we find a free index.
// Cues above index 8 are not accessible in Mixxx, only visible in waveforms
// During export the Mixxx indexes are kept to allow a perfect round-trip.
Comment on lines +253 to +257
Copy link
Member

@Holzhaus Holzhaus May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like a regression to me. Now the ordering of hotcues and loops is quite unpredictable to the regular user.
The main target audience of this feature is people switching from Serato, where these banks are separate. In Mixxx, they are suddenly mixed.

In Serato, you have the following cues:

Cues:

  1. Hotcue A
  2. Hotcue B
  3. Hotcue D
  4. ...

Loops:

  1. Loop A
  2. Loop B
  3. Loop C
  4. Loop D
  5. ...

When importing into Mixxx, this will now suddenly become:

  1. Hotcue A
  2. Hotcue B
  3. Loop C
  4. Hotcue D
  5. ...
  6. ...
  7. ...
  8. ...
  9. Loop A
  10. Loop B
  11. Loop D
  12. ...

This is IMHO unexpected and should be reverted. The fixed offset of 8. makes more sense: If you have more than 8 cues and loops total, you need access to cues with index > 8 anyway.

Also, users that previously used Serato are likely to have a Serato-Style DJ controller, which features separate Hotcue and Loop banks. For example, the Roland DJ-505 mapping uses cues 1-8 for hotcues and 9-16 for loop cues. This way, these users can continue using their controller and tracks as expected.

Copy link
Member

@Holzhaus Holzhaus May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oof, bad timing... this was just merged :( In my defense, from the PR's title I expected this to be a bugfix, not a behavior change.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry for the bad timing

This is IMHO unexpected and should be reverted. The fixed offset of 8. makes more sense: If you have more than 8 cues and loops total, you need access to cues with index > 8 anyway.

I fully agree, but how would you keep the offset index from accumulating then? How would tell loop cues interspersed with hotcues by mixxx and loopcues from serato apart?

Copy link
Member

@Holzhaus Holzhaus May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the export is still marked as experimental, and if we have to make a compromise, then it should be in the export logic, not the import logic. It's a format for a different software, so some downside is to be expected.

Serato does not have interspersed hotcues/loop cues, so the order will have to change on export anyway.
One simple way would be to restore the previous import behavior and check if all loop cues have an index > 8. If so, subtract 8 from the index during export.
This would at least allow to preserve empty loop cue slots.

Example:

Cues:

  1. Hotcue A
  2. Hotcue B
  3. Hotcue D
  4. ...

Loops:

  1. Loop A
  2. Loop C
  3. Loop D
  4. ...

Becomes this in Mixxx:

  1. Hotcue A
  2. Hotcue B
  3. Hotcue D
  4. ...
  5. ...
  6. ...
  7. ...
  8. Loop A
  9. Loop C
  10. Loop D
  11. ...

And on export the original is restored:

Cues:

  1. Hotcue A
  2. Hotcue B
  3. Hotcue D
  4. ...

Loops:

  1. Loop A
  2. Loop C
  3. Loop D
  4. ...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this will be suboptimal for people who want to move away from Mixxx to Serato, but if we have to choose which kind of user we want to accomodate, it should be the user group that wants to migrate from Serato to Mixxx, instead of the group that wants to leave Mixxx anyway.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this are two different demands. The issue in Mixxx is that we have only 8 buttons in the GUI. So from your example only Loop C is usable for all users. For me the current solution is a good compromise that at least fixes the round-trip starting from Mixxx. I am afraid a full solution requires to store the Mixxx index and the Serato index separate. This is out of scope of this PR. Do you have an alternative solution?
Another open issue is that the white loop cues are becoming orange after the round-trip. Do you have an idea how to fix that?

A regular Mixxx user has probably

  1. Hotcue A
  2. Hotcue B
  3. ...
  4. Hotcue D
  5. Loop A
  6. Loop B
  7. Loop D

This is exported now and before as

Hotcues

  1. Hotcue A
  2. Hotcue B
  3. ...
  4. Hotcue D

Loops

  1. Loop A
  2. Loop B
  3. ...
  4. Loop D

After reading it back from the file it becomes before that PR:

  1. Hotcue A
  2. Hotcue B
  3. ...
  4. Hotcue D
  5. ...
  6. ...
  7. ...
  8. ...
  9. Loop A
  10. Loop B
  11. Loop D

The Loops are no longer accessible. And after another round-trip it is:

  1. Hotcue A
  2. Hotcue B
  3. ...
  4. Hotcue D
  5. ...
  6. ...
  7. ...
  8. ...
  9. ...
  10. ...
  11. ...
  12. ...
  13. ...
  14. ...
  15. ..
  16. ...
  17. Loop A
  18. Loop B
  19. Loop D

Copy link
Member

@Holzhaus Holzhaus May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me the current solution is a good compromise that at least fixes the round-trip starting from Mixxx.

Again, I'd argue that our focus should be on the people who want to switch from Serato to Mixxx, not the people who want to switch from Mixxx to Serato. The primary purpose of the experimental export feature is to avoid lock-in. Due to differences between Mixxx and Serato, this format is not suited for lossless transfor of cues between two Mixxx instances.

For Mixxx-only users, the proper solution would be to devise a custom format, e.g. something bson-based maybe. This could be stored in a different tag.

Another open issue is that the white loop cues are becoming orange after the round-trip.

Loop cues don't have colors in Serato, they are all blue. I hypothesized that there is a color field and just the GUI support is missing in Serato, because there is a field that would indicate the color blue, but setting a different "color" in the metadata did not work. We should probably remove color support for loop cues altogether, because it is not supported.

for (const CueInfo& loopCueInfo : qAsConst(loopCueMap)) {
if (hotCueMap.contains(*loopCueInfo.getHotCueIndex())) {
CueInfo cueInfo = loopCueInfo;
cueInfo.setHotCueIndex(*loopCueInfo.getHotCueIndex() + kFirstLoopIndex);
while (hotCueMap.contains(*cueInfo.getHotCueIndex())) {
cueInfo.setHotCueIndex(*cueInfo.getHotCueIndex() + 1);
}
hotCueMap.insert(*cueInfo.getHotCueIndex(), cueInfo);
} else {
hotCueMap.insert(*loopCueInfo.getHotCueIndex(), loopCueInfo);
}
}

// Now that we know which cues should be present in the "Serato Markers_"
// tag but aren't, remove them from the set.
for (const int index : unsetCuesInMarkersTag) {
cueMap.remove(index);
const QList<CueInfo> cueInfos = hotCueMap.values();
qDebug() << "SeratoTags::getCueInfos()";
for (const CueInfo& cueInfo : cueInfos) {
qDebug() << cueInfo;
}

return cueMap.values();
return cueInfos;
}

void SeratoTags::setCueInfos(const QList<CueInfo>& cueInfos, double timingOffsetMillis) {
Expand All @@ -283,13 +294,13 @@ void SeratoTags::setCueInfos(const QList<CueInfo>& cueInfos, double timingOffset
}

CueInfo newCueInfo(cueInfo);
if (!cueInfo.getStartPositionMillis()) {
if (!cueInfo.getStartPositionMillis().has_value()) {
continue;
}
newCueInfo.setStartPositionMillis(
*cueInfo.getStartPositionMillis() - timingOffsetMillis);

if (cueInfo.getEndPositionMillis()) {
if (cueInfo.getEndPositionMillis().has_value()) {
newCueInfo.setEndPositionMillis(*cueInfo.getEndPositionMillis() - timingOffsetMillis);
}
newCueInfo.setFlags(cueInfo.flags());
Expand All @@ -299,6 +310,11 @@ void SeratoTags::setCueInfos(const QList<CueInfo>& cueInfos, double timingOffset
cueMap.insert(hotcueIndex, newCueInfo);
break;
case CueType::Loop:
if (!newCueInfo.getEndPositionMillis().has_value()) {
qWarning() << "Loop Cue" << hotcueIndex << "has no end position";
DEBUG_ASSERT(false);
continue;
}
loopMap.insert(hotcueIndex, newCueInfo);
break;
default:
Expand Down
Loading