diff --git a/.github/workflows/audio.yml b/.github/workflows/audio.yml index 0104f848e..0517efe6f 100644 --- a/.github/workflows/audio.yml +++ b/.github/workflows/audio.yml @@ -44,7 +44,7 @@ jobs: # Each feature on its own, so code that needs another feature fails here. - name: Clippy, each feature alone run: | - for f in "" decode decode-native symphonia encode lossless-encode pcm-encode flac-encode peaks aac-decode aac-encode "aac-encode,rayon" opus-decode ac3-decode mpa-decode serde; do + for f in "" decode decode-native symphonia encode lossless-encode pcm-encode flac-encode peaks aac-decode aac-encode "aac-encode,rayon" opus-decode opus-encode ac3-decode mpa-decode serde; do echo "::group::features: ${f:-none}" cargo clippy -p craft-audio --all-targets --locked --no-default-features ${f:+--features "$f"} -- -D warnings echo "::endgroup::" diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 19d38973d..32bae195d 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -6,6 +6,7 @@ author, source and license. Library code and original assets are under the repos | File | Author | Source | License | |---|---|---|---| +| `licenses/dwg/acadrust-MPL-2.0.txt`, `licenses/dwg/acadrust-THIRD_PARTY_NOTICES.md` | `acadrust` contributors; ACadSharp authors for the noted ported routines | [acadrust 0.6.6](https://github.com/hakanaktt/acadrust), used unmodified as `craft-dwg`'s optional native dependency | MPL-2.0; embedded ACadSharp notice is MIT; see both retained files | | `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft contributors; generated by `craft_cms::synth::coated_cmyk` | [PhotoCraft `crates/cms`](https://github.com/storytold/photocraft/tree/b3222ee8e7e6bcb4799c37f904c595f20f9baf5e/crates/cms) | CC0-1.0 | | `crates/cms/profiles/craft-coated-fogra39.icc` | craft-libs contributors; generated by `crates/cms/tests/fogra39.rs` from the FOGRA39 data below | this repository | CC0-1.0 | | `crates/font-store/index/sources.json` (unmodified) | craft-font-index contributors | [aiongg/craft-font-index@5a9a7f8](https://github.com/aiongg/craft-font-index/tree/5a9a7f8038c8f3e2e1583a79c92a816e8e71b34f) | Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index 09d5fa6a9..6a4d8e0e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,30 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "acadrust" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8923c99ea1f151cb7fc17ce2fc14f8b1e330fa22611e535602d985c0cc3c89a2" +dependencies = [ + "anyhow", + "bitflags 2.13.2", + "byteorder", + "encoding_rs", + "flate2", + "foldhash 0.2.0", + "indexmap", + "itoa", + "memmap2", + "nom 7.1.3", + "oem_cp", + "once_cell", + "rayon", + "ryu", + "thiserror 2.0.21", + "web-time", +] + [[package]] name = "accesskit" version = "0.25.1" @@ -98,24 +122,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -198,23 +204,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "argon2" version = "0.5.3" @@ -249,15 +238,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -410,49 +390,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.21", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" -dependencies = [ - "arrayvec", -] - [[package]] name = "aws-lc-rs" version = "1.18.1" @@ -580,15 +517,6 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "bitstream-io" -version = "4.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" -dependencies = [ - "no_std_io2", -] - [[package]] name = "bitvec" version = "1.1.1" @@ -799,12 +727,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "built" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" - [[package]] name = "bumpalo" version = "3.20.3" @@ -1388,6 +1310,17 @@ dependencies = [ "proptest", ] +[[package]] +name = "craft-designcraft-format" +version = "0.1.0" +dependencies = [ + "craft-document-bundle", + "craft-eps", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-dialogs" version = "0.1.0" @@ -1490,12 +1423,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-dwg" +version = "0.1.0" +dependencies = [ + "acadrust", + "log", +] + [[package]] name = "craft-dxf" version = "0.1.0" dependencies = [ "craft-codepage", "encoding_rs", + "kurbo 0.13.1", "log", "thiserror 2.0.21", ] @@ -1512,6 +1454,12 @@ dependencies = [ [[package]] name = "craft-eps" version = "0.1.0" +dependencies = [ + "kurbo 0.13.1", + "thiserror 2.0.21", + "vectorcraft-doc", + "vectorcraft-eps", +] [[package]] name = "craft-epub" @@ -1557,6 +1505,17 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-figcraft-format" +version = "0.1.0" +dependencies = [ + "craft-zip", + "serde", + "serde_json", + "thiserror 2.0.21", + "zip", +] + [[package]] name = "craft-figma-rest" version = "0.1.0" @@ -1578,6 +1537,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-file-import" +version = "0.1.0" +dependencies = [ + "craft-dxf", + "craft-eps", + "craft-file-formats", + "craft-pdf 0.1.0", + "craft-raster-codecs", + "craft-svg", + "kurbo 0.13.1", + "thiserror 2.0.21", + "vectorcraft-geom", +] + [[package]] name = "craft-flags" version = "0.1.0" @@ -1680,6 +1654,7 @@ dependencies = [ name = "craft-gif" version = "0.1.0" dependencies = [ + "gif", "image", ] @@ -1752,30 +1727,6 @@ dependencies = [ "zip", ] -[[package]] -name = "craft-image-codecs" -version = "0.1.0" -dependencies = [ - "craft-heif", - "craft-jpeg", - "exr", - "flate2", - "half", - "image", - "image-webp", - "jpeg-encoder 0.6.1", - "jxl-oxide", - "png 0.18.1", - "proptest", - "rayon", - "serde_json", - "thiserror 2.0.21", - "tiff", - "zune-core", - "zune-jpeg", - "zune-jpegxl", -] - [[package]] name = "craft-indd" version = "0.1.0" @@ -1800,6 +1751,16 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "craft-jxl" +version = "0.1.0" +dependencies = [ + "craft-cms", + "jixel", + "jxl-oxide", + "thiserror 2.0.21", +] + [[package]] name = "craft-keymap" version = "0.1.0" @@ -1846,6 +1807,7 @@ dependencies = [ name = "craft-lottie" version = "0.1.0" dependencies = [ + "craft-zip", "serde_json", "thiserror 2.0.21", ] @@ -2097,6 +2059,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "craft-pdf" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "hayro-interpret", + "hayro-syntax", + "krilla", + "thiserror 2.0.21", +] + [[package]] name = "craft-pdn" version = "0.1.0" @@ -2153,7 +2126,7 @@ name = "craft-plate-test" version = "0.1.0" dependencies = [ "craft-cms", - "craft-pdf", + "craft-pdf 0.1.0", "hayro-syntax", "serde", "serde_json", @@ -2214,7 +2187,7 @@ name = "craft-prepress" version = "0.1.0" dependencies = [ "craft-cms", - "craft-pdf", + "craft-pdf 0.1.0", "craft-plate-test", "kurbo 0.13.1", "linesweeper", @@ -2282,7 +2255,6 @@ name = "craft-raster-codecs" version = "0.1.0" dependencies = [ "base64 0.23.1", - "craft-image-codecs", "image", "rayon", "thiserror 2.0.21", @@ -2323,6 +2295,20 @@ dependencies = [ "proptest", ] +[[package]] +name = "craft-scraft-format" +version = "0.1.0" +dependencies = [ + "craft-audio", + "craft-midi", + "craft-time", + "craft-zip", + "proptest", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-settings" version = "0.1.0" @@ -2366,6 +2352,19 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-stencilcraft-document" +version = "0.1.0" +dependencies = [ + "craft-atomic", + "craft-stencilcraft-format", + "craft-svg", + "kurbo 0.13.1", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-stencilcraft-format" version = "0.1.0" @@ -2397,6 +2396,8 @@ dependencies = [ "roxmltree 0.21.2", "thiserror 2.0.21", "usvg 0.48.1", + "vectorcraft-geom", + "vectorcraft-pathops", ] [[package]] @@ -2437,10 +2438,21 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-time" +version = "0.1.0" +dependencies = [ + "proptest", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-timeline" version = "0.1.0" dependencies = [ + "craft-time", "craft-xml", "proptest", "serde", @@ -2614,6 +2626,14 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-xfl" +version = "0.1.0" +dependencies = [ + "miniz_oxide 0.8.9", + "thiserror 2.0.21", +] + [[package]] name = "craft-xls" version = "0.1.0" @@ -3321,7 +3341,7 @@ dependencies = [ "bitflags 2.13.2", "emath", "epaint", - "itertools 0.15.0", + "itertools", "log", "nohash-hasher", "profiling", @@ -3378,7 +3398,7 @@ dependencies = [ "ahash", "egui", "enum-map", - "itertools 0.15.0", + "itertools", "log", "profiling", "resvg", @@ -3586,7 +3606,7 @@ dependencies = [ "emath", "epaint_default_fonts", "font-types 0.12.7", - "harfrust", + "harfrust 0.12.0", "log", "nohash-hasher", "parking_lot", @@ -3607,26 +3627,6 @@ name = "epaint_default_fonts" version = "0.37.0" source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -3683,7 +3683,6 @@ dependencies = [ "lebe", "miniz_oxide 0.8.9", "multiversion", - "rayon-core", "smallvec", "zune-inflate", ] @@ -3835,6 +3834,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -4133,7 +4138,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -4150,7 +4155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -4325,6 +4330,28 @@ dependencies = [ "smallvec", ] +[[package]] +name = "harfrust" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "once_cell", + "read-fonts 0.43.3", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -4333,7 +4360,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -4342,7 +4369,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -4381,7 +4408,7 @@ dependencies = [ "hayro-syntax", "kurbo 0.13.1", "moxcms", - "phf", + "phf 0.13.1", "rustc-hash 2.1.3", "siphasher", "skrifa 0.47.0", @@ -4903,9 +4930,6 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", - "qoi", - "ravif", - "rgb", "tiff", "zune-core", "zune-jpeg", @@ -4944,12 +4968,6 @@ name = "indd" version = "0.1.0" source = "git+https://github.com/storytold/indd-utils?rev=7f3060e5bce78d21ea70e0d5eacb96edce15dfbe#7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" -[[package]] -name = "imgref" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" - [[package]] name = "indexmap" version = "2.14.2" @@ -4979,17 +4997,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "io-extras" version = "0.19.0" @@ -5037,15 +5044,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.15.0" @@ -5061,6 +5059,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jixel" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278a75e12caacec34be774d62ad05e45fc0b6a52d1f4e9f7fbc0209ecdf794f3" + [[package]] name = "jni" version = "0.22.4" @@ -5310,8 +5314,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25f15eb830aa77a7f21148d72e153562a26bfe570139bd4922eab1908dd499d3" dependencies = [ - "rayon", - "rayon-core", "tracing", ] @@ -5472,16 +5474,6 @@ version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" -[[package]] -name = "libfuzzer-sys" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" -dependencies = [ - "arbitrary", - "cc", -] - [[package]] name = "libloading" version = "0.8.9" @@ -5569,15 +5561,6 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - [[package]] name = "lru-slab" version = "0.1.3" @@ -5590,15 +5573,6 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", -] - [[package]] name = "md-5" version = "0.10.6" @@ -5659,6 +5633,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -5814,12 +5794,6 @@ dependencies = [ "jni-sys 0.3.1", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - [[package]] name = "nix" version = "0.31.3" @@ -5832,21 +5806,22 @@ dependencies = [ "libc", ] -[[package]] -name = "no_std_io2" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" -dependencies = [ - "memchr", -] - [[package]] name = "nohash-hasher" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -5856,12 +5831,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -5871,16 +5840,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -5913,17 +5872,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "num-integer" version = "0.1.47" @@ -5943,17 +5891,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -6317,6 +6254,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "oem_cp" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7959d3349f484aec36462f98226983578bf7d33f9b034c32967489c7000e1f" +dependencies = [ + "phf 0.11.3", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -6367,6 +6313,15 @@ dependencies = [ "libredox", ] +[[package]] +name = "ordered-channel" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95be4d57809897b5a7539fc15a7dfe0e84141bc3dfaa2e9b1b27caa90acf61ab" +dependencies = [ + "crossbeam-channel", +] + [[package]] name = "ordered-float" version = "5.5.0" @@ -6511,18 +6466,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - [[package]] name = "pbkdf2" version = "0.13.0" @@ -6637,7 +6580,7 @@ dependencies = [ "log", "md-5 0.10.6", "memchr", - "nom", + "nom 8.0.0", "num-bigint-dig", "num-traits", "num_enum", @@ -6663,6 +6606,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" @@ -6670,7 +6622,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -6680,7 +6632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -6690,12 +6642,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -7049,19 +7010,6 @@ name = "profiling" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" -dependencies = [ - "quote", - "syn 2.0.119", -] [[package]] name = "proptest" @@ -7089,15 +7037,6 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - [[package]] name = "quick-error" version = "1.2.3" @@ -7219,20 +7158,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -7254,16 +7183,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -7273,15 +7192,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -7312,55 +7222,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.5", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.21", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error 2.0.1", - "rav1e", - "rgb", -] - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -7429,6 +7290,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types 0.12.7", + "once_cell", +] + [[package]] name = "read-fonts" version = "0.44.0" @@ -7447,7 +7319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "font-types 0.12.7", "once_cell", "yoke", @@ -8398,15 +8270,6 @@ dependencies = [ "simdutf8", ] -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - [[package]] name = "simdutf8" version = "0.1.5" @@ -8606,6 +8469,16 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "string-interner" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -9049,6 +8922,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.11.3" @@ -9574,7 +9456,7 @@ dependencies = [ "base64 0.23.1", "data-url", "fontdb", - "harfrust", + "harfrust 0.12.0", "imagesize 0.15.0", "kurbo 0.13.1", "log", @@ -9621,6 +9503,221 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vectorcraft-brush" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "kurbo 0.13.1", + "serde", + "serde_json", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-geom", +] + +[[package]] +name = "vectorcraft-color" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "kurbo 0.13.1", + "moxcms", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "vectorcraft-doc" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "chrono", + "hayro-ccitt", + "image", + "kurbo 0.13.1", + "serde", + "serde_json", + "thiserror 2.0.21", + "tiff", + "vectorcraft-color", + "vectorcraft-geom", + "zune-jpeg", +] + +[[package]] +name = "vectorcraft-effects" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "image", + "kurbo 0.13.1", + "serde_json", + "vectorcraft-doc", + "vectorcraft-geom", + "vectorcraft-pathops", + "vectorcraft-plugins", + "vectorcraft-text", + "vectorcraft-three-d", +] + +[[package]] +name = "vectorcraft-eps" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "flate2", + "hayro-ccitt", + "image", + "kurbo 0.13.1", + "memchr", + "ruzstd", + "serde_json", + "vectorcraft-brush", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-effects", + "vectorcraft-geom", + "vectorcraft-metafile", + "vectorcraft-pdf", + "vectorcraft-render", + "vectorcraft-text", +] + +[[package]] +name = "vectorcraft-geom" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "kurbo 0.13.1", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "vectorcraft-metafile" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "image", + "kurbo 0.13.1", + "vectorcraft-brush", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-effects", + "vectorcraft-geom", + "vectorcraft-text", +] + +[[package]] +name = "vectorcraft-pathops" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "kurbo 0.13.1", + "linesweeper", + "thiserror 2.0.21", + "vectorcraft-geom", +] + +[[package]] +name = "vectorcraft-pdf" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "aes 0.8.4", + "chrono", + "craft-pdf 0.1.0 (git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0)", + "flate2", + "getrandom 0.4.3", + "image", + "jpeg-encoder 0.6.1", + "kurbo 0.13.1", + "log", + "md-5 0.10.6", + "roxmltree 0.21.2", + "serde", + "sha2 0.10.9", + "skrifa 0.47.0", + "smallvec", + "thiserror 2.0.21", + "vectorcraft-brush", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-effects", + "vectorcraft-geom", + "vectorcraft-text", + "weezl 0.2.1", +] + +[[package]] +name = "vectorcraft-plugins" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-geom", + "wasmi", +] + +[[package]] +name = "vectorcraft-render" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "flate2", + "gif", + "image", + "jpeg-encoder 0.6.1", + "log", + "png 0.18.1", + "vectorcraft-brush", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-effects", + "vectorcraft-geom", + "vectorcraft-text", + "vello_common 0.2.0", + "vello_cpu 0.2.0", + "weezl 0.2.1", + "winsafe", +] + +[[package]] +name = "vectorcraft-text" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "harfrust 0.13.3", + "icu_segmenter", + "kurbo 0.13.1", + "log", + "skrifa 0.47.0", + "subsetter", + "unicode-bidi", + "unicode-general-category", + "unicode-script", + "unicode-segmentation", + "vectorcraft-doc", + "vectorcraft-geom", + "vello_cpu 0.2.0", + "windows-registry", +] + +[[package]] +name = "vectorcraft-three-d" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "vectorcraft-geom", + "vectorcraft-pathops", +] + [[package]] name = "v_frame" version = "0.3.9" @@ -9632,6 +9729,7 @@ dependencies = [ "wasm-bindgen", ] + [[package]] name = "vello_common" version = "0.0.8" @@ -9702,9 +9800,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", + "crossbeam-channel", "glifo 0.3.0", "hashbrown 0.17.1", + "ordered-channel", "png 0.18.1", + "rayon", + "thread_local", "vello_common 0.2.0", ] @@ -9862,6 +9964,56 @@ version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe" +[[package]] +name = "wasmi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27" +dependencies = [ + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser", +] + +[[package]] +name = "wasmi_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557" +dependencies = [ + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.228.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3" +dependencies = [ + "bitflags 2.13.2", + "indexmap", +] + [[package]] name = "wayland-backend" version = "0.3.17" @@ -10419,7 +10571,7 @@ dependencies = [ [[package]] name = "winprint" version = "0.2.1" -source = "git+https://github.com/storytold/winprint.rs?rev=72951d7455ebcab569fbd9386c513d3d9f53fb02#72951d7455ebcab569fbd9386c513d3d9f53fb02" +source = "git+https://github.com/dotCooCoo/winprint.rs?rev=72951d7455ebcab569fbd9386c513d3d9f53fb02#72951d7455ebcab569fbd9386c513d3d9f53fb02" dependencies = [ "flate2", "fmt-derive", @@ -10654,12 +10806,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "yeslogic-fontconfig-sys" version = "6.0.1" diff --git a/Cargo.toml b/Cargo.toml index 5ca3bf2ab..dd7607666 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/craft-libs" [workspace.dependencies] +acadrust = "=0.6.6" +log = "0.4" # internal: add each crate here as `craft- = { path = "crates/" }` craft-platform = { path = "crates/platform" } craft-aep = { path = "crates/aep" } @@ -40,13 +42,20 @@ craft-dialogs = { path = "crates/dialogs" } craft-doc-binary = { path = "crates/doc-binary" } craft-docx = { path = "crates/docx" } craft-document-bundle = { path = "crates/document-bundle" } +craft-designcraft-format = { path = "crates/designcraft-format" } craft-dxf = { path = "crates/dxf" } craft-drawingml = { path = "crates/drawingml" } craft-eps = { path = "crates/eps" } craft-file-formats = { path = "crates/file-formats" } +craft-file-import = { path = "crates/file-import" } +vectorcraft-doc = { git = "https://github.com/storytold/vectorcraft.git", rev = "6b3a6b8788ebcb420d69449199cbdf323888d27e" } +vectorcraft-eps = { git = "https://github.com/storytold/vectorcraft.git", rev = "6b3a6b8788ebcb420d69449199cbdf323888d27e" } +vectorcraft-geom = { git = "https://github.com/storytold/vectorcraft.git", rev = "6b3a6b8788ebcb420d69449199cbdf323888d27e" } +vectorcraft-pathops = { git = "https://github.com/storytold/vectorcraft.git", rev = "6b3a6b8788ebcb420d69449199cbdf323888d27e" } craft-doc = { path = "crates/doc" } craft-docpath = { path = "crates/docpath" } craft-dpx = { path = "crates/dpx" } +craft-dwg = { path = "crates/dwg" } craft-ecproj-format = { path = "crates/ecproj-format" } craft-figma-rest = { path = "crates/figma-rest" } craft-exr = { path = "crates/exr" } @@ -70,6 +79,7 @@ craft-indd = { path = "crates/indd" } craft-jmap = { path = "crates/jmap" } craft-ical = { path = "crates/ical" } craft-jpeg = { path = "crates/jpeg" } +craft-jxl = { path = "crates/jxl" } craft-kiwi = { path = "crates/kiwi" } craft-keymap = { path = "crates/keymap" } craft-assuan = { path = "crates/assuan" } @@ -114,9 +124,12 @@ craft-raw = { path = "crates/raw" } craft-sheet = { path = "crates/sheet" } craft-sketch = { path = "crates/sketch" } craft-settings = { path = "crates/settings" } +craft-scraft-format = { path = "crates/scraft-format" } +craft-time = { path = "crates/time" } craft-silhouette-studio = { path = "crates/silhouette-studio" } craft-svg = { path = "crates/svg" } craft-stencilcraft-format = { path = "crates/stencilcraft-format" } +craft-stencilcraft-document = { path = "crates/stencilcraft-document" } craft-svg-paths = { path = "crates/svg-paths" } craft-stream-codecs = { path = "crates/stream-codecs" } craft-text = { path = "crates/text" } @@ -130,6 +143,7 @@ craft-vcard = { path = "crates/vcard" } craft-vectorcraft-format = { path = "crates/vectorcraft-format" } craft-vectordrawable = { path = "crates/vectordrawable" } craft-xls = { path = "crates/xls" } +craft-xfl = { path = "crates/xfl" } craft-wordmodel = { path = "crates/wordmodel" } craft-xlsx = { path = "crates/xlsx" } indd = { git = "https://github.com/storytold/indd-utils", rev = "7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" } @@ -146,6 +160,8 @@ craft-web-host = { path = "crates/web-host" } craft-webp = { path = "crates/webp" } egui = { version = "0.37", default-features = false } md5 = { package = "md-5", version = "0.10", default-features = false } +jxl-oxide = { version = "0.12", default-features = false } +jixel = "=0.3.4" rayon = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/NOTICE b/NOTICE index 10e0fcc19..e1cffb26c 100644 --- a/NOTICE +++ b/NOTICE @@ -5,6 +5,17 @@ Licensed under the MIT License or the Apache License, Version 2.0, at your optio Third-party material +Optional DWG bridge + +`craft-dwg` uses `acadrust` 0.6.6 unmodified as a separate dependency for DWG reading and writing. +It is licensed under the Mozilla Public License 2.0. The license text and upstream third-party +notices are retained in `licenses/dwg/`; the crate is available from crates.io and +https://github.com/hakanaktt/acadrust. + +The byte-level bridge source was moved from CADCraft `crates/dwg` at revision +`664837df8925f932e90a7ae37c58d2f54e7a1320`. The bridge itself is MIT OR Apache-2.0; its only +MPL-2.0 component is the unmodified `acadrust` dependency. + This product bundles third-party material under its own licenses, listed below. The per-file list (author, source, license) is in ATTRIBUTION.md. diff --git a/README.md b/README.md index c79fa2aff..aa47358da 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,9 @@ See the [release notes](CHANGELOG.md) for included functionality and known limit pages and version detection, and written back; and `model`, the drawing above the tags (header variables, tables, blocks, layouts, entities) and its writer (R12 to 2018), merged from CADCraft's and VectorCraft's readers. Bounded on hostile input. Mapping the model onto a document stays in each app. +- [`craft-dwg`](crates/dwg/README.md): bounded byte-level DWG↔DXF conversion with diagnostics, + capped output and an optional child-process worker; the `acadrust` dependency remains isolated + behind bytes and is unavailable on wasm32 and FreeBSD/aarch64. - [`craft-ecproj-format`](crates/ecproj-format/README.md): the envelope of EffectCraft `.ecproj` project files: `savedBy`, the schema version and its check, and the file object with the project and its saved view as type parameters. The project model stays in EffectCraft. @@ -203,6 +206,8 @@ See the [release notes](CHANGELOG.md) for included functionality and known limit decoder is bounded and returns its partial output on damage. From PdfCraft; a PDF filter chain is built on it in `craft-pdf`. - [`craft-timeline`](crates/timeline/README.md): editorial timeline interchange: exact media time and SMPTE timecode, import/export reports, media path and `file://` URL handling, Avid Log Exchange (ALE) and CMX 3600 EDL documents, format detection. From FilmCraft. +- [`craft-time`](crates/time/README.md): exact rational ticks and SMPTE time across media, with a `soundcraft` compatibility module retaining SoundCraft's sample-index timebase, tempo maps, grids and session serialization semantics. +- [`craft-scraft-format`](crates/scraft-format/README.md): SoundCraft's typed `.scraft` session schema, versioned reader/writer, hostile-input validation, and `.scraftz` bundle reader/writer. Engine and UI mapping stays in SoundCraft. - [`craft-metadata`](crates/metadata/README.md): photo metadata, from LightCraft: EXIF (read and write, maker-note lens and focus point, every tag by name), IPTC-IIM, XMP packets (read, write, and a merge that keeps what another program wrote), GPX track logs, and the blocks that carry them in JPEG, PNG, @@ -325,7 +330,8 @@ See the [release notes](CHANGELOG.md) for included functionality and known limit - [`craft-sheet`](crates/sheet/README.md): spreadsheet vocabulary shared by `craft-xls`, `craft-xlsx` and `craft-ods`: cell positions and A1 text, cell values and error values, sheet-name quoting, Excel's built-in number formats and indexed colour palette. Not a file format; no dependencies. - [`craft-sf2`](crates/sf2/README.md): SoundFont 2 (`.sf2`) reader: the sample pool and, per preset, the key and velocity regions a note sounds, with tuning, loop points, pan, level and volume envelope resolved from the preset and instrument zones. Bounded on hostile input. From LoopCraft. - [`craft-silhouette-studio`](crates/silhouette-studio/src/lib.rs): bounded, fail-closed reader for Silhouette Studio `.studio` and `.studio3` documents: outlines, line colours and page size in millimetres. Protected designs are refused. -- [`craft-stencilcraft-format`](crates/stencilcraft-format/README.md): the versioned `.stencilcraft` JSON wire schema and envelope: required members, version 1 defaults, optional fields, and header checks. The app retains its document model and domain validation. +- [`craft-stencilcraft-format`](crates/stencilcraft-format/README.md): the versioned `.stencilcraft` JSON wire schema and envelope: required members, version 1 defaults, optional fields, and header checks. +- `craft-stencilcraft-document`: StencilCraft's typed document model, geometry helpers, validation, and native `.stencilcraft` codec. - [`craft-sketch`](crates/sketch/README.md): clean-room reader for the `.sketch` container (Sketch 48 and newer): the ZIP archive, the version cut, `document.json`, the pages in document order and images, as the JSON the file holds. Bounded on hostile input. Carved from FigCraft. - [`craft-svg`](crates/svg/src/lib.rs): SVG. `sniff` recognises SVG and SVGZ data; `svgz` unpacks SVGZ within a byte limit, decodes UTF-8, UTF-16 and ISO 8859-1, and compresses; `guard` bounds diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index 822c8a8ad..9b6200198 100644 --- a/crates/audio/Cargo.toml +++ b/crates/audio/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "craft-audio" -description = "Audio file I/O and codecs: WAV/AIFF read and write, FLAC encoder, AAC and ALAC decoders and encoders, AC-3/E-AC-3 decoder and encoder, MP3 decoder and encoder, Opus and MP1/MP2 decoders." +description = "Audio file I/O and codecs: WAV/AIFF read and write, FLAC encoder, AAC and ALAC decoders and encoders, AC-3/E-AC-3 decoder and encoder, MP3 decoder and encoder, Opus decoder and encoder, MP1/MP2 decoders." version.workspace = true edition.workspace = true license.workspace = true @@ -22,8 +22,8 @@ decode = ["decode-native", "symphonia"] decode-native = ["aac-decode", "alac-decode", "opus-decode", "ac3-decode", "mpa-decode", "mp3-decode", "craft-media/isobmff", "craft-media/matroska", "craft-media/ogg", "dep:log"] # The symphonia fallback for the formats with no native decoder yet. symphonia = ["decode-native", "dep:symphonia"] -# Every encoder: the lossless file writers and the AAC, ALAC, AC-3/E-AC-3 and MP3 encoders. -encode = ["lossless-encode", "aac-encode", "alac-encode", "ac3-encode", "eac3-encode", "mp3-encode"] +# Every encoder: the lossless file writers and the AAC, ALAC, AC-3/E-AC-3, MP3 and Opus encoders. +encode = ["lossless-encode", "aac-encode", "alac-encode", "ac3-encode", "eac3-encode", "mp3-encode", "opus-encode"] # Encode WAV/BWF/RF64, AIFF/AIFF-C and FLAC files. lossless-encode = ["pcm-encode", "flac-encode"] # Encode WAV/BWF/RF64 and AIFF/AIFF-C files. @@ -34,6 +34,7 @@ flac-encode = ["craft-media/bitstream"] aac-decode = ["craft-media/bitstream"] aac-encode = ["craft-media/bitstream"] opus-decode = [] +opus-encode = [] ac3-decode = ["craft-media/bitstream"] mpa-decode = ["craft-media/bitstream"] # MPEG-1/2 Layer III (MP3) decoder (`mp3::Decoder`). diff --git a/crates/audio/README.md b/crates/audio/README.md index 30f09ea57..518dbeb4c 100644 --- a/crates/audio/README.md +++ b/crates/audio/README.md @@ -1,7 +1,8 @@ # craft-audio Audio file I/O and codecs for the Craft apps. Native implementations: WAV and AIFF read/write, -FLAC encoder, AAC and ALAC decoders/encoders, AC-3/E-AC-3 decoder/encoder, MP3 decoder/encoder, Opus and MP1/MP2 decoders. +FLAC encoder, AAC and ALAC decoders/encoders, AC-3/E-AC-3 decoder/encoder, MP3 decoder/encoder, Opus +decoder/encoder, MP1/MP2 decoders. Formats without a native decoder are decoded by [symphonia](#symphonia), which is to be replaced by native decoders. @@ -28,14 +29,14 @@ No default features. The shared types (`AudioBuffer`, `AudioInfo`, `ChannelLayou | `decode` | `decode`, `probe`, `Decoder`, `tracks`, `TrackDecoder`; every format in [Format support](#format-support) | `decode-native`, `symphonia` | | `decode-native` | the same, native formats only (others are `Unsupported`); `wav_pcm`, `aiff_pcm` (a file's integer PCM bit for bit, as `RawPcm`) | the six decoders below, craft-media `isobmff`/`matroska`/`ogg`, `log` | | `symphonia` | the fallback for formats with no native decoder; turning it on is the same as `decode` | `decode-native`, symphonia | -| `encode` | every encoder: the WAV, AIFF and FLAC file writers, and the AAC, ALAC, AC-3/E-AC-3 and MP3 encoders | `lossless-encode`, `aac-encode`, `alac-encode`, `ac3-encode`, `eac3-encode`, `mp3-encode` | +| `encode` | every encoder: the WAV, AIFF and FLAC file writers, and the AAC, ALAC, AC-3/E-AC-3, MP3 and Opus encoders | `lossless-encode`, `aac-encode`, `alac-encode`, `ac3-encode`, `eac3-encode`, `mp3-encode`, `opus-encode` | | `lossless-encode` | WAV, AIFF and FLAC writing | `pcm-encode`, `flac-encode` | | `pcm-encode` | `encode`, `FileEncoder` for WAV and AIFF; `wav_float_header`; `pcm_wav_file`, `pcm_wav_header`, `pcm_aiff_file`, `pcm_aiff_header` (minimal files around integer PCM bytes, for essence embedded in AAF / OMF) | none | | `flac-encode` | `encode`, `FileEncoder` for FLAC; `flac::Encoder`, the packet encoder for callers that mux FLAC themselves | craft-media `bitstream` | | `aac-decode`, `aac-encode` | `aac::Decoder`, `aac::Encoder`; with `aac-decode`, `aac::latm_config` / `latm_split` for LOAS / LATM frames (AAC in MPEG transport streams) | craft-media `bitstream` | | `alac-decode` | `alac::Decoder` | none | | `alac-encode` | `alac::Encoder` | none | -| `opus-decode` | `opus::Decoder`/`StreamDecoder` | none | +| `opus-decode`, `opus-encode` | `opus::Decoder`/`StreamDecoder`, `opus::Encoder` | none | | `ac3-decode` | `ac3::Decoder`, `ac3::ProgrammeDecoder` | craft-media `bitstream` | | `ac3-encode`, `eac3-encode` | `ac3::encoder::Encoder` | craft-media `bitstream` | | `mpa-decode` | `mpa::Decoder` | craft-media `bitstream` | @@ -264,6 +265,40 @@ Ls Rs) has no equivalent in that layout; passed unchanged, its channels go to th are a playable `.aac` file. To write MP4 or Matroska, pass the access units to craft-media's muxers ([MP4](../media/README.md#muxing), [Matroska](../media/README.md#muxing-1)). +### Encode Opus + +```rust +use craft_audio::opus::Encoder; + +let mut enc = Encoder::new(2, 128_000)?; // channels, bits per second +let frame_len = Encoder::FRAME_SIZE * 2; // 20 ms, interleaved stereo +let mut samples = buf.interleaved(); // 48 kHz audio +// Silence after the audio flushes the encoder's delay (the pre-skip) and fills the last frame. +let frames = samples.len() / 2 + enc.pre_skip() as usize; +samples.resize(frames.div_ceil(Encoder::FRAME_SIZE) * frame_len, 0.0); +let mut packets = Vec::new(); +for frame in samples.chunks_exact(frame_len) { + packets.push(enc.encode_float(frame)?); +} +let head = enc.opus_head(); // Matroska's CodecPrivate +let pre_skip = enc.pre_skip(); // samples the player drops at the start +``` + +The encoder takes 48 kHz only, and craft-audio has no resampler. + +craft-media has no Ogg muxer, so `.opus` files cannot be written yet; Opus goes into +Matroska/WebM or MP4: + +- **Matroska/WebM:** `head` is the track's `CodecPrivate`, and the pre-skip goes in + `TrackSpec::codec_delay_ns` (in nanoseconds at 48 kHz). The Matroska muxer cannot write + `DiscardPadding`, so the padding at the end stays in the file. +- **MP4:** the sample entry takes an `isobmff::OpusConfig` (`dOps`) with the same fields as the + `OpusHead`: channels, pre-skip, input rate, gain and mapping family. `TrackConfig::media_start` + set to the pre-skip skips it. To drop the padding as well, give `TrackConfig::edits` one + `Edit` instead, which replaces the edit `media_start` makes: `media_time` the pre-skip (track + timescale), `segment_duration` the audio's length (movie timescale), `media_rate` + `0x0001_0000`. + ### Waveform overviews ```rust @@ -309,11 +344,12 @@ match craft_audio::decode(&bytes, None) { | AIFF, AIFF-C | native | native | | FLAC | symphonia | native | | AAC-LC | native | native | -| HE-AAC v1/v2 | native, core only (see [Limits](#limits)) | — | +| HE-AAC v1 | native (SBR) | — | +| HE-AAC v2 | native, without parametric stereo (see [Limits](#limits)) | — | | ALAC | native | native, packets (`alac::Encoder`; no file writer) | | MP1, MP2 (MPEG audio Layer I/II) | native | — | | MP3 (Layer III) | native | native, frames (`mp3::Encoder`; the frames in a row are an `.mp3` file) | -| Opus | native | — | +| Opus | native | native | | AC-3, E-AC-3 | native | native packet encoder | | Vorbis, ADPCM, A-law, µ-law | symphonia | — | @@ -329,10 +365,10 @@ match craft_audio::decode(&bytes, None) { ## Limits -- HE-AAC: the AAC-LC core is decoded. `decode`, `Decoder` and `TrackDecoder` upsample it 2×; - `aac::Decoder` alone outputs the core rate. SBR is not reconstructed, so there is no content - above the core's Nyquist frequency. -- HE-AAC v2: parametric stereo is not reconstructed either, so the mono core decodes as mono. +- HE-AAC: `decode`, `Decoder` and `TrackDecoder` decode SBR at twice the core rate. + `aac::Decoder` outputs the core rate unless `set_sbr(true)` is called. Downsampled SBR (an + output rate equal to the core rate) plays the core alone. +- HE-AAC v2: parametric stereo is not reconstructed, so the mono core decodes as mono, with SBR. - AAC: Main, SSR and LTP profiles, coupling channels and 960-sample frames are unsupported. - Raw ADTS: frames with several raw data blocks are unsupported: a stream of them is refused, and one among other frames decodes as silence of its length and counts as damaged, as does a frame @@ -413,8 +449,8 @@ Each codec module's rustdoc lists the coding tools it implements. |---|---|---|---| | WAV, AIFF | Microsoft RIFF, Apple AIFF/AIFF-C | read: integer 8–32-bit, float 32/64-bit, `WAVE_FORMAT_EXTENSIBLE` masks, RF64/BW64, BWF `bext`, truncated files; write: 16/24/32-bit integer and 32-bit float, RF64 above 4 GiB, any layout, TPDF dither | round trips; hostile-input tests | | FLAC encoder | RFC 9639 | 16, 24, 32 bits; 1–8 channels, layouts decoders would not assume for the count as a `WAVEFORMATEXTENSIBLE_CHANNEL_MASK` comment; up to 655,350 Hz; LPC, stereo decorrelation, wasted bits, STREAMINFO MD5 | bit-exact through ffmpeg, STREAMINFO MD5 checked against ffmpeg | -| AAC (`aac`) | ISO/IEC 14496-3 | AAC-LC decode and encode (CBR, VBR, ADTS), 1–8 channels; HE-AAC: see [Limits](#limits) | ffmpeg: ~135 dB SNR; PNS noise differs per sample at equal band energy | -| Opus (`opus`) | RFC 6716, 8251, 7845 | decoder: SILK, hybrid, CELT, all frame sizes, PLC and FEC, multistream (`Decoder`) and single stream (`StreamDecoder`), 8–48 kHz output | all 36 RFC 8251 conformance cases | +| AAC (`aac`) | ISO/IEC 14496-3; SBR tables from ETSI ES 201 980 Annex I | AAC-LC decode and encode (CBR, VBR, ADTS), 1–8 channels; HE-AAC (SBR) decode, see [Limits](#limits) | ffmpeg: ~135 dB SNR; PNS noise differs per sample at equal band energy. HE-AAC from AudioToolbox, 1–6 channels: same 962-sample delay as ffmpeg, ~54 dB SNR, high band level within 0.01 dB | +| Opus (`opus`) | RFC 6716, 8251, 7845 | decoder: SILK, hybrid, CELT, all frame sizes, PLC and FEC, multistream (`Decoder`) and single stream (`StreamDecoder`), 8–48 kHz output; encoder (`Encoder`): SILK, hybrid, CELT, 48 kHz, 20 ms frames, mono or stereo | all 36 RFC 8251 conformance cases; encoder output through ffmpeg's two Opus decoders | | AC-3/E-AC-3 (`ac3`) | decoder: ATSC A/52:2012; encoder/programme API: ETSI TS 102 366 V1.4.1 | single-syncframe and complete-programme decoding; packet encoding through discrete 7.1 (see below) | decoder comparisons and encoder output through ffmpeg, CRC/channel/timing/bit-budget regressions; synthetic quality limitations below | | MP1/MP2 (`mpa`) | ISO/IEC 11172-3, 13818-3 | Layer I and II, MPEG-1/2/2.5, 8–48 kHz; also the Layer II rate/mode pairs the spec disallows (ffmpeg's default mono 384 kbit/s); no free format | ffmpeg-encoded streams, 73–84 dB SNR | | MP3 decoder (`mp3`) | ISO/IEC 11172-3, 13818-3 | Layer III, MPEG-1/2/2.5, 8–48 kHz, every bit rate, CBR and VBR; mono, stereo, dual channel, joint stereo (mid/side and intensity); long, short and mixed blocks; bit reservoir; CRC read, not checked; Xing / Info / VBRI and LAME tags; no free format (read through symphonia) | ffmpeg's float decoder: 127–131 dB SNR on LAME streams of every sampling frequency (CBR, VBR, CRC, no reservoir, transients with short blocks) and on this crate's encoder's; intensity stereo and mixed blocks through LAME frames edited to use them (112–131 dB) | @@ -582,6 +618,7 @@ the full tests with ffmpeg and the vectors required. | MP3 decoder | written here, from ISO/IEC 11172-3 and 13818-3; shares the encoder's tables and the MP1/MP2 synthesis filter | | MP3 encoder | FilmCraft `filmcraft-mp3enc` [4bec22b](https://github.com/storytold/filmcraft/commit/4bec22bd03bd1d995c9cf72e02a284f96d3d72cd) | | ALAC | Apple's reference implementation [macosforge/alac@c38887c](https://github.com/macosforge/alac/tree/c38887c5c5e64a4b31108733bd79ca9b2496d987/codec), Copyright © 2011 Apple Inc., Apache-2.0; ported to Rust and modified (see the `alac` module docs and [NOTICE](../../NOTICE)) | +| Opus encoder | EffectCraft `effectcraft-opusenc` [c618429](https://github.com/storytold/effectcraft/commit/c618429252587525a2a6d4b5bccbe4ec5261e1b9) | Where apps had overlapping implementations, the one that measured better was kept, with the tests of both: @@ -598,7 +635,8 @@ For the apps switching from their own code; to be removed once all three have sw The codec modules' public items keep their names, so apps can re-export them (`pub use craft_audio::aac::*;`). Names kept only for that are `#[doc(hidden)]`: -`ac3::Decoded` (now `ac3::Frame`), `aac::Upsample2x`, `aac::ProgramConfig::parse`, +`ac3::Decoded` (now `ac3::Frame`), `opus::encoder::OpusEncoder` (now `opus::Encoder`), +`aac::Upsample2x`, `aac::ProgramConfig::parse`, `aac::config_layout` and `aac::sample_rate_index`. What else an app adjusts: - `AudioInfo` has `container`, `codec`, `layout` and `damaged` instead of SoundCraft's `format`. @@ -624,8 +662,10 @@ The codec modules' public items keep their names, so apps can re-export them - `TrackInfo::sample_rate` and `channels` are `Option`s. - Features are per codec. SoundCraft's `encode` (WAV, AIFF, FLAC) is now `lossless-encode`, made of `pcm-encode` (WAV, AIFF) and `flac-encode`. `encode` turns on every encoder, including AAC, - ALAC and AC-3/E-AC-3. Writing a format whose feature is off returns `AudioError::Unsupported`. + ALAC, AC-3/E-AC-3 and Opus. Writing a format whose feature is off returns `AudioError::Unsupported`. - AAC encoder settings it cannot use are `aac::Error::InvalidSettings` (`AudioError::Encode`). +- The Opus encoder's constructors and `encode_float` return `Result` instead of panicking on an + invalid channel count, mode/bandwidth or frame length. - `TrackDecoder::decode` returns an error for a corrupt Opus packet, as for the other codecs; call `conceal`. - `FileEncoder::write` takes any per-channel slices, but refuses a block with missing channels or diff --git a/crates/audio/src/aac/decoder.rs b/crates/audio/src/aac/decoder.rs index 8c4d41300..825ebb23a 100644 --- a/crates/audio/src/aac/decoder.rs +++ b/crates/audio/src/aac/decoder.rs @@ -6,6 +6,7 @@ use crate::aac::config::{AudioSpecificConfig, ElementType, ProgramConfig}; use crate::aac::huffman::{self, INTENSITY_HCB, INTENSITY_HCB2, NOISE_HCB, ZERO_HCB}; use crate::aac::ics::{IcsInfo, WindowSequence}; use crate::aac::mdct::{MdctScratch, WindowShape, mdct_long, mdct_short, window}; +use crate::aac::sbr; use crate::aac::tables::{pow43, pow43_table, sf_gain}; use crate::aac::tns::{self, TnsData}; use crate::aac::{Error, Result}; @@ -63,7 +64,14 @@ impl Scratch { } } -/// AAC-LC decoder. +/// Where the SBR data of a channel element sits in its access unit: the bit range of the fill +/// element's payload after its extension type, and whether a CRC leads it. +type SbrRef = (usize, usize, bool); + +/// An access unit's core output, and the SBR data of each layout entry. +type Decoded = (Vec>, Vec>); + +/// AAC-LC decoder, with the SBR tool of HE-AAC when [`Decoder::set_sbr`] turns it on. #[derive(Clone)] pub struct Decoder { asc: AudioSpecificConfig, @@ -75,6 +83,11 @@ pub struct Decoder { scratch: Scratch, /// A fill element carried SBR data (implicitly signalled HE-AAC). sbr_seen: bool, + /// The SBR decoder of each layout entry, when output is at twice the core rate. + sbr_out: Option>, + /// The layout `sbr_out` was built for. Kept apart from `layout`, so that an access unit whose + /// new program config fails (and whose layout is put back) leaves the SBR history alone. + sbr_layout: Vec<(ElementType, u8)>, } impl Decoder { @@ -110,6 +123,8 @@ impl Decoder { noise_state: NOISE_SEED, scratch: Scratch::new(), sbr_seen: false, + sbr_out: None, + sbr_layout: Vec::new(), }; d.set_layout(layout); Ok(d) @@ -131,16 +146,27 @@ impl Decoder { pub fn config(&self) -> &AudioSpecificConfig { &self.asc } - /// Output sampling rate in Hz: the AAC-LC core rate. + /// Output sampling rate in Hz: the AAC-LC core rate, or twice it with [`Self::set_sbr`] on. pub fn sample_rate(&self) -> u32 { - self.asc.sample_rate + if self.sbr_out.is_some() { self.asc.sample_rate.saturating_mul(2) } else { self.asc.sample_rate } } /// Whether the stream is HE-AAC: SBR signalled in the `AudioSpecificConfig` (explicit) or SBR - /// data seen in a fill element of a decoded access unit (implicit). Only the AAC-LC core is - /// decoded, at [`Self::sample_rate`]; the SBR high band is not reconstructed. + /// data seen in a fill element of a decoded access unit (implicit). Without [`Self::set_sbr`], + /// only the AAC-LC core is decoded. pub fn sbr(&self) -> bool { self.asc.sbr || self.sbr_seen } + /// Decode HE-AAC at twice the core rate (`true`) or the AAC-LC core alone (`false`, the + /// default). With SBR on, [`Self::decode`] returns 2048 samples per channel: the core through + /// the SBR filter banks, delayed by [`SBR_DELAY`](crate::aac::SBR_DELAY) samples, with the high + /// band rebuilt from each frame's SBR data. Until the stream's first SBR header, and for a + /// frame whose SBR data is missing or damaged, the high band is empty. Parametric stereo + /// (HE-AAC v2) is not decoded: its mono core stays mono. + pub fn set_sbr(&mut self, on: bool) { + if on != self.sbr_out.is_some() { + self.sbr_out = on.then(Vec::new); + } + } /// Output channels of the current element layout. pub fn channels(&self) -> usize { self.channels.len() @@ -158,30 +184,61 @@ impl Decoder { c.overlap.fill(0.0); c.prev_shape = WindowShape::Sine; } + for e in self.sbr_out.iter_mut().flatten() { + e.reset(); + } } fn sf_index(&self) -> u8 { self.asc.sf_index } - /// Decode one access unit (one `raw_data_block`) into 1024 samples per channel. An access unit - /// that fails leaves the decoder as it was, but for [`Self::sbr`]: its damage, including a damaged - /// program config element, does not reach the access units after it. + /// Decode one access unit (one `raw_data_block`) into 1024 samples per channel (2048 with + /// [`Self::set_sbr`] on). An access unit that fails leaves the decoder as it was, but for + /// [`Self::sbr`]: its damage, including a damaged program config element, does not reach the + /// access units after it. Damaged SBR data alone does not fail it. pub fn decode(&mut self, au: &[u8]) -> Result>> { let before = (self.asc.pce.clone(), self.layout.clone(), self.offsets.clone(), self.channels.clone(), self.noise_state); - let decoded = self.decode_block(au, true); - if decoded.is_err() { - (self.asc.pce, self.layout, self.offsets, self.channels, self.noise_state) = before; + match self.decode_block(au, true) { + Ok((pcm, sbr_refs)) => Ok(self.apply_sbr(au, pcm, &sbr_refs)), + Err(e) => { + (self.asc.pce, self.layout, self.offsets, self.channels, self.noise_state) = before; + Err(e) + } } - decoded } - fn decode_block(&mut self, au: &[u8], allow_reconfig: bool) -> Result>> { + /// Run each layout entry's channels through its SBR decoder, when SBR is on. + fn apply_sbr(&mut self, au: &[u8], pcm: Vec>, refs: &[Option]) -> Vec> { + let rate = self.sample_rate(); + let Some(elements) = self.sbr_out.as_mut() else { + return pcm; + }; + // a new layout (a program config element) starts the SBR decoders afresh: their history + // belongs to the old element order, even where the channel counts match + if elements.len() != self.layout.len() || self.sbr_layout != self.layout { + *elements = self.layout.iter().map(|l| sbr::Element::new(l.0.channels(), rate)).collect(); + self.sbr_layout.clone_from(&self.layout); + } + let mut out = Vec::with_capacity(pcm.len()); + for (i, e) in elements.iter_mut().enumerate() { + let first = self.offsets.get(i).copied().unwrap_or(0); + let core = pcm.get(first..first + e.channels()).unwrap_or_default(); + let payload = refs.get(i).copied().flatten().map(|(start, end, crc)| sbr::Payload { data: au, start, end, crc }); + out.extend(e.process(core, payload)); + } + out + } + + fn decode_block(&mut self, au: &[u8], allow_reconfig: bool) -> Result { let mut br = BitReader::new(au); let nch = self.channels.len(); let mut saw_element = false; let mut out: Vec>> = vec![None; nch]; let mut used = vec![false; self.layout.len()]; + // SBR data belongs to the single channel or pair element before its fill element + let mut sbr_refs: Vec> = vec![None; self.layout.len()]; + let mut last_slot = None; loop { let id = br.read_bits(3)?; saw_element |= matches!(id, ID_SCE | ID_CPE | ID_LFE); @@ -191,6 +248,7 @@ impl Decoder { let ty = if id == ID_SCE { ElementType::Sce } else { ElementType::Lfe }; let slot = self.find_slot(ty, tag, &used)?; used[slot] = true; + last_slot = (id == ID_SCE).then_some(slot); let mut ics = self.decode_ics(&mut br, None)?; let ch = self.offsets[slot]; self.fill_noise(&mut ics, None); @@ -200,6 +258,7 @@ impl Decoder { let tag = br.read_bits(4)? as u8; let slot = self.find_slot(ElementType::Cpe, tag, &used)?; used[slot] = true; + last_slot = Some(slot); let ch = self.offsets[slot]; let [a, b] = self.decode_cpe(&mut br, ch)?; out[ch] = Some(a); @@ -237,8 +296,13 @@ impl Decoder { } if count > 0 { let ext = br.read_bits(4)?; - self.sbr_seen |= matches!(ext, EXT_SBR_DATA | EXT_SBR_DATA_CRC); + let is_sbr = matches!(ext, EXT_SBR_DATA | EXT_SBR_DATA_CRC); + self.sbr_seen |= is_sbr; + let start = br.position(); br.skip(count * 8 - 4)?; + if let (true, Some(slot)) = (is_sbr, last_slot.take()) { + sbr_refs[slot] = Some((start, br.position(), ext == EXT_SBR_DATA_CRC)); + } } } _ => break, // ID_END @@ -251,7 +315,7 @@ impl Decoder { for o in out { res.push(o.ok_or(Error::Bitstream("access unit is missing a channel element"))?); } - Ok(res) + Ok((res, sbr_refs)) } fn find_slot(&self, ty: ElementType, tag: u8, used: &[bool]) -> Result { @@ -637,6 +701,85 @@ mod tests { assert_eq!(dec.sample_rate(), 22_050); } + /// With SBR on, an access unit decodes to 2048 samples, and over a silent core the high band + /// its SBR fill element describes is there; with SBR off, the core alone (silence). + #[test] + fn sbr_fill_elements_rebuild_the_high_band() { + let au = sbr_au(0); + // AAC-LC, 22.05 kHz, mono + let asc = AudioSpecificConfig::lc(7, 1); + let mut dec = Decoder::from_config(asc.clone()).unwrap(); + let core = dec.decode(&au).unwrap(); + assert_eq!((core[0].len(), dec.sample_rate()), (1024, 22_050)); + assert!(dec.sbr() && core[0].iter().all(|&s| s == 0.0)); + let mut dec = Decoder::from_config(asc).unwrap(); + dec.set_sbr(true); + assert_eq!(dec.sample_rate(), 44_100); + let mut out = Vec::new(); + for _ in 0..4 { + out.extend(dec.decode(&au).unwrap().remove(0)); + } + assert_eq!(out.len(), 4 * 2048); + let rms = (out[2048..].iter().map(|s| s * s).sum::() / (out.len() - 2048) as f32).sqrt(); + assert!(rms > 1e-4, "{rms}"); + } + + /// A program config that changes the layout restarts the SBR decoders, even where the + /// channel counts match, and one in an access unit that fails leaves their history alone: the + /// access units after it decode as if it had not been there. + #[test] + fn a_new_layout_restarts_sbr_and_a_failed_one_keeps_it() { + let au = sbr_au(0); + let one = ProgramConfig { front: vec![(false, 0)], ..Default::default() }; + let six = ProgramConfig { front: vec![(false, 0), (true, 0)], back: vec![(true, 1)], lfe: vec![0], ..Default::default() }; + let decoder = || { + let mut d = Decoder::from_config(AudioSpecificConfig { pce: Some(one.clone()), ..AudioSpecificConfig::lc(7, 0) }).unwrap(); + d.set_sbr(true); + d + }; + let (mut kept, mut failed) = (decoder(), decoder()); + for _ in 0..3 { + assert_eq!(kept.decode(&au).unwrap(), failed.decode(&au).unwrap()); + } + // the new layout's elements are missing from this access unit + assert!(failed.decode(&silent_au(Some(&six), &[(ID_SCE, 0)])).is_err()); + for _ in 0..2 { + assert_eq!(kept.decode(&au).unwrap(), failed.decode(&au).unwrap()); + } + // the same channel count under another tag is a new layout: the SBR decoder starts over, + // as in a decoder that had that layout from the start + let tag1 = ProgramConfig { front: vec![(false, 1)], ..Default::default() }; + let mut fresh = Decoder::from_config(AudioSpecificConfig { pce: Some(tag1.clone()), ..AudioSpecificConfig::lc(7, 0) }).unwrap(); + fresh.set_sbr(true); + kept.set_layout(tag1.layout()); + assert_eq!(kept.decode(&sbr_au(1)).unwrap(), fresh.decode(&sbr_au(1)).unwrap()); + } + + /// One access unit of SCE `tag` with an SBR fill element of [`sbr::test_payload`] (44.1 kHz + /// output). + fn sbr_au(tag: u32) -> Vec { + let payload = sbr::test_payload(44_100, |_| 40, |_| 0).unwrap(); + let mut bw = BitWriter::new(); + bw.write_bits(ID_SCE, 3); + bw.write_bits(tag, 4); + bw.write_bits(0, 8 + 11 + 3); // global_gain, ics_info (max_sfb 0), no pulse/TNS/gain control + bw.write_bits(ID_FIL, 3); + let count = (4 + 8 * payload.len()).div_ceil(8); + if count < 15 { + bw.write_bits(count as u32, 4); + } else { + bw.write_bits(15, 4); + bw.write_bits((count - 14) as u32, 8); + } + bw.write_bits(EXT_SBR_DATA, 4); + for &b in &payload { + bw.write_bits(u32::from(b), 8); + } + bw.write_bits(0, 4); + bw.write_bits(ID_END, 3); + bw.finish() + } + /// An access unit whose elements are all empty: `elements` are (id, tag), then ID_END. fn silent_au(pce: Option<&ProgramConfig>, elements: &[(u32, u32)]) -> Vec { let mut bw = BitWriter::new(); diff --git a/crates/audio/src/aac/huffman.rs b/crates/audio/src/aac/huffman.rs index adac5c9c9..0c29673e7 100644 --- a/crates/audio/src/aac/huffman.rs +++ b/crates/audio/src/aac/huffman.rs @@ -62,12 +62,12 @@ const SUB: u32 = 1 << 31; /// entry with `SUB` set is `SUB | offset << 5 | width` and selects a sub-table of `1 << width` /// leaves indexed by the next `width` bits. An entry of 0 is a bit pattern that no codeword starts /// with (the codebooks are complete, so this does not occur). -struct Lut { +pub(crate) struct Lut { t: Vec, } impl Lut { - fn build(codes: &[(u32, u8)]) -> Lut { + pub(crate) fn build(codes: &[(u32, u8)]) -> Lut { let mut t = vec![0u32; 1 << L1]; // sub-table width per first-level prefix let mut width = vec![0u32; 1 << L1]; @@ -103,7 +103,7 @@ impl Lut { /// The symbol of the codeword at the reader position; `Eof` if the codeword is cut off. #[inline] - fn decode(&self, br: &mut BitReader) -> Result { + pub(crate) fn decode(&self, br: &mut BitReader) -> Result { let bits = br.peek(32); let mut e = self.t[(bits >> (32 - L1)) as usize]; if e & SUB != 0 { diff --git a/crates/audio/src/aac/mod.rs b/crates/audio/src/aac/mod.rs index 0a5f639a4..297aa7972 100644 --- a/crates/audio/src/aac/mod.rs +++ b/crates/audio/src/aac/mod.rs @@ -5,13 +5,13 @@ //! model, window switching, M/S, TNS, bit reservoir, CBR/VBR, 1–8 channels. //! - [`Decoder`] (feature `aac-decode`): `AudioSpecificConfig` + raw access units → planar f32. //! Supports M/S, intensity stereo, PNS, TNS, pulse data, both window shapes and every window -//! sequence. +//! sequence, and the SBR tool of HE-AAC ([`Decoder::set_sbr`]). //! - [`latm_config`] / [`latm_split`] (feature `aac-decode`): the configuration and payload of //! LOAS / LATM frames (AAC in MPEG transport streams), for the decoder. //! -//! HE-AAC (SBR/PS), Main/SSR/LTP profiles, coupling channels and 960-sample frames are not -//! supported; for HE-AAC only the AAC-LC core is decoded (at the core rate; [`Decoder::sbr`] says -//! when). +//! Parametric stereo (HE-AAC v2), Main/SSR/LTP profiles, coupling channels and 960-sample frames +//! are not supported. HE-AAC decodes at twice the core rate with [`Decoder::set_sbr`] on +//! ([`Decoder::sbr`] says when a stream uses SBR), and as its AAC-LC core otherwise. //! //! From FilmCraft's `filmcraft-aac` (storytold/filmcraft@8fda2f2). Public items keep their names, //! so FilmCraft can re-export this module. @@ -53,6 +53,8 @@ mod ics; #[cfg(feature = "aac-decode")] mod latm; mod mdct; +#[cfg(feature = "aac-decode")] +mod sbr; mod tables; mod tns; @@ -68,6 +70,12 @@ pub use latm::{LatmConfig, latm_config, latm_split}; pub use mdct::WindowShape; pub use tables::{SAMPLE_RATES, sample_rate_index}; +/// Output samples by which [`Decoder`] with [`Decoder::set_sbr`] on delays the core: the SBR filter +/// banks and the SBR frame's lag behind the core frame. HE-AAC encoders count it in the priming the +/// container signals. +#[cfg(feature = "aac-decode")] +pub const SBR_DELAY: usize = sbr::DELAY; + /// Samples per AAC-LC frame (per channel). pub const FRAME_LEN: usize = 1024; @@ -197,9 +205,9 @@ const UPSAMPLE_DELAY: usize = 481; #[cfg(feature = "aac-decode")] const UPSAMPLE_HISTORY: usize = UPSAMPLE_DELAY + UPSAMPLE_HALF; -/// 2× upsampler for HE-AAC. The decoder reconstructs the AAC-LC core at half the output rate and -/// not yet the SBR high band, so the core is interpolated to the output rate: timing and pitch are -/// right and the band above the core's Nyquist stays empty. Even outputs are the input samples, +/// 2× upsampler: interpolates an AAC-LC core to twice its rate, leaving the band above the core's +/// Nyquist empty. [`Decoder::set_sbr`] decodes HE-AAC with its high band instead; this stays for +/// callers that want the core alone at the output rate. Even outputs are the input samples, /// odd ones a Blackman-windowed sinc half-band interpolation over 2 × `UPSAMPLE_HALF` inputs. /// The output is delayed by `UPSAMPLE_DELAY` input samples, as an SBR decoder's is; the state /// carries across packets, so a seek primed with the preceding packet gives the same samples as diff --git a/crates/audio/src/aac/sbr/bitstream.rs b/crates/audio/src/aac/sbr/bitstream.rs new file mode 100644 index 000000000..0e8e70c3a --- /dev/null +++ b/crates/audio/src/aac/sbr/bitstream.rs @@ -0,0 +1,493 @@ +//! The SBR bitstream (ISO/IEC 14496-3 §4.4.2.8 syntax, §4.5.2.8 semantics): the header and the +//! single and channel pair elements of an `EXT_SBR_DATA` fill element. + +use std::sync::OnceLock; + +use craft_media::bitstream::BitReader; + +use super::freq::FreqTables; +use super::tables::{ + F_ENV_1_5DB, F_ENV_3_0DB, F_ENV_BAL_1_5DB, F_ENV_BAL_3_0DB, T_ENV_1_5DB, T_ENV_3_0DB, T_ENV_BAL_1_5DB, T_ENV_BAL_3_0DB, T_NOISE_3_0DB, + T_NOISE_BAL_3_0DB, +}; +use super::{Header, MAX_ENV}; +use crate::aac::huffman::Lut; +use crate::aac::{Error, Result}; + +/// `bs_frame_class`: fixed leading and trailing frame borders. +pub(crate) const FIXFIX: u8 = 0; +/// Fixed leading border, variable trailing border. +pub(crate) const FIXVAR: u8 = 1; +/// Variable leading border, fixed trailing border. +pub(crate) const VARFIX: u8 = 2; +/// Variable leading and trailing borders. +pub(crate) const VARVAR: u8 = 3; + +/// SBR time slots per frame (`numTimeSlots`; two QMF slots each). +const TIME_SLOTS: usize = 16; + +/// Decoding tables for the SBR Huffman codes, in [`Book`] order. +fn luts() -> &'static [Lut; 10] { + static LUTS: OnceLock<[Lut; 10]> = OnceLock::new(); + LUTS.get_or_init(|| { + [ + Lut::build(&T_ENV_1_5DB), + Lut::build(&F_ENV_1_5DB), + Lut::build(&T_ENV_BAL_1_5DB), + Lut::build(&F_ENV_BAL_1_5DB), + Lut::build(&T_ENV_3_0DB), + Lut::build(&F_ENV_3_0DB), + Lut::build(&T_ENV_BAL_3_0DB), + Lut::build(&F_ENV_BAL_3_0DB), + Lut::build(&T_NOISE_3_0DB), + Lut::build(&T_NOISE_BAL_3_0DB), + ] + }) +} + +/// One of the SBR Huffman codes, and its largest absolute value. +#[derive(Clone, Copy)] +struct Book(usize, i32); + +const T_ENV_15: Book = Book(0, 60); +const F_ENV_15: Book = Book(1, 60); +const T_BAL_15: Book = Book(2, 24); +const F_BAL_15: Book = Book(3, 24); +const T_ENV_30: Book = Book(4, 31); +const F_ENV_30: Book = Book(5, 31); +const T_BAL_30: Book = Book(6, 12); +const F_BAL_30: Book = Book(7, 12); +const T_NOISE: Book = Book(8, 31); +const T_NOISE_BAL: Book = Book(9, 12); + +fn huff(br: &mut BitReader, book: Book) -> Result { + Ok(luts()[book.0].decode(br)? as i32 - book.1) +} + +fn bits(br: &mut BitReader, n: u32) -> Result { + Ok(br.read_bits(n)? as usize) +} + +impl Header { + /// `sbr_header()`, with the defaults of the fields its extra parts leave out. + pub(crate) fn read(br: &mut BitReader) -> Result
{ + let amp_res = br.read_flag()?; + let start_freq = br.read_bits(4)? as u8; + let stop_freq = br.read_bits(4)? as u8; + let xover_band = br.read_bits(3)? as u8; + br.skip(2)?; // bs_reserved + let (extra_1, extra_2) = (br.read_flag()?, br.read_flag()?); + let mut h = Header { + amp_res, + start_freq, + stop_freq, + xover_band, + freq_scale: 2, + alter_scale: true, + noise_bands: 2, + limiter_bands: 2, + limiter_gains: 2, + interpol_freq: true, + smoothing_mode: true, + }; + if extra_1 { + h.freq_scale = br.read_bits(2)? as u8; + h.alter_scale = br.read_flag()?; + h.noise_bands = br.read_bits(2)? as u8; + } + if extra_2 { + h.limiter_bands = br.read_bits(2)? as u8; + h.limiter_gains = br.read_bits(2)? as u8; + h.interpol_freq = br.read_flag()?; + h.smoothing_mode = br.read_flag()?; + } + Ok(h) + } +} + +/// The time/frequency grid of one channel in one frame (`sbr_grid()`, §4.6.18.3.3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Grid { + pub(crate) class: u8, + /// Envelopes (1 to 5). + pub(crate) num_env: usize, + /// Envelope borders in time slots; `t_env[num_env]` may reach past the frame (up to 19). + pub(crate) t_env: [usize; MAX_ENV + 1], + /// Noise floors (1 or 2). + pub(crate) num_noise: usize, + /// Noise floor borders. + pub(crate) t_noise: [usize; 3], + /// High frequency resolution per envelope. + pub(crate) freq_res: [bool; MAX_ENV], + /// The transient envelope `l_A`, if any (it may be `num_env`: the next frame's first). + pub(crate) la: Option, + /// 3 dB envelope steps (the header's choice, but 1.5 dB for a single FIXFIX envelope). + pub(crate) amp_res: bool, +} + +/// `ceil(log2(num_env + 1))`: the width of `bs_pointer`. +fn pointer_bits(num_env: usize) -> u32 { + usize::BITS - num_env.leading_zeros() +} + +fn read_rel(br: &mut BitReader, n: usize) -> Result<[usize; 3]> { + let mut rel = [0; 3]; + for r in rel.iter_mut().take(n) { + *r = 2 * bits(br, 2)? + 2; + } + Ok(rel) +} + +impl Grid { + fn read(br: &mut BitReader, amp_res: bool) -> Result { + let bad = || Error::Bitstream("invalid SBR time grid"); + let class = br.read_bits(2)? as u8; + let mut t = [0i64; MAX_ENV + 1]; + let mut freq_res = [false; MAX_ENV]; + let (n, pointer) = match class { + FIXFIX => { + let n = 1usize << br.read_bits(2)?; + if n > 4 { + return Err(bad()); + } + freq_res = [br.read_flag()?; MAX_ENV]; + for (l, t) in t.iter_mut().enumerate().take(n + 1) { + *t = (l * TIME_SLOTS / n) as i64; + } + (n, 0) + } + FIXVAR | VARFIX => { + let abs = bits(br, 2)?; + let rels = bits(br, 2)?; + let n = rels + 1; + let rel = read_rel(br, rels)?; + let pointer = bits(br, pointer_bits(n))?; + for l in 0..n { + freq_res[if class == FIXVAR { n - 1 - l } else { l }] = br.read_flag()?; + } + if class == FIXVAR { + t[n] = (abs + TIME_SLOTS) as i64; + for l in (1..n).rev() { + t[l] = t[l + 1] - rel[n - 1 - l] as i64; + } + } else { + (t[0], t[n]) = (abs as i64, TIME_SLOTS as i64); + for l in 1..n { + t[l] = t[l - 1] + rel[l - 1] as i64; + } + } + (n, pointer) + } + _ => { + let abs0 = bits(br, 2)?; + let abs1 = bits(br, 2)? + TIME_SLOTS; + let (rels0, rels1) = (bits(br, 2)?, bits(br, 2)?); + let n = rels0 + rels1 + 1; + if n > MAX_ENV { + return Err(bad()); + } + let (rel0, rel1) = (read_rel(br, rels0)?, read_rel(br, rels1)?); + let pointer = bits(br, pointer_bits(n))?; + for r in freq_res.iter_mut().take(n) { + *r = br.read_flag()?; + } + (t[0], t[n]) = (abs0 as i64, abs1 as i64); + for l in 1..=rels0 { + t[l] = t[l - 1] + rel0[l - 1] as i64; + } + for l in (rels0 + 1..n).rev() { + t[l] = t[l + 1] - rel1[n - 1 - l] as i64; + } + (n, pointer) + } + }; + if t[0] < 0 || t[..=n].windows(2).any(|w| w[0] >= w[1]) { + return Err(bad()); + } + // l_A: in ISO/IEC 14496-3 a VARFIX pointer of 1, like 0, marks no transient envelope + let la = match (pointer, class) { + (0, _) | (1, VARFIX) => None, + (p, FIXVAR | VARVAR) => Some((n + 1).checked_sub(p).ok_or_else(bad)?), + (p, _) => Some(p - 1), + }; + if la.is_some_and(|la| la > n) { + return Err(bad()); + } + let t_env: [usize; MAX_ENV + 1] = std::array::from_fn(|i| t[i] as usize); + let (num_noise, t_noise) = if n == 1 { + (1, [t_env[0], t_env[1], 0]) + } else { + let middle = match (class, pointer) { + (FIXFIX, _) => n / 2, + (VARFIX, 0) => 1, + (VARFIX, 1) => n - 1, + (VARFIX, p) => p - 1, + (_, 0 | 1) => n - 1, + (_, p) => (n + 1).checked_sub(p).ok_or_else(bad)?, + }; + if middle == 0 || middle >= n { + return Err(bad()); + } + (2, [t_env[0], t_env[middle], t_env[n]]) + }; + Ok(Grid { class, num_env: n, t_env, num_noise, t_noise, freq_res, la, amp_res: amp_res && !(class == FIXFIX && n == 1) }) + } +} + +/// The data of one channel as transmitted: delta-coded envelope and noise floor values (the +/// first frequency-coded value of each is absolute), not yet scaled for a balance channel. +#[derive(Clone, Debug)] +pub(crate) struct ChannelData { + pub(crate) grid: Grid, + pub(crate) df_env: [bool; MAX_ENV], + pub(crate) df_noise: [bool; 2], + pub(crate) invf: [u8; 5], + pub(crate) env: [[i32; 64]; MAX_ENV], + pub(crate) noise: [[i32; 5]; 2], + /// `bs_add_harmonic` per high-resolution band, when `bs_add_harmonic_flag` is set. + pub(crate) harmonic: Option<[bool; 64]>, +} + +impl ChannelData { + fn new(grid: Grid) -> ChannelData { + ChannelData { + grid, + df_env: [false; MAX_ENV], + df_noise: [false; 2], + invf: [0; 5], + env: [[0; 64]; MAX_ENV], + noise: [[0; 5]; 2], + harmonic: None, + } + } + + fn read_dtdf(&mut self, br: &mut BitReader) -> Result<()> { + for d in self.df_env.iter_mut().take(self.grid.num_env) { + *d = br.read_flag()?; + } + for d in self.df_noise.iter_mut().take(self.grid.num_noise) { + *d = br.read_flag()?; + } + Ok(()) + } + + fn read_invf(&mut self, br: &mut BitReader, t: &FreqTables) -> Result<()> { + for m in self.invf.iter_mut().take(t.n_q()) { + *m = br.read_bits(2)? as u8; + } + Ok(()) + } + + /// `sbr_envelope()`; `balance` for the second channel of a coupled pair. + fn read_envelope(&mut self, br: &mut BitReader, t: &FreqTables, balance: bool) -> Result<()> { + let (t_book, f_book, start_bits) = match (self.grid.amp_res, balance) { + (false, false) => (T_ENV_15, F_ENV_15, 7), + (false, true) => (T_BAL_15, F_BAL_15, 6), + (true, false) => (T_ENV_30, F_ENV_30, 6), + (true, true) => (T_BAL_30, F_BAL_30, 5), + }; + for l in 0..self.grid.num_env { + let n = t.bands(self.grid.freq_res[l]).len() - 1; + let row = &mut self.env[l]; + if self.df_env[l] { + for v in row.iter_mut().take(n) { + *v = huff(br, t_book)?; + } + } else { + row[0] = br.read_bits(start_bits)? as i32; + for v in row.iter_mut().take(n).skip(1) { + *v = huff(br, f_book)?; + } + } + } + Ok(()) + } + + /// `sbr_noise()`. + fn read_noise(&mut self, br: &mut BitReader, t: &FreqTables, balance: bool) -> Result<()> { + let (t_book, f_book) = if balance { (T_NOISE_BAL, F_BAL_30) } else { (T_NOISE, F_ENV_30) }; + for l in 0..self.grid.num_noise { + let row = &mut self.noise[l]; + if self.df_noise[l] { + for v in row.iter_mut().take(t.n_q()) { + *v = huff(br, t_book)?; + } + } else { + row[0] = br.read_bits(5)? as i32; + for v in row.iter_mut().take(t.n_q()).skip(1) { + *v = huff(br, f_book)?; + } + } + } + Ok(()) + } + + /// `bs_add_harmonic_flag` and `sbr_sinusoidal_coding()`. + fn read_harmonics(&mut self, br: &mut BitReader, t: &FreqTables) -> Result<()> { + if br.read_flag()? { + let mut h = [false; 64]; + for v in h.iter_mut().take(t.n_high()) { + *v = br.read_flag()?; + } + self.harmonic = Some(h); + } + Ok(()) + } +} + +/// Skip `bs_extended_data` (parametric stereo, for one, is not decoded). +fn skip_extended(br: &mut BitReader) -> Result<()> { + if br.read_flag()? { + let mut count = bits(br, 4)?; + if count == 15 { + count += bits(br, 8)?; + } + br.skip(8 * count)?; + } + Ok(()) +} + +/// The SBR data of one channel element. +#[derive(Clone, Debug)] +pub(crate) struct Frame { + /// The pair codes its second channel as a balance against the first. + pub(crate) coupling: bool, + pub(crate) channels: Vec, +} + +/// `sbr_single_channel_element()` (`channels` 1) or `sbr_channel_pair_element()` (2). +pub(crate) fn read_data(br: &mut BitReader, t: &FreqTables, amp_res: bool, channels: usize) -> Result { + if br.read_flag()? { + br.skip(4 * channels)?; // bs_reserved + } + if channels == 1 { + let mut c = ChannelData::new(Grid::read(br, amp_res)?); + c.read_dtdf(br)?; + c.read_invf(br, t)?; + c.read_envelope(br, t, false)?; + c.read_noise(br, t, false)?; + c.read_harmonics(br, t)?; + skip_extended(br)?; + return Ok(Frame { coupling: false, channels: vec![c] }); + } + let coupling = br.read_flag()?; + let (mut a, mut b); + if coupling { + a = ChannelData::new(Grid::read(br, amp_res)?); + b = ChannelData::new(a.grid.clone()); + a.read_dtdf(br)?; + b.read_dtdf(br)?; + a.read_invf(br, t)?; + b.invf = a.invf; + a.read_envelope(br, t, false)?; + a.read_noise(br, t, false)?; + b.read_envelope(br, t, true)?; + b.read_noise(br, t, true)?; + } else { + a = ChannelData::new(Grid::read(br, amp_res)?); + b = ChannelData::new(Grid::read(br, amp_res)?); + a.read_dtdf(br)?; + b.read_dtdf(br)?; + a.read_invf(br, t)?; + b.read_invf(br, t)?; + a.read_envelope(br, t, false)?; + b.read_envelope(br, t, false)?; + a.read_noise(br, t, false)?; + b.read_noise(br, t, false)?; + } + a.read_harmonics(br, t)?; + b.read_harmonics(br, t)?; + skip_extended(br)?; + Ok(Frame { coupling, channels: vec![a, b] }) +} + +#[cfg(test)] +mod tests { + use craft_media::bitstream::BitWriter; + + use super::*; + + /// Every SBR Huffman table is a complete prefix code, with the shortest codeword for a zero + /// delta, and its decoder reads back every codeword. + #[test] + fn huffman_tables_are_complete_prefix_codes() { + let tables: [&[(u32, u8)]; 10] = [ + &T_ENV_1_5DB, + &F_ENV_1_5DB, + &T_ENV_BAL_1_5DB, + &F_ENV_BAL_1_5DB, + &T_ENV_3_0DB, + &F_ENV_3_0DB, + &T_ENV_BAL_3_0DB, + &F_ENV_BAL_3_0DB, + &T_NOISE_3_0DB, + &T_NOISE_BAL_3_0DB, + ]; + let lavs = [60, 60, 24, 24, 31, 31, 12, 12, 31, 12]; + for (i, (codes, lav)) in tables.iter().zip(lavs).enumerate() { + assert_eq!(codes.len(), 2 * lav + 1, "table {i}"); + let max = u32::from(codes.iter().map(|c| c.1).max().unwrap()); + assert_eq!(codes.iter().map(|&(_, l)| 1u64 << (max - u32::from(l))).sum::(), 1 << max, "table {i}: Kraft sum"); + let shortest = codes.iter().map(|c| c.1).min().unwrap(); + assert_eq!(codes[lav].1, shortest, "table {i}"); + for (sym, &(code, len)) in codes.iter().enumerate() { + let mut bw = BitWriter::new(); + bw.write_bits(code, u32::from(len)); + bw.write_bits(0x5A5A5A, 24); + let data = bw.finish(); + let mut br = BitReader::new(&data); + assert_eq!(huff(&mut br, Book(i, lav as i32)).unwrap(), sym as i32 - lav as i32, "table {i}"); + assert_eq!(br.position(), usize::from(len)); + } + } + } + + fn grid(bits: &[(u32, u32)]) -> Result { + let mut bw = BitWriter::new(); + for &(v, n) in bits { + bw.write_bits(v, n); + } + bw.write_bits(0, 16); + let data = bw.finish(); + Grid::read(&mut BitReader::new(&data), true) + } + + /// The four frame classes give the borders, noise floors and transient envelope of + /// §4.6.18.3.3; a FIXFIX frame of one envelope uses 1.5 dB steps. + #[test] + fn grids_of_each_frame_class() { + // FIXFIX, 4 envelopes, high resolution + let g = grid(&[(FIXFIX as u32, 2), (2, 2), (1, 1)]).unwrap(); + assert_eq!((g.num_env, &g.t_env[..5], g.num_noise, g.t_noise, g.la, g.amp_res), (4, &[0, 4, 8, 12, 16][..], 2, [0, 8, 16], None, true)); + let g = grid(&[(FIXFIX as u32, 2), (0, 2), (0, 1)]).unwrap(); + assert_eq!((g.num_env, g.t_noise, g.amp_res), (1, [0, 16, 0], false)); + // FIXVAR: trailing border 16 + 2, two relative borders (4, 2) back from it, pointer 1, + // resolutions sent last envelope first + let g = grid(&[(FIXVAR as u32, 2), (2, 2), (2, 2), (1, 2), (0, 2), (1, 2), (0, 1), (1, 1), (1, 1)]).unwrap(); + assert_eq!((g.num_env, &g.t_env[..4]), (3, &[0, 12, 14, 18][..])); + assert_eq!((g.la, g.t_noise, g.freq_res[..3].to_vec()), (Some(3), [0, 14, 18], vec![true, true, false])); + // VARFIX: leading border 1, relative 2, pointer 2: l_A = 1 + let g = grid(&[(VARFIX as u32, 2), (1, 2), (1, 2), (0, 2), (2, 2), (1, 1), (0, 1)]).unwrap(); + assert_eq!((g.num_env, &g.t_env[..3], g.la, g.t_noise), (2, &[1, 3, 16][..], Some(1), [1, 3, 16])); + // the same with pointer 1: no transient envelope, the noise floors still split at 3 + let g = grid(&[(VARFIX as u32, 2), (1, 2), (1, 2), (0, 2), (1, 2), (1, 1), (0, 1)]).unwrap(); + assert_eq!((g.la, g.t_noise), (None, [1, 3, 16])); + // VARVAR: 1 .. 17 with one border from each end + let g = grid(&[(VARVAR as u32, 2), (1, 2), (1, 2), (1, 2), (1, 2), (0, 2), (0, 2), (0, 2), (0, 1), (0, 1), (0, 1)]).unwrap(); + assert_eq!((g.num_env, &g.t_env[..4], g.la), (3, &[1, 3, 15, 17][..], None)); + } + + /// Grids whose borders do not increase, too many envelopes and a pointer past the envelopes + /// are refused. + #[test] + fn invalid_grids_are_refused() { + // FIXFIX with 8 envelopes + assert!(grid(&[(FIXFIX as u32, 2), (3, 2), (0, 1)]).is_err()); + // VARVAR with 7 envelopes + assert!(grid(&[(VARVAR as u32, 2), (0, 2), (0, 2), (3, 2), (3, 2)]).is_err()); + // FIXVAR whose relative borders run back past the start: 16 - (8 + 8 + 8) + assert!(grid(&[(FIXVAR as u32, 2), (0, 2), (3, 2), (3, 2), (3, 2), (3, 2), (0, 3), (0, 4)]).is_err()); + // VARFIX, 2 envelopes, pointer 3: the middle border would be the trailing one + assert!(grid(&[(VARFIX as u32, 2), (0, 2), (1, 2), (0, 2), (3, 2), (0, 1), (0, 1)]).is_err()); + } +} diff --git a/crates/audio/src/aac/sbr/freq.rs b/crates/audio/src/aac/sbr/freq.rs new file mode 100644 index 000000000..7bb372dc4 --- /dev/null +++ b/crates/audio/src/aac/sbr/freq.rs @@ -0,0 +1,372 @@ +//! Frequency band tables (ISO/IEC 14496-3 §4.6.18.3.2) and the patches and limiter bands built on +//! them (§4.6.18.6.3, §4.6.18.7.3). Every table holds QMF channel borders. + +use super::Header; + +/// The most patches the HF generator may build. +const MAX_PATCHES: usize = 5; + +/// The tables of one SBR header at one output rate. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FreqTables { + /// First QMF channel of the master table (the start of the SBR range before the crossover). + pub(crate) k0: usize, + /// First QMF channel of the SBR range. + pub(crate) kx: usize, + /// Channels in the SBR range (`kx + m` is its end). + pub(crate) m: usize, + /// High-resolution envelope bands (`n_high + 1` borders). + pub(crate) high: Vec, + /// Low-resolution envelope bands. + pub(crate) low: Vec, + /// Noise floor bands (1 to 5). + pub(crate) noise: Vec, + /// Limiter bands. + pub(crate) lim: Vec, + /// `(first source channel, channels)` of each patch, in order from `kx` up. + pub(crate) patches: Vec<(usize, usize)>, +} + +/// Round half up, as the specification's `NINT`. +fn nint(x: f64) -> i64 { + (x + 0.5).floor() as i64 +} + +/// The start channel offsets of `bs_start_freq`, by output rate (§4.6.18.3.2.1). +fn start_offsets(fs: u32) -> &'static [i8; 16] { + match fs { + 0..=16_000 => &[-8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7], + 16_001..=22_050 => &[-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 9, 11, 13], + 22_051..=24_000 => &[-5, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 9, 11, 13, 16], + 24_001..=32_000 => &[-6, -4, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 9, 11, 13, 16], + 32_001..=64_000 => &[-4, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 9, 11, 13, 16, 20], + _ => &[-2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 9, 11, 13, 16, 20, 24], + } +} + +/// `NINT(f · 128 / fs)` for the 3/4/5 kHz (start) or 6/8/10 kHz (stop) minimum at `fs`. +fn min_channel(fs: u32, low: f64, mid: f64, high: f64) -> i64 { + let f = if fs < 32_000 { + low + } else if fs < 64_000 { + mid + } else { + high + }; + nint(f * 128.0 / f64::from(fs)) +} + +/// The start and stop channels `k0`, `k2` of the master table. +fn start_stop(h: &Header, fs: u32) -> Option<(usize, usize)> { + let k0 = min_channel(fs, 3000.0, 4000.0, 5000.0) + i64::from(start_offsets(fs)[usize::from(h.start_freq & 15)]); + let k2 = match h.stop_freq { + 14 => 2 * k0, + 15 => 3 * k0, + s => { + let stop_min = min_channel(fs, 6000.0, 8000.0, 10_000.0); + if stop_min <= 0 { + return None; + } + let ratio = 64.0 / stop_min as f64; + let at = |p: i64| nint(stop_min as f64 * ratio.powf(p as f64 / 13.0)); + let mut dk: Vec = (0..13).map(|p| at(p + 1) - at(p)).collect(); + dk.sort_unstable(); + stop_min + dk.iter().take(usize::from(s)).sum::() + } + } + .min(64); + // the low band reaches only the analysis bank's 32 channels (`kx` is checked against them + // once the crossover is known) + (1..=32).contains(&k0).then_some(())?; + (k2 > k0).then_some((k0 as usize, k2 as usize)) +} + +/// The master table for `bs_freq_scale` 0: bands of one or two channels. +fn master_linear(k0: usize, k2: usize, alter_scale: bool) -> Option> { + let (dk, bands) = if alter_scale { (2, 2 * nint((k2 - k0) as f64 / 4.0) as usize) } else { (1, 2 * ((k2 - k0) / 2)) }; + if bands == 0 { + return None; + } + let mut v = vec![dk as i64; bands]; + let mut diff = k2 as i64 - (k0 + bands * dk) as i64; + // take the difference from the lowest bands (too wide) or give it to the highest (too narrow) + let mut k = if diff < 0 { 0 } else { bands - 1 }; + while diff != 0 { + let step = if diff < 0 { 1 } else { -1 }; + *v.get_mut(k)? -= step; + diff += step; + k = if step == 1 { k + 1 } else { k.checked_sub(1)? }; + } + cumulative(k0, &v) +} + +/// The borders `start, start + d[0], …`; `None` unless every band is at least a channel wide. +fn cumulative(start: usize, d: &[i64]) -> Option> { + let mut out = Vec::with_capacity(d.len() + 1); + out.push(start); + let mut at = start as i64; + for &d in d { + if d <= 0 { + return None; + } + at += d; + out.push(at as usize); + } + Some(out) +} + +/// The band widths of `2 · NINT(bands · log2(hi / lo) / (2 · warp))` logarithmic bands. +fn log_widths(lo: usize, hi: usize, bands: f64, warp: f64) -> Vec { + let ratio = hi as f64 / lo as f64; + let n = 2 * nint(bands * ratio.log2() / (2.0 * warp)); + let at = |k: i64| nint(lo as f64 * ratio.powf(k as f64 / n as f64)); + let mut d: Vec = (0..n.max(0)).map(|k| at(k + 1) - at(k)).collect(); + d.sort_unstable(); + d +} + +/// The master table for `bs_freq_scale` 1–3: 12, 10 or 8 bands per octave, warped above +/// `2 · k0` by `bs_alter_scale`. +fn master_log(k0: usize, k2: usize, freq_scale: u8, alter_scale: bool) -> Option> { + let bands = [12.0, 10.0, 8.0][usize::from(freq_scale.clamp(1, 3) - 1)]; + let two_regions = k2 as f64 / k0 as f64 > 2.2449; + let k1 = if two_regions { 2 * k0 } else { k2 }; + let d0 = log_widths(k0, k1, bands, 1.0); + let mut table = cumulative(k0, &d0)?; + if table.len() < 2 { + return None; + } + if two_regions { + let mut d1 = log_widths(k1, k2, bands, if alter_scale { 1.3 } else { 1.0 }); + let max0 = d0.iter().copied().max()?; + if d1.first().is_some_and(|&d| d < max0) { + // widen the first upper band to the widest lower one, at the cost of the last + let change = max0 - d1[0]; + d1[0] = max0; + *d1.last_mut()? -= change; + d1.sort_unstable(); + } + table.extend(cumulative(k1, &d1)?.into_iter().skip(1)); + } + Some(table) +} + +impl FreqTables { + /// The tables of `h` at output rate `fs`, or `None` if the header asks for an invalid layout. + pub(crate) fn new(h: &Header, fs: u32) -> Option { + let (k0, k2) = start_stop(h, fs)?; + let master = if h.freq_scale == 0 { master_linear(k0, k2, h.alter_scale)? } else { master_log(k0, k2, h.freq_scale, h.alter_scale)? }; + let n_master = master.len().checked_sub(1)?; + let xover = usize::from(h.xover_band); + if xover >= n_master { + return None; + } + let high: Vec = master.get(xover..)?.to_vec(); + let n_high = high.len() - 1; + let (kx, end) = (high[0], *high.last()?); + if kx > 32 || end > 64 || end <= kx { + return None; + } + let m = end - kx; + // every other border, keeping the first (and the second when the count is odd) + let n_low = n_high - n_high / 2; + let low: Vec = (0..=n_low).map(|k| high[if k == 0 { 0 } else { 2 * k - (n_high & 1) }]).collect(); + let n_q = if h.noise_bands == 0 { 1 } else { (nint(f64::from(h.noise_bands) * (end as f64 / kx as f64).log2()) as usize).max(1) }; + if n_q > 5 || n_q > n_low { + return None; + } + let mut noise = vec![low[0]]; + let mut i = 0; + for k in 1..=n_q { + i += (n_low - i) / (n_q + 1 - k); + noise.push(*low.get(i)?); + } + let patches = patches(&master, k0, kx, m, fs)?; + let lim = limiter(&low, &patches, kx, h.limiter_bands); + Some(FreqTables { k0, kx, m, high, low, noise, lim, patches }) + } + + /// High-resolution envelope bands. + pub(crate) fn n_high(&self) -> usize { + self.high.len() - 1 + } + + /// Noise floor bands. + pub(crate) fn n_q(&self) -> usize { + self.noise.len() - 1 + } + + /// The envelope band table of a resolution (`true`: high). + pub(crate) fn bands(&self, high_res: bool) -> &[usize] { + if high_res { &self.high } else { &self.low } + } +} + +/// Map the low band onto the SBR range (§4.6.18.6.3): runs of source channels, each ending below +/// `k0`, that together cover `kx .. kx + m`. +fn patches(master: &[usize], k0: usize, kx: usize, m: usize, fs: u32) -> Option> { + let n_master = master.len() - 1; + let goal = nint(2.048e6 / f64::from(fs)).max(0) as usize; + let mut k = if goal < kx + m { master.iter().position(|&f| f >= goal).unwrap_or(n_master) } else { n_master }; + let (mut msb, mut usb) = (k0, kx); + let mut out: Vec<(usize, usize)> = Vec::new(); + // each pass either adds a patch or lowers `msb` to `kx`; a stream that does neither twice + // running cannot finish. One patch over the limit may still be a short last one that is + // dropped below. + for _ in 0..2 * (MAX_PATCHES + 2) { + // the highest master border the patch can reach from below k0 + let mut j = k + 1; + let (sb, odd) = loop { + j = j.checked_sub(1)?; + let sb = *master.get(j)?; + let odd = (sb + k0) % 2; + if sb + odd < k0 + msb { + break (sb, odd); + } + }; + let count = sb.saturating_sub(usb); + if count > 0 { + let start = (k0 - odd).checked_sub(count)?; + out.push((start, count)); + if out.len() > MAX_PATCHES + 1 { + return None; + } + (usb, msb) = (sb, sb); + } else { + msb = kx; + } + if master.get(k)?.saturating_sub(sb) < 3 { + k = n_master; + } + if sb == kx + m { + if out.len() > 1 && out.last().is_some_and(|p| p.1 < 3) { + out.pop(); + } + return (!out.is_empty() && out.len() <= MAX_PATCHES).then_some(out); + } + } + None +} + +/// The limiter band table (§4.6.18.7.3): one band over the range, or about 1.2, 2 or 3 bands per +/// octave on the low-resolution and patch borders. +fn limiter(low: &[usize], patches: &[(usize, usize)], kx: usize, limiter_bands: u8) -> Vec { + let (Some(&first), Some(&last)) = (low.first(), low.last()) else { + return Vec::new(); + }; + if limiter_bands == 0 { + return vec![first, last]; + } + let per_octave = [1.2, 2.0, 3.0][usize::from(limiter_bands.clamp(1, 3) - 1)]; + let mut borders = vec![kx]; + for p in patches { + borders.push(borders.last().copied().unwrap_or(kx) + p.1); + } + let mut t: Vec = low.to_vec(); + t.extend(borders.iter().skip(1).take(patches.len().saturating_sub(1))); + t.sort_unstable(); + let is_border = |v: usize| borders.contains(&v); + let mut k = 1; + while k < t.len() { + let octaves = if t[k - 1] == 0 { f64::INFINITY } else { (t[k] as f64 / t[k - 1] as f64).log2() }; + if octaves * per_octave >= 0.49 { + k += 1; + } else if t[k] == t[k - 1] || !is_border(t[k]) { + t.remove(k); + } else if !is_border(t[k - 1]) { + t.remove(k - 1); + } else { + k += 1; + } + } + if t.len() < 2 { + return vec![first, last]; + } + t +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header(start: u8, stop: u8, xover: u8, freq_scale: u8, alter: bool, noise: u8) -> Header { + Header { + amp_res: true, + start_freq: start, + stop_freq: stop, + xover_band: xover, + freq_scale, + alter_scale: alter, + noise_bands: noise, + limiter_bands: 2, + limiter_gains: 2, + interpol_freq: true, + smoothing_mode: true, + } + } + + /// Every header field combination at every output rate gives tables that are strictly + /// increasing, start at `kx`, end at `kx + m`, keep the low band within the analysis bank and + /// patch from below `k0` (or is refused). + #[test] + fn every_header_gives_consistent_tables_or_none() { + let mut valid = 0; + for fs in [16_000, 22_050, 24_000, 32_000, 44_100, 48_000, 64_000, 88_200, 96_000] { + for start in 0..16 { + for stop in 0..16 { + for xover in 0..8 { + for fsc in 0..4 { + for alter in [false, true] { + for noise in 0..4 { + let Some(t) = FreqTables::new(&header(start, stop, xover, fsc, alter, noise), fs) else { continue }; + valid += 1; + for v in [&t.high, &t.low, &t.noise, &t.lim] { + assert!(v.windows(2).all(|w| w[0] < w[1]), "{v:?}"); + assert_eq!(v[0], t.kx); + } + for v in [&t.high, &t.low, &t.noise] { + assert_eq!(*v.last().unwrap(), t.kx + t.m); + } + assert!(t.kx <= 32 && t.kx + t.m <= 64 && t.n_q() <= 5); + // a last patch of under 3 channels is dropped, and the limiter + // bands may then stop short of the range's end + assert!(t.patches.iter().map(|p| p.1).sum::() <= t.m); + assert!(t.lim.len() >= 2 && *t.lim.last().unwrap() <= t.kx + t.m); + assert!(t.patches.iter().all(|&(s, n)| s + n <= t.k0)); + } + } + } + } + } + } + } + assert!(valid > 10_000, "{valid}"); + } + + /// The highest start a header can give, 32 at 24 kHz (start 15), is valid: with stop 14 + /// (twice the start) and linear bands it covers channels 32 to 64. + #[test] + fn a_start_at_channel_32_is_accepted() { + let t = FreqTables::new(&header(15, 14, 0, 0, false, 2), 24_000).unwrap(); + assert_eq!((t.k0, t.kx, t.m), (32, 32, 32)); + } + + /// At 96 kHz this header builds patches of 6, 6, 6, 6, 5 and 2 channels: the short sixth is + /// dropped, as any short last patch is, and the five left are within the limit. + #[test] + fn a_short_sixth_patch_is_dropped_before_the_limit() { + let t = FreqTables::new(&header(3, 9, 0, 0, false, 2), 96_000).unwrap(); + assert_eq!((t.k0, t.kx + t.m), (8, 39)); + assert_eq!(t.patches.iter().map(|p| p.1).collect::>(), [6, 6, 6, 6, 5]); + } + + /// A typical 44.1 kHz header: start channel 12 + offset, stop near 64, three noise bands. + #[test] + fn a_44k_header_has_the_expected_layout() { + let t = FreqTables::new(&header(5, 9, 0, 2, true, 2), 44_100).unwrap(); + // startMin 12 + offset 2 = 14 + assert_eq!((t.k0, t.kx), (14, 14)); + assert_eq!(t.low.len() - 1, t.n_high() - t.n_high() / 2); + assert!(t.n_q() >= 1); + } +} diff --git a/crates/audio/src/aac/sbr/hf.rs b/crates/audio/src/aac/sbr/hf.rs new file mode 100644 index 000000000..1c2fd81ac --- /dev/null +++ b/crates/audio/src/aac/sbr/hf.rs @@ -0,0 +1,333 @@ +//! HF generation (ISO/IEC 14496-3 §4.6.18.6) and HF adjustment (§4.6.18.7) of one channel. + +use super::bitstream::Grid; +use super::freq::FreqTables; +use super::tables::NOISE; +use super::{Cx, Header, MAX_ENV, T_HF_ADJ, X_SLOTS}; + +/// Low band subband samples of a frame: slots -8 .. 32 relative to the frame (`X_Low`). +pub(crate) type LowBand = [[Cx; 32]; X_SLOTS]; +/// High band subband samples, at slot `l + T_HF_ADJ` for QMF slot `l` of the frame's envelopes. +pub(crate) type HighBand = [[Cx; 64]; X_SLOTS]; + +/// The per-channel state the HF tools carry from frame to frame. +#[derive(Clone, Debug)] +pub(crate) struct History { + /// `bs_invf_mode` and chirp factor per noise band. + invf: [u8; 5], + bw: [f32; 5], + /// The last frame's transient envelope was its last (`l_A = L_E`): no noise in this frame's + /// first. + la_at_end: bool, + /// Sinusoids present in the last envelope, by channel above `kx`. + sine: [bool; 64], + /// Gains and noise levels of the last four slots, newest first (smoothing). + gains: [[f32; 64]; 4], + noise_levels: [[f32; 64]; 4], + /// The smoothing history holds data of the current tables. + primed: bool, + noise_index: usize, + sine_index: usize, +} + +impl History { + pub(crate) fn new() -> History { + History { + invf: [0; 5], + bw: [0.0; 5], + la_at_end: false, + sine: [false; 64], + gains: [[0.0; 64]; 4], + noise_levels: [[0.0; 64]; 4], + primed: false, + noise_index: 0, + sine_index: 0, + } + } + + /// Forget what depends on the frequency tables (after a header that changes them). + pub(crate) fn reset_tables(&mut self) { + self.sine = [false; 64]; + self.primed = false; + self.noise_index = 0; + } +} + +/// The chirp factor of a noise band from its inverse filtering mode and the last frame's. +fn chirp(mode: u8, prev_mode: u8, prev_bw: f32) -> f32 { + let new_bw = match (mode, prev_mode) { + (0, 1) => 0.6, + (0, _) => 0.0, + (1, 0) => 0.6, + (1, _) => 0.75, + (2, _) => 0.9, + _ => 0.98, + }; + let bw = if new_bw < prev_bw { 0.75 * new_bw + 0.25 * prev_bw } else { 0.90625 * new_bw + 0.09375 * prev_bw }; + if bw < 0.015625 { 0.0 } else { bw } +} + +/// Second order linear prediction of one low band channel over the frame and the 6 slots +/// before it (the covariance method, §4.6.18.6.2): `(α0, α1)`. +fn predictor(x: &LowBand, k: usize) -> (Cx, Cx) { + // φ(i, j) = Σ x(n − i + 2) · conj(x(n − j + 2)), n = 0 .. 37 + let phi = |i: usize, j: usize| { + let (mut re, mut im) = (0f64, 0f64); + for n in 0..SLOTS_PLUS_6 { + let (a, b) = (x[n + 2 - i][k], x[n + 2 - j][k]); + re += f64::from(a.re) * f64::from(b.re) + f64::from(a.im) * f64::from(b.im); + im += f64::from(a.im) * f64::from(b.re) - f64::from(a.re) * f64::from(b.im); + } + (re, im) + }; + let (p01, p02, p11, p12, p22) = (phi(0, 1), phi(0, 2), phi(1, 1).0, phi(1, 2), phi(2, 2).0); + let d = p22 * p11 - (p12.0 * p12.0 + p12.1 * p12.1) / (1.0 + 1e-6); + let a1 = if d != 0.0 { + // (φ01 · φ12 − φ02 · φ11) / d + ((p01.0 * p12.0 - p01.1 * p12.1 - p02.0 * p11) / d, (p01.0 * p12.1 + p01.1 * p12.0 - p02.1 * p11) / d) + } else { + (0.0, 0.0) + }; + let a0 = if p11 != 0.0 { + // −(φ01 + α1 · conj(φ12)) / φ11 + (-(p01.0 + a1.0 * p12.0 + a1.1 * p12.1) / p11, -(p01.1 + a1.1 * p12.0 - a1.0 * p12.1) / p11) + } else { + (0.0, 0.0) + }; + let mag = |a: (f64, f64)| (a.0 * a.0 + a.1 * a.1).sqrt(); + if mag(a0) >= 4.0 || mag(a1) >= 4.0 { + return (Cx::default(), Cx::default()); + } + (Cx { re: a0.0 as f32, im: a0.1 as f32 }, Cx { re: a1.0 as f32, im: a1.1 as f32 }) +} + +/// Slots of the covariance sums: the frame's 32 and 6 before it. +const SLOTS_PLUS_6: usize = super::SLOTS + 6; + +/// The index of the band of `bands` holding channel `k` (bands are increasing borders). +fn band_of(bands: &[usize], k: usize) -> usize { + bands.partition_point(|&b| b <= k).saturating_sub(1).min(bands.len().saturating_sub(2)) +} + +/// Patch the low band into the SBR range, inverse filtered (§4.6.18.6.3), for QMF slots +/// `from .. to` of the frame. +pub(crate) fn generate(x: &LowBand, t: &FreqTables, invf: &[u8; 5], h: &mut History, (from, to): (usize, usize), out: &mut HighBand) { + let mut bw = [0f32; 5]; + for i in 0..t.n_q() { + bw[i] = chirp(invf[i], h.invf[i], h.bw[i]); + } + (h.invf, h.bw) = (*invf, bw); + let mut pred: [Option<(Cx, Cx)>; 32] = [None; 32]; + let mut k = t.kx; + for &(start, count) in &t.patches { + for p in start..start + count { + let (a0, a1) = *pred[p].get_or_insert_with(|| predictor(x, p)); + let g = bw[band_of(&t.noise, k)]; + let (c0, c1) = (a0.scale(g), a1.scale(g * g)); + for l in from..to { + let i = l + T_HF_ADJ; + out[i][k] = x[i][p] + c0 * x[i - 1][p] + c1 * x[i - 2][p]; + } + k += 1; + } + } +} + +/// Dequantised envelope and noise floor values of one channel in one frame. +#[derive(Clone, Debug)] +pub(crate) struct Levels { + /// Envelope energy per envelope and band (of that envelope's resolution). + pub(crate) energy: [[f32; 64]; MAX_ENV], + /// Noise to signal ratio per noise floor and noise band. + pub(crate) noise: [[f32; 5]; 2], +} + +/// Gains for one envelope, per channel of the SBR range. +struct EnvelopeGains { + gain: [f32; 64], + noise: [f32; 64], + sine: [f32; 64], +} + +const LIMITER_GAINS: [f32; 4] = [0.70795, 1.0, 1.41254, 1e10]; +/// `h_smooth`, as the specification gives it. +#[allow(clippy::excessive_precision)] +const SMOOTHING: [f32; 5] = [0.33333333333333, 0.30150283239582, 0.21816949906249, 0.11516383427084, 0.03183050093751]; +const EPS: f32 = 1.0; +const EPS0: f32 = 1e-12; + +/// What the adjustment of one channel's frame works from. +pub(crate) struct Frame<'a> { + /// The generated high band. + pub(crate) x: &'a HighBand, + pub(crate) t: &'a FreqTables, + pub(crate) hdr: &'a Header, + pub(crate) g: &'a Grid, + pub(crate) lv: &'a Levels, + /// `bs_add_harmonic`, when the frame codes sinusoids. + pub(crate) harmonic: Option<&'a [bool; 64]>, +} + +/// The gains of envelope `l` (§4.6.18.7.3–4): `delta` is 0 for a transient envelope, which gets +/// no noise. +fn envelope_gains(f: &Frame, l: usize, sine_index: &[bool; 64], delta: bool) -> EnvelopeGains { + let Frame { x, t, hdr, g, lv, .. } = *f; + let (kx, m) = (t.kx, t.m); + let bands = t.bands(g.freq_res[l]); + let (from, to) = (2 * g.t_env[l] + T_HF_ADJ, 2 * g.t_env[l + 1] + T_HF_ADJ); + let slots = (to - from) as f32; + let floor = usize::from(g.num_noise == 2 && g.t_env[l] >= g.t_noise[1]); + // per channel: original energy, current energy, noise ratio, sinusoid in its band + let mut e_orig = [0f32; 64]; + let mut e_curr = [0f32; 64]; + let mut q = [0f32; 64]; + let mut s_band = [false; 64]; + for j in 0..bands.len() - 1 { + let (lo, hi) = (bands[j] - kx, bands[j + 1] - kx); + let any_sine = sine_index[lo..hi].iter().any(|&s| s); + let mut sum = 0f32; + for c in lo..hi { + let ch = c + kx; + let e: f32 = (from..to).map(|i| x[i][ch].norm_sqr()).sum::() / slots; + e_curr[c] = e; + sum += e; + e_orig[c] = lv.energy[l][j]; + q[c] = lv.noise[floor][band_of(&t.noise, ch)]; + s_band[c] = any_sine; + } + if !hdr.interpol_freq { + e_curr[lo..hi].fill(sum / (hi - lo) as f32); + } + } + let mut out = EnvelopeGains { gain: [0.0; 64], noise: [0.0; 64], sine: [0.0; 64] }; + for c in 0..m { + // `q / (1 + q)` first: energy and noise ratio are each clamped, their product is not + let (eo, ec, q) = (e_orig[c], e_curr[c], q[c]); + let share = q / (1.0 + q); + out.noise[c] = (eo * share).sqrt(); + if sine_index[c] { + out.sine[c] = (eo / (1.0 + q)).sqrt(); + } + out.gain[c] = if s_band[c] { (eo / (EPS + ec) * share).sqrt() } else { (eo / ((EPS + ec) * (1.0 + if delta { q } else { 0.0 }))).sqrt() }; + } + // limit per limiter band, then boost back the energy the limiter took + let lim_gain = LIMITER_GAINS[usize::from(hdr.limiter_gains & 3)]; + for w in t.lim.windows(2) { + let (lo, hi) = (w[0] - kx, w[1] - kx); + let (so, sc) = (e_orig[lo..hi].iter().sum::(), e_curr[lo..hi].iter().sum::()); + let max = (lim_gain * ((EPS0 + so) / (EPS0 + sc)).sqrt()).min(1e5); + let mut den = 0f32; + for c in lo..hi { + if out.gain[c] > max { + out.noise[c] *= max / out.gain[c]; + out.gain[c] = max; + } + den += e_curr[c] * out.gain[c] * out.gain[c]; + if out.sine[c] != 0.0 { + den += out.sine[c] * out.sine[c]; + } else if delta { + den += out.noise[c] * out.noise[c]; + } + } + let boost = ((EPS0 + so) / (EPS0 + den)).sqrt().min(1.584_893_2); + for c in lo..hi { + out.gain[c] *= boost; + out.noise[c] *= boost; + out.sine[c] *= boost; + } + } + out +} + +/// Adjust the generated high band to the transmitted envelopes and add noise and sinusoids +/// (§4.6.18.7), for the frame's envelopes, into `y`. +pub(crate) fn adjust(f: &Frame, h: &mut History, y: &mut HighBand) { + let Frame { x, t, hdr, g, harmonic, .. } = *f; + let (kx, m) = (t.kx, t.m); + let smooth = !hdr.smoothing_mode; + let prev_la_at_end = h.la_at_end; + for l in 0..g.num_env { + // sinusoids start at the transient envelope, unless one was already playing in the band + let mut sine_index = [false; 64]; + if let Some(harm) = harmonic { + for i in 0..t.n_high() { + let c = (t.high[i] + t.high[i + 1]) / 2 - kx; + sine_index[c] = harm[i] && (g.la.is_none_or(|la| l >= la) || h.sine[c]); + } + } + let transient = g.la == Some(l) || (l == 0 && prev_la_at_end); + let e = envelope_gains(f, l, &sine_index, !transient); + if !h.primed { + h.gains = [e.gain; 4]; + h.noise_levels = [e.noise; 4]; + h.primed = true; + } + for i in 2 * g.t_env[l]..2 * g.t_env[l + 1] { + let (mut gain, mut noise) = (e.gain, e.noise); + if smooth && !transient { + for c in 0..m { + gain[c] = SMOOTHING[0] * e.gain[c] + (0..4).map(|j| SMOOTHING[j + 1] * h.gains[j][c]).sum::(); + noise[c] = SMOOTHING[0] * e.noise[c] + (0..4).map(|j| SMOOTHING[j + 1] * h.noise_levels[j][c]).sum::(); + } + } + h.gains.rotate_right(1); + h.noise_levels.rotate_right(1); + (h.gains[0], h.noise_levels[0]) = (e.gain, e.noise); + let (sin_re, sin_im) = [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)][h.sine_index]; + let row = &mut y[i + T_HF_ADJ]; + for c in 0..m { + let ch = c + kx; + let mut v = x[i + T_HF_ADJ][ch].scale(gain[c]); + h.noise_index = (h.noise_index + 1) & 511; + if e.sine[c] != 0.0 { + let sign = if ch % 2 == 0 { 1.0 } else { -1.0 }; + v.re += e.sine[c] * sin_re; + v.im += e.sine[c] * sin_im * sign; + } else if !transient { + let [nr, ni] = NOISE[h.noise_index]; + v.re += noise[c] * nr; + v.im += noise[c] * ni; + } + row[ch] = v; + } + h.sine_index = (h.sine_index + 1) & 3; + } + if l + 1 == g.num_env { + h.sine = sine_index; + } + } + h.la_at_end = g.la == Some(g.num_env); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The chirp factor follows §4.6.18.6.2: rising quickly, falling slowly, and zero below 1/64. + #[test] + fn chirp_factors() { + assert!((chirp(3, 3, 0.0) - 0.90625 * 0.98).abs() < 1e-6); + assert!((chirp(0, 3, 0.98) - 0.25 * 0.98).abs() < 1e-6); + assert_eq!(chirp(0, 0, 0.05), 0.0); + assert!((chirp(1, 0, 0.0) - 0.90625 * 0.6).abs() < 1e-6); + } + + /// A channel that is a decaying complex exponential is predicted exactly by its first order + /// term. + #[test] + fn prediction_of_a_resonance() { + let mut x: LowBand = [[Cx::default(); 32]; X_SLOTS]; + let r = Cx { re: 0.9 * 0.3f32.cos(), im: 0.9 * 0.3f32.sin() }; + let mut v = Cx { re: 1.0, im: 0.0 }; + for slot in x.iter_mut() { + slot[3] = v; + v = v * r; + } + let (a0, a1) = predictor(&x, 3); + // x(n) + α0·x(n−1) + α1·x(n−2) = 0 with α0 = −r, α1 = 0 + assert!((a0.re + r.re).abs() < 1e-3 && (a0.im + r.im).abs() < 1e-3, "{a0:?}"); + assert!(a1.re.abs() < 1e-3 && a1.im.abs() < 1e-3, "{a1:?}"); + // a silent channel has no predictor + assert_eq!(predictor(&x, 4), (Cx::default(), Cx::default())); + } +} diff --git a/crates/audio/src/aac/sbr/mod.rs b/crates/audio/src/aac/sbr/mod.rs new file mode 100644 index 000000000..752cd03a0 --- /dev/null +++ b/crates/audio/src/aac/sbr/mod.rs @@ -0,0 +1,544 @@ +//! Spectral band replication (HE-AAC, ISO/IEC 14496-3 §4.6.18): rebuilds the band above the +//! AAC-LC core from the core's own low band and the envelope data of the stream's SBR fill +//! elements, at twice the core rate. +//! +//! Each channel's core output goes through a 32-band QMF analysis bank. The HF generator copies +//! (patches) low band channels into the SBR range, inverse filtered; the HF adjuster scales them +//! to the transmitted envelopes and adds noise and sinusoids; a 64-band QMF synthesis bank merges +//! the delayed low band and the new high band. A frame without usable SBR data (none sent yet, or +//! damaged) still passes through the banks, so the output keeps its rate and delay. +//! +//! Written from the SBR description and tables of ETSI ES 201 980 (DRM, Annex I has the tables it +//! shares with ISO/IEC 14496-3) and the decoder process of ISO/IEC 14496-3. Parametric stereo +//! (HE-AAC v2) is not decoded: its extension is skipped and the mono core is extended alone. + +mod bitstream; +mod freq; +mod hf; +mod qmf; +mod tables; + +use std::ops::{Add, Mul}; + +use craft_media::bitstream::BitReader; + +use self::bitstream::{ChannelData, Frame}; +use self::freq::FreqTables; +use self::hf::{HighBand, History, Levels, LowBand}; +use self::qmf::{Analysis, Synthesis}; + +/// QMF slots per frame: 1024 core samples in slots of 32. +pub(crate) const SLOTS: usize = 32; +/// The most envelopes in a frame. +pub(crate) const MAX_ENV: usize = 5; +/// Slots of the last frame kept ahead of this one's low band (`t_HFGen`). +const T_HF_GEN: usize = 8; +/// The SBR frame lags the core frame by `T_HF_GEN − T_HF_ADJ` slots. +pub(crate) const T_HF_ADJ: usize = 2; +/// Slots in the low and high band buffers: the frame and `T_HF_GEN` before it. +pub(crate) const X_SLOTS: usize = SLOTS + T_HF_GEN; +/// The 16-bit full scale the specification's energies are in. +const PCM_SCALE: f32 = 32768.0; +/// `NOISE_FLOOR_OFFSET`. +const NOISE_OFFSET: i32 = 6; + +/// Output samples by which the SBR decoder delays the core: the filter banks, and the SBR frame +/// lagging the core frame by `T_HF_GEN − T_HF_ADJ` slots of 64. +pub const DELAY: usize = qmf::DELAY + 64 * (T_HF_GEN - T_HF_ADJ); + +/// A complex subband sample. +#[derive(Clone, Copy, Default, Debug, PartialEq)] +pub(crate) struct Cx { + pub(crate) re: f32, + pub(crate) im: f32, +} + +impl Cx { + fn scale(self, g: f32) -> Cx { + Cx { re: self.re * g, im: self.im * g } + } + fn norm_sqr(self) -> f32 { + self.re * self.re + self.im * self.im + } +} + +impl Add for Cx { + type Output = Cx; + fn add(self, o: Cx) -> Cx { + Cx { re: self.re + o.re, im: self.im + o.im } + } +} + +impl Mul for Cx { + type Output = Cx; + fn mul(self, o: Cx) -> Cx { + Cx { re: self.re * o.re - self.im * o.im, im: self.re * o.im + self.im * o.re } + } +} + +/// `sbr_header()` (§4.5.2.8.1). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Header { + pub(crate) amp_res: bool, + pub(crate) start_freq: u8, + pub(crate) stop_freq: u8, + pub(crate) xover_band: u8, + pub(crate) freq_scale: u8, + pub(crate) alter_scale: bool, + pub(crate) noise_bands: u8, + pub(crate) limiter_bands: u8, + pub(crate) limiter_gains: u8, + pub(crate) interpol_freq: bool, + pub(crate) smoothing_mode: bool, +} + +impl Header { + /// Whether `o` keeps the frequency band tables (a change of any of these fields is an SBR + /// reset). + fn same_tables(&self, o: &Header) -> bool { + (self.start_freq, self.stop_freq, self.freq_scale, self.alter_scale, self.xover_band, self.noise_bands) + == (o.start_freq, o.stop_freq, o.freq_scale, o.alter_scale, o.xover_band, o.noise_bands) + } +} + +/// Where an element's SBR data is: the access unit, and the bit range of the fill element's +/// payload after its extension type. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Payload<'a> { + pub(crate) data: &'a [u8], + pub(crate) start: usize, + pub(crate) end: usize, + /// `EXT_SBR_DATA_CRC`: a 10-bit CRC leads the data. + pub(crate) crc: bool, +} + +/// Quantised envelope values per envelope and band, and noise floor values per floor and band. +type Quantised = ([[i32; 64]; MAX_ENV], [[i32; 5]; 2]); + +/// One channel's state across frames. +#[derive(Clone)] +struct Channel { + analysis: Analysis, + synthesis: Synthesis, + /// Low band of the frame, with the last `T_HF_GEN` slots of the frame before ahead of it. + x_low: Box, + /// Adjusted high band of the last frame (its envelopes may reach into this one). + y_prev: Box, + /// Where `y_prev` holds data: QMF channels, and the end of its slots. + y_prev_range: (usize, usize), + y_prev_end: usize, + /// Quantised envelope values of the last envelope, and its resolution; noise floor values of + /// the last noise floor (time-direction delta coding). + env: [i32; 64], + env_high_res: bool, + noise: [i32; 5], + history: History, +} + +impl Channel { + fn new() -> Channel { + Channel { + analysis: Analysis::new(), + synthesis: Synthesis::new(), + x_low: Box::new([[Cx::default(); 32]; X_SLOTS]), + y_prev: Box::new([[Cx::default(); 64]; X_SLOTS]), + y_prev_range: (0, 0), + y_prev_end: 0, + env: [0; 64], + env_high_res: false, + noise: [0; 5], + history: History::new(), + } + } + + /// Decode the delta-coded envelope and noise floor values (§4.6.18.3.4); `scale` is 2 for a + /// balance channel. + fn absolute(&mut self, d: &ChannelData, t: &FreqTables, scale: i32) -> Quantised { + let mut env = [[0i32; 64]; MAX_ENV]; + for l in 0..d.grid.num_env { + let res = d.grid.freq_res[l]; + let n = t.bands(res).len() - 1; + if d.df_env[l] { + for k in 0..n { + // the value this band refers to in the last envelope, mapped across resolutions + let reference = if res == self.env_high_res { + k + } else if res { + hf_band_of(t.bands(false), t.high[k]) + } else { + t.high.iter().position(|&b| b == t.low[k]).unwrap_or(0) + }; + env[l][k] = self.env.get(reference).copied().unwrap_or(0) + scale * d.env[l][k]; + } + } else { + env[l][0] = scale * d.env[l][0]; + for k in 1..n { + env[l][k] = env[l][k - 1] + scale * d.env[l][k]; + } + } + (self.env, self.env_high_res) = (env[l], res); + } + let mut noise = [[0i32; 5]; 2]; + for l in 0..d.grid.num_noise { + for k in 0..t.n_q() { + let delta = scale * d.noise[l][k]; + noise[l][k] = if d.df_noise[l] { + self.noise[k] + delta + } else if k == 0 { + delta + } else { + noise[l][k - 1] + delta + }; + } + self.noise = noise[l]; + } + (env, noise) + } +} + +/// The index of the band of `bands` holding channel `k`. +fn hf_band_of(bands: &[usize], k: usize) -> usize { + bands.partition_point(|&b| b <= k).saturating_sub(1) +} + +/// `2^x`, kept to a range whose energies stay finite. +fn exp2(x: f32) -> f32 { + x.clamp(-64.0, 64.0).exp2() +} + +/// Dequantise one channel (§4.6.18.3.5), or a coupled pair from its level and balance channels. +fn dequantise(frame: &Frame, values: &[Quantised], t: &FreqTables) -> Vec { + let g = &frame.channels[0].grid; + let amp = if g.amp_res { 1.0 } else { 0.5 }; + let mut out = vec![Levels { energy: [[0.0; 64]; MAX_ENV], noise: [[0.0; 5]; 2] }; values.len()]; + if frame.coupling && values.len() == 2 { + let pan = if g.amp_res { 12.0 } else { 24.0 }; + let ((e0, q0), (e1, q1)) = (&values[0], &values[1]); + for l in 0..g.num_env { + for k in 0..t.bands(g.freq_res[l]).len() - 1 { + let level = 64.0 * exp2(amp * e0[l][k] as f32 + 1.0); + let balance = amp * (pan - e1[l][k] as f32); + out[0].energy[l][k] = level / (1.0 + exp2(balance)); + out[1].energy[l][k] = level / (1.0 + exp2(-balance)); + } + } + for l in 0..g.num_noise { + for k in 0..t.n_q() { + let level = exp2((NOISE_OFFSET - q0[l][k] + 1) as f32); + let balance = (12 - q1[l][k]) as f32; + out[0].noise[l][k] = level / (1.0 + exp2(balance)); + out[1].noise[l][k] = level / (1.0 + exp2(-balance)); + } + } + return out; + } + for ((lv, (e, q)), d) in out.iter_mut().zip(values).zip(&frame.channels) { + let g = &d.grid; + let amp = if g.amp_res { 1.0 } else { 0.5 }; + for l in 0..g.num_env { + for k in 0..t.bands(g.freq_res[l]).len() - 1 { + lv.energy[l][k] = 64.0 * exp2(amp * e[l][k] as f32); + } + } + for l in 0..g.num_noise { + for k in 0..t.n_q() { + lv.noise[l][k] = exp2((NOISE_OFFSET - q[l][k]) as f32); + } + } + } + out +} + +/// The SBR decoder of one channel element: a single channel (SCE, or an LFE, which has no SBR +/// data) or a channel pair (CPE). +#[derive(Clone)] +pub(crate) struct Element { + /// Output sampling rate. + rate: u32, + header: Option
, + tables: Option, + channels: Vec, +} + +impl Element { + pub(crate) fn new(channels: usize, rate: u32) -> Element { + Element { rate, header: None, tables: None, channels: (0..channels).map(|_| Channel::new()).collect() } + } + + pub(crate) fn channels(&self) -> usize { + self.channels.len() + } + + /// Forget the signal (a seek). The header is kept: it holds until the stream sends another. + pub(crate) fn reset(&mut self) { + for c in &mut self.channels { + *c = Channel::new(); + } + } + + /// Read an element's SBR data: `None` without a header to read it by. + fn read(&mut self, p: Payload) -> Option { + let mut br = BitReader::new(p.data); + br.seek_bits(p.start); + if p.crc { + br.skip(10).ok()?; + } + if br.read_flag().ok()? { + let h = Header::read(&mut br).ok()?; + // a header that runs past its fill element is damaged: keep the one in use + (br.position() <= p.end).then_some(())?; + if self.header != Some(h) { + if self.header.is_none_or(|o| !o.same_tables(&h)) { + for c in &mut self.channels { + c.history.reset_tables(); + } + } + self.tables = FreqTables::new(&h, self.rate); + self.header = Some(h); + } + } + let (h, t) = (self.header?, self.tables.as_ref()?); + let frame = bitstream::read_data(&mut br, t, h.amp_res, self.channels.len()).ok()?; + (br.position() <= p.end).then_some(frame) + } + + /// Decode one frame: 1024 core samples per channel in, 2048 out. + pub(crate) fn process(&mut self, core: &[Vec], payload: Option) -> Vec> { + for (c, pcm) in self.channels.iter_mut().zip(core) { + c.x_low.copy_within(SLOTS..X_SLOTS, 0); + let scaled: Vec = pcm.iter().map(|s| s * PCM_SCALE).collect(); + c.analysis.process(&scaled, &mut c.x_low[T_HF_GEN..]); + } + let frame = payload.and_then(|p| self.read(p)); + let mut y: Vec> = self.channels.iter().map(|_| Box::new([[Cx::default(); 64]; X_SLOTS])).collect(); + // per channel: kx, m, and the first and end slot of `y` this frame fills + let mut current: Vec> = vec![None; self.channels.len()]; + if let (Some(frame), Some(h), Some(t)) = (&frame, self.header, self.tables.as_ref()) { + let values: Vec<_> = self + .channels + .iter_mut() + .zip(&frame.channels) + .enumerate() + .map(|(i, (c, d))| c.absolute(d, t, if frame.coupling && i == 1 { 2 } else { 1 })) + .collect(); + let levels = dequantise(frame, &values, t); + for (((c, d), (lv, y)), cur) in self.channels.iter_mut().zip(&frame.channels).zip(levels.iter().zip(&mut y)).zip(&mut current) { + let g = &d.grid; + let slots = (2 * g.t_env[0], 2 * g.t_env[g.num_env]); + let mut x_high: Box = Box::new([[Cx::default(); 64]; X_SLOTS]); + hf::generate(&c.x_low, t, &d.invf, &mut c.history, slots, &mut x_high); + let f = hf::Frame { x: &x_high, t, hdr: &h, g, lv, harmonic: d.harmonic.as_ref() }; + hf::adjust(&f, &mut c.history, y); + *cur = Some((t.kx, t.m, slots.0 + T_HF_ADJ, slots.1 + T_HF_ADJ)); + } + } + let mut out = vec![vec![0f32; 2 * 1024]; self.channels.len()]; + for (((c, y), out), current) in self.channels.iter_mut().zip(y).zip(&mut out).zip(current) { + for (l, block) in out.as_chunks_mut::<64>().0.iter_mut().enumerate() { + let i = l + T_HF_ADJ; + let mut x = [Cx::default(); 64]; + // the high band of this slot: this frame's envelopes, the last frame's reaching + // into this one, or none + let high = match current { + Some((kx, m, from, to)) if (from..to).contains(&i) => Some((&y[i], kx, m)), + _ if i + SLOTS < c.y_prev_end => Some((&c.y_prev[i + SLOTS], c.y_prev_range.0, c.y_prev_range.1)), + _ => None, + }; + let low = high.map_or(current.map_or(32, |cur| cur.0), |h| h.1); + x[..low].copy_from_slice(&c.x_low[i][..low]); + if let Some((row, kx, m)) = high { + x[kx..kx + m].copy_from_slice(&row[kx..kx + m]); + } + c.synthesis.process(&x, 64, block); + } + for s in out.iter_mut() { + *s /= PCM_SCALE; + } + c.y_prev = y; + (c.y_prev_range, c.y_prev_end) = current.map_or(((0, 0), 0), |cur| ((cur.0, cur.1), cur.3)); + } + out + } +} + +/// The header of [`test_payload`]. +#[cfg(test)] +pub(crate) const TEST_HEADER: Header = Header { + amp_res: true, + start_freq: 5, + stop_freq: 9, + xover_band: 0, + freq_scale: 2, + alter_scale: true, + noise_bands: 2, + limiter_bands: 2, + limiter_gains: 2, + interpol_freq: true, + smoothing_mode: true, +}; + +/// An SBR single channel element for tests at output rate `rate`: [`TEST_HEADER`] (3 dB steps, +/// start 5, stop 9, the default extra fields), then one FIXFIX envelope at high resolution with +/// band `k` at `env(k)` (1.5 dB steps, as a single FIXFIX envelope has) and noise band `k` at +/// `noise(k)`. +#[cfg(test)] +pub(crate) fn test_payload(rate: u32, env: impl Fn(usize) -> i32, noise: impl Fn(usize) -> i32) -> Option> { + use craft_media::bitstream::BitWriter; + let t = FreqTables::new(&TEST_HEADER, rate)?; + let (env, noise): (Vec, Vec) = ((0..t.n_high()).map(env).collect(), (0..t.n_q()).map(noise).collect()); + let mut bw = BitWriter::new(); + bw.write_bits(1, 1); // bs_header_flag + for (v, n) in [(1, 1), (5, 4), (9, 4), (0, 3), (0, 2), (0, 1), (0, 1)] { + bw.write_bits(v, n); // amp_res, start, stop, xover, reserved, no extra fields + } + bw.write_bits(0, 1); // bs_data_extra + bw.write_bits(u32::from(bitstream::FIXFIX), 2); + bw.write_bits(0, 2); // one envelope + bw.write_bits(1, 1); // high resolution + bw.write_bits(0, 2); // frequency-direction envelope and noise floor + for _ in &noise { + bw.write_bits(0, 2); // no inverse filtering + } + bw.write_bits(env[0] as u32, 7); + for w in env.windows(2) { + let (code, len) = tables::F_ENV_1_5DB[(w[1] - w[0] + 60) as usize]; + bw.write_bits(code, u32::from(len)); + } + bw.write_bits(noise[0] as u32, 5); + for w in noise.windows(2) { + let (code, len) = tables::F_ENV_3_0DB[(w[1] - w[0] + 31) as usize]; + bw.write_bits(code, u32::from(len)); + } + bw.write_bits(0, 2); // no sinusoids, no extended data + Some(bw.finish()) +} + +#[cfg(test)] +mod tests { + use std::f64::consts::PI; + + use super::*; + + const RATE: u32 = 44_100; + + fn tables() -> FreqTables { + FreqTables::new(&TEST_HEADER, RATE).unwrap() + } + + /// Mean energy per DFT bin of `x` between `lo` and `hi` QMF channels (64 per half rate). + fn energy(x: &[f32], lo: usize, hi: usize) -> f64 { + let n = x.len(); + let (b0, b1) = (lo * n / 128, hi * n / 128); + let mut e = 0f64; + for b in b0 + 2..b1 - 2 { + let (mut re, mut im) = (0f64, 0f64); + for (i, &s) in x.iter().enumerate() { + let w = 0.5 - 0.5 * (2.0 * PI * i as f64 / n as f64).cos(); + let ph = 2.0 * PI * (b * i) as f64 / n as f64; + re += f64::from(s) * w * ph.cos(); + im += f64::from(s) * w * ph.sin(); + } + e += re * re + im * im; + } + e / (b1 - b0 - 4) as f64 + } + + fn run(e: &mut Element, data: &[u8], frames: usize) -> Vec { + let core = vec![vec![0f32; 1024]]; + let mut out = Vec::new(); + for _ in 0..frames { + let p = Payload { data, start: 0, end: 8 * data.len(), crc: false }; + let block = e.process(&core, Some(p)); + assert_eq!((block.len(), block[0].len()), (1, 2048)); + assert!(block[0].iter().all(|s| s.is_finite())); + out.extend_from_slice(&block[0]); + } + out + } + + /// Over a silent core the high band is the noise the envelope asks for: the upper bands, + /// 12 steps of 1.5 dB above the lower ones, come out 18 dB louder, and nothing below `kx`. + #[test] + fn the_high_band_follows_the_envelope() { + let t = tables(); + let mid = t.n_high() / 2; + let data = test_payload(RATE, |k| if k < mid { 30 } else { 42 }, |_| 0).unwrap(); + let mut e = Element::new(1, RATE); + let out = run(&mut e, &data, 6); + let tail = &out[out.len() - 4096..]; + let (lower, upper) = (energy(tail, t.high[0], t.high[mid]), energy(tail, t.high[mid], t.high[t.n_high()])); + let step = 10.0 * (upper / lower).log10(); + assert!((step - 18.06).abs() < 1.5, "{step:.2} dB"); + let below = energy(tail, 1, t.kx - 1); + assert!(10.0 * (lower / below.max(1e-30)).log10() > 60.0); + // the same envelope without its header is not read: an element that has seen none + // passes the core through alone + let mut fresh = Element::new(1, RATE); + let mut no_header = data.clone(); + no_header[0] &= 0x7F; + assert!(run(&mut fresh, &no_header, 3).iter().all(|&s| s == 0.0)); + } + + /// Damaged SBR data (flipped bits, cut short, or noise) never panics and keeps the output + /// finite and of full length. + #[test] + fn damaged_sbr_data_is_survived() { + let good = test_payload(RATE, |k| 20 + k as i32, |_| 3).unwrap(); + let mut seed = 0x1234_5678u32; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed + }; + let mut e = Element::new(1, RATE); + let mut pair = Element::new(2, RATE); + for i in 0..600 { + let mut data = good.clone(); + for _ in 0..1 + next() % 6 { + let bit = next() as usize % (8 * data.len()); + data[bit / 8] ^= 0x80 >> (bit % 8); + } + if i % 3 == 0 { + data.truncate(1 + next() as usize % data.len()); + } + if i % 5 == 0 { + data = (0..1 + next() % 40).map(|_| next() as u8).collect(); + data[0] |= 0x80; + } + run(&mut e, &data, 1); + let p = Payload { data: &data, start: 0, end: 8 * data.len(), crc: i % 2 == 0 }; + let out = pair.process(&[vec![0.1; 1024], vec![-0.1; 1024]], Some(p)); + assert!(out.iter().all(|c| c.len() == 2048 && c.iter().all(|s| s.is_finite()))); + } + } + + /// The largest envelope (127 steps of 1.5 dB) with the largest noise floor (each band 31 + /// steps below the last) keeps the output finite: the gains are formed so that no + /// intermediate product of two clamped levels overflows. + #[test] + fn extreme_levels_stay_finite() { + let rate = + [22_050, 24_000, 32_000, 44_100, 48_000].into_iter().find(|&r| FreqTables::new(&TEST_HEADER, r).is_some_and(|t| t.n_q() >= 3)).unwrap(); + let data = test_payload(rate, |_| 127, |k| -31 * k as i32).unwrap(); + let mut e = Element::new(1, rate); + run(&mut e, &data, 3); + } + + /// A header that runs past the end of its fill element is not taken: the one in use stays, + /// and the frame is dropped. + #[test] + fn a_header_cut_short_is_not_taken() { + let data = test_payload(RATE, |_| 30, |_| 0).unwrap(); + let mut e = Element::new(1, RATE); + run(&mut e, &data, 1); + assert_eq!(e.header, Some(TEST_HEADER)); + // another start frequency (bits 2..6), in an element that ends 6 bits in + let mut cut = data.clone(); + cut[0] ^= 0x04; + assert!(e.read(Payload { data: &cut, start: 0, end: 6, crc: false }).is_none()); + assert_eq!(e.header, Some(TEST_HEADER)); + assert!(e.read(Payload { data: &cut, start: 0, end: 8 * cut.len(), crc: false }).is_some()); + assert_ne!(e.header, Some(TEST_HEADER)); + } +} diff --git a/crates/audio/src/aac/sbr/qmf.rs b/crates/audio/src/aac/sbr/qmf.rs new file mode 100644 index 000000000..de947f303 --- /dev/null +++ b/crates/audio/src/aac/sbr/qmf.rs @@ -0,0 +1,155 @@ +//! The SBR filter banks (ISO/IEC 14496-3 §4.6.18.4): a 32-band complex QMF analysis bank at the +//! core rate and a 64-band complex QMF synthesis bank at the output rate, sharing the window `c`. +//! +//! Each bank is a polyphase window followed by a complex modulation: analysis +//! `2·exp(iπ(k + ½)(2n − ½)/64)`, synthesis `exp(iπ(k + ½)(2n − 251)/128)/64`. With the window's +//! indexing, the two phase offsets put the banks' combined response on its centre tap, so the low +//! band comes back unchanged (to about -60 dB, the window's own limit), 576 output samples late. +//! The synthesis bank holds back its last two output samples, which makes it 578: with the SBR +//! frame's lag, the 962 samples by which HE-AAC decoders (ffmpeg's among them) delay the core, and +//! which the priming encoders signal counts on. + +use std::f64::consts::PI; +use std::sync::OnceLock; + +use super::Cx; +use super::tables::QMF_WINDOW; + +/// Output samples by which analysis followed by synthesis delays the low band. +pub(crate) const DELAY: usize = 578; + +/// Modulation matrices, computed once. +struct Kernels { + /// Analysis, at `[k][n]`: 32 × 64. + analysis: Vec<[Cx; 64]>, + /// Synthesis, at `[n][k]`: 128 × 64. + synthesis: Vec<[Cx; 64]>, +} + +fn kernels() -> &'static Kernels { + static K: OnceLock = OnceLock::new(); + K.get_or_init(|| { + let cx = |phase: f64, scale: f64| Cx { re: (phase.cos() * scale) as f32, im: (phase.sin() * scale) as f32 }; + let analysis = (0..32).map(|k| std::array::from_fn(|n| cx(PI * (k as f64 + 0.5) * (2.0 * n as f64 - 0.5) / 64.0, 2.0))).collect(); + let synthesis = (0..128).map(|n| std::array::from_fn(|k| cx(PI * (k as f64 + 0.5) * (2.0 * n as f64 - 251.0) / 128.0, 1.0 / 64.0))).collect(); + Kernels { analysis, synthesis } + }) +} + +/// The 32-band analysis bank of one channel. +#[derive(Clone)] +pub(crate) struct Analysis { + /// The last 320 input samples, newest first. + x: Box<[f32; 320]>, +} + +impl Analysis { + pub(crate) fn new() -> Analysis { + Analysis { x: Box::new([0.0; 320]) } + } + + /// Split `32 · out.len()` input samples into `out.len()` slots of 32 subbands. + pub(crate) fn process(&mut self, input: &[f32], out: &mut [[Cx; 32]]) { + let k = kernels(); + for (block, slot) in input.as_chunks::<32>().0.iter().zip(out.iter_mut()) { + self.x.copy_within(0..288, 32); + for (n, &s) in block.iter().enumerate() { + self.x[31 - n] = s; + } + let mut u = [0f32; 64]; + for (n, u) in u.iter_mut().enumerate() { + *u = (0..5).map(|j| self.x[n + 64 * j] * QMF_WINDOW[2 * (n + 64 * j)]).sum(); + } + for (sb, row) in slot.iter_mut().zip(&k.analysis) { + let (mut re, mut im) = (0f32, 0f32); + for (u, m) in u.iter().zip(row) { + re += u * m.re; + im += u * m.im; + } + *sb = Cx { re, im }; + } + } + } +} + +/// The 64-band synthesis bank of one channel. +#[derive(Clone)] +pub(crate) struct Synthesis { + v: Box<[f32; 1280]>, + /// The last two output samples, which the next slot puts out first. + held: [f32; 2], +} + +impl Synthesis { + pub(crate) fn new() -> Synthesis { + Synthesis { v: Box::new([0.0; 1280]), held: [0.0; 2] } + } + + /// Merge one slot of 64 subbands into 64 output samples (`out[..64]`, of which it needs 64). + /// Bands from `bands` up are taken as zero. + pub(crate) fn process(&mut self, x: &[Cx; 64], bands: usize, out: &mut [f32]) { + let k = kernels(); + let bands = bands.min(64); + self.v.copy_within(0..1152, 128); + for (n, row) in k.synthesis.iter().enumerate() { + self.v[n] = x[..bands].iter().zip(row).map(|(x, m)| x.re * m.re - x.im * m.im).sum(); + } + let mut slot = [0f32; 66]; + slot[..2].copy_from_slice(&self.held); + for (n, o) in slot[2..].iter_mut().enumerate() { + let mut s = 0f32; + for i in 0..5 { + s += self.v[256 * i + n] * QMF_WINDOW[128 * i + n]; + s += self.v[256 * i + 192 + n] * QMF_WINDOW[128 * i + 64 + n]; + } + *o = s; + } + self.held = [slot[64], slot[65]]; + for (o, s) in out.iter_mut().zip(&slot[..64]) { + *o = *s; + } + } +} + +#[cfg(test)] +mod tests { + use super::super::SLOTS; + use super::*; + + fn tones(t: f64) -> f64 { + [0.0371, 0.0913, 0.1527, 0.2211, 0.2903, 0.3519, 0.4127].iter().enumerate().map(|(j, f)| (2.0 * PI * f * t + j as f64).sin() * 0.1).sum() + } + + /// Tones across the core band through analysis and synthesis (low 32 bands only) come out at + /// twice the rate, [`DELAY`] samples late, with the error 60 dB down. + #[test] + fn analysis_then_synthesis_reconstructs_the_input() { + let input: Vec = (0..8 * 1024).map(|i| tones(i as f64) as f32).collect(); + let (mut a, mut s) = (Analysis::new(), Synthesis::new()); + let mut out = Vec::new(); + let mut slots = [[Cx::default(); 32]; SLOTS]; + for frame in input.as_chunks::<1024>().0 { + a.process(frame, &mut slots); + for slot in &slots { + let mut x = [Cx::default(); 64]; + x[..32].copy_from_slice(slot); + let mut o = [0f32; 64]; + s.process(&x, 32, &mut o); + out.extend_from_slice(&o); + } + } + assert_eq!(out.len(), 2 * input.len()); + let snr_at = |delay: usize| { + let (mut err, mut sig) = (0f64, 0f64); + for (i, &o) in out.iter().enumerate().skip(3000) { + let want = tones((i as f64 - delay as f64) / 2.0); + err += (o as f64 - want).powi(2); + sig += want * want; + } + 10.0 * (sig / err).log10() + }; + let snr = snr_at(DELAY); + assert!(snr > 60.0, "reconstruction SNR {snr:.1} dB"); + assert!(snr_at(DELAY - 1) < 40.0 && snr_at(DELAY + 1) < 40.0); + } +} diff --git a/crates/audio/src/aac/sbr/tables.rs b/crates/audio/src/aac/sbr/tables.rs new file mode 100644 index 000000000..7a648120e --- /dev/null +++ b/crates/audio/src/aac/sbr/tables.rs @@ -0,0 +1,357 @@ +//! SBR tables (ETSI ES 201 980 V1.2.2 Annex I, the SBR tables it shares with ISO/IEC 14496-3 §4.A.6). +//! +//! Transcribed from the specification's tables, which give the window to 14 digits (more than an +//! `f32` holds). Annex I misprints the labels of `c[297]` and `c[305]`; the values are in order. +//! Each Huffman table is a complete prefix code (see the tests in `sbr::bitstream`). + +#![allow(clippy::excessive_precision)] + +/// The QMF bank window `c` (640 coefficients). +#[rustfmt::skip] +pub(crate) static QMF_WINDOW: [f32; 640] = [ + -0.00055252865047, -0.00056176925738, -0.00049475180896, -0.00048752279712, -0.00048937912498, -0.00050407143497, -0.00052265642972, -0.00054665656337, + -0.00056778025613, -0.00058709304852, -0.00061327473938, -0.00063124935319, -0.00065403333621, -0.00067776907764, -0.00069416146273, -0.00071577364744, + -0.00072550431222, -0.00074409418541, -0.00074905980532, -0.00076813719270, -0.00077248485949, -0.00078343322877, -0.00077798694927, -0.00078036647100, + -0.00078014496257, -0.00077579773310, -0.00076307935757, -0.00075300014201, -0.00073193571525, -0.00072153919876, -0.00069179375372, -0.00066504150893, + -0.00063415949025, -0.00059461189330, -0.00055645763906, -0.00051455722108, -0.00046063254803, -0.00040951214522, -0.00035011758756, -0.00028969811748, + -0.00020983373440, -0.00014463809349, -0.00006173344072, 0.00001349497418, 0.00010943831274, 0.00020430170688, 0.00029495311041, 0.00040265402160, + 0.00051073884952, 0.00062393761391, 0.00074580258865, 0.00086084433262, 0.00098859883015, 0.00112501551307, 0.00125778846475, 0.00139024948272, + 0.00154432198471, 0.00168680832531, 0.00183482654224, 0.00198411407369, 0.00214615835557, 0.00230172547746, 0.00246256169126, 0.00262017586902, + 0.00278704643465, 0.00294694477165, 0.00311254206525, 0.00327396134847, 0.00344188741828, 0.00360082681231, 0.00376039229104, 0.00392074323703, + 0.00408197531935, 0.00422642692270, 0.00437307196781, 0.00452098527825, 0.00466064606118, 0.00479325608498, 0.00491376035745, 0.00503930226013, + 0.00514073539032, 0.00524611661324, 0.00534716811982, 0.00541967759307, 0.00548760401507, 0.00554757145088, 0.00559380230045, 0.00562206432097, + 0.00564551969164, 0.00563891995151, 0.00562661141932, 0.00559171286630, 0.00554043639400, 0.00547537830770, 0.00538389758970, 0.00527157587272, + 0.00513822754514, 0.00498396877629, 0.00481094690600, 0.00460395301471, 0.00438018617447, 0.00412516423270, 0.00384564081246, 0.00354012465507, + 0.00320918858098, 0.00284467578623, 0.00245085400321, 0.00202741761850, 0.00157846825768, 0.00109023290512, 0.00058322642480, 0.00002760451905, + -0.00054642808664, -0.00115681355227, -0.00180394725893, -0.00248267236449, -0.00319337783900, -0.00394011240522, -0.00472225962400, -0.00553372111088, + -0.00637922932685, -0.00726158168517, -0.00817982333726, -0.00913253296085, -0.01011502154986, -0.01113155480321, -0.01218499959508, -0.01327182200351, + 0.01439046660792, 0.01554055533423, 0.01673247129989, 0.01794333813443, 0.01918724313698, 0.02045317933555, 0.02174675502535, 0.02306801692862, + 0.02441609920285, 0.02578758475467, 0.02718594296329, 0.02860721736385, 0.03005026574279, 0.03150176087389, 0.03297540810337, 0.03446209487686, + 0.03596975605542, 0.03748128504252, 0.03900536794745, 0.04053491705584, 0.04206490946367, 0.04360975421304, 0.04514884056413, 0.04668430272642, + 0.04821657200672, 0.04973857556014, 0.05125561555216, 0.05276307465207, 0.05424527683589, 0.05571736482138, 0.05716164501299, 0.05859156836260, + 0.05998374801761, 0.06134551717207, 0.06268578081172, 0.06397158980681, 0.06522471064380, 0.06643675122104, 0.06760759851228, 0.06870438283512, + 0.06976302447127, 0.07076287107266, 0.07170026731102, 0.07256825833083, 0.07336202550803, 0.07410036424342, 0.07474525581194, 0.07531373362019, + 0.07580083586584, 0.07619924793396, 0.07649921704119, 0.07670934904245, 0.07681739756964, 0.07682300113923, 0.07672049241746, 0.07650507183194, + 0.07617483218536, 0.07573057565061, 0.07515762552870, 0.07446643947564, 0.07364060057620, 0.07267746427299, 0.07158263647903, 0.07035330735093, + 0.06896640131951, 0.06745250215166, 0.06576906686508, 0.06394448059633, 0.06196027790387, 0.05981665708090, 0.05751526919867, 0.05504600343009, + 0.05240938217366, 0.04959786763445, 0.04663033051701, 0.04347687821958, 0.04014582784127, 0.03664181168133, 0.03295839306691, 0.02908240060125, + 0.02503075618909, 0.02079970728622, 0.01637012582228, 0.01176238327857, 0.00696368621617, 0.00197656014503, -0.00320868968304, -0.00857117491366, + -0.01412888273558, -0.01988341292573, -0.02582272888064, -0.03195312745332, -0.03827765720822, -0.04478068215856, -0.05148041767934, -0.05837053268336, + -0.06544098531359, -0.07269433008129, -0.08013729344279, -0.08775475365593, -0.09555333528914, -0.10353295311463, -0.11168269317730, -0.12000779846800, + -0.12850028503878, -0.13715517611934, -0.14597664911870, -0.15496070710605, -0.16409588556669, -0.17338081721706, -0.18281725485142, -0.19239667457267, + -0.20212501768103, -0.21197358538056, -0.22196526964149, -0.23206908706791, -0.24230168845974, -0.25264803095722, -0.26310532994603, -0.27366340405625, + -0.28432141891085, -0.29507167170646, -0.30590985751916, -0.31682789136456, -0.32781137272105, -0.33887226938665, -0.34999141229310, -0.36115899031355, + 0.37237955463061, 0.38363500139043, 0.39492117615675, 0.40623176767625, 0.41756968968409, 0.42891199207373, 0.44025537543665, 0.45159965356824, + 0.46293080852757, 0.47424532146115, 0.48552530911099, 0.49677082545707, 0.50798175000434, 0.51912349702391, 0.53022408956855, 0.54125534487322, + 0.55220512585061, 0.56307891401370, 0.57385241316923, 0.58454032354679, 0.59511230862496, 0.60557835389180, 0.61591099320291, 0.62612426956055, + 0.63619801077286, 0.64612696959461, 0.65590163024671, 0.66551398801627, 0.67496631901712, 0.68423532934598, 0.69332823767032, 0.70223887193539, + 0.71094104263095, 0.71944626349561, 0.72774489002994, 0.73582117582769, 0.74368278636488, 0.75131374561237, 0.75870807608242, 0.76586748650939, + 0.77277808813327, 0.77942875190216, 0.78583531203920, 0.79197358416424, 0.79784664137700, 0.80344857518505, 0.80876950044491, 0.81381912706217, + 0.81857760046468, 0.82304198905409, 0.82722753473360, 0.83110384571520, 0.83469373618402, 0.83797173378865, 0.84095413924722, 0.84362382812005, + 0.84598184698206, 0.84803157770763, 0.84978051984268, 0.85119715249343, 0.85230470352147, 0.85310209497017, 0.85357205739107, 0.85373856005937, + 0.85357205739107, 0.85310209497017, 0.85230470352147, 0.85119715249343, 0.84978051984268, 0.84803157770763, 0.84598184698206, 0.84362382812005, + 0.84095413924722, 0.83797173378865, 0.83469373618402, 0.83110384571520, 0.82722753473360, 0.82304198905409, 0.81857760046468, 0.81381912706217, + 0.80876950044491, 0.80344857518505, 0.79784664137700, 0.79197358416424, 0.78583531203920, 0.77942875190216, 0.77277808813327, 0.76586748650939, + 0.75870807608242, 0.75131374561237, 0.74368278636488, 0.73582117582769, 0.72774489002994, 0.71944626349561, 0.71094104263095, 0.70223887193539, + 0.69332823767032, 0.68423532934598, 0.67496631901712, 0.66551398801627, 0.65590163024671, 0.64612696959461, 0.63619801077286, 0.62612426956055, + 0.61591099320291, 0.60557835389180, 0.59511230862496, 0.58454032354679, 0.57385241316923, 0.56307891401370, 0.55220512585061, 0.54125534487322, + 0.53022408956855, 0.51912349702391, 0.50798175000434, 0.49677082545707, 0.48552530911099, 0.47424532146115, 0.46293080852757, 0.45159965356824, + 0.44025537543665, 0.42891199207373, 0.41756968968409, 0.40623176767625, 0.39492117615675, 0.38363500139043, 0.37237955463061, 0.36115899031355, + -0.34999141229310, -0.33887226938665, -0.32781137272105, -0.31682789136456, -0.30590985751916, -0.29507167170646, -0.28432141891085, -0.27366340405625, + -0.26310532994603, -0.25264803095722, -0.24230168845974, -0.23206908706791, -0.22196526964149, -0.21197358538056, -0.20212501768103, -0.19239667457267, + -0.18281725485142, -0.17338081721706, -0.16409588556669, -0.15496070710605, -0.14597664911870, -0.13715517611934, -0.12850028503878, -0.12000779846800, + -0.11168269317730, -0.10353295311463, -0.09555333528914, -0.08775475365593, -0.08013729344279, -0.07269433008129, -0.06544098531359, -0.05837053268336, + -0.05148041767934, -0.04478068215856, -0.03827765720822, -0.03195312745332, -0.02582272888064, -0.01988341292573, -0.01412888273558, -0.00857117491366, + -0.00320868968304, 0.00197656014503, 0.00696368621617, 0.01176238327857, 0.01637012582228, 0.02079970728622, 0.02503075618909, 0.02908240060125, + 0.03295839306691, 0.03664181168133, 0.04014582784127, 0.04347687821958, 0.04663033051701, 0.04959786763445, 0.05240938217366, 0.05504600343009, + 0.05751526919867, 0.05981665708090, 0.06196027790387, 0.06394448059633, 0.06576906686508, 0.06745250215166, 0.06896640131951, 0.07035330735093, + 0.07158263647903, 0.07267746427299, 0.07364060057620, 0.07446643947564, 0.07515762552870, 0.07573057565061, 0.07617483218536, 0.07650507183194, + 0.07672049241746, 0.07682300113923, 0.07681739756964, 0.07670934904245, 0.07649921704119, 0.07619924793396, 0.07580083586584, 0.07531373362019, + 0.07474525581194, 0.07410036424342, 0.07336202550803, 0.07256825833083, 0.07170026731102, 0.07076287107266, 0.06976302447127, 0.06870438283512, + 0.06760759851228, 0.06643675122104, 0.06522471064380, 0.06397158980681, 0.06268578081172, 0.06134551717207, 0.05998374801761, 0.05859156836260, + 0.05716164501299, 0.05571736482138, 0.05424527683589, 0.05276307465207, 0.05125561555216, 0.04973857556014, 0.04821657200672, 0.04668430272642, + 0.04514884056413, 0.04360975421304, 0.04206490946367, 0.04053491705584, 0.03900536794745, 0.03748128504252, 0.03596975605542, 0.03446209487686, + 0.03297540810337, 0.03150176087389, 0.03005026574279, 0.02860721736385, 0.02718594296329, 0.02578758475467, 0.02441609920285, 0.02306801692862, + 0.02174675502535, 0.02045317933555, 0.01918724313698, 0.01794333813443, 0.01673247129989, 0.01554055533423, 0.01439046660792, 0.01327182200351, + -0.01218499959508, -0.01113155480321, -0.01011502154986, -0.00913253296085, -0.00817982333726, -0.00726158168517, -0.00637922932685, -0.00553372111088, + -0.00472225962400, -0.00394011240522, -0.00319337783900, -0.00248267236449, -0.00180394725893, -0.00115681355227, -0.00054642808664, 0.00002760451905, + 0.00058322642480, 0.00109023290512, 0.00157846825768, 0.00202741761850, 0.00245085400321, 0.00284467578623, 0.00320918858098, 0.00354012465507, + 0.00384564081246, 0.00412516423270, 0.00438018617447, 0.00460395301471, 0.00481094690600, 0.00498396877629, 0.00513822754514, 0.00527157587272, + 0.00538389758970, 0.00547537830770, 0.00554043639400, 0.00559171286630, 0.00562661141932, 0.00563891995151, 0.00564551969164, 0.00562206432097, + 0.00559380230045, 0.00554757145088, 0.00548760401507, 0.00541967759307, 0.00534716811982, 0.00524611661324, 0.00514073539032, 0.00503930226013, + 0.00491376035745, 0.00479325608498, 0.00466064606118, 0.00452098527825, 0.00437307196781, 0.00422642692270, 0.00408197531935, 0.00392074323703, + 0.00376039229104, 0.00360082681231, 0.00344188741828, 0.00327396134847, 0.00311254206525, 0.00294694477165, 0.00278704643465, 0.00262017586902, + 0.00246256169126, 0.00230172547746, 0.00214615835557, 0.00198411407369, 0.00183482654224, 0.00168680832531, 0.00154432198471, 0.00139024948272, + 0.00125778846475, 0.00112501551307, 0.00098859883015, 0.00086084433262, 0.00074580258865, 0.00062393761391, 0.00051073884952, 0.00040265402160, + 0.00029495311041, 0.00020430170688, 0.00010943831274, 0.00001349497418, -0.00006173344072, -0.00014463809349, -0.00020983373440, -0.00028969811748, + -0.00035011758756, -0.00040951214522, -0.00046063254803, -0.00051455722108, -0.00055645763906, -0.00059461189330, -0.00063415949025, -0.00066504150893, + -0.00069179375372, -0.00072153919876, -0.00073193571525, -0.00075300014201, -0.00076307935757, -0.00077579773310, -0.00078014496257, -0.00078036647100, + -0.00077798694927, -0.00078343322877, -0.00077248485949, -0.00076813719270, -0.00074905980532, -0.00074409418541, -0.00072550431222, -0.00071577364744, + -0.00069416146273, -0.00067776907764, -0.00065403333621, -0.00063124935319, -0.00061327473938, -0.00058709304852, -0.00056778025613, -0.00054665656337, + -0.00052265642972, -0.00050407143497, -0.00048937912498, -0.00048752279712, -0.00049475180896, -0.00056176925738, -0.00055252865047, 0.00000000000000, +]; + +/// The SBR noise table `V` (Table I.13): 512 unit-variance complex values (re, im). +#[rustfmt::skip] +pub(crate) static NOISE: [[f32; 2]; 512] = [ + [-0.99948153278296, -0.59483417516607], [0.97113454393991, -0.67528515225647], [0.14130051758487, -0.95090983575689], [-0.47005496701697, -0.37340549728647], + [0.80705063769351, 0.29653668284408], [-0.38981478896926, 0.89572605717087], [-0.01053049862020, -0.66959058036166], [-0.91266367957293, -0.11522938140034], + [0.54840422910309, 0.75221367176302], [0.40009252867955, -0.98929400334421], [-0.99867974711855, -0.88147068645358], [-0.95531076805040, 0.90908757154593], + [-0.45725933317144, -0.56716323646760], [-0.72929675029275, -0.98008272727324], [0.75622801399036, 0.20950329995549], [0.07069442601050, -0.78247898470706], + [0.74496252926055, -0.91169004445807], [-0.96440182703856, -0.94739918296622], [0.30424629369539, -0.49438267012479], [0.66565033746925, 0.64652935542491], + [0.91697008020594, 0.17514097332009], [-0.70774918760427, 0.52548653416543], [-0.70051415345560, -0.45340028808763], [-0.99496513054797, -0.90071908066973], + [0.98164490790123, -0.77463155528697], [-0.54671580548181, -0.02570928536004], [-0.01689629065389, 0.00287506445732], [-0.86110349531986, 0.42548583726477], + [-0.98892980586032, -0.87881132267556], [0.51756627678691, 0.66926784710139], [-0.99635026409640, -0.58107730574765], [-0.99969370862163, 0.98369989360250], + [0.55266258627194, 0.59449057465591], [0.34581177741673, 0.94879421061866], [0.62664209577999, -0.74402970906471], [-0.77149701404973, -0.33883658042801], + [-0.91592244254432, 0.03687901376713], [-0.76285492357887, -0.91371867919124], [0.79788337195331, -0.93180971199849], [0.54473080610200, -0.11919206037186], + [-0.85639281671058, 0.42429854760451], [-0.92882402971423, 0.27871809078609], [-0.11708371046774, -0.99800843444966], [0.21356749817493, -0.90716295627033], + [-0.76191692573909, 0.99768118356265], [0.98111043100884, -0.95854459734407], [-0.85913269895572, 0.95766566168880], [-0.93307242253692, 0.49431757696466], + [0.30485754879632, -0.70540034357529], [0.85289650925190, 0.46766131791044], [0.91328082618125, -0.99839597361769], [-0.05890199924154, 0.70741827819497], + [0.28398686150148, 0.34633555702188], [0.95258164539612, -0.54893416026939], [-0.78566324168507, -0.75568541079691], [-0.95789495447877, -0.20423194696966], + [0.82411158711197, 0.96654618432562], [-0.65185446735885, -0.88734990773289], [-0.93643603134666, 0.99870790442385], [0.91427159529618, -0.98290505544444], + [-0.70395684036886, 0.58796798221039], [0.00563771969365, 0.61768196727244], [0.89065051931895, 0.52783352697585], [-0.68683707712762, 0.80806944710339], + [0.72165342518718, -0.69259857349564], [-0.62928247730667, 0.13627037407335], [0.29938434065514, -0.46051329682246], [-0.91781958879280, -0.74012716684186], + [0.99298717043688, 0.40816610075661], [0.82368298622748, -0.74036047190173], [-0.98512833386833, -0.99972330709594], [-0.95915368242257, -0.99237800466040], + [-0.21411126572790, -0.93424819052545], [-0.68821476106884, -0.26892306315457], [0.91851997982317, 0.09358228901785], [-0.96062769559127, 0.36099095133739], + [0.51646184922287, -0.71373332873917], [0.61130721139669, 0.46950141175917], [0.47336129371299, -0.27333178296162], [0.90998308703519, 0.96715662938132], + [0.44844799194357, 0.99211574628306], [0.66614891079092, 0.96590176169121], [0.74922239129237, -0.89879858826087], [-0.99571588506485, 0.52785521494349], + [0.97401082477563, -0.16855870075190], [0.72683747733879, -0.48060774432251], [0.95432193457128, 0.68849603408441], [-0.72962208425191, -0.76608443420917], + [-0.85359479233537, 0.88738125901579], [-0.81412430338535, -0.97480768049637], [-0.87930772356786, 0.74748307690436], [-0.71573331064977, -0.98570608178923], + [0.83524300028228, 0.83702537075163], [-0.48086065601423, -0.98848504923531], [0.97139128574778, 0.80093621198236], [0.51992825347895, 0.80247631400510], + [-0.00848591195325, -0.76670128000486], [-0.70294374303036, 0.55359910445577], [-0.95894428168140, -0.43265504344783], [0.97079252950321, 0.09325857238682], + [-0.92404293670797, 0.85507704027855], [-0.69506469500450, 0.98633412625459], [0.26559203620024, 0.73314307966524], [0.28038443336943, 0.14537913654427], + [-0.74138124825523, 0.99310339807762], [-0.01752795995444, -0.82616635284178], [-0.55126773094930, -0.98898543862153], [0.97960898850996, -0.94021446752851], + [-0.99196309146936, 0.67019017358456], [-0.67684928085260, 0.12631491649378], [0.09140039465500, -0.20537731453108], [-0.71658965751996, -0.97788200391224], + [0.81014640078925, 0.53722648362443], [0.40616991671205, -0.26469008598449], [-0.67680188682972, 0.94502052337695], [0.86849774348749, -0.18333598647899], + [-0.99500381284851, -0.02634122068550], [0.84329189340667, 0.10406957462213], [-0.09215968531446, 0.69540012101253], [0.99956173327206, -0.12358542001404], + [-0.79732779473535, -0.91582524736159], [0.96349973642406, 0.96640458041000], [-0.79942778496547, 0.64323902822857], [-0.11566039853896, 0.28587846253726], + [-0.39922954514662, 0.94129601616966], [0.99089197565987, -0.92062625581587], [0.28631285179909, -0.91035047143603], [-0.83302725605608, -0.67330410892084], + [0.95404443402072, 0.49162765398743], [-0.06449863579434, 0.03250560813135], [-0.99575054486311, 0.42389784469507], [-0.65501142790847, 0.82546114655624], + [-0.81254441908887, -0.51627234660629], [-0.99646369485481, 0.84490533520752], [0.00287840603348, 0.64768261158166], [0.70176989408455, -0.20453028573322], + [0.96361882270190, 0.40706967140989], [-0.68883758192426, 0.91338958840772], [-0.34875585502238, 0.71472290693300], [0.91980081243087, 0.66507455644919], + [-0.99009048343881, 0.85868021604848], [0.68865791458395, 0.55660316809678], [-0.99484402129368, -0.20052559254934], [0.94214511408023, -0.99696425367461], + [-0.67414626793544, 0.49548221180078], [-0.47339353684664, -0.85904328834047], [0.14323651387360, -0.94145598222488], [-0.29268293575672, 0.05759224927952], + [0.43793861458754, -0.78904969892724], [-0.36345126374441, 0.64874435357162], [-0.08750604656825, 0.97686944362527], [-0.96495267812511, -0.53960305946511], + [0.55526940659947, 0.78891523734774], [0.73538215752630, 0.96452072373404], [-0.30889773919437, -0.80664389776860], [0.03574995626194, -0.97325616900959], + [0.98720684660488, 0.48409133691962], [-0.81689296271203, -0.90827703628298], [0.67866860118215, 0.81284503870856], [-0.15808569732583, 0.85279555024382], + [0.80723395114371, -0.24717418514605], [0.47788757329038, -0.46333147839295], [0.96367554763201, 0.38486749303242], [-0.99143875716818, -0.24945277239809], + [0.83081876925833, -0.94780851414763], [-0.58753191905341, 0.01290772389163], [0.95538108220960, -0.85557052096538], [-0.96490920476211, -0.64020970923102], + [-0.97327101028521, 0.12378128133110], [0.91400366022124, 0.57972471346930], [-0.99925837363824, 0.71084847864067], [-0.86875903507313, -0.20291699203564], + [-0.26240034795124, -0.68264554369108], [-0.24664412953388, -0.87642273115183], [0.02416275806869, 0.27192914288905], [0.82068619590515, -0.85087787994476], + [0.88547373760759, -0.89636802901469], [-0.18173078152226, -0.26152145156800], [0.09355476558534, 0.54845123045604], [-0.54668414224090, 0.95980774020221], + [0.37050990604091, -0.59910140383171], [-0.70373594262891, 0.91227665827081], [-0.34600785879594, -0.99441426144200], [-0.68774481731008, -0.30238837956299], + [-0.26843291251234, 0.83115668004362], [0.49072334613242, -0.45359708737775], [0.38975993093975, 0.95515358099121], [-0.97757125224150, 0.05305894580606], + [-0.17325552859616, -0.92770672250494], [0.99948035025744, 0.58285545563426], [-0.64946246527458, 0.68645507104960], [-0.12016920576437, -0.57147322153312], + [-0.58947456517751, -0.34847132454388], [-0.41815140454465, 0.16276422358861], [0.99885650204884, 0.11136095490444], [-0.56649614128386, -0.90494866361587], + [0.94138021032330, 0.35281916733018], [-0.75725076534641, 0.53650549640587], [0.20541973692630, -0.94435144369918], [0.99980371023351, 0.79835913565599], + [0.29078277605775, 0.35393777921520], [-0.62858772103030, 0.38765693387102], [0.43440904467688, -0.98546330463232], [-0.98298583762390, 0.21021524625209], + [0.19513029146934, -0.94239832251867], [-0.95476662400101, 0.98364554179143], [0.93379635304810, -0.70881994583682], [-0.85235410573336, -0.08342347966410], + [-0.86425093011245, -0.45795025029466], [0.38879779059045, 0.97274429344593], [0.92045124735495, -0.62433652524220], [0.89162532251878, 0.54950955570563], + [-0.36834336949252, 0.96458298020975], [0.93891760988045, -0.89968353740388], [0.99267657565094, -0.03757034316958], [-0.94063471614176, 0.41332338538963], + [0.99740224117019, -0.16830494996370], [-0.35899413170555, -0.46633226649613], [0.05237237274947, -0.25640361602661], [0.36703583957424, -0.38653265641875], + [0.91653180367913, -0.30587628726597], [0.69000803499316, 0.90952171386132], [-0.38658751133527, 0.99501571208985], [-0.29250814029851, 0.37444994344615], + [-0.60182204677608, 0.86779651036123], [-0.97418588163217, 0.96468523666475], [0.88461574003963, 0.57508405276414], [0.05198933055162, 0.21269661669964], + [-0.53499621979720, 0.97241553731237], [-0.49429560226497, 0.98183865291903], [-0.98935142339139, -0.40249159006933], [-0.98081380091130, -0.72856895534041], + [-0.27338148835532, 0.99950922447209], [0.06310802338302, -0.54539587529618], [-0.20461677199539, -0.14209977628489], [0.66223843141647, 0.72528579940326], + [-0.84764345483665, 0.02372316801261], [-0.89039863483811, 0.88866581484602], [0.95903308477986, 0.76744927173873], [0.73504123909879, -0.03747203173192], + [-0.31744434966056, -0.36834111883652], [-0.34110827591623, 0.40211222807691], [0.47803883714199, -0.39423219786288], [0.98299195879514, 0.01989791390047], + [-0.30963073129751, -0.18076720599336], [0.99992588229018, -0.26281872094289], [-0.93149731080767, -0.98313162570490], [0.99923472302773, -0.80142993767554], + [-0.26024169633417, -0.75999759855752], [-0.35712514743563, 0.19298963768574], [-0.99899084509530, 0.74645156992493], [0.86557171579452, 0.55593866696299], + [0.33408042438752, 0.86185953874709], [0.99010736374716, 0.04602397576623], [-0.66694269691195, -0.91643611810148], [0.64016792079480, 0.15649530836856], + [0.99570534804836, 0.45844586038111], [-0.63431466947340, 0.21079116459234], [-0.07706847005931, -0.89581437101329], [0.98590090577724, 0.88241721133981], + [0.80099335254678, -0.36851896710853], [0.78368131392666, 0.45506999802597], [0.08707806671691, 0.80938994918745], [-0.86811883080712, 0.39347308654705], + [-0.39466529740375, -0.66809432114456], [0.97875325649683, -0.72467840967746], [-0.95038560288864, 0.89563219587625], [0.17005239424212, 0.54683053962658], + [-0.76910792026848, -0.96226617549298], [0.99743281016846, 0.42697157037567], [0.95437383549973, 0.97002324109952], [0.99578905365569, -0.54106826257356], + [0.28058259829990, -0.85361420634036], [0.85256524470573, -0.64567607735589], [-0.50608540105128, -0.65846015480300], [-0.97210735183243, -0.23095213067791], + [0.95424048234441, -0.99240147091219], [-0.96926570524023, 0.73775654896574], [0.30872163214726, 0.41514960556126], [-0.24523839572639, 0.63206633394807], + [-0.33813265086024, -0.38661779441897], [-0.05826828420146, -0.06940774188029], [-0.22898461455054, 0.97054853316316], [-0.18509915019881, 0.47565762892084], + [-0.10488238045009, -0.87769947402394], [-0.71886586182037, 0.78030982480538], [0.99793873738654, 0.90041310491497], [0.57563307626120, -0.91034337352097], + [0.28909646383717, 0.96307783970534], [0.42188998312520, 0.48148651230437], [0.93335049681047, -0.43537023883588], [-0.97087374418267, 0.86636445711364], + [0.36722871286923, 0.65291654172961], [-0.81093025665696, 0.08778370229363], [-0.26240603062237, -0.92774095379098], [0.83996497984604, 0.55839849139647], + [-0.99909615720225, -0.96024605713970], [0.74649464155061, 0.12144893606462], [-0.74774595569805, -0.26898062008959], [0.95781667469567, -0.79047927052628], + [0.95472308713099, -0.08588776019550], [0.48708332746299, 0.99999041579432], [0.46332038247497, 0.10964126185063], [-0.76497004940162, 0.89210929242238], + [0.57397389364339, 0.35289703373760], [0.75374316974495, 0.96705214651335], [-0.59174397685714, -0.89405370422752], [0.75087906691890, -0.29612672982396], + [-0.98607857336230, 0.25034911730023], [-0.40761056640505, -0.90045573444695], [0.66929266740477, 0.98629493401748], [-0.97463695257310, -0.00190223301301], + [0.90145509409859, 0.99781390365446], [-0.87259289048043, 0.99233587353666], [-0.91529461447692, -0.15698707534206], [-0.03305738840705, -0.37205262859764], + [0.07223051368337, -0.88805001733626], [0.99498012188353, 0.97094358113387], [-0.74904939500519, 0.99985483641521], [0.04585228574211, 0.99812337444082], + [-0.89054954257993, -0.31791913188064], [-0.83782144651251, 0.97637632547466], [0.33454804933804, -0.86231516800408], [-0.99707579362824, 0.93237990079441], + [-0.22827527843994, 0.18874759397997], [0.67248046289143, -0.03646211390569], [-0.05146538187944, -0.92599700120679], [0.99947295749905, 0.93625229707912], + [0.66951124390363, 0.98905825623893], [-0.99602956559179, -0.44654715757688], [0.82104905483590, 0.99540741724928], [0.99186510988782, 0.72023001312947], + [-0.65284592392918, 0.52186723253637], [0.93885443798188, -0.74895312615259], [0.96735248738388, 0.90891816978629], [-0.22225968841114, 0.57124029781228], + [-0.44132783753414, -0.92688840659280], [-0.85694974219574, 0.88844532719844], [0.91783042091762, -0.46356892383970], [0.72556974415690, -0.99899555770747], + [-0.99711581834508, 0.58211560180426], [0.77638976371966, 0.94321834873819], [0.07717324253925, 0.58638399856595], [-0.56049829194163, 0.82522301569036], + [0.98398893639988, 0.39467440420569], [0.47546946844938, 0.68613044836811], [0.65675089314631, 0.18331637134880], [0.03273375457980, -0.74933109564108], + [-0.38684144784738, 0.51337349030406], [-0.97346267944545, -0.96549364384098], [-0.53282156061942, -0.91423265091354], [0.99817310731176, 0.61133572482148], + [-0.50254500772635, -0.88829338134294], [0.01995873238855, 0.85223515096765], [0.99930381973804, 0.94578896296649], [0.82907767600783, -0.06323442598128], + [-0.58660709669728, 0.96840773806582], [-0.17573736667267, -0.48166920859485], [0.83434292401346, -0.13023450646997], [0.05946491307025, 0.20511047074866], + [0.81505484574602, -0.94685947861369], [-0.44976380954860, 0.40894572671545], [-0.89746474625671, 0.99846578838537], [0.39677256130792, -0.74854668609359], + [-0.07588948563079, 0.74096214084170], [0.76343198951445, 0.41746629422634], [-0.74490104699626, 0.94725911744610], [0.64880119792759, 0.41336660830571], + [0.62319537462542, -0.93098313552599], [0.42215817594807, -0.07712787385208], [0.02704554141885, -0.05417518053666], [0.80001773566818, 0.91542195141039], + [-0.79351832348816, -0.36208897989136], [0.63872359151636, 0.08128252493444], [0.52890520960295, 0.60048872455592], [0.74238552914587, 0.04491915291044], + [0.99096131449250, -0.19451182854402], [-0.80412329643109, -0.88513818199457], [-0.64612616129736, 0.72198674804544], [0.11657770663191, -0.83662833815041], + [-0.95053182488101, -0.96939905138082], [-0.62228872928622, 0.82767262846661], [0.03004475787316, -0.99738896333384], [-0.97987214341034, 0.36526129686425], + [-0.99986980746200, -0.36021610299715], [0.89110648599879, -0.97894250343044], [0.10407960510582, 0.77357793811619], [0.95964737821728, -0.35435818285502], + [0.50843233159162, 0.96107691266205], [0.17006334670615, -0.76854025314829], [0.25872675063360, 0.99893303933816], [-0.01115998681937, 0.98496019742444], + [-0.79598702973261, 0.97138411318894], [-0.99264708948101, -0.99542822402536], [-0.99829663752818, 0.01877138824311], [-0.70801016548184, 0.33680685948117], + [-0.70467057786826, 0.93272777501857], [0.99846021905254, -0.98725746254433], [-0.63364968534650, -0.16473594423746], [-0.16258217500792, -0.95939125400802], + [-0.43645594360633, -0.94805030113284], [-0.99848471702976, 0.96245166923809], [-0.16796458968998, -0.98987511890470], [-0.87979225745213, -0.71725725041680], + [0.44183099021786, -0.93568974498761], [0.93310180125532, -0.99913308068246], [-0.93941931782002, -0.56409379640356], [-0.88590003188677, 0.47624600491382], + [0.99971463703691, -0.83889954253462], [-0.75376385639978, 0.00814643438625], [0.93887685615875, -0.11284528204636], [0.85126435782309, 0.52349251543547], + [0.39701421446381, 0.81779634174316], [-0.37024464187437, -0.87071656222959], [-0.36024828242896, 0.34655735648287], [-0.93388812549209, -0.84476541096429], + [-0.65298804552119, -0.18439575450921], [0.11960319006843, 0.99899346780168], [0.94292565553160, 0.83163906518293], [0.75081145286948, -0.35533223142265], + [0.56721979748394, -0.24076836414499], [0.46857766746029, -0.30140233457198], [0.97312313923635, -0.99548191630031], [-0.38299976567017, 0.98516909715427], + [0.41025800019463, 0.02116736935734], [0.09638062008048, 0.04411984381457], [-0.85283249275397, 0.91475563922421], [0.88866808958124, -0.99735267083226], + [-0.48202429536989, -0.96805608884164], [0.27572582416567, 0.58634753335832], [-0.65889129659168, 0.58835634138583], [0.98838086953732, 0.99994349600236], + [-0.20651349620689, 0.54593044066355], [-0.62126416356920, -0.59893681700392], [0.20320105410437, -0.86879180355289], [-0.97790548600584, 0.96290806999242], + [0.11112534735126, 0.21484763313301], [-0.41368337314182, 0.28216837680365], [0.24133038992960, 0.51294362630238], [-0.66393410674885, -0.08249679629081], + [-0.53697829178752, -0.97649903936228], [-0.97224737889348, 0.22081333579837], [0.87392477144549, -0.12796173740361], [0.19050361015753, 0.01602615387195], + [-0.46353441212724, -0.95249041539006], [-0.07064096339021, -0.94479803205886], [-0.92444085484466, -0.10457590187436], [-0.83822593578728, -0.01695043208885], + [0.75214681811150, -0.99955681042665], [-0.42102998829339, 0.99720941999394], [-0.72094786237696, -0.35008961934255], [0.78843311019251, 0.52851398958271], + [0.97394027897442, -0.26695944086561], [0.99206463477946, -0.57010120849429], [0.76789609461795, -0.76519356730966], [-0.82002421836409, -0.73530179553767], + [0.81924990025724, 0.99698425250579], [-0.26719850873357, 0.68903369776193], [-0.43311260380975, 0.85321815947490], [0.99194979673836, 0.91876249766422], + [-0.80692001248487, -0.32627540663214], [0.43080003649976, -0.21919095636638], [0.67709491937357, -0.95478075822906], [0.56151770568316, -0.70693811747778], + [0.10831862810749, -0.08628837174592], [0.91229417540436, -0.65987351408410], [-0.48972893932274, 0.56289246362686], [-0.89033658689697, -0.71656563987082], + [0.65269447475094, 0.65916004833932], [0.67439478141121, -0.81684380846796], [-0.47770832416973, -0.16789556203025], [-0.99715979260878, -0.93565784007648], + [-0.90889593602546, 0.62034397054380], [-0.06618622548177, -0.23812217221359], [0.99430266919728, 0.18812555317553], [0.97686402381843, -0.28664534366620], + [0.94813650221268, -0.97506640027128], [-0.95434497492853, -0.79607978501983], [-0.49104783137150, 0.32895214359663], [0.99881175120751, 0.88993983831354], + [0.50449166760303, -0.85995072408434], [0.47162891065108, -0.18680204049569], [-0.62081581361840, 0.75000676218956], [-0.43867015250812, 0.99998069244322], + [0.98630563232075, -0.53578899600662], [-0.61510362277374, -0.89515019899997], [-0.03841517601843, -0.69888815681179], [-0.30102157304644, -0.07667808922205], + [0.41881284182683, 0.02188098922282], [-0.86135454941237, 0.98947480909359], [0.67226861393788, -0.13494389011014], [-0.70737398842068, -0.76547349325992], + [0.94044946687963, 0.09026201157416], [-0.82386352534327, 0.08924768823676], [-0.32070666698656, 0.50143421908753], [0.57593163224487, -0.98966422921509], + [-0.36326018419965, 0.07440243123228], [0.99979044674350, -0.14130287347405], [-0.92366023326932, -0.97979298068180], [-0.44607178518598, -0.54233252016394], + [0.44226800932956, 0.71326756742752], [0.03671907158312, 0.63606389366675], [0.52175424682195, -0.85396826735705], [-0.94701139690956, -0.01826348194255], + [-0.98759606946049, 0.82288714303073], [0.87434794743625, 0.89399495655433], [-0.93412041758744, 0.41374052024363], [0.96063943315511, 0.93116709541280], + [0.97534253457837, 0.86150930812689], [0.99642466504163, 0.70190043427512], [-0.94705089665984, -0.29580042814306], [0.91599807087376, -0.98147830385781], +]; + +/// `t_huffman_env_1_5dB` (`(codeword, length)` by index): envelope, time direction, 1.5 dB steps (LAV 60). +#[rustfmt::skip] +pub(crate) static T_ENV_1_5DB: [(u32, u8); 121] = [ + (0x3ffd6, 18), (0x3ffd7, 18), (0x3ffd8, 18), (0x3ffd9, 18), (0x3ffda, 18), (0x3ffdb, 18), (0x7ffb8, 19), (0x7ffb9, 19), + (0x7ffba, 19), (0x7ffbb, 19), (0x7ffbc, 19), (0x7ffbd, 19), (0x7ffbe, 19), (0x7ffbf, 19), (0x7ffc0, 19), (0x7ffc1, 19), + (0x7ffc2, 19), (0x7ffc3, 19), (0x7ffc4, 19), (0x7ffc5, 19), (0x7ffc6, 19), (0x7ffc7, 19), (0x7ffc8, 19), (0x7ffc9, 19), + (0x7ffca, 19), (0x7ffcb, 19), (0x7ffcc, 19), (0x7ffcd, 19), (0x7ffce, 19), (0x7ffcf, 19), (0x7ffd0, 19), (0x7ffd1, 19), + (0x7ffd2, 19), (0x7ffd3, 19), (0x1ffe6, 17), (0x3ffd4, 18), (0xfff0, 16), (0x1ffe9, 17), (0x3ffd5, 18), (0x1ffe7, 17), + (0xfff1, 16), (0xffec, 16), (0xffed, 16), (0xffee, 16), (0x7ff4, 15), (0x3ff9, 14), (0x3ff7, 14), (0x1ffa, 13), + (0x1ff9, 13), (0xffb, 12), (0x7fc, 11), (0x3fc, 10), (0x1fd, 9), (0xfd, 8), (0x7d, 7), (0x3d, 6), + (0x1d, 5), (0xd, 4), (0x5, 3), (0x1, 2), (0x0, 2), (0x4, 3), (0xc, 4), (0x1c, 5), + (0x3c, 6), (0x7c, 7), (0xfc, 8), (0x1fc, 9), (0x3fd, 10), (0xffa, 12), (0x1ff8, 13), (0x3ff6, 14), + (0x3ff8, 14), (0x7ff5, 15), (0xffef, 16), (0x1ffe8, 17), (0xfff2, 16), (0x7ffd4, 19), (0x7ffd5, 19), (0x7ffd6, 19), + (0x7ffd7, 19), (0x7ffd8, 19), (0x7ffd9, 19), (0x7ffda, 19), (0x7ffdb, 19), (0x7ffdc, 19), (0x7ffdd, 19), (0x7ffde, 19), + (0x7ffdf, 19), (0x7ffe0, 19), (0x7ffe1, 19), (0x7ffe2, 19), (0x7ffe3, 19), (0x7ffe4, 19), (0x7ffe5, 19), (0x7ffe6, 19), + (0x7ffe7, 19), (0x7ffe8, 19), (0x7ffe9, 19), (0x7ffea, 19), (0x7ffeb, 19), (0x7ffec, 19), (0x7ffed, 19), (0x7ffee, 19), + (0x7ffef, 19), (0x7fff0, 19), (0x7fff1, 19), (0x7fff2, 19), (0x7fff3, 19), (0x7fff4, 19), (0x7fff5, 19), (0x7fff6, 19), + (0x7fff7, 19), (0x7fff8, 19), (0x7fff9, 19), (0x7fffa, 19), (0x7fffb, 19), (0x7fffc, 19), (0x7fffd, 19), (0x7fffe, 19), + (0x7ffff, 19), +]; + +/// `f_huffman_env_1_5dB` (`(codeword, length)` by index): envelope, frequency direction, 1.5 dB steps (LAV 60). +#[rustfmt::skip] +pub(crate) static F_ENV_1_5DB: [(u32, u8); 121] = [ + (0x7ffe7, 19), (0x7ffe8, 19), (0xfffd2, 20), (0xfffd3, 20), (0xfffd4, 20), (0xfffd5, 20), (0xfffd6, 20), (0xfffd7, 20), + (0xfffd8, 20), (0x7ffda, 19), (0xfffd9, 20), (0xfffda, 20), (0xfffdb, 20), (0xfffdc, 20), (0x7ffdb, 19), (0xfffdd, 20), + (0x7ffdc, 19), (0x7ffdd, 19), (0xfffde, 20), (0x3ffe4, 18), (0xfffdf, 20), (0xfffe0, 20), (0xfffe1, 20), (0x7ffde, 19), + (0xfffe2, 20), (0xfffe3, 20), (0xfffe4, 20), (0x7ffdf, 19), (0xfffe5, 20), (0x7ffe0, 19), (0x3ffe8, 18), (0x7ffe1, 19), + (0x3ffe0, 18), (0x3ffe9, 18), (0x1ffef, 17), (0x3ffe5, 18), (0x1ffec, 17), (0x1ffed, 17), (0x1ffee, 17), (0xfff4, 16), + (0xfff3, 16), (0xfff0, 16), (0x7ff7, 15), (0x7ff6, 15), (0x3ffa, 14), (0x1ffa, 13), (0x1ff9, 13), (0xffa, 12), + (0xff8, 12), (0x7f9, 11), (0x3fb, 10), (0x1fc, 9), (0x1fa, 9), (0xfb, 8), (0x7c, 7), (0x3c, 6), + (0x1c, 5), (0xc, 4), (0x5, 3), (0x1, 2), (0x0, 2), (0x4, 3), (0xd, 4), (0x1d, 5), + (0x3d, 6), (0xfa, 8), (0xfc, 8), (0x1fb, 9), (0x3fa, 10), (0x7f8, 11), (0x7fa, 11), (0x7fb, 11), + (0xff9, 12), (0xffb, 12), (0x1ff8, 13), (0x1ffb, 13), (0x3ff8, 14), (0x3ff9, 14), (0xfff1, 16), (0xfff2, 16), + (0x1ffea, 17), (0x1ffeb, 17), (0x3ffe1, 18), (0x3ffe2, 18), (0x3ffea, 18), (0x3ffe3, 18), (0x3ffe6, 18), (0x3ffe7, 18), + (0x3ffeb, 18), (0xfffe6, 20), (0x7ffe2, 19), (0xfffe7, 20), (0xfffe8, 20), (0xfffe9, 20), (0xfffea, 20), (0xfffeb, 20), + (0xfffec, 20), (0x7ffe3, 19), (0xfffed, 20), (0xfffee, 20), (0xfffef, 20), (0xffff0, 20), (0x7ffe4, 19), (0xffff1, 20), + (0x3ffec, 18), (0xffff2, 20), (0xffff3, 20), (0x7ffe5, 19), (0x7ffe6, 19), (0xffff4, 20), (0xffff5, 20), (0xffff6, 20), + (0xffff7, 20), (0xffff8, 20), (0xffff9, 20), (0xffffa, 20), (0xffffb, 20), (0xffffc, 20), (0xffffd, 20), (0xffffe, 20), + (0xfffff, 20), +]; + +/// `t_huffman_env_bal_1_5dB` (`(codeword, length)` by index): envelope balance, time direction, 1.5 dB steps (LAV 24). +#[rustfmt::skip] +pub(crate) static T_ENV_BAL_1_5DB: [(u32, u8); 49] = [ + (0xffe4, 16), (0xffe5, 16), (0xffe6, 16), (0xffe7, 16), (0xffe8, 16), (0xffe9, 16), (0xffea, 16), (0xffeb, 16), + (0xffec, 16), (0xffed, 16), (0xffee, 16), (0xffef, 16), (0xfff0, 16), (0xfff1, 16), (0xfff2, 16), (0xfff3, 16), + (0xfff4, 16), (0xffe2, 16), (0xffc, 12), (0x7fc, 11), (0x1fe, 9), (0x7e, 7), (0x1e, 5), (0x6, 3), + (0x0, 1), (0x2, 2), (0xe, 4), (0x3e, 6), (0xfe, 8), (0x7fd, 11), (0xffd, 12), (0x7ff0, 15), + (0xffe3, 16), (0xfff5, 16), (0xfff6, 16), (0xfff7, 16), (0xfff8, 16), (0xfff9, 16), (0xfffa, 16), (0x1fff6, 17), + (0x1fff7, 17), (0x1fff8, 17), (0x1fff9, 17), (0x1fffa, 17), (0x1fffb, 17), (0x1fffc, 17), (0x1fffd, 17), (0x1fffe, 17), + (0x1ffff, 17), +]; + +/// `f_huffman_env_bal_1_5dB` (`(codeword, length)` by index): envelope balance, frequency direction, 1.5 dB steps (LAV 24). +#[rustfmt::skip] +pub(crate) static F_ENV_BAL_1_5DB: [(u32, u8); 49] = [ + (0x3ffe2, 18), (0x3ffe3, 18), (0x3ffe4, 18), (0x3ffe5, 18), (0x3ffe6, 18), (0x3ffe7, 18), (0x3ffe8, 18), (0x3ffe9, 18), + (0x3ffea, 18), (0x3ffeb, 18), (0x3ffec, 18), (0x3ffed, 18), (0x3ffee, 18), (0x3ffef, 18), (0x3fff0, 18), (0xfff7, 16), + (0x1fff0, 17), (0x3ffc, 14), (0x7fe, 11), (0x7fc, 11), (0xfe, 8), (0x7e, 7), (0xe, 4), (0x2, 2), + (0x0, 1), (0x6, 3), (0x1e, 5), (0x3e, 6), (0x1fe, 9), (0x7fd, 11), (0xffe, 12), (0x7ffa, 15), + (0xfff6, 16), (0x3fff1, 18), (0x3fff2, 18), (0x3fff3, 18), (0x3fff4, 18), (0x3fff5, 18), (0x3fff6, 18), (0x3fff7, 18), + (0x3fff8, 18), (0x3fff9, 18), (0x3fffa, 18), (0x3fffb, 18), (0x3fffc, 18), (0x3fffd, 18), (0x3fffe, 18), (0x7fffe, 19), + (0x7ffff, 19), +]; + +/// `t_huffman_env_3_0dB` (`(codeword, length)` by index): envelope, time direction, 3 dB steps (LAV 31). +#[rustfmt::skip] +pub(crate) static T_ENV_3_0DB: [(u32, u8); 63] = [ + (0x3ffed, 18), (0x3ffee, 18), (0x7ffde, 19), (0x7ffdf, 19), (0x7ffe0, 19), (0x7ffe1, 19), (0x7ffe2, 19), (0x7ffe3, 19), + (0x7ffe4, 19), (0x7ffe5, 19), (0x7ffe6, 19), (0x7ffe7, 19), (0x7ffe8, 19), (0x7ffe9, 19), (0x7ffea, 19), (0x7ffeb, 19), + (0x7ffec, 19), (0x1fff4, 17), (0xfff7, 16), (0xfff9, 16), (0xfff8, 16), (0x3ffb, 14), (0x3ffa, 14), (0x3ff8, 14), + (0x1ffa, 13), (0xffc, 12), (0x7fc, 11), (0xfe, 8), (0x3e, 6), (0xe, 4), (0x2, 2), (0x0, 1), + (0x6, 3), (0x1e, 5), (0x7e, 7), (0x1fe, 9), (0x7fd, 11), (0x1ffb, 13), (0x3ff9, 14), (0x3ffc, 14), + (0x7ffa, 15), (0xfff6, 16), (0x1fff5, 17), (0x3ffec, 18), (0x7ffed, 19), (0x7ffee, 19), (0x7ffef, 19), (0x7fff0, 19), + (0x7fff1, 19), (0x7fff2, 19), (0x7fff3, 19), (0x7fff4, 19), (0x7fff5, 19), (0x7fff6, 19), (0x7fff7, 19), (0x7fff8, 19), + (0x7fff9, 19), (0x7fffa, 19), (0x7fffb, 19), (0x7fffc, 19), (0x7fffd, 19), (0x7fffe, 19), (0x7ffff, 19), +]; + +/// `f_huffman_env_3_0dB` (`(codeword, length)` by index): envelope and noise floor, frequency direction, 3 dB steps (LAV 31). +#[rustfmt::skip] +pub(crate) static F_ENV_3_0DB: [(u32, u8); 63] = [ + (0xffff0, 20), (0xffff1, 20), (0xffff2, 20), (0xffff3, 20), (0xffff4, 20), (0xffff5, 20), (0xffff6, 20), (0x3fff3, 18), + (0x7fff5, 19), (0x7ffee, 19), (0x7ffef, 19), (0x7fff6, 19), (0x3fff4, 18), (0x3fff2, 18), (0xffff7, 20), (0x7fff0, 19), + (0x1fff5, 17), (0x3fff0, 18), (0x1fff4, 17), (0xfff7, 16), (0xfff6, 16), (0x7ff8, 15), (0x3ffb, 14), (0xffd, 12), + (0x7fd, 11), (0x3fd, 10), (0x1fd, 9), (0xfd, 8), (0x3e, 6), (0xe, 4), (0x2, 2), (0x0, 1), + (0x6, 3), (0x1e, 5), (0xfc, 8), (0x1fc, 9), (0x3fc, 10), (0x7fc, 11), (0xffc, 12), (0x1ffc, 13), + (0x3ffa, 14), (0x7ff9, 15), (0x7ffa, 15), (0xfff8, 16), (0xfff9, 16), (0x1fff6, 17), (0x1fff7, 17), (0x3fff5, 18), + (0x3fff6, 18), (0x3fff1, 18), (0xffff8, 20), (0x7fff1, 19), (0x7fff2, 19), (0x7fff3, 19), (0xffff9, 20), (0x7fff7, 19), + (0x7fff4, 19), (0xffffa, 20), (0xffffb, 20), (0xffffc, 20), (0xffffd, 20), (0xffffe, 20), (0xfffff, 20), +]; + +/// `t_huffman_env_bal_3_0dB` (`(codeword, length)` by index): envelope balance, time direction, 3 dB steps (LAV 12). +#[rustfmt::skip] +pub(crate) static T_ENV_BAL_3_0DB: [(u32, u8); 25] = [ + (0x1ff2, 13), (0x1ff3, 13), (0x1ff4, 13), (0x1ff5, 13), (0x1ff6, 13), (0x1ff7, 13), (0x1ff8, 13), (0xff8, 12), + (0xfe, 8), (0x7e, 7), (0xe, 4), (0x6, 3), (0x0, 1), (0x2, 2), (0x1e, 5), (0x3e, 6), + (0x1fe, 9), (0x1ff9, 13), (0x1ffa, 13), (0x1ffb, 13), (0x1ffc, 13), (0x1ffd, 13), (0x1ffe, 13), (0x3ffe, 14), + (0x3fff, 14), +]; + +/// `f_huffman_env_bal_3_0dB` (`(codeword, length)` by index): envelope and noise floor balance, frequency direction, 3 dB steps (LAV 12). +#[rustfmt::skip] +pub(crate) static F_ENV_BAL_3_0DB: [(u32, u8); 25] = [ + (0x1ff7, 13), (0x1ff8, 13), (0x1ff9, 13), (0x1ffa, 13), (0x1ffb, 13), (0x3ff8, 14), (0x3ff9, 14), (0x7fc, 11), + (0xfe, 8), (0x7e, 7), (0xe, 4), (0x2, 2), (0x0, 1), (0x6, 3), (0x1e, 5), (0x3e, 6), + (0x1fe, 9), (0xffa, 12), (0x1ff6, 13), (0x3ffa, 14), (0x3ffb, 14), (0x3ffc, 14), (0x3ffd, 14), (0x3ffe, 14), + (0x3fff, 14), +]; + +/// `t_huffman_noise_3_0dB` (`(codeword, length)` by index): noise floor, time direction (LAV 31). +#[rustfmt::skip] +pub(crate) static T_NOISE_3_0DB: [(u32, u8); 63] = [ + (0x1fce, 13), (0x1fcf, 13), (0x1fd0, 13), (0x1fd1, 13), (0x1fd2, 13), (0x1fd3, 13), (0x1fd4, 13), (0x1fd5, 13), + (0x1fd6, 13), (0x1fd7, 13), (0x1fd8, 13), (0x1fd9, 13), (0x1fda, 13), (0x1fdb, 13), (0x1fdc, 13), (0x1fdd, 13), + (0x1fde, 13), (0x1fdf, 13), (0x1fe0, 13), (0x1fe1, 13), (0x1fe2, 13), (0x1fe3, 13), (0x1fe4, 13), (0x1fe5, 13), + (0x1fe6, 13), (0x1fe7, 13), (0x7f2, 11), (0xfd, 8), (0x3e, 6), (0xe, 4), (0x6, 3), (0x0, 1), + (0x2, 2), (0x1e, 5), (0xfc, 8), (0x3f8, 10), (0x1fcc, 13), (0x1fe8, 13), (0x1fe9, 13), (0x1fea, 13), + (0x1feb, 13), (0x1fec, 13), (0x1fcd, 13), (0x1fed, 13), (0x1fee, 13), (0x1fef, 13), (0x1ff0, 13), (0x1ff1, 13), + (0x1ff2, 13), (0x1ff3, 13), (0x1ff4, 13), (0x1ff5, 13), (0x1ff6, 13), (0x1ff7, 13), (0x1ff8, 13), (0x1ff9, 13), + (0x1ffa, 13), (0x1ffb, 13), (0x1ffc, 13), (0x1ffd, 13), (0x1ffe, 13), (0x3ffe, 14), (0x3fff, 14), +]; + +/// `t_huffman_noise_bal_3_0dB` (`(codeword, length)` by index): noise floor balance, time direction (LAV 12). +#[rustfmt::skip] +pub(crate) static T_NOISE_BAL_3_0DB: [(u32, u8); 25] = [ + (0xec, 8), (0xed, 8), (0xee, 8), (0xef, 8), (0xf0, 8), (0xf1, 8), (0xf2, 8), (0xf3, 8), + (0xf4, 8), (0xf5, 8), (0x1c, 5), (0x2, 2), (0x0, 1), (0x6, 3), (0x3a, 6), (0xf6, 8), + (0xf7, 8), (0xf8, 8), (0xf9, 8), (0xfa, 8), (0xfb, 8), (0xfc, 8), (0xfd, 8), (0xfe, 8), + (0xff, 8), +]; diff --git a/crates/audio/src/lib.rs b/crates/audio/src/lib.rs index 3ed684245..f8441a51d 100644 --- a/crates/audio/src/lib.rs +++ b/crates/audio/src/lib.rs @@ -24,9 +24,9 @@ //! file bit for bit, and minimal files around such PCM, for essence embedded in other containers. //! - [`peaks`] (feature `peaks`): min/max/RMS pyramid for waveform drawing. //! - Codec modules for packet-level use, each with its own features: [`aac`] (`aac-decode`, -//! `aac-encode`), [`alac`] (`alac-decode`, `alac-encode`), [`opus`] (`opus-decode`), [`ac3`] -//! (`ac3-decode`), [`mpa`] (`mpa-decode`), [`mp3`] (`mp3-decode`, `mp3-encode`), -//! [`flac`] (`flac-encode`: the packet encoder). +//! `aac-encode`), [`alac`] (`alac-decode`, `alac-encode`), [`opus`] (`opus-decode`, and +//! [`opus::encoder`] with `opus-encode`), [`ac3`] (`ac3-decode`), [`mpa`] (`mpa-decode`), [`mp3`] +//! (`mp3-decode`, `mp3-encode`), [`flac`] (`flac-encode`: the packet encoder). //! //! The shared types below build with no features. @@ -42,7 +42,7 @@ mod layout; pub mod mp3; #[cfg(feature = "mpa-decode")] pub mod mpa; -#[cfg(feature = "opus-decode")] +#[cfg(any(feature = "opus-decode", feature = "opus-encode"))] pub mod opus; #[cfg(any(feature = "aac-decode", feature = "alac-decode", feature = "opus-decode", feature = "ac3-decode", feature = "mpa-decode"))] mod pcm; @@ -207,6 +207,13 @@ impl From for AudioError { } } +#[cfg(feature = "opus-encode")] +impl From for AudioError { + fn from(e: opus::encoder::Error) -> Self { + AudioError::Encode(e.to_string()) + } +} + /// The file formats [`encode`] writes. Each needs its feature: WAV and AIFF `pcm-encode`, FLAC /// `flac-encode`; asking for one that is not built returns [`AudioError::Unsupported`]. #[non_exhaustive] diff --git a/crates/audio/src/opus/encoder/bands.rs b/crates/audio/src/opus/encoder/bands.rs new file mode 100644 index 000000000..604aab595 --- /dev/null +++ b/crates/audio/src/opus/encoder/bands.rs @@ -0,0 +1,497 @@ +//! Band shape coding: PVQ search and codeword indexing, spreading, recursive band splitting +//! (RFC 6716 §4.3.4, encoder direction). +//! +//! The encoder codes 20 ms long-block frames only (no transients, no TF changes), and stereo +//! as dual (independent L/R) stereo, so a band is always coded by `quant_partition` with a single +//! block. Every bit-budget computation mirrors the decoder so both sides take the same +//! split/pulse decisions. + +use crate::opus::encoder::range::{BITRES, RangeEncoder, ilog}; +use crate::opus::encoder::rate::{Mode, QTHETA_OFFSET, get_pulses}; +use crate::opus::encoder::tables::{EBANDS, NB_EBANDS}; + +pub const SPREAD_NORMAL: usize = 2; + +#[inline] +fn frac_mul16(a: i32, b: i32) -> i32 { + (16384 + (a as i16 as i32) * (b as i16 as i32)) >> 15 +} + +fn bitexact_cos(x: i32) -> i32 { + let tmp = (4096 + x * x) >> 13; + let x2 = tmp; + let x2 = (32767 - x2) + frac_mul16(x2, -7651 + frac_mul16(x2, 8277 + frac_mul16(-626, x2))); + 1 + x2 +} + +fn bitexact_log2tan(isin: i32, icos: i32) -> i32 { + let lc = ilog(icos as u32); + let ls = ilog(isin as u32); + let icos = icos << (15 - lc); + let isin = isin << (15 - ls); + (ls - lc) * (1 << 11) + frac_mul16(isin, frac_mul16(isin, -2597) + 7932) - frac_mul16(icos, frac_mul16(icos, -2597) + 7932) +} + +fn compute_qn(n: i32, b: i32, offset: i32, pulse_cap: i32, stereo: bool) -> i32 { + const EXP2_TABLE8: [i32; 8] = [16384, 17866, 19483, 21247, 23170, 25267, 27554, 30048]; + let mut n2 = 2 * n - 1; + if stereo && n == 2 { + n2 -= 1; + } + let mut qb = (b + n2 * offset) / n2; + qb = qb.min(b - pulse_cap - (4 << BITRES)); + qb = qb.min(8 << BITRES); + if qb < (1 << BITRES >> 1) { + 1 + } else { + let qn = EXP2_TABLE8[(qb & 0x7) as usize] >> (14 - (qb >> BITRES)); + (qn + 1) >> 1 << 1 + } +} + +/// One elementary pass of the spreading rotation, inverted (the decoder applies the forward +/// pass; the encoder undoes it before the pulse search). +fn exp_rotation1_inv(x: &mut [f32], len: usize, stride: usize, c: f32, s: f32) { + if len <= stride { + return; + } + if len >= 2 * stride + 1 { + for i in 0..=len - 2 * stride - 1 { + let x1 = x[i]; + let x2 = x[i + stride]; + x[i + stride] = c * x2 - s * x1; + x[i] = c * x1 + s * x2; + } + } + for i in (0..len - stride).rev() { + let x1 = x[i]; + let x2 = x[i + stride]; + x[i + stride] = c * x2 - s * x1; + x[i] = c * x1 + s * x2; + } +} + +/// Inverse of the decoder's spreading rotation (§4.3.4.3) for one block of `len` samples. +pub fn exp_rotation_inv(x: &mut [f32], len: usize, k: usize, spread: usize) { + const SPREAD_FACTOR: [usize; 3] = [15, 10, 5]; + if 2 * k >= len || spread == 0 { + return; + } + let factor = SPREAD_FACTOR[spread - 1]; + let gain = len as f32 / (len + factor * k) as f32; + let theta = 0.5 * gain * gain; + let c = (0.5 * std::f32::consts::PI * theta).cos(); + let s = (0.5 * std::f32::consts::PI * (1.0 - theta)).cos(); + let mut stride2 = 0; + if len >= 8 { + stride2 = 1; + while stride2 * stride2 + stride2 < len { + stride2 += 1; + } + } + // The decoder applies (stride2: s, c) then (1: c, s); undo in reverse order. + exp_rotation1_inv(x, len, 1, c, s); + if stride2 != 0 { + exp_rotation1_inv(x, len, stride2, s, c); + } +} + +/// Greedy PVQ search: the integer vector `y` with `sum |y| = k` closest in direction to `x`. +pub fn pvq_search(x: &[f32], k: usize, y: &mut [i32]) { + let n = x.len(); + y[..n].fill(0); + let abs: Vec = x.iter().map(|v| v.abs()).collect(); + let sum: f32 = abs.iter().sum(); + if !sum.is_finite() || sum <= 1e-15 { + y[0] = k as i32; + return; + } + let mut placed = 0usize; + let mut xy = 0f32; + let mut yy = 0f32; + if k > n / 2 { + // Pre-project onto the pyramid (rounding down) and place the rest greedily. + let r = (k as f32 - 1.0) / sum; + for j in 0..n { + let p = (abs[j] * r).floor() as i32; + y[j] = p; + placed += p as usize; + xy += abs[j] * p as f32; + yy += (p * p) as f32; + } + } + while placed < k { + let mut best = 0usize; + let mut best_num = -1f32; + let mut best_den = 1f32; + for j in 0..n { + let num = xy + abs[j]; + let num = num * num; + let den = yy + 2.0 * y[j] as f32 + 1.0; + if num * best_den > best_num * den { + best_num = num; + best_den = den; + best = j; + } + } + xy += abs[best]; + yy += 2.0 * y[best] as f32 + 1.0; + y[best] += 1; + placed += 1; + } + for j in 0..n { + if x[j] < 0.0 { + y[j] = -y[j]; + } + } +} + +/// Encodes the PVQ codeword `y` (`sum |y| = k`) as the index the RFC 6716 §4.3.4.2 decoder maps +/// back to `y`. +pub fn encode_pulses(y: &[i32], k: usize, enc: &mut RangeEncoder, v: &mut Vec) { + let n = y.len(); + let w = k + 1; + v.clear(); + v.resize((n + 1) * w, 0); + v[0] = 1; + for nn in 1..=n { + v[nn * w] = 1; + for kk in 1..=k { + let a = v[(nn - 1) * w + kk]; + let b = v[nn * w + kk - 1]; + let c = v[(nn - 1) * w + kk - 1]; + v[nn * w + kk] = a.saturating_add(b).saturating_add(c); + } + } + let total = v[n * w + k]; + let vv = |nn: usize, kx: usize| v[nn * w + kx]; + let mut index = 0u64; + let mut kk = k; + for j in 0..n { + if kk == 0 { + break; + } + let rem = n - j; + let t = y[j].unsigned_abs() as usize; + // Codewords with |y_j| = t start at sum_{m < kk - t} V(rem-1, m) within their sign half. + let mut base: u64 = (0..kk - t).map(|m| vv(rem - 1, m)).sum(); + if y[j] < 0 { + base += (vv(rem - 1, kk) + vv(rem, kk)) / 2; + } + index += base; + kk -= t; + } + debug_assert!(total <= u32::MAX as u64); + if total >= 2 { + enc.uint(index as u32, total as u32); + } +} + +pub struct BandEnc<'m, 'e> { + pub m: &'m Mode, + pub enc: &'e mut RangeEncoder, + pub i: usize, + pub spread: usize, + pub remaining_bits: i32, + pvq_v: Vec, + iy: Vec, + tmp: Vec, +} + +impl BandEnc<'_, '_> { + /// `compute_theta` for a mono split (single block): measures the energy split between the + /// two halves and codes the quantised angle with the triangular pdf. + fn compute_theta_split(&mut self, xa: &[f32], xb: &[f32], b: &mut i32, lm: i32) -> (i32, i32, i32) { + let n = xa.len() as i32; + let i = self.i; + let pulse_cap = self.m.log_n[i] + lm * (1 << BITRES); + let offset = (pulse_cap >> 1) - QTHETA_OFFSET; + let qn = compute_qn(n, *b, offset, pulse_cap, false); + let tell = self.enc.tell_frac(); + let mut itheta = 0i32; + if qn != 1 { + let mid = xa.iter().map(|v| v * v).sum::().sqrt(); + let side = xb.iter().map(|v| v * v).sum::().sqrt(); + let ang = (side as f64).atan2(mid as f64) * std::f64::consts::FRAC_2_PI; + let it = ((ang * qn as f64 + 0.5).floor() as i32).clamp(0, qn) as u32; + let q = qn as u32; + let half = q >> 1; + let ft = (half + 1) * (half + 1); + let (fl, fs) = if it <= half { (it * (it + 1) >> 1, it + 1) } else { (ft - ((q + 1 - it) * (q + 2 - it) >> 1), q + 1 - it) }; + self.enc.encode(fl, fl + fs, ft); + itheta = ((it * 16384) / q) as i32; + } + let qalloc = self.enc.tell_frac() - tell; + *b -= qalloc; + let delta = if itheta == 0 { + -16384 + } else if itheta == 16384 { + 16384 + } else { + let imid = bitexact_cos(itheta); + let iside = bitexact_cos(16384 - itheta); + frac_mul16((n - 1) << 7, bitexact_log2tan(iside, imid)) + }; + (itheta, delta, qalloc) + } + + /// `quant_partition` (single block, encoder side). + pub fn quant_partition(&mut self, x: &[f32], mut b: i32, mut lm: i32) { + let n = x.len(); + let i = self.i; + let cache = self.m.cache(lm, i); + let cache_max = cache[cache[0] as usize] as i32; + if lm != -1 && b > cache_max + 12 && n > 2 { + let half = n >> 1; + let (xa, xb) = x.split_at(half); + lm -= 1; + let (itheta, delta, qalloc) = self.compute_theta_split(xa, xb, &mut b, lm); + let mut mbits = 0.max(b.min((b - delta) / 2)); + let mut sbits = b - mbits; + self.remaining_bits -= qalloc; + let mut rebalance = self.remaining_bits; + if mbits >= sbits { + self.quant_partition(xa, mbits, lm); + rebalance = mbits - (rebalance - self.remaining_bits); + if rebalance > 3 << BITRES && itheta != 0 { + sbits += rebalance - (3 << BITRES); + } + self.quant_partition(xb, sbits, lm); + } else { + self.quant_partition(xb, sbits, lm); + rebalance = sbits - (rebalance - self.remaining_bits); + if rebalance > 3 << BITRES && itheta != 16384 { + mbits += rebalance - (3 << BITRES); + } + self.quant_partition(xa, mbits, lm); + } + return; + } + let m = self.m; + let mut q = m.bits2pulses(i, lm, b); + let mut curr_bits = m.pulses2bits(i, lm, q); + self.remaining_bits -= curr_bits; + while self.remaining_bits < 0 && q > 0 { + self.remaining_bits += curr_bits; + q -= 1; + curr_bits = m.pulses2bits(i, lm, q); + self.remaining_bits -= curr_bits; + } + if q != 0 { + let k = get_pulses(q) as usize; + let mut tmp = std::mem::take(&mut self.tmp); + tmp.clear(); + tmp.extend_from_slice(x); + exp_rotation_inv(&mut tmp, n, k, self.spread); + let mut iy = std::mem::take(&mut self.iy); + iy.clear(); + iy.resize(n, 0); + pvq_search(&tmp, k, &mut iy); + encode_pulses(&iy, k, self.enc, &mut self.pvq_v); + self.iy = iy; + self.tmp = tmp; + } + } +} + +/// Codes all band shapes (`quant_all_bands`, encoder side, long blocks). `x` holds `c` channels +/// of `n` normalised MDCT coefficients each. +#[allow(clippy::too_many_arguments)] +pub fn quant_all_bands( + m: &Mode, + start: usize, + end: usize, + x: &[f32], + n_total: usize, + c: usize, + pulses: &[i32; NB_EBANDS], + spread: usize, + dual_stereo: bool, + intensity: usize, + total_bits: i32, + mut balance: i32, + enc: &mut RangeEncoder, + lm: usize, + coded_bands: usize, +) { + let mm = 1usize << lm; + debug_assert!(c == 1 || dual_stereo || intensity <= start, "stereo frames are coded as dual stereo"); + let mut ctx = BandEnc { m, enc, i: 0, spread, remaining_bits: 0, pvq_v: Vec::new(), iy: Vec::new(), tmp: Vec::new() }; + for i in start..end { + ctx.i = i; + let xo = mm * EBANDS[i] as usize; + let n = mm * EBANDS[i + 1] as usize - xo; + let tell = ctx.enc.tell_frac(); + if i != start { + balance -= tell; + } + let remaining_bits = total_bits - tell - 1; + ctx.remaining_bits = remaining_bits; + let b = if i < coded_bands { + let curr_balance = balance / 3.min(coded_bands as i32 - i as i32); + 0.max(16383.min((remaining_bits + 1).min(pulses[i] + curr_balance))) + } else { + 0 + }; + // Bands at and above `intensity` (= coded_bands) carry no bits, so they emit no symbols. + if i < coded_bands || c == 1 { + if c == 2 && dual_stereo && i < intensity { + ctx.quant_partition(&x[xo..xo + n], b / 2, lm as i32); + ctx.quant_partition(&x[n_total + xo..n_total + xo + n], b / 2, lm as i32); + } else { + ctx.quant_partition(&x[xo..xo + n], b, lm as i32); + } + } + balance += pulses[i] + tell; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::opus::encoder::range::dec::RangeDecoder; + + fn exp_rotation1(x: &mut [f32], len: usize, stride: usize, c: f32, s: f32) { + let ms = -s; + if len <= stride { + return; + } + for i in 0..len - stride { + let x1 = x[i]; + let x2 = x[i + stride]; + x[i + stride] = c * x2 + s * x1; + x[i] = c * x1 + ms * x2; + } + if len >= 2 * stride + 1 { + for i in (0..=len - 2 * stride - 1).rev() { + let x1 = x[i]; + let x2 = x[i + stride]; + x[i + stride] = c * x2 + s * x1; + x[i] = c * x1 + ms * x2; + } + } + } + + /// The decoder's spreading rotation (single block). + fn exp_rotation(x: &mut [f32], len: usize, k: usize, spread: usize) { + const SPREAD_FACTOR: [usize; 3] = [15, 10, 5]; + if 2 * k >= len || spread == 0 { + return; + } + let factor = SPREAD_FACTOR[spread - 1]; + let gain = len as f32 / (len + factor * k) as f32; + let theta = 0.5 * gain * gain; + let c = (0.5 * std::f32::consts::PI * theta).cos(); + let s = (0.5 * std::f32::consts::PI * (1.0 - theta)).cos(); + let mut stride2 = 0; + if len >= 8 { + stride2 = 1; + while stride2 * stride2 + stride2 < len { + stride2 += 1; + } + } + if stride2 != 0 { + exp_rotation1(x, len, stride2, s, c); + } + exp_rotation1(x, len, 1, c, s); + } + + #[test] + fn spreading_inverse_is_exact() { + for &len in &[8usize, 16, 24, 48, 96, 176] { + for k in [1usize, 2, 3, 5] { + let orig: Vec = (0..len).map(|i| ((i * 31 % 17) as f32 - 8.0) / 8.0).collect(); + let mut x = orig.clone(); + exp_rotation_inv(&mut x, len, k, SPREAD_NORMAL); + exp_rotation(&mut x, len, k, SPREAD_NORMAL); + for (a, b) in x.iter().zip(&orig) { + assert!((a - b).abs() < 1e-4, "len={len} k={k}"); + } + } + } + } + + /// PVQ decoder of RFC 6716 §4.3.4.2 (as in FilmCraft's decoder). + fn decode_pulses(n: usize, k: usize, dec: &mut RangeDecoder) -> Vec { + let w = k + 1; + let mut v = vec![0u64; (n + 1) * w]; + v[0] = 1; + for nn in 1..=n { + v[nn * w] = 1; + for kk in 1..=k { + v[nn * w + kk] = v[(nn - 1) * w + kk].saturating_add(v[nn * w + kk - 1]).saturating_add(v[(nn - 1) * w + kk - 1]); + } + } + let total = v[n * w + k]; + let mut i = if total >= 2 { dec.uint(total as u32) as u64 } else { 0 }; + let mut y = vec![0i32; n]; + let mut kk = k; + for j in 0..n { + let rem = n - j; + if kk == 0 { + break; + } + let vv = |nn: usize, kx: usize| v[nn * w + kx]; + let mut p = (vv(rem - 1, kk) + vv(rem, kk)) / 2; + let neg = i >= p; + if neg { + i -= p; + } + let k0 = kk; + p -= vv(rem - 1, kk); + while p > i && kk > 0 { + kk -= 1; + p -= vv(rem - 1, kk); + } + let mag = (k0 - kk) as i32; + y[j] = if neg { -mag } else { mag }; + i -= p.min(i); + } + y + } + + fn all_codewords(n: usize, k: usize) -> Vec> { + if n == 0 { + return if k == 0 { vec![vec![]] } else { vec![] }; + } + let mut out = Vec::new(); + for t in -(k as i32)..=(k as i32) { + for mut rest in all_codewords(n - 1, k - t.unsigned_abs() as usize) { + rest.insert(0, t); + out.push(rest); + } + } + out + } + + #[test] + fn pvq_index_roundtrip_exhaustive() { + let mut v = Vec::new(); + for n in 1..6usize { + for k in 1..6usize { + for y in all_codewords(n, k) { + let mut e = RangeEncoder::new(); + encode_pulses(&y, k, &mut e, &mut v); + let buf = e.done(16); + let mut d = RangeDecoder::new(&buf); + assert_eq!(decode_pulses(n, k, &mut d), y, "n={n} k={k}"); + } + } + } + } + + #[test] + fn pvq_search_finds_good_direction() { + let x: Vec = (0..32).map(|i| ((i as f32) * 0.7).sin()).collect(); + let mut y = vec![0i32; 32]; + for k in [1usize, 4, 16, 64] { + pvq_search(&x, k, &mut y); + assert_eq!(y.iter().map(|v| v.unsigned_abs() as usize).sum::(), k); + let xy: f32 = x.iter().zip(&y).map(|(a, &b)| a * b as f32).sum(); + let yy: f32 = y.iter().map(|&b| (b * b) as f32).sum(); + let xx: f32 = x.iter().map(|a| a * a).sum(); + let corr = xy / (xx * yy).sqrt(); + assert!(corr > if k >= 16 { 0.9 } else { 0.2 }, "k={k} corr={corr}"); + } + } +} diff --git a/crates/audio/src/opus/encoder/energy.rs b/crates/audio/src/opus/encoder/energy.rs new file mode 100644 index 000000000..8d4f357d0 --- /dev/null +++ b/crates/audio/src/opus/encoder/energy.rs @@ -0,0 +1,230 @@ +//! Band energy quantisation: Laplace-coded coarse energy with inter/intra prediction, fine +//! energy and the final refinement bits (RFC 6716 §4.3.2, encoder direction). +//! +//! The quantised energies are updated with exactly the decoder's arithmetic so the prediction +//! state never drifts between encoder and decoder. + +use crate::opus::encoder::range::RangeEncoder; +use crate::opus::encoder::rate::MAX_FINE_BITS; +use crate::opus::encoder::tables::{BETA_COEF, BETA_INTRA, E_PROB_MODEL, NB_EBANDS, PRED_COEF, SMALL_ENERGY_ICDF}; + +const LAPLACE_MINP: u32 = 1; +const LAPLACE_NMIN: u32 = 16; + +fn laplace_get_freq1(fs0: u32, decay: u32) -> u32 { + let ft = 32768 - LAPLACE_MINP * (2 * LAPLACE_NMIN) - fs0; + (ft * (16384 - decay)) >> 15 +} + +/// Encodes a Laplace-distributed integer (`fs` = probability of 0 in Q15, `decay` in Q14). +/// Returns the value actually coded (very large magnitudes are clamped to the representable +/// range). +pub fn laplace_encode(enc: &mut RangeEncoder, value: i32, mut fs: u32, decay: u32) -> i32 { + let mut fl = 0u32; + let mut coded = value; + if value != 0 { + let s: i32 = if value < 0 { -1 } else { 0 }; + let val = value.abs(); + fl = fs; + fs = laplace_get_freq1(fs, decay); + let mut i = 1; + while fs > 0 && i < val { + fs *= 2; + fl += fs + 2 * LAPLACE_MINP; + fs = (fs * decay) >> 15; + i += 1; + } + if fs == 0 { + let ndi_max = (32768 - fl + LAPLACE_MINP - 1) as i32; + let ndi_max = (ndi_max - s) >> 1; + let di = (val - i).min(ndi_max - 1); + fl = (fl as i32 + (2 * di + 1 + s) * LAPLACE_MINP as i32) as u32; + fs = LAPLACE_MINP.min(32768 - fl); + let mag = i + di; + coded = if s < 0 { -mag } else { mag }; + } else { + fs += LAPLACE_MINP; + if s == 0 { + fl += fs; + } + } + } + enc.encode_bin(fl, fl + fs, 15); + coded +} + +/// `quant_coarse_energy` for one prediction mode. `e` holds the target log2 energies (relative +/// to `E_MEANS`), `old_e` the quantised energies, updated in place. `budget` is the frame size in +/// bits. +#[allow(clippy::too_many_arguments)] +pub fn quant_coarse_energy( + start: usize, + end: usize, + e: &[f32; 2 * NB_EBANDS], + old_e: &mut [f32; 2 * NB_EBANDS], + intra: bool, + enc: &mut RangeEncoder, + c: usize, + lm: usize, + budget: i32, +) { + let prob = &E_PROB_MODEL[lm][intra as usize]; + let (coef, beta) = if intra { (0.0, BETA_INTRA) } else { (PRED_COEF[lm], BETA_COEF[lm]) }; + let mut prev = [0f32; 2]; + for i in start..end { + for ch in 0..c { + let idx = i + ch * NB_EBANDS; + let oldc = old_e[idx].max(-9.0); + let x = e[idx]; + let f = x - coef * oldc - prev[ch]; + let mut qi = (f + 0.5).floor().clamp(-64.0, 64.0) as i32; + let tell = enc.tell(); + // Keep a few bits per remaining band in reserve when the budget runs out. + let bits_left = budget - tell - 3 * c as i32 * (end - i) as i32; + if i != start && bits_left < 30 { + if bits_left < 24 { + qi = qi.min(1); + } + if bits_left < 16 { + qi = qi.max(-1); + } + } + if budget - tell >= 15 { + let pi = 2 * i.min(20); + qi = laplace_encode(enc, qi, (prob[pi] as u32) << 7, (prob[pi + 1] as u32) << 6); + } else if budget - tell >= 2 { + qi = qi.clamp(-1, 1); + enc.icdf(if qi < 0 { (-2 * qi - 1) as usize } else { (2 * qi) as usize }, &SMALL_ENERGY_ICDF, 2); + } else if budget - tell >= 1 { + qi = qi.clamp(-1, 0); + enc.bit_logp(qi == -1, 1); + } else { + qi = -1; + } + let q = qi as f32; + old_e[idx] = oldc; + let tmp = coef * old_e[idx] + prev[ch] + q; + old_e[idx] = tmp; + prev[ch] = prev[ch] + q - beta * q; + } + } +} + +/// `quant_fine_energy`: raw bits refining each band's quantised energy. +pub fn quant_fine_energy( + start: usize, + end: usize, + e: &[f32; 2 * NB_EBANDS], + old_e: &mut [f32; 2 * NB_EBANDS], + fine_quant: &[i32; NB_EBANDS], + enc: &mut RangeEncoder, + c: usize, +) { + for i in start..end { + let fq = fine_quant[i]; + if fq <= 0 { + continue; + } + for ch in 0..c { + let idx = i + ch * NB_EBANDS; + let err = e[idx] - old_e[idx]; + let levels = 1i32 << fq; + let q2 = (((err + 0.5) * levels as f32).floor() as i32).clamp(0, levels - 1); + enc.bits(q2 as u32, fq as u32); + let offset = (q2 as f32 + 0.5) * (1 << (14 - fq)) as f32 / 16384.0 - 0.5; + old_e[idx] += offset; + } + } +} + +/// `quant_energy_finalise`: spends the bits left at the end of the frame on one more bit of +/// energy resolution per band, in priority order. +#[allow(clippy::too_many_arguments)] +pub fn quant_energy_finalise( + start: usize, + end: usize, + e: &[f32; 2 * NB_EBANDS], + old_e: &mut [f32; 2 * NB_EBANDS], + fine_quant: &[i32; NB_EBANDS], + fine_priority: &[i32; NB_EBANDS], + mut bits_left: i32, + enc: &mut RangeEncoder, + c: usize, +) { + for prio in 0..2 { + let mut i = start; + while i < end && bits_left >= c as i32 { + if fine_quant[i] >= MAX_FINE_BITS || fine_priority[i] != prio { + i += 1; + continue; + } + for ch in 0..c { + let idx = i + ch * NB_EBANDS; + let q2 = (e[idx] - old_e[idx] >= 0.0) as u32; + enc.bits(q2, 1); + let offset = (q2 as f32 - 0.5) * (1 << (14 - fine_quant[i] - 1)) as f32 / 16384.0; + old_e[idx] += offset; + bits_left -= 1; + } + i += 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::opus::encoder::range::dec::RangeDecoder; + + /// Laplace decoder of RFC 6716 §4.3.2.1 (as in FilmCraft's decoder). + fn laplace_decode(dec: &mut RangeDecoder, mut fs: u32, decay: u32) -> i32 { + let mut val = 0i32; + let fm = dec.decode(32768); + let mut fl = 0u32; + if fm >= fs { + val += 1; + fl = fs; + fs = laplace_get_freq1(fs, decay) + LAPLACE_MINP; + while fs > LAPLACE_MINP && fm >= fl + 2 * fs { + fs *= 2; + fl += fs; + fs = (((fs - 2 * LAPLACE_MINP) * decay) >> 15) + LAPLACE_MINP; + val += 1; + } + if fs <= LAPLACE_MINP { + let di = (fm - fl) >> 1; + val += di as i32; + fl += 2 * di * LAPLACE_MINP; + } + if fm < fl + fs { + val = -val; + } else { + fl += fs; + } + } + dec.update(fl, (fl + fs).min(32768), 32768); + val + } + + #[test] + fn laplace_roundtrip_all_models() { + for lm in 0..4 { + for intra in 0..2 { + for band in 0..21 { + let prob = &E_PROB_MODEL[lm][intra]; + let (fs, decay) = ((prob[2 * band] as u32) << 7, (prob[2 * band + 1] as u32) << 6); + let vals: Vec = (-64..=64).collect(); + let mut e = RangeEncoder::new(); + let coded: Vec = vals.iter().map(|&v| laplace_encode(&mut e, v, fs, decay)).collect(); + let size = (e.tell() as usize).div_ceil(8); + let buf = e.done(size); + let mut d = RangeDecoder::new(&buf); + for (&v, &c) in vals.iter().zip(&coded) { + assert!(c == v || (c.signum() == v.signum() && c.abs() < v.abs()), "clamp {v} -> {c}"); + assert_eq!(laplace_decode(&mut d, fs, decay), c, "lm={lm} intra={intra} band={band} v={v}"); + } + } + } + } + } +} diff --git a/crates/audio/src/opus/encoder/mdct.rs b/crates/audio/src/opus/encoder/mdct.rs new file mode 100644 index 000000000..6a42cce96 --- /dev/null +++ b/crates/audio/src/opus/encoder/mdct.rs @@ -0,0 +1,294 @@ +//! Forward MDCT with the CELT low-overlap window (RFC 6716 §4.3.7, run in the analysis +//! direction). +//! +//! A 20 ms frame is a 1920-point MDCT (960 coefficients) whose window is zero for 420 samples, +//! rises over the 120-sample overlap, is flat for 840 samples, falls over the next 120 samples +//! and is zero again for the last 420. The input block is therefore the 120 overlap samples kept +//! from the previous frame followed by the 960 new samples. The MDCT is folded into a DCT-IV, +//! which is computed with an N/2-point complex FFT. + +use crate::opus::encoder::tables::OVERLAP; + +#[derive(Clone, Copy, Default, Debug)] +pub struct Cpx { + pub re: f32, + pub im: f32, +} + +impl Cpx { + #[inline] + fn mul(self, o: Cpx) -> Cpx { + Cpx { re: self.re * o.re - self.im * o.im, im: self.re * o.im + self.im * o.re } + } + #[inline] + fn add(self, o: Cpx) -> Cpx { + Cpx { re: self.re + o.re, im: self.im + o.im } + } +} + +/// Mixed-radix (2, 3, 4, 5) forward complex FFT, unscaled (decimation in time, recursive). +pub struct Fft { + n: usize, + factors: Vec, + twiddle: Vec, +} + +impl Fft { + pub fn new(n: usize) -> Fft { + let mut factors = Vec::new(); + let mut m = n; + for p in [4usize, 2, 3, 5] { + while m.is_multiple_of(p) { + factors.push(p); + m /= p; + } + } + assert_eq!(m, 1, "unsupported FFT size {n}"); + let twiddle = (0..n) + .map(|k| { + let ph = -2.0 * std::f64::consts::PI * k as f64 / n as f64; + Cpx { re: ph.cos() as f32, im: ph.sin() as f32 } + }) + .collect(); + Fft { n, factors, twiddle } + } + + pub fn process(&self, data: &mut [Cpx], scratch: &mut Vec) { + scratch.clear(); + scratch.extend_from_slice(&data[..self.n]); + self.rec(&mut data[..self.n], scratch, 1, self.n, 0, 1); + } + + fn rec(&self, out: &mut [Cpx], inp: &[Cpx], in_stride: usize, n: usize, fi: usize, tw_stride: usize) { + let p = self.factors[fi]; + let m = n / p; + if m == 1 { + for (i, o) in out.iter_mut().enumerate().take(p) { + *o = inp[i * in_stride]; + } + } else { + for q in 0..p { + self.rec(&mut out[q * m..(q + 1) * m], &inp[q * in_stride..], in_stride * p, m, fi + 1, tw_stride * p); + } + } + let big = self.n; + let mut t = [Cpx::default(); 5]; + for k in 0..m { + for (q, tq) in t.iter_mut().enumerate().take(p) { + *tq = out[q * m + k].mul(self.twiddle[(q * k * tw_stride) % big]); + } + for q2 in 0..p { + let mut acc = Cpx::default(); + for (q, tq) in t.iter().enumerate().take(p) { + acc = acc.add(tq.mul(self.twiddle[((q * q2) % p) * (big / p)])); + } + out[q2 * m + k] = acc; + } + } + } +} + +/// Forward MDCT producing `n` coefficients from a block of `n + OVERLAP` samples. +pub struct Mdct { + /// Number of coefficients (half the MDCT length). + n: usize, + fft: Fft, + pre: Vec, + post: Vec, + /// Rising half of the low-overlap window (RFC 6716 §4.3.7). + pub window: [f32; OVERLAP], + scale: f32, + z: Vec, + scratch: Vec, + v: Vec, +} + +impl Mdct { + pub fn new(n: usize) -> Mdct { + let n2 = n / 2; + let pi = std::f64::consts::PI; + let pre = (0..n2) + .map(|m| { + let ph = -pi * m as f64 / n as f64; + Cpx { re: ph.cos() as f32, im: ph.sin() as f32 } + }) + .collect(); + let post = (0..n2) + .map(|p| { + let ph = -pi * (p as f64 + 0.25) / n as f64; + Cpx { re: ph.cos() as f32, im: ph.sin() as f32 } + }) + .collect(); + let mut window = [0f32; OVERLAP]; + for (i, w) in window.iter_mut().enumerate() { + let x = std::f64::consts::FRAC_PI_2 * (i as f64 + 0.5) / OVERLAP as f64; + let s = x.sin(); + *w = (std::f64::consts::FRAC_PI_2 * s * s).sin() as f32; + } + Mdct { n, fft: Fft::new(n2), pre, post, window, scale: 2.0 / n as f32, z: Vec::new(), scratch: Vec::new(), v: vec![0.0; n] } + } + + /// Transforms `block` (`n + OVERLAP` samples: the previous frame's last `OVERLAP` samples, then + /// `n` new ones) into `out[..n]`, scaled so that the RFC 6716 decoder's inverse MDCT and + /// overlap-add reproduce the input. + pub fn forward(&mut self, block: &[f32], out: &mut [f32]) { + let n = self.n; + let n2 = n / 2; + debug_assert_eq!(block.len(), n + OVERLAP); + // Place the windowed block into the (virtual) 2n-sample MDCT input u at offset `pad`. + let pad = (n - OVERLAP) / 2; + let w = &self.window; + let u = |t: usize| -> f32 { + if t < pad || t >= pad + n + OVERLAP { + return 0.0; + } + let j = t - pad; + let g = if j < OVERLAP { + w[j] + } else if j >= n { + w[OVERLAP - 1 - (j - n)] + } else { + 1.0 + }; + block[j] * g + }; + // Fold to a DCT-IV input: v = (-c_r - d, a - b_r) over the four quarters of u. + for m in 0..n2 { + self.v[m] = -u(n + n2 - 1 - m) - u(n + n2 + m); + self.v[n2 + m] = u(m) - u(n2 + n2 - 1 - m); + } + // DCT-IV via an n/2-point complex FFT. + self.z.clear(); + for m in 0..n2 { + let wv = Cpx { re: self.v[2 * m], im: self.v[n - 1 - 2 * m] }; + self.z.push(wv.mul(self.pre[m])); + } + self.fft.process(&mut self.z, &mut self.scratch); + for p in 0..n2 { + let y = self.z[p].mul(self.post[p]); + out[2 * p] = y.re * self.scale; + out[n - 1 - 2 * p] = -y.im * self.scale; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Direct MDCT definition X[k] = sum u[t] cos(pi/N (t + 1/2 + N/2)(k + 1/2)). + fn direct(block: &[f32], n: usize, window: &[f32; OVERLAP]) -> Vec { + let pad = (n - OVERLAP) / 2; + let mut u = vec![0f64; 2 * n]; + for (j, &b) in block.iter().enumerate() { + let g = if j < OVERLAP { + window[j] + } else if j >= n { + window[OVERLAP - 1 - (j - n)] + } else { + 1.0 + }; + u[pad + j] = (b * g) as f64; + } + (0..n) + .map(|k| { + (0..2 * n).map(|t| u[t] * (std::f64::consts::PI / n as f64 * (t as f64 + 0.5 + n as f64 / 2.0) * (k as f64 + 0.5)).cos()).sum::() + * 2.0 + / n as f64 + }) + .collect() + } + + #[test] + fn matches_direct_formula() { + for &n in &[240usize, 960] { + let block: Vec = (0..n + OVERLAP).map(|i| ((i * 37 % 101) as f32 - 50.0) / 7.0).collect(); + let mut m = Mdct::new(n); + let mut out = vec![0f32; n]; + m.forward(&block, &mut out); + let d = direct(&block, n, &m.window); + for k in 0..n { + assert!((out[k] as f64 - d[k]).abs() < 1e-2, "n={n} k={k}: {} vs {}", out[k], d[k]); + } + } + } + + // ---- Replica of the RFC 6716 decoder inverse MDCT (as in FilmCraft's decoder) ---- + + fn imdct_backward(n_full: usize, input: &[f32], out: &mut [f32], window: &[f32]) { + let n2 = n_full >> 1; + let n4 = n_full >> 2; + let t: Vec = (0..n2).map(|i| (2.0 * std::f64::consts::PI * (i as f64 + 0.125) / n_full as f64).cos() as f32).collect(); + let fft = Fft::new(n4); + let mut z = Vec::new(); + for i in 0..n4 { + let x1 = input[2 * i]; + let x2 = input[n2 - 1 - 2 * i]; + let yr = x2 * t[i] + x1 * t[n4 + i]; + let yi = x1 * t[i] - x2 * t[n4 + i]; + z.push(Cpx { re: yi, im: yr }); + } + fft.process(&mut z, &mut Vec::new()); + let overlap = OVERLAP; + let base = overlap >> 1; + let y = &mut out[base..base + n2]; + for i in 0..n4.div_ceil(2) { + let front = z[i]; + let back = z[n4 - 1 - i]; + let (re, im) = (front.im, front.re); + let (t0, t1) = (t[i], t[n4 + i]); + let yr0 = re * t0 + im * t1; + let yi0 = re * t1 - im * t0; + let (re, im) = (back.im, back.re); + let (t0, t1) = (t[n4 - i - 1], t[n2 - i - 1]); + let yr1 = re * t0 + im * t1; + let yi1 = re * t1 - im * t0; + y[2 * i] = yr0; + y[n2 - 1 - 2 * i] = yi0; + y[n2 - 2 - 2 * i] = yr1; + y[2 * i + 1] = yi1; + } + for i in 0..overlap / 2 { + let x1 = out[overlap - 1 - i]; + let x2 = out[i]; + let w1 = window[i]; + let w2 = window[overlap - 1 - i]; + out[i] = x2 * w2 - x1 * w1; + out[overlap - 1 - i] = x2 * w1 + x1 * w2; + } + } + + /// MDCT analysis followed by the decoder's IMDCT + TDAC overlap-add reproduces the input + /// delayed by exactly `OVERLAP` samples. + #[test] + fn perfect_reconstruction_through_decoder_imdct() { + let n = 960; + let frames = 5; + let sig: Vec = (0..n * frames).map(|i| (i as f32 * 0.013).sin() * 1000.0 + ((i * 7919) % 211) as f32 - 105.0).collect(); + let mut m = Mdct::new(n); + let mut hist = vec![0f32; OVERLAP]; + // Decoder memory: [frame output n][folded tail OVERLAP/2] laid out like decode_mem. + let mut mem = vec![0f32; n + OVERLAP]; + let mut decoded = Vec::new(); + for f in 0..frames { + let mut block = hist.clone(); + block.extend_from_slice(&sig[f * n..(f + 1) * n]); + hist.copy_from_slice(&block[n..]); + let mut coefs = vec![0f32; n]; + m.forward(&block, &mut coefs); + // Shift: the previous folded tail (at n..n+OVERLAP/2) moves to 0..OVERLAP/2. + mem.copy_within(n..n + OVERLAP / 2, 0); + imdct_backward(2 * n, &coefs, &mut mem, &m.window); + decoded.extend_from_slice(&mem[..n]); + } + let mut err = 0f64; + let mut sum = 0f64; + for t in OVERLAP..n * frames { + let d = decoded[t] as f64 - sig[t - OVERLAP] as f64; + err += d * d; + sum += (sig[t - OVERLAP] as f64).powi(2); + } + let snr = 10.0 * (sum / err).log10(); + assert!(snr > 80.0, "reconstruction SNR {snr:.1} dB"); + } +} diff --git a/crates/audio/src/opus/encoder/mod.rs b/crates/audio/src/opus/encoder/mod.rs new file mode 100644 index 000000000..cbb0d7984 --- /dev/null +++ b/crates/audio/src/opus/encoder/mod.rs @@ -0,0 +1,593 @@ +//! Clean-room pure-Rust Opus encoder (RFC 6716 SILK, hybrid and CELT modes; RFC 7845 `OpusHead`). +//! +//! Produces 20 ms Opus packets from 48 kHz float input, mono or stereo. The coding mode and +//! audio bandwidth are chosen once per stream from the per-channel bitrate and the +//! [`Application`] (see the crate README for the table): +//! +//! - **SILK** (narrowband/mediumband/wideband): linear-predictive speech coding at an internal +//! rate of 8/12/16 kHz; packet sizes vary around the target bitrate. +//! - **Hybrid** (super-wideband/fullband): SILK wideband codes 0–8 kHz and CELT codes the bands +//! above in the same range-coded frame; constant packet size. +//! - **CELT** (fullband): MDCT transform coding; constant packet size. +//! +//! From EffectCraft's `effectcraft-opusenc` (storytold/effectcraft@c618429). Public items keep their +//! names, so EffectCraft can re-export this module. +//! +//! ``` +//! # #[cfg(feature = "opus-encode")] { +//! use craft_audio::opus::encoder::{Application, Mode, Encoder}; +//! let mut enc = Encoder::new(2, 128_000).unwrap(); +//! let pcm = vec![0.0f32; Encoder::FRAME_SIZE * 2]; +//! let packet = enc.encode_float(&pcm).unwrap(); +//! assert_eq!(packet[0] >> 3, 31); // CELT-only, fullband, 20 ms +//! assert_eq!(enc.opus_head().len(), 19); +//! let voip = Encoder::with_application(1, 16_000, Application::Voip).unwrap(); +//! assert_eq!(voip.mode(), Mode::Silk); +//! # } +//! ``` + +// The bit-allocation code mirrors the normative integer arithmetic; keep C-like shift expressions. +#![allow(clippy::precedence, clippy::int_plus_one)] + +mod bands; +mod energy; +mod mdct; +mod range; +mod rate; +mod resample; +mod silk; +mod tables; + +use std::sync::OnceLock; + +use bands::{SPREAD_NORMAL, quant_all_bands}; +use energy::{quant_coarse_energy, quant_energy_finalise, quant_fine_energy}; +use mdct::Mdct; +use range::{BITRES, RangeEncoder}; +use rate::{Mode as CeltMode, compute_allocation}; +use resample::Downsampler; +use silk::SilkEncoder; +use tables::{E_MEANS, EBANDS, NB_EBANDS, OVERLAP, PREEMPH, SPREAD_ICDF, TF_SELECT_TABLE, TRIM_ICDF}; + +/// Samples per channel in one 20 ms frame at 48 kHz. +const N: usize = 960; +/// LM of a 20 ms frame (8 short blocks). +const LM: usize = 3; +/// Smallest CELT payload the encoder produces (bytes). +const MIN_FRAME_BYTES: usize = 16; +/// Largest payload allowed in one frame (RFC 6716 §3.4, R2). +const MAX_FRAME_BYTES: usize = 1275; +/// First CELT band coded in hybrid frames (8 kHz). +const HYBRID_START_BAND: usize = 17; + +fn celt_mode() -> &'static CeltMode { + static MODE: OnceLock = OnceLock::new(); + MODE.get_or_init(CeltMode::new) +} + +/// Why an encoder could not be created or a frame could not be encoded. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// Opus family-0 streams have 1 or 2 channels. + #[error("Opus family-0 streams have 1 or 2 channels, not {0}")] + Channels(u8), + /// The mode cannot code that bandwidth. + #[error("unsupported mode/bandwidth combination {0:?}/{1:?}")] + ModeBandwidth(Mode, Bandwidth), + /// `encode_float` takes exactly `FRAME_SIZE * channels` samples. + #[error("a frame is {expected} interleaved samples, not {got}")] + FrameLength { + /// Interleaved samples a frame takes (`FRAME_SIZE * channels`). + expected: usize, + /// Interleaved samples passed. + got: usize, + }, +} + +/// Intended use of the stream; selects the mode/bandwidth table. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Application { + /// General audio (music, mixed content): CELT unless the bitrate is low. + #[default] + Audio, + /// Speech: SILK and hybrid over a wider bitrate range, narrower bandwidths at low rates. + Voip, +} + +/// Opus coding mode (RFC 6716 §2). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Mode { + /// MDCT transform coding only. + Celt, + /// Linear-prediction coding only. + Silk, + /// SILK below 8 kHz plus CELT above. + Hybrid, +} + +pub use crate::opus::Bandwidth; + +/// Mode and bandwidth for a per-channel bitrate (bits per second per channel). +fn choose_mode(app: Application, per_channel: u32) -> (Mode, Bandwidth) { + match app { + Application::Voip => match per_channel { + 0..10_000 => (Mode::Silk, Bandwidth::Narrowband), + 10_000..12_000 => (Mode::Silk, Bandwidth::Mediumband), + 12_000..18_000 => (Mode::Silk, Bandwidth::Wideband), + 18_000..28_000 => (Mode::Hybrid, Bandwidth::SuperWideband), + 28_000..40_000 => (Mode::Hybrid, Bandwidth::Fullband), + _ => (Mode::Celt, Bandwidth::Fullband), + }, + Application::Audio => match per_channel { + 0..16_000 => (Mode::Silk, Bandwidth::Wideband), + 16_000..36_000 => (Mode::Hybrid, Bandwidth::Fullband), + _ => (Mode::Celt, Bandwidth::Fullband), + }, + } +} + +/// SILK side of a SILK-only or hybrid stream. +struct SilkPath { + enc: SilkEncoder, + down: Vec, + /// Bits per frame aimed at (excluding the TOC byte in SILK-only mode). + target_bits: f64, + /// Bits saved (positive) or overspent by previous frames (SILK-only mode). + reservoir: f64, +} + +/// The name EffectCraft's `effectcraft-opusenc` used, kept so it can re-export this module. +#[doc(hidden)] +pub type OpusEncoder = Encoder; + +/// Opus encoder for one mono or stereo stream at 48 kHz. +pub struct Encoder { + channels: usize, + application: Application, + mode: Mode, + bandwidth: Bandwidth, + /// Requested bitrate (clamped), bits per second. + bitrate_bps: u32, + /// Payload bytes per packet in CELT and hybrid modes (the packet is one byte longer: the + /// TOC). + frame_bytes: usize, + mdct: Mdct, + /// Last input sample of each channel (pre-emphasis filter memory, ×32768). + preemph_mem: [f32; 2], + /// Last `OVERLAP` pre-emphasised samples of each channel. + hist: Vec, + /// Quantised band energies, exactly as the decoder holds them. + old_band_e: [f32; 2 * NB_EBANDS], + block: Vec, + freq: Vec, + silk: Option, +} + +impl Encoder { + /// Samples per channel per packet (20 ms at 48 kHz). + pub const FRAME_SIZE: usize = N; + + /// Creates a general-audio encoder ([`Application::Audio`]) for `channels` (1 or 2) + /// channels of 48 kHz audio at `bitrate_bps` (total for all channels). + pub fn new(channels: u8, bitrate_bps: u32) -> Result { + Self::with_application(channels, bitrate_bps, Application::Audio) + } + + /// Creates an encoder for `channels` (1 or 2) channels of 48 kHz audio at `bitrate_bps` + /// (total for all channels; clamped to 6 kb/s per channel – 510 kb/s), choosing the mode + /// and bandwidth for `application`. + pub fn with_application(channels: u8, bitrate_bps: u32, application: Application) -> Result { + // Checked before the clamp below, whose lower bound passes its upper one from 86 channels. + if channels != 1 && channels != 2 { + return Err(Error::Channels(channels)); + } + let c = channels as u32; + let (mode, bandwidth) = choose_mode(application, bitrate_bps.clamp(6_000 * c, 510_000) / c); + Self::build(channels, bitrate_bps, application, mode, bandwidth) + } + + /// Creates an encoder with an explicit mode and bandwidth instead of the application's + /// table. Valid combinations: SILK with narrowband, mediumband or wideband; hybrid with + /// super-wideband or fullband; CELT with fullband. + /// + /// Any other combination is [`Error::ModeBandwidth`]; `channels` other than 1 or 2 is + /// [`Error::Channels`]. + pub fn with_mode(channels: u8, bitrate_bps: u32, mode: Mode, bandwidth: Bandwidth) -> Result { + let ok = match mode { + Mode::Silk => bandwidth <= Bandwidth::Wideband, + Mode::Hybrid => bandwidth >= Bandwidth::SuperWideband, + Mode::Celt => bandwidth == Bandwidth::Fullband, + }; + if !ok { + return Err(Error::ModeBandwidth(mode, bandwidth)); + } + Self::build(channels, bitrate_bps, Application::Audio, mode, bandwidth) + } + + fn build(channels: u8, bitrate_bps: u32, application: Application, mode: Mode, bandwidth: Bandwidth) -> Result { + if channels != 1 && channels != 2 { + return Err(Error::Channels(channels)); + } + let c = channels as usize; + let bitrate_bps = bitrate_bps.clamp(6_000 * c as u32, 510_000); + let packet_bytes = (bitrate_bps as usize).div_ceil(400); + let frame_bytes = packet_bytes.saturating_sub(1).clamp(MIN_FRAME_BYTES, MAX_FRAME_BYTES); + let silk = match mode { + Mode::Celt => None, + Mode::Silk | Mode::Hybrid => { + let fs_khz = match bandwidth { + Bandwidth::Narrowband => 8, + Bandwidth::Mediumband => 12, + _ => 16, + }; + let target_bits = if mode == Mode::Silk { + bitrate_bps as f64 / 50.0 - 8.0 + } else { + // SILK gets most of a hybrid packet; CELT codes 8 kHz and up with the rest. + let per = (bitrate_bps / c as u32) as f64; + let share = if bandwidth == Bandwidth::SuperWideband { 0.72 } else { 0.62 }; + c as f64 * (per * share).min(per - 6_000.0).max(per * 0.5) / 50.0 + }; + Some(SilkPath { + enc: SilkEncoder::new(fs_khz, c), + down: (0..c).map(|_| Downsampler::new(fs_khz as u32 * 1000)).collect(), + target_bits, + reservoir: 0.0, + }) + } + }; + Ok(Encoder { + channels: c, + application, + mode, + bandwidth, + bitrate_bps, + frame_bytes, + mdct: Mdct::new(N), + preemph_mem: [0.0; 2], + hist: vec![0.0; c * OVERLAP], + old_band_e: [0.0; 2 * NB_EBANDS], + block: vec![0.0; N + OVERLAP], + freq: vec![0.0; c * N], + silk, + }) + } + + /// Number of channels. + pub fn channels(&self) -> u8 { + self.channels as u8 + } + + /// The application the encoder was created for. + pub fn application(&self) -> Application { + self.application + } + + /// Coding mode of every packet of the stream. + pub fn mode(&self) -> Mode { + self.mode + } + + /// Coded audio bandwidth of every packet of the stream. + pub fn bandwidth(&self) -> Bandwidth { + self.bandwidth + } + + /// Bitrate in bits per second: the constant packet size × 50 packets per second in CELT + /// and hybrid modes, the (average) target in SILK mode. + pub fn bitrate(&self) -> u32 { + match self.mode { + Mode::Silk => self.bitrate_bps, + _ => ((self.frame_bytes + 1) * 8 * 50) as u32, + } + } + + /// Samples at 48 kHz the decoder must discard from the start of the stream (the `OpusHead` + /// pre-skip): the encoder's total delay. CELT: the 120-sample MDCT overlap. SILK/hybrid: + /// the decimator (83 samples, 88 for narrowband) plus the decoder's SILK delay (one + /// internal-rate sample and the resampler delay of RFC 6716 Table 54), which also rounds + /// to 120 samples. + pub fn pre_skip(&self) -> u16 { + let (fs, table54_ms) = match (self.mode, self.bandwidth) { + (Mode::Celt, _) => return OVERLAP as u16, + (_, Bandwidth::Narrowband) => (8_000, 0.538), + (_, Bandwidth::Mediumband) => (12_000, 0.692), + _ => (16_000, 0.706), + }; + (resample::delay(fs) as f64 + 48_000.0 / fs as f64 + table54_ms * 48.0).round() as u16 + } + + /// The 19-byte `OpusHead` identification header (RFC 7845 §5.1): version 1, channel count, + /// pre-skip, input sample rate 48000, output gain 0, channel mapping family 0. + pub fn opus_head(&self) -> Vec { + let mut h = Vec::with_capacity(19); + h.extend_from_slice(b"OpusHead"); + h.push(1); + h.push(self.channels as u8); + h.extend_from_slice(&self.pre_skip().to_le_bytes()); + h.extend_from_slice(&48_000u32.to_le_bytes()); + h.extend_from_slice(&0i16.to_le_bytes()); + h.push(0); + h + } + + /// TOC byte (RFC 6716 §3.1, Table 2) of a 20 ms single-frame packet. + fn toc(&self) -> u8 { + let config: u8 = match (self.mode, self.bandwidth) { + (Mode::Silk, Bandwidth::Narrowband) => 1, + (Mode::Silk, Bandwidth::Mediumband) => 5, + (Mode::Silk, _) => 9, + (Mode::Hybrid, Bandwidth::SuperWideband) => 13, + (Mode::Hybrid, _) => 15, + (Mode::Celt, _) => 31, + }; + (config << 3) | if self.channels == 2 { 4 } else { 0 } + } + + /// Encodes one 20 ms frame of interleaved f32 samples in [-1, 1] (exactly + /// `FRAME_SIZE * channels` values) and returns one Opus packet, or + /// [`Error::FrameLength`] for any other length. + pub fn encode_float(&mut self, pcm: &[f32]) -> Result, Error> { + let c = self.channels; + if pcm.len() != N * c { + return Err(Error::FrameLength { expected: N * c, got: pcm.len() }); + } + let mut packet = vec![self.toc()]; + match self.mode { + Mode::Celt => { + let (log_e, silence) = self.celt_analysis(pcm); + let len = self.frame_bytes; + packet.extend_from_slice(&self.encode_celt(RangeEncoder::new(), len, 0, NB_EBANDS, &log_e, silence)); + } + Mode::Silk => { + let x = self.silk_input(pcm); + // The SILK state exists whenever the mode uses SILK (set with the mode). + if let Some(sp) = self.silk.as_mut() { + let target = sp.target_bits + 0.25 * sp.reservoir; + let mut enc = RangeEncoder::new(); + sp.enc.encode(&x, target.max(24.0), (MAX_FRAME_BYTES * 8) as i32, &mut enc); + let bytes = (enc.tell() as usize).div_ceil(8).max(2); + let lim = 4.0 * sp.target_bits; + sp.reservoir = (sp.reservoir + sp.target_bits - (bytes * 8) as f64).clamp(-lim, lim); + packet.extend_from_slice(&enc.done(bytes)); + } + } + Mode::Hybrid => { + let x = self.silk_input(pcm); + let len = self.frame_bytes; + let total = (len * 8) as i32; + let celt_min = (total / 5).max(80); + let mut enc = RangeEncoder::new(); + if let Some(sp) = self.silk.as_mut() { + sp.enc.encode(&x, sp.target_bits, total - celt_min, &mut enc); + } + // No redundancy (RFC 6716 §4.5.1); the flag is present when there is room. + if enc.tell() + 17 + 20 <= total { + enc.bit_logp(false, 12); + } + let (log_e, _) = self.celt_analysis(pcm); + let end = if self.bandwidth == Bandwidth::SuperWideband { 19 } else { NB_EBANDS }; + packet.extend_from_slice(&self.encode_celt(enc, len, HYBRID_START_BAND, end, &log_e, false)); + } + } + Ok(packet) + } + + /// Decimates the input to the SILK internal rate (16-bit scale), one vector per channel. + fn silk_input(&mut self, pcm: &[f32]) -> Vec> { + let c = self.channels; + let Some(sp) = self.silk.as_mut() else { return vec![vec![]; c] }; + let mut out = Vec::with_capacity(c); + let mut tmp = vec![0f32; N]; + for ch in 0..c { + for (j, t) in tmp.iter_mut().enumerate() { + let s = pcm[j * c + ch]; + *t = if s.is_finite() { s.clamp(-1.0, 1.0) * 32768.0 } else { 0.0 }; + } + let mut y = Vec::with_capacity(N / 3); + sp.down[ch].process(&tmp, &mut y); + out.push(y); + } + out + } + + /// Pre-emphasis, the MDCT, band energies (log2, relative to `E_MEANS`) and normalised + /// bands; returns the energies and whether the frame is digital silence. + fn celt_analysis(&mut self, pcm: &[f32]) -> ([f32; 2 * NB_EBANDS], bool) { + let c = self.channels; + let mut log_e = [0f32; 2 * NB_EBANDS]; + let mut peak = 0f32; + for ch in 0..c { + self.block[..OVERLAP].copy_from_slice(&self.hist[ch * OVERLAP..(ch + 1) * OVERLAP]); + let mut mem = self.preemph_mem[ch]; + for j in 0..N { + let s = pcm[j * c + ch]; + let s = if s.is_finite() { s.clamp(-2.0, 2.0) * 32768.0 } else { 0.0 }; + let v = s - PREEMPH * mem; + mem = s; + self.block[OVERLAP + j] = v; + } + self.preemph_mem[ch] = mem; + self.hist[ch * OVERLAP..(ch + 1) * OVERLAP].copy_from_slice(&self.block[N..]); + peak = self.block.iter().fold(peak, |p, v| p.max(v.abs())); + let freq = &mut self.freq[ch * N..(ch + 1) * N]; + self.mdct.forward(&self.block, freq); + for i in 0..NB_EBANDS { + let lo = (EBANDS[i] as usize) << LM; + let hi = (EBANDS[i + 1] as usize) << LM; + let e = (1e-27f32 + freq[lo..hi].iter().map(|v| v * v).sum::()).sqrt(); + log_e[ch * NB_EBANDS + i] = e.log2() - E_MEANS[i]; + let g = 1.0 / e; + for v in freq[lo..hi].iter_mut() { + *v *= g; + } + } + } + (log_e, peak < 1e-4) + } + + /// Codes one CELT frame (RFC 6716 §4.3, in bitstream order) of `len` bytes into `enc` + /// (which already holds the SILK frame in hybrid mode), coding bands `start..end`, and + /// finalises the frame. + #[allow(clippy::too_many_arguments)] + fn encode_celt(&mut self, mut enc: RangeEncoder, len: usize, start: usize, end: usize, log_e: &[f32; 2 * NB_EBANDS], silence: bool) -> Vec { + let m = celt_mode(); + let c = self.channels; + if c == 1 { + for i in 0..NB_EBANDS { + self.old_band_e[i] = self.old_band_e[i].max(self.old_band_e[NB_EBANDS + i]); + } + } + if silence && start == 0 { + // The silence flag; the decoder treats every remaining bit as consumed. + enc.bit_logp(true, 15); + self.old_band_e = [-28.0; 2 * NB_EBANDS]; + return enc.done(2); + } + let total_bits_raw = len as i32 * 8; + if enc.tell() == 1 { + enc.bit_logp(false, 15); + } + // No pitch post-filter (only signalled in CELT-only frames). + if start == 0 && enc.tell() + 16 <= total_bits_raw { + enc.bit_logp(false, 1); + } + // Long blocks only. + if enc.tell() + 3 <= total_bits_raw { + enc.bit_logp(false, 3); + } + // Coarse energy, choosing intra or inter prediction by trial (whichever is cheaper). + if enc.tell() + 3 <= total_bits_raw { + let mut enc_intra = enc.clone(); + let mut old_intra = self.old_band_e; + enc_intra.bit_logp(true, 3); + quant_coarse_energy(start, end, log_e, &mut old_intra, true, &mut enc_intra, c, LM, total_bits_raw); + enc.bit_logp(false, 3); + let mut old_inter = self.old_band_e; + quant_coarse_energy(start, end, log_e, &mut old_inter, false, &mut enc, c, LM, total_bits_raw); + if enc_intra.tell_frac() < enc.tell_frac() { + enc = enc_intra; + self.old_band_e = old_intra; + } else { + self.old_band_e = old_inter; + } + } else { + quant_coarse_energy(start, end, log_e, &mut self.old_band_e, false, &mut enc, c, LM, total_bits_raw); + } + // TF resolution: no changes. + { + let mut budget = total_bits_raw; + let mut tell = enc.tell(); + let mut logp = 4; + let tf_select_rsv = LM > 0 && tell + logp + 1 <= budget; + budget -= tf_select_rsv as i32; + for _ in start..end { + if tell + logp <= budget { + enc.bit_logp(false, logp as u32); + tell = enc.tell(); + } + logp = 5; + } + if tf_select_rsv && TF_SELECT_TABLE[LM][0] != TF_SELECT_TABLE[LM][2] { + enc.bit_logp(false, 1); + } + } + if enc.tell() + 4 <= total_bits_raw { + enc.icdf(SPREAD_NORMAL, &SPREAD_ICDF, 5); + } + // Dynamic allocation: no boosts. + let cap = m.init_caps(LM, c); + let offsets = [0i32; NB_EBANDS]; + let total_bits = total_bits_raw << BITRES; + let mut tellf = enc.tell_frac(); + for &cp in cap.iter().take(end).skip(start) { + if tellf + (6 << BITRES) < total_bits && 0 < cp { + enc.bit_logp(false, 6); + tellf = enc.tell_frac(); + } + } + let alloc_trim = 5; + if tellf + (6 << BITRES) <= total_bits { + enc.icdf(alloc_trim as usize, &TRIM_ICDF, 7); + } + let bits = (total_bits_raw << BITRES) - enc.tell_frac() - 1; + let alloc = compute_allocation(m, start, end, &offsets, &cap, alloc_trim, bits, c, LM, &mut enc); + quant_fine_energy(start, end, log_e, &mut self.old_band_e, &alloc.fine_quant, &mut enc, c); + quant_all_bands( + m, + start, + end, + &self.freq, + N, + c, + &alloc.pulses, + SPREAD_NORMAL, + alloc.dual_stereo, + alloc.intensity, + total_bits_raw << BITRES, + alloc.balance, + &mut enc, + LM, + alloc.coded_bands, + ); + let bits_left = total_bits_raw - enc.tell(); + quant_energy_finalise(start, end, log_e, &mut self.old_band_e, &alloc.fine_quant, &alloc.fine_priority, bits_left, &mut enc, c); + debug_assert!(enc.tell() <= total_bits_raw, "frame overflow: {} > {}", enc.tell(), total_bits_raw); + if c == 1 { + let (a, b) = self.old_band_e.split_at_mut(NB_EBANDS); + b.copy_from_slice(a); + } + // The decoder forgets the energies of bands it did not code. + for ch in 0..2 { + for i in (0..start).chain(end..NB_EBANDS) { + self.old_band_e[ch * NB_EBANDS + i] = 0.0; + } + } + enc.done(len) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opus_head_layout() { + let e = Encoder::new(2, 96_000).unwrap(); + let h = e.opus_head(); + assert_eq!(&h[..8], b"OpusHead"); + assert_eq!(h[8], 1); + assert_eq!(h[9], 2); + assert_eq!(u16::from_le_bytes([h[10], h[11]]), e.pre_skip()); + assert_eq!(u32::from_le_bytes([h[12], h[13], h[14], h[15]]), 48_000); + assert_eq!(&h[16..], &[0, 0, 0]); + } + + #[test] + fn packet_sizes_follow_bitrate() { + for (ch, rate) in [(1u8, 64_000u32), (2, 128_000), (2, 256_000)] { + let mut e = Encoder::new(ch, rate).unwrap(); + let pcm: Vec = (0..N * ch as usize).map(|i| (i as f32 * 0.05).sin() * 0.5).collect(); + let p = e.encode_float(&pcm).unwrap(); + assert_eq!(p.len(), rate as usize / 400); + assert_eq!(p[0], 0xF8 | if ch == 2 { 4 } else { 0 }); + assert_eq!(e.bitrate(), rate); + } + } + + #[test] + fn silence_is_a_tiny_packet() { + let mut e = Encoder::new(1, 64_000).unwrap(); + let p = e.encode_float(&vec![0.0; N]).unwrap(); + assert_eq!(p.len(), 3); + } + + #[test] + fn wrong_frame_length_is_an_error() { + let mut e = Encoder::new(2, 128_000).unwrap(); + assert_eq!(e.encode_float(&vec![0.0; N]), Err(Error::FrameLength { expected: 2 * N, got: N })); + assert_eq!(e.encode_float(&[]), Err(Error::FrameLength { expected: 2 * N, got: 0 })); + assert!(e.encode_float(&vec![0.0; 2 * N]).is_ok()); + } +} diff --git a/crates/audio/src/opus/encoder/range.rs b/crates/audio/src/opus/encoder/range.rs new file mode 100644 index 000000000..daedd58ae --- /dev/null +++ b/crates/audio/src/opus/encoder/range.rs @@ -0,0 +1,472 @@ +//! Range encoder (RFC 6716 §5.1), the exact inverse of the §4.1 range decoder. +//! +//! Range-coded symbols are written from the front of the buffer and raw bits from the back; the +//! bit accounting ([`RangeEncoder::tell`], [`RangeEncoder::tell_frac`]) matches the decoder's, so +//! every budget decision the decoder takes can be mirrored exactly. + +const SYM_BITS: u32 = 8; +const CODE_BITS: u32 = 32; +const SYM_MAX: u32 = (1 << SYM_BITS) - 1; +const CODE_SHIFT: u32 = CODE_BITS - SYM_BITS - 1; +const CODE_TOP: u32 = 1 << (CODE_BITS - 1); +const CODE_BOT: u32 = CODE_TOP >> SYM_BITS; +/// Bit resolution of [`RangeEncoder::tell_frac`] (1/8 bit). +pub const BITRES: u32 = 3; + +/// Number of bits needed to represent `x` (0 for 0). +#[inline] +pub fn ilog(x: u32) -> i32 { + (32 - x.leading_zeros()) as i32 +} + +#[derive(Clone, Debug)] +pub struct RangeEncoder { + out: Vec, + end: Vec, + end_window: u32, + nend_bits: i32, + nbits_total: i32, + rng: u32, + low: u32, + rem: i32, + ext: u32, +} + +impl Default for RangeEncoder { + fn default() -> Self { + Self::new() + } +} + +impl RangeEncoder { + pub fn new() -> Self { + RangeEncoder { + out: Vec::new(), + end: Vec::new(), + end_window: 0, + nend_bits: 0, + nbits_total: (CODE_BITS + 1) as i32, + rng: CODE_TOP, + low: 0, + rem: -1, + ext: 0, + } + } + + /// Current range (equals the decoder's range after the same symbols). + #[cfg(test)] + pub fn rng(&self) -> u32 { + self.rng + } + + fn carry_out(&mut self, c: u32) { + if c != SYM_MAX { + let carry = c >> SYM_BITS; + if self.rem >= 0 { + self.out.push((self.rem as u32 + carry) as u8); + } + if self.ext > 0 { + let sym = (SYM_MAX + carry) & SYM_MAX; + for _ in 0..self.ext { + self.out.push(sym as u8); + } + self.ext = 0; + } + self.rem = (c & SYM_MAX) as i32; + } else { + self.ext += 1; + } + } + + #[inline] + fn normalize(&mut self) { + while self.rng <= CODE_BOT { + self.carry_out(self.low >> CODE_SHIFT); + self.low = (self.low << SYM_BITS) & (CODE_TOP - 1); + self.rng <<= SYM_BITS; + self.nbits_total += SYM_BITS as i32; + } + } + + /// Encodes the symbol with cumulative range `[fl, fh)` of total `ft`. + pub fn encode(&mut self, fl: u32, fh: u32, ft: u32) { + let r = self.rng / ft; + if fl > 0 { + self.low += self.rng - r * (ft - fl); + self.rng = r * (fh - fl); + } else { + self.rng -= r * (ft - fh); + } + self.normalize(); + } + + /// Like [`Self::encode`] with `ft = 1 << bits`. + pub fn encode_bin(&mut self, fl: u32, fh: u32, bits: u32) { + let r = self.rng >> bits; + let ft = 1u32 << bits; + if fl > 0 { + self.low += self.rng - r * (ft - fl); + self.rng = r * (fh - fl); + } else { + self.rng -= r * (ft - fh); + } + self.normalize(); + } + + /// Encodes a binary symbol whose probability of being 1 is `1/2^logp`. + pub fn bit_logp(&mut self, val: bool, logp: u32) { + let r = self.rng; + let s = r >> logp; + let r2 = r - s; + if val { + self.low += r2; + self.rng = s; + } else { + self.rng = r2; + } + self.normalize(); + } + + /// Encodes symbol `s` with an "inverse CDF" table (`ft = 1 << ftb`). + pub fn icdf(&mut self, s: usize, icdf: &[u8], ftb: u32) { + let r = self.rng >> ftb; + if s > 0 { + self.low += self.rng - r * icdf[s - 1] as u32; + self.rng = r * (icdf[s - 1] as u32 - icdf[s] as u32); + } else { + self.rng -= r * icdf[s] as u32; + } + self.normalize(); + } + + /// Encodes a uniformly distributed integer `fl` in `[0, ft)` (`ft >= 2`). + pub fn uint(&mut self, fl: u32, ft: u32) { + debug_assert!(ft > 1 && fl < ft); + let ft1 = ft - 1; + let ftb = ilog(ft1); + if ftb > 8 { + let ftb = (ftb - 8) as u32; + let top = (ft1 >> ftb) + 1; + let v = fl >> ftb; + self.encode(v, v + 1, top); + self.bits(fl & ((1 << ftb) - 1), ftb); + } else { + self.encode(fl, fl + 1, ft); + } + } + + /// Writes `bits` raw bits (0..=25) to the end of the buffer. + pub fn bits(&mut self, val: u32, bits: u32) { + if bits == 0 { + return; + } + let mut window = self.end_window; + let mut used = self.nend_bits; + if used as u32 + bits > CODE_BITS { + while used >= SYM_BITS as i32 { + self.end.push((window & SYM_MAX) as u8); + window >>= SYM_BITS; + used -= SYM_BITS as i32; + } + } + window |= val << used; + used += bits as i32; + self.end_window = window; + self.nend_bits = used; + self.nbits_total += bits as i32; + } + + /// Number of bits written so far (rounded up), identical to the decoder's `tell`. + #[inline] + pub fn tell(&self) -> i32 { + self.nbits_total - ilog(self.rng) + } + + /// Number of 1/8 bits written so far (rounded up), identical to the decoder's `tell_frac`. + pub fn tell_frac(&self) -> i32 { + let nbits = (self.nbits_total as u32) << BITRES; + let mut l = ilog(self.rng) as u32; + let mut r = self.rng >> (l - 16); + for _ in 0..BITRES { + r = (r * r) >> 15; + let b = r >> 16; + l = (l << 1) | b; + r >>= b; + } + nbits.wrapping_sub(l) as i32 + } + + /// Finalises the stream into a buffer of exactly `size` bytes (range data at the front, raw + /// bits at the back, zero padding in between; a partial raw-bit byte is merged into the byte + /// just before the full raw-bit bytes). + pub fn done(mut self, size: usize) -> Vec { + let mut l = CODE_BITS as i32 - ilog(self.rng); + let mut msk = (CODE_TOP - 1) >> l; + let mut end = (self.low.wrapping_add(msk)) & !msk; + if (end | msk) >= self.low.wrapping_add(self.rng) { + l += 1; + msk >>= 1; + end = (self.low.wrapping_add(msk)) & !msk; + } + while l > 0 { + self.carry_out(end >> CODE_SHIFT); + end = (end << SYM_BITS) & (CODE_TOP - 1); + l -= SYM_BITS as i32; + } + if self.rem >= 0 || self.ext > 0 { + self.carry_out(0); + } + let mut window = self.end_window; + let mut used = self.nend_bits; + while used >= SYM_BITS as i32 { + self.end.push((window & SYM_MAX) as u8); + window >>= SYM_BITS; + used -= SYM_BITS as i32; + } + let mut buf = vec![0u8; size]; + let full = self.end.len().min(size); + let room = size - full; + let n = self.out.len().min(room); + buf[..n].copy_from_slice(&self.out[..n]); + for (k, &b) in self.end.iter().take(full).enumerate() { + buf[size - 1 - k] = b; + } + if used > 0 && room > 0 { + buf[room - 1] |= window as u8; + } + buf + } +} + +#[cfg(test)] +pub(crate) mod dec { + //! Minimal range decoder (RFC 6716 §4.1) used to verify the encoder. + use super::*; + const CODE_EXTRA: u32 = (CODE_BITS - 2) % SYM_BITS + 1; + + pub struct RangeDecoder<'a> { + buf: &'a [u8], + offs: usize, + end_offs: usize, + end_window: u32, + nend_bits: i32, + nbits_total: i32, + rng: u32, + val: u32, + rem: u32, + } + + impl<'a> RangeDecoder<'a> { + pub fn new(buf: &'a [u8]) -> Self { + let mut d = RangeDecoder { + buf, + offs: 0, + end_offs: 0, + end_window: 0, + nend_bits: 0, + nbits_total: (CODE_BITS + 1 - ((CODE_BITS - CODE_EXTRA) / SYM_BITS) * SYM_BITS) as i32, + rng: 1 << CODE_EXTRA, + val: 0, + rem: 0, + }; + d.rem = d.read_byte(); + d.val = d.rng - 1 - (d.rem >> (SYM_BITS - CODE_EXTRA)); + d.normalize(); + d + } + fn read_byte(&mut self) -> u32 { + if self.offs < self.buf.len() { + self.offs += 1; + self.buf[self.offs - 1] as u32 + } else { + 0 + } + } + fn read_byte_from_end(&mut self) -> u32 { + if self.end_offs < self.buf.len() { + self.end_offs += 1; + self.buf[self.buf.len() - self.end_offs] as u32 + } else { + 0 + } + } + fn normalize(&mut self) { + while self.rng <= CODE_BOT { + self.nbits_total += SYM_BITS as i32; + self.rng <<= SYM_BITS; + let sym = self.rem; + self.rem = self.read_byte(); + let sym = ((sym << SYM_BITS) | self.rem) >> (SYM_BITS - CODE_EXTRA); + self.val = ((self.val << SYM_BITS).wrapping_add(SYM_MAX & !sym)) & (CODE_TOP - 1); + } + } + pub fn rng(&self) -> u32 { + self.rng + } + pub fn decode(&mut self, ft: u32) -> u32 { + let ext = self.rng / ft; + let s = self.val / ext; + ft - (s + 1).min(ft) + } + pub fn update(&mut self, fl: u32, fh: u32, ft: u32) { + let ext = self.rng / ft; + let s = ext * (ft - fh); + self.val = self.val.wrapping_sub(s); + self.rng = if fl > 0 { ext * (fh - fl) } else { self.rng - s }; + self.normalize(); + } + pub fn bit_logp(&mut self, logp: u32) -> bool { + let r = self.rng; + let d = self.val; + let s = r >> logp; + let ret = d < s; + if !ret { + self.val = d - s; + } + self.rng = if ret { s } else { r - s }; + self.normalize(); + ret + } + pub fn icdf(&mut self, icdf: &[u8], ftb: u32) -> usize { + let mut s = self.rng; + let d = self.val; + let r = s >> ftb; + let mut ret = 0usize; + let mut t; + loop { + t = s; + s = r * icdf[ret] as u32; + if d >= s { + break; + } + ret += 1; + } + self.val = d - s; + self.rng = t - s; + self.normalize(); + ret + } + pub fn uint(&mut self, ft: u32) -> u32 { + let ft1 = ft - 1; + let ftb = ilog(ft1); + if ftb > 8 { + let ftb = (ftb - 8) as u32; + let top = (ft1 >> ftb) + 1; + let s = self.decode(top); + self.update(s, s + 1, top); + (s << ftb) | self.bits(ftb) + } else { + let s = self.decode(ft); + self.update(s, s + 1, ft); + s + } + } + pub fn bits(&mut self, bits: u32) -> u32 { + let mut window = self.end_window; + let mut available = self.nend_bits; + if (available as u32) < bits { + loop { + window |= self.read_byte_from_end() << available; + available += SYM_BITS as i32; + if available > (CODE_BITS - SYM_BITS) as i32 { + break; + } + } + } + let ret = window & ((1u32 << bits) - 1); + self.end_window = window >> bits; + self.nend_bits = available - bits as i32; + self.nbits_total += bits as i32; + ret + } + pub fn tell(&self) -> i32 { + self.nbits_total - ilog(self.rng) + } + } +} + +#[cfg(test)] +mod tests { + use super::dec::RangeDecoder; + use super::*; + + struct Lcg(u32); + impl Lcg { + fn next(&mut self) -> u32 { + self.0 = self.0.wrapping_mul(1664525).wrapping_add(1013904223); + self.0 >> 8 + } + } + + #[test] + fn starts_at_one_bit() { + let e = RangeEncoder::new(); + assert_eq!(e.tell(), 1); + assert_eq!(e.tell_frac(), 8); + } + + #[test] + fn roundtrip_mixed_symbols_and_tell_matches() { + let mut rng = Lcg(7); + let icdf: [u8; 4] = [200, 120, 30, 0]; + for trial in 0..300 { + let n = 1 + (rng.next() % 300) as usize; + let mut ops = Vec::new(); + for _ in 0..n { + match rng.next() % 5 { + 0 => { + let ft = 2 + rng.next() % 1000; + ops.push((0u8, rng.next() % ft, ft)); + } + 1 => { + let logp = 1 + rng.next() % 15; + ops.push((1, rng.next().is_multiple_of(5) as u32, logp)); + } + 2 => { + let bits = 1 + rng.next() % 24; + ops.push((2, rng.next() & ((1 << bits) - 1), bits)); + } + 3 => { + let ft = 2 + rng.next() % 100_000_000; + ops.push((3, rng.next() % ft, ft)); + } + _ => ops.push((4, rng.next() % 4, 0)), + } + } + let mut e = RangeEncoder::new(); + let mut tells = Vec::new(); + for &(k, v, p) in &ops { + match k { + 0 => e.encode(v, v + 1, p), + 1 => e.bit_logp(v != 0, p), + 2 => e.bits(v, p), + 3 => e.uint(v, p), + _ => e.icdf(v as usize, &icdf, 8), + } + tells.push(e.tell()); + } + // Exactly the number of bytes `tell` says were used must suffice. + let size = (e.tell() as usize).div_ceil(8); + let final_rng = e.rng(); + let buf = e.done(size); + assert_eq!(buf.len(), size); + let mut d = RangeDecoder::new(&buf); + for (i, &(k, v, p)) in ops.iter().enumerate() { + let got = match k { + 0 => { + let s = d.decode(p); + d.update(s, s + 1, p); + s + } + 1 => d.bit_logp(p) as u32, + 2 => d.bits(p), + 3 => d.uint(p), + _ => d.icdf(&icdf, 8) as u32, + }; + assert_eq!(got, v, "trial {trial} op {i} kind {k}"); + assert_eq!(d.tell(), tells[i], "tell mismatch trial {trial} op {i}"); + } + assert_eq!(d.rng(), final_rng); + } + } +} diff --git a/crates/audio/src/opus/encoder/rate.rs b/crates/audio/src/opus/encoder/rate.rs new file mode 100644 index 000000000..c360f9da7 --- /dev/null +++ b/crates/audio/src/opus/encoder/rate.rs @@ -0,0 +1,561 @@ +//! Pulse cache and bit allocation (RFC 6716 §4.3.3, §4.3.4.1). +//! +//! The allocation must be bit-identical to the decoder's, so this mirrors FilmCraft's +//! spec-derived `filmcraft-opus` decoder (first-party, MIT OR Apache-2.0) step by step; the only +//! difference is that the encoder *writes* the band-skip, intensity and dual-stereo symbols. + +use crate::opus::encoder::range::{BITRES, RangeEncoder, ilog}; +use crate::opus::encoder::tables::{ALLOC, EBANDS, LOG2_FRAC_TABLE, MAX_LM, NB_EBANDS}; + +const MAX_PSEUDO: i32 = 40; +const LOG_MAX_PSEUDO: i32 = 6; +pub const MAX_FINE_BITS: i32 = 8; +const FINE_OFFSET: i32 = 21; +pub const QTHETA_OFFSET: i32 = 4; +pub const QTHETA_OFFSET_TWOPHASE: i32 = 16; +const ALLOC_STEPS: i32 = 6; + +/// Number of pulses represented by pseudo-pulse index `i`. +pub fn get_pulses(i: i32) -> i32 { + if i < 8 { i } else { (8 + (i & 7)) << ((i >> 3) - 1) } +} + +/// Conservative (rounded up) log2 of `val` with `frac` fractional bits. +pub fn log2_frac(mut val: u32, mut frac: u32) -> i32 { + let mut l = ilog(val); + if val & val.wrapping_sub(1) != 0 { + if l > 16 { + val = ((val - 1) >> (l - 16)) + 1; + } else { + val <<= 16 - l; + } + let mut l = (l - 1) << frac; + loop { + let b = (val >> 16) as i32; + l += b << frac; + val = (val + b as u32) >> b; + val = (val * val + 0x7FFF) >> 15; + if frac == 0 { + break; + } + frac -= 1; + } + l + (val > 0x8000) as i32 + } else { + l -= 1; + l << frac + } +} + +/// PVQ codebook size V(N, K) (saturating far above 2^32). +pub fn pvq_v(n: usize, k: usize) -> u128 { + if k == 0 { + return 1; + } + if n == 0 { + return 0; + } + let mut row = vec![0u128; k + 1]; + row[0] = 1; + for _ in 0..n { + let mut prev_diag = row[0]; + for kk in 1..=k { + let up = row[kk]; + let v = up.saturating_add(row[kk - 1]).saturating_add(prev_diag); + prev_diag = up; + row[kk] = v.min(u128::MAX >> 8); + } + row[0] = 1; + } + row[k] +} + +fn fits_in32(n: usize, k: i32) -> bool { + pvq_v(n, k as usize) <= u32::MAX as u128 +} + +/// Precomputed per-mode data. +pub struct Mode { + /// log2 of band width in 1/8 bits. + pub log_n: [i32; NB_EBANDS], + cache_index: Vec, + cache_bits: Vec, + caps: Vec, +} + +impl Mode { + pub fn new() -> Mode { + let mut log_n = [0i32; NB_EBANDS]; + for (i, l) in log_n.iter_mut().enumerate() { + *l = log2_frac((EBANDS[i + 1] - EBANDS[i]) as u32, BITRES); + } + let (cache_index, cache_bits) = compute_pulse_cache(MAX_LM as i32); + let mut m = Mode { log_n, cache_index, cache_bits, caps: Vec::new() }; + m.caps = compute_caps(&m); + m + } + + #[inline] + pub fn cache(&self, lm: i32, band: usize) -> &[u8] { + let idx = self.cache_index[((lm + 1) as usize) * NB_EBANDS + band]; + &self.cache_bits[idx.max(0) as usize..] + } + + pub fn bits2pulses(&self, band: usize, lm: i32, bits: i32) -> i32 { + let cache = self.cache(lm, band); + let mut lo = 0i32; + let mut hi = cache[0] as i32; + let bits = bits - 1; + for _ in 0..LOG_MAX_PSEUDO { + let mid = (lo + hi + 1) >> 1; + if cache[mid as usize] as i32 >= bits { + hi = mid; + } else { + lo = mid; + } + } + let lo_bits = if lo == 0 { -1 } else { cache[lo as usize] as i32 }; + if bits - lo_bits <= cache[hi as usize] as i32 - bits { lo } else { hi } + } + + pub fn pulses2bits(&self, band: usize, lm: i32, pulses: i32) -> i32 { + if pulses == 0 { 0 } else { self.cache(lm, band)[pulses as usize] as i32 + 1 } + } + + /// Per-band allocation caps for this frame (`init_caps`). + pub fn init_caps(&self, lm: usize, c: usize) -> [i32; NB_EBANDS] { + let mut cap = [0i32; NB_EBANDS]; + for (i, cp) in cap.iter_mut().enumerate() { + let n = (EBANDS[i + 1] - EBANDS[i]) << lm; + *cp = (self.caps[NB_EBANDS * (2 * lm + c - 1) + i] as i32 + 64) * c as i32 * n >> 2; + } + cap + } +} + +fn compute_pulse_cache(lm_max: i32) -> (Vec, Vec) { + let nb = NB_EBANDS; + let rows = (lm_max + 2) as usize; + let mut index = vec![-1i16; nb * rows]; + let mut entries: Vec<(usize, i32, usize)> = Vec::new(); + let mut curr = 0usize; + for i in 0..rows { + for j in 0..nb { + let n = (((EBANDS[j + 1] - EBANDS[j]) << i) >> 1) as usize; + index[i * nb + j] = -1; + 'search: for k in 0..=i { + for nn in 0..nb { + if k == i && nn >= j { + break; + } + if n == (((EBANDS[nn + 1] - EBANDS[nn]) << k) >> 1) as usize { + index[i * nb + j] = index[k * nb + nn]; + break 'search; + } + } + } + if index[i * nb + j] == -1 && n != 0 { + let mut k = 0; + while k < MAX_PSEUDO && fits_in32(n, get_pulses(k + 1)) { + k += 1; + } + index[i * nb + j] = curr as i16; + entries.push((n, k, curr)); + curr += (k + 1) as usize; + } + } + } + let mut bits = vec![0u8; curr]; + for &(n, k, off) in &entries { + let maxk = get_pulses(k) as usize; + let tmp = required_bits(n, maxk, BITRES); + for j in 1..=k { + bits[off + j as usize] = (tmp[get_pulses(j) as usize] - 1) as u8; + } + bits[off] = k as u8; + } + (index, bits) +} + +fn required_bits(n: usize, maxk: usize, frac: u32) -> Vec { + let mut out = vec![0i32; maxk + 1]; + if n == 1 { + for o in out.iter_mut().skip(1) { + *o = 1 << frac; + } + } else { + for (k, o) in out.iter_mut().enumerate().skip(1) { + *o = log2_frac(pvq_v(n, k) as u32, frac); + } + } + out +} + +fn compute_caps(m: &Mode) -> Vec { + let nb = NB_EBANDS; + let mut caps = vec![0u8; (MAX_LM + 1) * 2 * nb]; + for i in 0..=MAX_LM as i32 { + for c in 1..=2i32 { + for j in 0..nb { + let mut n0 = EBANDS[j + 1] - EBANDS[j]; + + let max_bits = if n0 << i == 1 { + c * (1 + MAX_FINE_BITS) << BITRES + } else { + let mut lm0 = 0; + if n0 > 2 { + n0 >>= 1; + lm0 -= 1; + } else if n0 <= 1 { + lm0 = i.min(1); + n0 <<= lm0; + } + let pcache = m.cache(lm0, j); + let mut mb = pcache[pcache[0] as usize] as i32 + 1; + let mut n = n0; + for k in 0..(i - lm0) { + mb <<= 1; + let offset = ((m.log_n[j] + ((lm0 + k) << BITRES)) >> 1) - QTHETA_OFFSET; + let num = 459 * ((2 * n - 1) * offset + mb); + let den = ((2 * n - 1) << 9) - 459; + let qb = ((num + (den >> 1)) / den).min(57); + mb += qb; + n <<= 1; + } + if c == 2 { + mb <<= 1; + let offset = ((m.log_n[j] + (i << BITRES)) >> 1) - if n == 2 { QTHETA_OFFSET_TWOPHASE } else { QTHETA_OFFSET }; + let ndof = 2 * n - 1 - (n == 2) as i32; + let p = if n == 2 { 512 } else { 487 }; + let num = p * (mb + ndof * offset); + let den = (ndof << 9) - p; + let qb = ((num + (den >> 1)) / den).min(if n == 2 { 64 } else { 61 }); + mb += qb; + } + let ndof = c * n + if c == 2 && n > 2 { 1 } else { 0 }; + let mut offset = ((m.log_n[j] + (i << BITRES)) >> 1) - FINE_OFFSET; + if n == 2 { + offset += (1 << BITRES) >> 2; + } + let num = mb + ndof * offset; + let den = (ndof - 1) << BITRES; + let qb = ((num + (den >> 1)) / den).min(MAX_FINE_BITS); + mb += c * qb << BITRES; + mb + }; + let w = c * ((EBANDS[j + 1] - EBANDS[j]) << i); + let v = (4 * max_bits / w) - 64; + caps[((i * 2 + c - 1) as usize) * nb + j] = v.clamp(0, 255) as u8; + } + } + } + caps +} + +/// Result of the allocation process. +pub struct Allocation { + pub coded_bands: usize, + pub intensity: usize, + pub dual_stereo: bool, + pub balance: i32, + pub pulses: [i32; NB_EBANDS], + pub fine_quant: [i32; NB_EBANDS], + pub fine_priority: [i32; NB_EBANDS], +} + +/// `clt_compute_allocation`, encoder side. Stereo frames are always coded as dual (L/R) stereo +/// with no intensity bands, and no band is skipped voluntarily. +#[allow(clippy::too_many_arguments)] +pub fn compute_allocation( + m: &Mode, + start: usize, + end: usize, + offsets: &[i32; NB_EBANDS], + cap: &[i32; NB_EBANDS], + alloc_trim: i32, + total: i32, + c: usize, + lm: usize, + enc: &mut RangeEncoder, +) -> Allocation { + let ci = c as i32; + let lmi = lm as i32; + let mut total = total.max(0); + let mut skip_start = start; + let skip_rsv = if total >= 1 << BITRES { 1 << BITRES } else { 0 }; + total -= skip_rsv; + let mut intensity_rsv = 0; + let mut dual_stereo_rsv = 0; + if c == 2 { + intensity_rsv = LOG2_FRAC_TABLE[end - start]; + if intensity_rsv > total { + intensity_rsv = 0; + } else { + total -= intensity_rsv; + dual_stereo_rsv = if total >= 1 << BITRES { 1 << BITRES } else { 0 }; + total -= dual_stereo_rsv; + } + } + let mut bits1 = [0i32; NB_EBANDS]; + let mut bits2 = [0i32; NB_EBANDS]; + let mut thresh = [0i32; NB_EBANDS]; + let mut trim_offset = [0i32; NB_EBANDS]; + for j in start..end { + let w = EBANDS[j + 1] - EBANDS[j]; + thresh[j] = (ci << BITRES).max((3 * w << lmi << BITRES) >> 4); + trim_offset[j] = ci * w * (alloc_trim - 5 - lmi) * (end as i32 - j as i32 - 1) * (1 << (lmi + BITRES as i32)) >> 6; + if w << lmi == 1 { + trim_offset[j] -= ci << BITRES; + } + } + let nb_alloc = ALLOC.len() as i32; + let mut lo = 1; + let mut hi = nb_alloc - 1; + loop { + let mut done = false; + let mut psum = 0; + let mid = (lo + hi) >> 1; + for j in (start..end).rev() { + let n = EBANDS[j + 1] - EBANDS[j]; + let mut bitsj = ci * n * (ALLOC[mid as usize][j] as i32) << lmi >> 2; + if bitsj > 0 { + bitsj = (bitsj + trim_offset[j]).max(0); + } + bitsj += offsets[j]; + if bitsj >= thresh[j] || done { + done = true; + psum += bitsj.min(cap[j]); + } else if bitsj >= ci << BITRES { + psum += ci << BITRES; + } + } + if psum > total { + hi = mid - 1; + } else { + lo = mid + 1; + } + if lo > hi { + break; + } + } + hi = lo; + lo -= 1; + for j in start..end { + let n = EBANDS[j + 1] - EBANDS[j]; + let mut bits1j = ci * n * (ALLOC[lo as usize][j] as i32) << lmi >> 2; + let mut bits2j = if hi >= nb_alloc { cap[j] } else { ci * n * (ALLOC[hi as usize][j] as i32) << lmi >> 2 }; + if bits1j > 0 { + bits1j = (bits1j + trim_offset[j]).max(0); + } + if bits2j > 0 { + bits2j = (bits2j + trim_offset[j]).max(0); + } + if lo > 0 { + bits1j += offsets[j]; + } + bits2j += offsets[j]; + if offsets[j] > 0 { + skip_start = j; + } + bits2j = (bits2j - bits1j).max(0); + bits1[j] = bits1j; + bits2[j] = bits2j; + } + interp_bits2pulses(m, start, end, skip_start, &bits1, &bits2, &thresh, cap, total, skip_rsv, intensity_rsv, dual_stereo_rsv, c, lm, enc) +} + +#[allow(clippy::too_many_arguments)] +fn interp_bits2pulses( + m: &Mode, + start: usize, + end: usize, + skip_start: usize, + bits1: &[i32; NB_EBANDS], + bits2: &[i32; NB_EBANDS], + thresh: &[i32; NB_EBANDS], + cap: &[i32; NB_EBANDS], + mut total: i32, + skip_rsv: i32, + mut intensity_rsv: i32, + mut dual_stereo_rsv: i32, + c: usize, + lm: usize, + enc: &mut RangeEncoder, +) -> Allocation { + let ci = c as i32; + let alloc_floor = ci << BITRES; + let stereo = (c > 1) as i32; + let log_m = (lm as i32) << BITRES; + let mut lo = 0; + let mut hi = 1 << ALLOC_STEPS; + for _ in 0..ALLOC_STEPS { + let mid = (lo + hi) >> 1; + let mut psum = 0; + let mut done = false; + for j in (start..end).rev() { + let tmp = bits1[j] + (mid * bits2[j] >> ALLOC_STEPS); + if tmp >= thresh[j] || done { + done = true; + psum += tmp.min(cap[j]); + } else if tmp >= alloc_floor { + psum += alloc_floor; + } + } + if psum > total { + hi = mid; + } else { + lo = mid; + } + } + let mut bits = [0i32; NB_EBANDS]; + let mut psum = 0; + let mut done = false; + for j in (start..end).rev() { + let mut tmp = bits1[j] + (lo * bits2[j] >> ALLOC_STEPS); + if tmp < thresh[j] && !done { + tmp = if tmp >= alloc_floor { alloc_floor } else { 0 }; + } else { + done = true; + } + tmp = tmp.min(cap[j]); + bits[j] = tmp; + psum += tmp; + } + let s = start; + let mut coded_bands = end; + loop { + let j = coded_bands - 1; + if j <= skip_start { + total += skip_rsv; + break; + } + let mut left = total - psum; + let span = EBANDS[coded_bands] - EBANDS[s]; + let percoeff = left / span; + left -= span * percoeff; + let rem = (left - (EBANDS[j] - EBANDS[s])).max(0); + let band_width = EBANDS[coded_bands] - EBANDS[j]; + let band_bits = bits[j] + percoeff * band_width + rem; + if band_bits >= thresh[j].max(alloc_floor + (1 << BITRES)) { + // Keep this band (and every band below it). + enc.bit_logp(true, 1); + break; + } + psum -= bits[j] + intensity_rsv; + if intensity_rsv > 0 { + intensity_rsv = LOG2_FRAC_TABLE[j - s]; + } + psum += intensity_rsv; + if band_bits >= alloc_floor { + psum += alloc_floor; + bits[j] = alloc_floor; + } else { + bits[j] = 0; + } + coded_bands -= 1; + } + let intensity = if intensity_rsv > 0 { + // No intensity stereo: every coded band is coded in full. + enc.uint((coded_bands - s) as u32, (coded_bands + 1 - s) as u32); + coded_bands + } else { + 0 + }; + if intensity <= s { + total += dual_stereo_rsv; + dual_stereo_rsv = 0; + } + let dual_stereo = if dual_stereo_rsv > 0 { + enc.bit_logp(true, 1); + true + } else { + false + }; + let mut left = total - psum; + let span = EBANDS[coded_bands] - EBANDS[s]; + let percoeff = left / span; + left -= span * percoeff; + for j in s..coded_bands { + bits[j] += percoeff * (EBANDS[j + 1] - EBANDS[j]); + } + for j in s..coded_bands { + let tmp = left.min(EBANDS[j + 1] - EBANDS[j]); + bits[j] += tmp; + left -= tmp; + } + let mut ebits = [0i32; NB_EBANDS]; + let mut fine_priority = [0i32; NB_EBANDS]; + let mut balance = 0; + for j in s..coded_bands { + let n0 = EBANDS[j + 1] - EBANDS[j]; + let n = n0 << lm; + let bit = bits[j] + balance; + let mut excess; + if n > 1 { + excess = (bit - cap[j]).max(0); + bits[j] = bit - excess; + let den = ci * n + if c == 2 && n > 2 && !dual_stereo && j < intensity { 1 } else { 0 }; + let nclogn = den * (m.log_n[j] + log_m); + let mut offset = (nclogn >> 1) - den * FINE_OFFSET; + if n == 2 { + offset += (den << BITRES) >> 2; + } + if bits[j] + offset < den * 2 << BITRES { + offset += nclogn >> 2; + } else if bits[j] + offset < den * 3 << BITRES { + offset += nclogn >> 3; + } + let mut eb = (bits[j] + offset + (den << (BITRES - 1))).max(0); + eb = (eb / den) >> BITRES; + if ci * eb > (bits[j] >> BITRES) { + eb = bits[j] >> stereo >> BITRES; + } + eb = eb.min(MAX_FINE_BITS); + ebits[j] = eb; + fine_priority[j] = (eb * (den << BITRES) >= bits[j] + offset) as i32; + bits[j] -= ci * eb << BITRES; + } else { + excess = (bit - (ci << BITRES)).max(0); + bits[j] = bit - excess; + ebits[j] = 0; + fine_priority[j] = 1; + } + if excess > 0 { + let extra_fine = (excess >> (stereo + BITRES as i32)).min(MAX_FINE_BITS - ebits[j]); + ebits[j] += extra_fine; + let extra_bits = extra_fine * ci << BITRES; + fine_priority[j] = (extra_bits >= excess - balance) as i32; + excess -= extra_bits; + } + balance = excess; + } + for j in coded_bands..end { + ebits[j] = bits[j] >> stereo >> BITRES; + bits[j] = 0; + fine_priority[j] = (ebits[j] < 1) as i32; + } + Allocation { coded_bands, intensity, dual_stereo, balance, pulses: bits, fine_quant: ebits, fine_priority } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn caps_match_decoder_reference() { + let m = Mode::new(); + let first_rows: [u8; 42] = [ + 224, 224, 224, 224, 224, 224, 224, 224, 160, 160, 160, 160, 185, 185, 185, 178, 178, 168, 134, 61, 37, 224, 224, 224, 224, 224, 224, 224, + 224, 240, 240, 240, 240, 207, 207, 207, 198, 198, 183, 144, 66, 40, + ]; + assert_eq!(&m.caps[..42], &first_rows[..]); + } + + #[test] + fn pvq_counts() { + assert_eq!(pvq_v(5, 1), 10); + assert_eq!(pvq_v(1, 7), 2); + assert_eq!(pvq_v(2, 9), 36); + assert_eq!(pvq_v(3, 2), 18); + } +} diff --git a/crates/audio/src/opus/encoder/resample.rs b/crates/audio/src/opus/encoder/resample.rs new file mode 100644 index 000000000..9045cb86d --- /dev/null +++ b/crates/audio/src/opus/encoder/resample.rs @@ -0,0 +1,131 @@ +//! 48 kHz → 8/12/16 kHz decimator feeding the SILK layer (own design). +//! +//! A linear-phase low-pass FIR (Kaiser-windowed sinc at 48 kHz) evaluated only at the retained +//! output phases. Its group delay ([`delay`]) is a whole number of 48 kHz samples, chosen so +//! that the SILK path (this filter + the decoder's internal-rate delay and resampler, RFC 6716 +//! Table 54) lands within a fraction of a sample of the CELT path's 120-sample MDCT overlap. + +/// Group delay of the decimator to `fs_out` in 48 kHz samples (the filter has `2 * delay + 1` +/// taps). +pub fn delay(fs_out: u32) -> usize { + if fs_out == 8_000 { 88 } else { 83 } +} + +/// Zeroth-order modified Bessel function of the first kind (power series). +fn bessel_i0(x: f64) -> f64 { + let mut sum = 1.0; + let mut term = 1.0; + let q = x * x / 4.0; + for k in 1..64 { + term *= q / (k * k) as f64; + sum += term; + if term < sum * 1e-17 { + break; + } + } + sum +} + +/// Windowed-sinc low-pass with cutoff `fc` (Hz at 48 kHz), unit DC gain. +fn design(fc: f64, beta: f64, delay: usize) -> Vec { + let wc = 2.0 * std::f64::consts::PI * fc / 48_000.0; + let i0b = bessel_i0(beta); + let mut h: Vec = (0..2 * delay + 1) + .map(|k| { + let m = k as f64 - delay as f64; + let sinc = if m == 0.0 { wc / std::f64::consts::PI } else { (wc * m).sin() / (std::f64::consts::PI * m) }; + let r = m / delay as f64; + sinc * bessel_i0(beta * (1.0 - r * r).max(0.0).sqrt()) / i0b + }) + .collect(); + let s: f64 = h.iter().sum(); + for v in h.iter_mut() { + *v /= s; + } + h.iter().map(|&v| v as f32).collect() +} + +/// Decimates one channel of 48 kHz audio by 3, 4 or 6. +#[derive(Clone)] +pub struct Downsampler { + factor: usize, + h: Vec, + taps: usize, + /// The last `taps - 1` input samples followed by the current frame. + buf: Vec, +} + +impl Downsampler { + /// `fs_out` is 8000, 12000 or 16000. + pub fn new(fs_out: u32) -> Self { + let factor = (48_000 / fs_out) as usize; + // Cutoff at 95 % of the output Nyquist frequency (pass band to ~88 %, stop band from ~110 %). + let fc = 0.95 * fs_out as f64 / 2.0; + let d = delay(fs_out); + let taps = 2 * d + 1; + Downsampler { factor, h: design(fc, 6.5, d), taps, buf: vec![0.0; taps - 1] } + } + + /// Decimates `input` (a multiple of the factor long), appending to `out`. Output sample `j` + /// is the filtered input at frame position `factor * j`, delayed by [`delay`]. + pub fn process(&mut self, input: &[f32], out: &mut Vec) { + debug_assert!(input.len().is_multiple_of(self.factor)); + let taps = self.taps; + self.buf.truncate(taps - 1); + self.buf.extend_from_slice(input); + for j in 0..input.len() / self.factor { + let t = taps - 1 + self.factor * j; + let win = &self.buf[t + 1 - taps..=t]; + // h is symmetric, so the window can be taken in forward order. + out.push(win.iter().zip(&self.h).map(|(a, b)| a * b).sum()); + } + let n = self.buf.len(); + self.buf.copy_within(n - (taps - 1).., 0); + self.buf.truncate(taps - 1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tone(f: f64, n: usize) -> Vec { + (0..n).map(|i| (2.0 * std::f64::consts::PI * f * i as f64 / 48_000.0).sin() as f32).collect() + } + + fn rms(x: &[f32]) -> f64 { + (x.iter().map(|&v| (v as f64).powi(2)).sum::() / x.len() as f64).sqrt() + } + + #[test] + fn passes_band_and_rejects_aliases() { + for &(fs, pass, stop) in &[(16_000u32, 6_000.0, 8_600.0), (12_000, 4_500.0, 6_500.0), (8_000, 3_000.0, 4_500.0)] { + for &(f, want_pass) in &[(pass, true), (stop, false)] { + let mut d = Downsampler::new(fs); + let x = tone(f, 9600); + let mut y = Vec::new(); + for c in x.chunks(960) { + d.process(c, &mut y); + } + let r = rms(&y[y.len() / 2..]) * std::f64::consts::SQRT_2; + if want_pass { + assert!((r - 1.0).abs() < 0.02, "{fs}: {f} Hz gain {r}"); + } else { + assert!(r < 0.01, "{fs}: {f} Hz leaks {r}"); + } + } + } + } + + #[test] + fn delay_is_exact() { + let mut d = Downsampler::new(16_000); + let mut x = vec![0f32; 960]; + x[300] = 1.0; + let mut y = Vec::new(); + d.process(&x, &mut y); + // The impulse at 300 shows up centred at 48 kHz time 300 + 83 = 383 (output 127.67). + let peak = y.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).map(|(i, _)| i).unwrap_or(0); + assert!((peak as f64 - (300.0 + delay(16_000) as f64) / 3.0).abs() < 1.0, "peak at {peak}"); + } +} diff --git a/crates/audio/src/opus/encoder/silk/analysis.rs b/crates/audio/src/opus/encoder/silk/analysis.rs new file mode 100644 index 000000000..439331076 --- /dev/null +++ b/crates/audio/src/opus/encoder/silk/analysis.rs @@ -0,0 +1,322 @@ +//! Encoder-side signal analysis for SILK (own design): windowed autocorrelation LPC, LPC → NLSF +//! conversion, open-loop pitch search and LTP codebook search. + +use super::tables::{CB_NB_20, CB_WB_20, LTP_FILTER, LTP_PERIODICITY, LTP_TAPS_0, LTP_TAPS_1, LTP_TAPS_2}; + +/// Short-term prediction coefficients `a` (prediction `x[n] ≈ Σ a[j] x[n-1-j]`) from the +/// windowed segment `x`, with a Gaussian lag window and white-noise correction. +pub fn lpc(x: &[f32], order: usize, fs_khz: usize) -> Vec { + let n = x.len(); + // Asymmetric window: sin² rise over the first half, flat, cos fall over the last 1/8. + let rise = n / 2; + let fall = n / 8; + let w = |i: usize| -> f64 { + if i < rise { + let s = (std::f64::consts::FRAC_PI_2 * (i as f64 + 0.5) / rise as f64).sin(); + s * s + } else if i >= n - fall { + (std::f64::consts::FRAC_PI_2 * (i + fall - n) as f64 / fall as f64 + 0.0).cos() + } else { + 1.0 + } + }; + let xw: Vec = x.iter().enumerate().map(|(i, &v)| v as f64 * w(i)).collect(); + let mut r = vec![0f64; order + 1]; + for (k, rk) in r.iter_mut().enumerate() { + *rk = xw[k..].iter().zip(&xw).map(|(a, b)| a * b).sum(); + } + if r[0] <= 1e-6 { + return vec![0.0; order]; + } + // White-noise correction (-40 dB) and a 50 Hz Gaussian lag window. + r[0] *= 1.0 + 1e-4; + let f0 = 2.0 * std::f64::consts::PI * 50.0 / (fs_khz as f64 * 1000.0); + for (k, rk) in r.iter_mut().enumerate().skip(1) { + *rk *= (-0.5 * (f0 * k as f64).powi(2)).exp(); + } + let mut a = levinson(&r, order); + // Mild bandwidth expansion keeps the NLSFs apart. + let mut g = 1.0; + for v in a.iter_mut() { + g *= 0.996; + *v *= g; + } + a +} + +/// Levinson-Durbin recursion on autocorrelation `r` (predictor convention as in [`lpc`]). +pub fn levinson(r: &[f64], order: usize) -> Vec { + let mut a = vec![0f64; order]; + let mut err = r[0]; + let mut tmp = vec![0f64; order]; + for i in 0..order { + let mut acc = r[i + 1]; + for j in 0..i { + acc -= a[j] * r[i - j]; + } + let k = (acc / err).clamp(-0.9999, 0.9999); + tmp[..i].copy_from_slice(&a[..i]); + for j in 0..i { + a[j] = tmp[j] - k * tmp[i - 1 - j]; + } + a[i] = k; + err *= 1.0 - k * k; + if err <= 0.0 { + break; + } + } + a +} + +/// Evaluates the symmetric polynomial `p` (length `d + 1`, `d` even) as the cosine series +/// `e^{jωd/2} P(e^{jω})`. +fn cos_series(p: &[f64], w: f64) -> f64 { + let h = (p.len() - 1) / 2; + let mut s = p[h]; + for k in 0..h { + s += 2.0 * p[k] * ((h - k) as f64 * w).cos(); + } + s +} + +fn roots(p: &[f64], out: &mut Vec) { + const GRID: usize = 1024; + let mut w0 = 0.0; + let mut f0 = cos_series(p, w0); + for g in 1..=GRID { + let w1 = std::f64::consts::PI * g as f64 / GRID as f64; + let f1 = cos_series(p, w1); + if f0 == 0.0 { + out.push(w0); + } else if f0 * f1 < 0.0 { + let (mut lo, mut hi, mut flo) = (w0, w1, f0); + for _ in 0..40 { + let mid = 0.5 * (lo + hi); + let fm = cos_series(p, mid); + if fm * flo <= 0.0 { + hi = mid; + } else { + lo = mid; + flo = fm; + } + } + out.push(0.5 * (lo + hi)); + } + w0 = w1; + f0 = f1; + } +} + +/// LPC → normalised line spectral frequencies in radians (ascending, `order` values), or `None` +/// when the root search fails (unstable or degenerate filter). +pub fn a2nlsf(a: &[f64]) -> Option> { + let d = a.len(); + let mut c = vec![0f64; d + 2]; + c[0] = 1.0; + for k in 0..d { + c[k + 1] = -a[k]; + } + let mut p = vec![0f64; d + 2]; + let mut q = vec![0f64; d + 2]; + for k in 0..d + 2 { + p[k] = c[k] + c[d + 1 - k]; + q[k] = c[k] - c[d + 1 - k]; + } + // Remove the trivial roots at z = -1 (P) and z = +1 (Q). + let mut p1 = vec![0f64; d + 1]; + let mut q1 = vec![0f64; d + 1]; + p1[0] = p[0]; + q1[0] = q[0]; + for k in 1..=d { + p1[k] = p[k] - p1[k - 1]; + q1[k] = q[k] + q1[k - 1]; + } + let mut r = Vec::with_capacity(d); + roots(&p1, &mut r); + roots(&q1, &mut r); + if r.len() != d { + return None; + } + r.sort_by(f64::total_cmp); + Some(r) +} + +/// Normalised cross-correlation of `x[n]` and `x[n - lag]` over `n` in `range`. +fn ncorr(x: &[f32], range: std::ops::Range, lag: usize) -> f64 { + let (mut xy, mut xx, mut yy) = (0f64, 0f64, 0f64); + for n in range { + let a = x[n] as f64; + let b = x[n - lag] as f64; + xy += a * b; + xx += a * a; + yy += b * b; + } + if xy <= 0.0 { 0.0 } else { xy / (xx * yy + 1e-9).sqrt() } +} + +pub struct Pitch { + pub lag_index: i32, + pub contour: usize, + pub lags: [i32; 4], + pub score: f64, +} + +/// Open-loop pitch search on `x` (history followed by the frame starting at `start`, `len` +/// samples, four subframes) at `fs_khz`. +pub fn pitch_search(x: &[f32], start: usize, len: usize, fs_khz: usize) -> Pitch { + let fs = fs_khz as i32; + let min_lag = 2 * fs; + let max_lag = 18 * fs - 1; + let sub = len / 4; + let nl = (max_lag - min_lag + 1) as usize; + // Whole-frame score per lag. + let full: Vec = (0..nl).map(|i| ncorr(x, start..start + len, min_lag as usize + i)).collect(); + let mut best = 0usize; + for i in 0..nl { + // Slight preference for shorter lags. + if full[i] * (1.0 - 0.0004 * i as f64) > full[best] * (1.0 - 0.0004 * best as f64) { + best = i; + } + } + let mut lag = min_lag + best as i32; + // Prefer a sub-multiple when it explains the signal almost as well (octave errors). + for div in [4, 3, 2] { + let cand = (lag as f64 / div as f64).round() as i32; + let mut cbest = (0.0, 0); + for c in cand - 1..=cand + 1 { + if c >= min_lag && c <= max_lag { + let s = full[(c - min_lag) as usize]; + if s > cbest.0 { + cbest = (s, c); + } + } + } + if cbest.1 != 0 && cbest.0 > 0.9 * full[(lag - min_lag) as usize] { + lag = cbest.1; + break; + } + } + let score = full[(lag - min_lag) as usize]; + // Contour: per-subframe lag offsets around the chosen lag. + let cb: &[[i32; 4]] = if fs_khz == 8 { &CB_NB_20 } else { &CB_WB_20 }; + let sub_score = |k: usize, l: i32| -> f64 { + let l = l.clamp(min_lag, 18 * fs); + ncorr(x, start + k * sub..start + (k + 1) * sub, l as usize) + }; + let mut best = (f64::MIN, lag, 0usize); + for l0 in (lag - 2).max(min_lag)..=(lag + 2).min(max_lag) { + for (ci, offs) in cb.iter().enumerate() { + let s: f64 = (0..4).map(|k| sub_score(k, l0 + offs[k])).sum::() - 0.002 * ci as f64; + if s > best.0 { + best = (s, l0, ci); + } + } + } + let (_, l0, ci) = best; + let mut lags = [0i32; 4]; + for k in 0..4 { + lags[k] = (l0 + cb[ci][k]).clamp(min_lag, 18 * fs); + } + Pitch { lag_index: l0 - min_lag, contour: ci, lags, score } +} + +/// Result of the LTP codebook search. +pub struct Ltp { + pub per_index: usize, + pub ltp_index: [usize; 4], + /// Residual energy after LTP prediction / before, per subframe. + pub err: [f64; 4], + pub energy: [f64; 4], +} + +fn icdf_bits(icdf: &[u8], s: usize) -> f64 { + let hi = if s == 0 { 256 } else { icdf[s - 1] as u32 }; + let p = (hi - icdf[s] as u32).max(1) as f64 / 256.0; + -p.log2() +} + +/// Searches the three LTP codebooks (RFC 6716 Tables 39–42) for the taps that best predict the +/// residual `r` (history + frame starting at `start`) from its past at the per-subframe `lags`. +pub fn ltp_search(r: &[f64], start: usize, sub: usize, lags: &[i32; 4]) -> Ltp { + let mut rr = [[0f64; 25]; 4]; + let mut cv = [[0f64; 5]; 4]; + let mut e = [0f64; 4]; + for k in 0..4 { + let s0 = start + k * sub; + let lag = lags[k] as usize; + for n in s0..s0 + sub { + let t = r[n]; + e[k] += t * t; + let base = n + 2 - lag; + for i in 0..5 { + let vi = r[base - i]; + cv[k][i] += t * vi; + for j in 0..5 { + rr[k][i * 5 + j] += vi * r[base - j]; + } + } + } + } + let books: [&[[i32; 5]]; 3] = [<P_TAPS_0, <P_TAPS_1, <P_TAPS_2]; + let mut best = (f64::MAX, 0usize, [0usize; 4], [0f64; 4]); + for (p, book) in books.iter().enumerate() { + let mut total = icdf_bits(<P_PERIODICITY, p) * 1e-3 * e.iter().sum::() / 4.0; + let mut idx = [0usize; 4]; + let mut errs = [0f64; 4]; + for k in 0..4 { + let mut bk = (f64::MAX, 0usize, 0f64); + for (i, taps) in book.iter().enumerate() { + let b: [f64; 5] = std::array::from_fn(|j| taps[j] as f64 / 128.0); + let mut err = e[k]; + for a in 0..5 { + err -= 2.0 * b[a] * cv[k][a]; + for c in 0..5 { + err += b[a] * b[c] * rr[k][a * 5 + c]; + } + } + let cost = err + icdf_bits(LTP_FILTER[p], i) * 1e-3 * e[k]; + if cost < bk.0 { + bk = (cost, i, err.max(0.0)); + } + } + total += bk.0; + idx[k] = bk.1; + errs[k] = bk.2; + } + if total < best.0 { + best = (total, p, idx, errs); + } + } + Ltp { per_index: best.1, ltp_index: best.2, err: best.3, energy: e } +} + +#[cfg(test)] +mod tests { + use super::super::lpc::nlsf2a; + use super::*; + + #[test] + fn nlsf_roundtrip_through_decoder_conversion() { + // A resonant 10th/16th-order predictor from a synthetic signal. + for &(order, fs) in &[(10usize, 8usize), (16, 16)] { + let mut st = [0f64; 4]; + let mut seed = 1u32; + let x: Vec = (0..400) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let w = (seed >> 9) as f64 / (1u32 << 23) as f64 - 1.0; + let y = w + 1.6 * st[0] - 0.9 * st[1] + 0.3 * st[2] - 0.2 * st[3]; + st = [y, st[0], st[1], st[2]]; + (y * 1000.0) as f32 + }) + .collect(); + let a = lpc(&x, order, fs); + let nlsf = a2nlsf(&a).expect("roots"); + let q15: Vec = nlsf.iter().map(|w| (w / std::f64::consts::PI * 32768.0).round() as i32).collect(); + let a_q12 = nlsf2a(&q15); + for j in 0..order { + assert!((a_q12[j] as f64 / 4096.0 - a[j]).abs() < 0.02, "order {order} coef {j}: {} vs {}", a_q12[j] as f64 / 4096.0, a[j]); + } + } + } +} diff --git a/crates/audio/src/opus/encoder/silk/lpc.rs b/crates/audio/src/opus/encoder/silk/lpc.rs new file mode 100644 index 000000000..e4e9ae624 --- /dev/null +++ b/crates/audio/src/opus/encoder/silk/lpc.rs @@ -0,0 +1,370 @@ +//! Fixed-point NLSF decoding, NLSF→LPC conversion and filter stabilisation +//! (RFC 6716 §4.2.7.5, with the RFC 8251 §6/§7 overflow fixes). +//! +//! This is the decoder's normative arithmetic, mirrored from FilmCraft's first-party, +//! spec-derived `filmcraft-opus` decoder (MIT OR Apache-2.0): the encoder must reconstruct the +//! quantised NLSFs and LPC coefficients bit-exactly as the decoder does. + +use super::tables::{ + COS_Q12, LSF_ORDER_NBMB, LSF_ORDER_WB, NLSF_CB_NBMB, NLSF_CB_WB, NLSF_MIN_SPACING_NBMB, NLSF_MIN_SPACING_WB, NLSF_PRED_NBMB, NLSF_PRED_WB, + NLSF_PSEL_NBMB, NLSF_PSEL_WB, +}; + +pub const MAX_LPC_ORDER: usize = 16; + +#[inline] +pub fn smulwb(a: i32, b: i32) -> i32 { + ((a as i64 * (b as i16) as i64) >> 16) as i32 +} +#[inline] +pub fn smlawb(a: i32, b: i32, c: i32) -> i32 { + a.wrapping_add(smulwb(b, c)) +} +#[inline] +pub fn smulww(a: i32, b: i32) -> i32 { + ((a as i64 * b as i64) >> 16) as i32 +} +#[inline] +pub fn smmul(a: i32, b: i32) -> i32 { + ((a as i64 * b as i64) >> 32) as i32 +} +#[inline] +pub fn rshift_round(a: i32, s: u32) -> i32 { + if s == 1 { (a >> 1) + (a & 1) } else { ((a >> (s - 1)) + 1) >> 1 } +} +#[inline] +pub fn rshift_round64(a: i64, s: u32) -> i64 { + if s == 1 { (a >> 1) + (a & 1) } else { ((a >> (s - 1)) + 1) >> 1 } +} +#[inline] +pub fn sat16(a: i32) -> i32 { + a.clamp(-32768, 32767) +} + +/// `silk_INVERSE32_varQ`: approximation of `(1 << q_res) / b32`. +pub fn inverse32_varq(b32: i32, q_res: i32) -> i32 { + let b_headrm = b32.unsigned_abs().leading_zeros() as i32 - 1; + let b32_nrm = b32.wrapping_shl(b_headrm as u32); + let b32_inv = (i32::MAX >> 2) / (b32_nrm >> 16); + let mut result = b32_inv << 16; + let err_q32 = ((1i32 << 29).wrapping_sub(smulwb(b32_nrm, b32_inv))).wrapping_shl(3); + result = result.wrapping_add(((err_q32 as i64 * b32_inv as i64) >> 16) as i32); + let lshift = 61 - b_headrm - q_res; + if lshift <= 0 { + let s = (-lshift) as u32; + let lim_hi = i32::MAX >> s; + let lim_lo = i32::MIN >> s; + result.clamp(lim_lo, lim_hi) << s + } else if lshift < 32 { + result >> lshift + } else { + 0 + } +} + +/// `silk_DIV32_varQ`: approximation of `(a32 << q_res) / b32`. +pub fn div32_varq(a32: i32, b32: i32, q_res: i32) -> i32 { + let a_headrm = a32.unsigned_abs().leading_zeros() as i32 - 1; + let a32_nrm = a32.wrapping_shl(a_headrm as u32); + let b_headrm = b32.unsigned_abs().leading_zeros() as i32 - 1; + let b32_nrm = b32.wrapping_shl(b_headrm as u32); + let b32_inv = (i32::MAX >> 2) / (b32_nrm >> 16); + let mut result = smulwb(a32_nrm, b32_inv); + let a32_nrm = a32_nrm.wrapping_sub(((smmul(b32_nrm, result) as i64) << 3) as i32); + result = smlawb(result, a32_nrm, b32_inv); + let lshift = 29 + a_headrm - b_headrm - q_res; + if lshift < 0 { + let s = (-lshift) as u32; + let lim_hi = i32::MAX >> s; + let lim_lo = i32::MIN >> s; + result.clamp(lim_lo, lim_hi) << s + } else if lshift < 32 { + result >> lshift + } else { + 0 + } +} + +pub fn sqrt_approx(x: i32) -> i32 { + if x <= 0 { + return 0; + } + let lz = x.leading_zeros() as i32; + let frac = (x.rotate_right((24 - lz).rem_euclid(32) as u32)) & 0x7f; + let mut y = if lz & 1 != 0 { 32768 } else { 46214 }; + y >>= lz >> 1; + smlawb(y, y, 213 * frac) +} + +/// Decodes the stage-1/stage-2 indices into stabilised NLSFs (Q15). +pub fn nlsf_decode(indices: &[i32], wb: bool) -> [i32; MAX_LPC_ORDER] { + let order = if wb { 16 } else { 10 }; + let i1 = indices[0] as usize; + let cb1: &[i32] = if wb { &NLSF_CB_WB[i1] } else { &NLSF_CB_NBMB[i1] }; + let qstep = if wb { 9830 } else { 11796 }; + // Residual dequantisation with backwards prediction. + let mut res_q10 = [0i32; MAX_LPC_ORDER]; + let mut out_q10 = 0i32; + for i in (0..order).rev() { + let pred = if i + 1 < order { + let sel = if wb { NLSF_PSEL_WB[i1][i] } else { NLSF_PSEL_NBMB[i1][i] } as usize; + let w = if wb { NLSF_PRED_WB[sel][i] } else { NLSF_PRED_NBMB[sel][i] }; + (out_q10 * w) >> 8 + } else { + 0 + }; + let mut v = indices[i + 1] << 10; + if v > 0 { + v -= 102; + } else if v < 0 { + v += 102; + } + out_q10 = smlawb(pred, v, qstep); + res_q10[i] = out_q10; + } + // Laroia weights. + let mut nlsf = [0i32; MAX_LPC_ORDER]; + for k in 0..order { + let prev = if k == 0 { 0 } else { cb1[k - 1] }; + let next = if k + 1 == order { 256 } else { cb1[k + 1] }; + let w = (1024 / (cb1[k] - prev).max(1) + 1024 / (next - cb1[k]).max(1)).min(32767); + let w_q9 = sqrt_approx(w << 16); + let v = (cb1[k] << 7) + (res_q10[k] << 14) / w_q9.max(1); + nlsf[k] = v.clamp(0, 32767); + } + let dmin: &[i32] = if wb { &NLSF_MIN_SPACING_WB } else { &NLSF_MIN_SPACING_NBMB }; + nlsf_stabilize(&mut nlsf[..order], dmin); + nlsf +} + +pub fn nlsf_stabilize(nlsf: &mut [i32], dmin: &[i32]) { + let l = nlsf.len(); + for _ in 0..20 { + let mut min_diff = nlsf[0] - dmin[0]; + let mut idx = 0; + for i in 1..l { + let d = nlsf[i] - (nlsf[i - 1] + dmin[i]); + if d < min_diff { + min_diff = d; + idx = i; + } + } + let d = 32768 - (nlsf[l - 1] + dmin[l]); + if d < min_diff { + min_diff = d; + idx = l; + } + if min_diff >= 0 { + return; + } + if idx == 0 { + nlsf[0] = dmin[0]; + } else if idx == l { + nlsf[l - 1] = 32768 - dmin[l]; + } else { + let mut min_center = dmin[..idx].iter().sum::(); + min_center += dmin[idx] >> 1; + let mut max_center = 32768 - dmin[idx + 1..=l].iter().sum::(); + max_center -= dmin[idx] >> 1; + let c = rshift_round(nlsf[idx - 1] + nlsf[idx], 1); + let c = if min_center > max_center { c.clamp(max_center, min_center) } else { c.clamp(min_center, max_center) }; + nlsf[idx - 1] = c - (dmin[idx] >> 1); + nlsf[idx] = nlsf[idx - 1] + dmin[idx]; + } + } + // Fallback. + nlsf.sort_unstable(); + nlsf[0] = nlsf[0].max(dmin[0]); + for i in 1..l { + nlsf[i] = nlsf[i].max(sat16(nlsf[i - 1] + dmin[i])); + } + nlsf[l - 1] = nlsf[l - 1].min(32768 - dmin[l]); + for i in (0..l - 1).rev() { + nlsf[i] = nlsf[i].min(nlsf[i + 1] - dmin[i + 1]); + } +} + +fn bwexpander_32(ar: &mut [i32], mut chirp_q16: i32) { + let d = ar.len(); + let chirp_minus_one = chirp_q16 - 65536; + for v in ar.iter_mut().take(d - 1) { + *v = smulww(chirp_q16, *v); + chirp_q16 += rshift_round(chirp_q16.wrapping_mul(chirp_minus_one), 16); + } + ar[d - 1] = smulww(chirp_q16, ar[d - 1]); +} + +fn lpc_inverse_pred_gain(a_q12: &[i32]) -> i32 { + const QA: u32 = 24; + const A_LIMIT: i32 = 16_773_022; + let order = a_q12.len(); + let mut a = [0i32; MAX_LPC_ORDER]; + let mut dc = 0; + for k in 0..order { + dc += a_q12[k]; + a[k] = a_q12[k] << (QA - 12); + } + if dc >= 4096 { + return 0; + } + let mut inv_gain: i32 = 1 << 30; + let mul32_frac_q31 = |x: i32, y: i32| rshift_round64(x as i64 * y as i64, 31) as i32; + for k in (1..order).rev() { + if a[k] > A_LIMIT || a[k] < -A_LIMIT { + return 0; + } + let rc_q31 = -(a[k] << (31 - QA)); + let rc_mult1 = (1i32 << 30) - smmul(rc_q31, rc_q31); + inv_gain = smmul(inv_gain, rc_mult1) << 2; + if inv_gain < 107_374 { + return 0; + } + let mult2q = 32 - rc_mult1.unsigned_abs().leading_zeros() as i32; + let rc_mult2 = inverse32_varq(rc_mult1, mult2q + 30); + for n in 0..(k + 1) >> 1 { + let t1 = a[n]; + let t2 = a[k - n - 1]; + let v1 = rshift_round64(t1.saturating_sub(mul32_frac_q31(t2, rc_q31)) as i64 * rc_mult2 as i64, mult2q as u32); + if v1 > i32::MAX as i64 || v1 < i32::MIN as i64 { + return 0; + } + let v2 = rshift_round64(t2.saturating_sub(mul32_frac_q31(t1, rc_q31)) as i64 * rc_mult2 as i64, mult2q as u32); + if v2 > i32::MAX as i64 || v2 < i32::MIN as i64 { + return 0; + } + a[n] = v1 as i32; + a[k - n - 1] = v2 as i32; + } + } + if a[0] > A_LIMIT || a[0] < -A_LIMIT { + return 0; + } + let rc_q31 = -(a[0] << (31 - QA)); + let rc_mult1 = (1i32 << 30) - smmul(rc_q31, rc_q31); + inv_gain = smmul(inv_gain, rc_mult1) << 2; + if inv_gain < 107_374 { + return 0; + } + inv_gain +} + +/// Converts NLSFs (Q15) to stable Q12 LPC coefficients (`silk_NLSF2A`). +pub fn nlsf2a(nlsf: &[i32]) -> [i32; MAX_LPC_ORDER] { + let d = nlsf.len(); + let ordering: &[usize] = if d == 16 { &LSF_ORDER_WB } else { &LSF_ORDER_NBMB }; + let mut c_q17 = [0i32; MAX_LPC_ORDER]; + for k in 0..d { + let i = (nlsf[k] >> 8) as usize; + let f = nlsf[k] & 255; + let c0 = COS_Q12[i]; + let c1 = COS_Q12[(i + 1).min(128)]; + c_q17[ordering[k]] = (c0 * 256 + (c1 - c0) * f + 4) >> 3; + } + let d2 = d / 2; + let find_poly = |off: usize| -> [i64; 10] { + let mut out = [0i64; 10]; + out[0] = 1 << 16; + out[1] = -(c_q17[off] as i64); + for k in 1..d2 { + let ftmp = c_q17[2 * k + off] as i64; + out[k + 1] = 2 * out[k - 1] - ((ftmp * out[k] + 32768) >> 16); + for n in (2..=k).rev() { + out[n] += out[n - 2] - ((ftmp * out[n - 1] + 32768) >> 16); + } + out[1] -= ftmp; + } + out + }; + let p = find_poly(0); + let q = find_poly(1); + let mut a32 = [0i32; MAX_LPC_ORDER]; + for k in 0..d2 { + let ptmp = p[k + 1] + p[k]; + let qtmp = q[k + 1] - q[k]; + a32[k] = (-qtmp - ptmp) as i32; + a32[d - k - 1] = (qtmp - ptmp) as i32; + } + let a32 = &mut a32[..d]; + // Limit the dynamic range (silk_LPC_fit with QIN = 17, QOUT = 12). + let mut a_q12 = [0i32; MAX_LPC_ORDER]; + let mut i = 0; + while i < 10 { + let mut maxabs = 0i32; + let mut idx = 0; + for (k, &v) in a32.iter().enumerate() { + let av = v.saturating_abs(); + if av > maxabs { + maxabs = av; + idx = k; + } + } + let maxabs = rshift_round(maxabs, 5); + if maxabs > 32767 { + let maxabs = maxabs.min(163_838); + let chirp = 65470 - ((maxabs - 32767) << 14) / ((maxabs * (idx as i32 + 1)) >> 2); + bwexpander_32(a32, chirp); + } else { + break; + } + i += 1; + } + if i == 10 { + for k in 0..d { + a_q12[k] = sat16(rshift_round(a32[k], 5)); + a32[k] = a_q12[k] << 5; + } + } else { + for k in 0..d { + a_q12[k] = rshift_round(a32[k], 5); + } + } + for i in 0..16 { + if lpc_inverse_pred_gain(&a_q12[..d]) == 0 { + bwexpander_32(a32, 65536 - (2 << i)); + for k in 0..d { + a_q12[k] = rshift_round(a32[k], 5); + } + } else { + break; + } + } + a_q12 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flat_nlsf_gives_small_lpc() { + // Uniformly spaced NLSFs correspond to an (almost) flat spectrum. + let nlsf: Vec = (1..=16).map(|k| k * 32768 / 17).collect(); + let a = nlsf2a(&nlsf); + assert!(a.iter().all(|v| v.abs() < 600), "{a:?}"); + let nlsf: Vec = (1..=10).map(|k| k * 32768 / 11).collect(); + let a = nlsf2a(&nlsf); + assert!(a[..10].iter().all(|v| v.abs() < 600), "{a:?}"); + } + + #[test] + fn stabilize_enforces_spacing() { + let mut v = [100, 90, 80, 30000, 30001, 30002, 31000, 32000, 32700, 32760]; + nlsf_stabilize(&mut v, &NLSF_MIN_SPACING_NBMB); + assert!(v[0] >= NLSF_MIN_SPACING_NBMB[0]); + for i in 1..10 { + assert!(v[i] - v[i - 1] >= NLSF_MIN_SPACING_NBMB[i], "{v:?}"); + } + assert!(32768 - v[9] >= NLSF_MIN_SPACING_NBMB[10]); + } + + #[test] + fn inverse_approximations() { + for &b in &[65536, 100_000, 1 << 20, 81920, 1_686_110_208] { + let inv = inverse32_varq(b, 47) as f64; + let exact = 2f64.powi(47) / b as f64; + assert!((inv - exact).abs() / exact < 1e-3, "{b}"); + } + let q = div32_varq(65536, 131072, 16) as f64; + assert!((q - 32768.0).abs() < 2.0); + } +} diff --git a/crates/audio/src/opus/encoder/silk/mod.rs b/crates/audio/src/opus/encoder/silk/mod.rs new file mode 100644 index 000000000..d82e537e0 --- /dev/null +++ b/crates/audio/src/opus/encoder/silk/mod.rs @@ -0,0 +1,714 @@ +//! SILK layer encoder (RFC 6716 §4.2, encoder direction), 20 ms frames at 8, 12 or 16 kHz. +//! +//! Each frame is analysed (VAD, open-loop pitch, windowed-autocorrelation LPC, LTP codebook +//! search), its NLSFs are quantised, and the excitation is chosen sample by sample by running +//! the decoder's exact fixed-point reconstruction (LTP and LPC synthesis with the dequantised +//! Q12/Q14 coefficients, quantisation offsets and the pseudo-random sign sequence), so the +//! encoder always holds the decoder's state and there is no drift. A noise-feedback loop +//! shapes the coding noise with a bandwidth-expanded LPC envelope, and a per-frame search over +//! a gain offset hits the bit budget. Stereo input is coded as mid/side with the decoder's +//! stereo prediction (§4.2.8). No LBRR, no NLSF interpolation. + +pub(crate) mod analysis; +pub(crate) mod lpc; +mod nlsf; +mod pulses; +#[allow(dead_code)] +pub(crate) mod tables; + +use lpc::{MAX_LPC_ORDER, div32_varq, inverse32_varq, nlsf2a, rshift_round, sat16, smlawb, smulwb, smulww}; +use tables::{ + DELTA_GAIN, GAIN_MSB, LTP_FILTER, LTP_PERIODICITY, LTP_SCALE, LTP_TAPS_0, LTP_TAPS_1, LTP_TAPS_2, MID_ONLY, NLSF_EXT, NLSF_INTERP, NLSF_SEL_NBMB, + NLSF_SEL_WB, NLSF_STAGE1, NLSF_STAGE2, PITCH_CONTOUR, PITCH_HIGH, PITCH_LOW, STEREO_PRED_JOINT, STEREO_WEIGHTS_Q13, TYPE_ACTIVE, TYPE_INACTIVE, + UNIFORM3, UNIFORM4, UNIFORM5, UNIFORM8, +}; + +use crate::opus::encoder::range::RangeEncoder; + +const MAX_FRAME: usize = 320; +const LTP_ORDER: usize = 5; +/// Frame RMS (16-bit scale) below which a frame is coded as inactive (VAD off). +const VAD_RMS: f32 = 30.0; +/// Normalised pitch correlation above which a frame is a voicing candidate. +const VOICING_THRESHOLD: f64 = 0.45; +/// Minimum LTP prediction gain (energy ratio) for a frame to be coded as voiced. +const MIN_LTP_GAIN: f64 = 1.3; +/// Rate penalty (squared pulse units per unit magnitude) of the excitation quantiser. +const RD_LAMBDA: f64 = 0.15; +/// Extra gain offset (log2) for inactive frames. +const INACTIVE_OFFSET: f64 = 1.0; +/// Noise feedback: the coding noise is shaped by `1 / A(z/γ)` (A the short-term predictor). +/// γ = 0 leaves it white (closed-loop DPCM, best waveform SNR at high rates), γ = 1 shapes it +/// like the spectral envelope (open-loop residual coding, needed at low rates). γ grows with the +/// gain offset, i.e. as the quantisation gets coarser. +fn nf_gamma(offset: f64) -> f64 { + (0.75 + 0.05 * offset).clamp(0.75, 0.9) +} + +/// `silk_log2lin` (decoder arithmetic). +fn log2lin(in_log_q7: i32) -> i32 { + if in_log_q7 < 0 { + return 0; + } + if in_log_q7 >= 3967 { + return i32::MAX; + } + let out = 1i32 << (in_log_q7 >> 7); + let frac = in_log_q7 & 0x7F; + if in_log_q7 < 2048 { + out + ((out * smlawb(frac, frac * (128 - frac), -174)) >> 7) + } else { + out + (out >> 7) * smlawb(frac, frac * (128 - frac), -174) + } +} + +fn gain_q16(index: i32) -> i32 { + log2lin((smulwb(0x1D1C71, index) + 2090).min(3967)) +} + +/// The decoder's LPC analysis filter on 16-bit samples. +fn lpc_analysis_filter(out: &mut [i16], input: &[i16], b: &[i32], d: usize) { + let len = out.len(); + for ix in d..len { + let mut acc: i32 = 0; + for j in 0..d { + acc = acc.wrapping_add((input[ix - 1 - j] as i32).wrapping_mul(b[j])); + } + let v = ((input[ix] as i32) << 12).wrapping_sub(acc); + out[ix] = sat16(rshift_round(v, 12)) as i16; + } + for v in out.iter_mut().take(d.min(len)) { + *v = 0; + } +} + +/// The part of the decoder's channel state that the reconstruction depends on. +#[derive(Clone)] +struct DecState { + prev_gain_q16: i32, + s_lpc_q14: [i32; MAX_LPC_ORDER], + out_buf: [i16; MAX_FRAME], + last_gain_index: i32, +} + +impl DecState { + fn new() -> Self { + DecState { prev_gain_q16: 65536, s_lpc_q14: [0; MAX_LPC_ORDER], out_buf: [0; MAX_FRAME], last_gain_index: 10 } + } + + /// The decoder's reset of the side channel when it is coded again after mid-only frames. + fn reset_side(&mut self) { + self.out_buf = [0; MAX_FRAME]; + self.s_lpc_q14 = [0; MAX_LPC_ORDER]; + self.last_gain_index = 10; + } +} + +/// Coded parameters of one frame (everything except gains and pulses). +#[derive(Clone, Default)] +struct Params { + vad: bool, + signal_type: usize, + quant_offset_type: usize, + nlsf_ix: [i32; 17], + a_q12: [i32; MAX_LPC_ORDER], + lag_index: i32, + contour: usize, + per_index: usize, + ltp_index: [usize; 4], + pitch_l: [i32; 4], + /// RMS (16-bit scale) of the open-loop prediction residual per subframe. + res_rms: [f32; 4], +} + +/// Gains, seed and pulses chosen by the quantiser. +struct Coded { + gain_ix: [i32; 4], + gains_q16: [i32; 4], + seed: usize, + pulses: Vec, +} + +#[derive(Clone)] +struct Channel { + fs_khz: usize, + len: usize, + sub: usize, + order: usize, + dec: DecState, + /// The last two frames of input (internal rate, 16-bit scale). + hist: Vec, + /// Recent output errors (decoded − input), most recent first, for noise feedback. + nf: [f64; MAX_LPC_ORDER], +} + +impl Channel { + fn new(fs_khz: usize) -> Self { + let len = 20 * fs_khz; + Channel { + fs_khz, + len, + sub: 5 * fs_khz, + order: if fs_khz == 16 { 16 } else { 10 }, + dec: DecState::new(), + hist: vec![0.0; 2 * len], + nf: [0.0; MAX_LPC_ORDER], + } + } + + fn push_history(&mut self, x: &[f32]) { + let l = self.len; + self.hist.copy_within(l.., 0); + self.hist[l..].copy_from_slice(x); + } + + /// Frame analysis: VAD, signal type, pitch, LPC/NLSF quantisation and LTP taps. + fn analyze(&self, x: &[f32]) -> Params { + let l = self.len; + let sub = self.sub; + let order = self.order; + let wb = order == 16; + let mut buf = self.hist.clone(); + buf.extend_from_slice(x); + let start = 2 * l; + let rms = (x.iter().map(|v| v * v).sum::() / l as f32).sqrt(); + let vad = rms > VAD_RMS; + let pitch = if vad { Some(analysis::pitch_search(&buf, start, l, self.fs_khz)) } else { None }; + let mut voiced = pitch.as_ref().is_some_and(|p| p.score > VOICING_THRESHOLD); + + let a = analysis::lpc(&buf[start - l / 4..], order, self.fs_khz); + let target: Vec = match analysis::a2nlsf(&a) { + Some(w) => w.iter().map(|v| v / std::f64::consts::PI * 32768.0).collect(), + None => (1..=order).map(|k| k as f64 * 32768.0 / (order + 1) as f64).collect(), + }; + let quant = |signal_type: usize| { + let (ix, nlsf) = nlsf::quantize(&target, signal_type, wb); + (ix, nlsf2a(&nlsf[..order])) + }; + let mut p = Params { vad, ..Default::default() }; + let (mut ix, mut a_q12) = quant(if voiced { 2 } else { 1 }); + // Open-loop residual with the quantised predictor. + let mut r = vec![0f64; buf.len()]; + for n in order..buf.len() { + let mut acc = buf[n] as f64; + for j in 0..order { + acc -= a_q12[j] as f64 / 4096.0 * buf[n - 1 - j] as f64; + } + r[n] = acc; + } + if voiced && let Some(pitch) = &pitch { + let ltp = analysis::ltp_search(&r, start, sub, &pitch.lags); + let gain = ltp.energy.iter().sum::() / ltp.err.iter().sum::().max(1e-9); + if gain >= MIN_LTP_GAIN { + p.lag_index = pitch.lag_index; + p.contour = pitch.contour; + p.pitch_l = pitch.lags; + p.per_index = ltp.per_index; + p.ltp_index = ltp.ltp_index; + } else { + voiced = false; + (ix, a_q12) = quant(1); + } + } + // Gains follow the short-term residual level (the long-term predictor then only + // reduces the number of pulses), which keeps the noise-to-signal ratio steady. + for k in 0..4 { + let e: f64 = r[start + k * sub..start + (k + 1) * sub].iter().map(|v| v * v).sum(); + p.res_rms[k] = (e / sub as f64).sqrt() as f32; + } + p.signal_type = if !vad { + 0 + } else if voiced { + 2 + } else { + 1 + }; + p.quant_offset_type = 0; + p.nlsf_ix = ix; + p.a_q12 = a_q12; + p + } + + /// Chooses gains and pulses for target `x` by running the decoder's reconstruction, and + /// advances the mirrored decoder state. `offset` scales the gains (log2), `floor` gives + /// minimum gains (Q16) per subframe. Returns the coded values and the decoded frame. + fn quantize(&mut self, p: &Params, x: &[f32], offset: f64, floor: Option<&[i32; 4]>, seed: usize) -> (Coded, Vec) { + let l = self.len; + let sub = self.sub; + let order = self.order; + let st = &mut self.dec; + let nf = &mut self.nf; + let off = if p.signal_type == 0 { offset + INACTIVE_OFFSET } else { offset }; + let gamma = nf_gamma(off); + let mut nf_c = [0f64; MAX_LPC_ORDER]; + let mut gpow = 1.0; + for j in 0..order { + gpow *= gamma; + nf_c[j] = p.a_q12[j] as f64 / 4096.0 * gpow; + } + // Gains (independent coding of the first subframe, deltas for the rest). + let mut gain_ix = [0i32; 4]; + let mut gains_q16 = [0i32; 4]; + let mut prev = st.last_gain_index; + for k in 0..4 { + let mut g = p.res_rms[k] as f64 * off.exp2(); + if let Some(f) = floor { + g = g.max(f[k] as f64 / 65536.0); + } + let want = (((g * 65536.0).max(1.0).log2() * 128.0 - 2090.0) / (1_907_825.0 / 65536.0)).round().clamp(0.0, 63.0) as i32; + if k == 0 { + gain_ix[0] = want; + prev = want.max(prev - 16).clamp(0, 63); + } else { + let mut best = (i32::MAX, 0i32, prev); + for ind in 0..41 { + let tmp = ind - 4; + let thr = 2 * 36 - 64 + prev; + let d = if tmp > thr { prev + (tmp << 1) - thr } else { prev + tmp }.clamp(0, 63); + let e = (d - want).abs(); + if e < best.0 { + best = (e, ind, d); + } + } + gain_ix[k] = best.1; + prev = best.2; + } + gains_q16[k] = gain_q16(prev); + } + st.last_gain_index = prev; + + // Analysis-by-synthesis excitation quantisation through the decoder's exact synthesis. + let voiced = p.signal_type == 2; + let offset_q10 = [[100, 240], [32, 100]][p.signal_type >> 1][p.quant_offset_type]; + let off16 = offset_q10 << 4; + let mut seed_v = seed as i32; + let mut pulses = vec![0i32; l]; + let mut xq = vec![0i16; l]; + let mut s_lpc = [0i32; MAX_FRAME / 4 + MAX_LPC_ORDER]; + s_lpc[..MAX_LPC_ORDER].copy_from_slice(&st.s_lpc_q14); + let mut s_ltp = [0i16; MAX_FRAME]; + let mut s_ltp_q15 = [0i32; 2 * MAX_FRAME]; + let ltp_mem = l; + let mut ltp_buf_idx = ltp_mem; + let a_q12 = &p.a_q12; + let ltp_scale_q14 = 15565; + for k in 0..4 { + let mut b_q14 = [0i32; LTP_ORDER]; + if voiced { + let ix = p.ltp_index[k]; + let taps = match p.per_index { + 0 => LTP_TAPS_0[ix], + 1 => LTP_TAPS_1[ix], + _ => LTP_TAPS_2[ix], + }; + for i in 0..LTP_ORDER { + b_q14[i] = taps[i] << 7; + } + } + let gain_q10 = gains_q16[k] >> 6; + let mut inv_gain_q31 = inverse32_varq(gains_q16[k], 47); + let gain_adj_q16 = if gains_q16[k] != st.prev_gain_q16 { + let g = div32_varq(st.prev_gain_q16, gains_q16[k], 16); + for v in s_lpc[..MAX_LPC_ORDER].iter_mut() { + *v = smulww(g, *v); + } + g + } else { + 1 << 16 + }; + st.prev_gain_q16 = gains_q16[k]; + let lag = p.pitch_l[k]; + if voiced { + let lag_u = lag as usize; + if k == 0 { + let start_idx = (ltp_mem as isize - lag as isize - order as isize - (LTP_ORDER / 2) as isize).max(0) as usize; + lpc_analysis_filter(&mut s_ltp[start_idx..ltp_mem], &st.out_buf[start_idx..ltp_mem], a_q12, order); + inv_gain_q31 = smulwb(inv_gain_q31, ltp_scale_q14) << 2; + for i in 0..(lag_u + LTP_ORDER / 2).min(ltp_mem) { + s_ltp_q15[ltp_buf_idx - i - 1] = smulwb(inv_gain_q31, s_ltp[ltp_mem - i - 1] as i32); + } + } else if gain_adj_q16 != 1 << 16 { + for i in 0..(lag_u + LTP_ORDER / 2).min(ltp_buf_idx) { + let q = ltp_buf_idx - i - 1; + s_ltp_q15[q] = smulww(gain_adj_q16, s_ltp_q15[q]); + } + } + } + let inv_g = 16_777_216.0 / gain_q10.max(1) as f64; + for i in 0..sub { + let n = k * sub + i; + seed_v = seed_v.wrapping_mul(196_314_165).wrapping_add(907_633_515); + let ltp_q14 = if voiced { + let base = ltp_buf_idx as isize - lag as isize + (LTP_ORDER / 2) as isize; + let g = |o: isize| -> i32 { if base - o >= 0 { s_ltp_q15[(base - o) as usize] } else { 0 } }; + let mut pred = 2i32; + for o in 0..LTP_ORDER { + pred = smlawb(pred, g(o as isize), b_q14[o]); + } + pred << 1 + } else { + 0 + }; + let mut lpc_pred = (order >> 1) as i32; + for j in 0..order { + lpc_pred = smlawb(lpc_pred, s_lpc[MAX_LPC_ORDER + i - 1 - j], a_q12[j]); + } + let lpc_q14 = lpc_pred.saturating_mul(16); + // Target excitation (Q14) for this sample. + let fb: f64 = (0..order).map(|j| nf_c[j] * nf[j]).sum(); + let tgt = (x[n] as f64 + fb).clamp(-32768.0, 32767.0) * inv_g - lpc_q14 as f64 - ltp_q14 as f64; + let sgn = if seed_v < 0 { -1.0 } else { 1.0 }; + let u = sgn * tgt; + let pf = (u - off16 as f64) / 16384.0; + let p0 = if pf > 0.0 { (pf + 0.078_125).round() } else { (pf - 0.078_125).round() } as i32; + let level = |q: i32| -> i32 { + let mut e = q << 14; + if e > 0 { + e -= 80 << 4; + } else if e < 0 { + e += 80 << 4; + } + e + off16 + }; + let mut best = (f64::MAX, 0i32); + for q in [p0 - 1, p0, p0 + 1, 0] { + let q = q.clamp(-pulses::MAX_PULSE, pulses::MAX_PULSE); + let d = (level(q) as f64 - u) / 16384.0; + let cost = d * d + RD_LAMBDA * q.abs() as f64; + if cost < best.0 { + best = (cost, q); + } + } + let q = best.1; + pulses[n] = q; + let mut e = level(q); + if seed_v < 0 { + e = -e; + } + seed_v = seed_v.wrapping_add(q); + let res = e.wrapping_add(ltp_q14); + if voiced { + s_ltp_q15[ltp_buf_idx] = res << 1; + ltp_buf_idx += 1; + } + let v = res.saturating_add(lpc_q14); + s_lpc[MAX_LPC_ORDER + i] = v; + xq[n] = sat16(rshift_round(smulww(v, gain_q10), 8)) as i16; + nf.copy_within(0..MAX_LPC_ORDER - 1, 1); + nf[0] = xq[n] as f64 - x[n] as f64; + } + s_lpc.copy_within(sub..sub + MAX_LPC_ORDER, 0); + } + st.s_lpc_q14.copy_from_slice(&s_lpc[..MAX_LPC_ORDER]); + st.out_buf[..l].copy_from_slice(&xq); + (Coded { gain_ix, gains_q16, seed, pulses }, xq) + } + + /// Writes the frame's indices and pulses (`decode_indices` + `decode_pulses` order, coded + /// independently). + fn write(&self, enc: &mut RangeEncoder, p: &Params, c: &Coded) { + let ix = p.signal_type * 2 + p.quant_offset_type; + if p.vad { + enc.icdf(ix - 2, &TYPE_ACTIVE, 8); + } else { + enc.icdf(ix, &TYPE_INACTIVE, 8); + } + enc.icdf((c.gain_ix[0] >> 3) as usize, GAIN_MSB[p.signal_type], 8); + enc.icdf((c.gain_ix[0] & 7) as usize, &UNIFORM8, 8); + for k in 1..4 { + enc.icdf(c.gain_ix[k] as usize, &DELTA_GAIN, 8); + } + let wb = self.order == 16; + let i1 = p.nlsf_ix[0] as usize; + enc.icdf(i1, NLSF_STAGE1[(p.signal_type >> 1) + if wb { 2 } else { 0 }], 8); + for i in 0..self.order { + let cb = if wb { 8 + NLSF_SEL_WB[i1][i] as usize } else { NLSF_SEL_NBMB[i1][i] as usize }; + let v = p.nlsf_ix[i + 1] + 4; + if v <= 0 { + enc.icdf(0, NLSF_STAGE2[cb], 8); + enc.icdf((-v) as usize, &NLSF_EXT, 8); + } else if v >= 8 { + enc.icdf(8, NLSF_STAGE2[cb], 8); + enc.icdf((v - 8) as usize, &NLSF_EXT, 8); + } else { + enc.icdf(v as usize, NLSF_STAGE2[cb], 8); + } + } + // No NLSF interpolation. + enc.icdf(4, &NLSF_INTERP, 8); + if p.signal_type == 2 { + let half = (self.fs_khz >> 1) as i32; + enc.icdf((p.lag_index / half) as usize, &PITCH_HIGH, 8); + let low = match self.fs_khz { + 8 => PITCH_LOW[0], + 12 => PITCH_LOW[1], + _ => PITCH_LOW[2], + }; + enc.icdf((p.lag_index % half) as usize, low, 8); + enc.icdf(p.contour, if self.fs_khz == 8 { PITCH_CONTOUR[1] } else { PITCH_CONTOUR[3] }, 8); + enc.icdf(p.per_index, <P_PERIODICITY, 8); + for k in 0..4 { + enc.icdf(p.ltp_index[k], LTP_FILTER[p.per_index], 8); + } + // LTP scaling index 0 (15565 in Q14), matching `quantize`. + enc.icdf(0, <P_SCALE, 8); + } + enc.icdf(c.seed, &UNIFORM4, 8); + pulses::encode(enc, &c.pulses, p.signal_type, p.quant_offset_type); + } +} + +/// Quantises a stereo prediction weight (Q13) to the decoder's grid (§4.2.7.1): returns the +/// (UNIFORM3, UNIFORM5, joint) indices and the reconstructed value. +fn quant_pred(w: f64) -> ([usize; 3], i32) { + let mut best = (f64::MAX, [0usize; 3], 0i32); + for i in 0..15 { + let low = STEREO_WEIGHTS_Q13[i]; + let step = smulwb(STEREO_WEIGHTS_Q13[i + 1] - low, 6554); + for j in 0..5 { + let v = low + step * (2 * j as i32 + 1); + let e = (v as f64 - w).abs(); + if e < best.0 { + best = (e, [i % 3, j, i / 3], v); + } + } + } + (best.1, best.2) +} + +/// Per-frame preparation shared by every rate-loop trial. +struct Prep { + x: [Vec; 2], + params: [Params; 2], + mid_only: bool, + pred_ix: [[usize; 3]; 2], + pred_q13: [i32; 2], +} + +/// SILK encoder for one mono or stereo (mid/side) stream. +#[derive(Clone)] +pub struct SilkEncoder { + fs_khz: usize, + channels: usize, + ch: [Channel; 2], + pred_prev_q13: [i32; 2], + prev_mid_only: bool, + /// Last mid sample of the previous frame (for the stereo predictor's low-pass). + prev_mid: f32, + frame: usize, + /// Gain offset chosen for the previous frame (search starting point). + pub last_offset: f64, +} + +impl SilkEncoder { + pub fn new(fs_khz: usize, channels: usize) -> Self { + SilkEncoder { + fs_khz, + channels, + ch: [Channel::new(fs_khz), Channel::new(fs_khz)], + pred_prev_q13: [0; 2], + prev_mid_only: false, + prev_mid: 0.0, + frame: 0, + last_offset: 1.0, + } + } + + /// Samples per channel of one 20 ms frame at the internal rate. + pub fn frame_len(&self) -> usize { + 20 * self.fs_khz + } + + fn prepare(&self, input: &[Vec]) -> Prep { + let l = self.frame_len(); + if self.channels == 1 { + let x = input[0].clone(); + let p = self.ch[0].analyze(&x); + return Prep { x: [x, vec![0.0; l]], params: [p, Params::default()], mid_only: true, pred_ix: [[0; 3]; 2], pred_q13: [0; 2] }; + } + let mid: Vec = (0..l).map(|i| 0.5 * (input[0][i] + input[1][i])).collect(); + let side: Vec = (0..l).map(|i| 0.5 * (input[0][i] - input[1][i])).collect(); + let lp = |j: usize| -> f64 { + let a = if j == 0 { self.prev_mid } else { mid[j - 1] }; + let c = if j + 1 < l { mid[j + 1] } else { mid[l - 1] }; + (a as f64 + 2.0 * mid[j] as f64 + c as f64) / 4.0 + }; + // Least-squares prediction of the side from the low-passed mid and the mid. + let (mut r00, mut r01, mut r11, mut c0, mut c1, mut ss, mut mm) = (0f64, 0f64, 0f64, 0f64, 0f64, 0f64, 0f64); + for j in 0..l { + let (a, b, s) = (lp(j), mid[j] as f64, side[j] as f64); + r00 += a * a; + r01 += a * b; + r11 += b * b; + c0 += a * s; + c1 += b * s; + ss += s * s; + mm += b * b; + } + // Ridge regularisation: the low-passed mid and the mid are nearly collinear. + let lam = 0.02 * 0.5 * (r00 + r11) + 1e-9; + let (a00, a11) = (r00 + lam, r11 + lam); + let det = a00 * a11 - r01 * r01; + let (p0, p1) = ((c0 * a11 - c1 * r01) / det, (c1 * a00 - c0 * r01) / det); + let lim = 13732.0; + let quant = |p0: f64, p1: f64| { + let (ix0, q0) = quant_pred(((p0 + p1) * 8192.0).clamp(-lim, lim)); + let (ix1, q1) = quant_pred((p1 * 8192.0).clamp(-lim, lim)); + ([ix0, ix1], [q0 - q1, q1]) + }; + // Side residual with the decoder's interpolated predictor. + let fs = self.fs_khz as i32; + let denom_q16 = (1 << 16) / (8 * fs); + let interp = 8 * self.fs_khz; + let residual = |pred_q13: [i32; 2]| -> Vec { + let d0 = rshift_round((pred_q13[0] - self.pred_prev_q13[0]) as i16 as i32 * denom_q16 as i16 as i32, 16); + let d1 = rshift_round((pred_q13[1] - self.pred_prev_q13[1]) as i16 as i32 * denom_q16 as i16 as i32, 16); + let (mut pp0, mut pp1) = (self.pred_prev_q13[0] + d0, self.pred_prev_q13[1] + d1); + (0..l) + .map(|j| { + if j + 1 < interp { + pp0 += d0; + pp1 += d1; + } else { + pp0 = pred_q13[0]; + pp1 = pred_q13[1]; + } + (side[j] as f64 - (pp0 as f64 * lp(j) + pp1 as f64 * mid[j] as f64) / 8192.0) as f32 + }) + .collect() + }; + let energy = |v: &[f32]| -> f64 { v.iter().map(|&x| (x as f64).powi(2)).sum() }; + let (mut pred_ix, mut pred_q13) = quant(p0, p1); + let mut res = residual(pred_q13); + let mut res_e = energy(&res); + // Never predict worse than not predicting at all. + if res_e > ss { + (pred_ix, pred_q13) = quant(0.0, 0.0); + res = residual(pred_q13); + res_e = energy(&res); + } + let res_rms = (res_e / l as f64).sqrt(); + let mid_rms = (mm / l as f64).sqrt(); + let mid_only = res_rms < 0.01 * mid_rms + 1.0; + let pm = self.ch[0].analyze(&mid); + let ps = if mid_only { Params::default() } else { self.ch[1].analyze(&res) }; + Prep { x: [mid, res], params: [pm, ps], mid_only, pred_ix, pred_q13 } + } + + /// Codes one frame into `enc` with gain offset `offset`. + fn code(&mut self, prep: &Prep, offset: f64, enc: &mut RangeEncoder) { + let stereo = self.channels == 2; + let side_vad = stereo && !prep.mid_only && prep.params[1].vad; + enc.bit_logp(prep.params[0].vad, 1); + enc.bit_logp(false, 1); + if stereo { + enc.bit_logp(side_vad, 1); + enc.bit_logp(false, 1); + let [a, b] = prep.pred_ix; + enc.icdf(5 * a[2] + b[2], &STEREO_PRED_JOINT, 8); + enc.icdf(a[0], &UNIFORM3, 8); + enc.icdf(a[1], &UNIFORM5, 8); + enc.icdf(b[0], &UNIFORM3, 8); + enc.icdf(b[1], &UNIFORM5, 8); + if !side_vad { + enc.icdf(prep.mid_only as usize, &MID_ONLY, 8); + } + } + let seed = self.frame & 3; + let (cm, _) = self.ch[0].quantize(&prep.params[0], &prep.x[0], offset, None, seed); + self.ch[0].write(enc, &prep.params[0], &cm); + if stereo { + if !prep.mid_only { + if self.prev_mid_only { + self.ch[1].dec.reset_side(); + } + let (cs, _) = self.ch[1].quantize(&prep.params[1], &prep.x[1], offset, Some(&cm.gains_q16), seed); + self.ch[1].write(enc, &prep.params[1], &cs); + } + self.pred_prev_q13 = prep.pred_q13; + self.prev_mid_only = prep.mid_only; + } + } + + /// Encodes one 20 ms frame (`input`: one internal-rate, 16-bit-scale vector per channel) + /// into `enc`, aiming at `target_bits` total and never exceeding `max_bits`. + pub fn encode(&mut self, input: &[Vec], target_bits: f64, max_bits: i32, enc: &mut RangeEncoder) { + let prep = self.prepare(input); + let start_bits = enc.tell(); + let trial = |s: &Self, o: f64| -> i32 { + let mut t = s.clone(); + let mut e = enc.clone(); + t.code(&prep, o, &mut e); + e.tell() - start_bits + }; + // Bisection on the gain offset: the smallest offset (finest quantisation) within budget. + let target = target_bits.min(max_bits as f64); + // Bits are not strictly monotonic in the offset (very coarse quantisation feeds large + // errors back), so walk an integer grid from the previous frame's offset to find a + // bracket [infeasible, feasible], then bisect inside it. + const MIN_O: f64 = -3.0; + const MAX_O: f64 = 6.0; + let mut best_any = (i32::MAX, MAX_O); + let mut probe = |o: f64| -> bool { + let b = trial(self, o); + if b < best_any.0 { + best_any = (b, o); + } + b as f64 <= target + }; + let g0 = self.last_offset.round().clamp(MIN_O, MAX_O); + let mut bracket = None; + if probe(g0) { + let mut hi = g0; + loop { + if hi - 1.0 < MIN_O { + bracket = Some((None, hi)); + break; + } + if probe(hi - 1.0) { + hi -= 1.0; + } else { + bracket = Some((Some(hi - 1.0), hi)); + break; + } + } + } else { + let mut lo = g0; + while lo + 1.0 <= MAX_O { + if probe(lo + 1.0) { + bracket = Some((Some(lo), lo + 1.0)); + break; + } + lo += 1.0; + } + } + let mut o = match bracket { + Some((Some(mut lo), mut hi)) => { + for _ in 0..4 { + let m = 0.5 * (lo + hi); + if probe(m) { + hi = m; + } else { + lo = m; + } + } + hi + } + Some((None, hi)) => hi, + None => best_any.1, + }; + // Hard limit (hybrid packets must leave room for CELT). + while trial(self, o) > max_bits && o < 12.0 { + o += 1.0; + } + self.last_offset = o; + self.code(&prep, o, enc); + for ch in 0..self.channels { + self.ch[ch].push_history(&prep.x[ch]); + } + if self.channels == 2 { + self.prev_mid = prep.x[0][self.frame_len() - 1]; + } + self.frame += 1; + } +} diff --git a/crates/audio/src/opus/encoder/silk/nlsf.rs b/crates/audio/src/opus/encoder/silk/nlsf.rs new file mode 100644 index 000000000..59fca68cb --- /dev/null +++ b/crates/audio/src/opus/encoder/silk/nlsf.rs @@ -0,0 +1,131 @@ +//! NLSF quantisation (encoder side of RFC 6716 §4.2.7.5): stage-1 vector codebook preselection +//! and a backwards-predictive stage-2 scalar quantiser that reproduces the decoder's residual +//! dequantisation exactly. The search strategy is our own. + +use super::lpc::{MAX_LPC_ORDER, nlsf_decode, smlawb, sqrt_approx}; +use super::tables::{ + NLSF_CB_NBMB, NLSF_CB_WB, NLSF_EXT, NLSF_PRED_NBMB, NLSF_PRED_WB, NLSF_PSEL_NBMB, NLSF_PSEL_WB, NLSF_SEL_NBMB, NLSF_SEL_WB, NLSF_STAGE1, + NLSF_STAGE2, +}; + +/// Number of stage-1 candidates refined with stage 2. +const SURVIVORS: usize = 8; +/// Rate weight (weighted squared Q15 error per bit). +const LAMBDA: f64 = 60.0; + +fn icdf_bits(icdf: &[u8], s: usize) -> f64 { + let hi = if s == 0 { 256 } else { icdf[s - 1] as u32 }; + let p = (hi - icdf[s] as u32).max(1) as f64 / 256.0; + -p.log2() +} + +/// Bits of a stage-2 index `q` (-10..=10) with stage-2 table `tab`. +fn stage2_bits(tab: &[u8], q: i32) -> f64 { + let v = q + 4; + if v <= 0 { + icdf_bits(tab, 0) + icdf_bits(&NLSF_EXT, (-v) as usize) + } else if v >= 8 { + icdf_bits(tab, 8) + icdf_bits(&NLSF_EXT, (v - 8) as usize) + } else { + icdf_bits(tab, v as usize) + } +} + +/// Quantises target NLSFs (`target`, Q15 as f64, ascending) and returns the 1 + order indices +/// in the decoder's layout (`[stage1, stage2...]`) together with the reconstructed NLSFs. +pub fn quantize(target: &[f64], signal_type: usize, wb: bool) -> ([i32; 17], [i32; MAX_LPC_ORDER]) { + let order = target.len(); + // Laroia-style weights. + let w: Vec = (0..order) + .map(|k| { + let prev = if k == 0 { 0.0 } else { target[k - 1] }; + let next = if k + 1 == order { 32768.0 } else { target[k + 1] }; + 1.0 / (target[k] - prev).max(50.0) + 1.0 / (next - target[k]).max(50.0) + }) + .collect(); + let s1_tab = NLSF_STAGE1[(signal_type >> 1) + if wb { 2 } else { 0 }]; + let n_cb = 32; + let mut cands: Vec<(f64, usize)> = (0..n_cb) + .map(|i1| { + let cb: &[i32] = if wb { &NLSF_CB_WB[i1] } else { &NLSF_CB_NBMB[i1] }; + let d: f64 = (0..order).map(|k| w[k] * ((cb[k] << 7) as f64 - target[k]).powi(2)).sum(); + (d + LAMBDA * icdf_bits(s1_tab, i1), i1) + }) + .collect(); + cands.sort_by(|a, b| a.0.total_cmp(&b.0)); + let qstep = if wb { 9830 } else { 11796 }; + let mut best = (f64::MAX, [0i32; 17], [0i32; MAX_LPC_ORDER]); + for &(_, i1) in cands.iter().take(SURVIVORS) { + let cb1: &[i32] = if wb { &NLSF_CB_WB[i1] } else { &NLSF_CB_NBMB[i1] }; + let mut ix = [0i32; 17]; + ix[0] = i1 as i32; + let mut bits = icdf_bits(s1_tab, i1); + // Exact decoder weights per coefficient. + let w_q9: Vec = (0..order) + .map(|k| { + let prev = if k == 0 { 0 } else { cb1[k - 1] }; + let next = if k + 1 == order { 256 } else { cb1[k + 1] }; + let ww = (1024 / (cb1[k] - prev).max(1) + 1024 / (next - cb1[k]).max(1)).min(32767); + sqrt_approx(ww << 16).max(1) + }) + .collect(); + let mut out_q10 = 0i32; + for i in (0..order).rev() { + let pred = if i + 1 < order { + let sel = if wb { NLSF_PSEL_WB[i1][i] } else { NLSF_PSEL_NBMB[i1][i] } as usize; + let wp = if wb { NLSF_PRED_WB[sel][i] } else { NLSF_PRED_NBMB[sel][i] }; + (out_q10 * wp) >> 8 + } else { + 0 + }; + let cb = if wb { 8 + NLSF_SEL_WB[i1][i] as usize } else { NLSF_SEL_NBMB[i1][i] as usize }; + let tab = NLSF_STAGE2[cb]; + let want_q10 = (target[i] - (cb1[i] << 7) as f64) * w_q9[i] as f64 / 16384.0; + let step = qstep as f64 * 1024.0 / 65536.0; + let q0 = ((want_q10 - pred as f64) / step).round() as i32; + let mut bq = (f64::MAX, 0i32, 0i32); + for q in (q0 - 1).max(-10)..=(q0 + 1).min(10) { + let mut v = q << 10; + if v > 0 { + v -= 102; + } else if v < 0 { + v += 102; + } + let o = smlawb(pred, v, qstep); + let nl = (cb1[i] << 7) as f64 + ((o << 14) / w_q9[i]) as f64; + let cost = w[i] * (nl - target[i]).powi(2) + LAMBDA * stage2_bits(tab, q); + if cost < bq.0 { + bq = (cost, q, o); + } + } + ix[i + 1] = bq.1; + out_q10 = bq.2; + bits += stage2_bits(tab, bq.1); + } + let nlsf = nlsf_decode(&ix, wb); + let d: f64 = (0..order).map(|k| w[k] * (nlsf[k] as f64 - target[k]).powi(2)).sum(); + let cost = d + LAMBDA * bits; + if cost < best.0 { + best = (cost, ix, nlsf); + } + } + (best.1, best.2) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quantised_nlsfs_track_the_target() { + for wb in [false, true] { + let order = if wb { 16 } else { 10 }; + // A smooth, plausible NLSF vector. + let target: Vec = (0..order).map(|k| 32768.0 * (k as f64 + 0.8 + 0.3 * (k as f64).sin()) / (order as f64 + 1.0)).collect(); + let (ix, nlsf) = quantize(&target, 1, wb); + assert!(ix[1..=order].iter().all(|v| (-10..=10).contains(v))); + let max_err = (0..order).map(|k| (nlsf[k] as f64 - target[k]).abs()).fold(0.0, f64::max); + assert!(max_err < 1200.0, "wb={wb} max err {max_err}: {:?} vs {target:?}", &nlsf[..order]); + } + } +} diff --git a/crates/audio/src/opus/encoder/silk/pulses.rs b/crates/audio/src/opus/encoder/silk/pulses.rs new file mode 100644 index 000000000..7ec4fa6bf --- /dev/null +++ b/crates/audio/src/opus/encoder/silk/pulses.rs @@ -0,0 +1,113 @@ +//! Excitation coding (encoder side of RFC 6716 §4.2.7.8): rate level, per-block pulse counts +//! with LSB escapes, the recursive shell code, LSBs and signs, written in exactly the order the +//! decoder reads them. + +use super::tables::{EXC_LSB, EXC_SIGN, PULSE_COUNT, RATE_LEVEL, SHELL_2, SHELL_4, SHELL_8, SHELL_16}; +use crate::opus::encoder::range::RangeEncoder; + +/// Largest pulse magnitude the quantiser produces (keeps every 16-sample block codable). +pub const MAX_PULSE: i32 = 500; + +pub fn icdf_bits(icdf: &[u8], s: usize) -> f32 { + let hi = if s == 0 { 256 } else { icdf[s - 1] as u32 }; + let p = (hi - icdf[s] as u32).max(1) as f32 / 256.0; + -p.log2() +} + +fn count_table(nls: usize) -> &'static [u8] { + if nls == 10 { PULSE_COUNT[10] } else { PULSE_COUNT[9] } +} + +fn shell(enc: &mut RangeEncoder, v: &[i32], count: i32) { + let n = v.len(); + if n == 1 || count == 0 { + return; + } + let tab = match n { + 16 => SHELL_16[(count - 1) as usize], + 8 => SHELL_8[(count - 1) as usize], + 4 => SHELL_4[(count - 1) as usize], + _ => SHELL_2[(count - 1) as usize], + }; + let (a, b) = v.split_at(n / 2); + let left: i32 = a.iter().sum(); + enc.icdf(left as usize, tab, 8); + shell(enc, a, left); + shell(enc, b, count - left); +} + +/// Codes the frame's pulses (`len` a multiple of 16). +pub fn encode(enc: &mut RangeEncoder, pulses: &[i32], signal_type: usize, quant_offset_type: usize) { + let nblk = pulses.len() / 16; + let mut nls = vec![0usize; nblk]; + let mut sums = vec![0i32; nblk]; + let mut hi = vec![0i32; pulses.len()]; + for b in 0..nblk { + let blk = &pulses[b * 16..(b + 1) * 16]; + let mut s = 0usize; + loop { + let sum: i32 = blk.iter().map(|p| p.abs() >> s).sum(); + if sum <= 16 || s == 10 { + sums[b] = sum.min(16); + break; + } + s += 1; + } + nls[b] = s; + for (h, p) in hi[b * 16..(b + 1) * 16].iter_mut().zip(blk) { + *h = p.abs() >> s; + } + } + // Rate level with the cheapest pulse-count symbols. + let rl_tab = RATE_LEVEL[signal_type >> 1]; + let mut best = (f32::MAX, 0usize); + for rl in 0..9 { + let mut bits = icdf_bits(rl_tab, rl); + for b in 0..nblk { + bits += if nls[b] == 0 { icdf_bits(PULSE_COUNT[rl], sums[b] as usize) } else { icdf_bits(PULSE_COUNT[rl], 17) }; + } + if bits < best.0 { + best = (bits, rl); + } + } + let rl = best.1; + enc.icdf(rl, rl_tab, 8); + for b in 0..nblk { + if nls[b] == 0 { + enc.icdf(sums[b] as usize, PULSE_COUNT[rl], 8); + } else { + enc.icdf(17, PULSE_COUNT[rl], 8); + for j in 1..nls[b] { + enc.icdf(17, count_table(j), 8); + } + enc.icdf(sums[b] as usize, count_table(nls[b]), 8); + } + } + for b in 0..nblk { + if sums[b] > 0 { + shell(enc, &hi[b * 16..(b + 1) * 16], sums[b]); + } + } + for b in 0..nblk { + if nls[b] > 0 { + for p in &pulses[b * 16..(b + 1) * 16] { + let a = p.abs(); + for j in (0..nls[b]).rev() { + enc.icdf(((a >> j) & 1) as usize, &EXC_LSB, 8); + } + } + } + } + let base = 7 * (quant_offset_type + (signal_type << 1)); + for b in 0..nblk { + let p = sums[b] | ((nls[b] as i32) << 5); + if p > 0 { + let tab = EXC_SIGN[base + ((p & 0x1F) as usize).min(6)]; + for &q in &pulses[b * 16..(b + 1) * 16] { + if q != 0 { + enc.icdf((q > 0) as usize, tab, 8); + } + } + } + } +} diff --git a/crates/audio/src/opus/encoder/silk/tables.rs b/crates/audio/src/opus/encoder/silk/tables.rs new file mode 100644 index 000000000..a4789a414 --- /dev/null +++ b/crates/audio/src/opus/encoder/silk/tables.rs @@ -0,0 +1,543 @@ +//! SILK probability tables and codebooks, transcribed from RFC 6716 §4.2 (Tables 4–53). +//! +//! PDFs are written as in the RFC (`{...}/256`) and converted at compile time to the inverse-CDF +//! form consumed by the range coder. +//! +//! Copied from FilmCraft's first-party, spec-derived `filmcraft-opus` decoder (MIT OR +//! Apache-2.0) so encoder and decoder share exactly the same values. + +/// Converts a PDF (summing to 256) into an inverse CDF (`256 - cdf[i+1]`). +pub const fn icdf(pdf: &[u8; N]) -> [u8; N] { + let mut out = [0u8; N]; + let mut acc: u32 = 0; + let mut i = 0; + while i < N { + acc += pdf[i] as u32; + out[i] = (256 - acc) as u8; + i += 1; + } + out +} + +pub const STEREO_PRED_JOINT: [u8; 25] = icdf(&[7, 2, 1, 1, 1, 10, 24, 8, 1, 1, 3, 23, 92, 23, 3, 1, 1, 8, 24, 10, 1, 1, 1, 2, 7]); +pub const UNIFORM3: [u8; 3] = icdf(&[85, 86, 85]); +pub const UNIFORM5: [u8; 5] = icdf(&[51, 51, 52, 51, 51]); +pub const STEREO_WEIGHTS_Q13: [i32; 16] = [-13732, -10050, -8266, -7526, -6500, -5000, -2950, -820, 820, 2950, 5000, 6500, 7526, 8266, 10050, 13732]; +pub const MID_ONLY: [u8; 2] = icdf(&[192, 64]); +pub const TYPE_INACTIVE: [u8; 2] = icdf(&[26, 230]); +pub const TYPE_ACTIVE: [u8; 4] = icdf(&[24, 74, 148, 10]); +pub const GAIN_MSB: [&[u8]; 3] = + [&icdf::<8>(&[32, 112, 68, 29, 12, 1, 1, 1]), &icdf::<8>(&[2, 17, 45, 60, 62, 47, 19, 4]), &icdf::<8>(&[1, 3, 26, 71, 94, 50, 9, 2])]; +pub const UNIFORM8: [u8; 8] = icdf(&[32, 32, 32, 32, 32, 32, 32, 32]); +pub const DELTA_GAIN: [u8; 41] = + icdf(&[6, 5, 11, 31, 132, 21, 8, 4, 3, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]); +pub const NLSF_STAGE1: [&[u8]; 4] = [ + &icdf::<32>(&[44, 34, 30, 19, 21, 12, 11, 3, 3, 2, 16, 2, 2, 1, 5, 2, 1, 3, 3, 1, 1, 2, 2, 2, 3, 1, 9, 9, 2, 7, 2, 1]), + &icdf::<32>(&[1, 10, 1, 8, 3, 8, 8, 14, 13, 14, 1, 14, 12, 13, 11, 11, 12, 11, 10, 10, 11, 8, 9, 8, 7, 8, 1, 1, 6, 1, 6, 5]), + &icdf::<32>(&[31, 21, 3, 17, 1, 8, 17, 4, 1, 18, 16, 4, 2, 3, 1, 10, 1, 3, 16, 11, 16, 2, 2, 3, 2, 11, 1, 4, 9, 8, 7, 3]), + &icdf::<32>(&[1, 4, 16, 5, 18, 11, 5, 14, 15, 1, 3, 12, 13, 14, 14, 6, 14, 12, 2, 6, 1, 12, 12, 11, 10, 3, 10, 5, 1, 1, 1, 3]), +]; +pub const NLSF_STAGE2: [&[u8]; 16] = [ + &icdf::<9>(&[1, 1, 1, 15, 224, 11, 1, 1, 1]), + &icdf::<9>(&[1, 1, 2, 34, 183, 32, 1, 1, 1]), + &icdf::<9>(&[1, 1, 4, 42, 149, 55, 2, 1, 1]), + &icdf::<9>(&[1, 1, 8, 52, 123, 61, 8, 1, 1]), + &icdf::<9>(&[1, 3, 16, 53, 101, 74, 6, 1, 1]), + &icdf::<9>(&[1, 3, 17, 55, 90, 73, 15, 1, 1]), + &icdf::<9>(&[1, 7, 24, 53, 74, 67, 26, 3, 1]), + &icdf::<9>(&[1, 1, 18, 63, 78, 58, 30, 6, 1]), + &icdf::<9>(&[1, 1, 1, 9, 232, 9, 1, 1, 1]), + &icdf::<9>(&[1, 1, 2, 28, 186, 35, 1, 1, 1]), + &icdf::<9>(&[1, 1, 3, 42, 152, 53, 2, 1, 1]), + &icdf::<9>(&[1, 1, 10, 49, 126, 65, 2, 1, 1]), + &icdf::<9>(&[1, 4, 19, 48, 100, 77, 5, 1, 1]), + &icdf::<9>(&[1, 1, 14, 54, 100, 72, 12, 1, 1]), + &icdf::<9>(&[1, 1, 15, 61, 87, 61, 25, 4, 1]), + &icdf::<9>(&[1, 7, 21, 50, 77, 81, 17, 1, 1]), +]; +pub const NLSF_SEL_NBMB: [[u8; 10]; 32] = [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [1, 3, 1, 2, 2, 1, 2, 1, 1, 1], + [2, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [1, 2, 2, 2, 2, 1, 2, 1, 1, 1], + [2, 3, 3, 3, 3, 2, 2, 2, 2, 2], + [0, 5, 3, 3, 2, 2, 2, 2, 1, 1], + [0, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [2, 3, 6, 4, 4, 4, 5, 4, 5, 5], + [2, 4, 5, 5, 4, 5, 4, 6, 4, 4], + [2, 4, 4, 7, 4, 5, 4, 5, 5, 4], + [4, 3, 3, 3, 2, 3, 2, 2, 2, 2], + [1, 5, 5, 6, 4, 5, 4, 5, 5, 5], + [2, 7, 4, 6, 5, 5, 5, 5, 5, 5], + [2, 7, 5, 5, 5, 5, 5, 6, 5, 4], + [3, 3, 5, 4, 4, 5, 4, 5, 4, 4], + [2, 3, 3, 5, 5, 4, 4, 4, 4, 4], + [2, 4, 4, 6, 4, 5, 4, 5, 5, 5], + [2, 5, 4, 6, 5, 5, 5, 4, 5, 4], + [2, 7, 4, 5, 4, 5, 4, 5, 5, 5], + [2, 5, 4, 6, 7, 6, 5, 6, 5, 4], + [3, 6, 7, 4, 6, 5, 5, 6, 4, 5], + [2, 7, 6, 4, 4, 4, 5, 4, 5, 5], + [4, 5, 5, 4, 6, 6, 5, 6, 5, 4], + [2, 5, 5, 6, 5, 6, 4, 6, 4, 4], + [4, 5, 5, 5, 3, 7, 4, 5, 5, 4], + [2, 3, 4, 5, 5, 6, 4, 5, 5, 4], + [2, 3, 2, 3, 3, 4, 2, 3, 3, 3], + [1, 1, 2, 2, 2, 2, 2, 3, 2, 2], + [4, 5, 5, 6, 6, 6, 5, 6, 4, 5], + [3, 5, 5, 4, 4, 4, 4, 3, 3, 2], + [2, 5, 3, 7, 5, 5, 4, 4, 5, 4], + [4, 4, 5, 4, 5, 6, 5, 6, 5, 4], +]; +pub const NLSF_SEL_WB: [[u8; 16]; 32] = [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [2, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 1, 1, 1, 0, 3], + [2, 5, 5, 3, 7, 4, 4, 5, 2, 5, 4, 5, 5, 4, 3, 3], + [0, 2, 1, 2, 2, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1], + [0, 6, 5, 4, 6, 4, 7, 5, 4, 4, 4, 5, 5, 4, 4, 3], + [0, 3, 5, 5, 4, 3, 3, 5, 3, 3, 3, 3, 3, 3, 2, 4], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 2, 6, 3, 7, 2, 5, 3, 4, 5, 5, 4, 3, 3, 2, 3], + [0, 6, 2, 6, 6, 4, 5, 4, 6, 5, 4, 4, 5, 3, 3, 3], + [2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [2, 2, 3, 4, 5, 3, 3, 3, 3, 3, 3, 3, 2, 2, 1, 3], + [2, 2, 3, 3, 4, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1, 3], + [3, 4, 4, 4, 6, 4, 4, 5, 3, 5, 4, 4, 5, 4, 3, 4], + [0, 6, 4, 5, 4, 7, 5, 2, 6, 5, 7, 4, 4, 3, 5, 3], + [0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0], + [1, 6, 5, 7, 5, 4, 5, 3, 4, 5, 4, 4, 4, 3, 3, 4], + [1, 3, 3, 4, 4, 3, 3, 5, 2, 3, 3, 5, 5, 5, 3, 4], + [2, 3, 3, 2, 2, 2, 3, 2, 1, 2, 1, 2, 1, 1, 1, 4], + [0, 2, 3, 5, 3, 3, 2, 2, 2, 1, 1, 0, 0, 0, 0, 0], + [3, 4, 3, 5, 3, 3, 2, 2, 1, 1, 1, 1, 1, 2, 2, 4], + [2, 6, 3, 7, 7, 4, 5, 4, 5, 3, 5, 3, 3, 2, 3, 3], + [2, 3, 5, 6, 6, 3, 5, 3, 4, 4, 3, 3, 3, 3, 2, 4], + [1, 3, 3, 4, 4, 4, 4, 3, 5, 5, 5, 3, 1, 1, 1, 1], + [2, 5, 3, 6, 6, 4, 7, 4, 4, 5, 3, 4, 4, 3, 3, 3], + [0, 6, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 6, 6, 3, 5, 2, 5, 5, 3, 4, 4, 7, 7, 4, 4, 4], + [3, 3, 7, 3, 5, 4, 3, 3, 3, 2, 2, 3, 3, 3, 2, 3], + [0, 0, 1, 0, 0, 0, 2, 1, 2, 1, 1, 2, 2, 2, 1, 1], + [0, 3, 2, 5, 3, 3, 2, 3, 2, 1, 0, 0, 1, 0, 0, 1], + [3, 5, 5, 4, 7, 5, 3, 3, 2, 3, 2, 2, 1, 0, 1, 0], + [2, 3, 5, 3, 4, 3, 3, 3, 2, 1, 2, 6, 4, 0, 0, 0], +]; +pub const NLSF_EXT: [u8; 7] = icdf(&[156, 60, 24, 9, 4, 2, 1]); +pub const NLSF_PRED_NBMB: [[i32; 9]; 2] = [[179, 138, 140, 148, 151, 149, 153, 151, 163], [116, 67, 82, 59, 92, 72, 100, 89, 92]]; +pub const NLSF_PRED_WB: [[i32; 15]; 2] = [ + [175, 148, 160, 176, 178, 173, 174, 164, 177, 174, 196, 182, 198, 192, 182], + [68, 62, 66, 60, 72, 117, 85, 90, 118, 136, 151, 142, 160, 142, 155], +]; +pub const NLSF_PSEL_NBMB: [[u8; 9]; 32] = [ + [0, 1, 0, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0], + [1, 1, 1, 0, 0, 0, 0, 1, 0], + [0, 1, 0, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0, 0, 0], + [1, 0, 1, 1, 0, 0, 0, 1, 0], + [0, 1, 1, 0, 0, 1, 1, 0, 0], + [0, 0, 1, 1, 0, 1, 0, 1, 1], + [0, 0, 1, 1, 0, 0, 1, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 1, 1, 1, 1, 1, 0], + [0, 1, 0, 1, 1, 1, 1, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0], + [1, 0, 1, 1, 0, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 0, 1, 0], + [0, 0, 1, 1, 0, 1, 0, 1, 0], + [0, 0, 1, 1, 1, 0, 1, 1, 1], + [0, 1, 1, 0, 0, 1, 1, 1, 0], + [0, 0, 0, 1, 1, 1, 0, 1, 0], + [0, 1, 1, 0, 0, 1, 0, 1, 0], + [0, 1, 1, 0, 0, 0, 1, 1, 0], + [0, 0, 0, 0, 0, 1, 1, 1, 1], + [0, 0, 1, 1, 0, 0, 0, 1, 1], + [0, 0, 0, 1, 0, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 1, 1, 0, 1, 0], + [1, 0, 0, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 1, 0, 1, 0, 1], + [1, 0, 1, 1, 0, 1, 1, 1, 1], +]; +pub const NLSF_PSEL_WB: [[u8; 15]; 32] = [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0], + [0, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 1, 0], + [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0], + [0, 1, 1, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1, 0, 1], + [0, 1, 0, 1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0], + [0, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0], + [0, 0, 0, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 0, 0], + [0, 1, 0, 0, 0, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1], + [0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0], + [0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 0], + [0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 1, 0, 1, 1, 0, 1, 0, 1, 0, 1, 1, 0], + [0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0], + [0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0], + [0, 0, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1], + [0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0], + [0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0], +]; +pub const NLSF_CB_NBMB: [[i32; 10]; 32] = [ + [12, 35, 60, 83, 108, 132, 157, 180, 206, 228], + [15, 32, 55, 77, 101, 125, 151, 175, 201, 225], + [19, 42, 66, 89, 114, 137, 162, 184, 209, 230], + [12, 25, 50, 72, 97, 120, 147, 172, 200, 223], + [26, 44, 69, 90, 114, 135, 159, 180, 205, 225], + [13, 22, 53, 80, 106, 130, 156, 180, 205, 228], + [15, 25, 44, 64, 90, 115, 142, 168, 196, 222], + [19, 24, 62, 82, 100, 120, 145, 168, 190, 214], + [22, 31, 50, 79, 103, 120, 151, 170, 203, 227], + [21, 29, 45, 65, 106, 124, 150, 171, 196, 224], + [30, 49, 75, 97, 121, 142, 165, 186, 209, 229], + [19, 25, 52, 70, 93, 116, 143, 166, 192, 219], + [26, 34, 62, 75, 97, 118, 145, 167, 194, 217], + [25, 33, 56, 70, 91, 113, 143, 165, 196, 223], + [21, 34, 51, 72, 97, 117, 145, 171, 196, 222], + [20, 29, 50, 67, 90, 117, 144, 168, 197, 221], + [22, 31, 48, 66, 95, 117, 146, 168, 196, 222], + [24, 33, 51, 77, 116, 134, 158, 180, 200, 224], + [21, 28, 70, 87, 106, 124, 149, 170, 194, 217], + [26, 33, 53, 64, 83, 117, 152, 173, 204, 225], + [27, 34, 65, 95, 108, 129, 155, 174, 210, 225], + [20, 26, 72, 99, 113, 131, 154, 176, 200, 219], + [34, 43, 61, 78, 93, 114, 155, 177, 205, 229], + [23, 29, 54, 97, 124, 138, 163, 179, 209, 229], + [30, 38, 56, 89, 118, 129, 158, 178, 200, 231], + [21, 29, 49, 63, 85, 111, 142, 163, 193, 222], + [27, 48, 77, 103, 133, 158, 179, 196, 215, 232], + [29, 47, 74, 99, 124, 151, 176, 198, 220, 237], + [33, 42, 61, 76, 93, 121, 155, 174, 207, 225], + [29, 53, 87, 112, 136, 154, 170, 188, 208, 227], + [24, 30, 52, 84, 131, 150, 166, 186, 203, 229], + [37, 48, 64, 84, 104, 118, 156, 177, 201, 230], +]; +pub const NLSF_CB_WB: [[i32; 16]; 32] = [ + [7, 23, 38, 54, 69, 85, 100, 116, 131, 147, 162, 178, 193, 208, 223, 239], + [13, 25, 41, 55, 69, 83, 98, 112, 127, 142, 157, 171, 187, 203, 220, 236], + [15, 21, 34, 51, 61, 78, 92, 106, 126, 136, 152, 167, 185, 205, 225, 240], + [10, 21, 36, 50, 63, 79, 95, 110, 126, 141, 157, 173, 189, 205, 221, 237], + [17, 20, 37, 51, 59, 78, 89, 107, 123, 134, 150, 164, 184, 205, 224, 240], + [10, 15, 32, 51, 67, 81, 96, 112, 129, 142, 158, 173, 189, 204, 220, 236], + [8, 21, 37, 51, 65, 79, 98, 113, 126, 138, 155, 168, 179, 192, 209, 218], + [12, 15, 34, 55, 63, 78, 87, 108, 118, 131, 148, 167, 185, 203, 219, 236], + [16, 19, 32, 36, 56, 79, 91, 108, 118, 136, 154, 171, 186, 204, 220, 237], + [11, 28, 43, 58, 74, 89, 105, 120, 135, 150, 165, 180, 196, 211, 226, 241], + [6, 16, 33, 46, 60, 75, 92, 107, 123, 137, 156, 169, 185, 199, 214, 225], + [11, 19, 30, 44, 57, 74, 89, 105, 121, 135, 152, 169, 186, 202, 218, 234], + [12, 19, 29, 46, 57, 71, 88, 100, 120, 132, 148, 165, 182, 199, 216, 233], + [17, 23, 35, 46, 56, 77, 92, 106, 123, 134, 152, 167, 185, 204, 222, 237], + [14, 17, 45, 53, 63, 75, 89, 107, 115, 132, 151, 171, 188, 206, 221, 240], + [9, 16, 29, 40, 56, 71, 88, 103, 119, 137, 154, 171, 189, 205, 222, 237], + [16, 19, 36, 48, 57, 76, 87, 105, 118, 132, 150, 167, 185, 202, 218, 236], + [12, 17, 29, 54, 71, 81, 94, 104, 126, 136, 149, 164, 182, 201, 221, 237], + [15, 28, 47, 62, 79, 97, 115, 129, 142, 155, 168, 180, 194, 208, 223, 238], + [8, 14, 30, 45, 62, 78, 94, 111, 127, 143, 159, 175, 192, 207, 223, 239], + [17, 30, 49, 62, 79, 92, 107, 119, 132, 145, 160, 174, 190, 204, 220, 235], + [14, 19, 36, 45, 61, 76, 91, 108, 121, 138, 154, 172, 189, 205, 222, 238], + [12, 18, 31, 45, 60, 76, 91, 107, 123, 138, 154, 171, 187, 204, 221, 236], + [13, 17, 31, 43, 53, 70, 83, 103, 114, 131, 149, 167, 185, 203, 220, 237], + [17, 22, 35, 42, 58, 78, 93, 110, 125, 139, 155, 170, 188, 206, 224, 240], + [8, 15, 34, 50, 67, 83, 99, 115, 131, 146, 162, 178, 193, 209, 224, 239], + [13, 16, 41, 66, 73, 86, 95, 111, 128, 137, 150, 163, 183, 206, 225, 241], + [17, 25, 37, 52, 63, 75, 92, 102, 119, 132, 144, 160, 175, 191, 212, 231], + [19, 31, 49, 65, 83, 100, 117, 133, 147, 161, 174, 187, 200, 213, 227, 242], + [18, 31, 52, 68, 88, 103, 117, 126, 138, 149, 163, 177, 192, 207, 223, 239], + [16, 29, 47, 61, 76, 90, 106, 119, 133, 147, 161, 176, 193, 209, 224, 240], + [15, 21, 35, 50, 61, 73, 86, 97, 110, 119, 129, 141, 175, 198, 218, 237], +]; +pub const NLSF_MIN_SPACING_NBMB: [i32; 11] = [250, 3, 6, 3, 3, 3, 4, 3, 3, 3, 461]; +pub const NLSF_MIN_SPACING_WB: [i32; 17] = [100, 3, 40, 3, 3, 3, 5, 14, 14, 10, 11, 3, 8, 9, 7, 3, 347]; +pub const NLSF_INTERP: [u8; 5] = icdf(&[13, 22, 29, 11, 181]); +pub const LSF_ORDER_NBMB: [usize; 10] = [0, 9, 6, 3, 4, 5, 8, 1, 2, 7]; +pub const LSF_ORDER_WB: [usize; 16] = [0, 15, 8, 7, 4, 11, 12, 3, 2, 13, 10, 5, 6, 9, 14, 1]; +pub const COS_Q12: [i32; 129] = [ + 4096, 4095, 4091, 4085, 4076, 4065, 4052, 4036, 4017, 3997, 3973, 3948, 3920, 3889, 3857, 3822, 3784, 3745, 3703, 3659, 3613, 3564, 3513, 3461, + 3406, 3349, 3290, 3229, 3166, 3102, 3035, 2967, 2896, 2824, 2751, 2676, 2599, 2520, 2440, 2359, 2276, 2191, 2106, 2019, 1931, 1842, 1751, 1660, + 1568, 1474, 1380, 1285, 1189, 1093, 995, 897, 799, 700, 601, 501, 401, 301, 201, 101, 0, -101, -201, -301, -401, -501, -601, -700, -799, -897, + -995, -1093, -1189, -1285, -1380, -1474, -1568, -1660, -1751, -1842, -1931, -2019, -2106, -2191, -2276, -2359, -2440, -2520, -2599, -2676, -2751, + -2824, -2896, -2967, -3035, -3102, -3166, -3229, -3290, -3349, -3406, -3461, -3513, -3564, -3613, -3659, -3703, -3745, -3784, -3822, -3857, + -3889, -3920, -3948, -3973, -3997, -4017, -4036, -4052, -4065, -4076, -4085, -4091, -4095, -4096, +]; +pub const PITCH_HIGH: [u8; 32] = icdf(&[3, 3, 6, 11, 21, 30, 32, 19, 11, 10, 12, 13, 13, 12, 11, 9, 8, 7, 6, 4, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1]); +pub const PITCH_LOW: [&[u8]; 3] = + [&icdf::<4>(&[64, 64, 64, 64]), &icdf::<6>(&[43, 42, 43, 43, 42, 43]), &icdf::<8>(&[32, 32, 32, 32, 32, 32, 32, 32])]; +pub const PITCH_DELTA: [u8; 21] = icdf(&[46, 2, 2, 3, 4, 6, 10, 15, 26, 38, 30, 22, 15, 10, 7, 6, 4, 4, 2, 2, 2]); +pub const PITCH_CONTOUR: [&[u8]; 4] = [ + &icdf::<3>(&[143, 50, 63]), + &icdf::<11>(&[68, 12, 21, 17, 19, 22, 30, 24, 17, 16, 10]), + &icdf::<12>(&[91, 46, 39, 19, 14, 12, 8, 7, 6, 5, 5, 4]), + &icdf::<34>(&[33, 22, 18, 16, 15, 14, 14, 13, 13, 10, 9, 9, 8, 6, 6, 6, 5, 4, 4, 4, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1]), +]; +pub const CB_NB_10: [[i32; 2]; 3] = [[0, 0], [1, 0], [0, 1]]; +pub const CB_NB_20: [[i32; 4]; 11] = [ + [0, 0, 0, 0], + [2, 1, 0, -1], + [-1, 0, 1, 2], + [-1, 0, 0, 1], + [-1, 0, 0, 0], + [0, 0, 0, 1], + [0, 0, 1, 1], + [1, 1, 0, 0], + [1, 0, 0, 0], + [0, 0, 0, -1], + [1, 0, 0, -1], +]; +pub const CB_WB_10: [[i32; 2]; 12] = [[0, 0], [0, 1], [1, 0], [-1, 1], [1, -1], [-1, 2], [2, -1], [-2, 2], [2, -2], [-2, 3], [3, -2], [-3, 3]]; +pub const CB_WB_20: [[i32; 4]; 34] = [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [1, 1, 0, 0], + [-1, 0, 0, 0], + [0, 0, 0, 1], + [1, 0, 0, 0], + [-1, 0, 0, 1], + [0, 0, 0, -1], + [-1, 0, 1, 2], + [1, 0, 0, -1], + [-2, -1, 1, 2], + [2, 1, 0, -1], + [-2, 0, 0, 2], + [-2, 0, 1, 3], + [2, 1, -1, -2], + [-3, -1, 1, 3], + [2, 0, 0, -2], + [3, 1, 0, -2], + [-3, -1, 2, 4], + [-4, -1, 1, 4], + [3, 1, -1, -3], + [-4, -1, 2, 5], + [4, 2, -1, -3], + [4, 1, -1, -4], + [-5, -1, 2, 6], + [5, 2, -1, -4], + [-6, -2, 2, 6], + [-5, -2, 2, 5], + [6, 2, -1, -5], + [-7, -2, 3, 8], + [6, 2, -2, -6], + [5, 2, -2, -5], + [8, 3, -2, -7], + [-9, -3, 3, 9], +]; +pub const LTP_PERIODICITY: [u8; 3] = icdf(&[77, 80, 99]); +pub const LTP_FILTER: [&[u8]; 3] = [ + &icdf::<8>(&[185, 15, 13, 13, 9, 9, 6, 6]), + &icdf::<16>(&[57, 34, 21, 20, 15, 13, 12, 13, 10, 10, 9, 10, 9, 8, 7, 8]), + &icdf::<32>(&[15, 16, 14, 12, 12, 12, 11, 11, 11, 10, 9, 9, 9, 9, 8, 8, 8, 8, 7, 7, 6, 6, 5, 4, 5, 4, 4, 4, 3, 4, 3, 2]), +]; +pub const LTP_TAPS_0: [[i32; 5]; 8] = [ + [4, 6, 24, 7, 5], + [0, 0, 2, 0, 0], + [12, 28, 41, 13, -4], + [-9, 15, 42, 25, 14], + [1, -2, 62, 41, -9], + [-10, 37, 65, -4, 3], + [-6, 4, 66, 7, -8], + [16, 14, 38, -3, 33], +]; +pub const LTP_TAPS_1: [[i32; 5]; 16] = [ + [13, 22, 39, 23, 12], + [-1, 36, 64, 27, -6], + [-7, 10, 55, 43, 17], + [1, 1, 8, 1, 1], + [6, -11, 74, 53, -9], + [-12, 55, 76, -12, 8], + [-3, 3, 93, 27, -4], + [26, 39, 59, 3, -8], + [2, 0, 77, 11, 9], + [-8, 22, 44, -6, 7], + [40, 9, 26, 3, 9], + [-7, 20, 101, -7, 4], + [3, -8, 42, 26, 0], + [-15, 33, 68, 2, 23], + [-2, 55, 46, -2, 15], + [3, -1, 21, 16, 41], +]; +pub const LTP_TAPS_2: [[i32; 5]; 32] = [ + [-6, 27, 61, 39, 5], + [-11, 42, 88, 4, 1], + [-2, 60, 65, 6, -4], + [-1, -5, 73, 56, 1], + [-9, 19, 94, 29, -9], + [0, 12, 99, 6, 4], + [8, -19, 102, 46, -13], + [3, 2, 13, 3, 2], + [9, -21, 84, 72, -18], + [-11, 46, 104, -22, 8], + [18, 38, 48, 23, 0], + [-16, 70, 83, -21, 11], + [5, -11, 117, 22, -8], + [-6, 23, 117, -12, 3], + [3, -8, 95, 28, 4], + [-10, 15, 77, 60, -15], + [-1, 4, 124, 2, -4], + [3, 38, 84, 24, -25], + [2, 13, 42, 13, 31], + [21, -4, 56, 46, -1], + [-1, 35, 79, -13, 19], + [-7, 65, 88, -9, -14], + [20, 4, 81, 49, -29], + [20, 0, 75, 3, -17], + [5, -9, 44, 92, -8], + [1, -3, 22, 69, 31], + [-6, 95, 41, -12, 5], + [39, 67, 16, -4, 1], + [0, -6, 120, 55, -36], + [-13, 44, 122, 4, -24], + [81, 5, 11, 3, 7], + [2, 0, 9, 10, 88], +]; +pub const LTP_SCALE: [u8; 3] = icdf(&[128, 64, 64]); +pub const UNIFORM4: [u8; 4] = icdf(&[64, 64, 64, 64]); +pub const RATE_LEVEL: [&[u8]; 2] = [&icdf::<9>(&[15, 51, 12, 46, 45, 13, 33, 27, 14]), &icdf::<9>(&[33, 30, 36, 17, 34, 49, 18, 21, 18])]; +pub const PULSE_COUNT: [&[u8]; 11] = [ + &icdf::<18>(&[131, 74, 25, 8, 3, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), + &icdf::<18>(&[58, 93, 60, 23, 7, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), + &icdf::<18>(&[43, 51, 46, 33, 24, 16, 11, 8, 6, 3, 3, 3, 2, 1, 1, 2, 1, 2]), + &icdf::<18>(&[17, 52, 71, 57, 31, 12, 5, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]), + &icdf::<18>(&[6, 21, 41, 53, 49, 35, 21, 11, 6, 3, 2, 2, 1, 1, 1, 1, 1, 1]), + &icdf::<18>(&[7, 14, 22, 28, 29, 28, 25, 20, 17, 13, 11, 9, 7, 5, 4, 4, 3, 10]), + &icdf::<18>(&[2, 5, 14, 29, 42, 46, 41, 31, 19, 11, 6, 3, 2, 1, 1, 1, 1, 1]), + &icdf::<18>(&[1, 2, 4, 10, 19, 29, 35, 37, 34, 28, 20, 14, 8, 5, 4, 2, 2, 2]), + &icdf::<18>(&[1, 2, 2, 5, 9, 14, 20, 24, 27, 28, 26, 23, 20, 15, 11, 8, 6, 15]), + &icdf::<18>(&[1, 1, 1, 6, 27, 58, 56, 39, 25, 14, 10, 6, 3, 3, 2, 1, 1, 2]), + &icdf::<18>(&[2, 1, 6, 27, 58, 56, 39, 25, 14, 10, 6, 3, 3, 2, 1, 1, 2, 0]), +]; +pub const SHELL_16: [&[u8]; 16] = [ + &icdf::<2>(&[126, 130]), + &icdf::<3>(&[56, 142, 58]), + &icdf::<4>(&[25, 101, 104, 26]), + &icdf::<5>(&[12, 60, 108, 64, 12]), + &icdf::<6>(&[7, 35, 84, 87, 37, 6]), + &icdf::<7>(&[4, 20, 59, 86, 63, 21, 3]), + &icdf::<8>(&[3, 12, 38, 72, 75, 42, 12, 2]), + &icdf::<9>(&[2, 8, 25, 54, 73, 59, 27, 7, 1]), + &icdf::<10>(&[2, 5, 17, 39, 63, 65, 42, 18, 4, 1]), + &icdf::<11>(&[1, 4, 12, 28, 49, 63, 54, 30, 11, 3, 1]), + &icdf::<12>(&[1, 4, 8, 20, 37, 55, 57, 41, 22, 8, 2, 1]), + &icdf::<13>(&[1, 3, 7, 15, 28, 44, 53, 48, 33, 16, 6, 1, 1]), + &icdf::<14>(&[1, 2, 6, 12, 21, 35, 47, 48, 40, 25, 12, 5, 1, 1]), + &icdf::<15>(&[1, 1, 4, 10, 17, 27, 37, 47, 43, 33, 21, 9, 4, 1, 1]), + &icdf::<16>(&[1, 1, 1, 8, 14, 22, 33, 40, 43, 38, 28, 16, 8, 1, 1, 1]), + &icdf::<17>(&[1, 1, 1, 1, 13, 18, 27, 36, 41, 41, 34, 24, 14, 1, 1, 1, 1]), +]; +pub const SHELL_8: [&[u8]; 16] = [ + &icdf::<2>(&[127, 129]), + &icdf::<3>(&[53, 149, 54]), + &icdf::<4>(&[22, 105, 106, 23]), + &icdf::<5>(&[11, 61, 111, 63, 10]), + &icdf::<6>(&[6, 35, 86, 88, 36, 5]), + &icdf::<7>(&[4, 20, 59, 87, 62, 21, 3]), + &icdf::<8>(&[3, 13, 40, 71, 73, 41, 13, 2]), + &icdf::<9>(&[3, 9, 27, 53, 70, 56, 28, 9, 1]), + &icdf::<10>(&[3, 8, 19, 37, 57, 61, 44, 20, 6, 1]), + &icdf::<11>(&[3, 7, 15, 28, 44, 54, 49, 33, 17, 5, 1]), + &icdf::<12>(&[1, 7, 13, 22, 34, 46, 48, 38, 28, 14, 4, 1]), + &icdf::<13>(&[1, 1, 11, 22, 27, 35, 42, 47, 33, 25, 10, 1, 1]), + &icdf::<14>(&[1, 1, 6, 14, 26, 37, 43, 43, 37, 26, 14, 6, 1, 1]), + &icdf::<15>(&[1, 1, 4, 10, 20, 31, 40, 42, 40, 31, 20, 10, 4, 1, 1]), + &icdf::<16>(&[1, 1, 3, 8, 16, 26, 35, 38, 38, 35, 26, 16, 8, 3, 1, 1]), + &icdf::<17>(&[1, 1, 2, 6, 12, 21, 30, 36, 38, 36, 30, 21, 12, 6, 2, 1, 1]), +]; +pub const SHELL_4: [&[u8]; 16] = [ + &icdf::<2>(&[127, 129]), + &icdf::<3>(&[49, 157, 50]), + &icdf::<4>(&[20, 107, 109, 20]), + &icdf::<5>(&[11, 60, 113, 62, 10]), + &icdf::<6>(&[7, 36, 84, 87, 36, 6]), + &icdf::<7>(&[6, 24, 57, 82, 60, 23, 4]), + &icdf::<8>(&[5, 18, 39, 64, 68, 42, 16, 4]), + &icdf::<9>(&[6, 14, 29, 47, 61, 52, 30, 14, 3]), + &icdf::<10>(&[1, 15, 23, 35, 51, 50, 40, 30, 10, 1]), + &icdf::<11>(&[1, 1, 21, 32, 42, 52, 46, 41, 18, 1, 1]), + &icdf::<12>(&[1, 6, 16, 27, 36, 42, 42, 36, 27, 16, 6, 1]), + &icdf::<13>(&[1, 5, 12, 21, 31, 38, 40, 38, 31, 21, 12, 5, 1]), + &icdf::<14>(&[1, 3, 9, 17, 26, 34, 38, 38, 34, 26, 17, 9, 3, 1]), + &icdf::<15>(&[1, 3, 7, 14, 22, 29, 34, 36, 34, 29, 22, 14, 7, 3, 1]), + &icdf::<16>(&[1, 2, 5, 11, 18, 25, 31, 35, 35, 31, 25, 18, 11, 5, 2, 1]), + &icdf::<17>(&[1, 1, 4, 9, 15, 21, 28, 32, 34, 32, 28, 21, 15, 9, 4, 1, 1]), +]; +pub const SHELL_2: [&[u8]; 16] = [ + &icdf::<2>(&[128, 128]), + &icdf::<3>(&[42, 172, 42]), + &icdf::<4>(&[21, 107, 107, 21]), + &icdf::<5>(&[12, 60, 112, 61, 11]), + &icdf::<6>(&[8, 34, 86, 86, 35, 7]), + &icdf::<7>(&[8, 23, 55, 90, 55, 20, 5]), + &icdf::<8>(&[5, 15, 38, 72, 72, 36, 15, 3]), + &icdf::<9>(&[6, 12, 27, 52, 77, 47, 20, 10, 5]), + &icdf::<10>(&[6, 19, 28, 35, 40, 40, 35, 28, 19, 6]), + &icdf::<11>(&[4, 14, 22, 31, 37, 40, 37, 31, 22, 14, 4]), + &icdf::<12>(&[3, 10, 18, 26, 33, 38, 38, 33, 26, 18, 10, 3]), + &icdf::<13>(&[2, 8, 13, 21, 29, 36, 38, 36, 29, 21, 13, 8, 2]), + &icdf::<14>(&[1, 5, 10, 17, 25, 32, 38, 38, 32, 25, 17, 10, 5, 1]), + &icdf::<15>(&[1, 4, 7, 13, 21, 29, 35, 36, 35, 29, 21, 13, 7, 4, 1]), + &icdf::<16>(&[1, 2, 5, 10, 17, 25, 32, 36, 36, 32, 25, 17, 10, 5, 2, 1]), + &icdf::<17>(&[1, 2, 4, 7, 13, 21, 28, 34, 36, 34, 28, 21, 13, 7, 4, 2, 1]), +]; +pub const EXC_LSB: [u8; 2] = icdf(&[136, 120]); +pub const EXC_SIGN: [&[u8]; 42] = [ + &icdf::<2>(&[2, 254]), + &icdf::<2>(&[207, 49]), + &icdf::<2>(&[189, 67]), + &icdf::<2>(&[179, 77]), + &icdf::<2>(&[174, 82]), + &icdf::<2>(&[163, 93]), + &icdf::<2>(&[157, 99]), + &icdf::<2>(&[58, 198]), + &icdf::<2>(&[245, 11]), + &icdf::<2>(&[238, 18]), + &icdf::<2>(&[232, 24]), + &icdf::<2>(&[225, 31]), + &icdf::<2>(&[220, 36]), + &icdf::<2>(&[211, 45]), + &icdf::<2>(&[1, 255]), + &icdf::<2>(&[210, 46]), + &icdf::<2>(&[190, 66]), + &icdf::<2>(&[178, 78]), + &icdf::<2>(&[169, 87]), + &icdf::<2>(&[162, 94]), + &icdf::<2>(&[152, 104]), + &icdf::<2>(&[48, 208]), + &icdf::<2>(&[242, 14]), + &icdf::<2>(&[235, 21]), + &icdf::<2>(&[224, 32]), + &icdf::<2>(&[214, 42]), + &icdf::<2>(&[205, 51]), + &icdf::<2>(&[190, 66]), + &icdf::<2>(&[1, 255]), + &icdf::<2>(&[162, 94]), + &icdf::<2>(&[152, 104]), + &icdf::<2>(&[147, 109]), + &icdf::<2>(&[144, 112]), + &icdf::<2>(&[141, 115]), + &icdf::<2>(&[138, 118]), + &icdf::<2>(&[8, 248]), + &icdf::<2>(&[203, 53]), + &icdf::<2>(&[187, 69]), + &icdf::<2>(&[176, 80]), + &icdf::<2>(&[168, 88]), + &icdf::<2>(&[161, 95]), + &icdf::<2>(&[154, 102]), +]; +pub const LBRR_FLAGS: [&[u8]; 2] = [&icdf::<3>(&[53, 53, 150]), &icdf::<7>(&[41, 20, 29, 41, 15, 28, 82])]; diff --git a/crates/audio/src/opus/encoder/tables.rs b/crates/audio/src/opus/encoder/tables.rs new file mode 100644 index 000000000..1985202cc --- /dev/null +++ b/crates/audio/src/opus/encoder/tables.rs @@ -0,0 +1,123 @@ +//! Constant tables of the 48 kHz CELT mode (RFC 6716 §4.3, Tables 55–58 and §4.3.2). +//! +//! Transcribed from RFC 6716; copied from FilmCraft's spec-derived `filmcraft-opus` decoder +//! (first-party, MIT OR Apache-2.0) so both sides share the exact same values. + +/// Number of energy bands. +pub const NB_EBANDS: usize = 21; +/// Band edges in units of 2.5 ms MDCT bins (Table 55). +pub const EBANDS: [i32; NB_EBANDS + 1] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 14, 16, 20, 24, 28, 34, 40, 48, 60, 78, 100]; +/// Overlap of the low-overlap window (samples at 48 kHz). +pub const OVERLAP: usize = 120; +/// Largest LM (20 ms frames). +pub const MAX_LM: usize = 3; +/// Pre-emphasis coefficient. +pub const PREEMPH: f32 = 0.850_006_1; + +/// Static allocation table (Table 57), indexed `[quality][band]`, in 1/32 bit per MDCT bin. +pub const ALLOC: [[u8; NB_EBANDS]; 11] = transpose([ + [0, 90, 110, 118, 126, 134, 144, 152, 162, 172, 200], + [0, 80, 100, 110, 119, 127, 137, 145, 155, 165, 200], + [0, 75, 90, 103, 112, 120, 130, 138, 148, 158, 200], + [0, 69, 84, 93, 104, 114, 124, 132, 142, 152, 200], + [0, 63, 78, 86, 95, 103, 113, 123, 133, 143, 200], + [0, 56, 71, 80, 89, 97, 107, 117, 127, 137, 200], + [0, 49, 65, 75, 83, 91, 101, 111, 121, 131, 200], + [0, 40, 58, 70, 78, 85, 95, 105, 115, 125, 200], + [0, 34, 51, 65, 72, 78, 88, 98, 108, 118, 198], + [0, 29, 45, 59, 66, 72, 82, 92, 102, 112, 193], + [0, 20, 39, 53, 60, 66, 76, 86, 96, 106, 188], + [0, 18, 32, 47, 54, 60, 70, 80, 90, 100, 183], + [0, 10, 26, 40, 47, 54, 64, 74, 84, 94, 178], + [0, 0, 20, 31, 39, 47, 57, 67, 77, 87, 173], + [0, 0, 12, 23, 32, 41, 51, 61, 71, 81, 168], + [0, 0, 0, 15, 25, 35, 45, 55, 65, 75, 163], + [0, 0, 0, 4, 17, 29, 39, 49, 59, 69, 158], + [0, 0, 0, 0, 12, 23, 33, 43, 53, 63, 153], + [0, 0, 0, 0, 1, 16, 26, 36, 46, 56, 148], + [0, 0, 0, 0, 0, 10, 15, 20, 30, 45, 129], + [0, 0, 0, 0, 0, 1, 1, 1, 1, 20, 104], +]); + +const fn transpose(t: [[u8; 11]; NB_EBANDS]) -> [[u8; NB_EBANDS]; 11] { + let mut out = [[0u8; NB_EBANDS]; 11]; + let mut b = 0; + while b < NB_EBANDS { + let mut q = 0; + while q < 11 { + out[q][b] = t[b][q]; + q += 1; + } + b += 1; + } + out +} + +/// Mean band energy (log2 domain) removed before coarse quantisation. +pub const E_MEANS: [f32; 25] = [ + 6.4375, 6.25, 5.75, 5.3125, 5.0625, 4.8125, 4.5, 4.375, 4.875, 4.6875, 4.5625, 4.4375, 4.875, 4.625, 4.3125, 4.5, 4.375, 4.625, 4.75, 4.4375, + 3.75, 3.75, 3.75, 3.75, 3.75, +]; + +/// Inter-frame prediction coefficient (alpha) per LM, Q15. +pub const PRED_COEF: [f32; 4] = [29440.0 / 32768.0, 26112.0 / 32768.0, 21248.0 / 32768.0, 16384.0 / 32768.0]; +/// Inter-band prediction coefficient (beta) per LM for inter frames, Q15. +pub const BETA_COEF: [f32; 4] = [30147.0 / 32768.0, 22282.0 / 32768.0, 12124.0 / 32768.0, 6554.0 / 32768.0]; +/// Beta for intra frames. +pub const BETA_INTRA: f32 = 4915.0 / 32768.0; + +/// Laplace model parameters for coarse energy: `[LM][intra][2*band + {0: prob, 1: decay}]`. +pub const E_PROB_MODEL: [[[u8; 42]; 2]; 4] = [ + [ + [ + 72, 127, 65, 129, 66, 128, 65, 128, 64, 128, 62, 128, 64, 128, 64, 128, 92, 78, 92, 79, 92, 78, 90, 79, 116, 41, 115, 40, 114, 40, 132, + 26, 132, 26, 145, 17, 161, 12, 176, 10, 177, 11, + ], + [ + 24, 179, 48, 138, 54, 135, 54, 132, 53, 134, 56, 133, 55, 132, 55, 132, 61, 114, 70, 96, 74, 88, 75, 88, 87, 74, 89, 66, 91, 67, 100, 59, + 108, 50, 120, 40, 122, 37, 97, 43, 78, 50, + ], + ], + [ + [ + 83, 78, 84, 81, 88, 75, 86, 74, 87, 71, 90, 73, 93, 74, 93, 74, 109, 40, 114, 36, 117, 34, 117, 34, 143, 17, 145, 18, 146, 19, 162, 12, + 165, 10, 178, 7, 189, 6, 190, 8, 177, 9, + ], + [ + 23, 178, 54, 115, 63, 102, 66, 98, 69, 99, 74, 89, 71, 91, 73, 91, 78, 89, 86, 80, 92, 66, 93, 64, 102, 59, 103, 60, 104, 60, 117, 52, + 123, 44, 138, 35, 133, 31, 97, 38, 77, 45, + ], + ], + [ + [ + 61, 90, 93, 60, 105, 42, 107, 41, 110, 45, 116, 38, 113, 38, 112, 38, 124, 26, 132, 27, 136, 19, 140, 20, 155, 14, 159, 16, 158, 18, 170, + 13, 177, 10, 187, 8, 192, 6, 175, 9, 159, 10, + ], + [ + 21, 178, 59, 110, 71, 86, 75, 85, 84, 83, 91, 66, 88, 73, 87, 72, 92, 75, 98, 72, 105, 58, 107, 54, 115, 52, 114, 55, 112, 56, 129, 51, + 132, 40, 150, 33, 140, 29, 98, 35, 77, 42, + ], + ], + [ + [ + 42, 121, 96, 66, 108, 43, 111, 40, 117, 44, 123, 32, 120, 36, 119, 33, 127, 33, 134, 34, 139, 21, 147, 23, 152, 20, 158, 25, 154, 26, + 166, 21, 173, 16, 184, 13, 184, 10, 150, 13, 139, 15, + ], + [ + 22, 178, 63, 114, 74, 82, 84, 83, 92, 82, 103, 62, 96, 72, 96, 67, 101, 73, 107, 72, 113, 55, 118, 52, 125, 52, 118, 52, 117, 55, 135, + 49, 137, 39, 157, 32, 145, 29, 97, 33, 77, 40, + ], + ], +]; + +/// Inverse CDF for coarse energy when only a few bits remain. +pub const SMALL_ENERGY_ICDF: [u8; 3] = [2, 1, 0]; +/// Conservative log2 in 1/8 bits of the number of coded bands (intensity reservation). +pub const LOG2_FRAC_TABLE: [i32; 24] = [0, 8, 13, 16, 19, 21, 23, 24, 26, 27, 28, 29, 30, 31, 32, 32, 33, 34, 34, 35, 36, 36, 37, 37]; +/// TF resolution changes: `[LM][4*transient + 2*tf_select + tf_flag]`. +pub const TF_SELECT_TABLE: [[i8; 8]; 4] = + [[0, -1, 0, -1, 0, -1, 0, -1], [0, -1, 0, -2, 1, 0, 1, -1], [0, -2, 0, -3, 2, 0, 1, -1], [0, -2, 0, -3, 3, 0, 1, -1]]; +/// Spreading decision `{7, 2, 21, 2}/32`. +pub const SPREAD_ICDF: [u8; 4] = [25, 23, 2, 0]; +/// Allocation trim (Table 58). +pub const TRIM_ICDF: [u8; 11] = [126, 124, 119, 109, 87, 41, 19, 9, 4, 2, 0]; diff --git a/crates/audio/src/opus/mod.rs b/crates/audio/src/opus/mod.rs index cf2accd33..b67e1ef3d 100644 --- a/crates/audio/src/opus/mod.rs +++ b/crates/audio/src/opus/mod.rs @@ -1,20 +1,31 @@ //! Clean-room pure-Rust Opus (RFC 6716, updated by RFC 8251; RFC 7845 `OpusHead`). //! -//! Decoding (feature `opus-decode`): [`Decoder`] (multistream, surround), [`StreamDecoder`] (one -//! elementary stream), [`OpusHead`] and the packet parser [`packet`]. From FilmCraft's -//! `filmcraft-opus` (storytold/filmcraft@8fda2f2). +//! - Decoding (feature `opus-decode`): [`Decoder`] (multistream, surround), [`StreamDecoder`] (one +//! elementary stream), [`OpusHead`] and the packet parser [`packet`]. From FilmCraft's +//! `filmcraft-opus` (storytold/filmcraft@8fda2f2). +//! - Encoding (feature `opus-encode`): [`Encoder`] (in [`encoder`]) (SILK, hybrid and CELT modes, 48 kHz, +//! 20 ms, mono or stereo). From EffectCraft's `effectcraft-opusenc` (storytold/effectcraft@c618429). //! -//! Public items keep their names, so FilmCraft can re-export this module. +//! Public items keep their names, so FilmCraft and EffectCraft can re-export this module. The two +//! halves come from separate crates and do not share code yet (each has its own range coder, MDCT +//! and band tables). [`Bandwidth`] is one type for both (`encoder::Bandwidth` is the same type); +//! the two `Mode` enums keep their apps' variant names and convert into each other. -// Index loops and long argument lists follow the specification's pseudo-code (FilmCraft allows -// these lints workspace-wide). +// Index loops and long argument lists follow the specification's pseudo-code (FilmCraft and +// EffectCraft allow these lints workspace-wide). #![allow(clippy::needless_range_loop, clippy::too_many_arguments)] +#[cfg(feature = "opus-decode")] mod decode; +#[cfg(feature = "opus-encode")] +pub mod encoder; +#[cfg(feature = "opus-decode")] pub use decode::{Decoder, Error, Mode, OpusHead, Packet, Result, SAMPLE_RATES, StreamDecoder, Toc, packet}; +#[cfg(feature = "opus-encode")] +pub use encoder::Encoder; -/// Coded audio bandwidth (RFC 6716 §2, Table 1). +/// Coded audio bandwidth (RFC 6716 §2, Table 1), for the decoder and the encoder alike. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Bandwidth { /// 4 kHz (8 kHz sampling). @@ -29,9 +40,32 @@ pub enum Bandwidth { Fullband, } +#[cfg(all(feature = "opus-decode", feature = "opus-encode"))] +impl From for encoder::Mode { + fn from(m: Mode) -> Self { + match m { + Mode::SilkOnly => encoder::Mode::Silk, + Mode::Hybrid => encoder::Mode::Hybrid, + Mode::CeltOnly => encoder::Mode::Celt, + } + } +} + +#[cfg(all(feature = "opus-decode", feature = "opus-encode"))] +impl From for Mode { + fn from(m: encoder::Mode) -> Self { + match m { + encoder::Mode::Silk => Mode::SilkOnly, + encoder::Mode::Hybrid => Mode::Hybrid, + encoder::Mode::Celt => Mode::CeltOnly, + } + } +} + /// Channel order for Vorbis-ordered surround (mapping family 1, RFC 7845 §5.1.1.2): output channel /// → decoded channel, giving the WAV/SMPTE order (L R C LFE back… side…) the rest of craft-audio /// uses. From FilmCraft's `filmcraft-codecs`. +#[cfg(feature = "opus-decode")] pub fn wav_order(channels: usize) -> Option<&'static [usize]> { Some(match channels { 3 => &[0, 2, 1], diff --git a/crates/audio/src/track.rs b/crates/audio/src/track.rs index 920f571ae..e522e6f68 100644 --- a/crates/audio/src/track.rs +++ b/crates/audio/src/track.rs @@ -24,8 +24,10 @@ pub const LOOKAHEAD_PACKETS: usize = 8; enum Inner { #[cfg(feature = "aac-decode")] Aac { + /// The decoder, with SBR on for HE-AAC. dec: Box, - up: Option, + /// HE-AAC: 2048 samples per access unit, at twice the core rate. + sbr: bool, order: Option<&'static [usize]>, /// The format ADTS packets must keep, when their headers gave the configuration. adts_format: Option, @@ -74,7 +76,7 @@ pub(crate) type PacketFrames = fn(&[u8], u64) -> Option; /// /// It takes the track's [`CodecParams`] (from `isobmff::Track::codec_params`, /// `matroska::Track::codec_params`, `ogg::Stream::codec_params`, or built by hand for another -/// container) and decodes AAC (LC; HE-AAC as its upsampled LC core), ALAC, Opus, AC-3/E-AC-3, +/// container) and decodes AAC (LC, and HE-AAC with its SBR high band), ALAC, Opus, AC-3/E-AC-3, /// MP1/MP2, MP3 and PCM. Container timing (AAC priming, Opus pre-skip, end trimming) is the caller's: /// every decoded sample is returned. /// @@ -168,17 +170,18 @@ impl TrackDecoder { params.extradata.clone() }; let asc = asc.as_slice(); - let dec = aac::Decoder::new(asc)?; + let mut dec = aac::Decoder::new(asc)?; let out = aac::output_rate(asc, payloads.iter().copied()).unwrap_or(dec.sample_rate()); - let up = (dec.sample_rate().checked_mul(2) == Some(out)).then(|| aac::Upsample2x::new(dec.channels())); - let codec = if up.is_some() { CodecId::HeAac } else { CodecId::Aac }; - // Only twice the core rate is upsampled to; any other signalled rate plays the core. - let out = if up.is_some() { out } else { dec.sample_rate() }; + let sbr = dec.sample_rate().checked_mul(2) == Some(out); + let codec = if sbr { CodecId::HeAac } else { CodecId::Aac }; + // Only SBR at twice the core rate is decoded; any other signalled rate plays the core. + dec.set_sbr(sbr); + let out = dec.sample_rate(); // a program config element that spells out a configuration has its speakers let cfg = aac::speaker_config(dec.config()); let layout = aac_layout(cfg, dec.channels()); let order = layout.is_ordered().then(|| aac::wav_order(cfg)).flatten(); - (Inner::Aac { dec: Box::new(dec), up, order, adts_format }, codec, out, layout, SampleFormat::Compressed) + (Inner::Aac { dec: Box::new(dec), sbr, order, adts_format }, codec, out, layout, SampleFormat::Compressed) } #[cfg(feature = "alac-decode")] CodecId::Alac => { @@ -320,7 +323,7 @@ impl TrackDecoder { let mut partial = false; let channels = match &mut self.inner { #[cfg(feature = "aac-decode")] - Inner::Aac { dec, up, order, adts_format } => { + Inner::Aac { dec, order, adts_format, .. } => { let mut out: Vec> = Vec::new(); let units = match adts_frames(packet) { Some(frames) => adts_units(frames, *adts_format)?, @@ -328,17 +331,11 @@ impl TrackDecoder { }; // A failed frame leaves the decoder as it was, so the frames before it in the // packet must too: their audio is replaced by the packet's concealment. - let before = (units.len() > 1).then(|| (dec.clone(), up.clone())); - let decoded = units.into_iter().try_for_each(|unit| { - let mut block = dec.decode(unit)?; - if let Some(up) = up.as_mut() { - block = up.process(block); - } - append(&mut out, block) - }); + let before = (units.len() > 1).then(|| dec.clone()); + let decoded = units.into_iter().try_for_each(|unit| append(&mut out, dec.decode(unit)?)); if let Err(e) = decoded { if let Some(before) = before { - (*dec, *up) = before; + *dec = before; } return Err(e); } @@ -440,8 +437,8 @@ impl TrackDecoder { None } #[cfg(feature = "aac-decode")] - Inner::Aac { up, .. } => { - let per_unit = aac::FRAME_LEN * if up.is_some() { 2 } else { 1 }; + Inner::Aac { sbr, .. } => { + let per_unit = aac::FRAME_LEN * if *sbr { 2 } else { 1 }; match adts_frames(data) { Some(f) => Some(f.iter().map(|(h, _)| usize::from(h.raw_data_blocks)).sum::().min(MAX_ADTS_FRAMES_PER_PACKET) * per_unit), // A packet lost whole: as long as the last one, in a stream of multi-frame @@ -488,7 +485,7 @@ impl TrackDecoder { pub fn frames_per_packet(&self) -> Option { match &self.inner { #[cfg(feature = "aac-decode")] - Inner::Aac { up, .. } => Some(aac::FRAME_LEN * if up.is_some() { 2 } else { 1 }), + Inner::Aac { sbr, .. } => Some(aac::FRAME_LEN * if *sbr { 2 } else { 1 }), #[cfg(feature = "alac-decode")] Inner::Alac { .. } => None, #[cfg(feature = "ac3-decode")] @@ -544,7 +541,7 @@ impl TrackDecoder { pub fn preroll(&self) -> usize { match &self.inner { #[cfg(feature = "aac-decode")] - Inner::Aac { up, .. } => 2 * aac::FRAME_LEN * if up.is_some() { 2 } else { 1 }, + Inner::Aac { sbr, .. } => 2 * aac::FRAME_LEN * if *sbr { 2 } else { 1 }, #[cfg(feature = "alac-decode")] Inner::Alac { .. } => 0, #[cfg(feature = "ac3-decode")] @@ -570,12 +567,7 @@ impl TrackDecoder { self.last_frames = 0; match &mut self.inner { #[cfg(feature = "aac-decode")] - Inner::Aac { dec, up, .. } => { - dec.reset(); - if let Some(up) = up { - up.reset(); - } - } + Inner::Aac { dec, .. } => dec.reset(), #[cfg(feature = "alac-decode")] Inner::Alac { .. } => {} #[cfg(feature = "ac3-decode")] diff --git a/crates/audio/tests/codecs/aac/he_aac_oracle.rs b/crates/audio/tests/codecs/aac/he_aac_oracle.rs new file mode 100644 index 000000000..0eeba69c8 --- /dev/null +++ b/crates/audio/tests/codecs/aac/he_aac_oracle.rs @@ -0,0 +1,169 @@ +//! HE-AAC (SBR) decoding against ffmpeg's decoder, on streams from ffmpeg's AudioToolbox encoder +//! (oracle only; skipped without ffmpeg or without `aac_at`, which only macOS builds have). + +use std::process::Command; + +use craft_audio::aac::{Decoder, split_adts}; + +use super::common::*; + +/// ffmpeg, if it has the AudioToolbox AAC encoder. +fn ffmpeg_with_aac_at() -> Option { + let ff = ffmpeg()?; + let out = Command::new(&ff).args(["-hide_banner", "-encoders"]).output().ok()?; + if !String::from_utf8_lossy(&out.stdout).contains("aac_at") { + eprintln!("SKIPPED: ffmpeg has no aac_at encoder"); + return None; + } + Some(ff) +} + +fn input(rate: u32, channels: usize, secs: f32) -> Vec> { + let n = (rate as f32 * secs) as usize; + (0..channels) + .map(|c| { + let a = chords(rate, n, 0.004 * c as f32); + let s = speech(rate, n, 11 + c as u64); + let z = noise(n, 0.05, 3 + c as u64); + let k = clicks(rate, n); + a.iter().zip(&s).zip(&z).zip(&k).map(|(((a, s), z), k)| 0.4 * a + 0.4 * s * if c == 1 { -0.7 } else { 1.0 } + z + 0.3 * k).collect() + }) + .collect() +} + +/// ffmpeg's decode of a stream of `channels` channels. ffmpeg decodes mono HE-AAC as stereo (it +/// allows for parametric stereo the stream may hold), with the same signal in both channels when +/// there is none: of those, the first is taken (a downmix would scale it by -3 dB). +fn ffmpeg_decode_as(ff: &std::path::Path, file: &std::path::Path, channels: usize) -> Vec> { + let mut planar = ffmpeg_decode_raw(ff, file, channels.max(2)); + planar.truncate(channels); + planar +} + +fn ffmpeg_decode_raw(ff: &std::path::Path, file: &std::path::Path, channels: usize) -> Vec> { + let out = Command::new(ff).args(["-v", "error", "-i"]).arg(file).args(["-ac", &channels.to_string(), "-f", "f32le", "-"]).output().unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{}", String::from_utf8_lossy(&out.stderr)); + let samples: Vec = out.stdout.as_chunks::<4>().0.iter().map(|&b| f32::from_le_bytes(b)).collect(); + (0..channels).map(|c| samples.iter().skip(c).step_by(channels).copied().collect()).collect() +} + +/// Our decode of an ADTS stream, SBR on. +fn decode_sbr(data: &[u8]) -> (Vec>, u32) { + let frames = split_adts(data).unwrap(); + let mut dec = Decoder::from_adts(&frames[0].0).unwrap(); + dec.set_sbr(true); + let mut out: Vec> = Vec::new(); + for (_, au) in frames { + let pcm = dec.decode(au).unwrap(); + out.resize(pcm.len(), Vec::new()); + for (o, p) in out.iter_mut().zip(pcm) { + o.extend(p); + } + } + (out, dec.sample_rate()) +} + +/// The lag of `b` behind `a` (within ±`range`) that correlates best. +fn best_lag(a: &[f32], b: &[f32], range: i64) -> i64 { + let n = a.len().min(b.len()) as i64; + let (from, to) = (range, n - range); + (-range..=range) + .max_by(|&x, &y| { + let c = |lag: i64| (from..to).step_by(3).map(|i| a[i as usize] as f64 * b[(i + lag) as usize] as f64).sum::(); + c(x).total_cmp(&c(y)) + }) + .unwrap() +} + +/// Energy in dB of `x[from..from + 2048]` within `lo..hi` Hz (Hann-windowed DFT). +fn band_db(x: &[f32], from: usize, rate: f32, lo: f32, hi: f32) -> f64 { + let n = 2048; + let seg = &x[from..from + n]; + let (b0, b1) = ((lo / rate * n as f32) as usize, (hi / rate * n as f32) as usize); + let mut e = 0f64; + for b in b0..b1.min(n / 2) { + let (mut re, mut im) = (0f64, 0f64); + for (i, &s) in seg.iter().enumerate() { + let w = 0.5 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / n as f64).cos(); + let ph = 2.0 * std::f64::consts::PI * (b * i) as f64 / n as f64; + re += s as f64 * w * ph.cos(); + im += s as f64 * w * ph.sin(); + } + e += re * re + im * im; + } + 10.0 * (e + 1e-12).log10() +} + +struct Case { + name: &'static str, + rate: u32, + channels: usize, + bitrate: &'static str, +} + +/// HE-AAC streams of 1 to 6 channels at 22.05 to 48 kHz decode as ffmpeg decodes them: the same +/// timing, the low band to 55 dB and the rebuilt high band's level to 0.1 dB. +#[test] +fn matches_ffmpeg_he_aac_decoder() { + let Some(ff) = ffmpeg_with_aac_at() else { return }; + let cases = [ + Case { name: "he_stereo_44k_48k", rate: 44_100, channels: 2, bitrate: "48k" }, + Case { name: "he_mono_44k_32k", rate: 44_100, channels: 1, bitrate: "32k" }, + Case { name: "he_stereo_48k_64k", rate: 48_000, channels: 2, bitrate: "64k" }, + Case { name: "he_stereo_32k_32k", rate: 32_000, channels: 2, bitrate: "32k" }, + Case { name: "he_stereo_44k_24k", rate: 44_100, channels: 2, bitrate: "24k" }, + Case { name: "he_stereo_22k_24k", rate: 22_050, channels: 2, bitrate: "24k" }, + Case { name: "he_51_48k_160k", rate: 48_000, channels: 6, bitrate: "160k" }, + ]; + for case in &cases { + let path = fixtures().join(format!("{}.aac", case.name)); + ffmpeg_encode(&ff, &input(case.rate, case.channels, 3.0), case.rate, &["-c:a", "aac_at", "-profile:a", "4", "-b:a", case.bitrate], &path); + let data = std::fs::read(&path).unwrap(); + let refr = ffmpeg_decode_as(&ff, &path, case.channels); + let (ours, rate) = decode_sbr(&data); + // ffmpeg puts surround in WAV order, the decoder in AAC element order + let ours: Vec> = match (case.channels == 6).then(|| craft_audio::aac::wav_order(6)).flatten() { + Some(order) => order.iter().map(|&c| ours[c].clone()).collect(), + None => ours, + }; + assert_eq!(rate, case.rate, "{}", case.name); + assert_eq!(ours.len(), case.channels); + // the same delay as ffmpeg's decoder: 962 samples + assert_eq!(best_lag(&refr[0], &ours[0], 2100), 0, "{}", case.name); + let core_nyquist = case.rate as f32 / 4.0; + for c in 0..case.channels { + let n = refr[c].len().min(ours[c].len()) - 4096; + let a: Vec = refr[c][2048..n].to_vec(); + let b: Vec = ours[c][2048..n].to_vec(); + let full = snr(&a, &b); + let (la, lb) = (lowpass(&a, core_nyquist * 0.8, case.rate as f32), lowpass(&b, core_nyquist * 0.8, case.rate as f32)); + let low = snr(&la, &lb); + let mut diffs = Vec::new(); + for from in (4096..a.len() - 4096).step_by(4096) { + for (lo, hi) in + [(core_nyquist * 1.05, core_nyquist * 1.3), (core_nyquist * 1.3, core_nyquist * 1.7), (core_nyquist * 1.7, core_nyquist * 2.0)] + { + let (ea, eb) = (band_db(&a, from, case.rate as f32, lo, hi), band_db(&b, from, case.rate as f32, lo, hi)); + if ea > 0.0 { + diffs.push(eb - ea); + } + } + } + // the LFE (WAV channel 3 of 5.1) has no SBR, so no high band; every other channel must + // have one to compare + let lfe = case.channels == 6 && c == 3; + assert!(lfe || !diffs.is_empty(), "{} ch{c}: no high band to compare", case.name); + let mean = diffs.iter().sum::() / diffs.len() as f64; + let mad = diffs.iter().map(|d| d.abs()).sum::() / diffs.len() as f64; + println!( + "{} ch{c}: full SNR {full:.1} dB, low band SNR {low:.1} dB, high band level {mean:+.2} dB (mean |Δ| {mad:.2} dB over {})", + case.name, + diffs.len() + ); + // The low band differs by the core decoders' rounding; the high band's noise comes + // from the same table, so whole frames match too. + assert!(full > 45.0 && low > 55.0, "{} ch{c}: SNR {full:.1} / {low:.1} dB", case.name); + assert!(lfe || mad < 0.1, "{} ch{c}: high band off by {mad:.2} dB", case.name); + } + } +} diff --git a/crates/audio/tests/codecs/aac/mod.rs b/crates/audio/tests/codecs/aac/mod.rs index 45e627242..7d55a7364 100644 --- a/crates/audio/tests/codecs/aac/mod.rs +++ b/crates/audio/tests/codecs/aac/mod.rs @@ -3,4 +3,5 @@ mod common; mod decoder_oracle; mod encoder_oracle; +mod he_aac_oracle; mod robustness; diff --git a/crates/audio/tests/codecs/main.rs b/crates/audio/tests/codecs/main.rs index 5db6af4b0..279246dc6 100644 --- a/crates/audio/tests/codecs/main.rs +++ b/crates/audio/tests/codecs/main.rs @@ -1,5 +1,5 @@ -//! Tests of the codecs ported from FilmCraft, built as one test binary: each file is a module, as -//! in their original crates. +//! Tests of the codecs ported from FilmCraft and EffectCraft, built as one test binary: each file +//! is a module, as in their original crates. // Index loops follow the specifications, as in FilmCraft (which allows this lint workspace-wide). #![allow(clippy::needless_range_loop)] @@ -16,5 +16,6 @@ mod mp3_decoder_oracle; mod mp3_encoder_oracle; mod mpa_oracle; mod opus; +mod opusenc; mod routing_oracle; mod track_decoder; diff --git a/crates/audio/tests/codecs/opusenc/common.rs b/crates/audio/tests/codecs/opusenc/common.rs new file mode 100644 index 000000000..571c9826c --- /dev/null +++ b/crates/audio/tests/codecs/opusenc/common.rs @@ -0,0 +1,286 @@ +//! Shared helpers for the SILK/hybrid round-trip tests: synthetic signals, encoding, decoding +//! with FilmCraft's spec-derived decoder, delay-searched SNR and an Ogg Opus writer for ffmpeg. +#![allow(dead_code)] + +use craft_audio::opus::encoder::OpusEncoder; + +pub const SR: f64 = 48_000.0; +pub const FRAME: usize = OpusEncoder::FRAME_SIZE; + +/// Two-pole resonator state. +struct Reson { + y1: f64, + y2: f64, +} + +impl Reson { + fn run(&mut self, x: f64, f: f64, bw: f64) -> f64 { + let r = (-std::f64::consts::PI * bw / SR).exp(); + let a1 = 2.0 * r * (2.0 * std::f64::consts::PI * f / SR).cos(); + let a2 = -r * r; + let g = 1.0 - r; + let y = g * x + a1 * self.y1 + a2 * self.y2; + self.y2 = self.y1; + self.y1 = y; + y + } +} + +/// Speech-like test signal: a glottal pulse train (100–200 Hz, gliding pitch) through three +/// formant resonators whose frequencies change per syllable, with syllable envelopes, short +/// pauses and a little aspiration noise. +pub fn speech(len: usize, seed: u32) -> Vec { + let vowels: [[f64; 3]; 5] = + [[730.0, 1090.0, 2440.0], [270.0, 2290.0, 3010.0], [530.0, 1840.0, 2480.0], [570.0, 840.0, 2410.0], [300.0, 870.0, 2240.0]]; + let syl = (0.24 * SR) as usize; + let mut out = Vec::with_capacity(len); + let mut res = [Reson { y1: 0.0, y2: 0.0 }, Reson { y1: 0.0, y2: 0.0 }, Reson { y1: 0.0, y2: 0.0 }]; + let mut phase = 0.0f64; + let mut s = seed; + let mut prev_glottal = 0.0; + for n in 0..len { + let t = n as f64 / SR; + let k = n / syl; + let pos = (n % syl) as f64 / syl as f64; + // Every fourth syllable is a pause. + let pause = k % 4 == 3; + let env = if pause { 0.0 } else { (std::f64::consts::PI * pos).sin().powf(0.7) }; + let f0 = 100.0 + 50.0 * (1.0 + (2.0 * std::f64::consts::PI * 0.9 * t).sin()) + 15.0 * ((k * 7) % 5) as f64 - 30.0 * pos; + phase += f0 / SR; + if phase >= 1.0 { + phase -= 1.0; + } + // Rosenberg-like glottal flow; its derivative excites the vocal tract. + let g = if phase < 0.4 { + 0.5 * (1.0 - (std::f64::consts::PI * phase / 0.4).cos()) + } else if phase < 0.56 { + (std::f64::consts::FRAC_PI_2 * (phase - 0.4) / 0.16).cos() + } else { + 0.0 + }; + let dg = (g - prev_glottal) * 40.0; + prev_glottal = g; + s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let noise = ((s >> 8) as f64 / (1u32 << 24) as f64 - 0.5) * 0.02; + let v = vowels[(k * 3 + 1) % 5]; + let w = vowels[(k * 3 + 4) % 5]; + let mix = pos; + let mut x = dg + noise; + let mut y = 0.0; + for (i, r) in res.iter_mut().enumerate() { + let f = v[i] * (1.0 - mix) + w[i] * mix; + y += r.run(x, f, 60.0 + 40.0 * i as f64) * [1.0, 0.6, 0.3][i]; + } + x = y * env * 1.2; + out.push(x as f32); + } + let peak = out.iter().fold(0f32, |m, v| m.max(v.abs())); + let g = 0.5 / peak.max(1e-9); + out.iter().map(|v| v * g).collect() +} + +/// Music-like test signal: three-note chords of harmonic tones (six partials each), changing +/// every half second with soft attacks and releases. +pub fn music(len: usize) -> Vec { + let chords: [[f64; 3]; 4] = [[261.63, 329.63, 392.0], [220.0, 277.18, 329.63], [174.61, 220.0, 261.63], [196.0, 246.94, 293.66]]; + let seg = (0.5 * SR) as usize; + (0..len) + .map(|n| { + let t = n as f64 / SR; + let k = n / seg; + let pos = (n % seg) as f64 / seg as f64; + let env = (pos * 20.0).min(1.0) * ((1.0 - pos) * 10.0).min(1.0); + let mut x = 0.0; + for (j, &f) in chords[k % 4].iter().enumerate() { + for h in 1..=6 { + x += (2.0 * std::f64::consts::PI * f * h as f64 * t + j as f64).sin() / h as f64; + } + } + (0.12 * env * x) as f32 + }) + .collect() +} + +pub fn interleave(planar: &[Vec]) -> Vec { + let c = planar.len(); + let len = planar[0].len(); + let mut pcm = vec![0f32; len * c]; + for (ch, p) in planar.iter().enumerate() { + for (i, &v) in p.iter().enumerate() { + pcm[i * c + ch] = v; + } + } + pcm +} + +/// Encodes planar input (padding the tail so the encoder delay is flushed). +pub fn encode(mut enc: OpusEncoder, planar: &[Vec]) -> (OpusEncoder, Vec>) { + let c = planar.len(); + let len = planar[0].len(); + let total = len + enc.pre_skip() as usize + 16; + let frames = total.div_ceil(FRAME); + let mut pcm = interleave(planar); + pcm.resize(frames * FRAME * c, 0.0); + let packets = pcm.chunks(FRAME * c).map(|f| enc.encode_float(f).unwrap()).collect(); + (enc, packets) +} + +/// Decodes with filmcraft-opus, drops the pre-skip and returns planar channels (full length). +pub fn decode(head: &[u8], packets: &[Vec]) -> Vec> { + let mut dec = craft_audio::opus::Decoder::new(head).expect("OpusHead"); + dec.set_trim_pre_skip(true); + let mut out: Vec> = vec![Vec::new(); dec.channels()]; + for p in packets { + let planes = dec.decode(Some(p)).expect("decode"); + for (o, p) in out.iter_mut().zip(planes) { + o.extend_from_slice(&p); + } + } + out +} + +/// Waveform SNR (dB) of `dec[i + shift]` against `orig[i]`, excluding the first and last 20 ms. +pub fn snr_at(orig: &[f32], dec: &[f32], shift: isize) -> f64 { + let (mut s, mut e) = (0f64, 0f64); + for i in FRAME..orig.len() - FRAME { + let j = i as isize + shift; + let d = if j >= 0 && (j as usize) < dec.len() { dec[j as usize] as f64 } else { 0.0 }; + s += (orig[i] as f64).powi(2); + e += (orig[i] as f64 - d).powi(2); + } + 10.0 * (s / e.max(1e-30)).log10() +} + +/// Best SNR over residual delays of ±`range` samples: (SNR dB, delay). +pub fn snr_search(orig: &[f32], dec: &[f32], range: isize) -> (f64, isize) { + (-range..=range).map(|d| (snr_at(orig, dec, d), d)).fold((f64::MIN, 0), |a, b| if b.0 > a.0 { b } else { a }) +} + +/// Segmental SNR (dB): mean over 20 ms segments holding signal (each clamped to [-10, 40]). +pub fn seg_snr(orig: &[f32], dec: &[f32], shift: isize) -> f64 { + let mut acc = 0f64; + let mut n = 0; + let mut i = FRAME; + while i + FRAME <= orig.len() - FRAME { + let (mut s, mut e) = (0f64, 0f64); + for k in i..i + FRAME { + let j = k as isize + shift; + let d = if j >= 0 && (j as usize) < dec.len() { dec[j as usize] as f64 } else { 0.0 }; + s += (orig[k] as f64).powi(2); + e += (orig[k] as f64 - d).powi(2); + } + if s / FRAME as f64 > 1e-5 { + acc += (10.0 * (s / e.max(1e-30)).log10()).clamp(-10.0, 40.0); + n += 1; + } + i += FRAME; + } + acc / n.max(1) as f64 +} + +/// Average bitrate (bits per second) of a packet sequence of 20 ms packets. +pub fn bitrate(packets: &[Vec]) -> f64 { + packets.iter().map(|p| p.len() * 8).sum::() as f64 / (packets.len() as f64 * 0.02) +} + +/// Low-pass filtered copy (FFT brick wall) used to compare band-limited modes fairly. +pub fn lowpass(x: &[f32], fc: f64) -> Vec { + let n = x.len().next_power_of_two(); + let mut re: Vec = x.iter().map(|&v| v as f64).collect(); + re.resize(n, 0.0); + let mut im = vec![0f64; n]; + fft(&mut re, &mut im, false); + for k in 0..n { + let f = (k.min(n - k)) as f64 * SR / n as f64; + if f > fc { + re[k] = 0.0; + im[k] = 0.0; + } + } + fft(&mut re, &mut im, true); + re[..x.len()].iter().map(|&v| (v / n as f64) as f32).collect() +} + +pub fn fft(re: &mut [f64], im: &mut [f64], inverse: bool) { + let n = re.len(); + let mut j = 0; + for i in 1..n { + let mut bit = n >> 1; + while j & bit != 0 { + j ^= bit; + bit >>= 1; + } + j |= bit; + if i < j { + re.swap(i, j); + im.swap(i, j); + } + } + let mut len = 2; + while len <= n { + let ang = if inverse { 2.0 } else { -2.0 } * std::f64::consts::PI / len as f64; + for s in (0..n).step_by(len) { + for k in 0..len / 2 { + let (wr, wi) = ((ang * k as f64).cos(), (ang * k as f64).sin()); + let (a, b) = (s + k, s + k + len / 2); + let tr = re[b] * wr - im[b] * wi; + let ti = re[b] * wi + im[b] * wr; + re[b] = re[a] - tr; + im[b] = im[a] - ti; + re[a] += tr; + im[a] += ti; + } + } + len <<= 1; + } +} + +// ---- Ogg Opus (RFC 3533 + RFC 7845) for the ffmpeg oracle ---- + +fn ogg_crc(data: &[u8]) -> u32 { + let mut crc = 0u32; + for &b in data { + crc ^= (b as u32) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 }; + } + } + crc +} + +fn ogg_page(out: &mut Vec, packet: &[u8], header_type: u8, granule: u64, seq: u32) { + let mut lacing = vec![255u8; packet.len() / 255]; + lacing.push((packet.len() % 255) as u8); + let start = out.len(); + out.extend_from_slice(b"OggS"); + out.push(0); + out.push(header_type); + out.extend_from_slice(&granule.to_le_bytes()); + out.extend_from_slice(&0x4543_4F50u32.to_le_bytes()); + out.extend_from_slice(&seq.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.push(lacing.len() as u8); + out.extend_from_slice(&lacing); + out.extend_from_slice(packet); + let crc = ogg_crc(&out[start..]); + out[start + 22..start + 26].copy_from_slice(&crc.to_le_bytes()); +} + +/// An Ogg Opus file holding `packets` for `len` samples of audio. +pub fn ogg_opus(enc: &OpusEncoder, packets: &[Vec], len: usize) -> Vec { + let mut out = Vec::new(); + ogg_page(&mut out, &enc.opus_head(), 0x02, 0, 0); + let vendor = b"effectcraft-opusenc"; + let mut tags = b"OpusTags".to_vec(); + tags.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); + tags.extend_from_slice(vendor); + tags.extend_from_slice(&0u32.to_le_bytes()); + ogg_page(&mut out, &tags, 0, 0, 1); + let pre_skip = enc.pre_skip() as u64; + for (i, p) in packets.iter().enumerate() { + let last = i + 1 == packets.len(); + let granule = if last { pre_skip + len as u64 } else { ((i + 1) * FRAME) as u64 }; + ogg_page(&mut out, p, if last { 0x04 } else { 0 }, granule, i as u32 + 2); + } + out +} diff --git a/crates/audio/tests/codecs/opusenc/ffmpeg.rs b/crates/audio/tests/codecs/opusenc/ffmpeg.rs new file mode 100644 index 000000000..41965be20 --- /dev/null +++ b/crates/audio/tests/codecs/opusenc/ffmpeg.rs @@ -0,0 +1,83 @@ +//! ffmpeg as a second, external oracle: every mode/bandwidth written as Ogg Opus must decode +//! without errors with both ffmpeg's native `opus` decoder and `libopus`, honour the pre-skip +//! and end granule, and reproduce the input. Skipped when ffmpeg is not installed. + +use super::common; + +use std::process::Command; + +use common::*; +use craft_audio::opus::encoder::{Bandwidth, Mode, OpusEncoder}; + +/// ffmpeg, found and skipped by craft-media's oracle rules (EffectCraft looked in Homebrew's +/// directory, then `PATH`). +fn ffmpeg() -> Option { + crate::testkit::ffmpeg_or_skip("opus encoder ffmpeg oracle") +} + +/// Decodes an Ogg Opus file with ffmpeg's decoder `codec`; returns interleaved f32 output. +fn ffmpeg_decode(exe: &std::path::Path, codec: &str, file: &[u8], channels: usize, tag: &str) -> Option> { + let has = match Command::new(exe).args(["-hide_banner", "-decoders"]).output() { + Ok(out) => out, + Err(e) => return crate::common::skip(&format!("ffmpeg -decoders failed: {e}")), + }; + if !String::from_utf8_lossy(&has.stdout).lines().any(|l| l.split_whitespace().nth(1) == Some(codec)) { + return crate::common::skip(&format!("ffmpeg has no {codec} decoder")); + } + let path = std::env::temp_dir().join(format!("effectcraft-opusenc-{}-{tag}.opus", std::process::id())); + std::fs::write(&path, file).unwrap(); + let out = Command::new(exe) + .args(["-v", "error", "-c:a", codec, "-i"]) + .arg(&path) + .args(["-f", "f32le", "-ac", &channels.to_string(), "-ar", "48000", "-"]) + .output() + .expect("run ffmpeg"); + let _ = std::fs::remove_file(&path); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "ffmpeg ({codec}) failed: {stderr}"); + // ffmpeg's native decoder also makes its raw-PCM muxer print a timestamp complaint for + // narrowband SILK streams written by libopus itself; that line is about ffmpeg's own + // timestamping, not the bitstream, so it is the one message tolerated. + let errors: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty() && !l.contains("non monotonically increasing dts to muxer")).collect(); + assert!(errors.is_empty(), "ffmpeg ({codec}) reported errors: {errors:?}"); + Some(out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect()) +} + +#[test] +fn ffmpeg_decodes_every_mode() { + let Some(exe) = ffmpeg() else { + eprintln!("ffmpeg not installed; skipping the oracle test"); + return; + }; + let len = 48_000; + let sp = speech(len + 30_000, 3); + let left = sp[..len].to_vec(); + let right: Vec = sp[30_000..].iter().map(|v| v * 0.7).collect(); + let cases = [ + (1usize, Mode::Silk, Bandwidth::Narrowband, 8_000u32, 3_400.0, 8.0), + (1, Mode::Silk, Bandwidth::Mediumband, 11_000, 5_000.0, 10.0), + (1, Mode::Silk, Bandwidth::Wideband, 16_000, 7_000.0, 12.0), + (2, Mode::Silk, Bandwidth::Wideband, 32_000, 7_000.0, 12.0), + (1, Mode::Hybrid, Bandwidth::SuperWideband, 24_000, 24_000.0, 10.0), + (1, Mode::Hybrid, Bandwidth::Fullband, 32_000, 24_000.0, 10.0), + (2, Mode::Hybrid, Bandwidth::Fullband, 64_000, 24_000.0, 10.0), + (2, Mode::Celt, Bandwidth::Fullband, 96_000, 24_000.0, 10.0), + ]; + for (c, mode, bw, rate, fc, min_snr) in cases { + let planar: Vec> = if c == 1 { vec![left.clone()] } else { vec![left.clone(), right.clone()] }; + let (enc, packets) = encode(OpusEncoder::with_mode(c as u8, rate, mode, bw).unwrap(), &planar); + let file = ogg_opus(&enc, &packets, len); + for codec in ["opus", "libopus"] { + let Some(dec) = ffmpeg_decode(&exe, codec, &file, c, &format!("{mode:?}{bw:?}{c}{codec}")) else { + continue; + }; + assert_eq!(dec.len(), len * c, "{codec}: pre-skip and end granule must be honoured"); + for (ch, x) in planar.iter().enumerate() { + let d: Vec = (0..len).map(|i| dec[i * c + ch]).collect(); + let (snr, delay) = snr_search(&lowpass(x, fc), &d, 6); + eprintln!("ffmpeg {codec:7} {mode:?}/{bw:?} {c} ch @ {} kb/s, channel {ch}: SNR {snr:.1} dB (residual delay {delay})", rate / 1000); + assert!(snr > min_snr, "{codec} {mode:?}/{bw:?}: SNR {snr:.1} dB"); + } + } + } +} diff --git a/crates/audio/tests/codecs/opusenc/mod.rs b/crates/audio/tests/codecs/opusenc/mod.rs new file mode 100644 index 000000000..f2a886463 --- /dev/null +++ b/crates/audio/tests/codecs/opusenc/mod.rs @@ -0,0 +1,5 @@ +//! EffectCraft's `effectcraft-opusenc` integration tests. + +mod common; +mod ffmpeg; +mod modes; diff --git a/crates/audio/tests/codecs/opusenc/modes.rs b/crates/audio/tests/codecs/opusenc/modes.rs new file mode 100644 index 000000000..263858560 --- /dev/null +++ b/crates/audio/tests/codecs/opusenc/modes.rs @@ -0,0 +1,292 @@ +//! SILK, hybrid and CELT round trips through FilmCraft's spec-derived Opus decoder: every +//! mode/bandwidth decodes, waveform quality on speech-like and music-like signals, bitrate +//! tracking, stereo, the application tables and delay alignment. + +use super::common; + +use common::*; +use craft_audio::opus::encoder::{Application, Bandwidth, Error, Mode, OpusEncoder}; + +struct Run { + snr: f64, + seg: f64, + delay: isize, + kbps: f64, + packets: Vec>, +} + +/// Encodes `planar`, decodes, and measures each channel against `reference` (searching a +/// residual delay of up to ±6 samples). +fn run(enc: OpusEncoder, planar: &[Vec], reference: &[Vec]) -> Vec { + let (enc, packets) = encode(enc, planar); + let dec = decode(&enc.opus_head(), &packets); + let kbps = bitrate(&packets) / 1000.0; + reference + .iter() + .zip(&dec) + .map(|(r, d)| { + assert!(d.len() >= r.len(), "decoded {} < {}", d.len(), r.len()); + assert!(d.iter().all(|v| v.is_finite())); + let (snr, delay) = snr_search(r, d, 6); + Run { snr, seg: seg_snr(r, d, delay), delay, kbps, packets: packets.clone() } + }) + .collect() +} + +fn mono(mode: Mode, bw: Bandwidth, rate: u32, x: &[f32], fc: f64) -> Run { + let reference = if fc < 20_000.0 { lowpass(x, fc) } else { x.to_vec() }; + run(OpusEncoder::with_mode(1, rate, mode, bw).unwrap(), &[x.to_vec()], &[reference]).remove(0) +} + +/// Upper edge of the band a SILK bandwidth keeps (for band-limited references). +fn band_edge(bw: Bandwidth) -> f64 { + match bw { + Bandwidth::Narrowband => 3_400.0, + Bandwidth::Mediumband => 5_000.0, + Bandwidth::Wideband => 7_000.0, + _ => 24_000.0, + } +} + +const LEN: usize = 96_000; + +#[test] +fn every_mode_and_bandwidth_decodes() { + let sp = speech(LEN + 40_000, 11); + let left = sp[..LEN].to_vec(); + let right: Vec = sp[40_000..].iter().map(|v| v * 0.6).collect(); + for (mode, bw, config, rate) in [ + (Mode::Silk, Bandwidth::Narrowband, 1u8, 8_000u32), + (Mode::Silk, Bandwidth::Mediumband, 5, 11_000), + (Mode::Silk, Bandwidth::Wideband, 9, 16_000), + (Mode::Hybrid, Bandwidth::SuperWideband, 13, 24_000), + (Mode::Hybrid, Bandwidth::Fullband, 15, 32_000), + (Mode::Celt, Bandwidth::Fullband, 31, 64_000), + ] { + for c in [1usize, 2] { + let planar = if c == 1 { vec![left.clone()] } else { vec![left.clone(), right.clone()] }; + let reference: Vec> = planar.iter().map(|x| lowpass(x, band_edge(bw))).collect(); + let enc = OpusEncoder::with_mode(c as u8, rate * c as u32, mode, bw).unwrap(); + assert_eq!((enc.mode(), enc.bandwidth()), (mode, bw)); + let res = run(enc, &planar, &reference); + for p in &res[0].packets { + assert_eq!(p[0], (config << 3) | if c == 2 { 4 } else { 0 }, "TOC of {mode:?}/{bw:?}"); + assert!(p.len() >= 3 && p.len() <= 1276); + } + for (ch, r) in res.iter().enumerate() { + eprintln!( + "{mode:?}/{bw:?} {c} ch @ {} kb/s: {:.1} kb/s, channel {ch} SNR {:.1} dB, segSNR {:.1} dB, residual delay {}", + rate * c as u32 / 1000, + r.kbps, + r.snr, + r.seg, + r.delay + ); + assert!(r.snr > 8.0, "{mode:?}/{bw:?} {c} ch channel {ch}: SNR {:.1} dB", r.snr); + assert!(r.delay.abs() <= 1, "{mode:?}/{bw:?}: residual delay {} (pre-skip mismatch)", r.delay); + } + } + } +} + +#[test] +fn silk_speech_quality() { + let x = speech(LEN, 7); + for (bw, rate, min_snr) in [ + (Bandwidth::Narrowband, 8_000u32, 10.0), + (Bandwidth::Mediumband, 11_000, 14.0), + (Bandwidth::Wideband, 12_000, 14.0), + (Bandwidth::Wideband, 16_000, 18.0), + (Bandwidth::Wideband, 24_000, 24.0), + ] { + let r = mono(Mode::Silk, bw, rate, &x, band_edge(bw)); + eprintln!("SILK {bw:?} speech @ {} kb/s: {:.1} kb/s, SNR {:.1} dB, segSNR {:.1} dB", rate / 1000, r.kbps, r.snr, r.seg); + assert!(r.snr > min_snr && r.seg > min_snr - 3.0, "SILK {bw:?} @ {rate}: SNR {:.1} / seg {:.1}", r.snr, r.seg); + } +} + +#[test] +fn silk_beats_celt_on_speech_at_low_rate() { + let x = speech(LEN, 5); + // Both measured against the full-band input. + let silk = mono(Mode::Silk, Bandwidth::Wideband, 12_000, &x, 24_000.0); + let celt = mono(Mode::Celt, Bandwidth::Fullband, 12_000, &x, 24_000.0); + eprintln!( + "12 kb/s mono speech: SILK WB {:.1} dB (seg {:.1}) at {:.1} kb/s vs CELT {:.1} dB (seg {:.1}) at {:.1} kb/s", + silk.snr, silk.seg, silk.kbps, celt.snr, celt.seg, celt.kbps + ); + assert!(silk.snr > celt.snr + 3.0, "SILK {:.1} dB vs CELT {:.1} dB", silk.snr, celt.snr); + assert!(silk.seg > celt.seg + 3.0, "SILK seg {:.1} dB vs CELT seg {:.1} dB", silk.seg, celt.seg); +} + +#[test] +fn hybrid_quality() { + let sp = speech(LEN, 9); + let mu = music(LEN); + for (name, x, bw, rate, min_snr) in [ + ("speech", &sp, Bandwidth::SuperWideband, 24_000u32, 18.0), + ("speech", &sp, Bandwidth::Fullband, 32_000, 20.0), + ("music", &mu, Bandwidth::SuperWideband, 24_000, 9.0), + ("music", &mu, Bandwidth::Fullband, 32_000, 10.0), + ] { + let r = mono(Mode::Hybrid, bw, rate, x, 24_000.0); + eprintln!("hybrid {bw:?} {name} @ {} kb/s: {:.1} kb/s, SNR {:.1} dB, segSNR {:.1} dB", rate / 1000, r.kbps, r.snr, r.seg); + assert!(r.snr > min_snr, "hybrid {bw:?} {name}: SNR {:.1} dB", r.snr); + // Constant packet size. + assert!(r.packets.iter().all(|p| p.len() == rate as usize / 400)); + } +} + +#[test] +fn silk_music_quality() { + let x = music(LEN); + for (rate, min_snr) in [(12_000u32, 5.0), (16_000, 8.0), (24_000, 13.0)] { + let r = mono(Mode::Silk, Bandwidth::Wideband, rate, &x, 7_000.0); + eprintln!("SILK WB music @ {} kb/s: {:.1} kb/s, SNR {:.1} dB", rate / 1000, r.kbps, r.snr); + assert!(r.snr > min_snr, "SILK music @ {rate}: SNR {:.1} dB", r.snr); + } +} + +#[test] +fn bitrate_tracking() { + let sp = speech(LEN, 13); + let mu = music(LEN); + for (name, x) in [("speech", &sp), ("music", &mu)] { + for (mode, bw, rate) in [ + (Mode::Silk, Bandwidth::Narrowband, 6_000u32), + (Mode::Silk, Bandwidth::Narrowband, 9_000), + (Mode::Silk, Bandwidth::Mediumband, 11_000), + (Mode::Silk, Bandwidth::Wideband, 12_000), + (Mode::Silk, Bandwidth::Wideband, 20_000), + (Mode::Silk, Bandwidth::Wideband, 32_000), + (Mode::Hybrid, Bandwidth::SuperWideband, 20_000), + (Mode::Hybrid, Bandwidth::Fullband, 36_000), + ] { + let r = mono(mode, bw, rate, x, band_edge(bw)); + let ratio = r.kbps * 1000.0 / rate as f64; + eprintln!( + "{name} {mode:?}/{bw:?} target {} kb/s: actual {:.2} kb/s ({:+.0} %), SNR {:.1} dB", + rate / 1000, + r.kbps, + (ratio - 1.0) * 100.0, + r.snr + ); + assert!((0.75..=1.25).contains(&ratio), "{name} {mode:?}/{bw:?} @ {rate}: {:.2} kb/s", r.kbps); + } + } +} + +#[test] +fn stereo_silk_and_hybrid() { + let sp = speech(LEN + 40_000, 21); + let a = sp[..LEN].to_vec(); + let b: Vec = sp[40_000..].iter().map(|v| v * 0.7).collect(); + let mu = music(LEN); + let cases: Vec<(&str, Vec, Vec)> = vec![ + ("dual mono", a.clone(), a.clone()), + ("panned", a.iter().map(|v| v * 0.9).collect(), a.iter().map(|v| v * 0.35).collect()), + ("independent", a.clone(), b), + ("speech+music", a.iter().zip(&mu).map(|(x, y)| 0.8 * x + 0.3 * y).collect(), a.iter().zip(&mu).map(|(x, y)| 0.3 * x + 0.8 * y).collect()), + ]; + for (name, l, r) in &cases { + for (mode, bw, rate, min_snr) in [(Mode::Silk, Bandwidth::Wideband, 32_000u32, 9.0), (Mode::Hybrid, Bandwidth::Fullband, 64_000, 9.0)] { + let fc = band_edge(bw); + let res = run(OpusEncoder::with_mode(2, rate, mode, bw).unwrap(), &[l.clone(), r.clone()], &[lowpass(l, fc), lowpass(r, fc)]); + eprintln!( + "stereo {name} {mode:?}/{bw:?} @ {} kb/s: {:.1} kb/s, L {:.1} dB, R {:.1} dB", + rate / 1000, + res[0].kbps, + res[0].snr, + res[1].snr + ); + assert!(res[0].snr > min_snr && res[1].snr > min_snr, "stereo {name} {mode:?}: L {:.1} R {:.1}", res[0].snr, res[1].snr); + } + } +} + +#[test] +fn application_tables_and_pre_skip() { + let cases = [ + (Application::Voip, 1u8, 8_000u32, Mode::Silk, Bandwidth::Narrowband), + (Application::Voip, 1, 11_000, Mode::Silk, Bandwidth::Mediumband), + (Application::Voip, 1, 16_000, Mode::Silk, Bandwidth::Wideband), + (Application::Voip, 1, 24_000, Mode::Hybrid, Bandwidth::SuperWideband), + (Application::Voip, 1, 32_000, Mode::Hybrid, Bandwidth::Fullband), + (Application::Voip, 1, 48_000, Mode::Celt, Bandwidth::Fullband), + (Application::Voip, 2, 32_000, Mode::Silk, Bandwidth::Wideband), + (Application::Audio, 1, 12_000, Mode::Silk, Bandwidth::Wideband), + (Application::Audio, 1, 24_000, Mode::Hybrid, Bandwidth::Fullband), + (Application::Audio, 2, 48_000, Mode::Hybrid, Bandwidth::Fullband), + (Application::Audio, 1, 64_000, Mode::Celt, Bandwidth::Fullband), + (Application::Audio, 2, 128_000, Mode::Celt, Bandwidth::Fullband), + ]; + for (app, c, rate, mode, bw) in cases { + let e = OpusEncoder::with_application(c, rate, app).unwrap(); + assert_eq!((e.mode(), e.bandwidth(), e.application()), (mode, bw, app), "{app:?} {c} ch @ {rate}"); + assert_eq!(e.pre_skip(), 120, "every mode is aligned to a 120-sample delay"); + } + assert_eq!(OpusEncoder::new(2, 128_000).unwrap().mode(), Mode::Celt); + assert_eq!(OpusEncoder::new(1, 12_000).unwrap().application(), Application::Audio); +} + +/// EffectCraft's encoder panicked here; craft-audio returns an error (never crash). +#[test] +fn invalid_mode_bandwidth_is_an_error() { + assert_eq!(OpusEncoder::with_mode(1, 16_000, Mode::Silk, Bandwidth::Fullband).err(), Some(Error::ModeBandwidth(Mode::Silk, Bandwidth::Fullband))); + assert_eq!(OpusEncoder::new(3, 128_000).err(), Some(Error::Channels(3))); + assert_eq!(OpusEncoder::new(0, 128_000).err(), Some(Error::Channels(0))); + // From 86 channels the per-channel minimum bitrate passes the 510 kb/s cap. + for channels in [86, u8::MAX] { + assert_eq!(OpusEncoder::new(channels, 128_000).err(), Some(Error::Channels(channels))); + let app = OpusEncoder::with_application(channels, 128_000, Application::Voip).err(); + assert_eq!(app, Some(Error::Channels(channels))); + } +} + +#[test] +fn extreme_inputs_stay_decodable() { + let len = 24_000; + let mut s = 1u32; + let noise: Vec = (0..len) + .map(|_| { + s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (s >> 8) as f32 / (1u32 << 23) as f32 - 1.0 + }) + .collect(); + let square: Vec = (0..len).map(|i| if (i / 60) % 2 == 0 { 0.999 } else { -0.999 }).collect(); + let clicks: Vec = (0..len).map(|i| if i % 4_801 == 0 { 1.0 } else { 0.0 }).collect(); + let dc = vec![0.7f32; len]; + let nan: Vec = (0..len).map(|i| if i % 1000 == 0 { f32::NAN } else { 0.1 * (i as f32 * 0.01).sin() }).collect(); + for (name, x) in [("noise", &noise), ("square", &square), ("clicks", &clicks), ("dc", &dc), ("nan", &nan)] { + for (mode, bw, rate) in + [(Mode::Silk, Bandwidth::Narrowband, 6_000u32), (Mode::Silk, Bandwidth::Wideband, 40_000), (Mode::Hybrid, Bandwidth::Fullband, 24_000)] + { + for c in [1usize, 2] { + let planar: Vec> = if c == 1 { vec![x.clone()] } else { vec![x.clone(), x.iter().map(|v| -0.5 * v).collect()] }; + let (enc, packets) = encode(OpusEncoder::with_mode(c as u8, rate * c as u32, mode, bw).unwrap(), &planar); + assert!(packets.iter().all(|p| p.len() >= 3 && p.len() <= 1276), "{name} {mode:?}: packet sizes"); + let dec = decode(&enc.opus_head(), &packets); + let peak = dec.iter().flatten().fold(0f32, |m, v| m.max(v.abs())); + let snr = if name == "nan" { f64::NAN } else { snr_search(&planar[0], &dec[0], 2).0 }; + eprintln!("{name} {mode:?}/{bw:?} {c} ch: peak {peak:.2}, SNR {snr:.1} dB, {:.1} kb/s", bitrate(&packets) / 1000.0); + assert!(dec.iter().all(|ch| ch.iter().all(|v| v.is_finite() && v.abs() <= 4.0)), "{name} {mode:?}/{bw:?} {c} ch: bad output"); + } + } + } +} + +#[test] +fn silk_and_hybrid_silence() { + for (mode, bw, rate) in [(Mode::Silk, Bandwidth::Wideband, 16_000u32), (Mode::Hybrid, Bandwidth::Fullband, 32_000)] { + let x = vec![0f32; 48_000]; + let (enc, packets) = encode(OpusEncoder::with_mode(1, rate, mode, bw).unwrap(), std::slice::from_ref(&x)); + let dec = decode(&enc.opus_head(), &packets); + let peak = dec[0].iter().fold(0f32, |m, v| m.max(v.abs())); + let kbps = bitrate(&packets) / 1000.0; + eprintln!("{mode:?} silence: {kbps:.2} kb/s, decoded peak {peak:.2e}"); + assert!(peak < 1e-3, "{mode:?} silence decodes to peak {peak}"); + if mode == Mode::Silk { + assert!(kbps < 4.0, "SILK silence costs {kbps:.2} kb/s"); + } + } +} diff --git a/crates/audio/tests/codecs/track_decoder.rs b/crates/audio/tests/codecs/track_decoder.rs index 0607db4ac..fee8f1f97 100644 --- a/crates/audio/tests/codecs/track_decoder.rs +++ b/crates/audio/tests/codecs/track_decoder.rs @@ -1,7 +1,8 @@ //! `TrackDecoder` on streams an app's own demuxer hands over without codec configuration: -//! ADTS-framed AAC (MPEG-TS). +//! ADTS-framed AAC (MPEG-TS) and Opus without an `OpusHead` (RTP). use craft_audio::aac::{Encoder as AacEncoder, EncoderConfig}; +use craft_audio::opus::Encoder as OpusEncoder; use craft_audio::{CodecId, CodecParams, TrackDecoder}; fn tone(n: usize) -> Vec { @@ -32,3 +33,20 @@ fn adts_aac_without_extradata() { let raw = &units[0][7..]; assert!(TrackDecoder::new(&CodecParams::new(CodecId::Aac), &[raw]).is_err()); } + +#[test] +fn opus_without_opus_head() { + let mut enc = OpusEncoder::new(1, 64_000).unwrap(); + let pcm = tone(OpusEncoder::FRAME_SIZE * 10); + let packets: Vec> = pcm.chunks(OpusEncoder::FRAME_SIZE).map(|f| enc.encode_float(f).unwrap()).collect(); + + let params = CodecParams::new(CodecId::Opus).with_channels(1).with_sample_rate(48_000); + let mut dec = TrackDecoder::new(¶ms, &[]).unwrap(); + assert_eq!((dec.sample_rate(), dec.channels()), (48_000, 1)); + for p in &packets { + assert_eq!(dec.decode(p).unwrap().frames(), OpusEncoder::FRAME_SIZE); + } + + // More than two channels need the mapping an OpusHead carries. + assert!(TrackDecoder::new(&CodecParams::new(CodecId::Opus).with_channels(6), &[]).is_err()); +} diff --git a/crates/csv/README.md b/crates/csv/README.md index c677ec9eb..6369ce8b4 100644 --- a/crates/csv/README.md +++ b/crates/csv/README.md @@ -11,7 +11,7 @@ mapping a SYLK sheet onto a document are the caller's work. | Function | What it does | |---|---| | `decode_text(bytes)` | UTF-8 and UTF-16 byte order marks; without one, valid UTF-8 is kept; other bytes are Windows-1256 when they read as Arabic, else Windows-1252 | -| `sniff_delimiter(text, quote)` | comma, semicolon or tab: the one with the most consistent count per line over the first 20 non-blank lines, outside quotes | +| `sniff_delimiter(text, quote)` | comma, semicolon or tab: the one with the most consistent field count over the first 20 non-blank records in a 64 KiB sample; quote boundaries must be coherent, and ties prefer tab, comma, then semicolon | | `parse_records(text, delim, quote, max_fields, on_record)` | records of fields, one callback per record (return `false` to stop); CR, LF and CRLF line ends; quoted fields with doubled quotes and line breaks; a quote in the middle of a field is text | | `records(text, delim, quote, max_records, max_fields)` | the same, collected | | `push_field(out, field, delim)`, `field_len(field, delim)` | one field as written, quoted only when it holds the delimiter, a quote or a line break; its exact length, to enforce a size cap before writing | diff --git a/crates/csv/src/decode.rs b/crates/csv/src/decode.rs index 238f7f18e..63b4bb590 100644 --- a/crates/csv/src/decode.rs +++ b/crates/csv/src/decode.rs @@ -19,6 +19,16 @@ const CP1256: [u32; 128] = [ 0x200F, 0x06D2, ]; +/// Decode strictly as UTF-8, stripping a leading UTF-8 BOM when present. +/// +/// Unlike [`decode_text`], this does not guess a legacy encoding or accept UTF-16. It is useful +/// when a format or application contract requires UTF-8 and malformed bytes must be rejected. +pub fn decode_utf8(bytes: &[u8]) -> Result { + let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + let text = std::str::from_utf8(bytes)?; + Ok(text.strip_prefix('\u{FEFF}').unwrap_or(text).to_string()) +} + /// Evidence that bytes which aren't UTF-8 are Windows-1256 Arabic rather than Windows-1252. #[derive(Default)] struct Cp1256Evidence { @@ -165,6 +175,16 @@ mod tests { assert_eq!(decode_text(&[0xFF, 0xFE, 0x00, 0xD8]), "\u{FFFD}", "a lone surrogate"); } + #[test] + fn strict_utf8_strips_bom_and_rejects_legacy_or_malformed_bytes() { + assert_eq!(decode_utf8(b"\xEF\xBB\xBFName,City\nAda,Town\n").unwrap(), "Name,City\nAda,Town\n"); + assert_eq!(decode_utf8("café".as_bytes()).unwrap(), "café"); + assert!(decode_utf8(&[0xFF, 0xFE, 0x00]).is_err()); + assert!(decode_utf8(b"\xEF\xBB\xBF\xFF").is_err()); + // The permissive text decoder still accepts legacy encodings for callers that want it. + assert_eq!(decode_text(&[0x80, b'a', 0xE9]), "€aé"); + } + #[test] fn windows_1256_table_matches_the_unicode_mapping() { let at = |b: u8| CP1256.get(usize::from(b - 0x80)).and_then(|&c| char::from_u32(c)); diff --git a/crates/csv/src/lib.rs b/crates/csv/src/lib.rs index a773f066f..0aec971ac 100644 --- a/crates/csv/src/lib.rs +++ b/crates/csv/src/lib.rs @@ -3,6 +3,7 @@ //! //! - [`decode_text`] turns a file's bytes into a string: byte order marks, UTF-8, else //! Windows-1256 when the bytes read as Arabic and Windows-1252 otherwise. +//! - [`decode_utf8`] requires valid UTF-8 and strips a leading UTF-8 byte order mark. //! - [`sniff_delimiter`] picks comma, semicolon or tab. //! - [`parse_records`] splits text into records of fields (RFC 4180 with the usual leniency), //! handing each record to a callback, with a cap on the fields kept per record. [`records`] @@ -27,46 +28,98 @@ mod decode; pub mod sylk; -pub use decode::decode_text; +pub use decode::{decode_text, decode_utf8}; -/// Picks comma, semicolon or tab: the candidate with the most consistent count per line -/// (outside quotes) over the first non-blank lines. A candidate must appear on every such line, otherwise comma wins. +/// Picks comma, semicolon or tab by consistent field counts in the first non-blank records. +/// Quotes start only at a candidate field boundary; candidates with incoherent quoted fields +/// are discarded when another coherent candidate separates the records. Ties prefer tab, +/// then comma, then semicolon. Comma is the fallback when no candidate separates the text. pub fn sniff_delimiter(text: &str, quote: char) -> u8 { - let mut best = (b',', 0usize, 0usize); - for d in [',', ';', '\t'] { - let mut counts = vec![]; - let mut n = 0usize; - let mut in_q = false; - let mut blank = true; - for ch in text.chars().take(64 * 1024) { - if !in_q && ch == '\n' { - // Blank lines carry no delimiter evidence. - if !blank { - counts.push(n); - } - n = 0; - blank = true; - if counts.len() >= 20 { - break; + let mut candidates = [(b'\t', 0usize, 0usize, true), (b',', 0, 0, true), (b';', 0, 0, true)]; + const SAMPLE_BYTES: usize = 64 * 1024; + let mut sample_end = text.len().min(SAMPLE_BYTES); + while !text.is_char_boundary(sample_end) { + sample_end -= 1; + } + let sample = &text[..sample_end]; + let truncated = sample_end < text.len(); + for (delimiter, min_count, total, coherent) in &mut candidates { + let mut counts = Vec::new(); + let mut count = 0usize; + let mut quoted = false; + let mut after_quote = false; + let mut field_start = true; + let mut record_has_content = false; + let mut record_coherent = true; + let mut candidate_coherent = true; + let mut reached_record_limit = false; + let mut chars = sample.chars().peekable(); + while let Some(ch) = chars.next() { + if quoted { + if ch == quote { + if chars.peek() == Some("e) { + chars.next(); + record_has_content = true; + } else { + quoted = false; + after_quote = true; + } + } else if !ch.is_whitespace() { + record_has_content = true; } continue; } - if ch != '\r' { - blank = false; + if ch == '\r' || ch == '\n' { + if ch == '\r' && chars.peek() == Some(&'\n') { + chars.next(); + } + if record_has_content { + counts.push(count); + candidate_coherent &= record_coherent; + if counts.len() >= 20 { + reached_record_limit = true; + break; + } + } + count = 0; + quoted = false; + after_quote = false; + field_start = true; + record_has_content = false; + record_coherent = true; + continue; } - if ch == quote { - in_q = !in_q; - } else if !in_q && ch == d { - n += 1; + if ch == quote && field_start { + quoted = true; + field_start = false; + record_has_content = true; + } else if ch == char::from(*delimiter) { + count += 1; + field_start = true; + after_quote = false; + } else { + record_coherent &= !after_quote || ch.is_whitespace(); + record_has_content |= !ch.is_whitespace(); + field_start = false; } } - if !blank { - counts.push(n); + if !reached_record_limit && !truncated { + if quoted { + candidate_coherent = false; + } else if record_has_content { + candidate_coherent &= record_coherent; + counts.push(count); + } } - let min = counts.iter().copied().min().unwrap_or(0); - let total: usize = counts.iter().sum(); - if min > 0 && (min, total) > (best.1, best.2) { - best = (d as u8, min, total); + *coherent &= candidate_coherent; + *min_count = counts.iter().copied().min().unwrap_or(0); + *total = counts.iter().sum(); + } + let prefer_coherent = candidates.iter().any(|(_, min, _, coherent)| *min > 0 && *coherent); + let mut best = (b',', 0usize, 0usize); + for (delimiter, min, total, coherent) in candidates { + if min > 0 && (!prefer_coherent || coherent) && (min, total) > (best.1, best.2) { + best = (delimiter, min, total); } } best.0 @@ -204,6 +257,9 @@ mod tests { assert_eq!(sniff_delimiter("a\tb\n1\t2\n", '"'), b'\t'); assert_eq!(sniff_delimiter("a,b\n\"x;y;z\",2\n", '"'), b','); assert_eq!(sniff_delimiter("single", '"'), b','); + assert_eq!(sniff_delimiter("a,b\tc\n1,2\td\n", '"'), b'\t'); + assert_eq!(sniff_delimiter("a,b;c\n1,2;c\n", '"'), b','); + assert_eq!(sniff_delimiter("a;b\tc\n1;2\td\n", '"'), b'\t'); // A delimiter missing from some lines is text, not a separator (single-column export round trip). assert_eq!(sniff_delimiter("alpha;beta\r\ngamma\r\n", '"'), b','); assert_eq!(sniff_delimiter("alpha\tbeta\r\ngamma\r\n", '"'), b','); @@ -211,6 +267,48 @@ mod tests { assert_eq!(sniff_delimiter("x;y\n1,5;2", '"'), b';'); } + #[test] + fn delimiter_sniffing_preserves_candidate_quote_boundaries() { + for ending in ["\n", "\r\n", "\r"] { + for (text, expected) in [ + (format!("Name,\"preferred\tCity\"{ending}Ada\tLondon{ending}"), b'\t'), + (format!("\"Last, First\";City{ending}Ada;London{ending}"), b';'), + (format!("\"Name\";\"Last, First\"{ending}\"Ada\";\"Lovelace, Countess\"{ending}\"Grace\";\"Hopper, Admiral\"{ending}"), b';'), + (format!("\"Name\"suffix,Title\tCity{ending}\"Ada\"suffix,Analyst\tLondon\tignored{ending}"), b'\t'), + (format!("\r\n \r\n\"Name\";City{ending}Ada;London{ending}"), b';'), + ] { + assert_eq!(sniff_delimiter(&text, '"'), expected, "{text:?}"); + } + } + } + + #[test] + fn delimiter_sniffing_counts_empty_quoted_fields_and_ignores_whitespace_records() { + assert_eq!(sniff_delimiter("\"\";name\n\"\";Ada\n", '"'), b';'); + + let blank_rows = "\t \t\n".repeat(25); + assert_eq!(sniff_delimiter(&format!("{blank_rows}a;b\nc;d\n"), '"'), b';'); + } + + #[test] + fn delimiter_sniffing_does_not_count_the_twentieth_record_twice() { + let mut text = "a;b\tc\td\n".repeat(19); + text.push('a'); + text.push_str(&";".repeat(19)); + text.push_str("\tc\n"); + assert_eq!(sniff_delimiter(&text, '"'), b'\t'); + } + + #[test] + fn delimiter_sniffing_discards_an_incomplete_record_at_the_sample_boundary() { + let complete = "a\tb\tc\n1\t2\t3\n"; + let remaining = 64 * 1024 - complete.len(); + let partial = format!("{}x\t\"unfinished", "z".repeat(remaining - 4)); + assert!(partial.len() >= remaining); + let text = format!("{complete}{partial}\"\n"); + assert_eq!(sniff_delimiter(&text, '"'), b'\t'); + } + #[test] fn parser_stops_collecting_fields_at_the_cap() { let (max, extra) = (16_384usize, 50); diff --git a/crates/dav/src/lib.rs b/crates/dav/src/lib.rs index 87040feae..db137a050 100644 --- a/crates/dav/src/lib.rs +++ b/crates/dav/src/lib.rs @@ -74,7 +74,14 @@ pub fn parse_multistatus(bytes: &[u8]) -> Result { if !text.chars().all(craft_xml::is_xml_char) { return Err(Error::Characters); } - let limits = Limits { max_input: MAX_BYTES, max_depth: MAX_DEPTH, max_nodes: MAX_NODES, max_attrs: MAX_ATTRS, max_attrs_total: MAX_ATTRS_TOTAL }; + let limits = Limits { + max_input: MAX_BYTES, + max_depth: MAX_DEPTH, + max_nodes: MAX_NODES, + max_elements: MAX_NODES, + max_attrs: MAX_ATTRS, + max_attrs_total: MAX_ATTRS_TOTAL, + }; let options = Options { doctype: Doctype::Reject, ..Options::canonical(&CANONICAL).strict().with_limits(limits) }; let root = options.parse(bytes).map_err(Error::Xml)?; if root.name != "d:multistatus" { diff --git a/crates/designcraft-format/Cargo.toml b/crates/designcraft-format/Cargo.toml new file mode 100644 index 000000000..19557b7ae --- /dev/null +++ b/crates/designcraft-format/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "craft-designcraft-format" +description = "Versioned DesignCraft document JSON codec and legacy migrations over craft-document-bundle" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-document-bundle = { workspace = true } +craft-eps = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/designcraft-format/src/lib.rs b/crates/designcraft-format/src/lib.rs new file mode 100644 index 000000000..6256eab1c --- /dev/null +++ b/crates/designcraft-format/src/lib.rs @@ -0,0 +1,251 @@ +//! Versioned JSON and bundle codec for DesignCraft's native document format. +//! +//! The application owns its document model. This crate owns the on-disk schema version, +//! serialization, legacy JSON upgrades, version inference and the shared ZIP envelope. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Seek}; + +pub use craft_document_bundle::Asset; +use craft_document_bundle::Spec; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; + +pub const MIME: &str = "application/vnd.designcraft+zip"; +pub const VERSION: u32 = 4; +pub const EXTENSION: &str = "designcraft"; +pub const PREVIEW: &str = "preview.png"; +pub const MAX_PREVIEW_BYTES: u64 = 4 << 20; + +const BUNDLE: Spec<'static> = Spec { + mimetype_entry: "mimetype", + mimetype: MIME, + metadata_entry: "meta.json", + document_entry: "document.json", + preview_entry: PREVIEW, + assets_directory: "assets", + format_name: "designcraft", + legacy_assets_field: Some("assetData"), +}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("not a DesignCraft document: {0}")] + NotOurs(String), + #[error("document is from a newer DesignCraft (format {0}); please update")] + TooNew(u64), + #[error("{0}")] + Io(String), + #[error("document cannot be saved: {0}")] + Unsaveable(String), +} + +impl From for Error { + fn from(error: craft_document_bundle::Error) -> Self { + match error { + craft_document_bundle::Error::TooNew(version) => Error::TooNew(version), + craft_document_bundle::Error::NotBundle(message) => Error::NotOurs(message), + craft_document_bundle::Error::Read(message) | craft_document_bundle::Error::Write(message) => Error::Io(message), + } + } +} + +/// A decoded native document and the separately stored embedded assets. +#[derive(Clone, Debug, PartialEq)] +pub struct Loaded { + pub document: T, + pub assets: Vec<(String, Vec)>, +} + +/// The reader capability required by `document`, inferred from its neutral JSON schema and +/// embedded assets. Traversal beyond 64 levels conservatively requires version 4. +pub fn required_version(document: &Value, assets: &[Asset<'_>]) -> u32 { + if has_version_four_feature(document) { + return 4; + } + if assets.iter().any(|asset| craft_eps::is_eps(asset.data)) { 3 } else { 2 } +} + +/// Serialize a document and its external assets as a `.designcraft` ZIP bundle. +pub fn save(document: &T, generator: &str, preview: Option<&[u8]>, assets: &[Asset<'_>]) -> Result, Error> { + let value = serde_json::to_value(document).map_err(|error| Error::Io(error.to_string()))?; + let version = required_version(&value, assets); + let json = serde_json::to_vec_pretty(&value).map_err(|error| Error::Io(error.to_string()))?; + craft_document_bundle::write(&json, version, generator, preview, assets, BUNDLE).map_err(Into::into) +} + +/// Read a `.designcraft` ZIP or legacy single-file JSON, applying all versioned schema migrations +/// before deserializing the caller's model. +pub fn load(bytes: &[u8]) -> Result, Error> { + let mut package = craft_document_bundle::read(bytes, VERSION, BUNDLE)?; + upgrade_legacy_json(&mut package.document, package.version); + let document = serde_json::from_value(package.document).map_err(|error| Error::NotOurs(error.to_string()))?; + Ok(Loaded { document, assets: package.assets }) +} + +/// Whether `bytes` are a native DesignCraft ZIP package. +pub fn is_designcraft(bytes: &[u8]) -> bool { + craft_document_bundle::has_document(bytes, BUNDLE) +} + +/// Whether `prefix` begins with this format's stored mimetype entry. +pub fn starts_designcraft(prefix: &[u8]) -> bool { + craft_document_bundle::starts_with_mimetype(prefix, BUNDLE) +} + +/// Read the first-page PNG preview from a ZIP package, with a fixed 4 MiB cap. +pub fn read_preview(reader: R) -> Option> { + craft_document_bundle::read_preview_from(reader, PREVIEW) +} + +fn has_version_four_feature(document: &Value) -> bool { + let mut stack = vec![(document, 0usize)]; + let mut visited = 0usize; + while let Some((value, depth)) = stack.pop() { + if depth > 64 || visited >= 1_000_000 { + return true; + } + visited += 1; + match value { + Value::Object(object) => { + if object.get("layerOptions").is_some_and(|options| !options.is_null()) + || (object.get("pdfLayerOverride").and_then(Value::as_bool) == Some(true) + && object.get("pdfHiddenLayers").and_then(Value::as_array).is_none_or(Vec::is_empty)) + { + return true; + } + stack.extend(object.values().map(|child| (child, depth + 1))); + } + Value::Array(children) => stack.extend(children.iter().map(|child| (child, depth + 1))), + _ => {} + } + } + false +} + +fn upgrade_legacy_json(document: &mut Value, from: u64) { + let mut vertical = HashSet::new(); + vertical_frames(document, &mut vertical, 0); + if let Some(stories) = document.get_mut("stories").and_then(Value::as_object_mut) { + for story in stories.values_mut() { + let first = story.get("frames").and_then(|frames| frames.get(0)).and_then(Value::as_u64); + if let (Some(frame), Some(story)) = (first, story.as_object_mut()) + && vertical.contains(&frame) + && !story.contains_key("vertical") + { + story.insert("vertical".into(), Value::Bool(true)); + } + } + } + if from < 2 { + pin_paragraph_defaults(document); + } +} + +fn vertical_frames(value: &Value, out: &mut HashSet, depth: usize) { + if depth > 256 { + return; + } + match value { + Value::Object(object) => { + if let (Some(id), Some(content)) = (object.get("id").and_then(Value::as_u64), object.get("content")) + && content.get("type").and_then(Value::as_str) == Some("text") + && content.get("options").and_then(|options| options.get("vertical")).and_then(Value::as_bool) == Some(true) + { + out.insert(id); + } + for child in object.values() { + vertical_frames(child, out, depth + 1); + } + } + Value::Array(children) => { + for child in children { + vertical_frames(child, out, depth + 1); + } + } + _ => {} + } +} + +fn pin_paragraph_defaults(document: &mut Value) { + const MAX_STYLE_DEPTH: usize = 64; + let styles = document.pointer("/styles/paragraph").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut by_name: HashMap, bool, bool)> = HashMap::new(); + for style in &styles { + let Some(name) = style.get("name").and_then(Value::as_str) else { continue }; + let based_on = style.get("basedOn").and_then(Value::as_str).map(str::to_string); + let attrs = style.get("para"); + let missing_keep = attrs.and_then(|attrs| attrs.get("keepAllLines")).is_none(); + let missing_drop_cap = attrs.and_then(|attrs| attrs.get("dropCapAlignLeft")).is_none(); + by_name.entry(name.to_string()).or_insert((based_on, missing_keep, missing_drop_cap)); + } + + let mut roots = HashSet::new(); + for name in by_name.keys() { + let mut chain = Vec::new(); + let mut seen = HashSet::new(); + let mut current = Some(name.as_str()); + while let Some(style_name) = current { + let Some((based_on, missing_keep, missing_drop_cap)) = by_name.get(style_name) else { break }; + if chain.len() >= MAX_STYLE_DEPTH || !seen.insert(style_name.to_string()) { + break; + } + chain.push((style_name, *missing_keep, *missing_drop_cap)); + current = based_on.as_deref(); + } + if !chain.is_empty() + && chain.iter().all(|(_, keep, drop_cap)| *keep && *drop_cap) + && let Some((root, _, _)) = chain.last() + { + roots.insert((*root).to_string()); + } + } + if let Some(styles) = document.pointer_mut("/styles/paragraph").and_then(Value::as_array_mut) { + for style in styles { + if let Some(style) = style.as_object_mut() + && style.get("name").and_then(Value::as_str).is_some_and(|name| roots.contains(name)) + { + let attrs = style.entry("para").or_insert_with(|| Value::Object(Default::default())); + if let Some(attrs) = attrs.as_object_mut() { + attrs.entry("keepAllLines").or_insert(Value::Bool(true)); + attrs.entry("dropCapAlignLeft").or_insert(Value::Bool(false)); + } + } + } + } + let known_styles: HashSet = by_name.into_keys().collect(); + pin_unstyled_paragraphs(document, &known_styles, 0); +} + +fn pin_unstyled_paragraphs(value: &mut Value, known_styles: &HashSet, depth: usize) { + if depth > 256 { + return; + } + match value { + Value::Object(object) => { + if let Some(style) = object.get("style").and_then(Value::as_str) + && !known_styles.contains(style) + { + let attrs = object.entry("para").or_insert_with(|| Value::Object(Default::default())); + if let Some(attrs) = attrs.as_object_mut() { + attrs.entry("keepAllLines").or_insert(Value::Bool(true)); + attrs.entry("dropCapAlignLeft").or_insert(Value::Bool(false)); + } + } + for child in object.values_mut() { + pin_unstyled_paragraphs(child, known_styles, depth + 1); + } + } + Value::Array(children) => { + for child in children { + pin_unstyled_paragraphs(child, known_styles, depth + 1); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/designcraft-format/src/tests.rs b/crates/designcraft-format/src/tests.rs new file mode 100644 index 000000000..0ef2ca191 --- /dev/null +++ b/crates/designcraft-format/src/tests.rs @@ -0,0 +1,72 @@ +use super::*; +use serde_json::json; + +#[test] +fn native_bundle_roundtrips_document_assets_and_preview() -> Result<(), Box> { + let document = json!({"title":"A page", "assets":{"7":{"mime":"image/png"}}}); + let image = [1u8, 2, 3, 4]; + let assets = [Asset { id: "7", mime: "image/png", data: &image }]; + let preview = b"preview png"; + let bytes = save(&document, "DesignCraft test", Some(preview), &assets)?; + + assert!(is_designcraft(&bytes)); + assert!(starts_designcraft(&bytes[..bytes.len().min(80)])); + let loaded = load::(&bytes)?; + assert_eq!(loaded.document, document); + assert_eq!(loaded.assets, [("7".to_owned(), image.to_vec())]); + assert_eq!(read_preview(std::io::Cursor::new(&bytes)).as_deref(), Some(&preview[..])); + Ok(()) +} + +#[test] +fn preview_reader_rejects_an_entry_over_its_declared_read_budget() -> Result<(), Box> { + let oversized = vec![0u8; usize::try_from(MAX_PREVIEW_BYTES + 1)?]; + let bytes = save(&json!({}), "DesignCraft test", Some(&oversized), &[])?; + assert_eq!(read_preview(std::io::Cursor::new(bytes)), None); + Ok(()) +} + +#[test] +fn required_version_preserves_the_reader_capability_contract() { + assert_eq!(required_version(&json!({"title":"plain"}), &[]), 2); + assert_eq!(required_version(&json!({}), &[Asset { id: "1", mime: "application/postscript", data: b"%!PS-Adobe-3.0 EPSF-3.0" }]), 3); + assert_eq!(required_version(&json!({"layerOptions":{}}), &[]), 4); + assert_eq!(required_version(&json!({"pdfLayerOverride":true,"pdfHiddenLayers":[]}), &[]), 4); + assert_eq!(required_version(&json!({"pdfLayerOverride":true,"pdfHiddenLayers":["guide"]}), &[]), 2); + + let mut nested = json!(null); + for _ in 0..65 { + nested = json!([nested]); + } + assert_eq!(required_version(&nested, &[]), 4); +} + +#[test] +fn legacy_json_extracts_assets_and_applies_v1_migrations() -> Result<(), Box> { + let legacy = json!({ + "assetData":{"7":"AQID"}, + "frames":[{"id":8,"content":{"type":"text","options":{"vertical":true}}}], + "stories":{"vertical":{"frames":[8]},"explicit":{"frames":[8],"vertical":false}}, + "styles":{"paragraph":[{"name":"Base"},{"name":"Child","basedOn":"Base","para":{"keepAllLines":false}}]}, + "paragraphs":[{"style":"Missing"}] + }); + let loaded = load::(&serde_json::to_vec(&legacy)?)?; + + assert_eq!(loaded.assets, [("7".to_owned(), vec![1, 2, 3])]); + assert_eq!(loaded.document.get("assetData"), None); + assert_eq!(loaded.document["stories"]["vertical"]["vertical"], true); + assert_eq!(loaded.document["stories"]["explicit"]["vertical"], false); + assert_eq!(loaded.document["styles"]["paragraph"][0]["para"]["keepAllLines"], true); + assert_eq!(loaded.document["styles"]["paragraph"][0]["para"]["dropCapAlignLeft"], false); + assert_eq!(loaded.document["styles"]["paragraph"][1]["para"]["keepAllLines"], false); + assert_eq!(loaded.document["paragraphs"][0]["para"]["keepAllLines"], true); + Ok(()) +} + +#[test] +fn newer_metadata_precedes_malformed_document_json() -> Result<(), Box> { + let bytes = craft_document_bundle::write(b"{", VERSION + 1, "future", None, &[], BUNDLE)?; + assert!(matches!(load::(&bytes), Err(Error::TooNew(version)) if version == u64::from(VERSION + 1))); + assert!(matches!(load::(b"not a package"), Err(Error::NotOurs(_)))); + Ok(()) +} diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index ff953f43d..bb86755d9 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -373,6 +373,7 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res max_depth: MAX_DEPTH + 1, // Text stretches count as nodes too: two for each element. max_nodes: 2 * MAX_ELEMENTS, + max_elements: MAX_ELEMENTS, max_attrs: MAX_ATTRS, max_attrs_total: 2 * MAX_ELEMENTS, }; diff --git a/crates/dwg/Cargo.toml b/crates/dwg/Cargo.toml new file mode 100644 index 000000000..d2bc8ffff --- /dev/null +++ b/crates/dwg/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "craft-dwg" +description = "Bounded DWG↔DXF byte bridge with an optional child-process worker" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +# acadrust is used unmodified and only on targets supported by its 0.6.6 platform APIs. +[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dependencies] +acadrust = { workspace = true } +log = { workspace = true } diff --git a/crates/dwg/README.md b/crates/dwg/README.md new file mode 100644 index 000000000..ed52bdcfc --- /dev/null +++ b/crates/dwg/README.md @@ -0,0 +1,18 @@ +# craft-dwg + +A byte-level bridge between DWG and DXF. It does not expose `acadrust` types: callers pass bytes, +choose limits, and receive converted bytes plus notes about data the conversion could not preserve. +Application document mapping remains with the caller. + +The default limits reject DWG inputs over 512 MiB and stop DXF output over 1 GiB. The bridge +also catches panics from the dependency and validates its conversion diagnostics. Because a small +hostile file can declare very large internal sections, size checks alone cannot prevent an +allocation failure inside the decoder. On native applications, call `use_own_worker()` during +startup and call `serve_if_asked()` before normal argument handling. Subsequent `decode` and +`encode_*` calls then run in a child process with a 30-minute timeout; a crash or stack overflow +is contained to that child. The worker has no operating-system memory limit yet. Without a +configured worker, conversions run in the current process. + +The bridge is not built for `wasm32` or FreeBSD/aarch64. Those targets retain the public API and +return a clear unsupported-build error. The `acadrust` 0.6.6 dependency is used unmodified under +MPL-2.0; its license and third-party notices are in `licenses/dwg/` at the workspace root. diff --git a/crates/dwg/build.rs b/crates/dwg/build.rs new file mode 100644 index 000000000..e97782ab4 --- /dev/null +++ b/crates/dwg/build.rs @@ -0,0 +1,12 @@ +//! Sets `no_dwg_bridge` on the targets built without acadrust (see Cargo.toml): the DWG functions +//! there return an error that says to use DXF. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo::rustc-check-cfg=cfg(no_dwg_bridge)"); + let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if arch == "wasm32" || (os == "freebsd" && arch == "aarch64") { + println!("cargo::rustc-cfg=no_dwg_bridge"); + } +} diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs new file mode 100644 index 000000000..2db7afeae --- /dev/null +++ b/crates/dwg/src/lib.rs @@ -0,0 +1,2096 @@ +//! DWG support through a DXF bridge. +//! +//! DWG files are read with the `acadrust` crate (MPL-2.0, used unmodified as a dependency) and +//! converted to DXF bytes for the caller's DXF reader; saving goes the other way. Keeping the +//! dependency behind this byte-level API prevents its document types from leaking to consumers. +//! Native targets only (the dependency memory-maps files). +//! +//! # Guardrails around the third-party decoder +//! +//! The decoder allocates tables and section buffers from sizes a (possibly damaged or hostile) +//! file declares, and once it starts it cannot be interrupted (an allocation failure aborts the +//! process, which `catch_unwind` cannot recover). Because the dependency is used unmodified, this +//! crate guards it from the outside: +//! +//! - Both directions reject oversized input *before* the decoder runs ([`Limits::max_dwg_bytes`] +//! for DWG→DXF, [`Limits::max_dxf_bytes`] for DXF→DWG), and the DWG→DXF output is capped while +//! it is written (`CappedWriter`). +//! - The reader and writer run inside `catch_unwind`, so a decode failure is reported instead of +//! crashing. +//! +//! These bound the input- and output-size variants. A small file that declares a huge internal +//! table or section can still make the dependency allocate before any size is known, so the app +//! runs both directions in a child process ([`worker`]): what that kills is the child. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +/// DWG version codes we can write. +pub const VERSIONS: &[(&str, &str)] = + &[("AC1015", "2000"), ("AC1018", "2004"), ("AC1021", "2007"), ("AC1024", "2010"), ("AC1027", "2013"), ("AC1032", "2018")]; + +/// True when the bytes look like a DWG file (`AC10xx` magic). +pub fn is_dwg(bytes: &[u8]) -> bool { + bytes.len() > 6 && bytes.starts_with(b"AC10") && bytes.get(4..6).is_some_and(|v| v.iter().all(u8::is_ascii_digit)) +} + +/// The DWG version code of a file (e.g. "AC1032"). +pub fn version(bytes: &[u8]) -> Option { + is_dwg(bytes).then(|| String::from_utf8_lossy(bytes.get(0..6).unwrap_or_default()).to_string()) +} + +/// Size limits for reading a DWG file. DWG is compressed and the DXF text it converts to is +/// several times larger (about 6x in our tests: a 10 MB DWG becomes about 60 MB of DXF), so +/// the limits are generous for real drawings. They exist because a damaged file, or one the reader +/// misreads, can make the DWG reader produce a drawing thousands of times larger than the file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + /// Largest DWG file read at all (checked before reading starts). + pub max_dwg_bytes: usize, + /// Largest DXF rendition of a DWG (checked while it is written). + pub max_dxf_bytes: usize, +} + +impl Limits { + /// 512 MB of DWG, 1 GB of DXF (more than the DXF reader's group-code limit fills). + pub const DEFAULT: Limits = Limits { max_dwg_bytes: 512 << 20, max_dxf_bytes: 1 << 30 }; +} + +/// A byte count in megabytes for messages ("9.9 MB", "1024 MB"). +#[cfg_attr(no_dwg_bridge, allow(dead_code))] +fn mb(n: u64) -> String { + let m = n as f64 / f64::from(1u32 << 20); + if m >= 100.0 { format!("{m:.0} MB") } else { format!("{m:.1} MB") } +} + +#[cfg(not(no_dwg_bridge))] +pub mod worker; +#[cfg(not(no_dwg_bridge))] +pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_kept_noted, encode_noted, serve_if_asked, use_own_worker, use_worker}; +#[cfg(not(no_dwg_bridge))] +mod proxy_transport; + +#[cfg(not(no_dwg_bridge))] +mod native { + use std::io::{Cursor, Write}; + use std::panic::AssertUnwindSafe; + + use super::{Limits, mb}; + + /// Read DWG bytes and return an ASCII DXF rendition, within [`Limits::DEFAULT`]. + pub fn dwg_to_dxf(bytes: &[u8]) -> Result, String> { + dwg_to_dxf_with(bytes, Limits::DEFAULT) + } + + /// [`dwg_to_dxf`] with explicit limits. Both are checked before the expensive step they + /// guard: the input size before the DWG reader runs, the DXF size while it is written, so a + /// runaway conversion stops at the limit instead of filling memory first. + pub fn dwg_to_dxf_with(bytes: &[u8], limits: Limits) -> Result, String> { + dwg_to_dxf_noted(bytes, limits).map(|(dxf, _)| dxf) + } + + /// [`dwg_to_dxf_with`], also returning notes on what the read left out. The reader runs in + /// failsafe mode: an object record it can't decode is skipped instead of failing the whole + /// file, and the notes say how many were skipped and of which types, so a partial read never + /// passes for a complete one. + pub fn dwg_to_dxf_noted(bytes: &[u8], limits: Limits) -> Result<(Vec, Vec), String> { + if bytes.len() > limits.max_dwg_bytes { + return Err(format!( + "DWG: the file is {}, larger than the {} accepted by this bridge", + mb(bytes.len() as u64), + mb(limits.max_dwg_bytes as u64) + )); + } + let outcome = std::panic::catch_unwind(|| { + acadrust::DwgReader::from_stream_with_options(Cursor::new(bytes), acadrust::DwgReadOptions::failsafe()).read_with_stats() + }) + .map_err(|_| "the DWG reader failed on this file".to_string())? + .map_err(|e| format!("DWG: {e}"))?; + // Failsafe skips records, not whole files: when the objects stream itself couldn't be + // read (a damaged header or section map, or not a DWG at all), nothing was read. + if !outcome.stats.record_stream_read { + let why = outcome.stats.diagnostics.first().map(|d| d.message.trim_start_matches("Failsafe: ").to_string()); + return Err(format!("DWG: {}", why.unwrap_or_else(|| "the file's objects couldn't be read".to_string()))); + } + let mut notes = read_notes(&outcome.stats); + let mut doc = outcome.document; + notes.extend( + guarded(AssertUnwindSafe(|| application_objects(&mut doc))).map_err(|_| "the DWG application object conversion failed".to_string())?, + ); + // After application_objects, so the proxy objects it makes are numbered as the DXF's. + notes.extend( + guarded(AssertUnwindSafe(|| super::proxy_transport::prepare(&mut doc))).map_err(|_| "the DWG proxy conversion failed".to_string())?, + ); + let orphans = guarded(AssertUnwindSafe(|| post_process(&mut doc))).map_err(|_| "the DWG post-processing failed on this file".to_string())?; + if orphans > 0 { + let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") }; + notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out")); + } + let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false }; + let written = std::panic::catch_unwind(AssertUnwindSafe(|| { + acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)?; + mtext_contents(&doc, &mut out) + })) + .map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?; + if out.exceeded { + return Err(format!( + "DWG: converting this {} file produced more than {} of drawing data (the limit); \ + the file is probably damaged, or uses objects the DWG reader misreads", + mb(bytes.len() as u64), + mb(limits.max_dxf_bytes as u64) + )); + } + written.map_err(|e| format!("DWG→DXF: {e}"))?; + Ok((add_header_vars(out.buf, &doc.header), notes)) + } + + /// acadrust 0.6.6's ASCII writer treats a caret followed by a literal space as already + /// escaped, and turns line feeds into paragraph codes. Serialize affected MTEXT contents + /// from the native values instead, using its lossless string writer. All other groups, + /// including embedded-object data, remain byte-for-byte as the DXF writer emitted them. + fn mtext_contents(doc: &acadrust::CadDocument, out: &mut CappedWriter) -> acadrust::Result<()> { + use acadrust::io::dxf::{DxfStreamWriter, DxfTextWriter}; + let contents: std::collections::HashMap<_, _> = doc + .entities() + .filter_map(|e| match e { + acadrust::EntityType::MText(t) if t.value.contains(['^', '\t', '\n']) => Some((t.common.handle.value(), t.value.as_str())), + _ => None, + }) + .collect(); + if contents.is_empty() { + return Ok(()); + } + let dxf = std::mem::take(&mut out.buf); + let mut lines = dxf.split_inclusive(|b| *b == b'\n'); + let (mut mtext, mut text, mut written) = (false, None, false); + while let Some(code) = lines.next() { + let value = lines.next().ok_or_else(|| std::io::Error::other("incomplete DWG bridge DXF pair"))?; + match code.trim_ascii() { + b"0" => { + mtext = value.trim_ascii() == b"MTEXT"; + text = None; + written = false; + } + b"5" if mtext => { + text = std::str::from_utf8(value.trim_ascii()).ok().and_then(|s| u64::from_str_radix(s, 16).ok()).and_then(|h| contents.get(&h)); + } + b"101" => mtext = false, + b"1" | b"3" if mtext && text.is_some() => { + if let Some(&text) = text + && !written + { + let mut writer = DxfTextWriter::new(&mut *out); + if doc.version < acadrust::DxfVersion::AC1021 + && let Some(encoding) = acadrust::io::dxf::code_page::encoding_from_code_page(&doc.header.code_page) + { + writer.set_encoding(encoding); + } + // Each character can expand to two caret-escape characters. Keep + // those pairs together and each group within 250 characters. + let mut rest = text; + while let Some((end, _)) = rest.char_indices().nth(125) { + let (chunk, tail) = rest.split_at(end); + writer.write_xrecord_string(3, chunk)?; + rest = tail; + } + writer.write_xrecord_string(1, rest)?; + written = true; + } + continue; + } + _ => {} + } + out.write_all(code)?; + out.write_all(value)?; + } + Ok(()) + } + + #[cfg(test)] + #[test] + fn mtext_contents_keeps_other_groups_and_the_output_limit() -> acadrust::Result<()> { + for version in [acadrust::DxfVersion::AC1015, acadrust::DxfVersion::AC1032] { + let mut doc = acadrust::CadDocument::new(); + doc.version = version; + doc.header.code_page = "ANSI_1252".into(); + let mut t = acadrust::MText::with_value("é ^ C", acadrust::Vector3::ZERO); + t.common.handle = acadrust::Handle::new(0x10); + doc.add_entity(acadrust::EntityType::MText(Box::new(t)))?; + // Only the MTEXT's own 1/3 groups change. The embedded object's groups and the + // following TEXT record must survive byte-for-byte, including their spaces. + let prefix = b" 0\r\nMTEXT\r\n 5\r\n10\r\n 8\r\n0\r\n"; + let suffix = b"101\r\nEmbedded Object\r\n 1\r\n^ embedded\r\n 3\r\n^ tail\r\n 0\r\nTEXT\r\n 1\r\n^ plain\r\n"; + let raw = [prefix.as_slice(), b" 3\r\nold\r\n 1\r\ntext\r\n", suffix.as_slice()].concat(); + let mut out = CappedWriter { buf: raw.clone(), limit: 4096, exceeded: false }; + mtext_contents(&doc, &mut out)?; + let value: &[u8] = if version == acadrust::DxfVersion::AC1015 { b"\xe9 ^ C" } else { "é ^ C".as_bytes() }; + assert_eq!(out.buf, [prefix.as_slice(), b" 1\r\n", value, b"\r\n", suffix.as_slice()].concat()); + let mut limited = CappedWriter { buf: raw, limit: 1, exceeded: false }; + assert!(mtext_contents(&doc, &mut limited).is_err()); + assert!(limited.exceeded); + } + Ok(()) + } + + /// Most record types named in the skipped-records note. + const NOTE_TYPES: usize = 6; + + /// The note on object records a failsafe read skipped: how many, and how many of each type + /// (most first), or nothing when the read was complete. + pub(crate) fn read_notes(stats: &acadrust::ReadStats) -> Vec { + let skipped = stats.skipped_source_records; + if skipped == 0 { + return Vec::new(); + } + let label = |t: Option<&str>| match t.map(str::parse::) { + Some(Ok(code)) => type_name(code).map_or_else(|| format!("type {code}"), str::to_string), + Some(Err(_)) => t.unwrap_or_default().to_string(), + None => "unknown".to_string(), + }; + let mut by_type: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for d in stats.diagnostics.iter().filter(|d| d.record_handle.is_some()) { + *by_type.entry(label(d.record_type.as_deref())).or_default() += 1; + } + let mut types: Vec<(String, usize)> = by_type.into_iter().collect(); + types.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + let mut list: Vec = types.iter().take(NOTE_TYPES).map(|(t, n)| format!("{t} × {n}")).collect(); + if types.len() > NOTE_TYPES { + list.push(format!("{} more types", types.len() - NOTE_TYPES)); + } + let of = if stats.source_records > 0 { format!(" of {}", stats.source_records) } else { String::new() }; + let records = if skipped == 1 && of.is_empty() { "object record" } else { "object records" }; + let (were, them) = if skipped == 1 { ("was", "it") } else { ("were", "them") }; + let which = if list.is_empty() { String::new() } else { format!(" ({})", list.join(", ")) }; + vec![format!( + "DWG: {skipped}{of} {records} couldn't be read and {were} left out{which}; \ + saving over the original file would lose {them}" + )] + } + + /// The DXF name of a DWG object type code with a fixed number (classes, 500 and up, are + /// numbered per file). + fn type_name(code: i16) -> Option<&'static str> { + Some(match code { + 1 => "TEXT", + 2 => "ATTRIB", + 3 => "ATTDEF", + 4 => "BLOCK", + 5 => "ENDBLK", + 6 => "SEQEND", + 7 => "INSERT", + 8 => "MINSERT", + 10..=14 => "VERTEX", + 15 | 16 | 29 | 30 => "POLYLINE", + 17 => "ARC", + 18 => "CIRCLE", + 19 => "LINE", + 20..=26 => "DIMENSION", + 27 => "POINT", + 28 => "3DFACE", + 31 => "SOLID", + 32 => "TRACE", + 33 => "SHAPE", + 34 => "VIEWPORT", + 35 => "ELLIPSE", + 36 => "SPLINE", + 37 => "REGION", + 38 => "3DSOLID", + 39 => "BODY", + 40 => "RAY", + 41 => "XLINE", + 42 => "DICTIONARY", + 43 => "OLEFRAME", + 44 => "MTEXT", + 45 => "LEADER", + 46 => "TOLERANCE", + 47 => "MLINE", + 48 => "BLOCK_CONTROL", + 49 => "BLOCK_RECORD", + 50 => "LAYER_CONTROL", + 51 => "LAYER", + 52 => "STYLE_CONTROL", + 53 => "STYLE", + 56 => "LTYPE_CONTROL", + 57 => "LTYPE", + 60 => "VIEW_CONTROL", + 61 => "VIEW", + 62 => "UCS_CONTROL", + 63 => "UCS", + 64 => "VPORT_CONTROL", + 65 => "VPORT", + 66 => "APPID_CONTROL", + 67 => "APPID", + 68 => "DIMSTYLE_CONTROL", + 69 => "DIMSTYLE", + 70 => "VX_CONTROL", + 71 => "VX_TABLE_RECORD", + 72 => "GROUP", + 73 => "MLINESTYLE", + 74 => "OLE2FRAME", + 77 => "LWPOLYLINE", + 78 => "HATCH", + 79 => "XRECORD", + 80 => "ACDBPLACEHOLDER", + 82 => "LAYOUT", + _ => return None, + }) + } + + /// Merged table cells onto the cells. acadrust 0.6.3 reads a 2010+ (AC1024+) DWG table's + /// merged cells into the table's `merged_ranges` only, while its DXF writer writes each cell's + /// own merge groups (173, 175, 176), so without this the merges would be lost. Tables whose + /// cells already carry merges are left alone. Each range's first cell gets the span; the + /// cells it covers are marked merged with no span of their own, as CADCraft's DXF writer does. + fn table_merges(doc: &mut acadrust::CadDocument) { + table_merges_within(doc, MAX_MERGE_CELLS); + } + + /// The most table cells [`table_merges`] visits in one document: each range is capped, but + /// can cover rows × cols cells, so the total is bounded too. + const MAX_MERGE_CELLS: usize = 1 << 24; + + /// A table cell's own text height only when the file says it overrides the table style's. + /// acadrust's DXF writer writes a cell style's height (group 140) whenever the cell has a + /// style record, even a default 0.18 with no override flag, and CADCraft reads a 140 as an + /// override (#2501). The override is the cell style's TEXT_HEIGHT property flag or the + /// content's format override bit 128; without either the height is cleared (0 is no + /// override), so the table style's height applies. + fn table_cell_heights(doc: &mut acadrust::CadDocument) { + use acadrust::entities::table::CellStylePropertyFlags; + const CONTENT_TEXT_HEIGHT: i32 = 128; + for e in doc.entities_mut() { + let acadrust::entities::EntityType::Table(t) = e else { continue }; + for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + // The first content's own height (group 144) when its format override sets bit 128. + // CADCraft keeps one height for the cell's text, so a later content's override + // must not stand in for the first content's. + let content_height = cell + .contents + .first() + .filter(|c| c.format_override_flags & CONTENT_TEXT_HEIGHT != 0) + .map(|c| c.text_height) + .filter(|h| h.is_finite() && *h > 0.0); + if let Some(h) = content_height { + // CADCraft reads only the style's height (group 140), so carry it there. + cell.style.get_or_insert_with(acadrust::entities::table::CellStyle::new).text_height = h; + continue; + } + // A style overrides through its property flags or, in newer files, its override + // flags; anything else is the writer's default and is cleared. + if let Some(style) = cell.style.as_mut() + && !style.property_flags.contains(CellStylePropertyFlags::TEXT_HEIGHT) + && style.override_flags & CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32 == 0 + { + style.text_height = 0.0; + } + } + } + } + + /// The fix-ups made to a document after the DWG read, returning how many orphaned DGN + /// components were dropped. The document comes from a third-party reader and is hostile. + fn post_process(doc: &mut acadrust::CadDocument) -> usize { + super::fail_in_test("post_process"); + current_style_names(doc); + table_merges(doc); + table_cell_heights(doc); + table_style_rows(doc, false); + keep_sort_handles(doc); + drop_orphaned_dgn_components(doc) + } + + /// Runs `f`, turning a panic into an error (the reader and writer are guarded the same way). + fn guarded(f: impl FnOnce() -> R + std::panic::UnwindSafe) -> Result { + std::panic::catch_unwind(f).map_err(|_| ()) + } + + /// The version a proxy record of a DWG of `version` names its format by (Open Design + /// Specification for .dwg, proxy objects): 19 for R13, 21 for R14, 23 for 2000, 25 for 2004, + /// 27 for 2007, 29 for 2010, 31 for 2013 and 33 for 2018; `None` for any other version. + pub(super) fn proxy_dwg_version(version: acadrust::DxfVersion) -> Option { + Some(match version.version_code() { + 1012 => 19, + 1014 => 21, + 1015 => 23, + 1018 => 25, + 1021 => 27, + 1024 => 29, + 1027 => 31, + 1032 => 33, + _ => return None, + }) + } + + /// Objects of another application's class that acadrust keeps only as their DWG record + /// (`ObjectType::Unknown`, which has no DXF form): its DXF writer left them out, and the + /// references to them went with them (a dimension's extension dictionary entry naming its + /// application data, say), with no note. Each is carried as a proxy object of its class instead + /// (DXF Reference, ACAD_PROXY_OBJECT), so the DXF keeps it and a DWG written from that DXF has it + /// again: its common object data (owner, reactors, extension dictionary) and the streams after + /// it (data bits, R2007+ strings, object references), as the Open Design Specification for .dwg + /// splits an object record. Objects of the drawing database's own classes are not turned into + /// another application's proxies. What is still without a DXF form is named in the notes, so a + /// save over the original is not mistaken for a complete one. + fn application_objects(doc: &mut acadrust::CadDocument) -> Vec { + use acadrust::objects::{ObjectType, ProxyObject}; + let classes: Vec = doc.classes.iter().cloned().collect(); + let source = doc.dwg_source_version.unwrap_or(doc.version); + if let Some(dwg_version) = proxy_dwg_version(source) { + let maintenance = i32::from(doc.maintenance_version); + for object in doc.objects.values_mut() { + let ObjectType::Unknown { handle, raw_dwg_data: Some(data), raw_dwg_handle_bits, raw_dwg_version: Some(version), type_name, .. } = + &*object + else { + continue; + }; + if !application_class(&classes, type_name) { + continue; + } + let raw = acadrust::entities::RawRecord { data: data.clone(), handle_bits: *raw_dwg_handle_bits, version: *version }; + let handle = *handle; + // A record that doesn't split stays as it was, and is named below. + let Ok(Ok(record)) = std::panic::catch_unwind(AssertUnwindSafe(|| object_record(&raw, handle.value()))) else { continue }; + let Some(class) = classes.iter().find(|c| c.class_number == record.kind && !c.is_an_entity) else { continue }; + *object = ObjectType::ProxyObject(ProxyObject { + handle, + owner: record.owner, + reactors: record.reactors, + xdictionary_handle: record.xdictionary, + proxy_id: 499, + class_id: i32::from(class.class_number), + dwg_version, + maintenance_version: maintenance, + version: (maintenance << 16) | dwg_version, + payload: record.payload, + text_payload: record.text_payload, + object_ids: record.object_ids, + ..Default::default() + }); + } + } + let mut left: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new(); + for object in doc.objects.values() { + if let ObjectType::Unknown { type_name, raw_dxf_codes: None, .. } = object { + let n = left.entry(type_name.as_str()).or_insert(0); + *n = n.saturating_add(1); + } + } + if left.is_empty() { + return Vec::new(); + } + let total = left.values().fold(0usize, |a, n| a.saturating_add(*n)); + let mut list: Vec = left.iter().take(NOTE_TYPES).map(|(t, n)| format!("{t:.60} × {n}")).collect(); + if left.len() > NOTE_TYPES { + list.push(format!("{} more types", left.len() - NOTE_TYPES)); + } + vec![format!( + "DWG: {total} objects of types this bridge can't preserve were left out ({}); saving over the original file would lose them", + list.join(", ") + )] + } + + /// Whether `name` is an object class of another application, which only that application + /// rebuilds: not one of the drawing database's own classes (`AcDb…`/`ACDB…`/`ACAD_…`), whose + /// leftovers the database regenerates itself. + fn application_class(classes: &[acadrust::classes::DxfClass], name: &str) -> bool { + classes.iter().any(|c| { + !c.is_an_entity + && c.dxf_name.eq_ignore_ascii_case(name) + && !c.cpp_class_name.starts_with("AcDb") + && !["ACDB", "ACAD_", "ACAD"].iter().any(|p| c.dxf_name.to_ascii_uppercase().starts_with(p)) + }) + } + + /// An application object acadrust kept only as its record: the parts a proxy object carries. + struct ObjectRecord { + kind: i16, + owner: acadrust::types::Handle, + reactors: Vec, + xdictionary: Option, + payload: acadrust::objects::ProxyPayload, + text_payload: acadrust::objects::ProxyPayload, + object_ids: Vec, + } + + /// Split an object record into its common object data and the uninterpreted streams after it + /// (data bits, R2007+ strings and object references), as an application object's proxy holds + /// them (Open Design Specification for .dwg, common object data and proxy objects). The record + /// is hostile: sizes are checked against it, and acadrust's checked stream reader does the rest. + fn object_record(raw: &acadrust::entities::RawRecord, handle: u64) -> Result { + use acadrust::io::dwg::{ + DwgVersion, dwg_reference_type::DwgReferenceType, dwg_stream_readers::object_reader::DwgObjectReader, + dwg_stream_writers::bit_writer::DwgBitWriter, + }; + use acadrust::objects::{ProxyObjectReference, ProxyPayload, ProxyReferenceKind}; + use acadrust::types::{DxfVersion, Handle}; + if raw.data.len() > 32 << 20 { + return Err("object record exceeds the transport limit".into()); + } + let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "object has a negative handle stream size")?; + let total_bits = raw.data.len().checked_mul(8).ok_or("object record is too large")?; + if handle_bits > total_bits { + return Err("object handle stream exceeds its record".into()); + } + let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| e.to_string())?; + let mut frame = DwgBitWriter::new(version, raw.version); + frame.write_modular_short(raw.data.len()); + if raw.version >= DxfVersion::AC1024 { + frame.write_modular_char(handle_bits); + } + frame.write_bytes(&raw.data); + let reader = DwgObjectReader::new(frame.into_bytes(), raw.version, std::collections::HashMap::new()).map_err(|e| e.to_string())?; + let (kind, mut streams) = reader.read_record_at(0).map_err(|e| e.to_string())?; + if kind < 500 { + return Err("object has no application class".into()); + } + let common = reader.read_common_non_entity_data(&mut streams, kind); + streams.check_error().map_err(|e| e.to_string())?; + if common.common.handle != handle { + return Err("object record names another handle".into()); + } + let read_bits = |count: usize, bit: &mut dyn FnMut() -> bool| -> Result { + if count > total_bits { + return Err("object stream exceeds its record".into()); + } + let n = u32::try_from(count).map_err(|_| "object stream is too large")?; + let mut bytes = vec![0u8; count.div_ceil(8)]; + for index in 0..count { + if bit() + && let Some(b) = bytes.get_mut(index / 8) + { + *b |= 0x80 >> (index % 8); + } + } + Ok(ProxyPayload::from_bits(&bytes, n)) + }; + let main = usize::try_from(streams.main_remaining_bits()).map_err(|_| "object body has an invalid size")?; + let payload = read_bits(main, &mut || streams.read_bit())?; + let text_payload = if raw.version >= DxfVersion::AC1021 { + let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "object has a negative string stream size")?; + read_bits(bits, &mut || streams.read_text_bit())? + } else { + ProxyPayload::default() + }; + // Every reference to the end of the handle stream, null ones too: an object keeps a null pointer in its + // field's place, and the references after it are the next fields' (as acadrust reads a proxy's own). R2010+ + // records give the stream's exact length; the bits of its last byte past it are padding. + let mut object_ids = Vec::new(); + let mut left = if raw.version >= DxfVersion::AC1024 { + streams.get_handle_bits().max(0).min(streams.handle_remaining_bits()) + } else { + streams.handle_remaining_bits() + }; + while left >= 8 { + if object_ids.len() >= 100_000 { + return Err("object has too many references".into()); + } + let before = streams.handle_remaining_bits(); + let (target, reference) = streams.read_handle_reference(handle); + let used = before.saturating_sub(streams.handle_remaining_bits()); + if used <= 0 || used > left { + break; + } + left -= used; + let kind = match reference { + DwgReferenceType::SoftOwnership => ProxyReferenceKind::SoftOwnership, + DwgReferenceType::HardOwnership => ProxyReferenceKind::HardOwnership, + DwgReferenceType::SoftPointer => ProxyReferenceKind::SoftPointer, + DwgReferenceType::HardPointer => ProxyReferenceKind::HardPointer, + DwgReferenceType::Undefined => ProxyReferenceKind::Undefined, + }; + object_ids.push(ProxyObjectReference { handle: Handle::from(target), kind }); + } + streams.check_error().map_err(|e| e.to_string())?; + Ok(ObjectRecord { + kind, + owner: Handle::from(common.owner_handle), + reactors: common.reactors.into_iter().map(Handle::from).collect(), + xdictionary: common.xdictionary_handle.map(Handle::from), + payload, + text_payload, + object_ids, + }) + } + + /// [`table_merges`] visiting at most `budget` cells in all; a range that would pass it is + /// left unmerged. Returns the cells visited. + fn table_merges_within(doc: &mut acadrust::CadDocument, budget: usize) -> usize { + let mut left = budget; + for e in doc.entities_mut() { + let acadrust::entities::EntityType::Table(t) = e else { continue }; + if t.merged_ranges.is_empty() || t.rows.iter().flat_map(|r| &r.cells).any(|c| c.merge_width > 1 || c.merge_height > 1) { + continue; + } + let (rows, cols) = (t.rows.len(), t.columns.len()); + // A real table has at most one merge per cell; more ranges than that come from a + // damaged file and would only make this rows × cols × ranges. + let max_ranges = rows.saturating_mul(cols).min(1 << 16); + for r in t.merged_ranges.iter().take(max_ranges).cloned().collect::>() { + // Clamp to the table: the ranges come from the file. + let (bottom, right) = (r.bottom_row.min(rows.saturating_sub(1)), r.right_col.min(cols.saturating_sub(1))); + if r.top_row > bottom || r.left_col > right || rows == 0 || cols == 0 || (r.top_row, r.left_col) == (bottom, right) { + continue; + } + let area = (bottom - r.top_row + 1).saturating_mul(right - r.left_col + 1); + if area > left { + continue; + } + left -= area; + let span = |n: usize| i32::try_from(n + 1).unwrap_or(i32::MAX); + for (ri, row) in t.rows.iter_mut().enumerate().take(bottom + 1).skip(r.top_row) { + for (ci, cell) in row.cells.iter_mut().enumerate().take(right + 1).skip(r.left_col) { + cell.merged = 1; + let first = (ri, ci) == (r.top_row, r.left_col); + cell.merge_width = if first { span(right - r.left_col) } else { 0 }; + cell.merge_height = if first { span(bottom - r.top_row) } else { 0 }; + } + } + } + } + budget - left + } + + /// Draw-order tables (SORTENTSTABLE) keep the sort handles the file gave them. acadrust's DXF + /// writer renumbers them past every entity handle, but an entity left out of a table sorts by + /// its own handle, so after renumbering it would draw below every listed entity (#2410). Giving + /// the table its codes verbatim makes the writer emit them unchanged. + fn keep_sort_handles(doc: &mut acadrust::CadDocument) { + use acadrust::objects::ObjectType; + let version = doc.version; + for o in doc.objects.values_mut() { + let ObjectType::SortEntitiesTable(t) = o else { continue }; + let hex = |h: acadrust::types::Handle| format!("{:X}", h.value()); + let mut codes = + vec![(5, hex(t.handle)), (330, hex(t.owner_handle)), (100, "AcDbSortentsTable".to_string()), (330, hex(t.block_owner_handle))]; + for e in t.entries() { + codes.push((331, hex(e.entity_handle))); + codes.push((5, hex(e.sort_handle))); + } + t.raw_dxf_codes = Some(codes); + t.raw_dxf_version = Some(version); + } + } + + /// Table styles' title and header suppression between the two places acadrust 0.6.3 keeps + /// it. 2010+ (AC1024+) DWG stores it only in the style's flags (title 1, header 2), while + /// acadrust's DXF reader and writer use the `title_suppressed`/`header_suppressed` fields + /// (groups 280, 281). `to_flags` copies the fields into the flags before a DWG is written; + /// otherwise the flags are copied into the fields after one is read. Older versions store + /// both and are left alone. + fn table_style_rows(doc: &mut acadrust::CadDocument, to_flags: bool) { + use acadrust::objects::{ObjectType, TableStyleFlags}; + if doc.version < acadrust::DxfVersion::AC1024 { + return; + } + for o in doc.objects.values_mut() { + let ObjectType::TableStyle(s) = o else { continue }; + for (bit, suppressed) in + [(TableStyleFlags::TITLE_SUPPRESSED, &mut s.title_suppressed), (TableStyleFlags::HEADER_SUPPRESSED, &mut s.header_suppressed)] + { + if to_flags { + s.flags.set(bit, *suppressed); + } else { + *suppressed = s.flags.contains(bit); + } + } + } + } + + /// The DWG dimension flags byte. Its bit 1 is DXF group 70's bit 32 (ODA DWG specification, + /// common dimension data), which acadrust keeps no copy of from DXF: it is set here as + /// CADCraft's DXF writer always sets it (#1094). Bit 0, the opposite of group 70's bit 128, + /// acadrust 0.6.6 reads and writes itself. + fn dimension_flags(doc: &mut acadrust::CadDocument) { + for e in doc.entities_mut() { + let acadrust::entities::EntityType::Dimension(d) = e else { continue }; + d.base_mut().dwg_flags_byte |= 0x02; + } + } + + /// Leave out DGN line style components (`LS*COMPONENT`) that no line style definition uses + /// (#1482). A drawing that once came from MicroStation can carry hundreds of thousands of + /// them, orphaned copies piled up by earlier DGN imports and copy-pastes (AutoCAD's PURGE + /// removes them as orphaned data): in a real 83 MB site plan they were 888,483 of 889,053 + /// components and made its DXF rendition 1.2 GB, over [`Limits::DEFAULT`], so the file + /// didn't open at all. No definition that was read refers to them (a definition the reader + /// skipped can't use them either) and CADCraft doesn't draw DGN line styles, so nothing + /// visible is lost. + /// Every component a definition reaches (directly, or through compound and point + /// components) is kept, as are the definitions; dictionary entries that named a dropped + /// component go with it. Returns how many were left out. + fn drop_orphaned_dgn_components(doc: &mut acadrust::CadDocument) -> usize { + use acadrust::objects::{DgnLineStyleData, ObjectType}; + use std::collections::{HashMap, HashSet}; + // Each component's references, and the definitions' root components to start from. + let mut refs: HashMap> = HashMap::new(); + let mut stack: Vec = Vec::new(); + for (h, o) in &doc.objects { + let ObjectType::DgnLineStyle(ls) = o else { continue }; + match &ls.data { + DgnLineStyleData::Definition { root_component, .. } => stack.push(*root_component), + DgnLineStyleData::Component { component, .. } => { + refs.insert(*h, component.references()); + } + DgnLineStyleData::Registered { .. } => {} + } + } + if refs.is_empty() { + return 0; + } + // Each component is visited once, so cycles and shared subtrees end. + let mut used: HashSet = HashSet::new(); + while let Some(h) = stack.pop() { + if let Some(r) = refs.get(&h) + && used.insert(h) + { + stack.extend(r.iter().copied()); + } + } + let orphans: HashSet = refs.into_keys().filter(|h| !used.contains(h)).collect(); + if orphans.is_empty() { + return 0; + } + doc.objects.retain(|h, _| !orphans.contains(h)); + doc.dgn_ls_components.retain(|h, _| !orphans.contains(h)); + for o in doc.objects.values_mut() { + let ObjectType::Dictionary(d) = o else { continue }; + if d.entries.iter().any(|(_, e)| orphans.contains(e)) { + d.entries.retain(|(_, e)| !orphans.contains(e)); + let keys: HashSet<&str> = d.entries.iter().map(|(k, _)| k.as_str()).collect(); + d.hard_owner_entries.retain(|k| keys.contains(k.as_str())); + } + } + orphans.len() + } + + /// Leave out what acadrust can't write as this DWG version: other programs' custom entities + /// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file + /// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types + /// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many + /// records of each DXF type were left out, so a save can say so. + fn drop_unwritable(doc: &mut acadrust::CadDocument) -> super::Unwritten { + let version = doc.version; + let mut dropped = super::Unwritten::new(); + let mut count = |name: &str| { + let n = dropped.entry(name.trim().to_ascii_uppercase()).or_insert(0); + *n = n.saturating_add(1); + }; + let unwritable: Vec = doc + .entities() + .filter_map(|e| match e { + acadrust::entities::EntityType::Unknown(u) + if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 => + { + count(&u.dxf_name); + Some(u.common.handle) + } + _ => None, + }) + .collect(); + for h in &unwritable { + doc.remove_entity(*h); + } + let objects: Vec = doc + .objects + .iter() + .filter_map(|(h, o)| match o { + acadrust::objects::ObjectType::Unknown { type_name, raw_dwg_data, raw_dwg_version, .. } + if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 => + { + count(type_name); + Some(*h) + } + _ => None, + }) + .collect(); + for h in &objects { + doc.objects.remove(h); + } + dropped + } + + /// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of + /// the HEADER section: (name, group code, value). + fn unwritten_header_vars(h: &acadrust::document::HeaderVariables) -> [(&'static str, i32, String); 7] { + [ + // DWG holds radians; `$ANGBASE` is in degrees. + ("$ANGBASE", 50, h.angle_base.to_degrees().to_string()), + ("$ANGDIR", 70, h.angle_direction.to_string()), + ("$FILLETRAD", 40, h.fillet_radius.to_string()), + ("$CHAMFERA", 40, h.chamfer_distance_a.to_string()), + ("$CHAMFERB", 40, h.chamfer_distance_b.to_string()), + ("$ELEVATION", 40, h.elevation.to_string()), + ("$THICKNESS", 40, h.thickness.to_string()), + ] + } + + /// Add the [`unwritten_header_vars`] to the HEADER section of acadrust's DXF text, before its + /// ENDSEC; a variable the writer already wrote is left alone. Text of another layout than + /// acadrust's (` 0\r\nSECTION\r\n 2\r\nHEADER\r\n` … ` 0\r\nENDSEC\r\n`) is returned unchanged. + fn add_header_vars(mut dxf: Vec, h: &acadrust::document::HeaderVariables) -> Vec { + const START: &[u8] = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n"; + const END: &[u8] = b"\r\n 0\r\nENDSEC\r\n"; + if !dxf.starts_with(START) { + return dxf; + } + let Some(end) = dxf.windows(END.len()).position(|w| w == END).map(|p| p + 2) else { return dxf }; + let section = dxf.get(..end).unwrap_or_default(); + let mut add = String::new(); + for (name, code, value) in unwritten_header_vars(h) { + let written = section.windows(name.len() + 2).any(|w| w.starts_with(name.as_bytes()) && w.ends_with(b"\r\n")); + if !written && value.parse::().is_ok_and(f64::is_finite) { + add.push_str(&format!(" 9\r\n{name}\r\n{code:>3}\r\n{value}\r\n")); + } + } + dxf.splice(end..end, add.into_bytes()); + dxf + } + + /// Collects the DXF text and fails the write once it would grow past `limit`. + struct CappedWriter { + buf: Vec, + limit: usize, + exceeded: bool, + } + + impl Write for CappedWriter { + fn write(&mut self, b: &[u8]) -> std::io::Result { + // Sticky: once over, every later write fails too. + if self.exceeded || self.buf.len().saturating_add(b.len()) > self.limit { + self.exceeded = true; + return Err(std::io::Error::other("DXF size limit reached")); + } + self.buf.extend_from_slice(b); + Ok(b.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + /// Oldest DWG version written; older DXF input is written as this version. + const MIN_DWG_VERSION: acadrust::DxfVersion = acadrust::DxfVersion::AC1018; + + /// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004 + /// (AC1018). + pub fn dxf_to_dwg(dxf: &[u8]) -> Result, String> { + convert(dxf, None, None).map(|(dwg, _)| dwg) + } + + /// Convert DXF bytes into a DWG file of `version`, a code from [`super::VERSIONS`] ("AC1032"), + /// whatever version the DXF declares. + pub fn dxf_to_dwg_as(dxf: &[u8], version: &str) -> Result, String> { + dxf_to_dwg_noted(dxf, version).map(|(dwg, _)| dwg) + } + + /// [`dxf_to_dwg_as`], also returning what the DWG writer left out: how many records of each + /// DXF type it can't write (other programs' custom entities and objects that the DWG reader + /// didn't read from a DWG of this version), empty when the DWG keeps everything. + pub fn dxf_to_dwg_noted(dxf: &[u8], version: &str) -> Result<(Vec, super::Unwritten), String> { + convert(dxf, Some(writable(version)?), None) + } + + /// Like [`dxf_to_dwg_as`], for a DXF that declares an older version than the groups it carries + /// over from another file. CADCraft's R2000 DWG bridge holds the objects it keeps without + /// modelling them (render settings, fields, associative and dynamic block data) as they were + /// read from a file of version `kept` ("AC1032"), and acadrust reads several of those + /// differently by version: read as R2000, an AC1032 file's mental ray render settings came out + /// a class version too high, for which the ODA rejects the whole DWG, and a field lost its + /// cached value. So the DXF is read as `kept` declares. A `kept` version that is unknown, or + /// not newer than the DXF's own, changes nothing. + pub fn dxf_to_dwg_kept_as(dxf: &[u8], version: &str, kept: &str) -> Result, String> { + dxf_to_dwg_kept_noted(dxf, version, kept).map(|(dwg, _)| dwg) + } + + /// [`dxf_to_dwg_kept_as`], also returning what the DWG writer left out, as + /// [`dxf_to_dwg_noted`] does. + pub fn dxf_to_dwg_kept_noted(dxf: &[u8], version: &str, kept: &str) -> Result<(Vec, super::Unwritten), String> { + let kept = acadrust::DxfVersion::from_version_string(kept.trim().to_ascii_uppercase().as_str()); + let kept = (kept != acadrust::DxfVersion::Unknown).then_some(kept); + convert(dxf, Some(writable(version)?), kept) + } + + /// The DWG version for a code from [`super::VERSIONS`]. + fn writable(version: &str) -> Result { + Ok(match version.trim().to_ascii_uppercase().as_str() { + "AC1015" => acadrust::DxfVersion::AC1015, + "AC1018" => acadrust::DxfVersion::AC1018, + "AC1021" => acadrust::DxfVersion::AC1021, + "AC1024" => acadrust::DxfVersion::AC1024, + "AC1027" => acadrust::DxfVersion::AC1027, + "AC1032" => acadrust::DxfVersion::AC1032, + _ => return Err(super::unsupported(version)), + }) + } + + /// The ASCII DXF with its `$ACADVER` set to `kept` when that is newer than the version it + /// declares, or `None` to read it as it is (binary, no `$ACADVER`, or non-ASCII text without a + /// `$DWGCODEPAGE` to decode it by). From AutoCAD 2007 (AC1021) a DXF is UTF-8, so other bytes + /// are first decoded with the code page the DXF declares; `\U+XXXX` escapes read the same in + /// every version, and CADCraft's R2000 DXF is ASCII with such escapes. + pub(super) fn declare_version(dxf: &[u8], kept: acadrust::DxfVersion) -> Option> { + if dxf.starts_with(b"AutoCAD Binary DXF") { + return None; + } + let (_, declared) = header_value(dxf, b"$ACADVER")?; + let declared = acadrust::DxfVersion::from_version_string(std::str::from_utf8(declared).ok()?); + if declared >= kept { + return None; + } + let text: std::borrow::Cow<'_, [u8]> = if dxf.is_ascii() || kept < acadrust::DxfVersion::AC1021 { + std::borrow::Cow::Borrowed(dxf) + } else { + // As acadrust decodes an older DXF: by its code page, Windows-1252 for one it doesn't + // know; a code page that names UTF-8 needs no decoding. + let (_, code_page) = header_value(dxf, b"$DWGCODEPAGE")?; + match acadrust::io::dxf::code_page::encoding_from_code_page(std::str::from_utf8(code_page).ok()?) { + Some(encoding) => { + let (text, errors) = encoding.decode_without_bom_handling(dxf); + if errors { + return None; + } + std::borrow::Cow::Owned(text.into_owned().into_bytes()) + } + None => std::borrow::Cow::Borrowed(std::str::from_utf8(dxf).ok()?.as_bytes()), + } + }; + let (at, value) = header_value(&text, b"$ACADVER")?; + let code = kept.to_string(); + let mut out = Vec::with_capacity(text.len().saturating_add(code.len())); + out.extend_from_slice(text.get(..at)?); + out.extend_from_slice(code.as_bytes()); + out.extend_from_slice(text.get(at.checked_add(value.len())?..)?); + Some(out) + } + + /// The byte offset and the trimmed value of header variable `name` in an ASCII DXF: the value + /// of the group that follows the `9` group naming it. The file is read as (group code, + /// value) line pairs, in one pass with no buffering, up to the first `0`/`ENDSEC`, the end + /// of the HEADER section: a large input without one costs time, not memory (a line index of + /// a 32 MiB file used 0.8 GB). Lines may end in `\r\n`. + pub(super) fn header_value<'a>(dxf: &'a [u8], name: &[u8]) -> Option<(usize, &'a [u8])> { + let mut offset = 0usize; + let mut lines = dxf.split(|b| *b == b'\n').map(|line| { + let at = offset.saturating_add(line.len() - line.trim_ascii_start().len()); + offset = offset.saturating_add(line.len()).saturating_add(1); + (at, line.trim_ascii()) + }); + while let (Some((_, code)), Some((_, value))) = (lines.next(), lines.next()) { + if code == b"0" && value == b"ENDSEC" { + return None; + } + if code == b"9" && value == name { + let (_, code) = lines.next()?; + let (at, value) = lines.next()?; + return (code != b"0" && code != b"9").then_some((at, value)); + } + } + None + } + + /// The current dimension and text styles' names from their handles: a DWG stores the + /// handles, and the reader leaves the names at "Standard", so a drawing reopened from DWG + /// lost its current dimension style (#1051). + fn current_style_names(doc: &mut acadrust::CadDocument) { + let h = doc.header.current_dimstyle_handle; + if let Some(n) = doc.dim_styles.iter().find(|ds| ds.handle == h && !h.is_null()).map(|ds| ds.name.clone()) { + doc.header.current_dimstyle_name = n; + } + let h = doc.header.current_text_style_handle; + if let Some(n) = doc.text_styles.iter().find(|ts| ts.handle == h && !h.is_null()).map(|ts| ts.name.clone()) { + doc.header.current_text_style_name = n; + } + } + + /// The current dimension style (`$DIMSTYLE`) by its name: the DXF gives the name, and the + /// handle read with it needn't be the style's in the document, in which case the DWG writer + /// fell back to Standard and AutoCAD opened the drawing with Standard current (#1051). + fn current_dimstyle(doc: &mut acadrust::CadDocument) { + let name = doc.header.current_dimstyle_name.clone(); + if let Some(h) = doc.dim_styles.iter().find(|ds| ds.name.eq_ignore_ascii_case(&name)).map(|ds| ds.handle) { + doc.header.current_dimstyle_handle = h; + } + } + + /// The current text style (`$TEXTSTYLE`) by its name, the same way as [`current_dimstyle`]: + /// the handle read with the DXF needn't be the style's in the document, in which case the DWG + /// writer fell back to Standard and AutoCAD opened the drawing with Standard current (#1650). + fn current_text_style(doc: &mut acadrust::CadDocument) { + let name = doc.header.current_text_style_name.clone(); + if let Some(h) = doc.text_styles.iter().find(|ts| ts.name.eq_ignore_ascii_case(&name)).map(|ts| ts.handle) { + doc.header.current_text_style_handle = h; + } + } + + /// The DXF names of an ASCII DXF's CLASS records, in CLASSES order: a proxy's class number + /// (group 91) is 500 plus its class's place here (DXF Reference, ACAD_PROXY_OBJECT). Empty for + /// one without a CLASSES section. `None` for a binary DXF, whose classes this doesn't read: + /// CADCraft's own bridge DXF is always ASCII, and only a binary DXF given to the public + /// conversion functions comes here. + pub(super) fn dxf_class_names(dxf: &[u8]) -> Option> { + let pairs = super::proxy_transport::text_pairs(dxf)?; + let mut names = Vec::new(); + let (mut section, mut in_classes, mut in_class) = (false, false, false); + for pair in pairs { + let (code, value) = pair?; + match (code, value.as_str()) { + (0, "ENDSEC") if in_classes => break, + (0, "SECTION") => section = true, + (2, name) if section => { + in_classes = name == "CLASSES"; + section = false; + } + (0, kind) if in_classes => { + in_class = kind == "CLASS"; + // Even an unnamed record keeps its place in the source class table. + if in_class { + names.push(String::new()); + } + } + (1, value) if in_class => { + if let Some(last) = names.last_mut() { + *last = value.to_string(); + } + in_class = false; + } + _ => section = false, + } + } + Some(names) + } + + /// Proxy objects' and entities' class numbers as the DWG numbers its classes. A DXF proxy + /// names its class by its place in the DXF's CLASSES (`classes`, from 500), but acadrust's + /// reader puts its own default classes first and numbers the DXF's after them, so the DXF + /// number named another class in the DWG: a 2018 drawing's piping proxies became + /// LARGE_RADIAL_DIMENSION and SECTIONOBJECT, and the ODA File Converter refused the file. A + /// proxy whose number names no class of the DXF is left out: kept as it is, its number would + /// name some other class of the DWG, for the same reason. + /// + /// Before 2013 (AC1027) acadrust writes only the classes it knows a pre-2013 DWG needs, and a + /// proxy's class is not one of them: the proxy would name a missing class (the ODA refuses + /// the file: "No ClassId") or, by its old number, some other class. Such a DWG leaves the + /// proxies out instead, as it does other programs' data it can't write (`not_saved` reports + /// the entities). So does a DXF whose classes are unknown (`classes` is `None`, a binary + /// DXF): its proxies' numbers can't be mapped, and kept as they are they would name the + /// wrong classes and the ODA would refuse the file. Returns the proxies left out, by DXF type, for + /// what the DWG writer left out ([`super::Unwritten`]). + fn proxy_classes(doc: &mut acadrust::CadDocument, classes: Option<&[String]>) -> super::Unwritten { + let classes = match classes { + Some(c) if doc.version >= acadrust::DxfVersion::AC1027 => c, + _ => { + let entities: Vec = doc + .entities() + .filter_map(|e| match e { + acadrust::entities::EntityType::Extended(x) if matches!(x.data, acadrust::entities::ExtendedEntityData::Proxy(_)) => { + Some(x.common.handle) + } + _ => None, + }) + .collect(); + let objects: Vec = + doc.objects.iter().filter_map(|(h, o)| matches!(o, acadrust::objects::ObjectType::ProxyObject(_)).then_some(*h)).collect(); + for h in &entities { + doc.remove_entity(*h); + } + for h in &objects { + doc.objects.remove(h); + } + return left_out(objects.len(), entities.len()); + } + }; + // Name matching cannot choose between different source classes with the same name. + // Count those proxies as unwritten rather than silently changing their class identity. + let mut unique = std::collections::HashMap::new(); + for name in classes { + unique.entry(name.to_uppercase()).and_modify(|value| *value = false).or_insert(true); + } + let number = |id: i32| -> Option { + let name = classes.get(usize::try_from(id.checked_sub(500)?).ok()?)?; + if unique.get(&name.to_uppercase()) != Some(&true) { + return None; + } + doc.classes.get_by_name(name).map(|c| i32::from(c.class_number)) + }; + let objects: Vec<(acadrust::Handle, Option)> = doc + .objects + .iter() + .filter_map(|(h, o)| match o { + acadrust::objects::ObjectType::ProxyObject(p) => Some((*h, number(p.class_id))), + _ => None, + }) + .collect(); + let entities: Vec<(acadrust::Handle, Option)> = doc + .entities() + .filter_map(|e| match e { + acadrust::entities::EntityType::Extended(x) => match &x.data { + acadrust::entities::ExtendedEntityData::Proxy(p) => Some((x.common.handle, number(p.class_id))), + _ => None, + }, + _ => None, + }) + .collect(); + let unmapped = |list: &[(acadrust::Handle, Option)]| list.iter().filter(|(_, n)| n.is_none()).map(|(h, _)| *h).collect::>(); + let (gone_objects, gone_entities) = (unmapped(&objects), unmapped(&entities)); + for h in &gone_objects { + doc.objects.remove(h); + } + for h in &gone_entities { + doc.remove_entity(*h); + } + let objects = objects.into_iter().filter_map(|(h, n)| Some((h, n?))); + let entities = entities.into_iter().filter_map(|(h, n)| Some((h, n?))); + for (h, n) in objects { + if let Some(acadrust::objects::ObjectType::ProxyObject(p)) = doc.objects.get_mut(&h) { + p.class_id = n; + } + } + let entities: std::collections::HashMap = entities.into_iter().collect(); + for e in doc.entities_mut() { + if let acadrust::entities::EntityType::Extended(x) = e { + let Some(n) = entities.get(&x.common.handle).copied() else { continue }; + if let acadrust::entities::ExtendedEntityData::Proxy(p) = &mut x.data { + p.class_id = n; + } + } + } + left_out(gone_objects.len(), gone_entities.len()) + } + + /// Proxy objects and entities left out of the DWG, by their DXF types. + fn left_out(objects: usize, entities: usize) -> super::Unwritten { + [("ACAD_PROXY_OBJECT", objects), ("ACAD_PROXY_ENTITY", entities)] + .into_iter() + .filter(|(_, n)| *n > 0) + .map(|(t, n)| (t.to_string(), n)) + .collect() + } + + /// The current layer (`$CLAYER`) by its name, the same way as [`current_dimstyle`]: a DWG + /// stores the handle of the layer, and the one read with the DXF needn't be the layer's in + /// the document, so the drawing reopened with layer "0" current (#2252). + fn current_layer(doc: &mut acadrust::CadDocument) { + let name = doc.header.current_layer_name.clone(); + // The table's own lookup compares Unicode case-insensitively ("Survey_Ä" = "Survey_ä"). + if let Some(layer) = doc.layers.get(&name) { + doc.header.current_layer_handle = layer.handle; + } + } + + /// `kept`: the version the DXF's carried-over groups were read from ([`dxf_to_dwg_kept_as`]). + fn convert(dxf: &[u8], version: Option, kept: Option) -> Result<(Vec, super::Unwritten), String> { + // Reject a pathologically large DXF before handing it to the third-party decoder, which + // allocates tables and section buffers from the input's declared sizes and cannot be + // interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder + // runs inside `catch_unwind` so a decode failure is reported rather than crashing. + if dxf.len() > Limits::DEFAULT.max_dxf_bytes { + return Err(format!( + "DXF: the input is {}, larger than the {} accepted for DWG conversion", + mb(dxf.len() as u64), + mb(Limits::DEFAULT.max_dxf_bytes as u64) + )); + } + // Decoding a code page to UTF-8 can grow the text, so the limit applies again. + let data = match kept.and_then(|k| declare_version(dxf, k)) { + Some(d) if d.len() > Limits::DEFAULT.max_dxf_bytes => { + return Err(format!( + "DXF: the input is {} as UTF-8, larger than the {} accepted for DWG conversion", + mb(d.len() as u64), + mb(Limits::DEFAULT.max_dxf_bytes as u64) + )); + } + Some(d) => d, + None => dxf.to_vec(), + }; + let (classes, source) = guarded(AssertUnwindSafe(|| (dxf_class_names(&data), super::proxy_transport::source_classes(&data)))) + .map_err(|_| "the DXF proxy class conversion failed".to_string())?; + let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read())) + .map_err(|_| "the DXF→DWG conversion failed".to_string())? + .map_err(|e| format!("DXF: {e}"))?; + match version { + Some(v) => doc.version = v, + // Without a version asked for, write at least AutoCAD 2004 (AC1018) DWG: R2000 DWG + // has no true colours, transparency, table styles or gradients. The DXF stays R2000; + // only the DWG is newer. + None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION, + None => {} + } + // The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash. + let unwritten = guarded(AssertUnwindSafe(|| { + super::fail_in_test("convert"); + // From 2013 the DWG takes the source classes as they are, duplicates and metadata + // included; otherwise (before 2013, a binary DXF, classes that can't be written back) + // proxies are matched to classes by name or left out. + let restored = match source { + Some(source) if doc.version >= acadrust::DxfVersion::AC1027 => super::proxy_transport::restore_classes(&mut doc, source), + _ => None, + }; + let proxies = match restored { + Some((objects, entities)) => left_out(objects, entities), + None => proxy_classes(&mut doc, classes.as_deref()), + }; + other_layout_owners(&mut doc); + table_style_rows(&mut doc, true); + dimension_flags(&mut doc); + let mut unwritten = drop_unwritable(&mut doc); + for (kind, n) in proxies { + let total = unwritten.entry(kind).or_insert(0); + *total = total.saturating_add(n); + } + current_dimstyle(&mut doc); + add_variable_dictionary(&mut doc); + current_text_style(&mut doc); + current_layer(&mut doc); + unwritten + })) + .map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?; + let dwg = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc))) + .map_err(|_| "the DWG writer failed on this drawing".to_string())? + .map_err(|e| format!("DWG write: {e}"))?; + Ok((dwg, unwritten)) + } + + /// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the + /// ACDBVARIABLEDICTIONARY; dictionary keys are case-insensitive). + const VARIABLE_DICTIONARY: &str = "AcDbVariableDictionary"; + + /// A style or scale name, or `default` when the drawing has none. + fn name_or_default(name: &str, default: &str) -> String { + let name = name.trim(); + if name.is_empty() { default.to_string() } else { name.to_string() } + } + + /// A drawing variable's value as text, or `default` when the header holds a value the + /// variable doesn't accept (a hostile file can put any integer in it). + fn variable_value(value: i16, valid: &[i16], default: i16) -> String { + if valid.contains(&value) { value.to_string() } else { default.to_string() } + } + + /// Add the drawing variables dictionary AutoCAD always writes into the named object + /// dictionary, with the variables it keeps there, when the drawing has none (#1813). + /// Vectric Aspire 10.5 reads a 2004 (AC1018) DWG without it as empty ("No data was imported + /// from the DXF / DWG file"), though it reads the same drawing as DXF — which has no such + /// dictionary either, as CADCraft's and ezdxf's writers don't make one. AutoCAD's AC1032 DWGs + /// read there without it, so newer output is left as it was, and a dictionary the drawing + /// does have keeps its own entries and values. + fn add_variable_dictionary(doc: &mut acadrust::CadDocument) { + use acadrust::objects::{Dictionary, DictionaryVariable, ObjectType}; + if doc.version >= acadrust::DxfVersion::AC1032 { + return; + } + let root_handle = doc.header.named_objects_dict_handle; + match doc.objects.get(&root_handle) { + Some(ObjectType::Dictionary(root)) => { + if root.get(VARIABLE_DICTIONARY).is_some() { + return; + } + } + _ => return, + } + // The variables AutoCAD writes for a drawing with default styles and a 1:1 annotation + // scale. DIMASSOC and HIDETEXT keep the drawing's own header values; LAYEREVAL and + // LAYERNOTIFY have no DXF header variable. + let variables = [ + ("CMLEADERSTYLE", name_or_default(&doc.header.current_mleader_style_name, "Standard")), + ("CTABLESTYLE", name_or_default(&doc.header.current_table_style_name, "Standard")), + ("CANNOSCALE", name_or_default(&doc.header.current_annotation_scale, "1:1")), + ("DIMASSOC", variable_value(doc.header.dimension_associativity, &[0, 1, 2], 2)), + ("HIDETEXT", variable_value(doc.header.hide_text, &[0, 1], 1)), + ("LAYEREVAL", "0".to_string()), + ("LAYERNOTIFY", "0".to_string()), + ]; + let dictionary_handle = doc.allocate_handle(); + let mut dictionary = Dictionary::new(); + dictionary.handle = dictionary_handle; + dictionary.owner = root_handle; + for (name, value) in variables { + let handle = doc.allocate_handle(); + let mut variable = DictionaryVariable::new(name, &value); + variable.handle = handle; + variable.owner_handle = dictionary_handle; + dictionary.add_entry(name, handle); + doc.objects.insert(handle, ObjectType::DictionaryVariable(variable)); + } + doc.objects.insert(dictionary_handle, ObjectType::Dictionary(dictionary)); + if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_handle) { + root.add_entry(VARIABLE_DICTIONARY, dictionary_handle); + } + } + + /// Entities of a layout other than the one in `*Paper_Space` are written owned by their block + /// (entity mode 0, with its handle). acadrust writes every paper-space entity with mode 1, + /// "paper space", which AutoCAD reads as the `*Paper_Space` block's: everything drawn on a + /// second layout landed on the first one, and the second stayed empty (#1057). + fn other_layout_owners(doc: &mut acadrust::CadDocument) { + let others: std::collections::HashSet = doc + .block_records + .iter() + .filter(|br| { + let n = br.name.to_ascii_uppercase(); + n.len() > 12 && n.starts_with("*PAPER_SPACE") && n[12..].bytes().all(|b| b.is_ascii_digit()) + }) + .map(|br| br.handle) + .collect(); + if others.is_empty() { + return; + } + for e in doc.entities_mut() { + let c = e.common_mut(); + if others.contains(&c.owner_handle) { + c.entity_mode = Some(0); + } + } + } + + #[cfg(test)] + mod post_tests { + use super::*; + use acadrust::entities::{CellRange, EntityType, Table}; + + fn doc_with_table(rows: usize, cols: usize, ranges: usize) -> acadrust::CadDocument { + let mut t = Table::new(acadrust::types::Vector3::new(0.0, 0.0, 0.0), rows, cols); + t.merged_ranges = (0..ranges).map(|_| CellRange::new(0, 0, rows - 1, cols - 1)).collect(); + let mut doc = acadrust::CadDocument::new(); + assert!(doc.add_entity(EntityType::Table(Box::new(t))).is_ok()); + doc + } + + /// Overlapping whole-table ranges can't make the merge pass visit more than its budget (#1934). + #[test] + fn merge_work_is_bounded_by_the_cell_budget() { + let mut doc = doc_with_table(10, 10, 50); + assert_eq!(table_merges_within(&mut doc, 250), 200, "two whole-table ranges fit, the rest are skipped"); + let mut doc = doc_with_table(10, 10, 1); + assert_eq!(table_merges_within(&mut doc, usize::MAX), 100); + } + + /// A height marked only in the style's override flags (as newer files store it) is kept, + /// and the same height with no flag at all is cleared (review on #2588). Tested on the + /// read document because acadrust's DWG writer doesn't write the override flags. + #[test] + fn a_style_override_flag_keeps_the_cell_height() { + use acadrust::entities::table::{CellStyle, CellStylePropertyFlags}; + let height = |override_flags: i32| { + let mut doc = doc_with_table(1, 1, 0); + for e in doc.entities_mut() { + if let EntityType::Table(t) = e { + for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + let mut s = CellStyle::new(); + s.text_height = 0.5; + s.override_flags = override_flags; + cell.style = Some(s); + } + } + } + table_cell_heights(&mut doc); + doc.entities().find_map(|e| match e { + EntityType::Table(t) => t.rows.first()?.cells.first()?.style.as_ref().map(|s| s.text_height), + _ => None, + }) + }; + assert_eq!(height(CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32), Some(0.5)); + assert_eq!(height(0), Some(0.0)); + } + + /// A panic in the post-read fix-ups is an error, not a crash (#1934). + #[test] + fn a_panic_in_post_processing_is_an_error() { + #[allow(clippy::panic)] + let r = guarded(AssertUnwindSafe(|| -> usize { panic!("boom") })); + assert!(r.is_err()); + assert_eq!(guarded(|| 3), Ok(3)); + } + } + + #[cfg(test)] + mod application_object_tests { + use super::*; + use acadrust::CadDocument; + use acadrust::classes::DxfClass; + use acadrust::objects::{ObjectType, ProxyObjectReference, ProxyPayload, ProxyReferenceKind, RegisteredClassObject}; + use acadrust::types::{DxfVersion, Handle}; + + /// An object of another application's class that acadrust keeps only as its record (a + /// vertical product's dimension data, say) is carried as that class's proxy object, with its + /// data bits, references, owner and extension dictionary, through DXF and back to DWG, twice. + #[test] + fn undecoded_application_object_is_kept_as_its_proxy() { + for version in [DxfVersion::AC1027, DxfVersion::AC1032] { + let mut source = CadDocument::new(); + source.version = version; + source.classes.add_or_update(DxfClass::new("TESTCONTAINER", "xyzDbTestContainer")); + let handle = Handle::from(0x2Bu64); + let target = Handle::from(0x2Cu64); + let payload = ProxyPayload::from_bits(&[0x5A, 0x3C, 0xC0], 18); + // A null pointer between two references: it keeps its place, the one after it is not lost. + let references = vec![ + ProxyObjectReference { handle: target, kind: ProxyReferenceKind::HardPointer }, + ProxyObjectReference { handle: Handle::from(0u64), kind: ProxyReferenceKind::SoftPointer }, + ProxyObjectReference { handle: Handle::from(0x0Cu64), kind: ProxyReferenceKind::HardPointer }, + ]; + source.objects.insert( + handle, + ObjectType::RegisteredClass(RegisteredClassObject { + handle, + owner: Handle::from(0x0Cu64), + dxf_name: "TESTCONTAINER".into(), + cpp_class_name: "xyzDbTestContainer".into(), + payload: payload.clone(), + object_ids: references.clone(), + ..Default::default() + }), + ); + let mut dwg = acadrust::DwgWriter::write_to_vec(&source).unwrap(); + // acadrust has no model of it: read back, it is only its record. + let decoded = acadrust::DwgReader::from_stream(Cursor::new(dwg.clone())).read().unwrap(); + assert!(matches!(decoded.objects.get(&handle), Some(ObjectType::Unknown { .. })), "{:?}", decoded.objects.get(&handle)); + for generation in 0..2 { + let (dxf, notes) = dwg_to_dxf_noted(&dwg, Limits::DEFAULT).unwrap(); + assert!(notes.is_empty(), "{version:?} {generation}: {notes:?}"); + // The DXF has the object, by its handle, as a proxy record. + let text = String::from_utf8_lossy(&dxf); + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + let record = lines.windows(4).any(|w| w == ["0", "ACAD_PROXY_OBJECT", "5", "2B"]); + assert!(record, "{version:?} {generation}: no proxy record for the object"); + dwg = dxf_to_dwg_as(&dxf, "AC1032").unwrap(); + let doc = acadrust::DwgReader::from_stream(Cursor::new(dwg.clone())).read().unwrap(); + let Some(ObjectType::ProxyObject(proxy)) = doc.objects.get(&handle) else { panic!("{version:?} {generation}: proxy object") }; + assert_eq!(proxy.payload.bit_count, payload.bit_count); + assert_eq!(proxy.payload.data(), payload.data()); + assert_eq!(proxy.object_ids, references); + let class = doc.classes.iter().find(|c| i32::from(c.class_number) == proxy.class_id).unwrap(); + assert_eq!(class.dxf_name, "TESTCONTAINER"); + assert!(!class.is_an_entity); + } + } + } + + /// The database's own classes are not turned into another application's proxies; what is + /// left without a DXF form is said, not dropped silently. + #[test] + fn database_objects_without_a_form_are_reported() { + let mut doc = CadDocument::new(); + doc.classes.add_or_update(DxfClass::new("BLOCKPROPERTIESTABLE", "AcDbBlockPropertiesTable")); + let handle = Handle::from(0x40u64); + doc.objects.insert( + handle, + ObjectType::Unknown { + type_name: "BLOCKPROPERTIESTABLE".into(), + handle, + owner: Handle::from(0x0Cu64), + raw_dxf_codes: None, + raw_dwg_data: Some(vec![0; 8]), + raw_dwg_handle_bits: 0, + raw_dwg_version: Some(DxfVersion::AC1032), + }, + ); + let notes = application_objects(&mut doc); + assert!(matches!(doc.objects.get(&handle), Some(ObjectType::Unknown { .. }))); + assert_eq!( + notes, + [ + "DWG: 1 objects of types this bridge can't preserve were left out (BLOCKPROPERTIESTABLE × 1); saving over the original file would lose them" + ] + ); + } + + /// A record that doesn't split stays as it was and is named, never guessed at. + #[test] + fn invalid_object_records_are_rejected() { + for handle_bits in [-1, 1000] { + let raw = acadrust::entities::RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 }; + assert!(object_record(&raw, 42).is_err()); + } + let mut doc = CadDocument::new(); + doc.classes.add_or_update(DxfClass::new("TESTCONTAINER", "xyzDbTestContainer")); + let handle = Handle::from(0x41u64); + doc.objects.insert( + handle, + ObjectType::Unknown { + type_name: "TESTCONTAINER".into(), + handle, + owner: Handle::from(0x0Cu64), + raw_dxf_codes: None, + raw_dwg_data: Some(vec![0xFF; 4]), + raw_dwg_handle_bits: 1000, + raw_dwg_version: Some(DxfVersion::AC1032), + }, + ); + let notes = application_objects(&mut doc); + assert!(matches!(doc.objects.get(&handle), Some(ObjectType::Unknown { .. }))); + assert!(notes.iter().any(|n| n.contains("(TESTCONTAINER × 1)")), "{notes:?}"); + } + } +} + +#[cfg(not(no_dwg_bridge))] +pub use native::{ + dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as, dxf_to_dwg_kept_as, dxf_to_dwg_kept_noted, dxf_to_dwg_noted, +}; + +/// What saving as DWG left out: how many records of each DXF type (upper case) the DWG writer +/// can't write. +pub type Unwritten = std::collections::BTreeMap; + +// Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The +// stage then panics on entry, so a test through the public API shows whether it is guarded. +#[cfg(all(test, not(no_dwg_bridge)))] +thread_local! { + static PANIC_IN: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Panics when a test asked for `stage` to fail (see [`PANIC_IN`]); does nothing otherwise. +#[cfg(not(no_dwg_bridge))] +#[allow(clippy::panic)] +fn fail_in_test(_stage: &str) { + #[cfg(test)] + if PANIC_IN.with(|p| p.get()) == Some(_stage) { + panic!("test-forced panic in {_stage}"); + } +} + +/// The error for a DWG version this writer can't create. +#[cfg_attr(no_dwg_bridge, allow(dead_code))] +fn unsupported(version: &str) -> String { + let list: Vec<&str> = VERSIONS.iter().map(|(_, year)| *year).collect(); + format!("DWG version `{version}` can't be written (this bridge writes {})", list.join(", ")) +} + +/// Which build has no DWG bridge, for the stubs' errors. +#[cfg(no_dwg_bridge)] +const NO_BRIDGE_BUILD: &str = if cfg!(target_arch = "wasm32") { "web" } else { "FreeBSD aarch64" }; + +/// There's no worker without a bridge. +#[cfg(no_dwg_bridge)] +pub const WORKER_ARG: &str = "--dwg-worker"; +#[cfg(no_dwg_bridge)] +pub fn serve_if_asked() -> Option { + None +} +#[cfg(no_dwg_bridge)] +pub fn use_own_worker() {} +#[cfg(no_dwg_bridge)] +pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec, Vec), String> { + dwg_to_dxf_noted(bytes, limits) +} +#[cfg(no_dwg_bridge)] +pub fn encode_as(dxf: &[u8], version: &str) -> Result, String> { + dxf_to_dwg_as(dxf, version) +} +#[cfg(no_dwg_bridge)] +pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec, Unwritten), String> { + dxf_to_dwg_noted(dxf, version) +} +#[cfg(no_dwg_bridge)] +pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec, Unwritten), String> { + match kept { + Some(kept) => dxf_to_dwg_kept_noted(dxf, version, kept), + None => dxf_to_dwg_noted(dxf, version), + } +} + +#[cfg(no_dwg_bridge)] +pub fn dwg_to_dxf(_bytes: &[u8]) -> Result, String> { + Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dwg_to_dxf_noted(_bytes: &[u8], _limits: Limits) -> Result<(Vec, Vec), String> { + Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dwg_to_dxf_with(_bytes: &[u8], _limits: Limits) -> Result, String> { + Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dxf_to_dwg(_dxf: &[u8]) -> Result, String> { + Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result, String> { + Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dxf_to_dwg_noted(_dxf: &[u8], _version: &str) -> Result<(Vec, Unwritten), String> { + Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dxf_to_dwg_kept_as(_dxf: &[u8], _version: &str, _kept: &str) -> Result, String> { + Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dxf_to_dwg_kept_noted(_dxf: &[u8], _version: &str, _kept: &str) -> Result<(Vec, Unwritten), String> { + Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The DXF of an empty drawing, enough for the bridge to make a small DWG. + #[cfg(not(no_dwg_bridge))] + const MINIMAL_DXF: &str = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nEOF\n"; + + /// Makes `stage` panic on the current thread until dropped. + #[cfg(not(no_dwg_bridge))] + struct PanicIn; + + #[cfg(not(no_dwg_bridge))] + impl PanicIn { + fn at(stage: &'static str) -> Self { + PANIC_IN.with(|p| p.set(Some(stage))); + Self + } + } + + #[cfg(not(no_dwg_bridge))] + impl Drop for PanicIn { + fn drop(&mut self) { + PANIC_IN.with(|p| p.set(None)); + } + } + + /// The public read reports a panic in the post-read fix-ups as an error: this fails if the + /// `guarded` call around `post_process` is taken out (#2402). + #[cfg(not(no_dwg_bridge))] + #[test] + fn reading_a_dwg_survives_a_panic_in_post_processing() { + let dwg = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap(); + assert!(dwg_to_dxf(&dwg).is_ok(), "the DWG reads without the forced panic"); + let _panic = PanicIn::at("post_process"); + let err = dwg_to_dxf(&dwg).unwrap_err(); + assert!(err.contains("the DWG post-processing failed on this file"), "{err}"); + } + + /// The DXF to DWG conversion reports a panic in its fix-ups as an error, not a crash (#2402). + #[cfg(not(no_dwg_bridge))] + #[test] + fn converting_to_dwg_survives_a_panic_in_the_fix_ups() { + let _panic = PanicIn::at("convert"); + let err = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap_err(); + assert!(err.contains("the DXF to DWG conversion failed on this file"), "{err}"); + } + + #[test] + fn detects_magic() { + assert!(is_dwg(b"AC1032\0\0\0\0")); + assert!(!is_dwg(b" 0\r\nSECTION")); + assert_eq!(version(b"AC1018xxxx").as_deref(), Some("AC1018")); + } + + /// A DXF with ROMANS as `$TEXTSTYLE` gives a DWG whose current text style is ROMANS, not + /// Standard: the DXF names the style, and the handle read with it was Standard's (#1650). + #[cfg(not(no_dwg_bridge))] + #[test] + fn current_text_style_reaches_the_dwg() { + let header = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 9\n$TEXTSTYLE\n 7\nROMANS\n 0\nENDSEC\n"; + let style = " 0\nSTYLE\n 5\n1F0\n100\nAcDbSymbolTableRecord\n100\nAcDbTextStyleTableRecord\n 2\nROMANS\n 70\n0\n"; + let style = format!("{style} 40\n0\n 41\n1\n 50\n0\n 71\n0\n 42\n2.5\n 3\nromans.shx\n 4\n\n"); + let dxf = format!("{header} 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nSTYLE\n 70\n1\n{style} 0\nENDTAB\n 0\nENDSEC\n 0\nEOF\n"); + let dwg = dxf_to_dwg(dxf.as_bytes()).unwrap(); + let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap(); + let romans = doc.text_styles.iter().find(|ts| ts.name == "ROMANS").map(|ts| ts.handle); + assert!(romans.is_some(), "ROMANS is missing from the DWG"); + assert_eq!(Some(doc.header.current_text_style_handle), romans); + } + + #[test] + fn garbage_is_an_error_not_a_crash() { + assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err()); + assert!(dwg_to_dxf(b"").is_err()); + } + + #[cfg(not(no_dwg_bridge))] + #[test] + fn dxf_to_dwg_rejects_garbage_without_crashing() { + // The DXF→DWG path runs the third-party reader inside catch_unwind and reports failures + // rather than crashing (and rejects oversized input before decoding). + assert!(dxf_to_dwg(b"not a dxf file").is_err()); + assert!(dxf_to_dwg(b"").is_err()); + } + + /// A DXF with one DIMENSION per (text, group 70, subclass, points) row. + #[cfg(not(no_dwg_bridge))] + type DimensionSpec<'a> = (&'a str, i64, &'a str, &'a [(i32, [f64; 2])]); + + #[cfg(not(no_dwg_bridge))] + fn dims_dxf(dims: &[DimensionSpec<'_>]) -> String { + let mut s = String::from(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n"); + for (text, flags, sub, pts) in dims { + s += " 0\nDIMENSION\n 8\n0\n100\nAcDbEntity\n100\nAcDbDimension\n 10\n0\n 20\n0\n 30\n0\n"; + s += &format!(" 11\n50\n 21\n-20\n 31\n0\n 70\n{flags}\n 1\n{text}\n 3\nStandard\n100\n{sub}\n"); + for (code, [x, y]) in pts.iter() { + s += &format!("{code:>3}\n{x}\n{:>3}\n{y}\n{:>3}\n0\n", code + 10, code + 20); + } + } + s + " 0\nENDSEC\n 0\nEOF\n" + } + + /// DWG stores DXF group 70's bit 128 inverted, in bit 0 of the dimension flags byte (set: + /// the text is at its default position), and bit 32 in bit 1 (ODA DWG specification, common + /// dimension data). The bytes are read back raw, so a reader that inverts bit 0 the same way + /// as the writer can't hide a wrong byte. + #[cfg(not(no_dwg_bridge))] + #[test] + fn dimension_text_position_flags_reach_the_dwg_as_autocad_reads_them() { + let linear: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [100.0, 0.0])]; + let radius: &[(i32, [f64; 2])] = &[(15, [10.0, 0.0])]; + let angular: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [10.0, 0.0]), (15, [0.0, 0.0]), (16, [0.0, 10.0])]; + let kinds = [ + (0, "AcDbAlignedDimension", linear), + (1, "AcDbAlignedDimension", linear), + (4, "AcDbRadialDimension", radius), + (2, "AcDb2LineAngularDimension", angular), + ]; + let mut dims = Vec::new(); + for (ty, sub, pts) in kinds { + dims.push(("HOME", ty | 32, sub, pts)); + dims.push(("MOVED", ty | 32 | 128, sub, pts)); + } + let dwg = dxf_to_dwg(dims_dxf(&dims).as_bytes()).unwrap(); + let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap(); + let mut seen = Vec::new(); + for e in doc.entities() { + let acadrust::entities::EntityType::Dimension(d) = e else { continue }; + let (text, byte) = (d.base().text_override().unwrap_or_default(), d.base().dwg_flags_byte); + seen.push((text.to_string(), byte & 0x03)); + assert_eq!(byte & 0x02, 0x02, "{text} {:?}: bit 32 of group 70 is lost (flags byte {byte:#04x})", d.base().dimension_type); + let default_pos = text == "HOME"; + assert_eq!(byte & 0x01 != 0, default_pos, "{text} {:?}: flags byte {byte:#04x}", d.base().dimension_type); + } + for text in ["HOME", "MOVED"] { + assert_eq!(seen.iter().filter(|(t, _)| t == text).count(), 4, "{seen:?}"); + } + + // And CADCraft reads its own DWG back as it wrote it: 128 only on the moved texts. + let dxf = String::from_utf8(dwg_to_dxf(&dwg).unwrap()).unwrap(); + let lines: Vec<&str> = dxf.lines().map(str::trim).collect(); + let mut flags = Vec::new(); + for rec in lines.chunks(2).collect::>().split(|p| p.first() == Some(&"0")) { + let value = |code: &str| rec.iter().find(|p| p.first() == Some(&code)).and_then(|p| p.get(1).copied()); + if let (Some(text @ ("HOME" | "MOVED")), Some(v)) = (value("1"), value("70")) { + flags.push((text, v.parse::().unwrap_or(-1) & 128)); + } + } + assert_eq!(flags.len(), 8, "{flags:?}"); + assert!(flags.iter().all(|(l, f)| (*f == 128) == (*l == "MOVED")), "{flags:?}"); + } + + /// The handles a DXF text refers to or gives its records (groups 5, 105 and 330–369). + #[cfg(not(no_dwg_bridge))] + fn dxf_handles(dxf: &str) -> std::collections::HashSet { + let lines: Vec<&str> = dxf.lines().collect(); + lines + .chunks(2) + .filter_map(|p| match p { + [c, v] if matches!(c.trim().parse::(), Ok(5 | 105 | 330..=369)) => u64::from_str_radix(v.trim(), 16).ok(), + _ => None, + }) + .collect() + } + + /// Issue #1482: a DWG that once came from MicroStation can carry hundreds of thousands of DGN + /// line style components no definition uses (a real 83 MB site plan: 888,483 of 889,053, a + /// 1.2 GB DXF rendition over the limit, so it didn't open). They're left out with a note, and + /// their dictionary entries with them; the definition and every component it reaches — + /// through compound and point components, around a cycle — are kept. + #[cfg(not(no_dwg_bridge))] + #[test] + fn orphaned_dgn_line_style_components_are_left_out() { + use acadrust::objects::*; + let mut doc = acadrust::CadDocument::new(); + let container = doc.allocate_handle(); + let nod = doc.header.named_objects_dict_handle; + let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&nod) else { panic!("no named object dictionary") }; + root.entries.push(("ACAD_DGNLINESTYLECOMP".into(), container)); + let pattern = || DgnLsStrokePattern { + has_iteration_limit: false, + is_single_segment: false, + iteration_limit: 0, + auto_phase: 0.0, + phase: 0.0, + phase_mode: DgnLsPhaseMode::Distance, + strokes: vec![DgnLsStroke { + is_dash: true, + bypass_corner: false, + can_be_scaled: true, + invert_at_origin: false, + invert_at_end: false, + length: 2.5, + start_width: 0.0, + end_width: 0.0, + width_mode: 0, + cap_mode: 0, + }], + }; + let kind = |c: &DgnLsComponentData| match c { + DgnLsComponentData::Symbol(_) => DgnLsComponentType::Symbol, + DgnLsComponentData::Compound(_) => DgnLsComponentType::Compound, + DgnLsComponentData::Stroke(_) => DgnLsComponentType::Stroke, + DgnLsComponentData::Point(_) => DgnLsComponentType::Point, + DgnLsComponentData::Internal(_) => DgnLsComponentType::Internal, + }; + let compound = |of: &[acadrust::Handle]| { + DgnLsComponentData::Compound(DgnLsCompoundComponent { + entries: of.iter().map(|&component| DgnLsCompoundEntry { component, offset: 0.0 }).collect(), + }) + }; + let mut entries = Vec::new(); + let mut put = |doc: &mut acadrust::CadDocument, h: acadrust::Handle, data: DgnLineStyleData| { + entries.push((format!("{:X}", h.value()), h)); + let reactors = vec![container]; + doc.objects + .insert(h, ObjectType::DgnLineStyle(DgnLineStyleObject { handle: h, owner: container, reactors, xdictionary_handle: None, data })); + }; + let component = |c: DgnLsComponentData| DgnLineStyleData::Component { + kind: kind(&c), + description: "dash".into(), + version: 1, + component_uid: [7; 16], + scale: 1.0, + property_flags: 0, + component: c, + properties: vec![], + }; + let h: Vec = (0..9).map(|_| doc.allocate_handle()).collect(); + let [def, root, stroke, point, back, orphan, orphan_cycle, internal, shared] = h[..] else { panic!("nine handles") }; + put( + &mut doc, + def, + DgnLineStyleData::Definition { + description: "{ dash }".into(), + version: 1, + style_number: 1, + component_uid: [1; 16], + is_continuous: false, + unit_definition: 1.0, + unit_scale: 1.0, + units_type: 0, + is_element: false, + is_physical: false, + is_scale_independent: false, + is_snappable: false, + root_component: root, + properties: vec![], + }, + ); + // Used: def → root → {stroke, point → shared, back → root (a cycle)}. + put(&mut doc, root, component(compound(&[stroke, point, back]))); + put(&mut doc, stroke, component(DgnLsComponentData::Stroke(pattern()))); + put(&mut doc, point, component(DgnLsComponentData::Point(DgnLsPointComponent { stroke_component: shared, symbols: vec![] }))); + put(&mut doc, back, component(compound(&[root]))); + put(&mut doc, shared, component(DgnLsComponentData::Stroke(pattern()))); + // Orphans: an internal component, and two compounds that refer to each other and to a + // used component (which stays). + let pattern_internal = + DgnLsInternalComponent { pattern: pattern(), internal_version: 0, hardware_style: 0, is_hardware_style: false, line_code: 0 }; + put(&mut doc, internal, component(DgnLsComponentData::Internal(pattern_internal))); + put(&mut doc, orphan, component(compound(&[orphan_cycle, shared]))); + put(&mut doc, orphan_cycle, component(compound(&[orphan]))); + let mut dict = Dictionary::new(); + dict.handle = container; + dict.owner = nod; + dict.entries = entries; + doc.objects.insert(container, ObjectType::Dictionary(dict)); + let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap(); + + let (dxf, notes) = dwg_to_dxf_noted(&dwg, Limits::DEFAULT).unwrap(); + let dxf = String::from_utf8(dxf).unwrap(); + let handles = dxf_handles(&dxf); + for kept in [def, root, stroke, point, back, shared] { + assert!(handles.contains(&kept.value()), "{:X} was left out", kept.value()); + } + for gone in [internal, orphan, orphan_cycle] { + assert!(!handles.contains(&gone.value()), "{:X} was kept or is still referred to", gone.value()); + } + let lines: Vec<&str> = dxf.lines().collect(); + let ls: Vec<&str> = lines.chunks(2).filter_map(|p| p.get(1).filter(|_| p[0].trim() == "0" && p[1].starts_with("LS")).copied()).collect(); + assert_eq!(ls.iter().filter(|t| t.ends_with("COMPONENT")).count(), 5, "{ls:?}"); + assert!(ls.contains(&"LSDEFINITION") && !ls.contains(&"LSINTERNALCOMPONENT"), "{ls:?}"); + assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]); + } + + /// An R2000 DXF header (`\r\n` lines, as CADCraft writes them) with `extra` after `$ACADVER`. + #[cfg(not(no_dwg_bridge))] + fn r2000(extra: &[u8]) -> Vec { + let mut dxf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\nAC1015\r\n 9\r\n$DWGCODEPAGE\r\n 3\r\nANSI_1252\r\n".to_vec(); + dxf.extend_from_slice(b" 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nTEXT\r\n 1\r\n"); + dxf.extend_from_slice(extra); + dxf.extend_from_slice(b"\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"); + dxf + } + + /// The kept version replaces only the `$ACADVER` value; everything else stays byte for byte. + #[cfg(not(no_dwg_bridge))] + #[test] + fn declare_version_changes_only_acadver() { + let dxf = r2000(b"A\\U+00E7\\U+00E3o"); + let out = native::declare_version(&dxf, acadrust::DxfVersion::AC1032).unwrap(); + assert_eq!(String::from_utf8(out).unwrap(), String::from_utf8(dxf).unwrap().replacen("AC1015", "AC1032", 1)); + } + + /// A kept version not newer than the declared one, a binary DXF and a DXF without `$ACADVER` + /// are read as they are. + #[cfg(not(no_dwg_bridge))] + #[test] + fn declare_version_leaves_what_it_need_not_change() { + let dxf = r2000(b"x"); + assert_eq!(native::declare_version(&dxf, acadrust::DxfVersion::AC1015), None); + assert_eq!(native::declare_version(&dxf, acadrust::DxfVersion::AC1009), None); + assert_eq!(native::declare_version(b"AutoCAD Binary DXF\r\n\x1a\0", acadrust::DxfVersion::AC1032), None); + let no_version = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"; + assert_eq!(native::declare_version(no_version, acadrust::DxfVersion::AC1032), None); + // A `$ACADVER` outside the HEADER section is not the file's version. + let late = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 9\r\n$ACADVER\r\n 1\r\nAC1015\r\n"; + assert_eq!(native::declare_version(late, acadrust::DxfVersion::AC1032), None); + } + + /// The header is read as group code and value pairs up to the first `0`/`ENDSEC`: a `$ACADVER` + /// name whose value would come after it, or a file that never ends its HEADER, is no version + /// to replace, and `\r\n` lines keep the value's offset right. + #[cfg(not(no_dwg_bridge))] + #[test] + fn declare_version_reads_the_header_in_pairs_up_to_endsec() { + let cut = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 0\r\nENDSEC\r\n 1\r\nAC1015\r\n"; + assert_eq!(native::declare_version(cut, acadrust::DxfVersion::AC1032), None); + let endless = vec![b'\n'; 1 << 20]; + assert_eq!(native::declare_version(&endless, acadrust::DxfVersion::AC1032), None); + let crlf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\n AC1015\r\n 0\r\nENDSEC\r\n"; + let out = native::declare_version(crlf, acadrust::DxfVersion::AC1032).unwrap(); + assert_eq!(out, b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\n AC1032\r\n 0\r\nENDSEC\r\n"); + } + + /// Declared as 2007 or later, a DXF is UTF-8: code page text is decoded first. Declared as an + /// older version, the code page bytes stay as they are. + #[cfg(not(no_dwg_bridge))] + #[test] + fn declare_version_decodes_a_code_page_for_utf8_versions() { + let dxf = r2000(b"A\xe7\xe3o"); + let utf8 = native::declare_version(&dxf, acadrust::DxfVersion::AC1032).unwrap(); + let text = String::from_utf8(utf8).unwrap(); + assert!(text.contains("\r\nAção\r\n") && text.contains("$ACADVER\r\n 1\r\nAC1032\r\n"), "{text}"); + let legacy = native::declare_version(&dxf, acadrust::DxfVersion::AC1018).unwrap(); + assert!(legacy.windows(4).any(|w| w == b"A\xe7\xe3o"), "the code page bytes stay"); + assert!(legacy.windows(6).any(|w| w == b"AC1018")); + // A code page it doesn't know is read as Windows-1252, as acadrust reads an older DXF. + let at = dxf.windows(9).position(|w| w == b"ANSI_1252").unwrap(); + let unknown = [&dxf[..at], b"NO_SUCH_CP".as_slice(), &dxf[at + 9..]].concat(); + let text = String::from_utf8(native::declare_version(&unknown, acadrust::DxfVersion::AC1032).unwrap()).unwrap(); + assert!(text.contains("\r\nAção\r\n"), "{text}"); + // Without a `$DWGCODEPAGE` there is nothing to decode it by: it is read as it is. + let none = [&dxf[..at - 21], &dxf[at + 11..]].concat(); + assert!(!none.windows(12).any(|w| w == b"$DWGCODEPAGE"), "{}", String::from_utf8_lossy(&none)); + assert_eq!(native::declare_version(&none, acadrust::DxfVersion::AC1032), None); + } + + /// An unknown kept version converts as [`dxf_to_dwg_as`] does. + #[cfg(not(no_dwg_bridge))] + #[test] + fn unknown_kept_version_converts_as_before() { + let dxf = r2000(b"x"); + assert_eq!(dxf_to_dwg_kept_as(&dxf, "AC1032", "AC9999"), dxf_to_dwg_as(&dxf, "AC1032")); + assert!(dxf_to_dwg_kept_as(&dxf, "AC0000", "AC1032").is_err(), "an unwritable version is still an error"); + } + + /// The CLASS names in CLASSES order; nothing for a binary DXF or one without CLASSES. + #[cfg(not(no_dwg_bridge))] + #[test] + fn dxf_class_names_in_classes_order() { + let dxf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nCLASSES\r\n 0\r\nCLASS\r\n 1\r\nWIDGETA\r\n 2\r\nAcDbWidgetA\r\n 3\r\nApp\r\n 0\r\nCLASS\r\n 1\r\nWIDGETB\r\n 2\r\nAcDbWidgetB\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nTEXT\r\n 1\r\nNOT_A_CLASS\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"; + assert_eq!(native::dxf_class_names(dxf).unwrap(), ["WIDGETA", "WIDGETB"]); + // A CLASS record without a name keeps its place, so the classes after it keep theirs + // (#2837 review). + let unnamed = b" 0\r\nSECTION\r\n 2\r\nCLASSES\r\n 0\r\nCLASS\r\n 2\r\nAcDbNoName\r\n 0\r\nCLASS\r\n 1\r\nWIDGETB\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"; + assert_eq!(native::dxf_class_names(unnamed).unwrap(), ["", "WIDGETB"]); + assert_eq!(native::dxf_class_names(b" 0\nSECTION\n 2\nHEADER\n 0\nENDSEC\n 0\nEOF\n"), Some(Vec::new())); + assert_eq!(native::dxf_class_names(b"AutoCAD Binary DXF\r\n\x1a\0"), None); + } + + /// AutoCAD's DWG scale action (BLOCKSCALEACTION) ends with a byte after its three + /// connections that DXF doesn't carry. acadrust 0.6.4 neither read nor wrote it, so AutoCAD + /// read past the object and refused every DWG CADCraft wrote with a dynamic block that scales + /// ("Error Decrypting Data"); 0.6.5 added it and 0.6.6, which this uses, still keeps it (0.6.6 also + /// fixes multi-layout DWGs, #1463). The byte reaches the DWG, and a scale action + /// from DXF is written whole, with the byte AutoCAD writes (0). + #[cfg(not(no_dwg_bridge))] + #[test] + fn scale_actions_keep_the_byte_autocad_reads_after_their_connections() { + use acadrust::objects::*; + let scale_action = |doc: &acadrust::CadDocument| { + doc.objects.values().find_map(|o| match o { + ObjectType::DynamicBlock(DynamicBlockObject { data: DynamicBlockData::ScaleAction(s), .. }) => Some(s.clone()), + _ => None, + }) + }; + let mut doc = acadrust::CadDocument::new(); + let h = doc.allocate_handle(); + let conn = |code, name: &str| BlockConnection { code, name: name.into() }; + let mut action = DynamicBlockObject::new("BLOCKSCALEACTION", "AcDbBlockScaleAction"); + action.handle = h; + action.owner = doc.header.named_objects_dict_handle; + let mut a = BlockBasePointAction::default(); + a.action.action.element.name = "Scale".into(); + a.action.connections = vec![conn(2, "UpdatedBaseX"), conn(2, "UpdatedBaseY")]; + a.action.dependent = true; + a.connections = vec![conn(2, "Scale"), conn(2, "XScale"), conn(2, "YScale")]; + a.dwg_tail_byte = 7; + action.data = DynamicBlockData::ScaleAction(a.clone()); + doc.objects.insert(h, ObjectType::DynamicBlock(action)); + + let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap(); + let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap(); + assert_eq!(scale_action(&back).map(|s| s.dwg_tail_byte), Some(7), "the byte isn't written"); + + let mut dxf = Vec::new(); + acadrust::DxfWriter::new(&doc).write_to_writer(&mut dxf).unwrap(); + for version in ["AC1018", "AC1032"] { + let dwg = dxf_to_dwg_as(&dxf, version).unwrap(); + let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap(); + let read = scale_action(&back).unwrap_or_else(|| panic!("{version}: the scale action wasn't written")); + assert_eq!(read.connections, a.connections, "{version}"); + assert_eq!(read.action.connections, a.action.connections, "{version}"); + assert_eq!(read.dwg_tail_byte, 0, "{version}"); + } + } + + /// Saving says what the DWG writer left out, per type: entities of types acadrust's DXF + /// reader doesn't model (another program's custom entity) can't be written; a drawing the DWG + /// keeps whole reports nothing. + #[cfg(not(no_dwg_bridge))] + #[test] + fn saving_lists_the_types_the_dwg_writer_leaves_out() { + let line = " 0\nLINE\n 8\n0\n100\nAcDbEntity\n100\nAcDbLine\n 10\n0.0\n 20\n0.0\n 30\n0.0\n 11\n10.0\n 21\n0.0\n 31\n0.0\n"; + let widget = " 0\nACME_WIDGET\n 8\n0\n100\nAcDbEntity\n100\nAcmeWidget\n 10\n1.0\n 20\n2.0\n 30\n0.0\n"; + let dxf = |body: &str| { + format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{body} 0\nENDSEC\n 0\nEOF\n") + }; + for version in ["AC1015", "AC1018", "AC1032"] { + let (dwg, unwritten) = dxf_to_dwg_noted(dxf(&format!("{line}{widget}{widget}")).as_bytes(), version).unwrap(); + assert!(is_dwg(&dwg), "{version}"); + assert_eq!(unwritten, [("ACME_WIDGET".to_string(), 2)].into_iter().collect::(), "{version}"); + } + let (_, unwritten) = dxf_to_dwg_noted(dxf(line).as_bytes(), "AC1032").unwrap(); + assert!(unwritten.is_empty(), "{unwritten:?}"); + } + + /// An OLE2FRAME, which CADCraft keeps as its DXF groups without modelling it, is written to + /// the DWG with its data: it isn't in the list of what the writer left out. + #[cfg(not(no_dwg_bridge))] + #[test] + fn ole_frames_are_written_with_their_data() { + let ole = " 0\nOLE2FRAME\n 8\n0\n100\nAcDbEntity\n100\nAcDbOle2Frame\n 70\n2\n 10\n1.0\n 20\n9.0\n 30\n0.0\n 11\n8.0\n 21\n2.0\n 31\n0.0\n 71\n2\n 72\n0\n 73\n1\n 90\n4\n310\nDEADBEEF\n 1\nOLE\n"; + let dxf = + format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{ole} 0\nENDSEC\n 0\nEOF\n"); + for version in ["AC1015", "AC1018", "AC1032"] { + let (dwg, unwritten) = dxf_to_dwg_noted(dxf.as_bytes(), version).unwrap(); + assert!(unwritten.is_empty(), "{version} {unwritten:?}"); + let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap(); + let frames: Vec> = back + .entities() + .filter_map(|e| match e { + acadrust::entities::EntityType::Ole2Frame(o) => Some(o.encoded_payload()), + _ => None, + }) + .collect(); + assert_eq!(frames, [vec![0xDE, 0xAD, 0xBE, 0xEF]], "{version}"); + } + } +} diff --git a/crates/dwg/src/proxy_transport.rs b/crates/dwg/src/proxy_transport.rs new file mode 100644 index 000000000..db0b984ec --- /dev/null +++ b/crates/dwg/src/proxy_transport.rs @@ -0,0 +1,632 @@ +//! Another application's entities and their classes through the DWG bridge: on reading a DWG, +//! registered entities acadrust has no model for become proxy entities of their class; on writing +//! one, the DWG gets the source DXF's classes in their places, so every proxy names its own class. + +use std::collections::{HashMap, HashSet}; + +use acadrust::CadDocument; +use acadrust::classes::{DxfClass, ProxyFlags}; +use acadrust::entities::{EntityType, ExtendedEntityData, ProxyEntityData}; +use acadrust::objects::{ObjectType, ProxyPayload}; + +fn class_number(index: usize) -> Result { + index.checked_add(500).and_then(|n| i16::try_from(n).ok()).ok_or_else(|| "DXF: too many application classes".into()) +} + +/// After a DWG read: each registered entity of another application's class that acadrust keeps +/// only as its record and opaque data (no decoded properties) becomes a proxy entity of that class +/// (DXF Reference, ACAD_PROXY_ENTITY), with its graphics, data bits, R2007+ strings and object +/// references, so the DXF keeps it; and every proxy's class number becomes 500 plus its class's +/// place in the classes, as a DXF numbers it. A record that can't be converted (its class is +/// missing or not unique) is left out and named in the notes; one with decoded properties stays as +/// read, also named, as its opaque data has no DXF form beside them. +pub(super) fn prepare(doc: &mut CadDocument) -> Vec { + let classes: Vec<_> = doc.classes.iter().cloned().collect(); + let mut numbers = HashMap::new(); + let mut identities = HashMap::new(); + for (index, class) in classes.iter().enumerate() { + let number = class_number(index).ok(); + numbers.entry(i32::from(class.class_number)).and_modify(|v| *v = None).or_insert(number.map(i32::from)); + let identity = (class.dxf_name.as_str(), class.cpp_class_name.as_str(), class.is_an_entity); + identities.entry(identity).and_modify(|v| *v = None).or_insert(number); + } + let identity = |dxf: &str, cpp: &str, entity: bool| { + identities + .get(&(dxf, cpp, entity)) + .copied() + .flatten() + .map(i32::from) + .ok_or_else(|| format!("missing or ambiguous application CLASS for {dxf:.80} ({cpp:.80})")) + }; + let Some(dwg_version) = super::native::proxy_dwg_version(doc.dwg_source_version.unwrap_or(doc.version)) else { return Vec::new() }; + let maintenance = i32::from(doc.maintenance_version); + let remap = |id: i32| numbers.get(&id).copied().flatten().ok_or_else(|| format!("proxy names missing or ambiguous application class {id}")); + // Only mutate proxies: entities_mut invalidates the decoder's verbatim records for every entity. + let handles: Vec<_> = doc + .entities() + .filter_map(|e| match e { + EntityType::Extended(e) => match &e.data { + ExtendedEntityData::Proxy(_) => Some(e.common.handle), + ExtendedEntityData::RegisteredClass(data) if data.properties.is_empty() || data.payload.bit_count > 0 => Some(e.common.handle), + _ => None, + }, + _ => None, + }) + .collect(); + let mut notes = Vec::new(); + let mut entity_count = 0usize; + let mut object_count = 0usize; + let mut semantic = 0usize; + for handle in handles { + // Ok(None): not an application record; Ok(Some(None)): kept as read, with its properties. + let converted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result>, String> { + let Some(EntityType::Extended(entity)) = doc.get_entity(handle) else { return Ok(None) }; + let data = match &entity.data { + ExtendedEntityData::Proxy(data) => { + let mut proxy = data.clone(); + proxy.class_id = remap(proxy.class_id)?; + ExtendedEntityData::Proxy(proxy) + } + ExtendedEntityData::RegisteredClass(data) => { + if !data.properties.is_empty() { + return Ok(Some(None)); + } + let class_id = identity(&data.dxf_name, &data.cpp_class_name, true)?; + let text_payload = entity.common.raw_record.as_deref().map(registered_text).transpose()?.unwrap_or_default(); + ExtendedEntityData::Proxy(ProxyEntityData { + proxy_id: 498, + class_id, + dxf_subclass: String::new(), + version: (maintenance << 16) | dwg_version, + dwg_version, + maintenance_version: maintenance, + from_dxf: false, + graphics: ProxyPayload::from_bytes(entity.common.graphic_data.as_deref().unwrap_or_default()), + payload: data.payload.clone(), + text_payload, + object_ids: data.object_ids.clone(), + }) + } + _ => return Ok(None), + }; + Ok(Some(Some(data))) + })) + .unwrap_or_else(|_| Err("application record conversion failed".into())); + match converted { + Ok(Some(Some(data))) => { + if let Some(EntityType::Extended(entity)) = doc.get_entity_mut(handle) { + entity.data = data; + } + } + Ok(Some(None)) => semantic = semantic.saturating_add(1), + Ok(None) => {} + Err(why) => { + doc.remove_entity(handle); + entity_count = entity_count.saturating_add(1); + skipped_note(&mut notes, "entity", handle, &why); + } + } + } + doc.objects.retain(|handle, object| { + if let ObjectType::ProxyObject(proxy) = object { + match remap(proxy.class_id) { + Ok(id) => proxy.class_id = id, + Err(why) => { + object_count = object_count.saturating_add(1); + skipped_note(&mut notes, "object", *handle, &why); + return false; + } + } + } + true + }); + if entity_count > 0 || object_count > 0 { + notes.insert(0, format!("DWG: partial read; application proxy records left out (entities: {entity_count}, objects: {object_count})")); + } + if semantic > 0 { + notes.push(format!( + "DWG: {semantic} registered entities with decoded properties keep them, without their other data; saving over the original file would lose it" + )); + } + notes +} + +fn skipped_note(notes: &mut Vec, kind: &str, handle: acadrust::types::Handle, why: &str) { + if notes.len() < 6 { + let why: String = why.chars().take(256).collect(); + notes.push(format!("DWG: proxy {kind} {handle} left out: {why}")); + } +} + +/// The decoder's registered-class representation omits the separate R2007+ string stream. +fn registered_text(raw: &acadrust::entities::RawRecord) -> Result { + use acadrust::io::dwg::DwgVersion; + use acadrust::io::dwg::dwg_stream_readers::object_reader::DwgObjectReader; + use acadrust::io::dwg::dwg_stream_writers::bit_writer::DwgBitWriter; + use acadrust::types::DxfVersion; + + if raw.version < DxfVersion::AC1021 { + return Ok(ProxyPayload::default()); + } + let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| format!("DWG proxy text: {e}"))?; + let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "DWG proxy text: negative handle stream size")?; + let total_bits = raw.data.len().checked_mul(8).ok_or("DWG proxy text: record too large")?; + if handle_bits > total_bits { + return Err("DWG proxy text: handle stream exceeds record".into()); + } + // Frame the verbatim record for the dependency's checked object reader. + let mut frame = DwgBitWriter::new(version, raw.version); + frame.write_modular_short(raw.data.len()); + if raw.version >= DxfVersion::AC1024 { + frame.write_modular_char(handle_bits); + } + frame.write_bytes(&raw.data); + let reader = DwgObjectReader::new(frame.into_bytes(), raw.version, HashMap::new()).map_err(|e| format!("DWG proxy text: {e}"))?; + let (kind, mut streams) = reader.read_record_at(0).map_err(|e| format!("DWG proxy text: {e}"))?; + reader.read_common_entity_data(&mut streams, kind); + if kind == 498 { + // An existing registered-class envelope is a proxy and carries its class name first. + streams.read_variable_text(); + } else if kind < 500 { + return Err("DWG proxy text: registered entity has no application class".into()); + } + streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?; + let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "DWG proxy text: negative string stream size")?; + if bits > total_bits { + return Err("DWG proxy text: string stream exceeds record".into()); + } + let count = u32::try_from(bits).map_err(|_| "DWG proxy text: string stream too large")?; + let mut bytes = vec![0; bits.div_ceil(8)]; + for (index, byte) in bytes.iter_mut().enumerate() { + for bit in 0..8.min(bits.saturating_sub(index * 8)) { + if streams.read_text_bit() { + *byte |= 0x80 >> bit; + } + } + } + streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?; + Ok(ProxyPayload::from_bits(&bytes, count)) +} + +/// ASCII DXF pairs decoded with the bridge's public code-page and escape codecs. A malformed +/// pair yields `None`, so both class scans fall back rather than guessing its identity. +pub(super) fn text_pairs(dxf: &[u8]) -> Option> + '_> { + use acadrust::io::dxf::code_page::{decode_legacy_escapes, encoding_from_code_page}; + + if dxf.starts_with(b"AutoCAD Binary DXF") { + return None; + } + let version = + super::native::header_value(dxf, b"$ACADVER").and_then(|(_, v)| std::str::from_utf8(v).ok()).map(acadrust::DxfVersion::from_version_string); + let unicode = version.is_some_and(|v| v >= acadrust::DxfVersion::AC1021); + let encoding = if version.is_some_and(|v| v < acadrust::DxfVersion::AC1021) { + super::native::header_value(dxf, b"$DWGCODEPAGE").and_then(|(_, page)| std::str::from_utf8(page).ok()).and_then(encoding_from_code_page) + } else { + None + }; + let mut lines = dxf.split(|b| *b == b'\n').map(|line| line.strip_suffix(b"\r").unwrap_or(line)); + Some(std::iter::from_fn(move || { + let code = lines.next()?; + let Some(value) = lines.next() else { return (!code.trim_ascii().is_empty()).then_some(None) }; + let pair = || { + let code = std::str::from_utf8(code).ok()?.trim().parse().ok()?; + let text = if let Some(encoding) = encoding { + encoding.decode(value).0.into_owned() + } else if unicode { + String::from_utf8_lossy(value).into_owned() + } else { + // The decoder uses UTF-8 with Latin-1 fallback when no encoding was declared. + std::str::from_utf8(value).map_or_else(|_| value.iter().map(|&b| char::from(b)).collect(), str::to_string) + }; + let text = decode_legacy_escapes(&text); + // DXF's escaped control characters are decoded after CIF/MIF, as in the bridge. + let text = text.replace("^J", "\n").replace("^M", "\r").replace("^I", "\t").replace("^ ", "^"); + Some((code, text)) + }; + Some(pair()) + })) +} + +/// The CLASS records of an ASCII DXF, in CLASSES order and numbered by place from 500 (DXF +/// Reference, CLASSES; a proxy's group 91 is that number). `None` for a binary DXF (the bridge's +/// generated DXF is ASCII), and for one with a record that can't be written back as it is: no names, +/// flags or instance count out of range, more classes than the numbers allow. Then +/// `proxy_classes` matches the classes by name instead. +pub(super) fn source_classes(dxf: &[u8]) -> Option> { + let pairs = text_pairs(dxf)?; + let mut records: Vec> = Vec::new(); + let (mut section, mut in_classes) = (false, false); + for pair in pairs { + let (code, value) = pair?; + match (code, value.as_str()) { + (0, "ENDSEC") if in_classes => break, + (0, "SECTION") => section = true, + (2, name) if section => { + in_classes = name == "CLASSES"; + section = false; + } + (0, kind) if in_classes => { + if kind != "CLASS" { + return None; + } + records.push(Vec::new()); + } + (code, value) if in_classes => records.last_mut()?.push((code, value.to_string())), + _ => section = false, + } + } + records.iter().enumerate().map(|(index, groups)| source_class(groups, class_number(index).ok()?)).collect() +} + +/// One CLASS record's groups as the class `number`, or `None` when it can't be written back. +fn source_class(groups: &[(i32, String)], number: i16) -> Option { + let text = |code| groups.iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()).unwrap_or_default(); + let integer = |code| groups.iter().find(|(c, _)| *c == code).map_or(Some(0), |(_, v)| v.trim().parse::().ok()); + let mut class = DxfClass::new(text(1), text(2)); + if class.dxf_name.is_empty() || class.cpp_class_name.is_empty() { + return None; + } + class.application_name = text(3); + class.proxy_flags = ProxyFlags(u16::try_from(integer(90)?).ok()?); + class.instance_count = i32::try_from(integer(91)?).ok()?; + class.was_zombie = integer(280)? != 0; + class.is_an_entity = integer(281)? != 0; + class.item_class_id = if class.is_an_entity { 498 } else { 499 }; + class.class_number = number; + Some(class) +} + +/// Give a DWG of 2013 or later the source DXF's classes (`classes`, from [`source_classes`]) in +/// their places, with their metadata and duplicate names, so each proxy's DXF class number names +/// its own class; the decoder's default classes the source lacks follow them. A proxy whose number +/// names no source class is left out, as `proxy_classes` leaves it out. Returns how many proxy objects and +/// entities were left out; nothing changes when the drawing has no proxies. `None`, with nothing +/// changed, when the classes would be more than the class numbers allow. +pub(super) fn restore_classes(doc: &mut CadDocument, classes: Vec) -> Option<(usize, usize)> { + let fits = |number: i32| number.checked_sub(500).and_then(|n| usize::try_from(n).ok()).is_some_and(|i| i < classes.len()); + let mut any = false; + let mut entities = Vec::new(); + for entity in doc.entities() { + if let EntityType::Extended(e) = entity + && let ExtendedEntityData::Proxy(p) = &e.data + { + any = true; + if !fits(p.class_id) { + entities.push(e.common.handle); + } + } + } + let mut objects = Vec::new(); + for (handle, object) in &doc.objects { + if let ObjectType::ProxyObject(p) = object { + any = true; + if !fits(p.class_id) { + objects.push(*handle); + } + } + } + if !any { + return Some((0, 0)); + } + let mut names: HashSet<_> = classes.iter().map(|c| c.dxf_name.to_uppercase()).collect(); + let defaults: Vec<_> = doc.classes.iter().filter(|c| names.insert(c.dxf_name.to_uppercase())).cloned().collect(); + if let Some(last) = classes.len().checked_add(defaults.len())?.checked_sub(1) { + class_number(last).ok()?; + } + for handle in &entities { + doc.remove_entity(*handle); + } + for handle in &objects { + doc.objects.remove(handle); + } + doc.classes.clear(); + for (index, mut class) in classes.into_iter().chain(defaults).enumerate() { + class.class_number = class_number(index).ok()?; + doc.classes.push_preserving(class); + } + Some((objects.len(), entities.len())) +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::{EntityCommon, ExtendedEntity, RegisteredClassEntityData}; + use acadrust::types::DxfVersion; + + fn proxy_doc() -> CadDocument { + let mut doc = CadDocument::new(); + doc.version = DxfVersion::AC1032; + doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity { + common: EntityCommon::default(), + data: ExtendedEntityData::Proxy(ProxyEntityData { + proxy_id: 498, + class_id: 501, + dxf_subclass: String::new(), + version: 33, + dwg_version: 33, + maintenance_version: 0, + from_dxf: false, + graphics: ProxyPayload::default(), + payload: ProxyPayload::from_bits(&[0x80], 1), + text_payload: ProxyPayload::default(), + object_ids: vec![], + }), + }))) + .unwrap(); + doc + } + + /// An ASCII DXF of `groups`, `(code, value)` per pair of lines. + fn dxf(groups: &[(i32, &str)]) -> Vec { + groups.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect::().into_bytes() + } + + #[test] + fn source_classes_keep_duplicates_and_metadata_before_defaults() { + let bytes = dxf(&[ + (0, "SECTION"), + (2, "HEADER"), + (9, "$DIMSTYLE"), + (2, "CLASSES"), + (0, "ENDSEC"), + (0, "SECTION"), + (2, "CLASSES"), + (0, "CLASS"), + (1, "Объект"), + (2, "First"), + (3, "Первый"), + (90, "1025"), + (91, "7"), + (280, "1"), + (281, "1"), + (0, "CLASS"), + (1, "Объект"), + (2, "Second"), + (3, "Второй"), + (90, "128"), + (281, "1"), + (0, "ENDSEC"), + (0, "EOF"), + ]); + let classes = source_classes(&bytes).unwrap(); + assert_eq!(classes.len(), 2); + assert_eq!(classes[0].application_name, "Первый"); + assert_eq!(classes[0].proxy_flags.0, 1025); + assert_eq!(classes[0].instance_count, 7); + assert!(classes[0].was_zombie); + assert_eq!((classes[0].class_number, classes[1].class_number), (500, 501)); + assert_eq!(classes[1].cpp_class_name, "Second"); + let mut doc = proxy_doc(); + assert_eq!(restore_classes(&mut doc, classes.clone()), Some((0, 0))); + assert_eq!(doc.classes.iter().take(2).cloned().collect::>(), classes); + // A binary DXF, and a CLASS record that can't be written back as it is, give no classes. + assert!(source_classes(b"AutoCAD Binary DXF\r\n\x1a\0").is_none()); + for (code, value) in [(1, ""), (2, ""), (90, "65536"), (91, "9223372036854775807"), (281, "x")] { + let mut class = vec![(1, "A"), (2, "A")]; + class.retain(|(c, _)| *c != code); + class.push((code, value)); + let mut groups = vec![(0, "SECTION"), (2, "CLASSES"), (0, "CLASS")]; + groups.extend(class); + groups.extend([(0, "ENDSEC"), (0, "EOF")]); + assert!(source_classes(&dxf(&groups)).is_none(), "{code} {value}"); + } + } + + #[test] + fn source_classes_decode_escapes_once_and_respect_legacy_code_pages() { + let body = "0\nCLASS\n1\n\\U+041E\\U+0431\\U+044A\\U+0435\\U+043A\\U+0442\n2\nClass\n3\nCaf\\U+00E9\\U+D83D\\U+DE42\\U+005CU+0041\n281\n1\n0\nENDSEC\n0\nEOF\n"; + let text = format!("0\nSECTION\n2\nCLASSES\n{body}"); + let classes = source_classes(text.as_bytes()).unwrap(); + assert_eq!(classes[0].dxf_name, "Объект"); + assert_eq!(classes[0].application_name, "Café🙂\\U+0041"); + + let prefix = + b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1251\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n0\nCLASS\n1\n"; + let encoded = [prefix.as_slice(), b"\xCF\xF0\xE8\xEC\xE5\xF0", b"\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n"].concat(); + assert_eq!(source_classes(&encoded).unwrap()[0].dxf_name, "Пример"); + // R2007+ text is UTF-8 even with a legacy code-page declaration. + let modern = String::from_utf8_lossy(prefix).replace("AC1015", "AC1032") + "Пример\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n"; + assert_eq!(source_classes(modern.as_bytes()).unwrap()[0].dxf_name, "Пример"); + } + + #[test] + fn proxies_of_missing_classes_are_left_out() { + for id in [-1, 499, 501, i32::MAX] { + let mut doc = proxy_doc(); + if let Some(EntityType::Extended(e)) = doc.entities_mut().next() + && let ExtendedEntityData::Proxy(p) = &mut e.data + { + p.class_id = id; + } + let mut class = DxfClass::new_entity("ENTITY", "Entity"); + class.class_number = 500; + assert_eq!(restore_classes(&mut doc, vec![class]), Some((0, 1)), "{id}"); + assert_eq!(doc.entities().count(), 0); + } + // Class 501 is there: the proxy keeps it, in its place. + let mut doc = proxy_doc(); + assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A"), DxfClass::new_entity("B", "B")]), Some((0, 0))); + assert_eq!(doc.entities().count(), 1); + assert_eq!(doc.classes.iter().nth(1).map(|c| (c.dxf_name.clone(), c.class_number)), Some(("B".to_string(), 501))); + // Without proxies the classes stay the decoder's. + let mut doc = CadDocument::new(); + let before: Vec<_> = doc.classes.iter().cloned().collect(); + assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A")]), Some((0, 0))); + assert_eq!(doc.classes.iter().cloned().collect::>(), before); + } + + #[test] + fn unrepresentable_proxies_keep_other_geometry_and_bounded_diagnostics() { + use acadrust::entities::{Line, RawRecord}; + use acadrust::objects::ProxyObject; + use acadrust::types::{Handle, Vector3}; + for count in [1, 10] { + let mut doc = proxy_doc(); + if let Some(EntityType::Extended(e)) = doc.entities_mut().next() + && let ExtendedEntityData::Proxy(p) = &mut e.data + { + p.class_id = i32::MAX; + } + let template = doc.entities().next().unwrap().clone(); + for _ in 1..count { + let mut entity = template.clone(); + entity.common_mut().handle = Handle::NULL; + doc.add_entity(entity).unwrap(); + } + let object = Handle::new(u64::MAX - 1); + assert!(!doc.objects.contains_key(&object)); + doc.objects.insert(object, ObjectType::ProxyObject(ProxyObject { handle: object, class_id: i32::MAX, ..Default::default() })); + let line = doc.add_entity(EntityType::Line(Line::from_points(Vector3::new(2.0, 3.0, 0.0), Vector3::new(12.0, 13.0, 0.0)))).unwrap(); + let raw = RawRecord { data: vec![0xAA, 0x55], handle_bits: 0, version: DxfVersion::AC1032 }; + doc.get_entity_mut(line).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone())); + let notes = prepare(&mut doc); + assert_eq!(notes.len(), (count + 1).min(6) + 1); + assert!(notes.iter().all(|n| n.starts_with("DWG:"))); + assert!(notes[0].contains(&format!("entities: {count}, objects: 1"))); + assert_eq!(doc.entities().count(), 1); + assert!(!doc.objects.contains_key(&object)); + let Some(EntityType::Line(actual)) = doc.get_entity(line) else { panic!("readable line") }; + assert_eq!(actual.start, Vector3::new(2.0, 3.0, 0.0)); + assert_eq!(actual.end, Vector3::new(12.0, 13.0, 0.0)); + assert_eq!(doc.get_entity(line).unwrap().common().raw_record.as_deref(), Some(&raw)); + } + } + #[test] + fn opaque_registered_class_ambiguity_is_not_guessed() { + let mut doc = CadDocument::new(); + doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom")); + doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom")); + doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity { + common: EntityCommon::default(), + data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData { + dxf_name: "CUSTOM".into(), + cpp_class_name: "Custom".into(), + properties: vec![], + payload: ProxyPayload::from_bits(&[0x80], 1), + object_ids: vec![], + }), + }))) + .unwrap(); + assert!(prepare(&mut doc).iter().any(|n| n.contains("ambiguous"))); + assert_eq!(doc.entities().count(), 0); + assert!(class_number(usize::MAX).is_err()); + } + #[test] + fn semantic_records_keep_verbatim_data_and_mixed_payloads_are_reported() { + use acadrust::entities::RawRecord; + use acadrust::objects::{SemanticProperty, SemanticPropertyValue}; + for payload in [ProxyPayload::default(), ProxyPayload::from_bits(&[0x80], 1)] { + let mut doc = CadDocument::new(); + let raw = RawRecord { data: vec![0xAA], handle_bits: 0, version: DxfVersion::AC1032 }; + let handle = doc + .add_entity(EntityType::Extended(Box::new(ExtendedEntity { + common: EntityCommon::default(), + data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData { + dxf_name: "SEMANTIC".into(), + cpp_class_name: "Semantic".into(), + properties: vec![SemanticProperty { + subclass: "Semantic".into(), + code: 1, + value: SemanticPropertyValue::Text("value".into()), + }], + payload: payload.clone(), + object_ids: vec![], + }), + }))) + .unwrap(); + doc.get_entity_mut(handle).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone())); + assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw)); + if payload.bit_count == 0 { + assert!(prepare(&mut doc).is_empty()); + assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw)); + } else { + // Kept as read, and named: its opaque data has no DXF form beside the properties. + assert!(prepare(&mut doc).iter().any(|n| n.contains("decoded properties"))); + assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw)); + } + } + } +} + +#[cfg(test)] +mod string_stream_tests { + use super::*; + use acadrust::entities::{EntityCommon, ExtendedEntity, RawRecord, RegisteredClassEntityData}; + use acadrust::types::DxfVersion; + use std::io::Cursor; + + #[test] + fn registered_transport_recovers_separate_non_byte_aligned_strings() { + for version in [DxfVersion::AC1027, DxfVersion::AC1032] { + let mut source = CadDocument::new(); + source.version = version; + source.classes.add_or_update(DxfClass::new_entity("CUSTOM_TEXT", "CustomText")); + let class_id = i32::from(source.classes.get_by_name("CUSTOM_TEXT").unwrap().class_number); + let text = ProxyPayload::from_bits(&[0xA5, 0xC7, 0xE0], 19); + let payload = ProxyPayload::from_bits(&[0x80], 1); + source + .add_entity(EntityType::Extended(Box::new(ExtendedEntity { + common: EntityCommon::default(), + data: ExtendedEntityData::Proxy(ProxyEntityData { + proxy_id: 498, + class_id, + dxf_subclass: "CustomText".into(), + version: 33, + dwg_version: 33, + maintenance_version: 0, + from_dxf: false, + graphics: ProxyPayload::default(), + payload: payload.clone(), + text_payload: text.clone(), + object_ids: vec![], + }), + }))) + .unwrap(); + let bytes = acadrust::DwgWriter::write_to_vec(&source).unwrap(); + let mut decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap(); + let entity = decoded.entities().next().unwrap(); + let handle = entity.common().handle; + let raw = entity.common().raw_record.clone().unwrap(); + assert_eq!(registered_text(&raw).unwrap().data(), text.data()); + assert_eq!(registered_text(&raw).unwrap().bit_count, 19); + let EntityType::Extended(entity) = decoded.get_entity_mut(handle).unwrap() else { panic!() }; + entity.data = ExtendedEntityData::RegisteredClass(RegisteredClassEntityData { + dxf_name: "CUSTOM_TEXT".into(), + cpp_class_name: "CustomText".into(), + properties: vec![], + payload: payload.clone(), + object_ids: vec![], + }); + entity.common.raw_record = Some(raw); + assert!(prepare(&mut decoded).is_empty()); + for _ in 0..2 { + let mut dxf = Vec::new(); + acadrust::DxfWriter::new(&decoded).write_to_writer(&mut dxf).unwrap(); + let bytes = crate::dxf_to_dwg_as(&dxf, "AC1032").unwrap(); + decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap(); + let p = decoded + .entities() + .find_map(|e| match e { + EntityType::Extended(e) => match &e.data { + ExtendedEntityData::Proxy(p) => Some(p), + _ => None, + }, + _ => None, + }) + .unwrap(); + assert_eq!(p.payload.bit_count, 1); + assert_eq!(p.payload.data(), payload.data()); + assert_eq!(p.text_payload.bit_count, 19); + assert_eq!(p.text_payload.data(), text.data()); + } + } + } + + #[test] + fn invalid_string_stream_boundaries_return_errors() { + for handle_bits in [-1, 1000] { + let raw = RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 }; + assert!(registered_text(&raw).is_err()); + } + } +} diff --git a/crates/dwg/src/worker.rs b/crates/dwg/src/worker.rs new file mode 100644 index 000000000..0c273257b --- /dev/null +++ b/crates/dwg/src/worker.rs @@ -0,0 +1,378 @@ +//! The DWG reader and writer in a child process. +//! +//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and +//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says +//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker +//! that reads one request from standard input and writes one reply to standard output. A file that +//! kills the worker then costs that one open or save, and every other open drawing stays safe. +//! +//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`] +//! run in the calling process, as they always did. There's no memory limit on the worker yet: that +//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate. +//! +//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as +//! untrusted input. + +use std::ffi::OsString; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Mutex; +use std::sync::mpsc::{RecvTimeoutError, channel}; +use std::time::Duration; + +use super::Limits; + +/// The argument that makes the app a worker. +pub const WORKER_ARG: &str = "--dwg-worker"; +/// Bumped when the messages change: an app updated while running may start a newer copy. +const PROTOCOL: u8 = 2; +const DECODE: u8 = 1; +const ENCODE: u8 = 2; +const CRASH: u8 = 0xff; +/// How long one file may take before the worker is stopped. It can't be interrupted otherwise. +const TIMEOUT: Duration = Duration::from_secs(30 * 60); +/// Most bytes accepted for what a reply carries besides the drawing. +const MAX_NOTES_BYTES: u64 = 64 << 20; +/// Most bytes accepted as a written DWG. +const MAX_DWG_REPLY: u64 = 2 << 30; +/// Most bytes one request holds: the largest input, whichever the direction, and its header. +const MAX_REQUEST: u64 = (2 << 30) + 256; + +struct Worker { + program: PathBuf, + args: Vec, +} + +static WORKER: Mutex> = Mutex::new(None); + +/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`]. +pub fn use_worker(program: PathBuf, args: Vec) { + if let Ok(mut w) = WORKER.lock() { + *w = Some(Worker { program, args }); + } +} + +/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be +/// found, the reader and writer stay in this process. +pub fn use_own_worker() { + match std::env::current_exe() { + Ok(program) => use_worker(program, vec![WORKER_ARG.into()]), + Err(e) => log::warn!("DWG worker: can't find this program: {e}"), + } +} + +/// Serve one request and return the exit code if this process was started as a worker. +pub fn serve_if_asked() -> Option { + (std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock())) +} + +/// [`super::dwg_to_dxf_noted`] in the worker, when there is one. +pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec, Vec), String> { + // Before starting a process, and so the same limit holds with or without one. + if bytes.len() > limits.max_dwg_bytes { + return super::dwg_to_dxf_noted(bytes, limits); + } + let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES); + match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") { + Some(reply) => reply?, + None => super::dwg_to_dxf_noted(bytes, limits), + } +} + +/// [`super::dxf_to_dwg_as`] in the worker, when there is one. +pub fn encode_as(dxf: &[u8], version: &str) -> Result, String> { + encode_noted(dxf, version).map(|(dwg, _)| dwg) +} + +/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes +/// back as notes of `TYPEcount`. +pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec, super::Unwritten), String> { + encode_kept_noted(dxf, version, None) +} + +/// [`encode_noted`] for a DXF whose carried-over groups were read from a file of version `kept` +/// ([`super::dxf_to_dwg_kept_noted`]); `None` is [`encode_noted`]. +pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec, super::Unwritten), String> { + if u8::try_from(version.len()).is_err() { + return Err(super::unsupported(version)); + } + // A kept version too long to send is unsupported by this bridge and changes nothing. + let kept = kept.filter(|k| u8::try_from(k.len()).is_ok()); + let mut header = request(ENCODE, Limits::DEFAULT, version); + push_str(&mut header, kept.unwrap_or("")); + match call(&header, dxf, MAX_DWG_REPLY, "writing this file") { + Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(¬es))), + None => encode_here(dxf, version, kept), + } +} + +/// The conversion in this process. +fn encode_here(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec, super::Unwritten), String> { + match kept { + Some(kept) => super::dxf_to_dwg_kept_noted(dxf, version, kept), + None => super::dxf_to_dwg_noted(dxf, version), + } +} + +fn unwritten_from(notes: &[String]) -> super::Unwritten { + notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::().ok()?))).fold( + super::Unwritten::new(), + |mut m, (kind, n)| { + *m.entry(kind).or_insert(0) = n; + m + }, + ) +} + +/// Make the worker abort, as the third-party code can: what the tests prove isolation with. +#[doc(hidden)] +pub fn crash_worker_for_test() -> Result<(), String> { + call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ()) +} + +fn request(op: u8, limits: Limits, version: &str) -> Vec { + let mut r = vec![PROTOCOL, op]; + r.extend((limits.max_dwg_bytes as u64).to_le_bytes()); + r.extend((limits.max_dxf_bytes as u64).to_le_bytes()); + push_str(&mut r, version); + r +} + +/// `text` with a one-byte length before it; callers keep it under 256 bytes. +fn push_str(r: &mut Vec, text: &str) { + r.push(u8::try_from(text.len()).unwrap_or(0)); + r.extend(text.as_bytes()); +} + +type Reply = Result<(Vec, Vec), String>; + +/// Run one request in a worker. `None`: none could be started, so the caller does the work. +/// `Err`: the worker went wrong, and `doing` says at what, for the user. +fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option> { + let mut command = { + let worker = WORKER.lock().ok()?; + let worker = worker.as_ref()?; + let mut c = Command::new(&worker.program); + c.args(&worker.args); + c + }; + command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + let mut child = match command.spawn() { + Ok(c) => c, + Err(e) => { + log::warn!("DWG worker: can't start it: {e}"); + return None; + } + }; + let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else { + let _ = child.kill(); + let _ = child.wait(); + return None; + }; + let received = std::thread::scope(|scope| { + // Written beside the read: a worker that replies before it has read everything, or dies, + // must block neither side. A failed write shows up as the reply going wrong. + scope.spawn(move || { + let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload)); + }); + let (tx, rx) = channel(); + scope.spawn(move || { + let mut reply = Vec::new(); + let read = from_child.take(reply_limit).read_to_end(&mut reply); + let _ = tx.send(read.map(|_| reply)); + }); + let received = rx.recv_timeout(TIMEOUT); + if matches!(received, Err(RecvTimeoutError::Timeout)) { + let _ = child.kill(); + } + received + }); + let status = child.wait(); + let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected"); + Some(match (received, status) { + (Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")), + (Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped), + _ => Err(stopped()), + }) +} + +/// Take `n` bytes off the front of `b`. +fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> { + let (head, tail) = b.split_at_checked(n)?; + *b = tail; + Some(head) +} + +fn take_u32(b: &mut &[u8]) -> Option { + let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?; + usize::try_from(u32::from_le_bytes(bytes)).ok() +} + +fn take_u64(b: &mut &[u8]) -> Option { + Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?)) +} + +/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1 +/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most +/// of the memory the app may use: they are kept, not copied. +fn parse_reply(mut reply: Vec) -> Option { + let total = reply.len(); + let mut b = &reply[..]; + match *take(&mut b, 1)?.first()? { + 0 => { + let count = take_u32(&mut b)?; + let mut notes = Vec::new(); + for _ in 0..count { + let n = take_u32(&mut b)?; + notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned()); + } + let n = usize::try_from(take_u64(&mut b)?).ok()?; + if b.len() != n { + return None; + } + reply.drain(..total - n); + Some(Ok((reply, notes))) + } + 1 => Some(Err(String::from_utf8_lossy(b).into_owned())), + _ => None, + } +} + +fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> { + match reply { + Ok((bytes, notes)) => { + out.write_all(&[0])?; + out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?; + for note in ¬es { + out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?; + if u32::try_from(note.len()).is_ok() { + out.write_all(note.as_bytes())?; + } + } + out.write_all(&(bytes.len() as u64).to_le_bytes())?; + out.write_all(&bytes)?; + } + Err(e) => { + out.write_all(&[1])?; + out.write_all(e.as_bytes())?; + } + } + out.flush() +} + +/// What a request asks for, or why it can't be served. +fn handle(mut b: &[u8]) -> Reply { + let bad = || "the DWG worker got a request it can't read (a different bridge version?)".to_string(); + if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) { + return Err(bad()); + } + let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?; + let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?; + let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?; + let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?; + let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?; + match op { + DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }), + ENCODE => { + // The version the DXF's carried-over groups were read from; empty for none. + let kept_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?; + let kept = take(&mut b, usize::from(kept_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?; + encode_here(b, version, (!kept.is_empty()).then_some(kept)) + .map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect())) + } + CRASH => std::process::abort(), + _ => Err(bad()), + } +} + +/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent. +fn serve(input: impl Read, mut output: impl Write) -> i32 { + let mut request = Vec::new(); + let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) { + Ok(_) => handle(&request), + Err(e) => Err(format!("the DWG worker could not read its request: {e}")), + }; + i32::from(write_reply(&mut output, reply).is_err()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wire(reply: Reply) -> Vec { + let mut out = Vec::new(); + write_reply(&mut out, reply).unwrap(); + out + } + + #[test] + fn what_the_writer_left_out_survives_the_reply() { + let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()]; + assert_eq!(unwritten_from(¬es), [("ACME_WIDGET".to_string(), 2)].into_iter().collect()); + } + + #[test] + fn replies_round_trip() { + let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()])); + assert_eq!(parse_reply(wire(ok.clone())), Some(ok)); + let failed: Reply = Err("DWG: no".into()); + assert_eq!(parse_reply(wire(failed.clone())), Some(failed)); + } + + #[test] + fn a_reply_cut_short_or_made_up_is_refused() { + let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()]))); + for n in 0..whole.len() { + assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}"); + } + assert_eq!(parse_reply(vec![7, 0, 0]), None); + // A note count far beyond what follows ends at the first missing note. + assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None); + assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None); + } + + #[test] + fn a_request_it_cannot_read_is_an_error_not_a_crash() { + for request in [ + &[][..], + &[PROTOCOL + 1, DECODE], + &[PROTOCOL], + &[PROTOCOL, DECODE, 1, 2, 3], + &[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ] { + assert!(handle(request).is_err(), "{request:?}"); + } + } + + /// An encode request carries the kept version after the target one; a request without it is + /// one it can't read, and one with it reaches the converter. + #[test] + fn an_encode_request_carries_the_kept_version() { + let bad = "the DWG worker got a request it can't read (a different bridge version?)"; + let short = self::request(ENCODE, Limits::DEFAULT, "AC1032"); + assert_eq!(handle(&short).unwrap_err(), bad); + for kept in ["", "AC1032"] { + let mut request = self::request(ENCODE, Limits::DEFAULT, "AC1032"); + push_str(&mut request, kept); + request.extend(b"not a drawing"); + let err = handle(&request).unwrap_err(); + assert_ne!(err, bad, "kept {kept:?}"); + } + } + + #[test] + fn serving_reports_a_decode_failure_as_a_reply() { + let mut request = self::request(DECODE, Limits::DEFAULT, ""); + request.extend(b"AC1032 not really a drawing"); + let mut out = Vec::new(); + assert_eq!(serve(&request[..], &mut out), 0); + assert!(matches!(parse_reply(out.clone()), Some(Err(_))), "{out:?}"); + } +} diff --git a/crates/dxf/Cargo.toml b/crates/dxf/Cargo.toml index 70c4f1234..11de1c377 100644 --- a/crates/dxf/Cargo.toml +++ b/crates/dxf/Cargo.toml @@ -15,3 +15,4 @@ craft-codepage = { workspace = true } thiserror = { workspace = true } log = "0.4" encoding_rs = "0.8.35" +kurbo = { workspace = true } diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 0109e9040..4cf3e46e9 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -11,6 +11,7 @@ use std::io::Write as _; pub mod model; +pub mod paths; mod version; pub use version::Version; diff --git a/crates/dxf/src/model/write.rs b/crates/dxf/src/model/write.rs index 3ee234153..6d2879cd9 100644 --- a/crates/dxf/src/model/write.rs +++ b/crates/dxf/src/model/write.rs @@ -1478,6 +1478,13 @@ const MODEL: &str = "*Model_Space"; const PAPER: &str = "*Paper_Space"; impl Drawing<'_> { + /// Set `$LUNITS` without making callers construct low-level group tags. + pub fn with_linear_units(mut self, units: i64) -> Self { + self.header.retain(|(name, _)| !name.eq_ignore_ascii_case("$LUNITS")); + self.header.push(("$LUNITS".into(), vec![Tag::i(70, units)])); + self + } + /// The whole file as tags, ending with `0 EOF`, and how many entities were left out /// ([`EntityKind::Other`], and a MULTILEADER before 2007 or an ACAD_TABLE before 2004, which it has no groups for: pass /// those in `raw_entities`). diff --git a/crates/dxf/src/paths.rs b/crates/dxf/src/paths.rs new file mode 100644 index 000000000..7e4f18f0f --- /dev/null +++ b/crates/dxf/src/paths.rs @@ -0,0 +1,859 @@ +//! Bounded conversion of common planar DXF entities into neutral Bézier paths. +//! +//! This is geometry extraction, not a full CAD scene renderer: unsupported entities such as +//! text, dimensions, meshes, and block inserts are reported as warnings and omitted. + +use kurbo::{BezPath, Point, Rect, Shape as _}; + +/// Limits on the number of paths returned by [`import`]. Parsing also uses the crate's tag cap. +pub const MAX_PATHS: usize = 200_000; +/// Aggregate number of Bézier elements a drawing may generate during conversion. +pub const MAX_PATH_ELEMENTS: usize = 2_000_000; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("DXF parse failed: {0}")] + Parse(#[from] crate::DxfError), + #[error("DXF contains more than {MAX_PATHS} paths")] + TooManyPaths, + #[error("DXF curve exceeds the subdivision limit")] + TooManySegments, + #[error("DXF contains a non-finite path coordinate")] + NonFinite, +} + +/// Geometry in source DXF coordinates. `unit_to_mm` maps a source coordinate to millimetres; +/// unitless drawings use `1.0`, as the coordinates are then interpreted as millimetres. +#[derive(Debug, Clone)] +pub struct Imported { + pub paths: Vec, + pub bounds: Option, + pub unit_to_mm: f64, + pub warnings: Vec, +} + +/// Parse bounded DXF input and extract visible planar linework. +pub fn import(bytes: &[u8]) -> Result { + let tags = crate::parse_with_limit(bytes, crate::max_tags())?; + let drawing = crate::model::Document::read(&tags); + let unit_to_mm = units_to_mm(drawing.header.insunits(), drawing.header.measurement()); + let layer_visibility: std::collections::HashMap<_, _> = + drawing.layers().into_iter().map(|layer| (layer.name, layer.on && !layer.frozen)).collect(); + let mut paths = Vec::new(); + let mut warnings = Vec::new(); + let mut unsupported = 0usize; + let mut elements = 0usize; + for record in &drawing.entities { + let entity = crate::model::Entity::read(record); + if !entity.common.visible || record.paper_space() || layer_visibility.get(&entity.common.layer) == Some(&false) { + continue; + } + if excessive_sweep(&entity.kind) { + return Err(Error::TooManySegments); + } + let remaining = MAX_PATH_ELEMENTS.saturating_sub(elements); + if estimated_elements(&entity.kind) > remaining { + return Err(Error::TooManySegments); + } + let converted = if let crate::model::EntityKind::Spline(spline) = &entity.kind { + spline_path(spline)?.into_iter().collect() + } else { + entity_paths(&entity.kind, entity.common.extrusion) + }; + if converted.iter().flat_map(|path| path.elements()).any(|element| { + let finite = |p: Point| p.x.is_finite() && p.y.is_finite(); + match *element { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => !finite(p), + kurbo::PathEl::QuadTo(a, b) => !finite(a) || !finite(b), + kurbo::PathEl::CurveTo(a, b, c) => !finite(a) || !finite(b) || !finite(c), + kurbo::PathEl::ClosePath => false, + } + }) { + return Err(Error::NonFinite); + } + if converted.is_empty() { + unsupported = unsupported.saturating_add(1); + continue; + } + elements = elements.saturating_add(converted.iter().map(|path| path.elements().len()).sum::()); + if elements > MAX_PATH_ELEMENTS { + return Err(Error::TooManySegments); + } + if paths.len().saturating_add(converted.len()) > MAX_PATHS { + return Err(Error::TooManyPaths); + } + paths.extend(converted); + } + if unsupported > 0 { + warnings.push(format!("{unsupported} visible DXF entities are unsupported and were omitted")); + } + if drawing.skipped.iter().any(|(_, n)| *n > 0) { + warnings.push("some unsupported or stray DXF records were skipped".into()); + } + let bounds = paths.iter().map(|path| path.bounding_box()).reduce(|a, b| a.union(b)); + Ok(Imported { paths, bounds, unit_to_mm, warnings }) +} + +fn estimated_elements(kind: &crate::model::EntityKind) -> usize { + use crate::model::EntityKind as E; + let arc_count = |sweep: f64| (sweep.abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.0) as usize; + let bulge_count = |bulge: f64| { + if bulge.is_finite() && bulge.abs() > 1e-12 { arc_count(4.0 * bulge.atan()) } else { 1 } + }; + let polyline_count = |vertices: &[(Point, f64)], closed: bool| { + if vertices.is_empty() { + return 0; + } + let mut count = 1usize; + for pair in vertices.windows(2) { + count = count.saturating_add(bulge_count(pair[0].1)); + } + if closed && vertices.len() > 1 { + count = count.saturating_add(bulge_count(vertices.last().map_or(0.0, |vertex| vertex.1))); + count = count.saturating_add(1); + } + count + }; + match kind { + E::Line { .. } => 2, + E::Circle { .. } => 6, + E::Arc { start_angle, end_angle, .. } => 1usize.saturating_add(arc_count((end_angle - start_angle).to_radians())), + E::LwPolyline(poly) => { + let vertices: Vec<_> = poly.vertices.iter().map(|v| (Point::new(v.x, v.y), v.bulge)).collect(); + polyline_count(&vertices, poly.closed) + } + E::Polyline(poly) if !poly.is_mesh() && !poly.is_polyface() => { + let vertices: Vec<_> = poly + .vertices + .iter() + .filter(|vertex| !vertex.is_face()) + .map(|vertex| (Point::new(vertex.location[0], vertex.location[1]), vertex.bulge)) + .collect(); + polyline_count(&vertices, poly.closed()) + } + E::Ellipse { start, end, .. } => 1usize.saturating_add(arc_count(end - start)), + // `spline_path` already caps subdivision at MAX_SPLINE_ELEMENTS. Its actual output is + // checked against the drawing-wide budget after conversion. + E::Spline(_) => 0, + _ => 0, + } +} + +fn units_to_mm(code: i64, _measurement: Option) -> f64 { + match code { + 0 => 1.0, + 1 => 25.4, + 2 => 304.8, + 3 => 1_609_344.0, + 5 => 10.0, + 6 => 1_000.0, + 7 => 1_000_000.0, + 8 => 0.000_025_4, + 9 => 0.0254, + 10 => 914.4, + 11 => 1e-7, + 12 => 1e-6, + 13 => 0.001, + 14 => 100.0, + 15 => 10_000.0, + 16 => 100_000.0, + 17 => 1e12, + 18 => 149_597_870_700_000.0, + 19 => 9.460_730_472_580_8e18, + 20 => 3.085_677_581_491_367e19, + 21 => 1200.0 / 3937.0 * 1000.0, + 22 => 1200.0 / 3937.0 * 1000.0 / 12.0, + 23 => 1200.0 / 3937.0 * 1000.0 * 3.0, + 24 => 1200.0 / 3937.0 * 1000.0 * 5280.0, + _ => 1.0, + } +} + +fn entity_paths(kind: &crate::model::EntityKind, normal: crate::model::Point) -> Vec { + use crate::model::EntityKind as E; + match kind { + E::Line { start, end } => vec![line(*start, *end)], + E::Circle { center, radius } if radius.is_finite() && *radius > 0.0 => { + vec![map_ocs_path(circle(Point::new(center[0], center[1]), *radius), normal, center[2])] + } + E::Arc { center, radius, start_angle, end_angle } + if radius.is_finite() && *radius > 0.0 && start_angle.is_finite() && end_angle.is_finite() => + { + vec![map_ocs_path(arc(Point::new(center[0], center[1]), *radius, start_angle.to_radians(), end_angle.to_radians()), normal, center[2])] + } + E::LwPolyline(poly) => { + vec![map_ocs_path(polyline(poly.vertices.iter().map(|v| (Point::new(v.x, v.y), v.bulge)), poly.closed), normal, poly.elevation)] + } + E::Polyline(poly) if !poly.is_mesh() && !poly.is_polyface() => { + let is_3d = poly.flags & 8 != 0; + let z = if is_3d { 0.0 } else { poly.elevation }; + let path = + polyline(poly.vertices.iter().filter(|v| !v.is_face()).map(|v| (Point::new(v.location[0], v.location[1]), v.bulge)), poly.closed()); + vec![if is_3d { path } else { map_ocs_path(path, normal, z) }] + } + E::Ellipse { center, major, ratio, start, end } if ratio.is_finite() && *ratio > 0.0 => { + vec![ellipse(*center, *major, normal, *ratio, *start, *end)] + } + _ => Vec::new(), + } +} + +const MAX_SPLINE_ELEMENTS: usize = 200_000; +const SPLINE_TOLERANCE: f64 = 1e-6; +const MAX_SPLINE_DEPTH: u32 = 12; +const MAX_SPLINE_DEGREE: usize = 11; +const MIN_RELATIVE_WEIGHT: f64 = 1e-100; + +#[derive(Clone, Copy)] +struct Homogeneous { + x: f64, + y: f64, + w: f64, +} + +impl Homogeneous { + fn lerp(self, other: Self, t: f64) -> Self { + let s = 1.0 - t; + Self { x: self.x * s + other.x * t, y: self.y * s + other.y * t, w: self.w * s + other.w * t } + } + fn point(self) -> Option { + (self.w.abs() > 1e-300).then(|| Point::new(self.x / self.w, self.y / self.w)).filter(|point| point.x.is_finite() && point.y.is_finite()) + } +} + +// Keep the NURBS conversion semantics of the previous importer: exact polynomial Bezier +// spans through cubic degree, tolerance-bounded rational/high-degree spans, invalid-weight +// fallback, and fit-point fallback when control data is malformed. +fn spline_path(spline: &crate::model::Spline) -> Result, Error> { + let weights = (spline.flags & 4 != 0 && spline.weights.len() == spline.control.len()).then_some(spline.weights.as_slice()); + let mut limit_hit = false; + let converted = usize::try_from(spline.degree).ok().and_then(|degree| nurbs(degree, &spline.knots, &spline.control, weights, &mut limit_hit)); + let path = converted + .map(|mut path| { + if spline.flags & 1 != 0 { + path.close_path(); + } + path + }) + .or_else(|| { + let closed = spline.flags & 1 != 0; + // `through` emits one move, n-1 (open) or n (closed) cubics, and + // an additional close element for closed paths. Check before allocating + // its converted point vector. + let max_points = MAX_SPLINE_ELEMENTS.saturating_sub(if closed { 2 } else { 0 }); + if spline.fit.len() > max_points { + limit_hit = true; + return None; + } + through(&spline.fit.iter().map(|p| Point::new(p[0], p[1])).collect::>(), closed) + }); + if limit_hit { + return Err(Error::TooManySegments); + } + Ok(path) +} + +fn nurbs(degree: usize, knots: &[f64], control: &[[f64; 3]], weights: Option<&[f64]>, limit_hit: &mut bool) -> Option { + let n = control.len(); + if !(1..=MAX_SPLINE_DEGREE).contains(°ree) + || n <= degree + || knots.len() != n + degree + 1 + || knots.windows(2).any(|w| w[1] < w[0]) + || control.iter().any(|p| !p[0].is_finite() || !p[1].is_finite()) + || knots.iter().any(|k| !k.is_finite()) + { + return None; + } + let raw = |i: usize| weights.and_then(|w| w.get(i)).copied().filter(|w| *w > 0.0 && w.is_finite()).unwrap_or(1.0); + let max_w = (0..n).map(raw).fold(0.0, f64::max); + if max_w == 0.0 { + return None; + } + let weight = |i: usize| (raw(i) / max_w).max(MIN_RELATIVE_WEIGHT); + let rational = (0..n).any(|i| (weight(i) - weight(0)).abs() > 1e-12); + let (lo, hi) = control.iter().fold((Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)), |(lo, hi), p| { + (Point::new(lo.x.min(p[0]), lo.y.min(p[1])), Point::new(hi.x.max(p[0]), hi.y.max(p[1]))) + }); + let tolerance = ((hi - lo).hypot() * SPLINE_TOLERANCE).max(1e-12); + let mut path = BezPath::new(); + for span in degree..n { + let (Some(&t0), Some(&t1)) = (knots.get(span), knots.get(span + 1)) else { continue }; + if t1 <= t0 { + continue; + } + let bezier: Vec = (0..=degree) + .map(|j| { + let args: Vec<_> = (0..degree).map(|k| if k < degree - j { t0 } else { t1 }).collect(); + blossom(degree, knots, control, span, &args, &weight) + }) + .collect::>()?; + let start = bezier.first()?.point()?; + let needs_new_subpath = path_endpoint(&path).is_none_or(|end| end.distance(start) > 1e-12 * (1.0 + start.to_vec2().hypot())); + if needs_new_subpath { + path.move_to(start); + } + // A rational degree-one span changes its parameterization with its weights, but its locus + // is still the straight segment between its endpoints. Preserve that exact geometry. + let polynomial: Option> = if rational && degree > 1 { None } else { bezier.iter().map(|p| p.point()).collect() }; + match polynomial.as_deref() { + Some([_, p]) => path.line_to(*p), + Some([_, q, p]) => path.quad_to(*q, *p), + Some([_, a, b, p]) => path.curve_to(*a, *b, *p), + _ => fit_spline(&mut path, &bezier, tolerance, 0, limit_hit)?, + } + if path.elements().len() > MAX_SPLINE_ELEMENTS { + *limit_hit = true; + return None; + } + } + (path.elements().len() > 1).then_some(path) +} + +fn path_endpoint(path: &BezPath) -> Option { + match path.elements().last()? { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => Some(*p), + kurbo::PathEl::QuadTo(_, p) => Some(*p), + kurbo::PathEl::CurveTo(_, _, p) => Some(*p), + kurbo::PathEl::ClosePath => None, + } +} + +fn blossom(degree: usize, knots: &[f64], control: &[[f64; 3]], span: usize, args: &[f64], weight: &impl Fn(usize) -> f64) -> Option { + // Only the active degree+1 control points are needed for one span. Convert this small window + // here instead of making a second vector proportional to an untrusted entity's full controls. + let first = span.checked_sub(degree)?; + let mut points: Vec<_> = control + .get(first..=span)? + .iter() + .enumerate() + .map(|(offset, p)| { + let w = weight(first + offset); + Homogeneous { x: p[0] * w, y: p[1] * w, w } + }) + .collect(); + for r in 1..=degree { + let u = *args.get(r - 1)?; + for j in (r..=degree).rev() { + let i = span - degree + j; + let (a, b) = (*knots.get(i)?, *knots.get(i + degree + 1 - r)?); + let alpha = if b - a > 1e-300 { (u - a) / (b - a) } else { 0.0 }; + let previous = *points.get(j - 1)?; + let current = points.get_mut(j)?; + *current = previous.lerp(*current, alpha); + } + } + points.get(degree).copied() +} + +fn eval_spline_bezier(bezier: &[Homogeneous], t: f64) -> Option<(Point, kurbo::Vec2)> { + let mut points = bezier.to_vec(); + let n = points.len().checked_sub(1)?; + for level in (1..=n).rev() { + if level == 1 { + let (a, b) = (*points.first()?, *points.get(1)?); + let p = a.lerp(b, t); + let point = p.point()?; + let k = n as f64; + let (dx, dy, dw) = (k * (b.x - a.x), k * (b.y - a.y), k * (b.w - a.w)); + let w2 = p.w * p.w; + return Some((point, kurbo::Vec2::new((dx * p.w - p.x * dw) / w2, (dy * p.w - p.y * dw) / w2))); + } + for i in 0..level { + let next = *points.get(i + 1)?; + let current = points.get_mut(i)?; + *current = current.lerp(next, t); + } + } + Some((points.first()?.point()?, kurbo::Vec2::ZERO)) +} + +fn fit_spline(path: &mut BezPath, bezier: &[Homogeneous], tolerance: f64, depth: u32, limit_hit: &mut bool) -> Option<()> { + let (p0, d0) = eval_spline_bezier(bezier, 0.0)?; + let (p3, d3) = eval_spline_bezier(bezier, 1.0)?; + let (c1, c2) = (p0 + d0 / 3.0, p3 - d3 / 3.0); + if ![c1, c2].iter().all(|p| p.x.is_finite() && p.y.is_finite()) { + path.line_to(p3); + return Some(()); + } + let candidate = [p0, c1, c2, p3]; + if cubic_error_bound(bezier, &candidate).is_some_and(|error| error <= tolerance) { + if path.elements().len() >= MAX_SPLINE_ELEMENTS { + *limit_hit = true; + return None; + } + path.curve_to(c1, c2, p3); + return Some(()); + } + if depth >= MAX_SPLINE_DEPTH || path.elements().len() >= MAX_SPLINE_ELEMENTS { + *limit_hit = true; + return None; + } + let (left, right) = split_homogeneous_bezier(bezier)?; + fit_spline(path, &left, tolerance, depth + 1, limit_hit)?; + fit_spline(path, &right, tolerance, depth + 1, limit_hit) +} + +fn split_homogeneous_bezier(bezier: &[Homogeneous]) -> Option<(Vec, Vec)> { + let count = bezier.len(); + if count < 2 { + return None; + } + let mut row = bezier.to_vec(); + let mut left = vec![*bezier.first()?; count]; + let mut right = vec![*bezier.last()?; count]; + left[0] = row[0]; + right[count - 1] = row[count - 1]; + for level in 1..count { + for index in 0..count - level { + row[index] = row[index].lerp(row[index + 1], 0.5); + } + left[level] = row[0]; + right[count - 1 - level] = row[count - 1 - level]; + } + Some((left, right)) +} + +// Bound the rational curve/cubic difference over the whole parameter interval. In Bernstein +// form, X(t)-W(t)C(t) has degree p+3, so its norm is bounded by the largest coefficient norm; +// W(t) is a convex combination of positive weights and is at least their minimum. +fn cubic_error_bound(bezier: &[Homogeneous], cubic: &[Point; 4]) -> Option { + let degree = bezier.len().checked_sub(1)?; + let total = degree.checked_add(3)?; + let min_weight = bezier.iter().map(|point| point.w).fold(f64::INFINITY, f64::min); + if !(min_weight > 0.0 && min_weight.is_finite()) { + return None; + } + let mut max_error_numerator: f64 = 0.0; + for k in 0..=total { + let mut x = 0.0; + let mut y = 0.0; + for (i, point) in bezier.iter().enumerate() { + let Some(j) = k.checked_sub(i).filter(|j| *j < 4) else { continue }; + let factor = binomial(degree, i) * binomial(3, j) / binomial(total, k); + x += (point.x - point.w * cubic[j].x) * factor; + y += (point.y - point.w * cubic[j].y) * factor; + } + max_error_numerator = max_error_numerator.max(x.hypot(y)); + } + Some(max_error_numerator / min_weight) +} + +fn binomial(n: usize, k: usize) -> f64 { + if k > n { + return 0.0; + } + let k = k.min(n - k); + (1..=k).fold(1.0, |value, i| value * (n + 1 - i) as f64 / i as f64) +} + +fn through(points: &[Point], closed: bool) -> Option { + let n = points.len(); + let first = *points.first()?; + if n < 2 { + return None; + } + let at = |i: isize| { + let len = n as isize; + let j = if closed { i.rem_euclid(len) } else { i.clamp(0, len - 1) }; + points.get(j as usize).copied().unwrap_or(first) + }; + let mut path = BezPath::new(); + path.move_to(first); + let segments = if closed { n } else { n - 1 }; + for i in 0..segments as isize { + let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2)); + path.curve_to(p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2); + } + if closed { + path.close_path(); + } + Some(path) +} + +fn map_ocs_path(path: BezPath, normal: crate::model::Point, z: f64) -> BezPath { + let [x, y, n] = crate::model::ocs_axes(normal); + let transform = kurbo::Affine::new([x[0], x[1], y[0], y[1], n[0] * z, n[1] * z]); + transform * path +} + +fn excessive_sweep(kind: &crate::model::EntityKind) -> bool { + let cap = 4096.0 * std::f64::consts::FRAC_PI_2; + let over = |sweep: f64| !sweep.is_finite() || sweep.abs() > cap; + match kind { + crate::model::EntityKind::Arc { start_angle, end_angle, .. } => over((end_angle - start_angle).to_radians()), + crate::model::EntityKind::Ellipse { start, end, .. } => over(end - start), + _ => false, + } +} + +fn line(start: crate::model::Point, end: crate::model::Point) -> BezPath { + let mut path = BezPath::new(); + path.move_to((start[0], start[1])); + path.line_to((end[0], end[1])); + path +} + +fn polyline(vertices: impl IntoIterator, closed: bool) -> BezPath { + let vertices = vertices.into_iter().collect::>(); + let mut path = BezPath::new(); + let Some((first, _)) = vertices.first().copied() else { return path }; + path.move_to(first); + for i in 1..vertices.len() { + let (a, bulge) = vertices[i - 1]; + let (b, _) = vertices[i]; + if bulge.is_finite() && bulge.abs() > 1e-12 { + append_bulge(&mut path, a, b, bulge); + } else { + path.line_to(b); + } + } + if closed && vertices.len() > 1 { + let (a, bulge) = *vertices.last().unwrap_or(&(first, 0.0)); + if bulge.is_finite() && bulge.abs() > 1e-12 { + append_bulge(&mut path, a, first, bulge); + } else { + path.line_to(first); + } + path.close_path(); + } + path +} + +fn append_bulge(path: &mut BezPath, a: Point, b: Point, bulge: f64) { + let chord = b - a; + let length = chord.hypot(); + if !(length.is_finite() && length > 0.0) { + path.line_to(b); + return; + } + let center = a + chord * 0.5 + kurbo::Vec2::new(-chord.y, chord.x) * ((1.0 - bulge * bulge) / (4.0 * bulge)); + let start = (a.y - center.y).atan2(a.x - center.x); + let sweep = 4.0 * bulge.atan(); + let radius = length * (1.0 + bulge * bulge) / (4.0 * bulge.abs()); + arc_segments(path, center, start, sweep, radius); +} + +fn circle(center: Point, radius: f64) -> BezPath { + let mut path = BezPath::new(); + path.move_to((center.x + radius, center.y)); + arc_segments(&mut path, center, 0.0, std::f64::consts::TAU, radius); + path.close_path(); + path +} + +fn arc(center: Point, radius: f64, start: f64, end: f64) -> BezPath { + let mut path = BezPath::new(); + path.move_to((center.x + radius * start.cos(), center.y + radius * start.sin())); + let sweep = if end >= start { end - start } else { end - start + std::f64::consts::TAU }; + arc_segments(&mut path, center, start, sweep, radius); + path +} + +fn arc_segments(path: &mut BezPath, center: Point, start: f64, sweep: f64, radius: f64) { + let count = (sweep.abs() / (std::f64::consts::FRAC_PI_2)).ceil().max(1.0) as usize; + if count > 4096 { + return; + } + let step = sweep / count as f64; + for i in 0..count { + let a = start + step * i as f64; + let b = a + step; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let p1 = Point::new(center.x + radius * (a.cos() - k * a.sin()), center.y + radius * (a.sin() + k * a.cos())); + let p2 = Point::new(center.x + radius * (b.cos() + k * b.sin()), center.y + radius * (b.sin() - k * b.cos())); + let p3 = Point::new(center.x + radius * b.cos(), center.y + radius * b.sin()); + path.curve_to(p1, p2, p3); + } +} + +fn ellipse(center: crate::model::Point, major: crate::model::Point, normal: crate::model::Point, ratio: f64, start: f64, end: f64) -> BezPath { + let minor = cross(normal, major); + let minor_len = minor.iter().map(|v| v * v).sum::().sqrt(); + if !(minor_len.is_finite() && minor_len > 0.0) { + return BezPath::new(); + } + let major_len = major.iter().map(|v| v * v).sum::().sqrt(); + let bx = [minor[0] / minor_len * major_len * ratio, minor[1] / minor_len * major_len * ratio, minor[2] / minor_len * major_len * ratio]; + let point = |t: f64| Point::new(center[0] + major[0] * t.cos() + bx[0] * t.sin(), center[1] + major[1] * t.cos() + bx[1] * t.sin()); + let mut path = BezPath::new(); + path.move_to(point(start)); + let sweep = if end >= start { end - start } else { end - start + std::f64::consts::TAU }; + let count = (sweep.abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.0) as usize; + if count > 4096 { + return path; + } + let step = sweep / count as f64; + for i in 0..count { + let a = start + step * i as f64; + let b = a + step; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let derivative = |t: f64| Point::new(-major[0] * t.sin() + bx[0] * t.cos(), -major[1] * t.sin() + bx[1] * t.cos()); + let p1 = point(a) + derivative(a).to_vec2() * k; + let p2 = point(b) - derivative(b).to_vec2() * k; + path.curve_to(p1, p2, point(b)); + } + path +} + +fn cross(a: crate::model::Point, b: crate::model::Point) -> crate::model::Point { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +#[cfg(test)] +mod spline_tests { + use super::*; + use crate::model::Spline; + use kurbo::ParamCurveNearest; + + // The previous importer accepted cubic fits based on seven fixed probes, then bisected the + // parameter interval. Keep this test-only reference to compare real legacy output with the + // conservative fit used by craft-dxf. + fn prior_fit(path: &mut BezPath, bezier: &[Homogeneous], t0: f64, t1: f64, tolerance: f64, depth: u32) -> Option<()> { + let (p0, d0) = eval_spline_bezier(bezier, t0)?; + let (p3, d3) = eval_spline_bezier(bezier, t1)?; + let h = (t1 - t0) / 3.0; + let (c1, c2) = (p0 + d0 * h, p3 - d3 * h); + if ![c1, c2].iter().all(|point| point.x.is_finite() && point.y.is_finite()) { + path.line_to(p3); + return Some(()); + } + let cubic = kurbo::CubicBez::new(p0, c1, c2, p3); + let close = (1..8).all(|i| { + let t = f64::from(i) / 8.0; + eval_spline_bezier(bezier, t0 + (t1 - t0) * t).is_some_and(|(point, _)| kurbo::ParamCurve::eval(&cubic, t).distance(point) <= tolerance) + }); + if close || depth >= MAX_SPLINE_DEPTH || path.elements().len() >= MAX_SPLINE_ELEMENTS { + path.curve_to(c1, c2, p3); + return Some(()); + } + let midpoint = (t0 + t1) / 2.0; + prior_fit(path, bezier, t0, midpoint, tolerance, depth + 1)?; + prior_fit(path, bezier, midpoint, t1, tolerance, depth + 1) + } + + fn legacy_path(bezier: &[Homogeneous], tolerance: f64) -> BezPath { + let mut path = BezPath::new(); + let start = eval_spline_bezier(bezier, 0.0).expect("legacy start point").0; + path.move_to(start); + prior_fit(&mut path, bezier, 0.0, 1.0, tolerance, 0).expect("legacy fit succeeds"); + path + } + + fn sampled_path_distance(from: &BezPath, to: &BezPath, tolerance: f64) -> f64 { + let target: Vec<_> = to.segments().collect(); + from.segments() + .flat_map(|segment| (0..=32).map(move |i| kurbo::ParamCurve::eval(&segment, f64::from(i) / 32.0))) + .map(|point| target.iter().map(|segment| segment.nearest(point, tolerance / 100.0).distance_sq.sqrt()).fold(f64::INFINITY, f64::min)) + .fold(0.0_f64, f64::max) + } + + fn cubic() -> Spline { + Spline { + degree: 3, + flags: 8, + knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], + control: vec![[0.0, 0.0, 0.0], [1.0, 2.0, 0.0], [2.0, 2.0, 0.0], [3.0, 0.0, 0.0]], + weights: Vec::new(), + fit: Vec::new(), + start_tangent: None, + end_tangent: None, + fit_tolerance: 0.0, + } + } + + fn xyz(points: &[Point]) -> Vec<[f64; 3]> { + points.iter().map(|point| [point.x, point.y, 0.0]).collect() + } + + #[test] + fn spline_path_follows_clamped_control_curve_and_bounds_work() { + let path = spline_path(&cubic()).unwrap().expect("valid cubic spline"); + assert_eq!(path.elements().len(), 2); + assert!(matches!(path.elements()[0], kurbo::PathEl::MoveTo(p) if p == Point::new(0.0, 0.0))); + assert!(matches!(path.elements()[1], kurbo::PathEl::CurveTo(a, b, p) + if a == Point::new(1.0, 2.0) && b == Point::new(2.0, 2.0) && p == Point::new(3.0, 0.0))); + assert_eq!(estimated_elements(&crate::model::EntityKind::Spline(cubic())), 0); + } + + #[test] + fn rational_closed_periodic_and_fit_point_splines_keep_compatibility() { + let mut rational_line = Spline { + degree: 1, + flags: 4, + knots: vec![0.0, 0.0, 1.0, 1.0], + control: vec![[2.0, 3.0, 0.0], [5.0, 7.0, 0.0]], + weights: vec![1.0, 100.0], + fit: Vec::new(), + start_tangent: None, + end_tangent: None, + fit_tolerance: 0.0, + }; + rational_line.flags |= 8; + let line = spline_path(&rational_line).unwrap().expect("rational degree-one spline"); + assert!(matches!(line.elements(), [kurbo::PathEl::MoveTo(a), kurbo::PathEl::LineTo(b)] + if *a == Point::new(2.0, 3.0) && *b == Point::new(5.0, 7.0))); + + let mut rational = cubic(); + rational.flags |= 4; + rational.weights = vec![1.0, 0.5, 0.5, 1.0]; + let rational_path = spline_path(&rational).unwrap().expect("rational spline"); + assert!(rational_path.elements().iter().any(|el| matches!(el, kurbo::PathEl::CurveTo(..)))); + + let mut closed = cubic(); + closed.flags |= 1; + assert!(matches!(spline_path(&closed).unwrap().unwrap().elements().last(), Some(kurbo::PathEl::ClosePath))); + let mut periodic = cubic(); + periodic.flags |= 2; + assert!(!matches!(spline_path(&periodic).unwrap().unwrap().elements().last(), Some(kurbo::PathEl::ClosePath))); + + let mut fit = cubic(); + fit.knots.clear(); + fit.fit = vec![[0.0, 0.0, 0.0], [1.0, 2.0, 0.0], [3.0, 0.0, 0.0]]; + let path = spline_path(&fit).unwrap().expect("fit-point fallback"); + assert!(matches!(path.elements()[0], kurbo::PathEl::MoveTo(p) if p == Point::new(0.0,0.0))); + assert!(matches!(path.elements().last(), Some(kurbo::PathEl::CurveTo(_, _, p)) if *p == Point::new(3.0,0.0))); + + let mut negative = cubic(); + negative.degree = -1; + negative.fit = fit.fit; + assert!(spline_path(&negative).unwrap().is_some(), "invalid degree reaches the prior fit-point fallback"); + } + + #[test] + fn spline_path_rejects_malformed_knots_and_defaults_invalid_weights() { + let mut spline = cubic(); + spline.knots[4] = f64::NAN; + assert!(spline_path(&spline).unwrap().is_none()); + let mut spline = cubic(); + spline.flags |= 4; + spline.weights = vec![1.0, 0.0, 1.0, 1.0]; + assert!(spline_path(&spline).unwrap().is_some(), "invalid weights historically fall back to unit weights"); + } + + #[test] + fn ascii_document_import_keeps_spline_as_supported_geometry() { + let bytes = b"0\nSECTION\n2\nENTITIES\n0\nSPLINE\n8\n0\n70\n8\n71\n3\n72\n8\n73\n4\n40\n0\n40\n0\n40\n0\n40\n0\n40\n1\n40\n1\n40\n1\n40\n1\n10\n0\n20\n0\n10\n1\n20\n2\n10\n2\n20\n2\n10\n3\n20\n0\n0\nENDSEC\n0\nEOF\n"; + let imported = import(bytes).expect("valid spline drawing"); + assert_eq!(imported.paths.len(), 1); + assert!(imported.warnings.is_empty()); + let bounds = imported.bounds.expect("spline bounds"); + assert!((bounds.x0 - 0.0).abs() < 1e-12 && (bounds.x1 - 3.0).abs() < 1e-12); + assert!((bounds.y0 - 0.0).abs() < 1e-12 && (bounds.y1 - 1.5).abs() < 1e-12); + } + + #[test] + fn discontinuous_nurbs_spans_remain_separate_subpaths() { + let mut limit_hit = false; + let path = nurbs( + 1, + &[0.0, 0.0, 0.5, 0.5, 1.0, 1.0], + &xyz(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(5.0, 5.0), Point::new(6.0, 5.0)]), + None, + &mut limit_hit, + ) + .expect("piecewise linear spline"); + assert!(!limit_hit); + assert!(matches!(path.elements(), [kurbo::PathEl::MoveTo(a), kurbo::PathEl::LineTo(b), kurbo::PathEl::MoveTo(c), kurbo::PathEl::LineTo(d)] + if *a == Point::new(0.0,0.0) && *b == Point::new(1.0,0.0) && *c == Point::new(5.0,5.0) && *d == Point::new(6.0,5.0))); + } + + #[test] + fn positive_knot_spans_at_large_offsets_are_kept() { + let start = 1.0e12; + let delta = 1.0e-3; + let mut limit_hit = false; + let path = nurbs(1, &[start, start, start + delta, start + delta], &xyz(&[Point::new(2.0, 3.0), Point::new(5.0, 7.0)]), None, &mut limit_hit) + .expect("positive-width span"); + assert!(!limit_hit); + assert!(matches!(path.elements(), [kurbo::PathEl::MoveTo(a), kurbo::PathEl::LineTo(b)] + if *a == Point::new(2.0, 3.0) && *b == Point::new(5.0, 7.0))); + } + + #[test] + fn fit_point_fallback_checks_element_budget_before_collecting() { + let mut spline = cubic(); + spline.degree = -1; + spline.fit = vec![[0.0, 0.0, 0.0]; MAX_SPLINE_ELEMENTS + 1]; + assert!(matches!(spline_path(&spline), Err(Error::TooManySegments))); + } + + #[test] + fn bernstein_error_bound_covers_rational_and_high_degree_deviations() { + for control in [ + vec![Homogeneous { x: 0.0, y: 0.0, w: 1.0 }, Homogeneous { x: 0.2, y: 2.0, w: 0.5 }, Homogeneous { x: 1.0, y: 0.0, w: 1.0 }], + vec![ + Homogeneous { x: 0.0, y: 0.0, w: 1.0 }, + Homogeneous { x: 0.2, y: 2.0, w: 0.7 }, + Homogeneous { x: 0.5, y: -2.0, w: 0.9 }, + Homogeneous { x: 0.8, y: 2.0, w: 1.2 }, + Homogeneous { x: 1.0, y: 0.0, w: 1.0 }, + ], + ] { + let degree = control.len() - 1; + let bezier = [Point::new(0.0, 0.0), Point::new(0.25, 1.0), Point::new(0.75, 1.0), Point::new(1.0, 0.0)]; + let bound = cubic_error_bound(&control, &bezier).unwrap(); + let cubic = kurbo::CubicBez::new(bezier[0], bezier[1], bezier[2], bezier[3]); + for sample in 0..=1024 { + let t = sample as f64 / 1024.0; + let mut points = control.clone(); + for level in (1..=degree).rev() { + for i in 0..level { + points[i] = points[i].lerp(points[i + 1], t); + } + } + let source = points[0].point().unwrap(); + let approximation = kurbo::ParamCurve::eval(&cubic, t); + assert!(source.distance(approximation) <= bound * (1.0 + 1e-12), "sample {t}: bound {bound}"); + } + } + } + + #[test] + fn rational_quadratic_circle_matches_the_prior_importer_tolerance() { + let weight = std::f64::consts::FRAC_1_SQRT_2; + let control = xyz(&[(1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (-1.0, 1.0), (-1.0, 0.0), (-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (1.0, 0.0)] + .map(|(x, y)| Point::new(200.0 + x * 100.0, 200.0 + y * 100.0))); + let knots = [0.0, 0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 4.0]; + let weights = [1.0, weight, 1.0, weight, 1.0, weight, 1.0, weight, 1.0]; + let mut limit_hit = false; + let path = nurbs(2, &knots, &control, Some(&weights), &mut limit_hit).expect("circle NURBS"); + assert!(!limit_hit); + let center = Point::new(200.0, 200.0); + let max_radial_error = path + .segments() + .flat_map(|segment| (0..=32).map(move |i| kurbo::ParamCurve::eval(&segment, i as f64 / 32.0))) + .map(|point| (point.distance(center) - 100.0).abs()) + .fold(0.0_f64, f64::max); + assert!(max_radial_error < 0.01, "prior importer tolerance was 0.01; got {max_radial_error}"); + } + + #[test] + fn bounded_spline_fit_matches_prior_rational_and_high_degree_geometry() { + let weight = std::f64::consts::FRAC_1_SQRT_2; + let curves = [ + vec![Homogeneous { x: 1.0, y: 0.0, w: 1.0 }, Homogeneous { x: weight, y: weight, w: weight }, Homogeneous { x: 0.0, y: 1.0, w: 1.0 }], + vec![ + Homogeneous { x: 0.0, y: 0.0, w: 1.0 }, + Homogeneous { x: 0.2, y: 2.0, w: 0.7 }, + Homogeneous { x: 0.5, y: -2.0, w: 0.9 }, + Homogeneous { x: 0.8, y: 2.0, w: 1.2 }, + Homogeneous { x: 1.0, y: 0.0, w: 1.0 }, + ], + ]; + for bezier in curves { + let points: Vec<_> = bezier.iter().map(|point| point.point().expect("finite source point")).collect(); + let (lo, hi) = points.iter().fold((points[0], points[0]), |(lo, hi), point| { + (Point::new(lo.x.min(point.x), lo.y.min(point.y)), Point::new(hi.x.max(point.x), hi.y.max(point.y))) + }); + let tolerance = ((hi - lo).hypot() * SPLINE_TOLERANCE).max(1e-12); + let mut current = BezPath::new(); + current.move_to(points[0]); + let mut limit_hit = false; + fit_spline(&mut current, &bezier, tolerance, 0, &mut limit_hit).expect("bounded fit succeeds"); + assert!(!limit_hit); + let prior = legacy_path(&bezier, tolerance); + let difference = sampled_path_distance(&prior, ¤t, tolerance).max(sampled_path_distance(¤t, &prior, tolerance)); + assert!(difference <= 2.1 * tolerance, "legacy/new Hausdorff sample distance {difference} exceeds 2.1 × tolerance {tolerance}"); + } + } +} diff --git a/crates/dxf/tests/model_write.rs b/crates/dxf/tests/model_write.rs index 1f1df5fcb..8ece9c5bb 100644 --- a/crates/dxf/tests/model_write.rs +++ b/crates/dxf/tests/model_write.rs @@ -596,7 +596,8 @@ fn the_file_around_the_content() { raw_objects: vec![Tag::s(0, "DICTIONARY"), Tag::s(5, "901"), Tag::s(330, "A")], raw_classes: vec![Tag::s(0, "CLASS"), Tag::s(1, "IMAGEDEF")], ..Drawing::default() - }; + } + .with_linear_units(2); let (tags, skipped) = drawing.tags(); assert_eq!(skipped, 1, "the entity with no groups to write"); assert_eq!(tags.last().map(|t| (t.code, t.str())), Some((0, "EOF".to_string()))); @@ -617,6 +618,7 @@ fn the_file_around_the_content() { assert_eq!((d.header.acadver().as_deref(), d.header.ltscale(), d.header.insunits()), (Some("AC1032"), 2.0, 6)); assert_eq!(d.header.vars.iter().filter(|(n, _)| *n == "$INSUNITS" || *n == "$ACADVER" || *n == "$HANDSEED").count(), 3); assert_eq!(d.header.point("$EXTMAX"), Some([100.0, 50.0, 0.0])); + assert_eq!(d.header.vars.iter().find(|(name, _)| *name == "$LUNITS").map(|(_, tags)| tags[0].i64()), Some(2)); // Blocks, their records and the layouts that name them. let names = d.block_names(); let block = d.block("DOOR").expect("the block"); diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index fbe69c26f..6984fb576 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -11,3 +11,11 @@ repository.workspace = true workspace = true [dependencies] +kurbo = { workspace = true, optional = true } +vectorcraft-doc = { workspace = true, optional = true } +vectorcraft-eps = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } + +[features] +default = [] +paths = ["dep:kurbo", "dep:thiserror", "dep:vectorcraft-doc", "dep:vectorcraft-eps"] diff --git a/crates/eps/src/embedded.rs b/crates/eps/src/embedded.rs new file mode 100644 index 000000000..8e2225993 --- /dev/null +++ b/crates/eps/src/embedded.rs @@ -0,0 +1,269 @@ +//! Embedded Type 1 and CFF font programs in PostScript / EPS source. + +/// A font program embedded in the PostScript source of an EPS file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbeddedFontProgram { + /// The PostScript `/FontName` when one is present in the source. CFF `StartData` blocks store + /// their name inside the CFF Name INDEX, which the font parser reads from `program`. + pub name: Option, + /// The bytes of the Type 1 program or CFF font set. + pub program: Vec, + /// Whether `program` is a CFF font set rather than a Type 1 program. + pub cff: bool, +} + +/// Embedded font programs in a PostScript section, in source order within each format. +/// +/// Type 1 programs are recognized from their `eexec` and `cleartomark` delimiters and are named +/// by `/FontName`. CFF `StartData` blocks are returned without a name because the name is inside +/// the binary CFF data. Malformed or truncated blocks are skipped, as in the original EPS reader. +pub fn embedded_font_programs(ps: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut heads = positions(ps, b"%!PS-AdobeFont"); + heads.extend(positions(ps, b"%!FontType1")); + heads.sort_unstable(); + let names = positions(ps, b"/FontName"); + let mut from = 0; + let mut used = 0; + while let Some(ee) = find_eexec(ps, from) { + from = ee + 5; + let last_before = |v: &[usize]| v[..v.partition_point(|&p| p < ee)].last().copied().filter(|&p| p >= used); + // The fallback look-back stops at the previous font's end, so it cannot pick up its header. + let start = last_before(&heads).or_else(|| last_before(&names).map(|p| p.saturating_sub(512).max(used))); + let Some(start) = start else { continue }; + let end = find_from(ps, b"cleartomark", ee).map_or(ps.len(), |p| p + 11); + if let Some(name) = name_after(&ps[start..ee], b"/FontName") { + out.push(EmbeddedFontProgram { name: Some(name), program: ps[start..end].to_vec(), cff: false }); + } + from = end; + used = end; + } + + let mut from = 0; + while let Some(sd) = find_from(ps, b"StartData", from) { + from = sd + 9; + let head = String::from_utf8_lossy(&ps[sd.saturating_sub(64)..sd]).into_owned(); + let Some(len) = head.split_whitespace().last().and_then(|t| t.parse::().ok()) else { continue }; + let s = sd + 10; + let Some(data) = ps.get(s..s.saturating_add(len)) else { continue }; + out.push(EmbeddedFontProgram { name: None, program: data.to_vec(), cff: true }); + from = s + len; + } + out +} + +fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option { + hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|p| p + from) +} + +/// Find an executable `eexec` operator, ignoring comments, literal strings and names. +fn find_eexec(ps: &[u8], from: usize) -> Option { + let mut pos = from.min(ps.len()); + while pos < ps.len() { + let c = ps[pos]; + if is_white(c) { + pos += 1; + continue; + } + if c == b'%' { + let start = pos; + while pos < ps.len() && !matches!(ps[pos], b'\n' | b'\r') { + pos += 1; + } + let line = &ps[start..pos]; + let marker: Option<&[u8]> = if line.starts_with(b"%%BeginData:") { + Some(b"%%EndData") + } else if line.starts_with(b"%%BeginBinary:") { + Some(b"%%EndBinary") + } else if line.starts_with(b"%%BeginPreview") { + Some(b"%%EndPreview") + } else { + None + }; + if let Some(marker) = marker { + let rest = ps.get(pos..)?; + pos = rest.windows(marker.len()).position(|window| window == marker).map_or(ps.len(), |offset| pos + offset); + } + continue; + } + if c == b'(' { + pos += 1; + let mut depth = 1usize; + while pos < ps.len() && depth > 0 { + match ps[pos] { + b'\\' => pos = pos.saturating_add(2).min(ps.len()), + b'(' => { + depth = depth.saturating_add(1); + pos += 1; + } + b')' => { + depth -= 1; + pos += 1; + } + _ => pos += 1, + } + } + continue; + } + if c == b'<' { + match ps.get(pos + 1) { + Some(b'<') => pos += 2, + Some(b'~') => { + pos += 2; + pos = ps + .get(pos..) + .and_then(|rest| { + rest.windows(2).position(|window| window == b"~>").map(|offset| pos.saturating_add(offset).saturating_add(2)) + }) + .unwrap_or(ps.len()); + } + _ => { + pos += 1; + while pos < ps.len() && ps[pos] != b'>' { + pos += 1; + } + pos = pos.saturating_add(1).min(ps.len()); + } + } + continue; + } + if c == b'/' { + pos += 1; + if ps.get(pos) == Some(&b'/') { + pos += 1; + } + while pos < ps.len() && !is_white(ps[pos]) && !is_delimiter(ps[pos]) { + pos += 1; + } + continue; + } + if is_delimiter(c) { + pos += 1; + continue; + } + let start = pos; + while pos < ps.len() && !is_white(ps[pos]) && !is_delimiter(ps[pos]) { + pos += 1; + } + if &ps[start..pos] == b"eexec" { + return Some(start); + } + } + None +} + +fn is_white(c: u8) -> bool { + matches!(c, 0 | 9 | 10 | 12 | 13 | 32) +} + +fn is_delimiter(c: u8) -> bool { + matches!(c, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') +} + +fn positions(hay: &[u8], needle: &[u8]) -> Vec { + let mut out = vec![]; + let mut from = 0; + while let Some(p) = find_from(hay, needle, from) { + out.push(p); + from = p + needle.len(); + } + out +} + +fn name_after(b: &[u8], key: &[u8]) -> Option { + let p = find_from(b, key, 0)? + key.len(); + let rest = &b[p..]; + let s = rest.iter().position(|c| *c == b'/')? + 1; + let e = rest[s..].iter().position(|c| is_white(*c) || is_delimiter(*c)).map_or(rest.len(), |q| s + q); + (e > s).then(|| String::from_utf8_lossy(&rest[s..e]).into_owned()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::embedded_font_programs; + + #[test] + fn finds_type1_font_programs_by_name() { + let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%!PS-AdobeFont-1.0: Test\n/FontName /Test def\ncurrentfile eexec\n0000\ncleartomark\n"; + let fonts = embedded_font_programs(ps); + assert_eq!(fonts.len(), 1); + assert_eq!(fonts[0].name.as_deref(), Some("Test")); + assert!(!fonts[0].cff); + assert!(String::from_utf8_lossy(&fonts[0].program).contains("cleartomark")); + } + + #[test] + fn finds_cff_startdata_without_interpreting_its_name_index() { + let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n10 StartData\n0123456789\n"; + let fonts = embedded_font_programs(ps); + assert_eq!(fonts.len(), 1); + assert_eq!(fonts[0].name, None); + assert!(fonts[0].cff); + assert_eq!(fonts[0].program, b"0123456789"); + } + + #[test] + fn ignores_truncated_startdata() { + let fonts = embedded_font_programs(b"%!PS-Adobe-3.0 EPSF-3.0\n100 StartData\nshort"); + assert!(fonts.is_empty()); + } + + #[test] + fn many_headerless_eexec_words_do_not_hide_later_font() { + let mut ps = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec(); + ps.extend_from_slice(b"eexec ".repeat(20_000).as_slice()); + ps.extend_from_slice(b"\n%!PS-AdobeFont-1.0: Real\n/FontName /Real def\ncurrentfile eexec\n0000\ncleartomark\n"); + let fonts = embedded_font_programs(&ps); + assert_eq!(fonts.len(), 1); + assert_eq!(fonts[0].name.as_deref(), Some("Real")); + } + + #[test] + fn headerless_font_does_not_reuse_previous_font_header() { + let first = b"%!PS-AdobeFont-1.0: A\n/FontName /A def\ncurrentfile eexec\n0000\ncleartomark\n"; + let second = b"/FontName /B def\ncurrentfile eexec\n0000\ncleartomark\n"; + let mut ps = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec(); + ps.extend_from_slice(first); + ps.extend_from_slice(second); + let fonts = embedded_font_programs(&ps); + assert_eq!(fonts.len(), 2); + let b = fonts.iter().find(|font| font.name.as_deref() == Some("B")).unwrap(); + assert!(!String::from_utf8_lossy(&b.program).contains("AdobeFont")); + } + + #[test] + fn eexec_in_comments_strings_names_and_font_names_is_not_an_operator() { + let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%!PS-AdobeFont-1.0: Test\n%% eexec in a comment\n%%BeginBinary: 10\neexec junk\n%%EndBinary\n/FontName /eexecTest def\n(nested (ignored eexec)) pop\n<6565786563> pop\n<~eexec~> pop\n/eexec def\ncurrentfile eexec\n0000\ncleartomark\n"; + let fonts = embedded_font_programs(ps); + assert_eq!(fonts.len(), 1); + assert_eq!(fonts[0].name.as_deref(), Some("eexecTest")); + } + + #[test] + fn font_name_stops_at_postscript_delimiters_and_comments() { + let ps = b"%!PS-AdobeFont-1.0: Test\n/FontName /Test%comment\n def\ncurrentfile eexec\n0000\ncleartomark\n"; + let fonts = embedded_font_programs(ps); + assert_eq!(fonts.len(), 1); + assert_eq!(fonts[0].name.as_deref(), Some("Test")); + } + + #[test] + fn font_names_stop_at_every_postscript_delimiter() { + for delimiter in b"()<>[]{}/%" { + let mut source = b"/FontName /Test".to_vec(); + source.push(*delimiter); + source.extend_from_slice(b"ignored"); + assert_eq!(super::name_after(&source, b"/FontName").as_deref(), Some("Test")); + } + } + + #[test] + fn literal_name_backslash_does_not_escape_a_comment_delimiter() { + let ps = b"%!PS-AdobeFont-1.0: Test\n/FontName /Test def\n/unused\\%(\npop\ncurrentfile eexec\n0000\ncleartomark\n"; + let fonts = embedded_font_programs(ps); + assert_eq!(fonts.len(), 1); + assert_eq!(fonts[0].name.as_deref(), Some("Test")); + } +} diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index e48532349..c3878830b 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -18,11 +18,16 @@ pub mod ascii85; mod container; mod dsc; +mod embedded; +#[cfg(feature = "paths")] +pub mod paths; +pub mod postscript; mod preview; mod rectangles; pub use container::{DOS_HEADER, DOS_MAGIC, Sections, sections, wrap}; pub use dsc::{Comments, MAX_HEADER, MAX_TRAILER}; +pub use embedded::{EmbeddedFontProgram, embedded_font_programs}; pub use preview::{Bitmap, MAX_EPSI, epsi}; pub use rectangles::{MAX_SIMPLE_RECTANGLE_SAVE_DEPTH, MAX_SIMPLE_RECTANGLE_TOKENS, MAX_SIMPLE_RECTANGLES, Rectangle, simple_rectangles}; diff --git a/crates/eps/src/paths.rs b/crates/eps/src/paths.rs new file mode 100644 index 000000000..271f905bd --- /dev/null +++ b/crates/eps/src/paths.rs @@ -0,0 +1,108 @@ +//! Neutral path extraction for EPS and PostScript files. +//! +//! This facade keeps the PostScript interpreter and its format decisions in shared dependencies; +//! applications receive Bézier paths, page bounds and warnings for their own document adapters. + +use kurbo::{BezPath, Rect}; + +/// Limits on the extracted geometry. The interpreter has its own execution and allocation caps. +#[derive(Debug, Clone, Copy)] +pub struct Limits { + pub max_input_bytes: usize, + pub max_paths: usize, + pub max_path_elements: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { max_input_bytes: 64 * 1024 * 1024, max_paths: 200_000, max_path_elements: 2_000_000 } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("PostScript input exceeds {limit} bytes")] + InputLimit { limit: usize }, + #[error("PostScript import failed: {0}")] + Parse(String), + #[error("PostScript path output exceeds configured limit")] + OutputLimit, +} + +/// First-page path geometry normalized to the top-left origin in PostScript points, plus notes. +#[derive(Debug, Clone)] +pub struct Imported { + pub paths: Vec, + /// Origin-zero page bounds in PostScript points, using the same accepted DSC box as the + /// interpreter. + pub page_bounds: Rect, + pub warnings: Vec, + pub preview_fallback: bool, +} + +/// Interpret EPS/PostScript bytes and return their editable vector path geometry. +pub fn import(bytes: &[u8], limits: Limits) -> Result { + if bytes.len() > limits.max_input_bytes { + return Err(Error::InputLimit { limit: limits.max_input_bytes }); + } + let source = vectorcraft_eps::import(bytes).map_err(Error::Parse)?; + let page_bounds = vectorcraft_eps::page(bytes).map_err(Error::Parse)?; + // Illustrator's editable copy is expressed in its art-box document coordinates. The page + // reader, in contrast, returns a page at the origin. Normalize extracted paths to match it. + let origin = source.document.artboards.first().map(|board| board.rect.origin()).unwrap_or_default(); + let to_page = kurbo::Affine::translate((-origin.x, -origin.y)); + let mut paths = Vec::new(); + let mut total_elements = 0usize; + let mut clipped = false; + let mut omitted = false; + for layer in &source.document.layers { + collect(layer, &mut paths, &mut total_elements, limits, to_page, &mut clipped, &mut omitted)?; + } + let mut warnings = source.warnings; + if clipped { + warnings.push("EPS clipping groups are returned as unclipped artwork".into()); + } + if omitted { + warnings.push("visible EPS text or image content is not returned as paths".into()); + } + Ok(Imported { paths, page_bounds, warnings, preview_fallback: source.preview }) +} + +fn collect( + node: &vectorcraft_doc::Node, + paths: &mut Vec, + elements: &mut usize, + limits: Limits, + to_page: kurbo::Affine, + clipped: &mut bool, + omitted: &mut bool, +) -> Result<(), Error> { + if !node.visible { + return Ok(()); + } + if let Some(data) = node.path_data() { + if matches!(node.kind, vectorcraft_doc::NodeKind::Path { clipping: true, .. }) { + return Ok(()); + } + let path = data.to_bezpath(); + if !path.elements().is_empty() { + *elements = elements.saturating_add(path.elements().len()); + if paths.len() >= limits.max_paths || *elements > limits.max_path_elements { + return Err(Error::OutputLimit); + } + paths.push(to_page * path); + } + } else if let Some(children) = node.children() { + let is_clip = matches!(node.kind, vectorcraft_doc::NodeKind::Group { clip: true, .. } | vectorcraft_doc::NodeKind::Layer { clip: true, .. }); + *clipped |= is_clip; + for (index, child) in children.iter().enumerate() { + if is_clip && index == 0 { + continue; + } + collect(child, paths, elements, limits, to_page, clipped, omitted)?; + } + } else { + *omitted = true; + } + Ok(()) +} diff --git a/crates/eps/src/postscript.rs b/crates/eps/src/postscript.rs new file mode 100644 index 000000000..92ed2c06d --- /dev/null +++ b/crates/eps/src/postscript.rs @@ -0,0 +1,695 @@ +//! Shared PostScript format primitives for EPS programs. +//! +//! [`Scanner`] handles bounded lexical parsing. [`OperandStack`] and [`ExecutionBudget`] provide +//! reusable language-level VM behavior while leaving values, dictionaries, graphics state, +//! rendering, and font policy to the host interpreter. + +/// Maximum nesting depth for executable procedures in a source token. +pub const MAX_PROCEDURE_DEPTH: usize = 100; + +/// Maximum number of operand values an interpreter should retain by default. +pub const MAX_OPERAND_STACK: usize = 100_000; + +/// Maximum nested executable procedure calls supported by the bounded VM. +pub const MAX_CALL_DEPTH: usize = 256; + +/// PostScript operand-stack operators whose semantics do not depend on the value model. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StackOperator { + Pop, + Exchange, + Duplicate, + CopyTop(usize), + Index(usize), + Roll { count: usize, shift: i64 }, + Clear, + Count, +} + +/// Result of applying an operand-stack operator. +#[derive(Debug, PartialEq, Eq)] +pub enum StackOutcome { + None, + Popped(Option), + Count(usize), + Copied(bool), +} + +/// A bounded PostScript operand stack. +/// +/// The stack is deliberately generic: an interpreter owns the concrete PostScript values and +/// graphics state, while this module provides the shared language-level stack operators. A push +/// past `limit` is ignored, matching the bounded EPS interpreter's defensive behavior. +#[derive(Clone, Debug)] +pub struct OperandStack { + values: Vec, + limit: usize, +} + +impl OperandStack { + /// Create an empty stack with a maximum number of values. + pub fn new(limit: usize) -> Self { + Self { values: Vec::new(), limit } + } + + /// Number of values currently on the stack. + pub fn len(&self) -> usize { + self.values.len() + } + + /// Whether the stack is empty. + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + /// Borrow all stack values mutably from bottom to top. + pub fn values_mut(&mut self) -> &mut [T] { + &mut self.values + } + + /// Remove and return the top value, if present. + pub fn pop(&mut self) -> Option { + self.values.pop() + } + + /// Remove every value. + pub fn clear(&mut self) { + self.values.clear(); + } + + /// Remove values at and above `at`, returning them in bottom-to-top order. An index beyond + /// the stack length removes nothing. + pub fn split_off(&mut self, at: usize) -> Vec { + let at = at.min(self.values.len()); + self.values.split_off(at) + } + + /// Push a value if the configured limit allows it; report whether it was pushed. + pub fn push(&mut self, value: T) -> bool { + if self.values.len() >= self.limit { + return false; + } + self.values.push(value); + true + } + + /// Pop a value, or return the caller's language-specific underflow value. + pub fn pop_or(&mut self, underflow: T) -> T { + self.values.pop().unwrap_or(underflow) + } + + /// Borrow the top value. + pub fn top(&self) -> Option<&T> { + self.values.last() + } + + /// PostScript `dup`: duplicate the top value if one exists. + pub fn duplicate(&mut self) { + if let Some(value) = self.values.last().cloned() { + let _ = self.push(value); + } + } + + /// PostScript `dup` with a caller-supplied underflow value. + pub fn duplicate_or(&mut self, underflow: T) { + let value = self.values.last().cloned().unwrap_or(underflow); + let _ = self.push(value); + } + + /// PostScript `exch`: exchange the top two values when both exist. + pub fn exchange(&mut self) { + let len = self.values.len(); + if len >= 2 { + self.values.swap(len - 1, len - 2); + } + } + + /// PostScript `exch` with caller-supplied underflow values. Both operands are popped and + /// restored even if either pop underflows, matching interpreters that use a null sentinel. + pub fn exchange_or(&mut self, underflow: T) { + let b = self.pop_or(underflow.clone()); + let a = self.pop_or(underflow); + let _ = self.push(b); + let _ = self.push(a); + } + + /// PostScript `index`: copy the value `depth` below the top onto the stack. + /// + /// Invalid or negative depths are left to the caller to validate; an out-of-range depth is a + /// no-op. This keeps the helper bounded even for hostile numeric operands. + pub fn index(&mut self, depth: usize) { + let len = self.values.len(); + if depth < len && len < self.limit { + let value = self.values[len - depth - 1].clone(); + self.values.push(value); + } + } + + /// PostScript `index` with a caller-supplied underflow value, pushed even when `depth` is out + /// of range. The configured stack limit still applies. + pub fn index_or(&mut self, depth: usize, underflow: T) { + let len = self.values.len(); + let value = if depth < len { self.values[len - depth - 1].clone() } else { underflow }; + let _ = self.push(value); + } + + /// PostScript `n j roll`: rotate the top `count` values by signed `shift` places. + /// + /// Positive shifts move values toward the top; negative shifts move them toward the bottom. + /// Counts larger than the stack and empty rolls are ignored. + pub fn roll(&mut self, count: usize, shift: i64) { + let len = self.values.len(); + if count == 0 || count > len { + return; + } + let places = (i128::from(shift).rem_euclid(count as i128)) as usize; + self.values[len - count..].rotate_right(places); + } + + /// PostScript `copy` for an integer operand: append a copy of the top `count` values if it + /// fits. Returns false when the range is invalid or the configured limit would be exceeded. + pub fn copy_top(&mut self, count: usize) -> bool { + let len = self.values.len(); + if count > len || len.saturating_add(count) > self.limit { + return false; + } + let copied = self.values[len - count..].to_vec(); + self.values.extend(copied); + true + } + + /// Borrow all stack values from bottom to top. + pub fn values(&self) -> &[T] { + &self.values + } + + /// Apply a PostScript operand-stack operator. `underflow` is the interpreter's fallback value + /// for `exch`, `dup`, and `index`; the stack's configured limit remains in force. + pub fn execute(&mut self, operator: StackOperator, underflow: T) -> StackOutcome { + match operator { + StackOperator::Pop => StackOutcome::Popped(self.pop()), + StackOperator::Exchange => { + self.exchange_or(underflow); + StackOutcome::None + } + StackOperator::Duplicate => { + self.duplicate_or(underflow); + StackOutcome::None + } + StackOperator::CopyTop(count) => StackOutcome::Copied(self.copy_top(count)), + StackOperator::Index(depth) => { + self.index_or(depth, underflow); + StackOutcome::None + } + StackOperator::Roll { count, shift } => { + self.roll(count, shift); + StackOutcome::None + } + StackOperator::Clear => { + self.clear(); + StackOutcome::None + } + StackOperator::Count => StackOutcome::Count(self.len()), + } + } +} + +/// Bounded execution accounting shared by PostScript interpreters. +/// +/// EPS programs can recurse or repeatedly execute procedures. A host can use this counter to +/// stop work before it consumes unbounded CPU, independently of its operator and graphics policy. +#[derive(Clone, Debug)] +pub struct ExecutionBudget { + remaining: usize, + call_depth: usize, + max_call_depth: usize, +} + +impl ExecutionBudget { + /// Create a budget with an operation allowance and maximum nested procedure calls. + pub fn new(operations: usize, max_call_depth: usize) -> Self { + Self { remaining: operations, call_depth: 0, max_call_depth } + } + + /// Units of work still available. + pub fn remaining(&self) -> usize { + self.remaining + } + + /// Current nested procedure depth. + pub fn call_depth(&self) -> usize { + self.call_depth + } + + /// Charge work atomically. On failure the remaining budget stays unchanged. + pub fn spend(&mut self, units: usize) -> bool { + let Some(remaining) = self.remaining.checked_sub(units) else { + return false; + }; + self.remaining = remaining; + true + } + + /// Charge work that is accounted lazily: saturate at zero so the caller's next strict + /// [`Self::spend`] stops execution. This supports work whose current operator preserves the + /// app's historical behavior after exhausting its budget. + pub fn charge(&mut self, units: usize) { + self.remaining = self.remaining.saturating_sub(units); + } + + /// Enter a procedure call if it remains within the configured depth. + pub fn enter_call(&mut self) -> bool { + if self.call_depth >= self.max_call_depth { + return false; + } + self.call_depth += 1; + true + } + + /// Leave a procedure call. Extra leaves are ignored to keep cleanup paths safe. + pub fn leave_call(&mut self) { + self.call_depth = self.call_depth.saturating_sub(1); + } +} + +/// A lexical PostScript value. Names are left unevaluated for the caller's interpreter. +#[derive(Clone, Debug, PartialEq)] +pub enum Token { + /// A number accepted by the PostScript source reader. + Number(f64), + /// An executable name, including immediately evaluated `//name`. + Name(String), + /// A literal name such as `/name`. + LiteralName(String), + /// A parenthesized or hexadecimal byte string. + String(Vec), + /// The encoded bytes between `<~` and `~>` (including delimiters), not decoded data. + Ascii85(Vec), + /// A procedure whose nested values have already been tokenized. + Procedure(Vec), + /// A closing brace not consumed as part of a procedure, or another malformed delimiter. + Null, +} + +/// PostScript source bytes and the current byte offset. +pub struct Scanner<'a> { + /// Source bytes. Public so an interpreter can implement `currentfile` operators. + pub data: &'a [u8], + /// Current byte offset in [`Scanner::data`]. + pub pos: usize, +} + +impl<'a> Scanner<'a> { + /// Start scanning at a byte offset, clamped to the source length. + pub fn new(data: &'a [u8], pos: usize) -> Self { + Self { data, pos: pos.min(data.len()) } + } + + /// The next token; `{ … }` is returned as one procedure token. + pub fn token(&mut self) -> Option { + self.token_at_depth(0) + } + + fn token_at_depth(&mut self, depth: usize) -> Option { + self.skip_ws(); + let c = *self.data.get(self.pos)?; + Some(match c { + b'{' => { + self.pos += 1; + let mut body = Vec::new(); + loop { + self.skip_ws(); + match self.data.get(self.pos) { + None => break, + Some(b'}') => { + self.pos += 1; + break; + } + _ if depth >= MAX_PROCEDURE_DEPTH => break, + _ => match self.token_at_depth(depth + 1) { + Some(t) => body.push(t), + None => break, + }, + } + } + Token::Procedure(body) + } + b'}' => { + self.pos += 1; + Token::Null + } + b'/' => { + self.pos += 1; + let immediately_evaluated = self.data.get(self.pos) == Some(&b'/'); + if immediately_evaluated { + self.pos += 1; + } + let name = String::from_utf8_lossy(self.word()).into_owned(); + if immediately_evaluated { Token::Name(name) } else { Token::LiteralName(name) } + } + b'(' => Token::String(self.parenthesized()), + b'<' if self.data.get(self.pos + 1) == Some(&b'<') => { + self.pos += 2; + Token::Name("<<".to_owned()) + } + b'>' if self.data.get(self.pos + 1) == Some(&b'>') => { + self.pos += 2; + Token::Name(">>".to_owned()) + } + b'<' if self.data.get(self.pos + 1) == Some(&b'~') => self.ascii85(), + b'<' => Token::String(self.hex_string()), + b'[' | b']' => { + self.pos += 1; + Token::Name(char::from(c).to_string()) + } + b')' | b'>' => { + self.pos += 1; + Token::Null + } + _ => { + let word = self.word(); + if word.is_empty() { + self.pos += 1; + return Some(Token::Null); + } + match parse_number(word) { + Some(n) => Token::Number(n), + None => { + let name = String::from_utf8_lossy(word).into_owned(); + if let Some((radix, digits)) = name.split_once('#') { + let radix = radix.parse::().ok().filter(|r| (2..=36).contains(r)); + if let Some(number) = radix.and_then(|r| i64::from_str_radix(digits, r).ok()) { + return Some(Token::Number(number as f64)); + } + } + Token::Name(name) + } + } + } + }) + } + + /// Step over inline image data that follows an `image` operator reading `currentfile`. + pub fn skip_image_data(&mut self) { + self.skip_ws(); + let rest = self.data.get(self.pos..).unwrap_or_default(); + if rest.starts_with(b"<~") || self.data.get(self.pos).is_some_and(|c| (b'!'..=b'u').contains(c) && !c.is_ascii_hexdigit()) { + self.pos = match rest.windows(2).position(|w| w == b"~>") { + Some(p) => self.pos.saturating_add(p).saturating_add(2).min(self.data.len()), + None => self.data.len(), + }; + return; + } + while self.pos < self.data.len() && (self.data[self.pos].is_ascii_hexdigit() || is_white(self.data[self.pos])) { + self.pos += 1; + } + if self.data.get(self.pos) == Some(&b'>') { + self.pos += 1; + } + } + + fn skip_ws(&mut self) { + while self.pos < self.data.len() { + let c = self.data[self.pos]; + if is_white(c) { + self.pos += 1; + } else if c == b'%' { + let start = self.pos; + while self.pos < self.data.len() && !matches!(self.data[self.pos], b'\n' | b'\r') { + self.pos += 1; + } + let line = &self.data[start..self.pos]; + let end_marker: Option<&[u8]> = if line.starts_with(b"%%BeginData:") { + Some(b"%%EndData") + } else if line.starts_with(b"%%BeginBinary:") { + Some(b"%%EndBinary") + } else if line.starts_with(b"%%BeginPreview") { + Some(b"%%EndPreview") + } else { + None + }; + if let Some(marker) = end_marker { + self.pos = match self.data.get(self.pos..).unwrap_or_default().windows(marker.len()).position(|w| w == marker) { + Some(offset) => self.pos.saturating_add(offset).min(self.data.len()), + None => self.data.len(), + }; + } + } else { + break; + } + } + } + + fn word(&mut self) -> &[u8] { + let start = self.pos; + while self.pos < self.data.len() { + let c = self.data[self.pos]; + if is_white(c) || matches!(c, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') { + break; + } + self.pos += 1; + } + &self.data[start..self.pos] + } + + fn parenthesized(&mut self) -> Vec { + self.pos += 1; + let mut out = Vec::new(); + let mut nesting = 1usize; + while self.pos < self.data.len() { + let c = self.data[self.pos]; + self.pos += 1; + match c { + b'\\' => { + let e = self.data.get(self.pos).copied().unwrap_or(b'\\'); + self.pos = self.pos.saturating_add(1).min(self.data.len()); + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'0'..=b'7' => { + let mut value = u32::from(e - b'0'); + for _ in 0..2 { + match self.data.get(self.pos) { + Some(d @ b'0'..=b'7') => { + value = value * 8 + u32::from(*d - b'0'); + self.pos += 1; + } + _ => break, + } + } + out.push(value as u8); + } + b'\n' | b'\r' => {} + other => out.push(other), + } + } + b'(' => { + nesting = nesting.saturating_add(1); + out.push(c); + } + b')' => { + nesting = nesting.saturating_sub(1); + if nesting == 0 { + break; + } + out.push(c); + } + _ => out.push(c), + } + } + out + } + + fn hex_string(&mut self) -> Vec { + self.pos += 1; + let mut hex = Vec::new(); + while self.pos < self.data.len() && self.data[self.pos] != b'>' { + if self.data[self.pos].is_ascii_hexdigit() { + hex.push(self.data[self.pos]); + } + self.pos += 1; + } + if self.data.get(self.pos) == Some(&b'>') { + self.pos += 1; + } + if hex.len() % 2 == 1 { + hex.push(b'0'); + } + hex.chunks(2).filter_map(|pair| Some((hex_value(*pair.first()?) << 4) | hex_value(*pair.get(1)?))).collect() + } + + fn ascii85(&mut self) -> Token { + let start = self.pos; + self.pos = self.pos.saturating_add(2).min(self.data.len()); + while self.pos + 1 < self.data.len() && !(self.data[self.pos] == b'~' && self.data[self.pos + 1] == b'>') { + self.pos += 1; + } + self.pos = self.pos.saturating_add(2).min(self.data.len()); + Token::Ascii85(self.data[start..self.pos].to_vec()) + } +} + +fn is_white(c: u8) -> bool { + matches!(c, 0 | 9 | 10 | 12 | 13 | 32) +} + +fn hex_value(c: u8) -> u8 { + (c as char).to_digit(16).unwrap_or(0) as u8 +} + +fn parse_number(word: &[u8]) -> Option { + if word.is_empty() || !word.iter().all(|c| c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.')) || !word.iter().any(u8::is_ascii_digit) { + return None; + } + let text = std::str::from_utf8(word).ok()?; + text.parse::() + .ok() + .or_else(|| text.trim_start_matches(['+', '-']).parse::().ok().map(|value| if text.starts_with('-') { -value } else { value })) +} + +#[cfg(test)] +mod stack_tests { + use super::{ExecutionBudget, OperandStack, StackOperator, StackOutcome}; + + #[test] + fn stack_operators_follow_postscript_order_and_respect_limit() { + let mut stack = OperandStack::new(8); + assert!(stack.push(1)); + assert!(stack.push(2)); + assert!(stack.push(3)); + stack.exchange(); + assert_eq!(stack.values(), &[1, 3, 2]); + stack.duplicate(); + assert_eq!(stack.values(), &[1, 3, 2, 2]); + stack.roll(4, 1); + assert_eq!(stack.values(), &[2, 1, 3, 2]); + stack.roll(4, -1); + assert_eq!(stack.values(), &[1, 3, 2, 2]); + stack.index(2); + assert_eq!(stack.values(), &[1, 3, 2, 2, 3]); + assert!(stack.copy_top(2)); + assert_eq!(stack.values(), &[1, 3, 2, 2, 3, 2, 3]); + stack.duplicate(); + assert_eq!(stack.len(), 8); + assert!(!stack.push(7)); + } + + #[test] + fn stack_underflow_and_invalid_rolls_are_safe() { + let mut stack = OperandStack::new(2); + assert_eq!(stack.pop_or(99), 99); + stack.duplicate_or(99); + assert_eq!(stack.values(), &[99]); + stack.clear(); + stack.exchange_or(99); + assert_eq!(stack.values(), &[99, 99]); + stack.clear(); + stack.roll(1, i64::MAX); + stack.index(usize::MAX); + assert!(stack.is_empty()); + assert!(stack.copy_top(0)); + assert!(stack.is_empty()); + } + + #[test] + fn stack_mutation_and_underflow_index_are_bounded() { + let mut stack = OperandStack::new(4); + assert!(stack.push(10)); + assert!(stack.push(20)); + stack.index_or(1, -1); + stack.index_or(9, -1); + assert_eq!(stack.values(), &[10, 20, 10, -1]); + stack.index_or(0, -1); + assert_eq!(stack.values(), &[10, 20, 10, -1]); // full stack silently drops the push + + assert_eq!(stack.top(), Some(&-1)); + stack.values_mut()[3] = 40; + stack.roll(3, i64::MIN); + assert_eq!(stack.values(), &[10, 40, 20, 10]); + assert!(stack.split_off(usize::MAX).is_empty()); + assert_eq!(stack.split_off(1), [40, 20, 10]); + assert_eq!(stack.values(), &[10]); + assert_eq!(stack.pop(), Some(10)); + assert_eq!(stack.pop(), None); + stack.clear(); + assert!(stack.is_empty()); + } + + #[test] + fn shared_stack_operator_dispatch_reports_only_value_results() { + let mut stack = OperandStack::new(4); + assert!(stack.push(1)); + assert!(stack.push(2)); + assert_eq!(stack.execute(StackOperator::Count, 0), StackOutcome::Count(2)); + assert_eq!(stack.execute(StackOperator::Exchange, 0), StackOutcome::None); + assert_eq!(stack.values(), &[2, 1]); + assert_eq!(stack.execute(StackOperator::Duplicate, 0), StackOutcome::None); + assert_eq!(stack.execute(StackOperator::Index(2), 0), StackOutcome::None); + assert_eq!(stack.values(), &[2, 1, 1, 2]); + assert_eq!(stack.execute(StackOperator::Roll { count: 3, shift: 1 }, 0), StackOutcome::None); + assert_eq!(stack.values(), &[2, 2, 1, 1]); + assert_eq!(stack.execute(StackOperator::CopyTop(2), 0), StackOutcome::Copied(false)); + assert_eq!(stack.execute(StackOperator::Pop, 0), StackOutcome::Popped(Some(1))); + assert_eq!(stack.execute(StackOperator::Clear, 0), StackOutcome::None); + assert!(stack.is_empty()); + } + + #[test] + fn execution_budget_charges_atomically_and_bounds_call_depth() { + let mut budget = ExecutionBudget::new(3, 1); + assert!(budget.spend(2)); + assert_eq!(budget.remaining(), 1); + assert!(!budget.spend(2)); + assert_eq!(budget.remaining(), 1); + budget.charge(2); + assert_eq!(budget.remaining(), 0); + assert!(!budget.spend(1)); + assert!(budget.enter_call()); + assert_eq!(budget.call_depth(), 1); + assert!(!budget.enter_call()); + budget.leave_call(); + budget.leave_call(); + assert_eq!(budget.call_depth(), 0); + assert!(budget.enter_call()); + } +} + +#[cfg(test)] +mod tests { + use super::{Scanner, Token}; + + fn scan(source: &[u8]) -> Vec { + let mut scanner = Scanner::new(source, 0); + let mut tokens = Vec::new(); + while let Some(token) = scanner.token() { + tokens.push(token); + } + tokens + } + + #[test] + fn scans_names_numbers_strings_and_procedures() { + assert_eq!( + scan(b"/x //y 16#ff { 2 (a\\n(b)) }"), + vec![ + Token::LiteralName("x".into()), + Token::Name("y".into()), + Token::Number(255.0), + Token::Procedure(vec![Token::Number(2.0), Token::String(b"a\n(b)".to_vec())]), + ] + ); + } + + #[test] + fn skips_dsc_binary_data_and_preserves_inline_ascii85() { + assert_eq!(scan(b"%%BeginBinary: 4\nxxxx%%EndBinary\n<~z~>"), vec![Token::Ascii85(b"<~z~>".to_vec())]); + } + + #[test] + fn malformed_delimiters_terminate_without_advancing_past_source() { + let mut scanner = Scanner::new(b", +} + +/// A decoded part whose channels are placed in the display window. +#[derive(Debug, Clone, PartialEq)] +pub struct DisplayPart { + pub info: PartInfo, + pub channels: Vec, +} + +/// Decodes the kept flat channels of one part and places them in its display window. +/// +/// Channels are decoded in their stored sample type, converted to `f32`, and clipped or padded +/// according to their data and display windows. `keep` and `fill` receive the part-qualified name +/// from [`part_channel_name`], which is also returned in [`DisplayChannel::name`]. Both decoded +/// block data and display-window buffers are bounded by `limits` before allocation. +pub fn decode_display_part( + bytes: &[u8], + index: usize, + keep: &dyn Fn(&str) -> bool, + fill: &dyn Fn(&str) -> f32, + limits: &Limits, +) -> Result { + // Only the caller-selected display planes are allocated here. Keep the shared header and + // expansion-ratio checks, but do not charge ignored channel data against the output budget. + let mut header_limits = *limits; + header_limits.max_width = u32::MAX; + header_limits.max_height = u32::MAX; + header_limits.max_pixels = u64::MAX; + header_limits.max_bytes = u64::MAX; + let meta = read_meta(bytes, &header_limits)?; + let info = meta.headers.get(index).map(|header| part_info(index, header)).ok_or_else(|| malformed(format!("part {index}")))?; + if info.deep { + return Err(Error::Unsupported("deep data (use the deep reader)".into())); + } + let display = info.display_window; + let pixels = u64::from(display.width) + .checked_mul(u64::from(display.height)) + .ok_or_else(|| Error::LimitExceeded("display window dimensions overflow".into()))?; + if pixels > limits.max_pixels { + return Err(Error::LimitExceeded(format!("display window of {pixels} pixels exceeds {}", limits.max_pixels))); + } + let kept = info.channels.iter().filter(|channel| keep(&part_channel_name(info.name.as_deref(), &channel.name))).count(); + let bytes_needed = pixels + .checked_mul(4) + .and_then(|bytes_per_channel| bytes_per_channel.checked_mul(kept as u64)) + .ok_or_else(|| Error::LimitExceeded("display window byte size overflow".into()))?; + if bytes_needed > limits.max_bytes || usize::try_from(bytes_needed).is_err() { + return Err(Error::LimitExceeded(format!("{bytes_needed} display-window bytes exceed {}", limits.max_bytes))); + } + let block_limit = std::cell::Cell::new(None); + let select = DisplaySelect { index, keep, fill, limits, seen: std::cell::Cell::new(0), block_limit: &block_limit }; + let image = ReadImage::new(ReadAllLayers { read_channels: select }, |_| {}) + .from_buffered(Cursor::new(bytes)) + .map_err(|error| block_limit.take().map(Error::LimitExceeded).unwrap_or_else(|| map_exr(error)))?; + let channels = image + .layer_data + .into_iter() + .nth(index) + .ok_or_else(|| malformed("the part was not read"))? + .channel_data + .into_iter() + .flatten() + .map(|(name, samples)| DisplayChannel { name, samples }) + .collect(); + Ok(DisplayPart { info, channels }) +} + +/// Filtered channel reader that writes each decoded block directly into a bounded display plane. +struct DisplaySelect<'a> { + index: usize, + keep: &'a dyn Fn(&str) -> bool, + fill: &'a dyn Fn(&str) -> f32, + limits: &'a Limits, + seen: std::cell::Cell, + block_limit: &'a std::cell::Cell>, +} + +struct DisplaySelectReader { + planes: Vec)>>, + offset: (i64, i64), + layer: (usize, usize), + display: (usize, usize), + f16s: Vec, + f32s: Vec, + u32s: Vec, +} + +impl<'s> ReadChannels<'s> for DisplaySelect<'_> { + type Reader = DisplaySelectReader; + + fn create_channels_reader(&'s self, header: &Header) -> exr::error::Result { + let part = self.seen.get(); + self.seen.set(part.saturating_add(1)); + let (layer_w, layer_h) = (header.layer_size.0, header.layer_size.1); + let display_window = header.shared_attributes.display_window; + let (display_w, display_h) = (display_window.size.0, display_window.size.1); + if part != self.index { + return Ok(DisplaySelectReader { + planes: header.channels.list.iter().map(|_| None).collect(), + offset: (0, 0), + layer: (layer_w, layer_h), + display: (display_w, display_h), + f16s: vec![], + f32s: vec![], + u32s: vec![], + }); + } + // The EXR crate decompresses each accepted block before `read_block` can discard ignored + // channels. Bound that all-channel input separately from the selected output planes. + let Some(block_bytes) = layer_w + .checked_mul(layer_h) + .and_then(|pixels| pixels.checked_mul(header.channels.list.len().max(1))) + .and_then(|samples| samples.checked_mul(std::mem::size_of::())) + else { + self.block_limit.set(Some(format!("data-window block size exceeds {}", self.limits.max_bytes))); + return Err(exr::error::Error::Invalid("data-window block size overflow".into())); + }; + if u64::try_from(block_bytes).map_or(true, |bytes| bytes > self.limits.max_bytes) || block_bytes > isize::MAX as usize { + self.block_limit.set(Some(format!("{block_bytes} data-window bytes exceed {}", self.limits.max_bytes))); + return Err(exr::error::Error::Invalid("data-window blocks exceed limit".into())); + } + let pixels = display_w.checked_mul(display_h).filter(|n| *n > 0).ok_or(exr::error::Error::Invalid("display window".into()))?; + let prefix = header.own_attributes.layer_name.as_ref().map(|name| name.to_string()).filter(|name| !name.is_empty()); + let kept = + header.channels.list.iter().filter(|channel| (self.keep)(&part_channel_name(prefix.as_deref(), &channel.name.to_string()))).count(); + let bytes = pixels + .checked_mul(4) + .and_then(|n| n.checked_mul(kept)) + .ok_or_else(|| exr::error::Error::Invalid("display planes exceed limit".into()))?; + if pixels as u64 > self.limits.max_pixels || bytes as u64 > self.limits.max_bytes || bytes > isize::MAX as usize { + return Err(exr::error::Error::Invalid("display planes exceed limit".into())); + } + let mut planes = Vec::with_capacity(header.channels.list.len()); + for channel in header.channels.list.iter() { + let name = part_channel_name(prefix.as_deref(), &channel.name.to_string()); + let plane = if (self.keep)(&name) { + if channel.sampling.0 != 1 || channel.sampling.1 != 1 { + return Err(exr::error::Error::NotSupported("subsampled channels".into())); + } + Some((name.clone(), vec![(self.fill)(&name); pixels])) + } else { + None + }; + planes.push(plane); + } + let position = header.own_attributes.layer_position; + Ok(DisplaySelectReader { + planes, + offset: (i64::from(position.0) - i64::from(display_window.position.0), i64::from(position.1) - i64::from(display_window.position.1)), + layer: (layer_w, layer_h), + display: (display_w, display_h), + f16s: vec![], + f32s: vec![], + u32s: vec![], + }) + } +} + +impl ChannelsReader for DisplaySelectReader { + type Channels = Vec)>>; + + fn filter_block(&self, tile: TileCoordinates) -> bool { + tile.is_largest_resolution_level() && self.planes.iter().any(Option::is_some) + } + + fn read_block(&mut self, header: &Header, block: UncompressedBlock) -> exr::error::UnitResult { + let ((layer_w, layer_h), (display_w, display_h)) = (self.layer, self.display); + for line in block.lines(&header.channels) { + let Some(Some((_, dst_plane))) = self.planes.get_mut(line.location.channel) else { continue }; + let (x, y, count) = (line.location.position.0, line.location.position.1, line.location.sample_count); + if y >= layer_h || x.checked_add(count).is_none_or(|end| end > layer_w) { + continue; + } + let Some(ty) = usize::try_from(y as i64 + self.offset.1).ok().filter(|ty| *ty < display_h) else { continue }; + let tx0 = x as i64 + self.offset.0; + let skip = usize::try_from(-tx0).unwrap_or(0).min(count); + let tx = usize::try_from(tx0.max(0)).unwrap_or(usize::MAX); + let take = (count - skip).min(display_w.saturating_sub(tx)); + if take == 0 { + continue; + } + let start = ty.checked_mul(display_w).and_then(|row| row.checked_add(tx)); + let Some(dst) = start.and_then(|s| dst_plane.get_mut(s..s.checked_add(take)?)) else { continue }; + match header.channels.list.get(line.location.channel).map(|c| c.sample_type) { + Some(ExrSample::F16) => { + self.f16s.resize(count, f16::ZERO); + line.read_samples_into_slice(&mut self.f16s)?; + for (d, s) in dst.iter_mut().zip(self.f16s.iter().skip(skip)) { + *d = s.to_f32(); + } + } + Some(ExrSample::F32) => { + self.f32s.resize(count, 0.0); + line.read_samples_into_slice(&mut self.f32s)?; + for (d, s) in dst.iter_mut().zip(self.f32s.iter().skip(skip)) { + *d = *s; + } + } + Some(ExrSample::U32) => { + self.u32s.resize(count, 0); + line.read_samples_into_slice(&mut self.u32s)?; + for (d, s) in dst.iter_mut().zip(self.u32s.iter().skip(skip)) { + *d = *s as f32; + } + } + None => continue, + } + } + Ok(()) + } + + fn into_channels(self) -> Self::Channels { + self.planes + } +} + /// Places a data-window plane in a display window: samples outside the display window are cut, /// the display window's pixels the data window does not cover are `fill`. `None` when the plane /// is not `data.width × data.height` samples or the display window has more than `max_pixels` diff --git a/crates/exr/src/decode_tests.rs b/crates/exr/src/decode_tests.rs index 69d8d07ac..54c28d9aa 100644 --- a/crates/exr/src/decode_tests.rs +++ b/crates/exr/src/decode_tests.rs @@ -118,6 +118,46 @@ fn display_window_placement() { assert_eq!(to_display_window(&[1.0, 2.0, 3.0, 4.0], far, display, 0.0, 100).unwrap(), [0.0; 6]); } +#[test] +fn decoded_display_part_places_selected_channels_and_checks_total_budget() { + let bytes = file(vec![layer(None, 2, 2, &["R", "G"], false, exr::prelude::Compression::ZIP1)], (3, 2)); + let part = decode_display_part(&bytes, 0, &|name| name == "R", &|_| 9.0, &Limits::default()).unwrap(); + assert_eq!(part.channels.len(), 1); + assert_eq!(part.channels[0].name, "R"); + assert_eq!(part.channels[0].samples, [0.0, 1.0, 9.0, 2.0, 3.0, 9.0]); + + let limits = Limits { max_bytes: 6 * 4 - 1, ..Limits::default() }; + assert!(matches!(decode_display_part(&bytes, 0, &all, &|_| 0.0, &limits), Err(Error::LimitExceeded(_)))); + + // One selected display plane fits exactly, but the EXR decoder must decompress both data + // channels in its input block before discarding the ignored one. + let block_limits = Limits { max_bytes: 6 * 4, ..Limits::default() }; + assert!(matches!(decode_display_part(&bytes, 0, &|name| name == "R", &|_| 0.0, &block_limits), Err(Error::LimitExceeded(_)))); +} + +#[test] +fn decoded_display_part_clips_a_wide_data_window_to_the_small_display() { + let width = (Limits::default().max_width as usize) + 1; + let bytes = file(vec![layer(None, width, 1, &["R"], false, exr::prelude::Compression::ZIP1)], (1, 1)); + let part = decode_display_part(&bytes, 0, &all, &|_| 9.0, &Limits::default()).unwrap(); + assert_eq!(part.channels.len(), 1); + assert_eq!(part.channels[0].samples, [0.0]); +} + +#[test] +fn decoded_display_part_rejects_kept_subsampled_channels() { + let mut bytes = file(vec![layer(None, 4, 1, &["R"], false, exr::prelude::Compression::Uncompressed)], (4, 1)); + // exr's writer doesn't emit subsampling yet, so patch the standard chlist sampling field in a + // valid uncompressed file. The reader must reject it before looking at the block payload. + let tag = b"channels\0chlist\0"; + let attr = bytes.windows(tag.len()).position(|window| window == tag).unwrap(); + let payload = attr + tag.len() + 4; + let name_end = payload + bytes[payload..].iter().position(|byte| *byte == 0).unwrap(); + let x_sampling = name_end + 1 + 4 + 1 + 3; + bytes[x_sampling..x_sampling + 4].copy_from_slice(&2u32.to_le_bytes()); + assert!(matches!(decode_display_part(&bytes, 0, &|name| name == "R", &|_| 0.0, &Limits::default()), Err(Error::Unsupported(_)))); +} + /// A 2x2 single-part file whose layer carries a 2x1 preview. fn with_preview() -> Vec { let mut attrs = LayerAttributes::named("rgb"); diff --git a/crates/figcraft-format/Cargo.toml b/crates/figcraft-format/Cargo.toml new file mode 100644 index 000000000..009e40c01 --- /dev/null +++ b/crates/figcraft-format/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "craft-figcraft-format" +description = "The native FigCraft document JSON and archive contract." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +craft-zip = { workspace = true } +zip = { version = "9", default-features = false, features = ["deflate-flate2"] } +thiserror = { workspace = true } diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs new file mode 100644 index 000000000..7475c5e21 --- /dev/null +++ b/crates/figcraft-format/src/lib.rs @@ -0,0 +1,849 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! The neutral JSON header and version contract for FigCraft's native project format. +//! App-specific document fields remain a generic flattened payload. +pub mod library; +use serde::de::{IgnoredAny, MapAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; + +pub const EXTENSION: &str = "figcraft"; +pub const FORMAT: &str = "figcraft"; +pub const VERSION: u32 = 1; + +fn is_false(value: &bool) -> bool { + !value +} + +/// One native version-history index entry. Invalid optional values decode to the same defaults as +/// the historical lenient reader; the app applies document-specific validity and issue reporting. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct VersionEntry { + pub id: String, + pub name: String, + pub description: String, + pub time: u64, + #[serde(skip_serializing_if = "is_false")] + pub auto: bool, + pub images: Vec, +} + +impl<'de> Deserialize<'de> for VersionEntry { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + let string = |key: &str| value.get(key).and_then(Value::as_str).unwrap_or_default().to_owned(); + let images = value.get("images").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); + Ok(Self { + id: string("id"), + name: string("name"), + description: string("description"), + time: value.get("time").and_then(Value::as_u64).unwrap_or(0), + auto: value.get("auto").and_then(Value::as_bool).unwrap_or(false), + images, + }) + } +} + +/// Native version-history index, oldest first. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct VersionIndex { + pub versions: Vec, +} + +/// Encode the native version-history index using the FigCraft wire schema. +pub fn write_version_index(index: &VersionIndex) -> Result, serde_json::Error> { + serde_json::to_vec(index) +} + +/// Decode the native version-history index using the format's lenient optional-field defaults. +pub fn read_version_index(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) +} + +impl<'de> Deserialize<'de> for VersionIndex { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + let versions = value + .get("versions") + .and_then(Value::as_array) + .map(|entries| entries.iter().cloned().map(serde_json::from_value).collect::, _>>()) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(); + Ok(Self { versions }) + } +} + +/// State stored on a native branch entry. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BranchState { + #[default] + Active, + Merged, + Archived, +} + +/// One native branch index entry. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BranchEntry { + pub id: String, + pub name: String, + pub time: u64, + pub client: u32, + pub state: BranchState, + pub base_images: Vec, + pub head_images: Vec, +} + +impl<'de> Deserialize<'de> for BranchEntry { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + let string = |key: &str| value.get(key).and_then(Value::as_str).unwrap_or_default().to_owned(); + let images = + |key: &str| value.get(key).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); + let state = value.get("state").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let client = value.get("client").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + Ok(Self { + id: string("id"), + name: string("name"), + time: value.get("time").and_then(Value::as_u64).unwrap_or(0), + client, + state, + base_images: images("baseImages"), + head_images: images("headImages"), + }) + } +} + +/// Native branch index. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct BranchIndex { + pub branches: Vec, +} + +/// Encode the native branch index using the FigCraft wire schema. +pub fn write_branch_index(index: &BranchIndex) -> Result, serde_json::Error> { + serde_json::to_vec(index) +} + +/// Decode the native branch index using the format's lenient optional-field defaults. +pub fn read_branch_index(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) +} + +impl<'de> Deserialize<'de> for BranchIndex { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + let branches = value + .get("branches") + .and_then(Value::as_array) + .map(|entries| entries.iter().cloned().map(serde_json::from_value).collect::, _>>()) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(); + Ok(Self { branches }) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Header { + pub version: Option, + pub images: Option>, +} + +pub fn read_header(text: &str) -> Result { + serde_json::from_str(text) +} + +impl<'de> Deserialize<'de> for Header { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> Visitor<'de> for V { + type Value = Header; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a FigCraft document object") + } + fn visit_map>(self, mut m: A) -> Result { + let mut h = Header::default(); + while let Some(k) = m.next_key::()? { + match k.as_str() { + "version" => h.version = m.next_value::()?.as_u64(), + "imageIndex" => { + h.images = m + .next_value::()? + .as_array() + .map(|a| a.iter().filter_map(|v| v.get("hash").and_then(Value::as_str).map(str::to_owned)).collect()) + } + _ => { + m.next_value::()?; + } + } + } + Ok(h) + } + } + d.deserialize_map(V) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +#[error("this file was saved by a newer FigCraft (format version {0})")] +pub struct NewerVersion(pub u32); + +/// Failure while reading the native document envelope or its app-supplied payload type. +#[derive(Debug, thiserror::Error)] +pub enum ReadDocumentError { + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + NewerVersion(#[from] NewerVersion), +} + +/// Read a document payload after checking the native header and version first. +/// +/// The payload type is supplied by the consumer, keeping app model conversion outside this crate. +pub fn read_document_json(text: &str) -> Result<(D, Header), ReadDocumentError> { + let header = read_header(text)?; + check_version(&header)?; + let document = serde_json::from_str(text)?; + Ok((document, header)) +} + +/// The native document fields needed to render a recent-file preview. +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +pub struct PreviewDocument { + #[serde(default)] + pub pages: Vec, + #[serde(default)] + pub nodes: std::collections::BTreeMap, + #[serde(default)] + pub kind: PreviewKind, +} + +/// Preview-relevant fields from one native document node. +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +pub struct PreviewNode { + #[serde(default)] + pub background: Option, +} + +/// The supported native document kinds as stored on disk. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreviewKind { + #[default] + Design, + Board, +} + +/// Decode the preview-relevant subset of the native FigCraft document schema. +pub fn read_preview_document(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) +} + +/// Largest `thumbnail.png` returned for a recent-file preview. +pub const MAX_PREVIEW_THUMBNAIL_BYTES: u64 = 8 * 1024 * 1024; +/// Largest `document.json` read to build a recent-file preview. +pub const MAX_PREVIEW_DOCUMENT_BYTES: u64 = 64 * 1024 * 1024; + +/// Native `.figcraft` members needed by the recent-file preview. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PreviewArchive { + /// Whether the archive has a `document.json` entry, even if it is damaged or oversized. + /// Consumers use this to distinguish a malformed native archive from other ZIP formats. + pub has_document: bool, + /// The thumbnail bytes, absent when missing, damaged, or above [`MAX_PREVIEW_THUMBNAIL_BYTES`]. + pub thumbnail_png: Option>, + /// The first-page fields, absent when `document.json` is missing, damaged, or oversized. + pub document: Option, +} + +/// Read only the thumbnail member for a native archive-list thumbnail. +/// +/// This avoids unpacking the document when a caller needs only the image. Missing, damaged, +/// and oversized entries return None; an unreadable archive is an error. +pub fn read_preview_thumbnail(reader: R) -> Result>, FormatError> { + let mut archive = SeekArchive::open(reader, u64::MAX).map_err(|error| FormatError::Corrupt(error.to_string()))?; + Ok(archive.read("thumbnail.png", MAX_PREVIEW_THUMBNAIL_BYTES).ok().flatten()) +} + +/// Read the bounded native preview members of a `.figcraft` archive. +/// +/// An unreadable archive is an error; a missing, damaged, or oversized member is represented by +/// `None`, so the caller can still show the other part of the preview. The thumbnail and document +/// retain the separate limits used by the app's recent-file preview. +pub fn read_preview_archive(reader: R) -> Result { + let mut archive = SeekArchive::open(reader, u64::MAX).map_err(|error| FormatError::Corrupt(error.to_string()))?; + Ok(read_preview_archive_from(&mut archive)) +} + +/// Read bounded native preview members from an already-open shared archive reader. +/// +/// This lets callers preserve fallback handling for other ZIP formats without implementing ZIP +/// member access themselves. +pub fn read_preview_archive_from(archive: &mut impl NativeArchive) -> PreviewArchive { + let has_document = archive.contains("document.json"); + let thumbnail_png = archive.read_member("thumbnail.png", MAX_PREVIEW_THUMBNAIL_BYTES).ok().flatten(); + let document = if has_document { + archive.read_member("document.json", MAX_PREVIEW_DOCUMENT_BYTES).ok().flatten().and_then(|bytes| read_preview_document(&bytes).ok()) + } else { + None + }; + PreviewArchive { has_document, thumbnail_png, document } +} + +pub fn check_version(h: &Header) -> Result<(), NewerVersion> { + if let Some(v) = h.version + && v > u64::from(VERSION) + { + return Err(NewerVersion(u32::try_from(v).unwrap_or(u32::MAX))); + } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ImageIndexEntry { + pub hash: String, + pub width: u32, + pub height: u32, +} + +pub fn write_document_json(doc: &D, images: &[ImageIndexEntry], w: W) -> Result<(), serde_json::Error> { + #[derive(Serialize)] + struct Flat<'a, D: ?Sized> { + format: &'static str, + version: u32, + #[serde(rename = "imageIndex")] + images: &'a [ImageIndexEntry], + #[serde(flatten)] + document: &'a D, + } + Flat { format: FORMAT, version: VERSION, images, document: doc }.serialize(&mut serde_json::Serializer::new(w)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preview_archive_reads_native_members_and_preserves_document_presence() { + let document = br#"{"pages":[7],"nodes":{"7":{"background":{"r":0.25}}},"kind":"board"}"#; + let bytes = write_archive([ + Member { name: "thumbnail.png", bytes: b"png bytes", compression: Compression::Stored }, + Member { name: "document.json", bytes: document, compression: Compression::Deflated }, + ]) + .unwrap(); + assert_eq!(read_preview_thumbnail(std::io::Cursor::new(bytes.clone())).unwrap().as_deref(), Some(b"png bytes".as_slice())); + let mut archive = SeekArchive::open(std::io::Cursor::new(bytes.clone()), u64::MAX).unwrap(); + assert!(read_preview_archive_from(&mut archive).has_document); + let preview = read_preview_archive(std::io::Cursor::new(bytes)).unwrap(); + assert!(preview.has_document); + assert_eq!(preview.thumbnail_png.as_deref(), Some(b"png bytes".as_slice())); + let document = preview.document.unwrap(); + assert_eq!(document.pages, [7]); + assert_eq!(document.kind, PreviewKind::Board); + assert_eq!(document.nodes["7"].background.as_ref().unwrap()["r"], 0.25); + + // A malformed native document still identifies this as a FigCraft archive, allowing the + // app to retain its existing fallback instead of interpreting it as another ZIP format. + let bytes = write_archive([Member { name: "document.json", bytes: b"{", compression: Compression::Stored }]).unwrap(); + let preview = read_preview_archive(std::io::Cursor::new(bytes)).unwrap(); + assert!(preview.has_document); + assert!(preview.document.is_none()); + assert!(preview.thumbnail_png.is_none()); + assert!(read_preview_archive(std::io::Cursor::new(b"not a zip".as_slice())).is_err()); + } + + #[test] + fn preview_archive_keeps_thumbnail_and_document_size_limits_independent() { + let too_large_thumbnail = vec![0; MAX_PREVIEW_THUMBNAIL_BYTES as usize + 1]; + let bytes = write_archive([ + Member { name: "thumbnail.png", bytes: &too_large_thumbnail, compression: Compression::Deflated }, + Member { name: "document.json", bytes: br#"{"pages":[3]}"#, compression: Compression::Stored }, + ]) + .unwrap(); + let preview = read_preview_archive(std::io::Cursor::new(bytes)).unwrap(); + assert!(preview.thumbnail_png.is_none()); + assert_eq!(preview.document.unwrap().pages, [3]); + + let too_large_document = vec![b' '; MAX_PREVIEW_DOCUMENT_BYTES as usize + 1]; + let bytes = write_archive([Member { name: "document.json", bytes: &too_large_document, compression: Compression::Deflated }]).unwrap(); + let preview = read_preview_archive(std::io::Cursor::new(bytes)).unwrap(); + assert!(preview.has_document); + assert!(preview.document.is_none()); + } + + #[test] + fn header_skips_app_fields() { + let h = read_header(r#"{"version":1,"imageIndex":[{"hash":"abc"}],"nested":{"unused":true}}"#).unwrap(); + assert_eq!(h.version, Some(1)); + assert_eq!(h.images, Some(vec!["abc".into()])); + } + + #[test] + fn version_index_preserves_lenient_defaults_and_writes_compact_auto_flag() { + let index = read_version_index(br#"{"extra":true,"versions":[{"id":"v1","time":7,"auto":false,"images":["a",3]},{}]}"#).unwrap(); + assert_eq!(index.versions.len(), 2); + assert_eq!(index.versions[0].images, vec!["a"]); + assert_eq!(index.versions[1], VersionEntry::default()); + let encoded: Value = serde_json::from_slice(&write_version_index(&index).unwrap()).unwrap(); + assert!(encoded["versions"][0].get("auto").is_none()); + assert_eq!(encoded["versions"][0]["time"], 7); + } + + #[test] + fn branch_index_uses_native_camel_case_and_tolerates_invalid_optional_fields() { + let index = + read_branch_index(br#"{"branches":[{"id":"b1","client":4294967296,"state":"unknown","baseImages":["a",false],"headImages":["b"]},{}]}"#) + .unwrap(); + let first = &index.branches[0]; + assert_eq!(first.client, 0); + assert_eq!(first.state, BranchState::Active); + assert_eq!(first.base_images, vec!["a"]); + assert_eq!(first.head_images, vec!["b"]); + let encoded = serde_json::to_value(&index).unwrap(); + assert_eq!(encoded["branches"][0]["baseImages"][0], "a"); + assert!(encoded["branches"][0].get("base_images").is_none()); + } + + #[test] + fn document_limit_keeps_compressible_files_and_caps_expansion() { + const MB: u64 = 1024 * 1024; + assert_eq!(document_limit(0, 0), MAX_MEMBER); + assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER); + assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER); + assert_eq!(document_limit(MAX_DOCUMENT, MAX_DOCUMENT), MAX_DOCUMENT); + assert_eq!(document_limit(244_362_923, 260 * MB), 244_362_923 * DOCUMENT_RATIO); + } + + #[test] + fn read_budget_enforces_member_and_archive_totals() { + let bytes = write_archive([ + Member { name: "one", bytes: b"12345678", compression: Compression::Stored }, + Member { name: "two", bytes: b"abcdefgh", compression: Compression::Stored }, + ]) + .unwrap(); + let mut archive = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 128 }).unwrap(); + let mut budget = ReadBudget::new(10); + assert_eq!(budget.read(&mut archive, "one", 16).unwrap(), b"12345678"); + assert!(budget.exceeds_remaining(&mut archive, "two")); + assert!(budget.exhausted_message().contains("contents together pass 10 bytes")); + assert!(budget.read(&mut archive, "two", 16).unwrap_err().to_string().contains("contents together pass 10 bytes")); + + let mut member_budget = ReadBudget::new(100); + assert!(member_budget.read(&mut archive, "two", 4).unwrap_err().to_string().contains("two is too large")); + assert_eq!(member_budget.read(&mut archive, "two", 16).unwrap(), b"abcdefgh"); + } + #[test] + fn newer_version_is_reported_before_document_decode() { + assert_eq!(check_version(&Header { version: Some(2), images: None }), Err(NewerVersion(2))); + assert_eq!(check_version(&Header { version: Some(u64::MAX), images: None }), Err(NewerVersion(u32::MAX))); + + #[derive(Deserialize)] + struct Payload { + document: u32, + } + let text = r#"{"version":2,"document":{"wrong":"type"}}"#; + assert!(matches!(read_document_json::(text), Err(ReadDocumentError::NewerVersion(NewerVersion(2))))); + let (payload, header) = read_document_json::(r#"{"version":1,"imageIndex":[{"hash":"a"}],"document":5}"#).unwrap(); + assert_eq!(payload.document, 5); + assert_eq!(header.images, Some(vec!["a".into()])); + } + #[test] + fn writer_flattens_document_fields() { + let doc = serde_json::json!({"name":"example"}); + let mut bytes = Vec::new(); + write_document_json(&doc, &[], &mut bytes).unwrap(); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["format"], FORMAT); + assert_eq!(v["version"], VERSION); + assert_eq!(v["name"], "example"); + } + + #[test] + fn preview_document_reads_native_page_background_and_kind() { + let doc = read_preview_document(br#"{"pages":[7],"nodes":{"7":{"background":{"r":0.1}}},"kind":"board","unrelated":true}"#).unwrap(); + assert_eq!(doc.pages, vec![7]); + assert_eq!(doc.nodes["7"].background.as_ref().and_then(|v| v.get("r")).and_then(Value::as_f64), Some(0.1)); + assert_eq!(doc.kind, PreviewKind::Board); + assert_eq!(read_preview_document(b"{}").unwrap(), PreviewDocument::default()); + assert!(read_preview_document(br#"{"kind":"unknown"}"#).is_err()); + } +} + +/// Limits for the native ZIP package reader. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ArchiveLimits { + pub max_entries: u64, + pub max_total_bytes: u64, +} + +/// A bounded reader for a native `.figcraft` ZIP package. +pub struct Archive<'a> { + inner: craft_zip::Archive<'a>, +} + +impl<'a> Archive<'a> { + pub fn open(bytes: &'a [u8], limits: ArchiveLimits) -> Result { + let inner = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: limits.max_entries, max_total_bytes: limits.max_total_bytes })?; + Ok(Self { inner }) + } + + pub fn names(&self) -> impl Iterator> { + self.inner.names() + } + + pub fn has_duplicate_names(&self) -> bool { + self.inner.has_duplicate_names() + } + + pub fn contains(&self, name: &str) -> bool { + self.inner.contains(name) + } + + pub fn declared_size(&mut self, name: &str) -> Option { + self.inner.declared_size(name) + } + + pub fn compressed_size(&mut self, name: &str) -> Option { + self.inner.compressed_size(name) + } + + pub fn read(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + self.inner.read(name, max_bytes) + } + + pub fn budget_used(&self) -> u64 { + self.inner.budget().used() + } +} + +/// One member to write into a native ZIP package. +#[derive(Clone, Copy, Debug)] +pub struct Member<'a> { + pub name: &'a str, + pub bytes: &'a [u8], + pub compression: craft_zip::Compression, +} + +/// Write a deterministic native ZIP package using the shared ZIP writer. +pub fn write_archive<'a>(members: impl IntoIterator>) -> Result, craft_zip::Error> { + let mut writer = craft_zip::Writer::new(); + for member in members { + writer.add(member.name, member.bytes, member.compression)?; + } + writer.finish() +} + +#[cfg(test)] +mod archive_tests { + use super::*; + + #[test] + fn bounded_archive_reader_and_writer_round_trip_members() { + let bytes = + write_archive([Member { name: "document.json", bytes: br#"{"version":1}"#, compression: craft_zip::Compression::Deflated }]).unwrap(); + let mut archive = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 128 }).unwrap(); + assert!(archive.contains("document.json")); + assert_eq!(archive.read("document.json", 32).unwrap(), Some(br#"{"version":1}"#.to_vec())); + assert_eq!(archive.budget_used(), 13); + } + + #[test] + fn archive_reader_enforces_entry_and_total_limits() { + let bytes = write_archive([Member { name: "document.json", bytes: b"123456789", compression: craft_zip::Compression::Deflated }]).unwrap(); + let mut small_entry = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 100 }).unwrap(); + assert!(small_entry.read("document.json", 8).is_err()); + let mut small_total = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 8 }).unwrap(); + assert!(small_total.read("document.json", 100).is_err()); + } +} + +/// A native archive writer that keeps the streaming document serializer out of the consumer. +pub struct ArchiveWriter { + inner: craft_zip::Writer, +} + +impl ArchiveWriter { + pub fn new() -> Self { + Self { inner: craft_zip::Writer::new() } + } + + pub fn add(&mut self, name: &str, bytes: &[u8], compression: craft_zip::Compression) -> Result<(), craft_zip::Error> { + self.inner.add(name, bytes, compression) + } + + pub fn add_document_json(&mut self, name: &str, document: &D, images: &[ImageIndexEntry]) -> Result<(), ArchiveWriteError> { + self.inner + .add_with(name, craft_zip::Compression::Level(3), |w| write_document_json(document, images, w).map_err(std::io::Error::other)) + .map_err(ArchiveWriteError::Archive) + } + + pub fn finish(self) -> Result, craft_zip::Error> { + self.inner.finish() + } +} + +impl Default for ArchiveWriter { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ArchiveWriteError { + #[error("could not serialize native document JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("could not write native archive: {0}")] + Archive(#[from] craft_zip::Error), +} + +#[cfg(test)] +mod streaming_writer_tests { + use super::*; + + #[test] + fn native_writer_streams_flat_document_into_archive() { + let document = serde_json::json!({"name":"streamed"}); + let mut writer = ArchiveWriter::new(); + writer.add_document_json("document.json", &document, &[]).unwrap(); + let bytes = writer.finish().unwrap(); + let mut archive = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 4096 }).unwrap(); + let json = archive.read("document.json", 1024).unwrap().unwrap(); + let value: Value = serde_json::from_slice(&json).unwrap(); + assert_eq!(value["name"], "streamed"); + assert_eq!(value["format"], FORMAT); + } +} + +/// ZIP compression modes for native archive members. +pub use craft_zip::Compression; + +/// A native ZIP reader for seekable sources that should not be copied into memory first. +pub struct SeekArchive { + inner: zip::ZipArchive, +} + +impl SeekArchive { + pub fn open(reader: R, max_entries: u64) -> Result { + let inner = zip::ZipArchive::new(reader).map_err(|e| craft_zip::Error::NotZip(e.to_string()))?; + let count = inner.len() as u64; + if count > max_entries { + return Err(craft_zip::Error::TooManyEntries { declared: count, limit: max_entries }); + } + Ok(Self { inner }) + } + + pub fn contains(&self, name: &str) -> bool { + self.inner.index_for_name(name).is_some() + } + + pub fn names(&self) -> Vec { + self.inner.file_names().filter_map(Result::ok).map(|n| n.into_owned()).collect() + } + + pub fn declared_size(&mut self, name: &str) -> Option { + self.inner.index_for_name(name).and_then(|i| self.inner.by_index_raw(i).ok().map(|f| f.size())) + } + + pub fn read(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + let Some(index) = self.inner.index_for_name(name) else { return Ok(None) }; + let mut file = self.inner.by_index(index).map_err(|e| craft_zip::Error::Entry { name: name.to_owned(), msg: e.to_string() })?; + if file.size() > max_bytes { + return Err(craft_zip::Error::EntryTooLarge { name: name.to_owned(), limit: max_bytes }); + } + let mut bytes = Vec::new(); + let mut limited = std::io::Read::take(&mut file, max_bytes.saturating_add(1)); + let read = std::io::Read::read_to_end(&mut limited, &mut bytes); + if let Err(error) = read { + return Err(craft_zip::Error::Entry { name: name.to_owned(), msg: error.to_string() }); + } + if bytes.len() as u64 > max_bytes { + return Err(craft_zip::Error::EntryTooLarge { name: name.to_owned(), limit: max_bytes }); + } + Ok(Some(bytes)) + } +} + +/// Shared access contract implemented by in-memory and seekable native archive readers. +pub trait NativeArchive { + fn contains(&self, name: &str) -> bool; + fn names(&self) -> Vec; + fn declared_size(&mut self, name: &str) -> Option; + fn compressed_size(&mut self, name: &str) -> Option; + fn read_member(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error>; +} + +impl NativeArchive for Archive<'_> { + fn contains(&self, name: &str) -> bool { + Archive::contains(self, name) + } + fn names(&self) -> Vec { + Archive::names(self).map(|n| n.into_owned()).collect() + } + fn declared_size(&mut self, name: &str) -> Option { + Archive::declared_size(self, name) + } + fn compressed_size(&mut self, name: &str) -> Option { + Archive::compressed_size(self, name) + } + fn read_member(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + Archive::read(self, name, max_bytes) + } +} + +impl NativeArchive for SeekArchive { + fn contains(&self, name: &str) -> bool { + SeekArchive::contains(self, name) + } + fn names(&self) -> Vec { + SeekArchive::names(self) + } + fn declared_size(&mut self, name: &str) -> Option { + SeekArchive::declared_size(self, name) + } + fn compressed_size(&mut self, name: &str) -> Option { + self.inner.index_for_name(name).and_then(|i| self.inner.by_index_raw(i).ok().map(|f| f.compressed_size())) + } + fn read_member(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + SeekArchive::read(self, name, max_bytes) + } +} + +#[cfg(test)] +mod seek_archive_tests { + use super::*; + + #[test] + fn seekable_reader_matches_bounded_in_memory_reader() { + let bytes = write_archive([Member { name: "document.json", bytes: b"payload", compression: Compression::Stored }]).unwrap(); + let mut reader = SeekArchive::open(std::io::Cursor::new(bytes), 4).unwrap(); + assert!(reader.contains("document.json")); + assert_eq!(reader.read_member("document.json", 16).unwrap(), Some(b"payload".to_vec())); + assert_eq!(reader.declared_size("document.json"), Some(7)); + assert!(reader.read_member("document.json", 3).is_err()); + } +} + +/// Largest individual ZIP member read by the native format. +pub const MAX_MEMBER: u64 = 512 * 1024 * 1024; +/// The document expansion ratio that its compressed size may claim. +pub const DOCUMENT_RATIO: u64 = 16; +#[cfg(not(target_pointer_width = "32"))] +pub const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +pub const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024; + +/// Upper bound for the expanded document member based on both the archive and member policy. +/// The floor preserves support for large but highly compressible documents; the ceiling prevents +/// a tiny archive from claiming an unbounded expansion. +pub fn document_limit(packed: u64, archive_bytes: u64) -> u64 { + packed.min(archive_bytes).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT) +} +/// Most version snapshots kept on save and read on open. +pub const MAX_VERSIONS: usize = 100; +/// Maximum version and branch name lengths, in Unicode scalar values. +pub const MAX_VERSION_NAME: usize = 200; +pub const MAX_VERSION_DESCRIPTION: usize = 2000; +/// Maximum bytes of an index and all version snapshots read. +pub const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024; +pub const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024; +/// Maximum images read and maximum combined image bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ImageBudget { + pub images: usize, + pub bytes: u64, +} +pub const IMAGE_BUDGET: ImageBudget = ImageBudget { images: 20_000, bytes: 4 * 1024 * 1024 * 1024 }; +/// Maximum branch histories kept. +pub const MAX_BRANCHES: usize = 100; +/// The branch index member. +pub const BRANCH_INDEX: &str = "branches/index.json"; +/// Maximum uncompressed bytes read from one native archive in total. +#[cfg(not(target_pointer_width = "32"))] +pub const MAX_UNPACKED: u64 = 6 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +pub const MAX_UNPACKED: u64 = 2 * 1024 * 1024 * 1024; + +/// Native-format errors shared with the consumer's document mapping. +#[derive(Debug, PartialEq, Eq)] +pub enum FormatError { + Io(String), + Corrupt(String), + Newer(u32), +} + +impl std::fmt::Display for FormatError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "{e}"), + Self::Corrupt(e) => write!(f, "not a valid FigCraft file: {e}"), + Self::Newer(v) => write!(f, "this file was saved by a newer FigCraft (format version {v})"), + } + } +} + +impl std::error::Error for FormatError {} + +/// Tracks the total bytes unpacked from one native archive across its document, images, library, +/// history, and branch members. +#[derive(Clone, Debug)] +pub struct ReadBudget { + left: u64, + total: u64, +} + +impl ReadBudget { + pub fn new(total: u64) -> Self { + Self { left: total, total } + } + + fn passed(&self) -> String { + match self.total / (1024 * 1024) { + 0 => format!("too large: the file's contents together pass {} bytes", self.total), + mb => format!("too large: the file's contents together pass {mb} MB"), + } + } + + /// Whether the member's declared uncompressed size exceeds what remains in this budget. + pub fn exceeds_remaining(&self, archive: &mut impl NativeArchive, name: &str) -> bool { + archive.declared_size(name).is_some_and(|size| size > self.left) + } + + /// Human-readable reason for leaving out a member after the archive-wide limit is spent. + pub fn exhausted_message(&self) -> String { + self.passed() + } + + /// Read one native member within its own size limit and the remaining archive-wide budget. + /// A failed read that consumed a member is charged at its declared size. + pub fn read(&mut self, archive: &mut impl NativeArchive, name: &str, limit: u64) -> Result, FormatError> { + let declared = archive.declared_size(name); + if declared.is_some_and(|size| size > self.left && size <= limit) { + return Err(FormatError::Corrupt(format!("{name}: {}", self.passed()))); + } + let cap = limit.min(self.left); + let size = declared.unwrap_or(0); + let result = match archive.read_member(name, cap) { + Ok(Some(bytes)) => Ok(bytes), + Ok(None) => Err(FormatError::Corrupt(format!("{name}: not found"))), + Err(error) if error.is_limit() => Err(FormatError::Corrupt(format!("{name} is too large"))), + Err(error) => Err(FormatError::Corrupt(format!("{name}: {error}"))), + }; + if size <= cap { + self.left = self.left.saturating_sub(result.as_ref().map_or(size, |bytes| bytes.len() as u64)); + } + result + } +} + +/// Whether `id` can name a version or branch: 1–64 ASCII letters, digits, `-` and `_`. +pub fn is_valid_version_id(id: &str) -> bool { + (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// Clip text to at most `max` Unicode scalar values. +pub fn clip(s: &str, max: usize) -> String { + s.chars().take(max).collect() +} diff --git a/crates/figcraft-format/src/library.rs b/crates/figcraft-format/src/library.rs new file mode 100644 index 000000000..7cf6b5b2c --- /dev/null +++ b/crates/figcraft-format/src/library.rs @@ -0,0 +1,205 @@ +//! Wire schema and member limits for published libraries embedded in a native project archive. +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +pub const MANIFEST: &str = "library/manifest.json"; +pub const DOCUMENT: &str = "library/document.json"; +pub const IMAGES_PREFIX: &str = "library/images/"; +pub const MAX_MANIFEST_BYTES: u64 = 16 * 1024 * 1024; +pub const MAX_IMAGE_BYTES: u64 = 1024 * 1024 * 1024; +pub const MAX_ID_CHARS: usize = 64; +pub const MAX_NAME_CHARS: usize = 200; +pub const MAX_DESCRIPTION_CHARS: usize = 2000; + +/// Native library manifest, independent of the consumer's document model. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Manifest { + pub id: String, + pub name: String, + pub version: u64, + pub description: String, + pub time: u64, + pub components: Vec, + pub styles: Vec