Vuzix Z100 で PowerPoint と連動するプロンプターをつくる

この記事では、PowerPoint のプレゼンに合わせてノート部分に記載した文字をスマートグラス(Vuzix Z100)にプロンプターのように表示するアプリケーションの作成方法を説明します。

![PowerPointのノート部分をスマートグラスに表示する説明図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43450/1652df6e-aac8-8f35-8db8-8932f760e157.png)


## Vuzix Z100 について
[Vuzix Z100](https://www.value-press.com/pressrelease/347407) は以下のような特徴を持つスマートグラスです。見た目が普通のメガネにかなり近いのが良いですね。

- 40g程度と軽量でバッテリー、プロセッサ、プロジェクター、タップ検知センサを内蔵する
- 右目のみのモノクロ表示(緑のみ, 640x480px)
- バッテリーが2日程度持つ
- Android/iPhoneからBLE経由でZ100に文字情報・画像情報・スクロール情報等を転送して表示する方式
- Android/iOS 向けのSDKが提供されている
    - [Ultralite SDK for Android](https://github.com/Vuzix/ultralite-sdk-android)
    - [Ultralite SDK for iOS](https://github.com/Vuzix/UltraliteSDK-releases-iOS)

![外観](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43450/dfde94f3-eb54-0d59-395b-77b277ae64f1.jpeg)
![表示部](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43450/c7b08180-b1d0-441e-46fc-6e96c128e1b8.jpeg)

## 標準アプリの仕様
スマートフォン向けにVuzix Connectというアプリケーションが提供されており、デモとして`テレプロンプター`というアプリケーションも用意されています。表示するテキストは編集することができるので、単にプロンプターとして利用するだけの場合には、新たにアプリケーションを開発する必要はありません。

![標準アプリ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43450/2f5c761b-da74-f3af-2e5b-7193d0aa2410.png)


## アプリケーションの開発
今回は、以下の2つのアプリケーションを作成しました。

1. HTTPで文字列を受け取り Vuzix Z100 にその文字列を表示させる機能を持つ Android アプリ
2. PowerPointでページをめくるとそのタイミングでAndroid アプリにPowerPointのノート部分に記載されている文字列をHTTPで転送する PowerPointプラグイン

### 1. Android アプリケーション
[Ultralite SDK Sample for Android](https://github.com/Vuzix/ultralite-sdk-android-sample)には、Vuzix Z100 で提供されている機能に関するサンプルコードを提供されています。このコードをベースに修正を加えました。`MainActivity.java` の `startDemoThread()` には、各デモコードを順次実行するようなコードが記載されていますが、このうち、`DemoScrollLiveText.runDemo()` を使いたいので、その行以外をコメントアウトしました。

```MainActivity.java 
        private void startDemoThread() {
            new Thread(() -> {
                // Always be sure we have control before any drawing starts
                if(haveControlOfGlasses) {
                    running.postValue(true);
                    try {
                        //DemoCanvasLayout.runDemo(getApplication(), this, ultralite);
                        //DemoScrollAutoScroller.runDemo(getApplication(), this, ultralite);
                        DemoScrollLiveText.runDemo(getApplication(), this, ultralite);
                        //DemoScrollNative.runDemo(getApplication(), this, ultralite);
                        //DemoTapInput.runDemo(getApplication(), this, ultralite);

                        // Always release control when finished drawing to the glasses
                        ultralite.releaseControl();
                        ultralite.sendNotification("Demo Success", "The demo is over");
                                 :
                                 :

```

また、Android端末のIPアドレスが画面に表示されると便利なので、同じく `MainActivity.java` に以下のコードを追加しました。

```MainActivity.java
    // 自分自身のIPアドレスを取得する
    private String getIPAddress(boolean useIPv4) {
        try {
            List<NetworkInterface> interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
            for (NetworkInterface iface : interfaces) {
                if(iface.getName().equalsIgnoreCase("wlan0")){ // WiFiのIPアドレスのみを利用
                    List<InetAddress> addresses = Collections.list(iface.getInetAddresses());
                    for (InetAddress addr : addresses) {
                        if (!addr.isLoopbackAddress()) {
                            String sAddr = addr.getHostAddress();
                            boolean isIPv4 = addr instanceof Inet4Address;
                            if (useIPv4) {
                                if (isIPv4) return sAddr;
                            } else {
                                if (!isIPv4) {
                                    int delim = sAddr.indexOf('%'); // drop ip6 port suffix
                                    return delim < 0 ? sAddr.toUpperCase() : sAddr.substring(0, delim).toUpperCase();
                                }
                            }
                        }
                    }
                }
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return "none";
    }

         :
         :

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main_activity);
         :
         :
        ultralite.getControlledByMe().observe(this, controlled -> {
            // Always watch to see if you have lost control to another application. Our ViewModel
            // observes this in controlledObserver, so this observer is just for the sake of
            // our UI.
            controlledImageView.setImageResource(controlled ? R.drawable.ic_check_24 : R.drawable.ic_close_24);
            String ipAddress = " {"+getIPAddress(true)+"}";
            String info = ultralite.getName() + ipAddress;
            nameTextView.setText(info);
        });

         :
         :

        // Now set the click listeners to kick-off the two demos
        demoButton.setOnClickListener(v -> model.runDemo());
        notificationButton.setOnClickListener(v -> sendSampleNotification() );
    }
```

また、`AndroidManifest.xml` に `android.permission.Internet` と `android.permission.ACCESS_NETWORK_STATE` 権限を追加しておきます。

```AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.UltraliteSDKSample">

        <activity android:name=".MainActivity" android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>
</manifest>
```

HTTPの待ち受けには NanoHTTPD を利用しました。まず、`settings.gradle.kts (Module app)`の`dependencies` に `nanohttpd` を追加しておきます。

```settings.gradle.kts
dependencies {
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.11.0")
    implementation("com.vuzix:ultralite-sdk-android:1.7")
    implementation("org.nanohttpd:nanohttpd:2.3.1")
}
```

次に、`DemoScrollLiveText.java` のほうも修正していきます。`runDemo()` 内で待ち受けのクラスを定義しています。

```DemoScrollLiveText.java
package com.vuzix.ultralite.sample;

import android.content.Context;

import com.vuzix.ultralite.Layout;
import com.vuzix.ultralite.UltraliteSDK;
import com.vuzix.ultralite.utils.scroll.LiveText;

import java.io.IOException;
import java.util.Map;

import fi.iki.elonen.NanoHTTPD;

public class DemoScrollLiveText {
    private static void chunkStringsToEngine(MainActivity.DemoActivityViewModel demoActivityViewModel, LiveText liveTextSender, int intervalMs, String[] fullStrings) throws MainActivity.Stop {
        String fullTextToSend = "";
        for (String eachLine : fullStrings) {
            fullTextToSend += eachLine;
            liveTextSender.sendText(fullTextToSend);
            
            demoActivityViewModel.pause(intervalMs);
        }
    }

    public static void runDemo(Context context, MainActivity.DemoActivityViewModel demoActivityViewModel, UltraliteSDK ultralite) throws MainActivity.Stop  {
        // MyWebServer クラスの定義
        class MyWebServer extends NanoHTTPD {
            LiveText liveText;
            public MyWebServer(int port, LiveText liveTextSender) {
                super(port);
                liveText = liveTextSender;
            }

            @Override
            public Response serve(IHTTPSession session) {
                Map<String, String> parms = session.getParms();
                String text = parms.getOrDefault("text", "?text=文字列 で指定してください");

                try{
                    if(!text.startsWith("?")){
                        int length = 20;
                        int inputLength = text.length(); // 分割後の配列のサイズを計算
                        int arraySize = (int) Math.ceil((double) inputLength / length);
                        String[] parts = new String[arraySize]; // 文字列を指定の長さごとに分割して配列に格納
                        for (int i = 0; i < arraySize; i++) {
                            int start = i * length;
                            int end = Math.min(start + length, inputLength);
                            parts[i] = text.substring(start, end);
                        }
                        chunkStringsToEngine(demoActivityViewModel, liveText, 1900, parts);
                    }

                }catch (Exception e){
                    e.printStackTrace();
                }
                return newFixedLengthResponse(text);
            }
        }
        MyWebServer myWebServer;
        final int sliceHeightInPixels = 48;    // The lines will be 48 pixels high, so each line is 1/10th the screen height. This affects the
                                               // ranges for all other values below since this configuration now has a maximum of 10 lines.
        final int sliceWidthInPixels = UltraliteSDK.Canvas.WIDTH; // Use the full width
        final int startingScreenLocation = 1;  // The lines will appear at line 0, the lowest point on the screen.  (Since above we
                                               // configured a total of 10 lines on the screen, this can be (0-9) and we're choosing 1.
        final int numberLinesShowing = 5;      // Number of full lines when the text pauses. A fourth line shows during the transition.
                                               // (Since each line is set to be 48 pixels high above, we can have a max of 10 lines on
                                               // the screen, 1 up from the bottom, we can choose between 1 and 9, and we choose 3).

        ultralite.setLayout(Layout.SCROLL, 0, true, true, 0);
        LiveText liveTextSender = new LiveText(ultralite, sliceHeightInPixels, sliceWidthInPixels, startingScreenLocation, numberLinesShowing, null);
        try {
            myWebServer = new MyWebServer(8080,liveTextSender); // ポート番号を指定
            myWebServer.start();
        } catch (IOException e) {
            e.printStackTrace();
        }


        // Often the LiveText is used with a speech recognition engine that gives us results. We will
        // simulate that by sending some arrays.
        while(true){
            demoActivityViewModel.pause(1000);
        }

    }
}

```

これで、httpで文字列をAndroid端末に送るとVuzix Z100に文字が表示されるようになりました。
Android端末のIPアドレスはアプリを起動すると表示されます。

![起動画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43450/2c2244ed-e759-3d2e-06ca-5316aec48176.png)


試しに、PC上のブラウザから下記のようなURLを入力して、スマートグラス上に `こんにちは` と表示されることを確認してください。

```text
http://(Android端末のIPアドレス):8080/?text=こんにちは
```

![実機上での表示](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43450/ecc4ce7e-903d-a5d0-06d2-3203a8f301cb.jpeg)


### 2. PowerPoint プラグイン

Visual Studio 2022 でPowerPointプラグインを作成していきます。まず Visual Studio Installer を起動し、`Office/SharePoint 開発`にチェックを入れてOfficeの開発環境をインストールしてください。

![Visual Studio Installer](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43450/ca0036f9-768a-eb0a-4822-d3aac515db99.png)

次に、Visual Studio 2022 を起動し、新しいプロジェクトの作成 をクリック、`PowerPoint VSTO アドイン` をクリックして新規作成します。

![テンプレートの選択](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43450/64256d68-27d7-fee2-7ea1-26758250bcdd.png)

PowerPointでスライドをめくったときに処理を行うため、`ThisAddin.cs`のコード中で `_application.SlideShowNextSlide` イベントに必要な処理を追加します。今回は、Android端末のIPアドレスを 環境変数 `Z100_IP_ADDRESS` から読み取るようにしました。別途、この環境変数に以下のような値を設定しておいてください。

```text
(Android端末のIPアドレス):8080
```

```ThisAddin.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Xml.Linq;
using PowerPoint = Microsoft.Office.Interop.PowerPoint;
using Office = Microsoft.Office.Core;
using Microsoft.Office.Interop.PowerPoint;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace PrompterAddin
{
    public partial class ThisAddIn
    {
        private Microsoft.Office.Interop.PowerPoint.Application _application;
        private string _ipAddress;

        private void ThisAddIn_Startup(object sender, System.EventArgs e)
        {
            _ipAddress = GetIpAddressFromEnvironment();
            if (string.IsNullOrEmpty(_ipAddress))
            {
                MessageBox.Show("スマートグラスのIPアドレスが環境変数 Z100_IP_ADDRESS から設定されていません。");
                return;
            }
            _application = this.Application;
            _application.SlideShowNextSlide += new EApplication_SlideShowNextSlideEventHandler(Application_SlideShowNextSlide);

        }
        private string GetIpAddressFromEnvironment()
        {
            return Environment.GetEnvironmentVariable("Z100_IP_ADDRESS");
        }
        private void ThisAddIn_Shutdown(object sender, System.EventArgs e)
        {
        }
        private void Application_SlideShowNextSlide(SlideShowWindow window)
        {
            Slide slide = window.View.Slide;
            string notes = slide.NotesPage.Shapes.Placeholders[2].TextFrame.TextRange.Text;
            SendHttpRequest(notes);
        }
        private async void SendHttpRequest(string notes)
        {
            if (String.IsNullOrEmpty(_ipAddress))
            {
                return;
            }
            string url = "http://"+ _ipAddress+"/?text=" + Uri.EscapeDataString(notes);

            using (HttpClient client = new HttpClient())
            {
                try
                {
                    HttpResponseMessage response = await client.GetAsync(url);
                    response.EnsureSuccessStatusCode();
                    string responseBody = await response.Content.ReadAsStringAsync();
                    // レスポンスの処理が必要な場合はここで行います
                }
                catch (Exception ex)
                {
                    // エラーハンドリング
                    Console.WriteLine(ex.Message);
                }
            }
        }


        #region VSTO で生成されたコード

        /// <summary>
        /// デザイナーのサポートに必要なメソッドです。
        /// このメソッドの内容をコード エディターで変更しないでください。
        /// </summary>
        private void InternalStartup()
        {
            this.Startup += new System.EventHandler(ThisAddIn_Startup);
            this.Shutdown += new System.EventHandler(ThisAddIn_Shutdown);
        }
        
        #endregion
    }
}

```

これで、開始をクリックすると PowerPointが起動します。PowerPointファイルを新規作成し、ノート部分に文字列を記載してプレゼンを開始してください。正常に設定されていればスライドをめくるごとにノート部分の文字が表示されます。

プラグインを配布したい場合には、ソリューションエクスプローラーでプロジェクトを選択して右クリック > 発行 をクリックして、公開ウィザードで適切な公開場所を設定して 次へ を選択、CD-ROM まはた DVD-ROM からを選択して 次へ > 完了 を順にクリックします。

![公開ウィザード](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43450/0531bc40-4d38-c0dd-3f3a-0c890be40348.png)


こうすると、setup.exe, アドイン名.vsto, Application Files フォルダの 3つが作成されるのでこれを配布してください。
プラグインをアンインストールしたい場合には、`プログラムの追加と削除`または 設定 > アプリ > インストールされたアプリ に作成したアドイン名(例えば PrompterAddin)がありますのでそれを削除してください。

## 制限事項など

- ページを早くめくると前の文字列が表示終えるまで表示がおかしくなります。
- Android端末とPCは同一のWiFiネットワーク上に存在する必要があります。モバイル回線には対応していません。
- Android端末とVuzix Z100の距離はあまり離さないでください。3～5mくらい離すとうまく通信できないことがあります。
- スリープからの復帰処理を記述していないため、動作がおかしくなる場合があります。その場合は、Androidアプリを再起動してください。

## 最後に
最小限のコード修正でプロンプターとして機能するアプリケーションを作成しました。ぜひ皆さんもいろいろ試してみてください。