Split Main audio into partial class

This commit is contained in:
gamer147
2026-08-02 17:52:54 -04:00
parent f395b77a93
commit 123794cace
4 changed files with 320 additions and 307 deletions

308
godot/Main.Audio.cs Normal file
View File

@@ -0,0 +1,308 @@
using System;
using System.IO;
using Godot;
using Age.Engine.Model;
using Age.Engine.Persistence;
public partial class Main
{
private AudioStreamPlayer _bgm = null!; // looping background music
private Tween? _bgmFadeTween;
private AudioStreamPlayer _voice = null!; // interrupt-on-new voice
private int _voiceQueuedGeneration;
private int _voiceStartedGeneration;
private int _voiceCompletedGeneration;
private bool _voiceBgmDuckActive;
private int _voiceBgmDuckGeneration;
private float _voiceBgmDuckRestoreDb;
private readonly AudioStreamPlayer[] _sfx = new AudioStreamPlayer[10]; // SC0000 channels 0..9
private readonly int[] _sfxGenerations = new int[10];
private AudioMixerSettings _audioMixerSettings = null!;
private Sys4RegIniStore? _sys4RegIniStore;
// Decode VFS-owned bytes in Godot. Ordinary BGM loops; forced starts retain AGE's
// loop/one-shot mode. Voice plays once, cutting off any prior line.
public void PlayBgm(byte[] oggBytes, string assetName)
=> StartBgm(oggBytes, assetName, 1);
public void RestartBgm(byte[] oggBytes, string assetName, int startMode)
=> StartBgm(oggBytes, assetName, startMode);
private void StartBgm(byte[] oggBytes, string assetName, int startMode)
{
var stream = AudioStreamOggVorbis.LoadFromBuffer(oggBytes);
if (stream == null) { GD.Print($"OGG load failed {assetName}"); return; }
stream.Loop = startMode != 0;
// FadeBgm is marshalled from the VM thread and starts on the next Godot frame, while the VM's
// blocking deadline begins immediately. Scene startup can consequently request the replacement
// track just before the old fade tween reaches zero. Do not let that orphaned tween mute the new
// stream after this method restores its normal gain.
CancelBgmFade();
_bgm.VolumeDb = 0;
_bgm.Stream = stream;
_bgm.Play();
}
public void StopBgm()
{
RestoreVoiceBgmDuck();
CancelBgmFade();
_bgm.Stop();
_bgm.Stream = null;
_bgm.VolumeDb = 0;
}
private void PersistAudioMixerSettings(AudioMixerSettingsSnapshot snapshot)
{
try
{
_sys4RegIniStore?.Save(snapshot);
}
catch (Exception error) when (
error is IOException or UnauthorizedAccessException or InvalidDataException)
{
System.Console.Error.WriteLine(
$"[settings] audio mixer save failed; runtime setting remains active: {error.Message}");
}
}
private void ApplyAudioMixerSnapshot(AudioMixerSettingsSnapshot snapshot)
{
for (int category = 0; category < AudioMixerSettings.CategoryCount; category++)
ApplyAudioVolume(category, snapshot.Volumes[category]);
for (int category = (int)AudioMixerCategory.Music;
category < AudioMixerSettings.CategoryCount;
category++)
SetAudioBusMuted(category, !snapshot.Routes[category]);
}
public void ApplyAudioVolume(int category, int basisPoints)
{
string? busName = AudioBusName(category);
if (busName == null) return;
int bus = AudioServer.GetBusIndex(busName);
if (bus < 0) return;
int effectiveBasisPoints = basisPoints < 0
? AudioMixerSettings.MaximumVolume
: System.Math.Clamp(basisPoints, 0, AudioMixerSettings.MaximumVolume);
float linear = effectiveBasisPoints / (float)AudioMixerSettings.MaximumVolume;
AudioServer.SetBusVolumeDb(bus, linear <= 0 ? -80.0f : Mathf.LinearToDb(linear));
}
public void ApplyAudioRouteEnabled(int category, bool enabled)
{
if (category == (int)AudioMixerCategory.Music)
{
RestoreVoiceBgmDuck();
CancelBgmFade();
if (enabled)
{
if (_bgm.Stream != null) _bgm.Play();
}
else
_bgm.Stop();
}
else if (!enabled && category == (int)AudioMixerCategory.SoundEffect)
{
CancelScheduledSoundEffectStarts();
foreach (AudioStreamPlayer player in _sfx) player.Stop();
}
else if (!enabled && category == (int)AudioMixerCategory.Voice)
StopVoiceForMessageSkip();
SetAudioBusMuted(category, !enabled);
}
private static void SetAudioBusMuted(int category, bool muted)
{
string? busName = AudioBusName(category);
if (busName == null) return;
int bus = AudioServer.GetBusIndex(busName);
if (bus >= 0) AudioServer.SetBusMute(bus, muted);
}
private static string? AudioBusName(int category) => category switch
{
(int)AudioMixerCategory.Master => "Master",
(int)AudioMixerCategory.Music => "Music",
(int)AudioMixerCategory.SoundEffect => "SFX",
(int)AudioMixerCategory.Voice => "Voice",
(int)AudioMixerCategory.Movie => "Movie",
_ => null,
};
public int QueueVoicePlayback()
=> System.Threading.Interlocked.Increment(ref _voiceQueuedGeneration);
public bool IsVoicePlaybackActive
=> System.Threading.Volatile.Read(ref _voiceCompletedGeneration)
< System.Threading.Volatile.Read(ref _voiceQueuedGeneration);
public void PlayVoice(byte[] oggBytes, string assetName, int generation, bool duckBgm, int duckTargetPercent)
{
var stream = AudioStreamOggVorbis.LoadFromBuffer(oggBytes);
if (stream == null)
{
GD.Print($"OGG load failed {assetName}");
CompleteVoiceGeneration(generation);
return;
}
if (duckBgm)
BeginVoiceBgmDuck(generation, duckTargetPercent);
else
RestoreVoiceBgmDuck();
stream.Loop = false;
_voice.Stream = stream;
System.Threading.Volatile.Write(ref _voiceStartedGeneration, generation);
_voice.Play();
}
public void StopVoiceForMessageSkip()
{
_voice.Stop();
CompleteVoiceGeneration(System.Threading.Volatile.Read(ref _voiceQueuedGeneration));
}
private void UpdateVoicePlaybackState()
{
int started = System.Threading.Volatile.Read(ref _voiceStartedGeneration);
if (started > System.Threading.Volatile.Read(ref _voiceCompletedGeneration) && !_voice.Playing)
CompleteVoiceGeneration(started);
}
private void CompleteVoiceGeneration(int generation)
{
int current;
do
{
current = System.Threading.Volatile.Read(ref _voiceCompletedGeneration);
if (current >= generation) return;
}
while (System.Threading.Interlocked.CompareExchange(
ref _voiceCompletedGeneration, generation, current) != current);
if (_voiceBgmDuckActive && generation >= _voiceBgmDuckGeneration)
RestoreVoiceBgmDuck();
}
private void BeginVoiceBgmDuck(int generation, int targetPercent)
{
// Native voice ducking is suppressed while an explicit 0xc2 BGM fade owns the envelope.
if (_bgmFadeTween != null) return;
if (!_voiceBgmDuckActive)
_voiceBgmDuckRestoreDb = _bgm.VolumeDb;
_voiceBgmDuckActive = true;
_voiceBgmDuckGeneration = generation;
float linear = System.Math.Clamp(targetPercent / 100.0f, 0.0f, 1.0f);
_bgm.VolumeDb = linear <= 0 ? -80.0f : Mathf.LinearToDb(linear);
}
private void RestoreVoiceBgmDuck()
{
if (!_voiceBgmDuckActive) return;
_bgm.VolumeDb = _voiceBgmDuckRestoreDb;
_voiceBgmDuckActive = false;
_voiceBgmDuckGeneration = 0;
}
public void LoadSoundEffect(byte[] wavBytes, string assetName, int channel)
{
if ((uint)channel >= (uint)_sfx.Length) return;
_sfxGenerations[channel]++;
_sfx[channel].Stop();
_sfx[channel].Stream = null;
// This is intentionally a Godot-only compatibility boundary. The VFS and engine retain the
// original WAV bytes; Godot sees an AGE-compatible first-RIFF copy without CP932 INFO metadata.
byte[] godotWav = RiffWaveSanitizer.PrepareForGodot(wavBytes);
var stream = AudioStreamWav.LoadFromBuffer(godotWav);
if (stream == null) { GD.Print($"WAV load failed {assetName}"); return; }
stream.LoopMode = AudioStreamWav.LoopModeEnum.Disabled;
_sfx[channel].VolumeDb = 0;
_sfx[channel].Stream = stream;
}
public void StartSoundEffect(int channel) => StartSoundEffect(channel, 0);
public void StartSoundEffect(int channel, int startMode)
{
if ((uint)channel >= (uint)_sfx.Length || _sfx[channel].Stream == null) return;
if (_sfx[channel].Stream is AudioStreamWav wav)
wav.LoopMode = startMode == 0
? AudioStreamWav.LoopModeEnum.Disabled
: AudioStreamWav.LoopModeEnum.Forward;
_sfx[channel].Play();
}
public void ScheduleSoundEffectStart(int channel, int startMode, double realDelaySeconds)
{
if ((uint)channel >= (uint)_sfx.Length || _sfx[channel].Stream == null) return;
int generation = _sfxGenerations[channel];
void StartIfCurrent()
{
if (_sfxGenerations[channel] != generation || _sfx[channel].Stream == null) return;
if (_sfx[channel].Stream is AudioStreamWav wav)
wav.LoopMode = startMode == 0
? AudioStreamWav.LoopModeEnum.Disabled
: AudioStreamWav.LoopModeEnum.Forward;
_sfx[channel].Play();
}
if (realDelaySeconds <= 0)
{
StartIfCurrent();
return;
}
GetTree().CreateTimer(realDelaySeconds).Timeout += StartIfCurrent;
}
public void CancelScheduledSoundEffectStarts()
{
// Native scene_context_init_reset calls sfx_clear_scheduled_starts. Generation invalidation
// cancels the timer callbacks without stopping active sounds or unloading their channel streams.
for (int channel = 0; channel < _sfxGenerations.Length; channel++)
_sfxGenerations[channel]++;
}
public void ReleaseSoundEffect(int channel)
{
if ((uint)channel >= (uint)_sfx.Length) return;
_sfxGenerations[channel]++;
_sfx[channel].Stop();
_sfx[channel].Stream = null;
}
public void FadeBgm(int targetPercent, double realDurationSeconds)
{
float linear = System.Math.Clamp(targetPercent / 100.0f, 0.0f, 1.0f);
float targetDb = linear <= 0 ? -80.0f : Mathf.LinearToDb(linear);
RestoreVoiceBgmDuck();
CancelBgmFade();
if (realDurationSeconds <= 0) { _bgm.VolumeDb = targetDb; return; }
var tween = CreateTween();
_bgmFadeTween = tween;
tween.Finished += () =>
{
if (ReferenceEquals(_bgmFadeTween, tween))
_bgmFadeTween = null;
};
tween.TweenProperty(_bgm, "volume_db", targetDb, realDurationSeconds);
}
private void CancelBgmFade()
{
var tween = _bgmFadeTween;
_bgmFadeTween = null;
if (tween?.IsValid() == true)
tween.Kill();
}
private static void EnsureAudioBuses()
{
foreach (string name in new[] { "Music", "SFX", "Voice", "Movie" })
{
if (AudioServer.GetBusIndex(name) >= 0) continue;
AudioServer.AddBus();
AudioServer.SetBusName(AudioServer.BusCount - 1, name);
}
}
}

View File

@@ -36,19 +36,6 @@ public partial class Main : Godot.Control
private byte[] _screenPixels = [];
private Label _status = null!;
private Label _locatorHud = null!;
private AudioStreamPlayer _bgm = null!; // looping background music
private Tween? _bgmFadeTween;
private AudioStreamPlayer _voice = null!; // interrupt-on-new voice
private int _voiceQueuedGeneration;
private int _voiceStartedGeneration;
private int _voiceCompletedGeneration;
private bool _voiceBgmDuckActive;
private int _voiceBgmDuckGeneration;
private float _voiceBgmDuckRestoreDb;
private readonly AudioStreamPlayer[] _sfx = new AudioStreamPlayer[10]; // SC0000 channels 0..9
private readonly int[] _sfxGenerations = new int[10];
private AudioMixerSettings _audioMixerSettings = null!;
private Sys4RegIniStore? _sys4RegIniStore;
private VirtualMachine _vm = null!;
private GodotAdvHost _host = null!;
private IDisposable? _glyphRasterizerOwner;
@@ -1621,281 +1608,6 @@ public partial class Main : Godot.Control
px[i + 3] = 0;
}
// Decode VFS-owned bytes in Godot. Ordinary BGM loops; forced starts retain AGE's
// loop/one-shot mode. Voice plays once, cutting off any prior line.
public void PlayBgm(byte[] oggBytes, string assetName)
=> StartBgm(oggBytes, assetName, 1);
public void RestartBgm(byte[] oggBytes, string assetName, int startMode)
=> StartBgm(oggBytes, assetName, startMode);
private void StartBgm(byte[] oggBytes, string assetName, int startMode)
{
var stream = AudioStreamOggVorbis.LoadFromBuffer(oggBytes);
if (stream == null) { GD.Print($"OGG load failed {assetName}"); return; }
stream.Loop = startMode != 0;
// FadeBgm is marshalled from the VM thread and starts on the next Godot frame, while the VM's
// blocking deadline begins immediately. Scene startup can consequently request the replacement
// track just before the old fade tween reaches zero. Do not let that orphaned tween mute the new
// stream after this method restores its normal gain.
CancelBgmFade();
_bgm.VolumeDb = 0;
_bgm.Stream = stream;
_bgm.Play();
}
public void StopBgm()
{
RestoreVoiceBgmDuck();
CancelBgmFade();
_bgm.Stop();
_bgm.Stream = null;
_bgm.VolumeDb = 0;
}
private void PersistAudioMixerSettings(AudioMixerSettingsSnapshot snapshot)
{
try
{
_sys4RegIniStore?.Save(snapshot);
}
catch (Exception error) when (
error is IOException or UnauthorizedAccessException or InvalidDataException)
{
System.Console.Error.WriteLine(
$"[settings] audio mixer save failed; runtime setting remains active: {error.Message}");
}
}
private void ApplyAudioMixerSnapshot(AudioMixerSettingsSnapshot snapshot)
{
for (int category = 0; category < AudioMixerSettings.CategoryCount; category++)
ApplyAudioVolume(category, snapshot.Volumes[category]);
for (int category = (int)AudioMixerCategory.Music;
category < AudioMixerSettings.CategoryCount;
category++)
SetAudioBusMuted(category, !snapshot.Routes[category]);
}
public void ApplyAudioVolume(int category, int basisPoints)
{
string? busName = AudioBusName(category);
if (busName == null) return;
int bus = AudioServer.GetBusIndex(busName);
if (bus < 0) return;
int effectiveBasisPoints = basisPoints < 0
? AudioMixerSettings.MaximumVolume
: System.Math.Clamp(basisPoints, 0, AudioMixerSettings.MaximumVolume);
float linear = effectiveBasisPoints / (float)AudioMixerSettings.MaximumVolume;
AudioServer.SetBusVolumeDb(bus, linear <= 0 ? -80.0f : Mathf.LinearToDb(linear));
}
public void ApplyAudioRouteEnabled(int category, bool enabled)
{
if (category == (int)AudioMixerCategory.Music)
{
RestoreVoiceBgmDuck();
CancelBgmFade();
if (enabled)
{
if (_bgm.Stream != null) _bgm.Play();
}
else
_bgm.Stop();
}
else if (!enabled && category == (int)AudioMixerCategory.SoundEffect)
{
CancelScheduledSoundEffectStarts();
foreach (AudioStreamPlayer player in _sfx) player.Stop();
}
else if (!enabled && category == (int)AudioMixerCategory.Voice)
StopVoiceForMessageSkip();
SetAudioBusMuted(category, !enabled);
}
private static void SetAudioBusMuted(int category, bool muted)
{
string? busName = AudioBusName(category);
if (busName == null) return;
int bus = AudioServer.GetBusIndex(busName);
if (bus >= 0) AudioServer.SetBusMute(bus, muted);
}
private static string? AudioBusName(int category) => category switch
{
(int)AudioMixerCategory.Master => "Master",
(int)AudioMixerCategory.Music => "Music",
(int)AudioMixerCategory.SoundEffect => "SFX",
(int)AudioMixerCategory.Voice => "Voice",
(int)AudioMixerCategory.Movie => "Movie",
_ => null,
};
public int QueueVoicePlayback()
=> System.Threading.Interlocked.Increment(ref _voiceQueuedGeneration);
public bool IsVoicePlaybackActive
=> System.Threading.Volatile.Read(ref _voiceCompletedGeneration)
< System.Threading.Volatile.Read(ref _voiceQueuedGeneration);
public void PlayVoice(byte[] oggBytes, string assetName, int generation, bool duckBgm, int duckTargetPercent)
{
var stream = AudioStreamOggVorbis.LoadFromBuffer(oggBytes);
if (stream == null)
{
GD.Print($"OGG load failed {assetName}");
CompleteVoiceGeneration(generation);
return;
}
if (duckBgm)
BeginVoiceBgmDuck(generation, duckTargetPercent);
else
RestoreVoiceBgmDuck();
stream.Loop = false;
_voice.Stream = stream;
System.Threading.Volatile.Write(ref _voiceStartedGeneration, generation);
_voice.Play();
}
public void StopVoiceForMessageSkip()
{
_voice.Stop();
CompleteVoiceGeneration(System.Threading.Volatile.Read(ref _voiceQueuedGeneration));
}
private void UpdateVoicePlaybackState()
{
int started = System.Threading.Volatile.Read(ref _voiceStartedGeneration);
if (started > System.Threading.Volatile.Read(ref _voiceCompletedGeneration) && !_voice.Playing)
CompleteVoiceGeneration(started);
}
private void CompleteVoiceGeneration(int generation)
{
int current;
do
{
current = System.Threading.Volatile.Read(ref _voiceCompletedGeneration);
if (current >= generation) return;
}
while (System.Threading.Interlocked.CompareExchange(
ref _voiceCompletedGeneration, generation, current) != current);
if (_voiceBgmDuckActive && generation >= _voiceBgmDuckGeneration)
RestoreVoiceBgmDuck();
}
private void BeginVoiceBgmDuck(int generation, int targetPercent)
{
// Native voice ducking is suppressed while an explicit 0xc2 BGM fade owns the envelope.
if (_bgmFadeTween != null) return;
if (!_voiceBgmDuckActive)
_voiceBgmDuckRestoreDb = _bgm.VolumeDb;
_voiceBgmDuckActive = true;
_voiceBgmDuckGeneration = generation;
float linear = System.Math.Clamp(targetPercent / 100.0f, 0.0f, 1.0f);
_bgm.VolumeDb = linear <= 0 ? -80.0f : Mathf.LinearToDb(linear);
}
private void RestoreVoiceBgmDuck()
{
if (!_voiceBgmDuckActive) return;
_bgm.VolumeDb = _voiceBgmDuckRestoreDb;
_voiceBgmDuckActive = false;
_voiceBgmDuckGeneration = 0;
}
public void LoadSoundEffect(byte[] wavBytes, string assetName, int channel)
{
if ((uint)channel >= (uint)_sfx.Length) return;
_sfxGenerations[channel]++;
_sfx[channel].Stop();
_sfx[channel].Stream = null;
// This is intentionally a Godot-only compatibility boundary. The VFS and engine retain the
// original WAV bytes; Godot sees an AGE-compatible first-RIFF copy without CP932 INFO metadata.
byte[] godotWav = RiffWaveSanitizer.PrepareForGodot(wavBytes);
var stream = AudioStreamWav.LoadFromBuffer(godotWav);
if (stream == null) { GD.Print($"WAV load failed {assetName}"); return; }
stream.LoopMode = AudioStreamWav.LoopModeEnum.Disabled;
_sfx[channel].VolumeDb = 0;
_sfx[channel].Stream = stream;
}
public void StartSoundEffect(int channel) => StartSoundEffect(channel, 0);
public void StartSoundEffect(int channel, int startMode)
{
if ((uint)channel >= (uint)_sfx.Length || _sfx[channel].Stream == null) return;
if (_sfx[channel].Stream is AudioStreamWav wav)
wav.LoopMode = startMode == 0
? AudioStreamWav.LoopModeEnum.Disabled
: AudioStreamWav.LoopModeEnum.Forward;
_sfx[channel].Play();
}
public void ScheduleSoundEffectStart(int channel, int startMode, double realDelaySeconds)
{
if ((uint)channel >= (uint)_sfx.Length || _sfx[channel].Stream == null) return;
int generation = _sfxGenerations[channel];
void StartIfCurrent()
{
if (_sfxGenerations[channel] != generation || _sfx[channel].Stream == null) return;
if (_sfx[channel].Stream is AudioStreamWav wav)
wav.LoopMode = startMode == 0
? AudioStreamWav.LoopModeEnum.Disabled
: AudioStreamWav.LoopModeEnum.Forward;
_sfx[channel].Play();
}
if (realDelaySeconds <= 0)
{
StartIfCurrent();
return;
}
GetTree().CreateTimer(realDelaySeconds).Timeout += StartIfCurrent;
}
public void CancelScheduledSoundEffectStarts()
{
// Native scene_context_init_reset calls sfx_clear_scheduled_starts. Generation invalidation
// cancels the timer callbacks without stopping active sounds or unloading their channel streams.
for (int channel = 0; channel < _sfxGenerations.Length; channel++)
_sfxGenerations[channel]++;
}
public void ReleaseSoundEffect(int channel)
{
if ((uint)channel >= (uint)_sfx.Length) return;
_sfxGenerations[channel]++;
_sfx[channel].Stop();
_sfx[channel].Stream = null;
}
public void FadeBgm(int targetPercent, double realDurationSeconds)
{
float linear = System.Math.Clamp(targetPercent / 100.0f, 0.0f, 1.0f);
float targetDb = linear <= 0 ? -80.0f : Mathf.LinearToDb(linear);
RestoreVoiceBgmDuck();
CancelBgmFade();
if (realDurationSeconds <= 0) { _bgm.VolumeDb = targetDb; return; }
var tween = CreateTween();
_bgmFadeTween = tween;
tween.Finished += () =>
{
if (ReferenceEquals(_bgmFadeTween, tween))
_bgmFadeTween = null;
};
tween.TweenProperty(_bgm, "volume_db", targetDb, realDurationSeconds);
}
private void CancelBgmFade()
{
var tween = _bgmFadeTween;
_bgmFadeTween = null;
if (tween?.IsValid() == true)
tween.Kill();
}
public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId,
long resourceId, int assetId, long movieFlags,
long initialPositionMs, long startDelayMs,
@@ -2004,16 +1716,6 @@ public partial class Main : Godot.Control
return MovieAudioRoute.Movie;
}
private static void EnsureAudioBuses()
{
foreach (string name in new[] { "Music", "SFX", "Voice", "Movie" })
{
if (AudioServer.GetBusIndex(name) >= 0) continue;
AudioServer.AddBus();
AudioServer.SetBusName(AudioServer.BusCount - 1, name);
}
}
public void PageBreak()
{
_pageCount++;