0
0

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

Last updated at Posted at 2024-08-01

本記事について

Turbowarpのカスタム拡張機能をる方法の説明、その5です。

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

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

やりたいことの解説

  • Scratchスピーチを実装する(再現する)
https://synthesis-service.scratch.mit.edu/synth?locale=ja-JP&gender=female&text=こんにちは

locale=『言語』、gender=『男女(male/female)』、text=『スピーチさせたい文字列』を指定し、音声データを取得し、音声データを再生することでスピーチをさせる仕組みです。

動画による解説

拡張機能のコード

コードGITHUB

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ファイルコード

コードGITHUB

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};
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0