Created
December 16, 2024 14:46
-
-
Save smontlouis/a29ac563e5a9c3b429d805e07a09f553 to your computer and use it in GitHub Desktop.
expo-av audio fade
This file contains hidden or 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 { | |
AVPlaybackStatus, | |
AVPlaybackStatusError, | |
AVPlaybackStatusSuccess, | |
Audio, | |
} from 'expo-av' | |
import { Platform } from 'react-native' | |
const FADE_DURATION = 600 | |
const isPlaybackStatusSuccess = ( | |
s: AVPlaybackStatus | |
): s is AVPlaybackStatusSuccess => { | |
return (s as AVPlaybackStatusError)?.error === undefined | |
} | |
export class FadingLooper { | |
uri: string | |
sound1: Audio.Sound | |
sound2: Audio.Sound | |
isInitialized: boolean | |
isCurrentlyPlaying: boolean | |
isFading: boolean | |
soundIsPlaying: 'sound1' | 'sound2' | |
volume: number | |
private fadeOutPromise: Promise<void> | null = null | |
private lastAction: 'play' | 'pause' | null = null | |
private actionTimestamp: number = 0 | |
constructor(uri: string, volume: number) { | |
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' | |
// round to 1 decimal places | |
this.volume = Math.round(volume * 10) / 10 | |
} | |
async init() { | |
try { | |
await this.sound1.loadAsync({ uri: this.uri }) | |
await this.sound2.loadAsync({ uri: this.uri }) | |
await this.sound1.setProgressUpdateIntervalAsync(200) | |
await this.sound2.setProgressUpdateIntervalAsync(200) | |
await this.sound1.setVolumeAsync(this.volume) | |
await this.sound2.setVolumeAsync(this.volume) | |
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 | |
} catch (error) { | |
console.warn('FadingLooper init error:', error) | |
} | |
} | |
play = async () => { | |
try { | |
const now = Date.now() | |
this.lastAction = 'play' | |
this.actionTimestamp = now | |
if (!this.isInitialized) { | |
await this.init() | |
} | |
// Attendre que le fadeOut soit terminé avant de jouer | |
if (this.fadeOutPromise) { | |
await this.fadeOutPromise | |
} | |
// Vérifier si une autre action n'a pas été demandée entre temps | |
if (this.lastAction !== 'play' || this.actionTimestamp !== now) { | |
return | |
} | |
if (!this.isCurrentlyPlaying) { | |
await this[this.soundIsPlaying].playAsync() | |
this.isCurrentlyPlaying = true | |
} | |
} catch (error) { | |
console.warn('FadingLooper play error:', error) | |
} | |
} | |
setVolume = async (volume: number) => { | |
try { | |
const status = (await this[ | |
this.soundIsPlaying | |
].getStatusAsync()) as AVPlaybackStatusSuccess | |
if ((!this.isFading || this.isInitialized) && status.isLoaded) { | |
await this.sound1.setVolumeAsync(volume) | |
await this.sound2.setVolumeAsync(volume) | |
} | |
this.volume = volume | |
} catch (error) { | |
console.warn('FadingLooper setVolume error:', error) | |
} | |
} | |
pause = async () => { | |
try { | |
const now = Date.now() | |
this.lastAction = 'pause' | |
this.actionTimestamp = now | |
const status = (await this[ | |
this.soundIsPlaying | |
].getStatusAsync()) as AVPlaybackStatusSuccess | |
if (status.isPlaying) { | |
this.fadeOutPromise = this.fadeOutAndPause( | |
this[this.soundIsPlaying], | |
now | |
) | |
await this.fadeOutPromise | |
this.fadeOutPromise = null | |
this.isCurrentlyPlaying = false | |
} | |
} catch (error) { | |
console.warn('FadingLooper pause error:', error) | |
} | |
} | |
fadeOutAndPause = async (sound: Audio.Sound, timestamp: number) => { | |
try { | |
if (Platform.OS === 'android') { | |
await sound.pauseAsync() | |
return | |
} | |
const fadeSteps = 10 | |
const stepDuration = FADE_DURATION / fadeSteps | |
const volumeStep = this.volume / fadeSteps | |
for (let i = fadeSteps; i > 0; i--) { | |
// Vérifier si une nouvelle action n'a pas été demandée | |
if (this.actionTimestamp !== timestamp) { | |
return | |
} | |
const newVolume = volumeStep * i | |
await sound.setVolumeAsync(newVolume) | |
await new Promise((resolve) => setTimeout(resolve, stepDuration)) | |
} | |
// Vérifier une dernière fois si l'action est toujours valide | |
if (this.actionTimestamp === timestamp) { | |
await sound.pauseAsync() | |
} | |
} catch (error) { | |
console.warn('FadingLooper fadeOutAndPause error:', error) | |
} | |
} | |
destroy = async () => { | |
try { | |
this.sound1.unloadAsync() | |
this.sound2.unloadAsync() | |
} catch (error) { | |
console.warn('FadingLooper destroy error:', error) | |
} | |
} | |
fade = async ({ | |
name, | |
sound, | |
fromVolume, | |
toVolume, | |
}: { | |
name: 'sound1' | 'sound2' | |
sound: Audio.Sound | |
fromVolume: number | |
toVolume: number | |
}) => { | |
try { | |
if (Platform.OS === 'android') { | |
if (fromVolume > toVolume) { | |
await sound.setVolumeAsync(0) | |
await sound.stopAsync() | |
} else { | |
await sound.setVolumeAsync(toVolume) | |
} | |
return | |
} | |
const fadeSteps = 10 | |
const stepDuration = FADE_DURATION / fadeSteps | |
const volumeDiff = toVolume - fromVolume | |
const volumeStep = volumeDiff / fadeSteps | |
for (let i = 0; i <= fadeSteps; i++) { | |
const newVolume = fromVolume + volumeStep * i | |
try { | |
await sound.setVolumeAsync(newVolume) | |
await new Promise((resolve) => setTimeout(resolve, stepDuration)) | |
} catch (e) { | |
console.log('error for', name, newVolume, toVolume) | |
} | |
} | |
if (toVolume === 0) { | |
await sound.stopAsync() | |
} | |
} catch (error) { | |
console.warn('FadingLooper fade error:', error) | |
} | |
} | |
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 | |
) { | |
this.isFading = true | |
const nextSoundName = soundName === 'sound1' ? 'sound2' : 'sound1' | |
this.soundIsPlaying = nextSoundName | |
try { | |
await Promise.all([ | |
this.fade({ | |
name: soundName, | |
sound: currentSound, | |
fromVolume: this.volume, | |
toVolume: 0, | |
}), | |
(async () => { | |
await nextSound.setVolumeAsync(0) | |
await nextSound.setPositionAsync(0) | |
await nextSound.playAsync() | |
return this.fade({ | |
name: nextSoundName, | |
sound: nextSound, | |
fromVolume: 0, | |
toVolume: this.volume, | |
}) | |
})(), | |
]) | |
this.isFading = false | |
} catch (error) { | |
console.error('Fade error:', error) | |
this.isFading = false | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment