diff --git a/examples/index.html b/examples/index.html index d65a244..ab3aee0 100644 --- a/examples/index.html +++ b/examples/index.html @@ -181,8 +181,11 @@

const sound = PIXI.sound.utils.sineTone(523.251); -sound.play(); +
const sound = PIXI.sound.utils.sineTone(523.251, 3);
+sound.play({
+    fadeIn: 1,
+    fadeOut: 1
+});
diff --git a/src/Sound.ts b/src/Sound.ts index 660c7a9..3408aed 100644 --- a/src/Sound.ts +++ b/src/Sound.ts @@ -30,6 +30,9 @@ export interface PlayOptions { end?: number; speed?: number; loop?: boolean; + fadeIn?: number; + fadeOut?: number; + sprite?: string; complete?: CompleteCallback; loaded?: LoadedCallback; } @@ -39,8 +42,9 @@ export interface PlayOptions { * @callback PIXI.sound.Sound~loadedCallback * @param {Error} err The callback error. * @param {PIXI.sound.Sound} sound The instance of new sound. + * @param {PIXI.sound.SoundInstance} instance The instance of auto-played sound. */ -export declare type LoadedCallback = (err: Error, sound?: Sound) => void; +export declare type LoadedCallback = (err: Error, sound?: Sound, instance?: SoundInstance) => void; /** * Callback when sound is completed. @@ -90,22 +94,6 @@ export default class Sound */ public autoPlay: boolean; - /** - * Callback when finished playing. - * @name PIXI.sound.Sound#complete - * @type {PIXI.sound.Sound~completeCallback} - * @default false - */ - public complete: CompleteCallback; - - /** - * Callback when load is finished. - * @type {PIXI.sound.Sound~loadedCallback} - * @name PIXI.sound.Sound#loaded - * @readOnly - */ - public loaded: LoadedCallback; - /** * `true` to disallow playing multiple layered instances at once. * @name PIXI.sound.Sound#singleInstance @@ -148,6 +136,22 @@ export default class Sound */ public useXHR: boolean; + /** + * The options when auto-playing. + * @name PIXI.sound.Sound#_autoPlayOptions + * @type {PlayOptions} + * @private + */ + private _autoPlayOptions: PlayOptions; + + /** + * The internal volume. + * @name PIXI.sound.Sound#_volume + * @type {Number} + * @private + */ + private _volume: number; + /** * Reference to the sound context. * @name PIXI.sound.Sound#_context @@ -253,13 +257,14 @@ export default class Sound this._instances = []; this._sprites = {}; + const complete = options.complete; + this._autoPlayOptions = complete ? { complete } : null; + this.isLoaded = false; this.isPlaying = false; this.autoPlay = options.autoPlay; this.singleInstance = options.singleInstance; this.preload = options.preload || this.autoPlay; - this.complete = options.complete; - this.loaded = options.loaded; this.src = options.src; this.srcBuffer = options.srcBuffer; this.useXHR = options.useXHR; @@ -274,7 +279,7 @@ export default class Sound if (this.preload) { - this._beginPreload(); + this._beginPreload(options.loaded); } } @@ -294,8 +299,6 @@ export default class Sound this.removeSprites(); this._sprites = null; - this.complete = null; - this.loaded = null; this.srcBuffer = null; this._removeInstances(); @@ -331,11 +334,11 @@ export default class Sound */ public get volume(): number { - return this._nodes.gain.gain.value; + return this._volume; } public set volume(volume: number) { - this._nodes.gain.gain.value = volume; + this._volume = this._nodes.gain.gain.value = volume; } /** @@ -528,9 +531,11 @@ export default class Sound * @param {Number} data.end Time to end playing in seconds. * @param {Number} [data.speed] Override default speed, default to the Sound's speed setting. * @param {PIXI.sound.Sound~completeCallback} [callback] Callback when completed. - * @return {PIXI.sound.SoundInstance} Current playing instance. + * @return {PIXI.sound.SoundInstance|Promise} The sound instance, + * this cannot be reused after it is done playing. Returns a Promise if the sound + * has not yet loaded. */ - public play(alias: string, callback?: CompleteCallback): SoundInstance; + public play(alias: string, callback?: CompleteCallback): SoundInstance|Promise; /** * Plays the sound. @@ -538,33 +543,32 @@ export default class Sound * @param {PIXI.sound.Sound~completeCallback|object} options Either completed function or play options. * @param {Number} [options.start=0] Time when to play the sound in seconds. * @param {Number} [options.end] Time to end playing in seconds. + * @param {String} [options.sprite] Play a named sprite. Will override end, start and speed options. + * @param {Number} [options.fadeIn] Amount of time to fade in volume. If less than 10, + * considered seconds or else milliseconds. + * @param {Number} [options.fadeOut] Amount of time to fade out volume. If less than 10, + * considered seconds or else milliseconds. * @param {Number} [options.speed] Override default speed, default to the Sound's speed setting. * @param {Boolean} [options.loop] Override default loop, default to the Sound's loop setting. * @param {PIXI.sound.Sound~completeCallback} [options.complete] Callback when complete. * @param {PIXI.sound.Sound~loadedCallback} [options.loaded] If the sound isn't already preloaded, callback when * the audio has completely finished loading and decoded. - * @return {PIXI.sound.SoundInstance} Current playing instance. + * @return {PIXI.sound.SoundInstance|Promise} The sound instance, + * this cannot be reused after it is done playing. Returns a Promise if the sound + * has not yet loaded. */ - public play(source?: PlayOptions|CompleteCallback, callback?: CompleteCallback): SoundInstance; + public play(source?: PlayOptions|CompleteCallback, + callback?: CompleteCallback): SoundInstance|Promise; // Overloaded function - public play(source?: any, callback?: CompleteCallback): SoundInstance + public play(source?: any, complete?: CompleteCallback): SoundInstance|Promise { let options: PlayOptions; if (typeof source === "string") { - const alias: string = source as string; - // @if DEBUG - console.assert(!!this._sprites[alias], `Alias ${alias} is not available`); - // @endif - const sprite: SoundSprite = this._sprites[alias]; - options = { - start: sprite.start, - end: sprite.end, - speed: sprite.speed, - complete: callback, - }; + const sprite: string = source as string; + options = { sprite, complete }; } else if (typeof source === "function") { @@ -579,9 +583,26 @@ export default class Sound options = Object.assign({ complete: null, loaded: null, + sprite: null, start: 0, + fadeIn: 0, + fadeOut: 0, }, options || {}); + // A sprite is specified, add the options + if (options.sprite) + { + const alias: string = options.sprite; + // @if DEBUG + console.assert(!!this._sprites[alias], `Alias ${alias} is not available`); + // @endif + const sprite: SoundSprite = this._sprites[alias]; + options.start = sprite.start; + options.end = sprite.end; + options.speed = sprite.speed; + delete options.sprite; + } + // @deprecated offset option if ((options as any).offset) { options.start = (options as any).offset as number; @@ -589,19 +610,28 @@ export default class Sound // if not yet playable, ignore // - usefull when the sound download isnt yet completed - if (!this.isPlayable) + if (!this.isLoaded) { - this.autoPlay = true; - if (!this.isLoaded) + return new Promise((resolve, reject) => { - const loaded = options.loaded; - if (loaded) + this.autoPlay = true; + this._autoPlayOptions = options; + this._beginPreload((err: Error, sound: Sound, instance: SoundInstance) => { - this.loaded = loaded; - } - this._beginPreload(); - } - return; + if (err) + { + reject(err); + } + else + { + if (options.loaded) + { + options.loaded(err, sound, instance); + } + resolve(instance); + } + }); + }); } // Stop all sounds @@ -624,11 +654,14 @@ export default class Sound instance.once("stop", () => { this._onComplete(instance); }); + instance.play( options.start, options.end, options.speed, options.loop, + options.fadeIn, + options.fadeOut, ); return instance; } @@ -643,6 +676,7 @@ export default class Sound if (!this.isPlayable) { this.autoPlay = false; + this._autoPlayOptions = null; return this; } this.isPlaying = false; @@ -690,21 +724,21 @@ export default class Sound * @method PIXI.sound.Sound#_beginPreload * @private */ - private _beginPreload(): void + private _beginPreload(callback?: LoadedCallback): void { // Load from the file path if (this.src) { - this.useXHR ? this._loadUrl() : this._loadPath(); + this.useXHR ? this._loadUrl(callback) : this._loadPath(callback); } // Load from the arraybuffer, incase it was loaded outside else if (this.srcBuffer) { - this._decode(this.srcBuffer); + this._decode(this.srcBuffer, callback); } - else if (this.loaded) + else if (callback) { - this.loaded(new Error("sound.src or sound.srcBuffer must be set")); + callback(new Error("sound.src or sound.srcBuffer must be set")); } else { @@ -752,7 +786,7 @@ export default class Sound * @method PIXI.sound.Sound#_loadUrl * @private */ - private _loadUrl(): void + private _loadUrl(callback?: LoadedCallback): void { const request = new XMLHttpRequest(); const src: string = this.src; @@ -761,9 +795,8 @@ export default class Sound // Decode asynchronously request.onload = () => { - this.isLoaded = true; this.srcBuffer = request.response as ArrayBuffer; - this._decode(request.response); + this._decode(request.response, callback); }; // actually start the request @@ -775,7 +808,7 @@ export default class Sound * @method PIXI.sound.Sound#_loadPath * @private */ - private _loadPath() + private _loadPath(callback?: LoadedCallback) { const fs = require("fs"); const src: string = this.src; @@ -785,9 +818,9 @@ export default class Sound // @if DEBUG console.error(err); // @endif - if (this.loaded) + if (callback) { - this.loaded(new Error(`File not found ${this.src}`)); + callback(new Error(`File not found ${this.src}`)); } return; } @@ -798,7 +831,7 @@ export default class Sound view[i] = data[i]; } this.srcBuffer = arrayBuffer; - this._decode(arrayBuffer); + this._decode(arrayBuffer, callback); }); } @@ -808,25 +841,29 @@ export default class Sound * @param {ArrayBuffer} arrayBuffer From load. * @private */ - private _decode(arrayBuffer: ArrayBuffer): void + private _decode(arrayBuffer: ArrayBuffer, callback?: LoadedCallback): void { this._context.decode(arrayBuffer, (err: Error, buffer: AudioBuffer) => { if (err) { - this.loaded(err); + if (callback) + { + callback(err); + } } else { this.isLoaded = true; this.buffer = buffer; - if (this.loaded) + let instance: SoundInstance; + if (this.autoPlay) { - this.loaded(null, this); + instance = this.play(this._autoPlayOptions) as SoundInstance; } - if (this.autoPlay) + if (callback) { - this.play(this.complete); + callback(null, this, instance); } } }, diff --git a/src/SoundInstance.ts b/src/SoundInstance.ts index bca0b4b..c679a79 100644 --- a/src/SoundInstance.ts +++ b/src/SoundInstance.ts @@ -58,6 +58,22 @@ export default class SoundInstance extends PIXI.utils.EventEmitter */ private _elapsed: number; + /** + * The number of time in seconds to fade in. + * @type {Number} + * @name PIXI.sound.SoundInstance#_fadeIn + * @private + */ + private _fadeIn: number; + + /** + * The number of time in seconds to fade out. + * @type {Number} + * @name PIXI.sound.SoundInstance#_fadeOut + * @private + */ + private _fadeOut: number; + /** * Playback rate, where 1 is 100%. * @type {Number} @@ -66,6 +82,22 @@ export default class SoundInstance extends PIXI.utils.EventEmitter */ private _speed: number; + /** + * Playback rate, where 1 is 100%. + * @type {Number} + * @name PIXI.sound.SoundInstance#_end + * @private + */ + private _end: number; + + /** + * `true` if should be looping. + * @type {Boolean} + * @name PIXI.sound.SoundInstance#_loop + * @private + */ + private _loop: boolean; + /** * Length of the sound in seconds. * @type {Number} @@ -149,8 +181,10 @@ export default class SoundInstance extends PIXI.utils.EventEmitter * @param {Number} [end] The ending position in seconds. * @param {Number} [speed] Override the default speed. * @param {Boolean} [loop] Override the default loop. + * @param {Number} [fadeIn] Time to fadein volume. + * @param {Number} [fadeOut] Time to fadeout volume. */ - public play(start: number = 0, end?: number, speed?: number, loop?: boolean): void + public play(start: number, end: number, speed: number, loop: boolean, fadeIn: number, fadeOut: number): void { // @if DEBUG if (end) @@ -167,20 +201,46 @@ export default class SoundInstance extends PIXI.utils.EventEmitter this._speed = this._source.playbackRate.value; if (loop !== undefined) { - this._source.loop = loop; + this._loop = this._source.loop = !!loop; } // WebAudio doesn't support looping when a duration is set // we'll set this just for the heck of it - if (this._source.loop && end !== undefined) + if (this._loop && end !== undefined) { // @if DEBUG console.warn('Looping not support when specifying an "end" time'); // @endif - this._source.loop = false; + this._loop = this._source.loop = false; } + this._end = end; + + const duration: number = this._source.buffer.duration; + + fadeIn = this._toSec(fadeIn); + + // Clamp fadeIn to the duration + if (fadeIn > duration) + { + fadeIn = duration; + } + + // Cannot fade out for looping sounds + if (!this._loop) + { + fadeOut = this._toSec(fadeOut); + + // Clamp fadeOut to the duration + fadeIn + if (fadeOut > duration - fadeIn) + { + fadeOut = duration - fadeIn; + } + } + + this._duration = duration; + this._fadeIn = fadeIn; + this._fadeOut = fadeOut; this._lastUpdate = this._now(); this._elapsed = start; - this._duration = this._source.buffer.duration; this._source.onended = this._onComplete.bind(this); this._source.start(0, start, (end ? end - start : undefined)); @@ -197,6 +257,22 @@ export default class SoundInstance extends PIXI.utils.EventEmitter this._enabled = true; } + /** + * Utility to convert time in millseconds or seconds + * @method PIXI.sound.SoundInstance#_toSec + * @private + * @param {Number} [time] Time in either ms or sec + * @return {Number} Time in seconds + */ + private _toSec(time?: number): number + { + if (time > 10) + { + time /= 1000; + } + return time || 0; + } + /** * Start the update progress. * @name PIXI.sound.SoundInstance#_enabled @@ -256,7 +332,14 @@ export default class SoundInstance extends PIXI.utils.EventEmitter this.emit("resumed"); // resume the playing with offset - this.play(this._elapsed % this._duration); + this.play( + this._elapsed % this._duration, + this._end, + this._speed, + this._loop, + this._fadeIn, + this._fadeOut, + ); } /** @@ -276,14 +359,15 @@ export default class SoundInstance extends PIXI.utils.EventEmitter { this.removeAllListeners(); this._internalStop(); - if (this._source) - { - this._source.onended = null; - } this._source = null; + this._speed = 0; + this._end = 0; this._parent = null; this._elapsed = 0; this._duration = 0; + this._loop = false; + this._fadeIn = 0; + this._fadeOut = 0; this._paused = false; // Add it if it isn't already added @@ -332,7 +416,38 @@ export default class SoundInstance extends PIXI.utils.EventEmitter this._elapsed += delta; this._lastUpdate = now; const duration: number = this._duration; - this._progress = ((this._elapsed * this._speed) % duration) / duration; + const progress: number = ((this._elapsed * this._speed) % duration) / duration; + + if (this._fadeIn || this._fadeOut) + { + const position: number = progress * duration; + const gain = this._parent.nodes.gain.gain; + const maxVolume = this._parent.volume; + + if (this._fadeIn) + { + if (position <= this._fadeIn && progress < 1) + { + // Manipulate the gain node directly + // so we can maintain the starting volume + gain.value = maxVolume * (position / this._fadeIn); + } + else + { + gain.value = maxVolume; + this._fadeIn = 0; + } + } + + if (this._fadeOut && position >= duration - this._fadeOut) + { + const percent: number = (duration - position) / this._fadeOut; + gain.value = maxVolume * percent; + } + } + + // Update the progress + this._progress = progress; /** * The sound progress is updated. @@ -369,6 +484,8 @@ export default class SoundInstance extends PIXI.utils.EventEmitter this._source.stop(); this._source = null; + // Reset the volume + this._parent.volume = this._parent.volume; } } diff --git a/src/SoundLibrary.ts b/src/SoundLibrary.ts index bf2f264..b1cc68a 100644 --- a/src/SoundLibrary.ts +++ b/src/SoundLibrary.ts @@ -358,10 +358,11 @@ export default class SoundLibrary * @param {Number} [options.end] End time offset. * @param {Number} [options.speed] Override default speed, default to the Sound's speed setting. * @param {Boolean} [options.loop] Override default loop, default to the Sound's loop setting. - * @return {PIXI.sound.SoundInstance|null} The sound instance, this cannot be reused - * after it is done playing. Returns `null` if the sound has not yet loaded. + * @return {PIXI.sound.SoundInstance|Promise} The sound instance, + * this cannot be reused after it is done playing. Returns a Promise if the sound + * has not yet loaded. */ - public play(alias: string, options?: PlayOptions|Object|string): SoundInstance + public play(alias: string, options?: PlayOptions|Object|string): SoundInstance|Promise { return this.find(alias).play(options); } diff --git a/src/SoundSprite.ts b/src/SoundSprite.ts index 43f2f56..456befb 100644 --- a/src/SoundSprite.ts +++ b/src/SoundSprite.ts @@ -79,9 +79,9 @@ export default class SoundSprite * Play the sound sprite. * @method PIXI.sound.SoundSprite#play * @param {PIXI.sound.Sound~completeCallback} [complete] Function call when complete - * @return {PIXI.sound.SoundInstance} Sound instance being played. + * @return {PIXI.sound.SoundInstance|Promise} Sound instance being played. */ - public play(complete?: CompleteCallback): SoundInstance + public play(complete?: CompleteCallback): SoundInstance|Promise { return this.parent.play(Object.assign({ complete, diff --git a/src/deprecations.ts b/src/deprecations.ts index 6fd3d67..086bcd2 100644 --- a/src/deprecations.ts +++ b/src/deprecations.ts @@ -72,3 +72,33 @@ Object.defineProperty(SoundPrototype, "block", { this.singleInstance = value; }, }); + +/** + * Retired property on Sound for handing loaded event. + * @name PIXI.sound.Sound#loaded + * @deprecated since 1.4.0 + */ +Object.defineProperty(SoundPrototype, "loaded", { + get() { + console.warn("PIXI.sound.Sound.prototype.loaded is deprecated, use constructor option instead"); + return null; + }, + set(value: boolean) { + console.warn("PIXI.sound.Sound.prototype.loaded is deprecated, use constructor option instead"); + }, +}); + +/** + * Retired property on Sound for handling autoPlay completed event. + * @name PIXI.sound.Sound#complete + * @deprecated since 1.4.0 + */ +Object.defineProperty(SoundPrototype, "complete", { + get() { + console.warn("PIXI.sound.Sound.prototype.complete is deprecated, use constructor option instead"); + return null; + }, + set(value: boolean) { + console.warn("PIXI.sound.Sound.prototype.complete is deprecated, use constructor option instead"); + }, +}); diff --git a/test/index.js b/test/index.js index 1d549ec..f839c02 100644 --- a/test/index.js +++ b/test/index.js @@ -259,6 +259,43 @@ describe("PIXI.sound", function() }); }); +describe("PIXI.sound.SoundInstance", function() +{ + afterEach(function() + { + library.default.removeAll(); + }); + + it("should return Promise for playing unloaded sound", function(done) + { + const Sound = library.default.Sound; + const sound = Sound.from(manifest.silence); + expect(sound).to.be.instanceof(Sound); + const promise = sound.play(); + promise.then((instance) => { + expect(instance).to.be.instanceof(library.default.SoundInstance); + done(); + }); + expect(promise).to.be.instanceof(Promise); + }); + + it("should return instance for playing loaded sound", function(done) + { + const sound = library.default.Sound.from({ + src: manifest.silence, + preload: true, + loaded: (err) => { + expect(err).to.be.null; + expect(sound.isLoaded).to.be.true; + expect(sound.isPlayable).to.be.true; + const instance = sound.play(); + expect(instance).to.be.instanceof(library.default.SoundInstance); + done(); + }, + }); + }); +}); + describe("PIXI.loader", function() { afterEach(function()