Git commit c25850c2d9748d94a42443c320aae35234aee49d by Sandro S. Andrade. Committed on 13/07/2026 at 18:16. Pushed by sandroandrade into branch 'master'.
Add rhythm with rests exercises M +1 -0 data/definitions/CMakeLists.txt M +8 -8 data/definitions/rhythm-definitions.json A +194 -0 data/definitions/rhythm-rest-definitions.json M +2 -0 data/exercises/CMakeLists.txt A +19 -0 data/exercises/rhythm-easy-rests.json A +19 -0 data/exercises/rhythm-medium-rests.json M +6 -0 doc/index.docbook M +9 -0 src/app/CMakeLists.txt A +286 -0 src/app/autotests/rhythmresttest.cpp [License: GPL(v2.0+)] M +10 -0 src/app/exercisecatalogcontroller.cpp M +17 -5 src/app/exercisesessioncontroller.cpp M +13 -5 src/app/qml/ExerciseClappingView.qml M +4 -0 src/app/qml/PracticeModePage.qml M +7 -11 src/plugins/fluidsynthsoundcontroller/fluidsynthsoundcontroller.cpp A +43 -0 src/utils/rhythmtoken.h [License: GPL(v2.0+)] https://invent.kde.org/education/minuet/-/commit/c25850c2d9748d94a42443c320aae35234aee49d diff --git a/data/definitions/CMakeLists.txt b/data/definitions/CMakeLists.txt index ee43ade..57225c9 100644 --- a/data/definitions/CMakeLists.txt +++ b/data/definitions/CMakeLists.txt @@ -16,6 +16,7 @@ set(MINUET_DEFINITIONS scales-simmetric-definitions.json scales-bebop-definitions.json rhythm-definitions.json + rhythm-rest-definitions.json ) install(FILES ${MINUET_DEFINITIONS} diff --git a/data/definitions/rhythm-definitions.json b/data/definitions/rhythm-definitions.json index 977aaf8..a4587b9 100644 --- a/data/definitions/rhythm-definitions.json +++ b/data/definitions/rhythm-definitions.json @@ -1,42 +1,42 @@ { "definitions": [ { - "tags": ["rhythm", "easy", "medium"], + "tags": ["rhythm", "easy", "medium", "easy-with-rests", "medium-with-rests", "rest-listening"], "name": "\uE1F0", "sequence": "4" }, { - "tags": ["rhythm", "easy", "medium"], + "tags": ["rhythm", "easy", "medium", "easy-with-rests", "medium-with-rests", "rest-listening"], "name": "\uE1F0\uE1F7\uE1F2", "sequence": "8 8" }, { - "tags": ["rhythm", "easy", "medium"], + "tags": ["rhythm", "easy", "medium", "easy-with-rests", "medium-with-rests", "rest-listening"], "name": "\uE1F0\uE1F7\uE1FC\uE1F7\uE1F4", "sequence": "8. 16" }, { - "tags": ["rhythm", "easy", "medium"], + "tags": ["rhythm", "easy", "medium", "easy-with-rests", "medium-with-rests", "rest-listening"], "name": "\uE1F0\uE1F9\uE1F2\uE1FC", "sequence": "16 8." }, { - "tags": ["rhythm", "medium"], + "tags": ["rhythm", "medium", "medium-with-rests", "rest-listening"], "name": "\uE1F0\uE1F9\uE1F4\uE1F9\uE1F4\uE1F9\uE1F4", "sequence": "16 16 16 16" }, { - "tags": ["rhythm", "medium"], + "tags": ["rhythm", "medium", "medium-with-rests", "rest-listening"], "name": "\uE1F0\uE1F7\uE1F2\uE1F9\uE1F4", "sequence": "8 16 16" }, { - "tags": ["rhythm", "medium"], + "tags": ["rhythm", "medium", "medium-with-rests", "rest-listening"], "name": "\uE1F0\uE1F9\uE1F2\uE1F7\uE1F4", "sequence": "16 8 16" }, { - "tags": ["rhythm", "medium"], + "tags": ["rhythm", "medium", "medium-with-rests", "rest-listening"], "name": "\uE1F0\uE1F9\uE1F4\uE1F7\uE1F2", "sequence": "16 16 8" } diff --git a/data/definitions/rhythm-rest-definitions.json b/data/definitions/rhythm-rest-definitions.json new file mode 100644 index 0000000..4b0dce3 --- /dev/null +++ b/data/definitions/rhythm-rest-definitions.json @@ -0,0 +1,194 @@ +{ + "definitions": [ + { + "tags": ["rhythm", "easy-with-rests", "medium-with-rests", "rest-listening"], + "name": "\uE4E6 \uE1D7", + "sequence": "r8 8" + }, + { + "tags": ["rhythm", "easy-with-rests", "medium-with-rests"], + "name": "\uE1D7 \uE4E6", + "sequence": "8 r8" + }, + { + "tags": ["rhythm", "easy-with-rests", "medium-with-rests", "rest-listening"], + "name": "\uE4E6\uE1E7 \uE1D9", + "sequence": "r8. 16" + }, + { + "tags": ["rhythm", "easy-with-rests", "medium-with-rests"], + "name": "\uE1D7\uE1E7 \uE4E7", + "sequence": "8. r16" + }, + { + "tags": ["rhythm", "easy-with-rests", "medium-with-rests", "rest-listening"], + "name": "\uE4E7 \uE1D7\uE1E7", + "sequence": "r16 8." + }, + { + "tags": ["rhythm", "easy-with-rests", "medium-with-rests"], + "name": "\uE1D9 \uE4E6\uE1E7", + "sequence": "16 r8." + }, + { + "tags": ["rhythm", "medium-with-rests", "rest-listening"], + "name": "\uE4E7 \uE1F0\uE1F9\uE1F4\uE1F9\uE1F4", + "sequence": "r16 16 16 16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1D9 \uE4E7 \uE1F0\uE1F9\uE1F4", + "sequence": "16 r16 16 16" + }, + { + "tags": ["rhythm", "medium-with-rests", "rest-listening"], + "name": "\uE4E7 \uE4E7 \uE1F0\uE1F9\uE1F4", + "sequence": "r16 r16 16 16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1F0\uE1F9\uE1F4 \uE4E7 \uE1D9", + "sequence": "16 16 r16 16" + }, + { + "tags": ["rhythm", "medium-with-rests", "rest-listening"], + "name": "\uE4E7 \uE1D9 \uE4E7 \uE1D9", + "sequence": "r16 16 r16 16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1D9 \uE4E7 \uE4E7 \uE1D9", + "sequence": "16 r16 r16 16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE4E7 \uE4E7 \uE4E7 \uE1D9", + "sequence": "r16 r16 r16 16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1F0\uE1F9\uE1F4\uE1F9\uE1F4 \uE4E7", + "sequence": "16 16 16 r16" + }, + { + "tags": ["rhythm", "medium-with-rests", "rest-listening"], + "name": "\uE4E7 \uE1F0\uE1F9\uE1F4 \uE4E7", + "sequence": "r16 16 16 r16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1D9 \uE4E7 \uE1D9 \uE4E7", + "sequence": "16 r16 16 r16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE4E7 \uE4E7 \uE1D9 \uE4E7", + "sequence": "r16 r16 16 r16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1F0\uE1F9\uE1F4 \uE4E7 \uE4E7", + "sequence": "16 16 r16 r16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE4E7 \uE1D9 \uE4E7 \uE4E7", + "sequence": "r16 16 r16 r16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1D9 \uE4E7 \uE4E7 \uE4E7", + "sequence": "16 r16 r16 r16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE4E6 \uE1F0\uE1F9\uE1F4", + "sequence": "r8 16 16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1D7 \uE4E7 \uE1D9", + "sequence": "8 r16 16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE4E6 \uE4E7 \uE1D9", + "sequence": "r8 r16 16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1D7 \uE1D9 \uE4E7", + "sequence": "8 16 r16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE4E6 \uE1D9 \uE4E7", + "sequence": "r8 16 r16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1D7 \uE4E7 \uE4E7", + "sequence": "8 r16 r16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE4E7 \uE1D7 \uE1D9", + "sequence": "r16 8 16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1D9 \uE4E6 \uE1D9", + "sequence": "16 r8 16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE4E7 \uE4E6 \uE1D9", + "sequence": "r16 r8 16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1D9 \uE1D7 \uE4E7", + "sequence": "16 8 r16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE4E7 \uE1D7 \uE4E7", + "sequence": "r16 8 r16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1D9 \uE4E6 \uE4E7", + "sequence": "16 r8 r16" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE4E7 \uE1D9 \uE1D7", + "sequence": "r16 16 8" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1D9 \uE4E7 \uE1D7", + "sequence": "16 r16 8" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE4E7 \uE4E7 \uE1D7", + "sequence": "r16 r16 8" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1F0\uE1F9\uE1F4 \uE4E6", + "sequence": "16 16 r8" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE4E7 \uE1D9 \uE4E6", + "sequence": "r16 16 r8" + }, + { + "tags": ["rhythm", "medium-with-rests"], + "name": "\uE1D9 \uE4E7 \uE4E6", + "sequence": "16 r16 r8" + } + ] +} diff --git a/data/exercises/CMakeLists.txt b/data/exercises/CMakeLists.txt index 103d817..f387dfb 100644 --- a/data/exercises/CMakeLists.txt +++ b/data/exercises/CMakeLists.txt @@ -18,7 +18,9 @@ set(MINUET_EXERCISES scales-simmetric-exercises.json scales-bebop-exercises.json rhythm-easy.json + rhythm-easy-rests.json rhythm-medium.json + rhythm-medium-rests.json ) install(FILES ${MINUET_EXERCISES} diff --git a/data/exercises/rhythm-easy-rests.json b/data/exercises/rhythm-easy-rests.json new file mode 100644 index 0000000..f580166 --- /dev/null +++ b/data/exercises/rhythm-easy-rests.json @@ -0,0 +1,19 @@ +{ + "exercises": [ + { + "name": "Rhythm", + "playMode": "rhythm", + "userMessage": "Hear the rhythm and choose your answers", + "template": "lilypond-templates/rhythm.ly.tmpl", + "_icon": "minuet-rhythms-symbolic.svg", + "children": [ + { + "name": "Easy with rests", + "and-tags": ["rhythm", "easy-with-rests", "rest-listening"], + "clapping-and-tags": ["rhythm", "easy-with-rests"], + "description": "Practice basic rhythm patterns that combine notes and rests." + } + ] + } + ] +} diff --git a/data/exercises/rhythm-medium-rests.json b/data/exercises/rhythm-medium-rests.json new file mode 100644 index 0000000..f964d69 --- /dev/null +++ b/data/exercises/rhythm-medium-rests.json @@ -0,0 +1,19 @@ +{ + "exercises": [ + { + "name": "Rhythm", + "playMode": "rhythm", + "userMessage": "Hear the rhythm and choose your answers", + "template": "lilypond-templates/rhythm.ly.tmpl", + "_icon": "minuet-rhythms-symbolic.svg", + "children": [ + { + "name": "Medium with rests", + "and-tags": ["rhythm", "medium-with-rests", "rest-listening"], + "clapping-and-tags": ["rhythm", "medium-with-rests"], + "description": "Practice varied rhythm patterns that combine notes and rests." + } + ] + } + ] +} diff --git a/doc/index.docbook b/doc/index.docbook index 2071fa3..90ce883 100644 --- a/doc/index.docbook +++ b/doc/index.docbook @@ -220,6 +220,12 @@ Rhythm exercises focus on timing rather than pitch. In the clapping mode, &minue microphone input and compares the onsets to the target pattern. </para> +<para> +The <guilabel>Easy with rests</guilabel> and <guilabel>Medium with rests</guilabel> categories add +silent positions to the corresponding note-only patterns. Listening exercises avoid choices that +sound identical, while <guilabel>Read and clap</guilabel> includes every mixed note-and-rest pattern. +</para> + <screenshot> <screeninfo>&minuet; clapping exercise</screeninfo> <mediaobject> diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index a12e540..9bc6611 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -330,6 +330,15 @@ if(NOT MINUET_MOBILE_PLATFORM) target_link_libraries(minuet PRIVATE KF6::Crash) endif() +if(BUILD_TESTING AND NOT MINUET_MOBILE_PLATFORM) + add_executable(rhythmresttest autotests/rhythmresttest.cpp) + target_compile_definitions( + rhythmresttest PRIVATE MINUET_SOURCE_DIR="${CMAKE_SOURCE_DIR}" + ) + target_link_libraries(rhythmresttest Qt::Core Qt::Test Minuet::Utils) + add_test(NAME rhythmresttest COMMAND rhythmresttest) +endif() + if(NOT MINUET_MOBILE_PLATFORM) install(TARGETS minuet ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) install(PROGRAMS org.kde.minuet.desktop DESTINATION ${KDE_INSTALL_APPDIR}) diff --git a/src/app/autotests/rhythmresttest.cpp b/src/app/autotests/rhythmresttest.cpp new file mode 100644 index 0000000..958f6cb --- /dev/null +++ b/src/app/autotests/rhythmresttest.cpp @@ -0,0 +1,286 @@ +// SPDX-FileCopyrightText: 2026 Sandro Andrade <[email protected]> +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#include <utils/rhythmtoken.h> + +#include <QFile> +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonObject> +#include <QSet> +#include <QTest> + +#include <utility> + +using namespace Qt::StringLiterals; + +namespace +{ +QJsonArray loadArray(const QString &relativePath, const QString &arrayName) +{ + QFile file(u"%1/%2"_s.arg(QStringLiteral(MINUET_SOURCE_DIR), relativePath)); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return QJsonDocument::fromJson(file.readAll()).object().value(arrayName).toArray(); +} + +bool hasTag(const QJsonObject &definition, const QString &tag) +{ + return definition.value(u"tags"_s).toArray().contains(tag); +} + +QJsonArray definitionsWithTags(const QJsonArray &definitions, const QStringList &tags) +{ + QJsonArray filtered; + for (const QJsonValue &value : definitions) { + const QJsonObject definition = value.toObject(); + bool matches = true; + for (const QString &tag : tags) { + if (!hasTag(definition, tag)) { + matches = false; + break; + } + } + if (matches) { + filtered.append(definition); + } + } + return filtered; +} + +QStringList stringList(const QJsonArray &values) +{ + QStringList strings; + for (const QJsonValue &value : values) { + strings.append(value.toString()); + } + return strings; +} + +QJsonObject restCategory(const QString &relativePath) +{ + const QJsonArray exercises = loadArray(relativePath, u"exercises"_s); + if (exercises.isEmpty()) { + return {}; + } + const QJsonArray children = exercises.first().toObject().value(u"children"_s).toArray(); + for (const QJsonValue &value : children) { + const QJsonObject category = value.toObject(); + if (category.value(u"name"_s).toString().endsWith(u"with rests"_s)) { + return category; + } + } + return {}; +} + +QString nameForSequence(const QJsonArray &definitions, const QString &sequence) +{ + for (const QJsonValue &value : definitions) { + const QJsonObject definition = value.toObject(); + if (definition.value(u"sequence"_s).toString() == sequence) { + return definition.value(u"name"_s).toString(); + } + } + return {}; +} + +QString onsetSignature(const QString &sequence) +{ + QStringList onsets; + double cursor = 0.0; + for (const QString &text : sequence.split(u' ', Qt::SkipEmptyParts)) { + const Minuet::RhythmToken token = Minuet::parseRhythmToken(text); + if (!token.rest) { + onsets.append(QString::number(cursor, 'f', 2)); + } + cursor += token.quarterNoteBeats(); + } + return onsets.join(u','); +} + +QSet<QString> expectedRestVariants(const QJsonArray &baseDefinitions) +{ + QSet<QString> variants; + for (const QJsonValue &value : baseDefinitions) { + const QStringList tokens = value.toObject().value(u"sequence"_s).toString().split(u' ', Qt::SkipEmptyParts); + const int allRestMask = (1 << tokens.size()) - 1; + for (int mask = 1; mask < allRestMask; ++mask) { + QStringList variant = tokens; + for (int tokenIndex = 0; tokenIndex < variant.size(); ++tokenIndex) { + if (mask & (1 << tokenIndex)) { + variant[tokenIndex].prepend(u'r'); + } + } + variants.insert(variant.join(u' ')); + } + } + return variants; +} +} + +class RhythmRestTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void initTestCase(); + void parsesRhythmTokens(); + void containsEveryMixedRestVariant(); + void categoryCountsAreStable(); + void categoryFiltersMatchPracticeModes(); + void consecutiveSixteenthsUseBeams(); + void listeningPatternsHaveUniqueOnsets(); + void everyPatternFillsOneBeat(); + +private: + QJsonArray m_baseDefinitions; + QJsonArray m_restDefinitions; + QJsonArray m_allDefinitions; +}; + +void RhythmRestTest::initTestCase() +{ + m_baseDefinitions = loadArray(u"data/definitions/rhythm-definitions.json"_s, u"definitions"_s); + m_restDefinitions = loadArray(u"data/definitions/rhythm-rest-definitions.json"_s, u"definitions"_s); + QVERIFY(!m_baseDefinitions.isEmpty()); + QVERIFY(!m_restDefinitions.isEmpty()); + + m_allDefinitions = m_baseDefinitions; + for (const QJsonValue &definition : std::as_const(m_restDefinitions)) { + m_allDefinitions.append(definition); + } +} + +void RhythmRestTest::parsesRhythmTokens() +{ + const Minuet::RhythmToken note = Minuet::parseRhythmToken(u"16"); + QVERIFY(note.valid); + QVERIFY(!note.rest); + QVERIFY(!note.dotted); + QCOMPARE(note.denominator, 16); + QCOMPARE(note.quarterNoteBeats(), 0.25); + + const Minuet::RhythmToken rest = Minuet::parseRhythmToken(u"r8."); + QVERIFY(rest.valid); + QVERIFY(rest.rest); + QVERIFY(rest.dotted); + QCOMPARE(rest.denominator, 8); + QCOMPARE(rest.quarterNoteBeats(), 0.75); + + QVERIFY(!Minuet::parseRhythmToken(u"r").valid); + QVERIFY(!Minuet::parseRhythmToken(u"r8..").valid); + QVERIFY(!Minuet::parseRhythmToken(u"0").valid); +} + +void RhythmRestTest::containsEveryMixedRestVariant() +{ + QCOMPARE(m_restDefinitions.size(), 38); + + QSet<QString> actualSequences; + for (const QJsonValue &value : std::as_const(m_restDefinitions)) { + const QJsonObject definition = value.toObject(); + const QString sequence = definition.value(u"sequence"_s).toString(); + QVERIFY2(!actualSequences.contains(sequence), qPrintable(sequence)); + actualSequences.insert(sequence); + + bool containsNote = false; + bool containsRest = false; + for (const QString &text : sequence.split(u' ', Qt::SkipEmptyParts)) { + const Minuet::RhythmToken token = Minuet::parseRhythmToken(text); + QVERIFY2(token.valid, qPrintable(text)); + containsRest |= token.rest; + containsNote |= !token.rest; + } + QVERIFY(containsRest); + QVERIFY(containsNote); + QVERIFY(!definition.value(u"name"_s).toString().isEmpty()); + } + + const QJsonArray mediumBases = definitionsWithTags(m_baseDefinitions, {u"rhythm"_s, u"medium"_s}); + QCOMPARE(actualSequences, expectedRestVariants(mediumBases)); + + const QJsonArray easyBases = definitionsWithTags(m_baseDefinitions, {u"rhythm"_s, u"easy"_s}); + const QSet<QString> easyVariants = expectedRestVariants(easyBases); + QSet<QString> taggedEasyVariants; + for (const QJsonValue &value : std::as_const(m_restDefinitions)) { + const QJsonObject definition = value.toObject(); + if (hasTag(definition, u"easy-with-rests"_s)) { + taggedEasyVariants.insert(definition.value(u"sequence"_s).toString()); + } + } + QCOMPARE(taggedEasyVariants, easyVariants); +} + +void RhythmRestTest::categoryCountsAreStable() +{ + QCOMPARE(definitionsWithTags(m_allDefinitions, {u"rhythm"_s, u"easy-with-rests"_s}).size(), 10); + QCOMPARE(definitionsWithTags(m_allDefinitions, {u"rhythm"_s, u"easy-with-rests"_s, u"rest-listening"_s}).size(), 7); + QCOMPARE(definitionsWithTags(m_allDefinitions, {u"rhythm"_s, u"medium-with-rests"_s}).size(), 46); + QCOMPARE(definitionsWithTags(m_allDefinitions, {u"rhythm"_s, u"medium-with-rests"_s, u"rest-listening"_s}).size(), 15); +} + +void RhythmRestTest::categoryFiltersMatchPracticeModes() +{ + const QJsonObject easy = restCategory(u"data/exercises/rhythm-easy-rests.json"_s); + const QJsonObject medium = restCategory(u"data/exercises/rhythm-medium-rests.json"_s); + QVERIFY(!easy.isEmpty()); + QVERIFY(!medium.isEmpty()); + + QCOMPARE(definitionsWithTags(m_allDefinitions, stringList(easy.value(u"and-tags"_s).toArray())).size(), 7); + QCOMPARE(definitionsWithTags(m_allDefinitions, stringList(easy.value(u"clapping-and-tags"_s).toArray())).size(), 10); + QCOMPARE(definitionsWithTags(m_allDefinitions, stringList(medium.value(u"and-tags"_s).toArray())).size(), 15); + QCOMPARE(definitionsWithTags(m_allDefinitions, stringList(medium.value(u"clapping-and-tags"_s).toArray())).size(), 46); +} + +void RhythmRestTest::consecutiveSixteenthsUseBeams() +{ + const QString pair = u"\uE1F0\uE1F9\uE1F4"_s; + const QString triple = u"\uE1F0\uE1F9\uE1F4\uE1F9\uE1F4"_s; + const QString sixteenthRest = u"\uE4E7"_s; + const QString eighthRest = u"\uE4E6"_s; + const QString isolatedSixteenth = u"\uE1D9"_s; + + QCOMPARE(nameForSequence(m_restDefinitions, u"r16 16 16 16"_s), sixteenthRest + u' ' + triple); + QCOMPARE(nameForSequence(m_restDefinitions, u"16 r16 16 16"_s), isolatedSixteenth + u' ' + sixteenthRest + u' ' + pair); + QCOMPARE(nameForSequence(m_restDefinitions, u"r16 r16 16 16"_s), sixteenthRest + u' ' + sixteenthRest + u' ' + pair); + QCOMPARE(nameForSequence(m_restDefinitions, u"16 16 r16 16"_s), pair + u' ' + sixteenthRest + u' ' + isolatedSixteenth); + QCOMPARE(nameForSequence(m_restDefinitions, u"16 16 16 r16"_s), triple + u' ' + sixteenthRest); + QCOMPARE(nameForSequence(m_restDefinitions, u"r16 16 16 r16"_s), sixteenthRest + u' ' + pair + u' ' + sixteenthRest); + QCOMPARE(nameForSequence(m_restDefinitions, u"16 16 r16 r16"_s), pair + u' ' + sixteenthRest + u' ' + sixteenthRest); + QCOMPARE(nameForSequence(m_restDefinitions, u"r8 16 16"_s), eighthRest + u' ' + pair); + QCOMPARE(nameForSequence(m_restDefinitions, u"16 16 r8"_s), pair + u' ' + eighthRest); +} + +void RhythmRestTest::listeningPatternsHaveUniqueOnsets() +{ + for (const QString &category : {u"easy-with-rests"_s, u"medium-with-rests"_s}) { + const QJsonArray definitions = definitionsWithTags(m_allDefinitions, {u"rhythm"_s, category, u"rest-listening"_s}); + QSet<QString> signatures; + for (const QJsonValue &value : definitions) { + const QString signature = onsetSignature(value.toObject().value(u"sequence"_s).toString()); + QVERIFY2(!signatures.contains(signature), qPrintable(signature)); + signatures.insert(signature); + } + QCOMPARE(signatures.size(), definitions.size()); + } +} + +void RhythmRestTest::everyPatternFillsOneBeat() +{ + for (const QJsonValue &value : std::as_const(m_allDefinitions)) { + const QString sequence = value.toObject().value(u"sequence"_s).toString(); + double duration = 0.0; + for (const QString &text : sequence.split(u' ', Qt::SkipEmptyParts)) { + const Minuet::RhythmToken token = Minuet::parseRhythmToken(text); + QVERIFY2(token.valid, qPrintable(text)); + duration += token.quarterNoteBeats(); + } + QVERIFY2(qAbs(duration - 1.0) < 0.0001, qPrintable(sequence)); + } +} + +QTEST_GUILESS_MAIN(RhythmRestTest) + +#include "rhythmresttest.moc" diff --git a/src/app/exercisecatalogcontroller.cpp b/src/app/exercisecatalogcontroller.cpp index fe51736..1190345 100644 --- a/src/app/exercisecatalogcontroller.cpp +++ b/src/app/exercisecatalogcontroller.cpp @@ -283,6 +283,13 @@ QJsonArray ExerciseCatalogController::applyDefinitions(QJsonArray exercises, QJs if (i1->isObject()) { QJsonObject exerciseObject = i1->toObject(); QJsonArray filteredDefinitions = definitions; + QJsonArray clappingDefinitions; + if (exerciseObject.value(u"clapping-and-tags"_s).isArray()) { + clappingDefinitions = definitions; + QJsonObject clappingFilter; + clappingFilter.insert(u"and-tags"_s, exerciseObject.take(u"clapping-and-tags"_s)); + filterDefinitions(clappingDefinitions, clappingFilter, u"and-tags"_s, DefinitionFilteringMode::AndFiltering); + } const QStringList exerciseObjectKeys = exerciseObject.keys(); if (exerciseObjectKeys.contains(u"and-tags"_s) && exerciseObject[u"and-tags"_s].isArray()) { filterDefinitions(filteredDefinitions, exerciseObject, u"and-tags"_s, DefinitionFilteringMode::AndFiltering); @@ -306,6 +313,9 @@ QJsonArray ExerciseCatalogController::applyDefinitions(QJsonArray exercises, QJs } } exerciseObject.insert(u"options"_s, filteredDefinitions); + if (!clappingDefinitions.isEmpty()) { + exerciseObject.insert(u"rhythmClappingOptions"_s, clappingDefinitions); + } } exercises[i1 - exercisesBegin] = exerciseObject; } diff --git a/src/app/exercisesessioncontroller.cpp b/src/app/exercisesessioncontroller.cpp index d2e7089..543782d 100644 --- a/src/app/exercisesessioncontroller.cpp +++ b/src/app/exercisesessioncontroller.cpp @@ -4,6 +4,8 @@ #include "exercisesessioncontroller.h" +#include <utils/rhythmtoken.h> + #include <KLocalizedString> #include <QDebug> @@ -456,14 +458,24 @@ void ExerciseSessionController::randomlySelectExerciseOptions(int selectedOption } for (const QString &additionalNote : additionalNotes) { - QString noteText = additionalNote; - if (isRhythm && noteText.endsWith(QLatin1Char('.'))) { - noteText.chop(1); + if (isRhythm) { + const RhythmToken rhythmToken = parseRhythmToken(additionalNote); + if (!rhythmToken.valid) { + failSelection(u"Current exercise option has an invalid sequence."_s); + return false; + } + if (rhythmToken.denominator > maxNote) { + maxNote = rhythmToken.denominator; + } + if (rhythmToken.denominator < minNote) { + minNote = rhythmToken.denominator; + } + continue; } bool ok = false; - const int note = noteText.toInt(&ok); - if (!ok || (isRhythm && note <= 0)) { + const int note = additionalNote.toInt(&ok); + if (!ok) { failSelection(u"Current exercise option has an invalid sequence."_s); return false; } diff --git a/src/app/qml/ExerciseClappingView.qml b/src/app/qml/ExerciseClappingView.qml index e47a00c..172768e 100644 --- a/src/app/qml/ExerciseClappingView.qml +++ b/src/app/qml/ExerciseClappingView.qml @@ -228,6 +228,9 @@ Item { } function durationForToken(token: string): real { let note = token; + if (note.startsWith("r")) { + note = note.slice(1); + } let dotted = 1.0; if (note.endsWith(".")) { dotted = 1.5; @@ -325,11 +328,13 @@ Item { let figureOnsets = []; const figureStart = cursor; for (const part of parts) { - figureOnsets.push(onsetList.length); - onsetList.push({ - "figure": figureIndex, - "timeMs": cursor - }); + if (!isRestToken(part)) { + figureOnsets.push(onsetList.length); + onsetList.push({ + "figure": figureIndex, + "timeMs": cursor + }); + } cursor += durationForToken(part); } states.push({ @@ -371,6 +376,9 @@ Item { root.performedOnsets = performed; refreshFigureStates(elapsedMs); } + function isRestToken(token: string): bool { + return token.startsWith("r"); + } function mapRhythmRowX(localX: real): real { const geometryDependency = rhythmFrame.x + rhythmViewport.x + rhythmViewport.contentX + rhythmContent.x + rhythmRow.x + rhythmRow.implicitWidth; return rhythmRow.mapToItem(root, localX + geometryDependency * 0, 0).x; diff --git a/src/app/qml/PracticeModePage.qml b/src/app/qml/PracticeModePage.qml index d5ae1ff..3c21e8d 100644 --- a/src/app/qml/PracticeModePage.qml +++ b/src/app/qml/PracticeModePage.qml @@ -26,6 +26,10 @@ Kirigami.Page { exercise[key] = page.currentExercise[key]; } exercise.inputMode = inputMode; + if (inputMode === "clapping" && exercise.rhythmClappingOptions !== undefined) { + exercise.options = exercise.rhythmClappingOptions; + } + delete exercise.rhythmClappingOptions; if (inputMode === "singing") { exercise.singingExerciseKind = page.practiceMode; } diff --git a/src/plugins/fluidsynthsoundcontroller/fluidsynthsoundcontroller.cpp b/src/plugins/fluidsynthsoundcontroller/fluidsynthsoundcontroller.cpp index c1ed690..a799be4 100644 --- a/src/plugins/fluidsynthsoundcontroller/fluidsynthsoundcontroller.cpp +++ b/src/plugins/fluidsynthsoundcontroller/fluidsynthsoundcontroller.cpp @@ -4,6 +4,8 @@ #include "fluidsynthsoundcontroller.h" +#include <utils/rhythmtoken.h> + #include <KLocalizedString> #include <QCoreApplication> @@ -220,21 +222,15 @@ void FluidSynthSoundController::prepareFromExerciseOptions(QJsonArray selectedEx } else { // appendEvent(9, 80, 127, 1000*(60.0/m_tempo)); const QStringList additionalNotes = sequence.split(QLatin1Char(' '), Qt::SkipEmptyParts); - for (QString additionalNote : additionalNotes) { - float dotted = 1; - if (additionalNote.endsWith('.')) { - dotted = 1.5; - additionalNote.chop(1); - } - bool ok = false; - const int denominator = additionalNote.toInt(&ok); - if (!ok || denominator <= 0) { + for (const QString &additionalNote : additionalNotes) { + const Minuet::RhythmToken token = Minuet::parseRhythmToken(additionalNote); + if (!token.valid) { qWarning() << "Ignoring exercise option with invalid rhythm value:" << additionalNote; clearSong(); return; } - unsigned int duration = dotted * 1000 * (60.0 / m_tempo) * (4.0 / denominator); - appendEvent(RhythmChannel, m_rhythmInstrument, 127, duration); + const unsigned int duration = token.quarterNoteBeats() * 1000 * (60.0 / m_tempo); + appendEvent(RhythmChannel, m_rhythmInstrument, token.rest ? 0 : 127, duration); } } } diff --git a/src/utils/rhythmtoken.h b/src/utils/rhythmtoken.h new file mode 100644 index 0000000..f3c93c0 --- /dev/null +++ b/src/utils/rhythmtoken.h @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 Sandro Andrade <[email protected]> +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#ifndef MINUET_RHYTHMTOKEN_H +#define MINUET_RHYTHMTOKEN_H + +#include <QStringView> + +namespace Minuet +{ +struct RhythmToken { + bool valid = false; + bool rest = false; + bool dotted = false; + int denominator = 0; + + double quarterNoteBeats() const + { + return valid ? 4.0 / denominator * (dotted ? 1.5 : 1.0) : 0.0; + } +}; + +inline RhythmToken parseRhythmToken(QStringView text) +{ + RhythmToken token; + if (text.startsWith(u'r')) { + token.rest = true; + text = text.sliced(1); + } + if (text.endsWith(u'.')) { + token.dotted = true; + text = text.first(text.size() - 1); + } + + bool ok = false; + token.denominator = text.toInt(&ok); + token.valid = ok && token.denominator > 0; + return token; +} +} + +#endif
