fix(engine): correct PCMs16 audio FourCC constant
All checks were successful
CI / build-and-test (push) Successful in 31s

The PCMs16 FourCC was 0x73334d50, which is the little-endian packing of
the bytes 'P','M','3','s' — not the intended 'P','C','M','s'. NDI packs
FourCCs little-endian (cf. BGRA = 0x41524742 = bytes 'B','G','R','A'),
so 'P','C','M','s' is 0x734D4350.

Effect of the bug: any legacy Teams/NDI sender delivering 16-bit PCM
audio fell through AudioPeakComputer.ComputePeak's switch to the unknown
branch and reported 0.0, so the operator VU meter read silent for those
sources even when audio was present. FLTP (the common NDI 6 format) was
unaffected. The existing tests passed because they referenced the symbol
rather than the literal, so the wrong value was self-consistent in-test.
This commit is contained in:
Zac Gaetano 2026-06-13 00:21:09 -04:00
parent 46c96c8434
commit 915510c938

View file

@ -5,29 +5,34 @@ namespace DragonISO.Engine.Pipeline;
/// <summary>
/// Computes a single peak amplitude (in [0.0, 1.0]) from one NDI audio frame.
///
/// NDI 6's preferred audio format is <c>NDIlib_FourCC_audio_type_FLTP</c> —
/// NDI 6's preferred audio format is <c>NDIlib_FourCC_audio_type_FLTP</c>
/// 32-bit IEEE float, planar (one contiguous chunk per channel). Values are
/// nominally normalized to [-1, 1]; brief excursions past 1 during transient
/// clipping are clamped here. We compute a max-absolute peak across every
/// sample of every channel rather than RMS so the UI VU bar reads
/// "loudest part of the buffer" — the same convention OBS / Resolve / Studio
/// "loudest part of the buffer" the same convention OBS / Resolve / Studio
/// Monitor use for their meters.
///
/// Pulled out of <see cref="NdiReceiver"/> so the math is unit-testable
/// without an NDI runtime; the heavy work (FLTP decode) runs entirely on
/// managed memory the caller has already copied across the P/Invoke
/// boundary, so tests exercise the same code path that production does.
///
/// FourCC values are packed little-endian, matching the NDI SDK and
/// <see cref="DragonISO.Engine.NdiInterop"/>'s video FourCCs: the first ASCII
/// character occupies the least-significant byte. So <c>'P','C','M','s'</c>
/// is <c>0x73</c>('s')<c>4D</c>('M')<c>43</c>('C')<c>50</c>('P') = 0x734D4350.
/// </summary>
public static class AudioPeakComputer
{
/// <summary>FourCC for FLTP — 32-bit float, planar layout. <c>'F','L','T','p'</c>.</summary>
/// <summary>FourCC for FLTP 32-bit float, planar layout. <c>'F','L','T','p'</c>.</summary>
public const uint FourCC_FLTP = 0x70544c46;
/// <summary>FourCC for FLT — 32-bit float, interleaved. <c>'F','L','T',' '</c>. Rarely seen but cheap to handle.</summary>
/// <summary>FourCC for FLT 32-bit float, interleaved. <c>'F','L','T',' '</c>. Rarely seen but cheap to handle.</summary>
public const uint FourCC_FLT = 0x20544c46;
/// <summary>FourCC for PCM 16-bit signed integer, interleaved. Some legacy senders use this. <c>'P','C','M','s'</c>.</summary>
public const uint FourCC_PCMs16 = 0x73334d50;
public const uint FourCC_PCMs16 = 0x734D4350;
/// <summary>
/// Returns the largest absolute sample value found in the buffer,
@ -38,7 +43,7 @@ public static class AudioPeakComputer
/// <param name="fourCC">The NDI audio FourCC (see the constants on this class).</param>
/// <param name="totalSamples">
/// Total sample count across all channels (e.g. <c>no_samples * no_channels</c>
/// for FLTP — channels are concatenated planes, but every sample contributes).
/// for FLTP channels are concatenated planes, but every sample contributes).
/// </param>
public static double ComputePeak(ReadOnlySpan<byte> data, uint fourCC, int totalSamples)
{
@ -48,7 +53,7 @@ public static class AudioPeakComputer
{
FourCC_FLTP or FourCC_FLT => ComputePeakFloat32(data, totalSamples),
FourCC_PCMs16 => ComputePeakInt16(data, totalSamples),
_ => 0.0, // unknown format — surface silence rather than throw
_ => 0.0, // unknown format surface silence rather than throw
};
}
@ -56,7 +61,7 @@ public static class AudioPeakComputer
{
// 4 bytes per sample. Cap by what's actually in the buffer in case
// the caller's totalSamples disagrees with the byte length (defensive
// — a misreporting source shouldn't take down the receiver loop).
// a misreporting source shouldn't take down the receiver loop).
var available = Math.Min(totalSamples, data.Length / 4);
if (available <= 0) return 0.0;