diff --git a/package.json b/package.json index 825ff3032..4b8f69f5e 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,9 @@ "copy-webpack-plugin": "4.0.1", "escape-html": "1.0.3", "eslint": "^4.5.0", - "eslint-config-scratch": "^4.0.0", + "eslint-config-scratch": "^5.0.0", "expose-loader": "0.7.4", - "gh-pages": "^0.12.0", + "gh-pages": "^1.1.0", "got": "5.7.1", "highlightjs": "^9.8.0", "htmlparser2": "3.9.2", @@ -44,18 +44,18 @@ "json": "^9.0.4", "lodash.defaultsdeep": "4.6.0", "minilog": "3.1.0", - "promise": "7.1.1", + "promise": "8.0.1", "scratch-audio": "latest", "scratch-blocks": "latest", "scratch-render": "latest", "scratch-storage": "^0.3.0", - "script-loader": "0.7.0", - "socket.io-client": "1.7.3", + "script-loader": "0.7.2", + "socket.io-client": "2.0.4", "stats.js": "^0.17.0", "tap": "^10.2.0", "tiny-worker": "^2.1.1", "webpack": "^2.4.1", "webpack-dev-server": "^2.4.1", - "worker-loader": "0.8.1" + "worker-loader": "1.1.0" } } diff --git a/src/engine/blocks.js b/src/engine/blocks.js index 2f2de7b40..8776c6f6c 100644 --- a/src/engine/blocks.js +++ b/src/engine/blocks.js @@ -25,6 +25,30 @@ class Blocks { * @type {Array.} */ this._scripts = []; + + /** + * Runtime Cache + * @type {{inputs: {}, procedureParamNames: {}, procedureDefinitions: {}}} + * @private + */ + this._cache = { + /** + * Cache block inputs by block id + * @type {object.>} + */ + inputs: {}, + /** + * Cache procedure Param Names by block id + * @type {object.>} + */ + procedureParamNames: {}, + /** + * Cache procedure definitions by block id + * @type {object.} + */ + procedureDefinitions: {} + }; + } /** @@ -105,11 +129,16 @@ class Blocks { /** * Get all non-branch inputs for a block. * @param {?object} block the block to query. - * @return {!object} All non-branch inputs and their associated blocks. + * @return {?Array.} All non-branch inputs and their associated blocks. */ getInputs (block) { if (typeof block === 'undefined') return null; - const inputs = {}; + let inputs = this._cache.inputs[block.id]; + if (typeof inputs !== 'undefined') { + return inputs; + } + + inputs = {}; for (const input in block.inputs) { // Ignore blocks prefixed with branch prefix. if (input.substring(0, Blocks.BRANCH_INPUT_PREFIX.length) !== @@ -117,6 +146,8 @@ class Blocks { inputs[input] = block.inputs[input]; } } + + this._cache.inputs[block.id] = inputs; return inputs; } @@ -149,16 +180,24 @@ class Blocks { * @return {?string} ID of procedure definition. */ getProcedureDefinition (name) { + const blockID = this._cache.procedureDefinitions[name]; + if (typeof blockID !== 'undefined') { + return blockID; + } + for (const id in this._blocks) { if (!this._blocks.hasOwnProperty(id)) continue; const block = this._blocks[id]; if (block.opcode === 'procedures_definition') { const internal = this._getCustomBlockInternal(block); if (internal && internal.mutation.proccode === name) { - return id; // The outer define block id + this._cache.procedureDefinitions[name] = id; // The outer define block id + return id; } } } + + this._cache.procedureDefinitions[name] = null; return null; } @@ -168,14 +207,23 @@ class Blocks { * @return {?Array.} List of param names for a procedure. */ getProcedureParamNames (name) { + const cachedNames = this._cache.procedureParamNames[name]; + if (typeof cachedNames !== 'undefined') { + return cachedNames; + } + for (const id in this._blocks) { if (!this._blocks.hasOwnProperty(id)) continue; const block = this._blocks[id]; if (block.opcode === 'procedures_prototype' && block.mutation.proccode === name) { - return JSON.parse(block.mutation.argumentnames); + const paramNames = JSON.parse(block.mutation.argumentnames); + this._cache.procedureParamNames[name] = paramNames; + return paramNames; } } + + this._cache.procedureParamNames[name] = null; return null; } @@ -271,6 +319,15 @@ class Blocks { // --------------------------------------------------------------------- + /** + * Reset all runtime caches. + */ + resetCache () { + this._cache.inputs = {}; + this._cache.procedureParamNames = {}; + this._cache.procedureDefinitions = {}; + } + /** * Block management: create blocks and scripts from a `create` event * @param {!object} block Blockly create event to be processed @@ -289,6 +346,8 @@ class Blocks { if (block.topLevel) { this._addScript(block.id); } + + this.resetCache(); } /** @@ -301,7 +360,6 @@ class Blocks { if (['field', 'mutation', 'checkbox'].indexOf(args.element) === -1) return; const block = this._blocks[args.id]; if (typeof block === 'undefined') return; - const wasMonitored = block.isMonitored; switch (args.element) { case 'field': @@ -337,6 +395,8 @@ class Blocks { } break; } + + this.resetCache(); } /** @@ -393,6 +453,7 @@ class Blocks { } this._blocks[e.id].parent = e.newParent; } + this.resetCache(); } @@ -447,6 +508,8 @@ class Blocks { // Delete block itself. delete this._blocks[blockId]; + + this.resetCache(); } // --------------------------------------------------------------------- diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index 938e791dc..c3346a22d 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -8,7 +8,7 @@ const BlockType = require('./block-type'); // TODO: change extension spec so that library info, including extension ID, can be collected through static methods const Scratch3PenBlocks = require('../blocks/scratch3_pen'); const Scratch3WeDo2Blocks = require('../blocks/scratch3_wedo2'); -const Scratch3MusicBlocks = require('../blocks/scratch3_music'); +const Scratch3MusicBlocks = require('../extensions/scratch3_music'); const builtinExtensions = { pen: Scratch3PenBlocks, wedo2: Scratch3WeDo2Blocks, diff --git a/src/extensions/scratch3_music/assets/1-snare.mp3 b/src/extensions/scratch3_music/assets/1-snare.mp3 new file mode 100644 index 000000000..38970d352 Binary files /dev/null and b/src/extensions/scratch3_music/assets/1-snare.mp3 differ diff --git a/src/extensions/scratch3_music/assets/10-wood-block.mp3 b/src/extensions/scratch3_music/assets/10-wood-block.mp3 new file mode 100644 index 000000000..9c551b366 Binary files /dev/null and b/src/extensions/scratch3_music/assets/10-wood-block.mp3 differ diff --git a/src/extensions/scratch3_music/assets/11-cowbell.mp3 b/src/extensions/scratch3_music/assets/11-cowbell.mp3 new file mode 100644 index 000000000..4f623cd12 Binary files /dev/null and b/src/extensions/scratch3_music/assets/11-cowbell.mp3 differ diff --git a/src/extensions/scratch3_music/assets/12-triangle.mp3 b/src/extensions/scratch3_music/assets/12-triangle.mp3 new file mode 100644 index 000000000..0987759ca Binary files /dev/null and b/src/extensions/scratch3_music/assets/12-triangle.mp3 differ diff --git a/src/extensions/scratch3_music/assets/13-bongo.mp3 b/src/extensions/scratch3_music/assets/13-bongo.mp3 new file mode 100644 index 000000000..5faf07936 Binary files /dev/null and b/src/extensions/scratch3_music/assets/13-bongo.mp3 differ diff --git a/src/extensions/scratch3_music/assets/14-conga.mp3 b/src/extensions/scratch3_music/assets/14-conga.mp3 new file mode 100644 index 000000000..585feca6a Binary files /dev/null and b/src/extensions/scratch3_music/assets/14-conga.mp3 differ diff --git a/src/extensions/scratch3_music/assets/15-cabasa.mp3 b/src/extensions/scratch3_music/assets/15-cabasa.mp3 new file mode 100644 index 000000000..4fce06176 Binary files /dev/null and b/src/extensions/scratch3_music/assets/15-cabasa.mp3 differ diff --git a/src/extensions/scratch3_music/assets/16-guiro.mp3 b/src/extensions/scratch3_music/assets/16-guiro.mp3 new file mode 100644 index 000000000..882feaaaf Binary files /dev/null and b/src/extensions/scratch3_music/assets/16-guiro.mp3 differ diff --git a/src/extensions/scratch3_music/assets/17-vibraslap.mp3 b/src/extensions/scratch3_music/assets/17-vibraslap.mp3 new file mode 100644 index 000000000..faf7315ae Binary files /dev/null and b/src/extensions/scratch3_music/assets/17-vibraslap.mp3 differ diff --git a/src/extensions/scratch3_music/assets/18-cuica.mp3 b/src/extensions/scratch3_music/assets/18-cuica.mp3 new file mode 100644 index 000000000..1970b60cb Binary files /dev/null and b/src/extensions/scratch3_music/assets/18-cuica.mp3 differ diff --git a/src/extensions/scratch3_music/assets/2-bass-drum.mp3 b/src/extensions/scratch3_music/assets/2-bass-drum.mp3 new file mode 100644 index 000000000..176d5bacc Binary files /dev/null and b/src/extensions/scratch3_music/assets/2-bass-drum.mp3 differ diff --git a/src/extensions/scratch3_music/assets/3-side-stick.mp3 b/src/extensions/scratch3_music/assets/3-side-stick.mp3 new file mode 100644 index 000000000..c7757c932 Binary files /dev/null and b/src/extensions/scratch3_music/assets/3-side-stick.mp3 differ diff --git a/src/extensions/scratch3_music/assets/4-crash-cymbal.mp3 b/src/extensions/scratch3_music/assets/4-crash-cymbal.mp3 new file mode 100644 index 000000000..1522b05cb Binary files /dev/null and b/src/extensions/scratch3_music/assets/4-crash-cymbal.mp3 differ diff --git a/src/extensions/scratch3_music/assets/5-open-hi-hat.mp3 b/src/extensions/scratch3_music/assets/5-open-hi-hat.mp3 new file mode 100644 index 000000000..2dc63fa30 Binary files /dev/null and b/src/extensions/scratch3_music/assets/5-open-hi-hat.mp3 differ diff --git a/src/extensions/scratch3_music/assets/6-closed-hi-hat.mp3 b/src/extensions/scratch3_music/assets/6-closed-hi-hat.mp3 new file mode 100644 index 000000000..eb870f2b5 Binary files /dev/null and b/src/extensions/scratch3_music/assets/6-closed-hi-hat.mp3 differ diff --git a/src/extensions/scratch3_music/assets/7-tambourine.mp3 b/src/extensions/scratch3_music/assets/7-tambourine.mp3 new file mode 100644 index 000000000..f0ea66a58 Binary files /dev/null and b/src/extensions/scratch3_music/assets/7-tambourine.mp3 differ diff --git a/src/extensions/scratch3_music/assets/8-hand-clap.mp3 b/src/extensions/scratch3_music/assets/8-hand-clap.mp3 new file mode 100644 index 000000000..a5b55f522 Binary files /dev/null and b/src/extensions/scratch3_music/assets/8-hand-clap.mp3 differ diff --git a/src/extensions/scratch3_music/assets/9-claves.mp3 b/src/extensions/scratch3_music/assets/9-claves.mp3 new file mode 100644 index 000000000..75b76533d Binary files /dev/null and b/src/extensions/scratch3_music/assets/9-claves.mp3 differ diff --git a/src/blocks/scratch3_music.js b/src/extensions/scratch3_music/index.js similarity index 58% rename from src/blocks/scratch3_music.js rename to src/extensions/scratch3_music/index.js index 5cccaf278..fc07b6479 100644 --- a/src/blocks/scratch3_music.js +++ b/src/extensions/scratch3_music/index.js @@ -1,62 +1,9 @@ -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' -]; +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'); /** * Class for the music-related blocks in Scratch 3.0 @@ -78,27 +25,246 @@ class Scratch3MusicBlocks { */ this.tempo = 60; - this.drumMenu = this._buildMenu(drumNames); - this.instrumentMenu = this._buildMenu(instrumentNames); + /** + * The number of drum sounds currently being played simultaneously. + * @type {number} + * @private + */ + this._drumConcurrencyCounter = 0; + + /** + * An array of audio buffers, one for each drum sound. + * @type {Array} + * @private + */ + this._drumBuffers = []; + + this._loadAllDrumSounds(); } /** - * 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. + * Download and decode the full set of drum sounds, and store the audio buffers + * in the drum buffers array. + * @TODO: Also load the instrument sounds here (rename this fn) + */ + _loadAllDrumSounds () { + const loadingPromises = []; + this.DRUM_INFO.forEach((drumInfo, index) => { + const promise = this._loadSound(drumInfo.fileName, index, this._drumBuffers); + loadingPromises.push(promise); + }); + Promise.all(loadingPromises).then(() => { + // @TODO: Update the extension status indicator. + }); + } + + /** + * Download and decode a sound, and store the buffer in an array. + * @param {string} fileName - the audio file name. + * @param {number} index - the index at which to store the audio buffer. + * @param {array} bufferArray - the array of buffers in which to store it. + * @return {Promise} - a promise which will resolve once the sound has loaded. + */ + _loadSound (fileName, index, bufferArray) { + if (!this.runtime.storage) return; + if (!this.runtime.audioEngine) return; + return this.runtime.storage.load(this.runtime.storage.AssetType.Sound, fileName, 'mp3') + .then(soundAsset => + this.runtime.audioEngine.audioContext.decodeAudioData(soundAsset.data.buffer) + ) + .then(buffer => { + bufferArray[index] = buffer; + }); + } + + /** + * Create data for a menu in scratch-blocks format, consisting of an array of objects with text and + * value properties. The text is a translated string, and the value is one-indexed. + * @param {object[]} info - An array of info objects each having a name property. + * @return {array} - An array of objects with text and value properties. * @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; + _buildMenu (info) { + return info.map((entry, index) => { + const obj = {}; + obj.text = entry.name; + obj.value = index + 1; + return obj; + }); + } + + /** + * An array of translatable drum names and corresponding audio file names. + * @type {array} + */ + get DRUM_INFO () { + return [ + { + name: '(1) Snare Drum', + fileName: '1-snare' + }, + { + name: '(2) Bass Drum', + fileName: '2-bass-drum' + }, + { + name: '(3) Side Stick', + fileName: '3-side-stick' + }, + { + name: '(4) Crash Cymbal', + fileName: '4-crash-cymbal' + }, + { + name: '(5) Open Hi-Hat', + fileName: '5-open-hi-hat' + }, + { + name: '(6) Closed Hi-Hat', + fileName: '6-closed-hi-hat' + }, + { + name: '(7) Tambourine', + fileName: '7-tambourine' + }, + { + name: '(8) Hand Clap', + fileName: '8-hand-clap' + }, + { + name: '(9) Claves', + fileName: '9-claves' + }, + { + name: '(10) Wood Block', + fileName: '10-wood-block' + }, + { + name: '(11) Cowbell', + fileName: '11-cowbell' + }, + { + name: '(12) Triangle', + fileName: '12-triangle' + }, + { + name: '(13) Bongo', + fileName: '13-bongo' + }, + { + name: '(14) Conga', + fileName: '14-conga' + }, + { + name: '(15) Cabasa', + fileName: '15-cabasa' + }, + { + name: '(16) Guiro', + fileName: '16-guiro' + }, + { + name: '(17) Vibraslap', + fileName: '17-vibraslap' + }, + { + name: '(18) Cuica', + fileName: '18-cuica' + } + ]; + } + + /** + * An array of translatable instrument names and corresponding audio file names. + * @type {array} + */ + get INSTRUMENT_INFO () { + return [ + { + name: '(1) Piano', + fileName: '1-piano' + }, + { + name: '(2) Electric Piano', + fileName: '2-electric-piano' + }, + { + name: '(3) Organ', + fileName: '3-organ' + }, + { + name: '(4) Guitar', + fileName: '4-guitar' + }, + { + name: '(5) Electric Guitar', + fileName: '5-electric-guitar' + }, + { + name: '(6) Bass', + fileName: '6-bass' + }, + { + name: '(7) Pizzicato', + fileName: '7-pizzicato' + }, + { + name: '(8) Cello', + fileName: '8-cello' + }, + { + name: '(9) Trombone', + fileName: '9-trombone' + }, + { + name: '(10) Clarinet', + fileName: '10-clarinet' + }, + { + name: '(11) Saxophone', + fileName: '11-saxophone' + }, + { + name: '(12) Flute', + fileName: '12-flute' + }, + { + name: '(13) Wooden Flute', + fileName: '13-wooden-flute' + }, + { + name: '(14) Bassoon', + fileName: '14-bassoon' + }, + { + name: '(15) Choir', + fileName: '15-choir' + }, + { + name: '(16) Vibraphone', + fileName: '16-vibraphone' + }, + { + name: '(17) Music Box', + fileName: '17-music-box' + }, + { + name: '(18) Steel Drum', + fileName: '18-steel-drum' + }, + { + name: '(19) Marimba', + fileName: '19-marimba' + }, + { + name: '(20) Synth Lead', + fileName: '20-synth-lead' + }, + { + name: '(21) Synth Pad', + fileName: '21-synth-pad' + } + ]; } /** @@ -143,6 +309,14 @@ class Scratch3MusicBlocks { return {min: 20, max: 500}; } + /** + * The maximum number of sounds to allow to play simultaneously. + * @type {number} + */ + static get CONCURRENCY_LIMIT () { + return 30; + } + /** * @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. @@ -248,8 +422,8 @@ class Scratch3MusicBlocks { } ], menus: { - drums: this.drumMenu, - instruments: this.instrumentMenu + drums: this._buildMenu(this.DRUM_INFO), + instruments: this._buildMenu(this.INSTRUMENT_INFO) } }; } @@ -264,20 +438,41 @@ class Scratch3MusicBlocks { playDrumForBeats (args, util) { if (this._stackTimerNeedsInit(util)) { let drum = Cast.toNumber(args.DRUM); + drum = Math.round(drum); drum -= 1; // drums are one-indexed - if (typeof this.runtime.audioEngine === 'undefined') return; - drum = MathUtil.wrapClamp(drum, 0, this.runtime.audioEngine.numDrums - 1); + drum = MathUtil.wrapClamp(drum, 0, this.DRUM_INFO.length - 1); let beats = Cast.toNumber(args.BEATS); beats = this._clampBeats(beats); - if (util.target.audioPlayer !== null) { - util.target.audioPlayer.playDrumForBeats(drum, beats); - } + this._playDrumNum(util, drum); this._startStackTimer(util, this._beatsToSec(beats)); } else { this._checkStackTimer(util); } } + /** + * Play a drum sound using its 0-indexed number. + * @param {object} util - utility object provided by the runtime. + * @param {number} drumNum - the number of the drum to play. + * @private + */ + _playDrumNum (util, drumNum) { + if (util.target.audioPlayer === null) return; + // If we're playing too many sounds, do not play the drum sound. + if (this._drumConcurrencyCounter > Scratch3MusicBlocks.CONCURRENCY_LIMIT) { + return; + } + const outputNode = util.target.audioPlayer.getInputNode(); + const bufferSource = this.runtime.audioEngine.audioContext.createBufferSource(); + bufferSource.buffer = this._drumBuffers[drumNum]; + bufferSource.connect(outputNode); + bufferSource.start(); + this._drumConcurrencyCounter++; + bufferSource.onended = () => { + this._drumConcurrencyCounter--; + }; + } + /** * Rest for some number of beats. * @param {object} args - the block arguments. @@ -359,9 +554,7 @@ class Scratch3MusicBlocks { util.stackFrame.timer = new Timer(); util.stackFrame.timer.start(); util.stackFrame.duration = duration; - if (util.stackFrame.duration > 0) { - util.yield(); - } + util.yield(); } /** diff --git a/test/unit/blocks_music.js b/test/unit/extension_music.js similarity index 78% rename from test/unit/blocks_music.js rename to test/unit/extension_music.js index ae097beb1..b9e3c8afc 100644 --- a/test/unit/blocks_music.js +++ b/test/unit/extension_music.js @@ -1,10 +1,9 @@ const test = require('tap').test; -const Music = require('../../src/blocks/scratch3_music'); +const Music = require('../../src/extensions/scratch3_music/index.js'); let playedDrum; let playedInstrument; const runtime = { audioEngine: { - numDrums: 3, numInstruments: 3, instrumentPlayer: { loadInstrument: instrument => (playedInstrument = instrument) @@ -12,13 +11,14 @@ const runtime = { } }; const blocks = new Music(runtime); +blocks._playDrumNum = (util, drum) => (playedDrum = drum); + const util = { + stackFrame: Object.create(null), target: { - audioPlayer: { - playDrumForBeats: drum => (playedDrum = drum) - } + audioPlayer: null }, - stackFrame: Object.create(null) + yield: () => null }; test('playDrum uses 1-indexing and wrap clamps', t => { @@ -26,7 +26,7 @@ test('playDrum uses 1-indexing and wrap clamps', t => { blocks.playDrumForBeats(args, util); t.strictEqual(playedDrum, 0); - args = {DRUM: runtime.audioEngine.numDrums + 1}; + args = {DRUM: blocks.DRUM_INFO.length + 1}; blocks.playDrumForBeats(args, util); t.strictEqual(playedDrum, 0); diff --git a/webpack.config.js b/webpack.config.js index 7166587fc..f2d4c880c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -60,7 +60,13 @@ module.exports = [ libraryTarget: 'commonjs2', path: path.resolve(__dirname, 'dist/node'), filename: '[name].js' - } + }, + plugins: base.plugins.concat([ + new CopyWebpackPlugin([{ + from: './src/extensions/scratch3_music/assets', + to: 'assets/scratch3_music' + }]) + ]) }), // Playground defaultsDeep({}, base, {