🦔

MuseScore Studio で音程を変更するときに表示される臨時記号を固定化する

2024/11/21に公開

はじめに

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

この記事では楽譜作成ソフト MuseScore Studio で音程を変更したときに、表示される臨時記号を固定化する機能を実装するために行ったことを説明します。

開発環境

OS: macOS Sequoia (バージョン: 15.0.1)
チップ: クアッドコア Intel Core i5

そもそも臨時記号の固定化とは何か

ショートカットなどといった他の機能と比べると、どのような機能を実現したのかわかりにくいと思うので、そもそもどういう機能なのか説明します。

臨時記号について

いわゆる#(シャープ)とb(フラット)のことです。5線譜上では表現できない音を表現するのに使われます。ハ長調(Cmajor)の場合、ピアノの黒鍵に相当しますね。

ドより半音高い音はド#、ソより半音高い音はソ#というように、半音上がると「#」がつきます。
一方で、レより半音低い音はレb、ラより半音低い音はラbというように、半音下がると「b」がつきます。ここで注目してほしいのは、ド#とレbのように、表記が違っても音程としては全く同じ音が存在するということです。


ド#とレbは表記は違っても同じ音

このように、音楽界隈ではデフォルトで表記揺れが存在してしまっているわけですが、実際に楽譜を作成するときには基本的にどちらかに統一します。つまり、「ド#」と「レb」の片方のみを基本的に用い、両方が混在しないようにするということですね。

元々の挙動

MuseScoreでは楽譜上に音を配置したあとに、音を変更したときに「↑」キーで半音上げ、「↓」キーで半音下げることができます。

ところが、このとき「↑」キーを押すと、基本的には#を使った表記で半音上がります。つまり、「ド」を選択した状態で「↑」キーを押すと、「レb」ではなく「ド#」となります。一方で、「↓」キーを押すとbを使った表記で半音下がります。つまり、「レ」を選択した状態で「↓」キーを押すと、「ド#」ではなく「レb」となります。繰り返しますが、「ド#」と「レb」は表記が違うだけで、同じ音です。

ドを選択した状態で↑を押すことで、ド#に変わる


レを選択した状態で↓を押すことで、レbに変わる

先ほど述べたように、楽譜を作るときはどちらか一方のみ用いるのですが、矢印キーを使って音程を変更すると、このように矢印の向きに応じて#表記とb表記が両方出現してしまいます。

例えば「レ」を半音下げたいときに楽譜全体で#表記を使っている場合、「レ」から「ド#」に変更しなければいけないのですが、現状の仕様だと、ただ「↓」キーを使うと「レb」になってしまうため、
「↓」キーを2回おして一旦「ド」にしたあとに、「↑」キーで半音上げることで「ド#」にしなければいけません。少し面倒くさいですね。

したがって、「↓」「↑」にかかわらず#表記、b表記どちらかに固定して音が変化できるような機能を実装しました。

実装したこと

概要

以上のことを踏まえ、「↓」または「↑」を押したときに変化する臨時記号を固定化するべく、「フォーマット」 → 「スタイル」 → 「臨時記号」の下部に以下のようなUIを追加しました。


臨時記号を固定化する"Accidental setting in pitch change"を新たに追加

  • 「default」は本来のMuseScoreの仕様と同じ
  • 「sharp」は「↓」「↑」にかかわらず、#がつくように音の表記が変化する(ex. レ → ド#)
  • 「flat」は「↓」「↑」にかかわらず、bがつくように音の表記が変化する(ex. ド → レb)
    これらを選択することで、楽譜上の臨時記号を固定化(または解除)することができます。本節では、どのようにこの機能を実装したか説明します。

Musescoreにおける音程変更の仕組み

そもそも、Musescoreでは音程の変更をどのように行っているのでしょうか。
Musescore上で音程を変更した時に呼び出される関数をデバッガで追跡した結果、src/engraving/dom/cmd.cppの1834行目からのupDownChromatic関数が直接の処理をしていることがわかりました。

src/engraving/dom/cmd.cpp
static void upDownChromatic(bool up, int pitch, Note* n, Key key, int tpc1, int tpc2, int& newPitch, int& newTpc1, int& newTpc2){
    bool concertPitch = n->concertPitch();
    AccidentalVal noteAccVal = tpc2alter(concertPitch ? tpc1 : tpc2);
    AccidentalVal accState = AccidentalVal::NATURAL;
    if (Measure* m = n->findMeasure()) {
        accState = m->findAccidental(n);
    }
    if (up && pitch < 127) {
        newPitch = pitch + 1;
        if (concertPitch) {
            if (tpc1 > Tpc::TPC_A + int(key) && noteAccVal >= accState) {
                newTpc1 = tpc1 - 5;                // up semitone diatonic
            } else {
                newTpc1 = tpc1 + 7;                // up semitone chromatic
            }
            newTpc2 = n->transposeTpc(newTpc1);
        } else {
            if (tpc2 > Tpc::TPC_A + int(key) && noteAccVal >= accState) {
                newTpc2 = tpc2 - 5;           // up semitone diatonic
            } else {
                newTpc2 = tpc2 + 7;           // up semitone chromatic
            }

            newTpc1 = n->transposeTpc(newTpc2);
        }
    } else if (!up && pitch > 0) {
        newPitch = pitch - 1;
        if (concertPitch) {
            if (tpc1 > Tpc::TPC_C + int(key) || noteAccVal > accState) {
                newTpc1 = tpc1 - 7;           // down semitone chromatic
            } else {
                newTpc1 = tpc1 + 5;           // down semitone diatonic
            }
            newTpc2 = n->transposeTpc(newTpc1);
        } else {
            if (tpc2 > Tpc::TPC_C + int(key) || noteAccVal > accState) {
                newTpc2 = tpc2 - 7;           // down semitone chromatic
            } else {
                newTpc2 = tpc2 + 5;           // down semitone diatonic
            }
            newTpc1 = n->transposeTpc(newTpc2);
        }
    }
}

音の表記に関しては変数tpc1またはtpc2で管理しており[1]、この関数ではtpc1またはtpc2を様々な条件をもとに更新することで、音の表記の変更を行っていることがわかります。おおまかな手順としては以下のようになります。

  1. スコアの調号など、様々な情報を取得
  2. upの値に応じて半音上げまたは半音下げの処理を行う
  3. concertPitchの値に応じて、tpc1またはtpc2の処理を行う
  4. 1で取得した情報などを用いてif文による場合分けを行い、適切に音の表記を変化させる(新しい表記はnewTpc1またはnewTpc2に記録される)。
  5. newTpc1またはnewTpc2について、4で更新しなかった方をtransposeTpc関数で更新

今回実現したい機能を実装する上で重要になってくるのは4の部分です。これについてより詳しく説明します。まず、tpc1tpc2についてですが、以下の配列に基づいています。

static const int tab[36] = {
    26, 14,  2,    // 60  B#   C   Dbb
    21, 21,  9,    // 61  C#   C#  Db
    28, 16,  4,    // 62  C##  D   Ebb
    23, 23, 11,    // 63  D#   D#  Eb
    30, 18,  6,    // 64  D##  E   Fb
    25, 13,  1,    // 65  E#   F   Gbb
    20, 20,  8,    // 66  F#   F#  Gb
    27, 15,  3,    // 67  F##  G   Abb
    22, 22, 10,    // 68  G#   G#  Ab
    29, 17,  5,    // 69  G##  A   Bbb
    24, 24, 12,    // 70  A#   A#  Bb
    31, 19,  7,    // 71  A##  B   Cb
};

この配列では、数値と音の表記の対応づけを行っています。例えば、ド (C)であれば14、ファ# (F#)であれば20というようになっています。重要なのは音程ではなく音の表記との対応であるため、ド# (C#)とレb (Db)というように同じ音であっても表記が違えば違う数値になるということです。

一見特に規則のない謎の数値のように思えますが、考えられる全ての表記を完全5度の音程に従って並べたものとなっています[2]。例えば、ド (C)が14だとすると完全5度上がったソ (G)は15、完全5度下がったファ (F)は13です。

このような並び順を踏まえた上で音を半音上げたり下げたりする場合に着目すると、ある規則が見えてきます。

元の音(tpcの値) 変化量 変化後の音(tpcの値)
ド(14) +7 ド#(21)
ド(14) -5 レb(9)
ソ(15) +7 ソ#(22)
ソ(15) -5 ラb(10)

この表から、音程を上げる場合は数値を+7または-5すればよく、どちらを選択するかによって変化後の音の表記が変わることがわかります。

それでは、先ほどの関数の関連する条件分岐の部分に注目します。以下は実音表記の音を半音上げる時の処理です。

if (tpc1 > Tpc::TPC_A + int(key) && noteAccVal >= accState) {
    newTpc1 = tpc1 - 5;                // up semitone diatonic
} else {
    newTpc1 = tpc1 + 7;                // up semitone chromatic
}
  • tpc1 > Tpc::TPC_A + int(key): Tpc::TPC_Aは定数であり、先ほどの配列と基本的に一致しています。Tpc::TPC_Aは17になります。また、int(key)は調号を数値化した値です。ハ長調(Cmajor)であれば0、ト長調(Gmajor)であれば1というように、調号において#が増えるたびに+1、bが増えるたびに-1されます。この条件式では、現在の音程の数値が特定の数値より大きいかどうか判別しています
  • noteAccVal >= accState: 選択した音の小説における臨時記号に関係する条件式です。今回の実装には特に関係ありません

以上の条件式に従い、音程を-5または+7していることがわかりますね。よって、音程を変更するときの音の表記を#かbで固定化するためには、条件分岐の条件文を変更すればよいということがわかりました。
実は、これはTpc::TPC_Aの定数の部分を変更することで簡単に実現できます[3]

ロジックに関する変更

まず、根幹的な処理を行っているsrc/engraving/dom/cmd.cppupDownChromatic関数については以下のように変更しました。以下、代表的な変更点のみ記載します。

src/engraving/dom/cmd.cpp
- static void upDownChromatic(bool up, int pitch, Note* n, Key key, int tpc1, int tpc2, int& newPitch, int& newTpc1, int& newTpc2)
+ static void upDownChromatic(bool up, int pitch, Note* n, Key key, int tpc1, int tpc2, int& newPitch,AccidentalMode accidental_mode, int& newTpc1, int& newTpc2)
{
    bool concertPitch = n->concertPitch();
    AccidentalVal noteAccVal = tpc2alter(concertPitch ? tpc1 : tpc2);
    AccidentalVal accState = AccidentalVal::NATURAL;
    if (Measure* m = n->findMeasure()) {
        accState = m->findAccidental(n);
    }
    if (up && pitch < 127) {
        newPitch = pitch + 1;
+       Tpc tpc_std = (int(accidental_mode) < 0 ? Tpc::TPC_B_B : Tpc::TPC_A);
        if (concertPitch) {
-           if (tpc1 > Tpc::TPC_A + int(key) && noteAccVal >= accState) {
+           if (tpc1 > tpc + int(key) && noteAccVal >= accState) {
                newTpc1 = tpc1 - 5;                // up semitone diatonic
            } else {
                newTpc1 = tpc1 + 7;           // up semitone chromatic
            }
            newTpc2 = n->transposeTpc(newTpc1);
        } else {
                .
                .
                .
        }
    } else if (!up && pitch > 0) {
        newPitch = pitch - 1;
+       Tpc tpc_std = (int(accidental_mode) > 0 ? Tpc::TPC_B : Tpc::TPC_C);
        if (concertPitch) {
-           if (tpc2 > Tpc::TPC_C + int(key) || noteAccVal > accState) {
+           if (tpc1 > tpc_std + int(key) || noteAccVal > accState) {
                newTpc1 = tpc1 - 7;           // down semitone chromatic
            } else {
                newTpc1 = tpc1 + 5;           // down semitone diatonic
            }
            newTpc2 = n->transposeTpc(newTpc1);
        } else {
                .
                .
                .
        }
    }

例えば「sharp」モードのとき、音程を上げるときは特に変更する必要はなく、音程を下げるときは#表記で下がるようにしたいわけです。このようにしたい場合、音程を下げるときに本来TPC_CであったのをTPC_Bに変更するだけで実行可能です。同様に「flat」モードのときは音程を上げる時に本来TPC_AであったのをTPC_B_Bに変更することで実行可能です[3:1]

このように挙動を実現するため、新たに臨時記号の固定化を判別する変数AccidentalMode accidental_modeを定義し、その値によって定数Tpc::TPC_...を変更するようにしました。accidental_modeが1のとき「sharp」モード、0のときは「default」モード、-1のときは「flat」モードです。

関連する変更

新たにAccidentalMode型?を追加するため、型の定義を行っていると思われるsrc/engraving/types/types.hに以下の内容を追加しました。

src/engraving/types/types.h
enum class AccidentalMode : signed char {
    SHARP = 1,
    NATURAL = 0,
    FLAT  = -1
};

こうすることでupDownChromatic関数でAccidentalModeが使えるようになりました。
他にも以下のような変更を行っています。

src/engraving/dom/cmd.cpp
void Score::upDown(bool up, UpDownMode mode)
{
    .
    .
    .
         Part* part   = staff->part();
                Key key      = staff->key(tick);
+               AccidentalMode accidental_mode = staff->getAccidentalMode();
                int tpc1     = oNote->tpc1();
                int tpc2     = oNote->tpc2();
                .
                .
                .
                     case UpDownMode::DIATONIC:  // increase / decrease the pitch,
                // letting the algorithm to choose fret & string
-                   upDownChromatic(up, pitch, oNote, key, tpc1, tpc2, newPitch, newTpc1, newTpc2);
+                   upDownChromatic(up, pitch, oNote, key, tpc1, tpc2, newPitch, accidental_mode, newTpc1, newTpc2);
                break;
                .
                .
                .

upDownChromatic関数を直接呼び出す同ファイルupDown関数に対する変更です。AccidentalMode accidental_modegetAccidentalMode関数で取得し、それをupDownChromatic関数を呼び出す時に使用しています。

src/engraving/dom/staff.cpp
//---------------------------------------------------------
//   Staff::setAccidentalMode
//---------------------------------------------------------
void Staff::setAccidentalMode(int v){
    if (v>0){
        acc_mode = AccidentalMode::SHARP;
    }else if(v==0){
        acc_mode = AccidentalMode::NATURAL;
    }else{
        acc_mode = AccidentalMode::FLAT;
    }
    return;
}
AccidentalMode Staff::getAccidentalMode(){
    return acc_mode;
}

5線譜上のの様々な要素を管理していると思われるsrc/engraving/dom/staff.cppに、臨時記号の固定化設定を設定・取得できる関数を追加しました。

src/engraving/dom/score.cpp
//---------------------------------------------------------
//   setAccidentalMode
//---------------------------------------------------------
void Score::setAccidentalMode(const PropertyValue& v){
    int value = v.toInt();
    for (Staff* staff : m_staves){
        staff->setAccidentalMode(value);
    }
    return;
}

さらに譜面全体を管理していると思われるsrc/engraving/dom/score.cppにも臨時記号の固定化設定を設定する関数を追加しました。

UIに関する変更

参考: Additional visibility options for trill cue note #25262

UIそのものはsrc/notation/view/widgets/editstyle.uiを編集して作成しました。github上ではマークアップ言語で管理されていますが、Qtで.uiで終わるファイルを編集するときはGUI上で編集できる(というかGUIでしかできない)ため、比較的簡単にUIを作成することができました。

もちろんこれだけでは表示が変わっただけでボタンを押しても何も起こらないので、これを実際に動かすために以下の内容をそれぞれの対応するファイルに追加しました。

src/engraving/style/styledef.cpp
 styleDef(linearStretch,                              PropertyValue(double(1.5))),
 styleDef(crossMeasureValues,                         false),
 styleDef(keySigNaturals,                             PropertyValue(int(KeySigNatural::NONE))),
+styleDef(pitchChangeAccidentals,                     PropertyValue(int(AccidentalMode::NATURAL))),
 styleDef(tupletMaxSlope,                             PropertyValue(double(0.5))),
 styleDef(tupletOutOfStaff,                           true),

src/notation/view/widgets/editstyle.cpp
    ksng->addButton(radioKeySigNatBefore, int(KeySigNatural::BEFORE));
    ksng->addButton(radioKeySigNatAfter, int(KeySigNatural::AFTER));

+   QButtonGroup* pca = new QButtonGroup(this);
+   pca->addButton(radioAccidentalSetDefault, int(AccidentalMode::NATURAL));
+   pca->addButton(radioAccidentalSetSharp, int(AccidentalMode::SHARP));
+   pca->addButton(radioAccidentalSetFlat, int(AccidentalMode::FLAT));

    QButtonGroup* ksbl = new QButtonGroup(this);
    ksbl->addButton(radioKeySigCourtesyBarlineAlwaysSingle, int(CourtesyBarlineMode::ALWAYS_SINGLE));
    ksbl->addButton(radioKeySigCourtesyBarlineAlwaysDouble, int(CourtesyBarlineMode::ALWAYS_DOUBLE));
src/notation/view/widgets/editstyle.cpp
        { StyleId::figuredBassLineHeight,   true,  spinFBLineHeight,        0 },
        { StyleId::tabClef,                 false, ctg,                     0 },
        { StyleId::keySigNaturals,          false, ksng,                    0 },
+       { StyleId::pitchChangeAccidentals,  false, pca,                     0 },
        { StyleId::voltaLineStyle,          false, voltaLineStyle,          resetVoltaLineStyle },
        { StyleId::voltaDashLineLen,        false, voltaLineStyleDashSize,  resetVoltaLineStyleDashSize },
        { StyleId::voltaDashGapLen,         false, voltaLineStyleGapSize,   resetVoltaLineStyleGapSize },

こうすることで、先ほど追加したUIがpitchChangeAccidentalsとして管理されるようになりました。

最後に、UI上で設定した内容が反映されるように以下の変更を行います。

src/notation/internal/notationstyle.cpp
void NotationStyle::setStyleValue(const StyleId& styleId, const PropertyValue& newValue)
{
    if (styleValue(styleId) == newValue) {
        return;
    }

    if (styleId == StyleId::concertPitch) {
        score()->cmdConcertPitchChanged(newValue.toBool());
+   } else if(styleId == StyleId::pitchChangeAccidentals){
+       score()->undoChangeStyleVal(styleId, newValue);
+       score()->setAccidentalMode(newValue);
    } else {
        score()->undoChangeStyleVal(styleId, newValue);
        score()->update();
    }

    m_styleChanged.notify();
}

こうすることで、UI上で選択したボタンに対応する値がscore.cppsetAccidentalMode関数に渡され、回り回って所望の機能を実現することができました。なお、setAccidentalMode関数だけだと行った設定がUI上で反映されなかったのですが、undoChangeStyleVal関数を入れることで解決しました。

おわりに

このようにして、臨時記号の固定化機能を実装することができた一方で、UIが日本語表記に対応していなかったり、スクロール設定が少しおかしくなっていたりと、本家Musescoreにpull requestするためにはもう少しブラッシュアップする必要がありそうです...

脚注
  1. わかりにくいと思いますが、吹奏楽などにおいては、楽器によって楽譜に書かれた音(楽譜音)と実際に演奏される音(実音)が違うことがあります(例えばアルトサックスの場合、実音「ド」を楽譜上では「ラ」と表記します)。Musescoreでは楽譜音表記と実音表記を切り替えることができ、それに対応させるために実音の表記をtpc1で、楽譜音の表記をtpc2で管理しています(わかりにくければ、音の表し方には2種類あり、したがってそれに関する変数が2つあるという認識で大丈夫です)。 ↩︎

  2. 完全5度とは、音楽理論的に重要な音程のことで、半音7つ分離れた音の間隔のことです。「ド」と「ソ」、「レ」と「ラ」などが完全5度の音程となります。実際にこれらの音を同時に鳴らしてみると、綺麗なハーモニーになります。 ↩︎

  3. なぜ定数を変更するだけで所望の動作となるかはプログラミングというよりも音楽理論的な話であり、正直僕もあまりわかっていません... ↩︎ ↩︎

Discussion