#!/usr/bin/perl # # This is a simple command-line MP3 playback utility. Invoke as: # mp3play [-m] [-r] [-t] [-vVOLUME] FILE... # where: # -m selects mono instead of stereo output. # -r enables repeat mode if two or more files are given, or loop mode # if one file is given. # -t enables updating of the title of a terminal window. # -v applies a scale factor of VOLUME (default 1.0) to the output. # # The program displays a status line similar to the following: # # (r)epeat (l)oop |> 123:45 filename.mp3 # # "(r)epeat" and "(l)oop" are displayed in bright or dark color depending # on whether the corresponding mode is enabled or disabled (see below). # (The coloring assumes a terminal with a dark background color or image.) # The "|>" symbol indicates that the file is currently playing; when # playback is paused, it changes to "||". # # In repeat mode, when the last file finishes playing, playback continues # with the first file. If repeat mode is disabled, the program exits after # the last file finishes playing. # # In loop mode, the current file will be repeated endlessly. If the file # contains an ID3v2 ASCII comment of the form "repeat start=X len=Y" (where # X is an integer sample offset and Y is an integer sample count), playback # will jump back to sample X after playing sample X+Y-1. If loop mode is # enabled when playback has already reached sample X+Y, the file will first # be played to completion, then restarted and looped as described above. # For Ogg Vorbis and FLAC files, the script instead looks for comment tags # (case-insensitive) in either of the following formats: # - "loopstart=X" and "looplength=Y", with X and Y specified as above # - "loopstart=X" and "loopend=Z", with Z = X+Y-1 # # The following keys can be used to control playback: # Spacebar Toggles between pause and play mode. # < Moves backward one file in the playlist. # > Moves forward one file in the playlist. # [ Reduces volume by 1%. # ] Increases volume by 1%. # = Resets volume to 100%. # L Toggles loop mode on or off. # Q Quits the program. # R Toggles repeat mode on or off. # # If the -t option is given, the program will also output escape sequences # to change the title of a terminal window to reflect playback status. # # The programs "sox" and "lame" must be in the executable path. # # Author: Andrew Church # This program is public domain. # # Change log: # # 2023/05/07 # - Added support for Vorbis/FLAC files with loops specified using # "loopstart"/"loopend" tags instead of "loopstart"/"looplength". # - Loop tags are now recognized case-insensitively. # # 2022/07/21 # - Fixed a runtime warning when invoked with no command-line arguments. # # 2019/11/02 # - Added support for setting the terminal window title to reflect # playback status. # # 2019/08/18 # - Added support for parsing the EXTENSIBLEWAVEFORMAT header produced # by flac-1.3.3 when decoding FLAC files. # - Prefixed all error messages produced by the script with "mp3play:". # # 2019/02/08 # - Added support for Ogg Vorbis decoding (requires SoX to have been # built with Ogg Vorbis support). # - Changed all SoX invocations to use long options for future-proofing. # # 2018/09/10 # - Added support for playing 16-bit PCM WAV files. Header parsing is # extremely simplistic; there must be no extra chunks or extended # format information in the file. # # 2018/07/28 # - Added [ ] = keys for volume control while playing. # # 2018/06/14 # - Added support for FLAC decoding (requires the "flac" program to be # in the executable path). # # 2018/04/29 # - Added the -v option for simple volume control. # # 2017/10/04 # - mp3play now properly remixes between mono and stereo if the input # and output channel counts differ. # # 2016/10/14 # - mp3play now reopens the output stream if the input sampling rate # changes, rather than resampling all files to match the rate of the # first file. This fixes incorrect time display and looping for # files whose sampling rates differ from that of the first file. # # 2015/5/21 # - Updated sox command lines for option name changes in sox 14.4. # use strict; use warnings; use IO::Handle; use Term::ReadKey; use Time::HiRes; STDOUT->autoflush(1); END { ReadMode(0); } my $uname_s = `uname -s 2>/dev/null`; $uname_s =~ s/^\s+//g; $uname_s =~ s/\s+$//g; my $playcmd; if ($uname_s eq 'Darwin') { # Mac OS X $playcmd = 'sox --volume 0.5 --no-show-progress --type raw --encoding signed-integer --bits 16 --rate $rate --channels $chans - --type coreaudio default'; } else { # Linux $playcmd = 'sox --no-show-progress --type raw --encoding signed-integer --bits 16 --rate $rate --channels $chans - --type alsa default 2>/dev/null'; # Current aplay dies on underrun... #$playcmd = 'aplay -q -fs16_le -r$rate -c$chans'; } my @files = (); my $cur_file = 0; my $pause = 0; my $loop = 0; my $repeat = 0; my $status_in_title = 0; my $volume = 1; sub escape_quotes { my ($str) = @_; $str =~ s/'/'\\''/g; return $str; } sub open_pipe_in { my ($cmd) = @_; my $pipe; open $pipe, "${cmd}|" or die "mp3play: pipein(${cmd}): $!\n"; return $pipe; } sub open_pipe_out { my ($cmd) = @_; my $pipe; open $pipe, "|${cmd}" or die "mp3play: pipeout(${cmd}): $!\n"; return $pipe; } my $last_status_line = ''; my $show_volume = 0; # Set nonzero to trigger volume display for a short time. my $show_volume_timeout = 0; sub status { my ($playtime) = @_; # (r)epeat (l)oop |> mmm:ss path... my $status_line = ''; my $path = $files[$cur_file]; $path =~ s|^.*/||; my $term_width = (GetTerminalSize(*STDOUT))[0] || 80; my $path_maxwidth = ($term_width-1) - 26; my $short_path = $path; my $path_width = 0; for (my $i = 0; $i < length($short_path); ) { my $byte0 = unpack("C", substr($short_path, $i, 1)); my ($width, $charlen); if ($byte0 < 0x80) { $width = 1; $charlen = 1; } elsif ($byte0 < 0xC0) { $charlen = 1; # Invalid byte. } elsif ($byte0 < 0xE0) { $width = 1; $charlen = 2; } elsif ($byte0 < 0xF0) { # Assume U+2000 and above are double width. $width = ($byte0 >= 0xE2 ? 2 : 1); $charlen = 3; } elsif ($byte0 < 0xF8) { $width = 2; $charlen = 4; } elsif ($byte0 < 0xFC) { $width = 2; $charlen = 5; } else { $width = 2; $charlen = 6; } if ($path_width + $width > $path_maxwidth) { $short_path = substr($short_path, 0, $i); } else { $path_width += $width; $i += $charlen; } } $status_line .= sprintf(" \033[1;%sm(r)epeat\033[m", $repeat ? '37' : '30'); $status_line .= sprintf(" \033[1;%sm(l)oop\033[m", $loop ? '37' : '30'); $status_line .= $pause ? ' ||' : ' |>'; $status_line .= sprintf(' %3d:%02d', int($playtime/60), int($playtime)%60); $status_line .= sprintf(' %s', $short_path); my $title_line = sprintf('[%d:%02d] %s', int($playtime/60), int($playtime)%60, $path); my $now = Time::HiRes::time(); if ($show_volume) { $show_volume = 0; $show_volume_timeout = Time::HiRes::time() + 1; } if ($now < $show_volume_timeout) { $status_line = sprintf("[Volume: %d%%]", int($volume*100+0.5)); $title_line = $status_line; } if ($status_line ne $last_status_line) { if ($status_in_title) { print "\033]0;$title_line\007"; } print "$status_line\033[K\r"; $last_status_line = $status_line; } } my $r_option = 0; my $mono = 0; while (@ARGV && $ARGV[0] =~ /^-/) { if ($ARGV[0] eq '-r') { $r_option = 1; } elsif ($ARGV[0] eq '-m') { $mono = 1; } elsif ($ARGV[0] eq '-t') { $status_in_title = 1; } elsif ($ARGV[0] =~ /^-v([0-9.]+)$/) { $volume = $1 - 0; # Force numeric interpretation. } else { die "Usage: $0 [-m] [-r] [-vVOLUME] FILE...\n"; } shift @ARGV; } foreach my $file (@ARGV) { if (-f $file) { push @files, $file; } else { print STDERR "${file}: file not found\n"; } } if (!@files) { if (!@ARGV) { print STDERR "Usage: $0 [-m] [-r] [-vVOLUME] FILE...\n"; } exit 1 } if ($r_option) { if (@files == 1) { $loop = 1; } else { $repeat = 1; } } my $chans = ($mono ? 1 : 2); my $rate = 0; my $audio_play = undef; my $FADETIME = 0.3; my $fade = 0; ReadMode(3); my $quit = 0; for (my $repeating = 1; !$quit && $repeating; $repeating = $repeat) { FILELOOP: for ($cur_file = 0; !$quit && $cur_file < @files; $cur_file++) { my $file = $files[$cur_file]; my ($decode_cmd, $repstart, $replen) = &parse_file($file); my $decode_pipe = &open_pipe_in($decode_cmd); binmode $decode_pipe; my $header; sysread($decode_pipe, $header, 68) == 68 or die "mp3play: Failed to decode ${file}\n"; substr($header, 0, 4) eq "RIFF" and substr($header, 8, 8) eq "WAVEfmt " or die "mp3play: Unexpected data format 1 while decoding ${file}\n"; my ($fmt_len, $wave_type) = unpack("Vs<", substr($header,16,6)); (($fmt_len==16 && $wave_type==1) || ($fmt_len==40 && $wave_type==-2)) and substr($header, 20+$fmt_len, 4) eq "data" or die "mp3play: Unexpected data format 2 while decoding ${file}\n"; my (undef, $thischans, $thisrate, undef, undef, $thisbits) = unpack('(SSLLSS)<', substr($header,20,16)); defined($thischans) && $thischans > 0 or die "mp3play: Failed to read channel count from ${file}\n"; defined($thisrate) && $thisrate > 0 or die "mp3play: Failed to read sampling rate from ${file}\n"; defined($thisbits) && $thisbits > 0 or die "mp3play: Failed to read sample size from ${file}\n"; $thisbits == 16 || $thisbits == 24 or die "mp3play: Unknown sample size ${thisbits} in ${file}\n"; if ($thisrate != $rate) { $rate = $thisrate; $audio_play = &open_pipe_out(eval("\"${playcmd}\"")); binmode $audio_play; $audio_play->autoflush(1); } my $pcm_data = substr($header, 20+$fmt_len+8); my $repend = (defined($replen) ? $repstart + $replen : 0); my $can_loop = ($repend > 0); my $is_first_loop = 1; for (my $looping = 1; !($quit && $fade == 0) && $looping; $looping = $loop, $is_first_loop = !$can_loop, $can_loop = ($repend > 0)) { my $playback_pos = (!$is_first_loop && $repend>0 ? $repstart : 0); while (!($quit && $fade == 0)) { &status($playback_pos / $thisrate); my $rin = ''; my $win = ''; vec($rin, 0, 1) = 1; if ($decode_pipe) { vec($rin, fileno($decode_pipe), 1) = 1; } else { last if $playback_pos*($thischans*2) >= length($pcm_data); } if (!($pause && $fade == 0) && length($pcm_data) > $playback_pos*($thischans*2)) { vec($win, fileno($audio_play), 1) = 1; } my ($rout, $wout) = ($rin, $win); select($rout, $wout, undef, undef); if ($decode_pipe && vec($rout, fileno($decode_pipe), 1)) { if (read($decode_pipe, $_, 1024*($thischans*$thisbits/8))) { if ($thisbits == 24) { for (my $i = 0; $i+3 <= length($_); $i += 3) { $pcm_data .= substr($_, $i+1, 2); } } else { # $thisbits == 16 $pcm_data .= $_; } } else { close $decode_pipe; $decode_pipe = undef; } } if (vec($wout, fileno($audio_play), 1)) { my $n = length($pcm_data)/($thischans*2) - $playback_pos; if ($n > 1024) { $n = 1024; } if ($can_loop && $playback_pos + $n >= $repend) { if ($loop) { $n = $repend - $playback_pos; } else { $can_loop = 0; } } my $out = substr($pcm_data, $playback_pos*($thischans*2), $n*($thischans*2)); if ($thischans != $chans) { my @out = unpack("s*", $out); if ($thischans == 1) { $out = join("", map {pack("s",$_) x $chans} @out); } elsif ($chans == 1) { $out = ""; for (my $i = 0; $i < $n; $i++) { my $sum = 0; for (my $j = 0; $j < $thischans; $j++) { $sum += $out[$i*$thischans + $j]; } $out .= pack("s", int($sum/$thischans)); } } else { die "mp3play: Don't know how to mix from $thischans to $chans channels\n"; } } if ($fade != 0) { my $fade_out = ($fade > 0); my @pcm = unpack("s<*", $out); my $add_silence = 0; for (my $i = 0; $i < $n; $i++) { my $level; if ($fade_out) { $fade -= 1/$thisrate; if ($fade < 0) { $fade = 0; $add_silence = 1; } $level = $fade / $FADETIME; } else { $fade += 1/$thisrate; if ($fade > 0) { $fade = 0; } $level = 1 + ($fade / $FADETIME); } for (my $c = 0; $c < $chans; $c++) { $pcm[$i*$chans+$c] = int($pcm[$i*$chans+$c] * $level + 0.5); } } $out = pack("s<*", @pcm); if ($add_silence) { $out .= "\0\0" x int($chans * $rate / 2); } } if ($volume != 1) { $out = pack("s<*", map {$_*$volume} unpack("s<*", $out)); } print $audio_play $out; $playback_pos += $n; } while (defined(my $key = ReadKey(-1))) { if ($key eq ' ') { $pause = !$pause; $fade = $pause ? $FADETIME : -$FADETIME; } elsif ($key eq '<') { if ($cur_file > 0) { $cur_file--; } $pause = 0; $fade = 0; redo FILELOOP; } elsif ($key eq '>') { if ($cur_file+1 < @files) { $cur_file++; } elsif ($repeat) { $cur_file = 0; } $pause = 0; $fade = 0; redo FILELOOP; } elsif ($key eq '[') { $volume -= 0.01; $volume = 0 if $volume < 0; $show_volume = 1; } elsif ($key eq ']') { $volume += 0.01; $show_volume = 1; } elsif ($key eq '=') { $volume = 1; $show_volume = 1; } elsif ($key eq 'l') { $loop = !$loop; } elsif ($key eq 'q') { $quit = 1; if (!$pause) { $fade = $FADETIME; } } elsif ($key eq 'r') { $repeat = !$repeat; } } last if $can_loop && $loop && $playback_pos >= $repend; } if ($decode_pipe) { close $decode_pipe; } } # while looping } # for each file } # while repeating print "\n"; sub parse_file { my ($file) = @_; my ($decode_cmd, $repstart, $replen); local *F; open F, "<${file}" or die "mp3play: ${file}: $!\n"; binmode F; read F, $_, 0x2000; if (substr($_, 0, 4) eq "RIFF" && substr($_, 8, 14) eq "WAVEfmt \x10\0\0\0\1\0" && substr($_, 34, 6) eq "\x10\0data") { $decode_cmd = sprintf("cat '%s'", &escape_quotes($file)); } elsif (substr($_, 0, 4) eq "fLaC") { $decode_cmd = sprintf("flac -dcs '%s' 2>/dev/null", &escape_quotes($file)); ($repstart, $replen) = &get_ogg_loop(); } elsif (substr($_, 0, 4) eq "OggS") { $decode_cmd = sprintf("sox --no-show-progress --type ogg '%s' --type wav --encoding signed-integer --bits 16 - 2>/dev/null", &escape_quotes($file)); ($repstart, $replen) = &get_ogg_loop(); } else { $decode_cmd = sprintf("lame --decode '%s' - 2>/dev/null", &escape_quotes($file)); while (s/^[\0-\377]*?COMM([\0-\377]{4})//) { my ($a,$b,$c,$d) = unpack("CCCC", $1); my $packet_size = $a<<21 | $b<<14 | $c<<7 | $d; my $packet = substr($_, 7, $packet_size - 5); $_ = substr($_, $packet_size + 2); if ($packet =~ /repeat start=(\d+) len=(\d+)/i) { $repstart = $1; $replen = $2; last; } } } return ($decode_cmd, $repstart, $replen); } sub get_ogg_loop { my ($repstart, $replen, $repend); while (s/^[\0-\377]*?([\0-\377])\0\0\0(loopstart|looplength|loopend)=//i) { my ($comment_len, $type) = ($1, $2); my $value_len = unpack("C", $comment_len) - (length($type)+1); my $value = substr($_, 0, $value_len); $_ = substr($_, $value_len); $value =~ /^[0-9]+$/ or next; $value = $value - 0; if (lc($type) eq "loopstart") { $repstart = $value; } elsif (lc($type) eq "loopend") { $repend = $value; } else { $replen = $value; } } $repend = undef if defined($replen); if (defined($repstart) && defined($repend)) { $replen = ($repend+1) - $repstart; } $repstart = undef if !defined($replen); return ($repstart, $replen); }