[education/minuet] /: Add rhythm with rests exercises
Sandro S. Andrade
null at kde.org
Mon Jul 13 19:16:59 BST 2026
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 <sandroandrade at kde.org>
+//
+// 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 <sandroandrade at kde.org>
+//
+// 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
More information about the kde-doc-english
mailing list