見出し画像

Node.js・TypeScriptでAivis Cloud APIを使う方法|型定義・ストリーミング・エラー処理まで解説!

この記事はこんな方に向けた内容です
・Node.js / TypeScriptのサービスに音声合成を組み込みたい方
・音声を受信しながら再生・配信する方法を知りたい方
・APIの型定義やエラー処理を、最初からきちんと設計したい方
・APIキーをブラウザへ公開せず、安全に実装したい方

⚠️ この記事の内容は2026年9月時点のものです。最新の仕様はAivis Cloud APIドキュメントをご確認ください。

皆さんこんにちは。
感情豊かな音声合成技術を誰もがかんたんに活用できる未来を目指す、Aivis Projectです✨

Node.jsから音声合成APIを呼ぶだけなら難しくなさそう。
でも、TypeScriptの型やストリーミング、エラー時の再試行まで考えると、どこから手を付ければよいのだろう?

そのような疑問をお持ちの方に向けて、今回はAivis Cloud APIをNode.js / TypeScriptから利用する方法を、実際のコードとともに解説します。

先に結論をお伝えすると、Aivis Cloud APIを呼び出すための専用SDKは必要ありません。
Node.js標準のfetchでHTTPリクエストを送り、返ってきた音声データを用途に合わせて扱えば利用できます。

ただし、音声ファイルを1本保存する実装と、届いた音声をすぐに再生する実装では、レスポンスの扱い方が異なります。
後半ではその違いも整理しながら、実運用を見据えた構成まで解説します。📚

⚠️ 本記事で掲載しているコードはあくまでもサンプルコードです。
コマンド等は実行する前にお使いの環境に適しているかどうかを確認し、必要に応じて修正した上でご利用ください。


今回実装するもの

この記事では、ひとつの共通関数をもとに、次の3つを実装します。

・生成した音声をMP3ファイルとして保存する
・Aivis Cloud APIの音声ストリームをExpress経由でブラウザへ中継する
・ブラウザでチャンクを受け取り、音声の生成完了を待たずに再生を始める

また、TypeScriptの型でstyle_idとstyle_nameの同時指定を防ぎ、HTTPステータスごとに再試行の可否を分けます。

準備するもの

今回は、Node.js 22以降とTypeScriptを使います。Node.js公式ドキュメントでは、fetchはNode.js 21から安定版となっています。そのため、現在のLTS版を使っていれば追加のHTTPクライアントは不要です。

まず、作業用のプロジェクトを用意します。

mkdir aivis-cloud-api-sample
cd aivis-cloud-api-sample
npm init -y
npm pkg set type=module
npm install express
npm install --save-dev typescript tsx @types/node @types/express
mkdir src public

Aivis Cloud APIを呼び出す部分には、Aivis専用の追加パッケージは使いません。expressは、ブラウザへ音声を中継するサーバーの例で使用します。

APIキーとモデルUUIDを環境変数へ設定する

APIキーはAivis Cloud APIダッシュボードから取得できます。読み上げに使うモデルのUUIDは、AivisHubの各モデルページで確認してください。

macOS / Linuxでは、次のように設定します。

export AIVIS_API_KEY="your-api-key"
export AIVIS_MODEL_UUID="your-model-uuid"

Windows PowerShellの場合はこちらです。

$env:AIVIS_API_KEY="your-api-key"
$env:AIVIS_MODEL_UUID="your-model-uuid"

⚠️ APIキーはソースコードへ直接書かず、Gitにも登録しないでください。
公開Webアプリでは、ブラウザへ配信されるJavaScriptに埋め込むのも避けましょう。

TypeScriptでリクエストの型を定義する

まずはsrc/aivis.tsを作り、Aivis Cloud APIへ送るデータの型を定義します。

// src/aivis.ts
export type SamplingRate =
  | 8000
  | 11025
  | 12000
  | 16000
  | 22050
  | 24000
  | 44100
  | 48000;

type StyleSelector =
  | { style_id?: never; style_name?: never }
  | { style_id: number; style_name?: never }
  | { style_id?: never; style_name: string };

type CompressedOutput = {
  output_format?: "mp3" | "aac" | "opus";
  output_bitrate?: number;
};

type LosslessOutput = {
  output_format: "wav" | "flac";
  output_bitrate?: never;
};

export type SynthesizeRequest = {
  model_uuid: string;
  text: string;
  speaker_uuid?: string;
  user_dictionary_uuid?: string;
  use_ssml?: boolean;
  use_volume_normalizer?: boolean;
  language?: "ja";
  speaking_rate?: number;
  emotional_intensity?: number;
  tempo_dynamics?: number;
  pitch?: number;
  volume?: number;
  leading_silence_seconds?: number;
  trailing_silence_seconds?: number;
  line_break_silence_seconds?: number;
  output_sampling_rate?: SamplingRate;
  output_audio_channels?: "mono" | "stereo";
} & StyleSelector & (CompressedOutput | LosslessOutput);

ここで大切なのは、style_idとstyle_nameを単純なオプション項目として並べていない点です。ユニオン型を使い、「どちらも指定しない」「IDだけ指定する」「名前だけ指定する」の3通りに分けています。

そのため、次のような同時指定はTypeScriptのコンパイル時に検出できます。

const invalidRequest: SynthesizeRequest = {
  model_uuid: "モデルUUID",
  text: "こんにちは。",
  style_id: 1,
  style_name: "Happy", // エラー: style_idと同時に指定できない
};

同じ考え方で、wavとflacにはoutput_bitrateを指定できないようにしています。

なお、TypeScriptの型だけでは「textは1〜3,000文字」「speaking_rateは0.5〜2.0」といった数値範囲までは保証できません。
フォーム入力や外部データを受け取る場合は、サーバー側の実行時チェックも必要です。

OpenAPI定義から型を自動生成する方法

APIの項目が増えたときに手書きの型を追従させたくない場合は、公開されているOpenAPI定義から型を生成できます。

npx openapi-typescript \
  https://api.aivis-project.com/v1/openapi.json \
  --output src/aivis-api.d.ts

生成後は、次のようにスキーマの型を取り出せます。

import type { components } from "./aivis-api.js";

type SynthesizeRequest = components["schemas"]["TTSRequest"];

OpenAPIから生成した型は、公式仕様へ追従しやすいのが利点です。

一方、今回のように排他条件をより厳密に表現したい場合は、アプリ側で補助型を加える方法もあります。

API呼び出しをひとつの関数にまとめる

ファイル保存とストリーミング中継の両方から使えるように、通信処理は共通関数へまとめます。エンドポイントはPOST /v1/tts/synthesizeです。

先ほどのsrc/aivis.tsへ、以下を追加してください。

import { setTimeout as delay } from "node:timers/promises";

const ENDPOINT = "https://api.aivis-project.com/v1/tts/synthesize";

const API_KEY = process.env.AIVIS_API_KEY;
if (!API_KEY) {
  throw new Error("環境変数 AIVIS_API_KEY が設定されていません");
}

export class AivisApiError extends Error {
  constructor(
    public readonly status: number,
    public readonly responseBody: string,
  ) {
    super(`Aivis Cloud API returned HTTP ${status}`);
    this.name = "AivisApiError";
  }
}

type RequestOptions = {
  signal?: AbortSignal;
  maxRetries?: number;
};

function retryDelayMs(response: Response, attempt: number): number {
  const resetHeader = response.headers.get(
    "X-Aivis-RateLimit-Requests-Reset",
  );
  const resetSeconds = resetHeader === null ? Number.NaN : Number(resetHeader);

  if (response.status === 429 && Number.isFinite(resetSeconds)) {
    return Math.max(0, resetSeconds * 1000);
  }

  const exponentialBackoff = 1000 * 2 ** attempt;
  const jitter = Math.floor(Math.random() * 250);
  return exponentialBackoff + jitter;
}

export async function requestSynthesis(
  body: SynthesizeRequest,
  options: RequestOptions = {},
): Promise<Response> {
  const maxRetries = options.maxRetries ?? 2;
  const timeoutSignal = AbortSignal.timeout(120_000);
  const signal = options.signal
    ? AbortSignal.any([options.signal, timeoutSignal])
    : timeoutSignal;

  for (let attempt = 0; ; attempt++) {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
      signal,
    });

    if (response.ok && response.body) {
      return response;
    }

    const responseBody = await response.text().catch(() => "");
    const error = new AivisApiError(response.status, responseBody);
    const retryable = response.status === 429 || response.status >= 500;

    if (!retryable || attempt >= maxRetries) {
      throw error;
    }

    await delay(retryDelayMs(response, attempt), undefined, { signal });
  }
}

この関数は、HTTPステータスに応じて再試行の可否を分けています。

判断の理由と運用時の注意点は、後ほど「エラーは『待てば直るもの』と『設定を直すもの』に分ける」でまとめます。

最小実装:生成した音声をMP3で保存する

まずは、レスポンス全体を受け取ってからファイルへ保存してみましょう。src/save.tsを作ります。

// src/save.ts
import { writeFile } from "node:fs/promises";
import { requestSynthesis, type SynthesizeRequest } from "./aivis.js";

const modelUuid = process.env.AIVIS_MODEL_UUID;
if (!modelUuid) {
  throw new Error("環境変数 AIVIS_MODEL_UUID が設定されていません");
}

const request = {
  model_uuid: modelUuid,
  text: "こんにちは。Aivis Cloud APIをNode.jsから呼び出しています。",
  output_format: "mp3",
  speaking_rate: 1.0,
} satisfies SynthesizeRequest;

const response = await requestSynthesis(request);
const audio = Buffer.from(await response.arrayBuffer());

await writeFile("output.mp3", audio);

console.log("output.mp3を保存しました");
console.log(
  "文字数:",
  response.headers.get("X-Aivis-Character-Count") ?? "不明",
);

実行します。

npx tsx src/save.ts

カレントディレクトリにoutput.mp3が作成されれば成功です。

この方法は実装がわかりやすく、短いナレーションの生成やバッチ処理に向いています。

ただし、arrayBuffer()は音声全体をメモリへ読み込むため、長い音声を扱う場合や、できるだけ早く再生を始めたい場合にはストリームを使います。

ストリーミングは「専用モード」ではない

Aivis Cloud APIの音声合成APIでは、ストリーミング用の別エンドポイントやstream: trueのような指定は必要ありません。
レスポンス自体が、生成できた音声から順にストリーミング配信されます。

Node.jsのfetchが返すResponse.bodyは、データを少しずつ読めるWeb標準のReadableStreamです。
Node.jsのWeb Streams APIには、WebストリームとNode.jsストリームを相互変換する方法も用意されています。
これを最後までまとめてarrayBuffer()へ変換せず、届いたチャンクから次の処理へ渡すことがポイントです。

ただし、ひとつだけ注意があります。

Aivis Cloud APIは、改行や対応するSSMLタグで区切られた単位ごとに音声を生成します。そのため、長い文章を改行せず1行で送ると、最初のチャンクが届くまでの時間を短縮しにくくなります。

一方で、単語や短いフレーズごとに細かく分けすぎると、音声が不自然になり、処理のオーバーヘッドも増えます。自然な意味のまとまりを保った1〜2文程度を目安に、改行や<s>タグで区切るのがおすすめです。

const text = [
  "今日は、Aivis Cloud APIのストリーミングについてご紹介します。",
  "音声は、生成できた部分から順にブラウザへ届けられます。",
  "そのため、長い文章でも完成を待たずに再生を始められます。",
].join("\n");

1行が200文字を超える場合は、API側でも文末記号の近くを探して自動分割します。
ただし、読み上げの自然さや間の取り方を調整したい場合は、送信前に文章の意味に合わせて区切るほうが扱いやすくなります。

Expressで音声ストリームをブラウザへ中継する

公開するWebサービスでは、Aivis Cloud APIのAPIキーをサーバー側だけで保持し、ブラウザには生成音声だけを返す構成が安全です。

src/server.tsを作り、次のように実装します。

// src/server.ts
import express from "express";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
  AivisApiError,
  requestSynthesis,
  type SynthesizeRequest,
} from "./aivis.js";

const modelUuid = process.env.AIVIS_MODEL_UUID;
if (!modelUuid) {
  throw new Error("環境変数 AIVIS_MODEL_UUID が設定されていません");
}

const app = express();
app.use(express.json({ limit: "32kb" }));
app.use(express.static("public"));

app.post("/api/tts", async (req, res) => {
  const text = req.body?.text;
  const characterCount =
    typeof text === "string" ? Array.from(text).length : 0;

  if (characterCount < 1 || characterCount > 3000) {
    res.status(400).json({
      message: "textは1文字以上3,000文字以下で指定してください",
    });
    return;
  }

  const controller = new AbortController();
  res.on("close", () => {
    if (!res.writableEnded) controller.abort();
  });

  const body = {
    model_uuid: modelUuid,
    text,
    output_format: "mp3",
    leading_silence_seconds: 0,
    use_volume_normalizer: true,
  } satisfies SynthesizeRequest;

  try {
    const upstream = await requestSynthesis(body, {
      signal: controller.signal,
    });

    res.status(200);
    res.setHeader(
      "Content-Type",
      upstream.headers.get("Content-Type") ?? "audio/mpeg",
    );
    res.setHeader("Cache-Control", "no-store");
    res.setHeader("X-Accel-Buffering", "no");

    await pipeline(Readable.fromWeb(upstream.body!), res);
  } catch (error) {
    if (controller.signal.aborted) return;

    if (error instanceof AivisApiError && !res.headersSent) {
      res.status(error.status).json({
        message: "音声を生成できませんでした",
      });
      return;
    }

    if (!res.headersSent) {
      res.status(500).json({ message: "サーバー内でエラーが発生しました" });
    } else {
      res.destroy();
    }
  }
});

app.listen(3000, () => {
  console.log("http://localhost:3000 で起動しました");
});

Readable.fromWeb()は、fetchで受け取ったWeb標準のストリームを、Node.jsのストリームへ変換します。
さらにpipeline()を使うことで、ブラウザ側の受信速度に合わせて読み込みを調整するバックプレッシャーと、ストリームの終了処理を任せられます。

ブラウザがページを閉じた場合はAbortControllerで上流のリクエストも中断します。不要になった音声をサーバーが受け取り続けないための処理です。

また、ストリーミング中はContent-Lengthを設定しません。
リバースプロキシやCDNがレスポンスをバッファリングすると、サーバー側が少しずつ送っていてもブラウザへまとめて届くことがあります。本番環境では、利用しているプロキシ側のバッファリング設定も確認してください。

⚠️ この中継APIをインターネットへ公開する場合は、ログイン確認、利用回数の制限、入力文字数の検証を追加してください。APIキーを隠していても、中継エンドポイントが誰でも使える状態では、第三者にクレジットを消費される可能性があります。

ブラウザで受信しながら再生する

サーバーがストリームを中継するだけでは、ブラウザが必ずすぐに再生してくれるとは限りません。受信したMP3チャンクをMediaSourceへ順番に追加し、audio要素へ渡します。

public/index.htmlを作成してください。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Aivis Cloud API Streaming Sample</title>
  </head>
  <body>
    <textarea id="text" rows="6" cols="60">今日は、Aivis Cloud APIのストリーミング再生を試します。
音声は、生成できた部分から順に再生されます。</textarea>
    <button id="play">音声を生成して再生</button>
    <audio id="audio" controls></audio>

    <script type="module">
      const button = document.querySelector("#play");
      const textarea = document.querySelector("#text");
      const audio = document.querySelector("#audio");

      const waitForUpdateEnd = (sourceBuffer) =>
        sourceBuffer.updating
          ? new Promise((resolve) =>
              sourceBuffer.addEventListener("updateend", resolve, {
                once: true,
              }),
            )
          : Promise.resolve();

      button.addEventListener("click", async () => {
        button.disabled = true;

        try {
          const MediaSourceClass =
            globalThis.MediaSource ?? globalThis.ManagedMediaSource;

          if (!MediaSourceClass?.isTypeSupported("audio/mpeg")) {
            throw new Error("このブラウザはMP3の逐次再生に対応していません");
          }

          const mediaSource = new MediaSourceClass();
          const objectUrl = URL.createObjectURL(mediaSource);
          audio.src = objectUrl;
          audio.disableRemotePlayback = true;
          audio.addEventListener(
            "ended",
            () => URL.revokeObjectURL(objectUrl),
            { once: true },
          );

          // クリック操作中に再生を予約し、自動再生制限を避ける
          const playPromise = audio.play();

          await new Promise((resolve) =>
            mediaSource.addEventListener("sourceopen", resolve, { once: true }),
          );

          const response = await fetch("/api/tts", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ text: textarea.value }),
          });

          if (!response.ok || !response.body) {
            throw new Error(`音声を生成できませんでした: HTTP ${response.status}`);
          }

          const sourceBuffer = mediaSource.addSourceBuffer("audio/mpeg");
          const reader = response.body.getReader();

          for (;;) {
            const { value, done } = await reader.read();
            if (done) break;

            await waitForUpdateEnd(sourceBuffer);
            sourceBuffer.appendBuffer(value);
          }

          await waitForUpdateEnd(sourceBuffer);
          mediaSource.endOfStream();
          await playPromise;
        } catch (error) {
          console.error(error);
          alert(error instanceof Error ? error.message : "再生に失敗しました");
        } finally {
          button.disabled = false;
        }
      });
    </script>
  </body>
</html>

サーバーを起動し、ブラウザでhttp://localhost:3000を開きます。

npx tsx src/server.ts

「音声を生成して再生」を押すと、Aivis Cloud APIから届いたMP3データがSourceBufferへ追加され、音声全体の生成完了を待たずに再生が始まります。

SourceBufferの更新中に次のチャンクを追加すると例外になるため、updateendを待ってからappendBuffer()を呼ぶのが重要です。
これは単にResponse.bodyをループで読むだけでは足りない理由でもあります。

ブラウザによってMediaSourceの対応状況や自動再生の制限は異なります。Aivis Cloud APIの公式デモと同じく、iOS SafariではManagedMediaSourceを利用する構成も考慮していますが、公開前には対象ブラウザの実機で確認してください。未対応環境向けには、レスポンス全体をBlobとして受け取ってから再生するフォールバックを用意すると安心です。

出力形式は用途に合わせて選ぶ

ストリーミング再生の例では、互換性を重視してMP3を使いました。
Aivis Cloud APIは、ほかにもWAV、FLAC、AAC、Opusを出力できます。

mp3:ブラウザやOSをまたいで扱いやすく、リアルタイム再生の第一候補
opus:圧縮効率と低遅延を重視したい場合に向く。ただし、再生環境の対応確認が必要
aac:互換性と圧縮効率のバランスを取りたい場合に使いやすい
flac:無劣化で保存したい場合に向く
wav:無圧縮で扱いやすい一方、ファイルサイズが大きく、リアルタイム用途には不向き

WAVはストリーミング時にRIFFヘッダーのデータサイズが未確定のまま配信されるため、厳密な一部のデコーダーでは読み込めないことがあります。
ブラウザでの逐次再生には、まずMP3を選ぶと実装しやすいでしょう。

leading_silence_seconds: 0を指定すると、音声先頭の無音をなくし、再生開始までの体感時間を短くできます。ただし、複数の音声を連結する用途では、前後の無音を完全に削ると詰まって聞こえることもあります。実際の文章に合わせて調整してください。

エラーは「待てば直るもの」と「設定を直すもの」に分ける

実運用では、すべてのエラーを同じように再試行しないことが大切です。

401 Unauthorized:APIキーが無効、またはAuthorizationヘッダーがない
402 Payment Required:クレジット残高が不足している
404 Not Found:指定したモデルが見つからない
422 Unprocessable Entity:リクエストパラメータが不正
429 Too Many Requests:レート制限に達している
500 / 502 / 503 / 504:サーバー障害やタイムアウトなど、一時的な可能性がある

401、402、404、422は、認証情報や設定、入力内容を修正する必要があります。
待つだけでは直らないため、利用者へ原因がわかるメッセージを返し、運用ログにも残します。

429は、レスポンスヘッダーのX-Aivis-RateLimit-Requests-Resetを確認し、制限が解除されるまで待ってから再試行します。
5xxも一時障害の可能性がありますが、回数を限定し、間隔を広げながら再送してください。

実装例では、指数バックオフに少しのランダム時間を加えています。
複数のリクエストが同時に失敗したとき、すべてが同じ瞬間に再送される「再試行の集中」を避けるためです。

⚠️ ネットワーク切断のように、サーバーで処理されたか判断できない失敗は、この例では自動再試行していません。処理済みのリクエストを重ねて送る可能性があるためです。必要に応じて、入力内容のハッシュを使ったキャッシュやジョブ管理をアプリ側で設計してください。

なお、ストリーミング開始後に接続が切れた場合は、HTTPステータスをエラーレスポンスへ変更できません。クライアント側でもreader.read()の失敗を検知し、「途中まで再生されたが完了しなかった」状態として扱う必要があります。

レスポンスヘッダーも運用に活用する

Aivis Cloud APIのレスポンスには、音声データだけでなく、利用状況を確認できるヘッダーも含まれます。

共通で確認できる主な項目は、課金モードを示すX-Aivis-Billing-Modeと、今回処理した文字数を示すX-Aivis-Character-Countです。

従量課金ではX-Aivis-Credits-UsedとX-Aivis-Credits-Remaining、定額プランではレート制限の上限・残数・リセットまでの秒数も返ります。

これらをサーバー側のメトリクスへ記録しておくと、クレジット残高の低下やレート制限への接近を早めに検知できます。
ただし、ブラウザへそのまま転送する必要はありません。
運用に必要な情報だけをサーバー側で扱いましょう。

よくある質問

Q. Aivis Cloud API専用のNode.js SDKは必要ですか?

必要ありません。

HTTPリクエストを送れる環境であれば利用でき、Node.jsでは標準のfetchで実装できます。
TypeScriptの型は手書きするほか、OpenAPI定義から自動生成できます。

Q. stream: trueはどこへ指定しますか?

指定する必要はありません。

POST /v1/tts/synthesizeのレスポンスそのものがストリーミング配信されます。arrayBuffer()で全体を待つか、Response.bodyを順次読むかは、クライアント側で選びます。

Q. テキストは何文字まで送れますか?

1回のリクエストで最大3,000文字です。

長い原稿は意味のまとまりで分割してください。
低遅延を狙って細かく分けすぎると、読み上げが途切れやすくなるため、自然さとのバランスが大切です。

Q. ブラウザからAivis Cloud APIを直接呼べますか?

音声合成APIはブラウザからのクロスオリジンアクセスに対応しているため、技術的には直接呼び出せます。
ただし、公開サイトのJavaScriptへAPIキーを含めると、開発者ツールなどから確認できてしまいます。

手元での検証や利用者自身がキーを入力するツールを除き、公開サービスではサーバー経由の構成をおすすめします。

Q. ストリームを再生しながら保存することもできますか?

できます。

Node.js側でPassThroughなどを使ってストリームを分岐し、一方をブラウザへ、もう一方をファイルやオブジェクトストレージへ送ります。
ただし、片方の書き込みが遅い場合のバックプレッシャーや、途中失敗時に不完全なファイルをどう扱うかも決めておきましょう。

まとめ

Node.js / TypeScriptからAivis Cloud APIを使うときは、APIを呼び出すこと自体よりも、音声データを「全部待つか」「届いた順に流すか」を先に決めることが大切

短い音声をファイルへ保存するだけなら、fetchとarrayBuffer()で十分です。リアルタイム再生ではResponse.bodyをストリームのまま扱い、ブラウザ側でもMediaSourceへ順番に追加します。

TypeScriptの排他型、入力値の実行時チェック、ステータス別の再試行、接続切断時の中断処理まで用意しておくと、検証用のコードをそのまま本番へ持ち込んだときのトラブルを減らせます。

まずはMP3ファイルを1本保存するところから試し、動作を確認できたらストリーミング中継へ進んでみてください✨

以上で、この記事はおしまいです。
Aivis ProjectやAivis Cloud APIに関してご不明点やご相談がある場合には、お問い合わせフォームよりお気軽にご連絡ください。

🔗 関連リンク
Aivis Cloud APIドキュメント
Aivis Cloud APIダッシュボード
Aivis Cloud API OpenAPI定義
AivisHub(音声合成モデル共有プラットフォーム)
Node.jsのfetchドキュメント
Node.jsのWeb Streams API
openapi-typescript CLI

いいなと思ったら応援しよう!