Browse Source

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.
Dmitry Fedorov (easydozen) 3 years ago
parent
commit
fd7d1267d7
1 changed files with 88 additions and 63 deletions
  1. 88 63
      qubes/tests/integ/vm_qrexec_gui.py

+ 88 - 63
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):
         """