11
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[C#] [WPF] ピアノのユーザーコントロールを作ってみる

Last updated at Posted at 2021-11-27

作るもの

最小限のピアノを作る

  • 鍵盤の画像が表示される
  • 鍵盤にカーソルを当てると色が変わる
  • 鍵盤をクリックすると音が鳴る

準備

  • IDE: Microsoft Visual Studio Community 2019
    • 「新しいプロジェクト」から「WPFアプリ(.NET Framework)」を選択してプロジェクトを作成
    • 作成したプロジェクトに「ページ(WPF)」として「DemoPage.xaml」を追加
    • 作成したプロジェクトに「ユーザーコントロール(WPF)」として「PianoKeyboardControl.xaml」を追加
    • PianoKeyboardControl.xaml を編集
PianoKeyboardControl.xaml
<UserControl x:Class="<プロジェクトの名前空間>.PianoKeyboardControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:Mth.WPF.Piano.ForQiita"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <Canvas x:Name="canvas"/>
</UserControl>
  • DemoPage.xaml を編集
DemoPage.xaml
<Page x:Class="<プロジェクトの名前空間>.DemoPage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
      xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
      xmlns:local="clr-namespace:Mth.WPF.Piano.ForQiita"
      mc:Ignorable="d" 
      d:DesignHeight="450" d:DesignWidth="800"
      Background="Maroon"
      Title="DemoPage">

    <Grid>
        <ScrollViewer VerticalScrollBarVisibility="Hidden" HorizontalScrollBarVisibility="Auto">
            <local:PianoKeyboardControl x:Name="keyboard" />
        </ScrollViewer>
    </Grid>
</Page>

鍵盤の描画部分を作成する

鍵盤の形に関する Enum を用意する

鍵盤の色に関するもの(後から鍵盤の色を変えられるようにするため、白/黒ではなく、広い/狭いで表現)

KeyType.cs
enum KeyType {
    Wide, Narrow
}

白い鍵盤の形状に関するもの(左右に黒い鍵盤のための切り欠きがあるかのフラグ)

CutOffType.cs
[Flags]
enum CutOffType {
    None = 0,
    CutLeft = 1,
    CutRight = 2,
    CutBoth = CutLeft | CutRight
}

PianoKeyboardControl.canvas に鍵盤の形状に関する値を定義する

後から変更しやすいように諸々のパラメータをプロパティとしておく

PianoKeyboardControl.cs
// 88鍵ピアノの最低音(=A0)のノートナンバー
private const byte GENERAL_PIANO_LOWEST_PITCH = 21;
// 88鍵ピアノの最高音(=C8)のノートナンバー
private const byte GENERAL_PIANO_HIGHEST_PITCH = 108;
// 描画する最低音
public byte Lowest => GENERAL_PIANO_LOWEST_PITCH;
// 描画する最高音
public byte Highest => GENERAL_PIANO_HIGHEST_PITCH;
// 白鍵の幅
public int WideKeyWidth => 44;
// 黒鍵の幅
public int NarrowKeyWidth => 28;
// 白鍵の高さ
public int WideKeyHeight => 220;
// 黒鍵の高さ
public int NarrowKeyHeight => 150;

PianoKeyboardControl.canvas に鍵盤の描画オブジェクトを追加する

PianoKeyboardControl.cs
public PianoKeyboardControl() {
    InitializeComponent();
    // オブジェクトを作成する
    Loaded += (s, e) => CreateKeyboard();
}

private void CreateKeyboard() => CreateKeyboard(Lowest, Highest);

private void CreateKeyboard(byte lowest, byte highest) {
    canvas.Children.Clear();

    // curX = その時点で描画されている白鍵の右端
    int curX = 0;
    // 一番左の鍵盤には左側の切り欠きがない
    curX = AddKey(curX, lowest, CutOffType.CutLeft);

    for (byte pitch = (byte) (lowest + 1); pitch < highest; ++pitch) {
        curX = AddKey(curX, pitch, CutOffType.None);
    }

    // 一番右の鍵盤には右側の切り欠きがない
    curX = AddKey(curX, highest, CutOffType.CutRight);

    // canvas の大きさを中身に合わせる
    canvas.Width = curX;
    canvas.Height = WideKeyHeight;
}

private int AddKey(int curX, byte pitch, CutOffType ignoreCutOff) {
    // 音高から鍵盤の形状を取得する
    (KeyType type, CutOffType cutOff) = GetKeyShape(pitch);
    // 左右の切り欠きを無視する必要があればここで取り除く
    cutOff &= ~ignoreCutOff;

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

        canvas.Children.Add(element);

        // curX = その時点で描画されている白鍵の右端
        curX += WideKeyWidth;
    } else /* if(type == KeyType.Black) */ {
        Shape element = CreateNarrowKeyShape();
        // (curX = その時点で描画されている白鍵の右端) なので、黒鍵の半分だけ左に食い込ませる
        Canvas.SetLeft(element, curX - NarrowKeyWidth / 2);
        Canvas.SetTop(element, 0);

        canvas.Children.Add(element);
    }

    return curX;
}

private Shape CreateNarrowKeyShape() {
    // 黒鍵の色と形状
    return new Polygon {
        Fill = Brushes.Black,
        Stroke = Brushes.CadetBlue,
        StrokeThickness = 2,
        Points = new PointCollection {
            // Left side
            new Point(0, 0),
            // Bottom side
            new Point(0, NarrowKeyHeight),
            new Point(NarrowKeyWidth, NarrowKeyHeight),
            // Right side
            new Point(NarrowKeyWidth, 0)
        }
    };
}

private Shape CreateWideKeyShape(CutOffType cutOff) {
    // 白鍵の共通項目
    Polygon shape = new Polygon {
        Fill = Brushes.White,
        Stroke = Brushes.OrangeRed,
        StrokeThickness = 2,
    };
    // 切り欠きの位置に応じて形状を決定する
    switch (cutOff) {
    case CutOffType.CutLeft:
        shape.Points = new PointCollection {
            // Left side
            new Point(NarrowKeyWidth / 2, 0),
            new Point(NarrowKeyWidth / 2, NarrowKeyHeight),
            new Point(0, NarrowKeyHeight),
            // Bottom side
            new Point(0, WideKeyHeight),
            new Point(WideKeyWidth, WideKeyHeight),
            // Right side
            new Point(WideKeyWidth, 0)
        };
    break; case CutOffType.CutRight:
        shape.Points = new PointCollection {
            // Left side
            new Point(0, 0),
            // Bottom side
            new Point(0, WideKeyHeight),
            new Point(WideKeyWidth, WideKeyHeight),
            // Right side
            new Point(WideKeyWidth, NarrowKeyHeight),
            new Point(WideKeyWidth - NarrowKeyWidth / 2, NarrowKeyHeight),
            new Point(WideKeyWidth - NarrowKeyWidth / 2, 0)
        };
    break; case CutOffType.CutBoth:
        shape.Points = new PointCollection {
            // Left side
            new Point(NarrowKeyWidth / 2, 0),
            new Point(NarrowKeyWidth / 2, NarrowKeyHeight),
            new Point(0, NarrowKeyHeight),
            // Bottom side
            new Point(0, WideKeyHeight),
            new Point(WideKeyWidth, WideKeyHeight),
            // Right side
            new Point(WideKeyWidth, NarrowKeyHeight),
            new Point(WideKeyWidth - NarrowKeyWidth / 2, NarrowKeyHeight),
            new Point(WideKeyWidth - NarrowKeyWidth / 2, 0)
        };
    break; case CutOffType.None:
        shape.Points = new PointCollection {
            // Left side
            new Point(0, 0),
            // Bottom side
            new Point(0, WideKeyHeight),
            new Point(WideKeyWidth, WideKeyHeight),
            // Right side
            new Point(WideKeyWidth, 0)
        };
    break; default: throw new InvalidOperationException($"Unknown cutOffType: {cutOff}");
    }
    return shape;
}

private (KeyType type, CutOffType cutOff) GetKeyShape(byte pitch) {
    // 鍵盤の色と切り欠きの位置を取得する
    switch (pitch % 12) {
    case 0:// C
    case 5:// F
        return (KeyType.Wide, CutOffType.CutRight);
    case 4:// E
    case 11:// B
        return (KeyType.Wide, CutOffType.CutLeft);
    case 2:// D
    case 7:// G
    case 9:// A
        return (KeyType.Wide, CutOffType.CutBoth);
    case 1:// Db
    case 3:// Eb
    case 6:// Gb
    case 8:// Ab
    case 10:// Bb
        return (KeyType.Narrow, CutOffType.None);
    default: throw new ArgumentException($"Invalid pitch: {pitch}", "pitch");
    }
}

途中経過

ここまでの内容で動かすと以下のようになるはず
ここまでで作成したプログラムのSS

マウスオーバーで鍵盤の色が変わるようにする

鍵盤の色をプロパティに定義(せっかくなのでデフォルトではグラデーションをかける)

PianoKeyboardControl.cs
// マウスオーバーしているときの白鍵の色
public static readonly DependencyProperty SelectedWideKeyColorProperty = DependencyProperty.Register(nameof(SelectedWideKeyColor), typeof(Brush), typeof(PianoKeyboardControl), new PropertyMetadata(new LinearGradientBrush(new GradientStopCollection() {
        new GradientStop(Colors.Red, 0.7),
        new GradientStop(Colors.LightSalmon, 1.0)
    }), (sender, args) => {
        PianoKeyboardControl control = sender as PianoKeyboardControl;
        if (control.IsLoaded) control.CreateKeyboard();
    }));
public Brush SelectedWideKeyColor {
    get { return (Brush) GetValue(SelectedWideKeyColorProperty); }
    set { SetValue(SelectedWideKeyColorProperty, value); }
}
// マウスオーバーしているときの黒鍵の色
public static readonly DependencyProperty SelectedNarrowKeyColorProperty = DependencyProperty.Register(nameof(SelectedNarrowKeyColor), typeof(Brush), typeof(PianoKeyboardControl), new PropertyMetadata(new LinearGradientBrush(new GradientStopCollection() {
        new GradientStop(Colors.LightSteelBlue, 0.0),
        new GradientStop(Colors.Blue, 0.7)
    }), (sender, args) => {
        PianoKeyboardControl control = sender as PianoKeyboardControl;
        if (control.IsLoaded) control.CreateKeyboard();
    }));
public Brush SelectedNarrowKeyColor {
    get { return (Brush) GetValue(SelectedNarrowKeyColorProperty); }
    set { SetValue(SelectedNarrowKeyColorProperty, value); }
}
// マウスオーバーしていないときの白鍵の色
public static readonly DependencyProperty NotSelectedWideKeyColorProperty = DependencyProperty.Register(nameof(NotSelectedWideKeyColor), typeof(Brush), typeof(PianoKeyboardControl), new PropertyMetadata(new LinearGradientBrush(new GradientStopCollection() {
        new GradientStop(Colors.AliceBlue, 0.0),
        new GradientStop(Colors.White, 0.7)
    }), (sender, args) => {
        PianoKeyboardControl control = sender as PianoKeyboardControl;
        if (control.IsLoaded) control.CreateKeyboard();
    }));
public Brush NotSelectedWideKeyColor {
    get { return (Brush) GetValue(NotSelectedWideKeyColorProperty); }
    set { SetValue(NotSelectedWideKeyColorProperty, value); }
}
// マウスオーバーしていないときの黒鍵の色
public static readonly DependencyProperty NotSelectedNarrowKeyColorProperty = DependencyProperty.Register(nameof(NotSelectedNarrowKeyColor), typeof(Brush), typeof(PianoKeyboardControl), new PropertyMetadata(new LinearGradientBrush(new GradientStopCollection() {
        new GradientStop(Colors.Black, 0.7),
        new GradientStop(Colors.DarkGray, 1.0)
    }), (sender, args) => {
        PianoKeyboardControl control = sender as PianoKeyboardControl;
        if (control.IsLoaded) control.CreateKeyboard();
    }));
public Brush NotSelectedNarrowKeyColor {
    get { return (Brush) GetValue(NotSelectedNarrowKeyColorProperty); }
    set { SetValue(NotSelectedNarrowKeyColorProperty, value); }
}

PianoKeyboardControl.xaml にスタイルを定義

PianoKeyboardControl.xaml
<UserControl.Resources>
    <Style x:Key="WideKeyStyle" TargetType="Shape">
        <Style.Triggers>
            <Trigger Property="IsMouseOver" Value="True">
                <Setter Property="Fill" Value="{Binding SelectedWideKeyColor, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=local:PianoKeyboardControl}}"/>
            </Trigger>
            <Trigger Property="IsMouseOver" 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>
            <Trigger Property="IsMouseOver" Value="True">
                <Setter Property="Fill" Value="{Binding SelectedNarrowKeyColor, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=local:PianoKeyboardControl}}"/>
            </Trigger>
            <Trigger Property="IsMouseOver" Value="False">
                <Setter Property="Fill" Value="{Binding NotSelectedNarrowKeyColor, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=local:PianoKeyboardControl}}"/>
            </Trigger>
        </Style.Triggers>
    </Style>
</UserControl.Resources>

C#側で Shape.Fill を定義する代わりに Shape.Style を定義するよう変更

PianoKeyboardControl.cs
private Shape CreateNarrowKeyShape() {
    // 黒鍵の色と形状
    return new Polygon {
        // ここで Fill を削除し、Style を追加する
        // Fill = NotSelectedNarrowKeyColor,
        Style = FindResource("NarrowKeyStyle") as Style,
        Stroke = Brushes.CadetBlue,
        StrokeThickness = 2,
        : : : (中略)
}// CreateNarrowKeyShape()

private Shape CreateWideKeyShape(CutOffType cutOff) {
    // 白鍵の共通項目
    Polygon shape = new Polygon {
        // ここで Fill を削除し、Style を追加する
        // Fill = NotSelectedWideKeyColor,
        Style = FindResource("WideKeyStyle") as Style,
        Stroke = Brushes.OrangeRed,
        StrokeThickness = 2,
    };
    : : : (後略)

途中経過

ここまでの内容で動かすと以下のようになるはず
白鍵にマウスを当てたとき
ここまでで作成したプログラムのSS
黒鍵にマウスを当てたとき
ここまでで作成したプログラムのSS

(ついでに)鍵盤の色を変える

鍵盤の色をプロパティにしたので、ユーザーコントロールの利用側から鍵盤の色を指定できるようになった

DemoPage.xaml
<!-- ~KeyColor 属性に Brush を設定することで好きな色が使える -->
<local:PianoKeyboardControl x:Name="keyboard"
                            NotSelectedWideKeyColor="DarkSlateBlue"
                            SelectedWideKeyColor="SlateBlue"
                            NotSelectedNarrowKeyColor="Bisque"
                            SelectedNarrowKeyColor="LightSalmon"/>

クリックしたときに音が鳴るようにする

MIDI を扱うためにライブラリを追加する

参照にMicrosoft.Windows.SDK.Contractsする

  1. IDE のソリューションエクスプローラーでプロジェクトを右クリック
  2. NuGet パッケージの管理(N)...
  3. 「参照」タブ
  4. 検索窓に「Microsoft.Windows.SDK.Contracts」と入力して検索Visual Studio の SS
  5. 「インストール」ボタンを押す
  6. (設定次第で)「変更のプレビュー」というウィンドウが開くので「OK」
  7. 「ライセンスへの同意」ウィンドウが開くので「同意する」

これでMIDIが扱えるようになった(Windows.Devices.EnumerationWindows.Devices.Midi 名前空間)

PianoKeyboardControl.csを編集する

PianoKeyboardControl.cs
// [追加] MIDI の出力ポート
private IMidiOutPort outPort;

public PianoKeyboardControl() {
    InitializeComponent();

    // [追加] MIDIポートを取得する
    _ = PrepareMidiOutPort();

    Loaded += (s, e) => CreateKeyboard();
}

// [追加]
private async Task PrepareMidiOutPort() {
    // PC上に存在する MIDI の出力ポートを検索して取得する
    string selector = MidiOutPort.GetDeviceSelector();
    DeviceInformationCollection deviceInformationCollection = await DeviceInformation.FindAllAsync(selector);
    if (deviceInformationCollection?.Count > 0) {
        // 出力ポートがひとつ以上取得できた場合はとりあえず先頭のポートを使用する
        // Windows の場合は普通 Microsoft GS Wavetable Synth が取得できるはず
        string id = deviceInformationCollection[0].Id;
        outPort = await MidiOutPort.FromIdAsync(id);
    } else {
        // 出力ポートが取得できない場合はエラー
        throw new InvalidOperationException($"No MIDI device for {selector}");
    }
}

: : : (中略)

private int AddKey(int curX, byte pitch, CutOffType ignoreCutOff) {
    // 音高から鍵盤の形状を取得する
    (KeyType type, CutOffType cutOff) = GetKeyShape(pitch);
    // 左右の切り欠きを無視する必要があればここで取り除く
    cutOff &= ~ignoreCutOff;

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

        // [追加] クリックされたときに音を出し、(左ボタン/マウスカーソルを)離したときに音を止める
        element.MouseLeftButtonDown += (sender, eventArgs) => NoteOn(pitch);
        element.MouseLeftButtonUp += (sender, eventArgs) => NoteOff(pitch);
        element.MouseLeave += (sender, eventArgs) => NoteOff(pitch);

        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);

        // [追加] クリックされたときに音を出し、(左ボタン/マウスカーソルを)離したときに音を止める
        element.MouseLeftButtonDown += (sender, eventArgs) => NoteOn(pitch);
        element.MouseLeftButtonUp += (sender, eventArgs) => NoteOff(pitch);
        element.MouseLeave += (sender, eventArgs) => NoteOff(pitch);

        canvas.Children.Add(element);
    }

    return curX;
}

// [追加] 指定した高さの音を鳴らす
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);
}

: : : (後略)

完成

ここまでの内容で動かすと、鍵盤をクリックすることでピアノの音が鳴る

最後に

ここまで読んでいただいてありがとうございます。
C#初心者がQiitaに初めて投稿した記事ということでつたない部分も多かったとは思いますが、誰かの何かに役立てばうれしいです。
以上、長い記事となりましたが改めてありがとうございました。

11
14
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
11
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?