mirror of
https://github.com/scratchfoundation/scratch-vm.git
synced 2024-12-27 00:12:57 -05:00
436 lines
14 KiB
JavaScript
436 lines
14 KiB
JavaScript
const ArgumentType = require('../../extension-support/argument-type');
|
|
const BlockType = require('../../extension-support/block-type');
|
|
const Clone = require('../../util/clone');
|
|
const Cast = require('../../util/cast');
|
|
const MathUtil = require('../../util/math-util');
|
|
const Timer = require('../../util/timer');
|
|
|
|
/**
|
|
* An array of drum names, used in the play drum block.
|
|
* @type {string[]}
|
|
*/
|
|
const drumNames = [
|
|
'Snare Drum',
|
|
'Bass Drum',
|
|
'Side Stick',
|
|
'Crash Cymbal',
|
|
'Open Hi-Hat',
|
|
'Closed Hi-Hat',
|
|
'Tambourine',
|
|
'Hand Clap',
|
|
'Claves',
|
|
'Wood Block',
|
|
'Cowbell',
|
|
'Triangle',
|
|
'Bongo',
|
|
'Conga',
|
|
'Cabasa',
|
|
'Guiro',
|
|
'Vibraslap',
|
|
'Open Cuica'
|
|
];
|
|
|
|
/**
|
|
* An array of instrument names, used in the set instrument block.
|
|
* @type {string[]}
|
|
*/
|
|
const instrumentNames = [
|
|
'Piano',
|
|
'Electric Piano',
|
|
'Organ',
|
|
'Guitar',
|
|
'Electric Guitar',
|
|
'Bass',
|
|
'Pizzicato',
|
|
'Cello',
|
|
'Trombone',
|
|
'Clarinet',
|
|
'Saxophone',
|
|
'Flute',
|
|
'Wooden Flute',
|
|
'Bassoon',
|
|
'Choir',
|
|
'Vibraphone',
|
|
'Music Box',
|
|
'Steel Drum',
|
|
'Marimba',
|
|
'Synth Lead',
|
|
'Synth Pad'
|
|
];
|
|
|
|
/**
|
|
* Class for the music-related blocks in Scratch 3.0
|
|
* @param {Runtime} runtime - the runtime instantiating this block package.
|
|
* @constructor
|
|
*/
|
|
class Scratch3MusicBlocks {
|
|
constructor (runtime) {
|
|
/**
|
|
* The runtime instantiating this block package.
|
|
* @type {Runtime}
|
|
*/
|
|
this.runtime = runtime;
|
|
|
|
/**
|
|
* The current tempo in beats per minute. The tempo is a global property of the project,
|
|
* not a property of each sprite, so it is not stored in the MusicState object.
|
|
* @type {number}
|
|
*/
|
|
this.tempo = 60;
|
|
|
|
this.drumMenu = this._buildMenu(drumNames);
|
|
this.instrumentMenu = this._buildMenu(instrumentNames);
|
|
}
|
|
|
|
/**
|
|
* Build a menu using an array of strings.
|
|
* Used for creating the drum and instrument menus.
|
|
* @param {string[]} names - An array of names.
|
|
* @return {array} - An array of objects with text and value properties, for constructing a block menu.
|
|
* @private
|
|
*/
|
|
_buildMenu (names) {
|
|
const menu = [];
|
|
for (let i = 0; i < names.length; i++) {
|
|
const entry = {};
|
|
const num = i + 1; // Menu numbers are one-indexed
|
|
entry.text = `(${num}) ${names[i]}`;
|
|
entry.value = String(num);
|
|
menu.push(entry);
|
|
}
|
|
return menu;
|
|
}
|
|
|
|
/**
|
|
* The key to load & store a target's music-related state.
|
|
* @type {string}
|
|
*/
|
|
static get STATE_KEY () {
|
|
return 'Scratch.music';
|
|
}
|
|
|
|
/**
|
|
* The default music-related state, to be used when a target has no existing music state.
|
|
* @type {MusicState}
|
|
*/
|
|
static get DEFAULT_MUSIC_STATE () {
|
|
return {
|
|
currentInstrument: 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};
|
|
}
|
|
|
|
/**
|
|
* @param {Target} target - collect music state for this target.
|
|
* @returns {MusicState} the mutable music state associated with that target. This will be created if necessary.
|
|
* @private
|
|
*/
|
|
_getMusicState (target) {
|
|
let musicState = target.getCustomState(Scratch3MusicBlocks.STATE_KEY);
|
|
if (!musicState) {
|
|
musicState = Clone.simple(Scratch3MusicBlocks.DEFAULT_MUSIC_STATE);
|
|
target.setCustomState(Scratch3MusicBlocks.STATE_KEY, musicState);
|
|
}
|
|
return musicState;
|
|
}
|
|
|
|
/**
|
|
* @returns {object} metadata for this extension and its blocks.
|
|
*/
|
|
getInfo () {
|
|
return {
|
|
id: 'music',
|
|
name: 'Music',
|
|
blocks: [
|
|
{
|
|
opcode: 'playDrumForBeats',
|
|
blockType: BlockType.COMMAND,
|
|
text: 'play drum [DRUM] for [BEATS] beats',
|
|
arguments: {
|
|
DRUM: {
|
|
type: ArgumentType.NUMBER,
|
|
menu: 'drums',
|
|
defaultValue: 1
|
|
},
|
|
BEATS: {
|
|
type: ArgumentType.NUMBER,
|
|
defaultValue: 0.25
|
|
}
|
|
}
|
|
},
|
|
{
|
|
opcode: 'restForBeats',
|
|
blockType: BlockType.COMMAND,
|
|
text: 'rest for [BEATS] beats',
|
|
arguments: {
|
|
BEATS: {
|
|
type: ArgumentType.NUMBER,
|
|
defaultValue: 0.25
|
|
}
|
|
}
|
|
},
|
|
{
|
|
opcode: 'playNoteForBeats',
|
|
blockType: BlockType.COMMAND,
|
|
text: 'play note [NOTE] for [BEATS] beats',
|
|
arguments: {
|
|
NOTE: {
|
|
type: ArgumentType.NUMBER,
|
|
defaultValue: 60
|
|
},
|
|
BEATS: {
|
|
type: ArgumentType.NUMBER,
|
|
defaultValue: 0.25
|
|
}
|
|
}
|
|
},
|
|
{
|
|
opcode: 'setInstrument',
|
|
blockType: BlockType.COMMAND,
|
|
text: 'set instrument to [INSTRUMENT]',
|
|
arguments: {
|
|
INSTRUMENT: {
|
|
type: ArgumentType.NUMBER,
|
|
menu: 'instruments',
|
|
defaultValue: 1
|
|
}
|
|
}
|
|
},
|
|
{
|
|
opcode: 'setTempo',
|
|
blockType: BlockType.COMMAND,
|
|
text: 'set tempo to [TEMPO]',
|
|
arguments: {
|
|
TEMPO: {
|
|
type: ArgumentType.NUMBER,
|
|
defaultValue: 60
|
|
}
|
|
}
|
|
},
|
|
{
|
|
opcode: 'changeTempo',
|
|
blockType: BlockType.COMMAND,
|
|
text: 'change tempo by [TEMPO]',
|
|
arguments: {
|
|
TEMPO: {
|
|
type: ArgumentType.NUMBER,
|
|
defaultValue: 20
|
|
}
|
|
}
|
|
},
|
|
{
|
|
opcode: 'getTempo',
|
|
text: 'tempo',
|
|
blockType: BlockType.REPORTER
|
|
}
|
|
],
|
|
menus: {
|
|
drums: this.drumMenu,
|
|
instruments: this.instrumentMenu
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Play a drum sound for some number of beats.
|
|
* @param {object} args - the block arguments.
|
|
* @param {object} util - utility object provided by the runtime.
|
|
* @property {int} DRUM - the number of the drum to play.
|
|
* @property {number} BEATS - the duration in beats of the drum sound.
|
|
*/
|
|
playDrumForBeats (args, util) {
|
|
if (this._stackTimerNeedsInit(util)) {
|
|
let drum = Cast.toNumber(args.DRUM);
|
|
drum -= 1; // drums are one-indexed
|
|
if (typeof this.runtime.audioEngine === 'undefined') return;
|
|
drum = MathUtil.wrapClamp(drum, 0, this.runtime.audioEngine.numDrums - 1);
|
|
let beats = Cast.toNumber(args.BEATS);
|
|
beats = this._clampBeats(beats);
|
|
if (util.target.audioPlayer !== null) {
|
|
util.target.audioPlayer.playDrumForBeats(drum, beats);
|
|
}
|
|
this._startStackTimer(util, this._beatsToSec(beats));
|
|
} else {
|
|
this._checkStackTimer(util);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rest for some number of beats.
|
|
* @param {object} args - the block arguments.
|
|
* @param {object} util - utility object provided by the runtime.
|
|
* @property {number} BEATS - the duration in beats of the rest.
|
|
*/
|
|
restForBeats (args, util) {
|
|
if (this._stackTimerNeedsInit(util)) {
|
|
let beats = Cast.toNumber(args.BEATS);
|
|
beats = this._clampBeats(beats);
|
|
this._startStackTimer(util, this._beatsToSec(beats));
|
|
} else {
|
|
this._checkStackTimer(util);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Play a note using the current musical instrument for some number of beats.
|
|
* @param {object} args - the block arguments.
|
|
* @param {object} util - utility object provided by the runtime.
|
|
* @property {number} NOTE - the pitch of the note to play, interpreted as a MIDI note number.
|
|
* @property {number} BEATS - the duration in beats of the note.
|
|
*/
|
|
playNoteForBeats (args, util) {
|
|
if (this._stackTimerNeedsInit(util)) {
|
|
let note = Cast.toNumber(args.NOTE);
|
|
note = MathUtil.clamp(note,
|
|
Scratch3MusicBlocks.MIDI_NOTE_RANGE.min, Scratch3MusicBlocks.MIDI_NOTE_RANGE.max);
|
|
let beats = Cast.toNumber(args.BEATS);
|
|
beats = this._clampBeats(beats);
|
|
const musicState = this._getMusicState(util.target);
|
|
const inst = musicState.currentInstrument;
|
|
if (typeof this.runtime.audioEngine !== 'undefined') {
|
|
this.runtime.audioEngine.playNoteForBeatsWithInstAndVol(note, beats, inst, 100);
|
|
}
|
|
this._startStackTimer(util, this._beatsToSec(beats));
|
|
} else {
|
|
this._checkStackTimer(util);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clamp a duration in beats to the allowed min and max duration.
|
|
* @param {number} beats - a duration in beats.
|
|
* @return {number} - the clamped duration.
|
|
* @private
|
|
*/
|
|
_clampBeats (beats) {
|
|
return MathUtil.clamp(beats, Scratch3MusicBlocks.BEAT_RANGE.min, Scratch3MusicBlocks.BEAT_RANGE.max);
|
|
}
|
|
|
|
/**
|
|
* Convert a number of beats to a number of seconds, using the current tempo.
|
|
* @param {number} beats - number of beats to convert to secs.
|
|
* @return {number} seconds - number of seconds `beats` will last.
|
|
* @private
|
|
*/
|
|
_beatsToSec (beats) {
|
|
return (60 / this.tempo) * beats;
|
|
}
|
|
|
|
/**
|
|
* Check if the stack timer needs initialization.
|
|
* @param {object} util - utility object provided by the runtime.
|
|
* @return {boolean} - true if the stack timer needs to be initialized.
|
|
* @private
|
|
*/
|
|
_stackTimerNeedsInit (util) {
|
|
return !util.stackFrame.timer;
|
|
}
|
|
|
|
/**
|
|
* Start the stack timer and the yield the thread if necessary.
|
|
* @param {object} util - utility object provided by the runtime.
|
|
* @param {number} duration - a duration in seconds to set the timer for.
|
|
* @private
|
|
*/
|
|
_startStackTimer (util, duration) {
|
|
util.stackFrame.timer = new Timer();
|
|
util.stackFrame.timer.start();
|
|
util.stackFrame.duration = duration;
|
|
if (util.stackFrame.duration > 0) {
|
|
util.yield();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check the stack timer, and if its time is not up yet, yield the thread.
|
|
* @param {object} util - utility object provided by the runtime.
|
|
* @private
|
|
*/
|
|
_checkStackTimer (util) {
|
|
const timeElapsed = util.stackFrame.timer.timeElapsed();
|
|
if (timeElapsed < util.stackFrame.duration * 1000) {
|
|
util.yield();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Select an instrument for playing notes.
|
|
* @param {object} args - the block arguments.
|
|
* @param {object} util - utility object provided by the runtime.
|
|
* @property {int} INSTRUMENT - the number of the instrument to select.
|
|
* @return {Promise} - a promise which will resolve once the instrument has loaded.
|
|
*/
|
|
setInstrument (args, util) {
|
|
const musicState = this._getMusicState(util.target);
|
|
let instNum = Cast.toNumber(args.INSTRUMENT);
|
|
instNum -= 1; // instruments are one-indexed
|
|
if (typeof this.runtime.audioEngine === 'undefined') return;
|
|
instNum = MathUtil.wrapClamp(instNum, 0, this.runtime.audioEngine.numInstruments - 1);
|
|
musicState.currentInstrument = instNum;
|
|
return this.runtime.audioEngine.instrumentPlayer.loadInstrument(musicState.currentInstrument);
|
|
}
|
|
|
|
/**
|
|
* Set the current tempo to a new value.
|
|
* @param {object} args - the block arguments.
|
|
* @property {number} TEMPO - the tempo, in beats per minute.
|
|
*/
|
|
setTempo (args) {
|
|
const tempo = Cast.toNumber(args.TEMPO);
|
|
this._updateTempo(tempo);
|
|
}
|
|
|
|
/**
|
|
* Change the current tempo by some amount.
|
|
* @param {object} args - the block arguments.
|
|
* @property {number} TEMPO - the amount to change the tempo, in beats per minute.
|
|
*/
|
|
changeTempo (args) {
|
|
const change = Cast.toNumber(args.TEMPO);
|
|
const tempo = change + this.tempo;
|
|
this._updateTempo(tempo);
|
|
}
|
|
|
|
/**
|
|
* Update the current tempo, clamping it to the min and max allowable range.
|
|
* @param {number} tempo - the tempo to set, in beats per minute.
|
|
* @private
|
|
*/
|
|
_updateTempo (tempo) {
|
|
tempo = MathUtil.clamp(tempo, Scratch3MusicBlocks.TEMPO_RANGE.min, Scratch3MusicBlocks.TEMPO_RANGE.max);
|
|
this.tempo = tempo;
|
|
}
|
|
|
|
/**
|
|
* Get the current tempo.
|
|
* @return {number} - the current tempo, in beats per minute.
|
|
*/
|
|
getTempo () {
|
|
return this.tempo;
|
|
}
|
|
}
|
|
|
|
module.exports = Scratch3MusicBlocks;
|