about summary refs log tree commit homepage
diff options
context:
space:
mode:
-rw-r--r--Documentation/dtas-archive.pod11
-rw-r--r--Documentation/dtas-player.pod10
-rw-r--r--Documentation/dtas-sourceedit.pod8
-rw-r--r--Documentation/dtas-splitfx.pod9
-rwxr-xr-xGIT-VERSION-GEN2
-rw-r--r--GNUmakefile2
-rw-r--r--INSTALL6
-rw-r--r--README4
-rwxr-xr-xbin/dtas-archive6
-rwxr-xr-xbin/dtas-splitfx1
-rwxr-xr-xbin/dtas-tl38
-rw-r--r--dtas.gemspec2
-rw-r--r--lib/dtas/player.rb7
-rw-r--r--lib/dtas/player/client_handler.rb2
-rw-r--r--lib/dtas/source/av.rb7
-rw-r--r--lib/dtas/source/av_ff_common.rb41
-rw-r--r--lib/dtas/source/ff.rb8
-rw-r--r--lib/dtas/splitfx.rb7
-rw-r--r--lib/dtas/unix_accepted.rb4
-rw-r--r--lib/dtas/unix_client.rb2
-rwxr-xr-xscript/dtas-2splitfx44
-rw-r--r--test/test_player_integration.rb4
-rw-r--r--test/test_source_ff.rb102
-rw-r--r--test/test_unixserver.rb2
24 files changed, 266 insertions, 63 deletions
diff --git a/Documentation/dtas-archive.pod b/Documentation/dtas-archive.pod
index 157ea70..50237e8 100644
--- a/Documentation/dtas-archive.pod
+++ b/Documentation/dtas-archive.pod
@@ -52,11 +52,20 @@ Continue after error
 
 Number of times to repeat the L<sndfile-cmp(1)> check.  Default: 1
 
+=item -m, --match REGEX
+
+Only archive files matching a given Ruby (or Perl-compatible) regular
+expression.  The regular expression is implementation-dependent and
+using the Perl-compatible subset of Ruby regexps is recommended as dtas
+will be moving away from Ruby at some point.
+
+Added for dtas v0.22.0
+
 =back
 
 =head1 COPYRIGHT
 
-Copyright 2013-2020 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright all contributors L<mailto:dtas-all@nongnu.org>
 
 License: GPL-3.0+ L<https://www.gnu.org/licenses/gpl-3.0.txt>
 
diff --git a/Documentation/dtas-player.pod b/Documentation/dtas-player.pod
index 932441a..4cfdd12 100644
--- a/Documentation/dtas-player.pod
+++ b/Documentation/dtas-player.pod
@@ -70,10 +70,10 @@ To play audio on my favorite USB DAC directly to ALSA, I use:
 =head2 Seeking/playing audio from large video containers (e.g. VOB) fails
 
 This is a problem with large VOBs.  We recommend breaking up the
-VOB into smaller files or using L<avconv(1)> or L<ffmpeg(1)> to extract
-the desired audio stream.
+VOB into smaller files or using L<ffmpeg(1)> to extract
+the desired audio stream at C<$STREAM_NR>.
 
-      avconv -analyzeduration 2G -probesize 2G \
+      ffmpeg -analyzeduration 2G -probesize 2G \
         -i input.vob -vn -sn -c:a copy -map 0:$STREAM_NR output.ext
 
 =head1 ADVANCED EXAMPLES
@@ -115,7 +115,7 @@ No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2020 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright all contributors L<mailto:dtas-all@nongnu.org>
 
 License: GPL-3.0+ L<https://www.gnu.org/licenses/gpl-3.0.txt>
 
@@ -123,4 +123,4 @@ License: GPL-3.0+ L<https://www.gnu.org/licenses/gpl-3.0.txt>
 
 L<dtas-player_protocol(7)>, L<dtas-ctl(1)>, L<dtas-enq(1)>,
 L<dtas-sourceedit(1)>, L<dtas-sinkedit(1)>, L<sox(1)>, L<play(1)>,
-L<avconv(1)>, L<ffmpeg(1)>, L<screen(1)>, L<tmux(1)>
+L<ffmpeg(1)>, L<screen(1)>, L<tmux(1)>
diff --git a/Documentation/dtas-sourceedit.pod b/Documentation/dtas-sourceedit.pod
index 67ecabf..593bcf5 100644
--- a/Documentation/dtas-sourceedit.pod
+++ b/Documentation/dtas-sourceedit.pod
@@ -51,11 +51,7 @@ of a previous "dtas-ctl source cat sox" invocation:
 
         $ dtas-sourceedit sox < saved.yml
 
-To change the way dtas-player calls avconv (part of libav):
-
-        $ dtas-sourceedit av
-
-To change the way dtas-player calls ffmpeg (lightly-tested):
+To change the way dtas-player calls ffmpeg:
 
         $ dtas-sourceedit ff
 
@@ -77,7 +73,7 @@ No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2020 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright all contributors L<mailto:dtas-all@nongnu.org>
 
 License: GPL-3.0+ L<https://www.gnu.org/licenses/gpl-3.0.txt>
 
diff --git a/Documentation/dtas-splitfx.pod b/Documentation/dtas-splitfx.pod
index f8de59b..6f8d9ed 100644
--- a/Documentation/dtas-splitfx.pod
+++ b/Documentation/dtas-splitfx.pod
@@ -37,6 +37,12 @@ Print, but do not run the commands to be executed
 
 Silent operation, commands are not printed as executed
 
+=item -S, --stats
+
+Add the sox "stats" effect to the end of the effects chain,
+use this with L</--err-suffix> to get a C<.stats> file with
+every track output
+
 =item -D, --no-dither
 
 Disable automatic setting of the DITHERFX env.  This also passes
@@ -46,7 +52,8 @@ the option to L<sox(1)> via SOX_OPTS.
 
 Write the contents of C<stderr>.  This is useful for capturing
 the per-track output of the L<sox(1)> C<stats> effect when
-combined with parallel C<--jobs>.
+combined with parallel C<--jobs>.  Recommended for use with the
+L</--stats> switch.
 
 =item -O, --outdir OUTDIR
 
diff --git a/GIT-VERSION-GEN b/GIT-VERSION-GEN
index ef12348..ecf9879 100755
--- a/GIT-VERSION-GEN
+++ b/GIT-VERSION-GEN
@@ -5,7 +5,7 @@
 CONSTANT = "DTAS::VERSION"
 RVF = "lib/dtas/version.rb"
 GVF = "GIT-VERSION-FILE"
-DEF_VER = "v0.20.0"
+DEF_VER = "v0.21.0"
 vn = DEF_VER
 
 # First see if there is a version file (included in release tarballs),
diff --git a/GNUmakefile b/GNUmakefile
index 084a2d8..9019b31 100644
--- a/GNUmakefile
+++ b/GNUmakefile
@@ -82,7 +82,7 @@ bindir = $(prefix)/bin
 symlink-install :
         mkdir -p $(bindir)
         dtas=$(CURDIR)/dtas.sh && cd $(bindir) && \
-        for x in $(CURDIR)/bin/*; do \
+        for x in $(CURDIR)/bin/* $(CURDIR)/script/*; do \
                 ln -sf "$$dtas" $$(basename "$$x"); \
         done
 
diff --git a/INSTALL b/INSTALL
index e9ab26c..cb65528 100644
--- a/INSTALL
+++ b/INSTALL
@@ -30,10 +30,10 @@ For future upgrades of dtas
 
 Grab the latest tarball from our HTTPS site:
 
-    https://80x24.org/dtas/2022/dtas-0.20.0.tar.gz
+    https://80x24.org/dtas/2022/dtas-0.21.0.tar.gz
 
-    $ tar zxvf dtas-0.20.0.tar.gz
-    $ cd dtas-0.20.0
+    $ tar zxvf dtas-0.21.0.tar.gz
+    $ cd dtas-0.21.0
 
     # To install symlinks into ~/bin (assuming your Ruby executable is "ruby")
     $ make symlink-install
diff --git a/README b/README
index 1d619e0..2d13fb3 100644
--- a/README
+++ b/README
@@ -84,8 +84,8 @@ You may also read via:
 NNTP: <nntps://news.public-inbox.org/inbox.comp.audio.dtas>
         <nntp://7fh6tueqddpjyxjmgtdiueylzoqt6pt7hec3pukyptlmohoowvhde4yd.onion/inbox.comp.audio.dtas>
         <nntp://news.gmane.io/gmane.comp.audio.dtas.general>
-IMAP: <imaps://anon:mous@public-inbox.org/inbox.comp.audio.dtas.0>
-        <imap://anon:mous@7fh6tueqddpjyxjmgtdiueylzoqt6pt7hec3pukyptlmohoowvhde4yd.onion/inbox.comp.audio.dtas.0>
+IMAP: <imaps://;AUTH=ANONYMOUS@public-inbox.org/inbox.comp.audio.dtas.0>
+        <imap://;AUTH=ANONYMOUS@7fh6tueqddpjyxjmgtdiueylzoqt6pt7hec3pukyptlmohoowvhde4yd.onion/inbox.comp.audio.dtas.0>
 Atom: <https://80x24.org/dtas-all/new.atom>
         <http://7fh6tueqddpjyxjmgtdiueylzoqt6pt7hec3pukyptlmohoowvhde4yd.onion/dtas-all/new.atom>
 (.onion URLs require Tor: <https://www.torproject.org/>)
diff --git a/bin/dtas-archive b/bin/dtas-archive
index c88873e..7c0e4f7 100755
--- a/bin/dtas-archive
+++ b/bin/dtas-archive
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2015-2020 all contributors <dtas-all@nongnu.org>
+# Copyright (C) all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 usage = "#$0 SOURCE DESTINATION"
@@ -30,6 +30,7 @@ stats = false
 keep_going = false
 compression = []
 comment = []
+match = nil
 
 OptionParser.new('', 24, '  ') do |op|
   op.banner = usage
@@ -47,6 +48,7 @@ OptionParser.new('', 24, '  ') do |op|
   op.on('-r', '--repeat [COUNT]', 'number of times to check', Integer) do |r|
     repeat = r
   end
+  op.on('-m', '--match=REGEX', String) { |s| match = Regexp.new(s) }
   op.on('-s', '--quiet', '--silent') { silent = true }
   op.on('-h', '--help') do
     puts(op.to_s)
@@ -55,6 +57,7 @@ OptionParser.new('', 24, '  ') do |op|
   op.parse!(ARGV)
 end
 
+match ||= %r/./
 comment.push('--comment', '') if comment.empty?
 
 dst = ARGV.pop
@@ -67,6 +70,7 @@ src.each do |s|
   src_st = File.stat(s)
   if src_st.directory?
     Find.find(s) do |path|
+      path =~ match or next
       File.file?(path) or next
       dir = File.dirname(path)
       dir_st = File.stat(dir)
diff --git a/bin/dtas-splitfx b/bin/dtas-splitfx
index d0afc7b..17d915d 100755
--- a/bin/dtas-splitfx
+++ b/bin/dtas-splitfx
@@ -13,6 +13,7 @@ OptionParser.new('', 24, '  ') do |op|
   op.on('-n', '--dry-run') { opts[:dryrun] = true }
   op.on('-j', '--jobs [JOBS]', Integer) { |val| opts[:jobs] = val } # nil==inf
   op.on('-s', '--quiet', '--silent') { opts[:silent] = true }
+  op.on('-S', '--stats', 'run stats every track') { opts[:stats] = true }
   op.on('-f', '--filter FILTER') { |val| (opts[:filter] ||= []) << val }
   op.on('-D', '--no-dither') { opts[:no_dither] = true }
   op.on('-O', '--outdir OUTDIR') { |val| opts[:outdir] = val }
diff --git a/bin/dtas-tl b/bin/dtas-tl
index e58ee31..c7f4c83 100755
--- a/bin/dtas-tl
+++ b/bin/dtas-tl
@@ -43,7 +43,8 @@ def do_edit(c)
     track_id = $1.to_i
     orig_idx[track_id] = orig.size
     orig << track_id
-    tmp.write("#{Shellwords.escape(line)} =#{track_id}\n")
+    line = Shellwords.escape(line) if line.include?("\n")
+    tmp.write("#{line} =#{track_id}\n")
   end
   tmp.flush
 
@@ -100,13 +101,8 @@ def do_edit(c)
   non_existent = []
   add.each do |path, after_id|
     orig = path
-    path = Shellwords.split(path)[0]
-    path = File.expand_path(path)
-    unless File.exist?(path)
-      path = orig.dup
-      path = Shellwords.split(path)[0]
-      path = File.expand_path(path)
-    end
+    path = File.expand_path(orig)
+    path = File.expand_path(Shellwords.split(path)[0]) unless File.exist?(path)
 
     if File.exist?(path)
       cmd = %W(tl add #{path})
@@ -142,6 +138,8 @@ case cmd = ARGV[0]
 when 'cat'
   each_track(c) { |line| print "#{line}\n" }
 when 'prune'
+  c2 = nil
+  pending = 0
   each_track(c) do |line|
     line.sub!(/\A(\d+)=/n, '') or abort "unexpected line=#{line.inspect}\n"
     track_id = $1.to_i
@@ -153,7 +151,19 @@ when 'prune'
       warn "# #{line}: #{e.class}"
       # raise other exceptions
     end
-    c.req("tl remove #{track_id}") unless ok
+    unless ok
+      c2 ||= DTAS::UNIXClient.new
+      if pending > 5
+        c2.res_wait
+        pending -= 1
+      end
+      pending += 1
+      c2.req_start("tl remove #{track_id}")
+    end
+  end
+  while pending > 0
+    c2.res_wait
+    pending -= 1
   end
 when 'aac' # add-after-current
   ARGV.shift
@@ -178,11 +188,11 @@ when "reto"
   re = ARGV[1]
   time = ARGV[2]
   re = Regexp.quote(re) if fixed
-  re = ignorecase ? %r{#{re}}i : %r{#{re}}
-  get_track_ids(c).each do |track_id|
-    res = c.req("tl get #{track_id}")
-    res.sub!(/\A1 \d+=/, '')
-    if re =~ res
+  re = ignorecase ? %r{#{re}}in : %r{#{re}}n
+  each_track(c) do |line|
+    line.sub!(/\A(\d+)=/n, '') or abort "unexpected line=#{line.inspect}\n"
+    track_id = $1
+    if re =~ line
       req = %W(tl goto #{track_id})
       req << time if time
       res = c.req(req)
diff --git a/dtas.gemspec b/dtas.gemspec
index d5a1916..9bc1cc5 100644
--- a/dtas.gemspec
+++ b/dtas.gemspec
@@ -3,7 +3,7 @@
 Gem::Specification.new do |s|
   manifest = File.read('.gem-manifest').split(/\n/)
   s.name = %q{dtas}
-  s.version = (ENV["VERSION"] || '0.20.0').dup
+  s.version = (ENV["VERSION"] || '0.21.0').dup
   s.authors = ["dtas hackers"]
   s.summary = "duct tape audio suite for *nix"
   s.description = File.read("README").split(/\n\n/)[1].strip
diff --git a/lib/dtas/player.rb b/lib/dtas/player.rb
index 06ba788..6ea3aba 100644
--- a/lib/dtas/player.rb
+++ b/lib/dtas/player.rb
@@ -37,6 +37,7 @@ class DTAS::Player # :nodoc:
     @paused = false
     @format = DTAS::Format.new
     @bypass = [] # %w(rate bits channels) (not worth Hash overhead)
+    @bypass_next = nil # source_spec
 
     @sinks = {} # { user-defined name => sink }
     @targets = [] # order matters
@@ -331,6 +332,7 @@ class DTAS::Player # :nodoc:
 
   # called when the player is leaving idle state
   def spawn_sinks(source_spec)
+    @bypass_next = nil
     return true if @targets[0]
     @sinks.each_value do |sink|
       sink.active or next
@@ -392,6 +394,8 @@ class DTAS::Player # :nodoc:
       if ! @bypass.empty? && pending.respond_to?(:format)
         new_fmt = bypass_match!(@format.dup, pending.format)
         if new_fmt != @format
+          @bypass_next = source_spec
+          return if @sink_buf.inflight > 0
           stop_sinks # we may fail to start below
           format_update!(new_fmt)
         end
@@ -434,6 +438,7 @@ class DTAS::Player # :nodoc:
   end
 
   def stop_sinks
+    @bypass_next = nil
     @targets.each { |t| drop_target(t) }.clear
   end
 
@@ -458,7 +463,9 @@ class DTAS::Player # :nodoc:
     end
 
     # nothing left inflight, stop the sinks until we have a source
+    bn = @bypass_next
     stop_sinks
+    next_source(bn) if bn # are we restarting for bypass?
     :ignore
   end
 
diff --git a/lib/dtas/player/client_handler.rb b/lib/dtas/player/client_handler.rb
index 2914fe7..3c5fe5d 100644
--- a/lib/dtas/player/client_handler.rb
+++ b/lib/dtas/player/client_handler.rb
@@ -564,7 +564,7 @@ module DTAS::Player::ClientHandler # :nodoc:
         rescue => e
           res = "ERR dumping to #{xs(sf.path)} #{e.message}"
         end
-        io.to_io.send(res, Socket::MSG_EOR)
+        io.to_io.send(res, 0)
       ensure
         exit!(0)
       end
diff --git a/lib/dtas/source/av.rb b/lib/dtas/source/av.rb
index 39cad6c..dcebcfd 100644
--- a/lib/dtas/source/av.rb
+++ b/lib/dtas/source/av.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
+# Copyright (C) all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 require_relative '../../dtas'
@@ -13,13 +13,12 @@ class DTAS::Source::Av # :nodoc:
       'avconv -v error $SSPOS $PROBE -i "$INFILE" $AMAP -f sox - |' \
       'sox -p $SOXFMT - $TRIMFX $RGFX',
 
-    # this is above ffmpeg because this av is the Debian default and
-    # it's easier for me to test av than ff
-    "tryorder" => 1,
+    "tryorder" => 2,
   )
 
   def initialize
     command_init(AV_DEFAULTS)
+    @mcache = nil
     @av_ff_probe = "avprobe"
   end
 
diff --git a/lib/dtas/source/av_ff_common.rb b/lib/dtas/source/av_ff_common.rb
index 5299fdb..c600c48 100644
--- a/lib/dtas/source/av_ff_common.rb
+++ b/lib/dtas/source/av_ff_common.rb
@@ -7,10 +7,10 @@ require_relative '../replaygain'
 require_relative '../xs'
 require_relative 'file'
 
-# Common code for libav (avconv/avprobe) and ffmpeg (and ffprobe)
-# TODO: newer versions of both *probes support JSON, which will be easier to
-# parse.  However, the packaged libav version in Debian 7.0 does not
-# support JSON, so we have an ugly parser...
+# Common code for ffmpeg/ffprobe and the abandoned libav (avconv/avprobe).
+# TODO: newer versions of both *probes support JSON, which will be easier
+# to parse.  libav is abandoned, nowadays, and Debian only packages
+# ffmpeg+ffprobe nowadays.
 module DTAS::Source::AvFfCommon # :nodoc:
   include DTAS::Source::File
   include DTAS::XS
@@ -21,10 +21,23 @@ module DTAS::Source::AvFfCommon # :nodoc:
   attr_reader :format
   attr_reader :duration
 
+  CACHE_KEYS = [ :@duration, :@probe_harder, :@comments, :@astreams,
+                 :@format ].freeze
+
+  def mcache_lookup(infile)
+    (@mcache ||= DTAS::Mcache.new).lookup(infile) do |input, dst|
+      tmp = source_file_dup(infile, nil, nil)
+      tmp.av_ff_ok? or return nil
+      CACHE_KEYS.each { |k| dst[k] = tmp.instance_variable_get(k) }
+      dst
+    end
+  end
+
   def try(infile, offset = nil, trim = nil)
-    rv = source_file_dup(infile, offset, trim)
-    rv.av_ff_ok? or return
-    rv
+    ent = mcache_lookup(infile) or return
+    ret = source_file_dup(infile, offset, trim)
+    CACHE_KEYS.each { |k| ret.instance_variable_set(k, ent[k]) }
+    ret
   end
 
   def __parse_astream(cmd, stream)
@@ -104,13 +117,14 @@ module DTAS::Source::AvFfCommon # :nodoc:
       prev_cmd = cmd
     end while incomplete.compact[0]
 
+    enc = Encoding.default_external # typically Encoding::UTF_8
     # old avprobe
     s.scan(%r{^\[FORMAT\]\n(.*?)\n\[/FORMAT\]\n}m) do |_|
       f = $1.dup
       f =~ /^duration=([\d\.]+)\s*$/nm and @duration = $1.to_f
       # TODO: multi-line/multi-value/repeated tags
       f.gsub!(/^TAG:([^=]+)=(.*)$/ni) { |_|
-        @comments[$1.upcase] = -($2)
+        @comments[-DTAS.try_enc($1.upcase, enc)] = $2
       }
     end
 
@@ -118,13 +132,22 @@ module DTAS::Source::AvFfCommon # :nodoc:
     s.scan(%r{^\[format\.tags\]\n(.*?)\n\n}m) do |_|
       f = $1.dup
       f.gsub!(/^([^=]+)=(.*)$/ni) { |_|
-        @comments[$1.upcase] = -$2
+        @comments[-DTAS.try_enc($1.upcase, enc)] = $2
       }
     end
     s.scan(%r{^\[format\]\n(.*?)\n\n}m) do |_|
       f = $1.dup
       f =~ /^duration=([\d\.]+)\s*$/nm and @duration = $1.to_f
     end
+    comments.each do |k,v|
+      v.chomp!
+      comments[k] = -DTAS.try_enc(v, enc)
+    end
+
+    # ffprobe always uses "track", favor FLAC convention "TRACKNUMBER":
+    if @comments['TRACK'] && !@comments['TRACKNUMBER']
+      @comments['TRACKNUMBER'] = @comments.delete('TRACK')
+    end
 
     ! @astreams.compact.empty?
   end
diff --git a/lib/dtas/source/ff.rb b/lib/dtas/source/ff.rb
index 687cd18..c337b42 100644
--- a/lib/dtas/source/ff.rb
+++ b/lib/dtas/source/ff.rb
@@ -1,12 +1,10 @@
-# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
+# Copyright (C) all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 require_relative '../../dtas'
 require_relative 'av_ff_common'
 
 # ffmpeg support
-# note: only tested with the compatibility wrapper in the Debian 7.0 package
-# (so still using avconv/avprobe)
 class DTAS::Source::Ff  # :nodoc:
   include DTAS::Source::AvFfCommon
 
@@ -15,12 +13,12 @@ class DTAS::Source::Ff  # :nodoc:
       'ffmpeg -v error $SSPOS $PROBE -i "$INFILE" $AMAP -f sox - |' \
       'sox -p $SOXFMT - $TRIMFX $RGFX',
 
-    # I haven't tested this much since av is in Debian stable and ff is not
-    "tryorder" => 2,
+    "tryorder" => 1,
   )
 
   def initialize
     command_init(FF_DEFAULTS)
+    @mcache = nil
     @av_ff_probe = "ffprobe"
   end
 
diff --git a/lib/dtas/splitfx.rb b/lib/dtas/splitfx.rb
index c7eaf42..1150ee0 100644
--- a/lib/dtas/splitfx.rb
+++ b/lib/dtas/splitfx.rb
@@ -10,7 +10,8 @@ require 'tempfile'
 # Unlike the stuff for dtas-player, dtas-splitfx is fairly tied to sox
 # (but we may still pipe to ecasound or anything else)
 class DTAS::SplitFX # :nodoc:
-  CMD = 'sox "$INFILE" $COMMENTS $OUTFMT $OUTDST $TRIMFX $FX $RATEFX $DITHERFX'
+  CMD = 'sox "$INFILE" $COMMENTS $OUTFMT $OUTDST $TRIMFX $FX' \
+        ' $RATEFX $DITHERFX $STATS'
   include DTAS::Process
   attr_reader :infile, :env, :command
 
@@ -204,6 +205,7 @@ class DTAS::SplitFX # :nodoc:
     elsif outfmt.bits && outfmt.bits <= 16
       env["DITHERFX"] = "dither -s"
     end
+    env['STATS'] = 'stats' if opts[:stats]
     comments = Tempfile.new(%W(dtas-splitfx-#{t.comments["TRACKNUMBER"]} .txt))
     t.comments.each do |k,v|
       env[k] = v.to_s
@@ -290,6 +292,7 @@ class DTAS::SplitFX # :nodoc:
       t = T.new
       t.tbeg = @t2s.call(start_time)
       t.comments = @comments.dup
+      title.valid_encoding? or warn "#{title.inspect} encoding invalid"
       t.comments["TITLE"] = title
       t.env = @env.dup
 
@@ -395,7 +398,7 @@ class DTAS::SplitFX # :nodoc:
         @out.puts "DONE #{done[0].inspect}" if $DEBUG
         done[1].close!
       else
-        fails << [ t, status ]
+        fails << [ done[0], status ]
       end
     end
 
diff --git a/lib/dtas/unix_accepted.rb b/lib/dtas/unix_accepted.rb
index a84eade..63d3ce0 100644
--- a/lib/dtas/unix_accepted.rb
+++ b/lib/dtas/unix_accepted.rb
@@ -17,7 +17,7 @@ class DTAS::UNIXAccepted # :nodoc:
   # returns :wait_readable on success
   def emit(msg)
     if @sbuf.empty?
-      case rv = @to_io.sendmsg_nonblock(msg, Socket::MSG_EOR, exception: false)
+      case rv = @to_io.sendmsg_nonblock(msg, 0, exception: false)
       when :wait_writable
         @sbuf << msg
         rv
@@ -34,7 +34,7 @@ class DTAS::UNIXAccepted # :nodoc:
 
   # flushes pending data if it got buffered
   def writable_iter
-    case @to_io.sendmsg_nonblock(@sbuf[0], Socket::MSG_EOR, exception: false)
+    case @to_io.sendmsg_nonblock(@sbuf[0], 0, exception: false)
     when :wait_writable then return :wait_writable
     else
       @sbuf.shift
diff --git a/lib/dtas/unix_client.rb b/lib/dtas/unix_client.rb
index 71f833c..8c73b7d 100644
--- a/lib/dtas/unix_client.rb
+++ b/lib/dtas/unix_client.rb
@@ -24,7 +24,7 @@ class DTAS::UNIXClient # :nodoc:
 
   def req_start(args)
     args = xs(args) if Array === args
-    @to_io.send(args, Socket::MSG_EOR)
+    @to_io.send(args, 0)
   end
 
   def req_ok(args, timeout = nil)
diff --git a/script/dtas-2splitfx b/script/dtas-2splitfx
new file mode 100755
index 0000000..afa761d
--- /dev/null
+++ b/script/dtas-2splitfx
@@ -0,0 +1,44 @@
+#!/usr/bin/perl -w
+# Copyright (C) all contributors <dtas-all@nongnu.org>
+# License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
+# parse soxi output and generates a dtas-splitfx-compatible YAML snippet
+# usage: dtas-2splitfx 1.flac 2.flac ... >tracks.yml
+use v5.12;
+use POSIX qw(strftime);
+open my $fh, '-|', 'soxi', @ARGV or die $!;
+my $title = '';
+my $off = 0;
+my $sec = 0;
+
+my $flush = sub {
+        my ($start) = @_;
+        my $frac = $start =~ s/\.([0-9]+)\z// ? $1 : 0;
+        $start = strftime('%H:%M:%S', gmtime($start));
+        $start .= ".$frac" if $frac;
+        $start;
+};
+
+while (<$fh>) {
+        if (/^Duration\s*:\s*([0-9:\.]+)/) {
+                my $t = $1;
+                $sec = $t =~ s/\.([0-9]+)\z// ? "0.$1" : 0;
+                my @t = split(/:/, $t); # HH:MM:SS
+                my $mult = 1;
+                while (defined(my $part = pop @t)) {
+                        $sec += $part * $mult;
+                        $mult *= 60;
+                }
+        } elsif (s/^title=//i) {
+                chomp;
+                $title = $_;
+                $title =~ tr!"!'!;
+        } elsif (/^\s*\z/s && $sec) {
+                my $start = $flush->($off);
+                say qq(- t $start "), , $title, '"';
+                $off += $sec;
+                $sec = 0;
+                $title = '';
+        }
+}
+close $fh or die "soxi failed: \$?=$?";
+say qq(- stop ), $flush->($off);
diff --git a/test/test_player_integration.rb b/test/test_player_integration.rb
index 5059bd2..09eceee 100644
--- a/test/test_player_integration.rb
+++ b/test/test_player_integration.rb
@@ -209,11 +209,11 @@ class TestPlayerIntegration < Testcase
 
   def test_source_ed
     s = client_socket
-    assert_equal "sox av ff splitfx", s.req("source ls")
+    assert_equal "sox ff av splitfx", s.req("source ls")
     s.req_ok("source ed av tryorder=-1")
     assert_equal "av sox ff splitfx", s.req("source ls")
     s.req_ok("source ed av tryorder=")
-    assert_equal "sox av ff splitfx", s.req("source ls")
+    assert_equal "sox ff av splitfx", s.req("source ls")
 
     s.req_ok("source ed sox command=true")
     sox = DTAS.yaml_load(s.req("source cat sox"))
diff --git a/test/test_source_ff.rb b/test/test_source_ff.rb
new file mode 100644
index 0000000..e53e72e
--- /dev/null
+++ b/test/test_source_ff.rb
@@ -0,0 +1,102 @@
+# Copyright (C) all contributors <dtas-all@nongnu.org>
+# License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
+# frozen_string_literal: true
+require './test/helper'
+require 'dtas/source/ff'
+require 'tempfile'
+
+class TestSourceFf < Testcase
+  def teardown
+    @tempfiles.each(&:close!)
+  end
+
+  def setup
+    @tempfiles = []
+  end
+
+  def x(cmd)
+    system(*cmd)
+    assert $?.success?, cmd.inspect
+  end
+
+  def new_file(suffix)
+    tmp = Tempfile.new(%W(tmp .#{suffix}))
+    @tempfiles << tmp
+    cmd = %W(sox -r 44100 -b 16 -c 2 -n #{tmp.path} trim 0 1)
+    return tmp if system(*cmd)
+    nil
+  end
+
+  def test_flac
+    return if `which metaflac`.strip.size == 0
+    tmp = new_file('flac') or return
+
+    x(%W(metaflac --set-tag=FOO=BAR #{tmp.path}))
+    x(%W(metaflac --add-replay-gain #{tmp.path}))
+    source = DTAS::Source::Ff.new.try(tmp.path)
+    assert_equal source.comments["FOO"], "BAR", source.inspect
+    rg = source.replaygain('track_gain')
+    assert_kind_of DTAS::ReplayGain, rg
+    assert_in_delta 0.0, rg.track_peak.to_f, 0.00000001
+    assert_in_delta 0.0, rg.album_peak.to_f, 0.00000001
+    assert_operator rg.album_gain.to_f, :>, 1
+    assert_operator rg.track_gain.to_f, :>, 1
+  end
+
+  def test_mp3gain
+    return if `which mp3gain`.strip.size == 0
+    a = new_file('mp3') or return
+    b = new_file('mp3') or return
+
+    # redirect stdout to /dev/null temporarily, mp3gain is noisy
+    File.open("/dev/null", "w") do |null|
+      old_out = $stdout.dup
+      $stdout.reopen(null)
+      begin
+        x(%W(mp3gain -q #{a.path} #{b.path}))
+      ensure
+        $stdout.reopen(old_out)
+        old_out.close
+      end
+    end
+
+    source = DTAS::Source::Ff.new.try(a.path)
+    rg = source.replaygain('track_gain')
+    assert_kind_of DTAS::ReplayGain, rg
+    assert_in_delta 0.0, rg.track_peak.to_f, 0.00000001
+    assert_in_delta 0.0, rg.album_peak.to_f, 0.00000001
+    assert_operator rg.album_gain.to_f, :>, 1
+    assert_operator rg.track_gain.to_f, :>, 1
+  end
+
+  def test_offset
+    tmp = new_file('flac') or return
+    source = DTAS::Source::Ff.new.try(*%W(#{tmp.path} 5s))
+    assert_equal 5, source.offset_samples
+
+    source = DTAS::Source::Ff.new.try(*%W(#{tmp.path} 1:00:00.5))
+    expect = 1 * 60 * 60 * 44100 + (44100/2)
+    assert_equal expect, source.offset_samples
+
+    source = DTAS::Source::Ff.new.try(*%W(#{tmp.path} 1:10.5))
+    expect = 1 * 60 * 44100 + (10 * 44100) + (44100/2)
+    assert_equal expect, source.offset_samples
+
+    source = DTAS::Source::Ff.new.try(*%W(#{tmp.path} 10.03))
+    expect = (10 * 44100) + (44100 * 3/100.0)
+    assert_equal expect, source.offset_samples
+  end
+
+  def test_offset_us
+    tmp = new_file('flac') or return
+    source = DTAS::Source::Ff.new.try(*%W(#{tmp.path} 441s))
+    assert_equal 10000.0, source.offset_us
+
+    source = DTAS::Source::Ff.new.try(*%W(#{tmp.path} 22050s))
+    assert_equal 500000.0, source.offset_us
+
+    source = DTAS::Source::Ff.new.try(tmp.path, '1')
+    assert_equal 1000000.0, source.offset_us
+  end
+end if `which ffprobe 2>/dev/null` =~ /ffprobe/ &&
+       `which ffmpeg 2>/dev/null` =~ /ffmpeg/
diff --git a/test/test_unixserver.rb b/test/test_unixserver.rb
index 7e99b9e..c91354d 100644
--- a/test/test_unixserver.rb
+++ b/test/test_unixserver.rb
@@ -41,7 +41,7 @@ class TestUNIXServer < Testcase
     @srv.run_once # nothing
     msgs = []
     clients = []
-    client.send("HELLO", Socket::MSG_EOR)
+    client.send("HELLO", 0)
     @srv.run_once do |c, msg|
       clients << c
       msgs << msg