20211202のC#に関する記事は11件です。

[C#] [NAudio] [MIDI] NAudio で MIDI ファイルを読み込んで再生する

はじめに 私はMIDIファイルを再生するための処理を C# の基本ライブラリや NAudio から見つけられませんでした。 ですので、この記事はMIDIファイルの読み込みを NAudio にやらせそこから先は自力で行う、という処理について書いたものになります。 探せば確実に何かしらのライブラリが存在するであろう類の処理ではありますが、自身の勉強も兼ねて実装したので記事にした次第です。 環境 Microsoft Visual Studio Community 2019 Version 16.11.3 対称のフレームワーク: .NET Framework 4.7.2 C# 7.3 作るもの NAudio で読み込んだMIDIファイルを再生するためのMIDIシーケンサクラス. ただし、ファイルの頭から尻尾までの再生のみとし、部分再生やループには対応しない. 事前知識 MIDI ファイルはざっくり次のような作りになっている. MIDI ファイル フォーマットタイプ(0, 1, 2 のいずれか) 音の分解能(四分音符ひとつが何 Tick であるか)1 トラック数 <トラック数>個のトラック 0個以上のMIDIイベント 直前のイベントからの経過 Tick 数 実際の MIDI メッセージ (音を鳴らす/止める、テンポを変える、楽器を変える、等々) 実装上、音を鳴らす/止める、楽器を変える、等々のメッセージは適当な変換を施して出力ポートに送り込むだけでよいものの、再生速度、あるいは各イベントの発火タイミングはシーケンサーが正しく制御しなければならない. このため、この記事の中で最も重要な話題はタイミングの計り方となる. MIDI ファイルは複数のトラック(format=0 の場合はひとつのみ)を持っているが、テンポに関するデータは先頭のトラックにのみ置く、という決まりがあるので、先頭のトラックはコンダクター(=指揮者)トラックとも呼ばれる. シーケンサーの仕事量を減らす上で最も重要でありがたいルールだと思う. MIDI ファイルに含まれるテンポ/時間の情報 テンポ/時間に関わる項目は、上記の「分解能」、「直前のイベントからの経過 Tick 数」、そして実際のMIDIメッセージとして現れる「テンポを変える」命令である. NAudio は上記の加え、MIDIイベントに曲の先頭からの経過 Tick 数をプロパティとして持っているため、今回はこれも利用できる. 結果として、「直前のイベントからの経過 Tick 数」以外の3つをどうにかして再生速度、MIDIメッセージを送るタイミングを制御することとなる. 曲のテンポの単位と考え方 普通の人が曲の速さについて言及する際の単位は BPM(Beats Per Minutes, 一分間にいくつの四分音符を鳴らせるか)だが、MIDI ファイルの中でテンポの設定をする場合は MPQ(Microseconds Per Quater note, 四分音符ひとつは何マイクロ秒であるか)である. このふたつは以下の公式で互いに変換される. MPQ * BPM = 60 000 000 ここに出てきている6千万という数字は、1[分] = 60[秒] = 60 000 [ミリ秒] = 60 000 000 [マイクロ秒] ということで出てきたものである. 経過時間の単位と考え方 普通の人が曲の経過時間について言及する際の単位は時分秒、小節数、拍子などだと思われるが、MIDI ファイルの中では Tick数が用いられる. Tick数はMIDI MPQ によって四分音符の数と相互に変換でき、四分音符の数はファイルの先頭で定義される分解能(=四分音符ひとつは何マイクロ秒であるか)によってマイクロ秒に変換できる. 例えば、Resolution(=分解能)=480、MPQ = 500 000、Ticks=1 650 として、ここから[ミリ秒]を求める場合は \begin{eqnarray} [Number\ of\ quater\ notes] &=& Ticks / Resolution \\ &=& 1,650 / 480 \\ &=& 3.4375 \\ \\ [microseconds] &=& [Number\ of\ quater\ notes] * MPQ \\ &=& 3.4375 * 500,000 \\ &=& 1,718,750 \\ \\ [milliseconds] &=& [microseconds] / 1,000 \\ &=& 1,718,750 / 1,000 \\ &=& 1,718.750 \end{eqnarray} のようになる. 実装の方針 再生処理の先頭で System.Diagnostics.Stopwatch を動かし、曲の再生開始からの経過時間をミリ秒で取得できるようにする. 各トラックについて、何番目のイベントまで再生したかを変数として保持する. 全てのイベントが再生されたトラックの数を変数として保持する. 全てのトラックの再生が終わるまでループを回し続ける. ループの先頭で経過時間(ミリ秒)を経過時間(Tick数)に変換し、イベントの持つ(Tick数)が経過時間(Tick数)以下であればそのイベントを再生する. 作る プロジェクトの作成と参照の追加 プロジェクトの種類は何でもよい(ライブラリが望ましい)とは思われるが、ここでは私が作業したものを記録の意味で書き残す 「新しいプロジェクトを追加」から「コンソールアプリ(.NET Framework)」を選択し「次へ」 適当なプロジェクト名を入力し「作成」 作成したプロジェクトを右クリックし「NuGetパッケージの管理(N)...」 「参照」タブを開き、検索窓に「Microsoft.Windows.SDK.Contracts」を入力. 出てきた「Microsoft.Windows.SDK.Contracts」を選択し「インストール」(バージョンは 2021-12-2現在で最新の 10.0.22000.196) 同じく検索窓に「NAudio」を入力. 出てきた「NAudio」を選択し「インストール」(バージョンは 2021-12-2現在で最新の 2.0.1) 曲のテンポデータを扱うクラスを作る MIDI ファイルを正しく再生するためには、コンダクタートラックからテンポを設定するメッセージを抜き出し、正しく管理しなければならない. つまり、どのタイミングで曲の速度がどう変化するのかを把握し、経過時間(ミリ秒)を経過時間(Tick数)に正しく変換する処理が必要となる. まずはこの操作を実現するクラス TempoData を作成する. TempoData.cs using NAudio.Midi; using System.Collections.Generic; using System.Linq; <名前空間は略> // これは曲再生の際に用いるだけのクラスであるから、可視性は internal としている internal class TempoData { // ファイル内にテンポ指定がない場合は 120 bpm = 500 000 mpq とする. private const int DEFAULT_MPQ = 500_000; // mpqStack[n] = n 個目の Set Tempo イベントが持つ MPQ private readonly int[] mpqStack; // cumulativeTicks[n] = 曲の先頭から、n 個目の Set Tempo イベントが発生するまでの時間 (Ticks) private readonly long[] cumulativeTicks; // cumulativeMicroseconds[n] = 曲の先頭から、n 個目の Set Tempo イベントが発生するまでの時間 (us) private readonly long[] cumulativeMicroseconds; // 分解能(四分音符ひとつは何 Tick であるか) public int Resolution { get; } // 再生に当たって、NAudio.Midi.MidiEventCollection は実質的に Midi ファイルとして見なせる public TempoData(MidiEventCollection midiEvents) { // Pulses Per Quater note int resolution = midiEvents.DeltaTicksPerQuarterNote; // TempoEvent のみを抜き出す (イベントは AbsoluteTime の昇順で並んでいる) // Set Tempo イベントは 0 番トラックにのみ現れるはずなので、midiEvents[0] のみから探す List<(long tick, TempoEvent message)> tempoEvents = midiEvents[0].Where(evt => evt is TempoEvent) .Select(evt => (tick: evt.AbsoluteTime, message: (TempoEvent) evt)) .ToList(); if ((tempoEvents.Count == 0) || (tempoEvents[0].tick != 0L)) { // 先頭にテンポ指定がない場合はデフォルト値を入れる tempoEvents.Insert(0, (0L, new TempoEvent(DEFAULT_MPQ, 0))); } this.mpqStack = new int[tempoEvents.Count]; this.cumulativeTicks = new long[tempoEvents.Count]; this.cumulativeMicroseconds = new long[tempoEvents.Count]; // 0 Tick 時点での値を先に入れる mpqStack[0] = tempoEvents[0].message.MicrosecondsPerQuarterNote; cumulativeTicks[0] = cumulativeMicroseconds[0] = 0L; int pos = 1; foreach ((long tick, TempoEvent message) in tempoEvents.Skip(1)) { cumulativeTicks[pos] = tick; // deltaTick = 前回の Set Tempo からの時間 (Ticks) long deltaTick = tick - cumulativeTicks[pos - 1]; mpqStack[pos] = message.MicrosecondsPerQuarterNote; // deltaMicroseconds = 前回の Set Tempo からの時間 (us) // <= MPQ = mpqStack[pos - 1] で deltaTick だけ経過している long deltaMicroseconds = TicksToMicroseconds(deltaTick, mpqStack[pos - 1], resolution); cumulativeMicroseconds[pos] = cumulativeMicroseconds[pos - 1] + deltaMicroseconds; ++pos; } this.Resolution = resolution; }// Constructor public long MicrosecondsToTicks(long us) { // 曲の開始から us[マイクロ秒] 経過した時点は、 // 曲の開始から 何Ticks 経過した時点であるかを計算する int index = GetIndexFromMicroseconds(us); // 現在の MPQ は mpq である int mpq = mpqStack[index]; // 直前のテンポ変更があったのは cumUs(マイクロ秒) 経過した時点であった long cumUs = cumulativeMicroseconds[index]; // 直前のテンポ変更があったのは cumTicks(Ticks) 経過した時点であった long cumTicks = cumulativeTicks[index]; // 直前のテンポ変更から deltaUs(マイクロ秒)が経過している long deltaUs = us - cumUs; // 直前のテンポ変更から deltaTicks(Ticks)が経過している long deltaTicks = MicrosecondsToTicks(deltaUs, mpq, Resolution); return cumTicks + deltaTicks; } private int GetIndexFromMicroseconds(long us) { // 指定された時間(マイクロ秒)時点におけるインデックスを二分探索で探す int lo = -1; int hi = cumulativeMicroseconds.Length; while ((hi - lo) > 1) { int m = hi - (hi - lo) / 2; if (cumulativeMicroseconds[m] <= us) lo = m; else hi = m; } return lo; } private static long MicrosecondsToTicks(long us, long mpq, int resolution) { // 時間(マイクロ秒)を時間(Tick)に変換する return us * resolution / mpq; } private static long TicksToMicroseconds(long tick, long mqp, int resolution) { // 時間(Tick)を時間(マイクロ秒)に変換する return tick * mqp / resolution; } }// class TempoData シーケンサークラスを作る NAudio.Midi にあるMIDIイベントクラスと Windows.Devices.Midi にあるMIDIメッセージクラスとの変換処理の記述がやや長いが、メインである Play() でやっていることはさほど難しくないはず. (なお、申し訳ありませんが入念な調査や試験をしているわけではありませんので、勘違いによる実装/不具合があるかもしれません. 見つけた方は私を含めこの記事を参照する人のため教えていただけるととてもありがたいです.) MidiSequencer.cs using NAudio.Midi; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices.WindowsRuntime; using System.Threading.Tasks; using Windows.Devices.Midi; <名前空間は略> // MIDI イベント発生時の処理 public delegate void MidiEventHandler(MidiEvent e); public class MidiSequencer { // MIDI 出力ポート private readonly IMidiOutPort outPort; // MIDI データ private readonly MidiEventCollection midiEvents; // MIDI イベント発生時の処理ハンドラー public event MidiEventHandler OnMidiEvent; public MidiSequencer(IMidiOutPort outPort, MidiEventCollection midiEvents) { this.outPort = outPort ?? throw new ArgumentNullException(nameof(outPort)); this.midiEvents = midiEvents ?? throw new ArgumentNullException(nameof(midiEvents)); } public async Task Play() { TempoData tempo = new TempoData(midiEvents); // 完了したトラック数 int finishedTracks = 0; // 各トラックの再生済みイベント数 int[] eventIndices = new int[midiEvents.Tracks]; // イベントがひとつもないトラックは始まる前から終わってる. for (int i = 0; i < midiEvents.Tracks; ++i) { IList<MidiEvent> currentTrack = midiEvents[i]; if (currentTrack.Count == 0) ++finishedTracks; } // ここから曲の再生を開始する Stopwatch stopWatch = new Stopwatch(); stopWatch.Start(); while (finishedTracks != midiEvents.Tracks) { // 経過時間 (microseconds) long elapsed = stopWatch.ElapsedMilliseconds * 1000L; // 経過時間 (Ticks) long elapsedTicks = tempo.MicrosecondsToTicks(elapsed); // elapsedTicks が負の場合はバグか何か if (elapsedTicks < 0) throw new InvalidProgramException($"elapsedTicks = {elapsedTicks} < 0 !!"); for (int i = 0; i < midiEvents.Tracks; ++i) { IList<MidiEvent> currentTrack = midiEvents[i]; // このトラックについて再生が終了していれば次のトラックへ if (eventIndices[i] == currentTrack.Count) continue; while (currentTrack[eventIndices[i]].AbsoluteTime <= elapsedTicks) { // 再生されるべき時刻(AbsoluteTime) を過ぎていれば再生する MidiEvent currentEvent = currentTrack[eventIndices[i]]; // イベントの通知 OnMidiEvent?.Invoke(currentEvent); // 出力ポートに送信するオブジェクトに変換する(送信できないイベントの場合 null が返される) IMidiMessage messageToSend = ConvertToMidiMessageOrNull(currentEvent); if(messageToSend != null) outPort.SendMessage(messageToSend); // 消費済みイベント数をインクリメント ++eventIndices[i]; if (eventIndices[i] == currentTrack.Count) { // トラック内の全イベントが消化されていれば完了トラック数をインクリメントし、このトラックについてのループを抜ける ++finishedTracks; break; } } } // 1ms のディレイを入れる await Task.Delay(1); } }// #Play() /// <summary> /// NAudio が定義したMIDIイベントオブジェクトを、Microsoft が定義したものに変換する. /// </summary> /// <param name="midiEvent">NAudio が定義したMIDIイベントオブジェクト</param> /// <returns>対応するクラスが存在する場合 Microsoft が定義したメッセージ. さもなくば null</returns> private IMidiMessage ConvertToMidiMessageOrNull(MidiEvent midiEvent) { switch (midiEvent.CommandCode) { case MidiCommandCode.NoteOff: {// 8n NoteEvent @event = midiEvent as NoteEvent; return new MidiNoteOffMessage((byte) @event.Channel, (byte) @event.NoteNumber, (byte) @event.Velocity); } case MidiCommandCode.NoteOn: {// 9n NoteOnEvent @event = midiEvent as NoteOnEvent; return new MidiNoteOnMessage((byte) @event.Channel, (byte) @event.NoteNumber, (byte) @event.Velocity); } case MidiCommandCode.KeyAfterTouch: {// An NoteEvent @event = midiEvent as NoteEvent; return new MidiPolyphonicKeyPressureMessage((byte) @event.Channel, (byte) @event.NoteNumber, (byte) @event.Velocity); } case MidiCommandCode.ControlChange: {// Bn ControlChangeEvent @event = midiEvent as ControlChangeEvent; return new MidiControlChangeMessage((byte) @event.Channel, (byte) @event.Controller, (byte) @event.ControllerValue); } case MidiCommandCode.PatchChange: {// Cn PatchChangeEvent @event = midiEvent as PatchChangeEvent; return new MidiProgramChangeMessage((byte) @event.Channel, (byte) @event.Patch); } case MidiCommandCode.ChannelAfterTouch: {// Dn ChannelAfterTouchEvent @event = midiEvent as ChannelAfterTouchEvent; return new MidiChannelPressureMessage((byte) @event.Channel, (byte) @event.AfterTouchPressure); } case MidiCommandCode.PitchWheelChange: {// En PitchWheelChangeEvent @event = midiEvent as PitchWheelChangeEvent; return new MidiPitchBendChangeMessage((byte) @event.Channel, (byte) @event.Pitch); } case MidiCommandCode.Sysex: {// F0 SysexEvent @event = midiEvent as SysexEvent; MemoryStream ms = new MemoryStream(); BinaryWriter bw = new BinaryWriter(ms); long _ = 0L; @event.Export(ref _, bw); bw.Flush(); return new MidiSystemExclusiveMessage(ms.ToArray().AsBuffer()); } case MidiCommandCode.Eox: // F7 case MidiCommandCode.TimingClock: // F8 case MidiCommandCode.StartSequence: // FA case MidiCommandCode.ContinueSequence: // FB case MidiCommandCode.StopSequence: // FC case MidiCommandCode.AutoSensing: // FE case MidiCommandCode.MetaEvent: // FF // これらは対応する IMidiMessage が存在しない return null; default: throw new InvalidOperationException($"Unknown MidiCommandCode: {midiEvent.CommandCode}"); } }// #ConvertToMidiMessageOrNull(MidiEvent) }// class MidiSequencer 使う MIDI ファイルの用意 今回は動作確認のため、 MuseScore 3 を利用して以下のような楽譜を用意し MIDI 出力したものを使用した: 楽譜その1: 楽譜その2: 利用側クラスを作成する Program.cs class Program { // 行儀は悪いが、フィールドに MIDI ファイルが持つ分解能を保持する static int resolution; static void Main(string[] args) { // 指定した MIDI ファイルを再生する string filename = @"C:\sandbox\Test_-_Chord.mid"; Task task = PlayMidi(filename); task.Wait(); // コンソール画面が閉じるのを防ぐ Console.ReadLine(); } // MIDI ファイルを再生する private static async Task PlayMidi(string filename) { Task<IMidiOutPort> port = PrepareMidiOutPort(); MidiFile midiFile = new MidiFile(filename); // 行儀は悪いが、Sequencer_OnMidiEvent から使う変数をここでフィールドに代入する Program.resolution = midiFile.DeltaTicksPerQuarterNote; // シーケンサーを作成する MidiSequencer sequencer = new MidiSequencer(port.Result, midiFile.Events); sequencer.OnMidiEvent += Sequencer_OnMidiEvent; // 実際に再生する await sequencer.Play(); } // MIDI 出力ポートを取得する private static async Task<IMidiOutPort> PrepareMidiOutPort() { string selector = MidiOutPort.GetDeviceSelector(); DeviceInformationCollection deviceInformationCollection = await DeviceInformation.FindAllAsync(selector); if (deviceInformationCollection?.Count > 0) { // collection has items string id = deviceInformationCollection[0].Id; IMidiOutPort outPort = await MidiOutPort.FromIdAsync(id); return outPort; } else { // collection is null or empty throw new InvalidOperationException($"No MIDI device for {selector}"); } } // MIDI イベントの処理ハンドラー private static void Sequencer_OnMidiEvent(MidiEvent e) { // 今回は 4/4 拍子であることが分かっているので 4 で割っているが、 // MIDI ファイル内には拍子情報が必須ではないため実際に任意の曲で小節数を出そうとすると難しい. // ちなみに、拍子情報がある場合は MetaEvent として現れる. long measure = (e.AbsoluteTime / Program.resolution / 4) + 1; // 次の内容で出力する: [小節数 : 経過時間(Ticks) : イベントの内容] Console.WriteLine($"{measure,3} : {e.AbsoluteTime,5} : {e}"); } }// class Program 動作結果 上のプログラムを動かすと、曲が再生され、標準出力は以下のようになった(楽譜その1の場合): 1 : 0 : 0 TimeSignature 4/4 TicksInClick:24 32ndsInQuarterNote:8 1 : 0 : 0 KeySignature -2 0 1 : 0 : 0 SetTempo 127bpm (468751) 1 : 0 : 0 ControlChange Ch: 1 Controller ResetAllControllers Value 0 1 : 0 : 0 PatchChange Ch: 1 Acoustic Grand 1 : 0 : 0 ControlChange Ch: 1 Controller MainVolume Value 100 1 : 0 : 0 ControlChange Ch: 1 Controller Pan Value 64 1 : 0 : 0 ControlChange Ch: 1 Controller 91 Value 0 1 : 0 : 0 ControlChange Ch: 1 Controller 93 Value 0 1 : 0 : 0 MidiPort 00 1 : 0 : 0 NoteOn Ch: 1 A#4 Vel:80 Len: 455 1 : 0 : 0 NoteOn Ch: 1 D5 Vel:80 Len: 455 1 : 0 : 0 NoteOn Ch: 1 F5 Vel:80 Len: 455 1 : 455 : 455 NoteOn Ch: 1 A#4 Vel:0 (Note Off) 1 : 455 : 455 NoteOn Ch: 1 D5 Vel:0 (Note Off) 1 : 455 : 455 NoteOn Ch: 1 F5 Vel:0 (Note Off) 1 : 480 : 480 NoteOn Ch: 1 C5 Vel:80 Len: 455 1 : 480 : 480 NoteOn Ch: 1 D#5 Vel:80 Len: 455 1 : 480 : 480 NoteOn Ch: 1 G#5 Vel:80 Len: 455 1 : 935 : 935 NoteOn Ch: 1 C5 Vel:0 (Note Off) 1 : 935 : 935 NoteOn Ch: 1 D#5 Vel:0 (Note Off) 1 : 935 : 935 NoteOn Ch: 1 G#5 Vel:0 (Note Off) 1 : 960 : 960 NoteOn Ch: 1 C#5 Vel:80 Len: 455 1 : 960 : 960 NoteOn Ch: 1 F5 Vel:80 Len: 455 1 : 960 : 960 NoteOn Ch: 1 G#5 Vel:80 Len: 455 1 : 1415 : 1415 NoteOn Ch: 1 C#5 Vel:0 (Note Off) 1 : 1415 : 1415 NoteOn Ch: 1 F5 Vel:0 (Note Off) 1 : 1415 : 1415 NoteOn Ch: 1 G#5 Vel:0 (Note Off) 1 : 1440 : 1440 NoteOn Ch: 1 D#5 Vel:80 Len: 455 1 : 1440 : 1440 NoteOn Ch: 1 G5 Vel:80 Len: 455 1 : 1440 : 1440 NoteOn Ch: 1 A#5 Vel:80 Len: 455 1 : 1895 : 1895 NoteOn Ch: 1 D#5 Vel:0 (Note Off) 1 : 1895 : 1895 NoteOn Ch: 1 G5 Vel:0 (Note Off) 1 : 1895 : 1895 NoteOn Ch: 1 A#5 Vel:0 (Note Off) 2 : 1920 : 1920 NoteOn Ch: 1 E5 Vel:80 Len: 455 2 : 1920 : 1920 NoteOn Ch: 1 G#5 Vel:80 Len: 455 2 : 1920 : 1920 NoteOn Ch: 1 B5 Vel:80 Len: 455 2 : 2375 : 2375 NoteOn Ch: 1 E5 Vel:0 (Note Off) 2 : 2375 : 2375 NoteOn Ch: 1 G#5 Vel:0 (Note Off) 2 : 2375 : 2375 NoteOn Ch: 1 B5 Vel:0 (Note Off) 2 : 2400 : 2400 NoteOn Ch: 1 F#5 Vel:80 Len: 455 2 : 2400 : 2400 NoteOn Ch: 1 A#5 Vel:80 Len: 455 2 : 2400 : 2400 NoteOn Ch: 1 C#6 Vel:80 Len: 455 2 : 2855 : 2855 NoteOn Ch: 1 F#5 Vel:0 (Note Off) 2 : 2855 : 2855 NoteOn Ch: 1 A#5 Vel:0 (Note Off) 2 : 2855 : 2855 NoteOn Ch: 1 C#6 Vel:0 (Note Off) 2 : 2880 : 2880 NoteOn Ch: 1 G5 Vel:80 Len: 455 2 : 2880 : 2880 NoteOn Ch: 1 B5 Vel:80 Len: 455 2 : 2880 : 2880 NoteOn Ch: 1 D6 Vel:80 Len: 455 2 : 3335 : 3335 NoteOn Ch: 1 G5 Vel:0 (Note Off) 2 : 3335 : 3335 NoteOn Ch: 1 B5 Vel:0 (Note Off) 2 : 3335 : 3335 NoteOn Ch: 1 D6 Vel:0 (Note Off) 2 : 3360 : 3360 NoteOn Ch: 1 A5 Vel:80 Len: 455 2 : 3360 : 3360 NoteOn Ch: 1 C#6 Vel:80 Len: 455 2 : 3360 : 3360 NoteOn Ch: 1 E6 Vel:80 Len: 455 2 : 3815 : 3815 NoteOn Ch: 1 A5 Vel:0 (Note Off) 2 : 3815 : 3815 NoteOn Ch: 1 C#6 Vel:0 (Note Off) 2 : 3815 : 3815 NoteOn Ch: 1 E6 Vel:0 (Note Off) 3 : 3840 : 3840 NoteOn Ch: 1 B4 Vel:80 Len: 1823 3 : 3840 : 3840 NoteOn Ch: 1 F#5 Vel:80 Len: 1823 3 : 3840 : 3840 NoteOn Ch: 1 B5 Vel:80 Len: 1823 3 : 3840 : 3840 NoteOn Ch: 1 F#6 Vel:80 Len: 1823 3 : 3840 : 3840 NoteOn Ch: 1 B6 Vel:80 Len: 1823 3 : 5663 : 5663 NoteOn Ch: 1 B4 Vel:0 (Note Off) 3 : 5663 : 5663 NoteOn Ch: 1 F#5 Vel:0 (Note Off) 3 : 5663 : 5663 NoteOn Ch: 1 B5 Vel:0 (Note Off) 3 : 5663 : 5663 NoteOn Ch: 1 F#6 Vel:0 (Note Off) 3 : 5663 : 5663 NoteOn Ch: 1 B6 Vel:0 (Note Off) 3 : 5664 : 5664 EndTrack 最後に ここまで読んでいただいてありがとうございます。 拙い記事ではございましたが、C# で MIDI を扱おうとする方、MIDI の勉強をなさる方のお役に立ちましたら幸いと存じます。 私の環境では使われる MIDI シンセサイザーが Windows 標準のものであるため音質などは何とか再生できるといった風情でしたが、MidiSequencer に渡す IMidiOutport がきちんとしたものであれば実用に耐える音が再生できるのではないでしょう。多分。 C# には Midi シンセサイザも見当たらなかったので、自分で使う分にはPCの方にインストールしてしまうのが簡単なのでしょうか。 以上、改めてありがとうございました。 SMPTEコード(1秒は何Tickであるか)による指定もあるが、ここでは考えないものとする. NAudio も DeltaTicksPerQuarterNote というプロパティ名を利用しているから、普通は分解能が設定されるものと考えていそう. ↩
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【C#】Head First デザインパターン2章 Observerパターン

Observerパターン 2章まで進みました。今回はObserverパターンです。1対他の構造をとるので、購読システム等を作るときに使うとよいです。観察者(Observer)が観察対象者(Subject)の変化を観察します。 良い点として挙げられているのは、疎結合です。 観察対象者が観察者がどのようなクラスであるか等を知る必要はなく、そのクラスが特定のインターフェイスを持っていることだけを知っています。 そのほかにもいろいろありますが、疎結合であるとうれしいことは一方を変更しても他方に影響がないことです。 2章では、気象台が出す気象データ(観察対象: WeatherData)を登録されているデバイス(観察者: Displays)に送信します。 インターフェイスを使わないパターン StatisticsDisplayはここから持ってきました。 気象台が温度計等から観測したデータをすべて集め、WeatherDataにセットします。データが新しくセットされるたびに、各ディスプレイはアップデートされます。ここではWeatherDataが現在の状態と統計を表す二つのディスプレイの更新をします。 CurrentConditionsDisplayでは現在の気温と湿度を、StatisticsDisplayでは今まで受け取った結果の平均気温、最大気温、最低気温を表示します。 図はシンプルですが、クラス間に依存があります。 BadExample.cs class Program { static void Main(string[] args) { WeatherData weatherData = new WeatherData(); weatherData.setMeasurements(80, 65, 30.4f); weatherData.setMeasurements(90, 80, 60.4f); weatherData.setMeasurements(20, 45, 20.4f); } } //新しくディスプレイを追加するたびにこのWeatherDataを書き換える必要がある。 public class WeatherData { private float temperature; private float humidity; private float pressure; CurrentConditionsDisplay currentDisplay = new CurrentConditionsDisplay(); StatisticsDisplay statisticsDisplay = new StatisticsDisplay(); public void measurementsChanged() { currentDisplay.update(temperature,humidity,pressure); statisticsDisplay.update(temperature, humidity, pressure); statisticsDisplay.display(); } public void setMeasurements(float temperature, float humidity, float pressure) { this.temperature = temperature; this.humidity = humidity; this.pressure = pressure; measurementsChanged(); } } public class CurrentConditionsDisplay { private float temperature; private float humidity; public void update(float temperature, float humidity, float pressure) { this.temperature = temperature; this.humidity = humidity; display(); } public void display() { Console.WriteLine("Current conditions: " + temperature + "F degrees and " + humidity + "% humidity"); } } public class StatisticsDisplay { private float maxTemp = 0.0f; private float minTemp = 200;//set intial high so that minimum //is set first invokation private float temperatureSum = 0.0f; private int numReadings = 0; public int NumberOfReadings { get { return numReadings; } } public void update(float temperature, float humidity, float pressure) { temperatureSum += temperature; numReadings++; if (temperature > maxTemp) { maxTemp = temperature; } if (temperature < minTemp) { minTemp = temperature; } } public void display() { Console.WriteLine("Avg /Max/Min temperature = " + RoundFloatToString(temperatureSum / numReadings) + "F/" + maxTemp + "F/" + minTemp + "F"); } public static string RoundFloatToString(float floatToRound) { System.Globalization.CultureInfo cultureInfo = new System.Globalization.CultureInfo("en-US"); cultureInfo.NumberFormat.CurrencyDecimalDigits = 2; cultureInfo.NumberFormat.CurrencyDecimalSeparator = "."; return floatToRound.ToString("F", cultureInfo); } } ここでの問題点は新しくディスプレイが増えるたびにWeatherDataを書き換えなければいけなくなることです。この密結合を疎結合にするためにインターフェイスを導入します。 図は複雑になりましたが、インターフェイスを通しているのでクラス間に依存はありません。 ディスプレイを追加したい場合、簡単にmainの中で追加することができ、追加の際にコードの変更をする必要はありません。 WithInterface.cs class Program { static void Main(string[] args) { WeatherData weatherData = new WeatherData(); CurrentConditionsDisplay currentDisplay = new CurrentConditionsDisplay(weatherData); StatisticsDisplay statisticsDisplay = new StatisticsDisplay(weatherData); weatherData.setMeasurements(80, 65, 30.4f); weatherData.setMeasurements(90, 80, 60.4f); weatherData.setMeasurements(20, 45, 20.4f); statisticsDisplay.display(); } } public interface ISubject { public void registerObserver(IObserver o); public void removeObserver(IObserver o); public void notifyObservers(); } public interface IObserver { public void update(float temp, float humidity, float pressure); } public interface IDisplayElement { public void display(); } public class WeatherData: ISubject { private List<IObserver> observers; private float temperature; private float humidity; private float pressure; public WeatherData() { observers = new List<IObserver>(); } public void registerObserver(IObserver o) { observers.Add(o); } public void removeObserver(IObserver o) { int i = observers.IndexOf(o); if(i>= 0) { observers.RemoveAt(i); } } public void notifyObservers() { for(int i = 0; i < observers.Count; i++) { IObserver observer = (IObserver)observers[i]; observer.update(temperature, humidity, pressure); } } public void measurementsChanged() { notifyObservers(); } public void setMeasurements(float temperature,float humidity,float pressure) { this.temperature = temperature; this.humidity = humidity; this.pressure = pressure; measurementsChanged(); } } public class CurrentConditionsDisplay : IObserver, IDisplayElement { private float temperature; private float humidity; private ISubject weatherData; public CurrentConditionsDisplay(ISubject weatherData) { this.weatherData = weatherData; weatherData.registerObserver(this); } public void update(float temperature, float humidity, float pressure) { this.temperature = temperature; this.humidity = humidity; display(); } public void display() { Console.WriteLine("Current conditions: " + temperature + "F degrees and " + humidity + "% humidity"); } } public class StatisticsDisplay : IObserver, IDisplayElement { #region Members private float maxTemp = 0.0f; private float minTemp = 200;//set intial high so that minimum //is set first invokation private float temperatureSum = 0.0f; private int numReadings = 0; private ISubject weatherData; #endregion//Members #region NumberOfReadings Property public int NumberOfReadings { get { return numReadings; } } #endregion//NumberOfReadings Property #region Constructor public StatisticsDisplay(ISubject weatherData) { this.weatherData = weatherData; weatherData.registerObserver(this); } #endregion//Constructor #region IObserver Members public void update(float temperature, float humidity, float pressure) { temperatureSum += temperature; numReadings++; if (temperature > maxTemp) { maxTemp = temperature; } if (temperature < minTemp) { minTemp = temperature; } //display(); } #endregion #region IDisplayElement Members public void display() { Console.WriteLine("Avg /Max/Min temperature = " + RoundFloatToString(temperatureSum / numReadings) + "F/" + maxTemp + "F/" + minTemp + "F"); } #endregion #region RoundFloatToString public static string RoundFloatToString(float floatToRound) { System.Globalization.CultureInfo cultureInfo = new System.Globalization.CultureInfo("en-US"); cultureInfo.NumberFormat.CurrencyDecimalDigits = 2; cultureInfo.NumberFormat.CurrencyDecimalSeparator = "."; return floatToRound.ToString("F", cultureInfo); } #endregion//RoundFloatToString } 結果です。 一言 次回は3章です。UML図いまだに慣れません…
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

遺伝的アルゴリズムでドット絵の「Qiitan」を作ってみる

はじめに われらがQiitaのマスコットキャラクター「Qiitan」。 現在実施中の「Qiita Advent Calendar 2021」に参加すれば、抽選で巨大Qiitanぬいぐるみをもらえるキャンペーンも行っているみたいですね! 今回は、こんな愛くるしい笑顔の「Qiitan」を遺伝的アルゴリズムを使って画像生成することを試みる記事です。 遺伝的アルゴリズムとは 遺伝的アルゴリズムは、新幹線の先端の形状を考えるのに利用されたり、工場でより効率的な生産計画を立てるのに利用されたりするアルゴリズムです。 どういったアルゴリズムかというと、 データ(解の候補)を遺伝子で表現した「個体」を複数用意し、適応度の高い個体を優先的に選択して交叉・突然変異などの操作を繰り返しながら解を探索する。 (Wikipediaより引用) 簡単に説明すると、以下の作業を繰り返し、できる限り理想に近い個体を生成するというようなアルゴリズムです。 (⓪事前準備として、ランダムな複数の個体を用意)  ①ある基準をもって個体を評価し、優秀な個体を選別する  ②選別された個体の情報をもとに新たな個体を複数生成する(低確率で突然変異させる) 今回私が行った方法は、まずランダムな画像を256個用意します。 その後、トーナメント形式で2枚ずつ画像を比較して勝敗をつけ4枚の画像となるまで選別します。 そうして残った4枚の画像から次の世代の256枚の画像を作成していきました。 完成品 実装 ソースの全容は以下のリンクから確認できます。 0. 事前準備 まずは、元となる画像を準備します。 画像をドット絵に変えてくれるサイトを利用してドット絵に変換します。 画像サイズが大きいと大変になりそうなので、ピクセル数を50×50としておきます。 ※目が小さいとうまく目が生成されてくれなかったので、少し大きめに修正しています。 次に、ランダムな画像を作成します。 0~255の3つの乱数からRGBを指定して色を作成します。 そうしてできる色を1ピクセルずつ配置することでランダムな画像を生成することができます。 コード static void CreateRandomImg() { var generation = 0; Directory.CreateDirectory(string.Format(FOLDER_PATH, generation)); for (int i = 0; i < 256; i++) { Bitmap img = new Bitmap(PIXEL_SIZE, PIXEL_SIZE); Random rnd = new Random(); for (int x = 0; x < img.Width; x++) { for (int y = 0; y < img.Height; y++) { Color c = Color.FromArgb(rnd.Next(256), rnd.Next(256), rnd.Next(256)); img.SetPixel(x, y, c); } } img.Save(string.Format(IMAGE_PATH, generation, i)); } } 1. 優秀な画像の選別 基準となる画像と生成された画像を1ピクセルずつ色の差を計算して数値化し、合計値が低い画像を優秀な画像と判断していきます。 ここで問題となるのが色の差です。 みなさんは下の画像を見て、色の差をどう感じるでしょうか? 左と真ん中の色の差より、真ん中と右の色の差のほうが大きく感じられたのではないでしょうか? しかし、単純にRGBの3つの値を3次元の点と考えて距離を数値化した場合、左右2つの色の差は同じ値となってしまいます。 そこで、CIEDE2000という考え方を利用します。 細かい計算の方法はよくわかっていませんが、色の差をより人間の目で見た感覚に近い値として計算されるようです。 この値を利用することで、より人間が似ていると思えるような画像の生成ができるようになります。 コード static double GetScore(Bitmap img) { var score = 0.0; for (int x = 0; x < PIXEL_SIZE; x++) { for (int y = 0; y < PIXEL_SIZE; y++) { var lab1 = CIELAB.RGBToLab(ORIGINAL_IMAGE.GetPixel(x, y)); var lab2 = CIELAB.RGBToLab(img.GetPixel(x, y)); var ciede = new CIEDE2000(lab1.L, lab1.A, lab1.B); score += ciede.DE00(lab2.L, lab2.A, lab2.B); } } return score; } 次に、トーナメント形式で2枚ずつ画像を比較して勝敗をつけ4枚の画像となるまで選別する方法についてです。 これには「キュー」というデータ構造を利用します。 まず、キューに0~255までの数字を追加します。 そして、2つずつ数字を取り出して、その番号の画像同士を競わせます。 その勝った画像の番号の数字を別のキューに溜めていきます。 キューが空になったら、勝った画像を溜めたキューを元のキューにコピーして、残り画像が4枚になるまでこの作業を繰り返します。 コード static void SelectWinners(int generation) { Queue<int> que = new Queue<int>(); Queue<int> queWinners = new Queue<int>(); for (int i = 0; i < 256; i++) que.Enqueue(i); //4枚になるまで選別作業を繰り返す while (que.Count > 4) { while (que.Any()) { var num1 = que.Dequeue(); var num2 = que.Dequeue(); var score1 = GetScore(new Bitmap(string.Format(IMAGE_PATH, generation, num1))); var score2 = GetScore(new Bitmap(string.Format(IMAGE_PATH, generation, num2))); if (score1 <= score2) { queWinners.Enqueue(num1); } else { queWinners.Enqueue(num2); } } que = queWinners; queWinners = new Queue<int>(); } Directory.CreateDirectory(string.Format(WINNERS_FOLDER_PATH, generation)); foreach (var num in que) { File.Copy(string.Format(IMAGE_PATH, generation, num), string.Format(WINNERS_IMAGE_PATH, generation, num)); } } 2. 次の世代の生成 優秀な4枚の画像からランダムに1ピクセルずつ色を選んで次の世代の画像を生成していきます。 その中で1%の確率で突然変異を起こし、ランダムな色から選ぶパターンを用意しておきます。 (突然変異の確率は調整が必要です。) 突然変異がないと、最初に作成されたランダムな画像にない色が作成されなくなります。 突然変異を入れることで、あまり基準の画像と似ていない局所的最適解に陥ることを防ぎます。 コード static Color CreateColor(List<Bitmap> images, int x, int y) { Random rnd = new Random(); //1%の確率で突然変異 if (rnd.Next(100) < 1) { return Color.FromArgb(rnd.Next(256), rnd.Next(256), rnd.Next(256)); } var imageNo = rnd.Next(4); return images[imageNo].GetPixel(x, y); } static void CreateNewGenarations(int generation) { //1つ前の世代の、残った4枚を取得 var images = new List<Bitmap>(); foreach (var path in Directory.EnumerateFiles(string.Format(WINNERS_FOLDER_PATH, generation - 1), "*")) { images.Add(new Bitmap(path)); } Directory.CreateDirectory(string.Format(FOLDER_PATH, generation)); for (int i = 0; i < 256; i++) { Bitmap img = new Bitmap(PIXEL_SIZE, PIXEL_SIZE); for (int x = 0; x < img.Width; x++) { for (int y = 0; y < img.Height; y++) { img.SetPixel(x, y, CreateColor(images, x, y)); } } img.Save(string.Format(IMAGE_PATH, generation, i)); } } あとは、「1.優秀な画像の選別」と「2.次の世代の生成」の作業をひたすら繰り返して、基準となる画像に似た画像が出来上がるのを待つだけです。 おわりに 遺伝的アルゴリズムと聞くと「なんだか難しそう」と思っていましたが、ちょっとしたものであれば意外と簡単に実装することができました。 遺伝的アルゴリズムは、「目的は分かっているが、方法が分からない」というような場面で活用されるようです。物理エンジンやゲームの攻略などでよく見かけますが、自分なりに活用できる場を見つけて応用できればなーと思いました。 参考 Qiita - 色の距離(色差)の計算方法 YouTube - 【実験】遺伝的アルゴリズムで素敵なモザイクアート作ってみた【理系の休日】
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

はじめに これは前回作成したピアノアプリを改修するものですので、そちらをご覧いただいていることを前提としています 前回の仕様 鍵盤の画像が表示される 鍵盤にカーソルを当てると色が変わる 鍵盤をクリックすると音が鳴る(音を鳴らす処理はユーザーコントロール内) 今回の仕様 鍵盤の画像が表示される 鍵盤にカーソルを当てると 鍵盤をクリックすると色が変わる 鍵盤をクリックすると音が鳴る(音を鳴らす処理はユーザーコントロール内 利用側) (上に関連して、)ユーザーコントロールは鍵盤がクリックされたことを伝える 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, および Highest を SelectedWideKeyColorProperty と同様に DependencyProperty を使い定義し直して、 DemoPage.xaml 上で Lowest="48" Highest="72" を追加する. この状態で動かした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 note を params byte[] notes に変えるなどして(可変長引数では引数の順序を変える必要がありますが)ループ処理を入れれば和音演奏も可能ですから、よろしければ色々遊んでみてください。 その内 MIDI ファイルの再生処理も書くつもりではいます。 MIDI ファイルの再生処理について書きました(NAudio で MIDI ファイルを読み込んで再生する) 以上、改めてありがとうございました。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Debug.Log()の全文をファイル出力し、<message truncated>で省略された内容をすぐに確認できるようにする

PONOS Advent Calendar 2021の3日目の記事です。 昨日は私@e73ryoの「SystemInfo.operatingSystemからバージョンを取得するときの注意(iPadOS15.1以降のiPad端末の挙動について)」でした。 はじめに 先日、Unityプロジェクトへ外部SDKの導入をしていたところ、久しぶりにConsoleウインドウ上で<message truncated>に遭遇しました。 Unityには、Consoleウインドウに出力しようとしているログの文章が一定の文字数(私の手元で確認した限りでは16,300文字)を超えると<message truncated>が末尾に挿入されてログが終了してしまう仕様があります。 すごーーく長いエラーログが出力された場合に、その全文をConsoleウインドウ上ですぐに確認できないのは不便ですね。 一応、ConsoleウインドウのOpen Editor Logメニューから参照できるログには全文が記録されていますので、 そちらを開いて該当のログを検索することで、ログの全文を取得することが可能ではあります。 <message truncated>になってしまうのはあくまでConsoleウインドウで表示される上での仕様なので、ログ情報としては全文が残っています。 しかし、ここで確認できるログはプレーンテキストですべてのログが1ファイルに保存されているため、目的のログだけを抽出するには少し手間がかかります。 ということで、ログの全文取得を改善するディタ拡張を考えてみました。 確認環境はUnity 2019.4.32f1です。 実装の方針 では、どういった拡張機能を作りましょうか。 最初はUnityエディタのConsoleウインドウ上でログの全文が表示される機能にしようと考えていました。 やはり、Consoleウインドウ上ですぐにログの全文が読めるのが一番効率良いですからね。 しかし、 Consoleウインドウに表示されているメッセージ要素 Debug.Log()で出力されたログ この2つを関連付ける方法がどうしても見つからず、断念することに… (UnityEditor.LogEntryあたりを色々触ってみましたがうまくいきませんでした…機会があればリベンジしたいです) 今回は、Consoleウインドウと連携させることは諦め、とにかく作業者がログの全文情報が取得しやすい環境を作ることを目指しました。 機能要件は以下としました。 プロジェクトフォルダ以下にログの全文を、1ログ出力1ファイルとして保存する(作業者がすぐに開くことができる場所に置く) ファイル名にはログ出力のタイムスタンプを含め、目的のログを探しやすくする そのままだとログのファイルが無尽蔵に増え続けてしまうため、最大保存件数を設け、超えた場合は古いログから削除していく ログの全文を取得するには ログの全文はDebug.Log()へリクエストされたログの文字列から取得することができます。 Debug.Log()によるログの発行はApplication.logMessageReceivedイベントに登録することでハンドリングすることができます。 Application-logMessageReceived - Unity スクリプトリファレンス 以下のように使用します。 [InitializeOnLoad] public static class LogReceiver { static LogReceiver() { // ログの発行イベントを登録。 Application.logMessageReceived += OnReceived; } static void OnReceived(string condition, string stackTrace, LogType type) { // condition … 発行されたログの全文。 // stackTrace … 発行されたログのスタックトレース。 // type … ログの種別。 } } 実装したもの 実装したコードはこちら。 using System; using System.IO; using System.Linq; using UnityEngine; using UnityEditor; /// <summary> /// Debug.Log()で出力されたログを、ログファイルとして書き出すクラス。 /// </summary> [InitializeOnLoad] public static class DebugLogWriter { // ログを保存するフォルダ名。 const string DirectoryName = "DebugLogs"; // 最大件数。 const int LogFileLimit = 100; static DebugLogWriter() { Application.logMessageReceived += OnReceived; } static void OnReceived(string condition, string stackTrace, LogType type) { if (!Directory.Exists(DirectoryName)) { Directory.CreateDirectory(DirectoryName); } // ファイル名にタイムスタンプを含めてログ内容を書き出す。 var now = DateTime.Now; var fileName = now.ToString("yyyy-MM-dd-HH-mm-ss-fffffff") + "_" + type.ToString() + ".txt"; File.WriteAllText(DirectoryName + "/" + fileName, condition + "\n\n" + stackTrace); // 最大件数以上を超えるようであれば古いログから削除する。 // ファイルのメタ情報でソートしたい場合はDirectoryInfo、FileInfoが便利。 var directoryInfo = new DirectoryInfo(DirectoryName); var fileInfos = directoryInfo.GetFiles("*.txt"); while (LogFileLimit < fileInfos.Length) { var oldestFileInfo = fileInfos.OrderBy(fileInfo => fileInfo.CreationTime).FirstOrDefault(); File.Delete(oldestFileInfo.FullName); fileInfos = directoryInfo.GetFiles("*.txt"); } } } 実行すると、以下のようにDebug.Log()の呼び出しごとにファイルが作成されていきます。 では、試しに<message truncated>となるログを出力してみます。 今回は以下のようなコードを用いてテストしてみました。 static void LogLongMessage() { var stringBuilder = new System.Text.StringBuilder(); for(int i = 0; i < 10000; ++i) { stringBuilder.AppendFormat("{0:D5},", i); } stringBuilder.AppendLine("End"); // ログの最後には「End」が出力される。 Debug.Log(stringBuilder.ToString()); } Consoleウインドウ上だと以下のように途中で<message truncated>にされて、ログの最後の「End」が確認できませんが、 書き出されたログのファイル上では全文が参照でき、最後の「End」まで確認できます。 まとめ 不具合の調査等でDebug.Log()の内容は重要なヒントになりますので、なるべく少ない手間で正確な情報が取得できるように環境を整備しておきたいですね。 (Unity公式でなにか機能を用意してほしい…) 明日は@FW14Bさんです!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CLIから使えるC#のフォーマッターdotnet-formatの紹介

この記事はPONOS Advent Calendar2021の5日目の記事です。 昨日は@FW14Bさんの「VSCodeのインテリセンスが機能しなかったときに確認すること」でした。 概要 dotnet-formatとは 何が良いのか インストール方法 使い方 設定 オプション dotnet-formatとは C#やF#, VBのコードをフォーマット(整形)してくれるツール(フォーマッター)です。 (私はC#以外で使ったことは無いですが) 設定にもよりますが、こんなコードを // 例: 整形前 using System.Linq; using System; namespace FormatSample { class Program { static void Main(string[] args) { Console.WriteLine("Hello World!"); foreach(var arg in args){ Console.WriteLine(arg); } } } } このように整形してくれます。 using System; using System.Linq; namespace FormatSample { class Program { static void Main(string[] args) { Console.WriteLine("Hello World!"); foreach (var arg in args) { Console.WriteLine(arg); } } } } 上記の場合変わったところは - usingを名前順で並び替え - インデントの修正 - foreachの後ろにスペースを追加 - {, }の後ろに改行を追加 です。 何が良いのか チーム開発などではコードが決まった形に整形されることで、書いた人間によるコードの書き方の違いが消えるためコードが読みやすくなります。 そのためコードレビューで細かい書き方に注意を払う必要がなくなりロジックなど必要な箇所を見ることだけに集中できます。 またフォーマッターで修正できる範囲であればコーディング規約に従っているかを気にしてコードを書く必要がないので、コードを書く側もコードの内容に集中できます。 個人開発でもフォーマットされたコードのほうが気持ち良いので気分が良くなります。(個人の感想です) インストール方法 dotnetコマンドがすでに使える状態であれば dotnet tool install -g dotnet-format でインストール可能です。 プロジェクトのローカルにインストールしたいのであれば dotnet tool install --local dotnet-format とします。 ただしプロジェクトのローカルにインストールする場合はマニフェストファイルが必要です。ない場合は先に dotnet new tool-manifest でマニフェストファイルを作成しておきましょう。 使い方 csprojが1つしか無いディレクトリであれば dotnet format というコマンドだけでフォーマットしてくれます。 slnやcsprojが複数という構成プロジェクトの場合は dotnet format [<PROJECT | SOLUTION>] というように引数にslnかcsprojを渡してworkspaceを指定してやる必要があります。 Unityのプロジェクトはまさにそういう構成なので dotnet format Assembly-CSharp.csproj というようなコマンドを叩く必要があります。 設定 .editorconfigにdotnet-formatの設定を書くことでコマンド実行時にその設定を読み込みフォーマットを行ってくれます。 .editorconfigファイルが何かわからない方はEditorConfigで調べてみてください。 どのような設定があるかはdotnet-formatの設定を確認してください。 dotnet_sort_system_directives_first = true としておくとusing Xxx.Yyyが名前順にかつSystem.Zzzが先頭くるようにソートされるのでおすすめです。 dotnet-formatの設定の例をほとんどそのままコピーしてくればいい感じにしてくれます。 オプション dotnet format [options] [<PROJECT | SOLUTION>] のようにworkspaceの前にオプションを指定することができます。 dotnet-formatコマンドの説明に有効なオプションが書いてあります。 よく使いそうなのは --include <ファイル or ディレクトリ> ファイルかディレクトリを指定してフォーマットの対象にします。スペース区切りで複数指定可能。 単一のファイルだけフォーマットしたいときなどに使えます。 --exclude <ファイル or ディレクトリ> ファイルかディレクトリを指定してフォーマットの対象から除外します。スペース区切りで複数指定可能。 プロジェクト内の自分が書いたものではないコード(Unityのパッケージとか)を除外するのに使えます。 --check フォーマットせずにフォーマットされているかだけを判断できます。 フォーマットされていないプルリクエストを拒否するなどで使えます。 あたりだと思います。 リンク先に--checkは書いてありませんがdotnet format --helpで出てきます。 注意 dotnet format --include Bar.cs Foo.csproj のように--includeオプションを使用した場合、最後の引数のworkspaceが--includeオプションの引数として扱われてしまいます。 (おそらく--excludeでも同じことがおきます) その場合は dotnet format --include Bar.cs -v d Foo.csproj のように別のオプションを挟むことで解決できます。 まとめ dotnet-formatはC#/F#/VBのコードをフォーマットしてくれるツール 使用することでコードが読みやすくなる コードの整形をフォーマッタに任せることでコードを書くことに集中できるようになる インストールはdotnet tool install -g dotnet-format or dotnet tool install -g dotnet-format 設定は.editorconfigに書く オプションはdotnet format [options] [<PROJECT | SOLUTION>]の形式で指定する 記事中のリンク dotnet-format dotnet-formatの設定 dotnet-formatコマンドの説明 明日は私(@block)です。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[WinForms] Languageの値ごとにコントールの大きさが定義できる

Language の値ごとに、コントールの大きさが定義できる。 例えば以下のように設定することが可能。 日本語ならSize = (100,100) 英語ならSize = (50, 200)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity】 RewiredでInputManagerをPrefabで使用する方法

0.アセットの紹介 Unity Asset Storeで販売されている「Rewired」です。 スマホ、PC、コントローラー(XBox360 , XBoxOne , PS4 , Nintendo Switch)の様々なコントローラーに対応できる最強アセットになります。 https://assetstore.unity.com/packages/tools/utilities/rewired-21676 1.セットアップ 基本的なセットアップは以下のサイトをご参照ください。 Rewiredという入力管理で最強のアセット https://gentome.com/gentomeblog/2287/userewired/ 2.疑問点 Rewiredは使用するときにInputManagerを生成する必要があります。 生成する方法を【in scene】と【prefab】の2つがあり、【prefab】での使用方法を調べました。 3.解決方法 入力を取るScene内でInitializerを生成 Window -> Rewired -> Create -> Initializer Hierarcyの【Rewired Initializer】Initializer(Script)がアタッチされているので、生成したPrefabをアタッチする 4. 入力を検知 以下のコードで入力を検知できます。 InputSample.cs using Rewired; public void Update() { var player = ReInput.players.GetPlayer(0); //action名の検知 if (player.GetButtonDown("Jump")) { Debug.Log("Jumpキーの入力を検知しました。"); } } 5. まとめ Rewiredは日本語での解説が非常に少なく英語の公式ドキュメントを翻訳しつつ読み解く必要があります。 英語は苦手ですが少しづつ日本語記事が増やしていきたいですね。がんばります!!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

C#でカノジョをつくる(2日目)

お疲れ様です。たなしょです。 今日から本格的にC#を勉強していきたいと思います。 C#のコードの全体像 基本的に一番初めにusing Systemが来ますね。 using System; これはCでいうところのインクルードファイルなのでしょうか? 今のところ特に記載もないのでおまじないということで覚えておきます。 次に名前空間を書くのが一般的らしいです。(本ではしばらく書いてませんが…) namesapce hogehoge 名前空間とはディレクトリみたいなものだそうです。詳しく進めばわかるようになるんですかね。 そのあとはclassを記載して中に変数やら式やらをいつも通り記載していく流れですね。 class Hoge { int a = 1; } 上記のことまとめるとこんな風に書くことができます。 using System; namespace Sample { class Render2D { public int A { get; set; } public int B { get; set; } public override string ToString() => $"({A}, {B})"; } class Program { static void Main(string[] args) { var p = new Render2D { A = 1, B = 2 }; Console.WriteLine(p); } } } こう見ると名前空間とクラスとメソッドがとても見やすい言語ですね! 脱線 Visual Studio 2022というIDEを開発では使っているのですがとても便利ですね。フォーマットも自動でただしてくれるし、予測変換はいい感じで効いてくれるのでとても楽に書けます。もうこれなしではC#の開発はできないです。(そういってvimmerなのでいつか環境がVimに移行してると思います) IDEのライバルとしてJetBrains社のRiderがあるのですがどちらのほうが使いやすいのですかね?個人開発するならVisual Studioで充分な気もしますが、エンタープライズになるとまた違った選択肢もあるのでしょうね。 変数、式、スコープなど ここに記載することでもないので割愛します。 基本的にどの言語もこの辺は同じだなーという感じで眺めてました。 整数型、浮動小数点数型、文字型、文字列型 「C#はCと違ってstring型があるから文字列が簡単に代入できていいね!」(最近のどの言語でも言えますが)ってところでしょうか。(最近のCにはString型が実装されたのですかね?ANSIしか触ったことがないのでわかりません。) あとはどの言語も同じ仕様なため割愛します。 ビット演算、関係演算、論理演算、キャスト ここも特にこれと言って目新しいものはなかったので割愛します。 ここら辺の分野を学習するなら本をちょっと読んで競技プログラミングの問題を解いたほうが理解が早まると思いました。さすがにこのカレンダーに競技プログラミングに参加した旨を書いたら、アドベントカレンダーの趣旨と食い違うため書きません。(たぶん) 最後に ざっとこんな感じで二日目は終了しました。 割と本が読めたので満足しています。 C#のソースはとてもきれいに書けるので見やすくて個人的には好きになりました。 明日も引き続きよろしくお願いします。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

似た顔のFANZA女優を顔写真から検索出来るようにした

この記事は 驚異のFANZA女優検索 Advent Calendar 2021 の 2 日目の記事です。 ?似た顔のFANZA女優検索 似た顔のFANZA女優を検索出来るページを作った。顔写真の画像はURLを入力するか、ローカルからファイルをアップロードすることで登録できる。登録した画像の顔が認識出来れば、似たFANZA女優の顔がスコアの高い順に表示されるという仕組みだ。対象となるFANZA女優は顔写真が提供されていてかつ顔認識に成功している人に限定されるので実質1万人ぐらいだ。作ったきっかけははてなブックマークコメント駆動? サンプル画像 入力してもらう手間を下げるためサンプル画像をいくつか置いておくのが親切かと思った。最初はFANZA女優を並べようかと思ったが、本人の写真では信頼度が100%になってしまうので面白くない。かといって作品のパッケージだと色々と表現的に不味いもののばかりだ。ぱくたそのモデルリリースを取得した顔写真を使おうと思ったが規約的にまずそう。凄く悩んだがFANZAの商品情報APIの画像なら使えることに気づいてアイドルの作品を並べてみた。結果的には面白いサンプルになったと思う。 登録しているFAZNZA女優は1人1枚のみ 顔認識には1人の顔写真を沢山登録して、ある顔写真が、その沢山登録した人と同一人物かをみる方法がある。しかし、今回似た顔検索で使ったのはその方法ではなく、単純に沢山の顔写真を登録してその中から自分が指定した顔写真と近い顔を信頼度順に並べるという方法だ。つまり、1人1枚の顔写真との比較なので、例え本人の別の顔写真を登録しても必ずしも本人が1番になるとは限らない(もちろん1番にヒットすることも多々ある)。本人であっても違う顔写真であれば信頼度は大体70%ぐらいかなという印象。では別人で70%以上ならそっくりかというとやっぱりばらつきがある。この辺は主観になってくるので、実際に遊んでもらえればと思う。 Azure Cognitive ServicesのFace API 顔認識にはAzure Cognitive ServicesのFace APIを使用しているのでこの使い方を書く。顔の登録にはFaceListとLargeFaceListがあるが、FaceListは「Add a face to a specified face list, up to 1,000 faces.」という記載あった。FANZA女優は5万人いるので、FaceListでは上限オーバーしてしまう。そのためLargeFaceListを使った(但し、LargeFaceListに「Free-tier subscription quota: 1,000 faces per large face list.」とあるので有料版でしか1000人以上は登録できないかも。未検証)。 Face APIで顔リスト作成 先ずはリストを作る。LargeFaceList - Create。これはリンクのHP上で作ってしまってもよい。nameは単に認識用なので日本語が分かりやすいが、自分はIdと同じにした。recognitionModelは省略可能だが、省略するとrecognition_01になる。当然だが新しい方が精度が高い。実際最初recognitionModelを指定せずに試したが、recognition_04にした方が顔認識度は高かった。 https://japaneast.api.cognitive.microsoft.com/face/v1.0/largefacelists/{largeFaceListId} { "name": "large-face-list-name", "recognitionModel": "recognition_04" } LargeFaceList - Add Faceを呼ぶと顔を登録出来る。こちらもdetectionModelは省略できるが、detection_03とした方が良い。実際recognitionModelとdetectionModelを組み合わせた検証はしていない。ただ言えることはrecognition_01×detection_01よりもrecognition_04×detection_03の方が精度が良かったことは間違いない。まあ特に理由がなければバージョンは最新に越したことはないと思う。 https://japaneast.api.cognitive.microsoft.com/face/v1.0/largefacelists/{largeFaceListId}/persistedfaces?detectionModel=detection_03 これで画像を登録するとレスポンスでpersistedFaceIdが返ってくる。実際に近い顔として返ってくるのはこのIdなので記録しておく必要がある。 public async Task<string> AddFaceAsync(string imageUrl) { var url = $"{AzureFaceApiUrl}largefacelists/fanza_actress_face_list_id/persistedfaces?detectionModel=detection_03"; var image = await GetImageContent(imageUrl); var personResponse = await HttpClient.PostAsync(url, image); var json = await personResponse.Content.ReadAsStringAsync(); var largeFaceListResponse = JsonSerializer.Deserialize<LargeFaceListResponse>(json); return string.IsNullOrEmpty(largeFaceListResponse.persistedFaceId) ? "" : largeFaceListResponse.persistedFaceId; } これで登録したい顔を全て登録したらLargeFaceList - Trainでトレーニングをすれば準備は完了だ。 Face APIでfindsimilars 先ずはユーザーが選択した画像をFace - Detectする。 https://japaneast.api.cognitive.microsoft.com/face/v1.0/detect?recognitionModel=recognition_04&detectionModel=detection_03 これで画像から顔を認識が出来れば、レスポンスが返ってくる。レスポンスからfaceIdを設定してFace - Find Similarを呼ぶ。ランク付けされた類似の顔を返したいならmodeはmatchFaceにする。 https://japaneast.api.cognitive.microsoft.com/face/v1.0/findsimilars { "faceId": "detectで返ってきたID", "largeFaceListId": "fanza_actress_face_list_id", "maxNumOfCandidatesReturned": 10, "mode": "matchFace" } これで先ほど登録した顔がmaxNumOfCandidatesReturnedの数分だけpersistedFaceIdとconfidenceが返ってくるので、confidenceでソートしてやれば晴れて、似た顔にリストが作れる。 つづく さて、顔写真から似た顔のFANZA女優を検索出来るようにしたのにAzure Cognitive ServicesのFace APIを使いこなすよりももっと苦労したことがある。それはまた明日。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Native DependenciesのサポートでBlazor WebAssemblyはどうなるか

この記事は Qiita Advent Calendar 2021 Blazor の2日目の記事です。 .NET 6.0 が無事GAされ数多くの新機能が追加されました(日本マイクロソフトさんのPR Advent Calendar で他の方が新機能だったり嬉しい機能について熱く語ってくれるはずなのでこちらもどうぞ)。 その中で Blazor に関して私が一番アツいと感じた変更が Blazor WebAssemblyのネイティブ依存サポート です。 どの様な仕組みで行われているのかだったり歴史みたいのは別の記事かコミケの記事で書こうかな… これまでのBlazor WebAssembly .NET 6.0 以前の Blazor WebAssembly ではネイティブ依存のあるライブラリを使用することが出来ませんでした(https://github.com/dotnet/aspnetcore/issues/8158 )。 例えば画像処理に多く使われているOpenCVみたいなライブラリがそれに該当します。 そのためデスクトップ上で動作するアプリケーションをそのままブラウザ上で動作させることはプロジェクトによっては困難なケースがありました。 その場合は C# や F# で同様の処理を行うことの出来るライブラリに移行する(OpenCVの代替としてImageSharpとか)みたいな処置が行われていたことでしょう。 これからのBlazor WebAssembly しかし .NET 6.0 時代の Blazor WebAssembly ではネイティブの依存があるライブラリでも、ブラウザ上で動作させることが可能となりました(条件あり)。 これによりデスクトップ上で動作していたアプリケーションの UI を Blazor や JS で記述し、ロジックをそのままブラウザ上で動作させるみたいなことが現実となりました。 UIに関しては 1日目の @kimuradesu さんの Blazorプロジェクトのテンプレートについて のサードパーティの Blazor テンプレートとかを参考にかっこいいのも使えそうです また React で UI を構築して、Blazor 側のロジックを使う例は 昨年の Advent Calendar 1日目 の記事をどうぞ。 またネタではありますが、Rust で作ったライブラリを Blazor WebAssembly 上で動かす、みたいな異色の融合みたいのも wasm を介することで可能となっています。 しかし現状は さて条件ありと書いたのはそのままの意味で、ネイティブ依存を持つライブラリ側が Blazor WebAssembly 上で動作するように対応している必要があるからです。 執筆時点で対応が確認されているメジャーなライブラリは Skia.Sharp ぐらいしか観測されず、他は私が対応させた日本語TTSの OpenJTalk ラッパー だったり、音声分析合成ライブラリの World ラッパー ぐらいだったりします。 GitHub上での検索 でも執筆時点で "NativeFileReference" の文字が含まれた XML は 33 件とまだまだ対応が進んでいない状況です。 単純にまだ GA されてから日が浅いのと、十分にライブラリのテストを行うのが難しいなど、ノウハウがまだ溜まっていないことが影響していそうです。 そのため自分が使いたいライブラリがある場合は自分で Blazor WebAssembly 対応を行う必要があります。 実際に自分が対応を行うために準備したことなどは こちらに記載しましたのでぜひともご覧ください。 基本的には Emscripten でビルドを通すことが出来れば Blazor WebAssembly 対応に大幅に近づきます。 またもう一例として OpenCVSharp の Blazor WebAssembly も現在行っています。 ライブラリのロード周りや、コールバックなど、サポートされていない機能を Managed 側で呼んでいるとクラッシュするなどのハマりどころも多かったりしますが、ぜひとも上記の記事やPRを参考に挑戦してみてください。 おわりに ネイティブ依存のサポートが行われるまで、行われてからの Blazor WebAssembly がどうであるか、どうなるかと、それを取り巻く環境について簡単に述べました。 実際に Blazor WebAssembly 対応に挑戦しようとなると、例えば C/C++ のプロジェクトのビルド方法だったり、P/Invoke 周りの知識だったりが必要でなかなかハードルが高いのは事実ではあります。 しかし多くの人が挑戦しその知見が共有されれば対応ライブラリも増えたりみたいな未来がやってくると思うので、ぜひ挑戦してみてください。 テスト周りが一番求められるところではあるので、その辺りをガンガン掘り進められるといいな…
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む