diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..cace6bb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.x86_64-pc-windows-gnu] +rustflags = [ "-C", "link-arg=-lssp" ] + +[target.i686-pc-windows-gnu] +rustflags = [ "-C", "link-arg=-lssp" ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c45f3c2..329b964 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,8 @@ jobs: with: name: zoog-debug-${{ matrix.target }} path: | - target/${{ matrix.target }}/debug/zoog - target/${{ matrix.target }}/debug/zoog.exe + target/${{ matrix.target }}/debug/opusgain + target/${{ matrix.target }}/debug/opusgain.exe if-no-files-found: error cargo-test: @@ -73,6 +73,6 @@ jobs: toolchain: nightly override: true - name: Run cargo bloat - uses: orf/cargo-bloat-action@v1 + uses: Kobzol/cargo-bloat-action@ee38cc711cd045e30be53db2660dbcbb88f35e58 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3754264..65a87c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.0 + +* `zoog` binary is deprecated and removed from the repository. +* `opusgain` binary is added which can compute the loudness of Opus files + directly in order to adjust the output gain and generate tags. + ## 0.1.4 * Strip debug info from release binaries (requires Rust nightly). diff --git a/Cargo.lock b/Cargo.lock index 2e15236..6ab616b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,22 +1,47 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 3 + +[[package]] +name = "audiopus_sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" +dependencies = [ + "cmake", + "log", + "pkg-config", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + [[package]] name = "bitflags" -version = "1.2.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bs1770" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6332c61c205ff066246fff2cb876cdf9009557200f96e15d88d156193b16775b" [[package]] name = "byteorder" -version = "1.3.4" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] -name = "cfg-if" -version = "0.1.10" +name = "cc" +version = "1.0.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" [[package]] name = "cfg-if" @@ -26,21 +51,55 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "2.33.3" +version = "3.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +checksum = "86447ad904c7fb335a790c9d7fe3d0d971dc523b8ccd1561a520de9a85302750" dependencies = [ "bitflags", - "term_size", + "clap_derive", + "clap_lex", + "indexmap", + "once_cell", + "terminal_size", "textwrap", - "unicode-width", +] + +[[package]] +name = "clap_derive" +version = "3.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0c8bce528c4be4da13ea6fead8965e95b6073585a2f05204bd8f4119f82a65" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "cmake" +version = "0.1.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8ad8cef104ac57b68b89df3208164d228503abbdce70f6880ffa3d970e7443a" +dependencies = [ + "cc", ] [[package]] name = "derivative" -version = "2.1.3" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaed5874effa6cde088c644ddcdcb4ffd1511391c5be4fdd7a5ccd02c7e4a183" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" dependencies = [ "proc-macro2", "quote", @@ -48,152 +107,227 @@ dependencies = [ ] [[package]] -name = "getrandom" -version = "0.1.16" +name = "errno" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" dependencies = [ - "cfg-if 1.0.0", + "errno-dragonfly", "libc", - "wasi 0.9.0+wasi-snapshot-preview1", + "winapi", ] [[package]] -name = "getrandom" -version = "0.2.1" +name = "errno-dragonfly" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4060f4657be78b8e766215b02b18a2e862d83745545de804638e2b545e81aee6" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" dependencies = [ - "cfg-if 1.0.0", + "cc", "libc", - "wasi 0.10.0+wasi-snapshot-preview1", ] [[package]] -name = "libc" -version = "0.2.81" +name = "fastrand" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1482821306169ec4d07f6aca392a4681f66c75c9918aa49641a2595db64053cb" +checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" +dependencies = [ + "instant", +] [[package]] -name = "ogg" -version = "0.7.1" +name = "getrandom" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13e571c3517af9e1729d4c63571a27edd660ade0667973bfc74a67c660c2b651" +checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" dependencies = [ - "byteorder", + "cfg-if", + "libc", + "wasi", ] [[package]] -name = "ppv-lite86" -version = "0.2.10" +name = "hashbrown" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] -name = "proc-macro2" -version = "1.0.24" +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + +[[package]] +name = "indexmap" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" +checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" dependencies = [ - "unicode-xid", + "autocfg", + "hashbrown", ] [[package]] -name = "quote" -version = "1.0.8" +name = "instant" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "991431c3519a3f36861882da93630ce66b52918dcf1b8e2fd66b397fc96f28df" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" dependencies = [ - "proc-macro2", + "cfg-if", ] [[package]] -name = "rand" +name = "io-lifetimes" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +checksum = "1ea37f355c05dde75b84bba2d767906ad522e97cd9e2eef2be7a4ab7fb442c06" + +[[package]] +name = "libc" +version = "0.2.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f80d65747a3e43d1596c7c5492d95d5edddaabd45a7fcdb02b95f644164966" + +[[package]] +name = "linux-raw-sys" +version = "0.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d2456c373231a208ad294c33dc5bff30051eafd954cd4caae83a712b12854d" + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc 0.2.0", + "cfg-if", ] [[package]] -name = "rand" -version = "0.8.1" +name = "ogg" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c24fcd450d3fa2b592732565aa4f17a27a61c65ece4726353e000939b0edee34" +checksum = "13e571c3517af9e1729d4c63571a27edd660ade0667973bfc74a67c660c2b651" dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e82dad04139b71a90c080c8463fe0dc7902db5192d939bd0950f074d014339e1" + +[[package]] +name = "opus" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6526409b274a7e98e55ff59d96aafd38e6cd34d46b7dbbc32ce126dffcd75e8e" +dependencies = [ + "audiopus_sys", "libc", - "rand_chacha 0.3.0", - "rand_core 0.6.1", - "rand_hc 0.3.0", ] [[package]] -name = "rand_chacha" -version = "0.2.2" +name = "os_str_bytes" +version = "6.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff" + +[[package]] +name = "pkg-config" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" + +[[package]] +name = "ppv-lite86" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", ] [[package]] -name = "rand_chacha" -version = "0.3.0" +name = "proc-macro-error-attr" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" dependencies = [ - "ppv-lite86", - "rand_core 0.6.1", + "proc-macro2", + "quote", + "version_check", ] [[package]] -name = "rand_core" -version = "0.5.1" +name = "proc-macro2" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" dependencies = [ - "getrandom 0.1.16", + "unicode-ident", ] [[package]] -name = "rand_core" -version = "0.6.1" +name = "quote" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c026d7df8b298d90ccbbc5190bd04d85e159eaf5576caeacf8741da93ccbd2e5" +checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" dependencies = [ - "getrandom 0.2.1", + "proc-macro2", ] [[package]] -name = "rand_hc" -version = "0.2.0" +name = "rand" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "rand_core 0.5.1", + "libc", + "rand_chacha", + "rand_core", ] [[package]] -name = "rand_hc" -version = "0.3.0" +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "rand_core 0.6.1", + "getrandom", ] [[package]] name = "redox_syscall" -version = "0.1.57" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags", +] [[package]] name = "remove_dir_all" @@ -204,65 +338,78 @@ dependencies = [ "winapi", ] +[[package]] +name = "rustix" +version = "0.35.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af895b90e5c071badc3136fc10ff0bcfc98747eadbaf43ed8f214e07ba8f8477" +dependencies = [ + "bitflags", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "syn" -version = "1.0.57" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4211ce9909eb971f111059df92c45640aad50a619cf55cd76476be803c4c68e6" +checksum = "52205623b1b0f064a4e71182c3b18ae902267282930c6d5462c91b859668426e" dependencies = [ "proc-macro2", "quote", - "unicode-xid", + "unicode-ident", ] [[package]] name = "tempfile" -version = "3.1.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a6e24d9338a0a5be79593e2fa15a648add6138caa803e2d5bc782c371732ca9" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" dependencies = [ - "cfg-if 0.1.10", + "cfg-if", + "fastrand", "libc", - "rand 0.7.3", "redox_syscall", "remove_dir_all", "winapi", ] [[package]] -name = "term_size" -version = "0.3.2" +name = "terminal_size" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4129646ca0ed8f45d09b929036bafad5377103edd06e50bf574b353d2b08d9" +checksum = "8440c860cf79def6164e4a0a983bcc2305d82419177a0e0c71930d049e3ac5a1" dependencies = [ - "libc", - "winapi", + "rustix", + "windows-sys", ] [[package]] name = "textwrap" -version = "0.11.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +checksum = "949517c0cf1bf4ee812e2e07e08ab448e3ae0d23472aee8a06c985f0c8815b16" dependencies = [ - "term_size", - "unicode-width", + "terminal_size", ] [[package]] name = "thiserror" -version = "1.0.23" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76cc616c6abf8c8928e2fdcc0dbfab37175edd8fb49a4641066ad1364fdab146" +checksum = "c53f98874615aea268107765aa1ed8f6116782501d18e53d08b471733bea6c85" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.23" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9be73a2caec27583d0046ef3796c3794f868a5bc813db689eed00c7631275cd1" +checksum = "f8b463991b4eab2d801e724172285ec4195c650e8ec79b149e6c2a8e6dd3f783" dependencies = [ "proc-macro2", "quote", @@ -270,28 +417,22 @@ dependencies = [ ] [[package]] -name = "unicode-width" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" - -[[package]] -name = "unicode-xid" -version = "0.2.1" +name = "unicode-ident" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" +checksum = "dcc811dc4066ac62f84f11307873c4850cb653bfa9b1719cee2bd2204a4bc5dd" [[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" +name = "version_check" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" +version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "winapi" @@ -315,15 +456,61 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + [[package]] name = "zoog" -version = "0.1.4" +version = "0.2.0" dependencies = [ + "audiopus_sys", + "bs1770", "byteorder", "clap", "derivative", "ogg", - "rand 0.8.1", + "opus", + "rand", "tempfile", "thiserror", ] diff --git a/Cargo.toml b/Cargo.toml index 3db66fc..b7d7157 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,6 @@ -cargo-features = ["strip"] - [package] name = "zoog" -version = "0.1.4" +version = "0.2.0" authors = ["Francis Russell "] edition = "2018" homepage = "https://github.com/FrancisRussell/zoog" @@ -13,16 +11,19 @@ keywords = ["opus", "normalization"] description = "Modifies Opus output gain values and R128 tags" [dependencies] +audiopus_sys = { version = "0.2.2", features = ["static"] } +bs1770 = "1.0.0" byteorder = "1.3.4" derivative = "2.1.1" ogg = "0.7.1" +opus = "0.3.0" tempfile = "3.1.0" thiserror = "1.0.23" [dependencies.clap] -version = "2.33.3" +version = "3.2.22" default-features = false -features = [ "wrap_help" ] +features = [ "std", "wrap_help", "derive" ] [dev-dependencies.rand] version = "0.8.0" diff --git a/LICENSE b/LICENSE index 46af876..060cf6f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2021, Francis Russell +Copyright (c) 2022, Francis Russell All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md index 6ef61f4..667a8d7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,22 @@ # Zoog: Zero Opus Output Gain -Zoog is a tool for setting the output gain value located in a binary header -inside Opus files (specifically an Opus-encoded audio stream within an Ogg -file). It is intended to solve the "Opus plays too quietly" problem. +Zoog is a Rust library that consists of functionality that can be used +to determine the loudness of an Ogg Opus file and also to rewrite that +file with new internal gain information as well as loudness-related comment +tags. + +Zoog currently contains a single tool, `opusgain` which can be used to: + +* set the output gain value located in the Opus binary header inside Opus files + so that the file plays at the loudness of the original encoded audio, or of + that consistent with the + [ReplayGain](https://en.wikipedia.org/wiki/ReplayGain) or [EBU R + 128](https://en.wikipedia.org/wiki/EBU_R_128) standards. + +* write the Opus comment tags used by some music players to decide +what volume to play an Opus-encoded audio file at. + +It is intended to solve the "Opus plays too quietly" problem. ## Background @@ -11,10 +25,10 @@ gain’](https://tools.ietf.org/html/rfc7845) value which describes a gain to be applied when decoding the audio. This value appears to exist in order to ensure that loudness changes to Opus files are *always* applied, rather than being dependent on decoder support for tags such as `REPLAYGAIN_TRACK_GAIN` and -`REPLAYGAIN_ALBUM_GAIN` (used in Ogg Vorbis, but *not* Opus). +`REPLAYGAIN_ALBUM_GAIN` which are used in Ogg Vorbis, but *not* Opus. The in-header value was intended to correspond to the album gain with -[RFC7845](https://tools.ietf.org/html/rfc7845) defining the tag +[RFC 7845](https://tools.ietf.org/html/rfc7845) defining the tag `R128_TRACK_GAIN` for single-track normalization. It seems the original intent of the output gain was to eliminate the need for an album gain tag, however `R128_ALBUM_GAIN` was later added for album normalization. @@ -22,37 +36,52 @@ of the output gain was to eliminate the need for an album gain tag, however ## The problem When encoding an Opus stream using `opusenc` from a FLAC stream which has -embedded ReplaygGain tags, the resulting Opus stream will have the output-gain -field set in the Opus header. The gain value will be chosen using -[EBU R 128](https://en.wikipedia.org/wiki/EBU_R_128) with a loudness value -of -23 [LUFS](https://en.wikipedia.org/wiki/LKFS) (ReplayGain uses -18 LUFS). - -The presence of either `R128_TRACK_GAIN` or `R128_ALBUM_GAIN` will allow -players that support these to play tracks at the correct volume. However, in +embedded ReplayGain tags, the resulting Opus stream will have the output-gain +field set in the Opus header. The gain value will be chosen using [EBU R +128](https://en.wikipedia.org/wiki/EBU_R_128) with a loudness value of -23 +[LUFS](https://en.wikipedia.org/wiki/LKFS), which is 5 dB quieter than +ReplayGain. + +The presence of either `R128_TRACK_GAIN` or `R128_ALBUM_GAIN` tags will allow +players that support these to play tracks at an appropriate volume. However, in audio players that do not support these tags, track will likely sound extremely -quiet. - -## What Zoog does - -Zoog adjusts both the Opus binary header and the `R128_TRACK_GAIN` and -`R128_ALBUM_GAIN` tags such that files will continue to play at the correct -volume in players that support these tags, and at a more appropriate volume in -players that don't. - -Zoog doesn't have the ability to decode and do loudness analysis of Opus files, -so it depends on the presence of `R128_ALBUM_GAIN` and/or `R128_TRACK_GAIN` -tags. If you do not have these tags, a tool such as -[loudgain](https://github.com/Moonbase59/loudgain) can be used to generate them. +quiet (unless your entire music collection is normalized to -23 LUFS). + +Even more problematically, using `opusenc` with a FLAC file that does not have +embedded ReplayGain tags will produce a file that plays at the original volume +of the source audio. This difference in behaviour means that it's not possible +for players that do not support `R128` tags to assume that different Opus files will +play at a similar volume, despite the presence of the internal gain header. + +Even if a player does support the `R128` tags, this is not enough to correctly +play Opus files at the right volume. In the case described above, `opusenc` +will use the internal gain to apply album normalization, meaning that it does +not generate a `R128_ALBUM_GAIN` tag. Without this, it's not possible for a +music player to play a track at album volume without again assuming that the +internal gain corresponds to an album normalization at -23 LUFS. + +## What `opusgain` does + +`opusgain` adjusts the Opus binary header for playback at a specific volume and +will always generate the `R128_TRACK_GAIN` tag and the `R128_ALBUM_GAIN` tag +(when in album mode) such that files will play at an appropriate volume in +players that support these tags, and at a more appropriate volume in players +that don't. Existing `R128_ALBUM_GAIN` tags will be stripped when not in album +mode. + +`opusgain` (unlike its predecessor `zoog`) decodes Opus audio in order to +determine its volume so that it's possible to be certain that all generated +gain values are correct without making assumptions about their existing values. The following options are available: -* `--preset=none`: In this mode, Zoog will set the output gain in the +* `--preset=original`: In this mode, `opusgain` will set the output gain in the Opus binary header to 0dB. In players that do not support `R128` tags, this will cause the Opus file to play back at the volume of the originally encoded - source. You may want this if you use a player that doesn't support any - sort of volume normalization. + source. You may want this if you prefer volume normalization to only occur via + tags. -* `--preset=rg`: In this mode, Zoog will set the output gain in the Opus binary +* `--preset=rg`: In this mode, `opusgain` will set the output gain in the Opus binary header to the value that ensures playback will occur at -18 LUFS, which should match the loudness of ReplayGain normalized files. This is probably the best option when you have a player that doesn't know about Opus `R128` @@ -63,32 +92,103 @@ The following options are available: [aacgain](http://aacgain.altosdesign.com/) can do this) to the ReplayGain reference volume. - This will use the album normalization value if present, and the track - normalization value if not. - -* `--preset=r128`: In this mode, Zoog will set the output gain in the Opus +* `--preset=r128`: In this mode, `opusgain` will set the output gain in the Opus binary header to the value that ensures playback will occur at -23 LUFS, which should match the loudness of files produced by `opusenc` from FLAC files which contained ReplayGain information. You're unlikely to want this - option as the main use of Zoog is modify files which were generated this way. - This will use the album normalization value if present, and the track - normalization value if not. + option as the main use of `opusgain` is modify files which were generated this way. + +* `--output-gain-mode=auto`: In this mode, `opusgain` will set the output gain + in the Opus binary header such that each track is album-normalized in album + mode, or track-normalized otherwise. In album mode, this results in all + tracks having the same output gain value as well as the same + `R128_ALBUM_GAIN` tag. + +* `--output-gain-mode=track`: In this mode, `opusgain` will set the output gain + in the Opus binary header such that each track is track-normalized, even if + album mode is enabled. In album mode, this results in + all tracks having the same different output gain values as well as different + `R128_ALBUM_GAIN` tags, but their `R128_TRACK_GAIN` tags will be identical. + Unless you know what you're doing, you probably don't want this option. + +* `-a`: Enables album mode. In this mode `R128_ALBUM_GAIN` tags will also be + generated. These tell players that support these tags what gain to apply so + that each track in the album maintains its relative loudness. By default the + output gain value for each file will be set to identical values in order to + apply the calculated album gain, but this behaviour can be overridden using + the `--output-gain-mode` option. + +* `--display-only`: Displays the same output that `opusgain` would otherwise + produce, but does not make any changes to the supplied files. + +If the internal gain and tag values are already correct for the specified files, +`opusgain` will avoid rewriting them. + +## Q & A + +### How is loudness calculated? + +Loudness is calculated using [ITU-R +BS.1770](https://en.wikipedia.org/wiki/LKFS). This is the standard used by [EBU +R 128](https://en.wikipedia.org/wiki/EBU_R_128) for measuring loudness and the +one intended for use when calculating Opus `R128` tags. + +### What happened to the `zoog` program? + +It was deprecated and removed from the repository. + +### What did `zoog` do? + +`zoog` modified the internal gain values of Opus files and applied the inverse +gain delta to the any `R128` tags present in the file. Like `opusgain`, this +enabled targeting Opus-encoded tracks to a particular loudness level on players +that did not support `R128` tags whilst maintaining the same loudness value for +players that used them. + +### Why was `zoog` deprecated? + +`zoog` did not decode audio in order to determine loudness. Instead it relied +upon existing `R128` tags. This was problematic because lack of an +`R128_ALBUM_GAIN` tag does not indicate a track is not album normalized - it +might still have been album normalized via the internal gain header (as done by +`opusenc` when encoding from FLAC files containing ReplayGain tags). Such files +are problematic for players in general if they wish to play tracks at an +album-normalized volume because it's not obvious how to tell if tracks have +been album normalized. + +`zoog` had a similar issue. Modifying an album-normalized track's internal gain +requires creation of an `R128_ALBUM_GAIN` tag if there is not one present. If +the track is not album-normalized, then adding such a tag is nonsensical. + +`zoog` did not introduce new `R128_ALBUM_GAIN` tags and It was suggested that a +tool like [loudgain](https://github.com/Moonbase59/loudgain) be used create +`R128_ALBUM_GAIN` tags before applying `zoog` to album-normalized files. +However, failure to do this would likely result in different internal gains being +applied to different tracks in an album, losing album-normalization in a way that +would likely go unnoticed. + +Due to the potential for error, `zoog` was removed and `opusgain` was created. +Like `vorbisgain` and similar tools, `opusgain` decodes the audio to determine loudness +and has an option to specify whether the tracks being normalized are part of an album. + +### When should I use `opusgain` versus `loudgain` +If you only play Opus files in players which support `R128` tags, then use +[loudgain](https://github.com/Moonbase59/loudgain). -If neither the `R128_ALBUM_GAIN` or `R128_TRACK_GAIN` tags are found in the -input file, Zoog will not modify the file. +You should use `opusgain` if you play Ogg Opus files in players that do not +support `R128` tags and would like them to play at either their original +volume, or at the volumes suggested by ReplayGain or EBU R 128. -## What Zoog doesn't do +Once you have set the internal gains of a set of Opus files to the desired +values, then `loudgain` is likely preferable for any future tag updates related to +normalization. -* Zorg doesn't actually compute the loudness of the input file itself, hence the requirements -for either the `R128_ALBUM_GAIN` or `R128_TRACK_GAIN` tags. +### How can I check if `opusgain` is working correctly? -* Due to the first point, Zorg cannot do anything about clipping. If -`--preset=none` is set, the clipping will be the same as would have existed -if `opusenc` had been used on an input file without any loudness information. -On audio with high levels of -[dynamic range compression](https://en.wikipedia.org/wiki/Dynamic_range_compression), -clipping is unlikely to occur on the other presets. +Applying `opusgain` to various test files then reviewing the diagnostic output +and `R128` tags generated by [loudgain](https://github.com/Moonbase59/loudgain) +when applied to the rewritten files is helpful in this regard. ## Build Instructions diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..c7dcff4 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,10 @@ +fn_args_layout = "Compressed" +fn_single_line = true +force_multiline_blocks = false +group_imports = "StdExternalCrate" +imports_granularity = "Module" +max_width = 120 +newline_style = "Unix" +reorder_impl_items = true +use_small_heuristics = "Max" +wrap_comments = true diff --git a/scripts/build-release b/scripts/build-release index 908fc28..bb1a335 100755 --- a/scripts/build-release +++ b/scripts/build-release @@ -25,7 +25,7 @@ mkdir "${NAME}" cp -a "${TOP}/README.md" "${NAME}" cp -a "${TOP}/CHANGELOG.md" "${NAME}" cp -a "${TOP}/LICENSE" "${NAME}" -for BINARY in zoog; do +for BINARY in opusgain; do BINARY_PATH="${TOP}/target/${TARGET}/release/${BINARY}${EXE_SUFFIX}" if [ -f "${BINARY_PATH}" ]; then cp -a "${BINARY_PATH}" "${NAME}" diff --git a/src/bin/opusgain.rs b/src/bin/opusgain.rs new file mode 100644 index 0000000..10b917a --- /dev/null +++ b/src/bin/opusgain.rs @@ -0,0 +1,272 @@ +use std::collections::HashMap; +use std::fs::File; +use std::io::{self, BufReader, BufWriter, Read, Seek, Write}; +use std::path::{Path, PathBuf}; + +use clap::{Parser, ValueEnum}; +use ogg::reading::PacketReader; +use ogg::writing::PacketWriter; +use zoog::rewriter::{OutputGainMode, RewriteResult, Rewriter, RewriterConfig, VolumeTarget}; +use zoog::volume_analyzer::VolumeAnalyzer; +use zoog::{Decibels, Error, R128_LUFS, REPLAY_GAIN_LUFS}; + +fn main() { + match main_impl() { + Ok(()) => {} + Err(e) => { + eprintln!("Error was: {}", e); + std::process::exit(1); + } + } +} + +fn remove_file_verbose>(path: P) { + let path = path.as_ref(); + if let Err(e) = std::fs::remove_file(path) { + eprintln!("Unable to delete {} due to error {}", path.to_string_lossy(), e); + } +} + +fn rename_file, Q: AsRef>(from: P, to: Q) -> Result<(), Error> { + std::fs::rename(from.as_ref(), to.as_ref()) + .map_err(|e| Error::FileMove(PathBuf::from(from.as_ref()), PathBuf::from(to.as_ref()), e)) +} + +fn apply_volume_analysis>(analyzer: &mut VolumeAnalyzer, path: P) -> Result<(), Error> { + let input_path = path.as_ref(); + print!("Computing loudness of {}... ", input_path.to_string_lossy()); + io::stdout().flush().map_err(Error::GenericIoError)?; + let input_file = File::open(input_path).map_err(|e| Error::FileOpenError(input_path.to_path_buf(), e))?; + let input_file = BufReader::new(input_file); + let mut ogg_reader = PacketReader::new(input_file); + loop { + match ogg_reader.read_packet() { + Err(e) => { + println!(); + break Err(Error::OggDecode(e)); + } + Ok(None) => { + analyzer.file_complete(); + println!( + "{:.2} LUFS (ignoring output gain)", + analyzer.last_track_lufs().expect("Last track volume unexpectedly missing").as_f64() + ); + break Ok(()); + } + Ok(Some(packet)) => analyzer.submit(packet)?, + } + } +} + +#[derive(Debug)] +struct AlbumVolume { + mean: Decibels, + tracks: HashMap, +} + +impl AlbumVolume { + pub fn get_album_mean(&self) -> Decibels { self.mean } + + pub fn get_track_mean(&self, path: &Path) -> Option { self.tracks.get(path).cloned() } +} + +fn compute_album_volume, P: AsRef>(paths: I) -> Result { + let mut analyzer = VolumeAnalyzer::default(); + let mut tracks = HashMap::new(); + for input_path in paths.into_iter() { + apply_volume_analysis(&mut analyzer, input_path.as_ref())?; + tracks.insert( + input_path.as_ref().to_path_buf(), + analyzer.last_track_lufs().expect("Track volume unexpectedly missing"), + ); + } + let album_volume = AlbumVolume { tracks, mean: analyzer.mean_lufs() }; + Ok(album_volume) +} + +fn rewrite_stream( + input: R, mut output: W, config: &RewriterConfig, +) -> Result { + let mut ogg_reader = PacketReader::new(input); + let ogg_writer = PacketWriter::new(&mut output); + let mut rewriter = Rewriter::new(config, ogg_writer, true); + loop { + match ogg_reader.read_packet() { + Err(e) => break Err(Error::OggDecode(e)), + Ok(None) => { + // Make sure to flush any buffered data + break output.flush().map(|_| RewriteResult::Ready).map_err(Error::WriteError); + } + Ok(Some(packet)) => { + let submit_result = rewriter.submit(packet); + match submit_result { + Ok(RewriteResult::Ready) => {} + _ => break submit_result, + } + } + } + } +} + +#[derive(Copy, Clone, Debug, ValueEnum)] +enum Preset { + #[clap(name = "rg")] + ReplayGain, + #[clap(name = "r128")] + R128, + #[clap(name = "original")] + ZeroGain, +} + +#[derive(Copy, Clone, Debug, ValueEnum)] +enum OutputGainSetting { + Auto, + Track, +} + +#[derive(Debug, Parser)] +#[clap(author, version, about)] +struct Cli { + #[clap(short, long, action)] + /// Enable album mode + album: bool, + + #[clap(arg_enum, value_parser, short, long, default_value_t = Preset::ReplayGain)] + /// Normalizes to loudness used by ReplayGain (rg), EBU R 128 (r128) or + /// original (none) + preset: Preset, + + #[clap(arg_enum, value_parser, short, long, default_value_t = OutputGainSetting::Auto)] + /// When "auto" is specified, each track's output gain is chosen to be + /// per-track or per-album dependent on whether album mode is enabled. + /// When "track" is specified, each file's output gain will be + /// track-specific, even in album mode. + output_gain_mode: OutputGainSetting, + + #[clap(value_parser, required(true))] + /// The Opus files to process + input_files: Vec, + + #[clap(short, long, action)] + /// Display output without performing any file modification. + display_only: bool, +} + +#[derive(Debug)] +enum OutputFile { + Temp(tempfile::NamedTempFile), + Sink(io::Sink), +} + +impl OutputFile { + fn as_write(&mut self) -> &mut dyn Write { + match self { + OutputFile::Temp(ref mut temp) => temp, + OutputFile::Sink(ref mut sink) => sink, + } + } +} + +fn main_impl() -> Result<(), Error> { + let cli = Cli::parse(); + let album_mode = cli.album; + + let output_gain_mode = match cli.output_gain_mode { + OutputGainSetting::Auto => match album_mode { + true => OutputGainMode::Album, + false => OutputGainMode::Track, + }, + OutputGainSetting::Track => OutputGainMode::Track, + }; + let volume_target = match cli.preset { + Preset::ReplayGain => VolumeTarget::LUFS(REPLAY_GAIN_LUFS), + Preset::R128 => VolumeTarget::LUFS(R128_LUFS), + Preset::ZeroGain => VolumeTarget::ZeroGain, + }; + + let mut num_processed: usize = 0; + let mut num_already_normalized: usize = 0; + + let display_only = cli.display_only; + if display_only { + println!("Display-only mode is enabled so no files will actually be modified.\n"); + } + + let input_files = cli.input_files; + let album_volume = if album_mode { Some(compute_album_volume(&input_files)?) } else { None }; + + for input_path in input_files { + println!( + "Processing file {} with target loudness of {}...", + &input_path.to_string_lossy(), + volume_target.to_friendly_string() + ); + let track_volume = match &album_volume { + None => { + let mut analyzer = VolumeAnalyzer::default(); + apply_volume_analysis(&mut analyzer, &input_path)?; + analyzer.last_track_lufs().expect("Last track volume unexpectedly missing") + } + Some(album_volume) => { + album_volume.get_track_mean(&input_path).expect("Could not find previously computed track volume") + } + }; + let rewriter_config = RewriterConfig::new( + volume_target, + output_gain_mode, + track_volume, + album_volume.as_ref().map(|a| a.get_album_mean()), + ); + + let input_dir = input_path.parent().expect("Unable to find parent folder of input file"); + let input_base = input_path.file_name().expect("Unable to find name of input file"); + let input_file = File::open(&input_path).map_err(|e| Error::FileOpenError(input_path.to_path_buf(), e))?; + let mut input_file = BufReader::new(input_file); + + let mut output_file = if display_only { + OutputFile::Sink(io::sink()) + } else { + let temp = tempfile::Builder::new() + .prefix(input_base) + .suffix("zoog") + .tempfile_in(input_dir) + .map_err(Error::TempFileOpenError)?; + OutputFile::Temp(temp) + }; + let rewrite_result = { + let output_file = output_file.as_write(); + let mut output_file = BufWriter::new(output_file); + rewrite_stream(&mut input_file, &mut output_file, &rewriter_config) + }; + num_processed += 1; + + match rewrite_result { + Err(e) => { + println!("Failure during processing of {:#?}.", input_path); + return Err(e); + } + Ok(RewriteResult::Ready) => match output_file { + OutputFile::Temp(output_file) => { + let mut backup_path = input_path.clone(); + backup_path.set_extension("zoog-orig"); + rename_file(&input_path, &backup_path)?; + output_file + .persist_noclobber(&input_path) + .map_err(Error::PersistError) + .and_then(|f| f.sync_all().map_err(Error::WriteError))?; + remove_file_verbose(&backup_path); + } + OutputFile::Sink(_) => {} + }, + Ok(RewriteResult::AlreadyNormalized) => { + println!("All gains are already correct so doing nothing."); + num_already_normalized += 1; + } + } + println!(); + } + println!("Processing complete."); + println!("Total files processed: {}", num_processed); + println!("Files processed but already normalized: {}", num_already_normalized); + Ok(()) +} diff --git a/src/bin/zoog.rs b/src/bin/zoog.rs deleted file mode 100644 index de3e1c3..0000000 --- a/src/bin/zoog.rs +++ /dev/null @@ -1,147 +0,0 @@ -use clap::{App, Arg}; -use ogg::reading::PacketReader; -use ogg::writing::PacketWriter; -use std::fs::File; -use std::io::{BufReader, BufWriter, Write}; -use std::path::{Path, PathBuf}; -use zoog::constants::{R128_LUFS, REPLAY_GAIN_LUFS}; -use zoog::rewriter::{OperationMode, RewriteResult, Rewriter}; -use zoog::ZoogError; - -pub const VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); -pub const AUTHORS: Option<&'static str> = option_env!("CARGO_PKG_AUTHORS"); -pub const DESCRIPTION: Option<&'static str> = option_env!("CARGO_PKG_DESCRIPTION"); - -fn get_version() -> String { - VERSION.map(String::from).unwrap_or_else(|| String::from("Unknown version")) -} - -fn get_authors() -> String { - AUTHORS.map(String::from).unwrap_or_else(|| String::from("Unknown author")) -} - -fn get_description() -> String { - DESCRIPTION.map(String::from).unwrap_or_else(|| String::from("Missing description")) -} - -fn main() { - match main_impl() { - Ok(()) => {} - Err(e) => { - eprintln!("Error was: {}", e); - std::process::exit(1); - } - } -} - -fn remove_file_verbose>(path: P) { - let path = path.as_ref(); - if let Err(e) = std::fs::remove_file(path) { - eprintln!("Unable to delete {} due to error {}", path.to_string_lossy(), e); - } -} - -fn rename_file, Q: AsRef>(from: P, to: Q) -> Result<(), ZoogError> { - std::fs::rename(from.as_ref(), to.as_ref()).map_err(|e| { - ZoogError::FileCopy(PathBuf::from(from.as_ref()), PathBuf::from(to.as_ref()), e) - }) -} - -fn main_impl() -> Result<(), ZoogError> { - let matches = App::new("Zoog") - .author(get_authors().as_str()) - .about(get_description().as_str()) - .version(get_version().as_str()) - .arg(Arg::with_name("preset") - .long("preset") - .possible_values(&["rg", "r128", "none"]) - .default_value("rg") - .multiple(false) - .help("Normalizes to loudness used by ReplayGain (rg), EBU R 128 (r128) or original (none)")) - .arg(Arg::with_name("input_files") - .multiple(true) - .required(true) - .help("The Opus files to process")) - .get_matches(); - - let mode = match matches.value_of("preset").unwrap() { - "rg" => OperationMode::TargetLUFS(REPLAY_GAIN_LUFS), - "r128" => OperationMode::TargetLUFS(R128_LUFS), - "none" => OperationMode::ZeroOutputGain, - p => panic!("Unknown preset: {}", p), - }; - - let mut num_processed: usize = 0; - let mut num_already_normalized: usize = 0; - let mut num_missing_r128: usize = 0; - - let input_files = matches.values_of("input_files").expect("No input files"); - for input_path in input_files { - let input_path = PathBuf::from(input_path); - println!("Processing file {:#?} with target loudness of {}...", input_path, mode.to_friendly_string()); - let input_file = File::open(&input_path).map_err(|e| ZoogError::FileOpenError(input_path.clone(), e))?; - let input_file = BufReader::new(input_file); - - let input_dir = input_path.parent().expect("Unable to find parent folder of input file"); - let input_base = input_path.file_name().expect("Unable to find name of input file"); - let mut output_file = tempfile::Builder::new() - .prefix(input_base) - .suffix("zoog") - .tempfile_in(input_dir) - .map_err(ZoogError::TempFileOpenError)?; - - let rewrite_result = { - let mut ogg_reader = PacketReader::new(input_file); - let mut output_file = BufWriter::new(&mut output_file); - let ogg_writer = PacketWriter::new(&mut output_file); - let mut rewriter = Rewriter::new(mode, ogg_writer, true); - loop { - match ogg_reader.read_packet() { - Err(e) => break Err(ZoogError::OggDecode(e)), - Ok(None) => { - // Make sure to flush the buffered writer - break output_file.flush() - .map(|_| RewriteResult::Ready) - .map_err(ZoogError::WriteError); - } - Ok(Some(packet)) => { - let submit_result = rewriter.submit(packet); - match submit_result { - Ok(RewriteResult::Ready) => {} - _ => break submit_result, - } - } - } - } - }; - - num_processed += 1; - match rewrite_result { - Err(e) => { - println!("Failure during processing of {:#?}.", input_path); - return Err(e); - } - Ok(RewriteResult::Ready) => { - let mut backup_path = input_path.clone(); - backup_path.set_extension("zoog-orig"); - rename_file(&input_path, &backup_path)?; - output_file.persist_noclobber(&input_path)?; - remove_file_verbose(&backup_path); - } - Ok(RewriteResult::NoR128Tags) => { - println!("No R128 tags found in file so doing nothing."); - num_missing_r128 += 1; - } - Ok(RewriteResult::AlreadyNormalized) => { - println!("All gains are already correct so doing nothing."); - num_already_normalized += 1; - } - } - println!(); - } - println!("Processing complete."); - println!("Total files processed: {}", num_processed); - println!("Files processed but already normalized: {}", num_already_normalized); - println!("Files skipped due to missing R128 tags: {}", num_missing_r128); - Ok(()) -} diff --git a/src/comment_header.rs b/src/comment_header.rs index 201ce54..3823df4 100644 --- a/src/comment_header.rs +++ b/src/comment_header.rs @@ -1,9 +1,10 @@ -use crate::constants::{TAG_ALBUM_GAIN, TAG_TRACK_GAIN}; -use crate::error::ZoogError; -use crate::gain::Gain; +use std::io::{Cursor, Read, Write}; + use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; use derivative::Derivative; -use std::io::{Cursor, Read, Write}; + +use crate::opus::{FixedPointGain, TAG_ALBUM_GAIN, TAG_TRACK_GAIN}; +use crate::Error; const COMMENT_MAGIC: &[u8] = &[0x4f, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73]; @@ -17,29 +18,25 @@ pub struct CommentHeader<'a> { } impl<'a> CommentHeader<'a> { - fn read_length(mut reader: R) -> Result { - reader.read_u32::().map_err(|_| ZoogError::MalformedCommentHeader) + fn read_length(mut reader: R) -> Result { + reader.read_u32::().map_err(|_| Error::MalformedCommentHeader) } - fn read_exact(mut reader: R, data: &mut [u8]) -> Result<(), ZoogError> { - reader.read_exact(data).map_err(|_| ZoogError::MalformedCommentHeader) + fn read_exact(mut reader: R, data: &mut [u8]) -> Result<(), Error> { + reader.read_exact(data).map_err(|_| Error::MalformedCommentHeader) } pub fn empty(data: &'a mut Vec) -> CommentHeader<'a> { - CommentHeader { - data, - vendor: String::new(), - user_comments: Vec::new(), - } + CommentHeader { data, vendor: String::new(), user_comments: Vec::new() } } - pub fn set_vendor(&mut self, vendor: &str) { - self.vendor = vendor.to_string(); - } + pub fn set_vendor(&mut self, vendor: &str) { self.vendor = vendor.to_string(); } - pub fn try_parse(data: &'a mut Vec) -> Result>, ZoogError> { + pub fn try_parse(data: &'a mut Vec) -> Result>, Error> { let identical = data.iter().take(COMMENT_MAGIC.len()).eq(COMMENT_MAGIC.iter()); - if !identical { return Ok(None); } + if !identical { + return Ok(None); + } let mut reader = Cursor::new(&data[COMMENT_MAGIC.len()..]); let vendor_len = Self::read_length(&mut reader)?; let mut vendor = vec![0u8; vendor_len as usize]; @@ -52,28 +49,24 @@ impl<'a> CommentHeader<'a> { let mut comment = vec![0u8; comment_len as usize]; Self::read_exact(&mut reader, &mut comment)?; let comment = String::from_utf8(comment)?; - let offset = comment.find('=').ok_or(ZoogError::MalformedCommentHeader)?; + let offset = comment.find('=').ok_or(Error::MalformedCommentHeader)?; let (key, value) = comment.split_at(offset); user_comments.push((String::from(key), String::from(&value[1..]))); } - let result = CommentHeader { - data, - vendor, - user_comments, - }; + let result = CommentHeader { data, vendor, user_comments }; Ok(Some(result)) } pub fn get_first(&self, key: &str) -> Option<&str> { for (k, v) in self.user_comments.iter() { - if k == key { return Some(v); } + if k == key { + return Some(v); + } } None } - pub fn remove_all(&mut self, key: &str) { - self.user_comments = self.user_comments.iter().filter(|(k, _)| key != k).cloned().collect(); - } + pub fn remove_all(&mut self, key: &str) { self.user_comments.retain(|(k, _)| key != k); } pub fn replace(&mut self, key: &str, value: &str) { self.remove_all(key); @@ -84,9 +77,9 @@ impl<'a> CommentHeader<'a> { self.user_comments.push((String::from(key), String::from(value))); } - pub fn get_gain_from_tag(&self, tag: &str) -> Result, ZoogError> { - let parsed = self.get_first(tag) - .map(|v| v.parse::().map_err(|_| ZoogError::InvalidR128Tag(v.to_string()))); + pub fn get_gain_from_tag(&self, tag: &str) -> Result, Error> { + let parsed = + self.get_first(tag).map(|v| v.parse::().map_err(|_| Error::InvalidR128Tag(v.to_string()))); match parsed { Some(Ok(v)) => Ok(Some(v)), Some(Err(e)) => Err(e), @@ -94,21 +87,23 @@ impl<'a> CommentHeader<'a> { } } - pub fn get_album_or_track_gain(&self) -> Result, ZoogError> { + pub fn get_album_or_track_gain(&self) -> Result, Error> { for tag in [TAG_ALBUM_GAIN, TAG_TRACK_GAIN].iter() { - if let Some(gain) = self.get_gain_from_tag(*tag)? { + if let Some(gain) = self.get_gain_from_tag(tag)? { return Ok(Some(gain)); } } Ok(None) } - pub fn adjust_gains(&mut self, adjustment: Gain) -> Result<(), ZoogError> { - if adjustment.is_zero() { return Ok(()); } + pub fn adjust_gains(&mut self, adjustment: FixedPointGain) -> Result<(), Error> { + if adjustment.is_zero() { + return Ok(()); + } for tag in [TAG_ALBUM_GAIN, TAG_TRACK_GAIN].iter() { - if let Some(gain) = self.get_gain_from_tag(*tag)? { - let gain = gain.checked_add(adjustment).ok_or(ZoogError::GainOutOfBounds)?; - self.replace(*tag, &format!("{}", gain.as_fixed_point())); + if let Some(gain) = self.get_gain_from_tag(tag)? { + let gain = gain.checked_add(adjustment).ok_or(Error::GainOutOfBounds)?; + self.replace(tag, &format!("{}", gain.as_fixed_point())); } } Ok(()) @@ -138,13 +133,19 @@ impl<'a> Drop for CommentHeader<'a> { fn drop(&mut self) { self.commit(); } } +impl<'a> PartialEq for CommentHeader<'a> { + fn eq(&self, other: &CommentHeader<'a>) -> bool { + self.vendor == other.vendor && self.user_comments == other.user_comments + } +} + #[cfg(test)] mod tests { - use super::*; - use rand::Rng; use rand::distributions::{Standard, Uniform}; use rand::rngs::SmallRng; - use rand::SeedableRng; + use rand::{Rng, SeedableRng}; + + use super::*; const MAX_STRING_LENGTH: usize = 1024; const MAX_COMMENTS: usize = 128; @@ -167,7 +168,7 @@ mod tests { header.set_vendor(&random_string(engine, true)); let num_comments_dist = Uniform::new_inclusive(0, MAX_COMMENTS); let num_comments = engine.sample(&num_comments_dist); - for _ in 0 .. num_comments { + for _ in 0..num_comments { let key = random_string(engine, false); let value = random_string(engine, true); header.append(key.as_str(), value.as_str()); @@ -188,7 +189,7 @@ mod tests { #[test] fn parse_and_commit_is_identity() { let mut rng = SmallRng::seed_from_u64(19489); - for _ in 0 .. NUM_IDENTITY_TESTS { + for _ in 0..NUM_IDENTITY_TESTS { let mut header_data = Vec::new(); { create_random_header(&mut rng, &mut header_data); @@ -215,9 +216,8 @@ mod tests { fn truncated_header() { let mut header: Vec = COMMENT_MAGIC.iter().cloned().collect(); match CommentHeader::try_parse(&mut header) { - Err(ZoogError::MalformedCommentHeader) => {} - _ => assert!(false, "Wrong error for malformed header") + Err(Error::MalformedCommentHeader) => {} + _ => assert!(false, "Wrong error for malformed header"), }; } } - diff --git a/src/constants.rs b/src/constants.rs index 772fa8c..499e45e 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,4 +1,11 @@ -pub const TAG_TRACK_GAIN: &str = "R128_TRACK_GAIN"; -pub const TAG_ALBUM_GAIN: &str = "R128_ALBUM_GAIN"; -pub const R128_LUFS: f64 = -23.0; -pub const REPLAY_GAIN_LUFS: f64 = -18.0; +pub mod global { + use crate::Decibels; + + pub const R128_LUFS: Decibels = Decibels::from(-23.0); + pub const REPLAY_GAIN_LUFS: Decibels = Decibels::from(-18.0); +} + +pub mod opus { + pub const TAG_TRACK_GAIN: &str = "R128_TRACK_GAIN"; + pub const TAG_ALBUM_GAIN: &str = "R128_ALBUM_GAIN"; +} diff --git a/src/decibels.rs b/src/decibels.rs new file mode 100644 index 0000000..c0f109b --- /dev/null +++ b/src/decibels.rs @@ -0,0 +1,37 @@ +use std::fmt::{Display, Formatter}; +use std::ops::{Add, Sub}; + +#[derive(Copy, Clone, Debug)] +pub struct Decibels { + inner: f64, +} + +impl Decibels { + pub fn as_f64(&self) -> f64 { self.inner } +} + +impl Default for Decibels { + fn default() -> Decibels { Decibels::from(0.0) } +} + +impl const From for Decibels { + fn from(value: f64) -> Decibels { Decibels { inner: value } } +} + +impl Display for Decibels { + fn fmt(&self, formatter: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + write!(formatter, "{} dB", self.inner) + } +} + +impl Sub for Decibels { + type Output = Decibels; + + fn sub(self, other: Decibels) -> Decibels { Decibels { inner: self.inner - other.inner } } +} + +impl Add for Decibels { + type Output = Decibels; + + fn add(self, other: Decibels) -> Decibels { Decibels { inner: self.inner + other.inner } } +} diff --git a/src/error.rs b/src/error.rs index 4821d81..db9e9e8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,10 +1,11 @@ -use ogg::reading::OggReadError; use std::path::PathBuf; + +use ogg::reading::OggReadError; use tempfile::PersistError; use thiserror::Error; #[derive(Debug, Error)] -pub enum ZoogError { +pub enum Error { #[error("Unable to open file `{0}` due to `{1}`")] FileOpenError(PathBuf, std::io::Error), #[error("Unable to open temporary file due to `{0}`")] @@ -26,7 +27,13 @@ pub enum ZoogError { #[error("A computed gain value was not representable")] GainOutOfBounds, #[error("Failed to rename `{0}` to `{1}` due to `{2}`")] - FileCopy(PathBuf, PathBuf, std::io::Error), - #[error("Failed to persist temporary file due to `{0}``")] + FileMove(PathBuf, PathBuf, std::io::Error), + #[error("Failed to persist temporary file due to `{0}`")] PersistError(#[from] PersistError), + #[error("Unsupported channel count: `{0}`")] + InvalidChannelCount(usize), + #[error("Opus error: `{0}`")] + OpusError(opus::Error), + #[error("IO error: `{0}`")] + GenericIoError(std::io::Error), } diff --git a/src/fixed_point_gain.rs b/src/fixed_point_gain.rs new file mode 100644 index 0000000..3f4b34b --- /dev/null +++ b/src/fixed_point_gain.rs @@ -0,0 +1,113 @@ +use std::convert::TryFrom; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; + +use crate::{Decibels, Error}; + +#[derive(Default, Copy, Clone, Debug, Eq, PartialEq)] +pub struct FixedPointGain { + value: i16, +} + +impl FixedPointGain { + pub fn as_fixed_point(self) -> i16 { self.value } + + pub fn as_decibels(self) -> Decibels { Decibels::from(self.value as f64 / 256.0) } + + pub fn from_integer(value: i16) -> FixedPointGain { FixedPointGain { value } } + + pub fn is_zero(self) -> bool { self.value == 0 } + + pub fn checked_add(self, rhs: FixedPointGain) -> Option { + self.value.checked_add(rhs.value).map(|value| FixedPointGain { value }) + } + + pub fn checked_neg(self) -> Option { + self.value.checked_neg().map(|value| FixedPointGain { value }) + } +} + +impl TryFrom for FixedPointGain { + type Error = Error; + + fn try_from(value: Decibels) -> Result { + let fixed = (value.as_f64() * 256.0).round(); + let value = fixed as i16; + if ((value as f64) - fixed).abs() < std::f64::EPSILON { + Ok(FixedPointGain { value }) + } else { + Err(Error::GainOutOfBounds) + } + } +} + +impl FromStr for FixedPointGain { + type Err = ::Err; + + fn from_str(s: &str) -> Result { s.parse::().map(|value| FixedPointGain { value }) } +} + +impl Display for FixedPointGain { + fn fmt(&self, formatter: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + write!(formatter, "{}", self.as_decibels()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero_db_is_none() { + assert!(FixedPointGain::try_from(Decibels::default()).unwrap().is_zero()); + } + + #[test] + fn positive_overflow() { + let max_gain = FixedPointGain { value: std::i16::MAX }; + let one = FixedPointGain { value: 1 }; + assert_eq!(max_gain.checked_add(one), None); + assert_eq!(one.checked_add(max_gain), None); + } + + #[test] + fn negative_overflow() { + let min_gain = FixedPointGain { value: std::i16::MIN }; + let neg_one = FixedPointGain { value: -1 }; + assert_eq!(min_gain.checked_add(neg_one), None); + assert_eq!(neg_one.checked_add(min_gain), None); + } + + #[test] + fn negate_lowest_value() { + let min_gain = FixedPointGain { value: std::i16::MIN }; + assert_eq!(min_gain.checked_neg(), None); + } + + #[test] + fn decibel_conversion() { + for value in std::i16::MIN..=std::i16::MAX { + let gain = FixedPointGain { value }; + let decibels = gain.as_decibels(); + let gain2 = FixedPointGain::try_from(decibels).unwrap(); + assert_eq!(gain, gain2); + } + } + + #[test] + fn parse_valid() { + assert_eq!("-32768".parse::(), Ok(FixedPointGain { value: -32768 })); + assert_eq!("-1".parse::(), Ok(FixedPointGain { value: -1 })); + assert_eq!("0".parse::(), Ok(FixedPointGain { value: 0 })); + assert_eq!("1".parse::(), Ok(FixedPointGain { value: 1 })); + assert_eq!("32767".parse::(), Ok(FixedPointGain { value: 32767 })); + } + + #[test] + fn parse_invalid() { + assert!("-32769".parse::().is_err()); + assert!("32768".parse::().is_err()); + assert!("0.0".parse::().is_err()); + assert!("".parse::().is_err()); + } +} diff --git a/src/gain.rs b/src/gain.rs deleted file mode 100644 index 070b142..0000000 --- a/src/gain.rs +++ /dev/null @@ -1,95 +0,0 @@ -use std::str::FromStr; - -#[derive(Default, Copy, Clone, Debug, Eq, PartialEq)] -pub struct Gain { - pub(crate) value: i16, -} - -impl Gain { - pub fn as_decibels(self) -> f64 { self.value as f64 / 256.0 } - - pub fn as_fixed_point(self) -> i16 { self.value } - - pub fn from_decibels(value: f64) -> Option { - let fixed = (value * 256.0).round(); - let value = fixed as i16; - if ((value as f64) - fixed).abs() < std::f64::EPSILON { - Some(Gain { value }) - } else { - None - } - } - - pub fn is_zero(self) -> bool { self.value == 0 } - - pub fn checked_add(self, rhs: Gain) -> Option { - self.value.checked_add(rhs.value).map(|value| Gain { value }) - } - - pub fn checked_neg(self) -> Option { self.value.checked_neg().map(|value| Gain { value }) } -} - -impl FromStr for Gain { - type Err = ::Err; - - fn from_str(s: &str) -> Result { s.parse::().map(|value| Gain { value }) } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn zero_db_is_none() { - assert!(Gain::from_decibels(0.0).unwrap().is_zero()); - } - - #[test] - fn positive_overflow() { - let max_gain = Gain { value: std::i16::MAX }; - let one = Gain { value: 1 }; - assert_eq!(max_gain.checked_add(one), None); - assert_eq!(one.checked_add(max_gain), None); - } - - #[test] - fn negative_overflow() { - let min_gain = Gain { value: std::i16::MIN }; - let neg_one = Gain { value: -1 }; - assert_eq!(min_gain.checked_add(neg_one), None); - assert_eq!(neg_one.checked_add(min_gain), None); - } - - #[test] - fn negate_lowest_value() { - let min_gain = Gain { value: std::i16::MIN }; - assert_eq!(min_gain.checked_neg(), None); - } - - #[test] - fn decibel_conversion() { - for value in std::i16::MIN..=std::i16::MAX { - let gain = Gain { value }; - let decibels = gain.as_decibels(); - let gain2 = Gain::from_decibels(decibels).unwrap(); - assert_eq!(gain, gain2); - } - } - - #[test] - fn parse_valid() { - assert_eq!("-32768".parse::(), Ok(Gain{ value: -32768 })); - assert_eq!("-1".parse::(), Ok(Gain{ value: -1 })); - assert_eq!("0".parse::(), Ok(Gain{ value: 0 })); - assert_eq!("1".parse::(), Ok(Gain{ value: 1 })); - assert_eq!("32767".parse::(), Ok(Gain{ value: 32767 })); - } - - #[test] - fn parse_invalid() { - assert!("-32769".parse::().is_err()); - assert!("32768".parse::().is_err()); - assert!("0.0".parse::().is_err()); - assert!("".parse::().is_err()); - } -} diff --git a/src/lib.rs b/src/lib.rs index e7607b3..aafd15e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,22 @@ -pub mod comment_header; -pub mod constants; -pub mod error; -pub mod gain; -pub mod opus_header; +#![feature(const_trait_impl)] + +mod comment_header; +mod constants; +mod decibels; +mod error; +mod fixed_point_gain; +mod opus_header; + pub mod rewriter; +pub mod volume_analyzer; -pub use comment_header::*; +pub use constants::global::*; +pub use decibels::*; pub use error::*; -pub use gain::*; -pub use opus_header::*; + +pub mod opus { + pub use crate::comment_header::*; + pub use crate::constants::opus::*; + pub use crate::fixed_point_gain::*; + pub use crate::opus_header::*; +} diff --git a/src/opus_header.rs b/src/opus_header.rs index d37491b..ce7090e 100644 --- a/src/opus_header.rs +++ b/src/opus_header.rs @@ -1,8 +1,10 @@ -use crate::error::ZoogError; -use crate::gain::Gain; -use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; use std::io::Cursor; +use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; + +use crate::opus::FixedPointGain; +use crate::Error; + const OPUS_MIN_HEADER_SIZE: usize = 19; const OPUS_MAGIC: &[u8] = &[0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]; @@ -12,29 +14,41 @@ pub struct OpusHeader<'a> { impl<'a> OpusHeader<'a> { pub fn try_new(data: &'a mut Vec) -> Option> { - if data.len() < OPUS_MIN_HEADER_SIZE { return None; } + if data.len() < OPUS_MIN_HEADER_SIZE { + return None; + } let identical = data.iter().take(OPUS_MAGIC.len()).eq(OPUS_MAGIC.iter()); - if !identical { return None; } - Some(OpusHeader { - data, - }) + if !identical { + return None; + } + Some(OpusHeader { data }) } - pub fn get_output_gain(&self) -> Gain { + pub fn get_output_gain(&self) -> FixedPointGain { let mut reader = Cursor::new(&self.data[16..18]); let value = reader.read_i16::().expect("Error reading gain"); - Gain { value } + FixedPointGain::from_integer(value) } - pub fn set_output_gain(&mut self, gain: Gain) { + pub fn set_output_gain(&mut self, gain: FixedPointGain) { let mut writer = Cursor::new(&mut self.data[16..18]); - writer.write_i16::(gain.value).expect("Error writing gain"); + writer.write_i16::(gain.as_fixed_point()).expect("Error writing gain"); } - pub fn adjust_output_gain(&mut self, adjustment: Gain) -> Result<(), ZoogError> { + pub fn adjust_output_gain(&mut self, adjustment: FixedPointGain) -> Result<(), Error> { let gain = self.get_output_gain(); - let gain = gain.checked_add(adjustment).ok_or(ZoogError::GainOutOfBounds)?; + let gain = gain.checked_add(adjustment).ok_or(Error::GainOutOfBounds)?; self.set_output_gain(gain); Ok(()) } + + pub fn num_output_channels(&self) -> Result { + let mut reader = Cursor::new(&self.data[9..10]); + let value = reader.read_u8().expect("Error reading output channel count"); + Ok(value.into()) + } +} + +impl<'a> PartialEq for OpusHeader<'a> { + fn eq(&self, other: &OpusHeader) -> bool { self.data == other.data } } diff --git a/src/rewriter.rs b/src/rewriter.rs index 4328caf..2e66744 100644 --- a/src/rewriter.rs +++ b/src/rewriter.rs @@ -1,24 +1,54 @@ -use crate::comment_header::CommentHeader; -use crate::constants::{R128_LUFS, TAG_ALBUM_GAIN, TAG_TRACK_GAIN}; -use crate::error::ZoogError; -use crate::gain::Gain; -use crate::opus_header::OpusHeader; -use ogg::writing::{PacketWriteEndInfo, PacketWriter}; -use ogg::Packet; use std::collections::VecDeque; +use std::convert::TryFrom; use std::io::Write; +use ogg::writing::{PacketWriteEndInfo, PacketWriter}; +use ogg::Packet; + +use crate::opus::{CommentHeader, FixedPointGain, OpusHeader, TAG_ALBUM_GAIN, TAG_TRACK_GAIN}; +use crate::{Decibels, Error, R128_LUFS}; + +#[derive(Clone, Copy, Debug)] +pub enum VolumeTarget { + ZeroGain, + LUFS(Decibels), +} + +#[derive(Clone, Copy, Debug)] +pub enum OutputGainMode { + Album, + Track, +} + #[derive(Clone, Copy, Debug)] -pub enum OperationMode { - ZeroOutputGain, - TargetLUFS(f64), +pub struct RewriterConfig { + output_gain: VolumeTarget, + output_gain_mode: OutputGainMode, + track_volume: Decibels, + album_volume: Option, } -impl OperationMode { +impl RewriterConfig { + pub fn new( + output_gain: VolumeTarget, output_gain_mode: OutputGainMode, track_volume: Decibels, + album_volume: Option, + ) -> RewriterConfig { + RewriterConfig { output_gain, output_gain_mode, track_volume, album_volume } + } + + pub fn volume_for_output_gain_calculation(&self) -> Decibels { + match self.output_gain_mode { + OutputGainMode::Album => self.album_volume.unwrap_or(self.track_volume), + OutputGainMode::Track => self.track_volume, + } + } +} + +impl VolumeTarget { pub fn to_friendly_string(&self) -> String { match *self { - OperationMode::ZeroOutputGain => String::from("original input"), - OperationMode::TargetLUFS(lufs) => format!("{} LUFS", lufs), + VolumeTarget::ZeroGain => String::from("original input"), + VolumeTarget::LUFS(lufs) => format!("{:.2} LUFS", lufs.as_f64()), } } } @@ -26,7 +56,6 @@ impl OperationMode { #[derive(Clone, Copy, Debug)] pub enum RewriteResult { Ready, - NoR128Tags, AlreadyNormalized, } @@ -37,11 +66,11 @@ enum State { Forwarding, } -fn print_gains<'a>(opus_header: &OpusHeader<'a>, comment_header: &CommentHeader<'a>) -> Result<(), ZoogError> { - println!("\tOutput Gain: {}dB", opus_header.get_output_gain().as_decibels()); +fn print_gains<'a>(opus_header: &OpusHeader<'a>, comment_header: &CommentHeader<'a>) -> Result<(), Error> { + println!("\tOutput Gain: {}", opus_header.get_output_gain().as_decibels()); for tag in [TAG_ALBUM_GAIN, TAG_TRACK_GAIN].iter() { if let Some(gain) = comment_header.get_gain_from_tag(tag)? { - println!("\t{}: {}dB", tag, gain.as_decibels()); + println!("\t{}: {}", tag, gain.as_decibels()); } } Ok(()) @@ -52,23 +81,23 @@ pub struct Rewriter { header_packet: Option, state: State, packet_queue: VecDeque, - mode: OperationMode, + config: RewriterConfig, verbose: bool, } impl Rewriter { - pub fn new(mode: OperationMode, packet_writer: PacketWriter, verbose: bool) -> Rewriter { + pub fn new(config: &RewriterConfig, packet_writer: PacketWriter, verbose: bool) -> Rewriter { Rewriter { packet_writer, header_packet: None, state: State::AwaitingHeader, packet_queue: VecDeque::new(), - mode, + config: *config, verbose, } } - pub fn submit(&mut self, mut packet: Packet) -> Result { + pub fn submit(&mut self, mut packet: Packet) -> Result { match self.state { State::AwaitingHeader => { self.header_packet = Some(packet); @@ -78,44 +107,50 @@ impl Rewriter { // Parse Opus header let mut opus_header_packet = self.header_packet.take().expect("Missing header packet"); { - let mut opus_header = OpusHeader::try_new(&mut opus_header_packet.data) - .ok_or(ZoogError::MissingOpusStream)?; + // Create copies of Opus and comment header to check if they have changed + let mut opus_header_packet_data_orig = opus_header_packet.data.clone(); + let mut comment_header_data_orig = packet.data.clone(); + + // Parse Opus header + let mut opus_header = + OpusHeader::try_new(&mut opus_header_packet.data).ok_or(Error::MissingOpusStream)?; // Parse comment header let mut comment_header = match CommentHeader::try_parse(&mut packet.data) { Ok(Some(header)) => header, - Ok(None) => return Err(ZoogError::MissingCommentHeader), + Ok(None) => return Err(Error::MissingCommentHeader), Err(e) => return Err(e), }; - - let header_gain = opus_header.get_output_gain(); - let comment_gain = match comment_header.get_album_or_track_gain() { - Err(e) => return Err(e), - Ok(None) => return Ok(RewriteResult::NoR128Tags), - Ok(Some(gain)) => gain, + let volume_for_output_gain = self.config.volume_for_output_gain_calculation(); + let new_header_gain = match self.config.output_gain { + VolumeTarget::ZeroGain => FixedPointGain::default(), + VolumeTarget::LUFS(target_lufs) => { + FixedPointGain::try_from(target_lufs - volume_for_output_gain)? + } }; - if self.verbose { - println!("Original gain values:"); - print_gains(&opus_header, &comment_header)?; + let track_gain_r128 = + FixedPointGain::try_from(R128_LUFS - self.config.track_volume - new_header_gain.as_decibels())?; + let album_gain_r128 = if let Some(album_volume) = self.config.album_volume { + Some(FixedPointGain::try_from(R128_LUFS - album_volume - new_header_gain.as_decibels())?) + } else { + None + }; + opus_header.set_output_gain(new_header_gain); + comment_header.replace(TAG_TRACK_GAIN, &format!("{}", track_gain_r128.as_fixed_point())); + if let Some(album_gain_r128) = album_gain_r128 { + comment_header.replace(TAG_ALBUM_GAIN, &format!("{}", album_gain_r128.as_fixed_point())); + } else { + comment_header.remove_all(TAG_ALBUM_GAIN); } - match self.mode { - OperationMode::ZeroOutputGain => { - // Set Opus header gain - opus_header.set_output_gain(Gain::default()); - // Set comment header gain - if header_gain.is_zero() { - return Ok(RewriteResult::AlreadyNormalized); - } else { - comment_header.adjust_gains(header_gain)?; - } - } - OperationMode::TargetLUFS(target_lufs) => { - let header_delta = Gain::from_decibels(comment_gain.as_decibels() + target_lufs - R128_LUFS); - let header_delta = header_delta.ok_or(ZoogError::GainOutOfBounds)?; - if header_delta.is_zero() { return Ok(RewriteResult::AlreadyNormalized); } - let comment_delta = header_delta.checked_neg().ok_or(ZoogError::GainOutOfBounds)?; - opus_header.adjust_output_gain(header_delta)?; - comment_header.adjust_gains(comment_delta)?; - } + + // We have decoded both of these already, so these should never fail + let opus_header_orig = OpusHeader::try_new(&mut opus_header_packet_data_orig) + .expect("Unexpectedly failed to decode Opus header"); + let comment_header_orig = CommentHeader::try_parse(&mut comment_header_data_orig) + .expect("Unexpectedly failed to decode comment header") + .expect("Comment header unexpectedly missing"); + + if opus_header == opus_header_orig && comment_header == comment_header_orig { + return Ok(RewriteResult::AlreadyNormalized); } if self.verbose { println!("New gain values:"); @@ -142,14 +177,10 @@ impl Rewriter { let packet_serial = packet.stream_serial(); let packet_granule = packet.absgp_page(); - self.packet_writer.write_packet(packet.data.into_boxed_slice(), - packet_serial, - packet_info, - packet_granule, - ).map_err(ZoogError::WriteError)?; + self.packet_writer + .write_packet(packet.data.into_boxed_slice(), packet_serial, packet_info, packet_granule) + .map_err(Error::WriteError)?; } Ok(RewriteResult::Ready) } } - - diff --git a/src/volume_analyzer.rs b/src/volume_analyzer.rs new file mode 100644 index 0000000..e80354f --- /dev/null +++ b/src/volume_analyzer.rs @@ -0,0 +1,187 @@ +use bs1770::{ChannelLoudnessMeter, Power, Windows100ms}; +use ogg::Packet; +use opus::{Channels, Decoder}; + +use crate::opus::{CommentHeader, OpusHeader}; +use crate::{Decibels, Error}; + +// Opus uses this internally so we decode to this regardless of the input file +// sampling rate +const OPUS_DECODE_SAMPLE_RATE: usize = 48000; + +// Specified in RFC6716 +const OPUS_MAX_PACKET_DURATION_MS: usize = 120; + +#[derive(Clone, Copy, Debug)] +enum State { + AwaitingHeader, + AwaitingComments, + Analyzing, +} + +struct DecodeStateChannel { + loudness_meter: ChannelLoudnessMeter, + sample_buffer: Vec, +} + +impl DecodeStateChannel { + fn new(sample_rate: usize) -> DecodeStateChannel { + DecodeStateChannel { loudness_meter: ChannelLoudnessMeter::new(sample_rate as u32), sample_buffer: Vec::new() } + } +} + +struct DecodeState { + channel_count: usize, + _sample_rate: usize, + decoder: Decoder, + channel_states: Vec, + sample_buffer: Vec, +} + +impl DecodeState { + fn new(channel_count: usize, sample_rate: usize) -> Result { + let channel_count_typed = match channel_count { + 1 => Channels::Mono, + 2 => Channels::Stereo, + n => return Err(Error::InvalidChannelCount(n)), + }; + let decoder = Decoder::new(sample_rate as u32, channel_count_typed).map_err(Error::OpusError)?; + let mut channel_states = Vec::with_capacity(channel_count); + for _ in 0..channel_count { + channel_states.push(DecodeStateChannel::new(sample_rate)); + } + assert_eq!(channel_states.len(), channel_count); + let ms_per_second: usize = 1000; + let state = DecodeState { + channel_count, + _sample_rate: sample_rate, + decoder, + channel_states, + sample_buffer: vec![0.0f32; channel_count * sample_rate * OPUS_MAX_PACKET_DURATION_MS / ms_per_second], + }; + Ok(state) + } + + fn push_packet(&mut self, packet: &[u8]) -> Result<(), Error> { + // Decode to interleaved PCM + let decode_fec = false; + let num_decoded_samples = + self.decoder.decode_float(packet, &mut self.sample_buffer, decode_fec).map_err(Error::OpusError)?; + + for (c, channel_state) in &mut self.channel_states.iter_mut().enumerate() { + channel_state.sample_buffer.resize(num_decoded_samples, 0.0f32); + // Extract interleaved data + for i in 0..num_decoded_samples { + let offset = i * self.channel_count + c; + channel_state.sample_buffer[i] = self.sample_buffer[offset]; + } + // Feed to meter + channel_state.loudness_meter.push(channel_state.sample_buffer.iter().cloned()); + } + Ok(()) + } + + fn get_windows(&self) -> Windows100ms> { + let windows: Vec<_> = self.channel_states.iter().map(|cs| cs.loudness_meter.as_100ms_windows()).collect(); + // See notes on `reduce_stero` in `bs1770` crate. + let power_scale_factor = match self.channel_count { + 1 => 2.0, // Since mono is still output to two devices + 2 => 1.0, + n => panic!("Calculating power for number of channels {} not yet supported", n), + }; + let num_windows = windows[0].len(); + for channel_windows in &windows { + assert_eq!(num_windows, channel_windows.len(), "Channels had different amounts of audio"); + } + let mut result_windows = Vec::with_capacity(num_windows); + for i in 0..num_windows { + let mut power = 0.0; + for channel_windows in &windows { + let channel_windows = &channel_windows.inner; + // It would be nice if `Power` implemented addition since this is a + // semantically-valid operation + power += channel_windows[i].0; + } + power *= power_scale_factor; + result_windows.push(Power(power)); + } + Windows100ms { inner: result_windows } + } +} + +pub struct VolumeAnalyzer { + decode_state: Option, + state: State, + windows: Windows100ms>, + track_loudness: Vec, +} + +impl Default for VolumeAnalyzer { + fn default() -> VolumeAnalyzer { + VolumeAnalyzer { + decode_state: None, + state: State::AwaitingHeader, + windows: Windows100ms::new(), + track_loudness: Vec::new(), + } + } +} + +impl VolumeAnalyzer { + pub fn submit(&mut self, mut packet: Packet) -> Result<(), Error> { + match self.state { + State::AwaitingHeader => { + let header = OpusHeader::try_new(&mut packet.data).ok_or(Error::MissingOpusStream)?; + let channel_count = header.num_output_channels()?; + let sample_rate = OPUS_DECODE_SAMPLE_RATE; + self.decode_state = Some(DecodeState::new(channel_count, sample_rate)?); + self.state = State::AwaitingComments; + } + State::AwaitingComments => { + // Check comment header is valid + match CommentHeader::try_parse(&mut packet.data) { + Ok(Some(_)) => (), + Ok(None) => return Err(Error::MissingCommentHeader), + Err(e) => return Err(e), + } + self.state = State::Analyzing; + } + State::Analyzing => { + let decode_state = self.decode_state.as_mut().expect("Decode state unexpectedly missing"); + decode_state.push_packet(&packet.data)?; + } + } + Ok(()) + } + + fn gated_mean_to_lufs(windows: Windows100ms<&[Power]>) -> Decibels { + let power = bs1770::gated_mean(windows.as_ref()); + let lufs = if power.0.is_nan() { + // Near silence can result in a NaN result (https://github.com/ruuda/bs1770/issues/1). + // Returning a large negative value might result in the application of a massive + // gain and is therefore not a good idea. Instead we return zero, + // which indicates the audio is at peak volume. + 0.0 + } else { + power.loudness_lkfs().into() + }; + Decibels::from(lufs) + } + + pub fn file_complete(&mut self) { + if let Some(decode_state) = self.decode_state.take() { + let windows = decode_state.get_windows(); + let track_power = Self::gated_mean_to_lufs(windows.as_ref()); + self.track_loudness.push(track_power); + self.windows.inner.extend(windows.inner); + } + assert!(self.decode_state.is_none()); + self.state = State::AwaitingHeader; + } + + pub fn mean_lufs(&self) -> Decibels { Self::gated_mean_to_lufs(self.windows.as_ref()) } + + pub fn track_lufs(&self) -> Vec { self.track_loudness.clone() } + + pub fn last_track_lufs(&self) -> Option { self.track_loudness.last().cloned() } +}