本記事について
Turbowarpのカスタム拡張機能をる方法の説明、その5です。
前記事までで、スプライトを動かす、ふきだしを出す、という簡単なScratch操作を実装してみました。
今回は、テスト用Javascriptのなかで『スピーチ』をさせてみようと思います。
やりたいことの解説
- Scratchスピーチを実装する(再現する)
https://synthesis-service.scratch.mit.edu/synth?locale=ja-JP&gender=female&text=こんにちは
locale=『言語』、gender=『男女(male/female)』、text=『スピーチさせたい文字列』を指定し、音声データを取得し、音声データを再生することでスピーチをさせる仕組みです。
動画による解説
拡張機能のコード
Extension.js
/**
* Turbowarpの『カスタム拡張機能』を使おう【5】
* 外部JSファイルにて、スピーチさせる
*/
((Scratch) => {
/** 拡張機能ID */
const ExtensionID = 'MyExtensionSpeech';
/** 拡張機能表示名 */
const ExtensionName = '独自スピーチ実装';
// 歯車画像URL
const GEAR_IMAGE_SVG_URI
= 'https://amami-harhid.github.io/turbowarpExtensions/assets/gear.svg';
// テスト用JSファイルの場所(HOST+DIRECTORY)
const TEST_URL
= 'http://127.0.0.1:5500/turbowarpExtensions/_05_extension';
メニューで使う固定値を宣言
/**
* 言語表示名
*/
const LOCALE_TEXT = {
ENGLISH : 'English',
JAPANESE: "日本語",
}
/**
* 言語ID
*/
const LOCALE_VALUE = {
ENGLISH : 'en-US',
JAPANESE : 'ja-JP',
}
/**
* 性別表示名
*/
const GENDER_TEXT = {
MALE: '男声',
FEMALE: '女声',
};
/**
* 性別ID
*/
const GENDER_VALUE = {
MALE: 'male',
FEMALE: 'female',
}
/**
* 拡張機能定義
*/
const ExtensionInfo = {
id: ExtensionID,
name: ExtensionName,
blocks: [
{
opcode: "loadJSFileSetting",
blockType: Scratch.BlockType.COMMAND,
text: "[IMG_GEAR]JSファイルを指定する[JSURL]",
arguments: {
IMG_GEAR: {
type: Scratch.ArgumentType.IMAGE, //タイプ
dataURI: GEAR_IMAGE_SVG_URI, //歯車画像のURI
},
JSURL: {
type: Scratch.ArgumentType.STRING,
defaultValue: `${TEST_URL}/sub.js`,
},
},
},
{
opcode: "setup",
blockType: Scratch.BlockType.COMMAND,
text: "[IMG_GEAR]事前準備",
arguments: {
IMG_GEAR: {
type: Scratch.ArgumentType.IMAGE, //タイプ
dataURI: GEAR_IMAGE_SVG_URI, //歯車画像のURI
},
},
},
{
opcode: "speech",
blockType: Scratch.BlockType.COMMAND,
text: "[IMG_GEAR]話す [text],[locale],[gender]",
arguments: {
IMG_GEAR: {
type: Scratch.ArgumentType.IMAGE, //タイプ
dataURI: GEAR_IMAGE_SVG_URI, //歯車画像のURI
},
text: {
type: Scratch.ArgumentType.STRING,
defaultValue: "こんにちは",
},
locale: {
type: Scratch.ArgumentType.NUMBER,
menu: 'LocaleMenu',
defaultValue: LOCALE_VALUE.JAPANESE,
},
gender: {
type: Scratch.ArgumentType.NUMBER,
menu: 'GenderMenu',
defaultValue: GENDER_VALUE.MALE,
}
},
},
{
opcode: "speechAndWait",
blockType: Scratch.BlockType.COMMAND,
text: "[IMG_GEAR]話し終わるまで待つ[text],[locale],[gender]",
arguments: {
IMG_GEAR: {
type: Scratch.ArgumentType.IMAGE, //タイプ
dataURI: GEAR_IMAGE_SVG_URI, //歯車画像のURI
},
text: {
type: Scratch.ArgumentType.STRING,
defaultValue: "こんにちは",
},
locale: {
type: Scratch.ArgumentType.NUMBER,
menu: 'LocaleMenu',
defaultValue: LOCALE_VALUE.JAPANESE,
},
gender: {
type: Scratch.ArgumentType.NUMBER,
menu: 'GenderMenu',
defaultValue: GENDER_VALUE.MALE,
}
},
},
],
// メニューの定義
menus: {
LocaleMenu: {
items: [
{text : LOCALE_TEXT.JAPANESE, value : LOCALE_VALUE.JAPANESE},
{text : LOCALE_TEXT.ENGLISH, value : LOCALE_VALUE.ENGLISH},
],
},
GenderMenu: {
items: [
{text : GENDER_TEXT.MALE, value : GENDER_VALUE.MALE},
{text : GENDER_TEXT.FEMALE, value : GENDER_VALUE.FEMALE},
],
}
}
}
/**
* 独自スピーチ用のクラス
*/
class MyExtension {
getInfo(){
return ExtensionInfo;
}
外部JSファイルを読み込む処理
/**
* ロードするJSファイルのURLを設定する
* @param {*} args
* @param {*} util
*/
loadJSFileSetting( args, util ){
this.jsUrl = args.JSURL;
}
/**
* JSファイルをロードする
* @param {*} args
* @param {*} util
*/
async setup( args, util ){
try{
const _t = new Date().getTime();
const sub = await import(`${this.jsUrl}?_t=${_t}`);
// 読み込むJSは export {TestJS} をしている前提。
this.testJS = new sub.TestJS();
}catch(e){
const mesagge = '読み込みに失敗した、もしくはクラス定義が存在しないみたいです'
console.error( mesagge, e );
alert(mesagge);
}
}
スピーチさせる処理
/**
* 話す
* @param {*} args
* @param {*} util
*/
speech(args, util ) {
this.testJS.speech(args, util);
}
/**
* 話し終わるまで待つ
* @param {*} args
* @param {*} util
*/
async speechAndWait(args, util ) {
await this.testJS.speechAndWait(args, util);
}
}
登録する処理
/** 独自Speechのインスタンスを登録する */
Scratch.extensions.register(new MyExtension());
おわり
})(Scratch);
外部JSファイルコード
sub.js
// 音声合成用URL(scratch.mit.edu)
const SERVER_HOST = "https://synthesis-service.scratch.mit.edu";
クラスの定義( TestJS )
sub.js
const TestJS = class{
constructor() {
/** 音声データをキャッシュするためのマップ */
this.soundPlayerCache = new Map();
}
音を再生するための処理
Scratch vm Speech機能を参考にして書きました。
音を再生させる内部処理
/**
* 生成したURLを使って音声データを取得し再生する。
* 本メソッド内では「this」を使っていないので、アロー関数としている。
* @param {*} url
* @param {*} util
* @returns
*/
_playSpeech = async (url, util) => {
const target = util.target;
try{
const success = await this._speechWithAudioEngine(url, target);
if (!success) {
return await this._speechWithAudioElement(url, target);
}
}catch(e){
console.warn(`Speechに失敗しました。${url}`, e);
}
}
/**
* Scratch3 AudioEngineを使って再生する
* 音ブロックの音量の指定は AudioEngineの再生時に自動的に反映されるので
* 本スクリプトにて指定する必要はない。
* 本メソッド内では「this」を使っていないので、アロー関数としている。
* @param {*} url
* @param {*} target
* @returns 成功(True),失敗(False)
*/
_speechWithAudioEngine = async (url, target) => {
const soundBank = target.sprite.soundBank;
let soundPlayer;
try{
const originalSoundPlayer = await this._decodeSoundPlayer(url);
soundPlayer = originalSoundPlayer.take();
}catch(e){
console.warn("音声データの取得に失敗しました。",e);
return false;
}
soundBank.addSoundPlayer(soundPlayer);
// 再生する(話す)
await soundBank.playSound(target, soundPlayer.id);
// 再生が終わったらSoundBankから除去する
delete soundBank.soundPlayers[soundPlayer.id];
soundBank.playerTargets.delete(soundPlayer.id);
soundBank.soundEffects.delete(soundPlayer.id);
return true;
}
/**
* URLをもとに音声データを取り込む。
* @param {*} url
* @returns 取得した音声データ
*/
_decodeSoundPlayer = async (url) => {
const cached = this.soundPlayerCache.get(url);
// キャッシュされているとき
if (cached) {
if (cached.sound) {
return cached.sound;
}
throw cached.error;
}
// キャッシュされていないとき(はじめての話すワードの場合)
try{
// Turbowarpで動作するとき グローバル変数Scratchが存在している。
const audioEngine = Scratch.vm.runtime.audioEngine;
// 音声データを取得する
const arrayBuffer = await this._fetchAsArrayBufferWithTimeout(url);
// 音声データをもとにSoundPlayerを作る。
const soundPlayer = await audioEngine.decodeSoundPlayer({
data: {
buffer: arrayBuffer,
}
});
// キャッシュする
this.soundPlayerCache.set(url, {
sound: soundPlayer,
error:null,
});
return soundPlayer;
}catch(e){
this.soundPlayerCache.set(url, {
sound: null,
error: e,
});
throw e;
}
}
/**
* 音声データを返す。
* @param {*} url
* @returns 応答データ(音声データ)
*/
_fetchAsArrayBufferWithTimeout(url){
const promise = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// 5000msec経過するとタイムアウトする定義
let timeout = setTimeout(() => {
// タイムアウト時はアボートする
xhr.abort();
reject(new Error("Timed out"));
},5000);
xhr.onload = () => {
clearTimeout(timeout);
if (xhr.status === 200) {
resolve(xhr.response);
}else{
reject(new Error(`HTTP error ${xhr.status} while fetching ${url}`));
}
};
xhr.onerror = () => {
clearTimeout(timeout);
reject(new Error(`Failed to request ${url}`));
};
xhr.responseType = "arraybuffer";
xhr.open("GET", url);
xhr.send();
});
return promise;
}
/**
* Javascript標準のAudioを使って再生する
* @param {*} url
* @param {*} target
* @returns 成功(True),失敗(False)
*/
_speechWithAudioElement (url, target) {
const promise = new Promise((resolve, reject) => {
const mediaElement = new Audio(url);
mediaElement.volume = target.volume / 100; // 音ブロックの音量
mediaElement.onended = () => {
resolve();
};
mediaElement.play().then(()=>{
// 再生するまで待つ。
}).catch((err) => {
reject(err);
});
});
return promise;
}
/**
* 話す
* @param {*} args
* @param {*} util
*/
speech(args, util) {
const path =
`${SERVER_HOST}/synth`
+`?locale=${args.locale}`
+`&gender=${args.gender}`
+`&text=${args.text}`;
this._playSpeech(path, util);
}
/**
* 話し終わるまで待つ
* @param {*} args
* @param {*} util
*/
async speechAndWait(args, util ) {
// 言語、性別、テキストをもとにURLを組み立てる。
let path =
`${SERVER_HOST}/synth`
+`?locale=${args.locale}`
+`&gender=${args.gender}`
+`&text=${args.text}`;
// await をつけているので、再生が終わるまで待つ
await this._playSpeech(path, util);
}
}
export {TestJS};