[Asterisk-code-review] Testsuite - codec opus: Add encode/decode test (testsuite[master])

Kevin Harwell asteriskteam at digium.com
Fri Sep 30 16:35:10 CDT 2016


Kevin Harwell has uploaded a new change for review.

  https://gerrit.asterisk.org/4010

Change subject: Testsuite - codec_opus: Add encode/decode test
......................................................................

Testsuite - codec_opus: Add encode/decode test

Tests encoding and decoding of opus in Asterisk. For each test a call is
originated into app record and either a tone or audio file (containing a tone)
is played back. Once done the recorded file is analyzed to make sure that the
original tone matches the recorded tone.

ASTERISK-26409 #close

Change-Id: I961d483d87b5d1439e20171cee859c1e1606c824
---
A tests/codecs/audio_analyzer.py
A tests/codecs/configs/ast1/extensions.conf
A tests/codecs/configs/ast1/pjsip.conf.inc
A tests/codecs/opus/decode/16_bit_48khz_2_4_2.opus
A tests/codecs/opus/decode/configs/ast1/pjsip.conf
A tests/codecs/opus/decode/test-config.yaml
A tests/codecs/opus/encode/configs/ast1/pjsip.conf
A tests/codecs/opus/encode/test-config.yaml
A tests/codecs/opus/tests.yaml
A tests/codecs/tests.yaml
A tests/codecs/tonetest.py
M tests/tests.yaml
12 files changed, 433 insertions(+), 0 deletions(-)


  git pull ssh://gerrit.asterisk.org:29418/testsuite refs/changes/10/4010/1

diff --git a/tests/codecs/audio_analyzer.py b/tests/codecs/audio_analyzer.py
new file mode 100644
index 0000000..9903c6d
--- /dev/null
+++ b/tests/codecs/audio_analyzer.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python
+'''
+Copyright (C) 2016, Digium, Inc.
+Kevin Harwell <kharwell at digium.com>
+
+This program is free software, distributed under the terms of
+the GNU General Public License Version 2.
+'''
+
+import os
+import shutil
+import sys
+import logging
+from twisted.internet import reactor
+
+import ami
+import ari
+import tonetest
+
+sys.path.append("lib/python")
+
+LOGGER = logging.getLogger(__name__)
+
+
+def _get_media(test_object):
+    """Retrieve the media to playback from the given test object.
+
+    :param test_object The test object
+    """
+
+    def __get_playback_file():
+        duration = 0
+        for t in test_object.tones:
+            duration += t['duration']
+        return ('sound:' + os.getcwd() + '/' + test_object.test_name + '/' +
+                test_object.playback_file, duration / 1000)
+
+    def __get_tones():
+        tones = ""
+        duration = 0
+        for t in test_object.tones:
+            tones += str(t['frequency']) + '/' + str(t['duration']) + ','
+            duration += t['duration']
+        return ('tone:\"' + tones.rstrip(',') + '\"', duration / 1000)
+
+    return __get_playback_file() if test_object.playback_file else __get_tones()
+
+
+def on_start(ari, event, test_object):
+    """Handle StasisStart event
+
+    Start playback of a tone on the channel
+
+    :param event The StasisStart event
+    :param test_object The test object
+    """
+
+    def __stop_media():
+        # Should be done so hangup the channel and stop the test
+        ari.delete('channels', test_object.channel_id)
+
+    test_object.channel_id = event['channel']['id']
+    media, duration = _get_media(test_object)
+
+    ari.post('channels', test_object.channel_id, 'answer')
+    try:
+        ari.post('channels', test_object.channel_id, 'play/play_id',
+                 media=media)
+    except:
+        LOGGER.error("Failed to play media " + media)
+        return False
+
+    LOGGER.info("Generating {0} second(s) of media {1}"
+                .format(duration, media))
+    reactor.callLater(duration, __stop_media)
+    return True
+
+
+def on_stasis_end(ari, event, test_object):
+    """Handle StasisEnd event
+
+    Check the output file against the generated tone(s).
+
+    :param event The StasisEnd event
+    :param test_object the test object
+    """
+
+    def __validate_output(output_file, tones):
+        output = tonetest.tonetest(output_file)
+        if len(tones) != len(output):
+            LOGGER.error("Number of generated tones {0} does not "
+                         "match output {1}".format(tones, output))
+            return False
+
+        for i in range(0, len(tones)):
+            LOGGER.info("Checking tone {0} against output {1}".
+                        format(tones[i], output[i]))
+            # Make sure the duration of the tone is within a second
+            duration = tones[i]['duration'] / 1000
+            if (duration < output[i]['duration'] - 1 or
+                    duration > output[i]['duration'] + 1):
+                LOGGER.error("Tone #{0} {1} duration out of range for {2}".
+                             format(i, tones[i], output[i]))
+                return False
+            # Make sure the frequency of the tone is within +-7
+            frequency = tones[i]['frequency']
+            if (frequency < output[i]['frequency'] - 7 or
+                    frequency > output[i]['frequency'] + 7):
+                LOGGER.error("Tone #{0} {1} frequency out of range for {2}".
+                             format(i, tones[i], output[i]))
+                return False
+        return True
+
+    test_object.set_passed(__validate_output(
+        test_object.sounds_path + test_object.output_file,
+        test_object.tones))
+    return True
+
+
+class Analyzer(ari.AriTestObject):
+    """Test object used to playback, record, and analyze output audio.
+
+    Upon start of the test a channel (default - PJSIP/audio) is originated into
+    the Record application with another channel entering stasis. Once in stasis,
+    an audio tone (file or generated tone) is played on the channel while being
+    recorded on the other channel. When play back has completed the channel
+    leaves stasis and the recorded audio is checked against the configured
+    tones.
+
+    Configuration options:
+
+    output-file: name of the file audio is recorded into (default -
+        output_audio). The named file can be found in the 'sounds' directory.
+
+    playback-file: tone file used during media playback (default - None).
+        If specified, the file is expected to be under the test directory.
+
+    tones: frequency and duration of tones found in the recorded file
+        (default - 2 seconds of silence, 4 seconds of audio, 2 seconds
+        of silence). Note if no playback file is specified then the given
+        tones are used for playback.
+    """
+
+    on_stasis_start = {
+        'conditions': {
+            'match': {
+                'type': 'StasisStart',
+                'application': 'testsuite'
+            },
+        },
+        'count': 1,
+        'callback': {
+            'module': 'audio_analyzer',
+            'method': 'on_start'
+        }}
+
+    on_stasis_end = {
+        'conditions': {
+            'match': {
+                'type': 'StasisEnd',
+                'application': 'testsuite'
+            },
+        },
+        'count': 1,
+        'callback': {
+            'module': 'audio_analyzer',
+            'method': 'on_stasis_end'
+        }}
+
+    def __init__(self, test_path='', test_config=None):
+        """Constructor for a test object
+
+        :param test_path The full path to the test location
+        :param test_config The YAML test configuration
+        """
+
+        if test_config is None:
+            test_config = {}
+
+        # Global conf file path
+        test_config['config-path'] = 'tests/codecs/configs/'
+
+        self.output_file = test_config.get('output-file', 'audio_output.wav')
+
+        if not test_config.get('test-iterations'):
+            test_config['test-iterations'] = [{
+                'channel': 'PJSIP/audio',
+                'application': 'Record',
+                'data': self.output_file + ',,,k',
+                'async': 'True'
+            }]
+
+        super(Analyzer, self).__init__(test_path, test_config)
+
+        # Get the path to Asterisk and build a path to sounds
+        ast = self.ast[0]
+        self.sounds_path = (ast.base + ast.directories["astvarlibdir"] +
+                            "/sounds/")
+
+        if not test_config.get('events'):
+            test_config['events'] = [self.on_stasis_start,
+                                     self.on_stasis_end]
+
+        self.playback_file = test_config.get('playback-file')
+
+        # Default tones - 2 seconds of silence, 4 seconds of audio,
+        # followed by 2 seconds of silence
+        self.tones = test_config.get('tones', [
+            {'frequency': 0, 'duration': 2000},
+            {'frequency': 440, 'duration': 4000},
+            {'frequency': 0, 'duration': 2000}
+        ])
+
+        self._events = ari.WebSocketEventModule(test_config, self)
diff --git a/tests/codecs/configs/ast1/extensions.conf b/tests/codecs/configs/ast1/extensions.conf
new file mode 100644
index 0000000..2dcef9f
--- /dev/null
+++ b/tests/codecs/configs/ast1/extensions.conf
@@ -0,0 +1,4 @@
+[default]
+exten => audio,1,Answer()
+    same => n,Stasis(testsuite)
+    same => n,Hangup()
diff --git a/tests/codecs/configs/ast1/pjsip.conf.inc b/tests/codecs/configs/ast1/pjsip.conf.inc
new file mode 100644
index 0000000..f4bd42f
--- /dev/null
+++ b/tests/codecs/configs/ast1/pjsip.conf.inc
@@ -0,0 +1,24 @@
+[global]
+type=global
+debug=yes
+
+[transport]
+type=transport
+protocol=udp
+bind=127.0.0.1
+
+[endpoint_t](!)
+type=endpoint
+context=default
+direct_media=no
+from_domain=127.0.0.1
+;media_address=127.0.0.1
+
+[audio]
+type=aor
+max_contacts=1
+contact=sip:audio at 127.0.0.1:5060
+
+[audio_endpoint_t](!,endpoint_t)
+aors=audio
+from_user=audio
diff --git a/tests/codecs/opus/decode/16_bit_48khz_2_4_2.opus b/tests/codecs/opus/decode/16_bit_48khz_2_4_2.opus
new file mode 100644
index 0000000..2bb9af8
--- /dev/null
+++ b/tests/codecs/opus/decode/16_bit_48khz_2_4_2.opus
Binary files differ
diff --git a/tests/codecs/opus/decode/configs/ast1/pjsip.conf b/tests/codecs/opus/decode/configs/ast1/pjsip.conf
new file mode 100644
index 0000000..03a3ff2
--- /dev/null
+++ b/tests/codecs/opus/decode/configs/ast1/pjsip.conf
@@ -0,0 +1,4 @@
+#include "pjsip.conf.inc"
+
+[audio](audio_endpoint_t)
+allow=!all,opus
diff --git a/tests/codecs/opus/decode/test-config.yaml b/tests/codecs/opus/decode/test-config.yaml
new file mode 100644
index 0000000..d64a46d
--- /dev/null
+++ b/tests/codecs/opus/decode/test-config.yaml
@@ -0,0 +1,25 @@
+testinfo:
+    summary: 'Test opus decoding'
+    description: |
+        'Plays an opus file that gets decoded and written to a .wav file.
+         Once written, the file is analyzed to see if the data in the file
+         approximately matches the a specified tone.'
+
+test-modules:
+    add-to-search-path:
+        - 'tests/codecs'
+    test-object:
+        config-section: test-object-config
+        typename: 'audio_analyzer.Analyzer'
+
+test-object-config:
+    playback-file: '16_bit_48khz_2_4_2'
+
+properties:
+    minversion: '14.0.1'
+    dependencies:
+        - asterisk : 'res_pjsip'
+        - asterisk : 'codec_opus'
+    tags:
+        - pjsip
+        - codec
diff --git a/tests/codecs/opus/encode/configs/ast1/pjsip.conf b/tests/codecs/opus/encode/configs/ast1/pjsip.conf
new file mode 100644
index 0000000..03a3ff2
--- /dev/null
+++ b/tests/codecs/opus/encode/configs/ast1/pjsip.conf
@@ -0,0 +1,4 @@
+#include "pjsip.conf.inc"
+
+[audio](audio_endpoint_t)
+allow=!all,opus
diff --git a/tests/codecs/opus/encode/test-config.yaml b/tests/codecs/opus/encode/test-config.yaml
new file mode 100644
index 0000000..5e9bfd7
--- /dev/null
+++ b/tests/codecs/opus/encode/test-config.yaml
@@ -0,0 +1,22 @@
+testinfo:
+    summary: 'Test opus encoding'
+    description: |
+        'Plays a tone that gets encoded into opus. This tone is then written
+         out/decoded into a .wav file. Once written, the file is analyzed to
+         see if the data in the file approximately matches the original tone.'
+
+test-modules:
+    add-to-search-path:
+        - 'tests/codecs'
+    test-object:
+        config-section: test-object-config
+        typename: 'audio_analyzer.Analyzer'
+
+properties:
+    minversion: '14.0.1'
+    dependencies:
+        - asterisk : 'res_pjsip'
+        - asterisk : 'codec_opus'
+    tags:
+        - pjsip
+        - codec
diff --git a/tests/codecs/opus/tests.yaml b/tests/codecs/opus/tests.yaml
new file mode 100644
index 0000000..209627b
--- /dev/null
+++ b/tests/codecs/opus/tests.yaml
@@ -0,0 +1,4 @@
+# Enter tests here in the order they should be considered for execution:
+tests:
+    - test: 'encode'
+    - test: 'decode'
diff --git a/tests/codecs/tests.yaml b/tests/codecs/tests.yaml
new file mode 100644
index 0000000..9d38aaa
--- /dev/null
+++ b/tests/codecs/tests.yaml
@@ -0,0 +1,3 @@
+# Enter tests here in the order they should be considered for execution:
+tests:
+    - dir: 'opus'
diff --git a/tests/codecs/tonetest.py b/tests/codecs/tonetest.py
new file mode 100644
index 0000000..bcf6dea
--- /dev/null
+++ b/tests/codecs/tonetest.py
@@ -0,0 +1,128 @@
+"""
+tonetest - analyze monophonic tones in wav file
+
+Copyright (c) 2016, Digium, Inc
+Scott Griepentrog <sgriepentrog at digium.com>
+"""
+
+import numpy
+import wave
+
+
+class Analyzer(object):
+    def __init__(self, samples_per_second, full_scale):
+        self.samples_per_second = samples_per_second
+        self.full_scale = full_scale
+        self.samples = 0
+        self.last_sample = 0
+        self.results = []
+        self.last_positive = 0
+        self.last_quiet = 0
+        self.peak = 0
+        self.result_current = None
+        # controls affecting results accuracy
+        self.tolerance_amplitude = 0.1
+        self.tolerance_seconds = 0.05
+
+    def store_results(self, frequency, lo, hi, amplitude):
+        second = float(self.samples) / float(self.samples_per_second)
+        if self.result_current:
+            old_freq = self.result_current['frequency']
+            frequency_changed = old_freq < lo or old_freq > hi
+            elapsed = second - self.result_current['seconds']
+            if frequency_changed:
+                if elapsed < self.tolerance_seconds:
+                    # ignore short duration glitch in freq detection
+                    self.result_current['frequency'] = frequency
+                    self.result_current['amplitude'] = amplitude
+                else:
+                    self.results.append(self.result_current)
+                    self.result_current = None
+
+        if not self.result_current:
+            self.result_current = {
+                'frequency': frequency,
+                'amplitude': amplitude,
+                'seconds': second
+            }
+
+    def process_each(self, sample):
+        if sample > self.peak:
+            self.peak = sample
+        if self.last_sample <= 0 and sample > 0 and self.samples:
+            amplitude = float(self.peak) / float(self.full_scale)
+            count = self.samples - self.last_positive
+            frequency = float(self.samples_per_second) / float(count)
+            freq_hi = float(self.samples_per_second) / float(count - 1)
+            freq_lo = float(self.samples_per_second) / float(count + 1)
+            if amplitude < self.tolerance_amplitude:
+                frequency = 0
+                freq_hi = 0
+                freq_lo = 0
+                amplitude = 0
+            if self.last_positive:
+                self.store_results(frequency, freq_lo, freq_hi, amplitude)
+            self.peak = 0
+            self.last_positive = self.samples
+            self.last_quiet = self.samples
+        self.last_sample = sample
+        self.samples += 1
+        elapsed_samples = self.samples - self.last_quiet
+        elapsed_seconds = float(elapsed_samples) / float(self.samples_per_second)
+        if elapsed_seconds > self.tolerance_seconds:
+            self.store_results(0.0, 0.0, 0.0, 0.0)
+            self.last_quiet = self.samples
+            self.last_positive = 0
+
+    def process(self, samples):
+        for sample in samples:
+            self.process_each(sample)
+
+    def get_results(self):
+        if self.result_current:
+            self.results.append(self.result_current)
+        last = None
+        for result in self.results:
+            if last:
+                last['duration'] = result['seconds'] - last['seconds']
+            last = result
+        second = float(self.samples) / float(self.samples_per_second)
+        last['duration'] = second - last['seconds']
+        return self.results
+
+
+def tonetest(filename):
+    wavefile = wave.open(filename, 'rb')
+
+    if wavefile.getnchannels() != 1:
+        raise Exception('Unexpected channels')
+
+    bytes_per_sample = wavefile.getsampwidth()
+
+    if bytes_per_sample == 1:  # this might be uLaw?
+        sample_type = numpy.int8
+        full_scale = 127
+    elif bytes_per_sample == 2:
+        sample_type = numpy.int16
+        full_scale = 32767
+    else:
+        raise Exception('Unexpected sample width')
+
+    samples_per_second = wavefile.getframerate()
+
+    chunk_size = samples_per_second
+
+    analyzer = Analyzer(samples_per_second, full_scale)
+
+    while True:
+        data = wavefile.readframes(chunk_size)
+        if not data:
+            break
+
+        samples = numpy.fromstring(data, dtype=sample_type)
+
+        analyzer.process(samples)
+
+    wavefile.close()
+
+    return analyzer.get_results()
diff --git a/tests/tests.yaml b/tests/tests.yaml
index 3455387..e3a0b07 100644
--- a/tests/tests.yaml
+++ b/tests/tests.yaml
@@ -36,3 +36,4 @@
     - dir: 'http_server'
     - dir: 'sorcery'
     - test: 'remote-test'
+    - dir: 'codecs'

-- 
To view, visit https://gerrit.asterisk.org/4010
To unsubscribe, visit https://gerrit.asterisk.org/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I961d483d87b5d1439e20171cee859c1e1606c824
Gerrit-PatchSet: 1
Gerrit-Project: testsuite
Gerrit-Branch: master
Gerrit-Owner: Kevin Harwell <kharwell at digium.com>



More information about the asterisk-code-review mailing list