Skip to content

Instantly share code, notes, and snippets.

@smontlouis
Last active October 20, 2024 13:00
Show Gist options
  • Save smontlouis/d18fdce47c9003d1d24eecf9524eb97e to your computer and use it in GitHub Desktop.
Save smontlouis/d18fdce47c9003d1d24eecf9524eb97e to your computer and use it in GitHub Desktop.
seemless / gapless loop crossfade solution with expo-av
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,
})
}
}
}
@rdobda-gaia
Copy link

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment