mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
fix(web): support TTS playback in Safari and Firefox (#39444)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
a3c18c561e
commit
00fe96325d
@ -835,14 +835,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/base/audio-btn/audio.ts": {
|
|
||||||
"node-js/prefer-global/buffer": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"typescript/no-explicit-any": {
|
|
||||||
"count": 3
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/base/audio-gallery/AudioPlayer.tsx": {
|
"web/app/components/base/audio-gallery/AudioPlayer.tsx": {
|
||||||
"jsx_a11y/media-has-caption": {
|
"jsx_a11y/media-has-caption": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
@ -12,14 +12,8 @@ type AudioPlayerCtorArgs = [
|
|||||||
|
|
||||||
type MockAudioPlayerInstance = {
|
type MockAudioPlayerInstance = {
|
||||||
setCallback: ReturnType<typeof vi.fn>
|
setCallback: ReturnType<typeof vi.fn>
|
||||||
pauseAudio: ReturnType<typeof vi.fn>
|
destroy: ReturnType<typeof vi.fn>
|
||||||
resetMsgId: ReturnType<typeof vi.fn>
|
resetMsgId: ReturnType<typeof vi.fn>
|
||||||
cacheBuffers: Array<ArrayBuffer>
|
|
||||||
sourceBuffer:
|
|
||||||
| {
|
|
||||||
abort: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockState = vi.hoisted(() => ({
|
const mockState = vi.hoisted(() => ({
|
||||||
@ -31,10 +25,8 @@ const mockAudioPlayerConstructor = vi.hoisted(() => vi.fn())
|
|||||||
const MockAudioPlayer = vi.hoisted(() => {
|
const MockAudioPlayer = vi.hoisted(() => {
|
||||||
return class MockAudioPlayerClass {
|
return class MockAudioPlayerClass {
|
||||||
setCallback = vi.fn()
|
setCallback = vi.fn()
|
||||||
pauseAudio = vi.fn()
|
destroy = vi.fn()
|
||||||
resetMsgId = vi.fn()
|
resetMsgId = vi.fn()
|
||||||
cacheBuffers = [new ArrayBuffer(1)]
|
|
||||||
sourceBuffer = { abort: vi.fn() }
|
|
||||||
|
|
||||||
constructor(...args: AudioPlayerCtorArgs) {
|
constructor(...args: AudioPlayerCtorArgs) {
|
||||||
mockAudioPlayerConstructor(...args)
|
mockAudioPlayerConstructor(...args)
|
||||||
@ -132,9 +124,7 @@ describe('AudioPlayerManager', () => {
|
|||||||
callback,
|
callback,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(previous!.pauseAudio).toHaveBeenCalledTimes(1)
|
expect(previous!.destroy).toHaveBeenCalledTimes(1)
|
||||||
expect(previous!.cacheBuffers).toEqual([])
|
|
||||||
expect(previous!.sourceBuffer?.abort).toHaveBeenCalledTimes(1)
|
|
||||||
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
|
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
|
||||||
expect(next).toBe(mockState.instances[1])
|
expect(next).toBe(mockState.instances[1])
|
||||||
})
|
})
|
||||||
@ -144,7 +134,7 @@ describe('AudioPlayerManager', () => {
|
|||||||
const callback = vi.fn()
|
const callback = vi.fn()
|
||||||
manager.getAudioPlayer('/text-to-audio', false, 'msg-1', 'hello', 'en-US', callback)
|
manager.getAudioPlayer('/text-to-audio', false, 'msg-1', 'hello', 'en-US', callback)
|
||||||
const previous = mockState.instances[0]
|
const previous = mockState.instances[0]
|
||||||
previous!.pauseAudio.mockImplementation(() => {
|
previous!.destroy.mockImplementation(() => {
|
||||||
throw new Error('cleanup failure')
|
throw new Error('cleanup failure')
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -152,7 +142,7 @@ describe('AudioPlayerManager', () => {
|
|||||||
manager.getAudioPlayer('/apps/1/text-to-audio', false, 'msg-2', 'world', 'en-US', callback)
|
manager.getAudioPlayer('/apps/1/text-to-audio', false, 'msg-2', 'world', 'en-US', callback)
|
||||||
}).not.toThrow()
|
}).not.toThrow()
|
||||||
|
|
||||||
expect(previous!.pauseAudio).toHaveBeenCalledTimes(1)
|
expect(previous!.destroy).toHaveBeenCalledTimes(1)
|
||||||
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
|
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -3,15 +3,8 @@ import { waitFor } from '@testing-library/react'
|
|||||||
import { AppSourceType } from '@/service/share'
|
import { AppSourceType } from '@/service/share'
|
||||||
import AudioPlayer from '../audio'
|
import AudioPlayer from '../audio'
|
||||||
|
|
||||||
const mockToastNotify = vi.hoisted(() => vi.fn())
|
|
||||||
const mockTextToAudioStream = vi.hoisted(() => vi.fn())
|
const mockTextToAudioStream = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
|
||||||
toast: {
|
|
||||||
error: (message: string) => mockToastNotify({ type: 'error', message }),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/service/share', () => ({
|
vi.mock('@/service/share', () => ({
|
||||||
AppSourceType: {
|
AppSourceType: {
|
||||||
webApp: 'webApp',
|
webApp: 'webApp',
|
||||||
@ -22,7 +15,7 @@ vi.mock('@/service/share', () => ({
|
|||||||
|
|
||||||
type AudioEventName =
|
type AudioEventName =
|
||||||
| 'ended'
|
| 'ended'
|
||||||
| 'paused'
|
| 'pause'
|
||||||
| 'loaded'
|
| 'loaded'
|
||||||
| 'play'
|
| 'play'
|
||||||
| 'timeupdate'
|
| 'timeupdate'
|
||||||
@ -30,6 +23,7 @@ type AudioEventName =
|
|||||||
| 'canplay'
|
| 'canplay'
|
||||||
| 'error'
|
| 'error'
|
||||||
| 'sourceopen'
|
| 'sourceopen'
|
||||||
|
| 'updateend'
|
||||||
|
|
||||||
type AudioEventListener = () => void
|
type AudioEventListener = () => void
|
||||||
|
|
||||||
@ -51,12 +45,31 @@ type AudioResponse = {
|
|||||||
|
|
||||||
class MockSourceBuffer {
|
class MockSourceBuffer {
|
||||||
updating = false
|
updating = false
|
||||||
|
private listeners: Partial<Record<AudioEventName, AudioEventListener[]>> = {}
|
||||||
|
|
||||||
|
addEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
|
||||||
|
const listeners = this.listeners[event] || []
|
||||||
|
listeners.push(listener)
|
||||||
|
this.listeners[event] = listeners
|
||||||
|
})
|
||||||
|
|
||||||
|
removeEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
|
||||||
|
this.listeners[event] = (this.listeners[event] || []).filter((item) => item !== listener)
|
||||||
|
})
|
||||||
|
|
||||||
appendBuffer = vi.fn((_buffer: ArrayBuffer) => undefined)
|
appendBuffer = vi.fn((_buffer: ArrayBuffer) => undefined)
|
||||||
abort = vi.fn(() => undefined)
|
abort = vi.fn(() => undefined)
|
||||||
|
|
||||||
|
emit(event: AudioEventName) {
|
||||||
|
const listeners = this.listeners[event] || []
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockMediaSource {
|
class MockMediaSource {
|
||||||
readyState: 'open' | 'closed' = 'open'
|
readyState: 'open' | 'closed' | 'ended' = 'closed'
|
||||||
sourceBuffer = new MockSourceBuffer()
|
sourceBuffer = new MockSourceBuffer()
|
||||||
private listeners: Partial<Record<AudioEventName, AudioEventListener[]>> = {}
|
private listeners: Partial<Record<AudioEventName, AudioEventListener[]>> = {}
|
||||||
|
|
||||||
@ -66,10 +79,15 @@ class MockMediaSource {
|
|||||||
this.listeners[event] = listeners
|
this.listeners[event] = listeners
|
||||||
})
|
})
|
||||||
|
|
||||||
|
removeEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
|
||||||
|
this.listeners[event] = (this.listeners[event] || []).filter((item) => item !== listener)
|
||||||
|
})
|
||||||
|
|
||||||
addSourceBuffer = vi.fn((_contentType: string) => this.sourceBuffer)
|
addSourceBuffer = vi.fn((_contentType: string) => this.sourceBuffer)
|
||||||
endOfStream = vi.fn(() => undefined)
|
endOfStream = vi.fn(() => undefined)
|
||||||
|
|
||||||
emit(event: AudioEventName) {
|
emit(event: AudioEventName) {
|
||||||
|
if (event === 'sourceopen') this.readyState = 'open'
|
||||||
const listeners = this.listeners[event] || []
|
const listeners = this.listeners[event] || []
|
||||||
listeners.forEach((listener) => {
|
listeners.forEach((listener) => {
|
||||||
listener()
|
listener()
|
||||||
@ -110,7 +128,7 @@ class MockAudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MockAudioContext {
|
class MockAudioContext {
|
||||||
state: 'running' | 'suspended' = 'running'
|
state: 'interrupted' | 'running' | 'suspended' = 'running'
|
||||||
destination = {}
|
destination = {}
|
||||||
connect = vi.fn(() => undefined)
|
connect = vi.fn(() => undefined)
|
||||||
createMediaElementSource = vi.fn((_audio: MockAudio) => ({
|
createMediaElementSource = vi.fn((_audio: MockAudio) => ({
|
||||||
@ -121,9 +139,11 @@ class MockAudioContext {
|
|||||||
this.state = 'running'
|
this.state = 'running'
|
||||||
})
|
})
|
||||||
|
|
||||||
suspend = vi.fn(() => {
|
suspend = vi.fn(async () => {
|
||||||
this.state = 'suspended'
|
this.state = 'suspended'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
close = vi.fn(async () => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const testState = {
|
const testState = {
|
||||||
@ -133,6 +153,8 @@ const testState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MockMediaSourceCtor extends MockMediaSource {
|
class MockMediaSourceCtor extends MockMediaSource {
|
||||||
|
static isTypeSupported = vi.fn(() => true)
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
testState.mediaSources.push(this)
|
testState.mediaSources.push(this)
|
||||||
@ -156,6 +178,7 @@ class MockAudioContextCtor extends MockAudioContext {
|
|||||||
const originalAudio = globalThis.Audio
|
const originalAudio = globalThis.Audio
|
||||||
const originalAudioContext = globalThis.AudioContext
|
const originalAudioContext = globalThis.AudioContext
|
||||||
const originalCreateObjectURL = globalThis.URL.createObjectURL
|
const originalCreateObjectURL = globalThis.URL.createObjectURL
|
||||||
|
const originalRevokeObjectURL = globalThis.URL.revokeObjectURL
|
||||||
const originalMediaSource = window.MediaSource
|
const originalMediaSource = window.MediaSource
|
||||||
const originalManagedMediaSource = window.ManagedMediaSource
|
const originalManagedMediaSource = window.ManagedMediaSource
|
||||||
|
|
||||||
@ -192,6 +215,7 @@ describe('AudioPlayer', () => {
|
|||||||
testState.mediaSources = []
|
testState.mediaSources = []
|
||||||
testState.audios = []
|
testState.audios = []
|
||||||
testState.audioContexts = []
|
testState.audioContexts = []
|
||||||
|
MockMediaSourceCtor.isTypeSupported.mockReturnValue(true)
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'Audio', {
|
Object.defineProperty(globalThis, 'Audio', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@ -208,6 +232,11 @@ describe('AudioPlayer', () => {
|
|||||||
writable: true,
|
writable: true,
|
||||||
value: vi.fn(() => 'blob:mock-url'),
|
value: vi.fn(() => 'blob:mock-url'),
|
||||||
})
|
})
|
||||||
|
Object.defineProperty(globalThis.URL, 'revokeObjectURL', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
setMediaSourceSupport({ mediaSource: true, managedMediaSource: false })
|
setMediaSourceSupport({ mediaSource: true, managedMediaSource: false })
|
||||||
})
|
})
|
||||||
@ -228,6 +257,11 @@ describe('AudioPlayer', () => {
|
|||||||
writable: true,
|
writable: true,
|
||||||
value: originalCreateObjectURL,
|
value: originalCreateObjectURL,
|
||||||
})
|
})
|
||||||
|
Object.defineProperty(globalThis.URL, 'revokeObjectURL', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: originalRevokeObjectURL,
|
||||||
|
})
|
||||||
Object.defineProperty(window, 'MediaSource', {
|
Object.defineProperty(window, 'MediaSource', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
writable: true,
|
writable: true,
|
||||||
@ -256,7 +290,7 @@ describe('AudioPlayer', () => {
|
|||||||
expect(audioContext!.connect).toHaveBeenCalledTimes(1)
|
expect(audioContext!.connect).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should notify unsupported browser when no MediaSource implementation exists', () => {
|
it('should use complete-audio fallback when no MediaSource implementation exists', () => {
|
||||||
setMediaSourceSupport({ mediaSource: false, managedMediaSource: false })
|
setMediaSourceSupport({ mediaSource: false, managedMediaSource: false })
|
||||||
|
|
||||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||||
@ -264,12 +298,22 @@ describe('AudioPlayer', () => {
|
|||||||
|
|
||||||
expect(player.mediaSource).toBeNull()
|
expect(player.mediaSource).toBeNull()
|
||||||
expect(audio!.src).toBe('')
|
expect(audio!.src).toBe('')
|
||||||
expect(mockToastNotify).toHaveBeenCalledTimes(1)
|
expect(audio!.autoplay).toBe(false)
|
||||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
|
||||||
expect.objectContaining({
|
})
|
||||||
type: 'error',
|
|
||||||
}),
|
it('should use complete-audio fallback when MP3 MediaSource is unsupported', () => {
|
||||||
)
|
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||||
|
|
||||||
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||||
|
const audio = testState.audios[0]
|
||||||
|
|
||||||
|
expect(MockMediaSourceCtor.isTypeSupported).toHaveBeenCalledWith('audio/mpeg')
|
||||||
|
expect(player.mediaSource).toBeNull()
|
||||||
|
expect(testState.mediaSources).toHaveLength(0)
|
||||||
|
expect(audio!.src).toBe('')
|
||||||
|
expect(audio!.autoplay).toBe(false)
|
||||||
|
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should configure fallback audio controls when ManagedMediaSource is used', () => {
|
it('should configure fallback audio controls when ManagedMediaSource is used', () => {
|
||||||
@ -283,6 +327,17 @@ describe('AudioPlayer', () => {
|
|||||||
expect(audio!.disableRemotePlayback).toBe(true)
|
expect(audio!.disableRemotePlayback).toBe(true)
|
||||||
expect(audio!.controls).toBe(true)
|
expect(audio!.controls).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should configure ManagedMediaSource when both media source implementations exist', () => {
|
||||||
|
setMediaSourceSupport({ mediaSource: true, managedMediaSource: true })
|
||||||
|
|
||||||
|
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, vi.fn())
|
||||||
|
const audio = testState.audios[0]
|
||||||
|
|
||||||
|
expect(player.mediaSource).not.toBeNull()
|
||||||
|
expect(audio!.disableRemotePlayback).toBe(true)
|
||||||
|
expect(audio!.controls).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('event wiring', () => {
|
describe('event wiring', () => {
|
||||||
@ -294,7 +349,7 @@ describe('AudioPlayer', () => {
|
|||||||
audio!.emit('play')
|
audio!.emit('play')
|
||||||
audio!.emit('ended')
|
audio!.emit('ended')
|
||||||
audio!.emit('error')
|
audio!.emit('error')
|
||||||
audio!.emit('paused')
|
audio!.emit('pause')
|
||||||
audio!.emit('loaded')
|
audio!.emit('loaded')
|
||||||
audio!.emit('timeupdate')
|
audio!.emit('timeupdate')
|
||||||
audio!.emit('loadeddate')
|
audio!.emit('loadeddate')
|
||||||
@ -354,6 +409,7 @@ describe('AudioPlayer', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should emit error callback and reset load flag when stream response status is not 200', async () => {
|
it('should emit error callback and reset load flag when stream response status is not 200', async () => {
|
||||||
|
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||||
const callback = vi.fn()
|
const callback = vi.fn()
|
||||||
mockTextToAudioStream.mockResolvedValue(
|
mockTextToAudioStream.mockResolvedValue(
|
||||||
makeAudioResponse(500, [{ value: new Uint8Array([1]), done: true }]),
|
makeAudioResponse(500, [{ value: new Uint8Array([1]), done: true }]),
|
||||||
@ -366,25 +422,171 @@ describe('AudioPlayer', () => {
|
|||||||
expect(callback).toHaveBeenCalledWith('error')
|
expect(callback).toHaveBeenCalledWith('error')
|
||||||
})
|
})
|
||||||
expect(player.isLoadData).toBe(false)
|
expect(player.isLoadData).toBe(false)
|
||||||
|
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
|
||||||
|
expect(testState.audios[0]!.play).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should resume and play immediately when playAudio is called in suspended loaded state', async () => {
|
it('should play a complete MP3 blob when MediaSource does not support audio/mpeg', async () => {
|
||||||
|
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||||
|
const callback = vi.fn()
|
||||||
|
mockTextToAudioStream.mockResolvedValue(
|
||||||
|
makeAudioResponse(200, [
|
||||||
|
{ value: new Uint8Array([1, 2]), done: false },
|
||||||
|
{ value: new Uint8Array([3, 4]), done: true },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||||
|
const audio = testState.audios[0]
|
||||||
|
|
||||||
|
player.playAudio()
|
||||||
|
|
||||||
|
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||||
|
expect(player.mediaSource).toBeNull()
|
||||||
|
expect(player.cacheBuffers).toHaveLength(0)
|
||||||
|
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||||
|
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[0]![0] as Blob
|
||||||
|
expect(audioBlob).toBeInstanceOf(Blob)
|
||||||
|
expect(audioBlob).toMatchObject({ type: 'audio/mpeg', size: 4 })
|
||||||
|
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
|
||||||
|
expect(audio!.src).toBe('blob:mock-url')
|
||||||
|
expect(callback).toHaveBeenCalledWith('play')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wait for the complete MP3 before retrying playback without MediaSource', async () => {
|
||||||
|
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||||
|
let resolveResponse: ((response: AudioResponse) => void) | undefined
|
||||||
|
mockTextToAudioStream.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<AudioResponse>((resolve) => {
|
||||||
|
resolveResponse = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, vi.fn())
|
||||||
|
const audio = testState.audios[0]
|
||||||
|
|
||||||
|
player.playAudio()
|
||||||
|
player.playAudio()
|
||||||
|
|
||||||
|
expect(audio!.play).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
resolveResponse?.(makeAudioResponse(200, [{ value: new Uint8Array([1, 2]), done: true }]))
|
||||||
|
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['suspended', 'interrupted'] as const)(
|
||||||
|
'should resume and play immediately when playAudio is called in %s loaded state',
|
||||||
|
async (audioContextState) => {
|
||||||
|
const callback = vi.fn()
|
||||||
|
const player = new AudioPlayer(
|
||||||
|
'/text-to-audio',
|
||||||
|
false,
|
||||||
|
'msg-1',
|
||||||
|
'hello',
|
||||||
|
undefined,
|
||||||
|
callback,
|
||||||
|
)
|
||||||
|
const audio = testState.audios[0]
|
||||||
|
const audioContext = testState.audioContexts[0]
|
||||||
|
|
||||||
|
player.isLoadData = true
|
||||||
|
audioContext!.state = audioContextState
|
||||||
|
player.playAudio()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||||
|
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||||
|
expect(callback).toHaveBeenCalledWith('play')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('should request media playback before a suspended audio context finishes resuming', async () => {
|
||||||
|
const callback = vi.fn()
|
||||||
|
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||||
|
const audio = testState.audios[0]
|
||||||
|
const audioContext = testState.audioContexts[0]
|
||||||
|
let resolveResume: (() => void) | undefined
|
||||||
|
|
||||||
|
player.isLoadData = true
|
||||||
|
audioContext!.state = 'suspended'
|
||||||
|
audioContext!.resume.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
resolveResume = () => {
|
||||||
|
audioContext!.state = 'running'
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
player.playAudio()
|
||||||
|
|
||||||
|
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||||
|
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||||
|
expect(callback).not.toHaveBeenCalledWith('play')
|
||||||
|
|
||||||
|
resolveResume?.()
|
||||||
|
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['suspended', 'interrupted'] as const)(
|
||||||
|
'should resume a %s audio context when the media element is still playing',
|
||||||
|
async (audioContextState) => {
|
||||||
|
const callback = vi.fn()
|
||||||
|
const player = new AudioPlayer(
|
||||||
|
'/text-to-audio',
|
||||||
|
false,
|
||||||
|
'msg-1',
|
||||||
|
'hello',
|
||||||
|
undefined,
|
||||||
|
callback,
|
||||||
|
)
|
||||||
|
const audio = testState.audios[0]
|
||||||
|
const audioContext = testState.audioContexts[0]
|
||||||
|
|
||||||
|
player.isLoadData = true
|
||||||
|
audio!.paused = false
|
||||||
|
audioContext!.state = audioContextState
|
||||||
|
player.playAudio()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||||
|
expect(callback).toHaveBeenCalledWith('play')
|
||||||
|
})
|
||||||
|
expect(audio!.play).not.toHaveBeenCalled()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('should report an error when the audio context remains interrupted and allow retry', async () => {
|
||||||
const callback = vi.fn()
|
const callback = vi.fn()
|
||||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||||
const audio = testState.audios[0]
|
const audio = testState.audios[0]
|
||||||
const audioContext = testState.audioContexts[0]
|
const audioContext = testState.audioContexts[0]
|
||||||
|
|
||||||
player.isLoadData = true
|
player.isLoadData = true
|
||||||
|
audio!.paused = false
|
||||||
audioContext!.state = 'suspended'
|
audioContext!.state = 'suspended'
|
||||||
|
audioContext!.resume.mockImplementationOnce(async () => {
|
||||||
|
audioContext!.state = 'interrupted'
|
||||||
|
})
|
||||||
player.playAudio()
|
player.playAudio()
|
||||||
await Promise.resolve()
|
|
||||||
|
|
||||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
|
||||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
expect(callback).not.toHaveBeenCalledWith('play')
|
||||||
expect(callback).toHaveBeenCalledWith('play')
|
|
||||||
|
audioContext!.resume.mockImplementationOnce(async () => {
|
||||||
|
audioContext!.state = 'running'
|
||||||
|
})
|
||||||
|
player.playAudio()
|
||||||
|
|
||||||
|
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||||
|
expect(audioContext!.resume).toHaveBeenCalledTimes(2)
|
||||||
|
expect(audio!.play).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should play ended audio when data is already loaded', () => {
|
it('should play ended audio when data is already loaded', async () => {
|
||||||
const callback = vi.fn()
|
const callback = vi.fn()
|
||||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||||
const audio = testState.audios[0]
|
const audio = testState.audios[0]
|
||||||
@ -395,11 +597,13 @@ describe('AudioPlayer', () => {
|
|||||||
audio!.ended = true
|
audio!.ended = true
|
||||||
player.playAudio()
|
player.playAudio()
|
||||||
|
|
||||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
await waitFor(() => {
|
||||||
expect(callback).toHaveBeenCalledWith('play')
|
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||||
|
expect(callback).toHaveBeenCalledWith('play')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should only emit play callback without replaying when loaded audio is already playing', () => {
|
it('should report loaded audio that is already playing without replaying it', () => {
|
||||||
const callback = vi.fn()
|
const callback = vi.fn()
|
||||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||||
const audio = testState.audios[0]
|
const audio = testState.audios[0]
|
||||||
@ -407,6 +611,7 @@ describe('AudioPlayer', () => {
|
|||||||
|
|
||||||
player.isLoadData = true
|
player.isLoadData = true
|
||||||
audioContext!.state = 'running'
|
audioContext!.state = 'running'
|
||||||
|
audio!.paused = false
|
||||||
audio!.ended = false
|
audio!.ended = false
|
||||||
player.playAudio()
|
player.playAudio()
|
||||||
|
|
||||||
@ -451,22 +656,20 @@ describe('AudioPlayer', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should end stream without playback when playAudioWithAudio receives empty content', async () => {
|
it('should end stream without playback when playAudioWithAudio receives empty content', async () => {
|
||||||
vi.useFakeTimers()
|
const callback = vi.fn()
|
||||||
try {
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||||
const callback = vi.fn()
|
const mediaSource = testState.mediaSources[0]
|
||||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
|
||||||
const mediaSource = testState.mediaSources[0]
|
|
||||||
|
|
||||||
await player.playAudioWithAudio('', true)
|
await player.playAudioWithAudio('', true)
|
||||||
await vi.advanceTimersByTimeAsync(40)
|
|
||||||
|
|
||||||
expect(player.isLoadData).toBe(false)
|
expect(player.isLoadData).toBe(false)
|
||||||
expect(player.cacheBuffers).toHaveLength(0)
|
expect(player.cacheBuffers).toHaveLength(0)
|
||||||
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
|
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||||
expect(callback).not.toHaveBeenCalledWith('play')
|
|
||||||
} finally {
|
mediaSource!.emit('sourceopen')
|
||||||
vi.useRealTimers()
|
|
||||||
}
|
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
|
||||||
|
expect(callback).not.toHaveBeenCalledWith('play')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should decode base64 and start playback when playAudioWithAudio is called with playable content', async () => {
|
it('should decode base64 and start playback when playAudioWithAudio is called with playable content', async () => {
|
||||||
@ -479,8 +682,8 @@ describe('AudioPlayer', () => {
|
|||||||
|
|
||||||
mediaSource!.emit('sourceopen')
|
mediaSource!.emit('sourceopen')
|
||||||
audio!.paused = true
|
audio!.paused = true
|
||||||
|
audioContext!.state = 'suspended'
|
||||||
await player.playAudioWithAudio(audioBase64, true)
|
await player.playAudioWithAudio(audioBase64, true)
|
||||||
await Promise.resolve()
|
|
||||||
|
|
||||||
expect(player.isLoadData).toBe(true)
|
expect(player.isLoadData).toBe(true)
|
||||||
expect(player.cacheBuffers).toHaveLength(0)
|
expect(player.cacheBuffers).toHaveLength(0)
|
||||||
@ -488,9 +691,11 @@ describe('AudioPlayer', () => {
|
|||||||
const appendedAudioData = mediaSource!.sourceBuffer.appendBuffer.mock.calls[0]![0]
|
const appendedAudioData = mediaSource!.sourceBuffer.appendBuffer.mock.calls[0]![0]
|
||||||
expect(appendedAudioData).toBeInstanceOf(ArrayBuffer)
|
expect(appendedAudioData).toBeInstanceOf(ArrayBuffer)
|
||||||
expect(appendedAudioData.byteLength).toBeGreaterThan(0)
|
expect(appendedAudioData.byteLength).toBeGreaterThan(0)
|
||||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
await waitFor(() => {
|
||||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||||
expect(callback).toHaveBeenCalledWith('play')
|
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||||
|
expect(callback).toHaveBeenCalledWith('play')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should skip playback when playAudioWithAudio is called with play=false', async () => {
|
it('should skip playback when playAudioWithAudio is called with play=false', async () => {
|
||||||
@ -507,6 +712,88 @@ describe('AudioPlayer', () => {
|
|||||||
expect(callback).not.toHaveBeenCalledWith('play')
|
expect(callback).not.toHaveBeenCalledWith('play')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should combine automatic TTS chunks into a playable MP3 blob without MediaSource', async () => {
|
||||||
|
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||||
|
const callback = vi.fn()
|
||||||
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||||
|
const audio = testState.audios[0]
|
||||||
|
|
||||||
|
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
|
||||||
|
await player.playAudioWithAudio(Buffer.from([3, 4]).toString('base64'), true)
|
||||||
|
|
||||||
|
expect(audio!.play).not.toHaveBeenCalled()
|
||||||
|
expect(player.cacheBuffers).toHaveLength(2)
|
||||||
|
|
||||||
|
await player.playAudioWithAudio('', false)
|
||||||
|
|
||||||
|
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||||
|
expect(player.cacheBuffers).toHaveLength(0)
|
||||||
|
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||||
|
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[0]![0] as Blob
|
||||||
|
expect(audioBlob).toMatchObject({ type: 'audio/mpeg', size: 4 })
|
||||||
|
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
|
||||||
|
expect(audio!.src).toBe('blob:mock-url')
|
||||||
|
expect(callback).toHaveBeenCalledWith('play')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not start fallback playback after it is paused while buffering', async () => {
|
||||||
|
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||||
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
|
||||||
|
const audio = testState.audios[0]
|
||||||
|
|
||||||
|
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
|
||||||
|
player.pauseAudio()
|
||||||
|
await player.playAudioWithAudio('', false)
|
||||||
|
|
||||||
|
expect(audio!.autoplay).toBe(false)
|
||||||
|
expect(audio!.play).not.toHaveBeenCalled()
|
||||||
|
expect(audio!.src).toBe('blob:mock-url')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to a complete MP3 when addSourceBuffer throws', async () => {
|
||||||
|
const callback = vi.fn()
|
||||||
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||||
|
const mediaSource = testState.mediaSources[0]
|
||||||
|
const audio = testState.audios[0]
|
||||||
|
mediaSource!.addSourceBuffer.mockImplementationOnce(() => {
|
||||||
|
throw new DOMException('Unsupported type', 'NotSupportedError')
|
||||||
|
})
|
||||||
|
|
||||||
|
mediaSource!.emit('sourceopen')
|
||||||
|
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
|
||||||
|
await player.playAudioWithAudio('', false)
|
||||||
|
|
||||||
|
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||||
|
expect(player.mediaSource).toBeNull()
|
||||||
|
expect(audio!.autoplay).toBe(false)
|
||||||
|
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||||
|
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should complete buffered fallback when addSourceBuffer throws after stream end', async () => {
|
||||||
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
|
||||||
|
const mediaSource = testState.mediaSources[0]
|
||||||
|
const audio = testState.audios[0]
|
||||||
|
mediaSource!.addSourceBuffer.mockImplementationOnce(() => {
|
||||||
|
throw new DOMException('Unsupported type', 'NotSupportedError')
|
||||||
|
})
|
||||||
|
|
||||||
|
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
|
||||||
|
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||||
|
await player.playAudioWithAudio('', false)
|
||||||
|
audio!.paused = true
|
||||||
|
|
||||||
|
mediaSource!.emit('sourceopen')
|
||||||
|
|
||||||
|
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(2))
|
||||||
|
expect(player.mediaSource).toBeNull()
|
||||||
|
expect(player.cacheBuffers).toHaveLength(0)
|
||||||
|
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(2)
|
||||||
|
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[1]![0] as Blob
|
||||||
|
expect(audioBlob).toMatchObject({ type: 'audio/mpeg', size: 2 })
|
||||||
|
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2]))
|
||||||
|
})
|
||||||
|
|
||||||
it('should play immediately for ended audio in playAudioWithAudio', async () => {
|
it('should play immediately for ended audio in playAudioWithAudio', async () => {
|
||||||
const callback = vi.fn()
|
const callback = vi.fn()
|
||||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||||
@ -517,7 +804,7 @@ describe('AudioPlayer', () => {
|
|||||||
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
|
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
|
||||||
|
|
||||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||||
expect(callback).toHaveBeenCalledWith('play')
|
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not replay when played list exists in playAudioWithAudio', async () => {
|
it('should not replay when played list exists in playAudioWithAudio', async () => {
|
||||||
@ -534,18 +821,63 @@ describe('AudioPlayer', () => {
|
|||||||
expect(callback).not.toHaveBeenCalledWith('play')
|
expect(callback).not.toHaveBeenCalledWith('play')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should replay when paused is false and played list is empty in playAudioWithAudio', async () => {
|
it('should report a play failure and retry without requesting audio again', async () => {
|
||||||
const callback = vi.fn()
|
const callback = vi.fn()
|
||||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||||
const audio = testState.audios[0]
|
const audio = testState.audios[0]
|
||||||
|
const mediaSource = testState.mediaSources[0]
|
||||||
|
mockTextToAudioStream.mockResolvedValue(
|
||||||
|
makeAudioResponse(200, [{ value: undefined, done: true }]),
|
||||||
|
)
|
||||||
|
audio!.play.mockRejectedValueOnce(new DOMException('Playback aborted', 'AbortError'))
|
||||||
|
|
||||||
audio!.paused = false
|
mediaSource!.emit('sourceopen')
|
||||||
audio!.ended = false
|
player.playAudio()
|
||||||
audio!.played = null
|
|
||||||
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
|
|
||||||
|
|
||||||
|
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
|
||||||
|
expect(callback).not.toHaveBeenCalledWith('play')
|
||||||
|
expect(player.isLoadData).toBe(true)
|
||||||
|
|
||||||
|
audio!.play.mockImplementationOnce(async () => {
|
||||||
|
audio!.paused = false
|
||||||
|
})
|
||||||
|
player.playAudio()
|
||||||
|
|
||||||
|
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||||
|
|
||||||
|
expect(audio!.play).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockTextToAudioStream).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should report a resume failure and allow playback to be retried', async () => {
|
||||||
|
const callback = vi.fn()
|
||||||
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||||
|
const audio = testState.audios[0]
|
||||||
|
const audioContext = testState.audioContexts[0]
|
||||||
|
const mediaSource = testState.mediaSources[0]
|
||||||
|
mockTextToAudioStream.mockResolvedValue(
|
||||||
|
makeAudioResponse(200, [{ value: undefined, done: true }]),
|
||||||
|
)
|
||||||
|
audioContext!.state = 'suspended'
|
||||||
|
audioContext!.resume.mockRejectedValueOnce(new DOMException('Not allowed', 'NotAllowedError'))
|
||||||
|
|
||||||
|
mediaSource!.emit('sourceopen')
|
||||||
|
player.playAudio()
|
||||||
|
|
||||||
|
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
|
||||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||||
expect(callback).toHaveBeenCalledWith('play')
|
expect(player.isLoadData).toBe(true)
|
||||||
|
|
||||||
|
audioContext!.resume.mockImplementationOnce(async () => {
|
||||||
|
audioContext!.state = 'running'
|
||||||
|
})
|
||||||
|
player.playAudio()
|
||||||
|
|
||||||
|
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||||
|
|
||||||
|
expect(audioContext!.resume).toHaveBeenCalledTimes(2)
|
||||||
|
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockTextToAudioStream).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -562,7 +894,7 @@ describe('AudioPlayer', () => {
|
|||||||
expect(finishStream).toHaveBeenCalledTimes(1)
|
expect(finishStream).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should finish stream when receiveAudioData gets empty bytes while source is open', () => {
|
it('should finish stream when receiveAudioData gets empty bytes', () => {
|
||||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||||
const finishStream = vi
|
const finishStream = vi
|
||||||
.spyOn(player as unknown as { finishStream: () => void }, 'finishStream')
|
.spyOn(player as unknown as { finishStream: () => void }, 'finishStream')
|
||||||
@ -586,6 +918,52 @@ describe('AudioPlayer', () => {
|
|||||||
expect(player.cacheBuffers.length).toBe(1)
|
expect(player.cacheBuffers.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should preserve audio received before sourceopen and append it once ready', () => {
|
||||||
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||||
|
const mediaSource = testState.mediaSources[0]
|
||||||
|
|
||||||
|
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
|
||||||
|
new Uint8Array([1, 2, 3]),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(player.cacheBuffers).toHaveLength(1)
|
||||||
|
expect(mediaSource!.sourceBuffer.appendBuffer).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
mediaSource!.emit('sourceopen')
|
||||||
|
|
||||||
|
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
|
||||||
|
expect(player.cacheBuffers).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should append queued buffers in order after updateend', () => {
|
||||||
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||||
|
const mediaSource = testState.mediaSources[0]
|
||||||
|
mediaSource!.emit('sourceopen')
|
||||||
|
mediaSource!.sourceBuffer.updating = true
|
||||||
|
|
||||||
|
const first = new Uint8Array([1])
|
||||||
|
const second = new Uint8Array([2])
|
||||||
|
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
|
||||||
|
first,
|
||||||
|
)
|
||||||
|
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
|
||||||
|
second,
|
||||||
|
)
|
||||||
|
|
||||||
|
mediaSource!.sourceBuffer.updating = false
|
||||||
|
mediaSource!.sourceBuffer.emit('updateend')
|
||||||
|
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
|
||||||
|
expect(new Uint8Array(mediaSource!.sourceBuffer.appendBuffer.mock.calls[0]![0])).toEqual(
|
||||||
|
first,
|
||||||
|
)
|
||||||
|
|
||||||
|
mediaSource!.sourceBuffer.emit('updateend')
|
||||||
|
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(2)
|
||||||
|
expect(new Uint8Array(mediaSource!.sourceBuffer.appendBuffer.mock.calls[1]![0])).toEqual(
|
||||||
|
second,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('should append previously queued buffer before new one when source buffer is idle', () => {
|
it('should append previously queued buffer before new one when source buffer is idle', () => {
|
||||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||||
const mediaSource = testState.mediaSources[0]
|
const mediaSource = testState.mediaSources[0]
|
||||||
@ -603,19 +981,68 @@ describe('AudioPlayer', () => {
|
|||||||
expect(player.cacheBuffers.length).toBe(1)
|
expect(player.cacheBuffers.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should append cache chunks and end stream when finishStream drains buffers', () => {
|
it('should end the stream only after the final queued buffer is appended', () => {
|
||||||
vi.useFakeTimers()
|
|
||||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||||
const mediaSource = testState.mediaSources[0]
|
const mediaSource = testState.mediaSources[0]
|
||||||
mediaSource!.emit('sourceopen')
|
mediaSource!.emit('sourceopen')
|
||||||
mediaSource!.sourceBuffer.updating = false
|
mediaSource!.sourceBuffer.updating = true
|
||||||
player.cacheBuffers = [new ArrayBuffer(3)]
|
player.cacheBuffers = [new ArrayBuffer(3)]
|
||||||
;(player as unknown as { finishStream: () => void }).finishStream()
|
|
||||||
vi.advanceTimersByTime(50)
|
|
||||||
|
|
||||||
|
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||||
|
|
||||||
|
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
mediaSource!.sourceBuffer.updating = false
|
||||||
|
mediaSource!.sourceBuffer.emit('updateend')
|
||||||
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
|
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
mediaSource!.sourceBuffer.emit('updateend')
|
||||||
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
|
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
|
||||||
vi.useRealTimers()
|
})
|
||||||
|
|
||||||
|
it('should end an open stream at most once', () => {
|
||||||
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||||
|
const mediaSource = testState.mediaSources[0]
|
||||||
|
mediaSource!.emit('sourceopen')
|
||||||
|
|
||||||
|
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||||
|
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||||
|
|
||||||
|
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['closed', 'ended'] as const)('should not end a %s media source', (readyState) => {
|
||||||
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||||
|
const mediaSource = testState.mediaSources[0]
|
||||||
|
mediaSource!.emit('sourceopen')
|
||||||
|
mediaSource!.readyState = readyState
|
||||||
|
|
||||||
|
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||||
|
|
||||||
|
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop buffering and release browser resources after destroy', async () => {
|
||||||
|
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||||
|
const mediaSource = testState.mediaSources[0]
|
||||||
|
const audio = testState.audios[0]
|
||||||
|
const audioContext = testState.audioContexts[0]
|
||||||
|
mediaSource!.emit('sourceopen')
|
||||||
|
|
||||||
|
player.destroy()
|
||||||
|
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
|
||||||
|
new Uint8Array([1]),
|
||||||
|
)
|
||||||
|
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||||
|
mediaSource!.sourceBuffer.emit('updateend')
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(mediaSource!.sourceBuffer.appendBuffer).not.toHaveBeenCalled()
|
||||||
|
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||||
|
expect(audio!.pause).toHaveBeenCalledTimes(1)
|
||||||
|
expect(audioContext!.close).toHaveBeenCalledTimes(1)
|
||||||
|
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -35,9 +35,7 @@ export class AudioPlayerManager {
|
|||||||
} else {
|
} else {
|
||||||
if (this.audioPlayers) {
|
if (this.audioPlayers) {
|
||||||
try {
|
try {
|
||||||
this.audioPlayers.pauseAudio()
|
this.audioPlayers.destroy()
|
||||||
this.audioPlayers.cacheBuffers = []
|
|
||||||
this.audioPlayers.sourceBuffer?.abort()
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,19 @@
|
|||||||
import { toast } from '@langgenius/dify-ui/toast'
|
|
||||||
import { AppSourceType, textToAudioStream } from '@/service/share'
|
import { AppSourceType, textToAudioStream } from '@/service/share'
|
||||||
|
|
||||||
|
const AUDIO_CONTENT_TYPE = 'audio/mpeg'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// oxlint-disable-next-line typescript/consistent-type-definitions
|
// oxlint-disable-next-line typescript/consistent-type-definitions
|
||||||
interface Window {
|
interface Window {
|
||||||
ManagedMediaSource: any
|
ManagedMediaSource?: typeof MediaSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export default class AudioPlayer {
|
export default class AudioPlayer {
|
||||||
mediaSource: MediaSource | null
|
mediaSource: MediaSource | null
|
||||||
audio: HTMLAudioElement
|
audio: HTMLAudioElement
|
||||||
audioContext: AudioContext
|
audioContext: AudioContext
|
||||||
sourceBuffer?: any
|
sourceBuffer?: SourceBuffer
|
||||||
cacheBuffers: ArrayBuffer[] = []
|
cacheBuffers: ArrayBuffer[] = []
|
||||||
pauseTimer: number | null = null
|
|
||||||
msgId: string | undefined
|
msgId: string | undefined
|
||||||
msgContent: string | null | undefined = null
|
msgContent: string | null | undefined = null
|
||||||
voice: string | undefined = undefined
|
voice: string | undefined = undefined
|
||||||
@ -21,6 +21,13 @@ export default class AudioPlayer {
|
|||||||
url: string
|
url: string
|
||||||
isPublic: boolean
|
isPublic: boolean
|
||||||
callback: ((event: string) => void) | null
|
callback: ((event: string) => void) | null
|
||||||
|
private objectUrl = ''
|
||||||
|
private streamEnded = false
|
||||||
|
private endOfStreamCalled = false
|
||||||
|
private destroyed = false
|
||||||
|
private playbackPending = false
|
||||||
|
private playWhenReady = false
|
||||||
|
private sourceOpenListener?: () => void
|
||||||
constructor(
|
constructor(
|
||||||
streamUrl: string,
|
streamUrl: string,
|
||||||
isPublic: boolean,
|
isPublic: boolean,
|
||||||
@ -37,25 +44,26 @@ export default class AudioPlayer {
|
|||||||
this.voice = voice
|
this.voice = voice
|
||||||
this.callback = callback
|
this.callback = callback
|
||||||
// Compatible with iphone ios17 ManagedMediaSource
|
// Compatible with iphone ios17 ManagedMediaSource
|
||||||
const MediaSource = window.ManagedMediaSource || window.MediaSource
|
const MediaSourceConstructor = window.ManagedMediaSource || window.MediaSource
|
||||||
if (!MediaSource) {
|
const isManagedMediaSource = Boolean(
|
||||||
toast.error(
|
window.ManagedMediaSource && MediaSourceConstructor === window.ManagedMediaSource,
|
||||||
'Your browser does not support audio streaming, if you are using an iPhone, please update to iOS 17.1 or later.',
|
)
|
||||||
)
|
const supportsStreaming = Boolean(MediaSourceConstructor?.isTypeSupported?.(AUDIO_CONTENT_TYPE))
|
||||||
}
|
this.mediaSource =
|
||||||
this.mediaSource = MediaSource ? new MediaSource() : null
|
supportsStreaming && MediaSourceConstructor ? new MediaSourceConstructor() : null
|
||||||
this.audio = new Audio()
|
this.audio = new Audio()
|
||||||
this.setCallback(callback)
|
this.setCallback(callback)
|
||||||
if (!window.MediaSource) {
|
if (this.mediaSource && isManagedMediaSource) {
|
||||||
// if use ManagedMediaSource
|
// if use ManagedMediaSource
|
||||||
this.audio.disableRemotePlayback = true
|
this.audio.disableRemotePlayback = true
|
||||||
this.audio.controls = true
|
this.audio.controls = true
|
||||||
}
|
}
|
||||||
this.audio.src = this.mediaSource ? URL.createObjectURL(this.mediaSource) : ''
|
this.listenMediaSource(AUDIO_CONTENT_TYPE)
|
||||||
this.audio.autoplay = true
|
this.objectUrl = this.mediaSource ? URL.createObjectURL(this.mediaSource) : ''
|
||||||
|
this.audio.src = this.objectUrl
|
||||||
|
this.audio.autoplay = Boolean(this.mediaSource)
|
||||||
const source = this.audioContext.createMediaElementSource(this.audio)
|
const source = this.audioContext.createMediaElementSource(this.audio)
|
||||||
source.connect(this.audioContext.destination)
|
source.connect(this.audioContext.destination)
|
||||||
this.listenMediaSource('audio/mpeg')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetMsgId(msgId: string) {
|
public resetMsgId(msgId: string) {
|
||||||
@ -63,10 +71,77 @@ export default class AudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private listenMediaSource(contentType: string) {
|
private listenMediaSource(contentType: string) {
|
||||||
this.mediaSource?.addEventListener('sourceopen', () => {
|
this.sourceOpenListener = () => {
|
||||||
if (this.sourceBuffer) return
|
if (this.destroyed || this.sourceBuffer) return
|
||||||
this.sourceBuffer = this.mediaSource?.addSourceBuffer(contentType)
|
try {
|
||||||
})
|
this.sourceBuffer = this.mediaSource?.addSourceBuffer(contentType)
|
||||||
|
this.sourceBuffer?.addEventListener('updateend', this.flushBuffers)
|
||||||
|
this.flushBuffers()
|
||||||
|
} catch {
|
||||||
|
this.mediaSource = null
|
||||||
|
this.audio.autoplay = false
|
||||||
|
this.releaseObjectUrl()
|
||||||
|
if (this.streamEnded) this.finishBlobAudio()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.mediaSource?.addEventListener('sourceopen', this.sourceOpenListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushBuffers = () => {
|
||||||
|
if (
|
||||||
|
this.destroyed ||
|
||||||
|
!this.sourceBuffer ||
|
||||||
|
this.sourceBuffer.updating ||
|
||||||
|
this.mediaSource?.readyState !== 'open'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
const nextBuffer = this.cacheBuffers.shift()
|
||||||
|
if (nextBuffer) {
|
||||||
|
this.sourceBuffer.appendBuffer(nextBuffer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.streamEnded && !this.endOfStreamCalled) {
|
||||||
|
this.endOfStreamCalled = true
|
||||||
|
this.mediaSource.endOfStream()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestPlayback(reportIfPlaying = false) {
|
||||||
|
if (this.destroyed || this.playbackPending) return
|
||||||
|
if (!this.isAudioContextPaused() && !this.audio.paused && !this.audio.ended) {
|
||||||
|
if (reportIfPlaying) this.callback?.('play')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playbackPending = true
|
||||||
|
void this.resumeAndPlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAudioContextPaused() {
|
||||||
|
return this.audioContext.state === 'suspended' || this.audioContext.state === 'interrupted'
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resumeAndPlay() {
|
||||||
|
try {
|
||||||
|
const pendingOperations: Promise<unknown>[] = []
|
||||||
|
if (this.isAudioContextPaused()) pendingOperations.push(this.audioContext.resume())
|
||||||
|
if (this.audio.paused || this.audio.ended) pendingOperations.push(this.audio.play())
|
||||||
|
|
||||||
|
await Promise.all(pendingOperations)
|
||||||
|
if (this.destroyed) return
|
||||||
|
if (this.isAudioContextPaused()) {
|
||||||
|
this.callback?.('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.destroyed) this.callback?.('play')
|
||||||
|
} catch {
|
||||||
|
if (!this.destroyed) this.callback?.('error')
|
||||||
|
} finally {
|
||||||
|
this.playbackPending = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setCallback(callback: ((event: string) => void) | null) {
|
public setCallback(callback: ((event: string) => void) | null) {
|
||||||
@ -80,7 +155,7 @@ export default class AudioPlayer {
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
this.audio.addEventListener(
|
this.audio.addEventListener(
|
||||||
'paused',
|
'pause',
|
||||||
() => {
|
() => {
|
||||||
callback('paused')
|
callback('paused')
|
||||||
},
|
},
|
||||||
@ -133,7 +208,7 @@ export default class AudioPlayer {
|
|||||||
|
|
||||||
private async loadAudio() {
|
private async loadAudio() {
|
||||||
try {
|
try {
|
||||||
const audioResponse: any = await textToAudioStream(
|
const audioResponse = (await textToAudioStream(
|
||||||
this.url,
|
this.url,
|
||||||
this.isPublic ? AppSourceType.webApp : AppSourceType.installedApp,
|
this.isPublic ? AppSourceType.webApp : AppSourceType.installedApp,
|
||||||
{ content_type: 'audio/mpeg' },
|
{ content_type: 'audio/mpeg' },
|
||||||
@ -143,19 +218,21 @@ export default class AudioPlayer {
|
|||||||
voice: this.voice,
|
voice: this.voice,
|
||||||
text: this.msgContent,
|
text: this.msgContent,
|
||||||
},
|
},
|
||||||
)
|
)) as Response
|
||||||
if (audioResponse.status !== 200) {
|
if (audioResponse.status !== 200) {
|
||||||
this.isLoadData = false
|
this.isLoadData = false
|
||||||
if (this.callback) this.callback('error')
|
this.callback?.('error')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if (!audioResponse.body) throw new Error('Audio response body is missing')
|
||||||
const reader = audioResponse.body.getReader()
|
const reader = audioResponse.body.getReader()
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done } = await reader.read()
|
const { value, done } = await reader.read()
|
||||||
|
if (value?.byteLength) this.receiveAudioData(value)
|
||||||
if (done) {
|
if (done) {
|
||||||
this.receiveAudioData(value)
|
this.finishStream()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
this.receiveAudioData(value)
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.isLoadData = false
|
this.isLoadData = false
|
||||||
@ -166,46 +243,29 @@ export default class AudioPlayer {
|
|||||||
// play audio
|
// play audio
|
||||||
public playAudio() {
|
public playAudio() {
|
||||||
if (this.isLoadData) {
|
if (this.isLoadData) {
|
||||||
if (this.audioContext.state === 'suspended') {
|
if (!this.mediaSource && !this.objectUrl) {
|
||||||
this.audioContext.resume().then((_) => {
|
this.playWhenReady = true
|
||||||
this.audio.play()
|
return
|
||||||
this.callback?.('play')
|
|
||||||
})
|
|
||||||
} else if (this.audio.ended) {
|
|
||||||
this.audio.play()
|
|
||||||
this.callback?.('play')
|
|
||||||
}
|
}
|
||||||
this.callback?.('play')
|
this.requestPlayback(true)
|
||||||
} else {
|
} else {
|
||||||
this.isLoadData = true
|
this.isLoadData = true
|
||||||
this.audioContext.resume().then((_) => {
|
this.playWhenReady = true
|
||||||
this.audio.play()
|
if (this.mediaSource) this.requestPlayback(true)
|
||||||
this.callback?.('play')
|
else if (this.isAudioContextPaused()) void this.audioContext.resume().catch(() => {})
|
||||||
})
|
|
||||||
this.loadAudio()
|
this.loadAudio()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private theEndOfStream() {
|
|
||||||
const endTimer = setInterval(() => {
|
|
||||||
if (!this.sourceBuffer?.updating) {
|
|
||||||
this.mediaSource?.endOfStream()
|
|
||||||
clearInterval(endTimer)
|
|
||||||
}
|
|
||||||
}, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
private finishStream() {
|
private finishStream() {
|
||||||
const timer = setInterval(() => {
|
if (this.destroyed) return
|
||||||
if (!this.cacheBuffers.length) {
|
this.streamEnded = true
|
||||||
this.theEndOfStream()
|
if (this.mediaSource) {
|
||||||
clearInterval(timer)
|
this.flushBuffers()
|
||||||
}
|
return
|
||||||
if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
|
}
|
||||||
const arrayBuffer = this.cacheBuffers.shift()!
|
|
||||||
this.sourceBuffer?.appendBuffer(arrayBuffer)
|
this.finishBlobAudio()
|
||||||
}
|
|
||||||
}, 10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async playAudioWithAudio(audio: string, play = true) {
|
public async playAudioWithAudio(audio: string, play = true) {
|
||||||
@ -213,54 +273,82 @@ export default class AudioPlayer {
|
|||||||
this.finishStream()
|
this.finishStream()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const audioContent = Buffer.from(audio, 'base64')
|
const audioContent = Uint8Array.from(atob(audio), (char) => char.charCodeAt(0))
|
||||||
this.receiveAudioData(new Uint8Array(audioContent))
|
this.receiveAudioData(audioContent)
|
||||||
if (play) {
|
if (play) {
|
||||||
this.isLoadData = true
|
this.isLoadData = true
|
||||||
if (this.audio.paused) {
|
this.playWhenReady = true
|
||||||
this.audioContext.resume().then((_) => {
|
if (this.mediaSource) this.requestPlayback()
|
||||||
this.audio.play()
|
|
||||||
this.callback?.('play')
|
|
||||||
})
|
|
||||||
} else if (this.audio.ended) {
|
|
||||||
this.audio.play()
|
|
||||||
this.callback?.('play')
|
|
||||||
} else if (this.audio.played) {
|
|
||||||
/* empty */
|
|
||||||
} else {
|
|
||||||
this.audio.play()
|
|
||||||
this.callback?.('play')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public pauseAudio() {
|
public pauseAudio() {
|
||||||
|
this.playWhenReady = false
|
||||||
this.callback?.('paused')
|
this.callback?.('paused')
|
||||||
this.audio.pause()
|
this.audio.pause()
|
||||||
this.audioContext.suspend()
|
void this.audioContext.suspend().catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
private receiveAudioData(unit8Array: Uint8Array) {
|
public destroy() {
|
||||||
|
if (this.destroyed) return
|
||||||
|
|
||||||
|
this.destroyed = true
|
||||||
|
this.cacheBuffers = []
|
||||||
|
this.callback?.('paused')
|
||||||
|
this.audio.pause()
|
||||||
|
|
||||||
|
if (this.sourceOpenListener)
|
||||||
|
this.mediaSource?.removeEventListener('sourceopen', this.sourceOpenListener)
|
||||||
|
|
||||||
|
if (this.sourceBuffer) {
|
||||||
|
this.sourceBuffer.removeEventListener('updateend', this.flushBuffers)
|
||||||
|
if (this.mediaSource?.readyState === 'open') {
|
||||||
|
try {
|
||||||
|
this.sourceBuffer.abort()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.audioContext.close().catch(() => {})
|
||||||
|
this.releaseObjectUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
private receiveAudioData(unit8Array: Uint8Array | undefined) {
|
||||||
|
if (this.destroyed || this.streamEnded) return
|
||||||
if (!unit8Array) {
|
if (!unit8Array) {
|
||||||
this.finishStream()
|
this.finishStream()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const audioData = this.byteArrayToArrayBuffer(unit8Array)
|
const audioData = this.byteArrayToArrayBuffer(unit8Array)
|
||||||
if (!audioData.byteLength) {
|
if (!audioData.byteLength) {
|
||||||
if (this.mediaSource?.readyState === 'open') this.finishStream()
|
this.finishStream()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.sourceBuffer?.updating) {
|
this.cacheBuffers.push(audioData)
|
||||||
this.cacheBuffers.push(audioData)
|
this.flushBuffers()
|
||||||
} else {
|
}
|
||||||
if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
|
|
||||||
this.cacheBuffers.push(audioData)
|
private finishBlobAudio() {
|
||||||
const cacheBuffer = this.cacheBuffers.shift()!
|
if (!this.cacheBuffers.length) {
|
||||||
this.sourceBuffer?.appendBuffer(cacheBuffer)
|
if (!this.objectUrl) this.isLoadData = false
|
||||||
} else {
|
return
|
||||||
this.sourceBuffer?.appendBuffer(audioData)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const audioBlob = new Blob(this.cacheBuffers, { type: AUDIO_CONTENT_TYPE })
|
||||||
|
this.cacheBuffers = []
|
||||||
|
this.releaseObjectUrl()
|
||||||
|
this.objectUrl = URL.createObjectURL(audioBlob)
|
||||||
|
this.audio.src = this.objectUrl
|
||||||
|
this.isLoadData = true
|
||||||
|
if (this.playWhenReady) this.requestPlayback()
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseObjectUrl() {
|
||||||
|
if (!this.objectUrl) return
|
||||||
|
|
||||||
|
URL.revokeObjectURL(this.objectUrl)
|
||||||
|
this.objectUrl = ''
|
||||||
|
this.audio.src = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
private byteArrayToArrayBuffer(byteArray: Uint8Array): ArrayBuffer {
|
private byteArrayToArrayBuffer(byteArray: Uint8Array): ArrayBuffer {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user