use-discussion-tts.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. 'use client';
  2. import { useCallback, useEffect, useRef } from 'react';
  3. import { useSettingsStore } from '@/lib/store/settings';
  4. import { useBrowserTTS } from '@/lib/hooks/use-browser-tts';
  5. import {
  6. resolveAgentVoice,
  7. getAvailableProvidersWithVoices,
  8. type ResolvedVoice,
  9. } from '@/lib/audio/voice-resolver';
  10. import type { AgentConfig } from '@/lib/orchestration/registry/types';
  11. import type { TTSProviderId } from '@/lib/audio/types';
  12. import type { AudioIndicatorState } from '@/components/roundtable/audio-indicator';
  13. interface DiscussionTTSOptions {
  14. enabled: boolean;
  15. agents: AgentConfig[];
  16. onAudioStateChange?: (agentId: string | null, state: AudioIndicatorState) => void;
  17. }
  18. interface QueueItem {
  19. messageId: string;
  20. partId: string;
  21. text: string;
  22. agentId: string | null;
  23. providerId: TTSProviderId;
  24. modelId?: string;
  25. voiceId: string;
  26. }
  27. export function useDiscussionTTS({ enabled, agents, onAudioStateChange }: DiscussionTTSOptions) {
  28. const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig);
  29. const ttsSpeed = useSettingsStore((s) => s.ttsSpeed);
  30. const ttsMuted = useSettingsStore((s) => s.ttsMuted);
  31. const ttsVolume = useSettingsStore((s) => s.ttsVolume);
  32. const playbackSpeed = useSettingsStore((s) => s.playbackSpeed);
  33. // Global lecture voice — used as fallback for teacher agent
  34. const globalTtsProviderId = useSettingsStore((s) => s.ttsProviderId);
  35. const globalTtsVoice = useSettingsStore((s) => s.ttsVoice);
  36. const queueRef = useRef<QueueItem[]>([]);
  37. const isPlayingRef = useRef(false);
  38. const pausedRef = useRef(false);
  39. /** Tracks which TTS provider is currently speaking (for pause/resume delegation) */
  40. const currentProviderRef = useRef<TTSProviderId | null>(null);
  41. const segmentDoneCounterRef = useRef(0);
  42. const abortControllerRef = useRef<AbortController | null>(null);
  43. const audioRef = useRef<HTMLAudioElement | null>(null);
  44. const onAudioStateChangeRef = useRef(onAudioStateChange);
  45. onAudioStateChangeRef.current = onAudioStateChange;
  46. const processQueueRef = useRef<() => void>(() => {});
  47. const {
  48. speak: browserSpeak,
  49. pause: browserPause,
  50. resume: browserResume,
  51. cancel: browserCancel,
  52. } = useBrowserTTS({
  53. rate: ttsSpeed,
  54. onEnd: () => {
  55. isPlayingRef.current = false;
  56. segmentDoneCounterRef.current++;
  57. onAudioStateChangeRef.current?.(null, 'idle');
  58. // Don't advance queue while paused — resume() will kick-start it
  59. if (!pausedRef.current) {
  60. processQueueRef.current();
  61. }
  62. },
  63. });
  64. const browserCancelRef = useRef(browserCancel);
  65. browserCancelRef.current = browserCancel;
  66. const browserSpeakRef = useRef(browserSpeak);
  67. browserSpeakRef.current = browserSpeak;
  68. const browserPauseRef = useRef(browserPause);
  69. browserPauseRef.current = browserPause;
  70. const browserResumeRef = useRef(browserResume);
  71. browserResumeRef.current = browserResume;
  72. // Build agent index map for deterministic voice resolution
  73. const agentIndexMap = useRef<Map<string, number>>(new Map());
  74. useEffect(() => {
  75. const map = new Map<string, number>();
  76. agents.forEach((agent, i) => map.set(agent.id, i));
  77. agentIndexMap.current = map;
  78. }, [agents]);
  79. const resolveVoiceForAgent = useCallback(
  80. (agentId: string | null): ResolvedVoice => {
  81. const providers = getAvailableProvidersWithVoices(ttsProvidersConfig);
  82. if (!agentId) {
  83. if (providers.length > 0) {
  84. return {
  85. providerId: providers[0].providerId,
  86. voiceId: providers[0].voices[0]?.id ?? 'default',
  87. };
  88. }
  89. return { providerId: 'browser-native-tts', voiceId: 'default' };
  90. }
  91. const agent = agents.find((a) => a.id === agentId);
  92. if (!agent) {
  93. if (providers.length > 0) {
  94. return {
  95. providerId: providers[0].providerId,
  96. voiceId: providers[0].voices[0]?.id ?? 'default',
  97. modelId: undefined,
  98. };
  99. }
  100. return { providerId: 'browser-native-tts', voiceId: 'default', modelId: undefined };
  101. }
  102. // Teacher: always use global lecture voice (single source of truth with settings)
  103. if (agent.role === 'teacher') {
  104. return {
  105. providerId: globalTtsProviderId,
  106. voiceId: globalTtsVoice,
  107. modelId: ttsProvidersConfig[globalTtsProviderId]?.modelId,
  108. };
  109. }
  110. const index = agentIndexMap.current.get(agentId) ?? 0;
  111. return resolveAgentVoice(agent, index, providers);
  112. },
  113. [agents, ttsProvidersConfig, globalTtsProviderId, globalTtsVoice],
  114. );
  115. const processQueue = useCallback(async () => {
  116. if (pausedRef.current) return; // Don't advance while paused
  117. if (isPlayingRef.current || queueRef.current.length === 0) return;
  118. if (!enabled || ttsMuted) {
  119. queueRef.current = [];
  120. return;
  121. }
  122. isPlayingRef.current = true;
  123. const item = queueRef.current.shift()!;
  124. // Browser TTS
  125. if (item.providerId === 'browser-native-tts') {
  126. currentProviderRef.current = item.providerId;
  127. onAudioStateChangeRef.current?.(item.agentId, 'playing');
  128. browserSpeakRef.current(item.text, item.voiceId);
  129. return;
  130. }
  131. // Server TTS — use the item's provider, not the global one
  132. currentProviderRef.current = item.providerId;
  133. onAudioStateChangeRef.current?.(item.agentId, 'generating');
  134. const controller = new AbortController();
  135. abortControllerRef.current = controller;
  136. try {
  137. const providerConfig = ttsProvidersConfig[item.providerId];
  138. const res = await fetch('/api/generate/tts', {
  139. method: 'POST',
  140. headers: { 'Content-Type': 'application/json' },
  141. body: JSON.stringify({
  142. text: item.text,
  143. audioId: item.partId,
  144. ttsProviderId: item.providerId,
  145. ttsModelId: item.modelId || providerConfig?.modelId,
  146. ttsVoice: item.voiceId,
  147. ttsSpeed: ttsSpeed,
  148. ttsApiKey: providerConfig?.apiKey,
  149. ttsBaseUrl: providerConfig?.serverBaseUrl || providerConfig?.baseUrl,
  150. }),
  151. signal: controller.signal,
  152. });
  153. if (!res.ok) throw new Error(`TTS API error: ${res.status}`);
  154. const data = await res.json();
  155. if (!data.base64) throw new Error('No audio in response');
  156. const audioUrl = `data:audio/${data.format || 'mp3'};base64,${data.base64}`;
  157. const audio = new Audio(audioUrl);
  158. audio.playbackRate = playbackSpeed;
  159. audio.volume = ttsMuted ? 0 : ttsVolume;
  160. audioRef.current = audio;
  161. audio.addEventListener('ended', () => {
  162. audioRef.current = null;
  163. isPlayingRef.current = false;
  164. segmentDoneCounterRef.current++;
  165. onAudioStateChangeRef.current?.(item.agentId, 'idle');
  166. if (!pausedRef.current) {
  167. queueMicrotask(() => processQueueRef.current());
  168. }
  169. });
  170. audio.addEventListener('error', () => {
  171. audioRef.current = null;
  172. isPlayingRef.current = false;
  173. segmentDoneCounterRef.current++;
  174. onAudioStateChangeRef.current?.(item.agentId, 'idle');
  175. if (!pausedRef.current) {
  176. queueMicrotask(() => processQueueRef.current());
  177. }
  178. });
  179. // If paused during TTS generation, keep audio ready but don't play
  180. if (pausedRef.current) {
  181. onAudioStateChangeRef.current?.(item.agentId, 'playing');
  182. audio.pause();
  183. return;
  184. }
  185. onAudioStateChangeRef.current?.(item.agentId, 'playing');
  186. await audio.play();
  187. } catch (err) {
  188. if ((err as Error).name !== 'AbortError') {
  189. console.error('[DiscussionTTS] TTS generation failed:', err);
  190. }
  191. audioRef.current = null;
  192. isPlayingRef.current = false;
  193. segmentDoneCounterRef.current++;
  194. onAudioStateChangeRef.current?.(item.agentId, 'idle');
  195. if (!pausedRef.current) {
  196. queueMicrotask(() => processQueueRef.current());
  197. }
  198. }
  199. }, [enabled, ttsMuted, ttsVolume, ttsProvidersConfig, ttsSpeed, playbackSpeed]);
  200. processQueueRef.current = processQueue;
  201. const handleSegmentSealed = useCallback(
  202. (messageId: string, partId: string, fullText: string, agentId: string | null) => {
  203. if (!enabled || ttsMuted || !fullText.trim()) return;
  204. const { providerId, modelId, voiceId } = resolveVoiceForAgent(agentId);
  205. queueRef.current.push({
  206. messageId,
  207. partId,
  208. text: fullText,
  209. agentId,
  210. providerId,
  211. modelId,
  212. voiceId,
  213. });
  214. if (!isPlayingRef.current) {
  215. processQueueRef.current();
  216. } else if (providerId !== 'browser-native-tts') {
  217. onAudioStateChangeRef.current?.(agentId, 'generating');
  218. }
  219. },
  220. [enabled, ttsMuted, resolveVoiceForAgent],
  221. );
  222. const cleanup = useCallback(() => {
  223. pausedRef.current = false;
  224. currentProviderRef.current = null;
  225. abortControllerRef.current?.abort();
  226. abortControllerRef.current = null;
  227. if (audioRef.current) {
  228. audioRef.current.pause();
  229. audioRef.current.src = '';
  230. audioRef.current = null;
  231. }
  232. browserCancelRef.current();
  233. queueRef.current = [];
  234. isPlayingRef.current = false;
  235. segmentDoneCounterRef.current = 0;
  236. onAudioStateChangeRef.current?.(null, 'idle');
  237. }, []);
  238. /** Pause TTS audio (browser-native or server). Does NOT stop the SSE stream. */
  239. const pause = useCallback(() => {
  240. if (pausedRef.current) return;
  241. pausedRef.current = true;
  242. if (currentProviderRef.current === 'browser-native-tts') {
  243. browserPauseRef.current();
  244. } else if (audioRef.current && !audioRef.current.paused) {
  245. audioRef.current.pause();
  246. }
  247. }, []);
  248. /** Resume TTS audio. If the previous utterance already ended while paused, advance the queue. */
  249. const resume = useCallback(() => {
  250. if (!pausedRef.current) return;
  251. pausedRef.current = false;
  252. if (currentProviderRef.current === 'browser-native-tts') {
  253. browserResumeRef.current();
  254. } else if (audioRef.current && audioRef.current.paused) {
  255. audioRef.current.play();
  256. } else if (!isPlayingRef.current) {
  257. // Audio finished while paused — kick-start the queue
  258. processQueueRef.current();
  259. }
  260. }, []);
  261. // Sync playbackSpeed to currently playing audio in real-time
  262. useEffect(() => {
  263. if (audioRef.current) {
  264. audioRef.current.playbackRate = playbackSpeed;
  265. }
  266. }, [playbackSpeed]);
  267. // Sync volume and mute to currently playing audio in real-time
  268. useEffect(() => {
  269. if (audioRef.current) {
  270. audioRef.current.volume = ttsMuted ? 0 : ttsVolume;
  271. }
  272. }, [ttsVolume, ttsMuted]);
  273. useEffect(() => cleanup, [cleanup]);
  274. /**
  275. * Returns true when TTS audio for the *current* segment is still playing.
  276. * Uses a monotonic counter so the buffer releases as soon as one segment's
  277. * audio finishes, even if the next segment starts immediately.
  278. */
  279. const shouldHold = useCallback(() => {
  280. return {
  281. holding: isPlayingRef.current || queueRef.current.length > 0,
  282. segmentDone: segmentDoneCounterRef.current,
  283. };
  284. }, []);
  285. return {
  286. handleSegmentSealed,
  287. cleanup,
  288. pause,
  289. resume,
  290. shouldHold,
  291. };
  292. }