😺

CloudRunでText To To Speechをする方法

2024/09/30に公開

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エンジンで、次のような利点があります。

  1. 高音質: 軽量でありながら、非常に自然な音声を生成します。既存の音声モデルは多言語対応で、様々な言語やアクセントをサポートしています。

  2. 低遅延: リアルタイム処理が可能であり、軽量なシステムリソースで動作します。これにより、スマートデバイスやWebアプリケーションなど、レスポンス速度が重要な場面での利用が効果的です。

  3. カスタマイズ可能: オープンソースであるため、独自の音声モデルや言語モデルをトレーニングして使用できます。また、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