Реализация голосового меню на perl через usb модем Huawei e1550

в 4:46, , рубрики: dtmf, linux, perl, usb модем, голосовое меню, Программирование, телефония, метки: , , , , , ,

Совсем недавно я написал пост в котором дал немного теории, и описал практическую реализацию скрипта производящего голосовой обзвон (оповещение) абонентов по списку через usb модем Hyawei e1550. В одном из комментариев был задан вопрос о том как получить во время голосового соединения данные о нажатии кнопок на телефоне абонента. Детальное изучение этого вопроса и привело к созданию этого поста.

В данной статье будет представлена реализация колосового меню, с функциями:
— записи голосового сообщения
— выполнения системных команд
Все это стало возможным благодаря реализации декодера DTMF сигналов основанного на алгоритме Гёрцеля.
В качестве бонуса — архив с реализованными на perl-е скриптами голосового меню.

Среда разработки

операционная система: Linux
Дистрибутив: openSUSE 12.3
Ядро: 3.7.10-1.16-desktop #1 SMP PREEMPT Fri May 31 20:21:23 UTC 2013 (97c14ba) i686 i686 i386 GNU/Linux
Язык программирования: Perl
usb модем: Huawei e1550

Приступим

Реализация содержит следующие файлы и папки:
1. voice_menu.pl — основной скрипт с реализацией функций голосового меню
2. dtmf_decoder.pm — модуль декодирования dtmf сигналов (нажатия кнопок телефона в режиме тонового набора)
3. menu.01.pl — содержит описание голосового меню
4. menu.01 — папка с аудио файлами для menu.01.pl
5. messages — папка с записями голосовых сообщений

voice_menu.pl

#!/usr/bin/perl

use v5.16;              # использовать версию Perl не ниже указанной
use strict;             # включить дополнительные проверки
use warnings;           # и расширенную диагностику
use diagnostics;        # выводить подробную диагностику ошибок
use utf8;
use locale;
no warnings 'utf8';

# подключаем модуль Time::HiRes и импортируем
# в текущее пространство имен функцию sleep
# особенность данной функции - возможность указывать
# задержку меньше секунды
use Time::HiRes qw(sleep usleep gettimeofday);

# подключаем модуль dtmf_decoder
use dtmf_decoder;


# Для информации:
# Сообщения типа CEND выдаются модемом при завершении вызова
# и содержат в себе информацию о вызове, о причине завершения вызова
# и о состоянии устройства.
# формат вывода ^CEND:call_index, duration, end_status, cc_cause
# где:
# call_index - уникальный идентификатор вызова
# duration - длительность вызова в секундах
# end_status - код статуса устройства после завершения вызова
# cc_cause - код причины завершения вызова

# при подключении модема к компьютеру с OS Linux
# создаются 3 usb интерфейса для обмена данными с модемом
# обычно это:
# /dev/ttyUSB0 - командный интерфейс модема
# /dev/ttyUSB1 - голосовой(при включенном голосовом режиме) интерфейс модема
# /dev/ttyUSB2 - командный интерфейс модема. Отличается от /dev/ttyUSB0 тем
# что с него можно читать не только ответы модема на команды, а также служебные
# сообщения. Такие как данные о качестве сигнала, вывод ^CEND и прочее

# указываем порт для отсылки модему звука
my $VOICE_PORT = "/dev/ttyUSB4";

# указываем порт для подачи модему команд
my $COMMAND_PORT = "/dev/ttyUSB5";

# устанавливаем в:
# 0 - чтобы отключить вывод отладочной информации
# 1 - чтобы включить вывод отладочной информации
my $VERBOSE = 0;

# Открываем командный порт модема на чтение и запись
open my $SENDPORT, '+<', $COMMAND_PORT or die "Can't open '$COMMAND_PORT': $!n";

# Открываем голосовой  порт модема на чтение и запись
# чтение аудио потока из порта в данной программе не используется
# но вам ничто не мешает превратить данный скрипт в автоответчик например
open my $SENDPORT_WAV, '+<', $VOICE_PORT or die "Can't open '$VOICE_PORT': $!n";


# вызываем функцию ожидания вызовов, которой передаются 1 параметр:
#  - имя файла с голосовым меню
expect_calls('menu.01.pl');

# по окончании обзвона закрываем все открытые файлы/порты
exit_call();



# данная функция производит обзвон абонентов по списку
sub expect_calls{
    # получаем имя файла с голосовым меню
    my $l_file = shift;

    # загружаем голосовое меню (файл menu.01.pl)
    my $menu = load_menu('menu.01.pl'); 

    # данная команда включает в модеме голосовой режим
    # один раз включив его можно удалить/заремарить
    # эту команду. Модем запомнит состояние.
    #at_send('AT^CVOICE=0'); 

    # данная команда включает в модеме отображение номера звонящего
    my $l_rec = at_send("AT+CLIP=1",qr/^(OK|ERROR)/);


    # цикл ожидания входящего звонка
    while ( ) {
        # при входящем звонке должно поступить сообщение RING
        $l_rec = at_rec(qr/^(RING)/);
        accept_call($menu);
    }
}


# данная функция производит попытку вызова указного номера
# и в случае успеха - транслирует голосовое сообщение
sub accept_call{
    my $menu = shift;

    # в этом массиве хранится стtк перемещений по меню
    my $position = [$menu];

    # текущее меню
    my $cmenu = $position->[0];

    my %call_info = ();
    # запоминаем время начала
    $call_info{start_time} = time;
    # ждем сообщения с номером телефона звонящего абонента #+CLIP: "+79117654321",145,,,,0
    $call_info{phone} = at_rec(qr/^+CLIP: "(+d+)/);
    $call_info{phone} =~s/^+d//;
    # генерим имя файла для записи
    $call_info{record_fname} = "phone_$call_info{phone}.time_$call_info{start_time}";

    # принимаем входящий вызов
    my $l_rec = at_send("ATA",qr/^(OK|ERROR)/);
    return 0 if $l_rec eq "ERROR";

    # ожидаем установления соединения
    $l_rec = at_rec(qr/^^??(CONN:1|CEND:|ERROR)/);
    return 0 if $l_rec ne "CONN:1";

    # переключаем модем в режим приема/передачи голоса
    # OK - переключение прошло успешно
    # ERROR - переключение не произведено
    # CEND:.... - абонент недоступен, занят или сбросил вызов
    $l_rec = at_send('AT^DDSETEX=2',qr/(OK|ERROR|CEND:)/);
    return 0 if $l_rec ne "OK";

    # Если дошли до сюда - значит вызов установлен
    # Звук модему и от него передается порциями по 320 байт каждые 0.02 секунды
    print "time: [$call_info{start_time}] tphone: [$call_info{phone}] t"."Вызов принят.n";

    my $checker = 0;

    my $dtmf = 0;

    # буфер для входящих аудиоданных данных
    my $snd_in;
    
    # буфер для исходящих аудиоданных данных
    my $snd_out = $cmenu->{info_voice};
    my $snd_count = 0;
    my $snd_max = scalar @{$snd_out};

    # открываем файл для записи входящего аудиопотока
    my $l_fh = new IO::File "> ./messages/$call_info{record_fname}.raw" or die "Cannot open $call_info{record_fname}.raw : $!";
    binmode($l_fh);

    # Устанавливаем служебную переменную $| в единицу это отключает буферизацию.
    # Таким образом данные в звуковой порт будут отправляться незамедлительно.
    $|=1;

    # проигрываем приветстви
    #play_voice($snd_out);

    # запоминаем время для отсчета 0.02 секунд
    my $before = gettimeofday;

    # основной цикл голосового меню
    while (){
        if ($snd_count == $snd_max) {
            if ($cmenu->{record}==1){
                    $snd_out = $menu->{standart_messages}{null}{title_voice};
                    $snd_max = scalar @{$snd_out};
                    $cmenu->{record}=2;
                    print "time: [$call_info{start_time}] tphone: [$call_info{phone}] tПроизводится запись голосового сообщения в [./messages/$call_info{record_fname}.raw].n";
            }

            $snd_count = 0;
        }

        syswrite  $SENDPORT_WAV, $snd_out->[$snd_count] , 320;

        sysread $SENDPORT_WAV, $snd_in, 320;
        syswrite  $l_fh, $snd_in, 320 if $cmenu->{record} && $cmenu->{record} == 2;

        $dtmf = dtmf_sample($snd_in);

        if ($dtmf) {
            #print "time: [$call_info{start_time}] tphone: [$call_info{phoe}] tНажата кнопка [$dtmf].n";
            if ($dtmf eq '#') {
                print "time: [$call_info{start_time}] tphone: [$call_info{phone}] tВыбран возврат в главное меню.n";
                $position = [$menu];
                $cmenu = $position->[0];
                $snd_out = $menu->{info_voice};
                $snd_count = 0;
                $snd_max = scalar @{$snd_out};
            } elsif ($dtmf eq '*') {
                if ((scalar @{$position}) > 1) {
                    print "time: [$call_info{start_time}] tphone: [$call_info{phone}] tВыбран возврат в предыдущее меню.n";
                    shift @{$position};
                    $cmenu = $position->[0];
                    $snd_out = $cmenu->{info_voice};
                    $snd_count = 0;
                    $snd_max = scalar @{$snd_out};
                }
            } elsif ($cmenu->{menu}) {
                if ($cmenu->{menu}{$dtmf}) {
                    $cmenu = $cmenu->{menu}{$dtmf};
                    print "time: [$call_info{start_time}] tphone: [$call_info{phone}] tВыбран пункт меню [$cmenu->{title}].n";
                    unshift @{$position}, $cmenu;
                    $snd_out = $cmenu->{info_voice};
                    $snd_count = 0;
                    $snd_max = scalar @{$snd_out};
                    if ($cmenu->{command}) {
                        print "time: [$call_info{start_time}] tphone: [$call_info{phone}] tВыполнена команда [$cmenu->{command}].n";
                        system "$cmenu->{command} &";
                    }
                } 
            }
        }

        # мониторим состояние звонка
        if ($checker==10) {
            $l_rec = at_send("AT+CLCC",qr/^^??(OK|ERROR|CEND)/);
            # выходим если сброшен
            if ($l_rec eq "CEND") {
                print "time: [$call_info{start_time}] tphone: [$call_info{phone}] tВызов завершен.n";
                return 0
            }
            $checker=0;
        }

        # ряд управляющих циклом переменных
        $dtmf=0;
        $checker++;
        $snd_count++;

        # ожидаем остаток времени
        while( gettimeofday-$before < 0.02 ) { }
        $before = gettimeofday;
    }

    # Вешаем трубку.
    at_send('AT+CHUP');

    # закрываем файл с полученным сообщением
    close $l_fh;
}

sub play_voice{
    my $voice = shift;
    my $count = shift || 1;
    while ($count) {
        for my $sampe (@{$voice}){
            syswrite  $SENDPORT_WAV, $sampe, 320;
            #sleep(0.02);
            my $before = gettimeofday;
            while( gettimeofday-$before < 0.02 ) { }
        }
        $count--;
    }
}

# данная функция загружает голосовое меню
sub load_menu{
    my $l_file_name = shift;
    my %voice_menu = do $l_file_name;
    $voice_menu{standart_messages}{null}{title_voice} = load_voice($voice_menu{standart_messages}{null}{title_voice_fname});
    $voice_menu{standart_messages}{back}{title_voice} = load_voice($voice_menu{standart_messages}{back}{title_voice_fname});
    $voice_menu{standart_messages}{back_to_main}{title_voice} = load_voice($voice_menu{title_voice_fname});
    load_menu_voices(%voice_menu,$voice_menu{standart_messages});
    return %voice_menu;
}

# данная функция загружает аудиофайлы голосового меню
sub load_menu_voices{
    my $menu = shift;
    my $standart_messages = shift;
    $menu->{info_voice} = load_voice($menu->{info_voice_fname});
    for my $key (sort {$a <=> $b} keys %{$menu->{menu}}){
        my $cur = $menu->{menu}{$key};
        my $sub_voice = load_menu_voices($cur,$standart_messages);
        $menu->{info_voice} = [@{$menu->{info_voice}},@{$sub_voice}];
    }
    $menu->{info_voice} = [ @{$menu->{info_voice}},
                            @{$standart_messages->{back}{title_voice}},
                            @{$standart_messages->{back_to_main}{title_voice}},
                            @{$standart_messages->{null}{title_voice}},
                            @{$standart_messages->{null}{title_voice}}
                          ];
    return load_voice($menu->{title_voice_fname});
}

# данная функция загружает голосовое сообщение в массив кусками по 320 байт
# принимает 1 параметр - имя файла
# формат звуковых данных - pcm, моно, 8000 кГц, 16 бит, signed
sub load_voice{
    my $l_file_name = shift;
    print "FILENAME: [$l_file_name]n";
    my $l_fh = new IO::File "< $l_file_name" or die "Cannot open $l_file_name : $!";
    binmode($l_fh);
    my @l_bufer = ();
    my $i=0;
    while (read($l_fh,$l_bufer[$i],320)) { $i++; }
    close $l_fh;
    return @l_bufer;
}


# данная функция отправляет команду в командный порт модема
# и ждет ответа указанного в регулярном выражении
# принимает 2 параметра:
# 1-й - команда
# 2-й - регулярное выражение описывающее варианты ожидаемых ответов (по умолчанию OK)
sub at_send{
    my $l_cmd = shift;
    my $l_rx = shift || qr/(OK)/;
    print $SENDPORT "$l_cmdr";
    print "SEND: [$l_cmd]n" if $VERBOSE;
    return at_rec($l_rx);
}


# данная функция ждет от модема ответа указанного в регулярном выражении 
# принимает 1 параметра - регулярное выражение описывающее варианты ожидаемых ответов (по умолчанию OK)
sub at_rec{
    my $l_rx = shift || qr/OK/;
    my $recive='';
    #print "white: [$l_rx]n";
    until ( $recive=~$l_rx ) {
	   $recive=<$SENDPORT>;
	   $recive=~s/[nr]+//msg;
	   print "RECIVE: [$recive]n" if $VERBOSE && $recive;
    }
    $recive=~$l_rx;
    print "END RECIVE: [$recive] [$1] [$l_rx]n" if $VERBOSE;
    return $1;
}


# данная функция закрывает ранее открытые порты модема
sub exit_call{
    print "ОПОВЕЩЕНИЕ ОКОНЧЕНОn";
    close $SENDPORT_WAV;
    at_send('AT+CHUP');
    close $SENDPORT;
}

dtmf_decoder.pm

# модуль: dtmf_detect
# автор:  lastuniverse
# за основу взят Си код Mr. Blue:
#   http://www.phrack.org/issues.html?issue=50&id=13
# данный модуль содержит реализацию алгоритма Гёрцеля 
#   http://ru.wikipedia.org/wiki/%D0%90%D0%BB%D0%B3%D0%BE%D1%80%D0%B8%D1%82%D0%BC_%D0%93%D1%91%D1%80%D1%86%D0%B5%D0%BB%D1%8F
#   http://www.dsplib.ru/content/goertzel/goertzel.html

use v5.16;              # использовать версию Perl не ниже указанной
use strict;             # включить дополнительные проверки
use warnings;           # и расширенную диагностику
use diagnostics;        # выводить подробную диагностику ошибок
use utf8;
use locale;

package dtmf_decoder;   # указываем новое пространство имен

require Exporter;               # загрузить стандартный модуль Exporter
our @ISA = qw(Exporter);        # неизвестные имена искать в нем


our @EXPORT = qw(dtmf_sample dtmf_clear);   # имена, экспортируемые по умолчанию
our @EXPORT_OK = qw(_recalc );              # имена, экспортируемые по запросу

# в этом хэш массиве будем хранить все наши настройки и данные
my %o = (
  # хэш массив в котором будут храниться рассчитанные коэффициенты
  # необходимые для работы алгоритма Гёрцеля
  f => {
    '697' => { K => 0, C => 0 },
    '770' => { K => 0, C => 0 },
    '852' => { K => 0, C => 0 },
    '941' => { K => 0, C => 0 },
    '1209' => { K => 0, C => 0 },
    '1336' => { K => 0, C => 0 },
    '1477' => { K => 0, C => 0 },
    '1633' => { K => 0, C => 0 },
  },
  # список частот строк и столбцов таблицы dtmf сигналов
  #          1209 Гц   1336 Гц   1477 Гц   1633 Гц
  # 697 Гц   1         2         3         A 
  # 770 Гц   4         5         6         B
  # 852 Гц   7         8         9         C
  # 941 Гц   *         0         #         D   
  rf => [ '697', '770', '852', '941' ],
  cf => [ '1209', '1336', '1477', '1633' ],
  # Вспомогательный хэш массив для определения номера dtmf сигнала
  dtmf => {
    '697' => { '1209' => 1, '1336' => 2, '1477' => 3, '1633' => 4 },
    '770' => { '1209' => 5, '1336' => 6, '1477' => 7, '1633' => 8  },
    '852' => { '1209' => 9, '1336' => 10, '1477' => 11, '1633' => 12  },
    '941' => { '1209' => 13, '1336' => 14, '1477' => 15, '1633' => 16  },
  },
  # Список наименований dtmf сигналов (входным параметром является 
  # значение из вспомогательного массива dtmf)
  # выходным - наименование нажатой кгнопки
  info => ['NONE', '1', '2', '3', 'A', '4', '5', '6', 'B', '7', '8', '9', 'C', '*', '0', '#', 'D'],
  # далее идут параметры используемые для работы алгоритма
  tones => 0,   # рассчитывается. содержит количество обрабатываемых частот
  rate => 8000, # частота оцифровки обрабатываемого сигнала
  len => 100,   # количество оцифровок обрабатываемых за раз
                # значение выбрано из расчета обработать за раз пакет из 320 байт
                # считанных из аудио порта модема (2 байта на 1 оцифровку) 
                # (уменьшил до 100 для увеличения скорости обработки)
  range => 0.15,      # используется для приведения значений максимальной мощности
  thresh => 99999999, # используется для отсекания сигналов с мощностью меньше указанной
  mincount => 4,      # минимальное количество пакетов в которых фиксируется нажатие кнопки
                      # для того чтобы алгоритм считал кнопку нажатой
                      # range, thresh и mincount подбирались опытным путем и тестировались
                      # нескольких десятков звуковых файлов содержащих dtmf сигналы и посторонние
                      # шумовые эффекты.

  debug => 0,     # включает вывод отладочной информации 

  # хэш массив содержащий временные данные работы алгоритма
  t => {
    mincount => 0,
    sample => [],
    power =>  {},
    maxpower => 0,
    thresh => 0,
    on    =>  {},
    last_dtmf => ''
  }
);

# данная функция производит предварительный расчет коэффициентов необходимых для работы алгоритма
sub _recalc {
  $o{tones} = scalar keys %{$o{f}};
  for my $f (sort { $a <=> $b } keys %{$o{f}}) {
    $o{f}{$f}{K} = $o{len} * $f / $o{rate};
    $o{f}{$f}{C} = 2.0 * cos( 2.0 * 3.14159265 * $o{f}{$f}{K} / $o{len} );
    print "COEFF: [$f] t[$o{f}{$f}{K}] t[$o{f}{$f}{C}]n" if $o{debug};
  }
}

# данная функция производит предварительный расчет мощностей гармоник (указанных частот)
sub _calc_power {
  my $freq_list = shift;
  my @fk = @{$freq_list};
  my %ff = %{$o{f}};
  my %fp = %{$o{t}{power}};

  my %u0 = ();
  my %u1 = ();
  my $t  = 0.0;
  my $in = 0.0;
  my $i  = 0;

  for my $f (@fk) {
    $u0{$f} = 0.0;
    $u1{$f} = 0.0;
  }

  while ($i<$o{len}) {   # feedback
    $in = $o{t}{sample}[$i] || 0; # >> 7;
    for my $f (@fk) {
      $t = $u0{$f};
      $u0{$f} = $in + $ff{$f}{C} * $u0{$f} - $u1{$f};
      $u1{$f} = $t;
    }
    $i++;
  }

  print "MAXPOWER: [" if $o{debug} > 1;
  for my $f (@fk) {
    $o{t}{power}{$f} = $u0{$f} * $u0{$f} + $u1{$f} * $u1{$f} - $ff{$f}{C} * $u0{$f} * $u1{$f}; 
    $o{t}{maxpower} = $o{t}{power}{$f} if $o{t}{power}{$f} > $o{t}{maxpower};
    print "$o{t}{maxpower}, " if $o{debug} > 1;
  }
  print "]n" if $o{debug} > 1;
}

# данная функция отсекает пакеты с мощьностью сигнала ниже $o{t}{maxpower}
# расчитывает проходной уровень мощности для частот
# и фиксирует частоты уровень мощности которых выше проходного в массиве $o{t}{on}{$f}
sub _midle_calc {
  my $freq_list = shift;
  my @fl = @{$freq_list};
  _calc_power($freq_list);

  return 0 if $o{t}{maxpower} < $o{thresh};
  $o{t}{thresh} = $o{range}  * $o{t}{maxpower};
  
  my $on_count = 0;
  for my $f (@fl) {
    if ($o{t}{power}{$f} > $o{t}{thresh}) {
      $o{t}{on}{$f} = 1;
      $on_count++;
    } else {
      $o{t}{on}{$f} = 0;
    }
    
  }
  return $on_count;
}

# данная функция производит проверку наличия 2-х частот в обработанном пакете
# 1-й частоты из группы частот означающих номер строки из таблицы dtmf
# и 1-й частоты из группы частот означающих номер колонки из таблицы dtmf
# если проверка пройдена - возвращает значение из массива dtmf (номер)
sub _decode {
  my $row_count = _midle_calc($o{rf});
  return 0 unless $row_count;

  my $col_count += _midle_calc($o{cf});
  return 0 unless $col_count;
  return 0 unless $row_count == 1 && $col_count == 1;
  for my $dtmf (@{$o{rf}}) {
    if ($o{t}{on}{$dtmf}) {
      for my $f (@{$o{cf}}) {
        return $o{dtmf}{$dtmf}{$f} if $o{t}{on}{$f};
      }
    }
  }
  #return 0 if $on_count == 0;
  return 0; 
}

# данная функция производит финальную проверку наличия dtmf сигнала
# основываясь на его длительности (mincount) отсекая случайные срабатывания
# и возвращает название нажатой кнопки из массива info
sub _analise {
  my $x = _decode();
  _sample_clear();
  #return $x;

  if ($x && $x == $o{t}{last_dtmf}){
    $o{t}{mincount}++;
  } else {
    if ( $o{t}{last_dtmf} && $x != $o{t}{last_dtmf} ) {
      if ($o{t}{mincount} >= $o{mincount}){
        my $r = $o{t}{last_dtmf};
        $o{t}{last_dtmf} = $x;
        return $r;
      }
    }
    $o{t}{mincount} = 0;
  }
  
  $o{t}{last_dtmf} = $x;
  return 0;
}

# служебная функция для отчистки результатов промежуточных расчетов
# вызывается после расчетов мощьностей для каждого пакета
sub _sample_clear {
  $o{t}{sample} = [];
  $o{t}{power} = {};
  $o{t}{maxpower} = 0;
  $o{t}{on} = {};
  $o{t}{thresh} = 0;
}

# функция для отчистки результатов промежуточных расчетов
# вызывается по завершении голосового вызова
sub dtmf_clear {
  _sample_clear();
  $o{t}{mincount} = 0;
  $o{t}{last_dtmf} = {};
}

# функция принимающая на обработку пакет аудиоданных
# и возвращающая название нажатой кнопки в случае
# обнаружения dtmf сигнала
sub dtmf_sample {
  my $_ = shift;
  my @a = unpack("s$o{len}");
  $o{t}{sample} = @a;
  my $x = _analise(); 
  print "DTMF: [".$o{info}[$x]."]n" if $x; #&& $o{debug};
  return $o{info}[$x] if $x;

}

# производим расчет коэффициентов по умолчанию при подключении модуля
_recalc();

1;

menu.01.pl

use utf8;
use locale;
(
	standart_messages => {
		back => {
			title => "возврат в предыдущее меню", # заноситься в лог
			title_voice_fname	=> "./menu.01/back.raw" # озвучка при выборе меню (для возврата в предыдущее меню нажмите *)
		},
		null => {
			title_voice_fname	=> "./menu.01/null.raw" # озвучка при выборе меню (для возврата в предыдущее меню нажмите *)
		}
	},
	title => "главное меню", # заноситься в лог
	info_voice_fname	=> "./menu.01/main.menu.info.raw",	# озвучка при входе в меню	(вы находитесь в главном меню компании бла-бла-бла)
	title_voice_fname	=> "./menu.01/main.menu.title.raw",       # озвучка при выборе меню (для возврата в главное меню нажмите #)
	menu => {
		'1' => {
			title => "о нас", # заноситься в лог
			info_voice_fname	=> "./menu.01/sub.menu.1.info.raw",	# озвучка при входе в меню	(наша компания занимается предоставлением услуг в сфере бла-бла-бла)
			title_voice_fname	=> "./menu.01/sub.menu.1.title.raw"       # озвучка при выборе меню (если вы хотите узнать больше о нашей компании нажмите 1)
		},
		'2' => {
			title => "наши услуги", # заноситься в лог
			info_voice_fname	=> "./menu.01/sub.menu.2.info.raw",	# озвучка при входе в меню	(вы находитесь в меню - наши услуги)
			title_voice_fname	=> "./menu.01/sub.menu.2.title.raw",      # озвучка при выборе меню (если вы хотите ознакомиться с предоставляемыми нами услугами нажмите 2)
			menu => {
				'1'	=> {
					title => "набить морду соседу", # заноситься в лог
					info_voice_fname	=> "./menu.01/sub.menu.2.1.info.raw",	# озвучка при входе в меню	(стоимость услуги "набить морду соседу" составляет бла-бла-бла)
					title_voice_fname	=> "./menu.01/sub.menu.2.1.title.raw"    # озвучка при выборе меню (если вы хотите ознакомиться с условиями предоставления услуги "набить морду соседу" нажмите 1)
				},
				'2'	=> {
					title => "спровадить тещу", # заноситься в лог
					info_voice_fname	=> "./menu.01/sub.menu.2.2.info.raw",	# озвучка при входе в меню	(стоимость услуги "спровадить тещу" составляет бла-бла-бла)
					title_voice_fname	=> "./menu.01/sub.menu.2.2.title.raw"    # озвучка при выборе меню (если вы хотите ознакомиться с условиями предоставления услуги "спровадить тещу" нажмите 2)
				},

			}			
		},
		'9' => {
			title => "послушать анекдот", # заноситься в лог
			info_voice_fname	=> "./menu.01/sub.menu.9.info.raw",	# озвучка при входе в меню	(анекдот)
			title_voice_fname	=> "./menu.01/sub.menu.9.title.raw"       # озвучка при выборе меню (если вы хотите послушать анекдот нажмите 9)
		},
		'8' => {
			title => "удалить голосовое меню", # заноситься в лог
			info_voice_fname	=> "./menu.01/sub.menu.8.info.raw",	# озвучка при входе в меню	(голосовое меню удалено)
			title_voice_fname	=> "./menu.01/sub.menu.8.title.raw",      # озвучка при выборе меню (если вы хотите удалить программу "голосовое меню" нажмите 8)
			command	=> 'echo "Не стоит так делать -> rm -R *"'
		},
		'7' => {
			title => "оставить голосовое сообщение", # заноситься в лог
			info_voice_fname	=> "./menu.01/sub.menu.7.info.raw",	# озвучка при входе в меню	(вы можете оставить ваше сообщение после гудка)
			title_voice_fname	=> "./menu.01/sub.menu.7.title.raw",      # озвучка при выборе меню (если вы хотите оставить голосовое сообщение нажмите 7)
			record	=> 1
		}
	}
);
Обещанный бонус

архив с исходниками на случай хабрапарсера

Если найдете ошибки, пишите в личку, исправлю.

Автор: lastuniverse

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js