Last active
October 20, 2024 13:00
-
-
Save smontlouis/d18fdce47c9003d1d24eecf9524eb97e to your computer and use it in GitHub Desktop.
seemless / gapless loop crossfade solution with expo-av
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
AVPlaybackSource, | |
AVPlaybackStatus, | |
AVPlaybackStatusError, | |
AVPlaybackStatusSuccess, | |
Audio, | |
} from 'expo-av' | |
const FADE_DURATION = 4000 | |
const isPlaybackStatusSuccess = ( | |
s: AVPlaybackStatus | |
): s is AVPlaybackStatusSuccess => { | |
return (s as AVPlaybackStatusError)?.error === undefined | |
} | |
export class SeamlessLooper { | |
uri: AVPlaybackSource | |
sound1: Audio.Sound | |
sound2: Audio.Sound | |
isInitialized: boolean | |
isCurrentlyPlaying: boolean | |
isFading: boolean | |
soundIsPlaying: 'sound1' | 'sound2' | |
constructor(uri: AVPlaybackSource) { | |
this.uri = uri | |
this.sound1 = new Audio.Sound() | |
this.sound2 = new Audio.Sound() | |
this.isInitialized = false | |
this.isCurrentlyPlaying = false | |
this.isFading = false | |
this.soundIsPlaying = 'sound1' | |
} | |
async init() { | |
await this.sound1.loadAsync(this.uri) | |
await this.sound2.loadAsync(this.uri) | |
await this.sound1.setProgressUpdateIntervalAsync(1000) | |
await this.sound2.setProgressUpdateIntervalAsync(1000) | |
this.sound1.setOnPlaybackStatusUpdate( | |
this.handlePlaybackStatusUpdate.bind( | |
this, | |
this.sound1, | |
this.sound2, | |
'sound1' | |
) | |
) | |
this.sound2.setOnPlaybackStatusUpdate( | |
this.handlePlaybackStatusUpdate.bind( | |
this, | |
this.sound2, | |
this.sound1, | |
'sound2' | |
) | |
) | |
this.isInitialized = true | |
} | |
play = async () => { | |
if (!this.isInitialized) { | |
await this.init() | |
} | |
if (!this.isCurrentlyPlaying) { | |
await this[this.soundIsPlaying].playAsync() | |
this.isCurrentlyPlaying = true | |
} | |
} | |
pause = async () => { | |
if (this.isCurrentlyPlaying) { | |
this.sound1.pauseAsync() | |
this.sound2.pauseAsync() | |
this.isCurrentlyPlaying = false | |
} | |
} | |
destroy = async () => { | |
this.sound1.unloadAsync() | |
this.sound2.unloadAsync() | |
} | |
fade = ({ | |
sound, | |
fromVolume, | |
toVolume, | |
}: { | |
sound: Audio.Sound | |
fromVolume: number | |
toVolume: number | |
}) => | |
new Promise((resolve, reject) => { | |
this.isFading = true | |
let fadeTimeout: ReturnType<typeof setTimeout> | null = null | |
if (fadeTimeout) { | |
clearTimeout(fadeTimeout) | |
} | |
const start = Math.floor(fromVolume * 10) | |
const end = toVolume * 10 | |
let currVolume = start | |
const loop = async () => { | |
if (currVolume !== end) { | |
start < end ? currVolume++ : currVolume-- | |
await sound.setVolumeAsync(currVolume / 10) | |
fadeTimeout = setTimeout(loop, FADE_DURATION / 10) | |
} else { | |
// @ts-ignore | |
clearTimeout(this.fadeTimeout) | |
fadeTimeout = null | |
this.isFading = false | |
if (currVolume === 0) { | |
await sound.stopAsync() | |
} | |
console.log('Done fading') | |
resolve(true) | |
} | |
} | |
fadeTimeout = setTimeout(loop, 5) | |
}) | |
handlePlaybackStatusUpdate = async ( | |
currentSound: Audio.Sound, | |
nextSound: Audio.Sound, | |
soundName: 'sound1' | 'sound2', | |
status: AVPlaybackStatus | |
) => { | |
if (!isPlaybackStatusSuccess(status) || !this.isCurrentlyPlaying) { | |
return | |
} | |
if (!status.durationMillis) { | |
return | |
} | |
if ( | |
status.positionMillis > status.durationMillis - FADE_DURATION && | |
!this.isFading | |
) { | |
const nextSoundName = soundName === 'sound1' ? 'sound2' : 'sound1' | |
console.log(`Start fading out ${soundName} from ${status.volume} to 0`) | |
this.soundIsPlaying = nextSoundName | |
this.fade({ | |
sound: currentSound, | |
fromVolume: status.volume, | |
toVolume: 0, | |
}) | |
await nextSound.setVolumeAsync(0) | |
await nextSound.setPositionAsync(0) | |
await nextSound.playAsync() | |
const nextStatus = | |
(await nextSound.getStatusAsync()) as AVPlaybackStatusSuccess | |
console.log( | |
`Start fading in ${nextSoundName} from 0 | |
${nextStatus.volume} to 1` | |
) | |
this.fade({ | |
sound: nextSound, | |
fromVolume: nextStatus.volume, | |
toVolume: 1, | |
}) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Cool, looks similar to what i did. https://github.com/happyruss/expo-fader-loop/blob/master/AudioTrackComponent.js but unfortunately, it's not a true native seamless loop solution as it fades tracks in and out. Could work for things like noise though.