Chuyển đến nội dung chính
友田 陽大
Voice 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・フォーカス管理)を、型安全な実コードで解説します。

Published
Reading time
10 min read
Author
友田 陽大
Chia sẻ

「記事を音声で聴けるようにしたい」——アクセシビリティ向上にも、ながら聴取の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・コストを同時に満たすフロントエンドを作ってきました(サブスク学習プラットフォーム)。「自社のコンテンツを、誰でも聴ける形で・速く・安く届ける」——設計から実装・テストまで一気通貫で伴走します。 お気軽にご相談ください。


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

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

I can take on the implementation from this article as an engagement

GPU inference pipelines for source separation, lip-sync, and speech synthesis — from design to production

I single-handedly built a fully automated GPU pipeline that takes an uploaded video all the way through source separation → transcription → translation → multilingual dubbing → lip-sync, running on spot GPUs (Tesla T4). Resumable per-segment processing for long jobs, ~40% less GPU work via speech-segment detection, and graceful degradation when lip-sync fails — I design and implement for both cost and completion.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc