# A VNGE sound plugin
# Specially optimized for VNSS
# By Countd360
# 
# 
# v1.0
# - everything
# v1.1
# - add function for play control
# v1.2
# - VNSound gameobject add to common space by default
# - attach to chara will place near head automatically
# - save data not using vector3 anymore
# - clear garbage when new scene loaded
# v1.3
# - Export in-PNG filedata to file
# - fix manager window size
# v1.4
# - vnanime2 support
# v1.4.1
# - init bug fix
# v2.0
# - audio spectrum support
# v2.1
# - adapt to new toolbar style
# v2.2
# - support record spectrum data
# v2.3
# - fix record spectrum data issue in 60sps
# - support trigger type for vnanime clip
# v2.4
# - support extra object trigger
#
from vngameengine import HSNeoOCI, GData
from UnityEngine import Vector3, Vector2, Quaternion, Color, Font, Rect, RectOffset
from UnityEngine import GUI, GUILayout, GUIStyle, Screen, Application, Time, Mathf, Camera
from UnityEngine import GameObject, Object, TextAnchor, TextAlignment, FFTWindow, AudioSettings
from System import Action, Array, String, Single
from os import path
import copy
import traceback
import codecs

save_png_version = "1.0"

def vnsnd_toolbar_handle(param1 = "", param2 = ""):
    # function call when toolbar button clicked
    # this should be the main entry of VNSound
    from vngameengine import vnge_game as game
    from vngameengine import tlbBtn_UpdateBtnColor, tlbBtn_VNGESound
    manager = get_vnsound_manager(game)

    if game.gdata.vnSoundWndID != None:
        wnd = game.get_extra_window(game.gdata.vnSoundWndID)
        wnd.visible = not wnd.visible
        hasContents = manager.SoundCount > 0 or len(manager.spectrumSetting) > 0
        tlbBtn_UpdateBtnColor(tlbBtn_VNGESound, wnd.visible, hasContents)
    else:
        # if not self.visible:
        #     self.visible = True
        new_vnsound_window(game)
        tlbBtn_UpdateBtnColor(tlbBtn_VNGESound, True)

def vnsnd_toolbar_update():
    from vngameengine import vnge_game as game
    from vngameengine import tlbBtn_UpdateBtnColor, tlbBtn_VNGESound
    manager = get_vnsound_manager(game)
    wndVisible = False
    if hasattr(game.gdata, "vnSoundWndID"):
        wnd = game.get_extra_window(game.gdata.vnSoundWndID)
        if wnd != None:
            wndVisible = wnd.visible
    hasContents = manager.SoundCount > 0 or len(manager.spectrumSetting) > 0
    tlbBtn_UpdateBtnColor(tlbBtn_VNGESound, wndVisible, hasContents)

def init_vnsound_manager(game):
    # do cleanup before start
    print("Initialize VNSound Manager")
    if hasattr(game.gdata, "vnSoundManager"):
        game.gdata.vnSoundManager.Cleanup()
        del game.gdata.vnSoundManager
    if hasattr(game.gdata, "vnSoundWndID"):
        close_vnsound_window(game)

    # create vntext manager
    game.gdata.vnSoundManager = SoundManager(game)
    game.gdata.vnSoundWndID = None
    game.scenedata.vnSoundGuiData = None

    return game.gdata.vnSoundManager

def clear_vnsound_manager(game):
    game.gdata.vnSoundManager = None

def get_vnsound_manager(game):
    if not check_vnsound(game):
        return init_vnsound_manager(game)
    else:
        return game.gdata.vnSoundManager

def check_vnsound(game):
    return hasattr(game.gdata, "vnSoundManager") and game.gdata.vnSoundManager != None

def scene_saved(game):
    print("VNSound: scene_saving...")
    try:
        if check_vnsound(game):
            import json
            from libjsoncoder import SceneEncoder
            mgr = get_vnsound_manager(game)
            setting, extData = mgr.exportSetting()
            jsonStr = json.dumps(setting, cls=SceneEncoder)
            game.dataPNG_save("vnge_sound", jsonStr, save_png_version)
            if game.isStudioNEO:
                # extra step because HS1 StudioNeo can only save string?
                from vnframe import script2string
                from vnactor import str_to_bytearray, bytearray_to_str64
                extDataKeys = extData.keys()
                game.dataPNG_save("vnge_sound_extdata", script2string(extDataKeys), save_png_version)
                for i in range(len(extDataKeys)):
                    if extData[extDataKeys[i]]:
                        str64 = bytearray_to_str64(str_to_bytearray(extData[extDataKeys[i]]))
                        game.dataPNG_save("vnge_sound_extdata_%d"%i, str64, save_png_version)
            else:
                game.dataPNG_save("vnge_sound_extdata", extData, save_png_version)
        else:
            game.dataPNG_save("vnge_sound", "", save_png_version)
            game.dataPNG_save("vnge_sound_extdata", "", save_png_version)
        print("VNSound: scene_saved!..." + save_png_version)
    except Exception as e:
        traceback.print_exc()
        print("Fail to save VNSound data in PNG: " + str(e))
        
def scene_loaded_imported(game, isImport=False):
    print("VNSound: " + ("scene_importing..." if isImport else "scene_loading..."))
    try:
        mgr = get_vnsound_manager(game)
        mgr.CleanGarbage()
        settingStr = None
        extData = None
        loadmsg = "no data found"
        if game.isSceneDataSaveSupported:
            settingStr, version = game.dataPNG_load_checkversion("vnge_sound", ["1.0"])
            extData, version = game.dataPNG_load_checkversion("vnge_sound_extdata", ["1.0"])
        if settingStr != None and len(settingStr) != 0 and extData != None:
            import json
            from libjsoncoder import sceneDecoder
            setting = json.loads(settingStr, object_hook=sceneDecoder)
            if game.isStudioNEO:
                # extra step for HS1 StudioNeo
                from vnactor import bytearray_to_str, str64_to_bytearray
                extDataKeys = eval(extData)
                tempExtData = {}
                for i in range(len(extDataKeys)):
                    extDataContents, version = game.dataPNG_load_checkversion("vnge_sound_extdata_%d"%i, ["1.0"])
                    if extDataContents != None:
                        tempExtData[extDataKeys[i]] = bytearray_to_str(str64_to_bytearray(extDataContents))
                    else:
                        tempExtData[extDataKeys[i]] = None
                extData = tempExtData
            # import to mgr
            mgr.importSetting(setting, extData, isImport)
            loadmsg = " %d sound sources loaded"%(mgr.SoundCount)
        vnsnd_toolbar_update()
        print("VNSound: scene_loaded!... " + loadmsg)
    except Exception as e:
        traceback.print_exc()
        print("Fail to load VNSound data from PNG: " + str(e))

def new_vnsound_window(game):
    def skin_setup(g):
        wwidth = 500
        wheight = 300
        g.windowName = "VNSound Manager"
        #g.windowRect = Rect(Screen.width / 2 - wwidth / 2, Screen.height / 2 - wheight / 2, wwidth, wheight)
        g.windowRect = Rect(Screen.width  - wwidth - 20, Screen.height - wheight - 20, wwidth, wheight)
        g.windowStyle = g.windowStyleDefault
    
    # check open status
    if game.gdata.vnSoundWndID != None and game.get_extra_window(game.gdata.vnSoundWndID):
        return

    # setup skin
    from skin_customwindow import SkinCustomWindow
    skin = SkinCustomWindow()
    skin.funcSetup = skin_setup
    skin.funcWindowGUI = vnsnd_GUI_subwindow

    # open sub-window
    game.gdata.vnSoundWndID = game.new_extra_window_skin(skin)

def close_vnsound_window(game):
    if game.gdata.vnSoundWndID != None:
        game.close_extra_window(game.gdata.vnSoundWndID)
        game.gdata.vnSoundWndID = None

# ==================================  SoundSource update function ===================================
def vnsnd_init_soundtrg_hook(game):
    # init hook
    game.event_unreg_listener("update", vnsnd_soundtrg_hook_update, "vnsnd_soundtrg_update")
    game.event_reg_listener("update", vnsnd_soundtrg_hook_update, "vnsnd_soundtrg_update")
    game.scenedata.vnsnd_trigger_data = {}
    mgr = get_vnsound_manager(game)
    for alias in mgr.soundAlias:
        ss = mgr.soundDict[alias]
        if ss.triggerId != None and ss.triggerOCIObj != None:
            game.scenedata.vnsnd_trigger_data[alias] = ss.triggerOCIObj.treeNodeObject.visible

def vnsnd_cleanup_soundtrg_hook(game):
    game.event_unreg_listener("update", vnsnd_soundtrg_hook_update, "vnsnd_soundtrg_update")
    if hasattr(game.scenedata, "vnsnd_trigger_data"):
        del game.scenedata.vnsnd_trigger_data

def vnsnd_soundtrg_hook_update(game, evid, u_param):
    mgr = get_vnsound_manager(game)
    for alias in mgr.soundAlias:
        ss = mgr.soundDict[alias]
        if ss.triggerId != None and ss.triggerOCIObj != None:
            try:
                if not game.scenedata.vnsnd_trigger_data[alias] and ss.triggerOCIObj.treeNodeObject.visible:
                    # trigger play
                    if ss.autoRewind:
                        ss.Rewind()
                    ss.Play()
                if game.scenedata.vnsnd_trigger_data[alias] and not ss.triggerOCIObj.treeNodeObject.visible:
                    # trigger stop
                    ss.Pause()
                game.scenedata.vnsnd_trigger_data[alias] = ss.triggerOCIObj.treeNodeObject.visible
            except:
                traceback.print_exc()
                print("Fail to update trigger status for soundsource %s, disable link!"%alias)
                ss.LinkTrigger(None)
        

# ==================================  Spectrum update function ===================================
def vnsnd_init_spectrum_hook(game):
    # init hook
    game.event_unreg_listener("update", vnsnd_spectrum_hook_update, "vnsnd_spectrum_update")
    game.event_reg_listener("update", vnsnd_spectrum_hook_update, "vnsnd_spectrum_update")

def vnsnd_cleanup_spectrum_hook(game):
    game.event_unreg_listener("update", vnsnd_spectrum_hook_update, "vnsnd_spectrum_update")
    if hasattr(game.scenedata, "vnsnd_spectrum_data"):
        del game.scenedata.vnsnd_spectrum_data

def vnsnd_spectrum_hook_update(game, evid, u_param):
    mgr = get_vnsound_manager(game)
    for ss in mgr.spectrumSetting:
        if ss.isRecorded:
            ss.searchAutoStart(game)
            ss.update_record(game)
        else:
            if not ss.enable:
                continue
            if ss.isValid:
                ss.update_realtime(game)
            else:
                ss.searchAudioSource(game)

# ==================================  GUI function ===================================
def vnsnd_GUI_subwindow(baseController, windowid):
    import vngameengine
    game = vngameengine.vnge_game
    def backFunc():
        close_vnsound_window(game)
    vnsnd_GUI(game, backFunc, "Close")

def vnsnd_GUI(game, backFunc, backTitle="Back"):
    # data init
    if not hasattr(game.scenedata, "vnSoundGuiData") or game.scenedata.vnSoundGuiData == None:
        game.scenedata.vnSoundGuiData = GData()
        sgd = game.scenedata.vnSoundGuiData
        sgd.guiWindowId = None
        sgd.guiFunction = vnsnd_GUI_manager
        sgd.guiPrevFunction = None
        sgd.soundListScrollPos = Vector2.zero
        sgd.soundSourceMoreData = {}
        sgd.spectrumPreview = None
        sgd.spectrumPreviewDetail = False
    else:
        sgd = game.scenedata.vnSoundGuiData
    sgd.backFunction = backFunc
    sgd.backTitle = backTitle
    # call gui function
    sgd.guiFunction(game)

def vnsnd_GUI_switch(game, tgtGuiFunc):
    if tgtGuiFunc == None:
        raise Exception("Error, try to switch to None GUI")
    sgd = game.scenedata.vnSoundGuiData
    if tgtGuiFunc == sgd.guiFunction:
        return False
    else:
        sgd.guiPrevFunction = sgd.guiFunction
        sgd.guiFunction = tgtGuiFunc
        return True

def vnsnd_GUI_manager(game):
    if vnsnd_GUI_switch(game, vnsnd_GUI_manager):
        return

    # size and style
    fullw = 500 - 36 # fix width instead of: game.wwidth-36
    btnstyle = GUIStyle("button")
    btnstyle.fontSize = 14
    nameW = 70

    # data shortcuts
    mgr = game.gdata.vnSoundManager
    sgd = game.scenedata.vnSoundGuiData

    # GUI starts
    # begin of scroll zone
    sgd.soundListScrollPos = GUILayout.BeginScrollView(sgd.soundListScrollPos)
    if mgr.SoundCount:
        for alias in mgr.soundAlias:
            vnsnd_GUI_parts_SoundSource(game, alias)
    else:
        GUILayout.Label(greenText("No sound source loaded."))
    for spSetting in mgr.spectrumSetting:
        vnsnd_GUI_parts_SoundSpectrum(game, spSetting)

    # end of scroll zone
    GUILayout.EndScrollView()

    # end button
    GUILayout.FlexibleSpace()
    GUILayout.BeginHorizontal()
    if GUILayout.Button("New Sound", btnstyle):
        vnsnd_GUI_new(game)
    if GUILayout.Button("New Spectrum", btnstyle):
        sgd.selectSpectrumData = SoundSpectrum()
        sgd.backupSpectrumData = None
        vnsnd_GUI_spectrum(game)
    if GUILayout.Button("Reset", btnstyle):
        def resetvnsound(game):
            mgr.Cleanup()
            scene_loaded_imported(game)
            vnsnd_GUI_manager(game)
        vnsnd_GUI_message_help(game, redText("Delete ALL sound/specturm setting and try load from scene again?"), resetvnsound, vnsnd_GUI_manager)
    if GUILayout.Button("Setting", btnstyle):
        vnsnd_GUI_setting(game)
    if GUILayout.Button(sgd.backTitle, btnstyle):
        sgd.backFunction()
        vnsnd_toolbar_update()

    # GUI Ends
    GUILayout.EndHorizontal()

def vnsnd_GUI_new(game):
    mgr = game.gdata.vnSoundManager
    sgd = game.scenedata.vnSoundGuiData

    if vnsnd_GUI_switch(game, vnsnd_GUI_new):
        if not hasattr(sgd, "newTag"):
            n = mgr.SoundCount
            while "snd%d"%n in mgr.soundDict:
                n += 1
            sgd.newTag = "snd%d"%n
            sgd.newAudioFilepath = ""
            sgd.newAudioType = SoundSource.SOUND_SOURCE_TYPE[0]
        if not hasattr(sgd, "lastLoadFolder"):
            sgd.lastLoadFolder = None
        return
    btnstyle = GUIStyle("button")
    btnstyle.fontSize = 14
    filestyle = GUIStyle("textfield")
    filestyle.wordWrap = True
    nameW = 80

    def onLoadFiles(files):
        if files and len(files) > 0:
            onLoadFile(files[0])
    def onLoadFile(file):
        if file and len(file) > 0:
            sgd.newAudioFilepath = file
            sgd.lastLoadFolder = path.split(file)[0]
    def clearTempData():
        del sgd.newTag
        del sgd.newAudioFilepath
        del sgd.newAudioType

    # title
    GUILayout.BeginHorizontal()
    GUILayout.Label("Creating new VNSound:")
    GUILayout.EndHorizontal()
    # file path
    GUILayout.BeginHorizontal()
    GUILayout.Label("Audio File:", GUILayout.Width(nameW))
    sgd.newAudioFilepath = GUILayout.TextField(sgd.newAudioFilepath, filestyle)
    if GUILayout.Button("...", GUILayout.Width(30)):
        if mgr.openUseAPI and mgr.illApi.isDetected:
            title = "Load sound file"
            extFilter = "Sound Files(*.WAV;*.OGG;*.MP3)|*.WAV;*.OGG;*.MP3|All files (*.*)|*.*"
            mgr.illApi.Utilities_OpenFileDialog(onLoadFiles, True, sgd.lastLoadFolder, title, extFilter)
        else:
            title = "Load sound file"
            extFilter = [".MP3", ".WAV", ".OGG"]
            if game.gdata.vnSoundWndID != None:
                baseCtrl = game.get_extra_window(game.gdata.vnSoundWndID)
            else:
                baseCtrl = game
            from vnframe import VNFileBrowser
            VNFileBrowser(baseCtrl, onLoadFile, True, sgd.lastLoadFolder, title, extFilter)
    GUILayout.EndHorizontal()
    # alias
    GUILayout.BeginHorizontal()
    GUILayout.Label("Audio Alias:", GUILayout.Width(nameW))
    sgd.newTag = GUILayout.TextField(sgd.newTag, GUILayout.Width(100))
    GUILayout.EndHorizontal()
    GUILayout.BeginHorizontal()
    GUILayout.Space(nameW + 5)
    GUILayout.Label("* Serve as ID when you operate this audio in SSS/VNSS")
    GUILayout.EndHorizontal()
    # type
    GUILayout.BeginHorizontal()
    GUILayout.Label("Audio Type:", GUILayout.Width(nameW))
    for sndtype in SoundSource.SOUND_SOURCE_TYPE:
        color = GUI.color
        if sndtype == sgd.newAudioType: GUI.color = Color.green
        if GUILayout.Button(sndtype):
            sgd.newAudioType = sndtype
        GUI.color = color
    GUILayout.EndHorizontal()
    GUILayout.BeginHorizontal()
    GUILayout.Space(nameW + 5)
    GUILayout.Label("* Select which volume bar in system/config affect this audio")
    GUILayout.EndHorizontal()

    GUILayout.FlexibleSpace()
    GUILayout.BeginHorizontal()
    if GUILayout.Button("Create", btnstyle):
        err = mgr.LoadSoundSource(sgd.newTag, sgd.newAudioFilepath, sgd.newAudioType)
        if err:
            err = redText("FAILED: ") + err
            vnsnd_GUI_message_help(game, err)
        else:
            msg = greenText("DONE: ") + "Sound file '%s' loaded as '%s'!"%(greenText(sgd.newAudioFilepath), yellowText(sgd.newTag))
            clearTempData()
            vnsnd_GUI_message_help(game, msg, vnsnd_GUI_manager)
    if GUILayout.Button("Cancel", btnstyle):
        clearTempData()
        vnsnd_GUI_manager(game)

    GUILayout.EndHorizontal()

def vnsnd_GUI_attach(game):
    if vnsnd_GUI_switch(game, vnsnd_GUI_attach):
        return

    sgd = game.scenedata.vnSoundGuiData
    alias = sgd.attachSoundSourceAlias
    ss = game.gdata.vnSoundManager.soundDict[alias]
    lblstyle = GUIStyle("label")
    lblstyle.fontSize = 14
    btnstyle = GUIStyle("button")
    btnstyle.fontSize = 14

    if len(game.studio.treeNodeCtrl.selectNodes) != 1:
        dicKey = None
        tgtName = ""
        note = "<color=#00ff00>Select ONE character/item/folder from workspace to attach</color>"
    else:
        from Studio import OCIItem, OCIFolder, OCIChar
        try:
            seloci = HSNeoOCI.create_from_selected().objctrl
        except:
            seloci = None
        if isinstance(seloci, OCIFolder) or isinstance(seloci, OCIItem):
            dicKey = seloci.objectInfo.dicKey
            tgtName = seloci.treeNodeObject.textName
            if sgd.attachMode == "position":
                note = "* Sound will be put at the position of selected item/folder."
            else:
                note = "* Sound starts when extra trigger object's visibility switch from OFF to ON, and stops when its visibility switch from from ON to OFF.\n* Note if extra trigger object's visibility doesn't change, it will NOT start or stop the sound!\n* If 'auto rewind' is ON, sound play from beginning when started by extra trigger."
        elif isinstance(seloci, OCIChar):
            dicKey = seloci.objectInfo.dicKey
            tgtName = seloci.treeNodeObject.textName
            note = "* Attach sound to character directly will put sound source near character's head now! And if you want to attach to other place on charactor, attach sound to folder and parent it to charactor."
        else:
            dicKey = None
            tgtName = ""
            if sgd.attachMode == "position":
                note = yellowText("This kind of object is not supported. Select a character or item or folder.")
            else:
                note = "* Sound starts when extra trigger object's visibility switch from OFF to ON, and stops when its visibility switch from from ON to OFF.\n* Note if extra trigger object's visibility doesn't change, it will NOT start or stop the sound!\n* If 'auto rewind' is ON, sound play from beginning when started by extra trigger."

    # title    
    GUILayout.BeginHorizontal()
    if sgd.attachMode == "position":
        title = "Attach sound '%s:%s' to '%s'"%(yellowText(alias), cyanText(ss.audioTitle), greenText(tgtName))
    else:
        title = "Use '%s' as the extra trigger of sound '%s:%s'"%(greenText(tgtName), yellowText(alias), cyanText(ss.audioTitle))
    GUILayout.Label(title, lblstyle)
    GUILayout.EndHorizontal()

    # note
    if note:
        GUILayout.BeginHorizontal()
        GUILayout.Space(20)
        GUILayout.Label(note)
        GUILayout.EndHorizontal()

    GUILayout.FlexibleSpace()
    GUILayout.BeginHorizontal()
    if GUILayout.Button("OK" if dicKey != None else greyText("OK"), btnstyle) and dicKey != None:
        if sgd.attachMode == "position":
            ss.Attach(dicKey, Vector3.zero)
        else:
            ss.LinkTrigger(dicKey)
            mgr = game.gdata.vnSoundManager
            mgr.UpdateSoundSourceTrigger()
        vnsnd_GUI_manager(game)
    if GUILayout.Button("Cancel", btnstyle):
        vnsnd_GUI_manager(game)
    GUILayout.EndHorizontal()

def vnsnd_GUI_setting(game):
    mgr = game.gdata.vnSoundManager
    sgd = game.scenedata.vnSoundGuiData
    if vnsnd_GUI_switch(game, vnsnd_GUI_setting):
        sgd.settingSaveModeValid = {
            "abspath": True,
            "relpath": True,
            "filedata": True,
            "clipdata": True,
        }
        if mgr.SoundCount > 0:
            sgd.settingSaveModeInfo = mgr.getValidSaveModeInfo()
            for sm in sgd.settingSaveModeValid.keys():
                sgd.settingSaveModeValid[sm] = len(sgd.settingSaveModeInfo[sm]) > 0
        else:
            sgd.settingSaveModeInfo = None
        sgd.settingScroll = Vector2.zero
        if not hasattr(sgd, "lastExportFolder"):
            sgd.lastExportFolder = None
        sgd.exportTarget = None
        return

    def onExportFiles(files):
        if files and len(files) > 0:
            onExportFile(files[0])
    def onExportFile(file):
        if file and len(file) > 0:
            ss = sgd.exportTarget
            err = ss.ExportAudioFile(file)
            msg = "Export %s to %s: %s"%(ss.audioTitle, file, greenText("Done") if err == None else redText("Failed\n" + err))
            sgd.exportTarget = None
            vnsnd_GUI_message_help(game, msg)

    lblstyle = GUIStyle("label")
    lblstyle.fontSize = 14
    btnstyle = GUIStyle("button")
    btnstyle.fontSize = 14

    sgd.settingScroll = GUILayout.BeginScrollView(sgd.settingScroll)

    # save mode select   
    GUILayout.BeginHorizontal()
    GUILayout.Label(cyanText("Sound Data Saving Mode:"), lblstyle)
    GUILayout.EndHorizontal()
    GUILayout.BeginHorizontal()
    color = GUI.color
    if not sgd.settingSaveModeValid["abspath"]: GUI.color = Color(0.5, 0.5, 0.5)
    elif mgr.saveMode == "abspath": GUI.color = Color.green
    if GUILayout.Button("Abs-Path") and sgd.settingSaveModeValid["abspath"]:
        mgr.saveMode = "abspath"
    GUI.color = color
    if not sgd.settingSaveModeValid["relpath"]: GUI.color = Color(0.5, 0.5, 0.5)
    elif mgr.saveMode == "relpath": GUI.color = Color.green
    if GUILayout.Button("Rel-Path") and sgd.settingSaveModeValid["relpath"]:
        mgr.saveMode = "relpath"
    GUI.color = color
    if not sgd.settingSaveModeValid["filedata"]: GUI.color = Color(0.5, 0.5, 0.5)
    elif mgr.saveMode == "filedata": GUI.color = Color.green
    if GUILayout.Button("FileData") and sgd.settingSaveModeValid["filedata"]:
        mgr.saveMode = "filedata"
    GUI.color = color
    if not sgd.settingSaveModeValid["clipdata"]: GUI.color = Color(0.5, 0.5, 0.5)
    elif mgr.saveMode == "clipdata": GUI.color = Color.green
    if not game.isStudioNEO and GUILayout.Button("ClipData") and sgd.settingSaveModeValid["clipdata"]:
        mgr.saveMode = "clipdata"
    GUI.color = color
    GUILayout.EndHorizontal()

    # save mode info
    savemodeinfo = {
        "abspath": "Simplest mode, save audio file's absolute path. So audio file can be stored at anywhere on your local PC, but hard to share with others.",
        "relpath": "Save audio file's relative path from your game folder. It is recommended to put audio files in /UserData/audio folder, at least in the same driver or absolute path will be saved. And when you share the scene, ship the audio files with PNG.",
        "filedata": "Save audio file's data into PNG, suitable for scene sharing, just need to ship one PNG. Temp file will be created in game folder when loading and will be deleted after loaded.",
        "clipdata": yellowText("Not recommended.") + " Save raw audio clip data into PNG, No temp file, but slower save/load and bigger PNG size than 'filedata' mode. Use this mode just in case 'filedata' mode doesn't work.",
    }
    GUILayout.BeginHorizontal()
    GUILayout.Label(savemodeinfo[mgr.saveMode])
    GUILayout.EndHorizontal()

    # save mode valid
    if sgd.settingSaveModeInfo and len(sgd.settingSaveModeInfo[mgr.saveMode]) != mgr.SoundCount:
        wmsg = yellowText("Following sound not support current save mode: ")
        for alias in mgr.soundAlias:
            if alias not in sgd.settingSaveModeInfo[mgr.saveMode]:
                wmsg += magentaText(alias) + yellowText(", ")
        wmsg += yellowText("they will be saved in their own valid mode:")
        GUILayout.BeginHorizontal()
        GUILayout.Label(wmsg)
        GUILayout.EndHorizontal()

    # export data
    expCanidate = []
    for alias in mgr.soundAlias:
        ss = mgr.soundDict[alias]
        if ss.canExportToFile:
            expCanidate.append(alias)
    GUILayout.BeginHorizontal()
    GUILayout.Label(cyanText("Export in-PNG filedata:"), lblstyle)
    GUILayout.EndHorizontal()
    if len(expCanidate):
        for alias in expCanidate:
            ss = mgr.soundDict[alias]
            GUILayout.BeginHorizontal()
            if GUILayout.Button("Export %s:%s%s"%(yellowText(alias), greenText(ss.audioTitle), greenText(ss.audioExt))):
                sgd.exportTarget = ss
                if mgr.openUseAPI and mgr.illApi.isDetected:
                    title = "Export sound file"
                    extFilter = "Sound Files(*%s)|*%s|All files (*.*)|*.*"%(ss.audioExt, ss.audioExt)
                    mgr.illApi.Utilities_OpenFileDialog(onExportFiles, False, sgd.lastExportFolder, title, extFilter, ss.audioExt)
                else:
                    title = "Export sound file"
                    extFilter = ss.audioExt
                    if game.gdata.vnSoundWndID != None:
                        baseCtrl = game.get_extra_window(game.gdata.vnSoundWndID)
                    else:
                        baseCtrl = game
                    from vnframe import VNFileBrowser
                    VNFileBrowser(baseCtrl, onExportFile, False, sgd.lastExportFolder, title, extFilter, ss.audioExt)
            GUILayout.EndHorizontal()
    else:
        GUILayout.BeginHorizontal()
        GUILayout.Label("No filedata found")
        GUILayout.EndHorizontal()

    # openfiledialog style
    GUILayout.BeginHorizontal()
    GUILayout.Label(cyanText("Open file dialog:"), lblstyle)
    GUILayout.EndHorizontal()
    GUILayout.BeginHorizontal()
    if not mgr.illApi.isDetected: GUI.color = Color(0.5, 0.5, 0.5)
    elif mgr.openUseAPI: GUI.color = Color.green
    if GUILayout.Button("Use Windows Dialog") and mgr.illApi.isDetected:
        mgr.openUseAPI = True
    GUI.color = color
    if not mgr.openUseAPI: GUI.color = Color.green
    if GUILayout.Button("Use VNGE Dialog"):
        mgr.openUseAPI = False
    GUI.color = color
    GUILayout.EndHorizontal()

    GUILayout.EndScrollView()

    GUILayout.FlexibleSpace()
    GUILayout.BeginHorizontal()
    if GUILayout.Button("OK", btnstyle):
        vnsnd_GUI_manager(game)
    GUILayout.EndHorizontal()

def vnsnd_GUI_spectrum(game):
    from vnanime import check_keyframe_anime, init_keyframe_anime
    mgr = game.gdata.vnSoundManager
    sgd = game.scenedata.vnSoundGuiData

    if vnsnd_GUI_switch(game, vnsnd_GUI_spectrum):
        if not check_keyframe_anime(game):
            init_keyframe_anime(game)
        sgd.spectrumSettingSclPos = Vector2.zero
        sgd.showBandFreq = False
        sgd.newClipForBandName = None
        sgd.newClipNameTemp = None
        sgd.newClipLengthTemp = None
        sgd.newClipErrorMsg = None
        return
    titleWidth = 100
    validSampleCount = (64, 128, 256, 512, 1024, 2048, 4096, 8192)
    validSampleWindow = (FFTWindow.Rectangular, FFTWindow.Triangle, FFTWindow.Hamming, FFTWindow.Hanning, FFTWindow.Blackman, FFTWindow.BlackmanHarris)
    
    def shiftSampleCount(dir):
        idx = validSampleCount.index(sgd.selectSpectrumData.sampleCount)
        if dir > 0 and idx < len(validSampleCount) - 1:
            sgd.selectSpectrumData.sampleCount = validSampleCount[idx + 1]
        if dir < 0 and idx > 0:
            sgd.selectSpectrumData.sampleCount = validSampleCount[idx - 1]
        for bs in sgd.selectSpectrumData.bandSetting:
            if bs[2] > sgd.selectSpectrumData.sampleCount:
                bs[2] = sgd.selectSpectrumData.sampleCount
            if bs[1] >= bs[2]:
                bs[1] = bs[2] - 1
    def shiftWindowSelection(dir):
        idx = validSampleWindow.index(sgd.selectSpectrumData.sampleWindow)
        if dir > 0 and idx < len(validSampleWindow) - 1:
            sgd.selectSpectrumData.sampleWindow = validSampleWindow[idx + 1]
        if dir < 0 and idx > 0:
            sgd.selectSpectrumData.sampleWindow = validSampleWindow[idx - 1]
    def selectClipName(oldName, dir):
        cadi = [None]
        for clip in game.gdata.kfaSortedClips:
            cadi.append(clip.name)
        if oldName in cadi:
            idx = cadi.index(oldName)
        else:
            idx = 0
        newIdx = (idx + dir) % len(cadi)
        return cadi[newIdx]
    def createNewClip(newClipName, newClipLength):
        from vnframe import verifyTagName
        from vnanime import kfa_register
        from vnanime2 import KeyFrameClip2
        iderr = verifyTagName(newClipName)
        if iderr:
            raise Exception(iderr)
        if not newClipLength.isnumeric():
            raise Exception("Length should be a number")
        fl = int(newClipLength)
        if fl < 2:
            raise Exception("Need at least 2 frames to work properly")
        newClip = KeyFrameClip2(game, None)
        newClip.name = newClipName
        newClip.frameLength = fl
        kfa_register(game, newClip)
    def onExportFiles(files):
        if files and len(files) > 0:
            onExportFile(files[0])
    def onExportFile(file):
        if file and len(file) > 0:
            try:
                from vnframe import dumpToJsonString
                setting = sgd.selectSpectrumData.exportSetting()
                jsonStr = dumpToJsonString(setting, True)
                fp = codecs.open(file, "w", 'utf-8')
                fp.write(jsonStr)
                fp.close()
                vnsnd_GUI_message_help(game, "Spectrum setting exported to: " + greenText(file))
            except Exception as e:
                vnsnd_GUI_message_help(game, "Spectrum setting export failed: " + redText(str(e)))
    def onImportFiles(files):
        if files and len(files) > 0:
            onImportFile(files[0])
    def onImportFile(file):
        if file and len(file) > 0:
            try:
                from vnframe import loadFromJsonString
                fp = codecs.open(file, "r", 'utf-8')
                fileContent = fp.read()
                fp.close()
                settings = loadFromJsonString(fileContent)
                err = sgd.selectSpectrumData.importSetting(settings)
                if err:
                    vnsnd_GUI_message_help(game, "Spectrum setting import failed: " + redText(err))
            except Exception as e:
                vnsnd_GUI_message_help(game, "Spectrum setting import failed: " + redText(str(e)))


    oldColor = GUI.color
    freqPerSample = float(AudioSettings.outputSampleRate / 2) / sgd.selectSpectrumData.sampleCount
    sgd.spectrumSettingSclPos = GUILayout.BeginScrollView(sgd.spectrumSettingSclPos)
    # Spectrum sampling setting
    GUILayout.BeginHorizontal()
    GUILayout.Label(greenText("Spectrum sampling:"))
    GUILayout.EndHorizontal()
    # seeker family
    GUILayout.BeginHorizontal()
    GUILayout.Label("Target audio:", GUILayout.Width(titleWidth))
    if sgd.selectSpectrumData.audioSeeker == "actived":
        GUI.color = Color.green
    if GUILayout.Button("Playing Audio"):
        sgd.selectSpectrumData.audioSeeker = "actived"
        sgd.selectSpectrumData.audioSeekerOpt = "mmdd_vnsnd"
    GUI.color = oldColor
    if sgd.selectSpectrumData.audioSeeker == "mmdd":
        GUI.color = Color.green
    if GUILayout.Button("MMD Director"):
        sgd.selectSpectrumData.audioSeeker = "mmdd"
        sgd.selectSpectrumData.audioSeekerOpt = "0"
    GUI.color = oldColor
    if sgd.selectSpectrumData.audioSeeker == "vnsnd":
        GUI.color = Color.green
    if GUILayout.Button("VNSound"):
        sgd.selectSpectrumData.audioSeeker = "vnsnd"
        sgd.selectSpectrumData.audioSeekerOpt = "0"
    GUI.color = oldColor
    GUILayout.FlexibleSpace()
    GUILayout.EndHorizontal()
    # seeker option
    GUILayout.BeginHorizontal()
    GUILayout.Label(" ", GUILayout.Width(titleWidth))
    if sgd.selectSpectrumData.audioSeeker == "actived":
        if GUILayout.Toggle(sgd.selectSpectrumData.audioSeekerOpt != "any", "MMDD/VNSound only"):
            sgd.selectSpectrumData.audioSeekerOpt = "mmdd_vnsnd"
        if GUILayout.Toggle(sgd.selectSpectrumData.audioSeekerOpt == "any", "Any audio found"):
            sgd.selectSpectrumData.audioSeekerOpt = "any"
    if sgd.selectSpectrumData.audioSeeker == "mmdd":
        GUILayout.Label("Index of sound controller:")
        sgd.selectSpectrumData.audioSeekerOpt = GUILayout.TextField(sgd.selectSpectrumData.audioSeekerOpt, GUILayout.Width(50))
    if sgd.selectSpectrumData.audioSeeker == "vnsnd":
        GUILayout.Label("ID or index of VNSound:")
        sgd.selectSpectrumData.audioSeekerOpt = GUILayout.TextField(sgd.selectSpectrumData.audioSeekerOpt, GUILayout.Width(50))
    GUI.color = oldColor
    GUILayout.FlexibleSpace()
    GUILayout.EndHorizontal()
    # sampling count
    GUILayout.BeginHorizontal()
    GUILayout.Label("Sample Count:", GUILayout.Width(titleWidth))
    if GUILayout.Button("<"):
        shiftSampleCount(-1)
    GUILayout.Label(str(sgd.selectSpectrumData.sampleCount), GUILayout.Width(40))
    if GUILayout.Button(">"):
        shiftSampleCount(1)
    GUILayout.Space(5)
    GUILayout.Label("About %.2fHz per sample"%freqPerSample)
    GUILayout.FlexibleSpace()
    GUILayout.EndHorizontal()
    # sample channel
    GUILayout.BeginHorizontal()
    GUILayout.Label("Sample channel:", GUILayout.Width(titleWidth))
    if sgd.selectSpectrumData.sampleChannel == "left":
        GUI.color = Color.green
    if GUILayout.Button("Left only"):
        sgd.selectSpectrumData.sampleChannel = "left"
    GUI.color = oldColor
    if sgd.selectSpectrumData.sampleChannel == "right":
        GUI.color = Color.green
    if GUILayout.Button("Right only"):
        sgd.selectSpectrumData.sampleChannel = "right"
    GUI.color = oldColor
    if sgd.selectSpectrumData.sampleChannel == "stereo":
        GUI.color = Color.green
    if GUILayout.Button("Stereo"):
        sgd.selectSpectrumData.sampleChannel = "stereo"
    GUI.color = oldColor
    GUILayout.FlexibleSpace()
    GUILayout.EndHorizontal()
    # sample window
    GUILayout.BeginHorizontal()
    GUILayout.Label("Sample Window:", GUILayout.Width(titleWidth))
    if GUILayout.Button("<"):
        shiftWindowSelection(-1)
    GUILayout.Label(str(sgd.selectSpectrumData.sampleWindow))
    if GUILayout.Button(">"):
        shiftWindowSelection(1)
    GUILayout.FlexibleSpace()
    GUILayout.EndHorizontal()

    # Spectrum band setting
    GUILayout.Space(10)
    GUILayout.BeginHorizontal()
    GUILayout.Label(greenText("Spectrum Band:"))
    GUILayout.EndHorizontal()
    # title
    GUILayout.BeginHorizontal()
    GUILayout.Label("Band name", GUILayout.Width(titleWidth+10))
    GUILayout.Label("Sample from - to", GUILayout.Width(140))
    GUILayout.Label("Action clip")
    GUILayout.FlexibleSpace()
    GUILayout.Label("Operation")
    GUILayout.EndHorizontal()
    # band content
    bandToDelete = None
    for bs in sgd.selectSpectrumData.bandSetting:
        # band setting
        GUILayout.BeginHorizontal()
        bs[0] = GUILayout.TextField(bs[0], GUILayout.Width(titleWidth))
        GUILayout.Space(10)
        bf = GUILayout.TextField(str(bs[1]), GUILayout.Width(40))
        if bf != str(bs[1]):
            try:
                bs[1] = int(bf)
                if bs[1] < 0:
                    bs[1] = 0
                if bs[1] >= bs[2]:
                    bs[1] = bs[2] - 1
            except:
                pass
        GUILayout.Label("-")
        bt = GUILayout.TextField(str(bs[2]), GUILayout.Width(40))
        if bt != str(bs[2]):
            try:
                bs[2] = int(bt)
                if bs[2] <= bs[1]:
                    bs[2] = bs[1] + 1
                if bs[2] > sgd.selectSpectrumData.sampleCount:
                    bs[2] = sgd.selectSpectrumData.sampleCount
            except:
                pass
        GUILayout.FlexibleSpace()
        if GUILayout.Button("<"):
            bs[3] = selectClipName(bs[3], -1)
        if GUILayout.Button(">"):
            bs[3] = selectClipName(bs[3], +1)
        GUILayout.Label(str(bs[3]), GUILayout.Width(90))
        if GUILayout.Button("New"):
            sgd.newClipForBandName = bs[0]
            sgd.newClipNameTemp = "SPB_" + bs[0]
            sgd.newClipLengthTemp = "10"
            sgd.newClipErrorMsg = None
        GUI.color = Color.red
        if GUILayout.Button("X"):
            bandToDelete = bs
        GUI.color = oldColor
        GUILayout.EndHorizontal()
        # new clip line
        if sgd.newClipForBandName == bs[0]:
            GUILayout.BeginHorizontal()
            GUILayout.FlexibleSpace()
            if sgd.newClipErrorMsg:
                GUILayout.Label(redText(sgd.newClipErrorMsg))
                if GUILayout.Button(greenText("OK")):
                    sgd.newClipErrorMsg = None
            else:
                GUILayout.Label("New clip name:")
                sgd.newClipNameTemp = GUILayout.TextField(sgd.newClipNameTemp, GUILayout.Width(90))
                GUILayout.Label("Length")
                sgd.newClipLengthTemp = GUILayout.TextField(sgd.newClipLengthTemp, GUILayout.Width(40))
                if GUILayout.Button(greenText("OK")):
                    try:
                        createNewClip(sgd.newClipNameTemp, sgd.newClipLengthTemp)
                        bs[3] = sgd.newClipNameTemp
                        sgd.newClipForBandName = None
                    except Exception as e:
                        sgd.newClipErrorMsg = str(e)
                if GUILayout.Button(redText("Cancel")):
                    sgd.newClipForBandName = None
            GUILayout.EndHorizontal()
        elif sgd.showBandFreq:
            GUILayout.BeginHorizontal()
            GUILayout.Space(titleWidth+20)
            GUILayout.Label("%.2fHz - %.2fHz"%(bs[1] * freqPerSample, bs[2] * freqPerSample))
            GUILayout.EndHorizontal()
    # add band
    GUILayout.BeginHorizontal()
    if GUILayout.Button("Add a new band"):
        newBand = ["Band%02d"%(len(sgd.selectSpectrumData.bandSetting)+1), 0, sgd.selectSpectrumData.sampleCount, None]
        sgd.selectSpectrumData.bandSetting.append(newBand)
    sgd.showBandFreq = GUILayout.Toggle(sgd.showBandFreq, "Show band frequency range")
    GUILayout.FlexibleSpace()
    GUILayout.EndHorizontal()
        
    # algorithm setting
    GUILayout.Space(10)
    GUILayout.BeginHorizontal()
    GUILayout.Label(greenText("Algorithm options:"))
    GUILayout.EndHorizontal()
    # band Statistics
    GUILayout.BeginHorizontal()
    GUILayout.Label("Band Statistics:", GUILayout.Width(titleWidth))
    if GUILayout.Button(greenText("Sum") if sgd.selectSpectrumData.bandStatistics == "sum" else "Sum"):
        sgd.selectSpectrumData.bandStatistics = "sum"
    if GUILayout.Button(greenText("Avg") if sgd.selectSpectrumData.bandStatistics == "avg" else "Avg"):
        sgd.selectSpectrumData.bandStatistics = "avg"
    if GUILayout.Button(greenText("RSS") if sgd.selectSpectrumData.bandStatistics == "rss" else "RSS"):
        sgd.selectSpectrumData.bandStatistics = "rss"
    if GUILayout.Button(greenText("RMS") if sgd.selectSpectrumData.bandStatistics == "rms" else "RMS"):
        sgd.selectSpectrumData.bandStatistics = "rms"
    if sgd.selectSpectrumData.bandStatistics == "sum":
        GUILayout.Label("Sum value")
    elif sgd.selectSpectrumData.bandStatistics == "avg":
        GUILayout.Label("Mean value")
    elif sgd.selectSpectrumData.bandStatistics == "rss":
        GUILayout.Label("Root Sum Square")
    else:
        GUILayout.Label("Root Mean Square")
    GUILayout.FlexibleSpace()
    GUILayout.EndHorizontal()
    # audio init
    GUILayout.BeginHorizontal()
    GUILayout.Label("Audio profile:", GUILayout.Width(titleWidth))
    ap = GUILayout.TextField(str(sgd.selectSpectrumData.audioProfile), GUILayout.Width(50))
    if ap != str(sgd.selectSpectrumData.audioProfile):
        try:
            apf = float(ap)
            if apf > 0:
                sgd.selectSpectrumData.audioProfile = apf
        except:
            pass
    GUILayout.Label(" Initial max value for band")
    GUILayout.FlexibleSpace()
    GUILayout.EndHorizontal()
    # audio normalize
    GUILayout.BeginHorizontal()
    GUILayout.Label("Audio normalize:", GUILayout.Width(titleWidth))
    if sgd.selectSpectrumData.unifiedProfile:
        if GUILayout.Button(greenText("Unified")):
            sgd.selectSpectrumData.unifiedProfile = False
        GUILayout.Label("All band normalized by the total MAX value")
    else:
        if GUILayout.Button(greenText("Independent")):
            sgd.selectSpectrumData.unifiedProfile = True
        GUILayout.Label("Each band normalized by its own MAX value")
    GUILayout.FlexibleSpace()
    GUILayout.EndHorizontal()
    # band algorithm
    GUILayout.BeginHorizontal()
    GUILayout.Label("Auto drop down:", GUILayout.Width(titleWidth))
    if GUILayout.Button(greenText("ON") if sgd.selectSpectrumData.bandBufferDecrease else redText("OFF")):
        sgd.selectSpectrumData.bandBufferDecrease = not sgd.selectSpectrumData.bandBufferDecrease
    if sgd.selectSpectrumData.bandBufferDecrease:
        GUILayout.Label("Drop speed:")
        dsi = GUILayout.TextField(str(sgd.selectSpectrumData.bandBufferDecreaseSpeedInit), GUILayout.Width(50))
        if dsi != str(sgd.selectSpectrumData.bandBufferDecreaseSpeedInit):
            try:
                dsif = float(dsi)
                if dsif > 0:
                    sgd.selectSpectrumData.bandBufferDecreaseSpeedInit = dsif
            except:
                pass
        GUILayout.Label("Drop accelerate:")
        dsa = GUILayout.TextField(str(sgd.selectSpectrumData.bandBufferDecreaseSpeedAccl), GUILayout.Width(50))
        if dsa != str(sgd.selectSpectrumData.bandBufferDecreaseSpeedAccl):
            try:
                dsaf = float(dsa)
                if dsaf > 0:
                    sgd.selectSpectrumData.bandBufferDecreaseSpeedAccl = dsaf
            except:
                pass
    GUILayout.FlexibleSpace()
    GUILayout.EndHorizontal()
    # amplitude
    GUILayout.BeginHorizontal()
    GUILayout.Label("Avg Amplitude:", GUILayout.Width(titleWidth))
    if GUILayout.Button(greenText("ON") if sgd.selectSpectrumData.amplitudeEnable else redText("OFF")):
        sgd.selectSpectrumData.amplitudeEnable = not sgd.selectSpectrumData.amplitudeEnable
    if sgd.selectSpectrumData.amplitudeEnable:
        if GUILayout.Button("<"):
            sgd.selectSpectrumData.amplitudeClip = selectClipName(sgd.selectSpectrumData.amplitudeClip, -1)
        if GUILayout.Button(">"):
            sgd.selectSpectrumData.amplitudeClip = selectClipName(sgd.selectSpectrumData.amplitudeClip, -1)
        GUILayout.Label(str(sgd.selectSpectrumData.amplitudeClip), GUILayout.Width(90))
        if GUILayout.Button("New"):
            sgd.newClipForBandName = "reserved_new_clip_for_amplitude"
            sgd.newClipNameTemp = "SPB_AMP"
            sgd.newClipLengthTemp = "10"
            sgd.newClipErrorMsg = None
    GUILayout.FlexibleSpace()
    GUILayout.EndHorizontal()
    if sgd.newClipForBandName == "reserved_new_clip_for_amplitude":
        GUILayout.BeginHorizontal()
        GUILayout.FlexibleSpace()
        if sgd.newClipErrorMsg:
            GUILayout.Label(redText(sgd.newClipErrorMsg))
            if GUILayout.Button(greenText("OK")):
                sgd.newClipErrorMsg = None
        else:
            GUILayout.Label("New clip name:")
            sgd.newClipNameTemp = GUILayout.TextField(sgd.newClipNameTemp, GUILayout.Width(90))
            GUILayout.Label("Length")
            sgd.newClipLengthTemp = GUILayout.TextField(sgd.newClipLengthTemp, GUILayout.Width(40))
            if GUILayout.Button(greenText("OK")):
                try:
                    createNewClip(sgd.newClipNameTemp, sgd.newClipLengthTemp)
                    sgd.selectSpectrumData.amplitudeClip = sgd.newClipNameTemp
                    sgd.newClipForBandName = None
                except Exception as e:
                    sgd.newClipErrorMsg = str(e)
            if GUILayout.Button(redText("Cancel")):
                sgd.newClipForBandName = None
        GUILayout.EndHorizontal()

    GUILayout.EndScrollView()

    btnstyle = GUIStyle("button")
    btnstyle.fontSize = 14
    # control button
    GUILayout.FlexibleSpace()
    GUILayout.BeginHorizontal()
    if GUILayout.Button("OK", btnstyle):
        sgd.selectSpectrumData.initialize(game)
        if sgd.backupSpectrumData:
            pass
        else:
            mgr.AppendSoundSpectrum(sgd.selectSpectrumData)
        vnsnd_GUI_manager(game)
    if GUILayout.Button("Import", btnstyle):
        if mgr.openUseAPI and mgr.illApi.isDetected:
            title = "Import spectrum setting"
            extFilter = "Setting Files(*.json)|*.json|All files (*.*)|*.*"
            mgr.illApi.Utilities_OpenFileDialog(onImportFiles, True, None, title, extFilter)
        else:
            title = "Import spectrum setting"
            extFilter = [".json",]
            if game.gdata.vnSoundWndID != None:
                baseCtrl = game.get_extra_window(game.gdata.vnSoundWndID)
            else:
                baseCtrl = game
            from vnframe import VNFileBrowser
            VNFileBrowser(baseCtrl, onImportFile, True, None, title, extFilter)
    if GUILayout.Button("Export", btnstyle):
        if mgr.openUseAPI and mgr.illApi.isDetected:
            title = "Export spectrum setting"
            extFilter = "Setting Files(*.json)|*.json|All files (*.*)|*.*"
            mgr.illApi.Utilities_OpenFileDialog(onExportFiles, False, None, title, extFilter)
        else:
            title = "Export spectrum setting"
            extFilter = [".json",]
            if game.gdata.vnSoundWndID != None:
                baseCtrl = game.get_extra_window(game.gdata.vnSoundWndID)
            else:
                baseCtrl = game
            from vnframe import VNFileBrowser
            VNFileBrowser(baseCtrl, onExportFile, False, None, title, extFilter)
    if GUILayout.Button("Cancel", btnstyle):
        if sgd.backupSpectrumData:
            sgd.selectSpectrumData.copySetting(sgd.backupSpectrumData)
        else:
            pass
        vnsnd_GUI_manager(game)
    GUILayout.EndHorizontal()

    # update
    if bandToDelete != None:
        sgd.selectSpectrumData.bandSetting.remove(bandToDelete)

def vnsnd_GUI_spectrum_recording(game):
    from vnframe import VNCheckGarbageCollect, VNEnableGarbageCollect, VNDoGarbageCollectNow
    mgr = game.gdata.vnSoundManager
    sgd = game.scenedata.vnSoundGuiData
    lblWidth = 70

    if vnsnd_GUI_switch(game, vnsnd_GUI_spectrum_recording):
        sgd.recordingStep = "before"
        sgd.recordingFilePath = ""
        sgd.recordingId = "spm" + str(mgr.spectrumSetting.index(sgd.recordingSpectrum))
        sgd.recordingAlias = ""
        sgd.recordingResample = 30
        sgd.recordingSyncStart = "MMDD"
        sgd.recordingSuspendGC = VNCheckGarbageCollect() != None
        sgd.recordingSoundSource = None
        return
    if not hasattr(sgd, "lastLoadFolder"):
        sgd.lastLoadFolder = None

    def onLoadFiles(files):
        if files and len(files) > 0:
            onLoadFile(files[0])
    def onLoadFile(file):
        if file and len(file) > 0:
            sgd.recordingFilePath = file
            sgd.lastLoadFolder, fname = path.split(file)
            sgd.recordingAlias = path.splitext(fname)[0]
    def tryLoadFile():
        try:
            ss = SoundSource()
            err = ss.CreateAudioSource(SoundSource.SOUND_SOURCE_TYPE[0])
            if err:
                return err
            err = ss.LoadAudioFile(sgd.recordingFilePath)
            if err:
                return err
            sgd.recordingSoundSource = ss
            return None
        except Exception as e:
            return str(e)
    def startRecording():
        sgd.recordingSpectrum.attachAudioSource(sgd.recordingSoundSource.audioSource, sgd.recordingAlias)
        if sgd.recordingSpectrum.startRecording() > 0:
            print(">>> start recording....")
            if sgd.recordingSuspendGC:
                VNDoGarbageCollectNow()
                VNEnableGarbageCollect(False)
            sgd.recordingSoundSource.Play()
            sgd.recordingStep = "recording"
            return None
        else:
            return "No band connected which clip or action, nothing to record."
    def quitRecording():
        if sgd.recordingSoundSource != None:
            sgd.recordingSoundSource.Cleanup()
        del sgd.recordingStep
        del sgd.recordingFilePath
        del sgd.recordingId
        del sgd.recordingAlias
        del sgd.recordingResample
        del sgd.recordingSyncStart
        del sgd.recordingSuspendGC
        del sgd.recordingSoundSource
        vnsnd_GUI_manager(game)

    oldColor = GUI.color
    filestyle = GUIStyle("textfield")
    filestyle.wordWrap = True

    # title
    if sgd.recordingStep == "before":
        GUILayout.Label("Select a sound file and record its spectrum data. Then the spectrum data can be replayed when using VideoExport.")
        GUILayout.Label("Before you start, test this spectrum carefully! If you want to change spectrum setting, you have to do the record again.")
        if sgd.recordingSuspendGC:
            GUI.color = Color.yellow
            GUILayout.Label("Garbage collect will be suspened when recording. Try keep your scene simple and hide all unnecessary UI.")
            GUI.color = oldColor
    elif sgd.recordingStep == "recording":
        GUI.color = Color.yellow
        GUILayout.Label("Recording in progress: %ds / %ds"%(int(sgd.recordingSoundSource.time), int(sgd.recordingSoundSource.length)))
        GUILayout.Label("Anime clips linked to spectrum are not working while recording. NOT A BUG.")
        GUI.color = oldColor
        GUILayout.Label("You can setup recording options now, while enjoying the music.")
    else:
        GUI.color = Color.green
        GUILayout.Label("Spectrum data recorded! Length: %.2fs, Avg sample rate: %.2fsps"%(sgd.recordingSoundSource.length, float(len(sgd.recordingSpectrum.tempTimestamp))/sgd.recordingSoundSource.length))
        GUI.color = oldColor
    GUILayout.Space(10)

    # source
    GUILayout.BeginHorizontal()
    GUILayout.Label("Source", GUILayout.Width(lblWidth))
    if sgd.recordingStep == "before":
        sgd.recordingFilePath = GUILayout.TextField(sgd.recordingFilePath, filestyle)
        if GUILayout.Button("...", GUILayout.Width(30)):
            if mgr.openUseAPI and mgr.illApi.isDetected:
                title = "Load sound file"
                extFilter = "Sound Files(*.WAV;*.OGG;*.MP3)|*.WAV;*.OGG;*.MP3|All files (*.*)|*.*"
                mgr.illApi.Utilities_OpenFileDialog(onLoadFiles, True, sgd.lastLoadFolder, title, extFilter)
            else:
                title = "Load sound file"
                extFilter = [".MP3", ".WAV", ".OGG"]
                if game.gdata.vnSoundWndID != None:
                    baseCtrl = game.get_extra_window(game.gdata.vnSoundWndID)
                else:
                    baseCtrl = game
                from vnframe import VNFileBrowser
                VNFileBrowser(baseCtrl, onLoadFile, True, sgd.lastLoadFolder, title, extFilter)
    else:
        GUILayout.Label(sgd.recordingFilePath)
    GUILayout.EndHorizontal()

    if sgd.recordingStep != "before":
        # id
        GUILayout.BeginHorizontal()
        GUILayout.Label("Id", GUILayout.Width(lblWidth))
        sgd.recordingId = GUILayout.TextField(sgd.recordingId, GUILayout.Width(100))
        GUILayout.EndHorizontal()

        # name
        GUILayout.BeginHorizontal()
        GUILayout.Label("Alias", GUILayout.Width(lblWidth))
        sgd.recordingAlias = GUILayout.TextField(sgd.recordingAlias)
        GUILayout.EndHorizontal()

        # re-sample
        GUILayout.BeginHorizontal()
        GUILayout.Label("Resample", GUILayout.Width(lblWidth))
        if GUILayout.Toggle(sgd.recordingResample == 30, "30sps", GUILayout.ExpandWidth(False)):
            sgd.recordingResample = 30
        if GUILayout.Toggle(sgd.recordingResample == 60, "60sps", GUILayout.ExpandWidth(False)):
            sgd.recordingResample = 60
        GUILayout.EndHorizontal()

        # sync-start
        GUILayout.BeginHorizontal()
        GUILayout.Label("Sync with", GUILayout.Width(lblWidth))
        if GUILayout.Toggle(sgd.recordingSyncStart == "None", "None", GUILayout.ExpandWidth(False)):
            sgd.recordingSyncStart = "None"
        if GUILayout.Toggle(sgd.recordingSyncStart == "MMDD", "MMD Director", GUILayout.ExpandWidth(False)):
            sgd.recordingSyncStart = "MMDD"
        if GUILayout.Toggle(sgd.recordingSyncStart.startswith("Clip#"), "VNClip", GUILayout.ExpandWidth(False)):
            tgtclp = GUILayout.TextField(sgd.recordingSyncStart[5:], GUILayout.Width(100))
            sgd.recordingSyncStart = "Clip#" + tgtclp
        GUILayout.EndHorizontal()

    # buttons
    btnstyle = GUIStyle("button")
    btnstyle.fontSize = 14
    # control button
    GUILayout.FlexibleSpace()
    GUILayout.BeginHorizontal()
    if sgd.recordingStep == "before" and GUILayout.Button("     Start recording     ", btnstyle):
        err = tryLoadFile()
        if err == None:
            err = startRecording()
        if err:
            vnsnd_GUI_message_help(game, "Fail to start recording:\n" + err)
    if sgd.recordingStep == "after"  and GUILayout.Button("           Done           ", btnstyle):
        options = {
            "id": sgd.recordingId,
            "name": sgd.recordingAlias,
            "length": sgd.recordingSoundSource.length,
            "syncStart": sgd.recordingSyncStart,
            "interval": 1 / float(sgd.recordingResample),
        }
        sgd.recordingSpectrum.saveRecording(options)
        quitRecording()
    if GUILayout.Button("Cancel", btnstyle):
        sgd.recordingSpectrum.abortRecording()
        quitRecording()
    GUILayout.EndHorizontal()

    # monitor recording status change
    if hasattr(sgd, "recordingStep") and sgd.recordingStep == "recording" and not sgd.recordingSoundSource.isPlaying:
        print(">>> stop recording")
        VNEnableGarbageCollect(True)
        sgd.recordingStep = "after"


def vnsnd_GUI_message(game):
    if vnsnd_GUI_switch(game, vnsnd_GUI_message):
        return

    sgd = game.scenedata.vnSoundGuiData
    lblstyle = GUIStyle("label")
    lblstyle.fontSize = 14
    btnstyle = GUIStyle("button")
    btnstyle.fontSize = 14
    
    GUILayout.Label(sgd.msgContents, lblstyle)
    GUILayout.FlexibleSpace()
    GUILayout.BeginHorizontal()
    if GUILayout.Button("OK", btnstyle):
        sgd.msgOkFunc(game)
    if sgd.msgCancelFunc and GUILayout.Button("Cancel", btnstyle):
        sgd.msgCancelFunc(game)
    GUILayout.EndHorizontal()

def vnsnd_GUI_message_help(game, msg, okFunc=None, cancelFunc=None):
    sgd = game.scenedata.vnSoundGuiData
    sgd.msgContents = msg
    sgd.msgOkFunc = okFunc if okFunc != None else sgd.guiFunction
    sgd.msgCancelFunc = cancelFunc

    vnsnd_GUI_message(game)

def vnsnd_GUI_parts_SoundSource(game, alias):
    mgr = game.gdata.vnSoundManager
    sgd = game.scenedata.vnSoundGuiData
    ss = mgr.soundDict[alias]

    if alias not in sgd.soundSourceMoreData:
        sgd.soundSourceMoreData[alias] = GData()
        sgd.soundSourceMoreData[alias].expand = False
    moreData = sgd.soundSourceMoreData[alias]

    def formatTime(ss):
        if ss.length > 60:
            ctm = int(ss.time / 60)
            cts = round(ss.time % 60)
            ttm = int(ss.length / 60)
            tts = round(ss.length % 60)
            return "%d:%02d / %d:%02d"%(ctm, cts, ttm, tts)
        else:
            return "%.1fs / %.1fs"%(ss.time, ss.length)

    GUILayout.BeginVertical(GUI.skin.box)
    # 1st line, tag, title, type
    GUILayout.BeginHorizontal()
    GUILayout.Label(yellowText(alias), GUILayout.Width(80))
    color = GUI.color 
    if ss.isPlaying:
        GUI.color = Color.green
    ss.audioTitle = GUILayout.TextField(ss.audioTitle, GUI.skin.label)
    GUI.color = color
    GUILayout.FlexibleSpace()
    GUILayout.Label(magentaText(ss.audioType))
    GUI.color = Color.red
    if GUILayout.Button("X", GUILayout.Width(25)):
        def delSS(game):
            mgr.RemoveSoundSource(alias)
            del sgd.soundSourceMoreData[alias]
            vnsnd_GUI_manager(game)
        vnsnd_GUI_message_help(game, "Delete sound '%s'?"%yellowText(alias), delSS, vnsnd_GUI_manager)
    GUI.color = color
    GUILayout.EndHorizontal()

    # 2nd line, control, options
    GUILayout.BeginHorizontal()
    if ss.isPlaying:
        if GUILayout.Button("<color=#00ff00>||</color>", GUILayout.Width(25)):
            ss.Pause()
        if GUILayout.Button("<color=#ff0000>" + u'\u25a0' + "</color>", GUILayout.Width(25)):
            ss.Stop()
    else:
        if GUILayout.Button("<color=#00ff00>" + u'\u25b6' + "</color>", GUILayout.Width(25)):
            ss.Play()
        if GUILayout.Button("|<", GUILayout.Width(25)):
            ss.Rewind()
    # time
    GUILayout.Label(formatTime(ss), GUILayout.Width(80))
    # volume
    GUILayout.Label("Vol")
    volume = GUILayout.HorizontalSlider(ss.volume, 0, 1, GUILayout.Width(40))
    if volume != ss.volume:
        ss.SetVolume(volume)
    GUILayout.Label("%d%%"%(ss.volume*100), GUILayout.Width(35))
    # play on load
    pol = GUILayout.Toggle(ss.playOnLoad, "PlayOnLoad")
    if pol != ss.playOnLoad:
        ss.SetPlayOnLoad(pol)
    # loop
    loop = GUILayout.Toggle(ss.loop, "Loop")
    if loop != ss.loop:
        ss.SetLoop(loop)
    # 3D
    threeD = GUILayout.Toggle(ss.threeD, "3D")
    if threeD != ss.threeD:
        ss.SetThreeD(threeD)
    # detail
    GUILayout.FlexibleSpace()
    if not moreData.expand and GUILayout.Button("+", GUILayout.Width(25)):
        moreData.expand = True
        moreData.pitchInput = "%.2f"%ss.pitch
        moreData.priorityInput = str(ss.priority)
        moreData.minDstInput = "%.0f"%ss.minDistance
        moreData.maxDstInput = "%.0f"%ss.maxDistance
        moreData.onPosSetting = False
    if moreData.expand and GUILayout.Button("-", GUILayout.Width(25)):
        moreData.expand = False
    GUILayout.EndHorizontal()

    # 3rd line, more options
    if moreData.expand:
        GUILayout.BeginHorizontal()
        # pan stereo
        GUILayout.Label("PanStereo L")
        panStereo = GUILayout.HorizontalSlider(ss.panStereo, -1, 1, GUILayout.Width(60))
        if panStereo != ss.panStereo:
            ss.SetPanStereo(panStereo)
        GUILayout.Label("R")
        if GUILayout.Button("center"):
            ss.SetPanStereo(0)
        # pitch
        GUILayout.Label("Pitch")
        moreData.pitchInput = GUILayout.TextField(moreData.pitchInput, GUILayout.Width(40))
        # priority
        GUILayout.Label("Priority")
        moreData.priorityInput = GUILayout.TextField(moreData.priorityInput, GUILayout.Width(40))
        GUILayout.FlexibleSpace()

        if not ss.threeD and GUILayout.Button("Update"):
            check = ""
            try:
                check = "pitch"
                pitch = float(moreData.pitchInput)
                ss.SetPitch(pitch)
                check = "priority"
                priority = int(moreData.priorityInput)
                if 0 <= priority <= 255:
                    ss.SetPriority(priority)
                else:
                    raise Exception("Value should in range 0-255. 0 the highest, 255 the lowest.")
            except Exception as e:
                errmsg = "Invalid input for %s: %s"%(check, str(e))
                vnsnd_GUI_message_help(game, errmsg)
        GUILayout.EndHorizontal()

    # 4th line, 3d setting
    if moreData.expand and ss.threeD:
        GUILayout.BeginHorizontal()
        # doppler level
        GUILayout.Label("DopplerLevel")
        dopplerLevel = GUILayout.HorizontalSlider(ss.dopplerLevel, 0, 1, GUILayout.Width(40))
        if dopplerLevel != ss.dopplerLevel:
            ss.SetDopplerLevel(dopplerLevel)
        GUILayout.Label("%.1f"%(ss.dopplerLevel), GUILayout.Width(25))
        # min/max distance
        GUILayout.Label("Distance")
        moreData.minDstInput = GUILayout.TextField(moreData.minDstInput, GUILayout.Width(50))
        GUILayout.Label("~")
        moreData.maxDstInput = GUILayout.TextField(moreData.maxDstInput, GUILayout.Width(50))

        GUILayout.FlexibleSpace()
        if GUILayout.Button("Update"):
            check = ""
            try:
                check = "pitch"
                pitch = float(moreData.pitchInput)
                ss.SetPitch(pitch)
                check = "priority"
                priority = int(moreData.priorityInput)
                if 0 <= priority <= 255:
                    ss.SetPriority(priority)
                else:
                    raise Exception("Value should in range 0-255. 0 the highest, 255 the lowest.")
                check = "min distance"
                minDst = float(moreData.minDstInput)
                ss.SetMinDistance(minDst)
                check = "max distance"
                maxDst = float(moreData.maxDstInput)
                ss.SetMaxDistance(maxDst)
            except Exception as e:
                errmsg = "Invalid input for %s: %s"%(check, str(e))
                vnsnd_GUI_message_help(game, errmsg)
        GUILayout.EndHorizontal()

    # 5th line, attach/position setting
    if moreData.expand and ss.threeD:
        GUILayout.BeginHorizontal()
        if ss.parentId != None:
            GUILayout.Label(greenText("Attached to"))
            if GUILayout.Button(ss.parentOCIObj.treeNodeObject.textName):
                if game.studio.treeNodeCtrl.selectNode != ss.parentOCIObj.treeNodeObject:
                    game.studio.treeNodeCtrl.selectNode = ss.parentOCIObj.treeNodeObject
            GUILayout.Label("at (%.1f, %.1f, %.1f) distance %.1f"%(ss.position.x, ss.position.y, ss.position.z, Vector3.Distance(ss.position, Camera.main.transform.position)))
            GUILayout.FlexibleSpace()
            if GUILayout.Button("Attach"):
                sgd.attachSoundSourceAlias = alias
                sgd.attachMode = "position"
                vnsnd_GUI_attach(game)
            if GUILayout.Button("Detach"):
                curPos = ss.position
                ss.Attach(None, curPos)
        elif moreData.onPosSetting:
            GUILayout.Label("Move to:")
            GUILayout.Label("x")
            moreData.posXInput = GUILayout.TextField(moreData.posXInput, GUILayout.Width(60))
            GUILayout.Label("y")
            moreData.posYInput = GUILayout.TextField(moreData.posYInput, GUILayout.Width(60))
            GUILayout.Label("z")
            moreData.posZInput = GUILayout.TextField(moreData.posZInput, GUILayout.Width(60))
            GUILayout.FlexibleSpace()
            if GUILayout.Button("Update Position"):
                try:
                    px = float(moreData.posXInput)
                    py = float(moreData.posYInput)
                    pz = float(moreData.posZInput)
                    ss.Attach(None, Vector3(px, py, pz))
                    moreData.onPosSetting = False
                except:
                    vnsnd_GUI_message_help(game, "Invalid input, set float value in x,y,z please")
        else:
            GUILayout.Label(yellowText("Not Attached!") + " Position at (%.3f, %.3f, %.3f)"%(ss.position.x, ss.position.y, ss.position.z))
            GUILayout.FlexibleSpace()
            if GUILayout.Button("Attach"):
                sgd.attachSoundSourceAlias = alias
                sgd.attachMode = "position"
                vnsnd_GUI_attach(game)
            if GUILayout.Button("SetPos"):
                moreData.posXInput = "%.3f"%ss.position.x
                moreData.posYInput = "%.3f"%ss.position.y
                moreData.posZInput = "%.3f"%ss.position.z
                moreData.onPosSetting = True
        GUILayout.EndHorizontal()

    # 4th/6th line, trigger object
    if moreData.expand:
        GUILayout.BeginHorizontal()
        if ss.triggerId != None:
            # trigger object
            GUILayout.Label("Extra object trigger:", GUILayout.ExpandWidth(False))
            # trigger folder shortcut
            if GUILayout.Button(ss.triggerOCIObj.treeNodeObject.textName, GUILayout.ExpandWidth(False)):
                if game.studio.treeNodeCtrl.selectNode != ss.triggerOCIObj.treeNodeObject:
                    game.studio.treeNodeCtrl.selectNode = ss.triggerOCIObj.treeNodeObject
            # auto rewind
            ss.autoRewind = GUILayout.Toggle(ss.autoRewind, "Auto rewind", GUILayout.ExpandWidth(False))
            # button
            GUILayout.FlexibleSpace()
            if GUILayout.Button("Re-Link"):
                sgd.attachSoundSourceAlias = alias
                sgd.attachMode = "trigger"
                vnsnd_GUI_attach(game)
            if GUILayout.Button("De-link"):
                ss.LinkTrigger(None)
                mgr.UpdateSoundSourceTrigger()
        else:
            # N/A
            GUILayout.Label("Extra object trigger not linked", GUILayout.ExpandWidth(False))
            # button
            GUILayout.FlexibleSpace()
            if GUILayout.Button("Link", GUILayout.ExpandWidth(False)):
                sgd.attachSoundSourceAlias = alias
                sgd.attachMode = "trigger"
                vnsnd_GUI_attach(game)
        GUILayout.EndHorizontal()

    GUILayout.EndVertical()

def vnsnd_GUI_parts_SoundSpectrum(game, spSetting):
    mgr = game.gdata.vnSoundManager
    sgd = game.scenedata.vnSoundGuiData
    color = GUI.color

    def formatTime(ss):
        if ss.length > 60:
            ctm = int(ss.time / 60)
            cts = round(ss.time % 60)
            ttm = int(ss.length / 60)
            tts = round(ss.length % 60)
            return "%d:%02d / %d:%02d"%(ctm, cts, ttm, tts)
        else:
            return "%.1fs / %.1fs"%(ss.time, ss.length)

    GUILayout.BeginVertical(GUI.skin.box)
    # 1st spectrum, title, sample count, band count, STATUS
    GUILayout.BeginHorizontal()
    if spSetting.isRecorded:
        GUILayout.Label(magentaText("Recorded Spectrum: ") + yellowText(spSetting.recordData["id"]) + " " + spSetting.recordData["name"])
        GUILayout.FlexibleSpace()
        GUILayout.Label(yellowText("Sync with: " + spSetting.recordData["syncStart"]))
    else:
        GUILayout.Label(magentaText("Spectrum") + " %d samples %d bands"%(spSetting.sampleCount, spSetting.bandCount))
        GUILayout.FlexibleSpace()
        if spSetting.isValid:
            GUILayout.Label(greenText(spSetting.audioTitle))
        else:
            GUILayout.Label(redText("Searching " + spSetting.audioSeeker))
    GUILayout.EndHorizontal()

    # 2nd control buttons
    GUILayout.BeginHorizontal()
    if spSetting.isRecorded:
        if spSetting.isPlaying:
            if GUILayout.Button("<color=#00ff00>||</color>", GUILayout.Width(25)):
                spSetting.Pause()
            if GUILayout.Button("<color=#ff0000>" + u'\u25a0' + "</color>", GUILayout.Width(25)):
                spSetting.Stop()
        else:
            if GUILayout.Button("<color=#00ff00>" + u'\u25b6' + "</color>", GUILayout.Width(25)):
                spSetting.Play()
            if GUILayout.Button("|<", GUILayout.Width(25)):
                spSetting.Rewind()
        # time
        GUILayout.Label(formatTime(spSetting), GUILayout.Width(80))
        GUILayout.FlexibleSpace()
        GUI.color = Color.red
        if GUILayout.Button("Discard", GUILayout.ExpandWidth(False)):
            def dismissData(game):
                spSetting.cleanup()
                spSetting.abortRecording()
                spSetting.initialize(game)
                vnsnd_GUI_manager(game)
            vnsnd_GUI_message_help(game, "Discard record data?", dismissData, vnsnd_GUI_manager)
        GUI.color = color
    else:
        if GUILayout.Button(greenText("Enabled") if spSetting.enable else redText("Disabled"), GUILayout.Width(70)):
            spSetting.enable = not spSetting.enable
        if GUILayout.Button("Edit"):
            spSetting.cleanup()
            sgd.selectSpectrumData = spSetting
            sgd.backupSpectrumData = SoundSpectrum()
            sgd.backupSpectrumData.copySetting(spSetting)
            vnsnd_GUI_spectrum(game)
        if GUILayout.Button("Record"):
            sgd.recordingSpectrum = spSetting
            vnsnd_GUI_spectrum_recording(game)
        if GUILayout.Button("Reset"):
            spSetting.cleanup()
            spSetting.initialize(game)
        if GUILayout.Button(greenText("Preview") if sgd.spectrumPreview == spSetting else "Preview"):
            if sgd.spectrumPreview == spSetting:
                sgd.spectrumPreview = None
            else:
                sgd.spectrumPreview = spSetting
        if sgd.spectrumPreview == spSetting:
            sgd.spectrumPreviewDetail = GUILayout.Toggle(sgd.spectrumPreviewDetail, "More info")
        GUILayout.FlexibleSpace()
        GUI.color = Color.red
        if GUILayout.Button("X", GUILayout.Width(25)):
            def delSP(game):
                mgr.RemoveSoundSpectrum(spSetting)
                sgd.spectrumPreview = None
                vnsnd_GUI_manager(game)
            vnsnd_GUI_message_help(game, "Delete selected spectrum setting?", delSP, vnsnd_GUI_manager)
        GUI.color = color
    GUILayout.EndHorizontal()

    # preview
    if sgd.spectrumPreview == spSetting:
        freqPerSample = float(AudioSettings.outputSampleRate / 2) / spSetting.sampleCount
        bnwidth = 150
        bvwidth = 80
        if spSetting.isValid:
            # preview title
            GUILayout.BeginHorizontal()
            if sgd.spectrumPreviewDetail:
                GUILayout.Label("Band name/range", GUILayout.Width(bnwidth))
                GUILayout.Label("Cur/Max", GUILayout.Width(bvwidth))
                if spSetting.unifiedProfile:
                    GUILayout.Label("Normalized by max = %.5f"%spSetting.freqBandHighestAll)
                else:
                    GUILayout.Label("Normalized value")
            else:
                GUILayout.Label("Band name", GUILayout.Width(bnwidth))
                GUILayout.Label("Max value", GUILayout.Width(bvwidth))
                GUILayout.Label("Normalized value")
            GUILayout.EndHorizontal()
            # band preview
            for i in range(spSetting.bandCount):
                nmlValue = spSetting.audioBandBuffer[i]
                curValue = spSetting.bandBuffer[i]
                maxValue = spSetting.freqBandHighest[i]
                GUILayout.BeginHorizontal()
                if sgd.spectrumPreviewDetail:
                    GUILayout.Label("%s\n(%.2f-%.2fHz)"%(spSetting.bandSetting[i][0], spSetting.bandSetting[i][1] * freqPerSample, spSetting.bandSetting[i][2] * freqPerSample), GUILayout.Width(bnwidth))
                    GUILayout.Label("%.5f\n%.5f"%(curValue, maxValue), GUILayout.Width(bvwidth))
                else:
                    GUILayout.Label(spSetting.bandSetting[i][0], GUILayout.Width(bnwidth))
                    GUILayout.Label("%.5f"%(maxValue), GUILayout.Width(bvwidth))
                GUILayout.HorizontalScrollbar(0, nmlValue, 0, 1.0)
                GUILayout.EndHorizontal()
            # amplitude preview
            if spSetting.amplitudeEnable:
                nmlValue = spSetting.amplitude
                maxValue = spSetting.amplitudeHighest
                GUILayout.BeginHorizontal()
                GUILayout.Label("Amplitude", GUILayout.Width(bnwidth))
                GUILayout.Label("%.5f"%(maxValue), GUILayout.Width(bvwidth))
                GUILayout.HorizontalScrollbar(0, nmlValue, 0, 1.0)
                GUILayout.EndHorizontal()
            # buffer decrease setting
            if spSetting.bandBufferDecrease:
                GUILayout.BeginHorizontal()
                GUILayout.Label("Audio drop speed:")
                dsi = GUILayout.TextField("%.5f"%spSetting.bandBufferDecreaseSpeedInit, GUILayout.Width(70))
                if dsi != "%.5f"%spSetting.bandBufferDecreaseSpeedInit:
                    try:
                        ndsi = float(dsi)
                        if ndsi > 0:
                            spSetting.bandBufferDecreaseSpeedInit = ndsi
                    except:
                        pass
                GUILayout.Label("Accelerate rate:")
                dsa = GUILayout.TextField("%.5f"%spSetting.bandBufferDecreaseSpeedAccl, GUILayout.Width(70))
                if dsa != "%.5f"%spSetting.bandBufferDecreaseSpeedAccl:
                    try:
                        ndsa = float(dsa)
                        if ndsa > 0:
                            spSetting.bandBufferDecreaseSpeedAccl = ndsa
                    except:
                        pass
                GUILayout.FlexibleSpace()
                GUILayout.EndHorizontal()

        else:
            GUILayout.Label(yellowText("Spectrum not linked to audio source yet, preview not valid"))


    GUILayout.EndVertical()


# ==================================  VNAnime function ===================================
def vnsnd_set_playing(snd, param):
    if param:
        snd.Play()
    else:
        snd.Pause()

def vnsnd_set_time(snd, param):
    snd.SetTime(param)

def vnsnd_set_position(snd, param):
    snd.SetPosition(param)

def vnsnd_set_volume(snd, param):
    snd.SetVolume(param)

def vnsnd_set_loop(snd, param):
    snd.SetLoop(param)

def vnsnd_set_panStereo(snd, param):
    snd.SetPanStereo(param)

def vnsnd_set_pitch(snd, param):
    snd.SetPitch(param)

def vnsnd_set_spatialBlend(snd, param):
    snd.SetThreeD(param)

def vnsnd_set_minDistance(snd, param):
    snd.SetMinDistance(param)

def vnsnd_set_maxDistance(snd, param):
    snd.SetMaxDistance(param)

def vnsnd_set_dopplerLevel(snd, param):
    snd.SetDopplerLevel(param)

vnsnd_act_funcs = {
    'isPlaying': (vnsnd_set_playing, False, "force", "T"),
    'time': (vnsnd_set_time, False, "force", "T"), # time can not be interpolate
    'position': (vnsnd_set_position, True),
    'volume': (vnsnd_set_volume, True),
    'loop': (vnsnd_set_loop, False),
    'panStereo': (vnsnd_set_panStereo, True),
    'pitch': (vnsnd_set_pitch, True),
    'spatialBlend': (vnsnd_set_spatialBlend, False), # to avoid confuse, disable interpolate for spatial blend
    'minDistance': (vnsnd_set_minDistance, True),
    'maxDistance': (vnsnd_set_maxDistance, True),
    'dopplerLevel': (vnsnd_set_dopplerLevel, True),
}

vnsnd_act_update_sequence = (
    'time',
    'volume',
    'loop',
    'panStereo',
    'pitch',
    'spatialBlend',
    'minDistance',
    'maxDistance',
    'dopplerLevel',
    'position',
    # play status the last
    'isPlaying',
)

# ====================================  help function ====================================
def colorText(text, color = "ffffff"):
    return "<color=#" + color + ">" + text + "</color>"
def redText(text):
    return colorText(text, "ff0000")
def greenText(text):
    return colorText(text, "00ff00")
def blueText(text):
    return colorText(text, "0000ff")
def yellowText(text):
    return colorText(text, "ffff00")
def orangeText(text):
    return colorText(text, "ffa500")
def cyanText(text):
    return colorText(text, "00ffff")
def magentaText(text):
    return colorText(text, "ff00ff")
def greyText(text):
    return colorText(text, "555555")

# ====================================  Data Class ====================================
class SoundSource():
    SOUND_SOURCE_TYPE = ("BGM", "ENV", "SystemSE", "GameSE")
    VALID_SAVE_MODE = ("abspath", "relpath", "filedata", "clipdata")

    def __init__(self):
        # setting
        self.audioFilePath = ""
        self.audioTitle = ""
        self.audioExt = ""
        self.audioType = ""
        self.parentId = None
        self.setPos = Vector3.zero
        self.triggerId = None
        self.autoRewind = False

        # runtime
        self.audioSource = None
        self.audioFileData = None
        self.parentOCIObj = None
        self.triggerOCIObj = None
        self.CreateGameObject(None, Vector3.zero)

    def exportSetting(self, mode):
        if not self.isLoaded:
            return {}, None
        if mode not in SoundSource.VALID_SAVE_MODE:
            raise Exception("Invalid save mode '%s'"%mode)
        setting = {}
        extData = None
        # setting
        setting["audioTitle"] = self.audioTitle
        setting["audioType"] = self.audioType
        setting["parentId"] = self.parentId
        setting["position"] = (self.position.x, self.position.y, self.position.z)
        setting["volume"] = self.audioSource.volume
        setting["loop"] = self.audioSource.loop
        setting["playOnAwake"] = self.audioSource.playOnAwake
        setting["panStereo"] = self.audioSource.panStereo
        setting["priority"] = self.audioSource.priority
        setting["pitch"] = self.audioSource.pitch
        setting["spatialBlend"] = self.audioSource.spatialBlend
        setting["minDistance"] = self.audioSource.minDistance
        setting["maxDistance"] = self.audioSource.maxDistance
        setting["dopplerLevel"] = self.audioSource.dopplerLevel
        setting["triggerId"] = self.triggerId
        setting["autoRewind"] = self.autoRewind
        # data
        if mode == "abspath" and self.audioFilePath:
            # save in abs path mode
            setting["audioFilePath"] = self.audioFilePath
        elif mode == "relpath" and self.audioFilePath:
            # save in rel path mode
            basePath = path.realpath(path.join(Application.dataPath, '..'))
            try:
                relpath = path.relpath(self.audioFilePath, basePath)
                setting["audioRelPath"] = relpath
            except:
                print("Warning: sound source '%s' can not be saved in relative path, it will save in absolute path instead."%(self.audioTitle))
                setting["audioFilePath"] = self.audioFilePath
        elif mode == "filedata" and (self.audioFilePath or self.audioFileData):
            # save all data in file
            if self.audioFilePath:
                f = open(self.audioFilePath, "rb")
                extData = f.read()
                f.close()
            else:
                extData = self.audioFileData
            setting["audioExt"] = self.audioExt
            setting["audioFileData"] = 1
        else:
            # if cannot export path/filedata or mode = clipdata
            if mode != "clipdata":
                print("Warning: sound source '%s' can not be saved in %s mode"%(self.audioTitle, mode))
            clip = self.audioSource.clip
            pdata = [0] * clip.samples * clip.channels
            extData = Array[Single](pdata)
            if not clip.GetData(extData, 0):
                raise Exception("Fail to get data from clip")
            setting["samples"] = clip.samples
            setting["channels"] = clip.channels
            setting["frequency"] = clip.frequency
            setting["audioClipData"] = 1
        return setting, extData

    def importSetting(self, setting, extData):
        self.Cleanup()
        try:
            from vnactor import tuple3_2_vector3
            # create object
            self.CreateGameObject(setting["parentId"], tuple3_2_vector3(setting["position"]))

            # load audio setting
            err = self.CreateAudioSource(setting["audioType"])
            if err:
                raise Exception(err)
            self.audioSource.volume = setting["volume"]
            self.audioSource.loop = setting["loop"]
            self.audioSource.playOnAwake = setting["playOnAwake"]
            self.audioSource.panStereo = setting["panStereo"]
            self.audioSource.priority = setting["priority"]
            self.audioSource.pitch = setting["pitch"]
            self.audioSource.spatialBlend = setting["spatialBlend"]
            self.audioSource.minDistance = setting["minDistance"]
            self.audioSource.maxDistance = setting["maxDistance"]
            self.audioSource.dopplerLevel = setting["dopplerLevel"]
            # new from v2.4
            if "triggerId" in setting:
                self.LinkTrigger(setting["triggerId"])
            if "autoRewind" in setting:
                self.autoRewind = setting["autoRewind"]
            # load audio data
            if "audioFilePath" in setting:
                # abspath
                err = self.LoadAudioFile(setting["audioFilePath"])
                if err:
                    raise Exception(err)
            elif "audioRelPath" in setting:
                # relpath
                basePath = path.realpath(path.join(Application.dataPath, '..'))
                filepath = path.join(basePath, setting["audioRelPath"])
                err = self.LoadAudioFile(filepath)
                if err:
                    raise Exception(err)
            elif "audioFileData" in setting and "audioExt" in setting and extData != None:
                # filedata
                err = self.LoadFileData(extData, setting["audioExt"])
                if err:
                    raise Exception(err)
            elif "audioClipData" in setting and extData != None:
                # clipdata
                from UnityEngine import AudioClip
                clip = AudioClip.Create(setting["audioTitle"], setting["samples"], setting["channels"], setting["frequency"], True, False)
                extData = Array[Single](extData)
                if clip.SetData(extData, 0):
                    self.audioSource.clip = clip
                    self.audioFilePath = None
                    self.audioTitle = setting["audioTitle"]
                    # auto play
                    if self.audioSource.playOnAwake and not self.audioSource.isPlaying:
                        self.audioSource.Play()
                    print("AudioClip '%s' loaded from ext data!"%self.audioTitle)
                else:
                    raise Exception("Set clip data failed")
            else:
                raise Exception("No audio data in setting")

            # restore title
            self.audioTitle = setting["audioTitle"]

            return None
        except Exception as e:
            traceback.print_exc()
            return "Failed import sound source: " + str(e)

    def validSaveMode(self):
        vsm = set()
        if self.audioFilePath and path.isfile(self.audioFilePath):
            vsm.add("abspath")
            basePath = path.realpath(path.join(Application.dataPath, '..'))
            try:
                path.relpath(self.audioFilePath, basePath)
                vsm.add("relpath")
            except:
                pass
            vsm.add("filedata")
        if self.audioFileData:
            vsm.add("filedata")
        if self.isLoaded:
            vsm.add("clipdata")
        return vsm

    @property
    def isLoaded(self):
        return self.audioSource != None and self.audioSource.clip != None

    @property
    def canExportToFile(self):
        return self.audioFileData != None and len(self.audioFileData) != 0 and self.audioExt.lower() in {".wav", ".mp3", ".ogg"}

    @property
    def length(self):
        return 0 if not self.isLoaded else self.audioSource.clip.length

    @property
    def isPlaying(self):
        return self.isLoaded and self.audioSource.isPlaying
    
    @property
    def time(self):
        return 0 if not self.isLoaded else self.audioSource.time

    def SetTime(self, value):
        if self.isLoaded and 0 <= value <= self.length:
            self.audioSource.time = value

    @property
    def volume(self):
        return 0 if not self.isLoaded else self.audioSource.volume

    def SetVolume(self, value):
        if self.isLoaded:
            self.audioSource.volume = value

    @property
    def loop(self):
        return False if not self.isLoaded else self.audioSource.loop

    def SetLoop(self, value):
        if self.isLoaded:
            self.audioSource.loop = value

    @property
    def playOnLoad(self):
        return False if not self.isLoaded else self.audioSource.playOnAwake

    def SetPlayOnLoad(self, value):
        if self.isLoaded:
            self.audioSource.playOnAwake = value

    @property
    def pitch(self):
        return 0 if not self.isLoaded else self.audioSource.pitch

    def SetPitch(self, value):
        if self.isLoaded:
            self.audioSource.pitch = value

    @property
    def panStereo(self):
        return 0 if not self.isLoaded else self.audioSource.panStereo

    def SetPanStereo(self, value):
        if self.isLoaded:
            self.audioSource.panStereo = value

    @property
    def priority(self):
        return 0 if not self.isLoaded else self.audioSource.priority

    def SetPriority(self, value):
        if self.isLoaded and 0 <= value < 256:
            self.audioSource.priority = value

    @property
    def threeD(self):
        return False if not self.isLoaded else self.audioSource.spatialBlend != 0

    def SetThreeD(self, value):
        if self.isLoaded:
            if isinstance(value, float) and 0 <= value <= 1:
                self.audioSource.spatialBlend = value
            elif value:
                self.audioSource.spatialBlend = 1
            else:
                self.audioSource.spatialBlend = 0

    @property
    def position(self):
        return self.setPos if self.audioGO == None else self.audioGO.transform.position

    def SetPosition(self, value):
        if self.audioGO:
            self.setPos = value
            self.audioGO.transform.position = value

    @property
    def minDistance(self):
        return 0 if not self.isLoaded else self.audioSource.minDistance

    def SetMinDistance(self, value):
        if self.isLoaded:
            self.audioSource.minDistance = value

    @property
    def maxDistance(self):
        return 0 if not self.isLoaded else self.audioSource.maxDistance

    def SetMaxDistance(self, value):
        if self.isLoaded:
            self.audioSource.maxDistance = value

    @property
    def dopplerLevel(self):
        return 0 if not self.isLoaded else self.audioSource.dopplerLevel

    def SetDopplerLevel(self, value):
        if self.isLoaded:
            self.audioSource.dopplerLevel = value

    def CreateGameObject(self, parentId, position):
        self.audioGO = GameObject()
        self.audioGO.name = "VNSound"
        self.audioGO.transform.position = position
        self.audioGO.transform.rotation = Quaternion.identity
        self.Attach(parentId, position)

    def CreateAudioSource(self, sndType):
        # check type
        if not sndType in SoundSource.SOUND_SOURCE_TYPE:
            return "Invalid sound type: %s"%sndType

        from Manager import Sound
        presetDict = {
            "BGM": Sound.Type.BGM,
            "ENV": Sound.Type.ENV,
            "SystemSE": Sound.Type.SystemSE,
            "GameSE": Sound.Type.GameSE3D,
        }
        asType = presetDict[sndType]
        
        # create AudioSource
        from vngameengine import get_engine_id
        engId = get_engine_id()
        if engId == "neo":
            # HS1 neo
            from UnityEngine import AudioSource
            settingObj = Sound.instance.settingObject[int(asType)]
            cloneObj = Object.Instantiate[GameObject](settingObj)
            cloneObj.SetActive(True)
            self.audioSource = cloneObj.GetComponent[AudioSource]()
        elif hasattr(Sound, "Instance"):
            #
            self.audioSource = Sound.Instance.Create(asType, True)
        else:
            # HS2/AI
            self.audioSource = Sound.Create(asType, True)
        self.audioSource.gameObject.transform.parent = self.audioGO.transform
        self.audioSource.gameObject.transform.localPosition = Vector3.zero
        self.audioSource.gameObject.transform.localRotation = Quaternion.identity
        self.audioSource.loop = False
        self.audioSource.pitch = 1.0
        self.audioSource.dopplerLevel = 0.0
        self.audioSource.spatialize = False
        self.audioSource.spatialBlend = 0.0
        self.audioSource.playOnAwake = False
        self.audioSource.minDistance = 10
        self.audioSource.maxDistance = 200

        # all green
        self.audioType = sndType
        return None

    def LoadAudioFile(self, filepath):
        if not self.audioSource:
            return "AudioSource not existed. create AudioSource first!"
        if not path.isfile(filepath):
            return "File '%s' not found"%filepath
        filepath = path.abspath(filepath)
        filedir, filename = path.split(filepath)
        title, ext = path.splitext(filename)

        try:
            if ext.lower() == ".mp3":
                from NLayer import MpegFile
                from UnityEngine import AudioClip

                mpegFile = MpegFile(filepath)
                #print("mpegFile file length: %d"%mpegFile.Length)
                lengthSamples = (int)(mpegFile.Length / 4 / mpegFile.Channels)
                def OnMp3Read(data):
                    actualReadCount = mpegFile.ReadSamples(data, 0, len(data))
                def OnClipPositionSet(postion):
                    pass
                self.audioSource.clip = AudioClip.Create(title, lengthSamples, mpegFile.Channels, mpegFile.SampleRate, True, False, OnMp3Read)
                mpegFile.Dispose()
            else:
                from UnityEngine import WWW
                from System import Uri
                tpath = Uri(filepath).AbsoluteUri
                w = WWW(tpath)
                while not w.isDone: pass
                if w.error:
                    return "Fail to create WWW object: " + str(w.error)
                # try get clip
                clip = None
                try:
                    if hasattr(w, "audioClip"):
                        clip = w.audioClip
                    if clip == None and hasattr(w, "GetAudioClip"):
                        clip = w.GetAudioClip()
                    if clip == None:
                        from UnityEngine import WWWAudioExtensions
                        clip = WWWAudioExtensions.GetAudioClip(w)
                except:
                    pass
                # check clip
                if clip == None:
                    return "Fail to get audio clip from '%s'."%filepath
                self.audioSource.clip = clip
            # auto play
            if self.audioSource.playOnAwake and not self.audioSource.isPlaying:
                self.audioSource.Play()
        except Exception as e:
            traceback.print_exc()
            return str(e)
        # load ok
        self.audioFilePath = filepath
        self.audioTitle = title
        self.audioExt = ext.lower()
        return None

    def ExportAudioFile(self, filepath):
        if not self.canExportToFile:
            return "No file data!"
        if not filepath.lower().endswith(self.audioExt.lower()):
            return "File extension not match!"
        try:
            # filedata
            f = open(filepath, "wb")
            f.write(self.audioFileData)
            f.close()
            self.audioFilePath = filepath
        except Exception as e:
            traceback.print_exc()
            return str(e)
        # export ok
        return None

    def LoadFileData(self, fileData, audioExt):
        try:
            basePath = path.realpath(path.join(Application.dataPath, '..'))
            tempfilepath = path.join(basePath, "vnsoundtemp" + audioExt)
            f = open(tempfilepath, "wb")
            f.write(fileData)
            f.close()
            err = self.LoadAudioFile(tempfilepath)
            self.audioFilePath = None # remove file path info because it is temp file
            try:
                import os
                os.remove(tempfilepath)
            except:
                pass
            if err:
                raise Exception(err)
        except Exception as e:
            traceback.print_exc()
            return str(e)
        # load ok
        self.audioFileData = fileData
        self.audioExt = audioExt
        return None

    def Attach(self, targetId, position):
        def searchObj(parentObj, tgtName):
            if parentObj.name == tgtName:
                return parentObj
            for i in range(parentObj.transform.childCount):
                childTR = parentObj.transform.GetChild(i)
                tObj = searchObj(childTR.gameObject, tgtName)
                if tObj != None:
                    return tObj
            return None

        # check audioGO
        if not self.audioGO:
            return
        # check attach target
        from Studio import Studio, OCIItem, OCIFolder, OCIChar
        studio = Studio.Instance
        if targetId != None and targetId in studio.dicObjectCtrl:
            # get attach object
            oci = studio.dicObjectCtrl[targetId]
            if isinstance(oci, OCIItem) or isinstance(oci, OCIFolder):
                pgo = oci.objectItem
            elif isinstance(oci, OCIChar):
                pgo = oci.charInfo.gameObject
                from vngameengine import get_engine_id, HSNeoOCIChar
                engId = get_engine_id()
                if engId == "neov2":
                    tgtHeadBone = "cf_J_Head"   # AI/HS2
                elif engId == "charastudio":
                    tgtHeadBone = "cf_j_head"   # KK/KKS
                else:
                    # PH/HS1
                    if HSNeoOCIChar(oci).as_actor.sex == 0:
                        tgtHeadBone = "cm_J_Head"
                    else:
                        tgtHeadBone = "cf_J_Head"
                phead = searchObj(pgo, tgtHeadBone)
                if phead:
                    print("Bind to char: found %s, use it as parent of vnsound"%tgtHeadBone)
                    pgo = phead
            else:
                pgo = None
        else:
            oci = None
            pgo = None
        # do attach/detach
        if pgo:
            # attach
            self.audioGO.transform.parent = pgo.transform
            self.audioGO.transform.localPosition = Vector3.zero
            self.parentId = targetId
            self.parentOCIObj = oci
        else:
            # detach: add into commonspace if possible
            from vnframe import VNGetCommonSpace
            cmn = VNGetCommonSpace()
            self.audioGO.transform.parent = None if cmn == None else cmn.transform
            self.SetPosition(position)
            self.parentId = None
            self.parentOCIObj = None

    def LinkTrigger(self, triggerId):
        # check attach target
        from Studio import Studio
        studio = Studio.Instance
        if triggerId != None and triggerId in studio.dicObjectCtrl:
            # get link object
            oci = studio.dicObjectCtrl[triggerId]
        else:
            oci = None
        # do link/de-link
        if oci:
            # link
            self.triggerId = triggerId
            self.triggerOCIObj = oci
        else:
            # de-link
            self.triggerId = None
            self.triggerOCIObj = None

    def Play(self):
        if self.isLoaded and not self.audioSource.isPlaying:
            self.audioSource.Play()

    def Pause(self):
        if self.isLoaded and self.audioSource.isPlaying:
            self.audioSource.Pause()

    def Stop(self):
        if self.isLoaded and self.audioSource.isPlaying:
            self.audioSource.Stop()

    def Seek(self, time):
        if self.isLoaded:
            self.audioSource.time = time

    def Rewind(self):
        if self.isLoaded and not self.audioSource.isPlaying:
            self.audioSource.time = 0

    def ExportStatus(self):
        if self.isLoaded:
            status = {}
            status["isPlaying"] = self.isPlaying
            status["parentId"] = self.parentId
            status["position"] = (self.position.x, self.position.y, self.position.z)
            status["volume"] = self.audioSource.volume
            status["loop"] = self.audioSource.loop
            status["playOnAwake"] = self.audioSource.playOnAwake
            status["panStereo"] = self.audioSource.panStereo
            status["priority"] = self.audioSource.priority
            status["pitch"] = self.audioSource.pitch
            status["spatialBlend"] = self.audioSource.spatialBlend
            status["minDistance"] = self.audioSource.minDistance
            status["maxDistance"] = self.audioSource.maxDistance
            status["dopplerLevel"] = self.audioSource.dopplerLevel
            return status
        else:
            return None

    def export_full_status(self):
        # status for vnanime ctrl, not all setting needed, just followed naming rules...
        fs = {}
        if self.isLoaded:
            fs["isPlaying"] = self.isPlaying
            if not self.isPlaying:
                fs["time"] = self.time
            fs["position"] = self.position
            fs["volume"] = self.audioSource.volume
            fs["loop"] = self.audioSource.loop
            fs["panStereo"] = self.audioSource.panStereo
            fs["pitch"] = self.audioSource.pitch
            fs["spatialBlend"] = self.audioSource.spatialBlend
            fs["minDistance"] = self.audioSource.minDistance
            fs["maxDistance"] = self.audioSource.maxDistance
            fs["dopplerLevel"] = self.audioSource.dopplerLevel
        return fs

    def ImportStatus(self, status):
        if self.isLoaded:
            from vnactor import tuple3_2_vector3
            for skey in status:
                if skey == "volume" and status[skey] != self.audioSource.volume:
                    self.audioSource.volume = status["volume"]
                if skey == "loop" and status[skey] != self.audioSource.loop:
                    self.audioSource.loop = status["loop"]
                if skey == "playOnAwake" and status[skey] != self.audioSource.playOnAwake:
                    self.audioSource.playOnAwake = status["playOnAwake"]
                if skey == "panStereo" and status[skey] != self.audioSource.panStereo:
                    self.audioSource.panStereo = status["panStereo"]
                if skey == "priority" and status[skey] != self.audioSource.priority:
                    self.audioSource.priority = status["priority"]
                if skey == "pitch" and status[skey] != self.audioSource.pitch:
                    self.audioSource.pitch = status["pitch"]
                if skey == "spatialBlend" and status[skey] != self.audioSource.spatialBlend:
                    self.audioSource.spatialBlend = status["spatialBlend"]
                if skey == "minDistance" and status[skey] != self.audioSource.minDistance:
                    self.audioSource.minDistance = status["minDistance"]
                if skey == "maxDistance" and status[skey] != self.audioSource.maxDistance:
                    self.audioSource.maxDistance = status["maxDistance"]
                if skey == "dopplerLevel" and status[skey] != self.audioSource.dopplerLevel:
                    self.audioSource.dopplerLevel = status["dopplerLevel"]
                # parent
                if skey == "parentId" and status[skey] != self.parentId and "position" in status:
                    self.Attach(status["parentId"], tuple3_2_vector3(status["position"]))
                # position
                if skey == "position" and self.parentId == None:
                    pos = tuple3_2_vector3(status["position"])
                    if pos != self.audioGO.transform.position:
                        self.SetPosition(pos)
                # play status
                if skey == "isPlaying":
                    if status["isPlaying"] and not self.isPlaying:
                        self.Play()
                    elif not status["isPlaying"] and self.isPlaying:
                        self.Pause()

    def Reload(self):
        if self.isLoaded:
            return
        try:
            if self.audioGO == None:
                self.CreateGameObject(self.parentId, self.setPos)
            if self.audioSource == None:
                err = self.CreateAudioSource(self.audioType)
                if err:
                    raise Exception(err)
            if self.audioSource.clip == None:
                if self.audioFileData and self.audioExt:
                    err = self.LoadFileData(self.audioFileData, self.audioExt)
                else:
                    err = self.LoadAudioFile(self.audioFilePath)
                if err:
                    raise Exception(err)
            print("vnsound %s reload"%self.audioTitle)
        except Exception as e:
            print("Fail to reload vnsound: %s"%str(e))

    def Cleanup(self):
        try:
            self.Stop()
            self.audioFilePath = ""
            self.audioTitle = ""
            self.audioFileData = None

            Object.Destroy(self.audioSource)
            self.audioSource = None
            Object.Destroy(self.audioGO)
            self.audioGO = None
        except:
            pass    # nothing to do

# ====================================  Spectrum Class ====================================
class SoundSpectrum():
    @property
    def isValid(self):
        return self.audioSource != None

    @property
    def isRecorded(self):
        return self.recordData != None

    @property
    def isPlaying(self):
        if self.isValid:
            return self.audioSource.isPlaying
        elif self.isRecorded:
            return self.recordData["isPlaying"]
        else:
            return False

    @property
    def bandCount(self):
        return len(self.bandSetting)

    @property
    def time(self):
        if self.isValid:
            return self.audioSource.time
        elif self.isRecorded:
            return self.recordData["time"]
        else:
            return 0

    @property
    def length(self):
        if self.isValid:
            return self.audioSource.clip.length
        elif self.isRecorded:
            return self.recordData["length"]
        else:
            return 0

    def __init__(self):
        # setting
        self.enable = True
        self.audioSeeker = "actived"
        self.audioSeekerOpt = "mmdd_vnsnd"
        self.sampleCount = 512
        self.sampleChannel = "left"
        self.sampleWindow = FFTWindow.Blackman
        self.bandSetting = [
            ["Band0",   0, 2,   None],
            ["Band1",   2, 6,   None],
            ["Band2",   6, 14,  None],
            ["Band3",  14, 30,  None],
            ["Band4",  30, 62,  None],
            ["Band5",  62, 126, None],
            ["Band6", 126, 512, None],
        ]
        self.bandStatistics = "sum"
        self.bandBufferDecrease = True
        self.bandBufferDecreaseSpeedInit = 0.5
        self.bandBufferDecreaseSpeedAccl = 15.0
        self.audioProfile = 1.0
        self.unifiedProfile = False
        self.amplitudeEnable = False
        self.amplitudeClip = None
        # record data
        self.recordData = None
        # runtime
        self.audioSource = None
        self.isRecording = False

    def exportSetting(self):
        setting = {}
        # sampling
        setting["enable"] = self.enable
        setting["audioSeeker"] = self.audioSeeker
        setting["audioSeekerOpt"] = self.audioSeekerOpt
        setting["sampleCount"] = self.sampleCount
        setting["sampleChannel"] = self.sampleChannel
        setting["sampleWindow"] = str(self.sampleWindow)
        setting["bandSetting"] = copy.deepcopy(self.bandSetting)
        setting["bandStatistics"] = self.bandStatistics
        setting["bandBufferDecrease"] = self.bandBufferDecrease
        setting["bandBufferDecreaseSpeedInit"] = self.bandBufferDecreaseSpeedInit
        setting["bandBufferDecreaseSpeedAccl"] = self.bandBufferDecreaseSpeedAccl
        setting["audioProfile"] = self.audioProfile
        setting["unifiedProfile"] = self.unifiedProfile
        setting["amplitudeEnable"] = self.amplitudeEnable
        setting["amplitudeClip"] = self.amplitudeClip
        # record data
        setting["recordData"] = copy.deepcopy(self.recordData)
        # done
        return setting

    def importSetting(self, setting):
        from vnactor import str_2_enum
        self.cleanup()
        # copy from setting
        try:
            self.enable = setting["enable"]
            self.audioSeeker = setting["audioSeeker"]
            self.audioSeekerOpt = setting["audioSeekerOpt"]
            self.sampleCount = setting["sampleCount"]
            self.sampleChannel = setting["sampleChannel"]
            self.sampleWindow = str_2_enum(FFTWindow, setting["sampleWindow"])
            self.bandSetting = copy.deepcopy(setting["bandSetting"])
            self.bandStatistics = setting["bandStatistics"]
            self.bandBufferDecrease = setting["bandBufferDecrease"]
            self.bandBufferDecreaseSpeedInit = setting["bandBufferDecreaseSpeedInit"]
            self.bandBufferDecreaseSpeedAccl = setting["bandBufferDecreaseSpeedAccl"]
            self.audioProfile = setting["audioProfile"]
            self.unifiedProfile = setting["unifiedProfile"]
            self.amplitudeEnable = setting["amplitudeEnable"]
            self.amplitudeClip = setting["amplitudeClip"]

            # record data
            if "recordData" in setting:
                self.recordData = copy.deepcopy(setting["recordData"])

            return None
        except Exception as e:
            traceback.print_exc()
            return "Failed import sound spectrum: " + str(e)            

    def copySetting(self, src):
        # copy from source
        self.enable = src.enable
        self.audioSeeker = src.audioSeeker
        self.audioSeekerOpt = src.audioSeekerOpt
        self.sampleCount = src.sampleCount
        self.sampleChannel = src.sampleChannel
        self.sampleWindow = src.sampleWindow
        self.bandSetting = copy.deepcopy(src.bandSetting)
        self.bandStatistics = src.bandStatistics
        self.bandBufferDecrease = src.bandBufferDecrease
        self.bandBufferDecreaseSpeedInit = src.bandBufferDecreaseSpeedInit
        self.bandBufferDecreaseSpeedAccl = src.bandBufferDecreaseSpeedAccl
        self.audioProfile = src.audioProfile
        self.unifiedProfile = src.unifiedProfile
        self.amplitudeEnable = src.amplitudeEnable
        self.amplitudeClip = src.amplitudeClip
        self.recordData = copy.deepcopy(src.recordData)

    def initialize(self, game):
        # initialize runtime
        def getTargetClip(clipname):
            if clipname and clipname in game.gdata.kfaManagedClips:
                return game.gdata.kfaManagedClips[clipname]
            else:
                return None

        from vnanime import check_keyframe_anime, init_keyframe_anime
        if not check_keyframe_anime(game):
            init_keyframe_anime(game)

        # audio source
        self.searchAudioSource(game)

        # data
        self.sampleStereo = self.sampleChannel == "stereo"
        self.samples = Array[Single]([0.0] * self.sampleCount)
        if self.sampleStereo:
            self.samples2 = Array[Single]([0.0] * self.sampleCount)
        else:
            self.sampleChannelIdx = 0 if self.sampleChannel == "left" else 1
        self.freqBand = [0.0] * self.bandCount
        self.bandBuffer = [0.0] * self.bandCount
        self.bufferDecrease = [self.bandBufferDecreaseSpeedInit] * self.bandCount
        self.freqBandHighest = [self.audioProfile] * self.bandCount
        self.freqBandHighestAll = self.audioProfile
        self.audioBandBuffer = [0.0] * self.bandCount
        self.amplitude = 0.0
        self.amplitudeHighest = self.audioProfile
        self.isRecording = False

        # target clips
        self.bandTargetClip = [getTargetClip(bs[3]) for bs in self.bandSetting]
        self.amplitudeTargetClip = getTargetClip(self.amplitudeClip)

    def cleanup(self):
        self.audioSource = None

        if self.isRecorded:
            self.recordData["isPlaying"] = False
            self.recordData["time"] = 0

        if hasattr(self, "freqBand") and True:
            self.freqBand = [0.0] * self.bandCount
            self.bandBuffer = [0.0] * self.bandCount
            self.bufferDecrease = [self.bandBufferDecreaseSpeedInit] * self.bandCount
            self.freqBandHighest = [self.audioProfile] * self.bandCount
            self.freqBandHighestAll = self.audioProfile
            self.audioBandBuffer = [0.0] * self.bandCount
            self.amplitude = 0.0
            self.amplitudeHighest = self.audioProfile
        
        if hasattr(self, "bandTargetClip"):
            for clp in self.bandTargetClip:
                if clp != None:
                    clp.seek(0)

        if hasattr(self, "amplitudeTargetClip") and self.amplitudeTargetClip != None:
            self.amplitudeTargetClip.seek(0)

    def searchAudioSource(self, game):
        def searchActive(any):
            # in mmdd 
            if hasattr(game, "gdata") and hasattr(game.gdata, "mmdd"):
                for sc in game.gdata.mmdd.soundControllers:
                    if sc.soundSource.isPlaying:
                        return sc.soundSource.audioSource, sc.FileName
            # in vnsnd
            mgr = get_vnsound_manager(game)
            for sid in mgr.soundAlias:
                ss = mgr.soundDict[sid]
                if mgr.soundDict[sid].isPlaying:
                    return ss.audioSource, ss.audioTitle
            # any
            if any:
                from UnityEngine import Object, AudioSource
                ass = Object.FindObjectsOfType[AudioSource]()
                if len(ass) > 0:
                    return ass[0], ass[0].name
            # Not found
            return None, None

        def searchMMDD(index):
            if not hasattr(game, "gdata") or not hasattr(game.gdata, "mmdd"):
                return None, None
            mmdd = game.gdata.mmdd
            if len(mmdd.soundControllers) == 0:
                return None, None
            index = int(index)
            if 0 <= index < len(mmdd.soundControllers):
                sc = mmdd.soundControllers[index]
                return sc.soundSource.audioSource, sc.FileName
            else:
                return None, None

        def searchVNSound(idOrIndex):
            mgr = get_vnsound_manager(game)
            if idOrIndex in mgr.soundAlias:
                ss = mgr.soundDict[idOrIndex]
                return ss.audioSource, ss.audioTitle
            elif idOrIndex.isnumeric():
                idx = int(idOrIndex)
                if 0 <= idx < mgr.SoundCount:
                    ss = mgr.soundDict[mgr.soundAlias[idx]]
                    return ss.audioSource, ss.audioTitle
                else:
                    return None, None
            else:
                return None, None
        
        try:
            if self.audioSeeker == "actived":
                self.audioSource, self.audioTitle = searchActive(self.audioSeekerOpt == "any")
            elif self.audioSeeker == "mmdd":
                self.audioSource, self.audioTitle = searchMMDD(self.audioSeekerOpt)
            elif self.audioSeeker.startswith("vnsnd"):
                self.audioSource, self.audioTitle = searchVNSound(self.audioSeekerOpt)
            else:
                print("Invalid audio seeker define: " + self.audioSeeker + " option:" + self.audioSeekerOpt)
                return
        except:
            traceback.print_exc()

    def attachAudioSource(self, audioSource, title):
        self.audioSource = audioSource
        self.audioTitle = title

    def startRecording(self):
        if not self.audioSource or self.audioSource.clip.length == 0:
            return 0
        # create temp data
        self.tempTimestamp = []
        self.tempRecordData = {}
        for i in range(self.bandCount):
            if self.bandTargetClip[i]:
                self.tempRecordData[i] = []
        if self.amplitudeEnable and self.amplitudeTargetClip:
            self.tempRecordData["amp"] = []
        # mark start
        res = len(self.tempRecordData)
        if res:
            self.isRecording = True
        return res

    def saveRecording(self, options):
        if hasattr(self, "tempRecordData") and hasattr(self, "tempTimestamp"):
            try:
                # init data
                recordData = {}
                recordData["isPlaying"] = False
                recordData["time"] = 0
                for k in options:
                    recordData[k] = options[k]
                for k in self.tempRecordData.keys():
                    recordData[k] = [self.tempRecordData[k][0]]
                recordData["recordKeys"] = sorted(self.tempRecordData.keys())
                # re-sample
                interval = options["interval"]
                targetTime = interval
                totalWrnCount = 0
                for i in range(len(self.tempTimestamp) - 1):
                    wrnCount = 0
                    while self.tempTimestamp[i] <= targetTime < self.tempTimestamp[i+1]:
                        if wrnCount > 0:
                            #print("  timestamp %.5f: (%.5f, %.5f) LOW-FRAMERATE WARNING!"%(targetTime, self.tempTimestamp[i], self.tempTimestamp[i+1]))
                            totalWrnCount += 1
                        dt = (self.tempTimestamp[i+1] - targetTime) / (self.tempTimestamp[i+1] - self.tempTimestamp[i])
                        for k in self.tempRecordData.keys():
                            dk = (self.tempRecordData[k][i+1] - self.tempRecordData[k][i]) * dt + self.tempRecordData[k][i]
                            recordData[k].append(dk)
                        targetTime += interval
                        wrnCount += 1
                recordData["dataCount"] = len(recordData[self.tempRecordData.keys()[0]])
                self.recordData = recordData
                # remove temp
                del self.tempTimestamp
                del self.tempRecordData
                # done
                print("Data recorded, count: %d, interval: %.4f, Low performance warning count: %d"%(recordData["dataCount"], interval, totalWrnCount))
            except:
                traceback.print_exc()
                self.recordData = None
        else:
            self.recordData = None
        # mark done
        self.isRecording = False

    def abortRecording(self):
        self.recordData = None
        self.isRecording = False
        if hasattr(self, "tempRecordData") and hasattr(self, "tempTimestamp"):
            del self.tempTimestamp
            del self.tempRecordData

    def Play(self):
        if self.isRecorded and not self.isPlaying:
            self.recordData["isPlaying"] = True

    def Pause(self):
        if self.isRecorded and self.isPlaying:
            self.recordData["isPlaying"] = False

    def Stop(self):
        if self.isRecorded:
            self.cleanup()

    def Seek(self, time):
        if self.isRecorded:
            self.recordData["time"] = time

    def Rewind(self):
        if self.isRecorded and not self.isPlaying:
            self.recordData["time"] = 0

    def searchAutoStart(self, game):
        if self.isRecorded:
            try:
                if self.recordData["syncStart"] == "None":
                    return
                elif self.recordData["syncStart"] == "MMDD":
                    if hasattr(game.gdata, "mmdd"):
                        if self.recordData["isPlaying"] == False and game.gdata.mmdd.isPlaying == True:
                            self.recordData["time"] = game.gdata.mmdd.curFrame / 30.0
                            self.recordData["isPlaying"] = True
                        elif self.recordData["isPlaying"] == True and game.gdata.mmdd.isPlaying == False:
                            self.Stop()
                elif self.recordData["syncStart"].startswith("Clip#"):
                    tgtClip = self.recordData["syncStart"][5:]
                    if hasattr(game.gdata, "kfaManagedClips") and tgtClip in game.gdata.kfaManagedClips:
                        if self.recordData["isPlaying"] == False and game.gdata.kfaManagedClips[tgtClip].isPlaying == True:
                            self.recordData["time"] = game.gdata.kfaManagedClips[tgtClip].curTime
                            self.recordData["isPlaying"] = True
                        elif self.recordData["isPlaying"] == True and game.gdata.kfaManagedClips[tgtClip].isPlaying == False:
                            self.Stop()
                else:
                    raise Exception("Unexpected auto start mode: " + self.recordData["syncStart"])
            except:
                traceback.print_exc()
                self.recordData["syncStart"] = "None"

    def update(self, game):
        if self.isRecorded:
            self.update_record(game)
        else:
            self.update_realtime(game)

    def update_record(self, game):
        from UnityEngine import Time
        try:
            if self.isPlaying:
                deltaTime = Time.deltaTime
                ntime = self.recordData["time"] + deltaTime
                if ntime > self.length:
                    ntime = self.length
                    self.cleanup()
                else:
                    # get recorded value
                    pIdx = int(ntime / self.recordData["interval"])
                    if pIdx < self.recordData["dataCount"] - 1:
                        pRate = (ntime - pIdx * self.recordData["interval"]) / self.recordData["interval"]
                    else:
                        pIdx = -1
                        pRate = 0
                    #print(" record specturm playing: dt=%f, ntime=%f, pIdx=%d, pRate=%f"%(deltaTime, ntime, pIdx, pRate))

                    # drive linked clip by recorded value
                    for k in self.recordData["recordKeys"]:
                        rv = self.recordData[k][pIdx]
                        if pIdx != -1:
                            rvn = self.recordData[k][pIdx+1]
                            rv += (rvn - rv) * pRate
                        if k != "amp":
                            self.bandTargetClip[k].seekNormalized(rv)
                        else:
                            self.amplitudeTargetClip.seekNormalized(rv)

                    # update time
                    self.recordData["time"] = ntime
        except:
            traceback.print_exc()
            print("Spectrum record update failed, clean up!")
            self.cleanup()

    def update_realtime(self, game):
        try:
            if self.enable and self.audioSource != None and self.audioSource.isPlaying:
                if self.isRecording:
                    self.tempTimestamp.append(self.audioSource.time)
                
                if self.sampleStereo:
                    self.audioSource.GetSpectrumData(self.samples, 0, self.sampleWindow)
                    self.audioSource.GetSpectrumData(self.samples2, 1, self.sampleWindow)
                else:
                    self.audioSource.GetSpectrumData(self.samples, self.sampleChannelIdx, self.sampleWindow)
                
                # sample to frequency band
                self.amplitude = 0.0
                for i in range(self.bandCount):
                    # band data
                    self.freqBand[i] = 0.0
                    for j in range(self.bandSetting[i][1], self.bandSetting[i][2]):
                        if self.bandStatistics == "sum" or self.bandStatistics == "avg":
                            self.freqBand[i] += self.samples[j]
                            if self.sampleStereo:
                                self.freqBand[i] += self.samples2[j]
                        else:
                            self.freqBand[i] += self.samples[j] * self.samples[j]
                            if self.sampleStereo:
                                self.freqBand[i] += self.samples2[j] * self.samples2[j]
                    if self.bandStatistics == "avg" or self.bandStatistics == "rms":
                        self.freqBand[i] /= self.bandSetting[i][2] - self.bandSetting[i][1]
                    if self.bandStatistics == "rss" or self.bandStatistics == "rms":
                        self.freqBand[i] = Mathf.Sqrt(self.freqBand[i])
                    self.freqBand[i] *= 10

                    # auto decrease
                    if self.bandBufferDecrease:
                        if self.freqBand[i] > self.bandBuffer[i]:
                            self.bandBuffer[i] = self.freqBand[i]
                            self.bufferDecrease[i] = self.bandBufferDecreaseSpeedInit
                        else:
                            self.bandBuffer[i] -= self.bufferDecrease[i] * Time.deltaTime
                            if self.bandBuffer[i] < 0:
                                self.bandBuffer[i] = 0
                            self.bufferDecrease[i] *= 1 + self.bandBufferDecreaseSpeedAccl * Time.deltaTime
                    else:
                        self.bandBuffer[i] = self.freqBand[i]

                    # normalize band buffer
                    if self.freqBand[i] > self.freqBandHighestAll:  # unitied
                        self.freqBandHighestAll = self.freqBand[i]
                    if self.freqBand[i] > self.freqBandHighest[i]:  # seperated
                        self.freqBandHighest[i] = self.freqBand[i]
                    if self.unifiedProfile:
                        self.audioBandBuffer[i] = self.bandBuffer[i] / self.freqBandHighestAll
                    else:
                        self.audioBandBuffer[i] = self.bandBuffer[i] / self.freqBandHighest[i]

                    # add to amplitude
                    self.amplitude += self.audioBandBuffer[i]

                    # drive linked clip
                    if self.bandTargetClip[i]:
                        if self.isRecording:
                            self.tempRecordData[i].append(self.audioBandBuffer[i])
                        else:
                            self.bandTargetClip[i].seekNormalized(self.audioBandBuffer[i])

                # amplitude
                if self.amplitudeEnable:
                    if self.amplitude > self.amplitudeHighest:
                        self.amplitudeHighest = self.amplitude
                    self.amplitude /= self.amplitudeHighest
                    
                    # drive amplitude clip
                    if self.amplitudeTargetClip:
                        if self.isRecording:
                            self.tempRecordData["amp"].append(self.amplitude)
                        else:
                            self.amplitudeTargetClip.seekNormalized(self.amplitude)

            else:
                if self.audioSeeker == "actived":
                    self.cleanup()
            
        except:
            traceback.print_exc()
            print("Spectrum realtime update failed, clean up!")
            self.enable = False
            self.cleanup()

# ====================================  Manager Class ====================================
class SoundManager():

    def __init__(self, game):
        self.game = game
        self.soundAlias = []
        self.soundDict = {}
        
        self.saveMode = "abspath"
        self.openUseAPI = True

        self.spectrumSetting = []

        from extplugins import ILLAPI
        self.illApi = ILLAPI()
        if not self.illApi.isDetected:
            self.openUseAPI = False

    @property
    def SoundCount(self):
        return len(self.soundAlias)

    def LoadSoundSource(self, alias, filepath, sndType):
        alias = alias.strip()
        if len(alias) == 0:
            return "Empty alias is invalid"
        if alias in self.soundDict:
            return "Alias '%s' was used by other sound"%alias
        if not path.isfile(filepath):
            return "File '%s' not found"%filepath
        if sndType not in SoundSource.SOUND_SOURCE_TYPE:
            return "Invalid sound type '%s'"%sndType 

        newSS = SoundSource()
        err = newSS.CreateAudioSource(sndType)
        if err:
            return err
        err = newSS.LoadAudioFile(filepath)
        if err:
            return err
        
        # Done, register
        self.AppendSoundSource(alias, newSS)
        return None

    def AppendSoundSource(self, alias, newSS, autoRename=False):
        if alias in self.soundDict:
            if autoRename:
                n = 1
                while "%s_%d"%(alias, n) in self.soundDict:
                    n += 1
                alias = "%s_%d"%(alias, n)
            else:
                return None
        self.soundAlias.append(alias)
        self.soundDict[alias] = newSS
        return alias

    def RemoveSoundSource(self, alias):
        if alias not in self.soundDict:
            return
        
        ss = self.soundDict[alias]
        ss.Cleanup()

        self.soundAlias.remove(alias)
        del self.soundDict[alias]
        self.UpdateSoundSourceTrigger()

    def UpdateSoundSourceTrigger(self):
        # remove trigger update if not needed anymore
        needTrg = [self.soundDict[a].triggerId != None for a in self.soundAlias]
        if any(needTrg):
            vnsnd_init_soundtrg_hook(self.game)
        else:
            vnsnd_cleanup_soundtrg_hook(self.game)

    def AppendSoundSpectrum(self, spSetting):
        installHook = len(self.spectrumSetting) == 0
        self.spectrumSetting.append(spSetting)
        if installHook:
            vnsnd_init_spectrum_hook(self.game)

    def RemoveSoundSpectrum(self, spSetting):
        self.spectrumSetting.remove(spSetting)
        if len(self.spectrumSetting) == 0:
            vnsnd_cleanup_spectrum_hook(self.game)

    def exportSetting(self):
        setting = {}
        extData = {}
        # General
        setting["General"] = {}
        setting["General"]["saveMode"] = self.saveMode
        # setting for every sound
        setting["Sound"] = {}
        for alias in self.soundAlias:
            setting["Sound"][alias], extData[alias] = self.soundDict[alias].exportSetting(self.saveMode)
        # setting for every spectrum, from v2
        setting["Spectrum"] = []
        for sps in self.spectrumSetting:
            setting["Spectrum"].append(sps.exportSetting())
        return setting, extData

    def importSetting(self, setting, extData, append=False):
        try:
            # clear
            if not append:
                self.Cleanup()
            
            # General
            self.saveMode = setting["General"]["saveMode"]

            # Sound
            for alias in setting["Sound"]:
                ext = extData[alias]
                newSS = SoundSource()
                err = newSS.importSetting(setting["Sound"][alias], ext)
                if err:
                    msg = "Fail to load sound source '%s': %s"%(alias, err)
                    self.game.logger_log_error(msg, True)
                else:
                    ra = self.AppendSoundSource(alias, newSS, append)
                    if ra != alias:
                        msg = "Sound alias for '%s' changed from '%s' to '%s'"%(newSS.audioTitle, alias, ra)
                        self.game.logger_log_message(msg)

            # update trigger
            self.UpdateSoundSourceTrigger()

            # spectrum from v2
            if setting.has_key("Spectrum"):
                for sps in setting["Spectrum"]:
                    newSP = SoundSpectrum()
                    err = newSP.importSetting(sps)
                    if err:
                        msg = "Fail to load spectrum: %s"%(err)
                        self.game.logger_log_error(msg, True)
                    else:
                        newSP.initialize(self.game)
                        self.AppendSoundSpectrum(newSP)

            # Done
            return None
        except Exception as e:
            traceback.print_exc()
            return "Fail to load vnsound data: " + str(e)

    def getValidSaveModeInfo(self):
        validModeInfo = {
            "abspath": [],
            "relpath": [],
            "filedata": [],
            "clipdata": [],
        }
        for alias in self.soundAlias:
            vsm = self.soundDict[alias].validSaveMode()
            if "abspath" in vsm:
                validModeInfo["abspath"].append(alias)
            if "relpath" in vsm:
                validModeInfo["relpath"].append(alias)
            if "filedata" in vsm:
                validModeInfo["filedata"].append(alias)
            if "clipdata" in vsm:
                validModeInfo["clipdata"].append(alias)
        if self.game.isStudioNEO:
            validModeInfo["clipdata"] = []  # clipdata not supported in StudioNeo of HS1
        return validModeInfo

    def ExportStatus(self):
        status = {}
        for alias in self.soundDict:
            status[alias] = self.soundDict[alias].ExportStatus()
        return status

    def ImportStatus(self, status):
        for skey in status.keys():
            if skey in self.soundDict:
                self.soundDict[skey].ImportStatus(status[skey])

    def CleanGarbage(self):
        gb = set()
        for alias in self.soundDict:
            if not self.soundDict[alias].isLoaded:
                gb.add(alias)
        for alias in gb:
            self.RemoveSoundSource(alias)

    def Cleanup(self):
        try:
            for alias in self.soundDict:
                self.soundDict[alias].Cleanup()
            self.soundAlias = []
            self.soundDict.clear()
            self.spectrumSetting = []
            vnsnd_cleanup_spectrum_hook(self.game)
            vnsnd_cleanup_soundtrg_hook(self.game)
        except:
            traceback.print_exc()
