Built motion from commit 0dbf6b8.|0.0.131
[motion.git] / server / config / agi_scripts / googletts.agi
1 #!/usr/bin/env perl
2
3 #
4 # AGI script that uses Google's translate text to speech engine.
5 #
6 # Copyright (C) 2011 - 2015, Lefteris Zafiris <zaf.000@gmail.com>
7 #
8 # This program is free software, distributed under the terms of
9 # the GNU General Public License Version 2. See the COPYING file
10 # at the top of the source tree.
11 #
12 # -----
13 # Usage
14 # -----
15 # agi(googletts.agi,"text",[language],[intkey],[speed]): This will invoke the Google TTS
16 # engine, render the text string to speech and play it back to the user.
17 # If 'intkey' is set the script will wait for user input. Any given interrupt keys will
18 # cause the playback to immediately terminate and the dialplan to proceed to the
19 # matching extension (this is mainly for use in IVR, see README for examples).
20 # If 'speed' is set the speech rate is altered by that factor (defaults to 1.2).
21 #
22 # The script contacts google's TTS service in order to get the voice data
23 # which then stores in a local cache (by default /tmp/) for future use.
24 #
25 # Parameters like default language, sample rate, caching and cache dir
26 # can be set up by altering the following variables:
27 # Default langeuage: $lang
28 # Sample rate:       $samplerate
29 # Speed factor:      $speed
30 # Chace:             $usecache
31 # Chache directory:  $cachedir
32 # SoX Version:       $sox_ver
33 #
34
35 use warnings;
36 use strict;
37 use utf8;
38 use Encode qw(decode encode);
39 use File::Temp qw(tempfile);
40 use File::Copy qw(move);
41 use File::Path qw(mkpath);
42 use Digest::MD5 qw(md5_hex);
43 use URI::Escape;
44 use LWP::UserAgent;
45 use LWP::ConnCache;
46 $| = 1;
47
48 # ----------------------------- #
49 #   User defined parameters:    #
50 # ----------------------------- #
51 # Default language              #
52 my $lang = "en";
53
54 # Output speed factor           #
55 my $speed = 1;
56
57 # Use of cache mechanism        #
58 my $usecache = 1;
59
60 # Cache directory path          #
61 my $cachedir = "/tmp";
62
63 # Output audio sample rate      #
64 # Leave blank to auto-detect    #
65 my $samplerate = "";
66
67 # SoX Version                   #
68 # Leave blank to auto-detect    #
69 my $sox_ver = "";
70
71 # Verbose debugging messages    #
72 my $debug = 0;
73
74 # ----------------------------- #
75
76 my %AGI;
77 my @text;
78 my $fh;
79 my $tmpname;
80 my $fexten;
81 my $intkey  = "";
82 my $tmpdir  = "/tmp";
83 my $maxlen  = 4096;
84 my $timeout = 10;
85 my $url     = "https://translate.google.com";
86 my $sox     = `/usr/bin/which sox`;
87 my $mpg123  = `/usr/bin/which mpg123`;
88
89 # Store AGI input #
90 ($AGI{arg_1}, $AGI{arg_2}, $AGI{arg_3}, $AGI{arg_4}) = @ARGV;
91 while (<STDIN>) {
92         chomp;
93         last if (!length);
94         $AGI{$1} = $2 if (/^agi_(\w+)\:\s+(.*)$/);
95 }
96 my $name = " -- $AGI{request}:";
97
98 # Abort if required programs not found. #
99 fatal_log("sox or mpg123 is missing. Aborting.") if (!$sox || !$mpg123);
100 chomp($sox, $mpg123);
101
102 # Sanitising input #
103 $AGI{arg_1} = uri_unescape($AGI{arg_1}); # Added by XCALLY team #
104 $AGI{arg_1} = decode('utf8', $AGI{arg_1});
105 for ($AGI{arg_1}) {
106         s/[\\|*~<>^\(\)\[\]\{\}[:cntrl:]]/ /g;
107         s/\s+/ /g;
108         s/^\s|\s$//g;
109         fatal_log("No text passed for synthesis.") if (!length);
110         # Split input to comply with google tts requirements #
111         $_ .= "." unless (/^.+[.,?!:;]$/);
112         @text = /.{1,150}[.,?!:;]|.{1,150}\s/g;
113 }
114 my $lines = @text;
115
116 # Setting language, interrupt keys and speed rate #
117 if (length($AGI{arg_2})) {
118         if ($AGI{arg_2} =~ /^[a-zA-Z]{2}(-[a-zA-Z]{2,6})?$/) {
119                 $lang = $AGI{arg_2};
120         } else {
121                 console_log("Invalid language setting. Using default.");
122         }
123 }
124
125 if (length($AGI{arg_3})) {
126         $intkey = "0123456789#*" if ($AGI{arg_3} eq "any");
127         $intkey = $AGI{arg_3}    if ($AGI{arg_3} =~ /^[0-9*#]+$/);
128 }
129
130 if (length($AGI{arg_4})) {
131         $speed = $AGI{arg_4} if ($AGI{arg_4} =~ /^\d+(\.\d+)?$/);
132 }
133
134 # Check cache path size: dir length + md5 + file extension #
135 if ($usecache) {
136         if ((length($cachedir) + 32 + 6) < $maxlen) {
137                 mkpath("$cachedir") unless (-d "$cachedir");
138         } else {
139                 console_log("Cache path size exceeds limit. Disabling cache.");
140                 $usecache = 0;
141         }
142 }
143
144 # Answer channel if not already answered #
145 print "CHANNEL STATUS\n";
146 my @result = checkresponse();
147 if ($result[0] == 4) {
148         print "ANSWER\n";
149         @result = checkresponse();
150         if ($result[0] != 0) {
151                 fatal_log("Failed to answer channel.");
152         }
153 }
154
155 # Setting filename extension according to sample rate. #
156 if    (!$samplerate)         { ($fexten, $samplerate) = detect_format(); }
157 elsif ($samplerate == 12000) { $fexten = "sln12"; }
158 elsif ($samplerate == 16000) { $fexten = "sln16"; }
159 elsif ($samplerate == 32000) { $fexten = "sln32"; }
160 elsif ($samplerate == 44100) { $fexten = "sln44"; }
161 elsif ($samplerate == 48000) { $fexten = "sln48"; }
162 else                         { ($fexten, $samplerate) = ("sln", 8000); }
163
164 # Initialise User angent #
165 my $ua = LWP::UserAgent->new(ssl_opts => { verify_hostname => 1 });
166 $ua->agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.101 Safari/537.36");
167 $ua->env_proxy;
168 $ua->conn_cache(LWP::ConnCache->new());
169 $ua->timeout($timeout);
170
171 for (my $i = 0; $i < $lines; $i++) {
172         my $filename;
173         my $res;
174         my $len = length($text[$i]);
175         my $line = encode('utf8', $text[$i]);
176         $line =~ s/^\s+|\s+$//g;
177         next if (length($line) == 0);
178         if ($debug) {
179                 console_log("Text passed for synthesis: $line",
180                         "Language: $lang, Interrupt keys: $intkey, Sample rate: $samplerate",
181                         "Speed: $speed, Caching: $usecache, Cache dir: $cachedir"
182                 );
183         }
184         if ($usecache) {
185                 $filename = md5_hex("$line.$lang.$speed");
186
187                 # Stream file from cache if it exists #
188                 if (-r "$cachedir/$filename.$fexten") {
189                         console_log("File already in cache.") if ($debug);
190                         $res = playback("$cachedir/$filename", $intkey);
191                         die  if ($res < 0);
192                         last if ($res > 0);
193                         next;
194                 }
195         }
196
197         # Hnadle interrupts #
198         $SIG{'INT'} = \&int_handler;
199         $SIG{'HUP'} = \&int_handler;
200
201         ($fh, $tmpname) = tempfile("ggl_XXXXXXXX", DIR => $tmpdir, UNLINK => 1);
202         my $token = make_token($line);
203         $line = uri_escape($line);
204         my $req   = "$url/translate_tts?ie=UTF-8&q=$line&tl=$lang&total=$lines&idx=$i&textlen=$len&client=tw-ob&tk=$token&prev=input";
205         console_log("URL passed: $req") if ($debug);
206
207         my $ua_request = HTTP::Request->new('GET' => $req);
208         $ua_request->header(
209                 'Accept'          => '*/*',
210                 'Accept-Encoding' => 'identity;q=1, *;q=0',
211                 'Accept-Language' => 'en-US,en;q=0.5',
212                 'DNT'             => '1',
213                 'Range'           => 'bytes=0-',
214                 'Referer'         => 'https://translate.google.com/',
215         );
216         my $ua_response = $ua->request($ua_request, $tmpname);
217         fatal_log("Failed to fetch file: ", $ua_response->code, $ua_response->message) unless ($ua_response->is_success);
218
219         # Convert mp3 file to 16bit 8Khz or 16kHz mono raw #
220         system($mpg123, "-q", "-w", "$tmpname.wav", $tmpname) == 0
221                 or fatal_log("$mpg123 failed: $?");
222
223         # Detect sox version #
224         if (!$sox_ver) {
225                 $sox_ver = (system("$sox --version > /dev/null 2>&1") == 0) ? 14 : 12;
226                 console_log("Found sox version $sox_ver in: $sox, mpg123 in: $mpg123") if ($debug);
227         }
228         my @soxargs = get_sox_args("$tmpname.wav", "$tmpname.$fexten");
229         system(@soxargs) == 0 or fatal_log("$sox failed: $?");
230         unlink "$tmpname.wav";
231
232         # Playback and save file in cache #
233         $res = playback($tmpname, $intkey);
234         die if ($res < 0);
235         if ($usecache) {
236                 console_log("Saving file $filename to cache") if ($debug);
237                 move("$tmpname.$fexten", "$cachedir/$filename.$fexten");
238         } else {
239                 unlink "$tmpname.$fexten";
240         }
241         last if ($res > 0);
242 }
243 exit;
244
245 sub checkresponse {
246         my $input = <STDIN>;
247         my @values;
248
249         chomp $input;
250         if ($input =~ /^200 result=(-?\d+)\s?(.*)$/) {
251                 @values = ("$1", "$2");
252         } else {
253                 $input .= <STDIN> if ($input =~ /^520-Invalid/);
254                 warn "$name Unexpected result: $input\n";
255                 @values = (-1, -1);
256         }
257         return @values;
258 }
259
260 sub playback {
261         my ($file, $keys) = @_;
262         my @response;
263
264         print "STREAM FILE $file \"$keys\"\n";
265         @response = checkresponse();
266         if ($response[0] >= 32 && chr($response[0]) =~ /[\w*#]/) {
267                 console_log("Got digit chr($response[0])") if ($debug);
268                 print "SET EXTENSION ", chr($response[0]), "\n";
269                 checkresponse();
270                 print "SET PRIORITY 1\n";
271                 checkresponse();
272         } elsif ($response[0] == -1) {
273                 console_log("Failed to play $file.");
274         }
275         return $response[0];
276 }
277
278 sub detect_format {
279         # Detect the sound format used #
280         my @format;
281         print "GET FULL VARIABLE \${CHANNEL(audionativeformat)}\n";
282         my @reply = checkresponse();
283         for ($reply[1]) {
284                 if    (/(silk|sln)12/)                    { @format = ("sln12", 12000); }
285                 elsif (/(speex|slin|silk)16|g722|siren7/) { @format = ("sln16", 16000); }
286                 elsif (/(speex|slin|celt)32|siren14/)     { @format = ("sln32", 32000); }
287                 elsif (/(celt|slin)44/)                   { @format = ("sln44", 44100); }
288                 elsif (/(celt|slin)48/)                   { @format = ("sln48", 48000); }
289                 else                                      { @format = ("sln",    8000); }
290         }
291         return @format;
292 }
293
294 sub get_sox_args {
295         # Set the appropiate sox cli arguments #
296         my ($source_file, $dest_file) = @_;
297
298         my @soxargs = ($sox, $source_file, "-q", "-r", $samplerate, "-t", "raw", $dest_file);
299         if ($speed != 1) {
300                 if ($sox_ver >= 14) {
301                         push(@soxargs, ("tempo", "-s", $speed));
302                 } else {
303                         push(@soxargs, ("stretch", 1 / $speed, "80"));
304                 }
305         }
306         return @soxargs;
307 }
308
309 # Obfuscated crap straight from Google:
310 # https://translate.google.com/translate/releases/twsfe_w_20151214_RC03/r/js/desktop_module_main.js
311 sub make_token {
312         my $text = shift;
313         my $time = int(time() / 3600);
314         my @chars = unpack('U*', $text);
315         my $stamp = $time;
316
317         foreach (@chars) {
318                 $stamp = make_rl($stamp + $_, '+-a^+6');
319         }
320         $stamp = make_rl($stamp, '+-3^+b+-f');
321         if ($stamp < 0) {
322                 $stamp = ($stamp & 2147483647) + 2147483648;
323         }
324         $stamp %= 10**6;
325         return ($stamp . '.' . ($stamp ^ $time));
326 }
327
328 sub make_rl {
329         my ($num, $str) = @_;
330
331         for (my $i = 0; $i < length($str) - 2 ; $i += 3) {
332                 my $d = substr($str, $i+2, 1);
333                 if (ord($d) >= ord('a')) {
334                         $d = ord($d) - 87;
335                 } else {
336                         $d = int($d);
337                 }
338                 if (substr($str, $i+1, 1) eq '+') {
339                         $d = $num >> $d;
340                 } else {
341                         $d = $num << $d;
342                 }
343                 if (substr($str, $i, 1) eq '+') {
344                         $num = $num + $d & 4294967295;
345                 } else {
346                         $num = $num ^ $d;
347                 }
348         }
349         return $num;
350 }
351
352 sub console_log {
353         foreach my $message (@_) {
354                 warn "$name $message\n";
355                 print "NOOP \"$name $message\"\n";
356                 checkresponse();
357         }
358 }
359
360 sub fatal_log {
361         console_log(@_);
362         die;
363 }
364
365 sub int_handler {
366         die "$name Interrupt signal received, terminating...\n";
367 }
368
369 END {
370         if ($tmpname) {
371                 warn "$name Cleaning temp files.\n" if ($debug);
372                 unlink glob "$tmpname*";
373         }
374 }