async Task Load(int id)
        {
            // Get a test from the API
            ABTestData aBTestData = await TJBarnesService.GetHttpClient()
                                    .GetFromJsonAsync <ABTestData>("api/abtests/" + id.ToString());

            // Use the Deserializer method of the JsonSerializer class (in the System.Text.Json namespace) to create
            // a BlowSetCore object for each of A and B
            BlowSetCore blowSetCoreA = JsonSerializer.Deserialize <BlowSetCore>(aBTestData.ABTestSpecA);
            BlowSetCore blowSetCoreB = JsonSerializer.Deserialize <BlowSetCore>(aBTestData.ABTestSpecB);

            testSpec.AHasErrors = blowSetCoreA.HasErrors;

            // Create a BlowSet object from the BlowSetCore object for A
            blowSetA = new BlowSet(blowSetCoreA.Stage, blowSetCoreA.NumRows, blowSetCoreA.TenorWeight,
                                   blowSetCoreA.ErrorType, blowSetCoreA.HasErrors);

            // Use an audioIdSuffix of "b" for this blowset
            blowSetA.LoadBlows(blowSetCoreA, "a");

            blowSetA.SetUnstruck();

            // Create a BlowSet object from the BlowSetCore object for B
            blowSetB = new BlowSet(blowSetCoreB.Stage, blowSetCoreB.NumRows, blowSetCoreB.TenorWeight,
                                   blowSetCoreB.ErrorType, blowSetCoreB.HasErrors);

            // Use an audioIdSuffix of "b" for this blowset
            blowSetB.LoadBlows(blowSetCoreB, "b");

            blowSetB.SetUnstruck();

            // Update drop down boxes on screen
            // Use BlowSetA - by definition the following properties for BlowSetA and BlowSetB will be the same
            testSpec.Stage       = blowSetA.Stage;
            testSpec.TenorWeight = blowSetA.TenorWeight;
            testSpec.ErrorType   = blowSetA.ErrorType;

            // Set up test spec-dependent elements of the screen object
            int baseGap = BaseGaps.BaseGap(testSpec.Stage, testSpec.TenorWeight, 1);

            testSpec.BaseGap = baseGap;

            // Set the timing for the animation (when not showing the bells)
            screenA.AnimationDuration = blowSetA.Blows.Last().GapCumulative + 1000;
            screenB.AnimationDuration = blowSetB.Blows.Last().GapCumulative + 1000;
            testSpec.ResultEntered    = false;

            testSpec.ShowGaps = false;
            StateHasChanged();
        }
        async Task Load(int id)
        {
            // Get a test from the API
            GapTestData gapTestData = await TJBarnesService.GetHttpClient().
                                      GetFromJsonAsync <GapTestData>("api/gaptests/" + id.ToString());

            // Use the Deserializer method of the JsonSerializer class (in the System.Text.Json namespace) to create
            // a BlowSetCore object
            BlowSetCore blowSetCore = JsonSerializer.Deserialize <BlowSetCore>(gapTestData.GapTestSpec);

            // Now create a BlowSet object from the BlowSetCore object
            blowSet = new BlowSet(blowSetCore.Stage, blowSetCore.NumRows, blowSetCore.TenorWeight,
                                  blowSetCore.ErrorType, true);

            // No need for an audio suffix in a Gap test (this is used to distinguish A and B in an A/B test)
            blowSet.LoadBlows(blowSetCore, string.Empty);
            blowSet.SetUnstruck();

            // Update drop down boxes on screen
            testSpec.Stage       = blowSet.Stage;
            testSpec.TenorWeight = blowSet.TenorWeight;
            testSpec.NumRows     = blowSet.NumRows;

            // Set up test spec-dependent elements of the screen object
            int baseGap = BaseGaps.BaseGap(testSpec.Stage, testSpec.TenorWeight, 1);

            testSpec.BaseGap = baseGap;
            testSpec.GapMin  = 20;

            // If test bell is 1st's place of a handstroke row, need to adjust GapMax to have a higher value
            // because of the handstroke gap
            if (blowSet.Blows.Last().IsHandstroke == true && blowSet.Blows.Last().Place == 1)
            {
                testSpec.GapMax = Convert.ToInt32(Math.Round(((double)testSpec.BaseGap * 3) / 50)) * 50;
            }
            else
            {
                testSpec.GapMax = Convert.ToInt32(Math.Round(((double)baseGap * 2) / 50)) * 50;
            }

            testSpec.ShowGaps = false;
            StateHasChanged();
        }
        void Create()
        {
            Block testBlock = new Block(testSpec.Stage, testSpec.NumRows);

            testBlock.CreateRandomBlock();

            // Set place to be the test place
            int testPlace;

            testPlace = testSpec.Stage + (testSpec.Stage % 2);

            if (testSpec.TestBellLoc != 1)
            {
                Random rand = new Random();
                testPlace = rand.Next(1, testPlace + 1);
            }

            blowSet = new BlowSet(testSpec.Stage, testSpec.NumRows, testSpec.TenorWeight, testSpec.ErrorType, true);

            // No need for an audio suffix in a Gap test (this is used to distinguish A and B in an A/B test)
            blowSet.PopulateBlows(testBlock, testPlace, string.Empty);
            blowSet.CreateRandomSpacing(testSpec.ErrorSize, Constants.Rounding);
            blowSet.SetUnstruck();

            // Set up test spec-dependent elements of the screen object
            // When practicing in a Gap Test, gaps are rounded to the nearest 10ms so that bells will align
            // if zero gap error is selected
            int baseGap = BaseGaps.BaseGap(testSpec.Stage, testSpec.TenorWeight, 10);

            testSpec.BaseGap = baseGap;
            testSpec.GapMin  = 20;

            // If test bell is 1st's place of a handstroke row, need to adjust GapMax to have a higher value
            // because of the handstroke gap
            if (testSpec.NumRows % 2 == 1 && testPlace == 1)
            {
                testSpec.GapMax = Convert.ToInt32(Math.Round(((double)testSpec.BaseGap * 3) / 50)) * 50;
            }
            else
            {
                testSpec.GapMax = Convert.ToInt32(Math.Round(((double)baseGap * 2) / 50)) * 50;
            }
        }
        async Task PlayAsync()
        {
            int initialDelay = 1000;

            if (screen.PlayLabel == "Play")
            {
                // Change test bell color to disabled color - can't adjust gap during play
                blowSet.Blows.Last().BellColor = Constants.DisabledUnstruckTestBellColor;

                DateTime strikeTime = DateTime.Now;

                foreach (Blow blow in blowSet.Blows)
                {
                    // Set strike times
                    strikeTime      = strikeTime.AddMilliseconds(blow.Gap);
                    blow.StrikeTime = strikeTime;
                }

                cancellationTokenSource = new CancellationTokenSource();
                cancellationToken       = cancellationTokenSource.Token;

                screen.PlayLabel             = "Stop";
                testSpec.ControlsDisabled    = true;
                testSpec.TenorWeightDisabled = true;

                TimeSpan delay;
                int      delayMs;

                foreach (Blow blow in blowSet.Blows)
                {
                    delay   = blow.StrikeTime - DateTime.Now;
                    delayMs = Convert.ToInt32(delay.TotalMilliseconds);

                    if (delayMs > 0)
                    {
                        await Task.Delay(delayMs, cancellationToken);
                    }

                    if (cancellationToken.IsCancellationRequested)
                    {
                        break;
                    }

                    // Change bell color
                    if (Device.DeviceLoad == DeviceLoad.High)
                    {
                        blow.BellColor = Constants.StruckBellColor;

                        // Confirmed this is needed here
                        StateHasChanged();
                    }

                    // Strike bell
                    await JSRuntime.InvokeVoidAsync("PlayBellAudio", blow.AudioId);
                }
            }
            else if (screen.PlayLabel == "Stop")
            {
                cancellationTokenSource.Cancel();
                initialDelay = 0;
            }

            // Initial delay
            if (initialDelay > 0)
            {
                await Task.Delay(initialDelay);
            }

            // Start spinner
            screen.PlayDisabled   = true;
            screen.PlayLabel      = "Wait";
            screen.SpinnerPlaying = true;
            StateHasChanged();

            // Wait 2.6 or 1.6 further seconds for the sound to finish, depending on whether arriving here
            // on stop or end of play
            await Task.Delay(2600 - initialDelay);

            // Reset play button
            screen.SpinnerPlaying = false;
            screen.PlayLabel      = "Play";
            screen.PlayDisabled   = false;

            // Reset screen
            if (Device.DeviceLoad == DeviceLoad.High)
            {
                blowSet.SetUnstruck();
            }
            else
            {
                blowSet.Blows.Last().BellColor = Constants.UnstruckTestBellColor;
            }

            testSpec.ControlsDisabled    = false;
            testSpec.TenorWeightDisabled = TenorWeightSelect.TenorWeightDisabled(testSpec.Stage);
            StateHasChanged();
        }
        async Task PlayAsyncB()
        {
            int initialDelay = 1000;

            if (screenB.PlayLabel == "Play B")
            {
                DateTime strikeTime = DateTime.Now;

                foreach (Blow blow in blowSetB.Blows)
                {
                    // Set strike times
                    strikeTime      = strikeTime.AddMilliseconds(blow.Gap);
                    blow.StrikeTime = strikeTime;
                }

                cancellationTokenSource = new CancellationTokenSource();
                cancellationToken       = cancellationTokenSource.Token;

                screenB.PlayLabel            = "Stop B";
                testSpec.ControlsDisabled    = true;
                testSpec.TenorWeightDisabled = true;
                screenA.PlayDisabled         = true;

                // Start the animation if not showing the bells
                if (testSpec.ShowGaps == false)
                {
                    screenB.RunAnimation = true;
                }

                TimeSpan delay;
                int      delayMs;

                foreach (Blow blow in blowSetB.Blows)
                {
                    delay   = blow.StrikeTime - DateTime.Now;
                    delayMs = Convert.ToInt32(delay.TotalMilliseconds);

                    if (delayMs > 0)
                    {
                        await Task.Delay(delayMs, cancellationToken);
                    }

                    if (cancellationToken.IsCancellationRequested)
                    {
                        break;
                    }

                    // Change bell color
                    if (Device.DeviceLoad == DeviceLoad.High && testSpec.ShowGaps == true)
                    {
                        blow.BellColor = Constants.StruckBellColor;

                        // Confirmed this is needed here
                        StateHasChanged();
                    }

                    // Strike bell
                    await JSRuntime.InvokeVoidAsync("PlayBellAudio", blow.AudioId);
                }
            }
            else if (screenB.PlayLabel == "Stop B")
            {
                cancellationTokenSource.Cancel();
                initialDelay = 0;
            }

            // Initial delay
            if (initialDelay > 0)
            {
                // Test was not stopped
                await Task.Delay(initialDelay);
            }
            else
            {
                // Reset animation immediately when test is stopped
                if (testSpec.ShowGaps == false)
                {
                    screenB.RunAnimation = false;
                }
            }

            // Start spinner
            screenB.PlayDisabled   = true;
            screenB.PlayLabel      = "Wait";
            screenB.SpinnerPlaying = true;
            StateHasChanged();

            // Wait 2.6 or 1.6 further seconds for the sound to finish, depending on whether arriving here
            // on stop or end of play
            await Task.Delay(2600 - initialDelay);

            // Reset animation, unless it was already reset earlier after a stop
            if (initialDelay > 0 && testSpec.ShowGaps == false)
            {
                screenB.RunAnimation = false;
            }

            // Reset play button
            screenB.SpinnerPlaying = false;
            screenB.PlayLabel      = "Play B";
            screenB.PlayDisabled   = false;

            // Reset screen
            if (Device.DeviceLoad == DeviceLoad.High && testSpec.ShowGaps == true)
            {
                blowSetB.SetUnstruck();
            }

            testSpec.ControlsDisabled    = false;
            testSpec.TenorWeightDisabled = TenorWeightSelect.TenorWeightDisabled(testSpec.Stage);
            screenA.PlayDisabled         = false;
            StateHasChanged();
        }
        void Create()
        {
            // Choose whether A or B will have the errors
            Random rand = new Random();

            testSpec.AHasErrors = rand.Next(0, 2) == 0;

            // Create the test block
            Block testBlock = new Block(testSpec.Stage, testSpec.NumRows);

            testBlock.CreateRandomBlock();

            blowSetA = new BlowSet(testSpec.Stage, testSpec.NumRows, testSpec.TenorWeight,
                                   testSpec.ErrorType, testSpec.AHasErrors);
            blowSetA.PopulateBlows(testBlock, testSpec.TestBellLoc, "a");

            // No rounding in an A/B test
            blowSetA.CreateEvenSpacing(1);
            blowSetA.SetUnstruck();

            blowSetB = new BlowSet(testSpec.Stage, testSpec.NumRows, testSpec.TenorWeight,
                                   testSpec.ErrorType, !testSpec.AHasErrors);
            blowSetB.PopulateBlows(testBlock, testSpec.TestBellLoc, "b");

            // No rounding in an A/B test
            blowSetB.CreateEvenSpacing(1);
            blowSetB.SetUnstruck();

            if (testSpec.AHasErrors == true)
            {
                if (testSpec.ErrorType == 1)
                {
                    blowSetA.CreateStrikingError(testSpec.ErrorSize);
                }
                else
                {
                    blowSetA.CreateCompassError(testSpec.ErrorSize);
                }
            }
            else
            {
                if (testSpec.ErrorType == 1)
                {
                    blowSetB.CreateStrikingError(testSpec.ErrorSize);
                }
                else
                {
                    blowSetB.CreateCompassError(testSpec.ErrorSize);
                }
            }

            // Set up test spec-dependent elements of the screen object
            int baseGap = BaseGaps.BaseGap(testSpec.Stage, testSpec.TenorWeight, 1);

            testSpec.BaseGap = baseGap;

            // Set the timing for the animation (when not showing the bells)
            screenA.AnimationDuration = blowSetA.Blows.Last().GapCumulative + 1000;
            screenB.AnimationDuration = blowSetB.Blows.Last().GapCumulative + 1000;
            testSpec.ResultEntered    = false;

            testSpec.ShowGaps = false;
        }