about summary refs log tree commit homepage
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--Documentation/.gitignore2
-rw-r--r--Documentation/GNUmakefile2
-rw-r--r--Documentation/dtas-archive.pod13
-rw-r--r--Documentation/dtas-console.pod4
-rw-r--r--Documentation/dtas-ctl.pod4
-rw-r--r--Documentation/dtas-cueedit.pod4
-rw-r--r--Documentation/dtas-enq.pod4
-rw-r--r--Documentation/dtas-env.pod6
-rw-r--r--Documentation/dtas-msinkctl.pod4
-rw-r--r--Documentation/dtas-player.pod12
-rw-r--r--Documentation/dtas-player_effects.pod4
-rw-r--r--Documentation/dtas-player_protocol.pod8
-rw-r--r--Documentation/dtas-player_sink_examples.pod4
-rw-r--r--Documentation/dtas-sinkedit.pod8
-rw-r--r--Documentation/dtas-sourceedit.pod14
-rw-r--r--Documentation/dtas-splitfx.pod58
-rw-r--r--Documentation/dtas-tl.pod10
-rw-r--r--Documentation/dtas-xdelay.pod4
-rwxr-xr-xDocumentation/update-footer.rb4
-rwxr-xr-xGIT-VERSION-GEN4
-rw-r--r--GNUmakefile14
-rw-r--r--HACKING4
-rw-r--r--INSTALL42
-rw-r--r--README22
-rw-r--r--Rakefile2
-rw-r--r--TODO4
-rwxr-xr-xbin/dtas-archive14
-rwxr-xr-xbin/dtas-console76
-rwxr-xr-xbin/dtas-ctl2
-rwxr-xr-xbin/dtas-cueedit2
-rwxr-xr-xbin/dtas-enq2
-rwxr-xr-xbin/dtas-mlib2
-rwxr-xr-xbin/dtas-msinkctl5
-rwxr-xr-xbin/dtas-partstats12
-rwxr-xr-xbin/dtas-player3
-rwxr-xr-xbin/dtas-readahead40
-rwxr-xr-xbin/dtas-sinkedit7
-rwxr-xr-xbin/dtas-sourceedit7
-rwxr-xr-xbin/dtas-splitfx23
-rwxr-xr-xbin/dtas-tl93
-rwxr-xr-xbin/dtas-xdelay2
-rw-r--r--dtas-linux.gemspec4
-rw-r--r--dtas-mpris.gemspec4
-rw-r--r--dtas.gemspec8
-rwxr-xr-xdtas.sh8
-rw-r--r--examples/splitfx.sample.yml2
-rw-r--r--examples/tfx.sample.yml2
-rw-r--r--examples/zsh-completion/README3
-rw-r--r--examples/zsh-completion/_dtas-archive16
-rw-r--r--examples/zsh-completion/_dtas-ctl114
-rw-r--r--examples/zsh-completion/_dtas-cueedit7
-rw-r--r--examples/zsh-completion/_dtas-enq7
-rw-r--r--examples/zsh-completion/_dtas-mlib29
-rw-r--r--examples/zsh-completion/_dtas-msinkctl8
-rw-r--r--examples/zsh-completion/_dtas-partstats7
-rw-r--r--examples/zsh-completion/_dtas-sinkedit11
-rw-r--r--examples/zsh-completion/_dtas-sourceedit11
-rw-r--r--examples/zsh-completion/_dtas-splitfx17
-rw-r--r--examples/zsh-completion/_dtas-tl52
-rw-r--r--examples/zsh-completion/_dtas-xdelay17
-rw-r--r--lib/dtas.rb36
-rw-r--r--lib/dtas/buffer.rb19
-rw-r--r--lib/dtas/buffer/fiddle_splice.rb217
-rw-r--r--lib/dtas/buffer/read_write.rb7
-rw-r--r--lib/dtas/buffer/splice.rb17
-rw-r--r--lib/dtas/command.rb2
-rw-r--r--lib/dtas/compat_onenine.rb17
-rw-r--r--lib/dtas/cue_index.rb2
-rw-r--r--lib/dtas/disclaimer.rb2
-rw-r--r--lib/dtas/edit_client.rb5
-rw-r--r--lib/dtas/encoding.rb17
-rw-r--r--lib/dtas/fadefx.rb4
-rw-r--r--lib/dtas/format.rb2
-rw-r--r--lib/dtas/mcache.rb17
-rw-r--r--lib/dtas/mlib.rb36
-rw-r--r--lib/dtas/mlib/migrations/0001_initial.rb2
-rw-r--r--lib/dtas/nonblock.rb24
-rw-r--r--lib/dtas/parse_freq.rb2
-rw-r--r--lib/dtas/parse_time.rb2
-rw-r--r--lib/dtas/partstats.rb8
-rw-r--r--lib/dtas/pipe.rb25
-rw-r--r--lib/dtas/pipeline.rb5
-rw-r--r--lib/dtas/player.rb29
-rw-r--r--lib/dtas/player/client_handler.rb44
-rw-r--r--lib/dtas/process.rb8
-rw-r--r--lib/dtas/replaygain.rb2
-rw-r--r--lib/dtas/rg_state.rb4
-rw-r--r--lib/dtas/serialize.rb2
-rw-r--r--lib/dtas/sigevent.rb9
-rw-r--r--lib/dtas/sigevent/efd.rb4
-rw-r--r--lib/dtas/sigevent/fiddle_efd.rb37
-rw-r--r--lib/dtas/sigevent/pipe.rb7
-rw-r--r--lib/dtas/sink.rb3
-rw-r--r--lib/dtas/source.rb2
-rw-r--r--lib/dtas/source/av.rb7
-rw-r--r--lib/dtas/source/av_ff_common.rb45
-rw-r--r--lib/dtas/source/cmd.rb2
-rw-r--r--lib/dtas/source/common.rb2
-rw-r--r--lib/dtas/source/ff.rb8
-rw-r--r--lib/dtas/source/file.rb2
-rw-r--r--lib/dtas/source/mp3gain.rb2
-rw-r--r--lib/dtas/source/sox.rb8
-rw-r--r--lib/dtas/source/splitfx.rb5
-rw-r--r--lib/dtas/spawn_fix.rb10
-rw-r--r--lib/dtas/splitfx.rb39
-rw-r--r--lib/dtas/state_file.rb4
-rw-r--r--lib/dtas/tfx.rb2
-rw-r--r--lib/dtas/track.rb4
-rw-r--r--lib/dtas/tracklist.rb2
-rw-r--r--lib/dtas/unix_accepted.rb49
-rw-r--r--lib/dtas/unix_client.rb6
-rw-r--r--lib/dtas/unix_server.rb16
-rw-r--r--lib/dtas/util.rb2
-rw-r--r--lib/dtas/watchable.rb116
-rw-r--r--lib/dtas/watchable/fiddle_ino.rb78
-rw-r--r--lib/dtas/watchable/inotify.rb13
-rw-r--r--lib/dtas/writable_iter.rb2
-rw-r--r--lib/dtas/xs.rb2
-rwxr-xr-xscript/dtas-2splitfx44
-rwxr-xr-xscript/dtas-graph (renamed from perl/dtas-graph)9
-rw-r--r--setup.rb3
-rw-r--r--test/covshow.rb2
-rw-r--r--test/helper.rb2
-rw-r--r--test/player_integration.rb3
-rw-r--r--test/test_buffer.rb16
-rw-r--r--test/test_encoding.rb5
-rw-r--r--test/test_env.rb2
-rw-r--r--test/test_fadefx.rb2
-rw-r--r--test/test_format.rb2
-rw-r--r--test/test_format_change.rb4
-rw-r--r--test/test_mcache.rb20
-rw-r--r--test/test_mlib.rb2
-rw-r--r--test/test_parse_freq.rb2
-rw-r--r--test/test_pipeline.rb2
-rw-r--r--test/test_player.rb2
-rw-r--r--test/test_player_client_handler.rb4
-rw-r--r--test/test_player_integration.rb19
-rw-r--r--test/test_process.rb2
-rw-r--r--test/test_rg_integration.rb18
-rw-r--r--test/test_rg_state.rb2
-rw-r--r--test/test_sigevent.rb20
-rw-r--r--test/test_sink.rb4
-rw-r--r--test/test_sink_pipe_size.rb29
-rw-r--r--test/test_sink_tee_integration.rb2
-rw-r--r--test/test_source_av.rb2
-rw-r--r--test/test_source_ff.rb102
-rw-r--r--test/test_source_sox.rb2
-rw-r--r--test/test_splitfx.rb7
-rw-r--r--test/test_tfx.rb5
-rw-r--r--test/test_tracklist.rb2
-rw-r--r--test/test_unixserver.rb6
-rw-r--r--test/test_util.rb2
153 files changed, 1584 insertions, 691 deletions
diff --git a/.gitignore b/.gitignore
index 0d3f766..4e1a2e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 /GIT-VERSION-FILE
 /Manifest.txt
diff --git a/Documentation/.gitignore b/Documentation/.gitignore
index 29953eb..b212ef7 100644
--- a/Documentation/.gitignore
+++ b/Documentation/.gitignore
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 *.1
 *.5
diff --git a/Documentation/GNUmakefile b/Documentation/GNUmakefile
index d3834f9..9bad54f 100644
--- a/Documentation/GNUmakefile
+++ b/Documentation/GNUmakefile
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 all::
 
diff --git a/Documentation/dtas-archive.pod b/Documentation/dtas-archive.pod
index 4d7f4e8..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-2016 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>
 
@@ -65,7 +74,7 @@ License: GPL-3.0+ L<https://www.gnu.org/licenses/gpl-3.0.txt>
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
diff --git a/Documentation/dtas-console.pod b/Documentation/dtas-console.pod
index 1aa5acc..3bf11af 100644
--- a/Documentation/dtas-console.pod
+++ b/Documentation/dtas-console.pod
@@ -78,13 +78,13 @@ a problem.
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright 2013-2020 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-ctl.pod b/Documentation/dtas-ctl.pod
index 699a723..25a8b3f 100644
--- a/Documentation/dtas-ctl.pod
+++ b/Documentation/dtas-ctl.pod
@@ -62,13 +62,13 @@ This defaults to ~/.dtas/player.sock
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright 2013-2020 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-cueedit.pod b/Documentation/dtas-cueedit.pod
index 4452afe..9ee4797 100644
--- a/Documentation/dtas-cueedit.pod
+++ b/Documentation/dtas-cueedit.pod
@@ -23,13 +23,13 @@ VISUAL / EDITOR - your favorite *nix text editor, defaults to 'vi' if unset.
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright 2013-2020 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-enq.pod b/Documentation/dtas-enq.pod
index 9992f73..dceea88 100644
--- a/Documentation/dtas-enq.pod
+++ b/Documentation/dtas-enq.pod
@@ -28,13 +28,13 @@ This defaults to ~/.dtas/player.sock
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright 2013-2020 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-env.pod b/Documentation/dtas-env.pod
index d0ea2e8..a460700 100644
--- a/Documentation/dtas-env.pod
+++ b/Documentation/dtas-env.pod
@@ -10,7 +10,7 @@ As dtas uses Bourne shell and exposes it to users, dtas should have
 a cohesive set of common environment variables across its audio
 production and playback environments.  This attempts to document
 them.  Most of these environments are set and managed by dtas
-itself, but users editing commands (e.g. via L<dtas-sourcedit(1)>
+itself, but users editing commands (e.g. via L<dtas-sourcedit(1)>)
 should be aware of them.
 
 =head1 ENVIRONMENT
@@ -64,12 +64,12 @@ temporary files are placed for most programs.
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright 2013-2020 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-msinkctl.pod b/Documentation/dtas-msinkctl.pod
index 2cb4187..bee2af0 100644
--- a/Documentation/dtas-msinkctl.pod
+++ b/Documentation/dtas-msinkctl.pod
@@ -45,13 +45,13 @@ This defaults to ~/.dtas/player.sock
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright 2013-2020 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 c4eb696..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
@@ -109,13 +109,13 @@ state across restarts of dtas-player.
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 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-player_effects.pod b/Documentation/dtas-player_effects.pod
index c0861f5..6c20934 100644
--- a/Documentation/dtas-player_effects.pod
+++ b/Documentation/dtas-player_effects.pod
@@ -92,12 +92,12 @@ playback hardware are applied at the sink:
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright 2013-2020 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_protocol.pod b/Documentation/dtas-player_protocol.pod
index f709e96..e98197d 100644
--- a/Documentation/dtas-player_protocol.pod
+++ b/Documentation/dtas-player_protocol.pod
@@ -2,7 +2,7 @@
 
 =head1 NAME
 
-dtas-player_protocol - protocol for controling dtas-player
+dtas-player_protocol - protocol for controlling dtas-player
 
 =head1 DESCRIPTION
 
@@ -382,7 +382,7 @@ Clear current tracklist
 
 Show/or change consume status of the tracklist.  Enabling this causes
 tracks to be deleted from the tracklist after they are played or skipped.
-With no args, this will show "true" or "false
+With no args, this will show "true" or "false"
 
 =item tl current
 
@@ -464,12 +464,12 @@ another client socket to issue non-watch commands.
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright 2013-2020 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_sink_examples.pod b/Documentation/dtas-player_sink_examples.pod
index f40707e..a4cc623 100644
--- a/Documentation/dtas-player_sink_examples.pod
+++ b/Documentation/dtas-player_sink_examples.pod
@@ -71,13 +71,13 @@ See L<dtas-xdelay(1)>.
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright 2013-2020 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-sinkedit.pod b/Documentation/dtas-sinkedit.pod
index d6af071..f30d29a 100644
--- a/Documentation/dtas-sinkedit.pod
+++ b/Documentation/dtas-sinkedit.pod
@@ -13,8 +13,8 @@ dtas-sinkedit SINKNAME
 dtas-sinkedit spawns an editor to allow editing of a sink as a YAML file.
 See L<dtas-player_protocol(7)> for details on SINKARGS.
 
-On Linux machines with the sleepy_penguin RubyGem installed, L<inotify(7)>
-is used to monitor the file for changes while the text exitor is running.
+On Linux machines,  L<inotify(7)>
+is used to monitor the file for changes while the text editor is running.
 Each time a user finishes saving a file, changes are committed immediately.
 This behavior may be disabled by using the -N or --no-watch command-line
 switch.
@@ -64,13 +64,13 @@ This defaults to ~/.dtas/player.sock
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright 2013-2020 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-sourceedit.pod b/Documentation/dtas-sourceedit.pod
index 169f08b..593bcf5 100644
--- a/Documentation/dtas-sourceedit.pod
+++ b/Documentation/dtas-sourceedit.pod
@@ -16,8 +16,8 @@ a pipe or file, it is parsed as YAML and fed to the L<dtas-player(1)> instance
 non-interactively.  This is useful for loading various profiles from the
 filesystem.
 
-On Linux machines with the sleepy_penguin RubyGem installed, L<inotify(7)>
-is used to monitor the file for changes while the text exitor is running.
+On Linux machines, L<inotify(7)>
+is used to monitor the file for changes while the text editor is running.
 Each time a user finishes saving a file, changes are committed immediately.
 This behavior may be disabled by using the -N or --no-watch command-line
 switch.
@@ -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
 
@@ -71,13 +67,13 @@ This defaults to ~/.dtas/player.sock
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 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 b5dadfd..6f8d9ed 100644
--- a/Documentation/dtas-splitfx.pod
+++ b/Documentation/dtas-splitfx.pod
@@ -26,7 +26,8 @@ to use L<ecasound(1)>, too.
 =item -j, --jobs [JOBS]
 
 Number of jobs to run in parallel.  If no number is specified, all
-jobs are run in parallel.
+jobs are run in parallel.  Default: number of CPUS (dtas 0.20.0+),
+previous versions of dtas defaulted to 1.
 
 =item -n, --dry-run
 
@@ -36,11 +37,24 @@ 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
 the option to L<sox(1)> via SOX_OPTS.
 
+=item -E, --err-suffix SUFFIX
+
+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>.  Recommended for use with the
+L</--stats> switch.
+
 =item -O, --outdir OUTDIR
 
 Set output directory instead of current directory.
@@ -70,6 +84,16 @@ outputs the result as a single file with the TRACKNUMBER
 of "000".  For ease-of-typing, commas in this command-line
 argument are automatically expanded to spaces when passed to sox.
 
+This switch may not be combined with L</--filter>
+
+=item -f, --filter [FIELD=]VALUE
+
+Only process tracks matching a given comment FIELD and VALUE.
+If no C<=> is exists, then all comment fields are matched
+against the specified VALUE.
+
+This switch may not be combined with L</--trim>
+
 =item -p, --sox-pipe
 
 Used in place of an output target to specify outputting audio data in
@@ -84,9 +108,9 @@ moves printing of output to stderr and disables parallel job invocation.
 
 =item infile - string, the pathname of the original audio file
 
-=item env - ordered hash of environment variables to set for all commands
+=item env - hash of environment variables to set for all commands
 
-    env: !omap
+    env:
       FX: gain 3 stats
 
 =item comments - hash of common tags for all audio (e.g. ARTIST, ALBUM, YEAR)
@@ -115,7 +139,12 @@ highest-numbered track.  Default: true
 
 =item targets - hash, see "TARGETS" section
 
-=item command - used only by L<dtas-player(1)>
+=item command - override the default sox invocation
+
+This command may be used to specify an alternate command to process each
+track.
+
+Default: sox "$INFILE" $COMMENTS $OUTFMT $OUTDST $TRIMFX $FX $RATEFX $DITHERFX
 
 =back
 
@@ -127,11 +156,22 @@ segment.
 
 =over
 
-=item t TIME TITLE [fade_in/fade_out=FADE_ARGS]
+=item t TIME TITLE [fade_in/fade_out=FADE_ARGS] [.FIELD=VALUE] [env.X=Y]
+
+The start of a new track at TIME with TITLE.
+An optional L</fade_in> and L</fade_out> may be specified for any tracks.
+Per-track comments may be specified in the form of C<.FIELD=VALUE>.
+Using per-track C<.ARTIST=FOO> allows proper tagging of multi-artist
+compilations.
 
-The start of a new track
-at TIME with TITLE.  An optional fade_in and fade_out may be specified
-for the first/last tracks.
+Per-track environment variables may be specified in the form
+of C<env.K=V> where C<K> is the environment variable name and
+C<V> is its value.  Per-track environment variables do not affect
+playback of YAML files via L<dtas-player(1)> nor use of the L</--trim>
+command-line option.  However, per-track environment variables do affect
+any tracks written to the filesystem, including those using the L</--filter>
+switch.   These environment variables are intended to affect the specified
+L</command> or default L<sox(1)> invocation.
 
 =item skip TIME - skip a segment starting at TIME
 
@@ -262,7 +302,7 @@ imbalance in a live concert recording from the audience:
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 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-tl.pod b/Documentation/dtas-tl.pod
index 6ca88d0..b5a4b31 100644
--- a/Documentation/dtas-tl.pod
+++ b/Documentation/dtas-tl.pod
@@ -29,7 +29,7 @@ client).
 =item consume [BOOLEAN] - show, enable, or disable consume mode
 
 Enabling "consume" mode causes tracks to be removed when they are
-done playing (or skipped.
+done playing (or skipped).
 
 =item current - display the current track, "NONE" if not playing
 
@@ -60,6 +60,8 @@ optionally seek to POS.  POS should be a timestamp in HH:MM:SS.FRAC format.
 
 =item prev - play the previous track in the tracklist
 
+=item prune - cull non-existent pathnames from the tracklist
+
 =item repeat 1 - repeat the current track
 
 =item repeat false - disable repeat
@@ -78,7 +80,7 @@ display the current tracklist
 
         $ dtas-tl cat
 
-to add an an entire directory of FLAC files
+to add an entire directory of FLAC files
 
         $ dtas-tl addtail /path/to/directory/*.flac
 
@@ -114,13 +116,13 @@ This defaults to ~/.dtas/player.sock
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 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-xdelay.pod b/Documentation/dtas-xdelay.pod
index b05b093..358a31d 100644
--- a/Documentation/dtas-xdelay.pod
+++ b/Documentation/dtas-xdelay.pod
@@ -80,13 +80,13 @@ are greatly appreciated.
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 
 =head1 COPYRIGHT
 
-Copyright 2013-2016 all contributors L<mailto:dtas-all@nongnu.org>
+Copyright 2013-2020 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/update-footer.rb b/Documentation/update-footer.rb
index ea7d7cd..e26e3e3 100755
--- a/Documentation/update-footer.rb
+++ b/Documentation/update-footer.rb
@@ -1,12 +1,12 @@
 #!/usr/bin/env ruby
-# Copyright 2015-2016 all contributors <dtas-all@nongnu.org>
+# Copyright 2015-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 contact = %q{
 All feedback welcome via plain-text mail to: L<mailto:dtas-all@nongnu.org>
 
 Mailing list archives available at L<https://80x24.org/dtas-all/>
-and L<ftp://lists.gnu.org/dtas-all/>
+and L<https://lists.gnu.org/archive/html/dtas-all/>
 
 No subscription is necessary to post to the mailing list.
 }
diff --git a/GIT-VERSION-GEN b/GIT-VERSION-GEN
index 095c769..ecf9879 100755
--- a/GIT-VERSION-GEN
+++ b/GIT-VERSION-GEN
@@ -1,11 +1,11 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2013-2019 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
 CONSTANT = "DTAS::VERSION"
 RVF = "lib/dtas/version.rb"
 GVF = "GIT-VERSION-FILE"
-DEF_VER = "v0.16.1"
+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 8a50278..9019b31 100644
--- a/GNUmakefile
+++ b/GNUmakefile
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2021 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 all::
 pkg = dtas
@@ -74,5 +74,17 @@ $(pkgtgz): .tgz-manifest
 
 package: $(pkgtgz) $(pkggem)
 
+# Install symlinks to ~/bin (which is hopefuly in PATH) which point to
+# this source tree.
+# prefix + bindir matches git.git Makefile:
+prefix = $(HOME)
+bindir = $(prefix)/bin
+symlink-install :
+        mkdir -p $(bindir)
+        dtas=$(CURDIR)/dtas.sh && cd $(bindir) && \
+        for x in $(CURDIR)/bin/* $(CURDIR)/script/*; do \
+                ln -sf "$$dtas" $$(basename "$$x"); \
+        done
+
 .PHONY: all .FORCE-GIT-VERSION-FILE test $(test_units) NEWS
 .PHONY: check-warnings fix-perms
diff --git a/HACKING b/HACKING
index b34966f..5e838d0 100644
--- a/HACKING
+++ b/HACKING
@@ -18,10 +18,10 @@ developers do.  Please send patches via git-send-email(1) to the public
 mailing list at <dtas-all@nongnu.org>.  Pull requests should be
 formatted using git-request-pull(1).\
 Mailing list archives available at <https://80x24.org/dtas-all/> and
-<ftp://lists.gnu.org/dtas-all/>\
+<https://lists.gnu.org/archive/html/dtas-all/>\
 No subscription is necessary to post to the mailing list.
 
 # COPYRIGHT
 
-Copyright 2013-2016 all contributors <dtas-all@nongnu.org>.\
+Copyright 2013-2020 all contributors <dtas-all@nongnu.org>.\
 License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
diff --git a/INSTALL b/INSTALL
index e9c366d..cb65528 100644
--- a/INSTALL
+++ b/INSTALL
@@ -1,33 +1,24 @@
-Uncommon for audio software, dtas is implemented in Ruby.
+Uncommon for audio software, dtas is currently implemented in Ruby
+(and some Perl5).
 
 The latest stable release or development snapshot of Ruby is recommended.
-However, Ruby 1.9.3 and later works, but older versions of Ruby do not.
+However, Ruby 2.3 and later works, but older versions of Ruby do not.
 
 SoX is a dependency of dtas-player.  While not _strictly_ required,
 dtas-player uses SoX by default and you will need it unless you've
 reconfigured dtas-player to use something else.
 
 mp3gain is required if you want to use ReplayGain with MP3s
+(it is no longer in new versions of Debian)
 
 If you only intend to use dtas-cueedit, you will need metaflac(1) from
 the FLAC package.
 
-Debian 7+ users can install dependencies easily:
+Debian 10+ users can install dependencies easily:
 
-    sudo apt-get install sox libsox-fmt-all mp3gain flac ruby-dev
+    sudo apt-get install sox libsox-fmt-all flac ruby-dev ruby-charlock-holmes
 
-# installing dtas RubyGem on GNU/Linux (Linux kernel 2.6.32+)
-
-Be sure to have Ruby development headers and a working C compiler.
-This will pull in the sleepy_penguin RubyGems for minor
-speedups.  If you cannot be bothered to have a development
-environment, just use "gem install dtas".
-
-    sudo gem install dtas-linux
-
-This should pull in the "sleepy_penguin" RubyGems
-
-For future upgrades of dtas (upgrades to dtas-linux will be infrequent)
+For future upgrades of dtas
 
     sudo gem update dtas
 
@@ -35,29 +26,30 @@ For future upgrades of dtas (upgrades to dtas-linux will be infrequent)
 
     sudo gem install dtas
 
-# installing dtas via tarball and setup.rb
+# installing dtas via tarball
 
 Grab the latest tarball from our HTTPS site:
 
-    https://80x24.org/dtas/2019/dtas-0.16.1.tar.gz
+    https://80x24.org/dtas/2022/dtas-0.21.0.tar.gz
 
-    $ tar zxvf dtas-0.16.1.tar.gz
-    $ cd dtas-0.16.1
-    $ sudo ruby setup.rb
+    $ tar zxvf dtas-0.21.0.tar.gz
+    $ cd dtas-0.21.0
 
-GNU/Linux users may optionally install the "sleepy_penguin" package:
+    # To install symlinks into ~/bin (assuming your Ruby executable is "ruby")
+    $ make symlink-install
 
-    * sleepy_penguin - https://bogomips.org/sleepy_penguin/
+    # or using setup.rb:
+    $ sudo ruby setup.rb
 
 # CONTACT
 
 Please do not hesitate to send plain-text mail to <dtas-all@nongnu.org>
 regarding installation and to share your notes/experiences.
 Mailing list archives available at <https://80x24.org/dtas-all/> or
-<ftp://lists.gnu.org/dtas-all>
+<https://lists.gnu.org/archive/html/dtas-all/>
 No subscription is necessary to post to the mailing list.
 
 # COPYRIGHT
 
-Copyright 2013-2019 all contributors <dtas-all@nongnu.org>
+Copyright all contributors <dtas-all@nongnu.org>
 License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
diff --git a/README b/README
index cc6258d..2d13fb3 100644
--- a/README
+++ b/README
@@ -25,7 +25,7 @@ for audio.  dtas-player supports:
 * ReplayGain (including fallback gain and peak normalization)
 
 dtas-player is a *nix pipeline and process manager.  It may be used
-spawn and pipe to abitrary Unix commands, not just audio-related
+spawn and pipe to arbitrary Unix commands, not just audio-related
 commands.  It can interactively restart/replace the source (audio
 decoder) component of a pipeline while keeping the sink (playback
 endpoint) running.
@@ -74,19 +74,25 @@ All feedback (comments, results, feature requests, bug reports, patches,
 pull-requests) via plain-text mail to the mailing list is very much
 appreciated.
 
-Please send plain-text mail to the list at <dtas-all@nongnu.org>\
-HTML mail will not be read.  dtas is for GUI-phobes, by GUI-phobes.\
-Mailing list archives available at <ftp://lists.gnu.org/dtas-all>
-or <https://80x24.org/dtas-all/>
+Please send plain-text mail to the list at <dtas-all@nongnu.org>
+HTML mail will not be read.  dtas is for GUI-phobes, by GUI-phobes.
+Mailing list archives available at <https://80x24.org/dtas-all/> or
+<https://lists.gnu.org/archive/html/dtas-all/>.
+
 No subscription is necessary to post to the mailing list.
 You may also read via:
-NNTP: <nntp://news.public-inbox.org/inbox.comp.audio.dtas>
-        <nntp://news.gmane.org/gmane.comp.audio.dtas.general>
+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://;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/>)
 
 ## Copyright
 
-Copyright 2013-2019 all contributors <dtas-all@nongnu.org>\
+Copyright all contributors <dtas-all@nongnu.org>
 License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 
 dtas is copyrighted Free Software by all contributors, see logs
diff --git a/Rakefile b/Rakefile
index af3a811..08a916f 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2019 all contributors <dtas-all@nongnu.org>.
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>.
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 require 'tempfile'
diff --git a/TODO b/TODO
index 8cabaeb..d1f6a1c 100644
--- a/TODO
+++ b/TODO
@@ -3,7 +3,9 @@
 
 * tests for bin/*
 
+* consider rewriting piecemeal in a more stable glue language than Ruby
+
 # COPYRIGHT
 
-Copyright 2013-2016 all contributors <dtas-all@nongnu.org>\
+Copyright 2013-2020 all contributors <dtas-all@nongnu.org>
 License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
diff --git a/bin/dtas-archive b/bin/dtas-archive
index f47f05a..7c0e4f7 100755
--- a/bin/dtas-archive
+++ b/bin/dtas-archive
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2015-2016 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"
@@ -29,6 +29,8 @@ repeat = 1
 stats = false
 keep_going = false
 compression = []
+comment = []
+match = nil
 
 OptionParser.new('', 24, '  ') do |op|
   op.banner = usage
@@ -36,6 +38,7 @@ OptionParser.new('', 24, '  ') do |op|
   op.on('-C', '--compression [FACTOR]', 'compression factor for sox') { |c|
     compression = [ '-C', c ]
   }
+  op.on('--comment=TEXT', String) { |c| comment.push('--comment', c) }
   op.on('-j', '--jobs [JOBS]', Integer) { |j| jobs = j }
   op.on('-S', '--stats', 'save stats on the file') { stats = true }
   op.on('-k', '--keep-going', 'continue after error') { keep_going = true }
@@ -45,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)
@@ -53,6 +57,9 @@ OptionParser.new('', 24, '  ') do |op|
   op.parse!(ARGV)
 end
 
+match ||= %r/./
+comment.push('--comment', '') if comment.empty?
+
 dst = ARGV.pop
 src = ARGV.dup
 
@@ -63,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)
@@ -137,7 +145,7 @@ thrs = jobs.times.map do |i|
 
       if dry_run || !silent
         names = job.map { |x| Shellwords.escape(x) }
-        cmd = [ 'sox', *names ]
+        cmd = [ 'sox', names[0], *compression, *comment, names[1] ]
         if stats
           cmd << 'stats'
           cmd << "2>#{Shellwords.escape(stats_out)}"
@@ -151,7 +159,7 @@ thrs = jobs.times.map do |i|
         end
       end
 
-      cmd = [ 'sox', input, *compression, output ]
+      cmd = [ 'sox', input, *compression, *comment, output ]
       if stats
         cmd << 'stats'
         cmd = [ *cmd, { err: stats_out } ]
diff --git a/bin/dtas-console b/bin/dtas-console
index 00b5cd8..eedd0f0 100755
--- a/bin/dtas-console
+++ b/bin/dtas-console
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2013-2019 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
 #
@@ -11,21 +11,24 @@ require 'dtas/sigevent'
 require 'dtas/process'
 require 'dtas/format'
 include DTAS::Process
-require 'yaml'
 begin
   require 'curses'
 rescue LoadError
   abort "please install the 'curses' RubyGem to use #$0"
 end
 
+# workaround https://bugs.debian.org/958973
+$VERBOSE = nil if RUBY_VERSION.to_f < 3.0
+
 tsec = false
 se = DTAS::Sigevent.new
 trap(:WINCH) { se.signal }
 w = DTAS::UNIXClient.new
 w.req_ok('watch')
 c = DTAS::UNIXClient.new
-cur = YAML.load(c.req('current'))
+cur = DTAS.yaml_load(c.req('current'))
 readable = [ se, w, $stdin ]
+set_title = (ENV['DISPLAY'] || ENV['WAYLAND_DISPLAY']) ? $stdout : nil
 
 # current rg mode
 rg_mode = DTAS::RGState::RG_MODE.keys.unshift("off")
@@ -34,6 +37,7 @@ if (rg = cur["rg"]) && (rg = rg["mode"])
 else
   rg_mode_i = 0
 end
+show_info = false
 
 def update_tfmt(prec, tsec)
   if tsec
@@ -53,8 +57,6 @@ tfmt = update_tfmt(prec_step[prec_nr], tsec)
 events = []
 interval = 1.0 / 10 ** prec_nr
 
-pause = nil
-
 def show_events(lineno, screen, events)
   Curses.setpos(lineno += 1, 0)
   Curses.clrtoeol
@@ -114,14 +116,14 @@ def rg_string(rg, current)
   rv
 end
 
-def may_fail(res, events)
+def may_fail(c, req, events)
+  res = c.req(req)
   events << res if res != "OK"
 end
 
 pre_mute_vol = 1.0
 enc_locale = Encoding.find("locale")
 $stdout.set_encoding(enc_locale)
-enc_opts = { undef: :replace, invalid: :replace, replace: '?' }
 begin
   Curses.init_screen
   Curses.nonl
@@ -135,7 +137,6 @@ begin
     pfmt = cur['format']
     elapsed = samples = 0
     fmt = total = ''
-    paused = false
     if current = cur['current']
       infile = current['infile'] || current['command']
       elapsed = DTAS.now - current['spawn_at']
@@ -150,7 +151,6 @@ begin
       end
     elsif cur['paused'] && infile = cur['current_paused']
       fmt = "[paused] (#{fmt_to_s(pfmt)})"
-      paused = true
       infile = infile['command'] if Hash === infile
       if Array === infile
         infile, elapsed = infile
@@ -169,7 +169,12 @@ begin
       # FS encoding != locale encoding, but we need to display an FS path
       # name to whatever locale the terminal is encoded to, so force it
       # and risk mojibake...
-      infile.encode(enc_locale, enc_opts)
+      infile.encode(enc_locale,
+                    undef: :replace, invalid: :replace, replace: '?')
+      if set_title
+        dir, base = File.split(infile)
+        set_title.syswrite("\033]0;#{base} dtas-console\07")
+      end
       Curses.setpos(lineno += 1, 0)
       Curses.clrtoeol
       Curses.addstr(infile)
@@ -206,6 +211,27 @@ begin
     Curses.addstr(extra.join(' '))
     pre_mute_vol = cur_vol if cur_vol != 0
 
+    if show_info && current && comments = current['comments']
+      Curses.setpos(lineno += 1, 0)
+      Curses.clrtoeol
+      Curses.addstr('comments:')
+      comments.each do |k,v|
+        v = v.split(/\n+/)
+        k = k.dump if /[[:cntrl:]]/ =~ k
+        if first = v.shift
+          Curses.setpos(lineno += 1, 0)
+          Curses.clrtoeol
+          first = first.dump if /[[:cntrl:]]/ =~ first
+          Curses.addstr("  #{k}: #{first}")
+          v.each do |val|
+            val = val.dump if /[[:cntrl:]]/ =~ val
+            Curses.setpos(lineno += 1, 0)
+            Curses.clrtoeol
+            Curses.addstr("   #{val}")
+          end
+        end
+      end
+    end
     show_events(lineno, screen, events)
 
     Curses.refresh # draw and wait
@@ -220,29 +246,28 @@ begin
         case event
         when "pause"
           if current
-            pause = current['infile'] || current['command']
+            current['infile'] || current['command']
           end
         when %r{\Afile }
-          pause = nil
         end
         events << "#{Time.now.strftime(tfmt)} #{event}"
         # something happened, refresh current
         # we could be more intelligent here, maybe, but too much work.
-        cur = YAML.load(c.req('current'))
+        cur = DTAS.yaml_load(c.req('current'))
       when $stdin
         # keybindings taken from mplayer / vi
         case key = Curses.getch
-        when "j" then c.req_ok("seek -5")
-        when "k" then c.req_ok("seek +5")
+        when "j" then may_fail(c, "seek -5", events)
+        when "k" then may_fail(c, "seek +5", events)
         when "q" then exit(0)
-        when Curses::KEY_DOWN then c.req_ok("seek -60")
-        when Curses::KEY_UP then c.req_ok("seek +60")
-        when Curses::KEY_LEFT then c.req_ok("seek -10")
-        when Curses::KEY_RIGHT then c.req_ok("seek +10")
-        when Curses::KEY_BACKSPACE then c.req_ok("seek 0")
+        when Curses::KEY_DOWN then may_fail(c, "seek -60", events)
+        when Curses::KEY_UP then may_fail(c, "seek +60", events)
+        when Curses::KEY_LEFT then may_fail(c, "seek -10", events)
+        when Curses::KEY_RIGHT then may_fail(c, "seek +10", events)
+        when Curses::KEY_BACKSPACE then may_fail(c, "seek 0", events)
         # yes, some of us have long audio files
-        when Curses::KEY_PPAGE then c.req_ok("seek +600")
-        when Curses::KEY_NPAGE then c.req_ok("seek -600")
+        when Curses::KEY_PPAGE then may_fail(c, "seek +600", events)
+        when Curses::KEY_NPAGE then may_fail(c, "seek -600", events)
         when '9' then c.req_ok('rg volume-=0.01')
         when '0' then c.req_ok('rg volume+=0.01')
         when '=' then c.req_ok('rg volume=1')
@@ -253,8 +278,8 @@ begin
         when "f" then c.req_ok("rg fallback_gain-=1")
         when ">" then c.req_ok("tl next")
         when "<" then c.req_ok("tl prev")
-        when "!" then may_fail(c.req("cue prev"), events)
-        when "@" then may_fail(c.req("cue next"), events)
+        when "!" then may_fail(c, "cue prev", events)
+        when "@" then may_fail(c, "cue next", events)
         when "o" then tfmt = update_tfmt(prec_step[prec_nr], tsec = !tsec)
         when " "
           c.req("play_pause")
@@ -276,6 +301,9 @@ begin
             interval = 1.0 / 10 ** prec_nr
           end
         when 27 # TODO readline/edit mode?
+        when 'i'
+          show_info = !show_info
+          Curses.clear if !show_info
         else
           Curses.setpos(screen.maxy - 1, 0)
           Curses.clrtoeol
diff --git a/bin/dtas-ctl b/bin/dtas-ctl
index 171576f..2d55e16 100755
--- a/bin/dtas-ctl
+++ b/bin/dtas-ctl
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 require 'dtas/unix_client'
diff --git a/bin/dtas-cueedit b/bin/dtas-cueedit
index 127b1a0..e176271 100755
--- a/bin/dtas-cueedit
+++ b/bin/dtas-cueedit
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 require 'tempfile'
diff --git a/bin/dtas-enq b/bin/dtas-enq
index f054687..f49d3f7 100755
--- a/bin/dtas-enq
+++ b/bin/dtas-enq
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 require 'dtas/unix_client'
diff --git a/bin/dtas-mlib b/bin/dtas-mlib
index 45c3d01..7a07794 100755
--- a/bin/dtas-mlib
+++ b/bin/dtas-mlib
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2015-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2015-2020 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 [-d DATABASE-URI] ACTION [ARGS]"
diff --git a/bin/dtas-msinkctl b/bin/dtas-msinkctl
index 9abf2dc..79c7f26 100755
--- a/bin/dtas-msinkctl
+++ b/bin/dtas-msinkctl
@@ -1,8 +1,7 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2013-2016 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 'yaml'
 require 'dtas/unix_client'
 usage = "#$0 <active-set|active-add|active-sub|nonblock|active> SINK"
 c = DTAS::UNIXClient.new
@@ -29,7 +28,7 @@ def filter(c, player_sinks, key)
   rv = []
   player_sinks.each do |name|
     buf = c.req("sink cat #{name}")
-    sink = YAML.load(buf)
+    sink = DTAS.yaml_load(buf)
     rv << sink["name"] if sink[key]
   end
   rv
diff --git a/bin/dtas-partstats b/bin/dtas-partstats
index 388f7ba..6a0c9d4 100755
--- a/bin/dtas-partstats
+++ b/bin/dtas-partstats
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2013-2016 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
 # TODO
@@ -8,17 +8,11 @@
 # - configurable output formatting
 # - Sequel/SQLite support
 require 'dtas/partstats'
+require 'etc'
 infile = ARGV[0] or abort "usage: #$0 INFILE"
 ps = DTAS::PartStats.new(infile)
 
-def nproc
-  require 'etc'
-  Etc.nprocessors
-rescue NoMethodError
-  `nproc 2>/dev/null || echo 2`.to_i
-end
-
-opts = { jobs: nproc }
+opts = { jobs: Etc.nprocessors }
 stats = ps.run(opts)
 
 headers = ps.key_idx.to_a
diff --git a/bin/dtas-player b/bin/dtas-player
index ccb3969..c926e5f 100755
--- a/bin/dtas-player
+++ b/bin/dtas-player
@@ -1,9 +1,8 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2013-2016 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
 Thread.abort_on_exception = $stderr.sync = $stdout.sync = true
-require 'yaml'
 require 'dtas/player'
 sock = (ENV["DTAS_PLAYER_SOCK"] || File.expand_path("~/.dtas/player.sock"))
 state = (ENV["DTAS_PLAYER_STATE"] ||
diff --git a/bin/dtas-readahead b/bin/dtas-readahead
index 93ab8c9..f2ab514 100755
--- a/bin/dtas-readahead
+++ b/bin/dtas-readahead
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2015-2019 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
 #
@@ -12,13 +12,11 @@ end
 @ffprobe = 'ffprobe'
 @avprobe = 'avprobe'
 
-require 'yaml'
 require 'io/wait'
 require 'dtas/unix_client'
 require 'dtas/process'
 
 include DTAS::Process
-include DTAS::SpawnFix
 trap(:CHLD) { DTAS::Process.reaper {} }
 trap(:INT) { exit(0) }
 trap(:TERM) { exit(0) }
@@ -30,18 +28,6 @@ null = DTAS.null
 @redir = { err: null, out: null, in: null }.freeze
 require 'pp'
 
-if RUBY_VERSION.to_r >= '2.3'.to_r
-  # Old Rubies did FIONREAD, which breaks on SOCK_SEQPACKET
-  def wait_read(w, timeout)
-    w.to_io.wait_readable(timeout)
-  end
-else
-  def wait_read(w, timeout)
-    r = IO.select([w], nil, nil, timeout)
-    r ? r[0] : nil
-  end
-end
-
 def seek_to_cur_pos(cur_pid, fp)
   cur_fd = []
   fpst = fp.stat
@@ -57,7 +43,7 @@ def seek_to_cur_pos(cur_pid, fp)
       end
     end
   rescue Errno::ENOENT => e # race, process is dead
-    return false
+    return nil
   rescue => e
     warn "error reading FDs from for PID:#{cur_pid}: #{e.message}"
   end
@@ -71,7 +57,7 @@ def seek_to_cur_pos(cur_pid, fp)
   end
   pos
 rescue Errno::ENOENT => e # race, process is dead
-  return false
+  return nil
 end
 
 def children_of(ppid)
@@ -122,7 +108,7 @@ def do_ra(fp, pos, w)
     len -= n
 
     # stop reading immediately if there's an event
-    if wait_read(w, 0)
+    if w.to_io.wait_readable(0)
       adj = @todo_ra
       pos += size
       break
@@ -141,8 +127,8 @@ def do_open(path)
       when "---\n"
         buf << fp.read(fp.size - 4)
         Dir.chdir(File.dirname(path)) do
-          yml = YAML.load(buf)
-          x = yml['infile'] and return File.open(File.expand_path(x).freeze)
+          yml = DTAS.yaml_load(buf)
+          x = yml['infile'] and return File.open(-File.expand_path(x))
         end
       end
     end
@@ -156,12 +142,12 @@ begin
   @todo_ra = @max_ra
   t0 = DTAS.now
   fp = nil
-  cur = YAML.load(c.req('current'))
+  cur = DTAS.yaml_load(c.req('current'))
   while @todo_ra > 0 && fp.nil?
     if current = cur['current']
       track = current['infile']
       break unless track.kind_of?(String)
-      track.freeze
+      track = -track
       fp = work[track] ||= do_open(track)
       cur_pid = current['pid']
       if fp
@@ -178,7 +164,7 @@ begin
     end
 
     # queue has priority, work on it, first
-    queue = YAML.load(c.req('queue cat'))
+    queue = DTAS.yaml_load(c.req('queue cat'))
     while @todo_ra > 0 && track = queue.shift
       next unless track.kind_of?(String)
       fp = nil
@@ -198,7 +184,7 @@ begin
     repeat = c.req('tl repeat').split[-1]
     while @todo_ra > 0 && idx && (cid = ids[idx])
       fp = nil
-      track = c.req("tl get #{cid}").sub!(/\A1 \d+=/, '').freeze
+      track = -(c.req("tl get #{cid}").sub!(/\A1 \d+=/, ''))
       begin
         fp = work[track] ||= do_open(track)
       rescue SystemCallError
@@ -210,7 +196,7 @@ begin
       end
     end
     idx or break
-    cur = YAML.load(c.req('current'))
+    cur = DTAS.yaml_load(c.req('current'))
     current = cur['current'] or break
   end
   if current
@@ -220,10 +206,10 @@ begin
     timeout = 0 if timeout < 0
   else
     work.each_value(&:close).clear
-    fp.close if fp && !fp.closed?
+    fp.close if fp
     fp = timeout = nil
   end
-  r = wait_read(w, timeout)
+  r = w.to_io.wait_readable(timeout)
   p w.res_wait if r
 rescue EOFError
   abort "dtas-player exited"
diff --git a/bin/dtas-sinkedit b/bin/dtas-sinkedit
index 61bb959..252270f 100755
--- a/bin/dtas-sinkedit
+++ b/bin/dtas-sinkedit
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2013-2016 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 'optparse'
@@ -36,10 +36,10 @@ st_in = $stdin.stat
 
 buf = c.req(%W(sink cat #{name}))
 abort(buf) if buf =~ /\AERR/
-orig = YAML.load(buf)
+orig = DTAS.yaml_load(buf)
 
 commit_update = lambda do |buf|
-  sink = YAML.load(buf)
+  sink = DTAS.yaml_load(buf)
   cmd = %W(sink ed #{name})
   update_cmd_env(cmd, orig, sink)
 
@@ -68,7 +68,6 @@ if st_in.file? || st_in.pipe?
   buf = $stdin.read
   commit_update.call(buf)
 else
-  include DTAS::SpawnFix
   tmp = tmpyaml
   tmp_path = tmp.path
   do_update = lambda { commit_update.call(File.read(tmp_path)) }
diff --git a/bin/dtas-sourceedit b/bin/dtas-sourceedit
index 713c466..1b3f4ee 100755
--- a/bin/dtas-sourceedit
+++ b/bin/dtas-sourceedit
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2013-2016 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 'optparse'
@@ -36,10 +36,10 @@ st_in = $stdin.stat
 
 buf = c.req(%W(source cat #{name}))
 abort(buf) if buf =~ /\AERR/
-orig = YAML.load(buf)
+orig = DTAS.yaml_load(buf)
 
 commit_update = lambda do |buf|
-  source = YAML.load(buf)
+  source = DTAS.yaml_load(buf)
   cmd = %W(source ed #{name})
   update_cmd_env(cmd, orig, source)
 
@@ -55,7 +55,6 @@ if st_in.file? || st_in.pipe?
   buf = $stdin.read
   commit_update.call(buf)
 else
-  include DTAS::SpawnFix
   tmp = tmpyaml
   tmp_path = tmp.path
   do_update = lambda { commit_update.call(File.read(tmp_path)) }
diff --git a/bin/dtas-splitfx b/bin/dtas-splitfx
index 839d273..17d915d 100755
--- a/bin/dtas-splitfx
+++ b/bin/dtas-splitfx
@@ -1,19 +1,20 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2013-2016 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 'yaml'
 require 'optparse'
 require 'dtas/splitfx'
 usage = "#$0 [-n|--dry-run][-j [JOBS]][-s|--silent] SPLITFX_FILE.yml [TARGET]"
 overrides = {} # FIXME: not tested
 default_target = "flac"
-opts = { jobs: 1 }
+opts = { jobs: nil }
 OptionParser.new('', 24, '  ') do |op|
   op.banner = usage
   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 }
   op.on('-C', '--compression FACTOR') { |val| opts[:compression] = val }
@@ -23,6 +24,9 @@ OptionParser.new('', 24, '  ') do |op|
   end
   op.on('-b', '--bits RATE', Integer) { |val| opts[:bits] = val }
   op.on('-t', '--trim POSITION') { |val| opts[:trim] = val.tr(',', ' ') }
+  op.on('-E', '--err-suffix SUFFIX') do |val|
+    opts[:err_suffix] = val.start_with?('.') ? val.freeze : ".#{val}"
+  end
   op.on('-p', '--sox-pipe') do
     opts[:sox_pipe] = true
     default_target = 'sox'
@@ -30,13 +34,22 @@ OptionParser.new('', 24, '  ') do |op|
   op.parse!(ARGV)
 end
 
+if opts[:sox_pipe] && opts[:err_suffix]
+  abort '--err-suffix and --sox-pipe are mutually exclusive'
+end
+
+if opts[:jobs].nil?
+  require 'etc'
+  opts[:jobs] = Etc.nprocessors
+end
+
 args = []
 ARGV.each do |arg|
   case arg
   when %r{\A(\w+)=(.*)\z}
     key, val = $1, $2
     # only one that makes sense is infile=another_file
-    overrides[key] = YAML.load(val)
+    overrides[key] = DTAS.yaml_load(val)
   when %r{\A(\w+)\.(\w+)=(.*)\z}
     # comments.ARTIST='blah'
     top, key, val = $1, $2, $3
@@ -51,5 +64,5 @@ trap(:INT) { exit 130 }
 file = args.shift or abort usage
 target = args.shift || default_target
 splitfx = DTAS::SplitFX.new
-splitfx.import(YAML.load(File.read(file)), overrides)
+splitfx.import(DTAS.yaml_load(File.read(file)), overrides)
 splitfx.run(target, opts)
diff --git a/bin/dtas-tl b/bin/dtas-tl
index 1ce18de..c7f4c83 100755
--- a/bin/dtas-tl
+++ b/bin/dtas-tl
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2013-2016 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
 # encoding: binary
@@ -7,6 +7,8 @@
 # itself is also unstable, but better than this one probably).
 require 'dtas/unix_client'
 require 'shellwords'
+$stdout.binmode
+$stderr.binmode
 
 def get_track_ids(c)
   track_ids = c.req("tl tracks")
@@ -16,14 +18,18 @@ def get_track_ids(c)
   track_ids
 end
 
-def fix_enc!(str, enc)
-  str.force_encoding(enc)
-  str.force_encoding(Encoding::ASCII_8BIT) unless str.valid_encoding?
+def each_track(c)
+  get_track_ids(c).each_slice(128) do |track_ids|
+    res = c.req("tl get #{track_ids.join(' ')}")
+    res = Shellwords.split(res.sub!(/\A\d+ /, ''))
+    while line = res.shift
+      yield line
+    end
+  end
 end
 
 def do_edit(c)
   require 'dtas/edit_client'
-  require 'yaml'
   require 'tempfile'
   extend DTAS::EditClient
   tmp = Tempfile.new(%w(dtas-tl-edit .txt))
@@ -31,19 +37,14 @@ def do_edit(c)
   tmp_path = tmp.path
   orig = []
   orig_idx = {}
-  enc = Encoding.default_external
 
-  get_track_ids(c).each_slice(128) do |track_ids|
-    res = c.req("tl get #{track_ids.join(' ')}")
-    res = Shellwords.split(res.sub!(/\A\d+ /, ''))
-    while line = res.shift
-      line.sub!(/\A(\d+)=/n, '') or abort "unexpected line=#{line.inspect}\n"
-      fix_enc!(line, enc)
-      track_id = $1.to_i
-      orig_idx[track_id] = orig.size
-      orig << track_id
-      tmp.write("#{Shellwords.escape(line)} =#{track_id}\n")
-    end
+  each_track(c) do |line|
+    line.sub!(/\A(\d+)=/n, '') or abort "unexpected line=#{line.inspect}\n"
+    track_id = $1.to_i
+    orig_idx[track_id] = orig.size
+    orig << track_id
+    line = Shellwords.escape(line) if line.include?("\n")
+    tmp.write("#{line} =#{track_id}\n")
   end
   tmp.flush
 
@@ -51,7 +52,7 @@ def do_edit(c)
   # jump to the line of the currently playing track if using vi or vim
   # Patches for other editors welcome: dtas-all@nongnu.org
   if ed =~ /vim?\z/
-    cur = YAML.load(c.req('current'))
+    cur = DTAS.yaml_load(c.req('current'))
     if tl = cur['tracklist']
       if pos = tl['pos']
         ed += " +#{pos + 1}"
@@ -100,14 +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
-      fix_enc!(path, enc)
-      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})
@@ -140,15 +135,35 @@ end
 
 c = DTAS::UNIXClient.new
 case cmd = ARGV[0]
-when "cat"
-  enc = Encoding.default_external
-  get_track_ids(c).each_slice(128) do |track_ids|
-    res = c.req("tl get #{track_ids.join(' ')}")
-    res = Shellwords.split(res.sub!(/\A\d+ /, ''))
-    while line = res.shift
-      fix_enc!(line, enc)
-      print "#{line}\n"
+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
+    ok = false
+    begin
+      st = File.stat(line)
+      ok = st.readable? && st.size?
+    rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES => e
+      warn "# #{line}: #{e.class}"
+      # raise other exceptions
     end
+    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
@@ -173,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/bin/dtas-xdelay b/bin/dtas-xdelay
index 138f521..060752c 100755
--- a/bin/dtas-xdelay
+++ b/bin/dtas-xdelay
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 USAGE = "Usage: #$0 [-x FREQ] [-l] /dev/fd/LO /dev/fd/HI DELAY [DELAY ...]"
diff --git a/dtas-linux.gemspec b/dtas-linux.gemspec
index f792978..a51b4d2 100644
--- a/dtas-linux.gemspec
+++ b/dtas-linux.gemspec
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2019 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 #
 # this just declares dependencies to make gem installation a little easier
@@ -12,7 +12,7 @@ Gem::Specification.new do |s|
                   "via tee(), splice() and eventfd() on Linux"
   s.email = %q{e@80x24.org}
   s.files = []
-  s.homepage = 'https://80x24.org/dtas/'
+  s.homepage = 'https://80x24.org/dtas.git/about/'
   s.add_dependency(%q<dtas>, '~> 0.16')
   s.add_dependency(%q<sleepy_penguin>, '~> 3.5')
   s.licenses = 'GPL-3.0+'
diff --git a/dtas-mpris.gemspec b/dtas-mpris.gemspec
index 3603529..c94271e 100644
--- a/dtas-mpris.gemspec
+++ b/dtas-mpris.gemspec
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-2.0+ or later <https://www.gnu.org/licenses/gpl-2.0.txt>
 # This is GPL-2.0+ instead of GPL-3.0+ because ruby-dbus is LGPL-2.1 (only)
 Gem::Specification.new do |s|
@@ -11,7 +11,7 @@ Gem::Specification.new do |s|
     "This is currently a dummy package as dtas-mpris is not implemented"
   s.email = %q{e@80x24.org}
   s.files = []
-  s.homepage = 'https://80x24.org/dtas/'
+  s.homepage = 'https://80x24.org/dtas.git/about/'
   s.add_dependency(%q<dtas>)
   s.add_dependency(%q<ruby-dbus>)
   s.licenses = 'GPL-2.0+'
diff --git a/dtas.gemspec b/dtas.gemspec
index ec18254..9bc1cc5 100644
--- a/dtas.gemspec
+++ b/dtas.gemspec
@@ -1,16 +1,16 @@
-# Copyright (C) 2013-2016 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>
 Gem::Specification.new do |s|
   manifest = File.read('.gem-manifest').split(/\n/)
   s.name = %q{dtas}
-  s.version = ENV["VERSION"].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
   s.email = %q{e@80x24.org}
   s.executables = manifest.grep(%r{\Abin/}).map { |s| s.sub(%r{\Abin/}, "") }
   s.files = manifest
-  s.homepage = 'https://80x24.org/dtas/'
+  s.homepage = 'https://80x24.org/dtas.git/about/'
   s.licenses = "GPL-3.0+"
-  s.required_ruby_version = '>= 1.9.3'
+  s.required_ruby_version = '>= 2.3'
 end
diff --git a/dtas.sh b/dtas.sh
new file mode 100755
index 0000000..1ceca15
--- /dev/null
+++ b/dtas.sh
@@ -0,0 +1,8 @@
+#!/bin/sh -e
+# symlink this file to a directory in PATH to run dtas (or anything in bin/*)
+# without needing perms to install globally.  Used by "make symlink-install"
+p=$(realpath "$0" || readlink "$0"); # neither is POSIX, but common
+p=$(dirname "$p") c=$(basename "$0"); c="${c%.sh}"
+if test -x "$p/bin/$c"; then exec ${RUBY-ruby} -I"$p"/lib "$p/bin/$c" "$@";
+else exec ${PERL-perl} -I"$p"/lib "$p/script/$c" "$@"; fi
+: this script is too short to copyright
diff --git a/examples/splitfx.sample.yml b/examples/splitfx.sample.yml
index 9c29df2..979ba0d 100644
--- a/examples/splitfx.sample.yml
+++ b/examples/splitfx.sample.yml
@@ -11,7 +11,7 @@ comments:
 # the sox command for dtas-player playback, there is no need to
 # specify this as it is the default:
 # command: exec sox "$INFILE" $SOXFMT - $TRIMFX $RGFX $FX
-env: !omap
+env:
   PATH: $PATH
   # these effects may be used in any command in this file, including targets
   SOX_OPTS: $SOX_OPTS -R
diff --git a/examples/tfx.sample.yml b/examples/tfx.sample.yml
index b8add0b..144129f 100644
--- a/examples/tfx.sample.yml
+++ b/examples/tfx.sample.yml
@@ -4,7 +4,7 @@
 # test_trimfx.rb relies on this.
 ---
 infile: foo.flac
-env: !omap
+env:
   PATH: $PATH
   SOX_OPTS: $SOX_OPTS -R
   I2: second.flac
diff --git a/examples/zsh-completion/README b/examples/zsh-completion/README
new file mode 100644
index 0000000..89b3afd
--- /dev/null
+++ b/examples/zsh-completion/README
@@ -0,0 +1,3 @@
+To use the completion functions defined in this directory either: add
+the completions you wish to use to a directory in your $fpath, or add
+this directory's path to $fpath *prior* to calling compinit.
diff --git a/examples/zsh-completion/_dtas-archive b/examples/zsh-completion/_dtas-archive
new file mode 100644
index 0000000..2bfdf7e
--- /dev/null
+++ b/examples/zsh-completion/_dtas-archive
@@ -0,0 +1,16 @@
+#compdef dtas-archive
+
+# To the extent possible under law, James Rowe has waived all copyright and
+# related or neighboring rights to this example.
+
+_arguments -S \
+    "--type=[file type]" \
+    "--compression=[compression factor for sox]:select compression:({0..8})" \
+    "--jobs=[number of jobs]: :_guard '[0-9]#' value" \
+    "--stats[save stats on the file]" \
+    "--keep-going[continue after error]" \
+    "--dry-run[only print commands, do not run them]" \
+    "--repeat=[number of times to check]: :_guard '[0-9]#' value" \
+    "--help[display help message]" \
+    ":select source:_files" \
+    ":select destination:_files"
diff --git a/examples/zsh-completion/_dtas-ctl b/examples/zsh-completion/_dtas-ctl
new file mode 100644
index 0000000..d82533b
--- /dev/null
+++ b/examples/zsh-completion/_dtas-ctl
@@ -0,0 +1,114 @@
+#compdef dtas-ctl
+
+# To the extent possible under law, James Rowe has waived all copyright and
+# related or neighboring rights to this example.
+
+_arguments \
+    ':dtas-ctl command:((
+        cd\:"change the current working directory of the player"
+        clear\:"clear current queue"
+        cue\:"display the index/offsets of the embedded CUE sheet"
+        current\:"output information about the current track/command in YAML"
+        enq\:"enqueue the given FILENAME for playback"
+        enq-cmd\:"run the following command for playback"
+        env\:"set/unset environment variables"
+        format\:"configure the format between source and sink"
+        pause\:"pause playback"
+        play\:"restart playback from pause"
+        play_pause\:"toggle the play/pause state"
+        queue\ cat\:"dump the contents of the queue as YAML"
+        restart\:"restarts all processes in the current pipeline"
+        rg\:"configure ReplayGain support"
+        seek\:"seek the current track to a specified time"
+        skip\:"abort current track/command"
+        sink\:"control sinks"
+        source\:"control sources"
+        state\ dump\:"immediately dump the state of the player"
+        tl\:"control tracklist"
+        trim\:"limits playback of all tracks in the tracklist"
+        watch\:"adds the client to the passive watch list for notifications"
+    ))' \
+    "*::subcmd:->subcmd" && return 0
+
+case "$words[1]" in
+(cd)
+    _arguments \
+        ":select dir:_path_files -/"
+    ;;
+(cue)
+    _arguments \
+        ':dtas-ctl command:((
+            next\:"skip to the next cue sheet offset"
+            prev\:"skip to the previous cue sheet offset"
+            goto\:"go to the cue index"
+            seek\:"seek within the current cue index"
+        ))'
+    ;;
+(enq)
+    _arguments \
+        "*:select file:_files"
+    ;;
+(format)
+    _arguments \
+        '*:dtas-ctl format command:((
+            channels\:"number of channels to use internally"
+            endian\:"change endianess"
+            bits\:"sample precision"
+            rate\:"sample rate of audio"
+            type\:"change the raw PCM format"
+        ))'
+    ;;
+(seek)
+    _arguments \
+        ":select track:_guard '[0-9]#' 'track number'"
+    ;;
+(sink)
+    _arguments \
+        ':sink subcommand:((
+            ls\:"list names of current sinks"
+            cat\:"dump SINKNAME config in YAML"
+            rm\:"remove SINKNAME"
+            ed\:"create/edit SINKNAME"
+        ))'
+    ;;
+(source)
+    _arguments \
+        ':source subcommand:((
+            cat\:"dump the current source command and env in YAML"
+            ed\:"edit the source parameters"
+            ls\:"dump the names of sources sorted by tryorder"
+            restart\:"restart the current source command"
+        ))'
+    ;;
+(state dump)
+    _arguments \
+        ":select file:_files"
+    ;;
+(tl)
+    _arguments \
+        ':tl subcommand:((
+            add\:"add files to the tracklist"
+            clear\:"clear current tracklist"
+            consume\:"show/or change consume status of the tracklist"
+            current\:"display the pathname to the currently playing track"
+            current-id\:"display the TRACKID of the currently playing track"
+            remove\:"remove the track with the given TRACKID from the track list"
+            get\:"returns a list of TRACKIDS mapped to shell-escaped filenames"
+            goto\:"plays the given TRACKID"
+            max\:"sets or gets the maximum number of tracks allowed in the tracklist"
+            next\:"jump to the next track in the tracklist"
+            prev\:"jump to the previous track in the tracklist"
+            repeat\:"show/or change repeat status of the tracklist"
+            shuffle\:"show/or change the current shuffle status of the tracklist"
+            swap\:"swaps the positions of two tracks"
+            tracks\:"returns a list of all TRACKIDS in the tracklist"
+        ))'
+    ;;
+(trim)
+    _arguments \
+        ":select beginning" \
+        ":select end"
+    ;;
+(*)
+    ;;
+esac
diff --git a/examples/zsh-completion/_dtas-cueedit b/examples/zsh-completion/_dtas-cueedit
new file mode 100644
index 0000000..ac87822
--- /dev/null
+++ b/examples/zsh-completion/_dtas-cueedit
@@ -0,0 +1,7 @@
+#compdef dtas-cueedit
+
+# To the extent possible under law, James Rowe has waived all copyright and
+# related or neighboring rights to this example.
+
+_arguments \
+    ':select file:_files -g "*.flac"'
diff --git a/examples/zsh-completion/_dtas-enq b/examples/zsh-completion/_dtas-enq
new file mode 100644
index 0000000..a8f9c3f
--- /dev/null
+++ b/examples/zsh-completion/_dtas-enq
@@ -0,0 +1,7 @@
+#compdef dtas-enq
+
+# To the extent possible under law, James Rowe has waived all copyright and
+# related or neighboring rights to this example.
+
+_arguments \
+    "*:select file:_files"
diff --git a/examples/zsh-completion/_dtas-mlib b/examples/zsh-completion/_dtas-mlib
new file mode 100644
index 0000000..24418ea
--- /dev/null
+++ b/examples/zsh-completion/_dtas-mlib
@@ -0,0 +1,29 @@
+#compdef dtas-mlib
+
+# To the extent possible under law, James Rowe has waived all copyright and
+# related or neighboring rights to this example.
+#
+_arguments \
+    "--database=[database]:select file:_files" \
+    "--force[force updates]" \
+    "--help[display help message]" \
+    ':dtas-mlib action:((
+            dump\:"dump database"
+            search\:"search database"
+            stats\:"display statistics"
+            update\:"migrate database"
+        ))' \
+    "*::subcmd:->subcmd" && return 0
+
+case "$words[1]" in
+(dump|update)
+    _arguments -S \
+        ":select directory:_path_files -/"
+    ;;
+(search)
+    _arguments -S \
+        "*:search term:"
+    ;;
+(*)
+    ;;
+esac
diff --git a/examples/zsh-completion/_dtas-msinkctl b/examples/zsh-completion/_dtas-msinkctl
new file mode 100644
index 0000000..941c23b
--- /dev/null
+++ b/examples/zsh-completion/_dtas-msinkctl
@@ -0,0 +1,8 @@
+#compdef dtas-msinkctl
+
+# To the extent possible under law, James Rowe has waived all copyright and
+# related or neighboring rights to this example.
+
+_arguments \
+    "1:select action:(active{,-{set,add,sub}} nonblock)" \
+    "*:select sink:($(dtas-ctl sink ls 2> /dev/null))"
diff --git a/examples/zsh-completion/_dtas-partstats b/examples/zsh-completion/_dtas-partstats
new file mode 100644
index 0000000..9460684
--- /dev/null
+++ b/examples/zsh-completion/_dtas-partstats
@@ -0,0 +1,7 @@
+#compdef dtas-partstats
+
+# To the extent possible under law, James Rowe has waived all copyright and
+# related or neighboring rights to this example.
+
+_arguments \
+    ":select file:_files"
diff --git a/examples/zsh-completion/_dtas-sinkedit b/examples/zsh-completion/_dtas-sinkedit
new file mode 100644
index 0000000..be6de53
--- /dev/null
+++ b/examples/zsh-completion/_dtas-sinkedit
@@ -0,0 +1,11 @@
+#compdef dtas-sinkedit
+
+# To the extent possible under law, James Rowe has waived all copyright and
+# related or neighboring rights to this example.
+
+_arguments \
+    "--no-watch[disable inotify support]" \
+    "--dry-run[only print commands, do not run them]" \
+    "--verbose[print out commands sent to change the sink]" \
+    "--help[display help message]" \
+    ":select sink:($(dtas-ctl sink ls 2> /dev/null))"
diff --git a/examples/zsh-completion/_dtas-sourceedit b/examples/zsh-completion/_dtas-sourceedit
new file mode 100644
index 0000000..56da661
--- /dev/null
+++ b/examples/zsh-completion/_dtas-sourceedit
@@ -0,0 +1,11 @@
+#compdef dtas-sourceedit
+
+# To the extent possible under law, James Rowe has waived all copyright and
+# related or neighboring rights to this example.
+
+_arguments \
+    "--no-watch[disable inotify support]" \
+    "--dry-run[only print commands, do not run them]" \
+    "--verbose[print out commands sent to change the source]" \
+    "--help[display help message]" \
+    ":select source:($(dtas-ctl source ls 2> /dev/null))"
diff --git a/examples/zsh-completion/_dtas-splitfx b/examples/zsh-completion/_dtas-splitfx
new file mode 100644
index 0000000..dfebb64
--- /dev/null
+++ b/examples/zsh-completion/_dtas-splitfx
@@ -0,0 +1,17 @@
+#compdef dtas-splitfx
+
+# To the extent possible under law, James Rowe has waived all copyright and
+# related or neighboring rights to this example.
+
+_arguments -S \
+    "--dry-run[only print commands, do not run them]" \
+    "--jobs=[number of jobs]: :_guard '[0-9]#' value" \
+    "--no-dither[don't apply sox dithering]" \
+    "--outdir=[select output directory]:select directory:_path_files -/" \
+    "--compression=[compression factor for sox]:select compression:({0..8})" \
+    "--rate=[sample rate of audio]:select sample rate:(22050 44100 48000)" \
+    "--bits=[sample precision]:select precision:(8 16 24)" \
+    "--trim=[sections of audio to cut]:select sections:_guard '[0-9,]#' 'value'" \
+    "--sox-pipe[use as pipeline]" \
+    '1:select splitfx file:_files -g "*.yml"' \
+    "*:select file:_files"
diff --git a/examples/zsh-completion/_dtas-tl b/examples/zsh-completion/_dtas-tl
new file mode 100644
index 0000000..8e2b098
--- /dev/null
+++ b/examples/zsh-completion/_dtas-tl
@@ -0,0 +1,52 @@
+#compdef dtas-tl
+
+# To the extent possible under law, James Rowe has waived all copyright and
+# related or neighboring rights to this example.
+
+_arguments \
+    ':dtas-tl command:((
+       aac\:"add tracks after current track in the tracklist"
+       addhead\:"add tracks to the beginning of the tracklist"
+       addtail\:"add tracks to the end of the tracklist"
+       consume\:"enabling \"consume\" mode"
+       current\:"display the current track"
+       current-id\:"display the track of the current track"
+       cat\:"display a tracklist"
+       clear\:"remove all tracks from the tracklist"
+       edit\:"spawn an editor to allow editing the tracklist"
+       goto\:"play track immediately"
+       reto\:"play track matching regular expression"
+       next\:"play the next track in the tracklist"
+       prev\:"play the previous track in the tracklist"
+       repeat\:"control track repeating"
+       shuffle\:"control playback randomization"
+    ))' \
+    "*::subcmd:->subcmd" && return 0
+
+case "$words[1]" in
+(aac|addtail)
+    _arguments \
+        ":select file:_files"
+    ;;
+(addhead)
+    _arguments \
+        "*:select file:_files"
+    ;;
+(consume|repeat|shuffle)
+    _arguments \
+        ":select state:(true false)"
+    ;;
+(goto)
+    _arguments \
+        ":select track:($(dtas-ctl tl tracks 2> /dev/null))"
+    ;;
+(reto)
+    _arguments \
+        "-F[use fixed strings]" \
+        "-i[ignore case]" \
+        ":search term" \
+        ":select beginning"
+    ;;
+(*)
+    ;;
+esac
diff --git a/examples/zsh-completion/_dtas-xdelay b/examples/zsh-completion/_dtas-xdelay
new file mode 100644
index 0000000..356537d
--- /dev/null
+++ b/examples/zsh-completion/_dtas-xdelay
@@ -0,0 +1,17 @@
+#compdef dtas-xdelay
+
+# To the extent possible under law, James Rowe has waived all copyright and
+# related or neighboring rights to this example.
+
+_arguments -S \
+    "--crossover-frequency=[frequency at which to set the crossover]: :_guard '[0-9]#' frequency" \
+    "--lowpass-delay[delay the lowpass frequency instead of the highpass one]" \
+    "--channels=[number of channels]:select channels:(1 2)" \
+    "--rate=[sample rate of audio]:select sample rate:(22050 44100 48000)" \
+    "--type=[file type]:select output type:($(sox --help 2> /dev/null | sed -n '/AUDIO FILE FORMATS/s/.*: //p'))" \
+    "--dry-run[only print commands, do not run them]" \
+    "--lowpass=[Custom format string for lowpass filter]" \
+    "--highpass=[Custom format string for highpass filter]" \
+    ":select input1:_files" \
+    ":select input2:_files" \
+    ":select delay:_guard '[0-9]#' delay"
diff --git a/lib/dtas.rb b/lib/dtas.rb
index 9c1b5a5..cb7c33d 100644
--- a/lib/dtas.rb
+++ b/lib/dtas.rb
@@ -1,23 +1,16 @@
-# Copyright (C) 2013-2019 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
 
 # DTAS currently exposes no public API for Ruby programmers.
-# See https://80x24.org/dtas/ for more info.
+# See https://80x24.org/dtas.git/about/ for more info.
 module DTAS
 
   # try to use the monotonic clock in Ruby >= 2.1, it is immune to clock
   # offset adjustments and generates less garbage (Float vs Time object)
   # :stopdoc:
-  begin
+  def self.now # :nodoc:
     ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
-    def self.now # :nodoc:
-      ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
-    end
-  rescue NameError, NoMethodError
-    def self.now # :nodoc:
-      Time.now.to_f # Ruby <= 2.0
-    end
   end
 
   @null = nil
@@ -25,22 +18,21 @@ module DTAS
     @null ||= File.open('/dev/null', 'r+')
   end
 
-  # String#-@ will deduplicate strings when Ruby 2.5 is released (Dec 2017)
-  # https://bugs.ruby-lang.org/issues/13077
-  if RUBY_VERSION.to_f >= 2.5
-    def self.dedupe_str(str)
-      -str
-    end
-  else
-    # Ruby 2.1 - 2.4, noop for older Rubies
-    def self.dedupe_str(str)
-      eval "#{str.inspect}.freeze"
+  @libc = nil
+  def self.libc
+    @libc ||= begin
+      require 'fiddle'
+      Fiddle.dlopen(nil)
     end
   end
+
+  # prevent breakage in Psych 4.x; we're a shell and designed to execute code
+  def self.yaml_load(buf)
+    require 'yaml'
+    YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(buf) : YAML.load(buf)
+  end
   # :startdoc:
 end
 
-require_relative 'dtas/compat_onenine'
-require_relative 'dtas/spawn_fix'
 require_relative 'dtas/encoding'
 DTAS.extend(DTAS::Encoding)
diff --git a/lib/dtas/buffer.rb b/lib/dtas/buffer.rb
index 39070d7..0688af9 100644
--- a/lib/dtas/buffer.rb
+++ b/lib/dtas/buffer.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2019 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 'io/wait'
@@ -8,12 +8,15 @@ require_relative '../dtas'
 class DTAS::Buffer # :nodoc:
   begin
     raise LoadError, "no splice with _DTAS_POSIX" if ENV["_DTAS_POSIX"]
-    require 'sleepy_penguin' # splice is only in Linux for now...
-    SleepyPenguin.respond_to?(:splice) or
-      raise LoadError, 'sleepy_penguin 3.5+ required for splice', []
-    require_relative 'buffer/splice'
-    include DTAS::Buffer::Splice
-  rescue LoadError
+    # splice is only in Linux for now
+    begin
+      require_relative 'buffer/splice'
+      include DTAS::Buffer::Splice
+    rescue LoadError
+      require_relative 'buffer/fiddle_splice'
+      include DTAS::Buffer::FiddleSplice
+    end
+  rescue LoadError, StandardError
     require_relative 'buffer/read_write'
     include DTAS::Buffer::ReadWrite
   end
@@ -42,7 +45,7 @@ class DTAS::Buffer # :nodoc:
 
   def __dst_error(dst, e)
     warn "dropping #{dst.inspect} due to error: #{e.message} (#{e.class})"
-    dst.close unless dst.closed?
+    dst.close
   end
 
   # This will modify targets
diff --git a/lib/dtas/buffer/fiddle_splice.rb b/lib/dtas/buffer/fiddle_splice.rb
new file mode 100644
index 0000000..d9232cd
--- /dev/null
+++ b/lib/dtas/buffer/fiddle_splice.rb
@@ -0,0 +1,217 @@
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
+# License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
+# frozen_string_literal: true
+require 'io/nonblock'
+require 'fiddle' # require_relative caller should expect LoadError
+require_relative '../../dtas'
+require_relative '../pipe'
+
+# Used by -player on Linux systems with the "splice" syscall
+module DTAS::Buffer::FiddleSplice # :nodoc:
+  MAX_AT_ONCE = 4096 # page size in Linux
+  MAX_AT_ONCE_1 = 65536
+  F_MOVE = 1
+  F_NONBLOCK = 2
+
+  Splice = Fiddle::Function.new(DTAS.libc['splice'], [
+      Fiddle::TYPE_INT, # int fd_in,
+      Fiddle::TYPE_VOIDP, # loff_t *off_in
+      Fiddle::TYPE_INT, # int fd_out
+      Fiddle::TYPE_VOIDP, # loff_t *off_out
+      Fiddle::TYPE_SIZE_T, # size_t len
+      Fiddle::TYPE_INT, # unsigned int flags
+    ],
+    Fiddle::TYPE_SSIZE_T) # ssize_t
+
+  Tee = Fiddle::Function.new(DTAS.libc['tee'], [
+      Fiddle::TYPE_INT, # int fd_in,
+      Fiddle::TYPE_INT, # int fd_out
+      Fiddle::TYPE_SIZE_T, # size_t len
+      Fiddle::TYPE_INT, # unsigned int flags
+    ],
+    Fiddle::TYPE_SSIZE_T) # ssize_t
+
+  def _syserr(s, func)
+    raise "BUG: we should not encounter EOF on #{func}" if s == 0
+    case errno = Fiddle.last_error
+    when Errno::EAGAIN::Errno
+      return :EAGAIN
+    when Errno::EPIPE::Errno
+      raise Errno::EPIPE.exception
+    when Errno::EINTR::Errno
+      return nil
+    else
+      raise SystemCallError, "#{func} error: #{errno}"
+    end
+  end
+
+  def splice(src, dst, len, flags)
+    begin
+      s = Splice.call(src.fileno, nil, dst.fileno, nil, len, flags)
+      return s if s > 0
+      sym = _syserr(s, 'splice') and return sym
+    end while true
+  end
+
+  def tee(src, dst, len, flags = 0)
+    begin
+      s = Tee.call(src.fileno, dst.fileno, len, flags)
+      return s if s > 0
+      sym = _syserr(s, 'tee') and return sym
+    end while true
+  end
+
+  def buffer_size
+    @to_io.pipe_size
+  end
+
+  # nil is OK, won't reset existing pipe, either...
+  def buffer_size=(bytes)
+    @to_io.pipe_size = bytes if bytes
+    @buffer_size = bytes
+  end
+
+  # be sure to only call this with nil when all writers to @wr are done
+  def discard(bytes)
+    splice(@to_io, DTAS.null, bytes, 0)
+  end
+
+  def broadcast_one(targets, limit = nil)
+    # single output is always non-blocking
+    limit ||= MAX_AT_ONCE_1
+    s = splice(@to_io, targets[0], limit, F_MOVE|F_NONBLOCK)
+    if Symbol === s
+      targets # our one and only target blocked on write
+    else
+      @bytes_xfer += s
+      # s < limit means targets[0] is full
+      s < limit ? targets : :wait_readable
+    end
+  rescue Errno::EPIPE, IOError => e
+    __dst_error(targets[0], e)
+    targets.clear
+    nil # do not return error here, we already spewed an error message
+  end
+
+  def __tee_in_full(src, dst, bytes)
+    rv = 0
+    while bytes > 0
+      s = tee(src, dst, bytes)
+      bytes -= s
+      rv += s
+    end
+    rv
+  end
+
+  def __splice_in_full(src, dst, bytes, flags)
+    rv = 0
+    while bytes > 0
+      s = splice(src, dst, bytes, flags)
+      rv += s
+      bytes -= s
+    end
+    rv
+  end
+
+  # returns the largest value we teed
+  def __broadcast_tee(blocked, targets, chunk_size)
+    most_teed = 0
+    targets.delete_if do |dst|
+      begin
+        t = (dst.nonblock? || most_teed == 0) ?
+              tee(@to_io, dst, chunk_size, F_NONBLOCK) :
+              __tee_in_full(@to_io, dst, chunk_size)
+        if Integer === t
+          if t > most_teed
+            chunk_size = t if most_teed == 0
+            most_teed = t
+          end
+        else
+          blocked << dst
+        end
+        false
+      rescue IOError, Errno::EPIPE => e
+        __dst_error(dst, e)
+        true
+      end
+    end
+    most_teed
+  end
+
+  def broadcast_inf(targets, limit = nil)
+    if targets.all?(&:ready_write_optimized?)
+      blocked = []
+    elsif targets.none?(&:nonblock?)
+      # if all targets are blocking, don't start until they're all writable
+      r = IO.select(nil, targets, nil, 0) or return targets
+      blocked = targets - r[1]
+
+      # tell DTAS::UNIXServer#run_once to wait on the blocked targets
+      return blocked if blocked[0]
+
+      # all writable, yay!
+    else
+      blocked = []
+    end
+
+    # don't pin too much on one target
+    bytes = limit || MAX_AT_ONCE
+    last = targets.pop # we splice to the last one, tee to the rest
+
+    # this may return zero if all targets were non-blocking
+    most_teed = __broadcast_tee(blocked, targets, bytes)
+
+    # don't splice more than the largest amount we successfully teed
+    bytes = most_teed if most_teed > 0
+
+    begin
+      targets << last
+      if last.nonblock? || most_teed == 0
+        s = splice(@to_io, last, bytes, F_MOVE|F_NONBLOCK)
+        if Symbol === s
+          blocked << last
+
+          # we accomplished nothing!
+          # If _all_ writers are blocked, do not discard data,
+          # stay blocked on :wait_writable
+          return blocked if most_teed == 0
+
+          # the tees targets win, drop data intended for last
+          if most_teed > 0
+            discard(most_teed)
+            @bytes_xfer += most_teed
+            # do not watch for writability of last, last is non-blocking
+            return :wait_readable
+          end
+        end
+      else
+        # the blocking case is simple
+        s = __splice_in_full(@to_io, last, bytes, F_MOVE)
+      end
+      @bytes_xfer += s
+
+      # if we can't splice everything
+      # discard it so the early targets do not get repeated data
+      if s < bytes && most_teed > 0
+        discard(bytes - s)
+      end
+      :wait_readable
+    rescue IOError, Errno::EPIPE => e # last failed, drop it
+      __dst_error(last, e)
+      targets.pop # we're no longer a valid target
+
+      if most_teed == 0
+        # nothing accomplished, watch any targets
+        return blocked if blocked[0]
+      else
+        # some progress, discard the data we could not splice
+        @bytes_xfer += most_teed
+        discard(most_teed)
+      end
+
+      # stop decoding if we're completely errored out
+      # returning nil will trigger close
+      return targets[0] ? :wait_readable : nil
+    end
+  end
+end
diff --git a/lib/dtas/buffer/read_write.rb b/lib/dtas/buffer/read_write.rb
index 04856c7..8fdb25d 100644
--- a/lib/dtas/buffer/read_write.rb
+++ b/lib/dtas/buffer/read_write.rb
@@ -1,13 +1,12 @@
-# Copyright (C) 2013-2019 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 'io/nonblock'
 require_relative '../../dtas'
 require_relative '../pipe'
-require_relative '../nonblock'
 
-# compatibility code for systems lacking "splice" support via the
-# "sleepy_penguin" 3.5+ RubyGem.  Used only by -player
+# compatibility code for non-Linux systems lacking "splice" support.
+# Used only by -player
 module DTAS::Buffer::ReadWrite # :nodoc:
   MAX_AT_ONCE = 512 # min PIPE_BUF value in POSIX
   attr_accessor :buffer_size
diff --git a/lib/dtas/buffer/splice.rb b/lib/dtas/buffer/splice.rb
index 281ecfd..b9957ce 100644
--- a/lib/dtas/buffer/splice.rb
+++ b/lib/dtas/buffer/splice.rb
@@ -1,10 +1,12 @@
-# Copyright (C) 2013-2019 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 require 'io/nonblock'
 require 'sleepy_penguin'
 require_relative '../../dtas'
 require_relative '../pipe'
+SleepyPenguin.respond_to?(:splice) or
+  raise LoadError, 'sleepy_penguin 3.5+ required for splice', []
 
 # Used by -player on Linux systems with the "sleepy_penguin" RubyGem installed
 module DTAS::Buffer::Splice # :nodoc:
@@ -12,7 +14,6 @@ module DTAS::Buffer::Splice # :nodoc:
   MAX_AT_ONCE_1 = 65536
   F_MOVE = SleepyPenguin::F_MOVE
   F_NONBLOCK = SleepyPenguin::F_NONBLOCK
-  TRY = { exception: false }.freeze
 
   def buffer_size
     @to_io.pipe_size
@@ -32,12 +33,14 @@ module DTAS::Buffer::Splice # :nodoc:
   def broadcast_one(targets, limit = nil)
     # single output is always non-blocking
     limit ||= MAX_AT_ONCE_1
-    s = SleepyPenguin.splice(@to_io, targets[0], limit, F_MOVE|F_NONBLOCK, TRY)
+    s = SleepyPenguin.splice(@to_io, targets[0], limit, F_MOVE|F_NONBLOCK,
+                             exception: false)
     if Symbol === s
       targets # our one and only target blocked on write
     else
       @bytes_xfer += s
-      :wait_readable # we want to read more from @to_io soon
+      # s < limit means targets[0] is full
+      s < limit ? targets : :wait_readable
     end
   rescue Errno::EPIPE, IOError => e
     __dst_error(targets[0], e)
@@ -71,7 +74,8 @@ module DTAS::Buffer::Splice # :nodoc:
     targets.delete_if do |dst|
       begin
         t = (dst.nonblock? || most_teed == 0) ?
-            SleepyPenguin.tee(@to_io, dst, chunk_size, F_NONBLOCK, TRY) :
+            SleepyPenguin.tee(@to_io, dst, chunk_size, F_NONBLOCK,
+                              exception: false) :
             __tee_in_full(@to_io, dst, chunk_size)
         if Integer === t
           if t > most_teed
@@ -119,7 +123,8 @@ module DTAS::Buffer::Splice # :nodoc:
     begin
       targets << last
       if last.nonblock? || most_teed == 0
-        s = SleepyPenguin.splice(@to_io, last, bytes, F_MOVE|F_NONBLOCK, TRY)
+        s = SleepyPenguin.splice(@to_io, last, bytes, F_MOVE|F_NONBLOCK,
+                                 exception: false)
         if Symbol === s
           blocked << last
 
diff --git a/lib/dtas/command.rb b/lib/dtas/command.rb
index 5ac1eb7..116f880 100644
--- a/lib/dtas/command.rb
+++ b/lib/dtas/command.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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 'serialize'
diff --git a/lib/dtas/compat_onenine.rb b/lib/dtas/compat_onenine.rb
deleted file mode 100644
index 39cc1ec..0000000
--- a/lib/dtas/compat_onenine.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
-# License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
-
-# Make Ruby 1.9.3 look like Ruby 2.0.0 to us
-# This exists for Debian wheezy users using the stock Ruby 1.9.3 install.
-# We'll drop this interface when Debian wheezy (7.0) becomes unsupported.
-class String # :nodoc:
-  def b # :nodoc:
-    dup.force_encoding(Encoding::BINARY)
-  end
-end unless String.method_defined?(:b)
-
-def IO # :nodoc:
-  def self.pipe # :nodoc:
-    super.each { |io| io.close_on_exec = true }
-  end
-end if RUBY_VERSION.to_f <= 1.9
diff --git a/lib/dtas/cue_index.rb b/lib/dtas/cue_index.rb
index 74d4098..9ba9334 100644
--- a/lib/dtas/cue_index.rb
+++ b/lib/dtas/cue_index.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/lib/dtas/disclaimer.rb b/lib/dtas/disclaimer.rb
index b5e0a57..91cfb7d 100644
--- a/lib/dtas/disclaimer.rb
+++ b/lib/dtas/disclaimer.rb
@@ -1,5 +1,5 @@
 # :enddoc:
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 DTAS_PROGNAME = File.basename($0)
diff --git a/lib/dtas/edit_client.rb b/lib/dtas/edit_client.rb
index 82cc857..2bdc4d8 100644
--- a/lib/dtas/edit_client.rb
+++ b/lib/dtas/edit_client.rb
@@ -1,8 +1,7 @@
-# Copyright (C) 2013-2016 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 'tempfile'
-require 'yaml'
 require_relative 'unix_client'
 require_relative 'disclaimer'
 
@@ -14,7 +13,7 @@ module DTAS::EditClient # :nodoc:
       v.empty? and next
       return v
     end
-    'vi'.freeze
+    'vi'
   end
 
   def client_socket
diff --git a/lib/dtas/encoding.rb b/lib/dtas/encoding.rb
index 613e376..bbc6076 100644
--- a/lib/dtas/encoding.rb
+++ b/lib/dtas/encoding.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2018-2019 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2018-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 
@@ -11,15 +11,14 @@ module DTAS::Encoding # :nodoc:
 private
 
   def try_enc_harder(str, enc, old) # :nodoc:
+    begin
+      require 'charlock_holmes'
+      @charlock_holmes = CharlockHolmes::EncodingDetector.new
+    rescue LoadError
+      @charlock_holmes = false
+    end if @charlock_holmes.nil?
+
     case @charlock_holmes
-    when nil
-      begin
-        require 'charlock_holmes'
-        @charlock_holmes = CharlockHolmes::EncodingDetector.new
-      rescue LoadError
-        warn "`charlock_holmes` gem not available for encoding detection"
-        @charlock_holmes = false
-      end
     when false
       enc_fallback(str, enc, old)
     else
diff --git a/lib/dtas/fadefx.rb b/lib/dtas/fadefx.rb
index 1a00653..0ec108c 100644
--- a/lib/dtas/fadefx.rb
+++ b/lib/dtas/fadefx.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 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'
@@ -95,7 +95,7 @@ class DTAS::FadeFX # :nodoc:
   def parse!(str)
     return nil if str.empty?
     type = "t"
-    str.sub!(/\A([a-z])/, "") and type = DTAS.dedupe_str($1)
+    str.sub!(/\A([a-z])/, "") and type = -$1
     F.new(type, parse_time(str))
   end
 end
diff --git a/lib/dtas/format.rb b/lib/dtas/format.rb
index f4ea102..2c26517 100644
--- a/lib/dtas/format.rb
+++ b/lib/dtas/format.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/lib/dtas/mcache.rb b/lib/dtas/mcache.rb
index b638a23..e0a39af 100644
--- a/lib/dtas/mcache.rb
+++ b/lib/dtas/mcache.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2016 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
 # encoding: binary
@@ -13,16 +13,27 @@ class DTAS::Mcache
 
   def lookup(infile)
     bucket = infile.hash & @mask
+    st = nil
     if cur = @tbl[bucket]
       if cur[:infile] == infile && (DTAS.now - cur[:btime]) < @ttl
-        return cur
+        begin
+          st = File.stat(infile)
+          return cur if cur[:ctime] == st.ctime
+        rescue
+        end
       end
     end
     return unless block_given?
     @tbl[bucket] = begin
       cur = cur ? cur.clear : {}
+      begin
+        st ||= File.stat(infile)
+        cur[:ctime] = st.ctime
+      rescue
+        return
+      end
       if ret = yield(infile, cur)
-        ret[:infile] = infile.frozen? ? infile : infile.dup.freeze
+        ret[:infile] = infile.frozen? ? infile : -(infile.dup)
         ret[:btime] = DTAS.now
       end
       ret
diff --git a/lib/dtas/mlib.rb b/lib/dtas/mlib.rb
index e0f19ab..f99ed6a 100644
--- a/lib/dtas/mlib.rb
+++ b/lib/dtas/mlib.rb
@@ -1,5 +1,5 @@
 # -*- encoding: utf-8 -*-
-# Copyright (C) 2015-2019 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
 #
@@ -129,9 +129,13 @@ class DTAS::Mlib # :nodoc:
       comments.where(q).delete
       tmp.each do |tid, val|
         v = vals[val: val]
-        q[:val_id] = v ? v[:id] : vals.insert(val: val)
-        q[:tag_id] = tid
-        comments.insert(q)
+        begin
+          q[:val_id] = v ? v[:id] : vals.insert(val: val)
+          q[:tag_id] = tid
+          comments.insert(q)
+        rescue => e
+          warn "E: #{e.message} (#{e.class}) q=#{q.inspect} val=#{val.inspect}"
+        end
       end
     end
   end
@@ -197,9 +201,7 @@ class DTAS::Mlib # :nodoc:
       tag_id = tag_map[x] and tag_map["#{x}number"] = tag_id
     end
     @tag_rmap = tag_map.invert.freeze
-    tag_map.merge!(Hash[*(tag_map.map { |k,v|
-      [DTAS.dedupe_str(k.upcase), v]
-    }.flatten!)])
+    tag_map.merge!(Hash[*(tag_map.map { |k,v| [-(k.upcase), v] }.flatten!)])
     @tag_map = tag_map.freeze
   end
 
@@ -214,12 +216,16 @@ class DTAS::Mlib # :nodoc:
     end
   end
 
+  def maybe_blob(path)
+    path.valid_encoding? ? path : Sequel.blob(path)
+  end
+
   def scan_file(path, st, parent_id)
     return if @suffixes !~ path || st.size == 0
 
     # no-op if no change
     unless @force
-      if node = @db[:nodes][name: path, parent_id: parent_id]
+      if node = @db[:nodes][name: maybe_blob(path), parent_id: parent_id]
         return if st.ctime.to_i == node[:ctime] || node[:tlen] == DM_IGN
       end
     end
@@ -271,14 +277,16 @@ class DTAS::Mlib # :nodoc:
     node_id = node.delete(:id)
     @db[:nodes].where(id: node_id).update(node.merge(q))
     node[:id] = node_id
+  rescue => e
+    warn "E: #{e.message} (#{e.class}) node=#{node.inspect}"
   end
 
   def node_lookup(parent_id, name)
-    @db[:nodes][name: name, parent_id: parent_id]
+    @db[:nodes][name: maybe_blob(name), parent_id: parent_id]
   end
 
   def node_ensure(parent_id, name, tlen, ctime = nil)
-    q = { name: name, parent_id: parent_id }
+    q = { name: maybe_blob(name), parent_id: parent_id }
     if node = @db[:nodes][q]
       node_update_maybe(node, tlen, ctime)
     else
@@ -289,6 +297,8 @@ class DTAS::Mlib # :nodoc:
       node[:id] = @db[:nodes].insert(node)
     end
     node
+  rescue => e
+    warn "E: #{e.message} (#{e.class}) q=#{q.inspect}"
   end
 
   def cd(path)
@@ -409,7 +419,7 @@ class DTAS::Mlib # :nodoc:
     return '/' if base == '' # root_node
     parent_id = node[:parent_id]
     base += '/' unless node[:tlen] >= 0
-    ppath = cache[parent_id] and return DTAS.dedupe_str("#{ppath}/#{base}")
+    ppath = cache[parent_id] and return -"#{ppath}/#{base}"
     parts = []
     begin
       node = @db[:nodes][id: node[:parent_id]]
@@ -417,9 +427,9 @@ class DTAS::Mlib # :nodoc:
       parts.unshift node[:name]
     end while true
     parts.unshift('')
-    cache[parent_id] = DTAS.dedupe_str(parts.join('/'))
+    cache[parent_id] = -(parts.join('/'))
     parts << base
-    DTAS.dedupe_str(parts.join('/'))
+    -(parts.join('/'))
   end
 
   def emit_recurse(node, cache, cb)
diff --git a/lib/dtas/mlib/migrations/0001_initial.rb b/lib/dtas/mlib/migrations/0001_initial.rb
index 688b6a5..d3da6a3 100644
--- a/lib/dtas/mlib/migrations/0001_initial.rb
+++ b/lib/dtas/mlib/migrations/0001_initial.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2015-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2015-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 
 Sequel.migration do
diff --git a/lib/dtas/nonblock.rb b/lib/dtas/nonblock.rb
deleted file mode 100644
index 504c8d2..0000000
--- a/lib/dtas/nonblock.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# Copyright (C) 2015-2016 all contributors <dtas-all@nongnu.org>
-# License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
-
-class DTAS::Nonblock < IO # :nodoc:
-  if RUBY_VERSION.to_f <= 2.0
-    EX = {}.freeze
-    def read_nonblock(len, buf = nil, opts = EX)
-      super(len, buf)
-    rescue IO::WaitReadable
-      raise if opts[:exception]
-      :wait_readable
-    rescue EOFError
-      raise if opts[:exception]
-      nil
-    end
-
-    def write_nonblock(buf, opts = EX)
-      super(buf)
-    rescue IO::WaitWritable
-      raise if opts[:exception]
-      :wait_writable
-    end
-  end
-end
diff --git a/lib/dtas/parse_freq.rb b/lib/dtas/parse_freq.rb
index afc9048..201c284 100644
--- a/lib/dtas/parse_freq.rb
+++ b/lib/dtas/parse_freq.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2015-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2015-2020 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'
diff --git a/lib/dtas/parse_time.rb b/lib/dtas/parse_time.rb
index f156c5c..73f5b5c 100644
--- a/lib/dtas/parse_time.rb
+++ b/lib/dtas/parse_time.rb
@@ -1,5 +1,5 @@
 # frozen_string_literal: true
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/lib/dtas/partstats.rb b/lib/dtas/partstats.rb
index 1037c87..061ff50 100644
--- a/lib/dtas/partstats.rb
+++ b/lib/dtas/partstats.rb
@@ -1,9 +1,8 @@
 # -*- encoding: binary -*-
-# Copyright (C) 2013-2016 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 'xs'
 require_relative 'process'
 require_relative 'sigevent'
 
@@ -12,7 +11,6 @@ require_relative 'sigevent'
 class DTAS::PartStats # :nodoc:
   CMD = 'sox "$INFILE" -n $TRIMFX $SOXFX stats $STATSOPTS'
   include DTAS::Process
-  include DTAS::SpawnFix
   attr_reader :key_idx
   attr_reader :key_width
 
@@ -56,7 +54,7 @@ class DTAS::PartStats # :nodoc:
     rd, wr = IO.pipe
     env = opts[:env]
     env = env ? env.dup : {}
-    env["INFILE"] = xs(@infile)
+    env["INFILE"] = @infile
     env["TRIMFX"] = "trim #{trim_part.tbeg}s #{trim_part.tlen}s"
     opts = { pgroup: true, close_others: true, err: wr }
     pid = spawn(env, CMD, opts)
@@ -173,7 +171,7 @@ becomes:
       else
         next
       end
-      key = DTAS.dedupe_str($1)
+      key = -$1
       key_idx = @key_idx[key]
       parts = line.split(/\s+/)
       nshift.times { parts.shift } # remove stuff we don't need
diff --git a/lib/dtas/pipe.rb b/lib/dtas/pipe.rb
index 58d926c..a7b02b0 100644
--- a/lib/dtas/pipe.rb
+++ b/lib/dtas/pipe.rb
@@ -1,19 +1,19 @@
-# Copyright (C) 2013-2019 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
-begin
-  require 'sleepy_penguin'
-rescue LoadError
-end
 require_relative '../dtas'
 require_relative 'writable_iter'
-require_relative 'nonblock'
 
 # pipe wrapper for -player sinks
-class DTAS::Pipe < DTAS::Nonblock # :nodoc:
+class DTAS::Pipe < IO # :nodoc:
   include DTAS::WritableIter
   attr_accessor :sink
 
+  if RUBY_PLATFORM =~ /linux/i && File.readable?('/proc/sys/fs/pipe-max-size')
+    F_SETPIPE_SZ = 1031
+    F_GETPIPE_SZ = 1032
+  end
+
   def self.new
     _, w = rv = pipe
     w.writable_iter_init
@@ -21,13 +21,16 @@ class DTAS::Pipe < DTAS::Nonblock # :nodoc:
   end
 
   def pipe_size=(nr)
-    defined?(SleepyPenguin::F_SETPIPE_SZ) and
-      fcntl(SleepyPenguin::F_SETPIPE_SZ, nr)
+    fcntl(F_SETPIPE_SZ, nr) if defined?(F_SETPIPE_SZ)
+  rescue Errno::EINVAL # old kernel
+  rescue Errno::EPERM
+    # resizes fail if Linux is close to the pipe limit for the user
+    # or if the user does not have permissions to resize
   end
 
   def pipe_size
-    fcntl(SleepyPenguin::F_GETPIPE_SZ)
-  end if defined?(SleepyPenguin::F_GETPIPE_SZ)
+    fcntl(F_GETPIPE_SZ)
+  end if defined?(F_GETPIPE_SZ)
 
   # avoid syscall, we never change IO#nonblock= directly
   def nonblock?
diff --git a/lib/dtas/pipeline.rb b/lib/dtas/pipeline.rb
index b900fee..1bebe87 100644
--- a/lib/dtas/pipeline.rb
+++ b/lib/dtas/pipeline.rb
@@ -1,12 +1,9 @@
-# Copyright (C) 2017-2019 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 'spawn_fix'
 
 module DTAS::Pipeline # :nodoc:
-  include DTAS::SpawnFix
-
   # Process.spawn wrapper which supports running Proc-like objects in
   # a separate process, not just external commands.
   # Returns the pid of the spawned process
diff --git a/lib/dtas/player.rb b/lib/dtas/player.rb
index 37f2c96..6ea3aba 100644
--- a/lib/dtas/player.rb
+++ b/lib/dtas/player.rb
@@ -1,8 +1,8 @@
-# Copyright (C) 2013-2016 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 'yaml'
 require 'shellwords'
+require 'yaml'
 require_relative '../dtas'
 require_relative 'xs'
 require_relative 'source'
@@ -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
@@ -123,10 +124,6 @@ class DTAS::Player # :nodoc:
     rv
   end
 
-  def to_omap(hash)
-    YAML::Omap === hash ? hash : YAML::Omap.new.merge!(hash)
-  end
-
   def self.load(hash)
     rv = new
     rv.instance_eval do
@@ -157,7 +154,6 @@ class DTAS::Player # :nodoc:
         @source_map.each do |name, src|
           src_hsh = v[name] or next
           src.load!(src_hsh)
-          src.env = to_omap(src.env)
         end
         source_map_reload
       end
@@ -168,9 +164,8 @@ class DTAS::Player # :nodoc:
 
       if sinks = hash["sinks"]
         sinks.each do |sink_hsh|
-          sink_hsh['name'] = DTAS.dedupe_str(sink_hsh['name'])
+          sink_hsh['name'] = -sink_hsh['name']
           sink = DTAS::Sink.load(sink_hsh)
-          sink.env = to_omap(sink.env)
           @sinks[sink.name] = sink
         end
       end
@@ -208,13 +203,13 @@ class DTAS::Player # :nodoc:
     command = msg.shift
     case command
     when "enq"
-      enq_handler(io, msg[0])
+      enq_handler(io, -msg[0])
     when "enq-cmd"
-      enq_handler(io, { "command" => msg[0]})
+      enq_handler(io, { "command" => -msg[0]})
     when "pause", "play", "play_pause"
       play_pause_handler(io, command)
     when "pwd"
-      io.emit(Dir.pwd)
+      io.emit(-Dir.pwd)
     else
       m = "dpc_#{command.tr('-', '_')}"
       __send__(m, io, msg) if respond_to?(m)
@@ -282,7 +277,7 @@ class DTAS::Player # :nodoc:
     if deleted[0]
       warn("#{sink.name} died unexpectedly: #{status.inspect}")
       deleted.each { |t| drop_target(t) }
-      __current_drop unless @targets[0]
+      do_pause unless @targets[0]
       return # sink stays dead if it died unexpectedly
     end
 
@@ -337,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
@@ -398,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
@@ -440,6 +438,7 @@ class DTAS::Player # :nodoc:
   end
 
   def stop_sinks
+    @bypass_next = nil
     @targets.each { |t| drop_target(t) }.clear
   end
 
@@ -451,7 +450,7 @@ class DTAS::Player # :nodoc:
   # pull data from sink_buf into @targets, source feeds into sink_buf
   def sink_iter
     wait_iter = broadcast_iter(@sink_buf, @targets)
-    __current_drop if nil == wait_iter # sink error, stop source
+    do_pause if nil == wait_iter # sink error, stop source
     return wait_iter if @current
 
     # no source left to feed sink_buf, drain the remaining data
@@ -464,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 1e4ac96..3c5fe5d 100644
--- a/lib/dtas/player/client_handler.rb
+++ b/lib/dtas/player/client_handler.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 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 '../xs'
@@ -135,7 +135,7 @@ module DTAS::Player::ClientHandler # :nodoc:
       # or variable names.
       sink.valid_name?(name) or return io.emit("ERR sink name invalid")
 
-      sink.name = DTAS.dedupe_str(name)
+      sink.name = -name
       active_before = sink.active
       before = __sink_snapshot(sink)
 
@@ -144,7 +144,7 @@ module DTAS::Player::ClientHandler # :nodoc:
         k, v = kv.split('=', 2)
         case k
         when %r{\Aenv\.([^=]+)\z}
-          sink.env[DTAS.dedupe_str($1)] = v
+          sink.env[$1] = v
         when %r{\Aenv#([^=]+)\z}
           v == nil or return io.emit("ERR unset env has no value")
           sink.env.delete($1)
@@ -197,19 +197,20 @@ module DTAS::Player::ClientHandler # :nodoc:
     end
   end
 
+  def __offset_to_i(offset, src)
+    # either "999s" for 999 samples or HH:MM:SS for time
+    offset.sub!(/s\z/, '') ? offset.to_i : src.format.hhmmss_to_samples(offset)
+  end
+
   def __offset_to_samples(offset)
-    offset.sub!(/s\z/, '') and return offset.to_i
-    @current.format.hhmmss_to_samples(offset)
+    __offset_to_i(offset, @current)
   end
 
   # returns seek offset as an Integer in sample count
-  def __seek_offset_adj(dir, offset)
-    if offset.sub!(/s\z/, '')
-      offset = offset.to_i
-    else # time
-      offset = @current.format.hhmmss_to_samples(offset)
-    end
-    n = __current_decoded_samples + (dir * offset)
+  def __seek_offset_adj(dir, offset,
+                        src = @current,
+                        current_decoded_samples = __current_decoded_samples)
+    n = current_decoded_samples + (dir * __offset_to_i(offset, src))
     n = 0 if n < 0
     "#{n}s"
   end
@@ -391,15 +392,17 @@ module DTAS::Player::ClientHandler # :nodoc:
     end
   end
 
+  def __offset_direction(offset)
+    offset.sub!(/\A\+/, '') ? 1 : (offset.sub!(/\A-/, '') ? -1 : nil)
+  end
+
   def dpc_seek(io, msg)
     offset = msg[0] or return io.emit('ERR usage: seek OFFSET')
     if @current
       if @current.respond_to?(:infile)
         begin
-          if offset.sub!(/\A\+/, '')
-            offset = __seek_offset_adj(1, offset)
-          elsif offset.sub!(/\A-/, '')
-            offset = __seek_offset_adj(-1, offset)
+          if direction = __offset_direction(offset)
+            offset = __seek_offset_adj(direction, offset)
           # else: pass to sox directly
           end
         rescue ArgumentError
@@ -413,7 +416,12 @@ module DTAS::Player::ClientHandler # :nodoc:
       case file = @queue[0]
       when String
         @queue[0] = [ file, offset ]
-      when Array
+      when Array # offset already stored, adjust
+        if direction = __offset_direction(offset)
+          tmp = try_file(*file)
+          cur_off = __offset_to_i(file[1].dup, tmp)
+          offset = __seek_offset_adj(direction, offset, tmp, cur_off)
+        end
         file[1] = offset
       else
         return io.emit("ERR unseekable")
@@ -556,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/process.rb b/lib/dtas/process.rb
index 4caf96b..02bf77e 100644
--- a/lib/dtas/process.rb
+++ b/lib/dtas/process.rb
@@ -1,17 +1,15 @@
-# Copyright (C) 2013-2016 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 'io/wait'
 require 'shellwords'
 require_relative '../dtas'
 require_relative 'xs'
-require_relative 'nonblock'
 
 # process management helpers
 module DTAS::Process # :nodoc:
   PIDS = {}
   include DTAS::XS
-  include DTAS::SpawnFix
 
   def self.reaper
     begin
@@ -89,12 +87,12 @@ module DTAS::Process # :nodoc:
       env = {}
     end
     buf = ''.b
-    r, w = DTAS::Nonblock.pipe
+    r, w = IO.pipe
     opts = opts.merge(out: w)
     r.binmode
     no_raise = opts.delete(:no_raise)
     if err_str = opts.delete(:err_str)
-      re, we = DTAS::Nonblock.pipe
+      re, we = IO.pipe
       re.binmode
       opts[:err] = we
     end
diff --git a/lib/dtas/replaygain.rb b/lib/dtas/replaygain.rb
index 116625e..5fa6dcf 100644
--- a/lib/dtas/replaygain.rb
+++ b/lib/dtas/replaygain.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 
diff --git a/lib/dtas/rg_state.rb b/lib/dtas/rg_state.rb
index 6b2c718..9a44835 100644
--- a/lib/dtas/rg_state.rb
+++ b/lib/dtas/rg_state.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 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
 #
@@ -72,7 +72,7 @@ class DTAS::RGState # :nodoc:
     when 1 then return 'gain 192'
     else
       val.abs <= 0.00000001 and return
-      DTAS.dedupe_str(sprintf('gain %0.8f', val))
+      -sprintf('gain %0.8f', val)
     end
   end
 
diff --git a/lib/dtas/serialize.rb b/lib/dtas/serialize.rb
index fbed5af..d8331e6 100644
--- a/lib/dtas/serialize.rb
+++ b/lib/dtas/serialize.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 
diff --git a/lib/dtas/sigevent.rb b/lib/dtas/sigevent.rb
index d4a96d7..16edacb 100644
--- a/lib/dtas/sigevent.rb
+++ b/lib/dtas/sigevent.rb
@@ -1,10 +1,13 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 begin
   raise LoadError, "no eventfd with _DTAS_POSIX" if ENV["_DTAS_POSIX"]
-  require 'sleepy_penguin'
-  require_relative 'sigevent/efd'
+  begin
+    require_relative 'sigevent/efd'
+  rescue LoadError
+    require_relative 'sigevent/fiddle_efd'
+  end
 rescue LoadError
   require_relative 'sigevent/pipe'
 end
diff --git a/lib/dtas/sigevent/efd.rb b/lib/dtas/sigevent/efd.rb
index 4be2c84..d13c32a 100644
--- a/lib/dtas/sigevent/efd.rb
+++ b/lib/dtas/sigevent/efd.rb
@@ -1,8 +1,10 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 
 # used in various places for safe wakeups from IO.select via signals
 # This requires a modern Linux system and the "sleepy_penguin" RubyGem
+require 'sleepy_penguin'
+
 class DTAS::Sigevent < SleepyPenguin::EventFD # :nodoc:
   def self.new
     super(0, :CLOEXEC)
diff --git a/lib/dtas/sigevent/fiddle_efd.rb b/lib/dtas/sigevent/fiddle_efd.rb
new file mode 100644
index 0000000..8bfa332
--- /dev/null
+++ b/lib/dtas/sigevent/fiddle_efd.rb
@@ -0,0 +1,37 @@
+# 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
+
+# used in various places for safe wakeups from IO.select via signals
+# This requires a modern GNU/Linux system with eventfd(2) support
+require 'fiddle'
+class DTAS::Sigevent # :nodoc:
+
+  EventFD = Fiddle::Function.new(DTAS.libc['eventfd'],
+    [ Fiddle::TYPE_INT, Fiddle::TYPE_INT ], # initval, flags
+    Fiddle::TYPE_INT) # fd
+
+  attr_reader :to_io
+  ONE = -([ 1 ].pack('Q'))
+
+  def initialize
+    fd = EventFD.call(0, 02000000|00004000) # EFD_CLOEXEC|EFD_NONBLOCK
+    raise "eventfd failed: #{Fiddle.last_error}" if fd < 0
+    @to_io = IO.for_fd(fd)
+    @buf = ''.b
+  end
+
+  def signal
+    @to_io.syswrite(ONE)
+  end
+
+  def readable_iter
+    @to_io.read_nonblock(8, @buf, exception: false)
+    yield self, nil # calls DTAS::Process.reaper
+    :wait_readable
+  end
+
+  def close
+    @to_io.close
+  end
+end
diff --git a/lib/dtas/sigevent/pipe.rb b/lib/dtas/sigevent/pipe.rb
index 921a5b3..6c3b83c 100644
--- a/lib/dtas/sigevent/pipe.rb
+++ b/lib/dtas/sigevent/pipe.rb
@@ -1,15 +1,14 @@
-# Copyright (C) 2013-2016 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
 
 # used in various places for safe wakeups from IO.select via signals
-# A fallback for non-Linux systems lacking the "sleepy_penguin" RubyGem
-require_relative '../nonblock'
+# A fallback for non-Linux systems lacking the "splice" syscall
 class DTAS::Sigevent # :nodoc:
   attr_reader :to_io
 
   def initialize
-    @to_io, @wr = DTAS::Nonblock.pipe
+    @to_io, @wr = IO.pipe
     @rbuf = ''.b
   end
 
diff --git a/lib/dtas/sink.rb b/lib/dtas/sink.rb
index c481032..966bab4 100644
--- a/lib/dtas/sink.rb
+++ b/lib/dtas/sink.rb
@@ -1,7 +1,6 @@
-# Copyright (C) 2013-2016 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 'yaml'
 require_relative '../dtas'
 require_relative 'pipe'
 require_relative 'process'
diff --git a/lib/dtas/source.rb b/lib/dtas/source.rb
index 1944894..e3ca17e 100644
--- a/lib/dtas/source.rb
+++ b/lib/dtas/source.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/lib/dtas/source/av.rb b/lib/dtas/source/av.rb
index 0a9d39b..dcebcfd 100644
--- a/lib/dtas/source/av.rb
+++ b/lib/dtas/source/av.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 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 ae654ba..c600c48 100644
--- a/lib/dtas/source/av_ff_common.rb
+++ b/lib/dtas/source/av_ff_common.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 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'
@@ -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[DTAS.dedupe_str($1.upcase)] = DTAS.dedupe_str($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[DTAS.dedupe_str($1.upcase)] = DTAS.dedupe_str($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
@@ -186,7 +209,7 @@ module DTAS::Source::AvFfCommon # :nodoc:
 
     e["PROBE"] = @probe_harder ? @probe_harder.join(' ') : nil
     # make sure these are visible to the source command...
-    e["INFILE"] = xs(@infile)
+    e["INFILE"] = @infile
     e["AMAP"] = amap
     e["SSPOS"] = sspos
     e["RGFX"] = rg_state.effect(self) || nil
diff --git a/lib/dtas/source/cmd.rb b/lib/dtas/source/cmd.rb
index cdcd3b3..435ac07 100644
--- a/lib/dtas/source/cmd.rb
+++ b/lib/dtas/source/cmd.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/lib/dtas/source/common.rb b/lib/dtas/source/common.rb
index a6d1a60..bdcb16d 100644
--- a/lib/dtas/source/common.rb
+++ b/lib/dtas/source/common.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 module DTAS::Source::Common # :nodoc:
   attr_reader :dst_zero_byte # first byte this source object saw
diff --git a/lib/dtas/source/ff.rb b/lib/dtas/source/ff.rb
index 80436c7..c337b42 100644
--- a/lib/dtas/source/ff.rb
+++ b/lib/dtas/source/ff.rb
@@ -1,12 +1,10 @@
-# Copyright (C) 2013-2016 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/source/file.rb b/lib/dtas/source/file.rb
index 01ac998..88beb39 100644
--- a/lib/dtas/source/file.rb
+++ b/lib/dtas/source/file.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/lib/dtas/source/mp3gain.rb b/lib/dtas/source/mp3gain.rb
index 3a7569d..7688822 100644
--- a/lib/dtas/source/mp3gain.rb
+++ b/lib/dtas/source/mp3gain.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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 '../process'
diff --git a/lib/dtas/source/sox.rb b/lib/dtas/source/sox.rb
index dc23c27..6ca29bc 100644
--- a/lib/dtas/source/sox.rb
+++ b/lib/dtas/source/sox.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2019 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
 # encoding: binary
@@ -56,14 +56,14 @@ class DTAS::Source::Sox # :nodoc:
         key = nil
         $1.split(/\n/n).each do |line|
           if line.sub!(/^([^=]+)=/ni, '')
-            key = DTAS.dedupe_str(DTAS.try_enc($1.upcase, enc))
+            key = DTAS.try_enc($1.upcase, enc)
           end
           (comments[key] ||= ''.b) << "#{line}\n" unless line.empty?
         end
         comments.each do |k,v|
           v.chomp!
           DTAS.try_enc(v, enc)
-          comments[k] = DTAS.dedupe_str(v)
+          comments[k] = -v
         end
       end
       dst
@@ -113,7 +113,7 @@ class DTAS::Source::Sox # :nodoc:
   def src_spawn(player_format, rg_state, opts)
     raise "BUG: #{self.inspect}#src_spawn called twice" if @to_io
     e = @env.merge!(player_format.to_env)
-    e["INFILE"] = xs(@infile)
+    e["INFILE"] = @infile
 
     # make sure these are visible to the "current" command...
     e["TRIMFX"] = trimfx
diff --git a/lib/dtas/source/splitfx.rb b/lib/dtas/source/splitfx.rb
index f746bee..2268404 100644
--- a/lib/dtas/source/splitfx.rb
+++ b/lib/dtas/source/splitfx.rb
@@ -1,7 +1,6 @@
-# Copyright (C) 2014-2016 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 'yaml'
 require_relative 'sox'
 require_relative '../splitfx'
 require_relative '../watchable'
@@ -36,7 +35,7 @@ class DTAS::Source::SplitFX < DTAS::Source::Sox # :nodoc:
 
     sfx = DTAS::SplitFX.new
     Dir.chdir(File.dirname(ymlfile)) do # ugh
-      @ymlhash = YAML.load(buf)
+      @ymlhash = DTAS.yaml_load(buf)
       @ymlhash['tracks'] ||= [ "t 0 default" ]
       sfx.import(@ymlhash)
       sfx.infile.replace(File.expand_path(sfx.infile))
diff --git a/lib/dtas/spawn_fix.rb b/lib/dtas/spawn_fix.rb
deleted file mode 100644
index a510a9e..0000000
--- a/lib/dtas/spawn_fix.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
-# License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
-# workaround for older Rubies: https://bugs.ruby-lang.org/issues/8770
-module DTAS::SpawnFix # :nodoc:
-  def spawn(*args)
-    super(*args)
-  rescue Errno::EINTR
-    retry
-  end if RUBY_VERSION.to_f <= 2.1
-end
diff --git a/lib/dtas/splitfx.rb b/lib/dtas/splitfx.rb
index c0c7ac9..1150ee0 100644
--- a/lib/dtas/splitfx.rb
+++ b/lib/dtas/splitfx.rb
@@ -1,19 +1,18 @@
-# Copyright (C) 2013-2016 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 'format'
 require_relative 'process'
-require_relative 'xs'
 require 'tempfile'
 
 # The backend for dtas-splitfx(1) command, but also supported by dtas-player
 # 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
-  include DTAS::XS
   attr_reader :infile, :env, :command
 
   # for --trim on the command-line
@@ -115,7 +114,7 @@ class DTAS::SplitFX # :nodoc:
     end
 
     case v = hash["track_zpad"]
-    when Integer then @track_zpad = val
+    when Integer then @track_zpad = v
     else
       _bool(hash, "track_zpad") { |val| @track_zpad = val }
     end
@@ -206,12 +205,13 @@ 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))
-    comments.sync = true
     t.comments.each do |k,v|
       env[k] = v.to_s
       comments.puts("#{k}=#{v}")
     end
+    comments.flush
     env["COMMENTS"] = "--comment-file=#{comments.path}"
     infile_env(env, @infile)
     outarg = outfmt.to_sox_arg
@@ -250,7 +250,10 @@ class DTAS::SplitFX # :nodoc:
     command = 'true' if opts[:dryrun] # still gotta fork
 
     # pgroup: false so Ctrl-C on command-line will immediately stop everything
-    [ dtas_spawn(env, command, pgroup: false), comments ]
+    o = { pgroup: false }
+    e = opts[:err_suffix] and
+      o[:err] = [ "#{env['OUTDIR']}#{env['TRACKNUMBER']}#{e}", 'a' ]
+    [ dtas_spawn(env, command, o), comments ]
   end
 
   def load_tracks!(hash)
@@ -289,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
 
@@ -298,6 +302,7 @@ class DTAS::SplitFX # :nodoc:
           t.fade_in = $1.split(/\s+/)
         when %r{\Afade_out=(.+)\z} # $1 = "t 4" or just "4"
           t.fade_out = $1.split(/\s+/)
+        when %r{\Aenv\.([^=]+)=(.+)\z} then t.env[$1] = -$2
         when %r{\A\.(\w+)=(.+)\z} then t.comments[$1] = $2
         else
           raise ArgumentError, "unrecognized arg(s): #{xs(argv)}"
@@ -355,9 +360,19 @@ class DTAS::SplitFX # :nodoc:
     @rate = opts[:rate]
     @bits = opts[:bits]
     trim = opts[:trim] and @tracks = [ UTrim.new(trim, @env, @comments) ]
-
+    if trim && opts[:filter]
+      raise ArgumentError, 'trim and filter are mutually exclusive'
+    end
     fails = []
     tracks = @tracks.dup
+    (opts[:filter] || []).each do |re|
+      field, val = re.split(/=/, 2)
+      if val
+        tracks.delete_if { |t| (t.comments[field] || '') !~ /#{val}/ }
+      else
+        tracks.delete_if { |t| t.comments.values.grep(/#{re}/).empty? }
+      end
+    end
     pids = {}
     jobs = opts[:jobs] || tracks.size # jobs == nil => everything at once
     if opts[:sox_pipe]
@@ -383,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
 
@@ -401,10 +416,10 @@ class DTAS::SplitFX # :nodoc:
   end
 
   def infile_env(env, infile)
-    env["INFILE"] = xs(infile)
+    env["INFILE"] = infile
     dir, base = File.split(File.expand_path(infile))
-    env["INDIR"] = xs(dir)
-    env["INBASE"] = xs(base)
+    env["INDIR"] = dir
+    env["INBASE"] = base
   end
 
   def expand_cmd(env, command) # for display purposes only
diff --git a/lib/dtas/state_file.rb b/lib/dtas/state_file.rb
index b577850..f16a866 100644
--- a/lib/dtas/state_file.rb
+++ b/lib/dtas/state_file.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 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 'yaml'
@@ -14,7 +14,7 @@ class DTAS::StateFile # :nodoc:
   end
 
   def tryload
-    YAML.load(IO.binread(@path)) if File.readable?(@path)
+    DTAS.yaml_load(IO.binread(@path)) if File.readable?(@path)
   end
 
   def dump(obj, force_fsync = false)
diff --git a/lib/dtas/tfx.rb b/lib/dtas/tfx.rb
index 2ccdcf1..80051e8 100644
--- a/lib/dtas/tfx.rb
+++ b/lib/dtas/tfx.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/lib/dtas/track.rb b/lib/dtas/track.rb
index 35a00c1..3f4b813 100644
--- a/lib/dtas/track.rb
+++ b/lib/dtas/track.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2015-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2015-2020 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'
@@ -9,6 +9,6 @@ class DTAS::Track # :nodoc:
 
   def initialize(track_id, path)
     @track_id = track_id
-    @to_path = path
+    @to_path = -path
   end
 end
diff --git a/lib/dtas/tracklist.rb b/lib/dtas/tracklist.rb
index e6a8fb6..a7f4c15 100644
--- a/lib/dtas/tracklist.rb
+++ b/lib/dtas/tracklist.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/lib/dtas/unix_accepted.rb b/lib/dtas/unix_accepted.rb
index 4a01972..63d3ce0 100644
--- a/lib/dtas/unix_accepted.rb
+++ b/lib/dtas/unix_accepted.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 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 'socket'
@@ -10,23 +10,22 @@ class DTAS::UNIXAccepted # :nodoc:
 
   def initialize(sock)
     @to_io = sock
-    @send_buf = []
+    @sbuf = []
   end
 
   # public API (for DTAS::Player)
   # returns :wait_readable on success
   def emit(msg)
-    buffered = @send_buf.size
-    if buffered == 0
-      case rv = sendmsg_nonblock(msg)
+    if @sbuf.empty?
+      case rv = @to_io.sendmsg_nonblock(msg, 0, exception: false)
       when :wait_writable
-        @send_buf << msg
+        @sbuf << msg
         rv
       else
         :wait_readable
       end
-    else # buffered > 0
-      @send_buf << msg
+    else
+      @sbuf << msg
       :wait_writable
     end
   rescue => e
@@ -35,11 +34,11 @@ class DTAS::UNIXAccepted # :nodoc:
 
   # flushes pending data if it got buffered
   def writable_iter
-    case sendmsg_nonblock(@send_buf[0])
+    case @to_io.sendmsg_nonblock(@sbuf[0], 0, exception: false)
     when :wait_writable then return :wait_writable
     else
-      @send_buf.shift
-      @send_buf.empty? ? :wait_readable : :wait_writable
+      @sbuf.shift
+      @sbuf.empty? ? :wait_readable : :wait_writable
     end
   rescue => e
     e
@@ -51,13 +50,13 @@ class DTAS::UNIXAccepted # :nodoc:
     # EOF, assume no spurious wakeups for SOCK_SEQPACKET
     return nil if nread == 0
 
-    case msg = recv_nonblock(nread)
+    case msg = @to_io.recv_nonblock(nread, exception: false)
     when :wait_readable then return msg
     when '', nil then return nil # EOF
     else
       yield(self, msg) # DTAS::Player deals with this
     end
-    @send_buf.empty? ? :wait_readable : :wait_writable
+    @sbuf.empty? ? :wait_readable : :wait_writable
   rescue SystemCallError
     nil
   end
@@ -69,28 +68,4 @@ class DTAS::UNIXAccepted # :nodoc:
   def closed?
     @to_io.closed?
   end
-
-  if RUBY_VERSION.to_f >= 2.3
-    def sendmsg_nonblock(msg)
-      @to_io.sendmsg_nonblock(msg, Socket::MSG_EOR, exception: false)
-    end
-
-    def recv_nonblock(len)
-      @to_io.recv_nonblock(len, exception: false)
-    end
-  else
-    def sendmsg_nonblock(msg)
-      @to_io.sendmsg_nonblock(msg, Socket::MSG_EOR)
-    rescue IO::WaitWritable
-      :wait_writable
-    end
-
-    def recv_nonblock(len)
-      @to_io.recv_nonblock(len)
-    rescue IO::WaitReadable
-      :wait_readable
-    rescue EOFError
-      nil
-    end
-  end
 end
diff --git a/lib/dtas/unix_client.rb b/lib/dtas/unix_client.rb
index aae8c9d..8c73b7d 100644
--- a/lib/dtas/unix_client.rb
+++ b/lib/dtas/unix_client.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 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'
@@ -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)
@@ -39,7 +39,7 @@ class DTAS::UNIXClient # :nodoc:
   end
 
   def res_wait(timeout = nil)
-    IO.select([@to_io], nil, nil, timeout)
+    @to_io.wait_readable(timeout)
     nr = @to_io.nread
     nr > 0 or raise EOFError, "unexpected EOF from server"
     @to_io.recv(nr)
diff --git a/lib/dtas/unix_server.rb b/lib/dtas/unix_server.rb
index ccfa662..60ab86c 100644
--- a/lib/dtas/unix_server.rb
+++ b/lib/dtas/unix_server.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 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 'socket'
@@ -59,7 +59,7 @@ class DTAS::UNIXServer # :nodoc:
 
   def readable_iter
     # we do not do anything with the block passed to us
-    case rv = accept_nonblock
+    case rv = @to_io.accept_nonblock(exception: false)
     when :wait_readable then return rv
     else
       @readers[DTAS::UNIXAccepted.new(rv[0])] = true
@@ -114,16 +114,4 @@ class DTAS::UNIXServer # :nodoc:
       wait_ctl(io, io.readable_iter { |_io, msg| yield(_io, msg) })
     end
   end
-
-  if RUBY_VERSION.to_f >= 2.3
-    def accept_nonblock
-      @to_io.accept_nonblock(exception: false)
-    end
-  else
-    def accept_nonblock
-      @to_io.accept_nonblock
-    rescue Errno::EAGAIN, Errno::ECONNABORTED, Errno::EPROTO
-      :wait_readable
-    end
-  end
 end
diff --git a/lib/dtas/util.rb b/lib/dtas/util.rb
index acfcafe..a74c14e 100644
--- a/lib/dtas/util.rb
+++ b/lib/dtas/util.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/lib/dtas/watchable.rb b/lib/dtas/watchable.rb
index d0f37af..445bf98 100644
--- a/lib/dtas/watchable.rb
+++ b/lib/dtas/watchable.rb
@@ -1,71 +1,71 @@
-# Copyright (C) 2013-2019 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'
 begin
-require 'sleepy_penguin'
+  module DTAS::Watchable # :nodoc:
+    module InotifyCommon # :nodoc:
+      FLAGS = 8 | 128 # IN_CLOSE_WRITE | IN_MOVED_TO
 
-# used to restart DTAS::Source::SplitFX processing in dtas-player
-# if the YAML file is edited
-module DTAS::Watchable # :nodoc:
-  class InotifyReadableIter < SleepyPenguin::Inotify # :nodoc:
-    def self.new
-      super(:CLOEXEC)
-    end
-
-    FLAGS = CLOSE_WRITE | MOVED_TO
-
-    def readable_iter
-      or_call = false
-      while event = take(true) # drain the buffer
-        w = @watches[event.wd] or next
-        if (event.mask & FLAGS) != 0 && w[event.name]
-          or_call = true
+      def readable_iter
+        or_call = false
+        while event = take(true) # drain the buffer
+          w = @watches[event.wd] or next
+          if (event.mask & FLAGS) != 0 && w[event.name]
+            or_call = true
+          end
+        end
+        if or_call
+          @on_readable.call
+          :delete
+        else
+          :wait_readable
         end
       end
-      if or_call
-        @on_readable.call
-        :delete
-      else
-        :wait_readable
-      end
-    end
 
-    # we must watch the directory, since
-    def watch_files(paths, blk)
-      @watches = {} # wd -> { basename -> true }
-      @on_readable = blk
-      @dir2wd = {}
-      Array(paths).each do |path|
-        watchdir, watchbase = File.split(File.expand_path(path))
-        begin
-          wd = @dir2wd[watchdir] ||= add_watch(watchdir, FLAGS)
-          m = @watches[wd] ||= {}
-          m[watchbase] = true
-        rescue SystemCallError => e
-          warn "#{watchdir.dump}: #{e.message} (#{e.class})"
+      # we must watch the directory, since
+      def watch_files(paths, blk)
+        @watches = {} # wd -> { basename -> true }
+        @on_readable = blk
+        @dir2wd = {}
+        Array(paths).each do |path|
+          watchdir, watchbase = File.split(File.expand_path(path))
+          begin
+            wd = @dir2wd[watchdir] ||= add_watch(watchdir, FLAGS)
+            m = @watches[wd] ||= {}
+            m[watchbase] = true
+          rescue SystemCallError => e
+            warn "#{watchdir.dump}: #{e.message} (#{e.class})"
+          end
         end
       end
-    end
-  end
+    end # module InotifyCommon
 
-  def watch_begin(blk)
-    @ino = InotifyReadableIter.new
-    @ino.watch_files(@watch_extra << @infile, blk)
-    @ino
-  end
+    begin
+      require_relative 'watchable/inotify'
+    rescue LoadError
+      # TODO: support kevent
+      require_relative 'watchable/fiddle_ino'
+    end
 
-  def watch_extra(paths)
-    @ino.watch_extra(paths)
-  end
+    def watch_begin(blk)
+      @ino = DTAS::Watchable::InotifyReadableIter.new
+      @ino.watch_files(@watch_extra << @infile, blk)
+      @ino
+    end
 
-  # Closing the inotify descriptor (instead of using inotify_rm_watch)
-  # is cleaner because it avoids EINVAL on race conditions in case
-  # a directory is deleted: https://lkml.org/lkml/2007/7/9/3
-  def watch_end(srv)
-    srv.wait_ctl(@ino, :delete)
-    @ino = @ino.close
-  end
-end
+    def watch_extra(paths)
+      @ino.watch_extra(paths)
+    end
 
-rescue LoadError
-end
+    # Closing the inotify descriptor (instead of using inotify_rm_watch)
+    # is cleaner because it avoids EINVAL on race conditions in case
+    # a directory is deleted: https://lkml.org/lkml/2007/7/9/3
+    def watch_end(srv)
+      srv.wait_ctl(@ino, :delete)
+      @ino = @ino.close
+    end
+  end # module DTAS::Watchable
+rescue LoadError, StandardError => e
+  warn "#{e.message} (#{e.class})"
+end # begin
diff --git a/lib/dtas/watchable/fiddle_ino.rb b/lib/dtas/watchable/fiddle_ino.rb
new file mode 100644
index 0000000..3ec72a1
--- /dev/null
+++ b/lib/dtas/watchable/fiddle_ino.rb
@@ -0,0 +1,78 @@
+# 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 'fiddle'
+
+# used to restart DTAS::Source::SplitFX processing in dtas-player
+# if the YAML file is edited
+class DTAS::Watchable::InotifyReadableIter # :nodoc:
+  include DTAS::Watchable::InotifyCommon
+
+  Inotify_init = Fiddle::Function.new(DTAS.libc['inotify_init1'],
+    [ Fiddle::TYPE_INT ],
+    Fiddle::TYPE_INT)
+
+  Inotify_add_watch = Fiddle::Function.new(DTAS.libc['inotify_add_watch'],
+    [ Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT ],
+    Fiddle::TYPE_INT)
+
+  # IO.select compatibility
+  attr_reader :to_io  #:nodoc:
+
+  def initialize # :nodoc:
+    fd = Inotify_init.call(02000000 | 04000) # CLOEXEC | NONBLOCK
+    raise "inotify_init failed: #{Fiddle.last_error}" if fd < 0
+    @to_io = IO.for_fd(fd)
+    @buf = ''.b
+    @q = []
+  end
+
+  # struct inotify_event {
+  #     int      wd;       /* Watch descriptor */
+  #     uint32_t mask;     /* Mask describing event */
+  #     uint32_t cookie;   /* Unique cookie associating related
+  #                           events (for rename(2)) */
+  #     uint32_t len;      /* Size of name field */
+  #     char     name[];   /* Optional null-terminated name */
+  InotifyEvent = Struct.new(:wd, :mask, :cookie, :len, :name) # :nodoc:
+
+  def take(nonblock) # :nodoc:
+    event = @q.pop and return event
+    case rv = @to_io.read_nonblock(16384, @buf, exception: false)
+    when :wait_readable, nil
+      return
+    else
+      until rv.empty?
+        hdr = rv.slice!(0,16)
+        name = nil
+        wd, mask, cookie, len = res = hdr.unpack('iIII')
+        wd && mask && cookie && len or
+          raise "bogus inotify_event #{res.inspect} hdr=#{hdr.inspect}"
+        if len > 0
+          name = rv.slice!(0, len)
+          name.size == len or raise "short name #{name.inspect} != #{len}"
+          name.sub!(/\0+\z/, '') or
+            raise "missing: `\\0', inotify_event.name=#{name.inspect}"
+          name = -name
+        end
+        ie = InotifyEvent.new(wd, mask, cookie, len, name)
+        if event
+          @q << ie
+        else
+          event = ie
+        end
+      end # /until rv.empty?
+      return event
+    end while true
+  end
+
+  def add_watch(watchdir, flags)
+    wd = Inotify_add_watch.call(@to_io.fileno, watchdir, flags)
+    raise "inotify_add_watch failed: #{Fiddle.last_error}" if wd < 0
+    wd
+  end
+
+  def close
+    @to_io = @to_io.close if @to_io
+  end
+end
diff --git a/lib/dtas/watchable/inotify.rb b/lib/dtas/watchable/inotify.rb
new file mode 100644
index 0000000..36b5746
--- /dev/null
+++ b/lib/dtas/watchable/inotify.rb
@@ -0,0 +1,13 @@
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
+# License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
+# frozen_string_literal: true
+require 'sleepy_penguin'
+
+# used to restart DTAS::Source::SplitFX processing in dtas-player
+# if the YAML file is edited
+class DTAS::Watchable::InotifyReadableIter < SleepyPenguin::Inotify # :nodoc:
+  include DTAS::Watchable::InotifyCommon
+  def self.new
+    super(:CLOEXEC)
+  end
+end
diff --git a/lib/dtas/writable_iter.rb b/lib/dtas/writable_iter.rb
index 24d1cee..caf6850 100644
--- a/lib/dtas/writable_iter.rb
+++ b/lib/dtas/writable_iter.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/lib/dtas/xs.rb b/lib/dtas/xs.rb
index 6836172..d4586a3 100644
--- a/lib/dtas/xs.rb
+++ b/lib/dtas/xs.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
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/perl/dtas-graph b/script/dtas-graph
index 9028303..d918351 100755
--- a/perl/dtas-graph
+++ b/script/dtas-graph
@@ -1,6 +1,10 @@
 #!/usr/bin/perl -w
-# Copyright (C) 2013-2016 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>
+#
+# Process visualizer which shows pipe connections between processes with
+# ASCII art.  Useful for displaying complex interations between different
+# processes in a non-traditional pipeline.
 use strict;
 use Graph::Easy; # for ASCII-art graphs
 $^O =~ /linux/ or print STDERR "$0 probably only works on Linux...\n";
@@ -123,7 +127,8 @@ foreach my $pid (sort { $a <=> $b } keys %pids) {
 print "\nPIPEID PIPE_INO\n";
 foreach my $pipe_id (sort { $a <=> $b } keys %graphed) {
         printf "% 6s", "|$pipe_id";
-        print " ", $graphed{$pipe_id}, "\n";
+        my $ino = $graphed{$pipe_id};
+        printf " %u (0x%0x)\n", $ino, $ino;
 }
 
 print $graph->as_ascii;
diff --git a/setup.rb b/setup.rb
index 9f0c826..4fe301d 100644
--- a/setup.rb
+++ b/setup.rb
@@ -281,7 +281,6 @@ class ConfigTable
     'site-ruby-common' => 'siteruby',     # For backward compatibility
     'site-ruby'        => 'siterubyver',  # For backward compatibility
     'bin-dir'          => 'bindir',
-    'bin-dir'          => 'bindir',
     'rb-dir'           => 'rbdir',
     'so-dir'           => 'sodir',
     'data-dir'         => 'datadir',
@@ -785,7 +784,7 @@ class ToplevelInstaller
     else
       require 'rbconfig'
     end
-    ::Config::CONFIG
+    ::RbConfig::CONFIG
   end
 
   def initialize(ardir_root, config)
diff --git a/test/covshow.rb b/test/covshow.rb
index df89037..95f8bfa 100644
--- a/test/covshow.rb
+++ b/test/covshow.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 #
diff --git a/test/helper.rb b/test/helper.rb
index 4031394..0a351e5 100644
--- a/test/helper.rb
+++ b/test/helper.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
 $stdout.sync = $stderr.sync = Thread.abort_on_exception = true
diff --git a/test/player_integration.rb b/test/player_integration.rb
index 2e81280..66d1c6e 100644
--- a/test/player_integration.rb
+++ b/test/player_integration.rb
@@ -1,11 +1,10 @@
-# Copyright (C) 2013-2016 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 './test/helper'
 require 'dtas/player'
 require 'dtas/state_file'
 require 'dtas/unix_client'
-require 'yaml'
 require 'tempfile'
 require 'shellwords'
 require 'timeout'
diff --git a/test/test_buffer.rb b/test/test_buffer.rb
index 8f5d8b5..a47e2d4 100644
--- a/test/test_buffer.rb
+++ b/test/test_buffer.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2019 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
@@ -11,7 +11,7 @@ class TestBuffer < Testcase
   @@max_size = nil if @@max_size == 0
 
   def teardown
-    @to_close.each { |io| io.close unless io.closed? }
+    @to_close.each(&:close)
   end
 
   def setup
@@ -49,20 +49,20 @@ class TestBuffer < Testcase
     buf = new_buffer
     buf.buffer_size = @@max_size
     assert_equal @@max_size, buf.buffer_size
-  end if defined?(SleepyPenguin::F_GETPIPE_SZ)
+  end if defined?(DTAS::Pipe::F_GETPIPE_SZ)
 
   def test_buffer_size
     buf = new_buffer
     assert_operator buf.buffer_size, :>, 128
     buf.buffer_size = @@max_size
     assert_equal @@max_size, buf.buffer_size
-  end if defined?(SleepyPenguin::F_GETPIPE_SZ)
+  end if defined?(DTAS::Pipe::F_GETPIPE_SZ)
 
   def test_broadcast_1
     buf = new_buffer
     r, w = IO.pipe
     buf.wr.write "HIHI"
-    assert_equal :wait_readable, buf.broadcast([w])
+    assert_equal [w], buf.broadcast([w])
     assert_equal 4, buf.bytes_xfer
     tmp = [w]
     r.close
@@ -108,7 +108,7 @@ class TestBuffer < Testcase
     assert_equal "HELLO", a[0].read(5)
     assert_equal "HELLO", b[0].read(5)
 
-    return unless defined?(SleepyPenguin::F_GETPIPE_SZ)
+    return unless defined?(DTAS::Pipe::F_GETPIPE_SZ)
 
     b[1].nonblock = true
     b[1].write('*' * pipe_size(b[1]))
@@ -167,7 +167,7 @@ class TestBuffer < Testcase
     buf.wr.write "HELLO"
     assert_equal tmp, buf.broadcast(tmp)
     assert_equal [a[1], b[1]], tmp
-  end if defined?(SleepyPenguin::F_GETPIPE_SZ)
+  end if defined?(DTAS::Pipe::F_GETPIPE_SZ)
 
   def test_serialize
     buf = new_buffer
@@ -206,6 +206,6 @@ class TestBuffer < Testcase
   end
 
   def pipe_size(io)
-    io.fcntl(SleepyPenguin::F_GETPIPE_SZ)
+    io.fcntl(DTAS::Pipe::F_GETPIPE_SZ)
   end
 end
diff --git a/test/test_encoding.rb b/test/test_encoding.rb
index b60ba36..5cd5da7 100644
--- a/test/test_encoding.rb
+++ b/test/test_encoding.rb
@@ -1,9 +1,8 @@
-# Copyright (C) 2018-2019 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 './test/helper'
 require 'dtas'
-require 'yaml'
 
 class TestEncoding < Testcase
   def test_encoding
@@ -13,7 +12,7 @@ comments:
   ARTIST: !binary |-
     RW5yaXF1ZSBSb2Ryw61ndWV6
 EOD
-    hash = YAML.load(data)
+    hash = DTAS.yaml_load(data)
     artist = DTAS.try_enc(hash['comments']['ARTIST'], Encoding::UTF_8)
     assert_equal 'Enrique Rodríguez', artist
   end
diff --git a/test/test_env.rb b/test/test_env.rb
index 4426be3..8520842 100644
--- a/test/test_env.rb
+++ b/test/test_env.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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 'helper'
diff --git a/test/test_fadefx.rb b/test/test_fadefx.rb
index fb36c21..481a5a5 100644
--- a/test/test_fadefx.rb
+++ b/test/test_fadefx.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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 'helper'
diff --git a/test/test_format.rb b/test/test_format.rb
index a2118df..81b67bf 100644
--- a/test/test_format.rb
+++ b/test/test_format.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/test/test_format_change.rb b/test/test_format_change.rb
index 73b4403..dc94f02 100644
--- a/test/test_format_change.rb
+++ b/test/test_format_change.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 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 './test/player_integration'
@@ -26,7 +26,7 @@ class TestFormatChange < Testcase
 
       Timeout.timeout(len) do
         begin
-          cur = YAML.load(s.req("current"))
+          cur = DTAS.yaml_load(s.req("current"))
         end while cur["sinks"] && sleep(0.01)
       end
 
diff --git a/test/test_mcache.rb b/test/test_mcache.rb
index 6957021..983a69e 100644
--- a/test/test_mcache.rb
+++ b/test/test_mcache.rb
@@ -1,19 +1,29 @@
-# Copyright (C) 2016 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 './test/helper'
 require 'dtas/mcache'
+require 'tempfile'
 
 class TestMcache < Testcase
   def test_mcache
+    tmp = Tempfile.new(%W(tmp .sox))
+    fn = tmp.path
+    cmd = %W(sox -r 44100 -b 16 -c 2 -n #{fn} trim 0 1)
+    system(*cmd) or skip
     mc = DTAS::Mcache.new
     exist = nil
-    mc.lookup('hello') { |infile, hash| exist = hash }
+    mc.lookup(fn) { |infile, hash|
+      hash[:ctime] = File.stat(infile).ctime
+      exist = hash
+    }
     assert_kind_of Hash, exist
-    assert_equal 'hello', exist[:infile]
+    assert_equal fn, exist[:infile]
     assert_operator exist[:btime], :<=, DTAS.now
-    assert_same exist, mc.lookup('hello')
+    assert_same exist, mc.lookup(fn)
     assert_nil mc.lookup('HELLO')
-    assert_same exist, mc.lookup('hello'), 'no change after miss'
+    assert_same exist, mc.lookup(fn), 'no change after miss'
+  ensure
+    tmp.close!
   end
 end
diff --git a/test/test_mlib.rb b/test/test_mlib.rb
index b16ea15..75c07f2 100644
--- a/test/test_mlib.rb
+++ b/test/test_mlib.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2019 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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 'helper'
diff --git a/test/test_parse_freq.rb b/test/test_parse_freq.rb
index 7fd5907..b84c131 100644
--- a/test/test_parse_freq.rb
+++ b/test/test_parse_freq.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2015-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2015-2020 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'
diff --git a/test/test_pipeline.rb b/test/test_pipeline.rb
index f235410..19cf2b0 100644
--- a/test/test_pipeline.rb
+++ b/test/test_pipeline.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2017-2019 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2017-2020 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'
diff --git a/test/test_player.rb b/test/test_player.rb
index 0c79b3b..d27bd89 100644
--- a/test/test_player.rb
+++ b/test/test_player.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/test/test_player_client_handler.rb b/test/test_player_client_handler.rb
index d76eaeb..eee5e49 100644
--- a/test/test_player_client_handler.rb
+++ b/test/test_player_client_handler.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 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 './test/helper'
@@ -71,7 +71,7 @@ class TestPlayerClientHandler < Testcase
     @sinks["default"] = sink
     dpc_sink(@io, %W(cat default))
     assert_equal 1, @io.size
-    hsh = YAML.load(@io[0])
+    hsh = DTAS.yaml_load(@io[0])
     assert_kind_of Hash, hsh
     assert_equal "default", hsh["name"]
     assert_match("dither -s", hsh["command"])
diff --git a/test/test_player_integration.rb b/test/test_player_integration.rb
index 175ed00..09eceee 100644
--- a/test/test_player_integration.rb
+++ b/test/test_player_integration.rb
@@ -1,10 +1,9 @@
-# Copyright (C) 2013-2016 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 './test/player_integration'
 class TestPlayerIntegration < Testcase
   include PlayerIntegration
-  include DTAS::SpawnFix
 
   def test_cmd_rate
     env = ENV.to_hash.merge(@fmt.to_env)
@@ -140,7 +139,7 @@ class TestPlayerIntegration < Testcase
     Timeout.timeout(5) do
       begin
         yaml = s.req("current")
-        cur = YAML.load(yaml)
+        cur = DTAS.yaml_load(yaml)
       end while cur["sinks"] && sleep(0.01)
     end
 
@@ -167,7 +166,7 @@ class TestPlayerIntegration < Testcase
     Timeout.timeout(len) do
       begin
         yaml = s.req("current")
-        cur = YAML.load(yaml)
+        cur = DTAS.yaml_load(yaml)
       end while cur["sinks"] && sleep(0.01)
     end
     assert(system("cmp", dump.path, expect.path),
@@ -196,13 +195,13 @@ class TestPlayerIntegration < Testcase
     state.close!
     s = client_socket
     s.req_ok(%W(state dump #{state_path}))
-    hash = YAML.load(IO.binread(state_path))
+    hash = DTAS.yaml_load(IO.binread(state_path))
     assert_equal @sock_path, hash["socket"]
     assert_equal "default", hash["sinks"][0]["name"]
 
     assert_equal "", IO.binread(@state_tmp.path)
     s.req_ok(%W(state dump))
-    orig = YAML.load(IO.binread(@state_tmp.path))
+    orig = DTAS.yaml_load(IO.binread(@state_tmp.path))
     assert_equal orig, hash
   ensure
     File.unlink(state_path)
@@ -210,18 +209,18 @@ 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 = YAML.load(s.req("source cat sox"))
+    sox = DTAS.yaml_load(s.req("source cat sox"))
     assert_equal "true", sox["command"]
 
     s.req_ok("source ed sox command=")
-    sox = YAML.load(s.req("source cat sox"))
+    sox = DTAS.yaml_load(s.req("source cat sox"))
     assert_equal DTAS::Source::Sox::SOX_DEFAULTS["command"], sox["command"]
   end
 
diff --git a/test/test_process.rb b/test/test_process.rb
index 368d877..94abf20 100644
--- a/test/test_process.rb
+++ b/test/test_process.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/test/test_rg_integration.rb b/test/test_rg_integration.rb
index 29dd29b..f274272 100644
--- a/test/test_rg_integration.rb
+++ b/test/test_rg_integration.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 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 './test/player_integration'
@@ -39,7 +39,7 @@ class TestRgIntegration < Testcase
     yaml = cur = nil
     Timeout.timeout(5) do
       begin
-        cur = YAML.load(yaml = s.req("current"))
+        cur = DTAS.yaml_load(yaml = s.req("current"))
       end while cur["current_offset"] == 0 && sleep(0.01)
     end
 
@@ -55,7 +55,7 @@ class TestRgIntegration < Testcase
       Timeout.timeout(5) do
         begin
           yaml = s.req("current")
-          cur = YAML.load(yaml)
+          cur = DTAS.yaml_load(yaml)
         end while cur["current"]["env"]["RGFX"] !~ expect && sleep(0.01)
       end
       assert_match expect, cur["current"]["env"]["RGFX"]
@@ -67,27 +67,27 @@ class TestRgIntegration < Testcase
     check_gain.call(%r{gain 2\.5}, "track_peak")
 
     s.req_ok("rg preamp+=1")
-    rg = YAML.load(yaml = s.req("rg"))
+    rg = DTAS.yaml_load(yaml = s.req("rg"))
     assert_equal 1, rg["preamp"]
 
     s.req_ok("rg preamp-=1")
-    rg = YAML.load(yaml = s.req("rg"))
+    rg = DTAS.yaml_load(yaml = s.req("rg"))
     assert_nil rg["preamp"]
 
     s.req_ok("rg preamp=2")
-    rg = YAML.load(yaml = s.req("rg"))
+    rg = DTAS.yaml_load(yaml = s.req("rg"))
     assert_equal 2, rg["preamp"]
 
     s.req_ok("rg preamp-=0.3")
-    rg = YAML.load(yaml = s.req("rg"))
+    rg = DTAS.yaml_load(yaml = s.req("rg"))
     assert_equal 1.7, rg["preamp"]
 
     s.req_ok("rg preamp-=-0.3")
-    rg = YAML.load(yaml = s.req("rg"))
+    rg = DTAS.yaml_load(yaml = s.req("rg"))
     assert_equal 2.0, rg["preamp"]
 
     s.req_ok("rg preamp-=+0.3")
-    rg = YAML.load(yaml = s.req("rg"))
+    rg = DTAS.yaml_load(yaml = s.req("rg"))
     assert_equal 1.7, rg["preamp"]
 
     dethrottle_decoder(s)
diff --git a/test/test_rg_state.rb b/test/test_rg_state.rb
index 59061a2..729e7f7 100644
--- a/test/test_rg_state.rb
+++ b/test/test_rg_state.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/test/test_sigevent.rb b/test/test_sigevent.rb
new file mode 100644
index 0000000..f7e7385
--- /dev/null
+++ b/test/test_sigevent.rb
@@ -0,0 +1,20 @@
+# Copyright (C) 2019-2020 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 'helper'
+require 'dtas'
+require 'dtas/sigevent'
+
+class TestSigevent < Testcase
+  def test_sigevent
+    io = DTAS::Sigevent.new
+    io.signal
+    assert IO.select([io]), 'IO.select returns'
+    res = io.readable_iter do |f,arg|
+      assert_same io, f
+      assert_nil arg
+    end
+    assert_equal :wait_readable, res
+    assert_nil io.close
+  end
+end
diff --git a/test/test_sink.rb b/test/test_sink.rb
index 761ccb1..7214da6 100644
--- a/test/test_sink.rb
+++ b/test/test_sink.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 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 './test/helper'
@@ -27,6 +27,6 @@ class TestSink < Testcase
   def test_inactive_load
     orig = { "active" => false }.freeze
     tmp = orig.to_yaml
-    assert_equal orig, YAML.load(tmp)
+    assert_equal orig, DTAS.yaml_load(tmp)
   end
 end
diff --git a/test/test_sink_pipe_size.rb b/test/test_sink_pipe_size.rb
index 1b6db72..86a7f2c 100644
--- a/test/test_sink_pipe_size.rb
+++ b/test/test_sink_pipe_size.rb
@@ -1,20 +1,17 @@
-# Copyright (C) 2013-2019 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 all contributors <dtas-all@nongnu.org>
 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
 # frozen_string_literal: true
-begin
-  require 'sleepy_penguin'
-  require './test/player_integration'
-  class TestSinkPipeSizeIntegration < Testcase
-    include PlayerIntegration
+require './test/player_integration'
+class TestSinkPipeSizeIntegration < Testcase
+  include PlayerIntegration
 
-    def test_sink_pipe_size_integration
-      s = client_socket
-      default_sink_pid(s)
-      s.req_ok("sink ed default pipe_size=0x1000")
-      s.req_ok("sink ed default pipe_size=0x10000")
-      s.req_ok("sink ed default pipe_size=")
-      s.req_ok("sink ed default pipe_size=4096")
-    end if SleepyPenguin.const_defined?(:F_SETPIPE_SZ)
+  def test_sink_pipe_size_integration
+    s = client_socket
+    default_sink_pid(s)
+    s.req_ok("sink ed default pipe_size=0x1000")
+    s.req_ok("sink ed default pipe_size=0x10000")
+    s.req_ok("sink ed default pipe_size=")
+    s.req_ok("sink ed default pipe_size=4096")
   end
-rescue LoadError
-end
+end if RUBY_PLATFORM =~ /linux/i &&
+      File.readable?('/proc/sys/fs/pipe-max-size')
diff --git a/test/test_sink_tee_integration.rb b/test/test_sink_tee_integration.rb
index e130f42..3a44e7b 100644
--- a/test/test_sink_tee_integration.rb
+++ b/test/test_sink_tee_integration.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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/player_integration'
diff --git a/test/test_source_av.rb b/test/test_source_av.rb
index dddd4a6..3ee115e 100644
--- a/test/test_source_av.rb
+++ b/test/test_source_av.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
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_source_sox.rb b/test/test_source_sox.rb
index 8c994f1..6e091ee 100644
--- a/test/test_source_sox.rb
+++ b/test/test_source_sox.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
diff --git a/test/test_splitfx.rb b/test/test_splitfx.rb
index d952031..f2e0e09 100644
--- a/test/test_splitfx.rb
+++ b/test/test_splitfx.rb
@@ -1,14 +1,11 @@
-# Copyright (C) 2013-2016 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 'yaml'
 require 'dtas/splitfx'
 require 'thread'
 require_relative 'helper'
 
 class TestSplitfx < Testcase
-  include DTAS::SpawnFix
-
   def tmp_err(path)
     err = $stderr.dup
     $stderr.reopen(path, 'a')
@@ -41,7 +38,7 @@ class TestSplitfx < Testcase
   end
 
   def test_example
-    hash = YAML.load(File.read("examples/splitfx.sample.yml"))
+    hash = DTAS.yaml_load(File.read("examples/splitfx.sample.yml"))
     sfx = DTAS::SplitFX.new
     Dir.mktmpdir do |dir|
       Dir.chdir(dir) do
diff --git a/test/test_tfx.rb b/test/test_tfx.rb
index 5d77b9d..be68079 100644
--- a/test/test_tfx.rb
+++ b/test/test_tfx.rb
@@ -1,10 +1,9 @@
-# Copyright (C) 2013-2016 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 './test/helper'
 require 'dtas/tfx'
 require 'dtas/format'
-require 'yaml'
 
 class TestTFX < Testcase
   def rate
@@ -12,7 +11,7 @@ class TestTFX < Testcase
   end
 
   def test_example
-    ex = YAML.load(File.read("examples/tfx.sample.yml"))
+    ex = DTAS.yaml_load(File.read("examples/tfx.sample.yml"))
     effects = []
     ex["effects"].each do |line|
       words = Shellwords.split(line)
diff --git a/test/test_tracklist.rb b/test/test_tracklist.rb
index e98fa8c..b280390 100644
--- a/test/test_tracklist.rb
+++ b/test/test_tracklist.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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 'helper'
diff --git a/test/test_unixserver.rb b/test/test_unixserver.rb
index 64f71be..c91354d 100644
--- a/test/test_unixserver.rb
+++ b/test/test_unixserver.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'
@@ -21,7 +21,7 @@ class TestUNIXServer < Testcase
   end
 
   def teardown
-    @clients.each { |io| io.close unless io.closed? }
+    @clients.each(&:close)
     if File.exist?(@tmp.path)
       @tmp.close!
     else
@@ -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
diff --git a/test/test_util.rb b/test/test_util.rb
index f618511..66194cf 100644
--- a/test/test_util.rb
+++ b/test/test_util.rb
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2016 all contributors <dtas-all@nongnu.org>
+# Copyright (C) 2013-2020 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'