机上の空論

考えたことを気ままに書きます

UI Automationを使ってTeams会議のミュート切り替えするプログラムを書いて、キーボードのGキーに割り当てた話

読まなくてもよい背景

僕はロジクールのG613というキーボードを使っています。
(公式サイトへのリンクを張りたいのに、公式の商品ページが見つからない。Amazonの在庫も補充される気配もないので、多分既に生産終了してる。)

以外は良いキーボードです。

G613の圧倒的によいところは左端にあるGキーという、G HUBというロジクールのアプリで色々と機能を割り当てられるキーがあるところです。
(GキーはG915などにもあるので、G613独自の強みではない)

僕的に一番いい使い方はdiscordのミュート切り替えです。
ワンクリックでミュートにできるので、離席する時とかくしゃみが出そうな時とか、トイレで離席する時とかに重宝します。

で、僕はTeamsで通話することがあるのでTeamsのミュート切り替えも割り当てたかったのですが、G HUB上に公式でTeamsに関するキー割当の機能は提供されていません。
(どうやらTeamsがそういうAPIを提供していないらしい)

ただ、Gキーにはexeとかの実行も割り当てることができるので、ほな自分でTeams会議をミュート切り替えするプログラムを作ってやろうというモチベーションで作りました。

 

本編

当然動作やらなんやらは保証しません。ここからは君の目で確かめてくれ。

プログラムのざっくりしたフローは以下。

  1. EnumWindowsでTeams会議のウィンドウとミュートボタンを探す
  2. ミュート状態を切り替える。
  3. ミュートしたか、ミュート解除したかに応じた効果音を鳴らす。

以下がソースコードです。
(プロジェクト作るところは色々あったので端折ります。)

 

Program.cs

using System.Runtime.InteropServices;
using static TeamsMute.Logger;
using static TeamsMute.Program.NativeMethods;

namespace TeamsMute
{
    public class Program
    {
        internal static class NativeMethods
        {
            public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);

            [DllImport("user32.dll")]
            [return: MarshalAs(UnmanagedType.Bool)]
            public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);

            [DllImport("USER32.DLL")]
            public static extern IntPtr GetForegroundWindow();

            [DllImport("USER32.DLL")]
            [return: MarshalAs(UnmanagedType.Bool)]
            public static extern bool SetForegroundWindow(IntPtr hWnd);

            [DllImport("user32.dll")]
            public static extern bool FlashWindowEx(ref FLASHWINFO pwfi);

            [StructLayout(LayoutKind.Sequential)]
            public struct FLASHWINFO
            {
                public uint cbSize;
                public IntPtr hwnd;
                public uint dwFlags;
                public uint uCount;
                public uint dwTimeout;
            }
        }

        const uint FLASHW_STOP = 0;  // フラッシュを停止

        public static void StopFlashing(IntPtr hWnd)
        {
            FLASHWINFO info = new FLASHWINFO();
            info.cbSize = (uint)Marshal.SizeOf(info);
            info.hwnd = hWnd;
            info.dwFlags = FLASHW_STOP;
            info.uCount = 0;
            info.dwTimeout = 0;

            FlashWindowEx(ref info);
        }

        public static async Task Main(string[] args)
        {
            try
            {
                var activeWindowHandle = GetForegroundWindow();
                var teamsMeetingWindows = new List<TeamsMeetingWindow>();
                EnumWindows(new EnumWindowsProc*1;
                    }
                    catch (Exception ex)
                    {
                        Trace(ex.ToString());
                    }

                    return true;
                }), IntPtr.Zero);

                if (teamsMeetingWindows.Count == 0)
                {
                    return;
                }

                var targetTeamsMeetingWindow = teamsMeetingWindows.First();
                Task soundTask;
                if (targetTeamsMeetingWindow.IsMuted)
                {
                    targetTeamsMeetingWindow.InvokePattern?.Invoke();
                    soundTask = SoundPlayer.PlayUnmuteSoundAsync();
                }
                else
                {
                    targetTeamsMeetingWindow.InvokePattern?.Invoke();
                    soundTask = SoundPlayer.PlayMuteSoundAsync();
                }

                StopFlashing(targetTeamsMeetingWindow.HWnd);
                SetForegroundWindow(activeWindowHandle);
                await soundTask;
            }
            catch (Exception ex)
            {
                Fatal(ex.ToString());
            }
        }
    }
}

 

TeamsMeetingWindow.cs

using System.Text;
using System.Windows.Automation;
using Newtonsoft.Json;
using static TeamsMute.Logger;

namespace TeamsMute
{
    public class TeamsMeetingWindow
    {
        internal static class NativeMethods
        {
            [System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Auto)]
            public static extern int GetWindowTextLength(IntPtr hWnd);

            [System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Auto)]
            public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);

            [System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Auto)]
            public static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
        }

        public IntPtr HWnd { get; set; }

        public IntPtr LParam { get; set; }

        public  bool IsMuted { get; set; }

        public InvokePattern? InvokePattern { get; set; }

        public TeamsMeetingWindow(IntPtr hWnd, IntPtr lParam)
        {
            //ウィンドウのタイトルを取得する
            int textLen = NativeMethods.GetWindowTextLength(hWnd);
            StringBuilder tsb = new StringBuilder(textLen + 1);
            NativeMethods.GetWindowText(hWnd, tsb, tsb.Capacity);
            var title = tsb.ToString();

            //ウィンドウのクラス名を取得する
            StringBuilder csb = new StringBuilder(256);
            NativeMethods.GetClassName(hWnd, csb, csb.Capacity);
            var className = csb.ToString();

            // タイトルかクラス名が不一致なら例外を投げる
            Debug(JsonConvert.SerializeObject(new { Title = title, ClassName = className }));
            if (title.EndsWith(@" | Microsoft Teams") && className == @"TeamsWebView")
            {
                // UI Automation Elementを取得する
                AutomationElement mainFrame = AutomationElement.FromHandle(hWnd);
                Debug(JsonConvert.SerializeObject(mainFrame.Current));
                var buttons = mainFrame.FindAll(TreeScope.Descendants,
                    new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button))
                    .Cast<AutomationElement>();
                Debug(JsonConvert.SerializeObject(buttons.Select(button => button.Current)));

                // Teams会議が含むべきボタンによってこれを判断する
                if (buttons.Any(button => button.Current.Name.Contains("ミュート") && button.Current.AcceleratorKey == "Ctrl+Shift+M")
                    && buttons.Any(button => button.Current.Name.Contains("手を") && button.Current.AcceleratorKey == "Ctrl+Shift+K")
                    && buttons.Any(button => button.Current.Name.Contains("共有") && button.Current.AcceleratorKey == "Ctrl+Shift+E"))
                {
                    // ここまで来たらTeams会議ウィンドウと判断する
                    var muteButton = buttons.First(button => button.Current.Name.Contains("ミュート") && button.Current.AcceleratorKey == "Ctrl+Shift+M");
                    Info(JsonConvert.SerializeObject(muteButton.Current));
                    HWnd = hWnd;
                    LParam = lParam;
                    if (muteButton.Current.Name == "マイクのミュート")
                    {
                        IsMuted = false;

                    }
                    else if (muteButton.Current.Name == "マイクのミュートを解除")
                    {
                        IsMuted = true;
                    }
                    else
                    {
                        throw new ArgumentException("Cannot determine mute state from the mute button name.");
                    }

                    this.InvokePattern = muteButton.GetCurrentPattern(InvokePattern.Pattern) as InvokePattern;
                }
                else
                {
                    throw new ArgumentException("This window is not a Teams meeting window.");
                }
            }
            else
            {
                throw new ArgumentException("This window is not a Teams meeting window.");
            }

        }

        /// <summary>
        /// デフォルトコンストラクタは非公開
        /// </summary>
        private TeamsMeetingWindow() { }
    }
}

 

SoundPlayer.cs

using WMPLib;

namespace TeamsMute
{
    public static class SoundPlayer
    {
        private const string MuteSoundFilePath = "C:\\Path\\To\\MuteSE.mp3";

        private const string UnmuteSoundFilePath = "C:\\Path\To\\UnmuteSE.mp3";

        public static async Task PlayMuteSoundAsync()
        {
            WindowsMediaPlayer windowsMediaPlayer = new WindowsMediaPlayer();
            windowsMediaPlayer.URL = MuteSoundFilePath;
            windowsMediaPlayer.controls.play();
            await Task.Delay(TimeSpan.FromMilliseconds(500));
        }

        public static async Task PlayUnmuteSoundAsync()
        {
            WindowsMediaPlayer windowsMediaPlayer = new WindowsMediaPlayer();
            windowsMediaPlayer.URL = UnmuteSoundFilePath;
            windowsMediaPlayer.controls.play();
            await Task.Delay(TimeSpan.FromMilliseconds(500));
        }
    }
}

 

Logger.cs

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Text;

namespace TeamsMute
{
    public static class Logger
    {
        /// <summary>
        /// 出力先ファイルパス
        /// </summary>
        private const string logFilePath = "C:\\Path\\To\\LogFile\\TeamsMute.log";

        /// <summary>
        /// ログレベル
        /// </summary>
        public enum LogLevel
        {
            TRACE,
            DEBUG,
            INFO,
            WARN,
            ERROR,
            FATAL
        }

        /// <summary>
        /// ファイルに出力するログのカットオフレベル
        /// </summary>
        public static LogLevel CutoffLogLevvel = LogLevel.ERROR;

        private static ReadOnlyDictionary<LogLevel, string> LogLevelStrings { get; } = new ReadOnlyDictionary<LogLevel, string>(
            new Dictionary<LogLevel, string>
            {
                { LogLevel.TRACE, "TRACE" },
                { LogLevel.DEBUG, "DEBUG" },
                { LogLevel.INFO,  "INFO"  },
                { LogLevel.WARN,  "WARN"  },
                { LogLevel.ERROR, "ERROR" },
                { LogLevel.FATAL, "FATAL" }
            }
        );

        private static StreamWriter StreamWriter { get; } = new StreamWriter(logFilePath, true, Encoding.UTF8);

        public static void WriteLog(LogLevel logLevel, string message)
        {
            // カットオフレベル以下のログは出力しない
            if (logLevel < CutoffLogLevvel)
            {
                return;
            }

            string logMessage = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}, {LogLevelStrings[logLevel]}, {message}";
            StreamWriter.WriteLine(logMessage);
            StreamWriter.Flush();
        }

        public static void Trace(string message)
        {
            WriteLog(LogLevel.TRACE, message);
        }

        public static void Debug(string message)
        {
            WriteLog(LogLevel.DEBUG, message);
        }

        public static void Info(string message)
        {
            WriteLog(LogLevel.INFO, message);
        }

        public static void Warn(string message)
        {
            WriteLog(LogLevel.WARN, message);
        }

        public static void Error(string message)
        {
            WriteLog(LogLevel.ERROR, message);
        }

        public static void Fatal(string message)
        {
            WriteLog(LogLevel.FATAL, message);
        }
    }
}

 

GitHub copilotとネット上の記事を参考にしつつ、試行錯誤しながら書いたものです。
コード上のファイルパスとかはいい感じにしてください。

僕は.NET 10で作ったんですが、この環境でUI Automationを使うには色々と注意点があって、

  1. 参照マネージャーからUIAutomationClientとUIAutomationCoreを追加する
  2. csprojにUseWPF Trueを追加する

が必要っぽいです。

SoundPlayer.csではWindowsMediaPlayerを使用しているので、これも参照マネージャーから参照を追加してください。

個人的な好みでログにはjson形式で出しているので、NewtonSoft.jsonを使っているので、nupkgマネージャーで参照を追加してください。

ミュート時、ミュート解除時のSEは、僕は効果音ラボで拾ってきてミュートはボタン・システム音[1]のキャンセル4、ミュート解除は決定ボタンを押す42にしました。

 

TeamsMeetingWindow.csの動作は、当然のようにTeamsの仕様に依存しているので、Teamsのバージョンが上がってボタンが増えたり減ったりすると動作しなくなる可能性があります。その時は僕がこれを書いた時同様に、UIElementをJsonconvertしてログに吐いて、目当てのボタンに合わせてコードを修正してください。

 

 

追記

はてなブログソースコード書くときってどうやるのがいいんだろ。

今回はVSCodeからコピペしてみたが。

*1:hWnd, iParam) =>

                {
                    try
                    {
                        teamsMeetingWindows.Add(new TeamsMeetingWindow(hWnd, iParam

クラウドセーブがないゲームのセーブデータの話

大体のアプリケーションは以下のパスのどこかにセーブデータを保存しています。

C:\\Users\(ユーザ名)\AppData

んでこれはゲームにおいてもそうなので、SteamなりEpicなりFANZAなりDLSiteなりで買ったゲームにクラウドセーブ機能がなかった場合、ここらへんのそれっぽいフォルダをOneDriveなりDropBoxなりのクラウドストレージサービスに手動でコピーすれば、別デバイスでも同じセーブデータでゲームができる。

 

僕の場合、普段はデスクトップでゲームしてるけど、実家帰るときとかはノーパソを持って帰るので、そのときにもFANZAで買ったサクラノ刻のセーブデータを引き継げて便利。

 

もちろん、ご使用は自己責任で。

beatmania INFINITASをインストールしたドライブの名前を変更したらアンインストールもできなくなった話

色々あってWindowsクリーンインストールして、DドライブなしでEドライブにSSDがあって、そこにinfinitasをインストールした。

そのあと、DがなくてEなのがキモいので、Dドライブに割り当て直したら、Windowsの標準のアンインストールが通らなくなった。

仕方ないのでDドライブのソフト本体を削除して、プログラムのインストールまたは削除がブロックされる問題を解決する - Microsoft サポートをつかってWindows上のエントリを削除した。

その後でインストーラを動かすと、Eドライブがないよっていってインストールもできなくて詰んだ。

どうせレジストリエディタにどこにインストールしてあるよみたいな情報が残ってるんだろうなと思ってレジストリエディタ開いたら、案の定正しくなエントリが下記のパスにあった。

/HKEY_LOCAL_MACHINE/SOFTWARE/KONAMI/*

KONAMI以下のパスを削除したらインストーラが普通に動くようになったので、新Dドライブにインストールし直したら普通に動くようになった。

めでたしめでたし。

ワールズエンドクラブ【World’s End Club】をクリアしたので感想

点数としては60点。

ダンガンロンパの人が関わってるって聞いて、ストーリー面を期待して買ったけど、期待に応えるってほどではなかったかな。

クリア時間は8時間半くらいでした。

 

以下ネタバレ含みます。

 

まず、良かったところはキャラですね。

全員キャラが立ってたし、バニラちゃんとニョロは可愛いしアドだった。

声優も良かったので、バニラちゃんが喋るたびにアドを感じることができました。

 

逆に悪かったところはアクションパート。

なんか全体的にもっさり感がある。

スマブラで言えば横スマしか使えないみたいな感じ。

ジャンプとかダッシュの感じはカズヤが近いかな。

とにかくどのモーションも入力に対するレスポンスが遅い。

 

で、死ぬわけだけど、死んだ時の演出がトロい。

ライフ1しかないからそれなりに死ぬんだけど、その度にトロめの演出が入ってウザったい。

 

諸説ありそうなのは、全体的な難易度とストーリー。

敵の攻撃やら謎解きやらは正直そこまで難しくない。

難易度としては3Dマリオくらいかな。

なんで、対象年齢としては小学生くらいなんかなってなる。

大人としては物足りない感じ。

 

ストーリーは、途中までは割と良かった。

世界の謎とか主人公の正体とか、そこら辺の見せ方は結構良かったと思う。

ただ、最終的なオチの部分はありがちだなーって感じだった。

ブレイブリーデフォルトとか、moonとかそんな感じの、使い古されたメタい感じのオチ。

まあ逆に考えれば、小学生はそういうゲーム触れてる可能性低いだろうし、新鮮に感じられるのかもしれない。

 

こんなところかなー。

イラストとか演出とかBGMとかの記述のないところは普通で、あえて述べるほどのことはない。

 

とにかく総評としては60点っていう評価が妥当だと思う。

単位はあげるけど別に良いってほどでもない。

オススメってほどでもないんで、これよりいいゲームは山ほどあるので積極的にやるほどのもんじゃないですね。

著作権について

思考実験をします。

色の種類が有限で、画素数も有限であるので、十分大きなストレージを用意することで、フルHD程度の絵であれば全種類を網羅することができますね。

コードを書いてこれらを全種類作って、適当にブログかなんかに全ファイルを置いて公開しておきます。

Googleドライブとかに突っ込んでURLを公開するんでもいいです。

ところで、著作権というのは申請不要で、作成した時点で発生する権利だそうです。

よって、世の中に新たな作品が生み出された際に「私がブログで公開していたこの絵と似ている!著作権侵害だ!」という主張ができるわけです。

意味不明ですね。

ですが、おそらく実際は独創性がないだとか言われて棄却される気もします。

結果的に生まれたjgpなりPNGなりはバイナリ的に全く同じであってもです。

意味不明ですね。

人が生きる理由について

突然ですが、人が生きることに理由なんてないっていう話をします。

これと似たような話を聞いたことがないので、みなさんが内心で当たり前だと思っているのか、それとも思いついてないのかはわかりませんが、僕としては納得感があって面白い話だと思ってます。

もちろん、人の生きる理由なんて勝手に見つければいいと思うので、幸せになるために生きてるんだ!っていうのはそれはそれで良いと思います。

ただ僕はこんな話を思いついちゃったって感じです。

 

それでは本題。

かつて地球上に生き物はいなかったそうです。

そこに雷がいい感じに落ちていい感じにRNAができたそうです。

RNAは自己複製をする機能、というよりは性質を持っていて、どんどん自己複製をしたり進化したりするうちに今みたいな生き物ができていったらしいです。

ここで、自己複製をする機能を持っているものが、自己複製をする機能を持っていないものと比べて繁栄するのはとうぜんですよね。

そして、子孫の繁栄だの何だのって言うのは、自己複製という性質を持っているものがに結果的に起こるものです。

他の種族やら人種やらよりも優れている(っていう言い方も若干恣意的な感じがあって良くないかもしれません)ものが繁栄するというのも、結果的に起こることです。

生物自身に子孫の繁栄なんていう義務はなくて、自己複製という機能を持っているものが結果として繁栄するっていうだけです。

なので、LGBTだのなんだのの人に、生物としての本質からずれているっていうのは、ちょっとおかしいというか、押し付けなんじゃないのって言う感じがします。

 

その性質から生じるものを押し付けるのってなんか変な気がしませんかね。

あんまいい例えが思いつかないんですけど、女性に対して「女性とは胸が大きくなる性質を持っているのだから、全女性は巨乳にならなくてはならない」みたいな?

男性に対して「男性は筋肉質になるような性質を持っているのだから、全男性は筋肉もりもりのマッチョマンにならなければならない」みたいな?

そんなん人の勝手じゃんで終わり。

 

そーいうわけで、僕が言いたいのは、人が生きてることに、外の世界から意味は与えてくれないってことです。

RNAに生きる理由はないよね。

自分で勝手に「幸せになるために生きる!」っていうのは勝手ですけどね。

 

ちょっと話は変わるんだけど、人の意識なんてものは、機械と何ら変わらないんじゃないのっていうのも思ってます。

ここで言う機械っていうのは、パソコンみたいな複雑な電子機器ってイメージじゃなくて、もっと素朴な風車とか自転車とかとかそういうレベルのもののイメージです。

我々は、そういう機械を見た時、こういう仕組みに作ってるんだからこういう機能を持つのは当然だと思うわけです。

でも、その仕組みをしらない、あるいは理解できない人とか赤ちゃんからみると、とても複雑で理解の及ばない知性のようなものを感じるかもしれません。

風が吹いたときに回り、風が吹かないときには回らないというだけのものでさえも。

我々の意識というのもそういうものなんじゃないですかね。

他人を見たときに、複雑な思考やら意思決定やらをしているように見えても、本質は風車と同じ感じかもしれません。

「いや、私は今世界を認識していて、思考をしている!我考えるゆえに我あり!」っていう人もいるかも知れませんが、風車とか自転車もそう考えてるかもしれませんよね?

なんで人間やら猿やら犬やらには意識があって、風車や自転車には意識がないって言えるんですか?

風車や自転車は知性を感じられるような行動やらふるまいやらが見られないじゃないかっていうのは当然通りません。

それだったら脳死状態の人間はさっさとジャンクとして解体してパーツ取ったほうがいいです。

 

結局意識なんてそうだと思ってる・感じているっていうだけで、実在を実証できません。

なんか言いたいことからずれてる気もしますが、私は意識は、機構が理解できてないっていうだけの機械に過ぎないって考えてます。

 

 

なんかこういう哲学っぽいことを考えるの好きなんですよね。

僕はそういうの考えても、何にもメモらなくて忘れるのを無限に繰り返してます。

でもこういうのも、人類でちゃんと積み上げてったらもっと面白い考えにたどり着ける気がするんで、そのためにもたまには文字に起こそうかなと思います。

 

一応の補足ですが、別に生きる理由が外から与えられなくて、意識が機械と変わらんっていうのは、だから人間死んでもいいとはならないと思うんで、そこだけ補足しておきます。

物理的に意味がなくても、僕は死にたくないし、楽しく幸せに過ごしたいとは思っているので。

それでは。

個人的ミュウツー技評価

技評価でAとかBとかで書かれているものがよくあるけど、評価基準がようわからんので、ぼくは数字で振ります。偏差値的な気分で数字をつけますが、厳密性はなく、あくまで気分なのでご了承ください。

 

弱 58

下強 56

横強 51

上強 47

DA 45

空前 58

空下 52

空後 40

空上 44

上B 54

横B 44

下B 42

下スマ 53

横スマ 50

上スマ 46

掴み 46

ダッシュ掴み 52

前投げ 48

下投げ 42

後ろ投げ 58

上投げ 58

 

以上です。異論は認めます。