🛰️

Next.jsでNASA画像を日本語翻訳&音声読み上げするページを作ってみた

に公開

前回はこちら

きっかけ

  • ずんだもんの音声APIが公開される

https://x.com/t_zunko/status/1993203676270542908?s=20

  • 説明文読み上げさせれば面白くない?
  • ChatGPT「"技術的にはできる"ゾ!これはサーバーで計算。声を生成してデータ返すやつだから
    他にサーバー立てないと無理やで^^」
  • "技術的にできる"これはよくある考え直せよの比喩なのは誰もがおなじみなので諦め。
  • 悔しいのでMyMemory(無料版)を搭載した翻訳と音声再生機能実装

使ったシステム

本題

例によってChatGPT頼りにコード追記

コンポーネントの追加

まずは翻訳+音声流す+止めるの機能を作成
(Headerと同じ感じで翻訳機能ブロックを作る)

// app/components/ApodDescription.tsx
"use client";

import { useState } from "react";

type Props = {
  text: string;
};

export default function ApodDescription({ text }: Props) {
  const [translated, setTranslated] = useState("");
  const [speaking, setSpeaking] = useState(false);
  const [utterance, setUtterance] = useState<SpeechSynthesisUtterance | null>(null);

  const translateText = async () => {
    if (!text) return;
    const res = await fetch(
      `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=en|ja`
    );
    const data = await res.json();
    setTranslated(data.responseData.translatedText);
  };

  const speak = () => {
    if (!translated) return;
    const u = new SpeechSynthesisUtterance(translated);
    u.rate = 1;    // 速度
    u.pitch = 1;   // 高さ
    u.onend = () => setSpeaking(false);
    setUtterance(u);
    setSpeaking(true);
    speechSynthesis.speak(u);
  };

  const stop = () => {
    if (utterance) {
      speechSynthesis.cancel();
      setSpeaking(false);
    }
  };

  return (
    <div className="mt-4">
      <button onClick={translateText} className="mr-2 px-2 py-1 border rounded">
        翻訳
      </button>
      <button onClick={speak} className="mr-2 px-2 py-1 border rounded" disabled={!translated || speaking}>
        再生
      </button>
      <button onClick={stop} className="px-2 py-1 border rounded" disabled={!speaking}>
        停止
      </button>

      <p className="mt-2">{translated || text}</p>
    </div>
  );
}

そんでHomeも書き換え

// app/page.tsx の説明文部分を置き換え
import ApodDescription from "./components/ApodDescription";

そしてコンポーネントの適用

export default async function Home() {
  const data = await getLatestAPOD();

  return (
    <div>
      <Header />
      <main className="p-4">
        <h2 className="text-2xl font-bold mb-2">{data.title}</h2>
        <p className="text-gray-600 mb-4">{data.date}</p>

        {data.media_type === "image" ? (
          <div className="mb-4 w-full max-w-full h-[500px] sm:h-[600px] md:h-[700px] overflow-hidden rounded-lg">
            <img src={data.url} alt={data.title} className="w-full h-full object-contain" />
          </div>
        ) : (
          <iframe src={data.url} allow="fullscreen" className="mb-4 rounded-lg w-full aspect-video" />
        )}

        <ApodDescription text={data.explanation} /> //ここをPタグからコンポーネントに。
      </main>
    </div>
  );

エラー発生

QUERY LENGTH LIMIT EXCEEDED. MAX ALLOWED QUERY : 500 CHARS

つまり 文字数多いから無理! とのこと。。。
便利な機能にあやかりたかったら有料版使えというのが翻訳機能の常。
(まあしゃーない)

エラー対策

結局初心者なのでGPT泣きつきます。

  1. 文章を分割して送る方法
    -500文字ずつに分けて翻訳
    -フロントで少し処理が増えるが無料で試せる
  2. 翻訳をサーバー側に任せる方法
    -Vercel の API Route 経由で POST 送信
    -文字数制限を回避できる
    -ただし公式API(DeepL, Google Translate)だと 有料プラン必須

なので、完全無料で「長文・自動翻訳+音声読み上げ」をやるのは
フロントでの分割処理でなんとかする か 短い文章だけ翻訳 という形になります。

フロントで分割する方法

今回は無料の方法で作り上げます

// app/components/ApodDescription.tsx
"use client";

import { useState } from "react";

type Props = {
  text: string;
};

export default function ApodDescription({ text }: Props) {
  const [translated, setTranslated] = useState("");
  const [speaking, setSpeaking] = useState(false);
  const [utterance, setUtterance] = useState<SpeechSynthesisUtterance | null>(null);
  const MAX_LEN = 500; // MyMemoryの1リクエスト最大文字数

  // 文字列を500文字以内に分割
  const chunkText = (str: string) => {
    const chunks: string[] = [];
    let start = 0;
    while (start < str.length) {
      chunks.push(str.slice(start, start + MAX_LEN));
      start += MAX_LEN;
    }
    return chunks;
  };

  const translateText = async () => {
    const chunks = chunkText(text);
    const translatedChunks: string[] = [];

    for (const chunk of chunks) {
      const res = await fetch(
        `https://api.mymemory.translated.net/get?q=${encodeURIComponent(
          chunk
        )}&langpair=en|ja`
      );
      const data = await res.json();
      translatedChunks.push(data.responseData.translatedText);
    }

    setTranslated(translatedChunks.join("")); // チャンクをまとめる
  };

  const speak = () => {
    if (!translated) return;
    const u = new SpeechSynthesisUtterance(translated);
    u.rate = 1;
    u.pitch = 1;
    u.onend = () => setSpeaking(false);
    setUtterance(u);
    setSpeaking(true);
    speechSynthesis.speak(u);
  };

  const stop = () => {
    if (utterance) {
      speechSynthesis.cancel();
      setSpeaking(false);
    }
  };

  return (
    <div className="mt-4">
      <button
        onClick={translateText}
        className="mr-2 px-2 py-1 border rounded"
        disabled={translated.length > 0}
      >
        翻訳
      </button>
      <button
        onClick={speak}
        className="mr-2 px-2 py-1 border rounded"
        disabled={!translated || speaking}
      >
        再生
      </button>
      <button
        onClick={stop}
        className="px-2 py-1 border rounded"
        disabled={!speaking}
      >
        停止
      </button>

      <p className="mt-2">{translated || text}</p>
    </div>
  );
}

つまり500区切りでプール、
最終的にガッチャンコ(古い)して出力するということだ。

実装し、ローカルでテスト

Runtime TypeErrorServer fetch failed

またもエラーだ。ローカルでテストした旨も伝えると以下の回答が。

ローカルブラウザでも fetch failed が出る場合は CORSやネットワークの問題
が原因である可能性が高いです。

ポイント

  • https://api.mymemory.translated.net はブラウザから直接呼ぶと
    CORS制限で弾かれることがある
  • Server Component からだとさらに fetch failed が出やすい
    (サーバー側では CORS がなくても API 側が拒否する場合あり)

解決策

  1. ブラウザ拡張やローカルプロキシを使う
  • 開発中だけなら Chrome の CORS ブロッカー拡張で回避可能
  • 実運用には向かない
  1. Vercel API Route 経由で翻訳する
  • フロントから直接 MyMemory API にアクセスせず、サーバー側で fetch
  • ブラウザ→自サーバー→API なので CORS を回避できる
  • 無料枠で可能

無料がいいので2番でお願いします!

APIルートの作成

// app/api/translate/route.ts
import { NextRequest, NextResponse } from "next/server";

const MAX_LEN = 500; // MyMemory 1リクエストの最大文字数

export async function GET(req: NextRequest) {
  const text = req.nextUrl.searchParams.get("text");
  if (!text) return NextResponse.json({ error: "textが指定されていません" }, { status: 400 });

  // 文字列を分割
  const chunks: string[] = [];
  let start = 0;
  while (start < text.length) {
    chunks.push(text.slice(start, start + MAX_LEN));
    start += MAX_LEN;
  }

  const translatedChunks: string[] = [];

  for (const chunk of chunks) {
    const res = await fetch(
      `https://api.mymemory.translated.net/get?q=${encodeURIComponent(
        chunk
      )}&langpair=en|ja`
    );

    if (!res.ok) {
      const msg = await res.text();
      return NextResponse.json({ error: msg }, { status: res.status });
    }

    const data = await res.json();
    translatedChunks.push(data.responseData.translatedText);
  }

  return NextResponse.json({ translated: translatedChunks.join("") });
}

ポイント

  • 文字列を500文字ごとに分割
  • 各チャンクを MyMemory に送信
  • 結果をまとめて返す

それに伴いコンポーネント側から分割の仕組みを削除

// app/components/ApodDescription.tsx
"use client";

import { useState } from "react";

type Props = {
  text: string;
};

export default function ApodDescription({ text }: Props) {
  const [translated, setTranslated] = useState("");
  const [speaking, setSpeaking] = useState(false);
  const [utterance, setUtterance] = useState<SpeechSynthesisUtterance | null>(null);

  const translateText = async () => {
    const res = await fetch(`/api/translate?text=${encodeURIComponent(text)}`);
    const data = await res.json();
    if (data.translated) setTranslated(data.translated);
  };

  const speak = () => {
    if (!translated) return;
    const u = new SpeechSynthesisUtterance(translated);
    u.rate = 1;
    u.pitch = 1;
    u.onend = () => setSpeaking(false);
    setUtterance(u);
    setSpeaking(true);
    speechSynthesis.speak(u);
  };

  const stop = () => {
    if (utterance) {
      speechSynthesis.cancel();
      setSpeaking(false);
    }
  };

  return (
    <div className="mt-4">
      <button
        onClick={translateText}
        className="mr-2 px-2 py-1 border rounded"
        disabled={translated.length > 0}
      >
        翻訳
      </button>
      <button
        onClick={speak}
        className="mr-2 px-2 py-1 border rounded"
        disabled={!translated || speaking}
      >
        再生
      </button>
      <button
        onClick={stop}
        className="px-2 py-1 border rounded"
        disabled={!speaking}
      >
        停止
      </button>

      <p className="mt-2">{translated || text}</p>
    </div>
  );
}

ポイント

  • フロントは API Route を叩くだけ → CORS の心配なし
  • 文字数制限もサーバー側で分割して対応
  • 音声再生はブラウザの SpeechSynthesis を使用
  • ボタン制御で再生・停止が可能

補足

実装でき、翻訳ボタンを押すと
Runtime SyntaxError JSON.parse: unexpected character at line 1 column 1 of the JSON data
とissueが表示されてしまう
→ "app/api/translate/route.ts" ではなく、"app/api/"直下に置いていたのが原因でした。
(凡ミス)

実装完了。しかし日本語が流れない。

翻訳はばっちり、しかし音声が英語のままという理想とは違う状態に。
これは単純に言語設定をしていなかっただけ。

const speak = () => {
  if (!translated) return;

  const u = new SpeechSynthesisUtterance(translated);
  u.lang = "ja-JP"; // ← ここを追加
  u.rate = 1;        // 速度
  u.pitch = 1;       // 高さ
  u.onend = () => setSpeaking(false);

  setUtterance(u);
  setSpeaking(true);
  speechSynthesis.speak(u);
};

これでばっちり

今度は原文に戻したい…戻したくない?

今のままでは翻訳が一方通行なので、理想の形にしたいです。
理想は

  • ボタンの表示が最初は「翻訳」→クリックでボタンが「原文」、本文と音声は「日本語」
  • もう一度「原文」をクリックすると最初の状態にもどり、本文と音声が「英語」
    にしたい。
  • ただし、ボタンを押すたびにAPI回るのはよろしくないので翻訳結果は保持したい。
    人間はなんて欲深くて愚かなのでしょう。

欲望をかなえるコード

またもコンポーネントをいじればいいことはわかる。
ただし、どこをいじればいいのかわからん。先生!お願いします!

"use client";
import { useState, useEffect } from "react";

type Props = { text: string };

export default function ApodDescription({ text }: Props) {
  const [translated, setTranslated] = useState("");
  const [showTranslation, setShowTranslation] = useState(false); // false = 原文表示
  const [speaking, setSpeaking] = useState(false);
  const [utterance, setUtterance] = useState<SpeechSynthesisUtterance | null>(null);

  // 初回に翻訳を取得
  useEffect(() => {
    const fetchTranslation = async () => {
      const res = await fetch(`/api/translate?text=${encodeURIComponent(text)}`);
      const data = await res.json();
      if (data.translated) setTranslated(data.translated);
    };
    fetchTranslation();
  }, [text]);

  // ボタン押下で表示切替
  const toggleText = () => {
    setShowTranslation(prev => !prev);
  };

  // 音声再生
  const speak = () => {
    const content = showTranslation ? translated : text;
    if (!content) return;

    const u = new SpeechSynthesisUtterance(content);
    u.lang = showTranslation ? "ja-JP" : "en-US"; // 状態に応じて言語切替
    u.rate = 1;
    u.pitch = 1;
    u.onend = () => setSpeaking(false);

    setUtterance(u);
    setSpeaking(true);
    speechSynthesis.speak(u);
  };

  const stop = () => {
    if (utterance) {
      speechSynthesis.cancel();
      setSpeaking(false);
    }
  };

  return (
    <div className="mt-4">
      <button onClick={toggleText} className="mr-2 px-2 py-1 border rounded">
        {showTranslation ? "原文" : "翻訳"} {/* 切り替え先を表示 */}
      </button>
      <button
        onClick={speak}
        className="mr-2 px-2 py-1 border rounded"
        disabled={showTranslation && !translated || speaking}
      >
        再生
      </button>
      <button onClick={stop} className="px-2 py-1 border rounded" disabled={!speaking}>
        停止
      </button>

      <p className="mt-2">{showTranslation ? translated || "翻訳中..." : text}</p>
    </div>
  );
}

ポイント

  • 初期表示は原文 → showTranslation = false
  • ボタンは切り替え先を表示 → 原文なら「翻訳」、翻訳中なら「原文」
  • 音声読み上げも状態に応じて切り替え → 原文は英語、翻訳文は日本語
  • 翻訳APIは初回に一度だけ呼び出す → 再翻訳不要
    これで 希望通りの挙動 になります。

言語の補足

そういやlang のとこ、auto にしたら自動化できない?
せっかくなので聞いてみた
~以下、回答~

なるほど、SpeechSynthesisUtterance.lang を自動判定(auto)にしたい、ということですね。
残念ながら Web Speech API(SpeechSynthesis)では lang に "auto" のような値はサポートされていません。

  • lang は ISO 言語コード(例:en-US / ja-JP)で指定する必要があります
  • 指定しない場合は ブラウザのデフォルト言語設定 が使われます
  • ただし多くの場合 en-US やシステム言語に固定され、翻訳文が日本語でも英語読みになることがあります
  • 自動判定したい場合は 自前で言語を判定 する必要があります
  • 例えば翻訳状態を知っていれば showTranslation ? "ja-JP" : "en-US" のように切り替え
  • もしくは簡易的に文字列に日本語文字が含まれているかで判定することも可能
    ~以上、回答~
    …つまりはバリデーション入れて判定システム作ってね。とのこと。ウッ。

実装結果

まとめ

  1. 翻訳機能をサーバー経由に変更
  • ブラウザから直接翻訳APIを呼ぶとCORSや文字数制限で失敗する可能性がある
  • Next.jsのAPI Routeを経由することで安定して翻訳できるように
  1. 翻訳結果をキャッシュ
  • 一度翻訳したテキストを状態に保持
  • 「翻訳」/「原文」ボタンで切り替え可能
  1. 音声読み上げの言語を切り替え
  • 原文 → 英語 (en-US)
  • 翻訳文 → 日本語 (ja-JP)
  1. 再生・停止ボタンで操作可能
  • ボタン表記は「切り替え先」を示す
  • 初期表示:本文は原文、ボタンは「翻訳」
  • 翻訳クリック後:本文は日本語、ボタンは「原文」
  1. 翻訳文の文字数制限対応
  • 無料翻訳API(MyMemory)1リクエスト500文字制限に対応するため、長文は分割して送信

編集後記

  • 「意見を出すのは簡単、実装は大変」を経験するいい機会になった。
  • 将来的にずんだもんボイスは諦めてないです。じわじわ進めたい。
  • Googleでも財団でもどこでもいいのでAPI実験用の無料サーバーとか
    需要あると思うんだけど、どすか?(セキュリティ度外視過ぎて危険思想)

Discussion