using FMOD.Studio; using System.IO; using System.Linq; using UnityEngine; public abstract class NPCController : MonoBehaviour { protected Transform playerTransform; [Header("Voiceline Amplitude Timeline Config")] public string voicelinesFolder = "CharacterVoicelines"; public string characterSpecificFolder; public string[] voiceLineKeys; [Header("Mouth Transform")] public Transform mouth; // assign your billboard mouth object [Header("Mouth Animation Settings")] public float minScaleY = 0.3f; public float maxScaleY = 1.0f; public float gain = 30f; // multiply RMS to make mouth open larger public float attack = 0.6f; // faster opening public float release = 0.2f; // slower closing public bool inverted = false; private float[] rmsCurve; private EventInstance currentVoicelineEvent; private bool isSpeaking; private float smoothed; // If you change RMS window in Python, update this private const float FRAME_DURATION = 0.02f; private float sampleRate = 1f / FRAME_DURATION; // RMS samples per second (20ms windows) // Start is called before the first frame update void Awake() { } void Start() { } // Update is called once per frame void Update() { if (playerTransform != null) { // As if player is on same Y coordinate as this object, to not rotate around Y axis. Vector3 lookTargetPos = new Vector3(playerTransform.position.x, transform.position.y, playerTransform.position.z); transform.LookAt(lookTargetPos); } if (isSpeaking && rmsCurve != null && currentVoicelineEvent.isValid()) { AnimateMouth(); } } private void AnimateMouth() { // get FMOD timeline position currentVoicelineEvent.getTimelinePosition(out int ms); float time = ms / 1000f; // find sample index int index = Mathf.FloorToInt(time * sampleRate); index = Mathf.Clamp(index, 0, rmsCurve.Length - 1); float amp = rmsCurve[index] * gain; // Normal mapping: louder -> larger scale float mapped = Mathf.Clamp(minScaleY + amp, minScaleY, maxScaleY); // Inverted mapping: louder -> smaller scale // Achieve this by mirroring within [min, max]: // invertedTarget = min + (max - (min + amp)) = min + (max - min) - amp // equivalently: float invertedTarget = Mathf.Clamp(minScaleY + (maxScaleY - minScaleY) - (mapped - minScaleY), minScaleY, maxScaleY); float targetY = inverted ? invertedTarget : mapped; // attack/release smoothing if (targetY > smoothed) smoothed = Mathf.Lerp(smoothed, targetY, attack); else smoothed = Mathf.Lerp(smoothed, targetY, release); // apply mouth scale Vector3 s = mouth.localScale; s.y = smoothed; Debug.Log("mouth scale: " + smoothed); mouth.localScale = s; } private void StopSpeaking() { isSpeaking = false; smoothed = inverted ? maxScaleY : minScaleY; if (mouth != null) { var scale = mouth.localScale; scale.y = smoothed; mouth.localScale = scale; } Debug.Log("mouth scale stopped: " + smoothed); currentVoicelineEvent.release(); } private void OnTriggerEnter(Collider other) { if (other.gameObject.tag == "Player Head") { playerTransform = other.transform; OnPlayerApproach(); } } protected virtual void OnPlayerApproach() {} private void OnTriggerExit(Collider other) { if (other.gameObject.tag == "Player Head") { playerTransform = null; OnPlayerLeave(); } } protected virtual void OnPlayerLeave() {} public void SpeakVoiceLine(int voiceLineId) { if (voiceLineId < 0 || voiceLineId >= voiceLineKeys.Length) { Debug.LogError("Invalid voiceLineId!"); return; } string key = voiceLineKeys[voiceLineId]; LoadCurve(key); // load RMS data currentVoicelineEvent = AudioManager.Instance.PlayDialogue(characterSpecificFolder + "/" + key, gameObject); if (!currentVoicelineEvent.isValid()) { Debug.LogError("Failed to start dialogue event."); return; } isSpeaking = true; // Stop mouth on end float voicelineDuration = rmsCurve.Length * FRAME_DURATION; Invoke(nameof(StopSpeaking), voicelineDuration + 0.1f); } // --------------------------- // Load RMS Timeline (.txt) // --------------------------- private void LoadCurve(string key) { string folderPath = Path.Combine(Application.streamingAssetsPath, voicelinesFolder, characterSpecificFolder); string filePath = Path.Combine(folderPath, key + ".txt"); if (!File.Exists(filePath)) { Debug.LogError("Missing RMS timeline file: " + filePath); rmsCurve = null; return; } var lines = File.ReadAllLines(filePath); Debug.Log("read lines: " + lines.Length); rmsCurve = lines.Select(l => float.Parse(l, System.Globalization.CultureInfo.InvariantCulture)).ToArray(); } }