Skip to content

Commit

Permalink
✨ feat: Add stream tts
Browse files Browse the repository at this point in the history
  • Loading branch information
canisminor1990 committed Nov 10, 2023
1 parent 1a37f4f commit 51d4129
Show file tree
Hide file tree
Showing 18 changed files with 367 additions and 127 deletions.
97 changes: 97 additions & 0 deletions src/AudioPlayer/StreamAudioPlayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { ActionIcon, ActionIconProps, Tag } from '@lobehub/ui';
import { Slider } from 'antd';
import { Pause, Play, StopCircle } from 'lucide-react';
import React, { memo, useMemo } from 'react';
import { Flexbox } from 'react-layout-kit';

import { AudioProps } from '@/hooks/useStreamAudioPlayer';
import { secondsToMinutesAndSeconds } from '@/utils/secondsToMinutesAndSeconds';

export interface StreamAudioPlayerProps {
allowPause?: boolean;
audio: AudioProps;
buttonSize?: ActionIconProps['size'];
className?: string;
showSlider?: boolean;
showTime?: boolean;
style?: React.CSSProperties;
timeRender?: 'tag' | 'text';
timeStyle?: React.CSSProperties;
timeType?: 'left' | 'current' | 'combine';
}

const StreamAudioPlayer = memo<StreamAudioPlayerProps>(
({
style,
timeStyle,
buttonSize,
className,
audio,
allowPause,
timeType = 'left',
showTime = true,
showSlider = true,
timeRender = 'text',
}) => {
const { isPlaying, play, stop, pause, duration, setTime, currentTime } = audio;

const formatedLeftTime = secondsToMinutesAndSeconds(duration - currentTime);
const formatedCurrentTime = secondsToMinutesAndSeconds(currentTime);
const formatedDuration = secondsToMinutesAndSeconds(duration);

const Time = useMemo(
() => (timeRender === 'tag' ? Tag : (props: any) => <time {...props} />),
[timeRender],
);

return (
<Flexbox
align={'center'}
className={className}
gap={8}
horizontal
style={{ paddingRight: 8, width: '100%', ...style }}
>
{allowPause ? (
<ActionIcon
icon={isPlaying ? Pause : Play}
onClick={isPlaying ? pause : play}
size={buttonSize}
style={{ flex: 'none' }}
/>
) : (
<ActionIcon
icon={isPlaying ? StopCircle : Play}
onClick={isPlaying ? stop : play}
size={buttonSize}
style={{ flex: 'none' }}
/>
)}
{showSlider && (
<Slider
max={duration}
min={0}
onChange={(e) => setTime(e)}
style={{ flex: 1 }}
tooltip={{ formatter: secondsToMinutesAndSeconds as any }}
value={currentTime}
/>
)}
{showTime && (
<Time style={{ flex: 'none', ...timeStyle }}>
{timeType === 'left' && formatedLeftTime}
{timeType === 'current' && formatedCurrentTime}
{timeType === 'combine' && (
<span>
{formatedCurrentTime}
<span style={{ opacity: 0.66 }}>{` / ${formatedDuration}`}</span>
</span>
)}
</Time>
)}
</Flexbox>
);
},
);

export default StreamAudioPlayer;
11 changes: 7 additions & 4 deletions src/hooks/useBlobUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from 'react';
import useSWR from 'swr';

import { arrayBufferConvert } from '@/utils/arrayBufferConvert';
import { audioBufferToBlob } from '@/utils/audioBufferToBlob';
import { playAudioBlob } from '@/utils/playAudioBlob';

export const useBlobUrl = (src: string) => {
Expand All @@ -18,12 +19,14 @@ export const useBlobUrl = (src: string) => {
return await arrayBufferConvert(buffer);
},
{
onSuccess: (data) => {
if (!data || data.size === 0) return;
onSuccess: async (data) => {
if (!data) return;
const blob = await audioBufferToBlob(data);
if (!blob || blob.size === 0) return;
if (audio) audio.remove();
if (url) URL.revokeObjectURL(url);
setBlob(data);
const newAudio = playAudioBlob(data);
setBlob(blob);
const newAudio = playAudioBlob(blob);
setUrl(newAudio.url);
setAudio(newAudio.audio);
setIsGlobalLoading(false);
Expand Down
156 changes: 156 additions & 0 deletions src/hooks/useStreamAudioPlayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { useCallback, useEffect, useRef, useState } from 'react';

type BufferQueueItem = {
audioBuffer: AudioBuffer;
endOffset: number;
startOffset: number;
};

export interface AudioProps {
currentTime: number;
duration: number;
isPlaying: boolean;
pause: () => void;
play: () => void;
setTime: (time: number) => void;
stop: () => void;
}
export interface StreamAudioPlayerHook extends AudioProps {
load: (audioBuffer: AudioBuffer) => void;
reset: () => void;
}

export const useStreamAudioPlayer = (): StreamAudioPlayerHook => {
const audioContextRef = useRef<AudioContext | null>(null);
const [bufferQueue, setBufferQueue] = useState<BufferQueueItem[]>([]);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const sourceNodeRef = useRef<AudioBufferSourceNode | null>(null);
const startTimeRef = useRef(0);
const startOffsetRef = useRef(0);

const initAudioContext = () => {
if (!audioContextRef.current) {
audioContextRef.current = new AudioContext();
}
};

const addAudioBuffer = useCallback(
(audioBuffer: AudioBuffer) => {
initAudioContext();
const context = audioContextRef.current;
if (context) {
const newItem: BufferQueueItem = {
audioBuffer,
endOffset: duration + audioBuffer.duration,
startOffset: duration,
};
setBufferQueue((prevQueue) => [...prevQueue, newItem]);
setDuration(newItem.endOffset);
}
},
[duration],
);

const playAudio = useCallback(() => {
if (!audioContextRef.current || isPlaying) return;

const context = audioContextRef.current;
const sourceNode = context.createBufferSource();
sourceNodeRef.current = sourceNode;

const nextBufferItem = bufferQueue.find((item) => item.startOffset >= currentTime);
if (nextBufferItem) {
sourceNode.buffer = nextBufferItem.audioBuffer;
const playOffset = currentTime - nextBufferItem.startOffset;
sourceNode.connect(context.destination);
sourceNode.start(0, playOffset);
startTimeRef.current = context.currentTime - playOffset;
startOffsetRef.current = playOffset;

setIsPlaying(true);

sourceNode.addEventListener('ended', () => {
// 检查是否是队列中的最后一段音频
setIsPlaying(false);
if (nextBufferItem === bufferQueue.at(-1)) {
setCurrentTime(0); // 回到开头
} else {
setCurrentTime(nextBufferItem.endOffset);
}
sourceNodeRef.current = null;
});
}
}, [bufferQueue, currentTime, isPlaying]);

const pauseAudio = useCallback(() => {
if (!audioContextRef.current || !isPlaying) return;

sourceNodeRef.current?.stop();
setIsPlaying(false);
}, [isPlaying]);

const seekAudio = useCallback(
(time: number) => {
if (time < 0 || time > duration) return;

const wasPlaying = isPlaying;
pauseAudio();
setCurrentTime(time);

if (wasPlaying) {
playAudio();
}
},
[duration, isPlaying, pauseAudio, playAudio],
);

// Update currentTime while playing
useEffect(() => {
let intervalId: any;

if (isPlaying) {
intervalId = setInterval(() => {
if (!audioContextRef.current) return;
const elapsed = audioContextRef.current.currentTime - startTimeRef.current;
setCurrentTime(startOffsetRef.current + elapsed);
}, 100);
}

return () => {
clearInterval(intervalId);
};
}, [isPlaying]);

// Clean up the audio context on unmount
useEffect(() => {
return () => {
audioContextRef.current?.close();
};
}, []);

const stopAudio = useCallback(() => {
pauseAudio();
seekAudio(0);
}, [pauseAudio, seekAudio]);

const resetAudio = useCallback(() => {
pauseAudio();
seekAudio(0);
setBufferQueue([]);
setDuration(0);
}, [pauseAudio, seekAudio]);

return {
currentTime,
duration,
isPlaying,
load: addAudioBuffer,
pause: pauseAudio,
play: playAudio,
reset: resetAudio,
setTime: seekAudio,
stop: stopAudio,
};
};
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
export { default as AudioPlayer, type AudioPlayerProps } from './AudioPlayer';
export {
default as StreamAudioPlayer,
type StreamAudioPlayerProps,
} from './AudioPlayer/StreamAudioPlayer';
export { default as AudioVisualizer, type AudioVisualizerProps } from './AudioVisualizer';
export { useAudioPlayer } from './hooks/useAudioPlayer';
export { useAudioVisualizer } from './hooks/useAudioVisualizer';
export { useBlobUrl } from './hooks/useBlobUrl';
export { useStreamAudioPlayer } from './hooks/useStreamAudioPlayer';
export { type AzureSpeechOptions, fetchAzureSpeech } from './services/fetchAzureSpeech';
export { type EdgeSpeechOptions, fetchEdgeSpeech } from './services/fetchEdgeSpeech';
export { fetchMicrosoftSpeech, type MicrosoftSpeechOptions } from './services/fetchMicrosoftSpeech';
Expand Down
2 changes: 1 addition & 1 deletion src/services/fetchAzureSpeech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface AzureSpeechOptions extends SsmlOptions {
export const fetchAzureSpeech = async (
text: string,
{ api, ...options }: AzureSpeechOptions,
): Promise<Blob> => {
): Promise<AudioBuffer> => {
const data = JSON.stringify({
api,
ssml: genSSML(text, options),
Expand Down
6 changes: 3 additions & 3 deletions src/services/fetchEdgeSpeech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export interface EdgeSpeechOptions extends Pick<SsmlOptions, 'name'> {
export const fetchEdgeSpeech = async (
text: string,
{ api, ...options }: EdgeSpeechOptions,
): Promise<Blob> => {
): Promise<AudioBuffer> => {
const connectId = uuidv4().replaceAll('-', '');
const url = qs.stringifyUrl({
query: {
Expand Down Expand Up @@ -75,8 +75,8 @@ export const fetchEdgeSpeech = async (
case 'turn.end': {
ws.close();
if (!audioData.byteLength) return;
const blob = await arrayBufferConvert(audioData);
resolve(blob);
const audioBuffer = await arrayBufferConvert(audioData);
resolve(audioBuffer);
break;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/services/fetchMicrosoftSpeech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface MicrosoftSpeechOptions extends SsmlOptions {
export const fetchMicrosoftSpeech = async (
text: string,
{ api, ...options }: MicrosoftSpeechOptions,
): Promise<Blob> => {
): Promise<AudioBuffer> => {
const data = JSON.stringify({
offsetInPlainText: 0,
properties: {
Expand Down
2 changes: 1 addition & 1 deletion src/services/fetchOpenaiTTS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface OpenaiTtsOptions extends Pick<SsmlOptions, 'name'> {
export const fetchOpenaiTTS = async (
text: string,
{ api, model = 'tts-1', ...options }: OpenaiTtsOptions,
): Promise<Blob> => {
): Promise<AudioBuffer> => {
const key = api.key || OPENAI_API_KEY;
const url = OPENAI_TTS_URL(api.proxy);

Expand Down
8 changes: 4 additions & 4 deletions src/useAzureSpeech/demos/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getEdgeVoiceList, useAzureSpeech } from '@lobehub/tts';
import { StreamAudioPlayer, getEdgeVoiceList, useAzureSpeech } from '@lobehub/tts';
import { Icon, StoryBook, useControls, useCreateStore } from '@lobehub/ui';
import { Button, Input } from 'antd';
import { StopCircle, Volume2 } from 'lucide-react';
Expand Down Expand Up @@ -60,14 +60,14 @@ export default () => {
},
{ store },
);
const { setText, isLoading, isPlaying, start, stop, url } = useAzureSpeech(defaultText, {
const { setText, isLoading, audio, start, stop } = useAzureSpeech(defaultText, {
api,
...options,
});
return (
<StoryBook levaStore={store}>
<Flexbox gap={8}>
{isPlaying ? (
{audio.isPlaying ? (
<Button block icon={<Icon icon={StopCircle} />} onClick={stop}>
Stop
</Button>
Expand All @@ -81,7 +81,7 @@ export default () => {
</Button>
)}
<Input.TextArea defaultValue={defaultText} onChange={(e) => setText(e.target.value)} />
{url && <audio controls src={url} />}
<StreamAudioPlayer audio={audio} />
</Flexbox>
</StoryBook>
);
Expand Down
10 changes: 6 additions & 4 deletions src/useAzureSpeech/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ export const useAzureSpeech = (
{ api, name, style, pitch, rate }: AzureSpeechOptions,
) => {
const [text, setText] = useState<string>(defaultText);
return useTTS({
fetchTTS: () => fetchAzureSpeech(text, { api, name, pitch, rate, style }),
key: [name, text].join('-'),
const rest = useTTS(text, (segmentText: string) =>
fetchAzureSpeech(segmentText, { api, name, pitch, rate, style }),
);
return {
setText,
});
...rest,
};
};
Loading

0 comments on commit 51d4129

Please sign in to comment.