Spaces:
Build error
Build error
const MathUtil = require('../util/math-util'); | |
const Cast = require('../util/cast'); | |
const Clone = require('../util/clone'); | |
const getMonitorIdForBlockWithArgs = require('../util/get-monitor-id'); | |
/** | |
* Occluded boolean value to make its use more understandable. | |
* @const {boolean} | |
*/ | |
const STORE_WAITING = true; | |
class Scratch3SoundBlocks { | |
constructor (runtime) { | |
/** | |
* The runtime instantiating this block package. | |
* @type {Runtime} | |
*/ | |
this.runtime = runtime; | |
this.waitingSounds = {}; | |
// Clear sound effects on green flag and stop button events. | |
this.stopAllSounds = this.stopAllSounds.bind(this); | |
this._stopWaitingSoundsForTarget = this._stopWaitingSoundsForTarget.bind(this); | |
this._clearEffectsForAllTargets = this._clearEffectsForAllTargets.bind(this); | |
if (this.runtime) { | |
this.runtime.on('PROJECT_STOP_ALL', this.stopAllSounds); | |
this.runtime.on('PROJECT_STOP_ALL', this._clearEffectsForAllTargets); | |
this.runtime.on('STOP_FOR_TARGET', this._stopWaitingSoundsForTarget); | |
this.runtime.on('PROJECT_START', this._clearEffectsForAllTargets); | |
} | |
this._onTargetCreated = this._onTargetCreated.bind(this); | |
if (this.runtime) { | |
runtime.on('targetWasCreated', this._onTargetCreated); | |
} | |
} | |
/** | |
* The key to load & store a target's sound-related state. | |
* @type {string} | |
*/ | |
static get STATE_KEY () { | |
return 'Scratch.sound'; | |
} | |
/** | |
* The default sound-related state, to be used when a target has no existing sound state. | |
* @type {SoundState} | |
*/ | |
static get DEFAULT_SOUND_STATE () { | |
return { | |
effects: { | |
pitch: 0, | |
pan: 0 | |
} | |
}; | |
} | |
/** | |
* The minimum and maximum MIDI note numbers, for clamping the input to play note. | |
* @type {{min: number, max: number}} | |
*/ | |
static get MIDI_NOTE_RANGE () { | |
return {min: 36, max: 96}; // C2 to C7 | |
} | |
/** | |
* The minimum and maximum beat values, for clamping the duration of play note, play drum and rest. | |
* 100 beats at the default tempo of 60bpm is 100 seconds. | |
* @type {{min: number, max: number}} | |
*/ | |
static get BEAT_RANGE () { | |
return {min: 0, max: 100}; | |
} | |
/** The minimum and maximum tempo values, in bpm. | |
* @type {{min: number, max: number}} | |
*/ | |
static get TEMPO_RANGE () { | |
return {min: 20, max: 500}; | |
} | |
/** The minimum and maximum values for each sound effect. | |
* @type {{effect:{min: number, max: number}}} | |
*/ | |
static get EFFECT_RANGE () { | |
return { | |
pitch: {min: -360, max: 360}, // -3 to 3 octaves | |
pan: {min: -100, max: 100} // 100% left to 100% right | |
}; | |
} | |
/** The minimum and maximum values for sound effects when miscellaneous limits are removed. */ | |
static get LARGER_EFFECT_RANGE () { | |
return { | |
// scratch-audio throws if pitch is too big because some math results in Infinity | |
pitch: {min: -1000, max: 1000}, | |
// No reason for these to go beyond 100 | |
pan: {min: -100, max: 100} | |
}; | |
} | |
/** | |
* @param {Target} target - collect sound state for this target. | |
* @returns {SoundState} the mutable sound state associated with that target. This will be created if necessary. | |
* @private | |
*/ | |
_getSoundState (target) { | |
let soundState = target.getCustomState(Scratch3SoundBlocks.STATE_KEY); | |
if (!soundState) { | |
soundState = Clone.simple(Scratch3SoundBlocks.DEFAULT_SOUND_STATE); | |
target.setCustomState(Scratch3SoundBlocks.STATE_KEY, soundState); | |
target.soundEffects = soundState.effects; | |
} | |
return soundState; | |
} | |
/** | |
* When a Target is cloned, clone the sound state. | |
* @param {Target} newTarget - the newly created target. | |
* @param {Target} [sourceTarget] - the target used as a source for the new clone, if any. | |
* @listens Runtime#event:targetWasCreated | |
* @private | |
*/ | |
_onTargetCreated (newTarget, sourceTarget) { | |
if (sourceTarget) { | |
const soundState = sourceTarget.getCustomState(Scratch3SoundBlocks.STATE_KEY); | |
if (soundState && newTarget) { | |
newTarget.setCustomState(Scratch3SoundBlocks.STATE_KEY, Clone.simple(soundState)); | |
this._syncEffectsForTarget(newTarget); | |
} | |
} | |
} | |
/** | |
* Retrieve the block primitives implemented by this package. | |
* @return {object.<string, Function>} Mapping of opcode to Function. | |
*/ | |
getPrimitives () { | |
return { | |
sound_play: this.playSound, | |
sound_playallsounds: this.playSoundAllLolOpAOIUHFoiubea87fge87iufwhef87wye87fn, | |
sound_playuntildone: this.playSoundAndWait, | |
sound_stop: this.stopSpecificSound, | |
sound_stopallsounds: this.stopAllSounds, | |
sound_seteffectto: this.setEffect, | |
sound_changeeffectby: this.changeEffect, | |
sound_cleareffects: this.clearEffects, | |
sound_sounds_menu: this.soundsMenu, | |
sound_beats_menu: this.beatsMenu, | |
sound_effects_menu: this.effectsMenu, | |
sound_setvolumeto: this.setVolume, | |
sound_changevolumeby: this.changeVolume, | |
sound_volume: this.getVolume, | |
sound_isSoundPlaying: this.isSoundPlaying, | |
sound_getEffectValue: this.getEffectValue, | |
sound_getLength: this.getLength, | |
sound_set_stop_fadeout_to: this.setStopFadeout, | |
sound_play_at_seconds: this.playAtSeconds, | |
sound_play_at_seconds_until_done: this.playAtSecondsAndWait, | |
sound_getSoundVolume: this.currentSoundVolume | |
}; | |
} | |
getMonitored () { | |
return { | |
sound_volume: { | |
isSpriteSpecific: true, | |
getId: targetId => `${targetId}_volume` | |
}, | |
sound_getEffectValue: { | |
isSpriteSpecific: true, | |
getId: (targetId, fields) => getMonitorIdForBlockWithArgs(`${targetId}_getEffectValue`, fields) | |
}, | |
}; | |
} | |
currentSoundVolume (args, util) { | |
} | |
playAtSeconds (args, util) { | |
const seconds = Cast.toNumber(args.VALUE); | |
if (seconds < 0) { | |
return; | |
} | |
this._playSoundAtTimePosition({ | |
sound: Cast.toString(args.SOUND_MENU), | |
seconds: seconds | |
}, util, STORE_WAITING); | |
} | |
playAtSecondsAndWait (args, util) { | |
// return promise | |
const seconds = Cast.toNumber(args.VALUE); | |
if (seconds < 0) { | |
return; | |
} | |
return this._playSoundAtTimePosition({ | |
sound: Cast.toString(args.SOUND_MENU), | |
seconds: seconds | |
}, util, STORE_WAITING); | |
} | |
_playSoundAtTimePosition ({ sound, seconds }, util, storeWaiting) { | |
const index = this._getSoundIndex(sound, util); | |
if (index >= 0) { | |
const {target} = util; | |
const {sprite} = target; | |
const {soundId} = sprite.sounds[index]; | |
if (sprite.soundBank) { | |
if (storeWaiting === STORE_WAITING) { | |
this._addWaitingSound(target.id, soundId); | |
} else { | |
this._removeWaitingSound(target.id, soundId); | |
} | |
return sprite.soundBank.playSound(target, soundId, seconds); | |
} | |
} | |
} | |
setStopFadeout (args, util) { | |
const id = Cast.toString(args.SOUND_MENU); | |
const index = this._getSoundIndex(id, util); | |
if (index < 0) return; | |
const target = util.target; | |
const sprite = target.sprite; | |
if (!sprite) return; | |
if (!sprite.sounds) return; | |
const { soundId } = sprite.sounds[index]; | |
const soundBank = sprite.soundBank | |
if (!soundBank) return; | |
const decayTime = Cast.toNumber(args.VALUE); | |
if (decayTime <= 0) { | |
soundBank.soundPlayers[soundId].stopFadeDecay = 0; | |
return; | |
} | |
soundBank.soundPlayers[soundId].stopFadeDecay = decayTime; | |
} | |
getEffectValue (args, util) { | |
const target = util.target; | |
const effects = target.soundEffects; | |
if (!effects) return 0; | |
const effect = Cast.toString(args.EFFECT).toLowerCase(); | |
if (!effects.hasOwnProperty(effect)) return 0; | |
const value = Cast.toNumber(effects[effect]); | |
return value; | |
} | |
isSoundPlaying (args, util) { | |
const index = this._getSoundIndex(args.SOUND_MENU, util); | |
if (index < 0) return false; | |
const target = util.target; | |
const sprite = target.sprite; | |
if (!sprite) return false; | |
const { soundId } = sprite.sounds[index]; | |
const soundBank = sprite.soundBank | |
if (!soundBank) return false; | |
const players = soundBank.soundPlayers; | |
if (!players) return false; | |
if (!players.hasOwnProperty(soundId)) return false; | |
return players[soundId].isPlaying == true; | |
} | |
getLength (args, util) { | |
const index = this._getSoundIndex(args.SOUND_MENU, util); | |
if (index < 0) return 0; | |
const target = util.target; | |
const sprite = target.sprite; | |
if (!sprite) return 0; | |
const { soundId } = sprite.sounds[index]; | |
const soundBank = sprite.soundBank | |
if (!soundBank) return 0; | |
const players = soundBank.soundPlayers; | |
if (!players) return 0; | |
if (!players.hasOwnProperty(soundId)) return 0; | |
const buffer = players[soundId].buffer; | |
if (!buffer) return 0; | |
return Cast.toNumber(buffer.duration); | |
} | |
stopSpecificSound (args, util) { | |
const index = this._getSoundIndex(args.SOUND_MENU, util); | |
if (index < 0) return; | |
const target = util.target; | |
const sprite = target.sprite; | |
if (!sprite) return; | |
const { soundId } = sprite.sounds[index]; | |
const soundBank = sprite.soundBank | |
if (!soundBank) return; | |
soundBank.stop(target, soundId); | |
} | |
playSound (args, util) { | |
// Don't return the promise, it's the only difference for AndWait | |
this._playSound(args, util); | |
} | |
playSoundAndWait (args, util) { | |
return this._playSound(args, util, STORE_WAITING); | |
} | |
_playSound (args, util, storeWaiting) { | |
const index = this._getSoundIndex(args.SOUND_MENU, util); | |
if (index >= 0) { | |
const {target} = util; | |
const {sprite} = target; | |
const {soundId} = sprite.sounds[index]; | |
if (sprite.soundBank) { | |
if (storeWaiting === STORE_WAITING) { | |
this._addWaitingSound(target.id, soundId); | |
} else { | |
this._removeWaitingSound(target.id, soundId); | |
} | |
return sprite.soundBank.playSound(target, soundId); | |
} | |
} | |
} | |
_addWaitingSound (targetId, soundId) { | |
if (!this.waitingSounds[targetId]) { | |
this.waitingSounds[targetId] = new Set(); | |
} | |
this.waitingSounds[targetId].add(soundId); | |
} | |
_removeWaitingSound (targetId, soundId) { | |
if (!this.waitingSounds[targetId]) { | |
return; | |
} | |
this.waitingSounds[targetId].delete(soundId); | |
} | |
_getSoundIndex (soundName, util) { | |
// if the sprite has no sounds, return -1 | |
const len = util.target.sprite.sounds.length; | |
if (len === 0) { | |
return -1; | |
} | |
// look up by name first | |
const index = this.getSoundIndexByName(soundName, util); | |
if (index !== -1) { | |
return index; | |
} | |
// then try using the sound name as a 1-indexed index | |
const oneIndexedIndex = parseInt(soundName, 10); | |
if (!isNaN(oneIndexedIndex)) { | |
return MathUtil.wrapClamp(oneIndexedIndex - 1, 0, len - 1); | |
} | |
// could not be found as a name or converted to index, return -1 | |
return -1; | |
} | |
getSoundIndexByName (soundName, util) { | |
const sounds = util.target.sprite.sounds; | |
for (let i = 0; i < sounds.length; i++) { | |
if (sounds[i].name === soundName) { | |
return i; | |
} | |
} | |
// if there is no sound by that name, return -1 | |
return -1; | |
} | |
stopAllSounds () { | |
if (this.runtime.targets === null) return; | |
const allTargets = this.runtime.targets; | |
for (let i = 0; i < allTargets.length; i++) { | |
this._stopAllSoundsForTarget(allTargets[i]); | |
} | |
} | |
playSoundAllLolOpAOIUHFoiubea87fge87iufwhef87wye87fn (_, util) { | |
const target = util.target; | |
const sprite = target.sprite; | |
if (!sprite) return; | |
for (let i = 0; i < sprite.sounds.length; i++) { | |
const { soundId } = sprite.sounds[i]; | |
if (sprite.soundBank) { | |
sprite.soundBank.playSound(target, soundId); | |
} | |
} | |
} | |
_stopAllSoundsForTarget (target) { | |
if (target.sprite.soundBank) { | |
target.sprite.soundBank.stopAllSounds(target); | |
if (this.waitingSounds[target.id]) { | |
this.waitingSounds[target.id].clear(); | |
} | |
} | |
} | |
_stopWaitingSoundsForTarget (target) { | |
if (target.sprite.soundBank) { | |
if (this.waitingSounds[target.id]) { | |
for (const soundId of this.waitingSounds[target.id].values()) { | |
target.sprite.soundBank.stop(target, soundId); | |
} | |
this.waitingSounds[target.id].clear(); | |
} | |
} | |
} | |
setEffect (args, util) { | |
return this._updateEffect(args, util, false); | |
} | |
changeEffect (args, util) { | |
return this._updateEffect(args, util, true); | |
} | |
_updateEffect (args, util, change) { | |
const effect = Cast.toString(args.EFFECT).toLowerCase(); | |
const value = Cast.toNumber(args.VALUE); | |
const soundState = this._getSoundState(util.target); | |
if (!soundState.effects.hasOwnProperty(effect)) return; | |
if (change) { | |
soundState.effects[effect] += value; | |
} else { | |
soundState.effects[effect] = value; | |
} | |
const miscLimits = this.runtime.runtimeOptions.miscLimits; | |
const {min, max} = miscLimits ? | |
Scratch3SoundBlocks.EFFECT_RANGE[effect] : | |
Scratch3SoundBlocks.LARGER_EFFECT_RANGE[effect]; | |
soundState.effects[effect] = MathUtil.clamp(soundState.effects[effect], min, max); | |
this._syncEffectsForTarget(util.target); | |
if (miscLimits) { | |
// Yield until the next tick. | |
return Promise.resolve(); | |
} | |
// Requesting a redraw makes sure that "forever: change pitch by 1" still work but without | |
// yielding unnecessarily in other cases | |
this.runtime.requestRedraw(); | |
} | |
_syncEffectsForTarget (target) { | |
if (!target || !target.sprite.soundBank) return; | |
target.soundEffects = this._getSoundState(target).effects; | |
target.sprite.soundBank.setEffects(target); | |
} | |
clearEffects (args, util) { | |
this._clearEffectsForTarget(util.target); | |
} | |
_clearEffectsForTarget (target) { | |
const soundState = this._getSoundState(target); | |
for (const effect in soundState.effects) { | |
if (!soundState.effects.hasOwnProperty(effect)) continue; | |
soundState.effects[effect] = 0; | |
} | |
this._syncEffectsForTarget(target); | |
} | |
_clearEffectsForAllTargets () { | |
if (this.runtime.targets === null) return; | |
const allTargets = this.runtime.targets; | |
for (let i = 0; i < allTargets.length; i++) { | |
this._clearEffectsForTarget(allTargets[i]); | |
} | |
} | |
setVolume (args, util) { | |
const volume = Cast.toNumber(args.VOLUME); | |
return this._updateVolume(volume, util.target); | |
} | |
changeVolume (args, util) { | |
const volume = Cast.toNumber(args.VOLUME) + util.target.volume; | |
return this._updateVolume(volume, util.target); | |
} | |
_updateVolume (volume, target) { | |
volume = MathUtil.clamp(volume, 0, 100); | |
target.volume = volume; | |
this._syncEffectsForTarget(target); | |
if (this.runtime.runtimeOptions.miscLimits) { | |
// Yield until the next tick. | |
return Promise.resolve(); | |
} | |
this.runtime.requestRedraw(); | |
} | |
getVolume (args, util) { | |
return util.target.volume; | |
} | |
soundsMenu (args) { | |
return args.SOUND_MENU; | |
} | |
beatsMenu (args) { | |
return args.BEATS; | |
} | |
effectsMenu (args) { | |
return args.EFFECT; | |
} | |
} | |
module.exports = Scratch3SoundBlocks; | |