Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/core/src/asset/AssetType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export enum AssetType {
Font = "Font",
/** Source Font, include ttf、 otf and woff. */
SourceFont = "SourceFont",
/** AudioClip, inclue ogg, wav and mp3 */
Audio = "Audio",
/** Project asset. */
Project = "project"
}


67 changes: 67 additions & 0 deletions packages/core/src/audio/AudioClip.ts
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;
}
Comment on lines +21 to +23
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add null checks for AudioBuffer access

The getter methods directly access _audioBuffer without 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


/**
* 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
Copy link

Choose a reason for hiding this comment

The 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setAudioSource(value: AudioBuffer): void {
this._audioBuffer = value;
}
setAudioSource(value: AudioBuffer): void {
if (!value) {
throw new Error("AudioBuffer cannot be null or undefined");
}
this._audioBuffer = value;
}


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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix resource cleanup

Setting name to null is incorrect as it's declared as a string. Consider using an empty string instead.

  protected override _onDestroy(): void {
    super._onDestroy();
    this._audioBuffer = null;
-   this.name = null;
+   this.name = "";
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
protected override _onDestroy(): void {
super._onDestroy();
this._audioBuffer = null;
this.name = null;
}
protected override _onDestroy(): void {
super._onDestroy();
this._audioBuffer = null;
this.name = "";
}

}
50 changes: 50 additions & 0 deletions packages/core/src/audio/AudioManager.ts
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);
window.document.addEventListener("touchend", AudioManager._unlock, true);
window.document.addEventListener("touchstart", AudioManager._unlock, true);
}
Comment on lines +17 to +21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (AudioManager._context.state !== "running") {
window.document.addEventListener("pointerdown", AudioManager._unlock, true);
window.document.addEventListener("touchend", AudioManager._unlock, true);
window.document.addEventListener("touchstart", AudioManager._unlock, true);
}
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;
}

/**
* 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;
}
});
}
}
264 changes: 264 additions & 0 deletions packages/core/src/audio/AudioSource.ts
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 {
@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) {
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 {
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;
}
}
Copy link
Member

@GuoLei1990 GuoLei1990 Jun 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the unique value that is different from play from the perspective of usage


/** @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;
}
}
2 changes: 2 additions & 0 deletions packages/core/src/audio/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { AudioClip } from "./AudioClip";
export { AudioSource } from "./AudioSource";
Loading