[education/minuet/release/26.08] /: Add rhythm with rests exercises

Sandro S. Andrade null at kde.org
Mon Jul 13 19:16:30 BST 2026


Git commit c3bce2849aee20c15317d255cefcbbeb8d140644 by Sandro S. Andrade.
Committed on 13/07/2026 at 18:16.
Pushed by sandroandrade into branch 'release/26.08'.

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/c3bce2849aee20c15317d255cefcbbeb8d140644

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