feat: 音频参数保存功能

This commit is contained in:
2026-07-28 01:12:22 +08:00
parent 2902c554a6
commit 22a0d7377d
8 changed files with 455 additions and 13 deletions
@@ -0,0 +1,260 @@
using System.Collections.Generic;
using System.Linq;
using AibisDream.Kit;
using AibisDream.SaveSystem;
using FMOD;
using FMOD.Studio;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
namespace AibisDream.SystemEditor.Tests
{
public sealed class AudioGlobalParameterSnapshotTests
{
[Test]
public void SetFromYarn_TracksOnlySuccessfulUniqueNames()
{
var api = new FakeFmodGlobalParameterApi();
api.SetByNameResults["missing"] = RESULT.ERR_EVENT_NOTFOUND;
var registry = new YarnGlobalParameterRegistry(api);
Assert.That(registry.SetFromYarn("reverb", 0.4f), Is.EqualTo(RESULT.OK));
Assert.That(registry.SetFromYarn("REVERB", 0.7f), Is.EqualTo(RESULT.OK));
Assert.That(
registry.SetFromYarn("missing", 1f),
Is.EqualTo(RESULT.ERR_EVENT_NOTFOUND));
Assert.That(registry.TrackedNames, Has.Count.EqualTo(1));
Assert.That(registry.TrackedNames.Single(), Is.EqualTo("reverb"));
Assert.That(api.SetByNameCalls[0].IgnoreSeekSpeed, Is.False);
Assert.That(api.SetByNameCalls[1].IgnoreSeekSpeed, Is.False);
}
[Test]
public void CaptureFinalValues_ReadsFmodFinalValueAndRetainsFailuresForRetry()
{
var api = new FakeFmodGlobalParameterApi();
api.Reads["stage"] = new ParameterRead(9f, 2.37f, RESULT.OK);
api.Reads["missing"] = new ParameterRead(0f, 0f, RESULT.ERR_EVENT_NOTFOUND);
var registry = new YarnGlobalParameterRegistry(api);
registry.SetFromYarn("stage", 9f);
registry.SetFromYarn("missing", 1f);
var warnings = new List<string>();
var snapshot = registry.CaptureFinalValues(warnings.Add);
Assert.That(snapshot, Has.Count.EqualTo(1));
Assert.That(snapshot["stage"], Is.EqualTo(2.37f));
Assert.That(snapshot["stage"], Is.Not.EqualTo(9f));
Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "stage", "missing" }));
Assert.That(warnings, Has.Count.EqualTo(1));
StringAssert.Contains("missing", warnings[0]);
}
[Test]
public void ResetToDefaults_RemovesOnlyParametersThatFmodResetSuccessfully()
{
var goodId = new PARAMETER_ID { data1 = 1, data2 = 2 };
var failedId = new PARAMETER_ID { data1 = 3, data2 = 4 };
var api = new FakeFmodGlobalParameterApi();
api.Descriptions["good"] = new ParameterDescriptionRead(
new PARAMETER_DESCRIPTION { id = goodId, defaultvalue = 0.25f },
RESULT.OK);
api.Descriptions["failed"] = new ParameterDescriptionRead(
new PARAMETER_DESCRIPTION { id = failedId, defaultvalue = 0.75f },
RESULT.OK);
api.SetByIdResults[failedId] = RESULT.ERR_INVALID_PARAM;
var registry = new YarnGlobalParameterRegistry(api);
registry.SetFromYarn("good", 1f);
registry.SetFromYarn("failed", 1f);
var warnings = new List<string>();
registry.ResetToDefaults(warnings.Add);
Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "failed" }));
Assert.That(api.SetByIdCalls, Has.Count.EqualTo(2));
var goodReset = api.SetByIdCalls.Single(call => call.Id.Equals(goodId));
Assert.That(goodReset.Value, Is.EqualTo(0.25f));
Assert.That(goodReset.IgnoreSeekSpeed, Is.True);
Assert.That(warnings, Has.Count.EqualTo(1));
StringAssert.Contains("failed", warnings[0]);
}
[Test]
public void RestoreFinalValues_JumpsImmediatelyThenNextYarnTargetUsesSeekSpeed()
{
var api = new FakeFmodGlobalParameterApi();
api.SetByNameResults["missing"] = RESULT.ERR_EVENT_NOTFOUND;
var registry = new YarnGlobalParameterRegistry(api);
var warnings = new List<string>();
registry.RestoreFinalValues(
new Dictionary<string, float>
{
["stage"] = 2.37f,
["missing"] = 1f
},
warnings.Add);
registry.SetFromYarn("stage", 3f);
Assert.That(api.SetByNameCalls, Has.Count.EqualTo(3));
Assert.That(api.SetByNameCalls[0].Value, Is.EqualTo(2.37f));
Assert.That(api.SetByNameCalls[0].IgnoreSeekSpeed, Is.True);
Assert.That(api.SetByNameCalls[1].Name, Is.EqualTo("missing"));
Assert.That(api.SetByNameCalls[1].IgnoreSeekSpeed, Is.True);
Assert.That(api.SetByNameCalls[2].Value, Is.EqualTo(3f));
Assert.That(api.SetByNameCalls[2].IgnoreSeekSpeed, Is.False);
Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "stage" }));
Assert.That(warnings, Has.Count.EqualTo(1));
StringAssert.Contains("missing", warnings[0]);
}
[Test]
public void AudioSnapshotDto_RoundTripsFinalValuesWithoutChangingSchema()
{
var source = new SaveSnapshot();
source.sections[SnapshotProviderIds.Audio] = new AudioSnapshotDto
{
ambState = "Dream",
yarnGlobalParameters = new Dictionary<string, float>
{
["reverb"] = 0.42f,
["hs1LogStage"] = 2.37f
}
};
var json = JsonConvert.SerializeObject(source);
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(json);
var audio = ((JObject)restored.sections[SnapshotProviderIds.Audio])
.ToObject<AudioSnapshotDto>();
Assert.That(restored.schemaVersion, Is.EqualTo(2));
Assert.That(SaveSnapshotSchema.CurrentVersion, Is.EqualTo(2));
Assert.That(audio.yarnGlobalParameters["reverb"], Is.EqualTo(0.42f));
Assert.That(audio.yarnGlobalParameters["hs1LogStage"], Is.EqualTo(2.37f));
}
private readonly struct SetByNameCall
{
internal SetByNameCall(string name, float value, bool ignoreSeekSpeed)
{
Name = name;
Value = value;
IgnoreSeekSpeed = ignoreSeekSpeed;
}
internal string Name { get; }
internal float Value { get; }
internal bool IgnoreSeekSpeed { get; }
}
private readonly struct SetByIdCall
{
internal SetByIdCall(PARAMETER_ID id, float value, bool ignoreSeekSpeed)
{
Id = id;
Value = value;
IgnoreSeekSpeed = ignoreSeekSpeed;
}
internal PARAMETER_ID Id { get; }
internal float Value { get; }
internal bool IgnoreSeekSpeed { get; }
}
private readonly struct ParameterRead
{
internal ParameterRead(float value, float finalValue, RESULT result)
{
Value = value;
FinalValue = finalValue;
Result = result;
}
internal float Value { get; }
internal float FinalValue { get; }
internal RESULT Result { get; }
}
private readonly struct ParameterDescriptionRead
{
internal ParameterDescriptionRead(
PARAMETER_DESCRIPTION description,
RESULT result)
{
Description = description;
Result = result;
}
internal PARAMETER_DESCRIPTION Description { get; }
internal RESULT Result { get; }
}
private sealed class FakeFmodGlobalParameterApi : IFmodGlobalParameterApi
{
internal readonly Dictionary<string, RESULT> SetByNameResults =
new(System.StringComparer.OrdinalIgnoreCase);
internal readonly Dictionary<string, ParameterRead> Reads =
new(System.StringComparer.OrdinalIgnoreCase);
internal readonly Dictionary<string, ParameterDescriptionRead> Descriptions =
new(System.StringComparer.OrdinalIgnoreCase);
internal readonly Dictionary<PARAMETER_ID, RESULT> SetByIdResults = new();
internal readonly List<SetByNameCall> SetByNameCalls = new();
internal readonly List<SetByIdCall> SetByIdCalls = new();
public RESULT SetParameterByName(
string name,
float value,
bool ignoreSeekSpeed)
{
SetByNameCalls.Add(new SetByNameCall(name, value, ignoreSeekSpeed));
return SetByNameResults.TryGetValue(name, out var result)
? result
: RESULT.OK;
}
public RESULT GetParameterByName(
string name,
out float value,
out float finalValue)
{
if (Reads.TryGetValue(name, out var read))
{
value = read.Value;
finalValue = read.FinalValue;
return read.Result;
}
value = 0f;
finalValue = 0f;
return RESULT.OK;
}
public RESULT GetParameterDescriptionByName(
string name,
out PARAMETER_DESCRIPTION description)
{
if (Descriptions.TryGetValue(name, out var read))
{
description = read.Description;
return read.Result;
}
description = default;
return RESULT.ERR_EVENT_NOTFOUND;
}
public RESULT SetParameterById(
PARAMETER_ID id,
float value,
bool ignoreSeekSpeed)
{
SetByIdCalls.Add(new SetByIdCall(id, value, ignoreSeekSpeed));
return SetByIdResults.TryGetValue(id, out var result)
? result
: RESULT.OK;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e120d9cfce5a4723a6e640b3cc3ce29b
@@ -42,6 +42,7 @@ namespace AibisDream.Kit
private EventInstance _voiceInstance;
private readonly Dictionary<string, EventInstance> _musicDict = new();
private readonly Dictionary<string, EventInstance> _sfxDict = new();
private YarnGlobalParameterRegistry _yarnGlobalParameters;
#endregion
@@ -52,6 +53,9 @@ namespace AibisDream.Kit
private AmbState? _ambStateBeforePauseMenu;
public AmbState CurrentAmbState => _currentAmbState;
private YarnGlobalParameterRegistry YarnGlobalParameters =>
_yarnGlobalParameters ??= new YarnGlobalParameterRegistry(
new RuntimeFmodGlobalParameterApi());
#endregion
@@ -124,7 +128,8 @@ namespace AibisDream.Kit
var dto = new AudioSnapshotDto
{
ambState = ambState.ToString()
ambState = ambState.ToString(),
yarnGlobalParameters = YarnGlobalParameters.CaptureFinalValues(Debug.LogWarning)
};
foreach (var pair in _musicDict)
@@ -142,21 +147,34 @@ namespace AibisDream.Kit
return dto;
}
public void RestoreSnapshot(AudioSnapshotDto data)
public void RestoreSnapshot(AudioSnapshotDto data, Action<string> warn = null)
{
warn ??= Debug.LogWarning;
YarnGlobalParameters.ResetToDefaults(warn);
foreach (var musicEvent in _musicDict.Values)
{
musicEvent.stop(STOP_MODE.IMMEDIATE);
musicEvent.release();
}
foreach (var sfxEvent in _sfxDict.Values)
{
sfxEvent.stop(STOP_MODE.IMMEDIATE);
sfxEvent.release();
}
_musicDict.Clear();
_sfxDict.Clear();
if (!string.IsNullOrEmpty(data.ambState)
&& Enum.TryParse<AmbState>(data.ambState, out var ambState))
{
ChangeAmb(ambState);
}
YarnGlobalParameters.RestoreFinalValues(data.yarnGlobalParameters, warn);
if (data.musicPaths != null)
{
foreach (var music in data.musicPaths)
@@ -176,13 +194,6 @@ namespace AibisDream.Kit
_sfxDict[sfx.Key] = instance;
}
}
if (!string.IsNullOrEmpty(data.ambState)
&& Enum.TryParse<AmbState>(data.ambState, out var ambState))
{
ChangeAmb(ambState);
}
}
public void PauseAll()
@@ -250,6 +261,16 @@ namespace AibisDream.Kit
RuntimeManager.StudioSystem.setParameterByName(paramName, value);
}
public void SetYarnGlobalParam(string paramName, float value)
{
var result = YarnGlobalParameters.SetFromYarn(paramName, value);
if (result != RESULT.OK)
{
Debug.LogWarning(
$"[AudioManager] Yarn 全局参数 {paramName ?? "(null)"} 设置失败:{result}。");
}
}
public void SetMainMenuStage(int stage)
{
SetGlobalParam(MainMenuStageParamName, stage);
@@ -267,6 +288,7 @@ namespace AibisDream.Kit
private void OnGameStart()
{
YarnGlobalParameters.ResetToDefaults(Debug.LogWarning);
SetMainMenuStage(3);
}
@@ -300,6 +322,7 @@ namespace AibisDream.Kit
_musicDict.Clear();
_sfxDict.Clear();
YarnGlobalParameters.ResetToDefaults(Debug.LogWarning);
ChangeAmb(menuAmbState);
}
@@ -66,7 +66,7 @@ namespace AibisDream.Kit
[YarnCommand("set_global_param")]
public static void SetGlobalParam(string paramName, float value)
{
AudioManager.Instance.SetGlobalParam(paramName, value);
AudioManager.Instance.SetYarnGlobalParam(paramName, value);
}
}
}
}
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using FMOD;
using FMOD.Studio;
using FMODUnity;
namespace AibisDream.Kit
{
internal interface IFmodGlobalParameterApi
{
RESULT SetParameterByName(string name, float value, bool ignoreSeekSpeed);
RESULT GetParameterByName(string name, out float value, out float finalValue);
RESULT GetParameterDescriptionByName(string name, out PARAMETER_DESCRIPTION description);
RESULT SetParameterById(PARAMETER_ID id, float value, bool ignoreSeekSpeed);
}
internal sealed class RuntimeFmodGlobalParameterApi : IFmodGlobalParameterApi
{
public RESULT SetParameterByName(string name, float value, bool ignoreSeekSpeed)
{
return RuntimeManager.StudioSystem.setParameterByName(name, value, ignoreSeekSpeed);
}
public RESULT GetParameterByName(string name, out float value, out float finalValue)
{
return RuntimeManager.StudioSystem.getParameterByName(name, out value, out finalValue);
}
public RESULT GetParameterDescriptionByName(
string name,
out PARAMETER_DESCRIPTION description)
{
return RuntimeManager.StudioSystem.getParameterDescriptionByName(name, out description);
}
public RESULT SetParameterById(PARAMETER_ID id, float value, bool ignoreSeekSpeed)
{
return RuntimeManager.StudioSystem.setParameterByID(id, value, ignoreSeekSpeed);
}
}
/// <summary>
/// 只记录曾被 Yarn set_global_param 成功修改的参数名。
/// 参数值与默认值始终以 FMOD Studio System 为唯一来源。
/// </summary>
internal sealed class YarnGlobalParameterRegistry
{
private readonly IFmodGlobalParameterApi _api;
private readonly HashSet<string> _trackedNames =
new(StringComparer.OrdinalIgnoreCase);
internal YarnGlobalParameterRegistry(IFmodGlobalParameterApi api)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
}
internal IReadOnlyCollection<string> TrackedNames => _trackedNames;
internal RESULT SetFromYarn(string name, float value)
{
if (string.IsNullOrWhiteSpace(name))
{
return RESULT.ERR_INVALID_PARAM;
}
var result = _api.SetParameterByName(name, value, ignoreSeekSpeed: false);
if (result == RESULT.OK)
{
_trackedNames.Add(name);
}
return result;
}
internal Dictionary<string, float> CaptureFinalValues(Action<string> warn)
{
var values = new Dictionary<string, float>(StringComparer.OrdinalIgnoreCase);
foreach (var name in _trackedNames)
{
var result = _api.GetParameterByName(name, out _, out var finalValue);
if (result == RESULT.OK)
{
values[name] = finalValue;
continue;
}
warn?.Invoke(
$"[AudioManager] 捕获 Yarn 全局参数 {name} 失败:{result}。本次存档跳过该参数。");
}
return values;
}
internal void ResetToDefaults(Action<string> warn)
{
var names = new List<string>(_trackedNames);
foreach (var name in names)
{
var descriptionResult =
_api.GetParameterDescriptionByName(name, out var description);
if (descriptionResult != RESULT.OK)
{
warn?.Invoke(
$"[AudioManager] 查询 Yarn 全局参数 {name} 默认值失败:{descriptionResult}。保留追踪以便稍后重试。");
continue;
}
var resetResult = _api.SetParameterById(
description.id,
description.defaultvalue,
ignoreSeekSpeed: true);
if (resetResult != RESULT.OK)
{
warn?.Invoke(
$"[AudioManager] 重置 Yarn 全局参数 {name} 失败:{resetResult}。保留追踪以便稍后重试。");
continue;
}
_trackedNames.Remove(name);
}
}
internal void RestoreFinalValues(
IReadOnlyDictionary<string, float> values,
Action<string> warn)
{
if (values == null)
{
return;
}
foreach (var pair in values)
{
var result = _api.SetParameterByName(
pair.Key,
pair.Value,
ignoreSeekSpeed: true);
if (result == RESULT.OK)
{
_trackedNames.Add(pair.Key);
continue;
}
warn?.Invoke(
$"[AudioManager] 恢复 Yarn 全局参数 {pair.Key} 失败:{result}。跳过该参数。");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fe8472981b434b1d94e494cca8e3801d
@@ -37,7 +37,7 @@ namespace AibisDream.SaveSystem
return;
}
AudioManager.Instance.RestoreSnapshot(audio);
AudioManager.Instance.RestoreSnapshot(audio, context.Warn);
}
}
}
+7 -1
View File
@@ -123,7 +123,10 @@ namespace AibisDream.SaveSystem
public bool isTalking;
}
/// <summary>sections["audio"]FMOD 音乐/SFX 终态与 AMB 状态(不记播放进度)。</summary>
/// <summary>
/// sections["audio"]FMOD 音乐/SFX 终态、AMB 状态与 Yarn 全局参数 finalValue
/// (不记 Event 播放进度)。
/// </summary>
[Serializable]
public class AudioSnapshotDto
{
@@ -134,6 +137,9 @@ namespace AibisDream.SaveSystem
/// <summary><see cref="AmbState"/> 枚举名。</summary>
public string ambState;
/// <summary>由 Yarn set_global_param 修改过的参数名 → 捕获时的 FMOD finalValue。</summary>
public Dictionary<string, float> yarnGlobalParameters = new();
}
/// <summary>