• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orion-ecs / keen-eye / 30142831845

25 Jul 2026 03:45AM UTC coverage: 63.937% (+0.05%) from 63.89%
30142831845

push

github

tyevco
docs: Fix CLAUDE.md TestBridge integration snippets to use the real game-side API

The 'Integrating TestBridge in Your Application' example told game developers to
use TestBridgeManager, which lives in editor/KeenEyes.Editor (an Exe) and is
therefore unreferenceable from a game; it also called Dispose() on a type that
is IAsyncDisposable. The headless example used an InProcessBridge(world,
inputContext, captureContext) constructor overload that does not exist.

Both snippets now use the shipped API that samples/KeenEyes.Sample.NovaFall
actually runs: TestBridgePlugin (registering the ITestBridge extension) plus an
IpcBridgeServer with IpcOptions.PipeName, and the real single-argument
InProcessBridge constructor. Namespaces are spelled out in the using lists
(ITestBridge/IpcOptions are in KeenEyes.TestBridge, EntityQuery is in
KeenEyes.TestBridge.State, Key is in KeenEyes.Input.Abstractions) and a note
points at the sample as the complete working example.

Both snippets were compile-verified against the real assemblies (0 errors,
0 warnings) in a throwaway project before committing.

8876 of 13123 branches covered (67.64%)

Branch coverage included in aggregate %.

52661 of 83123 relevant lines covered (63.35%)

1.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

10.47
/samples/KeenEyes.Sample.NovaFall/Systems/NovaFallAudioSystem.cs
1
using KeenEyes.Audio.Abstractions;
2

3
namespace KeenEyes.Sample.NovaFall;
4

5
/// <summary>
6
/// Owns every sound in the game: four synced music stems cross-faded by heat
7
/// tier and the Flashover Surge, a fall-speed-pitched wind loop, a
8
/// crush-proximity rumble, event one-shots (graze ting, tier-up swell, smash
9
/// crunch, brittle crackle, bumper boing, death impact), the Adrenaline Save's
10
/// muffled duck, and the 400 ms of true silence in the death beat.
11
/// </summary>
12
/// <remarks>
13
/// <para>
14
/// STEM MIXING — the four loops (<c>music-pad</c>, <c>music-pulse</c>,
15
/// <c>music-lead</c>, <c>music-lead-surge</c>) are exactly the same length and
16
/// tempo and are started on the Music channel in the same frame, so they stay
17
/// sample-locked forever. Intensity comes from mixing, not switching: the pad
18
/// always plays, the pulse fades in at Flame, the lead at Plasma — and a
19
/// Flashover Surge cross-fades the ordinary lead OUT and its surge variant IN,
20
/// so even the event swap is a mix move, never a restart. Ember Garden's
21
/// <see cref="StemMix.PadOnly"/> setting mutes everything but a ducked pad.
22
/// </para>
23
/// <para>
24
/// PITCH AS PARAMETER — with WAV-only audio, per-sound pitch is the expressive
25
/// axis: wind pitch maps to fall speed, the graze ting steps up per consecutive
26
/// graze, and the smash crunch scales with impact speed.
27
/// </para>
28
/// <para>
29
/// All fades use real delta time. Missing asset files, a missing audio context,
30
/// or an audio device the machine cannot provide (no OpenAL runtime, no output
31
/// device) disable the system gracefully: one console warning, then silence, and
32
/// the game keeps playing. This is the pattern to copy - audio is optional
33
/// hardware, so a game must never require it to start.
34
/// </para>
35
/// </remarks>
36
public sealed class NovaFallAudioSystem : SystemBase
37
{
38
    private IAudioContext? audio;
39

40
    private AudioClipHandle padClip;
41
    private AudioClipHandle pulseClip;
42
    private AudioClipHandle leadClip;
43
    private AudioClipHandle surgeLeadClip;
44
    private AudioClipHandle windClip;
45
    private AudioClipHandle rumbleClip;
46
    private AudioClipHandle tingClip;
47
    private AudioClipHandle swellClip;
48
    private AudioClipHandle crunchClip;
49
    private AudioClipHandle crackleClip;
50
    private AudioClipHandle boingClip;
51
    private AudioClipHandle impactClip;
52

53
    private SoundHandle padSound;
54
    private SoundHandle pulseSound;
55
    private SoundHandle leadSound;
56
    private SoundHandle surgeLeadSound;
57
    private SoundHandle windSound;
58
    private SoundHandle rumbleSound;
59

60
    private float padVolume;
61
    private float pulseVolume;
62
    private float leadVolume;
63
    private float surgeLeadVolume;
64

65
    private bool loadAttempted;
66
    private bool loaded;
67
    private bool unavailableReported;
68
    private bool loopsActive;
69
    private bool deathImpactPlayed;
70
    private bool silenced;
71

72
    /// <inheritdoc />
73
    public override void Update(float deltaTime)
74
    {
75
        var juice = World.GetSingleton<JuiceConfig>();
1✔
76
        if (!juice.PresentationAvailable
1✔
77
            || !World.TryGetExtension<IAudioContext>(out var context) || context is null)
1✔
78
        {
79
            return;
1✔
80
        }
81

82
        // The extension exists as soon as the audio plugin is installed, but the device is
83
        // opened later, when the window loads - and it can fail (no OpenAL runtime installed,
84
        // no output device). Playing into an uninitialized context throws, so check first and
85
        // fall silent instead.
86
        if (!context.IsInitialized)
1✔
87
        {
88
            ReportAudioUnavailable(context.InitializationError);
1✔
89
            return;
1✔
90
        }
91

92
        audio = context;
×
93
        if (!EnsureClipsLoaded())
×
94
        {
95
            return;
×
96
        }
97

98
        var phase = World.GetSingleton<GameState>().Phase;
×
99
        ref readonly var death = ref World.GetSingleton<DeathSequenceState>();
×
100

101
        if (phase == GamePhase.Dead)
×
102
        {
103
            UpdateDeathBeat(in death, juice.Enabled);
×
104
            return;
×
105
        }
106

107
        deathImpactPlayed = false;
×
108
        silenced = false;
×
109

110
        if (phase != GamePhase.Playing)
×
111
        {
112
            return;
×
113
        }
114

115
        if (!loopsActive)
×
116
        {
117
            StartLoops();
×
118
        }
119

120
        UpdateStemMix(deltaTime, juice.Enabled);
×
121
        UpdateWindAndRumble(juice.Enabled);
×
122
        PlayEventOneShots(juice.Enabled);
×
123
    }
×
124

125
    /// <summary>
126
    /// Warns once that the game is running without sound, naming the reason the backend gave.
127
    /// </summary>
128
    /// <param name="error">The backend failure, or null when audio simply never initialized.</param>
129
    private void ReportAudioUnavailable(AudioException? error)
130
    {
131
        if (unavailableReported)
1✔
132
        {
133
            return;
1✔
134
        }
135

136
        unavailableReported = true;
1✔
137
        Console.WriteLine(error is null
1✔
138
            ? "Audio unavailable - continuing without sound."
1✔
139
            : $"Audio unavailable: {error.Message} Continuing without sound.");
1✔
140
    }
1✔
141

142
    private bool EnsureClipsLoaded()
143
    {
144
        if (loadAttempted)
×
145
        {
146
            return loaded;
×
147
        }
148

149
        loadAttempted = true;
×
150
        try
151
        {
152
            padClip = LoadClip("music-pad.wav");
×
153
            pulseClip = LoadClip("music-pulse.wav");
×
154
            leadClip = LoadClip("music-lead.wav");
×
155
            surgeLeadClip = LoadClip("music-lead-surge.wav");
×
156
            windClip = LoadClip("wind-loop.wav");
×
157
            rumbleClip = LoadClip("crush-rumble.wav");
×
158
            tingClip = LoadClip("graze-ting.wav");
×
159
            swellClip = LoadClip("tier-up-swell.wav");
×
160
            crunchClip = LoadClip("smash-crunch.wav");
×
161
            crackleClip = LoadClip("brittle-crackle.wav");
×
162
            boingClip = LoadClip("bumper-boing.wav");
×
163
            impactClip = LoadClip("death-impact.wav");
×
164
            loaded = true;
×
165
        }
×
166
        catch (Exception ex) when (ex is IOException or AudioLoadException)
×
167
        {
168
            Console.WriteLine($"Audio disabled - could not load sound assets: {ex.Message}");
×
169
        }
×
170

171
        return loaded;
×
172
    }
173

174
    private AudioClipHandle LoadClip(string fileName)
175
        => audio!.LoadClip(Path.Combine(AppContext.BaseDirectory, "Assets", "Audio", fileName));
×
176

177
    /// <summary>
178
    /// Starts every loop in the same frame so the stems stay sample-locked.
179
    /// Everything except the pad starts at zero volume and is mixed in later.
180
    /// </summary>
181
    private void StartLoops()
182
    {
183
        var music = PlaybackOptions.Default with { Loop = true, Channel = AudioChannel.Music };
×
184
        padSound = audio!.Play(padClip, music with { Volume = 0.75f });
×
185
        pulseSound = audio.Play(pulseClip, music with { Volume = 0f });
×
186
        leadSound = audio.Play(leadClip, music with { Volume = 0f });
×
187
        surgeLeadSound = audio.Play(surgeLeadClip, music with { Volume = 0f });
×
188

189
        var ambient = PlaybackOptions.Default with { Loop = true, Channel = AudioChannel.Ambient, Volume = 0f };
×
190
        windSound = audio.Play(windClip, ambient);
×
191
        rumbleSound = audio.Play(rumbleClip, ambient);
×
192

193
        padVolume = 0.75f;
×
194
        pulseVolume = 0f;
×
195
        leadVolume = 0f;
×
196
        surgeLeadVolume = 0f;
×
197
        loopsActive = true;
×
198
    }
×
199

200
    private void UpdateStemMix(float deltaTime, bool juiceEnabled)
201
    {
202
        var tier = World.GetSingleton<HeatState>().Tier;
×
203
        var mix = World.GetSingleton<RunConfig>().Settings.Music;
×
204
        var surging = World.GetSingleton<SurgeState>().Active;
×
205

206
        float padTarget;
207
        float pulseTarget;
208
        float leadTarget;
209
        float surgeLeadTarget;
210

211
        if (mix == StemMix.PadOnly)
×
212
        {
213
            // Ember Garden: a ducked pad and nothing else. The other stems keep
214
            // looping silently, so switching modes never breaks the sample lock.
215
            padTarget = Tuning.EmberPadVolume;
×
216
            pulseTarget = 0f;
×
217
            leadTarget = 0f;
×
218
            surgeLeadTarget = 0f;
×
219
        }
220
        else
221
        {
222
            // Pad always (swelling slightly with tier); pulse from Flame; lead
223
            // from Plasma; Nova pushes everything up. A Flashover Surge swaps
224
            // the lead for its surge variant — as a cross-fade, never a restart.
225
            padTarget = 0.75f + 0.10f * tier / 3f;
×
226
            pulseTarget = juiceEnabled && tier >= 1 ? (tier >= 3 ? 0.70f : 0.55f) : 0f;
×
227
            leadTarget = juiceEnabled && tier >= 2 && !surging ? (tier >= 3 ? 0.65f : 0.50f) : 0f;
×
228
            surgeLeadTarget = juiceEnabled && surging ? 0.70f : 0f;
×
229
        }
230

231
        padVolume = MoveTowards(padVolume, padTarget, Tuning.StemFadePerSecond * deltaTime);
×
232
        pulseVolume = MoveTowards(pulseVolume, pulseTarget, Tuning.StemFadePerSecond * deltaTime);
×
233
        leadVolume = MoveTowards(leadVolume, leadTarget, Tuning.StemFadePerSecond * deltaTime);
×
234
        surgeLeadVolume = MoveTowards(surgeLeadVolume, surgeLeadTarget, Tuning.StemFadePerSecond * deltaTime);
×
235

236
        // The Adrenaline Save muffles the music channel — the world holds its
237
        // breath — while the ambient danger cues (wind, rumble) stay honest.
238
        var duck = World.GetSingleton<AdrenalineState>().Active ? Tuning.AdrenalineMusicDuck : 1f;
×
239

240
        audio!.SetVolume(padSound, padVolume * duck);
×
241
        audio.SetVolume(pulseSound, pulseVolume * duck);
×
242
        audio.SetVolume(leadSound, leadVolume * duck);
×
243
        audio.SetVolume(surgeLeadSound, surgeLeadVolume * duck);
×
244
    }
×
245

246
    private void UpdateWindAndRumble(bool juiceEnabled)
247
    {
248
        if (!juiceEnabled)
×
249
        {
250
            audio!.SetVolume(windSound, 0f);
×
251
            audio.SetVolume(rumbleSound, 0f);
×
252
            return;
×
253
        }
254

255
        foreach (var entity in World.Query<Ball, Position2D, Velocity2D>())
×
256
        {
257
            ref readonly var position = ref World.Get<Position2D>(entity);
×
258
            ref readonly var velocity = ref World.Get<Velocity2D>(entity);
×
259

260
            // Wind pitch and volume follow fall speed.
261
            var speedFraction = Math.Clamp(velocity.Y / Tuning.MaxFallSpeed, 0f, 1f);
×
262
            audio!.SetPitch(windSound, float.Lerp(Tuning.WindPitchMin, Tuning.WindPitchMax, speedFraction));
×
263
            audio.SetVolume(windSound, 0.05f + 0.55f * speedFraction);
×
264

265
            // Rumble volume follows Furnace proximity.
266
            var ceilingDistance = position.Y - Tuning.CeilingY;
×
267
            var danger = 1f - Math.Clamp(ceilingDistance / Tuning.CrushProximityRange, 0f, 1f);
×
268
            audio.SetVolume(rumbleSound, 0.85f * danger);
×
269
            break;
×
270
        }
271
    }
×
272

273
    private void PlayEventOneShots(bool juiceEnabled)
274
    {
275
        if (!juiceEnabled)
×
276
        {
277
            return;
×
278
        }
279

280
        ref readonly var events = ref World.GetSingleton<FrameEvents>();
×
281

282
        if (events.Grazes > 0)
×
283
        {
284
            // The ting climbs with the graze chain — the audible combo ladder.
285
            var chain = Math.Min(World.GetSingleton<ComboState>().ConsecutiveGrazes, Tuning.GrazePitchCap);
×
286
            audio!.Play(tingClip, PlaybackOptions.Default with
×
287
            {
×
288
                Volume = 0.7f,
×
289
                Pitch = 1f + Tuning.GrazePitchStep * chain,
×
290
            });
×
291
        }
292

293
        if (events.TierChanged && events.TierTo > events.TierFrom)
×
294
        {
295
            audio!.Play(swellClip, PlaybackOptions.Default with { Volume = 0.8f });
×
296
        }
297

298
        if (events.Smashed)
×
299
        {
300
            // Crunch scales with the kinetic energy of the impact.
301
            var impact = Math.Clamp(events.SmashImpactSpeed / Tuning.MaxFallSpeed, 0f, 1f);
×
302
            audio!.Play(crunchClip, PlaybackOptions.Default with
×
303
            {
×
304
                Volume = 0.55f + 0.45f * impact,
×
305
                Pitch = 0.85f + 0.4f * impact,
×
306
            });
×
307
        }
308

309
        if (events.CrackStarted)
×
310
        {
311
            // The audible half of the Brittle telegraph: this starts a full
312
            // crumble delay (>= 0.6 s) before the floor lets go.
313
            audio!.Play(crackleClip, PlaybackOptions.Default with { Volume = 0.8f });
×
314
        }
315

316
        if (events.Crumbled)
×
317
        {
318
            // The crumble reuses the smash crunch, smaller and drier — same
319
            // vocabulary, lower stakes.
320
            audio!.Play(crunchClip, PlaybackOptions.Default with { Volume = 0.4f, Pitch = 1.2f });
×
321
        }
322

323
        if (events.Bumped)
×
324
        {
325
            var impact = Math.Clamp(events.BumpImpactSpeed / Tuning.MaxFallSpeed, 0f, 1f);
×
326
            audio!.Play(boingClip, PlaybackOptions.Default with
×
327
            {
×
328
                Volume = 0.6f + 0.3f * impact,
×
329
                Pitch = 0.9f + 0.25f * impact,
×
330
            });
×
331
        }
332
    }
×
333

334
    private void UpdateDeathBeat(in DeathSequenceState death, bool juiceEnabled)
335
    {
336
        if (!deathImpactPlayed && juiceEnabled)
×
337
        {
338
            audio!.Play(impactClip, PlaybackOptions.Default with { Volume = 0.9f });
×
339
            deathImpactPlayed = true;
×
340
        }
341

342
        // True silence: every loop and every ringing one-shot stops at once.
343
        if (death.AudioSilenced && !silenced)
×
344
        {
345
            audio!.StopAll();
×
346
            loopsActive = false;
×
347
            silenced = true;
×
348
        }
349
    }
×
350

351
    private static float MoveTowards(float current, float target, float maxDelta)
352
    {
353
        var delta = target - current;
×
354
        return Math.Abs(delta) <= maxDelta ? target : current + Math.Sign(delta) * maxDelta;
×
355
    }
356
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc