diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are 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.
+
+  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.
+
+  Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+  A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+  The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+  An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+  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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
+
+  Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+  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 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 work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero 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 your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+  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 AGPL, see
+<http://www.gnu.org/licenses/>.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,51 @@
+# herald: sheet music in Haskell
+
+[![CI](https://github.com/joaquin-rossi/herald/actions/workflows/ci.yml/badge.svg)](https://github.com/joaquin-rossi/herald/actions/workflows/ci.yml)
+
+Haskell library for writing musical scores as code and synthesizing them into audio.
+
+Original idea: https://www.youtube.com/watch?v=FYTZkE5BZ-0
+
+## Features
+
+- Named pitches, key signatures and accidentals that last until the bar line
+- Note values, dots, ties, chords and trills
+- Time signatures, with every bar checked
+- Dynamics
+- Equal temperament and historical tunings
+- Several voices, each with its own instrument and stereo position
+- Mono or stereo WAV output and playback
+
+Instruments: `pureTone`, `additive`, `organ`, `harpsichord`.
+
+## Example
+
+```haskell
+import Herald
+
+melody :: Score m => m ()
+melody = do
+  setKey gMinor
+  tellNotes quarter [g4, b4, a4, g4]
+  tellBar
+  tellNote whole g4
+
+main :: IO ()
+main =
+  either (putStrLn . displayScoreError) (saveWav "melody.wav") $
+    playScoreMono defaultConfig [(defaultVoice, melody)]
+```
+
+## Demo
+
+```sh
+cabal run herald-demo -- play little-fugue  # requires ffplay
+```
+
+Little Fugue in G minor ([source](https://www.mit.edu/~jcb/little-fugue-two-violins.abc)):
+
+https://github.com/user-attachments/assets/ad373e14-ba8c-4924-a4e0-e839ccfaa71f
+
+Happy Birthday:
+
+https://github.com/user-attachments/assets/da93b721-d94f-4736-bea8-e08e566b3189
diff --git a/herald.cabal b/herald.cabal
new file mode 100644
--- /dev/null
+++ b/herald.cabal
@@ -0,0 +1,72 @@
+cabal-version:      3.0
+name:               herald
+version:            0.1.0.1
+synopsis:           Write sheet music as Haskell code and synthesize it
+description:
+    Herald synthesizes music from scores written as Haskell code, with named
+    pitches, key signatures, checked bars, ties, dynamics and several voices.
+    Voices are performed by simple instruments (pure tones, additive, organ
+    and a Karplus-Strong harpsichord) in equal temperament or historical
+    tunings, and rendered to mono or stereo WAV or played through ffplay.
+    Start with the "Herald" module, which re-exports the whole library.
+homepage:           https://github.com/joaquin-rossi/herald
+bug-reports:        https://github.com/joaquin-rossi/herald/issues
+license:            AGPL-3.0-or-later
+license-file:       LICENSE
+author:             Joaquín Rossi
+maintainer:         joaquin@joaquinrossi.net
+category:           Sound
+build-type:         Simple
+tested-with:        GHC ==9.10.3 || ==9.12.4 || ==9.14.1
+extra-doc-files:    README.md
+
+source-repository head
+    type:     git
+    location: https://github.com/joaquin-rossi/herald.git
+
+common common
+    default-language: GHC2024
+    ghc-options:
+        -Wall
+        -Wcompat
+        -Widentities
+        -Wincomplete-record-updates
+        -Wmissing-export-lists
+        -Wredundant-constraints
+        -Wunused-packages
+
+library
+    import:           common
+    hs-source-dirs:   src
+    exposed-modules:
+        Herald
+        Herald.Performance
+        Herald.Performance.Instrument
+        Herald.Score
+        Herald.Score.Duration
+        Herald.Score.Dynamic
+        Herald.Score.Pitch
+        Herald.Sound
+        Herald.Sound.IO
+        Herald.Tuning
+
+    build-depends:
+        base >=4.20 && <4.23,
+        bytestring >=0.12 && <0.13,
+        containers >=0.7 && <0.9,
+        mtl >=2.3.1 && <2.4,
+        process >=1.6.19 && <1.7,
+        vector >=0.13.1 && <0.14,
+
+test-suite herald-test
+    import:           common
+    type:             exitcode-stdio-1.0
+    hs-source-dirs:   test
+    main-is:          Main.hs
+    build-depends:
+        base,
+        bytestring,
+        herald,
+        tasty >=1.4.2 && <1.6,
+        tasty-hunit >=0.10 && <0.11,
+        vector,
diff --git a/src/Herald.hs b/src/Herald.hs
new file mode 100644
--- /dev/null
+++ b/src/Herald.hs
@@ -0,0 +1,23 @@
+-- | Write sheet music as Haskell code and synthesize it.
+module Herald
+  ( module Herald.Score,
+    module Herald.Score.Pitch,
+    module Herald.Score.Duration,
+    module Herald.Score.Dynamic,
+    module Herald.Performance,
+    module Herald.Performance.Instrument,
+    module Herald.Tuning,
+    module Herald.Sound,
+    module Herald.Sound.IO,
+  )
+where
+
+import Herald.Performance
+import Herald.Performance.Instrument
+import Herald.Score
+import Herald.Score.Duration
+import Herald.Score.Dynamic
+import Herald.Score.Pitch
+import Herald.Sound
+import Herald.Sound.IO
+import Herald.Tuning
diff --git a/src/Herald/Performance.hs b/src/Herald/Performance.hs
new file mode 100644
--- /dev/null
+++ b/src/Herald/Performance.hs
@@ -0,0 +1,280 @@
+-- | Renders "Herald.Score" voices to sound.
+--
+-- >>> let short = setMeter (Meter 4 4) >> tellNotes quarter [c4, d4, e4] >> tellBar
+-- >>> either displayScoreError (const "ok") (playScoreMono defaultConfig [(defaultVoice, short)])
+-- "voice 1, bar 1: the bar has 3.0 beats but the meter has 4.0"
+module Herald.Performance
+  ( Performance,
+    ScoreConfig (..),
+    defaultConfig,
+    VoiceConfig (..),
+    defaultVoice,
+    playScoreMono,
+    playScoreStereo,
+    ScoreError (..),
+    ErrorReason (..),
+    displayScoreError,
+  )
+where
+
+import Control.Monad (forM_, when, zipWithM)
+import Control.Monad.Except (MonadError (..))
+import Control.Monad.Reader
+import Control.Monad.State.Strict
+import Control.Monad.Writer.Strict
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Data.Vector.Unboxed qualified as V
+import Herald.Performance.Instrument
+import Herald.Score
+import Herald.Score.Duration
+import Herald.Score.Dynamic
+import Herald.Score.Pitch
+import Herald.Sound
+import Herald.Tuning
+
+-- | Shared by every voice.
+data ScoreConfig = ScoreConfig
+  { tempo :: Bpm,
+    tuning :: Tuning
+  }
+
+-- | 120 bpm in equal temperament at 440 Hz.
+defaultConfig :: ScoreConfig
+defaultConfig =
+  ScoreConfig
+    { tempo = 120,
+      tuning = a440
+    }
+
+-- | How a voice is performed.
+data VoiceConfig = VoiceConfig
+  { instrument :: Instrument,
+    -- | Ignored by 'playScoreMono'.
+    pan :: Pan
+  }
+
+-- | The additive instrument, centred.
+defaultVoice :: VoiceConfig
+defaultVoice =
+  VoiceConfig
+    { instrument = additive,
+      pan = 0
+    }
+
+-- | Voices and bars count from 1.
+data ScoreError = ScoreError
+  { errorVoice :: Int,
+    errorBar :: Int,
+    errorReason :: ErrorReason
+  }
+  deriving (Show, Eq)
+
+-- | A notation mistake in a voice.
+data ErrorReason
+  = -- | Actual and expected beats.
+    WrongBarLength Beats Beats
+  | TieToDifferentPitch
+  | UnfinishedTie
+  deriving (Show, Eq)
+
+-- | As in @voice 2, bar 49: the bar has 4.25 beats but the meter has 4.0@.
+displayScoreError :: ScoreError -> String
+displayScoreError (ScoreError v b reason) =
+  "voice " ++ show v ++ ", bar " ++ show b ++ ": " ++ case reason of
+    WrongBarLength actual expected ->
+      "the bar has " ++ show actual ++ " beats but the meter has " ++ show expected
+    TieToDifferentPitch -> "a tie continues into a different pitch"
+    UnfinishedTie -> "a tie is not followed by a note of the same pitch"
+
+-- | Voices can only use the 'Score' operations, not its state or errors.
+newtype Performance a = Performance (Perform a)
+  deriving newtype (Functor, Applicative, Monad)
+
+instance Score Performance where
+  tellNote b p = Performance (noteP b p)
+  tellTie b p = Performance (tieP b p)
+  tellChord b ps = Performance (chordP b ps)
+  tellTrill b k p q = Performance (trillP b k p q)
+  tellRest b = Performance (soundP b silence)
+  tellBar = Performance barP
+  setKey k = Performance (modify' (\st -> st {stateKey = k}))
+  setMeter m = Performance (modify' (\st -> st {stateMeter = Just m}))
+  setDynamic d = Performance (modify' (\st -> st {stateDynamic = d}))
+
+type Perform =
+  ReaderT (ScoreConfig, VoiceConfig) (StateT PerformState (WriterT Track (Either ScoreError)))
+
+data PerformState = PerformState
+  { stateKey :: Key,
+    stateMeter :: Maybe Meter,
+    stateDynamic :: Dynamic,
+    stateBarAccidentals :: Map (Letter, Octave) Accidental,
+    stateBar :: Int,
+    stateBarBeats :: Beats,
+    stateTie :: Maybe Tie
+  }
+
+data Tie = Tie
+  { tieWritten :: (Letter, Octave),
+    tieSemitones :: Semitones,
+    tieDynamic :: Dynamic,
+    tieBeats :: Beats
+  }
+
+-- | Chunks joined once at the end: appending vectors note by note would be
+-- quadratic.
+newtype Track = Track [Sound]
+  deriving newtype (Semigroup, Monoid)
+
+-- | Perform the voices in mono.
+playScoreMono :: ScoreConfig -> [(VoiceConfig, Performance ())] -> Either ScoreError Sound
+playScoreMono config voices = balanceSound . map snd <$> performVoices config voices
+
+-- | Perform the voices in stereo.
+playScoreStereo :: ScoreConfig -> [(VoiceConfig, Performance ())] -> Either ScoreError Stereo
+playScoreStereo config voices =
+  balanceStereo . map (\(v, s) -> panSound (pan v) s) <$> performVoices config voices
+
+performVoices :: ScoreConfig -> [(VoiceConfig, Performance ())] -> Either ScoreError [(VoiceConfig, Sound)]
+performVoices config = zipWithM perform [1 ..]
+  where
+    perform k (v, Performance voice) =
+      case runWriterT (execStateT (runReaderT (voice >> finishP) (config, v)) start) of
+        Left e -> Left e {errorVoice = k}
+        Right (_, Track chunks) -> Right (v, V.concat chunks)
+    start =
+      PerformState
+        { stateKey = cMajor,
+          stateMeter = Nothing,
+          stateDynamic = mezzoForte,
+          stateBarAccidentals = Map.empty,
+          stateBar = 1,
+          stateBarBeats = 0,
+          stateTie = Nothing
+        }
+
+finishP :: Perform ()
+finishP = do
+  requireNoTie
+  beats <- gets stateBarBeats
+  when (beats > 0) checkBar
+
+failWith :: ErrorReason -> Perform a
+failWith reason = do
+  b <- gets stateBar
+  throwError (ScoreError 0 b reason)
+
+barP :: Perform ()
+barP = do
+  checkBar
+  modify' $ \st ->
+    st
+      { stateBar = stateBar st + 1,
+        stateBarBeats = 0,
+        stateBarAccidentals = Map.empty
+      }
+
+checkBar :: Perform ()
+checkBar = do
+  meter <- gets stateMeter
+  beats <- gets stateBarBeats
+  forM_ meter $ \m ->
+    when (abs (beats - meterBeats m) > 1e-9) $
+      failWith (WrongBarLength beats (meterBeats m))
+
+-- | A written accidental lasts until the end of the bar for that letter and
+-- octave; an unwritten one comes from earlier in the bar, else the key.
+resolve :: Pitch -> Perform Semitones
+resolve p = do
+  k <- gets stateKey
+  case accidental p of
+    Just a -> do
+      modify' $ \st -> st {stateBarAccidentals = Map.insert here a (stateBarAccidentals st)}
+      pure (pitchSemitones k p)
+    Nothing -> do
+      written <- gets (Map.lookup here . stateBarAccidentals)
+      pure (pitchSemitones k p {accidental = written})
+  where
+    here = (letter p, octave p)
+
+-- | Like 'resolve' without recording the accidental, for trill auxiliaries.
+resolveAux :: Pitch -> Perform Semitones
+resolveAux p = do
+  k <- gets stateKey
+  written <- gets (Map.lookup (letter p, octave p) . stateBarAccidentals)
+  pure (pitchSemitones k p {accidental = maybe written Just (accidental p)})
+
+advance :: Beats -> Perform ()
+advance b = modify' (\st -> st {stateBarBeats = stateBarBeats st + b})
+
+seconds :: Beats -> Perform Seconds
+seconds b = asks (\(config, _) -> b * (60.0 / tempo config))
+
+frequency :: Semitones -> Perform Hz
+frequency n = asks (\(config, _) -> tune (tuning config) n)
+
+voiceInstrument :: Perform Instrument
+voiceInstrument = asks (instrument . snd)
+
+emit :: Dynamic -> Sound -> Perform ()
+emit d s = tell $ Track [gain (dynamicGain d) s]
+
+soundP :: Beats -> (Seconds -> Sound) -> Perform ()
+soundP b sound = do
+  requireNoTie
+  t <- seconds b
+  d <- gets stateDynamic
+  emit d (sound t)
+  advance b
+
+requireNoTie :: Perform ()
+requireNoTie = do
+  t <- gets stateTie
+  forM_ t $ \_ -> failWith UnfinishedTie
+
+continueTie :: Beats -> Pitch -> Tie -> Perform Tie
+continueTie b p t = do
+  when ((letter p, octave p) /= tieWritten t) $ failWith TieToDifferentPitch
+  advance b
+  pure t {tieBeats = tieBeats t + b}
+
+noteP :: Beats -> Pitch -> Perform ()
+noteP b p =
+  gets stateTie >>= \case
+    Nothing -> do
+      f <- resolve p >>= frequency
+      i <- voiceInstrument
+      soundP b (renderNote i f)
+    Just tie -> do
+      tie' <- continueTie b p tie
+      modify' (\st -> st {stateTie = Nothing})
+      f <- frequency (tieSemitones tie')
+      t <- seconds (tieBeats tie')
+      i <- voiceInstrument
+      emit (tieDynamic tie') (renderNote i f t)
+
+tieP :: Beats -> Pitch -> Perform ()
+tieP b p =
+  gets stateTie >>= \case
+    Nothing -> do
+      n <- resolve p
+      d <- gets stateDynamic
+      advance b
+      modify' (\st -> st {stateTie = Just (Tie (letter p, octave p) n d b)})
+    Just tie -> do
+      tie' <- continueTie b p tie
+      modify' (\st -> st {stateTie = Just tie'})
+
+chordP :: Beats -> [Pitch] -> Perform ()
+chordP b ps = do
+  fs <- mapM (\p -> resolve p >>= frequency) ps
+  i <- voiceInstrument
+  soundP b (chord i fs)
+
+trillP :: Beats -> Int -> Pitch -> Pitch -> Perform ()
+trillP b k p q = do
+  f <- resolve p >>= frequency
+  g <- resolveAux q >>= frequency
+  i <- voiceInstrument
+  soundP b (trill i k f g)
diff --git a/src/Herald/Performance/Instrument.hs b/src/Herald/Performance/Instrument.hs
new file mode 100644
--- /dev/null
+++ b/src/Herald/Performance/Instrument.hs
@@ -0,0 +1,67 @@
+-- | Instruments render a note of a frequency and duration, peaking at most 1.
+module Herald.Performance.Instrument
+  ( Instrument (..),
+    pureTone,
+    additive,
+    organ,
+    harpsichord,
+    partials,
+    chord,
+    trill,
+  )
+where
+
+import Data.Vector.Unboxed qualified as V
+import Herald.Sound
+
+-- | Renders a note of a frequency and duration.
+newtype Instrument = Instrument {renderNote :: Hz -> Seconds -> Sound}
+
+-- | Sine partials as frequency and relative amplitude, with short attack and
+-- release ramps. Partials above Nyquist are dropped to avoid aliasing.
+partials :: [(Hz, Double)] -> Seconds -> Sound
+partials ws t = mixSound [gain (a / total) (envelope (sine f t)) | (f, a) <- ws']
+  where
+    ws' = filter ((< sampleRate / 2) . fst) ws
+    total = sum (snd <$> ws')
+    len = sampleCount t
+    ramp i = min 1.0 (fromIntegral i * 0.001)
+    envelope = V.imap (\i x -> ramp i * ramp (len - 1 - i) * x)
+
+-- | A single sine wave.
+pureTone :: Instrument
+pureTone = Instrument $ \f -> partials [(f, 1)]
+
+-- | Five slightly inharmonic partials.
+additive :: Instrument
+additive = Instrument $ \f -> partials (take 5 $ zip (inharmonics 0.001 f) (iterate (* 0.5) 1.0))
+
+-- | Pipe organ stops.
+organ :: Instrument
+organ = Instrument $ \f -> partials [(f * r, a) | (r, a) <- stops]
+  where
+    stops =
+      [ (0.5, 0.5), -- 16'
+        (1, 1), -- 8'
+        (2, 0.6), -- 4'
+        (3, 0.3), -- 2 2/3'
+        (4, 0.4), -- 2'
+        (8, 0.2) -- 1'
+      ]
+
+-- | Faded out over the last 5 ms so notes cut short don't click.
+harpsichord :: Instrument
+harpsichord = Instrument $ \f t ->
+  let len = sampleCount t
+      fade = fromIntegral (sampleCount 0.005)
+   in V.imap (\i x -> min 1 (fromIntegral (len - 1 - i) / fade) * x) (pluck 0.996 f t)
+
+-- | Notes sounding together.
+chord :: Instrument -> [Hz] -> Seconds -> Sound
+chord i fs t = balanceSound [renderNote i f t | f <- fs]
+
+-- | @k@ equal notes alternating @f@ and @g@.
+trill :: Instrument -> Int -> Hz -> Hz -> Seconds -> Sound
+trill i k f g t = V.concat [renderNote i x (t / fromIntegral k') | x <- take k' (cycle [f, g])]
+  where
+    k' = max 1 k
diff --git a/src/Herald/Score.hs b/src/Herald/Score.hs
new file mode 100644
--- /dev/null
+++ b/src/Herald/Score.hs
@@ -0,0 +1,44 @@
+-- | The language voices are written in. Interpreters are instances of
+-- 'Score'; "Herald.Performance" renders to sound.
+module Herald.Score
+  ( Score (..),
+    tellNotes,
+  )
+where
+
+import Herald.Score.Duration
+import Herald.Score.Dynamic
+import Herald.Score.Pitch
+
+-- | Pitches follow notation rules: an unwritten accidental comes from earlier
+-- in the bar, else from the key (C major by default); a written one lasts
+-- until the next 'tellBar'.
+class (Monad m) => Score m where
+  -- | After 'tellTie', continues the tied note instead: same letter and
+  -- octave, keeping its accidental across bar lines.
+  tellNote :: Beats -> Pitch -> m ()
+
+  -- | Ties into the next note, which must have the same pitch.
+  tellTie :: Beats -> Pitch -> m ()
+
+  tellChord :: Beats -> [Pitch] -> m ()
+
+  -- | @tellTrill b k p q@: @k@ notes alternating @p@ and @q@. The accidental
+  -- of @q@ doesn't carry on through the bar.
+  tellTrill :: Beats -> Int -> Pitch -> Pitch -> m ()
+
+  tellRest :: Beats -> m ()
+
+  -- | Ends the bar, which must add up to the meter, and clears accidentals.
+  tellBar :: m ()
+
+  setKey :: Key -> m ()
+
+  -- | From then on, every bar must add up to it, including the last one.
+  setMeter :: Meter -> m ()
+
+  setDynamic :: Dynamic -> m ()
+
+-- | Notes of the same length.
+tellNotes :: (Score m) => Beats -> [Pitch] -> m ()
+tellNotes b = mapM_ (tellNote b)
diff --git a/src/Herald/Score/Duration.hs b/src/Herald/Score/Duration.hs
new file mode 100644
--- /dev/null
+++ b/src/Herald/Score/Duration.hs
@@ -0,0 +1,42 @@
+-- | Durations in beats, where a quarter note is one beat.
+module Herald.Score.Duration
+  ( Beats,
+    Bpm,
+    whole,
+    half,
+    quarter,
+    eighth,
+    sixteenth,
+    thirtySecond,
+    dotted,
+    Meter (..),
+    meterBeats,
+  )
+where
+
+-- | A duration in beats.
+type Beats = Double
+
+-- | Beats per minute.
+type Bpm = Double
+
+-- | Note values.
+whole, half, quarter, eighth, sixteenth, thirtySecond :: Beats
+whole = 4
+half = 2
+quarter = 1
+eighth = 1 / 2
+sixteenth = 1 / 4
+thirtySecond = 1 / 8
+
+-- | One and a half times as long.
+dotted :: Beats -> Beats
+dotted = (* 1.5)
+
+-- | Time signature, as in @Meter 6 8@.
+data Meter = Meter Int Int
+  deriving (Show, Eq)
+
+-- | Beats in a bar.
+meterBeats :: Meter -> Beats
+meterBeats (Meter n d) = fromIntegral n * 4 / fromIntegral d
diff --git a/src/Herald/Score/Dynamic.hs b/src/Herald/Score/Dynamic.hs
new file mode 100644
--- /dev/null
+++ b/src/Herald/Score/Dynamic.hs
@@ -0,0 +1,24 @@
+-- | Dynamics as gains relative to 'mezzoForte', where voices start.
+module Herald.Score.Dynamic
+  ( Dynamic (..),
+    pianissimo,
+    piano,
+    mezzoPiano,
+    mezzoForte,
+    forte,
+    fortissimo,
+  )
+where
+
+-- | A loudness.
+newtype Dynamic = Dynamic {dynamicGain :: Double}
+  deriving (Show, Eq, Ord)
+
+-- | From /pp/ to /ff/.
+pianissimo, piano, mezzoPiano, mezzoForte, forte, fortissimo :: Dynamic
+pianissimo = Dynamic 0.35
+piano = Dynamic 0.5
+mezzoPiano = Dynamic 0.7
+mezzoForte = Dynamic 1
+forte = Dynamic 1.4
+fortissimo = Dynamic 2
diff --git a/src/Herald/Score/Pitch.hs b/src/Herald/Score/Pitch.hs
new file mode 100644
--- /dev/null
+++ b/src/Herald/Score/Pitch.hs
@@ -0,0 +1,494 @@
+-- | Written pitches and key signatures. Names are letter, @b@ (flat) or @s@
+-- (sharp), and octave: @c4@ is middle C, @bb4@ is B flat.
+module Herald.Score.Pitch
+  ( Semitones,
+    Octave,
+    Letter (..),
+    Accidental (..),
+    Pitch (..),
+    flat,
+    sharp,
+    natural,
+    Key (..),
+    keyAccidental,
+    sharps,
+    flats,
+    cMajor,
+    gMinor,
+    pitchSemitones,
+    cb0,
+    c0,
+    cs0,
+    db0,
+    d0,
+    ds0,
+    eb0,
+    e0,
+    es0,
+    fb0,
+    f0,
+    fs0,
+    gb0,
+    g0,
+    gs0,
+    ab0,
+    a0,
+    as0,
+    bb0,
+    b0,
+    bs0,
+    cb1,
+    c1,
+    cs1,
+    db1,
+    d1,
+    ds1,
+    eb1,
+    e1,
+    es1,
+    fb1,
+    f1,
+    fs1,
+    gb1,
+    g1,
+    gs1,
+    ab1,
+    a1,
+    as1,
+    bb1,
+    b1,
+    bs1,
+    cb2,
+    c2,
+    cs2,
+    db2,
+    d2,
+    ds2,
+    eb2,
+    e2,
+    es2,
+    fb2,
+    f2,
+    fs2,
+    gb2,
+    g2,
+    gs2,
+    ab2,
+    a2,
+    as2,
+    bb2,
+    b2,
+    bs2,
+    cb3,
+    c3,
+    cs3,
+    db3,
+    d3,
+    ds3,
+    eb3,
+    e3,
+    es3,
+    fb3,
+    f3,
+    fs3,
+    gb3,
+    g3,
+    gs3,
+    ab3,
+    a3,
+    as3,
+    bb3,
+    b3,
+    bs3,
+    cb4,
+    c4,
+    cs4,
+    db4,
+    d4,
+    ds4,
+    eb4,
+    e4,
+    es4,
+    fb4,
+    f4,
+    fs4,
+    gb4,
+    g4,
+    gs4,
+    ab4,
+    a4,
+    as4,
+    bb4,
+    b4,
+    bs4,
+    cb5,
+    c5,
+    cs5,
+    db5,
+    d5,
+    ds5,
+    eb5,
+    e5,
+    es5,
+    fb5,
+    f5,
+    fs5,
+    gb5,
+    g5,
+    gs5,
+    ab5,
+    a5,
+    as5,
+    bb5,
+    b5,
+    bs5,
+    cb6,
+    c6,
+    cs6,
+    db6,
+    d6,
+    ds6,
+    eb6,
+    e6,
+    es6,
+    fb6,
+    f6,
+    fs6,
+    gb6,
+    g6,
+    gs6,
+    ab6,
+    a6,
+    as6,
+    bb6,
+    b6,
+    bs6,
+    cb7,
+    c7,
+    cs7,
+    db7,
+    d7,
+    ds7,
+    eb7,
+    e7,
+    es7,
+    fb7,
+    f7,
+    fs7,
+    gb7,
+    g7,
+    gs7,
+    ab7,
+    a7,
+    as7,
+    bb7,
+    b7,
+    bs7,
+    cb8,
+    c8,
+    cs8,
+    db8,
+    d8,
+    ds8,
+    eb8,
+    e8,
+    es8,
+    fb8,
+    f8,
+    fs8,
+    gb8,
+    g8,
+    gs8,
+    ab8,
+    a8,
+    as8,
+    bb8,
+    b8,
+    bs8,
+  )
+where
+
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Data.Maybe
+
+-- | A distance in semitones.
+type Semitones = Int
+
+-- | Octave 4 starts at middle C.
+type Octave = Int
+
+-- | A letter name.
+data Letter = C | D | E | F | G | A | B
+  deriving (Show, Eq, Ord, Enum, Bounded)
+
+-- | A flat, natural or sharp.
+data Accidental = Flat | Natural | Sharp
+  deriving (Show, Eq, Ord, Enum, Bounded)
+
+-- | A written pitch.
+data Pitch = Pitch
+  { letter :: Letter,
+    octave :: Octave,
+    -- | 'Nothing' when unwritten: the key decides.
+    accidental :: Maybe Accidental
+  }
+  deriving (Show, Eq)
+
+-- | Write an accidental.
+flat, sharp, natural :: Pitch -> Pitch
+flat p = p {accidental = Just Flat}
+sharp p = p {accidental = Just Sharp}
+natural p = p {accidental = Just Natural}
+
+-- | A key signature.
+newtype Key = Key (Map Letter Accidental)
+  deriving (Show, Eq)
+
+-- | The accidental a key gives a letter.
+keyAccidental :: Key -> Letter -> Accidental
+keyAccidental (Key as) l = Map.findWithDefault Natural l as
+
+-- | Sharps added in the order F C G D A E B.
+sharps :: Int -> Key
+sharps n = Key $ Map.fromList [(l, Sharp) | l <- take n [F, C, G, D, A, E, B]]
+
+-- | Flats added in the order B E A D G C F.
+flats :: Int -> Key
+flats n = Key $ Map.fromList [(l, Flat) | l <- take n [B, E, A, D, G, C, F]]
+
+-- | Common keys.
+cMajor, gMinor :: Key
+cMajor = Key Map.empty
+gMinor = flats 2
+
+-- | Relative to A4.
+pitchSemitones :: Key -> Pitch -> Semitones
+pitchSemitones key (Pitch l o acc) = base l + offset (fromMaybe (keyAccidental key l) acc) + 12 * (o - 4) - 9
+  where
+    base C = 0
+    base D = 2
+    base E = 4
+    base F = 5
+    base G = 7
+    base A = 9
+    base B = 11
+    offset Flat = -1
+    offset Natural = 0
+    offset Sharp = 1
+
+-- | Octave 0.
+cb0, c0, cs0, db0, d0, ds0, eb0, e0, es0, fb0, f0, fs0, gb0, g0, gs0, ab0, a0, as0, bb0, b0, bs0 :: Pitch
+c0 = Pitch C 0 Nothing
+d0 = Pitch D 0 Nothing
+e0 = Pitch E 0 Nothing
+f0 = Pitch F 0 Nothing
+g0 = Pitch G 0 Nothing
+a0 = Pitch A 0 Nothing
+b0 = Pitch B 0 Nothing
+cb0 = flat c0
+db0 = flat d0
+eb0 = flat e0
+fb0 = flat f0
+gb0 = flat g0
+ab0 = flat a0
+bb0 = flat b0
+cs0 = sharp c0
+ds0 = sharp d0
+es0 = sharp e0
+fs0 = sharp f0
+gs0 = sharp g0
+as0 = sharp a0
+bs0 = sharp b0
+
+-- | Octave 1.
+cb1, c1, cs1, db1, d1, ds1, eb1, e1, es1, fb1, f1, fs1, gb1, g1, gs1, ab1, a1, as1, bb1, b1, bs1 :: Pitch
+c1 = Pitch C 1 Nothing
+d1 = Pitch D 1 Nothing
+e1 = Pitch E 1 Nothing
+f1 = Pitch F 1 Nothing
+g1 = Pitch G 1 Nothing
+a1 = Pitch A 1 Nothing
+b1 = Pitch B 1 Nothing
+cb1 = flat c1
+db1 = flat d1
+eb1 = flat e1
+fb1 = flat f1
+gb1 = flat g1
+ab1 = flat a1
+bb1 = flat b1
+cs1 = sharp c1
+ds1 = sharp d1
+es1 = sharp e1
+fs1 = sharp f1
+gs1 = sharp g1
+as1 = sharp a1
+bs1 = sharp b1
+
+-- | Octave 2.
+cb2, c2, cs2, db2, d2, ds2, eb2, e2, es2, fb2, f2, fs2, gb2, g2, gs2, ab2, a2, as2, bb2, b2, bs2 :: Pitch
+c2 = Pitch C 2 Nothing
+d2 = Pitch D 2 Nothing
+e2 = Pitch E 2 Nothing
+f2 = Pitch F 2 Nothing
+g2 = Pitch G 2 Nothing
+a2 = Pitch A 2 Nothing
+b2 = Pitch B 2 Nothing
+cb2 = flat c2
+db2 = flat d2
+eb2 = flat e2
+fb2 = flat f2
+gb2 = flat g2
+ab2 = flat a2
+bb2 = flat b2
+cs2 = sharp c2
+ds2 = sharp d2
+es2 = sharp e2
+fs2 = sharp f2
+gs2 = sharp g2
+as2 = sharp a2
+bs2 = sharp b2
+
+-- | Octave 3.
+cb3, c3, cs3, db3, d3, ds3, eb3, e3, es3, fb3, f3, fs3, gb3, g3, gs3, ab3, a3, as3, bb3, b3, bs3 :: Pitch
+c3 = Pitch C 3 Nothing
+d3 = Pitch D 3 Nothing
+e3 = Pitch E 3 Nothing
+f3 = Pitch F 3 Nothing
+g3 = Pitch G 3 Nothing
+a3 = Pitch A 3 Nothing
+b3 = Pitch B 3 Nothing
+cb3 = flat c3
+db3 = flat d3
+eb3 = flat e3
+fb3 = flat f3
+gb3 = flat g3
+ab3 = flat a3
+bb3 = flat b3
+cs3 = sharp c3
+ds3 = sharp d3
+es3 = sharp e3
+fs3 = sharp f3
+gs3 = sharp g3
+as3 = sharp a3
+bs3 = sharp b3
+
+-- | Octave 4.
+cb4, c4, cs4, db4, d4, ds4, eb4, e4, es4, fb4, f4, fs4, gb4, g4, gs4, ab4, a4, as4, bb4, b4, bs4 :: Pitch
+c4 = Pitch C 4 Nothing
+d4 = Pitch D 4 Nothing
+e4 = Pitch E 4 Nothing
+f4 = Pitch F 4 Nothing
+g4 = Pitch G 4 Nothing
+a4 = Pitch A 4 Nothing
+b4 = Pitch B 4 Nothing
+cb4 = flat c4
+db4 = flat d4
+eb4 = flat e4
+fb4 = flat f4
+gb4 = flat g4
+ab4 = flat a4
+bb4 = flat b4
+cs4 = sharp c4
+ds4 = sharp d4
+es4 = sharp e4
+fs4 = sharp f4
+gs4 = sharp g4
+as4 = sharp a4
+bs4 = sharp b4
+
+-- | Octave 5.
+cb5, c5, cs5, db5, d5, ds5, eb5, e5, es5, fb5, f5, fs5, gb5, g5, gs5, ab5, a5, as5, bb5, b5, bs5 :: Pitch
+c5 = Pitch C 5 Nothing
+d5 = Pitch D 5 Nothing
+e5 = Pitch E 5 Nothing
+f5 = Pitch F 5 Nothing
+g5 = Pitch G 5 Nothing
+a5 = Pitch A 5 Nothing
+b5 = Pitch B 5 Nothing
+cb5 = flat c5
+db5 = flat d5
+eb5 = flat e5
+fb5 = flat f5
+gb5 = flat g5
+ab5 = flat a5
+bb5 = flat b5
+cs5 = sharp c5
+ds5 = sharp d5
+es5 = sharp e5
+fs5 = sharp f5
+gs5 = sharp g5
+as5 = sharp a5
+bs5 = sharp b5
+
+-- | Octave 6.
+cb6, c6, cs6, db6, d6, ds6, eb6, e6, es6, fb6, f6, fs6, gb6, g6, gs6, ab6, a6, as6, bb6, b6, bs6 :: Pitch
+c6 = Pitch C 6 Nothing
+d6 = Pitch D 6 Nothing
+e6 = Pitch E 6 Nothing
+f6 = Pitch F 6 Nothing
+g6 = Pitch G 6 Nothing
+a6 = Pitch A 6 Nothing
+b6 = Pitch B 6 Nothing
+cb6 = flat c6
+db6 = flat d6
+eb6 = flat e6
+fb6 = flat f6
+gb6 = flat g6
+ab6 = flat a6
+bb6 = flat b6
+cs6 = sharp c6
+ds6 = sharp d6
+es6 = sharp e6
+fs6 = sharp f6
+gs6 = sharp g6
+as6 = sharp a6
+bs6 = sharp b6
+
+-- | Octave 7.
+cb7, c7, cs7, db7, d7, ds7, eb7, e7, es7, fb7, f7, fs7, gb7, g7, gs7, ab7, a7, as7, bb7, b7, bs7 :: Pitch
+c7 = Pitch C 7 Nothing
+d7 = Pitch D 7 Nothing
+e7 = Pitch E 7 Nothing
+f7 = Pitch F 7 Nothing
+g7 = Pitch G 7 Nothing
+a7 = Pitch A 7 Nothing
+b7 = Pitch B 7 Nothing
+cb7 = flat c7
+db7 = flat d7
+eb7 = flat e7
+fb7 = flat f7
+gb7 = flat g7
+ab7 = flat a7
+bb7 = flat b7
+cs7 = sharp c7
+ds7 = sharp d7
+es7 = sharp e7
+fs7 = sharp f7
+gs7 = sharp g7
+as7 = sharp a7
+bs7 = sharp b7
+
+-- | Octave 8.
+cb8, c8, cs8, db8, d8, ds8, eb8, e8, es8, fb8, f8, fs8, gb8, g8, gs8, ab8, a8, as8, bb8, b8, bs8 :: Pitch
+c8 = Pitch C 8 Nothing
+d8 = Pitch D 8 Nothing
+e8 = Pitch E 8 Nothing
+f8 = Pitch F 8 Nothing
+g8 = Pitch G 8 Nothing
+a8 = Pitch A 8 Nothing
+b8 = Pitch B 8 Nothing
+cb8 = flat c8
+db8 = flat d8
+eb8 = flat e8
+fb8 = flat f8
+gb8 = flat g8
+ab8 = flat a8
+bb8 = flat b8
+cs8 = sharp c8
+ds8 = sharp d8
+es8 = sharp e8
+fs8 = sharp f8
+gs8 = sharp g8
+as8 = sharp a8
+bs8 = sharp b8
diff --git a/src/Herald/Sound.hs b/src/Herald/Sound.hs
new file mode 100644
--- /dev/null
+++ b/src/Herald/Sound.hs
@@ -0,0 +1,141 @@
+-- | Mono signals sampled at 'sampleRate', and stereo.
+module Herald.Sound
+  ( Seconds,
+    Hz,
+    Sample,
+    Sound,
+    sampleRate,
+    sampleCount,
+    silence,
+    sine,
+    pluck,
+    harmonics,
+    inharmonics,
+    gain,
+    mixSound,
+    balanceSound,
+    Pan,
+    Stereo (..),
+    panSound,
+    balanceStereo,
+  )
+where
+
+import Control.Monad (forM_)
+import Data.Vector.Unboxed (Vector)
+import Data.Vector.Unboxed qualified as V
+import Data.Vector.Unboxed.Mutable qualified as MV
+
+-- | A duration in seconds.
+type Seconds = Double
+
+-- | A frequency in hertz.
+type Hz = Double
+
+-- | An audio sample.
+type Sample = Double
+
+-- | A mono signal.
+type Sound = Vector Sample
+
+-- | Samples per second.
+sampleRate :: Double
+sampleRate = 48000.0
+
+-- | Samples in a duration.
+sampleCount :: Seconds -> Int
+sampleCount t = round (sampleRate * t)
+
+-- | Silence.
+silence :: Seconds -> Sound
+silence t = V.replicate (sampleCount t) 0
+
+-- | A sine wave.
+sine :: Hz -> Seconds -> Sound
+sine f t = V.generate (sampleCount t) (\i -> sin (fromIntegral i * step))
+  where
+    step = (f * 2 * pi) / sampleRate
+
+-- | Deterministic white noise in @[-1, 1)@ from a linear congruential
+-- generator.
+noise :: Int -> [Sample]
+noise = map unit . drop 1 . iterate step
+  where
+    step x = (1103515245 * x + 12345) `mod` 2147483648
+    unit x = fromIntegral x / 1073741824 - 1
+
+-- | Karplus-Strong plucked string. @decay@ is the gain per period.
+pluck :: Double -> Hz -> Seconds -> Sound
+pluck decay f t = V.create $ do
+  out <- MV.new len
+  forM_ (zip [0 .. len - 1] excitation) (uncurry (MV.write out))
+  let go j averaged' allpassed'
+        | n + j >= len = pure ()
+        | otherwise = do
+            x <- MV.read out j
+            y <- MV.read out (j + 1)
+            let averaged = decay * (x + y) / 2
+                allpassed = c * averaged + averaged' - c * allpassed'
+            MV.write out (n + j) allpassed
+            go (j + 1) averaged allpassed
+  go 0 0 0
+  pure out
+  where
+    len = sampleCount t
+    -- The loop is n samples of delay line, minus half a sample from the
+    -- averaging filter, plus a fractional delay d from a first-order all-pass
+    -- (Jaffe & Smith), so that its total length is exactly one period.
+    period = sampleRate / f
+    n = max 2 (floor (period + 0.4))
+    d = period + 0.5 - fromIntegral n
+    c = (1 - d) / (1 + d)
+    burst = take n (noise (round f))
+    mean = sum burst / fromIntegral n
+    peak = maximum [abs (x - mean) | x <- burst]
+    excitation = [(x - mean) / peak | x <- burst]
+
+-- | Integer multiples of a frequency.
+harmonics :: Hz -> [Hz]
+harmonics f = (* f) <$> [1 ..]
+
+-- | Harmonics stretched as in stiff strings: @n f sqrt (1 + b n^2)@.
+inharmonics :: Double -> Hz -> [Hz]
+inharmonics b f = zipWith stretch [1 ..] (harmonics f)
+  where
+    stretch n h = h * sqrt (1 + b * n ** 2)
+
+-- | Scale every sample.
+gain :: Double -> Sound -> Sound
+gain g = V.map (* g)
+
+-- | Sums sample by sample, padding shorter sounds with silence.
+mixSound :: [Sound] -> Sound
+mixSound xs = foldl add (V.replicate len 0) xs
+  where
+    len = maximum (0 : map V.length xs)
+    add acc s = V.imap (\i a -> a + if i < V.length s then V.unsafeIndex s i else 0) acc
+
+-- | Mixes dividing by the square root of the count, to keep the loudness of
+-- a single sound.
+balanceSound :: [Sound] -> Sound
+balanceSound xs = gain (1 / sqrt (fromIntegral (max 1 (length xs)))) (mixSound xs)
+
+-- | From -1 (left) to 1 (right).
+type Pan = Double
+
+-- | A left and a right channel.
+data Stereo = Stereo
+  { leftChannel :: Sound,
+    rightChannel :: Sound
+  }
+  deriving (Show, Eq)
+
+-- | Constant-power pan law: equally loud at any position.
+panSound :: Pan -> Sound -> Stereo
+panSound p s = Stereo (gain (cos theta) s) (gain (sin theta) s)
+  where
+    theta = (max (-1) (min 1 p) + 1) * pi / 4
+
+-- | 'balanceSound' for each channel.
+balanceStereo :: [Stereo] -> Stereo
+balanceStereo xs = Stereo (balanceSound (map leftChannel xs)) (balanceSound (map rightChannel xs))
diff --git a/src/Herald/Sound/IO.hs b/src/Herald/Sound/IO.hs
new file mode 100644
--- /dev/null
+++ b/src/Herald/Sound/IO.hs
@@ -0,0 +1,80 @@
+-- | 16-bit PCM WAV output, and playback through @ffplay@.
+module Herald.Sound.IO
+  ( Audio (..),
+    encodeWav,
+    saveWav,
+    play,
+  )
+where
+
+import Control.Exception
+import Control.Monad
+import Data.ByteString.Builder qualified as BB
+import Data.ByteString.Lazy qualified as B
+import Data.Int
+import Data.Vector.Unboxed qualified as V
+import Data.Word
+import GHC.IO.Exception (IOErrorType (..), IOException (..))
+import Herald.Sound
+import System.Exit
+import System.IO (hClose)
+import System.Process
+
+-- | Mono or stereo audio.
+class Audio a where
+  channels :: a -> [Sound]
+
+instance Audio (V.Vector Double) where
+  channels s = [s]
+
+instance Audio Stereo where
+  channels s = [leftChannel s, rightChannel s]
+
+-- | Samples outside @[-1, 1]@ are clipped.
+encodeWav :: (Audio a) => a -> B.ByteString
+encodeWav audio = BB.toLazyByteString (header <> samples)
+  where
+    chs = channels audio
+    n = length chs
+    frames = maximum (0 : map V.length chs)
+    sample s i = if i < V.length s then V.unsafeIndex s i else 0
+    samples = foldr (\i b -> foldr (\s b' -> BB.int16LE (pcm (sample s i)) <> b') b chs) mempty [0 .. frames - 1]
+    rate = round sampleRate :: Word32
+    blockAlign = fromIntegral (2 * n) :: Word16
+    dataSize = fromIntegral (2 * n * frames) :: Word32
+    header =
+      mconcat
+        [ BB.string7 "RIFF",
+          BB.word32LE (36 + dataSize),
+          BB.string7 "WAVE",
+          BB.string7 "fmt ",
+          BB.word32LE 16, -- fmt chunk size
+          BB.word16LE 1, -- PCM
+          BB.word16LE (fromIntegral n), -- channels
+          BB.word32LE rate,
+          BB.word32LE (rate * fromIntegral blockAlign), -- byte rate
+          BB.word16LE blockAlign,
+          BB.word16LE 16, -- bits per sample
+          BB.string7 "data",
+          BB.word32LE dataSize
+        ]
+    pcm x = round (32767 * max (-1) (min 1 x)) :: Int16
+
+-- | Write a WAV file.
+saveWav :: (Audio a) => FilePath -> a -> IO ()
+saveWav filePath = B.writeFile filePath . encodeWav
+
+-- | Requires @ffplay@ on the @PATH@.
+play :: (Audio a) => a -> IO ()
+play audio = do
+  let args = ["-autoexit", "-showmode", "1", "-"]
+  withCreateProcess (proc "ffplay" args) {std_in = CreatePipe} $ \stdin' _ _ ph -> do
+    -- ffplay closes the pipe early if the user quits; that's not an error.
+    forM_ stdin' $ \h -> ignoreBrokenPipe $ B.hPut h (encodeWav audio) >> hClose h
+    code <- waitForProcess ph
+    case code of
+      ExitSuccess -> pure ()
+      ExitFailure c -> ioError . userError $ "ffplay exited with code " ++ show c
+  where
+    ignoreBrokenPipe = handle $ \e ->
+      unless (ioe_type e == ResourceVanished) (throwIO e)
diff --git a/src/Herald/Tuning.hs b/src/Herald/Tuning.hs
new file mode 100644
--- /dev/null
+++ b/src/Herald/Tuning.hs
@@ -0,0 +1,44 @@
+-- | Frequencies of pitches: equal temperament and historical temperaments.
+module Herald.Tuning
+  ( Tuning (..),
+    a440,
+    equalTemperament,
+    temperament,
+    werckmeisterIII,
+    quarterCommaMeantone,
+  )
+where
+
+import Data.Map.Strict qualified as Map
+import Herald.Score.Pitch (Semitones)
+import Herald.Sound (Hz)
+
+-- | Semitones are relative to A4.
+newtype Tuning = Tuning {tune :: Semitones -> Hz}
+
+-- | Equal temperament at 440 Hz.
+a440 :: Tuning
+a440 = equalTemperament 440
+
+-- | Takes the frequency of A4.
+equalTemperament :: Hz -> Tuning
+equalTemperament ref = Tuning $ \n -> ref * 2 ** (fromIntegral n / 12)
+
+-- | Deviations from equal temperament in cents for each pitch class from C
+-- to B, anchored at the frequency of A4.
+temperament :: [Double] -> Hz -> Tuning
+temperament cents ref = Tuning $ \n ->
+  ref * 2 ** ((fromIntegral n + (offset (n + 9) - offset 9) / 100) / 12)
+  where
+    deviations = Map.fromList (zip [0 .. 11] cents)
+    offset n = Map.findWithDefault 0 (n `mod` 12) deviations
+
+-- | Werckmeister III well temperament.
+werckmeisterIII :: Hz -> Tuning
+werckmeisterIII =
+  temperament [0, -9.775, -7.82, -5.865, -9.775, -1.955, -11.73, -3.91, -7.82, -11.73, -3.91, -7.82]
+
+-- | Quarter-comma meantone.
+quarterCommaMeantone :: Hz -> Tuning
+quarterCommaMeantone =
+  temperament [0, -24.0, -6.8, 10.3, -13.7, 3.4, -20.5, -3.4, -27.4, -10.3, 6.8, -17.1]
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,246 @@
+module Main (main) where
+
+import Data.ByteString.Lazy qualified as B
+import Data.ByteString.Lazy.Char8 qualified as BC
+import Data.Vector.Unboxed qualified as V
+import Herald
+import Test.Tasty
+import Test.Tasty.HUnit
+
+main :: IO ()
+main =
+  defaultMain $
+    testGroup
+      "herald"
+      [ pitchTests,
+        tuningTests,
+        soundTests,
+        instrumentTests,
+        scoreTests,
+        tieTests,
+        meterTests,
+        stereoTests,
+        wavTests
+      ]
+
+pitchTests :: TestTree
+pitchTests =
+  testGroup
+    "Pitch"
+    [ testCase "A4 is the reference" $ pitchSemitones cMajor a4 @?= 0,
+      testCase "middle C is 9 semitones below A4" $ pitchSemitones cMajor c4 @?= -9,
+      testCase "octaves are 12 semitones apart" $ pitchSemitones cMajor a5 @?= 12,
+      testCase "names carry accidentals" $ do
+        pitchSemitones cMajor fs4 @?= pitchSemitones cMajor (sharp f4)
+        pitchSemitones cMajor bb4 @?= pitchSemitones cMajor a4 + 1,
+      testCase "unwritten accidentals come from the key" $
+        pitchSemitones gMinor b4 @?= pitchSemitones cMajor bb4,
+      testCase "a natural sign cancels the key" $
+        pitchSemitones gMinor (natural b4) @?= pitchSemitones cMajor b4,
+      testCase "key signatures add accidentals in order" $ do
+        map (keyAccidental (sharps 2)) [F, C, G] @?= [Sharp, Sharp, Natural]
+        map (keyAccidental (flats 2)) [B, E, A] @?= [Flat, Flat, Natural]
+    ]
+
+tuningTests :: TestTree
+tuningTests =
+  testGroup
+    "Tuning"
+    [ testCase "A4 is 440 Hz" $ tune a440 0 @?= 440,
+      testCase "an octave doubles the frequency" $ tune a440 12 @?= 880,
+      testCase "equal temperament takes a reference pitch" $ tune (equalTemperament 415) 0 @?= 415,
+      testCase "temperaments are anchored at A4" $ do
+        tune (werckmeisterIII 440) 0 @?= 440
+        tune (quarterCommaMeantone 440) 0 @?= 440,
+      testCase "Werckmeister III raises C4 by its offset from A" $
+        assertApprox "cents" 11.73 (cents (tune (werckmeisterIII 440) (-9)) (tune a440 (-9))),
+      testCase "quarter-comma meantone has a pure major third on C" $
+        assertApprox "cents" (cents 5 4) (cents (tune meantone (-5)) (tune meantone (-9))),
+      testCase "the score uses the configured tuning" $
+        playScoreMono defaultConfig {tuning = equalTemperament 220} [(defaultVoice, tellNote quarter a5)]
+          @?= Right (render (tellNote quarter a4))
+    ]
+  where
+    meantone = quarterCommaMeantone 440
+    cents x y = 1200 * logBase 2 (x / y)
+    assertApprox msg expected actual =
+      assertBool (msg ++ ": expected " ++ show expected ++ ", got " ++ show actual) (abs (expected - actual) < 0.1)
+
+soundTests :: TestTree
+soundTests =
+  testGroup
+    "Sound"
+    [ testCase "durations round to whole samples" $ do
+        V.length (silence 1) @?= 48000
+        V.length (sine 440 0.5) @?= 24000,
+      testCase "mixing pads shorter sounds with silence" $
+        mixSound [V.fromList [1, 2, 3], V.fromList [10]] @?= V.fromList [11, 2, 3],
+      testCase "balanced mixing divides by the square root of the count" $
+        balanceSound [V.fromList [1], V.fromList [1], V.fromList [1], V.fromList [1]] @?= V.fromList [2],
+      testCase "a pluck starts at a peak of 1 and decays" $ do
+        let s = pluck 0.996 220 1
+        V.length s @?= 48000
+        peak s @?= 1
+        assertBool "doesn't decay" (peak (V.drop 40000 s) < 0.5)
+    ]
+
+instrumentTests :: TestTree
+instrumentTests =
+  testGroup "Instrument" . concat $
+    [ [ testCase ("notes last exactly their duration: " ++ name) $
+          V.length (renderNote i 440 0.3) @?= 14400
+      | (name, i) <- instruments
+      ],
+      [ testCase ("notes peak at most 1: " ++ name ++ " at " ++ show f ++ " Hz") $
+          assertBool "too loud" (peak (renderNote i f 0.5) <= 1)
+      | (name, i) <- instruments,
+        f <- [110, 440, 1760]
+      ],
+      [ testCase "a trill fills its duration" $
+          V.length (trill additive 6 440 494 0.75) @?= 36000,
+        testCase "a chord is as long as its notes" $
+          V.length (chord organ [262, 330, 392] 0.5) @?= 24000
+      ]
+    ]
+  where
+    instruments = [("pure tone", pureTone), ("additive", additive), ("organ", organ), ("harpsichord", harpsichord)]
+
+scoreTests :: TestTree
+scoreTests =
+  testGroup
+    "Score"
+    [ testCase "a quarter note at 120 bpm lasts half a second" $
+        V.length (render (tellNotes quarter [c4, d4])) @?= 48000,
+      testCase "rests and notes share the timeline" $
+        V.length (render (tellRest half >> tellNote eighth c4)) @?= 60000,
+      testCase "the tempo comes from the configuration" $
+        V.length <$> playScoreMono defaultConfig {tempo = 60} [(defaultVoice, tellNote quarter c4)] @?= Right 48000,
+      testCase "voices start in C major and setKey applies from then on" $
+        render (tellNote quarter b4 >> setKey gMinor >> tellNote quarter b4)
+          @?= render (tellNotes quarter [natural b4, bb4]),
+      testCase "an accidental lasts until the end of the bar" $
+        render (tellNote quarter (sharp f4) >> tellNote quarter f4)
+          @?= render (tellNotes quarter [fs4, fs4]),
+      testCase "a bar line cancels earlier accidentals" $
+        render (tellNote quarter (sharp f4) >> tellBar >> tellNote quarter f4)
+          @?= render (tellNotes quarter [fs4, natural f4]),
+      testCase "a trill's auxiliary accidental doesn't carry on" $
+        render (tellTrill quarter 4 e4 (sharp f4) >> tellNote quarter f4)
+          @?= render (tellTrill quarter 4 e4 fs4 >> tellNote quarter (natural f4)),
+      testCase "voices are rendered independently" $
+        playScoreMono defaultConfig [(defaultVoice, tellNote quarter (sharp f4)), (defaultVoice, tellNote quarter f4)]
+          @?= playScoreMono defaultConfig [(defaultVoice, tellNote quarter fs4), (defaultVoice, tellNote quarter (natural f4))],
+      testCase "each voice plays its own instrument" $
+        playScoreMono defaultConfig [(defaultVoice {instrument = organ}, tellNote quarter c4), (defaultVoice, tellNote quarter c4)]
+          @?= Right (balanceSound [renderNote organ (tune a440 (-9)) 0.5, renderNote additive (tune a440 (-9)) 0.5]),
+      testCase "dynamics scale notes from then on" $
+        render (setDynamic forte >> tellNote quarter c4)
+          @?= gain (dynamicGain forte) (render (tellNote quarter c4)),
+      testCase "dynamics go from soft to loud"
+        $ assertBool "out of order"
+        $ and (zipWith (<) dynamics (drop 1 dynamics))
+    ]
+  where
+    dynamics = [pianissimo, piano, mezzoPiano, mezzoForte, forte, fortissimo]
+
+tieTests :: TestTree
+tieTests =
+  testGroup
+    "Tie"
+    [ testCase "tied notes sound as one" $
+        render (tellTie quarter c4 >> tellNote quarter c4) @?= render (tellNote half c4),
+      testCase "ties can be chained" $
+        render (tellTie eighth c4 >> tellTie eighth c4 >> tellNote quarter c4)
+          @?= render (tellNote half c4),
+      testCase "a tied note keeps its accidental across the bar line" $
+        render (tellTie quarter (sharp f4) >> tellBar >> tellNote quarter f4)
+          @?= render (tellNote half fs4),
+      testCase "a tie into a different pitch is an error" $
+        result [tellTie quarter c4 >> tellNote quarter d4]
+          @?= Left (ScoreError 1 1 TieToDifferentPitch),
+      testCase "a tie followed by a rest is an error" $
+        result [tellTie quarter c4 >> tellBar >> tellRest quarter]
+          @?= Left (ScoreError 1 2 UnfinishedTie),
+      testCase "a voice can't end in a tie" $
+        result [tellTie quarter c4] @?= Left (ScoreError 1 1 UnfinishedTie)
+    ]
+
+meterTests :: TestTree
+meterTests =
+  testGroup
+    "Meter"
+    [ testCase "a bar lasts its time signature in beats" $ do
+        meterBeats (Meter 4 4) @?= 4
+        meterBeats (Meter 6 8) @?= 3,
+      testCase "bars that add up are accepted" $
+        result [setMeter (Meter 3 4) >> tellNotes quarter [c4, d4, e4] >> tellBar >> tellNote (dotted half) c4]
+          @?= Right (),
+      testCase "a short bar is an error" $
+        result [setMeter (Meter 4 4) >> tellNotes quarter [c4, d4, e4] >> tellBar]
+          @?= Left (ScoreError 1 1 (WrongBarLength 3 4)),
+      testCase "the last bar is checked too" $
+        result [setMeter (Meter 2 4) >> tellNotes quarter [c4, d4] >> tellBar >> tellNote quarter c4]
+          @?= Left (ScoreError 1 2 (WrongBarLength 1 2)),
+      testCase "a tie counts in each bar it crosses" $
+        result [setMeter (Meter 2 4) >> tellNote quarter c4 >> tellTie quarter d4 >> tellBar >> tellNotes quarter [d4, e4]]
+          @?= Right (),
+      testCase "errors name the voice" $
+        result [tellNote quarter c4, setMeter (Meter 2 4) >> tellNote quarter c4]
+          @?= Left (ScoreError 2 1 (WrongBarLength 1 2))
+    ]
+
+stereoTests :: TestTree
+stereoTests =
+  testGroup
+    "Stereo"
+    [ testCase "the centre splits a sound at equal power" $ do
+        let Stereo l r = panSound 0 (V.fromList [1])
+        assertClose l (V.fromList [sqrt 0.5])
+        assertClose r (V.fromList [sqrt 0.5]),
+      testCase "hard left and right use a single channel" $ do
+        assertClose (rightChannel (panSound (-1) (V.fromList [1]))) (V.fromList [0])
+        assertClose (leftChannel (panSound 1 (V.fromList [1]))) (V.fromList [0]),
+      testCase "voices go to their side" $ do
+        let left = tellNote quarter c4
+            right = tellNote quarter e4
+        case playScoreStereo defaultConfig [(defaultVoice {pan = -1}, left), (defaultVoice {pan = 1}, right)] of
+          Left err -> assertFailure (displayScoreError err)
+          Right (Stereo l r) -> do
+            assertClose l (gain (1 / sqrt 2) (render left))
+            assertClose r (gain (1 / sqrt 2) (render right))
+    ]
+  where
+    assertClose x y = do
+      V.length x @?= V.length y
+      assertBool "channels differ" (V.and (V.zipWith (\a b -> abs (a - b) < 1e-12) x y))
+
+wavTests :: TestTree
+wavTests =
+  testGroup
+    "WAV"
+    [ testCase "header and 16-bit samples" $ do
+        let wav = encodeWav (mono [0, 1, -1])
+        B.length wav @?= 44 + 6
+        BC.take 4 wav @?= BC.pack "RIFF"
+        BC.take 4 (B.drop 8 wav) @?= BC.pack "WAVE"
+        B.unpack (B.drop 44 wav) @?= [0, 0, 0xff, 0x7f, 0x01, 0x80],
+      testCase "out-of-range samples are clipped" $
+        B.drop 44 (encodeWav (mono [2, -2])) @?= B.drop 44 (encodeWav (mono [1, -1])),
+      testCase "stereo interleaves left and right" $ do
+        let wav = encodeWav (Stereo (V.fromList [1, 0]) (V.fromList [-1]))
+        B.length wav @?= 44 + 8
+        B.unpack (B.take 2 (B.drop 22 wav)) @?= [2, 0]
+        B.unpack (B.drop 44 wav) @?= [0xff, 0x7f, 0x01, 0x80, 0, 0, 0, 0]
+    ]
+
+render :: Performance () -> Sound
+render v = either (error . displayScoreError) id (playScoreMono defaultConfig [(defaultVoice, v)])
+
+result :: [Performance ()] -> Either ScoreError ()
+result vs = () <$ playScoreMono defaultConfig [(defaultVoice, v) | v <- vs]
+
+peak :: Sound -> Double
+peak = V.maximum . V.map abs
+
+mono :: [Double] -> Sound
+mono = V.fromList
