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(() => {
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;
+22 -4
View File
@@ -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;
+173 -110
View File
@@ -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;
+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 { 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,
+1 -9
View File
@@ -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>
</>
);
};
+25 -3
View File
@@ -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
+47 -8
View File
@@ -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>
+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' });
}