diff --git a/src/blocks/scratch3_music.js b/src/blocks/scratch3_music.js new file mode 100644 index 000000000..f70fd48ae --- /dev/null +++ b/src/blocks/scratch3_music.js @@ -0,0 +1,371 @@ +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'); + +/** + * 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; + + 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. + * @return {Promise} - a promise which will resolve at the end of the duration. + */ + playDrumForBeats (args, 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) return; + return util.target.audioPlayer.playDrumForBeats(drum, beats); + } + + /** + * 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. + * @return {Promise} - a promise which will resolve at the end of the duration. + */ + restForBeats (args) { + let beats = Cast.toNumber(args.BEATS); + beats = this._clampBeats(beats); + if (typeof this.runtime.audioEngine === 'undefined') return; + return this.runtime.audioEngine.waitForBeats(beats); + } + + /** + * 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. + * @return {Promise} - a promise which will resolve at the end of the duration. + */ + playNoteForBeats (args, 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') return; + return this.runtime.audioEngine.playNoteForBeatsWithInstAndVol(note, beats, inst, 100); + } + + /** + * 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); + } + + /** + * 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); + } + + /** + * 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); + if (typeof this.runtime.audioEngine === 'undefined') return; + const tempo = change + this.runtime.audioEngine.currentTempo; + 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); + if (typeof this.runtime.audioEngine === 'undefined') return; + this.runtime.audioEngine.setTempo(tempo); + } + + /** + * Get the current tempo. + * @return {number} - the current tempo, in beats per minute. + */ + getTempo () { + if (typeof this.runtime.audioEngine === 'undefined') return; + return this.runtime.audioEngine.currentTempo; + } +} + +module.exports = Scratch3MusicBlocks; diff --git a/src/blocks/scratch3_sound.js b/src/blocks/scratch3_sound.js index 1f7745742..6f5fed972 100644 --- a/src/blocks/scratch3_sound.js +++ b/src/blocks/scratch3_sound.js @@ -91,10 +91,6 @@ class Scratch3SoundBlocks { sound_play: this.playSound, sound_playuntildone: this.playSoundAndWait, sound_stopallsounds: this.stopAllSounds, - sound_playnoteforbeats: this.playNoteForBeats, - sound_playdrumforbeats: this.playDrumForBeats, - sound_restforbeats: this.restForBeats, - sound_setinstrumentto: this.setInstrument, sound_seteffectto: this.setEffect, sound_changeeffectby: this.changeEffect, sound_cleareffects: this.clearEffects, @@ -103,10 +99,7 @@ class Scratch3SoundBlocks { sound_effects_menu: this.effectsMenu, sound_setvolumeto: this.setVolume, sound_changevolumeby: this.changeVolume, - sound_volume: this.getVolume, - sound_settempotobpm: this.setTempo, - sound_changetempoby: this.changeTempo, - sound_tempo: this.getTempo + sound_volume: this.getVolume }; } @@ -167,50 +160,6 @@ class Scratch3SoundBlocks { util.target.audioPlayer.stopAllSounds(); } - playNoteForBeats (args, util) { - let note = Cast.toNumber(args.NOTE); - note = MathUtil.clamp(note, Scratch3SoundBlocks.MIDI_NOTE_RANGE.min, Scratch3SoundBlocks.MIDI_NOTE_RANGE.max); - let beats = Cast.toNumber(args.BEATS); - beats = this._clampBeats(beats); - const soundState = this._getSoundState(util.target); - const inst = soundState.currentInstrument; - const vol = soundState.volume; - if (typeof this.runtime.audioEngine === 'undefined') return; - return this.runtime.audioEngine.playNoteForBeatsWithInstAndVol(note, beats, inst, vol); - } - - playDrumForBeats (args, 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) return; - return util.target.audioPlayer.playDrumForBeats(drum, beats); - } - - restForBeats (args) { - let beats = Cast.toNumber(args.BEATS); - beats = this._clampBeats(beats); - if (typeof this.runtime.audioEngine === 'undefined') return; - return this.runtime.audioEngine.waitForBeats(beats); - } - - _clampBeats (beats) { - return MathUtil.clamp(beats, Scratch3SoundBlocks.BEAT_RANGE.min, Scratch3SoundBlocks.BEAT_RANGE.max); - } - - setInstrument (args, util) { - const soundState = this._getSoundState(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); - soundState.currentInstrument = instNum; - return this.runtime.audioEngine.instrumentPlayer.loadInstrument(soundState.currentInstrument); - } - setEffect (args, util) { this._updateEffect(args, util, false); } @@ -273,29 +222,6 @@ class Scratch3SoundBlocks { return soundState.volume; } - setTempo (args) { - const tempo = Cast.toNumber(args.TEMPO); - this._updateTempo(tempo); - } - - changeTempo (args) { - const change = Cast.toNumber(args.TEMPO); - if (typeof this.runtime.audioEngine === 'undefined') return; - const tempo = change + this.runtime.audioEngine.currentTempo; - this._updateTempo(tempo); - } - - _updateTempo (tempo) { - tempo = MathUtil.clamp(tempo, Scratch3SoundBlocks.TEMPO_RANGE.min, Scratch3SoundBlocks.TEMPO_RANGE.max); - if (typeof this.runtime.audioEngine === 'undefined') return; - this.runtime.audioEngine.setTempo(tempo); - } - - getTempo () { - if (typeof this.runtime.audioEngine === 'undefined') return; - return this.runtime.audioEngine.currentTempo; - } - soundsMenu (args) { return args.SOUND_MENU; } diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index 5cfa2b4a3..938e791dc 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -8,9 +8,11 @@ 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 builtinExtensions = { pen: Scratch3PenBlocks, - wedo2: Scratch3WeDo2Blocks + wedo2: Scratch3WeDo2Blocks, + music: Scratch3MusicBlocks }; /** diff --git a/src/serialization/sb2_specmap.js b/src/serialization/sb2_specmap.js index cf5be1c89..b511d59c8 100644 --- a/src/serialization/sb2_specmap.js +++ b/src/serialization/sb2_specmap.js @@ -418,7 +418,7 @@ const specMap = { ] }, 'playDrum': { - opcode: 'sound_playdrumforbeats', + opcode: 'music.playDrumForBeats', argMap: [ { type: 'input', @@ -433,7 +433,7 @@ const specMap = { ] }, 'rest:elapsed:from:': { - opcode: 'sound_restforbeats', + opcode: 'music.restForBeats', argMap: [ { type: 'input', @@ -443,7 +443,7 @@ const specMap = { ] }, 'noteOn:duration:elapsed:from:': { - opcode: 'sound_playnoteforbeats', + opcode: 'music.playNoteForBeats', argMap: [ { type: 'input', @@ -458,7 +458,7 @@ const specMap = { ] }, 'instrument:': { - opcode: 'sound_setinstrumentto', + opcode: 'music.setInstrument', argMap: [ { type: 'input', @@ -493,7 +493,7 @@ const specMap = { ] }, 'changeTempoBy:': { - opcode: 'sound_changetempoby', + opcode: 'music.changeTempo', argMap: [ { type: 'input', @@ -503,7 +503,7 @@ const specMap = { ] }, 'setTempoTo:': { - opcode: 'sound_settempotobpm', + opcode: 'music.setTempo', argMap: [ { type: 'input', @@ -513,7 +513,7 @@ const specMap = { ] }, 'tempo': { - opcode: 'sound_tempo', + opcode: 'music.getTempo', argMap: [ ] }, diff --git a/test/integration/sound.js b/test/integration/sound.js index 50100d70f..1c5f20dcd 100644 --- a/test/integration/sound.js +++ b/test/integration/sound.js @@ -1,12 +1,17 @@ +const Worker = require('tiny-worker'); const path = require('path'); const test = require('tap').test; const makeTestStorage = require('../fixtures/make-test-storage'); const extract = require('../fixtures/extract'); const VirtualMachine = require('../../src/index'); +const dispatch = require('../../src/dispatch/central-dispatch'); const uri = path.resolve(__dirname, '../fixtures/sound.sb2'); const project = extract(uri); +// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead. +dispatch.workerClass = Worker; + test('sound', t => { const vm = new VirtualMachine(); vm.attachStorage(makeTestStorage()); diff --git a/test/unit/blocks_music.js b/test/unit/blocks_music.js new file mode 100644 index 000000000..3b8f8d949 --- /dev/null +++ b/test/unit/blocks_music.js @@ -0,0 +1,48 @@ +const test = require('tap').test; +const Music = require('../../src/blocks/scratch3_music'); +let playedDrum; +let playedInstrument; +const runtime = { + audioEngine: { + numDrums: 3, + numInstruments: 3, + instrumentPlayer: { + loadInstrument: instrument => (playedInstrument = instrument) + } + } +}; +const blocks = new Music(runtime); +const util = { + target: { + audioPlayer: { + playDrumForBeats: drum => (playedDrum = drum) + } + } +}; + +test('playDrum uses 1-indexing and wrap clamps', t => { + let args = {DRUM: 1}; + blocks.playDrumForBeats(args, util); + t.strictEqual(playedDrum, 0); + + args = {DRUM: runtime.audioEngine.numDrums + 1}; + blocks.playDrumForBeats(args, util); + t.strictEqual(playedDrum, 0); + + t.end(); +}); + +test('setInstrument uses 1-indexing and wrap clamps', t => { + // Stub getMusicState + blocks._getMusicState = () => ({}); + + let args = {INSTRUMENT: 1}; + blocks.setInstrument(args, util); + t.strictEqual(playedInstrument, 0); + + args = {INSTRUMENT: runtime.audioEngine.numInstruments + 1}; + blocks.setInstrument(args, util); + t.strictEqual(playedInstrument, 0); + + t.end(); +}); diff --git a/test/unit/blocks_sounds.js b/test/unit/blocks_sounds.js index 753f46f0c..9a44e7f9f 100644 --- a/test/unit/blocks_sounds.js +++ b/test/unit/blocks_sounds.js @@ -1,18 +1,8 @@ const test = require('tap').test; const Sound = require('../../src/blocks/scratch3_sound'); let playedSound; -let playedDrum; -let playedInstrument; -const runtime = { - audioEngine: { - numDrums: 3, - numInstruments: 3, - instrumentPlayer: { - loadInstrument: instrument => (playedInstrument = instrument) - } - } -}; -const blocks = new Sound(runtime); + +const blocks = new Sound(); const util = { target: { sprite: { @@ -24,8 +14,7 @@ const util = { ] }, audioPlayer: { - playSound: soundId => (playedSound = soundId), - playDrumForBeats: drum => (playedDrum = drum) + playSound: soundId => (playedSound = soundId) } } }; @@ -82,30 +71,3 @@ test('playSound prioritizes sound name if given a string', t => { t.strictEqual(playedSound, 'fourth soundId'); t.end(); }); - -test('playDrum uses 1-indexing and wrap clamps', t => { - let args = {DRUM: 1}; - blocks.playDrumForBeats(args, util); - t.strictEqual(playedDrum, 0); - - args = {DRUM: runtime.audioEngine.numDrums + 1}; - blocks.playDrumForBeats(args, util); - t.strictEqual(playedDrum, 0); - - t.end(); -}); - -test('setInstrument uses 1-indexing and wrap clamps', t => { - // Stub getSoundState - blocks._getSoundState = () => ({}); - - let args = {INSTRUMENT: 1}; - blocks.setInstrument(args, util); - t.strictEqual(playedInstrument, 0); - - args = {INSTRUMENT: runtime.audioEngine.numInstruments + 1}; - blocks.setInstrument(args, util); - t.strictEqual(playedInstrument, 0); - - t.end(); -});