Turbowarpの『カスタム拡張機能』を使おう【５】

# 本記事について
Turbowarpのカスタム拡張機能をる方法の説明、その５です。

前記事までで、スプライトを動かす、ふきだしを出す、という簡単なScratch操作を実装してみました。

今回は、テスト用Javascriptのなかで『スピーチ』をさせてみようと思います。

# やりたいことの解説

- Scratchスピーチを実装する（再現する）

```
https://synthesis-service.scratch.mit.edu/synth?locale=ja-JP&gender=female&text=こんにちは
```
locale=『言語』、gender=『男女(male/female)』、text=『スピーチさせたい文字列』を指定し、音声データを取得し、音声データを再生することでスピーチをさせる仕組みです。


## 動画による解説

<iframe width="560" height="315" src="https://www.youtube.com/embed/hFDpyOO-bLI?si=nfKcPtHO7Fk3c8RV" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


# 拡張機能のコード

[コードGITHUB](https://github.com/amami-harhid/turbowarpExtensions/blob/main/_05_extension/Extension.js)

```javascript:Extension.js
/**
 * Turbowarpの『カスタム拡張機能』を使おう【５】
 * 外部JSファイルにて、スピーチさせる 
 */
((Scratch) => {
    /** 拡張機能ＩＤ */
    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';
``` 
#### メニューで使う固定値を宣言
```javascript
    /**
     * 言語表示名
    */
    const LOCALE_TEXT = {
        ENGLISH : 'English',
        JAPANESE: "日本語",
    }
    /**
     * 言語ＩＤ
     */
    const LOCALE_VALUE = {
        ENGLISH : 'en-US',
        JAPANESE : 'ja-JP',
    }
    /**
     * 性別表示名
     */
    const GENDER_TEXT = {
        MALE: '男声',
        FEMALE: '女声',
    };
    /**
     * 性別ＩＤ
     */
    const GENDER_VALUE = {
        MALE: 'male',
        FEMALE: 'female',
    }
```
```javascript
    /**
     * 拡張機能定義
     */
    const ExtensionInfo = {
        id: ExtensionID,
        name: ExtensionName,
```
```javascript
        blocks: [
```
```javascript
            {
                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`,
                    },
                },
            },
```
```javascript
            {
                opcode: "setup",
                blockType: Scratch.BlockType.COMMAND,
                text: "[IMG_GEAR]事前準備",
                arguments: {
                    IMG_GEAR: {
                        type: Scratch.ArgumentType.IMAGE,       //タイプ
                        dataURI: GEAR_IMAGE_SVG_URI,            //歯車画像のURI
                    },
                },
            },
```
```javascript
            {
                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,
                    }
                },
            },
```
```javascript
            {
                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,
                    }
                },
            },
        ],
```
```javascript
        // メニューの定義
        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},
                ],
            }

        }
```
```javascript

    }
```
```javascript
    /**
     * 独自スピーチ用のクラス
     */
    class MyExtension {
        getInfo(){
            return ExtensionInfo;
        }
```
#### 外部JSファイルを読み込む処理
```javascript
        /**
         * ロードする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);
            }
        }
```
#### スピーチさせる処理
```javascript
        /**
         * 話す
         * @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);
        }
    }

```
#### 登録する処理
```javascript
    /** 独自Speechのインスタンスを登録する */
    Scratch.extensions.register(new MyExtension());

```
#### おわり
```javascript
})(Scratch);

```

# 外部JSファイルコード

[コードGITHUB](https://github.com/amami-harhid/turbowarpExtensions/blob/main/_05_extension/sub.js)

```javascript:sub.js
// 音声合成用ＵＲＬ（scratch.mit.edu）
const SERVER_HOST = "https://synthesis-service.scratch.mit.edu";
```
#### クラスの定義( TestJS )
```javascript:sub.js
const TestJS = class{
    constructor() {
        /** 音声データをキャッシュするためのマップ */
        this.soundPlayerCache = new Map();
    }
```
#### 音を再生するための処理
Scratch vm Speech機能を参考にして書きました。

<details>
<summary>音を再生させる内部処理</summary>


```javascript
    /**
     * 生成したＵＲＬを使って音声データを取得し再生する。
     * 本メソッド内では「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;
    }    
    /**
     * ＵＲＬをもとに音声データを取り込む。
     * @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;
    }
```
</details>

```javascript
    /**
     * 話す
     * @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);
    }
```
```javascript
    /**
     * 話し終わるまで待つ
     * @param {*} args 
     * @param {*} util 
     */
    async speechAndWait(args, util ) {
        // 言語、性別、テキストをもとにＵＲＬを組み立てる。
        let path = 
            `${SERVER_HOST}/synth`
            +`?locale=${args.locale}`
            +`&gender=${args.gender}`
            +`&text=${args.text}`;
        // await をつけているので、再生が終わるまで待つ
        await this._playSpeech(path, util);
    }
}
```
```javascript
export {TestJS};
```