📹

OSCとSyphon を使った Tauri x TouchDesignerの(ほぼ)リアルタイム連携

2025/03/30に公開

私からのメッセージは Syphon (Spout) はいいぞ! / Tauri最高!! 以上になります。
今日はこれだけ覚えて帰ってください。

ここ数年、歌詞と楽曲をリアルタイムで同期再生するアプリケーションの制作に取り組んでいます。
今回は Tauriで構築した音楽プレイヤーアプリケーションと、TouchDesigner を連携させてみました。

https://x.com/8beeeaaat/status/1905761465208103305

ちなみに上記のサンプル楽曲・歌詞は私がSuno AIで作成したものになります。
(こういったサンプル作成時に気兼ねなく楽曲を利用できるのでSunoは重宝しています)
https://suno.com/song/6bb46d10-8af0-41bf-8da0-d45c6681f8d1

OSC(Open Sound Control)プロトコルを介して TouchDesigner に値を入力して演出描画処理を行わせ、Touch Deisgner からは Syphon を用いてレイテンシー無しにGPUメモリから直接動画フレームを取り出すことで(ほぼ)リアルタイムに連携する映像演出システムを構築することが可能になります。

Syphon は MacOS オンリーですが、Windows には Spout という同様の技術があります。

リアルタイムの画像共有を行うためのフレームワークとしては、Max OSXではSyphon、WindowsではSpoutがデファクトスタンダードになっており、ほとんどのグラフィックス処理を行うアプリケーションで対応しています。

https://cgworld.jp/regular/201908-codelight-unity10.html

システム構成の全体像

以下の主要要素を連携して実現しています

  1. OSC 経由でのデータ送信
    TauriアプリケーションとTouchDesignerを連携するため、OSCプロトコルを活用します。
    フロントエンドから入力した値をRustに渡し、OSC送受信用のcrate rosc を利用して UDP ソケット経由で演出のパラメータとなるデータを送信します。

  2. TouchDesigner による OSC 受信と映像生成
    TouchDesigner 側では、OSCIn DAT を用いて Rust から送られたデータを受信し、映像処理やエフェクト演出を行います。

  3. Syphon + OBS を用いた HTML Video 上でのリアルタイム描画
    TouchDesigner で生成された映像は Syphon Spout Out を用いることでGPUメモリから直接レイテンシー無しに取り出すことが可能になります。
    OBS を用いてGPUメモリから取り出したフレームを仮想Webカメラの映像ストリーム (MediaStream)として取り扱うことで、TauriアプリケーションのWebView上で TouchDesigner で加工した映像をリアルタイムに描画できるようになります。

なんで Syphon を使うの?

デモでお察しの様に、楽曲と映像を同期させる様なアプリケーションにおいては映像と音声のラグは短ければ短いほど嬉しいものです。
せっかく表現力豊かな演出の映像を出力できても楽曲と同期できていなければ魅力ゼロなので...。

当初は NDI を利用しようとしていたのですが、あまりにレイテンシーが大きく使い物になりませんでした。
https://x.com/8beeeaaat/status/1905040781163078023

そこで Syphon を試してみたところ、レイテンシーの小ささに感激した次第です。
https://x.com/8beeeaaat/status/1905590772810088758

各コンポーネントの詳細

1. Rust 側で OSC データ送信の実装

Rust側の実装として、OSCプロトコルを利用して UDP 経由でデータを送信する関数 / invokeコマンドを用意します。
具体的な実装例は以下の通りです:

use rosc::{OscArray, OscMessage, OscPacket, OscType};
use serde_json::Value;
use std::net::UdpSocket;

pub fn send(msg: OscMessage) -> Result<(), String> {
    let packet = OscPacket::Message(msg);
    let buf = rosc::encoder::encode(&packet).unwrap();
    let sock = UdpSocket::bind("0.0.0.0:0").map_err(|e| e.to_string())?;

    // TouchDesigner側の OSCIn DAT 受信ポートと合わせる
    sock.send_to(&buf, "127.0.0.1:8000")
        .map_err(|e| e.to_string())?;
    Ok(())
}

pub fn send_lyric(json_data: &Value) -> Result<(), String> {
    let json_string = json_data.to_string();
    let msg = OscMessage {
        addr: "/lyric".to_string(),
        args: vec![OscType::String(json_string)],
    };
    send(msg)?;
    Ok(())
}

pub fn send_text_color(r: &f32, g: &f32, b: &f32) -> Result<(), String> {
    let msg = OscMessage {
        addr: "/text_color".to_string(),
        args: vec![OscType::Array(OscArray {
            content: vec![OscType::Float(*r), OscType::Float(*g), OscType::Float(*b)],
        })],
    };
    send(msg)?;
    Ok(())
}

フロントエンドから呼び出すための invoke コマンド登録

#[tauri::command]
pub fn send_osc_lyric(json_data: Value) -> Result<(), String> {
    osc::send_lyric(&json_data).map_err(|e| e.to_string())?;
    Ok(())
}

#[tauri::command]
pub fn send_osc_text_color(r: f32, g: f32, b: f32) -> Result<(), String> {
    osc::send_text_color(&r, &g, &b).map_err(|e| e.to_string())?;
    Ok(())
}

tauri::Builder::default()
    ...
    .invoke_handler(tauri::generate_handler![
        invokes::send_osc_lyric,
        invokes::send_osc_text_color,
    ])
    ...

2. フロントエンド側 から invoke で値を送出

フロントエンド側からinvoke登録したコマンド経由でRust側にパラメータを渡して実行します。

import { invoke } from "@tauri-apps/api/core";

export async function sendLyric(params:{
  prev_word: string,
  prev_line: string,
  current_word: string,
  current_line: string,
  next_word: string,
  next_line: string,
}) {
  await invoke("send_osc_lyric", { jsonData: params});
}

export async function sendTextColor(params:{
  r: number,
  g: number,
  b: number,
}) {
  await invoke("send_text_color", { params });
}

3. TouchDesigner での OSC 受信とパース

TouchDesigner 側では、OSCIn DAT を用いて UDP ポート 8000 から送信されたデータを受信します。(ポートは任意で変更できます)
受信した OSC メッセージを、OSCIn Datの Python スクリプトでパースして処理を進めます。
以下は、受信例とパース処理のサンプルコードです。

# TouchDesigner用のOSCIn DAT内スクリプト例
import json

def onReceiveOSC(dat, rowIndex, message, bytes, timeStamp, address, args, peer):
    if address == "/lyric":
        if not args or len(args) == 0:
            print("エラー: lyricの引数が空です")
            return None
           
        # 引数を取得
        lyric_text = str(args[0])
        
        try:
            # JSONをパース
            parsed_data = json.loads(lyric_text)
            
            # 例: パースしたデータを 'table_lyric' Table DATに格納
            if op('table_lyric') != None:
                table = op('table_lyric')
                table.clear()
                # JSONの内容をテーブルの行として追加
                if isinstance(parsed_data, dict):
                    for key, value in parsed_data.items():
                        table.appendRow([key, str(value)])
                
            return parsed_data
            
        except json.JSONDecodeError as e:
            print(f"JSONパースエラー: {e}")
        except Exception as e:
            print(f"エラー発生: {e}")

    # 例: 色データを 'table_text_color' Table DATに格納  
    if address == "/text_color":
        text_color = args[0]
        op("table_text_color")[1,0] = text_color[0]
        op("table_text_color")[1,1] = text_color[1]
        op("table_text_color")[1,2] = text_color[2]
    return

このスクリプトでは、address 毎に処理の振る舞いを変えています。
/lyric アドレスで受信した際にはJSON文字列をパースした後に table_lyric と命名した Table DATへ格納しています。
同様に /text_color アドレスでの受信時は table_text_color と命名した Table DATへ格納しています。

print による標準出力は Textport ダイアログ で確認できるので、プリントデバッグ時に利用してみてください。

4. Syphon と OBS を用いた仮想Webカメラによる映像描画

TouchDesigner で生成された映像フレームは、Syphon を通じてGPUメモリに載せられます。
同一マシン内のアプリケーションはGPUメモリを参照することでリアルタイムに映像データを取り出すことができます。
WebフロントエンドではGPUメモリを直接参照することはできない為、一度映像を MediaStream として識別させる必要があります。
そこで OBS + Syphonプラグインを用いることでGPUメモリからフレームを取り出し、仮想 Web カメラの映像としてシステムに認識させ、Web ブラウザ上の Video Element に取り込める様にします。

const stream = await navigator.mediaDevices.getUserMedia({
  video: { width: 1920, height: 1920 },
});

videoElm.muted = true;
videoElm.playsInline = true;
videoElm.autoplay = true;
videoElm.srcObject = stream; // MediaStream を映像ソースとしてバインド

この仕組みにより、下記のような一連のデータフローが実現されます:

  1. osc => TouchDesigner
    Rust アプリケーションから送られる OSC メッセージが TouchDesigner で受信・パースされる。

  2. TouchDesigner => Syphon
    受信したデータに基づいたリアルタイムの演出映像フレームがGPUメモリに載り、Syphon 経由で映像フレームを取り出せる様になる。

  3. Syphon + OBS => 仮想Webカメラ
    OBSを通じてGPUメモリ上の映像フレームが仮想 Web カメラの映像として識別・受信することが可能になる。

  4. Video Element (Media Stream)
    仮想 Web カメラからの映像データを Media Stream として Tauri WebView上のVideo Element に入力し、インタラクティブな映像体験コンテンツとしてユーザーに提供される。

ぜひ Tauri / Syphon (Spout) を使ってアプリを作ってみてください。

以上、私からのメッセージは Syphonはいいぞ! / Tauri最高!! となります。

ちなみに、フロントエンドに強いエンジニアをお探しの皆さんからのお声がけもお待ちしてます 🥰

Discussion