fix: use html audio to load stream
This commit is contained in:
@@ -189,4 +189,4 @@ const AudioAnimation: React.FC<AudioAnimationProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AudioAnimation);
|
||||
export default AudioAnimation;
|
||||
|
||||
@@ -18,8 +18,9 @@ const RawAudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
|
||||
// ========================================================
|
||||
|
||||
const initAudioContext = useCallback(() => {
|
||||
audioContext.current = new (window.AudioContext ||
|
||||
window.webkitAudioContext)();
|
||||
audioContext.current = new (
|
||||
window.AudioContext || (window as any).webkitAudioContext
|
||||
)();
|
||||
|
||||
analyser.current = audioContext.current.createAnalyser();
|
||||
analyser.current.fftSize = 512;
|
||||
@@ -52,7 +53,9 @@ const RawAudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
|
||||
});
|
||||
|
||||
audioRef.current.addEventListener('timeupdate', () => {
|
||||
const current = audioRef.current?.currentTime || 0;
|
||||
props.onTimeUpdate?.();
|
||||
props.onAudioProcess?.(current);
|
||||
});
|
||||
|
||||
audioRef.current.addEventListener('ended', () => {
|
||||
@@ -85,11 +88,6 @@ const RawAudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
|
||||
props.onVolumeChange?.();
|
||||
});
|
||||
|
||||
audioRef.current.addEventListener('audioprocess', () => {
|
||||
const current = audioRef.current?.currentTime || 0;
|
||||
props.onAudioProcess?.(current);
|
||||
});
|
||||
|
||||
audioRef.current.addEventListener('playing', () => {
|
||||
props.onPlaying?.();
|
||||
});
|
||||
@@ -111,16 +109,44 @@ const RawAudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
play: () => {
|
||||
audioRef.current?.play();
|
||||
if (audioRef.current) {
|
||||
// If playback has ended, reset to beginning
|
||||
if (audioRef.current.currentTime >= audioRef.current.duration) {
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
audioRef.current.play();
|
||||
}
|
||||
},
|
||||
pause: () => {
|
||||
audioRef.current?.pause();
|
||||
},
|
||||
seekTo: (ratio: number) => {
|
||||
if (audioRef.current && audioRef.current.duration) {
|
||||
audioRef.current.currentTime = ratio * audioRef.current.duration;
|
||||
}
|
||||
},
|
||||
// Compatibility layer for wavesurfer interface
|
||||
wavesurfer: {
|
||||
current: {
|
||||
play: () => {
|
||||
if (audioRef.current) {
|
||||
// If playback has ended, reset to beginning
|
||||
if (audioRef.current.currentTime >= audioRef.current.duration) {
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
return audioRef.current.play();
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
isPlaying: () => {
|
||||
return audioRef.current ? !audioRef.current?.paused : false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
console.log('audioRef.current', audioRef.current, props.url);
|
||||
initEnvents();
|
||||
}
|
||||
return () => {
|
||||
@@ -138,7 +164,6 @@ const RawAudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
|
||||
audioRef.current?.removeEventListener('seeked', () => {});
|
||||
audioRef.current?.removeEventListener('seeking', () => {});
|
||||
audioRef.current?.removeEventListener('volumechange', () => {});
|
||||
audioRef.current?.removeEventListener('audioprocess', () => {});
|
||||
audioRef.current?.removeEventListener('playing', () => {});
|
||||
audioRef.current?.removeEventListener('loadedmetadata', () => {});
|
||||
audioRef.current?.removeEventListener('ended', () => {});
|
||||
@@ -146,6 +171,13 @@ const RawAudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
|
||||
};
|
||||
}, [audioRef.current]);
|
||||
|
||||
// Reload audio when URL changes
|
||||
useEffect(() => {
|
||||
if (audioRef.current && props.url) {
|
||||
audioRef.current.load();
|
||||
}
|
||||
}, [props.url]);
|
||||
|
||||
return (
|
||||
<audio
|
||||
controls
|
||||
|
||||
@@ -36,8 +36,9 @@ const AudioPlayer: React.FC<
|
||||
const mediaElement = useRef<any>(null);
|
||||
|
||||
const initAudioContext = useCallback(() => {
|
||||
audioContext.current = new (window.AudioContext ||
|
||||
window.webkitAudioContext)();
|
||||
audioContext.current = new (
|
||||
window.AudioContext || window.webkitAudioContext
|
||||
)();
|
||||
|
||||
analyser.current = audioContext.current.createAnalyser();
|
||||
analyser.current.fftSize = 512;
|
||||
@@ -218,4 +219,4 @@ const AudioPlayer: React.FC<
|
||||
);
|
||||
});
|
||||
|
||||
export default React.memo(AudioPlayer);
|
||||
export default AudioPlayer;
|
||||
|
||||
@@ -4,16 +4,34 @@ import SpeechItem from './speech-item';
|
||||
interface SpeechContentProps {
|
||||
dataList: any[];
|
||||
loading?: boolean;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
playerRef?: React.RefObject<any>;
|
||||
isPlaying?: boolean;
|
||||
isStream?: boolean;
|
||||
analyserData?: {
|
||||
data: Uint8Array;
|
||||
analyser: any;
|
||||
};
|
||||
}
|
||||
|
||||
const SpeechContent: React.FC<SpeechContentProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{props.dataList.map((item) => (
|
||||
<SpeechItem key={item.uid} {...item} />
|
||||
<SpeechItem
|
||||
key={item.uid}
|
||||
{...item}
|
||||
isStream={props.isStream}
|
||||
isPlaying={props.isPlaying}
|
||||
onPlay={props.onPlay}
|
||||
onPause={props.onPause}
|
||||
playerRef={props.playerRef}
|
||||
analyserData={props.analyserData}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(SpeechContent);
|
||||
export default SpeechContent;
|
||||
|
||||
@@ -8,8 +8,14 @@ import { useIntl } from '@umijs/max';
|
||||
import { Button, Slider, Tooltip } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import _, { throttle } from 'lodash';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import AudioPlayer from './audio-player';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import RawAudioPlayer from '../audio-player/raw-audio-player';
|
||||
import './styles/index.less';
|
||||
import './styles/slider-progress.less';
|
||||
|
||||
@@ -34,10 +40,25 @@ interface SpeechContentProps {
|
||||
format: string;
|
||||
speed: number;
|
||||
audioUrl: string;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
isStream?: boolean;
|
||||
isPlaying?: boolean;
|
||||
playerRef?: React.RefObject<any>;
|
||||
analyserData?: {
|
||||
data: Uint8Array;
|
||||
analyser: any;
|
||||
};
|
||||
}
|
||||
const SpeechItem: React.FC<SpeechContentProps> = (props) => {
|
||||
const { isStream } = props;
|
||||
console.log(
|
||||
'Rendering SpeechItem with props:',
|
||||
props.isStream,
|
||||
props.analyserData
|
||||
);
|
||||
const intl = useIntl();
|
||||
const [isPlay, setIsPlay] = useState(props.autoplay);
|
||||
const [isPlay, setIsPlay] = useState(props.autoplay || props.isPlaying);
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const [animationSize, setAnimationSize] = useState({ width: 900, height: 0 });
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
@@ -48,23 +69,47 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
|
||||
const wrapper = useRef<any>(null);
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
const handlePlay = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
setIsPlay(props.autoplay || props.isPlaying);
|
||||
}, [props.autoplay, props.isPlaying]);
|
||||
|
||||
const isPCMStream = useMemo(() => {
|
||||
return props.audioUrl?.startsWith('pcm-stream://');
|
||||
}, [props.audioUrl]);
|
||||
|
||||
// Sync internal ref with external playerRef if provided
|
||||
React.useEffect(() => {
|
||||
if (props.playerRef) {
|
||||
(props.playerRef as any).current = ref.current;
|
||||
}
|
||||
}, [props.playerRef, ref.current]);
|
||||
|
||||
const onPause = () => {
|
||||
ref.current?.pause();
|
||||
props.onPause?.();
|
||||
};
|
||||
|
||||
const onPlay = async () => {
|
||||
await ref.current?.wavesurfer.current?.play();
|
||||
props.onPlay?.();
|
||||
};
|
||||
|
||||
const handlePlay = async () => {
|
||||
try {
|
||||
if (ref.current?.wavesurfer.current?.isPlaying()) {
|
||||
ref.current?.pause();
|
||||
onPause();
|
||||
setIsPlay(false);
|
||||
return;
|
||||
} else {
|
||||
await ref.current?.wavesurfer.current?.play();
|
||||
await onPlay();
|
||||
setIsPlay(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('error:', error);
|
||||
}
|
||||
}, [ref.current]);
|
||||
};
|
||||
|
||||
const handleOnAnalyse = useCallback((data: any, analyser: any) => {
|
||||
console.log('data+++++++++++++++++=:', data);
|
||||
setAudioChunks((pre: any) => {
|
||||
return {
|
||||
data: data,
|
||||
@@ -76,9 +121,11 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
|
||||
const handleOnFinish = useCallback(() => {
|
||||
setIsPlay(false);
|
||||
}, []);
|
||||
|
||||
const handleOnPlay = useCallback(() => {
|
||||
setIsPlay(true);
|
||||
}, []);
|
||||
|
||||
const handleOnPause = useCallback(() => {
|
||||
setIsPlay(false);
|
||||
}, []);
|
||||
@@ -87,22 +134,9 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
|
||||
setCurrentTime(current);
|
||||
}, 100);
|
||||
|
||||
const handleOnAudioprocess = useCallback(
|
||||
(current: number) => {
|
||||
console.log('current:', current);
|
||||
throttleUpdateCurrentTime(current);
|
||||
},
|
||||
[throttleUpdateCurrentTime]
|
||||
);
|
||||
|
||||
const handleReay = useCallback((duration: number) => {
|
||||
setIsPlay(props.autoplay);
|
||||
setDuration(duration);
|
||||
}, []);
|
||||
|
||||
const handleOnClick = useCallback((value: number) => {
|
||||
console.log('current:', value);
|
||||
}, []);
|
||||
const handleOnAudioprocess = (current: number) => {
|
||||
throttleUpdateCurrentTime(current);
|
||||
};
|
||||
|
||||
const handleAnimationResize = useCallback((size: any) => {
|
||||
setAnimationSize({
|
||||
@@ -121,7 +155,6 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
|
||||
};
|
||||
|
||||
const handleReady = useCallback((duration: number) => {
|
||||
console.log('ready+++++++++++++++', duration);
|
||||
setDuration(duration);
|
||||
}, []);
|
||||
|
||||
@@ -130,9 +163,16 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
|
||||
setCurrentTime(value);
|
||||
}, []);
|
||||
|
||||
const convertFormat = () => {
|
||||
if (props.format === 'pcm') {
|
||||
return 'wav';
|
||||
}
|
||||
return props.format;
|
||||
};
|
||||
|
||||
const onDownload = useCallback(() => {
|
||||
const url = props.audioUrl || '';
|
||||
const filename = `audio-${dayjs().format('YYYYMMDDHHmmss')}.${props.format}`;
|
||||
const filename = `audio-${dayjs().format('YYYYMMDDHHmmss')}.${convertFormat()}`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
@@ -142,6 +182,71 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
|
||||
link.remove();
|
||||
}, [props.audioUrl, props.format]);
|
||||
|
||||
console.log('isPCMStream:', isPCMStream, isPlay);
|
||||
|
||||
console.log('props.analyserData:', props.analyserData);
|
||||
|
||||
const renderPlayerActions = () => {
|
||||
if (isPCMStream) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<Slider
|
||||
className="slider-progress"
|
||||
value={currentTime}
|
||||
max={duration}
|
||||
step={0.01}
|
||||
onChange={handleSliderChange}
|
||||
></Slider>
|
||||
<div className="speech-actions">
|
||||
<span className="tags">
|
||||
<span className="item">{props.format}</span>
|
||||
</span>
|
||||
<span className="duration">
|
||||
{_.round(currentTime, 2) || _.round(duration, 2)}
|
||||
</span>
|
||||
<div className="actions">
|
||||
<Tooltip
|
||||
title={
|
||||
isPlay
|
||||
? intl.formatMessage({ id: 'playground.audio.button.stop' })
|
||||
: intl.formatMessage({ id: 'playground.audio.button.play' })
|
||||
}
|
||||
>
|
||||
<Button
|
||||
disabled={!props.audioUrl || duration === 0}
|
||||
onClick={handlePlay}
|
||||
icon={
|
||||
isPlay ? (
|
||||
<PauseCircleOutlined className="font-size-16" />
|
||||
) : (
|
||||
<PlayCircleOutlined className="font-size-16" />
|
||||
)
|
||||
}
|
||||
type="text"
|
||||
size="small"
|
||||
></Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={intl.formatMessage({
|
||||
id: 'playground.audio.button.download'
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
disabled={!props.audioUrl || duration === 0}
|
||||
onClick={onDownload}
|
||||
icon={<DownloadOutlined className="font-size-16" />}
|
||||
type="text"
|
||||
size="small"
|
||||
></Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="speech-item">
|
||||
@@ -150,93 +255,51 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
|
||||
style={{ height: 120, width: '100%' }}
|
||||
ref={wrapper}
|
||||
>
|
||||
<AudioPlayer
|
||||
{...props}
|
||||
audioUrl={props.audioUrl}
|
||||
onReady={handleReady}
|
||||
onFinish={handleOnFinish}
|
||||
onPlay={handleOnPlay}
|
||||
onPause={handleOnPause}
|
||||
onAnalyse={handleOnAnalyse}
|
||||
onAudioprocess={handleOnAudioprocess}
|
||||
ref={ref}
|
||||
></AudioPlayer>
|
||||
{/* <RawAudioPlayer
|
||||
{...props}
|
||||
url={props.audioUrl}
|
||||
onReady={handleReady}
|
||||
onEnded={handleOnFinish}
|
||||
onPlay={handleOnPlay}
|
||||
onPause={handleOnPause}
|
||||
onAnalyse={handleOnAnalyse}
|
||||
onAudioProcess={handleOnAudioprocess}
|
||||
ref={ref}
|
||||
></RawAudioPlayer> */}
|
||||
{isPlay && (
|
||||
<AudioAnimation
|
||||
maxBarCount={100}
|
||||
amplitude={60}
|
||||
fixedHeight={true}
|
||||
height={120}
|
||||
width={800}
|
||||
analyserData={audioChunks}
|
||||
></AudioAnimation>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
className="slider-progress"
|
||||
value={currentTime}
|
||||
max={duration}
|
||||
step={0.01}
|
||||
onChange={handleSliderChange}
|
||||
></Slider>
|
||||
<div className="speech-actions">
|
||||
<span className="tags">
|
||||
<span className="item">{props.format}</span>
|
||||
</span>
|
||||
<span className="duration">
|
||||
{_.round(currentTime, 2) || _.round(duration, 2)}
|
||||
</span>
|
||||
<div className="actions">
|
||||
<Tooltip
|
||||
title={
|
||||
isPlay
|
||||
? intl.formatMessage({ id: 'playground.audio.button.stop' })
|
||||
: intl.formatMessage({ id: 'playground.audio.button.play' })
|
||||
}
|
||||
>
|
||||
<Button
|
||||
disabled={!props.audioUrl || duration === 0}
|
||||
onClick={handlePlay}
|
||||
icon={
|
||||
isPlay ? (
|
||||
<PauseCircleOutlined className="font-size-16" />
|
||||
) : (
|
||||
<PlayCircleOutlined className="font-size-16" />
|
||||
)
|
||||
}
|
||||
type="text"
|
||||
size="small"
|
||||
></Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={intl.formatMessage({
|
||||
id: 'playground.audio.button.download'
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
disabled={!props.audioUrl || duration === 0}
|
||||
onClick={onDownload}
|
||||
icon={<DownloadOutlined className="font-size-16" />}
|
||||
type="text"
|
||||
size="small"
|
||||
></Button>
|
||||
</Tooltip>
|
||||
<>
|
||||
{/* {!isPCMStream && (
|
||||
<AudioPlayer
|
||||
{...props}
|
||||
audioUrl={props.audioUrl}
|
||||
onReady={handleReady}
|
||||
onFinish={handleOnFinish}
|
||||
onPlay={handleOnPlay}
|
||||
onPause={handleOnPause}
|
||||
onAnalyse={handleOnAnalyse}
|
||||
onAudioprocess={handleOnAudioprocess}
|
||||
ref={ref}
|
||||
></AudioPlayer>
|
||||
)} */}
|
||||
{!isPCMStream && (
|
||||
<RawAudioPlayer
|
||||
{...props}
|
||||
url={props.audioUrl}
|
||||
onReady={handleReady}
|
||||
onEnded={handleOnFinish}
|
||||
onPlay={handleOnPlay}
|
||||
onPause={handleOnPause}
|
||||
onAnalyse={handleOnAnalyse}
|
||||
onAudioProcess={handleOnAudioprocess}
|
||||
ref={ref}
|
||||
></RawAudioPlayer>
|
||||
)}
|
||||
{isPlay &&
|
||||
(props.analyserData?.analyser?.current ||
|
||||
audioChunks.analyser?.current) && (
|
||||
<AudioAnimation
|
||||
maxBarCount={100}
|
||||
amplitude={60}
|
||||
fixedHeight={true}
|
||||
height={120}
|
||||
width={800}
|
||||
analyserData={props.analyserData || audioChunks}
|
||||
></AudioAnimation>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
{renderPlayerActions()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(SpeechItem);
|
||||
export default SpeechItem;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useQueryModelList } from '@/pages/llmodels/services/use-query-model-lis
|
||||
import { useIntl, useNavigate } from '@umijs/max';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { ConfigProvider, Table, message } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import _ from 'lodash';
|
||||
import { useEffect } from 'react';
|
||||
import NoResult from '../_components/no-result';
|
||||
@@ -35,6 +36,23 @@ import useQueryDataset from './services/use-query-dataset';
|
||||
import useQueryProfiles from './services/use-query-profiles';
|
||||
import useStopBenchmark from './services/use-stop-benchmark';
|
||||
|
||||
const useStyle = createStyles(({ css, token }) => {
|
||||
const antCls = '.ant';
|
||||
return {
|
||||
customTable: css`
|
||||
${antCls}-table {
|
||||
${antCls}-table-container {
|
||||
${antCls}-table-body,
|
||||
${antCls}-table-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-scrollbar-thumb) transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
};
|
||||
});
|
||||
|
||||
const Benchmark: React.FC = () => {
|
||||
const {
|
||||
dataSource,
|
||||
@@ -58,6 +76,7 @@ const Benchmark: React.FC = () => {
|
||||
watch: true,
|
||||
contentForDelete: 'menu.models.benchmark'
|
||||
});
|
||||
const { styles } = useStyle();
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const { openBenchmarkModal, closeBenchmarkModal, openBenchmarkModalStatus } =
|
||||
@@ -242,13 +261,14 @@ const Benchmark: React.FC = () => {
|
||||
<Table
|
||||
tableLayout="fixed"
|
||||
columns={columns}
|
||||
className={styles.customTable}
|
||||
dataSource={dataSource.dataList}
|
||||
rowSelection={rowSelection}
|
||||
loading={dataSource.loading}
|
||||
sortDirections={TABLE_SORT_DIRECTIONS}
|
||||
showSorterTooltip={false}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1260 }}
|
||||
scroll={{ x: 1200 }}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
|
||||
@@ -155,16 +155,8 @@ export const textToSpeech = async (params: any, options?: any) => {
|
||||
}
|
||||
|
||||
const audioBlob = await res.blob();
|
||||
if (audioBlob?.type?.indexOf('audio') === -1) {
|
||||
return {
|
||||
url: '',
|
||||
type: ''
|
||||
};
|
||||
}
|
||||
const audioUrl = audioBlob.size > 0 ? URL.createObjectURL(audioBlob) : '';
|
||||
return {
|
||||
url: audioUrl,
|
||||
type: audioBlob.type
|
||||
audioBlob
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -55,8 +55,10 @@ const ParamsFields: React.FC<ParamsFieldsProps> = ({ paramsConfig = [] }) => {
|
||||
'formItemAttrs',
|
||||
'dependencies',
|
||||
'disabledConfig',
|
||||
'description'
|
||||
'description',
|
||||
'initAttrs'
|
||||
])}
|
||||
{...item.initAttrs?.(meta)}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
@@ -93,7 +95,8 @@ const ParamsFields: React.FC<ParamsFieldsProps> = ({ paramsConfig = [] }) => {
|
||||
'formItemAttrs',
|
||||
'dependencies',
|
||||
'disabledConfig',
|
||||
'description'
|
||||
'description',
|
||||
'initAttrs'
|
||||
])}
|
||||
{...item.initAttrs?.(meta)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
const workletCode = `class PCMPlayerProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.capacity = 24000 * 2; // 2 seconds buffer
|
||||
this.jitterThreshold = 24000 * 0.1; // 100ms
|
||||
|
||||
this.buffer = new Float32Array(this.capacity);
|
||||
|
||||
this.readIndex = 0;
|
||||
this.writeIndex = 0;
|
||||
this.size = 0;
|
||||
|
||||
this.streamEnded = false;
|
||||
this.completionNotified = false;
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
const { type, data } = event.data || {};
|
||||
|
||||
if (type === 'push' && data) {
|
||||
this.push(data);
|
||||
}
|
||||
|
||||
if (type === 'clear') {
|
||||
this.readIndex = 0;
|
||||
this.writeIndex = 0;
|
||||
this.size = 0;
|
||||
this.streamEnded = false;
|
||||
this.completionNotified = false;
|
||||
}
|
||||
|
||||
if (type === 'end-stream') {
|
||||
this.streamEnded = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
push(data) {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (this.size >= this.capacity) {
|
||||
// backpressure: drop oldest data
|
||||
this.readIndex = (this.readIndex + 1) % this.capacity;
|
||||
this.size--;
|
||||
}
|
||||
|
||||
this.buffer[this.writeIndex] = data[i];
|
||||
|
||||
this.writeIndex = (this.writeIndex + 1) % this.capacity;
|
||||
this.size++;
|
||||
}
|
||||
}
|
||||
|
||||
process(inputs, outputs) {
|
||||
const output = outputs[0];
|
||||
if (!output) return true;
|
||||
|
||||
const channel = output[0];
|
||||
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
// When stream has ended, ignore jitter threshold and play remaining data
|
||||
if (!this.streamEnded && this.size < this.jitterThreshold) {
|
||||
channel[i] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.size === 0) {
|
||||
channel[i] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
channel[i] = this.buffer[this.readIndex];
|
||||
|
||||
this.readIndex = (this.readIndex + 1) % this.capacity;
|
||||
this.size--;
|
||||
}
|
||||
|
||||
// Check if playback is complete
|
||||
if (this.streamEnded && this.size === 0 && !this.completionNotified) {
|
||||
console.log('PCM playback complete, notifying main thread');
|
||||
this.completionNotified = true;
|
||||
this.port.postMessage({ type: 'playback-complete' });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('pcm-player', PCMPlayerProcessor);`;
|
||||
|
||||
export const workerletUrl = (): string => {
|
||||
const blob = new Blob([workletCode], { type: 'application/javascript' });
|
||||
const workletUrl = URL.createObjectURL(blob);
|
||||
return workletUrl;
|
||||
};
|
||||
@@ -128,17 +128,6 @@ const TTSAdvanceConfig: React.FC = () => {
|
||||
})}
|
||||
></CheckboxField>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="stream"
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<CheckboxField
|
||||
label={intl.formatMessage({
|
||||
id: 'playground.params.streamMode'
|
||||
})}
|
||||
></CheckboxField>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AutoComplete from '@/components/seal-form/auto-complete';
|
||||
import CheckboxField from '@/components/seal-form/checkbox-field';
|
||||
import SealSelect from '@/components/seal-form/seal-select';
|
||||
import CollapsePanel from '@/pages/_components/collapse-panel';
|
||||
import { getLocale, useIntl, useSearchParams } from '@umijs/max';
|
||||
@@ -13,7 +14,7 @@ import React, {
|
||||
} from 'react';
|
||||
import ModelSelect from '../../components/model-select';
|
||||
import ParamsFields from '../../components/params-fields';
|
||||
import { FormContext } from '../../config/form-context';
|
||||
import { FormContext, useFormContext } from '../../config/form-context';
|
||||
import { TTSAdvancedParamsConfig } from '../params-config';
|
||||
import AdvanceConfig from './tts-advance';
|
||||
|
||||
@@ -35,6 +36,7 @@ type ParamsSettingsProps = {
|
||||
|
||||
const ParamsSettings: React.FC<ParamsSettingsProps> = forwardRef(
|
||||
({ onFinish, onFinishFailed, updatateParams, modelList = [] }, ref) => {
|
||||
const { onValuesChange } = useFormContext();
|
||||
const [searchParams] = useSearchParams();
|
||||
const modelType = searchParams.get('type') || '';
|
||||
const selectModel = searchParams.get('model')
|
||||
@@ -124,6 +126,14 @@ const ParamsSettings: React.FC<ParamsSettingsProps> = forwardRef(
|
||||
if (changeValues.model) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('stream' in changeValues && changeValues.stream) {
|
||||
allValues.response_format = 'pcm';
|
||||
form.setFieldsValue({
|
||||
response_format: 'pcm'
|
||||
});
|
||||
}
|
||||
|
||||
updatateParams(allValues);
|
||||
};
|
||||
|
||||
@@ -164,15 +174,27 @@ const ParamsSettings: React.FC<ParamsSettingsProps> = forwardRef(
|
||||
options={vociceOptions}
|
||||
></AutoComplete>
|
||||
</Form.Item>
|
||||
<Form.Item name="response_format">
|
||||
<Form.Item name="response_format" style={{ marginBottom: 8 }}>
|
||||
<SealSelect
|
||||
label={intl.formatMessage({ id: 'playground.params.format' })}
|
||||
options={[
|
||||
{ label: 'mp3', value: 'mp3' },
|
||||
{ label: 'wav', value: 'wav' }
|
||||
{ label: 'wav', value: 'wav' },
|
||||
{ label: 'pcm', value: 'pcm' }
|
||||
]}
|
||||
></SealSelect>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="stream"
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<CheckboxField
|
||||
label={intl.formatMessage({
|
||||
id: 'playground.params.streamMode'
|
||||
})}
|
||||
></CheckboxField>
|
||||
</Form.Item>
|
||||
<CollapsePanel
|
||||
activeKey={activeKey}
|
||||
onChange={handleOnCollapse}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pcmToWav } from '@/utils/pcm-to-wav';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { textToSpeech } from '../../apis';
|
||||
import { extractErrorMessage } from '../../config';
|
||||
@@ -21,6 +22,35 @@ export const useNonStreamTTS = (params?: UseNonStreamTTSParams) => {
|
||||
const [error, setError] = useState<any>(null);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const convertPCMToWav = async (ab: Blob) => {
|
||||
let audioBlob = ab;
|
||||
// Convert PCM to WAV for browser playback
|
||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||
audioBlob = pcmToWav(arrayBuffer);
|
||||
|
||||
const audioUrl = audioBlob.size > 0 ? URL.createObjectURL(audioBlob) : '';
|
||||
return {
|
||||
url: audioUrl,
|
||||
type: audioBlob.type
|
||||
};
|
||||
};
|
||||
|
||||
// handle non-pcm formats
|
||||
const generateAudioUrl = async (audioBlob: Blob) => {
|
||||
const audioUrl = audioBlob.size > 0 ? URL.createObjectURL(audioBlob) : '';
|
||||
return {
|
||||
url: audioUrl,
|
||||
type: audioBlob.type
|
||||
};
|
||||
};
|
||||
|
||||
const isPCMFormat = (audioBlob: Blob) => {
|
||||
return (
|
||||
audioBlob.type === 'audio/pcm' ||
|
||||
audioBlob.type === 'application/octet-stream'
|
||||
);
|
||||
};
|
||||
|
||||
const generate = useCallback(
|
||||
async (ttsParams: TTSParams) => {
|
||||
try {
|
||||
@@ -47,7 +77,26 @@ export const useNonStreamTTS = (params?: UseNonStreamTTSParams) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
params?.onSuccess?.(res);
|
||||
let result = {
|
||||
url: '',
|
||||
type: ''
|
||||
};
|
||||
|
||||
if (res.audioBlob?.type?.indexOf('audio') === -1) {
|
||||
result = {
|
||||
url: '',
|
||||
type: ''
|
||||
};
|
||||
} else if (
|
||||
ttsParams.response_format === 'pcm' ||
|
||||
isPCMFormat(res.audioBlob)
|
||||
) {
|
||||
result = await convertPCMToWav(res.audioBlob);
|
||||
} else {
|
||||
result = await generateAudioUrl(res.audioBlob);
|
||||
}
|
||||
|
||||
params?.onSuccess?.(result);
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
const res = err?.response?.data;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { useRef, useState } from 'react';
|
||||
import { workerletUrl } from '../audio/pcm-player-workerlet';
|
||||
|
||||
interface Params {
|
||||
sampleRate?: number;
|
||||
numChannels?: number;
|
||||
bitsPerSample?: number;
|
||||
onReady?: () => void;
|
||||
onError?: (err: any) => void;
|
||||
onPlaybackComplete?: () => void;
|
||||
}
|
||||
|
||||
export const usePCMStreamPlayer = (params?: Params) => {
|
||||
const {
|
||||
sampleRate = 24000,
|
||||
numChannels = 1,
|
||||
bitsPerSample = 16,
|
||||
onReady,
|
||||
onError,
|
||||
onPlaybackComplete
|
||||
} = params || {};
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const workletNodeRef = useRef<AudioWorkletNode | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
|
||||
const leftoverRef = useRef<Uint8Array | null>(null);
|
||||
const streamEndedRef = useRef(false);
|
||||
|
||||
const [audioChunks, setAudioChunks] = useState<any>({
|
||||
data: new Uint8Array(128),
|
||||
analyser: null
|
||||
});
|
||||
|
||||
const initialize = useMemoizedFn(async () => {
|
||||
if (audioContextRef.current) return;
|
||||
|
||||
try {
|
||||
const ctx = new AudioContext({ sampleRate });
|
||||
|
||||
const workletUrl = workerletUrl();
|
||||
|
||||
await ctx.audioWorklet.addModule(workletUrl);
|
||||
|
||||
URL.revokeObjectURL(workletUrl);
|
||||
const node = new AudioWorkletNode(ctx, 'pcm-player');
|
||||
|
||||
// Listen for messages from the worklet
|
||||
node.port.onmessage = (event) => {
|
||||
if (event.data.type === 'playback-complete') {
|
||||
setIsPlaying(false);
|
||||
onPlaybackComplete?.();
|
||||
}
|
||||
};
|
||||
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
|
||||
node.connect(analyser);
|
||||
analyser.connect(ctx.destination);
|
||||
|
||||
audioContextRef.current = ctx;
|
||||
workletNodeRef.current = node;
|
||||
analyserRef.current = analyser;
|
||||
|
||||
setAudioChunks({
|
||||
data: new Uint8Array(analyser.frequencyBinCount),
|
||||
analyser: analyserRef
|
||||
});
|
||||
|
||||
streamEndedRef.current = false;
|
||||
|
||||
onReady?.();
|
||||
} catch (err) {
|
||||
onError?.(err);
|
||||
}
|
||||
});
|
||||
|
||||
const convertPCM = (pcmData: Uint8Array) => {
|
||||
let pcm = pcmData;
|
||||
const bytesPerSample = bitsPerSample / 8;
|
||||
const frameSize = bytesPerSample * numChannels;
|
||||
|
||||
// If there's leftover data from the previous chunk, prepend it to the current chunk
|
||||
if (leftoverRef.current) {
|
||||
const merged = new Uint8Array(leftoverRef.current.length + pcm.length);
|
||||
merged.set(leftoverRef.current);
|
||||
merged.set(pcm, leftoverRef.current.length);
|
||||
pcm = merged;
|
||||
}
|
||||
|
||||
const usableLength = Math.floor(pcm.length / frameSize) * frameSize;
|
||||
|
||||
leftoverRef.current = pcm.slice(usableLength);
|
||||
|
||||
const usable = pcm.slice(0, usableLength);
|
||||
|
||||
const view = new DataView(
|
||||
usable.buffer,
|
||||
usable.byteOffset,
|
||||
usable.byteLength
|
||||
);
|
||||
|
||||
const numSamples = usable.length / 2;
|
||||
|
||||
const float32 = new Float32Array(numSamples);
|
||||
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
float32[i] = view.getInt16(i * 2, true) / 32768;
|
||||
}
|
||||
|
||||
return float32;
|
||||
};
|
||||
|
||||
const addChunk = useMemoizedFn((pcmChunk: Uint8Array) => {
|
||||
const node = workletNodeRef.current;
|
||||
if (!node) return;
|
||||
|
||||
setIsPlaying(true);
|
||||
const floatData = convertPCM(pcmChunk);
|
||||
|
||||
if (!floatData.length) return;
|
||||
|
||||
node.port.postMessage({
|
||||
type: 'push',
|
||||
data: floatData
|
||||
});
|
||||
});
|
||||
|
||||
const stop = useMemoizedFn(() => {
|
||||
const ctx = audioContextRef.current;
|
||||
if (!ctx) return;
|
||||
|
||||
workletNodeRef.current?.port.postMessage({ type: 'clear' });
|
||||
|
||||
ctx.close();
|
||||
|
||||
audioContextRef.current = null;
|
||||
workletNodeRef.current = null;
|
||||
analyserRef.current = null;
|
||||
streamEndedRef.current = false;
|
||||
|
||||
setIsPlaying(false);
|
||||
});
|
||||
|
||||
const cleanup = useMemoizedFn(() => {
|
||||
stop();
|
||||
});
|
||||
|
||||
const endStream = useMemoizedFn(() => {
|
||||
const node = workletNodeRef.current;
|
||||
if (!node) return;
|
||||
|
||||
streamEndedRef.current = true;
|
||||
node.port.postMessage({ type: 'end-stream' });
|
||||
});
|
||||
|
||||
return {
|
||||
initialize,
|
||||
addChunk,
|
||||
stop,
|
||||
cleanup,
|
||||
endStream,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
setAudioChunks,
|
||||
audioChunks
|
||||
};
|
||||
};
|
||||
@@ -1,13 +1,18 @@
|
||||
import { fetchChunkedData } from '@/utils/fetch-chunk-data';
|
||||
import { pcmToWav } from '@/utils/pcm-to-wav';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { AUDIO_TEXT_TO_SPEECH_API } from '../../apis';
|
||||
import { extractErrorMessage } from '../../config';
|
||||
import { usePCMStreamPlayer } from './use-pcm-stream-player';
|
||||
|
||||
interface UseStreamTTSParams {
|
||||
onChunk?: (chunk: ArrayBuffer) => void;
|
||||
onComplete?: (audioUrl: string) => void; // Return complete audio URL when done
|
||||
onError?: (error: any) => void;
|
||||
onUrlReady?: (url: string) => void; // Called when the stream URL is ready
|
||||
autoPlay?: boolean;
|
||||
playerRef?: React.RefObject<any>; // Reference to the player for controlling playback
|
||||
}
|
||||
|
||||
interface TTSParams {
|
||||
@@ -20,262 +25,325 @@ interface TTSParams {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio chunk queue manager for smooth playback
|
||||
* Uses a single Audio element for better performance and seamless playback
|
||||
*/
|
||||
class AudioQueue {
|
||||
private queue: Blob[] = [];
|
||||
private audioElement: HTMLAudioElement;
|
||||
private isPlaying = false;
|
||||
private currentIndex = 0;
|
||||
private onComplete?: () => void;
|
||||
private streamEnded = false;
|
||||
private minBufferSize = 2; // Minimum chunks to buffer before starting playback
|
||||
private maxQueueSize = 10; // Maximum queue size to prevent memory issues
|
||||
private currentBlobUrl: string | null = null;
|
||||
// MediaSource codec mapping for different formats
|
||||
const MEDIA_SOURCE_CODECS: Record<string, string> = {
|
||||
mp4: 'audio/mp4; codecs="mp4a.40.2"',
|
||||
webm: 'audio/webm; codecs="opus"',
|
||||
ogg: 'audio/ogg; codecs="opus"',
|
||||
opus: 'audio/webm; codecs="opus"',
|
||||
pcm: 'audio/pcm; codecs=pcm'
|
||||
};
|
||||
|
||||
constructor(onComplete?: () => void) {
|
||||
this.onComplete = onComplete;
|
||||
// Create a single Audio element for the entire playback session
|
||||
this.audioElement = new Audio();
|
||||
this.setupAudioListeners();
|
||||
}
|
||||
|
||||
private setupAudioListeners() {
|
||||
this.audioElement.onended = () => {
|
||||
this.cleanupCurrentBlob();
|
||||
this.playNext();
|
||||
};
|
||||
|
||||
this.audioElement.onerror = () => {
|
||||
console.error('Audio playback error');
|
||||
this.cleanupCurrentBlob();
|
||||
this.playNext();
|
||||
};
|
||||
}
|
||||
|
||||
private cleanupCurrentBlob() {
|
||||
if (this.currentBlobUrl) {
|
||||
URL.revokeObjectURL(this.currentBlobUrl);
|
||||
this.currentBlobUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
addChunk(chunk: Blob) {
|
||||
this.queue.push(chunk);
|
||||
|
||||
// Start playback if we have enough buffer
|
||||
if (!this.isPlaying && this.queue.length >= this.minBufferSize) {
|
||||
this.playNext();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if queue is full (for backpressure)
|
||||
isFull(): boolean {
|
||||
return this.queue.length - this.currentIndex >= this.maxQueueSize;
|
||||
}
|
||||
|
||||
// Get current queue size (unplayed chunks)
|
||||
getQueueSize(): number {
|
||||
return this.queue.length - this.currentIndex;
|
||||
}
|
||||
|
||||
private async playNext() {
|
||||
if (this.currentIndex >= this.queue.length) {
|
||||
// If stream has ended and no more chunks, complete
|
||||
if (this.streamEnded) {
|
||||
this.isPlaying = false;
|
||||
this.onComplete?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
const chunk = this.queue[this.currentIndex];
|
||||
this.currentIndex++;
|
||||
|
||||
// Reuse the same Audio element, just update the src
|
||||
this.cleanupCurrentBlob();
|
||||
this.currentBlobUrl = URL.createObjectURL(chunk);
|
||||
this.audioElement.src = this.currentBlobUrl;
|
||||
|
||||
try {
|
||||
await this.audioElement.play();
|
||||
} catch (error) {
|
||||
console.error('Failed to play audio chunk:', error);
|
||||
this.cleanupCurrentBlob();
|
||||
this.playNext();
|
||||
}
|
||||
}
|
||||
|
||||
markStreamEnded() {
|
||||
this.streamEnded = true;
|
||||
// If not playing and has remaining chunks, start playing
|
||||
if (!this.isPlaying && this.currentIndex < this.queue.length) {
|
||||
this.playNext();
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isPlaying = false;
|
||||
this.audioElement.pause();
|
||||
this.cleanupCurrentBlob();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.stop();
|
||||
this.queue = [];
|
||||
this.currentIndex = 0;
|
||||
this.streamEnded = false;
|
||||
}
|
||||
}
|
||||
// Check if format supports MediaSource API
|
||||
const supportsMediaSource = (format: string): boolean => {
|
||||
if (!window.MediaSource) return false;
|
||||
const codec = MEDIA_SOURCE_CODECS[format];
|
||||
return codec ? MediaSource.isTypeSupported(codec) : false;
|
||||
};
|
||||
|
||||
export const useStreamTTS = (params?: UseStreamTTSParams) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [streamUrl, setStreamUrl] = useState<string>('');
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const audioQueueRef = useRef<AudioQueue | null>(null);
|
||||
const mediaSourceRef = useRef<MediaSource | null>(null);
|
||||
const sourceBufferRef = useRef<SourceBuffer | null>(null);
|
||||
const queueRef = useRef<Uint8Array[]>([]);
|
||||
const isAppendingRef = useRef(false);
|
||||
const allChunksRef = useRef<Uint8Array[]>([]);
|
||||
const [isPCM, setIsPCM] = useState(false);
|
||||
const completeAudioUrlRef = useRef<string>('');
|
||||
|
||||
const generate = useCallback(
|
||||
async (ttsParams: TTSParams) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
// PCM stream player instance
|
||||
const pcmPlayer = usePCMStreamPlayer({
|
||||
onError: (error) => {
|
||||
console.error('PCM player error:', error);
|
||||
params?.onError?.(error);
|
||||
},
|
||||
onPlaybackComplete: () => {
|
||||
console.log('PCM playback complete');
|
||||
// Create complete audio URL from all chunks when playback finishes
|
||||
if (allChunksRef.current.length > 0) {
|
||||
const totalLength = allChunksRef.current.reduce(
|
||||
(acc, chunk) => acc + chunk.length,
|
||||
0
|
||||
);
|
||||
const pcmData = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of allChunksRef.current) {
|
||||
pcmData.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// Abort previous request if exists
|
||||
controllerRef.current?.abort();
|
||||
audioQueueRef.current?.clear();
|
||||
// Convert combined PCM data to WAV and create URL
|
||||
const wavBlob = pcmToWav(pcmData.buffer);
|
||||
const completeUrl = URL.createObjectURL(wavBlob);
|
||||
params?.onComplete?.(completeUrl);
|
||||
// when the stream ends, should clear the audio analyer data, because the the audio elment will play the complete audio, and the analyser will generate new.
|
||||
pcmPlayer.setAudioChunks(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
controllerRef.current = new AbortController();
|
||||
const signal = controllerRef.current.signal;
|
||||
const processQueue = useCallback(() => {
|
||||
if (
|
||||
isAppendingRef.current ||
|
||||
queueRef.current.length === 0 ||
|
||||
!sourceBufferRef.current ||
|
||||
sourceBufferRef.current.updating
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create audio queue for smooth playback
|
||||
audioQueueRef.current = new AudioQueue();
|
||||
isAppendingRef.current = true;
|
||||
const chunk = queueRef.current.shift()!;
|
||||
try {
|
||||
sourceBufferRef.current.appendBuffer(chunk.buffer as ArrayBuffer);
|
||||
} catch (error) {
|
||||
console.error('Failed to append buffer:', error);
|
||||
isAppendingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Add stream parameter
|
||||
const streamParams = {
|
||||
...ttsParams,
|
||||
stream: true
|
||||
};
|
||||
const generate = useMemoizedFn(async (ttsParams: TTSParams) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setIsPCM(ttsParams.response_format === 'pcm');
|
||||
allChunksRef.current = [];
|
||||
|
||||
const result = await fetchChunkedData({
|
||||
url: AUDIO_TEXT_TO_SPEECH_API,
|
||||
data: streamParams,
|
||||
signal
|
||||
});
|
||||
// Abort previous request if exists
|
||||
controllerRef.current?.abort();
|
||||
|
||||
if ('error' in result) {
|
||||
const errorMessage = extractErrorMessage(result.data);
|
||||
setError({
|
||||
error: true,
|
||||
errorMessage
|
||||
// Clean up previous PCM player
|
||||
pcmPlayer.stop();
|
||||
|
||||
// Clean up previous MediaSource/URL
|
||||
if (streamUrl) {
|
||||
URL.revokeObjectURL(streamUrl);
|
||||
setStreamUrl('');
|
||||
}
|
||||
|
||||
if (mediaSourceRef.current) {
|
||||
if (mediaSourceRef.current.readyState === 'open') {
|
||||
mediaSourceRef.current.endOfStream();
|
||||
}
|
||||
mediaSourceRef.current = null;
|
||||
}
|
||||
|
||||
sourceBufferRef.current = null;
|
||||
queueRef.current = [];
|
||||
isAppendingRef.current = false;
|
||||
|
||||
controllerRef.current = new AbortController();
|
||||
const signal = controllerRef.current.signal;
|
||||
|
||||
const format = ttsParams.response_format || 'mp3';
|
||||
const isPCM = format === 'pcm';
|
||||
const useMediaSource = !isPCM && supportsMediaSource(format);
|
||||
|
||||
let url = '';
|
||||
console.log(
|
||||
'format:',
|
||||
format,
|
||||
'isPCM:',
|
||||
isPCM,
|
||||
'useMediaSource:',
|
||||
useMediaSource
|
||||
);
|
||||
|
||||
if (isPCM) {
|
||||
// PCM format: use Web Audio API for playback
|
||||
pcmPlayer.initialize();
|
||||
// Create a virtual URL identifier for PCM stream (not an actual URL since we're using Web Audio API)
|
||||
url = 'pcm-stream://playing';
|
||||
setStreamUrl(url);
|
||||
params?.onUrlReady?.(url);
|
||||
} else if (useMediaSource) {
|
||||
// Use MediaSource API for supported formats
|
||||
const mediaSource = new MediaSource();
|
||||
mediaSourceRef.current = mediaSource;
|
||||
url = URL.createObjectURL(mediaSource);
|
||||
console.log('Created MediaSource URL:', url);
|
||||
setStreamUrl(url);
|
||||
params?.onUrlReady?.(url); // Notify that URL is ready
|
||||
console.log('Called onUrlReady with URL:', url);
|
||||
|
||||
// Wait for MediaSource to be ready
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const handleSourceOpen = () => {
|
||||
try {
|
||||
const mimeType = MEDIA_SOURCE_CODECS[format];
|
||||
const sourceBuffer = mediaSource.addSourceBuffer(mimeType);
|
||||
sourceBufferRef.current = sourceBuffer;
|
||||
|
||||
sourceBuffer.addEventListener('updateend', () => {
|
||||
isAppendingRef.current = false;
|
||||
processQueue();
|
||||
});
|
||||
|
||||
sourceBuffer.addEventListener('error', (e) => {
|
||||
console.error('SourceBuffer error:', e);
|
||||
});
|
||||
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
mediaSource.addEventListener('sourceopen', handleSourceOpen, {
|
||||
once: true
|
||||
});
|
||||
params?.onError?.(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const { reader } = result;
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get reader from response');
|
||||
}
|
||||
|
||||
// Read stream data
|
||||
let audioChunks: Uint8Array[] = [];
|
||||
let allAudioChunks: Uint8Array[] = []; // Collect all chunks for final URL
|
||||
let chunkCount = 0;
|
||||
|
||||
while (true) {
|
||||
// Backpressure: wait if queue is full
|
||||
while (audioQueueRef.current?.isFull()) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
}
|
||||
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
// Process remaining chunks for playback
|
||||
if (audioChunks.length > 0) {
|
||||
const blob = new Blob(audioChunks as any, {
|
||||
type: `audio/${ttsParams.response_format || 'mp3'}`
|
||||
});
|
||||
audioQueueRef.current?.addChunk(blob);
|
||||
params?.onChunk?.(blob.arrayBuffer() as any);
|
||||
// Timeout fallback
|
||||
setTimeout(() => {
|
||||
if (mediaSource.readyState !== 'open') {
|
||||
reject(new Error('MediaSource failed to open'));
|
||||
}
|
||||
audioQueueRef.current?.markStreamEnded();
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// Create complete audio URL from all chunks
|
||||
if (allAudioChunks.length > 0) {
|
||||
const completeBlob = new Blob(allAudioChunks as any, {
|
||||
type: `audio/${ttsParams.response_format || 'mp3'}`
|
||||
});
|
||||
const completeUrl = URL.createObjectURL(completeBlob);
|
||||
params?.onComplete?.(completeUrl);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Add stream parameter
|
||||
const streamParams = {
|
||||
...ttsParams,
|
||||
stream: true
|
||||
};
|
||||
|
||||
if (value) {
|
||||
audioChunks.push(value);
|
||||
allAudioChunks.push(value); // Keep all chunks for final URL
|
||||
chunkCount++;
|
||||
const result = await fetchChunkedData({
|
||||
url: AUDIO_TEXT_TO_SPEECH_API,
|
||||
data: streamParams,
|
||||
signal
|
||||
});
|
||||
|
||||
// For fast API responses, batch chunks to avoid too many small audio elements
|
||||
// Create a blob every N chunks or when chunk size exceeds threshold
|
||||
const totalSize = audioChunks.reduce(
|
||||
(sum, chunk) => sum + chunk.length,
|
||||
0
|
||||
);
|
||||
const shouldFlush = chunkCount >= 5 || totalSize >= 50000; // ~50KB threshold
|
||||
|
||||
if (shouldFlush) {
|
||||
const blob = new Blob(audioChunks as any, {
|
||||
type: `audio/${ttsParams.response_format || 'mp3'}`
|
||||
});
|
||||
audioQueueRef.current?.addChunk(blob);
|
||||
params?.onChunk?.(blob.arrayBuffer() as any);
|
||||
|
||||
audioChunks = [];
|
||||
chunkCount = 0;
|
||||
setProgress((prev) => prev + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
console.log('Stream aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = err?.message || 'Stream processing failed';
|
||||
if ('error' in result) {
|
||||
const errorMessage = extractErrorMessage(result.data);
|
||||
setError({
|
||||
error: true,
|
||||
errorMessage
|
||||
});
|
||||
params?.onError?.(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[params]
|
||||
);
|
||||
|
||||
const { reader } = result;
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get reader from response');
|
||||
}
|
||||
|
||||
// Read stream data
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
if (useMediaSource) {
|
||||
// Wait for all chunks to be appended before ending stream
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkQueue = () => {
|
||||
if (queueRef.current.length === 0 && !isAppendingRef.current) {
|
||||
if (mediaSourceRef.current?.readyState === 'open') {
|
||||
try {
|
||||
mediaSourceRef.current.endOfStream();
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to end stream:', error);
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkQueue, 100);
|
||||
}
|
||||
};
|
||||
checkQueue();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle completion based on format
|
||||
if (allChunksRef.current.length > 0) {
|
||||
if (isPCM) {
|
||||
// PCM format: notify the player that streaming has ended
|
||||
// onComplete will be called by the player when playback finishes
|
||||
pcmPlayer.endStream();
|
||||
} else {
|
||||
const completeBlob = new Blob(allChunksRef.current as any, {
|
||||
type: `audio/${format}`
|
||||
});
|
||||
const completeUrl = URL.createObjectURL(completeBlob);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
allChunksRef.current.push(value);
|
||||
if (isPCM) {
|
||||
// PCM chunks are sent directly to the player for real-time playback
|
||||
pcmPlayer.addChunk(value);
|
||||
} else if (useMediaSource) {
|
||||
queueRef.current.push(value);
|
||||
processQueue();
|
||||
}
|
||||
|
||||
params?.onChunk?.(value.buffer);
|
||||
setProgress((prev) => prev + 1);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
console.log('Stream aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = err?.message || 'Stream processing failed';
|
||||
setError({
|
||||
error: true,
|
||||
errorMessage
|
||||
});
|
||||
params?.onError?.(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const abort = useCallback(() => {
|
||||
controllerRef.current?.abort();
|
||||
audioQueueRef.current?.stop();
|
||||
pcmPlayer.stop();
|
||||
if (streamUrl) {
|
||||
URL.revokeObjectURL(streamUrl);
|
||||
}
|
||||
if (mediaSourceRef.current?.readyState === 'open') {
|
||||
try {
|
||||
mediaSourceRef.current.endOfStream();
|
||||
} catch (error) {
|
||||
console.error('Failed to end stream on abort:', error);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
}, [streamUrl, pcmPlayer]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
params?.playerRef?.current?.play();
|
||||
}, [params?.playerRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
params?.playerRef?.current?.pause();
|
||||
}, [params?.playerRef]);
|
||||
|
||||
return {
|
||||
generate,
|
||||
abort,
|
||||
loading,
|
||||
error,
|
||||
progress
|
||||
progress,
|
||||
streamUrl,
|
||||
audioChunks: isPCM ? pcmPlayer.audioChunks : undefined,
|
||||
isPlaying: pcmPlayer.isPlaying,
|
||||
pcmPlayer,
|
||||
play,
|
||||
pause
|
||||
};
|
||||
};
|
||||
|
||||
@@ -80,7 +80,6 @@ export const TTSAdvancedParamsConfig: ParamsSchema[] = [
|
||||
isLocalized: true
|
||||
},
|
||||
attrs: {
|
||||
allowClear: true,
|
||||
step: 1,
|
||||
min: 0,
|
||||
max: 4096
|
||||
|
||||
@@ -58,8 +58,18 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
|
||||
const checkvalueRef = useRef<any>(true);
|
||||
const [currentPrompt, setCurrentPrompt] = useState<string>('');
|
||||
const formRef = useRef<any>(null);
|
||||
const [playingStream, setPlayingStream] = useState(false);
|
||||
|
||||
const handleOnPlay = () => {
|
||||
// TODO
|
||||
console.log('Play audio');
|
||||
};
|
||||
|
||||
const handleOnPause = () => {
|
||||
// TODO
|
||||
console.log('Pause audio');
|
||||
};
|
||||
|
||||
// Initialize non-stream TTS hook
|
||||
const nonStreamTTS = useNonStreamTTS({
|
||||
onSuccess: (result) => {
|
||||
setMessageList([
|
||||
@@ -83,11 +93,25 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize stream TTS hook
|
||||
const playerRef = useRef<any>(null);
|
||||
|
||||
const streamTTS = useStreamTTS({
|
||||
autoPlay: checkvalueRef.current,
|
||||
playerRef: playerRef,
|
||||
onUrlReady: (url) => {
|
||||
console.log('onUrlReady called with URL:', url);
|
||||
// Update messageList with stream URL when it's ready
|
||||
setMessageList((prev) => {
|
||||
if (prev.length > 0) {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1].audioUrl = url;
|
||||
return updated;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
onComplete: (audioUrl) => {
|
||||
// Update messageList with complete audio URL when stream finishes
|
||||
console.log('onComplete called with audioUrl:', audioUrl);
|
||||
setMessageList((prev) => {
|
||||
if (prev.length > 0) {
|
||||
const updated = [...prev];
|
||||
@@ -96,7 +120,7 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
console.log('Stream playback completed');
|
||||
setPlayingStream(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
setTokenResult({
|
||||
@@ -104,9 +128,12 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
|
||||
errorMessage: error
|
||||
});
|
||||
setMessageList([]);
|
||||
setPlayingStream(false);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('isPlaying:', streamTTS.isPlaying);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
viewCode() {
|
||||
@@ -180,10 +207,12 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
|
||||
};
|
||||
|
||||
setParams(params);
|
||||
console.log('submitMessage params:', streamTTS.isPlaying);
|
||||
|
||||
// Choose stream or non-stream based on parameters
|
||||
if (parameters.stream) {
|
||||
// Stream mode: audio will play in real-time
|
||||
setPlayingStream(true);
|
||||
// Stream mode: create initial message entry, URL will be set via onUrlReady callback
|
||||
setMessageList([
|
||||
{
|
||||
input: inputText,
|
||||
@@ -191,8 +220,8 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
|
||||
format: parameters.response_format,
|
||||
speed: parameters.speed,
|
||||
uid: messageId.current,
|
||||
autoplay: checkvalueRef.current,
|
||||
audioUrl: '' // No URL for stream mode, audio plays in real-time
|
||||
autoplay: false,
|
||||
audioUrl: '' // Will be updated via onUrlReady callback
|
||||
}
|
||||
]);
|
||||
await streamTTS.generate(params);
|
||||
@@ -202,6 +231,7 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log('error:', error);
|
||||
setPlayingStream(false);
|
||||
setTokenResult({
|
||||
error: true,
|
||||
errorMessage: error?.message || 'Unknown error'
|
||||
@@ -252,7 +282,16 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
|
||||
>
|
||||
<div className="content" style={{ maxWidth: 1000 }}>
|
||||
{messageList.length ? (
|
||||
<SpeechContent dataList={messageList} loading={loading} />
|
||||
<SpeechContent
|
||||
dataList={messageList}
|
||||
loading={loading}
|
||||
onPlay={handleOnPlay}
|
||||
onPause={handleOnPause}
|
||||
playerRef={playerRef}
|
||||
isPlaying={playingStream}
|
||||
isStream={parameters.stream}
|
||||
analyserData={streamTTS.audioChunks}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-column font-size-14 flex-center gap-20">
|
||||
<span>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
function writeString(view: DataView, offset: number, string: string) {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PCM audio data to WAV format
|
||||
* @param pcmData - Raw PCM audio data as ArrayBuffer
|
||||
* @param sampleRate - Sample rate (default: 24000 Hz, common for TTS)
|
||||
* @param numChannels - Number of channels (default: 1 for mono)
|
||||
* @param bitsPerSample - Bits per sample (default: 16)
|
||||
* @returns WAV format audio as Blob
|
||||
*/
|
||||
export function pcmToWav(
|
||||
pcmData: ArrayBuffer,
|
||||
sampleRate: number = 24000,
|
||||
numChannels: number = 1,
|
||||
bitsPerSample: number = 16
|
||||
): Blob {
|
||||
const pcmBytes = new Uint8Array(pcmData);
|
||||
const dataLength = pcmBytes.length;
|
||||
|
||||
// WAV file header size
|
||||
const headerSize = 44;
|
||||
const wavBuffer = new ArrayBuffer(headerSize + dataLength);
|
||||
const view = new DataView(wavBuffer);
|
||||
|
||||
// Write WAV header
|
||||
// "RIFF" chunk descriptor
|
||||
writeString(view, 0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataLength, true); // File size - 8
|
||||
writeString(view, 8, 'WAVE');
|
||||
|
||||
// "fmt " sub-chunk
|
||||
writeString(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true); // Subchunk1Size (16 for PCM)
|
||||
view.setUint16(20, 1, true); // AudioFormat (1 for PCM)
|
||||
view.setUint16(22, numChannels, true); // NumChannels
|
||||
view.setUint32(24, sampleRate, true); // SampleRate
|
||||
view.setUint32(28, sampleRate * numChannels * (bitsPerSample / 8), true); // ByteRate
|
||||
view.setUint16(32, numChannels * (bitsPerSample / 8), true); // BlockAlign
|
||||
view.setUint16(34, bitsPerSample, true); // BitsPerSample
|
||||
|
||||
// "data" sub-chunk
|
||||
writeString(view, 36, 'data');
|
||||
view.setUint32(40, dataLength, true); // Subchunk2Size
|
||||
|
||||
// Write PCM data
|
||||
const wavBytes = new Uint8Array(wavBuffer);
|
||||
wavBytes.set(pcmBytes, headerSize);
|
||||
|
||||
return new Blob([wavBuffer], { type: 'audio/wav' });
|
||||
}
|
||||
Reference in New Issue
Block a user