| // Copyright 2020 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "src/media/audio/lib/test/hermetic_fidelity_test.h" |
| |
| #include <fuchsia/media/cpp/fidl.h> |
| #include <fuchsia/thermal/cpp/fidl.h> |
| #include <lib/syslog/cpp/macros.h> |
| #include <zircon/types.h> |
| |
| #include <array> |
| #include <cmath> |
| #include <iomanip> |
| #include <map> |
| #include <set> |
| #include <string> |
| |
| #include <test/thermal/cpp/fidl.h> |
| |
| #include "lib/zx/time.h" |
| #include "src/media/audio/lib/analysis/analysis.h" |
| #include "src/media/audio/lib/analysis/generators.h" |
| #include "src/media/audio/lib/format/audio_buffer.h" |
| #include "src/media/audio/lib/test/renderer_shim.h" |
| |
| using ASF = fuchsia::media::AudioSampleFormat; |
| |
| namespace media::audio::test { |
| |
| namespace { |
| struct ResultsIndex { |
| HermeticFidelityTest::RenderPath path; |
| size_t channel; |
| uint32_t thermal_state; |
| |
| bool operator<(const ResultsIndex& rhs) const { |
| return std::tie(path, channel, thermal_state) < |
| std::tie(rhs.path, rhs.channel, rhs.thermal_state); |
| } |
| }; |
| |
| }; // namespace |
| |
| // For each path|channel|thermal_state, we maintain two results arrays: Frequency Response and |
| // Signal-to-Noise-and-Distortion (sinad). A map of array results is saved as a function-local |
| // static variable. If kRetainWorstCaseResults is set, we persist results across repeated test runs. |
| // |
| // Note: two test cases must not collide on the same path/channel/thermal_state. Thus, this must be |
| // refactored if two test cases need to specify the same path|output_channels|thermal_state (an |
| // example would be Dynamic Range testing -- the same measurements, but at different volumes). |
| |
| // static |
| // Retrieve (initially allocating, if necessary) the array of level results for this path|channel. |
| std::array<double, HermeticFidelityTest::kNumReferenceFreqs>& HermeticFidelityTest::level_results( |
| RenderPath path, size_t channel, uint32_t thermal_state) { |
| // Allocated only when first needed, and automatically cleaned up when process exits. |
| static auto results_level_db = |
| new std::map<ResultsIndex, std::array<double, HermeticFidelityTest::kNumReferenceFreqs>>(); |
| |
| ResultsIndex index{ |
| .path = path, |
| .channel = channel, |
| .thermal_state = thermal_state, |
| }; |
| if (results_level_db->find(index) == results_level_db->end()) { |
| auto& results = (*results_level_db)[index]; |
| std::fill(results.begin(), results.end(), std::numeric_limits<double>::infinity()); |
| } |
| |
| return results_level_db->find(index)->second; |
| } |
| |
| // static |
| // Retrieve (initially allocating, if necessary) the array of sinad results for this path|channel. |
| // A map of these array results is saved as a function-local static variable. |
| std::array<double, HermeticFidelityTest::kNumReferenceFreqs>& HermeticFidelityTest::sinad_results( |
| RenderPath path, size_t channel, uint32_t thermal_state) { |
| // Allocated only when first needed, and automatically cleaned up when process exits. |
| static auto results_sinad_db = |
| new std::map<ResultsIndex, std::array<double, HermeticFidelityTest::kNumReferenceFreqs>>(); |
| |
| ResultsIndex index{ |
| .path = path, |
| .channel = channel, |
| .thermal_state = thermal_state, |
| }; |
| if (results_sinad_db->find(index) == results_sinad_db->end()) { |
| auto& results = (*results_sinad_db)[index]; |
| std::fill(results.begin(), results.end(), std::numeric_limits<double>::infinity()); |
| } |
| |
| return results_sinad_db->find(index)->second; |
| } |
| |
| void HermeticFidelityTest::SetUp() { |
| HermeticPipelineTest::SetUp(); |
| |
| // We save input|output files if requested. Ensure the requested frequency is one we measure. |
| save_fidelity_wav_files_ = HermeticPipelineTest::save_input_and_output_files_; |
| if (save_fidelity_wav_files_) { |
| bool requested_frequency_found = false; |
| for (auto freq : kReferenceFrequencies) { |
| if (freq == kFrequencyForSavedWavFiles) { |
| requested_frequency_found = true; |
| break; |
| } |
| } |
| |
| if (!requested_frequency_found) { |
| FX_LOGS(WARNING) << kFrequencyForSavedWavFiles |
| << " is not in the frequency list, a WAV file cannot be saved"; |
| save_fidelity_wav_files_ = false; |
| } |
| } |
| } |
| |
| // Translate real-world frequencies to frequencies that fit perfectly into our signal buffer. |
| // Internal frequencies must be integers, so we don't need to Window the output before frequency |
| // analysis. We use buffer size and frame rate. Thus, when measuring real-world frequency 2000 Hz |
| // with buffer size 65536 at frame rate 96 kHz, we use the internal frequency 1365, rather than |
| // 1365.333... -- translating to a real-world frequency of 1999.5 Hz (this is not a problem). |
| // |
| // We also want these internal frequencies to have fewer common factors with our buffer size and |
| // frame rates, as this can mask problems where previous buffer sections are erroneously repeated. |
| // So if a computed internal frequency is not integral, we use the odd neighbor, rather than round. |
| void HermeticFidelityTest::TranslateReferenceFrequencies(uint32_t device_frame_rate) { |
| for (auto freq_idx = 0u; freq_idx < kReferenceFrequencies.size(); ++freq_idx) { |
| double internal_freq = |
| static_cast<double>(kReferenceFrequencies[freq_idx] * kFreqTestBufSize) / device_frame_rate; |
| uint32_t floor_freq = std::floor(internal_freq); |
| uint32_t ceil_freq = std::ceil(internal_freq); |
| translated_ref_freqs_[freq_idx] = (floor_freq % 2) ? floor_freq : ceil_freq; |
| } |
| } |
| |
| // Retrieve the number of thermal subscribers, and set them all to the specified thermal state. |
| // thermal_test_control is synchronous: when SetThermalState returns, a change is committed. |
| zx_status_t HermeticFidelityTest::ConfigurePipelineForThermal(uint32_t state) { |
| constexpr size_t kMaxRetries = 100; |
| constexpr zx::duration kRetryPeriod = zx::msec(10); |
| |
| std::optional<size_t> audio_subscriber; |
| |
| std::vector<::test::thermal::SubscriberInfo> subscriber_data; |
| // We might query thermal::test::Control before AudioCore has subscribed, so wait for it. |
| for (size_t retries = 0u; retries < kMaxRetries; ++retries) { |
| auto status = thermal_test_control()->GetSubscriberInfo(&subscriber_data); |
| if (status != ZX_OK) { |
| ADD_FAILURE() << "GetSubscriberInfo failed: " << status; |
| return status; |
| } |
| |
| // There is only one thermal subscriber for audio; there might be others of non-audio types. |
| for (auto subscriber_num = 0u; subscriber_num < subscriber_data.size(); ++subscriber_num) { |
| if (subscriber_data[subscriber_num].actor_type == fuchsia::thermal::ActorType::AUDIO) { |
| audio_subscriber = subscriber_num; |
| break; |
| } |
| } |
| if (audio_subscriber.has_value()) { |
| break; |
| } |
| zx::nanosleep(zx::deadline_after(kRetryPeriod)); |
| } |
| |
| if (!audio_subscriber.has_value()) { |
| ADD_FAILURE() << "No audio-related thermal subscribers. " |
| "Don't set thermal_state if a pipeline has no thermal support"; |
| return ZX_ERR_TIMED_OUT; |
| } |
| |
| auto max_state = subscriber_data[audio_subscriber.value()].num_thermal_states - 1; |
| if (state > max_state) { |
| ADD_FAILURE() << "Subscriber cannot be put into thermal state " << state |
| << " (max: " << max_state << ")"; |
| return ZX_ERR_NOT_SUPPORTED; |
| } |
| |
| auto status = this->thermal_test_control()->SetThermalState(audio_subscriber.value(), state); |
| if (status != ZX_OK) { |
| ADD_FAILURE() << "SetThermalState failed: " << status; |
| return status; |
| } |
| |
| return ZX_OK; |
| } |
| |
| template <ASF InputFormat, ASF OutputFormat> |
| AudioBuffer<OutputFormat> HermeticFidelityTest::GetRendererOutput( |
| TypedFormat<InputFormat> input_format, size_t input_buffer_frames, RenderPath path, |
| AudioBuffer<InputFormat> input, VirtualOutput<OutputFormat>* device) { |
| FX_CHECK(input_format.frames_per_second() == 96000); |
| |
| fuchsia::media::AudioRenderUsage usage = fuchsia::media::AudioRenderUsage::MEDIA; |
| |
| if (path == RenderPath::Communications) { |
| usage = fuchsia::media::AudioRenderUsage::COMMUNICATION; |
| } |
| |
| // Render input such that first input frame will be rendered into first ring buffer frame. |
| if (path == RenderPath::Ultrasound) { |
| auto renderer = CreateUltrasoundRenderer(input_format, input_buffer_frames, true); |
| auto packets = renderer->AppendPackets({&input}); |
| |
| renderer->PlaySynchronized(this, device, 0); |
| renderer->WaitForPackets(this, packets); |
| } else { |
| auto renderer = CreateAudioRenderer(input_format, input_buffer_frames, usage); |
| auto packets = renderer->AppendPackets({&input}); |
| |
| renderer->PlaySynchronized(this, device, 0); |
| renderer->WaitForPackets(this, packets); |
| } |
| |
| // Extract it from the VAD ring-buffer. |
| return device->SnapshotRingBuffer(); |
| } |
| |
| template <ASF InputFormat, ASF OutputFormat> |
| void HermeticFidelityTest::DisplaySummaryResults( |
| const TestCase<InputFormat, OutputFormat>& test_case) { |
| // Loop by channel, displaying summary results, in a separate loop from checking each result. |
| for (const auto& channel_spec : test_case.channels_to_measure) { |
| // Show results in tabular forms, for easy copy into hermetic_fidelity_results.cc. |
| const auto& chan_level_results_db = |
| level_results(test_case.path, channel_spec.channel, test_case.thermal_state.value_or(0)); |
| printf("\n\tFull-spectrum Frequency Response - %s - output channel %zu", |
| test_case.test_name.c_str(), channel_spec.channel); |
| for (auto freq_idx = 0u; freq_idx < kNumReferenceFreqs; ++freq_idx) { |
| printf("%s%8.3f,", (freq_idx % 10 == 0 ? "\n" : ""), |
| floor(chan_level_results_db[freq_idx] / kFidelityDbTolerance) * kFidelityDbTolerance); |
| } |
| printf("\n"); |
| |
| const auto& chan_sinad_results_db = |
| sinad_results(test_case.path, channel_spec.channel, test_case.thermal_state.value_or(0)); |
| printf("\n\tSignal-to-Noise and Distortion - %s - output channel %zu", |
| test_case.test_name.c_str(), channel_spec.channel); |
| for (auto freq_idx = 0u; freq_idx < kNumReferenceFreqs; ++freq_idx) { |
| printf("%s%8.3f,", (freq_idx % 10 == 0 ? "\n" : ""), |
| floor(chan_sinad_results_db[freq_idx] / kFidelityDbTolerance) * kFidelityDbTolerance); |
| } |
| printf("\n\n"); |
| } |
| } |
| |
| template <ASF InputFormat, ASF OutputFormat> |
| void HermeticFidelityTest::VerifyResults(const TestCase<InputFormat, OutputFormat>& test_case) { |
| // Loop by channel_to_measure |
| for (const auto& channel_spec : test_case.channels_to_measure) { |
| const auto& chan_level_results_db = |
| level_results(test_case.path, channel_spec.channel, test_case.thermal_state.value_or(0)); |
| for (auto freq_idx = 0u; freq_idx < kNumReferenceFreqs; ++freq_idx) { |
| EXPECT_GE(chan_level_results_db[freq_idx], |
| channel_spec.freq_resp_lower_limits_db[freq_idx] - kFidelityDbTolerance) |
| << " Channel " << channel_spec.channel << ", FreqResp [" << std::setw(2) << freq_idx |
| << "] (" << std::setw(5) << kReferenceFrequencies[freq_idx] |
| << " Hz): " << std::setprecision(7) |
| << floor(chan_level_results_db[freq_idx] / kFidelityDbTolerance) * kFidelityDbTolerance; |
| } |
| |
| const auto& chan_sinad_results_db = |
| sinad_results(test_case.path, channel_spec.channel, test_case.thermal_state.value_or(0)); |
| for (auto freq_idx = 0u; freq_idx < kNumReferenceFreqs; ++freq_idx) { |
| EXPECT_GE(chan_sinad_results_db[freq_idx], |
| channel_spec.sinad_lower_limits_db[freq_idx] - kFidelityDbTolerance) |
| << " Channel " << channel_spec.channel << ", SINAD [" << std::setw(2) << freq_idx |
| << "] (" << std::setw(5) << kReferenceFrequencies[freq_idx] |
| << " Hz): " << std::setprecision(7) |
| << floor(chan_sinad_results_db[freq_idx] / kFidelityDbTolerance) * kFidelityDbTolerance; |
| } |
| } |
| } |
| |
| // Additional fidelity assessments, potentially added in the future: |
| // (1) Cross-talk (FreqResp/Sinad across channels) |
| // This could identify issues masked by our collapse to mono for final device output. |
| // (2) Dynamic range (1kHz input at -30/60/90 db: measure level, sinad. Overall gain sensitivity) |
| // This should clearly show the impact of dynamic compression in the effects chain. |
| // (3) Assess the e2e input data path (from device to capturer) |
| // Included for completeness: we apply no capture effects; should equal audio_fidelity_tests. |
| template <ASF InputFormat, ASF OutputFormat> |
| void HermeticFidelityTest::Run( |
| const HermeticFidelityTest::TestCase<InputFormat, OutputFormat>& tc) { |
| // Compute input signal length: time to ramp in, enough to analyze, time to ramp out. |
| size_t input_signal_frames = |
| std::ceil(static_cast<double>(kFreqTestBufSize * tc.input_format.frames_per_second()) / |
| tc.output_format.frames_per_second()); |
| // Compute the renderer payload buffer size (including pre-signal and post-signal silence). |
| // TODO(mpuryear): revisit, once pipeline automatically handles filter_width by feeding silence. |
| auto input_signal_start = tc.pipeline.pos_filter_width; |
| auto input_buffer_frames = input_signal_start + tc.pipeline.neg_filter_width + |
| input_signal_frames + tc.pipeline.pos_filter_width + |
| tc.pipeline.neg_filter_width; |
| |
| // TODO(mpuryear): support source frequencies other than 96k, when necessary |
| FX_CHECK(tc.input_format.frames_per_second() == 96000) |
| << "For now, non-96k renderer frame rates are disallowed in this test"; |
| // Translate from input frame number to output frame number. |
| auto input_frame_to_output_frame = [](size_t input_frame) { return input_frame; }; |
| |
| auto input_type_mono = |
| Format::Create<InputFormat>(1, tc.input_format.frames_per_second()).take_value(); |
| auto bookend_silence = GenerateSilentAudio(input_type_mono, input_signal_start); |
| auto total_input_frames = input_signal_start + tc.pipeline.neg_filter_width + |
| input_signal_frames + tc.pipeline.pos_filter_width + input_signal_start; |
| |
| // We create the AudioBuffer later. Ensure no out-of-range channels are requested to play. |
| for (const auto& channel : tc.channels_to_play) { |
| ASSERT_LT(channel, tc.input_format.channels()) << "Cannot play out-of-range input channel"; |
| } |
| |
| // |
| // Then, calculate the length of the output signal and set up the VAD, with a 1-sec ring-buffer. |
| auto output_buffer_frames = input_frame_to_output_frame(input_buffer_frames); |
| FX_CHECK(output_buffer_frames < tc.output_format.frames_per_second()); |
| FX_CHECK(tc.output_format.frames_per_second() == 96000) |
| << "For now, non-96k device frame rates are disallowed in this test"; |
| auto device = CreateOutput(AUDIO_STREAM_UNIQUE_ID_BUILTIN_SPEAKERS, tc.output_format, |
| tc.output_format.frames_per_second(), std::nullopt, |
| tc.pipeline.output_device_gain_db); |
| |
| // Generate the device-rate-specific internal frequency values for our power-of-two-sized buffer. |
| TranslateReferenceFrequencies(tc.output_format.frames_per_second()); |
| |
| if (tc.thermal_state.has_value()) { |
| if (ConfigurePipelineForThermal(tc.thermal_state.value()) != ZX_OK) { |
| return; |
| } |
| } |
| |
| // |
| // Now iterate through the spectrum, completely processing one frequency at a time. |
| for (auto freq_idx = 0u; freq_idx < kNumReferenceFreqs; ++freq_idx) { |
| auto freq = translated_ref_freqs_[freq_idx]; // The frequency within our power-of-two buffer |
| auto freq_for_display = kReferenceFrequencies[freq_idx]; |
| |
| // Write input signal to input buffer. Start with silence, for pre-ramping; this aligns the |
| // input and output WAV files (if enabled). Append signal to account for full ramp-in and |
| // ramp-out. Finally, include trailing silence to flush out any cached values and show decay. |
| auto signal_section = GenerateCosineAudio(input_type_mono, input_signal_frames, freq); |
| auto input_mono = bookend_silence; |
| input_mono.Append(AudioBufferSlice(&signal_section)); |
| input_mono.Append(AudioBufferSlice( |
| &signal_section, 0, tc.pipeline.neg_filter_width + tc.pipeline.pos_filter_width)); |
| input_mono.Append(AudioBufferSlice(&bookend_silence)); |
| FX_CHECK(input_mono.NumFrames() == total_input_frames) |
| << "Miscalculated input_mono length: testcode error"; |
| |
| auto silence_mono = GenerateSilentAudio(input_type_mono, total_input_frames); |
| |
| std::vector<AudioBufferSlice<InputFormat>> channels; |
| for (auto play_channel = 0u; play_channel < tc.input_format.channels(); ++play_channel) { |
| if (tc.channels_to_play.find(play_channel) != tc.channels_to_play.end()) { |
| channels.push_back(AudioBufferSlice(&input_mono)); |
| } else { |
| channels.push_back(AudioBufferSlice(&silence_mono)); |
| } |
| } |
| auto input = AudioBuffer<InputFormat>::Interleave(channels); |
| FX_CHECK(input.NumFrames() == total_input_frames); |
| |
| if constexpr (kDebugInputBuffer) { |
| // We construct the input buffer in pieces. If signals don't align at these seams, it causes |
| // distortion. For debugging, show these "seam" locations in the input buffer we created. |
| std::string tag = "\nInput buffer for " + std::to_string(freq_for_display) + " Hz [" + |
| std::to_string(freq_idx) + "]"; |
| input.Display(0, 16, tag); |
| input.Display(input_signal_start - 16, input_signal_start + 16, "Start of input signal"); |
| input.Display(input_signal_start + input_signal_frames - 16, |
| input_signal_start + input_signal_frames + 16, |
| "End of input signal; start of additional section for ramp in/out"); |
| input.Display(input_signal_start + tc.pipeline.neg_filter_width + input_signal_frames + |
| tc.pipeline.pos_filter_width - 16, |
| input_signal_start + tc.pipeline.neg_filter_width + input_signal_frames + |
| tc.pipeline.pos_filter_width + 16, |
| "End of additional ramp section"); |
| input.Display(input.NumFrames() - 16, input.NumFrames(), "End of input buffer"); |
| } |
| |
| // Save off the input file, if requested. |
| if (save_fidelity_wav_files_) { |
| // We shouldn't save files for ALL frequencies -- just save the files for this frequency. |
| if (freq_for_display == kFrequencyForSavedWavFiles) { |
| std::string test_name = tc.test_name + "_" + std::to_string(freq_for_display) + "hz"; |
| HermeticPipelineTest::WriteWavFile<InputFormat>(test_name, "input", |
| AudioBufferSlice(&input)); |
| } |
| } |
| |
| // Set up the renderer, run it and retrieve the output. |
| auto ring_buffer = |
| GetRendererOutput(tc.input_format, input_buffer_frames, tc.path, input, device); |
| |
| // Loop here on each channel to measure... |
| for (const auto& channel_spec : tc.channels_to_measure) { |
| auto ring_buffer_chan = AudioBufferSlice(&ring_buffer).GetChannel(channel_spec.channel); |
| |
| // Analyze the results |
| auto output_analysis_start = |
| input_frame_to_output_frame(input_signal_start + tc.pipeline.neg_filter_width); |
| auto output = AudioBufferSlice(&ring_buffer_chan, output_analysis_start, |
| output_analysis_start + kFreqTestBufSize); |
| |
| if constexpr (kDebugOutputBuffer) { |
| std::string tag = "\nOutput buffer for " + std::to_string(freq_for_display) + " Hz [" + |
| std::to_string(freq_idx) + "], channel " + |
| std::to_string(channel_spec.channel); |
| // For debugging, show critical locations in the output buffer we retrieved. |
| ring_buffer_chan.Display(output_analysis_start - 16, output_analysis_start + 16, tag); |
| ring_buffer_chan.Display(output_analysis_start + kFreqTestBufSize - 16, |
| output_analysis_start + kFreqTestBufSize + 16, |
| "End of output analysis section"); |
| } |
| |
| auto channel_is_out_of_band = (channel_spec.freq_resp_lower_limits_db[0] == -INFINITY); |
| auto out_of_band = (freq_for_display < tc.low_cut_frequency || |
| freq_for_display > tc.low_pass_frequency || channel_is_out_of_band); |
| |
| double sinad_db, level_db = 0.0; |
| if (!out_of_band) { |
| auto result = MeasureAudioFreqs(output, {freq}); |
| level_db = DoubleToDb(result.magnitudes[freq]); |
| sinad_db = DoubleToDb(result.magnitudes[freq] / result.total_magn_other); |
| if constexpr (kDisplayInProgressResults) { |
| FX_LOGS(INFO) << "Channel " << channel_spec.channel << ": " << std::setw(5) |
| << freq_for_display << " Hz -- level " << std::fixed |
| << std::setprecision(4) << std::setw(9) << level_db << " db, sinad " |
| << std::setw(8) << sinad_db << " db"; |
| } |
| } else { |
| // For out-of-band frequencies, we use the sinad array to store Out-of-Band Rejection, |
| // which is measured as the sinad(all frequencies), assuming a full-scale input. |
| sinad_db = DoubleToDb(1.0 / MeasureAudioFreqs(output, {}).total_magn_other); |
| |
| if constexpr (kDisplayInProgressResults) { |
| FX_LOGS(INFO) << "Channel " << channel_spec.channel << ": " << std::setw(5) |
| << freq_for_display << " Hz -- out-of-band rejection " << std::fixed |
| << std::setprecision(4) << std::setw(8) << sinad_db << " db"; |
| } |
| } |
| |
| if (save_fidelity_wav_files_) { |
| // We shouldn't save files for the full frequency set -- just save files for this frequency. |
| if (freq_for_display == kFrequencyForSavedWavFiles) { |
| std::string test_name = tc.test_name + "_chan" + std::to_string(channel_spec.channel) + |
| "_" + std::to_string(freq_for_display) + "hz"; |
| HermeticPipelineTest::WriteWavFile<OutputFormat>(test_name, "output", output); |
| } |
| } |
| |
| // Retrieve the arrays of measurements for this path and channel |
| auto& curr_level_db = |
| level_results(tc.path, channel_spec.channel, tc.thermal_state.value_or(0)); |
| auto& curr_sinad_db = |
| sinad_results(tc.path, channel_spec.channel, tc.thermal_state.value_or(0)); |
| if constexpr (kRetainWorstCaseResults) { |
| curr_level_db[freq_idx] = std::min(curr_level_db[freq_idx], level_db); |
| curr_sinad_db[freq_idx] = std::min(curr_sinad_db[freq_idx], sinad_db); |
| } else { |
| curr_level_db[freq_idx] = level_db; |
| curr_sinad_db[freq_idx] = sinad_db; |
| } |
| } |
| } |
| |
| if constexpr (kDisplaySummaryResults) { |
| DisplaySummaryResults(tc); |
| } |
| |
| VerifyResults(tc); |
| } |
| |
| // We only run the pipeline fidelity tests with FLOAT inputs/outputs, for full data precision. |
| template void HermeticFidelityTest::Run<ASF::FLOAT, ASF::FLOAT>( |
| const TestCase<ASF::FLOAT, ASF::FLOAT>& tc); |
| |
| } // namespace media::audio::test |