電話にボイスチェンジャーをのせる: Twilio Voice JS SDK の送信音声差し替えと VST3 適用

電話にボイスチェンジャーをのせる: Twilio Voice JS SDK の送信音声差し替えと VST3 適用

Twilio Voice JS SDK の Audio Processor で送信音声を差し替え、通話へエフェクトを載せる実験を行いました。テストトーンとモックエフェクトで成立性を確認し、最終的にネイティブ VST3 プラグインで処理した音声を送信します。
2025.12.26

はじめに

動画配信でボイスチェンジャーなどのエフェクトを使っていると、電話でも同じエフェクトをかけてエンドユーザーと会話したいと思う瞬間があるのではないでしょうか。本記事では、Twilio Voice JS SDK の送信音声を差し替え、 ネイティブの VST3 プラグインで処理した音声を電話に送れるようにした 実験の記録を紹介します。

twilio-vst-demo-1

Twilio とは

Twilio は API を通じて電話や SMS などのコミュニケーション機能をアプリケーションに組み込めるクラウドサービスです。

Twilio Voice JS SDK は、Web ブラウザや Electron から WebRTC 経由で Twilio に接続し、PSTN (電話網) と音声通話できる SDK です。Device はソフトフォンとして動作し、通話の開始と終了やイベント処理をアプリ側で扱えます。さらに device.audio を通じて、入力デバイスの選択に加え、送信前の音声を加工する Audio Processor API を組み込めます。

VST プラグインとは

VST (Virtual Studio Technology) は、DAW などのホストアプリケーションと、エフェクトやシンセサイザーなどのオーディオプラグインをつなぐための共通インターフェースです。

2025 年 10 月、Steinberg は VST 3.8 SDK を公開すると同時に、ライセンスを MIT ライセンスに変更 しました (参考)。以前は Steinberg 独自ライセンス (+ 場合によっては個別契約) が必要でしたが、現在は MIT ライセンスに従うことで、商用製品への組み込みも含めて自由に利用できる ようになりました。主な条件は、著作権表示とライセンス文を残すことです。詳しくはライセンス本文を確認してください。

vst-virtual-studio-technology-steinbergs-virtual-studio-technology

対象読者

  • 電話音声にネイティブの VST3 プラグインを挿せるようにしたい方
  • Twilio Voice JS SDK や Audio Processor の実装例を知りたい方
  • スタンドアロンアプリで音声通話の PoC を作りたい方

参考

構成

電話の音声を加工する方法を考えると、Twilio Media Streams を使って WebSocket で音声を外へ出し、サーバー側で加工して戻す構成がまず思い浮かびます。

ただしこの構成は、ネットワーク往復が増えやすく、遅延と運用コストが増えます。本実験の狙いは、 電話をかけたときに、こちらの送信音声だけを加工して相手へ届けること です。まずクライアントアプリだけで完結する最短経路を確認したかったため、 Twilio Voice JS SDK の AudioProcessor で送信音声を差し替える方針を採用しました。 (なお、同時通話数 1 の想定。)

Renderer で Twilio Voice JS SDK と WebAudio を扱い、必要に応じて Main 側のネイティブ VST3 ホストへ音声バッファを渡して処理します。Twilio Functions は Access Token 発行と、固定 1 番号への Dial を担当します。

  • Twilio Functions: Access Token を発行し、通話開始時に TwiML で固定番号へ Dial
  • Renderer: 通話の開始と終了、送信音声の生成、音声ソース切り替え、VST 操作 UI を持つ
  • Main: VST3 ホスト機能を持ち、Renderer からの要求に応じてプラグインのロード、処理、UI 表示
  • Native Addon: VST3 SDK を使い、プラグイン実体をロードし process() 相当の処理を呼び出す

Phase 0: Twilio Voice JS SDK で通話を成立させる

Electron アプリから発信し、固定電話番号へ着信できる状態までを作ります。 音声にはマイク入力をそのまま送信します。Phase 1 以降で送信音声を差し替える前に、まずは Twilio 側の設定と Device.connect() が正しく動くことを確認するのが狙いです。ここで通話が成立していれば、後続フェーズで音が出なくなったときも、原因を音声処理側へ切り分けやすくなります。

Access Token 発行用 Twilio Function の用意

Twilio Voice JS SDK は、ブラウザ側に AccessToken を渡して Device を初期化します。実験では Twilio Functions を使い、ブラウザから叩ける token エンドポイントを用意しました。

/token
// /token
const twilio = require('twilio');

exports.handler = function (context, event, callback) {
  const response = new twilio.Response();
  response.appendHeader('Access-Control-Allow-Origin', '*');
  response.appendHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  response.appendHeader('Access-Control-Allow-Headers', 'Content-Type');

  if (event.httpMethod === 'OPTIONS') {
    response.setStatusCode(204);
    return callback(null, response);
  }

  const AccessToken = twilio.jwt.AccessToken;
  const VoiceGrant = AccessToken.VoiceGrant;

  const identity = event.identity || 'electron-vst-client';

  const voiceGrant = new VoiceGrant({
    outgoingApplicationSid: context.TWIML_APP_SID,
    incomingAllow: false,
  });

  const token = new AccessToken(
    context.ACCOUNT_SID,
    context.API_KEY_SID,
    context.API_KEY_SECRET,
    { identity }
  );
  token.addGrant(voiceGrant);

  response.setBody({
    token: token.toJwt(),
    identity,
  });

  callback(null, response);
};

/token Function の Visibility には Public を選択します。

Function Visibility

Twilio Function コンソールの Environment Variables から環境変数を設定します。

変数名
ACCOUNT_SID ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
API_KEY_SID SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
API_KEY_SECRET xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWIML_APP_SID APxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CALLER_ID +81xxxxxxxxxx
DEFAULT_TO_NUMBER +81xxxxxxxxxx
  • API_KEY_SID, API_KEY_SECRET, TWIML_APP_SID はのちの手順で取得します。
  • CALLER_ID は Twilio で購入した発信元番号を指定します。
  • DEFAULT_TO_NUMBER は、クライアントが To を渡さない場合の着信先です。

TwiML を返す Function の用意

Device.connect() は TwiML App の Voice URL を叩きます。そこで Dial を返し、固定電話番号へ転送する形にします。

/voice
// /voice
const Twilio = require('twilio');

exports.handler = function (context, event, callback) {
  const twiml = new Twilio.twiml.VoiceResponse();

  const toNumber = event.To || context.DEFAULT_TO_NUMBER;

  const dial = twiml.dial({
    callerId: context.CALLER_ID,
  });

  dial.number(toNumber);

  return callback(null, twiml);
};

TwiML 設定

次に、TwiML App を作成します。Voice Request URL に、上で用意した voice Function の URL を HTTP POST として設定し、作成した TwiML App の SID を Function の環境変数へ設定します。

TwiML Apps

API Key 作成

API keys & tokens で API Key と Secret を作成し、これも Function の環境変数へ設定します。

create api key

Electron 側の実装

環境変数を次のように設定します。VITE_TWILIO_TOKEN_ENDPOINT は Twilio Functions の /token URL です。

VITE_TWILIO_TOKEN_ENDPOINT=https://xxxxx.twil.io/token

Device 初期化と発信処理

起動時に token を取得して Device を生成し、connect()To を渡して発信します。

src/hooks/useTwilioDevice.ts より抜粋
// token を取得して Device を生成する (抜粋)
const resp = await fetch(TOKEN_ENDPOINT);
const { token } = await resp.json();

const device = new Device(token, {
  logLevel: 'debug',
  codecPreferences: ['opus', 'pcmu'],
  edge: 'tokyo',
});

// 発信する (抜粋)
const call = await deviceRef.current.connect({
  params: { To: phoneNumber },
});

検証手順

ブラウザまたは curltoken URL を叩き、JSON が返ることを確認します。返却値の token は JWT なので長い文字列になります。

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....",
  "identity": "electron-vst-client"
}

次に Electron アプリを起動し、DevTools のコンソールログで Device の初期化と登録が進むことを確認します。logLevel: debug を有効にしているため、登録や接続に関するログが出ます。

この時点で VST3 ホストのネイティブアドオンが未ビルドでも、Phase 0 の通話成立には影響しません。ログにエラーが出る場合がありますが、通話ができていれば Phase 0 としては問題ありません。

Call を実行し、指定した番号に着信することを確認します。

  • 着信先で呼び出し音が鳴ること
  • 応答すると双方向に会話できること
  • Twilio Console の Call Logs に通話が記録されること

Phase 1: テストトーンで送信音声を差し替える

Phase 1 では、マイク入力ではなく 明確に識別できるテストトーン を送信し、送信音声の差し替えが成立していることを確認します。電話回線では音質の期待値が低く、音量差や軽い質感変化は気づきにくいです。テストトーンであれば、電話口でも判別が容易です。

実装方針

Twilio Voice JS SDK は、送信直前の音声に対して Audio Processor を挟めます。ここで WebAudio のパイプラインを組み、最終的に MediaStreamDestinationNode.stream を返すことで、Twilio 側へ渡す送信音声を差し替えます。

このフェーズでは、音声ソースとして次の 2 つを用意します。

  • microphone: マイク入力をそのまま送る
  • tone: OscillatorNode で生成したテストトーンを送る

Audio Processor の実装

src/audio/CustomAudioProcessor.ts を用意し、Twilio の AudioProcessor を実装します。createProcessedStream() 内で WebAudio ノードを生成し、返却ストリームを Twilio へ渡します。

CustomAudioProcessor より抜粋
import type { AudioProcessor } from '@twilio/voice-sdk'

type AudioSourceType = 'microphone' | 'tone'

export class CustomAudioProcessor implements AudioProcessor {
  private audioContext?: AudioContext
  private micSourceNode?: MediaStreamAudioSourceNode
  private destinationNode?: MediaStreamAudioDestinationNode

  private oscillator?: OscillatorNode
  private oscillatorGain?: GainNode

  private sourceType: AudioSourceType = 'microphone'

  async createProcessedStream(stream: MediaStream): Promise<MediaStream> {
    this.audioContext = new AudioContext({ sampleRate: 48000 })

    this.micSourceNode = this.audioContext.createMediaStreamSource(stream)
    this.destinationNode = this.audioContext.createMediaStreamDestination()

    this.oscillatorGain = this.audioContext.createGain()
    this.oscillatorGain.gain.value = 0.1

    this.updateAudioRouting()
    return this.destinationNode.stream
  }

  setSourceType(type: AudioSourceType) {
    this.sourceType = type
    this.updateAudioRouting()
  }

  startTone(freqHz: number) {
    if (!this.audioContext) return
    this.stopTone()

    this.oscillator = this.audioContext.createOscillator()
    this.oscillator.type = 'sine'
    this.oscillator.frequency.value = freqHz
    this.oscillator.connect(this.oscillatorGain!)
    this.oscillator.start()
  }

  stopTone() {
    if (this.oscillator) {
      this.oscillator.stop()
      this.oscillator.disconnect()
      this.oscillator = undefined
    }
  }

  private updateAudioRouting() {
    if (!this.destinationNode) return

    // いったん全接続を外す
    this.micSourceNode?.disconnect()
    this.oscillatorGain?.disconnect()

    // 選択中のソースだけを destination へつなぐ
    if (this.sourceType === 'microphone') {
      this.micSourceNode?.connect(this.destinationNode)
    } else {
      this.oscillatorGain?.connect(this.destinationNode)
    }
  }
}

Device への組み込み

Phase 0 で作成した Device に対して、Audio Processor を登録します。device.audio.addProcessor() を呼ぶタイミングは、Device 生成後で問題ありません。

useTwilioDevice での組み込み例
import { Device } from '@twilio/voice-sdk'
import { CustomAudioProcessor } from '../audio/CustomAudioProcessor'

const processor = new CustomAudioProcessor()
device.audio.addProcessor(processor)

// UI の操作に応じて切り替える
processor.setSourceType('tone')
processor.startTone(440)

検証手順

Phase 1 の検証では、電話口で確実に差が分かるように、次の順で確認します。

  1. Phase 0 と同じ手順で発信し、相手が応答できる
  2. 通話中に送信音声ソースを tone に切り替え、440 Hz 程度のテストトーンを開始
  3. 相手側で、声ではなく連続したビープ音が聞こえる
  4. microphone に戻し、再び声が届く

Phase 2: マイク入力を「モック VST エフェクト」で加工して送信する

Phase 1 ではテストトーンで「送信音声を差し替えられる」ことを確認しました。Phase 2 ではもう一段進めて、マイク入力を加工して送るところまでを固めます。電話回線では帯域や圧縮の影響で、軽い EQ や薄いリバーブは差が分かりにくいことがあります。そこで本フェーズでは、電話口でも変化が分かりやすい リングモジュレーター (声がロボ声っぽくなる強めの加工) を、WebAudio だけで実装しモック VST として挿し込みます。

実装方針

  • 送信音声の起点は Phase 1 と同じく AudioProcessor.createProcessedStream()
  • マイク入力 MediaStreamMediaStreamAudioSourceNode に変換し、WebAudio のノード列へ通す
  • 最後に MediaStreamDestinationNode.stream を Twilio に返し、送信音声として採用させる

このフェーズで用意する送信経路は次の 2 系統です。

  • microphone: マイク入力をそのまま送る
  • vst-processed: マイク入力をモック VST (WebAudio) で加工して送る

モック VST (WebAudio のリングモジュレーター) を作る

エフェクトを「入出力ノードを持つ部品」として切り出しておくと、後の Phase 3 で 本物の VST3 に差し替えやすくなります。Phase 2 では、入力に対して別の波形で振幅変調をかける仮実装を差し込みます。WebAudio に Audio 信号どうしの乗算ノードはありませんが、GainNode.gain をオーディオレートで揺らすことで近いことができます。

src/audio/MockVstEffect.ts より抜粋
export class MockVstEffect {
  constructor(
    private carrierFreqHz = 90,
    private depth = 0.8,
    private outputGain = 1.0
  ) {}

  createNodes(ctx: AudioContext) {
    const input = ctx.createGain()
    const output = ctx.createGain()

    // carrier (変調用の波形) 
    const carrier = ctx.createOscillator()
    carrier.type = 'sine'
    carrier.frequency.value = this.carrierFreqHz

    // (1 + depth * carrier) を作って Gain に入れる
    const depthGain = ctx.createGain()
    depthGain.gain.value = this.depth

    const constant = ctx.createConstantSource()
    constant.offset.value = 1.0

    const modulator = ctx.createGain()

    carrier.connect(depthGain)
    depthGain.connect(modulator.gain)
    constant.connect(modulator.gain)

    // 入力に変調をかけて出力へ
    input.connect(modulator)
    modulator.connect(output)

    const outGain = ctx.createGain()
    outGain.gain.value = this.outputGain
    output.connect(outGain)

    carrier.start()
    constant.start()

    return { input, output: outGain, carrier, constant }
  }
}

CustomAudioProcessor に組み込む

src/audio/CustomAudioProcessor.ts 側で、sourceType === 'vst-processed' のときに「マイク入力 → モック VST → destination」の順で接続します。Twilio Voice JS SDK の音声設定は device.audio 経由でも扱えるため、「送る前に加工したい」という今回の目的と相性が良いです。接続の切り替え時に必ず disconnect してから connect する点、エフェクトを乗せるときは、ブラウザの音声処理 (AEC/NS/AGC) が邪魔になることがあるので、必要に応じて無効化する点に注意します。

CustomAudioProcessor.ts より抜粋
// sourceType === 'vst-processed' のときだけ「マイク -> MockVstEffect -> destination」に差し替える例

import { MockVstEffect } from './MockVstEffect'

type AudioSourceType = 'microphone' | 'tone' | 'vst-processed'

export class CustomAudioProcessor {
  private audioContext?: AudioContext
  private micSourceNode?: MediaStreamAudioSourceNode
  private destinationNode?: MediaStreamAudioDestinationNode

  private sourceType: AudioSourceType = 'microphone'

  private mock?: { input: GainNode; output: GainNode }
  private mockEffect = new MockVstEffect()

  async createProcessedStream(stream: MediaStream): Promise<MediaStream> {
    this.audioContext = new AudioContext({ sampleRate: 48000 })
    this.micSourceNode = this.audioContext.createMediaStreamSource(stream)
    this.destinationNode = this.audioContext.createMediaStreamDestination()

    // Mock VST を部品として初期化しておく
    const { input, output } = this.mockEffect.createNodes(this.audioContext)
    this.mock = { input, output }

    this.updateAudioRouting()
    return this.destinationNode.stream
  }

  setSourceType(type: AudioSourceType) {
    this.sourceType = type
    this.updateAudioRouting()
  }

  private updateAudioRouting() {
    if (!this.destinationNode) return

    // いったん全接続を外す
    this.micSourceNode?.disconnect()
    this.mock?.input.disconnect()
    this.mock?.output.disconnect()

    if (this.sourceType === 'vst-processed') {
      // マイク -> MockVst -> destination
      this.micSourceNode?.connect(this.mock!.input)
      this.mock!.output.connect(this.destinationNode)
    } else {
      // マイク -> destination (または tone は別分岐)
      this.micSourceNode?.connect(this.destinationNode)
    }
  }
}

検証手順

  1. Phase 0 と同じ手順で発信し、通話が成立する
  2. 通話中に sourceType を vst-processed に切り替える
  3. 相手側で、声質が明確に変わる (ロボ声・金属的)
  4. microphone に戻し、素の声に戻る

Phase 3: ネイティブ VST3 プラグインで加工して送信する

Phase 2 では WebAudio だけで「加工した音声を送れる」ことを確認しました。Phase 3 では、Electron Main 側に用意したネイティブ VST3 ホストを使い、実際の VST3 プラグインで処理した音声を送信します。Renderer は WebAudio (AudioWorklet) で 48 kHz の音声ブロックを扱います。Worklet から直接 IPC はできないため、Worklet → Renderer メインスレッド → Electron IPC → ネイティブ処理、という経路で処理結果を戻します。

構成

ネイティブ VST3 ホスト (Native Addon) の役割

Native Addon は VST3 SDK を使って VST3 プラグインをロードし、入力波形 (Float32Array) を渡して処理結果を返します。さらに、プラグインが Editor (UI) を持つ場合は、別ウィンドウとして表示します。

本実験では、概ね次の I/F を用意します。

  • loadPlugin(path): VST3 をロード
  • unloadPlugin(): アンロード
  • process(input): 音声処理
  • showUI(): プラグイン UI を表示
  • hideUI(): UI を閉じる

Renderer で AudioWorklet を使う

VST3 処理はネイティブ側で実行したい一方で、WebAudio の処理はリアルタイム性が高く、メインスレッドの負荷や GC の影響を受けにくい場所で動かしたくなります。そこで Phase 3 では、次の分担にします。

  • AudioWorkletProcessor: 入力ブロックを受け取り、Renderer メインスレッドへ処理要求を投げる
  • Renderer メインスレッド: Electron IPC 経由でネイティブ処理を実行し、結果を Worklet に返す

AudioWorklet 側は「順序付きでブロックを流す」ことが重要なので、簡単なシーケンス番号を付けて往復させます。

CustomAudioProcessor のルーティング

Phase 2 と同様に AudioProcessor.createProcessedStream() 内でパイプラインを組み、sourceType がネイティブ処理のときだけ Worklet を経由するようにします。

  • microphone: マイク入力をそのまま destination へ
  • vst-processed: マイク入力を Worklet へ → destination へ

MediaStreamDestinationNode.stream を Twilio に返す点は Phase 1/2 と同じです。

検証手順

  1. Phase 0 と同じ手順で発信し、通話が成立する
  2. VST3 プラグインをロードする (検証では AGain AGain ビルドについてはこちら)
  3. sourceType を vst-processed に切り替える
  4. 相手側で、声質が明確に変化する
  5. bypass 有無で効果が変わる
  6. microphone に戻すと、素の声に戻る

twilio-vst-demo-1

考察

遅延のボトルネックと今回の構成の意味

通話全体の遅延は、クライアント側の音声処理よりも、電話による音声通話そのものの遅延に強く支配されると感じました。筆者の体感では、VST の有無による遅延差はほとんど意識できず、ボトルネックにはなりませんでした。これは、通話で体感しやすい遅延が、そもそもネットワークや回線側で大きめに発生しているためだと推測しています。

また、Twilio Media Streams 版は今回実装していませんが、WebSocket 往復が増える構成は、遅延と運用の両面でコストが上がりやすいはずです。今回のように Voice JS SDK の Audio Processor で送信直前に差し替える方法では、体感として通常の電話と遅延感が大きく変わりませんでした。

電話回線の音質と、載せるべきエフェクトの方向性

VST の効きを検証するために、カスタマイズした AGain を使って変化を分かりやすくしました。ただし、電話回線では帯域や圧縮の影響があり、音質の期待値そのものが高くありません。このため、薄い EQ や軽い質感変化のような繊細な処理は、相手側で差が分かりにくいと感じました。

一方で、リングモジュレーターのように音色へ強い特徴を付ける処理は、電話口でも変化が認識しやすく、載せる意味が出やすいです。結論として、通話へ VST を挿すなら、ハイファイな音質改善よりも、ボイスチェンジャーや歪みなど「意図的にキャラクターを付ける」用途の方が相性が良いと思われます。

まとめ

Twilio Voice JS SDK の Audio Processor を使うと、アプリ側の処理で送信音声を差し替えることができます。この機能は VST3 プラグインの効果を通話へ載せたいユースケースなどで活用できます。筆者の体感では、遅延の支配要因は回線側にあり、アプリ側の音声差し替え処理の有無で遅延が問題になることはありませんでした。電話回線では音質の期待値が高くないため、載せるなら繊細な補正よりも、強い特徴を付けるエフェクトの方が有効と考えられます。

この記事をシェアする

FacebookHatena blogX

関連記事