Compare commits

..

9 Commits

Author SHA1 Message Date
18cec7bac7 fix: stabilize desktop audio playback
Some checks failed
CI / rust (push) Has been cancelled
2026-03-13 18:58:07 +03:00
9068e78a62 docs: add audio output design spec and implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:21:30 +03:00
f9b2b05f3f feat(desktop): replace audio stub with cpal backend and volume slider
- CpalAudioSink writes to SPSC ring buffer, cpal callback reads
- Graceful fallback to silent operation on audio device errors
- Volume slider (0-100%) in header bar with speaker icon
- Ring buffer cleared on ROM load and reset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:21:25 +03:00
c97bad5551 feat: add lock-free SPSC ring buffer for audio streaming
AtomicU32-based storage avoids unsafe while maintaining SPSC guarantees.
Capacity: 4096 samples (~85ms at 48kHz). Exported from crate root.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:21:19 +03:00
5895344f6f feat(mixer): 5-channel APU mixing with linear approximation formula 2026-03-13 16:09:50 +03:00
e63b5783bd feat(apu): add ChannelOutputs struct and channel_outputs() method 2026-03-13 16:08:55 +03:00
49568a582b feat(apu): clock pulse/triangle/noise timers and sequencers 2026-03-13 16:07:57 +03:00
cd0a99a813 feat(apu): add timer/sequencer/LFSR fields for channel output tracking 2026-03-13 16:06:37 +03:00
6f81eb4b08 chore: ignore local worktrees 2026-03-13 15:06:29 +03:00
23 changed files with 2840 additions and 82 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
/target /target
/.worktrees
/docs/superpowers

687
Cargo.lock generated
View File

@@ -2,25 +2,92 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.0" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 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]] [[package]]
name = "cairo-rs" name = "cairo-rs"
version = "0.19.4" version = "0.19.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2ac2a4d0e69036cf0062976f6efcba1aaee3e448594e6514bb2ddf87acce562" checksum = "b2ac2a4d0e69036cf0062976f6efcba1aaee3e448594e6514bb2ddf87acce562"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"cairo-sys-rs", "cairo-sys-rs",
"glib", "glib",
"libc", "libc",
@@ -38,6 +105,33 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "cfg-expr" name = "cfg-expr"
version = "0.15.8" version = "0.15.8"
@@ -48,6 +142,94 @@ dependencies = [
"target-lexicon", "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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -64,6 +246,12 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.32"
@@ -183,6 +371,18 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "gio" name = "gio"
version = "0.19.8" version = "0.19.8"
@@ -211,7 +411,7 @@ dependencies = [
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps", "system-deps",
"windows-sys", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -220,7 +420,7 @@ version = "0.19.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39650279f135469465018daae0ba53357942a5212137515777d5fdca74984a44" checksum = "39650279f135469465018daae0ba53357942a5212137515777d5fdca74984a44"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.0",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-executor", "futures-executor",
@@ -259,6 +459,12 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]] [[package]]
name = "gobject-sys" name = "gobject-sys"
version = "0.19.8" version = "0.19.8"
@@ -398,12 +604,88 @@ dependencies = [
"hashbrown", "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]] [[package]]
name = "libc" name = "libc"
version = "0.2.182" version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" 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]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@@ -419,6 +701,41 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "nesemu" name = "nesemu"
version = "0.1.0" version = "0.1.0"
@@ -443,10 +760,92 @@ name = "nesemu-desktop"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cairo-rs", "cairo-rs",
"cpal",
"gtk4", "gtk4",
"nesemu", "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]] [[package]]
name = "pango" name = "pango"
version = "0.19.8" version = "0.19.8"
@@ -510,6 +909,47 @@ dependencies = [
"proc-macro2", "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]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -519,6 +959,21 @@ dependencies = [
"semver", "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]] [[package]]
name = "semver" name = "semver"
version = "1.0.27" version = "1.0.27"
@@ -563,6 +1018,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -701,13 +1162,169 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" 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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [ 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]] [[package]]
@@ -716,28 +1333,46 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm",
"windows_i686_msvc", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc", "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]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -750,24 +1385,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -782,3 +1441,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"

View File

@@ -40,3 +40,12 @@ match_same_arms = "allow"
module_name_repetitions = "allow" module_name_repetitions = "allow"
too_many_lines = "allow" too_many_lines = "allow"
needless_pass_by_value = "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

View File

@@ -7,3 +7,4 @@ edition = "2024"
nesemu = { path = "../.." } nesemu = { path = "../.." }
gtk4 = "0.8" gtk4 = "0.8"
cairo-rs = "0.19" cairo-rs = "0.19"
cpal = "0.15"

View File

@@ -1,23 +1,27 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; 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::gdk;
use gtk::gio;
use gtk::glib; use gtk::glib;
use gtk::prelude::*; use gtk::prelude::*;
use gtk4 as gtk; use gtk4 as gtk;
use nesemu::prelude::{EmulationState, HostConfig, RuntimeHostLoop}; use nesemu::prelude::{EmulationState, HostConfig, RuntimeHostLoop};
use nesemu::{ use nesemu::{
FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, FrameClock, InputProvider, JoypadButton, set_button_pressed, FrameClock, InputProvider, JoypadButton, JoypadButtons, NesRuntime,
JoypadButtons, NesRuntime, set_button_pressed, RingBuffer, VideoMode, VideoOutput, FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH,
}; };
const APP_ID: &str = "org.nesemu.desktop"; const APP_ID: &str = "org.nesemu.desktop";
const TITLE: &str = "NES Emulator"; const TITLE: &str = "NES Emulator";
const SCALE: i32 = 3; const SCALE: i32 = 3;
const SAMPLE_RATE: u32 = 48_000; const SAMPLE_RATE: u32 = 48_000;
const AUDIO_RING_CAPACITY: usize = 1536;
const AUDIO_CALLBACK_FRAMES: u32 = 256;
fn main() { fn main() {
if std::env::var_os("GSK_RENDERER").is_none() { if std::env::var_os("GSK_RENDERER").is_none() {
@@ -26,9 +30,7 @@ fn main() {
} }
} }
let app = gtk::Application::builder() let app = gtk::Application::builder().application_id(APP_ID).build();
.application_id(APP_ID)
.build();
let initial_rom: Rc<RefCell<Option<PathBuf>>> = let initial_rom: Rc<RefCell<Option<PathBuf>>> =
Rc::new(RefCell::new(std::env::args().nth(1).map(PathBuf::from))); Rc::new(RefCell::new(std::env::args().nth(1).map(PathBuf::from)));
@@ -73,10 +75,33 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
.sensitive(false) .sensitive(false)
.build(); .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(&open_button);
header.pack_start(&pause_button); header.pack_start(&pause_button);
header.pack_start(&reset_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)); window.set_titlebar(Some(&header));
// --- Drawing area --- // --- Drawing area ---
@@ -99,17 +124,18 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
window.set_child(Some(&overlay)); window.set_child(Some(&overlay));
// --- State --- // --- State ---
let desktop = Rc::new(RefCell::new(DesktopApp::new())); let desktop = Rc::new(RefCell::new(DesktopApp::new(Arc::clone(&volume))));
let frame_for_draw: Rc<RefCell<Vec<u8>>> = let frame_for_draw: Rc<RefCell<Vec<u8>>> = Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES]));
Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES])); let scheduler = Rc::new(RefCell::new(DesktopFrameScheduler::new()));
// --- Draw function (pixel-perfect nearest-neighbor) --- // --- Draw function (pixel-perfect nearest-neighbor) ---
{ {
let frame_for_draw = Rc::clone(&frame_for_draw); let frame_for_draw = Rc::clone(&frame_for_draw);
drawing_area.set_draw_func(move |_da, cr, width, height| { drawing_area.set_draw_func(move |_da, cr, width, height| {
let frame = frame_for_draw.borrow(); let frame = frame_for_draw.borrow();
let stride = let stride = cairo::Format::ARgb32
cairo::Format::ARgb32.stride_for_width(FRAME_WIDTH as u32).unwrap(); .stride_for_width(FRAME_WIDTH as u32)
.unwrap();
let mut argb = vec![0u8; stride as usize * FRAME_HEIGHT]; let mut argb = vec![0u8; stride as usize * FRAME_HEIGHT];
for y in 0..FRAME_HEIGHT { for y in 0..FRAME_HEIGHT {
for x in 0..FRAME_WIDTH { for x in 0..FRAME_WIDTH {
@@ -198,6 +224,7 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
// --- Open ROM handler --- // --- Open ROM handler ---
let do_open_rom = { let do_open_rom = {
let desktop = Rc::clone(&desktop); let desktop = Rc::clone(&desktop);
let scheduler = Rc::clone(&scheduler);
let sync_ui = Rc::clone(&sync_ui); let sync_ui = Rc::clone(&sync_ui);
let window = window.clone(); let window = window.clone();
Rc::new(move || { Rc::new(move || {
@@ -219,6 +246,7 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
chooser.add_filter(&all_filter); chooser.add_filter(&all_filter);
let desktop = Rc::clone(&desktop); let desktop = Rc::clone(&desktop);
let scheduler = Rc::clone(&scheduler);
let sync_ui = Rc::clone(&sync_ui); let sync_ui = Rc::clone(&sync_ui);
chooser.connect_response(move |dialog, response| { chooser.connect_response(move |dialog, response| {
if response == gtk::ResponseType::Accept { if response == gtk::ResponseType::Accept {
@@ -227,6 +255,7 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
if let Err(err) = app_state.load_rom_from_path(&path) { if let Err(err) = app_state.load_rom_from_path(&path) {
eprintln!("Failed to load ROM '{}': {err}", path.display()); eprintln!("Failed to load ROM '{}': {err}", path.display());
} else { } else {
scheduler.borrow_mut().reset_timing();
let name = rom_filename(&path); let name = rom_filename(&path);
sync_ui(&app_state, Some(&name)); sync_ui(&app_state, Some(&name));
} }
@@ -248,20 +277,24 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
{ {
let desktop = Rc::clone(&desktop); let desktop = Rc::clone(&desktop);
let scheduler = Rc::clone(&scheduler);
let sync_ui = Rc::clone(&sync_ui); let sync_ui = Rc::clone(&sync_ui);
pause_button.connect_clicked(move |_| { pause_button.connect_clicked(move |_| {
let mut app_state = desktop.borrow_mut(); let mut app_state = desktop.borrow_mut();
app_state.toggle_pause(); app_state.toggle_pause();
scheduler.borrow_mut().reset_timing();
sync_ui(&app_state, None); sync_ui(&app_state, None);
}); });
} }
{ {
let desktop = Rc::clone(&desktop); let desktop = Rc::clone(&desktop);
let scheduler = Rc::clone(&scheduler);
let sync_ui = Rc::clone(&sync_ui); let sync_ui = Rc::clone(&sync_ui);
reset_button.connect_clicked(move |_| { reset_button.connect_clicked(move |_| {
let mut app_state = desktop.borrow_mut(); let mut app_state = desktop.borrow_mut();
app_state.reset(); app_state.reset();
scheduler.borrow_mut().reset_timing();
sync_ui(&app_state, None); sync_ui(&app_state, None);
}); });
} }
@@ -280,11 +313,13 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
let action_pause = gio::SimpleAction::new("toggle-pause", None); let action_pause = gio::SimpleAction::new("toggle-pause", None);
{ {
let desktop = Rc::clone(&desktop); let desktop = Rc::clone(&desktop);
let scheduler = Rc::clone(&scheduler);
let sync_ui = Rc::clone(&sync_ui); let sync_ui = Rc::clone(&sync_ui);
action_pause.connect_activate(move |_, _| { action_pause.connect_activate(move |_, _| {
let mut app_state = desktop.borrow_mut(); let mut app_state = desktop.borrow_mut();
if app_state.is_loaded() { if app_state.is_loaded() {
app_state.toggle_pause(); app_state.toggle_pause();
scheduler.borrow_mut().reset_timing();
sync_ui(&app_state, None); sync_ui(&app_state, None);
} }
}); });
@@ -295,11 +330,13 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
let action_reset = gio::SimpleAction::new("reset", None); let action_reset = gio::SimpleAction::new("reset", None);
{ {
let desktop = Rc::clone(&desktop); let desktop = Rc::clone(&desktop);
let scheduler = Rc::clone(&scheduler);
let sync_ui = Rc::clone(&sync_ui); let sync_ui = Rc::clone(&sync_ui);
action_reset.connect_activate(move |_, _| { action_reset.connect_activate(move |_, _| {
let mut app_state = desktop.borrow_mut(); let mut app_state = desktop.borrow_mut();
if app_state.is_loaded() { if app_state.is_loaded() {
app_state.reset(); app_state.reset();
scheduler.borrow_mut().reset_timing();
sync_ui(&app_state, None); sync_ui(&app_state, None);
} }
}); });
@@ -329,6 +366,7 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
// --- Drag-and-drop --- // --- Drag-and-drop ---
{ {
let desktop = Rc::clone(&desktop); let desktop = Rc::clone(&desktop);
let scheduler = Rc::clone(&scheduler);
let sync_ui = Rc::clone(&sync_ui); let sync_ui = Rc::clone(&sync_ui);
let drop_target = gtk::DropTarget::new(gio::File::static_type(), gdk::DragAction::COPY); let drop_target = gtk::DropTarget::new(gio::File::static_type(), gdk::DragAction::COPY);
drop_target.connect_drop(move |_, value, _, _| { drop_target.connect_drop(move |_, value, _, _| {
@@ -339,6 +377,7 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
eprintln!("Failed to load ROM '{}': {err}", path.display()); eprintln!("Failed to load ROM '{}': {err}", path.display());
return false; return false;
} }
scheduler.borrow_mut().reset_timing();
let name = rom_filename(&path); let name = rom_filename(&path);
sync_ui(&app_state, Some(&name)); sync_ui(&app_state, Some(&name));
return true; return true;
@@ -351,23 +390,45 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
// --- Game loop --- // --- Game loop ---
{ {
let desktop = Rc::clone(&desktop); schedule_game_loop(
let drawing_area = drawing_area.clone(); Rc::clone(&desktop),
let frame_for_draw = Rc::clone(&frame_for_draw); drawing_area.clone(),
glib::timeout_add_local(Duration::from_millis(16), move || { 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 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(); app_state.tick();
frame_for_draw frame_for_draw
.borrow_mut() .borrow_mut()
.copy_from_slice(app_state.frame_rgba()); .copy_from_slice(app_state.frame_rgba());
drawing_area.queue_draw(); 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 { fn rom_filename(path: &Path) -> String {
@@ -409,14 +470,165 @@ impl InputProvider for InputState {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Audio (stub) // Audio (cpal backend)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[derive(Default)] struct CpalAudioSink {
struct AudioSink; _stream: Option<cpal::Stream>,
ring: Arc<RingBuffer>,
_volume: Arc<AtomicU32>,
}
impl nesemu::AudioOutput for AudioSink { impl CpalAudioSink {
fn push_samples(&mut self, _samples: &[f32]) {} 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 { struct DesktopApp {
host: Option<RuntimeHostLoop<Box<dyn FrameClock>>>, host: Option<RuntimeHostLoop<Box<dyn FrameClock>>>,
input: InputState, input: InputState,
audio: AudioSink, audio: CpalAudioSink,
frame_rgba: Vec<u8>, video: BufferedVideo,
state: EmulationState, state: EmulationState,
} }
impl DesktopApp { impl DesktopApp {
fn new() -> Self { fn new(volume: Arc<AtomicU32>) -> Self {
Self { Self {
host: None, host: None,
input: InputState::default(), input: InputState::default(),
audio: AudioSink, audio: CpalAudioSink::new(volume),
frame_rgba: vec![0; FRAME_RGBA_BYTES], video: BufferedVideo::new(),
state: EmulationState::Paused, state: EmulationState::Paused,
} }
} }
@@ -447,6 +659,7 @@ impl DesktopApp {
let runtime = NesRuntime::from_rom_bytes(&data)?; let runtime = NesRuntime::from_rom_bytes(&data)?;
let config = HostConfig::new(SAMPLE_RATE, false); let config = HostConfig::new(SAMPLE_RATE, false);
self.host = Some(RuntimeHostLoop::with_config(runtime, config)); self.host = Some(RuntimeHostLoop::with_config(runtime, config));
self.audio.clear();
self.state = EmulationState::Running; self.state = EmulationState::Running;
Ok(()) Ok(())
} }
@@ -454,6 +667,7 @@ impl DesktopApp {
fn reset(&mut self) { fn reset(&mut self) {
if let Some(host) = self.host.as_mut() { if let Some(host) = self.host.as_mut() {
host.runtime_mut().reset(); host.runtime_mut().reset();
self.audio.clear();
self.state = EmulationState::Running; self.state = EmulationState::Running;
} }
} }
@@ -483,23 +697,137 @@ impl DesktopApp {
return; return;
}; };
let mut null_video = nesemu::NullVideo; match host.run_frame_unpaced(&mut self.input, &mut self.video, &mut self.audio) {
if let Err(err) = host.run_frame_unpaced(&mut self.input, &mut null_video, &mut self.audio) Ok(_) => {}
{ Err(err) => {
eprintln!("Frame execution error: {err}"); eprintln!("Frame execution error: {err}");
self.state = EmulationState::Paused; self.state = EmulationState::Paused;
return; return;
} }
}
self.frame_rgba
.copy_from_slice(&host.runtime().frame_rgba());
} }
fn frame_rgba(&self) -> &[u8] { 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 { fn input_mut(&mut self) -> &mut InputState {
&mut self.input &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,
);
}
}

File diff suppressed because it is too large Load Diff

View 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, // 015
pub pulse2: u8, // 015
pub triangle: u8, // 015
pub noise: u8, // 015
pub dmc: u8, // 0127
}
```
#### 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 (07)
**Triangle channel:**
- `triangle_timer_counter: u16` — countdown timer, clocked every CPU cycle
- `triangle_step: u8` — position in 32-step triangle sequence (031)
**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 (015).
- **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 (015).
- **DMC:** Output is `dmc_output_level` (0127), 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 2561024 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 0100%)
- 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

View File

@@ -16,7 +16,7 @@ pub use nesemu_adapter_api as adapter_api;
#[cfg(feature = "adapter-headless")] #[cfg(feature = "adapter-headless")]
pub use nesemu_adapter_headless as 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::bus::NativeBus;
pub use native_core::cpu::{Cpu6502, CpuBus, CpuError}; pub use native_core::cpu::{Cpu6502, CpuBus, CpuError};
pub use native_core::ines::{InesHeader, InesRom, Mirroring, parse_header, parse_rom}; 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, AudioMixer, AudioOutput, ClientRuntime, EmulationState, FRAME_HEIGHT, FRAME_RGBA_BYTES,
FRAME_WIDTH, FrameClock, FramePacer, HostConfig, InputProvider, JOYPAD_BUTTON_ORDER, FRAME_WIDTH, FrameClock, FramePacer, HostConfig, InputProvider, JOYPAD_BUTTON_ORDER,
JOYPAD_BUTTONS_COUNT, JoypadButton, JoypadButtons, NesRuntime, NoopClock, NullAudio, NullInput, 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, VideoOutput, button_pressed, set_button_pressed,
}; };

View File

@@ -1,4 +1,4 @@
use super::types::{Apu, ApuStateTail}; use super::types::{Apu, ApuStateTail, ChannelOutputs};
impl Apu { impl Apu {
pub fn new() -> Self { pub fn new() -> Self {
@@ -34,6 +34,12 @@ impl Apu {
frame_reset_delay: 0, frame_reset_delay: 0,
pending_frame_mode_5step: false, pending_frame_mode_5step: false,
pending_frame_irq_inhibit: 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 => { 0x4003 => {
self.reload_length_counter(0, value >> 3); self.reload_length_counter(0, value >> 3);
self.envelope_start_flags |= 1 << 0; self.envelope_start_flags |= 1 << 0;
self.pulse_duty_step[0] = 0;
self.pulse_timer_counter[0] = self.pulse_timer_period(0x02);
} }
0x4001 => { 0x4001 => {
self.sweep_reload_flags |= 1 << 0; self.sweep_reload_flags |= 1 << 0;
@@ -68,6 +76,8 @@ impl Apu {
0x4007 => { 0x4007 => {
self.reload_length_counter(1, value >> 3); self.reload_length_counter(1, value >> 3);
self.envelope_start_flags |= 1 << 1; self.envelope_start_flags |= 1 << 1;
self.pulse_duty_step[1] = 0;
self.pulse_timer_counter[1] = self.pulse_timer_period(0x06);
} }
0x4005 => { 0x4005 => {
self.sweep_reload_flags |= 1 << 1; self.sweep_reload_flags |= 1 << 1;
@@ -154,6 +164,9 @@ impl Apu {
self.clock_frame_counter(); self.clock_frame_counter();
} }
self.clock_dmc(); self.clock_dmc();
self.clock_pulse_timers();
self.clock_triangle_timer();
self.clock_noise_timer();
self.cpu_cycle_parity = !self.cpu_cycle_parity; self.cpu_cycle_parity = !self.cpu_cycle_parity;
} }
@@ -227,6 +240,13 @@ impl Apu {
out.push(self.frame_reset_delay); out.push(self.frame_reset_delay);
out.push(u8::from(self.pending_frame_mode_5step)); out.push(u8::from(self.pending_frame_mode_5step));
out.push(u8::from(self.pending_frame_irq_inhibit)); 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) { pub fn load_state_tail(&mut self, state: ApuStateTail) {
@@ -260,5 +280,81 @@ impl Apu {
self.frame_reset_delay = state.frame_reset_delay; self.frame_reset_delay = state.frame_reset_delay;
self.pending_frame_mode_5step = state.pending_frame_mode_5step; self.pending_frame_mode_5step = state.pending_frame_mode_5step;
self.pending_frame_irq_inhibit = state.pending_frame_irq_inhibit; 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 }
} }
} }

View File

@@ -2,4 +2,4 @@ mod api;
mod timing; mod timing;
mod types; mod types;
pub use types::{Apu, ApuStateTail}; pub use types::{Apu, ApuStateTail, ChannelOutputs};

View File

@@ -215,6 +215,62 @@ impl Apu {
let hi = (self.io[timer_lo_idx + 1] as u16 & 0x07) << 8; let hi = (self.io[timer_lo_idx + 1] as u16 & 0x07) << 8;
hi | lo 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) { pub(crate) fn set_pulse_timer_period(&mut self, channel: usize, period: u16) {
let (timer_lo_idx, timer_hi_idx) = if channel == 0 { let (timer_lo_idx, timer_hi_idx) = if channel == 0 {
(0x02, 0x03) (0x02, 0x03)

View File

@@ -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_4_STEP_CYCLES: u32 = 14_915;
pub(super) const APU_FRAME_SEQ_5_STEP_CYCLES: u32 = 18_641; pub(super) const APU_FRAME_SEQ_5_STEP_CYCLES: u32 = 18_641;
pub(super) const APU_QUARTER_FRAME_1: u32 = 3_729; 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) frame_reset_delay: u8,
pub(crate) pending_frame_mode_5step: bool, pub(crate) pending_frame_mode_5step: bool,
pub(crate) pending_frame_irq_inhibit: 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 { pub struct ApuStateTail {
@@ -81,6 +99,12 @@ pub struct ApuStateTail {
pub frame_reset_delay: u8, pub frame_reset_delay: u8,
pub pending_frame_mode_5step: bool, pub pending_frame_mode_5step: bool,
pub pending_frame_irq_inhibit: 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 { impl Default for Apu {

View File

@@ -23,6 +23,7 @@ pub struct NativeBus {
odd_frame: bool, odd_frame: bool,
in_vblank: bool, in_vblank: bool,
frame_complete: bool, frame_complete: bool,
cpu_cycles_since_poll: u32,
mmc3_a12_prev_high: bool, mmc3_a12_prev_high: bool,
mmc3_a12_low_dots: u16, mmc3_a12_low_dots: u16,
mmc3_last_irq_scanline: u32, mmc3_last_irq_scanline: u32,
@@ -47,6 +48,7 @@ impl NativeBus {
odd_frame: false, odd_frame: false,
in_vblank: false, in_vblank: false,
frame_complete: false, frame_complete: false,
cpu_cycles_since_poll: 0,
mmc3_a12_prev_high: false, mmc3_a12_prev_high: false,
mmc3_a12_low_dots: 8, mmc3_a12_low_dots: 8,
mmc3_last_irq_scanline: u32::MAX, mmc3_last_irq_scanline: u32::MAX,
@@ -58,6 +60,10 @@ impl NativeBus {
self.apu.registers() 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]) { pub fn render_frame(&self, out_rgba: &mut [u8], frame_number: u32, buttons: [bool; 8]) {
let _ = (frame_number, buttons); let _ = (frame_number, buttons);
let src = self.ppu.frame_buffer(); let src = self.ppu.frame_buffer();
@@ -80,6 +86,12 @@ impl NativeBus {
pub fn clock_cpu(&mut self, cycles: u8) { pub fn clock_cpu(&mut self, cycles: u8) {
self.clock_cpu_cycles(cycles as u32); 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). // CpuBus trait implementation (memory map + side effects).

View File

@@ -112,6 +112,31 @@ impl NativeBus {
let frame_reset_delay = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?; 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_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 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 { self.apu.load_state_tail(ApuStateTail {
frame_cycle, frame_cycle,
frame_mode_5step, frame_mode_5step,
@@ -143,6 +168,12 @@ impl NativeBus {
frame_reset_delay, frame_reset_delay,
pending_frame_mode_5step, pending_frame_mode_5step,
pending_frame_irq_inhibit, 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_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)?; let mapper_state = sio::take_exact(data, &mut cursor, mapper_len, BUS_STATE_CTX)?;

View File

@@ -312,3 +312,23 @@ fn dmc_playback_updates_output_level_from_sample_bits() {
assert!(bus.apu.dmc_output_level < initial); 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");
}

View File

@@ -7,6 +7,7 @@ use crate::native_core::mapper::Mapper;
impl NativeBus { impl NativeBus {
fn clock_one_cpu_cycle(&mut self) { fn clock_one_cpu_cycle(&mut self) {
self.cpu_cycles_since_poll = self.cpu_cycles_since_poll.saturating_add(1);
for _ in 0..3 { for _ in 0..3 {
self.clock_ppu_dot(); self.clock_ppu_dot();
} }

View File

@@ -1,3 +1,4 @@
use crate::native_core::apu::ChannelOutputs;
use crate::runtime::VideoMode; use crate::runtime::VideoMode;
#[derive(Debug)] #[derive(Debug)]
@@ -5,6 +6,7 @@ pub struct AudioMixer {
sample_rate: u32, sample_rate: u32,
samples_per_cpu_cycle: f64, samples_per_cpu_cycle: f64,
sample_accumulator: f64, sample_accumulator: f64,
last_output_sample: f32,
} }
impl AudioMixer { impl AudioMixer {
@@ -14,6 +16,7 @@ impl AudioMixer {
sample_rate, sample_rate,
samples_per_cpu_cycle: sample_rate as f64 / cpu_hz, samples_per_cpu_cycle: sample_rate as f64 / cpu_hz,
sample_accumulator: 0.0, sample_accumulator: 0.0,
last_output_sample: 0.0,
} }
} }
@@ -23,17 +26,99 @@ impl AudioMixer {
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.sample_accumulator = 0.0; 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>) { pub fn push_cycles(&mut self, cpu_cycles: u32, channels: ChannelOutputs, out: &mut Vec<f32>) {
self.sample_accumulator += self.samples_per_cpu_cycle * f64::from(cpu_cycles); self.sample_accumulator += self.samples_per_cpu_cycle * cpu_cycles as f64;
let samples = self.sample_accumulator.floor() as usize; let samples = self.sample_accumulator.floor() as usize;
self.sample_accumulator -= samples as f64; self.sample_accumulator -= samples as f64;
// Current core does not expose a final mixed PCM stream yet. let pulse_out = 0.00752 * (f32::from(channels.pulse1) + f32::from(channels.pulse2));
// Use DMC output level as a stable interim signal in [-1.0, 1.0]. let tnd_out = 0.00851 * f32::from(channels.triangle)
let dmc = apu_regs[0x11] & 0x7F; + 0.00494 * f32::from(channels.noise)
let sample = (f32::from(dmc) / 63.5) - 1.0; + 0.00335 * f32::from(channels.dmc);
out.extend(std::iter::repeat_n(sample, samples)); 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]
);
} }
} }

View File

@@ -1,6 +1,6 @@
pub const FRAME_WIDTH: usize = 256; pub const FRAME_WIDTH: usize = 256;
pub const FRAME_HEIGHT: usize = 240; pub const FRAME_HEIGHT: usize = 240;
pub const FRAME_RGBA_BYTES: usize = FRAME_WIDTH * FRAME_HEIGHT * 4; 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"; pub(crate) const SAVE_STATE_MAGIC: &[u8; 8] = b"NESRT001";

View File

@@ -1,8 +1,8 @@
use crate::runtime::state::{load_runtime_state, save_runtime_state}; use crate::runtime::state::{load_runtime_state, save_runtime_state};
use crate::runtime::{ 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 { pub struct NesRuntime {
cpu: Cpu6502, cpu: Cpu6502,
@@ -79,11 +79,11 @@ impl NesRuntime {
self.bus.set_joypad_buttons(buttons); 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); self.bus.set_joypad_buttons(self.buttons);
let cycles = self.cpu.step(&mut self.bus).map_err(RuntimeError::Cpu)?; let cpu_cycles = self.cpu.step(&mut self.bus).map_err(RuntimeError::Cpu)?;
self.bus.clock_cpu(cycles); self.bus.clock_cpu(cpu_cycles);
Ok(cycles) Ok(self.bus.take_cpu_cycles_since_poll())
} }
pub fn run_until_frame_complete(&mut self) -> Result<(), RuntimeError> { pub fn run_until_frame_complete(&mut self) -> Result<(), RuntimeError> {
@@ -109,7 +109,7 @@ impl NesRuntime {
self.bus.begin_frame(); self.bus.begin_frame();
while !self.bus.take_frame_complete() { while !self.bus.take_frame_complete() {
let cycles = self.step_instruction()?; 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); self.frame_number = self.frame_number.saturating_add(1);
Ok(()) Ok(())

View File

@@ -5,6 +5,7 @@ mod constants;
mod core; mod core;
mod error; mod error;
mod host; mod host;
pub mod ring_buffer;
mod state; mod state;
mod timing; mod timing;
mod types; mod types;
@@ -12,6 +13,7 @@ mod types;
#[cfg(feature = "adapter-api")] #[cfg(feature = "adapter-api")]
pub use adapters::{AudioAdapter, ClockAdapter, InputAdapter, VideoAdapter}; pub use adapters::{AudioAdapter, ClockAdapter, InputAdapter, VideoAdapter};
pub use audio::AudioMixer; pub use audio::AudioMixer;
pub use ring_buffer::RingBuffer;
pub use constants::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, SAVE_STATE_VERSION}; pub use constants::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, SAVE_STATE_VERSION};
pub use core::NesRuntime; pub use core::NesRuntime;
pub use error::RuntimeError; pub use error::RuntimeError;

124
src/runtime/ring_buffer.rs Normal file
View 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]);
}
}

View File

@@ -1,12 +1,17 @@
use crate::runtime::{ use crate::runtime::{
AudioOutput, ClientRuntime, EmulationState, FRAME_RGBA_BYTES, HostConfig, InputProvider, button_pressed, set_button_pressed, AudioOutput, ClientRuntime, EmulationState, HostConfig,
JOYPAD_BUTTON_ORDER, JOYPAD_BUTTONS_COUNT, JoypadButton, NesRuntime, NoopClock, InputProvider, JoypadButton, NesRuntime, NoopClock, NullAudio, NullInput, NullVideo,
RuntimeHostLoop, VideoMode, VideoOutput, button_pressed, set_button_pressed, RuntimeHostLoop, VideoMode, VideoOutput, FRAME_RGBA_BYTES, JOYPAD_BUTTONS_COUNT,
JOYPAD_BUTTON_ORDER,
}; };
use std::cell::Cell; use std::cell::Cell;
use std::rc::Rc; use std::rc::Rc;
fn nrom_test_rom() -> Vec<u8> { 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]; let mut rom = vec![0u8; 16 + 16 * 1024 + 8 * 1024];
rom[0..4].copy_from_slice(b"NES\x1A"); rom[0..4].copy_from_slice(b"NES\x1A");
rom[4] = 1; // 16 KiB PRG rom[4] = 1; // 16 KiB PRG
@@ -17,11 +22,7 @@ fn nrom_test_rom() -> Vec<u8> {
rom[reset_vec] = 0x00; rom[reset_vec] = 0x00;
rom[reset_vec + 1] = 0x80; rom[reset_vec + 1] = 0x80;
// 0x8000: NOP; JMP $8000 rom[prg_offset..prg_offset + program.len()].copy_from_slice(program);
rom[prg_offset] = 0xEA;
rom[prg_offset + 1] = 0x4C;
rom[prg_offset + 2] = 0x00;
rom[prg_offset + 3] = 0x80;
rom rom
} }
@@ -83,6 +84,30 @@ fn audio_mixer_generates_samples() {
assert_eq!(mixer.sample_rate(), 48_000); 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; struct FixedInput;
impl InputProvider for FixedInput { impl InputProvider for FixedInput {

View File

@@ -1,7 +1,7 @@
use nesemu::prelude::*; use nesemu::prelude::*;
use nesemu::{ use nesemu::{
AudioOutput, HostConfig, InputProvider, JOYPAD_BUTTONS_COUNT, NullAudio, NullInput, NullVideo, AudioOutput, HostConfig, InputProvider, NullAudio, NullInput, NullVideo, RuntimeError,
RuntimeError, VideoOutput, VideoOutput, JOYPAD_BUTTONS_COUNT,
}; };
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -212,7 +212,7 @@ fn public_api_regression_hashes_for_reference_rom() {
.expect("run frames"); .expect("run frames");
let expected_frame_hash = 0x42d1_20e3_54e0_a325_u64; 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!( assert_eq!(
video.last_hash, expected_frame_hash, video.last_hash, expected_frame_hash,