Split Godot host audio operations
This commit is contained in:
@@ -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
|
||||
teardown. `godot/GodotAdvHost.Movies.cs` owns movie surface bindings, ordinary and modal playback, movie-mask
|
||||
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
|
||||
runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports
|
||||
|
||||
@@ -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
|
||||
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
|
||||
deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after
|
||||
each domain move.
|
||||
@@ -961,8 +966,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 planned `Main` domains and the first four
|
||||
`GodotAdvHost` domains isolated, move host audio ownership next, then continue one existing domain at a time
|
||||
while preserving public types, commands, and generated output.
|
||||
by the tracked launcher and layered validation driver. With the planned `Main` and `GodotAdvHost` domains
|
||||
isolated, begin the `GfxState` decomposition with one existing ownership domain at a time, 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.
|
||||
|
||||
196
godot/GodotAdvHost.Audio.cs
Normal file
196
godot/GodotAdvHost.Audio.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Age.Engine.Hosting;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
@@ -12,16 +10,11 @@ public sealed partial class GodotAdvHost : IHost
|
||||
private readonly Main _main;
|
||||
private readonly ResourceMap _res;
|
||||
private readonly string _rootScene;
|
||||
private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset
|
||||
private readonly int _screenWidth;
|
||||
private readonly int _screenHeight;
|
||||
private readonly Age.Engine.Hosting.FrameClock _clock;
|
||||
private readonly GodotTimelineLog? _timeline;
|
||||
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,
|
||||
PageLocatorState locator, Sys4LogicalCanvas logicalCanvas,
|
||||
IGlyphMaskRasterizer surfaceTextRasterizer,
|
||||
@@ -52,189 +45,6 @@ public sealed partial class GodotAdvHost : IHost
|
||||
|
||||
public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight);
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user