NestJS+Svelteを使って簡単なSSRを試してみた

はじめに

エンジニアの松原です。普段の業務ではバックエンド開発業務が多く、たまにはフロントエンドのこともしたいと思いつつ、新しいWebフレームワークにも触れたいと考えたため、 バックエンドとしてRails風にMVCベースの設計を持っているNestJS(厳密にいうと、NestJSはViewはデフォルトでは持っていませんが)、描画エンジンとしてフロントエンドのWebフレームワークであるSvelteを組み合わせて Hello World! をやってみました。

尚、今回はとっかかりとしてのコードしか書いておらず、ちゃんとSSRを行うにはSvelteをTypeScript対応にしたり、Webpackで依存するパッケージをバンドルするなどの手続きが必要になります。 以降フォローできればと思います。

また、今回はPCに node (v16以降、と npm ) が入っていることを前提に記事を書いています。

NestJSのセットアップ

NestJSのCLIが必要なので、npmからインストールしておきます

npm install -g nest

NestJSのプロジェクト作成を作成する

CLIから新規のプロジェクトを作成します

nest new example-my-project

その際、パッケージマネージャーを何にするか聞いてくることがありますので、選択します(矢印キーで操作して選びます) 今回はyarnを使用したいので、yarnを選んでいます

⚡  We will scaffold your app in a few seconds..

CREATE example-my-project/.eslintrc.js (665 bytes)
CREATE example-my-project/.prettierrc (51 bytes)
CREATE example-my-project/nest-cli.json (118 bytes)
CREATE example-my-project/package.json (2002 bytes)
CREATE example-my-project/README.md (3340 bytes)
CREATE example-my-project/tsconfig.build.json (97 bytes)
CREATE example-my-project/tsconfig.json (546 bytes)
CREATE example-my-project/src/app.controller.spec.ts (617 bytes)
CREATE example-my-project/src/app.controller.ts (274 bytes)
CREATE example-my-project/src/app.module.ts (249 bytes)
CREATE example-my-project/src/app.service.ts (142 bytes)
CREATE example-my-project/src/main.ts (208 bytes)
CREATE example-my-project/test/app.e2e-spec.ts (630 bytes)
CREATE example-my-project/test/jest-e2e.json (183 bytes)

? Which package manager would you ❤️  to use? (Use arrow keys)
> 

プロジェクトディレクトリに移動します。

cd example-my-project

READMEが自動生成されていますので、package.jsonと合わせて、自分の情報に書き換えます。

Svelteのパッケージをインストールする

サーバーサイドでhtmlレンダリングを行うためにSvelteの設定を追加していきます。 まずは必要パッケージをインストールします。それぞれ svelte はSvelte本体、 svelte-check はSvelteのテンプレートの記述を診断するCLIツールになります。

yarn install -D svelte svelte-check

SvelteをViewEngineとして設定する

NestJsのControllerからhtmlのレンダリング処理を自分でコントロールしたい場合、描画処理の手続きを記述する必要があります。 今回はSvelteのテンプレートを使うため、Svelteのモジュールに処理を実行させるコードを書きます。 src/ 配下に svelte-view-engine.ts というファイル名を追加し、以下のコードを追加します。

import 'svelte/register';

interface NodeCallback<T> {
  (err: any, result?: undefined | null): void;
  (err: undefined | null, result: T): void;
}

export function renderWithViewEngine(filePath: string, options: any, next: NodeCallback<any>) {
  const component = require(filePath).default;
  const { html } = component.render(options);
  next(null, html);
}

src/main.ts にこのコードを反映させます。

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { resolve } from 'path';
import { AppModule } from './app.module';
import { renderWithViewEngine } from './svelte-view-engine';

async function bootstrap() {
  // const app = await NestFactory.create(AppModule); // <- 変更前の記述
  // 以降を追加
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.engine('svelte', renderWithViewEngine);
  app.setViewEngine('svelte');
  app.setBaseViewsDir(resolve('./src/views'));
  // ここからは以前と同様
  await app.listen(3000);
}
bootstrap();

Svelteのテンプレートをレンダリングできるようにする

Svelteのテンプレートを作成します。 src/ 配下に views というディレクトリを作成し、 Layout.svelte を作成し、以下のコードを追加します。 このファイルはhtmlを作成する際のひな型になります。

<script>
   export let title;
</script>

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{title}</title>
  </head>
  <body>
    <slot />
  </body>
</html>

同じく Home.svelte を作成し、以下のコードを追加します。先ほどの Layout.svelte をベースにして、 <slot /> の箇所に Home.svelte の内容が流し込まれます。

<script>
   import Layout from './Layout.svelte';
   export let message = "";
   export let title = "";
</script>

<Layout title={title}>
    <h1>{message}</h1>
</Layout>

これらのテンプレートを使ってレンダリングするためには、コントローラー側に対象のビュー設定を行うためのメソッドデコレータを指定する必要があります。 src/app.controller.ts を書き換えます。新たに Render のメソッドデコレータを追加し、描画するテンプレート ( この場合は Home -> Home.svelte という対応関係) を指定します。

import { Controller, Get, Render } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  @Render('Home')
  getHello(): object {
    return {
      message: this.appService.getHello(),
      title: 'Home'
    };
  }
}

実行してみる

以下のコマンドを実行し、NestJSを開発モードでローカル実行してみます。

yarn start:dev

以下のようにNestJSのサービスが稼働していれば大丈夫です。

[10:33:31] Starting compilation in watch mode...

[10:33:33] Found 0 errors. Watching for file changes.

[Nest] 28600  - 2022/**/** **:**:**     LOG [NestFactory] Starting Nest application...
[Nest] 28600  - 2022/**/** **:**:**     LOG [InstanceLoader] AppModule dependencies initialized +26ms   
[Nest] 28600  - 2022/**/** **:**:**     LOG [RoutesResolver] AppController {/}: +5ms
[Nest] 28600  - 2022/**/** **:**:**     LOG [RouterExplorer] Mapped {/, GET} route +2ms
[Nest] 28600  - 2022/**/** **:**:**     LOG [NestApplication] Nest application successfully started +3ms

http://localhost:3000 にアクセスします。

無事NestJS + Sveleteで Hello World! ができました。

まとめ

今回はじめてNestJSを触りましたが、NestJSコード構造は、Railsの設計にも若干似ており、(RubyとTypeScriptという開発言語の違いはありますが)Rails開発で培ってきた経験がそのまま利用できるイメージがあります。
また、NestJSにはTypeScriptで利用できる強力なデコレータが用意されており、JavaのSpringのアノテーションにも似ており、メンテナンス性を考えて丁寧に設計されていることが良く分かります。
これまでがっつりMVCベースのWebフレームワークを触ってこられた方は使いやすいWebフレームワークなのかなと思います。

公式サイト(英語)のOverviewを見ていくと、どういったWebフレームワークなのか理解を深められると思いますので、NestJSに興味を持っていただいた方は是非お読みください。

docs.nestjs.com

SvelteはReactやVueと似たようなものかと考えていましたが、実際触ってみるとPugのようなテンプレートエンジンに近いイメージを持ちました。独特な構文が少ないため、学習コストもReactやVueと比べると非常に少ないと思います。
今回はアドホックなやり方でSvelteを使っているため、ブラウザ上のロジックが動作しない(動的バインディングや関数が動作しない)のですが、本来のSvelteはSvelteはReactやVueと同様にブラウザ上にロジックを持たせることができるWebフレームワークのようですので、Svelteが気になる方は公式サイトをぜひ除いてみてください。
公式の対話型チュートリアルを通して、実際にコードに触れながら学ぶことができますので、空き時間にポチポチ触ってみると良い時間つぶしになりそうです。

svelte.jp

次回以降の記事では具体的なWebアプリケーションとして動作する仕組みを試していければと考えております。

NFTなんもわからんのでとりあえず試してみた〜スマートコントラクト実装とERC721対応〜

こんばんは、エンジニアの黒岩(@kro96_xr)です。
バックエンドを中心にフロントエンドやらインフラやら色々担当しています。

突然ですが、最近NFTやらWeb3やら良くも悪くも盛り上がっていますね。
個人的に色々思うところはありつつ、技術としてはキャッチアップしてかねばならない…と言うわけでまずは完全に理解した状態を目指しつつ検証した内容をブログに認めていこうと思います。

なお、本記事の内容は個人的に触ったものであり弊社開発物と一切関係がありませんのでご承知おきください。(一応ね)

前置き

NFTって結局なんなのよ

言葉の定義は色々あるかと思いますが、技術的には「ERC721等の規格を満たすスマートコントラクトから生成されたトークン」であると私は理解しました。ERC721という規格を満たす実装をすることで一意のトークンIDを持つ、つまり非代替性を持つ=Non Fungibleなトークンになるわけです。巷でよく見るNFTアートと呼ばれる画像はあくまでトークンに紐づいたメタデータ(トークンからリンクされた画像)であり、NFTそのものではないと言えるかもしれません。

ちなみにERC1155という代替性トークンと非代替性トークンを同時に扱える規格もあり、一概にERC721に従わなくてはならないわけではないようです。

じゃあスマートコントラクトってなんなのよ

どうやらNFTを理解するにはスマートコントラクトとは何かということを理解しないといけなさそうです。

ではこのスマートコントラクトとはなんなのでしょうか?
検索してみると自動販売機に例えている説明を目にしました。

実際に自動販売機を例にとって、順を追って説明します。(私が、100円でコーラを買う場合)

  1. 100円を投入すると、コーラが買える自動販売機を業者が設置(前項目、図解の①、契約の事前定義)

  2. 私が自動販売機に100円を投入する(前項目、図解の④、決済)

  3. コーラのボタンを押す(前項目、図解の②、イベント発生)

  4. 自動販売機からコーラが出てくる(前項目、図解の③、契約執行・価値移転)

当たり前じゃないか!馬鹿にするな!

と、怒られるかもしれませんが、これがスマートコントラクトの原点です。
“スマートコントラクトの原点は自動販売機”というお話し ~スマートコントラクトは執行可能な契約である(英LDTP)~ | 一般社団法人日本暗号通貨技能検定協会より引用

…要するに「決まった命令に対して決まった処理を行い結果を返すプログラム」というイメージでしょうか。
引用した例で言うと、下記のようになります。

  1. 自動販売機を機業者が設置=コントラクトをデプロイ
  2. 自動販売機に100円を入れてボタンを押す=コントラクトに命令を送る
  3. 自動販売機から飲み物が出てくる=処理結果が返ってくる

ん?「特定の命令=入力に対して特定の出力を返す」ってことはただの関数とも言えるのでは?
ということでサンプルコードを見てみましょう。

pragma solidity ^0.4.0;
contract SingleNumRegister {
    uint storedData;
    function set(uint x) public{
        storedData = x;
    }
    function get() public constant returns (uint retVal){
        return storedData;
    }
}

スマートコントラクトを作成し実行する - Ethereum入門より引用

このコードを見る限りオブジェクト指向言語における「クラス」のようなものみたいですね。なんとなく自分でも書ける気がしてきました。

スマートコントラクトを書いてみよう

それでは一旦NFTとERC721のことは忘れてコントラクトを実装してみましょう。

コントラクトを実装するために使用する言語がSolidityになります。
イーサリアムはEVM(Ethereum Virtual Machine)というプログラム実行環境を持っており、EVM上でコントラクトを実行します。
Solidityはコンパイラを通してEVM上で動作するコントラクトのバイトコードを生成することができます。

開発環境

今回はサクッと試してみたかったのでRemix IDEを使用しました。
こちらはブラウザ上で動くIDEであり、テスト環境へのデプロイも簡単にできる優れものです。

というわけで文字列をコントラクト上に保存して、それを取得、更新できる簡単なプログラムを書いてみました。
Solidityの記法は下記を参照してください。

コントラクト指向言語Solidity詳解 - Ethereum入門

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.14;

contract HelloWorld {
    // コントラクトに保存される変数
    string greeting = "Hello, world!";
    // 保存されている文字列を返す関数
    function getGreeting() public view returns (string memory) {
        return greeting;
    }
    // 文字列の更新を行う関数
    function setGreeting(string memory _str) public {
        greeting = _str;
    }
}

こちらをコンパイルします。
SOLIDITY COMPILERタブを開き、コンパイラのバージョンをコード内で指定したものと合わせて"Compile {ファイル名}"をクリックします。

するとコントラクタが実行できるようになるので実行してみましょう。
今回はJavaScript VMという開発環境を使用して実際のブロックチェーン上へのデプロイは行いません。
DEPLOY & RUN TRANSACTIONSタブを開きEnvironmentをJavaScript VMにしてdeployボタンを押すと…

Deploys Contractsにコントラクトが追加されます。

ここからテストができます。

ということで非常に簡単ですが、Solidityを使ったコントラクトの作成ができました。

ERC721に対応してみよう

では、実際にNFTに対応するためにERC721への対応をしていきたいと思います。
これらの規格に対応する場合はOpenZeppelinなどのライブラリを利用するのが一般的なようです。
このライブラリを使用することで非常に簡単に実装することが出来ます。

では、下記のようなコードをRemix IDEに入れて同様にコンパイル、デプロイしてみます。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.14;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract KrocksNFT is ERC721, Ownable {
    constructor() ERC721("Krocks NFT", "KRONFT") {}

    function _baseURI() internal pure override returns (string memory) {
        return "";
    }

    function safeMint(address to, uint256 tokenId) public onlyOwner {
        _safeMint(to, tokenId);
    }
}

とりあえずトークンの名前とオーナーは確認できますね。

このトークンはまだmintされていないのでsafeMintを実行します。
Environmentの下にテスト用のアカウント一覧があるので適当なアカウントのアドレスをコピーします。

toにコピーしたアドレスを、tokenIdに数値を入力して"transact"ボタンを押すと…

無事mintされて指定されたアドレスにトークンが付与されました!
分かりづらいですが、0xB〜がコントラクトのオーナー、0xA現在の所有者になります。

さらにsafeTransferFromを実行すると….

トークンが移動しました!

以上、トークンの生成、移動が実行できることが確認できました。
まだ擬似環境で検証しただけですが、技術的観点からNFTが少し理解できたかなと思います。

とりあえず...

Crypto Zombies をやりましょう。

おわりに

以上、今回はコントラクトの実装を試してみました。

コントラクトの実装は初めてだったのですが、バックエンドエンジニアと親和性が高いのではないかと思いました。
ブロックチェーン上に残る状態変数が公開データベースのようなイメージで捉えているのですがこの感覚は正しいのでしょうか…?

今後は

  • プライベートイーサリアムネットワーク上へのデプロイ
  • もう少し複雑なコントラクトの実装とフロントエンドとの連携(Dapps?)
  • Hardhatを使った開発

など引き続き試していきたいなと考えています。

参考文献

以下の記事を参考にさせていただきました、ありがとうございます!

ブロックチェーンEthereum入門 3 | NTTデータ先端技術株式会社

コントラクト指向言語Solidity詳解 - Ethereum入門

NFT, Web3完全に理解した になるためのチュートリアル|DJ RIO|note

NFTを自分で作ってみた~Remix上でERC721、ERC1155トークンを実装 - 引きこもり系上海駐在員の日常

Serverless Frameworkを使ってlambdaとAPI GatewayでGoで書かれた簡単なAPIを作る

ごあいさつ

エンジニアのうぃすきー(@Whisky_shusuky)と申します。弊社ではインフラ・バックエンド・フロントエンドとWeb周りを全般的に対応しております。

前回Serverless Frameworkを使ってlambdaでメール用のワーカーをデプロイする記事を書きました。ワーカーだけではなくてlambdaとAPI Gatewayを組み合わせたら簡単なAPIも作成できたので紹介します。

前回のサンプルコードに追記したのでこれに則って記載します。 github.com

構成の紹介

api/main.goに実際にLambdaで実行するコードを置いています。クエリパラメータでparameterの項目を受け取ってその値をそのままjsonにして返す簡単なAPIです。

...
func Handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    // queryを取得
    parameter := aws.String(request.QueryStringParameters["parameter"])

    var resp events.APIGatewayProxyResponse
    body := Body{
        Parameter: *parameter,
    }
    jsonData, _ := json.Marshal(body)
    resp.StatusCode = 200
    resp.Body = string(jsonData)

    return resp, nil
}
...

こちらのコードをServerless Frameworkを使ってデプロイしています。 serveless.ymlにこのように記載することによってAPI Gatewayが作成されます。ちょっと詰まった箇所としてはquerystringsにて使用するクエリパラメータparameterを明示的に記述する必要がある点です。

...
  api:
    name: api
    handler: bin/api/main
    events:
      - http:
          path: /sample
          method: get
          cors: true
          # クエリストリングの設定
          request:
            parameters:
              querystrings:
                parameter: true
...

この状態でmake deployを実行するとMakefileの内容が実行されてデプロイされます。

するとAPI Gatewayが構築されます。ステージタブを開くとエンドポイントが表示されるのでそれ+/sample(Serverless Frameworkで指定したpath)を叩けば先ほどのAPIが実行されます。

実際はこのエンドポイントを直接使用することは無くてRoute53で管理しているドメインと連携するといった対応をするケースが多いでしょう。その場合Route53側の設定を行う前にAPI Gateway側でカスタムドメインを作成する必要があります。こちらの設定を行うとRoute53側のAレコード設定時に 作成したAPI Gatewayが設定できるようになります。

なお、serverless-domain-managerというpluginを使用すればカスタムドメイン・Route53も一緒に設定できるようです。ただし、インフラがプラグインに依存することになるので使用する際は念頭に置く必要があるでしょう。

結び

このようにして単純なAPIを作成することができました。静的ファイルでは物足りないもののわざわざサーバーを立てるまでもない時に使い所がありそうです。今回は値を返すだけのAPIですが勿論DynamoDBなどAWSのマネージドサービスと連携することも可能なので本格的なこともできそうです。 また試すにあたりこちらのブログを大いに参考にさせていただきました。

Unity上で外部デバイスとの同期処理を行うための考察(タイムコード読み取り編)

はじめに

エンジニアの松原です。Unityと外部デバイスの間で同期処理を取ろうとするとき、単純に時間のタイミングだけ測って同期を取ろうとしても、デバイス内部で利用しているクロックジェネレータの仕様によって、時間が経つほどズレが大きくなってくることがあります。
同期ズレをできる限り無くしたい、または同期ズレが起きた場合でも後で修正する仕組み(またはマスタリング)として時間の尺度としてタイムコード(Linear time code、またはSMPTE timecode)を利用するアイデアがあります。
前回の記事に引き続き、今回はマイク入力経由でタイムコード信号生成器から音声データとして受け取ったタイムコードを読み取る方法について記事としてまとめました。

synamon.hatenablog.com

ちなみに、60fps以上のフレームレートに対応したタイムコードについての規格についてはSMPTE ST 12-3:2016やITU-RのRecommendation BT.1366-3のドキュメントで読めますが、SDI信号として送受信するアンシラリータイムコード(ATC)についての記述がメインで、音声信号経由で送受信できるかについては書かれていませんでした。(SDI信号はGHzレベルの周波数で信号を送っているため、音声信号として受け取ることができません)

タイムコード信号を波形画像から読み取る

いきなりUnity側の処理に移ると何をしているか分かりづらいと思うので、まずはタイムコード信号を波形画像として表示し、画像から波形に含まれているデータを読み取る方法について解説します。

事前セットアップ

前回の記事でも紹介しましたスマホからタイムコードの信号を生成してくれる、「TimeCode Generator」を使います。

timecodesync.com

3.5mmオーディオジャックを使ってスマホのイヤフォン出力をPCの音声入力につなげた状態で、タイムコード信号をAudacityで信号をキャプチャします。

www.audacityteam.org

実際のタイムコードの信号の波形

タイムコードの信号をAudacityでキャプチャした際の波形はこのようになります。タイムコード信号はパルス波で送られてきます。

これだけでは分からないので、この信号について解説します。タイムコード信号は長い波長を0、短い波長の正負のペアをとしてみなす、Biphase Mark Code(Differential Manchester encodingのエンコードバリエーション)というデジタルデータのエンコード方式が利用されています。(以下の画像はWikipediaの画像の抜粋になります)

先ほどの波形信号を読み取ると、以下のようになります。今回利用するタイムコードはLinear timecode(SMPTE timecode)の規格に則っているので、一つのタイムコードのデータは80ビットで表現されます。赤の縦線が入っている間に80bit分のタイムコードの波形データが含まれています。それぞれの波形をBiphase Mark Codeとして読み取ったものが赤で書かれた[0, 1]の文字になります。

ビットデータをタイムコードに変換する

Wikipediaにタイムコードのデータフォーマット表があるので、これを元に読み取ってみたいと思います。もっと詳しく知りたい方は、ITU-RのRecommendation BR.780-2に詳しく解説されているので、ご興味があればお読みください。

波形画像からビットデータを取り出し、整理すると以下のようになります。(時間以外のデータの表記については省略しています)

これをタイムコードとして数値表現すると、「06:18:36:28」になります。音声の波形からタイムコードを読み取ることができました。

Unity上でタイムコード信号を読み取る

上記ではAudacityでタイムコード信号を直接波形から読み取る方法について紹介しましたが、これ以降はUnity上で動作するスクリプトからタイムコード信号を処理してタイムコードとして取得する方法について解説します。
今回はGistにソースコードを置いております。このコードをベースに記事を書いておりますので、参考しながら記事を読むのをお勧めします。

Unity上で音声信号からLinear timecodeに変換するヘルパーメソッド · GitHub

事前セットアップ

必要パッケージを追加する

今回のコードに含まれる計算処理の一部に com.unity.mathematicsを使っているため、PackageManagerから追加しておく必要があります。
追加方法はPackageManagerの【Add package from git URL...】から com.unity.mathematics を入力して【Add】ボタンを押してインストールします。

AudioのDSP Buffer Sizeを変更する

Project Settingsから、AudioのDSP Buffer Sizeを【Best latency】に変更しておきます。これはマイク入力の遅延を減らすことができます。

信号をUnityで受け取る

前回の記事でも書きましたが、マイク入力経由でタイムコード信号を受け取る処理ですが、一つのソースコードに書くと全体が長くなりがちなので、信号を処理すること自体は別のコードで行うようにしました。

https://gist.github.com/blkcatman/35fffc8e566a81aad09045ec24ee7bb4#file-microphonegrabber-cs

    private void OnAudioFilterRead(float[] data, int channels) {
        // オーディオデータの処理を外部に移譲する
        OnAudioDataRead?.Invoke(data, channels);

        // そのままだとTimecodeの音声が鳴り響くので、ゲインを0にする
        for (int i = 0; i < data.Length; i++)
        {
            data[i] *= 0f;
        }
    }

処理側はTimecodeDecorderクラスのProcessAudioData()メソッド内で行うようにしています。

https://gist.github.com/blkcatman/35fffc8e566a81aad09045ec24ee7bb4#file-timecodedecoder-cs

    private void ProcessAudioData(float[] data, int channels)
    {
        // ここにTimecode取得の処理を記述する
        ...
    }

以降はTimecodeDecorderのProcessAudioData()メソッド内の処理をベースに解説していきます。また、メソッド内ではヘルパー的にLtcHelper.csの処理を利用しています。

https://gist.github.com/blkcatman/35fffc8e566a81aad09045ec24ee7bb4#file-ltchelper-cs

無信号時を検出する

信号が来ていない、または無信号の時も処理が走ってしまうので、ProcessAudioData()のメソッド内でタイムコードの信号の解析をする前に無信号状態を検出しています

        // 信号の振幅が一定未満なら、無信号とみなす
        if (!LtcHelper.HasSignal(maxLevels, 0.2f))
        {
            if (currentBinarySize > 0)
            {
                Span<float> source = new Span<float>(binaryBuffer, 0, currentBinarySize);
                source.Fill(0f);
                currentBinarySize = 0;
            }
            return;
        }

タイムコード信号のチャンネルデータを取り出す

OnAudioFilterRead経由で取得したデータはステレオになっていますが、タイムコード信号は左側のみチャンネルしか受け取っていないため、右側のデータを削除します

        // チャンネル一つぶんの信号を取り出す
        var monauralDataLength = data.Length / channels;
        float[] monauralData = new float[monauralDataLength];
        Span<float> monauralSource = new Span<float>(monauralData);
        LtcHelper.GetMonauralData(data, ref monauralSource, channels, 0);

信号を2値化する

タイムコード信号はマイク入力経由で受け取っているため、一度音声信号としてアナログ変換されます。それをPC側で量子化してデジタルデータ化しているため、ノイズが混入したり、パルス信号の波形がなまる(立ち上がり時間が増える)問題があります。
このため、信号処理ではパルス幅を測定する際に振幅(Amplitude)にしきい値(下限)を設けるか、フィルタリング処理が必要になります。
本来であればパルス整形フィルタ(例:ガウスフィルタ)などを掛けるのがベストですが、Unity側で処理を簡略化するため、雑ですが信号そのものを1.0と-1.0に2値化して、パルス整形フィルタの代わりにします。

        // 信号を2値化する
        float[] binarizedData = new float[monauralDataLength];
        Span<float> binarizedDest = new Span<float>(binarizedData);
        LtcHelper.Binarize(monauralSource, ref binarizedDest);

2値化した後はこんなイメージになります。

2値化した信号をバッファに溜める

OnAudioFilterRead()メソッド経由で受け取るバッファサイズはDSP Buffer Sizeに影響を受けるため、OnAudioFilterRead()から呼び出された1回分の信号データにはタイムコードをデコードできるほどのデータ量が含まれていないことがあります。
このため、2値化したデータをある程度のデータ量になるまでバッファに保存します。一定以上データがたまったら信号をタイムコードとしてデコードする処理を行います。

        // 2値化データをバッファにコピーする
        var storeDest = new Span<float>(binaryBuffer, currentBinarySize, monauralDataLength);
        binarizedDest.CopyTo(storeDest);
        currentBinarySize += monauralDataLength;
        
        // 一定以上データをバッファに蓄積したら処理を行う
        if (currentBinarySize > 2000)
        {
        ...
        }

各パルスのパルス幅を取得する

パルスが0のビットを表現しているのか、それとも1のビットを表現しているかを知るためには、事前に各パルスのパルス幅を計算する必要があります。信号は既に2値化されているので、パルス幅の測り方は信号の符号の入れ替わりで計算することができます。プロセスについては以下のようになります。

  1. 信号のカウントを開始する
  2. 前後の信号を比較し、信号どうしの符号が同じなら、同じパルスと見なせる
  3. 同じ符号を持つ信号をカウントする
  4. 比較対象の符号が異なる時、それまでの信号のカウントをパルス幅として確定し、以降は別のパルスと見なす
  5. 1~4の処理を繰り返す

コードでも同様に、パルス幅の判定に連続する信号の符号を利用し、波長を計算しています。

            // 信号の波長を取得する
            var signalSource = new ReadOnlySpan<float>(binaryBuffer, 0, currentBinarySize);
            var waveLengthArray = 
                LtcHelper.GetWaveLengthArrayFromBinarizedFloats(signalSource, samplingRate);

パルス幅からビットを推定する

タイムコードの信号はBiphase Mark Code方式でエンコードされているので、0か1のビットを判断するにはパルス幅で判断する必要がありますが、タイムコード(Linear time code)はフレームレートによってパルス幅が変わってきます。

Wikipediaではパルス幅は30フレーム時2400Hz (= 416.66マイクロ秒)という表現になっていました。

Made up of 80 bits per frame, where there may be 24, 25 or 30 frames per second, LTC timecode varies from 960 Hz (binary zeros at 24 frames/s) to 2400 Hz (binary ones at 30 frames/s), and thus is comfortably in the audio frequency range.

ITU-Rのドキュメントでは30フレーム時の0ビットのパルス幅は417マイクロ秒、1ビットのパルスは片側208.5マイクロ秒と記述がありました。

流石に今回の記事でパルス幅を自動的に判断する方法までは入れられなかったのと、30fps以下にタイムコードを設定する意味も薄いため、タイムコード信号発生器のフレームレートは30fps固定として、0のビットと1のビットを判定するパルス幅は決め打ちで設定しました。
コードではノイズ等でパルス幅が長くなったり短くなったりすることを想定して、それぞれのビットのパルス幅の判定基準に上限下限のしきい値を設けてビットデータの配列に変換しています。

            // ビットデータ配列に変換する
            var bitArray = LtcHelper.DecodeWaveLengthToBits(waveLengthArray,
                0.0004f, //0ビットとして判定するパルス幅の下限
                0.0005f, //0ビットとして判定するパルス幅の上限
                0.0002f, //1ビットとして判定するパルス幅の下限
                0.00026f //1ビットとして判定するパルス幅の上限
            );

ビットデータ配列からSync wordを探す

Sync wordはタイムコード信号の始まりから終わりまでを区別するための固定の16ビット(0011111111111101)のデータです。Sync wordの前後にタイムコードの実データが含まれているので、ビット変換後はSync wordを頼りにタイムコードのデータの区切りを判別することができます。
Sync wordはタイムコードの実データ部の後ろに付与されているので、Array.LastIndexOf()メソッドを使って要素の最後から検索しています。

            // ビットデータ配列内にSyncWordが含まれているかチェックする
            var syncWordPosition = bitArray.LastIndexOf(LtcHelper.SyncWord, StringComparison.Ordinal);
            // SyncWordが入っていない場合は処理を中断する
            if (syncWordPosition < 0)
            {
                // 1秒以上バッファにデータがたまっているときはバッファを初期化する
                if (currentBinarySize > samplingRate)
                {
                    var clearSource = new Span<float>(binaryBuffer);
                    clearSource.Fill(0f);
                    currentBinarySize = 0;
                }
                return;
            }

Sync wordより前のデータ(64bit)をタイムコードしてデコードする

Sync wordの間にあるビットデータはタイムコードの実データになるので、Sync wordが見つかったビットのインデックス位置よりも前の64bit分のデータをデコードすることでタイムコードを取り出すことができます。
コードではタイムコードのデータフォーマットに従ってデコードしています。また、冗長な処理かもしれませんが、コードではSync word分も取り出して、最後にバリデーションをかけています。

            // SyncWordが含まれている箇所より前にタイムコードを計算できるデータ量が含まれている時
            if (syncWordPosition >= 64)
            {
                // タイムコード80bitぶんのデータを切り出す
                var bitData = bitArray.Substring(syncWordPosition - 64, 80);
                // タイムコードに変換する
                if (LtcHelper.DecodeBitsToTimecode(bitData, out var timecode))
                {
                    CurrentTime = timecode;
                }
            }

2値化した信号データをバッファから削除する

一度デコードしてタイムコード化したデータは不要になるため、タイムコードとして認識した信号データよりも前の2値化した信号データをバッファから削除します。

            // 後処理: バッファの先頭からSyncWordの末尾の位置を取得する
            var segmentPosition = waveLengthArray[syncWordPosition + 16].Position;
            var tempBufferSize = currentBinarySize - segmentPosition;
            if (tempBufferSize > 0)
            {
                // SyncWordの末尾からのデータをバッファの先頭に移動する
                var tempBuffer = new float[tempBufferSize];
                var temp = new Span<float>(tempBuffer);
                // Tempバッファにデータをコピー
                var tempSource = new Span<float>(binaryBuffer, segmentPosition, tempBufferSize);
                tempSource.CopyTo(temp);
                // Tempバッファを2値化データ用のバッファの先頭にコピーする
                var bufferDest = new Span<float>(binaryBuffer, 0, tempBufferSize);
                temp.CopyTo(bufferDest);
                currentBinarySize = tempBufferSize;
            }

できあがったもの

タイムコードの表示がUnity上でできるようになりました。マイク入力にOSネイティブのドライバ(ASIOなど)を使ったり、コードをチューニングすることで遅延をもっと減らすこともできると思います。

まとめ

この記事ではタイムコード信号生成器から発生した信号をマイク入力経由でUnity上でもタイムコードを扱えるようにする内容について紹介しました。次回以降はこのタイムコード表示を使った同期方法の検討や、タイムコード以外の方法を使った同期方法についても記事にできればと思います。

InputCompositeBindingのTouchscreenでの応用例の紹介

こんにちは、エンジニアリングマネージャーの渡辺(@mochi_neko_7)です。

前回、Unityの(New) Input Systemでデバイス入力をカスタマイズできるInputCompositeBindingを紹介しました。

synamon.hatenablog.com

こちらの記事ではInputCompositeBindingをそもそも知らない方が多いと思い、基本的な説明から行ったため、あまり応用の具体的な話ができませんでした。

今回はまだ低レベルなAPIしか用意されていないTouchscreenの入力の具体的な応用例を2つ紹介しながら、読者の方がよりInputCompositeBindingを理解し自身で実装ができるイメージを持てることをゴールにしたいと思います。

それではさっそく見ていきましょう。

1. TouchscreenでのTapした位置の入力

スマートフォン向けのアプリで、指でタップした位置にある3D空間内のオブジェクトになにかのインタラクションをしたい、というケースを想定しましょう。

それを実現するための一つの例として、以下のようなフローが考えられます。

指でタップしたスクリーン位置を取得する

→ スクリーン位置をCamera.ScreenPointToRay(...)で3D空間のRayに変換する

→ Rayを使って3D空間内でRaycastなどをしてオブジェクトを検索する

→ 見つかったオブジェクトになにかのインタラクションをする

ところがUnity標準のTouchscreenのBindingでは、Touch入力は0~9の10個のTouchControlが提供されていて、指定したTouchのTapやPositionは取得できますが、「TapしたTouchのPosition」を取るのは素直にはできません。

docs.unity3d.com

このような時にはInputCompositeBindingの出番です。

実際にソースコードを見ながら解説しますが、基本的な部分は前回の記事で丁寧に紹介したため、ここではカスタマイズのコアな部分のみ紹介します。

自作したクラスの内部に以下のような処理を記述します。

...
        // Please input path:"<Touchscreen>/*touch/tap" to update value.
        [InputControl(layout = "Button")] public int anyTap;

        public override Vector2 ReadValue(ref InputBindingCompositeContext context)
        {
            var touches = Touchscreen.current.touches;
            for (var index = 0; index <= 9; index++)
            {
                var touch = touches[index];
                if (IsEffectiveTap(touch))
                {
                    return touch.position.ReadValue();
                }
            }

            return Vector2.zero;
        }

        public override float EvaluateMagnitude(ref InputBindingCompositeContext context)
        {
            return ReadValue(ref context).magnitude;
        }

        private static bool IsEffectiveTap(TouchControl touch)
        {
            return touch.tapCount.ReadValue() > 0;
        }
...

最も重要な入力の加工処理は、public override T ReadValue(...) で行います。

今回はTapした位置を取得したいため、TにはVector2を指定してクラスを定義しています。

ReadValue(...)では、はじめにTouchscreen.current.touchesで現在のTouchControl10個の配列を取得します。

それからfor文で順番にTouchControlを見て、IsEffectiveTapがtrue、つまりTapをしているTouchを判定して、それが見つかった際にTouchControl.position.ReadValue()でPositionを返すようにしています。

ロジックとしてはとてもシンプルかと思います。

ただ注意しなければならないのが、ReadValue(...)以外の点です。

EvaluateMagnitude(...)のoverrideの実装ですが、これはReadValue(...)の結果を流用しているだけなのですが、この実装とセットでActionに設定する際にInteractionsのTapを設定することでTap操作としての振る舞いをさせることができるようになります。

次に最初から用意されているTouchControl.tap.ReadValue()というTap判定が(不具合なのか?設定が悪いのか?)正常に取得できませんでした。

そのため厳密には正しくはないのですが、Tapすると数が増えていくTouchControl.tapCount.ReadValue()を見てTapしているかどうかの判定としています。

ここの部分は不具合の可能性もあるため今後は改善されるかもしれません。

最後に、本来ReadValue(...)内部で使用すべきものですがここでは使用していない[InputControl(layout = "Button")] public int any;を説明します。

ご覧のとおり今回の処理では特にInputActionAsset上でのBindingsのセットアップをすることなく入力の取得ができるように見えると思います。

しかしこのBindingsを一つも定義しない場合、InputActionAsset上で自作したInputCompositeBindingにControlSchemeの指定欄がないため、ControlSchemeの指定ができない、つまりどのデバイス入力に対応して入力を流すのか指定できないこととなり、そもそも入力がまったく入らなくなってしまいます。

では適当に定義しておくとして何でも良いかというとそうではなく、そこで指定したBindingsの入力が更新されるタイミングで、自作したBindingsの入力も更新される仕様となっているため、自作した入力を利用したいタイミングと同じタイミングで値が更新されるBindingsを指定する必要があります。

今回は何かしらのTap操作のタイミングで入力が渡せられればよいので、ActionAssetのEditorで <Touchscreen>/*touch/tap をBindingsに設定します。

Tのアイコンを押すとテキストで指定できます、* はワイルドカードです。

ここは動かしてみないと分からない点なので気を付けていただきたいのと、後から見ても何が目的なのか分からないためコメントを残しておくことをおすすめします。

...

以上がTouchscreenでのTapした位置の入力のBindingのカスタマイズ例になります。

後半で説明したやや複雑な部分もありはしますが、ロジック自体は簡潔かと思います。

ちなみにこのようなCustom Bindingを作ると、例えばPCでのマウスのクリックとスマホでのタップ操作の切り替えをInput System側だけで完結させることができ、入力を利用する側ではデバイスの差異を気にすることなる処理をすることができるようになります。

2. Touchscreenの仮想Button入力

次はスクリーン上の仮想ボタンを押して何かのアクションをさせたいケースを想定しましょう。

スクリーン上のどこかに円形のボタンがあり、その領域内を指で押す判定を取りたいのですが、既存のBindingsでは領域を絞った入力を取ることは難しいです。

そのため、やはりこのケースでもInputCompositeBindingで自作をする必要があります。

1と同じくコアな部分の実装例だけ見ていきましょう。

...
        // Please input path:"<Touchscreen>/touch*/press" to update value
        [InputControl(layout = "Button")] public int any;

        public Vector2 centerPosition = Vector2.zero;
        public float radius = 1f;

        public override float ReadValue(ref InputBindingCompositeContext context)
        {
            var touches = Touchscreen.current.touches;
            for (var index = 0; index <= 9; index++)
            {
                var touch = touches[index];
                if (IsEffectiveTouch(touch))
                {
                    return touch.press.ReadValue();
                }
            }

            return 0f;
        }

        private static bool IsEffectiveTouch(TouchControl touch)
        {
            return touch.isInProgress
                   && IsInsideOfCircle(touch.position.ReadValue(), centerPosition, radius);
        }

        public static bool IsInsideOfCircle(Vector2 touchPosition, Vector2 centerPosition, float radius)
        {
            return Vector2.SqrMagnitude(touchPosition - centerPosition) <= radius * radius;
        }

        public override float EvaluateMagnitude(ref InputBindingCompositeContext context)
        {
            return ReadValue(ref context);
        }
...

まず一番重要な ReadValue(...) から見ていきます。

TouchControlの配列を取得して、0から順番に見ているのは変わりません。

そして IsEffectiveTouch(...) がtrueになるTouchControlを探して、その TouchControl.press の値を返すという処理をしています。

基本的な考え方はTap位置の取得の時と変わりませんね。

大事なのは、private static bool IsEffectiveTouch(TouchControl touch)の判定部分です。

一行目の TouchControl.isInProgress はそのTouchが今何かしらの入力状態にあるかどうか(厳密にはTouchPhaseがBegan/Moved/Stationaryのいずれかにあるかどうか)をチェックしています。

二行目の IsInsideOfCircle(...) でTouch位置が円形のボタン位置の内側にあるかどうかをチェックしています。

ちなみに意味合いとしては Vector2.Distance(...) のほうが直感的なのですが、内部で平方根の計算をしているため処理が少し重いため、代わりに Vector2.SqrMagnitude(...) を使用するほうがパフォーマンスが良いです。

[InputControl(layout = "Button")] public int any;EvaluateMagnitude(...) の実装は1の場合と同じですね。

注意事項もいくつかあります。

まずさせたい振る舞いがButtonなのでboolを入力として取り扱いたくなるのですが、あくまで 0~1の値を取る float を指定し、Actionに設定する際にInteractionsにPressを設定することでButtonの振る舞いをさせることができます。

また、Buttonの位置をcenterPositionradiusで指定していますが、GUIを出したい場合uGUI×EventSystemで扱う場合と違ってGUIの配置と一致しているとは限らないため、Binding側での判定領域とGUIの配置の整合性を自分で保障してあげる必要があります。

決め打ちなら何も問題はないのですが、実行時にSafeAreaを考慮するなどしたい場合はBinding側に位置の計算ロジックを持たせて、GUI側がそれを見てuGUIを動かすなどしてあげるとよいかもしれません。

また他の入力操作との重複を避ける必要もあると思いますのでご注意ください。

...

以上がスクリーン上での仮想Button入力の取得の実装例とその説明になります。

こちらも1でお作法が分かってしまえばシンプルなロジックで実装できることが分かるかと思います。

また、このBindingを使用すると、PCでのキー入力とスマホでのボタン操作の切り替えを容易に実装できることが分かるかと思います。

実装の注意点のまとめ

ロジック以外の部分で実装時に注意すべき項目を整理しておきます。

  • 基本的なお作法に則っているか?
    • これは前回の記事で紹介した範囲です
  • <T>の指定は適切か?
    • ActionAssetで思った通りに設定できるか試してみれば分かると思いますが、Actionの取り扱う型とCompositeBindings<T><T>を一致させる必要があります
    • 特にButtonはboolではなくfloatです
  • [InputControl] public int ***を定義し、ActionAssetで適切に設定できているか?
    • ロジックで使用しなくても必要です
    • 普通に利用する際は、型の指定がButtonなどの文字列である点に注意してください
  • EvaluateMagnitude(...)のoverrideは実装しているか?
    • 実装の有無で振る舞いが変わります
    • なぜか実装しないほうが良いケースもあるため注意してください
  • GUIとの整合性は取れているか?
    • EventSystemを経由しないため、実行時に調整をする際には気を付けてあげる必要があります

おわりに

InputCompositeBindingは特に低レベルのAPIしかないTouchまわりでは有効なのですが、まだまだ初見では分かりづらいポイントや落とし穴があり、とっつきづらい部分も多いです。

そもそも他デバイスとの共通化をまったく考えなくてもいいのであれば、Actionをただの入力として扱い、Actionを利用する側に入力処理のロジックを持たせればわざわざこのような実装はしなくても済みます。

ですが何度も説明しているように、逆にうまく使いこなすことができればデバイスの差異をうまくInputSystem側で吸収することができ、アプリケーション全体の設計として綺麗にまとまりやすくなると思います。

現在はこうやって自作する必要がありますが、将来的にTouchのジェスチャー系の実装の予定もあるとか聞きますし*1、もっと手軽に扱えるようになるかもしれませんね。

それを待たずともこのあたりの処理を汎用化してライブラリにしてあげるといいのかもしれませんが、特に入力領域を指定する場合に入力処理同士の重複を避ける処理がなかなか汎用化しづらいのが難点です。

今回はTouchでの応用例のみ紹介しましたが、もちろんそれ以外でも標準のBindingsでは欲しい形で入力を取得できない場合には有用ですので、困ったときには使用を検討してみるとよいかと思います。

以上、前回の記事と併せてInputCompositeBindingを使ってみたい方の参考になれば幸いです。

*1:> 新しいInput Systemは現時点では基本的なタッチ入力しかサポートしていませんが、将来的にはジェスチャー入力等より高度な表現にも対応する予定とのことです。> https://forpro.unity3d.jp/unity_pro_tips/2021/05/20/1957/

AWSを使ったクラウドUnityビルド環境の構築~GitHub Actions構築編~

エンジニアの岡村です。この記事は以前掲載した『AWSを使ったクラウドUnityビルド環境の構築~ビルドサーバー構築編~』の続編であり、シリーズの3記事目です。

synamon.hatenablog.com

synamon.hatenablog.com

また、インフラメンバーの黒岩さんに書いていただいたビルドサーバー構築編の補足になる記事もどうぞ。

synamon.hatenablog.com

これまでで作ったもの

ライセンスサーバー構築編ではUnityのフローティングライセンスを管理するサーバーを建て、ビルドサーバー構築編ではUnityがインストールされたマシンのAMI及び、それを利用してビルドトリガーに応じてSelf-Hosted Runnerを起動するよう設定しました。 その結果、以下の画像のような環境が構築されています。

今回はこの環境を利用して、Unityプロジェクトをgitのリモートリポジトリにプッシュした際、自動的にテストとビルドを行うWorkflowをGitHubで構築します。

GitHub ActionsのWorkflowを記述する

リポジトリのルート=Unityプロジェクトのルートとなっているリポジトリを用意し、.github/workflows/フォルダ内にyaml形式でワークフローを記述します。

GitHub Actionsのワークフロー構文 - GitHub Docs

今回はサンプルとして以下のような簡単なworkflowを用意しました。細かい解説は後述します。

name: ExampleWorkflow

on: [push, workflow_dispatch]

jobs:
  test:
    runs-on: [self-hosted, windows, unity-2021.3.0f1]
    strategy:
      matrix:
        platform: [EditMode, PlayMode]
    steps:
      - name: Test Unity Licensing Client
        run: '"C:\Program Files\Unity\Hub\Editor\2021.3.0f1\Editor\Data\Resources\Licensing\Client\Unity.Licensing.Client.exe" --acquire-floating'
        shell: cmd
      - uses: actions/checkout@v3
      - name: LFS pull
        run: git lfs pull
      - name: Run ${{ matrix.platform }} tests
        run: >-
          start /wait "" "C:\Program Files\Unity\Hub\Editor\2021.3.0f1\Editor\Unity.exe"
          -runTests
          -batchmode
          -projectPath .\
          -testResults .\TestResult.xml
          -testPlatform ${{ matrix.platform }}
          -logFile Editor.log & type Editor.log
        shell: cmd
      - uses: actions/upload-artifact@v3
        with:
          name: ${{ matrix.platform }}TestResult
          path: TestResult.xml
  build:
    runs-on: [self-hosted, windows, unity-2021.3.0f1]
    steps:
      - name: Test Unity Licensing Client
        run: '"C:\Program Files\Unity\Hub\Editor\2021.3.0f1\Editor\Data\Resources\Licensing\Client\Unity.Licensing.Client.exe" --acquire-floating'
        shell: cmd
      - uses: actions/checkout@v3
      - name: LFS pull
        run: git lfs pull
      - name: Run build
        run: >-
          start /wait "" "C:\Program Files\Unity\Hub\Editor\2021.3.0f1\Editor\Unity.exe"
          -batchmode -quit
          -projectPath .\
          -buildTarget Win64
          -buildWindowsPlayer Builds\Windows\Build.exe
          -logFile Editor.log & type Editor.log
        shell: cmd
      - name: Upload build
        uses: actions/upload-artifact@v3
        with:
          name: BuildWin64
          path: Builds/Windows/*

以上の内容を.github/workflow/main.ymlとして配置してGitHubにpushすると、以下のようにジョブが開始されます。

また、このタイミングでAWSの方を覗いてみると、ジョブの数に合わせて(terraformで制限した数を上限として)インスタンスが建っています。

(ここでインスタンスが暫く待っても表示されないようであれば、yaml内で指定しているself-hosted runnerのラベルが間違っているかもしれません。)

そこからさらに暫く待つとステップの処理が始まります。スポットインスタンスのリクエスト及びrunnerのセットアップに時間が掛かります。こちらの環境ではインスタンスが既に立ち上がっていない場合、pushしてから処理が始まるまで10分強程度待つ必要がありました。

ワークフローが完了すると成果物がアップロードされています。テスト結果はNUnitのxmlフォーマットで出力されているので、これを読めるレポーターに噛ませてあげれば失敗したテストをGitHub上で具体的に指摘する事も可能です(が、現時点でUnityが吐き出すxmlをきちんと読めるレポーターがMarketplaceに見つからなかった為そのままアップロードしています)。

Workflowの詳細

以降はWorkflowでUnityのビルドを行うために工夫した点をステップ単位でかいつまんで紹介します。Jenkins時代のコードを引き継いでいる個所もあるので古いハックも含まれている可能性があります。

Test Unity Licensing Client

一度FloatingLicenseのテストを行っているのですが、これを行わないとUnity実行時にライセンスサーバーと接続出来ないことがあるので毎回行っています。

      - name: Test Unity Licensing Client
        run: '"C:\Program Files\Unity\Hub\Editor\2021.3.0f1\Editor\Data\Resources\Licensing\Client\Unity.Licensing.Client.exe" --acquire-floating'
        shell: cmd

LFS pull

社内のプロジェクトではgit lfsを利用しているのですが、actions/checkoutではlfsのファイルが正しく取得できなかったため、明示的にステップを入れています。

      - name: LFS pull
        run: git lfs pull

Run Unity

Windowsはcmd、PowerShell、git bash等が使えますが、今回はcmdでUnityを実行しています。Unityをcmdで実行する際、直接exeを叩くとプロセスの終了を待たずにステップが進んでしまうため、start /waitを使って待機しています。この際、ファイルパスにスペースが含まれている為にパスをダブルクォートで囲むと、startコマンドの仕様上ウィンドウタイトルの指定として解釈されてしまうので、実行ファイルパスの前に""引数を付けています。

テストはTestFrameworkのコマンドライン引数を使って実行しているので、以前のバージョンで使用できたUnity本体のマニュアルのコマンドライン引数とは差異があります。

また、-logFile -とすることでJenkins時代はログをリアルタイムに出力できたのですが、GitHub Actionsでは(もしくはUnityバージョンの関係で)それが出来なくなっていたので、typeコマンドを繋いで出力するようにしています。

今回は特にキャッシュを行っていないのですが、Unityの実行時には大量のキャッシュファイルが生成されるので、特に大規模なプロジェクトになってきた場合はactions/cacheを使うか、Unity AcceleratorをAWS上のライセンスサーバーの横に追加で構築してもいいかもしれません。

      - name: Run ${{ matrix.platform }} tests
        run: >-
          start /wait "" "C:\Program Files\Unity\Hub\Editor\2021.3.0f1\Editor\Unity.exe"
          -runTests
          -batchmode
          -projectPath .\
          -testResults .\TestResult.xml
          -testPlatform ${{ matrix.platform }}
          -logFile Editor.log & type Editor.log
        shell: cmd
~~~~~~~~
      - name: Run build
        run: >-
          start /wait "" "C:\Program Files\Unity\Hub\Editor\2021.3.0f1\Editor\Unity.exe"
          -batchmode -quit
          -projectPath .\
          -buildTarget Win64
          -buildWindowsPlayer Builds\Windows\Build.exe
          -logFile Editor.log & type Editor.log
        shell: cmd

以上

現時点ではまだこの(BuildServerライセンス&GitHub Actions&AWSベースの)Unity CI環境はハマりポイント盛りだくさんで手探り状態です。安定を求めるのであれば既存のマネージドサービスや、物理マシンのself-hosted runnerを使った方が楽でしょう。しかし、フローティングライセンスを使うことでUnityのライセンスが扱いやすくなったこと、AWSにインスタンスを立てることでスペックの自由度やスケーラビリティが確保される事は、特にUnityを大規模に使っているような企業においては魅力的なのではないでしょうか。

AWSを使ったクラウドUnityビルド環境の構築シリーズとして、一応予定していた記事は出し切ったのですが、今後も追加情報があれば記事の修正や、追加の記事を出していこうと考えています。 記事での紹介内容も分かる人向けに端折ってお伝えしましたが、もし分からないところがあればTwitter等で指摘をお願いいたします。

このシリーズに長らくお付き合いいただき、ありがとうございました。


SynamonではUnityエンジニアを募集しています。Unityを使った最新のCI/CD環境に興味ある方、Unityを使ったマルチプレイ、マルチデバイスなメタバース構築に興味ある方は是非採用情報をご確認下さい。

herp.careers

カジュアル面談も行っています。

meety.net

Unity上で外部デバイスとの同期処理を行うための考察(デバイスセットアップ編)

はじめに

エンジニアの松原です。ゲームや業務用アプリケーション開発以外にもライブ配信システムにUnityを利用するケースがあります。以前実験調査の際に、デバイス間での時間同期を取る方法が課題に挙がりました。
デバイス間の同期ズレをできる限り無くしたい、または同期ズレが起きた場合でも後で修正する仕組み(またはマスタリング)として時間の尺度としてタイムコード(SMPTE timecode)を利用するアイデアがあります。 また、映像機器ではGenlockという、同期処理信号の入出力により、フレーム単位での同期処理を行う仕組みがあります。 Unityで同期処理に関する情報について数回の技術関連の記事としてまとめていきたいと思います。
今回はタイムコードの簡単な説明と、タイムコードを生成するための環境、また、Unity上で音声データとして取り込むところまで(タイムコードへの変換については次回)扱いたいと思います。

非互換デバイス間での同期処理の概要

デバイス間での同期処理では、お互いのシステム環境で似たようなハードウェアを持っているか、または互換性を担保するためのソフトウェアがあることが前提になっている場合があります。
その環境であれば、同じプロトコルやデータのフォーマットを利用でき、送受信データに同期処理に関する内容を含めやすいので、ソフトウェア側の工夫によって同期処理が解決するケースが多いと思います。

今回の記事ではデバイス間で扱っているデータが異なっており、ハードソフト両面で互換性が無い場合に同期処理をどうするか、という課題解決の一つにタイムコードを利用したく、タイムコードをUnity上で扱う際の考察を数回の記事に分割して紹介していく予定です。

タイムコードを使うメリットについて

タイムコードそのものについて説明すると記事がすごく長くなるため、詳しい話はWikipediaのSMPTEタイムコードの項目をお読みください。

ja.wikipedia.org

タイムコードを扱うメリットですが、音声(マイク入力)を扱う場合に大きな恩恵を受けることができます。タイムコードは音声信号(LTC信号)として入出力できるので、音声出力のチャンネルに混ぜることにより、どの時間軸での音声データであるかという紐づけが簡単にできます。ビデオカメラがタイムコード同期に対応していれば、映像の同期処理も簡単に行えるようになります。
さらにPC側でタイムコードが読み取れれば、処理単位にタイムコードとの紐づけが行えるようになります。今回目指しているのは、Unityの動作環境(PC)での時間をタイムコードをベースに合わせる方法について考えていきます。

また、UnrealEngine(5.0)ではタイムコードを扱う内容を取り上げた説明ページも用意されており、映像・音声とゲーム画面との同期を意識していそうです。(以下のページは2022年4月時点での仮ページのようなので、将来消えている可能性があります)

docs.unrealengine.com

音声映像の機器では、タイムコード以外にも、Genlockという専用の同期信号を受信する方法もあります。(Genlockは今回扱わないので説明は省略します)

タイムコード生成器とテストのデバイス構成について

タイムコード生成器はタイムコードのLTC信号を発生するデバイスで、業務用のものからホビーユースに近いものまで販売されています。
今回はスマホからお手軽にタイムコードの信号を生成してくれる、「TimeCode Generator」を利用しました。(※有料アプリ)

timecodesync.com

上記のアプリを使ってタイムコードのテストを行う場合、3.5mmオーディオジャックを使ってスマホのイヤフォン出力をPCの音声入力につなげるだけのシンプルな構成になります。

Unity上でのマイク音声読み取りについて

マイク入力の音声をUnity上でデータ配列として取得するようにします。

Unityでは Microphone クラスから AudioClip が取得でき、この AudioClip を設定した AudioSource をGameObjectコンポーネントに加えることでマイクの音声を再生することができます。

docs.unity3d.com

また、AudioSource をコンポーネントとしてGameObjectに登録しており、なおかつ AudioSource が再生状態の場合、OnAudioFilterRead() の関数の引数から float[] 型の配列として音声信号のデータを取得できます。
以下にサンプルコードを記載します。

#nullable enable

using System;
using UnityEngine;
using System.Linq;

public class MicrophoneGrabber : MonoBehaviour
{
    [SerializeField]
    private string micFilterName = string.Empty;
    
    [SerializeField]
    private int bufferSeconds = 1;

    private void Start()
    {
        var deviceName = GetDeviceName(micFilterName);
        if (deviceName == null)
        {
            Debug.LogWarning($"Audio Device (keywords: {micFilterName}) was not found.");
            return;
        }
        var clip = Microphone.Start(deviceName, true, bufferSeconds, 44100);
        var source = gameObject.AddComponent<AudioSource>();
        source.clip = clip;
        source.loop = true;
        while (Microphone.GetPosition(deviceName) < 0) { }
        source.Play();
    }

    private void OnAudioFilterRead(float[] data, int channels) {
        // ここにTimecode取得の処理を記述する
        
        // そのままだとTimecodeの音声が鳴り響くので、ゲインを0にする(以下のコードでは音を小さくしている)
        for (int i = 0; i < data.Length; i++)
        {
            data[i] *= 0.005f;
        }
    }

    private static string? GetDeviceName(string filterName)
    {
        return Microphone.devices.FirstOrDefault(device => device.Contains(filterName));
    }
}

このスクリプトを適当なGameObjectに張り付け、再生することで、スマホから出力したタイムコードの信号を音声データとしてUnity側で受け取れるようになります。 また、デバイス名を指定することで、デフォルトに指定していないデバイスも扱えます。上記のサンプルコードでは、デバイス名の一部からデバイス名を取得する実装になっています。

OnAudioFilterRead()以外にも、AudioClipから直接音声信号のデータを取り出すこともできます。 詳しいやり方については、「OnAudioFilterRead」や「AudioClip.GetData」などでGoogle検索すれば出てくると思います。

Unity上でのタイムコード読み取りについて

タイムコード読み取り方法については次回紹介したいと思います。

まとめ

この記事ではUnityでタイムコードを扱う経緯について、またその利便性について簡単に紹介しました。 次回は実際にタイムコードを読み取るところまでをカバーしていこうと考えています。 また、音声取り込み時の遅延に関してですが、ASIOドライバを利用することでUnityで再生する遅延時間を減らせることができるはずなので、こちらもいつか取り組んでみたいと思います。