228 lines
5.9 KiB
TypeScript
228 lines
5.9 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useRef, useCallback } from 'react';
|
||
|
||
export interface TTSRequest {
|
||
text: string;
|
||
accent?: 'us' | 'uk';
|
||
speed?: number;
|
||
voice?: string;
|
||
}
|
||
|
||
export interface TTSResponse {
|
||
audioUrl: string;
|
||
duration: number;
|
||
cacheHit: boolean;
|
||
error?: string;
|
||
}
|
||
|
||
export interface AudioState {
|
||
isPlaying: boolean;
|
||
isLoading: boolean;
|
||
error: string | null;
|
||
currentAudio: string | null;
|
||
}
|
||
|
||
export function useAudio() {
|
||
const [state, setState] = useState<AudioState>({
|
||
isPlaying: false,
|
||
isLoading: false,
|
||
error: null,
|
||
currentAudio: null
|
||
});
|
||
|
||
const audioRef = useRef<HTMLAudioElement>(null);
|
||
const currentRequestRef = useRef<AbortController | null>(null);
|
||
|
||
// 更新狀態的輔助函數
|
||
const updateState = useCallback((updates: Partial<AudioState>) => {
|
||
setState(prev => ({ ...prev, ...updates }));
|
||
}, []);
|
||
|
||
// 生成音頻
|
||
const generateAudio = useCallback(async (request: TTSRequest): Promise<string | null> => {
|
||
try {
|
||
// 取消之前的請求
|
||
if (currentRequestRef.current) {
|
||
currentRequestRef.current.abort();
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
currentRequestRef.current = controller;
|
||
|
||
updateState({ isLoading: true, error: null });
|
||
|
||
const token = localStorage.getItem('token');
|
||
if (!token) {
|
||
throw new Error('Authentication required');
|
||
}
|
||
|
||
const response = await fetch('/api/audio/tts', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify({
|
||
text: request.text,
|
||
accent: request.accent || 'us',
|
||
speed: request.speed || 1.0,
|
||
voice: request.voice || ''
|
||
}),
|
||
signal: controller.signal
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const data: TTSResponse = await response.json();
|
||
|
||
if (data.error) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
updateState({ currentAudio: data.audioUrl });
|
||
return data.audioUrl;
|
||
|
||
} catch (error) {
|
||
if (error instanceof Error && error.name === 'AbortError') {
|
||
return null; // 請求被取消
|
||
}
|
||
|
||
const errorMessage = error instanceof Error ? error.message : 'Failed to generate audio';
|
||
updateState({ error: errorMessage });
|
||
return null;
|
||
} finally {
|
||
updateState({ isLoading: false });
|
||
currentRequestRef.current = null;
|
||
}
|
||
}, [updateState]);
|
||
|
||
// 播放音頻
|
||
const playAudio = useCallback(async (audioUrl?: string, request?: TTSRequest) => {
|
||
try {
|
||
let urlToPlay = audioUrl;
|
||
|
||
// 如果沒有提供 URL,嘗試生成
|
||
if (!urlToPlay && request) {
|
||
const generatedUrl = await generateAudio(request);
|
||
urlToPlay = generatedUrl || undefined;
|
||
if (!urlToPlay) return false;
|
||
}
|
||
|
||
if (!urlToPlay) {
|
||
updateState({ error: 'No audio URL provided' });
|
||
return false;
|
||
}
|
||
|
||
// 創建新的音頻元素或使用現有的
|
||
let audio = audioRef.current;
|
||
if (!audio) {
|
||
audio = new Audio();
|
||
audioRef.current = audio;
|
||
}
|
||
|
||
// 設置音頻事件監聽器
|
||
const handleEnded = () => {
|
||
updateState({ isPlaying: false });
|
||
audio?.removeEventListener('ended', handleEnded);
|
||
audio?.removeEventListener('error', handleError);
|
||
};
|
||
|
||
const handleError = () => {
|
||
updateState({ isPlaying: false, error: 'Audio playback failed' });
|
||
audio?.removeEventListener('ended', handleEnded);
|
||
audio?.removeEventListener('error', handleError);
|
||
};
|
||
|
||
audio.addEventListener('ended', handleEnded);
|
||
audio.addEventListener('error', handleError);
|
||
|
||
// 設置音頻源並播放
|
||
audio.src = urlToPlay;
|
||
await audio.play();
|
||
|
||
updateState({ isPlaying: true, error: null });
|
||
return true;
|
||
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : 'Failed to play audio';
|
||
updateState({ error: errorMessage, isPlaying: false });
|
||
return false;
|
||
}
|
||
}, [generateAudio, updateState]);
|
||
|
||
// 暫停音頻
|
||
const pauseAudio = useCallback(() => {
|
||
const audio = audioRef.current;
|
||
if (audio) {
|
||
audio.pause();
|
||
updateState({ isPlaying: false });
|
||
}
|
||
}, [updateState]);
|
||
|
||
// 停止音頻
|
||
const stopAudio = useCallback(() => {
|
||
const audio = audioRef.current;
|
||
if (audio) {
|
||
audio.pause();
|
||
audio.currentTime = 0;
|
||
updateState({ isPlaying: false });
|
||
}
|
||
}, [updateState]);
|
||
|
||
// 切換播放/暫停
|
||
const togglePlayPause = useCallback(async (audioUrl?: string, request?: TTSRequest) => {
|
||
if (state.isPlaying) {
|
||
pauseAudio();
|
||
} else {
|
||
await playAudio(audioUrl, request);
|
||
}
|
||
}, [state.isPlaying, playAudio, pauseAudio]);
|
||
|
||
// 設置音量
|
||
const setVolume = useCallback((volume: number) => {
|
||
const audio = audioRef.current;
|
||
if (audio) {
|
||
audio.volume = Math.max(0, Math.min(1, volume));
|
||
}
|
||
}, []);
|
||
|
||
// 設置播放速度
|
||
const setPlaybackRate = useCallback((rate: number) => {
|
||
const audio = audioRef.current;
|
||
if (audio) {
|
||
audio.playbackRate = Math.max(0.25, Math.min(4, rate));
|
||
}
|
||
}, []);
|
||
|
||
// 清除錯誤
|
||
const clearError = useCallback(() => {
|
||
updateState({ error: null });
|
||
}, [updateState]);
|
||
|
||
// 清理函數
|
||
const cleanup = useCallback(() => {
|
||
if (currentRequestRef.current) {
|
||
currentRequestRef.current.abort();
|
||
}
|
||
stopAudio();
|
||
}, [stopAudio]);
|
||
|
||
return {
|
||
// 狀態
|
||
...state,
|
||
|
||
// 操作方法
|
||
generateAudio,
|
||
playAudio,
|
||
pauseAudio,
|
||
stopAudio,
|
||
togglePlayPause,
|
||
setVolume,
|
||
setPlaybackRate,
|
||
clearError,
|
||
cleanup
|
||
};
|
||
} |