neuroread/components/TTSProvider.tsx

174 lines
4.4 KiB
TypeScript

"use client";
import { createClient } from "@/utils/supabase/client";
import React, {
createContext,
useContext,
useState,
useEffect,
useRef,
ReactNode,
} from "react";
import removeMarkdown from "remove-markdown";
import { toast } from "sonner";
// More robust sentence splitter using Intl.Segmenter for better accuracy.
function splitIntoSentences(text: string): string[] {
if (typeof Intl !== "undefined" && Intl.Segmenter) {
const segmenter = new Intl.Segmenter("en", { granularity: "sentence" });
return Array.from(segmenter.segment(text)).map(
(segment) => segment.segment
);
}
// Fallback to regex-based splitting if Intl.Segmenter is unavailable.
return text.match(/[^\.!\?]+[\.!\?]+/g) || [text];
}
interface TTSContextType {
sentences: string[];
currentSentence: number;
voices: any[];
selectedSpeaker: string;
status: "ready" | "running" | null;
setSelectedSpeaker: (speaker: string) => void;
setCurrentSentence: (index: number) => void;
playSentence: (index: number) => void;
skipToSentence: (index: number) => void;
playInOrder: (index: number) => void;
pause: () => void;
resume: () => void;
stop: () => void;
}
const TTSContext = createContext<TTSContextType | undefined>(undefined);
export const TTSProvider = ({
pages,
children,
}: {
pages: string[];
children: ReactNode;
}) => {
const supabase = createClient();
// Combine pages and split into sentences.
const fullText = pages.join("\n");
const sentences = splitIntoSentences(fullText).filter(
(sentence) => sentence.trim() !== "\\n" && sentence.trim() !== ""
);
const [currentSentence, setCurrentSentence] = useState(0);
const audioRef = useRef<HTMLAudioElement>(null);
const [selectedSpeaker, setSelectedSpeaker] = useState("af_heart");
const [voices, setVoices] = useState<any[]>([]);
const [status, setStatus] = useState<"ready" | "running" | null>("ready");
async function generateTTS(sentence: string, index: number): Promise<string> {
try {
const { data, error } = await supabase.functions.invoke("generate-tts", {
body: {
text: sentence,
voice: selectedSpeaker,
index,
},
});
setStatus("running");
const { audioUrl } = data as { audioUrl: string };
return audioUrl;
} catch (error) {
console.error("Error generating TTS:", error);
toast.error("Failed to generate TTS. Please try again.");
throw error;
}
}
const playSentence = async (index: number) => {
setCurrentSentence(index);
const sentence = removeMarkdown(sentences[index]);
try {
const audioUrl = await generateTTS(sentence, index);
if (audioRef.current) {
audioRef.current.src = audioUrl;
await new Promise((res) => {
audioRef.current!.play();
audioRef.current!.onended = () => res(true);
});
}
} catch (error) {
console.error("Error playing sentence:", error);
}
};
const skipToSentence = (index: number) => {
if (index < 0 || index >= sentences.length) return;
playSentence(index);
};
const playInOrder = async (index: number) => {
if (index < 0 || index >= sentences.length) return;
setCurrentSentence(index);
for (let i = index; i < sentences.length; i++) {
console.log("Playing sentence:", i, sentences[i]);
try {
await playSentence(i);
} catch (error) {
console.error("Error playing sentence:", error);
break; // Stop playback on error
}
}
};
const pause = () => {
if (audioRef.current) {
audioRef.current.pause();
}
};
const resume = () => {
if (audioRef.current) {
audioRef.current.play();
}
};
const stop = () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
}
};
const value: TTSContextType = {
sentences,
currentSentence,
voices,
playSentence,
skipToSentence,
selectedSpeaker,
setSelectedSpeaker,
setCurrentSentence,
playInOrder,
pause,
resume,
stop,
status,
};
return (
<TTSContext.Provider value={value}>
{children}
<audio ref={audioRef} style={{ display: "none" }} />
</TTSContext.Provider>
);
};
export const useTTS = (): TTSContextType => {
const context = useContext(TTSContext);
if (!context) {
throw new Error("useTTS must be used within a TTSProvider");
}
return context;
};