【Nextjs】【TypeScript】テキストを音声に変換するアプリを構築する(完成編)

2023/08/24に公開

まえがき

前回に引き続き、Google CloudのText-to-Speech AIを使ってテキストを音声に変換するアプリを構築する続きをします。
https://cloud.google.com/text-to-speech?hl=ja
前回はNextjsのプロジェクトを新規作成して、ドキュメントのサンプルコードを動作させるところまで構築しました。
https://zenn.dev/arafipro/articles/2023-08-22-next-text-to-speech-1
今回は入力画面(UI)作成と関数quickStart()に値を渡すために引数を追加します。
そしてアプリの完成まで終わらせます。
場合は自己責任でお願いします。

開発環境

入力画面(UI)を作成

daisyUIを導入

今回はTailwind CSSのコンポーネントライブラリのdaisyUIを使用します。

daisyUIをインストール

以下のコマンドを実行してインストールします。

npm i -D daisyui@latest

tailwind.config.jspluginsにdaisyUIを追加します。

tailwind.config.js
- plugins: [],
+ plugins: [require("daisyui")],

入力画面(UI)を作成

UIのイメージは以下の通りです。

簡単に説明すると音声に変換するテキストを入力するテキストエリアと置いて、その下に音声ファイル名を入力するインプットエリアと音声変換を実行するボタンを並べています。

コードは以下通りです。

app/page.tsx
export default function Home() {
  return (
    <main className="bg-gray-200 w-full h-screen overflow-hidden">
      <div className="max-w-2xl mx-auto h-full">
        <h1 className="text-3xl text-center py-4">Text Recording App</h1>
        <form className="h-full">
          <textarea
            required
            className="textarea w-full h-2/3 mb-4"
            placeholder="音声変換するテキストを入力"
          ></textarea>
          <div className="flex">
            <input
              type="text"
              placeholder="音声ファイル名"
              required
              className="input w-1/2 mr-4"
            />
            <button type="submit" className="btn btn-neutral w-1/2">
              <span className="text-xl tracking-widest">音声変換実行</span>
            </button>
          </div>
        </form>
      </div>
    </main>
  );
}

ちなみに各パーツのサイズや配置などはお好みでOKです。

関数quickStart()に引数を追加

引数を設定

入力画面で入力した音声に変換するテキストと音声ファイル名を受け取れるように引数を設定します。
テキストの引数はinputText、ファイル名の引数はfileNameにします。
両方の引数の型はstringになります。

utils/textRecording.ts
- export async function quickStart() {
+ export async function quickStart(inputText: string, fileName: string) {

変数textを削除

変数textは不要なので削除します。

utils/textRecording.ts
- const text = "hello, world!";

新たな引数をコード内に設置

utils/textRecording.ts
  const request: textToSpeech.protos.google.cloud.texttospeech.v1.ISynthesizeSpeechRequest =
    {
-     input: { text: text },
+     input: { text: inputText },
      voice: { languageCode: "en-US", ssmlGender: "NEUTRAL" },
      audioConfig: { audioEncoding: "MP3" },
    };
utils/textRecording.ts
- await writeFile("output.mp3", response.audioContent as Buffer, "binary");
- console.log("Audio content written to file: output.mp3");
+ await writeFile(`${fileName}.mp3`, response.audioContent as Buffer, "binary");
+ console.log(`Audio content written to file: ${fileName}.mp3`);

関数名を変更

関数の機能がわかりやすくするように関数の名前をquickStart()からファイル名と同じtextRecording()に変更します。

utils/textRecording.ts
- export async function quickStart(inputText: string, fileName: string) {
+ export async function textRecording(inputText: string, fileName: string) {

ここまでのtextRecording.tsのコードは以下の通りです。

utils/textRecording.ts
utils/textRecording.ts
import * as textToSpeech from "@google-cloud/text-to-speech";

import fs from "fs";
import util from "util";

const option = {
  keyFilename: "secret.json",
};

export async function textRecording(inputText: string, fileName: string) {
  const client = new textToSpeech.TextToSpeechClient(option);

  const request: textToSpeech.protos.google.cloud.texttospeech.v1.ISynthesizeSpeechRequest =
    {
      input: { text: inputText },
      voice: { languageCode: "en-US", ssmlGender: "NEUTRAL" },
      audioConfig: { audioEncoding: "MP3" },
    };

  const [response] = await client.synthesizeSpeech(request);
  const writeFile = util.promisify(fs.writeFile);
  await writeFile(`${fileName}.mp3`, response.audioContent as Buffer, "binary");
  console.log(`Audio content written to file: ${fileName}.mp3`);
}

入力画面(UI)から関数textRecording()に入力データを渡す

入力されたデータを取得

まずは<textarea>タグに入力されたデータを取得するにはonChangeイベントを使います。
以下のようにコードを追加します。

app/page.tsx
  <textarea
    required
    className="textarea w-full h-2/3 mb-4"
    placeholder="音声変換するテキストを入力"
+   onChange={(e) => console.log(e.target.value)}
  ></textarea>

データが入力されるたびイベントeが発生します。
eは任意の変数なので、わかりやすいようにeventに置き換えてもOKです。
そのイベントを受け取るとe.target.valueに入力データが格納されます。
そしてconsole.log()でデータを確認します。
詳しくは以下のドキュメントを見てください。
https://react.dev/reference/react-dom/components/textarea#controlling-a-text-area-with-a-state-variable

<textarea>タグと同じように<input>タグにもコードを追加します。

app/page.tsx
  <input
    type="text"
    placeholder="音声ファイル名"
    required
    className="input input-bordered w-full max-w-xs mx-4"
+   onChange={(e) => console.log(e.target.value)}
  />

詳しくは以下のドキュメントを見てください。
https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable

useStateを使って入力したデータを変数に格納

<textarea>タグや<input>タグの入力データはe.target.valueで取得できました。
しかし、同じ値から取得することになるので、どのタグからの入力データなのかわかりません。
そこで、useStateというStateフックという機能を使います。
app/page.tsxの冒頭からコードを追加します。

app/page.tsx
+ "use client";

+ import { useState } from "react";

  export default function Home() {
+   const [inputText, setInputText] = useState<string>("");
+   const [fileName, setFileName] = useState<string>("");

追加したコードを見ていきます。
まずは"use client"を宣言します。
このコンポーネントはクライアントで動作させることを宣言することになります。
次はuseStatereactからインポートします。
最後は各入力タグ毎にuseStateを追加します。

ここではinputTextを例に見ます。

app/page.tsx
const [inputText, setInputText] = useState<string>("");

inputTextstate変数であり、setInputTextはセッタ関数です。
useStateに格納されるデータの型はstringなのでuseState<string>のように型を指定します。
その後の()内にはinputTextの初期値を指定しています。
useStateの動きとしては、inputTextには初期値の""が格納されます。
そしてsetInputTextを使って新たなデータをinputTextに上書きされます。
具体的には以下のようにコードを変更します。

app/page.tsx
  <textarea
    required
    className="textarea textarea-bordered w-full h-2/3 mb-4"
    placeholder="音声変換するテキストを入力"
-   onChange={(e) => console.log(e.target.value)}
+   onChange={(e) => setInputText(e.target.value)}
  ></textarea>

console.log()では入力データをコンソールで見ましたが、それをsetInputText()の引数としてe.target.valueを渡してinputTextを更新します。
これでinputTexttextRecording()の引数として使うことできます。

同様にinputタグ内も変更します。

app/page.tsx
  <input
    type="text"
    placeholder="音声ファイル名"
    required
    className="input w-1/2 mr-4"
-   onChange={(e) => console.log(e.target.value)}
+   onChange={(e) => setFileName(e.target.value)}
  />

念のためにconsole.log()で動作を確認します。

app/page.tsx
  const [inputText, setInputText] = useState<string>("");
  const [fileName, setFileName] = useState<string>("");
+ console.log(inputText);
+ console.log(fileName);

動作確認が終わったら、console.log()は削除かコメントアウトしておいてください。

useStateを詳しく知りたい方は以下のドキュメントをご覧ください。
https://ja.react.dev/reference/react/useState

入力したデータを関数textRecording()の引数として渡す

useStateでデータを格納した変数をtextRecording()の引数として渡します。
まずはtextRecording()の挙動を考えます。
textRecording()はボタンをされた時に実行されるようにしなければいけません。
そのためには、formタグにonSubmitイベントを追加して関数handleSubmitを指定します。

app/page.tsx
- <form className="h-full">
+ <form className="h-full" onSubmit={handleSubmit}>

関数handleSubmitはないのでuseStateの後にコードを追加します。

app/page.tsx
  export default function Home() {
    const [inputText, setInputText] = useState<string>("");
    const [fileName, setFileName] = useState<string>("");
+ const handleSubmit = async () => {
+   await textRecording(textInput, fileName);
+ };

Server Actions機能

関数handleSubmitを作成したらエラーが発生しました。

textRecording()で使われているモジュールfsはサーバー上で動かします。
ただapp/page.tsx"use client"を宣言しているのでクライアントコンポーネントになります。
クライアントコンポーネント内ではモジュールfsは使えません。
そこでServer Actionsという機能を使います。

next.config.jsに設定を追加

next.config.jsに設定を追加します。

next.config.js
  /** @type {import('next').NextConfig} */
  const nextConfig = {
+   experimental: {
+     serverActions: true,
+   },
  };
  
  module.exports = nextConfig;

"use server"を宣言

"use server"utils/textRecording.tsの1番上に追加します。

utils/textRecording.ts
+ "use server"

あとは以下が参考にしたドキュメントとサイトです。
https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions
https://azukiazusa.dev/blog/nextjs-server-action/

動作確認

ボタンが押されると関数handleSubmitが発火します。
そして関数handleSubmittextRecordingtextInputfileNameを引数として渡して実行されます。
すると音声ファイルが作成されます。

今回のコード

これまでのコードは以下の通りです。

app/page.tsx
app/page.tsx
"use client";

import { textRecording } from "@/utils/textRecording";
import { useState } from "react";

export default function Home() {
  const [textInput, setTextInput] = useState<string>("");
  const [fileName, setFileName] = useState<string>("");
  const handleSubmit = async () => {
    await textRecording(textInput, fileName);
  };

  return (
    <main className="bg-gray-200 w-full h-screen">
      <div className="max-w-2xl mx-auto h-full">
        <h1 className="text-3xl text-center py-4">Text to Speech App</h1>
        <form className="h-full" onSubmit={handleSubmit}>
          <textarea
            required
            className="textarea textarea-bordered w-full h-2/3 mb-4"
            placeholder="音声変換するテキストを入力"
            onChange={(e) => setTextInput(e.target.value)}
          ></textarea>
          <div className="flex">
            <input
              type="text"
              placeholder="音声ファイル名"
              required
              className="input input-bordered w-full max-w-xs mr-4"
              onChange={(e) => setFileName(e.target.value)}
            />
            <button type="submit" className="btn btn-neutral w-1/2">
              <span className="text-xl tracking-widest">音声変換実行</span>
            </button>
          </div>
        </form>
      </div>
    </main>
  );
}
utils/textRecording.ts
utils/textRecording.ts
"use server"

import * as textToSpeech from "@google-cloud/text-to-speech";

import fs from "fs";
import util from "util";

const option = {
  keyFilename: "secret.json",
};

export async function textRecording(inputText: string, fileName: string) {
  const client = new textToSpeech.TextToSpeechClient(option);

  const request: textToSpeech.protos.google.cloud.texttospeech.v1.ISynthesizeSpeechRequest =
    {
      input: { text: inputText },
      voice: { languageCode: "en-US", ssmlGender: "NEUTRAL" },
      audioConfig: { audioEncoding: "MP3" },
    };

  const [response] = await client.synthesizeSpeech(request);
  const writeFile = util.promisify(fs.writeFile);
  await writeFile(`${fileName}.mp3`, response.audioContent as Buffer, "binary");
  console.log(`Audio content written to file: ${fileName}.mp3`);
}

次回

これで目的のアプリが完成できました。
ただ、前回の記事に載せていたPythonのコードを見ていただくとわかりますが、完全に移植できたわけではありません。
改行で文章を分けてssmlタグを利用したり、neural2音声を利用して変換したりとすべて実装できていません。
そこで次回はneural2音声を利用できるように拡張します。

スマホアプリ「ひとこと投資メモ」シリーズをリリース

記事とは関係ないことですが、最後にお知らせです。
Flutter学習のアウトプットの一環として「日本株ひとこと投資メモ」「米国株ひとこと投資メモ」を公開しています。

簡単に使えるライトな投資メモアプリです。
iPhone、Android両方に対応しています。
みなさんの投資ライフに少しでも活用していただきれば幸いです。
以下のリンクからそれぞれのサイトに移動してダウンロードをお願いします。
https://jpstockminimemo.arafipro.com/
https://usstockminimemo.arafipro.com/

GitHubで編集を提案

Discussion