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

View File

@@ -135,9 +135,10 @@ The Godot deliverable includes `Himegari.sln` because Godot's .NET exporter requ
the project do not enter its import or export scan. `tools/export-linux-x64.ps1` produces and validates the
complete disposable artifact under `build/export/linux-x64/`.
`godot/Main.cs` retains the front-end's startup and runtime coordination. Its synthetic threaded/headless
regression harness lives in the behavior-neutral partial-class companion `godot/Main.SelfTest.cs`, keeping
the validation surface independently navigable without changing the Godot node type or invocation path.
`godot/Main.cs` retains the front-end's startup and runtime coordination. Behavior-neutral partial-class
companions keep cohesive surfaces independently navigable without changing the Godot node type or invocation
paths: `godot/Main.SelfTest.cs` owns the synthetic threaded/headless regression harness, while
`godot/Main.Audio.cs` owns BGM, voice, sound-effect, mixer-routing/persistence, and audio-bus control.
The disposable `build/page-map-<SCENE>.jsonl` files are produced by editor/development Godot runs and map
runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports

View File

@@ -559,9 +559,11 @@ do not mix mechanical moves with semantic changes.
stage, battle, card, routine, gallery, and general table implementations plus tests into importable
modules.
**Progress (2026-08-02):** the first bounded split moved `Main`'s synthetic scene builder and threaded
self-test harness into `godot/Main.SelfTest.cs`. The partial class retains the same node type and private
calls; runtime validation, including all 590 engine tests and the Godot threaded self-test, remains green.
**Progress (2026-08-02):** the first two bounded splits moved `Main`'s synthetic scene builder/threaded
self-test harness into `godot/Main.SelfTest.cs`, then its BGM, voice, sound-effect, mixer, and bus-control
surface into `godot/Main.Audio.cs`. The partial class retains the same node type, fields, signatures, and
call sites; runtime validation, including all 590 engine tests and the Godot threaded self-test, remains
green after each move.
**Gate:** no externally visible behavior or command changes; generated artifacts are byte-identical where
deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after
@@ -933,8 +935,8 @@ layer's rendering diverges from ADV; save layout.
## 8. Immediate next step
Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed
by the tracked launcher and layered validation driver. With the embedded `Main` self-test surface isolated,
move the `Main` audio control surface next, then one existing domain at a time while preserving public types,
commands, and generated output.
by the tracked launcher and layered validation driver. With the embedded `Main` self-test and audio surfaces
isolated, move the `Main` movie playback surface next, then one existing domain at a time while preserving
public types, commands, and generated output.
Concrete playthrough blockers may still preempt this bounded maintenance work; the consolidation effort does
not replace Phase B gameplay validation or the open cross-platform gates.

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++;