メインコンテンツへスキップ
友田 陽大
音声・ボイスAI
Next.js
音声合成
フロントエンド
Qwen
a11y
TypeScript

Next.js × Qwen-TTS:アクセシブルな「記事読み上げ」プレイヤーを本番品質で実装する(WCAG 2.2・型安全・キャッシュ)

Next.js 16 と Qwen-TTS で、記事やドキュメントを読み上げるアクセシブルな音声プレイヤーを実装するガイド。サーバー側でのTTS生成(Zod検証・内容ハッシュのキャッシュ・鍵の秘匿)と、WCAG 2.2準拠のReactプレイヤー(キーボード操作・aria-live・自動再生なし・prefers-reduced-motion・フォーカス管理)を、型安全な実コードで解説します。

公開日
読了時間
10分
著者
友田 陽大
シェア

「記事を音声で聴けるようにしたい」——アクセシビリティ向上にも、ながら聴取のUXにも効く施策です。ところが素朴に実装すると、自動再生でスクリーンリーダーを妨害し、マウス前提の操作でキーボード利用者を締め出し、読み込み中かエラーかも分からない——せっかくの善意がWCAG違反になりがちです。

この記事は、Next.js 16 と Qwen-TTS で、本当にアクセシブルな読み上げプレイヤーを本番品質で実装するガイドです。サーバー側のTTS生成(鍵の秘匿・キャッシュ)と、WCAG 2.2準拠のReactプレイヤーを、型安全な実コードで示します。a11yの基礎はNext.js × React アクセシビリティ(WCAG 2.2)ガイドに揃えています。

この記事のルール:Qwen-TTSのAPI仕様は公式ドキュメント(2026年6月時点)に基づきます。コードは Next.js 16 / React 19 を前提に、そのまま転用できる形で書いています。APIキーは環境変数・サーバー側のみ(ブラウザに出さない)です。


0. 設計:責務を2つに割る

読み上げ機能は、性質の違う2つの責務に分かれます。混ぜると鍵漏洩か密結合になります(SRP)。

[サーバー]  原稿 → Qwen-TTSで合成 → 内容ハッシュでキャッシュ → 安定URLを返す(鍵を保持)
[クライアント]  安定URLを受け取り → アクセシブルに再生(自動再生しない・キーボード可・状態告知)
  • サーバー=生成とコストと秘密の管理(鍵・キャッシュ・検証)。
  • クライアント=再生とアクセシビリティ(操作・状態・フォーカス)。

この分割により、TTSプロバイダを差し替えても(他社TTSへの選定変更)クライアントは無傷です(ETC)。


1. サーバー:生成・検証・キャッシュ・鍵の秘匿

Route Handler で、境界をZodで検証し、内容ハッシュでキャッシュします。同じ原稿・音色・言語なら二度と生成しない——これが冪等であり、最大のコスト削減です。

// app/api/tts/route.ts — サーバー専用。APIキーはここから出ない。
import { z } from "zod";
import { createHash } from "node:crypto";
import { put, head } from "@vercel/blob"; // 保存先は任意(S3等でも可)

// 許可する音色・言語を型で固定(不正値をAPIに渡さない=課金事故と例外を防ぐ)
const VOICES = ["Cherry", "Ethan", "Serena", "Jennifer"] as const;
const LANGS = ["Japanese", "English", "Chinese", "Korean"] as const;

const Body = z.object({
  text: z.string().min(1).max(4000),
  voice: z.enum(VOICES).default("Cherry"),
  language_type: z.enum(LANGS).default("Japanese"),
});

/** 入力で一意に決まる決定的キー。同入力 → 同キー → 再生成しない(冪等)。 */
function cacheKey(input: z.infer<typeof Body>): string {
  return createHash("sha256")
    .update(`qwen3-tts-flash\0${input.voice}\0${input.language_type}\0${input.text}`)
    .digest("hex");
}

export async function POST(req: Request): Promise<Response> {
  const parsed = Body.safeParse(await req.json());
  if (!parsed.success) {
    return Response.json({ error: parsed.error.flatten() }, { status: 400 });
  }
  const key = cacheKey(parsed.data);
  const blobPath = `tts/${key}.wav`;

  // 1) キャッシュヒットなら即返す(生成も課金も発生しない)
  const cached = await head(blobPath).catch(() => null);
  if (cached) return Response.json({ url: cached.url, cached: true });

  // 2) ミス時のみ Qwen-TTS で生成(鍵はサーバー環境変数)
  const upstream = await fetch(
    "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.DASHSCOPE_API_KEY!}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ model: "qwen3-tts-flash", input: parsed.data }),
    },
  );
  if (!upstream.ok) return Response.json({ error: "tts_failed" }, { status: 502 });

  // 3) 生成URLは24時間で失効するため、自前ストレージへ即退避(重要)
  const { output } = (await upstream.json()) as { output: { audio: { url: string } } };
  const audio = await fetch(output.audio.url).then((r) => r.arrayBuffer());
  const saved = await put(blobPath, audio, { access: "public", contentType: "audio/wav" });

  return Response.json({ url: saved.url, cached: false });
}

ポイントは3つ:①鍵はサーバーのみ ②内容ハッシュで冪等キャッシュ(コスト削減+高速化)③24時間で消える一時URLを自前ストレージへ退避。Qwen-TTSの料金・モデルの詳細は本番運用ガイドを参照。


2. クライアント:WCAG 2.2準拠のプレイヤー

ここが本記事の主役です。HTML5 <audio> を土台に、カスタムUIをアクセシブルに被せます。要件:自動再生しない・全操作キーボード可・状態をスクリーンリーダーに告知・prefers-reduced-motion尊重・再生速度可変。

"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";
import { Play, Pause, Loader2, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";

type Status = "idle" | "loading" | "ready" | "playing" | "paused" | "error";

interface Props {
  /** 読み上げる原稿。画面にも必ず併記すること(WCAG 1.2.1)。 */
  text: string;
  voice?: "Cherry" | "Ethan" | "Serena" | "Jennifer";
  language?: "Japanese" | "English" | "Chinese" | "Korean";
}

const SPEEDS = [0.75, 1, 1.25, 1.5, 2] as const;

export function ReadAloud({ text, voice = "Cherry", language = "Japanese" }: Props) {
  const audioRef = useRef<HTMLAudioElement>(null);
  const [status, setStatus] = useState<Status>("idle");
  const [progress, setProgress] = useState(0); // 0..1
  const [duration, setDuration] = useState(0);
  const [speed, setSpeed] = useState<(typeof SPEEDS)[number]>(1);
  const statusId = useId();

  // 初回再生時に「のみ」生成リクエスト(自動では絶対に鳴らさない=WCAG 1.4.2)
  const ensureLoaded = useCallback(async (): Promise<boolean> => {
    const el = audioRef.current;
    if (!el || el.src) return true;
    setStatus("loading");
    try {
      const res = await fetch("/api/tts", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text, voice, language_type: language }),
      });
      if (!res.ok) throw new Error("tts_failed");
      const { url } = (await res.json()) as { url: string };
      el.src = url;
      setStatus("ready");
      return true;
    } catch {
      setStatus("error");
      return false;
    }
  }, [text, voice, language]);

  const toggle = useCallback(async () => {
    const el = audioRef.current;
    if (!el) return;
    if (el.paused) {
      if (!(await ensureLoaded())) return;
      await el.play().catch(() => setStatus("error")); // ユーザー操作起点でのみ再生
    } else {
      el.pause();
    }
  }, [ensureLoaded]);

  const onSeek = useCallback((value: number) => {
    const el = audioRef.current;
    if (el && Number.isFinite(el.duration)) el.currentTime = value * el.duration;
  }, []);

  // 再生速度をオーディオ要素へ反映
  useEffect(() => {
    if (audioRef.current) audioRef.current.playbackRate = speed;
  }, [speed]);

  const isBusy = status === "loading";
  const isPlaying = status === "playing";
  const label = isPlaying ? "一時停止" : status === "loading" ? "読み込み中" : "読み上げを再生";

  return (
    <section
      aria-label="記事の音声読み上げ"
      className="flex items-center gap-3 rounded-xl border bg-card p-3"
    >
      {/* 再生/一時停止:name/role/value を満たす(WCAG 4.1.2)。aria-pressedで状態を表現 */}
      <button
        type="button"
        onClick={toggle}
        disabled={status === "error"}
        aria-pressed={isPlaying}
        aria-label={label}
        aria-describedby={statusId}
        className={cn(
          "inline-flex size-11 shrink-0 items-center justify-center rounded-full",
          "bg-primary text-primary-foreground transition-colors",
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
          "disabled:opacity-50",
        )}
      >
        {isBusy ? (
          // prefers-reduced-motionを尊重:motion-reduce で回転を止める
          <Loader2 className="size-5 animate-spin motion-reduce:animate-none" aria-hidden />
        ) : status === "error" ? (
          <AlertCircle className="size-5" aria-hidden />
        ) : isPlaying ? (
          <Pause className="size-5" aria-hidden />
        ) : (
          <Play className="size-5 translate-x-0.5" aria-hidden />
        )}
      </button>

      {/* シーク:ネイティブ range はキーボード操作可。時刻を aria-valuetext で読み上げ */}
      <input
        type="range"
        min={0}
        max={1}
        step={0.01}
        value={progress}
        onChange={(e) => onSeek(Number(e.target.value))}
        aria-label="再生位置"
        aria-valuetext={`${formatTime(progress * duration)} / ${formatTime(duration)}`}
        className="h-1.5 flex-1 cursor-pointer accent-primary"
      />

      <span className="tabular-nums text-xs text-muted-foreground" aria-hidden>
        {formatTime(progress * duration)}
      </span>

      {/* 再生速度:label と結びついた select(キーボード可・名前あり) */}
      <label className="text-xs text-muted-foreground">
        <span className="sr-only">再生速度</span>
        <select
          value={speed}
          onChange={(e) => setSpeed(Number(e.target.value) as (typeof SPEEDS)[number])}
          className="rounded-md border bg-background px-1.5 py-1 focus-visible:ring-2 focus-visible:ring-ring"
        >
          {SPEEDS.map((s) => (
            <option key={s} value={s}>{s}×</option>
          ))}
        </select>
      </label>

      {/* 状態の告知:視覚に依存せずスクリーンリーダーへ(WCAG 4.1.3) */}
      <span id={statusId} role="status" aria-live="polite" className="sr-only">
        {status === "loading" && "音声を準備しています"}
        {status === "playing" && "再生中"}
        {status === "paused" && "一時停止中"}
        {status === "error" && "音声の読み込みに失敗しました"}
      </span>

      <audio
        ref={audioRef}
        preload="none" // 自動ダウンロードしないコスト帯域自動再生防止onPlay={() => setStatus("playing")}
        onPause={() => audioRef.current && !audioRef.current.ended && setStatus("paused")}
        onTimeUpdate={(e) => {
          const el = e.currentTarget;
          if (Number.isFinite(el.duration)) setProgress(el.currentTime / el.duration);
        }}
        onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
        onEnded={() => { setStatus("ready"); setProgress(0); }}
        onError={() => setStatus("error")}
      />
    </section>
  );
}

function formatTime(sec: number): string {
  if (!Number.isFinite(sec)) return "0:00";
  const m = Math.floor(sec / 60);
  const s = Math.floor(sec % 60);
  return `${m}:${s.toString().padStart(2, "0")}`;
}

3. アクセシビリティの要点(このコードが満たすWCAG)

上のプレイヤーは、次のWCAG 2.2達成基準を設計で満たしています。

達成基準どう満たすか
1.2.1 音声のみ(収録済み)読み上げと同じテキストを画面に併記(音声だけにしない)。プレイヤーはあくまで補助。
1.4.2 音声の制御自動再生しないpreload="none"+ユーザー操作起点)。再生/一時停止/速度を提供。
2.1.1 キーボードネイティブ button / input[range] / select全操作キーボード可
2.4.7 フォーカスの可視化focus-visible:ring-* で明確なフォーカスリング。
4.1.2 名前・役割・値aria-labelaria-pressedaria-valuetext でカスタムUIに意味を付与。
4.1.3 ステータスメッセージrole="status" + aria-live="polite" で読み込み/エラーをフォーカスを奪わず告知。
動きへの配慮スピナーは motion-reduce:animate-noneprefers-reduced-motion を尊重。

最重要は 1.2.1:プレイヤーが格好良くても、音声でしか得られない情報を作らないことが本質です。読み上げは「テキストの代替手段」であって「テキストの代わり」ではありません。


4. パフォーマンスとコスト

  • 生成は遅延(lazy)preload="none"+初回再生時のみ生成。開いただけでは課金しない
  • 内容ハッシュで冪等キャッシュ:同じ記事は一度だけ生成。2人目以降は即・無料で再生(第1章)。
  • 意図予測のプリフェッチ(任意):再生ボタンに onPointerEnter を付け、押す前に生成を先行させると体感が向上(ただし無駄打ち課金とのトレードオフ。アクセス頻度の高い記事だけに限定)。
  • RSCで土台を配信:プレイヤーだけ "use client"、本文はサーバーコンポーネントのまま。バンドルを最小化(Next.js 16のキャッシュ設計)。

5. 型安全・エラー・回復性

  • 境界はZod:サーバーは入力を safeParse、クライアントは音色/言語をユニオン型で固定。不正な状態を表現不能にする(型安全の規律)。
  • エラーは握りつぶさない:生成失敗は status="error" にし、UIで明示+リトライ可能に。スクリーンリーダーにも aria-live で告知。
  • 状態は有限集合Statusidle | loading | ready | playing | paused | error の直和で表し、UI分岐を網羅的に。
  • テスト容易性:プレイヤーの状態遷移はPlaywrightでキーボード操作とaria属性を検証(@axe-coreでWCAGスイープ)。

6. まとめ:実装チェックリスト

  • 責務分割:サーバー(生成・鍵・キャッシュ)/クライアント(再生・a11y)。
  • サーバー:Zod検証・内容ハッシュの冪等キャッシュ・鍵の秘匿・24h URLの即退避。
  • クライアント:自動再生しない・全操作キーボード可・aria-liveで状態告知・prefers-reduced-motion尊重。
  • WCAG:1.2.1(テキスト併記)/1.4.2(自動再生なし)/2.1.1/2.4.7/4.1.2/4.1.3。
  • コスト:lazy生成+キャッシュ=「開いただけで課金」を防ぐ。
  • 型安全:状態は有限直和、境界はZod、エラーは可視化+告知。

読み上げ機能は「付ければアクセシブル」ではありません。自動再生しない・キーボードで完結・状態を告知する——この設計があって初めて、誰にとっても使える機能になります。私は学習プラットフォームやコンテンツ基盤で、UX・a11y・コストを同時に満たすフロントエンドを作ってきました(サブスク学習プラットフォーム)。「自社のコンテンツを、誰でも聴ける形で・速く・安く届ける」——設計から実装・テストまで一気通貫で伴走します。 お気軽にご相談ください。


参考(公式ドキュメント)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

金融リテラシー教育のサブスク学習プラットフォーム(学習コンテンツの音声化と相性が良い領域)

ケーススタディを見る