budctlc98214a
Bass boost band by band in the desktop and Android apps
W6c passed: with bass boost on, the low band at +5 and at -5 sound definitely different, where the band-less writes changed nothing. The frames matched the spec byte for byte. - The band-less write is gone (Session::set_bass_level): the T310 ignores it. The worker's Bass command and answer carry the band. - budctl-gui: under Bass boost, three rows instead of one (low, medium and high frequencies), each with its own dotted slider and the level last set here. Each band keeps its own draft, so moving one band while another is saving loses nothing. The keyboard cursor steps through the three. - Android: the same three rows; the JSON bass command and event name the band. - The README's screenshots show the three rows.
+240 −148
17 files changed
- M CLAUDE.md +1 −1
- M README.md +2 −1
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt +21 −17
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/Buds.kt +3 −3
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/Overview.kt +11 −8
- M crates/budctl-android/src/json.rs +26 −4
- M crates/budctl-core/src/devices/mod.rs +2 −9
- M crates/budctl-core/src/devices/realme_t310/mod.rs +4 −22
- M crates/budctl-core/src/worker.rs +8 −8
- M crates/budctl-gui/src/buds.rs +34 −7
- M crates/budctl-gui/src/dots.rs +3 −2
- M crates/budctl-gui/src/main.rs +90 −47
- M crates/budctl-gui/src/overview.rs +32 −17
- M docs/protocol/realme_t310.md +3 −2
- M docs/screenshots/android-overview.png
- M docs/screenshots/keys.png
- M docs/screenshots/overview.png
| @@ -107,7 +107,7 @@ Exit codes: | |||
| 107 | 107 | 3. game mode | |
| 108 | 108 | 4. EQ preset | |
| 109 | 109 | 5. switches (bass boost, 3D sound, wind; dual connection last) | |
| 110 | − | 6. bass level | |
| 110 | + | 6. bass level (per band: low, medium, high) | |
| 111 | 111 | 7. find | |
| 112 | 112 | 8. touch controls | |
| 113 | 113 | 9. hold cycle | |
| @@ -84,7 +84,8 @@ Exit codes: | |||
| 84 | 84 | ||
| 85 | 85 | `budctl-gui` has five pages: | |
| 86 | 86 | ||
| 87 | − | - **Overview:** battery, noise control and its strength, the switches, the bass level. | |
| 87 | + | - **Overview:** battery, noise control and its strength, the switches, and bass boost band by band | |
| 88 | + | (low, medium and high frequencies). | |
| 88 | 89 | - **Equalizer:** the presets; create, rename and delete your own, and change their bands. | |
| 89 | 90 | - **Gestures:** what each tap and hold does on each bud, and what a hold steps through. | |
| 90 | 91 | - **Earbud settings:** dual connection and the devices it keeps, find my earbuds, firmware, audio codec. | |
| @@ -25,7 +25,8 @@ sealed interface Item { | |||
| 25 | 25 | ||
| 26 | 26 | data class Switch(val key: String) : Item | |
| 27 | 27 | ||
| 28 | − | data object Bass : Item | |
| 28 | + | /** One band of bass boost: `low`, `medium` or `high`. */ | |
| 29 | + | data class Bass(val band: String) : Item | |
| 29 | 30 | ||
| 30 | 31 | /** A preset chosen, saved, renamed or deleted. */ | |
| 31 | 32 | data class Preset(val id: Int) : Item | |
| @@ -90,8 +91,8 @@ class App( | |||
| 90 | 91 | var ancLevel by mutableStateOf("deep") | |
| 91 | 92 | private set | |
| 92 | 93 | ||
| 93 | − | /** A bass level moved on the screen and not set on the buds yet. */ | |
| 94 | − | var bassDraft by mutableStateOf<Int?>(null) | |
| 94 | + | /** Bass levels moved on the screen and not set on the buds yet, by band. */ | |
| 95 | + | var bassDraft by mutableStateOf<Map<String, Int>>(emptyMap()) | |
| 95 | 96 | private set | |
| 96 | 97 | ||
| 97 | 98 | /** "Turn dual connection off?" is open. */ | |
| @@ -159,18 +160,19 @@ class App( | |||
| 159 | 160 | if (yes) command(Item.Switch(DUAL), JSONObject().put("command", "switch").put("key", DUAL).put("on", false)) | |
| 160 | 161 | } | |
| 161 | 162 | ||
| 162 | − | /** The bass level slider moved: shown at once, written when the finger lifts ([saveBass]). */ | |
| 163 | − | fun moveBass(level: Int) { | |
| 164 | − | bassDraft = level | |
| 163 | + | /** A band's bass slider moved: shown at once, written when the finger lifts ([saveBass]). */ | |
| 164 | + | fun moveBass(band: String, level: Int) { | |
| 165 | + | bassDraft = bassDraft + (band to level) | |
| 165 | 166 | } | |
| 166 | 167 | ||
| 167 | − | fun saveBass() { | |
| 168 | − | val level = bassDraft ?: return | |
| 169 | − | if (level == buds.bass) { | |
| 170 | − | bassDraft = null | |
| 168 | + | /** Writes a band's moved level. If one is on its way for that band, this one goes after it. */ | |
| 169 | + | fun saveBass(band: String) { | |
| 170 | + | val level = bassDraft[band] ?: return | |
| 171 | + | if (level == buds.bass[band]) { | |
| 172 | + | bassDraft = bassDraft - band | |
| 171 | 173 | return | |
| 172 | 174 | } | |
| 173 | − | command(Item.Bass, JSONObject().put("command", "bass").put("level", level)) | |
| 175 | + | command(Item.Bass(band), JSONObject().put("command", "bass").put("band", band).put("level", level)) | |
| 174 | 176 | } | |
| 175 | 177 | ||
| 176 | 178 | fun selectPreset(id: Int) { | |
| @@ -374,16 +376,18 @@ class App( | |||
| 374 | 376 | connection = Connection.Live | |
| 375 | 377 | } | |
| 376 | 378 | "bass" -> { | |
| 379 | + | val band = event.getString("band") | |
| 377 | 380 | val level = event.getInt("level") | |
| 378 | − | buds = buds.copy(bass = level) | |
| 379 | − | if (bassDraft == level) bassDraft = null | |
| 381 | + | buds = buds.copy(bass = buds.bass + (band to level)) | |
| 380 | 382 | connection = Connection.Live | |
| 383 | + | // A level moved while this one was on its way goes out now. | |
| 384 | + | if (bassDraft[band] == level) bassDraft = bassDraft - band else saveBass(band) | |
| 381 | 385 | } | |
| 382 | 386 | "failed" -> { | |
| 383 | 387 | val kind = event.getString("kind") | |
| 384 | 388 | connection = Connection.Failed(kind, event.getString("message")) | |
| 385 | 389 | if (kind == "not-found") buds = buds.copy(connected = false) | |
| 386 | − | if (item == Item.Bass) bassDraft = null | |
| 390 | + | if (item is Item.Bass) bassDraft = bassDraft - item.band | |
| 387 | 391 | // The buds keep what they had: show that, not what was picked. | |
| 388 | 392 | if (item is Item.Preset && draft?.preset == item.id) draft = null | |
| 389 | 393 | if (item is Item.Gesture && touchDraft?.let { Item.Gesture(it.side, it.gesture) } == item) touchDraft = null | |
| @@ -407,9 +411,9 @@ class App( | |||
| 407 | 411 | val on = json.getBoolean("on") | |
| 408 | 412 | buds = buds.copy(switches = buds.switches.map { if (it.key == item.key) it.copy(on = on) else it }) | |
| 409 | 413 | } | |
| 410 | − | Item.Bass -> { | |
| 411 | − | buds = buds.copy(bass = json.getInt("level")) | |
| 412 | − | bassDraft = null | |
| 414 | + | is Item.Bass -> { | |
| 415 | + | buds = buds.copy(bass = buds.bass + (item.band to json.getInt("level"))) | |
| 416 | + | bassDraft = bassDraft - item.band | |
| 413 | 417 | } | |
| 414 | 418 | is Item.Preset, Item.NewPreset -> pretendEq(json) | |
| 415 | 419 | is Item.Gesture -> { | |
| @@ -57,10 +57,10 @@ data class Buds( | |||
| 57 | 57 | val firmware: String? = null, | |
| 58 | 58 | val presets: EqPresets? = null, | |
| 59 | 59 | val touch: TouchControls? = null, | |
| 60 | − | /** The levels the bass level takes, when the buds have one to set. */ | |
| 60 | + | /** The levels each band of bass boost takes, when the buds have bass levels to set. */ | |
| 61 | 61 | val bassRange: IntRange? = null, | |
| 62 | − | /** The bass level last set from this app: the buds do not report it. */ | |
| 63 | − | val bass: Int? = null, | |
| 62 | + | /** The level of each band of bass boost (`low`, `medium`, `high`) last set from this app: the buds do not report them. */ | |
| 63 | + | val bass: Map<String, Int> = emptyMap(), | |
| 64 | 64 | val dual: List<DualDevice>? = null, | |
| 65 | 65 | ) { | |
| 66 | 66 | /** The name to show for the EQ preset in use (crates/budctl-gui/src/buds.rs, `eq_name`). */ | |
| @@ -111,26 +111,29 @@ private fun Switches(app: App) { | |||
| 111 | 111 | if (app.saving(Item.Switch(s.key))) SAVING else known?.third, | |
| 112 | 112 | onClick = { app.switch(s.key, !s.on) }, | |
| 113 | 113 | ) { Toggle(s.on) } | |
| 114 | − | if (s.key == "bass-boost") buds.bassRange?.let { BassLevel(app, it) } | |
| 114 | + | if (s.key == "bass-boost") buds.bassRange?.let { range -> for ((band, title) in BASS_BANDS) BassLevel(app, range, band, title) } | |
| 115 | 115 | } | |
| 116 | 116 | } | |
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | − | /** The bass level: the buds take it but do not report it, so the row shows the last level set here. */ | |
| 119 | + | /** The bands of bass boost, as the buds and the rows name them. */ | |
| 120 | + | private val BASS_BANDS = listOf("low" to "Low frequencies", "medium" to "Medium frequencies", "high" to "High frequencies") | |
| 121 | + | ||
| 122 | + | /** One band of bass boost: the buds take its level but do not report it, so the row shows the last level set here. */ | |
| 120 | 123 | @Composable | |
| 121 | − | private fun BassLevel(app: App, range: IntRange) { | |
| 122 | − | val level = app.bassDraft ?: app.buds.bass | |
| 124 | + | private fun BassLevel(app: App, range: IntRange, band: String, title: String) { | |
| 125 | + | val level = app.bassDraft[band] ?: app.buds.bass[band] | |
| 123 | 126 | SettingRow( | |
| 124 | 127 | Icon.Bass, | |
| 125 | − | "Bass level", | |
| 126 | − | if (app.saving(Item.Bass)) SAVING else "Last set here: the buds do not report it", | |
| 128 | + | title, | |
| 129 | + | if (app.saving(Item.Bass(band))) SAVING else "Bass boost, last set here", | |
| 127 | 130 | below = { | |
| 128 | 131 | DotSlider( | |
| 129 | 132 | range.first, | |
| 130 | 133 | range.last, | |
| 131 | 134 | level ?: 0, | |
| 132 | − | change = app::moveBass, | |
| 133 | − | release = app::saveBass, | |
| 135 | + | change = { app.moveBass(band, it) }, | |
| 136 | + | release = { app.saveBass(band) }, | |
| 134 | 137 | modifier = Modifier.padding(start = 52.dp, top = 6.dp), | |
| 135 | 138 | ) | |
| 136 | 139 | }, | |
| @@ -4,7 +4,7 @@ | |||
| 4 | 4 | //! as `{"event": "noise", "mode": "anc", "level": "deep"}`. The values are the status types the | |
| 5 | 5 | //! CLI's `--json` writes, except that switches keep their labels. | |
| 6 | 6 | ||
| 7 | − | use budctl_core::status::{AncLevel, EqBand, NoiseMode, Status}; | |
| 7 | + | use budctl_core::status::{AncLevel, BassBand, EqBand, NoiseMode, Status}; | |
| 8 | 8 | use budctl_core::worker::{Command, Event}; | |
| 9 | 9 | use budctl_core::{Error, Kind, PairedDevice, Result}; | |
| 10 | 10 | use serde::Deserialize; | |
| @@ -44,7 +44,9 @@ enum Request { | |||
| 44 | 44 | Find { | |
| 45 | 45 | ring: bool, | |
| 46 | 46 | }, | |
| 47 | + | /// One band of bass boost: `low`, `medium` or `high`. | |
| 47 | 48 | Bass { | |
| 49 | + | band: String, | |
| 48 | 50 | level: i8, | |
| 49 | 51 | }, | |
| 50 | 52 | CreateEq { | |
| @@ -87,13 +89,20 @@ pub fn command(json: &str) -> Result<Command> { | |||
| 87 | 89 | Request::Touch { side, gesture, action } => Command::Touch { side, gesture, action }, | |
| 88 | 90 | Request::HoldCycle { modes } => Command::HoldCycle(modes), | |
| 89 | 91 | Request::Find { ring } => Command::Find(ring), | |
| 90 | − | Request::Bass { level } => Command::Bass(level), | |
| 92 | + | Request::Bass { band, level } => Command::Bass(bass_band(&band)?, level), | |
| 91 | 93 | Request::CreateEq { name } => Command::CreateEq(name), | |
| 92 | 94 | Request::RenameEq { preset, name } => Command::RenameEq(preset, name), | |
| 93 | 95 | Request::DeleteEq { preset } => Command::DeleteEq(preset), | |
| 94 | 96 | }) | |
| 95 | 97 | } | |
| 96 | 98 | ||
| 99 | + | fn bass_band(name: &str) -> Result<BassBand> { | |
| 100 | + | BassBand::ALL | |
| 101 | + | .into_iter() | |
| 102 | + | .find(|band| band.name() == name) | |
| 103 | + | .ok_or_else(|| Error::Usage(format!("no bass band {name:?}: low, medium or high"))) | |
| 104 | + | } | |
| 105 | + | ||
| 97 | 106 | /// `anc` with no level is the deepest, as `budctl anc on` is. | |
| 98 | 107 | fn noise_mode(mode: &str, level: Option<&str>) -> Result<NoiseMode> { | |
| 99 | 108 | match (mode, level) { | |
| @@ -132,7 +141,10 @@ pub fn event(event: &Event) -> serde_json::Result<String> { | |||
| 132 | 141 | Event::Touch(gestures) => ("touch", vec![("gestures", to_value(gestures)?)]), | |
| 133 | 142 | Event::HoldCycle(modes) => ("hold-cycle", vec![("modes", to_value(modes)?)]), | |
| 134 | 143 | Event::Ringing(ring) => ("ringing", vec![("ring", Value::from(*ring))]), | |
| 135 | − | Event::Bass(level) => ("bass", vec![("level", Value::from(*level))]), | |
| 144 | + | Event::Bass(band, level) => ( | |
| 145 | + | "bass", | |
| 146 | + | vec![("band", Value::from(band.name())), ("level", Value::from(*level))], | |
| 147 | + | ), | |
| 136 | 148 | Event::Presets(eq) => ("presets", vec![("eq", to_value(eq)?)]), | |
| 137 | 149 | Event::Failed { kind, message } => ( | |
| 138 | 150 | "failed", | |
| @@ -251,6 +263,10 @@ mod tests { | |||
| 251 | 263 | command(r#"{"command": "hold-cycle", "modes": ["off", "anc"]}"#), | |
| 252 | 264 | Ok(Command::HoldCycle(modes)) if modes == ["off", "anc"] | |
| 253 | 265 | )); | |
| 266 | + | assert!(matches!( | |
| 267 | + | command(r#"{"command": "bass", "band": "medium", "level": -2}"#), | |
| 268 | + | Ok(Command::Bass(BassBand::Medium, -2)) | |
| 269 | + | )); | |
| 254 | 270 | } | |
| 255 | 271 | ||
| 256 | 272 | #[test] | |
| @@ -259,7 +275,9 @@ mod tests { | |||
| 259 | 275 | r#"{"command": "reboot"}"#, | |
| 260 | 276 | r#"{"command": "noise", "mode": "loud"}"#, | |
| 261 | 277 | r#"{"command": "noise", "mode": "off", "level": "deep"}"#, | |
| 262 | − | r#"{"command": "bass", "level": 300}"#, | |
| 278 | + | r#"{"command": "bass", "band": "low", "level": 300}"#, | |
| 279 | + | r#"{"command": "bass", "band": "deep", "level": 1}"#, | |
| 280 | + | r#"{"command": "bass", "level": 1}"#, | |
| 263 | 281 | "not json", | |
| 264 | 282 | ] { | |
| 265 | 283 | assert_eq!(command(json).err().map(|e| e.kind()), Some(Kind::Usage), "{json}"); | |
| @@ -293,6 +311,10 @@ mod tests { | |||
| 293 | 311 | parse(&Event::Ringing(true)), | |
| 294 | 312 | serde_json::json!({"event": "ringing", "ring": true}) | |
| 295 | 313 | ); | |
| 314 | + | assert_eq!( | |
| 315 | + | parse(&Event::Bass(BassBand::High, 3)), | |
| 316 | + | serde_json::json!({"event": "bass", "band": "high", "level": 3}) | |
| 317 | + | ); | |
| 296 | 318 | } | |
| 297 | 319 | ||
| 298 | 320 | #[test] | |
| @@ -81,8 +81,8 @@ pub trait Session: Send { | |||
| 81 | 81 | ))) | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | − | /// The lowest and the highest level [`Session::set_bass_level`] takes, when the buds have a | |
| 85 | − | /// bass level to set. | |
| 84 | + | /// The lowest and the highest level [`Session::set_bass_band`] takes, when the buds have bass | |
| 85 | + | /// levels to set. | |
| 86 | 86 | fn bass_range(&self) -> Option<(i8, i8)> { | |
| 87 | 87 | None | |
| 88 | 88 | } | |
| @@ -111,13 +111,6 @@ pub trait Session: Send { | |||
| 111 | 111 | ))) | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | − | /// Set the bass level. Only the ack can be checked when the buds cannot report the level. | |
| 115 | − | fn set_bass_level(&mut self, _level: i8) -> Result<()> { | |
| 116 | − | Err(Error::Unsupported( | |
| 117 | − | "budctl cannot change the bass level on these buds".to_owned(), | |
| 118 | − | )) | |
| 119 | − | } | |
| 120 | − | ||
| 121 | 114 | /// Set the level of one band of bass boost, on buds that set it band by band. Only the ack can | |
| 122 | 115 | /// be checked when the buds cannot report the level. | |
| 123 | 116 | fn set_bass_band(&mut self, _band: BassBand, _level: i8) -> Result<()> { | |
| @@ -503,16 +503,8 @@ impl Session for T310Session { | |||
| 503 | 503 | .then_some(codec::BASS_RANGE) | |
| 504 | 504 | } | |
| 505 | 505 | ||
| 506 | − | fn set_bass_level(&mut self, level: i8) -> Result<()> { | |
| 507 | − | let payload = codec::bass_write_payload(level)?; | |
| 508 | − | if !self.commands.allows(codec::BASS_LEVEL_WRITE) { | |
| 509 | − | return Err(Error::Unsupported("these buds do not offer a bass level".to_owned())); | |
| 510 | − | } | |
| 511 | − | let reply = self.link.request(codec::BASS_LEVEL_WRITE, &payload)?; | |
| 512 | − | codec::check_write_reply(&reply, "the bass-level change") | |
| 513 | − | } | |
| 514 | − | ||
| 515 | − | /// Ack-only too: the T310 never answers the bass-level read. Spec: realme_t310.md#bass-bands | |
| 506 | + | /// Ack-only: the T310 never answers the bass-level read. A level without its band is ignored, | |
| 507 | + | /// so there is no band-less write. Spec: realme_t310.md#bass-bands | |
| 516 | 508 | fn set_bass_band(&mut self, band: BassBand, level: i8) -> Result<()> { | |
| 517 | 509 | let payload = codec::bass_band_payload(band, level)?; | |
| 518 | 510 | if !self.commands.allows(codec::BASS_LEVEL_WRITE) { | |
| @@ -1357,17 +1349,6 @@ mod tests { | |||
| 1357 | 1349 | assert!(eq.warnings.is_empty()); | |
| 1358 | 1350 | } | |
| 1359 | 1351 | ||
| 1360 | − | #[test] | |
| 1361 | − | fn sets_the_bass_level() { | |
| 1362 | − | let mut steps = handshake(); | |
| 1363 | − | steps.extend([tx(0x041B, 0x05, "FB 05 FE"), rx(0x841B, 0x05, "00")]); | |
| 1364 | − | let script = Script::new(steps); | |
| 1365 | − | let mut session = open(&script).unwrap(); | |
| 1366 | − | assert_eq!(session.bass_range(), Some((-5, 5))); | |
| 1367 | − | session.set_bass_level(-2).unwrap(); | |
| 1368 | − | script.assert_done(); | |
| 1369 | − | } | |
| 1370 | − | ||
| 1371 | 1352 | #[test] | |
| 1372 | 1353 | fn sets_a_bass_band_with_its_band_byte() { | |
| 1373 | 1354 | let mut steps = handshake(); | |
| @@ -1379,6 +1360,7 @@ mod tests { | |||
| 1379 | 1360 | ]); | |
| 1380 | 1361 | let script = Script::new(steps); | |
| 1381 | 1362 | let mut session = open(&script).unwrap(); | |
| 1363 | + | assert_eq!(session.bass_range(), Some((-5, 5))); | |
| 1382 | 1364 | session.set_bass_band(BassBand::Low, 5).unwrap(); | |
| 1383 | 1365 | session.set_bass_band(BassBand::High, -5).unwrap(); | |
| 1384 | 1366 | script.assert_done(); | |
| @@ -1399,7 +1381,7 @@ mod tests { | |||
| 1399 | 1381 | let mut steps = handshake(); | |
| 1400 | 1382 | steps.pop(); | |
| 1401 | 1383 | let script = Script::new(steps); | |
| 1402 | − | let result = open(&script).unwrap().set_bass_level(6); | |
| 1384 | + | let result = open(&script).unwrap().set_bass_band(BassBand::Low, 6); | |
| 1403 | 1385 | script.assert_done(); | |
| 1404 | 1386 | assert!(matches!(result, Err(Error::Usage(_)))); | |
| 1405 | 1387 | } | |
| @@ -13,7 +13,7 @@ use std::time::{Duration, Instant}; | |||
| 13 | 13 | use crate::devices::Session; | |
| 14 | 14 | use crate::error::{Error, Kind, Result}; | |
| 15 | 15 | use crate::status::{ | |
| 16 | − | DualDevice, EqBand, EqPreset, EqPresets, Gesture, NoiseControl, NoiseMode, Status, Switch, TouchControls, | |
| 16 | + | BassBand, DualDevice, EqBand, EqPreset, EqPresets, Gesture, NoiseControl, NoiseMode, Status, Switch, TouchControls, | |
| 17 | 17 | }; | |
| 18 | 18 | ||
| 19 | 19 | /// How long the session stays open after the last command, unless the front end says otherwise. | |
| @@ -43,8 +43,8 @@ pub enum Command { | |||
| 43 | 43 | HoldCycle(Vec<String>), | |
| 44 | 44 | /// Start (`true`) or stop ringing the buds. | |
| 45 | 45 | Find(bool), | |
| 46 | − | /// Set the bass level. | |
| 47 | − | Bass(i8), | |
| 46 | + | /// Set the bass level of one band of bass boost. | |
| 47 | + | Bass(BassBand, i8), | |
| 48 | 48 | /// Create a custom EQ preset with this name. | |
| 49 | 49 | CreateEq(String), | |
| 50 | 50 | /// Give a custom EQ preset (`custom-<id>`) a new name. | |
| @@ -73,8 +73,8 @@ pub enum Event { | |||
| 73 | 73 | HoldCycle(Vec<String>), | |
| 74 | 74 | /// The answer to [`Command::Find`]: whether the buds were told to ring. | |
| 75 | 75 | Ringing(bool), | |
| 76 | − | /// The answer to [`Command::Bass`]: the level the buds took. | |
| 77 | − | Bass(i8), | |
| 76 | + | /// The answer to [`Command::Bass`]: the band, and the level the buds took for it. | |
| 77 | + | Bass(BassBand, i8), | |
| 78 | 78 | /// The answer to [`Command::CreateEq`] and [`Command::DeleteEq`]: the whole preset list after | |
| 79 | 79 | /// it (ids can change). | |
| 80 | 80 | Presets(EqPresets), | |
| @@ -226,10 +226,10 @@ impl Worker { | |||
| 226 | 226 | .session() | |
| 227 | 227 | .and_then(|session| session.rename_custom_eq(&preset, &name)) | |
| 228 | 228 | .map(Event::CustomEq), | |
| 229 | − | Command::Bass(level) => self | |
| 229 | + | Command::Bass(band, level) => self | |
| 230 | 230 | .session() | |
| 231 | − | .and_then(|session| session.set_bass_level(level)) | |
| 232 | − | .map(|()| Event::Bass(level)), | |
| 231 | + | .and_then(|session| session.set_bass_band(band, level)) | |
| 232 | + | .map(|()| Event::Bass(band, level)), | |
| 233 | 233 | Command::Find(ring) => self | |
| 234 | 234 | .session() | |
| 235 | 235 | .and_then(|session| session.find(ring)) | |
| @@ -3,10 +3,37 @@ | |||
| 3 | 3 | ||
| 4 | 4 | use budctl_core::bt::BdAddr; | |
| 5 | 5 | use budctl_core::status::{ | |
| 6 | − | Battery, Charge, DualDevice, EqBand, EqPreset, EqPresets, Gesture, GestureChoice, NoiseControl, Status, Switch, | |
| 7 | − | TouchControls, | |
| 6 | + | BassBand, Battery, Charge, DualDevice, EqBand, EqPreset, EqPresets, Gesture, GestureChoice, NoiseControl, Status, | |
| 7 | + | Switch, TouchControls, | |
| 8 | 8 | }; | |
| 9 | 9 | ||
| 10 | + | /// A bass level for each band of bass boost, where one is known. | |
| 11 | + | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] | |
| 12 | + | pub struct BassLevels { | |
| 13 | + | low: Option<i8>, | |
| 14 | + | medium: Option<i8>, | |
| 15 | + | high: Option<i8>, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | impl BassLevels { | |
| 19 | + | pub fn get(self, band: BassBand) -> Option<i8> { | |
| 20 | + | match band { | |
| 21 | + | BassBand::Low => self.low, | |
| 22 | + | BassBand::Medium => self.medium, | |
| 23 | + | BassBand::High => self.high, | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | pub fn set(&mut self, band: BassBand, level: Option<i8>) { | |
| 28 | + | let slot = match band { | |
| 29 | + | BassBand::Low => &mut self.low, | |
| 30 | + | BassBand::Medium => &mut self.medium, | |
| 31 | + | BassBand::High => &mut self.high, | |
| 32 | + | }; | |
| 33 | + | *slot = level; | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 10 | 37 | /// The buds the window shows. | |
| 11 | 38 | pub struct Buds { | |
| 12 | 39 | pub name: String, | |
| @@ -16,10 +43,10 @@ pub struct Buds { | |||
| 16 | 43 | pub eq: Option<EqPresets>, | |
| 17 | 44 | /// The touch controls, when the buds report them. | |
| 18 | 45 | pub touch: Option<TouchControls>, | |
| 19 | − | /// The levels the bass level takes, when the buds have one to set. | |
| 46 | + | /// The levels each band of bass boost takes, when the buds have bass levels to set. | |
| 20 | 47 | pub bass_range: Option<(i8, i8)>, | |
| 21 | − | /// The bass level last set from this window. The buds do not report it. | |
| 22 | − | pub bass: Option<i8>, | |
| 48 | + | /// The bass level of each band, as last set from this window. The buds do not report them. | |
| 49 | + | pub bass: BassLevels, | |
| 23 | 50 | /// The devices dual connection keeps, when the buds list them. | |
| 24 | 51 | pub dual: Option<Vec<DualDevice>>, | |
| 25 | 52 | } | |
| @@ -185,7 +212,7 @@ pub fn demo() -> Buds { | |||
| 185 | 212 | warnings: Vec::new(), | |
| 186 | 213 | }), | |
| 187 | 214 | bass_range: Some((-5, 5)), | |
| 188 | − | bass: None, | |
| 215 | + | bass: BassLevels::default(), | |
| 189 | 216 | dual: Some(vec![ | |
| 190 | 217 | DualDevice { | |
| 191 | 218 | address: BdAddr::new([0x2B, 0x1A, 0x00, 0xAA, 0xBB, 0xCC]), | |
| @@ -216,7 +243,7 @@ pub fn unknown() -> Buds { | |||
| 216 | 243 | eq: None, | |
| 217 | 244 | touch: None, | |
| 218 | 245 | bass_range: None, | |
| 219 | − | bass: None, | |
| 246 | + | bass: BassLevels::default(), | |
| 220 | 247 | dual: None, | |
| 221 | 248 | } | |
| 222 | 249 | } | |
| @@ -633,6 +633,7 @@ fn steps(since: Instant, every: Duration, now: Instant) -> (u128, Option<Instant | |||
| 633 | 633 | #[cfg(test)] | |
| 634 | 634 | mod tests { | |
| 635 | 635 | use super::*; | |
| 636 | + | use budctl_core::status::BassBand; | |
| 636 | 637 | ||
| 637 | 638 | fn slider(low: i16, high: i16) -> Slider<fn(i16, bool) -> Message> { | |
| 638 | 639 | Slider { | |
| @@ -640,8 +641,8 @@ mod tests { | |||
| 640 | 641 | high, | |
| 641 | 642 | value: 0, | |
| 642 | 643 | vertical: false, | |
| 643 | − | change: |_, _| Message::SaveBass, | |
| 644 | − | release: Message::SaveBass, | |
| 644 | + | change: |_, _| Message::SaveBass(BassBand::Low), | |
| 645 | + | release: Message::SaveBass(BassBand::Low), | |
| 645 | 646 | on: Color::WHITE, | |
| 646 | 647 | off: Color::BLACK, | |
| 647 | 648 | motion: false, | |
| @@ -28,12 +28,13 @@ use std::time::{Duration, Instant}; | |||
| 28 | 28 | ||
| 29 | 29 | use budctl_core::Kind; | |
| 30 | 30 | use budctl_core::bt::BdAddr; | |
| 31 | − | use budctl_core::status::{AncLevel, EqBand, EqPreset, NoiseControl, NoiseMode, Switch}; | |
| 31 | + | use budctl_core::status::{AncLevel, BassBand, EqBand, EqPreset, NoiseControl, NoiseMode, Switch}; | |
| 32 | 32 | use iced::keyboard::{self, Key, Modifiers}; | |
| 33 | 33 | use iced::widget::operation::{self, RelativeOffset}; | |
| 34 | 34 | use iced::widget::{Column, column, container, mouse_area, opaque, row, scrollable, space, stack, text, text_input}; | |
| 35 | 35 | use iced::{Alignment, Border, Element, Length, Size, Subscription, Task, Theme, event, mouse, window}; | |
| 36 | 36 | ||
| 37 | + | use buds::BassLevels; | |
| 37 | 38 | use keys::Action; | |
| 38 | 39 | use theme::Look; | |
| 39 | 40 | use worker::{Command, Event, Reading}; | |
| @@ -268,8 +269,8 @@ enum Item { | |||
| 268 | 269 | Cycle(u8), | |
| 269 | 270 | /// Find my earbuds. | |
| 270 | 271 | Find, | |
| 271 | − | /// The bass level. | |
| 272 | − | Bass, | |
| 272 | + | /// One band's bass level. | |
| 273 | + | Bass(BassBand), | |
| 273 | 274 | /// "New preset", on the Equalizer page. | |
| 274 | 275 | NewPreset, | |
| 275 | 276 | /// "Rename", on a custom EQ preset's card. | |
| @@ -400,13 +401,14 @@ enum Message { | |||
| 400 | 401 | Find, | |
| 401 | 402 | /// The answer to "Ring the buds?". | |
| 402 | 403 | ConfirmFind(bool), | |
| 403 | − | /// Move the bass level. With `save`, set it on the buds right away, else when | |
| 404 | + | /// Move one band's bass level. With `save`, set it on the buds right away, else when | |
| 404 | 405 | /// [`Message::SaveBass`] comes (the mouse lets go of the slider). | |
| 405 | 406 | Bass { | |
| 407 | + | band: BassBand, | |
| 406 | 408 | level: i8, | |
| 407 | 409 | save: bool, | |
| 408 | 410 | }, | |
| 409 | − | SaveBass, | |
| 411 | + | SaveBass(BassBand), | |
| 410 | 412 | /// Ask for a new preset's name. | |
| 411 | 413 | NewPreset, | |
| 412 | 414 | /// Ask for a custom preset's (by its id) new name. | |
| @@ -473,8 +475,8 @@ struct App { | |||
| 473 | 475 | draft: Option<Draft>, | |
| 474 | 476 | /// A gesture's action picked and not written yet. | |
| 475 | 477 | touch_draft: Option<TouchDraft>, | |
| 476 | − | /// A bass level moved and not set yet. | |
| 477 | − | bass_draft: Option<i8>, | |
| 478 | + | /// Bass levels moved and not set yet, band by band. | |
| 479 | + | bass_draft: BassLevels, | |
| 478 | 480 | /// The buds were told to ring and not yet to stop. | |
| 479 | 481 | ringing: bool, | |
| 480 | 482 | /// "Ring the buds?" is open. | |
| @@ -535,7 +537,7 @@ impl App { | |||
| 535 | 537 | read_at: None, | |
| 536 | 538 | draft: None, | |
| 537 | 539 | touch_draft: None, | |
| 538 | − | bass_draft: None, | |
| 540 | + | bass_draft: BassLevels::default(), | |
| 539 | 541 | ringing: false, | |
| 540 | 542 | confirm_find: false, | |
| 541 | 543 | palette_problem: None, | |
| @@ -602,14 +604,14 @@ impl App { | |||
| 602 | 604 | } | |
| 603 | 605 | } | |
| 604 | 606 | Message::ShowHelp => self.help = true, | |
| 605 | − | Message::Bass { level, save } => { | |
| 607 | + | Message::Bass { band, level, save } => { | |
| 606 | 608 | let (low, high) = self.buds.bass_range.unwrap_or((i8::MIN, i8::MAX)); | |
| 607 | − | self.bass_draft = Some(level.clamp(low, high)); | |
| 609 | + | self.bass_draft.set(band, Some(level.clamp(low, high))); | |
| 608 | 610 | if save { | |
| 609 | − | self.save_bass(); | |
| 611 | + | self.save_bass(band); | |
| 610 | 612 | } | |
| 611 | 613 | } | |
| 612 | − | Message::SaveBass => self.save_bass(), | |
| 614 | + | Message::SaveBass(band) => self.save_bass(band), | |
| 613 | 615 | Message::NewPreset => return self.ask_name(NameTarget::New, String::new()), | |
| 614 | 616 | Message::RenamePreset(id) => { | |
| 615 | 617 | let name = self.buds.preset(id).map(|p| p.name.clone()).unwrap_or_default(); | |
| @@ -802,9 +804,9 @@ impl App { | |||
| 802 | 804 | } | |
| 803 | 805 | } | |
| 804 | 806 | Command::Find(ring) => self.ringing = ring, | |
| 805 | − | Command::Bass(level) => { | |
| 806 | − | self.buds.bass = Some(level); | |
| 807 | − | self.bass_draft = None; | |
| 807 | + | Command::Bass(band, level) => { | |
| 808 | + | self.buds.bass.set(band, Some(level)); | |
| 809 | + | self.bass_draft.set(band, None); | |
| 808 | 810 | } | |
| 809 | 811 | Command::CreateEq(name) => { | |
| 810 | 812 | let customs = || { | |
| @@ -958,12 +960,12 @@ impl App { | |||
| 958 | 960 | self.editing = equalizer::in_use(&self.buds).map(|preset| preset.id); | |
| 959 | 961 | } | |
| 960 | 962 | } | |
| 961 | − | Event::Bass(level) => { | |
| 963 | + | Event::Bass(band, level) => { | |
| 962 | 964 | self.in_flight.pop_front(); | |
| 963 | − | self.buds.bass = Some(level); | |
| 965 | + | self.buds.bass.set(band, Some(level)); | |
| 964 | 966 | self.link = Link::Live; | |
| 965 | 967 | // A level moved while this one was on its way goes out now. | |
| 966 | − | self.save_bass(); | |
| 968 | + | self.save_bass(band); | |
| 967 | 969 | } | |
| 968 | 970 | Event::Ringing(ring) => { | |
| 969 | 971 | self.in_flight.pop_front(); | |
| @@ -986,7 +988,7 @@ impl App { | |||
| 986 | 988 | Some(Some(Item::Gesture(row))) if self.touch_draft.as_ref().is_some_and(|d| d.row == row) => { | |
| 987 | 989 | self.touch_draft = None; | |
| 988 | 990 | } | |
| 989 | − | Some(Some(Item::Bass)) => self.bass_draft = None, | |
| 991 | + | Some(Some(Item::Bass(band))) => self.bass_draft.set(band, None), | |
| 990 | 992 | _ => {} | |
| 991 | 993 | } | |
| 992 | 994 | if kind == Kind::NotFound { | |
| @@ -1187,16 +1189,16 @@ impl App { | |||
| 1187 | 1189 | } | |
| 1188 | 1190 | } | |
| 1189 | 1191 | ||
| 1190 | − | /// Sets the bass level moved on the screen. If a level is on its way, this one goes out | |
| 1191 | − | /// when that is answered: the latest level wins. | |
| 1192 | − | fn save_bass(&mut self) { | |
| 1193 | − | let Some(level) = self.bass_draft else { | |
| 1192 | + | /// Sets a band's bass level moved on the screen. If a level for that band is on its way, | |
| 1193 | + | /// this one goes out when that is answered: the latest level wins. | |
| 1194 | + | fn save_bass(&mut self, band: BassBand) { | |
| 1195 | + | let Some(level) = self.bass_draft.get(band) else { | |
| 1194 | 1196 | return; | |
| 1195 | 1197 | }; | |
| 1196 | − | if self.buds.bass == Some(level) { | |
| 1197 | − | self.bass_draft = None; | |
| 1198 | − | } else if !self.pending().contains(&Item::Bass) { | |
| 1199 | − | self.write(Item::Bass, Command::Bass(level)); | |
| 1198 | + | if self.buds.bass.get(band) == Some(level) { | |
| 1199 | + | self.bass_draft.set(band, None); | |
| 1200 | + | } else if !self.pending().contains(&Item::Bass(band)) { | |
| 1201 | + | self.write(Item::Bass(band), Command::Bass(band, level)); | |
| 1200 | 1202 | } | |
| 1201 | 1203 | } | |
| 1202 | 1204 | ||
| @@ -1391,9 +1393,9 @@ impl App { | |||
| 1391 | 1393 | Item::Delete(id) => (action != Action::Left).then_some(Message::DeletePreset(id)), | |
| 1392 | 1394 | Item::Edit(id) => (action != Action::Left).then_some(Message::EditPreset(id)), | |
| 1393 | 1395 | Item::Done => (action != Action::Left).then_some(Message::DoneEditing), | |
| 1394 | − | Item::Bass => { | |
| 1396 | + | Item::Bass(band) => { | |
| 1395 | 1397 | let (low, high) = self.buds.bass_range?; | |
| 1396 | − | let now = self.bass_draft.or(self.buds.bass); | |
| 1398 | + | let now = self.bass_draft.get(band).or(self.buds.bass.get(band)); | |
| 1397 | 1399 | let to = match (action, now) { | |
| 1398 | 1400 | // Not set yet: the first step starts from the middle. | |
| 1399 | 1401 | (Action::Left | Action::Right, None) => 0, | |
| @@ -1401,7 +1403,11 @@ impl App { | |||
| 1401 | 1403 | (Action::Right, Some(level)) => level.checked_add(1)?.min(high), | |
| 1402 | 1404 | _ => return None, | |
| 1403 | 1405 | }; | |
| 1404 | − | (Some(to) != now).then_some(Message::Bass { level: to, save: true }) | |
| 1406 | + | (Some(to) != now).then_some(Message::Bass { | |
| 1407 | + | band, | |
| 1408 | + | level: to, | |
| 1409 | + | save: true, | |
| 1410 | + | }) | |
| 1405 | 1411 | } | |
| 1406 | 1412 | Item::Cycle(mode) => { | |
| 1407 | 1413 | let touch = self.buds.touch.as_ref()?; | |
| @@ -1919,7 +1925,9 @@ mod tests { | |||
| 1919 | 1925 | Item::Switch("game"), | |
| 1920 | 1926 | Item::Switch("spatial"), | |
| 1921 | 1927 | Item::Switch("bass-boost"), | |
| 1922 | − | Item::Bass, | |
| 1928 | + | Item::Bass(BassBand::Low), | |
| 1929 | + | Item::Bass(BassBand::Medium), | |
| 1930 | + | Item::Bass(BassBand::High), | |
| 1923 | 1931 | Item::Switch("wind"), | |
| 1924 | 1932 | Item::Switch("dual"), | |
| 1925 | 1933 | Item::Open(Page::Equalizer), | |
| @@ -2091,7 +2099,8 @@ mod tests { | |||
| 2091 | 2099 | assert_eq!(app.link, Link::Live); | |
| 2092 | 2100 | assert_eq!(app.buds.name, "realme Buds T310"); | |
| 2093 | 2101 | assert!(app.buds.connected); | |
| 2094 | − | assert_eq!(app.items().len(), 11); | |
| 2102 | + | // The noise control and its strength, five switches, three bass bands, three pages. | |
| 2103 | + | assert_eq!(app.items().len(), 13); | |
| 2095 | 2104 | } | |
| 2096 | 2105 | ||
| 2097 | 2106 | #[test] | |
| @@ -2338,17 +2347,33 @@ mod tests { | |||
| 2338 | 2347 | } | |
| 2339 | 2348 | ||
| 2340 | 2349 | #[test] | |
| 2341 | − | fn the_bass_level_by_key() { | |
| 2350 | + | fn the_bass_levels_by_key() { | |
| 2342 | 2351 | let mut app = app(); | |
| 2343 | 2352 | press(&mut app, "j j j j j j"); | |
| 2344 | − | assert_eq!(app.focus(), Some(Item::Bass), "under bass boost, on the Overview"); | |
| 2345 | − | assert_eq!(app.buds.bass, None, "the buds do not say"); | |
| 2353 | + | assert_eq!( | |
| 2354 | + | app.focus(), | |
| 2355 | + | Some(Item::Bass(BassBand::Low)), | |
| 2356 | + | "under bass boost, on the Overview" | |
| 2357 | + | ); | |
| 2358 | + | assert_eq!(app.buds.bass.get(BassBand::Low), None, "the buds do not say"); | |
| 2346 | 2359 | press(&mut app, "l"); | |
| 2347 | − | assert_eq!(app.buds.bass, Some(0), "the first step starts from the middle"); | |
| 2360 | + | assert_eq!( | |
| 2361 | + | app.buds.bass.get(BassBand::Low), | |
| 2362 | + | Some(0), | |
| 2363 | + | "the first step starts from the middle" | |
| 2364 | + | ); | |
| 2348 | 2365 | press(&mut app, "l l"); | |
| 2349 | − | assert_eq!(app.buds.bass, Some(2)); | |
| 2366 | + | assert_eq!(app.buds.bass.get(BassBand::Low), Some(2)); | |
| 2350 | 2367 | press(&mut app, "h h h h h h h h"); | |
| 2351 | − | assert_eq!(app.buds.bass, Some(-5), "it stops at the bottom"); | |
| 2368 | + | assert_eq!(app.buds.bass.get(BassBand::Low), Some(-5), "it stops at the bottom"); | |
| 2369 | + | press(&mut app, "j l"); | |
| 2370 | + | assert_eq!(app.focus(), Some(Item::Bass(BassBand::Medium))); | |
| 2371 | + | assert_eq!( | |
| 2372 | + | app.buds.bass.get(BassBand::Medium), | |
| 2373 | + | Some(0), | |
| 2374 | + | "each band has its own level" | |
| 2375 | + | ); | |
| 2376 | + | assert_eq!(app.buds.bass.get(BassBand::Low), Some(-5)); | |
| 2352 | 2377 | } | |
| 2353 | 2378 | ||
| 2354 | 2379 | #[test] | |
| @@ -2356,20 +2381,38 @@ mod tests { | |||
| 2356 | 2381 | let (mut app, commands) = live(); | |
| 2357 | 2382 | let _ = commands.try_recv(); | |
| 2358 | 2383 | let _ = app.update(Message::Worker(Event::Read(Box::new(reading())))); | |
| 2359 | − | let _ = app.update(Message::Bass { level: 2, save: false }); | |
| 2384 | + | let low = BassBand::Low; | |
| 2385 | + | let _ = app.update(Message::Bass { | |
| 2386 | + | band: low, | |
| 2387 | + | level: 2, | |
| 2388 | + | save: false, | |
| 2389 | + | }); | |
| 2360 | 2390 | assert!( | |
| 2361 | 2391 | commands.try_recv().is_err(), | |
| 2362 | 2392 | "a drag sends nothing until the mouse lets go" | |
| 2363 | 2393 | ); | |
| 2364 | − | let _ = app.update(Message::SaveBass); | |
| 2365 | − | assert!(matches!(commands.try_recv(), Ok(Command::Bass(2)))); | |
| 2366 | − | let _ = app.update(Message::Bass { level: 4, save: true }); | |
| 2394 | + | let _ = app.update(Message::SaveBass(low)); | |
| 2395 | + | assert!(matches!(commands.try_recv(), Ok(Command::Bass(BassBand::Low, 2)))); | |
| 2396 | + | let _ = app.update(Message::Bass { | |
| 2397 | + | band: low, | |
| 2398 | + | level: 4, | |
| 2399 | + | save: true, | |
| 2400 | + | }); | |
| 2367 | 2401 | assert!(commands.try_recv().is_err()); | |
| 2368 | − | let _ = app.update(Message::Worker(Event::Bass(2))); | |
| 2369 | − | assert!(matches!(commands.try_recv(), Ok(Command::Bass(4)))); | |
| 2370 | − | let _ = app.update(Message::Worker(Event::Bass(4))); | |
| 2371 | − | assert_eq!(app.buds.bass, Some(4)); | |
| 2372 | − | assert_eq!(app.bass_draft, None); | |
| 2402 | + | // Another band does not wait for this one. | |
| 2403 | + | let _ = app.update(Message::Bass { | |
| 2404 | + | band: BassBand::High, | |
| 2405 | + | level: -1, | |
| 2406 | + | save: true, | |
| 2407 | + | }); | |
| 2408 | + | assert!(matches!(commands.try_recv(), Ok(Command::Bass(BassBand::High, -1)))); | |
| 2409 | + | let _ = app.update(Message::Worker(Event::Bass(low, 2))); | |
| 2410 | + | assert!(matches!(commands.try_recv(), Ok(Command::Bass(BassBand::Low, 4)))); | |
| 2411 | + | let _ = app.update(Message::Worker(Event::Bass(BassBand::High, -1))); | |
| 2412 | + | let _ = app.update(Message::Worker(Event::Bass(low, 4))); | |
| 2413 | + | assert_eq!(app.buds.bass.get(low), Some(4)); | |
| 2414 | + | assert_eq!(app.buds.bass.get(BassBand::High), Some(-1)); | |
| 2415 | + | assert_eq!(app.bass_draft, BassLevels::default()); | |
| 2373 | 2416 | assert!(commands.try_recv().is_err()); | |
| 2374 | 2417 | } | |
| 2375 | 2418 | ||
| @@ -1,10 +1,10 @@ | |||
| 1 | 1 | //! The Overview page: battery, noise control, the switches, and the way to the other pages. | |
| 2 | 2 | ||
| 3 | − | use budctl_core::status::{AncLevel, Charge, NoiseMode, Switch}; | |
| 3 | + | use budctl_core::status::{AncLevel, BassBand, Charge, NoiseMode, Switch}; | |
| 4 | 4 | use iced::widget::{column, container, row}; | |
| 5 | 5 | use iced::{Alignment, Element, Length}; | |
| 6 | 6 | ||
| 7 | − | use crate::buds::Buds; | |
| 7 | + | use crate::buds::{BassLevels, Buds}; | |
| 8 | 8 | use crate::dots; | |
| 9 | 9 | use crate::equalizer; | |
| 10 | 10 | use crate::icons::{self, Icon}; | |
| @@ -24,25 +24,25 @@ pub fn items(buds: &Buds) -> Vec<Item> { | |||
| 24 | 24 | for switch in shown(&buds.status.switches) { | |
| 25 | 25 | items.push(Item::Switch(switch.key)); | |
| 26 | 26 | if switch.key == BASS_BOOST && buds.bass_range.is_some() { | |
| 27 | − | items.push(Item::Bass); | |
| 27 | + | items.extend(BassBand::ALL.map(Item::Bass)); | |
| 28 | 28 | } | |
| 29 | 29 | } | |
| 30 | 30 | items.extend(MORE.map(|(page, _, _)| Item::Open(page))); | |
| 31 | 31 | items | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | − | /// The switch the bass level shows under. | |
| 34 | + | /// The switch the bass levels show under. | |
| 35 | 35 | const BASS_BOOST: &str = "bass-boost"; | |
| 36 | 36 | ||
| 37 | 37 | /// Shown instead of an item's subtitle while a write to it is on its way. | |
| 38 | 38 | const SAVING: &str = "Saving…"; | |
| 39 | 39 | ||
| 40 | − | /// `bass`: a bass level moved on the screen and not set on the buds yet. | |
| 40 | + | /// `bass`: bass levels moved on the screen and not set on the buds yet. | |
| 41 | 41 | pub fn view<'a>( | |
| 42 | 42 | look: Look, | |
| 43 | 43 | buds: &'a Buds, | |
| 44 | 44 | anc_level: AncLevel, | |
| 45 | − | bass: Option<i8>, | |
| 45 | + | bass: BassLevels, | |
| 46 | 46 | marks: Marks, | |
| 47 | 47 | ) -> Element<'a, Message> { | |
| 48 | 48 | column![ | |
| @@ -155,7 +155,7 @@ fn shown(switches: &[Switch]) -> Vec<&Switch> { | |||
| 155 | 155 | shown | |
| 156 | 156 | } | |
| 157 | 157 | ||
| 158 | − | fn switches<'a>(look: Look, buds: &Buds, bass: Option<i8>, marks: Marks) -> Element<'a, Message> { | |
| 158 | + | fn switches<'a>(look: Look, buds: &Buds, bass: BassLevels, marks: Marks) -> Element<'a, Message> { | |
| 159 | 159 | let known = |key: &str| SWITCHES.iter().find(|(k, _, _)| *k == key); | |
| 160 | 160 | let mut rows = Vec::new(); | |
| 161 | 161 | for s in shown(&buds.status.switches) { | |
| @@ -174,20 +174,34 @@ fn switches<'a>(look: Look, buds: &Buds, bass: Option<i8>, marks: Marks) -> Elem | |||
| 174 | 174 | ) | |
| 175 | 175 | }); | |
| 176 | 176 | if let (BASS_BOOST, Some(range)) = (s.key, buds.bass_range) { | |
| 177 | − | rows.push(bass_level(look, range, bass.or(buds.bass), marks)); | |
| 177 | + | for band in BassBand::ALL { | |
| 178 | + | let level = bass.get(band).or(buds.bass.get(band)); | |
| 179 | + | rows.push(bass_level(look, range, band, level, marks)); | |
| 180 | + | } | |
| 178 | 181 | } | |
| 179 | 182 | } | |
| 180 | 183 | ui::card(look, rows) | |
| 181 | 184 | } | |
| 182 | 185 | ||
| 183 | − | /// The bass level: the buds take it but do not report it, so the row shows the last level set | |
| 184 | − | /// from this window, if any. | |
| 185 | − | fn bass_level<'a>(look: Look, (low, high): (i8, i8), level: Option<i8>, marks: Marks) -> Element<'a, Message> { | |
| 186 | + | /// One band of bass boost: the buds take its level but do not report it, so the row shows the last | |
| 187 | + | /// level set from this window, if any. | |
| 188 | + | fn bass_level<'a>( | |
| 189 | + | look: Look, | |
| 190 | + | (low, high): (i8, i8), | |
| 191 | + | band: BassBand, | |
| 192 | + | level: Option<i8>, | |
| 193 | + | marks: Marks, | |
| 194 | + | ) -> Element<'a, Message> { | |
| 186 | 195 | let p = look.p; | |
| 187 | − | let about = if marks.pending.contains(&Item::Bass) { | |
| 196 | + | let about = if marks.pending.contains(&Item::Bass(band)) { | |
| 188 | 197 | SAVING | |
| 189 | 198 | } else { | |
| 190 | − | "Last set here: the buds do not report it" | |
| 199 | + | "Bass boost, last set here" | |
| 200 | + | }; | |
| 201 | + | let title = match band { | |
| 202 | + | BassBand::Low => "Low frequencies", | |
| 203 | + | BassBand::Medium => "Medium frequencies", | |
| 204 | + | BassBand::High => "High frequencies", | |
| 191 | 205 | }; | |
| 192 | 206 | let shown = level.map_or_else(|| "not set".to_owned(), equalizer::gain); | |
| 193 | 207 | let slider = dots::Slider { | |
| @@ -195,11 +209,12 @@ fn bass_level<'a>(look: Look, (low, high): (i8, i8), level: Option<i8>, marks: M | |||
| 195 | 209 | high: i16::from(high), | |
| 196 | 210 | value: i16::from(level.unwrap_or(0)), | |
| 197 | 211 | vertical: false, | |
| 198 | − | change: |level, save| Message::Bass { | |
| 212 | + | change: move |level, save| Message::Bass { | |
| 213 | + | band, | |
| 199 | 214 | level: i8::try_from(level).unwrap_or(0), | |
| 200 | 215 | save, | |
| 201 | 216 | }, | |
| 202 | − | release: Message::SaveBass, | |
| 217 | + | release: Message::SaveBass(band), | |
| 203 | 218 | on: p.fill, | |
| 204 | 219 | off: p.s3, | |
| 205 | 220 | motion: look.motion, | |
| @@ -210,11 +225,11 @@ fn bass_level<'a>(look: Look, (low, high): (i8, i8), level: Option<i8>, marks: M | |||
| 210 | 225 | ui::setting( | |
| 211 | 226 | look, | |
| 212 | 227 | Icon::Bass, | |
| 213 | − | "Bass level", | |
| 228 | + | title, | |
| 214 | 229 | Some(about.to_owned()), | |
| 215 | 230 | control.into(), | |
| 216 | 231 | None, | |
| 217 | − | marks.focus == Some(Item::Bass), | |
| 232 | + | marks.focus == Some(Item::Bass(band)), | |
| 218 | 233 | ) | |
| 219 | 234 | } | |
| 220 | 235 | ||
| @@ -455,7 +455,7 @@ Evidence: *vendor* | |||
| 455 | 455 | - **What budctl does:** `budctl bass <level>` refuses a level outside -5..+5 before sending anything. | |
| 456 | 456 | - **Expected frame** (*derived*), for `bass 3` with seq `05`: `AA 0A 00 00 1B 04 05 03 00 FB 05 03`. | |
| 457 | 457 | - **On the owner's T310** (*ours-2026-10-27*, W6b): `FB 05 FB` (-5), `FB 05 05` (+5) and `FB 05 00` (0) were each acked with a plain `0x841B 00` within 35 ms. Unlike the gesture write, the ack carries no data. Bass boost was off. By ear the owner heard at most a slight difference between -5 and +5. Whether the level does more with bass boost on is untested. | |
| 458 | − | - <a id="bass-bands"></a>**The band byte** (*vendor*). On realme buds whose bass engine is "dynamic sound", the vendor app shows bass boost as the switch `1D` and three sliders: low, medium and high frequencies. Each slider writes its own level, with a band byte after it: | |
| 458 | + | - <a id="bass-bands"></a>**The band byte** (*vendor*; the low band *ours-2026-11-02*, W6c). On realme buds whose bass engine is "dynamic sound", the vendor app shows bass boost as the switch `1D` and three sliders: low, medium and high frequencies. Each slider writes its own level, with a band byte after it: | |
| 459 | 459 | ||
| 460 | 460 | | Payload | Band | | |
| 461 | 461 | |---|---| | |
| @@ -463,7 +463,7 @@ Evidence: *vendor* | |||
| 463 | 463 | | `FB 05 <level s8> 02` | medium frequencies | | |
| 464 | 464 | | `FB 05 <level s8> 03` | high frequencies | | |
| 465 | 465 | ||
| 466 | − | The T310 is one of these models. Such buds ignore a write without the band byte, yet still answer `00`. That fits W6b: the band-less writes were acked, and changed nothing clear by ear. The owner also hears no difference with bass boost on (2026-11-02), which fits three bands still at their default level. `budctl bass <band> <level>` sends the band byte, for checkpoint W6c ([roadmap](#roadmap)). The desktop and Android apps keep the band-less write until W6c passes, then move to three sliders. | |
| 466 | + | The T310 is one of these models. Such buds ignore a write without the band byte, yet still answer `00`. That fits W6b: the band-less writes were acked, and changed nothing clear by ear. The owner also hears no difference with bass boost on (2026-11-02), which fits three bands still at their default level. budctl sends the band byte: `budctl bass <band> <level>`, and one slider per band in the desktop and Android apps. **On the owner's T310** (*ours-2026-11-02*, W6c): with bass boost on, the low band at +5 and at -5 sound definitely different. The band-less write is not sent any more. | |
| 467 | 467 | - **Expected frame with the band** (*derived*), for `bass low 3` with seq `05`: `AA 0B 00 00 1B 04 05 04 00 FB 05 03 01`. | |
| 468 | 468 | ||
| 469 | 469 | <a id="find"></a> | |
| @@ -628,6 +628,7 @@ The owner will not install realme Link (2026-10-27), so there is no app capture. | |||
| 628 | 628 | ||
| 629 | 629 | | Date | Checkpoint | Result | Notes | | |
| 630 | 630 | |---|---|---|---| | |
| 631 | + | | 2026-11-02 | W6c `set bass-boost on`, then `bass low 5`, `bass low -5`, `bass low 5` | pass | Each write went out byte for byte as the derived frame (`AA 0B 00 00 1B 04 05 04 00 FB 05 05 01` for +5, `FB` in the level byte for -5) and was acked with `0x841B 00` within 20-36 ms. The owner heard a definite difference between +5 and -5, where W6b's band-less writes changed nothing clear. The medium and high bands (`02`, `03`) were not tried by ear. See [§10.4](#bass-bands) | | |
| 631 | 632 | | 2026-11-02 | A4 Android app (v0.1.4) and `budctl status` on the laptop | pass (owner's report) | `budctl status` on the laptop works while the Android app is open and connected, not only once the app has let go: with dual connection on, the T310 gives each host a control link of its own, as with a phone app on 2026-10-27. No log was taken of this run | | |
| 632 | 633 | | 2026-11-01 | A3 Android app, Gestures and Earbud settings pages | pass | Right double tap set to `none` and back to `play-pause`, each written (`0x0401`) and read back in about 1 s. Six hold-cycle writes (`0x0404 02 01`), each acked and read back in 0.25-1.8 s, ending at off → anc as before. Ring and Stop (`0x0400 01`/`00`) acked; the buds rang. The devices list showed. The owner confirmed it all on screen | | |
| 633 | 634 | | 2026-11-01 | A3 Android app, Equalizer page: built-in presets, the custom one, its bands, a new preset, a delete | pass | Each built-in preset (`0x0406`) and the custom one were put in use and read back in 0.25-0.45 s. Fifteen band saves on `custom-4` were each acked and read back in 0.4-0.7 s; bands moved while a save was on its way went out as soon as it was answered, the latest winning. "New preset" created `custom-5` and opened its bands, five saves went to it, and the bin deleted it. Rename was not tried on the phone | | |
[Binary file]
[Binary file]
[Binary file]