famibee/SKYNovel

View on GitHub
src/sn/SndBuf.ts

Summary

Maintainability
A
0 mins
Test Coverage
/* ***** BEGIN LICENSE BLOCK *****
    Copyright (c) 2023-2024 Famibee (famibee.blog38.fc2.com)

    This software is released under the MIT License.
    http://opensource.org/licenses/mit-license.php
** ***** END LICENSE BLOCK ***** */

import {IEvtMng, argChk_Boolean, argChk_Num} from './CmnLib';
import {IVariable, IMain} from './CmnInterface';
import {SEARCH_PATH_ARG_EXT} from './ConfigBase';
import {Config} from './Config';
import {SysBase} from './SysBase';
import {HArg} from './Grammar';
import {CmnTween} from './CmnTween';

import {Loader, LoaderResource} from 'pixi.js';
import {sound, Sound, Options, filters} from '@pixi/sound';
import {Tween, remove} from '@tweenjs/tween.js'

interface ISndBuf {
    fn        : string;
    stt        : ISndState;
    snd        : Sound;
    loop    : boolean;
    start_ms: number;
    end_ms    : number;
    ret_ms    : number;
    pan        : number;
}

let cfg    : Config;
let val    : IVariable;
let main: IMain;
let sys    : SysBase;
let evtMng    : IEvtMng;

export class SndBuf {
    static    #hLP    : {[buf: string]: 0}    = {};
    static    init($cfg: Config, $val: IVariable, $main: IMain, $sys: SysBase) {
        SndBuf.#hLP = {};
        cfg    = $cfg;
        val    = $val;
        main= $main;
        sys    = $sys;
    }
    static    setEvtMng($evtMng: IEvtMng) {evtMng = $evtMng}
    static    delLoopPlay(buf: string): void {
        delete SndBuf.#hLP[buf];
        const vn = 'const.sn.sound.'+ buf +'.';
        val.setVal_Nochk('save', vn +'fn', '');
        val.setVal_Nochk('save', 'const.sn.loopPlaying', JSON.stringify(SndBuf.#hLP));
        val.flush();
    }
    static    getVol(hArg: HArg, def: number): number {
        const vol = argChk_Num(hArg, 'volume', def);
        if (vol < 0) return 0;
        if (vol > 1) return 1;
        return vol;
    }
    static    xchgbuf(hArg: HArg) {
        const {buf: buf1 = 'SE', buf2 = 'SE'} = hArg;
        if (buf1 === buf2) return;

        const n1 = 'const.sn.sound.'+ buf1 +'.';
        const v1 = Number(val.getVal('save:'+ n1 +'volume'));
        const f1 = String(val.getVal('save:'+ n1 +'fn'));
        const n2 = 'const.sn.sound.'+ buf2 +'.';
        const v2 = Number(val.getVal('save:'+ n2 +'volume'));
        const f2 = String(val.getVal('save:'+ n2 +'fn'));
        val.setVal_Nochk('save', n1 +'volume', v2);
        val.setVal_Nochk('save', n2 +'volume', v1);
        val.setVal_Nochk('save', n1 +'fn', f2);
        val.setVal_Nochk('save', n2 +'fn', f1);

        if (buf1 in SndBuf.#hLP !== buf2 in SndBuf.#hLP) {    // 演算子の優先順位確認済
            if (buf1 in SndBuf.#hLP)
                    {delete SndBuf.#hLP[buf1]; SndBuf.#hLP[buf2] = 0}
            else    {delete SndBuf.#hLP[buf2]; SndBuf.#hLP[buf1] = 0}
            val.setVal_Nochk('save', 'const.sn.loopPlaying', JSON.stringify(SndBuf.#hLP));
        }
        val.flush();
    }


    #sb        : ISndBuf;

    static    readonly    #MAX_END_MS    = 999000;
    init(hArg: HArg): boolean {
        const {buf = 'SE', fn = ''} = hArg;

        const start_ms = argChk_Num(hArg, 'start_ms', 0);
        const end_ms = argChk_Num(hArg, 'end_ms', SndBuf.#MAX_END_MS);
        const ret_ms = argChk_Num(hArg, 'ret_ms', 0);
        const pan = argChk_Num(hArg, 'pan', 0);
        const speed = argChk_Num(hArg, 'speed', 1);

        if (start_ms < 0) throw `[playse] start_ms:${start_ms} が負の値です`;
        if (ret_ms < 0) throw `[playse] ret_ms:${ret_ms} が負の値です`;
        if (0 < end_ms) {
            if (end_ms <= start_ms) throw `[playse] start_ms:${start_ms} >= end_ms:${end_ms} は異常値です`;
            if (end_ms <= ret_ms) throw `[playse] ret_ms:${ret_ms} >= end_ms:${end_ms} は異常値です`;
        }

        // この辺で属性を増減したら、loadFromSaveObj()にも反映する
        const vn = 'const.sn.sound.'+ buf +'.';
        val.setVal_Nochk('save', vn +'fn', fn);
        const savevol = SndBuf.getVol(hArg, 1);
        val.setVal_Nochk('save', vn +'volume', savevol);// 目標音量(save:)
        const volume = savevol * Number(val.getVal('sys:'+ vn +'volume', 1));

        const loop = argChk_Boolean(hArg, 'loop', false);
        if (loop) {
            SndBuf.#hLP[buf] = 0;
            val.setVal_Nochk('save', 'const.sn.loopPlaying', JSON.stringify(SndBuf.#hLP));
        }
        else SndBuf.delLoopPlay(buf);
        val.setVal_Nochk('save', vn +'start_ms', start_ms);
        val.setVal_Nochk('save', vn +'end_ms', end_ms);
        val.setVal_Nochk('save', vn +'ret_ms', ret_ms);
        val.setVal_Nochk('tmp', vn +'playing', true);
        val.flush();

        const snd = sound.find(fn);    // キャッシュにあるか
        this.#sb = {
            fn,
            stt        : snd ?new SsPlaying :new SsLoading,
            snd,
            loop,
            start_ms,
            end_ms,
            ret_ms    : 0,
            pan,
        };
        // @pixi/sound用基本パラメータ
        const o: Options = {
            loop,
            speed,
            volume,
            loaded    : (e, s2)=> {
                if (this.#sb.stt.isDestroy) return;
                if (e) {main.errScript(`Sound ロード失敗ですa fn:${fn} ${e}`, false); return}
                if (! s2) return;

                this.#sb.snd = s2;
                this.#sb.stt.onLoad(this.#sb);
                if (pan !== 0) s2.filters = [new filters.StereoFilter(pan)];
            //    if (! o.loop) sound.add(fn, snd);    // 手動キャッシュすると単発連打で無音に
            },
        };

        // start_ms・end_ms機能→@pixi/sound準備
        let sp_nm = '';
        if (0 < start_ms || end_ms < SndBuf.#MAX_END_MS) {
            sp_nm = `${fn};${start_ms};${end_ms};${ret_ms}`;
            const os = (o.sprites ??= {})[sp_nm] = {
                start    : start_ms /1000,
                end        : end_ms /1000,
            };
            o.preload = true;        // loaded発生用、トラブルの元なので使用を控えたい
            const old = o.loaded!;
            o.loaded = (e, s0)=> {
                if (this.#sb.stt.isDestroy) return;

                old(e, s0);
                const s2 = s0!;
                const d = s2.duration;
                if (os.end < 0) {    // 負の値は末尾から
                    os.end += d;
                    s2.removeSprites(sp_nm);
                    s2.addSprites(sp_nm, os);
                }
                if (os.end <= os.start) main.errScript(`[playse] end_ms:${end_ms}(${os.end *1000}) >= start_ms:${start_ms} は異常値です`);
                if (os.end *1000 <= ret_ms) main.errScript(`[playse] end_ms:${end_ms}(${os.end *1000}) <= ret_ms:${ret_ms} は異常値です`);
                if (d <= os.start) main.errScript(`[playse] 音声ファイル再生時間:${d *1000} <= start_ms:${start_ms} は異常値です`);
                if (end_ms !== SndBuf.#MAX_END_MS && d <= os.end) main.errScript(`[playse] 音声ファイル再生時間:${d *1000} <= end_ms:${end_ms} は異常値です`);

                s2.play(sp_nm, snd=> {
                    o.complete?.(snd);    // 流れ的にはすぐ下の「ループなし/あり」を呼ぶ
                    if (! loop) this.#sb.stt.onPlayEnd();
                });
            };
        }
        else o.autoPlay = true;

        // ループなし ... 再生完了イベント
        if (! loop) o.complete = ()=> {stop2var(this.#sb, buf); this.#sb.stt.onPlayEnd()};
        // ループあり ... ret_ms処理
        else if (ret_ms !== 0) {
            o.loop = false;    // 一周目はループなしとする
            o.complete = async snd=> {
                const d = snd.duration;
                const start    = ret_ms /1000;
                const end    = end_ms /1000;
                if (d <= start) main.errScript(`[playse] 音声ファイル再生時間:${d *1000} <=  ret_ms:${ret_ms} は異常値です`);

                await snd.play({    // 一周目はループなし、なのでキャッシュされてる
                    ...o,
                    start,
                    end        : (end < 0) ?end +d :end,// 負の値は末尾から
                //    speed,        // 重複
                    loop    : true,
                //    volume,        // 重複
                //-    muted?: boolean;
                    filters    : (pan !== 0) ?[new filters.StereoFilter(pan)] :[],
                //-    complete?: CompleteCallback;
                //-    loaded?: LoadedCallback;
                //-    singleInstance?: boolean;
                });
                //不要 this.#sb.snd = snd;    // this.#sb.snd === snd (true)
            };
        }

        this.#initVol();
        if (snd) {
            snd.volume = volume;    // 再生のたびに音量を戻す
            if (sp_nm) this.#playseSub(fn, o);
            else if (snd.isPlayable) {
                const ab = snd.options.source;
                if (! (ab instanceof ArrayBuffer)
                    || ab.byteLength === 0) snd.play(o);
                else {
                    this.#sb.snd = Sound.from({
                    ...o,
                    url        : snd.options.url,
                    source    : ab,
                })}
                if (pan !== 0) snd.filters = [new filters.StereoFilter(pan)];
            }
            return false;
        }

        const join = argChk_Boolean(hArg, 'join', true);
        if (join) {
            const old = o.loaded!;
            o.loaded = (e, s2)=> {
                if (this.#sb.stt.isDestroy) return;
                old(e, s2);
                main.resume();
            };
        }
        this.#playseSub(fn, o);

        return join;
    }
    #initVol = ()=> {
        sound.volumeAll = Number(val.getVal('sys:sn.sound.global_volume', 1));
        this.#initVol = ()=> {};
    };
    #playseSub(fn: string, o: Options): void {
        const url = cfg.searchPath(fn, SEARCH_PATH_ARG_EXT.SOUND);
    //    const url = 'http://localhost:8080/prj/audio/title.{ogg,mp3}';
        if (url.slice(-4) !== '.bin') {
            o.url = url;
            Sound.from(o);
            return;
        }

        (new Loader()).add({name: fn, url, xhrType: LoaderResource.XHR_RESPONSE_TYPE.BUFFER,})
        .use(async (res, next)=> {
            try {
                res.data = await sys.decAB(res.data);
            } catch (e) {
                main.errScript(`Sound ロード失敗ですc fn:${res.name} ${e}`, false);
            }
            next();
        })
        .load((_ldr, hRes)=> {
            o.source = hRes[fn]?.data;
            Sound.from(o);
        });
    }


    ws =(hArg: HArg)=> this.#sb.stt.ws(this.#sb, hArg);
    stopse(hArg: HArg) {
        const {buf = 'SE'} = hArg;
        stop2var(this.#sb, buf);
        this.#sb.stt.stopse(this.#sb);
    }

    fade(hArg: HArg) {this.#sb.stt.fade(this.#sb, hArg)}
    wf =(hArg: HArg)=> this.#sb.stt.wf(this.#sb, hArg);
    stopfadese =(hArg: HArg)=> this.#sb.stt.stopfadese(this.#sb, hArg);

}


// =================================================
function stop2var(sb: ISndBuf, buf: string): void {
    if (sb.loop) SndBuf.delLoopPlay(buf);
    else {
        const vn = 'const.sn.sound.'+ buf +'.';
        val.setVal_Nochk('tmp', vn +'playing', false);
        val.flush();
    }
}


function stopfadese(tw: Tween<Sound>): void {tw.stop().end()}    // stop()とend()は別

// =================================================


interface ISndState {
    onLoad(sb: ISndBuf)    : void;
    stopse(sb: ISndBuf)    : void;
    ws(sb: ISndBuf, hArg: HArg): boolean;
    onPlayEnd()            : void;
    fade(sb: ISndBuf, hArg: HArg): void;
    wf(sb: ISndBuf, hArg: HArg): boolean;
    compFade()            : void;
    stopfadese(sb: ISndBuf, hArg: HArg): void;
    isDestroy    : boolean;
}

class SsLoading implements ISndState {
    onLoad(sb: ISndBuf)    {sb.stt = new SsPlaying}
    stopse(sb: ISndBuf)    {sb.stt = new SsStop(sb, false)}
    ws =()=> false;
    onPlayEnd() {}        // ok
    fade() {}            // ok
    wf =()=> false;        // ok
    compFade() {}        // ok
    stopfadese() {}        // ok
    readonly    isDestroy    = false;
}

class SsPlaying implements ISndState {
    onLoad() {}            // ok
    stopse(sb: ISndBuf)    {sb.stt = new SsStop(sb)}
    ws(sb: ISndBuf, hArg: HArg) {
        if (sb.loop) return false;

        const {buf = 'SE'} = hArg;
        const stop = argChk_Boolean(hArg, 'stop', true);
        argChk_Boolean(hArg, 'canskip', false);    // waitEvent() のデフォルトと違うので先行上書き
        if (evtMng.waitEvent(hArg, ()=> {    // 順番固定
            stop2var(sb, buf);
            sb.stt.onPlayEnd();    // まず一回やる
            if (stop) sb.stt.stopse(sb); else sb.stt.onPlayEnd = ()=> {};
                // else後は SsWaitingStop か SsStop の想定
        })) {
            sb.stt = new SsWaitingStop;
            return true;
        }

        return false;
    }
    onPlayEnd() {}        // ok
    fade(sb: ISndBuf, hArg: HArg) {
        const {buf = 'SE'} = hArg;

        const vn = 'const.sn.sound.'+ buf +'.';
        const bnV = vn +'volume';
        const savevol = SndBuf.getVol(hArg, NaN);
        val.setVal_Nochk('save', bnV, savevol);    // 目標音量(save:)
        const vol = savevol * Number(val.getVal('sys:'+ bnV, 1))
        const stop = argChk_Boolean(hArg, 'stop', (savevol === 0));
            // this.getVol() により savevol = hArg.volume
        if (stop) SndBuf.delLoopPlay(buf);    // fade中reloadなど、できるだけ早く情報更新か
        val.flush();

        const time = argChk_Num(hArg, 'time', NaN);
        const delay = argChk_Num(hArg, 'delay', 0);
        if ((time === 0 && delay === 0) || evtMng.isSkipping) {
            sb.snd.volume = vol;
            sb.stt = stop ? new SsStop(sb) : new SsPlaying;
            return;
        }

//console.log('fadese start from:%f to:%f', sb.snd.volume, vol);
        const tw = new Tween(sb.snd);
        CmnTween.setTwProp(tw, hArg)
        .to({volume: vol}, time)
        .onComplete(()=> {
            remove(tw);
            sb.stt.compFade();
            sb.stt = stop ? new SsStop(sb) : new SsPlaying;
        })
        .start();

        sb.stt = new SsFade(tw);
    }
    wf =()=> false;        // ok
    compFade() {}        // ok
    stopfadese() {}        // ok
    readonly    isDestroy    = false;
}

class SsWaitingStop implements ISndState {
    onLoad() {}            // ok
    stopse(sb: ISndBuf)    {sb.stt = new SsStop(sb)}
    ws =()=> false;        // ok
    onPlayEnd()            {evtMng.breakEvent()}
    fade() {}            // ok
    wf =()=> false;        // ok
    compFade() {}        // ok
    stopfadese() {}        // ok
    readonly    isDestroy    = false;
}

class SsFade implements ISndState {
    constructor(readonly tw: Tween<Sound>) {}
    onLoad() {}            // ok
    stopse(sb: ISndBuf)    {stopfadese(this.tw); sb.stt = new SsStop(sb)}    // 順番厳守
    ws =()=> false;        // ok ?
    onPlayEnd() {}        // ok
    fade() {}            // ok
    wf(sb: ISndBuf, hArg: HArg) {
        argChk_Boolean(hArg, 'canskip', false);    // waitEvent() のデフォルトと違うので先行上書き
        if (evtMng.waitEvent(hArg, ()=> stopfadese(this.tw))) {
            sb.stt = new SsWaitingFade(this.tw);
            return true;
        }

        return false;
    }
    compFade() {}        // ok
    stopfadese =()=> stopfadese(this.tw);
    readonly    isDestroy    = false;
}

class SsWaitingFade implements ISndState {
    constructor(readonly tw: Tween<Sound>) {}
    onLoad() {}            // ok
    stopse(sb: ISndBuf)    {stopfadese(this.tw); sb.stt = new SsStop(sb)}
    ws =()=> false;        // ok
    onPlayEnd() {}        // ok
    fade() {}            // ok
    wf =()=> false;        // ok
    compFade() {evtMng.breakEvent()}
    stopfadese =()=> stopfadese(this.tw);
    readonly    isDestroy    = false;
}

class SsStop implements ISndState {
    constructor(sb: ISndBuf, stop = true) {
        if (stop) {
            sb.snd.stop();
            if (! sb.loop) return;

            sb.snd.destroy();
            sb.snd.destroy = ()=> {};    // 再度コール時エラー対策
        }
    }    // destroy がないと再生が残るケースが。効果音だと破棄が激しいのでループモノ(BGM)だけにする
    onLoad() {}            // ok
    stopse() {}            // ok
    ws =()=> false;        // ok
    onPlayEnd() {}        // ok
    fade() {}            // ok
    wf =()=> false;        // ok
    compFade() {}        // ok
    stopfadese() {}        // ok
    readonly    isDestroy    = true;
}