「記事を音声で聴けるようにしたい」——アクセシビリティ向上にも、ながら聴取の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-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のキャッシュ設計)。
5. 型安全・エラー・回復性
- 境界はZod:サーバーは入力を
safeParse、クライアントは音色/言語をユニオン型で固定。不正な状態を表現不能にする(型安全の規律)。 - エラーは握りつぶさない:生成失敗は
status="error"にし、UIで明示+リトライ可能に。スクリーンリーダーにもaria-liveで告知。 - 状態は有限集合:
Statusをidle | 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・コストを同時に満たすフロントエンドを作ってきました(サブスク学習プラットフォーム)。「自社のコンテンツを、誰でも聴ける形で・速く・安く届ける」——設計から実装・テストまで一気通貫で伴走します。 お気軽にご相談ください。
参考(公式ドキュメント)
- Qwen-TTS 音声合成(Alibaba Cloud Model Studio) — モデル・音色・APIパラメータ
- WAI-ARIA Authoring Practices — カスタムUIのa11yパターン
- MDN: HTMLAudioElement —
<audio>のイベントとプロパティ - WCAG 2.2 解説書 — 各達成基準の意図と実装