CloudRunでText To To Speechをする方法
LLMを使わない軽量TTSシステムの構築とPiperの優位性
最近のTTS(Text-to-Speech)技術は、特に大規模言語モデル(LLM)の普及により飛躍的な進歩を遂げていますが、すべてのユースケースにLLMを適用するのは現実的ではありません。例えば、低遅延や軽量性が求められるアプリケーションでは、LLMはその計算コストの高さから、処理が重くなりがちです。
この点で、LocalAIが提供するText-to-Audio機能は注目に値します。LocalAIはLLMを使わずに、効率的かつ迅速に音声を生成できる技術スタックを提供しており、その中でPiperは特に音質とパフォーマンスのバランスが良いTTSエンジンです。
なぜLLMを使わないのか?
従来のTTSシステムは、基本的に事前にトレーニングされた音声モデルを使ってテキストを音声に変換します。LLMを利用したTTSは、このプロセスに自然な音声表現や高度な文脈理解を加えることで非常に高い品質の音声を生成しますが、次のようなデメリットがあります。
- 遅延の増加: LLMの計算量が多く、音声をリアルタイムに生成するには多くのリソースが必要です。
- インフラコストの増加: 高度なGPUを使う必要があるため、特に大規模なデプロイではコストが跳ね上がります。
これに対して、Piperのような軽量なTTSエンジンは、より少ないリソースで迅速に音声を生成できます。これにより、特に低遅延が求められるアプリケーションや、リソースが限られた環境でもTTSを実現可能です。
Piperの特徴
PiperはRhasspyプロジェクトから派生したオープンソースのTTSエンジンで、次のような利点があります。
-
高音質: 軽量でありながら、非常に自然な音声を生成します。既存の音声モデルは多言語対応で、様々な言語やアクセントをサポートしています。
-
低遅延: リアルタイム処理が可能であり、軽量なシステムリソースで動作します。これにより、スマートデバイスやWebアプリケーションなど、レスポンス速度が重要な場面での利用が効果的です。
-
カスタマイズ可能: オープンソースであるため、独自の音声モデルや言語モデルをトレーニングして使用できます。また、Docker対応であるため、クラウドやオンプレミスに簡単にデプロイできます。
なぜPiperを選ぶのか?
Piperは、他のTTSエンジンと比べて以下の点で非常に優れています。
- 低コストでの実行: CPUベースで十分に高品質な音声が生成可能で、GPUが不要です。
- オープンソース: どのような環境にも適応できる柔軟性があり、商用アプリケーションでも自由に利用できます。
- カスタム対応: プロジェクトやビジネスの要件に合わせて、音声モデルを変更することができます。
例えば、音声案内システムや自動応答システム、ゲーム内のキャラクター音声生成など、リアルタイム処理が求められるシナリオで特に強力です。また、軽量なため、コンテナ化してクラウドサービス(Google Cloud RunやAWS Fargateなど)にデプロイするのにも最適です。
Piperを利用することで、低遅延かつコスト効率の高いTTSソリューションを簡単に構築できます。続いて、PiperをGoogle Cloud Runにデプロイする具体的な手順を見ていきます。
piperのpython libraryはCPUのアーキテクチャ依存しているのでapple silicon製のmacではエラーになってしまいます。ここではgolangでhttpサーバーをhostingしつつbinaryを実行することによってpiperをcloudrunでhttpサーバーとしてhostingすることを目標とします。
FROM golang:1.20
WORKDIR /go/src/app
COPY . .
RUN chmod +x ./download-piper.sh
RUN ./download-piper.sh
RUN chmod +x ./download-voices.sh
RUN ./download-voices.sh
RUN go build .
EXPOSE 8080
CMD ["./serve-piper-go"]
#!/bin/bash
# Function to get the machine architecture
get_architecture() {
case $(uname -m) in
x86_64) echo "amd64" ;;
aarch64) echo "linux_aarch64" ;;
armv7l) echo "armv7" ;;
esac
}
# Get the machine architecture
architecture=$(get_architecture)
# GitHub URL
url="https://github.com/rhasspy/piper/releases/latest"
# Get the redirect URL of the latest release
redirect_url=$(curl -sL -w %{url_effective} -o /dev/null $url)
# Extract the release tag from the redirect URL
release_tag=$(basename $redirect_url)
echo "Release tag: $release_tag"
echo "Architecture: $architecture"
# Construct the release file URL
release_file_url="https://github.com/rhasspy/piper/releases/download/$release_tag/piper_$architecture.tar.gz"
# Download the release file
curl -L -o piper.tar.gz $release_file_url
mkdir bin
# Extract the tar gz archive
tar -xzf piper.tar.gz -C bin
# Clean up the downloaded archive
#!/bin/bash
files=(
"voice-ca-upc_ona-x-low.tar.gz"
"voice-ca-upc_pau-x-low.tar.gz"
"voice-da-nst_talesyntese-medium.tar.gz"
"voice-de-eva_k-x-low.tar.gz"
"voice-de-karlsson-low.tar.gz"
"voice-de-kerstin-low.tar.gz"
"voice-de-pavoque-low.tar.gz"
"voice-de-ramona-low.tar.gz"
"voice-de-thorsten-low.tar.gz"
"voice-el-gr-rapunzelina-low.tar.gz"
"voice-en-gb-alan-low.tar.gz"
"voice-en-gb-southern_english_female-low.tar.gz"
"voice-en-us-amy-low.tar.gz"
"voice-en-us-danny-low.tar.gz"
"voice-en-us-kathleen-low.tar.gz"
"voice-en-us-lessac-low.tar.gz"
"voice-en-us-lessac-medium.tar.gz"
"voice-en-us-libritts-high.tar.gz"
"voice-en-us-ryan-high.tar.gz"
"voice-en-us-ryan-low.tar.gz"
"voice-en-us-ryan-medium.tar.gz"
"voice-en-us_lessac.tar.gz"
"voice-es-carlfm-x-low.tar.gz"
"voice-es-mls_10246-low.tar.gz"
"voice-es-mls_9972-low.tar.gz"
"voice-fi-harri-low.tar.gz"
"voice-fr-gilles-low.tar.gz"
"voice-fr-mls_1840-low.tar.gz"
"voice-fr-siwis-low.tar.gz"
"voice-fr-siwis-medium.tar.gz"
"voice-is-bui-medium.tar.gz"
"voice-is-salka-medium.tar.gz"
"voice-is-steinn-medium.tar.gz"
"voice-is-ugla-medium.tar.gz"
"voice-it-riccardo_fasol-x-low.tar.gz"
"voice-kk-iseke-x-low.tar.gz"
"voice-kk-issai-high.tar.gz"
"voice-kk-raya-x-low.tar.gz"
"voice-ne-google-medium.tar.gz"
"voice-ne-google-x-low.tar.gz"
"voice-nl-mls_5809-low.tar.gz"
"voice-nl-mls_7432-low.tar.gz"
"voice-nl-nathalie-x-low.tar.gz"
"voice-nl-rdh-medium.tar.gz"
"voice-nl-rdh-x-low.tar.gz"
"voice-no-talesyntese-medium.tar.gz"
"voice-pl-mls_6892-low.tar.gz"
"voice-pt-br-edresson-low.tar.gz"
"voice-ru-irinia-medium.tar.gz"
"voice-sv-se-nst-medium.tar.gz"
"voice-uk-lada-x-low.tar.gz"
"voice-vi-25hours-single-low.tar.gz"
"voice-vi-vivos-x-low.tar.gz"
"voice-zh-cn-huayan-x-low.tar.gz"
"voice-zh_CN-huayan-medium.tar.gz"
)
# GitHub release URL
url="https://github.com/rhasspy/piper/releases/download/v0.0.2"
# Destination folder to extract the files
destination_folder="models"
# Create the destination folder if it doesn't exist
if [ ! -d "$destination_folder" ]; then
mkdir "$destination_folder"
fi
# Function to download and extract files based on language filter
download_and_extract_files() {
local lang_filter=$1
for file in "${files[@]}"; do
# Check if the file matches the language filter
if [[ "$file" == *"$lang_filter"* ]]; then
# Download the file
curl -L -o "$file" "$url/$file"
# Extract the file to the destination folder
tar -xzf "$file" -C "$destination_folder"
# Clean up the downloaded archive
rm "$file"
fi
done
}
# Check if a language filter argument is provided
if [[ -n $1 ]]; then
# Call the download_and_extract_files function with the language filter argument
download_and_extract_files "$1"
else
# No language filter argument provided, download voice-en-us-libritts-high
download_and_extract_files "voice-en-us-libritts-high"
fi
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"
"github.com/gorilla/mux"
)
const (
DEFAULT_VOICE = "en-us-libritts-high.onnx"
DEFAULT_PORT = "8080"
MODELS_DIR = "models"
PIPER_PATH = "./bin/piper/piper"
// DEFAULT_TEXT = "Welcome to the world of speech synthesis!" // unused
)
func escapeString(input string) string {
input = strings.Replace(input, "'", "", -1)
input = strings.Replace(input, "|", "", -1)
input = strings.Replace(input, "\\", "", -1)
input = strings.Replace(input, "\"", "", -1)
input = strings.Replace(input, "\n", " ", -1)
input = strings.Replace(input, " ", " ", -1)
input = strings.TrimSpace(input)
return input
}
func getListOfVoices() ([]string, error) {
// read the list of files in the models directory
cmd := exec.Command("ls", MODELS_DIR)
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
err = cmd.Start()
if err != nil {
return nil, err
}
stdoutBytes, err := io.ReadAll(stdoutPipe)
if err != nil {
return nil, err
}
err = cmd.Wait()
if err != nil {
return nil, err
}
voices := strings.Split(string(stdoutBytes), "\n")
// filter out non .onnx files
var filteredVoices []string
for _, voice := range voices {
if strings.HasSuffix(voice, ".onnx") {
filteredVoices = append(filteredVoices, voice)
}
}
return filteredVoices, nil
}
func logToTextFile(text string, voice string) {
f, err := os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
fmt.Println("Error opening file")
return
}
defer f.Close()
// timestamp, text, voice " CSV Style"
timestamp := time.Now()
_, err = f.WriteString(timestamp.Format("2006-01-02 15:04:05") + ", " + text + ", " + voice + "\n")
if err != nil {
fmt.Println("Error writing to file")
return
}
}
func runExecutable(input string, voice string) (io.Reader, error) {
// fileName := hashString(input) + ".wav"
esc := escapeString(input)
logToTextFile(esc, voice)
voice = MODELS_DIR + "/" + voice
cmd := exec.Command(PIPER_PATH, "--model", voice, "--output_file", "-")
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
err = cmd.Start()
if err != nil {
return nil, err
}
_, err = io.WriteString(stdin, esc)
if err != nil {
return nil, err
}
stdin.Close()
return stdoutPipe, nil
}
func handlePostRequest(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
var jsonBody struct {
Text string `json:"text"`
Voice string `json:"voice"`
}
err := json.NewDecoder(r.Body).Decode(&jsonBody)
if err != nil {
http.Error(w, "Error parsing json", http.StatusBadRequest)
return
}
// trim whitespace
textInput := strings.TrimSpace(jsonBody.Text)
if textInput == "" {
http.Error(w, "Error parsing json - text", http.StatusBadRequest)
return
}
voice := DEFAULT_VOICE
if jsonBody.Voice != "" {
voice = jsonBody.Voice
}
defer r.Body.Close()
w.Header().Set("Content-Type", "audio/wav")
w.WriteHeader(http.StatusOK)
if voice != DEFAULT_VOICE {
voices, err := getListOfVoices()
if err != nil {
http.Error(w, "Error getting list of voices", http.StatusInternalServerError)
return
}
var voiceFound bool
for _, v := range voices {
if v == voice {
voiceFound = true
break
}
}
if !voiceFound {
voice = DEFAULT_VOICE
}
}
stdoutPipe, err := runExecutable(textInput, voice)
if err != nil {
fmt.Println("Error running executable", err)
http.Error(w, "Error running executable", http.StatusInternalServerError)
return
}
_, err = io.Copy(w, stdoutPipe)
if err != nil {
http.Error(w, "Error streaming audio data", http.StatusInternalServerError)
return
}
}
func handleGetRequest(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "audio/wav")
w.WriteHeader(http.StatusOK)
// trim whitespace
inputText := strings.TrimSpace(r.URL.Query().Get("text"))
if inputText == "" {
http.Error(w, "Missing Text Parameter.", http.StatusBadRequest)
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = DEFAULT_VOICE
}
if voice != DEFAULT_VOICE {
voices, err := getListOfVoices()
if err != nil {
http.Error(w, "Error getting list of voices", http.StatusInternalServerError)
return
}
var voiceFound bool
for _, v := range voices {
if v == voice {
voiceFound = true
break
}
}
if !voiceFound {
voice = DEFAULT_VOICE
}
}
stdoutPipe, err := runExecutable(inputText, voice)
if err != nil {
http.Error(w, "Error running executable", http.StatusInternalServerError)
return
}
_, err = io.Copy(w, stdoutPipe)
if err != nil {
http.Error(w, "Error streaming audio data", http.StatusInternalServerError)
return
}
}
func main() {
r := mux.NewRouter()
r.HandleFunc("/api/tts", handlePostRequest).Methods("POST")
r.HandleFunc("/api/tts", handleGetRequest).Methods("GET")
r.HandleFunc("/api/voices", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
voices, err := getListOfVoices()
if err != nil {
http.Error(w, "Error getting list of voices", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(voices)
}).Methods("GET")
r.PathPrefix("/").Handler(http.FileServer(http.Dir("./static/")))
r.Use(mux.CORSMethodMiddleware(r))
http.Handle("/", r)
fmt.Println("Server listening on port " + DEFAULT_PORT)
http.ListenAndServe(":"+DEFAULT_PORT, nil)
}
非常にシンプルですがこれでmacでも実行可能ですし、cloudrunでも動きます。
gcloud run deploy
Discussion