Compare commits

...

20 Commits

Author SHA1 Message Date
Dmytro Bogovych 85181b7d1e - improved setup script 2023-09-21 07:57:10 +03:00
Dmytro Bogovych 598456b830 - no more scheduled runs in offline mode - only interactive calls 2023-09-21 05:18:07 +01:00
Dmytro Bogovych cd9c250c95 - first working prototype 2023-09-20 18:16:37 +01:00
Dmytro Bogovych d0032364ee - first prototype of API enabled agent_gsm 2023-09-19 10:59:40 +01:00
Dmytro Bogovych 8ed8e5f255 - initial implementation of standalone hotspot 2023-09-19 09:01:45 +03:00
Dmytro Bogovych e06636132b - fix None reference 2023-09-17 18:23:38 +01:00
Dmytro Bogovych 3efc3d076c - fix the utils_sevana.py 2023-09-17 19:40:06 +03:00
Dmytro Bogovych e512f7a643 - attempt to make MOSes better 2023-09-17 19:37:15 +03:00
Dmytro Bogovych 4696b0e690 - remove non-used binaries 2023-09-17 19:37:03 +03:00
Dmytro Bogovych 0bf8134feb - use native speech detector 2023-09-14 15:44:55 +03:00
Dmytro Bogovych 48743574ad - non-used files removed 2023-09-14 10:20:02 +03:00
Dmytro Bogovych 19c9881784 Merge branch 'master' of https://git.sevana.biz/public/agent_gsm 2023-09-14 08:17:09 +01:00
Dmytro Bogovych 5e2390d9a5 - refresh speech detector 2023-09-14 08:16:49 +01:00
Dmytro Bogovych cc5cec6cd2 - fix global variable access 2023-09-11 12:21:33 +03:00
Dmytro Bogovych 7d21de2dd0 - fix error on name 2023-09-11 12:19:32 +03:00
Dmytro Bogovych ef9ac651f9 - fix error on None value 2023-09-11 12:18:08 +03:00
Dmytro Bogovych ed3b91d8c1 - fix remaining audio results uploading 2023-09-11 12:15:11 +03:00
Dmytro Bogovych c186badb43 - fix false alarm when checking recorded audio is good 2023-09-11 11:46:35 +03:00
Dmytro Bogovych 59d38975e3 - avoid 2nd copy of agent 2023-09-08 15:25:05 +01:00
Dmytro Bogovych ace93a7c51 - fix conf template 2023-09-08 13:46:53 +01:00
32 changed files with 724 additions and 344 deletions

View File

@ -1,7 +0,0 @@
#!/bin/bash
# Oneliner to find script's directory. Please note - last path component should NOT be symlink.
SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0}"; )" &> /dev/null && pwd 2> /dev/null; )";
/usr/bin/python3 ${SCRIPT_DIR}/src/utils_network_impairment.py --start

View File

@ -1,6 +0,0 @@
#!/bin/bash
# Oneliner to find script's directory. Please note - last path component should NOT be symlink.
SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0}"; )" &> /dev/null && pwd 2> /dev/null; )";
/usr/bin/python3 ${SCRIPT_DIR}/src/utils_network_impairment.py --stop

21
bin/aqua.cfg Normal file
View File

@ -0,0 +1,21 @@
AQuA:
mode: files
# src: file test_audio/jane_8k.wav
# tstf: test_audio/jane_8k_40.wav
avlp: off
smtnrm: off
decor: off
mprio: off
acr: auto
npnt: auto
voip: on
enorm: rms
g711: off
spfrcor: on
grad: off
tmc: on
miter: 1
ratem: "%%m"
trim: "r 15"
output: json

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1 +0,0 @@
This pjsua requires 10.14 at least!

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,226 +1,232 @@
BOF Common
IntervalLength = 0.68
IsUseUncertain = false
IsUseMixMode = true
IsUseDistance = false
AllWeight = 1.0
SilWeight = 1
VoiWeight = 1
AllCoefficient = 1.0
SilCoefficient = 1.0
VoiCoefficient = 1.0
SilThreshold = -37.50
IsOnePointSil = false
IsNormResult = true
IsMapScore = true
EOF Common
Common:
IntervalLength: 0.68
IsUseUncertain: no
IsUseMixMode: yes
IsUseDistance: no
AllWeight: 1.0
SilWeight: 1.0
VoiWeight: 1.0
AllCoefficient: 1.0
SilCoefficient: 1.0
VoiCoefficient: 1.0
SilThreshold: -37.50
IsOnePointSil: no
IsNormResult: yes
IsMapScore: yes
NormalizeByRms: yes
BOF Detector
Name = SNR
DetectorType = SNR
IntThresh = 0.10
FrameThresh = 14
DetThresh = 0.10
PVQA-Flag = true
PVQA-Weight = 1.0
DetMode = Both
EOF Detector
SilenceEraser:
Enabled: no
Options:
BOF Detector
Name = DeadAir-00
DetectorType = DeadAir
IntThresh = 0.60
DetThresh = 0.60
PVQA-Flag = true
PVQA-Weight = 1.0
DetMode = Both
EOF Detector
Detector:
- Name: SNR
DetectorType: SNR
IntThresh: 0.10
FrameThresh: 14
DetThresh: 0.10
PVQA-Flag: yes
PVQA-Weight: 1.0
DetMode: both
BOF Detector
Name = DeadAir-01
DetectorType = DeadAir
IntThresh = 0.5
DetThresh = 0.5
PVQA-Flag = true
PVQA-Weight = 1.0
DetMode = Both
EOF Detector
- Name: Noise
DetectorType: Noise
IntThresh: 0.99
DetThresh: 0.99
# This is still experimental detector so its values are not participating in MOS calculation
PVQA-Flag: false
PVQA-Weight: 1.0
DetMode: both
BOF Detector
Name = Click
DetectorType = Clicking
IntThresh = 0.10
DetThresh = 0.10
PVQA-Flag = true
PVQA-Weight = 1.0
DetMode = Both
EOF Detector
- Name: DTMF
DetectorType: DTMF
IntThresh: 0.99
DetThresh: 0.99
BOF Detector
Name = VAD-Clipping
DetectorType = VADClipping
IntThresh = 0.0
FrameThresh = 0.0
DetThresh = 0.0
PVQA-Flag = true
PVQA-Weight = 1.0
DetMode = Both
EOF Detector
# There is no sense to use detected DTMF signal in MOS calculation in the current config
PVQA-Flag: no
PVQA-Weight: 0.0
DetMode: both
BOF Detector
Name = Amplitude-Clipping
DetectorType = AmpClipping
IntThresh = 0.00
FrameThresh = 1.00
DetThresh = 0.00
PVQA-Flag = true
PVQA-Weight = 1.00
DetMode = Both
EOF Detector
- Name: DeadAir-00
DetectorType: DeadAir
IntThresh: 0.60
DetThresh: 0.60
PVQA-Flag: true
PVQA-Weight: 1.0
DetMode: both
BOF Detector
Name = Dynamic-Clipping
DetectorType = AmpClipping
IntThresh = 0.05
FrameThresh = 1.50
DetThresh = 0
PVQA-Flag = true
PVQA-Weight = 0.0
DetMode = Voice
EOF Detector
- Name: DeadAir-01
DetectorType: DeadAir
IntThresh: 0.5
DetThresh: 0.5
PVQA-Flag: yes
PVQA-Weight: 1.0
DetMode: both
Override:
MinLevelThreshold: 0
BOF Base EchoMono
SamplesType = UnKnownCodec
StepLengthSec = 0.5
MinDelayMs = 50
MaxLengthMs = 2800
WindowFunckID = 0
SpanLengthMs = 50
EOF Base EchoMono
- Name: Click
DetectorType: Clicking
IntThresh: 0.10
DetThresh: 0.10
PVQA-Flag: true
PVQA-Weight: 1.0
DetMode: both
BOF Detector
Name = ECHO
DetectorType = EchoMono
IntThresh = 0.00
FrameThresh = -40.0
DetThresh = 0.00
PVQA-Flag = true
PVQA-Weight = 1.0
DetMode = Voice
STAT-Flag = true
SpanLengthMs = 50
EOF Detector
- Name: VAD-Clipping
DetectorType: VADClipping
IntThresh: 0.0
FrameThresh: 0.0
DetThresh: 0.0
PVQA-Flag: true
PVQA-Weight: 1.0
DetMode: both
BOF Detector
Name = Silent-Call-Detection
DetectorType = DeadAir
IntThresh = 0.99
DetThresh = 0.99
PVQA-Flag = false
PVQA-Weight = 1.0
EOF Detector
- Name: AmpClipping
DetectorType: AmpClipping
IntThresh: 0.00
FrameThresh: 1.00
DetThresh: 0.00
PVQA-Flag: true
PVQA-Weight: 1.00
DetMode: both
BOF Base SNR
MinPowerThresh = 1.0000
LogEnergyCoefficient = 10.0000
MinSignalLevel = 40.0000
MinSNRDelta = 0.0001
MinEnergyDisp = 3.0000
MinEnergyDelta = 1.0000
SamplesType = UnKnownCodec
EOF Base SNR
- Name: DynClipping
DetectorType: AmpClipping
IntThresh: 0.05
FrameThresh: 1.50
DetThresh: 0
PVQA-Flag: true
PVQA-Weight: 0.0
DetMode: voice
Override:
FlyAddingCoefficient: 0.1000
SamplesType: UnKnownCodec
IsUseDynamicClipping: yes
BOF Base AmpClipping
FlyAddingCoefficient = 0.1000
IsUseDynamicClipping = false
SamplesType = UnKnownCodec
EOF Base AmpClipping
- Name: Echo
DetectorType: EchoMono
IntThresh: 0.00
FrameThresh: -40.0
DetThresh: 0.00
PVQA-Flag: true
PVQA-Weight: 1.0
DetMode: voice
STAT-Flag: true
SpanLengthMs: 50
BOF Base Clicking
SamplesType = UnKnownCodec
EOF Base Clicking
- Name: SilentCall
DetectorType: DeadAir
IntThresh: 0.99
DetThresh: 0.99
PVQA-Flag: false
PVQA-Weight: 1.0
Override:
MinLevelThreshold: 0
IsUseRMSPower: yes
MinRMSThreshold: -70
BOF Base DeadAir
StuckDeltaThreshold = 6
MinNonStuckTime = 80
MinStuckTime = 80
MinStartNonStuckTime = 1920
MinLevelThreshold = 256
SamplesType = UnKnownCodec
EOF Base DeadAir
BOF Base VADClipping
SamplesType = UnKnownCodec
EOF Base VADClipping
BOF DeadAir-01
MinLevelThreshold = 0
EOF DeadAir-01
Base EchoMono:
SamplesType: UnKnownCodec
StepLengthSec: 0.5
MinDelayMs: 50
MaxLengthMs: 2800
WindowFunckID: 0
SpanLengthMs: 50
BOF Silent-Call-Detection
MinLevelThreshold = 0
IsUseRMSPower = true
MinRMSThreshold = -70
EOF Silent-Call-Detection
Base SNR:
MinPowerThresh: 1.0000
LogEnergyCoefficient: 10.0000
MinSignalLevel: 40.0000
MinSNRDelta: 0.0001
MinEnergyDisp: 3.0000
MinEnergyDelta: 1.0000
SamplesType: UnKnownCodec
BOF Dynamic-Clipping
FlyAddingCoefficient = 0.1000
SamplesType = UnKnownCodec
IsUseDynamicClipping = true
EOF Dynamic-Clipping
Base DTMF:
SamplesType: UnKnownCodec
BOF Correction
IntStart = 5.0
IntEnd = 4.2
Mult = 1.0
#Shift = -1.7
Shift = 0
EOF Correction
Base AmpClipping:
FlyAddingCoefficient: 0.1000
IsUseDynamicClipping: no
SamplesType: UnKnownCodec
BOF Correction
IntStart = 4.2
IntEnd = 3.5
Mult = 1.0
#Shift = -0.85
Shift = 0
EOF Correction
Base Clicking:
SamplesType: UnKnownCodec
BOF SR Correction
SampleRate = 11000.0
Shift = 0.05
EOF SR Correction
Base DeadAir:
StuckDeltaThreshold: 6
MinNonStuckTime: 80
MinStuckTime: 80
MinStartNonStuckTime: 1920
MinLevelThreshold: 256
SamplesType: UnKnownCodec
BOF SR Correction
SampleRate = 16000.0
Shift = 0.1
EOF SR Correction
Base VADClipping:
SamplesType: UnKnownCodec
BOF SR Correction
SampleRate = 22000.0
Shift = 0.2
EOF SR Correction
Base Noise:
Interval: 0.1 # Seconds
DetectorType: RMS # This can be FFT as well
NoiseThreshold: 20
SignalThreshold: 80
Normalize: no
RemoveBias: no
ResultDb: yes
WindowType: Hann
WindowWidth: 3
BOF SR Correction
SampleRate = 32000.0
Shift = 0.3
EOF SR Correction
# Moved to Override: sections
# DeadAir-01:
# MinLevelThreshold: 0
BOF SR Correction
SampleRate = 48000.0
Shift = 0.45
EOF SR Correction
# SilentCall:
# MinLevelThreshold: 0
# IsUseRMSPower: yes
# MinRMSThreshold: -70
BOF SR Correction
SampleRate = 96000.0
Shift = 0.5
EOF SR Correction
# Dynamic-Clipping:
# FlyAddingCoefficient: 0.1000
# SamplesType: UnKnownCodec
# IsUseDynamicClipping: yes
BOF SR Correction
SampleRate = 192000.0
Shift = 0.6
EOF SR Correction
Correction:
- IntStart: 5.0
IntEnd: 4.2
Mult: 1.0
Shift: 0
BOF Scores Map
ScoresLine = 4;3.027000;2.935000;2.905000;2.818000;2.590000;2.432000;2.310000;1.665000;1.000000;
EOF Scores Map
- IntStart: 4.2
IntEnd: 3.5
Mult: 1.0
Shift: 0
SR Correction:
- SampleRate: 11000.0
Shift: 0.05
- SampleRate: 16000.0
Shift: 0.1
- SampleRate: 22000.0
Shift: 0.2
- SampleRate: 32000.0
Shift: 0.3
- SampleRate: 48000.0
Shift: 0.45
- SampleRate: 96000.0
Shift: 0.5
- SampleRate: 192000.0
Shift: 0.6
Scores Map:
ScoresLine: 4;3.027000;2.935000;2.905000;2.818000;2.590000;2.432000;2.310000;1.665000;1.000000;

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -25,8 +25,8 @@ rabbitmq:
cache_dir: cache
audio:
# Silence prefix & suffix lengths (in seconds)
silence_prefix: 30
silence_suffix: 30
silence_prefix: 10
silence_suffix: 10
bluetooth_mac: "MAC_ADDRESS"

View File

@ -0,0 +1,23 @@
# Defaults for hostapd initscript
#
# WARNING: The DAEMON_CONF setting has been deprecated and will be removed
# in future package releases.
#
# See /usr/share/doc/hostapd/README.Debian for information about alternative
# methods of managing hostapd.
#
# Uncomment and set DAEMON_CONF to the absolute path of a hostapd configuration
# file and hostapd will be started during system boot. An example configuration
# file can be found at /usr/share/doc/hostapd/examples/hostapd.conf.gz
#
DAEMON_CONF="/etc/hostapd/hostapd.conf"
# Additional daemon options to be appended to hostapd command:-
# -d show more debug messages (-dd for even more)
# -K include key data in debug messages
# -t include timestamps in some debug messages
#
# Note that -B (daemon mode) and -P (pidfile) options are automatically
# configured by the init.d script and must not be added to DAEMON_OPTS.
#
#DAEMON_OPTS=""

64
config/ap/etc/dhcpcd.conf Normal file
View File

@ -0,0 +1,64 @@
# A sample configuration for dhcpcd.
# See dhcpcd.conf(5) for details.
# Allow users of this group to interact with dhcpcd via the control socket.
#controlgroup wheel
# Inform the DHCP server of our hostname for DDNS.
hostname
# Use the hardware address of the interface for the Client ID.
clientid
# or
# Use the same DUID + IAID as set in DHCPv6 for DHCPv4 ClientID as per RFC4361.
# Some non-RFC compliant DHCP servers do not reply with this set.
# In this case, comment out duid and enable clientid above.
#duid
# Persist interface configuration when dhcpcd exits.
persistent
# Rapid commit support.
# Safe to enable by default because it requires the equivalent option set
# on the server to actually work.
option rapid_commit
# A list of options to request from the DHCP server.
option domain_name_servers, domain_name, domain_search, host_name
option classless_static_routes
# Respect the network MTU. This is applied to DHCP routes.
option interface_mtu
# Most distributions have NTP support.
#option ntp_servers
# A ServerID is required by RFC2131.
require dhcp_server_identifier
# Generate SLAAC address using the Hardware Address of the interface
#slaac hwaddr
# OR generate Stable Private IPv6 Addresses based from the DUID
slaac private
# Example static IP configuration:
#interface eth0
#static ip_address=192.168.0.10/24
#static ip6_address=fd51:42f8:caae:d92e::ff/64
#static routers=192.168.0.1
#static domain_name_servers=192.168.0.1 8.8.8.8 fd51:42f8:caae:d92e::1
# It is possible to fall back to a static IP if DHCP fails:
# define static profile
#profile static_eth0
#static ip_address=192.168.1.23/24
#static routers=192.168.1.1
#static domain_name_servers=192.168.1.1
# fallback to static profile on eth0
#interface eth0
#fallback static_eth0
#
#
interface wlan0
static ip_address=192.168.45.1/24
static routers=192.168.45.1

View File

@ -0,0 +1,5 @@
interface=wlan0 # Listening interface
dhcp-range=192.168.45.10,192.168.45.20,255.255.255.0,24h # Pool of IP addresses for wireless clients
domain=wlan # Domain
address=/gw.wlan/192.168.45.1 # Alias for router

View File

@ -0,0 +1,8 @@
country_code=GB
interface=wlan0
ssid=AGENT_GSM
hw_mode=g
channel=7
macaddr_acl=0
auth_algs=1
ignore_broadcast_ssid=0

View File

@ -0,0 +1,31 @@
[Unit]
Description=dnsmasq - A lightweight DHCP and caching DNS server
Requires=network.target
Wants=network-online.target
Before=nss-lookup.target
After=network-online.target
[Service]
Type=forking
PIDFile=/run/dnsmasq/dnsmasq.pid
# Test the config file and refuse starting if it is not valid.
ExecStartPre=/usr/sbin/dnsmasq --test
# We run dnsmasq via the /etc/init.d/dnsmasq script which acts as a
# wrapper picking up extra configuration files and then execs dnsmasq
# itself, when called with the "systemd-exec" function.
ExecStart=/etc/init.d/dnsmasq systemd-exec
# The systemd-*-resolvconf functions configure (and deconfigure)
# resolvconf to work with the dnsmasq DNS server. They're called like
# this to get correct error handling (ie don't start-resolvconf if the
# dnsmasq daemon fails to start.
ExecStartPost=/etc/init.d/dnsmasq systemd-start-resolvconf
ExecStop=/etc/init.d/dnsmasq systemd-stop-resolvconf
ExecReload=/bin/kill -HUP $MAINPID
[Install]
WantedBy=multi-user.target

View File

@ -3,6 +3,12 @@
# Oneliner to find script's directory. Please note - last path component should NOT be symlink.
SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0}"; )" &> /dev/null && pwd 2> /dev/null; )";
for pid in $(pidof -x run_agent.sh); do
if [ $pid != $$ ]; then
echo "[$(date)] : run_agent.sh : Process is already running with PID $pid. Exiting."
exit 1
fi
done
while :
do

View File

@ -10,8 +10,8 @@ INSTALL_DIR=agent_gsm
GIT_SOURCE=https://git.sevana.biz/public/agent_gsm
# Install prerequisites
sudo apt install --assume-yes git mc python3 sox vim libffi-dev screen python3-pip python3-numpy
sudo pip3 install pyyaml sox pyrabbit soundfile dbus_python pexpect pydub requests rabbitpy pydub
sudo apt install --assume-yes git mc python3 sox vim libffi-dev screen python3-pip python3-numpy dnsmasq hostapd screen
sudo pip3 install pyyaml sox pyrabbit soundfile dbus_python pexpect requests rabbitpy bottle
if [ -f "$INSTALL_DIR" ]; then
rm -rf "$INSTALL_DIR"
@ -45,7 +45,7 @@ cp config/agent.in.yaml config/agent.yaml
mkdir -p ~/.config/mc
cp config/mc/ini ~/.config/mc
# Replace the values
# Replace the values - finish preparing the agent configuration file
if [[ $BACKEND_URL != "" ]]; then
sed -i "s|BACKEND|$BACKEND|" config/agent.yaml
fi
@ -56,11 +56,45 @@ fi
sed -i "s|TASK_NAME|$TASK_NAME|" config/agent.yaml
# Update systemD unit file
cp config/systemd/agent_gsm.in.service config/systemd/agent_gsm.service
ABSOLUTE_INSTALL_DIR=`realpath .`
sed -i "s|ABSOLUTE_INSTALL_DIR|$ABSOLUTE_INSTALL_DIR|" config/systemd/agent_gsm.service
# Update systemD unit file
# cp config/systemd/agent_gsm.in.service config/systemd/agent_gsm.service
# sed -i "s|ABSOLUTE_INSTALL_DIR|$ABSOLUTE_INSTALL_DIR|" config/systemd/agent_gsm.service
install_ap() {
# $1 is AP name
sudo cp $ABSOLUTE_INSTALL_DIR/config/ap/etc/dhcpcd.conf /etc
sudo cp $ABSOLUTE_INSTALL_DIR/config/ap/etc/dnsmasq.conf /etc
sudo mkdir -p /etc/hostapd
sudo cp $ABSOLUTE_INSTALL_DIR/config/ap/etc/hostapd.conf /etc/hostapd
sudo sed -i "s|AGENT_GSM|$1|" /etc/hostapt/hostapd.conf
sudo cp $ABSOLUTE_INSTALL_DIR/config/ap/etc/default/hostapt /etd/default
sudo systemctl enable dnsmasq
sudo systemctl enable hostapd
sudo systemctl start dnsmasq
sudo systemctl start hostapd
}
function enable_autologin() {
sudo systemctl --quiet set-default multi-user.target
sudo cat > /etc/systemd/system/getty@tty1.service.d/autologin.conf << EOF
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin $USER --noclear %I \$TERM
EOF
}
# ToDo:
# - allow autologin in console mode for 'pi' user
enable_autologin
# - add $ABSOLUTE_INSTALL_DIR/run_agent_screen.sh to ~/.bashrc
echo "$ABSOLUTE_INSTALL_DIR/run_agent_screen.sh" >> ~/.bashrc
# - install wifi AP with name $PHONE_NAME
install_ap $PHONE_NAME
echo "Now the remaining prerequisites will be installed and system will reboot."
echo "You can connect the phone via Bluetooth after the reboot."

View File

@ -36,8 +36,8 @@ class AgentConfig:
# Should the first task run immediately ?
ForceRun = False
# Use silence eraser or not (speech detector is used in this case)
UseSilenceEraser = True
# Use external speech detector if needed
UseSpeechDetector = False
# Path to log file
LogPath : Path = None
@ -90,7 +90,7 @@ class AgentConfig:
if 'speech_detector' in config:
if config['speech_detector']:
self.UseSilenceEraser = False
self.UseSpeechDetector = True
if 'audio' in config:
audio = config['audio']

View File

@ -24,6 +24,7 @@ from bt_signal import SignalBoundaries
from bt_call_controller import INTERRUPT_SIGNAL
import bt_call_controller
import agent_point
CONFIG = AgentConfig()
@ -68,17 +69,23 @@ def detect_degraded_signal(file_test: Path, file_reference: Path) -> SignalBound
# Seems some problem with recording, return zero boundaries
return SignalBoundaries()
r = bt_signal.find_reference_signal(file_test)
r = SignalBoundaries()
if CONFIG.UseSpeechDetector:
r = bt_signal.find_reference_signal_via_speechdetector(file_test)
if r.offset_start == 0.0 and is_caller:
r.offset_start = 5.0 # Skip ringing tones
return r
def detect_reference_signal(file_reference: Path) -> SignalBoundaries:
# Run silence eraser on reference file as well
result = bt_signal.find_reference_signal(file_reference)
result = SignalBoundaries()
if CONFIG.UseSpeechDetector:
result = bt_signal.find_reference_signal_via_speechdetector(file_reference)
return result
@ -92,7 +99,8 @@ def upload_results():
# Path to audio
path_audio = t[1]
utils.log(f'Found {path_report.name} and {path_audio.name} files.')
utils.log(f'Found {t} report pair.')
if path_report is not None and path_report.exists():
try:
with open(path_report, 'rt') as f:
report = json.loads(f.read())
@ -104,26 +112,16 @@ def upload_results():
if success:
utils.log(f'Report {upload_id} is uploaded ok.')
# Rename files to make sync audio filename with reported ones
# path_report_fixed = CACHE.dir / f'{upload_id}.json'
# path_report = path_report.rename(path_report_fixed)
# path_audio_fixed = CACHE.dir / f'{upload_id}.wav'
# path_audio = path_audio.rename(path_audio_fixed)
if path_audio.exists():
utils.log(f'Uploading {path_audio.name} file...')
# Upload recorded audio
upload_result = BACKEND.upload_audio(upload_id, path_audio)
if upload_result:
utils.log(f' Recorded audio {upload_id}.wav is uploaded ok.')
os.remove(path_audio)
else:
utils.log(f'No recorded audio file found, skipping audio upload.')
os.remove(path_report)
else:
utils.log(f'Failed to upload report {path_report.name}')
break
if path_audio is not None and path_audio.exists():
utils.log(f'Uploading {path_audio.name} file...')
# Upload recorded audio
upload_result = BACKEND.upload_audio(path_audio.stem, path_audio)
if upload_result:
utils.log(f' Recorded audio {path_audio.stem}.wav is uploaded ok.')
os.remove(path_audio)
def run_analyze(file_test: str, file_reference: str, number: str) -> bool:
global CALL_COUNTER
@ -147,25 +145,28 @@ def run_analyze(file_test: str, file_reference: str, number: str) -> bool:
utils.log(f'Recorded audio call duration: {test_audio_length}s, reference audio length: {ref_audio_length}s')
# Check if audio length is strange - skip such calls. Usually this is missed call.
is_caller_audio_big = is_caller and test_audio_length > ref_audio_length * 1.5
is_answerer_audio_big = is_answerer and test_audio_length > ref_audio_length * 1.5
is_caller_audio_big = is_caller and test_audio_length > ref_audio_length * 3
is_answerer_audio_big = is_answerer and test_audio_length > ref_audio_length * 3
if is_caller_audio_big or is_answerer_audio_big:
utils.log_error(f'Recorded call is too big - looks like mobile operator prompt, skipping analysis')
return False
try:
bounds_signal : SignalBoundaries = detect_degraded_signal(Path(file_test), Path(file_reference))
# bounds_signal.offset_start = 0
# bounds_signal.offset_finish = 0
print(f'Found signal bounds: {bounds_signal}')
bounds_signal = SignalBoundaries()
if is_caller:
bounds_signal.offset_start = 10.0 # Skip ringtones
bounds_signal.offset_finish = 1.0 # Eat possible end tone
elif is_answerer:
bounds_signal.offset_start = 0.0
bounds_signal.offset_finish = 1.0 # Eat possible end tone
# PVQA report
pvqa_mos, pvqa_report, pvqa_rfactor = utils_sevana.find_pvqa_mos(file_test, bounds_signal.offset_start, bounds_signal.offset_finish)
utils.log(f'PVQA MOS: {pvqa_mos}, PVQA R-factor: {pvqa_rfactor}')
# AQuA report
bounds_reference : SignalBoundaries = detect_reference_signal(Path(file_reference))
bounds_reference : SignalBoundaries = SignalBoundaries()
bounds_reference.offset_start = 0
bounds_reference.offset_finish = 0
@ -261,7 +262,7 @@ def make_call(target: str):
timelimit_seconds=ref_time_length,
target=target)
run_analyze(CONFIG.RecordFile, CONFIG.ReferenceAudio, target)
run_analyze(CONFIG.RecordFile, CONFIG.PreparedReferenceAudio, target)
except Exception as e:
utils.log_error(f'BT I/O failed finally. Error: {str(e)}')
@ -297,7 +298,7 @@ def perform_answerer():
break
# Call analyzer script
run_analyze(CONFIG.RecordFile, CONFIG.ReferenceAudio, '')
run_analyze(CONFIG.RecordFile, CONFIG.PreparedReferenceAudio, '')
# Increase counter of attempts
attempt_idx += 1
@ -354,10 +355,10 @@ def run_caller_task(t):
make_call(target_addr)
# Runs caller probe - load task list and perform calls
def run_probe():
global TASK_LIST, CURRENT_TASK
offline_mode : bool = False
while True:
# Get task list update
@ -366,6 +367,7 @@ def run_probe():
# Check in cache
utils.log('Checking for task list in cache...')
new_tasks = CACHE.get_tasks(BACKEND.phone.name)
offline_mode = True
# Did we fetch anything ?
if new_tasks:
@ -386,7 +388,7 @@ def run_probe():
if TASK_LIST.tasks is not None:
utils.log_verbose(f"Resulting task list: {TASK_LIST.tasks}")
# Run test immediately if specified
if CONFIG.ForceRun and len(TASK_LIST.tasks) > 0:
run_caller_task(TASK_LIST.tasks[0])
break
@ -401,7 +403,9 @@ def run_probe():
# Remove sheduled time
del t['scheduled_time']
# Run task
# Run task if we are online
# Otherwise tasks run from the API point - via helper .apk
if not offline_mode:
run_caller_task(t)
utils.log_verbose(f'Call #{CALL_COUNTER.value} finished')
@ -413,15 +417,35 @@ def run_probe():
except Exception as err:
utils.log_error(message="Unexpected error.", err=err)
# Sleep for
spent_time = utils.get_monotonic_time() - start_time
# Wait 1 minute
if spent_time < 60:
time.sleep(60 - spent_time)
timeout_time = 60 - spent_time
else:
timeout_time = 0
# Try to get next task
try:
if agent_point.WEB_QUEUE is None:
utils.log('Web task queue is None')
task = agent_point.WEB_QUEUE.get(block=True, timeout=timeout_time)
if task is not None:
run_caller_task(task)
except multiprocessing.Queue.empty:
# Ignore this exception, this is normal
pass
except Exception as err:
utils.log_error(message='Error when running t')
# In case of empty task list wait 1 minute before refresh
if len(TASK_LIST.tasks) == 0:
time.sleep(60)
# if len(TASK_LIST.tasks) == 0:
# time.sleep(60)
def remove_pid_on_exit():
@ -436,6 +460,9 @@ def receive_signal(signal_number, frame):
# Delete PID file
remove_pid_on_exit()
# Stop optional access point
agent_point.stop()
# Debugging info
print(f'Got signal {signal_number} from {frame}')
@ -447,7 +474,6 @@ def receive_signal(signal_number, frame):
return
# Check if Python version is ok
assert sys.version_info >= (3, 6)
@ -471,6 +497,14 @@ if __name__ == '__main__':
signal.signal(signal.SIGINT, receive_signal)
signal.signal(signal.SIGQUIT, receive_signal)
if CONFIG.CacheDir:
CACHE = utils_cache.InfoCache(dir=CONFIG.CacheDir)
# Start own hotspot and API server
agent_point.CONFIG = CONFIG
agent_point.CACHE = CACHE
agent_point.start()
# Preconnect the phone
if CONFIG.BT_MAC:
# Connect to phone before
@ -482,7 +516,7 @@ if __name__ == '__main__':
utils.log_error(f'No BT MAC specified, cannot connect. Exiting.')
raise SystemExit(EXIT_ERROR)
# Init BT modem
# Init BT modem - here we wait for it
bt_call_controller.init()
# Logging settings
@ -491,18 +525,12 @@ if __name__ == '__main__':
if CONFIG.LogPath:
utils.open_log_file(CONFIG.LogPath, 'at')
if CONFIG.CacheDir:
CACHE = utils_cache.InfoCache(dir=CONFIG.CacheDir)
# Update path to pvqa/aqua-wb
VOICE_QUALITY_AVAILABLE = utils_sevana.find_binaries(DIR_PROJECT / 'bin')
# Load latest licenses & configs - this requires utils_sevana.find_binaries() to be called before
# utils_sevana.load_config_and_licenses(config['backend'])
# Limit number of calls
if CONFIG.TaskLimit:
utils.log(f'Limiting number of calls to {CONFIG.TaskLimit}')
@ -570,4 +598,7 @@ if __name__ == '__main__':
# Close log file
utils.close_log_file()
# Stop optional access point
agent_point.stop()
sys.exit(EXIT_OK)

133
src/agent_point.py Executable file
View File

@ -0,0 +1,133 @@
#!/usr/bin/python3
import bottle
import multiprocessing
import time
import os
import json
import utils_cache
from agent_config import AgentConfig
class AccessPoint:
active: bool = False
def __init__(self) -> None:
pass
def start(self):
pass
def stop(self):
pass
# Just a stub for now
ACCESS_POINT = AccessPoint()
# Web server process
SERVER_PROCESS = None
# Good status response
RESPONSE_OK = {'status': 'ok'}
# Available information in cache
CACHE : utils_cache.InfoCache = None
CONFIG: AgentConfig = None
# Web queue
WEB_QUEUE = multiprocessing.Manager().Queue()
@bottle.route('/status')
def web_status():
print(f'Serving /status request...')
r = RESPONSE_OK
if CONFIG is not None:
r['name'] = CONFIG.Name
r['backend'] = CONFIG.Backend
r['bt_mac'] = CONFIG.BT_MAC
if CACHE is not None:
print('Cache is found...')
# Phone information
phone = CACHE.get_phone(CONFIG.Name)
if phone is not None:
print('Phone information is found...')
r['phone'] = phone.to_dict()
# Task list information
task_list = CACHE.get_tasks(CONFIG.Name)
if task_list is not None and task_list.tasks is not None:
r['task_list'] = task_list.tasks
else:
print('Cache not found.')
return r
@bottle.route('/reboot')
def web_reboot():
os.system('sudo reboot')
return RESPONSE_OK
@bottle.route('/halt')
def web_reboot():
os.system('sudo halt')
return RESPONSE_OK
@bottle.route('/cache')
def web_list_cache():
result = []
if CACHE is None:
return result
# Iterate cache and return available files list
for f in os.listdir(CACHE.dir):
result.append(f)
return result
@bottle.route('/call', method=['POST'])
def web_call():
global WEB_QUEUE
try:
data = bottle.request.json
# Send task definition
print('Sending data to ougoing queue...')
WEB_QUEUE.put_nowait(data)
print('Returning OK response.')
return RESPONSE_OK
except Exception as e:
print(f'{str(e)}')
return RESPONSE_OK
def web_process(mp_queue: multiprocessing.Queue):
#global WEB_QUEUE
#WEB_QUEUE = mp_queue
print(f'Run web process...')
bottle.run(host='0.0.0.0', port=8080)
def start():
global ACCESS_POINT, SERVER_PROCESS
ACCESS_POINT.start()
SERVER_PROCESS = multiprocessing.Process(target=web_process, args=(None,), name='agent_gsm_web')
SERVER_PROCESS.start()
def stop():
global ACCESS_POINT, SERVER_PROCESS
ACCESS_POINT.stop()
SERVER_PROCESS.kill()
if __name__ == '__main__':
# Start test stuff
start()
# Wait 120 seconds for tests
time.sleep(120.0)
# Stop test
stop()

View File

@ -3,51 +3,46 @@
import sys
import os
import pathlib
from utils_types import SignalBoundaries
from utils_sevana import speech_detector
from pydub import silence, AudioSegment
# from pydub import silence, AudioSegment
class SignalBoundaries:
# Offset from start (in seconds)
offset_start: float
SILENCE_DELTA = 16
# Offset from finish (in seconds)
offset_finish: float
# def find_reference_signal(input_file: pathlib.Path, output_file: pathlib.Path = None, use_end_offset: bool = True) -> SignalBoundaries:
# myaudio = AudioSegment.from_wav(str(input_file))
# dBFS = myaudio.dBFS
def __init__(self, offset_start = 0.0, offset_finish = 0.0) -> None:
self.offset_start = offset_start
self.offset_finish = offset_finish
# # Find silence intervals
# intervals = silence.detect_nonsilent(myaudio, min_silence_len=1000, silence_thresh=dBFS-SILENCE_DELTA, seek_step=50)
def __repr__(self) -> str:
return f'[offset_start: {round(self.offset_start, 3)}, offset_finish : {round(self.offset_finish, 3)}]'
# # Translate to seconds
# intervals = [((start/1000),(stop/1000)) for start,stop in intervals] # in sec
# # print(intervals)
# # Example of intervals: [(5.4, 6.4), (18.7, 37.05)]
# for p in intervals:
# if p[1] - p[0] > 17:
# bounds = SignalBoundaries(offset_start=p[0], offset_finish=p[1])
# if output_file is not None:
# signal = myaudio[bounds.offset_start * 1000 : bounds.offset_finish * 1000]
# signal.export(str(output_file), format='wav', parameters=['-ar', '44100', '-sample_fmt', 's16'])
# if use_end_offset:
# bounds.offset_finish = myaudio.duration_seconds - bounds.offset_finish
# return bounds
# return SignalBoundaries()
def find_reference_signal(input_file: pathlib.Path, output_file: pathlib.Path = None, use_end_offset: bool = True) -> SignalBoundaries:
myaudio = AudioSegment.from_wav(str(input_file))
dBFS = myaudio.dBFS
# Find silence intervals
intervals = silence.detect_nonsilent(myaudio, min_silence_len=1000, silence_thresh=dBFS-17, seek_step=50)
# Translate to seconds
intervals = [((start/1000),(stop/1000)) for start,stop in intervals] #in sec
# print(intervals)
# Example of intervals: [(5.4, 6.4), (18.7, 37.05)]
for p in intervals:
if p[1] - p[0] > 17:
bounds = SignalBoundaries(offset_start=p[0], offset_finish=p[1])
if output_file is not None:
signal = myaudio[bounds.offset_start * 1000 : bounds.offset_finish * 1000]
signal.export(str(output_file), format='wav', parameters=['-ar', '44100', '-sample_fmt', 's16'])
if use_end_offset:
bounds.offset_finish = myaudio.duration_seconds - bounds.offset_finish
def find_reference_signal_via_speechdetector(input_file: pathlib.Path) -> SignalBoundaries:
bounds = speech_detector(str(input_file))
r = SignalBoundaries(bounds[0], bounds[1])
return bounds
return SignalBoundaries()
if __name__ == '__main__':
if len(sys.argv) < 2:
print(f'Please specify input filename.')

View File

@ -19,8 +19,6 @@ class InfoCache:
utils.log_error(str(e))
self.dir = None
def is_active(self) -> bool:
return self.dir is not None
@ -80,11 +78,16 @@ class InfoCache:
lst = os.listdir(self.dir)
for n in lst:
p = self.dir / n
if self.is_valid_uuid(p.stem) and n.endswith('.json'):
if self.is_valid_uuid(p.stem) and (n.endswith('.json') or n.endswith(".wav")):
# Probe found
p_json = p.with_suffix('.json')
p_audio = p.with_suffix('.wav')
if p_audio.exists():
r.append((p, p.with_suffix('.wav')))
if p_json.exists() and p_audio.exists():
r.append((p_json, p_audio))
elif p_json.exists():
r.append((p_json, None))
elif p_audio.exists():
r.append((None, p_audio))
return r

View File

@ -58,6 +58,7 @@ TRACE_TOTAL_TIMEOUT = 30
# a webpage is mostly I/O bound, it's not going to be significant.
def trace_function(frame, event, arg):
global TRACE_START_TIME
if time.time() - TRACE_START_TIME > TRACE_TOTAL_TIMEOUT:
raise Exception('Timed out!') # Use whatever exception you consider appropriate.
@ -65,12 +66,13 @@ def trace_function(frame, event, arg):
class QualtestBackend:
address: str
instance: str
online: bool
def __init__(self):
self.address = ""
self.instance = ""
self.__phone = None
self.online = False
@property
def phone(self) -> Phone:
@ -114,6 +116,7 @@ class QualtestBackend:
def upload_audio(self, probe_id, path_recorded: Path):
global TRACE_START_TIME
result = False
# Log about upload attempt
@ -188,8 +191,17 @@ class QualtestBackend:
response = urllib.request.urlopen(url, timeout=utils.NETWORK_TIMEOUT)
if response.getcode() != 200:
raise RuntimeError(f'Failed to load phone definition from server. Error code: {response.getcode()}')
# Consider backend as working one
self.online = True
except Exception as e:
utils.log_error(f'Problem when loading the phone definition from backend. Error: {str(e)}')
# Consider backend as non-working
self.online = False
# Try to get data from the cache
r = cache.get_phone(self.instance)
if r is None:
raise RuntimeError(f'No cached phone definition.')

View File

@ -12,8 +12,6 @@ import time
import urllib
from pathlib import Path
from colorama import Fore, Style
from utils_cache import InfoCache
PVQA_CMD = "{pvqa} --license {pvqa_lic} --config {pvqa_cfg} --mode analysis --channel 0 " \
"--report {output} --input {input} --cut-begin {cut_begin} --cut-end {cut_end}"
@ -21,10 +19,8 @@ PVQA_CMD = "{pvqa} --license {pvqa_lic} --config {pvqa_cfg} --mode analysis --ch
PVQA_CMD_LIC_SERVER = "{pvqa} --license-server {pvqa_lic} --config {pvqa_cfg} --mode analysis --channel 0 " \
"--report {output} --input {input}"
AQUA_CMD = ("{aqua} {aqua_lic} -mode files -src file \"{reference}\" -tstf \"{input}\" -avlp off -smtnrm on "
"-decor off -mprio off -acr auto -npnt auto -voip on -enorm rms -g711 off "
"-spfrcor on -grad off -tmc on -hist-pitch on on -hist-levels on on on -miter 1 -specp 32 {spectrum} "
"-ratem %%m -fau {faults} -output json -trim r 15 -cut-tst {cut_begin} {cut_end} -cut-src {cut_begin_src} {cut_end_src}")
AQUA_CMD = ("{aqua} {aqua_lic} -mode files -src file \"{reference}\" -tstf \"{input}\" -config {aqua_config} "
"-specp 32 {spectrum} -fau {faults} -cut-tst {cut_begin} {cut_end} -cut-src {cut_begin_src} {cut_end_src}")
PVQA_PATH = ""
PVQA_LIC_PATH = "pvqa.lic"
@ -32,6 +28,7 @@ PVQA_CFG_PATH = "pvqa.cfg"
AQUA_PATH = ""
AQUA_LIC_PATH = "aqua-wb.lic"
AQUA_CFG_PATH = "aqua.cfg"
SILER_PATH = ""
@ -78,12 +75,16 @@ def load_config_and_licenses(server: str):
load_file(utils.join_host_and_path(server, '/deploy/pvqa.cfg'), PVQA_CFG_PATH)
load_file(utils.join_host_and_path(server, '/deploy/pvqa.lic'), PVQA_LIC_PATH)
load_file(utils.join_host_and_path(server, '/deploy/aqua-wb.lic'), AQUA_LIC_PATH)
load_file(utils.join_host_and_path(server, '/deploy/aqua.cfg'), AQUA_CFG_PATH)
except Exception as e:
utils.log_error(f'Failed to fetch new licenses and config. Skipping it.')
def find_binaries(bin_directory: Path, license_server: str = None) -> bool:
# Update path to pvqa/aqua-wb
global PVQA_CFG_PATH, PVQA_LIC_PATH, AQUA_LIC_PATH, PVQA_PATH, AQUA_PATH, PVQA_CMD, AQUA_CMD, SILER_PATH, SPEECH_DETECTOR_PATH
global PVQA_CFG_PATH, PVQA_LIC_PATH, AQUA_LIC_PATH, AQUA_CFG_PATH
global PVQA_PATH, AQUA_PATH, PVQA_CMD, AQUA_CMD
global SILER_PATH, SPEECH_DETECTOR_PATH
# Find platform prefix
platform_prefix = platform.system().lower()
@ -94,8 +95,9 @@ def find_binaries(bin_directory: Path, license_server: str = None) -> bool:
PVQA_LIC_PATH = bin_directory / PVQA_LIC_PATH
PVQA_CFG_PATH = bin_directory / PVQA_CFG_PATH
AQUA_PATH = bin_directory / platform_prefix / AQUA_PATH
AQUA_CFG_PATH = bin_directory / AQUA_CFG_PATH
AQUA_LIC_PATH = bin_directory / AQUA_LIC_PATH
SILER_PATH = bin_directory / platform_prefix / SILER_PATH
# SILER_PATH = bin_directory / platform_prefix / SILER_PATH
SPEECH_DETECTOR_PATH = bin_directory / platform_prefix / SPEECH_DETECTOR_PATH
utils.log(f'Looking for binaries/licenses/configs at {bin_directory}...')
@ -109,16 +111,23 @@ def find_binaries(bin_directory: Path, license_server: str = None) -> bool:
PVQA_CFG_PATH = Path(utils.get_script_path()) / 'pvqa.cfg'
if not PVQA_CFG_PATH.exists():
utils.log_error(f'Failed to find pvqa config.')
utils.log_error(f'Failed to find PVQA config file.')
return False
if not AQUA_CFG_PATH.exists():
AQUA_CFG_PATH = Path(utils.get_script_path()) / 'aqua.cfg'
if not AQUA_CFG_PATH.exists():
utils.log_error(f'Failed to find AQuA config file.')
return False
if not AQUA_PATH.exists():
utils.log_error(f'Failed to find aqua-wb binary.')
return False
if not SILER_PATH.exists():
utils.log_error(f'Failed to find silence_eraser binary..')
return False
# if not SILER_PATH.exists():
# utils.log_error(f'Failed to find silence_eraser binary..')
# return False
if license_server is not None:
AQUA_LIC_PATH = '"license://' + license_server + '"'
@ -238,7 +247,7 @@ def find_aqua_mos(good_path, test_path, test_file_offset_begin: float = 0.0, tes
good_file_offset_begin: float = 0.0, good_file_offset_end: float = 0.0):
try:
out_data = ""
cmd = AQUA_CMD.format(aqua=AQUA_PATH, aqua_lic=AQUA_LIC_PATH,
cmd = AQUA_CMD.format(aqua=AQUA_PATH, aqua_lic=AQUA_LIC_PATH, aqua_config = AQUA_CFG_PATH,
reference=good_path, input=test_path, spectrum=AQUA_SPECTRUM,
faults=AQUA_FAULTS,
cut_begin=int(test_file_offset_begin * 1000), cut_end=int(test_file_offset_end * 1000),

View File

@ -6,12 +6,25 @@ import utils
import json
from crontab import CronTab
# Exit codes
EXIT_OK = 0
EXIT_ERROR = 1
class SignalBoundaries:
# Offset from start (in seconds)
offset_start: float
# Offset from finish (in seconds)
offset_finish: float
def __init__(self, offset_start = 0.0, offset_finish = 0.0) -> None:
self.offset_start = offset_start
self.offset_finish = offset_finish
def __repr__(self) -> str:
return f'[offset_start: {round(self.offset_start, 3)}, offset_finish : {round(self.offset_finish, 3)}]'
class Phone:
identifier: int = 0
name: str = ""