about summary refs log tree commit homepage
diff options
context:
space:
mode:
authorEric Wong <normalperson@yhbt.net>2013-08-24 09:54:45 +0000
committerEric Wong <normalperson@yhbt.net>2013-08-24 09:54:45 +0000
commit3e09ac0c10c95bb24a08af62393b4f761e2743d0 (patch)
tree778dffa2ba8798503fc047db0feef6d65426ea22
downloaddtas-3e09ac0c10c95bb24a08af62393b4f761e2743d0.tar.gz
-rw-r--r--.document2
-rw-r--r--.gitignore7
-rw-r--r--COPYRIGHT674
-rw-r--r--Documentation/.gitignore3
-rw-r--r--Documentation/GNUmakefile24
-rw-r--r--Documentation/dtas-enq.1.txt27
-rw-r--r--Documentation/dtas-player_effects.txt45
-rw-r--r--Documentation/dtas-player_protocol.7.txt77
-rw-r--r--Documentation/troubleshooting.txt13
-rwxr-xr-xGIT-VERSION-GEN30
-rw-r--r--GNUmakefile17
-rw-r--r--HACKING12
-rw-r--r--INSTALL29
-rw-r--r--README91
-rw-r--r--Rakefile61
-rw-r--r--TODO4
-rwxr-xr-xbin/dtas-console160
-rwxr-xr-xbin/dtas-ctl10
-rwxr-xr-xbin/dtas-cueedit78
-rwxr-xr-xbin/dtas-enq13
-rwxr-xr-xbin/dtas-graph129
-rwxr-xr-xbin/dtas-msinkctl51
-rwxr-xr-xbin/dtas-play-xover-delay85
-rwxr-xr-xbin/dtas-player34
-rwxr-xr-xbin/dtas-sinkedit46
-rwxr-xr-xbin/dtas-sourceedit39
-rw-r--r--examples/dtas_state.yml18
-rw-r--r--lib/dtas.rb8
-rw-r--r--lib/dtas/buffer.rb91
-rw-r--r--lib/dtas/buffer/read_write.rb103
-rw-r--r--lib/dtas/buffer/splice.rb143
-rw-r--r--lib/dtas/command.rb44
-rw-r--r--lib/dtas/compat_onenine.rb19
-rw-r--r--lib/dtas/disclaimer.rb14
-rw-r--r--lib/dtas/format.rb152
-rw-r--r--lib/dtas/pipe.rb40
-rw-r--r--lib/dtas/player.rb386
-rw-r--r--lib/dtas/player/client_handler.rb452
-rw-r--r--lib/dtas/process.rb88
-rw-r--r--lib/dtas/replaygain.rb42
-rw-r--r--lib/dtas/rg_state.rb100
-rw-r--r--lib/dtas/serialize.rb10
-rw-r--r--lib/dtas/sigevent.rb11
-rw-r--r--lib/dtas/sigevent/efd.rb21
-rw-r--r--lib/dtas/sigevent/pipe.rb29
-rw-r--r--lib/dtas/sink.rb122
-rw-r--r--lib/dtas/sink_reader_play.rb70
-rw-r--r--lib/dtas/source.rb148
-rw-r--r--lib/dtas/source/command.rb41
-rw-r--r--lib/dtas/source/common.rb14
-rw-r--r--lib/dtas/source/mp3.rb38
-rw-r--r--lib/dtas/state_file.rb34
-rw-r--r--lib/dtas/unix_accepted.rb77
-rw-r--r--lib/dtas/unix_client.rb52
-rw-r--r--lib/dtas/unix_server.rb111
-rw-r--r--lib/dtas/util.rb16
-rw-r--r--lib/dtas/writable_iter.rb23
-rw-r--r--pkg.mk176
-rw-r--r--setup.rb1586
-rw-r--r--test/covshow.rb30
-rw-r--r--test/helper.rb76
-rw-r--r--test/player_integration.rb121
-rw-r--r--test/test_buffer.rb216
-rw-r--r--test/test_format.rb61
-rw-r--r--test/test_format_change.rb49
-rw-r--r--test/test_player.rb37
-rw-r--r--test/test_player_client_handler.rb86
-rw-r--r--test/test_player_integration.rb199
-rw-r--r--test/test_rg_integration.rb117
-rw-r--r--test/test_rg_state.rb32
-rw-r--r--test/test_sink.rb32
-rw-r--r--test/test_sink_reader_play.rb49
-rw-r--r--test/test_sink_tee_integration.rb34
-rw-r--r--test/test_source.rb102
-rw-r--r--test/test_unixserver.rb66
-rw-r--r--test/test_util.rb15
76 files changed, 7362 insertions, 0 deletions
diff --git a/.document b/.document
new file mode 100644
index 0000000..50bd824
--- /dev/null
+++ b/.document
@@ -0,0 +1,2 @@
+NEWS
+README
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a0f864e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+/Manifest.txt
+/NEWS
+/pkg
+/lib/dtas/version.rb
+/coverage.dump
+*.tar.gz
+*.log
diff --git a/COPYRIGHT b/COPYRIGHT
new file mode 100644
index 0000000..94a9ed0
--- /dev/null
+++ b/COPYRIGHT
@@ -0,0 +1,674 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Use with the GNU Affero General Public License.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    <program>  Copyright (C) <year>  <name of author>
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<http://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<http://www.gnu.org/philosophy/why-not-lgpl.html>.
diff --git a/Documentation/.gitignore b/Documentation/.gitignore
new file mode 100644
index 0000000..56fdc90
--- /dev/null
+++ b/Documentation/.gitignore
@@ -0,0 +1,3 @@
+*.1
+*.5
+*.7
diff --git a/Documentation/GNUmakefile b/Documentation/GNUmakefile
new file mode 100644
index 0000000..0353ec7
--- /dev/null
+++ b/Documentation/GNUmakefile
@@ -0,0 +1,24 @@
+all::
+
+PANDOC = pandoc
+PANDOC_OPTS = -f markdown --email-obfuscation=none
+pandoc = $(PANDOC) $(PANDOC_OPTS)
+
+man1 := $(addsuffix .1,dtas-enq)
+man7 := $(addsuffix .7,dtas-player_protocol)
+
+all:: man
+
+man: $(man1) $(man7)
+
+install-man: man
+        mkdir -p ../man/man1
+        install -m 644 $(man1) ../man/man1
+
+%.1: %.1.txt
+        $(pandoc) -s -t man < $< > $@+ && mv $@+ $@
+%.7: %.7.txt
+        $(pandoc) -s -t man < $< > $@+ && mv $@+ $@
+
+clean::
+        $(RM) $(man1)
diff --git a/Documentation/dtas-enq.1.txt b/Documentation/dtas-enq.1.txt
new file mode 100644
index 0000000..0e36bb5
--- /dev/null
+++ b/Documentation/dtas-enq.1.txt
@@ -0,0 +1,27 @@
+% dtas-enq(1) dtas user manual
+%
+
+# NAME
+
+dtas-enq - enqueue audio files for playback with dtas-player
+
+# SYNOPSYS
+
+dtas-enq [FILE...]
+
+# DESCRIPTION
+
+dtas-enq will enqueue a list of files given on the command-line to a
+running instance of dtas-player.  dtas-player will start playing the
+newly enqueued files in the order given.
+
+# ENVIRONMENT
+
+DTAS_PLAYER_SOCK - the path to the dtas-player listen socket.
+This defaults to ~/.dtas/player.sock
+
+# SEE ALSO
+
+* dtas-player(1)
+* dtas-player(7)
+* dtas-ctl(1)
diff --git a/Documentation/dtas-player_effects.txt b/Documentation/dtas-player_effects.txt
new file mode 100644
index 0000000..b6aab2b
--- /dev/null
+++ b/Documentation/dtas-player_effects.txt
@@ -0,0 +1,45 @@
+Effects in dtas-player may be applied either at the source or the sink.
+They are applied in the order described.
+
+1. source effects
+
+  Source effects are effects which should be applied per-source and do not
+  rely on inter-track information.
+
+  Examples include:
+  - ReplayGain (simple vol/gain changes)
+  - anything which does not change the length of the audio:
+    vol, stereo, highpass, lowpass, loudness, bass, treble, equalizer, ...
+
+  Modifying source effects should introduce no extra gaps in playback.
+  Effects which modify the length of the audio is not recommended here,
+  as seek functionality will be impaired.
+
+2. sink effects
+
+  Sink effects are any effects which:
+
+  1) should only be applied to a specific sink
+  2) effects which require inter-track information
+     (multiband delays/compressors/expanders)
+  3) alter the length of the audio
+
+  In a multi-zone audio system (where each zone has its own sink), sink
+  effects may also customize the sound of a certain zone while not
+  affecting others.
+
+  Examples include:
+  - equalizer effects (highpass/bass/treble/equalizer)
+  - loudness
+  - delaying a certain channel or frequency range for time-alignment
+  - compressors/limiters
+  - reverb
+  - vol
+  - remix (for stereo image adjustments)
+
+  Additionally, effects which are necessary due to the limitation of the
+  playback hardware are applied at the sink:
+
+  - rate
+  - dither
+  - remix (static channel mappings)
diff --git a/Documentation/dtas-player_protocol.7.txt b/Documentation/dtas-player_protocol.7.txt
new file mode 100644
index 0000000..6af8fc4
--- /dev/null
+++ b/Documentation/dtas-player_protocol.7.txt
@@ -0,0 +1,77 @@
+% dtas-player_protocol(7) dtas user manual
+%
+
+# NAME
+        dtas-player_protocol - protocol for controlling dtas-player
+
+# ARGUMENT TYPES
+
+- BOOLEAN - must be "true" or "false"
+- INTEGER - a signed integer in decimal notation (base 10)
+- UNSIGNED - an unsigned integer in decimal or hex notation
+- ENVNAME - must be a suitable environment variable (setenv(3))
+- ENVVALUE - must be a suitable environment variable (setenv(3))
+- COMMAND, this may be quoted string passed to sh -c "",
+           variable/argument expansion will be performed by the shell
+- FILENAME - an expanded pathname relative to / is recommended since
+             dtas-player and the client may run in different directories
+
+# COMMANDS
+
+* enq FILENAME - enqueue the given FILENAME for playback
+  An expanded (full) pathname relative to '/' is recommended, as
+  dtas-player and the client may be in different directories.
+
+* pause - pause playback
+
+* play - restart playback from pause.  Playback sinks will yield
+  control of the audio playback device once no source is playing.
+
+* play_pause - toggle the play/pause state.  This starts playback if
+  paused, and pauses playback if playing.
+
+* restart - restarts all processes in the current pipeline.  Playback
+  will be momentarily interrupted while this change occurs.  This may
+  be necess
+
+* seek HH:MM:SS.FRAC - seek the current track to a specified time.
+  This is passed directly as the first argument for the sox(1) "trim"
+  command.   See the sox(1) manpage for details.
+
+* enq-cmd "COMMAND" - run the following command for playback.
+  The COMMAND is expected to output audio in the format compatible with
+  the current audio format of the player.  This may be a shell pipeline
+  and include multiple commands.
+
+* clear - clear current queue (current song continues playing)
+
+* skip - abort current track
+  Running the "clear" command before this will abort playback.
+
+* sink ls - list names of current sinks
+* sink cat SINKNAME - dump SINKNAME config in YAML
+* sink rm SINKNAME - remove SINKNAME
+* sink ed SINKNAME SINKARGS - create/edit SINKNAME
+
+  SINKARGS:
+    command=COMMAND
+    active=BOOLEAN
+    env.ENVNAME=ENVVALUE
+    prio=INTEGER
+    nonblock=BOOLEAN
+    pipe_size=UNSIGNED
+
+* env ENVTOSET=ENVVALUE ENVTOUNSET1# ENVTOSET2=ENVVALUE2
+
+ReplayGain state
+----------------
+
+* rg.mode=(album_gain|track_gain|track_norm|album_norm|off)
+
+* rg.preamp=DB_VALUE  (0)
+
+* rg.fallback_gain=DB_VALUE (-6.0)
+
+* rg.fallback_track=BOOLEAN (true)
+
+* rg.norm_level=FLOAT (1.0 == dBFS)
diff --git a/Documentation/troubleshooting.txt b/Documentation/troubleshooting.txt
new file mode 100644
index 0000000..e46b873
--- /dev/null
+++ b/Documentation/troubleshooting.txt
@@ -0,0 +1,13 @@
+dtas-player troubleshooting guide
+---------------------------------
+
+dtas-player is heavily dependent on external commands such as sox(1)/play(1)
+and ecasound(1).
+
+* problem: audio playback does not start
+
+  Since dtas-player just runs the play(1) command, the first step is to
+  ensure play(1) works without dtas-player.
+
+  Consult SoX documentation and mailing lists for getting play(1) to work,
+  first.
diff --git a/GIT-VERSION-GEN b/GIT-VERSION-GEN
new file mode 100755
index 0000000..f690dcd
--- /dev/null
+++ b/GIT-VERSION-GEN
@@ -0,0 +1,30 @@
+#!/usr/bin/env ruby
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+CONSTANT = "DTAS::VERSION"
+RVF = "lib/dtas/version.rb"
+DEF_VER = "v0.0.0"
+vn = DEF_VER
+
+# First see if there is a version file (included in release tarballs),
+# then try git-describe, then default.
+if File.exist?(".git")
+  describe = `git describe --abbrev=4 HEAD 2>/dev/null`.strip
+  case describe
+  when /\Av[0-9]*/
+    vn = describe
+    system(*%w(git update-index -q --refresh))
+    unless `git diff-index --name-only HEAD --`.chomp.empty?
+      vn << "-dirty"
+    end
+    vn.tr!('-', '.')
+  end
+end
+
+vn = vn.sub!(/\Av/, "")
+new_ruby_version = "#{CONSTANT} = '#{vn}'\n"
+cur_ruby_version = File.read(RVF) rescue nil
+if new_ruby_version != cur_ruby_version
+  File.open(RVF, "w") { |fp| fp.write(new_ruby_version) }
+end
+puts vn if $0 == __FILE__
diff --git a/GNUmakefile b/GNUmakefile
new file mode 100644
index 0000000..a0f83f5
--- /dev/null
+++ b/GNUmakefile
@@ -0,0 +1,17 @@
+all::
+RSYNC_DEST := 80x24.org:/srv/dtas/
+rfproject := dtas
+rfpackage := dtas
+include pkg.mk
+
+check: test
+coverage: export COVERAGE=1
+coverage:
+        > coverage.dump
+        $(MAKE) check
+        $(RUBY) ./test/covshow.rb
+
+RSYNC = rsync --exclude '*.html' --exclude '*.html.gz' \
+        --exclude images --exclude '*.css' --exclude '*.css.gz' \
+        --exclude created.* \
+        --exclude '*.ri' --exclude '*.ri.gz' --exclude ri
diff --git a/HACKING b/HACKING
new file mode 100644
index 0000000..88b675b
--- /dev/null
+++ b/HACKING
@@ -0,0 +1,12 @@
+serialization (dtas-player)
+---------------------------
+
+* objects serialize using the "to_hsh" method (like "to_hash", but omits
+  default values) and then to YAML.  We avoid exposing the fact we use
+  Ruby (or any programming language) in any formats.
+
+* every serializable class defines a "load" singleton method which takes the
+  output Hash of "to_hsh"
+
+* we avoid serializing default values to make the state file shorter and
+  more suitable for human viewing and editing.
diff --git a/INSTALL b/INSTALL
new file mode 100644
index 0000000..433e2b6
--- /dev/null
+++ b/INSTALL
@@ -0,0 +1,29 @@
+Uncommon for audio software, dtas is implemented in Ruby.
+
+The latest stable release of Ruby is recommended, currently 2.0.0-p247.
+We are currently NOT compatible with Ruby 1.9.3, but we may support it
+if there is demand.
+
+SoX is a dependency of dtas.  While not _strictly_ required, the
+dtas-player uses SoX by default and you will need it unless you've
+reconfigured dtas to use something else.
+
+mp3gain is required if you use ReplayGain with MP3s, and metaflac is
+required for dtas-cueedit (and possibly future scripts).
+
+Debian users can install sox, mp3gain, and flac dependencies easily:
+
+  apt-get install sox libsox-fmt-all mp3gain flac
+
+= 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 io_splice and sleepy_penguin RubyGems for minor
+  speedups.  If you cannot be bothered to have a development
+  environment, just use "gem install dtas"
+
+  gem install dtas-linux
+
+= installing dtas RubyGem on non-GNU/Linux or old GNU/Linux systems
+
+  gem install dtas
diff --git a/README b/README
new file mode 100644
index 0000000..7e12c1a
--- /dev/null
+++ b/README
@@ -0,0 +1,91 @@
+= dtas - duct tape audio suite for *nix
+
+Free Software command-line tools for audio playback, mastering, and
+whatever else related to audio.  dtas follows the worse-is-better
+philosophy and acts as duct tape to combine existing command-line tools
+for flexibility and ease-of-development.  dtas is currently implemented
+in Ruby (and some embedded shell), but may use other languages in the
+future.
+
+Primary executables available are:
+
+* dtas-player - gapless music player (or pipeline/process manager :P)
+* dtas-cueedit - embedded cuesheet editor (FLAC-only for now)
+
+The centerpiece is dtas-player, a gapless music player designed to aid
+in writing scripts for sox/ecasound use.  Unlike monolithic music
+players, dtas-player is close to a *nix shell in functionality, allowing
+for the execution of arbitrary commands as sources, filters, and sinks
+for audio.  dtas-player supports:
+
+* any DSP effects offered by SoX, ecasound, LADSPA, LV2, etc..
+* multiple outputs for playback (including dumping audio to
+  files or piping to arbitrary commands)
+* 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
+commands.  It can interactively restart/replace the source (audio
+decoder) component of a pipeline while keeping the sink (playback
+endpoint) running.
+
+Users of dtas-player will also be interested in the following scripts:
+
+* dtas-ctl - "raw" command-line scripting interface for dtas-player
+* dtas-ps - process viewer for dtas-player
+* dtas-enq - enqueue files/commands for dtas-player
+* dtas-sinkedit - edit sinks (playback targets) for dtas-player
+* dtas-play-xover-delay - alternative sink for dtas-player
+
+Coming:
+
+* MPRIS/MPRIS 2.0 bridge for partial dtas-player control
+* tracklist support in dtas-player (maybe?)
+* whatever command-line tools come to mind...
+* native ffmpeg/avconv/gst support in dtas-player
+* better error handling, many bugfixes, etc...
+* better documentation
+
+== Contact
+
+Feedback (results, bug reports, patches, pull-requests) via plain-text
+email is very much appreciated.
+
+Please send plain-text email to Eric Wong <normalperson@yhbt.net>,
+HTML will not be read.  dtas is for GUI-phobes, by GUI-phobes.
+Public mailing list coming soon.
+
+Please use git-format-patch(1) and git-send-email(1) distributed with
+the git(7) suite for generating and sending patches.  Please format
+pull requests with the git-request-pull(1) script (also distributed
+with git(7)) and send them via email.
+
+See http://www.git-scm.com/ for more information on git.
+
+== License
+
+dtas is copyrighted Free Software by all contributors, see logs
+in revision control for names and email addresses of all of them.
+
+dtas is free software; you can redistribute it and/or modify it
+under the terms of the GNU General Public License as published by the
+Free Software Foundation; either version 3 of the License, or (at your
+option) any later version.
+
+dtas is distributed in the hope that it will be useful, but WITHOUT ANY
+WARRANTY; without even the implied warranty of MERCHANTABILITY or
+FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+for more details.
+
+You should have received a copy of the GNU General Public License along
+with this program; if not, see https://www.gnu.org/licenses/gpl-3.0.txt
+
+dtas is distributed in the hope that it will be useful, but WITHOUT ANY
+WARRANTY; without even the implied warranty of MERCHANTABILITY or
+FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+for more details.
+
+Note: The GPL does not and can not apply to external commands run by
+dtas scripts, so users _may_ run any non-Free Software you want via dtas
+(just like one may do so via bash).  However, the dtas project does not
+endorse nor support the use of any non-Free Software.
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..a4118bf
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,61 @@
+load "./GIT-VERSION-GEN"
+manifest = "Manifest.txt"
+gitidx = File.stat(".git/index") rescue nil
+if ! File.exist?(manifest) || File.stat(manifest).mtime < gitidx.mtime
+  system("git ls-files > #{manifest}")
+  File.open(manifest, "a") do |fp|
+    fp.puts "NEWS"
+    fp.puts "lib/dtas/version.rb"
+  end
+  File.open("NEWS", "w") do |fp|
+    `git tag -l`.split(/\n/).each do |tag|
+      %r{\Av([\d\.]+)} =~ tag or next
+      version = $1
+      header, subject, body = `git cat-file tag #{tag}`.split(/\n\n/, 3)
+      header = header.split(/\n/)
+      tagger = header.grep(/\Atagger /)[0]
+      time = Time.at(tagger.split(/ /)[-2].to_i).utc
+      date = time.strftime("%Y-%m-%d")
+
+      fp.write("=== #{version} / #{date}\n\n#{subject}\n\n#{body}")
+    end
+    fp.flush
+    if fp.size <= 5
+      fp.puts "Unreleased"
+    end
+  end
+end
+
+require 'hoe'
+Hoe.plugin :git
+include Rake::DSL
+
+Hoe.spec('dtas') do |p|
+  developer 'Eric Wong', 'normalperson@yhbt.net'
+
+  self.readme_file = 'README'
+  self.history_file = 'NEWS'
+  self.urls = %w(http://dtas.80x24.org/)
+  self.summary = x = File.readlines("README")[0].split(/\s+/)[1].chomp
+  self.description = self.paragraphs_of("README", 1)
+  license "GPLv2+"
+
+  dependency 'io_splice', '~> 4.2.0'
+end
+
+task :publish_docs do
+  dest = "80x24.org:/srv/dtas/"
+  system("rsync", "--files-from=.document", "-av", "#{Dir.pwd}/", dest)
+end
+
+task :coverage do
+  env = {
+    "COVERAGE" => "1",
+    "RUBYOPT" => "-r./test/helper",
+  }
+  File.open("coverage.dump", "w").close # clear
+  pid = Process.spawn(env, "rake")
+  _, status = Process.waitpid2(pid)
+  require './test/covshow'
+  exit status.exitstatus
+end
diff --git a/TODO b/TODO
new file mode 100644
index 0000000..ff966e3
--- /dev/null
+++ b/TODO
@@ -0,0 +1,4 @@
+* _limited_ frontends for MPRIS/mpd/whatever-standards-are-in-use-today
+  Obviously we can't have untrusted users executing arbitrary commands
+
+* tests for bin/*
diff --git a/bin/dtas-console b/bin/dtas-console
new file mode 100755
index 0000000..3693c99
--- /dev/null
+++ b/bin/dtas-console
@@ -0,0 +1,160 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+#
+# Note: no idea what I'm doing, especially w.r.t. curses
+require 'dtas/unix_client'
+require 'curses'
+require 'yaml'
+
+w = DTAS::UNIXClient.new
+w.req_ok('watch')
+c = DTAS::UNIXClient.new
+cur = YAML.load(c.req('current'))
+readable = [ w, $stdin ]
+
+def update_tfmt(prec)
+  prec == 0 ? '%H:%M:%S' : "%H:%M:%S.%#{prec}N"
+end
+trap(:INT) { exit(0) }
+trap(:TERM) { exit(0) }
+
+# time precision
+prec_nr = 1
+prec_step = (0..9).to_a
+prec_max = prec_step.size - 1
+tfmt = update_tfmt(prec_step[prec_nr])
+events = []
+interval = 1.0 / 10 ** prec_nr
+
+def show_events(lineno, screen, events)
+  Curses.setpos(lineno += 1, 0)
+  Curses.clrtoeol
+  Curses.addstr('Events:')
+  maxy = screen.maxy - 1
+  maxx = screen.maxx
+  events.reverse_each do |e|
+    Curses.setpos(lineno += 1, 0)
+    Curses.clrtoeol
+    extra = e.size/maxx
+    break if (lineno + extra) >= maxy
+
+    # deal with long lines
+    if extra
+      rewind = lineno
+      extra.times do
+        Curses.setpos(lineno += 1, 0)
+        Curses.clrtoeol
+      end
+      Curses.setpos(rewind, 0)
+      Curses.addstr(e)
+      Curses.setpos(lineno, 0)
+    else
+      Curses.addstr(e)
+    end
+  end
+
+  # discard events we can't show
+  nr_events = events.size
+  if nr_events > maxy
+    events = events[(nr_events - maxy)..-1]
+    until lineno >= screen.maxy
+      Curses.setpos(lineno += 1, 0)
+      Curses.clrtoeol
+    end
+  else
+    Curses.setpos(maxy + 1, 0)
+    Curses.clrtoeol
+  end
+end
+
+begin
+  Curses.init_screen
+  Curses.nonl
+  Curses.cbreak
+  Curses.noecho
+  screen = Curses.stdscr
+  screen.scrollok(true)
+  screen.keypad(true)
+  loop do
+    lineno = -1
+    if current = cur['current']
+      Curses.setpos(lineno += 1, 0)
+      Curses.clrtoeol
+      Curses.addstr(current['infile'])
+
+      elapsed = Time.now.to_f - current['spawn_at']
+      if (nr = cur['current_initial']) && (current_format = current['format'])
+        rate = current_format['rate'].to_f
+        elapsed += nr / rate
+        total = " [#{Time.at(current['samples'] / rate).strftime(tfmt)}]"
+      else
+        total = ""
+      end
+
+      Curses.setpos(lineno += 1, 0)
+      Curses.clrtoeol
+      Curses.addstr("#{Time.at(elapsed).strftime(tfmt)}#{total}")
+    else
+      Curses.setpos(lineno += 1, 0)
+      Curses.clrtoeol
+      Curses.addstr(cur['paused'] ? 'paused' : 'idle')
+      Curses.setpos(lineno += 1, 0)
+      Curses.clrtoeol
+    end
+
+    show_events(lineno, screen, events)
+
+    Curses.refresh # draw and wait
+    r = IO.select(readable, nil, nil, current ? interval : nil) or next
+    r[0].each do |io|
+      case io
+      when w
+        event = w.res_wait
+        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'))
+      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 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")
+        # 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 " "
+          c.req("play_pause")
+        when "p" # lower precision of time display
+          if prec_nr >= 1
+            prec_nr -= 1
+            tfmt = update_tfmt(prec_step[prec_nr])
+            interval = 1.0 / 10 ** prec_nr
+          end
+        when "P" # increase precision of time display
+          if prec_nr < prec_max
+            prec_nr += 1
+            tfmt = update_tfmt(prec_step[prec_nr])
+            interval = 1.0 / 10 ** prec_nr
+          end
+        when 27 # TODO readline/edit mode?
+        else
+          Curses.setpos(screen.maxy - 1, 0)
+          Curses.clrtoeol
+          Curses.addstr("unknown key=#{key.inspect}")
+        end
+      end
+    end
+  end
+rescue EOFError
+  Curses.close_screen
+  abort "dtas-player exited"
+ensure
+  Curses.close_screen
+end
diff --git a/bin/dtas-ctl b/bin/dtas-ctl
new file mode 100755
index 0000000..c05dc28
--- /dev/null
+++ b/bin/dtas-ctl
@@ -0,0 +1,10 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'dtas/unix_client'
+
+# Unix paths are encoding agnostic
+ARGV.map! { |arg| arg.b }
+c = DTAS::UNIXClient.new
+puts c.req(ARGV)
diff --git a/bin/dtas-cueedit b/bin/dtas-cueedit
new file mode 100755
index 0000000..2a205ac
--- /dev/null
+++ b/bin/dtas-cueedit
@@ -0,0 +1,78 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'tempfile'
+require 'shellwords'
+usage = "Usage: #$0 FILENAME"
+editor = ENV["VISUAL"] || ENV["EDITOR"]
+ARGV.size > 0 or abort usage
+
+def err_msg(cmd, status)
+  err_cmd = cmd.map { |f| Shellwords.escape(f) }.join(' ')
+  "E: #{err_cmd} failed: #{status.inspect}"
+end
+
+def x!(*cmd)
+  system(*cmd) or abort err_msg(cmd, $?)
+end
+
+def tmpfile(file, suffix)
+  tmp = Tempfile.new([File.basename(file), suffix])
+  tmp.sync = true
+  tmp.binmode
+  tmp
+end
+
+ARGV.each do |file|
+  # Unix paths are encoding agnostic
+  file = file.b
+  file =~ /\.flac\z/i or warn "Unsupported suffix, assuming FLAC"
+  tmp = tmpfile(file, '.cue')
+  begin
+    # export the temporary file for the user to edit
+    if system(*%W(metaflac --export-cuesheet-to=#{tmp.path} #{file}))
+      remove_existing = true
+      backup = tmpfile(file, '.backup.cue')
+    else
+      remove_existing = false
+      backup = nil
+      tmp.puts 'FILE "dtas-cueedit.tmp.flac" FLAC'
+      tmp.puts '  TRACK 01 AUDIO'
+      tmp.puts '    INDEX 01 00:00:00'
+    end
+
+    # keep a backup, in case the user screws up the edit
+    original = File.binread(tmp.path)
+    backup.write(original) if backup
+
+    # user edits the file
+    x!("#{editor} #{tmp.path}")
+
+    # avoid an expensive update if the user didn't change anything
+    current = File.binread(tmp.path)
+    if current == original
+      $stderr.puts "tags for #{Shellwords.escape(file)} unchanged" if $DEBUG
+      next
+    end
+
+    # we must remove existing tags before importing again
+    if remove_existing
+      x!(*%W(metaflac --remove --block-type=CUESHEET #{file}))
+    end
+
+    # try to import the new file but restore from the original backup if the
+    # user wrote an improperly formatted cue sheet
+    cmd = %W(metaflac --import-cuesheet-from=#{tmp.path} #{file})
+    if ! system(*cmd) && backup
+      warn err_msg(cmd, $?)
+      warn "E: restoring original from backup"
+      x!(*%W(metaflac --import-cuesheet-from=#{backup.path} #{file}))
+      warn "E: backup cuesheet restored, #{Shellwords.escape(file)} unchanged"
+      exit(false)
+    end
+  ensure
+    tmp.close!
+    backup.close! if backup
+  end
+end
diff --git a/bin/dtas-enq b/bin/dtas-enq
new file mode 100755
index 0000000..e8ebd66
--- /dev/null
+++ b/bin/dtas-enq
@@ -0,0 +1,13 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'dtas/unix_client'
+c = DTAS::UNIXClient.new
+
+ARGV.each do |path|
+  # Unix paths are encoding agnostic
+  path = File.expand_path(path.b)
+  res = c.req_ok(%W(enq #{path}))
+  puts "#{path} #{res}"
+end
diff --git a/bin/dtas-graph b/bin/dtas-graph
new file mode 100755
index 0000000..a668d47
--- /dev/null
+++ b/bin/dtas-graph
@@ -0,0 +1,129 @@
+#!/usr/bin/perl -w
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+use strict;
+use Graph::Easy; # for ASCII-art graphs
+$^O =~ /linux/ or print STDERR "$0 probably only works on Linux...\n";
+scalar @ARGV or die "Usage: $0 PID [PID ...]";
+our $procfs = $ENV{PROCFS} || "/proc";
+my $cull_self_pipe = 1;
+
+# returns a list of PIDs which are children of the given PID
+sub children_of {
+        my ($ppid) = @_;
+        my %rv = map {
+                s/\A\s*//g;
+                s/\s*\z//g;
+                my ($pid, $cmd) = split(/\s+/, $_, 2);
+                $pid => $cmd;
+        } `ps h -o pid,cmd --ppid=$ppid`;
+        \%rv;
+}
+
+# pid => [ child pids ]
+my %pids;
+
+# pipe_ino => { r => [ [pid, fd], [pid, fd] ], w => [ [pid, fd], ... ] }
+my %pipes;
+
+# pid => argv
+my %cmds;
+
+my $pipe_nr = 0;
+# pipe_id -> pipe_ino (we use short pipe IDs to save space on small terms)
+my %graphed;
+
+my @to_scan = (@ARGV);
+
+sub cmd_of {
+        my ($pid) = @_;
+        my $cmd = `ps h -o cmd $pid`;
+        chomp $cmd;
+        $cmd;
+}
+
+while (my $pid = shift @to_scan) {
+        my $children = children_of($pid);
+        my @child_pids = keys %$children;
+        push @to_scan, @child_pids;
+        $pids{$pid} = \@child_pids;
+        foreach my $child (keys @child_pids) {
+                $cmds{$child} = $children->{$child};
+        }
+}
+
+# build up a hash of pipes and their connectivity to processes:
+#
+foreach my $pid (keys %pids) {
+        my @out = `lsof -p $pid`;
+        # output is like this:
+        # play    12739   ew    0r  FIFO    0,7      0t0 36924019 pipe
+        foreach my $l (@out) {
+                my @l = split(/\s+/, $l);
+                $l[4] eq "FIFO" or next;
+
+                my $fd = $l[3];
+                my $pipe_ino = $l[7];
+                my $info = $pipes{$pipe_ino} ||= { r => [], w => [] };
+                if ($fd =~ s/r\z//) {
+                        push @{$info->{r}}, [ $pid, $fd ];
+                } elsif ($fd =~ s/w\z//) {
+                        push @{$info->{w}}, [ $pid, $fd ];
+                }
+
+        }
+}
+
+my $graph = Graph::Easy->new();
+foreach my $pid (keys %pids) {
+        $graph->add_node($pid);
+}
+
+foreach my $pipe_ino (keys %pipes) {
+        my $info = $pipes{$pipe_ino};
+        my %pairs;
+        my $pipe_node;
+
+        foreach my $rw (qw(r w)) {
+                foreach my $pidfd (@{$info->{$rw}}) {
+                        my ($pid, $fd) = @$pidfd;
+                        my $pair = $pairs{$pid} ||= {};
+                        my $fds = $pair->{$rw} ||= [];
+                        push @$fds, $fd;
+                }
+        }
+        # use Data::Dumper;
+        # print Dumper(\%pairs);
+        my $nr_pids = scalar keys %pairs;
+
+        foreach my $pid (keys %pairs) {
+                my $pair = $pairs{$pid};
+                my $r = $pair->{r} || [];
+                my $w = $pair->{w} || [];
+                next if $cull_self_pipe && $nr_pids == 1 && @$r && @$w;
+
+                unless ($pipe_node) {
+                        my $pipe_id = $pipe_nr++;
+                        $graphed{$pipe_id} = $pipe_ino;
+                        $pipe_node = "|$pipe_id";
+                        $graph->add_node($pipe_node);
+                }
+
+                $graph->add_edge($pipe_node, $pid, join(',', @$r)) if @$r;
+                $graph->add_edge($pid, $pipe_node, join(',', @$w)) if @$w;
+        }
+}
+
+print "   PID COMMAND\n";
+foreach my $pid (sort { $a <=> $b } keys %pids) {
+        printf "% 6d", $pid;
+        print " ", $cmds{$pid} || cmd_of($pid), "\n";
+}
+
+print "\nPIPEID PIPE_INO\n";
+foreach my $pipe_id (sort { $a <=> $b } keys %graphed) {
+        printf "% 6s", "|$pipe_id";
+        print " ", $graphed{$pipe_id}, "\n";
+}
+
+print $graph->as_ascii;
diff --git a/bin/dtas-msinkctl b/bin/dtas-msinkctl
new file mode 100755
index 0000000..a3cb8ce
--- /dev/null
+++ b/bin/dtas-msinkctl
@@ -0,0 +1,51 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'yaml'
+require 'dtas/unix_client'
+usage = "#$0 <active-set|active-add|active-sub|nonblock|active> SINK"
+c = DTAS::UNIXClient.new
+action = ARGV.shift
+sink_args = ARGV
+
+buf = c.req("sink ls")
+abort(buf) if buf =~ /\AERR/
+player_sinks = buf.split(/ /)
+
+non_existent = sink_args - player_sinks
+non_existent[0] and
+  abort "non-existent sink(s): #{non_existent.join(' ')}"
+
+def activate_sinks(c, sink_names)
+  sink_names.each { |name| c.req_ok("sink ed #{name} active=true") }
+end
+
+def deactivate_sinks(c, sink_names)
+  sink_names.each { |name| c.req_ok("sink ed #{name} active=false") }
+end
+
+def filter(c, player_sinks, key)
+  rv = []
+  player_sinks.each do |name|
+    buf = c.req("sink cat #{name}")
+    sink = YAML.load(buf)
+    rv << sink["name"] if sink[key]
+  end
+  rv
+end
+
+case action
+when "active-set"
+  activate_sinks(c, sink_args)
+  deactivate_sinks(c, player_sinks - sink_args)
+when "active-add" # idempotent
+  activate_sinks(c, sink_args)
+when "active-sub"
+  deactivate_sinks(c, sink_args)
+when "active", "nonblock"
+  abort "`#$0 #{action}' takes no arguments" if sink_args[0]
+  puts filter(c, player_sinks, action).join(' ')
+else
+  abort usage
+end
diff --git a/bin/dtas-play-xover-delay b/bin/dtas-play-xover-delay
new file mode 100755
index 0000000..d5b6533
--- /dev/null
+++ b/bin/dtas-play-xover-delay
@@ -0,0 +1,85 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+USAGE = "Usage: #$0 [-x FREQ] [-l] /dev/fd/LO /dev/fd/HI DELAY [DELAY ...]"
+require 'optparse'
+dryrun = false
+xover = '80'
+delay_lo = []
+delay_hi = []
+adj_delay = delay_hi
+out_channels = out_rate = out_type = nil
+
+lowpass = 'lowpass %s lowpass %s'
+highpass = 'highpass %s highpass %s'
+
+op = OptionParser.new('', 24, '  ') do |opts|
+  opts.banner = USAGE
+  opts.on('-x', '--crossover-frequency FREQ') do |freq|
+    xover = freq
+  end
+  opts.on('-l', '--lowpass-delay') { adj_delay = delay_lo }
+  opts.on('-c', '--channels INTEGER') { |val| out_channels = val }
+  opts.on('-r', '--rate RATE') { |val| out_rate = val }
+  opts.on('-t', '--type FILE-TYPE') { |val| out_type = val }
+  opts.on('-n', '--dry-run') { dryrun = true }
+  opts.on('--lowpass FORMAT_STRING') { |s| lowpass = s }
+  opts.on('--highpass FORMAT_STRING') { |s| highpass = s }
+  opts.parse!(ARGV)
+end
+
+dev_fd_lo = ARGV.shift
+dev_fd_hi = ARGV.shift
+if ARGV.delete('-')
+  # we re-add the '-' below
+  out_channels && out_rate && out_type or
+    abort "-c, -r, and -t must all be specified for standard output"
+  cmd = "sox"
+elsif out_channels || out_rate || out_type
+  abort "standard output (`-') must be specified with -c, -r, or -t"
+else
+  cmd = "play"
+end
+soxfmt = ENV["SOXFMT"] or abort "#$0 SOXFMT undefined"
+
+# configure the sox "delay" effect
+delay = ARGV.dup
+delay[0] or abort USAGE
+channels = ENV['CHANNELS'] or abort "#$0 CHANNELS env must be set"
+channels = channels.to_i
+adj_delay.replace(delay.dup)
+until adj_delay.size == channels
+  adj_delay << delay.last
+end
+adj_delay.unshift("delay")
+
+# prepare two inputs:
+delay_lo = delay_lo.join(' ')
+delay_hi = delay_hi.join(' ')
+
+lowpass_args = []
+lowpass.gsub('%s') { |s| lowpass_args << xover; s }
+highpass_args = []
+highpass.gsub('%s') { |s| highpass_args << xover; s }
+
+lo = "|exec sox #{soxfmt} #{dev_fd_lo} -p " \
+     "#{sprintf(lowpass, *lowpass_args)} #{delay_lo}".strip
+hi = "|exec sox #{soxfmt} #{dev_fd_hi} -p " \
+     "#{sprintf(highpass, *highpass_args)} #{delay_hi}".strip
+
+args = [ "-m", "-v1", lo, "-v1", hi ]
+case cmd
+when "sox"
+  args.unshift "sox"
+  args.concat(%W(-t#{out_type} -c#{out_channels} -r#{out_rate} -))
+when "play"
+  args.unshift "play"
+else
+  abort "BUG: bad cmd=#{cmd.inspect}"
+end
+if dryrun
+  p args
+else
+  exec *args, close_others: false
+end
diff --git a/bin/dtas-player b/bin/dtas-player
new file mode 100755
index 0000000..8b78771
--- /dev/null
+++ b/bin/dtas-player
@@ -0,0 +1,34 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+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")).b
+state = (ENV["DTAS_PLAYER_STATE"] ||
+         File.expand_path("~/.dtas/player_state.yml")).b
+[ sock, state ].each do |file|
+  dir = File.dirname(file)
+  next if File.directory?(dir)
+  require 'fileutils'
+  FileUtils.mkpath(dir)
+end
+
+state = DTAS::StateFile.new(state)
+if tmp = state.tryload
+  tmp["socket"] ||= sock
+  player = DTAS::Player.load(tmp)
+  player.state_file ||= state
+else
+  player = DTAS::Player.new
+  player.state_file = state
+  player.socket = sock
+end
+
+at_exit { player.close }
+player.bind
+trap(:INT) { exit }
+trap(:TERM) { exit }
+player.run
diff --git a/bin/dtas-sinkedit b/bin/dtas-sinkedit
new file mode 100755
index 0000000..d1e8fb4
--- /dev/null
+++ b/bin/dtas-sinkedit
@@ -0,0 +1,46 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'dtas/unix_client'
+require 'dtas/disclaimer'
+require 'tempfile'
+require 'yaml'
+editor = ENV["VISUAL"] || ENV["EDITOR"]
+c = DTAS::UNIXClient.new
+usage = "#$0 SINKNAME"
+ARGV.size == 1 or abort usage
+name = ARGV[0]
+
+tmp = Tempfile.new(%w(dtas-sinkedit .yml))
+tmp.sync = true
+tmp.binmode
+
+buf = c.req(%W(sink cat #{name}))
+abort(buf) if buf =~ /\AERR/
+buf << DTAS_DISCLAIMER
+
+tmp.write(buf)
+cmd = "#{editor} #{tmp.path}"
+system(cmd) or abort "#{cmd} failed: #$?"
+tmp.rewind
+sink = YAML.load(tmp.read)
+
+cmd = %W(sink ed #{name})
+if env = sink["env"]
+  env.each do |k,v|
+    cmd << (v.nil? ? "env##{k}" : "env.#{k}=#{v}")
+  end
+end
+
+%w(nonblock active).each do |field|
+  if sink.key?(field)
+    cmd << "#{field}=#{sink[field] ? 'true' : 'false'}"
+  end
+end
+
+%w(prio pipe_size command).each do |field|
+  value = sink[field] and cmd << "#{field}=#{value}"
+end
+
+c.req_ok(cmd)
diff --git a/bin/dtas-sourceedit b/bin/dtas-sourceedit
new file mode 100755
index 0000000..9d329d7
--- /dev/null
+++ b/bin/dtas-sourceedit
@@ -0,0 +1,39 @@
+#!/usr/bin/env ruby
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'tempfile'
+require 'yaml'
+require 'dtas/unix_client'
+require 'dtas/disclaimer'
+editor = ENV["VISUAL"] || ENV["EDITOR"]
+c = DTAS::UNIXClient.new
+usage = $0
+ARGV.size == 0 or abort usage
+name = ARGV[0]
+
+tmp = Tempfile.new(%w(dtas-sourceedit .yml))
+tmp.sync = true
+tmp.binmode
+
+buf = c.req(%W(source cat))
+abort(buf) if buf =~ /\AERR/
+
+tmp.write(buf << DTAS_DISCLAIMER)
+cmd = "#{editor} #{tmp.path}"
+system(cmd) or abort "#{cmd} failed: #$?"
+tmp.rewind
+source = YAML.load(tmp.read)
+
+cmd = %W(source ed)
+if env = source["env"]
+  env.each do |k,v|
+    cmd << (v.nil? ? "env##{k}" : "env.#{k}=#{v}")
+  end
+end
+
+%w(command).each do |field|
+  value = source[field] and cmd << "#{field}=#{value}"
+end
+
+c.req_ok(cmd)
diff --git a/examples/dtas_state.yml b/examples/dtas_state.yml
new file mode 100644
index 0000000..592a93a
--- /dev/null
+++ b/examples/dtas_state.yml
@@ -0,0 +1,18 @@
+---
+socket: .dtas/player.sock
+sinks:
+- name: ODAC
+  command: |-
+    sox -t $SOX_FILETYPE -r $RATE -c $CHANNELS - \
+      -t s$SINK_BITS -r $SINK_RATE -c $SINK_CHANNELS - | \
+    aplay -D hw:DAC_1 -v -q -M --buffer-size=500000 --period-size=500 \
+      --disable-softvol --start-delay=100 \
+      --disable-format --disable-resample --disable-channels \
+      -t raw -c $SINK_CHANNELS -f S${SINK_BITS}_3LE -r $SINK_RATE
+  env:
+    SINK_BITS: 24
+    SINK_RATE: 44100
+    SINK_CHANNELS: 2
+- name: play
+  prio: -2
+  command: env AUDIODEV=hw:DAC play -t $SOX_FILETYPE -r $RATE -c $CHANNELS -
diff --git a/lib/dtas.rb b/lib/dtas.rb
new file mode 100644
index 0000000..c7ac0af
--- /dev/null
+++ b/lib/dtas.rb
@@ -0,0 +1,8 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+module DTAS
+end
+
+require 'dtas/compat_onenine'
diff --git a/lib/dtas/buffer.rb b/lib/dtas/buffer.rb
new file mode 100644
index 0000000..d02e8a6
--- /dev/null
+++ b/lib/dtas/buffer.rb
@@ -0,0 +1,91 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative '../dtas'
+
+class DTAS::Buffer
+  begin
+    raise LoadError, "no splice with _DTAS_POSIX" if ENV["_DTAS_POSIX"]
+    require 'io/splice' # splice is only in Linux for now...
+    require_relative 'buffer/splice'
+    include DTAS::Buffer::Splice
+  rescue LoadError
+    require_relative 'buffer/read_write'
+    include DTAS::Buffer::ReadWrite
+  end
+
+  attr_reader :to_io # call nread on this
+  attr_reader :wr # processes (sources) should redirect to this
+  attr_accessor :bytes_xfer
+
+  def initialize
+    @bytes_xfer = 0
+    @buffer_size = nil
+    @to_io, @wr = DTAS::Pipe.new
+  end
+
+  def self.load(hash)
+    buf = new
+    if hash
+      bs = hash["buffer_size"] and buf.buffer_size = bs
+    end
+    buf
+  end
+
+  def to_hsh
+    @buffer_size ? { "buffer_size" => @buffer_size } : {}
+  end
+
+  def __dst_error(dst, e)
+    warn "dropping #{dst.inspect} due to error: #{e.message} (#{e.class})"
+    dst.close unless dst.closed?
+  end
+
+  # This will modify targets
+  # returns one of:
+  # - :wait_readable
+  # - subset of targets array for :wait_writable
+  # - some type of StandardError
+  # - nil
+  def broadcast(targets)
+    bytes = inflight
+    return :wait_readable if 0 == bytes # spurious wakeup
+
+    case targets.size
+    when 0
+      :ignore # this will pause decoders
+    when 1
+      broadcast_one(targets, bytes)
+    else # infinity
+      broadcast_inf(targets, bytes)
+    end
+  end
+
+  def readable_iter
+    # this calls DTAS::Buffer#broadcast from DTAS::Player
+    yield(self, nil)
+  end
+
+  def inflight
+    @to_io.nread
+  end
+
+  # don't really close the pipes under normal circumstances, just clear data
+  def close
+    bytes = inflight
+    discard(bytes) if bytes > 0
+  end
+
+  def buf_reset
+    close!
+    @bytes_xfer = 0
+    @to_io, @wr = DTAS::Pipe.new
+    @wr.pipe_size = @buffer_size if @buffer_size
+  end
+
+  def close!
+    @to_io.close
+    @wr.close
+  end
+end
diff --git a/lib/dtas/buffer/read_write.rb b/lib/dtas/buffer/read_write.rb
new file mode 100644
index 0000000..93380d1
--- /dev/null
+++ b/lib/dtas/buffer/read_write.rb
@@ -0,0 +1,103 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'io/wait'
+require 'io/nonblock'
+require_relative '../../dtas'
+require_relative '../pipe'
+
+module DTAS::Buffer::ReadWrite
+  MAX_AT_ONCE = 512 # min PIPE_BUF value in POSIX
+  attr_accessor :buffer_size
+
+  def _rbuf
+    Thread.current[:dtas_pbuf] ||= ""
+  end
+
+  # be sure to only call this with nil when all writers to @wr are done
+  def discard(bytes)
+    buf = _rbuf
+    begin
+      @to_io.read(bytes, buf) or break # EOF
+      bytes -= buf.bytesize
+    end until bytes == 0
+  end
+
+  # always block when we have a single target
+  def broadcast_one(targets, bytes)
+    buf = _rbuf
+    @to_io.read(bytes, buf)
+    n = targets[0].write(buf) # IO#write has write-in-full behavior
+    @bytes_xfer += n
+    :wait_readable
+  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 broadcast_inf(targets, bytes)
+    nr_nb = targets.count { |sink| sink.nonblock? }
+    if nr_nb == 0 || nr_nb == targets.size
+      # if all targets are full, 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
+
+    again = {}
+
+    # don't pin too much on one target
+    bytes = bytes > MAX_AT_ONCE ? MAX_AT_ONCE : bytes
+    buf = _rbuf
+    @to_io.read(bytes, buf)
+    @bytes_xfer += buf.bytesize
+
+    targets.delete_if do |dst|
+      begin
+        if dst.nonblock?
+          w = dst.write_nonblock(buf)
+          again[dst] = buf.byteslice(w, n) if w < n
+        else
+          dst.write(buf)
+        end
+        false
+      rescue Errno::EAGAIN
+        blocked << dst
+        false
+      rescue IOError, Errno::EPIPE => e
+        again.delete(dst)
+        __dst_error(dst, e)
+        true
+      end
+    end
+
+    # try to write as much as possible
+    again.delete_if do |dst, sbuf|
+      begin
+        w = dst.write_nonblock(sbuf)
+        n = sbuf.bytesize
+        if w < n
+          again[dst] = sbuf.byteslice(w, n)
+          false
+        else
+          true
+        end
+      rescue Errno::EAGAIN
+        blocked << dst
+        true
+      rescue IOError, Errno::EPIPE => e
+        __dst_error(dst, e)
+        true
+      end
+    end until again.empty?
+    targets[0] ? :wait_readable : nil
+  end
+end
diff --git a/lib/dtas/buffer/splice.rb b/lib/dtas/buffer/splice.rb
new file mode 100644
index 0000000..c2540bd
--- /dev/null
+++ b/lib/dtas/buffer/splice.rb
@@ -0,0 +1,143 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'io/wait'
+require 'io/nonblock'
+require 'io/splice'
+require_relative '../../dtas'
+require_relative '../pipe'
+
+module DTAS::Buffer::Splice
+  MAX_AT_ONCE = 4096 # page size in Linux
+  MAX_SIZE = File.read("/proc/sys/fs/pipe-max-size").to_i
+  DEVNULL = File.open("/dev/null", "r+")
+  F_MOVE = IO::Splice::F_MOVE
+  WAITALL = IO::Splice::WAITALL
+
+  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)
+    IO.splice(@to_io, nil, DEVNULL, nil, bytes)
+  end
+
+  def broadcast_one(targets, bytes)
+    # single output is always non-blocking
+    s = IO.trysplice(@to_io, nil, targets[0], nil, bytes, F_MOVE)
+    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
+    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
+
+  # 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? ?
+            IO.trytee(@to_io, dst, chunk_size) :
+            IO.tee(@to_io, dst, chunk_size, WAITALL)
+        if Integer === t
+          most_teed = t if t > most_teed
+        else
+          blocked << dst
+        end
+        false
+      rescue IOError, Errno::EPIPE => e
+        __dst_error(dst, e)
+        true
+      end
+    end
+    most_teed
+  end
+
+  def broadcast_inf(targets, bytes)
+    if targets.none? { |sink| sink.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 = bytes > MAX_AT_ONCE ? MAX_AT_ONCE : bytes
+
+    last = targets.pop # we splice to the last one, tee to the rest
+    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?
+        s = IO.trysplice(@to_io, nil, last, nil, bytes, F_MOVE)
+        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 = IO.splice(@to_io, nil, last, nil, bytes, WAITALL|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/command.rb b/lib/dtas/command.rb
new file mode 100644
index 0000000..b957567
--- /dev/null
+++ b/lib/dtas/command.rb
@@ -0,0 +1,44 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+# common code for wrapping SoX/ecasound/... commands
+require_relative 'serialize'
+require 'shellwords'
+
+module DTAS::Command
+  include DTAS::Serialize
+  attr_reader :pid
+  attr_reader :to_io
+  attr_accessor :command
+  attr_accessor :env
+  attr_accessor :spawn_at
+
+  COMMAND_DEFAULTS = {
+    "env" => {},
+    "command" => nil,
+  }
+
+  def command_init(defaults = {})
+    @pid = nil
+    @to_io = nil
+    @spawn_at = nil
+    COMMAND_DEFAULTS.merge(defaults).each do |k,v|
+      v = v.dup if Hash === v || Array === v
+      instance_variable_set("@#{k}", v)
+    end
+  end
+
+  def kill(sig = :TERM)
+    # always kill the pgroup since we run subcommands in their own shell
+    Process.kill(sig, -@pid)
+  end
+
+  def on_death(status)
+    @pid = nil
+  end
+
+  def command_string
+    @command
+  end
+end
diff --git a/lib/dtas/compat_onenine.rb b/lib/dtas/compat_onenine.rb
new file mode 100644
index 0000000..98be8c9
--- /dev/null
+++ b/lib/dtas/compat_onenine.rb
@@ -0,0 +1,19 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (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
+  def b
+    dup.force_encoding(Encoding::BINARY)
+  end
+end unless String.method_defined?(:b)
+
+def IO
+  def self.pipe
+    super.map! { |io| io.close_on_exec = true; io }
+  end
+end if RUBY_VERSION.to_f <= 1.9
diff --git a/lib/dtas/disclaimer.rb b/lib/dtas/disclaimer.rb
new file mode 100644
index 0000000..c25ba77
--- /dev/null
+++ b/lib/dtas/disclaimer.rb
@@ -0,0 +1,14 @@
+DTAS_DISCLAIMER = <<EOF
+# WARNING!
+#
+# Ignorant or improper use of #$0 may lead to
+# data loss, hearing loss, and damage to audio equipment.
+#
+# Please read and understand the documentation of all commands you
+# attempt to configure.
+#
+# #$0 will never prevent you from doing stupid things.
+#
+# There is no warranty, the developers of #$0
+# are not responsible for your actions.
+EOF
diff --git a/lib/dtas/format.rb b/lib/dtas/format.rb
new file mode 100644
index 0000000..1ba5487
--- /dev/null
+++ b/lib/dtas/format.rb
@@ -0,0 +1,152 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+# class represents an audio format (type/bits/channels/sample rate/...)
+require_relative '../dtas'
+require_relative 'process'
+require_relative 'serialize'
+
+class DTAS::Format
+  include DTAS::Process
+  include DTAS::Serialize
+  NATIVE_ENDIAN = [1].pack("l") == [1].pack("l>") ? "big" : "little"
+
+  attr_accessor :type # s32, f32, f64 ... any point in others?
+  attr_accessor :channels # 1..666
+  attr_accessor :rate     # 44100, 48000, 88200, 96000, 176400, 192000 ...
+  attr_accessor :bits # only set for playback on 16-bit DACs
+  attr_accessor :endian
+
+  FORMAT_DEFAULTS = {
+    "type" => "s32",
+    "channels" => 2,
+    "rate" => 44100,
+    "bits" => nil,   # default: implied from type
+    "endian" => nil, # unspecified
+  }
+  SIVS = FORMAT_DEFAULTS.keys
+
+  def self.load(hash)
+    fmt = new
+    return fmt unless hash
+    (SIVS & hash.keys).each do |k|
+      fmt.instance_variable_set("@#{k}", hash[k])
+    end
+    fmt
+  end
+
+  def initialize
+    FORMAT_DEFAULTS.each do |k,v|
+      instance_variable_set("@#{k}", v)
+    end
+  end
+
+  def to_sox_arg
+   rv = %W(-t#@type -c#@channels -r#@rate)
+   rv.concat(%W(-b#@bits)) if @bits # needed for play(1) to 16-bit DACs
+   rv
+  end
+
+  # returns 'be' or 'le' depending on endianess
+  def endian2
+    case e = @endian || NATIVE_ENDIAN
+    when "big"
+      "be"
+    when "little"
+      "le"
+    else
+      raise"unsupported endian=#{e}"
+    end
+  end
+
+  def to_eca_arg
+    %W(-f #{@type}_#{endian2},#@channels,#@rate)
+  end
+
+  def inspect
+    "<#{self.class}(#{xs(to_sox_arg)})>"
+  end
+
+  def to_hsh
+    to_hash.delete_if { |k,v| v == FORMAT_DEFAULTS[k] }
+  end
+
+  def to_hash
+    ivars_to_hash(SIVS)
+  end
+
+  def from_file(path)
+    @channels = qx(%W(soxi -c #{path})).to_i
+    @type = qx(%W(soxi -t #{path})).strip
+    @rate = qx(%W(soxi -r #{path})).to_i
+    # we don't need to care for bits, do we?
+  end
+
+  # for the _decoded_ output
+  def bits_per_sample
+    return @bits if @bits
+    /\A[fst](8|16|24|32|64)\z/ =~ @type or
+      raise TypeError, "invalid type=#@type (must be s32/f32/f64)"
+    $1.to_i
+  end
+
+  def bytes_per_sample
+    bits_per_sample / 8
+  end
+
+  def to_env
+    rv = {
+      "SOX_FILETYPE" => @type,
+      "CHANNELS" => @channels.to_s,
+      "RATE" => @rate.to_s,
+      "ENDIAN" => @endian || NATIVE_ENDIAN,
+      "SOXFMT" => to_sox_arg.join(' '),
+      "ECAFMT" => to_eca_arg.join(' '),
+      "ENDIAN2" => endian2,
+    }
+    begin # don't set these if we can't get them, SOX_FILETYPE may be enough
+      rv["BITS_PER_SAMPLE"] = bits_per_sample.to_s
+    rescue TypeError
+    end
+    rv
+  end
+
+  def bytes_to_samples(bytes)
+    bytes / bytes_per_sample / @channels
+  end
+
+  def bytes_to_time(bytes)
+    Time.at(bytes_to_samples(bytes) / @rate.to_f)
+  end
+
+  def valid_type?(type)
+    !!(type =~ %r{\A[us](?:8|16|24|32)\z} || type =~ %r{\Af?:(32|64)})
+  end
+
+  def valid_endian?(endian)
+    !!(endian =~ %r{\A(?:big|little|swap)\z})
+  end
+
+  # HH:MM:SS.frac (don't bother with more complex times, too much code)
+  # part of me wants to drop this feature from playq, feels like bloat...
+  def hhmmss_to_samples(hhmmss)
+    time = hhmmss.dup
+    rv = 0
+    if time.sub!(/\.(\d+)\z/, "")
+      # convert fractional second to sample count:
+      rv = ("0.#$1".to_f * @rate).to_i
+    end
+
+    # deal with HH:MM:SS
+    t = time.split(/:/)
+    raise ArgumentError, "Bad time format: #{hhmmss}" if t.size > 3
+
+    mult = 1
+    while part = t.pop
+      rv += part.to_i * mult * @rate
+      mult *= 60
+    end
+    rv
+  end
+end
diff --git a/lib/dtas/pipe.rb b/lib/dtas/pipe.rb
new file mode 100644
index 0000000..891e9cd
--- /dev/null
+++ b/lib/dtas/pipe.rb
@@ -0,0 +1,40 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+begin
+  require 'io/splice'
+rescue LoadError
+end
+require_relative '../dtas'
+require_relative 'writable_iter'
+
+class DTAS::Pipe < IO
+  include DTAS::WritableIter
+  attr_accessor :sink
+
+  def self.new
+    _, w = rv = pipe
+    w.writable_iter_init
+    rv
+  end
+
+  # create no-op methods for non-Linux
+  unless method_defined?(:pipe_size=)
+    def pipe_size=(_)
+    end
+
+    def pipe_size
+    end
+  end
+end
+
+# for non-blocking sinks, this avoids extra fcntl(..., F_GETFL) syscalls
+# We don't need fcntl at all for splice/tee in Linux
+# For non-Linux, we write_nonblock/read_nonblock already call fcntl()
+# behind our backs, so there's no need to repeat it.
+class DTAS::PipeNB < DTAS::Pipe
+  def nonblock?
+    true
+  end
+end
diff --git a/lib/dtas/player.rb b/lib/dtas/player.rb
new file mode 100644
index 0000000..b26303c
--- /dev/null
+++ b/lib/dtas/player.rb
@@ -0,0 +1,386 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'yaml'
+require 'shellwords'
+require_relative '../dtas'
+require_relative 'source'
+require_relative 'source/command'
+require_relative 'sink'
+require_relative 'unix_server'
+require_relative 'buffer'
+require_relative 'sigevent'
+require_relative 'rg_state'
+require_relative 'state_file'
+
+class DTAS::Player
+  require_relative 'player/client_handler'
+  include DTAS::Player::ClientHandler
+  attr_accessor :state_file
+  attr_accessor :socket
+  attr_reader :sinks
+
+  def initialize
+    @state_file = nil
+    @socket = nil
+    @srv = nil
+    @queue = [] # sources
+    @paused = false
+    @format = DTAS::Format.new
+    @srccmd = nil
+    @srcenv = {}
+
+    @sinks = {} # { user-defined name => sink }
+    @targets = [] # order matters
+    @rg = DTAS::RGState.new
+
+    # sits in between shared effects (if any) and sinks
+    @sink_buf = DTAS::Buffer.new
+    @current = nil
+    @watchers = {}
+  end
+
+  def echo(msg)
+    msg = Shellwords.join(msg) if Array === msg
+    @watchers.delete_if do |io, _|
+      if io.closed?
+        true
+      else
+        case io.emit(msg)
+        when :wait_readable, :wait_writable
+          false
+        else
+          true
+        end
+      end
+    end
+    $stdout.write(msg << "\n")
+  end
+
+  def to_hsh
+    rv = {}
+    rv["socket"] = @socket
+    rv["paused"] = @paused if @paused
+    src = rv["source"] = {}
+    src["command"] = @srccmd if @srccmd
+    src["env"] = @srcenv if @srcenv.size > 0
+
+    # Arrays
+    rv["queue"] = @queue
+
+    %w(rg sink_buf format).each do |k|
+      rv[k] = instance_variable_get("@#{k}").to_hsh
+    end
+
+    # no empty hashes or arrays
+    rv.delete_if do |k,v|
+      case v
+      when Hash, Array
+        v.empty?
+      else
+        false
+      end
+    end
+
+    unless @sinks.empty?
+      sinks = rv["sinks"] = []
+      # sort sinks by name for human viewability
+      @sinks.keys.sort.each do |name|
+        sinks << @sinks[name].to_hsh
+      end
+    end
+
+    rv
+  end
+
+  def self.load(hash)
+    rv = new
+    rv.instance_eval do
+      @rg = DTAS::RGState.load(hash["rg"])
+      if v = hash["sink_buf"]
+        v = v["buffer_size"]
+        @sink_buf.buffer_size = v
+      end
+      %w(socket queue paused).each do |k|
+        v = hash[k] or next
+        instance_variable_set("@#{k}", v)
+      end
+      if v = hash["source"]
+        @srccmd = v["command"]
+        e = v["env"] and @srcenv = e
+      end
+
+      if sinks = hash["sinks"]
+        sinks.each do |sink_hsh|
+          sink = DTAS::Sink.load(sink_hsh)
+          @sinks[sink.name] = sink
+        end
+      end
+    end
+    rv
+  end
+
+  def enq_handler(io, msg)
+    # check @queue[0] in case we have no sinks
+    if @current || @queue[0] || @paused
+      @queue << msg
+    else
+      next_source(msg)
+    end
+    io.emit("OK")
+  end
+
+  def do_enq_head(io, msg)
+    # check @queue[0] in case we have no sinks
+    if @current || @queue[0] || @paused
+      @queue.unshift(msg)
+    else
+      next_source(msg)
+    end
+    io.emit("OK")
+  end
+
+  # yielded from readable_iter
+  def client_iter(io, msg)
+    msg = Shellwords.split(msg)
+    command = msg.shift
+    case command
+    when "enq"
+      enq_handler(io, msg[0])
+    when "enq-head"
+      do_enq_head(io, msg)
+    when "enq-cmd"
+      enq_handler(io, { "command" => msg[0]})
+    when "pause", "play", "play_pause"
+      play_pause_handler(io, command)
+    when "seek"
+      do_seek(io, msg[0])
+    when "clear"
+      @queue.clear
+      echo("clear")
+      io.emit("OK")
+    when "rg"
+      rg_handler(io, msg)
+    when "skip"
+      skip_handler(io, msg)
+    when "sink"
+      sink_handler(io, msg)
+    when "current"
+      current_handler(io, msg)
+    when "watch"
+      @watchers[io] = true
+      io.emit("OK")
+    when "format"
+      format_handler(io, msg)
+    when "env"
+      env_handler(io, msg)
+    when "restart"
+      restart_pipeline
+      io.emit("OK")
+    when "source"
+      source_handler(io, msg)
+    end
+  end
+
+  def event_loop_iter
+    @srv.run_once do |io, msg| # readability handler, request/response
+      case io
+      when @sink_buf
+        sink_iter
+      when DTAS::UNIXAccepted
+        client_iter(io, msg)
+      when DTAS::Sigevent # signal received
+        reap_iter
+      else
+        raise "BUG: unknown event: #{io.class} #{io.inspect} #{msg.inspect}"
+      end
+    end
+  end
+
+  def reap_iter
+    DTAS::Process.reaper do |status, obj|
+      warn [ :reap, obj, status ].inspect if $DEBUG
+      obj.on_death(status) if obj.respond_to?(:on_death)
+      case obj
+      when @current
+        next_source(@paused ? nil : @queue.shift)
+      when DTAS::Sink # on unexpected sink death
+        sink_death(obj, status)
+      end
+    end
+    :wait_readable
+  end
+
+  def sink_death(sink, status)
+    deleted = []
+    @targets.delete_if do |t|
+      if t.sink == sink
+        deleted << t
+      else
+        false
+      end
+    end
+
+    if deleted[0]
+      warn("#{sink.name} died unexpectedly: #{status.inspect}")
+      deleted.each { |t| drop_target(t) }
+      __current_drop unless @targets[0]
+    end
+
+    return unless sink.active
+
+    if @queue[0] && !@paused
+      # we get here if source/sinks are all killed in restart_pipeline
+      __sink_activate(sink)
+      next_source(@queue.shift)
+    elsif sink.respawn
+      __sink_activate(sink) if @current
+    end
+  ensure
+    sink.respawn = false
+  end
+
+  # returns a wait_ctl arg for self
+  def broadcast_iter(buf, targets)
+    case rv = buf.broadcast(targets)
+    when Array # array of blocked sinks
+      # have sinks wake up the this buffer when they're writable
+      trade_ctl = proc { @srv.wait_ctl(buf, :wait_readable) }
+      rv.each do |dst|
+        dst.on_writable = trade_ctl
+        @srv.wait_ctl(dst, :wait_writable)
+      end
+
+      # this @sink_buf hibernates until trade_ctl is called
+      # via DTAS::Sink#writable_iter
+      :ignore
+    else # :wait_readable or nil
+      rv
+    end
+  end
+
+  def bind
+    @srv = DTAS::UNIXServer.new(@socket)
+  end
+
+  # only used on new installations where no sink exists
+  def create_default_sink
+    return unless @sinks.empty?
+    s = DTAS::Sink.new
+    s.name = "default"
+    s.active = true
+    @sinks[s.name] = s
+  end
+
+  # called when the player is leaving idle state
+  def spawn_sinks(source_spec)
+    return true if @targets[0]
+    @sinks.each_value do |sink|
+      sink.active or next
+      next if sink.pid
+      @targets.concat(sink.spawn(@format))
+    end
+    if @targets[0]
+      @targets.sort_by! { |t| t.sink.prio }
+      true
+    else
+      # fail, no active sink
+      @queue.unshift(source_spec)
+      false
+    end
+  end
+
+  def next_source(source_spec)
+    @current = nil
+    if source_spec
+      # restart sinks iff we were idle
+      spawn_sinks(source_spec) or return
+
+      case source_spec
+      when String
+        @current = DTAS::Source.new(source_spec)
+        echo(%W(file #{@current.infile}))
+      when Array
+        @current = DTAS::Source.new(*source_spec)
+        echo(%W(file #{@current.infile} #{@current.offset_samples}s))
+      else
+        @current = DTAS::Source::Command.new(source_spec["command"])
+        echo(%W(command #{@current.command_string}))
+      end
+
+      if DTAS::Source === @current
+        @current.command = @srccmd if @srccmd
+        @current.env = @srcenv.dup unless @srcenv.empty?
+      end
+
+      dst = @sink_buf
+      @current.dst_assoc(dst)
+      @current.spawn(@format, @rg, out: dst.wr, in: "/dev/null")
+      @srv.wait_ctl(dst, :wait_readable)
+    else
+      stop_sinks if @sink_buf.inflight == 0
+      echo "idle"
+    end
+  end
+
+  def drop_target(target)
+    @srv.wait_ctl(target, :delete)
+    target.close
+  end
+
+  def stop_sinks
+    @targets.each { |t| drop_target(t) }.clear
+  end
+
+  # only call on unrecoverable errors (or "skip")
+  def __current_drop(src = @current)
+    __buf_reset(src.dst) if src && src.pid
+  end
+
+  # 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
+    return wait_iter if @current
+
+    # no source left to feed sink_buf, drain the remaining data
+    sink_bytes = @sink_buf.inflight
+    if sink_bytes > 0
+      return wait_iter if @targets[0] # play what is leftover
+
+      # discard the buffer if no sinks
+      @sink_buf.discard(sink_bytes)
+    end
+
+    # nothing left inflight, stop the sinks until we have a source
+    stop_sinks
+    :ignore
+  end
+
+  # the main loop
+  def run
+    sev = DTAS::Sigevent.new
+    @srv.wait_ctl(sev, :wait_readable)
+    old_chld = trap(:CHLD) { sev.signal }
+    create_default_sink
+    next_source(@paused ? nil : @queue.shift)
+    begin
+      event_loop_iter
+    rescue => e # just in case...
+      warn "E: #{e.message} (#{e.class})"
+      e.backtrace.each { |l| warn l }
+    end while true
+  ensure
+    __current_requeue
+    trap(:CHLD, old_chld)
+    sev.close if sev
+    # for state file
+  end
+
+  def close
+    @srv = @srv.close if @srv
+    @sink_buf.close!
+    @state_file.dump(self, true) if @state_file
+  end
+end
diff --git a/lib/dtas/player/client_handler.rb b/lib/dtas/player/client_handler.rb
new file mode 100644
index 0000000..aabd1ab
--- /dev/null
+++ b/lib/dtas/player/client_handler.rb
@@ -0,0 +1,452 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+module DTAS::Player::ClientHandler
+
+  # returns true on success, wait_ctl arg on error
+  def set_bool(io, kv, v)
+    case v
+    when "false" then yield(false)
+    when "true" then yield(true)
+    else
+      return io.emit("ERR #{kv} must be true or false")
+    end
+    true
+  end
+
+  def adjust_numeric(io, obj, k, v)
+    negate = !!v.sub!(/\A-/, '')
+    case v
+    when %r{\A\+?\d*\.\d+\z}
+      num = v.to_f
+    when %r{\A\+?\d+\z}
+      num = v.to_i
+    else
+      return io.emit("ERR #{k}=#{v} must be a float")
+    end
+    num = -num if negate
+
+    if k.sub!(/\+\z/, '') # increment existing
+      num += obj.__send__(k)
+    elsif k.sub!(/-\z/, '') # decrement existing
+      num = obj.__send__(k) - num
+    # else # normal assignment
+    end
+    obj.__send__("#{k}=", num)
+    true
+  end
+
+  # returns true on success, wait_ctl arg on error
+  def set_int(io, kv, v, null_ok)
+    case v
+    when %r{\A-?\d+\z}
+      yield(v.to_i)
+    when ""
+      null_ok or return io.emit("ERR #{kv} must be defined")
+      yield(nil)
+    else
+      return io.emit("ERR #{kv} must an integer")
+    end
+    true
+  end
+
+  # returns true on success, wait_ctl arg on error
+  def set_uint(io, kv, v, null_ok)
+    case v
+    when %r{\A\d+\z}
+      yield(v.to_i)
+    when %r{\A0x[0-9a-fA-F]+\z}i # hex
+      yield(v.to_i(16))
+    when ""
+      null_ok or return io.emit("ERR #{kv} must be defined")
+      yield(nil)
+    else
+      return io.emit("ERR #{kv} must an non-negative integer")
+    end
+    true
+  end
+
+  def __sink_activate(sink)
+    return if sink.pid
+    @targets.concat(sink.spawn(@format))
+    @targets.sort_by! { |t| t.sink.prio }
+  end
+
+  def drop_sink(sink)
+    @targets.delete_if do |t|
+      if t.sink == sink
+        drop_target(t)
+        true
+      else
+        false
+      end
+    end
+  end
+
+  # called to activate/deactivate a sink
+  def __sink_switch(sink)
+    if sink.active
+      if @current
+        # maybe it's still alive for now, but it's just being killed
+        # do not reactivate it until we've reaped it
+        if sink.pid
+          drop_sink(sink)
+          sink.respawn = true
+        else
+          __sink_activate(sink)
+        end
+      end
+    else
+      drop_sink(sink)
+    end
+    # if we change any sinks, make sure the event loop watches it for
+    # readability again, since we new sinks should be writable, and
+    # we've stopped waiting on killed sinks
+    @srv.wait_ctl(@sink_buf, :wait_readable)
+  end
+
+  # returns a wait_ctl arg
+  def sink_handler(io, msg)
+    name = msg[1]
+    case msg[0]
+    when "ls"
+      io.emit(Shellwords.join(@sinks.keys.sort))
+    when "rm"
+      sink = @sinks.delete(name) or return io.emit("ERR #{name} not found")
+      drop_sink(sink)
+      io.emit("OK")
+    when "ed"
+      sink = @sinks[name] || (new_sink = DTAS::Sink.new)
+
+      # allow things that look like audio device names ("hw:1,0" , "/dev/dsp")
+      # or variable names.
+      sink.valid_name?(name) or return io.emit("ERR sink name invalid")
+
+      sink.name = name
+      active_before = sink.active
+
+      # multiple changes may be made at once
+      msg[2..-1].each do |kv|
+        k, v = kv.split(/=/, 2)
+        case k
+        when %r{\Aenv\.([^=]+)\z}
+          sink.env[$1] = v
+        when %r{\Aenv#([^=]+)\z}
+          v == nil or return io.emit("ERR unset env has no value")
+          sink.env.delete($1)
+        when "prio"
+          rv = set_int(io, kv, v, false) { |i| sink.prio = i }
+          rv == true or return rv
+          @targets.sort_by! { |t| t.sink.prio } if sink.active
+        when "nonblock", "active"
+          rv = set_bool(io, kv, v) { |b| sink.__send__("#{k}=", b) }
+          rv == true or return rv
+        when "pipe_size"
+          rv = set_uint(io, kv, v, true) { |u| sink.__send__("#{k}=", u) }
+          rv == true or return rv
+        when "command" # nothing to validate, this could be "rm -rf /" :>
+          sink.command = v.empty? ? DTAS::Sink::SINK_DEFAULTS["command"] : v
+        end
+      end
+
+      @sinks[name] = new_sink if new_sink # no errors? it's a new sink!
+
+      # start or stop a sink if its active= flag changed.  Additionally,
+      # account for a crashed-but-marked-active sink.  The user may have
+      # fixed the command to not crash it.
+      if (active_before != sink.active) || (sink.active && !sink.pid)
+        __sink_switch(sink)
+      end
+      io.emit("OK")
+    when "cat"
+      sink = @sinks[name] or return io.emit("ERR #{name} not found")
+      io.emit(sink.to_hsh.to_yaml)
+    else
+      io.emit("ERR unknown sink op #{msg[0]}")
+    end
+  end
+
+  def bytes_decoded(src = @current)
+    bytes = src.dst.bytes_xfer - src.dst_zero_byte
+    bytes = bytes < 0 ? 0 : bytes # maybe negative in case of sink errors
+  end
+
+  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)
+    n = 0 if n < 0
+    "#{n}s"
+  end
+
+  def __current_decoded_samples
+    initial = @current.offset_samples
+    decoded = @format.bytes_to_samples(bytes_decoded)
+    decoded = out_samples(decoded, @format, @current.format)
+    initial + decoded
+  end
+
+  def __current_requeue
+    return unless @current && @current.pid
+
+    # no need to requeue if we're already due to die
+    return if @current.requeued
+    @current.requeued = true
+
+    dst = @current.dst
+    # prepare to seek to the desired point based on the number of bytes which
+    # passed through dst buffer we want the offset for the @current file,
+    # which may have a different rate than our internal @format
+    if @current.respond_to?(:infile)
+      # this offset in the @current.format (not player @format)
+      @queue.unshift([ @current.infile, "#{__current_decoded_samples}s" ])
+    else
+      # DTAS::Source::Command (hash), just rerun it
+      @queue.unshift(@current.to_hsh)
+    end
+    # We also want to hard drop the buffer so we do not get repeated audio.
+    __buf_reset(dst)
+  end
+
+  def out_samples(in_samples, infmt, outfmt)
+    in_rate = infmt.rate
+    out_rate = outfmt.rate
+    return in_samples if in_rate == out_rate # easy!
+    (in_samples * out_rate / in_rate.to_f).round
+  end
+
+  # returns the number of samples we expect from the source
+  # this takes into account sample rate differences between the source
+  # and internal player format
+  def current_expect_samples(in_samples) # @current.samples
+    out_samples(in_samples, @current.format, @format)
+  end
+
+  def rg_handler(io, msg)
+    return io.emit(@rg.to_hsh.to_yaml) if msg.empty?
+    before = @rg.to_hsh
+    msg.each do |kv|
+      k, v = kv.split(/=/, 2)
+      case k
+      when "mode"
+        case v
+        when "off"
+          @rg.mode = nil
+        else
+          DTAS::RGState::RG_MODE.include?(v) or
+            return io.emit("ERR rg mode invalid")
+          @rg.mode = v
+        end
+      when "fallback_track"
+        rv = set_bool(io, kv, v) { |b| @rg.fallback_track = b }
+        rv == true or return rv
+      when %r{(?:gain_threshold|norm_threshold|
+              preamp|norm_level|fallback_gain)[+-]?\z}x
+        rv = adjust_numeric(io, @rg, k, v)
+        rv == true or return rv
+      end
+    end
+    after = @rg.to_hsh
+    __current_requeue if before != after
+    io.emit("OK")
+  end
+
+  def active_sinks
+    sinks = @targets.map { |t| t.sink }
+    sinks.uniq!
+    sinks
+  end
+
+  # show current info about what's playing
+  # returns non-blocking iterator retval
+  def current_handler(io, msg)
+    tmp = {}
+    if @current
+      tmp["current"] = s = @current.to_hsh
+      s["spawn_at"] = @current.spawn_at
+      s["pid"] = @current.pid
+
+      # this offset and samples in the player @format (not @current.format)
+      decoded = @format.bytes_to_samples(bytes_decoded)
+      if @current.respond_to?(:infile)
+        initial = tmp["current_initial"] = @current.offset_samples
+        initial = out_samples(initial, @current.format, @format)
+        tmp["current_expect"] = current_expect_samples(s["samples"])
+        s["format"] = @current.format.to_hash.delete_if { |_,v| v.nil? }
+      else
+        initial = 0
+        tmp["current_expect"] = nil
+        s["format"] = @format.to_hash.delete_if { |_,v| v.nil? }
+      end
+      tmp["current_offset"] = initial + decoded
+    end
+    tmp["current_inflight"] = @sink_buf.inflight
+    tmp["format"] = @format.to_hash.delete_if { |_,v| v.nil? }
+    tmp["paused"] = @paused
+    rg = @rg.to_hsh
+    tmp["rg"] = rg unless rg.empty?
+    if @targets[0]
+      sinks = active_sinks
+      tmp["sinks"] = sinks.map! do |sink|
+        h = sink.to_hsh
+        h["pid"] = sink.pid
+        h
+      end
+    end
+    io.emit(tmp.to_yaml)
+  end
+
+  def __buf_reset(buf)
+    @srv.wait_ctl(buf, :ignore)
+    buf.buf_reset
+    @srv.wait_ctl(buf, :wait_readable)
+  end
+
+  def skip_handler(io, msg)
+    __current_drop
+    echo("skip")
+    io.emit("OK")
+  end
+
+  def play_pause_handler(io, command)
+    prev = @paused
+    __send__("do_#{command}")
+    io.emit({
+      "paused" => {
+        "before" => prev,
+        "after" => @paused,
+      }
+    }.to_yaml)
+  end
+
+  def do_pause
+    return if @paused
+    echo("pause")
+    @paused = true
+    __current_requeue
+  end
+
+  def do_play
+    # no echo, next_source will echo on new track
+    @paused = false
+    return if @current && @current.pid
+    next_source(@queue.shift)
+  end
+
+  def do_play_pause
+    @paused ? do_play : do_pause
+  end
+
+  def do_seek(io, offset)
+    if @current && @current.pid
+      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)
+          # else: pass to sox directly
+          end
+        rescue ArgumentError
+          return io.emit("ERR bad time format")
+        end
+        @queue.unshift([ @current.infile, offset ])
+        __buf_reset(@current.dst) # trigger EPIPE
+      else
+        return io.emit("ERR unseekable")
+      end
+    elsif @paused
+      case file = @queue[0]
+      when String
+        @queue[0] = [ file, offset ]
+      when Array
+        file[1] = offset
+      else
+        return io.emit("ERR unseekable")
+      end
+    # unpaused case... what do we do?
+    end
+    io.emit("OK")
+  end
+
+  def restart_pipeline
+    return if @paused
+    __current_requeue
+    @sinks.each_value { |sink| sink.respawn = sink.active }
+    @targets.each { |t| drop_target(t) }.clear
+  end
+
+  def format_handler(io, msg)
+    new_fmt = @format.dup
+    msg.each do |kv|
+      k, v = kv.split(/=/, 2)
+      case k
+      when "type"
+        new_fmt.valid_type?(v) or return io.emit("ERR invalid file type")
+        new_fmt.type = v
+      when "channels", "bits", "rate"
+        rv = set_uint(io, kv, v, false) { |u| new_fmt.__send__("#{k}=", u) }
+        rv == true or return rv
+      when "endian"
+        new_fmt.valid_endian?(v) or return io.emit("ERR invalid endian")
+        new_fmt.endian = v
+      end
+    end
+
+    if new_fmt != @format
+      restart_pipeline # calls __current_requeue
+
+      # we must assign this after __current_requeue since __current_requeue
+      # relies on the old @format for calculation
+      @format = new_fmt
+    end
+    io.emit("OK")
+  end
+
+  def env_handler(io, msg)
+    msg.each do |kv|
+      case kv
+      when %r{\A([^=]+)=(.*)\z}
+        ENV[$1] = $2
+      when %r{\A([^=]+)#}
+        ENV.delete($1)
+      else
+        return io.emit("ERR bad env")
+      end
+    end
+    io.emit("OK")
+  end
+
+  def source_handler(io, msg)
+    case msg.shift
+    when "cat"
+      io.emit({
+        "command" => @srccmd || DTAS::Source::SOURCE_DEFAULTS["command"],
+        "env" => @srcenv,
+      }.to_yaml)
+    when "ed"
+      before = [ @srccmd, @srcenv ].inspect
+      msg.each do |kv|
+        k, v = kv.split(/=/, 2)
+        case k
+        when "command"
+          @srccmd = v.empty? ? nil : v
+        when %r{\Aenv\.([^=]+)\z}
+          @srcenv[$1] = v
+        when %r{\Aenv#([^=]+)\z}
+          v == nil or return io.emit("ERR unset env has no value")
+          @srcenv.delete($1)
+        end
+      end
+      after = [ @srccmd, @srcenv ].inspect
+      __current_requeue if before != after
+      io.emit("OK")
+    else
+      io.emit("ERR unknown source op")
+    end
+  end
+end
diff --git a/lib/dtas/process.rb b/lib/dtas/process.rb
new file mode 100644
index 0000000..35ca6a6
--- /dev/null
+++ b/lib/dtas/process.rb
@@ -0,0 +1,88 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'shellwords'
+require 'io/wait'
+module DTAS::Process
+  PIDS = {}
+
+  def self.reaper
+    begin
+      pid, status = Process.waitpid2(-1, Process::WNOHANG)
+      pid or return
+      obj = PIDS.delete(pid)
+      yield status, obj
+    rescue Errno::ECHILD
+      return
+    end while true
+  end
+
+  # a convienient way to display commands so it's easy to
+  # read, copy and paste to a shell
+  def xs(cmd)
+    cmd.map { |w| Shellwords.escape(w) }.join(' ')
+  end
+
+  # for long-running processes (sox/play/ecasound filters)
+  def dtas_spawn(env, cmd, opts)
+    opts = { close_others: true, pgroup: true }.merge!(opts)
+
+    # stringify env, integer values are easier to type unquoted as strings
+    env.each { |k,v| env[k] = v.to_s }
+
+    pid = begin
+      Process.spawn(env, cmd, opts)
+    rescue Errno::EINTR # Ruby bug?
+      retry
+    end
+    warn [ :spawn, pid, cmd ].inspect if $DEBUG
+    @spawn_at = Time.now.to_f
+    PIDS[pid] = self
+    pid
+  end
+
+  # this is like backtick, but takes an array instead of a string
+  # This will also raise on errors
+  def qx(cmd, opts = {})
+    r, w = IO.pipe
+    opts = opts.merge(out: w)
+    r.binmode
+    if err = opts[:err]
+      re, we = IO.pipe
+      re.binmode
+      opts[:err] = we
+    end
+    pid = begin
+      Process.spawn(*cmd, opts)
+    rescue Errno::EINTR # Ruby bug?
+      retry
+    end
+    w.close
+    if err
+      we.close
+      res = ""
+      want = { r => res, re => err }
+      begin
+        readable = IO.select(want.keys) or next
+        readable[0].each do |io|
+          bytes = io.nread
+          begin
+            want[io] << io.read_nonblock(bytes > 0 ? bytes : 11)
+          rescue Errno::EAGAIN
+            # spurious wakeup, bytes may be zero
+          rescue EOFError
+            want.delete(io)
+          end
+        end
+      end until want.empty?
+      re.close
+    else
+      res = r.read
+    end
+    r.close
+    _, status = Process.waitpid2(pid)
+    return res if status.success?
+    raise RuntimeError, "`#{xs(cmd)}' failed: #{status.inspect}"
+  end
+end
diff --git a/lib/dtas/replaygain.rb b/lib/dtas/replaygain.rb
new file mode 100644
index 0000000..e049b8d
--- /dev/null
+++ b/lib/dtas/replaygain.rb
@@ -0,0 +1,42 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+#
+# Represents ReplayGain metadata for a DTAS::Source
+# cleanup/validate values to prevent malicious files from making us
+# run arbitrary commands
+# *_peak values are 0..inf (1.0 being full scale, but >1 is possible
+# *_gain values are specified in dB
+
+class DTAS::ReplayGain
+  ATTRS = %w(reference_loudness track_gain album_gain track_peak album_peak)
+  ATTRS.each { |a| attr_reader a }
+
+  def check_gain(val)
+    /([+-]?\d+(?:\.\d+)?)/ =~ val ? $1 : nil
+  end
+
+  def check_float(val)
+    /(\d+(?:\.\d+)?)/ =~ val ? $1 : nil
+  end
+
+  def initialize(comments)
+    comments or return
+
+    # the replaygain standard specifies 89.0 dB, but maybe some apps are
+    # different...
+    @reference_loudness =
+              check_gain(comments["REPLAYGAIN_REFERENCE_LOUDNESS"]) || "89.0"
+
+    @track_gain = check_gain(comments["REPLAYGAIN_TRACK_GAIN"])
+    @album_gain = check_gain(comments["REPLAYGAIN_ALBUM_GAIN"])
+    @track_peak = check_float(comments["REPLAYGAIN_TRACK_PEAK"])
+    @album_peak = check_float(comments["REPLAYGAIN_ALBUM_PEAK"])
+  end
+
+  def self.new(comments)
+    tmp = super
+    tmp.track_gain ? tmp : nil
+  end
+end
diff --git a/lib/dtas/rg_state.rb b/lib/dtas/rg_state.rb
new file mode 100644
index 0000000..6463be7
--- /dev/null
+++ b/lib/dtas/rg_state.rb
@@ -0,0 +1,100 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+#
+# provides support for generating appropriate effects for ReplayGain
+# MAYBE: account for non-standard reference loudness (89.0 dB is standard)
+require_relative '../dtas'
+require_relative 'serialize'
+class DTAS::RGState
+  include DTAS::Serialize
+
+  RG_MODE = {
+    # attribute name => method to use
+    "album_gain" => :rg_vol_gain,
+    "track_gain" => :rg_vol_gain,
+    "album_peak" => :rg_vol_norm,
+    "track_peak" => :rg_vol_norm,
+  }
+
+  RG_DEFAULT = {
+    # skip the effect if the adjustment is too small to be noticeable
+    "gain_threshold" => 0.00000001, # in dB
+    "norm_threshold" => 0.00000001,
+
+    "preamp" => 0, # no extra adjustment
+    # "mode" => "album_gain", # nil: off
+    "mode" => nil, # nil: off
+    "fallback_gain" => -6.0, # adjustment dB if necessary RG tag is missing
+    "fallback_track" => true,
+    "norm_level" => 1.0, # dBFS
+  }
+
+  SIVS = RG_DEFAULT.keys
+  SIVS.each { |iv| attr_accessor iv }
+
+  def initialize
+    RG_DEFAULT.each do |k,v|
+      instance_variable_set("@#{k}", v)
+    end
+  end
+
+  def self.load(hash)
+    rv = new
+    hash.each { |k,v| rv.__send__("#{k}=", v) } if hash
+    rv
+  end
+
+  def to_hash
+    ivars_to_hash(SIVS)
+  end
+
+  def to_hsh
+    # no point in dumping default values, it's just a waste of space
+    to_hash.delete_if { |k,v| RG_DEFAULT[k] == v }
+  end
+
+  # returns a dB argument to the "vol" effect, nil if nothing found
+  def rg_vol_gain(val)
+    val = val.to_f + @preamp
+    return if val.abs < @gain_threshold
+    sprintf('vol %0.8gdB', val)
+  end
+
+  # returns a linear argument to the "vol" effect
+  def rg_vol_norm(val)
+    diff = @norm_level - val.to_f
+    return if (@norm_level - diff).abs < @norm_threshold
+    diff += @norm_level
+    sprintf('vol %0.8g', diff)
+  end
+
+  # The ReplayGain fallback adjustment value (in dB), in case a file is
+  # missing ReplayGain tags.  This is useful to avoid damage to speakers,
+  # eardrums and amplifiers in case a file without then necessary ReplayGain
+  # tag slips into the queue
+  def rg_fallback_effect(reason)
+    @fallback_gain or return
+    warn(reason) if $DEBUG
+    "vol #{@fallback_gain + @preamp}dB"
+  end
+
+  # returns an array (for command-line argument) for the effect needed
+  # to apply ReplayGain
+  # this may return nil
+  def effect(source)
+    return unless @mode
+    rg = source.replaygain or
+      return rg_fallback_effect("ReplayGain tags missing")
+    val = rg.__send__(@mode)
+    if ! val && @fallback_track && @mode =~ /\Aalbum_(\w+)/
+      tag = "track_#$1"
+      val = rg.__send__(tag) or
+        return rg_fallback_effect("ReplayGain tag for #@mode missing")
+      warn("tag for #@mode missing, using #{tag}")
+    end
+    # this may be nil if the adjustment is too small:
+    __send__(RG_MODE[@mode], val)
+  end
+end
diff --git a/lib/dtas/serialize.rb b/lib/dtas/serialize.rb
new file mode 100644
index 0000000..57eb626
--- /dev/null
+++ b/lib/dtas/serialize.rb
@@ -0,0 +1,10 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+module DTAS::Serialize
+  def ivars_to_hash(ivars, rv = {})
+    ivars.each { |k| rv[k] = instance_variable_get("@#{k}") }
+    rv
+  end
+end
diff --git a/lib/dtas/sigevent.rb b/lib/dtas/sigevent.rb
new file mode 100644
index 0000000..ccaec2f
--- /dev/null
+++ b/lib/dtas/sigevent.rb
@@ -0,0 +1,11 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+begin
+  raise LoadError, "no eventfd with _DTAS_POSIX" if ENV["_DTAS_POSIX"]
+  require 'sleepy_penguin'
+  require_relative 'sigevent/efd'
+rescue LoadError
+  require_relative 'sigevent/pipe'
+end
diff --git a/lib/dtas/sigevent/efd.rb b/lib/dtas/sigevent/efd.rb
new file mode 100644
index 0000000..782e383
--- /dev/null
+++ b/lib/dtas/sigevent/efd.rb
@@ -0,0 +1,21 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+class DTAS::Sigevent < SleepyPenguin::EventFD
+  include SleepyPenguin
+
+  def self.new
+    super(0, EventFD::CLOEXEC)
+  end
+
+  def signal
+    incr(1)
+  end
+
+  def readable_iter
+    value(true)
+    yield self, nil # calls DTAS::Process.reaper
+    :wait_readable
+  end
+end
diff --git a/lib/dtas/sigevent/pipe.rb b/lib/dtas/sigevent/pipe.rb
new file mode 100644
index 0000000..139aa68
--- /dev/null
+++ b/lib/dtas/sigevent/pipe.rb
@@ -0,0 +1,29 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+class DTAS::Sigevent
+  attr_reader :to_io
+
+  def initialize
+    @to_io, @wr = IO.pipe
+  end
+
+  def signal
+    @wr.syswrite('.') rescue nil
+  end
+
+  def readable_iter
+    begin
+      @to_io.read_nonblock(11)
+      yield self, nil # calls DTAS::Process.reaper
+    rescue Errno::EAGAIN
+      return :wait_readable
+    end while true
+  end
+
+  def close
+    @to_io.close
+    @wr.close
+  end
+end
diff --git a/lib/dtas/sink.rb b/lib/dtas/sink.rb
new file mode 100644
index 0000000..7931694
--- /dev/null
+++ b/lib/dtas/sink.rb
@@ -0,0 +1,122 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'yaml'
+require_relative '../dtas'
+require_relative 'pipe'
+require_relative 'process'
+require_relative 'command'
+require_relative 'format'
+require_relative 'serialize'
+require_relative 'writable_iter'
+
+# this is a sink (endpoint, audio enters but never leaves)
+class DTAS::Sink
+  attr_accessor :prio    # any Integer
+  attr_accessor :active  # boolean
+  attr_accessor :name
+  attr_accessor :nonblock
+  attr_accessor :respawn
+
+  include DTAS::Command
+  include DTAS::Process
+  include DTAS::Serialize
+  include DTAS::WritableIter
+
+  SINK_DEFAULTS = COMMAND_DEFAULTS.merge({
+    "name" => nil, # order matters, this is first
+    "command" => "exec play -q $SOXFMT -",
+    "prio" => 0,
+    "nonblock" => false,
+    "pipe_size" => nil,
+    "active" => false,
+    "respawn" => false,
+  })
+
+  DEVFD_RE = %r{/dev/fd/([a-zA-Z]\w*)\b}
+
+  # order matters for Ruby 1.9+, this defines to_hsh serialization so we
+  # can make the state file human-friendly
+  SIVS = %w(name env command prio nonblock pipe_size active)
+
+  def initialize
+    command_init(SINK_DEFAULTS)
+    writable_iter_init
+    @sink = self
+  end
+
+  # allow things that look like audio device names ("hw:1,0" , "/dev/dsp")
+  # or variable names.
+  def valid_name?(s)
+    !!(s =~ %r{\A[\w:,/-]+\z})
+  end
+
+  def self.load(hash)
+    sink = new
+    return sink unless hash
+    (SIVS & hash.keys).each do |k|
+      sink.instance_variable_set("@#{k}", hash[k])
+    end
+    sink.valid_name?(sink.name) or raise ArgumentError, "invalid sink name"
+    sink
+  end
+
+  def parse(str)
+    inputs = {}
+    str.scan(DEVFD_RE) { |w| inputs[w[0]] = nil }
+    inputs
+  end
+
+  def on_death(status)
+    super
+  end
+
+  def spawn(format, opts = {})
+    raise "BUG: #{self.inspect}#spawn called twice" if @pid
+    rv = []
+
+    pclass = @nonblock ? DTAS::PipeNB : DTAS::Pipe
+
+    cmd = command_string
+    inputs = parse(cmd)
+
+    if inputs.empty?
+      # /dev/fd/* not specified in the command, assume one input for stdin
+      r, w = pclass.new
+      w.pipe_size = @pipe_size if @pipe_size
+      inputs[:in] = opts[:in] = r
+      w.sink = self
+      rv << w
+    else
+      # multiple inputs, fun!, we'll tee to them
+      inputs.each_key do |name|
+        r, w = pclass.new
+        w.pipe_size = @pipe_size if @pipe_size
+        inputs[name] = r
+        w.sink = self
+        rv << w
+      end
+      opts[:in] = "/dev/null"
+
+      # map to real /dev/fd/* values and setup proper redirects
+      cmd = cmd.gsub(DEVFD_RE) do
+        read_fd = inputs[$1].fileno
+        opts[read_fd] = read_fd # do not close-on-exec
+        "/dev/fd/#{read_fd}"
+      end
+    end
+
+    @pid = dtas_spawn(format.to_env.merge!(@env), cmd, opts)
+    inputs.each_value { |rpipe| rpipe.close }
+    rv
+  end
+
+  def to_hash
+    ivars_to_hash(SIVS)
+  end
+
+  def to_hsh
+    to_hash.delete_if { |k,v| v == SINK_DEFAULTS[k] }
+  end
+end
diff --git a/lib/dtas/sink_reader_play.rb b/lib/dtas/sink_reader_play.rb
new file mode 100644
index 0000000..17a0190
--- /dev/null
+++ b/lib/dtas/sink_reader_play.rb
@@ -0,0 +1,70 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative '../dtas'
+
+# parses lines from play(1) -S/--show-progress like this:
+#  In:0.00% 00:00:37.34 [00:00:00.00] Out:1.65M [ -====|====  ]        Clip:0
+#
+# The authors of sox probably did not intend for the output of play(1) to
+# be parsed, but we do it anyways.  We need to be ready to update this
+# code in case play(1) output changes.
+# play -S/--show-progress
+class DTAS::SinkReaderPlay
+  attr_reader :time, :out, :meter, :clips, :headroom
+  attr_reader :to_io
+  attr_reader :wr # this is stderr of play(1)
+
+  def initialize
+    @to_io, @wr = IO.pipe
+    reset
+  end
+
+  def readable_iter
+    buf = Thread.current[:dtas_lbuf] ||= ""
+    begin
+      @rbuf << @to_io.read_nonblock(1024, buf)
+
+      # do not OOM in case SoX changes output format on us
+      @rbuf.clear if @rbuf.size > 0x10000
+
+      # don't handle partial read
+      next unless / Clip:\S+ *\z/ =~ @rbuf
+
+      if @rbuf.gsub!(/(.*)\rIn:\S+ (\S+) \S+ Out:(\S+)\s+(\[[^\]]+\]) /m, "")
+        err = $1
+        @time = $2
+        @out = $3
+        @meter = $4
+        if @rbuf.gsub!(/Hd:(\d+\.\d+) Clip:(\S+) */, "")
+          @headroom = $1
+          @clips = $2
+        elsif @rbuf.gsub!(/\s+Clip:(\S+) */, "")
+          @headroom = nil
+          @clips = $1
+        end
+
+        $stderr.write(err)
+      end
+    rescue EOFError
+      return nil
+    rescue Errno::EAGAIN
+      return :wait_readable
+    end while true
+  end
+
+  def close
+    @wr.close unless @wr.closed?
+    @to_io.close
+  end
+
+  def reset
+    @rbuf = ""
+    @time = @out = @meter = @headroom = @clips = nil
+  end
+
+  def closed?
+    @to_io.closed?
+  end
+end
diff --git a/lib/dtas/source.rb b/lib/dtas/source.rb
new file mode 100644
index 0000000..f6dd443
--- /dev/null
+++ b/lib/dtas/source.rb
@@ -0,0 +1,148 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative '../dtas'
+require_relative 'command'
+require_relative 'format'
+require_relative 'replaygain'
+require_relative 'process'
+require_relative 'serialize'
+
+# this is usually one input file
+class DTAS::Source
+  attr_reader :infile
+  attr_reader :offset
+  require_relative 'source/common'
+  require_relative 'source/mp3'
+
+  include DTAS::Command
+  include DTAS::Process
+  include DTAS::Source::Common
+  include DTAS::Source::Mp3
+
+  SOURCE_DEFAULTS = COMMAND_DEFAULTS.merge(
+    "command" => 'exec sox "$INFILE" $SOXFMT - $TRIMFX $RGFX',
+    "comments" => nil,
+  )
+
+  SIVS = %w(infile comments command env)
+
+  def initialize(infile, offset = nil)
+    command_init(SOURCE_DEFAULTS)
+    @format = nil
+    @infile = infile
+    @offset = offset
+    @comments = nil
+    @samples = nil
+  end
+
+  # this exists mainly to make the mpris interface easier, but it's not
+  # necessary, the mpris interface also knows the sample rate
+  def offset_us
+    (offset_samples / format.rate.to_f) * 1000000
+  end
+
+  # returns any offset in samples (relative to the original source file),
+  # likely zero unless seek was used
+  def offset_samples
+    return 0 unless @offset
+    case @offset
+    when /\A\d+s\z/
+      @offset.to_i
+    else
+      format.hhmmss_to_samples(@offset)
+    end
+  end
+
+  def precision
+    qx(%W(soxi -p #@infile), err: "/dev/null").to_i # sox.git f4562efd0aa3
+  rescue # fallback to parsing the whole output
+    s = qx(%W(soxi #@infile), err: "/dev/null")
+    s =~ /Precision\s+:\s*(\d+)-bit/
+    v = $1.to_i
+    return v if v > 0
+    raise TypeError, "could not determine precision for #@infile"
+  end
+
+  def format
+    @format ||= begin
+      fmt = DTAS::Format.new
+      fmt.from_file(@infile)
+      fmt.bits ||= precision
+      fmt
+    end
+  end
+
+  # A user may be downloading the file and start playing
+  # it before the download completes, this refreshes
+  def samples!
+    @samples = nil
+    samples
+  end
+
+  # This is the number of samples according to the samples in the source
+  # file itself, not the decoded output
+  def samples
+    @samples ||= qx(%W(soxi -s #@infile)).to_i
+  rescue => e
+    warn e.message
+    0
+  end
+
+  # just run soxi -a
+  def __load_comments
+    tmp = {}
+    case @infile
+    when String
+      err = ""
+      cmd = %W(soxi -a #@infile)
+      begin
+        qx(cmd, err: err).split(/\n/).each do |line|
+          key, value = line.split(/=/, 2)
+          key && value or next
+          # TODO: multi-line/multi-value/repeated tags
+          tmp[key.upcase] = value
+        end
+      rescue => e
+        if /FAIL formats: no handler for file extension/ =~ err
+          warn("#{xs(cmd)}: #{err}")
+        else
+          warn("#{e.message} (#{e.class})")
+        end
+        # TODO: fallbacks
+      end
+    end
+    tmp
+  end
+
+  def comments
+    @comments ||= __load_comments
+  end
+
+  def replaygain
+    DTAS::ReplayGain.new(comments) || DTAS::ReplayGain.new(mp3gain_comments)
+  end
+
+  def spawn(format, rg_state, opts)
+    raise "BUG: #{self.inspect}#spawn called twice" if @to_io
+    e = format.to_env
+    e["INFILE"] = @infile
+
+    # make sure these are visible to the "current" command...
+    @env["TRIMFX"] = @offset ? "trim #@offset" : nil
+    @env["RGFX"] = rg_state.effect(self) || nil
+
+    @pid = dtas_spawn(e.merge!(@env), command_string, opts)
+  end
+
+  def to_hsh
+    to_hash.delete_if { |k,v| v == SOURCE_DEFAULTS[k] }
+  end
+
+  def to_hash
+    rv = ivars_to_hash(SIVS)
+    rv["samples"] = samples
+    rv
+  end
+end
diff --git a/lib/dtas/source/command.rb b/lib/dtas/source/command.rb
new file mode 100644
index 0000000..30441eb
--- /dev/null
+++ b/lib/dtas/source/command.rb
@@ -0,0 +1,41 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative '../../dtas'
+require_relative '../source'
+require_relative '../command'
+require_relative '../serialize'
+
+class DTAS::Source::Command
+  require_relative '../source/common'
+
+  include DTAS::Command
+  include DTAS::Process
+  include DTAS::Source::Common
+  include DTAS::Serialize
+
+  SIVS = %w(command env)
+
+  def initialize(command)
+    command_init(command: command)
+  end
+
+  def source_dup
+    rv = self.class.new
+    SIVS.each { |iv| rv.__send__("#{iv}=", self.__send__(iv)) }
+    rv
+  end
+
+  def to_hash
+    ivars_to_hash(SIVS)
+  end
+
+  alias to_hsh to_hash
+
+  def spawn(format, rg_state, opts)
+    raise "BUG: #{self.inspect}#spawn called twice" if @to_io
+    e = format.to_env
+    @pid = dtas_spawn(e.merge!(@env), command_string, opts)
+  end
+end
diff --git a/lib/dtas/source/common.rb b/lib/dtas/source/common.rb
new file mode 100644
index 0000000..333e74a
--- /dev/null
+++ b/lib/dtas/source/common.rb
@@ -0,0 +1,14 @@
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+module DTAS::Source::Common
+  attr_reader :dst_zero_byte
+  attr_reader :dst
+  attr_accessor :requeued
+
+  def dst_assoc(buf)
+    @dst = buf
+    @dst_zero_byte = buf.bytes_xfer + buf.inflight
+    @requeued = false
+  end
+end
diff --git a/lib/dtas/source/mp3.rb b/lib/dtas/source/mp3.rb
new file mode 100644
index 0000000..b013bee
--- /dev/null
+++ b/lib/dtas/source/mp3.rb
@@ -0,0 +1,38 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative '../process'
+
+module DTAS::Source::Mp3
+  include DTAS::Process
+  # we use dBFS = 1.0 as scale (not 32768)
+  def __mp3gain_peak(str)
+    sprintf("%0.8g", str.to_f / 32768.0)
+  end
+
+  # massage mp3gain(1) output
+  def mp3gain_comments
+    tmp = {}
+    case @infile
+    when String
+      @infile =~ /\.mp[g23]\z/i or return
+      qx(%W(mp3gain -s c #@infile)).split(/\n/).each do |line|
+        case line
+        when /^Recommended "(Track|Album)" dB change:\s*(\S+)/
+          tmp["REPLAYGAIN_#{$1.upcase}_GAIN"] = $2
+        when /^Max PCM sample at current gain: (\S+)/
+          tmp["REPLAYGAIN_TRACK_PEAK"] = __mp3gain_peak($1)
+        when /^Max Album PCM sample at current gain: (\S+)/
+          tmp["REPLAYGAIN_ALBUM_PEAK"] = __mp3gain_peak($1)
+        end
+      end
+      tmp
+    else
+      raise TypeError, "unsupported type: #{@infile.inspect}"
+    end
+  rescue => e
+    $DEBUG and
+        warn("mp3gain(#{@infile.inspect}) failed: #{e.message} (#{e.class})")
+  end
+end
diff --git a/lib/dtas/state_file.rb b/lib/dtas/state_file.rb
new file mode 100644
index 0000000..cfd83d5
--- /dev/null
+++ b/lib/dtas/state_file.rb
@@ -0,0 +1,34 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'yaml'
+require 'tempfile'
+class DTAS::StateFile
+  def initialize(path, do_fsync = false)
+    @path = path
+    @do_fsync = do_fsync
+  end
+
+  def tryload
+    YAML.load(IO.binread(@path)) if File.readable?(@path)
+  end
+
+  def dump(obj, force_fsync = false)
+    yaml = obj.to_hsh.to_yaml.b
+
+    # do not replace existing state file if there are no changes
+    # this will be racy if we ever do async dumps or shared state
+    # files, but we don't do that...
+    return if File.readable?(@path) && IO.binread(@path) == yaml
+
+    dir = File.dirname(@path)
+    Tempfile.open(%w(player.state .tmp), dir) do |tmp|
+      tmp.binmode
+      tmp.write(yaml)
+      tmp.flush
+      tmp.fsync if @do_fsync || force_fsync
+      File.rename(tmp.path, @path)
+    end
+  end
+end
diff --git a/lib/dtas/unix_accepted.rb b/lib/dtas/unix_accepted.rb
new file mode 100644
index 0000000..6883ee1
--- /dev/null
+++ b/lib/dtas/unix_accepted.rb
@@ -0,0 +1,77 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'socket'
+require 'io/wait'
+
+class DTAS::UNIXAccepted
+  attr_reader :to_io
+
+  def initialize(sock)
+    @to_io = sock
+    @send_buf = []
+  end
+
+  # public API (for DTAS::Player)
+  # returns :wait_readable on success
+  def emit(msg)
+    buffered = @send_buf.size
+    if buffered == 0
+      begin
+        @to_io.sendmsg_nonblock(msg, Socket::MSG_EOR)
+        return :wait_readable
+      rescue Errno::EAGAIN
+        @send_buf << msg
+        return :wait_writable
+      rescue => e
+        return e
+      end
+    elsif buffered > 100
+      return RuntimeError.new("too many messages buffered")
+    else # buffered > 0
+      @send_buf << msg
+      return :wait_writable
+    end
+  end
+
+  # flushes pending data if it got buffered
+  def writable_iter
+    begin
+      msg = @send_buf.shift or return :wait_readable
+      @to_io.send_nonblock(msg, Socket::MSG_EOR)
+    rescue Errno::EAGAIN
+      @send_buf.unshift(msg)
+      return :wait_writable
+    rescue => e
+      return e
+    end while true
+  end
+
+  def readable_iter
+    io = @to_io
+    nread = io.nread
+
+    # EOF, assume no spurious wakeups for SOCK_SEQPACKET
+    return nil if nread == 0
+
+    begin
+      begin
+        msg, _, _ = io.recvmsg_nonblock(nread)
+      rescue EOFError, SystemCallError
+        return nil
+      end
+      yield(self, msg) # DTAS::Player deals with this
+      nread = io.nread
+    end while nread > 0
+    :wait_readable
+  end
+
+  def close
+    @to_io.close
+  end
+
+  def closed?
+    @to_io.closed?
+  end
+end
diff --git a/lib/dtas/unix_client.rb b/lib/dtas/unix_client.rb
new file mode 100644
index 0000000..f46eddf
--- /dev/null
+++ b/lib/dtas/unix_client.rb
@@ -0,0 +1,52 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'dtas'
+require 'socket'
+require 'io/wait'
+require 'shellwords'
+
+class DTAS::UNIXClient
+  attr_reader :to_io
+
+  def self.default_path
+    (ENV["DTAS_PLAYER_SOCK"] || File.expand_path("~/.dtas/player.sock")).b
+  end
+
+  def initialize(path = self.class.default_path)
+    @to_io = begin
+      raise if ENV["_DTAS_NOSEQPACKET"]
+      Socket.new(:AF_UNIX, :SOCK_SEQPACKET, 0)
+    rescue
+      warn("get your operating system developers to support " \
+           "SOCK_SEQPACKET for AF_UNIX sockets")
+      warn("falling back to SOCK_DGRAM, reliability possibly compromised")
+      Socket.new(:AF_UNIX, :SOCK_DGRAM, 0)
+    end
+    @to_io.connect(Socket.pack_sockaddr_un(path))
+  end
+
+  def req_start(args)
+    args = Shellwords.join(args) if Array === args
+    @to_io.send(args, Socket::MSG_EOR)
+  end
+
+  def req_ok(args, timeout = nil)
+    res = req(args, timeout)
+    res == "OK" or raise "Unexpected response: #{res}"
+    res
+  end
+
+  def req(args, timeout = nil)
+    req_start(args)
+    res_wait(timeout)
+  end
+
+  def res_wait(timeout = nil)
+    @to_io.wait(timeout)
+    nr = @to_io.nread
+    nr > 0 or raise EOFError, "unexpected EOF from server"
+    @to_io.recvmsg[0]
+  end
+end
diff --git a/lib/dtas/unix_server.rb b/lib/dtas/unix_server.rb
new file mode 100644
index 0000000..90f8479
--- /dev/null
+++ b/lib/dtas/unix_server.rb
@@ -0,0 +1,111 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'socket'
+require_relative '../dtas'
+require_relative 'unix_accepted'
+
+# This uses SOCK_SEQPACKET, unlike ::UNIXServer in Ruby stdlib
+
+# The programming model for the event loop here aims to be compatible
+# with EPOLLONESHOT use with epoll, since that fits my brain far better
+# than existing evented APIs/frameworks.
+# If we cared about scalability to thousands of clients, we'd really use epoll,
+# but IO.select can be just as fast (or faster) with few descriptors and
+# is obviously more portable.
+
+class DTAS::UNIXServer
+  attr_reader :to_io
+
+  def close
+    File.unlink(@path)
+    @to_io.close
+  end
+
+  def initialize(path)
+    @path = path
+    # lock down access by default, arbitrary commands may run as the
+    # same user dtas-player runs as:
+    old_umask = File.umask(0077)
+    @to_io = Socket.new(:AF_UNIX, :SOCK_SEQPACKET, 0)
+    addr = Socket.pack_sockaddr_un(path)
+    begin
+      @to_io.bind(addr)
+    rescue Errno::EADDRINUSE
+      # maybe we have an old path leftover from a killed process
+      tmp = Socket.new(:AF_UNIX, :SOCK_SEQPACKET, 0)
+      begin
+        tmp.connect(addr)
+        raise RuntimeError, "socket `#{path}' is in use", []
+      rescue Errno::ECONNREFUSED
+        # ok, leftover socket, unlink and rebind anyways
+        File.unlink(path)
+        @to_io.bind(addr)
+      ensure
+        tmp.close
+      end
+    end
+    @to_io.listen(1024)
+    @readers = { self => true }
+    @writers = {}
+  ensure
+    File.umask(old_umask)
+  end
+
+  def write_failed(client, e)
+    warn "failed to write to #{client}: #{e.message} (#{e.class})"
+    client.close
+  end
+
+  def readable_iter
+    # we do not do anything with the block passed to us
+    begin
+      sock, _ = @to_io.accept_nonblock
+      @readers[DTAS::UNIXAccepted.new(sock)] = true
+    rescue Errno::ECONNABORTED # ignore this, it happens
+    rescue Errno::EAGAIN
+      return :wait_readable
+    end while true
+  end
+
+  def wait_ctl(io, err)
+    case err
+    when :wait_readable
+      @readers[io] = true
+    when :wait_writable
+      @writers[io] = true
+    when :delete
+      @readers.delete(io)
+      @writers.delete(io)
+    when :ignore
+      # There are 2 cases for :ignore
+      # - DTAS::Buffer was readable before, but all destinations (e.g. sinks)
+      #   were blocked, so we stop caring for producer (buffer) readability.
+      # - a consumer (e.g. DTAS::Sink) just became writable, but the
+      #   corresponding DTAS::Buffer was already readable in a previous
+      #   call.
+    when nil
+      io.close
+    when StandardError
+      io.close
+    else
+      raise "BUG: wait_ctl invalid: #{io} #{err.inspect}"
+    end
+  end
+
+  def run_once
+    begin
+      # give IO.select one-shot behavior, snapshot and replace the watchlist
+      r = IO.select(@readers.keys, @writers.keys) or return
+      r[1].each do |io|
+        @writers.delete(io)
+        wait_ctl(io, io.writable_iter)
+      end
+      r[0].each do |io|
+        @readers.delete(io)
+        wait_ctl(io, io.readable_iter { |_io, msg| yield(_io, msg) })
+      end
+    end
+  end
+end
diff --git a/lib/dtas/util.rb b/lib/dtas/util.rb
new file mode 100644
index 0000000..03c7ded
--- /dev/null
+++ b/lib/dtas/util.rb
@@ -0,0 +1,16 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative '../dtas'
+
+# in case we need to convert DB values to a linear scale
+module DTAS::Util
+  def db_to_linear(val)
+    Math.exp(val * Math.log(10) * 0.05)
+  end
+
+  def linear_to_db(val)
+    Math.log10(val) * 20
+  end
+end
diff --git a/lib/dtas/writable_iter.rb b/lib/dtas/writable_iter.rb
new file mode 100644
index 0000000..aa02905
--- /dev/null
+++ b/lib/dtas/writable_iter.rb
@@ -0,0 +1,23 @@
+# -*- encoding: binary -*-
+# :stopdoc:
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative '../dtas'
+
+module DTAS::WritableIter
+  attr_accessor :on_writable
+
+  def writable_iter_init
+    @on_writable = nil
+  end
+
+  # this is used to exchange our own writable status for the readable
+  # status of the DTAS::Buffer which triggered us.
+  def writable_iter
+    if owr = @on_writable
+      @on_writable = nil
+      owr.call # this triggers readability watching of DTAS::Buffer
+    end
+    :ignore
+  end
+end
diff --git a/pkg.mk b/pkg.mk
new file mode 100644
index 0000000..eb71d13
--- /dev/null
+++ b/pkg.mk
@@ -0,0 +1,176 @@
+RUBY = ruby
+RAKE = rake
+RSYNC = rsync
+WRONGDOC = wrongdoc
+
+GIT-VERSION-FILE: .FORCE-GIT-VERSION-FILE
+        @./GIT-VERSION-GEN
+-include GIT-VERSION-FILE
+-include local.mk
+DLEXT := $(shell $(RUBY) -rrbconfig -e 'puts RbConfig::CONFIG["DLEXT"]')
+RUBY_VERSION := $(shell $(RUBY) -e 'puts RUBY_VERSION')
+RUBY_ENGINE := $(shell $(RUBY) -e 'puts((RUBY_ENGINE rescue "ruby"))')
+lib := lib
+
+ifeq ($(shell test -f script/isolate_for_tests && echo t),t)
+isolate_libs := tmp/isolate/$(RUBY_ENGINE)-$(RUBY_VERSION)/isolate.mk
+$(isolate_libs): script/isolate_for_tests
+        @$(RUBY) script/isolate_for_tests
+-include $(isolate_libs)
+lib := $(lib):$(ISOLATE_LIBS)
+endif
+
+ext := $(firstword $(wildcard ext/*))
+ifneq ($(ext),)
+ext_pfx := tmp/ext/$(RUBY_ENGINE)-$(RUBY_VERSION)
+ext_h := $(wildcard $(ext)/*/*.h $(ext)/*.h)
+ext_src := $(wildcard $(ext)/*.c $(ext_h))
+ext_pfx_src := $(addprefix $(ext_pfx)/,$(ext_src))
+ext_d := $(ext_pfx)/$(ext)/.d
+$(ext)/extconf.rb: $(wildcard $(ext)/*.h)
+        @>> $@
+$(ext_d):
+        @mkdir -p $(@D)
+        @> $@
+$(ext_pfx)/$(ext)/%: $(ext)/% $(ext_d)
+        install -m 644 $< $@
+$(ext_pfx)/$(ext)/Makefile: $(ext)/extconf.rb $(ext_d) $(ext_h)
+        $(RM) -f $(@D)/*.o
+        cd $(@D) && $(RUBY) $(CURDIR)/$(ext)/extconf.rb
+ext_sfx := _ext.$(DLEXT)
+ext_dl := $(ext_pfx)/$(ext)/$(notdir $(ext)_ext.$(DLEXT))
+$(ext_dl): $(ext_src) $(ext_pfx_src) $(ext_pfx)/$(ext)/Makefile
+        @echo $^ == $@
+        $(MAKE) -C $(@D)
+lib := $(lib):$(ext_pfx)/$(ext)
+build: $(ext_dl)
+else
+build:
+endif
+
+pkg_extra += GIT-VERSION-FILE NEWS ChangeLog LATEST
+ChangeLog: GIT-VERSION-FILE .wrongdoc.yml
+        $(WRONGDOC) prepare
+NEWS LATEST: ChangeLog
+
+manifest:
+        $(RM) .manifest
+        $(MAKE) .manifest
+
+.manifest: $(pkg_extra)
+        (git ls-files && for i in $@ $(pkg_extra); do echo $$i; done) | \
+                LC_ALL=C sort > $@+
+        cmp $@+ $@ || mv $@+ $@
+        $(RM) $@+
+
+doc:: .document .wrongdoc.yml $(pkg_extra)
+        -find lib -type f -name '*.rbc' -exec rm -f '{}' ';'
+        -find ext -type f -name '*.rbc' -exec rm -f '{}' ';'
+        $(RM) -r doc
+        $(WRONGDOC) all
+        install -m644 GPL-2 doc/GPL-2
+        install -m644 GPL-3 doc/GPL-3
+        install -m644 $(shell LC_ALL=C grep '^[A-Z]' .document) doc/
+
+ifneq ($(VERSION),)
+pkggem := pkg/$(rfpackage)-$(VERSION).gem
+pkgtgz := pkg/$(rfpackage)-$(VERSION).tgz
+release_notes := release_notes-$(VERSION)
+release_changes := release_changes-$(VERSION)
+
+release-notes: $(release_notes)
+release-changes: $(release_changes)
+$(release_changes):
+        $(WRONGDOC) release_changes > $@+
+        $(VISUAL) $@+ && test -s $@+ && mv $@+ $@
+$(release_notes):
+        $(WRONGDOC) release_notes > $@+
+        $(VISUAL) $@+ && test -s $@+ && mv $@+ $@
+
+# ensures we're actually on the tagged $(VERSION), only used for release
+verify:
+        test x"$(shell umask)" = x0022
+        git rev-parse --verify refs/tags/v$(VERSION)^{}
+        git diff-index --quiet HEAD^0
+        test $$(git rev-parse --verify HEAD^0) = \
+             $$(git rev-parse --verify refs/tags/v$(VERSION)^{})
+
+fix-perms:
+        -git ls-tree -r HEAD | awk '/^100644 / {print $$NF}' | xargs chmod 644
+        -git ls-tree -r HEAD | awk '/^100755 / {print $$NF}' | xargs chmod 755
+
+gem: $(pkggem)
+
+install-gem: $(pkggem)
+        gem install $(CURDIR)/$<
+
+$(pkggem): manifest fix-perms
+        gem build $(rfpackage).gemspec
+        mkdir -p pkg
+        mv $(@F) $@
+
+$(pkgtgz): distdir = $(basename $@)
+$(pkgtgz): HEAD = v$(VERSION)
+$(pkgtgz): manifest fix-perms
+        @test -n "$(distdir)"
+        $(RM) -r $(distdir)
+        mkdir -p $(distdir)
+        tar cf - $$(cat .manifest) | (cd $(distdir) && tar xf -)
+        cd pkg && tar cf - $(basename $(@F)) | gzip -9 > $(@F)+
+        mv $@+ $@
+
+package: $(pkgtgz) $(pkggem)
+
+test-release:: verify package $(release_notes) $(release_changes)
+        # make tgz release on RubyForge
+        @echo rubyforge add_release -f \
+          -n $(release_notes) -a $(release_changes) \
+          $(rfproject) $(rfpackage) $(VERSION) $(pkgtgz)
+        @echo gem push $(pkggem)
+        @echo rubyforge add_file \
+          $(rfproject) $(rfpackage) $(VERSION) $(pkggem)
+release:: verify package $(release_notes) $(release_changes)
+        # make tgz release on RubyForge
+        rubyforge add_release -f -n $(release_notes) -a $(release_changes) \
+          $(rfproject) $(rfpackage) $(VERSION) $(pkgtgz)
+        # push gem to RubyGems.org
+        gem push $(pkggem)
+        # in case of gem downloads from RubyForge releases page
+        rubyforge add_file \
+          $(rfproject) $(rfpackage) $(VERSION) $(pkggem)
+else
+gem install-gem: GIT-VERSION-FILE
+        $(MAKE) $@ VERSION=$(GIT_VERSION)
+endif
+
+all:: test
+test_units := $(wildcard test/test_*.rb)
+test: test-unit
+test-unit: $(test_units)
+$(test_units): build
+        $(RUBY) -I $(lib) $@ $(RUBY_TEST_OPTS)
+
+# this requires GNU coreutils variants
+ifneq ($(RSYNC_DEST),)
+publish_doc:
+        -git set-file-times
+        $(MAKE) doc
+        find doc/images -type f | \
+                TZ=UTC xargs touch -d '1970-01-01 00:00:06' doc/rdoc.css
+        $(MAKE) doc_gz
+        $(RSYNC) -av doc/ $(RSYNC_DEST)/
+        git ls-files | xargs touch
+endif
+
+# Create gzip variants of the same timestamp as the original so nginx
+# "gzip_static on" can serve the gzipped versions directly.
+doc_gz: docs = $(shell find doc -type f ! -regex '^.*\.\(gif\|jpg\|png\|gz\)$$')
+doc_gz:
+        for i in $(docs); do \
+          gzip --rsyncable -9 < $$i > $$i.gz; touch -r $$i $$i.gz; done
+check-warnings:
+        @(for i in $$(git ls-files '*.rb'| grep -v '^setup\.rb$$'); \
+          do $(RUBY) -d -W2 -c $$i; done) | grep -v '^Syntax OK$$' || :
+
+.PHONY: all .FORCE-GIT-VERSION-FILE doc test $(test_units) manifest
+.PHONY: check-warnings
diff --git a/setup.rb b/setup.rb
new file mode 100644
index 0000000..5eb5006
--- /dev/null
+++ b/setup.rb
@@ -0,0 +1,1586 @@
+# -*- encoding: binary -*-
+#
+# setup.rb
+#
+# Copyright (c) 2000-2005 Minero Aoki
+#
+# This program is free software.
+# You can distribute/modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+#
+
+unless Enumerable.method_defined?(:map)   # Ruby 1.4.6
+  module Enumerable
+    alias map collect
+  end
+end
+
+unless File.respond_to?(:read)   # Ruby 1.6
+  def File.read(fname)
+    open(fname) {|f|
+      return f.read
+    }
+  end
+end
+
+unless Errno.const_defined?(:ENOTEMPTY)   # Windows?
+  module Errno
+    class ENOTEMPTY
+      # We do not raise this exception, implementation is not needed.
+    end
+  end
+end
+
+def File.binread(fname)
+  open(fname, 'rb') {|f|
+    return f.read
+  }
+end
+
+# for corrupted Windows' stat(2)
+def File.dir?(path)
+  File.directory?((path[-1,1] == '/') ? path : path + '/')
+end
+
+
+class ConfigTable
+
+  include Enumerable
+
+  def initialize(rbconfig)
+    @rbconfig = rbconfig
+    @items = []
+    @table = {}
+    # options
+    @install_prefix = nil
+    @config_opt = nil
+    @verbose = true
+    @no_harm = false
+  end
+
+  attr_accessor :install_prefix
+  attr_accessor :config_opt
+
+  attr_writer :verbose
+
+  def verbose?
+    @verbose
+  end
+
+  attr_writer :no_harm
+
+  def no_harm?
+    @no_harm
+  end
+
+  def [](key)
+    lookup(key).resolve(self)
+  end
+
+  def []=(key, val)
+    lookup(key).set val
+  end
+
+  def names
+    @items.map {|i| i.name }
+  end
+
+  def each(&block)
+    @items.each(&block)
+  end
+
+  def key?(name)
+    @table.key?(name)
+  end
+
+  def lookup(name)
+    @table[name] or setup_rb_error "no such config item: #{name}"
+  end
+
+  def add(item)
+    @items.push item
+    @table[item.name] = item
+  end
+
+  def remove(name)
+    item = lookup(name)
+    @items.delete_if {|i| i.name == name }
+    @table.delete_if {|name, i| i.name == name }
+    item
+  end
+
+  def load_script(path, inst = nil)
+    if File.file?(path)
+      MetaConfigEnvironment.new(self, inst).instance_eval File.read(path), path
+    end
+  end
+
+  def savefile
+    '.config'
+  end
+
+  def load_savefile
+    begin
+      File.foreach(savefile()) do |line|
+        k, v = *line.split(/=/, 2)
+        self[k] = v.strip
+      end
+    rescue Errno::ENOENT
+      setup_rb_error $!.message + "\n#{File.basename($0)} config first"
+    end
+  end
+
+  def save
+    @items.each {|i| i.value }
+    File.open(savefile(), 'w') {|f|
+      @items.each do |i|
+        f.printf "%s=%s\n", i.name, i.value if i.value? and i.value
+      end
+    }
+  end
+
+  def load_standard_entries
+    standard_entries(@rbconfig).each do |ent|
+      add ent
+    end
+  end
+
+  def standard_entries(rbconfig)
+    c = rbconfig
+
+    rubypath = File.join(c['bindir'], c['ruby_install_name'] + c['EXEEXT'])
+
+    major = c['MAJOR'].to_i
+    minor = c['MINOR'].to_i
+    teeny = c['TEENY'].to_i
+    version = "#{major}.#{minor}"
+
+    # ruby ver. >= 1.4.4?
+    newpath_p = ((major >= 2) or
+                 ((major == 1) and
+                  ((minor >= 5) or
+                   ((minor == 4) and (teeny >= 4)))))
+
+    if c['rubylibdir']
+      # V > 1.6.3
+      libruby         = "#{c['prefix']}/lib/ruby"
+      librubyver      = c['rubylibdir']
+      librubyverarch  = c['archdir']
+      siteruby        = c['sitedir']
+      siterubyver     = c['sitelibdir']
+      siterubyverarch = c['sitearchdir']
+    elsif newpath_p
+      # 1.4.4 <= V <= 1.6.3
+      libruby         = "#{c['prefix']}/lib/ruby"
+      librubyver      = "#{c['prefix']}/lib/ruby/#{version}"
+      librubyverarch  = "#{c['prefix']}/lib/ruby/#{version}/#{c['arch']}"
+      siteruby        = c['sitedir']
+      siterubyver     = "$siteruby/#{version}"
+      siterubyverarch = "$siterubyver/#{c['arch']}"
+    else
+      # V < 1.4.4
+      libruby         = "#{c['prefix']}/lib/ruby"
+      librubyver      = "#{c['prefix']}/lib/ruby/#{version}"
+      librubyverarch  = "#{c['prefix']}/lib/ruby/#{version}/#{c['arch']}"
+      siteruby        = "#{c['prefix']}/lib/ruby/#{version}/site_ruby"
+      siterubyver     = siteruby
+      siterubyverarch = "$siterubyver/#{c['arch']}"
+    end
+    parameterize = lambda {|path|
+      path.sub(/\A#{Regexp.quote(c['prefix'])}/, '$prefix')
+    }
+
+    if arg = c['configure_args'].split.detect {|arg| /--with-make-prog=/ =~ arg }
+      makeprog = arg.sub(/'/, '').split(/=/, 2)[1]
+    else
+      makeprog = 'make'
+    end
+
+    [
+      ExecItem.new('installdirs', 'std/site/home',
+                   'std: install under libruby; site: install under site_ruby; home: install under $HOME')\
+          {|val, table|
+            case val
+            when 'std'
+              table['rbdir'] = '$librubyver'
+              table['sodir'] = '$librubyverarch'
+            when 'site'
+              table['rbdir'] = '$siterubyver'
+              table['sodir'] = '$siterubyverarch'
+            when 'home'
+              setup_rb_error '$HOME was not set' unless ENV['HOME']
+              table['prefix'] = ENV['HOME']
+              table['rbdir'] = '$libdir/ruby'
+              table['sodir'] = '$libdir/ruby'
+            end
+          },
+      PathItem.new('prefix', 'path', c['prefix'],
+                   'path prefix of target environment'),
+      PathItem.new('bindir', 'path', parameterize.call(c['bindir']),
+                   'the directory for commands'),
+      PathItem.new('libdir', 'path', parameterize.call(c['libdir']),
+                   'the directory for libraries'),
+      PathItem.new('datadir', 'path', parameterize.call(c['datadir']),
+                   'the directory for shared data'),
+      PathItem.new('mandir', 'path', parameterize.call(c['mandir']),
+                   'the directory for man pages'),
+      PathItem.new('sysconfdir', 'path', parameterize.call(c['sysconfdir']),
+                   'the directory for system configuration files'),
+      PathItem.new('localstatedir', 'path', parameterize.call(c['localstatedir']),
+                   'the directory for local state data'),
+      PathItem.new('libruby', 'path', libruby,
+                   'the directory for ruby libraries'),
+      PathItem.new('librubyver', 'path', librubyver,
+                   'the directory for standard ruby libraries'),
+      PathItem.new('librubyverarch', 'path', librubyverarch,
+                   'the directory for standard ruby extensions'),
+      PathItem.new('siteruby', 'path', siteruby,
+          'the directory for version-independent aux ruby libraries'),
+      PathItem.new('siterubyver', 'path', siterubyver,
+                   'the directory for aux ruby libraries'),
+      PathItem.new('siterubyverarch', 'path', siterubyverarch,
+                   'the directory for aux ruby binaries'),
+      PathItem.new('rbdir', 'path', '$siterubyver',
+                   'the directory for ruby scripts'),
+      PathItem.new('sodir', 'path', '$siterubyverarch',
+                   'the directory for ruby extentions'),
+      PathItem.new('rubypath', 'path', rubypath,
+                   'the path to set to #! line'),
+      ProgramItem.new('rubyprog', 'name', rubypath,
+                      'the ruby program using for installation'),
+      ProgramItem.new('makeprog', 'name', makeprog,
+                      'the make program to compile ruby extentions'),
+      SelectItem.new('shebang', 'all/ruby/never', 'ruby',
+                     'shebang line (#!) editing mode'),
+      BoolItem.new('without-ext', 'yes/no', 'no',
+                   'does not compile/install ruby extentions')
+    ]
+  end
+  private :standard_entries
+
+  def load_multipackage_entries
+    multipackage_entries().each do |ent|
+      add ent
+    end
+  end
+
+  def multipackage_entries
+    [
+      PackageSelectionItem.new('with', 'name,name...', '', 'ALL',
+                               'package names that you want to install'),
+      PackageSelectionItem.new('without', 'name,name...', '', 'NONE',
+                               'package names that you do not want to install')
+    ]
+  end
+  private :multipackage_entries
+
+  ALIASES = {
+    'std-ruby'         => 'librubyver',
+    'stdruby'          => 'librubyver',
+    'rubylibdir'       => 'librubyver',
+    'archdir'          => 'librubyverarch',
+    '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',
+    'ruby-path'        => 'rubypath',
+    'ruby-prog'        => 'rubyprog',
+    'ruby'             => 'rubyprog',
+    'make-prog'        => 'makeprog',
+    'make'             => 'makeprog'
+  }
+
+  def fixup
+    ALIASES.each do |ali, name|
+      @table[ali] = @table[name]
+    end
+    @items.freeze
+    @table.freeze
+    @options_re = /\A--(#{@table.keys.join('|')})(?:=(.*))?\z/
+  end
+
+  def parse_opt(opt)
+    m = @options_re.match(opt) or setup_rb_error "config: unknown option #{opt}"
+    m.to_a[1,2]
+  end
+
+  def dllext
+    @rbconfig['DLEXT']
+  end
+
+  def value_config?(name)
+    lookup(name).value?
+  end
+
+  class Item
+    def initialize(name, template, default, desc)
+      @name = name.freeze
+      @template = template
+      @value = default
+      @default = default
+      @description = desc
+    end
+
+    attr_reader :name
+    attr_reader :description
+
+    attr_accessor :default
+    alias help_default default
+
+    def help_opt
+      "--#{@name}=#{@template}"
+    end
+
+    def value?
+      true
+    end
+
+    def value
+      @value
+    end
+
+    def resolve(table)
+      @value.gsub(%r<\$([^/]+)>) { table[$1] }
+    end
+
+    def set(val)
+      @value = check(val)
+    end
+
+    private
+
+    def check(val)
+      setup_rb_error "config: --#{name} requires argument" unless val
+      val
+    end
+  end
+
+  class BoolItem < Item
+    def config_type
+      'bool'
+    end
+
+    def help_opt
+      "--#{@name}"
+    end
+
+    private
+
+    def check(val)
+      return 'yes' unless val
+      case val
+      when /\Ay(es)?\z/i, /\At(rue)?\z/i then 'yes'
+      when /\An(o)?\z/i, /\Af(alse)\z/i  then 'no'
+      else
+        setup_rb_error "config: --#{@name} accepts only yes/no for argument"
+      end
+    end
+  end
+
+  class PathItem < Item
+    def config_type
+      'path'
+    end
+
+    private
+
+    def check(path)
+      setup_rb_error "config: --#{@name} requires argument"  unless path
+      path[0,1] == '$' ? path : File.expand_path(path)
+    end
+  end
+
+  class ProgramItem < Item
+    def config_type
+      'program'
+    end
+  end
+
+  class SelectItem < Item
+    def initialize(name, selection, default, desc)
+      super
+      @ok = selection.split('/')
+    end
+
+    def config_type
+      'select'
+    end
+
+    private
+
+    def check(val)
+      unless @ok.include?(val.strip)
+        setup_rb_error "config: use --#{@name}=#{@template} (#{val})"
+      end
+      val.strip
+    end
+  end
+
+  class ExecItem < Item
+    def initialize(name, selection, desc, &block)
+      super name, selection, nil, desc
+      @ok = selection.split('/')
+      @action = block
+    end
+
+    def config_type
+      'exec'
+    end
+
+    def value?
+      false
+    end
+
+    def resolve(table)
+      setup_rb_error "$#{name()} wrongly used as option value"
+    end
+
+    undef set
+
+    def evaluate(val, table)
+      v = val.strip.downcase
+      unless @ok.include?(v)
+        setup_rb_error "invalid option --#{@name}=#{val} (use #{@template})"
+      end
+      @action.call v, table
+    end
+  end
+
+  class PackageSelectionItem < Item
+    def initialize(name, template, default, help_default, desc)
+      super name, template, default, desc
+      @help_default = help_default
+    end
+
+    attr_reader :help_default
+
+    def config_type
+      'package'
+    end
+
+    private
+
+    def check(val)
+      unless File.dir?("packages/#{val}")
+        setup_rb_error "config: no such package: #{val}"
+      end
+      val
+    end
+  end
+
+  class MetaConfigEnvironment
+    def initialize(config, installer)
+      @config = config
+      @installer = installer
+    end
+
+    def config_names
+      @config.names
+    end
+
+    def config?(name)
+      @config.key?(name)
+    end
+
+    def bool_config?(name)
+      @config.lookup(name).config_type == 'bool'
+    end
+
+    def path_config?(name)
+      @config.lookup(name).config_type == 'path'
+    end
+
+    def value_config?(name)
+      @config.lookup(name).config_type != 'exec'
+    end
+
+    def add_config(item)
+      @config.add item
+    end
+
+    def add_bool_config(name, default, desc)
+      @config.add BoolItem.new(name, 'yes/no', default ? 'yes' : 'no', desc)
+    end
+
+    def add_path_config(name, default, desc)
+      @config.add PathItem.new(name, 'path', default, desc)
+    end
+
+    def set_config_default(name, default)
+      @config.lookup(name).default = default
+    end
+
+    def remove_config(name)
+      @config.remove(name)
+    end
+
+    # For only multipackage
+    def packages
+      raise '[setup.rb fatal] multi-package metaconfig API packages() called for single-package; contact application package vendor' unless @installer
+      @installer.packages
+    end
+
+    # For only multipackage
+    def declare_packages(list)
+      raise '[setup.rb fatal] multi-package metaconfig API declare_packages() called for single-package; contact application package vendor' unless @installer
+      @installer.packages = list
+    end
+  end
+
+end   # class ConfigTable
+
+
+# This module requires: #verbose?, #no_harm?
+module FileOperations
+
+  def mkdir_p(dirname, prefix = nil)
+    dirname = prefix + File.expand_path(dirname) if prefix
+    $stderr.puts "mkdir -p #{dirname}" if verbose?
+    return if no_harm?
+
+    # Does not check '/', it's too abnormal.
+    dirs = File.expand_path(dirname).split(%r<(?=/)>)
+    if /\A[a-z]:\z/i =~ dirs[0]
+      disk = dirs.shift
+      dirs[0] = disk + dirs[0]
+    end
+    dirs.each_index do |idx|
+      path = dirs[0..idx].join('')
+      Dir.mkdir path unless File.dir?(path)
+    end
+  end
+
+  def rm_f(path)
+    $stderr.puts "rm -f #{path}" if verbose?
+    return if no_harm?
+    force_remove_file path
+  end
+
+  def rm_rf(path)
+    $stderr.puts "rm -rf #{path}" if verbose?
+    return if no_harm?
+    remove_tree path
+  end
+
+  def remove_tree(path)
+    if File.symlink?(path)
+      remove_file path
+    elsif File.dir?(path)
+      remove_tree0 path
+    else
+      force_remove_file path
+    end
+  end
+
+  def remove_tree0(path)
+    Dir.foreach(path) do |ent|
+      next if ent == '.'
+      next if ent == '..'
+      entpath = "#{path}/#{ent}"
+      if File.symlink?(entpath)
+        remove_file entpath
+      elsif File.dir?(entpath)
+        remove_tree0 entpath
+      else
+        force_remove_file entpath
+      end
+    end
+    begin
+      Dir.rmdir path
+    rescue Errno::ENOTEMPTY
+      # directory may not be empty
+    end
+  end
+
+  def move_file(src, dest)
+    force_remove_file dest
+    begin
+      File.rename src, dest
+    rescue
+      File.open(dest, 'wb') {|f|
+        f.write File.binread(src)
+      }
+      File.chmod File.stat(src).mode, dest
+      File.unlink src
+    end
+  end
+
+  def force_remove_file(path)
+    begin
+      remove_file path
+    rescue
+    end
+  end
+
+  def remove_file(path)
+    File.chmod 0777, path
+    File.unlink path
+  end
+
+  def install(from, dest, mode, prefix = nil)
+    $stderr.puts "install #{from} #{dest}" if verbose?
+    return if no_harm?
+
+    realdest = prefix ? prefix + File.expand_path(dest) : dest
+    realdest = File.join(realdest, File.basename(from)) if File.dir?(realdest)
+    str = File.binread(from)
+    if diff?(str, realdest)
+      verbose_off {
+        rm_f realdest if File.exist?(realdest)
+      }
+      File.open(realdest, 'wb') {|f|
+        f.write str
+      }
+      File.chmod mode, realdest
+
+      File.open("#{objdir_root()}/InstalledFiles", 'a') {|f|
+        if prefix
+          f.puts realdest.sub(prefix, '')
+        else
+          f.puts realdest
+        end
+      }
+    end
+  end
+
+  def diff?(new_content, path)
+    return true unless File.exist?(path)
+    new_content != File.binread(path)
+  end
+
+  def command(*args)
+    $stderr.puts args.join(' ') if verbose?
+    system(*args) or raise RuntimeError,
+        "system(#{args.map{|a| a.inspect }.join(' ')}) failed"
+  end
+
+  def ruby(*args)
+    command config('rubyprog'), *args
+  end
+
+  def make(task = nil)
+    command(*[config('makeprog'), task].compact)
+  end
+
+  def extdir?(dir)
+    File.exist?("#{dir}/MANIFEST") or File.exist?("#{dir}/extconf.rb")
+  end
+
+  def files_of(dir)
+    Dir.open(dir) {|d|
+      return d.select {|ent| File.file?("#{dir}/#{ent}") }
+    }
+  end
+
+  DIR_REJECT = %w( . .. CVS SCCS RCS CVS.adm .svn )
+
+  def directories_of(dir)
+    Dir.open(dir) {|d|
+      return d.select {|ent| File.dir?("#{dir}/#{ent}") } - DIR_REJECT
+    }
+  end
+
+end
+
+
+# This module requires: #srcdir_root, #objdir_root, #relpath
+module HookScriptAPI
+
+  def get_config(key)
+    @config[key]
+  end
+
+  alias config get_config
+
+  # obsolete: use metaconfig to change configuration
+  def set_config(key, val)
+    @config[key] = val
+  end
+
+  #
+  # srcdir/objdir (works only in the package directory)
+  #
+
+  def curr_srcdir
+    "#{srcdir_root()}/#{relpath()}"
+  end
+
+  def curr_objdir
+    "#{objdir_root()}/#{relpath()}"
+  end
+
+  def srcfile(path)
+    "#{curr_srcdir()}/#{path}"
+  end
+
+  def srcexist?(path)
+    File.exist?(srcfile(path))
+  end
+
+  def srcdirectory?(path)
+    File.dir?(srcfile(path))
+  end
+
+  def srcfile?(path)
+    File.file?(srcfile(path))
+  end
+
+  def srcentries(path = '.')
+    Dir.open("#{curr_srcdir()}/#{path}") {|d|
+      return d.to_a - %w(. ..)
+    }
+  end
+
+  def srcfiles(path = '.')
+    srcentries(path).select {|fname|
+      File.file?(File.join(curr_srcdir(), path, fname))
+    }
+  end
+
+  def srcdirectories(path = '.')
+    srcentries(path).select {|fname|
+      File.dir?(File.join(curr_srcdir(), path, fname))
+    }
+  end
+
+end
+
+
+class ToplevelInstaller
+
+  Version   = '3.4.1'
+  Copyright = 'Copyright (c) 2000-2005 Minero Aoki'
+
+  TASKS = [
+    [ 'all',      'do config, setup, then install' ],
+    [ 'config',   'saves your configurations' ],
+    [ 'show',     'shows current configuration' ],
+    [ 'setup',    'compiles ruby extentions and others' ],
+    [ 'install',  'installs files' ],
+    [ 'test',     'run all tests in test/' ],
+    [ 'clean',    "does `make clean' for each extention" ],
+    [ 'distclean',"does `make distclean' for each extention" ]
+  ]
+
+  def ToplevelInstaller.invoke
+    config = ConfigTable.new(load_rbconfig())
+    config.load_standard_entries
+    config.load_multipackage_entries if multipackage?
+    config.fixup
+    klass = (multipackage?() ? ToplevelInstallerMulti : ToplevelInstaller)
+    klass.new(File.dirname($0), config).invoke
+  end
+
+  def ToplevelInstaller.multipackage?
+    File.dir?(File.dirname($0) + '/packages')
+  end
+
+  def ToplevelInstaller.load_rbconfig
+    if arg = ARGV.detect {|arg| /\A--rbconfig=/ =~ arg }
+      ARGV.delete(arg)
+      load File.expand_path(arg.split(/=/, 2)[1])
+      $".push 'rbconfig.rb'
+    else
+      require 'rbconfig'
+    end
+    ::Config::CONFIG
+  end
+
+  def initialize(ardir_root, config)
+    @ardir = File.expand_path(ardir_root)
+    @config = config
+    # cache
+    @valid_task_re = nil
+  end
+
+  def config(key)
+    @config[key]
+  end
+
+  def inspect
+    "#<#{self.class} #{__id__()}>"
+  end
+
+  def invoke
+    run_metaconfigs
+    case task = parsearg_global()
+    when nil, 'all'
+      parsearg_config
+      init_installers
+      exec_config
+      exec_setup
+      exec_install
+    else
+      case task
+      when 'config', 'test'
+        ;
+      when 'clean', 'distclean'
+        @config.load_savefile if File.exist?(@config.savefile)
+      else
+        @config.load_savefile
+      end
+      __send__ "parsearg_#{task}"
+      init_installers
+      __send__ "exec_#{task}"
+    end
+  end
+
+  def run_metaconfigs
+    @config.load_script "#{@ardir}/metaconfig"
+  end
+
+  def init_installers
+    @installer = Installer.new(@config, @ardir, File.expand_path('.'))
+  end
+
+  #
+  # Hook Script API bases
+  #
+
+  def srcdir_root
+    @ardir
+  end
+
+  def objdir_root
+    '.'
+  end
+
+  def relpath
+    '.'
+  end
+
+  #
+  # Option Parsing
+  #
+
+  def parsearg_global
+    while arg = ARGV.shift
+      case arg
+      when /\A\w+\z/
+        setup_rb_error "invalid task: #{arg}" unless valid_task?(arg)
+        return arg
+      when '-q', '--quiet'
+        @config.verbose = false
+      when '--verbose'
+        @config.verbose = true
+      when '--help'
+        print_usage $stdout
+        exit 0
+      when '--version'
+        puts "#{File.basename($0)} version #{Version}"
+        exit 0
+      when '--copyright'
+        puts Copyright
+        exit 0
+      else
+        setup_rb_error "unknown global option '#{arg}'"
+      end
+    end
+    nil
+  end
+
+  def valid_task?(t)
+    valid_task_re() =~ t
+  end
+
+  def valid_task_re
+    @valid_task_re ||= /\A(?:#{TASKS.map {|task,desc| task }.join('|')})\z/
+  end
+
+  def parsearg_no_options
+    unless ARGV.empty?
+      task = caller(0).first.slice(%r<`parsearg_(\w+)'>, 1)
+      setup_rb_error "#{task}: unknown options: #{ARGV.join(' ')}"
+    end
+  end
+
+  alias parsearg_show       parsearg_no_options
+  alias parsearg_setup      parsearg_no_options
+  alias parsearg_test       parsearg_no_options
+  alias parsearg_clean      parsearg_no_options
+  alias parsearg_distclean  parsearg_no_options
+
+  def parsearg_config
+    evalopt = []
+    set = []
+    @config.config_opt = []
+    while i = ARGV.shift
+      if /\A--?\z/ =~ i
+        @config.config_opt = ARGV.dup
+        break
+      end
+      name, value = *@config.parse_opt(i)
+      if @config.value_config?(name)
+        @config[name] = value
+      else
+        evalopt.push [name, value]
+      end
+      set.push name
+    end
+    evalopt.each do |name, value|
+      @config.lookup(name).evaluate value, @config
+    end
+    # Check if configuration is valid
+    set.each do |n|
+      @config[n] if @config.value_config?(n)
+    end
+  end
+
+  def parsearg_install
+    @config.no_harm = false
+    @config.install_prefix = ''
+    while a = ARGV.shift
+      case a
+      when '--no-harm'
+        @config.no_harm = true
+      when /\A--prefix=/
+        path = a.split(/=/, 2)[1]
+        path = File.expand_path(path) unless path[0,1] == '/'
+        @config.install_prefix = path
+      else
+        setup_rb_error "install: unknown option #{a}"
+      end
+    end
+  end
+
+  def print_usage(out)
+    out.puts 'Typical Installation Procedure:'
+    out.puts "  $ ruby #{File.basename $0} config"
+    out.puts "  $ ruby #{File.basename $0} setup"
+    out.puts "  # ruby #{File.basename $0} install (may require root privilege)"
+    out.puts
+    out.puts 'Detailed Usage:'
+    out.puts "  ruby #{File.basename $0} <global option>"
+    out.puts "  ruby #{File.basename $0} [<global options>] <task> [<task options>]"
+
+    fmt = "  %-24s %s\n"
+    out.puts
+    out.puts 'Global options:'
+    out.printf fmt, '-q,--quiet',   'suppress message outputs'
+    out.printf fmt, '   --verbose', 'output messages verbosely'
+    out.printf fmt, '   --help',    'print this message'
+    out.printf fmt, '   --version', 'print version and quit'
+    out.printf fmt, '   --copyright',  'print copyright and quit'
+    out.puts
+    out.puts 'Tasks:'
+    TASKS.each do |name, desc|
+      out.printf fmt, name, desc
+    end
+
+    fmt = "  %-24s %s [%s]\n"
+    out.puts
+    out.puts 'Options for CONFIG or ALL:'
+    @config.each do |item|
+      out.printf fmt, item.help_opt, item.description, item.help_default
+    end
+    out.printf fmt, '--rbconfig=path', 'rbconfig.rb to load',"running ruby's"
+    out.puts
+    out.puts 'Options for INSTALL:'
+    out.printf fmt, '--no-harm', 'only display what to do if given', 'off'
+    out.printf fmt, '--prefix=path',  'install path prefix', ''
+    out.puts
+  end
+
+  #
+  # Task Handlers
+  #
+
+  def exec_config
+    @installer.exec_config
+    @config.save   # must be final
+  end
+
+  def exec_setup
+    @installer.exec_setup
+  end
+
+  def exec_install
+    @installer.exec_install
+  end
+
+  def exec_test
+    @installer.exec_test
+  end
+
+  def exec_show
+    @config.each do |i|
+      printf "%-20s %s\n", i.name, i.value if i.value?
+    end
+  end
+
+  def exec_clean
+    @installer.exec_clean
+  end
+
+  def exec_distclean
+    @installer.exec_distclean
+  end
+
+end   # class ToplevelInstaller
+
+
+class ToplevelInstallerMulti < ToplevelInstaller
+
+  include FileOperations
+
+  def initialize(ardir_root, config)
+    super
+    @packages = directories_of("#{@ardir}/packages")
+    raise 'no package exists' if @packages.empty?
+    @root_installer = Installer.new(@config, @ardir, File.expand_path('.'))
+  end
+
+  def run_metaconfigs
+    @config.load_script "#{@ardir}/metaconfig", self
+    @packages.each do |name|
+      @config.load_script "#{@ardir}/packages/#{name}/metaconfig"
+    end
+  end
+
+  attr_reader :packages
+
+  def packages=(list)
+    raise 'package list is empty' if list.empty?
+    list.each do |name|
+      raise "directory packages/#{name} does not exist"\
+              unless File.dir?("#{@ardir}/packages/#{name}")
+    end
+    @packages = list
+  end
+
+  def init_installers
+    @installers = {}
+    @packages.each do |pack|
+      @installers[pack] = Installer.new(@config,
+                                       "#{@ardir}/packages/#{pack}",
+                                       "packages/#{pack}")
+    end
+    with    = extract_selection(config('with'))
+    without = extract_selection(config('without'))
+    @selected = @installers.keys.select {|name|
+                  (with.empty? or with.include?(name)) \
+                      and not without.include?(name)
+                }
+  end
+
+  def extract_selection(list)
+    a = list.split(/,/)
+    a.each do |name|
+      setup_rb_error "no such package: #{name}"  unless @installers.key?(name)
+    end
+    a
+  end
+
+  def print_usage(f)
+    super
+    f.puts 'Inluded packages:'
+    f.puts '  ' + @packages.sort.join(' ')
+    f.puts
+  end
+
+  #
+  # Task Handlers
+  #
+
+  def exec_config
+    run_hook 'pre-config'
+    each_selected_installers {|inst| inst.exec_config }
+    run_hook 'post-config'
+    @config.save   # must be final
+  end
+
+  def exec_setup
+    run_hook 'pre-setup'
+    each_selected_installers {|inst| inst.exec_setup }
+    run_hook 'post-setup'
+  end
+
+  def exec_install
+    run_hook 'pre-install'
+    each_selected_installers {|inst| inst.exec_install }
+    run_hook 'post-install'
+  end
+
+  def exec_test
+    run_hook 'pre-test'
+    each_selected_installers {|inst| inst.exec_test }
+    run_hook 'post-test'
+  end
+
+  def exec_clean
+    rm_f @config.savefile
+    run_hook 'pre-clean'
+    each_selected_installers {|inst| inst.exec_clean }
+    run_hook 'post-clean'
+  end
+
+  def exec_distclean
+    rm_f @config.savefile
+    run_hook 'pre-distclean'
+    each_selected_installers {|inst| inst.exec_distclean }
+    run_hook 'post-distclean'
+  end
+
+  #
+  # lib
+  #
+
+  def each_selected_installers
+    Dir.mkdir 'packages' unless File.dir?('packages')
+    @selected.each do |pack|
+      $stderr.puts "Processing the package `#{pack}' ..." if verbose?
+      Dir.mkdir "packages/#{pack}" unless File.dir?("packages/#{pack}")
+      Dir.chdir "packages/#{pack}"
+      yield @installers[pack]
+      Dir.chdir '../..'
+    end
+  end
+
+  def run_hook(id)
+    @root_installer.run_hook id
+  end
+
+  # module FileOperations requires this
+  def verbose?
+    @config.verbose?
+  end
+
+  # module FileOperations requires this
+  def no_harm?
+    @config.no_harm?
+  end
+
+end   # class ToplevelInstallerMulti
+
+
+class Installer
+
+  FILETYPES = %w( bin lib ext data conf man )
+
+  include FileOperations
+  include HookScriptAPI
+
+  def initialize(config, srcroot, objroot)
+    @config = config
+    @srcdir = File.expand_path(srcroot)
+    @objdir = File.expand_path(objroot)
+    @currdir = '.'
+  end
+
+  def inspect
+    "#<#{self.class} #{File.basename(@srcdir)}>"
+  end
+
+  def noop(rel)
+  end
+
+  #
+  # Hook Script API base methods
+  #
+
+  def srcdir_root
+    @srcdir
+  end
+
+  def objdir_root
+    @objdir
+  end
+
+  def relpath
+    @currdir
+  end
+
+  #
+  # Config Access
+  #
+
+  # module FileOperations requires this
+  def verbose?
+    @config.verbose?
+  end
+
+  # module FileOperations requires this
+  def no_harm?
+    @config.no_harm?
+  end
+
+  def verbose_off
+    begin
+      save, @config.verbose = @config.verbose?, false
+      yield
+    ensure
+      @config.verbose = save
+    end
+  end
+
+  #
+  # TASK config
+  #
+
+  def exec_config
+    exec_task_traverse 'config'
+  end
+
+  alias config_dir_bin noop
+  alias config_dir_lib noop
+
+  def config_dir_ext(rel)
+    extconf if extdir?(curr_srcdir())
+  end
+
+  alias config_dir_data noop
+  alias config_dir_conf noop
+  alias config_dir_man noop
+
+  def extconf
+    ruby "#{curr_srcdir()}/extconf.rb", *@config.config_opt
+  end
+
+  #
+  # TASK setup
+  #
+
+  def exec_setup
+    exec_task_traverse 'setup'
+  end
+
+  def setup_dir_bin(rel)
+    files_of(curr_srcdir()).each do |fname|
+      update_shebang_line "#{curr_srcdir()}/#{fname}"
+    end
+  end
+
+  alias setup_dir_lib noop
+
+  def setup_dir_ext(rel)
+    make if extdir?(curr_srcdir())
+  end
+
+  alias setup_dir_data noop
+  alias setup_dir_conf noop
+  alias setup_dir_man noop
+
+  def update_shebang_line(path)
+    return if no_harm?
+    return if config('shebang') == 'never'
+    old = Shebang.load(path)
+    if old
+      $stderr.puts "warning: #{path}: Shebang line includes too many args.  It is not portable and your program may not work." if old.args.size > 1
+      new = new_shebang(old)
+      return if new.to_s == old.to_s
+    else
+      return unless config('shebang') == 'all'
+      new = Shebang.new(config('rubypath'))
+    end
+    $stderr.puts "updating shebang: #{File.basename(path)}" if verbose?
+    open_atomic_writer(path) {|output|
+      File.open(path, 'rb') {|f|
+        f.gets if old   # discard
+        output.puts new.to_s
+        output.print f.read
+      }
+    }
+  end
+
+  def new_shebang(old)
+    if /\Aruby/ =~ File.basename(old.cmd)
+      Shebang.new(config('rubypath'), old.args)
+    elsif File.basename(old.cmd) == 'env' and old.args.first == 'ruby'
+      Shebang.new(config('rubypath'), old.args[1..-1])
+    else
+      return old unless config('shebang') == 'all'
+      Shebang.new(config('rubypath'))
+    end
+  end
+
+  def open_atomic_writer(path, &block)
+    tmpfile = File.basename(path) + '.tmp'
+    begin
+      File.open(tmpfile, 'wb', &block)
+      File.rename tmpfile, File.basename(path)
+    ensure
+      File.unlink tmpfile if File.exist?(tmpfile)
+    end
+  end
+
+  class Shebang
+    def Shebang.load(path)
+      line = nil
+      File.open(path) {|f|
+        line = f.gets
+      }
+      return nil unless /\A#!/ =~ line
+      parse(line)
+    end
+
+    def Shebang.parse(line)
+      cmd, *args = *line.strip.sub(/\A\#!/, '').split(' ')
+      new(cmd, args)
+    end
+
+    def initialize(cmd, args = [])
+      @cmd = cmd
+      @args = args
+    end
+
+    attr_reader :cmd
+    attr_reader :args
+
+    def to_s
+      "#! #{@cmd}" + (@args.empty? ? '' : " #{@args.join(' ')}")
+    end
+  end
+
+  #
+  # TASK install
+  #
+
+  def exec_install
+    rm_f 'InstalledFiles'
+    exec_task_traverse 'install'
+  end
+
+  def install_dir_bin(rel)
+    install_files targetfiles(), "#{config('bindir')}/#{rel}", 0755
+  end
+
+  def install_dir_lib(rel)
+    install_files libfiles(), "#{config('rbdir')}/#{rel}", 0644
+  end
+
+  def install_dir_ext(rel)
+    return unless extdir?(curr_srcdir())
+    install_files rubyextentions('.'),
+                  "#{config('sodir')}/#{File.dirname(rel)}",
+                  0555
+  end
+
+  def install_dir_data(rel)
+    install_files targetfiles(), "#{config('datadir')}/#{rel}", 0644
+  end
+
+  def install_dir_conf(rel)
+    # FIXME: should not remove current config files
+    # (rename previous file to .old/.org)
+    install_files targetfiles(), "#{config('sysconfdir')}/#{rel}", 0644
+  end
+
+  def install_dir_man(rel)
+    install_files targetfiles(), "#{config('mandir')}/#{rel}", 0644
+  end
+
+  def install_files(list, dest, mode)
+    mkdir_p dest, @config.install_prefix
+    list.each do |fname|
+      install fname, dest, mode, @config.install_prefix
+    end
+  end
+
+  def libfiles
+    glob_reject(%w(*.y *.output), targetfiles())
+  end
+
+  def rubyextentions(dir)
+    ents = glob_select("*.#{@config.dllext}", targetfiles())
+    if ents.empty?
+      setup_rb_error "no ruby extention exists: 'ruby #{$0} setup' first"
+    end
+    ents
+  end
+
+  def targetfiles
+    mapdir(existfiles() - hookfiles())
+  end
+
+  def mapdir(ents)
+    ents.map {|ent|
+      if File.exist?(ent)
+      then ent                         # objdir
+      else "#{curr_srcdir()}/#{ent}"   # srcdir
+      end
+    }
+  end
+
+  # picked up many entries from cvs-1.11.1/src/ignore.c
+  JUNK_FILES = %w(
+    core RCSLOG tags TAGS .make.state
+    .nse_depinfo #* .#* cvslog.* ,* .del-* *.olb
+    *~ *.old *.bak *.BAK *.orig *.rej _$* *$
+
+    *.org *.in .*
+  )
+
+  def existfiles
+    glob_reject(JUNK_FILES, (files_of(curr_srcdir()) | files_of('.')))
+  end
+
+  def hookfiles
+    %w( pre-%s post-%s pre-%s.rb post-%s.rb ).map {|fmt|
+      %w( config setup install clean ).map {|t| sprintf(fmt, t) }
+    }.flatten
+  end
+
+  def glob_select(pat, ents)
+    re = globs2re([pat])
+    ents.select {|ent| re =~ ent }
+  end
+
+  def glob_reject(pats, ents)
+    re = globs2re(pats)
+    ents.reject {|ent| re =~ ent }
+  end
+
+  GLOB2REGEX = {
+    '.' => '\.',
+    '$' => '\$',
+    '#' => '\#',
+    '*' => '.*'
+  }
+
+  def globs2re(pats)
+    /\A(?:#{
+      pats.map {|pat| pat.gsub(/[\.\$\#\*]/) {|ch| GLOB2REGEX[ch] } }.join('|')
+    })\z/
+  end
+
+  #
+  # TASK test
+  #
+
+  TESTDIR = 'test'
+
+  def exec_test
+    unless File.directory?('test')
+      $stderr.puts 'no test in this package' if verbose?
+      return
+    end
+    $stderr.puts 'Running tests...' if verbose?
+    begin
+      require 'test/unit'
+    rescue LoadError
+      setup_rb_error 'test/unit cannot loaded.  You need Ruby 1.8 or later to invoke this task.'
+    end
+    runner = Test::Unit::AutoRunner.new(true)
+    runner.to_run << TESTDIR
+    runner.run
+  end
+
+  #
+  # TASK clean
+  #
+
+  def exec_clean
+    exec_task_traverse 'clean'
+    rm_f @config.savefile
+    rm_f 'InstalledFiles'
+  end
+
+  alias clean_dir_bin noop
+  alias clean_dir_lib noop
+  alias clean_dir_data noop
+  alias clean_dir_conf noop
+  alias clean_dir_man noop
+
+  def clean_dir_ext(rel)
+    return unless extdir?(curr_srcdir())
+    make 'clean' if File.file?('Makefile')
+  end
+
+  #
+  # TASK distclean
+  #
+
+  def exec_distclean
+    exec_task_traverse 'distclean'
+    rm_f @config.savefile
+    rm_f 'InstalledFiles'
+  end
+
+  alias distclean_dir_bin noop
+  alias distclean_dir_lib noop
+
+  def distclean_dir_ext(rel)
+    return unless extdir?(curr_srcdir())
+    make 'distclean' if File.file?('Makefile')
+  end
+
+  alias distclean_dir_data noop
+  alias distclean_dir_conf noop
+  alias distclean_dir_man noop
+
+  #
+  # Traversing
+  #
+
+  def exec_task_traverse(task)
+    run_hook "pre-#{task}"
+    FILETYPES.each do |type|
+      if type == 'ext' and config('without-ext') == 'yes'
+        $stderr.puts 'skipping ext/* by user option' if verbose?
+        next
+      end
+      traverse task, type, "#{task}_dir_#{type}"
+    end
+    run_hook "post-#{task}"
+  end
+
+  def traverse(task, rel, mid)
+    dive_into(rel) {
+      run_hook "pre-#{task}"
+      __send__ mid, rel.sub(%r[\A.*?(?:/|\z)], '')
+      directories_of(curr_srcdir()).each do |d|
+        traverse task, "#{rel}/#{d}", mid
+      end
+      run_hook "post-#{task}"
+    }
+  end
+
+  def dive_into(rel)
+    return unless File.dir?("#{@srcdir}/#{rel}")
+
+    dir = File.basename(rel)
+    Dir.mkdir dir unless File.dir?(dir)
+    prevdir = Dir.pwd
+    Dir.chdir dir
+    $stderr.puts '---> ' + rel if verbose?
+    @currdir = rel
+    yield
+    Dir.chdir prevdir
+    $stderr.puts '<--- ' + rel if verbose?
+    @currdir = File.dirname(rel)
+  end
+
+  def run_hook(id)
+    path = [ "#{curr_srcdir()}/#{id}",
+             "#{curr_srcdir()}/#{id}.rb" ].detect {|cand| File.file?(cand) }
+    return unless path
+    begin
+      instance_eval File.read(path), path, 1
+    rescue
+      raise if $DEBUG
+      setup_rb_error "hook #{path} failed:\n" + $!.message
+    end
+  end
+
+end   # class Installer
+
+
+class SetupError < StandardError; end
+
+def setup_rb_error(msg)
+  raise SetupError, msg
+end
+
+if $0 == __FILE__
+  begin
+    ToplevelInstaller.invoke
+  rescue SetupError
+    raise if $DEBUG
+    $stderr.puts $!.message
+    $stderr.puts "Try 'ruby #{$0} --help' for detailed usage."
+    exit 1
+  end
+end
diff --git a/test/covshow.rb b/test/covshow.rb
new file mode 100644
index 0000000..e50c368
--- /dev/null
+++ b/test/covshow.rb
@@ -0,0 +1,30 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+#
+# this works with the __covmerge method in test/helper.rb
+# run this file after all tests are run
+
+# load the merged dump data
+res = Marshal.load(IO.binread("coverage.dump"))
+
+# Dirty little text formatter.  I tried simplecov but the default
+# HTML+JS is unusable without a GUI (I hate GUIs :P) and it would've
+# taken me longer to search the Internets to find a plain-text
+# formatter I like...
+res.keys.sort.each do |filename|
+  cov = res[filename]
+  puts "==> #{filename} <=="
+  File.readlines(filename).each_with_index do |line, i|
+    n = cov[i]
+    if n == 0 # BAD
+      print("  *** 0 #{line}")
+    elsif n
+      printf("% 7u %s", n, line)
+    elsif line =~ /\S/ # probably a line with just "end" in it
+      print("        #{line}")
+    else # blank line
+      print "\n" # don't output trailing whitespace on blank lines
+    end
+  end
+end
diff --git a/test/helper.rb b/test/helper.rb
new file mode 100644
index 0000000..e4643ae
--- /dev/null
+++ b/test/helper.rb
@@ -0,0 +1,76 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+$stdout.sync = $stderr.sync = Thread.abort_on_exception = true
+
+# fork-aware coverage data gatherer, see also test/covshow.rb
+if ENV["COVERAGE"]
+  require "coverage"
+  COVMATCH = %r{/lib/dtas\b.*rb\z}
+  COVTMP = File.open("coverage.dump", IO::CREAT|IO::RDWR)
+  COVTMP.binmode
+  COVTMP.sync = true
+
+  def __covmerge
+    res = Coverage.result
+
+    # we own this file (at least until somebody tries to use NFS :x)
+    COVTMP.flock(File::LOCK_EX)
+
+    COVTMP.rewind
+    prev = COVTMP.read
+    prev = prev.empty? ? {} : Marshal.load(prev)
+    res.each do |filename, counts|
+      # filter out stuff that's not in our project
+      COVMATCH =~ filename or next
+
+      merge = prev[filename] || []
+      merge = merge
+      counts.each_with_index do |count, i|
+        count or next
+        merge[i] = (merge[i] || 0) + count
+      end
+      prev[filename] = merge
+    end
+    COVTMP.rewind
+    COVTMP.truncate(0)
+    COVTMP.write(Marshal.dump(prev))
+  ensure
+    COVTMP.flock(File::LOCK_UN)
+  end
+
+  Coverage.start
+  at_exit { __covmerge }
+end
+
+gem 'minitest'
+require 'minitest/autorun'
+require "tempfile"
+
+FIFOS = []
+at_exit { FIFOS.each { |(pid,path)| File.unlink(path) if $$ == pid } }
+def tmpfifo
+  tmp = Tempfile.new(%w(dtas-test .fifo))
+  path = tmp.path
+  tmp.close!
+  assert system(*%W(mkfifo #{path})), "mkfifo #{path}"
+  FIFOS << [ $$, path ]
+  path
+end
+
+require 'tmpdir'
+class Dir
+  require 'fileutils'
+  def Dir.mktmpdir
+    begin
+      d = "#{Dir.tmpdir}/#$$.#{rand}"
+      Dir.mkdir(d)
+    rescue Errno::EEXIST
+    end while true
+    begin
+      yield d
+    ensure
+      FileUtils.remove_entry(d)
+    end
+  end
+end unless Dir.respond_to?(:mktmpdir)
diff --git a/test/player_integration.rb b/test/player_integration.rb
new file mode 100644
index 0000000..6580194
--- /dev/null
+++ b/test/player_integration.rb
@@ -0,0 +1,121 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'dtas/player'
+require 'dtas/state_file'
+require 'yaml'
+require 'tempfile'
+require 'shellwords'
+require 'timeout'
+
+module PlayerIntegration
+  def setup
+    sock_tmp = Tempfile.new(%w(dtas-test .sock))
+    @state_tmp = Tempfile.new(%w(dtas-test .yml))
+    @sock_path = sock_tmp.path
+    sock_tmp.close!
+    @player = DTAS::Player.new
+    @player.socket = @sock_path
+    @player.state_file = DTAS::StateFile.new(@state_tmp.path)
+    @player.bind
+    @out = Tempfile.new(%w(dtas-test .out))
+    @err = Tempfile.new(%w(dtas-test .err))
+    @out.sync = @err.sync = true
+    @pid = fork do
+      at_exit { @player.close }
+      ENV["SOX_OPTS"] = "#{ENV['SOX_OPTS']} -R"
+      unless $DEBUG
+        $stdout.reopen(@out)
+        $stderr.reopen(@err)
+      end
+      @player.run
+    end
+
+    # null playback device with delay to simulate a real device
+    @fmt = DTAS::Format.new
+    @period = 0.01
+    @period_size = @fmt.bytes_per_sample * @fmt.channels * @fmt.rate * @period
+    @cmd = "exec 2>/dev/null " \
+           "ruby -e " \
+           "\"b=%q();loop{STDIN.readpartial(#@period_size,b);sleep(#@period)}\""
+
+    # FIXME gross...
+    @player.instance_eval do
+      @sink_buf.close!
+    end
+  end
+
+  module PlayerClient
+    def preq(args)
+      args = Shellwords.join(args) if Array === args
+      send(args, Socket::MSG_EOR)
+    end
+  end
+
+  def client_socket
+    s = Socket.new(:AF_UNIX, :SOCK_SEQPACKET, 0)
+    s.connect(Socket.pack_sockaddr_un(@sock_path))
+    s.extend(PlayerClient)
+    s
+  end
+
+  def wait_pid_dead(pid, time = 5)
+    Timeout.timeout(time) do
+      begin
+        Process.kill(0, pid)
+        sleep(0.01)
+      rescue Errno::ESRCH
+        return
+      end while true
+    end
+  end
+
+  def wait_files_not_empty(*files)
+    files = Array(files)
+    Timeout.timeout(5) { sleep(0.01) until files.all? { |f| f.size > 0 } }
+  end
+
+  def default_sink_pid(s)
+    default_pid = Tempfile.new(%w(dtas-test .pid))
+    pf = "echo $$ >> #{default_pid.path}; "
+    s.send("sink ed default command='#{pf}#@cmd'", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    default_pid
+  end
+
+  def teardown
+    Process.kill(:TERM, @pid) if @pid
+    Process.waitall
+    refute File.exist?(@sock_path)
+    @state_tmp.close!
+    @out.close! if @out
+    @err.close! if @err
+  end
+
+  def read_pid_file(file)
+    file.rewind
+    pid = file.read.to_i
+    assert_operator pid, :>, 0
+    pid
+  end
+
+  def tmp_noise(len = 5)
+    noise = Tempfile.open(%w(junk .sox))
+    cmd = %W(sox -R -n -r44100 -c2 #{noise.path} synth #{len} pluck)
+    assert system(*cmd), cmd
+    [ noise, len ]
+  end
+
+  def dethrottle_decoder(s)
+    s.send("sink ed default active=false", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+  end
+
+  def stop_playback(pid_file, s)
+    s.send("skip", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    pid = read_pid_file(pid_file)
+    wait_pid_dead(pid)
+  end
+end
diff --git a/test/test_buffer.rb b/test/test_buffer.rb
new file mode 100644
index 0000000..32ae986
--- /dev/null
+++ b/test/test_buffer.rb
@@ -0,0 +1,216 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'stringio'
+require 'dtas/buffer'
+
+class TestBuffer < Minitest::Unit::TestCase
+  def teardown
+    @to_close.each { |io| io.close unless io.closed? }
+  end
+
+  def setup
+    @to_close = []
+  end
+
+  def pipe
+    ret = IO.pipe
+    @to_close.concat(ret)
+    ret
+  end
+
+  def tmperr
+    olderr = $stderr
+    $stderr = newerr = StringIO.new
+    yield
+    newerr
+  ensure
+    $stderr = olderr
+  end
+
+  def new_buffer
+    buf = DTAS::Buffer.new
+    @to_close << buf.to_io
+    @to_close << buf.wr
+    buf
+  end
+
+  def test_set_buffer_size
+    buf = new_buffer
+    buf.buffer_size = DTAS::Buffer::MAX_SIZE
+    assert_equal DTAS::Buffer::MAX_SIZE, buf.buffer_size
+  end if defined?(DTAS::Buffer::MAX_SIZE)
+
+  def test_buffer_size
+    buf = new_buffer
+    assert_operator buf.buffer_size, :>, 128
+    buf.buffer_size = DTAS::Buffer::MAX_SIZE
+    assert_equal DTAS::Buffer::MAX_SIZE, buf.buffer_size
+  end if defined?(DTAS::Buffer::MAX_SIZE)
+
+  def test_broadcast_1
+    buf = new_buffer
+    r, w = IO.pipe
+    assert_equal :wait_readable, buf.broadcast([w])
+    assert_equal 0, buf.bytes_xfer
+    buf.wr.write "HIHI"
+    assert_equal :wait_readable, buf.broadcast([w])
+    assert_equal 4, buf.bytes_xfer
+    assert_equal :wait_readable, buf.broadcast([w])
+    assert_equal 4, buf.bytes_xfer
+    tmp = [w]
+    r.close
+    buf.wr.write "HIHI"
+    newerr = tmperr { assert_nil buf.broadcast(tmp) }
+    assert_equal [], tmp
+    assert_match(%r{dropping}, newerr.string)
+  end
+
+  def test_broadcast_tee
+    buf = new_buffer
+    return unless buf.respond_to?(:__broadcast_tee)
+    blocked = []
+    a = pipe
+    b = pipe
+    buf.wr.write "HELLO"
+    assert_equal 4, buf.__broadcast_tee(blocked, [a[1], b[1]], 4)
+    assert_empty blocked
+    assert_equal "HELL", a[0].read(4)
+    assert_equal "HELL", b[0].read(4)
+    assert_equal 5, buf.__broadcast_tee(blocked, [a[1], b[1]], 5)
+    assert_empty blocked
+    assert_equal "HELLO", a[0].read(5)
+    assert_equal "HELLO", b[0].read(5)
+    max = '*' * a[0].pipe_size
+    assert_equal max.size, a[1].write(max)
+    assert_equal a[0].nread, a[0].pipe_size
+    a[1].nonblock = true
+    assert_equal 5, buf.__broadcast_tee(blocked, [a[1], b[1]], 5)
+    assert_equal [a[1]], blocked
+    a[1].nonblock = false
+    b[0].read(b[0].nread)
+    b[1].write(max)
+    t = Thread.new do
+      sleep 0.005
+      [ a[0].read(max.size).size, b[0].read(max.size).size ]
+    end
+    assert_equal 5, buf.__broadcast_tee(blocked, [a[1], b[1]], 5)
+    assert_equal [a[1]], blocked
+    assert_equal [ max.size, max.size ], t.value
+    b[0].close
+    tmp = [a[1], b[1]]
+
+    newerr = tmperr { assert_equal 5, buf.__broadcast_tee(blocked, tmp, 5) }
+    assert_equal [a[1]], blocked
+    assert_match(%r{dropping}, newerr.string)
+    assert_equal [a[1]], tmp
+  end
+
+  def test_broadcast
+    a = pipe
+    b = pipe
+    buf = new_buffer
+    buf.wr.write "HELLO"
+    assert_equal :wait_readable, buf.broadcast([a[1], b[1]])
+    assert_equal 5, buf.bytes_xfer
+    assert_equal "HELLO", a[0].read(5)
+    assert_equal "HELLO", b[0].read(5)
+    assert_equal :wait_readable, buf.broadcast([a[1], b[1]])
+    assert_equal 5, buf.bytes_xfer
+
+    b[1].nonblock = true
+    b[1].write('*' * b[1].pipe_size)
+    buf.wr.write "BYE"
+    assert_equal :wait_readable, buf.broadcast([a[1], b[1]])
+    assert_equal 8, buf.bytes_xfer
+
+    buf.wr.write "DROP"
+    b[0].close
+    tmp = [a[1], b[1]]
+    newerr = tmperr { assert_equal :wait_readable, buf.broadcast(tmp) }
+    assert_equal 12, buf.bytes_xfer
+    assert_equal [a[1]], tmp
+    assert_match(%r{dropping}, newerr.string)
+  end
+
+  def test_broadcast_total_fail
+    a = pipe
+    b = pipe
+    buf = new_buffer
+    buf.wr.write "HELLO"
+    a[0].close
+    b[0].close
+    tmp = [a[1], b[1]]
+    newerr = tmperr { assert_nil buf.broadcast(tmp) }
+    assert_equal [], tmp
+    assert_match(%r{dropping}, newerr.string)
+  end
+
+  def test_broadcast_mostly_fail
+    a = pipe
+    b = pipe
+    c = pipe
+    buf = new_buffer
+    buf.wr.write "HELLO"
+    b[0].close
+    c[0].close
+    tmp = [a[1], b[1], c[1]]
+    newerr = tmperr { assert_equal :wait_readable, buf.broadcast(tmp) }
+    assert_equal 5, buf.bytes_xfer
+    assert_equal [a[1]], tmp
+    assert_match(%r{dropping}, newerr.string)
+  end
+
+  def test_broadcast_all_full
+    a = pipe
+    b = pipe
+    buf = new_buffer
+    a[1].write('*' * a[1].pipe_size)
+    b[1].write('*' * b[1].pipe_size)
+
+    a[1].nonblock = true
+    b[1].nonblock = true
+    tmp = [a[1], b[1]]
+
+    buf.wr.write "HELLO"
+    assert_equal tmp, buf.broadcast(tmp)
+    assert_equal [a[1], b[1]], tmp
+  end
+
+  def test_serialize
+    buf = new_buffer
+    hash = buf.to_hsh
+    assert_empty hash
+    buf.buffer_size = 4096
+    hash = buf.to_hsh
+    assert_equal %w(buffer_size), hash.keys
+    assert_kind_of Integer, hash["buffer_size"]
+    assert_operator hash["buffer_size"], :>, 0
+  end
+
+  def test_close
+    buf = DTAS::Buffer.new
+    buf.wr.write "HI"
+    assert_equal 2, buf.inflight
+    buf.close
+    assert_equal 0, buf.inflight
+    assert_nil buf.close!
+  end
+
+  def test_load_nil
+    buf = DTAS::Buffer.load(nil)
+    buf.close!
+  end
+
+  def test_load_empty
+    buf = DTAS::Buffer.load({})
+    buf.close!
+  end
+
+  def test_load_size
+    buf = DTAS::Buffer.load({"buffer_size" => 4096})
+    assert_equal 4096, buf.buffer_size
+    buf.close!
+  end
+end
diff --git a/test/test_format.rb b/test/test_format.rb
new file mode 100644
index 0000000..ba039a7
--- /dev/null
+++ b/test/test_format.rb
@@ -0,0 +1,61 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'tempfile'
+require 'dtas/format'
+
+class TestFormat < Minitest::Unit::TestCase
+  def test_initialize
+    fmt = DTAS::Format.new
+    assert_equal %w(-ts32 -c2 -r44100), fmt.to_sox_arg
+    hash = fmt.to_hsh
+    assert_equal({}, hash)
+  end
+
+  def test_nonstandard
+    fmt = DTAS::Format.new
+    fmt.type = "s16"
+    fmt.rate = 48000
+    fmt.channels = 4
+    hash = fmt.to_hsh
+    assert_kind_of Hash, hash
+    assert_equal %w(channels rate type), hash.keys.sort
+    assert_equal "s16", hash["type"]
+    assert_equal 48000, hash["rate"]
+    assert_equal 4, hash["channels"]
+
+    # back to stereo
+    fmt.channels = 2
+    hash = fmt.to_hsh
+    assert_equal %w(rate type), hash.keys.sort
+    assert_equal "s16", hash["type"]
+    assert_equal 48000, hash["rate"]
+    assert_nil hash["channels"]
+  end
+
+  def test_from_file
+    Tempfile.open(%w(tmp .wav)) do |tmp|
+      # generate an empty file with 1s of audio
+      cmd = %W(sox -r 96000 -b 24 -c 2 -n #{tmp.path} trim 0 1)
+      system(*cmd)
+      assert $?.success?, "#{cmd.inspect} failed: #$?"
+      fmt = DTAS::Format.new
+      fmt.from_file tmp.path
+      assert_equal 96000, fmt.rate
+      assert_equal 2, fmt.channels
+      tmp.unlink
+    end
+  end
+
+  def test_bytes_per_sample
+    fmt = DTAS::Format.new
+    assert_equal 4, fmt.bytes_per_sample
+    fmt.type = "f64"
+    assert_equal 8, fmt.bytes_per_sample
+    fmt.type = "f32"
+    assert_equal 4, fmt.bytes_per_sample
+    fmt.type = "s16"
+    assert_equal 2, fmt.bytes_per_sample
+  end
+end
diff --git a/test/test_format_change.rb b/test/test_format_change.rb
new file mode 100644
index 0000000..ed0e7a2
--- /dev/null
+++ b/test/test_format_change.rb
@@ -0,0 +1,49 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/player_integration'
+require 'tmpdir'
+class TestFormatChange < Minitest::Unit::TestCase
+  include PlayerIntegration
+
+  def test_format_change
+    s = client_socket
+    default_pid = default_sink_pid(s)
+    Dir.mktmpdir do |dir|
+      d = "#{dir}/dump.$CHANNELS.$RATE"
+      f44100 = File.open("#{dir}/dump.2.44100", IO::RDWR|IO::CREAT)
+      f88200 = File.open("#{dir}/dump.2.88200", IO::RDWR|IO::CREAT)
+      s.preq("sink ed dump active=true command='cat > #{d}'")
+      assert_equal "OK", s.readpartial(666)
+      noise, len = tmp_noise
+      s.preq(%W(enq #{noise.path}))
+      assert_equal "OK", s.readpartial(666)
+      wait_files_not_empty(default_pid, f44100)
+
+      s.preq("format rate=88200")
+      assert_equal "OK", s.readpartial(666)
+
+      wait_files_not_empty(f88200)
+
+      dethrottle_decoder(s)
+
+      Timeout.timeout(len) do
+        begin
+          s.preq("current")
+          cur = YAML.load(s.readpartial(6666))
+        end while cur["sinks"] && sleep(0.01)
+      end
+
+      c = "sox -R -ts32 -c2 -r88200 #{dir}/dump.2.88200 " \
+          "-ts32 -c2 -r44100 #{dir}/part2"
+      assert(system(c), c)
+
+      c = "sox -R -ts32 -c2 -r44100 #{dir}/dump.2.44100 " \
+          "-ts32 -c2 -r44100 #{dir}/part2 #{dir}/res.sox"
+      assert(system(c), c)
+
+      assert_equal `soxi -s #{dir}/res.sox`, `soxi -s #{noise.path}`
+      File.unlink(*Dir["#{dir}/*"].to_a)
+    end
+  end
+end
diff --git a/test/test_player.rb b/test/test_player.rb
new file mode 100644
index 0000000..18a8a9e
--- /dev/null
+++ b/test/test_player.rb
@@ -0,0 +1,37 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'tempfile'
+require 'dtas/player'
+
+class TestPlayer < Minitest::Unit::TestCase
+  def setup
+    @player = nil
+    tmp = Tempfile.new(%w(dtas-player-test .sock))
+    @path = tmp.path
+    File.unlink(@path)
+  end
+
+  def teardown
+    @player.close if @player
+  end
+
+  def test_player_new
+    player = DTAS::Player.new
+    player.socket = @path
+    player.bind
+    assert File.socket?(@path)
+  ensure
+    player.close
+    refute File.socket?(@path)
+  end
+
+  def test_player_serialize
+    @player = DTAS::Player.new
+    @player.socket = @path
+    @player.bind
+    hash = @player.to_hsh
+    assert_equal({"socket" => @path}, hash)
+  end
+end
diff --git a/test/test_player_client_handler.rb b/test/test_player_client_handler.rb
new file mode 100644
index 0000000..9324101
--- /dev/null
+++ b/test/test_player_client_handler.rb
@@ -0,0 +1,86 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'dtas/player'
+
+class TestPlayerClientHandler < Minitest::Unit::TestCase
+  class MockIO < Array
+    alias emit push
+  end
+
+  include DTAS::Player::ClientHandler
+
+  def setup
+    @sinks = {}
+    @io = MockIO.new
+    @srv = nil # unused mock
+  end
+
+  def test_delete
+    @sinks["default"] = DTAS::Sink.new
+    @targets = []
+    sink_handler(@io, %w(rm default))
+    assert @sinks.empty?
+    assert_equal %w(OK), @io.to_a
+  end
+
+  def test_delete_noexist
+    sink_handler(@io, %w(rm default))
+    assert @sinks.empty?
+    assert_equal ["ERR default not found"], @io.to_a
+  end
+
+  def test_env
+    sink_handler(@io, %w(ed default env.FOO=bar))
+    assert_equal "bar", @sinks["default"].env["FOO"]
+    sink_handler(@io, %w(ed default env.FOO=))
+    assert_equal "", @sinks["default"].env["FOO"]
+    sink_handler(@io, %w(ed default env#FOO))
+    assert_nil @sinks["default"].env["FOO"]
+  end
+
+  def test_sink_ed
+    command = 'sox -t $SOX_FILETYPE -r $RATE -c $CHANNELS - \
+      -t s$SINK_BITS -r $SINK_RATE -c $SINK_CHANNELS - | \
+    aplay -D hw:DAC_1 -v -q -M --buffer-size=500000 --period-size=500 \
+      --disable-softvol --start-delay=100 \
+      --disable-format --disable-resample --disable-channels \
+      -t raw -c $SINK_CHANNELS -f S${SINK_BITS}_3LE -r $SINK_RATE
+    '
+    sink_handler(@io, %W(ed foo command=#{command}))
+    assert_equal command, @sinks["foo"].command
+    assert_empty @sinks["foo"].env
+    sink_handler(@io, %W(ed foo env.SINK_BITS=24))
+    sink_handler(@io, %W(ed foo env.SINK_CHANNELS=2))
+    sink_handler(@io, %W(ed foo env.SINK_RATE=48000))
+    expect = {
+      "SINK_BITS" => "24",
+      "SINK_CHANNELS" => "2",
+      "SINK_RATE" => "48000",
+    }
+    assert_equal expect, @sinks["foo"].env
+    @io.all? { |s| assert_equal "OK", s }
+    assert_equal 4, @io.size
+  end
+
+  def test_cat
+    sink = DTAS::Sink.new
+    sink.name = "default"
+    sink.command += "dither -s"
+    @sinks["default"] = sink
+    sink_handler(@io, %W(cat default))
+    assert_equal 1, @io.size
+    hsh = YAML.load(@io[0])
+    assert_kind_of Hash, hsh
+    assert_equal "default", hsh["name"]
+    assert_match("dither -s", hsh["command"])
+  end
+
+  def test_ls
+    expect = %w(a b c d)
+    expect.each { |s| @sinks[s] = true }
+    sink_handler(@io, %W(ls))
+    assert_equal expect, Shellwords.split(@io[0])
+  end
+end
diff --git a/test/test_player_integration.rb b/test/test_player_integration.rb
new file mode 100644
index 0000000..f7b1306
--- /dev/null
+++ b/test/test_player_integration.rb
@@ -0,0 +1,199 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/player_integration'
+class TestPlayerIntegration < Minitest::Unit::TestCase
+  include PlayerIntegration
+
+  def test_cmd_rate
+    pid = fork do
+      @fmt.to_env.each { |k,v| ENV[k] = v }
+      exec("sox -n $SOXFMT - synth 3 pinknoise | #@cmd")
+    end
+    t = Time.now
+    _, _ = Process.waitpid2(pid)
+    elapsed = Time.now - t
+    assert_in_delta 3.0, elapsed, 0.5
+  end if ENV["MATH_IS_HARD"] # ensure our @cmd timing is accurate
+
+  def test_sink_close_after_play
+    s = client_socket
+    @cmd = "cat >/dev/null"
+    default_pid = default_sink_pid(s)
+    Tempfile.open('junk') do |junk|
+      pink = "sox -n $SOXFMT - synth 0.0001 pinknoise | tee -i #{junk.path}"
+      s.send("enq-cmd \"#{pink}\"", Socket::MSG_EOR)
+      wait_files_not_empty(junk)
+      assert_equal "OK", s.readpartial(666)
+    end
+    wait_files_not_empty(default_pid)
+    pid = read_pid_file(default_pid)
+    wait_pid_dead(pid)
+  end
+
+  def test_sink_killed_during_play
+    s = client_socket
+    default_pid = default_sink_pid(s)
+    cmd = Tempfile.new(%w(sox-cmd .pid))
+    pink = "echo $$ > #{cmd.path}; sox -n $SOXFMT - synth 100 pinknoise"
+    s.send("enq-cmd \"#{pink}\"", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    wait_files_not_empty(cmd, default_pid)
+    pid = read_pid_file(default_pid)
+    Process.kill(:KILL, pid)
+    cmd_pid = read_pid_file(cmd)
+    wait_pid_dead(cmd_pid)
+  end
+
+  def test_sink_activate
+    s = client_socket
+    s.send("sink ls", Socket::MSG_EOR)
+    assert_equal "default", s.readpartial(666)
+
+    # setup two outputs
+
+    # make the default sink trickle
+    default_pid = Tempfile.new(%w(dtas-test .pid))
+    pf = "echo $$ >> #{default_pid.path}; "
+    s.send("sink ed default command='#{pf}#@cmd'", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    # make a sleepy sink trickle, too
+    sleepy_pid = Tempfile.new(%w(dtas-test .pid))
+    pf = "echo $$ >> #{sleepy_pid.path};"
+    s.send("sink ed sleepy command='#{pf}#@cmd' active=true", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    # ensure both sinks were created
+    s.send("sink ls", Socket::MSG_EOR)
+    assert_equal "default sleepy", s.readpartial(666)
+
+    # generate pinknoise
+    pinknoise = "sox -n -r 44100 -c 2 -t s32 - synth 0 pinknoise"
+    s.send("enq-cmd \"#{pinknoise}\"", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    # wait for sinks to start
+    wait_files_not_empty(sleepy_pid, default_pid)
+
+    # deactivate sleepy sink and ensure it's gone
+    sleepy = File.read(sleepy_pid).to_i
+    assert_operator sleepy, :>, 0
+    Process.kill(0, sleepy)
+    s.send("sink ed sleepy active=false", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    wait_pid_dead(sleepy)
+
+    # ensure default sink is still alive
+    default = File.read(default_pid).to_i
+    assert_operator default, :>, 0
+    Process.kill(0, default)
+
+    # restart sleepy sink
+    sleepy_pid.sync = true
+    sleepy_pid.seek(0)
+    sleepy_pid.truncate(0)
+    s.send("sink ed sleepy active=true", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    # wait for sleepy sink
+    wait_files_not_empty(sleepy_pid)
+
+    # check sleepy restarted
+    sleepy = File.read(sleepy_pid).to_i
+    assert_operator sleepy, :>, 0
+    Process.kill(0, sleepy)
+
+    # stop playing current track
+    s.send("skip", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    wait_pid_dead(sleepy)
+    wait_pid_dead(default)
+  end
+
+  def test_env_change
+    s = client_socket
+    tmp = Tempfile.new(%w(env .txt))
+    s.preq("sink ed default active=true command='cat >/dev/null'")
+    assert_equal "OK", s.readpartial(666)
+
+    s.preq("env FOO=BAR")
+    assert_equal "OK", s.readpartial(666)
+    s.preq(["enq-cmd", "echo $FOO | tee #{tmp.path}"])
+    assert_equal "OK", s.readpartial(666)
+    wait_files_not_empty(tmp)
+    assert_equal "BAR\n", tmp.read
+
+    tmp.rewind
+    tmp.truncate(0)
+    s.preq("env FOO#")
+    assert_equal "OK", s.readpartial(666)
+    s.preq(["enq-cmd", "echo -$FOO- | tee #{tmp.path}"])
+    assert_equal "OK", s.readpartial(666)
+    wait_files_not_empty(tmp)
+    assert_equal "--\n", tmp.read
+  end
+
+  def test_sink_env
+    s = client_socket
+    tmp = Tempfile.new(%w(env .txt))
+    s.preq("sink ed default active=true command='echo -$FOO- > #{tmp.path}'")
+    assert_equal "OK", s.readpartial(666)
+
+    s.preq("sink ed default env.FOO=BAR")
+    assert_equal "OK", s.readpartial(666)
+    s.preq(["enq-cmd", "echo HI"])
+    assert_equal "OK", s.readpartial(666)
+    wait_files_not_empty(tmp)
+    assert_equal "-BAR-\n", tmp.read
+
+    tmp.rewind
+    tmp.truncate(0)
+    s.preq("sink ed default env#FOO")
+    assert_equal "OK", s.readpartial(666)
+
+    Timeout.timeout(5) do
+      begin
+        s.preq("current")
+        yaml = s.readpartial(66666)
+        cur = YAML.load(yaml)
+      end while cur["sinks"] && sleep(0.01)
+    end
+
+    s.preq(["enq-cmd", "echo HI"])
+    assert_equal "OK", s.readpartial(666)
+    wait_files_not_empty(tmp)
+    assert_equal "--\n", tmp.read
+  end
+
+  def test_enq_head
+    s = client_socket
+    default_sink_pid(s)
+    dump = Tempfile.new(%W(d .sox))
+    s.preq "sink ed dump active=true command='sox $SOXFMT - #{dump.path}'"
+    assert_equal "OK", s.readpartial(666)
+    noise, len = tmp_noise
+    s.preq("enq-head #{noise.path}")
+    assert_equal "OK", s.readpartial(666)
+    s.preq("enq-head #{noise.path} 4")
+    assert_equal "OK", s.readpartial(666)
+    s.preq("enq-head #{noise.path} 3")
+    assert_equal "OK", s.readpartial(666)
+    dethrottle_decoder(s)
+    expect = Tempfile.new(%W(expect .sox))
+
+    c = "sox #{noise.path} -t sox '|sox #{noise.path} -p trim 3' " \
+            "-t sox '|sox #{noise.path} -p trim 4' #{expect.path}"
+    assert system(c)
+    Timeout.timeout(len) do
+      begin
+        s.preq("current")
+        yaml = s.readpartial(66666)
+        cur = YAML.load(yaml)
+      end while cur["sinks"] && sleep(0.01)
+    end
+    assert(system("cmp", dump.path, expect.path),
+           "files don't match #{dump.path} != #{expect.path}")
+  end
+end
diff --git a/test/test_rg_integration.rb b/test/test_rg_integration.rb
new file mode 100644
index 0000000..d8a8b85
--- /dev/null
+++ b/test/test_rg_integration.rb
@@ -0,0 +1,117 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/player_integration'
+class TestRgIntegration < Minitest::Unit::TestCase
+  include PlayerIntegration
+
+  def tmp_pluck(len = 5)
+    pluck = Tempfile.open(%w(pluck .flac))
+    cmd = %W(sox -R -n -r44100 -c2 -C0 #{pluck.path} synth #{len} pluck)
+    assert system(*cmd), cmd
+    cmd = %W(metaflac
+             --set-tag=REPLAYGAIN_TRACK_GAIN=-2
+             --set-tag=REPLAYGAIN_ALBUM_GAIN=-3.0
+             --set-tag=REPLAYGAIN_TRACK_PEAK=0.666
+             --set-tag=REPLAYGAIN_ALBUM_PEAK=0.999
+             #{pluck.path})
+    assert system(*cmd), cmd
+    [ pluck, len ]
+  end
+
+  def test_rg_changes_added
+    s = client_socket
+    pluck, len = tmp_pluck
+
+    # create the default sink, as well as a dumper
+    dumper = Tempfile.open(%w(dump .sox))
+    dump_pid = Tempfile.new(%w(dump .pid))
+    default_pid = default_sink_pid(s)
+    dump_cmd = "echo $$ > #{dump_pid.path}; sox $SOXFMT - #{dumper.path}"
+    s.send("sink ed dump active=true command='#{dump_cmd}'", Socket::MSG_EOR)
+    assert_equal("OK", s.readpartial(666))
+
+    # start playback!
+    s.send("enq \"#{pluck.path}\"", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    # wait for playback to start
+    yaml = cur = nil
+    Timeout.timeout(5) do
+      begin
+        s.send("current", Socket::MSG_EOR)
+        cur = YAML.load(yaml = s.readpartial(1666))
+      end while cur["current_offset"] == 0 && sleep(0.01)
+    end
+
+    assert_nil cur["current"]["env"]["RGFX"]
+
+    assert_equal DTAS::Format.new.rate * len, cur["current_expect"]
+
+    wait_files_not_empty(dump_pid)
+    pid = read_pid_file(dump_pid)
+
+    check_gain = proc do |expect, mode|
+      s.send("rg mode=#{mode}", Socket::MSG_EOR)
+      assert_equal "OK", s.readpartial(666)
+      Timeout.timeout(5) do
+        begin
+          s.send("current", Socket::MSG_EOR)
+          cur = YAML.load(yaml = s.readpartial(3666))
+        end while cur["current"]["env"]["RGFX"] !~ expect && sleep(0.01)
+      end
+      assert_match expect, cur["current"]["env"]["RGFX"]
+    end
+
+    check_gain.call(%r{vol -3dB}, "album_gain")
+    check_gain.call(%r{vol -2dB}, "track_gain")
+    check_gain.call(%r{vol 1\.3}, "track_peak")
+    check_gain.call(%r{vol 1\.0}, "album_peak")
+
+    s.send("rg preamp+=1", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    s.send("rg", Socket::MSG_EOR)
+    rg = YAML.load(yaml = s.readpartial(3666))
+    assert_equal 1, rg["preamp"]
+
+    s.send("rg preamp-=1", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    s.send("rg", Socket::MSG_EOR)
+    rg = YAML.load(yaml = s.readpartial(3666))
+    assert_nil rg["preamp"]
+
+    s.send("rg preamp=2", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    s.send("rg", Socket::MSG_EOR)
+    rg = YAML.load(yaml = s.readpartial(3666))
+    assert_equal 2, rg["preamp"]
+
+    s.send("rg preamp-=0.3", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    s.send("rg", Socket::MSG_EOR)
+    rg = YAML.load(yaml = s.readpartial(3666))
+    assert_equal 1.7, rg["preamp"]
+
+    s.send("rg preamp-=-0.3", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    s.send("rg", Socket::MSG_EOR)
+    rg = YAML.load(yaml = s.readpartial(3666))
+    assert_equal 2.0, rg["preamp"]
+
+    s.send("rg preamp-=+0.3", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+    s.send("rg", Socket::MSG_EOR)
+    rg = YAML.load(yaml = s.readpartial(3666))
+    assert_equal 1.7, rg["preamp"]
+
+    dethrottle_decoder(s)
+
+    # ensure we did not change audio length
+    wait_pid_dead(pid, len)
+    samples = `soxi -s #{dumper.path}`.to_i
+    assert_equal cur["current_expect"], samples
+    assert_equal `soxi -d #{dumper.path}`, `soxi -d #{pluck.path}`
+
+    stop_playback(default_pid, s)
+  end
+end
diff --git a/test/test_rg_state.rb b/test/test_rg_state.rb
new file mode 100644
index 0000000..f591ac8
--- /dev/null
+++ b/test/test_rg_state.rb
@@ -0,0 +1,32 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'dtas/rg_state'
+
+class TestRGState < Minitest::Unit::TestCase
+
+  def test_rg_state
+    rg = DTAS::RGState.new
+    assert_equal({}, rg.to_hsh)
+    rg.preamp = 0.1
+    assert_equal({"preamp" => 0.1}, rg.to_hsh)
+    rg.preamp = 0
+    assert_equal({}, rg.to_hsh)
+  end
+
+  def test_load
+    rg = DTAS::RGState.load("preamp" => 0.666)
+    assert_equal({"preamp" => 0.666}, rg.to_hsh)
+  end
+
+  def test_mode_set
+    rg = DTAS::RGState.new
+    orig = rg.mode
+    assert_equal DTAS::RGState::RG_DEFAULT["mode"], orig
+    %w(album_gain track_gain album_peak track_peak).each do |t|
+      rg.mode = t
+      assert_equal t, rg.mode
+    end
+  end
+end
diff --git a/test/test_sink.rb b/test/test_sink.rb
new file mode 100644
index 0000000..959bbf0
--- /dev/null
+++ b/test/test_sink.rb
@@ -0,0 +1,32 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'dtas/sink'
+require 'yaml'
+
+class TestSink < Minitest::Unit::TestCase
+  def test_serialize_reload
+    sink = DTAS::Sink.new
+    sink.name = "DAC"
+    hash = sink.to_hsh
+    assert_kind_of Hash, hash
+    refute_match(%r{ruby}i, hash.to_yaml, "ruby guts exposed: #{hash}")
+
+    s2 = DTAS::Sink.load(hash)
+    assert_equal sink.to_hsh, s2.to_hsh
+    assert_equal hash, s2.to_hsh
+  end
+
+  def test_name
+    sink = DTAS::Sink.new
+    sink.name = "dac1"
+    assert_equal({"name" => "dac1"}, sink.to_hsh)
+  end
+
+  def test_inactive_load
+    orig = { "active" => false }.freeze
+    tmp = orig.to_yaml
+    assert_equal orig, YAML.load(tmp)
+  end
+end
diff --git a/test/test_sink_reader_play.rb b/test/test_sink_reader_play.rb
new file mode 100644
index 0000000..03aa979
--- /dev/null
+++ b/test/test_sink_reader_play.rb
@@ -0,0 +1,49 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require 'dtas/sink_reader_play'
+require './test/helper'
+
+class TestSinkReaderPlay < Minitest::Unit::TestCase
+  FMT = "\rIn:%-5s %s [%s] Out:%-5s [%6s|%-6s] %s Clip:%-5s"
+  ZERO = "\rIn:0.00% 00:00:00.00 [00:00:00.00] Out:0     " \
+         "[      |      ]        Clip:0    "
+
+  def setup
+    @srp = DTAS::SinkReaderPlay.new
+  end
+
+  def teardown
+    @srp.close
+  end
+
+  def test_sink_reader_play
+    @srp.wr.write(ZERO)
+    assert_equal :wait_readable, @srp.readable_iter
+    assert_equal "0", @srp.clips
+    assert_equal nil, @srp.headroom
+    assert_equal "[      |      ]", @srp.meter
+    assert_equal "0", @srp.out
+    assert_equal "00:00:00.00", @srp.time
+
+    noheadroom = sprintf(FMT, '0.00%', '00:00:37.34', '00:00:00.00',
+                         '1.65M', ' -====', '====  ', ' ' * 6, '3M')
+    @srp.wr.write(noheadroom)
+    assert_equal :wait_readable, @srp.readable_iter
+    assert_equal '3M', @srp.clips
+    assert_equal nil, @srp.headroom
+    assert_equal '[ -====|====  ]', @srp.meter
+    assert_equal '1.65M', @srp.out
+    assert_equal '00:00:37.34', @srp.time
+
+    headroom = sprintf(FMT, '0.00%', '00:00:37.43', '00:00:00.00',
+                         '1.66M', ' =====', '===== ', 'Hd:1.2', '3.1M')
+    @srp.wr.write(headroom)
+    assert_equal :wait_readable, @srp.readable_iter
+    assert_equal '3.1M', @srp.clips
+    assert_equal '1.2', @srp.headroom
+    assert_equal '[ =====|===== ]', @srp.meter
+    assert_equal '1.66M', @srp.out
+    assert_equal '00:00:37.43', @srp.time
+  end
+end
diff --git a/test/test_sink_tee_integration.rb b/test/test_sink_tee_integration.rb
new file mode 100644
index 0000000..8c20adb
--- /dev/null
+++ b/test/test_sink_tee_integration.rb
@@ -0,0 +1,34 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/player_integration'
+class TestSinkTeeIntegration < Minitest::Unit::TestCase
+  include PlayerIntegration
+
+  def test_tee_integration
+    s = client_socket
+    default_sink_pid(s)
+    tee_pid = Tempfile.new(%w(dtas-test .pid))
+    orig = Tempfile.new(%w(orig .junk))
+    ajunk = Tempfile.new(%w(a .junk))
+    bjunk = Tempfile.new(%w(b .junk))
+    cmd = "echo $$ > #{tee_pid.path}; " \
+          "cat /dev/fd/a > #{ajunk.path} & " \
+          "cat /dev/fd/b > #{bjunk.path}; wait"
+    s.send("sink ed split active=true command='#{cmd}'", Socket::MSG_EOR)
+    assert_equal("OK", s.readpartial(666))
+    pluck = "sox -n $SOXFMT - synth 3 pluck | tee #{orig.path}"
+    s.send("enq-cmd \"#{pluck}\"", Socket::MSG_EOR)
+    assert_equal "OK", s.readpartial(666)
+
+    wait_files_not_empty(tee_pid)
+    pid = read_pid_file(tee_pid)
+    dethrottle_decoder(s)
+    wait_pid_dead(pid)
+    assert_equal ajunk.size, bjunk.size
+    assert_equal orig.size, bjunk.size
+    assert_equal ajunk.read, bjunk.read
+    bjunk.rewind
+    assert_equal orig.read, bjunk.read
+  end
+end
diff --git a/test/test_source.rb b/test/test_source.rb
new file mode 100644
index 0000000..21a56ac
--- /dev/null
+++ b/test/test_source.rb
@@ -0,0 +1,102 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'dtas/source'
+require 'tempfile'
+
+class TestSource < Minitest::Unit::TestCase
+  def teardown
+    @tempfiles.each { |tmp| tmp.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
+
+    source = DTAS::Source.new(tmp.path)
+    x(%W(metaflac --set-tag=FOO=BAR #{tmp.path}))
+    x(%W(metaflac --add-replay-gain #{tmp.path}))
+    assert_equal source.comments["FOO"], "BAR"
+    rg = source.replaygain
+    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
+
+    source = DTAS::Source.new(a.path)
+
+    # 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
+
+    rg = source.replaygain
+    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('sox') or return
+    source = DTAS::Source.new(*%W(#{tmp.path} 5s))
+    assert_equal 5, source.offset_samples
+
+    source = DTAS::Source.new(*%W(#{tmp.path} 1:00:00.5))
+    expect = 1 * 60 * 60 * 44100 + (44100/2)
+    assert_equal expect, source.offset_samples
+
+    source = DTAS::Source.new(*%W(#{tmp.path} 1:10.5))
+    expect = 1 * 60 * 44100 + (10 * 44100) + (44100/2)
+    assert_equal expect, source.offset_samples
+
+    source = DTAS::Source.new(*%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('sox') or return
+    source = DTAS::Source.new(*%W(#{tmp.path} 441s))
+    assert_equal 10000.0, source.offset_us
+
+    source = DTAS::Source.new(*%W(#{tmp.path} 22050s))
+    assert_equal 500000.0, source.offset_us
+
+    source = DTAS::Source.new(tmp.path, '1')
+    assert_equal 1000000.0, source.offset_us
+  end
+end
diff --git a/test/test_unixserver.rb b/test/test_unixserver.rb
new file mode 100644
index 0000000..46ddb49
--- /dev/null
+++ b/test/test_unixserver.rb
@@ -0,0 +1,66 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'tempfile'
+require 'dtas/unix_server'
+require 'stringio'
+
+class TestUNIXServer < Minitest::Unit::TestCase
+  def setup
+    @tmp = Tempfile.new(%w(dtas-unix_server-test .sock))
+    File.unlink(@tmp.path)
+    @clients = []
+    @srv = DTAS::UNIXServer.new(@tmp.path)
+  end
+
+  def test_close
+    assert File.exist?(@tmp.path)
+    assert_nil @srv.close
+    refute File.exist?(@tmp.path)
+  end
+
+  def teardown
+    @clients.each { |io| io.close unless io.closed? }
+    if File.exist?(@tmp.path)
+      @tmp.close!
+    else
+      @tmp.close
+    end
+  end
+
+  def new_client
+    c = Socket.new(:AF_UNIX, :SEQPACKET, 0)
+    @clients << c
+    c.connect(Socket.pack_sockaddr_un(@tmp.path))
+    c
+  end
+
+  def test_server_loop
+    client = new_client
+    @srv.run_once # nothing
+    msgs = []
+    clients = []
+    client.send("HELLO", Socket::MSG_EOR)
+    @srv.run_once do |c, msg|
+      clients << c
+      msgs << msg
+    end
+    assert_equal %w(HELLO), msgs, clients.inspect
+    assert_equal 1, clients.size
+    c = clients[0]
+    c.emit "HIHI"
+    assert_equal "HIHI", client.recv(4)
+
+    err = nil
+    500.times do
+      rc = c.emit "SPAM"
+      case rc
+      when StandardError
+        err = rc
+        break
+      end
+    end
+    assert_kind_of RuntimeError, err
+  end
+end
diff --git a/test/test_util.rb b/test/test_util.rb
new file mode 100644
index 0000000..9c0e218
--- /dev/null
+++ b/test/test_util.rb
@@ -0,0 +1,15 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net>
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require './test/helper'
+require 'dtas/util'
+
+class TestUtil < Minitest::Unit::TestCase
+  include DTAS::Util
+  def test_util
+    orig = 6.0
+    lin = db_to_linear(orig)
+    db = linear_to_db(lin)
+    assert_in_delta orig, db, 0.00000001
+  end
+end