-
-
Notifications
You must be signed in to change notification settings - Fork 397
Feat: setup audio component #1587
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
4613a78
4a94c9d
2c0d994
69eeb8f
69b0fab
f4e2474
524f93c
fe318a4
98d8fd7
aad876d
319a125
062a4c4
08f957c
010b4e3
843ce26
e74db44
0ffdf1b
3e549b3
36ba9de
03f5f3c
d841d41
05e4014
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 | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,67 @@ | ||||||||||||||||||||||
| import { Engine } from "../Engine"; | ||||||||||||||||||||||
| import { ReferResource } from "../asset/ReferResource"; | ||||||||||||||||||||||
| import { AudioManager } from "./AudioManager"; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Audio Clip | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export class AudioClip extends ReferResource { | ||||||||||||||||||||||
| /** @internal */ | ||||||||||||||||||||||
| _context: AudioContext; | ||||||||||||||||||||||
| private _audioBuffer: AudioBuffer | null = null; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Name of clip. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| name: string; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Number of discrete audio channels. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| get channels(): number { | ||||||||||||||||||||||
| return this._audioBuffer.numberOfChannels; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Sample rate, in samples per second. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| get sampleRate(): number { | ||||||||||||||||||||||
| return this._audioBuffer.sampleRate; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Duration, in seconds. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| get duration(): Readonly<number> { | ||||||||||||||||||||||
| return this._audioBuffer.duration; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Get the clip's audio buffer. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| getAudioSource(): AudioBuffer { | ||||||||||||||||||||||
| return this._audioBuffer; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Set audio buffer for the clip. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| setAudioSource(value: AudioBuffer): void { | ||||||||||||||||||||||
| this._audioBuffer = value; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+49
to
+51
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. 🛠️ Refactor suggestion Add validation in setAudioSource The method should validate the input buffer before assignment. setAudioSource(value: AudioBuffer): void {
+ if (!value) {
+ throw new Error("AudioBuffer cannot be null or undefined");
+ }
this._audioBuffer = value;
}📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| constructor(engine: Engine, name: string = "") { | ||||||||||||||||||||||
| super(engine); | ||||||||||||||||||||||
| this.name = name; | ||||||||||||||||||||||
| this._context = AudioManager.context; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * @internal | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| protected override _onDestroy(): void { | ||||||||||||||||||||||
| super._onDestroy(); | ||||||||||||||||||||||
| this._audioBuffer = null; | ||||||||||||||||||||||
| this.name = null; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+62
to
+66
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. Fix resource cleanup Setting protected override _onDestroy(): void {
super._onDestroy();
this._audioBuffer = null;
- this.name = null;
+ this.name = "";
}📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,50 @@ | ||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * @internal | ||||||||||||||||||||||||
| * Audio Manager | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| export class AudioManager { | ||||||||||||||||||||||||
| private static _context: AudioContext; | ||||||||||||||||||||||||
| private static _gainNode: GainNode; | ||||||||||||||||||||||||
| private static _unlocked: boolean = false; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Audio context | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| static get context(): AudioContext { | ||||||||||||||||||||||||
| if (!AudioManager._context) { | ||||||||||||||||||||||||
| AudioManager._context = new window.AudioContext(); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| if (AudioManager._context.state !== "running") { | ||||||||||||||||||||||||
| window.document.addEventListener("pointerdown", AudioManager._unlock, true); | ||||||||||||||||||||||||
GuoLei1990 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||
| window.document.addEventListener("touchend", AudioManager._unlock, true); | ||||||||||||||||||||||||
| window.document.addEventListener("touchstart", AudioManager._unlock, true); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
Comment on lines
+17
to
+21
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. Optimize event listener management Event listeners are added every time the context is accessed when not running, which could lead to multiple redundant listeners. + private static _listenersAdded: boolean = false;
static get context(): AudioContext {
if (!AudioManager._context) {
AudioManager._context = new window.AudioContext();
}
- if (AudioManager._context.state !== "running") {
+ if (AudioManager._context.state !== "running" && !AudioManager._listenersAdded) {
window.document.addEventListener("pointerdown", AudioManager._unlock, true);
window.document.addEventListener("touchend", AudioManager._unlock, true);
window.document.addEventListener("touchstart", AudioManager._unlock, true);
+ AudioManager._listenersAdded = true;
}
return AudioManager._context;
}📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||
| return AudioManager._context; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Audio GainNode. | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| static get gainNode(): GainNode { | ||||||||||||||||||||||||
| if (!AudioManager._gainNode) { | ||||||||||||||||||||||||
| const gain = AudioManager.context.createGain(); | ||||||||||||||||||||||||
| gain.connect(AudioManager.context.destination); | ||||||||||||||||||||||||
| AudioManager._gainNode = gain; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| return AudioManager._gainNode; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| private static _unlock(): void { | ||||||||||||||||||||||||
| if (AudioManager._unlocked) { | ||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| AudioManager._context.resume().then(() => { | ||||||||||||||||||||||||
| if (AudioManager._context.state === "running") { | ||||||||||||||||||||||||
| window.document.removeEventListener("pointerdown", AudioManager._unlock, true); | ||||||||||||||||||||||||
| window.document.removeEventListener("touchend", AudioManager._unlock, true); | ||||||||||||||||||||||||
| window.document.removeEventListener("touchstart", AudioManager._unlock, true); | ||||||||||||||||||||||||
| AudioManager._unlocked = true; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
GuoLei1990 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,264 @@ | ||
| import { Component } from "../Component"; | ||
| import { Entity } from "../Entity"; | ||
| import { AudioClip } from "./AudioClip"; | ||
| import { AudioManager } from "./AudioManager"; | ||
| import { deepClone, ignoreClone } from "../clone/CloneManager"; | ||
|
|
||
| /** | ||
| * Audio Source Component | ||
| */ | ||
| export class AudioSource extends Component { | ||
GuoLei1990 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| @ignoreClone | ||
| private _isPlaying: boolean = false; | ||
|
|
||
| @ignoreClone | ||
| private _clip: AudioClip; | ||
| @deepClone | ||
| private _gainNode: GainNode; | ||
| @ignoreClone | ||
| private _sourceNode: AudioBufferSourceNode | null = null; | ||
|
|
||
| @deepClone | ||
| private _pausedTime: number = -1; | ||
| @ignoreClone | ||
| private _absoluteStartTime: number = -1; | ||
|
|
||
| @deepClone | ||
| private _volume: number = 1; | ||
| @deepClone | ||
| private _lastVolume: number = 1; | ||
| @deepClone | ||
| private _playbackRate: number = 1; | ||
| @deepClone | ||
| private _loop: boolean = false; | ||
|
|
||
| /** If set to true, the audio component automatically begins to play on startup. */ | ||
| playOnEnabled: boolean = true; | ||
|
|
||
| /** | ||
| * The audio cilp to play. | ||
| */ | ||
| get clip(): AudioClip { | ||
| return this._clip; | ||
| } | ||
|
|
||
| set clip(value: AudioClip) { | ||
| const lastClip = this._clip; | ||
| if (lastClip !== value) { | ||
| lastClip && lastClip._addReferCount(-1); | ||
| this._clip = value; | ||
|
|
||
| if (this.playOnEnabled && this.enabled) { | ||
| this.play(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Whether the clip playing right now (Read Only). | ||
| */ | ||
| get isPlaying(): boolean { | ||
| return this._isPlaying; | ||
| } | ||
|
|
||
| /** | ||
| * The volume of the audio source. 1.0 is origin volume. | ||
| */ | ||
| get volume(): number { | ||
| return this._volume; | ||
| } | ||
|
|
||
| set volume(value: number) { | ||
| this._volume = value; | ||
| if (this._isPlaying) { | ||
| this._gainNode.gain.setValueAtTime(value, AudioManager.context.currentTime); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * The playback rate of the audio source, 1.0 is normal playback speed. | ||
| */ | ||
| get playbackRate(): number { | ||
| return this._playbackRate; | ||
| } | ||
|
|
||
| set playbackRate(value: number) { | ||
GuoLei1990 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| this._playbackRate = value; | ||
| if (this._isPlaying) { | ||
| this._sourceNode.playbackRate.value = this._playbackRate; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Mutes / Unmutes the AudioSource. | ||
| * Mute sets volume as 0, Un-Mute restore volume. | ||
| */ | ||
| get mute(): boolean { | ||
| return this.volume === 0; | ||
| } | ||
|
|
||
| set mute(value: boolean) { | ||
| if (value) { | ||
| this._lastVolume = this.volume; | ||
| this.volume = 0; | ||
| } else { | ||
| this.volume = this._lastVolume; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Whether the audio clip looping. Default false. | ||
| */ | ||
| get loop(): boolean { | ||
| return this._loop; | ||
| } | ||
|
|
||
| set loop(value: boolean) { | ||
| if (value !== this._loop) { | ||
| this._loop = value; | ||
|
|
||
| if (this._isPlaying) { | ||
| this._sourceNode.loop = this._loop; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Playback position in seconds. | ||
| */ | ||
| get time(): number { | ||
| if (this._isPlaying) { | ||
| return this._pausedTime > 0 | ||
| ? this.engine.time.elapsedTime - this._absoluteStartTime + this._pausedTime | ||
| : this.engine.time.elapsedTime - this._absoluteStartTime; | ||
| } else { | ||
| return this._pausedTime > 0 ? this._pausedTime : 0; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Plays the clip. | ||
| */ | ||
| play(): void { | ||
| if (!this._canPlay()) return; | ||
| if (this._isPlaying) return; | ||
| this._initSourceNode(); | ||
| this._startPlayback(this._pausedTime > 0 ? this._pausedTime : 0); | ||
|
|
||
| this._pausedTime = -1; | ||
| this._isPlaying = true; | ||
| } | ||
|
|
||
| /** | ||
| * Stops playing the clip. | ||
| */ | ||
| stop(): void { | ||
GuoLei1990 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if (this._isPlaying) { | ||
| this._clearSourceNode(); | ||
|
|
||
| this._isPlaying = false; | ||
| this._pausedTime = -1; | ||
| this._absoluteStartTime = -1; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Pauses playing the clip. | ||
| */ | ||
| pause(): void { | ||
| if (this._isPlaying) { | ||
| this._clearSourceNode(); | ||
|
|
||
| this._isPlaying = false; | ||
| this._pausedTime = this.time; | ||
| } | ||
| } | ||
|
Member
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. What is the unique value that is different from |
||
|
|
||
| /** @internal */ | ||
| constructor(entity: Entity) { | ||
| super(entity); | ||
| this._onPlayEnd = this._onPlayEnd.bind(this); | ||
|
|
||
| this._gainNode = AudioManager.context.createGain(); | ||
| this._gainNode.connect(AudioManager.gainNode); | ||
| } | ||
|
|
||
| /** | ||
| * @internal | ||
| */ | ||
| override _onEnable(): void { | ||
| this.playOnEnabled && this.play(); | ||
| } | ||
|
|
||
| /** | ||
| * @internal | ||
| */ | ||
| override _onDisable(): void { | ||
| this.pause(); | ||
| } | ||
|
|
||
| /** | ||
| * @internal | ||
| */ | ||
| protected override _onDestroy(): void { | ||
| super._onDestroy(); | ||
| if (this._clip) { | ||
| this._clip._addReferCount(-1); | ||
| this._clip = null; | ||
| } | ||
| } | ||
|
|
||
| private _onPlayEnd(): void { | ||
| this.stop(); | ||
| } | ||
|
|
||
| private _initSourceNode(): void { | ||
| this._clearSourceNode(); | ||
| this._sourceNode = AudioManager.context.createBufferSource(); | ||
|
|
||
| const { _sourceNode: sourceNode } = this; | ||
| sourceNode.buffer = this._clip.getAudioSource(); | ||
| sourceNode.onended = this._onPlayEnd; | ||
| sourceNode.playbackRate.value = this._playbackRate; | ||
| sourceNode.loop = this._loop; | ||
|
|
||
| this._gainNode.gain.setValueAtTime(this._volume, AudioManager.context.currentTime); | ||
| sourceNode.connect(this._gainNode); | ||
| } | ||
|
|
||
| private _clearSourceNode(): void { | ||
| if (!this._sourceNode) return; | ||
|
|
||
| this._sourceNode.stop(); | ||
| this._sourceNode.disconnect(); | ||
| this._sourceNode.onended = null; | ||
| this._sourceNode = null; | ||
| } | ||
|
|
||
| private _startPlayback(startTime: number): void { | ||
| this._sourceNode.start(0, startTime); | ||
| this._absoluteStartTime = | ||
| this._absoluteStartTime > 0 ? this.engine.time.elapsedTime - startTime : this.engine.time.elapsedTime; | ||
| } | ||
|
|
||
| private _canPlay(): boolean { | ||
| return this._isValidClip() && this._isAudioContextRunning(); | ||
| } | ||
|
|
||
| private _isValidClip(): boolean { | ||
| if (!this._clip || this._clip.duration <= 0) { | ||
| return false; | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| private _isAudioContextRunning(): boolean { | ||
| if (AudioManager.context.state !== "running") { | ||
| console.warn("AudioContext is not running. User interaction required."); | ||
| return false; | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export { AudioClip } from "./AudioClip"; | ||
| export { AudioSource } from "./AudioSource"; |
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.
Add null checks for AudioBuffer access
The getter methods directly access
_audioBufferwithout checking if it's null, which could lead to runtime errors.get channels(): number { + if (!this._audioBuffer) { + return 0; + } return this._audioBuffer.numberOfChannels; } get sampleRate(): number { + if (!this._audioBuffer) { + return 0; + } return this._audioBuffer.sampleRate; } get duration(): Readonly<number> { + if (!this._audioBuffer) { + return 0; + } return this._audioBuffer.duration; }Also applies to: 28-30, 35-37