WAAPIとPythonを利用したチーム作業の紹介とその例

Wwiseの使い方やツール

私はかなり以前より自分の意見を反映させたアプローチでWAAPIを使用していますが、今回はそれをご紹介したいと思います。Python、コマンドアドオン、そして小さなヘルパーライブラリで構成されるアプローチで、バニラの waapi-client からスタートするのと比べてWAAPIスクリプトを少しはやく作成し、ほかのチームメンバーとほぼ問題なく共有することができています。WAAPIのしくみの基礎を取り上げながら、実際に活用できるWAAPIスクリプトの事例と使い心地を説明したいと考えています。WAAPIを習いはじめたばかりの初心者にとっても役に立つかもしれません。オーディオチームをワークフローツールやオートメーションの観点からサポートする、オーディオチーム以外の同僚向けの使い方のガイドラインにもなります。

この記事のためにコード例をいくつか書きましたが、詳細なテストは行っていないためご注意ください。コード例を実行するためには、PC環境の設定が必要です。付録 の手順をご参照ください。

 

基本的な概念

まず最初にWwiseとWAAPIの基本をご説明します。

Wwiseプロジェクトはオブジェクトの階層として整理されています。オブジェクトにはいくつかのタイプがあります(EventRandomSequenceContainer など)。オブジェクトのタイプが、オブジェクトが有するプロパティを表します(BusVolumeIsStreamingEnabled など)。オブジェクトのプロパティ値の継承は階層内の相対的な位置によって定義されるため、子オブジェクトは親オブジェクトのプロパティ値を継承することになります。これらすべてによりオーディオエンジンのランタイムにおけるサウンド再生ルールや構成が定義されます。

WAAPIは階層を操作するためのクライアントサーバAPIであり、オブジェクトの名前変更、削除、プロパティ変更などが可能です。ただしすべてにアクセスできるわけではなく、例えばRTPCやブレンドなどは執筆時点でアクセスすることができませんでしたが、それでもさまざまなことができます。Wwiseプロジェクトの構造を理解し、さまざまなオブジェクトタイプと、それぞれのプロパティを把握することが重要です。私は以下の参照ページを、すぐにアクセスできるようにブックマークしてあります。

ただしWAAPIはオブジェクトプロパティのリストにないオブジェクト情報も提供しています。例えば sound:convertedWemFilePathsound:originalWavFilePath のプロパティや、 AudioSource オブジェクトの TrimEnd プロパティと TrimBegin プロパティの差分値を提供する maxDurationSource プロパティなどです。これらはWwiseオブジェクトリファレンスのオブジェクトプロパティとして掲載されていませんが、WAAPIでクエリが可能です(プロジェクトデータに特別にアクセスできるのかもしれません)。

また上級ユーザ向けに、ワークユニットなどのようなWAAPIやWwise AuthoringデータのJSONおよびXMLスキーマが、 %WWISEROOT%\Authoring\Data\Schemas 下にあります。何かの場面で役立つかもしれません。

 

検討事項

有効なWAAPIスクリプトが長くて複雑であるとは限りません。面倒なタスクを自動化する場合は特にそうです。スクリプトそのもの以外に、できれば以下の事項も検討してください。

  • スクリプトの実行方法。
  • チームメンバーへの配布方法。

これらについて私たちが採用した方法の1つがTotal Commanderのカスタムビルドです。SVN下に保存されていますが、オーディオ関連の技術的なニーズに幅広く対応する多数のポータブルユーティリティが含まれ、アプリケーション上部のメニューまたはサブメニューバーからアクセスできます。またプロジェクト専用スクリプトやWAAPIスクリプトなど、私がパッケージのインストールと管理を行っている組み込みPython配布も含まれています。これでチームメンバーはツールを実行するためにコンピュータを構成する必要がなく、私は簡単に環境全体をアップデートして全員の同期を行えます。ちなみにTotal Commanderは最高です!

私は時々すべてのPythonスクリプトを Scripts ディレクトリ下に置き、Wwiseプロジェクトにそのまま入れることもあります。この場合はもちろんスクリプトの対象が特定のプロジェクトとなります。

Total Commanderのボタンを使う代わりに、WAAPIスクリプトを コマンドアドオン 経由で呼び出す方法も好きです。任意の外部ツールをプロジェクト専用の引数(選択したオブジェクトのGUIDやWwiseプロジェクトへのパスなど)で実行するコマンドをAuthoring Tool内で定義します。これらのコマンドは、Authoring Toolのメニューに組み込むことができ、Wwise関連のカスタムツールを使う時に一番邪魔にならない方法だと思います。

 

ワークフローの概要

この記事の目的のためにシンプルにWAAPIスクリプトの実行をコマンドアドオン経由のみとし、Pythonファイル自体はScriptsディレクトリ下に置きます。ユーザが自分のPC環境を構成することを前提としたセットアップですが、手順は 付録 をご参照ください。

追加要件としてすべてのエラーをGUIに表示させますが、その理由は私の経験上、サウンドデザイナーが見つけた問題に対応する時に、本人にコンソールウィンドウで探してもらうよりこちらの方が簡単だからです。

Pythonプロジェクトの構成

以下は Scripts フォルダの構造のスクリーンショットです。

root5

重要なポイント:

  • Wwiseからコマンドアドオンで呼び出すスクリプトのみがルートディレクトリに含まれるため、これらのスクリプトをほかのPythonファイルがインポートするべきではありません。このルールには2つの例外があります。
    • __init__.pyScriptsフォルダを、モジュールとしてマークするファイル。現在のワーキングディレクトリがWwiseプロジェクトフォルダに設定されている場合、スクリプトで相対インポートを使用できます。
    • _template.py — 新しいスクリプトを作成するためのテンプレートファイル。ボイラープレートが含まれ、具体的なニーズに合わせて複製、名前変更、修正できます。
  • サブモジュールには、自明なサイズを超えるコードや、スクリプト全体で再利用される関数が含まれています。
  • Pythonインタプリタにスクリプトのパスを引数として渡し、スクリプトを実行します。モジュール名(フラグ -m )でスクリプトを実行する方が望ましいのですが、執筆時点ではカレントワーキングディレクトリを ${WwiseProjectRoot} に設定した時にコマンドアドオンが機能しないため、ここでは相対インポートを使いません。 1
  • 規則に従いスクリプトがエラーに遭遇した時や無効状態のデータを検出した場合は RuntimeError 例外を発生(raise)させるはずです。このような例外は最も外側のフレームで受け取り、そのメッセージはダイアログウィンドウに表示されます。

以下はPythonコードのテンプレートです。長いようですがエラーを処理してGUIに表示させ、さらに共通インポートを提供していることを忘れないでください。新しいスクリプトを書く時はこのテンプレートをコピー&ペーストするだけです。

# このファイルがインポートされるのを防ぎます
if __name__ != '__main__':
    print(f'error: {__file__} をインポートしないこと。スクリプト中断')
    exit(1)

# tkinterはアラートダイアログなどの単純なUIを表示するために使います

import tkinter
from tkinter.messagebox import showinfo, showerror
from waapi import WaapiClient, CannotConnectToWaapiException
from waapi_helpers import *
from helpers import *

# あなたのスクリプトで使用しないインポートを削除したり、新しく追加したりできます

# Tkウィジェットを初期化し、アイコンがタスクバーに表示されないようにします
tk = tkinter.Tk()
tk.withdraw()

# このtry-exceptブロックの目的は、ランタイムエラーをキャッチし
# コンソールではなくGUIウィンドウでユーザに表示することです
try:
    with WaapiClient() as client:
        # スクリプトはここからはじまります
        pass

except CannotConnectToWaapiException:
    # 実行中のWwise Applicationへの接続に失敗したことを伝える、分かりやすいメッセージを表示します
    showerror('エラー', 'WAAPI接続を確立できませんでした。Wwise Authoring Toolは稼働中ですか?')
except RuntimeError as e:
    # 予期できるエラー、つまりスクリプトから直接発生したエラー
    showerror('エラー', f'{e}')
except Exception as e:
    # 予期せぬエラーは必ずスタックトレースを表示します
    import traceback

    showerror('エラー', f'{e}\n\n{traceback.format_exc()}')
finally:
    # Tkウィンドウイベントループを停止させるためにこれを呼び出す必要があります
    # そうしないとスクリプトがここでストールします
    tk.destroy()

コマンドアドオンの設定

コマンドアドオンの構成は 公式ドキュメント にプロセスがかなり詳細に記載されているため、ここでは詳しく述べません。事例をすべて Wwise Project/Add-ons/Commands/ak_blog_addons.json に保存した1つのJSONファイルに入れてあるため、このファイルを確認したい場合は付属のアーカイブをダウンロードしてください( 付録 参照)。

JSONファイルを変更した場合はWwise Authoringツールでコマンドアドオンをリロードする必要があります。これを実行するためのホットキーはありませんが、以下のように検索フィールドで右アングルブラケットとcommandを入力することで検索して実行できます 2

root4

WAAPIスクリプトのデバッグ

WAAPI タブが Logs ウィンドウにありますが、すべてがデフォルトでログに記録されるわけではないため、必要に応じて Logs Settings で追加のログ項目を有効にしてください。さらにコマンドアドオンのJSONの redirectOutputs 設定でWwiseのPythonスクリプトのコンソール出力を強制的に General タブにリダイレクトすることができ、これはデフォルトで無効となります。

root22

waapi_helpersについて 3

私は waapi_helpers の小さなライブラリを作成して、この記事のさまざまな例に使用しています。このライブラリは WaapiClient を引数として受け入れるいくつかの小さなステートレスヘルパーから成り、バニラの waapi-client コードと合わせることができます。すべての関数はプロパティ取得の規則に従い、プロパティが存在しない場合の値は単純明快に None となります。具体的な形式はこれから出てくる各例で示すため、ここでは詳しく説明しません。

 

ここで紹介する例の多くと、私のWAAPIスクリプトの大半において言えることですが、構造が多かれ少なかれ共通していて、Wwiseプロジェクトのウォークスルーで情報を収集し、その情報で何かを行ったり変換したりして、Wwiseプロジェクトに変更を適用します。

例1: 選択したオブジェクトのGUIDをクリップボードにコピーする

意外にも便利なWAAPIを必要としないコマンドで、すでにWwiseに入っています。非常にシンプルなもので、該当するコマンドアドオンJSONをそのままPythonソースと共にここで記載します。

copy_guid.py (以下の通り特別なものではなく、テンプレートの大部分を除いてあります):

if __name__ != '__main__':
    print(f'error: {__file__} should not be imported, aborting script')
    exit(1)

# これはサードパーティlibです

import pyperclip  
# 引数をargvでリストとして返すシンプルな関数です
# 'helpers'サブモジュールがあるため相対インポートを使用していることに注目してください
from helpers import get_selected_guids_list

guids = get_selected_guids_list()
pyperclip.copy(' '.join(guids))

JSON:

{
    "version": 2,
    "commands": [
        {
            "id": "waapi_article.copy_guid",
            "displayName": "Copy GUID",
            "program": "python",
            "startMode": "MultipleSelectionSingleProcessSpaceSeparated",
            "args": "\"${WwiseProjectRoot}/Scripts/copy_guid.py\" ${id}",
            "redirectOutputs": false,
            "contextMenu": {
                "basePath": "WAAPI"
            }
        }
    ]
}

  • id — この新しいコマンドの固有ID。
  • displayName — メニューに表示される、このコマンドのヒューマンリーダブルな名前。
  • program — このコマンドをユーザが実行した時に実行されるプログラム。スクリプトパスがダブルクォーテーションでエスケープ処理されている点が重要であり、このおかげでWwiseはパスを分割せずに1つの引数として扱うことができます。
  • startMode — Wwiseがプログラムを1度呼び出してスペース区切りの引数(ここではオブジェクトのGUID)を渡します。
  • args — 以下の引数をPythonに渡します:
    • ${WwiseProjectRoot}/Scripts/copy_guid.py は、パスに空白文字が含まれる時に1つの引数として扱うためにクォーテーションでエスケープ処理されているスクリプトパス。
    • ${id} は、選択されたオブジェクトのGUIDにWwiseが置き換える特別な引数。
  • redirectOutputs — stdoutをWwiseのLogsウィンドウにリダイレクトし、スクリプトのデバッグを助けます(デフォルトで無効)。
  • contextMenu — Wwiseプロジェクト階層のすべてのオブジェクトのコンテキストメニューにコマンドが表示されるように設定するもので、今回の例は WAAPI というサブグループになります。

コマンドアドオンを更新すると、項目が以下のようにコンテキストメニューに表示されます:

root6

これをクリックすることで、以下のテキストがシステムのクリップボードにコピーされます。

{2E9E3B71-C905-4BB0-9B30-06CFF26E0C5E} {3AD5C9DF-C0B5-4A78-B87A-2EE37D64BFCB} {8F7A715D-5704-4F09-9563-4172E250419B}

例2: すべてのイベント名を表示させる

全く実用的ではありませんが、 walk_wproj 関数を使用して階層全体を横断する方法が分かるコマンドです。ボイラープレートは省略されています。

show_event_names.py:

events = []
with WaapiClient() as client:
    for guid, name in walk_wproj(client,
                                 start_guids_or_paths='\\Events',
                                 properties=['id', 'name'],
                                 types=['Event']):
        events.append(name)
showinfo('Hi tutorial!', '\n'.join(events))

walk_project 関数はパス \Events を始点として階層を上から下へと各オブジェクトのウォークスルーを行い、 idname プロパティを、遭遇する各 Event オブジェクトより取得します。これらの関数が私のライブラリの最初にありますが、それは私がXMLライブラリが提供するようなシンプルでPython的なイテレータベースのインターフェースを求めていたからです。さらにJSONオブジェクトはコマンドによってスキーマが異なり、常にワーキングメモリに入れておくことは難しいため、JSONオブジェクトの作成やアンパッキングは回避したいと考えました。

名前はアレイとしてまとめられイテレーション完了後に一緒に表示されます。全く役に立ちませんが学習用に紹介しました。その結果このように表示されます:

root9

例3: ボリュームフェーダをリセットする

ミキシングの時などに、階層の特定部分のすべてのフェーダをゼロに設定したい場合があります。以下のスクリプトを使うことで、選択したオブジェクトの子のうち、notesに @ignore タグが付けられていないすべての子に対してこの処理を適用できます。

reset_faders.py:

with WaapiClient() as client:
    num_reset_faders = 0
    selected_guid = get_selected_guid()

    for obj_id, obj_type, obj_notes in walk_wproj(client, selected_guid,

                                                  properties=['id', 'type', 'notes']):
        if '@ignore' in obj_notes:
            continue

        # どのプロパティを変更するのかを
        # Actor-MixerとMaster Mixerのどちらの階層に属するオブジェクトなのかによって決めます
        prop_name = 'Volume'
        if obj_type == 'Bus' or obj_type == 'AuxBus':
            prop_name = 'BusVolume'

        cur_volume = get_property_value(client, obj_id, prop_name)
        if cur_volume is not None:
            # 規則に従い、プロパティが存在しない場合は
            # `get_property_value`でNoneを返し、これにより
            # `set_property_value`の呼び出しを
            # オブジェクトのボリュームプロパティがない場合にスキップします
            set_property_value(client, obj_id, prop_name, 0)
            num_reset_faders += 1

    showinfo('Info', f'{num_reset_faders} faders were reset')

結果:

root10

root11

root12

例4: 無効なイベントを削除する

Actor-Mixer階層から多数のレガシーオブジェクトを削除したとします。これにより無効なリファレンスを持つイベントアクションが多数残される可能性があり、イベントによっては存在しないオブジェクトを参照するアクションだけととなり、無意味になっているかもしれません。このようなイベントは削除しても問題がないはずです。そこでWAAPIですべてのイベントのウォークスルーを行い、存在しないオブジェクトはかりを参照するアクションのイベントかどうかを確認します。該当するイベントは削除するマークを付けます。

delete_invalid_events.py:

# オブジェクトを参照するアクションタイプ識別子のセット
# 詳細はActionオブジェクトリファレンスを確認してください
action_types_to_check = {1, 2, 7, 9, 34, 37, 41}
events_to_delete = []

with WaapiClient() as client:

    num_obj_visited = 0
    for event_guid, in walk_wproj(client, '\\Events', properties=['id'], types=['Event']):
        print(f'Visited: {num_obj_visited}, To delete: {len(events_to_delete)}', end='\r')
        num_valid_actions = 0
        for action_id, action_type, target in walk_wproj(client, event_guid,
                                                         properties=['id', 'ActionType', 'Target'],
                                                         types=['Action']):
            if action_type in action_types_to_check:
                if does_object_exist(client, target['id']):
                    num_valid_actions += 1
            else:
                num_valid_actions += 1

        if num_valid_actions == 0:
            events_to_delete.append(event_guid)

    num_events_to_delete = len(events_to_delete)
    if num_events_to_delete > 0 \
            and askyesno('Confirm', f'{num_events_to_delete} events are going to be deleted. Proceed?'):
        begin_undo_group(client)
        for event_guid in events_to_delete:
            delete_object(client, event_guid)
        end_undo_group(client, 'Delete Invalid Events')  # Wwise規則に従い頭文字を大文字とします

showinfo('Success', f'{len(events_to_delete)} were deleted')

このコマンドはより複雑です。
最初の違いとしてオブジェクトを参照する可能性のあるアクションタイプのセット( PlayStopSet RTPC など)を保存することに着目してください。存在しないオブジェクトを参照するアクションはすべて無効とみなします。このセット内容は Action オブジェクトドキュメント( ActionType プロパティの説明を参照)に基づき手作業で作成しました。

2点目の違いは、ユーザに破壊的アクションを実行する許可を求める点です。一定数のイベントを削除してもよいかどうか、確認するダイアログが表示されます。

最後の違いとして、このコマンドはWAAPIの undo group 機能を使用します。複数のWAAPIコールを通して行った変更内容を1回のundo(元に戻す)アクションで取り消すことができ、このアクション自体に特別な名前があり、 Edit メニューでDelete Invalid Eventsとして表示されます。

関連するスクリーンショット:

root15

root13

root14

例5: 長いSFX用にストリーミングを設定する

ストリーミングの使用でSFXを一括構成することは、音源をデザイナーがトリムしてWaveファイルの長さとランタイムのSFXの時間に大きな差が出る可能性があるため、自明でないといえます。トリムされたSFXの長さを Wwise QueriesWAQL 経由で取得することは、執筆時点では不可能です。

幸いWAAPIに trimmedDuration プロパティがあります。さらに踏み込み、ツールで設定するストリーミングパラメータをWwise Authoringツールで設定できるようにします。この例では構成(コンフィギュレーション)を \Actor-Mixer Hierarchy\Default Work Unit のnotes部分に入れ、Pythonにすでに組み込まれている非常にシンプルな configparser 構文を使用します。例えば以下のようなconfigとなるとします:

[Enable_Streaming_For_SFX]
If_Longer_Than = 10
Non_Cachable = no
Zero_Latency = no
Prefetch_Length_Ms = 400

より大きなプロジェクトではサウンドの種類やプラットフォーム別に、さらに細かい制御が求められるかもしれません。便利なことにスクリプトを使い構成部分を追加し、具体的なタスクごとに必要なものを取得することができます。

set_streaming_for_long_sfx.py:

with WaapiClient() as client:
    dwu_notes = get_property_value(
        client, '\\Actor-Mixer Hierarchy\\Default Work Unit', 'notes')
    if dwu_notes is None:
        raise RuntimeError('Could not fetch notes from Default Work Unit')

    config = configparser.ConfigParser()
    config.read_string(dwu_notes)
    if 'Enable_Streaming_For_SFX' not in config:
        raise RuntimeError('Could not find [Enable_Streaming_For_SFX] config section')

    stream_config = config['Enable_Streaming_For_SFX']

    objects_to_modify = []
    for guid, name, max_dur_src in walk_wproj(client, '\\Actor-Mixer Hierarchy',
                                              ['id', 'name', 'maxDurationSource'], 'Sound'):
        if max_dur_src is None:
            continue
        # trimmedDurationの単位はミリ秒ではなく秒です
        is_long_sound = max_dur_src['trimmedDuration'] > stream_config.getfloat('If_Longer_Than')
        if is_long_sound:
            objects_to_modify.append(guid)
            print(name)
            break

    if len(objects_to_modify) > 0 and \
            askyesno('Confirm',
                     f'The tool is about to modify properties of {len(objects_to_modify)} objects. Proceed?'):
        begin_undo_group(client)
        for guid in objects_to_modify:
            set_property_value(client, guid, 'IsStreamingEnabled', True)
            set_property_value(client, guid, 'IsNonCachable', True)  # stream_config.getboolean('Non_Cachable'))
            set_property_value(client, guid, 'IsZeroLantency', stream_config.getboolean('Zero_Latency'))
            set_property_value(client, guid, 'PreFetchLength', stream_config.getint('Prefetch_Length_Ms'))
        end_undo_group(client, 'Bulk Set SFX Streaming')
        showinfo('Success', f'{len(objects_to_modify)} objects were updated')
    else:
        showinfo('Success', f'No changes have been made')

構成はこのようになります:

root16

例6: コンテナのグループをスイッチにリファクタリングする

Actor-Mixer階層の複数のコンテナを、1つのスイッチコンテナにリファクタリングしなければならない場合があります。これを自動化するツールをつくります。想定されるユーザストーリーは、いくつかのオブジェクトを選択してボタンをクリックした時に、新しいスイッチコンテナが表示され、選択したオブジェクトがスイッチにアサインされているというものです。この例ではWwise Adventure Gameプロジェクトの Surface_Type スイッチを使います。

refactor_into_switch_surface_type.py:

with WaapiClient() as client:
    obj_names = [get_name_of_guid(client, guid)
                 for guid in selected_guids]
    if None in obj_names:
        raise RuntimeError('Could not get names of all selected objects')
   
    switches = get_switches_for_group_surface_type(client)
    if len(switches) == 0:
        raise RuntimeError("Could not find switches for group 'Surface_Type'")
    parent_obj = get_parent_guid(client, selected_guids[0])
    if parent_obj is None:
        raise RuntimeError(f'{selected_guids[0]} has no parent')
   
    begin_undo_group(client)
   
    switch_obj = create_objects(client, parent_obj, 'RENAME_ME', 'SwitchContainer')[0]
    if switch_obj is not None:
        set_reference(client, switch_obj, 'SwitchGroupOrStateGroup',
                      f'SwitchGroup:{SURFACE_TYPE_SWITCH_GROUP_NAME}')
    else:
        # 操作途中でスクリプトが失敗した場合は変更をロールバックします
        end_undo_group(client, 'Refactor Into Surface_Type Switch')
        perform_undo(client)
        raise RuntimeError('Could not create switch container under ' +
                           f'{get_name_of_guid(client, parent_obj)}. '
                           'All changes have been reverted.')
   
    # 選択したオブジェクトの親を設定しなおします
    for guid in selected_guids:
        res = move_object(client, guid, switch_obj)
        if res is None:
            end_undo_group(client, 'Refactor Into Surface_Type Switch')
            perform_undo(client)
            raise RuntimeError(
                f'Could not move object {guid} to parent {switch_obj}. '
                'All changes have been reverted.')
   
    obj_assignments = infer_obj_assignments(selected_guids, switches)
   
    for obj_guid, sw_guid in obj_assignments:
        client.call('ak.wwise.core.switchContainer.addAssignment',
                    {'child': obj_guid, 'stateOrSwitch': sw_guid})
   
    end_undo_group(client, 'Refactor Into Surface_Type Switch')

このコードはこれまでの例よりも少し複雑です。リストを短くするために2つの関数にリファクタリングした箇所もあります。1つ目の関数である get_switches_for_group_surface_type はすべての Surface_Type スイッチのGUIDと名前を取得するためのヘルパーです。2つ目の関数である infer_obj_assignments が、選択したオブジェクトとスイッチをマッチングさせるために名前をペアワイズ法で比較し、最も近いスイッチ名を選択します( thefuzz ライブラリの partial_ratio 関数)。

その他の留意事項:

  • コードの実行過程でさまざまなデータ検証が行われ、ステートが無効な場合は RuntimeError 例外を発生させます。このような例外はユーザにエラーウィンドウとして表示されます。
  • 一部のエラーパスでは例外を発生させる直前に、スクリプトでundo操作を実行し、それまでのWAAPI経由のすべての変更をロールバックします。
  • ヘルパーライブラリにスイッチコンテナをアサインする関数がないため、 waapi-client を直接呼び出していることが分かります。
  • このスクリプトは完璧からはほど遠く、変わったエッジケースが含まれている可能性が高く、一切最適化されていません。この記事を書いている時に夜に楽しみながらやってみただけです。ご了承ください。

以下のスクリーンショットを見るとしくみが分かります。

root17


root18

例7: Originalsフォルダにある不要なWaveファイルを削除する

時間の経過とともにWwiseプロジェクトの Originals フォルダに蓄積されていくWaveファイルの中には、全く参照されずディスクスペースを無駄にしているものもあります。シンプルなユーザストーリーが考えられます。ボタンを押すとスクリプトから確認を求められ、続いてすべてが削除されたという通知、あるいは一部が削除されなかった(例えば試聴中で開いたままになっていたなどの理由でファイルがロックされている)という通知が出るというものです。ヒント: このような操作の後は Integrity Report を実行してください。

remove_unused_wavs.py:

with WaapiClient() as client:
    default_wu_path, = get_object(client, '\\Actor-Mixer Hierarchy\\Default Work Unit', 'filePath')
    # この関数は.wprojファイルを解析し、'Originals'ディレクトリの場所を見つけます
    origs_dir = find_originals_dir(default_wu_path)

    wavs_in_origs = set()

    wavs_in_wproj = set()

    # 'Plugins'ディレクトリは変更しないようにします
    for subdir in 'SFX', 'Voices':
        for wav_path in glob(os.path.join(origs_dir, subdir, '**', '*.wav'),
                             recursive=True):
            wavs_in_origs.add(normalize_path(wav_path))

    # 1つのwalk_wprojで1つの階層を
    # 複数の場所から複数回、横断できます
    for guid, wav_path in walk_wproj(
            client,
            start_guids_or_paths=['\\Actor-Mixer Hierarchy', '\\Interactive Music Hierarchy'],
            properties=['id', 'originalWavFilePath'],
            types=['AudioFileSource']
    ):
        wavs_in_wproj.add(normalize_path(wav_path))

    wavs_to_remove = wavs_in_origs.difference(wavs_in_wproj)
    files_left = len(wavs_to_remove)

    if files_left > 0 and askyesno(
            'Confirm', f'You are about to delete {files_left} files. Proceed?'):
        for wav_path in wavs_to_remove:
            try:
                os.remove(wav_path)
                files_left -= 1
            except PermissionError:
                pass

        if files_left == 0:
            showinfo('Success',
                     f'{len(wavs_to_remove)} files were deleted')
        else:
            showwarning('Warning',
                        f'{files_left} files could not have been deleted. '
                        f'Are they open in some apps?')

このコードは前の例よりもシンプルに見えますが、WAAPIの制限がいくつかあり、 Originals フォルダへのパスを取得するためにWwiseプロジェクトファイルを解析する必要があります。最初はこの情報が Project オブジェクト内に格納されていると考えましたが、そうではなく、ほかに探す方法が見つかりませんでした。

もう1つ分かりにくいことは walk_wproj にタイプ AudioFileSource のオブジェクトをすべて取得するよう求めていますが、実はこのタイプが Wwiseオブジェクトのリファレンス ページに記載されていないのです。最も近いタイプが AudioSource で、親のタイプかもしれませんが確認できませんでした。私がこれを試したのは、以前にWork Unit XMLの解析と生成に取り組んでいたことがあり、Waveファイルが <AudioFileSource/> タグに囲まれていることを知っていいたためで、直観的にこれを選びました。このタイプは、 %WWISEROOT%\Authoring\Data\Schemas のXMLスキーマファイルに記載されています。

スクリーンショット:

root20

root21

例8: Waveファイルのインポートの自動化

多くの場合ゲーム内のサウンドは非自明の階層で表され、さまざまなバスにルーティングされたり、さまざまなRTPCで制御されたりする複数のレイヤーやコンポーネントから構成されています。このようなシステムは特定の音を作成するために、オーディオアセットを取り付けるスロット(穴)のような場所がある階層で形成されていることがあります。作業をする上では既存の階層を複製して適切なスロットのアセットを入れ替えているように見えるかもしれません。

これはもちろんWAAPIでも行うことができ、シンプルにするため、このワークフローのユーザストーリーを1つだけ実装します。

  • ユーザはテンプレートとなる階層を構成し、各部のnotesを使いテンプレート名、スロット、スロット名などを指定します。下図ではテンプレートの Gun に、 ShotTail_IndoorTail_Outdoor のスロットがあります。

    root23
  • ユーザがテンプレートを右クリックし、ボタンを押すと、名前がフォーマット化されたWaveファイルのディレクトリを選択するよう促されます。選択するとツールがディレクトリをスキャンし、この特定テンプレートの名前に合致するファイルを見つけます。

    root27
    root26
    root24

  • ユーザがインポートを承認するとテンプレートがコピーされて名前が変更され、スロットにこれらのWaveファイルが入ります。

    root25

スクリーンショットではM4とM1911という2つのサウンドオブジェクトを、ツールが1回で実装させました。さらにテンプレートオブジェクトのGUIDが各Gunの仮想フォルダのnotesに記録されるため、必要に応じてほかのスクリプトでこれらをスキャンすることができます。

実際に機能する例を見るには import_wavs.py ソースを確認してください。ほかよりもやや複雑なコードであるため、すべてをここで記載することはできません。

 

結論

この記事ではWAAPIやPythonで作業する時に使用するワークフローを紹介し、コードを整理する方法や、オーディオチームとスクリプトを共有する方法などをお見せしました。考え方を裏付けるためにワークフローを実施するツール例もいくつか提示しました。ご質問やご意見などがある場合は、ぜひお気軽にご連絡いただくか、ブログ下のコメント欄に投稿してください。

以下のように、改善の余地がある箇所は(複数)あります:

  • allow_exception=True の使用を WaapiClient のインスタンス化にご検討ください。WAAPI独自の例外を把握し、例えばWwiseでダイアログウィンドウが開いているなどの、より分かりやすいエラーメッセージをユーザに提示できます。
  • 私たちのPythonプロジェクトの規則として、すべてのコマンドアドオンのスクリプトを Scripts フォルダの直下に置きました。これを活用してアドオンJSONファイルを自動生成することができます。
  • notes 部分の構成の保存を改善するために、Jekyllなどのウェブサイト作成フレームワークで広く利用されている YAML front matter のような方法で分割することができます。そうすることで人間が作成した通常のnotesと共存させることができます。
  • 例4で無効なイベントを削除しましたが、無効なアクションがプロジェクトに手付かずで今も残されていて、 Integrity Report でエラーを発生させる可能性があるので、同様に削除することが合理的かもしれません。
  • スイッチのリファクタリングコマンドで、スイッチに最適な名前を付けるアルゴリズムがあると便利です。例えば選択中の全オブジェクトの共通サブストリングを取得したり、AIにテキストを要約させたり。

 

謝辞

この記事は2021年秋のDevGAMMにおける私の短い講演 4 に基づいています。紹介したアイデアはNetEase Gamesのテクニカルオーディオチームで働いている時に、Dmitry Patrakov、 Ruslan Nesteruk、Victor Ermakov(アルファベット順)と一緒に考案したものです。DevGAMMの私のスライドに対する意見とWaveファイルのインポーターのアイデアを提供してくれたダミアン・キャストバウアー氏、ピアレビューを行いスクリプトのデバッグ部分を含めることを提案してくれたベルナール・ロドリグ氏、校正を行い掲載手続きを支援してくれたMasha Litvinava氏、スイッチコンテナの例を提案してくれたTyoma Makeev氏、これらの内容をAudiokineticのブログとして実際に公開することを後押ししてくれたDenis Zlobin氏に特に感謝を申し上げます。

付録: 事例の実行

ここで記載した例はすべてこの記事の添付資料で、 こちらからダウンロード できます。ダウンロードしたコンテンツは任意のWwiseプロジェクトでアンパックするとすぐに利用できますが、私はこれらの例をWwise Adventure Gameプロジェクトを使いながら作成しました。

その他必要なもの:

  • Git
  • Python 3 。最近のバージョンであればどれでも問題ありませんが、インストール時にPython executableをPATHに追加するチェックボックスと、 piptcl/Tk コンポーネントをインストールするチェックボックスを必ずチェックしてください
  • Wwise 2021と、この記事通りに進めるためにはWwise Adventure Gameをインストールしてください
  • Wwiseのプロジェクト設定で必ずWAAPIを有効にしてください

システムにPythonを入れた上で、以下のPythonパッケージをインストールしてください:

pip install -U pyperclip waapi-client
pip install -U git+https://github.com/ech2/waapi_helpers.git

例6は文字列のファジーマッチングアルゴリズムを使用するため、それを実行する場合は以下のパッケージもインストールしてください:

pip install -U thefuzz python-Levenshtein


  1. これは redirectOutput オプションがカスタム cwd とやり取りする方法に関連するバグとして確認されています。

  2. 私は以前はJSONをアップデートするたびにWwise Authoringツールを再起動していたため、このワザをもっと早く知りたかったです。

  3. この記事がプラグとして認識されないように、 waapi-client の上に自分の書いたラッパーを使用することには、少し不安でした。しかし正直なところ私がPythonでバニラWAAPIを使うことはほとんどなく、一般的なWAAPI機能のJSONスキーマを忘れかけています。読者のみなさんは私のツールを使用するのではなく、疑似コードとして読むことを推奨します。

  4. 講演はロシア語で行いましたが、スライドは英語です。講演は今後YouTubeに投稿されると思います。スライドとコード例はこちらからダウンロードできます: ech2/DevGAMM_2021_Fall 

ユージン・チェルニー

ユージン・チェルニー

オーディオプログラマーで、現在はゲーム業界にて活躍中。芸術とアカデミアの分野での経験を有する。ゲームオーディオをこよなく愛している。

https://eugn.ch/

コメント

Replyを残す

メールアドレスが公開されることはありません。

ほかの記事

なぜ、Wwiseのプラグインを自分で作成すべきか

別題「心配するのをやめて、要件書を好きになるまで」 写真:自宅にて(バーチャルリアリティとコーダーの現実のはざまで迷っている僕) はじめに...

10.10.2017 - 作者 ロバート・バンタン(ROBERT BANTIN)

ImpacterとUnreal ゲームの物理でプラグインImpacterを動かす

はじめに Impacterはインパクト(衝突)をモデル化したWwise向けの新しいプラグインのプロトタイプで、紹介は こちら にあります。今回の記事は、 Unreal...

20.5.2021 - 作者 ショーン・ソラハン(SEAN SORAGHAN)

日常生活のなかで、Wwiseでインタラクティブミュージックをつくってみる

3.11.2021 - 作者 レッサ・シュバルツバルト(Ressa Schwarzwald)

Wwise 2022.1 Unrealインテグレーションの変更

Wwise 2022.1 Unrealインテグレーションについて このバージョンはWwiseのUnrealインテグレーションにとって大きなマイルストーンです。Unreal Engine...

14.3.2023 - 作者 ミシェル・ドネイ(MICHEL DONAIS)

Wwise Spatial Audio 2023.1 最新情報 | Reverb Zone

Reverb Zone入門 Wwise 2023.1ではWwise Spatial AudioにReverb Zoneという新しいツールが追加されました。Reverb...

10.1.2024 - 作者 トーマス・ハンセン

Wwise Unrealインテグレーションの現状

このブログは私が2022年に書いた『Wwise Unrealインテグレーションの改善』に続くものです。前回のブログではWwise...

5.3.2024 - 作者 ギヨーム・ルノー(GUILLAUME RENAUD)

ほかの記事

なぜ、Wwiseのプラグインを自分で作成すべきか

別題「心配するのをやめて、要件書を好きになるまで」 写真:自宅にて(バーチャルリアリティとコーダーの現実のはざまで迷っている僕) はじめに...

ImpacterとUnreal ゲームの物理でプラグインImpacterを動かす

はじめに Impacterはインパクト(衝突)をモデル化したWwise向けの新しいプラグインのプロトタイプで、紹介は こちら にあります。今回の記事は、 Unreal...

日常生活のなかで、Wwiseでインタラクティブミュージックをつくってみる