From fd7d1267d7307694295714b1d1e2fd7b32b3754f Mon Sep 17 00:00:00 2001 From: "Dmitry Fedorov (easydozen)" Date: Tue, 23 Jun 2020 16:12:09 +0300 Subject: [PATCH] Audio: rework audio tests Replace constant audio bytearray with generated sine wave sample to get more robust results across test environments. Use zero-crossings as an audio fingerprint and compare it with sine wave frequency. --- qubes/tests/integ/vm_qrexec_gui.py | 151 +++++++++++++++++------------ 1 file changed, 88 insertions(+), 63 deletions(-) diff --git a/qubes/tests/integ/vm_qrexec_gui.py b/qubes/tests/integ/vm_qrexec_gui.py index 5619a62d..4c5c35de 100644 --- a/qubes/tests/integ/vm_qrexec_gui.py +++ b/qubes/tests/integ/vm_qrexec_gui.py @@ -36,6 +36,8 @@ import qubes.tests import qubes.vm.appvm import qubes.vm.templatevm +import numpy as np + class TC_00_AppVMMixin(object): def setUp(self): @@ -422,16 +424,13 @@ class TC_00_AppVMMixin(object): local_user = grp.getgrnam('qubes').gr_mem[0] p = self.loop.run_until_complete(asyncio.create_subprocess_shell( "sudo -E -u {} timeout 30s sh -c '" - "while ! pactl list sink-inputs | grep -q :{}; do sleep 1; done'".format( - local_user, vm.name))) + "while ! pactl list sink-inputs | grep -q :{}; do sleep 1; done'" + .format(local_user, vm.name))) self.loop.run_until_complete(p.wait()) # and some more... self.loop.run_until_complete(asyncio.sleep(1)) - - @unittest.skipUnless(spawn.find_executable('parecord'), - "pulseaudio-utils not installed in dom0") - def test_220_audio_playback(self): + def is_vm_suitable(self): if 'whonix-gw' in self.template: self.skipTest('whonix-gw have no audio') self.loop.run_until_complete(self.testvm1.start()) @@ -441,21 +440,30 @@ class TC_00_AppVMMixin(object): except subprocess.CalledProcessError: self.skipTest('pulseaudio-utils not installed in VM') + def common_audio_playback(self): + self.is_vm_suitable() self.wait_for_pulseaudio_startup(self.testvm1) - # generate some "audio" data - audio_in = b'\x20' * 44100 - self.loop.run_until_complete( - self.testvm1.run_for_stdio('cat > audio_in.raw', input=audio_in)) + # sine frequency + sfreq = 4400 + # generate signal + audio_in = np.sin(2*np.pi*np.arange(44100)*sfreq/44100) + self.loop.run_until_complete(self.testvm1.run_for_stdio( + 'pacat --raw --rate=44100 --channels=1 \ + --format=float32le > audio_in.raw', + input=audio_in.astype(np.float32).tobytes())) local_user = grp.getgrnam('qubes').gr_mem[0] with tempfile.NamedTemporaryFile() as recorded_audio: os.chmod(recorded_audio.name, 0o666) # FIXME: -d 0 assumes only one audio device p = subprocess.Popen(['sudo', '-E', '-u', local_user, - 'parecord', '-d', '0', '--raw', recorded_audio.name], - stdout=subprocess.PIPE) + 'parecord', '-d', '0', '--raw', + '--format=float32le', '--rate=44100', '--channels=1', + recorded_audio.name], stdout=subprocess.PIPE) try: self.loop.run_until_complete( - self.testvm1.run_for_stdio('paplay --raw audio_in.raw')) + self.testvm1.run_for_stdio( + 'paplay --format=float32le --rate=44100 \ + --channels=1 --raw audio_in.raw')) except subprocess.CalledProcessError as err: self.fail('{} stderr: {}'.format(str(err), err.stderr)) # wait for possible parecord buffering @@ -464,15 +472,16 @@ class TC_00_AppVMMixin(object): # for some reason sudo do not relay SIGTERM sent above subprocess.check_call(['pkill', 'parecord']) p.wait() - # allow up to 20ms missing, don't use assertIn, to avoid printing - # the whole data in error message - recorded_audio = recorded_audio.file.read() - if audio_in[:-3528] not in recorded_audio: - found_bytes = recorded_audio.count(audio_in[0]) - all_bytes = len(audio_in) + rec = np.fromstring(recorded_audio.file.read(), dtype=np.float32) + # find zero crossings + crossings = np.nonzero((rec[1:] > 0) & (rec[:-1] < 0))[0] + np.seterr('raise') + # compare against sine wave frequency + rec_freq = len(rec)/np.mean(np.diff(crossings)) + if not sfreq*0.8 < rec_freq < sfreq*1.2: self.fail('played sound not found in dom0, ' - 'missing {} bytes out of {}'.format( - all_bytes-found_bytes, all_bytes)) + 'frequency {} not in specified range' + .format(rec_freq)) def _configure_audio_recording(self, vm): '''Connect VM's output-source to sink monitor instead of mic''' @@ -497,18 +506,8 @@ class TC_00_AppVMMixin(object): subprocess.check_call(sudo + ['pacmd', 'move-source-output', last_index, '0']) - @unittest.skipUnless(spawn.find_executable('parecord'), - "pulseaudio-utils not installed in dom0") - def test_221_audio_record_muted(self): - if 'whonix-gw' in self.template: - self.skipTest('whonix-gw have no audio') - self.loop.run_until_complete(self.testvm1.start()) - try: - self.loop.run_until_complete( - self.testvm1.run_for_stdio('which parecord')) - except subprocess.CalledProcessError: - self.skipTest('pulseaudio-utils not installed in VM') - + def common_audio_record_muted(self): + self.is_vm_suitable() self.wait_for_pulseaudio_startup(self.testvm1) # connect VM's recording source output monitor (instead of mic) self._configure_audio_recording(self.testvm1) @@ -535,53 +534,79 @@ class TC_00_AppVMMixin(object): if audio_in[:32] in recorded_audio: self.fail('VM recorded something, even though mic disabled') - @unittest.skipUnless(spawn.find_executable('parecord'), - "pulseaudio-utils not installed in dom0") - def test_222_audio_record_unmuted(self): - if 'whonix-gw' in self.template: - self.skipTest('whonix-gw have no audio') - self.loop.run_until_complete(self.testvm1.start()) - try: - self.loop.run_until_complete( - self.testvm1.run_for_stdio('which parecord')) - except subprocess.CalledProcessError: - self.skipTest('pulseaudio-utils not installed in VM') - + def common_audio_record_unmuted(self): + self.is_vm_suitable() self.wait_for_pulseaudio_startup(self.testvm1) - da = qubes.devices.DeviceAssignment(self.app.domains[0], 'mic') + deva = qubes.devices.DeviceAssignment(self.app.domains[0], 'mic') self.loop.run_until_complete( - self.testvm1.devices['mic'].attach(da)) + self.testvm1.devices['mic'].attach(deva)) # connect VM's recording source output monitor (instead of mic) self._configure_audio_recording(self.testvm1) - - # generate some "audio" data - audio_in = b'\x20' * 44100 + sfreq = 4400 + audio_in = np.sin(2*np.pi*np.arange(44100)*sfreq/44100) local_user = grp.getgrnam('qubes').gr_mem[0] - record = self.loop.run_until_complete( - self.testvm1.run('parecord --raw audio_rec.raw')) + record = self.loop.run_until_complete(self.testvm1.run( + 'parecord --raw --format=float32le --rate=44100 \ + --channels=1 audio_rec.raw')) # give it time to start recording - self.loop.run_until_complete(asyncio.sleep(0.5)) p = subprocess.Popen(['sudo', '-E', '-u', local_user, - 'paplay', '--raw'], + 'paplay', '--raw', '--format=float32le', + '--rate=44100', '--channels=1'], stdin=subprocess.PIPE) - p.communicate(audio_in) - # wait for possible parecord buffering - self.loop.run_until_complete(asyncio.sleep(1)) + p.communicate(audio_in.astype(np.float32).tobytes()) self.loop.run_until_complete( self.testvm1.run_for_stdio('pkill parecord || :')) _, record_stderr = self.loop.run_until_complete(record.communicate()) if record_stderr: self.fail('parecord printed something on stderr: {}'.format( record_stderr)) + recorded_audio, _ = self.loop.run_until_complete( self.testvm1.run_for_stdio('cat audio_rec.raw')) - # allow up to 20ms to be missing - if audio_in[:-3528] not in recorded_audio: - found_bytes = recorded_audio.count(audio_in[0]) - all_bytes = len(audio_in) + rec = np.fromstring(recorded_audio, dtype=np.float32) + crossings = np.nonzero((rec[1:] > 0) & (rec[:-1] < 0))[0] + np.seterr('raise') + rec_freq = len(rec)/np.mean(np.diff(crossings)) + if not sfreq*0.8 < rec_freq < sfreq*1.2: self.fail('VM not recorded expected data, ' - 'missing {} bytes out of {}'.format( - all_bytes-found_bytes, all_bytes)) + 'frequency {} not in specified range' + .format(rec_freq)) + + @unittest.skipUnless(spawn.find_executable('parecord'), + "pulseaudio-utils not installed in dom0") + def test_220_audio_play(self): + self.common_audio_playback() + + @unittest.skipUnless(spawn.find_executable('parecord'), + "pulseaudio-utils not installed in dom0") + def test_221_audio_rec_muted(self): + self.common_audio_record_muted() + + @unittest.skipUnless(spawn.find_executable('parecord'), + "pulseaudio-utils not installed in dom0") + def test_222_audio_rec_unmuted(self): + self.common_audio_record_unmuted() + + @unittest.skipUnless(spawn.find_executable('parecord'), + "pulseaudio-utils not installed in dom0") + def test_223_audio_play_hvm(self): + self.testvm1.virt_mode = 'hvm' + self.testvm1.features['audio-model'] = 'ich6' + self.common_audio_playback() + + @unittest.skipUnless(spawn.find_executable('parecord'), + "pulseaudio-utils not installed in dom0") + def test_224_audio_rec_muted_hvm(self): + self.testvm1.virt_mode = 'hvm' + self.testvm1.features['audio-model'] = 'ich6' + self.common_audio_record_muted() + + @unittest.skipUnless(spawn.find_executable('parecord'), + "pulseaudio-utils not installed in dom0") + def test_225_audio_rec_unmuted_hvm(self): + self.testvm1.virt_mode = 'hvm' + self.testvm1.features['audio-model'] = 'ich6' + self.common_audio_record_unmuted() def test_250_resize_private_img(self): """