UNPKG

@knopkem/little-game-engine-ts

Version:

LittleGameEngineTS - Tiny and Fast HTML5 Game Engine typescript port of LittleJS

431 lines (353 loc) 18.5 kB
import { worldToScreen, mainCanvas } from './index'; import { defaultSoundRange, defaultSoundTaper, soundEnable, cameraPos, audioVolume } from './index'; import { percent, rand, clamp, abs, PI } from './index'; /** * LittleJS Audio System * <br> - ZzFX Sound Effects and ZzFXM Music * <br> - Caches sounds and music for fast playback * <br> - Can attenuate and apply stereo panning to sounds * <br> - Ability to play mp3, ogg, and wave files * <br> - Speech synthesis wrapper functions * @namespace Audio */ /** Sound Object - Stores a zzfx sound for later use and can be played positionally */ export class Sound { cachedSamples: any; randomness: any; range: any; taper: any; /** Create a sound object and cache the zzfx samples for later use * @param {Array} zzfxSound - Array of zzfx parameters, ex. [.5,.5] * @param {Number} [range=defaultSoundRange] - World space max range of sound, will not play if camera is farther away * @param {Number} [taper=defaultSoundTaper] - At what percentage of range should it start tapering off */ constructor(zzfxSound: any, range = defaultSoundRange, taper = defaultSoundTaper) { if (!soundEnable) return; this.range = range; this.taper = taper; // get randomness from sound parameters this.randomness = zzfxSound[1] || 0; zzfxSound[1] = 0; // generate sound now for fast playback this.cachedSamples = zzfxG(...zzfxSound); } /** Play the sound * @param {Vector2} [pos] - World space position to play the sound, sound is not attenuated if null * @param {Number} [volume=1] - How much to scale volume by (in addition to range fade) * @param {Number} [pitch=1] - How much to scale pitch by (also adjusted by this.randomness) * @param {Number} [randomnessScale=1] - How much to scale randomness * @return {AudioBufferSourceNode} - The audio, can be used to stop sound later */ play(pos: any, volume = 1, pitch = 1, randomnessScale = 1) { if (!soundEnable) return; let pan = 0; if (pos) { const range = this.range; if (range) { // apply range based fade const lengthSquared = cameraPos.distanceSquared(pos); if (lengthSquared > range * range) return; // out of range // attenuate volume by distance volume *= percent(lengthSquared ** .5, range * this.taper, range); } // get pan from screen space coords pan = worldToScreen(pos).x * 2 / mainCanvas.width - 1; } // play the sound const playbackRate = pitch + pitch * this.randomness * randomnessScale * rand(-1, 1); return playSamples([this.cachedSamples], volume, playbackRate, pan); } /** Play the sound as a note with a semitone offset * @param {Number} semitoneOffset - How many semitones to offset pitch * @param {Vector2} [pos] - World space position to play the sound, sound is not attenuated if null * @param {Number} [volume=1] - How much to scale volume by (in addition to range fade) * @return {AudioBufferSourceNode} - The audio, can be used to stop sound later */ playNote(semitoneOffset: any, pos: any, volume = 1) { if (!soundEnable) return; return this.play(pos, volume, 2 ** (semitoneOffset / 12), 0); } } /** Music Object - Stores a zzfx music track for later use */ export class Music { cachedSamples: any; /** Create a music object and cache the zzfx music samples for later use * @param {Array} zzfxMusic - Array of zzfx music parameters */ constructor(zzfxMusic: any) { if (!soundEnable) return; // @ts-expect-error ts-migrate(2556) FIXME: Expected 3-4 arguments, but got 0 or more. this.cachedSamples = zzfxM(...zzfxMusic); } /** Play the music * @param {Number} [volume=1] - How much to scale volume by * @param {Boolean} [loop=1] - True if the music should loop when it reaches the end * @return {AudioBufferSourceNode} - The audio node, can be used to stop sound later */ play(volume = 1, loop = 1) { if (!soundEnable) return; return playSamples(this.cachedSamples, volume, 1, 0, loop); } } /** Play an mp3 or wav audio from a local file or url * @param {String} url - Location of sound file to play * @param {Number} [volume=1] - How much to scale volume by * @param {Boolean} [loop=1] - True if the music should loop when it reaches the end * @return {HTMLAudioElement} - The audio element for this sound * @memberof Audio */ export function playAudioFile(url: any, volume = 1, loop = true): HTMLAudioElement | undefined { if (!soundEnable) return undefined; const audio = new Audio(url); audio.volume = audioVolume * volume; audio.loop = loop; audio.play(); return audio; } /** Speak text with passed in settings * @param {String} text - The text to speak * @param {String} [language] - The language/accent to use (examples: en, it, ru, ja, zh) * @param {Number} [volume=1] - How much to scale volume by * @param {Number} [rate=1] - How quickly to speak * @param {Number} [pitch=1] - How much to change the pitch by * @return {SpeechSynthesisUtterance} - The utterance that was spoken * @memberof Audio */ export function speak(text: any, language = '', volume = 1, rate = 1, pitch = 1): SpeechSynthesisUtterance | undefined { if (!soundEnable || !speechSynthesis) return undefined; // common languages (not supported by all browsers) // en - english, it - italian, fr - french, de - german, es - spanish // ja - japanese, ru - russian, zh - chinese, hi - hindi, ko - korean // build utterance and speak const utterance = new SpeechSynthesisUtterance(text); utterance.lang = language; utterance.volume = 2 * volume * audioVolume; utterance.rate = rate; utterance.pitch = pitch; speechSynthesis.speak(utterance); return utterance; } /** Stop all queued speech * @memberof Audio */ export const stopSpeech = () => speechSynthesis && speechSynthesis.cancel(); /** Get frequency of a note on a musical scale * @param {Number} semitoneOffset - How many semitones away from the root note * @param {Number} [rootNoteFrequency=220] - Frequency at semitone offset 0 * @return {Number} - The frequency of the note * @memberof Audio */ export const getNoteFrequency = (semitoneOffset: any, rootFrequency = 220) => rootFrequency * 2 ** (semitoneOffset / 12); /////////////////////////////////////////////////////////////////////////////// /** Audio context used by the engine * @memberof Audio */ let audioContext: any; /** Play cached audio samples with given settings * @param {Array} sampleChannels - Array of arrays of samples to play (for stereo playback) * @param {Number} [volume=1] - How much to scale volume by * @param {Number} [rate=1] - The playback rate to use * @param {Number} [pan=0] - How much to apply stereo panning * @param {Boolean} [loop=0] - True if the sound should loop when it reaches the end * @return {AudioBufferSourceNode} - The audio node of the sound played * @memberof Audio */ export function playSamples(sampleChannels: any, volume = 1, rate = 1, pan = 0, loop = 0) { if (!soundEnable) return; // create audio context if (!audioContext) // @ts-expect-error ts-migrate(2304) FIXME: Cannot find name 'webkitAudioContext'. audioContext = new (window.AudioContext || webkitAudioContext); // create buffer and source const buffer = audioContext.createBuffer(sampleChannels.length, sampleChannels[0].length, zzfxR), source = audioContext.createBufferSource(); // copy samples to buffer and setup source sampleChannels.forEach((c: any, i: any) => buffer.getChannelData(i).set(c)); source.buffer = buffer; source.playbackRate.value = rate; source.loop = loop; // create pan and gain nodes source .connect(new StereoPannerNode(audioContext, { 'pan': clamp(pan, 1, -1) })) .connect(new GainNode(audioContext, { 'gain': audioVolume * volume })) .connect(audioContext.destination); // play and return sound source.start(); return source; } /////////////////////////////////////////////////////////////////////////////// // ZzFXMicro - Zuper Zmall Zound Zynth - v1.1.8 by Frank Force /** Generate and play a ZzFX sound * @param {Array} zzfxSound - Array of ZzFX parameters, ex. [.5,.5] * @return {Array} - Array of audio samples * @memberof Audio */ export const zzfx = (...zzfxSound: any[]) => playSamples([zzfxG(...zzfxSound)]); /** Sample rate used for all ZzFX sounds * @default 44100 * @memberof Audio */ export const zzfxR = 44100; /** Generate samples for a ZzFX sound * @memberof Audio */ export function zzfxG ( // parameters volume = 1, randomness = .05, frequency = 220, attack = 0, sustain = 0, release = .1, shape = 0, shapeCurve = 1, slide = 0, deltaSlide = 0, pitchJump = 0, pitchJumpTime = 0, repeatTime = 0, noise = 0, modulation = 0, bitCrush = 0, delay = 0, sustainVolume = 1, decay = 0, tremolo = 0 ) { // init parameters let PI2 = PI * 2, sign = (v: any) => v > 0 ? 1 : -1, startSlide = slide *= 500 * PI2 / zzfxR / zzfxR, b: number[] = [], startFrequency = frequency *= (1 + randomness * rand(-1, 1)) * PI2 / zzfxR, t = 0, tm = 0, i = 0, j = 1, r = 0, c = 0, s = 0, f, length; // scale by sample rate attack = attack * zzfxR + 9; // minimum attack to prevent pop decay *= zzfxR; sustain *= zzfxR; release *= zzfxR; delay *= zzfxR; deltaSlide *= 500 * PI2 / zzfxR ** 3; modulation *= PI2 / zzfxR; pitchJump *= PI2 / zzfxR; pitchJumpTime *= zzfxR; repeatTime = repeatTime * zzfxR | 0; // generate waveform for (length = attack + decay + sustain + release + delay | 0; i < length; b[i++] = s) { if (!(++c % (bitCrush * 100 | 0))) // bit crush { s = shape ? shape > 1 ? shape > 2 ? shape > 3 ? // wave shape Math.sin((t % PI2) ** 3) : // 4 noise Math.max(Math.min(Math.tan(t), 1), -1) : // 3 tan 1 - (2 * t / PI2 % 2 + 2) % 2 : // 2 saw 1 - 4 * abs(Math.round(t / PI2) - t / PI2) : // 1 triangle Math.sin(t); // 0 sin s = (repeatTime ? 1 - tremolo + tremolo * Math.sin(PI2 * i / repeatTime) // tremolo : 1) * sign(s) * (abs(s) ** shapeCurve) * // curve 0=square, 2=pointy volume * audioVolume * ( // envelope i < attack ? i / attack : // attack i < attack + decay ? // decay 1 - ((i - attack) / decay) * (1 - sustainVolume) : // decay falloff i < attack + decay + sustain ? // sustain sustainVolume : // sustain volume i < length - delay ? // release (length - i - delay) / release * // release falloff sustainVolume : // release volume 0); // post release s = delay ? s / 2 + (delay > i ? 0 : // delay (i < length - delay ? 1 : (length - i) / delay) * // release delay b[i - delay | 0] / 2) : s; // sample delay } f = (frequency += slide += deltaSlide) * // frequency Math.cos(modulation * tm++); // modulation t += f - f * noise * (1 - (Math.sin(i) + 1) * 1e9 % 2); // noise if (j && ++j > pitchJumpTime) // pitch jump { frequency += pitchJump; // apply pitch jump startFrequency += pitchJump; // also apply to start j = 0; // reset pitch jump time } if (repeatTime && !(++r % repeatTime)) // repeat { frequency = startFrequency; // reset frequency slide = startSlide; // reset slide j = j || 1; // reset pitch jump time } } return b; } /////////////////////////////////////////////////////////////////////////////// // ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force /** Generate samples for a ZzFM song with given parameters * @param {Array} instruments - Array of ZzFX sound paramaters * @param {Array} patterns - Array of pattern data * @param {Array} sequence - Array of pattern indexes * @param {Number} [BPM=125] - Playback speed of the song in BPM * @returns {Array} - Left and right channel sample data * @memberof Audio */ export function zzfxM(instruments: any, patterns: any, sequence: any, BPM = 125) { let instrumentParameters; let i; let j; let k; let note; let sample; let patternChannel; let notFirstBeat: any; let stop; let instrument: any; let attenuation: any; let outSampleOffset: any; let isSequenceEnd; let sampleOffset = 0; let nextSampleOffset; let sampleBuffer: any = []; let leftChannelBuffer: any = []; let rightChannelBuffer: any = []; let channelIndex = 0; let panning = 0; let hasMore = 1; let sampleCache = {}; let beatLength = zzfxR / BPM * 60 >> 2; // for each channel in order until there are no more for (; hasMore; channelIndex++) { // reset current values sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0]; // for each pattern in sequence sequence.forEach((patternIndex: any, sequenceIndex: any) => { // get pattern for current channel, use empty 1 note pattern if none found patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0]; // check if there are more channels // @ts-expect-error ts-migrate(2363) FIXME: The right-hand side of an arithmetic operation mus... Remove this comment to see the full error message hasMore |= !!patterns[patternIndex][channelIndex]; // get next offset, use the length of first channel // @ts-expect-error ts-migrate(2363) FIXME: The right-hand side of an arithmetic operation mus... Remove this comment to see the full error message nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - !notFirstBeat) * beatLength; // for each beat in pattern, plus one extra if end of sequence isSequenceEnd = sequenceIndex == sequence.length - 1; for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) { // <channel-note> note = patternChannel[i]; // stop if end, different instrument or new note stop = i == patternChannel.length + isSequenceEnd - 1 && isSequenceEnd || // @ts-expect-error ts-migrate(2362) FIXME: The left-hand side of an arithmetic operation must... Remove this comment to see the full error message instrument != (patternChannel[0] || 0) | note | 0; // fill buffer with samples for previous beat, most cpu intensive part for (j = 0; j < beatLength && notFirstBeat; // fade off attenuation at end of beat if stopping note, prevents clicking // @ts-expect-error ts-migrate(2362) FIXME: The left-hand side of an arithmetic operation must... Remove this comment to see the full error message j++ > beatLength - 99 && stop ? attenuation += (attenuation < 1) / 99 : 0 ) { // copy sample to stereo buffers with panning sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0; leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample; rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample; } // set up for next note if (note) { // set attenuation attenuation = note % 1; panning = patternChannel[1] || 0; if (note |= 0) { // get cached sample sampleBuffer = sampleCache[ // @ts-expect-error ts-migrate(2538) FIXME: Type 'any[]' cannot be used as an index type. [ instrument = patternChannel[sampleOffset = 0] || 0, note ] // @ts-expect-error ts-migrate(2538) FIXME: Type 'any[]' cannot be used as an index type. ] = sampleCache[[instrument, note]] || ( // add sample to cache instrumentParameters = [...instruments[instrument]], instrumentParameters[2] *= 2 ** ((note - 12) / 12), // allow negative values to stop notes note > 0 ? zzfxG(...instrumentParameters) : [] ); } } } // update the sample offset outSampleOffset = nextSampleOffset; }); } return [leftChannelBuffer, rightChannelBuffer]; }