291 lines
9.6 KiB
Python
291 lines
9.6 KiB
Python
# Lint as: python3
|
|
"""Utils for bluetooth audio testing."""
|
|
|
|
import logging as log
|
|
import math
|
|
import os
|
|
from typing import Optional
|
|
import numpy as np
|
|
from scipy import signal as scipy_signal
|
|
from scipy.io import wavfile
|
|
# Internal import
|
|
# Internal import
|
|
|
|
# Dict keys of the THD+N analysis result
|
|
_THDN_KEY = 'thd+n'
|
|
_START_TIME_KEY = 'start_time'
|
|
_END_TIME_KEY = 'end_time'
|
|
|
|
|
|
def generate_sine_wave_to_device(
|
|
device,
|
|
pushed_file_path='/sdcard/Music',
|
|
frequency=480,
|
|
channel=2,
|
|
sample_rate=48000,
|
|
sample_format=16,
|
|
duration_sec=10):
|
|
"""Generates a fixed frequency sine wave file and push it to the device.
|
|
|
|
Generates a sine wave to the Mobly device directory and push it to the device
|
|
storage. The output file name format is such as the example:
|
|
sine_480hz_2ch_48000rate_16bit_10sec.wav
|
|
|
|
Args:
|
|
device: AndroidDevice, Mobly Android controller class.
|
|
pushed_file_path: string, the wave file path which is pushed to the device
|
|
storage. E.g. /sdcard/Music
|
|
frequency: int, fixed frequency in Hz.
|
|
channel: int, number of channels.
|
|
sample_rate: int, sampling rate in Hz.
|
|
sample_format: int, sampling format in bit.
|
|
duration_sec: int, audio duration in second.
|
|
|
|
Returns:
|
|
device_storage_path: string, the wave file on the device storage.
|
|
mobly_directory_path: string, the wave file on the Mobly device directory.
|
|
"""
|
|
file_name = 'sine_%dhz_%dch_%drate_%dbit_%dsec.wav' % (
|
|
frequency, channel, sample_rate, sample_format, duration_sec)
|
|
mobly_directory_path = os.path.join(device.log_path, file_name)
|
|
os.system('%s -n -c %d -r %d -b %d %s synth %d sine %d' %
|
|
(audio_processor.AudioProcessor.SOX, channel, sample_rate,
|
|
sample_format, mobly_directory_path, duration_sec, frequency))
|
|
device.adb.push([mobly_directory_path, pushed_file_path])
|
|
device_storage_path = os.path.join(pushed_file_path, file_name)
|
|
return device_storage_path, mobly_directory_path
|
|
|
|
|
|
def measure_audio_mos(recorded_audio_file, reference_audio_file):
|
|
"""Measures mean opinion score (MOS) of a recorded audio.
|
|
|
|
This function uses the module of A/V Analysis Service to measure MOS:
|
|
Internal reference
|
|
|
|
Args:
|
|
recorded_audio_file: string, the recorded audio file to be measured.
|
|
reference_audio_file: string, the reference audio file for comparison.
|
|
|
|
Returns:
|
|
Float which is the mean opinion score of the recorded audio.
|
|
"""
|
|
results = audio_calculator.AudioAnalyzer().Analyze(reference_audio_file,
|
|
recorded_audio_file)
|
|
# Returns 0.0 if the results fails to be generated.
|
|
if not results:
|
|
log.warning('Failed to generate the audio analysis results.')
|
|
return 0.0
|
|
return results[0].mos
|
|
|
|
|
|
def measure_fundamental_frequency(signal, sample_rate):
|
|
"""Measures fundamental frequency of a signal.
|
|
|
|
Args:
|
|
signal: An 1-D array representing the signal data.
|
|
sample_rate: int, sample rate of the signal.
|
|
|
|
Returns:
|
|
Float representing the fundamental frequency.
|
|
"""
|
|
return sample_rate * (np.argmax(np.abs(np.fft.rfft(signal))) / len(signal))
|
|
|
|
|
|
def measure_rms(signal):
|
|
"""Measures Root Mean Square (RMS) of a signal.
|
|
|
|
Args:
|
|
signal: An 1-D array representing the signal data.
|
|
|
|
Returns:
|
|
Float representing the root mean square.
|
|
"""
|
|
return np.sqrt(np.mean(np.absolute(signal)**2))
|
|
|
|
|
|
def measure_thdn(signal, sample_rate, q, frequency=None):
|
|
"""Measures Total Harmonic Distortion + Noise (THD+N) of a signal.
|
|
|
|
Args:
|
|
signal: An 1-D array representing the signal data.
|
|
sample_rate: int, sample rate of the signal.
|
|
q: float, quality factor for the notch filter.
|
|
frequency: float, fundamental frequency of the signal. All other frequencies
|
|
are noise. If not specified, will be calculated using FFT.
|
|
|
|
Returns:
|
|
Float representing THD+N ratio calculated from the ratio of RMS of pure
|
|
harmonics and noise signal to RMS of original signal.
|
|
"""
|
|
# Normalizes the signal.
|
|
signal -= np.mean(signal)
|
|
# Gets Blackman-Harris window from the signal.
|
|
window = signal * scipy_signal.blackmanharris(len(signal))
|
|
# Finds the fundamental frequency to remove if not specified.
|
|
if not frequency:
|
|
frequency = measure_fundamental_frequency(window, sample_rate)
|
|
# Creates a notch filter to get noise from the signal.
|
|
wo = frequency / (sample_rate / 2)
|
|
b, a = scipy_signal.iirnotch(wo, q)
|
|
noise = scipy_signal.lfilter(b, a, window)
|
|
return measure_rms(noise) / measure_rms(window)
|
|
|
|
|
|
def measure_audio_thdn_per_window(
|
|
audio_file,
|
|
thdn_threshold,
|
|
step_size,
|
|
window_size,
|
|
q=1.0,
|
|
frequency=None):
|
|
"""Measures Total Harmonic Distortion + Noise (THD+N) of an audio file.
|
|
|
|
This function is used to capture audio glitches from a recorded audio file,
|
|
and the audio file shall record a fixed frequency sine wave.
|
|
|
|
Args:
|
|
audio_file: A .wav file to be measured.
|
|
thdn_threshold: float, a THD+N threshold used to compare with the measured
|
|
THD+N for every windows. If THD+N of a window is greater than the
|
|
threshold, will record this to results.
|
|
step_size: int, number of samples to move the window by for each analysis.
|
|
window_size: int, number of samples to analyze each time.
|
|
q: float, quality factor for the notch filter.
|
|
frequency: float, fundamental frequency of the signal. All other frequencies
|
|
are noise. If not specified, will be calculated using FFT.
|
|
|
|
Returns:
|
|
List containing each result of channels. Like the following structure:
|
|
```
|
|
[
|
|
[ # result of channel 1
|
|
{
|
|
"thd+n": <float>, # THD+N of a window
|
|
"start_time": <float>, # start time of a window
|
|
"end_time": <float>, # end time of a window
|
|
},
|
|
...,
|
|
],
|
|
[...,] # result of channel 2
|
|
...,
|
|
]
|
|
```
|
|
"""
|
|
if step_size <= 0:
|
|
raise ValueError('step_size shall be greater than 0.')
|
|
if window_size <= 0:
|
|
raise ValueError('window_size shall be greater than 0.')
|
|
sample_rate, wave_data = wavfile.read(audio_file)
|
|
wave_data = wave_data.astype('float64')
|
|
if len(wave_data.shape) == 1:
|
|
channel_signals = (wave_data,)
|
|
else:
|
|
channel_signals = wave_data.transpose()
|
|
# Collects the result for each channels.
|
|
results = []
|
|
for signal in channel_signals:
|
|
current_position = 0
|
|
channel_result = []
|
|
while current_position + window_size <= len(signal):
|
|
window = signal[current_position:current_position + window_size]
|
|
thdn = measure_thdn(
|
|
signal=window,
|
|
sample_rate=sample_rate,
|
|
q=q,
|
|
frequency=frequency)
|
|
start_time = current_position / sample_rate
|
|
end_time = (current_position + window_size) / sample_rate
|
|
if thdn > thdn_threshold:
|
|
channel_result.append({
|
|
_THDN_KEY: thdn,
|
|
_START_TIME_KEY: start_time,
|
|
_END_TIME_KEY: end_time
|
|
})
|
|
current_position += step_size
|
|
results.append(channel_result)
|
|
return results
|
|
|
|
|
|
def get_audio_maximum_thdn(
|
|
audio_file: str,
|
|
step_size: int,
|
|
window_size: int,
|
|
q: float = 1.0,
|
|
frequency: Optional[float] = None) -> float:
|
|
"""Gets maximum THD+N from each audio sample with specified window size.
|
|
|
|
Args:
|
|
audio_file: A .wav file to be measured.
|
|
step_size: Number of samples to move the window by for each analysis.
|
|
window_size: Number of samples to analyze each time.
|
|
q: Quality factor for the notch filter.
|
|
frequency: Fundamental frequency of the signal. All other frequencies
|
|
are noise. If not specified, will be calculated using FFT.
|
|
|
|
Returns:
|
|
Float representing the maximum THD+N for the audio file.
|
|
"""
|
|
# Gets all analysis results.
|
|
total_results = measure_audio_thdn_per_window(
|
|
audio_file=audio_file,
|
|
thdn_threshold=-1.0,
|
|
step_size=step_size,
|
|
window_size=window_size,
|
|
q=q,
|
|
frequency=frequency)
|
|
|
|
max_thdn_result = {_THDN_KEY: -1.0, _START_TIME_KEY: -1, _END_TIME_KEY: -1}
|
|
for channel_results in total_results:
|
|
for result in channel_results:
|
|
if result[_THDN_KEY] > max_thdn_result[_THDN_KEY]:
|
|
max_thdn_result = result
|
|
|
|
log.info('Maximum THD+N result: %s', max_thdn_result)
|
|
return max_thdn_result[_THDN_KEY]
|
|
|
|
|
|
def convert_thdn_percent_to_decibels(thdn_percent: float) -> float:
|
|
"""Converts THD+N percentage to decibels (dB).
|
|
|
|
Args:
|
|
thdn_percent: THD+N in percent. E.g. 0.001.
|
|
|
|
Returns:
|
|
THD+N in decibels.
|
|
"""
|
|
return math.log(thdn_percent / 100, 10) * 20
|
|
|
|
|
|
def trim_audio(audio_file: str,
|
|
duration_sec: float,
|
|
start_time_sec: float = 0.0) -> str:
|
|
"""Trims an audio file with a specific start time and duration.
|
|
|
|
Generates a output file and its name is such as below format:
|
|
`<input file name>_<start time sec>-<duration sec>.<input file type>`
|
|
|
|
Args:
|
|
audio_file: string, an audio file to be trimed.
|
|
duration_sec: float, the duration of the output file in seconds.
|
|
start_time_sec: float, the start time of the audio file to be trimmed in
|
|
seconds. Default value is 0.0 second if not specified.
|
|
|
|
Returns:
|
|
String, the output file of the same path of the origin file.
|
|
"""
|
|
file_path, file_name = os.path.split(audio_file)
|
|
file_name, file_ext = os.path.splitext(file_name)
|
|
output_file_name = '%s_%s-%s%s' % (
|
|
file_name,
|
|
start_time_sec,
|
|
(start_time_sec + duration_sec),
|
|
file_ext)
|
|
output_file = os.path.join(file_path, output_file_name)
|
|
processor = audio_processor.AudioProcessor()
|
|
processor.TrimAudio(
|
|
input_file=audio_file,
|
|
output_file=output_file,
|
|
duration=duration_sec,
|
|
start=start_time_sec)
|
|
return output_file
|