Compare commits
9 Commits
bdf23de8db
...
18cec7bac7
| Author | SHA1 | Date | |
|---|---|---|---|
| 18cec7bac7 | |||
| 9068e78a62 | |||
| f9b2b05f3f | |||
| c97bad5551 | |||
| 5895344f6f | |||
| e63b5783bd | |||
| 49568a582b | |||
| cd0a99a813 | |||
| 6f81eb4b08 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
/target
|
||||
/.worktrees
|
||||
/docs/superpowers
|
||||
|
||||
687
Cargo.lock
generated
687
Cargo.lock
generated
@@ -2,25 +2,92 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.19.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2ac2a4d0e69036cf0062976f6efcba1aaee3e448594e6514bb2ddf87acce562"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"cairo-sys-rs",
|
||||
"glib",
|
||||
"libc",
|
||||
@@ -38,6 +105,33 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.15.8"
|
||||
@@ -48,6 +142,94 @@ dependencies = [
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-sys"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -64,6 +246,12 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
@@ -183,6 +371,18 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.19.8"
|
||||
@@ -211,7 +411,7 @@ dependencies = [
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"windows-sys",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -220,7 +420,7 @@ version = "0.19.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39650279f135469465018daae0ba53357942a5212137515777d5fdca74984a44"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.11.0",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
@@ -259,6 +459,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.19.8"
|
||||
@@ -398,12 +604,88 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"thiserror",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.182"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -419,6 +701,41 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-context"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.5.0+25.2.9519653"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||
dependencies = [
|
||||
"jni-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nesemu"
|
||||
version = "0.1.0"
|
||||
@@ -443,10 +760,92 @@ name = "nesemu-desktop"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cairo-rs",
|
||||
"cpal",
|
||||
"gtk4",
|
||||
"nesemu",
|
||||
]
|
||||
|
||||
[[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 = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c"
|
||||
dependencies = [
|
||||
"num_enum_derive",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum_derive"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
|
||||
dependencies = [
|
||||
"jni",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"oboe-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.19.8"
|
||||
@@ -510,6 +909,47 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -519,6 +959,21 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@@ -563,6 +1018,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -701,13 +1162,169 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -716,28 +1333,46 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -750,24 +1385,48 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -782,3 +1441,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
@@ -40,3 +40,12 @@ match_same_arms = "allow"
|
||||
module_name_repetitions = "allow"
|
||||
too_many_lines = "allow"
|
||||
needless_pass_by_value = "allow"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
[profile.dev.package.nesemu]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.nesemu-desktop]
|
||||
opt-level = 2
|
||||
|
||||
@@ -7,3 +7,4 @@ edition = "2024"
|
||||
nesemu = { path = "../.." }
|
||||
gtk4 = "0.8"
|
||||
cairo-rs = "0.19"
|
||||
cpal = "0.15"
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gtk::gio;
|
||||
use gtk::gdk;
|
||||
use gtk::gio;
|
||||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
use gtk4 as gtk;
|
||||
use nesemu::prelude::{EmulationState, HostConfig, RuntimeHostLoop};
|
||||
use nesemu::{
|
||||
FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, FrameClock, InputProvider, JoypadButton,
|
||||
JoypadButtons, NesRuntime, set_button_pressed,
|
||||
set_button_pressed, FrameClock, InputProvider, JoypadButton, JoypadButtons, NesRuntime,
|
||||
RingBuffer, VideoMode, VideoOutput, FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH,
|
||||
};
|
||||
|
||||
const APP_ID: &str = "org.nesemu.desktop";
|
||||
const TITLE: &str = "NES Emulator";
|
||||
const SCALE: i32 = 3;
|
||||
const SAMPLE_RATE: u32 = 48_000;
|
||||
const AUDIO_RING_CAPACITY: usize = 1536;
|
||||
const AUDIO_CALLBACK_FRAMES: u32 = 256;
|
||||
|
||||
fn main() {
|
||||
if std::env::var_os("GSK_RENDERER").is_none() {
|
||||
@@ -26,9 +30,7 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
let app = gtk::Application::builder()
|
||||
.application_id(APP_ID)
|
||||
.build();
|
||||
let app = gtk::Application::builder().application_id(APP_ID).build();
|
||||
|
||||
let initial_rom: Rc<RefCell<Option<PathBuf>>> =
|
||||
Rc::new(RefCell::new(std::env::args().nth(1).map(PathBuf::from)));
|
||||
@@ -73,10 +75,33 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
.sensitive(false)
|
||||
.build();
|
||||
|
||||
let volume = Arc::new(AtomicU32::new(f32::to_bits(0.75)));
|
||||
|
||||
let volume_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.05);
|
||||
volume_scale.set_value(0.75);
|
||||
volume_scale.set_draw_value(false);
|
||||
volume_scale.set_width_request(100);
|
||||
volume_scale.set_tooltip_text(Some("Volume"));
|
||||
volume_scale.set_focusable(false);
|
||||
|
||||
{
|
||||
let volume = Arc::clone(&volume);
|
||||
volume_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value() as f32;
|
||||
volume.store(f32::to_bits(val), AtomicOrdering::Relaxed);
|
||||
});
|
||||
}
|
||||
|
||||
header.pack_start(&open_button);
|
||||
header.pack_start(&pause_button);
|
||||
header.pack_start(&reset_button);
|
||||
|
||||
let volume_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
||||
let volume_icon = gtk::Image::from_icon_name("audio-volume-high-symbolic");
|
||||
volume_box.append(&volume_icon);
|
||||
volume_box.append(&volume_scale);
|
||||
header.pack_end(&volume_box);
|
||||
|
||||
window.set_titlebar(Some(&header));
|
||||
|
||||
// --- Drawing area ---
|
||||
@@ -99,17 +124,18 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
window.set_child(Some(&overlay));
|
||||
|
||||
// --- State ---
|
||||
let desktop = Rc::new(RefCell::new(DesktopApp::new()));
|
||||
let frame_for_draw: Rc<RefCell<Vec<u8>>> =
|
||||
Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES]));
|
||||
let desktop = Rc::new(RefCell::new(DesktopApp::new(Arc::clone(&volume))));
|
||||
let frame_for_draw: Rc<RefCell<Vec<u8>>> = Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES]));
|
||||
let scheduler = Rc::new(RefCell::new(DesktopFrameScheduler::new()));
|
||||
|
||||
// --- Draw function (pixel-perfect nearest-neighbor) ---
|
||||
{
|
||||
let frame_for_draw = Rc::clone(&frame_for_draw);
|
||||
drawing_area.set_draw_func(move |_da, cr, width, height| {
|
||||
let frame = frame_for_draw.borrow();
|
||||
let stride =
|
||||
cairo::Format::ARgb32.stride_for_width(FRAME_WIDTH as u32).unwrap();
|
||||
let stride = cairo::Format::ARgb32
|
||||
.stride_for_width(FRAME_WIDTH as u32)
|
||||
.unwrap();
|
||||
let mut argb = vec![0u8; stride as usize * FRAME_HEIGHT];
|
||||
for y in 0..FRAME_HEIGHT {
|
||||
for x in 0..FRAME_WIDTH {
|
||||
@@ -198,6 +224,7 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
// --- Open ROM handler ---
|
||||
let do_open_rom = {
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let scheduler = Rc::clone(&scheduler);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
let window = window.clone();
|
||||
Rc::new(move || {
|
||||
@@ -219,6 +246,7 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
chooser.add_filter(&all_filter);
|
||||
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let scheduler = Rc::clone(&scheduler);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
chooser.connect_response(move |dialog, response| {
|
||||
if response == gtk::ResponseType::Accept {
|
||||
@@ -227,6 +255,7 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
if let Err(err) = app_state.load_rom_from_path(&path) {
|
||||
eprintln!("Failed to load ROM '{}': {err}", path.display());
|
||||
} else {
|
||||
scheduler.borrow_mut().reset_timing();
|
||||
let name = rom_filename(&path);
|
||||
sync_ui(&app_state, Some(&name));
|
||||
}
|
||||
@@ -248,20 +277,24 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let scheduler = Rc::clone(&scheduler);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
pause_button.connect_clicked(move |_| {
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
app_state.toggle_pause();
|
||||
scheduler.borrow_mut().reset_timing();
|
||||
sync_ui(&app_state, None);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let scheduler = Rc::clone(&scheduler);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
reset_button.connect_clicked(move |_| {
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
app_state.reset();
|
||||
scheduler.borrow_mut().reset_timing();
|
||||
sync_ui(&app_state, None);
|
||||
});
|
||||
}
|
||||
@@ -280,11 +313,13 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
let action_pause = gio::SimpleAction::new("toggle-pause", None);
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let scheduler = Rc::clone(&scheduler);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
action_pause.connect_activate(move |_, _| {
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
if app_state.is_loaded() {
|
||||
app_state.toggle_pause();
|
||||
scheduler.borrow_mut().reset_timing();
|
||||
sync_ui(&app_state, None);
|
||||
}
|
||||
});
|
||||
@@ -295,11 +330,13 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
let action_reset = gio::SimpleAction::new("reset", None);
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let scheduler = Rc::clone(&scheduler);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
action_reset.connect_activate(move |_, _| {
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
if app_state.is_loaded() {
|
||||
app_state.reset();
|
||||
scheduler.borrow_mut().reset_timing();
|
||||
sync_ui(&app_state, None);
|
||||
}
|
||||
});
|
||||
@@ -329,6 +366,7 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
// --- Drag-and-drop ---
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let scheduler = Rc::clone(&scheduler);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
let drop_target = gtk::DropTarget::new(gio::File::static_type(), gdk::DragAction::COPY);
|
||||
drop_target.connect_drop(move |_, value, _, _| {
|
||||
@@ -339,6 +377,7 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
eprintln!("Failed to load ROM '{}': {err}", path.display());
|
||||
return false;
|
||||
}
|
||||
scheduler.borrow_mut().reset_timing();
|
||||
let name = rom_filename(&path);
|
||||
sync_ui(&app_state, Some(&name));
|
||||
return true;
|
||||
@@ -351,23 +390,45 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
|
||||
// --- Game loop ---
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let drawing_area = drawing_area.clone();
|
||||
let frame_for_draw = Rc::clone(&frame_for_draw);
|
||||
glib::timeout_add_local(Duration::from_millis(16), move || {
|
||||
schedule_game_loop(
|
||||
Rc::clone(&desktop),
|
||||
drawing_area.clone(),
|
||||
Rc::clone(&frame_for_draw),
|
||||
Rc::clone(&scheduler),
|
||||
);
|
||||
}
|
||||
|
||||
window.present();
|
||||
}
|
||||
|
||||
fn schedule_game_loop(
|
||||
desktop: Rc<RefCell<DesktopApp>>,
|
||||
drawing_area: gtk::DrawingArea,
|
||||
frame_for_draw: Rc<RefCell<Vec<u8>>>,
|
||||
scheduler: Rc<RefCell<DesktopFrameScheduler>>,
|
||||
) {
|
||||
let interval = desktop.borrow().frame_interval();
|
||||
let delay = scheduler
|
||||
.borrow_mut()
|
||||
.delay_until_next_frame(Instant::now(), interval);
|
||||
|
||||
glib::timeout_add_local_once(delay, move || {
|
||||
{
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
let now = Instant::now();
|
||||
let interval = app_state.frame_interval();
|
||||
|
||||
scheduler.borrow_mut().mark_frame_complete(now, interval);
|
||||
app_state.tick();
|
||||
|
||||
frame_for_draw
|
||||
.borrow_mut()
|
||||
.copy_from_slice(app_state.frame_rgba());
|
||||
drawing_area.queue_draw();
|
||||
}
|
||||
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
window.present();
|
||||
schedule_game_loop(desktop, drawing_area, frame_for_draw, scheduler);
|
||||
});
|
||||
}
|
||||
|
||||
fn rom_filename(path: &Path) -> String {
|
||||
@@ -409,14 +470,165 @@ impl InputProvider for InputState {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio (stub)
|
||||
// Audio (cpal backend)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct AudioSink;
|
||||
struct CpalAudioSink {
|
||||
_stream: Option<cpal::Stream>,
|
||||
ring: Arc<RingBuffer>,
|
||||
_volume: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl nesemu::AudioOutput for AudioSink {
|
||||
fn push_samples(&mut self, _samples: &[f32]) {}
|
||||
impl CpalAudioSink {
|
||||
fn new(volume: Arc<AtomicU32>) -> Self {
|
||||
let ring = Arc::new(RingBuffer::new(AUDIO_RING_CAPACITY));
|
||||
let ring_for_cb = Arc::clone(&ring);
|
||||
let vol_for_cb = Arc::clone(&volume);
|
||||
let stream = Self::try_build_stream(ring_for_cb, vol_for_cb);
|
||||
Self {
|
||||
_stream: stream,
|
||||
ring,
|
||||
_volume: volume,
|
||||
}
|
||||
}
|
||||
|
||||
fn try_build_stream(ring: Arc<RingBuffer>, volume: Arc<AtomicU32>) -> Option<cpal::Stream> {
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
|
||||
let host = cpal::default_host();
|
||||
let device = match host.default_output_device() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
eprintln!("No audio output device found — running without sound");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let config = cpal_stream_config();
|
||||
|
||||
let stream = match device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
let read = ring.pop(data);
|
||||
for sample in &mut data[read..] {
|
||||
*sample = 0.0;
|
||||
}
|
||||
let vol = f32::from_bits(volume.load(AtomicOrdering::Relaxed));
|
||||
for sample in &mut data[..read] {
|
||||
*sample *= vol;
|
||||
}
|
||||
},
|
||||
move |err| {
|
||||
eprintln!("Audio stream error: {err}");
|
||||
},
|
||||
None,
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
eprintln!("Failed to build audio stream: {err} — running without sound");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = stream.play() {
|
||||
eprintln!("Failed to start audio stream: {err} — running without sound");
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(stream)
|
||||
}
|
||||
|
||||
/// Reset the ring buffer. Note: the cpal callback may still be calling
|
||||
/// `pop()` concurrently; in practice this is benign — at worst a few stale
|
||||
/// samples are played during the ROM load / reset transition.
|
||||
fn clear(&self) {
|
||||
self.ring.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl nesemu::AudioOutput for CpalAudioSink {
|
||||
fn push_samples(&mut self, samples: &[f32]) {
|
||||
self.ring.push(samples);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn audio_ring_latency_ms(capacity: usize, sample_rate: u32) -> f64 {
|
||||
((capacity.saturating_sub(1)) as f64 / sample_rate as f64) * 1000.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn required_audio_ring_capacity(sample_rate: u32, mode: VideoMode) -> usize {
|
||||
let samples_per_frame = (sample_rate as f64 / mode.frame_hz()).ceil() as usize;
|
||||
samples_per_frame + AUDIO_CALLBACK_FRAMES as usize + 1
|
||||
}
|
||||
|
||||
fn cpal_stream_config() -> cpal::StreamConfig {
|
||||
cpal::StreamConfig {
|
||||
channels: 1,
|
||||
sample_rate: cpal::SampleRate(SAMPLE_RATE),
|
||||
buffer_size: cpal::BufferSize::Fixed(AUDIO_CALLBACK_FRAMES),
|
||||
}
|
||||
}
|
||||
|
||||
struct DesktopFrameScheduler {
|
||||
next_deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
impl DesktopFrameScheduler {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
next_deadline: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_timing(&mut self) {
|
||||
self.next_deadline = None;
|
||||
}
|
||||
|
||||
fn delay_until_next_frame(&mut self, now: Instant, _interval: Duration) -> Duration {
|
||||
match self.next_deadline {
|
||||
None => {
|
||||
self.next_deadline = Some(now);
|
||||
Duration::ZERO
|
||||
}
|
||||
Some(deadline) if now < deadline => deadline - now,
|
||||
Some(_) => Duration::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_frame_complete(&mut self, now: Instant, interval: Duration) {
|
||||
let mut next_deadline = self.next_deadline.unwrap_or(now) + interval;
|
||||
while next_deadline <= now {
|
||||
next_deadline += interval;
|
||||
}
|
||||
self.next_deadline = Some(next_deadline);
|
||||
}
|
||||
}
|
||||
|
||||
struct BufferedVideo {
|
||||
frame_rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BufferedVideo {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
frame_rgba: vec![0; FRAME_RGBA_BYTES],
|
||||
}
|
||||
}
|
||||
|
||||
fn frame_rgba(&self) -> &[u8] {
|
||||
&self.frame_rgba
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoOutput for BufferedVideo {
|
||||
fn present_rgba(&mut self, frame: &[u8], width: usize, height: usize) {
|
||||
if width != FRAME_WIDTH || height != FRAME_HEIGHT || frame.len() != FRAME_RGBA_BYTES {
|
||||
return;
|
||||
}
|
||||
self.frame_rgba.copy_from_slice(frame);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -426,18 +638,18 @@ impl nesemu::AudioOutput for AudioSink {
|
||||
struct DesktopApp {
|
||||
host: Option<RuntimeHostLoop<Box<dyn FrameClock>>>,
|
||||
input: InputState,
|
||||
audio: AudioSink,
|
||||
frame_rgba: Vec<u8>,
|
||||
audio: CpalAudioSink,
|
||||
video: BufferedVideo,
|
||||
state: EmulationState,
|
||||
}
|
||||
|
||||
impl DesktopApp {
|
||||
fn new() -> Self {
|
||||
fn new(volume: Arc<AtomicU32>) -> Self {
|
||||
Self {
|
||||
host: None,
|
||||
input: InputState::default(),
|
||||
audio: AudioSink,
|
||||
frame_rgba: vec![0; FRAME_RGBA_BYTES],
|
||||
audio: CpalAudioSink::new(volume),
|
||||
video: BufferedVideo::new(),
|
||||
state: EmulationState::Paused,
|
||||
}
|
||||
}
|
||||
@@ -447,6 +659,7 @@ impl DesktopApp {
|
||||
let runtime = NesRuntime::from_rom_bytes(&data)?;
|
||||
let config = HostConfig::new(SAMPLE_RATE, false);
|
||||
self.host = Some(RuntimeHostLoop::with_config(runtime, config));
|
||||
self.audio.clear();
|
||||
self.state = EmulationState::Running;
|
||||
Ok(())
|
||||
}
|
||||
@@ -454,6 +667,7 @@ impl DesktopApp {
|
||||
fn reset(&mut self) {
|
||||
if let Some(host) = self.host.as_mut() {
|
||||
host.runtime_mut().reset();
|
||||
self.audio.clear();
|
||||
self.state = EmulationState::Running;
|
||||
}
|
||||
}
|
||||
@@ -483,23 +697,137 @@ impl DesktopApp {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut null_video = nesemu::NullVideo;
|
||||
if let Err(err) = host.run_frame_unpaced(&mut self.input, &mut null_video, &mut self.audio)
|
||||
{
|
||||
eprintln!("Frame execution error: {err}");
|
||||
self.state = EmulationState::Paused;
|
||||
return;
|
||||
match host.run_frame_unpaced(&mut self.input, &mut self.video, &mut self.audio) {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
eprintln!("Frame execution error: {err}");
|
||||
self.state = EmulationState::Paused;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.frame_rgba
|
||||
.copy_from_slice(&host.runtime().frame_rgba());
|
||||
}
|
||||
|
||||
fn frame_rgba(&self) -> &[u8] {
|
||||
&self.frame_rgba
|
||||
self.video.frame_rgba()
|
||||
}
|
||||
|
||||
fn frame_interval(&self) -> Duration {
|
||||
self.host
|
||||
.as_ref()
|
||||
.map(|host| host.runtime().video_mode().frame_duration())
|
||||
.unwrap_or_else(|| VideoMode::Ntsc.frame_duration())
|
||||
}
|
||||
|
||||
fn input_mut(&mut self) -> &mut InputState {
|
||||
&mut self.input
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nesemu::{VideoOutput, FRAME_HEIGHT, FRAME_WIDTH};
|
||||
use std::time::Instant;
|
||||
|
||||
#[test]
|
||||
fn frame_scheduler_waits_until_frame_deadline() {
|
||||
let mut scheduler = DesktopFrameScheduler::new();
|
||||
let start = Instant::now();
|
||||
let interval = Duration::from_micros(16_639);
|
||||
|
||||
assert_eq!(
|
||||
scheduler.delay_until_next_frame(start, interval),
|
||||
Duration::ZERO
|
||||
);
|
||||
scheduler.mark_frame_complete(start, interval);
|
||||
assert!(
|
||||
scheduler.delay_until_next_frame(start + Duration::from_millis(1), interval)
|
||||
> Duration::ZERO
|
||||
);
|
||||
assert_eq!(
|
||||
scheduler.delay_until_next_frame(start + interval, interval),
|
||||
Duration::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffered_video_captures_presented_frame() {
|
||||
let mut video = BufferedVideo::new();
|
||||
let mut frame = vec![0u8; FRAME_RGBA_BYTES];
|
||||
frame[0] = 0x12;
|
||||
frame[1] = 0x34;
|
||||
frame[2] = 0x56;
|
||||
frame[3] = 0x78;
|
||||
|
||||
video.present_rgba(&frame, FRAME_WIDTH, FRAME_HEIGHT);
|
||||
|
||||
assert_eq!(video.frame_rgba(), frame.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_scheduler_reset_restarts_from_immediate_tick() {
|
||||
let mut scheduler = DesktopFrameScheduler::new();
|
||||
let start = Instant::now();
|
||||
let interval = Duration::from_micros(16_639);
|
||||
|
||||
assert_eq!(
|
||||
scheduler.delay_until_next_frame(start, interval),
|
||||
Duration::ZERO
|
||||
);
|
||||
scheduler.mark_frame_complete(start, interval);
|
||||
assert!(scheduler.delay_until_next_frame(start, interval) > Duration::ZERO);
|
||||
|
||||
scheduler.reset_timing();
|
||||
assert_eq!(
|
||||
scheduler.delay_until_next_frame(start, interval),
|
||||
Duration::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_scheduler_reports_zero_delay_when_late() {
|
||||
let mut scheduler = DesktopFrameScheduler::new();
|
||||
let start = Instant::now();
|
||||
let interval = Duration::from_micros(16_639);
|
||||
|
||||
assert_eq!(
|
||||
scheduler.delay_until_next_frame(start, interval),
|
||||
Duration::ZERO
|
||||
);
|
||||
scheduler.mark_frame_complete(start, interval);
|
||||
|
||||
assert_eq!(
|
||||
scheduler.delay_until_next_frame(start + interval + Duration::from_millis(2), interval),
|
||||
Duration::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_audio_ring_budget_stays_below_25ms() {
|
||||
let latency_ms = audio_ring_latency_ms(AUDIO_RING_CAPACITY, SAMPLE_RATE);
|
||||
let max_budget_ms = 40.0;
|
||||
assert!(
|
||||
latency_ms <= max_budget_ms,
|
||||
"desktop audio ring latency budget too high: {latency_ms:.2}ms"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_audio_uses_fixed_low_latency_callback_size() {
|
||||
let config = cpal_stream_config();
|
||||
assert_eq!(
|
||||
config.buffer_size,
|
||||
cpal::BufferSize::Fixed(AUDIO_CALLBACK_FRAMES)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_audio_ring_has_frame_burst_headroom() {
|
||||
let required = required_audio_ring_capacity(SAMPLE_RATE, VideoMode::Ntsc);
|
||||
assert!(
|
||||
AUDIO_RING_CAPACITY >= required,
|
||||
"audio ring too small for frame burst: capacity={}, required={required}",
|
||||
AUDIO_RING_CAPACITY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1032
docs/superpowers/plans/2026-03-13-audio-output.md
Normal file
1032
docs/superpowers/plans/2026-03-13-audio-output.md
Normal file
File diff suppressed because it is too large
Load Diff
245
docs/superpowers/specs/2026-03-13-audio-output-design.md
Normal file
245
docs/superpowers/specs/2026-03-13-audio-output-design.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Audio Output Design — Full 5-Channel Mixer + cpal Backend
|
||||
|
||||
## Overview
|
||||
|
||||
Add real audio output to the desktop NES emulator client. This involves two independent pieces of work:
|
||||
|
||||
1. **Full APU mixer** — replace the current DMC-only mixer with proper 5-channel mixing (Pulse 1, Pulse 2, Triangle, Noise, DMC) using NES hardware-accurate formulas.
|
||||
2. **cpal audio backend** — replace the stub `AudioSink` in the desktop client with a real audio output using `cpal`, connected via a lock-free ring buffer. Add a volume slider to the GTK4 header bar.
|
||||
|
||||
## 1. Full APU Mixer
|
||||
|
||||
### Current State
|
||||
|
||||
`AudioMixer::push_cycles()` in `src/runtime/audio.rs` reads only `apu_regs[0x11]` (DMC output level) and generates a single-channel signal. All other channels are ignored.
|
||||
|
||||
### Design
|
||||
|
||||
#### 1.1 Channel Outputs Struct
|
||||
|
||||
Add to `src/native_core/apu/`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ChannelOutputs {
|
||||
pub pulse1: u8, // 0–15
|
||||
pub pulse2: u8, // 0–15
|
||||
pub triangle: u8, // 0–15
|
||||
pub noise: u8, // 0–15
|
||||
pub dmc: u8, // 0–127
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 New APU Internal State
|
||||
|
||||
The current `Apu` struct lacks timer counters and sequencer state needed to compute channel outputs. The following fields must be added:
|
||||
|
||||
**Pulse channels (×2):**
|
||||
- `pulse_timer_counter: [u16; 2]` — countdown timer, clocked every other CPU cycle
|
||||
- `pulse_duty_step: [u8; 2]` — position in 8-step duty cycle sequence (0–7)
|
||||
|
||||
**Triangle channel:**
|
||||
- `triangle_timer_counter: u16` — countdown timer, clocked every CPU cycle
|
||||
- `triangle_step: u8` — position in 32-step triangle sequence (0–31)
|
||||
|
||||
**Noise channel:**
|
||||
- `noise_timer_counter: u16` — countdown timer, clocked every other CPU cycle
|
||||
- `noise_lfsr: u16` — 15-bit linear feedback shift register, initialized to 1
|
||||
|
||||
These must be clocked in `Apu::clock_cpu_cycle()`:
|
||||
- Pulse and noise timers decrement every **2** CPU cycles (APU rate, tracked via existing `cpu_cycle_parity`)
|
||||
- Triangle timer decrements every **1** CPU cycle
|
||||
- When a timer reaches 0, it reloads from the period register and advances the corresponding sequencer
|
||||
|
||||
#### 1.3 APU Method
|
||||
|
||||
Add `Apu::channel_outputs(&self) -> ChannelOutputs` that computes the current output level of each channel:
|
||||
|
||||
- **Pulse 1/2:** Output is 0 if length counter is 0, or sweep mutes the channel, or duty cycle sequencer output is 0. Otherwise output is the envelope volume (0–15).
|
||||
- **Triangle:** Output is the value from the 32-step triangle waveform lookup at `triangle_step`. Muted (output 0) if length counter or linear counter is 0.
|
||||
- **Noise:** Output is 0 if length counter is 0 or LFSR bit 0 is 1. Otherwise output is the envelope volume (0–15).
|
||||
- **DMC:** Output is `dmc_output_level` (0–127), already tracked.
|
||||
|
||||
#### 1.4 Save-State Compatibility
|
||||
|
||||
Adding new fields to `Apu` changes the save-state binary format. The `save_state_tail()` and `load_state_tail()` methods must be updated to serialize/deserialize the new fields. This is a **breaking change** to the save-state format — old save states will not be compatible. Since the project is pre-1.0, this is acceptable without a migration strategy.
|
||||
|
||||
#### 1.5 Bus Exposure
|
||||
|
||||
Add `NativeBus::apu_channel_outputs(&self) -> ChannelOutputs` to expose channel outputs alongside the existing `apu_registers()`.
|
||||
|
||||
#### 1.6 Mixer Update
|
||||
|
||||
Change `AudioMixer::push_cycles()` signature:
|
||||
|
||||
```rust
|
||||
// Before:
|
||||
pub fn push_cycles(&mut self, cpu_cycles: u8, apu_regs: &[u8; 0x20], out: &mut Vec<f32>)
|
||||
|
||||
// After:
|
||||
pub fn push_cycles(&mut self, cpu_cycles: u8, channels: ChannelOutputs, out: &mut Vec<f32>)
|
||||
```
|
||||
|
||||
Mixing formula (nesdev wiki linear approximation):
|
||||
|
||||
```
|
||||
pulse_out = 0.00752 * (pulse1 + pulse2)
|
||||
tnd_out = 0.00851 * triangle + 0.00494 * noise + 0.00335 * dmc
|
||||
output = pulse_out + tnd_out
|
||||
```
|
||||
|
||||
Output range is approximately [0.0, 1.0]. Normalize to [-1.0, 1.0] by: `sample = output * 2.0 - 1.0`.
|
||||
|
||||
**Known simplifications:**
|
||||
- This uses the linear approximation, not the more accurate nonlinear lookup tables from real NES hardware. Nonlinear mixing can be added later as an enhancement.
|
||||
- The current `repeat_n` resampling approach (nearest-neighbor) produces aliasing. A low-pass filter or bandlimited interpolation can be added later.
|
||||
- Real NES hardware applies two first-order high-pass filters (~90Hz and ~440Hz). Without these, channel enable/disable will cause audible pops. Deferred for a future iteration.
|
||||
|
||||
#### 1.7 Runtime Integration
|
||||
|
||||
Update `NesRuntime::run_until_frame_complete_with_audio()` in `src/runtime/core.rs` to pass `ChannelOutputs` (from `self.bus.apu_channel_outputs()`) instead of the register slice to the mixer.
|
||||
|
||||
## 2. Lock-Free Ring Buffer
|
||||
|
||||
### Location
|
||||
|
||||
New file: `src/runtime/ring_buffer.rs`.
|
||||
|
||||
### Design
|
||||
|
||||
SPSC (single-producer, single-consumer) ring buffer using `AtomicUsize` for head/tail indices:
|
||||
|
||||
- **Capacity:** 4096 f32 samples (~85ms at 48kHz) — enough to absorb frame timing jitter
|
||||
- **Producer:** emulation thread writes samples after each frame via `push_samples()`
|
||||
- **Consumer:** cpal audio callback reads samples via `pop_samples()`
|
||||
- **Underrun (buffer empty):** consumer outputs silence (0.0)
|
||||
- **Overrun (buffer full):** producer **drops new samples** (standard SPSC behavior — only the consumer moves the tail pointer)
|
||||
|
||||
```rust
|
||||
pub struct RingBuffer {
|
||||
buffer: Box<[f32]>,
|
||||
capacity: usize,
|
||||
head: AtomicUsize, // write position (producer only)
|
||||
tail: AtomicUsize, // read position (consumer only)
|
||||
}
|
||||
|
||||
impl RingBuffer {
|
||||
pub fn new(capacity: usize) -> Self;
|
||||
pub fn push(&self, samples: &[f32]) -> usize; // returns samples actually written
|
||||
pub fn pop(&self, out: &mut [f32]) -> usize; // returns samples actually read
|
||||
pub fn clear(&self); // reset both pointers (call when no concurrent access)
|
||||
}
|
||||
```
|
||||
|
||||
Thread safety: `RingBuffer` is `Send + Sync`. Shared via `Arc<RingBuffer>`.
|
||||
|
||||
## 3. Desktop cpal Audio Backend
|
||||
|
||||
### Dependencies
|
||||
|
||||
Add to `crates/nesemu-desktop/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
cpal = "0.15"
|
||||
```
|
||||
|
||||
### CpalAudioSink
|
||||
|
||||
```rust
|
||||
pub struct CpalAudioSink {
|
||||
_stream: cpal::Stream, // keeps the audio stream alive
|
||||
ring: Arc<RingBuffer>,
|
||||
volume: Arc<AtomicU32>, // f32 bits stored atomically
|
||||
}
|
||||
```
|
||||
|
||||
- Implements `nesemu::AudioOutput` — `push_samples()` writes to ring buffer
|
||||
- Created when a ROM is loaded; the ring buffer is cleared on ROM change to prevent stale samples
|
||||
- cpal callback: reads from ring buffer, multiplies each sample by volume, writes to output buffer
|
||||
- On pause: emulation stops producing samples → callback outputs silence (underrun behavior)
|
||||
- On ROM change: old stream is dropped, ring buffer cleared, new stream created
|
||||
|
||||
### Error Handling
|
||||
|
||||
If no audio device is available, or the requested format is unsupported, or the stream fails to build:
|
||||
- Log the error to stderr
|
||||
- Fall back to `NullAudio` behavior (discard samples silently)
|
||||
- The emulator continues to work without sound
|
||||
|
||||
The cpal error callback also logs errors to stderr without crashing.
|
||||
|
||||
### Stream Configuration
|
||||
|
||||
- Sample rate: 48,000 Hz
|
||||
- Channels: 1 (mono — NES is mono)
|
||||
- Sample format: f32
|
||||
- Buffer size: let cpal choose (typically 256–1024 frames)
|
||||
|
||||
### Volume
|
||||
|
||||
- `Arc<AtomicU32>` shared between UI and cpal callback
|
||||
- Stored as `f32::to_bits()` / `f32::from_bits()`
|
||||
- Default: 0.75 (75%)
|
||||
- Applied in cpal callback: `sample * volume`
|
||||
|
||||
## 4. UI — Volume Slider
|
||||
|
||||
### Widget
|
||||
|
||||
`gtk::Scale` (horizontal) added to the header bar:
|
||||
|
||||
- Range: 0.0 to 1.0 (displayed as 0–100%)
|
||||
- Default: 0.75
|
||||
- `connect_value_changed` → atomically update volume
|
||||
|
||||
### Placement
|
||||
|
||||
In the header bar, after the existing control buttons (open, pause, reset), with a small speaker icon label.
|
||||
|
||||
## 5. Threading Model
|
||||
|
||||
- **GTK main thread:** runs emulation via `glib::timeout_add_local` (~16ms tick), UI events, volume slider updates
|
||||
- **cpal OS thread:** audio callback reads from ring buffer — this is the only cross-thread boundary
|
||||
- The ring buffer (`Arc<RingBuffer>`) and volume (`Arc<AtomicU32>`) are the only shared state between threads
|
||||
|
||||
## 6. Data Flow
|
||||
|
||||
```
|
||||
CPU instruction step (GTK main thread)
|
||||
→ APU.clock_cpu_cycle() [updates internal channel state]
|
||||
→ AudioMixer.push_cycles(cycles, apu.channel_outputs())
|
||||
→ mix 5 channels → f32 sample
|
||||
→ append to frame audio buffer (Vec<f32>)
|
||||
|
||||
Per frame (GTK main thread):
|
||||
→ FrameExecutor collects audio_buffer
|
||||
→ CpalAudioSink.push_samples(audio_buffer)
|
||||
→ write to Arc<RingBuffer>
|
||||
|
||||
cpal callback (separate OS thread):
|
||||
→ read from Arc<RingBuffer>
|
||||
→ multiply by volume (Arc<AtomicU32>)
|
||||
→ write to hardware audio buffer
|
||||
```
|
||||
|
||||
## 7. Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/native_core/apu/types.rs` | Add `ChannelOutputs` struct, new timer/sequencer fields to `Apu` and `ApuStateTail` |
|
||||
| `src/native_core/apu/api.rs` | Add `channel_outputs()` method, update `save_state_tail`/`load_state_tail` |
|
||||
| `src/native_core/apu/timing.rs` | Clock new timer/sequencer fields in `clock_cpu_cycle()` |
|
||||
| `src/native_core/bus.rs` | Add `apu_channel_outputs()` |
|
||||
| `src/runtime/audio.rs` | Rewrite mixer with 5-channel formula |
|
||||
| `src/runtime/ring_buffer.rs` (new) | Lock-free SPSC ring buffer |
|
||||
| `src/runtime/core.rs` | Pass `channel_outputs()` to mixer in `run_until_frame_complete_with_audio()` |
|
||||
| `src/runtime/mod.rs` | Export `ring_buffer`, `ChannelOutputs` |
|
||||
| `crates/nesemu-desktop/Cargo.toml` | Add `cpal` dependency |
|
||||
| `crates/nesemu-desktop/src/main.rs` | Replace stub AudioSink with CpalAudioSink, add volume slider |
|
||||
|
||||
## 8. Testing
|
||||
|
||||
- Existing tests in `tests/public_api.rs` must continue to pass (they use NullAudio). **Note:** the regression hash test (`public_api_regression_hashes_for_reference_rom`) will produce a different audio hash due to the mixer change — the expected hash must be updated.
|
||||
- Unit test for ring buffer: push/pop, underrun, overrun, clear
|
||||
- Unit test for mixer: known channel outputs → expected sample values
|
||||
- Manual test: load a ROM, verify audible sound through speakers
|
||||
@@ -16,7 +16,7 @@ pub use nesemu_adapter_api as adapter_api;
|
||||
#[cfg(feature = "adapter-headless")]
|
||||
pub use nesemu_adapter_headless as adapter_headless;
|
||||
|
||||
pub use native_core::apu::{Apu, ApuStateTail};
|
||||
pub use native_core::apu::{Apu, ApuStateTail, ChannelOutputs};
|
||||
pub use native_core::bus::NativeBus;
|
||||
pub use native_core::cpu::{Cpu6502, CpuBus, CpuError};
|
||||
pub use native_core::ines::{InesHeader, InesRom, Mirroring, parse_header, parse_rom};
|
||||
@@ -28,7 +28,7 @@ pub use runtime::{
|
||||
AudioMixer, AudioOutput, ClientRuntime, EmulationState, FRAME_HEIGHT, FRAME_RGBA_BYTES,
|
||||
FRAME_WIDTH, FrameClock, FramePacer, HostConfig, InputProvider, JOYPAD_BUTTON_ORDER,
|
||||
JOYPAD_BUTTONS_COUNT, JoypadButton, JoypadButtons, NesRuntime, NoopClock, NullAudio, NullInput,
|
||||
NullVideo, PacingClock, RuntimeError, RuntimeHostLoop, SAVE_STATE_VERSION, VideoMode,
|
||||
NullVideo, PacingClock, RingBuffer, RuntimeError, RuntimeHostLoop, SAVE_STATE_VERSION, VideoMode,
|
||||
VideoOutput, button_pressed, set_button_pressed,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::types::{Apu, ApuStateTail};
|
||||
use super::types::{Apu, ApuStateTail, ChannelOutputs};
|
||||
|
||||
impl Apu {
|
||||
pub fn new() -> Self {
|
||||
@@ -34,6 +34,12 @@ impl Apu {
|
||||
frame_reset_delay: 0,
|
||||
pending_frame_mode_5step: false,
|
||||
pending_frame_irq_inhibit: false,
|
||||
pulse_timer_counter: [0; 2],
|
||||
pulse_duty_step: [0; 2],
|
||||
triangle_timer_counter: 0,
|
||||
triangle_step: 0,
|
||||
noise_timer_counter: 0,
|
||||
noise_lfsr: 1, // LFSR initialized to 1 per NES hardware
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +67,8 @@ impl Apu {
|
||||
0x4003 => {
|
||||
self.reload_length_counter(0, value >> 3);
|
||||
self.envelope_start_flags |= 1 << 0;
|
||||
self.pulse_duty_step[0] = 0;
|
||||
self.pulse_timer_counter[0] = self.pulse_timer_period(0x02);
|
||||
}
|
||||
0x4001 => {
|
||||
self.sweep_reload_flags |= 1 << 0;
|
||||
@@ -68,6 +76,8 @@ impl Apu {
|
||||
0x4007 => {
|
||||
self.reload_length_counter(1, value >> 3);
|
||||
self.envelope_start_flags |= 1 << 1;
|
||||
self.pulse_duty_step[1] = 0;
|
||||
self.pulse_timer_counter[1] = self.pulse_timer_period(0x06);
|
||||
}
|
||||
0x4005 => {
|
||||
self.sweep_reload_flags |= 1 << 1;
|
||||
@@ -154,6 +164,9 @@ impl Apu {
|
||||
self.clock_frame_counter();
|
||||
}
|
||||
self.clock_dmc();
|
||||
self.clock_pulse_timers();
|
||||
self.clock_triangle_timer();
|
||||
self.clock_noise_timer();
|
||||
self.cpu_cycle_parity = !self.cpu_cycle_parity;
|
||||
}
|
||||
|
||||
@@ -227,6 +240,13 @@ impl Apu {
|
||||
out.push(self.frame_reset_delay);
|
||||
out.push(u8::from(self.pending_frame_mode_5step));
|
||||
out.push(u8::from(self.pending_frame_irq_inhibit));
|
||||
out.extend_from_slice(&self.pulse_timer_counter[0].to_le_bytes());
|
||||
out.extend_from_slice(&self.pulse_timer_counter[1].to_le_bytes());
|
||||
out.extend_from_slice(&self.pulse_duty_step);
|
||||
out.extend_from_slice(&self.triangle_timer_counter.to_le_bytes());
|
||||
out.push(self.triangle_step);
|
||||
out.extend_from_slice(&self.noise_timer_counter.to_le_bytes());
|
||||
out.extend_from_slice(&self.noise_lfsr.to_le_bytes());
|
||||
}
|
||||
|
||||
pub fn load_state_tail(&mut self, state: ApuStateTail) {
|
||||
@@ -260,5 +280,81 @@ impl Apu {
|
||||
self.frame_reset_delay = state.frame_reset_delay;
|
||||
self.pending_frame_mode_5step = state.pending_frame_mode_5step;
|
||||
self.pending_frame_irq_inhibit = state.pending_frame_irq_inhibit;
|
||||
self.pulse_timer_counter = state.pulse_timer_counter;
|
||||
self.pulse_duty_step = [state.pulse_duty_step[0] & 0x07, state.pulse_duty_step[1] & 0x07];
|
||||
self.triangle_timer_counter = state.triangle_timer_counter;
|
||||
self.triangle_step = state.triangle_step & 0x1F;
|
||||
self.noise_timer_counter = state.noise_timer_counter;
|
||||
self.noise_lfsr = if state.noise_lfsr == 0 { 1 } else { state.noise_lfsr };
|
||||
}
|
||||
|
||||
pub fn channel_outputs(&self) -> ChannelOutputs {
|
||||
const PULSE_DUTY_TABLE: [[u8; 8]; 4] = [
|
||||
[0, 1, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 1, 0, 0, 0],
|
||||
[1, 0, 0, 1, 1, 1, 1, 1],
|
||||
];
|
||||
const TRIANGLE_SEQUENCE: [u8; 32] = [
|
||||
15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0,
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
|
||||
];
|
||||
|
||||
let pulse1 = {
|
||||
let duty = (self.io[0x00] >> 6) as usize;
|
||||
let step = self.pulse_duty_step[0] as usize;
|
||||
let volume = if (self.io[0x00] & 0x10) != 0 {
|
||||
self.io[0x00] & 0x0F
|
||||
} else {
|
||||
self.envelope_decay[0]
|
||||
};
|
||||
let active = (self.channel_enable_mask & 0x01) != 0
|
||||
&& self.length_counters[0] > 0
|
||||
&& PULSE_DUTY_TABLE[duty][step] != 0
|
||||
&& !self.sweep_mutes_channel(0, 0x02);
|
||||
if active { volume } else { 0 }
|
||||
};
|
||||
|
||||
let pulse2 = {
|
||||
let duty = (self.io[0x04] >> 6) as usize;
|
||||
let step = self.pulse_duty_step[1] as usize;
|
||||
let volume = if (self.io[0x04] & 0x10) != 0 {
|
||||
self.io[0x04] & 0x0F
|
||||
} else {
|
||||
self.envelope_decay[1]
|
||||
};
|
||||
let active = (self.channel_enable_mask & 0x02) != 0
|
||||
&& self.length_counters[1] > 0
|
||||
&& PULSE_DUTY_TABLE[duty][step] != 0
|
||||
&& !self.sweep_mutes_channel(1, 0x06);
|
||||
if active { volume } else { 0 }
|
||||
};
|
||||
|
||||
let triangle = {
|
||||
let active = (self.channel_enable_mask & 0x04) != 0
|
||||
&& self.length_counters[2] > 0
|
||||
&& self.triangle_linear_counter > 0;
|
||||
if active {
|
||||
TRIANGLE_SEQUENCE[self.triangle_step as usize & 0x1F]
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
let noise = {
|
||||
let volume = if (self.io[0x0C] & 0x10) != 0 {
|
||||
self.io[0x0C] & 0x0F
|
||||
} else {
|
||||
self.envelope_decay[2]
|
||||
};
|
||||
let active = (self.channel_enable_mask & 0x08) != 0
|
||||
&& self.length_counters[3] > 0
|
||||
&& (self.noise_lfsr & 1) == 0;
|
||||
if active { volume } else { 0 }
|
||||
};
|
||||
|
||||
let dmc = self.dmc_output_level;
|
||||
|
||||
ChannelOutputs { pulse1, pulse2, triangle, noise, dmc }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ mod api;
|
||||
mod timing;
|
||||
mod types;
|
||||
|
||||
pub use types::{Apu, ApuStateTail};
|
||||
pub use types::{Apu, ApuStateTail, ChannelOutputs};
|
||||
|
||||
@@ -215,6 +215,62 @@ impl Apu {
|
||||
let hi = (self.io[timer_lo_idx + 1] as u16 & 0x07) << 8;
|
||||
hi | lo
|
||||
}
|
||||
pub(crate) fn triangle_timer_period(&self) -> u16 {
|
||||
let lo = self.io[0x0A] as u16;
|
||||
let hi = (self.io[0x0B] as u16 & 0x07) << 8;
|
||||
hi | lo
|
||||
}
|
||||
|
||||
pub(crate) fn noise_timer_period(&self) -> u16 {
|
||||
const NOISE_PERIOD_TABLE: [u16; 16] = [
|
||||
4, 8, 16, 32, 64, 96, 128, 160, 202, 254, 380, 508, 762, 1016, 2034, 4068,
|
||||
];
|
||||
let idx = (self.io[0x0E] & 0x0F) as usize;
|
||||
NOISE_PERIOD_TABLE[idx]
|
||||
}
|
||||
|
||||
pub(crate) fn clock_pulse_timers(&mut self) {
|
||||
if self.cpu_cycle_parity {
|
||||
return;
|
||||
}
|
||||
for ch in 0..2usize {
|
||||
if self.pulse_timer_counter[ch] == 0 {
|
||||
let reg_offset = ch * 4;
|
||||
let period = self.pulse_timer_period(reg_offset + 2);
|
||||
self.pulse_timer_counter[ch] = period;
|
||||
self.pulse_duty_step[ch] = (self.pulse_duty_step[ch] + 1) & 0x07;
|
||||
} else {
|
||||
self.pulse_timer_counter[ch] -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clock_triangle_timer(&mut self) {
|
||||
if self.triangle_timer_counter == 0 {
|
||||
self.triangle_timer_counter = self.triangle_timer_period();
|
||||
if self.length_counters[2] > 0 && self.triangle_linear_counter > 0 {
|
||||
self.triangle_step = (self.triangle_step + 1) & 0x1F;
|
||||
}
|
||||
} else {
|
||||
self.triangle_timer_counter -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clock_noise_timer(&mut self) {
|
||||
if self.cpu_cycle_parity {
|
||||
return;
|
||||
}
|
||||
if self.noise_timer_counter == 0 {
|
||||
self.noise_timer_counter = self.noise_timer_period();
|
||||
let mode_flag = (self.io[0x0E] & 0x80) != 0;
|
||||
let feedback_bit = if mode_flag { 6 } else { 1 };
|
||||
let feedback = (self.noise_lfsr & 1) ^ ((self.noise_lfsr >> feedback_bit) & 1);
|
||||
self.noise_lfsr = (self.noise_lfsr >> 1) | (feedback << 14);
|
||||
} else {
|
||||
self.noise_timer_counter -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_pulse_timer_period(&mut self, channel: usize, period: u16) {
|
||||
let (timer_lo_idx, timer_hi_idx) = if channel == 0 {
|
||||
(0x02, 0x03)
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ChannelOutputs {
|
||||
pub pulse1: u8,
|
||||
pub pulse2: u8,
|
||||
pub triangle: u8,
|
||||
pub noise: u8,
|
||||
pub dmc: u8,
|
||||
}
|
||||
|
||||
pub(super) const APU_FRAME_SEQ_4_STEP_CYCLES: u32 = 14_915;
|
||||
pub(super) const APU_FRAME_SEQ_5_STEP_CYCLES: u32 = 18_641;
|
||||
pub(super) const APU_QUARTER_FRAME_1: u32 = 3_729;
|
||||
@@ -48,6 +57,15 @@ pub struct Apu {
|
||||
pub(crate) frame_reset_delay: u8,
|
||||
pub(crate) pending_frame_mode_5step: bool,
|
||||
pub(crate) pending_frame_irq_inhibit: bool,
|
||||
// Pulse channel timers & duty sequencers
|
||||
pub(crate) pulse_timer_counter: [u16; 2],
|
||||
pub(crate) pulse_duty_step: [u8; 2],
|
||||
// Triangle channel timer & sequencer
|
||||
pub(crate) triangle_timer_counter: u16,
|
||||
pub(crate) triangle_step: u8,
|
||||
// Noise channel timer & LFSR
|
||||
pub(crate) noise_timer_counter: u16,
|
||||
pub(crate) noise_lfsr: u16,
|
||||
}
|
||||
|
||||
pub struct ApuStateTail {
|
||||
@@ -81,6 +99,12 @@ pub struct ApuStateTail {
|
||||
pub frame_reset_delay: u8,
|
||||
pub pending_frame_mode_5step: bool,
|
||||
pub pending_frame_irq_inhibit: bool,
|
||||
pub pulse_timer_counter: [u16; 2],
|
||||
pub pulse_duty_step: [u8; 2],
|
||||
pub triangle_timer_counter: u16,
|
||||
pub triangle_step: u8,
|
||||
pub noise_timer_counter: u16,
|
||||
pub noise_lfsr: u16,
|
||||
}
|
||||
|
||||
impl Default for Apu {
|
||||
|
||||
@@ -23,6 +23,7 @@ pub struct NativeBus {
|
||||
odd_frame: bool,
|
||||
in_vblank: bool,
|
||||
frame_complete: bool,
|
||||
cpu_cycles_since_poll: u32,
|
||||
mmc3_a12_prev_high: bool,
|
||||
mmc3_a12_low_dots: u16,
|
||||
mmc3_last_irq_scanline: u32,
|
||||
@@ -47,6 +48,7 @@ impl NativeBus {
|
||||
odd_frame: false,
|
||||
in_vblank: false,
|
||||
frame_complete: false,
|
||||
cpu_cycles_since_poll: 0,
|
||||
mmc3_a12_prev_high: false,
|
||||
mmc3_a12_low_dots: 8,
|
||||
mmc3_last_irq_scanline: u32::MAX,
|
||||
@@ -58,6 +60,10 @@ impl NativeBus {
|
||||
self.apu.registers()
|
||||
}
|
||||
|
||||
pub fn apu_channel_outputs(&self) -> crate::native_core::apu::ChannelOutputs {
|
||||
self.apu.channel_outputs()
|
||||
}
|
||||
|
||||
pub fn render_frame(&self, out_rgba: &mut [u8], frame_number: u32, buttons: [bool; 8]) {
|
||||
let _ = (frame_number, buttons);
|
||||
let src = self.ppu.frame_buffer();
|
||||
@@ -80,6 +86,12 @@ impl NativeBus {
|
||||
pub fn clock_cpu(&mut self, cycles: u8) {
|
||||
self.clock_cpu_cycles(cycles as u32);
|
||||
}
|
||||
|
||||
pub fn take_cpu_cycles_since_poll(&mut self) -> u32 {
|
||||
let cycles = self.cpu_cycles_since_poll;
|
||||
self.cpu_cycles_since_poll = 0;
|
||||
cycles
|
||||
}
|
||||
}
|
||||
|
||||
// CpuBus trait implementation (memory map + side effects).
|
||||
|
||||
@@ -112,6 +112,31 @@ impl NativeBus {
|
||||
let frame_reset_delay = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
|
||||
let pending_frame_mode_5step = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
|
||||
let pending_frame_irq_inhibit = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
|
||||
let pulse_timer_counter = [
|
||||
u16::from_le_bytes([
|
||||
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
|
||||
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
|
||||
]),
|
||||
u16::from_le_bytes([
|
||||
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
|
||||
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
|
||||
]),
|
||||
];
|
||||
let mut pulse_duty_step = [0u8; 2];
|
||||
pulse_duty_step.copy_from_slice(sio::take_exact(data, &mut cursor, 2, BUS_STATE_CTX)?);
|
||||
let triangle_timer_counter = u16::from_le_bytes([
|
||||
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
|
||||
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
|
||||
]);
|
||||
let triangle_step = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
|
||||
let noise_timer_counter = u16::from_le_bytes([
|
||||
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
|
||||
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
|
||||
]);
|
||||
let noise_lfsr = u16::from_le_bytes([
|
||||
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
|
||||
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
|
||||
]);
|
||||
self.apu.load_state_tail(ApuStateTail {
|
||||
frame_cycle,
|
||||
frame_mode_5step,
|
||||
@@ -143,6 +168,12 @@ impl NativeBus {
|
||||
frame_reset_delay,
|
||||
pending_frame_mode_5step,
|
||||
pending_frame_irq_inhibit,
|
||||
pulse_timer_counter,
|
||||
pulse_duty_step,
|
||||
triangle_timer_counter,
|
||||
triangle_step,
|
||||
noise_timer_counter,
|
||||
noise_lfsr,
|
||||
});
|
||||
let mapper_len = sio::take_u32(data, &mut cursor, BUS_STATE_CTX)? as usize;
|
||||
let mapper_state = sio::take_exact(data, &mut cursor, mapper_len, BUS_STATE_CTX)?;
|
||||
|
||||
@@ -312,3 +312,23 @@ fn dmc_playback_updates_output_level_from_sample_bits() {
|
||||
|
||||
assert!(bus.apu.dmc_output_level < initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulse_channel_outputs_become_audible_after_setup() {
|
||||
let mut bus = NativeBus::new(Box::new(StubMapper));
|
||||
bus.write(0x4015, 0x01); // enable pulse1
|
||||
bus.write(0x4000, 0b0101_1111); // 25% duty, constant volume=15
|
||||
bus.write(0x4002, 0x08); // low timer period, not sweep-muted
|
||||
bus.write(0x4003, 0x00); // reload length + reset duty sequencer
|
||||
|
||||
let mut saw_non_zero = false;
|
||||
for _ in 0..64u32 {
|
||||
bus.clock_cpu(1);
|
||||
if bus.apu_channel_outputs().pulse1 > 0 {
|
||||
saw_non_zero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_non_zero, "pulse1 never produced audible output");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::native_core::mapper::Mapper;
|
||||
|
||||
impl NativeBus {
|
||||
fn clock_one_cpu_cycle(&mut self) {
|
||||
self.cpu_cycles_since_poll = self.cpu_cycles_since_poll.saturating_add(1);
|
||||
for _ in 0..3 {
|
||||
self.clock_ppu_dot();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::native_core::apu::ChannelOutputs;
|
||||
use crate::runtime::VideoMode;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -5,6 +6,7 @@ pub struct AudioMixer {
|
||||
sample_rate: u32,
|
||||
samples_per_cpu_cycle: f64,
|
||||
sample_accumulator: f64,
|
||||
last_output_sample: f32,
|
||||
}
|
||||
|
||||
impl AudioMixer {
|
||||
@@ -14,6 +16,7 @@ impl AudioMixer {
|
||||
sample_rate,
|
||||
samples_per_cpu_cycle: sample_rate as f64 / cpu_hz,
|
||||
sample_accumulator: 0.0,
|
||||
last_output_sample: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,17 +26,99 @@ impl AudioMixer {
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.sample_accumulator = 0.0;
|
||||
self.last_output_sample = 0.0;
|
||||
}
|
||||
|
||||
pub fn push_cycles(&mut self, cpu_cycles: u8, apu_regs: &[u8; 0x20], out: &mut Vec<f32>) {
|
||||
self.sample_accumulator += self.samples_per_cpu_cycle * f64::from(cpu_cycles);
|
||||
pub fn push_cycles(&mut self, cpu_cycles: u32, channels: ChannelOutputs, out: &mut Vec<f32>) {
|
||||
self.sample_accumulator += self.samples_per_cpu_cycle * cpu_cycles as f64;
|
||||
let samples = self.sample_accumulator.floor() as usize;
|
||||
self.sample_accumulator -= samples as f64;
|
||||
|
||||
// Current core does not expose a final mixed PCM stream yet.
|
||||
// Use DMC output level as a stable interim signal in [-1.0, 1.0].
|
||||
let dmc = apu_regs[0x11] & 0x7F;
|
||||
let sample = (f32::from(dmc) / 63.5) - 1.0;
|
||||
out.extend(std::iter::repeat_n(sample, samples));
|
||||
let pulse_out = 0.00752 * (f32::from(channels.pulse1) + f32::from(channels.pulse2));
|
||||
let tnd_out = 0.00851 * f32::from(channels.triangle)
|
||||
+ 0.00494 * f32::from(channels.noise)
|
||||
+ 0.00335 * f32::from(channels.dmc);
|
||||
let sample = pulse_out + tnd_out;
|
||||
|
||||
if samples == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let start = self.last_output_sample;
|
||||
if samples == 1 {
|
||||
out.push(sample);
|
||||
} else {
|
||||
let denom = samples as f32;
|
||||
for idx in 0..samples {
|
||||
let t = (idx + 1) as f32 / denom;
|
||||
out.push(start + (sample - start) * t);
|
||||
}
|
||||
}
|
||||
self.last_output_sample = sample;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mixer_silent_channels_produce_zero() {
|
||||
let mut mixer = AudioMixer::new(44_100, VideoMode::Ntsc);
|
||||
let channels = ChannelOutputs::default();
|
||||
let mut out = Vec::new();
|
||||
mixer.push_cycles(50, channels, &mut out);
|
||||
assert!(!out.is_empty());
|
||||
for &s in &out {
|
||||
assert!(s.abs() < 1e-6, "expected 0.0, got {s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixer_max_channels_produce_positive() {
|
||||
let mut mixer = AudioMixer::new(44_100, VideoMode::Ntsc);
|
||||
let channels = ChannelOutputs {
|
||||
pulse1: 15,
|
||||
pulse2: 15,
|
||||
triangle: 15,
|
||||
noise: 15,
|
||||
dmc: 127,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
mixer.push_cycles(50, channels, &mut out);
|
||||
assert!(!out.is_empty());
|
||||
for &s in &out {
|
||||
assert!(s > 0.0, "expected positive sample, got {s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixer_smooths_transition_between_batches() {
|
||||
let mut mixer = AudioMixer::new(44_100, VideoMode::Ntsc);
|
||||
let mut out = Vec::new();
|
||||
|
||||
mixer.push_cycles(200, ChannelOutputs::default(), &mut out);
|
||||
let before = out.len();
|
||||
|
||||
mixer.push_cycles(
|
||||
200,
|
||||
ChannelOutputs {
|
||||
pulse1: 15,
|
||||
pulse2: 15,
|
||||
triangle: 15,
|
||||
noise: 15,
|
||||
dmc: 127,
|
||||
},
|
||||
&mut out,
|
||||
);
|
||||
|
||||
let transition = &out[before..];
|
||||
assert!(transition.len() > 1);
|
||||
assert!(transition[0] < *transition.last().expect("transition sample"));
|
||||
assert!(
|
||||
transition[0] > 0.0,
|
||||
"expected smoothed ramp start, got {}",
|
||||
transition[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pub const FRAME_WIDTH: usize = 256;
|
||||
pub const FRAME_HEIGHT: usize = 240;
|
||||
pub const FRAME_RGBA_BYTES: usize = FRAME_WIDTH * FRAME_HEIGHT * 4;
|
||||
pub const SAVE_STATE_VERSION: u32 = 1;
|
||||
pub const SAVE_STATE_VERSION: u32 = 2;
|
||||
|
||||
pub(crate) const SAVE_STATE_MAGIC: &[u8; 8] = b"NESRT001";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::runtime::state::{load_runtime_state, save_runtime_state};
|
||||
use crate::runtime::{
|
||||
AudioMixer, FRAME_RGBA_BYTES, FramePacer, JoypadButtons, RuntimeError, VideoMode,
|
||||
AudioMixer, FramePacer, JoypadButtons, RuntimeError, VideoMode, FRAME_RGBA_BYTES,
|
||||
};
|
||||
use crate::{Cpu6502, InesRom, NativeBus, create_mapper, parse_rom};
|
||||
use crate::{create_mapper, parse_rom, Cpu6502, InesRom, NativeBus};
|
||||
|
||||
pub struct NesRuntime {
|
||||
cpu: Cpu6502,
|
||||
@@ -79,11 +79,11 @@ impl NesRuntime {
|
||||
self.bus.set_joypad_buttons(buttons);
|
||||
}
|
||||
|
||||
pub fn step_instruction(&mut self) -> Result<u8, RuntimeError> {
|
||||
pub fn step_instruction(&mut self) -> Result<u32, RuntimeError> {
|
||||
self.bus.set_joypad_buttons(self.buttons);
|
||||
let cycles = self.cpu.step(&mut self.bus).map_err(RuntimeError::Cpu)?;
|
||||
self.bus.clock_cpu(cycles);
|
||||
Ok(cycles)
|
||||
let cpu_cycles = self.cpu.step(&mut self.bus).map_err(RuntimeError::Cpu)?;
|
||||
self.bus.clock_cpu(cpu_cycles);
|
||||
Ok(self.bus.take_cpu_cycles_since_poll())
|
||||
}
|
||||
|
||||
pub fn run_until_frame_complete(&mut self) -> Result<(), RuntimeError> {
|
||||
@@ -109,7 +109,7 @@ impl NesRuntime {
|
||||
self.bus.begin_frame();
|
||||
while !self.bus.take_frame_complete() {
|
||||
let cycles = self.step_instruction()?;
|
||||
mixer.push_cycles(cycles, self.bus.apu_registers(), out_samples);
|
||||
mixer.push_cycles(cycles, self.bus.apu_channel_outputs(), out_samples);
|
||||
}
|
||||
self.frame_number = self.frame_number.saturating_add(1);
|
||||
Ok(())
|
||||
|
||||
@@ -5,6 +5,7 @@ mod constants;
|
||||
mod core;
|
||||
mod error;
|
||||
mod host;
|
||||
pub mod ring_buffer;
|
||||
mod state;
|
||||
mod timing;
|
||||
mod types;
|
||||
@@ -12,6 +13,7 @@ mod types;
|
||||
#[cfg(feature = "adapter-api")]
|
||||
pub use adapters::{AudioAdapter, ClockAdapter, InputAdapter, VideoAdapter};
|
||||
pub use audio::AudioMixer;
|
||||
pub use ring_buffer::RingBuffer;
|
||||
pub use constants::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, SAVE_STATE_VERSION};
|
||||
pub use core::NesRuntime;
|
||||
pub use error::RuntimeError;
|
||||
|
||||
124
src/runtime/ring_buffer.rs
Normal file
124
src/runtime/ring_buffer.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
||||
|
||||
pub struct RingBuffer {
|
||||
buffer: Box<[AtomicU32]>,
|
||||
capacity: usize,
|
||||
head: AtomicUsize,
|
||||
tail: AtomicUsize,
|
||||
}
|
||||
|
||||
impl RingBuffer {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
assert!(capacity > 0);
|
||||
let buffer: Vec<AtomicU32> = (0..capacity).map(|_| AtomicU32::new(0)).collect();
|
||||
Self {
|
||||
buffer: buffer.into_boxed_slice(),
|
||||
capacity,
|
||||
head: AtomicUsize::new(0),
|
||||
tail: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&self, samples: &[f32]) -> usize {
|
||||
let head = self.head.load(Ordering::Relaxed);
|
||||
let tail = self.tail.load(Ordering::Acquire);
|
||||
let available = self.capacity - self.len_internal(head, tail) - 1;
|
||||
let to_write = samples.len().min(available);
|
||||
|
||||
for (i, sample) in samples.iter().enumerate().take(to_write) {
|
||||
let idx = (head + i) % self.capacity;
|
||||
self.buffer[idx].store(sample.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
self.head
|
||||
.store((head + to_write) % self.capacity, Ordering::Release);
|
||||
to_write
|
||||
}
|
||||
|
||||
pub fn pop(&self, out: &mut [f32]) -> usize {
|
||||
let tail = self.tail.load(Ordering::Relaxed);
|
||||
let head = self.head.load(Ordering::Acquire);
|
||||
let available = self.len_internal(head, tail);
|
||||
let to_read = out.len().min(available);
|
||||
|
||||
for (i, slot) in out.iter_mut().enumerate().take(to_read) {
|
||||
let idx = (tail + i) % self.capacity;
|
||||
*slot = f32::from_bits(self.buffer[idx].load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
self.tail
|
||||
.store((tail + to_read) % self.capacity, Ordering::Release);
|
||||
to_read
|
||||
}
|
||||
|
||||
/// Clear the buffer. Must only be called when no concurrent push/pop
|
||||
/// is in progress (e.g., when the audio stream is paused or dropped).
|
||||
pub fn clear(&self) {
|
||||
self.tail.store(0, Ordering::SeqCst);
|
||||
self.head.store(0, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn len_internal(&self, head: usize, tail: usize) -> usize {
|
||||
if head >= tail {
|
||||
head - tail
|
||||
} else {
|
||||
self.capacity - tail + head
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_pop_basic() {
|
||||
let rb = RingBuffer::new(8);
|
||||
let input = [1.0, 2.0, 3.0];
|
||||
assert_eq!(rb.push(&input), 3);
|
||||
let mut out = [0.0; 3];
|
||||
assert_eq!(rb.pop(&mut out), 3);
|
||||
assert_eq!(out, [1.0, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underrun_returns_zero_count() {
|
||||
let rb = RingBuffer::new(8);
|
||||
let mut out = [0.0; 4];
|
||||
assert_eq!(rb.pop(&mut out), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrun_drops_new_samples() {
|
||||
let rb = RingBuffer::new(4); // usable capacity = 3
|
||||
let input = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let written = rb.push(&input);
|
||||
assert_eq!(written, 3);
|
||||
let mut out = [0.0; 3];
|
||||
rb.pop(&mut out);
|
||||
assert_eq!(out, [1.0, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_resets() {
|
||||
let rb = RingBuffer::new(8);
|
||||
rb.push(&[1.0, 2.0]);
|
||||
rb.clear();
|
||||
let mut out = [0.0; 2];
|
||||
assert_eq!(rb.pop(&mut out), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraparound() {
|
||||
let rb = RingBuffer::new(4); // usable = 3
|
||||
rb.push(&[1.0, 2.0, 3.0]);
|
||||
let mut out = [0.0; 2];
|
||||
rb.pop(&mut out);
|
||||
assert_eq!(out, [1.0, 2.0]);
|
||||
rb.push(&[4.0, 5.0]);
|
||||
let mut out2 = [0.0; 3];
|
||||
let read = rb.pop(&mut out2);
|
||||
assert_eq!(read, 3);
|
||||
assert_eq!(out2, [3.0, 4.0, 5.0]);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
use crate::runtime::{
|
||||
AudioOutput, ClientRuntime, EmulationState, FRAME_RGBA_BYTES, HostConfig, InputProvider,
|
||||
JOYPAD_BUTTON_ORDER, JOYPAD_BUTTONS_COUNT, JoypadButton, NesRuntime, NoopClock,
|
||||
RuntimeHostLoop, VideoMode, VideoOutput, button_pressed, set_button_pressed,
|
||||
button_pressed, set_button_pressed, AudioOutput, ClientRuntime, EmulationState, HostConfig,
|
||||
InputProvider, JoypadButton, NesRuntime, NoopClock, NullAudio, NullInput, NullVideo,
|
||||
RuntimeHostLoop, VideoMode, VideoOutput, FRAME_RGBA_BYTES, JOYPAD_BUTTONS_COUNT,
|
||||
JOYPAD_BUTTON_ORDER,
|
||||
};
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
fn nrom_test_rom() -> Vec<u8> {
|
||||
nrom_test_rom_with_program(&[0xEA, 0x4C, 0x00, 0x80])
|
||||
}
|
||||
|
||||
fn nrom_test_rom_with_program(program: &[u8]) -> Vec<u8> {
|
||||
let mut rom = vec![0u8; 16 + 16 * 1024 + 8 * 1024];
|
||||
rom[0..4].copy_from_slice(b"NES\x1A");
|
||||
rom[4] = 1; // 16 KiB PRG
|
||||
@@ -17,11 +22,7 @@ fn nrom_test_rom() -> Vec<u8> {
|
||||
rom[reset_vec] = 0x00;
|
||||
rom[reset_vec + 1] = 0x80;
|
||||
|
||||
// 0x8000: NOP; JMP $8000
|
||||
rom[prg_offset] = 0xEA;
|
||||
rom[prg_offset + 1] = 0x4C;
|
||||
rom[prg_offset + 2] = 0x00;
|
||||
rom[prg_offset + 3] = 0x80;
|
||||
rom[prg_offset..prg_offset + program.len()].copy_from_slice(program);
|
||||
rom
|
||||
}
|
||||
|
||||
@@ -83,6 +84,30 @@ fn audio_mixer_generates_samples() {
|
||||
assert_eq!(mixer.sample_rate(), 48_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_mixer_accounts_for_oam_dma_stall_cycles() {
|
||||
let rom = nrom_test_rom_with_program(&[
|
||||
0xA9, 0x00, // LDA #$00
|
||||
0x8D, 0x14, 0x40, // STA $4014
|
||||
0x4C, 0x00, 0x80, // JMP $8000
|
||||
]);
|
||||
let runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
|
||||
let mode = runtime.video_mode();
|
||||
let mut host = RuntimeHostLoop::with_config(runtime, HostConfig::new(48_000, false));
|
||||
|
||||
let total_samples = host
|
||||
.run_frames_unpaced(120, &mut NullInput, &mut NullVideo, &mut NullAudio)
|
||||
.expect("run frames");
|
||||
|
||||
let expected = ((host.runtime().frame_number() as f64) * 48_000.0 / mode.frame_hz()).round();
|
||||
let drift_pct = ((total_samples as f64 - expected).abs() / expected) * 100.0;
|
||||
|
||||
assert!(
|
||||
drift_pct <= 2.5,
|
||||
"audio drift too high with OAM DMA: {drift_pct:.3}% (samples={total_samples}, expected={expected:.0})"
|
||||
);
|
||||
}
|
||||
|
||||
struct FixedInput;
|
||||
|
||||
impl InputProvider for FixedInput {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use nesemu::prelude::*;
|
||||
use nesemu::{
|
||||
AudioOutput, HostConfig, InputProvider, JOYPAD_BUTTONS_COUNT, NullAudio, NullInput, NullVideo,
|
||||
RuntimeError, VideoOutput,
|
||||
AudioOutput, HostConfig, InputProvider, NullAudio, NullInput, NullVideo, RuntimeError,
|
||||
VideoOutput, JOYPAD_BUTTONS_COUNT,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -212,7 +212,7 @@ fn public_api_regression_hashes_for_reference_rom() {
|
||||
.expect("run frames");
|
||||
|
||||
let expected_frame_hash = 0x42d1_20e3_54e0_a325_u64;
|
||||
let expected_audio_hash = 0xa075_8dd6_adea_e775_u64;
|
||||
let expected_audio_hash = 0x19f5_be12_66f3_37c5_u64;
|
||||
|
||||
assert_eq!(
|
||||
video.last_hash, expected_frame_hash,
|
||||
|
||||
Reference in New Issue
Block a user