using System;
using TravelEar.Core;
using Xunit;
namespace TravelEar.Tests;
///
/// The phase-vocoder pitch shifter and its FFT (the voice mixer's Dry group Pitch Shifter and
/// the super-wet group pitch, catalogue 5.5).
///
public class PitchShifterTests
{
private const int Rate = 48_000;
private static float[] Sine(float hz, int samples, float amplitude = 0.5f)
{
var block = new float[samples];
for (var i = 0; i < samples; i++) block[i] = amplitude * MathF.Sin(2f * MathF.PI * hz * i / Rate);
return block;
}
private static float Peak(ReadOnlySpan block, int from)
{
var peak = 0f;
for (var i = from; i < block.Length; i++) peak = MathF.Max(peak, MathF.Abs(block[i]));
return peak;
}
// [unit->REQ-MIXER-RESYNTH]
[Fact]
public void Fft_round_trip_scales_by_the_length()
{
const int n = 16;
var buffer = new float[2 * n];
var expected = new float[2 * n];
for (var i = 0; i < n; i++) { buffer[2 * i] = i % 3 - 1f; buffer[2 * i + 1] = 0.25f * i; }
Array.Copy(buffer, expected, buffer.Length);
Fft.Transform(buffer, n, -1);
Fft.Transform(buffer, n, 1);
for (var i = 0; i < 2 * n; i++) Assert.Equal(expected[i] * n, buffer[i], 3);
}
// [unit->REQ-MIXER-RESYNTH]
[Fact]
public void Fft_finds_a_pure_tone_in_its_bin()
{
const int n = 1024;
var tone = Sine(Rate * 8f / n, n); // exactly bin 8
Assert.Equal(Rate * 8f / n, Fft.DominantFrequency(tone, n, Rate), 1);
}
// [unit->REQ-MIXER-RESYNTH]
[Fact]
public void Ratio_one_passes_the_tone_at_its_pitch_and_near_its_level()
{
var shifter = new PitchShifter(Rate);
var block = Sine(440f, Rate);
shifter.Process(block, 1f);
var settled = block.AsSpan(Rate / 2);
Assert.InRange(Fft.DominantFrequency(settled, 4096, Rate), 420f, 460f);
Assert.InRange(Peak(block, Rate / 2), 0.4f, 0.6f);
Assert.Equal(768, shifter.LatencySamples);
}
// [unit->REQ-MIXER-RESYNTH]
[Theory]
[InlineData(0.5f, 220f)]
[InlineData(0.75f, 330f)]
[InlineData(1.5f, 660f)]
public void Ratio_moves_the_tone_by_the_ratio(float ratio, float expectedHz)
{
var shifter = new PitchShifter(Rate);
var block = Sine(440f, Rate);
shifter.Process(block, ratio);
var hz = Fft.DominantFrequency(block.AsSpan(Rate / 2), 8192, Rate);
Assert.InRange(hz, expectedHz - 12f, expectedHz + 12f);
// Downward ratios (the game's only use: VoicePitch <= 1) keep the level; an upward ratio
// leaves target bins empty in the bin re-mapping and comes out quieter.
Assert.InRange(Peak(block, Rate / 2), ratio > 1f ? 0.15f : 0.35f, 0.75f);
}
// [unit->REQ-MIXER-RESYNTH]
[Fact]
public void Silence_in_is_silence_out_and_reset_clears_the_fifos()
{
var shifter = new PitchShifter(Rate);
var block = Sine(440f, 4096);
shifter.Process(block, 0.5f);
shifter.Reset();
var silence = new float[4096];
shifter.Process(silence, 0.5f);
Assert.Equal(0f, Peak(silence, 0));
}
}