- Use 1.7B model (0.6B had tensor mismatch with cached prompts) - Speak endpoint uses ref_audio directly (not cached pkl) as fallback - Cache voice clone prompts in memory on startup - Add SpeakableText component: 🔊 icon on each p and li element - Remove old TTSReader sequential approach - Add global exception handler to TTS server - Fix profile localStorage caching - inference_mode + bf16 optimization Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
86 lines
2.3 KiB
TypeScript
86 lines
2.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
|
|
|
interface SpeakableProps {
|
|
children: React.ReactNode;
|
|
text: string;
|
|
}
|
|
|
|
let cachedProfileId: string | null = null;
|
|
let profileChecked = false;
|
|
|
|
export default function SpeakableText({ children, text }: SpeakableProps) {
|
|
const [playing, setPlaying] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [hasProfile, setHasProfile] = useState(false);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (profileChecked) {
|
|
setHasProfile(!!cachedProfileId);
|
|
return;
|
|
}
|
|
try {
|
|
const profiles = JSON.parse(localStorage.getItem("tts_profiles") || "[]");
|
|
if (profiles.length > 0) {
|
|
cachedProfileId = profiles[0].id;
|
|
setHasProfile(true);
|
|
}
|
|
profileChecked = true;
|
|
} catch {}
|
|
}, []);
|
|
|
|
const handleSpeak = async (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
if (playing) {
|
|
audioRef.current?.pause();
|
|
setPlaying(false);
|
|
return;
|
|
}
|
|
|
|
if (!cachedProfileId || text.length < 5) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append("text", text);
|
|
fd.append("profile_id", cachedProfileId);
|
|
fd.append("language", "Korean");
|
|
const res = await fetch("/api/tts/speak", { method: "POST", body: fd });
|
|
if (!res.ok) { setLoading(false); return; }
|
|
const blob = await res.blob();
|
|
if (blob.size < 200) { setLoading(false); return; }
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
const audio = new Audio(url);
|
|
audioRef.current = audio;
|
|
audio.onended = () => setPlaying(false);
|
|
setPlaying(true);
|
|
setLoading(false);
|
|
audio.play();
|
|
} catch {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
if (!hasProfile || text.length < 5) return <>{children}</>;
|
|
|
|
return (
|
|
<>
|
|
{children}
|
|
<button
|
|
onClick={handleSpeak}
|
|
disabled={loading}
|
|
className="inline-flex items-center ml-1 text-[var(--color-text-muted)] hover:text-[var(--color-primary)] disabled:opacity-30 align-middle"
|
|
title={playing ? "중지" : "읽어주기"}
|
|
style={{ fontSize: "0.85em", verticalAlign: "middle", cursor: "pointer" }}
|
|
>
|
|
{loading ? "⏳" : playing ? "⏹" : "🔊"}
|
|
</button>
|
|
</>
|
|
);
|
|
}
|