Liquidsoap と VOICEVOX でつくる音声合成ラジオ


はじめに

Liquidsoap というのは、音声や動画のストリーミングをプログラマブルに扱うことのできるツールです。Liquidsoap 独自のスクリプト言語で番組を定義すると、それに従って音声や動画をストリーミングできます。また、Liquidsoap 自体をストリーミングを行うためのサーバーとすることもできます。

このエントリでは、その Liquidsoap を使って、日本語音声合成エンジンである VOICEVOX による音声合成ラジオ配信システムを作る方法を紹介します。

最終的な成果物は GitHub darashi/liquidsoap-voicevox-radio にあります。 やや複雑なデプロイメントが必要になりますので、compose.yml を置いておきました。併せてご覧ください。

VOICEVOX および音声合成エンジンの利用規約を遵守してお使いください。

Liquidsoap について

キューを定義して音楽を順に流すくらいはお手の物で、スクリプトを書きさえすれば、とにかく何でもできそうです。詳しくはドキュメントを参照してください。

さて、実は Liquidsoap はデフォルトでいくつかの音声合成エンジンに対応しています。Protocols として、HTTP 等の他に SAY などが定義されており、これを使うと音声合成エンジンに音声を送って、その音声をストリーミングできます。

しかし、 VOICEVOX には対応していません。そこで、対応させるためのライブラリを作りました。

本稿執筆時点での Liquidsoap のバージョンは 2.1.4 です。

なお、Ubuntu 22.10 で標準でインストールできる Liquidsoap は ocurl が入っていない状態でビルドされているのか(詳細は理解できていません)、Liquidsoap から外部への HTTP がうまく動作しませんでした。このため、 Docker image savonet/liquidsoap:v2.1.4 を使いました。

HLS 配信サーバーの構築

まず、簡単なところから始めましょう。 キューに投入された音声を順に再生する HLS サーバをつくる例です。

HLS とは、HTTP Live Streaming の略で、HTTP 経由で細切れのファイルを次々に配信することでライブストリーミングを実現します。なお、Liquidsoap は他にも icecast をはじめとして様々なストリーミングプロトコルの入出力に対応しています。詳細はドキュメントをご覧ください。

# hls.liq

settings.server.telnet.set(true)
settings.server.telnet.port.set(1234)
settings.server.telnet.bind_addr.set("0.0.0.0")
settings.server.log.level.set(4)
log.stdout.set(true)
settings.protocol.voicevox.url.set("http://example.com:50021") # localhost 以外に VOICEVOX を置く場合に URL を指定する

settings.server.telnet.set(true)
radio = request.queue(id="request")

formats = [("aac",
      %ffmpeg(
        format="mpegts",
        %audio(codec="aac", b="96k")
      ))]

output.harbor.hls(
    segment_duration=5.0,
    segments=5,
    segments_overhead=5,
    path="/live",
    tmpdir="/tmp/hls",
    port=port,
    formats,
    radio)

この hls.sqlliquidsoap hls.liq として実行します。 すると、 http://localhost:8080/live/stream.m3u8 で HLS で配信が開始されます。

caniuse で見る限り、執筆時点で HLS に対応しているデスクトップブラウザは Safari だけです。それでも hls.js という Polyfill を使うことで HLS を再生できます。iPhone や Android のブラウザは Polyfill 不要です。

また、mpvvlc でも再生できます。動作確認にはこれらのアプリケーションが便利です。たとえば mpv を使う場合、

mpv http://localhost:8080/live/stream.m3u8

とします。

さて、http://localhost:8080/live/stream.m3u8 を再生すると、無音状態になっているはずです。 これはキューが空のためです。

http、あるいは https で取得できる場所に音楽等を置いて、キューに追加してみましょう。 https://example.com/music.mp3 に配置したとすると、以下のようにします。

❯ telnet localhost 1234
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
request.push https://example.com/music.mp3
0
END

しばらくするとファイルが再生されるはずです。

Docker の中で実行している場合は 1234/tcp と 8080/tcp をポートフォワードする必要があることに注意してください。

さて、この push の引数に VOICEVOX への合成の指示を入れられるようにします。

VOICEVOX のセットアップ

まずは VOICEVOX https://voicevox.hiroshiba.jp/ をインストールしておきます。

Engine 部分 https://github.com/VOICEVOX/voicevox_engine だけのインストールでも、以降のスクリプトは動作します。

voicevox.liq

以下のファイルを voicevox.liq として保存します。

# voicevox.liq

let settings.protocol.voicevox = settings.make.protocol("voicevoox")
let settings.protocol.voicevox.url = settings.make(
    description="URL of VOICEVOX engine",
    "http://localhost:50021"
)

def voicevox_protocol(~rlog, ~maxtime, arg) =
    let [args, ...text] = r/:/.split(arg)
    let text = string.concat(separator=":", text)
    let speaker = args

    output = file.temp("voicevox", ".wav")
    let base = settings.protocol.voicevox.url()

    let query_url = "#{base}/audio_query?speaker=#{speaker}&text=#{text}"
    query = http.post(query_url)

    let synthesis_url = "#{base}/synthesis?speaker=#{speaker}"

    let file_writer = file.write.stream(output)
    let response = http.post.stream(synthesis_url, headers=[("Content-Type", "application/json")], data=query, on_body_data=file_writer)

    [output]
end

add_protocol("voicevox", voicevox_protocol)

このファイルを %include "voicevox.liq" で読み込むと、 voicevox というプロトコルが定義されます。使い方は以下の通りです。

# hls-voicevox.liq

%include "voicevox.liq" # ここを追加

settings.server.telnet.set(true)
settings.server.telnet.port.set(1234)
settings.server.telnet.bind_addr.set("0.0.0.0")
settings.server.log.level.set(4)
log.stdout.set(true)
settings.protocol.voicevox.url.set("http://example.com:50021") # localhost 以外に VOICEVOX を置く場合に URL を指定する

settings.server.telnet.set(true)
radio = request.queue(id="request")

formats = [("aac",
      %ffmpeg(
        format="mpegts",
        %audio(codec="aac", b="96k")
      ))]

output.harbor.hls(
    segment_duration=5.0,
    segments=5,
    segments_overhead=5,
    path="/live",
    tmpdir="/tmp/hls",
    port=port,
    formats,
    radio)

このスクリプトを hls-voicevox.liq として保存して、 liquidsoap hls-voicevox.liq で実行します。

次に、 telnet で 1234/tcp に接続して、音声合成リクエストをキューに投入します。

❯ telnet localhost 1234
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
request.push voicevox:1:test
0
END

ここで、 voicevox: のあとの 1 が speaker の ID 、2個目の : 以降が読み上げるテキストです。

VOICEVOX Engine が起動している状態で http://localhost:50021/speakers を表示すると話者一覧が取得できます。 speaker ID はここから選択できます。

また、Liquidsoap の仕様なのか、URL部分に日本語を含むと途中で化けてしまうようなので、テキスト部分は URL Encode する仕様にしました。

例として「本日は晴天なり」と発話させてみましょう。Node.js でエンコードするには以下のようにします。

❯ node
Welcome to Node.js v18.12.0.
Type ".help" for more information.
> encodeURI("本日は晴天なり")
'%E6%9C%AC%E6%97%A5%E3%81%AF%E6%99%B4%E5%A4%A9%E3%81%AA%E3%82%8A'

では、これを Liquidsoap に送ってみます。

❯ telnet localhost 1234
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
request.push voicevox:3:%E6%9C%AC%E6%97%A5%E3%81%AF%E6%99%B4%E5%A4%A9%E3%81%AA%E3%82%8A
0
END

どうでしょう。合成されたでしょうか。これで VOICEVOX による読み上げラジオができました。

HLS で配信するラジオ局スクリプト

とはいえ、telnet でコマンドを送信するのはやや面倒です。 配信サーバを外部からアクセス可能な場所に置くのであれば、何らかの方法で制御用のポートを保護する必要があります。

ところで、 Liquidsoap は HTTP サーバを作ることもできます。 これを使って、HTTP 経由でキューに投入できるようにしてみましょう。

Basic認証でパスワードを設定できるようにしておきます。 制御用のパスワードは CONTROL_PASSWORD 環境変数で設定します。

%include "voicevox.liq"

settings.server.log.level.set(4)
log.stdout.set(true)
settings.protocol.voicevox.url.set("http://example.com:50021") # localhost 以外に VOICEVOX を置く場合に URL を指定する
settings.harbor.verbose.set(true)
settings.frame.audio.samplerate.set(24000)
settings.frame.audio.channels.set(1)

port = 8080

formats = [("aac",
      %ffmpeg(
        format="mpegts",
        %audio(codec="aac", b="96k")
      ))]

queue = request.queue(id="request")

radio = mksafe(queue)

def ping_handler(~protocol, ~data, ~headers, uri) =
  http.response(
    protocol=protocol,
    code=200,
    headers=[("Content-Type","application/json; charset=utf-8")],
    data=json.stringify({message = "OK"})
  )
end
harbor.http.register(port=port, method="GET", "/ping", ping_handler)

def get_password(headers) =
  try
    let entry = list.find(fun (v) -> begin
      let (header_name, _) = v
      string.case(lower=true, header_name) == "authorization"
    end, headers)
    let (_, authorization) = entry
    let [scheme, ...credential] = r/ /.split(authorization)
    let credential = string.base64.decode(string.concat(separator=" ", credential))
    let [user, ...password] = r/:/.split(credential)
    string.concat(separator=":", password)
  catch _ : [error.not_found] do
    null()
  end
end

def say_handler(~protocol, ~data, ~headers, uri) =
  pw = get_password(headers)
  if pw == null() then
    http.response(
      protocol=protocol,
      code=401,
      headers=[("Content-Type","application/json; charset=utf-8")],
      data=json.stringify({message = "Unauthorized"})
    )
  else
    if pw != getenv("CONTROL_PASSWORD") then
      http.response(
        protocol=protocol,
        code=401,
        headers=[("Content-Type","application/json; charset=utf-8")],
        data=json.stringify({message = "Unauthorized"})
      )
    else
      let json.parse ({speaker, text} : {speaker: int, text: string} ) = data
      print("speaker: #{speaker}, text: #{text}")
      let req = request.create("voicevox:#{speaker}:#{url.encode(text)}")
      queue.push(req)
      http.response(
        protocol=protocol,
        code=200,
        headers=[("Content-Type","application/json; charset=utf-8")],
        data=json.stringify({message = "OK"})
      )
    end
  end
end
harbor.http.register(port=port, method="POST", "/say", say_handler)

output.harbor.hls(
    segment_duration=5.0,
    segments=5,
    segments_overhead=5,
    path="/live",
    tmpdir="/tmp/hls",
    port=port,
    formats,
    radio)

このスクリプトを実行して

❯ curl -v --data-binary '{"speaker": 14, "text": "テストなのだ"}' -H 'Content-type: application/json' http://vv:super-secret-password@localhost:8080/say

のようにすると、キューにリクエストが投入されます。

まとめ

Liquidsoap と VOICEVOX を使って、音声合成を活用したラジオストリーミングサーバをつくる方法を紹介しました。 面白いアプリケーションづくりに活用してみてください。

今後の課題

以下のようなAPIがあると楽しそうですね。