-
Notifications
You must be signed in to change notification settings - Fork 56
feat(animation): add sound playback mode selector #3210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ee4adac
198d6c7
b2ed272
e63ed25
08f1e4c
ff7f894
de8fc2b
80c1f49
3e816f3
c97a5fe
88cb735
60fc411
c2cf1bf
d8e43b4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,26 +14,35 @@ import { onUnmounted, ref, watchEffect } from 'vue' | |
| import { registerPlayer as registerAudioPlayer } from '@/utils/player-registry' | ||
| import { useActivated } from '@/utils/utils' | ||
| import { Cancelled, capture } from '@/utils/exception' | ||
| import { AnimationSoundPlayback } from '@/models/spx/animation' | ||
| import type { Costume } from '@/models/spx/costume' | ||
| import type { Sound } from '@/models/spx/sound' | ||
| import { UILoading } from '@/components/ui' | ||
| import CostumesPlayer from '@/components/common/CostumesPlayer.vue' | ||
| import CheckerboardBackground from '../CheckerboardBackground.vue' | ||
| import MuteSwitch from './MuteSwitch.vue' | ||
|
|
||
| const props = defineProps<{ | ||
| costumes: Costume[] | ||
| sound: Sound | null | ||
| duration: number | ||
| }>() | ||
| const props = withDefaults( | ||
| defineProps<{ | ||
| costumes: Costume[] | ||
| sound: Sound | null | ||
| soundPlayback?: AnimationSoundPlayback | ||
| duration: number | ||
| }>(), | ||
| { | ||
| soundPlayback: AnimationSoundPlayback.Once | ||
| } | ||
| ) | ||
|
|
||
| const registered = registerAudioPlayer(() => setMuted(true)) | ||
| const audioRef = ref<HTMLAudioElement | null>(null) | ||
| const audios = new Set<HTMLAudioElement>() | ||
| const mutedRef = ref(true) | ||
| function setMuted(muted: boolean) { | ||
| mutedRef.value = muted | ||
| if (audioRef.value != null) { | ||
| audioRef.value.muted = muted | ||
| for (const audio of audios) { | ||
| audio.muted = muted | ||
| } | ||
| if (props.sound != null) { | ||
| if (muted) registered.onStopped() | ||
| else registered.onStart() | ||
| } | ||
|
|
@@ -50,25 +59,70 @@ async function loadAudio(sound: Sound, signal: AbortSignal) { | |
| return audio | ||
| } | ||
|
|
||
| function playAudio(audio: HTMLAudioElement, duration: number, signal: AbortSignal) { | ||
| audio.muted = mutedRef.value | ||
| audioRef.value = audio | ||
| const playFromStart = () => { | ||
| try { | ||
| audio.currentTime = 0 | ||
| audio.play() | ||
| } catch { | ||
| // We can get an error from `play()` if the sound is not loaded yet | ||
| // or if the sound is not allowed to play | ||
| } | ||
| // Limit the number of concurrently playing audio instances to prevent overwhelming the browser and causing performance issues or crashes. | ||
| const concurrentAudioLimit = 10 | ||
|
|
||
| /** | ||
| * Play the audio with `playback: AnimationSoundPlayback.Once`. | ||
| * The sound is triggered once per animation cycle (duration). | ||
| * If the previous sound is still playing when the next cycle starts, it will keep playing. | ||
| * In that case, multiple sound instances can overlap. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NIP: 意思是如果 animation duration 比 sound 时长短,就会有同一个 sound 的不同时段叠加播放,极限情况可能叠加 10 遍?这样设计是合理的吗
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
是的
在编辑器这边限制为 10,在 spx 运行的时候我不确定,可能没有限制
它针对的是某个动作,其音效比视觉上的时间略久的情况;比如人物做一次攻击,1s 就做完动作了,但是对应的声音有可能是 1.2s,这后边的 0.2s 有可能是动作的尾音,这从效果上是合理的。如果人物很快又做一次攻击动作,那么两个声音发生重叠也是预期的。 因此一般来说选择
所以实际上不会有问题 如果这里真的达到了到 10 的限制,那大概率是配错了,我没想到有什么合理的场景,是需要为一个 animation 绑一个时长是它 10 倍的 |
||
| */ | ||
| function playAudioWithPlaybackOnce(audio: HTMLAudioElement, duration: number, signal: AbortSignal) { | ||
| function playOnce() { | ||
| // Create a new audio element to play the sound so it plays independently | ||
| const newAudio = new Audio(audio.src) | ||
| newAudio.muted = mutedRef.value | ||
| audios.add(newAudio) | ||
| newAudio.addEventListener('ended', () => audios.delete(newAudio), { once: true }) | ||
| signal.addEventListener( | ||
| 'abort', | ||
| () => { | ||
| newAudio.pause() | ||
| audios.delete(newAudio) | ||
| }, | ||
| { once: true } | ||
| ) | ||
| setTimeout( | ||
| () => { | ||
| // Stop the newAudio after some time to prevent too many overlapping audios | ||
| newAudio.pause() | ||
| audios.delete(newAudio) | ||
| }, | ||
| duration * 1000 * concurrentAudioLimit | ||
| ) | ||
| newAudio.play() | ||
| } | ||
| playOnce() | ||
| const timer = setInterval(playOnce, duration * 1000) | ||
| signal.addEventListener('abort', () => clearInterval(timer), { once: true }) | ||
| } | ||
|
|
||
| /** | ||
| * Play the audio with `playback: AnimationSoundPlayback.Loop`. | ||
| * The sound loops continuously within each animation cycle. | ||
| * When a new cycle starts, playback is reset to the beginning. | ||
| * This prevents overlapping between cycles. | ||
| */ | ||
| function playAudioWithPlaybackLoop(audio: HTMLAudioElement, duration: number, signal: AbortSignal) { | ||
| function playFromStart() { | ||
| audio.currentTime = 0 | ||
| audio.play() | ||
| } | ||
| audio.loop = true | ||
| audio.muted = mutedRef.value | ||
| audios.add(audio) | ||
| signal.addEventListener( | ||
| 'abort', | ||
| () => { | ||
| audio.pause() | ||
| audios.delete(audio) | ||
| }, | ||
| { once: true } | ||
| ) | ||
| playFromStart() | ||
| const timer = setInterval(playFromStart, duration * 1000) | ||
| signal.addEventListener('abort', async () => { | ||
| clearInterval(timer) | ||
| audio.pause() | ||
| audioRef.value = null | ||
| }) | ||
| signal.addEventListener('abort', () => clearInterval(timer), { once: true }) | ||
| } | ||
|
|
||
| const activatedRef = useActivated() | ||
|
|
@@ -93,15 +147,21 @@ watchEffect(async () => { | |
| if (costumesPlayer == null) return | ||
| try { | ||
| const signal = ctrl.signal | ||
| const { costumes, sound, duration } = props | ||
| const { costumes, sound, soundPlayback, duration } = props | ||
| const [, audio] = await Promise.all([ | ||
| costumesPlayer.load(costumes, duration, signal), | ||
| sound != null ? loadAudio(sound, signal) : null | ||
| ]) | ||
| signal.throwIfAborted() | ||
|
|
||
| costumesPlayer.play(signal) | ||
| if (audio != null) playAudio(audio, duration, signal) | ||
| if (audio != null) { | ||
| if (soundPlayback === AnimationSoundPlayback.Once) { | ||
| playAudioWithPlaybackOnce(audio, duration, signal) | ||
| } else if (soundPlayback === AnimationSoundPlayback.Loop) { | ||
| playAudioWithPlaybackLoop(audio, duration, signal) | ||
| } | ||
| } | ||
| } catch (e) { | ||
| ctrl.abort(e) | ||
| capture(e, 'load and play animation failed') | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
这里是特意判断
props.sound是否为空而不是判断audios是否为空;因为audios为空有可能是在两个 audio 播放的间隙(playback: Once的情况下),并不代表没有声音