Split Godot host audio operations

This commit is contained in:
gamer147
2026-08-02 21:02:27 -04:00
parent fa08c4ee71
commit 1fbaa363f9
4 changed files with 207 additions and 193 deletions

View File

@@ -154,6 +154,9 @@ scene-context lifecycle coordination. `godot/GodotAdvHost.Surfaces.cs` owns deco
surface pixels/resources/dimensions, fill/copy/resolve operations, render-target publication, and surface/range surface pixels/resources/dimensions, fill/copy/resolve operations, render-target publication, and surface/range
teardown. `godot/GodotAdvHost.Movies.cs` owns movie surface bindings, ordinary and modal playback, movie-mask teardown. `godot/GodotAdvHost.Movies.cs` owns movie surface bindings, ordinary and modal playback, movie-mask
transitions, diagnostic snapshots, frame/completion publication, and mask teardown. transitions, diagnostic snapshots, frame/completion publication, and mask teardown.
`godot/GodotAdvHost.Audio.cs` owns BGM, voice and SFX resolution/dispatch, delayed voice state, volume/routing
control, and blocking BGM fades; presentation/input retains the message-skip, reset, and frame-pulse consumers
of that state through the sealed partial class.
The disposable `build/page-map-<SCENE>.jsonl` files are produced by editor/development Godot runs and map 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 runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports

View File

@@ -591,6 +591,11 @@ do not mix mechanical moves with semantic changes.
`godot/GodotAdvHost.Movies.cs`. Presentation waits and mutable surface storage retain direct access through `godot/GodotAdvHost.Movies.cs`. Presentation waits and mutable surface storage retain direct access through
the sealed partial class; runtime validation remains green. the sealed partial class; runtime validation remains green.
The fifth bounded `GodotAdvHost` split moved BGM, voice and SFX resolution/dispatch, delayed voice state,
volume/routing control, and blocking BGM fades into `godot/GodotAdvHost.Audio.cs`. Message-skip release,
scene reset, and frame-pulse consumers retain direct access through the sealed partial class. The planned
host decomposition is complete; runtime validation remains green.
**Gate:** no externally visible behavior or command changes; generated artifacts are byte-identical where **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 deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after
each domain move. each domain move.
@@ -961,8 +966,8 @@ layer's rendering diverges from ADV; save layout.
## 8. Immediate next step ## 8. Immediate next step
Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed
by the tracked launcher and layered validation driver. With the planned `Main` domains and the first four by the tracked launcher and layered validation driver. With the planned `Main` and `GodotAdvHost` domains
`GodotAdvHost` domains isolated, move host audio ownership next, then continue one existing domain at a time isolated, begin the `GfxState` decomposition with one existing ownership domain at a time, preserving public
while preserving public types, commands, and generated output. types, commands, and generated output.
Concrete playthrough blockers may still preempt this bounded maintenance work; the consolidation effort does 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. not replace Phase B gameplay validation or the open cross-platform gates.

196
godot/GodotAdvHost.Audio.cs Normal file
View File

@@ -0,0 +1,196 @@
using System;
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
public sealed partial class GodotAdvHost
{
private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset
private int _voiceBgmDuckControl;
private (AudioPayload Audio, int PlaybackVariant)? _queuedSkippedVoice;
private readonly object _scheduledVoiceLock = new();
private (AudioPayload Audio, int PlaybackVariant, uint DelayMs, uint? StartMs)? _scheduledVoice;
public void PlayBgm(long id)
=> DispatchBgm(id, 1, false);
public void RestartBgm(long id, int startMode)
=> DispatchBgm(id, startMode, true);
private void DispatchBgm(long id, int startMode, bool forceRestart)
{
var asset = _res.ResolveBgm(id);
var audio = asset != null ? LoadAudio(asset) : null;
_timeline?.Event("bgm", new()
{
["id"] = id,
["file"] = audio?.Name,
["start_mode"] = startMode,
["force_restart"] = forceRestart,
});
if (audio == null) return;
if (forceRestart)
_main.CallDeferred("RestartBgm", audio.Bytes, audio.Name, startMode);
else
_main.CallDeferred("PlayBgm", audio.Bytes, audio.Name);
}
public void StopBgm()
{
_timeline?.Event("bgm-stop", new());
_main.CallDeferred("StopBgm");
}
public void PlayVoice(long id) => PlayVoice(id, 0);
public void PlayVoice(long id, int playbackVariant)
{
var asset = _res.ResolveVoice(id);
var audio = asset != null ? LoadAudio(asset) : null;
_timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name,
["playback_variant"] = playbackVariant });
if (audio == null) return;
bool queuedForSkip;
bool firstQueued = false;
lock (_messageSkipLock)
{
queuedForSkip = _messageSkipActive;
if (queuedForSkip)
{
firstQueued = _queuedSkippedVoice == null;
_queuedSkippedVoice = (audio, playbackVariant);
}
}
if (queuedForSkip)
{
if (firstQueued) _main.CallDeferred("StopVoiceForMessageSkip");
return;
}
DispatchVoice(audio, playbackVariant);
}
private void DispatchVoice(AudioPayload audio, int playbackVariant)
{
int generation = _main.QueueVoicePlayback();
bool duckBgm = (System.Threading.Volatile.Read(ref _voiceBgmDuckControl) & 1) == 0;
// Godot's stream player has no matching AGE start-mode control. Retain the native
// variant through dispatch/timeline so that distinction is not erased at the VM seam.
_main.CallDeferred("PlayVoice", audio.Bytes, audio.Name, generation, duckBgm, 50);
}
public void SetVoiceBgmDuckControl(long flags)
{
System.Threading.Volatile.Write(ref _voiceBgmDuckControl, unchecked((int)flags));
_timeline?.State("voice-bgm-duck-control", new() { ["flags"] = flags });
}
public void ScheduleVoicePlayback(long id, int playbackVariant, long delayMs)
{
var asset = _res.ResolveVoice(id);
var audio = asset != null ? LoadAudio(asset) : null;
_timeline?.Event("voice-scheduled", new()
{
["id"] = id, ["file"] = audio?.Name, ["playback_variant"] = playbackVariant,
["delay_ms"] = unchecked((uint)delayMs),
});
lock (_scheduledVoiceLock)
_scheduledVoice = audio == null
? null
: (audio, playbackVariant, unchecked((uint)delayMs), null);
}
public void LoadSoundEffect(long resourceId, int channel)
{
if ((uint)channel >= (uint)_sfxNames.Length) return;
var asset = _res.ResolveSoundEffect(resourceId);
var audio = asset != null ? LoadAudio(asset) : null;
_sfxNames[channel] = audio?.Name;
_timeline?.Event("sfx-load", new() { ["resource"] = resourceId, ["channel"] = channel,
["file"] = audio?.Name });
if (audio != null) _main.CallDeferred("LoadSoundEffect", audio.Bytes, audio.Name, channel);
}
public void StartSoundEffect(int channel) => StartSoundEffect(channel, 0);
public void StartSoundEffect(int channel, int startMode)
{
if ((uint)channel >= (uint)_sfxNames.Length || _sfxNames[channel] == null) return;
_timeline?.Event("sfx-start", new() { ["channel"] = channel,
["start_mode"] = startMode, ["file"] = _sfxNames[channel] });
_main.CallDeferred("StartSoundEffect", channel, startMode);
}
public void ScheduleSoundEffectStart(int channel, int startMode, long delayMs)
{
if ((uint)channel >= (uint)_sfxNames.Length || _sfxNames[channel] == null) return;
long ms = System.Math.Clamp(delayMs, 0, 60_000);
double realSeconds = ms / 1000.0 / System.Math.Max(0.05, _clock.Speed);
_timeline?.Event("sfx-start-scheduled", new() { ["channel"] = channel,
["start_mode"] = startMode, ["delay_ms"] = ms, ["file"] = _sfxNames[channel] });
_main.CallDeferred("ScheduleSoundEffectStart", channel, startMode, realSeconds);
}
public void ReleaseSoundEffect(int channel)
{
if ((uint)channel >= (uint)_sfxNames.Length) return;
_timeline?.Event("sfx-release", new() { ["channel"] = channel,
["file"] = _sfxNames[channel] });
_sfxNames[channel] = null;
_main.CallDeferred("ReleaseSoundEffect", channel);
}
private AudioPayload? LoadAudio(AssetEntry asset)
{
try { return _res.ReadAudio(asset); }
catch (System.Exception e)
{
Godot.GD.Print($"audio read failed {asset.Name}: {e.Message}");
return null;
}
}
public void FadeBgm(int targetPercent, long durationMs)
{
long ms = System.Math.Clamp(durationMs, 0, 60_000);
double realSeconds = ms / 1000.0 / System.Math.Max(0.05, _clock.Speed);
_timeline?.State("bgm-fade", new() { ["target_percent"] = targetPercent, ["duration_ms"] = ms });
_main.CallDeferred("FadeBgm", targetPercent, realSeconds);
long deadline = _clock.NowMs + ms;
IsSleeping = true;
bool scriptSuspended = SuspendScriptForPresentation();
try
{
// Native parks the interpreter in its audio service while the main render loop continues.
// Publish scene changes accumulated before the fade (notably GAMESTART -> SC0000's black
// frame), then leave presentation ownership with the compositor for the timed wait.
RequestSynchronizedPresentation();
while (_clock.NowMs < deadline && !_stopping) _frameSignal.WaitOne(50);
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
IsSleeping = false;
}
_timeline?.State("running", new() { ["bgm_fade_complete"] = true });
}
public void ApplyAudioVolume(int category, int basisPoints)
{
_timeline?.State("audio-volume", new()
{
["category"] = category,
["basis_points"] = basisPoints,
});
_main.CallDeferred("ApplyAudioVolume", category, basisPoints);
}
public void ApplyAudioRouteEnabled(int category, bool enabled)
{
_timeline?.State("audio-route", new()
{
["category"] = category,
["enabled"] = enabled,
});
_main.CallDeferred("ApplyAudioRouteEnabled", category, enabled);
}
}

View File

@@ -1,7 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Age.Engine.Hosting; using Age.Engine.Hosting;
using Age.Engine.Model; using Age.Engine.Model;
using Age.Engine.Sys4; using Age.Engine.Sys4;
@@ -12,16 +10,11 @@ public sealed partial class GodotAdvHost : IHost
private readonly Main _main; private readonly Main _main;
private readonly ResourceMap _res; private readonly ResourceMap _res;
private readonly string _rootScene; private readonly string _rootScene;
private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset
private readonly int _screenWidth; private readonly int _screenWidth;
private readonly int _screenHeight; private readonly int _screenHeight;
private readonly Age.Engine.Hosting.FrameClock _clock; private readonly Age.Engine.Hosting.FrameClock _clock;
private readonly GodotTimelineLog? _timeline; private readonly GodotTimelineLog? _timeline;
private readonly PageLocatorState _locator; private readonly PageLocatorState _locator;
private int _voiceBgmDuckControl;
private (AudioPayload Audio, int PlaybackVariant)? _queuedSkippedVoice;
private readonly object _scheduledVoiceLock = new();
private (AudioPayload Audio, int PlaybackVariant, uint DelayMs, uint? StartMs)? _scheduledVoice;
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock, public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock,
PageLocatorState locator, Sys4LogicalCanvas logicalCanvas, PageLocatorState locator, Sys4LogicalCanvas logicalCanvas,
IGlyphMaskRasterizer surfaceTextRasterizer, IGlyphMaskRasterizer surfaceTextRasterizer,
@@ -52,189 +45,6 @@ public sealed partial class GodotAdvHost : IHost
public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight); public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight);
public void ReportWarning(string message) => System.Console.Error.WriteLine(message); public void ReportWarning(string message) => System.Console.Error.WriteLine(message);
public void PlayBgm(long id)
=> DispatchBgm(id, 1, false);
public void RestartBgm(long id, int startMode)
=> DispatchBgm(id, startMode, true);
private void DispatchBgm(long id, int startMode, bool forceRestart)
{
var asset = _res.ResolveBgm(id);
var audio = asset != null ? LoadAudio(asset) : null;
_timeline?.Event("bgm", new()
{
["id"] = id,
["file"] = audio?.Name,
["start_mode"] = startMode,
["force_restart"] = forceRestart,
});
if (audio == null) return;
if (forceRestart)
_main.CallDeferred("RestartBgm", audio.Bytes, audio.Name, startMode);
else
_main.CallDeferred("PlayBgm", audio.Bytes, audio.Name);
}
public void StopBgm()
{
_timeline?.Event("bgm-stop", new());
_main.CallDeferred("StopBgm");
}
public void PlayVoice(long id) => PlayVoice(id, 0);
public void PlayVoice(long id, int playbackVariant)
{
var asset = _res.ResolveVoice(id);
var audio = asset != null ? LoadAudio(asset) : null;
_timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name,
["playback_variant"] = playbackVariant });
if (audio == null) return;
bool queuedForSkip;
bool firstQueued = false;
lock (_messageSkipLock)
{
queuedForSkip = _messageSkipActive;
if (queuedForSkip)
{
firstQueued = _queuedSkippedVoice == null;
_queuedSkippedVoice = (audio, playbackVariant);
}
}
if (queuedForSkip)
{
if (firstQueued) _main.CallDeferred("StopVoiceForMessageSkip");
return;
}
DispatchVoice(audio, playbackVariant);
}
private void DispatchVoice(AudioPayload audio, int playbackVariant)
{
int generation = _main.QueueVoicePlayback();
bool duckBgm = (System.Threading.Volatile.Read(ref _voiceBgmDuckControl) & 1) == 0;
// Godot's stream player has no matching AGE start-mode control. Retain the native
// variant through dispatch/timeline so that distinction is not erased at the VM seam.
_main.CallDeferred("PlayVoice", audio.Bytes, audio.Name, generation, duckBgm, 50);
}
public void SetVoiceBgmDuckControl(long flags)
{
System.Threading.Volatile.Write(ref _voiceBgmDuckControl, unchecked((int)flags));
_timeline?.State("voice-bgm-duck-control", new() { ["flags"] = flags });
}
public void ScheduleVoicePlayback(long id, int playbackVariant, long delayMs)
{
var asset = _res.ResolveVoice(id);
var audio = asset != null ? LoadAudio(asset) : null;
_timeline?.Event("voice-scheduled", new()
{
["id"] = id, ["file"] = audio?.Name, ["playback_variant"] = playbackVariant,
["delay_ms"] = unchecked((uint)delayMs),
});
lock (_scheduledVoiceLock)
_scheduledVoice = audio == null
? null
: (audio, playbackVariant, unchecked((uint)delayMs), null);
}
public void LoadSoundEffect(long resourceId, int channel)
{
if ((uint)channel >= (uint)_sfxNames.Length) return;
var asset = _res.ResolveSoundEffect(resourceId);
var audio = asset != null ? LoadAudio(asset) : null;
_sfxNames[channel] = audio?.Name;
_timeline?.Event("sfx-load", new() { ["resource"] = resourceId, ["channel"] = channel,
["file"] = audio?.Name });
if (audio != null) _main.CallDeferred("LoadSoundEffect", audio.Bytes, audio.Name, channel);
}
public void StartSoundEffect(int channel) => StartSoundEffect(channel, 0);
public void StartSoundEffect(int channel, int startMode)
{
if ((uint)channel >= (uint)_sfxNames.Length || _sfxNames[channel] == null) return;
_timeline?.Event("sfx-start", new() { ["channel"] = channel,
["start_mode"] = startMode, ["file"] = _sfxNames[channel] });
_main.CallDeferred("StartSoundEffect", channel, startMode);
}
public void ScheduleSoundEffectStart(int channel, int startMode, long delayMs)
{
if ((uint)channel >= (uint)_sfxNames.Length || _sfxNames[channel] == null) return;
long ms = System.Math.Clamp(delayMs, 0, 60_000);
double realSeconds = ms / 1000.0 / System.Math.Max(0.05, _clock.Speed);
_timeline?.Event("sfx-start-scheduled", new() { ["channel"] = channel,
["start_mode"] = startMode, ["delay_ms"] = ms, ["file"] = _sfxNames[channel] });
_main.CallDeferred("ScheduleSoundEffectStart", channel, startMode, realSeconds);
}
public void ReleaseSoundEffect(int channel)
{
if ((uint)channel >= (uint)_sfxNames.Length) return;
_timeline?.Event("sfx-release", new() { ["channel"] = channel,
["file"] = _sfxNames[channel] });
_sfxNames[channel] = null;
_main.CallDeferred("ReleaseSoundEffect", channel);
}
private AudioPayload? LoadAudio(AssetEntry asset)
{
try { return _res.ReadAudio(asset); }
catch (System.Exception e)
{
Godot.GD.Print($"audio read failed {asset.Name}: {e.Message}");
return null;
}
}
public void FadeBgm(int targetPercent, long durationMs)
{
long ms = System.Math.Clamp(durationMs, 0, 60_000);
double realSeconds = ms / 1000.0 / System.Math.Max(0.05, _clock.Speed);
_timeline?.State("bgm-fade", new() { ["target_percent"] = targetPercent, ["duration_ms"] = ms });
_main.CallDeferred("FadeBgm", targetPercent, realSeconds);
long deadline = _clock.NowMs + ms;
IsSleeping = true;
bool scriptSuspended = SuspendScriptForPresentation();
try
{
// Native parks the interpreter in its audio service while the main render loop continues.
// Publish scene changes accumulated before the fade (notably GAMESTART -> SC0000's black
// frame), then leave presentation ownership with the compositor for the timed wait.
RequestSynchronizedPresentation();
while (_clock.NowMs < deadline && !_stopping) _frameSignal.WaitOne(50);
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
IsSleeping = false;
}
_timeline?.State("running", new() { ["bgm_fade_complete"] = true });
}
public void ApplyAudioVolume(int category, int basisPoints)
{
_timeline?.State("audio-volume", new()
{
["category"] = category,
["basis_points"] = basisPoints,
});
_main.CallDeferred("ApplyAudioVolume", category, basisPoints);
}
public void ApplyAudioRouteEnabled(int category, bool enabled)
{
_timeline?.State("audio-route", new()
{
["category"] = category,
["enabled"] = enabled,
});
_main.CallDeferred("ApplyAudioRouteEnabled", category, enabled);
}
} }
public sealed record GodotHostDiagnosticSnapshot( public sealed record GodotHostDiagnosticSnapshot(