Implemented Sequence.attachAudio()

This commit is contained in:
Aria Minaei 2021-09-03 14:02:02 +02:00
parent 3cd126186e
commit adcd1ce848
2 changed files with 135 additions and 81 deletions

View file

@ -3,6 +3,23 @@ import {privateAPI, setPrivateAPI} from '@theatre/core/privateAPIs'
import {defer} from '@theatre/shared/utils/defer' import {defer} from '@theatre/shared/utils/defer'
import type Sequence from './Sequence' import type Sequence from './Sequence'
import type {IPlaybackDirection, IPlaybackRange} 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 { export interface ISequence {
readonly type: 'Theatre_Sequence_PublicAPI' readonly type: 'Theatre_Sequence_PublicAPI'
@ -43,6 +60,12 @@ export interface ISequence {
* In a time-based sequence, this represents the current time. * In a time-based sequence, this represents the current time.
*/ */
position: number position: number
/**
*
* @param args
*/
attachAudio(args: IAttachAudioArgs): Promise<void>
} }
export default class TheatreSequence implements ISequence { export default class TheatreSequence implements ISequence {
@ -98,4 +121,76 @@ export default class TheatreSequence implements ISequence {
set position(position: number) { set position(position: number) {
privateAPI(this).position = position 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,
}
} }

View file

@ -64,15 +64,8 @@ export default class AudioPlaybackController implements IPlaybackController {
this.playing = true this.playing = true
const ticker = this._ticker const ticker = this._ticker
let lastTickerTime = ticker.time const startPos = this.getCurrentPosition()
const dur = range.end - range.start const iterationLength = 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.`,
)
}
if (direction !== 'normal') { if (direction !== 'normal') {
throw new InvalidArgumentError( 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 // if we're currently out of the range
this._updatePositionInState(range.start) 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 // if we're currently at the very end of the range
this._updatePositionInState(range.start) this._updatePositionInState(range.start)
} }
let countSoFar = 1
const deferred = defer<boolean>() const deferred = defer<boolean>()
const currentSource = this._audioContext.createBufferSource() const currentSource = this._audioContext.createBufferSource()
currentSource.buffer = this._decodedBuffer currentSource.buffer = this._decodedBuffer
currentSource.connect(this._mainGain) currentSource.connect(this._mainGain)
currentSource.playbackRate.value = rate
const audioStartTimeInSeconds = this._audioContext.currentTime const audioStartTimeInSeconds = this._audioContext.currentTime
const wait = 0 const wait = 0
const timeToRangeEnd = range.end - prevTime const timeToRangeEnd = range.end - startPos
if (iterationCount > 1) {
currentSource.loop = true
currentSource.loopStart = range.start
currentSource.loopEnd = range.end
}
currentSource.start( currentSource.start(
audioStartTimeInSeconds + wait, audioStartTimeInSeconds + wait,
prevTime - wait, startPos - wait,
iterationCount === 1 ? wait + timeToRangeEnd : undefined, wait + timeToRangeEnd,
) )
const initialTickerTime = ticker.time
let initialElapsedPos = this.getCurrentPosition() - range.start
const totalPlaybackLength = iterationLength * iterationCount
const tick = (tickerTime: number) => { const tick = (currentTickerTime: number) => {
const lastTime = this.getCurrentPosition() const elapsedTickerTime = Math.max(
const timeDiff = (tickerTime - lastTickerTime) * rate currentTickerTime - initialTickerTime,
lastTickerTime = tickerTime 0,
/* )
* I don't know why exactly this happens, but every 10 times or so, the first sequence.play({iterationCount: 1}), const elapsedTickerTimeInSeconds = elapsedTickerTime / 1000
* 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 const elapsedPos = Math.min(
* I'm using performance.now() the wrong way. elapsedTickerTimeInSeconds * rate + initialElapsedPos,
* Anyway, this seems like a working fix for it: totalPlaybackLength,
*/ )
if (timeDiff < 0) {
if (elapsedPos !== totalPlaybackLength) {
let currentIterationPos =
((elapsedPos / iterationLength) % 1) * iterationLength
this._updatePositionInState(currentIterationPos)
requestNextTick() requestNextTick()
return
}
const newTime = lastTime + timeDiff
if (newTime < range.start) {
if (countSoFar === iterationCount) {
this._updatePositionInState(range.start)
this.playing = false
cleanup()
deferred.resolve(true)
return
} 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 { } else {
this._updatePositionInState(newTime) this._updatePositionInState(range.end)
requestNextTick() this.playing = false
return cleanup()
deferred.resolve(true)
} }
} }