🐷

お気に入り楽器保存機能を実装する

に公開

はじめに

この記事は、東京大学工学部電子情報工学科・電気電子工学科の学生実験「大規模ソフトウェアを手探る」のレポートの一部として書かれたものです。
レポートの全記事をご覧になりたい方は下のリンクよりご覧ください。
https://zenn.dev/yamamotomutsumi/articles/bb3ea47ad36e45/

実装内容

MuseScoreでは、新しい楽譜を作成する際に使用する楽器を一つずつ選択する必要があり、使っていて少々面倒だと感じた。お気に入りの楽器セットを保存しておき、楽譜を作成する際に選択できる機能があれば便利だと思ったので、実装することにした。

コード読解

まず、楽器選択画面のUIを管理するコードの場所を特定することにした。はじめに、MuseScoreのGitHubリポジトリにおいて楽器選択画面を編集したプルリクエストを探したが見当たらなかったので、ファイル名や関数名から探すことにした。"instrument id"などで検索した結果、ヒットしたファイルの中にsrc/instumentscene/qml/ChooseInstrumentPageといういかにもそれらしいファイルを見つけたので、ここらへんに関連するファイルをいじれば良さそうだと推定した。

楽器選択画面のUI設定

ChooseInstrumentPageは、楽器選択画面のUI画面を設定しているqmlファイルであり、ユーザーが楽器を選択し、スコアに追加するための一連の操作を担当している。変更前の内容では、楽器選択画面を「ファミリー」「楽器」「スコア」という3つのパネルに分割してレイアウトしていたので、新しく「プリセット」パネルを追加し、お気に入りの楽器を保存する機能を持たせることにした。変更すべき箇所を探るためにChooseInstrumentPage.qmlファイル内を探索したところ、ページのレイアウトを設定する機能を持つコンポーネントであるRowLayoutを発見し、その内部がFamilyView、InstrumentsView、InstrumentOnScoreViewという3つの要素で構成されていたので、それぞれの要素が各パネルに対応しているらしいことがわかった。新しく「プリセット」パネルを追加するために、FavoriteViewという要素を新たに追加した。

ChooseInstrumentPage
// レイアウトの設定.
    RowLayout {
        anchors.fill: parent
        spacing: 12 // Space between panels

        // ファミリーパネルの設定
        FamilyView {
            // ... 
        }

        SeparatorLine { orientation: Qt.Vertical }

        // 新しく追加したプリセットパネルの設定
         FavoriteView {
            id: favoritesView
            Layout.minimumWidth: 180
            Layout.preferredWidth: root.canSelectMultipleInstruments ? Math.max(180, root.width / 5) : 0 // Adjust width
            Layout.fillWidth: !root.canSelectMultipleInstruments
            Layout.fillHeight: true
            // Bind the data model for user sets
            userFavoritesModel: currentFavoritesModel
            // --- Signal Handlers ---
            // Connect signals from FavoritesView to functions in this component
            onAddFavoriteSetRequested: { root.saveCurrentSet(); }
            onFavoriteSetActivated: function(setData) { root.applyFavoriteSet(setData); }
            onMyOrchestraRequested: { prv.addMyOrchestra(); }
            onJazzTrioRequested: { prv.addJazzTrio(); }
            onBrassQuintetRequested: { prv.addBrassQuintet(); }

            onDeleteFavoriteSetRequested: function(setName) {
                root.deleteFavoriteSet(setName); 
            }

        }

        SeparatorLine { orientation: Qt.Vertical }

        // 楽器パネルの設定
        InstrumentsView {
            // ...
        }
  
        SeparatorLine { orientation: Qt.Vertical }

        // スコアパネルの設定
        InstrumentOnScoreView {
            // ...
        }
  }

また、各パネル内部のレイアウトやユーザーの入力に対応して発するシグナルなどを設定したqmlファイルは、src/instumentscene/qml/internalの階層に置かれていたので、ここに新しくqmlファイルを追加することにした。

FavoriteViewの追加

楽器選択画面を構成する3つのパネルが設定されているqmlファイルと同じ階層(src/instumentscene/qml/internal)に新しくFavoriteView.qmlというファイルを作成し、この中でお気に入り保存機能のUIに関する機能を追加した。具体的には、新しいパネルである「プリセット」パネルと、その内部に「セットに追加」ボタンと「お気に入り」ボタンを表示し、それぞれのボタンが押されたらシグナルが立ってChooseInstrumentPage上で対応する関数が呼び出されるという機能を追加した。

FavoirteView
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15

import Muse.Ui 1.0
import Muse.UiComponents 1.0

ColumnLayout {
    id: root

    // 各シグナルの定義
    signal myOrchestraRequested()
    signal jazzTrioRequested()
    signal brassQuintetRequested()

    signal addFavoriteSetRequested() // 「追加」ボタン用
    signal favoriteSetActivated(var setData) // セット適用ボタン用
    signal deleteFavoriteSetRequested(string setName) // セット削除用

    // ChooseInstrumentsPageからデータを受け取るためのプロパティ
    property var userFavoritesModel: []

    spacing: 8

    // 「プリセット」の見出し
    StyledTextLabel {
        Layout.fillWidth: true
        font: ui.theme.bodyBoldFont
        text: qsTrc("instruments", "プリセット")
        horizontalAlignment: Text.AlignLeft
    }

    // ...
    // Repeaterにより、ユーザーが保存したお気に入りセットのボタンを生成、表示
    Repeater {
        id: favoriteButtonsRepeater
        // セットに新しい楽器セットが保存されこのモデルが更新されると、Repeaterが再実行
        model: root.userFavoritesModel

        // 更新されたモデルを使ってボタンを生成
        delegate: FlatButton {
            id: favButton
            width: root.width
            text: modelData.setName

            MouseArea {
                id: mouseArea
                anchors.fill: parent
                acceptedButtons: Qt.LeftButton | Qt.RightButton

                onClicked: (mouse) => {
                    if (mouse.button === Qt.LeftButton) {
                        // 左クリック: セットを適用
                        console.log("Left clicked:", modelData.setName);
                        root.favoriteSetActivated(modelData);
                    } else if (mouse.button === Qt.RightButton) {
                        // 右クリック: 削除メニューを表示
                        console.log("Right clicked:", modelData.setName);
                        deleteMenu.popup();
                    }
                }

                Menu {
                   id: deleteMenu
                   MenuItem {
                       text: qsTr("削除")
                       onTriggered: {
                           console.log("Delete triggered for:", modelData.setName);
                           root.deleteFavoriteSetRequested(modelData.setName);
                       }
                   }
                }
            }
        }
    }

    Item {
        Layout.fillHeight: true
    }

    // 現在の楽器セットをお気に入りに追加するためのボタン
    FlatButton {
        id: addButton
        Layout.fillWidth: true
        text: qsTrc("instruments", "セットに追加")
        icon: IconCode.PLUS
        onClicked: {
            console.log("FavoritesView: 'セットに追加' button clicked, emitting signal.");
            root.addFavoriteSetRequested() // 「セットに追加」ボタンを押した時に発するシグナル
        }
    }
}

このとき、「セットに追加」ボタンを押すと「お気に入り」ボタンが「お気に入り1」、「お気に入り2」、、、という名前で追加されていくようにした。後述するsaveCurrentSet関数によって、「セットに追加」ボタンにより保存された楽器のIDは「お気に入りX」という名前で保存されるようになっており、FavoirteView.qmlでRepeaterを使うことで自動的に同じ名前のボタンが画面上に生成されるようにして実装した。ここまでの変更による結果を見るためにビルドして実行したところ、楽器選択画面に新しくプリセットパネルと各ボタンが追加されていることが確認できた。

プリセット機能の実装

楽器のIDを取得する関数を追加

UI面を設定することができたので、機能面の実装を目指す。まず、スコアに楽器が表示されている状態で、「セットに追加」ボタンを押したときに楽器のセットが保存される機能を実装した。楽器を選択しスコアに追加する際にどのような挙動をしているかを調べると、ChooseInstrumentPageファイルでスコアに楽器を追加する際にaddInstrumentsという関数が呼び出されており、この関数がsrc/instumentscene/view/instrumentsOnScoreModelというC++ファイルで定義されていることから、このファイルをいじれば良さそうだと推定した。instrumentsOnScoreModelでは、選択された各楽器の情報がinstrument idとして扱われていたので、新しい関数getCurrentInstrumentIdsを定義し、楽器のIDをリスト(QVariantList)に保存する機能を追加した。

instrumentsOnScoreModel
QVariantList InstrumentsOnScoreListModel::getCurrentInstrumentIds() const
{
    TRACEFUNC;
    QVariantList ids;

    for (const Item* it : items()) {
        auto instrument = dynamic_cast<const InstrumentItem*>(it);
        if (!instrument)
            continue;

        if (instrument->isExistingPart) {
            ids << instrument->id;
        } else if (instrument->instrumentTemplate.isValid()) {
            ids << instrument->instrumentTemplate.id.toQString();
        }
    }

    return ids;
}

楽器のIDを保存する機能の追加

C++上で定義した関数getCurrentInstrumentIdsは、呼び出されるとスコア上に表示された楽器のIDをリスト化して保存する機能を持つ。続いて、qml上で「セットに追加」ボタンを押した時にシグナルが発信しこの関数が呼び出され、楽器IDのリストを保存する機能を追加した。具体的には、ChooseInstrumentPage上に新しくsaveCurrentSetという関数を追加し、「セットに追加」ボタンが押されると呼び出され、getCurrentInstrumentIdsを呼び出し楽器のIDを取得したのちに、JavaScriptの配列に変換するような実装を行なった。

ChooseInstrumentPage
function saveCurrentSet() {
    console.log("saveCurrentSet: Attempting to get IDs from C++...");

    // 1. C++の関数を呼び出してIDリスト(QVariantList)を取得
    var rawIdList = root.instrumentsOnScoreModel.getCurrentInstrumentIds();

    // 2. QVariantList を JavaScript の配列に変換
    var idList = [];
    if (rawIdList && rawIdList.length > 0) {
        for (var i = 0; i < rawIdList.length; i++) {
            idList.push(rawIdList[i]);
        }
    }
}```

また、新しくsrc/instumentscene/qml/internalの階層にinstrumentsetrepository.qmlというファイルを作成し、saveCurrentSetによって変換された楽器IDのリストを保存しておくようにした。instrumentsetrepository内にsaveSetという関数を追加し、saveCurrentSetが実行された際に呼び出されて、お気に入り楽器のIDセットをデータベースに書き込むようにして実装した。はじめの実装では、楽器のIDが認識されず保存できないというエラーが発生したが、これはJavaScriptの形式のままではデータベースに直接保存できないために発生したものであった。これを踏まえて、保存する前にJSON文字列に変換するようにしたところ、エラーが起こらなくなった。
```javascript:instrumentsetrepository
function saveSet(setName, instrumentIds) {
    if (typeof setName !== 'string' || !setName || !Array.isArray(instrumentIds) || instrumentIds.length === 0) {
        console.error("Save failed: Invalid arguments provided.", "Name:", setName, "IDs:", instrumentIds);
        return false; 
    }

    var db = _getDb();
    var success = false;
    try {
        // IDリストをJSON文字列に変換
        var idsJson = JSON.stringify(instrumentIds);

        db.transaction(function(tx) {
            var result = tx.executeSql('INSERT OR REPLACE INTO UserInstrumentSets(setName, instrumentIds) VALUES(?, ?)', [setName, idsJson]);
            if (result.rowsAffected > 0) {
                success = true;
            }
        });
    } catch (error) {
        console.error("Error saving set '" + setName + "' to LocalStorage:", error);
        return false;
    }
    if (success) {
        console.log("Successfully saved set:", setName);
    } else {
         console.warn("Set '" + setName + "' might not have been saved correctly.");
    }
    return success;
}

お気に入りセットをスコアに表示する関数の追加

続いて、お気に入りボタンが押された際に、保存されたお気に入りセットをスコア画面に表示する機能を追加した。ChooseInstrumentPageファイル内に新しくapplyFavoriteSet関数を追加し、お気に入りボタンが押された際に呼び出されて、instrumentsetrepositoryに保存している楽器IDに対応する楽器を表示するようにした。この時は、instrumentsetrepository.qmlに保存する時とは逆に、JSON文字列からJavaScriptの形式に変換する作業を行い、エラーを発生させずに楽器のIDが読み取れるようにした。楽器のIDを引数にとりスコア画面に表示するaddInstrument関数が既存の関数として存在していたので、applyFavoriteSet内でこの関数を呼び出すことで、変換したIDに対応する楽器をスコア画面に表示するように実装した。

ChooseInstrumentPage
// applyFavoriteSet関数
function applyFavoriteSet(setData) {
    if (!setData || !setData.instrumentIds) {
        // ...
        return;
    }

    // JS配列に変換
    var idsToApply = [];
    for (var i = 0; i < setData.instrumentIds.length; i++) {
        idsToApply.push(setData.instrumentIds[i]);
    }
    // ...

    // ヘルパー関数applyPresetIdsを呼び出す
    prv.applyPresetIds(idsToApply);
}
ChooseInstrumentPage
// applyPresetIds関数
QtObject {
    id: prv
    // ...
    function applyPresetIds(ids) {
         // ... 

         // 既存の楽器をクリア 
         if (typeof root.instrumentsOnScoreModel.clear === "function") {
             root.instrumentsOnScoreModel.clear();
         }
         
         // 既存の関数addInstrumentsを用い、新しい楽器を追加 
         if (typeof root.instrumentsOnScoreModel.addInstruments === "function") {
             Qt.callLater(function() {
                 root.instrumentsOnScoreModel.addInstruments(ids);
                 Qt.callLater(instrumentsOnScoreView.scrollViewToEnd);
             });
         }
    }
}

動作確認

以上で、お気に入り楽器セット保存のUI面、機能面における実装が完了した。実際にビルドして動作確認してみると、「セットに追加」ボタンを押したら新しいお気に入りボタンが生成され、「お気に入り」ボタンを押したら保存した楽器が自動でスコア画面に追加されるようになっており、目標の機能が実装できていることを確認できた。

Discussion