File Coverage

blib/lib/Speak.pm
Criterion Covered Total %
statement 125 202 61.8
branch 26 86 30.2
condition 9 21 42.8
subroutine 22 24 91.6
pod 1 1 100.0
total 183 334 54.7


line stmt bran cond sub pod time code
1              
2             =pod
3              
4             =head1 NAME
5              
6             Speak - Convert text to speech using Google's engine and play it on speakers
7              
8             =head1 VERSION
9              
10             =over
11              
12             =item Author
13              
14             Andrew DeFaria
15              
16             =item Revision
17              
18             $Revision: 1.01 $
19              
20             =item Created
21              
22             =item Modified
23              
24             Fri 06 Sep 2026 08:30:00 AM PST
25              
26             =back
27              
28             =head1 SYNOPSIS
29              
30             This module offers subroutines to convert text into speach and speak them.
31              
32             =head2 DESCRIPTION
33              
34             This module exports subroutines to process text to speach and speak them.
35              
36             =head1 ROUTINES
37              
38             The following routines are exported:
39              
40             =cut
41              
42             package Speak;
43              
44 4     4   664856 use strict;
  4         8  
  4         163  
45 4     4   19 use warnings;
  4         11  
  4         253  
46              
47 4     4   24 use base 'Exporter';
  4         10  
  4         606  
48              
49 4     4   2479 use Clipboard;
  4         3488  
  4         29  
50              
51             our $VERSION = '1.01';
52              
53             {
54              
55             ## no critic (Modules::ProhibitMultiplePackages)
56             package Speak::Logger;
57              
58 4     4   30088 use strict;
  4         18  
  4         215  
59 4     4   36 use warnings;
  4         12  
  4         542  
60 4     4   36 use File::Basename;
  4         9  
  4         911  
61 4     4   4786 use POSIX qw(strftime);
  4         40917  
  4         25  
62 4     4   8973 use IO::Handle;
  4         29726  
  4         235  
63 4     4   25 use Carp;
  4         7  
  4         3818  
64              
65             sub new {
66 7     7   40 my ($class, %args) = @_;
67             my $self = {
68             path => $args{path} || '.',
69             name => $args{name} || 'speak',
70             timestamped => $args{timestamped},
71             append => $args{append},
72 7   50     63 handle => undef,
      50        
73             };
74              
75 7 50       24 my $mode = $self->{append} ? '>>' : '>';
76 7         154 my $logfile = File::Spec->catfile ($self->{path}, $self->{name} . '.log');
77              
78             # We try to open the logfile, but if it fails we just warn and carry on
79             # effectively logging nowhere (or we could default to STDERR)
80             ## no critic (InputOutput::RequireBriefOpen)
81 7 50       16652 if (open my $fh, $mode, $logfile) {
82 7         70 $fh->autoflush (1);
83 7         393 $self->{handle} = $fh;
84             } else {
85 0         0 carp "Could not open logfile $logfile: $!";
86             }
87              
88 7         26 bless $self, $class;
89 7         38 return $self;
90             } ## end sub new
91              
92             sub msg {
93 7     7   19 my ($self, $msg) = @_;
94 7 50       20 return unless defined $msg;
95              
96 7         128 print "$msg\n";
97              
98 7 50       87 if ($self->{handle}) {
99             my $timestamp =
100             $self->{timestamped}
101 7 50       362 ? strftime ("%Y-%m-%d %H:%M:%S", localtime) . ": "
102             : "";
103 7         22 my $fh = $self->{handle};
104 7         390 print $fh "$timestamp$msg\n";
105             } ## end if ($self->{handle})
106 7         25 return;
107             } ## end sub msg
108              
109             sub DESTROY {
110 7     7   17 my $self = shift;
111 7 50       204 close $self->{handle} if $self->{handle};
112 7         171 return;
113             }
114             }
115              
116             sub _get_config {
117 1     1   3290 my ($file) = @_;
118 1         3 my %config;
119 1 50       21 return %config unless -f $file;
120              
121             ## no critic (InputOutput::RequireBriefOpen)
122 1 50       42 if (open my $fh, '<', $file) {
123 1         15 while (my $line = <$fh>) {
124 2         5 chomp $line;
125 2 50 33     20 next if $line =~ /^\s*[#!]/ || $line =~ /^\s*$/;
126 2 50       15 if ($line =~ /^\s*([^:=]+?)\s*[:=]\s*(.*?)\s*$/) {
127 2         7 my $key = $1;
128 2         4 my $val = $2;
129              
130             # Simple variable interpolation for $ENV
131 2 50       9 $val =~ s/\$(\w+)/$ENV{$1} || "\$$1"/ge;
  1         8  
132 2         17 $config{$key} = $val;
133             } ## end if ($line =~ /^\s*([^:=]+?)\s*[:=]\s*(.*?)\s*$/)
134             } ## end while (my $line = <$fh>)
135 1         11 close $fh;
136             } ## end if (open my $fh, '<', ...)
137 1         12 return %config;
138             } ## end sub _get_config
139              
140 4     4   5287 use LWP::UserAgent;
  4         312145  
  4         195  
141 4     4   45 use URI::Escape;
  4         8  
  4         365  
142 4     4   2760 use File::Temp qw(tempfile);
  4         45089  
  4         342  
143 4     4   36 use File::Path qw(rmtree);
  4         9  
  4         251  
144 4     4   50 use File::Basename;
  4         9  
  4         249  
145 4     4   21 use Carp;
  4         7  
  4         8321  
146              
147             our @EXPORT_OK = qw(speak);
148              
149             sub _split_text ($) {
150 8     8   2725 my ($text) = @_;
151 8 50       21 return unless defined $text;
152              
153             # Split into sentences max 100 chars
154 8         13 my @sentences;
155              
156             # If text is long and has no punctuation, force split
157 8 100 66     32 if (length ($text) > 100 && $text !~ /[.!?;]/) {
158 1         12 return unpack ("(A100)*", $text);
159             }
160              
161             # Basic splitting on punctuation, keeping punctuation
162             # This is a simplified version of speak.pl logic
163 7         64 while ($text =~ /(.{1,100}?(?:[.!?;]|$))/g) {
164 8         43 push @sentences, $1;
165             }
166              
167             # Fallback if regex missed: chunk into 100-char segments
168 7 50       18 if (!@sentences) {
169 0         0 @sentences = unpack ("(A100)*", $text);
170             }
171              
172 7         43 return @sentences;
173             } ## end sub _split_text ($)
174              
175             sub _fetch_mp3 ($$$) {
176 0     0   0 my ($ua, $text, $lang) = @_;
177              
178 0         0 my $url =
179             "https://translate.google.com/translate_tts?ie=UTF-8&tl=$lang&q="
180             . uri_escape_utf8 ($text)
181             . "&total=1&idx=0&client=tw-ob";
182              
183 0         0 my $response = $ua->get ($url);
184              
185 0 0       0 if ($response->is_success) {
186 0         0 my $content = $response->content;
187 0 0       0 if (length ($content) == 0) {
188 0         0 carp "Fetch successful but content is empty";
189 0         0 return;
190             }
191              
192             # Check if we got HTML instead of MP3 (e.g. Captcha/Error)
193 0 0       0 if ($content =~ /^\s*<(!DOCTYPE|html)/i) {
194 0         0 carp
195             "Received HTML response instead of MP3 (likely CAPTCHA/Blocked) from URL: $url";
196 0         0 return;
197             }
198              
199 0         0 return $content;
200             } else {
201 0         0 carp "Failed to fetch TTS: " . $response->status_line;
202 0         0 return;
203             }
204             } ## end sub _fetch_mp3 ($$$)
205              
206             sub _convert_mp3_to_wav ($$) {
207 0     0   0 my ($mp3, $wav) = @_;
208              
209             # Try ffmpeg
210 0 0       0 if (system ("which ffmpeg >/dev/null 2>&1") == 0) {
211 0         0 return system ("ffmpeg -y -v error -i \"$mp3\" \"$wav\"") == 0;
212             }
213              
214             # Try mpg123
215 0 0       0 if (system ("which mpg123 >/dev/null 2>&1") == 0) {
216 0         0 return system ("mpg123 -q -w \"$wav\" \"$mp3\"") == 0;
217             }
218              
219             # Try lame
220 0 0       0 if (system ("which lame >/dev/null 2>&1") == 0) {
221 0         0 return system ("lame --decode --quiet \"$mp3\" \"$wav\"") == 0;
222             }
223              
224             # Fallback to sox (might fail if no mp3 handler)
225 0         0 return system ("sox \"$mp3\" \"$wav\"") == 0;
226             } ## end sub _convert_mp3_to_wav ($$)
227              
228             ## no critic (Subroutines::ProhibitExcessComplexity)
229             sub speak (;$$$) {
230 7     7 1 259471 my ($msg, $log, $lang) = @_;
231              
232             =pod
233              
234             =head2 speak($msg, $log, $lang)
235              
236             Convert $msg to speech.
237              
238             Parameters:
239              
240             =for html
241              
242             =over
243              
244             =item $msg:
245              
246             Message to speak. If $msg is defined and scalar then that is the message
247             to speak. If it is a file handle then the text will be read from that file.
248             Otherwise the text in the clipboard will be used.
249              
250             =item $log
251              
252             If provided, errors and messages will be logged to the logfile, otherwise to speak.log
253              
254             =item $lang
255              
256             Language code (e.g. 'en', 'en-gb', 'en-au'). Defaults to $ENV{SPEAK_LANG} or 'en'.
257              
258             =back
259              
260              
261              
262             =for html
263              
264             Returns:
265              
266             =for html
267              
268             =over
269              
270             =item Nothing
271              
272             =back
273              
274             =for html
275              
276             =cut
277              
278 7 50       62 $log = Speak::Logger->new (
279             path => '/var/log',
280             name => 'speak',
281             timestamped => 'yes',
282             append => 1,
283             ) unless $log;
284              
285 7 50       22 $msg = Clipboard->paste unless $msg;
286 7 50       23 $msg = do {local $/; <$msg>} if ref $msg eq 'GLOB';
  0         0  
  0         0  
287              
288             # Sanitize escape sequences
289             # 1. Remove bells (\a)
290 7         41 $msg =~ s/(\\a|\a)//g;
291              
292             # 2. Convert other escapes to space
293             # Literals: \n, \t, \r, \f, \b
294 7         32 $msg =~ s/\\[ntrfb]/ /g;
295              
296             # 3. Convert actual control characters to space
297 7         26 $msg =~ s/[\n\t\r\f\b]/ /g;
298              
299             # 4. Collapse multiple spaces
300 7         38 $msg =~ s/\s+/ /g;
301 7         48 $msg =~ s/^\s+|\s+$//g;
302              
303             my @mute_paths =
304 7         33 ($ENV{SPEAK_MUTE}, $ENV{HOME} . "/.speak/shh", "/etc/speak/shh");
305              
306 7         16 foreach my $path (@mute_paths) {
307 19 100 100     518 if ($path && -f $path) {
308 1         3 $msg .= ' [silent shh]';
309 1         6 $log->msg ($msg);
310 1         8 return;
311             }
312             } ## end foreach my $path (@mute_paths)
313              
314 6         25 $log->msg ($msg);
315              
316             # New Implementation
317 6         57 my $ua = LWP::UserAgent->new;
318 6         5656 $ua->agent ("Mozilla/5.0");
319              
320             # Determine language:
321             # 1. Argument $lang
322             # 2. Environment variable SPEAK_LANG
323             # 3. Config file
324             # 4. Default 'en'
325              
326 6 50       447 unless ($lang) {
327 6 50       35 if ($ENV{SPEAK_LANG}) {
328 0         0 $lang = $ENV{SPEAK_LANG};
329             } else {
330             my @config_paths =
331 6         22 ($ENV{HOME} . "/.speak/speak.conf", "/etc/speak/speak.conf");
332              
333 6         13 foreach my $conf_file (@config_paths) {
334 12 50       139 if (-f $conf_file) {
335 0         0 my %conf = _get_config ($conf_file);
336 0 0       0 if ($conf{language}) {
337 0         0 $lang = $conf{language};
338 0         0 last;
339             }
340             } ## end if (-f $conf_file)
341             } ## end foreach my $conf_file (@config_paths)
342             } ## end else [ if ($ENV{SPEAK_LANG}) ]
343              
344 6   50     27 $lang ||= 'en';
345             } ## end unless ($lang)
346              
347 6         20 my @sentences = _split_text ($msg);
348 6         10 my @mp3_files;
349              
350 6         9 foreach my $sentence (@sentences) {
351 6 50       21 next unless $sentence =~ /\S/;
352              
353 6         25 my $mp3_data = _fetch_mp3 ($ua, $sentence, $lang);
354 6 50       50 next unless $mp3_data;
355              
356 0         0 my ($fh, $filename) = tempfile (SUFFIX => '.mp3', UNLINK => 0);
357 0         0 binmode $fh;
358 0         0 print $fh $mp3_data;
359 0         0 close $fh;
360              
361 0         0 push @mp3_files, $filename;
362             } ## end foreach my $sentence (@sentences)
363              
364 6 50       13 if (@mp3_files) {
365              
366             # Combine or play sequentially
367             # Using 'sox' to play directly or concatenate would be better,
368             # but for compatibility with existing 'play' command:
369              
370             # Concatenate using sox if multiple files
371 0         0 my $final_file;
372 0 0       0 if (@mp3_files > 1) {
373 0         0 my ($fh, $joined) = tempfile (SUFFIX => '.mp3', UNLINK => 0);
374 0         0 close $fh;
375 0         0 $final_file = $joined;
376              
377             # Using system sox to join.
378             # Note: This requires sox with mp3 handler.
379 0         0 my $cmd = "sox " . join (" ", @mp3_files) . " $final_file";
380 0         0 system ($cmd);
381             } else {
382 0         0 $final_file = $mp3_files[0];
383             }
384              
385             # Play it
386 0 0       0 if (-f $final_file) {
387 0 0       0 if ($ENV{DEBUG_SPEAK}) {
388 0         0 print "File info for $final_file:\n";
389 0         0 system ("ls -l $final_file");
390 0         0 system ("file $final_file");
391             }
392              
393             # Cross-platform playback logic
394 0         0 my $os = $^O;
395              
396 0 0 0     0 if ($os eq 'darwin') {
    0          
397              
398             # macOS
399 0         0 system ("afplay \"$final_file\"");
400             } elsif ($os eq 'MSWin32' || $os eq 'cygwin') {
401              
402             # Windows / Cygwin
403             # Use powershell for headless playback if available, or start
404             # Note: 'start' might pop up a window.
405             # Cygwin often has 'play' (sox) as well.
406              
407             # Try PowerShell first as it's cleaner
408 0         0 my $win_path = $final_file;
409 0 0       0 if ($os eq 'cygwin') {
410 0         0 chomp ($win_path = `cygpath -w "$final_file"`);
411             }
412              
413             # Use PowerShell to play audio hidden
414             # System.Media.SoundPlayer only supports WAV, so convert MP3 -> WAV first
415 0         0 my ($fh_wav, $wav_file) = tempfile (SUFFIX => '.wav', UNLINK => 0);
416 0         0 close $fh_wav;
417              
418             # Convert to WAV using sox
419 0 0       0 if (system ("sox \"$final_file\" \"$wav_file\"") == 0) {
420 0         0 my $wav_win_path = $wav_file;
421 0 0       0 if ($os eq 'cygwin') {
422 0         0 chomp ($wav_win_path = `cygpath -w "$wav_file"`);
423             }
424              
425 0         0 my $cmd_wav =
426             "powershell -c (New-Object Media.SoundPlayer '$wav_win_path').PlaySync()";
427 0 0       0 if (system ($cmd_wav) != 0) {
428              
429             # Fallback
430 0         0 system ("play -q \"$final_file\"");
431             }
432 0         0 unlink $wav_file;
433             } else {
434              
435             # Conversion failed logic fallback
436 0         0 my $cmd =
437             "powershell -c (New-Object Media.SoundPlayer '$win_path').PlaySync()";
438 0 0       0 if (system ($cmd) != 0) {
439              
440             # Fallback to sox 'play' if powershell fails
441 0         0 system ("play -q \"$final_file\"");
442             }
443             } ## end else [ if (system ("sox \"$final_file\" \"$wav_file\""...))]
444             } else {
445              
446             # Linux / Unix
447             # paplay often requires WAV if libsndfile lacks mp3 support (e.g. on Mars)
448             # We convert to WAV to be safe.
449 0 0 0     0 if (-x '/usr/bin/paplay' || -x '/bin/paplay') {
450 0         0 my ($fh_wav, $wav_file) = tempfile (SUFFIX => '.wav', UNLINK => 0);
451 0         0 close $fh_wav;
452              
453             # Convert to WAV
454 0 0       0 if (_convert_mp3_to_wav ($final_file, $wav_file)) {
455 0         0 system ("paplay \"$wav_file\"");
456 0         0 unlink $wav_file;
457             } else {
458              
459             # Conversion failed, try playing mp3 directly
460 0         0 system ("paplay \"$final_file\"");
461             }
462             } else {
463 0         0 system ("play -q $final_file");
464             }
465             } ## end else [ if ($os eq 'darwin') ]
466              
467 0         0 unlink $final_file;
468             } ## end if (-f $final_file)
469             ## end if (-f $final_file)
470             } ## end if (@mp3_files)
471              
472             # Cleanup temp files
473 6         12 unlink @mp3_files;
474              
475 6         121 return;
476             } # speak
477              
478             1;
479              
480             =pod
481              
482             =head1 CONFIGURATION AND ENVIRONMENT
483              
484             SPEAK_LANG: Language code (e.g. 'en', 'en-gb', 'en-au').
485             See eg/speak.conf for available languages.
486             Defaults to $ENV{SPEAK_LANG} or 'en'.
487              
488             SPEAK_MUTE: If set to a true value, speech output is muted.
489             Alternatively, if a file exists at $ENV{HOME}/.speak/shh
490             or /etc/speak/shh, speech is muted.
491              
492             To silence Speak for while simply touch $ENV{HOME}/.speak/shh
493             or /etc/speak/shh file. To unsilence Speak, remove the file.
494              
495             =head2 Configuration File
496              
497             Speak supports a configuration file located at $ENV{HOME}/.speak/speak.conf
498             or /etc/speak/speak.conf.
499              
500             Format:
501             language: en-gb
502             # other options...
503              
504             Supported keys:
505             language - Default language code for speech generation.
506              
507             =head1 DEPENDENCIES
508              
509             =head2 Perl Modules
510              
511             L
512              
513             L
514              
515             L
516              
517             =head1 BUGS AND LIMITATIONS
518              
519             There are no known bugs in this module.
520              
521             Please report problems to Andrew DeFaria .
522              
523             =head1 LICENSE AND COPYRIGHT
524              
525             This Perl Module is freely available; you can redistribute it and/or
526             modify it under the terms of the GNU General Public License as
527             published by the Free Software Foundation; either version 2 of the
528             License, or (at your option) any later version.
529              
530             This Perl Module is distributed in the hope that it will be useful,
531             but WITHOUT ANY WARRANTY; without even the implied warranty of
532             MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
533             General Public License (L) for more
534             details.
535              
536             You should have received a copy of the GNU General Public License
537             along with this Perl Module; if not, write to the Free Software Foundation,
538             Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
539             reserved.
540              
541             =cut