Implemented Sequence.attachAudio()
This commit is contained in:
parent
3cd126186e
commit
adcd1ce848
2 changed files with 135 additions and 81 deletions
|
@ -3,6 +3,23 @@ import {privateAPI, setPrivateAPI} from '@theatre/core/privateAPIs'
|
|||
import {defer} from '@theatre/shared/utils/defer'
|
||||
import type Sequence from './Sequence'
|
||||
import type {IPlaybackDirection, IPlaybackRange} from './Sequence'
|
||||
import AudioPlaybackController from './playbackControllers/AudioPlaybackController'
|
||||
import coreTicker from '@theatre/core/coreTicker'
|
||||
|
||||
interface IAttachAudioArgs {
|
||||
/**
|
||||
* Either a URL to the audio file (eg "https://localhost/audio.mp3") or an instance of AudioBuffer
|
||||
*/
|
||||
source: string | AudioBuffer
|
||||
/**
|
||||
* An optional AudioContext. If not provided, one will be created.
|
||||
*/
|
||||
audioContext?: AudioContext
|
||||
/**
|
||||
* An AudioDestinationNode to feed the audio into. One will be created if not provided.
|
||||
*/
|
||||
destinationNode?: AudioDestinationNode
|
||||
}
|
||||
|
||||
export interface ISequence {
|
||||
readonly type: 'Theatre_Sequence_PublicAPI'
|
||||
|
@ -43,6 +60,12 @@ export interface ISequence {
|
|||
* In a time-based sequence, this represents the current time.
|
||||
*/
|
||||
position: number
|
||||
|
||||
/**
|
||||
*
|
||||
* @param args
|
||||
*/
|
||||
attachAudio(args: IAttachAudioArgs): Promise<void>
|
||||
}
|
||||
|
||||
export default class TheatreSequence implements ISequence {
|
||||
|
@ -98,4 +121,76 @@ export default class TheatreSequence implements ISequence {
|
|||
set position(position: number) {
|
||||
privateAPI(this).position = position
|
||||
}
|
||||
|
||||
async attachAudio(args: IAttachAudioArgs): Promise<void> {
|
||||
const {audioContext, destinationNode, decodedBuffer} =
|
||||
await resolveAudioBuffer(args)
|
||||
|
||||
const playbackController = new AudioPlaybackController(
|
||||
coreTicker,
|
||||
decodedBuffer,
|
||||
audioContext,
|
||||
destinationNode,
|
||||
)
|
||||
|
||||
privateAPI(this).replacePlaybackController(playbackController)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAudioBuffer(args: IAttachAudioArgs): Promise<{
|
||||
decodedBuffer: AudioBuffer
|
||||
audioContext: AudioContext
|
||||
destinationNode: AudioDestinationNode
|
||||
}> {
|
||||
const audioContext = args.audioContext || new AudioContext()
|
||||
|
||||
const decodedBufferDeferred = defer<AudioBuffer>()
|
||||
if (args.source instanceof AudioBuffer) {
|
||||
decodedBufferDeferred.resolve(args.source)
|
||||
} else if (typeof args.source !== 'string') {
|
||||
throw new Error(
|
||||
`Error validating arguments to sequence.attachAudio(). ` +
|
||||
`args.source must either be a string or an instance of AudioBuffer.`,
|
||||
)
|
||||
} else {
|
||||
let fetchResponse
|
||||
try {
|
||||
fetchResponse = await fetch(args.source)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw new Error(
|
||||
`Could not fetch '${args.source}'. Network error logged above.`,
|
||||
)
|
||||
}
|
||||
|
||||
let buffer
|
||||
try {
|
||||
buffer = await fetchResponse.arrayBuffer()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw new Error(`Could not read '${args.source}' as an arrayBuffer.`)
|
||||
}
|
||||
|
||||
audioContext.decodeAudioData(
|
||||
buffer,
|
||||
decodedBufferDeferred.resolve,
|
||||
decodedBufferDeferred.reject,
|
||||
)
|
||||
}
|
||||
|
||||
let decodedBuffer
|
||||
try {
|
||||
decodedBuffer = await decodedBufferDeferred.promise
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw new Error(`Could not decode ${args.source} as an audio file.`)
|
||||
}
|
||||
|
||||
const destinationNode = args.destinationNode || audioContext.destination
|
||||
|
||||
return {
|
||||
destinationNode,
|
||||
audioContext,
|
||||
decodedBuffer,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,15 +64,8 @@ export default class AudioPlaybackController implements IPlaybackController {
|
|||
this.playing = true
|
||||
|
||||
const ticker = this._ticker
|
||||
let lastTickerTime = ticker.time
|
||||
const dur = range.end - range.start
|
||||
const prevTime = this.getCurrentPosition()
|
||||
|
||||
if (rate !== 1.0) {
|
||||
throw new InvalidArgumentError(
|
||||
`Audio-controlled sequences can only have a playbackRate of 1.0. ${rate} given.`,
|
||||
)
|
||||
}
|
||||
const startPos = this.getCurrentPosition()
|
||||
const iterationLength = range.end - range.start
|
||||
|
||||
if (direction !== 'normal') {
|
||||
throw new InvalidArgumentError(
|
||||
|
@ -81,98 +74,64 @@ export default class AudioPlaybackController implements IPlaybackController {
|
|||
)
|
||||
}
|
||||
|
||||
if (prevTime < range.start || prevTime > range.end) {
|
||||
if (iterationCount !== 1) {
|
||||
throw new InvalidArgumentError(
|
||||
`Audio-controlled sequences can only have an iterationCount of 1 ` +
|
||||
`'${iterationCount}' given.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (startPos < range.start || startPos > range.end) {
|
||||
// if we're currently out of the range
|
||||
this._updatePositionInState(range.start)
|
||||
} else if (prevTime === range.end) {
|
||||
} else if (startPos === range.end) {
|
||||
// if we're currently at the very end of the range
|
||||
this._updatePositionInState(range.start)
|
||||
}
|
||||
|
||||
let countSoFar = 1
|
||||
|
||||
const deferred = defer<boolean>()
|
||||
|
||||
const currentSource = this._audioContext.createBufferSource()
|
||||
currentSource.buffer = this._decodedBuffer
|
||||
currentSource.connect(this._mainGain)
|
||||
currentSource.playbackRate.value = rate
|
||||
|
||||
const audioStartTimeInSeconds = this._audioContext.currentTime
|
||||
const wait = 0
|
||||
const timeToRangeEnd = range.end - prevTime
|
||||
|
||||
if (iterationCount > 1) {
|
||||
currentSource.loop = true
|
||||
currentSource.loopStart = range.start
|
||||
currentSource.loopEnd = range.end
|
||||
}
|
||||
const timeToRangeEnd = range.end - startPos
|
||||
|
||||
currentSource.start(
|
||||
audioStartTimeInSeconds + wait,
|
||||
prevTime - wait,
|
||||
iterationCount === 1 ? wait + timeToRangeEnd : undefined,
|
||||
startPos - wait,
|
||||
wait + timeToRangeEnd,
|
||||
)
|
||||
const initialTickerTime = ticker.time
|
||||
let initialElapsedPos = this.getCurrentPosition() - range.start
|
||||
const totalPlaybackLength = iterationLength * iterationCount
|
||||
|
||||
const tick = (currentTickerTime: number) => {
|
||||
const elapsedTickerTime = Math.max(
|
||||
currentTickerTime - initialTickerTime,
|
||||
0,
|
||||
)
|
||||
const elapsedTickerTimeInSeconds = elapsedTickerTime / 1000
|
||||
|
||||
const elapsedPos = Math.min(
|
||||
elapsedTickerTimeInSeconds * rate + initialElapsedPos,
|
||||
totalPlaybackLength,
|
||||
)
|
||||
|
||||
const tick = (tickerTime: number) => {
|
||||
const lastTime = this.getCurrentPosition()
|
||||
const timeDiff = (tickerTime - lastTickerTime) * rate
|
||||
lastTickerTime = tickerTime
|
||||
/*
|
||||
* I don't know why exactly this happens, but every 10 times or so, the first sequence.play({iterationCount: 1}),
|
||||
* the first call of tick() will have a timeDiff < 0.
|
||||
* This might be because of Spectre mitigation (they randomize performance.now() a bit), or it could be that
|
||||
* I'm using performance.now() the wrong way.
|
||||
* Anyway, this seems like a working fix for it:
|
||||
*/
|
||||
if (timeDiff < 0) {
|
||||
requestNextTick()
|
||||
return
|
||||
}
|
||||
const newTime = lastTime + timeDiff
|
||||
if (elapsedPos !== totalPlaybackLength) {
|
||||
let currentIterationPos =
|
||||
((elapsedPos / iterationLength) % 1) * iterationLength
|
||||
|
||||
if (newTime < range.start) {
|
||||
if (countSoFar === iterationCount) {
|
||||
this._updatePositionInState(range.start)
|
||||
this.playing = false
|
||||
cleanup()
|
||||
deferred.resolve(true)
|
||||
return
|
||||
this._updatePositionInState(currentIterationPos)
|
||||
requestNextTick()
|
||||
} else {
|
||||
countSoFar++
|
||||
const diff = (range.start - newTime) % dur
|
||||
|
||||
this._updatePositionInState(range.start + diff)
|
||||
requestNextTick()
|
||||
return
|
||||
}
|
||||
} else if (newTime === range.end) {
|
||||
this._updatePositionInState(range.end)
|
||||
if (countSoFar === iterationCount) {
|
||||
this.playing = false
|
||||
cleanup()
|
||||
deferred.resolve(true)
|
||||
return
|
||||
}
|
||||
requestNextTick()
|
||||
return
|
||||
} else if (newTime > range.end) {
|
||||
if (countSoFar === iterationCount) {
|
||||
this._updatePositionInState(range.end)
|
||||
this.playing = false
|
||||
cleanup()
|
||||
deferred.resolve(true)
|
||||
return
|
||||
} else {
|
||||
countSoFar++
|
||||
const diff = (newTime - range.end) % dur
|
||||
this._updatePositionInState(range.start + diff)
|
||||
|
||||
requestNextTick()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
this._updatePositionInState(newTime)
|
||||
requestNextTick()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue