Repository: Canop/bacon (language: rust)

Issue:
audible notification on job success / failure
Is it possible and easy to add a audible notification on job success / failure? It's not important but would be nice when I don't have the screen space to know if a job failed or succeed.  But if this is challenging it's not worth it. And this should be off by default if added... Would be nice if it can be set as global setting if supported. Feel free to decline this request if it doesn't seem useful, just came to mind and decided to ask instead of assuming it was not useful to anyone but me.


Discussion / hints:
This sounds easy. I'll make a PR.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5e1013..3995cd6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
 ### next
 - fix freeze on mac on config change - Fix #306 - Thanks @irh
+- fix race condition on config reload when saved by helix - Fix #310
+- sound can be enabled with sound.enabled = true, which allows to add job parameters such as `on_failure = "play-sound(volume=80%)"` - Fix #303
 
 <a name="v3.8.0"></a>
 ### v3.8.0 - 2025/01/17
diff --git a/Cargo.lock b/Cargo.lock
index 9428989..bd7391b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -35,6 +35,28 @@ version = "0.2.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
 
+[[package]]
+name = "alsa"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
+dependencies = [
+ "alsa-sys",
+ "bitflags 2.6.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 = "android-tzdata"
 version = "0.1.1"
@@ -132,6 +154,12 @@ version = "0.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
 
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
 [[package]]
 name = "autocfg"
 version = "1.4.0"
@@ -156,7 +184,8 @@ dependencies = [
  "iq",
  "lazy-regex",
  "notify",
- "rustc-hash",
+ "rodio",
+ "rustc-hash 2.1.0",
  "serde",
  "serde_json",
  "termimad",
@@ -165,6 +194,24 @@ dependencies = [
  "vte",
 ]
 
+[[package]]
+name = "bindgen"
+version = "0.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f"
+dependencies = [
+ "bitflags 2.6.0",
+ "cexpr",
+ "clang-sys",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash 1.1.0",
+ "shlex",
+ "syn 2.0.92",
+]
+
 [[package]]
 name = "bitflags"
 version = "1.3.2"
@@ -203,6 +250,18 @@ version = "3.16.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
 
+[[package]]
+name = "bytemuck"
+version = "1.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3"
+
+[[package]]
+name = "bytes"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
+
 [[package]]
 name = "camino"
 version = "1.1.9"
@@ -241,9 +300,26 @@ version = "1.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8d6dbb628b8f8555f86d0323c2eb39e3ec81901f4b83e091db8a6a76d316a333"
 dependencies = [
+ "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-if"
 version = "1.0.0"
@@ -270,6 +346,17 @@ dependencies = [
  "windows-targets 0.52.6",
 ]
 
+[[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 = "clap"
 version = "4.5.23"
@@ -354,6 +441,16 @@ version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
 
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
 [[package]]
 name = "coolor"
 version = "1.0.0"
@@ -369,6 +466,49 @@ 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.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ce857aa0b77d77287acc1ac3e37a05a8c95a2af3647d23b15f263bdaeb7562b"
+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 = "crc32fast"
 version = "1.4.2"
@@ -495,6 +635,12 @@ dependencies = [
  "windows-sys 0.59.0",
 ]
 
+[[package]]
+name = "dasp_sample"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
+
 [[package]]
 name = "directories-next"
 version = "2.0.0"
@@ -533,6 +679,21 @@ version = "1.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
 
+[[package]]
+name = "either"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
 [[package]]
 name = "equivalent"
 version = "1.0.1"
@@ -1216,7 +1377,7 @@ dependencies = [
  "iana-time-zone-haiku",
  "js-sys",
  "wasm-bindgen",
- "windows-core",
+ "windows-core 0.52.0",
 ]
 
 [[package]]
@@ -1423,6 +1584,15 @@ version = "1.70.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
 
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itoa"
 version = "1.0.14"
@@ -1455,6 +1625,37 @@ dependencies = [
  "jiff-tzdb",
 ]
 
+[[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 1.0.69",
+ "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.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "js-sys"
 version = "0.3.76"
@@ -1508,12 +1709,28 @@ dependencies = [
  "syn 2.0.92",
 ]
 
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
 [[package]]
 name = "libc"
 version = "0.2.169"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
 
+[[package]]
+name = "libloading"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
+dependencies = [
+ "cfg-if",
+ "windows-targets 0.52.6",
+]
+
 [[package]]
 name = "libredox"
 version = "0.1.3"
@@ -1553,6 +1770,15 @@ version = "0.4.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
 
+[[package]]
+name = "mach2"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "memchr"
 version = "2.7.4"
@@ -1577,6 +1803,12 @@ dependencies = [
  "once_cell",
 ]
 
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
 [[package]]
 name = "miniz_oxide"
 version = "0.8.2"
@@ -1598,6 +1830,35 @@ dependencies = [
  "windows-sys 0.52.0",
 ]
 
+[[package]]
+name = "ndk"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
+dependencies = [
+ "bitflags 2.6.0",
+ "jni-sys",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "thiserror 1.0.69",
+]
+
+[[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 = "nix"
 version = "0.29.0"
@@ -1610,6 +1871,16 @@ dependencies = [
  "libc",
 ]
 
+[[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 = "notify"
 version = "7.0.0"
@@ -1638,6 +1909,17 @@ dependencies = [
  "instant",
 ]
 
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.92",
+]
+
 [[package]]
 name = "num-traits"
 version = "0.2.19"
@@ -1647,6 +1929,27 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "num_enum"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179"
+dependencies = [
+ "num_enum_derive",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.92",
+]
+
 [[package]]
 name = "objc-sys"
 version = "0.3.5"
@@ -1746,6 +2049,29 @@ dependencies = [
  "objc2-metal",
 ]
 
+[[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.20.2"
@@ -1781,6 +2107,21 @@ version = "2.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
 
+[[package]]
+name = "pkg-config"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b"
+dependencies = [
+ "toml_edit",
+]
+
 [[package]]
 name = "proc-macro2"
 version = "1.0.92"
@@ -1867,6 +2208,22 @@ version = "0.8.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
 
+[[package]]
+name = "rodio"
+version = "0.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1"
+dependencies = [
+ "cpal",
+ "symphonia",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
 [[package]]
 name = "rustc-hash"
 version = "2.1.0"
@@ -2023,6 +2380,55 @@ version = "0.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
 
+[[package]]
+name = "symphonia"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9"
+dependencies = [
+ "lazy_static",
+ "symphonia-bundle-mp3",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-bundle-mp3"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c01c2aae70f0f1fb096b6f0ff112a930b1fb3626178fba3ae68b09dce71706d4"
+dependencies = [
+ "lazy_static",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-core"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3"
+dependencies = [
+ "arrayvec 0.7.6",
+ "bitflags 1.3.2",
+ "bytemuck",
+ "lazy_static",
+ "log",
+]
+
+[[package]]
+name = "symphonia-metadata"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c"
+dependencies = [
+ "encoding_rs",
+ "lazy_static",
+ "log",
+ "symphonia-core",
+]
+
 [[package]]
 name = "syn"
 version = "1.0.109"
@@ -2270,7 +2676,7 @@ version = "0.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "96cc8a191608603611e78c6ec11dafef37e3cca0775aeef1931824753e81711d"
 dependencies = [
- "arrayvec",
+ "arrayvec 0.5.2",
  "utf8parse",
  "vte_generate_state_changes",
 ]
@@ -2326,6 +2732,19 @@ dependencies = [
  "wasm-bindgen-shared",
 ]
 
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "once_cell",
+ "wasm-bindgen",
+ "web-sys",
+]
+
 [[package]]
 name = "wasm-bindgen-macro"
 version = "0.2.99"
@@ -2355,6 +2774,16 @@ version = "0.2.99"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6"
 
+[[package]]
+name = "web-sys"
+version = "0.3.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "winapi"
 version = "0.3.9"
@@ -2386,6 +2815,16 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
 
+[[package]]
+name = "windows"
+version = "0.54.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
+dependencies = [
+ "windows-core 0.54.0",
+ "windows-targets 0.52.6",
+]
+
 [[package]]
 name = "windows-core"
 version = "0.52.0"
@@ -2395,6 +2834,34 @@ dependencies = [
  "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-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"
@@ -2413,6 +2880,21 @@ dependencies = [
  "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]]
 name = "windows-targets"
 version = "0.48.5"
@@ -2444,6 +2926,12 @@ dependencies = [
  "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.48.5"
@@ -2456,6 +2944,12 @@ 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.48.5"
@@ -2468,6 +2962,12 @@ 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.48.5"
@@ -2486,6 +2986,12 @@ 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.48.5"
@@ -2498,6 +3004,12 @@ 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.48.5"
@@ -2510,6 +3022,12 @@ 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.48.5"
@@ -2522,6 +3040,12 @@ 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.48.5"
diff --git a/Cargo.toml b/Cargo.toml
index 01bc344..ca5ad63 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,8 +12,9 @@ readme = "README.md"
 rust-version = "1.76"
         
 [features]
-default = []
+default = ["sound"]
 clipboard = ["arboard"]
+sound = ["rodio"]
 
 [dependencies]
 anyhow = "1.0"
@@ -30,6 +31,7 @@ glob = "0.3"
 iq = { version = "0.2", features = ["template"] }
 lazy-regex = "3.4.1"
 notify = "7.0"
+rodio = { version = "0.20", optional = true, default-features = false, features = ["mp3"] }
 rustc-hash = "2"
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
diff --git a/bacon.toml b/bacon.toml
index c50d69c..28a9b37 100644
--- a/bacon.toml
+++ b/bacon.toml
@@ -27,6 +27,7 @@ command = [
 	"--all-targets",
 ]
 need_stdout = false
+allow_warnings = true
 
 [jobs.nightly]
 command = [
diff --git a/build.sh b/build.sh
index 3f2e792..57d3c0a 100755
--- a/build.sh
+++ b/build.sh
@@ -7,5 +7,5 @@
 #
 # bacon can also be installed system-wide with
 #  cargo install --locked .
-cargo build --release --features "clipboard"
+cargo build --release --features "clipboard sound"
 
diff --git a/defaults/default-prefs.toml b/defaults/default-prefs.toml
index f2cd0dc..f423203 100644
--- a/defaults/default-prefs.toml
+++ b/defaults/default-prefs.toml
@@ -69,6 +69,16 @@ path = ".bacon-locations"
 line_format = "{kind} {path}:{line}:{column} {message}"
 
 
+# If you want some job to emit a beep on success or on failure,
+# you need to globally enable sound, and you may set up the max volume here
+# 
+# With sound enabled, you may set up sound on a job with eg
+#    on_success = "play-sound(name=90s-game-ui-6,volume=50)"
+#    on_failure = "play-sound(name=beep-warning,volume=100)"
+[sound]
+enabled = false # set true to allow sound
+base_volume = "100%" # global volume multiplier
+
 # Uncomment and change the key-bindings you want to define
 # (some of those ones are the defaults and are just here for illustration)
 [keybindings]
diff --git a/resources/2-100419.mp3 b/resources/2-100419.mp3
new file mode 100644
index 0000000..81643ca
Binary files /dev/null and b/resources/2-100419.mp3 differ
diff --git a/resources/90s-game-ui-6-185099.mp3 b/resources/90s-game-ui-6-185099.mp3
new file mode 100644
index 0000000..7e43ba2
Binary files /dev/null and b/resources/90s-game-ui-6-185099.mp3 differ
diff --git a/resources/README.md b/resources/README.md
new file mode 100644
index 0000000..c5313bc
--- /dev/null
+++ b/resources/README.md
@@ -0,0 +1,9 @@
+Resources here are embedded in bacon, depending on features enabled at compilation.
+
+All mp3 files in this directory come from https://pixabay.com/
+Their name in this directory is the name in pixabay, to ease retrieval.
+They're free to use.
+
+
+You may propose other sounds for inclusion in bacon, provided that their usage isn't limited and that they're light enough.
+See src/sound/play_sound.rs for more information.
diff --git a/resources/beep-6-96243.mp3 b/resources/beep-6-96243.mp3
new file mode 100644
index 0000000..0d1f255
Binary files /dev/null and b/resources/beep-6-96243.mp3 differ
diff --git a/resources/beep-beep-6151.mp3 b/resources/beep-beep-6151.mp3
new file mode 100644
index 0000000..75eeebc
Binary files /dev/null and b/resources/beep-beep-6151.mp3 differ
diff --git a/resources/beep-warning-6387.mp3 b/resources/beep-warning-6387.mp3
new file mode 100644
index 0000000..b15e525
Binary files /dev/null and b/resources/beep-warning-6387.mp3 differ
diff --git a/resources/bell-chord1-83260.mp3 b/resources/bell-chord1-83260.mp3
new file mode 100644
index 0000000..bc1a5ee
Binary files /dev/null and b/resources/bell-chord1-83260.mp3 differ
diff --git a/resources/car-horn-beepsmp3-14659.mp3 b/resources/car-horn-beepsmp3-14659.mp3
new file mode 100644
index 0000000..d47a0f0
Binary files /dev/null and b/resources/car-horn-beepsmp3-14659.mp3 differ
diff --git a/resources/conveniencestorering-96090.mp3 b/resources/conveniencestorering-96090.mp3
new file mode 100644
index 0000000..4d27a4c
Binary files /dev/null and b/resources/conveniencestorering-96090.mp3 differ
diff --git a/resources/cow_bells_01-98236.mp3 b/resources/cow_bells_01-98236.mp3
new file mode 100644
index 0000000..cb956c7
Binary files /dev/null and b/resources/cow_bells_01-98236.mp3 differ
diff --git a/resources/pickup-sound-46472.mp3 b/resources/pickup-sound-46472.mp3
new file mode 100644
index 0000000..354269b
Binary files /dev/null and b/resources/pickup-sound-46472.mp3 differ
diff --git a/resources/positive_beeps-85504.mp3 b/resources/positive_beeps-85504.mp3
new file mode 100644
index 0000000..7154a01
Binary files /dev/null and b/resources/positive_beeps-85504.mp3 differ
diff --git a/resources/short-beep-tone-47916.mp3 b/resources/short-beep-tone-47916.mp3
new file mode 100644
index 0000000..b0860bc
Binary files /dev/null and b/resources/short-beep-tone-47916.mp3 differ
diff --git a/resources/slash1-94367.mp3 b/resources/slash1-94367.mp3
new file mode 100644
index 0000000..9bba1e3
Binary files /dev/null and b/resources/slash1-94367.mp3 differ
diff --git a/resources/store-scanner-beep-90395.mp3 b/resources/store-scanner-beep-90395.mp3
new file mode 100644
index 0000000..ac50b9c
Binary files /dev/null and b/resources/store-scanner-beep-90395.mp3 differ
diff --git a/resources/success-48018.mp3 b/resources/success-48018.mp3
new file mode 100644
index 0000000..c86f6c6
Binary files /dev/null and b/resources/success-48018.mp3 differ
diff --git a/src/analysis/biome/mod.rs b/src/analysis/biome/mod.rs
index c72df4f..b1ebd12 100644
--- a/src/analysis/biome/mod.rs
+++ b/src/analysis/biome/mod.rs
@@ -1,4 +1,4 @@
-//! An analyzer for biome ( https://biomejs.dev/ )
+//! An analyzer for biome ( <https://biomejs.dev>/ )
 
 use {
     super::*,
diff --git a/src/analysis/eslint/eslint_line_analyzer.rs b/src/analysis/eslint/eslint_line_analyzer.rs
index 46e5f37..0eb20c5 100644
--- a/src/analysis/eslint/eslint_line_analyzer.rs
+++ b/src/analysis/eslint/eslint_line_analyzer.rs
@@ -1,4 +1,4 @@
-//! An analyzer for eslint ( https://eslint.org/ )
+//! An analyzer for eslint ( <https://eslint.org/> )
 use {
     super::*,
     crate::*,
diff --git a/src/analysis/eslint/mod.rs b/src/analysis/eslint/mod.rs
index bc5bad4..e728192 100644
--- a/src/analysis/eslint/mod.rs
+++ b/src/analysis/eslint/mod.rs
@@ -1,4 +1,4 @@
-//! An analyzer for eslint ( https://eslint.org/ )
+//! An analyzer for eslint ( <https://eslint.org/> )
 mod eslint_line_analyzer;
 
 use {
diff --git a/src/analysis/python/ruff.rs b/src/analysis/python/ruff.rs
index a422ca3..628e35b 100644
--- a/src/analysis/python/ruff.rs
+++ b/src/analysis/python/ruff.rs
@@ -1,4 +1,4 @@
-//! An analyzer for ruff ( https://docs.astral.sh/ruff/ )
+//! An analyzer for ruff ( <https://docs.astral.sh/ruff/> )
 
 use {
     crate::*,
diff --git a/src/app.rs b/src/app.rs
index 5b5021b..1616b3d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -104,6 +104,8 @@ fn run_mission(
     let keybindings = mission.settings.keybindings.clone();
     let grace_period = mission.settings.grace_period;
 
+    let sound_player = mission.sound_player_if_needed();
+
     // build the watcher detecting and transmitting mission file changes
     let ignorer = time!(Info, mission.ignorer());
     let mission_watcher = Watcher::new(&mission.paths_to_watch, ignorer)?;
@@ -141,12 +143,14 @@ fn run_mission(
         _dummy_sender = sender;
         receiver
     };
-    let mut do_after_mission = DoAfterMission::Quit;
-
+    let mut mission_end = None;
     // loop on events
     #[allow(unused_mut)]
     loop {
-        let mut action: Option<&Action> = None;
+        // The actions to execute in response to the event.
+        // While it's a vec, action execution will stop at the first one quitting the
+        // mission or requesting a task execution, and the rest of the vec will be dropped.
+        let mut actions: Vec<Action> = Vec::new();
         select! {
             recv(ticker.tick_receiver) -> _ => {
                 // just redraw
@@ -160,14 +164,14 @@ fn run_mission(
                 state.receive_watch_event();
                 if state.auto_refresh.is_enabled() {
                     if !state.is_computing() || on_change_strategy == OnChangeStrategy::KillThenRestart {
-                        action = Some(&Action::Internal(Internal::ReRun));
+                        actions.push(Action::Internal(Internal::ReRun));
                     }
                 }
             }
             recv(config_watcher.receiver) -> _ => {
                 info!("config watch event received");
                 grace_period.sleep(); // Fix #310
-                action = Some(&Action::Internal(Internal::ReloadConfig));
+                actions.push(Action::Internal(Internal::ReloadConfig));
             }
             recv(executor.line_receiver) -> info => {
                 if let Ok(info) = info {
@@ -193,7 +197,21 @@ fn run_mission(
                                 writeln!(w)?;
                                 w.flush()?;
                             }
-                            action = state.action();
+                            if state.is_success() {
+                                if let Some(action) = &state.mission.job.on_success {
+                                    actions.push(action.clone());
+                                }
+                            }
+                            if state.is_failure() {
+                                if let Some(action) = &state.mission.job.on_failure {
+                                    actions.push(action.clone());
+                                }
+                            }
+                            if state.changes_since_last_job_start > 0 && state.auto_refresh.is_enabled() {
+                                // will be ignored if a on_success or on_failures ends the mission
+                                // or does a rerun already
+                                actions.push(Action::Internal(Internal::ReRun))
+                            }
                         }
                         CommandExecInfo::Error(e) => {
                             state.computation_stops();
@@ -214,16 +232,25 @@ fn run_mission(
                         let key_combination = KeyCombination::from(key_event);
                         debug!("key combination pressed: {}", key_combination);
                         if !state.apply_key_combination(key_combination) {
-                            action = keybindings.get(key_combination);
+                            let action = keybindings.get(key_combination);
+                            if let Some(action) = action {
+                                actions.push(action.clone());
+                            }
                         }
                     }
                     #[cfg(windows)]
                     Event::Mouse(MouseEvent { kind: MouseEventKind::ScrollDown, .. }) => {
-                        action = keybindings.get(key!(down));
+                        let action = keybindings.get(key!(down));
+                        if let Some(action) = action {
+                            actions.push(action.clone());
+                        }
                     }
                     #[cfg(windows)]
                     Event::Mouse(MouseEvent { kind: MouseEventKind::ScrollUp, .. }) => {
-                        action = keybindings.get(key!(up));
+                        let action = keybindings.get(key!(up));
+                        if let Some(action) = action {
+                            actions.push(action.clone());
+                        }
                     }
                     _ => {}
                 }
@@ -232,8 +259,8 @@ fn run_mission(
                 }
             }
         }
-        if let Some(action) = action.take() {
-            debug!("requested action: {action:?}");
+        for action in actions.drain(..) {
+            info!("requested action: {action:?}");
             match action {
                 Action::Export(export_name) => {
                     let export_name = export_name.to_string();
@@ -249,8 +276,7 @@ fn run_mission(
                 Action::Internal(internal) => match internal {
                     Internal::Back => {
                         if !state.back() {
-                            do_after_mission = DoAfterMission::NextJob(JobRef::Previous);
-                            break;
+                            mission_end = Some(DoAfterMission::NextJob(JobRef::Previous));
                         }
                     }
                     Internal::CopyUnstyledOutput => {
@@ -271,39 +297,49 @@ fn run_mission(
                     Internal::Pause => {
                         state.auto_refresh = AutoRefresh::Paused;
                     }
+                    Internal::PlaySound(play_sound_command) => {
+                        if let Some(sound_player) = &sound_player {
+                            sound_player.play(play_sound_command.clone());
+                        } else {
+                            debug!("sound not enabled");
+                        }
+                    }
                     Internal::Quit => {
+                        mission_end = Some(DoAfterMission::Quit);
                         break;
                     }
                     Internal::ReRun => {
                         task_executor.die();
                         task_executor = state.start_computation(&mut executor)?;
+                        break; // drop following actions
                     }
                     Internal::Refresh => {
                         state.clear();
                         task_executor.die();
                         task_executor = state.start_computation(&mut executor)?;
+                        break; // drop following actions
                     }
                     Internal::ReloadConfig => {
-                        do_after_mission = DoAfterMission::ReloadConfig;
+                        mission_end = Some(DoAfterMission::ReloadConfig);
                         break;
                     }
                     Internal::ScopeToFailures => {
                         if let Some(scope) = state.failures_scope() {
                             info!("scoping to failures: {scope:#?}");
-                            do_after_mission = JobRef::from(scope).into();
+                            mission_end = Some(JobRef::from(scope).into());
                             break;
                         } else {
                             warn!("no available failures scope");
                         }
                     }
                     Internal::Scroll(scroll_command) => {
-                        let scroll_command = *scroll_command;
                         state.apply_scroll_command(scroll_command);
                     }
                     Internal::ToggleBacktrace(level) => {
                         state.toggle_backtrace(level);
                         task_executor.die();
                         task_executor = state.start_computation(&mut executor)?;
+                        break; // drop following actions
                     }
                     Internal::TogglePause => match state.auto_refresh {
                         AutoRefresh::Enabled => {
@@ -314,6 +350,7 @@ fn run_mission(
                                 state.clear();
                                 task_executor.die();
                                 task_executor = state.start_computation(&mut executor)?;
+                                break; // drop following actions
                             }
                             state.auto_refresh = AutoRefresh::Enabled;
                         }
@@ -332,6 +369,7 @@ fn run_mission(
                             state.clear();
                             task_executor.die();
                             task_executor = state.start_computation(&mut executor)?;
+                            break; // drop following actions
                         }
                         state.auto_refresh = AutoRefresh::Enabled;
                     }
@@ -340,7 +378,7 @@ fn run_mission(
                     }
                 },
                 Action::Job(job_ref) => {
-                    do_after_mission = job_ref.clone().into();
+                    mission_end = Some(job_ref.clone().into());
                     break;
                 }
             }
@@ -348,7 +386,9 @@ fn run_mission(
         if !headless {
             state.draw(w)?;
         }
+        if let Some(mission_end) = mission_end {
+            task_executor.die();
+            return Ok(mission_end);
+        }
     }
-    task_executor.die();
-    Ok(do_after_mission)
 }
diff --git a/src/burp/mod.rs b/src/burp/mod.rs
index 1b3fedd..1063604 100644
--- a/src/burp/mod.rs
+++ b/src/burp/mod.rs
@@ -1,5 +1,5 @@
 //! Utilities related to BURP
-//! See https://dystroy.org/blog/bacon-everything-roadmap/#introduce-burp
+//! See <https://dystroy.org/blog/bacon-everything-roadmap/#introduce-burp>
 use crate::*;
 
 /// Make a BURP compliant location line
@@ -19,7 +19,7 @@ pub fn error_line(error: &str) -> TLine {
     line.strings.push(TString::new("", error.to_string()));
     line
 }
-/// Make a BURP compliant error line (title) from a [tstring]
+/// Make a BURP compliant error line (title) from a [TString]
 pub fn error_line_ts(error: &[TString]) -> TLine {
     let mut line = TLine::default();
     line.strings.push(TString::new(CSI_BOLD_RED, "error"));
@@ -27,7 +27,7 @@ pub fn error_line_ts(error: &[TString]) -> TLine {
     line.strings.extend(error.iter().cloned());
     line
 }
-/// Make a BURP compliant warning line (title) from a [tstring]
+/// Make a BURP compliant warning line (title) from a [TString]
 pub fn warning_line_ts(warning: &[TString]) -> TLine {
     let mut line = TLine::default();
     line.strings.push(TString::new(CSI_BOLD_YELLOW, "warning"));
diff --git a/src/conf/config.rs b/src/conf/config.rs
index 93073ae..54885e4 100644
--- a/src/conf/config.rs
+++ b/src/conf/config.rs
@@ -82,6 +82,12 @@ pub struct Config {
     /// Env vars to set for all job executions
     #[serde(default)]
     pub env: HashMap<String, String>,
+
+    /// Whether to beep when the job ends
+    pub beep_on_end: Option<bool>,
+
+    #[serde(default)]
+    pub sound: SoundConfig,
 }
 
 impl Config {
diff --git a/src/conf/settings.rs b/src/conf/settings.rs
index 027869b..5d2ccc4 100644
--- a/src/conf/settings.rs
+++ b/src/conf/settings.rs
@@ -41,6 +41,7 @@ pub struct Settings {
     pub watch: Vec<String>,
     pub wrap: bool,
     pub env: HashMap<String, String>,
+    pub sound: SoundConfig,
 }
 
 impl Default for Settings {
@@ -69,6 +70,7 @@ impl Default for Settings {
             default_watch: true,
             watch: Default::default(),
             env: Default::default(),
+            sound: Default::default(),
         }
     }
 }
@@ -200,6 +202,7 @@ impl Settings {
         for pattern in &config.ignore {
             self.ignore.push(pattern.clone());
         }
+        self.sound.apply(&config.sound);
     }
     pub fn apply_args(
         &mut self,
diff --git a/src/internal.rs b/src/internal.rs
index c589b20..77d4997 100644
--- a/src/internal.rs
+++ b/src/internal.rs
@@ -1,5 +1,10 @@
 use {
-    crate::ScrollCommand,
+    crate::{
+        PlaySoundCommand,
+        ScrollCommand,
+        Volume,
+    },
+    lazy_regex::*,
     serde::{
         Deserialize,
         Deserializer,
@@ -37,6 +42,7 @@ pub enum Internal {
     Validate, // validate search entry
     NextMatch,
     PreviousMatch,
+    PlaySound(PlaySoundCommand),
 }
 
 impl Internal {
@@ -63,6 +69,7 @@ impl Internal {
             Self::Validate => "validate".to_string(),
             Self::NextMatch => "next match".to_string(),
             Self::PreviousMatch => "previous match".to_string(),
+            Self::PlaySound(_) => "play sound".to_string(),
         }
     }
 }
@@ -93,6 +100,13 @@ impl fmt::Display for Internal {
             Self::Validate => write!(f, "validate"),
             Self::NextMatch => write!(f, "next-match"),
             Self::PreviousMatch => write!(f, "previous-match"),
+            Self::PlaySound(PlaySoundCommand { name, volume }) => {
+                write!(f, "play-sound(")?;
+                if let Some(name) = name {
+                    write!(f, "name={},", name)?;
+                }
+                write!(f, "volume={})", volume)
+            }
         }
     }
 }
@@ -125,7 +139,27 @@ impl std::str::FromStr for Internal {
             "next-match" => Ok(Self::NextMatch),
             "previous-match" => Ok(Self::PreviousMatch),
             "copy-unstyled-output" => Ok(Self::CopyUnstyledOutput),
-            _ => Err("invalid internal"),
+            "play-sound" => Ok(Self::PlaySound(PlaySoundCommand::default())),
+            _ => {
+                if let Some((_, props)) = regex_captures!(r"^play[_-]sound\((.*)\)$", s) {
+                    let iter = regex_captures_iter!(r"([^=,]+)=([^=,]+)", props);
+                    let mut volume = Volume::default();
+                    let mut name = None;
+                    for (_, [prop_name, prop_value]) in iter.map(|c| c.extract()) {
+                        match prop_name {
+                            "name" => {
+                                name = Some(prop_value.to_string());
+                            }
+                            "volume" => {
+                                volume = prop_value.parse()?;
+                            }
+                            _ => return Err("invalid internal"),
+                        }
+                    }
+                    return Ok(Self::PlaySound(PlaySoundCommand { name, volume }));
+                }
+                Err("invalid internal")
+            }
         }
     }
 }
@@ -152,6 +186,7 @@ impl<'de> Deserialize<'de> for Internal {
 
 #[test]
 fn test_internal_string_round_trip() {
+    use crate::Volume;
     let internals = [
         Internal::Back,
         Internal::FocusSearch,
@@ -174,8 +209,22 @@ fn test_internal_string_round_trip() {
         Internal::Validate,
         Internal::NextMatch,
         Internal::PreviousMatch,
+        Internal::PlaySound(PlaySoundCommand::default()),
+        Internal::PlaySound(PlaySoundCommand {
+            name: None,
+            volume: Volume::new(50),
+        }),
+        Internal::PlaySound(PlaySoundCommand {
+            name: Some("beep-beep".to_string()),
+            volume: Volume::new(100),
+        }),
+        Internal::PlaySound(PlaySoundCommand {
+            name: None,
+            volume: Volume::new(0),
+        }),
     ];
     for internal in internals {
+        println!("testing {:?}", internal.to_string());
         assert_eq!(internal.to_string().parse(), Ok(internal));
     }
 }
diff --git a/src/jobs/job.rs b/src/jobs/job.rs
index c7e9bbb..cfd18ba 100644
--- a/src/jobs/job.rs
+++ b/src/jobs/job.rs
@@ -85,9 +85,18 @@ pub struct Job {
 
     /// The optional action to run when there's no
     /// error, warning or test failures
+    /// (depending on whether allow_warnings is true or false)
+    ///
+    /// Could be made a vec in the future but that would involve
+    /// explaining subtleties like the fact that those actions stop
+    /// after the first one ending the mission or doing a refresh
     #[serde(default)]
     pub on_success: Option<Action>,
 
+    /// The optional action to run when it's not a success
+    #[serde(default)]
+    pub on_failure: Option<Action>,
+
     /// A list of directories that will be watched if the job
     /// is run on a package.
     /// src, examples, tests, and benches are implicitly included
@@ -126,6 +135,7 @@ impl Job {
             watch: None,
             need_stdout: false,
             on_success: None,
+            on_failure: None,
             allow_warnings: false,
             allow_failures: false,
             apply_gitignore: None,
diff --git a/src/lib.rs b/src/lib.rs
index 1354bcc..571df42 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -18,6 +18,7 @@ mod mission;
 mod result;
 mod scroll;
 mod search;
+mod sound;
 mod state;
 mod tty;
 mod watcher;
@@ -42,6 +43,7 @@ pub use {
     result::*,
     scroll::*,
     search::*,
+    sound::*,
     state::*,
     tty::*,
     watcher::*,
diff --git a/src/mission.rs b/src/mission.rs
index 53f6976..c51e3ad 100644
--- a/src/mission.rs
+++ b/src/mission.rs
@@ -49,11 +49,6 @@ impl Mission<'_> {
         set
     }
 
-    /// the action bound to success on this job
-    pub fn on_success(&self) -> &Option<Action> {
-        &self.job.on_success
-    }
-
     pub fn is_success(
         &self,
         report: &Report,
@@ -235,6 +230,20 @@ impl Mission<'_> {
             .or(self.settings.ignored_lines.as_ref())
             .filter(|p| !p.is_empty())
     }
+
+    pub fn sound_player_if_needed(&self) -> Option<SoundPlayer> {
+        if self.settings.sound.is_enabled() {
+            match SoundPlayer::new(self.settings.sound.get_base_volume()) {
+                Ok(sound_player) => Some(sound_player),
+                Err(e) => {
+                    warn!("Failed to initialise sound player: {e}");
+                    None
+                }
+            }
+        } else {
+            None
+        }
+    }
 }
 
 fn merge_features(
diff --git a/src/result/command_result.rs b/src/result/command_result.rs
index 8e6e60c..9b49a86 100644
--- a/src/result/command_result.rs
+++ b/src/result/command_result.rs
@@ -72,6 +72,9 @@ impl CommandResult {
 
     /// return true when the report has been computed and there's been no
     /// error, warning, or test failures
+    ///
+    /// This is different from the is_success that a mission can compute
+    /// from a report using its own settings (eg allow_warnings)
     pub fn is_success(&self) -> bool {
         match self {
             Self::Report(report) => {
diff --git a/src/sound/mod.rs b/src/sound/mod.rs
new file mode 100644
index 0000000..0f8c759
--- /dev/null
+++ b/src/sound/mod.rs
@@ -0,0 +1,27 @@
+#[cfg(not(feature = "sound"))]
+mod no_sound;
+#[cfg(feature = "sound")]
+mod play_sound;
+mod sound_config;
+#[cfg(feature = "sound")]
+mod sound_player;
+mod volume;
+
+#[cfg(not(feature = "sound"))]
+pub use no_sound::*;
+#[cfg(feature = "sound")]
+pub use {
+    play_sound::*,
+    sound_player::*,
+};
+pub use {
+    sound_config::*,
+    volume::*,
+};
+
+/// A command to play a sound
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
+pub struct PlaySoundCommand {
+    pub name: Option<String>,
+    pub volume: Volume,
+}
diff --git a/src/sound/no_sound.rs b/src/sound/no_sound.rs
new file mode 100644
index 0000000..f5f27dc
--- /dev/null
+++ b/src/sound/no_sound.rs
@@ -0,0 +1,17 @@
+use super::*;
+
+/// A dummy sound player which does nothing
+pub struct SoundPlayer {}
+impl SoundPlayer {
+    pub fn new(_base_volume: Volume) -> anyhow::Result<Self> {
+        Err(anyhow::anyhow!(
+            "Bacon is compiled without the sound feature"
+        ))
+    }
+    pub fn play(
+        &self,
+        _beep: PlaySoundCommand,
+    ) {
+        // should never be called as the  sound player is not instanciated
+    }
+}
diff --git a/src/sound/play_sound.rs b/src/sound/play_sound.rs
new file mode 100644
index 0000000..12fae05
--- /dev/null
+++ b/src/sound/play_sound.rs
@@ -0,0 +1,109 @@
+use {
+    super::*,
+    rodio::OutputStream,
+    std::{
+        io::Cursor,
+        thread,
+        time::Duration,
+    },
+};
+
+struct Sound {
+    bytes: &'static [u8],
+    duration: Duration,
+}
+
+/// Get a sound by name, or the default sound if name is None
+///
+/// Names here are as near as possible from the file names in the
+/// reources directory but without the number, syntax unconsistency and
+/// redundancy. Resource file names are kept identical to their original
+/// names to ease retrival for attribution).
+fn get_sound(name: Option<&str>) -> anyhow::Result<Sound> {
+    let name = name.unwrap_or("store-scanner");
+    let sound = match name {
+        "2" => Sound {
+            bytes: include_bytes!("../../resources/2-100419.mp3"),
+            duration: Duration::from_millis(2000),
+        },
+        "90s-game-ui-6" => Sound {
+            bytes: include_bytes!("../../resources/90s-game-ui-6-185099.mp3"),
+            duration: Duration::from_millis(1300),
+        },
+        "beep-6" => Sound {
+            bytes: include_bytes!("../../resources/beep-6-96243.mp3"),
+            duration: Duration::from_millis(1000),
+        },
+        "beep-beep" => Sound {
+            bytes: include_bytes!("../../resources/beep-beep-6151.mp3"),
+            duration: Duration::from_millis(1200),
+        },
+        "beep-warning" => Sound {
+            bytes: include_bytes!("../../resources/beep-warning-6387.mp3"),
+            duration: Duration::from_millis(1200),
+        },
+        "bell-chord" => Sound {
+            bytes: include_bytes!("../../resources/bell-chord1-83260.mp3"),
+            duration: Duration::from_millis(1900),
+        },
+        "car-horn" => Sound {
+            bytes: include_bytes!("../../resources/car-horn-beepsmp3-14659.mp3"),
+            duration: Duration::from_millis(1700),
+        },
+        "convenience-store-ring" => Sound {
+            bytes: include_bytes!("../../resources/conveniencestorering-96090.mp3"),
+            duration: Duration::from_millis(1700),
+        },
+        "cow-bells" => Sound {
+            bytes: include_bytes!("../../resources/cow_bells_01-98236.mp3"),
+            duration: Duration::from_millis(1400),
+        },
+        "pickup" => Sound {
+            bytes: include_bytes!("../../resources/pickup-sound-46472.mp3"),
+            duration: Duration::from_millis(500),
+        },
+        "positive-beeps" => Sound {
+            bytes: include_bytes!("../../resources/positive_beeps-85504.mp3"),
+            duration: Duration::from_millis(600),
+        },
+        "short-beep-tone" => Sound {
+            bytes: include_bytes!("../../resources/short-beep-tone-47916.mp3"),
+            duration: Duration::from_millis(400),
+        },
+        "slash" => Sound {
+            bytes: include_bytes!("../../resources/slash1-94367.mp3"),
+            duration: Duration::from_millis(800),
+        },
+        "store-scanner" => Sound {
+            bytes: include_bytes!("../../resources/store-scanner-beep-90395.mp3"),
+            duration: Duration::from_millis(250),
+        },
+        "success" => Sound {
+            bytes: include_bytes!("../../resources/success-48018.mp3"),
+            duration: Duration::from_millis(2000),
+        },
+        _ => {
+            anyhow::bail!("unknow sound name: {}", name)
+        }
+    };
+    Ok(sound)
+}
+
+/// Play the requested sound, sleeps for its duration
+///
+/// Apparently, rodio stream things
+/// - kill the sound as soon as they're dropped
+/// - can't be reused
+///
+/// So it's preferable not to call this directly from a working or
+/// UI thread but to use the SoundPlayer struct which manages a thread.
+pub fn play_sound(psc: &PlaySoundCommand) -> anyhow::Result<()> {
+    debug!("play sound: {:#?}", psc);
+    let Sound { bytes, duration } = get_sound(psc.name.as_deref())?;
+    let (_stream, stream_handle) = OutputStream::try_default()?;
+    let sound = Cursor::new(bytes);
+    let sink = stream_handle.play_once(sound)?;
+    sink.set_volume(psc.volume.as_part());
+    thread::sleep(duration);
+    Ok(())
+}
diff --git a/src/sound/sound_config.rs b/src/sound/sound_config.rs
new file mode 100644
index 0000000..81f4dc8
--- /dev/null
+++ b/src/sound/sound_config.rs
@@ -0,0 +1,30 @@
+use {
+    crate::*,
+    serde::Deserialize,
+};
+
+#[derive(Debug, Clone, Default, Deserialize)]
+pub struct SoundConfig {
+    pub enabled: Option<bool>,
+    pub base_volume: Option<Volume>,
+}
+
+impl SoundConfig {
+    pub fn apply(
+        &mut self,
+        sc: &SoundConfig,
+    ) {
+        if let Some(b) = sc.enabled {
+            self.enabled = Some(b);
+        }
+        if let Some(bv) = sc.base_volume {
+            self.base_volume = Some(bv);
+        }
+    }
+    pub fn is_enabled(&self) -> bool {
+        self.enabled.unwrap_or(false)
+    }
+    pub fn get_base_volume(&self) -> Volume {
+        self.base_volume.unwrap_or_default()
+    }
+}
diff --git a/src/sound/sound_player.rs b/src/sound/sound_player.rs
new file mode 100644
index 0000000..f8bd179
--- /dev/null
+++ b/src/sound/sound_player.rs
@@ -0,0 +1,90 @@
+use {
+    super::*,
+    std::thread,
+    termimad::crossbeam::channel::{
+        self,
+        Sender,
+    },
+};
+
+/// An instruction for the beeper
+#[derive(Debug, Clone)]
+enum Instruction {
+    PlaySound(PlaySoundCommand),
+    Die,
+}
+
+/// Manage a thread to play sounds without blocking bacon
+pub struct SoundPlayer {
+    thread: Option<thread::JoinHandle<()>>,
+    sender: Option<Sender<Instruction>>,
+}
+impl SoundPlayer {
+    pub fn new(base_volume: Volume) -> anyhow::Result<Self> {
+        let (sender, receiver) = channel::bounded(1);
+        let thread = thread::spawn(move || {
+            loop {
+                match receiver.recv() {
+                    Ok(Instruction::PlaySound(mut ps)) => {
+                        ps.volume = ps.volume * base_volume;
+                        if let Err(e) = play_sound(&ps) {
+                            error!("sound error: {}", e);
+                        }
+                    }
+                    Ok(Instruction::Die) => {
+                        info!("sound player thread is stopping");
+                        break;
+                    }
+                    Err(e) => {
+                        error!("sound player channel error: {}", e);
+                        break;
+                    }
+                }
+            }
+        });
+        Ok(Self {
+            thread: Some(thread),
+            sender: Some(sender),
+        })
+    }
+    /// Requests a beep, unless there's already one in the queue
+    /// (we don't want to beep too much)
+    pub fn beep(&self) {
+        if let Some(sender) = &self.sender {
+            info!("sending beep signal");
+            let _ = sender.try_send(Instruction::PlaySound(PlaySoundCommand::default()));
+        }
+    }
+    /// Requests a sound, unless there's already one in the queue
+    /// (we don't want to stack sounds)
+    pub fn play(
+        &self,
+        beep: PlaySoundCommand,
+    ) {
+        if let Some(sender) = &self.sender {
+            info!("sending beep command");
+            let _ = sender.try_send(Instruction::PlaySound(beep));
+        }
+    }
+    /// Make the beeper thread synchronously stop
+    /// (wait for the current sound to end)
+    pub fn die(&mut self) {
+        if let Some(sender) = self.sender.take() {
+            if let Err(e) = sender.send(Instruction::Die) {
+                warn!("failed to send 'kill' signal: {e}");
+            }
+        }
+        if let Some(thread) = self.thread.take() {
+            if thread.join().is_err() {
+                warn!("child_thread.join() failed"); // should not happen
+            } else {
+                info!("SoundPlayer gracefully stopped");
+            }
+        }
+    }
+}
+impl Drop for SoundPlayer {
+    fn drop(&mut self) {
+        self.die();
+    }
+}
diff --git a/src/sound/volume.rs b/src/sound/volume.rs
new file mode 100644
index 0000000..dfdb550
--- /dev/null
+++ b/src/sound/volume.rs
@@ -0,0 +1,88 @@
+use {
+    serde::{
+        Deserialize,
+        Deserializer,
+        Serialize,
+        Serializer,
+        de,
+    },
+    std::{
+        ops::Mul,
+        str::FromStr,
+    },
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct Volume {
+    /// Volume in [0, 100]
+    percent: u16, // u16 ensures we can multiply it without overflow
+}
+impl Default for Volume {
+    fn default() -> Self {
+        Self { percent: 100 }
+    }
+}
+impl Mul for Volume {
+    type Output = Self;
+    fn mul(
+        self,
+        rhs: Self,
+    ) -> Self {
+        Self {
+            percent: self.percent * rhs.percent / 100,
+        }
+    }
+}
+impl Volume {
+    pub fn new(percent: u16) -> Self {
+        Self {
+            percent: percent.clamp(0, 100),
+        }
+    }
+    /// Return the volume in [0, 100]
+    pub fn as_percent(self) -> u16 {
+        self.percent
+    }
+    /// Return the volume in [0, 1]
+    pub fn as_part(self) -> f32 {
+        self.percent as f32 / 100f32
+    }
+}
+impl FromStr for Volume {
+    type Err = &'static str;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let s = s.trim_end_matches('%');
+        let percent: u16 = s.parse().map_err(|_| "number in 0-100 expected")?;
+        Ok(Self {
+            percent: percent.clamp(0, 100),
+        })
+    }
+}
+impl std::fmt::Display for Volume {
+    fn fmt(
+        &self,
+        f: &mut std::fmt::Formatter<'_>,
+    ) -> std::fmt::Result {
+        write!(f, "{}%", self.percent)
+    }
+}
+impl Serialize for Volume {
+    fn serialize<S>(
+        &self,
+        serializer: S,
+    ) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        serializer.collect_str(self)
+    }
+}
+impl<'de> Deserialize<'de> for Volume {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        let s = String::deserialize(deserializer)?;
+        Self::from_str(&s).map_err(de::Error::custom)
+    }
+}
diff --git a/src/state.rs b/src/state.rs
index 1071049..13a89ee 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -717,20 +717,17 @@ impl<'s> AppState<'s> {
         )?;
         Ok(())
     }
-    /// the action to execute now
-    pub fn action(&self) -> Option<&Action> {
-        if let CommandResult::Report(report) = &self.cmd_result {
-            if self.mission.is_success(report) {
-                let on_success = self.mission.on_success().as_ref();
-                if on_success.is_some() {
-                    return on_success;
-                }
-            }
+    pub fn is_success(&self) -> bool {
+        match &self.cmd_result {
+            CommandResult::Report(report) => self.mission.is_success(report),
+            _ => false,
         }
-        if self.changes_since_last_job_start > 0 && self.auto_refresh.is_enabled() {
-            Some(&Action::Internal(Internal::ReRun))
-        } else {
-            None
+    }
+    pub fn is_failure(&self) -> bool {
+        match &self.cmd_result {
+            CommandResult::Report(report) => !self.mission.is_success(report),
+            CommandResult::Failure(_) => true,
+            CommandResult::None => false,
         }
     }
     /// Return the (unfiltered) set of lines to draw, depending on whether we wrap or not
diff --git a/website/docs/config.md b/website/docs/config.md
index 9d3ae17..5f18596 100644
--- a/website/docs/config.md
+++ b/website/docs/config.md
@@ -172,7 +172,7 @@ An export action is defined as `export:` followed by the export name.
 internal | default binding | meaning
 :-|:-|:-
 back | <kbd>Esc</kbd> | get back to the previous page or job, or cancel search
-copy_unstyled_output | | write the currently displayed job output to the clipboard
+copy-unstyled-output | | write the currently displayed job output to the clipboard
 help | <kbd>h</kbd> or <kbd>?</kbd> | open the help page
 quit | <kbd>q</kbd> or <kbd>ctrl</kbd><kbd>q</kbd> or <kbd>ctrl</kbd><kbd>c</kbd> | quit
 refresh | <kbd>F5</kbd> | clear output then run current job again
@@ -192,10 +192,11 @@ scroll-pages(1) | <kbd>PageDown</kbd> | move one page down
 pause |  | disable automatic job execution on change
 unpause |  | enable automatic job execution on change
 toggle pause | <kbd>p</kbd> | toggle pause
-focus_search | <kbd>/</kbd> | focus the search input
+focus-search | <kbd>/</kbd> | focus the search input
 validate | <kbd>enter</kbd> | unfocus the input, keeping the search
-next_match | <kbd>tab</kbd> | go to next search match
-previous_match | <kbd>backtab</kbd> | go to previous search match
+next-match | <kbd>tab</kbd> | go to next search match
+previous-match | <kbd>backtab</kbd> | go to previous search match
+play-sound |  | play a sound, eg `play-sound(volume=100%)`
 
 The `scroll-lines` and `scroll-pages` internals are parameterized.
 You can for example define a shortcut to move down half a page:
@@ -300,3 +301,23 @@ You can change the `summary`, `wrapping`, and `reverse` mode at launch (see `bac
 # reverse = true
 ```
 
+## Sound
+
+You may have audio notifications on job success or failures.
+
+This requires sound to be enabled:
+
+```TOML
+[sound]
+enabled = true
+base_volume = "100%" # global volume multiplier
+```
+
+Sound being enabled, you can add `play-sound` callbacks to jobs, eg
+
+```TOML
+on_success = "play-sound(name=90s-game-ui-6,volume=50)"
+on_failure = "play-sound(name=beep-warning,volume=100)"
+```
+
+Sound name can be ommited. Possible values are "2", "90s-game-ui-6", "beep-6", "beep-beep", "beep-warning", "bell-chord", "car-horn", "convenience-store-ring", "cow-bells", "pickup", "positive-beeps", "short-beep-tone", "slash", "store-scanner", "success".


Test patch: