fix: use html audio to load stream

This commit is contained in:
jialin
2026-03-17 16:35:55 +08:00
committed by jialin
parent c29ad1270b
commit 9aae2c7f50
17 changed files with 1000 additions and 385 deletions
+1 -1
View File
@@ -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(() => { const initAudioContext = useCallback(() => {
audioContext.current = new (window.AudioContext || audioContext.current = new (
window.webkitAudioContext)(); window.AudioContext || (window as any).webkitAudioContext
)();
analyser.current = audioContext.current.createAnalyser(); analyser.current = audioContext.current.createAnalyser();
analyser.current.fftSize = 512; analyser.current.fftSize = 512;
@@ -52,7 +53,9 @@ const RawAudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
}); });
audioRef.current.addEventListener('timeupdate', () => { audioRef.current.addEventListener('timeupdate', () => {
const current = audioRef.current?.currentTime || 0;
props.onTimeUpdate?.(); props.onTimeUpdate?.();
props.onAudioProcess?.(current);
}); });
audioRef.current.addEventListener('ended', () => { audioRef.current.addEventListener('ended', () => {
@@ -85,11 +88,6 @@ const RawAudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
props.onVolumeChange?.(); props.onVolumeChange?.();
}); });
audioRef.current.addEventListener('audioprocess', () => {
const current = audioRef.current?.currentTime || 0;
props.onAudioProcess?.(current);
});
audioRef.current.addEventListener('playing', () => { audioRef.current.addEventListener('playing', () => {
props.onPlaying?.(); props.onPlaying?.();
}); });
@@ -111,16 +109,44 @@ const RawAudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
play: () => { 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: () => { pause: () => {
audioRef.current?.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(() => { useEffect(() => {
if (audioRef.current) { if (audioRef.current) {
console.log('audioRef.current', audioRef.current, props.url);
initEnvents(); initEnvents();
} }
return () => { return () => {
@@ -138,7 +164,6 @@ const RawAudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
audioRef.current?.removeEventListener('seeked', () => {}); audioRef.current?.removeEventListener('seeked', () => {});
audioRef.current?.removeEventListener('seeking', () => {}); audioRef.current?.removeEventListener('seeking', () => {});
audioRef.current?.removeEventListener('volumechange', () => {}); audioRef.current?.removeEventListener('volumechange', () => {});
audioRef.current?.removeEventListener('audioprocess', () => {});
audioRef.current?.removeEventListener('playing', () => {}); audioRef.current?.removeEventListener('playing', () => {});
audioRef.current?.removeEventListener('loadedmetadata', () => {}); audioRef.current?.removeEventListener('loadedmetadata', () => {});
audioRef.current?.removeEventListener('ended', () => {}); audioRef.current?.removeEventListener('ended', () => {});
@@ -146,6 +171,13 @@ const RawAudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
}; };
}, [audioRef.current]); }, [audioRef.current]);
// Reload audio when URL changes
useEffect(() => {
if (audioRef.current && props.url) {
audioRef.current.load();
}
}, [props.url]);
return ( return (
<audio <audio
controls controls
@@ -36,8 +36,9 @@ const AudioPlayer: React.FC<
const mediaElement = useRef<any>(null); const mediaElement = useRef<any>(null);
const initAudioContext = useCallback(() => { const initAudioContext = useCallback(() => {
audioContext.current = new (window.AudioContext || audioContext.current = new (
window.webkitAudioContext)(); window.AudioContext || window.webkitAudioContext
)();
analyser.current = audioContext.current.createAnalyser(); analyser.current = audioContext.current.createAnalyser();
analyser.current.fftSize = 512; analyser.current.fftSize = 512;
@@ -218,4 +219,4 @@ const AudioPlayer: React.FC<
); );
}); });
export default React.memo(AudioPlayer); export default AudioPlayer;
+22 -4
View File
@@ -4,16 +4,34 @@ import SpeechItem from './speech-item';
interface SpeechContentProps { interface SpeechContentProps {
dataList: any[]; dataList: any[];
loading?: boolean; 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) => { const SpeechContent: React.FC<SpeechContentProps> = (props) => {
return ( return (
<> <div>
{props.dataList.map((item) => ( {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;
+173 -110
View File
@@ -8,8 +8,14 @@ import { useIntl } from '@umijs/max';
import { Button, Slider, Tooltip } from 'antd'; import { Button, Slider, Tooltip } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import _, { throttle } from 'lodash'; import _, { throttle } from 'lodash';
import React, { useCallback, useRef, useState } from 'react'; import React, {
import AudioPlayer from './audio-player'; useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import RawAudioPlayer from '../audio-player/raw-audio-player';
import './styles/index.less'; import './styles/index.less';
import './styles/slider-progress.less'; import './styles/slider-progress.less';
@@ -34,10 +40,25 @@ interface SpeechContentProps {
format: string; format: string;
speed: number; speed: number;
audioUrl: string; 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 SpeechItem: React.FC<SpeechContentProps> = (props) => {
const { isStream } = props;
console.log(
'Rendering SpeechItem with props:',
props.isStream,
props.analyserData
);
const intl = useIntl(); const intl = useIntl();
const [isPlay, setIsPlay] = useState(props.autoplay); const [isPlay, setIsPlay] = useState(props.autoplay || props.isPlaying);
const [duration, setDuration] = useState<number>(0); const [duration, setDuration] = useState<number>(0);
const [animationSize, setAnimationSize] = useState({ width: 900, height: 0 }); const [animationSize, setAnimationSize] = useState({ width: 900, height: 0 });
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
@@ -48,23 +69,47 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
const wrapper = useRef<any>(null); const wrapper = useRef<any>(null);
const ref = 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 { try {
if (ref.current?.wavesurfer.current?.isPlaying()) { if (ref.current?.wavesurfer.current?.isPlaying()) {
ref.current?.pause(); onPause();
setIsPlay(false); setIsPlay(false);
return; return;
} else { } else {
await ref.current?.wavesurfer.current?.play(); await onPlay();
setIsPlay(true); setIsPlay(true);
} }
} catch (error) { } catch (error) {
console.log('error:', error); console.log('error:', error);
} }
}, [ref.current]); };
const handleOnAnalyse = useCallback((data: any, analyser: any) => { const handleOnAnalyse = useCallback((data: any, analyser: any) => {
console.log('data+++++++++++++++++=:', data);
setAudioChunks((pre: any) => { setAudioChunks((pre: any) => {
return { return {
data: data, data: data,
@@ -76,9 +121,11 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
const handleOnFinish = useCallback(() => { const handleOnFinish = useCallback(() => {
setIsPlay(false); setIsPlay(false);
}, []); }, []);
const handleOnPlay = useCallback(() => { const handleOnPlay = useCallback(() => {
setIsPlay(true); setIsPlay(true);
}, []); }, []);
const handleOnPause = useCallback(() => { const handleOnPause = useCallback(() => {
setIsPlay(false); setIsPlay(false);
}, []); }, []);
@@ -87,22 +134,9 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
setCurrentTime(current); setCurrentTime(current);
}, 100); }, 100);
const handleOnAudioprocess = useCallback( const handleOnAudioprocess = (current: number) => {
(current: number) => { throttleUpdateCurrentTime(current);
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 handleAnimationResize = useCallback((size: any) => { const handleAnimationResize = useCallback((size: any) => {
setAnimationSize({ setAnimationSize({
@@ -121,7 +155,6 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
}; };
const handleReady = useCallback((duration: number) => { const handleReady = useCallback((duration: number) => {
console.log('ready+++++++++++++++', duration);
setDuration(duration); setDuration(duration);
}, []); }, []);
@@ -130,9 +163,16 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
setCurrentTime(value); setCurrentTime(value);
}, []); }, []);
const convertFormat = () => {
if (props.format === 'pcm') {
return 'wav';
}
return props.format;
};
const onDownload = useCallback(() => { const onDownload = useCallback(() => {
const url = props.audioUrl || ''; const url = props.audioUrl || '';
const filename = `audio-${dayjs().format('YYYYMMDDHHmmss')}.${props.format}`; const filename = `audio-${dayjs().format('YYYYMMDDHHmmss')}.${convertFormat()}`;
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
@@ -142,6 +182,71 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
link.remove(); link.remove();
}, [props.audioUrl, props.format]); }, [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 ( return (
<div> <div>
<div className="speech-item"> <div className="speech-item">
@@ -150,93 +255,51 @@ const SpeechItem: React.FC<SpeechContentProps> = (props) => {
style={{ height: 120, width: '100%' }} style={{ height: 120, width: '100%' }}
ref={wrapper} ref={wrapper}
> >
<AudioPlayer <>
{...props} {/* {!isPCMStream && (
audioUrl={props.audioUrl} <AudioPlayer
onReady={handleReady} {...props}
onFinish={handleOnFinish} audioUrl={props.audioUrl}
onPlay={handleOnPlay} onReady={handleReady}
onPause={handleOnPause} onFinish={handleOnFinish}
onAnalyse={handleOnAnalyse} onPlay={handleOnPlay}
onAudioprocess={handleOnAudioprocess} onPause={handleOnPause}
ref={ref} onAnalyse={handleOnAnalyse}
></AudioPlayer> onAudioprocess={handleOnAudioprocess}
{/* <RawAudioPlayer ref={ref}
{...props} ></AudioPlayer>
url={props.audioUrl} )} */}
onReady={handleReady} {!isPCMStream && (
onEnded={handleOnFinish} <RawAudioPlayer
onPlay={handleOnPlay} {...props}
onPause={handleOnPause} url={props.audioUrl}
onAnalyse={handleOnAnalyse} onReady={handleReady}
onAudioProcess={handleOnAudioprocess} onEnded={handleOnFinish}
ref={ref} onPlay={handleOnPlay}
></RawAudioPlayer> */} onPause={handleOnPause}
{isPlay && ( onAnalyse={handleOnAnalyse}
<AudioAnimation onAudioProcess={handleOnAudioprocess}
maxBarCount={100} ref={ref}
amplitude={60} ></RawAudioPlayer>
fixedHeight={true} )}
height={120} {isPlay &&
width={800} (props.analyserData?.analyser?.current ||
analyserData={audioChunks} audioChunks.analyser?.current) && (
></AudioAnimation> <AudioAnimation
)} maxBarCount={100}
</div> amplitude={60}
</div> fixedHeight={true}
<Slider height={120}
className="slider-progress" width={800}
value={currentTime} analyserData={props.analyserData || audioChunks}
max={duration} ></AudioAnimation>
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> </div>
{renderPlayerActions()}
</div> </div>
); );
}; };
export default React.memo(SpeechItem); export default SpeechItem;
+21 -1
View File
@@ -9,6 +9,7 @@ import { useQueryModelList } from '@/pages/llmodels/services/use-query-model-lis
import { useIntl, useNavigate } from '@umijs/max'; import { useIntl, useNavigate } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, Table, message } from 'antd'; import { ConfigProvider, Table, message } from 'antd';
import { createStyles } from 'antd-style';
import _ from 'lodash'; import _ from 'lodash';
import { useEffect } from 'react'; import { useEffect } from 'react';
import NoResult from '../_components/no-result'; 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 useQueryProfiles from './services/use-query-profiles';
import useStopBenchmark from './services/use-stop-benchmark'; 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 Benchmark: React.FC = () => {
const { const {
dataSource, dataSource,
@@ -58,6 +76,7 @@ const Benchmark: React.FC = () => {
watch: true, watch: true,
contentForDelete: 'menu.models.benchmark' contentForDelete: 'menu.models.benchmark'
}); });
const { styles } = useStyle();
const intl = useIntl(); const intl = useIntl();
const navigate = useNavigate(); const navigate = useNavigate();
const { openBenchmarkModal, closeBenchmarkModal, openBenchmarkModalStatus } = const { openBenchmarkModal, closeBenchmarkModal, openBenchmarkModalStatus } =
@@ -242,13 +261,14 @@ const Benchmark: React.FC = () => {
<Table <Table
tableLayout="fixed" tableLayout="fixed"
columns={columns} columns={columns}
className={styles.customTable}
dataSource={dataSource.dataList} dataSource={dataSource.dataList}
rowSelection={rowSelection} rowSelection={rowSelection}
loading={dataSource.loading} loading={dataSource.loading}
sortDirections={TABLE_SORT_DIRECTIONS} sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false} showSorterTooltip={false}
rowKey="id" rowKey="id"
scroll={{ x: 1260 }} scroll={{ x: 1200 }}
onChange={handleTableChange} onChange={handleTableChange}
pagination={{ pagination={{
showSizeChanger: true, showSizeChanger: true,
+1 -9
View File
@@ -155,16 +155,8 @@ export const textToSpeech = async (params: any, options?: any) => {
} }
const audioBlob = await res.blob(); const audioBlob = await res.blob();
if (audioBlob?.type?.indexOf('audio') === -1) {
return {
url: '',
type: ''
};
}
const audioUrl = audioBlob.size > 0 ? URL.createObjectURL(audioBlob) : '';
return { return {
url: audioUrl, audioBlob
type: audioBlob.type
}; };
}; };
@@ -55,8 +55,10 @@ const ParamsFields: React.FC<ParamsFieldsProps> = ({ paramsConfig = [] }) => {
'formItemAttrs', 'formItemAttrs',
'dependencies', 'dependencies',
'disabledConfig', 'disabledConfig',
'description' 'description',
'initAttrs'
])} ])}
{...item.initAttrs?.(meta)}
/> />
</Form.Item> </Form.Item>
); );
@@ -93,7 +95,8 @@ const ParamsFields: React.FC<ParamsFieldsProps> = ({ paramsConfig = [] }) => {
'formItemAttrs', 'formItemAttrs',
'dependencies', 'dependencies',
'disabledConfig', 'disabledConfig',
'description' 'description',
'initAttrs'
])} ])}
{...item.initAttrs?.(meta)} {...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> ></CheckboxField>
</Form.Item> </Form.Item>
<Form.Item
name="stream"
valuePropName="checked"
style={{ marginBottom: 8 }}
>
<CheckboxField
label={intl.formatMessage({
id: 'playground.params.streamMode'
})}
></CheckboxField>
</Form.Item>
</> </>
); );
}; };
+25 -3
View File
@@ -1,4 +1,5 @@
import AutoComplete from '@/components/seal-form/auto-complete'; 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 SealSelect from '@/components/seal-form/seal-select';
import CollapsePanel from '@/pages/_components/collapse-panel'; import CollapsePanel from '@/pages/_components/collapse-panel';
import { getLocale, useIntl, useSearchParams } from '@umijs/max'; import { getLocale, useIntl, useSearchParams } from '@umijs/max';
@@ -13,7 +14,7 @@ import React, {
} from 'react'; } from 'react';
import ModelSelect from '../../components/model-select'; import ModelSelect from '../../components/model-select';
import ParamsFields from '../../components/params-fields'; 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 { TTSAdvancedParamsConfig } from '../params-config';
import AdvanceConfig from './tts-advance'; import AdvanceConfig from './tts-advance';
@@ -35,6 +36,7 @@ type ParamsSettingsProps = {
const ParamsSettings: React.FC<ParamsSettingsProps> = forwardRef( const ParamsSettings: React.FC<ParamsSettingsProps> = forwardRef(
({ onFinish, onFinishFailed, updatateParams, modelList = [] }, ref) => { ({ onFinish, onFinishFailed, updatateParams, modelList = [] }, ref) => {
const { onValuesChange } = useFormContext();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const modelType = searchParams.get('type') || ''; const modelType = searchParams.get('type') || '';
const selectModel = searchParams.get('model') const selectModel = searchParams.get('model')
@@ -124,6 +126,14 @@ const ParamsSettings: React.FC<ParamsSettingsProps> = forwardRef(
if (changeValues.model) { if (changeValues.model) {
return; return;
} }
if ('stream' in changeValues && changeValues.stream) {
allValues.response_format = 'pcm';
form.setFieldsValue({
response_format: 'pcm'
});
}
updatateParams(allValues); updatateParams(allValues);
}; };
@@ -164,15 +174,27 @@ const ParamsSettings: React.FC<ParamsSettingsProps> = forwardRef(
options={vociceOptions} options={vociceOptions}
></AutoComplete> ></AutoComplete>
</Form.Item> </Form.Item>
<Form.Item name="response_format"> <Form.Item name="response_format" style={{ marginBottom: 8 }}>
<SealSelect <SealSelect
label={intl.formatMessage({ id: 'playground.params.format' })} label={intl.formatMessage({ id: 'playground.params.format' })}
options={[ options={[
{ label: 'mp3', value: 'mp3' }, { label: 'mp3', value: 'mp3' },
{ label: 'wav', value: 'wav' } { label: 'wav', value: 'wav' },
{ label: 'pcm', value: 'pcm' }
]} ]}
></SealSelect> ></SealSelect>
</Form.Item> </Form.Item>
<Form.Item
name="stream"
valuePropName="checked"
style={{ marginBottom: 8 }}
>
<CheckboxField
label={intl.formatMessage({
id: 'playground.params.streamMode'
})}
></CheckboxField>
</Form.Item>
<CollapsePanel <CollapsePanel
activeKey={activeKey} activeKey={activeKey}
onChange={handleOnCollapse} onChange={handleOnCollapse}
@@ -1,3 +1,4 @@
import { pcmToWav } from '@/utils/pcm-to-wav';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import { textToSpeech } from '../../apis'; import { textToSpeech } from '../../apis';
import { extractErrorMessage } from '../../config'; import { extractErrorMessage } from '../../config';
@@ -21,6 +22,35 @@ export const useNonStreamTTS = (params?: UseNonStreamTTSParams) => {
const [error, setError] = useState<any>(null); const [error, setError] = useState<any>(null);
const controllerRef = useRef<AbortController | null>(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( const generate = useCallback(
async (ttsParams: TTSParams) => { async (ttsParams: TTSParams) => {
try { try {
@@ -47,7 +77,26 @@ export const useNonStreamTTS = (params?: UseNonStreamTTSParams) => {
return null; 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; return res;
} catch (err: any) { } catch (err: any) {
const res = err?.response?.data; 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 { fetchChunkedData } from '@/utils/fetch-chunk-data';
import { pcmToWav } from '@/utils/pcm-to-wav';
import { useMemoizedFn } from 'ahooks';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import { AUDIO_TEXT_TO_SPEECH_API } from '../../apis'; import { AUDIO_TEXT_TO_SPEECH_API } from '../../apis';
import { extractErrorMessage } from '../../config'; import { extractErrorMessage } from '../../config';
import { usePCMStreamPlayer } from './use-pcm-stream-player';
interface UseStreamTTSParams { interface UseStreamTTSParams {
onChunk?: (chunk: ArrayBuffer) => void; onChunk?: (chunk: ArrayBuffer) => void;
onComplete?: (audioUrl: string) => void; // Return complete audio URL when done onComplete?: (audioUrl: string) => void; // Return complete audio URL when done
onError?: (error: any) => void; onError?: (error: any) => void;
onUrlReady?: (url: string) => void; // Called when the stream URL is ready
autoPlay?: boolean; autoPlay?: boolean;
playerRef?: React.RefObject<any>; // Reference to the player for controlling playback
} }
interface TTSParams { interface TTSParams {
@@ -20,262 +25,325 @@ interface TTSParams {
[key: string]: any; [key: string]: any;
} }
/** // MediaSource codec mapping for different formats
* Audio chunk queue manager for smooth playback const MEDIA_SOURCE_CODECS: Record<string, string> = {
* Uses a single Audio element for better performance and seamless playback mp4: 'audio/mp4; codecs="mp4a.40.2"',
*/ webm: 'audio/webm; codecs="opus"',
class AudioQueue { ogg: 'audio/ogg; codecs="opus"',
private queue: Blob[] = []; opus: 'audio/webm; codecs="opus"',
private audioElement: HTMLAudioElement; pcm: 'audio/pcm; codecs=pcm'
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;
constructor(onComplete?: () => void) { // Check if format supports MediaSource API
this.onComplete = onComplete; const supportsMediaSource = (format: string): boolean => {
// Create a single Audio element for the entire playback session if (!window.MediaSource) return false;
this.audioElement = new Audio(); const codec = MEDIA_SOURCE_CODECS[format];
this.setupAudioListeners(); return codec ? MediaSource.isTypeSupported(codec) : false;
} };
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;
}
}
export const useStreamTTS = (params?: UseStreamTTSParams) => { export const useStreamTTS = (params?: UseStreamTTSParams) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<any>(null); const [error, setError] = useState<any>(null);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [streamUrl, setStreamUrl] = useState<string>('');
const controllerRef = useRef<AbortController | null>(null); 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( // PCM stream player instance
async (ttsParams: TTSParams) => { const pcmPlayer = usePCMStreamPlayer({
try { onError: (error) => {
setLoading(true); console.error('PCM player error:', error);
setError(null); params?.onError?.(error);
setProgress(0); },
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 // Convert combined PCM data to WAV and create URL
controllerRef.current?.abort(); const wavBlob = pcmToWav(pcmData.buffer);
audioQueueRef.current?.clear(); 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 processQueue = useCallback(() => {
const signal = controllerRef.current.signal; if (
isAppendingRef.current ||
queueRef.current.length === 0 ||
!sourceBufferRef.current ||
sourceBufferRef.current.updating
) {
return;
}
// Create audio queue for smooth playback isAppendingRef.current = true;
audioQueueRef.current = new AudioQueue(); 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 generate = useMemoizedFn(async (ttsParams: TTSParams) => {
const streamParams = { try {
...ttsParams, setLoading(true);
stream: true setError(null);
}; setProgress(0);
setIsPCM(ttsParams.response_format === 'pcm');
allChunksRef.current = [];
const result = await fetchChunkedData({ // Abort previous request if exists
url: AUDIO_TEXT_TO_SPEECH_API, controllerRef.current?.abort();
data: streamParams,
signal
});
if ('error' in result) { // Clean up previous PCM player
const errorMessage = extractErrorMessage(result.data); pcmPlayer.stop();
setError({
error: true, // Clean up previous MediaSource/URL
errorMessage 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; // Timeout fallback
setTimeout(() => {
if (!reader) { if (mediaSource.readyState !== 'open') {
throw new Error('Failed to get reader from response'); reject(new Error('MediaSource failed to open'));
}
// 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);
} }
audioQueueRef.current?.markStreamEnded(); }, 5000);
});
}
// Create complete audio URL from all chunks // Add stream parameter
if (allAudioChunks.length > 0) { const streamParams = {
const completeBlob = new Blob(allAudioChunks as any, { ...ttsParams,
type: `audio/${ttsParams.response_format || 'mp3'}` stream: true
}); };
const completeUrl = URL.createObjectURL(completeBlob);
params?.onComplete?.(completeUrl);
}
break;
}
if (value) { const result = await fetchChunkedData({
audioChunks.push(value); url: AUDIO_TEXT_TO_SPEECH_API,
allAudioChunks.push(value); // Keep all chunks for final URL data: streamParams,
chunkCount++; signal
});
// For fast API responses, batch chunks to avoid too many small audio elements if ('error' in result) {
// Create a blob every N chunks or when chunk size exceeds threshold const errorMessage = extractErrorMessage(result.data);
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';
setError({ setError({
error: true, error: true,
errorMessage errorMessage
}); });
params?.onError?.(errorMessage); params?.onError?.(errorMessage);
} finally { return;
setLoading(false);
} }
},
[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(() => { const abort = useCallback(() => {
controllerRef.current?.abort(); 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); setLoading(false);
}, []); }, [streamUrl, pcmPlayer]);
const play = useCallback(() => {
params?.playerRef?.current?.play();
}, [params?.playerRef]);
const pause = useCallback(() => {
params?.playerRef?.current?.pause();
}, [params?.playerRef]);
return { return {
generate, generate,
abort, abort,
loading, loading,
error, 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 isLocalized: true
}, },
attrs: { attrs: {
allowClear: true,
step: 1, step: 1,
min: 0, min: 0,
max: 4096 max: 4096
+47 -8
View File
@@ -58,8 +58,18 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
const checkvalueRef = useRef<any>(true); const checkvalueRef = useRef<any>(true);
const [currentPrompt, setCurrentPrompt] = useState<string>(''); const [currentPrompt, setCurrentPrompt] = useState<string>('');
const formRef = useRef<any>(null); 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({ const nonStreamTTS = useNonStreamTTS({
onSuccess: (result) => { onSuccess: (result) => {
setMessageList([ 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({ const streamTTS = useStreamTTS({
autoPlay: checkvalueRef.current, 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) => { onComplete: (audioUrl) => {
// Update messageList with complete audio URL when stream finishes console.log('onComplete called with audioUrl:', audioUrl);
setMessageList((prev) => { setMessageList((prev) => {
if (prev.length > 0) { if (prev.length > 0) {
const updated = [...prev]; const updated = [...prev];
@@ -96,7 +120,7 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
} }
return prev; return prev;
}); });
console.log('Stream playback completed'); setPlayingStream(false);
}, },
onError: (error) => { onError: (error) => {
setTokenResult({ setTokenResult({
@@ -104,9 +128,12 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
errorMessage: error errorMessage: error
}); });
setMessageList([]); setMessageList([]);
setPlayingStream(false);
} }
}); });
console.log('isPlaying:', streamTTS.isPlaying);
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
return { return {
viewCode() { viewCode() {
@@ -180,10 +207,12 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
}; };
setParams(params); setParams(params);
console.log('submitMessage params:', streamTTS.isPlaying);
// Choose stream or non-stream based on parameters // Choose stream or non-stream based on parameters
if (parameters.stream) { 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([ setMessageList([
{ {
input: inputText, input: inputText,
@@ -191,8 +220,8 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
format: parameters.response_format, format: parameters.response_format,
speed: parameters.speed, speed: parameters.speed,
uid: messageId.current, uid: messageId.current,
autoplay: checkvalueRef.current, autoplay: false,
audioUrl: '' // No URL for stream mode, audio plays in real-time audioUrl: '' // Will be updated via onUrlReady callback
} }
]); ]);
await streamTTS.generate(params); await streamTTS.generate(params);
@@ -202,6 +231,7 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
} }
} catch (error: any) { } catch (error: any) {
console.log('error:', error); console.log('error:', error);
setPlayingStream(false);
setTokenResult({ setTokenResult({
error: true, error: true,
errorMessage: error?.message || 'Unknown error' errorMessage: error?.message || 'Unknown error'
@@ -252,7 +282,16 @@ const GroundTTS: React.FC<MessageProps> = forwardRef((props, ref) => {
> >
<div className="content" style={{ maxWidth: 1000 }}> <div className="content" style={{ maxWidth: 1000 }}>
{messageList.length ? ( {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"> <div className="flex-column font-size-14 flex-center gap-20">
<span> <span>
+54
View File
@@ -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' });
}