[Asterisk-code-review] Testsuite - codec opus: Add encode/decode test (testsuite[master])
Anonymous Coward
asteriskteam at digium.com
Wed Oct 12 08:44:39 CDT 2016
Anonymous Coward #1000019 has submitted this change and it was merged.
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(-)
Approvals:
George Joseph: Looks good to me, but someone else must approve
Anonymous Coward #1000019: Verified
Joshua Colp: Looks good to me, approved
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..171f9e7
--- /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: ['13.12.0', '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..edcc6c8
--- /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: ['13.12.0', '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: merged
Gerrit-Change-Id: I961d483d87b5d1439e20171cee859c1e1606c824
Gerrit-PatchSet: 3
Gerrit-Project: testsuite
Gerrit-Branch: master
Gerrit-Owner: Kevin Harwell <kharwell at digium.com>
Gerrit-Reviewer: Anonymous Coward #1000019
Gerrit-Reviewer: George Joseph <gjoseph at digium.com>
Gerrit-Reviewer: Joshua Colp <jcolp at digium.com>
Gerrit-Reviewer: Richard Mudgett <rmudgett at digium.com>
More information about the asterisk-code-review
mailing list