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

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

- 公開日: 2026-06-25
- 著者: 友田 陽大
- タグ: Next.js, 音声合成, フロントエンド, Qwen, a11y, TypeScript
- URL: https://tomodahinata.com/blog/nextjs-qwen-tts-accessible-audio-player-text-to-speech

## 要点

- TTSはアクセシビリティの強力な味方になり得るが、自動再生・キーボード非対応・状態の無告知で簡単にWCAG違反になる
- 生成はサーバー側に閉じる：Zodで境界検証し、内容ハッシュでキャッシュし、APIキーはブラウザに出さない
- 同じ原稿は二度生成しない（内容ハッシュ＝冪等キャッシュ）ことで、コストと初回以降の体感を同時に改善する
- プレイヤーは自動再生しない・全操作キーボード可・aria-liveで状態告知・prefers-reduced-motion尊重をデフォルトにする
- 読み上げと同じテキストを必ず画面に併記し、音声“だけ”の情報にしない（WCAG 1.2.1）

---

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

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

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

---

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

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

```text
[サーバー]  原稿 → Qwen-TTSで合成 → 内容ハッシュでキャッシュ → 安定URLを返す（鍵を保持）
[クライアント]  安定URLを受け取り → アクセシブルに再生（自動再生しない・キーボード可・状態告知）
```

- **サーバー**＝生成とコストと秘密の管理（鍵・キャッシュ・検証）。
- **クライアント**＝再生とアクセシビリティ（操作・状態・フォーカス）。

この分割により、TTSプロバイダを差し替えても（[他社TTSへの選定変更](/blog/qwen-tts-vs-elevenlabs-openai-google-azure-tts-comparison)）クライアントは無傷です（ETC）。

---

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

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

```ts
// 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の料金・モデルの詳細は[本番運用ガイド](/blog/qwen-tts-qwen3-tts-flash-production-guide)を参照。

---

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

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

```tsx
"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-label`・`aria-pressed`・`aria-valuetext` でカスタムUIに意味を付与。 |
| **4.1.3 ステータスメッセージ** | `role="status"` + `aria-live="polite"` で読み込み/エラーを**フォーカスを奪わず**告知。 |
| **動きへの配慮** | スピナーは `motion-reduce:animate-none` で `prefers-reduced-motion` を尊重。 |

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

---

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

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

---

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

- **境界はZod**：サーバーは入力を `safeParse`、クライアントは音色/言語をユニオン型で固定。**不正な状態を表現不能に**する（[型安全の規律](/blog/typescript-type-safety-discipline-zod-nevererror-no-any)）。
- **エラーは握りつぶさない**：生成失敗は `status="error"` にし、UIで明示＋リトライ可能に。スクリーンリーダーにも `aria-live` で告知。
- **状態は有限集合**：`Status` を `idle | loading | ready | playing | paused | error` の直和で表し、UI分岐を網羅的に。
- **テスト容易性**：プレイヤーの状態遷移は[Playwright](/blog/playwright-e2e-testing-production-design-guide)でキーボード操作と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・コストを同時に満たすフロントエンドを作ってきました（[サブスク学習プラットフォーム](/case-studies/subscription-learning-platform)）。**「自社のコンテンツを、誰でも聴ける形で・速く・安く届ける」——設計から実装・テストまで一気通貫で伴走します。** お気軽にご相談ください。

---

### 参考（公式ドキュメント）

- [Qwen-TTS 音声合成（Alibaba Cloud Model Studio）](https://www.alibabacloud.com/help/en/model-studio/qwen-tts) — モデル・音色・APIパラメータ
- [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/) — カスタムUIのa11yパターン
- [MDN: HTMLAudioElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLAudioElement) — `<audio>` のイベントとプロパティ
- [WCAG 2.2 解説書](https://www.w3.org/WAI/WCAG22/Understanding/) — 各達成基準の意図と実装
