LoginSignup
4
4

More than 1 year has passed since last update.

[C#] [WPF] 前回作成したピアノを改修する

Last updated at Posted at 2021-12-02

はじめに

これは前回作成したピアノアプリを改修するものですので、そちらをご覧いただいていることを前提としています

前回の仕様

  • 鍵盤の画像が表示される
  • 鍵盤にカーソルを当てると色が変わる
  • 鍵盤をクリックすると音が鳴る(音を鳴らす処理はユーザーコントロール内)

今回の仕様

  • 鍵盤の画像が表示される
  • 鍵盤にカーソルを当てると 鍵盤をクリックすると色が変わる
  • 鍵盤をクリックすると音が鳴る(音を鳴らす処理はユーザーコントロール内 利用側)
  • (上に関連して、)ユーザーコントロールは鍵盤がクリックされたことを伝える event を持つ
  • 利用側からユーザーコントロールに対して、鍵盤が選択されたことを伝えられるようにする(色を変えたり上の event を発火させたりする)

最終的には、自動演奏的な(?)表示をすることを目指す

前回の仕様で動くまで改修する

鍵盤の状態を表すカスタム添付プロパティを作成する

PianoKeyboardControl.xaml.cs
public static readonly DependencyProperty IsDownProperty = DependencyProperty.Register("IsDown"// プロパティ名
                                , typeof(bool)// プロパティの型
                                , typeof(PianoKeyboardControl)// プロパティを持つクラスの型
                                , new PropertyMetadata(false));// デフォルト値
public static void SetIsDown(Shape key, bool value = true) => key.SetValue(IsDownProperty, value);
public static bool GetIsDown(Shape key) => (bool) key.GetValue(IsDownProperty);

鍵盤が押された/離されたことを通知するイベントを作成する

PianoKeyboardControl.xaml.cs
// イベントを処理するハンドラー
public delegate void PianoKeyEventHandler(object sender, PianoKeyEventArgs eventArgs);

// イベントの内容を表すクラス
public class PianoKeyEventArgs : RoutedEventArgs {
                public PianoKeyEventArgs(RoutedEvent routedEvent, byte noteNumber) : base(routedEvent) {
        NoteNumber = noteNumber;
    }

    public byte NoteNumber { get; }
    public int Timestamp { get; } = Environment.TickCount;
}

// イベントの登録
public static readonly RoutedEvent PianoKeyDownEvent = EventManager.RegisterRoutedEvent("PianoKeyDown"// イベント名
                                , RoutingStrategy.Bubble// イベントの通知方針
                                , typeof(PianoKeyEventHandler)// イベントを処理する delegate
                                , typeof(PianoKeyboardControl));// イベントを持つクラスの型
// イベントの定義
public event PianoKeyEventHandler PianoKeyDown {
    add { AddHandler(PianoKeyDownEvent, value); }
    remove { RemoveHandler(PianoKeyDownEvent, value); }
}
// イベントの発火
private void RaisePianoKeyDownEvent(Shape sender) {
    // 既に押さえられている鍵盤なら無視する
    if(GetIsDown(sender)) return;
    // ここで IsDown プロパティを書き換える
    SetIsDown(sender, true);
    // sender.Tag にはノートナンバーを入れるので、それを参照する
    PianoKeyEventArgs newEventArgs = new PianoKeyEventArgs(PianoKeyboardControl.PianoKeyDownEvent, (byte) sender.Tag);
    // 登録されたハンドラに通知する
    RaiseEvent(newEventArgs);
}

// 鍵盤が離された場合にも同様にイベントを定義する
public static readonly RoutedEvent PianoKeyUpEvent = EventManager.RegisterRoutedEvent("PianoKeyUp", RoutingStrategy.Bubble, typeof(PianoKeyEventHandler), typeof(PianoKeyboardControl));

public event PianoKeyEventHandler PianoKeyUp {
    add { AddHandler(PianoKeyUpEvent, value); }
    remove { RemoveHandler(PianoKeyUpEvent, value); }
}

private void RaisePianoKeyUpEvent(Shape sender) {
    if( ! GetIsDown(sender)) return;
    SetIsDown(sender, false);
    PianoKeyEventArgs newEventArgs = new PianoKeyEventArgs(PianoKeyboardControl.PianoKeyUpEvent, (byte) sender.Tag);
    RaiseEvent(newEventArgs);
}

鍵盤の作成処理を書き換える

PianoKeyboardControl.xaml.cs
private int AddKey(int curX, byte pitch, CutOffType ignoreCutOff) {

    : : : (中略)

    if (type == KeyType.Wide) {
        Shape element = CreateWideKeyShape(cutOff);
        Canvas.SetLeft(element, curX);
        Canvas.SetTop(element, 0);

        // [追加] Tag にノートナンバーを入れることで、イベント発火時に参照できるようにする
        element.Tag = pitch;
        // [変更前] 元々はユーザーコントロールで発音処理をしていた
        // element.MouseLeftButtonDown += (sender, eventArgs) => NoteOn(pitch);
        // element.MouseLeftButtonUp += (sender, eventArgs) => NoteOff(pitch);
        // element.MouseLeave += (sender, eventArgs) => NoteOff(pitch);
        // [変更後] 今回はイベントを発生させ、イベントハンドラで諸々の処理が行えるようにする
        element.MouseLeftButtonDown += OnPianoKeyClicked;
        element.MouseLeftButtonUp += OnPianoKeyReleased;
        element.MouseLeave += OnPianoKeyReleased;

        canvas.Children.Add(element);

        // curX = その時点で描画されている白鍵の右端
        curX += WideKeyWidth;
    } else /* if(type == KeyType.Black) */ {
        Shape element = CreateNarrowKeyShape();
        Canvas.SetLeft(element, curX - NarrowKeyWidth / 2);
        Canvas.SetTop(element, 0);

        // [追加] Tag にノートナンバーを入れることで、イベント発火時に参照できるようにする
        element.Tag = pitch;
        // [変更] 上にある白鍵と同様に変更する
        // element.MouseLeftButtonDown += (sender, eventArgs) => NoteOn(pitch);
        // element.MouseLeftButtonUp += (sender, eventArgs) => NoteOff(pitch);
        // element.MouseLeave += (sender, eventArgs) => NoteOff(pitch);
        element.MouseLeftButtonDown += OnPianoKeyClicked;
        element.MouseLeftButtonUp += OnPianoKeyReleased;
        element.MouseLeave += OnPianoKeyReleased;

        canvas.Children.Add(element);
    }

    return curX;
}

// [追加] 現在マウスでクリックされている鍵盤
private Shape clickedPianoKey = null;

// [追加] 鍵盤がクリックされた場合の処理
private void OnPianoKeyClicked(object sender, MouseEventArgs e) {
    clickedPianoKey = sender as Shape;
    RaisePianoKeyDownEvent(clickedPianoKey);
}

// [追加] 鍵盤が解放された場合の処理
private void OnPianoKeyReleased(object sender, MouseEventArgs e = null) {
    // 何もクリックしていない状態で呼び出されても無視する
    if(clickedPianoKey == null) return;
    clickedPianoKey = null;
    RaisePianoKeyUpEvent(sender as Shape);
}

private void CreateKeyboard(byte lowest, byte highest) {
    // [追加] 鍵盤をクリックしている状態で鍵盤の更新をする場合は解放処理を呼ぶ
    OnPianoKeyReleased(clickedPianoKey);

    : : : (中略)

}

発音処理を移動する

PianoKeyboardControl.xaml.cs
// [DemoPage.xaml.cs] に移動
// private IMidiOutPort outPort;

// [DemoPage.xaml.cs] に移動
// private async Task PrepareMidiOutPort() { ... }

// [DemoPage.xaml.cs] に移動
// private void NoteOn(byte note) { ... }

// [DemoPage.xaml.cs] に移動
// private void NoteOff(byte note) { ... }

public PianoKeyboardControl() {
    InitializeComponent();

    // [DemoPage.xaml.cs] に移動
    // _ = PrepareMidiOutPort();

    Loaded += (s, e) => CreateKeyboard();
}
DemoPage.xaml.cs
public partial class DemoPage : Page {
    // [追加]
    private IMidiOutPort outPort;

    public DemoPage() {
        InitializeComponent();

        // [追加]
        _ = PrepareMidiOutPort();

        keyboard.PianoKeyDown += (s, e) => NoteOn(e.NoteNumber);
        keyboard.PianoKeyUp += (s, e) => NoteOff(e.NoteNumber);
    }

    // [追加]
    private async Task PrepareMidiOutPort() {
        string selector = MidiOutPort.GetDeviceSelector();
        DeviceInformationCollection deviceInformationCollection = await DeviceInformation.FindAllAsync(selector);
        if (deviceInformationCollection?.Count > 0) {
            // collection has items
            string id = deviceInformationCollection[0].Id;
            outPort = await MidiOutPort.FromIdAsync(id);
        } else {
            // collection is null or empty
            throw new InvalidOperationException($"No MIDI device for {selector}");
        }
    }

    // [追加]
    private void NoteOn(byte note) {
        if (outPort == null) return;
        IMidiMessage messageToSend = new MidiNoteOnMessage(0, note, 80);
        outPort.SendMessage(messageToSend);
    }

    // [追加]
    private void NoteOff(byte note) {
        if (outPort == null) return;
        IMidiMessage messageToSend = new MidiNoteOnMessage(0, note, 0);
        outPort.SendMessage(messageToSend);
    }
}

途中経過

ここまででいったん動かしてみると、前回作成した時点と同じ挙動で動作する

新しい仕様について実装する

変更点は以下の通り
- 色が変わるタイミングを変更する(鍵盤にカーソルを当てると -> 鍵盤をクリックすると)
- 利用側からユーザーコントロールに対して、鍵盤が選択されたことを伝えられるようにする(色を変えたり上の event を発火させたりする)

色が変わるタイミングを変更する

これは PianoKeyboardControl.xaml を書き換えるだけ

PianoKeyboardControl.xaml
<UserControl.Resources>
    <Style x:Key="WideKeyStyle" TargetType="Shape">
        <Style.Triggers>
            <!-- [変更前] 前回はマウスオーバーで色が変わっていた -->
            <!-- <Trigger Property="IsMouseOver" Value="True"> -->
            <!-- [変更後] 今回は PianoControl.IsDown プロパティが変更されたタイミングで色を変更する -->
            <Trigger Property="local:PianoKeyboardControl.IsDown" Value="True">
                <Setter Property="Fill" Value="{Binding SelectedWideKeyColor, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=local:PianoKeyboardControl}}"/>
            </Trigger>
            <!-- [変更] 上と同様に Property を書き換える -->
            <!-- <Trigger Property="IsMouseOver" Value="False"> -->
            <Trigger Property="local:PianoKeyboardControl.IsDown" Value="False">
                    <Setter Property="Fill" Value="{Binding NotSelectedWideKeyColor, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=local:PianoKeyboardControl}}"/>
            </Trigger>
        </Style.Triggers>
    </Style>
    <Style x:Key="NarrowKeyStyle" TargetType="Shape">
        <Style.Triggers>
            <!-- [変更] 上と同様に Property を書き換える -->
            <!-- <Trigger Property="IsMouseOver" Value="True"> -->
            <Trigger Property="local:PianoKeyboardControl.IsDown" Value="True">
                <Setter Property="Fill" Value="{Binding SelectedNarrowKeyColor, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=local:PianoKeyboardControl}}"/>
            </Trigger>
            <!-- [変更] 上と同様に Property を書き換える -->
            <!-- <Trigger Property="IsMouseOver" Value="False"> -->
            <Trigger Property="local:PianoKeyboardControl.IsDown" Value="False">
                <Setter Property="Fill" Value="{Binding NotSelectedNarrowKeyColor, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=local:PianoKeyboardControl}}"/>
            </Trigger>
        </Style.Triggers>
    </Style>
</UserControl.Resources>

鍵盤の状態を利用側から変更できるようにする

まずは PianoKeyboardControl に変更用の関数を用意する

PianoKeyboard.xaml.cs
// [追加] <ノートナンバー, 鍵盤の描画図形> をフィールドに持つ
private readonly ConcurrentDictionary<byte, Shape> keys = new ConcurrentDictionary<byte, Shape>();

private void CreateKeyboard(byte lowest, byte highest) {
    // [追加] 辞書を空にする
    keys.Clear();

    : : : (中略)

}

private int AddKey(int curX, byte pitch, CutOffType ignoreCutOff) {

    : : : (中略)

    if (type == KeyType.Wide) {
        Shape element = CreateWideKeyShape(cutOff);

        : : : (中略)

        // [追加] 辞書に鍵盤を登録する
        keys[pitch] = element

        canvas.Children.Add(element);

        // curX = その時点で描画されている白鍵の右端
        curX += WideKeyWidth;
    } else /* if(type == KeyType.Black) */ {
        Shape element = CreateNarrowKeyShape();

        : : : (中略)

        // [追加] 辞書に鍵盤を登録する
        keys[pitch] = element

        canvas.Children.Add(element);
    }

    return curX;
}

// [追加] 鍵盤の状態を取得/変更するメソッド
public bool GetIsPianoKeyDown(byte noteNumber) => GetIsDown(keys[noteNumber]);
public void SetIsPianoKeyDown(byte noteNumber, bool value = true) => SetIsDown(keys[noteNumber], value);

途中経過

DemoPage.xaml.cs を編集して、ユーザーコントロールの利用側から鍵盤の状態を書き換えられることを確認する

DemoPage.xaml.cs
public DemoPage() {
    InitializeComponent();

    _ = PrepareMidiOutPort();

    keyboard.PianoKeyDown += (s, e) => NoteOn(e.NoteNumber);
    keyboard.PianoKeyUp += (s, e) => NoteOff(e.NoteNumber);

    // [追加] 鍵盤の初期化が済み次第、状態を書き換える
    keyboard.Loaded += Keyboard_Loaded;
}

// [追加]
private void Keyboard_Loaded(object sender, RoutedEventArgs e) {
    PianoKeyboardControl kb = sender as PianoKeyboardControl;

    // C を含む全音階を選択する
    kb.SetIsPianoKeyDown(48, true);// C3
    kb.SetIsPianoKeyDown(48 + 2, true);
    kb.SetIsPianoKeyDown(48 + 4, true);
    kb.SetIsPianoKeyDown(48 + 6, true);
    kb.SetIsPianoKeyDown(48 + 8, true);
    kb.SetIsPianoKeyDown(48 + 10, true);
    kb.SetIsPianoKeyDown(48 + 12, true);// C4
    kb.SetIsPianoKeyDown(48 + 12 + 2, true);
    kb.SetIsPianoKeyDown(48 + 12 + 4, true);
    kb.SetIsPianoKeyDown(48 + 12 + 6, true);
    kb.SetIsPianoKeyDown(48 + 12 + 8, true);
    kb.SetIsPianoKeyDown(48 + 12 + 10, true);
    kb.SetIsPianoKeyDown(48 + 12 * 2, true);// C5
}

以上に加え、 PianoKeyboardControl.Lowest, および HighestSelectedWideKeyColorProperty と同様に DependencyProperty を使い定義し直して、 DemoPage.xaml 上で Lowest="48" Highest="72" を追加する.
この状態で動かしたSSは以下の通り:
作成したアプリのSS
期待通り、全音階の鍵盤の色が変化している

自動演奏

C# は MIDI について再生する手段を用意してくれていないらしいので、自力で泥臭く記述することとする.
(あまり良くは見ていないが、NAudio も MIDI ファイルの読み込みだけで、再生手段は用意してくれていない?)

かえるのうたを演奏させる

DemoPage.xaml.cs
public DemoPage() {
    InitializeComponent();

    // [変更] MIDI出力ポートの準備タスクを捨てない
    Task preparingTask = PrepareMidiOutPort();

    keyboard.PianoKeyDown += (s, e) => NoteOn(e.NoteNumber);
    keyboard.PianoKeyUp += (s, e) => NoteOff(e.NoteNumber);

    // [変更] MIDI出力ポートの準備タスクを渡す
    keyboard.Loaded += (s, e) => Keyboard_Loaded(preparingTask);
}

// [変更] 引数を変え、async を付与
private async void Keyboard_Loaded(Task preparingTask) {
    // MIDI出力ポートの準備が整うまで待つ
    await preparingTask;

    // 四分音符の長さ = 0.5s = 120bpm
    TimeSpan crotchetLength = TimeSpan.FromSeconds(0.5);
    // 半音符の長さ = 四分音符の長さ * 2
    TimeSpan minimLength = TimeSpan.FromTicks(crotchetLength.Ticks * 2);
    // 八分音符の長さ = 四分音符の長さ / 2
    TimeSpan quaverLength = TimeSpan.FromTicks(crotchetLength.Ticks / 2);

    byte DO = 60;
    byte RE = 62;
    byte MI = 64;
    byte FA = 65;
    byte SOL = 67;
    byte LA = 69;

    // ちまちま、「かえるのうた」を記述する
    // ドーレーミーファーミーレードーーー
    await PlayNote(crotchetLength, DO, 80);
    await PlayNote(crotchetLength, RE, 80);
    await PlayNote(crotchetLength, MI, 80);
    await PlayNote(crotchetLength, FA, 80);
    await PlayNote(crotchetLength, MI, 80);
    await PlayNote(crotchetLength, RE, 80);
    await PlayNote(minimLength, DO, 80);

    // ミーファーソーラーソーファーミーーー
    await PlayNote(crotchetLength, MI, 80);
    await PlayNote(crotchetLength, FA, 80);
    await PlayNote(crotchetLength, SOL, 80);
    await PlayNote(crotchetLength, LA, 80);
    await PlayNote(crotchetLength, SOL, 80);
    await PlayNote(crotchetLength, FA, 80);
    await PlayNote(minimLength, MI, 80);

    // ドー  ドー  ドー  ドー
    await PlayNote(crotchetLength, DO, 80);
    await Task.Delay(crotchetLength);// 休符の代わり
    await PlayNote(crotchetLength, DO, 80);
    await Task.Delay(crotchetLength);
    await PlayNote(crotchetLength, DO, 80);
    await Task.Delay(crotchetLength);
    await PlayNote(crotchetLength, DO, 80);
    await Task.Delay(crotchetLength);

    // ドドレレミミファファ
    await PlayNote(quaverLength, DO, 80);
    await PlayNote(quaverLength, DO, 80);
    await PlayNote(quaverLength, RE, 80);
    await PlayNote(quaverLength, RE, 80);
    await PlayNote(quaverLength, MI, 80);
    await PlayNote(quaverLength, MI, 80);
    await PlayNote(quaverLength, FA, 80);
    await PlayNote(quaverLength, FA, 80);

    // ミーレードーー
    await PlayNote(crotchetLength, MI, 80);
    await PlayNote(crotchetLength, RE, 80);
    await PlayNote(minimLength, DO, 80);
}

// [追加] 指定された長さ(duration)で、指定された音(note)を、指定された強さ(velocity)で発音し止める
private async Task PlayNote(TimeSpan duration, byte note, byte velocity = 80) {
    keyboard.SetIsPianoKeyDown(note, true);
    NoteOn(note, velocity);
    await Task.Delay(duration);
    keyboard.SetIsPianoKeyDown(note, false);
    NoteOff(note);
}

この状態でプログラムを動かすと、かえるのうたに合わせて対応する鍵盤の色が変わる

最後に

ここまで読んでいただいてありがとうございます。
書いたのがC#初心者ということもありつたない部分も多かったとは思いますが、誰かの何かに役立てばうれしいです。
曲の演奏処理について、今回書いた PlayNote で輪唱は難しく見送りました。
しかし、引数の byte noteparams byte[] notes に変えるなどして(可変長引数では引数の順序を変える必要がありますが)ループ処理を入れれば和音演奏も可能ですから、よろしければ色々遊んでみてください。
その内 MIDI ファイルの再生処理も書くつもりではいます。
MIDI ファイルの再生処理について書きました(NAudio で MIDI ファイルを読み込んで再生する)
以上、改めてありがとうございました。

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4